评论系统和可用性设计
评论系统和可用性设计
转载自stormspirit,原文链接:评论系统和可用性设计
存储设计
数据表设计
先看一张 b 站的评论图:

如上图所示,主评论下面有子评论,子评论之间也可以互相评论,并且评论之间是通过赞数进行排序的。
数据表设计:

如上图,评论系统分为三张表。
comment_subject
评论主题表。一个主题可能是一个视频稿件、一篇文章等。分成了 50 张表。自增 id 做主键。
obj_id
是主题 id, obj_type
是主题类型。
另外还保存了一些计数字段,比如评论总数等。这样做是为了避免每次需要统计总数时都要做一次 select count(*)
,这样效率很低,所以直接每次新增评论的时候就把相应字段 +1 即可。
comment_index
评论索引表。主要存的是评论的 id 与评论主题的对应关系,以及该评论的一些相关信息,比如是不是根评论、评论楼层,评论总数等。分成了 200 张表。使用自增 id 做主键,主键 id 就是评论 id。
root 是该评论的根评论 id,比如上面评论图红框里的就是根评论。不为 0 就是回复的评论,为 0 就是根评论。
parent 是这个评论的父评论,也就是它是回复哪条评论的,如果为 0 那这个评论就是根评论。
其他的都是一些统计信息等等。
comment_content
评论内容表。主要存的是评论实际内容。分成了 200 张表。
它直接使用 comment_id
(对应的就是 comment_index 表的 id )作为主键,这样的好处是:
表都有主键,即 cluster index,是物理组织形式存放的,comment_content 没有 id,是为了减少一次二级索引查找,直接基于主键检索,同时 comment_id 在写入要尽可能的顺序自增。
意思是从 comment_index 表里捞出来一堆
comment_id
,那就可以直接通过这些comment_id
作为主键去查询 comment_content 表了。如果 content 表还有自己的自增主键的话,那么通过comment_id
去查必然需要先查到自己的主键 id ,然后再通过 id 去查到这一行数据,多了一步操作。索引、内容分离,方便 mysql datapage 缓存更多的 row,如果和 content 耦合,会导致更大的 IO。长远来看 content 信息可以直接使用 KV storage 存储。比如 Rocks DB 等。
这也是一种 「索引内容分离」 的设计思想。
写数据
事务更新 comment_subject,comment_index,comment_content 三张表,其中 content 属于非强制需要一致性考虑的。可以先写入 content,之后事务更新其他表。即便 content 先成功,后续失败仅仅存在一条 ghost 数据。
读数据
基于 obj_id
+ obj_type
在 comment_index 表找到根评论列表,比如:
select id from comment_index where obj_id = 'x' and obj_type = 'y' and root = 0 ORDER BY floor;
对于二级的子楼层,由于一般只查询 3 层子楼层:
select id from comment_index where parent/root in (上面查询出来的 id) and floor <= 3 order by floor;
之后根据 comment_index 的 id 字段捞出 comment_content 的评论内容。
因为产品形态上只存在 「二级列表」,因此只需要迭代查询两次即可。对于嵌套层次多的,产品上,可以通过二次点击支持。
这种迭代查询的方式也可以直接用图数据库来实现,可能更好,比如 DGraph、HugeGraph 类似的图存储思路。
总结
主题一张表,评论索引与评论内容分开两张表来存,表里有一些统计字段,避免每次都重新统计。内容表的主键直接使用评论 id,避免使用评论 id 查询还需要回表。评论内容可以使用 kv 数据库。写入时可以先写评论内容表,评论索引表和主题表用一个事务更新。
缓存设计



comment_subject_cache
对应主题的缓存,value 使用 protobuf 序列化的方式存入。
comment_index_cache
使用 redis sortedset 进行索引的缓存。key 是主题 id + 主题 type + 排序方式, member 就是评论 id,score 就是根据各种要素排序的得分。这样就可以根据某个主题查询,得到排序过后的评论 id 列表。然后就可以通过评论 id 列表去批量查询评论内容了。
提示
索引即数据的组织顺序,而非数据内容。参考过百度的贴吧,他们使用自己研发的拉链存储来组织索引,我认为 mysql 作为主力存储,利用 redis 来做加速完全足够,因为 cache miss 的构建,我们前面讲过使用 kafka 的消费者中处理,预加载少量数据,通过增量加载的方式逐渐预热填充缓存,而 redis sortedset skiplist 的实现,可以做到 O(logN) + O(M) 的时间复杂度,效率很高。
sorted set 是要增量追加的,因此必须判定 key 存在,才能 zdd。
comment_content_cache
对应评论内容数据,使用 protobuf 序列化的方式存入。
缓存使用增量加载 + lazy 加载模式,也就是在查询第一页的时候会将后两页评论数据也一起加载进缓存。可以使用 kafka 进行缓存的异步构建。
可用性设计
缓存穿透
singleflight

对于热门的主题,如果存在缓存穿透的情况,会导致大量的同进程、跨进程的数据回源到存储层,可能会引起存储过载的情况,如何只交给同进程内,一个人去做加载存储?
使用归并回源的思路 singleflight,singleflight 的原理可以看这篇文章 Go并发编程(十二) Singleflight。
同进程只交给一个人去获取 mysql 数据,然后批量返回。
同时这个租约 owner 投递一个 kafka 消息,做该 key 的 cache build 的操作。这样可以大大减少查询 mysql 的压力,以及大量透穿导致的密集写 kafka 的问题(如果不这么做那么会有很多的进程向 kafka 发送 cache rebuild 的指令,然后它们都会去 mysql 里查询数据写缓存)。
更进一步的,后续连续的请求,仍然可能会短时 cache miss,我们可以在进程内设置一个过期时间为 5 秒的 short-lived flag,标记最近有一个人投递了同一个 key 的 cache rebuild 的消息,如果有这个 flag ,那么相同的 kafka 消息直接 drop 而不会再去查 mysql 构建缓存,这样 mysql 的压力更小。
再进一步,可以在 comment-job 内存里设置一个过期时间很短的比如 5 秒的 LRU cache, 有一个线程去 mysql 里查到了数据就更新这个缓存,然后其他的线程直接从这个缓存里拿数据即可,这样就不用重复去 mysql 里查了,同样减少了对 mysql 的查询压力。
一般不需要使用分布式锁,实现起来太复杂而且很容易出错。
热点
流量热点是因为突然热门的主题,被高频次的访问,因为底层的 cache 设计,一般是按照主题 key 进行一致性 hash 来进行分片,但是热点 key 一定命中某一个节点,这时候 remote cache 可能会变为瓶颈,因此做 cache 的升级 local cache 是有必要的,我们一般使用**「单进程自适应发现热点」**的思路,附加一个短时的 ttl local cache,可以在进程内吞掉大量的读请求。
在内存中使用 hashmap 统计每个 key 的访问频次,这里可以使用滑动窗口统计(如下图),即每个窗口中,维护一个 hashmap,之后统计所有未过期的 bucket,汇总所有 key 的数据。
之后使用小顶堆计算 TopK 的数据,自动进行热点识别。把 TopK 的 key 统一 load 到本地缓存。
