16. Design Comment System
Reference: B 站评论系统架构设计
01 基础功能模块
- 基础功能
- 发布评论:支持无限盖楼回复。
- 读取评论:按照时间、热度排序;显示评论数、楼中楼等。
- 删除评论:用户删除、UP 主删除等。
- 评论互动:点赞、点踩、举报等。
- 管理评论:置顶、精选、后台运营管理(搜索、删除、审核等)。
- 进阶功能
- 评论富文本展示:例如表情、@、分享链接、广告等。
- 评论标签:例如 UP 主点赞、UP 主回复、好友点赞等。
- 评论装扮:一般用于凸显发评人的身份等。
- 热评管理:结合 AI 和人工,为用户营造更好的评论区氛围。
02 架构设计
评论是主体内容的外延。因此一般会作为一个独立系统拆分设计。
2.1 架构设计 - 概览

2.2 架构设计 - reply-interface
- reply-interface 是评论系统的接入层,主要服务于两种调用者:
- 客户端的评论组件
- 基于评论系统做二次开发或存在业务关联的其他业务后端。
- 面向移动端/WEB 场景
- 设计一套基于视图模型的 API,利用客户端提供的布局能力。
- BFF 层负责组织业务数据模型,并转换为视图模型,编排后下发给客户端。
- 面向服务端场景
- 设计的 API 需要体现清晰的系统边界,最小可用原则对外提供数据。
- 同时做好安全校验和流量控制。
"BFF" 层在移动端或 WEB 应用场景中指的是 "Backend For Frontend",即“面向前端的后端”。BFF 层的引入是为了更好地适应不同前端的需求,实现数据的有效管理和优化的数据传输,从而提升整个应用的性能和用户体验。
- 移动端和 WEB 端请求
- 移动端可能只需要产品的基本信息和优化过的图片。
- 网页端可能需要更详细的产品信息和更高分辨率的图片。
- BFF 层处理
- 对于移动端的请求,BFF 层会从后端服务获取产品信息,然后筛选出移动端需要的字段,并可能对图片进行压缩。
- 对于网页端的请求,BFF 层提供完整的产品信息和高分辨率的图片。

对评论业务来说,业务数据模型是最为复杂的。最核心的是发布类接口以及列表类接口,一写一读,数据字段多、依赖服务多、调用关系复杂,特别是一些依赖的变更,容易造成整个系统的腐化。
因此,我们将整个业务数据模型组装,分为两个步骤。
- 服务编排
- 服务编排拆分为若干个层级,同一层级的可以并发调用,前置依赖较多的可以流水线调用,结构性提升了复杂调用场景下的接口性能下限
- 针对不同依赖服务所提供的 SLA 不同,设置不同的降级处理、超时控制和服务限流方案,保证少数弱依赖抖动甚至完全不可用情况下评论服务可用
- 数据组装
- 数据组装在服务编排之后执行。
- 例如在批量查询评论发布人的粉丝勋章数据之后,将其转换、组装到各个评论卡片之中。
2.3 架构设计 - reply-admin
评论管理服务层,为多 个内部管理后台提供服务。支持运营人员的数据查询。
- 组合、关联查询条件复杂
- 关键词检索能力
- 写后读的可靠性与实时性要求高等特征
此类查询需求,ES 几乎是不二选择。但是由于业务数据量较大,需要为多个不同的查询场景建立多种索引分片,且数据更新实时性不高。因此,我们基于 ES 做了一层封装,提供统一化的数据检索能力,并结合在线数据库刷新部分实时性要求较高的字段。

2.4 架构设计 - reply-service
评论基础服务层,专注于评论功能的原子化实现。
- 查询评论列表、删除评论等。
- 这一层是较少做业务逻辑变更的,但是需要提供极高的可用性与性能吞吐。
- 因此,reply-service 集成了多级缓存、布隆过滤器、热点探测等性能优化手段。

2.5 架构设计 - reply-job

