高性能系统设计:互联网点赞系统设计及实践
1 什么是点赞系统
点赞是互联网中常见的交互方式,系统根据用户的点赞操作来跟踪用户的行为,并对用户的喜好进行分析。
点赞,在互联网中是一个比较简单的操作。用户看到自己喜欢的信息,点击“赞”按钮,点亮“赞”操作,再次点击,取消之前的“赞”操作。是不是很 easy?
但,一个明星微博的点赞数可能高达几十万,甚至上百万。一个热点新闻,可能有数千、数万、甚至数十万用户同时点赞,如何处理这些极端情况呢?
2 系统设计要点
如果要构建一套通用点赞系统,首先需要对系统所涉及角色、主功能进行梳理。
2.1 系统角色
系统角色主要涉及 点赞发起者 和 点赞目标对象。
这两个角色,本质上是对其他对象的一种引用。从领域设计角度,应该是对其他限界上下文中聚合的引用。两个角色,本身没有唯一标识,并根据属性确定其相等性。因此,符合值对象建模规范,应该作为值对象处理。
通常情况下,点赞发起者对应系统的用户(有的系统会有多个用户系统)。但,点赞的目标对象可能会有很多,如新闻、评论、帖子等等。
系统角色如下:
角色 | 含义 | 建模方式 |
---|---|---|
Owner | 点赞发起者 | 值对象 |
Target | 点赞目标对象 | 值对象 |
2.2 系统功能用例
系统功能,主要围绕系统角色展开。
点赞发起者 Owner,可以选择一个点赞对象 Target 进行点击操作。当显示目标对象 Target 时,需要判断该 Target 是否已经点过赞。
对于点赞对象 Target,只有一个应用场景,就是在显示时,获取总的点赞数量。
详细用例如下:
用例 | 含义 |
---|---|
点击 | Owner 对特定 Target 进行点击。如果没有点赞,则点赞;如果已经点赞,则取消点赞 |
是否点赞 | 判断特定 Owner 对 特定 Target 是否已经点赞 |
获取点赞数量 | 获取特定 Target 总的点赞数量 |
2.3 赞功能设计
点赞发起者点击“赞”按钮,点亮“赞”操作,再次点击,取消之前的“赞”操作。
2.3.1 识别建模类型
Like 可以接收点击操作,并更新内部状态。同一个点赞发起者对同一个点赞目标进行“赞”和“取消”操作时,针对的应该是同一个 “Like” 实例,需要唯一标识对操作进行跟踪。综上分析 “Like” 是一个实体。
2.3.2 实体建模
首先,需要明确实体的名称,在这里,我们简单命名为 Like。
行为建模
分析下 Like 的业务行为,Like 对外操作只提供一个 click 方法,当触发 click 操作时,Like 在 Submitted 和 Cancelled 之间进行切换。当 Like 状态发生变化时,需要发布对应的内部事件。
Like 所涉及的业务方法如下:
业务方法 | 含义 | 事件 | 业务规则 |
---|---|---|---|
click | 用户点击行为 | 无 | 无 |
submit | 点赞 | LikeSubmittedEvent | 当用户未点赞时触发 |
cancel | 取消点赞 | LikeCancelledEvent | 当用户已经点赞时触发 |
为了避免 Like 的臃肿,我们将 Like 的状态进行单独建模。构建一个单独的值对象,并将状态相关的操作下推到该值对象中。我们称为 LikeStatus。
属性建模
Like 所关联的对象,包括 Target、Owner 和 LikeStatus,并且三者都是值对象。
属性 | 类型 | 含义 |
---|---|---|
owner | Owner | 点赞发起者 |
target | Target | 点赞目标对象 |
status | LikeStatus | 点赞状态 |
创建方式建模
Like 的创建方式比较简单,没有太复杂的业务逻辑,因此,采用静态方法对其进行创建。
2.3.3 小结
Like 是一个比较简单的聚合,具体结构如下:
赞功能所涉及的对象见下表。
对象 | 含义 | 建模方式 |
---|---|---|
Like | 赞 | 实体&聚合根 |
LikeStatus | 赞状态 | 值对象 |
LikeSubmittedEvent | 点赞事件 | 内部领域事件 |
LikeCancelledEvent | 取消赞事件 | 内部领域事件 |
2.4 日志功能设计
Like 代表的是当前点赞状态,对于多次点击,只会记录最后的结果,而中间的过程数据丢失了。
日志,本身不属于业务功能,但对用户行为分析非常重要,我们应该将用户的所有操作保存下来。我们称这些过程数据为 LikeLogger。
2.4.1 识别建模类型
日志主要用于记录谁(Owner)对什么(Target)进行哪个操作(Action),在创建后就不在改变。基本符合值对象建模条件,但,我们如何对其进行持久化呢?
一般情况下,值对象的持久化依赖于包含它的实体,值对象会随着实体的持久化而持久化。但,Logger 是个整体概念,本身不属于任何实体。在这种情况下,我们可以将其建模成一个不变实体,一来借助实体进行持久化,二来避免对实体的修改。
2.4.2 不变实体建模
LikeLogger 为不变实体,内部所包含的属性,不允许修改。
属性建模
LikeLogger 所包含属性如下:
属性 | 类型 | 含义 |
---|---|---|
owner | Owner | 点赞发起者 |
target | Target | 点赞目标对象 |
actionType | ActionType | 操作类型 |
创建方式建模
LikeLogger 支持 Like 和 Cancel 两种类型的日志,可以根据 ActionType 构建静态方法,以完成各自的创建。
方法 | 含义 |
---|---|
createLikeAction | 创建点赞日志 |
createCancelAction | 创建取消点赞日志 |
2.4.3 小结
LikeLogger 所涉及对象包括:
对象 | 含义 | 建模方式 |
---|---|---|
LikeLogger | 赞日志 | 不变实体 |
ActionType | 操作类型 | 值对象 |
2.5 计数功能设计
最简单的计数功能,便是通过 SQL 对 Like 进行 “count group by” 来完成,但在高并发系统中,group by 是一大忌讳。
从单一职责原则角度,Like 承载了过多的责任,将统计功能强加到服务于业务的 Like 也非常不合适。因此,我们对计数功能进行独立的业务建模。 我们称为 TargetCount。
2.5.1 识别建模类型
TargetCount 需要根据点赞和取消点赞对计数进行增减操作。对于同一个 Target,需要持续跟踪其数量变化。可见,TargetCount 是一个实体。
2.5.2 实体建模
行为建模
TargetCount 的操作,主要有 incr 和 decr 两个业务操作。在进行 count 更新时,存在一个业务规则,及 count 不能小于零。
业务方法 | 含义 | 事件 | 业务规则 |
---|---|---|---|
incr | 增加点赞数 | 无 | 无 |
decr | 减少点赞数 | 无 | count 不能小于零 |
属性建模
TargetCount 的属性包括:
属性 | 类型 | 含义 |
---|---|---|
target | Target | 点赞目标对象 |
count | Long | 总的点赞数 |
创建方式建模
TargetCount 的创建方式比较简单,因此,采用静态方法对其进行创建。
2.5.3 小结
计数功能所涉及对象包括:
对象 | 含义 | 建模方式 |
---|---|---|
TargetCount | 点赞目标计数 | 实体 |
2.6 用例走查
用例走查,主要从用例角度,验证当前设计是否满足业务需要。
用例 | 支持方式 |
---|---|
点击 | 由 Like 聚合的 click 方法进行支持 |
是否点赞 | 由 Like 聚合的 LikeStatus 进行支持 |
获取点赞数量 | 由 TargetCount 的计数进行支持 |
LikeLogger 不直接服务于业务,仍旧有很大意义。
2.7 架构设计
到现在,整个系统的核心组件就设计完成了,接下来,我们需要将其组装起来,以形成一个可用系统。
这设计架构前,有几个非功能性需求需要考虑。
- 点击行为的高并发
- 获取计数的高并发
- Like 与 Logger、 Count 的数据一致性
2.7.1 点击行为的高并发
点击行为是典型的写操作,需要对写操作进行优化。
对于写操作优化,常见的策略包括:
- 数据散列。也就是我们常说的分库分表,将写操作分散到多个数据库实例中,从而提升系统的整体吞吐。
- 先入队列,后台消费。将用户请求添加到队列,启动后台线程,从队列中获取请求,并挨个消费。这种策略的最大特点就是可以起到消峰的作用,将瞬间巨大的请求缓存起来,不会对后台服务造成很大冲击。
对于一致性要求高的业务场景(比如支付),数据散列是唯一解决方案;对于一致性要求不高的业务场景(比如咱们的点赞系统),队列方案是最佳解决方案。
在此,我们使用队列方案来应对点击行为的高并发。即用户提交点击请求并不会直接调用业务方法,而是将请求放入消息队列;后台消费线程从消息队列中获取请求,并调用业务方法执行业务逻辑。
2.7.2 获取计数的高并发
获取计数是典型的读操作,需要对读操作进行优化。
对读操作的优化,主要是使用缓存进行访问加速。我们使用 Redis 来加速访问。
具体的操作如下:
- 首先从 Redis 中获取计数信息,如果命中,直接返回
- 如果 Redis 未命中,从数据库中获取计数,将结果添加到 Redis 中,然后返回
- 当计数发生变化时,清理 Redis 的过期数据
2.7.3 Like 与 Logger、 Count 的数据一致性
在系统中 Like、Logger、Count 是三个聚合根,我们需要保证三者的数据一致性。
系统的操作入口只有 Like 聚合,当 Like 发生变化时,Logger 和 Count 都需要跟着联动起来。Like 与 Count、Logger 具有很强的因果关系,这也是领域事件建模的重要信号。
在常规操作中,我们会在操作 Like 后,直接调用 Logger、Count 相关接口进行业务操作。但在 DDD 中,是绝对不允许的,一个操作只能对一个聚合根进行处理,聚合根之间的同步只能基于事件通过最终一致性解决。
我们可以基于内存总线和内部事件,通过订阅 Like 相关事件在内存中完成与 Logger、Count 的业务同步;也可以使用专用消息队列和外部事件,完成多个系统间的数据同步。
考虑到系统读写的扩展性,在此,我们使用消息队列和外部事件完成数据一致性保障。
Like 在执行完业务操作后,将内部领域事件直接发布到内存总线(EventBus),Exporter 组件从内存总线中获取领域事件,将其转换为外部事件,并发送到消息队列中。Consumer 组件负责从消息队列中获取事件,调用 Logger、Count 相关业务接口以完成业务操作。
2.7.4 小结
综上分析,我们的最终架构如下: