3.3 评论与点赞:树形结构

场景:楼中楼把接口拖垮过一次

产品要楼中楼那周,我们低估了树形评论的读放大。第一版方案天真地把全部评论查回到 Node 里递归拼树,结果一个盖楼帖有一千八百条评论,接口拉数据加拼树耗时一点五秒,内存峰值三百多 MB,CVM 差点被评论接口拖垮。前端还要在楼里无限回复,层级越深查询越噩梦,那段时间评论区接口的慢请求日志天天霸榜。

点赞同样有坑。最开始点赞直接写 MySQL,一次热门帖的点赞高峰期每秒两百次写入,binlog 同步延迟、主库写延迟全线拉高,后来点赞全部挪进 Redis 的 Set,落库交给批量任务,写压力瞬间消失。评论接口被拖垮那晚我们做了个决定:评论接口允许慢,但必须分页可控;点赞接口必须快,但不能强一致。这两个功能定位完全不同,却被我们长期混在一起设计,吃亏在把它们当成同一个需求。

再复盘还发现一个容量判断错误:我们原以为评论是读多写少,实际上盖楼帖是写多读也多,且写的时间高度集中于热门帖发布后一小时内。这个误判让我们最初给评论表只建了主键索引,高峰期插入都开始排队,后来按读路径补了联合索引才顺畅。

点赞挪进 Redis 之后还带来一个连锁问题:用户点赞列表页面要展示这个人赞过哪些帖子,原本一个 SQL JOIN 就能解决,现在 Redis 的 Set 里只有帖子 id 没有排序时间。我们做了个拆分:点赞列表的持久数据仍然落 MySQL 的 t_like_log 表,Redis 只承担热点帖的实时计数,列表页直接查库。读写分离到字段级别后,每个场景各取所需。

方案:两次查询拼树加 Set 计数

评论表的树形我们没用邻接表加递归,而是采用 id 加 parent_id 加 root_id 的组合:一级评论的 root_id 为零,所有子孙评论都冗根评论 id。拉取时分两次查询:第一次取指定一级评论分页,第二次用 root_id IN 一次性把整棵楼查回,在 Node 里按 parent_id 做一次分组拼接,耗时稳定。点赞则完全走 Redis,用户点赞行为记 Set,计数用计数器,MySQL 只保存批量落库结果:

动作 读取路径 写入路径 去重方式
一级评论 分页扫描 直接写表 无需
楼中楼 root_id 聚合 直接写表 无需
点赞 Set 判断 Set 加计数器 Set 天然去重
计数 Redis 计数 批量回写 定时对账
撤销 Set 移除 删计数 Set 兜底
落库 对账任务 t_like_log 五分钟一次

这个方案的本质是把树形的递归成本转移到写入时的一次冗余字段上。root_id 在插入评论时由父评论的层级决定,写入时多算一次,读取时省掉整棵子树的递归。数据结构领域的经典权衡在工程里永远有效:要么读时算,要么写时存。后来产品加收藏、加关注,我们又用同样的思路冗余了目标的分组字段,接口复杂度始终没有涨上去。

配置与代码

评论表的关键是 root_id 冗余和楼内序号,一次 IN 查询就能拉回整棵子树,不需要递归 SQL:

