Payment service báo đã trừ tiền. Inventory service không trả lời. Order API hết thời gian chờ nên client gửi lại yêu cầu. Mười phút sau, hai message đến sai thứ tự và nhân viên hỗ trợ hỏi một câu tưởng như rất đơn giản: đơn hàng này rốt cuộc đã được tạo hay chưa?

Trong một database, transaction cho câu hỏi đó một đáp án rõ ràng. Khi các service được deploy độc lập và sở hữu database riêng, không còn một điểm commit duy nhất, trừ khi mọi bên cùng chấp nhận một giao thức transaction phân tán. Phần lớn hệ thống cloud-native chủ động tránh kiểu ràng buộc ấy. Tuy nhiên, chúng vẫn phải đưa một thao tác nghiệp vụ đi qua partial failure mà không giả vờ network luôn đáng tin cậy.

Saga giải quyết bài toán này bằng một chuỗi transaction cục bộ có trạng thái bền vững. Mỗi bước commit trong service của nó rồi công bố kết quả đủ để workflow đi tiếp. Nếu bước sau không thể hoàn tất, saga chạy các hành động bù trừ cho những bước trước. Ta đổi isolation và rollback tự động lấy business semantics tường minh, khả năng phục hồi tiến trình và khả năng quan sát. Sự đánh đổi chỉ đáng giá khi được thiết kế chủ động, thay vì ẩn sau một chuỗi HTTP call.

Saga là một giao thức, không phải transaction kéo dài

Hãy xét luồng đặt hàng: Orders tạo đơn chờ xử lý, Inventory giữ hàng, Payments xác thực khoản tiền và Orders xác nhận giao dịch. Mỗi service sở hữu dữ liệu và commit cục bộ. Không database lock nào tồn tại xuyên suốt workflow có thể kéo dài vài giây, vài giờ, thậm chí vài ngày.

sequenceDiagram autonumber participant C as Client participant O as Order orchestrator participant I as Inventory participant P as Payments C->>O: PlaceOrder(orderId) O->>O: Tạo saga PENDING O->>I: Reserve(orderId, items) I-->>O: StockReserved(reservationId) O->>P: Authorize(orderId, amount) P-->>O: PaymentDeclined O->>I: Release(reservationId) I-->>O: StockReleased O->>O: Chuyển đơn thành CANCELLED O-->>C: Từ chối đơn hàng

Sơ đồ mô tả nhánh lỗi, nhưng happy path tuân theo cùng một giao thức: sau khi khoản tiền được xác thực, orchestrator xác nhận đơn và đánh dấu saga hoàn tất. Mỗi mũi tên phải đại diện cho một sự kiện được lưu bền vững hoặc một command idempotent, không phải callback tạm thời sẽ biến mất khi process restart.

Có hai cách phối hợp saga phổ biến. Với choreography, service phản ứng với event rồi phát event tiếp theo. Với orchestration, một coordinator bền vững quyết định command kế tiếp. Two-phase commit (2PC) là cơ chế khác: coordinator yêu cầu các participant chuẩn bị, sau đó ra lệnh cho tất cả cùng commit hoặc abort.

Thuộc tính Choreography saga Orchestration saga Two-phase commit
Control flow Phân tán trong các event handler Tường minh trong một workflow/state machine Transaction coordinator trung tâm
Hiển thị nguyên tử Không; trạng thái trung gian có thể bị quan sát Không; trạng thái trung gian có thể bị quan sát Hướng tới commit nguyên tử giữa các participant
Xử lý lỗi Event bù trừ Command bù trừ tường minh Abort ở cấp giao thức trước commit
Coupling Event schema và thứ tự ngầm Command cùng contract của coordinator Giao thức transaction và availability của participant
Khả năng vận hành Giảm nhanh khi event graph lớn Thường rõ nhất với workflow phức tạp Kết quả transaction rõ, hạ tầng khó hơn
Phù hợp nhất Luồng ngắn, mang tính phản ứng tự nhiên Luồng nhiều bước, có rẽ nhánh và phục hồi Môi trường nhỏ, kiểm soát được và thật sự cần atomicity

Choreography khá thanh thoát khi luồng ngắn: OrderPlaced dẫn tới StockReserved, rồi tới PaymentAuthorized. Nó trở nên khó kiểm soát khi cancel, deadline, nhánh điều kiện và can thiệp thủ công biến event graph thành một state machine ngầm. Orchestration làm state machine ấy hiện rõ, nên thường là lựa chọn an toàn hơn cho workflow nghiệp vụ quan trọng. Coordinator mới không sở hữu dữ liệu của participant; nó chỉ sở hữu policy, vì vậy Inventory và Payments vẫn tự chủ.

