评论系统设计
# 功能模块
架构设计最重要的就是理解整个产品体系在系统中的定位。搞清楚系统背后的背景,才能做出最佳的设计和抽象。要理解业务背后的本质,事情的初衷。
- 发布评论:支持回复楼层、楼中楼
- 读取评论:按照时间、热度排序
- 删除评论:用户删除、作者删除
- 管理评论:作者置顶、后台运营管理(搜索、删除、审核等)
评论系统,往小里说就是评论系统,往大里做就是评论平台,可以接入各种业务形态。再大了做就是一个评论的中台。
提示
在动手设计前,反复思考,真正编码的时间只有5%
架构设计等同于数据设计、梳理清除数据的走向和逻辑。尽量避免环形依赖、数据双向请求等。
# 存储设计
数据写入:事务更新
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...)
因为产品形态上只存在二级列表,因此只需要迭代查询两次即可。对于嵌套层次多的,产品上,可以通过二次点击支持;每个主评论只显示前面2个子评论。
表SQL代码
# 我这里简单写一下表字段和注释,不一定sql语句语法就对
create table comment_subject(
id int(11) not null primary key INTERNAL_AUTO_INCREMENT comment '主键',
obj_id int(11) not null comment '对象id',
obj_type int(11) not null comment '对象类型',
member_id int(11) not null comment '作者用户id',
count int(11) unsigned not null default 0 comment '评论总数',
root_count int(11) unsigned not null default 0 comment '根评论总数',
all_count int(11) unsigned not null default 0 comment '评论+回复总数',
state int(8) unsigned not null default 0 comment '状态 0-正常 1-隐藏',
attrs int(32) unsigned not null default 0 comment '属性 0-运营置顶,1-up置顶,2-大数据过滤',
create_time timestamp comment '创建时间',
update_time TIMESTAMP comment '修改时间'
);
create table comment_index (
id int(11) not null primary key INTERNAL_AUTO_INCREMENT comment '主键',
obj_id int(11) not null comment '对象id',
obj_type int(11) not null comment '对象类型',
member_id int(11) not null comment '发表者有用户id',
root int(11) not null comment '根评论id,不为0是回复评论',
parent int(11) not null comment '父评论,为0是root评论',
floor int(11) not null comment '评论楼层',
count int(11) not null comment '评论总数',
root_count int(11) not null comment '根评论总数',
like int(11) not null comment '点赞数',
hate int(11) not null comment '点踩数',
state int(11) not null comment '状态 0-正常 1隐藏',
attrs int(11) not null comment '属性',
create_time TIMESTAMP comment '创建时间',
update_time TIMESTAMP comment '修改时间'
);
create table comment_content (
comment_id int(11) not null primary key INTERNAL_AUTO_INCREMENT comment '主键 和 comment_index的id对应',
at_member_ids varchar(255) not null comment '对象id',
ip int(11) not null comment 'ip地址',
platform int(11) not null comment '发表平台',
device varchar(255) not null comment '设备',
message varchar(255) not null comment '评论内容',
meta text comment '评论元数据:背景、字体',
create_time TIMESTAMP comment '创建时间',
update_time TIMESTAMP comment '修改时间'
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
这里牺牲了一部分写的性能,提升了读性能;有很多的数量的统计的字段做了拆分,在每次写的时候也会进行更新它的数量,避免
select count(*) where ...
这样一个高频的操作的场景里多次使用。
# 索引内容分离
comment_index
:评论楼层的索引组织表,实际并不包含内容
comment_content
:评论内容的表,包含评论的具体内容。其中
comment_index
的id
字段和comment_content
的字段comment_id
是一对一关系。
包含了几种设计思想:
- 表都有主键,即
cluster index
,是物理组织形式存放的,comment_content
没有id,是为了减少一次二级索引查找,直接基于主键检索,同时,comment_id
在写入时要尽可能的顺序自增。 - 索引、内容分离,方便
mysql datapage
缓存更多的row
,如果和content
耦合,会导致大量的IO
,长远来看content
信息可以直接使用KV storage
引擎存储。
# 缓存设计
comment_subject_cache[string]
key | string | old_type |
---|---|---|
value | int64 | subject_marshal string |
expire | duration | 24h |
对应主题的缓存,value
使用protobuf
序列化的方式存入。早期可能会使用memcache
来进行缓存,因为redis
早期单线程模型,吞吐能力不高。
comment_index_cache[sorted set]
key | string | cache key: old_type_sort 其中sort为排序方式 0:楼层 1:回复数量 |
---|---|---|
member | int64 | comment_id:评论id |
score | double | 楼层号、回复数量、排序得分 |
expire | duration | 8h |
使用redis sortedset
进行索引的缓存,索引即数据的组织顺序,而非数据内容。
百度贴吧使用的是自己研发的拉链存储来组织索引。毛大认为mysql
作为主力存储,利用redis
来做加速完全足够,因为cache miss
的构建,使用kafka
的消费者中处理,预加载少量数据,通过增量加载的方式主键预热填充缓存,而redis sortedset skiplist
的实现,可以做到O(logN) + O(M)
的时间复杂度,效率很高。
sorted set
是要增量追加的,因此必须判断key
存在才能zadd
comment_content_cache[string]
key | string | comment |
---|---|---|
value | int64 | content |
expire | duration | 24h |
comment_content_cache
:对应评论内容的数据,使用protobuf
序列化的方式存入。
# 可用性设计
# singleflight
对于热门的主题,如果存在缓存穿透的情况,会导致大量的同进程、跨进程的数据回源到存储层,可能会引起存储过载的情况。如何值交给同进程内,一个人去做加载存储?
使用归并回源的思路 归并回源 (opens new window)
同进程只交给一个人去获取mysql
数据,然后批量返回。同时这个lease owner
投递一个kafka
消息,做index cache
的recovery
操作。这样可以大大减少mysql
的压力,以及大量穿透导致的密集写kafka
的问题。
更进一步的,后续连续的请求,仍然可能会短时间cache miss
,可以在进程内设置一个short-lived flag
,标记最近有一个人投递了cached rebuild
的消息,直接drop
# 热点
流量热点是因为突然热门的主题,被高频次的访问,因为底层cache
的设计,一般是按照主题key
进行一致性hash
来进行分片,但是热点key
一定命中某一个节点,这时候remote cache
可能会变成瓶颈,因此做cache
的升级local cache
是必要的。一般使用单进程自适应发现热点的思路,附加一个短时的ttl local cache
可以在进程内吞掉大量的读请求。
在内存中使用hashmap
统计每个key
的访问频次,这里使用滑动窗口统计,即每个窗口中,维护一个hashmap
,之后统计所有未过去的bucket
,汇总所有key
的数据。
之后使用小堆计算TopK
的数据,自动进行热点识别。