评论是作为互联网用户的我们最耳熟能详的一个功能,几乎所有面向大众的互联网应用都提供了强大的评论功能。本章我们就来讨论评论服务的设计思想,下面先给出本章的学习路径。

12.1 节介绍评论功能的重要性,以及其作为一个通用评论服务应具有的基本能力。
12.2 节介绍目前主流的评论列表模式,包括单级模式、二级模式和盖楼模式。
12.3 节介绍评论服务设计的初步想法。
12.4 节介绍单级模式服务设计,包括存储选型和高并发设计。
12.5 节介绍盖楼模式服务设计,合理地获取到完整的盖楼信息。
12.6 节介绍二级模式服务设计。二级模式是最常用的评论列表模式,我们对此分别介绍了技术选型、评论审核、热门评论和高并发处理等,这也是本章的重点内容。

12.1 评论功能

评论是互联网应用中非常常见的功能。几乎所有面向大众的互联网应用都会提供评论能力,因为评论不仅是用户表达观点的入口,也是产品形成互动、内容沉淀和流量增长的重要手段。

评论功能的重要性主要体现在四个方面:

1. 增加社交互动

用户可以围绕话题、内容、文章或其他用户的评论进行交流。

评论让用户之间形成互动关系,从而增强产品黏性,提高用户留存率。

2. 促进用户参与和分享

用户可以通过评论表达自己的观点,也可以通过回复展示自己的看法。

这会提高用户参与度和参与率。用户还可以向其他人推荐、分享自己感兴趣的内容,进一步提高活跃度。

3. 促进用户生成内容

评论本身也是一种用户生成内容。

用户通过评论表达观点,吸引更多用户参与讨论,可以提高社交产品的内容数量和内容质量。

4. 增加社交产品流量

评论内容可以帮助平台积累更多可被搜索或传播的信息,从而通过搜索引擎、社交媒体等渠道为产品带来流量。


评论服务是一种通用能力,至少需要支持以下功能:

评论服务的基本能力
├── 发布评论:用户可以对内容发布评论,也可以回复其他评论
├── 删除评论:用户可以删除自己的评论,内容发布者也可以删除不友好评论
├── 点赞 / 点踩评论:用户可以对评论表达正向或负向反馈
├── 拉取内容评论列表:获取某内容下的分页评论列表
├── 拉取用户评论列表:查看某用户发布过的历史评论
└── 运营评论:支持置顶、关键词搜索、强制删除等运营能力

其中,拉取内容评论列表通常还需要支持不同排序方式,例如:

按评论时间排序
按热度排序

12.2 评论列表模式

评论列表是评论服务最重要的场景。

评论列表模式决定了评论之间的展示关系,也会直接影响评论服务的数据建模方式。主流评论列表模式主要有三种:

评论列表模式
├── 单级模式
├── 二级模式
└── 盖楼模式

1. 单级模式

单级模式下,所有评论都处于同一层级。

无论是对内容本身的评论,还是对其他评论的回复,都会被放在同一个评论列表中,通常按照时间顺序排列。

如下图所示。

image-20260531110352073

这种模式的优点是简单,可以满足用户评论和回复的基本诉求。

但它的问题也很明显:用户之间的互动容易被割裂。

例如,多个用户之间明明在连续对话,但列表中只能看到一条条平铺的评论,用户需要自己在评论列表里寻找上下文,很难直观看出大家到底在讨论什么。

所以单级模式更适合那些社交互动不是那么重要的产品,如:博客、微信朋友圈这类小范围互动场景

它不太适合强社交属性、评论量大、需要围绕内容形成互动场的产品。


2. 二级模式

二级模式把评论分成两级:

一级评论:直接评论内容
二级评论:回复某条一级评论,或回复一级评论下的其他回复

image-20260531111018786

这种模式可以清楚地区分两类互动意图:

对内容本身发表看法
对某条评论继续交流

用户打开评论区后,可以先看到大家对内容本身的观点;如果某条评论很有价值,再继续展开它的二级评论区。

例如:用户刷到电影片段后想知道电影名,打开评论区发现有人已经问了这个问题,再点开这条评论的二级评论区,就能看到其他人的回答。这个过程非常自然。

很多对单级模式缺点有顾虑的产品都会采用二级模式,例如:

微博
bilibili
知乎
小红书

3. 盖楼模式

盖楼模式会展示一条评论的完整回复链路。

例如:

用户 1 发布评论
用户 2 回复用户 1
用户 3 回复用户 2
用户 4 回复用户 3
用户 5 回复用户 4

那么对于用户 5 的回复来说,需要完整展示:

用户 1 说了什么
用户 2 回复了什么
用户 3 回复了什么
用户 4 回复了什么
用户 5 最后回复了什么

如下图所示。

image-20260531120329196

盖楼模式由网易公司创新设计。网易公司在 2004 年就推出了盖楼评论功能,是国内较早推广和使用盖楼评论功能的互联网公司。

盖楼模式的特点是:

上下文紧密
便于追踪观点和回复
互动性很强
容易形成精彩评论接力

它适合展示用户围绕某个话题持续交锋、接力和表达的过程。


三种模式对应不同业务场景,也要求不同的数据建模方式。

这也是评论服务设计的核心:不同评论展示诉求,对应不同的存储结构和查询方式。

本节总结

评论列表有三种典型模式:

单级模式:所有评论平铺在同一层级,简单但互动上下文弱
二级模式:一级评论评论内容,二级评论围绕一级评论互动
盖楼模式:展示一条评论完整的回复链路,上下文最强

12.3 评论服务设计的初步想法

一条评论数据至少包含以下信息:

评论文本
发布者
发布时间
评论对象
回复了哪个用户
点赞数
点踩数

其中:

评论文本:表示用户实际说了什么
评论元信息:表示评论是谁写的、评论目标是什么、评论之间有什么关系

与内容存储设计类似,评论数据也可以拆成两部分:

评论数据
├── 评论文本
└── 评论元信息

1. 评论文本

评论文本是纯粹的发言内容,适合存储在分布式 KV 存储系统中。

Key:评论 ID
Value:评论文本

2. 评论元信息

评论元信息适合用数据库存储。

不过,不同评论列表模式下,评论之间的关系不同,所以数据表设计也不同:

单级模式:重点记录评论属于哪个内容,以及回复了哪条评论
盖楼模式:重点支持追溯完整回复链路
二级模式:重点区分一级评论和二级评论

本节总结

评论数据可以拆分为:

评论文本:适合分布式 KV 存储
评论元信息:适合数据库存储

12.4 单级模式服务设计

如果产品不强调评论区互动性,或者评论区规模不会特别大,可以使用单级模式设计评论服务。

典型场景包括:

博客留言
微信朋友圈

12.4.1 数据表的初步设计

在单级模式下,对内容的评论和对评论的回复都处于同一层级,因此数据表设计相对简单。

假设数据表名为 comment,表结构如下。