Bù trừ là đưa nghiệp vụ tiến về phía trước

Compensation không phải database rollback. Khi Inventory đã commit một lượt giữ hàng, transaction khác có thể quan sát nó. Khi Payments ghi nhận authorization, hệ thống audit và fraud có thể đã nhận sự kiện. Không thể xóa lịch sử đó một cách an toàn. Compensation tạo một transaction cục bộ mới với ý nghĩa nghiệp vụ đối nghịch bước trước: trả hàng về kho, void authorization, hoàn tiền hoặc ghi một bút toán điều chỉnh.

Khác biệt này quyết định thiết kế. Compensation có thể không khôi phục nguyên trạng: hoàn tiền không thể làm kiện hàng chưa từng được giao, hủy phòng khách sạn có thể vẫn mất phí. Nó cũng có thể lỗi và cần retry. Quan trọng hơn, compensation phải idempotent vì orchestrator có thể chết sau khi participant thành công nhưng trước khi ghi nhận kết quả. ReleaseReservation(reservationId) phải trả về thành công nếu lượt giữ hàng đã được giải phóng, không được cộng tồn kho lần thứ hai.

Hãy định nghĩa forward action, compensation, retry policy và điểm không thể quay lại cùng lúc:

Bước xuôi Hành động bù trừ Ranh giới quan trọng
Giữ hàng trong kho Giải phóng lượt giữ Lượt giữ hết hạn sau deadline
Authorize thẻ Void authorization Khoản đã capture có thể phải refund thay vì void
Tạo shipment Hủy shipment Sau khi hãng vận chuyển nhận hàng, phải chuyển sang quy trình hoàn trả
Xác nhận đơn Đánh dấu đã hủy kèm lý do Giữ nguyên lịch sử confirmed/cancelled

Một số workflow cần semantic lock để user hoặc workflow khác không xem dữ liệu trung gian là kết quả cuối. Đơn ở trạng thái PENDING_PAYMENT chưa được phép giao; số hàng đã reserve không dành cho đơn cạnh tranh; một lệnh chuyển tiền có thể hiển thị TRANSFER_PENDING thay vì số dư cuối gây hiểu nhầm. Đây là domain state được business rule cưỡng chế, không phải database mutex. Nó vẫn có nghĩa sau khi process restart và biến các isolation anomaly thành điều có thể nhìn thấy, kiểm soát được.

Warning

Đừng gọi compensation là rollback nếu nó không thật sự khôi phục một trạng thái chưa ai quan sát. Hãy mô hình hóa đúng điều nghiệp vụ có thể đảm bảo, nhất là sau khi tiền đã chuyển, message đã phát hoặc quy trình vật lý đã bắt đầu.

Thứ tự cũng quan trọng. Compensation thường chạy ngược, nhưng không nên đảo máy móc. Nếu authorization chưa rõ kết quả do request timeout, trước hết hãy hỏi Payments bằng operation key; phát lệnh refund một cách mù quáng có thể tạo thêm lỗi. Một số effect là pivot step: trước pivot, lỗi còn có thể bù trừ; sau pivot, workflow phải retry theo hướng tiến tới khi hoàn tất hoặc chuyển sang phục hồi thủ công.

Biến orchestrator thành state machine bền vững

Orchestrator phải có khả năng bắt đầu lại từ trạng thái đã lưu. Nó không giữ database transaction mở trong lúc gọi service. Thay vào đó, nó load một saga instance, thực hiện một remote operation idempotent, lưu kết quả bằng optimistic concurrency rồi lập lịch cho bước kế tiếp. Queue consumer, timer hoặc recovery scanner đều có thể gọi advance nhiều lần.

Ví dụ TypeScript sau phối hợp inventory và payment. Các port che giấu transport, còn operation key ổn định giúp mọi command có thể replay an toàn. Relative import có đuôi .js vì source dùng strict ESM.

ts
// src/order-saga/order-saga.ts
import { VersionConflictError } from './errors.js';
import type {
  Clock,
  InventoryClient,
  OrderClient,
  PaymentClient,
  SagaScheduler,
  SagaStore,
} from './ports.js';