评论异 步处理层,主要有两个职责
职责 1. 与 reply-service 协同,为评论基础功能的原子化实现,做架构上的补充。
- 为什么基础功能的原子化实现需要架构的补充呢?
- 典型案例:缓存更新(Cache Aside 模式)
-
- 读取数据: 先读缓存
-
- 缓存未命中: 如果 cache miss, 从 DB 读取数据
-
- 反写缓存: 从 DB 读取到内容之后反写缓存。
-
- 缓存重建的代价
- 对于数据结构较为复杂的数据项(如分页的评论列表),重建缓存(即从数据库读取数据并存入缓存)的代价可能很高。
- 数据量大: 比如分页的列表,可能包含大量数据。
- 预加载机制: 为了提高效率,有时在重建缓存时会加载比当前请求更多的数据 (Pre-load),这增加了数据库的负担。
- 数据库抖动: 多个服务节点在短时间内频繁请求缓存未命中的数据时,这些请求都会穿透到数据库。数据库将经历高负载,可能导致性能下降或不稳定。
解决方案 - 使用消息队列实现 "单个评论列表 重建一次缓存"
- 引入 MQ: 当 cache miss 时,不再直接从数据库读取 数据,而是将请求放入消息队列。
- MQ Consumer:
- 消费消息,开始处理缓存重建的任务。
- 消费者需要保证,即使多个请求同时发生缓存未命中,针对同一数据(如同一评论列表)的缓存重建操作只会被执行一次。
- 有效减少了对 DB 的重复查询。
如何保证多个请求对同一数据的缓存重建操作只执行一次?
-
- 消息去重 (Deduplication)
- 2. 使用版本控制或时间戳
- 版本号: 为缓存中的每个数据项分配一个版本号。每次数据更新时,版本号增加。消费者在处理前检查当前版本号,如果版本号比它最初读取时的版本号高,说明数据已被更新。
- 时间戳: 每个缓存项可以包含一个时间戳,标记最后一次更新的时间。消费者可以通过比较时间戳来判断缓存是否已更新。
Consumer 的单线程处理特性: 每个消费者 在任何给定时间只处理一个消息。这种单线程处理特性在解决高并发数据库访问问题时非常重要。
- 1. 顺序处理
- 避免竞争条件: 在高并发环境中,多个进程或线程同时访问和修改同一个 DB 可能会导致竞争条件。这可能导致数据不一致或其他类型的错误。
- 有序访问: 单线程消费者按照接收到消息的顺序处理它们,从而确保了对 DB 的访问是有序的。
- 2. 控制 DB 负载
- 限制请求数量:由于每个消费者同时只处理一个请求,这自然限制了对数据库的请求量,从而避免了因并发高负载导致的数据库性能问题(如数据库抖动)。
解决方案 - MQ Consumer 消费 Binlog
- 当数据库中的数据发生变化时 (如新评论的添加),这些变化会被记录在 binlog 中。
- 消费者读取这些 binlog 记录,并据此更新缓存,确保缓存中的数据与数据库保持一致。
职责 2. 与 reply-interface 协同,为一些长耗时/高吞吐的调用做异步化/削峰处理。
诸如评论发布等操作,基于安全/策略考量,会有非常重的前置调用逻辑。对于用户来说,这个长耗时几乎是不可接受的。同时,时事热点容易造成发评论的瞬间峰值流量。
- 因此,
reply-interface在处理完一些必要校验逻辑之后,会通过消息队列送至reply-job异步处理,包括评论安全审查、写 DB、通知用户等。 - 这里也利用了消息队列的「有序」特性,将单个评论区内的发评串行处理,避免了并行处理导致的一些数据错乱风险。那么异步处理后用户体验是如何保证的呢?首先是 C 端的发评接口会返回展示新评论所需的数据内容,客户端据此展示新评论,完成一次用户交互。
- 若用户重新刷新页面,因为发评的异步处理端到端延迟基本在 2s 以内,此时所有数据已准备好,不会影响用户体验。
- 另外。对于评论的楼层号功能,本质上是评论区内的计数器。计数操作必须是串行的,即两条同时发布的评论,获取的楼层号不能重复,因此需要放到 MQ 中处理。
03 Storage 设计
3.1 Database 设计
评论系统对数据库的选型要求,有两个基本且重要的特征:
- 必须有事务
- 必须容量大
一开始,我们采用的是 MySQL 分表来满足这两个需求。但随着 B 站社区破圈起量,原来的 MySQL 分表架构很快到达存储瓶颈。于是从 2020 年起,我们逐步迁移到 TiDB,从而具备了水平扩容能力。