字段名 类型 含义
id BIGINT 自增主键,无特殊含义
content_id BIGINT 内容 ID
comment_id BIGINT 评论 ID
user_id BIGINT 评论发布者 ID
reply_user_id BIGINT 如果评论是回复,则记录被回复用户 ID,默认值为 0
reply_comment_id BIGINT 如果评论是回复,则记录被回复评论 ID,默认值为 0
comment_time DATE 评论发布时间

判断方式:

reply_user_id = 0 且 reply_comment_id = 0
表示这条评论是对内容本身的评论

reply_user_id 和 reply_comment_id 有明确值
表示这条评论是对其他评论的回复

评论 ID 是评论的唯一标识,需要在创建评论时由分布式唯一 ID 生成器生成。

12.4.2 读/写接口与索引

1. 发布评论

假设用户 111 在内容 222 的评论区发布评论 333

评论服务先把评论文本存储到分布式 KV 存储中:

Key:333
Value:评论文本

然后保存评论元信息:

INSERT INTO comment(content_id, comment_id, user_id, reply_user_id, comment_time)
VALUES (222, 333, 111, 当前时间);

如果用户 111 发布的评论 555 是对用户 333 的评论 444 的回复,则保存:

INSERT INTO comment(
content_id,
comment_id,
user_id,
reply_user_id,
reply_comment_id,
comment_time
)
VALUES (222, 555, 111, 333, 444, 当前时间戳);

2. 读取内容评论列表

评论列表通常分页展示。

假设内容 222 的评论区按照发布时间由远及近展示,第 1 页读取最早的 N 条评论:

SELECT user_id, comment_id, content_id, reply_user_id, reply_comment_id
FROM comment
WHERE content_id = 222
ORDER BY comment_time
LIMIT 0, N;

读取第 2 页:

SELECT user_id, comment_id, reply_user_id, reply_comment_id
FROM comment
WHERE content_id = 222
ORDER BY comment_time
LIMIT N, N;

读取第 M 页:

LIMIT (M - 1) * N, N

为了提高读取内容评论列表的效率,适合创建联合索引:

idx_comment_list(content_id, comment_time)

这样可以先根据内容 ID 定位评论区,再按发布时间顺序读取评论。

3. 删除评论

最直接的删除方式是按评论 ID 删除:

DELETE FROM comment WHERE comment_id = 555;

如果希望这条 SQL 高效执行,需要给 comment_id 创建索引。

但书中指出,这个索引并不是必须的。因为用户删除评论之前一定已经在客户端看到了这条评论,所以客户端知道:

内容 ID
评论发布时间
评论 ID

因此可以借助 content_idcomment_time 辅助删除:

DELETE FROM comment
WHERE content_id = 222
AND comment_time = 444
AND comment_id = 555;

这条语句会先命中 idx_comment_list 索引,再在定位到的数据中扫描 comment_id

同一内容在同一时刻收到的评论通常不会很多,所以扫描效率可以接受。

4. 查询用户历史评论

用户 111 查询自己发布的历史评论,一般按照发布时间由近及远分页展示:

SELECT content_id, user_id, comment_id, reply_user_id
FROM comment
WHERE user_id = 111
ORDER BY comment_time DESC
LIMIT (M - 1) * N, N;

这个场景适合创建联合索引:

idx_user_list(user_id, comment_time)

5. 分库分表带来的索引冲突

comment 表需要两个联合索引:

idx_comment_list(content_id, comment_time)
idx_user_list(user_id, comment_time)

但这两个索引的第一个字段不同。

如果 comment 表需要分库分表,就很难选择一个字段作为统一路由依据:

按 content_id 路由:
查询内容评论列表很快
查询用户历史评论需要扫多个分表

按 user_id 路由:
查询用户历史评论很快
查询内容评论列表需要扫多个分表

大部分互联网产品都有海量评论数据,评论表通常必然要分库分表,所以这个问题必须解决。

12.4.3 数据库的最终设计

解决思路是数据表冗余设计。

创建两个结构与 comment 完全一致的数据表:

content_comment
user_comment

二者分工如下:

content_comment:
用 idx_comment_list(content_id, comment_time) 作为索引
用 content_id 作为分库分表路由依据
负责查询某内容下的评论列表

user_comment:
用 idx_user_list(user_id, comment_time) 作为索引
用 user_id 作为分库分表路由依据
负责查询某用户发布的历史评论列表

发布评论、删除评论时,需要同时更新两张表。

为了保证一致性,可以选择:

content_comment 作为主表
user_comment 通过伪从技术自动同步最新评论数据

如下图所示。这种方案本质上是用冗余数据换取两个查询场景都能高效路由。

image-20260531122326606

12.4.4 高并发问题

评论功能可能是典型的读多写多场景。

当流量明星发布内容,或者某条内容具有很高话题度时,会出现两类压力:

高并发写评论
高并发读评论

1. 高并发写评论

写评论请求会直接操作数据库。

虽然数据库可以通过分库分表提高并行写能力,但 content_comment 表按照 content_id 路由。

对于热门内容,大量写评论请求都指向同一个内容 ID,因此会被路由到同一个子表中,数据库仍然可能被击垮。

如下图所示,可以使用异步写和写聚合来处理。

image-20260531122430599

异步写

在评论服务和数据库之间引入消息队列:

评论服务
↓ 写评论请求发送到 push_comment 消息队列
发评消费者
↓ 消费消息并逐步写入数据库
数据库

评论服务把写评论请求写入 push_comment 消息队列后,就可以成功响应用户。

发评消费者再慢慢把评论元信息插入数据库。

这样可以把高并发写请求削峰为数据库能够处理的平滑流量。

写聚合

发评消费者可以每隔 10s 按内容 ID 聚合写评论请求。

例如某 10s 内收到了 100 个对内容 111 的写评论请求:

原来:100 条 SQL,访问数据库 100 次
聚合后:1 条批量 SQL,访问数据库 1 次

这样可以提高消费者处理速度,降低数据库访问压力。

2. 高并发读评论

这里的读评论主要指拉取热门内容的评论列表。

绝大多数用户只会读取评论区前几页,因此可以为前 N 条评论构建 Redis 缓存。

评论列表按发布时间排序时,可以使用 Redis ZSET:

Key:内容 ID
Member:评论 ID
Score:评论发布时间

需要注意评论排序方向:

由远及近:
前 N 条评论相对固定
缓存很少需要更新

由近及远:
前 N 条评论动态变化
内容越热门,变化越快

对于由近及远排序的场景,更适合让 Redis 作为数据库的伪从:

每插入一条评论到数据库
同时把评论写入 Redis ZSET

为了进一步降低 Redis 压力,还可以引入本地缓存。

评论服务实例把从 Redis ZSET 得到的评论列表缓存到本地内存中,并设置较短过期时间,例如 5s

这会让用户最多看到 5s 前的评论,而不是绝对最新的评论。但用户通常不要求必须看到此刻最新评论,只要评论数据相对较新即可。

本节总结

单级模式的核心数据结构很简单:

content_id:评论属于哪个内容
comment_id:评论自身 ID
reply_user_id:回复了哪个用户
reply_comment_id:回复了哪条评论
comment_time:评论发布时间

