Saga パターンでマイクロサービスの分散トランザクションを管理する

1. 概要

モノリスなアプリケーションでは、メソッドに @Transactional を付与すると、データベースがすべての処理を管理して、すべてがコミットされるか、すべてがロールバックされるかのいずれかになります。マイクロサービスに移行すると、各サービスが独自のデータベースを持つため、単一の ACID トランザクションがビジネスプロセス全体をカバーできなくなります。

この記事では、この問題を解決する最も一般的な方法である Saga パターン を紹介します。この記事を読み終わると、Saga の2つのタイプ(choreography と orchestration)が理解でき、Java 21 を使って小さな Saga orchestrator を実装でき、compensation フローをテストでき、Saga を本番環境に導入する際の一般的なエラーについて知ることができます。

2. 問題: 1つのビジネスプロセス、複数のデータベース

3つのサービスで構成される注文フローを考えてみます:

  1. InventoryService - 倉庫内の商品を管理します。
  2. PaymentService - 顧客から支払いを徴収します。
  3. ShippingService - 配送伝票を作成します。

ステップ 1 は成功しましたが、ステップ 2 は顧客が十分なお金を持っていないため失敗したと仮定します。この時点で、商品は倉庫に保留されていますが、支払いはありません。PaymentService から InventoryService のデータベースを「ロールバック」することはできません。

従来の解決策は two-phase commit (2PC) です。ただし、マイクロサービスではあまり使用されていません。理由は以下の通りです:

  • すべての参加者からの応答を待つ間、リソースが長時間ロックされるため、スループットが大幅に低下します。
  • コーディネーターは単一障害点になります。
  • Kafka、MongoDB、サードパーティの REST API など、多くのシステムが XA トランザクションをサポートしていません。

3. Saga とは何ですか?

Saga は大きなトランザクションを ローカルトランザクションの連鎖 に分割します。各ローカルトランザクションはサービス内で実行され、そのサービスのデータベースに直ちにコミットされます。各ステップには compensating transaction が付属しており、これはそのステップを「取り消す」ビジネス操作です。

ステップ アクション Compensation
1 商品を予約 (reserve) 商品を返却 (release)
2 支払い処理 (charge) 払い戻し (refund)
3 配送伝票作成 (ship) 配送キャンセル (cancel)

ステップ n が失敗した場合、Saga はステップ n-1, n-2, …, 1 の compensation を 逆順で 実行します。

実際のロールバックと比較して、2つの重要な違いがあります:

  • Compensation は 履歴を削除しません。それは新しい変更を作成します。例えば、払い戻しトランザクションです。短い間に、他のサービスが中間状態を既に見ている可能性があります。
  • Saga は eventual consistency を保証し、ACID のような isolation は保証しません。

Saga を調整する2つの方法があります:

  • Choreography: 各サービスがイベントをリッスンし、自ら次のステップを決定します。中央で管理するものはありません。
  • Orchestration: Orchestrator が各サービスを順番に呼び出し、エラー時に compensation を実行する責任を負います。

4. Java 21 による Orchestration Saga

このセクションでは、フレームワークに依存しない純粋な Java orchestrator を構築します。これにより、内部メカニズムを明確に理解できます。

4.1. Saga ステップの定義

import java.util.function.Consumer;

public interface SagaStep<C> {
    String name();
    void execute(C context);
    void compensate(C context);

    static <C> SagaStep<C> of(String name, Consumer<C> action, Consumer<C> compensation) {
        return new SagaStep<>() {
            public String name() { return name; }
            public void execute(C ctx) { action.accept(ctx); }
            public void compensate(C ctx) { compensation.accept(ctx); }
        };
    }
}

各ステップには2つの動作があります:execute は順方向フローの場合、compensate は取り消しフローの場合です。ジェネリック C は、ステップ間で共有されるコンテキスト(例えば、注文情報)です。Factory メソッド of は、ラムダを使ってステップを簡潔に宣言するのに役立ちます。

4.2. Orchestrator

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;

public record SagaResult(boolean success, String failedStep, String reason) {
    static SagaResult ok() { return new SagaResult(true, null, null); }
    static SagaResult failed(String step, String reason) { return new SagaResult(false, step, reason); }
}

public class SagaOrchestrator<C> {
    private final List<SagaStep<C>> steps;

    public SagaOrchestrator(List<SagaStep<C>> steps) {
        this.steps = List.copyOf(steps);
    }

    public SagaResult run(C context) {
        Deque<SagaStep<C>> completed = new ArrayDeque<>();
        for (SagaStep<C> step : steps) {
            try {
                step.execute(context);
                completed.push(step);
            } catch (RuntimeException ex) {
                compensate(completed, context);
                return SagaResult.failed(step.name(), ex.getMessage());
            }
        }
        return SagaResult.ok();
    }

    private void compensate(Deque<SagaStep<C>> completed, C context) {
        while (!completed.isEmpty()) {
            SagaStep<C> step = completed.pop();
            try {
                step.compensate(context);
            } catch (RuntimeException ex) {
                // 本番環境: リトライのために保存し、スキップしてはいけません
                System.err.println("Compensation failed at " + step.name() + ": " + ex.getMessage());
            }
        }
    }
}

