路径枚举是另一种思路:每条评论存一个 path 字段,格式如 /1/3/5/,子评论的 path 以父评论的 path 为前缀。查询时用 WHERE path LIKE '/1/3/%' 取所有子评论。路径枚举避免了递归查询,但 path 字段长度有限(VARCHAR(255)),深度深的评论树会截断。而且 LIKE 前缀匹配虽然能用索引,但中间插入评论时存量数据的 path 不需要变更,这点比邻接表好。
生产级方案:分区存储。父评论和子评论拆开存储,而不是放在同一张表里。父评论用 article_id 索引,查父评论时走 WHERE article_id = ? ORDER BY created_at DESC LIMIT 20,走索引毫秒级返回。拿到父评论 ID 列表后,用 IN 查询批量取子评论:
sql
-- 先取父评论SELECT * FROM parent_comments WHERE article_id = 12345ORDER BY created_at DESCLIMIT 20;-- 再取所有子评论SELECT * FROM child_comments WHERE parent_id IN (101, 102, 103, ...)ORDER BY created_at ASC;
这种"先查父评论,再批量查子评论"的方案,避免了递归查询,IN 查询在 B+ 树索引下性能极好(MySQL 8.0 对 IN 做了优化,一次扫描可返回多个 ID 的数据)。每个子评论额外存 root_id(父评论 ID),索引建在 (root_id, created_at) 上,可以高效支持"某条父评论下面展开所有子评论"。
设计一个评论系统
提出问题
评论系统是互联网产品的"下水道工程"——看起来简单,做起来全是坑。一个文章有 10 万条评论,每条评论还有 N 条子评论,怎么存?怎么查?怎么排序?热门评论和新评论的矛盾怎么调和?面试官考这道题,表面上是问存储模型,深层是想看候选人有没有处理过"看似简单但数据量上去后复杂度暴涨"的真实场景。生产上,一个评论系统的核心矛盾很简单:父评论 + 子评论的嵌套结构,跟关系型数据库的扁平存储格格不入。存得不好,查一条热门文章下的评论能把数据库打挂。
分析问题
存储模型:邻接表 vs 路径枚举 vs 分区存储
最直觉的方案是邻接表:每条评论加一个
parent_id字段,父评论的parent_id = NULL,子评论的parent_id指向父评论 ID。查询时用递归 SQL 或WITH RECURSIVE取整棵树。这个方案的问题是:MySQL 的递归查询在高并发下性能极差,尤其是深度超过 3 层时,每次查询都要扫描多张索引页。当单篇文章评论数超过 10 万时,递归查询的延迟会飙升到秒级。路径枚举是另一种思路:每条评论存一个
path字段,格式如/1/3/5/,子评论的 path 以父评论的 path 为前缀。查询时用WHERE path LIKE '/1/3/%'取所有子评论。路径枚举避免了递归查询,但 path 字段长度有限(VARCHAR(255)),深度深的评论树会截断。而且LIKE前缀匹配虽然能用索引,但中间插入评论时存量数据的 path 不需要变更,这点比邻接表好。生产级方案:分区存储。父评论和子评论拆开存储,而不是放在同一张表里。父评论用
article_id索引,查父评论时走WHERE article_id = ? ORDER BY created_at DESC LIMIT 20,走索引毫秒级返回。拿到父评论 ID 列表后,用IN查询批量取子评论:这种"先查父评论,再批量查子评论"的方案,避免了递归查询,
IN查询在 B+ 树索引下性能极好(MySQL 8.0 对 IN 做了优化,一次扫描可返回多个 ID 的数据)。每个子评论额外存root_id(父评论 ID),索引建在(root_id, created_at)上,可以高效支持"某条父评论下面展开所有子评论"。热门评论排序:热度值 + 时间衰减
纯按时间倒序的坏处:24 小时前的优质评论永远沉底,点赞数再高也不会被看到。纯按热度排序的坏处:新评论永远没有曝光机会。解决方案是热度值 + 时间衰减:
时间衰减因子用
(当前时间 - 发布时间) / 半衰期计算,半衰期一般设为 12 小时或 24 小时。每条父评论的 score 实时更新(点赞时INCR),查询时ORDER BY score DESC。热度排序的 Top N 可以用 Redis ZSet 缓存,每篇文章一个 ZSet,score 就是热度值,ZREVRANGE 0 19秒级返回 Top 20。但纯热度排序对新评论不友好:你刚发一条评论,点赞数是 0,排到几百名之后去了。解法是热门排序中插入少量新评论:取 Top 20 时,15 条按热度排序 + 5 条按时间排序(从最近 1 小时内的评论中随机选),保证新评论也有曝光机会,同时不破坏热门的整体排序。
评论计数器的原子性
文章评论数、每条评论的点赞数,这些计数器不能依赖 MySQL 行锁。高并发下,每次
UPDATE都会锁行,看起来是原子的,但并发量上去后锁竞争导致延迟飙升。正确做法:Redis 的
INCR是单线程原子操作,10 万 QPS 没问题。计数器写完后,通过 MQ 异步刷到 MySQL 做持久化,每 5 秒批量 flush 一次:这种方案的好处:Redis 扛实时写入,MySQL 做持久化基线,两者互不阻塞。即使 Redis 挂了,重启后从 MySQL 恢复计数器,服务不中断。
总结
评论系统的核心设计要点:
IN批量查询性能极好INCR扛实时写入,MySQL 异步持久化做兜底,避免行锁竞争面试话术示例:"父评论和子评论我不建议放在同一张表写递归查询,实际生产中用分区存储——父评论按
article_id查,子评论按parent_id批量查,两次查询走索引毫秒级返回。排序用热度 + 时间衰减,同时预留 20% 的展示位给新评论,保证新内容不被淹没。"