Tech カテゴリのイメージ

Transactional Outboxとは?記録と通知の間で消える処理を防ぐ

AIエージェントが問い合わせを「解決済み」に更新した直後、通知キューへメッセージを送る前に停止すると、画面上は解決済みなのに利用者へ通知されない。逆に通知を先に送り、その後のデータ保存に失敗すれば、存在しない処理結果を伝えてしまう。データベースとキューのような二つの仕組みへ別々に書く限り、片方だけ成功する隙間が残る。

Transactional Outboxは、業務データの変更と「あとで送るメッセージ」を同じデータベーストランザクションへ保存する設計である。送信自体は別のワーカーが行う。これにより、記録は成功したのに通知予定が消える状態と、記録が失敗したのに通知だけ出る状態を避けられる。

「保存してから送る」は一行ずつ正しくても壊れる

注文確定を例にすると、素朴な処理は次の順になる。

  1. 注文を confirmed に更新する
  2. トランザクションをcommitする
  3. キューへ OrderConfirmed を送る

3の直前にプロセスが停止すると、注文は確定済みだが配送やメールの処理は始まらない。順番を逆にしても安全にはならない。キューへ送ったあとでデータベース更新が失敗すると、下流は確定していない注文を処理する。

AWS Prescriptive Guidanceは、データベース更新とイベント通知を異なるシステムへ書く操作をdual writeと呼び、片方の失敗によって不整合が生じると説明している。二つの外部システムへ連続して成功を祈るのではなく、最初の確定範囲を一つへ戻す必要がある。

業務データと送信予定を同じトランザクションへ入れる

Outbox方式では、注文と送信予定を同じデータベースへ保存する。

BEGIN;
UPDATE orders
SET status = 'confirmed'
WHERE order_id = 'order-123';
INSERT INTO outbox (
event_id,
aggregate_id,
event_type,
payload,
created_at
) VALUES (
'event-456',
'order-123',
'OrderConfirmed',
'{"order_id":"order-123"}',
CURRENT_TIMESTAMP
);
COMMIT;

二つのSQLが同じトランザクションなら、両方が保存されるか、両方が取り消される。commit後にアプリが停止しても、outbox行はデータベースへ残る。別のワーカーが未送信の行を読み、キューや外部APIへ届けられる。

ここで原子的になるのは、業務データとoutbox行の保存までである。外部への送信まで一つのデータベーストランザクションに含まれるわけではない。Microsoft Azure Architecture Centerも、業務オブジェクトとイベントを同じトランザクションで保存し、別の処理が未処理のoutboxを公開する流れを示している。

AIエージェントでは「実行済み」と「通知済み」を分ける

AIエージェントは、一回の仕事で複数の状態を扱う。回答文を生成した、担当者へ割り当てた、外部チケットを更新した、利用者へ通知した、という出来事は同じではない。

業務上の確定状態と、その後に必要な配送を分けて記録する。例えば人の承認後にメールを送る場合、承認結果と ApprovalCompleted のoutbox行を同時に保存する。メール送信ワーカーはそのイベントを受け取り、送信結果を別に記録する。画面で「承認済み」と「通知済み」を同じ一つの状態へ押し込まなければ、障害時にどこまで進んだかを説明できる。

外部APIそのものが業務上の正本なら、ローカルのoutboxだけで外部操作まで原子的にはできない。送信後の応答を失った場合は、操作IDによる冪等性、外部IDの保存、結果照会、必要に応じた補償処理を組み合わせる。Outboxは分散トランザクションを万能に置き換えるものではなく、少なくともローカルの状態変更と配送予定を切り離さないための境界である。

ワーカーは重複送信を前提にする

ワーカーがメッセージを送信した直後、outbox行を送信済みにする前に停止すると、再起動後に同じ行をもう一度送る可能性がある。送信済みフラグを先に立てれば、今度は送信前の停止でメッセージが失われる。二つの仕組みをまたぐ以上、この最後の隙間は残る。