主要なポイント:

  • completed は stack です(push/pop を持つ ArrayDeque)。成功したステップがスタックにプッシュされるため、ポップすると正しい逆順が得られます。
  • 失敗したステップは スタックに含まれません。そのステップはまだコミットされていないため、compensation は不要です。
  • 1つの compensation エラーが他の compensation を停止してはいけません。したがって、各 compensate コマンドは独自の try/catch でラップされています。

4.3. モックサービス

import java.math.BigDecimal;
import java.util.*;

public record Order(String id, String customerId, String sku, int quantity, BigDecimal amount) {}

public class InventoryService {
    private final Map<String, Integer> stock = new HashMap<>(Map.of("BOOK-01", 10));
    public void reserve(Order o) {
        int left = stock.get(o.sku()) - o.quantity();
        if (left < 0) throw new IllegalStateException("Out of stock");
        stock.put(o.sku(), left);
    }
    public void release(Order o) { stock.merge(o.sku(), o.quantity(), Integer::sum); }
    public int available(String sku) { return stock.get(sku); }
}

public class PaymentService {
    private final Map<String, BigDecimal> balances = new HashMap<>(Map.of("alice", new BigDecimal("100")));
    public void charge(Order o) {
        BigDecimal balance = balances.get(o.customerId());
        if (balance.compareTo(o.amount()) < 0) throw new IllegalStateException("Insufficient funds");
        balances.put(o.customerId(), balance.subtract(o.amount()));
    }
    public void refund(Order o) { balances.merge(o.customerId(), o.amount(), BigDecimal::add); }
    public BigDecimal balance(String customerId) { return balances.get(customerId); }
}

public class ShippingService {
    private final Set<String> shipments = new HashSet<>();
    public void ship(Order o) { shipments.add(o.id()); }
    public void cancel(Order o) { shipments.remove(o.id()); }
    public boolean hasShipment(String orderId) { return shipments.contains(orderId); }
}

実際には、上記の各メソッドは REST/gRPC 呼び出しまたは別のサービスへのメッセージです。このモック実装は、説明とテストのためだけに使用されます。

4.4. 注文 Saga の統合

import java.math.BigDecimal;
import java.util.List;

public class OrderSagaDemo {
    public static void main(String[] args) {
        var inventory = new InventoryService();
        var payment = new PaymentService();
        var shipping = new ShippingService();

        var saga = new SagaOrchestrator<Order>(List.of(
            SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
            SagaStep.of("charge-payment", payment::charge, payment::refund),
            SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
        ));

        var cheap = new Order("ORD-1", "alice", "BOOK-01", 2, new BigDecimal("40"));
        var pricey = new Order("ORD-2", "alice", "BOOK-01", 3, new BigDecimal("500"));

        System.out.println(saga.run(cheap));
        System.out.println(saga.run(pricey));
        System.out.println("Stock: " + inventory.available("BOOK-01"));
        System.out.println("Balance: " + payment.balance("alice"));
    }
}

結果:

SagaResult[success=true, failedStep=null, reason=null]
SagaResult[success=false, failedStep=charge-payment, reason=Insufficient funds]
Stock: 8
Balance: 60

注文 ORD-1 は3つのステップすべてを実行します。注文 ORD-2 は3冊の書籍を予約しましたが、支払いステップで失敗しました。Orchestrator は inventory.release を呼び出すため、在庫は8に戻ります(10 − 最初の注文の2冊)。5ではなく8です。

5. イベント駆動の Choreography Saga

Choreography では、どのサービスも完全なフローを「知りません」。各サービスは作業が完了した後にイベントを発行し、他のサービスはそのイベントに反応します。以下の例は、Spring の ApplicationEventPublisher を単一プロセス内で説明のために使用しています。実際のシステムでは、これを Kafka または RabbitMQ に置き換えます。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
public record StockReserved(Order order) {}
public record PaymentFailed(Order order, String reason) {}

@Component
public class PaymentHandler {
    private final PaymentService payment;
    private final ApplicationEventPublisher events;

    public PaymentHandler(PaymentService payment, ApplicationEventPublisher events) {
        this.payment = payment;
        this.events = events;
    }

    @EventListener
    public void on(StockReserved event) {
        try {
            payment.charge(event.order());
        } catch (IllegalStateException ex) {
            events.publishEvent(new PaymentFailed(event.order(), ex.getMessage()));
        }
    }
}

@Component
public class InventoryCompensationHandler {
    private final InventoryService inventory;

    public InventoryCompensationHandler(InventoryService inventory) {
        this.inventory = inventory;
    }

    @EventListener
    public void on(PaymentFailed event) {
        inventory.release(event.order());
    }
}

PaymentHandler は StockReserved をリッスンします。支払い処理が失敗した場合、PaymentFailed を発行します。次に、InventoryCompensationHandler がこのイベントを受け取り、商品を在庫に返却します。コードが動作するには、InventoryService と PaymentService も Bean として宣言される必要があります。

