アプリケーションが注文を正常に保存しても、決済システム、在庫サービス、ジョブキューに通知できないことがあります。また、通知を発行した後、注文を確定する前に処理が失敗することもあります。どちらの場合も、システム間に不整合が生じます。PHPでTransactional Outboxパターンを使うと、業務データの変更とイベントの記録を同じデータベーストランザクション内で一緒に確定し、この二重書き込みの問題を解決できます。
Outboxを使っても、配信が即時になるわけではなく、コンシューマーがイベントを必ず一度だけ処理することが保証されるわけでもありません。目的は、確定済みのイベントが、別個に失敗する可能性のある2回目の書き込み処理に依存しないようにすることです。適切に適用するには、並行読み取り、再試行、冪等性、日々の運用についても設計する必要があります。
データベースへの書き込みとイベント発行に伴う問題

ある処理が購入を確定し、その後、キューにOrderPlacedを発行するとします。この2つの処理の間にPHPプロセスが終了すると、購入は記録されているのに、イベントは誰にも届きません。先にイベントを発行し、その後データベーストランザクションがロールバックされると、コンシューマーが存在しない購入に対して処理を行う可能性があります。
通常、SQLトランザクションには、キュー、外部API、ブローカーへの呼び出しも含まれません。両方の処理を手動で調整しても、障害が起こる時間帯はなくならず、発生する順番が変わるだけです。Transactional Outboxパターンは、業務データの変更と、発行予定のイベントを表す行を1つのトランザクションに含めることで、非アトミックな二重書き込みを防ぎます。
Outboxテーブルの最小設計
テーブルには、別のプロセスがイベントを識別、配信、追跡するために十分な情報を保存します。初期設計には、次の項目を含められます。
- 一意の識別子: UUIDなど、安定しており、冪等性キーとして使えるもの。
- イベントの種類:
OrderPlacedなど。形式が進化する場合は、スキーマのバージョンも付けるのが望ましいです。 - ペイロード: コンシューマーに必要なシリアライズ済みデータ。不要な機密情報は含めません。
- 作成日時: 順序付け、経過時間の測定、遅延の調査に役立ちます。
- 状態と追跡情報: たとえば、保留中、取得済み、発行済みなどの状態、試行回数、直近のエラー、最終試行日時。
- 取得情報: コンシューマー間の調整にリースを使う場合は、ワーカーIDとリースの有効期限。
具体的なスキーマは、データベースエンジンやボリュームによって異なります。保留中のイベントを検索しやすいインデックスを追加し、作業対象を見つけるためにテーブル全体を読み込まないようにします。また、発行済みの行とエラー行をどれだけ保持するかも決めてください。Outboxをデフォルトで無期限のアーカイブにしてはいけません。
業務データとイベントを1つのトランザクションで保存する
PHPでは、業務状態を変更するコードとイベントを追加するコードで、同じ接続とトランザクションを使う必要があります。以下は処理の流れを示す例であり、汎用的なSQLスキーマではありません。
$pdo->beginTransaction();
try {
// この接続を使って注文を挿入または更新する。
$orderId = saveOrder($pdo, $orderData);
$eventId = generateUuid();
$payload = json_encode([
'event_id' => $eventId,
'order_id' => $orderId,
], JSON_THROW_ON_ERROR);
$statement = $pdo->prepare(
'INSERT INTO outbox_events (id, event_type, payload, status, created_at)
VALUES (:id, :type, :payload, :status, CURRENT_TIMESTAMP)'
);
$statement->execute([
'id' => $eventId,
'type' => 'OrderPlaced',
'payload' => $payload,
'status' => 'pending',
]);
$pdo->commit();
} catch (Throwable $error) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
トランザクションが確定すれば両方のレコードが保存され、失敗すれば、どちらも確定済みとして扱われるべきではありません。ブローカーへのメッセージ発行をトランザクション内で行えば処理がアトミックになると考えてはいけません。外部への呼び出しはSQLのコミットに参加しないためです。
この例のUUIDは、そのトランザクションで作成されたイベントを識別しますが、業務リクエスト全体を再試行した際の重複を、それだけで防ぐことはできません。そのリスクが問題になる場合(たとえば、タイムアウト後にリクエストが再送される場合)は、リクエストの冪等性キーを使うか、業務処理に一意制約を設けてください。リクエストの重複を検出したら、別のUUIDで新しいイベントを作るのではなく、元の処理に関連付けられた結果とイベントを取得します。
並行処理と復旧を考慮した保留イベントの処理
ワーカーは処理可能なイベントを上限付きで取得し、処理対象として確保したうえで、送信先に発行します。複数のワーカーが同じイベントを同時に処理しないようにするか、少なくとも重複を安全に処理できるように、取得処理を設計する必要があります。選択肢はデータベースエンジンによって異なり、行ロック、状態の条件付き更新、有効期限付きリースなどがあります。あらゆるケースを正しく解決する、移植可能なクエリはありません。利用するデータベースのロックと分離レベルの動作を確認してください。
一般的な方法は、一定期間だけ行を取得し、長時間のトランザクションを使わずに発行し、送信先から確認を受け取った後に発行済みとしてマークすることです。発行後、状態を更新する前にプロセスが停止すると、リースの期限切れ後に別のワーカーが同じイベントを再発行できます。発行前に発行済みとしてマークし、その後プロセスが停止すると、イベントが失われる可能性があります。そのため、発行の試行は少なくとも1回となる場合があり、厳密に1回とは限りません。重複が発生する可能性はありますが、送信先がイベントを受信する保証はありません。実際に配信されるかどうかは、再試行ポリシー、試行回数を使い切る前に送信先が復旧すること、またはイベントを復旧するための介入に左右されます。
バッチは小さくするか、設定可能にし、1回の実行時間に上限を設けます。外部ネットワークの応答を待つ間、SQLロックを保持するとデータベースの性能が低下する可能性があります。バッチサイズ、リース期間、ポーリング頻度は、計測結果に基づいて調整してください。大きなバッチが常に性能を高めるとは限らず、遅延が増えたり、復旧が難しくなったりすることがあります。
冪等性、再試行、処理が滞ったイベント
コンシューマーは、同じイベントが複数回届く可能性を考慮する必要があります。同じデータベース内で業務上の効果を適用する場合は、処理済みイベントのIDを一意制約付きのテーブルに記録し、そのIDを記録する処理と同じトランザクション内で効果を適用できます。これにより、トランザクションが確定すればIDと効果の両方が記録され、失敗した場合は片方だけが確定した状態を残さずに再試行できます。
この方法だけで、トランザクションに含まれない外部への効果が自動的に冪等になるわけではありません。コンシューマーがAPIを呼び出したり、メールを送信したり、データベース外で別の処理を実行したりする場合、外部処理の実行後、ローカルでの記録前に停止すると、処理が繰り返される可能性があります。送信先が対応している冪等性キー、個別の調整方法、または適切な補償フローを使ってください。いずれの方法も使えない場合は、重複のリスクを明示的に認識し、対処します。
指数的な待機時間と妥当な上限を設けて再試行し、タイムアウトなどの一時的なエラーと、不正なペイロードなどの恒久的なエラーを区別します。調査に役立つ形で直近のエラーを保存しますが、ログに秘密情報や不要な個人データを出力しないでください。試行回数の上限に達した後も、イベントを確認可能な失敗状態で保持し、黙って削除してはいけません。
復旧手順を明確に定めます。原因を特定して修正し、同じイベントを再試行するか、介入が必要かを判断してください。再開する前に、コンシューマーが冪等であることを確認します。制御のない「再処理」ボタンは、外部処理を重複実行したり、想定される順序を変えたりする可能性があります。実行できる担当者と、記録に残す内容を文書化してください。
監視、保持、データ保護
保留イベント数、最も古い保留イベントの経過時間、失敗した試行、発行率、作成から配信までの時間を監視します。キューの増加は、送信先の停止、コンシューマーの停止、継続的なエラー、または処理能力の不足を示している可能性があります。総数だけに基づくアラートでは遅延を見逃すことがあります。経過時間を確認すれば、処理が進んでいるかを把握しやすくなります。
イベントが増えるたびにテーブルも大きくなります。発行済みと失敗したイベントの保持ポリシーを定め、負荷の大きいメンテナンス処理にならないよう、バッチ単位で削除してください。削除する前に、業務要件に応じて監査、サポート、調査に必要な情報を保持します。ペイロードへのアクセスを制限し、必要に応じて機密データを暗号化または最小化し、保持期間と整合する削除ポリシーを定めてください。
Outboxを使うべき場合と、複雑さを避けるべき場合

業務処理の確定を信頼性の高い非同期処理につなげる必要があり、システム間で障害が起こり得て、通知の喪失が影響を及ぼす場合、Outboxは有効です。トランザクション対応データベースをすでに使っており、各リクエストで外部サービスの可用性に依存せずにキューや外部サービスと連携する必要がある場合に、特に役立ちます。
重要でないタスクや再構築できるタスク、業務状態を定期的にポーリングすれば十分な場合、またはキューと永続化層がそのフローに適した統合トランザクションを提供している場合は、不要なこともあります。こうした代替案は、実際の制約を踏まえて評価してください。同じ製品内にあるという理由だけで、2つのサービスがアトミックになると思い込んではいけません。
このパターンには、追加のテーブル、ワーカー、再試行、状態管理、データ保持、運用サポートなどのコストも伴います。要件が明らかになる前に、複雑な状態管理を導入するのは避けてください。まずは識別子、状態、日時、試行回数、安全な取得処理から始め、順序、ボリューム、監査、復旧について具体的な要件が生じた場合にのみ設計を拡張します。必要な整合性を保ち、障害を検出して解決できる、最もシンプルな選択肢が適切です。



