Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

Saga pattern trong Microservices

Tung Bui Thanh (TungDaDev)12 mins read

Saga pattern.webp

# vấn đề: sao microservices không dùng được acid transaction?

Ở monolith, bạn bọc hết vào 1 @Transactional: tạo order, trừ tiền, giảm inventory. Hoặc tất cả cùng thành công, hoặc tất cả cùng rollback. Database lo ACID cho mình. Đời đẹp.

Tách ra microservices thì khác: Order Service một PostgreSQL riêng, Payment Service một DB riêng, Inventory Service một DB riêng. Không còn transaction manager nào quản được cả 3 database. Two-Phase Commit (2PC) thì có đấy, nhưng dùng thật thì:

  • Chậm (lock tài nguyên xuyên qua nhiều service)
  • Dễ gãy (coordinator crash là lock bị treo)
  • Khó scale (các service dính chặt vào nhau)
  • Nhiều database/message broker không hỗ trợ

Saga Pattern (khái niệm có từ bài báo "Sagas" của Hector Garcia-Molina và Kenneth Salem năm 1987, vốn dành cho transaction chạy lâu trong một database) giải bài này: thay vì một transaction to, chia ra thành chuỗi local transaction. Service nào tự commit local transaction của service đó. Có step nào fail thì chạy compensating transaction để hoàn tác các step trước.

# saga là gì?

Saga là một chuỗi local transaction, mỗi transaction cập nhật 1 service/database. Local transaction nào xong thì publish event/message để kích step tiếp theo. Có step fail thì chạy compensating transaction theo thứ tự ngược lại.

saga

Điểm cần nhớ: Saga KHÔNG đảm bảo Isolation (chữ I trong ACID). Giữa T2 và T3, inventory đã reserve nhưng payment chưa charge, và transaction khác vẫn nhìn thấy cái state "tạm thời lệch" này. Trong hệ thống phân tán, đây là trade-off chấp nhận được (chấp nhận BASE thay vì ACID), nhưng phải chủ động xử lý: dùng trạng thái trung gian như PENDING làm "semantic lock" (xem phần dưới), thiết kế các cập nhật có thể đảo thứ tự mà vẫn đúng (cộng/trừ số dư thay vì ghi đè), hoặc đọc lại dữ liệu trước khi ghi để phát hiện thay đổi.

# hai mô hình: choreography vs orchestration

# choreography: event-driven, không có người điều phối trung tâm

Service nào cũng tự nghe event rồi tự quyết phản ứng ra sao. Không ai "chỉ huy" cả. Giống ban nhạc jazz: mọi người nghe nhau rồi ngẫu hứng.

saga-models
// Order Service — publishes event after creating order
@Service
@RequiredArgsConstructor
public class OrderService {
 
   private final OrderRepository orderRepository;
   private final RabbitTemplate rabbitTemplate;
 
   @Transactional
   public OrderDTO createOrder(CreateOrderRequest request) {
       Order order = Order.builder()
           .customerId(request.getCustomerId())
           .items(request.getItems())
           .status(OrderStatus.PENDING)
           .build();
       order = orderRepository.save(order);
 
       // Publish event → Inventory Service listens
       // Lưu ý: gửi message ngay trong @Transactional là "dual write": message đi trước khi DB commit,
       // DB rollback thì message vẫn đã gửi. Production nên dùng Transactional Outbox (xem đoạn ngay dưới)
       rabbitTemplate.convertAndSend("order.exchange", "order.created",
           new OrderCreatedEvent(order.getId(), order.getItems(), order.getTotalAmount()));
 
       return toDTO(order);
   }
}
 
// Inventory Service — listens and reacts
@Component
@RequiredArgsConstructor
@Slf4j
public class InventoryEventListener {
 
   private final InventoryService inventoryService;
   private final RabbitTemplate rabbitTemplate;
 
   @RabbitListener(queues = "inventory.order-created")
   public void onOrderCreated(OrderCreatedEvent event) {
       try {
           inventoryService.reserve(event.getOrderId(), event.getItems());
 
           // Success → notify Payment Service
           rabbitTemplate.convertAndSend("order.exchange", "inventory.reserved",
               new InventoryReservedEvent(event.getOrderId(), event.getTotalAmount()));
 
       } catch (InsufficientStockException e) {
           // Fail → compensate: cancel order
           rabbitTemplate.convertAndSend("order.exchange", "inventory.failed",
               new InventoryFailedEvent(event.getOrderId(), e.getMessage()));
       }
   }
 
   // Compensation: release inventory khi payment fails
   @RabbitListener(queues = "inventory.payment-failed")
   public void onPaymentFailed(PaymentFailedEvent event) {
       inventoryService.release(event.getOrderId());
       log.info("Inventory released for order: {}", event.getOrderId());
   }
}

