⚠️

分散サービスのトランザクションでアンチパターンを設計してしまった話 〜とその改善〜

に公開

この記事は、Finatext Advent Calendar 2025 の 16 日目の記事です。

はじめに

分散サービスでアプリケーションを構築する際、「分散トランザクションの壁」にぶつかることは避けられません。単一データベースで保証されていた ACID 特性(特に原子性)が、複数サービスにまたがると簡単には実現できないからです。

業務で複数の分散サービスとデータベースを更新する処理を実装する際、「DB の ACID 特性を最大限活用すれば、シンプルに実装できるのでは?」 という考えから設計を始めました。しかし、その結果として典型的なアンチパターンに陥り、深刻なバグを埋め込む設計になってしまいました。

本記事では、その失敗の経緯と、なぜそのアプローチが問題だったのか、そして最終的にどう改善すべきだったかを共有します。

用語の定義

本記事で使用する主要な用語を整理します。

  • ACID 特性: 原子性(Atomicity)、一貫性(Consistency)、独立性(Isolation)、永続性(Durability)の 4 つの性質
  • BASE 特性: 基本的可用性(Basically Available)、柔軟な状態(Soft state)、結果整合性(Eventual consistency)
  • ロングトランザクション: 長時間保持されるトランザクション。リソースを占有し、パフォーマンス低下の原因となる
  • 行ロック: データベースの特定の行に対するロック。他のトランザクションからの同時アクセスを制限する
  • 補償トランザクション(Compensating Transaction): 実行済みの処理を論理的に取り消すための逆操作
  • 結果整合性(Eventual Consistency): 即座の一貫性は保証しないが、最終的には整合性が取れる状態

システム構成

今回実装したシステムの構成と役割を整理します。

登場人物と役割

  • MySQL (Membership DB): サーバー内で完結する情報や他サービスとの連携情報を管理。ACID トランザクションが使用可能
  • ServiceA (Profile Service): ユーザーの個人情報等を管理。REST API で通信
  • ServiceB (Identity Service): 認証基盤。ログイン ID やパスワード、MFA 設定などを管理

サービス構成

実装したアプローチ: ACID 特性への執着

設計思想

当初、以下のような考えでアーキテクチャを設計しました:

  1. DB トランザクションを処理全体の境界とする: tx.Begin() から tx.Commit() までの間にすべての処理を含める
  2. ACID 特性で原子性を保証: トランザクション内ですべての更新を行えば、失敗時は DB がロールバックされて原子性が保たれる
  3. 補償トランザクションで外部サービスをカバー: 外部サービスの更新に失敗したら、それまでの変更を補償トランザクションで巻き戻す

この考え方自体は理論的には理解できるものでしたが、実装面で致命的な問題を抱えていました

実装の流れ

処理順序は以下のようになっていました:

[DBトランザクション開始] ← tx.Begin()
  ↓
[DB更新] (MySQL) ← ここで行ロック発生
  ↓
[ServiceA更新] ← 外部API呼び出し(ネットワーク遅延の可能性)
  ↓
[ServiceB更新] ← 外部API呼び出し(ネットワーク遅延の可能性)
  ↓
[DBコミット] ← tx.Commit() (ここでやっとロック解放)

実装例(簡略版)

func (r *repository) UpdateUser(ctx context.Context, user User) error {
    var compensations []func(context.Context) error

    // トランザクション開始
    tx, err := r.db.Begin(ctx)
    if err != nil {
        return fmt.Errorf("failed to begin transaction: %w", err)
    }

    defer func() {
        if err != nil {
            // DBロールバック + 補償トランザクション実行
            tx.Rollback()
            // 補償トランザクションを逆順で実行
            for i := len(compensations) - 1; i >= 0; i-- {
                compensations[i](ctx)
            }
        }
    }()

    // 1. データベースの更新(行ロック発生)
    if err = tx.UpdateUser(ctx, user); err != nil {
        return fmt.Errorf("failed to update user in DB: %w", err)
    }

    // 2. Service Aの更新(トランザクション継続中)
    orgUserA, err := r.serviceA.GetUser(ctx, user.ID)
    if err != nil {
        return fmt.Errorf("failed to get original user from serviceA: %w", err)
    }

    if err = r.serviceA.UpdateUser(ctx, user); err != nil {
        return fmt.Errorf("serviceA update failed: %w", err)
    }

    compensations = append(compensations, func(ctx context.Context) error {
        return r.serviceA.UpdateUser(ctx, orgUserA)
    })

    // 3. Service Bの更新(トランザクションまだ継続中)
    orgUserB, err := r.serviceB.GetUser(ctx, user.ID)
    if err != nil {
        return fmt.Errorf("failed to get original user from serviceB: %w", err)
    }

    if err = r.serviceB.UpdateUser(ctx, user); err != nil {
        return fmt.Errorf("serviceB update failed: %w", err)
    }

    compensations = append(compensations, func(ctx context.Context) error {
        return r.serviceB.UpdateUser(ctx, orgUserB)
    })

    // 4. DBコミット(ようやくロック解放)
    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit failed: %w", err)
    }

    return nil
}