但在海量评论场景下,查询内容评论列表和查询用户历史评论的路由字段不同。

最终需要冗余两张表:

content_comment:面向内容评论列表
user_comment:面向用户历史评论列表

高并发写评论用:

异步写
写聚合

高并发读评论用:

Redis ZSET 缓存前 N 条评论
本地缓存缓解 Redis 压力

一句话总结:

单级模式模型简单,但海量场景下仍然要解决分库分表路由、高并发写和热点评论读取问题。

12.5 盖楼模式服务设计

盖楼模式最大的特点是:每条评论都可以展示完整楼层。

楼层由初始评论和一层层回复组成。

例如:

用户 1 发布评论
用户 2 回复用户 1
用户 3 回复用户 2
...
用户 100 回复用户 99

对于用户 100 的回复,需要展示用户 1 到用户 100 的完整回复链路。

也就是说,在盖楼模式下,通过每条评论都要能回溯出完整回复链路。

12.5.1 数据库方案:递归查询

单级模式中的 content_comment 表已经记录了评论之间的回复关系:

comment_id:当前评论 ID
reply_comment_id:被回复评论 ID

如果要展示某条评论的盖楼情况,可以从这条评论开始,不断根据 reply_comment_id 向上查询上一层评论,直到查询到 reply_comment_id = 0 的顶层评论。

这个过程如下图所示。

image-20260531122705691

假设评论 99 是一个层数为 9 的楼层回复,要展示完整楼层,就需要从评论 99 开始递归查询上层评论。

数据库一般都支持这种递归查询方式,仍以 MySQL 为例,我们可以通过创建自定义函数、编写带变量的复杂 SQL 语句,或者使用高版本支持的 WITH RECURSIVE 语句来实现递归查询。

假设 content_comment 数据表中下表所示的数据。

content_id comment_id user_id reply_comment_id reply_user_id comment_time
1 11 111 0 0 2023-01-01
1 22 222 11 111 2023-01-02
1 33 333 22 222 2023-01-03
1 44 444 33 333 2023-01-04
1 55 555 44 444 2023-01-05
1 66 666 55 555 2023-01-06
1 77 777 66 666 2023-01-07
1 88 888 77 777 2023-01-08
1 99 999 88 888 2023-01-09

可以看到,评论 99 是一个层数为 9 的楼层回复。为了展示完整的楼层,需要从评论 99 开始递归查询所有的上一层评论。我们可以执行如下 SQL 语句来实现:

SELECT ID.level, DATA.* FROM(
SELECT
@comment_id as comment_id,
( SELECT @comment_id := reply_comment_id
FROM content_comment
WHERE comment_id = @comment_id
) as pid,
@l := @l+1 as level
FROM content_comment,
(SELECT @comment_id := 99, @l := 0 ) b
WHERE @comment_id > 0
) ID, content_comment DATA
WHERE ID.comment_id = DATA.comment_id
ORDER BY level;

上述语句的执行结果如下:

+-------+----+------------+------------+---------+------------------+---------------+---------------------+
| level | id | content_id | comment_id | user_id | reply_comment_id | reply_user_id | comment_time |
+-------+----+------------+------------+---------+------------------+---------------+---------------------+
| 1 | 37 | 1 | 99 | 999 | 88 | 888 | 2023-01-09 00:00:00 |
| 2 | 36 | 1 | 88 | 888 | 77 | 777 | 2023-01-08 00:00:00 |
| 3 | 35 | 1 | 77 | 777 | 66 | 666 | 2023-01-07 00:00:00 |
| 4 | 34 | 1 | 66 | 666 | 55 | 555 | 2023-01-06 00:00:00 |
| 5 | 33 | 1 | 55 | 555 | 44 | 444 | 2023-01-05 00:00:00 |
| 6 | 32 | 1 | 44 | 444 | 33 | 333 | 2023-01-04 00:00:00 |
| 7 | 31 | 1 | 33 | 333 | 22 | 222 | 2023-01-03 00:00:00 |
| 8 | 30 | 1 | 22 | 222 | 11 | 111 | 2023-01-02 00:00:00 |
| 9 | 29 | 1 | 11 | 111 | 0 | 0 | 2023-01-01 00:00:00 |
+-------+----+------------+------------+---------+------------------+---------------+---------------------+

另一种递归查询方式是使用 WITH RECURSIVE 语句,它是 MySQL 8.0 版本中引入的新功能。WITH RECURSIVE 是一个面向树形结构的递归查询语句,相较于传统的 SQL 查询语句,它能够帮助用户查询具有层级依赖关系的数据结构,在一定程度上提高了数据查询效率。

例如,展示评论 99 的完整楼层的语句如下:

WITH RECURSIVE temp AS (
SELECT p.* FROM content_comment p WHERE comment_id = 99
UNION ALL
SELECT q.* FROM content_comment q INNER JOIN temp ON temp.reply_comment_id = q.comment_id
)
SELECT * FROM temp;

MySQL 使用 WITH RECURSIVE 语句进行递归查询,虽然性能相对较好,但是递归操作毕竟是一个深度遍历的过程,如果一条盖楼评论的楼层过高,则意味着递归查询的深度大,于是需要循环查询同一个数据表的次数就增加了,时间和内存的开销也会增加,进而影响查询效率。

总的来说,基于 comment_id 和 reply_comment_id 字段,递归查询评论的完整回复链路的时间复杂度至少为 O(N),评论的楼层越高,查询效率越低。

12.5.2 数据库方案:保存完整楼层

为了避免每次递归查询,可以在 content_comment 表中增加一个 building 字段。

building 字段使用 Blob 类型,用于存储各楼层的评论 ID 列表。

例如评论 88 的盖楼评论 ID 列表为:

[11, 22, 33, 44, 55, 66, 77]

保存流程:

评论 ID 列表
↓ 序列化为字节流
↓ 压缩为二进制数据
↓ 存入 building 字段

如果评论 99 是对评论 88 继续盖楼:

读取评论 88 的 building 字段
解压缩、反序列化得到 [11,22,33,44,55,66,77]
把评论 88 加入列表
得到 [11,22,33,44,55,66,77,88]
序列化、压缩后保存到评论 99 的 building 字段

这种方案的优点是:

只访问一条数据记录
就能得到完整楼层
避免递归查询

Blob 不是万能的。

Blob 数据存储在 MySQL 数据表的单独数据页中,数据记录只保存指向数据页的指针。这有利于访问较大的二进制数据,但也会带来副作用:

降低数据表读写效率
Blob 数据越大,传输和查询压力越大
楼层越高,building 字段越大

如果产品不限制盖楼层数,可能出现数万层的“摩天大楼”式盖楼评论,这会给数据库带来巨大压力。

所以这种方案更适合:

天然限制最大盖楼层数
只存在较低楼层评论
例如最多盖 1000 层

12.5.3 图数据库方案

评论之间的回复关系,本质上是图结构。

如果关系型数据库不适合存储和查询较长的回复链路,就可以把评论回复关系存到图数据库中。

基本思路:

评论是节点
回复关系是边
评论发布时间是边的属性

