第一版按「无限层级」建模:每条评论有 parent_id,展示时递归查子评论。这个设计在数据量小的时候看不出问题,评论多了之后全面崩坏。
一是查询次数不可控。递归查询每层一次,一个有几百条评论、嵌套五六层的笔记,一次请求打出上百条 SQL。二是分页压根做不了——树形结构没法自然分页,只能全查出来在内存里组装,笔记有一万条评论就要把一万条全查出来。三是深层嵌套的展示体验很差,手机屏幕上缩进四层之后一行只能显示几个字。
关键判断是:无限层级是技术上的自由,但不是产品需要的。看主流社区产品,几乎都是两级——一级评论,以及针对一级评论的回复(回复的回复也平铺在同一层,只是文案上带「回复 @某人」)。把数据模型从树收敛成两级,是这个模块最重要的决策,它让分页、排序、计数全部变得简单。
root_id 是整个设计的核心字段。一级评论的 root_id 等于自己的 id,所有回复的 root_id 都指向所属的一级评论。这样「查某条一级评论下的所有回复」就是一个简单的等值查询加分页,不需要递归。reply_to_id 只用于展示,不参与查询逻辑。count(*)。写评论时 INCR,定时以数据库为准校准。这和计数场景的通用做法一致。note_id。因为查询几乎都是「查某个笔记的评论」,按 note_id 分表能保证单次查询落在一张表上。如果按 comment_id 分表就会导致每次查评论区都要扫所有分表,这个选择要能讲清依据——分表键跟着最高频的查询维度走。放弃无限层级的代价。确实丢失了表达能力——理论上用户可能想针对某条回复展开一个独立的子对话。但实际观察下来这种需求极少,而且产品上用「回复 @某人」的文案已经能表达出对话关系。用产品数据支撑技术决策,比单纯说「技术上更简单」有说服力。如果面试官追问「那如果产品坚持要无限层级呢」,答案是:可以用路径枚举(存一个 path 字段记录祖先链,比如 1/5/23)实现,一次查询就能取出整个子树并按 path 排序,但分页依然困难,而且深度有上限(path 字段长度限制)。
热评排序和分页的冲突。按热度排序时,用户翻页期间某条评论的热度分变了,可能导致它在第二页重复出现或者被跳过。彻底解决要把排序结果快照下来(第一次请求时生成一个有序列表存起来,翻页从快照里取),但那增加了存储和一致性成本。我们的选择是接受偶发的重复——热度分的刷新周期是分钟级,用户翻页通常在几秒内完成,实际撞上的概率很低。说清「为什么这个不完美是可接受的」比假装没有问题好。
踩过的坑:删除一级评论导致回复变孤儿。作者删掉一条一级评论后,它下面的几十条回复的 root_id 指向一个不存在的记录,查询时这些回复既不属于任何一级评论也不会被展示,但还占着评论计数。修法是删除改为逻辑删除并级联标记:一级评论被删时把 root_id 相同的回复一起标记为不可见,同时修正笔记的评论计数。这里的教训是:有父子关系的数据,删除逻辑必须和查询逻辑一起设计。
接口 P95:造一个有一万条评论(含回复)的笔记,压测拉评论区首屏。改造前约 860ms(大部分耗在几十次数据库往返和内存组装树),改造后 180ms 上下。要说清测试数据的构造方式——评论的分布(有多少条一级、平均每条几个回复)会显著影响结果,用「每条一级评论平均 5 条回复」这种描述让数字可复现。
数据库查询次数:开慢查询日志或者用 APM 看单次请求的 SQL 数量。改造前随评论数和嵌套深度增长(几十到上百次),改造后固定 3 次。这个「固定」是最有说服力的部分——它说明复杂度从 O(n) 降到了 O(1),比单纯的耗时数字更能体现设计质量。
不要报「评论发布成功率」之类的指标。评论是个简单的写入,成功率本来就该接近全部,报这个数字没有信息量,反而暴露对指标的选择能力不足。
1/5/23),一次 like '1/5/%' 就能取整个子树,按 path 排序天然是树的前序遍历,缺点是 path 长度有上限、移动节点要批量更新;闭包表额外建一张祖先后代关系表,查询最灵活但存储放大明显(一条深度 n 的评论要存 n 条关系)。评论场景我会选路径枚举,因为评论不会移动位置,path 一旦生成就不变,最大的缺点用不上。但分页问题依然存在——树形结构的分页没有好的通用解法,这也是为什么主流产品都选择收敛层级。
pipeline 或者 Lua 批量判断 20 个评论 ID。更省的做法是把某个用户点赞过的评论 ID 存成一个集合(按笔记维度分片),一次取出来在内存里比对。要注意集合的大小——用户点赞过的评论可能很多,全量取出不合适,所以按笔记维度分片存,只取当前笔记相关的那部分。
第一版是纯写扩散(推模式):用户发布内容时,遍历他的粉丝列表,给每个粉丝的收件箱插一条记录。这个设计对普通用户很好——读的时候直接查自己的收件箱,一次查询搞定。
问题出在粉丝量的分布是极度不均的。普通用户几十个粉丝,扇出几十条记录,几毫秒完成;但一个有 50 万粉丝的账号发一条内容,要插 50 万条记录。同步做的话接口要几秒甚至几十秒,用户以为发布失败反复重试;而且这几十万次写入会把数据库打满,影响所有其他业务。
更麻烦的是浪费——50 万粉丝里当天活跃的可能只有几万,给不活跃用户的收件箱写记录,他们压根不会来看。
所以设计成推拉结合:按粉丝量分流,普通用户继续推(读得快),大 V 改成拉(不扩散,读时实时查)。这是典型的按数据分布特征分级处理,也是社区系统里最经典的取舍。
大 V 走拉模式之所以可行,是因为大 V 的数量少、内容会被大量用户读取,缓存命中率极高。一个大 V 的最新内容列表被缓存后,几万个粉丝读的是同一份缓存,成本极低。这和写扩散给几十万人各存一份形成鲜明对比。
为什么不全部走拉模式(省掉收件箱)。纯拉模式下读关注流要「查关注的 N 个人,各查他们的最新内容,归并排序」。关注 500 人的用户,一次读要发起 500 次查询——读的成本转移到了每一次读,而读的次数远多于写。社区场景是读多写少,把成本压在写侧(而且可以异步)显然更划算。所以普通用户必须走推。
推拉结合的复杂度代价。要维护两套路径、要处理归并去重、游标要同时编码两种位点、阈值变化时有边界问题。代码复杂度明显高于单一模式。这个复杂度值不值得,取决于粉丝量分布是不是真的极度不均——如果所有用户粉丝量都差不多,就不该引入这个复杂度。面试时主动说「如果分布均匀我不会这么做」,能体现你是按数据决策而不是套架构。
踩过的坑:扇出消息堆积导致关注流延迟。活动期间大量用户同时发布,扇出任务在队列里积压,粉丝要几分钟后才在关注流看到新内容。修法是把扇出队列按优先级拆开——粉丝量小的任务(占绝大多数、执行快)走高优先队列,粉丝量大的走低优先队列慢慢跑。这样普通用户的体验不受少数大账号影响。教训是:同一类任务如果执行成本差几个数量级,就不该共用一个队列。
没做的部分:没做收件箱的定长裁剪。理论上收件箱应该只保留最近的 N 条(比如 1000 条),更早的从关注列表实时拉。我们是无限累积加定时清理,长期会有存储浪费。
发布接口 P95:造一个有 50 万粉丝的测试账号,压测发布接口。改造前约 5.4s(同步扇出),改造后 130ms 上下(只写内容表加发消息)。粉丝量必须说出来——「发布接口从 5.4s 降到 130ms」如果不说是多少粉丝的账号,这个数字就没有意义(普通用户压根没有 5.4s 的问题)。
同步写入次数:从数十万降为 1。这是个结构性的数字,比耗时更能说明问题——它直接说明「同步链路的工作量与粉丝量解耦了」。
扇出完成时间:要额外报这个,否则会被追问「你只是把慢转移到了后台」。实测 50 万粉丝的扇出在分片并行下几十秒内完成,粉丝侧感知的延迟在可接受范围。主动给出异步链路的耗时,说明你知道异步不等于免费。
pipeline 一次批量取 100 个 key,不是 100 次往返;三是关注大量大 V 的用户是少数。真要进一步优化,可以给「关注了很多大 V 的用户」单独做一份合并缓存,或者退回到给这部分用户做写扩散——因为这类用户少,扩散成本可控。这就是推拉结合的灵活性:可以按用户维度再分一次流。
最初的方案是「先审后发」:用户发布后进审核队列,审核通过才可见。这个方案安全但体验极差——用户发完看不到自己的内容,以为失败了,反复重发;机审排队时延迟到几十秒甚至几分钟;机审服务抖动时发布功能等于瘫痪。
另一个极端是「先发后审」:立刻可见,审核不通过再删。体验好但违规内容有一个暴露窗口,在有本地法规要求的市场是不能接受的。
最后的方案是两者结合的「先发后审 + 分级可见」:内容立刻对作者可见(作者体验和先发后审一样好),机审通过后才进入公开分发。关键洞察是「可见」不是一个布尔值,而是有范围的——仅自己可见、仅粉丝可见、公开可见是三个不同的状态。把这个维度打开之后,安全和体验的矛盾就有了解空间。
where status = '待审核'),非法的状态流转(比如已经人工通过了,机审的迟到回调又来判拒绝)要被拒绝而不是覆盖。「分级可见」不是所有内容都适用。对于高危类别(涉及法规红线的),必须先审后发,不能有任何暴露窗口。所以实际是按内容类型分流:普通图文走「先发后审 + 分级可见」,涉及特定类别(比如带商品链接的、涉及医疗健康的)走「先审后发」。用一套统一策略覆盖所有内容是错的,安全等级不同的内容要走不同的链路。
误杀和漏放的取舍方向。机审的阈值调松则漏放多,调紧则误杀多。我们的选择是向「宁可多转人工」倾斜——因为误杀会直接伤害创作者(他们会流失),而多转人工只是增加运营成本。但这个方向不是绝对的,在高危类别上必须反过来(宁可误杀)。说清「取舍方向随内容类别变化」,比给一个统一答案更专业。
踩过的坑:机审回调迟到覆盖了人工结论。一条内容机审超时转了人工,人工审核通过并公开了;十分钟后三方的机审回调迟到,结果是「拒绝」,直接把内容下架了。作者投诉。修法是状态机加严格的流转约束——人工结论的优先级高于机审,已经人工终审的内容,机审回调只记日志不改状态。这个坑的本质是「多个来源都能改同一个状态」,必须定义清楚优先级和终态。
没做的部分:没做机审规则的效果回归。理论上应该定期用人工标注的样本回测机审准确率,指导阈值调整。我们只做了误杀的个案分析,没有系统性的评估机制。
「发布到公开可见的中位延迟约 6 秒」:从审核日志里取「内容创建时间」到「visibility 变为公开的时间」的差值,取中位数。用中位数而不是平均值,因为转人工的那部分会拉高平均值到分钟级甚至小时级,平均值反映不出大多数用户的体验。被追问时要能给出分位数分布——中位 6 秒、P90 多少、转人工的占比多少。只报一个中位数容易被认为在藏问题。
不要报「违规内容拦截率」或「审核准确率」。这两个数字都需要「真实违规总量」作为分母,而那个数字压根不可知(没被发现的违规内容不在统计里)。这类分母不可知的比率是最容易被问穿的。可以报的是绝对数:每天机审处理量、转人工量、人工驳回量、申诉成功量。
「机审不可用时不阻塞」怎么验证:做故障演练——把机审服务的地址指向一个不可用的端点,验证发布功能仍然正常、内容正确转入人工队列、告警正常触发。这类可用性声明必须有演练支撑,说「我们做了降级」但没验证过,一问「你怎么知道它有效」就答不上来。
没有匹配的内容,换个关键词试试。
项目拆解 · 社区互动与消息(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据