信頼性の高い、タイムゾーン対応のプッシュ通知システムの構築
健康とウェルネスのアプリは、世界中の何千人ものユーザーに対して、食事のリマインダー、気分のチェックイン、水分補給の促し、睡眠の合図、カスタムリマインダーといった個別のデイリーメッセージを送信する必要がありました。課題は、すべての通知が正しい現地時間に、一度だけ、そして壊れたデバイスには送信されないようにすることでした。私たちはそれを実現するための分散パイプラインを設計し構築しました。
課題
- 大規模なタイムゾーンの正確性。「午前9時の朝食リマインダー」は、ユーザーごとに異なる意味を持ちます。サーバー時間で送信すると、シドニーの誰かに午前3時に通知が届いてしまいます。各通知はユーザーの現地の時間に解決される必要があります。
- スパムや重複を避ける。スケジュールされたcronは重複して実行されることがあります。厳格な保証がなければ、1人のユーザーが同じ「ランチの時間 🥗」の通知を2回または3回受け取る可能性があり、アンインストールの速道となります。
- デバイスは信頼できない。ユーザーはアプリをアンインストールしたり、許可を取り消したり、プッシュトークンを頻繁に変更します。古いトークンに通知を送信するとリソースが無駄になり、配信メトリクスが損なわれます。
- 力任せのスケジューラを使わずに正確なタイミングを実現。数百の通知を正確な分に配信するには、データベースを毎分叩くcronではなく、より賢いメカニズムが必要でした。
私たちの解決策
私たちは、3段階のパイプラインを構築しました。これにより、何を送信するか、いつ送信するか、実際に送信することが明確に分離され、各段階が独立して失敗し回復できるようになっています。データベースが真実の源であり、メッセージキューが正確なタイミングを管理し、単一のワーカーレイヤーがプッシュプロバイダーと通信します。

アーキテクチャ
- Expo-notificationsは、React Nativeクライアントで、ネイティブチャネル、ユニークな音、ディープリンクを持ち、iOSとAndroidの両方に対して単一のトークンフォーマットと配信APIを使用します。
- NestJSバックエンドは、expo-server-sdkを使用して、FCMとAPNsの統一プッシュ抽象化を行います。
- MongoDBは真実の源として、NotificationMessage、NotificationToken、NotificationCounterコレクションを使用します。
- ActiveMQ (STOMP)遅延キューは、食事、気分、活動、安全、リマインダーの各カテゴリごとに1つずつあり、正確なスケジュール配信を行います。
- クリエイターcronが、ユーザーごとにタイムゾーン解決済みの通知レコードを生成します。
- コンシューマーワーカーが各キューを購読し、送信前に最終的な検証を行います。
- AWS ECS Fargateがcronとコンシューマーを実行し、ActiveMQは専用のEC2インスタンス上で動作します。
主な機能
- タイムゾーン対応のスケジューリング。 date-fns-tzを使用して、各ユーザーの現地送信時間を計算し、UTCに変換して保存し、UTCの日付ウィンドウで制限して1日1回の通知を保証します。
- データベースでの冪等性の保証。保留中のメッセージに対する部分的なユニークインデックスにより、cronが2回実行されても重複の作成が不可能になります:
| // メッセージがまだPENDINGで削除されていない間のみユニーク schema.index( { userId: 1, notificationTokenId: 1, category: 1, label: 1, scheduledAt: 1 }, { unique: true, partialFilterExpression: { status: 'pending', isDeleted: false } } ); |
3. 遅延キューによる正確な配信。 1分ごとのcronの代わりに、スケジューラはメッセージを今エンキューしますが、ActiveMQのscheduled-delayヘッダーを使用して正確な予定時間に配信を遅延させます:
| client.send(`/queue/${queueName}`, { persistent: 'true', 'AMQ_SCHEDULED_DELAY': String(delayMs), // 正確に予定時間に配信 }, JSON.stringify(message)); |
4. デバイスごとの単一のアクティブトークン。 部分的なユニークインデックスにより、デバイスごとに正確に1つのアクティブトークンが保証され、新しいログインが古いトークンをクリーンに退役させ、同時サインインを生き延びるために指数バックオフリトライが行われます。
5. レシートチェックと自動クリーンアップ。 送信後、Expoレシートをポーリングします。DeviceNotRegisteredの応答があると、すぐに死んだトークンを無効化し、再び送信が無駄にならないようにします。
| f (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') { await this.deactivateToken(token); // 死んだデバイスへの送信を停止 } |
6. 失敗の上限。 各デバイスにはリトライカウンターがあり、3回連続で失敗するとトークンが自動的に退役されます。無限ループやゾンビトークンはありません。
7. 送信時にユーザーの意図を尊重。 通知の好みは、スケジューリング時だけでなく、配信時にもコンシューマーによって再確認されるため、通知の1時間前にオプトアウトしたユーザーには届きません。メッセージは正直な最終状態に解決されます:success、failed、またはis_missed。
結果
- すべてのユーザーが世界中で正しい現地時間に通知を受け取ります。オフ時間の通知はゼロです。
- データベースレベルの冪等性により、重複通知は完全に排除されました。
- 死んだトークンや古いデバイストークンは自動的に検出され、退役されるため、配信がクリーンに保たれます。
- 信頼性が向上し、力任せのスケジューラを使用せずに正確で分単位の配信が可能になり、データベースの負荷が軽減されます。
テクノロジースタック
React Native · Expo Notifications · NestJS · TypeScript · MongoDB · ActiveMQ (STOMP) · expo-server-sdk · date-fns-tz · AWS ECS Fargate