Table1 - 评论表 comments
- 主键是
comment_id - 关键索引是
comment_section_id - 逻辑删除: 不实际从数据库中删除一级评论的所有回复记录,而是通过一种标记
SET comment_status = 'deleted'来标识它们已被删除。 - 计数更新: 删除一个一级评论时,需要更新评论区的计数信息。需要从评论区的总评论数中减去该一级评论及其所有回复的数量。
- 维护用户体验。用户在查看评论区的总评论数时,期望这个数字反映的是当前可见的评论数量。
- 保持数据一致性。
CREATE TABLE comments (
comment_id INT AUTO_INCREMENT PRIMARY KEY, -- 主键,评论ID
comment_section_id INT NOT NULL, -- 评论区ID,用于关联评论区表
publisher_id INT NOT NULL, -- 发布人ID,关系类字段
parent_comment_id INT, -- 父评论ID,关系类字段
root_comment_id INT, -- 根评论ID,关系类字段,表示评论的根源
dialog_id INT, -- 对话ID,用于表示属于同一对话的评论集
total_comments_count INT DEFAULT 0, -- 总评论数,计数类字段
root_comments_count INT DEFAULT 0, -- 根评论数,计数类字段
child_comments_count INT DEFAULT 0, -- 子评论数,计数类字段
comment_status ENUM('normal', 'review', 'deleted') NOT NULL, -- 评论状态,状态类字段
comment_attributes INT, -- 评论属性,状态类字段(bitmap)
meta_data JSON -- 其他信息,例如关键的附属信息
) ENGINE=InnoDB;
-
- 关系类,包括发布人、父评论等,这些关系型数据是发布时已经确定的,基本不会修改。
-
- 计数类,包括总评论数、根评论数、子评论数等,一般会在有评论发布或者删除时修改。
-
- 状态类,包括评论/评论区状态、评论/评论区属性等,评论/评论区状态是一个枚举值,描述的是正常、审核、删除等可见性状态;评论/评论区属性是一个整型的
bitmap,可用于描述 评论/评论区的一些关键属性,例如 UP 主点赞等。
- 状态类,包括评论/评论区状态、评论/评论区属性等,评论/评论区状态是一个枚举值,描述的是正常、审核、删除等可见性状态;评论/评论区属性是一个整型的
-
- 其他,包括 meta 等,可用于存储一些关键的附属信息。
在 SQL 中使用整型字段(如 INT)来表示一个位图(bitmap)是一种常见的做法,尤其是在需要存储多个布尔值(是/否)类型的数据时。
comment_attributes 字段被定义为 INT 类型,用于存储多个评论属性。每个属性占用整数中的一位。每个属性都可以映射到整数的一个特定位。
- UP 主点赞 → 第 0 位
- 已审核 → 第 1 位
- 星标评论 → 第 2 位
- ...
-- 在二进制中,每一位的值是 2 的幂. 2^0 = 1, 2^1 = 2, 2^2 = 4.
-- 故此处第一位是 2^1 = 2
-- 设置 "已审核"
UPDATE comments SET comment_attributes = comment_attributes | 2 WHERE comment_id = 1;
-- 检查某个评论是否 "已审核"
SELECT comment_id FROM comments WHERE comment_attributes & 2;
-- 清除 "已审核"属性
UPDATE comments SET comment_attributes = comment_attributes & ~2 WHERE comment_id = 1;
评论回复的树形结构设计