一見すると、きれいに整理された実装ができたように思いました。しかし、この設計には深刻な問題が潜んでいました

深刻な問題点の発覚

DB のトランザクションのなかで外部サービスを使用していることによって、外部サービスの待機時間によるリソース枯渇、ロックの取り合いなどの問題点を作ってしまいました。
具体的な例を挙げてみます。

具体例 1: ロングトランザクションによるリソース枯渇

何が起きるのか

  • DB トランザクション開始から外部サービスの呼び出しが完了するまで、DB コネクションを占有し続ける
  • Service A、Service B の各 API コールには、通常 100ms〜数秒、タイムアウトまで最大 30 秒かかる可能性がある
  • その間、1 つの DB コネクションがずっと専有される

想定される影響

例えば、以下のような状況を考えてみます:

DB コネクションプール: 最大 100 接続
同時リクエスト数: 200 リクエスト/秒
各外部 API 平均レスポンス時間: 500ms(Service A + Service B)

計算:

  • 1 リクエストあたりの DB 占有時間: 約 500ms
  • 1 秒あたりの必要コネクション数: 200 × 0.5 = 100 接続
    → プールが完全に枯渇し、新規リクエストが処理不能に

もし Service A が一時的に遅延して 3 秒かかった場合:

  • 1 秒あたりの必要コネクション数: 200 × 3 = 600 接続
    → コネクションプールを大幅に超過
    → システム全体がダウン

具体例 2: 行ロックによるデッドロックと待機時間

何が起きるのか

  • tx.UpdateUser() で DB 内のユーザー行にロックがかかる
  • 外部サービス呼び出し中、この行ロックが解放されない
  • 同じユーザーへの他のリクエストは、このロックが解放されるまで待機する必要がある

想定ケース


時刻 0ms : Request A が User ID=123 の更新を開始(行ロック取得)
時刻 100ms: Request B が同じ User ID=123 の更新を試みる(待機開始)
時刻 500ms: Request A が Service A を呼び出し中(まだロック保持)
時刻 800ms: Request A が Service B を呼び出し中(まだロック保持)
時刻 1000ms: Request A がコミット完了(ようやくロック解放)
時刻 1001ms: Request B が処理開始可能

→ Request B は 900ms 待機させられた

同じユーザーへの連続アクセスがあると、待機時間が雪だるま式に増加します。

具体例 3: 外部サービスの障害が DB 層に波及

何が起きるのか

Service A や B が障害で応答しない場合:

  • タイムアウト(30 秒)まで待機
  • その間、DB トランザクションとコネクションを保持し続ける
  • 30 秒 × 複数リクエスト = 即座にコネクションプール枯渇

つまり、外部サービスの問題が DB 層全体のダウンに直結します。

これはアンチパターンだった

この実装は、分散システムにおける典型的なアンチパターンとなってしまっていました。

アンチパターン: トランザクション内での外部サービス呼び出し

なぜアンチパターンなのか

  1. トランザクションは短命であるべき: データベーストランザクションは、できるだけ短時間で完了するべきという原則に違反
  2. リソースの不適切な占有: 予測不可能な外部 API 呼び出しの間、貴重な DB リソースを占有
  3. 障害の伝播: 外部サービスの問題が、データベース層全体の可用性に影響する
  4. スケーラビリティの欠如: 負荷が増えるとすぐにリソース枯渇する

なぜこのアンチパターンに陥ったのか

振り返ると、以下の思い込みがありました:

  • ACID 特性への過度な執着: 「トランザクションで囲めば原子性が保証される」という単純な考え
  • 分散システムの特性への理解不足: ネットワークの不確実性を軽視していた
  • 実装のシンプルさの優先: 一つのトランザクションで完結させる方が実装が楽に見えた

正しいアプローチ: 最終的整合性(BASE)の採用

BASE 特性とは

ACID 特性に対して、分散システムではBASE 特性を採用することが推奨されます:

  • Basically Available: 基本的に利用可能
  • Soft state: 状態は時間とともに変化しうる
  • Eventual consistency: 最終的には整合性が取れる

重要なのは、「即座の完全な整合性」を諦める代わりに、「最終的な整合性」と「高い可用性」を得るというトレードオフです。

改善案 1: 非同期処理への分離

最もシンプルで効果的な改善策は、DB の更新と外部サービスの更新を分離することです。

アーキテクチャの概要