type SagaState =
  | 'RESERVING_INVENTORY'
  | 'AUTHORIZING_PAYMENT'
  | 'CONFIRMING_ORDER'
  | 'RELEASING_INVENTORY'
  | 'CANCELLING_ORDER'
  | 'COMPLETED'
  | 'CANCELLED'
  | 'MANUAL_REVIEW';

interface OrderSaga {
  id: string;
  orderId: string;
  customerId: string;
  items: ReadonlyArray<{ sku: string; quantity: number }>;
  amountCents: number;
  state: SagaState;
  version: number;
  attempts: number;
  deadline: string;
  reservationId?: string;
  paymentAuthorizationId?: string;
  failure?: string;
}

type StepResult =
  | { kind: 'advanced'; saga: OrderSaga }
  | { kind: 'retry'; reason: string }
  | { kind: 'idle' };

export class OrderSagaOrchestrator {
  constructor(
    private readonly store: SagaStore<OrderSaga>,
    private readonly inventory: InventoryClient,
    private readonly payments: PaymentClient,
    private readonly orders: OrderClient,
    private readonly scheduler: SagaScheduler,
    private readonly clock: Clock,
  ) {}

  async advance(sagaId: string): Promise<void> {
    const saga = await this.store.load(sagaId);
    if (!saga || this.isTerminal(saga.state)) return;

    if (this.clock.now() >= new Date(saga.deadline)) {
      await this.persist(saga, this.onDeadline(saga));
      return;
    }

    const result = await this.runStep(saga);
    if (result.kind === 'idle') return;

    if (result.kind === 'retry') {
      const attempts = saga.attempts + 1;
      if (attempts >= 8) {
        await this.persist(saga, {
          ...saga,
          state: 'MANUAL_REVIEW',
          attempts,
          failure: result.reason,
        });
        return;
      }

      await this.store.save(
        { ...saga, attempts, failure: result.reason },
        saga.version,
      );
      await this.scheduler.after(saga.id, this.backoff(attempts));
      return;
    }

    await this.persist(saga, { ...result.saga, attempts: 0 });
    if (!this.isTerminal(result.saga.state)) {
      await this.scheduler.now(saga.id);
    }
  }

  private async runStep(saga: OrderSaga): Promise<StepResult> {
    try {
      switch (saga.state) {
        case 'RESERVING_INVENTORY': {
          const result = await this.inventory.reserve({
            operationKey: `${saga.id}:reserve`,
            orderId: saga.orderId,
            items: saga.items,
          });
          if (!result.accepted) {
            return {
              kind: 'advanced',
              saga: { ...saga, state: 'CANCELLING_ORDER', failure: 'out_of_stock' },
            };
          }
          return {
            kind: 'advanced',
            saga: {
              ...saga,
              reservationId: result.reservationId,
              state: 'AUTHORIZING_PAYMENT',
            },
          };
        }

        case 'AUTHORIZING_PAYMENT': {
          const result = await this.payments.authorize({
            operationKey: `${saga.id}:authorize`,
            customerId: saga.customerId,
            amountCents: saga.amountCents,
          });
          if (!result.authorized) {
            return {
              kind: 'advanced',
              saga: { ...saga, state: 'RELEASING_INVENTORY', failure: 'payment_declined' },
            };
          }
          return {
            kind: 'advanced',
            saga: {
              ...saga,
              paymentAuthorizationId: result.authorizationId,
              state: 'CONFIRMING_ORDER',
            },
          };
        }

        case 'CONFIRMING_ORDER':
          await this.orders.confirm({
            operationKey: `${saga.id}:confirm`,
            orderId: saga.orderId,
            paymentAuthorizationId: saga.paymentAuthorizationId!,
          });
          return { kind: 'advanced', saga: { ...saga, state: 'COMPLETED' } };

        case 'RELEASING_INVENTORY':
          await this.inventory.release({
            operationKey: `${saga.id}:release`,
            reservationId: saga.reservationId!,
          });
          return { kind: 'advanced', saga: { ...saga, state: 'CANCELLING_ORDER' } };

        case 'CANCELLING_ORDER':
          await this.orders.cancel({
            operationKey: `${saga.id}:cancel`,
            orderId: saga.orderId,
            reason: saga.failure ?? 'saga_cancelled',
          });
          return { kind: 'advanced', saga: { ...saga, state: 'CANCELLED' } };

        default:
          return { kind: 'idle' };
      }
    } catch (error) {
      return {
        kind: 'retry',
        reason: error instanceof Error ? error.message : 'unknown_error',
      };
    }
  }

