cswork / 系统设计

第 8 章 / 共 57 章 · 约 9 分钟

案例 6:设计通知系统

本页目录
  1. 1. 需求澄清
  2. 2. 容量估算
  3. 3. 接口
  4. 4. 高层架构
  5. 5. 深挖
  6. 6. 面试中常见的坑
  7. 7. 回答分级
  8. 8. 不同级别的期望
  9. 章节测验

先花 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) 为键,分两阶段写:
    1. 发送前执行 SET key PENDING NX PX 60000(短 TTL)。写不进去说明键已存在:值是 SENT 就跳过;值是 PENDING 说明可能有别的 Worker 正在发,稍后再试。
    2. 供应商确认接收后,把值改成 SENT,TTL 放长(比如 7 天)。
    3. Worker 刚发完、还没提交 offset 就崩溃时,重投的消息会看到 SENT 并跳过;如果是在发送前崩溃,PENDING 过期后重投的消息能重新拿到键并补发。

为什么不能“发送前 SET NX,已存在就跳过”一步完成?因为写了键、还没真正发出去就崩溃的话,重投会被这个键拦下,消息就丢了,等于变成至多一次,这和验证码不能丢的要求冲突。两阶段写法的代价是:在“已经发出、还没改成 SENT”的小窗口里崩溃,会重发一次。

有一点要说清楚:调用第三方是跨系统的副作用,端到端的“恰好一次”做不到。请求可能已经到了 APNs,但响应在网络上丢了,你无从知道到底发没发。能做的是至少一次加尽量去重。事务类宁可重复也不能丢,用上面的两阶段键;营销类反过来,宁可漏发也别重复,可以只在发送前写一次键,接受极少量漏发。另外,APNs 的 apns-collapse-id(Android 通知用相同的 tag)能让重复的通知在设备上合并成一条显示。

5.3 优先级隔离

所有通知共用一个队列的话,一场 1 亿人的营销活动会把验证码堵在后面几十分钟,这就是队头阻塞。做法:

  • 按优先级拆 topic,每个优先级有独立的 Worker 池,高优先级预留足够容量
  • 按渠道拆分,短信供应商变慢不影响推送
  • 每个供应商配熔断器,错误率超过阈值就暂停调用;短信可以切到备用供应商

5.4 偏好、免打扰和频控

Orchestrator 对每个“用户 × 渠道”依次检查:

  1. 是否全局退订(商业邮件按法规必须提供退订入口)
  2. 是否关闭了这一类别
  3. 渠道是否可用:有没有有效的推送 token、手机号是否已验证
  4. 是否处在用户所在时区的免打扰时段:是的话放进延迟队列,到点再发
  5. 是否超过频率上限,比如营销推送每人每天最多 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+)从成本和风险出发:供应商故障切换与送达率、短信轰炸防护、跨时区大活动的节奏;说明端到端恰好一次为什么做不到,以及每类消息接受哪种失败

章节测验

6 道选择题
  1. 一场 1 亿人的营销活动开始后,登录验证码延迟了 20 分钟。最直接的修复是?
  2. 订单服务提交了数据库事务,但在把事件发到 Kafka 之前进程崩溃,用户没收到发货通知。应该用什么模式?
  3. Push Worker 调用 APNs 成功后、提交 Kafka offset 之前崩溃,消息被重新投递。既要避免重复,又不能在崩溃时丢消息,去重键应该怎么用?
  4. 用户设置了晚上 10 点到早上 8 点免打扰。凌晨 3 点他请求了登录验证码,系统应该怎么做?
  5. FCM 对某个 token 返回 UNREGISTERED,应该怎么处理?
  6. 关于通知系统的投递语义,下面哪个说法正确?

章节讨论

正在加载讨论…