架构设计之评论系统
功能模块
架构设计最重要的就是理解整个产品体系在系统中的定位。搞清楚系统背后的背景,才能做出最佳的设计和抽象。不要做需求的翻译机,先理解业务背后的本质,事情的初衷。
评论系统,往小里做,就是视频评论系统,往大做就是评论平台,可以接入各种业务形态。
评论的主要功能:
- 发布评论:支持回复楼层、楼中楼。(一般会做两层效果较好,但是设计上可以支持无限嵌套)
- 读取评论:按照时间、热度排序
- 删除评论:用户删除、作者删除
- 管理评论:作者置顶、后台运营管理(搜索、删除、审核等)
在动手设计前,反复思考,真正编码的时间只有5%。
架构设计
在做架构设计之前,可以先将大的模块设计出来,并且处理好流量方向。
BFF:
comment
复杂评论业务的服务编排,比如访问账号服务进行等级判定(糅杂一些业务规则,例如
account-service
,filter-service
),同时需要在 BFF 面向 移动端/WEB 场景来设计 API,这一层抽象把评论的本身的内容列表处理(加载、分页、排序等)进行了隔离,关注在业务平台化逻辑上。这一层专注于处理平台业务逻辑。Service:
comment-service
服务层,去平台业务的逻辑,专注在评论功能的 API 实现上,比如发布、读取、删除等,关注在稳定性、可用性上,这样让上游可以灵活组织逻辑,把基础能力和业务能力剥离。这一层专注于处理数据本身。
Job:
comment-job
消息队列的最大用途是削峰处理。当写入请求非常大的时候,通过异步消息队列处理写请求。
Admin:
comment-admin
管理平台,按照安全等级划分服务,尤其划分运营平台,他们会共享服务层的存储层(MySQL、Redis)。运营体系的数据大量都是检索,我们使用
Canal
进行同步到ES
中,整个业务的展示都是通过ES
,再通过业务主键更新业务数据层,这样运营端的查询压力就下放给了独立的fulltext search
系统。(运营后台搜索、统计等,这种业务不适合使用OLTP
,使用 ES 更加适合。)Dependency:
account-service
、filter-service
整个评论服务还会依赖一些外部 gRPC 服务,统一的平台业务逻辑在
comment
BFF 层收敛,这里account-service
主要是账号服务,filter-service
是忙按此过滤服务。
架构设计等同于数据设计,梳理清楚数据的走向和逻辑。尽量避免环形依赖、数据双向请求等。
comment-service
评论服务,专注在评论数据处理。(将 DDD 思想分隔开,作为独立的服务。Separation of Concerns)
最开始的时候,是将 comment-service
和 comment
是一层,业务耦合和功能耦合在一起,非常不利于迭代。当然,在设计层面可以考虑目录结构进行拆分,但是从架构层次来说,迭代隔离是更好的。将两个服务拆分开,注重不同的核心点,BFF 关注业务逻辑,comment-service
关注数据读写处理。
读的核心逻辑
Cache-Aside
模式,先读取缓存,再读取存储。早期
cache rebuild
是做到服务里的,对于重建逻辑,一般会使用read ahead
的思路。即预读,用户访问了第一页,很有可能访问第二页,所以缓存会超前加载,避免频繁cache miss
。当缓存抖动,特别容易引起集群
thundering herd
现象(线程惊群效应),大量的请求会触发cache rebuild
,大量往MySQL
获取数据,并且回填到Redis
中。因为使用了预加载,容易导致服务OOM
。所以再到回源的逻辑里,改为使用消息队列来进行逻辑异步化,对于当前请求只返回
MySQL
中部分数据即止,然后发送异步消息,处理cache miss
的数据和预读的数据到Redis
。写的核心逻辑
如果发生类似“明星出轨”等热点事件的发生,而且写和读相比较,写可以认为是透穿到存储层的,系统的瓶颈往往就来自于存储层,或者有状态层。
对于写的设计上,我们认为刚发布的评论有极短的延迟(通常小于几ms),对用户可见是可接受的,把对存储的直接冲击下放到消息队列,按照消息反压的思路,即如果存储
latency
升高,消费能力就下降,自然消息容易堆积,系统始终以最大化方式消费。Kafka
存在partition
概念,可以认为是物理上的一个小队列,一个topic
是由一组partition
组成的,所以Kafka
的吞吐模型理解为:全局并行,局部串行的生产消费方式。对于入队的消息,可以按照hash(comment_subject) % N(partitions)
的方式进行分发,那么某个partition
中的评论主题的数据一定都在一起,这样方便串行消费。同样的,处理回源消息也是类似的思路。
comment-admin
运营后台的读写能力。
MySQL binlog
中的数据被 canal
中间件流式消费,获取到业务的原始 CRUD
操作,需要回放录入到 ES
中,但是 ES
中的数据最终是面向运营体系提供服务能力,需要检索的数据维度比较多,在入 ES
前需要做一个异构的 joiner
,把单表变宽预处理好 join
逻辑,然后倒入到 ES
中。
一般来说,运营后台的检索条件都是组合的,使用 ES
的好处是避免依赖 MySQL
来做多条件组合检索,同时 MySQL
毕竟是 OLTP
面向线上联机事务处理的。通过冗余数据的方式,使用其他引擎来实现。
ES
一般会存储检索、展示、primary key
等数据,当操作编辑的时候,找到记录的 primary key
,最后交由 comment-admin
进行运营测的 CRUD
操作。
内部运营体系,基于ES
来完成更加有优势。
comment
comment
作为 BFF
,是面向端,面向平台,面向业务组合的服务。所以平台扩展的能力,都在 comment
服务来实现,方便统一和准入平台,以统一的接口形式提供平台化的能力。
- 依赖其他
gRPC
服务,整合统一平台测的逻辑(比如发布评论用户等级限定) - 直接向端上提供接口,提供数据的读写接口,甚至可以整合端上,提供统一的端上 SDK
- 需要对非核心依赖的
gRPC
服务进行降级,当这些服务不稳定时。
存储设计
也就是数据库设计。
comment_subject
:主题表(例如一个资源、视频、文稿等)- 要适配多种资源类型,通过
obj_id
和obj_type
匹配各种资源类型 member_id
记录作者用户 id,这个id不会变化,有利于查询count
、root_count
、all_count
分别代表评论总数,根评论总数,评论+回复总数,可更好的做展示,而不需要每次都count(*)
stat
状态记录这个资源的状态attrs
属性,记录资源属性,而且通过二进制,代表多个属性
表都应该带有
create_time
和update_time
,而且一定要有主键,写入的时候使用顺序写,避免出现页分裂。为了避免单表过大,通过
hash
分割表。尽量使用软删除,或者手动设置是否隐藏字段。
- 要适配多种资源类型,通过
comment_index
:索引表- 记录评论的索引
- 同样记录对应的主题,方便后续查询
- 通过
root
和parent
记录是否是根评论以及子评论的上级 floor
记录评论层级,也需要更新主题表中的楼层数,
comment_content
:评论内容表记录核心评论的内容,避免检索的时候内容过多导致效率低。
数据写入:事务更新 comment_subject
,comment_index
,comment_content
三张表。content
属于非强制需要一致性考虑的。可以先写入 content
,之后事务更新其他表。即便 content
先成功,后续失败仅仅存在一条 ghost
数据。
数据读取:基于 obj_id
+ obj_type
在 comment_index
表找到评论列表, WHERE root = 0 ORDER BY floor
。之后根据 comment_index
的 id
字段获取出 comment_content
的评论内容。对于二级的子楼层, WHERE parent/root IN (id...)
。
因为产品形态上只存在二级列表,因此只需要迭代查询两次即可。对于嵌套层次多的,产品上,可以通过二次点击支持。
可以看到,上面的存储结构比较简单,而且在这种读多写少的场景,有其他的数据库也支持。例如 ES 也可以,不分表。
Graph 存储也可以, DGraph、HugeGraph 类似的图存储思路,不分表,内部可以实现数据补全。
索引内容分离
用于检索的索引,和评论的具体内容分离成两张表。
comment_index
:评论楼层的索引组织表,实际并不包含内容。comment_content
:评论内容表,包含评论的具体内容。其中 comment_index
的 id
字段和 comment_content
是 1对1 的关系,这里面包含几种设计思想:
- 表都有主键,即
cluster index
,是物理组织形式存放的,comment_content
没有id
,是为了减少一次二级索引查找(回表),直接基于主键检索,同时comment_id
在写入要尽可能的顺序自增。 - 索引、内容分离,方便
mysql datapage
缓存更多的row
,如果和content
耦合,会导致更大的IO
,长远来看content
信息可以直接使用KV storage
存储。
缓存设计
comment_subject_cache
:对应主题的缓存,value
使用 protobuf
序列化的方式存入。现在使用 memcache
来进行缓存,或者使用 redis
这种KV
键值对存储都差不多。
comment_index_cache
:使用 redis sortedset
进行索引的缓存,索引即数据的组织顺序,而非数据内容。
参考百度贴吧,他们使用自己研发的拉链存储来组织索引。
使用mysql
作为主力存储,利用 redis
来做加速完全足够,因为 cache miss
的构建,使用 kafka
的消费者中处理,预加载少量数据,通过增量加载的方式主键预热填充缓存,而 redis sortedset skiplist
的实现,可以做到 O(logN) + O(M)
的时间复杂度,效率很高。
过期时间可以通过内存大小调整。
sorted set
是要增量追加的,因此必须判定 key
存在,才能 zadd
comment_content_cache
:对应评论内存数据,使用 protobuf
序列化的方式存入。类似的早期使用 memcache
进行缓存,或者使用 redis
存储。
增量加载(将数据库中的数据批量放到 redis
中,而且先续约时间,然后再加缓存。) + lazy 加载。(所以没有分页,都是使用懒加载,使用 mget
批量获取。)
还可以使用预加载,获取第一页时,将第二页的数据加入到缓存中。
可用性设计
Singlefilght
对于热门的主题,如果存在缓存穿透(缓存中没有数据,请求穿透了缓存,直接打到数据库)的情况,会导致大量的同进程、跨进程的数据回源到存储层,可能会引起存储过载的情况,如何只交给同进程内,一个人去做加载存储。
使用归并回源的思路:
singleflight
同进程只交给一个人去获取 mysql
数据,然后批量返回。同时这个 lease owner
投递一个 kafka
消息,做 index cache
的 recovery
操作。这样可以大大减少 mysql
的压力,以及大量穿透导致的密集写 kafka
的问题。
更进一步的,后续连续的请求,仍然可能会短时 cache miss
,我们可以在进程内设置了一个 short-lived flag
,标记最近有一个人投递了 cache rebuild
的消息,直接 drop
。
可以看到,这里说明的都是单进程下的解决思路。那么在多进程下,能否使用分布式锁来解决。理论上可以,但是实际操作起来,容易将这个简单问题复杂化,不推荐使用分布式锁。(PS:redis
作者不推荐使用 redis
实现分布式锁。)
多进程下,也是一样的思想,多个进程会发送多个消息到消息队列中,消费端获取消息的时候,通过单飞的思路,同样处理。
热点
热点分为写热点和读热点。
写操作一般会通过MQ削峰,当大量的请求都集中在 MQ
中,不仅仅会影响当前服务,还可能导致下游服务出现异常。这种情况下,可以再进行解耦,增加上游服务的吞吐,将下游服务解耦,不依赖同一个同步逻辑。
流量热点是因为突然热门的主题,被高频次的访问,因为底层的 cache
设计,一般是按照主题 key
进行一致性 hash
来进行分片,但是热点 key
一定命中某一个节点,这时 remote cache
可能会变成瓶颈。因此做 cache
升级 local cache
是有必要的,一般使用单进程自适应发现热点的思路,附加一个短时的 ttl local cache
,可以在进程内吞掉大量的读请求。
在内存中使用 hashmap
统计每个 key
的访问频次,这里可以使用滑动窗口(左角标和右角标一起移动,统计区间内部的数据量)统计,即每个窗口中,维护一个 hashmap
,之后统计所有未过去的 bucket
,汇总所有 key
的数据。
之后使用小堆计算 TopK
的数据,自动进行热点识别。
数据库层级结构设计
很多时候会碰到需要设计带有层级结构的数据,例如部门和成员、树级结构、评论、聊天等,一般会有要求检索某个节点下的子节点。
这种情况下,可以有根节点、叶子节点、中间节点。
邻接表 Adjacency List
通过 parent_id 记录层级关系
缺点:
- 难以查询全部子节点
- 难以查询特定层的节点
- 难以查询聚合函数
分段式 Path
通过
/a/b/c
的形式(或者使用#
分隔符_
)缺点:
- 查找主要依赖于
LIKE
查询 - 无法保证 PATH 正确
- Path 依赖于应用的字符串处理
- 查找主要依赖于
Nested Set
记录左节点和右节点。
nsleft 小于所有子节点的 nsleft
nlright 大于所有子节点的 nright
本质上是一个深度优先遍历的顺序
缺点:
- 查找父亲非常困难:c 结点的父亲节 点是祖先节点里面,没有别的祖先节 点作为其子节点的节点
- 插入非常困难:需要重新计算节点的 nsleft 和 nsright
Closure Table
用单独的表来存储节点之间的关系
对比
Resursive Query 直接依赖于数据库特性。
缓存模式 cache pattern
cache aside
把 cache 当成一个普通的数据源
更新 cache 和 DB 都是依赖于开发者自己写代码
cache 读不到,就去读取数据库,需要注意穿透,也需要使用 singlefilght
先写数据库,然后删除原本的 cache(这是数据不一致间隔最短的一种方式)
read through
从 cache 中读取数据,cache 会在缓存不命中的时候读取数据
更新数据的时候也直接更新DB,等待 Cache 过期,也可以更新 DB 之后同步更新 Cache
write through
只需要写入 cache ,由 cache 更新数据库
在读未命中缓存的情况下,需要去数据库捞数据,然后更新 cache
refresh-ahead
依赖于 CDC (changed data capture)接口:
- 数据库暴露数据变更接口
- cache 或者第四方在监听到数据变更之后自动更新数据。
总结对比:
推荐阅读
Scaling Memcache at Facebook
Scaling Memcache at Facebook (2013) 中文
SQL Antipatterns :Chapter 3: Naive Trees
A Hitchhiker’s Guide to Caching Patterns
一文吃透 Go 语言解密之接口 interface