マイクロサービスアーキテクチャは、スケーラビリティと開発チームの独立性を約束してくれる。しかし、その華やかな世界の裏には、分散トランザクションという深い闇が潜んでいる。本記事では、我々が実際に経験した障害事例を交えながら、分散トランザクションの課題とSaga Patternによる解決策を解説する。
モノリスを分割した日
2年前、我々はECプラットフォームのモノリスをマイクロサービスに分割した。注文サービス、在庫サービス、決済サービス、通知サービス——教科書通りのドメイン分割だった。最初の数ヶ月は順調だった。各チームが独立してデプロイでき、開発速度は確実に向上した。
問題が顕在化したのは、ブラックフライデーのセール時だった。
障害発生 — 在庫なし決済完了の悪夢
セール開始から30分後、カスタマーサポートに問い合わせが殺到した。「決済は完了したのに、注文確認メールが届かない」「マイページに注文が表示されない」。調査の結果、以下の事象が発生していた。
- 決済サービスがクレジットカード課金を完了
- 在庫サービスへの在庫引き当てリクエストがタイムアウト
- 注文サービスがエラーをキャッチし、注文を「失敗」としてロールバック
- しかし、決済サービスには返金指示が届かず、課金だけが残った
つまり、「お金は引かれたのに商品が届かない」状態が約200件発生したのだ。これは最悪のユーザー体験であり、信頼の根幹に関わる問題だった。
なぜこうなったのか — 2フェーズコミットの幻想
モノリス時代は、データベーストランザクションが全てを守ってくれた。BEGIN→処理→COMMIT。途中で失敗すればROLLBACK。シンプルで完璧だった。
マイクロサービスでは、各サービスが独立したデータベースを持つ。サービス間をまたぐトランザクションは、単一のBEGIN/COMMITでは管理できない。
2フェーズコミット(2PC)はこの問題の古典的な解決策だが、実用上の課題が多い。
- パフォーマンス: 全参加者がロックを取得して待機するため、スループットが激減する
- 可用性: コーディネーターが単一障害点になる
- ネットワーク障害: 準備フェーズ完了後にネットワークが切断されると、参加者がロックを保持したまま待機し続ける
我々は2PCを採用していなかった。代わりに「各サービスを順番に呼び出し、失敗したら手動でリカバリ」という楽観的なアプローチを取っていた。これが悲劇の原因だった。
Saga Pattern — 救世主の登場
Saga Patternは、長時間トランザクションを一連のローカルトランザクションに分割するパターンだ。各ステップが成功したら次のステップへ進み、いずれかのステップが失敗したら、それまでに完了したステップの補償トランザクション(Compensating Transaction)を逆順に実行する。
オーケストレーション型Saga
我々が採用したのは、オーケストレーション型Sagaだ。中央のSagaオーケストレーターが全体のフローを管理する。
// saga-orchestrator.ts — 注文Sagaの例
interface SagaStep {
execute: () => Promise<void>
compensate: () => Promise<void>
}
class OrderSaga {
private completedSteps: SagaStep[] = []
async execute(orderId: string): Promise<void> {
const steps: SagaStep[] = [
{
execute: () => this.reserveInventory(orderId),
compensate: () => this.releaseInventory(orderId),
},
{
execute: () => this.processPayment(orderId),
compensate: () => this.refundPayment(orderId),
},
{
execute: () => this.confirmOrder(orderId),
compensate: () => this.cancelOrder(orderId),
},
{
execute: () => this.sendNotification(orderId),
compensate: () => Promise.resolve(), // 通知は補償不要
},
]
for (const step of steps) {
try {
await step.execute()
this.completedSteps.push(step)
} catch (error) {
console.error(`Saga step failed: ${error}`)
await this.rollback()
throw error
}
}
}
private async rollback(): Promise<void> {
for (const step of this.completedSteps.reverse()) {
try {
await step.compensate()
} catch (error) {
console.error(`Compensation failed: ${error}`)
// 補償失敗はDead Letter Queueに送り、手動対応
await this.sendToDeadLetterQueue(error)
}
}
}
}
重要な設計原則
Saga Patternを実装する際の重要な原則をまとめる。
- べき等性(Idempotency): 各ステップと補償トランザクションは、何度実行しても同じ結果になるように設計する。ネットワーク障害でリトライが発生した場合に、二重処理を防ぐ
- 順序の設計: 取り消しやすいステップを先に、取り消しにくいステップを後に配置する。在庫引き当て→決済の順にすることで、在庫がない場合に無駄な決済を防げる
- タイムアウトとリトライ: 各ステップに適切なタイムアウトとリトライ戦略を設定する。指数バックオフとジッターの組み合わせが定番
- Dead Letter Queue: 補償トランザクションが失敗した場合のエスカレーションパスを必ず用意する
Outbox Pattern — イベントの確実な配信
Saga Patternと併せて導入したのがOutbox Patternだ。サービス間の通信にメッセージキュー(我々はAmazon SQSを使用)を使う場合、「DBへの書き込み」と「メッセージの送信」を原子的に行う必要がある。
// Outbox Pattern — DBトランザクション内でイベントを記録
async function reserveInventory(orderId: string) {
await prisma.$transaction(async (tx) => {
// 在庫を引き当て
await tx.inventory.update({
where: { productId },
data: { reserved: { increment: quantity } },
})
// 同じトランザクション内でOutboxテーブルに記録
await tx.outboxEvent.create({
data: {
aggregateId: orderId,
eventType: 'INVENTORY_RESERVED',
payload: JSON.stringify({ orderId, productId, quantity }),
published: false,
},
})
})
// 別プロセスがOutboxテーブルをポーリングしてメッセージキューに送信
}
Outboxテーブルに記録されたイベントは、別のポーリングプロセスが定期的に読み取り、メッセージキューに発行する。DBトランザクションが成功すれば必ずイベントが記録されるため、「処理は成功したがイベントが消失する」問題を防げる。
教訓 — モノリスを甘く見るな
マイクロサービスへの移行で得た最大の教訓は、「モノリスは悪ではない」ということだ。小〜中規模のチームであれば、モジュラーモノリスの方が生産性が高い場合が多い。マイクロサービスは、組織のスケーリングが必要な場合、つまり複数チームが独立してデプロイしたい場合に初めて価値を発揮する。
「マイクロサービスは技術的な問題を解決するのではなく、組織的な問題を解決するためのものだ。技術的にはむしろ問題が増える。」— Martin Fowler
分散トランザクションの複雑さは、マイクロサービスの「隠れたコスト」だ。このコストを支払う覚悟があるか、そしてSaga PatternやOutbox Patternなどの対策を適切に実装できるか。その判断がプロジェクトの成否を分ける。
我々のシステムはSaga Pattern導入後、同様の障害は発生していない。しかし、運用の複雑さは確実に増した。監視ダッシュボードにはSagaの進行状況を表示するパネルが追加され、Dead Letter Queueのアラートが日常の一部になった。これが分散システムの現実だ。












>_ コメント