id -> comment_id INT AUTO_INCREMENT PRIMARY KEY, -- 主键,评论ID
parent -> parent_comment_id INT, -- 父评论ID,关系类字段
root -> root_comment_id INT, -- 根评论ID,关系类字段,表示评论的根源
dialog -> dialog_id INT, -- 对话ID,用于表示属于同一对话的评论集
Table2 - 评论区表 comment_sections
- 主键是
comment_section_id - 平台化之后增加一个评论区 type 字段,与评论区 id 组成一个”联合主键“。
CREATE TABLE comment_sections (
comment_section_id INT AUTO_INCREMENT PRIMARY KEY, -- 主键,评论区ID
comment_section_type ENUM('type1', 'type2', 'type3'), -- 评论区类型,平台化字段
comment_section_status ENUM('active', 'inactive', 'archived'), -- 评论区状态,描述评论区的当前状态
comment_section_attributes INT, -- 评论区属性,状态类字段(bitmap),用于表示评论区的一些设置或标志
total_comments_count INT DEFAULT 0, -- 总评论数,计数类字段
total_active_comments_count INT DEFAULT 0, -- 总活跃评论数,计数类字段
total_hidden_comments_count INT DEFAULT 0, -- 总隐藏评论数,计数类字段
meta_data JSON, -- 其他元数据,可用于存储额外的信息
UNIQUE KEY comment_section_id_type (comment_section_id, comment_section_type) -- 联合主键
) ENGINE=InnoDB;
Table3 - 评论内容表 comment_contents
- 主键是评论 id
CREATE TABLE comment_contents (
comment_id INT PRIMARY KEY, -- 主键,评论ID,与评论表的主键相同
content TEXT NOT NULL -- 评论内容
) ENGINE=InnoDB;
SQL 查询实例
-- 查询评论区基础信息
SELECT * FROM comment_sections WHERE comment_section_id=? AND comment_section_type=?
-- 查询时间序一级评论列表
-- ps: floor 常用来表示评论的“楼层”,即该评论在所有评论中的位置或顺序。
SELECT comment_id FROM comments WHERE comment_section_id=? AND root_comment_id=0 AND comment_status='normal' ORDER BY floor=? LIMIT 0,20
-- 查询楼中楼的评论列表
SELECT comment_id FROM comments WHERE comment_section_id=? AND root_comment_id=? ORDER BY like_count LIMIT 0,3
3.2 Cache 设计
缓存的一致性依赖 binlog 刷新
- binlog 会发送到消息队列,分片 key 选择的是评论区
comment_section_id,保证单个评论区和单个评论的更新操作是串行的,消费者顺序执行,保证对同一个 member 的zadd和zrem操作不会顺序错乱。 - 数据库更新后,程序主动写缓存和 binlog 刷缓存,都采用删除缓存而非直接更新的方式,避免并发写操作时,特别是诸如 binlog 延迟、网络抖动等异常场景下的数据错乱。那大量写操作后读操作缓存命中率低的问题如何解决呢?此时可以利用
singleflight进行控制,防止缓存击穿。
singleflight 是一种编程模式,用于控制对于同一个资源的多次重复请求。
- 当多个相同的缓存请求(比如请求同一条数据)同时发生时,singleflight 确保只有一个请求会去数据库读取数据并更新缓存。
- 其他等待这个请求的操作将会等待这个请求完成,并共享相同的响应结果。
- 这种方式有效地减少了对数据库的访问次数,即使在缓存经常被删除的情况下也能维持较高的缓存命中率。
Cache1 - 评论区基础信息 comment_sections
- Redis Data Type: String
- Redis Value: 评论区的基础信息,如评论区 ID、类型等,使用 JSON 格式序列化后存储。
{
"key": "comment_section:123",
"value": {
"comment_section_id": 123,
"comment_section_type": "type1",
"comment_section_status": "active",
"comment_section_attributes": 7,
"total_comments_count": 340,
"meta_data": {
"additional_info": "some data"
}
}
}
Cache2 - 某个评论旗下的评论列表

- Redis Data Type: Sorted Set 有序集合
- Redis Value: 以用于排序的字段 (如发布时间、点赞数等)作为分数 score
- 为了保证数据完整性,必须要判定
key存在才能增量追加。 - 由于存在性判定和增量追加不是原子化的,判定存在后、增量追加前可能 出现缓存过期,因此选用 redis 的 EXPIRE 命令来执行存在性判定,避免此类极端情况导致的数据缺失。
{
"key": "comments:123",
"value": [
{ "member": "comment_id:789", "score": 200 },
{ "member": "comment_id:456", "score": 150 },
{ "member": "comment_id:101112", "score": 250 }
]
}
Cache3 - 某个评论的 meta-data 与内容
{
"key": "comment_content:789",
"value": {
"comment_id": 789,
"comment_section_id": 123,
"publisher_id": 42,
"parent_comment_id": null,
"root_comment_id": null,
"comment_status": "normal",
"comment_attributes": 0,
"body": "This is a comment body text",
"timestamp": "2021-07-16T19:20:30+01:00"
}
}
{
"key": "comment_content:456",
"value": {
"comment_id": 456,
"comment_section_id": 123,
"publisher_id": 27,
"parent_comment_id": 789,
"root_comment_id": 789,
"comment_status": "normal",
"comment_attributes": 0,
"body": "Another comment body text",
"timestamp": "2021-07-16T20:20:30+01:00"
}
}
04 可用性设计
4.1 写热点与读热点

相较于 2020 年之前的吞吐量的评论系统。我们有如下优化:
写热点优化
- 内存合并与批量写入:
- 评论区评论计数的更新,先在内存进行计数的合并。不是每次评论发生时都立即更新数据库,而是先在内存中累积这些更改,然后定期或达到一定条件时批量更新数据库。
- 这减少了 SQL 执行的次数。可以减少热点场景下的 SQL 执行条数。
- 非 DB 的业务异步执行:
- 非数据库写操作的其他业务逻辑,拆分为前置和后置两部分。
- 从数据写入主线程中剥离,交由其他的线程池并发执行。