专业书籍精读 · System Design Interview 卷一 · 第 11 章
System Design Interview Vol 1 · Ch 11 · Alex Xu · 2020
你打开朋友圈、微博、领英,往下一直刷——那一列不断冒出来的东西,就叫信息流。这一章讲怎么把它造出来。它看着只是「把朋友发的东西按时间排一排」,其实是全书里最能训练直觉的一道题:同一件事,你可以在写的时候干,也可以在读的时候干,成本会差出几十倍,而且没有哪一边永远划算。
把每个人的信息流想成一份专属报纸。造它有两种办法。
办法一,提前印。只要有人发了新东西,就立刻跑一趟,往他所有朋友的那份报纸上各补一行。你早上打开时,报纸早就印好躺在那儿,拿起来就走。
办法二,来了现拼。平时什么都不做。你打开的那一刻,编辑才跑去你关注的每个人那里,各抄最新几条回来,拼成一份给你。
「现拼」是最省事的写法,也是最先崩的。你关注了两百个人,每刷新一次就要跑两百趟;只要其中一趟慢了,你就得干等着——拼一份报纸的速度,由最慢的那一趟决定,不是由平均速度决定。用户刷一下要等好几秒,这个产品就没人用了。
所以主流做法是提前印。你发一条,系统马上去查你的好友名单,往每个人的报纸上补一行。读的时候只有一个动作:把印好的那份拿过来。刷新之所以能秒开,是因为工作早在你发帖那一刻就干完了——不是变快了,是挪走了。
这套办法有个软肋:印的份数等于粉丝数。一个有一亿粉丝的账号发一条,系统要往一亿份报纸上各补一行;就算每秒能印十万份,也要印十几分钟。更亏的是,这一亿人里今天真会来看的可能只有几百万——剩下的全白印了。
于是真实系统按发布者分成两类人:普通人发的,提前印;大明星发的,不印,等你来了现拼。因为你关注的大明星撑死几十个,现拼几十趟很划算。你看到的那份 = 提前印好的那一叠 + 现拼的明星那几条,按时间掺在一起。
诚实的代价:提前印意味着同一条内容在系统里被抄了成百上千份,写入量和存储都翻上去了,而且绝大多数抄件永远不会有人看。
信息流的全部设计,就是在回答一个问题:拼这份报纸的活,放在「有人发帖时」干,还是放在「有人来看时」干?前者读得飞快但写得要命,后者写得轻松但读得难受。答案不是二选一,是按发布者分开处理——普通人提前印,大明星现拼,最后掺在一起。
想进到扇出策略、缓存分层与具体量级? → 切到精读版
这一章教的不是「怎么把好友的帖子按时间排一排」,而是一道把计算成本在写路径和读路径之间搬家的选择题:写时扇出(fan-out on write)把工作全压在发帖那一刻,换来毫秒级的刷新,代价是写放大等于粉丝数;读时扇出(fan-out on read)把工作全压在刷新那一刻,写入极省,代价是每次读都要发散查询、并被最慢的那个分片拖住尾延迟。真正的答案是按发布者分流的混合策略——而这个「分流键是发布者、不是用户也不是内容」的判断,才是本章最该带走的东西。
本章是卷一 Part II「系统案例」的第 8 篇。它上承 Ch10 通知系统——两章都是扇出问题,区别在于扇出的终点:通知扇到你控制不了的第三方通道,信息流扇到你自己的存储;下启 Ch12 聊天系统,从「一对多广播」转到「一对一/群的实时投递」。现实里它对应 Twitter 的 home timeline、Facebook News Feed、LinkedIn feed、微博和朋友圈——几乎所有社交产品的心脏。
书里的场景设定是:1000 万日活、单个用户好友数上限 5000、信息流按时间倒序、内容含图片与视频、Web 与移动端都要支持。
把量级摊开算一遍,问题立刻现形(下面是按这个场景做的推算,不是书里给的数字)。假设人均每天发 2 条,就是 2000 万条 / 天 ≈ 230 条 / 秒,峰值按 3 倍算约 700 条 / 秒——单看这个数,一台机器都够。
但只要你选了「提前物化」,账就完全变了。人均好友按 200 算(远低于 5000 上限),每条帖子要写 200 份收件箱:2000 万 × 200 = 40 亿条写入 / 天 ≈ 4.6 万次写 / 秒,峰值逼近 14 万次 / 秒。而读这一侧呢?1000 万人每天刷 10 次,也才 1 亿次 / 天 ≈ 1160 次 / 秒。
这就是本章第一个反直觉的地方:信息流看起来是个典型的读多写少产品,可一旦上了写时扇出,它立刻变成写多读少的系统——写读比约 40:1。容量规划要按扇出写入量估,按刷新次数估会低估两个数量级。
两边都有崩法:不物化,刷新就是 200 次分片查询、耗时轻松到秒级;物化了,一个上亿粉丝的账号一发帖就是上亿次写入,把分片打成热点,连带拖垮普通用户的发帖延迟。
书里最先做的动作不是选技术,而是把系统切成两条流:发布流(feed publishing,用户发帖 → 内容落库 → 分发)和读取流(feed building,用户刷新 → 取回并渲染)。对应两个接口——POST /v1/me/feed 和 GET /v1/me/feed。
为什么这一刀值得单独说?因为所有关于扇出的争论,本质都是在争「这两条流之间,工作量该怎么分配」。切开之后问题才变得可量化:写侧成本正比于粉丝数,读侧成本正比于关注数,你只是在这两个数之间选一个来承担。发布流内部则是:Web 服务器管鉴权与限流(书里明确把限流当作防刷屏手段),Post 服务把内容落库,扇出服务才是这一章真正的主角。
发帖时就把帖子 ID 推进所有好友的收件箱。好处只有一个,但极其值钱:读变成一次缓存读,几毫秒到几十毫秒,与你关注了多少人无关。
坏处是两个,书里都点了名。热点键问题:粉丝数极大的账号一发帖,扇出量爆炸——一个一亿粉丝的账号,一条内容就是 1 亿 次写入,即使你的扇出集群能吃 10 万次 / 秒,也要 约 17 分钟 才发完;这期间粉丝之间看到的时间还不一样。给僵尸用户白算:假设日活只占注册用户的 20%,那么八成的扇出写入是纯浪费——写进去、占着内存、直到被挤出去都没人读过。
这里有条常被忽略的工程纪律:收件箱里只存 <post_id, user_id> 两个 ID,不存帖子内容。按 16 字节一条算,每人缓存最近 800 条、1000 万用户,总共 128 GB——一个内存集群装得下;换成存整个帖子对象(按 1 KB 算)就是 8 TB,差 64 倍。更重要的不是省内存:内容只有一份真相源,作者编辑或删除时改一处就行,否则你要去追杀几百上千份副本。
反过来,什么都不预存,刷新时才去你关注的每个人的时间线上各取最新几条,归并成一列。写入成本降到极致(一条帖子只写一次),也彻底没有热点键——粉丝再多,发帖也只是一次写。
代价全在读侧,而且是放大式的:关注 200 人就是每次刷新 200 次分片查询。用前面的量级算,1160 次 / 秒 的刷新会被放大成 约 23 万次 / 秒 的后端查询。更麻烦的是尾延迟——发散查询的总耗时由最慢的那个分片决定,200 个分片里只要有一个在做 GC 或磁盘抖动,这次刷新就慢了。分片越多,p99 越差,这是发散查询的固有性质,不是实现没写好。
书给的答案是混合:绝大多数用户走 push,粉丝数极大的账号走 pull,读的时候把两边归并。这句话容易被记成「一半推一半拉」,那就抓错了重点——关键在于分流的依据是「谁发的」。
为什么这个分法成立?因为两侧成本各自正比于不同的数,而这两个数的分布完全不同:粉丝数是极端长尾的(少数账号上亿,绝大多数只有几百),关注数却是有天花板的(你关注几千人已经算多,其中的大 V 更是只有几十个)。于是把长尾那一小撮从 push 里摘出来,一举消掉热点键;而它们改走 pull 之后带来的读放大,因为「每人关注的大 V 只有几十个」而完全可控。这是一次拿分布特性换成本的设计,不是折中。
书里把缓存拆成五层,每层的失效节奏完全不同,这才是分层的理由:信息流层(post ID 列表)、内容层(帖子对象,热门内容再单独放一份热缓存)、社交图层(关注关系)、行为层(是否点赞、是否回复过)、计数层(赞数、评论数、粉丝数)。
为什么不能揉成一层?因为计数层每秒都在变,内容层几乎不变。揉在一起就得按变得最快的那层设过期时间,内容也跟着被反复回源,命中率被最不稳定的那一项拖垮。分开之后各设各的:内容缓存很久,计数异步聚合、容忍几秒误差——没人会因为赞数慢了 3 秒投诉,但所有人都会因为刷新慢了 3 秒投诉。
表 1 · 三种扇出策略:成本放在哪一侧,就在哪一侧付账
| 策略 | 工作发生在 | 读延迟 | 写放大 | 会死在哪 | 适合 |
|---|---|---|---|---|---|
| 写时扇出 push | 写路径 | 最低:一次缓存读,与关注数无关 | 最高:× 粉丝数(本章场景 ≈ 4.6 万次写 / 秒) | 热点键:一亿粉丝 = 一条内容 1 亿次写;且给不活跃用户白算八成 | 粉丝数分布均匀、读远多于写 |
| 读时扇出 pull | 读路径 | 最高:M 次发散查询,p99 被最慢分片拖住 | 最低:一条帖子只写一次 | 读放大:1160 次刷新 / 秒 → 23 万次查询 / 秒 | 关注数有上限、排序算法要频繁迭代 |
| 混合 hybrid (本章的选择) | 按发布者分流 | 低:一次读 + 少量大 V 拉取后归并 | 中:长尾账号被摘出 push | 复杂度:两条路径、阈值抖动、跨路径去重 | 真实系统的默认答案 |
表 2 · 混合策略落地时必须回答的四个问题
| 问题 | 常见做法 | 代价 / 坑 |
|---|---|---|
| 阈值定在哪 | 按粉丝数分档(工程上常落在万级到十万级,取决于你的扇出集群吃得下多少),而不是按「是否认证名人」这类业务标签 | 阈值附近的账号会反复横跳,必须留一条滞后带(升上去要 10 万粉、掉下来要 8 万粉) |
| 账号跨过阈值时,存量怎么办 | 只改新内容的路径,已扇出的存量不回收也不回补 | 同一作者短期内一部分在收件箱、一部分靠拉取,读侧必须按 post_id 去重,否则用户看到重复 |
| 归并在哪一层做 | 读取服务把「收件箱列表」与「大 V 最近 N 条」按时间归并 | 每次读多一次拉取;关注的大 V 越多越贵,通常要给这个数设上限 |
| 不活跃用户要不要推 | 按最近登录时间过滤,长期不活跃的只保留 pull | 省下大头写入(本章场景可省约八成),代价是用户回归时首刷要现拼、明显变慢 |
还有一个高频追问:扇出要不要走消息队列?要。同步扇出意味着发帖接口的耗时正比于粉丝数——一个 5000 好友的用户发帖会明显比别人慢。入队后发帖接口只剩一次入队的 1–5ms,扇出在后台慢慢做;代价是信息流不再实时一致:粉丝之间会有秒级到分钟级的先后差,这在按时间倒序的产品里通常可接受,在实时互动场景里就不行了。
这套骨架不是教学模型,它就是各家 feed 团队真实在维护的东西。面试里被问到信息流,能把这四点说清楚基本就过了:先拆两条流、写读两侧的成本各正比于什么、混合的分流键是发布者、以及跨路径去重和阈值滞后这两个必然会踩的坑。
5000 条 / 秒(峰值 1.2 万)——写读比被扇出彻底反转,这正是本章那个「读多写少的产品变成写多读少的系统」的现实版。InfoQ《Twitter Timeline Scalability》· Raffi Krikorian, QCon 2012 ↗140ms、比上一代系统 Sensei 快 5 倍,索引大 20 倍却只用一半机器。它给出的理由很实在:pull 让排序与相关性实验迭代得更快——因为不用回填已经物化的历史。LinkedIn Engineering《FollowFeed: LinkedIn's Feed Made Faster and Smarter》, 2016 ↗40:1)。容量按扇出写入量估,不是按刷新次数估,这是最常见的估算错误。post_id 去重,用户就会看到重复条目——这是混合方案上线后最典型的线上 bug。post_id 幂等即可,追求端到端不重不漏只会把设计复杂化。① 核心命题:信息流的设计就是决定「拼一份 feed」这份工作放在写路径还是读路径。成本不会消失,只会换位置。
② 先把系统拆成发布流与读取流两条互不相干的流;所有扇出争论都是在争这两条流之间的工作量分配。
③ 写侧成本 ∝ 粉丝数,读侧成本 ∝ 关注数。本章场景:push ≈ 4.6 万次写 / 秒,pull ≈ 23 万次查询 / 秒。
④ push 一上,读多写少的产品立刻变成写读比 40:1 的系统——容量必须按扇出写入量估。
⑤ push 死于热点键(一亿粉丝 = 一条内容 1 亿次写,10 万次 / 秒 也要约 17 分钟)和给不活跃用户白算;pull 死于读放大与尾延迟(p99 由最慢的分片决定)。
⑥ 混合是答案,但分流键是发布者:粉丝数长尾、关注数有天花板——把长尾摘出去走 pull,是拿分布特性换成本,不是和稀泥。
⑦ 三条工程纪律:收件箱只存 <post_id, user_id>(128 GB vs 8 TB,且内容保持单一真相源)、扇出走消息队列(发帖接口耗时不再正比于好友数,代价是秒级不一致)、跨路径按 post_id 去重。
⑧ 缓存要分五层(信息流 / 内容 / 社交图 / 行为 / 计数),理由是各层的变更节奏差几个数量级;揉成一层,命中率会被最不稳定的计数层拖垮。