通知最初只是一行代码:订单发货时发一封邮件。一年之后,同一家公司从十几个服务发出推送、邮件和短信,用户同一条提醒收到三次,没人说得清重置密码的邮件到底有没有送达,营销消息还会在凌晨 3 点把人吵醒。
通知系统的解决办法,是在“判断某件事已经发生”的代码与“负责投递消息”的服务商之间放一个统一的服务。本文就来设计它:API、发送前执行的检查、带重试的分渠道投递,以及送达追踪。文末的架构图展示了完整流程。
需求
- 产品服务只需一次调用就能发出通知,无需知道用户开启了哪些渠道。
- 支持推送(iOS 和 Android)、邮件和短信,并按语言提供模板。
- 用户可以管理渠道和主题,免打扰时段得到遵守。
- 同一个事件绝不会产生两条相同的消息。
- 每条消息都有状态:已发送、已送达、被退回、失败。
- 一个服务商变慢,绝不拖累其他渠道。
API:发送事件,而不是消息
产品服务不应该自己拼邮件。它们发送一个包含事实和幂等键的事件:
POST /notifications
{
"idempotencyKey": "order-8812-shipped",
"userId": "u_7",
"type": "order_shipped",
"data": {"orderId": "8812", "carrier": "UPS", "eta": "2026-10-30"}
}
其余一切都归通知服务管:这类通知走哪些渠道、用哪个模板、用哪种语言,以及用户到底想不想收到。当某个团队想修改文案,或者给某类通知加上短信时,订单服务里什么都不用改。
发送之前
在任务进入任何渠道之前,API 按以下顺序执行四项检查:
- 去重。 把幂等键连同 TTL 一起保存,丢弃重复请求。上游服务重试是常态,但不应该以重复消息的形式打扰用户。
- 检查偏好。 查询用户开启的渠道、按主题退订的情况和免打扰时段。重置密码这类安全提醒通常会绕过营销退订;营销消息则绝不能绕过。把这条规则写下来。
- 限制数量。 按用户、渠道和小时限制消息数。一个触发 500 个事件的 bug,不应该变成 500 条推送通知。
- 渲染。 根据通知类型、渠道和用户语言选择模板,用事件数据填充。
免打扰时段需要知道用户的时区。除非消息有时效性,否则不要丢弃,而是先保留,安排在时段结束时发送。
每个渠道一个队列和一个工作进程池
检查完成后,API 为每个渠道写一个任务到该渠道的队列。分开的队列是这个设计中最重要的决策:短信服务商变慢时,短信任务只会在自己的队列里堆积,推送和邮件照常流转。
各渠道的工作进程负责调用服务商,并处理它们各自的特殊情况:
- 推送。 iOS 设备走 Apple Push Notification service,Android 走 FCM HTTP v1 API。设备令牌会失效:对于不再有效的令牌,APNs 返回 410,FCM 返回 unregistered 错误。删除这些令牌,否则你会一直为没人收到的消息付出延迟。
- 邮件。 通过服务商发送,并把退信和投诉反馈回来。如果你发送批量邮件,请加上 RFC 8058 定义的一键退订头。Gmail 的 发件人指南 要求,每天发送超过 5,000 封邮件的发件人,在营销类和订阅类邮件中提供一键退订。
- 短信。 单条成本最高、监管最严的渠道。只用于验证码和紧急提醒,并查清你发往的每个国家的规定(本文不构成法律建议)。
工作进程对临时性失败使用指数退避加抖动进行重试,达到固定次数后把任务移入死信队列。地址无效这类永久性失败不会重试。
追踪送达
服务商通过 webhook 或响应码告诉你发生了什么:已接收、已送达、被退回、已打开。把每一次状态变化写入以通知 ID 为键的送达日志。这样,客服几秒钟就能回答“用户收到重置邮件了吗?”,你也能按渠道和服务商统计送达率。
状态更新会乱序到达,有时还会重复。把它们存成带时间戳的事件,再从中推导当前状态,而不是直接覆盖一个状态字段。
应用内通知
很多产品还有一个应用内收件箱:就是那个带数字的铃铛图标。把它当作第四个渠道,只是它写入数据库,而不是调用服务商。按用户和通知各存一行,带上已读标记,按用户和时间建索引,让应用拉取最新一页和未读数。批量标记已读,免得用户打开收件箱就触发五十次写入。
粗略估算
日活用户一百万,每人每天五条通知,系统每天就要发送 500 万条通知,平均约每秒 58 条。真正要紧的是峰值:一次营销活动或一次故障告警,可能在几分钟内发出一百万条消息。按突发量来规划队列容量,让工作进程按各服务商允许的速率消费,并做好心理准备:在你自己的工作进程耗尽容量之前,服务商就会先对你限流。
架构图
图表内容
- Product services
- Notification API
- Preferences
- Push queue
- Email queue
- SMS queue
- Push workers
- Email workers
- SMS workers
- APNs and FCM
- Email provider
- SMS provider
- Delivery log
- event
- opt-outs, quiet hours
- status
- webhooks
这张图的读法如下:
- 左侧: 产品服务把带幂等键的事件发给通知 API。API 读取偏好存储,执行 Markdown 块中列出的检查。
- 中间: 每个渠道都有自己的队列和工作进程池,所以慢的服务商只会堵住自己的渠道。
- 右侧: 工作进程调用 APNs 和 FCM、邮件服务商和短信服务商。状态更新稍后才异步到达,所以以点线连线流回送达日志。
图上应该画什么
- 各项检查及其顺序。 去重、偏好、限额和渲染,才是这个系统真正的逻辑。
- 每个渠道一条泳道。 如果你的图上所有渠道共用一个队列,生产环境里多半也是如此。
- 重试和死信行为。 在每个工作进程上注明退避策略和重试上限。
- 状态回流的路径。 没有它,谁也没法排查一条丢失的消息。
想更深入了解队列和消费者一侧,请看 事件驱动架构一文。微服务架构图 展示了这类共享服务在其他服务之间的位置;在存储联系方式和送达日志之前,也值得读一读 GDPR 数据映射一文。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的一个 容器 表示产品服务,再添加一个表示通知 API,并在 API 下方添加一个 数据库 存放偏好。
- 添加数据节点包中的三个 队列 节点,每个渠道一个,再为每个渠道的工作进程各添加一个 容器。
- 添加 C4 模型节点包中的三个 外部系统 节点表示服务商,再添加一个 数据库 作为送达日志。
- 把状态连线画成点线,再添加一个基础节点包中的 Markdown 块,写明发送前的检查。
这个示例共用 14 个图表节点,在 Free 套餐的限制之内。
常见问题
为什么不让每个服务自己发通知? 因为那样一来,偏好、免打扰时段、去重和送达追踪都得在每个服务里各做一遍,而且会越来越不一致。统一的通知服务能在所有地方以同样的方式执行这些规则。
如何避免重复通知? 要求每个请求都带幂等键,并保存一段时间。重复的键会被确认,然后丢弃。
推送、邮件和短信应该共用一个队列吗? 不应该。分开的队列和工作进程池,可以避免一个变慢或出故障的服务商拖累其他渠道。
如何处理免打扰时段? 保存每个用户的时区和免打扰时段,把不紧急的消息安排在时段结束时发送,而不是直接发出或丢弃。
如何知道通知已经送达? 把服务商的响应和 webhook 记录到以通知 ID 为键的送达日志中。“送达”在不同渠道含义不同,所以要记录服务商自己的状态名称。