💥

【検証】分散トランザクション(Saga)は「データ不整合」を防げるか?あえて障害を起こして自動修復させてみた

に公開

はじめに:モノリスの「安心感」は失われた

こんにちは。現在、26 卒として IT 企業(テクノロジーアーキテクト職)への入社を控えている学生です。
現在、『ソフトウェアアーキテクチャの基礎』などの書籍を通じてアーキテクチャ設計を学んでいます。その中で、マイクロサービスにおける最大の難関の一つとして 「分散トランザクション」 が挙げられていました。

モノリス(単体アプリケーション)の時代、データの整合性を守るのは簡単でした。RDBMS の強力なトランザクション機能(ACID 特性)があり、エラーが起きれば ROLLBACK 一発ですべてを無かったことにできました。

しかし、DB が分割されるマイクロサービスでは、「サービス A(注文)のコミット」と「サービス B(決済)のコミット」を同時に保証する魔法(グローバルトランザクション)は存在しません。

「じゃあ、決済が失敗したら、確定してしまった注文データはどうなるの?」

この恐怖と向き合うため、Python(FastAPI)と Docker を使って 「Saga パターン(オーケストレーション)」 による補償トランザクションを実装し、実際にシステムを一部クラッシュさせて挙動を検証してみました。

参考にさせていただいた記事:
https://zenn.dev/farstep/articles/saga-pattern

今回構築したアーキテクチャ

https://github.com/shayate811/microservices-saga-poc

「Database Per Service」 パターンを厳守し、注文サービスと決済サービスが完全に分離した構成を作成しました。

技術スタック

App: Python 3.11 (FastAPI)

DB: PostgreSQL 15 (x2 instances)

Infra: Docker Compose

ORM: SQLAlchemy 2.0 (Async/Sync hybrid)

実験シナリオ:9999 円の爆弾

Saga パターンが正しく動くか確認するため、以下のシナリオを用意しました。

注文サービスで注文を受け付ける(DB に PENDING で保存)。

決済サービスを呼び出す。

決済サービスに「9999 円」のリクエストが来た場合、意図的にサーバーエラー(500)を発生させる。

注文サービスがエラーを検知し、「補償トランザクション(Compensating Transaction)」 を実行して注文を取り消す(CANCELLED に更新)。

いわゆるカオスエンジニアリング的なアプローチです。

実装のポイント(コード抜粋)
オーケストレーターとなる Order Service の実装です。 「DB への書き込み」と「API 呼び出し」が分かれているため、アプリ側で整合性を担保するロジックを書いています。

@app.post("/orders/")
async def create_order(order: schemas.OrderCreate, db: Session = Depends(get_db)):
    # 1. ローカルTX: まず自分のDBに「PENDING」で書き込む
    # ここでコミットされるため、後でエラーが起きてもこのレコードは消えない!
    new_order = services.create_order(db=db, order=order)

    # 2. 外部サービス呼び出し
    # 決済サービスへHTTPリクエスト(ここでネットワークエラー等が起きうる)
    payment_success = await services.request_payment(order_id=new_order.id, amount=order.price)

    # 3. Sagaロジック: 結果に応じた事後処理
    if payment_success:
        # 成功時: ステータスを完了にする
        services.update_order_status(db, new_order.id, "COMPLETED")
    else:
        # 【重要】失敗時: 補償トランザクション (Compensating Transaction)
        # これを書かないと「商品は確保したのに金が払われていない」状態になる
        services.update_order_status(db, new_order.id, "CANCELLED")

        raise HTTPException(status_code=400, detail="Payment failed, Order cancelled")

    return new_order

検証結果:ログで見る「整合性が守られた瞬間」

実際に curl コマンドで 9999 円の注文(爆弾)を投げてみました。

$ curl -X POST "http://localhost:8000/orders/" \
     -H "Content-Type: application/json" \
     -d '{"item_name": "Ghost", "quantity": 1, "price": 9999}'

# レスポンス
{"detail":"Payment failed, Order cancelled"}

API としては 400 エラーが返ってきましたが、重要なのは 「裏で何が起きたか」 です。 Docker コンテナのログを確認すると、Saga パターンの挙動が完璧に記録されていました。

実際のログ(抜粋)

# 1. 注文発生: ステータス PENDING で INSERT (コミット完了)

INFO sqlalchemy.engine.Engine INSERT INTO orders ... VALUES (..., 'PENDING') RETURNING orders.id
INFO sqlalchemy.engine.Engine COMMIT

# 2. 決済サービス呼び出し -> 500 エラー発生

order-service | Payment failed: Server error '500 Internal Server Error' for url 'http://payment-service:8001/payments/'

# 3. 補償トランザクション発動: ステータスを CANCELLED に UPDATE

INFO sqlalchemy.engine.Engine UPDATE orders SET status='CANCELLED' WHERE orders.id = 2
INFO sqlalchemy.engine.Engine COMMIT

もし Saga を実装していなかったら?
コード内の else ブロック(補償処理)をコメントアウトして同じ実験をしたところ、DB には ステータス PENDING(注文有効)のレコードが残ってしまいました。

これは実業務で言えば 「在庫引当はされているのに、お金は払われていない」 という最悪のデータ不整合(Inconsistency)です。マイクロサービスにおいて、いかにアプリケーション層でのハンドリングが重要かを痛感しました。

まとめ:アーキテクトとしての学び

今回の PoC を通じて、以下の学びがありました。

ACID から BASE への頭の切り替え マイクロサービスでは「常に整合性が取れている(Strong Consistency)」状態を維持するのは困難です。「最終的に整合性が取れる(Eventual Consistency)」状態を設計の前提にする必要があります。

失敗を前提にした設計(Design for Failure) 「ネットワークは切れる」「相手のサーバーは落ちる」という前提で、正常系だけでなく異常系の回復ロジック(Saga)を組むコストがかかります。

アーキテクチャはトレードオフ マイクロサービス化すればスケーラビリティや開発速度は上がりますが、今回のように「データ整合性を守るための複雑さ」というコストを支払うことになります。

本やドキュメントを読むだけでなく、実際に「不整合」を起こし、それをコードで解決する経験は非常に有意義でした。次は非同期メッセージング(Kafka/RabbitMQ)を使った、より疎結合な Saga パターン(コレオグラフィ)にも挑戦してみたいと思います。

GitHubで編集を提案

Discussion