Saga Pattern: quản lý transaction phân tán trong microservices — hoatq.dev

cat blog/.md

Saga Pattern: quản lý transaction phân tán trong microservices

date: tags: backend, microservices, design-pattern, event-driven, distributed-systems

Hồi còn làm monolith, viết một flow đặt hàng đơn giản như uống nước: mở transaction, trừ kho, tạo order, trừ ví, commit. Có lỗi ở bước nào thì rollback, không ai biết gì.

Sang microservices, mọi thứ vỡ vụn. Kho ở service A, order ở service B, ví ở service C — ba database riêng. Không có cái BEGIN TRANSACTION nào trải qua được ba service đó. Và nếu bạn cố làm two-phase commit (2PC) thì… chúc may mắn, vì hiệu năng tệ, coupling cao, và đa số message broker không hỗ trợ.

Cách công nghiệp đã chọn là Saga Pattern: chia một business transaction lớn thành chuỗi local transaction nhỏ ở từng service, và nếu một bước fail thì chạy compensating action để hoàn tác các bước trước.

Saga là gì

Định nghĩa kinh điển (Garcia-Molina & Salem, 1987): một saga là chuỗi T1, T2, ..., Tn, mỗi Ti là local transaction. Với mỗi Ti ta có Ci là compensating transaction — nó logically undo Ti.

“Logically” rất quan trọng. Bạn không rollback DB nữa, vì bước T1 đã commit từ lâu. Bạn phải viết một action ngược lại: nếu T1 là “trừ 100k khỏi ví”, thì C1 là “cộng lại 100k vào ví”.

Hai nguyên tắc bất di:

  1. Mỗi local transaction phải atomic trong service của nó (dùng DB transaction bình thường).
  2. Compensating action phải idempotent — vì retry là chuyện bình thường trong hệ phân tán.

Hai cách triển khai: Orchestration và Choreography

Choreography — không có nhạc trưởng

Mỗi service phát ra event sau khi xong việc của mình. Các service khác lắng nghe và phản ứng. Không có “saga manager” tập trung.

OrderService     →  emit  order.created
PaymentService   ←  consume order.created  →  charge  →  emit payment.completed
InventoryService ←  consume payment.completed → reserve → emit inventory.reserved
ShippingService  ←  consume inventory.reserved → ship  → emit order.shipped

Nếu InventoryService fail: nó phát inventory.reservation_failed. PaymentService lắng nghe event này và tự refund. OrderService lắng nghe và đổi status order về cancelled.

Ưu điểm: đơn giản, loose coupling, dễ thêm service mới. Nhược điểm: khi saga dài, không ai nhìn được toàn cảnh. Debug như đi tìm kim trong rừng event. Cyclic dependency dễ phát sinh.

Orchestration — có nhạc trưởng

Một service riêng (Orchestrator) điều phối toàn bộ saga. Nó gọi từng service theo thứ tự, theo dõi state, và quyết định compensate khi cần.

Orchestrator → OrderService.create()        →  ok
Orchestrator → PaymentService.charge()      →  ok
Orchestrator → InventoryService.reserve()   →  FAIL
Orchestrator → PaymentService.refund()      ← compensate
Orchestrator → OrderService.cancel()        ← compensate

Ưu điểm: flow nằm gọn một chỗ, dễ đọc, dễ monitor, dễ test. Nhược điểm: thêm một service nữa phải bảo trì. Orchestrator có nguy cơ thành “god service” nếu không kỷ luật.

Kinh nghiệm cá nhân: với saga > 3 bước, hoặc cần audit/compensate phức tạp, mình chọn Orchestration. Choreography hợp với flow ngắn 2-3 bước.

Ví dụ Orchestration với FastAPI

Bảng state để Orchestrator lưu tiến độ. Đây là phần quan trọng — nếu Orchestrator restart giữa chừng, nó phải biết đang ở đâu.

CREATE TABLE saga_state (
    id            UUID PRIMARY KEY,
    saga_type     TEXT NOT NULL,
    payload       JSONB NOT NULL,
    current_step  TEXT NOT NULL,
    status        TEXT NOT NULL,  -- running | completed | compensating | failed
    completed_steps JSONB NOT NULL DEFAULT '[]'::jsonb,
    created_at    TIMESTAMPTZ DEFAULT NOW(),
    updated_at    TIMESTAMPTZ DEFAULT NOW()
);

Định nghĩa các bước. Mỗi step có action (làm xuôi) và compensation (làm ngược):

from dataclasses import dataclass
from typing import Callable, Awaitable

@dataclass
class SagaStep:
    name: str
    action: Callable[[dict], Awaitable[dict]]
    compensation: Callable[[dict], Awaitable[None]]


async def charge_payment(ctx: dict) -> dict:
    res = await payment_client.charge(
        order_id=ctx["order_id"],
        amount=ctx["amount"],
        idempotency_key=f"saga-{ctx['saga_id']}-charge",
    )
    return {"payment_id": res["id"]}


async def refund_payment(ctx: dict) -> None:
    await payment_client.refund(
        payment_id=ctx["payment_id"],
        idempotency_key=f"saga-{ctx['saga_id']}-refund",
    )

