複数集約を跨ぐ処理を1つのDBトランザクションで括る前に読む記事
本稿は、DDDの集約モデリング手順や、境界の見つけ方そのものを解説する記事ではない。扱うのは、集約境界と単一DBトランザクション境界を混同したときの波及である。RDBのロックや運用上の負担、読み取り側の公開範囲、プロセスマネージャー設計へ、どのような影響が出るのかを見る。つまり、ドメインモデリングの本質論ではなく、不変条件を実装へ落とす過程で表面化する技術的な論点に焦点を当てる。
本稿の内容は、既存文献から学んだことと、筆者自身の設計・運用上の解釈に基づいている。設計に「常にこうすべき」という絶対解はない。ここで述べるのは、筆者が現時点で妥当だと考えている判断軸である。
というか、かなり長くなってしまったのでAIに要約させてた方がよいも…。
複数集約を単一DBトランザクションに束ねたくなるとき
ロールバックの誘惑
本稿で単一DBトランザクションと呼ぶのは、RDB上のACIDトランザクション[1]——更新を不可分にコミットまたはロールバックする実装機構——を指す。
DDDのリポジトリを中心とするアプリケーション設計に慣れていると、あるユースケースで2つの集約[2]を操作するとき、それらをすぐRDB上の同じ単一DBトランザクションに含めたくなる。片方を更新したらもう片方も整合させたい、どちらかが失敗したら両方ロールバックしたい——その要求は一見すると自然で、堅牢ですらあるように見える。だが、2つの集約を常に同一の単一DBトランザクションで更新しなければ整合性を維持できないのだとしたら、それは設計上のシグナルである。
たとえば注文確定処理では、在庫集約と注文集約の更新をまとめてtransactionへ入れた形になりやすい。
// よくない例
function confirmOrder(orderId: OrderId): void {
transaction(() => { // 複数集約の更新を単一のDBトランザクションに含める
// 注文集約
const order = orderRepository.findById(orderId)
// 在庫集約
const inventory = inventoryRepository.findReservableFor(order)
// 在庫確保
const reservedInventory = inventory.reserve(
order.id,
order.quantity,
)
// 在庫更新
inventoryRepository.save(reservedInventory)
// 注文確定
const confirmedOrder = order.confirm(reservedInventory.reservationId)
// 注文更新
orderRepository.save(confirmedOrder)
})
}
同時更新したいなら境界を疑う
なぜ、これが設計上のシグナルなのか。不変条件を守るために恒常的に複数集約のアトミック更新を要求しているなら、それは、本来一つの整合性単位として扱うべきものを、設計上は2つの集約として切ってしまっている可能性が高い。
強整合の境界は、「常に一貫していなければならないまとまり」として宣言する線引きだ。2つの集約を恒常的に同じ強整合の境界に入れているなら、設計者はそれらを「同時に、不可分に、整合していなければならないもの」だと宣言したことになる。それはもはや2つの集約ではなく、アトミックに更新される一つの整合性単位——すなわち一つの集約だ。
ここで見ているのは、単に複数のオブジェクトを一度に保存しているコードではない。不変条件が複数集約の同時更新を恒常的に要求している設計である。
そもそも、集約とは整合性境界そのものである。「ここからここまでは常に整合している」という不変条件を守るために引かれた境界であり、その境界の内側だけが強整合を保証され、境界を越えた整合は結果整合[3]に委ねられる。この前提に立てば、2つの集約を跨いで恒常的にアトミック性を要求した時点で、矛盾が露呈する。強整合を要求しているなら、それらは最初から一つの集約として切るべきだった可能性が高い。別々の集約として切るなら、即時・不可分な整合性を前提にしない形へドメイン概念を再配置しなければならない。仮確保、申請中、調整中、割り当て待ちといった中間状態をドメイン語彙として発見できれば、強整合は一つの集約の内側に収まる。集約間の整合は結果整合でつなげられる。複数集約を同一の単一DBトランザクションで更新したくなることは、設計上の便宜ではなく、この再配置がまだ済んでいないというシグナルなのだ。
この論点は既存文献でも整理されている
この見方は新しい発見ではない。少なくともこの10年ほど、DDDの集約設計では既存文献で繰り返し整理されてきた論点である。たとえば、ヴォーン・ヴァーノンの『実践ドメイン駆動設計』第10章「集約」でも、同じ趣旨が整理されている[4]。真の不変条件は整合性境界の内側にモデリングし、境界の外側では結果整合を用いる。つまり、複数集約を単一DBトランザクションで変更したくなる場面は、真の不変条件がどこにあるのかを問い直す材料になる。整合性を保つ責務が同じ主体にあるなら境界を見直し、別の主体や別のシステムに移るなら結果整合として扱う、という切り分けである。
クリス・リチャードソンも『マイクロサービスパターン』で、単一DBトランザクションでは1つの集約だけを作成または更新する、というルールを示している[5]。RDBMSを使えば、同じサービス内の複数集約を同じ単一DBトランザクションで更新する抜け道はある。しかし、複数集約に跨がる整合性を恒常的に要求するなら、まず境界の引き方を疑うべきである。集約境界もドメイン理解に応じて引き直す対象である。固定されたものではない。

