Saga pattern cho giao dịch phân tán trong microservices

1. Tổng quan

Trong một ứng dụng monolith, bạn đặt @Transactional lên method và database lo phần còn lại: hoặc mọi thứ được commit, hoặc mọi thứ được rollback. Khi tách sang microservices, mỗi service có database riêng, nên một transaction ACID duy nhất không còn bao trùm được toàn bộ nghiệp vụ.

Bài này giới thiệu Saga pattern, cách phổ biến nhất để giải quyết vấn đề đó. Đọc xong, bạn sẽ hiểu hai kiểu saga (choreography và orchestration), tự viết được một saga orchestrator nhỏ bằng Java 21, test được luồng compensation và biết những lỗi hay gặp khi đưa saga vào production.

2. Vấn đề: một nghiệp vụ, nhiều database

Xét luồng đặt hàng gồm ba service:

  1. InventoryService giữ hàng trong kho.
  2. PaymentService trừ tiền khách hàng.
  3. ShippingService tạo vận đơn.

Giả sử bước 1 thành công nhưng bước 2 thất bại vì khách không đủ tiền. Lúc này hàng đã bị giữ trong kho mà không có đơn nào trả tiền. Bạn không thể “rollback” database của InventoryService từ PaymentService.

Giải pháp kinh điển là two-phase commit (2PC). Tuy vậy, 2PC ít được dùng trong microservices vì:

  • Nó khóa tài nguyên trong suốt quá trình chờ mọi bên trả lời, nên throughput giảm mạnh.
  • Coordinator là single point of failure.
  • Nhiều hệ thống như Kafka, MongoDB hay REST API bên thứ ba không hỗ trợ XA transaction.

3. Saga là gì?

Saga chia một giao dịch lớn thành chuỗi local transaction. Mỗi local transaction chạy trong một service và commit ngay vào database của service đó. Mỗi bước đi kèm một compensating transaction, tức một thao tác nghiệp vụ “hoàn tác” bước đó.

Bước Hành động Compensation
1 Giữ hàng (reserve) Trả hàng (release)
2 Trừ tiền (charge) Hoàn tiền (refund)
3 Tạo vận đơn (ship) Hủy vận đơn (cancel)

Nếu bước thứ n thất bại, saga chạy compensation của các bước n-1, n-2, …, 1 theo thứ tự ngược lại.

Hai điểm khác biệt quan trọng so với rollback thật:

  • Compensation không xóa lịch sử. Nó tạo ra một thay đổi mới, ví dụ một giao dịch hoàn tiền. Trong một khoảng thời gian ngắn, các service khác có thể đã nhìn thấy trạng thái trung gian.
  • Saga đảm bảo eventual consistency, không đảm bảo isolation như ACID.

Có hai cách điều phối saga:

  • Choreography: mỗi service lắng nghe event và tự quyết định bước tiếp theo. Không có trung tâm điều khiển.
  • Orchestration: một orchestrator gọi từng service theo thứ tự và chịu trách nhiệm chạy compensation khi có lỗi.

4. Orchestration saga bằng Java 21

Phần này xây một orchestrator thuần Java, không phụ thuộc framework, để bạn thấy rõ cơ chế bên trong.

4.1. Định nghĩa một bước 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); }
        };
    }
}

Mỗi bước có hai hành vi: execute cho luồng xuôi và compensate cho luồng hoàn tác. Generic C là context dùng chung giữa các bước, chẳng hạn thông tin đơn hàng. Factory method of giúp khai báo bước bằng lambda cho gọn.

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) {
                // Production: lưu lại để retry, không được bỏ qua
                System.err.println("Compensation failed at " + step.name() + ": " + ex.getMessage());
            }
        }
    }
}

Các điểm chính:

  • completed là một stack (ArrayDeque với push/pop). Bước nào thành công thì được đẩy vào stack, nên khi pop ra ta có đúng thứ tự ngược.
  • Bước thất bại không nằm trong stack. Bước đó chưa commit gì nên không cần compensate.
  • Một compensation lỗi không được làm dừng các compensation còn lại. Vì vậy mỗi lệnh compensate được bọc trong try/catch riêng.

4.3. Các service giả lập

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); }
}

Trong thực tế, mỗi method trên là một lời gọi REST/gRPC hoặc một message gửi tới service khác. Phần giả lập này chỉ dùng để minh họa và test.

4.4. Ghép thành saga đặt hàng

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"));
    }
}

Kết quả:

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

Đơn ORD-1 chạy hết ba bước. Đơn ORD-2 giữ được 3 cuốn sách nhưng thất bại ở bước trừ tiền. Orchestrator khi đó gọi inventory.release, nên tồn kho quay về 8 (10 − 2 của đơn đầu) thay vì 5.

