振込の途中でサービスが落ちても、お金が消えないように — Saga と Outbox で分散した口座間の整合を守る
はじめに
自作の銀行系システムで、口座間の振込を実装している。システムの全体像は 自作の銀行系システムは実際に何が動いているか に書いた。
振込は、突き詰めると二手だ。送金元から引く、送金先へ足す。この二手を、途中でどれだけ落ちても「お金が消えたり増えたりしない」ように作った——という話をする。素朴に書くと必ず穴が空く場所で、そこを Saga と Outbox で塞いだ。
サービスを分けた瞬間、振込は危うくなる
最初のバージョンでは、振込は口座モジュールの中で完結していた。「引く」と「足す」は同じデータベースの一つのトランザクションで行う。だから、途中で落ちても両方まとめて無かったことになる。安全だ。
ところが、口座を独立したサービスに切り出した瞬間、話が変わる。「引く」と「足す」が別のサービス・別のトランザクションに分かれ、その間で落ちると——送金元からは引かれたのに、送金先には着かない、が起こりうる。お金が宙に消える。
振込 = 送金元から引く + 送金先へ足す
【分ける前:同一DBの1トランザクション】
引く+足す ─▶ どちらも成功 or どちらも無かったことに(安全)
【分けた後:サービス分割】
決済サービス ─▶ 口座(送金元)で引く ─▶ 口座(送金先)で足す
│ │
ここで落ちると… 送金元から消えて、送金先に着かない ×
2相コミットではなく、Saga と補償
分散したトランザクションを束ねる古典的な方法に 2相コミット(2PC) がある。これは複数のデータベースを「せーの」で一斉に確定する方式だ。まず全員に「準備できたか?」と聞き、全員が「OK」と答えてから、一斉に「確定!」と命じる。確実だが、その間全員をロックして待たせるので遅く重く、一つでも落ちたり返事が来ないと全体が固まる。マイクロサービスとは相性が悪い。
代わりに Saga(サーガ) を採る。「せーの」をやめて、各ステップをそれぞれのデータベースで一手ずつ確定して前に進める。もし途中で失敗したら、それまでに済ませた手を逆操作で一手ずつ戻す。この逆操作を 補償(compensation) と呼ぶ。「送金元から引く」の補償は「送金元へ戻す(足し直す)」——取り消しではなく、逆の操作で帳尻を合わせる。だから「引く」が済んだあとに「足す」が失敗したら、補償で「引く」を打ち消す。
① 送金元から引く(ローカルTX)── 成功
② 送金先へ足す
├─ 成功 ─────────▶ 振込完了
└─ 失敗 ─▶ ③ 補償:送金元へ戻す(①を打ち消す)
タイムアウトは「失敗」ではない
ここに、素朴に書くと必ず落ちる穴がある。補償をいつ起動するかだ。
「足す」が失敗したら補償で戻す——正しく聞こえる。だが「失敗」には二種類ある。
- 明確な失敗(4xx / 5xx が返ってきた):相手は確かに処理しなかった。戻してよい。
- タイムアウト(応答が返ってこない):これは失敗ではない。結果不明だ。相手は実は成功しているかもしれない。
タイムアウトを「失敗」と決めつけて補償(送金元へ戻す)を打つと、相手が実は成功していた場合、送金先には足されたのに送金元にも戻る——お金が二重になる。逆の壊れ方だ。
try {
accounts.deposit(toAccount, amount, idemKey); // 送金先へ足す
} catch (AccountsClientException e) { // 4xx/5xx = 明確な失敗
compensate(fromAccount, amount); // 送金元へ戻す(補償してよい)
} catch (AccountsUnavailableException e) { // タイムアウト = 結果不明
// 単純に戻してはいけない。結果を照会し、確定するまで保留する。
markPendingForReconciliation(transferId);
}
「明確な失敗」と「結果不明」を分けて、補償の起動可否を決める。ここを一緒くたにすると、消える方向にも二重になる方向にも壊れる。
状態変更とイベントを、Outbox で確実に結びつける
もう一つ、分散でよく落ちる穴がある。「残高を更新する」と「“振込完了”を他サービスに伝える」を、どう取りこぼさず両立するか。
素朴にやると、残高を更新してから、別途イベントを発行する。だがその二つの間で落ちると、残高は動いたのにイベントだけ消える(通知が飛ばない、後続処理が動かない)。
そこで Outbox(アウトボックス) を使う。これは私が勝手に作ったローカルなテーブルではなく、分散システムで広く使われる定番の型で、正式には Transactional Outbox(トランザクショナル・アウトボックス) と呼ばれる。
やり方はこうだ。イベントを直接外へ飛ばさず、残高更新と”同じ一つのトランザクション”で、「振込完了」という送りたいイベントを、専用のテーブル(outbox)に一行書き込む。こうすると、DBの更新とイベントの記録が、同時に成功・同時に失敗する(atomic に結びつく)。あとは別プロセス(Relay)が、その outbox テーブルの「まだ送っていない行」を読んで確実に配信し、送れたら送信済みの印を付ける。途中で落ちても、行が outbox に残っているので取りこぼさない。
【危険】残高更新 →(別で)イベント発行 … 間で落ちるとイベントだけ消える
【Outbox】同一トランザクションで:残高更新 + outbox に「振込完了」を書く
→ 別プロセス(Relay)が outbox を読んで確実に配信(取りこぼさない)
こうして、振込は——サービスが分かれていても、途中でどこが落ちても——お金が消えず、増えず、通知も取りこぼさない。分散していても、整合は設計で守れる。2相コミットで固く縛る代わりに、Saga で進めて補償で戻し、Outbox で確実に伝える。 タイムアウトを失敗と混同しない。この三つが、分散した振込の背骨になった。
関連する記事
- この振込を、外側(自作プラットフォームの決済)から実際に呼んで金を動かした話は 自作アプリと自作の銀行を、実際に金が動くまで繋いだ — 決済に口座振込を足し、E2Eで確かめる にある。
- システムの構成・画面・背骨のコードの一巡は 自作の銀行系システムは実際に何が動いているか にまとめた。
- Outbox で確実に送ったイベントを、受信側で二度処理しないための対の仕組み(Inbox・冪等キーの粒度)は 振込完了の通知を二度送らないための二重防御 — Idempotent Consumer と、正当な2通目まで殺しかけた冪等キーの粒度 に書いた。