Code trên cố tình giữ đơn giản để thấy luồng event, nhưng có một lỗ hổng kinh điển: ghi DB và gửi message là hai hệ thống khác nhau, không cùng một transaction. Order lưu xong mà broker chết thì saga không bao giờ chạy tiếp; ngược lại message đã gửi mà DB rollback thì Inventory đi reserve cho một order không tồn tại. Cách chuẩn là Transactional Outbox: ghi event vào bảng outbox trong cùng local transaction với order, rồi một tiến trình riêng (polling hoặc CDC như Debezium) đọc bảng đó và đẩy lên broker. Listener phía Inventory cũng vậy. Chi tiết có trong bài transactional outbox và idempotent consumer.

Ưu điểm Choreography:

  • Đơn giản: không cần thành phần trung tâm
  • Ít phụ thuộc: các service chỉ biết event, không biết nhau
  • Dễ thêm thành viên: service mới chỉ cần subscribe event

Nhược điểm:

  • Khó nắm flow: logic rải rác qua nhiều service
  • Khó debug: "sao order bị kẹt?" → phải lần event qua 5 service
  • Dễ phụ thuộc vòng: service A nghe B, B lại nghe A
  • Khó thêm logic chung (timeout, retry cho cả saga)

# orchestration: có người điều phối trung tâm

Một Saga Orchestrator nắm toàn bộ flow, gọi lần lượt từng service và xử lý khi có lỗi. Giống nhạc trưởng trong dàn nhạc: một người chỉ huy, cả dàn làm theo.

saga-orchestration
/**
* Saga Orchestrator — biết toàn bộ flow, điều phối step-by-step.
*
* Implement dưới dạng state machine hoặc sequential steps.
* Mỗi step: execute → success → next step / fail → compensate previous steps.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CreateOrderSagaOrchestrator {
 
   private final OrderService orderService;
   private final InventoryClient inventoryClient;
   private final PaymentClient paymentClient;
   private final NotificationClient notificationClient;
 
   /**
    * Execute saga: từng step, rollback nếu fail.
    *
    * Flow: Create Order → Reserve Inventory → Charge Payment → Confirm
    * Compensate: Cancel Order ← Release Inventory ← Refund Payment
    */
   public OrderDTO execute(CreateOrderRequest request) {
       // Step 1: Create order (PENDING status)
       OrderDTO order = orderService.create(request);
       log.info("Saga step 1/4: Order created | orderId={}", order.getId());
 
       try {
           // Step 2: Reserve inventory
           inventoryClient.reserve(order.getId(), request.getItems());
           log.info("Saga step 2/4: Inventory reserved | orderId={}", order.getId());
 
           try {
               // Step 3: Charge payment
               paymentClient.charge(order.getId(), order.getTotalAmount(), request.getPaymentMethod());
               log.info("Saga step 3/4: Payment charged | orderId={}", order.getId());
 
               // Step 4: Confirm order
               orderService.confirm(order.getId());
               notificationClient.sendOrderConfirmation(order.getCustomerId(), order.getId());
               log.info("Saga step 4/4: Order confirmed | orderId={}", order.getId());
 
               return orderService.getById(order.getId());
 
           } catch (PaymentException e) {
               // Compensate step 2: release inventory
               log.warn("Saga compensation: payment failed, releasing inventory | orderId={}",
                   order.getId());
               inventoryClient.release(order.getId());
               orderService.cancel(order.getId(), "Payment failed: " + e.getMessage());
               throw new OrderCreationFailedException("Payment failed", e);
           }
 
       } catch (InsufficientStockException e) {
           // Compensate step 1: cancel order
           log.warn("Saga compensation: insufficient stock, cancelling order | orderId={}",
               order.getId());
           orderService.cancel(order.getId(), "Insufficient stock: " + e.getMessage());
           throw new OrderCreationFailedException("Insufficient stock", e);
       }
   }
}

Code orchestrator ở trên là bản tối giản. Bản chạy production còn thiếu mấy thứ quan trọng:

  • Lưu trạng thái saga: orchestrator chết giữa step 2 và step 3 thì khi khởi động lại phải biết saga đang ở đâu để chạy tiếp hoặc compensate. Trạng thái (bước hiện tại, kết quả từng bước) cần được ghi vào DB sau mỗi bước, không chỉ nằm trên stack.
  • Lỗi không lường trước: code chỉ bắt PaymentException và InsufficientStockException. Lỗi mạng, RuntimeException khác hay notification fail sau khi đã confirm sẽ để order kẹt ở PENDING mà không ai compensate.
  • Kết quả mơ hồ: gọi charge bị timeout không có nghĩa là chưa trừ tiền. Phải hỏi lại Payment (theo orderId) hoặc compensate theo kiểu "refund nếu đã charge".

Đây cũng là lý do nhiều team dùng sẵn engine như Temporal, AWS Step Functions hay BPMN engine (phần dưới) thay vì tự viết.

