Saga: transaction xuyên nhiều service

5 câu hỏi~30 phútTừ khóa: distributed transaction, 2PC, compensating transaction, choreography, orchestration, saga state, idempotent compensation

Tiếp đi: Distributed Transaction và Saga.

Phần này nối ACID, transaction, Kafka/RabbitMQ, retry và idempotency thành một bài hoàn chỉnh. Ta đi từ case rất cụ thể trong shop-app:

Create Order     ✅
Charge Payment   ✅
Reserve Stock    ❌
        ↓
Làm sao đưa hệ thống về trạng thái hợp lệ?

1. Bài toán

Checkout của shop-app đi qua 3 service, mỗi service sở hữu DB riêng (đúng nguyên tắc ở bài 14.4):

order-service       → order_db
payment-service     → payment_db
inventory-service   → inventory_db

User mua iPhone $1000:

1. Create Order       ✅
2. Charge $1000       ✅
3. Reserve iPhone     ❌ OUT OF STOCK

Order     = CREATED
Payment   = PAID
Inventory = NOT RESERVED
💥 Khách đã mất tiền nhưng không có hàng.

2. Vấn đề: sao không dùng @Transactional?

Nếu là monolith và cùng một DB thì dễ:

@Transactional
public void checkout() {
    createOrder();
    chargePayment();
    reserveInventory();   // ném exception → ROLLBACK cả 3
}

Nhưng ở microservices:

                order-service
                     │
           ┌─────────┼──────────┐
           ▼         ▼          ▼
       order_db  payment_db  inventory_db
                 (của payment) (của inventory)

Đây là distributed transaction: một business operation cần thay đổi dữ liệu ở nhiều service/resource, mà mỗi nơi có transaction riêng.

Thế còn 2PC (Two-Phase Commit / XA)?

Coordinator
   │ Phase 1: PREPARE?   → Order OK, Payment OK, Inventory OK (giữ lock)
   │ Phase 2: COMMIT!    → tất cả commit
   │
   └── Coordinator chết giữa phase 1 và 2
         → các participant giữ lock, đứng chờ (blocking)

3. Giải pháp: Saga + Compensating Transaction

Thay vì một transaction khổng lồ, Saga chia business transaction thành nhiều local transaction:

T1 → T2 → T3        mỗi service commit transaction của chính nó

T1 ✅  T2 ✅  T3 ❌
             ↓
          C2 (undo T2)
             ↓
          C1 (undo T1)

Các bước C gọi là compensating transactions. Áp vào checkout:

Reserve Inventory ❌
       ↓
Refund Payment
       ↓
Cancel Order

Order = CANCELLED, Payment = REFUNDED, Inventory = NOT RESERVED
→ hệ thống về một business state hợp lệ.

Compensation không phải database rollback. Điểm này rất quan trọng:

Database rollback:              Saga compensation:
UPDATE balance                  Charge $1000 → COMMIT ✅   (transaction #1)
   ↓ ERROR                      ...5 giây sau...
ROLLBACK (chưa commit)          Inventory FAIL
                                Refund $1000 → COMMIT ✅   (transaction #2)

Có hai transaction thật sự. Transaction #1 đã hoàn thành, ta không "rollback" nó, mà tạo một business operation mới để undo về mặt logic. Khách vẫn thấy 2 dòng trong sao kê: trừ tiền và hoàn tiền.

Hệ quả: giữa 10:00:00 (PAID) và 10:00:05 (REFUNDED) dữ liệu tạm thời không nhất quán. Đó là eventual consistency (xem bài 14.5).

4. Code từng bước (Orchestration)

Bước 1: Saga có state riêng. Không chỉ Order/Payment có state, workflow cũng có state:

public enum SagaStatus {
    STARTED, ORDER_CREATED, PAYMENT_COMPLETED,
    COMPLETED,
    COMPENSATING, COMPENSATION_FAILED, CANCELLED
}

@Entity
@Table(name = "checkout_saga")
public class CheckoutSaga {
    @Id
    private String sagaId;            // CHECKOUT-123
    private String orderId;
    private String paymentId;
    @Enumerated(EnumType.STRING)
    private SagaStatus status;
    private String failedStep;
    private int retryCount;
    private Instant nextRetryAt;
    @Version
    private long version;             // tránh 2 instance cùng xử lý
    // getters/setters
}

Bước 2: Orchestrator điều khiển từng bước, lưu state sau mỗi bước:

@Service
@RequiredArgsConstructor
public class CheckoutOrchestrator {

    private final CheckoutSagaRepository sagaRepo;
    private final OrderClient orderClient;
    private final PaymentClient paymentClient;
    private final InventoryClient inventoryClient;

    public void start(String sagaId, CheckoutRequest req) {
        CheckoutSaga saga = sagaRepo.save(CheckoutSaga.started(sagaId));

        String orderId = orderClient.create(sagaId, req);        // idempotent theo sagaId
        saga.markOrderCreated(orderId);
        sagaRepo.save(saga);

        String paymentId = paymentClient.charge(sagaId + "-PAY", orderId, req.amount());
        saga.markPaymentCompleted(paymentId);
        sagaRepo.save(saga);

        try {
            inventoryClient.reserve(sagaId + "-RES", orderId, req.items());
            orderClient.confirm(orderId);
            saga.setStatus(SagaStatus.COMPLETED);
            sagaRepo.save(saga);
        } catch (BusinessException ex) {          // OUT_OF_STOCK
            saga.startCompensation("RESERVE_INVENTORY");
            sagaRepo.save(saga);
            compensate(saga);
        }
    }

    public void compensate(CheckoutSaga saga) {
        try {
            paymentClient.refund(saga.getSagaId() + "-REFUND", saga.getPaymentId());
            orderClient.cancel(saga.getOrderId());
            saga.setStatus(SagaStatus.CANCELLED);
        } catch (Exception ex) {
            saga.scheduleRetry();                 // retryCount++, nextRetryAt = backoff
        }
        sagaRepo.save(saga);
    }
}

Ghi chú: code minh họa luồng. Thực tế Payment trả timeout cần xử lý trạng thái UNKNOWN (bài 14.9), và mỗi bước nên đi qua message + Outbox (bài 14.7) để không mất bước khi app chết.

Bước 3: Compensation phải idempotent. Saga retry refund(PAY-123): request đầu đã hoàn tiền nhưng response bị mất, Saga tưởng fail nên gọi lại:

Không idempotent:   Refund #1 → +$1000,  Refund #2 → +$1000 💀

@Transactional
public RefundResult refund(String refundId, String paymentId) {
    return refundRepo.findById(refundId)                 // REFUND-PAY-123 đã xử lý?
        .map(RefundResult::from)                         // YES → trả kết quả cũ
        .orElseGet(() -> {
            Payment p = paymentRepo.findById(paymentId).orElseThrow();
            if (p.getStatus() == PaymentStatus.REFUNDED) {
                return RefundResult.alreadyRefunded(p);
            }
            p.setStatus(PaymentStatus.REFUNDED);
            refundRepo.save(new Refund(refundId, paymentId, p.getAmount()));
            return RefundResult.ok(p);                   // NO → xử lý refund
        });
}

refundId nên có unique constraint trong DB để hai request đồng thời không cùng insert. Cách làm idempotency key chi tiết đã có ở messaging 1.

Bước 4: Test

# Happy path
curl -X POST localhost:8080/checkout -H 'Content-Type: application/json' \
     -d '{"cartId":"CART-123"}'
# Hết hàng → saga CANCELLED, payment REFUNDED
curl localhost:8080/sagas/CHECKOUT-123
# {"status":"CANCELLED","failedStep":"RESERVE_INVENTORY"}

5. Cách hoạt động

             Orchestrator
                  │ create order
                  ▼
                Order ── OK ──┐
                              ▼
             Orchestrator ── charge() ──► Payment ── OK
                  │
                  │ reserve()
                  ▼
              Inventory ── FAIL
                  │
                  ▼
             Orchestrator
               /       \
              ▼         ▼
          Refund      Cancel
          Payment      Order

6. So sánh

Local @Transactional2PC / XASaga
Phạm vi1 DBNhiều resource hỗ trợ XANhiều service bất kỳ
ConsistencyStrong, atomicAtomicEventual
UndoROLLBACKROLLBACKCompensating transaction
LockNgắnGiữ lock qua mạng, blockingKhông giữ lock lâu
IsolationCóCóKhông: người khác thấy trạng thái trung gian

7. Khi nào nên dùng / không nên dùng

Trước khi qua, mình hỏi: nếu order-service chạy transaction chuyển Order sang CANCELLED nhưng bị lỗi thì sao?

Đúng, đây chính là điểm khiến Saga không đơn giản là "fail thì rollback".

Create Order       ✅
Charge Payment     ✅
Reserve Inventory  ❌
Refund Payment     ✅
Cancel Order       ❌

Order   = CREATED   ❌  ← inconsistent
Payment = REFUNDED  ✅
Stock   = NOT RESERVED

Saga đang ở trạng thái chưa hoàn tất. Không được coi Cancel fail một lần là xong.

1. Saga phải lưu state

Saga: CHECKOUT-123
Create Order       SUCCESS
Payment            SUCCESS
Inventory          FAILED
Refund Payment     SUCCESS
Cancel Order       FAILED   ← cần xử lý tiếp

2. Retry có backoff, và cancel phải idempotent

@Scheduled(fixedDelay = 10_000)
public void retryCompensations() {
    sagaRepo.findByStatusAndNextRetryAtBefore(SagaStatus.COMPENSATING, Instant.now())
        .forEach(saga -> {
            if (saga.getRetryCount() >= 10) {
                saga.setStatus(SagaStatus.COMPENSATION_FAILED);
                sagaRepo.save(saga);
                alertService.raise("Saga " + saga.getSagaId() + " requires attention");
                return;
            }
            orchestrator.compensate(saga);   // refund có idempotency key, gọi lại an toàn
        });
}

cancelOrder("ORDER-123") gọi 1 lần: CREATED → CANCELLED. Gọi lại 5 lần: vẫn CANCELLED, không có side effect linh tinh.

3. Retry mãi vẫn không được?

Cancel Order → retry 3 lần ❌ → retry background ❌
    ↓
DLQ / bảng failed task
    ↓
Alert: "Saga CHECKOUT-123 requires attention"
    ↓
Manual intervention: sửa nguyên nhân, replay bước lỗi

Distributed system phải chấp nhận: không phải lỗi nào cũng tự động recover được.

4. Business state rõ hơn: CANCELLING

PENDING
   ├── payment + inventory OK → CONFIRMED
   └── saga failed → CANCELLING → CANCELLED

Nếu cancel đang lỗi, Order = CANCELLING báo cho hệ thống: order này chưa hoàn tất hủy, đừng coi nó là order bình thường (không cho ship, không gửi email xác nhận). Đây cũng là một lý do Orchestration dễ dùng với flow phức tạp: orchestrator giữ rõ Saga state, biết đang fail ở bước nào.

Mình vẫn đang mơ hồ về Saga với Choreography và Orchestration:

  • Saga có bắt buộc phải có event không?
  • Choreography tức là các service cùng lắng nghe một queue à? Mình thấy kiểu này ở đâu đó trong Event Bus của Vert.x.

Bạn hiểu gần đúng, chỉ cần tách bạch: Saga, Choreography, Orchestration, event-driven và Event Bus có liên quan nhưng không phải cùng một thứ.

1. Saga trước tiên chỉ là một ý tưởng

Saga = business transaction đi qua nhiều service, mỗi service chạy local transaction; bước sau fail thì compensation các bước trước. Đến đây chưa hề yêu cầu Kafka, queue hay Event Bus.

2. Sau đó mới hỏi: ai điều khiển flow?

                    SAGA
                     │
             Ai điều khiển flow?
          ┌──────────┴──────────┐
          ▼                     ▼
   Choreography            Orchestration
   không có boss              có boss

Orchestration: có CheckoutSaga biết toàn bộ workflow và ra lệnh "Order, create đi → Payment, charge đi → Inventory, reserve đi → fail → Payment refund đi → Order cancel đi". Nó có thể gọi bằng HTTP, hoặc gửi command CHARGE_PAYMENT qua Kafka/RabbitMQ rồi nhận lại PAYMENT_SUCCEEDED. Vẫn là Orchestration.

Choreography: xóa CheckoutSaga. Order chỉ thông báo "tôi vừa tạo order":

Order
 │ ORDER_CREATED
 ▼
Payment       "Có order mới → việc của mình là charge"
 │ PAYMENT_SUCCEEDED
 ▼
Inventory     "Payment xong → mình reserve hàng"
 │ ❌ INVENTORY_FAILED
 ▼
Payment       "Inventory fail → mình refund"
 │ PAYMENT_REFUNDED
 ▼
Order → CANCELLED

Không có thằng nào đứng giữa. Mỗi service tự biết khi thấy event liên quan thì phải làm gì.

3. "Các service listen một queue?"

Gần đúng, nhưng đừng định nghĩa Choreography bằng queue. Concept là: service A publish event, các service quan tâm subscribe và react. Infrastructure có thể là Kafka, RabbitMQ, Event Bus... Và Vert.x Event Bus bạn nhớ đúng là cùng kiểu tư duy:

vertx.eventBus().consumer("order.created", msg -> handle(msg.body()));
vertx.eventBus().publish("order.created", order);

Nhưng Event Bus ≠ Saga, Kafka ≠ Saga. Chúng chỉ cung cấp event-driven communication. Ví dụ USER_LOGGED_IN → Audit, Metrics là event-driven nhưng không phải Saga, vì không có chuỗi bước cần compensation.

4. Cách nhớ

SAGA                                = WHAT  (local tx + compensation)
Choreography / Orchestration        = WHO controls the flow
HTTP / Kafka / RabbitMQ / Event Bus = HOW they communicate
ChoreographyOrchestration
Điều khiển trung tâmKhôngCó (orchestrator)
CommunicationEventsCommands (HTTP hoặc message) + replies
Ai biết flow tổng thểKhông ai, flow rải rác trong các serviceOrchestrator
Flow ngắn (2–3 bước)Rất hợpCó thể hơi dư
Flow dài, nhiều compensationKhó theo dõi, dễ có vòng eventDễ nhìn, dễ lưu state
DebugKhó, cần tracing tốtXem state của saga là biết

Không cái nào luôn tốt hơn. Order → Email thì Choreography tự nhiên. Order → Payment → Inventory → Shipping → Coupon → Loyalty nhiều compensation thì Orchestration (hoặc engine như Temporal, Camunda) dễ quản lý hơn.

Giả sử dùng RabbitMQ: event báo bước thành công và event báo bước thất bại là 2 message khác nhau, nhưng lại bị các consumer khác cũng lắng nghe. Làm sao lọc được nhỉ? Không lẽ tạo nhiều queue khác nhau?

Đúng, thường là nhiều queue. Không phải mọi consumer nghe chung một queue rồi tự if eventType.... RabbitMQ route message vào đúng queue trước bằng Topic Exchange + routing key + binding (chi tiết ở messaging 2).

                         checkout.events (Topic Exchange)
                               │
          ┌────────────────────┼─────────────────────┐
   payment.success      inventory.failed       payment.refunded
          ▼                    ▼                     ▼
   inventory.queue        payment.queue          order.queue
          ▼                    ▼                     ▼
      Inventory              Payment               Order
// Producer: không gửi thẳng tới consumer
rabbitTemplate.convertAndSend("checkout.events", "payment.success", event);

// Inventory chỉ nhận payment.success
@RabbitListener(queues = "inventory.queue")
public void onPaymentSuccess(PaymentSuccessEvent e) { reserveInventory(e.orderId()); }

// Payment chỉ nhận inventory.failed
@RabbitListener(queues = "payment.queue")
public void onInventoryFailed(InventoryFailedEvent e) { refund(e.paymentId()); }

Nếu Inventory, Notification, Analytics đều cần payment.success thì tạo 3 queue cùng bind key đó. Nhiều consumer trên một queue là competing consumers: mỗi message chỉ đến một consumer.

Điểm quan trọng: RabbitMQ không phải orchestrator. Nó chỉ biết "message này đi queue nào". Payment Service tự biết inventory.failed nghĩa là phải refund. Đó mới là lý do gọi là Choreography.

RabbitMQ:      "Event này đi đâu?"
Choreography:  "Service nhận event tự biết phải làm gì."
Orchestration: "Orchestrator bảo service tiếp theo phải làm gì."

Phỏng vấn hỏi Saga và distributed transaction thì trả lời sao?

Q1. Order, Payment, Inventory là 3 service. Payment thành công nhưng Inventory fail, xử lý thế nào?

🇻🇳 Tiếng Việt:

Mỗi service có DB riêng nên một local transaction không rollback được tất cả. Em dùng Saga: mỗi service chạy local transaction của mình, nếu bước sau fail thì chạy compensating transaction. Ví dụ Payment thành công mà Inventory fail thì refund payment và cancel order. Saga có thể làm theo choreography bằng event hoặc orchestration có coordinator. Compensation phải idempotent vì có thể bị retry.

🇬🇧 English:

Since these services have independent databases, a local transaction cannot roll back all of them. I would use the Saga pattern: each service performs its own local transaction, and if a later step fails we run compensating transactions. If payment succeeds but inventory reservation fails, we refund the payment and cancel the order. The Saga can use choreography with events or orchestration with a central coordinator. Compensations must be idempotent because they may be retried.

Q2. Vì sao không dùng 2PC?

🇻🇳 Tiếng Việt:

2PC là blocking: participant đã prepare phải giữ lock tới khi coordinator quyết định, coordinator chết là cả hệ thống đứng. Nó làm giảm availability, coupling chặt, và message broker hay API bên thứ ba thường không hỗ trợ XA. Saga đổi strong consistency lấy availability và eventual consistency.

🇬🇧 English:

2PC is blocking: prepared participants hold locks until the coordinator decides, and a coordinator failure can stall everyone. It reduces availability, couples services tightly, and brokers or third-party APIs usually don't support XA. Saga trades strong consistency for availability and eventual consistency.

Q3. Nếu chính compensating transaction cũng fail?

🇻🇳 Tiếng Việt:

Không giả định compensation luôn thành công. Saga phải lưu state, retry compensation với backoff, và compensation phải idempotent. Hết số lần retry thì chuyển sang COMPENSATION_FAILED, đưa vào DLQ, bắn alert để xử lý thủ công. Order nên có trạng thái trung gian như CANCELLING để hệ thống không coi nó là order bình thường.

🇬🇧 English:

We don't assume compensation always succeeds. The Saga persists its state and retries the compensation with backoff, and the compensation is idempotent. When retries are exhausted we mark it COMPENSATION_FAILED, move it to a DLQ and alert for manual intervention. The order should have an intermediate state such as CANCELLING so it isn't treated as a normal order.

Q4. Choreography và Orchestration khác nhau thế nào?

🇻🇳 Tiếng Việt:

Khác nhau ở ai điều khiển flow, không phải ở công nghệ. Choreography không có trung tâm, service publish event và service khác tự react; hợp với flow ngắn nhưng khó theo dõi khi flow dài. Orchestration có orchestrator ra lệnh từng bước và giữ saga state; dễ debug, dễ xử lý compensation, đổi lại orchestrator là thêm một thành phần cần vận hành.

🇬🇧 English:

The difference is who controls the flow, not the transport. In choreography there is no central coordinator: services publish events and others react, which fits short flows but gets hard to follow as the flow grows. In orchestration a coordinator commands each step and keeps the saga state, which makes debugging and compensation easier at the cost of another component to run.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao @Transactional trong order-service không rollback được payment_db?

Nó chỉ quản lý transaction trên connection DB của order-service. Payment Service có DB và transaction riêng, có thể đã commit rồi.

Compensating transaction khác rollback ở điểm nào?

Rollback hủy transaction chưa commit. Compensation là một transaction mới, đã commit, để undo về mặt business một transaction đã commit trước đó.

Vì sao refund phải có idempotency key?

Refund có thể đã thành công nhưng response bị mất; Saga retry sẽ hoàn tiền 2 lần nếu Payment không nhận ra request trùng.

Saga dùng HTTP thì có phải Orchestration không? Dùng Kafka thì có phải Choreography không?

Không nhất thiết. Communication không quyết định kiểu Saga; cái quyết định là có coordinator điều khiển flow hay không.

Vì sao Order nên có trạng thái CANCELLING?

Để biểu diễn "đang hủy nhưng chưa xong" khi compensation đang retry, tránh việc order bị ship hoặc xử lý như order bình thường.