Chú ý: idempotency_key gắn với saga_id + tên thao tác. Nhờ vậy retry không gây side effect lặp — xem thêm bài về Idempotency Key.

Orchestrator chạy saga:

async def run_saga(saga_id: UUID, steps: list[SagaStep], payload: dict) -> None:
    ctx = {"saga_id": str(saga_id), **payload}
    completed: list[str] = []

    try:
        for step in steps:
            await update_state(saga_id, current_step=step.name, status="running")
            result = await step.action(ctx)
            ctx.update(result)
            completed.append(step.name)
            await update_state(saga_id, completed_steps=completed)

        await update_state(saga_id, status="completed")

    except Exception as e:
        log.error("saga_failed", saga_id=str(saga_id), step=step.name, error=str(e))
        await update_state(saga_id, status="compensating")
        await compensate(saga_id, steps, completed, ctx)
        await update_state(saga_id, status="failed")
        raise


async def compensate(
    saga_id: UUID,
    steps: list[SagaStep],
    completed: list[str],
    ctx: dict,
) -> None:
    by_name = {s.name: s for s in steps}
    for name in reversed(completed):
        step = by_name[name]
        # Retry compensation đến khi xong — compensation phải idempotent
        for attempt in range(5):
            try:
                await step.compensation(ctx)
                break
            except Exception as e:
                wait = 2 ** attempt
                log.warning("compensation_retry", step=name, attempt=attempt, wait=wait)
                await asyncio.sleep(wait)
        else:
            # Hết retry vẫn fail — báo alert cho ops, không nuốt im lặng
            await alert_ops(saga_id, name)

Vài điểm cần để ý:

  • Compensate chạy ngược thứ tự. Đặt vé → thanh toán → trừ kho thì hoàn tác là trả kho → refund → huỷ vé.
  • Mỗi update state là local transaction. Đừng update sau khi gọi remote service xong nhưng trước khi commit — nếu Orchestrator crash đúng khoảnh khắc đó, state sai.
  • Compensation không được fail vĩnh viễn. Nếu refund không nổi sau N retry, phải alert con người. Đừng âm thầm bỏ qua — đó là tiền khách hàng.

Những tình huống dễ sai

Sai 1: compensation không idempotent

async def refund_payment(ctx: dict) -> None:
    payment = await db.get_payment(ctx["payment_id"])
    payment.balance += payment.amount  # nếu gọi 2 lần, cộng 2 lần
    await db.save(payment)

Fix: dùng idempotency key, hoặc check trạng thái trước khi compensate (if payment.status != 'refunded').

Sai 2: không có timeout cho mỗi step

Một service treo 30 phút, Orchestrator treo theo. Saga state mắc kẹt ở running. Luôn đặt timeout cho action, và có cơ chế “saga timeout” — sau X phút mà saga chưa xong, đánh dấu fail và compensate.

Sai 3: side effect không thể compensate

Bước “gửi email cho khách” không có cách rollback. Nếu nó nằm giữa saga, bước sau fail thì email vẫn đã gửi.

Giải pháp: đặt các bước không thể compensatecuối saga (gọi là pivot transaction — điểm không quay lại). Trước nó là các bước có thể rollback, sau nó là các bước “best effort” mà nếu fail thì chỉ retry, không undo.

Sai 4: isolation — saga thấy trạng thái dở dang

Saga không có isolation như ACID transaction. Trong lúc saga đang chạy, một query khác có thể thấy “order đã tạo, payment chưa trừ”. Đây là lack of isolation đặc trưng của saga.

Cách giảm đau: thêm trạng thái trung gian (PENDING, RESERVED) ở mỗi service. UI/API filter chúng ra cho đến khi saga complete. Hoặc dùng semantic lock — đánh dấu bản ghi đang trong saga để các saga khác không động vào.

Khi nào KHÔNG dùng saga

  • Flow chỉ nằm trong 1 service: dùng DB transaction bình thường, đừng vẽ vời.
  • Flow gồm read-only: không cần saga, chỉ cần aggregation.
  • Có thể đảo flow để eventual consistency không cần compensate: nhiều khi chỉ cần publish event đúng cách (Outbox Pattern) là đủ, không cần saga đầy đủ.

Saga là công cụ mạnh nhưng chi phí lập trình và vận hành không nhỏ. Đừng kéo nó ra cho flow 2 bước chỉ vì nghe “phân tán”.

Kết

Saga không loại bỏ được lỗi — nó cho bạn một cách có cấu trúc để xử lý lỗi giữa nhiều service. Hai điều quan trọng nhất cần khắc cốt ghi tâm:

  1. Compensation phải idempotent và phải retry tới cùng (kèm alert nếu hết retry).
  2. State của saga phải persist ở mỗi bước — Orchestrator có thể chết bất cứ lúc nào.

Lần tới khi bạn thấy code có ba lần await xxx_client.do(...) xếp hàng và một cái try/except để gọi rollback thủ công ở cuối — đó là saga viết tay, chưa đặt tên. Đặt tên cho nó, cho nó state, cho nó retry, và bạn sẽ ngủ ngon hơn vào những đêm production lúc 3h sáng.

// reactions


cat comments.log


hoatq@dev : ~/blog $