Một đơn hàng tưởng đơn giản nhưng đụng tới ba thứ: trừ kho, trừ tiền, tạo đơn. Trong kiến trúc microservice, ba việc này nằm ở ba service với ba database riêng — và đây là lúc một sự thật phũ phàng lộ ra: không có transaction nào trải được qua nhiều database. Bạn không thể BEGIN; trừ kho; trừ tiền; tạo đơn; COMMIT khi ba bước ở ba nơi. Nếu trừ kho xong, trừ tiền lỗi (hết tiền), thì kho đã bị trừ sai — một món hàng "biến mất" khỏi kho mà không có đơn nào. Transaction phân tán kiểu 2PC (two-phase commit) tồn tại nhưng chậm, phức tạp và khoá tài nguyên lâu. Lời giải thực dụng hơn là saga pattern: chuỗi các bước cục bộ, mỗi bước kèm một bước bù trừ (compensating) để "gỡ" lại khi có lỗi. Bài này (phần 10 loạt Message Queue) chạy thật một saga thành công và một saga lỗi để thấy cơ chế bù trừ.

Saga: chuỗi bước cục bộ + bước bù trừ

Ý tưởng: thay vì một transaction nguyên tử lớn (bất khả qua nhiều service), chia thành nhiều transaction cục bộ nhỏ, mỗi cái trong một service/database và commit ngay. Nhưng vì mỗi bước commit riêng, không có rollback tự động nếu bước sau lỗi. Nên mỗi bước phải định nghĩa một bước bù trừ — thao tác đảo ngược hiệu ứng của nó:

  • bước 1: trừ kho ↔ bù trừ: hoàn kho
  • bước 2: trừ tiền ↔ bù trừ: hoàn tiền
  • bước 3: tạo đơn ↔ bù trừ: huỷ đơn

Luồng chạy:

  • Forward (thành công): chạy bước 1 → 2 → 3, mọi bước OK → saga hoàn tất.
  • Rollback (lỗi): nếu bước k lỗi, chạy bù trừ của các bước đã làm (k−1, k−2, ... 1) theo thứ tự ngược → đưa hệ về trạng thái nhất quán.

Điểm khác biệt cốt lõi so với transaction DB: saga không có rollback tự động. Nó đạt nhất quán bằng cách chủ động gỡ từng bước — và đây là nhất quán cuối cùng (eventual consistency), không phải nguyên tử tức thời.

-- bước 2 chỉ thành công khi đủ tiền (điều kiện trong WHERE):
UPDATE payment SET balance = balance - 100
  WHERE acc = 'userB' AND balance >= 100;   -- 0 dòng đổi = lỗi
-- nếu 0 dòng → bước 2 thất bại → chạy bù trừ bước 1:
UPDATE inventory SET qty = qty + 1 WHERE item = 'book';  -- hoàn kho

Ảnh chụp code nền tối saga giao dịch phân tán bằng bước bù trừ, vấn đề đặt hàng trải 3 service không transaction chung trừ kho service A cộng trừ tiền service B cộng tạo đơn C nếu trừ tiền lỗi sau khi đã trừ kho thì kho bị sai, saga chuỗi bước cục bộ cộng bước bù trừ compensating bước 1 trừ kho bù hoàn kho bước 2 trừ tiền bù hoàn tiền bước 3 tạo đơn bù huỷ đơn forward chạy 1 2 3 mọi bước OK xong rollback bước k lỗi chạy bù k-1 k-2 ngược lại, demo thật pg-lab 3 bảng bước 2 chỉ thành công khi đủ tiền UPDATE payment balance trừ 100 WHERE balance lớn hơn bằng 100 0 dòng là lỗi lỗi thì bù trừ UPDATE inventory qty cộng 1

Hình 1: Saga chia giao dịch phân tán thành chuỗi bước cục bộ, mỗi bước có bước bù trừ đảo ngược. Forward chạy xuôi khi mọi bước OK; rollback chạy các bù trừ ngược lại khi một bước lỗi. Bước trừ tiền dùng điều kiện balance >= 100 trong WHERE — 0 dòng đổi nghĩa là thất bại, kích hoạt bù trừ.

Đo thật: saga thành công và saga lỗi tự bù trừ

Mình dựng ba bảng trên pg-lab (inventory, payment, orders) mô phỏng ba service, trạng thái ban đầu: kho book=10, userA=500, userB=20, 0 đơn, giá mỗi món = 100. Rồi chạy hai saga:

Ảnh chụp output thật nền tối saga thành công và saga lỗi tự bù trừ pg-lab PostgreSQL 16 3 bảng, ban đầu kho 10 userA 500 userB 20 đơn 0 giá 100, saga 1 thành công userA đủ tiền trừ kho trừ tiền tạo đơn mọi bước OK kết quả kho 9 userA 400 đơn 1 khớp, saga 2 lỗi ở bước tiền userB 20 nhỏ hơn 100 bước 1 trừ kho 9 sang 8 bước 2 trừ tiền 0 dòng đổi thất bại bù trừ hoàn kho 8 sang 9 không tạo đơn kết quả kho 9 userB 20 đơn 1 về trạng thái trước saga 2, không có rollback tự động như 1 transaction DB saga tự gỡ từng bước đã làm để hệ trở lại nhất quán

Hình 2: Kết quả thật. Saga 1 (userA đủ tiền): trừ kho → trừ tiền → tạo đơn, mọi bước OK → kho=9, userA=400, đơn=1 (khớp). Saga 2 (userB chỉ có 20 < 100): bước 1 trừ kho 9→8, bước 2 trừ tiền trả về 0 dòng (thất bại) → chạy bù trừ hoàn kho 8→9 và không tạo đơn → kho=9, userB=20, đơn=1 (về đúng trạng thái trước saga 2).