CREATE TABLE t_comment (
  comment_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  post_id BIGINT NOT NULL,
  uid BIGINT NOT NULL,
  root_id BIGINT DEFAULT 0,
  parent_id BIGINT DEFAULT 0,
  content VARCHAR(500) NOT NULL,
  like_cnt INT DEFAULT 0,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  KEY idx_root (root_id, comment_id),
  KEY idx_post (post_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

点赞的写入路径只碰 Redis,MySQL 完全不参与接口请求。落库表只存最终结果,中间过程在内存里对账。表结构如下,索引直接服务于用户点赞列表和帖子点赞总数两类查询:

CREATE TABLE t_like_log (
  log_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  uid BIGINT NOT NULL,
  post_id BIGINT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  KEY idx_uid (uid, created_at),
  KEY idx_post (post_id, uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
async function doLike(uid, post_id) {
  const ok = await redis.sadd('liked:' + post_id, uid);
  if (ok) await redis.incr('likecnt:post:' + post_id);
  return !!ok;
}

评论的读取分两步:先分页取一级评论,再按 root_id 一次取回所有楼。拼树在内存里做一次分组,耗时可控:

async function getComments(post_id, page) {
  const roots = await mysql.query(
    'SELECT * FROM t_comment WHERE post_id = ? AND root_id = 0 ORDER BY comment_id DESC LIMIT ?, 20',
    [post_id, (page - 1) * 20]);
  const ids = roots.map(r => r.comment_id);
  const subs = ids.length
    ? await mysql.query('SELECT * FROM t_comment WHERE root_id IN (?)', [ids])
    : [];
  return buildTree(roots, subs);
}

buildTree 的实现也贴一下原理:子评论按 parent_id 在内存里分组成映射表,然后一次遍历挂到各自父评论的 children 数组上。一千八百条评论的拼树耗时从最早的一秒降到二十毫秒,核心就是丢掉递归改用哈希查找。评论接口的分页参数也做了硬限制,单次请求最多取两百条一级评论,超过的引导进帖子详情分楼加载。

踩坑

第一个坑是点赞计数与 Set 长度不一致。某次热帖被批量取消点赞,移除成功但计数器减少失败,显示数比实际点赞数高,加了每分钟对账任务,用 Set 的 scard 校正计数器。第二个坑是楼中楼深嵌套展示,三级以上回复在前端显示成一整片,产品要求最多展示到第二级其余变查看更多,后端把同一楼层内连续同层回复做了折叠聚合。第三个坑是评论的顺序敏感性,并列的楼中楼在多人同时回复时偶发乱序,原因是排序键只用了 created_at,加了一个楼内序号字段作为并列排序键才稳定。第四个坑是删除评论的级联性,删除一级评论时子评论共同隐藏,但一开始只标记了根评论,子评论依然被拉出造成孤儿显示,后来删除动作统一按 root_id 批量更新状态。第五个坑是并发插入的楼内序号竞争,同一楼层两人同时回复拿到相同序号,用 Redis 的 incr 给楼内分配序号后解决。第六个坑是点赞落库延迟,批量任务五分钟一次,用户在手机上点赞后立刻切到网页版有时看到旧数据,在前端做了本地乐观更新兜底。第七个坑是评论内容审核的时序,评论写入即显示是产品铁律,于是违规评论会在审核前短暂可见,加了疑似评论先发后藏的策略,审核判定违规再统一隐藏,避免大部分用户看到垃圾内容。

小结

树形评论的关键是空间换时间:用 root_id 冗余把递归变成两次平坦查询。点赞的关键是把写压力从 MySQL 挪到 Redis 并接受最终一致。高频互动场景里,MySQL 的定位永远是持久层而非性能层。评论接口被拖垮那一晚之后,我们还把评论区的大查询全部改成分页小查询,单次接口内存占用硬上限两百条,这是我们对接口的一项基本纪律。写评论接口的分页限制是另一个重要设计:单次最多拉两百条一级评论,盖楼帖引导用户进入帖子详情页里逐楼加载,接口内存占用再也没失控过。这类设计的更多变体,IM 系统做得最彻底,感兴趣可看 IM 即时通讯实战。

延伸阅读

评论模块是整个项目里代码行数增长最快的模块,至今仍然以每月一版的速度迭代,好在前文那套分层骨架足够稳,没再发生推倒重来的事。点赞计数与评论计数的对账任务共用一个框架,都放在凌晨低峰跑,把最终一致性收敛到第二天上班之前。每周例会对一次对账差异,基本都能在巡检时间内定位到具体帖子,最高频的偏差来源永远是审核回滚那一步。