Ưu điểm Orchestration:

  • Dễ hiểu: cả flow nằm gọn trong 1 class/diagram
  • Dễ debug: orchestrator log từng step
  • Xử lý lỗi tập trung: timeout, retry, compensation nằm một chỗ
  • Không có phụ thuộc vòng

Nhược điểm:

  • Single point of failure (chính orchestrator service)
  • Coupling: orchestrator phải biết hết các service tham gia
  • Dễ thành "god class": orchestrator phình quá to

# khi nào dùng cái nào?

Tiêu chíChoreographyOrchestration
Số service2-3 (đơn giản)4+ (phức tạp)
Độ phức tạp của flowThẳng, ít rẽ nhánhNhiều nhánh, nhiều điều kiện
Cách chia teamMỗi team giữ 1 service1 team giữ cả flow
Nhu cầu debugThấp (flow đơn giản)Cao (flow phức tạp)
Business nhìn thấy flowKhóDễ (flow hiện rõ)
Dễ thay đổiCao (thêm listener)Vừa (sửa orchestrator)

Kinh nghiệm: flow đơn giản (2-3 step) thì bắt đầu bằng Choreography. Khi flow từ 4 step trở lên hoặc rẽ nhánh, compensation phức tạp thì chuyển sang Orchestration.

# mấy vấn đề phải xử lý

# idempotency: "bị trừ tiền 2 lần vì retry"

Message broker có thể giao một message nhiều lần (at-least-once). Compensating transaction cũng có thể bị gọi nhiều lần. Nên step nào cũng PHẢI idempotent.

@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentService {
 
   private final PaymentRepository paymentRepository;
 
   @Transactional
   public PaymentResult charge(UUID orderId, BigDecimal amount) {
       // Check idempotency: đã charge cho order này chưa?
       Optional<Payment> existing = paymentRepository.findByOrderId(orderId);
       if (existing.isPresent()) {
           log.info("Payment already processed for order: {}", orderId);
           return existing.get().toResult();  // Return existing result, don't charge again
       }
 
       // First time → execute charge
       // Check-then-act ở trên chưa đủ: 2 message trùng tới cùng lúc vẫn lọt qua cả hai.
       // Cần UNIQUE(order_id) ở bảng payment để insert thứ hai fail, và gửi orderId làm
       // idempotency key cho payment gateway để chính gateway không trừ tiền 2 lần
       Payment payment = processCharge(orderId, amount);
       return payment.toResult();
   }
}

# timeout: "saga kẹt mãi không xong"

Step nào cũng cần timeout. Service không trả lời trong X giây → coi như fail → chạy compensate.

// Orchestrator với timeout per step
CompletableFuture<InventoryResult> inventoryFuture = CompletableFuture
   .supplyAsync(() -> inventoryClient.reserve(orderId, items))
   .orTimeout(10, TimeUnit.SECONDS);  // 10s timeout
 
try {
   InventoryResult result = inventoryFuture.join();
} catch (CompletionException e) {
   // orTimeout làm future fail với TimeoutException, join() bọc nó trong CompletionException
   if (!(e.getCause() instanceof TimeoutException)) {
       throw e;
   }
   // Timeout = treat as failure → compensate.
   // Nhưng request có thể đã tới Inventory và reserve thành công, nên release luôn
   // (release phải idempotent: chưa reserve thì không làm gì)
   inventoryClient.release(orderId);
   orderService.cancel(orderId, "Inventory service timeout");
   throw new SagaTimeoutException("Inventory reservation timed out");
}

Hai chỗ dễ sai ở đây. Một, get() không ném TimeoutException trực tiếp mà bọc trong ExecutionException (join() thì bọc trong CompletionException), nên catch (TimeoutException e) quanh get() không biên dịch được. Hai, orTimeout chỉ làm future của mình kết thúc, không huỷ được request đang chạy (thread trong ForkJoinPool.commonPool() vẫn chờ tiếp). Trong thực tế nên đặt timeout ngay ở HTTP client (connect/read timeout) và coi timeout là "không rõ kết quả" chứ không phải "chắc chắn thất bại".

# semantic lock: tránh "dirty read" giữa các step của saga

Khi order còn PENDING (đang giữa các step), người khác không nên thấy nó như đã xong. Dùng field status: PENDING → CONFIRMED hoặc PENDING → CANCELLED. Các thao tác khác (huỷ đơn, sửa đơn, giao hàng) phải kiểm tra status này trước: gặp order chưa ở trạng thái cuối thì từ chối hoặc chờ, đó mới là "lock".

public enum OrderStatus {
   PENDING,              // Saga đang chạy — chưa final
   INVENTORY_RESERVED,   // Step 2 done
   PAYMENT_CHARGED,      // Step 3 done
   CONFIRMED,            // Saga complete — happy path
   CANCELLED,            // Saga compensated — sad path
   COMPENSATION_FAILED   // Compensation cũng fail — cần manual intervention
}