2つのタイプの簡単な比較:

  • Choreography は、フローが 2~3 ステップのみの場合、シンプルで結合度が低いです。フローが長い場合、ロジックが複数のサービスに分散されるため、追跡が困難になり、イベント ループが生じやすくなります。
  • Orchestration はフローを1つの場所に集中させるため、読みやすく、テストしやすく、状態を保存しやすいです。反面、orchestrator は慎重に運用する必要があるコンポーネントになります。

6. Compensation 動作のテスト

テスト依存関係を追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderSagaTest {

    private final InventoryService inventory = new InventoryService();
    private final PaymentService payment = new PaymentService();
    private final ShippingService shipping = new ShippingService();

    private final SagaOrchestrator<Order> saga = new SagaOrchestrator<>(List.of(
        SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
        SagaStep.of("charge-payment", payment::charge, payment::refund),
        SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
    ));

    @Test
    void givenInsufficientFunds_whenRunSaga_thenStockIsReleased() {
        var order = new Order("ORD-9", "alice", "BOOK-01", 3, new BigDecimal("500"));

        SagaResult result = saga.run(order);

        assertThat(result.success()).isFalse();
        assertThat(result.failedStep()).isEqualTo("charge-payment");
        assertThat(inventory.available("BOOK-01")).isEqualTo(10);
        assertThat(payment.balance("alice")).isEqualByComparingTo("100");
        assertThat(shipping.hasShipment("ORD-9")).isFalse();
    }

    @Test
    void givenValidOrder_whenRunSaga_thenAllStepsCommitted() {
        var order = new Order("ORD-10", "alice", "BOOK-01", 1, new BigDecimal("30"));

        assertThat(saga.run(order).success()).isTrue();
        assertThat(inventory.available("BOOK-01")).isEqualTo(9);
        assertThat(payment.balance("alice")).isEqualByComparingTo("70");
        assertThat(shipping.hasShipment("ORD-10")).isTrue();
    }
}

最初のテストは Saga の最も重要な側面をチェックします:失敗後、すべての副作用が取り消されていることを確認します。在庫は10に戻り、残高は変わらず、配送伝票は作成されていません。2番目のテストは、成功したフローが3つのステップすべてをコミットしていることを確認します。

7. 一般的なエラーと回避方法

注意: この記事の Orchestrator はメモリ内に状態を保持しています。プロセスが途中で停止した場合、どのステップに到達したかわからず、compensation もできません。本番環境では、Saga の状態(現在のステップ、完了したステップ)を各ステップの後にデータベースに保存し、再起動時に再開できるようにしてください。

Compensation は冪等である必要があります。 メッセージは複数回再送信される可能性があるため、refund が2回実行されても2回払い戻してはいけません。処理済みトランザクションの orderId を保存し、重複リクエストをスキップしてください。

Compensation は「放棄」してはいけません。 セクション 4.2 の catch の例は説明のためだけです。実際には、失敗した compensation は backoff で再試行される必要があります。複数回の再試行後も失敗する場合は、人間が処理するために dead-letter queue にプッシュしてください。

Dual write. サービスがデータベースをコミットして同時に Kafka にメッセージを送信する場合、2つのアクションのいずれかが失敗する可能性があります。Transactional Outbox を使用してください。同じローカルトランザクション内に outbox テーブルにイベントを書き込み、別のプロセスがそのテーブルを読み取って発行します。

Isolation の欠如。 支払い処理から払い戻しまでの間に、ユーザーは残高が一時的に減少しているのを見る可能性があります。semantic lock を使用してこの影響を減らすことができます。例えば、Saga が終了するまで注文の状態を PENDING に設定します。別の方法は、ステップの順序を変更して、失敗しやすいステップを最初に実行することです。

補償できないステップがあります。 送信済みのメールは取り消すことができません。このようなステップ(pivot または retriable step と呼ばれます)を Saga の最後に配置し、失敗する可能性のあるすべてのステップが成功した後に実行してください。

Saga を使用してはいけない場合:

  • すべてのデータが1つのデータベース内にある場合。その場合、シンプルにするためにローカルトランザクションを使用してください。
  • ビジネスが強い一貫性を必須とし、中間状態を容認できない場合(例えば、コア会計台帳業務など)。この場合、サービス境界の分割を再検討する必要があります。
  • フローに1つのサービスのみがデータを書き込み、他のサービスが読み取るだけの場合。

8. まとめ

Saga は分散トランザクションを compensating transaction を伴うローカルトランザクションの連鎖に置き換え、これにより 2PC を必要とせずに eventual consistency を達成します。スタックを使用して逆順で補償する小さな orchestrator を構築し、choreography がイベントを通じてどのように調整されるかを確認し、エラー時にすべての副作用が取り消されることを確認するテストを作成しました。

Saga を本番環境に導入する場合は、4つのことを忘れずに:Saga の状態を永続的に保存する、冪等な compensation を記述してリトライを行う、Transactional Outbox を使用して dual write を回避する、補償できない操作がフローの最後になるようにステップの順序を設計する。

microservicessagadistributed transactionspring bootjava