书中示例中,评论节点包含:

comment_id:评论 ID
user_id:评论发布者 ID

回复关系包含:

comment_time:评论发布时间

评论回复链路如下图所示。

image-20260531130838811

如果要展示评论 88 的盖楼情况,只需要执行类似查询:

MATCH (c:Comment{comment_id:88})-[r*..](building)
RETURN building;

这条语句会从评论 88 节点开始,沿回复关系做深度遍历,经过的每个节点都成为上一层楼层。

执行结果如下图所示。

image-20260531130917325

盖楼评论的本质是:

在评论回复关系树中
对某个评论节点做深度遍历

因此,盖楼模式非常适合使用图数据库。

本节总结

盖楼模式要求通过任意一条评论回溯完整回复链路。

关系型数据库有两种方案:

递归查询:
实现直接,但楼层越高查询越慢,复杂度至少 O(N)

保存完整楼层:
用 Blob 保存楼层评论 ID 列表
读取快,但写入和存储压力更大

图数据库方案:

评论作为节点
回复作为边
沿回复关系深度遍历得到完整楼层

一句话总结:

盖楼评论本质是回复关系的深度遍历,楼层较高时图数据库比关系型数据库更自然。

12.6 二级模式服务设计

二级模式是很多亿级用户应用采用的评论模式,例如微博、bilibili 等。

本节将重点讨论二级模式评论服务的设计,同时引入之前尚未讨论的评论审核和按照热度排序的能力。

12.6.1 一级评论和二级评论

在二级模式中:

一级评论:对内容本身的评论
二级评论:对一级评论的回复,或对回复的回复

用户打开内容评论区时,会看到若干一级评论。

每条一级评论也有自己的二级评论区。二级评论区通常默认折叠,只有用户主动点击打开某条一级评论时,才会展示二级评论。

1. 二级评论区排序

在二级评论区中:

对一级评论的回复
对回复的回复

一般按照评论发布时间由远及近排序(先发布的排在前面)

2. 一级评论排序

一级评论之间没有直接互动关系,因此排序方式更灵活。

常见排序方式包括:

按评论发布时间排序
按热度排序

例如微博评论区支持按热度排序和按时间排序,默认按热度排序。

所谓热度并不是固定概念,不同产品有不同定义,通常会参考:

点赞数
回复数
发布时间

3. 二级模式的两个评论列表

二级模式下,评论服务要处理两类列表:

内容评论区:
展示一级评论列表

一级评论评论区:
展示二级评论列表

二级模式的数据模型要解决两个判断:

一条评论是一级评论还是二级评论
一条二级评论回复的是一级评论还是二级评论

这是二级模式评论元信息设计的核心。

12.6.2 时间顺序:数据库方案

如果暂时不考虑一级评论的热度排序,只按时间排序,可以使用数据库存储评论元信息。

表结构如下所示。为了方便说明,假设表名为 t

列名 类型 含义
id BIGINT 自增主键,无特殊含义
content_id BIGINT 内容 ID,表示评论最终属于哪条内容
comment_id BIGINT 评论 ID
user_id BIGINT 评论发布者 ID
root_id BIGINT 如果是对内容的评论,则为内容 ID;如果是一级评论下的评论,则为一级评论 ID
level TINYINT 评论层级,1 表示一级评论,2 表示二级评论
reply_count BIGINT 评论被回复次数,仅一级评论需要记录
like_count BIGINT 评论被点赞次数
reply_user_id BIGINT 如果评论是对内容的评论,则为 0;如果回复一级评论,则为一级评论用户 ID;如果回复二级评论,则为二级评论用户 ID
reply_comment_id BIGINT 如果评论是对内容的评论,则为 0;如果回复一级评论,则为一级评论 ID;如果回复二级评论,则为二级评论 ID
comment_time DATE 评论发布时间

判断方式:

level = 1:
一级评论

level = 2:
二级评论,属于 root_id 指向的一级评论的二级评论区

level = 2 且 reply_comment_id = 一级评论 ID:
回复的是一级评论

level = 2 且 reply_comment_id = 二级评论 ID:
回复的是二级评论

1. 读取内容的一级评论列表

用户打开内容 111 的评论区,按发布时间由远及近展示 N 条一级评论:

SELECT comment_id, user_id, reply_count, like_count, comment_time
FROM t
WHERE root_id = 111
AND level = 1
ORDER BY comment_time
LIMIT 0, N;

一级评论不仅要展示评论 ID、用户 ID、发布时间,还要展示:

二级评论区长度 reply_count
点赞数 like_count

2. 读取二级评论列表

用户打开一级评论 222 的二级评论区,读取 N 条二级评论:

SELECT comment_id, user_id, like_count, comment_time
FROM t
WHERE root_id = 222
AND level = 2
ORDER BY comment_time
LIMIT 0, N;

3. 查询用户历史评论

用户 999 查询自己发布的前 N 条历史评论:

SELECT comment_id, like_count, comment_time, content_id,
reply_user_id, reply_comment_id
FROM t
WHERE user_id = 999
ORDER BY comment_time DESC
LIMIT 0, N;

4. 索引设计

为了让以上 SQL 高效执行,需要创建两个联合索引:

idx_comment_list(root_id, level, comment_time)
用于获取内容评论区和一级评论的二级评论区

idx_user_comment(user_id, comment_time)
用于获取用户历史评论

与单级模式类似,这里也需要拆成两张结构完全一致的表:

content_comment
user_comment

分工:

content_comment:
负责处理内容评论列表和一级评论的二级评论列表

user_comment:
负责处理用户历史评论列表

5. 写入示例

用户 999 对内容 111 发布一级评论 1000

INSERT INTO content_comment(
content_id, comment_id, user_id, root_id, level, comment_time
)
VALUES (111, 1000, 999, 111, 1, 当前时间);

用户 999 对内容 111 的一级评论 222 发布回复 1001,一级评论发布者 ID 为 333

INSERT INTO content_comment(
content_id, comment_id, user_id, root_id, level,
comment_time, reply_comment_id, reply_user_id
)
VALUES (111, 1001, 999, 222, 2, 当前时间, 222, 333);

用户 999 对一级评论 222 下二级评论区的评论 444 发布回复,评论 444 的发布者 ID 为 555

INSERT INTO content_comment(
content_id, comment_id, user_id, root_id, level,
comment_time, reply_comment_id, reply_user_id
)
VALUES (111, 1001, 999, 222, 2, 当前时间, 444, 555);

这些写入都会通过伪从技术同步到 user_comment 表。

12.6.3 时间顺序:图数据库方案

二级模式中存在多种回复关系:

一级评论回复内容
二级评论回复一级评论
二级评论回复另一条二级评论

如果把内容和评论看作点,把回复关系看作边,就可以得到一个有向图。

因此,图数据库也是二级模式评论元信息的一种可选方案。

这里通过一个完整的例子来介绍如何使用图数据库来获取评论列表。首先,创建内容节点和评论节点:

// 创建 1 个代表内容的节点
CREATE (ct1:Content{content_id:1})

