通知系统规格:送达语义与重试策略
每次我看到一份写着“用户重置密码时给他发一条通知”的规格,我就知道这个团队马上要踩坑了。世界上根本不存在“一条通知”这种东西。邮件可能被退信,SMS 要花真金白银而且绝对不能重复,push 可能悄无声息地失败,应用内的小红点用户可能永远也不会看一眼。规格里必须点名每一个渠道,并写清每一个渠道的保证是什么。
内容整理说明
复查日期:2026-04-29。本文作为定向参考保留,主要指导已并入 API 契约 Hub。它不再出现在 sitemap、RSS 和站内搜索推荐中。
“把这条通知送出去”本身就是句假话
“把这条通知送到用户那里”这一句话里,其实藏着四种截然不同的投递模型。邮件走的是 SMTP 中继,会连续几天不停地重试。SMS 走的是运营商网关,重试一次就可能被计费两次。push 走 APNs 或 FCM,它们只会告诉你报文被接收了,但永远不会告诉你设备是不是真的亮着屏。应用内通知存在你自己的数据库里,可靠性完全取决于你的读查询。
我的规矩是:规格必须对每个渠道分别交代清楚。别再写“通知已发送”。要写成“邮件以 at-least-once 语义投递给已验证的邮箱地址,SMS 以 at-most-once 语义投递给过去 365 天内完成同意登记的 E.164 号码”。
我宁死也要守住的每渠道保证
下面这张矩阵是我写每一份通知规格时都会套用的。它故意带着强烈的立场。
- Email:at-least-once。用户收到两份收据,远比一份都没收到容易接受。遇到服务商返回 4xx,最多重试三次,指数退避。
- SMS:at-most-once。一条重复的二次验证码等于一张客服工单,还会砸掉用户的信任。一旦发送失败,记日志、走告警,千万别闭着眼重试。SMS 还要花真金白银,这一点会让决策变得清楚很多。
- Push:best-effort。push 服务只会确认 token,不会确认设备。把 push 当成一个“提示”,永远不要当成正式投递。如果这条消息真的重要,就必须再走一个渠道去兜底。
- 应用内:在读取层做到 exactly-once。客户端按通知 ID 去重,服务端持久化已读状态,哪怕用户换了设备也不能看到同一条通知出现两次。
降级阶梯必须写出具体时间
大多数规格在“push 失败后降级到邮件”这里随手挥了挥手。这不是规格,这是愿望。我想看到真正的阶梯:带着时间、带着触发条件。
以密码重置通知为例,我会把阶梯写成这样:立即把 push 发送到所有已注册的设备 token。如果 30 秒内没有收到客户端侧的 ACK,则把邮件入队。如果该通知在模板层被标记为关键(critical)(密码重置、风控告警、账号锁定),并且用户存在已同意的 SMS 号码,那就在发送邮件的同时并行发送 SMS。不等待,不再往下降级。
把“30 秒”这个数字写进去。把“关键”作为模板上的一个标志位写进去,而不是留到每次个案去判断。没有数字的阶梯,在第一次有人问“这个到底该是 30 秒还是 5 分钟?”时就会开始烂掉。
必须能扛住所有“再发一遍”的抑制机制
抑制(suppression)是初级规格最容易遗漏的部分,也恰恰是监管机构最感兴趣的地方。规格里必须描述一个集中式的抑制存储,所有发送路径在入队之前都要去查它。
- 退订状态,要区分渠道和分类。用户退订营销邮件并不等于退订密码重置,规格必须把这一点写清楚。
- 硬退信和垃圾投诉。一次硬退信就永久抑制该邮箱,直到用户重新验证。
- 运营商级别的 SMS 屏蔽(STOP 关键字、Twilio 30007)必须跨账号、跨会话持久保留。
- 临时性的速率抑制。如果某个批处理任务因为 bug 连着 10 次发同一个模板,抑制机制应该在第二次之后就把它冷冷地拦下来。
抑制必须能扛住每一次重试、每一个“再发一遍”的按钮。一旦挡不住,随便一个实习生拿 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、客服、审计和测试。没有它,未送达问题会变成纯猜谜。
继续阅读
编辑说明与免责声明
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面