B站二面:设计一个“评论盖楼”系统,我只答了“递归”,面试官笑了:你连索引都不会建?

110 阅读6分钟

更多干货,欢迎关注公众号:【Fox爱分享

文章首发地址:mp.weixin.qq.com/s/Xe9MeJ6yQ…

写在开头

上周有个三年开发经验的粉丝找我哭诉,说去面 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:

  1. “索引怎么建?LIKE 查询会失效吗?”

答: 建普通索引。查询用 LIKE '1/23/%'。这走的是最左前缀匹配,索引不会失效,性能是可以的。

  1. “如果中间楼层(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)。
  • 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 + 时间衰减因子)

流程:

  1. 用户点赞 -> 异步写入 MQ -> 消费者更新 MySQL -> 同时更新 Redis ZSet 的 Score。
  2. 查询时 -> 先查 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} 你好"
    • 前端解析: 看到特定的格式,渲染成超链接。
  • 解耦通知:

    1. 保存时: 后端解析出 user_id:888
    2. 解耦: 扔个消息到 MQ topic: at_notification
    3. 消费: 通知服务消费消息,给 ID 为 888 的用户发站内信/Push。千万不要在保存评论的主线程里发通知,会卡死!

五、 面试满分模板(直接背诵)

下次遇到这个问题,别犹豫,直接按这个套路输出:

“对于 B 站这种高并发评论系统,传统的树形结构是死路,必须用‘两级扁平化’设计。

  1. 存储结构: 放弃递归,引入 root_id 冗余字段。所有子子孙孙评论的 root_id 都指向同一个一级评论。这样查询时,WHERE root_id = ? 一次索引扫描就能拉出整楼数据,性能 O(1)。
  2. 热度排序: 数据库只存数据,排序交给 Redis ZSet。用 (点赞+回复) 计算 Score,实现高性能 TopN 排序。
  3. 高并发优化: 引入 MQ 做削峰。点赞、回复、@通知全部异步化。
  4. 架构思想: 核心是 ‘读写分离,动静分离’。接受最终一致性,利用 Redis 做前置读缓存,MySQL 做兜底存储。

这就是一个生产级的、能抗千万 QPS 的评论系统架构。”

写在最后

技术面试考的从来不是你能不能写出代码,而是你 “见过多少世面” 。能用扁平化解决的,绝不搞递归;能用 MQ 异步的,绝不搞同步阻塞。

这套 扁平化存储 + Redis ZSet + MQ 异步 的组合拳,不仅能解决评论系统,帖子的回复、IM 群聊消息 通通都能用!

觉得有用的兄弟,点个赞,收藏起来,万一下次面试就用上了呢!

关注公众号【Fox爱分享】,只讲那些书上不写的实战坑。