通知投递架构:记录在哪出生、谁决定渠道、攒不攒着发、退信和投诉改变什么
比较 Transactional Outbox with Channel Workers、Preference-gated Service with Per-user Inbox、Digest with Scheduled Flush 与 Provider-tracked Delivery with Feedback Loop 四种通知拓扑:通知记录在哪里出生、会不会在业务写入和发送之间丢、谁决定渠道、决定存在哪、事件一条一发还是攒成一条、供应商的退信、投诉、过期或退订改变下一次发送的什么、状态归谁,以及一次 relay 崩溃或一个处理错的回调能波及多远、怎么收住。
产品里发生了一件事:订单付了、有人评论了、有人申请重置密码。通知这条路要把这一个事实变成收件人允许的渠道上的零条或多条投递,每条至少发一次、在收件人看来不发两次,还要从供应商回报的结果里学到东西。所有通知方案都要回答四个问题:通知记录在哪里出生、会不会在业务写入和发送之间丢;谁决定收件人拿到哪些渠道、这个决定存在哪;事件是一条一发还是攒起来一起发;一次退信、投诉、过期推送或退订会改变下一次发送的什么。这四个答案,而不是用的哪家供应商,才定义了拓扑。
想亲手走一遍四种拓扑、注入故障再恢复,打开互动 Lab:/system-design-lab/notification-delivery-architectures。
有约束的设计问题
一家公司先后要做四种通知:订单收据和密码重置,每一条都必须发出,宁可偶尔重复也不能少一条;有站内通知中心的产品通知,用户能按类别关掉推送或邮件、一键退订、设免打扰,站内看到的和推送看到的必须一致;点赞、评论、提及这类一分钟几十条的活动,一事件一通知会把人轰炸走,但安全提醒不能等;大规模邮件和短信,退信率、投诉率和停止请求决定账号还能不能发。每一种该怎么搭?
四种 topology signature
| 架构 | 记录在哪出生 | 谁决定渠道 | 发送时机 | 去重 | 反馈 | State owner | 适合 | 主要代价 |
|---|---|---|---|---|---|---|---|---|
| Transactional Outbox with Channel Workers | 业务自己的事务里,一条 outbox 行 | 各渠道 worker,按事件类型 | relay 发布后立刻 | worker 发送前查通知 id | 不建模;发失败从队列重试 | outbox / 通知表 | 绝不能丢的事务消息:收据、密码重置、安全提醒 | 端到端至少一次,每个 worker 都要幂等;relay 是单读者;outbox 不清理会一直涨 |
| Preference-gated Service with Per-user Inbox | 通知服务里,每个收件人一条站内收件箱记录 | 服务,按偏好存储,在任何外部发送之前 | 记录写完后立刻,按允许的渠道 | 记录 id 是键;同一 id 的第二个事件找到已有记录 | 退订写偏好存储;收件箱记录被标记 | 每用户的收件箱 | 有站内中心、按类别偏好、跨渠道的产品通知 | 服务是所有 producer 的关口;每个事件读一次偏好;类别没规则会静默压掉或狂发 |
| Digest with Scheduled Flush | 摘要桶里,按收件人追加 | flush 任务,按桶的类别 | 到点或免打扰结束 | 每人每窗口一个桶;已在桶里的事件不再加 | collapse id 合并推送;过期推送平台丢弃 | 摘要桶存储 | 高频活动:点赞、评论、提及,一个窗口一条比一事件一条好 | 延迟等于窗口;flush 中途失败整桶重发除非幂等;紧急事件必须绕过 |
| Provider-tracked Delivery with Feedback Loop | 投递日志里,每次尝试一行 | 上游;本拓扑只决定这个地址能不能发 | 提交时,可重试失败按退避 | 发给供应商的幂等键;日志拒绝同键的第二次进行中尝试 | 回调改日志;硬退信和投诉进压制名单 | 投递日志和压制名单 | 发信声誉和合规取决于退信、投诉、停止请求的邮件和短信 | 回调乱序还可能重复;永久失败绝不重试;名单每次发送前都要查 |
模板渲染和本地化在每种拓扑里都发生在渠道 worker 内部;按收件人限流是桶大小为一的摘要;webhook 验签是反馈回路的信任边界。它们在这里解释,不单独画。聊天投递本身见 聊天消息架构,outbox 在事务语境下的用法见 分布式事务,队列语义见 消息队列。
1. Transactional Outbox with Channel Workers:通知行和业务行同一个事务,relay 读 outbox 发队列,worker 去重后发
AWS 的模式指南把问题说清了:这个模式「解决分布式系统里单个操作既要写数据库又要发消息或事件通知时出现的双写问题」;「flight 表更新时,outbox 表在同一个事务里也被更新。另一个服务(例如事件处理服务)从 outbox 表读取并把事件发到 Amazon SQS」;「如果 flight 表更新失败或 outbox 表更新失败,整个事务回滚,所以下游不会有数据不一致」。下游是至少一次:「同一条消息或事件可能被投递不止一次,所以你应该确保事件通知服务是幂等的(也就是说,多次处理同一条消息不应该有副作用)」;「重复消息:事件处理服务可能发出重复的消息或事件,所以我们建议通过跟踪已处理的消息让消费服务幂等」;「事务回滚时不要发出事件通知」;示例里的 relay「定期扫描 outbox 表找新事件,把它们发到 Amazon SQS,并在 SQS 成功响应后从表里删除」。用 FIFO 队列时,去重 id「确保在 5 分钟的去重窗口内,只有一条相同去重 ID 的消息被处理和投递」;「如果 Amazon SQS 已经接受了某个去重 ID 的消息,之后相同 ID 的消息会被确认但不会投递给消费者」。
所以顺序是:订单 API 在一个事务里写订单行和 outbox 行,回 201;relay 只读已提交的行,把通知 id 发到队列,然后标记行已发布;worker 消费到 id,先查这条通知有没有发过,再读记录、渲染、带由通知 id 和渠道派生的幂等键调用供应商,最后确认队列消息。relay 发完再标记保证不少发,worker 先查再发和供应商的幂等键保证不多发。
典型事故:一次部署在 relay 发布之后、标记之前杀掉了进程,重启后它再读到这行、再发一次,队列里出现两条一样的通知 id——worker 按通知 id 查发送记录,第二条只确认不发,供应商按幂等键拒绝第二次,relay 发完立刻标记并定期清理已发布的行。worker 发完邮件、写发送记录之前被 OOM 杀掉,队列把消息重投给另一个 worker——幂等键设成通知 id 加渠道,供应商对同一个键返回第一次的结果。
2. Preference-gated Service with Per-user Inbox:一个服务按偏好决定渠道,先写站内记录再派发
邮件上的同意是协议要求而不是功能:RFC 8058 规定「想启用一键退订的邮件发送者在邮件里放一个 List-Unsubscribe 头和一个 List-Unsubscribe-Post 头」;「List-Unsubscribe 头必须包含一个 HTTPS URI」;「邮件接收方可以通过向 List-Unsubscribe 头里的 HTTPS URI 执行 HTTPS POST 来完成一键退订」;「退订过程必须无需人工干预就能工作,特别是不能要求软件去解读确认页面的内容」;「邮件必须有有效的 DKIM 签名,且至少覆盖 List-Unsubscribe 和 List-Unsubscribe-Post 头」。推送这边平台会保留和合并:APNs 的 apns-collapse-id 是「你用来把多条通知合并成给用户的一条通知的标识符」;apns-expiration 是「通知不再有效的日期……如果值非零,APNs 存储这条通知并至少尝试投递一次,按需重复尝试直到指定日期。如果值为 0,APNs 只尝试投递一次且不存储」;「APNs 每个 bundle ID 只存一条通知。当你向同一设备为一个 bundle ID 发送多条通知时,APNs 只选一条存储。大多数情况下存的是最新的那条」;apns-priority「10 表示立即发送」,「5 表示根据用户设备的电量考虑发送」,「1 表示把设备电量考虑置于其他一切因素之上,并且不唤醒设备」。Web Push 是同样的想法:「应用服务器必须包含 TTL 头」,「应用服务器可以包含 Urgency 头」,「带 topic 的推送消息会替换任何未送出的、topic 相同的推送消息」。
所有 producer 都把事件交给同一个通知服务。服务先读偏好存储:这个用户在这个类别下允许哪些渠道、有没有退订、现在是不是免打扰。决定之后先在这个用户的站内收件箱写一条记录,记录里带着允许的渠道和每个渠道的进度;记录存在了,才按记录派发到推送网关和邮件 worker。站内客户端读的是同一条记录,所以站内、推送、邮件三处看到的是同一件事;退订是写偏好存储,不是发送路上的过滤。
典型事故:产品上线了「有人提到你」这个新类别,偏好存储里没有它的规则,服务把查不到当成全部允许,关了推送的用户被推了,退订邮件的用户收到了邮件——查不到规则的类别默认只发站内,新类别上线前先建规则,把「默认允许」的分支从代码里删掉。为了让推送更快先调推送网关再写记录,写记录前服务崩溃,用户点开推送应用里找不到对应通知——固定顺序先写记录再派发,派发按记录里每个渠道的进度做,崩溃后从记录继续。
3. Digest with Scheduled Flush:高频事件先进每人一个桶,到点才把整桶合成一条
平台自己的合并是推送侧的摘要:APNs「当多次发送同一条通知时,在这个头里用相同的值来合并请求」,并为离线设备「每个 bundle ID 只存一条通知」;Web Push 用 topic 替换未送出的旧通知,过了 TTL 就丢弃。服务端的摘要决定那一条消息说什么;平台的合并只决定哪一条留下来。
触发发送的是时间,不是事件。聚合器把点赞、评论、提及追加进这个收件人当前窗口的桶就回 ack,自己从不发送;调度器按节奏或在免打扰结束时读出到期的桶,渲染器把整桶合成一封邮件和一条带 collapse id 的推送,发完把桶标为已发送。桶是批量的单位也是去重的单位:同一事件不会进同一个桶两次。紧急类别不进桶,直接走渠道 worker,也不受免打扰限制。
典型事故:渲染器把摘要交给供应商后被杀掉,桶没标为已发送,下一个窗口到点调度器又读到同一个桶,三十条活动再合成一封一模一样的邮件——先把桶标为「发送中」并记窗口 id,用收件人加窗口派生幂等键发送,供应商拒绝同一窗口的第二次,成功后标已发送;发送中超时的桶视为可重试。「新设备登录」事件被接进了同一条流,在桶里等到窗口关闭才和三十条点赞混在一封摘要里——按类别分流,紧急类别绕过聚合器。
4. Provider-tracked Delivery with Feedback Loop:每次发送先查名单、先记日志,供应商的回调改日志、填名单
邮件那边回来的是什么:退信对象带有「Undetermined、Permanent(硬)或 Transient(软)的退信类型」;「收到退信类型为 Permanent(硬)的退信通知时,你将来不太可能再给这个收件人发邮件。因此,你应该立即把产生退信的收件人地址从邮件列表里移除」;软退信时「SES 会在一段时间内尝试重新投递。这段时间结束后,如果 SES 仍然无法投递,它会停止尝试」;投诉意味着「你应该用这个信息确定哪个收件人提交了投诉,然后立即把该收件人从邮件列表里移除」;并且「SES 对通过 Amazon SNS 发送的通知不做顺序或批量保证」,一封邮件的送达通知和之后的退信通知「总是分开的通知」。压制名单:「只有硬退信会被加进账号级压制名单」;「如果你尝试向账号级压制名单上的地址发送……SES 接受这条消息,但不发送」;「账号级压制名单上的邮件地址会一直留在那里,直到你移除它们」。短信那边:「新创建的 Message 资源的初始状态不会发送状态回调请求」;「Twilio 在 Message 资源的状态在创建后发生变化时发送状态回调请求」;排队之后「发送成功则 sent,否则 failed」,然后「投递成功则 delivered,否则 undelivered」,支持已读回执的渠道「最终可能到达 read 状态」。重试怎么有界:投递策略「定义了 Amazon SNS 在服务端错误发生时如何重试消息投递」;「投递策略用尽后,Amazon SNS 停止重试并丢弃消息——除非订阅上挂了死信队列」;SMTP、SMS 和移动推送端点文档记载的策略是「50 次尝试,历时 6 小时」,分为退避前、退避和退避后阶段并带抖动;「Amazon SNS 把所有 5XX 错误和 429(请求过多)错误视为可重试……所有其他错误视为永久失败,不会重试」。
发送只是日志里的一次尝试,投递的真相稍后异步到达。发送器拿到通知 id 和渠道,派生幂等键,先查压制名单:在名单上就记「已压制」不发;不在就先在投递日志里写下这次尝试,再调用供应商。供应商接受后不等于送达,之后按状态变化回调;回调接收器验签后更新日志里那次尝试的状态,只让状态前进,硬退信和投诉的地址写进压制名单,软退信和 5xx 按退避重试,重试用尽进死信队列,永久失败一次都不重试。
典型事故:供应商回调了 Permanent 类型的退信,回调接收器把它记成普通失败,重试器按退避反复重发同一个不存在的地址,退信率一路上升直到供应商暂停账号——回调接收器区分类型,Permanent 退信和投诉标终态并进名单,只有 Transient 退信和 5xx 才重试;发送器每次发送前查名单。delivered 回调先到、sent 回调重试后晚到两次,接收器按到达顺序覆盖,日志里这封邮件从已送达变回已发送,重试器可能再发一次——给状态定序,日志只接受让状态前进的回调,重复的和倒退的都忽略。
四种拓扑共同的底线
- 说清哪条记录证明通知存在。 outbox 行、收件箱记录、摘要桶里的条目,还是投递日志里的尝试;队列、推送和供应商都不是。
- 先存再送。 outbox 行在事务里、收件箱记录在派发前、桶在发送前标发送中、日志在调用供应商前;先送后存的消息一旦送丢就没有第二次。
- 说清谁决定渠道、决定存在哪。 渠道 worker 按事件类型、通知服务按偏好存储、flush 按桶的类别,还是发送器按压制名单。
- 让每一跳都能重来。 worker 按通知 id 去重、供应商按幂等键拒绝、桶按窗口幂等、日志只让状态前进。
- 把反馈变成下一次的输入。 硬退信和投诉进压制名单并在每次发送前查;验签每一个回调,回调是数据不是指令,永远不能由回调触发新的发送。
故障与恢复
| 架构 | 故障 | 用户看到什么 | 恢复 |
|---|---|---|---|
| Transactional Outbox | relay 发布后崩溃没标记 | 同一封收据两次 | worker 按通知 id 去重;供应商幂等键;relay 发完立刻标记并清理 |
| Transactional Outbox | worker 发完、确认前崩溃 | 可能再发一次 | 幂等键 = 通知 id + 渠道;先记发送记录再确认 |
| Preference-gated Service | 新类别没有规则 | 关了的渠道也收到 | 缺规则默认只发站内;上线前建规则;删掉默认允许 |
| Preference-gated Service | 先推送后写记录 | 推送点开站内没有 | 先写记录再派发;按记录里每个渠道的进度继续 |
| Digest | flush 发完崩溃没标记 | 两封相同的摘要 | 先标发送中;幂等键 = 收件人 + 窗口;成功再标已发送 |
| Digest | 安全提醒进了桶 | 提醒等到窗口关闭 | 紧急类别绕过聚合器直接发 |
| Provider-tracked Delivery | 硬退信按退避重试 | 退信率超标,账号被停 | Permanent 进终态并进名单;只有 Transient 和 5xx 重试;用尽进死信 |
| Provider-tracked Delivery | 回调乱序又重复 | 已送达变回已发送 | 状态定序;只接受前进;忽略重复和倒退 |
| Provider-tracked Delivery | 名单只在失败后查 | 继续打到不存在的地址 | 每次发送前查名单;命中记为已压制 |
怎么选
- 绝不能丢的事务消息:Transactional Outbox with Channel Workers,outbox 行在业务事务里写,只从 outbox 发布,标记并清理已发布的行,每个 worker 按通知 id 幂等。
- 有站内中心和按类别偏好:Preference-gated Service with Per-user Inbox,所有 producer 经过服务,先写收件箱记录再派发,派发前读偏好,未知类别用最保守的规则,一键退订写偏好存储。
- 高频低紧急的活动:Digest with Scheduled Flush,每人每窗口一个桶,flush 先标记或带窗口派生的幂等键,推送带 collapse id,紧急类别显式绕过。
- 大规模邮件和短信:Provider-tracked Delivery with Feedback Loop,每个通知每个渠道一个幂等键,调供应商前先写日志,验签的回调更新日志,每次发送前查压制名单,只对可重试错误退避重试,其余进死信队列。
- 无论哪种:写下哪条记录证明通知存在、谁决定渠道、供应商的失败报告改变下一次的什么。
回到开头的四种通知:收据和密码重置走 Transactional Outbox;产品通知中心走 Preference-gated Service;活动摘要走 Digest;邮件短信合规走 Provider-tracked Delivery。
面试时这样回答
- 先复述约束:能不能丢、有没有站内中心和按类别偏好、频率多高紧急不紧急、退信率和投诉率是否决定账号存亡。
- 说通知路径:点名图上的边,例如「同一个事务写 outbox 行,relay 读已提交的行发队列,worker 去重后带幂等键发」「先读偏好,先写收件箱记录,再按记录派发」「追加进桶,到点整桶合成一条」「先查名单,先记日志,回调只让状态前进」。
- 说状态归谁:outbox 表、用户收件箱、摘要桶,还是投递日志。
- 说代价与一个故障:例如硬退信被当成普通失败按退避重试,修法是回调接收器区分永久和临时、永久退信进压制名单、每次发送前查名单、只有临时失败退避重试、用尽进死信。
一手证据
- AWS Prescriptive Guidance:Transactional outbox pattern
- Amazon SQS:Using the message deduplication ID
- Amazon SES:Amazon SNS notification contents for Amazon SES
- Amazon SES:Using the account-level suppression list
- Twilio:Outbound Message Status in Status Callbacks
- Amazon SNS:Message delivery retries
- IETF RFC 8058:Signaling One-Click Functionality for List Email Headers
- Apple Developer:Sending notification requests to APNs
- IETF RFC 8030:Generic Event Delivery Using HTTP Push