5. Choreography saga với event

Với choreography, không service nào “biết” toàn bộ luồng. Mỗi service phát event sau khi xong việc, và service khác phản ứng với event đó. Ví dụ dưới đây dùng ApplicationEventPublisher của Spring trong một process để minh họa. Trong hệ thống thật, bạn thay nó bằng Kafka hoặc 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 lắng nghe StockReserved. Nếu trừ tiền lỗi, nó phát PaymentFailed. Đến lượt InventoryCompensationHandler nhận event này và trả hàng về kho. Để code chạy được, InventoryService và PaymentService cũng cần được khai báo là bean.

So sánh nhanh hai kiểu:

  • Choreography đơn giản và ít coupling khi luồng chỉ có 2–3 bước. Khi luồng dài hơn, logic nằm rải rác ở nhiều service nên khó theo dõi và dễ sinh vòng event.
  • Orchestration tập trung luồng ở một chỗ, dễ đọc, dễ test và dễ lưu trạng thái. Đổi lại, orchestrator trở thành một thành phần bạn phải vận hành cẩn thận.

6. Test hành vi compensation

Thêm dependency test:

<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();
    }
}

Test đầu tiên kiểm tra điều quan trọng nhất của saga: sau khi thất bại, mọi side effect đã được hoàn tác. Tồn kho về lại 10, số dư giữ nguyên và không có vận đơn nào được tạo. Test thứ hai xác nhận luồng thành công commit đủ cả ba bước.

7. Lỗi hay gặp và cách phòng tránh

Lưu ý: Orchestrator trong bài giữ trạng thái trong bộ nhớ. Nếu process chết giữa chừng, bạn không biết đã tới bước nào và cũng không compensate được. Trong production, hãy lưu trạng thái saga (bước hiện tại, các bước đã xong) vào database sau mỗi bước, rồi cho phép resume khi khởi động lại.

Compensation phải idempotent. Message có thể được gửi lại nhiều lần, nên refund chạy hai lần không được hoàn tiền hai lần. Hãy lưu orderId của các giao dịch đã xử lý và bỏ qua yêu cầu trùng.

Compensation không được “bỏ cuộc”. Đoạn catch in log ở mục 4.2 chỉ dùng để minh họa. Trong thực tế, compensation thất bại cần được retry với backoff. Sau nhiều lần vẫn lỗi thì đẩy vào dead-letter queue để con người xử lý.

Dual write. Nếu service vừa commit database vừa gửi message lên Kafka, một trong hai thao tác có thể thất bại. Hãy dùng Transactional Outbox: ghi event vào bảng outbox trong cùng local transaction, rồi một tiến trình khác đọc bảng đó và publish.

Thiếu isolation. Giữa lúc trừ tiền và lúc hoàn tiền, người dùng có thể thấy số dư bị giảm tạm thời. Bạn có thể giảm tác động này bằng semantic lock, ví dụ đặt trạng thái đơn là PENDING cho tới khi saga kết thúc. Một cách khác là sắp xếp lại các bước, đưa bước dễ thất bại nhất lên trước.

Có những bước không thể compensate. Một email đã gửi thì không thu hồi được. Hãy đặt các bước như vậy (gọi là pivot hoặc retriable step) ở cuối saga, sau khi mọi bước có thể thất bại đã thành công.

Khi nào KHÔNG nên dùng saga:

  • Toàn bộ dữ liệu nằm trong một database. Khi đó cứ dùng local transaction cho đơn giản.
  • Nghiệp vụ đòi hỏi strong consistency tuyệt đối và không chấp nhận được trạng thái trung gian, ví dụ một số nghiệp vụ sổ cái kế toán lõi. Lúc này bạn nên cân nhắc lại cách chia ranh giới service.
  • Luồng chỉ có một service ghi dữ liệu, còn các service khác chỉ đọc.

8. Kết luận

Saga thay một transaction phân tán bằng chuỗi local transaction đi kèm compensating transaction, nhờ đó hệ thống đạt eventual consistency mà không cần 2PC. Bạn đã xây một orchestrator nhỏ dùng stack để compensate theo thứ tự ngược, thấy cách choreography phối hợp qua event và viết test xác nhận mọi side effect được hoàn tác khi có lỗi.

Khi đưa saga vào production, hãy nhớ bốn việc: lưu trạng thái saga bền vững, viết compensation idempotent và có retry, dùng Transactional Outbox để tránh dual write, và thiết kế thứ tự bước sao cho các thao tác không thể hoàn tác nằm ở cuối luồng.

microservicessagadistributed transactionspring bootjava