Đọc kết quả:

  • Saga 1 — thành công: userA có 500, đủ trả 100. Cả ba bước chạy xuôi: kho 10→9, tiền 500→400, tạo 1 đơn. Trạng thái cuối nhất quán hoàn hảo — đúng như một giao dịch thành công.
  • Saga 2 — lỗi giữa chừng, tự bù trừ: userB chỉ có 20, không đủ 100. Bước 1 (trừ kho) đã chạy → kho 9→8. Bước 2 (trừ tiền) dùng WHERE balance >= 100 → 0 dòng đổi = thất bại. Lúc này nếu không làm gì, kho sẽ kẹt ở 8 — sai, vì không có đơn nào tương ứng. Saga chạy bù trừ bước 1: hoàn kho 8→9. Không tạo đơn. Kết quả: kho=9, userB=20 (nguyên vẹn), đơn=1 (vẫn chỉ đơn của saga 1). Toàn hệ về đúng trạng thái trước khi saga 2 bắt đầu — nhất quán được khôi phục thủ công bằng bù trừ.

Thông điệp cốt lõi: saga đạt nhất quán không bằng rollback tự động mà bằng việc chủ động gỡ từng bước. Đây là đánh đổi nền tảng: bạn từ bỏ tính nguyên tử tức thời của transaction đơn để có khả năng phối hợp giao dịch qua nhiều service — thứ mà transaction DB không làm được.

Orchestration vs Choreography

Saga có hai cách điều phối, và đây là nơi message queue vào cuộc. Orchestration: một "điều phối viên" trung tâm (orchestrator) ra lệnh từng bước và quyết định khi nào bù trừ — dễ theo dõi, dễ debug, nhưng tạo một điểm tập trung. Choreography: không có trung tâm; mỗi service lắng nghe event và tự quyết hành động tiếp theo (service kho nghe "đơn được tạo" → trừ kho → phát event "kho đã trừ" → service thanh toán nghe tiếp...). Choreography phi tập trung, hợp kiến trúc event-driven, nhưng khó theo dõi luồng tổng thể (logic saga rải khắp các service). Với saga choreography, Kafka là xương sống — mỗi bước và mỗi bù trừ là một event. Chọn orchestration khi cần kiểm soát/quan sát rõ ràng; choreography khi muốn các service độc lập tối đa.

Đánh đổi cần cân nhắc

Không có cô lập (isolation) — trạng thái trung gian lộ ra ngoài. Transaction DB có chữ "I" trong ACID: người khác không thấy trạng thái dở dang. Saga không có điều đó. Trong demo, giữa bước 1 và bù trừ của saga 2, kho thực sự bằng 8 trong một khoảng thời gian — và một truy vấn khác có thể đọc thấy 8 đó. Điều này tạo bất thường (ví dụ hai saga cùng đọc kho rồi cùng trừ). Phải thiết kế để chịu được: dùng khoá nghiệp vụ, trạng thái "đang xử lý" (pending), hoặc chấp nhận nhất quán cuối cùng. Thiếu isolation là cái giá lớn nhất của saga.

Bước bù trừ phải idempotent và không được thất bại. Nếu bù trừ cũng lỗi (ví dụ hoàn kho nhưng DB kho đang sập), saga kẹt ở trạng thái không nhất quán — tệ hơn cả không có saga. Nên bù trừ phải luôn thành công cuối cùng: retry với backoff, và idempotent (chạy hai lần không hại — liên hệ bài 4). Một số thao tác không thể bù trừ (đã gửi email, đã chuyển tiền ra ngoài) — với chúng, đặt bước đó cuối cùng trong saga, hoặc dùng bước "ghi chú/hoàn tác nghiệp vụ" thay vì xoá thật.

Saga phức tạp hơn nhiều so với transaction — chỉ dùng khi buộc phải. Nếu giao dịch của bạn gói gọn trong một database, dùng transaction DB thường — nó nguyên tử, cô lập, đơn giản và đúng. Saga chỉ cần khi giao dịch thực sự trải nhiều service/database. Nhiều hệ chia nhỏ microservice quá sớm rồi phải dùng saga cho thứ lẽ ra là một transaction đơn — tự tạo phức tạp không cần thiết. Trước khi viết saga, hỏi: việc này có bắt buộc phải qua nhiều database không?

Ba ý mang về

  1. Saga thay transaction phân tán bằng bước cục bộ + bù trừ: đo thật saga thành công trừ kho/tiền và tạo đơn khớp (kho 9, userA 400, đơn 1); không có transaction nào trải qua nhiều database nên saga chia nhỏ và tự phối hợp.
  2. Lỗi giữa chừng được gỡ bằng bù trừ, không phải rollback tự động: đo thật saga 2 lỗi ở bước tiền (userB 20<100) sau khi đã trừ kho 9→8 → chạy bù trừ hoàn kho về 9, không tạo đơn → hệ về đúng trạng thái trước saga, nhất quán cuối cùng được khôi phục thủ công.
  3. Saga đổi isolation và đơn giản lấy khả năng phối hợp đa service: trạng thái trung gian lộ ra ngoài (không có "I" của ACID), bù trừ phải idempotent và không được thất bại, và saga phức tạp hơn transaction — chỉ dùng khi giao dịch buộc phải trải nhiều database.

Nguồn

Phần sau ta xử lý bài toán tiến hoá message theo thời gian: schema evolution — thêm/bớt trường mà không làm hỏng consumer cũ, demo thật tương thích ngược và xuôi giữa các phiên bản message.