通知系统规格:送达语义与重试策略

通知系统规格:送达语义与重试策略
Spec Coding 编辑部 · Spec-First 工程实践内容

每次我看到一份写着“用户重置密码时给他发一条通知”的规格,我就知道这个团队马上要踩坑了。世界上根本不存在“一条通知”这种东西。邮件可能被退信,SMS 要花真金白银而且绝对不能重复,push 可能悄无声息地失败,应用内的小红点用户可能永远也不会看一眼。规格里必须点名每一个渠道,并写清每一个渠道的保证是什么。

发布于 2026-03-01 · ✓ 已更新 2026-04-29 · 阅读约 6 分钟 · 作者:Spec Coding 编辑部 · 审校:编辑与事实核查政策

内容整理说明

复查日期:2026-04-29。本文作为定向参考保留,主要指导已并入 API 契约 Hub。它不再出现在 sitemap、RSS 和站内搜索推荐中。

“把这条通知送出去”本身就是句假话

“把这条通知送到用户那里”这一句话里,其实藏着四种截然不同的投递模型。邮件走的是 SMTP 中继,会连续几天不停地重试。SMS 走的是运营商网关,重试一次就可能被计费两次。push 走 APNs 或 FCM,它们只会告诉你报文被接收了,但永远不会告诉你设备是不是真的亮着屏。应用内通知存在你自己的数据库里,可靠性完全取决于你的读查询。

我的规矩是:规格必须对每个渠道分别交代清楚。别再写“通知已发送”。要写成“邮件以 at-least-once 语义投递给已验证的邮箱地址,SMS 以 at-most-once 语义投递给过去 365 天内完成同意登记的 E.164 号码”。

我宁死也要守住的每渠道保证

下面这张矩阵是我写每一份通知规格时都会套用的。它故意带着强烈的立场。

降级阶梯必须写出具体时间

大多数规格在“push 失败后降级到邮件”这里随手挥了挥手。这不是规格,这是愿望。我想看到真正的阶梯:带着时间、带着触发条件。

以密码重置通知为例,我会把阶梯写成这样:立即把 push 发送到所有已注册的设备 token。如果 30 秒内没有收到客户端侧的 ACK,则把邮件入队。如果该通知在模板层被标记为关键(critical)(密码重置、风控告警、账号锁定),并且用户存在已同意的 SMS 号码,那就在发送邮件的同时并行发送 SMS。不等待,不再往下降级。

把“30 秒”这个数字写进去。把“关键”作为模板上的一个标志位写进去,而不是留到每次个案去判断。没有数字的阶梯,在第一次有人问“这个到底该是 30 秒还是 5 分钟?”时就会开始烂掉。

必须能扛住所有“再发一遍”的抑制机制

抑制(suppression)是初级规格最容易遗漏的部分,也恰恰是监管机构最感兴趣的地方。规格里必须描述一个集中式的抑制存储,所有发送路径在入队之前都要去查它。

抑制必须能扛住每一次重试、每一个“再发一遍”的按钮。一旦挡不住,随便一个实习生拿 SQL 控制台就能把你整个用户群轰一遍。

速率限制也是契约的一部分

我写速率限制会分两个维度。按用户:同一分类每小时不超过 N 条、每天总通知数不超过 M。按账号(针对 B2B 产品):每个租户每天不超过 X 条,因为一个坏掉的工作流不该把你整个发件域的声誉烧光。这两条限制必须写进规格,而不是丢进一个没人评审的配置文件里。

批量、模板与版本陷阱

不是每一条通知都要单独飞出去。对于社交动态和低紧急度的活动,我会规定一种聚合摘要:用户在注册时选择订阅,摘要每 N 小时发送一次,规格里要写明截断时间(超过 24 小时的一律不发)。实时通道只留给交易消息和安全消息。

模板版本化是另一个陷阱。规格必须写清楚:模板是有版本的、每一次在途的发送都要记录它所使用的模板版本,灰度的方式是把一部分发送路由到 v2,同时让 v1 的队列自然排空。绝对不要在线上队列里热替换模板。我亲眼见过这种操作,让同一个用户在九十秒内收到两封内容不同的密码重置邮件。

投递回执:什么都别信

产品经理都爱看投递仪表盘。规格要把真实期望讲出来:邮件的“打开”依赖一个像素追踪图,很多邮件客户端会屏蔽掉,所以打开率只能当成下限看。SMS 的投递报告(MDN)依赖运营商,有些运营商甚至会直接说谎。APNs 或 FCM 给的 push 回执只能说明消息被 push 服务接收了,不代表手机把它渲染出来。唯一可靠的回执是用户在应用里做出对应动作,这也是为什么关键流程要自带一步确认。

合规与可观测性

我写的每一份通知规格都有一块合规章节。CAN-SPAM 要求所有商业邮件里都要有一个能用的退订链接,必须在 10 个工作日内生效。TCPA 要求营销 SMS 必须取得事前明示的书面同意,规格里要写清楚这份同意是在哪个环节采集的。GDPR 要求有合法基础:交易类邮件属于合同必要性,营销类属于同意,规格要逐个模板列出适用的依据。

仪表盘要在代码上线之前就命名好。按渠道的成功率(邮件低于 98% 就叫醒 on-call)。按模板的打开率和点击率,这样才能抓到那条让互动率崩盘的标题。投诉率按周跟踪,因为一旦越过 0.3%,你的发信域可能会被限速好几周。

密码重置通知的验收标准

- Given a user requests a password reset
  And the user has a verified email and an SMS-consented phone number
  When the reset event is emitted
  Then a push is dispatched to all registered devices
  And if no push ACK arrives within 30 seconds
  Then an email is enqueued for at-least-once delivery
  And an SMS is dispatched at-most-once in parallel

- Given the user has previously unsubscribed from marketing email
  When a password reset is triggered
  Then the transactional email is still sent
  And the suppression check records "transactional override"

- Given the SMS provider returns a transient error
  When the dispatcher handles the failure
  Then no retry is attempted
  And the event is logged with severity "warn" and surfaced on the SMS success-rate dashboard

最后一句

通知系统不是一个承诺,而是四个。把规格写成四个承诺:点名渠道,点名保证,点名降级时间,点名抑制规则。做到这一点,你的 on-call 排班就不会再沦为“用户说他没收到邮件”的墓地夜班。不做这一点,你只会在不同的壳子里不停地重发同一个 bug。

通知系统要按渠道写保证

邮件、短信、推送和站内信不该共享一句“尽力送达”。每个渠道的重试、去重、降级和用户可见状态都不同。规格里要把保证写成表。

Delivery guarantee:
- password reset email: at-least-once, dedupe by token_id, retry 3 times
- security SMS: at-most-once per 60 seconds, no fallback to marketing channel
- push marketing: best effort, suppressed during quiet hours
- in-app alert: exactly one unread item per event_id
- evidence: notification_attempt row records provider response and suppression reason

边界:不要承诺供应商无法保证的语义。短信和邮件天然会重复或延迟,系统能保证的是去重、审计和合理重试。

抑制规则要可解释

每次没有发送通知,都应该有 suppression_reason:quiet_hours、user_opt_out、provider_bounce、rate_limit、legal_hold。这个字段同时服务 API、客服、审计和测试。没有它,未送达问题会变成纯猜谜。

关键词:notification system spec · at-least-once delivery · SMS at-most-once · fallback ladder · suppression store · template versioning · TCPA consent

编辑说明与免责声明

本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。