卷一结束时,「回声」已经是一个能用的产品:用户能注册、登录、改资料、发一条动态,管理员能看到基本指标。从工程角度看,这套东西已经跑起来了;从产品角度看,它还缺一件最关键的事——没有人和人之间的互动。
这一卷要补的就是这件事。
从「功能可用」到「关系成立」
卷一的每一章,问题都比较清楚:账号要防撞库,资料要控可见性,BFF 要减少端上拼装,缓存要防止击穿,发布要能灰度。这些问题的共同点是:它们可以在一台机器、一个接口、一次请求里被想明白。
到了内容社区,问题开始跨对象、跨时间、跨用户。
一条内容从用户点「发布」到出现在陌生人的首页,中间经过至少六个系统:内容服务、审核、存储、Feed、排序、推荐。任何一段慢了,用户看到的是「发了但没人看」;任何一段错了,用户看到的是「不该出现的内容出现了」。
一次点赞看似只是一个计数加一,实际上牵动四件事:互动关系落库、计数更新、通知触发、Feed 热度重排。任何一件失败,用户看到的是「点了没反应」或者「点了两次」。
一条消息从 A 发到 B,中间至少经过连接网关、消息服务、存储、投递、推送。任何一段重复,用户看到的是两条一样的消息;任何一段丢失,用户看到的是「对方说发了但我没收到」。
所以这一卷讨论的核心,从「这个功能怎么写」变成了三件事:
- 这条数据在什么条件下对谁可见?
- 这次操作失败或重复时,用户会看到什么?
- 这条链路出故障时,影响范围有多大?
为什么先写可见性
很多内容社区的技术文章,开篇就是 Feed 架构、推荐算法、IM 协议。这些当然重要,但它们不是最先出问题的地方。
真实项目里最先出问题的,通常是可见性:
- 审核中的内容被搜索引擎抓到了。
- 拉黑的人还能在评论区看到你的回复。
- 删除的帖子还挂在热门榜上。
- 设置了「仅好友可见」,但推荐流里出现了。
- 群里踢掉的人,还能收到历史消息的推送。
这些 bug 的共同点是:它们不是性能问题,而是判定链缺了一段。而一旦发生,用户对产品的信任损失远大于一次 500 错误。
所以本卷把「内容模型」和「社交关系」放在很靠前的位置,把 Feed、推荐、搜索放在它们之后。不是因为后者不重要,而是因为后者都建立在「谁能看到什么」这个判定之上。判定错了,分发越快,错得越远。
实时能力的代价
内容社区做完之后,产品通常会往 IM 走。这是很多团队的必经之路:用户已经在内容下面互动了,自然会想要私聊、群聊、通知。
IM 的难点不在「发一条消息」,而在这些地方:
- 多端在线。同一个人手机、平板、网页同时在线,消息要都到,已读要合并。
- 离线补偿。对方离线三天,再上线时要能拉到这三天的消息,而且不能重复。
- 顺序。会话内的消息顺序必须稳定,跨会话不需要全局有序。
- 未读。未读数是派生值,真相是已读序号与最新序号;两者对不上,用户就会看到「有 3 条未读,点进去只有 1 条」。
- 推送。推送到达率不是 100%,失败要降级,而不是静默丢弃。
这些东西单独看都不难,组合起来就会产生大量边界情况。本卷用三章(消息模型、投递、推送)来拆这些边界,每一章都给一个可运行的最小实现和一组失败场景。
这一卷不追求「大而全」
市面上的系统设计书,很容易写成「某某系统的完整架构」:几十个组件、十几张图、一堆中间件选型。这类内容有参考价值,但很难直接用到自己的项目里,因为每个产品的约束都不一样。
这一卷选择另一种写法:每条结论都绑定一个触发条件。
- 什么阶段该用推模式,什么阶段该用拉模式,判据是粉丝数分布和刷新频率,不是「大厂都这么做」。
- 什么时候该上计数对账,判据是计数漂移对用户体验的影响,不是「数据必须强一致」。
- 什么时候该做通知聚合,判据是单位时间内同一用户被触发的次数,不是「聚合更优雅」。
这些判据不一定适用于你的产品,但它们至少是明确的、可以争论的、可以被数据推翻的。比起「最佳实践」,我更希望读者拿到的是「在什么条件下这个做法成立」。
代码的角色
本卷延续卷一的约定:所有核心逻辑都有可运行的 Go 代码,代码按章分包,每个包都能单独 go test。
代码的作用不是给出一份生产级实现,而是把文字里的判定链变成可执行的东西。比如「可见性判定」在文字里是一段规则,在代码里就是一个函数,输入是 owner、viewer、关系、内容属性,输出是 allow 或 deny。读者可以改一条规则,跑一次测试,立刻看到行为变化。
代码分四级:
- L1:单包纯逻辑,本卷第 1–12 章都是这一级。
- L2:进程内端到端,第 13 章有一个 HTTP 用例。
- L3:真依赖(数据库、缓存、消息队列),本卷不做。
- L4:多节点、故障注入,本卷不做。
L3 和 L4 不是不重要,而是它们依赖真实环境与真实流量,写进书里很容易变成「看起来跑过」。这一卷宁可把 L1/L2 写扎实,把 L3/L4 的判断留给卷四。
写给谁
这一卷主要写给三类人:
- 正在做内容社区、社交或 IM 的后端工程师,需要一套能落地的判定与取舍。
- 从其他领域转过来的工程师,已经会写 CRUD,但没处理过「一条内容对谁能见」这种问题。
- 技术负责人,需要在产品早期判断「哪些能力现在做、哪些可以晚点做」。
如果你只做过 To B 系统,这一卷会有一些陌生感:To B 的权限通常是角色和资源,To C 的可见性通常是关系和时间。但底层的思维方式是相通的:先定义状态,再定义谁能改,最后定义失败时怎么办。
与卷一的关系
卷一讲的是基础能力:账号、资料、BFF、API、入口、缓存、异步、发布、观测、容量。这些能力在本卷会被反复用到,但不会重复讲。
比如缓存,卷一讲了缓存模式与失效;本卷讲的是「Feed 缓存该按用户还是按作者」「已读状态能不能缓存」「删除后缓存怎么失效」。比如异步,卷一讲了消息、重试、幂等;本卷讲的是「点赞通知该不该异步」「审核结果怎么回传」「推送失败怎么补偿」。
如果读本卷时发现某个基础概念不熟,回到卷一对应章节即可。两卷的代码在同一个实验工程里,包名不冲突。
一次发布链路的事故
为了说明这一卷关心的问题,先讲一个抽象过的真实事故。
某个内容社区上线了「发布后自动同步到关注者首页」的功能。实现很直接:用户点发布,内容服务写库,然后发一条消息到队列,Feed 服务消费消息后写入每个粉丝的收件箱。
上线第一周没有问题。第二周,一位有两万粉丝的作者发了一篇长文,发布接口的响应时间从 200 毫秒涨到 4 秒。原因是 Feed 服务在同一个消费线程里逐个写两万个收件箱,写完后才 ack 消息。队列开始积压,后面的发布全部变慢。
团队的第一个修复是「把写收件箱改成异步」:发布接口只负责写内容库和发消息,Feed 服务慢慢消费。接口快了,但新的问题出现了:用户发完内容,刷新首页看不到,要等十几秒。用户以为发布失败,又点了一次,于是出现了两条一样的内容。
第二个修复是「发布后同步写作者自己的收件箱,粉丝的收件箱异步写」。这样作者自己刷新能看到,粉丝稍晚看到。听起来合理,但忽略了「作者也是别人的粉丝」:如果作者关注了自己,或者内容同时进入了推荐池,就会出现同一条内容在一个收件箱里写两次。
第三个修复是「收件箱写入按内容 ID 去重」。问题暂时解决,但两周后运营发现:热门内容的收件箱写入量是普通内容的几百倍,缓存和存储成本涨得比流量还快。团队又开始讨论「要不要改成拉模式」。
这个事故里没有一行代码是「明显错误」的。每一步修复都解决了当前最痛的问题,也都引入了新的边界。这正是内容社区服务端的典型状态:你很难一次性设计对,只能在约束变化时不断调整判定与成本结构。
本卷希望做的,是把这些「调整点」提前讲清楚:什么条件下推模式会撑不住,什么条件下拉模式会让读变慢,什么条件下混合模式值得引入。不是给一个标准答案,而是给一组可以提前判断的坐标。
为什么用「回声」而不是一个大厂案例
系统设计文章喜欢用大厂案例,因为规模足够大、问题足够典型。但大厂案例有一个问题:读者容易把「结果」当成「原因」。
比如「某公司用推模式撑住了十亿用户」,这句话省略了太多前提:他们的粉丝数分布是什么样,他们的读放大预算是多少,他们的存储成本能接受多少,他们的团队能维护多少套系统。缺少这些前提,「用推模式」就不是一个可迁移的结论。
「回声」是一个虚构的中等规模产品,用户量在百万级,团队规模在十几人。这个规模的好处是:大多数决策还没有被规模逼到「只能选一种」,读者能看到取舍本身,而不是取舍之后的结果。
书中会给出具体的数量级假设,比如「日活 50 万」「头部作者粉丝 20 万」「峰值每秒 3000 次刷新」。这些数字是示例,但它们让成本计算可以真的算一遍。读者可以把自己的数字代进去,看结论是否还成立。
本卷的三条主线
整卷 13 章,其实在反复回答三个问题。
第一条主线:内容在什么状态下对谁可见。
这条线从第 1 章的内容状态机开始,经过第 2 章的审核状态、第 5 章的关系判定、第 3 章的 Feed 过滤、第 11 章的搜索过滤,最后在第 13 章的端到端链路里收口。
这条线的核心不是「加一个 visible 字段」,而是「把判定拆成可组合的规则」:内容状态、作者设置、读者关系、场景入口,四者共同决定结果。任何一条规则缺失,都会在生产上变成「不该看到的人看到了」。
第二条主线:一次操作如何被放大,成本由谁承担。
点赞是一次操作,但它可能触发计数、通知、Feed 重排、推荐信号。发布是一次操作,但它可能触发写扩散、审核、索引、推送。评论是一次操作,但它可能触发楼中楼、通知、折叠、审核。
这条线从第 3 章的写扩散开始,经过第 6 章的计数、第 7 章的通知触发、第 10 章的推送聚合,最后在第 13 章给出放大倍数的估算方法。
核心问题是:哪些放大必须同步完成,哪些可以异步,哪些应该合并。 选错了,轻则延迟升高,重则雪崩。
第三条主线:实时通道如何保证不重、不丢、不乱。
这条线从第 8 章的消息模型开始,经过第 9 章的投递与去重、第 10 章的推送降级,最后在第 13 章的多端用例里收口。
核心问题是:可靠性来自哪里。 在线投递是尽力而为,真正的可靠性来自离线补偿、序号确认、去重窗口和对账。把可靠性寄托在「连接不断」上,是 IM 系统最常见的设计错误。
三条主线不是独立的:可见性决定 Feed 能放什么,放大成本决定 Feed 能不能放,实时通道决定互动能不能及时到达。第 13 章会把它们串在一起。
哪些问题这本书不回答
为了避免读者带着错误的期待读下去,这里明确列出本卷不覆盖的内容。
- 不回答「选哪个中间件」。 消息队列、缓存、搜索引擎、推送通道,各团队的基础设施不同,书里只讲语义与边界,不做好坏排序。
- 不回答「推荐模型怎么训」。 推荐只讲到服务边界、候选合并、特征补全与兜底。模型训练、特征工程、离线评估是另一个领域。
- 不回答「内容安全怎么做」。 审核只讲到流水线、置信度、人审与申诉,不展开模型与策略。
- 不回答「多机房怎么部署」。 多活、容灾、单元化属于规模化阶段,留到卷四。
- 不回答「怎么招人、怎么分工」。 团队与组织的话题在卷四讨论。
如果读者需要这些内容,站内有对应专题,本卷会在相关章节给出入口。把边界写清楚,比假装覆盖全部更有用。
读完这一卷应该能做到什么
如果读者认真读完并跑过代码,应该能做到下面几件事:
- 给定一个内容产品需求,能画出内容状态机,并指出每个状态的写权限与可见范围。
- 给定粉丝数分布与刷新频率,能估算推模式、拉模式、混合模式的写放大与读放大。
- 给定一组关系(关注、好友、拉黑、屏蔽),能写出可见性判定链,并说明判定顺序为什么不能换。
- 给定一次互动(点赞、评论、收藏),能列出它触发的所有副作用,并判断哪些可以异步、哪些必须同步。
- 给定一个 IM 需求,能设计会话 ID、消息序号、已读序号,并说明离线补偿与去重窗口如何配合。
- 给定一次推送失败,能说出降级链的顺序与每一级的用户可见效果。
- 给定一次内容删除,能说出数据库、缓存、索引、Feed、推送各自需要做什么,以及一致性窗口有多长。
这七件事不是「知识点」,而是七种判断。书里的代码只是这些判断的一种实现,读者可以换成自己的实现,但判断本身应该留下。
写作中反复出现的三个误区
在整理案例和评审代码的过程中,有三类误区反复出现。它们不是某一章的知识点,而是贯穿整卷的思维方式问题,提前说出来,读者读后面的章节会更有针对性。
误区一:把「能跑」当成「对」。 一个点赞接口能返回 200,不代表计数是对的。并发下的读改写、消息重试导致的重复消费、缓存与数据库的更新顺序,都会让「能跑」的代码在流量上来后产生漂移。本书每一节都要求读者问一句:这个结果在并发和重试下还成立吗?
误区二:把「配置」当成「设计」。 很多团队用配置解决一致性问题:把超时调大、把重试次数调多、把缓存时间调短。这些手段能缓解症状,但不改变判定链。如果可见性规则本身缺了一段,调参数只会让错误出现得更晚、更难复现。
误区三:把「平均」当成「体验」。 平均延迟 50 毫秒听起来很好,但如果 1% 的请求要 2 秒,这部分用户感受到的就是「这个产品很卡」。内容社区尤其如此:一次刷新要合并几十个源,任何一个源变慢都会拖累尾部。本书在讲性能时,优先看 p95、p99 和超时率,而不是平均值。
这三个误区的共同点是:它们都让系统在低负载下看起来正常。这也是为什么本书坚持给失败场景:失败场景是低负载测试看不见的那一部分。
关于「规模」的说明
本卷的案例规模是百万级用户、十几人团队。这个规模有一些特殊性,读者需要知道:
- 百万级用户还不需要自研存储,但已经需要认真做缓存与分片。
- 十几人团队不可能维护十几套系统,所以「引入一个新组件」的代价很高。
- 头部作者的粉丝数可能占到总用户的一定比例,写扩散的成本会集中爆发。
- 实时通道的在线连接数在十万级,单机撑不住,但还不需要全球多机房。
这些条件决定了本卷的默认方案偏保守:能用现有组件组合解决的,不引入新系统;能用异步削峰的,不追求同步强一致;能用兜底保证体验的,不追求每次都命中主路径。
如果读者的产品规模差一个数量级,结论可能需要调整。书里在关键处会说明「这个结论在什么规模下成立」,读者可以据此判断是否适用。规模再大一个数量级,问题会变成多机房、单元化和成本治理,那是卷四的内容。
三个可以立刻用上的检查
如果读者明天就要评审一个内容社区的服务端方案,可以先做下面三个检查。它们不能代替完整的架构评审,但能很快暴露出最危险的问题。
检查一:把可见性规则写成一张真值表。
取内容的四种状态(草稿、审核中、已发布、已删除)、读者的四种关系(本人、好友、关注者、陌生人)、三种场景(详情页、Feed、搜索),列成一张 4×4×3 的表,逐格填写可见或不可见。填表的过程中,团队通常会发现两类问题:有些格子的答案没人说得清;有些格子的答案在不同入口不一致。
这张表不需要写进代码,但它应该成为接口测试的输入。可见性判定最怕的不是规则复杂,而是规则只存在于某几个接口的 if 分支里。
检查二:给每个副作用标上「同步/异步/合并」。
列出一次操作触发的所有副作用,比如发布内容会触发写库、审核、写收件箱、建索引、推送、更新作者计数。对每一项标注:必须同步完成、可以异步、应该合并。标完后检查两件事:同步项是否太多(用户等待时间会被拉长),异步项是否缺少失败补偿(丢了就丢了)。
这个练习的价值在于暴露「隐性同步」:很多团队以为写收件箱是异步的,实际上它在消息消费线程里同步完成,队列一积压,发布就变慢。
检查三:画出消息从发送到显示的完整路径。
对 IM 或通知类需求,画出消息经过的每一个环节:客户端、网关、消息服务、存储、投递、推送、客户端。在每个环节标注「可能重复」「可能丢失」「可能乱序」。画完后,找出哪些环节有去重、哪些有补偿、哪些只靠「通常不会出问题」。
如果一条路径上有三个以上环节标注了「可能重复」却只有一处去重,那这条路径迟早会出问题。
关于「失败场景」的写法
本书每一节都有一个失败场景,它们大多来自真实事故,但做了三层抽象:去掉公司信息,把多个相似故障合并成一个,把具体报错替换成可复现的判定错误。
这样处理有好处,也有代价。好处是读者不会因为不熟悉某个中间件而读不懂;代价是某些细节被抹平了,读者可能觉得「我们的情况更复杂」。
抽象的原则有三条:
- 保留因果链。 哪个环节先坏、坏了之后如何扩散、用户看到什么,这三段必须真实。至于具体的服务名和错误码,可以换。
- 保留时间线。 故障从发生到被发现、从止损到修复,各花了多久,这个时间结构通常比技术细节更有信息量。
- 保留取舍。 当事团队为什么选了那个方案、当时知道哪些风险、事后怎么评价,这些判断比「应该怎么做」更有参考价值。
如果读者在自己的系统里遇到类似故障,建议先对照因果链,再看技术栈。技术栈不同,因果链往往相同:一次同步调用超时,可能被包装成连接池耗尽、线程阻塞、队列积压,但根因都是「把不确定性当成了确定性」。
一个提醒
内容社区是很容易被「指标」带偏的产品。DAU、时长、互动率、内容量,每一个指标都可以通过更激进的分发、更频繁的推送、更宽松的审核来提升,但这些提升往往以用户体验和长期留存为代价。
本卷在讲技术方案时,会尽量把「这个方案会让什么变好、让什么变差」写出来。不是为了让读者为难,而是因为服务端工程师离这些取舍很近:你选择推模式还是拉模式,实际上决定了小作者的内容能不能被看到;你选择通知聚合还是逐条推送,实际上决定了用户会不会关掉通知。
技术方案从来不是中立的。这一卷希望读者在做选择时,知道自己选了什么。
最后说一句读法上的建议:不要试图一次记住 13 章的所有结论。把它们当成 13 组判断,遇到具体问题时回来查。真正需要记住的只有一件事——先确定谁能看到什么,再决定怎么分发;先确定失败时用户看到什么,再决定用什么组件。 剩下的细节,代码和表格里都有。
遇到判断不了的情况,按这个顺序问:
- 这条数据如果被不该看的人看到,最坏结果是什么?
- 这次操作如果重复执行两次,用户会看到什么?
- 这条链路如果慢十倍,哪一部分先撑不住?
这三个问题没有标准答案,但它们能把讨论从「用什么技术」拉回到「产品承担什么风险」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。