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 的业务异步执行:
- 非数据库写操作的其他业务逻辑,拆分为前置和后置两部分。
- 从数据写入主线程中剥离,交由其他的线程池并发执行。
改造后,系统的并发处理能力有了极大提升,同时支持配置并行度/聚合粒度,在吞吐方面具备更大的弹性,热点评论区发评论的 TPS 提升了 10 倍以上。
读热点典型特征
- 由于大量接口都需要读取评论区基础信息,存在读放大,因此该操作是最先感知到读热点存在的。
- 由于评论业务的下游依赖较多,且多是批量查询,对下游来说也是读放大。此外,很多依赖是体量相对小的业务单元,数据稀疏,难以承载评论的大流量。
- 评论的读热点集中在评论列表的第一页,以及热评的热评。
- 评论列表的业务数据模型也包含部分个性化信息
读特点优化
因此,我们利用《直播场景下 高并发的热点处理实践》[5]一文所使用的 SDK,在读取评论区基础信息阶段探测热点,并将热点标识传递至 BFF 层;BFF 层实现了页面请求级的热点本地缓存,感知到热点后即读取本地缓存,然后再加载个性化信息。
热点探测的实现基于单机的滑动窗口 + LFU(Least Frequently Used)
LFU (Least Frequently Used): 最少使用频率算法,用于跟踪数据项被访问的频率。与最近最少使用(LRU)不同,LFU 关注的是总的访问频 率,而不仅仅是最近的访问。
即 跟踪每个数据项在滑动窗口期间的访问频率,并使用 LFU 算法确定其是否达到了热点阈值。
定义和计算热点条件阈值
- 首先进行系统容量设计,列出容量计算的数学公式
- 各接口 QPS 的关系
- 服务集群总 QPS 与节点数的关系
- 接口 QPS 与 CPU / 网络吞吐的关系等
- 收集系统内部以及相应依赖方的一些的热点相关统计信息,通过前面列出的公式,
- 定义热点阈值:
- 热点阈值是指一个数据项成为热点之前可以接受的最大访问频率。
- 超过这个频率,数据项就被认为是热点。
- 计算热点阈值:
- 这通常涉及分析系统的性能指标和历史数据。通过考虑系统的最大承载能力和期望的性能目标,可以设定一个合适的阈值。
- 例如,你可能会观察在系统开始出现性能下降之前,单个数据项的最高访问频率是多少,然后将其设为热点阈值。
4.2 冗余与降级
策略 1: 评论基础服务层的多级缓存
- 上一级缓存未命中的降级: 如果一个请求在上一级(更快、通常离用户更近的)缓存中未命中,系统将会尝试在下一级(可能更慢、离用户更远)缓存中查找所需数据。
- 保障基础体验: 各级缓存可能有功能上的略微差异,但都能保障用户的基础体验。
策略 2: 同城读双活架构
- 双机房独立部署: 数据库和缓存在两个不同的机房中各自独立部署,每个机房都有多个数据副本,提供了高可用性和故障转移能力。
- 水平扩容的弹性: 系统支持根据负载情况动态增加或减少资源
- 处理副机房数据延迟故障: 针对双机房架构下特有的副机房数据延迟故障,支持多个策略,尽可能保证极端情况下用户无感。
- 入口层切流。如果一个机房出现数据延迟或其他问题,流量可以被重定向到另一个机房。
- 跨机房重试。当一个请求在一个机房内部处理失败时,系统会自动尝试在另一个机房重新处理该请求。
- 应用层补偿。若机房的数据更新稍微滞后于另一个机房,此时可在应用层做补偿机制。
- 使用旧数据: 暂时向用户显示旧的库存数据,同时显示一个消息,比如“库存信息正在更新中”。
- 请求重试:提示用户稍后重试,或自动在几秒后重试以获取更新的数据。
策略 3: 功能层面的重要级别划分
- 强弱依赖划分: "强依赖" 对系统功能至关重要,如内容审核。"弱依赖" 如粉丝勋章,对基本功能影响较小。
- 异常处理: 对于弱依赖,在出现异常时,系统将采取措施如限流和熔断,以保证核心功能的正常运行。
- 优化非核心功能: 另一方面也通过超时控制、请求预过滤、优化调用编排甚至技术方案重构等方式持续优化提升非核心功能的可用性,为业务在评论区获得更好的曝光展现。
05 安全性与用户体验
5.1 数据安全
评论数据合规,一方面是审核和风控。
另一方面对工程侧的要求主要是「状态一致性」。例如,有害评论被删除后,在客户端不能展现,也不能通过 API 等对外暴露。这就对数据一致性,包括缓存,提出了较高要求。在设计层面主要有两方面实践:
- 数据读写阶段均考虑了一致性风险,严格保证时序性。
- 对各类数据写操作,定义了优先级,避免高优先级操作被低优先级操作覆盖,例如审核删除的有害评论,不能被其他普通运营人员/自动化策略放出。
- 通过冗余校验,避免风险数据外泄。例如评论列表的露出,读取 sorted set 中的 id 列表后,还需要校验对应评论的状态,是可见态才允许下发。
5.2 用户体验保证
接口错误导致用户操作失败、关闭评论区、评论计数不准,甚至新功能上线、用户不满意的评论被顶到热评前排等问题均可能引发用户体验问题。
- 不对用户暴露用户无法处理和不值得处理的错误: 例如评论点赞点踩、某个数据项读取失败这一类的轻量级操作,不值得用户重试,此时告知用户操作失败也没有意义。系统可以考虑自行重试,甚至直接忽略。
- 功能准确性: 例如评论计数、热评排序等。
- 事务加锁: 不可行。对 性能影响较大。越需要加锁的场景,越容易出现锁冲突。
- 串行化: 将评论区的所有操作,抽象为一个排队的 Domain Events,串行处理。
06 热评设计
6.1 什么是热评
热评排序逻辑包括以下维度:
- 点赞数。代表热度高
- 回复数
- 内容相关。热评用户流量大,社区影响也大。要权衡社会价值观引导、公司战略导向、商业利益、UP 主与用户的「情绪」等。→ 追求用户价值平衡。
- 负反馈数。保障高赞高踩的负面热评问题
- "时间衰退因子"
- 字数加权
- 用户等级加权等
- 解决高赞永远高赞的马太效应。即短时间内点赞率高,就代表热度高
6.2 挑战与应对
挑战 1: 点赞数排序
- 按照点赞绝对值排序,即要实现
ORDER BY like_count
的分页排序。点赞数是一个频繁更新的值,MySQL,特别是 TiDB,由于扫描行数约等于 OFFSET,因此在 OFFSET 较大时查询性能特别差 ,很难找到一个完美的优化方案。 - 此外,由于
like_count
的分布可能出现同一个值堆叠多个元素,比如评论区所有的评论都没有赞,我们更多依赖 redis 的 sorted set 来执行分页查询,这就要求缓存命中率要非常高。
挑战 2: 正负样本加权平均
按照正负样本加权平均的,即 Reddit:威尔逊排序[6]
- 到这个阶段,数据库已经无法实现这样复杂的 ORDER BY,热评开始几乎完全依赖 sorted set 这样的数据结构,预先计算好排序分数并写入。
- 在架构设计上,新增了 feed-service 和 feed-job 来支撑热评列表的读写。
挑战 3: 点赞率排序
- 按照点赞率排序,需要实现点赞率的近实时计算。
点赞率 = 点赞数 / 曝光数
- 曝光的数据来源是客户端上报的展现日志,量级非常大,可以说是一个写多读少的场景:只有重算排序的时候才会读取曝光数。