複数集約を常に同一の単一DBトランザクションで更新したくなるなら、境界の切り方を疑う。
強整合の境界と弱整合の境界
実装へ落とす前に、設計には2つの異なる整合性の境界があることを区別しておきたい。一つは集約の境界——強整合の境界である。その内側では、コミットされたドメイン状態として不変条件が破れた状態を残さない。更新はアトミックに扱われ、集約の外側には整合した状態だけが公開される。もう一つはユースケースの境界——弱整合の境界である。複数の集約に跨がるユースケースはこちらに属し、その整合に即時性や不可分性は求めない。更新が止まれば最終的に整合した状態へ収束するものとして扱う。集約を跨いだ操作を同じ単一DBトランザクションに入れたくなるのは、この二層を混同し、弱整合で十分なものに強整合の保証を要求してしまっているからだ。境界の種類を取り違えているのである。

強整合と弱整合は、保証する整合性を分けて扱う。
複数集約を跨ぐユースケースは強整合の境界ではない
ここで言葉の整理をしておきたい。本稿で「ユースケース」と呼ぶのは、利用者の目的を達成するために、アプリケーションが外部から受け付ける操作の単位である。ユースケースが単一の集約だけを更新するなら、その更新を単一DBトランザクションで実行しても、多くの場合は集約の強整合の境界を越えない。ただしこれは、ユースケースが常に強整合の境界になるという意味ではない。強整合の境界を決めるのは、ユースケースの形ではなく、集約が守る不変条件である。
ただし、単一DBトランザクションの便利さを、設計境界の代替にしてはいけない。どこかで失敗したら全部ロールバックできるから安心だ、という理由だけで、関係する更新処理を何でもかんでも単一DBトランザクションへ押し込むと、ユースケースの都合がそのまま強整合の境界になってしまう。ここで広がるのは処理範囲だけではなく、強整合として扱う責任範囲である。
問題は、そのユースケースが複数の集約の状態変化を協調させる場合である。このとき、ユースケースの境界と、強整合の境界を同一視してはいけない。複数集約に跨がるユースケースでは、強整合の境界が一致すべきなのは個々の集約であって、それらを束ねるユースケース全体ではない。ユースケースは、単一DBトランザクションで束ねる単位ではなく、結果整合のプロセスとして調停する単位になる。
永続化中心の設計に慣れていると、この混同が見えにくい。リポジトリを2つ呼んで、同じDBトランザクションスコープでコミットする——コードとしては数行で、何の障害もなく書けてしまう。RDBが背後にいる限り、それは「動いてしまう」。動いてしまうからこそ、強整合の境界を弱整合の領域へ無自覚に拡張していることに気付けない。
同一の単一DBトランザクションに束ねるコストとリスク
長いトランザクション
この境界の取り違えは、抽象的な設計論にとどまらない。永続化基盤の実行時コストと、運用時のリスクとしても現れる。複数の集約を単一DBトランザクションに束ねれば、そのトランザクションは長くなりやすい。
しかも、これは最初から巨大なトランザクションとして設計されるとは限らない。モデルの境界から「ここまでを一つの整合性単位にする」と決めたのではなく、「同じDBトランザクションに入れておけばロールバックできて便利だ」という理由で境界を引くと、その境界には制約がない。機能追加や改修のたびに更新処理が足され、一つのDBトランザクションが複数集約の更新処理を抱え込む。こうして、モデルから導かれていない単一DBトランザクションは肥大化していく。
先ほどの注文確定処理に戻ろう。最初は在庫確保と注文確定だけだったとしても、後から「配送枠の確保も注文確定の条件にする」という要件が足されることはある。このとき、既存のtransactionに次の更新を足すだけなら、コード上は簡単に見える。
// よくない例
function confirmOrder(orderId: OrderId): void {
transaction(() => {
const order = orderRepository.findById(orderId)
// 既存の在庫確保、注文確定
// ...
// 追加要件: 配送枠確保も注文確定の条件になった
const deliverySlot = deliverySlotRepository.findByDate(order.deliveryDate)
const reservedDeliverySlot = deliverySlot.reserve(order.id)
deliverySlotRepository.save(reservedDeliverySlot)
// さらに要件が増えれば、同じトランザクションに更新が足されていく
// ...
})
}
この追加の問題は、その場では妥当に見えることだ。注文確定時に在庫を確保する。さらに配送枠の確保も注文確定の条件になった。どれもユースケース上は関連している。しかし、呼び出すリポジトリが増えるたびに、同じ単一DBトランザクションの内側へ更新対象の集約が追加されていく。こうして、ユースケースの都合がそのまま強整合の境界になっていく。
このようにtransactionが長くなると、RDB側では何が起きるのか。MySQL/InnoDB[6]を例に取れば、開いているDBトランザクションが更新量や負荷に対して長く残るほど、その時点から見えるはずだった古いデータの状態を捨てにくくなる。これは「何秒を超えたら必ず問題になる」という話ではない。InnoDBはMVCC[7]によって、読み取りと書き込みを両立させるために過去の行バージョンを保持する。短いトランザクションばかりで、パージ処理が追いついていれば、大きな問題にはなりにくい。問題になりやすいのは、更新量が多い状況で、古い読み取りビューを保持するトランザクションが相対的に長く残る場合である。このとき、更新に伴って作られたUNDOログ[8]の後始末が遅れる。パージ処理が回収すべき履歴も増える。結果として、SHOW ENGINE INNODB STATUSに表示されるHistory list lengthが伸びやすくなる。
History list lengthは累積カウンタではなく、その時点でパージ処理がまだ回収できていない履歴の長さである。その値が伸び続けることは、古い行バージョンの後始末が滞っているサインである。これが続くと、読み取りや更新が余計な履歴を意識しなければならなくなり、処理が遅くなる。状況によっては、停止処理、アップグレード前チェック、復旧作業の負担増にも波及する。開発者から見れば、境界を広げた設計判断が、ある日突然クエリ遅延、タイムアウト、運用作業の負担増として返ってくる。
History list length は直接の原因というより、古い行バージョンの後始末が滞っていることを示す観測値である。実害は、読み取りや更新が余計な履歴を辿ることによる遅延として現れる。
これは机上の内部カウンタの話ではない。MySQLの公式ドキュメントでもHistory list lengthはパージ遅延を見る値として扱われている。AWS Auroraは、運用インサイトやメジャーアップグレード前チェックの対象としている。DatadogやAzure Database for MySQLの文書でも、運用監視の指標として扱われている。実運用では「この値が伸びるとつらい」という問題として現れる[9]。
ここで言いたいのはMySQLのチューニング論ではない[10]。複数集約を単一DBトランザクションに束ねる設計は、ドメイン境界の問題だけでなく、永続化基盤の後始末を遅らせ、読み書きの遅延、タイムアウト、運用作業の負担増として返ってくる。モデルから導かれていない境界拡張は、永続化基盤の後始末を遅らせ、運用負荷を高めうる。
ロック獲得順序と循環待ち
ここでいうロックは、概念上は集約に対するロックとして解説しているが、実体としてはRDBが集約の永続化に使う行や関連行に対して取得する行ロックである。
長い単一DBトランザクションは、古い行バージョンの後始末だけでなく、ロック獲得順序の問題も抱え込む。次のような3つの単一DBトランザクションを考えてみよう。一つは集約AとBを更新する {A,B}、もう一つはBとCを更新する {B,C}、最後にCとAを更新する {C,A}。3つはいずれも2つの集約を両端で共有している。同時に実行されれば、いずれかのペアが必ず同じ集約を取り合う。これは書き込み競合である。整合性は壊れないが、スループットは落ち、待ちやリトライの温床になる。
まず、悲観ロックで考える。SELECT ... FOR UPDATEなどで対象行の排他ロックを先に獲得し、単一DBトランザクションの終了まで保持する場合、ロック獲得順序はそのまま待ち関係になる[11]。{A,B} がA→Bの順、{B,C} がB→Cの順、{C,A} がC→Aの順でロックを取りに行くと、A待ち→B待ち→C待ち→Aという循環待ちが成立する。古典的な食事する哲学者と同じ構造の、循環デッドロックである。ロック獲得順序を対象となる単一DBトランザクションでそろえていなければ、普通に起きる。RDBはこの循環待ちを検出すると、関係するトランザクションのいずれかを中断し、ロールバックさせる。アプリケーションから見れば、処理が突然エラーとして返ってくる。これは、低負荷では問題なく動いているように見える設計が、高負荷時に初めて失敗として表面化する典型例だ。低負荷の開発環境では一度も再現せず、本番のピークで突然エラーが頻発する——という最悪の出方をする。
この循環待ちは、各単一DBトランザクションが「保持している行ロック」と「解放を待っている行ロック」に分けると見えやすい。
循環デッドロックは、処理順序ではなく、どの単一DBトランザクションがどのロックの解放を待っているかという待ち関係の輪として見るとわかりやすい。
この3つが同時に成立すると、どの単一DBトランザクションも次へ進めない。RDBの行ロックは単一DBトランザクションのCOMMIT / ROLLBACKまで保持されるため、待ち関係が循環すればデッドロックになる。
もちろん、対象となるすべての単一DBトランザクションで集約ID順にロックを取るといった規約を徹底すれば回避はできる。だがその規約は、ドメインモデルからは導かれない、永続化層由来の暗黙制約である。集約の図にもユースケースの記述にも、その制約は現れない。コードを読んでも見えない。回避策が存在しないのではなく、回避策そのものが、対象となるすべてのユースケースで守るべきモデル外の規約として残る——ここが問題の核心だ。本番で循環待ちが起きて初めて、その規約が必要だったことを知る。
では、楽観ロックならこの問題から完全に逃れられるのか。そうではない。Webアプリケーションでよくある楽観ロックでは、読んだ時点から対象行が変更されていないことをUPDATE時に検査し、バージョン不一致なら更新対象行数0件として失敗させる。これはアプリケーション層の競合検出である。
楽観ロックの基本形は、versionを条件に含めた更新である。この形でも、複数集約を同じ単一DBトランザクションに束ねれば、{A,B}、{B,C}、{C,A}という関係はそのまま残る。
-- よくない例
-- T1: 集約A -> 集約B の順に更新する
BEGIN;
UPDATE aggregate_states
SET state = :next_a_state, version = version + 1
WHERE aggregate_id = 'A'
AND version = :loaded_a_version;
UPDATE aggregate_states
SET state = :next_b_state, version = version + 1
WHERE aggregate_id = 'B'
AND version = :loaded_b_version;
COMMIT;
-- T2: 集約B -> 集約C の順に更新する
...
-- T3: 集約C -> 集約A の順に更新する
...
各UPDATEの対象行数が0件なら、読み込んだあとに別の処理が同じ行を更新していたということだ。アプリケーションは競合として扱い、再読み込みしてリトライする。ただし、ここで重要なのは、楽観ロックであっても実際のUPDATE時には行ロックを取得する点である。複数集約を同じ単一DBトランザクションに束ねるほど、バージョン不一致の競合点だけでなく、DB上のロック獲得順序も増える。
したがって、楽観ロックで表面化する失敗は1種類ではない。version不一致による更新対象行数0件の失敗もあれば、複数のUPDATEが異なる順序で行ロックを取り合った結果、DBが循環待ちを検出し、いずれかの単一DBトランザクションをロールバックさせることもある。楽観ロックは前者を検出するためのしくみであり、後者を原理的に消すものではない。つまり、楽観ロックでも、複数集約を同じ単一DBトランザクションに束ねたことによる競合とリトライの問題は消えない。
強整合は読み取り側も依存する
複数集約を同一の単一DBトランザクションで更新する設計は、更新側だけで完結しない。集約Aと集約Bが常に同じタイミングで更新される前提は、読み取り側のクエリにも漏れる。たとえばSELECT ... JOINで両者を結合して読む処理が、「Aは更新済みだがBはまだ反映されていない」という中間状態を想定しなくなる。
そうなると、あとからユースケース内の更新手順を分割したり、プロセスマネージャーで弱整合の流れに組み替えたりしたとき、壊れるのは更新側だけではない。読み取り側も、同時更新という暗黙前提に依存しているため、JOIN条件、表示条件、欠損時の扱いを見直す必要が出る。強整合の境界を広げるほど、その前提は読み取り側の仕様にも持ち込まれる。
一方、弱整合の境界として設計していれば、集約Aと集約Bの更新タイミングがずれることは最初から前提になる。読み取り側も、中間状態、未反映、処理中、補償中といった状態を扱う設計になるため、更新手順の変更に対して過度に脆くなりにくい。これはJOINを禁止するという話ではない。読み取り側が同時更新の暗黙前提に依存していないかを問う話である。
ここまでのリスクを引き受けてまで、不変条件が要求していない更新を単一DBトランザクションに含めたいだろうか。複数集約を同一の単一DBトランザクションに束ねるなら、ロック獲得順序、競合時のリトライ、失敗時の影響範囲、永続化層由来の運用上の規約まで引き受けることになる。それでもなお単一DBトランザクションに束ねるのかを、設計判断として明示しなければならない。
1集約1アクターでは強整合の境界が構造で閉じる
ここまでの話は、RDBを使ったリポジトリ中心の実装でも成立する。もう一つの補助線として、CQRS/ESでコマンド側の集約をアクターとして実装した場合も見ておく。ここで扱うのは、集約クラスをどう設計するかではなく、メッセージのやりとりで状態を進めるアクターモデル上の境界である。本稿では、1つの集約に1つのアクターを対応させ、その集約へのコマンドをアクターが順に処理する設計を想定する。ここでいうアクターは、メッセージを受け取り、自分が持つ状態を順に更新する処理単位である。
この対応関係を図にすると、次のようになる。
コマンドは集約アクターへ送られ、アクターが状態を進め、発生したイベントをジャーナルへ追記する。
この前提に立つ実装では、境界が構造として強制される。一つのアクターは一つの集約に対応し、アクター内部の状態遷移と、ジャーナルへのイベント追記は、そのアクターの処理内で完結する。重要なのは、アクターモデルの細部ではなく、強整合の更新境界が一つのアクター、つまり一つの集約に閉じるという点である。
この構成では、2つの集約を跨いでアトミックに書く経路を通常のコマンド処理に置かない。少なくとも、同一DBトランザクション内で複数集約のロックを取り合うことに由来する循環デッドロックは、構造上発生しない。ロック獲得順序という永続化層由来の暗黙制約も、同じ理由で消える。(ただし、アクター間で同期的に問い合わせ合い、応答待ちの循環を作れば、別種のデッドロックやプロトコル停止は依然として設計しうる。消えるのは、DBロック由来の循環デッドロックであって、分散協調の難しさ一般ではない。)不便に見えるこの制約こそが、強整合の境界を集約の内側に閉じ込めておくしくみになっている。
集約を跨ぐユースケースを弱整合のプロセスとして設計する
プロセスマネージャーで進行を調停する
単一DBトランザクションに束ねないなら、2つの集約に跨がるユースケース——弱整合の境界に属する処理——はどう実現するのか。答えは、結果整合を前提にした複数ステップの更新フローとして設計することだ。各ステップは個別の集約更新として実行し、失敗時には補償アクションでドメイン上の取り消し操作として戻す。その進行を、ユースケースの進行状態を持つプロセスマネージャー(オーケストレーション型のサーガ)[12]に調停させる。プロセスマネージャーはクライアントから開始要求を受け取り、現在のプロセス状態と各ステップの結果に基づいて、次にどの集約へどのコマンドを出すかを能動的に決める。つまり、中央の調停役がプロセスを進めるオーケストレーションである。
このときの弱整合は、「整合性制御が不要」という意味ではない。捨てるのは、複数集約を単一DBトランザクションで不可分に更新する設計である。プロセスマネージャーは、弱整合の境界を、結果整合と補償アクションを使う複数ステップの更新フローとして最後まで進める中央の制御役である。
ACID ではなく ACD としてとらえる
クリス・リチャードソンのSagaの整理に寄せれば、この更新フローはACIDではなくACDの性質としてとらえられる[13]。ACIDのIsolation(隔離性)は、単一DBトランザクションの途中状態をほかの処理へ公開せず、並行実行される処理どうしの干渉をトランザクション境界の内側に閉じ込める性質である。ここでいうAtomicity(原子性)は、単一DBトランザクションのロールバックと同じ意味ではない。失敗時に補償アクションを実行し、プロセス全体を業務上の取り消し済み状態へ収束させる、という意味での原子性である。Consistency(一貫性)は最終的にドメイン状態の整合として満たしにいく。Durability(耐久性)は、各ステップやプロセス状態をどう永続化するかに依存する。
ただし、このACDという整理で重要なのは、Isolation(隔離性)が含まれないことだ。プロセスマネージャーはユースケース全体を単一のACIDトランザクションとして隔離しない。各集約の更新はそれぞれの強整合の境界内で行われるが、プロセス全体は途中状態を公開しうる[14]。在庫確保済み、決済待ち、補償中といった状態が外から見える可能性がある。この点は後述するように、クエリ側の読み取りモデルで公開範囲を制御する。したがって、単一DBトランザクションのIsolation(隔離性)に頼るのではなく、中間状態、冪等性、再試行、補償処理を設計対象にする必要がある。
この性質を引き受けるために、プロセスマネージャーが弱整合の境界を担う。プロセスマネージャーは、複数集約を跨ぐユースケースという単位を実行時に体現する。整合は単一DBトランザクションによる同期的な一撃ではなく、時間軸を持ったプロセスになる。
時間軸を持つからこそ、この層で設計対象になるのは、補償処理、冪等性、再試行、タイムアウト、重複要求の吸収といった整合性制御である。
この設計は、「結果整合に逃げる」ことではない。
強整合の境界の内側に隠れていた整合性制御を、設計の表層に引き上げて明示することだ。
補償アクションとして戻す
たとえば注文プロセスマネージャーを考える。注文コマンドを受けたら、在庫確保集約へ在庫確保コマンドを送り、在庫確保に成功したら決済集約へ決済コマンドを送る。ここで決済に失敗した場合、在庫確保をデータベーストランザクションのロールバックで巻き戻すのではない。在庫確保集約へ取り消しコマンドを送り、在庫確保の取り消しという補償アクションで戻す。重要なのは、複数集約を同じ単一DBトランザクションに閉じ込めることではなく、成功時の次アクションと失敗時の補償アクションをプロセスとして明示することだ。
プロセスマネージャーを永続化するなら、プロセス状態の保存やリカバリ時の再開も設計対象になる。補償アクション自体が失敗した場合の再試行、タイムアウト、重複実行の吸収も同じく実装上の重要論点である[15]。ただし本稿ではそこまで広げず、弱整合の境界を進める更新フローとして、成功経路と補償経路だけを示す。
注文プロセスマネージャーは、弱整合の境界を進める更新フローとして、成功時の次コマンドと失敗時の補償コマンドを明示する。