  private onDeadline(saga: OrderSaga): OrderSaga {
    if (saga.state === 'RESERVING_INVENTORY') {
      return { ...saga, state: 'CANCELLING_ORDER', failure: 'deadline_exceeded' };
    }
    if (saga.state === 'AUTHORIZING_PAYMENT' && saga.reservationId) {
      return { ...saga, state: 'RELEASING_INVENTORY', failure: 'deadline_exceeded' };
    }
    return { ...saga, state: 'MANUAL_REVIEW', failure: 'deadline_after_pivot' };
  }

  private async persist(previous: OrderSaga, next: OrderSaga): Promise<void> {
    try {
      await this.store.save(next, previous.version);
    } catch (error) {
      if (!(error instanceof VersionConflictError)) throw error;
      await this.scheduler.now(previous.id);
    }
  }

  private backoff(attempt: number): number {
    const exponential = Math.min(1_000 * 2 ** attempt, 60_000);
    return exponential + Math.floor(Math.random() * 500);
  }

  private isTerminal(state: SagaState): boolean {
    return state === 'COMPLETED' || state === 'CANCELLED' || state === 'MANUAL_REVIEW';
  }
}

Ví dụ chủ động chỉ tiến một bước mỗi lần. Nếu process chết sau khi reserve thành công nhưng trước save, lần chạy sau gửi lại key ${saga.id}:reserve và nhận đúng reservation cũ. Participant phải lưu key cùng response một cách atomic trong local transaction. Inbox table loại command trùng; outbox table phát event kết quả mà không tạo dual-write gap.

Optimistic concurrency ngăn hai worker cùng đẩy một phiên bản saga đi tiếp. Nó không làm remote effect trở nên atomic với trạng thái coordinator, nên idempotency vẫn bắt buộc. Cũng không nên giữ worker thread chỉ để chờ backoff: hãy lưu nextAttemptAt hoặc lập lịch delayed message.

Xem sự không chắc chắn là một kết quả riêng

Retry chỉ đúng với lỗi tạm thời và chỉ an toàn khi operation idempotent. Dùng exponential backoff có jitter, giới hạn số lần thử tự động và phân biệt kết quả nghiệp vụ như payment_declined với dependency đang unavailable. Retry một lời từ chối nghiệp vụ chỉ làm tốn tài nguyên; bù trừ quá sớm sau một timeout tạm thời lại tạo công việc không cần thiết.

Timeout có nghĩa là chưa biết, không phải thất bại. Request có thể đã tới participant dù response không quay về. Mỗi participant nên cho phép truy vấn trạng thái bằng business identifier hoặc operation key để recovery hỏi: “Authorization saga-42:authorize đã xảy ra chuyện gì?” Câu trả lời có thể đưa workflow đi tiếp mà không cần đoán. Nếu không thể xác minh, hãy chuyển saga sang manual review thay vì phát các financial command mâu thuẫn.

Deadline thể hiện business policy: kho có thể giữ hàng mười lăm phút, còn đặt dịch vụ du lịch có thể chờ vài giờ. Hãy lưu mốc thời gian tuyệt đối trong saga state. Khi hết hạn, chọn transition dựa trên effect đã hoàn tất và pivot boundary, như ví dụ phía trên. HTTP timeout kỹ thuật ngắn hơn nhiều và chỉ điều khiển một attempt; nó không được âm thầm trở thành business deadline.

Quá trình phục hồi cần sweeper tìm saga chưa terminal nhưng heartbeat đã cũ hoặc đã tới giờ retry. Việc enqueue lại phải an toàn. Giữ terminal record đủ lâu cho deduplication và audit, sau đó archive theo retention policy. Dead-letter queue chỉ là lưới an toàn ở tầng transport, không phải hồ sơ workflow; operator cần saga state và business context để xử lý.

Tip

Hãy thiết kế thao tác hỗ trợ trước khi lên production: retry bước hiện tại, xác nhận một external effect đã được kiểm chứng, bắt đầu bù trừ hoặc chuyển cấp xử lý. Mỗi thao tác phải ghi audit và kiểm tra đúng phiên bản saga.

Observability, versioning và test đều thuộc về tính đúng đắn