// 创建 12 个代表评论的节点
CREATE (cm1:Comment{comment_id:11,user_id:1001})
CREATE (cm2:Comment{comment_id:22,user_id:1002})
CREATE (cm3:Comment{comment_id:33,user_id:1003})
CREATE (cm11:Comment{comment_id:111,user_id:1004})
CREATE (cm12:Comment{comment_id:112,user_id:1005})
CREATE (cm13:Comment{comment_id:113,user_id:1006})
CREATE (cm21:Comment{comment_id:211,user_id:1007})
CREATE (cm22:Comment{comment_id:212,user_id:1008})
CREATE (cm23:Comment{comment_id:213,user_id:1009})
CREATE (cm31:Comment{comment_id:311,user_id:1010})
CREATE (cm32:Comment{comment_id:312,user_id:1011})
CREATE (cm33:Comment{comment_id:313,user_id:1012})

然后,在 cm1、cm2、cm3 这 3 个评论节点与内容节点 ct1 之间建立回复关系,即这 3 条评论成为一级评论:

CREATE (cm1)-[:Reply{comment_time:'2022-11-03 22:05:37'}]->(ct1)

CREATE (cm2)-[:Reply{comment_time:'2022-11-03 19:29:51'}]->(ct1)

CREATE (cm3)-[:Reply{comment_time:'2022-11-03 21:35:49'}]->(ct1)

接下来,在剩下的评论节点与待回复的评论节点之间建立回复关系,待回复的评论包括一级评论和二级评论。

// 二级评论 cm11 回复 一级评论 cm1,cm12 回复 cm1,cm13 回复 cm12
CREATE (cm11)-[:Reply{comment_time:'2022-11-03 22:09:22'}]->(cm1)
CREATE (cm12)-[:Reply{comment_time:'2022-11-03 22:15:10'}]->(cm1)
CREATE (cm13)-[:Reply{comment_time:'2022-11-03 22:19:05'}]->(cm12)

// 二级评论 cm21 回复 一级评论 cm2,cm22 回复 cm21,cm23 回复 cm2
CREATE (cm21)-[:Reply{comment_time:'2022-11-03 20:01:15'}]->(cm2)
CREATE (cm22)-[:Reply{comment_time:'2022-11-03 20:12:39'}]->(cm21)
CREATE (cm23)-[:Reply{comment_time:'2022-11-03 20:15:58'}]->(cm2)

// 二级评论 cm31 回复 一级评论 cm3,cm32 和 cm33 回复 cm31
CREATE (cm31)-[:Reply{comment_time:'2022-11-03 21:49:19'}]->(cm3)
CREATE (cm32)-[:Reply{comment_time:'2022-11-03 22:15:10'}]->(cm31)
CREATE (cm33)-[:Reply{comment_time:'2022-11-03 22:19:05'}]->(cm31)

最后,内容 ct1 与 cm1、cm2 等 12 条评论形成回复关系图,如下图所示。

image-20260531174301060


读取内容 ct1 的一级评论列表就是读取与 ct1 节点有直接回复关系的评论集合,并按照回复时间排序。对应的 CQL 语句如下:

MATCH (l1_comment)-[r:Reply]->(Content{content_id:1}) RETURN
l1_comment.comment_id, l1_comment.user_id, r.comment_time ORDER BY r.comment_time

这条 CQL 语句的执行结果如下表所示。

“l1_comment.comment_id” “l1_comment.user_id” “r.comment_time”
22 1002 “2022-11-03 19:29:51”
33 1003 “2022-11-03 21:35:49”
11 1001 “2022-11-03 22:05:37”

读取评论 cm1 的二级评论列表就是读取那些与 cm1 节点有回复关系的节点,以及与这些节点有回复关系的节点。换言之,一个评论节点按照回复方向做深度遍历时,如果可以遍历到 cm1 节点,则说明此评论是评论 cm1 的二级评论。所以,我们使用 CQL 语句查询多跳回复链路指向 cm1 节点的那些节点,它们就是评论 cm1 的二级评论列表:

MATCH (l2_comment)-[r:Reply*1..]->(u:Comment{comment_id:11})
RETURN l2_comment.comment_id, l2_comment.user_id,r[0].comment_time ORDER BY
r[0].comment_time

这条 CQL 语句的执行结果如下表所示。

“l2_comment.comment_id” “l2_comment.user_id” “r[0].comment_time”
111 1004 “2022-11-03 22:09:22”
112 1005 “2022-11-03 22:15:10”
113 1006 “2022-11-03 22:19:05”

用户 1001 查看自己发布的评论,就是查看属性 user_id 为 1001 的评论节点:

MATCH (c:Comment{user_id:1001})-[r:Reply]->() RETURN c.comment_id, r.comment_time
ORDER BY r.comment_time DESC

这条 CQL 语句的执行结果如下表所示。

“c.comment_id” “r.comment_time”
11 “2022-11-03 22:05:37”

鉴于二级模式的评论功能可以非常直观地通过回复关系构建出评论与内容之间、评论与评论之间的回复关系,所以我们可以借助图数据库在图数据查询上的便捷性和高性能来存储二级模式评论的回复关系。


12.6.4 评论审核与状态

评论本质上也是用户输出的内容。

评论带有主观意图和情感色彩,因此需要审核介入,处理:

血腥暴力
谩骂攻击
垃圾广告
其他不合规评论

与内容审核类似,评论审核分为:

机审
人审

1. 机审

机审主要使用自然语言处理技术对评论文本做关键词分析,再使用机器学习技术判断评论类别。

识别出的不合规评论会被删除。

2. 人审

人审由审核人员阅读评论数据,主观判断是否删除。

但活跃应用每天可能产生几千万条评论,审核人员不可能逐条处理,所以人审通常只作用于被召回的评论。

常见召回渠道有三种:

关键词召回

审核人员可以在评论运营后台根据关键词搜索评论数据,然后逐条审核。

例如搜索诈骗相关关键词,筛查相关评论。

为了支持关键词搜索,评论运营后台适合使用 Elasticsearch 作为评论存储引擎。

评论服务可以通过伪从技术,把数据库中已发布的评论数据同步到 Elasticsearch。

热门召回

如果某条评论曝光量达到一定量级,说明它已经影响了较多用户,因此也应该进入人审。

客户端可以周期性上报每条评论的曝光次数,由服务端做流式汇总。

当某条评论曝光量达到预设阈值时,将其放入人审队列。

举报召回

被举报的评论很可能不合规,也需要人审。

服务端需要监听评论举报事件,并把对应评论放入人审队列。

3. 评论状态

当审核人员认为某条评论不合规时,需要将评论标记为不可见。

因此,评论元信息中需要记录评论状态。

评论状态不仅是简单的可见 / 不可见,通常还包括更细的状态:

全员可见:所有人都能看到此评论
仅好友可见:只有相互关注的好友能看到此评论
自见:只有评论发布者能看到,发布者以为自己已发布成功
审核中:被审核召回,当前可见性与自见相同
删除:任何人都看不到
神评论:热门优质评论,可被置顶、授予勋章或参与热评活动

4. 读取评论时的状态过滤

