通知系统设计:推送、邮件和短信的架构

设计推送、邮件和短信通知系统:统一 API、偏好与免打扰时段、去重、按渠道分队列、重试和送达追踪。

· 创始人兼开发者

发布于

通知系统设计:推送、邮件和短信的架构

通知最初只是一行代码:订单发货时发一封邮件。一年之后,同一家公司从十几个服务发出推送、邮件和短信,用户同一条提醒收到三次,没人说得清重置密码的邮件到底有没有送达,营销消息还会在凌晨 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 按以下顺序执行四项检查:

  1. 去重。 把幂等键连同 TTL 一起保存,丢弃重复请求。上游服务重试是常态,但不应该以重复消息的形式打扰用户。
  2. 检查偏好。 查询用户开启的渠道、按主题退订的情况和免打扰时段。重置密码这类安全提醒通常会绕过营销退订;营销消息则绝不能绕过。把这条规则写下来。
  3. 限制数量。 按用户、渠道和小时限制消息数。一个触发 500 个事件的 bug,不应该变成 500 条推送通知。
  4. 渲染。 根据通知类型、渠道和用户语言选择模板,用事件数据填充。

免打扰时段需要知道用户的时区。除非消息有时效性,否则不要丢弃,而是先保留,安排在时段结束时发送。

每个渠道一个队列和一个工作进程池

检查完成后,API 为每个渠道写一个任务到该渠道的队列。分开的队列是这个设计中最重要的决策:短信服务商变慢时,短信任务只会在自己的队列里堆积,推送和邮件照常流转。

各渠道的工作进程负责调用服务商,并处理它们各自的特殊情况:

  • 推送。 iOS 设备走 Apple Push Notification service,Android 走 FCM HTTP v1 API。设备令牌会失效:对于不再有效的令牌,APNs 返回 410,FCM 返回 unregistered 错误。删除这些令牌,否则你会一直为没人收到的消息付出延迟。
  • 邮件。 通过服务商发送,并把退信和投诉反馈回来。如果你发送批量邮件,请加上 RFC 8058 定义的一键退订头。Gmail 的 发件人指南 要求,每天发送超过 5,000 封邮件的发件人,在营销类和订阅类邮件中提供一键退订。
  • 短信。 单条成本最高、监管最严的渠道。只用于验证码和紧急提醒,并查清你发往的每个国家的规定(本文不构成法律建议)。

工作进程对临时性失败使用指数退避加抖动进行重试,达到固定次数后把任务移入死信队列。地址无效这类永久性失败不会重试。

追踪送达

服务商通过 webhook 或响应码告诉你发生了什么:已接收、已送达、被退回、已打开。把每一次状态变化写入以通知 ID 为键的送达日志。这样,客服几秒钟就能回答“用户收到重置邮件了吗?”,你也能按渠道和服务商统计送达率。

状态更新会乱序到达,有时还会重复。把它们存成带时间戳的事件,再从中推导当前状态,而不是直接覆盖一个状态字段。

应用内通知

很多产品还有一个应用内收件箱:就是那个带数字的铃铛图标。把它当作第四个渠道,只是它写入数据库,而不是调用服务商。按用户和通知各存一行,带上已读标记,按用户和时间建索引,让应用拉取最新一页和未读数。批量标记已读,免得用户打开收件箱就触发五十次写入。

粗略估算

日活用户一百万,每人每天五条通知,系统每天就要发送 500 万条通知,平均约每秒 58 条。真正要紧的是峰值:一次营销活动或一次故障告警,可能在几分钟内发出一百万条消息。按突发量来规划队列容量,让工作进程按各服务商允许的速率消费,并做好心理准备:在你自己的工作进程耗尽容量之前,服务商就会先对你限流。

架构图

产品服务把事件发给通知 API,API 读取偏好存储,再把任务分发到推送、邮件和短信三个队列。每个队列对应各自的工作进程,分别调用 APNs 和 FCM、邮件服务商或短信服务商。服务商把状态上报到送达日志。

14 个组件 · 14 个连接

在 Archboard 中打开
图表内容
  • 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 负责去重、检查偏好并渲染模板;之后每个渠道各有自己的队列、工作进程和服务商,送达状态汇总回同一个日志。

这张图的读法如下:

  • 左侧: 产品服务把带幂等键的事件发给通知 API。API 读取偏好存储,执行 Markdown 块中列出的检查。
  • 中间: 每个渠道都有自己的队列和工作进程池,所以慢的服务商只会堵住自己的渠道。
  • 右侧: 工作进程调用 APNs 和 FCM、邮件服务商和短信服务商。状态更新稍后才异步到达,所以以点线连线流回送达日志。

图上应该画什么

  • 各项检查及其顺序。 去重、偏好、限额和渲染,才是这个系统真正的逻辑。
  • 每个渠道一条泳道。 如果你的图上所有渠道共用一个队列,生产环境里多半也是如此。
  • 重试和死信行为。 在每个工作进程上注明退避策略和重试上限。
  • 状态回流的路径。 没有它,谁也没法排查一条丢失的消息。

想更深入了解队列和消费者一侧,请看 事件驱动架构一文。微服务架构图 展示了这类共享服务在其他服务之间的位置;在存储联系方式和送达日志之前,也值得读一读 GDPR 数据映射一文。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加 C4 模型节点包中的一个 容器 表示产品服务,再添加一个表示通知 API,并在 API 下方添加一个 数据库 存放偏好。
  3. 添加数据节点包中的三个 队列 节点,每个渠道一个,再为每个渠道的工作进程各添加一个 容器。
  4. 添加 C4 模型节点包中的三个 外部系统 节点表示服务商,再添加一个 数据库 作为送达日志。
  5. 把状态连线画成点线,再添加一个基础节点包中的 Markdown 块,写明发送前的检查。

这个示例共用 14 个图表节点,在 Free 套餐的限制之内。

常见问题

为什么不让每个服务自己发通知? 因为那样一来,偏好、免打扰时段、去重和送达追踪都得在每个服务里各做一遍,而且会越来越不一致。统一的通知服务能在所有地方以同样的方式执行这些规则。

如何避免重复通知? 要求每个请求都带幂等键,并保存一段时间。重复的键会被确认,然后丢弃。

推送、邮件和短信应该共用一个队列吗? 不应该。分开的队列和工作进程池,可以避免一个变慢或出故障的服务商拖累其他渠道。

如何处理免打扰时段? 保存每个用户的时区和免打扰时段,把不紧急的消息安排在时段结束时发送,而不是直接发出或丢弃。

如何知道通知已经送达? 把服务商的响应和 webhook 记录到以通知 ID 为键的送达日志中。“送达”在不同渠道含义不同,所以要记录服务商自己的状态名称。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。