アウトボックスパターンとデュアルライト問題
DB更新とイベント送信がズレて「保存したのに通知が飛ばない/飛んだのに保存されていない」を防ぐために。デュアルライト問題の正体と、同一トランザクション内のアウトボックステーブル+CDCで整合性を取る原理がわかります。
- 1.DB更新とメッセージ送信は別々のシステムへの書き込みで原子的に揃えられず、片方だけ成功する「デュアルライト問題」が起きる。2フェーズコミットは可用性とブローカ非対応で実務的に避けられる。
- 2.解決の核心は、送りたいイベントを業務データと同じDBトランザクション内のアウトボックステーブルへINSERTすること。これでDB更新とイベント記録が原子的に揃い、片方だけ成功が消える。
- 3.アウトボックスからブローカへの転送はCDC(WALを読むログテーリング)かポーラで非同期に行い、転送はクラッシュ再開で再送されるため at-least-once。コンシューマ側はべき等に組む必要がある。
デュアルライト問題:二つのシステムは原子的に揃わない
イベント駆動アーキテクチャでは、ひとつの業務操作がしばしば二つの書き込みを伴います。注文を受け付けたら、(1) 注文行をDBに保存し、(2) 「注文確定」イベントをメッセージブローカに送る。在庫サービスや通知サービスはそのイベントを購読して動きます。素直に書くと次のようになります。
処理(注文):
db.insert(注文) # 書き込み先A: データベース
broker.publish(イベント) # 書き込み先B: メッセージブローカ
ここに罠があります。DBとブローカは別々のシステムで、二つの書き込みをひとつの原子操作にまとめられません。どちらかが先に成功し、その直後にプロセスがクラッシュすれば、片方だけが残ります。
障害X: db.insert は成功、publish の前にクラッシュ
→ 注文は保存されたが、誰も知らない(イベント欠落)
障害Y: publish は成功、その直前で db のコミットが失敗
→ イベントは流れたが、注文の実体が無い(幻のイベント)
これがデュアルライト問題(dual write problem) です。二つの独立した書き込みの間には必ず「片方だけ成功した中間状態」が存在し、そこでクラッシュやネットワーク断が起きると整合性が壊れます。順序を入れ替えても消えません。先にDBコミットすればイベント欠落のリスク、先にpublishすれば幻イベントのリスクに化けるだけです。
「publish が失敗したらリトライすればよい」では不十分です。リトライ用のコードに到達する前にプロセスが落ちれば、DBには注文が残りイベントは永久に飛びません。問題は「失敗の検知」ではなく、二つの書き込みの間にあるアトミック性の欠如そのものです。リトライ(/devops/retry-backoff-jitter/)は片方の一時障害には効きますが、中間クラッシュは塞げません。
なぜ2フェーズコミットで解かないのか
教科書的には、DBとブローカを分散トランザクション(XA / 2フェーズコミット, 2PC) で束ねれば原子性が得られます。コーディネータが両者に prepare を問い、双方が yes なら commit を配ります。理屈では片方だけ成功は消えます。実務でこれを避ける理由は明確です。
- 多くのブローカが XA を実装していない:Kafka をはじめ主要なメッセージ基盤は XA リソースとして振る舞わない。そもそも参加できない。
- 可用性が落ちる:2PC はコーディネータが prepare 後 commit 前に落ちると、参加者がロックを握ったまま「in-doubt」で固まる(ブロッキング)。これはコンセンサスの不可能性(/devops/flp-impossibility/)と地続きの構造的弱点で、片方が遅れると全体が待たされる。
- スループットとスケールに不利:prepare フェーズの同期と分散ロックが、マイクロサービスの疎結合・高スループットの要求と噛み合わない。
つまり 2PC は「正しさは得られるが、可用性・スケール・対応状況の三方で割に合わない」。そこで発想を変えます。二つのシステムに書くのをやめ、書き込みをひとつのDBトランザクションに畳み込む——これがアウトボックスパターンの出発点です。
アウトボックスパターン:イベントを業務データと同じトランザクションに入れる
核心はひとつの観察です。同一DB内の複数テーブルへの書き込みは、ひとつのトランザクションで原子的に確定できる。ならば、ブローカへ送りたいイベントを、いったん同じDBのテーブルに書けばよい。このテーブルをアウトボックステーブル(outbox table) と呼びます。
BEGIN;
INSERT INTO orders (id, ...) VALUES (...); -- 業務データ
INSERT INTO outbox (id, aggregate_id, type, payload, created_at)
VALUES (...); -- 送りたいイベント
COMMIT;
このトランザクションは全部成功か全部失敗かです。注文行とイベント行が同時に確定するか、両方とも無かったことになる。中間状態(片方だけ)は原理的に存在しません。デュアルライト問題が、「二つのシステムへの書き込み」を「ひとつのDBへの二行の書き込み」に置き換えることで消えます。
ここで解けたのは「業務状態の更新」と「イベントを送る意図の記録」の原子性です。まだブローカに実際に届けてはいません。イベントはアウトボックステーブルに溜まっているだけ。次の段が、これを確実にブローカへ転送する仕組みです。
アウトボックステーブルの本質は、「このイベントを必ず送る」というコミット済みの約束をDBに残すことです。郵便の発送箱(outbox)に手紙を入れた時点で、配達は別の人(集配人)が後で確実に行う。書き手は配達の完了を待たない。この分離が、業務トランザクションを軽く保ちつつ at-least-once の転送を可能にします。
転送:CDC(ログテーリング)とポーリングパブリッシャ
アウトボックスに溜まったイベントをブローカへ運ぶ独立プロセスをリレー(message relay / publisher) と呼びます。実装は大きく二系統です。
ポーリングパブリッシャは、未送信行を定期的に SELECT してブローカへ publish し、成功したら送信済みに印を付ける(または行を削除する)方式です。単純で移植性が高い一方、ポーリング間隔ぶんの遅延が乗り、頻繁なポーリングはDB負荷になります。
loop:
rows = SELECT * FROM outbox WHERE published = false ORDER BY id LIMIT N
for row in rows:
broker.publish(row) # 成功するまでリトライ
mark_published(row) # publish 成功後に印を付ける
sleep(間隔)
CDC(Change Data Capture)方式は、DBのトランザクションログ(WAL / binlog)を末尾から読む(ログテーリング)ことでアウトボックスへのINSERTを検知し、ブローカへ流します。Debezium が代表例です。WAL は既にコミット済みの変更だけを順序通り含むため、ポーリングのオーバーヘッドなしに低遅延で、かつコミットされた変更だけを漏れなく拾えます。
| 観点 | ポーリングパブリッシャ | CDC(ログテーリング) |
|---|---|---|
| 仕組み | outbox表を定期SELECT | WAL/binlogを末尾から購読 |
| 遅延 | ポーリング間隔ぶん乗る | 低遅延(コミット直後に検知) |
| DB負荷 | ポーリング頻度に比例 | ログ読取りのみで本体クエリに無干渉 |
| 順序保証 | ORDER BYで明示制御が必要 | ログ順=コミット順を自然に保持 |
| 導入コスト | アプリ内ループで完結し簡単 | CDCコネクタ運用が必要 |
どちらの方式でも、リレーは進捗(どこまで送ったか) を記録します。ポーリングなら published フラグ、CDC なら WAL のオフセット(LSN)です。この記録の更新は publish 成功の後に行うのが鉄則です。先に進捗を進めてから publish 前に落ちると、そのイベントは二度と読まれず欠落します。
転送は at-least-once:べき等性が前提になる
リレーが落ちて再開する状況を考えます。「publish は成功したが、進捗を記録する前にクラッシュ」した場合、再開後にリレーは同じイベントをもう一度 publish します。
t=0: broker.publish(イベントE) 成功
t=1: 進捗(published=true / LSN前進) を記録する前にクラッシュ
t=2: 再起動。Eはまだ未送信扱い → Eを再publish(重複)
つまりアウトボックスの転送は構造的に at-least-once です。これは欠落を避けるための正しい設計選択でもあります。「進捗記録を publish より後」に置くことで、欠落(at-most-once)の代わりに重複(at-least-once)を選んでいるのです。重複は二将軍問題(/devops/idempotency-exactly-once/)と同根で、原理的に消せません。
結論として、コンシューマ側はべき等でなければなりません。各アウトボックスイベントに不変のイベントID(テーブルの主キーをそのまま使うのが定石)を持たせ、受信側は重複排除窓でそのIDを弾く。これにより「効果は一度きり」の実効的 exactly-once が端から端まで成立します。アウトボックスが保証するのは欠落しないことであって、配信が一度になることではありません。
複数のリレーワーカーで並列に転送すると、イベント順序が崩れることがあります。同じ集約(aggregate_id、例えば同一注文)に属するイベントは、集約キーでパーティションして同一パーティション内で順序を保つのが定石です。Kafka ならパーティションキーに aggregate_id を使い、パーティション内のFIFOで「同一エンティティのイベント順」を守ります。グローバルな全順序まで要求すると並列度が犠牲になるため、順序保証は集約単位に限定するのが現実的です。
運用上の論点:肥大・順序・配信先
アウトボックステーブルは放っておくと無限に膨らみます。送信済み行は定期的にパージ(削除、もしくはパーティション単位の drop)する必要があります。CDC 方式では「WAL に乗せるためだけにINSERTし、すぐ削除する」設計も使われます(行は一瞬だけ存在し、WALには記録が残るので転送には支障がない)。
もうひとつの実務論点が、アウトボックスとブローカ間の重複・障害の切り分けです。アウトボックスは「DB側の原子性」を保証しますが、ブローカ自体の可用性や、ブローカからコンシューマへの配信は別問題です。整合性の境界がDB → リレー → ブローカ → コンシューマと多段になり、各継ぎ目で at-least-once が積み重なる。だからイベントIDによるべき等性は、リレーとコンシューマの両方の継ぎ目で効かせる必要があります。
アウトボックスの正しさを確認する勘所は四点です。(1) 業務更新とoutbox INSERTが同一トランザクションか(別コネクション・別トランザクションだとデュアルライトが復活する)。(2) 進捗記録(published更新/オフセット前進)がpublish成功の後か(前だと欠落、後だと重複=正しい)。(3) イベントに不変IDがあり、コンシューマがべき等か。(4) 送信済み行のパージ運用があるか。なお「アウトボックスを使えば exactly-once」という主張は誤りで、得られるのは at-least-once 転送+べき等消費による実効的 exactly-onceです。
この発想の対称形がインボックス(inbox)パターンです。コンシューマ側で「受信したイベントID」を受信処理と同じトランザクションでインボックステーブルに記録し、既に処理済みなら捨てる。送信側のアウトボックスと受信側のインボックスが揃って初めて、デュアルライトのない端から端までのイベント整合性が完成します。これはイベント駆動マイクロサービス(/devops/microservices/)で状態を疎結合に伝播させる際の標準的な土台です。
まとめ
- DB更新とメッセージ送信は別システムへの二つの書き込みで原子的に揃わず、中間クラッシュで片方だけ成功するデュアルライト問題が起きる。順序を変えても欠落か幻イベントに化けるだけ。
- 2PC は正しさは得られるが、ブローカ非対応・ブロッキングによる可用性低下・スケール不利で実務的に避けられる。
- アウトボックスパターンは、送りたいイベントを業務データと同じDBトランザクション内のアウトボックステーブルへINSERTすることで、二つのシステムへの書き込みをひとつのDBへの二行の書き込みに畳み込み、原子性を取り戻す。
- 転送はCDC(WALのログテーリング)かポーリングで非同期に行い、進捗記録をpublishの後に置くため構造的にat-least-once。コンシューマはイベントIDでべき等に組む。
- 得られるのは真の exactly-once ではなく、at-least-once転送+べき等消費による実効的 exactly-once。インボックスパターンと組めば端から端までのイベント整合性が完成する。
DevOps/インフラ Article
アウトボックスパターンとデュアルライト問題を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
アウトボックスパターン
比較で見る軸
難易度: advanced / カテゴリ: DevOps/インフラ / タグ数: 6
導入後に効く点
解決の核心は、送りたいイベントを業務データと同じDBトランザクション内のアウトボックステーブルへINSERTすること。これでDB更新とイベント記録が原子的に揃い、片方だけ成功が消える。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- DevOps/インフラ
- タグ数
- 6
判断チェックリスト
- 自社の用途が「アウトボックスパターン / CDC」に近いか確認する。
- 強みである「DB更新とメッセージ送信は別々のシステムへの書き込みで原子的に揃えられず、片方だけ成功する「デュアルライト問題」が起きる。2フェーズコミットは可用性とブローカ非対応で実務的に避けられる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。