更多干货,欢迎关注公众号:【Fox爱分享】
写在开头
上周有个三年开发经验的粉丝找我哭诉,说去面 B 站,二面挂得莫名其妙。 面试官让他设计一个类似 B 站/抖音的评论系统。他心想:这不就是建张表,存个 parent_id 搞自关联嘛?
结果面试官一连串“夺命追问”:
- “如果一个评论下面有 1 万条子评论,你递归查库?数据库不崩吗? ”
- “按热度排序怎么做?实时 count 点赞表? ”
- “中间层的评论被删了,挂在它下面的子孙评论怎么办?树断了? ”
这兄弟当场自闭,支支吾吾半天没答上来。
其实,评论系统的核心不在于“存”,而在于“查”。 尤其是 B 站这种“二层盖楼”模式,和传统的无限层级论坛完全是两码事。
今天 Fox 带你把这道题拆得底裤都不剩,下次面试直接拿去“降维打击”。
一、 青铜回答:邻接表 (Adjacency List) —— 面试官的陷阱
很多人的第一反应,也是教科书里的标准答案,是设计成这样:
面试官追问 1: “我要查一个一级评论下的所有子孙评论(包括回复的回复),你怎么查?”
你的回答: “代码里写个递归啊,或者循环查。”
Fox 解析:
错!大错特错!这是典型的 N+1 问题。
如果一个楼层盖了 1000 层,你难道要查 1000 次数据库?
虽然 MySQL 8.0 支持 CTE (递归查询),但在千万级数据的高并发场景下,让数据库做递归计算就是找死,CPU 会瞬间飙高。哪怕你加了缓存,缓存穿透的那一瞬间,DB 也就挂了。
二、 黄金回答:路径枚举 (Materialized Path) —— 还是不够完美
为了解决递归,稍微有点经验的会说:加个 path 字段! 比如:1/23/456,代表 ID 为 456 的评论,父亲是 23,爷爷是 1。
面试官追问 2:
- “索引怎么建?LIKE 查询会失效吗?”
答: 建普通索引。查询用 LIKE '1/23/%'。这走的是最左前缀匹配,索引不会失效,性能是可以的。
- “如果中间楼层(ID 23)被删了,子评论(456)怎么办?”
答: 呃... 那得把所有以 1/23/ 开头的 path 全找出来,update 一遍...
面试官: “如果子评论有 10 万条,你为了删 1 条数据,引发 10 万次更新?这是数据库死锁的温床!”
三、 王者回答:反范式设计 + 扁平化存储(B站/抖音模式)
到了这一步,你要先反问面试官一句: “我们的业务形态是‘无限层级’(类似 Reddit/旧版贴吧)还是‘两层结构’(类似 B站/抖音/小红书)?”
现在主流的 App 为了移动端体验,都是两层结构:
- 一级评论: 对视频/文章的直接评论。
- 二级评论: 所有的回复(不管你是回复谁的),在 UI 上全部扁平化展示在一级评论下面。
满分表结构设计
放弃范式,我们需要引入冗余字段。
1. 评论主表 (comment)
-
root_id(核心): 永远存储最顶层那个一级评论的 ID。- 如果是 ID 101(一级评论),
root_id就是 0。 - 如果是 ID 102(回复 101),
root_id是 101。 - 如果是 ID 103(回复 102),
root_id还是 101(而不是 102)。
- 如果是 ID 101(一级评论),
-
reply_to_id: 仅用于前端展示“回复 @某某”,不参与核心查询逻辑。
查询神技: 不管楼盖得有多高,查询 ID 为 101 的一级评论下的所有回复,只需要一条 SQL:
SELECT * FROM comment WHERE root_id = 101 ORDER BY create_time ASC;
根本不需要递归!没有任何 N+1 问题!一个索引搞定,效率极高!
四、 追问地狱:热度排序、一致性与 @用户
别急,架构设计完了,面试官真正的高并发考验才开始。这三个回答能帮你拿 S 卡。
Q1:怎么按“热度”排序?(点赞数 + 回复数)
面试官: “点赞一直在变,每次查数据库都 ORDER BY (like_count + reply_count),数据库扛得住吗?”
Fox 破解:Redis ZSet 排行榜
千万不要在 MySQL 里实时排序! 这是计算密集型操作。
- Redis Key:
comment:hot:{video_id}:{root_id} - Value (Member):
comment_id - Score:
热度值(公式:点赞数 * 10 + 回复数 * 20 + 时间衰减因子)
流程:
- 用户点赞 -> 异步写入 MQ -> 消费者更新 MySQL -> 同时更新 Redis ZSet 的 Score。
- 查询时 -> 先查 Redis ZSet 拿到排好序的 ID List (Top 10) -> 再去 MySQL (或缓存) 里
WHERE id IN (...)查具体内容。
Q2:Redis 和 MySQL 一致性怎么保?
面试官: “Redis 更新了,MySQL 还没落库,用户刷新没变怎么办?”
Fox 破解:允许短暂不一致
评论的点赞数,不是“银行余额”,不需要强一致性!
- 策略: 用户点赞后,前端直接 +1(骗过用户眼睛,给足体验)。后台走 MQ 异步慢慢落库。哪怕 Redis 里的热度比 MySQL 里的真实数据快了 1 秒,谁在乎?
- 兜底: 就算 Redis 挂了,只是排序暂时乱了,数据都在 MySQL 里,不会丢。
Q3:@用户 怎么设计?
面试官: “评论里可以 @Fox,怎么解析?怎么存?怎么发通知?”
Fox 破解:结构化存储 + 异步通知
千万别直接存文本 “@Fox 你好”!万一我想改名叫 FoxPlus,你以前的评论显示的还是旧名字?
-
存储格式: 建议存 JSON 或者特殊标记。
- 数据库存:
"@{user_id:888, name:Fox} 你好" - 前端解析: 看到特定的格式,渲染成超链接。
- 数据库存:
-
解耦通知:
- 保存时: 后端解析出
user_id:888。 - 解耦: 扔个消息到 MQ
topic: at_notification。 - 消费: 通知服务消费消息,给 ID 为 888 的用户发站内信/Push。千万不要在保存评论的主线程里发通知,会卡死!
- 保存时: 后端解析出
五、 面试满分模板(直接背诵)
下次遇到这个问题,别犹豫,直接按这个套路输出:
“对于 B 站这种高并发评论系统,传统的树形结构是死路,必须用‘两级扁平化’设计。
- 存储结构: 放弃递归,引入
root_id冗余字段。所有子子孙孙评论的root_id都指向同一个一级评论。这样查询时,WHERE root_id = ?一次索引扫描就能拉出整楼数据,性能 O(1)。- 热度排序: 数据库只存数据,排序交给 Redis ZSet。用
(点赞+回复)计算 Score,实现高性能 TopN 排序。- 高并发优化: 引入 MQ 做削峰。点赞、回复、@通知全部异步化。
- 架构思想: 核心是 ‘读写分离,动静分离’。接受最终一致性,利用 Redis 做前置读缓存,MySQL 做兜底存储。
这就是一个生产级的、能抗千万 QPS 的评论系统架构。”
写在最后
技术面试考的从来不是你能不能写出代码,而是你 “见过多少世面” 。能用扁平化解决的,绝不搞递归;能用 MQ 异步的,绝不搞同步阻塞。
这套 扁平化存储 + Redis ZSet + MQ 异步 的组合拳,不仅能解决评论系统,帖子的回复、IM 群聊消息 通通都能用!
觉得有用的兄弟,点个赞,收藏起来,万一下次面试就用上了呢!
关注公众号【Fox爱分享】,只讲那些书上不写的实战坑。