用户读取评论列表时,每条评论能否展示取决于状态。

规则可以概括为:

删除:
用户一定不能读取到

自见 / 审核中:
只有评论发布者能读取到

仅好友可见:
用户必须是评论发布者的好友才能读取到

其他状态:
任何用户都可以读取到

这意味着:同一个评论区,不同用户看到的评论列表可能不同。

SQL 很难轻易完成所有评论状态筛选,所以数据库查询时不必判断评论状态。

**更合适的方式是:数据库先读取评论列表,评论服务再按每条评论状态做业务层过滤。 **

12.6.5 按照热度排序

很多互联网应用会在评论区优先展示热度较高的一级评论。

这类评论往往更有话题性、更优质,把它们优先展示出来,可以让评论区更有吸引力,提高用户黏性。

1. 热度如何度量

热度需要数字化。

不同产品的度量标准不同,常见指标包括:

点赞数:点赞越多,评论越热门
回复数:回复越多,说明讨论越多
点赞数与回复数加权:一起反映互动性

示例热度公式:

热度值 = 点赞数 * 0.4 + 回复数 * 0.6

有些产品还会考虑评论积极性,例如越早抢占评论区“沙发”的评论,给予越高正向激励。

2. 热度排序的几个特点

按照热度排序并不表示评论区所有评论都按热度展示。

对于按照热度排序的评论列表,有如下几个重点要说明。

  • 评论区并不是所有评论都按热度排序。打开评论区时,通常先展示热度最高的一批评论(如前 1000 条)。
  • 热门评论刷完后,后续评论会按发布时间从近到远展示。
  • 没有点赞、回复的评论热度为 0,不参与热门排序。
  • 如果热门评论数量不足(如只有 50 条),刷完后直接进入按时间排序的评论列表。
  • 按时间排序的列表中仍可能包含热门评论,因此用户可能会看到重复评论。

3. 用 Redis ZSET 维护热门评论 ID 列表

无论数据库方案还是图数据库方案,一级评论通常已经按发布时间排序。

在这个基础上很难同时按加权热度排序。

因此,可以使用独立存储系统保存热门评论 ID 列表。例如使用 Redis ZSET:

Key:hot_comment_{content_id}
Member:评论 ID
Score:评论热度值

这个 ZSET 从全量评论元信息数据库中筛选出热门评论 ID,并按热度排序存储。

4. 实时构建热门评论

如果热度由点赞数和回复数加权计算,那么评论收到点赞、回复时,热度都会变化。

所以需要监听:

评论点赞事件
评论回复事件
评论删除事件

创建消费者服务 hot_comment_trigger,通过消息队列监听这些事件,负责更新热门评论列表。

以内容 1 的一级评论 22 为例:

当前点赞数:10
当前回复数:8
热度值:10 * 0.4 + 8 * 0.6 = 8.8

评论 22 收到点赞后:

点赞数变为 11
消费者收到点赞事件
从 content_comment 表获取最新点赞数和回复数:11 和 8
重新计算热度:11 * 0.4 + 8 * 0.6 = 9.2
更新 Redis:
ZADD hot_comment_1 9.2 22

评论 22 又被回复一次后:

回复数变为 9
重新计算热度:11 * 0.4 + 9 * 0.6 = 9.8
更新 Redis:
ZADD hot_comment_1 9.8 22

评论 22 被删除后:

ZREM hot_comment_1 22

如下图所示。

image-20260531230925104

5. 热度序列表和时间序列表的分页组合

按热度排序的评论区,并不是所有评论都按热度展示。

它通常是两类列表组合:

热度序评论列表
时间序评论列表

示例:

每页 10 条评论
内容 1 有 16 条热门评论

分页过程:

第 1 页:
全部来自热度序评论列表
返回热度排名前 10 条

第 2 页:
剩余 6 条热门评论
再从时间序评论列表补 4 条

第 3 页:
已经进入时间序评论列表
返回时间序评论列表第 5 ~ 14 条

这里有几个关键点:

第 1 页一定先读热度序评论列表

其他页可以根据上一页最后一条评论的来源决定本次读取来源

从热度序切换到时间序时
offset 要重置为时间序评论列表的 offset

因此,评论服务返回评论数据时,还需要返回最后一条评论的来源。

客户端请求评论列表时,也要带上一个参数:

from_where

可选值:

hot:从热度序评论列表读取
time:从时间序评论列表读取

示例流程:

第 1 页请求:
content_id = 1
offset = 0
from_where = hot

评论服务执行:
ZREVRANGE hot_comment_1 0 9

响应:
from_where = hot
offset = 10

第 2 页:

请求:
content_id = 1
offset = 10
from_where = hot

评论服务执行:
ZREVRANGE hot_comment_1 10 19

只得到 6 条热门评论
再从时间序列表读取前 4 条:
LIMIT 0, 4

响应:
from_where = time
offset = 4

第 3 页:

请求:
content_id = 1
offset = 4
from_where = time

评论服务读取时间序列表:
LIMIT 4, 10

响应:
from_where = time
offset = 14

这样客户端分页读取评论列表时,可以平滑地从热度序切换到时间序。

6. 控制热门评论总数

一个内容评论区中,热门评论可能超过 1000 条。

没有必要在 Redis ZSET 中保存全部热门评论,只保留热度排名前 1000 即可。

这样做有两个好处:

节省 Redis 内存
避免 ZSET 成为大 Key

截断方法可以用周期性脚本执行,例如每 10s 做一次:

1. 执行 ZCARD hot_comment_{content_id},得到 ZSET 长度 N
2. 如果 N > 1000:
执行 ZREMRANGEBYRANK hot_comment_{content_id} 0 N-1000-1

因为 ZSET 按 Score 从小到大排列,所以删除前 N - 1000 个成员后,保留下来的就是热度 Top 1000。

这里以 Redis ZSET 为例,并不表示它一定是最优方案,图数据库也同样是一种可行方案。

12.6.6 高并发处理

二级模式评论同样存在高并发问题。

1. 高并发写评论

高并发写评论依然使用前面讨论过的方案:

异步写
写聚合

它与单级模式在 12.4.4 节中的处理思路一致。

异步写

在评论服务和数据库之间引入消息队列:

评论服务
↓ 写评论请求发送到 push_comment 消息队列
发评消费者
↓ 消费消息并逐步写入数据库
数据库

评论服务把写评论请求写入 push_comment 消息队列后,就可以成功响应用户。

发评消费者再慢慢把评论元信息插入数据库。

这样可以把高并发写请求削峰为数据库能够处理的平滑流量。

写聚合

发评消费者可以每隔 10s 按内容 ID 聚合写评论请求。

例如某 10s 内收到了 100 个对内容 111 的写评论请求:

原来:100 条 SQL,访问数据库 100 次
聚合后:1 条批量 SQL,访问数据库 1 次

这样可以提高消费者处理速度,降低数据库访问压力。

2. 读评论的完整流程

前面讨论的读评论流程其实还不完整。

