用户提交订单两次刷新页面,只应该看到一条“订单已受理”的逻辑通知;短信渠道返回超时,却无法据此判断短信没有发出。前一句是自己数据库能控制的去重,后一句是跨渠道的确认缺口。把两句话合成“通知恰好送达一次”,会让重试策略无法落地。

这里设计的是本地假渠道支持的教学方案,不代表任何邮件、短信平台的实际送达语义。业务事件先落地,再根据收件人偏好、安静时段与渠道错误决定投递时机。

需求从收件人开始

输入为 event_id, recipient_id, template_version。教学契约规定同一事件、收件人、模板版本最多生成一条逻辑通知;渠道改变不是新事件,但一次明确的用户主动重发要带新请求键。偏好以创建通知时的版本作为路由快照;取消订阅则是更强的安全约束,投递前还需重新查最新授权。营销退订不能因旧的排队任务而失效。

假设目标:事件受理成功响应意味着事件及通知意图已经持久化,不意味着外部送达;近 30 天的 99.9% 合法事件在受理后 60 秒内进入终态(渠道接受、永久拒绝或待人工核查),计时从入口收到请求至本地状态记录,按自然月窗口统计。安静时段里的营销通知不计入这一目标,应另报延迟分布。认证、跨设备已读、法律规定的退订时限、短信供应商资费和真正的到达率均在本题排除项内;欠缺供应商回执时不承诺手机屏幕已显示。

容量先用假设而非“规模很大”:日均 120 万条事件、每事件平均 1.5 个收件人,生成 180 万条通知/天,平均 1,800,000 / 86,400 ≈ 20.8 条/秒。若峰值系数为 12,峰值 250 条/秒;假设每条元数据 0.8 KiB、保留 30 天、双副本,物理量约 180万 × 0.8 KiB × 30 × 2 ≈ 82.4 GiB,不含索引、渠道响应和备份。若一次批量活动把收件人数从 1.5 提到 5,写入和外呼同时变为约 3.33 倍,峰值约 833 次/秒;此时先限渠道外呼并扩展排队能力,不是先增加读缓存。60 秒目标下,以 250 次/秒持续一分钟等待最多形成 1.5 万条待投递记录;这只是预算上界假设,不能冒充队列实测吞吐。

写入确认与状态归属

POST /notifications 带事件 ID、收件人、模板版本;重复提交返回原 notification_id 和当前状态。GET /notifications/{id} 只返回本地意图和尝试状态,不能把 ACCEPTED_BY_CHANNEL 命名为 DELIVERED。数据库候选模型:notifications(id, event_id, recipient_id, template_version, preference_version, state, due_at),唯一键 (event_id,recipient_id,template_version);attempts(notification_id,seq,channel,started_at,result,next_at) 保留 TIMEOUT_UNKNOWN、RATE_LIMITED 和 ACCEPTED。真实数据库的唯一键与事务必须在选型后验证;本篇用实际 SQLite 复合主键 (event,recipient,channel) 去重固定模板的通知,Python 集合只记录假渠道接受状态;没有测试多进程消费竞争。

flowchart LR
    E[事件入口] --> T[意图存储和偏好快照]
    T --> Q[到期任务索引]
    Q --> W[限速投递者]
    W --> C{本地假渠道}
    C --> A[尝试记录和待核查队列]
    P[退订状态] --> W

受理写入与投递任务要在同一事务里提交,或使用可补偿的 outbox;任务获取需有领取期限,避免工作者崩溃永久卡住。到期扫描按 due_at, id 分页,不用只按当前时间截断全表。失败码要分开:真实 HTTP 下游若返回 429,可携带 Retry-After,该字段只能解释何时建议重试,不能证明请求未被处理,见 RFC 6585 §4 与 RFC 9110 §10.2.3。供应商不接受幂等键时,本地唯一键不能阻止超时后的外部重复。

sequenceDiagram
    participant E as 事件入口
    participant W as 投递者
    participant C as 假短信渠道
    participant R as 记录库
    E->>R: 写入同一事件的唯一通知意图
    E->>R: 重复事件:取回同一意图
    W->>C: attempt 1
    C->>C: 已接受
    C--xW: 回复超时
    W->>R: 标记 UNKNOWN,等待核查/限次重试
    W->>C: attempt 2,同一逻辑键
    C-->>W: 接受(可能二次外送)
    W->>R: 记录接受,不声称恰好到达一次

方案 A 为“立即写库、定时任务按偏好投递”,故障时易追账,但需要扫描和过期清理;方案 B 为“事件处理线程直接调渠道”,端到端延迟少一跳,却在渠道超时、入口重放或安静时段无法可靠安排后续工作。若渠道能按稳定外部幂等键查询投递结果,优先查询消除 UNKNOWN;否则设置有限重试次数和人工核查阈值,避免无限放大。永久拒绝则终止该渠道、评估偏好是否允许安全回退,不能把“转推送”偷偷当成用户已同意。

固定输入能证明什么

运行 python3 -B examples/system-design/labs/27/notify.py:重复 ok 入队返回 0,不增加通知;limited 第一次限流、返回重试时点 3,t=3 再请求被接受。uncertain 的假渠道已接受但返回超时,本地先记 unknown,再查询假渠道集合对账成 accepted,没有盲目重发。投递 revoked 前关闭偏好,状态为 suppressed、尝试数为 0。最终四条通知,尝试数分别为 limited=2、ok=1、revoked=0、uncertain=1;三个假渠道接受记录均不是用户送达证明。脚本没有安静时段、多渠道回退或实际 HTTP 429;上方二次发送时间图是无查询接口时的风险分析。原始输出、版本、命令、退出码见 examples/system-design/evidence/27/RUN.md。

恢复要从 UNKNOWN/WAIT 按重试预算扫描,先查退订和偏好,再选择查询渠道或重投;重新入队必须保留原逻辑键及已用次数。若队列积压持续超过 60 秒,先对非关键营销通知限流,展示过期数量、各渠道未知数和用户投诉数,再恢复入口额度。面试追问:用户在排队期退订怎么办?渠道只支持“接收成功”如何报告送达率?两渠道都失败会不会自动转发给不在偏好里的渠道?这些问题决定数据模型和承诺边界,而非再加一个队列就结束。

[PATTERN] 先明确“产生意图、外部接受、用户送达”三个不同确认点,再为每个确认点选择去重、重试及核查策略。

实验源码 examples/system-design/labs/27/notify.py,证据 examples/system-design/evidence/27/RUN.md。系列导读;本篇是自拟工程扩展题,不是付费课程译稿。

实验附件:权威实验源码;运行记录;版本与源码哈希;本地原始结果。