一、 业务背景 (Background)
请设计一个适用于短视频平台(如抖音、快手)的评论系统。该系统需要支撑数亿日活用户,应对海量视频的评论读取与写入请求。尤其是当某个视频突然爆火时,单条视频的评论区会在短时间内涌入极高的并发读写流量。
二、 功能性需求 (Functional Requirements)
-
两级展示:支持两级评论结构(主评论和子回复)。用户可以直接评论视频(主评论),也可以回复其他人的评论(子评论)。
-
楼层平铺:为了保证移动端的阅读体验,无论子回复嵌套了多少层,展示时都必须"展平"在所属的主评论下方,并能清晰标识"A 回复 @B"。
-
计数展示:主评论需要实时或近实时地展示"当前共有 X 条回复"以及点赞数。
-
无限滑动:用户在评论区不断向下滑动时,需要平滑加载下一页评论。按热度(点赞数+时间)或纯时间排序。
实现思路
对于该问题可以从几个角度去考虑,首先数据库表结构/数据结构设计,在评论盖楼场景当中一个root评论下可能会关联多个child评论,对于每个child有可以作为parent再次关联child,依此类推会产生一个树状层级关系。在查询的过程当中必须处理好这种逻辑。
数据结构设计
首先从产品角度来看,当我们打开一个视频的评论框需要展示的内容包括root评论以及每个评论的child评论数量、点赞数量。这些一级评论包括所有的子评论都归属于一个video_id,那么对于每一个child来说又归属于相同的parent。而对于抖音来说采用的是二级评论,也就是不会循环嵌套,打开一个root评论之后展示的是包括这个root评论的child评论以及它们的child评论。因此在表结构设计的过程当中如果只通过parent_id关联paren评论就需要循环递归查询出一个root下的所有评论,这种方式的成本是非常高的。所以在表设计的角度来看,我们可以给每个child都关联到一级评论的id也就是root_id
| 字段名 | 数据类型 | 核心作用 | 备注 |
|---|---|---|---|
comment_id |
BigInt | 唯一主键 | 使用分布式发号器(如雪花算法)生成 |
video_id |
BigInt | 关联视频 | 建立普通索引,用于冷数据兜底查询 |
root_id |
BigInt | 根评论聚合 | 一级评论设为 0。用于将所有子回复展平在主评论下 |
parent_id |
BigInt | 溯源直接父级 | 用于在前端构建具体的回复对话链 |
| 剩下的就是业务相关字段 |
| 字段名 | 数据类型 | 核心作用 | 备注 |
|---|---|---|---|
user_id |
BigInt | 发帖人 | - |
content |
Varchar | 评论正文 | - |
reply_to_uid |
BigInt | 被回复人 | 冗余字段,前端直接渲染"回复 @张三",减少查表 |
reply_count |
Int | 回复总数 | 仅一级评论维护,用于展示"展开 X 条回复" |
like_count |
Int | 点赞总数 | 用于计算热度分 |
created_at |
Timestamp | 发布时间 | 用于时间序的分页和展示 |
高并发写架构和异步批量同步
在上述的场景当中需要注意到的一个细节是在新增child评论时,需要更新root的reply_count回复数量的这样一个计数器,试想一下某个爆款视频快速涌入上万条评论,我们不单单需要insert到数据库当中,而且需要更新root到计数器,对于这个计数器的更新操作来说必须考虑并发问题,简而言之必须加行锁,这是性能瓶颈所在。
相同的我们的获取当前评论的子评论数量实际上也可以使用count(),使用count(字段)需要遍历全表(无法走索引)不可避免会产生长sql,因此这种方式直接抛弃。
我们现在想到为root评论维护一个评论数量,那么就会产生一个连级更新的问题,每次新增一条child评论都需要更新root的计数,试想一下在某个视频突然爆火涌入了上万的评论,我们都需要连级更新,因此这里可以引入MQ异步批量同步。
系统先将记录快速 INSERT 到 MySQL,生成 comment_id。此时不更新任何计数,直接对前端返回,同时在前端界面为了用户友好,可以立刻看到自己的新评论,在显示当中直接对计数+1。
后端向消息队列发送一条消息,通知更新计数。在这个过程当中需要注意的是,需要处理接口的幂等性(这里如果不做幂等性处理,很有可能会重复计数),因此可以引入redis的setnx,后台消费者拿到消息,先通过 Redis 检查 msg_id 是否存在,如果存在则说明是重复请求。
除此之外如果每次insert评论都去同步root评论仍然可能会导致频繁的insert数据库压力增大,因此可以采用聚合策略,一次性聚合多条add,间隔一定时间再做同步。
数据冷热分离
在当前业务场景下有一个可想而知的问题,就是在用户看评论的过程当中,我们需要基于一定规则对评论排序,同时还要做翻页处理。而通常来说,一个视频如果有100w的评论,用户在阅读过程当中也不会全部阅读,因此数据呈现非常明显的冷热分离。
因此我们要做的就是:针对"某个视频下的主评论(一级评论)"进行热度排序的
所以在实现过程当中我们可以对数据基于热度拆分,对于热度来说必须要维护一种数据结构来保存排名,这里就可以使用zset。考虑到前面所说的数据呈现冷热分离的趋势,因此我们可以维护多个zset结构,一个zset用来保存热点评论排名,另一个热度不高的所有评论。那么在这个过程当中还必须设计合理的晋升机制,对于冷数据来说如果出现了突发流量导致其热度上升还需要将其晋升到hot zset当中还需要注意的是这里zset当中存储的是评论的ID而不是完整评论信息。
游标分页
在评论下拉刷新的场景当中有一个特点,分页机制采用的是游标分页,也就是下一页是基于上一页的index基础上再次加载n条数据,这种方式称之为游标分页。
在我们设计热度排名的场景当中就会出现一个不可避免的问题,比如说传统的游标分页我们在查询过程查到前10条,但是由于热度上升,后面有3条拍到到前十当中,再次通过游标分页查询会查询到第一次当中被挤下来的那几条重复信息。
但是基于zset不会出现这个问题,在查询过程当中比如说拿到 20 个 comment_id及其对应的 Score:
redis
ZREVRANGE video_comments:1001 0 19 WITHSCORES
用户向下滑动。前端发起请求,带上游标:cursor_score = 8500, cursor_id = c_123。 后端向 Redis 发送基于分数的范围查询指令(意思是:给我分数小于 8500 的接下来 20 条):
redis
ZREVRANGEBYSCORE video_comments:1001 (8500 -inf LIMIT 0 20
因为游标是"锚定"在具体的分数和 ID 上的。即使在你看第一页的时候,顶部突然增加了 100 条分数高达 10000 分的超级爆款评论,这完全不会影响你的查询。因为你的下一页查询条件死死锁定了"去查分数小于 8500 的数据",新增的高分数据根本不在你的查询范围内,数据平滑如丝,绝不重复。
读链路:冷热数据分离与游标分页检索
写链路:高并发评论发布与异步计数同步
- 分布式发号器生成 ID
- 快速 Insert 返回
- 异步旁路触发
- 投递
- Pull/Push
Key 已存在
Key 不存在 - 聚合 N 条后刷盘
首次加载/无游标
游标: cursor_score + id
查询
锚定查询,防数据抖动
返回排序结果
MGET 批量查详情
Cache Miss
回填缓存
聚合装配
POST /api/v1/comment
GET /api/v1/comments
响应成功
返回 JSON Tree
移动端/前端 App
接收评论/回复请求
MySQL 评论主表
构建并返回前台实体
UI 本地乐观自增计数
发送计数更新事件
RocketMQ/Kafka
异步消费者集群
Redis SetNX
MsgID 幂等校验
丢弃重复/无效消息
内存聚合/时间窗口缓冲
MySQL 批量更新
root_id reply_count
发起评论列表查询
首次请求 OR
携带游标滑动?
ZREVRANGE 获取首页
ZREVRANGEBYSCORE
锁定 score 查下一页
Redis ZSet
Hot ZSet & Cold ZSet
提取 Top N
comment_id 列表
Redis 详情哈希缓存
MySQL 兜底查询
构建两级平铺结构
附带 A 回复 @B 标识