分散トランザクション(2PC と Saga)
別々のシステムにまたがる更新には単一の commit が無く、片方だけ成功した中途半端な状態をどう避けるかが問題になる。答えは 2 つ。2 相コミットは全員に準備を聞き、全員 Yes のときだけ確定させるが、調整役が落ちると参加者がロックを抱えて止まる。Saga はロックを捨てて各ステップを即確定し、失敗したら補償を逆順に実行して辻褄を合わせる。
この章で作るもの
mvcc 編までのトランザクションは 1 つのストアの中の話だった。マイクロサービスに分かれた途端、前提が崩れる。在庫サービスと決済サービスはそれぞれ自分の DB を持ち、「両方まとめて commit」する仕組みがどこにも無い。在庫だけ引き当てて課金に失敗すれば、売れない在庫が残る。課金だけ通れば、金を取ったのに商品が無い。
この章は、この「またがる更新」を揃える 2 つの方法を作る。2 相コミット(2PC)は分散に commit を持ち込む正攻法で、Saga はロックを捨てて補償で辻褄を合わせる割り切りだ。両者は原子性と可用性の異なる点を選んでおり、どちらが優れているかではなく、何を諦めるかが違う。
調整役 inventory payment
│── prepare(30) ──────▶ 残高OK,30をロック
│── prepare(30) ─────────────────────────▶ 残高OK,30をロック
│◀───── Yes ──────────┘ │
│◀───── Yes ─────────────────────────────────┘
│ 全員 Yes → 決定 = commit
│── commit ───────────▶ 確定
│── commit ──────────────────────────────▶ 確定
※ 1 人でも No → 全員 abort(ロックした参加者も巻き戻す)順に見ていく。
- 2PC = 聞いてから確定: prepare で全員の Yes を集めてから commit を配る。一部だけ確定は起きない
- 2PC の代償 = ブロッキング: Yes と答えた参加者は決定が届くまでロックを抱える。調整役が落ちると動けない
- Saga = 即確定 + 逆順補償: ロック無しで各ステップを確定し、失敗したら補償(打ち消し)を逆順に実行する
① 参加者: prepare の約束
2PC の参加者は、残高を持つサービスとして作る。核心は Prepare の意味論だ。「amount を引き落とす準備ができるか」に答え、できるなら金額をロックして Yes と返す。Yes と答えた参加者は、以後 commit が来たら必ず遂行できる状態を保ち続ける義務を負う:
// Vote は prepare への返答。
type Vote int
const (
VoteYes Vote = iota // 準備完了。commit が来たら必ず遂行できる状態で待つ
VoteNo // 遂行できない(残高不足など)。全体を中止させる
)
// PState は参加者から見たトランザクションの状態。
type PState int
const (
PIdle PState = iota
PPrepared // Yes と答えた。資源をロックして決定を待っている
PCommitted
PAborted
)
func (s PState) String() string {
switch s {
case PPrepared:
return "prepared"
case PCommitted:
return "committed"
case PAborted:
return "aborted"
default:
return "idle"
}
}
// Participant は 2PC の参加者。1 つの口座(残高)を持つサービスを模す。
// Prepare で引き落とし可能かを検査し、可能なら金額をロックして Yes と答える。
// 以降 Commit / Abort が来るまで、そのロックは解けない。
type Participant struct {
Name string
balance int64
locked int64
state PState
crashed bool
}
// NewParticipant は初期残高つきの参加者を作る。
func NewParticipant(name string, balance int64) *Participant {
return &Participant{Name: name, balance: balance}
}
// Balance は確定済み残高。Locked は prepare でロック中の額。State は現在の状態。
func (p *Participant) Balance() int64 { return p.balance }
func (p *Participant) Locked() int64 { return p.locked }
func (p *Participant) State() PState { return p.state }
// Crash は参加者の停止を模す(prepare に答えられなくなる)。
func (p *Participant) Crash() { p.crashed = true }
func (p *Participant) Recover() { p.crashed = false }
// Prepare は「amount を引き落とす準備ができるか」に答える。できるなら金額をロックして
// Yes。ロックした資源は、調整役の決定(Commit/Abort)が届くまで他の用途に使えない。
// ここが 2PC の約束の重さで、Yes と答えた参加者は「commit が来たら必ず遂行できる」
// 状態を維持し続ける義務を負う。
func (p *Participant) Prepare(amount int64) Vote {
if p.crashed || p.balance < amount {
return VoteNo
}
p.balance -= amount
p.locked += amount
p.state = PPrepared
return VoteYes
}
// Commit はロック分を確定する(引き落とし完了)。
func (p *Participant) Commit() {
p.locked = 0
p.state = PCommitted
}
// Abort はロック分を残高へ戻す。prepare していなければ何もしない。
func (p *Participant) Abort() {
p.balance += p.locked
p.locked = 0
p.state = PAborted
}ロックした資源は、調整役の決定(Commit/Abort)が届くまで他の用途に使えない。この「Yes の重さ」が、後で見るブロッキングの原因になる。逆に言えば、この約束があるからこそ、調整役は全員の Yes を見た瞬間に commit を配ってよい。
② 調整役: 全員 Yes のときだけ commit
調整役は 2 相を進める。phase 1 で全員に prepare を聞き、1 人でも No なら決定は abort。phase 2 で決定を全員に配る。abort の場合、Yes と答えてロックしていた参加者も全員巻き戻す:
// Decision は調整役の最終決定。
type Decision int
const (
DecisionNone Decision = iota // まだ決めていない(または決定を配る前に落ちた)
DecisionCommit
DecisionAbort
)
func (d Decision) String() string {
switch d {
case DecisionCommit:
return "commit"
case DecisionAbort:
return "abort"
default:
return "none"
}
}
// Coordinator は 2PC の調整役。
type Coordinator struct {
participants []*Participant
decision Decision
}
// NewCoordinator は参加者を束ねる調整役を作る。
func NewCoordinator(ps ...*Participant) *Coordinator {
return &Coordinator{participants: ps}
}
// Decision は現在の決定。
func (c *Coordinator) Decision() Decision { return c.decision }
// Run は 2PC を最後まで実行する。
//
// phase 1(prepare): 全参加者に「amount を引けるか」を聞く。1 人でも No なら決定は abort。
// phase 2(decide) : 決定を全員に配る。commit なら全員確定、abort なら Yes と答えて
// ロックしていた参加者も全員巻き戻す。
//
// 全員 Yes のときだけ commit になるので、「一部だけ引き落とされた」状態は決して確定しない。
func (c *Coordinator) Run(amount int64) (Decision, error) {
votes := make([]Vote, len(c.participants))
c.decision = DecisionCommit
for i, p := range c.participants {
votes[i] = p.Prepare(amount)
if votes[i] == VoteNo {
c.decision = DecisionAbort
}
}
for i, p := range c.participants {
if c.decision == DecisionCommit {
p.Commit()
} else if votes[i] == VoteYes {
p.Abort() // Yes と答えてロックしていた参加者を解放する
}
}
if c.decision == DecisionAbort {
return c.decision, ErrAborted
}
return c.decision, nil
}
// RunPrepareOnly は phase 1 だけ実行して止まる——「決定を配る前に調整役が落ちた」を模す。
// Yes と答えた参加者は prepared のままロックを抱え、自分では commit も abort も決められない。
// これが 2PC のブロッキング問題で、調整役が復旧するまで資源は塞がったままになる。
func (c *Coordinator) RunPrepareOnly(amount int64) []Vote {
votes := make([]Vote, len(c.participants))
for i, p := range c.participants {
votes[i] = p.Prepare(amount)
}
c.decision = DecisionNone
return votes
}
// Blocked は決定が届かないまま prepared でロックを抱えている参加者の一覧。
func (c *Coordinator) Blocked() []string {
var out []string
for _, p := range c.participants {
if p.State() == PPrepared {
out = append(out, fmt.Sprintf("%s(locked=%d)", p.Name, p.Locked()))
}
}
return out
}Run が守るのは「一部だけ引き落とされた状態は決して確定しない」という一点だ。決済が残高不足で No と答えれば、在庫のロックも戻る。全員が同じ決定に従う。
問題は RunPrepareOnly が模す故障だ。全員が Yes と答えた直後、決定を配る前に調整役が落ちたとする。参加者は prepared のままロックを抱え、自分では commit も abort も決められない。勝手に abort して実は決定が commit だったら、原子性が壊れるからだ。調整役が復旧するまで、ロックされた在庫と与信枠は塞がったままになる。これが 2PC のブロッキング問題で、調整役が単一障害点になる。
③ Saga: ロックを捨てて補償で辻褄を合わせる
Saga は逆の割り切りをする。各ステップは Do(本処理)と Compensate(補償)の対で定義し、ローカルに即コミットする。ロックは持たない。途中で失敗したら、完了済みステップの補償を逆順に実行する:
// Step は Saga の 1 ステップ。本処理(Do)と、それを打ち消す補償(Compensate)の対で定義する。
// 補償は「逆操作」であって「ロールバック」ではない——Do は既にコミット済みなので、
// 取り消しではなく打ち消しの新しい操作を実行する(予約に対するキャンセルのように)。
type Step struct {
Name string
Do func() error
Compensate func()
}
// LogEntry は Saga の実行記録。何が実行され、何が補償されたかを順に残す。
type LogEntry struct {
Step string
Action string // "do" / "do-failed" / "compensate"
Err string
}
// Saga はステップ列を順に実行し、失敗時は完了済みを逆順に補償する実行機。
type Saga struct {
steps []Step
log []LogEntry
}
// NewSaga はステップ列から Saga を作る。
func NewSaga(steps ...Step) *Saga { return &Saga{steps: steps} }
// Log は実行記録(表示・検査用)。
func (s *Saga) Log() []LogEntry { return s.log }
// Run はステップを先頭から実行する。あるステップが失敗したら、そこで前進をやめ、
// 完了済みステップの補償を逆順に実行してから、元の失敗を返す。
//
// 逆順なのは依存関係のためだ。後のステップは前のステップの結果の上に成り立っている
// ことが多い(ホテルは航空券が取れている前提)。積み上げた順の逆に崩すのが安全になる。
func (s *Saga) Run() error {
var completed []Step
for _, st := range s.steps {
if err := st.Do(); err != nil {
s.log = append(s.log, LogEntry{Step: st.Name, Action: "do-failed", Err: err.Error()})
// 完了済みを逆順に補償する。
for i := len(completed) - 1; i >= 0; i-- {
completed[i].Compensate()
s.log = append(s.log, LogEntry{Step: completed[i].Name, Action: "compensate"})
}
return err
}
s.log = append(s.log, LogEntry{Step: st.Name, Action: "do"})
completed = append(completed, st)
}
return nil
}旅行予約で言えば、航空券を取り、ホテルを取り、レンタカーで失敗したら、ホテルをキャンセルし航空券をキャンセルする。ここで大事な区別が 2 つある。補償はロールバックではない。Do は既にコミット済みなので、取り消しではなく「キャンセル」という新しい操作を実行する。そして補償が逆順なのは、後のステップが前のステップの結果に依存しているからだ(ホテルは航空券が取れている前提)。積み上げた順の逆に崩す。
代償は途中状態の可視性だ。「航空券だけ取れている」瞬間が外から見える。2PC ならロックの内側に隠れていた中間状態が、Saga では露出する。原子性を諦め、最終的に辻褄が合う(結果整合)ことだけを保証する。
動かす
下のデモは、この対比をそのままブラウザで動かしている。2PC では正常系(全員 Yes → commit、1 人 No → 全員 abort)と、調整役が決定前に落ちて両参加者がロックを抱えて止まる故障系を切り替えられる。Saga では旅行予約が 3 歩目で失敗し、補償が逆順に走って辻褄が合うまでを追える。どちらが「止まる」リスクを取り、どちらが「見える」リスクを取っているかが分かるはずだ。
残高 100
残高 100
在庫と決済、別々のサービスの更新を 1 つのトランザクションに揃えたい。単一の commit は存在しない
2PC は prepare で全員の合意を取ってから commit を配る。原子性は強いが、Yes と答えた参加者は 調整役の決定が届くまでロックを抱え、調整役が落ちると動けない。Saga はロックを持たず各ステップを 即コミットし、失敗したら補償を逆順に実行する。止まらない代わりに、途中状態が外から見える。
設計の観点: どちらを選ぶか
- 2PC の前提: 参加者が prepare の約束(Yes なら必ず遂行できる状態の維持)を守れること。DB の XA トランザクションはこの契約の実装。強い原子性が要り、参加者が少なく、レイテンシとブロッキングを許容できる場面に向く
- ブロッキングの正体: prepared の参加者は一方的に decide できない。緩和策は調整役の決定ログ永続化と復旧、参加者同士の問い合わせ、タイムアウト後の推定 abort(ヒューリスティック、原子性を破りうる)。根本的には調整役自体を Raft で複製して単一障害点を消す(Spanner の構成)
- Saga の前提: すべてのステップに意味のある補償が書けること。送金は打ち消せるが、メール送信や発送は打ち消せない(取り消し不能な副作用は最後に置く)。補償自体も失敗しうるので、リトライと冪等性(message-queue 編の実質 1 回)が前提になる
- 途中状態と隔離: Saga には分離性が無い。途中状態を読んだ他のトランザクションが誤った判断をしうる(dirty read 相当)。対策は semantic lock(「予約中」フラグ)や、途中状態を見せない設計
- 実務の使い分け: 同一組織内の少数 DB なら 2PC(XA)も現実的。マイクロサービス間は Saga が主流で、オーケストレーション(中央の実行機がステップを指示)とコレオグラフィ(イベント連鎖で進む)の 2 形がある。この章の実装はオーケストレーション型
メリット・デメリットと実例
| 方式 | 原子性 | 可用性 | 途中状態 | 実例 |
|---|---|---|---|---|
| 2PC(XA) | 強い(全員一致) | 低(調整役 SPOF・ブロッキング) | 隠れる(ロック内) | XA トランザクション、MSDTC |
| 2PC + 合意で調整役複製 | 強い | 中〜高 | 隠れる | Google Spanner(2PC over Paxos) |
| Saga(オーケストレーション) | 無し(結果整合) | 高(ロック無し) | 見える | Temporal / AWS Step Functions 上のワークフロー |
| Saga(コレオグラフィ) | 無し | 高 | 見える | イベント駆動のマイクロサービス連携 |
裏どり:
- XA / JTA: 2PC の標準インターフェース。アプリサーバと複数 DB をまたぐトランザクションで長く使われてきたが、ブロッキングと運用の重さからサービス間では避けられる傾向
- Google Spanner: シャードをまたぐ書き込みに 2PC を使い、調整役と参加者を Paxos グループで複製してブロッキングの単一障害点を消した。「2PC は遅くて脆い」への構造的な答え
- Saga の由来: Garcia-Molina & Salem の論文(1987)。長時間トランザクションをローカルトランザクションの列 + 補償に分解する発想が、そのままマイクロサービスに再発見された
- Temporal / Step Functions: Saga のオーケストレータを汎用化したワークフローエンジン。ステップの実行・リトライ・補償の起動を永続ログで管理する
簡略化したこと
- 調整役のログ・復旧なし: 実物は決定を書き込んでから配り、再起動後にログから再送する。ここでは「決定前に落ちるとブロックする」ことを見せるに留める
- ネットワークの喪失・再送なし: メッセージは必ず届く前提。タイムアウト・重複・順序入れ替えは扱わない(rpc 編・distributed-intro 編の領域)
- Saga の永続化なし: 実行機が落ちたときに補償を再開するための Saga ログは持たない(実務では必須)
- 補償の失敗なし: Compensate は必ず成功する前提。実務はリトライ + 冪等性で守る
- 隔離の対策なし: semantic lock 等は設計の観点の節で言及するに留める
参考資料
- Garcia-Molina & Salem, "Sagas"(1987) — 補償ベースの分解の原典
- Martin Kleppmann, Designing Data-Intensive Applications 9 章 — 2PC・ブロッキング・XA の整理
- Spanner: Google's Globally-Distributed Database(2012) — 2PC over Paxos の実例
- 実装: distributed/txn