マイクロサービスで「注文 → 在庫引当 → 決済」のような複数サービスをまたぐ処理を書いていると、必ず一度はこう思いますよね。「 @Transactional を付けたいけど、サービスが分かれている以上どうにもならない」と。

この記事では、その悩みを解く現実解である Saga パターンを、Choreography 型と Orchestration 型の両方で Spring Boot + Kafka を使って実装していきます。補償トランザクションの設計やべき等性の確保まで、実務で詰まりやすい部分を中心にまとめました。

なぜ @Transactional や 2PC が使えないのか

単一プロセス内なら @Transactional で一発ですが、サービスが分かれた瞬間に話が変わります。@Transactional は単一の DB コネクションにスコープされるため、別サービスの DB やメッセージブローカーまでは巻き戻せません。単一サービス内のトランザクション挙動については Spring Bootのトランザクション管理ガイド で詳しく扱っています。

「ならば 2PC(XA)でいけるのでは」という発想もありますが、現代のマイクロサービス構成ではあまり現実的ではありません。Kafka をはじめとした多くのミドルウェアが XA に対応していませんし、対応していても可用性とスループットを大きく損ないます。

そこで取られるのが、結果整合性(Eventual Consistency)を前提にした設計です。各サービスのローカルトランザクションを連鎖させ、どこかで失敗したら「補償」して取り消す。これが Saga パターンの基本的な考え方です。

Choreography 型と Orchestration 型の違い

Saga には大きく 2 つの流派があります。

Choreography 型 は中央管理者を置かず、各サービスがイベントを購読・発行しながら自律的に進めます。OrderService が OrderCreated を発行 → InventoryService がそれを拾って在庫を引当 → InventoryReserved を発行 → PaymentService がそれを拾う、といった連鎖です。

Orchestration 型 は逆に、オーケストレータと呼ばれる中央コンポーネントが「次は在庫引当、次は決済」と順番に呼び出していくスタイルです。状態が一箇所に集約されるので追跡しやすい反面、オーケストレータが単一障害点になりやすい性質があります。

大まかな選定基準は以下の通りです。

  • サービス数が 3〜4 程度でフローが単純なら Choreography
  • ステップ数が多い、または分岐や条件が複雑なら Orchestration
  • 可観測性・運用容易性を最優先するなら Orchestration

サンプル題材と依存関係

以降は「注文 → 在庫引当 → 決済」の 3 サービス構成を題材にします。まずは Gradle の依存関係から。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.kafka:spring-kafka'
    implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
    runtimeOnly 'org.postgresql:postgresql'
}

サービス間で共有するイベントは、最低限「イベント ID」「相関 ID」「ペイロード」を含めます。相関 ID(correlationId)はあとで全イベントを横串で追うために必須で、 Saga 全体で一意な ID として注文 ID とは別に発行します。

public record OrderCreated(
        String eventId,        // イベント単位のユニークID(重複検知用)
        String correlationId,  // Saga全体で同じ値を持つ追跡ID
        String orderId,        // 業務キーとしての注文ID
        String productId,
        int quantity,
        long amount
) {}

public record InventoryReserved(String eventId, String correlationId, String orderId) {}
public record InventoryReservationFailed(String eventId, String correlationId, String orderId, String reason) {}
public record PaymentCompleted(String eventId, String correlationId, String orderId) {}
public record PaymentFailed(String eventId, String correlationId, String orderId, String reason) {}

Kafka 自体の詳しい設定は Spring BootでKafkaのProducer/Consumerを実装する方法 に譲ります。RabbitMQ を使いたい場合も基本構成は同じで、 Spring BootでRabbitMQを実装する方法 と組み合わせれば同様の Saga が組めます。

Choreography 型の実装

まず OrderService。注文を DB に保存し、 OrderCreated を発行します。correlationId は呼び出し元の CreateOrderCommand から受け取る前提にし、もし無ければここで新規発行します。

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @Transactional
    public void createOrder(CreateOrderCommand cmd) {
        var order = orderRepository.save(Order.pending(cmd));
        // correlationIdはSaga全体を貫く追跡用ID。リクエストから渡されたものを優先し、
        // 無ければここで新規発行する(orderIdとは別物として扱う)
        var correlationId = Optional.ofNullable(cmd.correlationId())
                .orElseGet(() -> UUID.randomUUID().toString());
        var event = new OrderCreated(
                UUID.randomUUID().toString(), // eventId
                correlationId,
                order.getId(),
                cmd.productId(),
                cmd.quantity(),
                cmd.amount()
        );
        kafkaTemplate.send("order.created", order.getId(), event);
    }
}

