专业书籍精读 · System Design Interview 卷一 · 第 10 章
System Design Interview Vol 1 · Ch 10 · Alex Xu · 2020
手机屏幕上弹出的那一条「你的包裹已派送」「有人回复了你」,背后是一整套通知系统。这一章讲怎么把它造出来——听起来像件小事,其实是全书里最典型的一堂课:你要负责的事情,最关键的那一步却不在你手里。
把通知系统想成一个快递总站。信是你写的,但最后一公里必须交给三家外包邮差:给苹果手机送的(苹果家)、给安卓手机送的(谷歌家)、发短信的(运营商)。麻烦在于,这三家只认自己的规矩:
苹果家说:人不在家时,我只帮你留最新的那一封,之前的直接扔;谷歌家说:我最多帮你存 100 封,超了全扔,而且存四个星期没送出去也扔。它们还都不给你「本人已签收」的回执——只告诉你「信我收下了」。
最朴素的做法:下单成功后,代码顺手就去敲邮差的门,等他回话再往下走。三个坏处一个比一个疼:邮差回话慢一秒,你的下单接口也跟着卡一秒;负责跑腿的那台机器一挂,所有通知凭空蒸发;大促当天通知涨了几十倍,你想加人手都插不进去。
做法是在中间放一条传送带。业务那边只管把「要发什么、发给谁」丢上传送带就走人,几毫秒的事;传送带另一头站着一排工人,慢慢取、慢慢跑腿。这样邮差再慢也慢不到你的主流程上。
关键是传送带要按邮差分开:苹果一条、安卓一条、短信一条、邮件一条。短信那家今天堵了,只堵短信这一条,推送照跑;短信忙不过来,就只给短信那条加工人。一条堵死不牵连另外三条,这是整章最值钱的一句话。
光有传送带不够,还有三件必须自己管住的事:开关——发之前先查这个人愿不愿意收这类通知;编号——每封信贴一个唯一编号,工人送之前查一下「这个号送过没」,送过就扔掉(因为跑腿失败要重试,重试就一定会重复);上限——每人每天最多几封。第三件最容易被忽略,代价却最狠:用户被烦到把通知权限一关,你以后连「支付成功」都送不到了,而且这扇门他多半不会再打开。
诚实的代价:加了传送带,通知就不再是「立刻」——正常几百毫秒到几秒,堵车时可能几分钟;而且你只能做到「至少送到一次」,重复得靠编号自己挡。
通知系统难的不是「怎么发出去」,是最后一公里在别人家:三方通道不保证送达、也不保证只送一次。你唯一能管住的是自己这一侧——用传送带把业务和跑腿隔开、按通道各走各的、每条信编个号防重复,以及别把用户烦到关掉开关。
想进到具体架构、投递语义与示意图? → 切到精读版
这一章表面在教「怎么把一条消息推到用户手机上」,实际是一堂依赖不可控外部通道的可靠性课:最后一跳交给 APNs / FCM / 短信网关,你既不能保证送达、也不能保证只送一次,于是所有工程手段全部前移到你自己这一侧——用消息队列把业务与投递解耦、按通道隔离故障、用幂等键挡住重试带来的重复、用用户设置与频率上限守住「别把用户烦到关掉通知权限」这条不可逆的底线。
本章是卷一 Part II「系统案例」的第 7 篇。它上承 Ch8 短网址、Ch9 爬虫那条「大规模读写与调度」的线,下启 Ch11 信息流——两章都是扇出问题,区别在于扇出的终点:本章扇到你控制不了的第三方通道,Ch11 扇到你自己的收件箱。现实里它对应的是几乎每个 To C 产品都会长出来的那套 notification platform(LinkedIn 的 ATC、Netflix 的 Pushy、各家的推送中台)。
先把规模摆出来。书里给的估算量级是:每天 iOS 推送 500 万、Android 推送 1000 万、短信 100 万、邮件 500 万,合计约 2100 万条 / 天,平均下来只有 243 条 / 秒——听起来毫无压力。
但均值在这里是最没用的数字。通知是典型的尖峰负载:一次面向 500 万人的营销推送,若要求 5 分钟内送完,瞬时就是 16,700 条 / 秒,是均值的 近 70 倍;一次线上故障告警要在 30 秒内触达所有值班人。系统必须按峰值设计,按均值付钱。
不做这套设计会坏在三处。拖垮主流程:下单后同步调 APNs,第三方正常 200ms、偶尔 5s,你的下单接口 p99 跟着被拽上去。静默丢失:通知只活在内存里,进程一重启,那条「支付成功」就永远不存在了,而且没有任何人会发现——通知丢失是所有故障里最安静的一种。把用户逼走:重试写得糙,同一条推送发 5 遍,用户顺手关掉通知权限——这是本章唯一不可逆的失败。
第一步是把联系方式收上来:用户安装 app 或登录时,客户端向 APNs / FCM 换取本机的 device token,交给 API 服务器落库。数据模型是一对多——user 表(user_id、邮箱、手机号)配 device 表(device_id、user_id、device_token、最近登录时间)。
这里藏着一个常被跳过的关键点:推送的收件人粒度是设备,不是人。一个用户手机 + 平板 + 旧手机三台在线,一条通知就要扇出成三次第三方调用。于是设备数而非用户数才是真正的负载参数——按人估算容量会低估两三倍。
另一半是回收。token 会因卸载、换机而失效,APNs 会回 410 Unregistered、FCM 回 UNREGISTERED。必须拿这个回执把死 token 删掉,否则两件事同时恶化:白白消耗调用配额,以及送达率统计被稀释到失真——而送达率是你判断「某条通道是不是坏了」的唯一外部信号。
最直接的做法是:各业务服务 → 一台通知服务器 → 直接调三方 → 设备。它死在三处:单点故障(那台机器挂了,全站通知停摆)、扩不动(模板渲染、图片附件、三方 SDK 全挤在一台机器上)、强耦合(第三方一慢,上游业务一起慢)。
这里有个反直觉的点值得算一笔:压垮朴素版的往往不是 QPS,是第三方的延迟方差。假设单机 200 个并发线程、三方平均耗时 200ms,理论吞吐 1000 条 / 秒;一旦短信网关抖动、耗时涨到 5s,同样 200 个线程的吞吐立刻掉到 40 条 / 秒——掉了 25 倍,而你的 CPU 还闲着。线程全被堵在等待上,这是同步直连架构的根本病灶。
改造分三刀。一:把数据库和缓存从通知服务器移出去,让服务器无状态——无状态才能水平扩容、才能自动伸缩。二:在服务器与第三方之间插入消息队列,写完队列就返回,主流程耗时从「等三方回话」压回一次入队的 1–5ms。三:每个通道一条独立队列,各配一组 worker 专职消费。
为什么是一通道一队列,而不是一条大队列加个 type 字段?两个理由,都很硬:
队列还顺带给了可观测的抓手:书里点名队列积压长度是这套系统最该盯的指标。可操作的判据是 积压条数 ÷ 出队速率 = 预计清空时间——一旦超过你的通知时效目标(比如 60 秒),就该扩容或降级,而不是等用户来投诉。
不丢,靠落库 + 重试。入队前先写一条通知日志(notification log)记下 event_id、收件人、通道、状态;worker 调用失败按指数退避重试(1s / 2s / 4s…),到上限后进死信队列并告警。诚实的代价:一条通知从 1 次网络调用变成「写库 + 调用 + 更新状态」,写放大 2–3 倍;换来的是事后能回答「这条到底发出去没有」——没有日志,你连追查的起点都没有。
不重,靠幂等,因为 exactly-once 根本做不到。网络超时时你分不清是「三方没收到」还是「三方收到了但回包丢了」,重试就必然产生重复。唯一的解法是把责任接到自己这边:每条通知带全局唯一 event_id,worker 发送前先做一次原子的「见过没」检查(Redis SET key NX EX,或数据库唯一索引),见过就丢弃。TTL 要覆盖整个重试窗口——重试窗口 24 小时,去重键就至少存 24 小时,存短了等于没做。
不烦,靠用户设置 + 频率上限。设置表很简单(user_id、channel、opt_in),关键是校验要前置到入队之前:否则队列里塞满根本不该发的东西白吃吞吐,而且用户中途关掉开关,队列里的旧消息照样会发出去。频率上限的理由书里说得很直白——用户被烦到会直接关掉系统级通知权限,那扇门关上就基本不会再开。折中做法是「入队前粗筛 + 发送前复查」,后者兜住那几秒内的设置变更。
模板:一条「订单已发货」要出中英日三语 × 推送 / 短信 / 邮件三通道 = 9 个变体;模板把不变的骨架与可变的参数分开,改一处全站生效。鉴权:调通知 API 要带 appKey / appSecret,否则任何拿到内网地址的人都能借你的通道向全体用户广播。追踪:送达率、打开率进分析系统——这不只是产品需求,三方返回 200 只说明它收下了,送达率骤降才是「通道坏了」的唯一外部信号。
这一章的真正选型不在架构图上,而在两个地方:走哪条通道,以及你打算把「重复」的责任放在哪一侧。
表 1 · 四条通道怎么选:确定性、成本与延迟的三角
| 通道 | 送达确定性 | 典型延迟 | 成本量级 | 适合 / 代价 |
|---|---|---|---|---|
| 推送 APNs / FCM | 最弱:不保证送达,用户可一键关闭;离线堆积有硬上限 | 秒级 | 边际成本 ≈ 0 | 高频、低单价、可丢的内容。代价:权限一关全盘失效 |
| 短信 SMS | 较高(运营商侧可靠),但仍无「用户已读」回执 | 秒 ~ 十几秒 | 最贵,按条计费 | 验证码、账号安全、关键告警。代价:成本随量线性上涨 |
| 邮件 Email | 中:有退信(bounce)回执,但可能进垃圾箱 | 秒 ~ 分钟 | 很低 | 账单、回执、长内容。代价:时效差,不能承载紧急信息 |
| 站内信 in-app | 最高——这是唯一完全由你掌控的通道 | 用户下次打开时 | 一次写库 | 兜底与历史留存。代价:完全被动,用户不来就等于没发 |
实践中的落法是分级 + 降级:营销只走推送 + 站内信;关键事务(支付、安全)走「推送优先,N 分钟内未点击则降级到短信」;同时所有通知都落一份站内信——它是唯一能保证「用户想找时找得到」的通道。
表 2 · 投递语义:责任放在哪一侧,决定了你要写多少代码
| 语义 | 怎么实现 | 会出什么问题 | 适用 |
|---|---|---|---|
| 至多一次 at-most-once | 发一次,失败就算了,不重试 | 会丢,且丢得静悄悄 | 纯营销、丢了无所谓的内容 |
| 至少一次 at-least-once (本章的选择) | 落库 + 指数退避重试 + 死信队列 | 会重复 | 绝大多数场景的默认选择 |
| 至少一次 + 幂等 | 上面这套,再加 event_id 去重(Redis SET NX / 唯一索引) | 多一次查询开销;去重键 TTL 短于重试窗口就等于没做 | 工程上的正解——效果等价于「恰好一次」 |
| 恰好一次 exactly-once | 端到端不可得:最后一跳在第三方家里,它自己就不保证 | 追求它只会让设计更复杂而不更正确 | 面试里说「做不到,用幂等等价替代」才是对的答案 |
还有一处常被追问:业务侧是同步调通知 API,还是直接写队列?直接写队列最快,但校验只能后移到 worker,发现参数错时消息已在队列里。多数团队选同步调一个极薄的通知 API——只做校验 + 入队、1–5ms 返回,把脏数据挡在门外,又不引入等待第三方的耗时。
这套骨架不是教学模型,它就是真实公司里那个叫「消息中台 / notification platform」的东西。面试里被问到通知系统,能把这四点说清楚基本就过了:一通道一队列(故障隔离)、event_id 幂等(重复责任在自己)、用户设置前置校验、队列积压监控(唯一有效的健康信号)。真实系统在此之上还多长了一层——决定「这条到底该不该发」的智能层,那才是各家最花力气的地方。
apns-expiration 非零时,APNs 会存储该通知并至少尝试投递一次、按需重复直到过期;设为 0 则只尝试一次且不存储——说明「重试与丢弃的规则由第三方定,不由你定」。Apple Developer Documentation《Sending notification requests to APNs》↗100 条不可折叠消息,超过就把已存的全部丢弃;消息默认最多保存 4 周——坐实了「推送通道不承诺送达」这一前提。Firebase Cloud Messaging 文档《Non-collapsible and collapsible messages》↗200 只表示 APNs / FCM 收下了请求;之后设备离线、被折叠、被丢弃、被关掉权限,你一概不知道。① 核心命题:通知系统的难点不在「怎么发」,而在最后一公里不归你管——三方通道不保证送达、不保证不重复,所有工程手段只能前移到自己这一侧。
② 收件人粒度是设备不是用户:一人多机意味着一条通知扇出成 N 次调用,容量要按设备数估;失效 token 必须靠 410 之类的回执主动回收。
③ 朴素的同步直连死于三处:单点、扩不动、强耦合;压垮它的是第三方的延迟方差而非 QPS(三方从 200ms 抖到 5s,单机吞吐掉 25 倍)。
④ 骨架三刀:服务器无状态化、插入消息队列、一通道一队列——后者换来故障隔离与独立伸缩,短信挂了不牵连推送。
⑤ 最该盯的指标是队列积压长度:积压 ÷ 出队速率 > 时效目标,就该扩容或降级。
⑥ 可靠性三件套:不丢(落库 + 指数退避 + 死信队列,代价是写放大 2–3 倍)、不重(event_id 幂等,去重键 TTL 必须覆盖整个重试窗口)、不烦(用户设置前置 + 频率上限)。
⑦ 投递语义只能是 at-least-once + 幂等;exactly-once 端到端不可得,说得出这一点比画对架构图更能证明你懂。
⑧ 通道选型是确定性 / 成本 / 时效的三角:推送最便宜最不确定、短信最贵最及时、站内信是唯一你能完全掌控的兜底通道。最贵的失败是不可逆的那种——用户关掉通知权限后连验证码都送不到,所以频率上限不是产品洁癖,是可靠性设计。