Mỗi saga cần sagaId duy nhất, được truyền qua log, trace, command, event và màn hình hỗ trợ cùng business key như orderId. Ghi transition dưới dạng structured event với fromState, toState, step, attempt, duration và loại lỗi đã loại bỏ dữ liệu nhạy cảm. Metric hữu ích gồm tuổi của saga active lâu nhất, số lượng theo state, retry theo từng bước, deadline bị vượt, tỷ lệ compensation và backlog manual review. Alert theo tiến độ bị kẹt, không chỉ theo từng HTTP error.

Instance dài hạn sẽ sống qua nhiều lần deploy, vì vậy workflow definition cần version. Lưu definitionVersion ngay khi saga bắt đầu. Duy trì handler cho các version còn active hoặc viết migration tường minh để ánh xạ state và payload cũ sang model mới. Chỉ đổi tên AUTHORIZING_PAYMENT trong code mà không migrate dữ liệu có thể làm mọi đơn đang chạy mắc kẹt. Event và command schema cũng phải tiến hóa theo hướng cộng thêm: bỏ qua field chưa biết, giữ default và không tái sử dụng ý nghĩa cũ cho mục đích mới.

Hãy test state machine như một hệ transition, không chỉ đi qua happy-path API. Test dạng bảng cần bao phủ mọi state và kết quả participant. Sau đó inject lỗi ở từng ranh giới: trước remote call, sau khi participant commit, trước khi coordinator persist, sau persist nhưng trước scheduling và trong lúc compensation. Kiểm tra delivery trùng hoặc sai thứ tự, worker chạy đồng thời, deadline hết hạn, retry cạn, workflow version cũ và restart từ mọi persisted state. Property-based test có thể kiểm tra invariant như “saga đã cancel không còn reservation active” và “saga hoàn tất có đúng một payment authorization identity”.

Trong integration test, dùng persistence thật cho inbox, outbox và optimistic version check. Fake client hữu ích để transition có tính xác định, nhưng không phát hiện được lỗi ở transaction boundary. Một failure-injection suite nhỏ thường tìm được nhiều lỗi hơn một tập end-to-end test chỉ đi theo happy path.

Biết lúc không nên dùng saga

Đừng dùng saga để biện minh cho service boundary tùy tiện. Nếu thao tác đòi hỏi invariant nghiêm ngặt trên dữ liệu luôn thay đổi cùng nhau, hãy giữ dữ liệu trong một transactional boundary, có thể là một modular monolith. Database transaction đơn giản hơn, nhanh hơn và có isolation tốt hơn. Tương tự, chỉ dùng 2PC khi database cùng hạ tầng hỗ trợ ổn định, các participant nằm trong quyền kiểm soát, transaction ngắn và đánh đổi availability là chấp nhận được.

Saga cũng không phù hợp khi không có compensation hay forward-recovery policy trung thực, trong khi việc lộ trạng thái một phần vi phạm yêu cầu cứng. Financial ledger thường dùng bút toán cân bằng append-only, nhưng một số thao tác pháp lý phải được system of record tiếp nhận atomically. Hành động vật lý có thể vượt qua điểm phần mềm không thể đảo ngược. Khi đó, hãy thiết kế lại workflow quanh reservation, approval, escrow hoặc checkpoint có con người trước bước không thể hoàn tác.

Đừng xây orchestrator cho một notification fire-and-forget duy nhất. Cũng đừng chọn choreography chỉ để né việc chỉ rõ ai sở hữu một quy trình phức tạp. Kiểu phối hợp phải đi theo vòng đời nghiệp vụ và failure mode, không theo trào lưu kiến trúc.

Tổng kết

  • Saga là chuỗi local commit bền vững, không phải ACID transaction bị kéo giãn qua nhiều service.
  • Compensation là hành động nghiệp vụ mới. Nó phải tường minh, idempotent, quan sát được và có thể thất bại một cách an toàn.
  • Semantic lock biểu diễn sự thật trung gian bằng domain state thay vì database lock sống lâu.
  • Hãy persist state machine, dùng operation key ổn định, truy vấn kết quả chưa rõ và tách attempt timeout khỏi business deadline.
  • Version workflow cùng schema vì code mới được deploy trong khi saga instance cũ vẫn còn sống.
  • Test mọi transition và crash boundary, đồng thời cung cấp thao tác recovery có audit cho operator.
  • Ưu tiên một local transaction khi dữ liệu và invariant tự nhiên thuộc về nhau. Saga là chi phí có chủ đích để đổi lấy tính tự chủ, không phải mặc định của hệ phân tán.