[リクエスト処理]
↓
[DB 更新] (短いトランザクション、即座に完了)
↓
[メッセージキューに投入] (RabbitMQ、AWS SQS、Redis Stream など)
↓
[リクエスト完了を返す] ← ここまでが同期処理

[別プロセス: ワーカープロセス]
↓
[キューからメッセージを取得]
↓
[Service A 更新]
↓
[Service B 更新]
↓
[完了 or リトライ]

実装パターン

// リクエスト処理: APIハンドラーから呼ばれる
func (r *repository) UpdateUser(ctx context.Context, user User) error {
    // 1. DBのみを即座に更新(短いトランザクション)
    tx, err := r.db.Begin(ctx)
    if err != nil {
        return fmt.Errorf("failed to begin transaction: %w", err)
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    if err = tx.UpdateUser(ctx, user); err != nil {
        return fmt.Errorf("failed to update user in DB: %w", err)
    }

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit failed: %w", err)
    }

    // 2. 外部サービス更新タスクをメッセージキューに投入
    // この関数はRabbitMQ、AWS SQS、Redis Streamなどにメッセージを送信
    // メッセージ投入自体は数ms程度で完了するため、リクエスト処理をブロックしない
    if err = r.enqueueExternalServiceUpdate(ctx, user); err != nil {
        // キュー投入失敗はログに記録するが、DB更新は成功扱い
        slog.Error("failed to enqueue external service update", "error", err)
    }

    return nil
}

重要なポイント

  1. enqueueExternalServiceUpdate()は同期的に実行される

    • この関数はメッセージキュー(RabbitMQ、AWS SQS など)にメッセージを投入するだけ
    • 投入自体は数 ms 程度で完了するため、リクエスト処理をほとんどブロックしない
    • 実際の外部サービス呼び出しは行わない
  2. 別プロセス(ワーカープロセス)が実際の処理を行う

    • ワーカープロセスは、アプリケーションサーバーとは独立して起動される
    • 常時メッセージキューを監視し、メッセージが来たら処理する
    • 複数のワーカーを起動して並列処理することも可能

このアプローチの利点

  1. DB トランザクションが短命: DB 更新のみで即座に完了(数 ms〜数十 ms)
  2. コネクションプールの効率的利用: 外部 API 呼び出し中は DB 接続を解放
  3. 障害の隔離: 外部サービスの障害が DB 層に影響しない
  4. リトライ可能: 非同期処理なので、失敗時のリトライが容易

トレードオフ

  • 即座の整合性は失われる: DB と外部サービスの間に一時的な不整合が発生
  • 複雑性の増加: メッセージキュー、ワーカープロセス、リトライロジックなどが必要
  • デバッグの困難性: 非同期処理のため、問題の追跡が複雑になる

重要な注意点

このパターンには一つ、看過できないリスクがあります。
それは 「DB のコミットには成功したが、メッセージキューへの投入に失敗(またはその直前にサーバーダウン)した場合、外部サービス連携が行われない」 という問題です。

ログ出力などで検知は可能ですが、データの完全な整合性が求められる決済システムなどでは、この「稀に発生するデータ欠損」が許容できない場合があります。

そこで、この問題を解決し、結果整合性をより堅牢に保証するのが、次の「Outbox パターン」です。

改善案 2: Outbox パターンの採用

より堅牢なアプローチとして、Outbox パターンがあります。

アーキテクチャの概要

[リクエスト処理]
  ↓
[DB更新 + Outboxテーブルにイベント記録] (単一トランザクション)
  ↓
[リクエスト完了を返す] ← ここで同期処理終了

[別プロセス: Outboxポーラー/バッチプロセス]
  ↓
[定期的にOutboxテーブルをSELECT] (例: 1秒ごと)
  ↓
[未処理イベントを取得]
  ↓
[Service A更新]
  ↓
[Service B更新]
  ↓
[処理完了したらOutboxから削除]

基本的な考え方

  1. リクエスト処理時: DB トランザクション内で、ビジネスデータと「実行すべきイベント」を両方記録
  2. 別プロセス(ポーラー): Outbox テーブルを定期的にポーリングし、未処理イベントを取得して処理
  3. 完了処理: イベント処理完了後、Outbox テーブルから該当レコードを削除

重要なポイント:

  • リクエスト処理は Outbox への記録だけで完了(数 ms〜数十 ms)
  • 別プロセス(ポーラー/バッチプロセス)が非同期でイベントを処理
  • メッセージキュー不要で DB だけで実現可能

実装例