注意: 上記は説明用の最小コードで、 @Transactional 内から直接 kafkaTemplate.send を呼んでいます。これは「DB コミットには成功したが Kafka 送信に失敗した」もしくはその逆の デュアルライト問題 を抱えるため、本番投入には不適切です。実運用ではイベントをいったん同一 DB の outbox テーブルに書き込み、別プロセスが Kafka に転送する Transactional Outbox パターン を採用してください。

続いて InventoryService。OrderCreated を購読して在庫を引き当て、結果イベントを発行します。

@Component
@RequiredArgsConstructor
public class InventoryEventListener {
    private final InventoryService inventoryService;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @KafkaListener(topics = "order.created", groupId = "inventory")
    public void handle(OrderCreated event) {
        try {
            inventoryService.reserve(event.eventId(), event.productId(), event.quantity());
            kafkaTemplate.send("inventory.reserved", event.orderId(),
                    new InventoryReserved(UUID.randomUUID().toString(), event.correlationId(), event.orderId()));
        } catch (OutOfStockException e) {
            kafkaTemplate.send("inventory.failed", event.orderId(),
                    new InventoryReservationFailed(UUID.randomUUID().toString(), event.correlationId(), event.orderId(), e.getMessage()));
        }
    }
}

PaymentService も同じ要領で inventory.reserved を購読し、決済結果を payment.completed / payment.failed に流します。失敗イベントを拾った OrderService は注文を CANCELLED に更新し、必要なら在庫戻しイベントを発行する流れになります。

Orchestration 型の実装

オーケストレータ方式では、Saga の状態を DB で管理します。

@Entity
public class OrderSaga {
    @Id private String orderId;
    @Enumerated(EnumType.STRING)
    private SagaState state;
    private String lastError;

    public enum SagaState {
        STARTED, INVENTORY_RESERVED, PAYMENT_COMPLETED, COMPENSATING, FAILED, COMPLETED
    }

    // ※ getter/setter および markInventoryReserved() などの状態遷移メソッドの実装は省略
}

オーケストレータは状態を見ながら次のステップを呼び出します。下の例は分かりやすさのためメソッド全体を @Transactional で包んでいますが、外部 REST 呼び出しを含む長時間トランザクションになるため DB ロックを長く保持してしまいます。実運用では 「状態保存のみコミット → 外部呼び出し → 次の状態保存」 のようにトランザクション境界を細かく切るのが定石です。

@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
    private final OrderSagaRepository repository;
    private final InventoryClient inventoryClient;
    private final PaymentClient paymentClient;

    @Transactional
    public void start(CreateOrderCommand cmd) {
        var saga = repository.save(OrderSaga.start(cmd.orderId()));
        try {
            inventoryClient.reserve(cmd.orderId(), cmd.productId(), cmd.quantity());
            saga.markInventoryReserved();

            paymentClient.charge(cmd.orderId(), cmd.amount());
            saga.markPaymentCompleted();

            saga.markCompleted();
        } catch (Exception e) {
            compensate(saga, e);
        }
    }

    private void compensate(OrderSaga saga, Exception e) {
        // 失敗した時点での到達状態を退避してから状態をCOMPENSATINGに更新する。
        // これをやらないとmarkCompensating後にstateが書き換わって補償判定が壊れる。
        var failedAt = saga.getState();
        saga.markCompensating(e.getMessage());

        if (failedAt.ordinal() >= OrderSaga.SagaState.PAYMENT_COMPLETED.ordinal()) {
            paymentClient.refund(saga.getOrderId());
        }
        if (failedAt.ordinal() >= OrderSaga.SagaState.INVENTORY_RESERVED.ordinal()) {
            inventoryClient.release(saga.getOrderId());
        }
        saga.markFailed();
    }
}

簡易ステートマシンで十分なケースが多いですが、遷移が複雑になってきたら Spring StateMachine を導入する選択肢もあります。

補償トランザクションの設計

補償処理は「元の操作の逆操作」を行うものです。在庫予約に対する在庫戻し、決済に対する返金、注文確定に対するキャンセル、といった具合ですね。

