EasyLive评论架构升级
1. 解决行锁竞争:异步计数逻辑 (MQ + Redis)
在原始实现中,用户每次发表评论都会直接更新视频表的评论数,这在热点视频场景下会导致大量并发请求竞争同一行数据,从而引发严重的行锁竞争和性能瓶颈。
针对这个问题,可以将"评论写入"和"评论计数更新"进行拆分。用户发表评论时,评论内容仍然直接插入评论表(保证数据可靠性);同时在 Redis 中维护视频的评论数,例如使用 video_comment_count:{videoId} 作为 key,通过 INCR 进行原子递增。前端则采用乐观更新策略,在用户提交评论后直接将评论数 +1,并将新评论置顶展示,从而提升用户体验。
对于视频表中的评论数更新,不再同步执行,而是通过消息队列进行异步处理。每次评论成功后发送一条计数消息,在消费者侧可以引入聚合窗口(例如每 5 秒或累计一定数量后触发),再批量更新数据库,例如执行 count = count + N。这样可以显著减少数据库写入频率,降低热点行的锁竞争,提高系统整体吞吐能力。
在使用消息队列的过程中,需要考虑重复投递带来的幂等性问题。可以为每条消息生成唯一的 msg_id,消费者在处理前先通过 Redis 的 SETNX 进行校验,如果 key 已存在则说明该消息已经被处理过,直接丢弃。为了避免幂等 key 无限增长,需要为其设置合理的过期时间。
在读路径上,为了减少对数据库的访问压力,可以优先从 Redis 中获取评论数;当缓存未命中时,再从数据库查询并回写缓存,同时设置 TTL,使缓存能够在一定时间后自动失效,从而保证数据不会长期不一致。
最后,由于 Redis 和数据库之间是异步更新关系,系统整体属于最终一致性模型。为了保证数据的正确性,可以通过定期任务对 Redis 和数据库中的评论数进行校验,当发现偏差较大时,以数据库为准对缓存进行修正,从而实现数据的自愈。
一致性保障
数据库 MySQL
聚合层
MQ层
缓存层 Redis
API层
用户侧
首次
重复
命中
未命中
是
否
用户发评论
客户端显示 +1
评论服务 API
计数器 INCR
缓存命中?
返回计数
写入缓存 + TTL
发送计数消息
消费消息
SETNX 幂等校验
时间窗口聚合
5s 或 50条
更新 count + N
查询 COUNT
定期校验
是否偏差过大
DB 覆盖缓存
丢弃
跳过
2.解决 1+N 查询:使用聚合根评论ID
接着是查询问题,在原项目中,对于评论之间的关系,我们只使用了parent_id这一个字段,这个字段表明了当前评论的所属的父级评论,一级评论的parent_id为0。每次查询视频评论时,都会使用嵌套查询------先查询视频的每一条一级评论,然后针对每一条一级评论再一次查询其子评论。这种1+N的查询,对于数据库的性能消耗是巨大的。
针对这个问题,我们引入了一个新的字段 root_id,用于标识当前评论所属的一级评论(即整条评论链的根节点)。如果是一级评论,则 root_id 为 0;对于所有非一级评论,其 root_id 均为对应一级评论的 comment_id。
通过 root_id 和 parent_id 的组合,可以同时表达评论的"归属关系"和"直接回复关系"。
| 字段名 | 类型 | 核心作用 | 备注 |
|---|---|---|---|
| comment_id | BIGINT | 唯一主键 | 使用雪花算法生成,保证分布式唯一性 |
| video_id | BIGINT | 关联视频 | 建立普通索引,用于冷数据兜底查询 |
| root_id | BIGINT | 根评论聚合 | 一级评论设为 0。一级评论以外的root_id均为一级评论的comment_id |
| parent_id | BIGINT | 父级溯源 | 记录回复评论的comment_id |
| user_id | BIGINT | 发帖人 | 关联用户信息 |
| reply_to_user_id | BIGINT | 被回复人 | 冗余字段,用于前端直接渲染,避免多表关联 |
| content | VARCHAR | 评论正文 | - |
| like_count | INT | 点赞总数 | 用于计算热度分,异步同步 |
| post_time | DATETIME | 发布时间 | 用于时间轴分页和展示 |
关于评论之间的关系,只需要结合root_id和parent_id我们就能进行判断:
假设有以下场景:有一场对话,一共发生了 4 次交互:
- 张三 发了一条主评论:"这视频真赞!" (一级评论)
- 李四 回复张三:"我也觉得。" (二级评论)
- 王五 回复李四:"你觉得哪儿好?" (三级评论)
- 赵六 回复王五:"我觉得运镜好。" (四级评论)
则在数据库中,部分表结构如下:
| 评论人 | 评论 ID | root_id (楼号) | parent_id (回复谁) | 逻辑深度 |
|---|---|---|---|---|
| 张三 (祖宗) | 100 | 0 | 0 | 1级 |
| 李四 | 200 | 100 | 100 | 2级 |
| 王五 | 300 | 100 | 200 | 3级 |
| 赵六 | 400 | 100 | 300 | 4级 |
查询时,我们可以先查询出该视频下的一级评论:
mysql
select * from video_comment where video_id = ? and root_id = 0
接着查询一级评论下的所有评论:
mysql
select * from video_comment where root_id = {当前一级评论的ID}
在实际实现中,还可以进一步优化:将多个一级评论的 root_id 收集起来,使用 IN 查询一次性拉取所有子评论,再在内存中按 root_id 进行分组,从而将查询进一步压缩为 2 次 SQL(一级评论 + 全部子评论),彻底避免N次查询问题。
3.解决查询时的排序问题:结合Zset
用户查看评论时,总会有一个偏好:查看热门评论或者最新的评论。
对于"最新评论"的实现,相对简单:可以在评论表中维护一个 post_time 字段,用于记录评论的发布时间。在查询时,根据该字段进行排序,并结合分页机制即可获取最新的评论列表。
对于"热门评论",则需要设计一套评分机制来衡量评论的热度。通常可以综合多个维度进行计算,例如点赞数、回复数、转发数、分享数等,结合一定的权重计算出一个"热度分数",用于表示评论的相对热度。
在实现上,可以利用 Redis 的 ZSet(有序集合)来存储评论的热度数据。在查询某个视频下的一级评论时,先计算或维护每条评论的热度分数,并将评论 ID 和对应分数存入 ZSet 中,例如只维护当前热度最高的前 1000 条评论。用户在对评论进行点赞、评论或分享时,可以实时更新 Redis 中对应评论的分数,并重新进行排序。
同时,可以通过定时任务或异步机制,将 Redis 中的热度数据定期同步到数据库中,从而保证数据的持久化和最终一致性。为了避免数据长期偏离,还可以周期性地重新计算所有评论的热度分数,并重新筛选出前 1000 名作为热门评论集合。
在上述设计中,如果仅维护一个"热门 Top N"的集合,会存在一个问题:当某个评论突然爆火,但尚未进入热门集合时,在当前缓存中是无法直接命中的,这可能会导致请求直接穿透到数据库,增加数据库压力。
针对这个问题,可以进一步优化,将评论分为两个集合进行管理:将所有一级评论都查询出来,然后将其分为两组,一组为热评组,一组为平常组。
| 容器名称 | 存储内容 | 维护策略 |
|---|---|---|
video:hot_zset:{video_id} |
热门评论 ID + 分数 | 固定容量(如 1000),只保留高分评论 |
video:normal_zset:{video_id} |
所有非热门评论 ID + 分数 | 动态容量,覆盖其余评论 |
| 基于该结构,可以设计一套评论"晋升与降级机制": |
- 当用户对某条评论进行点赞、评论等操作时,会实时更新其在 Redis 中的分数;
- 当普通评论的分数超过设定阈值(阈值可以设置可以是浮动的,这个根据当前视频的热度来计算)时,将其从
normal_zset晋升到hot_zset; - 当热门集合中的评论数量超过上限(如 1000),并且尾部评论的分数低于阈值时,将其降级到普通集合;
- 同时可以引入时间衰减因子,使得评论的分数随着时间逐渐下降,从而保证新评论有机会进入热门榜单。
需要注意的是,ZSet 本身只存储评论的 ID 和分数,并不直接存储评论的完整内容。评论的详细信息可以统一存储在 Redis 的 Hash 结构中,例如 comment:{comment_id},用于存放评论的完整数据。
这样做原因有以下几点:
- 避免在 ZSet 中存储过大的数据对象,提升内存使用效率;
- 支持一个评论同时出现在多个排行榜中(如热门榜、最新榜、个人评论等),只需维护同一个评论 ID;
- 当评论内容发生变化时,只需更新一处 Hash 数据,即可保证多个榜单数据的一致性,降低数据冗余和不一致风险。
拓展:使用MongDB呢?
MongoDB 的Document(文档)模型天然支持嵌套,它里面存储的类似JSON的数据格式,对于处理这种评论的多级嵌套结构应该很得心应手,但是在高并发、数据量庞大的场景下不建议使用。
首先是文档大小限制:
- MongoDB 单个文档限制是 16MB。
- 一个超级热门视频(如春晚或顶流明星的动态),单栋楼的评论可能达到数万条甚至数十万条。如果全部嵌套在一个文档里,会迅速超出16MB大小,导致数据写入失败。
更新与查询的性能较低:
- 在一个巨大的嵌套数组里找第 999 层的某条评论并给它"加个点赞",数据库需要解析整个文档结构,这种 CPU 消耗是非常高的。
- 分页难题:MongoDB 对嵌套数组的分页支持很弱。你想在 1 万层回复里翻到第 20 页,SQL 会变得异常复杂,且性能极差。
MongoDB 更适合读多写少、结构灵活的场景,而评论系统属于高并发写入 + 复杂排序与分页场景,因此更适合采用关系型数据库结合缓存来实现。
4.解决游标分页数据实时更新:Redis查询添加限制
在基于热度进行排序查询时,如果采用传统的分页方式(如 offset 分页),由于评论的热度是实时变化的,在用户连续翻页的过程中,数据的排序可能已经发生变化,从而导致出现"重复数据"或"数据跳跃"的问题。
例如:用户在查看第一页时获取了一批评论,但在请求第二页之前,由于部分评论的热度上升,原本在第一页的数据被重新排序到后面,导致在第二页中再次出现,影响用户体验。
针对该问题,可以引入游标分页机制来解决,不依赖页码,而是基于上一次查询结果的"边界值"作为下一次查询的起点。
在具体实现上,对于基于热度排序的评论列表,可以在 Redis 的 ZSet 中,使用"分数 + ID"作为游标标识。在查询下一页时,记录上一页最后一条评论的 score(例如 114514),并在下一次查询时作为边界条件进行过滤。
java
ZREVRANGEBYSCORE video_comments:1001 (114514 -inf LIMIT 0 20
需要注意的是,在实际实现中,单纯使用 score 作为游标可能会存在"score 相同"的情况(例如多个评论具有相同热度分数),因此可以进一步结合 score + comment_id 作为复合游标,以保证排序的唯一性和稳定性。
为代码实现:
java
ZREVRANGEBYSCORE key (score 或 (score, id) -inf LIMIT 0 20
原生Redis并不支持二级排序,这一步通常在业务层中实现,上面的代码只是作为示范。
5.系统总体架构图
最终一致性
评论模型(树结构)
读链路:评论查询 + 热度排序
写链路:评论写入 + 计数异步化
接入层
用户层
重复
首次
否
是
缓存未命中
用户
评论服务 API
接收评论请求
MySQL 评论表
Redis 计数器 INCR
发送 MQ 计数消息
Kafka / RocketMQ
消费者处理
SETNX 幂等校验
批量聚合写入
MySQL 更新计数
丢弃
请求评论列表
是否带游标
ZSet 查询热评
ZSet 游标分页
Redis ZSet
Hash 批量查详情
MySQL 兜底
组装评论结构
root_id
parent_id
定时任务对账
修复 Redis / DB 差异