// リクエスト処理: APIハンドラーから呼ばれる
func (r *repository) UpdateUser(ctx context.Context, user User) error {
    tx, err := r.db.Begin(ctx)
    if err != nil {
        return err
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    // 1. ビジネスデータの更新
    if err = tx.UpdateUser(ctx, user); err != nil {
        return err
    }

    // 2. Outboxにイベントを記録(同じトランザクション内)
    // DBコミットが成功すれば、必ずこのイベントも記録される
    event := OutboxEvent{
        EventType: "UserUpdated",
        Payload:   json.Marshal(user), // ユーザー情報をJSON化
        Status:    "pending",
        CreatedAt: time.Now(),
    }
    if err = tx.InsertOutboxEvent(ctx, event); err != nil {
        return err
    }

    // 3. 両方成功してからコミット
    if err = tx.Commit(); err != nil {
        return err
    }

    // ここでリクエスト処理は完了し、クライアントに成功を返す
    return nil
}

Outbox テーブルのスキーマ例

CREATE TABLE outbox_events (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    event_type VARCHAR(255) NOT NULL,
    payload JSON NOT NULL,
    status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
    retry_count INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    processed_at TIMESTAMP NULL,
    INDEX idx_status_created (status, created_at)
);

Outbox パターンの利点

  1. 確実なイベント配信: DB とイベント記録が同一トランザクションなので、整合性が保たれる
  2. At-least-once 保証: 処理失敗してもイベントは失われない(DB に残る)
  3. 冪等性の実装がしやすい: 同じイベントの重複実行に対応しやすい
  4. メッセージキュー不要: DB だけで実現できるため、インフラがシンプル
  5. 監視が容易: Outbox テーブルを直接 SQL で確認できる

トレードオフ

  • ポーリング間隔の調整が必要: 短すぎると DB 負荷、長すぎると遅延が発生
  • スケーラビリティの限界: 非常に高頻度のイベントには向かない(その場合はメッセージキュー推奨)
  • Outbox テーブルの肥大化: 定期的なクリーンアップが必要

改善案 3: Saga パターンの正しい実装

Saga(Orchestration)を実装しようとして、分散トランザクション(2PC)的な同期処理を混入させてしまった。正しい Saga パターンは以下のようになります。

Choreography Saga(イベント駆動)

各サービスがイベントを発行し、他のサービスがそれに反応します。

[サービスA] User更新イベント発行
     ↓
[ServiceB] イベント受信 → Profile更新 → 完了イベント発行
     ↓
[ServiceC] イベント受信 → Identity更新 → 完了イベント発行

失敗時は補償イベントを発行します。

Orchestration Saga(オーケストレーター)

中央のオーケストレーターが各サービスを順次呼び出します。

[Orchestrator]
  ↓ 1. DBを更新
  ↓ 2. Service A を更新
  ↓ 3. Service B を更新
  ↓
[完了 or 補償実行]

重要な違い: どちらのパターンでも、DB トランザクションは各ステップ内で短く完結させます。全体を一つの DB トランザクションで囲みません。

実装時の考慮事項

冪等性の担保

最終的整合性を採用する場合、冪等性は必須です:

func (r *repository) UpdateUser(ctx context.Context, user User) error {
    // バージョンチェックで冪等性を担保
    result, err := r.db.Exec(
        "UPDATE users SET name = ?, version = version + 1 WHERE id = ? AND version = ?",
        user.Name, user.ID, user.Version,
    )
    if err != nil {
        return err
    }

    rowsAffected, _ := result.RowsAffected()
    if rowsAffected == 0 {
        return ErrVersionConflict // すでに更新済み、または他のトランザクションが更新した
    }

    return nil
}

エラーハンドリング戦略

  1. リトライ戦略: Exponential backoff でリトライ
  2. Circuit Breaker: 外部サービスが継続的に失敗する場合は一時停止
  3. Dead Letter Queue: 最終的に失敗したメッセージを隔離し、手動対応

まとめ

学んだ教訓

  1. ACID 特性は万能ではない: 分散システムでは、ACID にこだわることで逆に問題が発生する
  2. トランザクションは短く保つ: 外部サービス呼び出しをトランザクション内で行うのは典型的なアンチパターン
  3. 最終的整合性を受け入れる: 即座の整合性を諦めることで、可用性とスケーラビリティを得られる
  4. トレードオフを理解する: どの設計も完璧ではなく、要件に応じた適切な選択が必要

適切な設計の選択

要件 推奨アプローチ
即座の整合性が必須 全体を単一サービス/DB に集約
高スループットが必要 非同期処理 + メッセージキュー
確実なイベント配信が必要 Outbox パターン
複雑なワークフロー Saga Orchestrator

最後に

「シンプルに実装したい」という思いから、DB トランザクションで全体を囲むアプローチを選びましたが、結果として重大な障害につながる設計となってしまいました。

分散システムにおいては、即座の整合性を諦め、最終的整合性を受け入れることが、むしろシンプルで堅牢なシステムへの近道だと学びました。

この教訓が同じような設計判断を迫られている方の参考になれば幸いです。

参考資料

Finatext Tech Blog

Discussion