そのため、配送はat-least-onceになりうると考え、受信側を冪等にする。各イベントへ一意な event_id を付け、受信側で処理済みIDを記録する。同じIDを再び受け取ったら、外部作用を繰り返さず成功として扱う。AWSの資料も、outboxから重複したメッセージが送られる可能性を挙げ、受信側で処理済みメッセージを追跡するよう勧めている。

ただし、IDが違う二つのイベントが同じ意味を持つ場合までは自動的に重複排除されない。利用者の一操作に対する操作ID、イベントID、対象IDを関連付け、どの単位で一回だけにしたいのかを決める必要がある。

順序は対象ごとに守る

同じ注文について Created、Confirmed、Cancelled が発生するなら、配送順が入れ替わると下流の状態が逆戻りする。全システムのイベントを一列に並べる必要はないが、少なくとも同じ対象ID内では順序を判断できる情報を持たせる。

outboxには対象ID、対象ごとの版番号、作成時刻を保存する。キューのパーティションキーを対象IDに合わせる方法もある。受信側では古い版を無条件に適用せず、現在の版と比較する。時刻だけでは同時刻や時計差を扱いにくいため、対象ごとの単調な版番号が役に立つ。

送信済みフラグだけでは運用できない

outbox行には、送る内容だけでなく運用に必要な情報を持たせる。

  • 一意なイベントID
  • 対象IDと対象の版
  • イベント種別とschemaの版
  • payload
  • 作成時刻
  • 試行回数と次回試行時刻
  • 最後のエラー
  • 送信完了時刻

古いイベントが何時間も未送信のままなら、ワーカー停止や下流障害を検知できる。監視では未処理件数だけでなく、最古の未処理イベントの経過時間を見る。新しいイベントが大量に増えると件数は同じでも遅延が悪化するためである。

再試行回数には上限と待機時間を設ける。形式が壊れたpayloadや廃止済みのイベント種別は、何度送っても成功しない。一定回数を超えた行を隔離し、原因と対象を人が確認できるようにする。修正後に再送するときも、元のイベントIDを変えないか、新しい訂正イベントとして扱うかを明確にする。

削除は監査と再送の期間を決めてから行う

送信済み行を永久に残せばテーブルが増え続け、すぐ消せば障害調査や再構築に使えない。保持期間は、下流の最大停止時間、監査要件、再送可能期間に合わせる。payloadへ会話全文や秘密情報をそのまま入れると、保持対象も増える。イベントには下流が必要な最小情報と参照IDを入れ、機密情報の保存期間を不用意に延ばさない。

変更データキャプチャを使い、データベースの変更履歴からイベントを配送する方式もある。AWSはoutbox tableとCDCの両方を紹介している。どちらでも、順序、重複、再試行、schema変更、監視は消えない。採用する製品の配送保証とトランザクション境界を確認する必要がある。

障害位置をずらして試す

正常系だけではOutboxの意味を確認できない。少なくとも次の位置で意図的に停止させる。

  • 業務更新前
  • 業務更新とoutbox追加の間
  • commit直後
  • キュー送信直後
  • 送信済み記録の直前
  • 受信側の処理途中

各位置で、業務データだけが確定しないこと、確定したイベントが最終的に配送されること、再送で副作用が重複しないことを確かめる。AIエージェントでは、同じ回答や同じ外部操作が二度実行されないかだけでなく、利用者画面に途中状態をどう示すかも検証する。

Transactional Outboxは、エージェントの判断を賢くする仕組みではない。状態を確定したのに次の処理が消えるという、モデルの性能とは無関係な故障を減らす仕組みである。業務データと配送予定を一緒に保存し、配送は再実行可能にし、受信側は重複を受け止める。この三つを分けて設計すると、停止後にどこから再開すべきかが明確になる。

参照資料

生成AI・機械学習講座の一覧へ