# saga + bpmn: dùng process engine làm orchestrator

BPMN engine (Activiti, Flowable, Camunda) vốn đã hợp làm Saga Orchestrator. Mỗi service task là 1 step của saga, error boundary event là chỗ kích compensation, còn compensation handler chứa logic hoàn tác.

<!-- BPMN as Saga Orchestrator (rút gọn: bỏ khai báo namespace và phần diagram) -->
<error id="PAYMENT_FAILED" errorCode="PAYMENT_FAILED"/>
 
<process id="createOrderSaga">
   <startEvent id="start"/>
   <sequenceFlow id="f1" sourceRef="start" targetRef="createOrder"/>
 
   <!-- Step 1: Create Order -->
   <serviceTask id="createOrder" activiti:delegateExpression="${createOrderDelegate}"/>
   <boundaryEvent id="createOrderCompensation" attachedToRef="createOrder">
       <compensateEventDefinition/>
   </boundaryEvent>
   <serviceTask id="cancelOrder" isForCompensation="true"
                activiti:delegateExpression="${cancelOrderDelegate}"/>
   <!-- compensation handler nối với boundary event bằng association, không phải sequenceFlow -->
   <association id="a1" sourceRef="createOrderCompensation" targetRef="cancelOrder"
                associationDirection="One"/>
   <sequenceFlow id="f2" sourceRef="createOrder" targetRef="reserveInventory"/>
 
   <!-- Step 2: Reserve Inventory -->
   <serviceTask id="reserveInventory" activiti:delegateExpression="${reserveInventoryDelegate}"/>
   <boundaryEvent id="reserveCompensation" attachedToRef="reserveInventory">
       <compensateEventDefinition/>
   </boundaryEvent>
   <serviceTask id="releaseInventory" isForCompensation="true"
                activiti:delegateExpression="${releaseInventoryDelegate}"/>
   <association id="a2" sourceRef="reserveCompensation" targetRef="releaseInventory"
                associationDirection="One"/>
   <sequenceFlow id="f3" sourceRef="reserveInventory" targetRef="chargePayment"/>
 
   <!-- Step 3: Charge Payment (may fail, delegate ném BpmnError("PAYMENT_FAILED")) -->
   <serviceTask id="chargePayment" activiti:delegateExpression="${chargePaymentDelegate}"/>
   <boundaryEvent id="paymentError" attachedToRef="chargePayment">
       <errorEventDefinition errorRef="PAYMENT_FAILED"/>
   </boundaryEvent>
   <sequenceFlow id="f4" sourceRef="chargePayment" targetRef="success"/>
 
   <!-- Happy path end -->
   <endEvent id="success"/>
 
   <!-- Error path: trigger compensation for all completed steps (ngược thứ tự hoàn thành) -->
   <sequenceFlow id="f5" sourceRef="paymentError" targetRef="compensateAll"/>
   <intermediateThrowEvent id="compensateAll">
       <compensateEventDefinition/>
   </intermediateThrowEvent>
   <sequenceFlow id="f6" sourceRef="compensateAll" targetRef="sagaFailed"/>
   <endEvent id="sagaFailed"/>
</process>

Cái lợi: flow nhìn được trên diagram, có version control, có sẵn monitoring, retry/timeout hỗ trợ sẵn, và quan trọng nhất là engine tự lưu trạng thái từng process instance vào DB, giải quyết luôn chuyện orchestrator chết giữa chừng. Nhiều hệ thống enterprise làm Saga theo cách này, khỏi phải tự viết orchestrator framework từ đầu.

Một lưu ý khi chọn engine: Camunda 7 Community Edition đã dừng ở bản 7.24 (10/2025), không còn bản vá; Camunda 8 (engine Zeebe) có hỗ trợ compensation event nhưng mô hình chạy khác hẳn (job worker gọi qua gRPC thay vì delegateExpression chạy trong JVM). Activiti và Flowable vẫn giữ kiểu nhúng vào ứng dụng như ví dụ trên.

# tổng kết

AspectSagaDistributed Transaction (2PC)
ConsistencyEventual (BASE)Strong (ACID)
AvailabilityHighLow (blocking)
PerformanceGood (async)Poor (locks)
ComplexityCao hơn (compensation logic)Thấp hơn (framework lo)
ScalabilityHighLimited
Use caseMicroservicesMonolith/tightly-coupled

Saga là một trade-off: bạn đổi strong consistency lấy availability và scalability. Viết compensation thì cực hơn rollback, nhưng hệ thống chịu lỗi tốt hơn nhiều. Trong thế giới microservices, đây là cách làm chuẩn được chấp nhận rộng rãi, không phải chữa cháy.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.