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 工程化搞透。
粤ICP备2026104257号-1