弱整合の更新フローでは、各集約の更新を個別に進め、失敗時は補償コマンドで戻す。複数集約を単一DBトランザクションに閉じ込める設計とは、失敗時の扱いと結合度が異なる。
読み取りモデルで公開範囲を制御する
集約を跨いだ更新では、途中状態だけでなく、片方の集約だけがコミットされ、後続の集約更新が失敗した状態も起こりうる。たとえば注文プロセスであれば、在庫確保は成功したが決済はまだ完了していない、あるいは決済失敗後の在庫確保取り消しがまだ終わっていない、といった状態である。これらは弱整合のプロセス内部では扱うべき状態だが、通常の利用者向けビューに「成立した注文」として見せてよい状態ではない。この場合、更新側で注文集約、在庫確保集約、決済集約を単一DBトランザクションに閉じ込めるのではなく、注文側に「確定待ち」「補償中」「失敗」といった状態を持たせる。クエリ側では、通常の注文一覧に出す状態と、処理中ビューや管理者向けビューに出す状態を分ければよい。
たとえば通常の注文一覧では、注文テーブルを起点にLEFT JOINして処理途中の行まで拾うのではなく、確定済みの注文と確保済みの在庫予約だけをINNER JOINで通す。
SELECT
o.id,
o.customer_name,
r.reservation_id
FROM orders o
INNER JOIN inventory_reservations r ON r.order_id = o.id
WHERE o.status = 'confirmed'
AND r.status = 'reserved';
たとえば、更新途中で注文id = 3の決済がまだ完了していないとする。
| orders.id | orders.customer_name | orders.status | inventory_reservations.status |
|---|---|---|---|
| 1 | 佐藤 | confirmed | reserved |
| 2 | 鈴木 | confirmed | reserved |
| 3 | 田中 | payment_pending | reserved |
このとき、通常一覧の出力は次のようになる。
| id | customer_name | reservation_id |
|---|---|---|
| 1 | 佐藤 | r-001 |
| 2 | 鈴木 | r-002 |
このクエリでは、決済待ちの注文は通常一覧に現れない。片方の集約だけが更新済みの状態や、補償処理待ちの状態を表示したい場合は、別の処理中ビューや管理者向けビューで、status = 'payment_pending'やstatus = 'compensating'などを明示的に読む。
処理中のデータや補償待ちのデータは、管理者向けの確認画面や処理中ビューにだけ出す。弱整合では、片方だけ反映された状態を存在しないことにするのではない。状態としてモデル化したうえで、どの読み取りモデルに公開するかを制御する。
まとめ
ここまで読むと、「では実装コードではどう書くのか」という疑問が残るはずだ。プロセスマネージャーをどの粒度で永続化するのか、補償コマンドをどう冪等にするのか、再試行やタイムアウトをどう扱うのかは、それだけで一つの記事になる。本稿ではそこまで広げず、まず設計判断として、強整合の内側に隠すのか、弱整合のプロセスとして明示するのかを切り分けるところまでにとどめる。
本稿の射程は、具体的な実装パターンを網羅することではない。まず、対象の処理をどちらの整合性境界として扱うのかを決めることである。
重要なのは、複数集約を跨ぐ処理をなくすことではない。複数集約の同時更新が真の不変条件ではないなら、単一DBトランザクションとして密結合にせず、弱整合のプロセスとして疎結合に設計することである。
この考え方は、いくつかの文献と実装経験を踏まえる限り、おおむね正しい方向を向いていると考えている。ただし、設計の問題に完全な終点はない。もし本稿の整理に誤りや不足があるなら、指摘してほしい。さらによい方法があれば、ぜひ教えてほしい。
-
Atomicity(原子性)、Consistency(一貫性)、Isolation(隔離性)、Durability(耐久性)の頭文字。データベーストランザクションの性質として解説される。 ↩︎
-
DDDにおける集約は、関連するオブジェクトを一つの整合性単位として扱う境界である。外部からの更新は集約ルートを通じて行い、その内側の不変条件を守る。 ↩︎
-
結果整合は、更新直後に一時的な不一致を許容し、必要な処理が完了すれば整合した状態になる、という考え方である。単に時間が経てば整合するという意味ではない。 ↩︎
-
ヴォーン・ヴァーノン『実践ドメイン駆動設計』第10章「集約」、10.2「ルール:真の不変条件を、整合性の境界内にモデリングする」および10.5「ルール:境界の外部では結果整合性を用いる」。同章では、集約をトランザクション整合性の境界として扱い、境界外では結果整合を使う考え方が整理されている。 ↩︎
-
クリス・リチャードソン『マイクロサービスパターン』5.2.3「集約のルール」ルール3「1つのトランザクションで1つの集約を作成・更新する」、p.175。本稿の語では、単一DBトランザクションで1つの集約だけを作成・更新する、という趣旨で扱っている。 ↩︎
-
InnoDBはMySQLの標準的なストレージエンジンであり、トランザクション、行ロック、MVCCなどを提供する。 ↩︎
-
MVCCは複数バージョン同時実行制御の略。更新時に古いバージョンを残すことで、読み取りと書き込みの並行実行を両立させるしくみである。 ↩︎
-
UNDOログは、更新前の情報を保持し、ロールバックや古い読み取りビューへの過去バージョン提供に使われるログである。 ↩︎
-
MySQL公式ドキュメントは、パージ遅延が
SHOW ENGINE INNODB STATUSのHistory list lengthとして表示され、長時間トランザクションや書き込み負荷で値が増えうることを解説している。AWS AuroraはHistory list lengthの増加を運用インサイトとして扱い、クエリやデータベース停止処理が遅くなりうると解説している。メジャーアップグレード前チェックでも、高い値を警告対象にしている。Datadogもmysql.innodb.history_list_lengthをMySQL監視メトリクスとして持つ。Azure Database for MySQLの文書でも、クエリ処理の低下やテーブルスペース消費につながる指標として解説されている。参照: MySQL 8.4 Reference Manual: Purge Configuration, Amazon Aurora: The InnoDB history list length increased significantly。そのほか、Aurora MySQL upgrade prechecks, Datadog MySQL Integration, Azure Database for MySQL: Understanding and managing History List Lengthも参照。 ↩︎ -
PostgreSQLは、InnoDBのようなUNDOログではなく、古い行バージョンをVACUUMで回収する。それでも、長く残るスナップショットがあると、不要になった行バージョンの回収は遅れる。細部は違っても、長いトランザクションが古い行バージョンの回収を重くする、という構造は変わらない。 ↩︎
-
悲観ロックでは、典型的には
SELECT ... FOR UPDATEなどで対象行の排他ロックを先に獲得し、トランザクション終了まで保持する。一方、楽観ロックのversionやupdated_atは、先に行ロックを取るためのものではない。UPDATE ... WHERE id = ? AND version = ?のように更新条件へ含めて、読んだ時点から変更されていないことを更新時に検査するための値である。楽観ロックでも実際のUPDATE実行時には行ロックが発生する。ただし、アプリケーション処理中にロックを保持し続けない点が異なる。 ↩︎ -
サーガには、参加者がイベントに反応して進むコレオグラフィー型もあるが、本稿では扱わない。ここでいうサーガは、中央の調停役が進行を決めるオーケストレーション型を指す。厳密には、サーガは一貫性よりも補償を優先し、長時間ロックを避けるパターンである。比較的単純な流れなら、永続的な進行状態を持たずに実装することも一般的だ。一方、プロセスマネージャーは有限状態機械として実装されるワークフローパターンであり、処理中の進行状態を永続化して保持する。本稿では、両者の差異には立ち入らず、結果整合と補償アクションで複数集約を進める中央の調停役を指す総称として「プロセスマネージャー(オーケストレーション型のサーガ)」と呼ぶ。具体的な進行状態の永続化方式は別論点として扱う。 ↩︎
-
クリス・リチャードソンはSagaについて、ACIDトランザクションとは異なりIsolation(隔離性)を欠くことを解説している。書籍『マイクロサービスパターン』では、SagaのトランザクションモデルをACDと整理し、Isolationの欠如によって並行実行時の異常が起こりうるため、設計上の対策が必要になると述べている。ただし、このAはデータベーストランザクションの不可分なロールバックではなく、補償可能なステップ列として全体を取り消し方向へ進める性質として読む必要がある。参照: Pattern: Saga。 ↩︎
-
途中状態が存在することと、その状態をすべての利用者向け画面やクエリでそのまま見せることは別である。クエリ側のリードモデルで、有効化済みの状態だけを通常一覧に出す、処理中の状態は管理者向けビューや処理中ビューにだけ出す、といった公開範囲の制御はできる。後述の「読み取りモデルで公開範囲を制御する」でこの点を扱う。 ↩︎
-
たとえば集約Aの更新をコミットしたあと、集約Bの更新に失敗してAPIがエラーを返した場合、同じ要求を安全に再実行できなければならない。再試行をクライアントに委ねるなら実装は単純になりやすいが、冪等キーや重複要求の扱いをAPI契約として明示する必要がある。サーバ側で再試行するなら、クライアントには受付済みとして返し、プロセス状態、再試行、タイムアウトをサーバ側で管理する必要がある。どちらを選んでも、片方だけコミットされた状態を前提に設計する点は変わらない。 ↩︎
Discussion