信頼できる通知のためのトリガーとバックグラウンドワーカーの比較
通知においてトリガーとバックグラウンドワーカーのどちらが安全かを学び、リトライ、トランザクション、重複防止について実践的な指針を提供します。

実際のアプリで通知配信が壊れる理由
通知は単純に聞こえます:ユーザーが何かを行い、メールやSMSが送られる。実際の失敗の多くはタイミングと重複に起因します。データが本当に保存される前にメッセージが送られたり、部分的な失敗のあとに二重送信されたりします。
「通知」は多様です:受注確認メール、SMSのワンタイムコード、プッシュ通知、アプリ内メッセージ、SlackやTelegramの通知、あるいは他システムへのWebhookなど。共通の問題は常に同じで、データベースの変更とアプリ外の何かを調整しようとしている点です。
外の世界は不安定です。プロバイダーは遅くなることがあり、タイムアウトを返すこともあれば、リクエストを受け付けたのにアプリが成功レスポンスを受け取れないこともあります。自分のアプリもリクエストの途中でクラッシュや再起動をするかもしれません。「成功した」送信も、インフラのリトライやワーカーの再起動、ユーザーの再送操作で再実行されることがあります。
通知配信が壊れる一般的な原因は、ネットワークのタイムアウト、プロバイダーの障害やレート制限、不適切なタイミングでのアプリ再起動、ユニークガードのないリトライ、そしてデータベース書き込みと外部送信を一体化してしまう設計などです。
「信頼できる通知」を求めるとき、人々が意味することは通常二つのどちらかです:
- ちょうど一度だけ配信する、または
- 少なくとも重複は絶対に起きない(重複は遅延より悪いことが多い)。
高速性と完璧な安全性の両立は難しく、速度、安全性、複雑さのトレードオフを選ぶことになります。
だからトリガーとバックグラウンドワーカーの選択は単なるアーキテクチャ議論ではありません。いつ送信が許されるか、失敗がどうリトライされるか、何かがうまくいかなかったときに重複メールやSMSをどう防ぐか、という話です。
トリガーとバックグラウンドワーカー:意味するところ
トリガーとバックグラウンドジョブワーカーを比較するとき、本質的には通知ロジックがどこで動くか、そしてそれがそれを引き起こしたアクションにどれだけ密接に結びついているかを比較しています。
トリガーは「Xが起きたら今すぐやる」です。多くのアプリではユーザーアクション直後、同一のウェブリクエスト内でメールやSMSを送ることを指します。トリガーはデータベースレベルにも存在します:行が挿入・更新されたときに自動で走るデータベーストリガーです。どちらも即時性を感じさせますが、それらを発火させた環境のタイミングと制約を受けます。
バックグラウンドワーカーは「近いうちにやるがフォアグラウンドではない」です。ワーカーはキューからジョブを引き、完了するまで処理する独立したプロセスです。メインのアプリは何をすべきかを記録してすぐに返し、遅く失敗しやすいプロバイダー呼び出しのような部分はワーカーが担当します。
「ジョブ」はワーカーが処理する仕事の単位です。通常、誰に通知するか、どのテンプレートか、埋めるデータ、現在のステータス(queued, processing, sent, failed)、試行回数、場合によっては予定時刻などが含まれます。
典型的な通知フローはこうです:メッセージの詳細を準備し、ジョブをエンキューし、プロバイダー経由で送信し、結果を記録し、リトライするか停止するか誰かにアラートするか決めます。
トランザクション境界:いつ本当に送って安全か
トランザクション境界とは「保存を試みた」状態と「本当に保存された」状態の境目です。データベースがコミットされるまではその変更はロールバックされる可能性があります。通知は後から取り消すのが難しいので、これは重要です。
コミット前にメールやSMSを送ると、実際には起きなかったことについてユーザーに連絡してしまうことがあります。制約違反やタイムアウトで書き込みが失敗した場合、顧客は「パスワードが変更されました」や「注文が確定しました」と受け取り混乱します。
データベーストリガー内から送るのは魅力的に見えますが、トリガーは同じトランザクション内で動きます。トランザクションがロールバックすると、既にメールやSMSプロバイダーを呼んでしまっている可能性があります。
データベーストリガーは観測性、テスト、セーフなリトライが難しくなる傾向があります。さらに遅い外部呼び出しを行うとロックを長引かせ、データベースの問題診断を難しくします。
より安全なアプローチはアウトボックスの考え方です:通知の意図をデータとして記録し、コミットした後で送ることです。
ビジネスの変更を行い、同じトランザクション内で誰に何を送るかを記述したアウトボックス行(受信者、チャネル、テンプレート、一意キーなど)を挿入します。コミット後、バックグラウンドワーカーが保留中のアウトボックス行を読み、送信してから送信済みにマークします。
即時送信は「あなたのリクエストを処理しています」のような影響の小さい情報通知では問題ないことが多いです。しかし最終状態と一致しなければいけないものは、コミット後まで待ちましょう。
リトライと障害処理:各アプローチの強み
リトライが決め手になることが多いです。
トリガー:速いが障害に脆い
多くのトリガー設計は適切なリトライ戦略を持ちません。
トリガーがメール/SMSプロバイダーを呼び、その呼び出しが失敗した場合、通常は二つの望ましくない選択肢に直面します:
- トランザクションを失敗させて元の更新をブロックする、または
- エラーを握りつぶして通知を失う。
信頼性が重要な場合、どちらも受け入れられません。
トリガー内でループや遅延処理を試みると、トランザクションを長時間開いたままにしてロック時間を伸ばし、データベースを遅くしてしまいます。データベースやアプリが送信途中で死ぬと、プロバイダーがリクエストを受け取ったかどうか判別できないことが多いです。
バックグラウンドワーカー:リトライ向けに設計されている
ワーカーは送信を独立した状態を持つタスクとして扱います。これにより適切なときだけ再試行するのが自然になります。
実務的には、一時的な失敗(タイムアウト、ネットワークの一過性エラー、サーバーエラー、レート制限での待ち)は再試行しますが、恒久的な問題(無効な電話番号、間違ったメール形式、購読解除などのハードリジェクション)は通常再試行しません。「不明な」エラーは試行回数を制限して状態を可視化します。
バックオフが再試行で状況を悪化させない鍵です。最初は短い待ち時間から始め、毎回増やしていきます(例:10秒、30秒、2分、10分)といった具合にし、固定回数の試行後に止めます。
デプロイや再起動に耐えられるように、各ジョブにリトライ状態を保存します:試行回数、次回試行時間、最後のエラー(短く読みやすい形)、最終試行時間、そして pending, sending, sent, failed のような明確なステータスです。
アプリが送信中に再起動しても、ワーカーは古いタイムスタンプで sending のまま残っているジョブを再チェックして安全にリトライできます。ここで冪等性が不可欠になり、再試行が重複送信を引き起こさないようにします。
冪等性でメールとSMSの重複を防ぐ
冪等性とは、同じ「通知送信」アクションを何度実行してもユーザーは一度だけ受け取ることが保証されることです。
典型的な重複ケースはタイムアウトです:アプリがプロバイダーにリクエストを送り、リクエストがタイムアウトしてコードが再試行すると、最初のリクエストは実際には成功していた可能性があり、再試行で重複が生じます。
実用的な対策は、各メッセージに安定したキーを付け、それを単一の真実の源とみなすことです。良いキーは「いつ送ろうとしたか」ではなく「メッセージが何を意味するか」を表します。
よく使われるアプローチ:
- 通知を決めたときに生成する
notification_idのような一意ID、または order_id + template + recipientのような業務由来のキー(ただし本当に一意性を定義できるときのみ)。
その後、送信台帳(多くはアウトボックステーブル自身)に保存し、すべてのリトライは送信前にそこを確認します。状態は単純かつ可視に保ちます:created(決定済み)、queued(準備OK)、sent(確認済み)、failed(確認された失敗)、canceled(不要になった)。重要なのは冪等キーごとに一つだけアクティブなレコードを許すことです。
プロバイダー側の冪等性サポートは助けになりますが、自分の台帳の代わりにはなりません。リトライ、デプロイ、ワーカー再起動の扱いは自分側で必要です。
また「不明な」結果を第一級で扱ってください。リクエストがタイムアウトしたら、すぐに再送するのではなく、確認待ちとしてマークし、可能ならプロバイダーの配達状況を確認してから安全に再試行します。確認できない場合は遅延させてアラートを出す方が二重送信より安全です。
安全なデフォルトパターン:アウトボックス+バックグラウンドワーカー(ステップバイステップ)
安全なデフォルトが欲しいなら、アウトボックスパターン+ワーカーが有力です。ビジネストランザクションの外で送信を行いながら、通知の意図が確実に保存されるからです。
フロー
「通知を送る」をアクションではなくデータとして扱います。
ビジネス変更(たとえば注文状態の更新)を通常のテーブルに保存し、同じデータベーストランザクション内で受信者、チャネル(email/SMS)、テンプレート、ペイロード、冪等キーを含むアウトボックス行を挿入してコミットします。コミット後にのみ送信が許可されます。
バックグラウンドワーカーが定期的に保留中のアウトボックス行をピックアップして送信し、結果を記録します。
2つのワーカーが同じ行を取得しないように、簡単なクレーム手順(ステータスを processing に変える、ロックタイムスタンプを使う等)を入れます。
重複防止と障害処理
送信は成功したがアプリが「sent」を記録する前にクラッシュしてしまうと重複が発生しやすいです。これを解決するには「送信済みマーク」を繰り返し安全にできるようにします。
ユニーク制約(冪等キーとチャネルに対するユニーク制約など)を使い、重複をブロックします。リトライは明確なルールで:試行回数に上限を設け、遅延を増やし、再試行可能なエラーのみ再試行します。最終試行後はジョブをデッドレター状態(例:failed_permanent)に移し、誰かがレビューして手動で再処理できるようにします。
監視はシンプルで良いです:pending、processing、sent、retrying、failed_permanent の件数と、最も古い pending のタイムスタンプを追いましょう。
具体例:注文が「Packed」から「Shipped」に変わるとき、注文行を更新し、order-4815-shipped の冪等キーでアウトボックス行を1件作ります。ワーカーがクラッシュしても再実行は二重送信になりません。なぜなら「送信済み」書き込みがそのユニークキーで保護されているからです。
バックグラウンドワーカーが適しているケース
データベーストリガーはデータ変更の瞬間に反応するのが得意です。しかし「現実世界の不安定さの下で確実に通知を届ける」仕事には、より多くの制御を与えるバックグラウンドワーカーの方が向いています。
ワーカーは、リマインダーやダイジェストのような時間ベースの送信、レート制限やバックプレッシャーがある高ボリューム、プロバイダーの変動に対する許容(429制限、遅い応答、短時間の停止)、複数ステップのワークフロー(送信→配達待ち→フォローアップ)、または照合が必要なクロスシステムイベントに適しています。
例として、顧客に課金してSMS領収書を送り、次に請求書をメールするケースを考えてください。SMSがゲートウェイの問題で失敗しても注文は支払い済みのままにしたく、後で安全にリトライしたいはずです。トリガーにそのロジックを入れると「データが正しい」処理と「外部サービスが今利用可能か」を混ぜてしまうリスクがあります。
バックグラウンドワーカーは運用上の制御もしやすく、インシデント時にキューを一時停止したり、失敗を点検して遅延リトライしたりできます。
メッセージを見逃したり重複させたりする典型的なミス
最も速く信頼性の低い通知を作る方法は、「便利な場所でただ送る」ことで、後はリトライに頼ることです。トリガーでもワーカーでも、失敗と状態の扱いの細部がユーザーに一通、二通、あるいはゼロ通届くかを決めます。
よくある罠は、データベーストリガーから送って失敗しないと仮定することです。トリガーはトランザクション内で動くため、遅いプロバイダー呼び出しで書き込みが滞ったりタイムアウトしたり、ロールバック後に最初の呼び出しが実際に成功していた場合に後の再試行で二重送信が起きたりします。
繰り返し出るミス:
- 恒久的なエラー(無効なメール、ブロックされた番号)まで全て同じように再試行する。
queuedとsentを分けておらず、クラッシュ後に何が安全に再試行できるかわからない。- タイムスタンプを重複除外キーに使ってしまい、再試行で一意性をすり抜ける。
- プロバイダー呼び出しをユーザーのリクエスト経路内で行う(チェックアウトやフォーム送信がゲートウェイ待ちになる)。
- プロバイダーのタイムアウトを「未配信」と扱ってしまうが、多くは「不明」である。
単純な例:SMSを送信したらプロバイダーがタイムアウトし、あなたは再試行します。最初のリクエストが実際には成功していればユーザーは2つのコードを受け取ります。対策は、notification_id のような安定した冪等キーを記録し、送信前にメッセージを queued にしておき、明確な成功レスポンスを受け取ってから sent にマークすることです。
リリース前に確認する簡単なチェックリスト
多くの通知バグはツールの問題ではなく、タイミング、リトライ、記録漏れです。
まず、データベース書き込みが安全にコミットされた後だけ送信していることを確認してください。書き込み中に送って後でロールバックされると、起きていないことについてユーザーに通知してしまいます。
次に、すべての通知に一意の識別子を与えていることを確認してください。各メッセージに安定した冪等キー(例:order_id + event_type + channel)を付け、ストレージで重複を拒否するようにします。
リリース前にチェックする基本項目:
- 送信は書き込み中ではなくコミット後に行われる。
- 各通知に一意の冪等キーがあり、重複は拒否される。
- リトライは安全で、同じジョブを再実行しても高々一度だけ送られる。
- すべての試行は記録される(ステータス、last_error、タイムスタンプ)。
- 試行回数に上限があり、スタックしたアイテムはレビューして再処理できる場所がある。
ワーカーを意図的に落として再起動挙動をテストしてください。送信中にワーカーを殺して再起動し、二重送信が発生しないことを確認します。データベースに負荷をかけた状態でも同様に試してみてください。
検証の簡単なシナリオ:ユーザーが電話番号を変更し、SMS確認を送る。SMSプロバイダーがタイムアウトしたらアプリは再試行します。良い冪等キーと試行ログがあれば、一度だけ送るか安全に後で再試行するかのいずれかになり、スパムになりません。
例:注文更新で二重送信を防ぐ
あるストアは二種類のメッセージを送ります:(1)支払い直後の注文確認メール、(2)配達中と配達完了時のSMS更新。
早すぎる送信(例えばデータベーストリガー内)で起きる問題:支払いステップが orders 行を書き、トリガーがメールを送信し、その直後に決済キャプチャが失敗したら、「Thanks for your order」メールが実際には成立していない注文について送られてしまいます。
逆の問題:配達ステータスが “Out for delivery” になったときにSMSプロバイダーを呼び、プロバイダーがタイムアウトした場合、送信されたか不明です。すぐに再試行すると二重送信のリスクがあり、再試行しないと一通も送られないリスクがあります。
より安全なフローはアウトボックス+バックグラウンドワーカーを使います。アプリは注文やステータス変更をコミットし、同じトランザクションで「テンプレートXをユーザーYへ送信、チャネルSMS、冪等キーZ」のアウトボックス行を保存します。コミット後にワーカーが配信します。
簡単なタイムライン:
- 支払いが成功し、トランザクションがコミットされ、確認メール用のアウトボックス行が保存される。
- ワーカーがメールを送り、プロバイダーのメッセージIDとともにアウトボックスを sent にマークする。
- 配達ステータスが変わり、トランザクションがコミットされ、SMS更新用のアウトボックス行が保存される。
- プロバイダーがタイムアウトし、ワーカーはアウトボックスを再試行可能としてマークし、同じ冪等キーで後で再試行する。
再試行時、アウトボックス行が単一の真実の源になります。新しい「送信」リクエストを作るのではなく、最初の送信を完了させるだけです。
サポートにとっても明快です。failed に詰まっているメッセージや最後のエラー(タイムアウト、無効な電話番号、ブロックされたメール)、試行回数を見れば、二重送信の危険なく再試行して良いかが判断できます。
次のステップ:パターンを選んできれいに実装する
デフォルトを決めて文書化しましょう。不整合な挙動はトリガーとワーカーを混ぜて使うことから生まれます。
まずは小さく始めてアウトボックステーブルと1つのワーカーループを作ってください。最初の目標は速度ではなく正確さです:送る意図を保存し、コミット後に送信し、プロバイダーが確認して初めて送信済みにマークすること。
簡単なローアウトプラン:
- イベント(order_paid, ticket_assigned)とそれらが使えるチャネルを定義する。
- event_id、recipient、payload、status、attempts、next_retry_at、sent_at を持つアウトボックステーブルを追加する。
- 保留中の行をポーリングして送信し、ステータスを更新するワーカーを1つ作る。
- 各メッセージに一意の冪等キーを付け、"既に送信済みなら何もしない" の挙動を実装する。
- エラーを再試行可能(タイムアウト、5xx)と再試行不可(無効な番号、ブロック)に分ける。
ボリュームを増やす前に基本的な可視化を追加してください。保留中の数、失敗率、最も古い保留メッセージの年齢を追い、最も古い保留が伸び続けるならワーカーが詰まっているかプロバイダー障害かロジックバグがあります。
AppMaster (appmaster.io) で構築しているなら、このパターンはきれいにマッピングできます:Data Designer でアウトボックスをモデル化し、ビジネス変更とアウトボックス行を1トランザクションで書き込み、送信とリトライのロジックを別のバックグラウンドプロセスで実行します。この分離が、プロバイダーやデプロイが不安定でも通知配信を信頼できるものにします。
よくある質問
バックグラウンドワーカーが通常は安全なデフォルトです。送信は遅く失敗しやすいため、ワーカーはリトライや可視化のための仕組みが整っています。トリガーは速いですが、トランザクションやリクエストに密接に結びつくため、失敗や重複の扱いが難しくなります。
データベース書き込みはロールバックされる可能性があるので危険です。コミット前に通知すると、実際には成立していない注文やパスワード変更、決済についてユーザーに通知してしまうことがあり、後で取り消すことはできません。
データベーストリガーは行の変更と同じトランザクション内で実行されます。トリガーからメールやSMSプロバイダーを呼ぶと、トランザクションが失敗した場合に実際にメッセージが送られてしまっていることがあり得ますし、外部呼び出しが遅いと書き込みが停滞してしまいます。
アウトボックスパターンは「送るという意図」をデータベースの行として保存する方法です。ビジネスの変更と同じトランザクションでアウトボックス行を追加し、コミット後にワーカーが未処理のアウトボックスを読み、送信して「送信済み」とマークします。これによりタイミングとリトライが安全になります。
プロバイダーのタイムアウトは多くの場合“失敗”ではなく“結果不明”です。良い設計では試行を記録し、同じメッセージIDで遅延して安全に再試行するか、配達状況を確認してから再送するなどして、即座に重複送信しないようにします。
冪等性を使って、同じ送信アクションを何度実行してもユーザーには一度だけ届くようにします。各メッセージに安定したキーを与え(送信を決めたときに生成するnotification_idやビジネスキーなど)、アウトボックス台帳に保存して、そのキーごとに一つの有効レコードだけを許可します。これによりリトライしても新しい送信が増えません。
タイムアウトや5xx、レートリミットなど一時的なエラーは再試行しますが、無効なアドレスやブロックされた番号、ハードバウンスなど恒久的なエラーは再試行しません。恒久エラーは失敗として可視化し、データを修正してから再処理するのが安全です。
ワーカーはsendingで長時間止まっているジョブを見つけて再度リトライ可能に戻すことができます。これが安全に働くには、各ジョブに試行回数、タイムスタンプ、最終エラーなどの状態が記録されていて、冪等性により二重送信が防がれている必要があります。
「再試行してよいか?」に答えられる状態を保存することです。pending、processing、sent、failed のような明確なステータス、試行回数、最終エラーを保存すれば、サポートやデバッグが現実的になり、システムは推測なしに回復できます。
Data Designerでアウトボックステーブルをモデル化し、ビジネス更新とアウトボックス行を1トランザクションで書き込み、その後別のバックグラウンドプロセスで送信とリトライのロジックを実行します。各メッセージに一意の冪等キーを付け、試行を記録しておけば、デプロイやワーカー再起動でも重複は起きにくくなります。