读取热度序评论列表时,Redis ZSET 只返回排好序的评论 ID,还需要读取这些评论对应的元信息,以及从分布式 KV 存储系统中获取这些评论的文本

读取时间序评论列表时,数据库虽然返回了评论元信息,但仍然要获取评论文本。

因此,构建完整评论列表分为三步:

1. 获取应该展示的评论 ID 列表
来源可能是热度序评论列表或时间序评论列表

2. 批量获取评论元信息
来源是数据库

3. 批量获取评论文本
来源是分布式 KV 存储系统

评论元信息表通过索引实现了按时间排序,因此时间序场景下,前两步可以合并执行。

构建完整评论列表如下图所示。

image-20260601133129411

3. 读评论的瓶颈

大多数高并发读取请求访问的是评论区前几页 ,毕竟很少有用户会花时间一直刷一个评论区。

前几页评论可能来自 热度序评论列表、时间序评论列表、两者的组合。 无论是热度序评论列表还是时间序评论列表,都可能会面临高并发请 求的问题。

两种列表的瓶颈不同:

热度序评论列表:
读取热门评论 ID 列表访问 Redis ZSET,高并发性能问题不大
但根据评论 ID 批量获取评论元信息要访问数据库,数据库有风险

时间序评论列表:
获取时间序评论 ID 和评论元信息都需要直接访问数据库
数据库同样无法应对高并发请求

所以,共同瓶颈是:

存储评论元信息的数据库

4. Redis 缓存设计

需要为评论元信息构建 Redis 缓存。

但热度序和时间序对缓存的访问形式不同:

热度序评论列表:
希望根据评论 ID 批量获取评论元信息

时间序评论列表:
希望获取按时间排序的评论 ID 列表
同时还希望批量获取评论元信息

单个 Redis 对象无法高效兼顾两种访问形式,因此使用两种 Redis 对象。

时间序评论 ID 列表缓存

使用 ZSET 存储某条内容前 N 条最近一级评论 ID:

Key:cache_time_{content_id}
Member:评论 ID
Score:评论发布时间

评论元信息缓存

使用 String 存储每条一级评论的元信息。

Key:meta_{content_id}_{comment_id}
Value:评论元信息 JSON

批量获取时使用 MGET。在 Redis 集群中,MGET 的多个 Key 如果位于同一个分片,性能最好。因此,可以为 meta_{content_id} 前缀打 hashtag 标签,让同一条内容下的一级评论元信息落到同一个 Redis 分片中。

另外,还可以把评论文本作为评论元信息的一个属性,一起放入 String 对象中,减少对分布式 KV 存储系统的访问,再次提升读取性能。

5. 本地缓存

为了减轻 Redis 这个中心化缓存的压力,还可以引入本地缓存。

评论服务把最近访问量较大的数据缓存在本地内存:

如果某内容评论区访问量较大:
本地缓存前 N 条热度序评论 ID 列表
本地缓存前 N 条时间序评论 ID 列表

如果某条评论最近曝光量较大:
本地缓存这条评论的完整数据

6. 读取评论列表的最终流程

最终读评论架构由以下部分组成:

本地缓存
Redis
数据库
图数据库

如下图所示。

image-20260601144434349

最终评论服务处理“读取评论列表”的请求时,主要依赖 4 类存储:

  1. 本地缓存
    • 缓存在评论服务进程内部。
    • 访问速度最快。
    • 用来缓存热点数据
  2. Redis 缓存
    • 访问速度比本地缓存慢,但比数据库快。
    • 用来缓存:
      • 热度序评论列表,使用 Redis ZSET
      • 时间序评论列表,使用 Redis ZSET
      • 评论内容或评论元信息,使用 Redis String
  3. 评论元信息数据库
    • 存储评论的结构化信息。
    • 主要用于当缓存未命中时兜底查询。
  4. 评论文本分布式 KV 存储系统
    • 存储评论正文。
    • 评论正文通常是大字段,不适合直接和评论元信息全部放在关系型数据库中。
    • 所以单独放在分布式 KV 存储系统中。

当用户访问某页的 10 条评论时,评论服务先执行第一部分流程,即获取评论 ID 列表。

  1. 检查请求希望访问的数据方向,如果要访问热度序评论列表,则执行下一步;如果要访问时间序评论列表,则执行第 4 步。
  2. 访问热度序评论列表的本地缓存,如果命中本地缓存,则执行下一步;否则,进一步访问热度序评论列表的 Redis 缓存。
  3. 如果可以得到 10 条热门评论数据,则完成此流程;如果得不到相应的数据,或者剩余的热门评论数量不足 10 条,则继续访问时间序评论列表。
  4. 准备访问时间序评论列表,先访问时间序评论列表的本地缓存,如果命中本地缓存,则完成此流程。
  5. 如果未命中本地缓存,则访问时间序评论列表的 Redis 缓存;如果命中 Redis 缓存,则完成此流程。
  6. 如果未命中 Redis 缓存,则访问数据库,得到评论 ID 列表。
热度序列表请求:
热度序本地缓存
↓ 未命中
热度序 Redis ZSET
↓ 不足 10 条
时间序本地缓存
↓ 未命中
时间序 Redis ZSET
↓ 未命中
数据库

时间序列表请求:
时间序本地缓存
↓ 未命中
时间序 Redis ZSET
↓ 未命中
数据库

这一阶段只获取评论 ID,不获取完整评论内容。


在执行完第一部分流程后,如果评论服务获取到若干评论 ID 列表,则继续执行第二部分流程,即批量获取评论元信息。

  1. 访问评论元信息的本地缓存,如果所有评论都命中本地缓存,则直接返回。
  2. 对于未命中本地缓存的评论 ID,则通过这些评论 ID 继续访问 Redis 缓存。
  3. 访问 Redis 缓存批量获取评论元信息,如果所有评论都命中 Redis 缓存,则直接返回。
  4. 对于未命中 Redis 缓存的评论 ID,则访问数据库获取评论元信息。
  5. 将访问数据库得到的评论元信息结果存储到 Redis 缓存和本地缓存中。
评论 ID 列表

查本地缓存中的评论元信息
↓ 未全部命中
查 Redis 中的评论元信息
↓ 未全部命中
查数据库

回填 Redis 缓存和本地缓存

在执行完第二部分流程后,如果每条评论都来自本地缓存或 Redis 缓存,则说明已经得到评论文本,可以直接返回响应;否则,对于那些需要访问数据库才能得到的评论,继续执行第三部分流程,即批量获取评论文本。

  1. 批量从分布式 KV 存储系统中读取评论文本。
  2. 将评论文本加入 Redis 缓存的评论元信息数据库中。
  3. 将评论文本加入本地缓存的评论元信息数据库中。

在执行完全部流程后,评论服务将最终数据返回。

检查评论正文是否已经在本地缓存或 Redis 缓存中
↓ 如果缺失
批量访问分布式 KV 存储系统

获取评论正文

回填 Redis 缓存和本地缓存

关键设计思想

多级缓存降低数据库压力

该架构使用了两级缓存:

本地缓存 → Redis 缓存 → 数据库 / 分布式 KV