設計時に押さえておきたいポイントは 3 つです。

  1. 補償もべき等にする。リトライや重複配信に耐える必要があるため、同じ補償リクエストが何度来ても結果が変わらないように作ります。
  2. やり直せない操作は後段に置く。メール送信や物理出荷など取り消し不能な操作を境にした、 pivot transaction(ピボットトランザクション)と呼ばれる考え方です。pivot を通った後の Saga は前進あるのみ、それより前で失敗したら補償で巻き戻せる、という設計にします。
  3. 補償の失敗に備える。補償処理自体が失敗することもあるので、デッドレターキューに退避して人間が介入できる経路を必ず用意します。
@Service
@RequiredArgsConstructor
public class InventoryCompensationService {
    private final InventoryRepository inventoryRepository;
    private final ProcessedEventRepository processedRepository;

    // メソッド全体が同一トランザクションでコミットされる。
    // 在庫加算と処理済み記録が原子的に進むので、二重実行を防げる。
    @Transactional
    public void release(String reservationId, String productId, int quantity) {
        if (processedRepository.existsById("release:" + reservationId)) {
            return;
        }
        inventoryRepository.increaseStock(productId, quantity);
        processedRepository.save(new ProcessedEvent("release:" + reservationId));
    }
}

べき等性・順序保証・リトライ

メッセージングを使う以上、「同じイベントが 2 回届く」「順序が入れ替わる」は避けられません。実装側で耐性を持たせる必要があります。

べき等性は、イベント ID をキーにした処理済みテーブルで担保するのが最もシンプルです。受信したらまず処理済みかをチェックし、未処理なら本処理と記録を同一トランザクションでコミットします。

順序保証は Kafka のパーティションキーで対応します。同じ orderId のイベントは必ず同じパーティションに入るので、コンシューマ側で順序が崩れません。なお、イベントスキーマを変更する際は破壊的変更を避け、Avro や JSON Schema によるスキーマレジストリでバージョン管理する戦略を検討すると安全です。

一時的な障害に対しては Resilience4j の Retry を組み合わせます。詳細は Spring BootでResilience4jのCircuit Breakerを実装する方法 を参照してください。

@Retry(name = "paymentClient")
@CircuitBreaker(name = "paymentClient")
public void charge(String orderId, long amount) {
    paymentApi.charge(new ChargeRequest(orderId, amount));
}

対応する application.yml の最小設定は以下のような形になります。

resilience4j:
  retry:
    instances:
      paymentClient:
        max-attempts: 3
        wait-duration: 500ms
        retry-exceptions:
          - java.io.IOException
          - org.springframework.web.client.ResourceAccessException
  circuitbreaker:
    instances:
      paymentClient:
        sliding-window-size: 20
        failure-rate-threshold: 50

ただし「決済済みのものを再決済してしまう」ような副作用のあるリトライは危険なので、リトライ可否を操作ごとに判断するのが大事です。

可観測性をどう確保するか

Saga の最大の弱点は「処理が複数サービスに散らばるので追いづらい」ことです。これに対する打ち手はだいたい決まっています。

  • 全イベントに correlationId を付与し、Micrometer Tracing で横断追跡できるようにする
  • ログ出力時に MDC へ correlationId を入れ、Loki や Elasticsearch などで一発検索できる状態を作る
  • Saga の状態を DB に永続化し、運用画面から進行状況を見られるようにする
  • 失敗イベントは Kafka のデッドレタートピックに退避し、アラートを飛ばす

Orchestration 型なら状態 DB がそのまま可視化対象になりますが、Choreography 型でも別途「Saga 状態テーブル」を持っておくと運用がぐっと楽になります。

運用上のアンチパターン

現場でよく見る失敗パターンをいくつか挙げておきます。

  • 補償処理を実装しないまま本番投入してしまう
  • 相関 ID が無くて、障害発生時にログを追えない
  • イベントスキーマを破壊的に変更してコンシューマが壊れる
  • Saga の状態をどこにも永続化しておらず、再起動で進行状況が消える
  • 「とりあえず全部リトライ」にして、副作用のある操作まで多重実行してしまう

どれも後から直そうとすると非常に痛いので、初期設計の段階で押さえておきたいところです。

まとめ

Saga パターンは、マイクロサービス間で ACID トランザクションが使えない問題に対する実用的な答えです。

フローが単純なら Choreography 型、ステップ数が多く可観測性を重視したいなら Orchestration 型、という大枠の選び方を覚えておくとブレません。そのうえで、補償トランザクションの設計、べき等性、相関 ID による追跡、この 3 点さえ最初から織り込んでおけば大きく外しません。

周辺技術としては KafkaRabbitMQResilience4jトランザクション管理 の記事も合わせて読むと、Saga の前提となる部品が揃うと思います。