Skip to content

设计一个评论系统

提出问题

评论系统是互联网产品的"下水道工程"——看起来简单,做起来全是坑。一个文章有 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 查询批量取子评论:

sql
-- 先取父评论
SELECT * FROM parent_comments 
WHERE article_id = 12345 
ORDER BY created_at DESC 
LIMIT 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) 上,可以高效支持"某条父评论下面展开所有子评论"。

热门评论排序:热度值 + 时间衰减

纯按时间倒序的坏处:24 小时前的优质评论永远沉底,点赞数再高也不会被看到。纯按热度排序的坏处:新评论永远没有曝光机会。解决方案是热度值 + 时间衰减

score = 点赞数 × 时间衰减因子

时间衰减因子用 (当前时间 - 发布时间) / 半衰期 计算,半衰期一般设为 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 article:comment:count:12345
INCR comment:12345:like:count

# 读的时候直接取
GET article:comment:count:12345

Redis 的 INCR 是单线程原子操作,10 万 QPS 没问题。计数器写完后,通过 MQ 异步刷到 MySQL 做持久化,每 5 秒批量 flush 一次:

java
@Component
public class CounterFlushJob {
    @Scheduled(fixedDelay = 5000)
    public void flush() {
        // 从 Redis 批量取所有脏计数器
        // 用 Redis pipeline 一次取 1000 个
        // 批量 UPDATE MySQL(用 CASE WHEN 一次更新多条)
        // 最后删除 Redis 中的脏标记
    }
}

这种方案的好处:Redis 扛实时写入,MySQL 做持久化基线,两者互不阻塞。即使 Redis 挂了,重启后从 MySQL 恢复计数器,服务不中断。

总结

评论系统的核心设计要点:

  • 存储选型:分区存储(父评论 + 子评论分开)比邻接表更实用,避免递归查询,配合 IN 批量查询性能极好
  • 排序策略:热度 + 时间衰减 + 新评论插入曝光,平衡热门内容和新鲜度
  • 计数器:Redis INCR 扛实时写入,MySQL 异步持久化做兜底,避免行锁竞争
  • 盖楼展示:每条父评论预加载最近 3 条子评论,减少前端请求次数,更多子评论再展开分页

面试话术示例:"父评论和子评论我不建议放在同一张表写递归查询,实际生产中用分区存储——父评论按 article_id 查,子评论按 parent_id 批量查,两次查询走索引毫秒级返回。排序用热度 + 时间衰减,同时预留 20% 的展示位给新评论,保证新内容不被淹没。"

参考:Disqus 评论系统设计、知乎回答排序算法、MySQL 8.0 IN 查询优化

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。