EasyLive评论架构升级

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_idparent_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_idparent_id我们就能进行判断:

假设有以下场景:有一场对话,一共发生了 4 次交互:

  1. 张三 发了一条主评论:"这视频真赞!" (一级评论
  2. 李四 回复张三:"我也觉得。" (二级评论
  3. 王五 回复李四:"你觉得哪儿好?" (三级评论
  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 差异

相关推荐
SuperEugene3 小时前
前端组件三层架构:页面/业务/基础组件划分,高内聚低耦合|组件化设计基础篇
前端·javascript·vue.js·架构·前端框架·状态模式
花千树-0103 小时前
Claude Code / Codex 架构推测 + 可实现版本设计(从0到1复刻一个Agent系统)
人工智能·ai·架构·aigc·ai编程
柒.梧.4 小时前
Redis架构演进:从主从到Cluster,读懂高可用与分布式核心
redis·分布式·架构
Java面试题总结4 小时前
WAF 误杀了正常请求怎么补数据?CloudFront + Lambda@Edge 双函数架构实战
数据库·架构·edge
weixin_704266055 小时前
Redis集群架构与搭建全攻略
数据库·redis·架构
70asunflower5 小时前
AI Infra 架构全景介绍
人工智能·架构
Hvitur5 小时前
软考架构师【第八章】系统质量属性与架构评估
数据库·架构
heimeiyingwang5 小时前
【架构实战】全链路压测实战与架构优化
架构