构建可靠的时区感知型推送通知系统
一款健康与保健应用需要向全球数千名用户发送个性化的每日提醒 — 用餐提醒、情绪打卡、补水提示、睡眠提示和自定义提醒 — 。难点在于:每条通知都必须在 正确的本地时间、 且仅一次 地发送,并且绝不能发送到已停用的设备。我们设计并构建了实现这一目标的分布式管道。
挑战
- 大规模时区准确性。 “早上9点的早餐提醒” 对每个用户来说意义不同。如果按照服务器时间发送,悉尼的用户会在凌晨3点收到通知。每条通知都需要根据用户的 本地 时间来确定。
- 无垃圾信息,无重复。 定时任务(crons)不可避免地会重叠和重复运行。如果没有严格的保证,单个用户可能会收到两次或三次相同的“午餐时间 🥗”提醒 — 这会很快导致应用被卸载。
- 设备不可靠。 用户会不断卸载应用、撤销权限并更换推送令牌。盲目向过期令牌发送通知会浪费资源并影响交付指标。
- 无需暴力调度器的精确计时。 在精确的分钟发送数百条通知 — 而不是让定时任务每60秒就猛击数据库 — 需要一种比简单轮询更智能的机制。
我们的解决方案
我们构建了一个三阶段管道,清晰地分离了 要发送什么、 何时发送 和 实际发送 — 这样每个阶段都可以独立地失败和恢复。数据库是唯一可靠的数据源,消息队列处理精确计时,而单个工作层与推送提供商通信。

架构
- Expo-notifications 是一个 React Native 客户端,具有原生通道、独特提示音和深度链接,它为 iOS 和 Android 使用单一的令牌格式和交付 API。
- NestJS 后端,使用 expo-server-sdk 作为 FCM 和 APNs 的统一推送抽象层。
- MongoDB 作为唯一可靠的数据源 — NotificationMessage、 NotificationToken 和 NotificationCounter 集合。
- ActiveMQ (STOMP) 延迟队列,每个类别(用餐、情绪、活动、安全、提醒)一个,用于精确的定时交付。
- 生成每个用户时区解析的通知记录的创建者定时任务。
- 订阅每个队列并在发送前执行最终验证的消费者工作器。
- AWS ECS Fargate 运行定时任务和消费者;ActiveMQ 在专用 EC2 实例上运行。
主要功能
- 时区感知型调度。 使用 date-fns-tz,计算每个用户的本地发送时间,然后转换回 UTC 进行存储,并受限于 UTC 日期窗口以保证每天发送一次提醒。
- 数据库强制幂等性。 对待处理消息的局部唯一索引使得重复创建不可能 — 即使定时任务运行两次:
| // Unique only while the message is still PENDING and not deleted schema.index( { userId: 1, notificationTokenId: 1, category: 1, label: 1, scheduledAt: 1 }, { unique: true, partialFilterExpression: { status: 'pending', isDeleted: false } } ); |
3. 通过延迟队列精确交付。 调度器现在就将消息入队,但利用 ActiveMQ 的 scheduled-delay 头部将交付推迟到精确的到期分钟,而不是使用每分钟执行的定时任务:
| client.send(`/queue/${queueName}`, { persistent: 'true', 'AMQ_SCHEDULED_DELAY': String(delayMs), // delivered exactly when due }, JSON.stringify(message)); |
4. 每个设备只有一个活跃令牌。 局部唯一索引保证每个设备只有一个活跃令牌;新登录会干净利落地淘汰旧令牌,并采用指数退避重试机制以应对并发登录。
5. 回执检查 + 自动清理。 发送后,我们会轮询 Expo receipts。收到 DeviceNotRegistered 响应后,会立即停用失效令牌,从而避免再次对其进行无效发送。
| f (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') { await this.deactivateToken(token); // stop sending to dead devices } |
6. 失败上限。 每个设备都有一个重试计数器;连续失败3次后,令牌会自动停用 — 没有无限循环,也没有僵尸令牌。
7. 发送时尊重用户意图。 消费者会在 交付时 而不仅仅是调度时重新检查通知偏好 — 因此,在提醒发送前一小时选择退出的用户将永远不会收到提醒。消息会解析为真实的最终状态:成功、 失败 或 已错过。
成果
- 全球每位用户都在正确的本地时间收到提醒 — 没有非工作时间通知。
- 通过数据库级别的幂等性完全消除了重复通知。
- 失效和过期的设备令牌被自动检测并停用,保持交付通道的清洁。
- 无需暴力调度器,即可实现精确到分钟的交付,提高了可靠性并降低了数据库负载。
技术栈
React Native · Expo Notifications · NestJS · TypeScript · MongoDB · ActiveMQ (STOMP) · expo-server-sdk · date-fns-tz · AWS ECS Fargate

