第 8 章 / 共 57 章 · 约 9 分钟
案例 6:设计通知系统
先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。
先写你的思路
只存在这台设备上;登录后会存进你的账号。
通知系统看起来像个转发器,实际上是一道可靠性工程题。上游业务只说一句“告诉用户 X,他的包裹发货了”,通知系统要决定:发不发、走哪个渠道、什么时候发、失败了怎么办、会不会发重。
1. 需求澄清
功能需求:
- 渠道:App 推送(iOS 走 APNs,Android 走 FCM)、短信、邮件、站内信
- 两类触发:业务事件触发的事务类通知(验证码、订单状态、安全提醒),以及运营发起的营销类批量通知(一次面向几千万用户)
- 定时发送
- 模板与多语言
- 用户偏好:按类别、按渠道退订;免打扰时段;频率上限
- 投递跟踪:已发送、已送达、已打开、已点击、失败
不做:自建短信网关或邮件服务器,统一对接第三方供应商。
非功能需求:
- 不丢:验证码、安全提醒丢了就是线上事故
- 尽量不重:同一条营销推送收到三次,用户可能直接卸载
- 优先级隔离:营销大促不能让验证码排队
- 能吞突发:几千万用户的活动一次性进来也要扛住
- 延迟分层:事务类秒级;营销类几分钟到几小时都可以
第一个问题最好就问:“事务类和营销类都要支持吗?”两者的 SLO 差别很大,后面几乎所有设计都围绕这条分界线展开。
2. 容量估算
| 指标 | 估算 |
|---|---|
| 日通知量 | 10 亿条 |
| 平均 QPS | ≈ 1.2 万/秒 |
| 营销活动峰值 | 1 小时发完 1 亿 ≈ 2.8 万/秒 |
| 单条日志 | 约 500 B(id、用户、渠道、状态、时间) |
| 日志存储 | 10 亿 × 500 B ≈ 500 GB/天 |
瓶颈往往不在自己的系统,而在第三方供应商的速率限制和费用。短信按条计费、有每秒上限;邮件服务商会限制发送速率,并且盯着退信率和投诉率。
3. 接口
POST /v1/notifications
{
"idempotency_key": "order-123-shipped",
"recipients": {"user_ids": [...]} | {"segment_id": "..."},
"template_id": "order_shipped",
"params": {"order_id": "123"},
"category": "transactional" | "marketing",
"channels": ["push", "email"], // 可选,不传则按用户偏好决定
"send_at": "2026-10-20T09:00:00Z" // 可选
}
→ 202 Accepted {"notification_id": "..."}
GET /v1/notifications/{id} 查询状态
PUT /v1/users/{id}/preferences 更新偏好
POST /v1/devices 注册推送 token {platform, token}
POST /v1/callbacks/{provider} 接收供应商回执(webhook)
返回 202 而不是 200:接口只承诺“收到了,会处理”,真正的发送是异步的。
4. 高层架构
查看图表源码
flowchart TD
accTitle: 通知系统的高层架构
up["上游业务"] --> napi["Notification API"]
napi -->|"校验 + 幂等检查"| req["Kafka: requests.{high, normal, low}"]
req --> orch["Orchestrator Workers:展开人群、查偏好 / 免打扰 / 频控、渲染模板、选渠道"]
orch --> pq["push.{high,low}"] --> pw["Push Workers"] --> apns["APNs / FCM"]
orch --> sq["sms.{high,low}"] --> sw["SMS Workers"] --> smsp["短信供应商 A / B"]
orch --> eq["email.{high,low}"] --> ew["Email Workers"] --> mailp["邮件供应商"]
orch --> inapp["inapp"] --> inbox["Inbox DB + WebSocket"]
apns -->|"回执 / webhook"| status["状态事件"]
smsp -->|"回执 / webhook"| status
mailp -->|"回执 / webhook"| status
status --> track[("跟踪存储 + 分析")]
旁路存储:用户偏好库(读多写少,加缓存)、设备 token 库、模板库、通知日志(宽列存储,按 user_id 或 notification_id 分区)、做去重和频控的 Redis。
5. 深挖
5.1 从源头不丢:Outbox 模式
最隐蔽的丢失发生在上游。订单服务提交了数据库事务,还没把事件发到 Kafka 就崩了:订单显示已发货,通知永远不会发。
解决办法是事务性 Outbox:业务表的更新和一条 outbox 记录写在同一个本地事务里,再由中继进程(轮询或 CDC)把 outbox 记录投递到 Kafka,确认后标记为已发送。中继可能重复投递,所以下游必须幂等。
通知系统内部同理:API 等 Kafka 确认写入(acks=all)后才返回 202;Worker 等供应商确认接收后才提交 offset。
5.2 重试、死信和去重
- 供应商返回 5xx 或超时:指数退避加随机抖动重试,超过次数进死信队列,再由人工或定时任务处理。
- 返回 4xx(号码格式错误、token 失效):重试没意义,直接标记失败。
- 去重:以
(notification_id, user_id, channel)为键,分两阶段写:- 发送前执行
SET key PENDING NX PX 60000(短 TTL)。写不进去说明键已存在:值是SENT就跳过;值是PENDING说明可能有别的 Worker 正在发,稍后再试。 - 供应商确认接收后,把值改成
SENT,TTL 放长(比如 7 天)。 - Worker 刚发完、还没提交 offset 就崩溃时,重投的消息会看到
SENT并跳过;如果是在发送前崩溃,PENDING过期后重投的消息能重新拿到键并补发。
- 发送前执行
为什么不能“发送前 SET NX,已存在就跳过”一步完成?因为写了键、还没真正发出去就崩溃的话,重投会被这个键拦下,消息就丢了,等于变成至多一次,这和验证码不能丢的要求冲突。两阶段写法的代价是:在“已经发出、还没改成 SENT”的小窗口里崩溃,会重发一次。
有一点要说清楚:调用第三方是跨系统的副作用,端到端的“恰好一次”做不到。请求可能已经到了 APNs,但响应在网络上丢了,你无从知道到底发没发。能做的是至少一次加尽量去重。事务类宁可重复也不能丢,用上面的两阶段键;营销类反过来,宁可漏发也别重复,可以只在发送前写一次键,接受极少量漏发。另外,APNs 的 apns-collapse-id(Android 通知用相同的 tag)能让重复的通知在设备上合并成一条显示。
5.3 优先级隔离
所有通知共用一个队列的话,一场 1 亿人的营销活动会把验证码堵在后面几十分钟,这就是队头阻塞。做法:
- 按优先级拆 topic,每个优先级有独立的 Worker 池,高优先级预留足够容量
- 按渠道拆分,短信供应商变慢不影响推送
- 每个供应商配熔断器,错误率超过阈值就暂停调用;短信可以切到备用供应商
5.4 偏好、免打扰和频控
Orchestrator 对每个“用户 × 渠道”依次检查:
- 是否全局退订(商业邮件按法规必须提供退订入口)
- 是否关闭了这一类别
- 渠道是否可用:有没有有效的推送 token、手机号是否已验证
- 是否处在用户所在时区的免打扰时段:是的话放进延迟队列,到点再发
- 是否超过频率上限,比如营销推送每人每天最多 3 条,用 Redis 计数器加过期时间实现
事务类通知跳过第 4、5 步。凌晨三点的登录验证码也得马上送到。
跳过频控不等于不设防。发验证码的接口本身要按手机号、IP、设备限流,并防短信刷量(SMS pumping):攻击者用脚本向高资费号段批量请求验证码,从运营商分成里牟利。常见手段是人机校验、限制可发送的国家和号段、监控“发了验证码却没人验证”的比例。
5.5 大规模营销活动
1 亿用户的活动不能变成一条巨大的消息:
- 人群展开作业按用户 ID 分段分页读取,每 1000 人打包成一个任务写进队列,Worker 并行处理
- 整体速率用令牌桶控制,均匀摊到发送窗口里,同时照顾供应商的限速
- 跨时区活动按当地时间分批发送
- 模板按语言预渲染,每个用户只填参数
5.6 定时发送
带 send_at 的通知写入一张按发送时间分桶的表(比如按分钟分桶),调度器每分钟取出到期的桶,投递到对应队列。多个调度器实例靠租约或分布式锁保证同一个桶只处理一次;就算偶尔重复,后面的去重也能兜住。
5.7 设备 token 和投递跟踪
- APNs 返回 410(token 已失效)、FCM 返回
UNREGISTERED时,立即删除该 token,否则会一直白发,还会拉低送达率。 - 一个用户多台设备:向所有有效 token 发送。
- 跟踪:每次状态变化都作为事件写入日志。邮件打开用 1×1 追踪像素统计,点击通过跳转链接统计。这些数据只做分析,不在发送主路径上同步写。
5.8 站内信
站内信本质上是一张按用户分区、按时间倒序的收件箱表,加上 Redis 里的未读计数。用户在线就通过 WebSocket 推一下;不在线就等他打开 App 时拉取。
6. 面试中常见的坑
- 所有通知共用一个队列,没有优先级
- 只讲重试不讲去重,或者声称能做到端到端恰好一次
- 发送前一步写死去重键,崩溃时把验证码丢掉
- 忽略上游“事务提交了、事件没发出去”的情况
- 免打扰和频控把验证码也拦住了
- 没考虑第三方供应商的限速和故障切换
7. 回答分级
不丢消息
- Bad:上游先提交事务再发事件,中间崩溃就丢;接口先返回再异步写队列。
- Good:用事务性 outbox 把业务变更和待发事件写进同一个本地事务;Kafka 确认(acks=all)后才返回 202,供应商受理后才提交 offset。
- Great:按类别区分失败偏好:验证码宁可重复不能丢,营销宁可漏发不能重复,并据此选两阶段去重键或发送前写一次键;说明调用第三方是跨系统副作用,端到端恰好一次做不到。
重试与去重
- Bad:失败就无限重试,不去重;或者声称做到了端到端恰好一次。
- Good:5xx 和超时用指数退避加抖动重试,超过次数进死信队列;4xx 直接标记失败;按 (notification_id, user_id, channel) 去重。
- Great:讲清两阶段去重键(PENDING 短 TTL、SENT 长 TTL)在每个崩溃点的行为,再用 APNs 的 collapse-id 或 Android 通知的 tag 在设备端合并重复通知。
隔离与规模
- Bad:所有通知共用一个队列,一场营销活动把验证码堵上几十分钟。
- Good:按优先级和渠道拆分 topic 与 worker 池,给高优先级预留容量;每个供应商一个熔断器,短信可以切到备用供应商。
- Great:用令牌桶按供应商限额把大活动摊开发送,并按用户时区投递;免打扰和频控不拦验证码,但验证码接口本身要按手机号、IP、设备限流,并防范短信轰炸(SMS pumping)。
8. 不同级别的期望
| 级别 | 期望 |
|---|---|
| Mid(L4) | 在提示下给出 API、队列、各渠道 worker 和供应商的完整链路,知道要重试、要尊重用户偏好 |
| Senior(L5) | 主动区分交易类和营销类消息,讲清 outbox、去重键、优先级隔离和供应商限流 |
| Staff(L6+) | 从成本和风险出发:供应商故障切换与送达率、短信轰炸防护、跨时区大活动的节奏;说明端到端恰好一次为什么做不到,以及每类消息接受哪种失败 |