本地缓存最快,Redis 次之,数据库和分布式 KV 是最终数据来源。

这样设计可以减少数据库直接访问次数,提升评论列表读取性能。


12.6.7 架构总览

评论服务架构可以从写评论、审核、读评论三条主线理解。

1. 评论数据拆分

评论数据分为:

评论元信息:
发布者、发布时间、点赞数、回复数等
适合数据库存储

评论文本:
用户实际发布的评论内容
适合分布式 KV 存储

2. 发布评论

发布评论逻辑相对简单:

创建评论

评论文本写入分布式 KV 存储

评论元信息写入数据库

如果发布评论请求量超过数据库承载能力,则使用异步写和写聚合策略:评论服务把新评论放入消息队列,由消费者均匀写入数据库和分布式 KV 存储。 如果某条内容被多人评论,可以聚合这些创建请求,降低数据库访问压力。

3. 评论审核

评论审核需要支持关键词搜索评论。这个功能适合 Elasticsearch。

每当评论被创建,评论元信息数据库都需要通过伪从关系通知 Elasticsearch,让它新增可搜索的评论数据。,所以在Elasticsearch与评论元信息数据库之间也需要建立伪从关系。

4. 读取评论

读取评论则是最复杂的逻辑,无法进行简单处理,其复杂性主要受如下影响:

  1. 用户可以读取某条内容下的评论列表和自己发布的评论:前者以内容为起点,后者以用户为起点。为了保持分库分表的高效率,在评论元信息数据库中需冗余建立两张数据表:

    • content_comment(面向内容的主表)
    • user_comment(面向用户的伪从表,用于同步评论元信息数据)
  2. 评论层级区分:读取某条内容的评论列表时,需区分一级评论列表和二级评论列表:

    • 一级评论:回复的是内容
    • 二级评论:回复的是一级评论或另一条二级评论
      实现方式:
    • 在关系型数据库中,可引入 level 字段来表示评论的层级
    • 也可使用图数据库来专门描述这种回复关系
  3. 一级评论的排序需求:某条内容的一级评论可以按照热度排序,评论区通常是热度序评论列表与时间序评论列表的组合:

    • 热度序评论 ID 列表:需要使用专门的存储系统(如 Redis ZSET 对象)来维护
    • 热度更新:每当有影响评论热度的事件发生时,需通过异步事件消费者重新计算和更新评论热度,以保证热度序评论列表的实时性
  4. 读取评论列表的高并发问题:读取评论列表分为三步:读取评论 ID 列表(时间序/热度序)、读取评论元信息、读取评论文本。为应对高并发,需要多级缓存:

    • 缓存对象:每条评论数据、某条内容的时间序评论 ID 列表
    • 缓存形式:Redis 缓存 + 本地缓存
    • Redis 存储选型:
      • 评论数据:使用 String 对象存储
      • 评论 ID 列表:适合使用 ZSET 对象存储

最终架构图

image-20260601170158082

flowchart LR

A[内容的评论列表] --> LC

subgraph LC[本地缓存]
LC1[热度序评论 ID 列表]
LC2[时间序评论 ID 列表]
LC3[评论数据]
end

subgraph R[Redis]
R1[热度序评论 ID 列表<br/>Redis ZSET]
R2[时间序评论 ID 列表<br/>Redis ZSET]
R3[评论数据<br/>String]
end

LC1 --> R1
LC2 --> R2
LC3 --> R3


H[更新评论热度消费者] --> R1


P[发布评论] --> C[创建评论]
P -. 异步写 .-> CC[创建评论消费者]
CC -. 写聚合 .-> C

subgraph DB[评论元信息数据库]
D1[content_comment 数据表]
D2[user_comment 数据表]
D3[回复关系图数据库]
end

C --> D1
C --> KV[评论文本<br/>分布式 KV 存储系统]

R2 --> D1
R3 --> D1

D1 --> D2
D1 --> D3
D1 --> ES[Elasticsearch]


Review[评论审核] --> ES
U[用户发布的评论] --> D2

本节总结

二级模式是最常见、最被广泛接受的评论模式。

它需要同时处理:

一级评论和二级评论建模
按时间排序
按热度排序
评论审核与状态过滤
高并发读写
缓存和分库分表

关键设计点:

用 level 区分一级评论和二级评论
用 root_id 找到内容评论区或一级评论的二级评论区
用 content_comment / user_comment 解决不同查询入口的分库分表问题
用 Redis ZSET 维护热度序和时间序评论 ID 列表
用 Redis String 和本地缓存保存评论完整信息
用异步事件消费者维护热门评论实时排名

12.7 本章小结

评论可以拆成:评论元信息 + 评论文本

评论文本适合存储在分布式 KV 存储系统中。

评论元信息,则需要考虑存储选型和数据模型设计。


本章围绕三种评论列表模式展开。

1. 单级模式

单级模式是最简单的评论列表模式。

它能基本满足评论功能的互动诉求:

内容发布者知道哪些用户发布了评论
评论发布者知道谁回复了自己的评论

单级模式可以直接基于数据库实现,只需要记录:

评论回复了哪条内容
评论回复了哪条评论

它适合不强调社交属性的互联网应用,定位是满足基本评论功能。

2. 盖楼模式

盖楼模式是一种特殊的评论列表模式,通过每条评论都可以回溯到完整的回复链路,它更像是一种接力。

数据库实现盖楼模式比较吃力。因为追溯一条评论的完整回复链路要么需要借助低效的递归查询,要么需要把完整的回复链路保存下来,所以数据库只适合那种楼层注定不会太高的场景。

对于楼层较高的场景,图数据库更合适,因为盖楼评论本质上就是沿回复关系做深度遍历。

3. 二级模式

二级模式是最常见、最被认可的评论列表模式。

它可以清楚反映:

谁对内容发表了意见
谁对评论发表了意见

它互动性强,也方便按照热度排序,广泛用于面向用户社交的互联网应用。

如果使用数据库方案,需要记录:

评论回复对象
评论层级:一级评论还是二级评论

如果使用图数据库,则可以把内容与评论组成回复关系图。

在二级模式中,按热度排序的评论列表并不是单一列表,而是:

优先展示热门评论
再展示时间序评论

评论服务应该为热度序评论列表和时间序评论列表分别建立评论 ID 列表存储系统,并可以明确区分什么时候访问热度序评论列表,什么时候访问时间序评论列表。

4. 评论审核

评论是用户发布的主观言论,审核必不可少。

评论审核包括:

机审
人审

不合规评论需要删除或标记为不可见。

评论状态会影响不同用户能否看到同一条评论。

5. 高并发读写

评论功能注定是读多写多场景。

高并发写评论:

消息队列异步写,削峰填谷
对同一内容的评论做写聚合,减少写请求量

高并发读评论:

拆成 3 步:
1. 获取评论 ID 列表
2. 批量获取评论元信息
3. 批量获取评论文本

通过分析,可以对热度序评论ID列表、时间序评论ID列表和每条评论的完整信息进行适当的缓存,可能的缓存形式包括Redis缓存和对最近热门内容、热门评论的本地缓存。