Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 25 — Saga Pattern — Distributed Transaction

Architecture Decision-Making Bài 25/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn đang xây hệ thống đặt hàng cho một sàn thương mại điện tử như Tiki hay Shopee. Khi khách bấm "Đặt hàng", có ba việc phải xảy ra gần như đồng thời: tạo đơn hàng (Order Service), trừ tiền hoặc giữ tiền (Payment Service), và trừ tồn kho (Inventory Service). Trong một hệ thống monolith cũ, cả ba việc này nằm trong cùng một database, bạn chỉ cần bọc chúng trong một BEGIN TRANSACTION ... COMMIT là xong. Hoặc tất cả thành công, hoặc tất cả rollback. Sạch sẽ.

Nhưng khi bạn đã tách thành microservices — mỗi service có database riêng (đây là nguyên tắc "database per service") — thì cái transaction "phép màu" đó biến mất. Order nằm ở database A, Payment ở database B, Inventory ở database C. Không có một cái COMMIT nào ôm được cả ba database thuộc ba service khác nhau. Vậy điều gì xảy ra khi bạn đã trừ tiền của khách thành công, nhưng lúc trừ tồn kho thì phát hiện hết hàng? Khách bị trừ tiền mà không có hàng — một thảm họa về niềm tin và cả về mặt pháp lý.

Đây chính là bài toán distributed transaction (giao dịch phân tán). Và Saga Pattern là câu trả lời thực tế nhất, được dùng rộng rãi nhất cho bài toán này. Nếu bạn muốn thiết kế hệ thống microservices nghiêm túc, đây là một trong những pattern bạn buộc phải nắm vững — không chỉ để code, mà để ra quyết định kiến trúc đúng đắn.

Khái niệm cốt lõi

Tại sao không dùng 2PC (Two-Phase Commit)?

Trước khi nói về Saga, phải hiểu vì sao giải pháp "kinh điển" là 2PC lại không được ưa chuộng.

2PC hoạt động qua hai pha: pha "prepare" (điều phối viên hỏi tất cả service "các anh sẵn sàng commit chưa?") và pha "commit" (nếu tất cả gật đầu, ra lệnh commit đồng loạt). Nghe thì hợp lý, nhưng nó có ba vấn đề chí mạng trong môi trường microservices:

  • Blocking (khóa tài nguyên): Trong lúc chờ điều phối viên ra lệnh, mỗi service phải giữ khóa (lock) trên dữ liệu của mình. Nếu điều phối viên chết, các service bị treo khóa vô thời hạn. Với hệ thống lưu lượng cao, đây là án tử cho hiệu năng.
  • Chậm: Phải chờ round-trip qua nhiều service, độ trễ tăng theo mắt xích chậm nhất.
  • Brittle (mong manh): Điều phối viên là single point of failure. Nhiều database hiện đại (đặc biệt NoSQL) còn không hỗ trợ giao thức XA cần cho 2PC.
Tóm lại, 2PC ưu tiên consistency tuyệt đối nhưng đánh đổi bằng availabilityhiệu năng — điều mà hệ thống thương mại điện tử quy mô lớn không chấp nhận được.

Saga là gì?

Saga là một chuỗi các local transaction (giao dịch cục bộ). Mỗi bước trong chuỗi là một transaction bình thường bên trong một service, commit ngay lập tức vào database của service đó. Sau khi một bước hoàn tất, nó phát ra một sự kiện hoặc gọi trực tiếp để kích hoạt bước tiếp theo.

Điểm mấu chốt: Saga không có rollback theo kiểu database. Vì mỗi bước đã commit rồi, bạn không thể "undo" bằng lệnh rollback. Thay vào đó, khi một bước thất bại, Saga chạy các compensating transaction (giao dịch bù trừ) — tức là các hành động nghiệp vụ đảo ngược lại tác động của những bước đã thành công trước đó.

Ví dụ với đơn hàng:

  • Bước 1: Tạo đơn (trạng thái PENDING) → bù trừ: Hủy đơn
  • Bước 2: Giữ tiền → bù trừ: Hoàn tiền
  • Bước 3: Trừ tồn kho → bù trừ: Cộng lại tồn kho
Nếu bước 3 thất bại (hết hàng), Saga chạy compensation của bước 2 (hoàn tiền) và bước 1 (hủy đơn). Kết quả cuối cùng: hệ thống trở về trạng thái nhất quán về mặt nghiệp vụ. Đây gọi là eventual consistency — không nhất quán tức thời, nhưng sẽ nhất quán sau một khoảng thời gian ngắn.

Hai kiểu điều phối Saga: Choreography vs Orchestration

Đây là quyết định kiến trúc quan trọng nhất khi triển khai Saga.

Choreography (vũ đạo — phân tán): Không có "nhạc trưởng". Mỗi service lắng nghe sự kiện của service khác và tự quyết định hành động. Order Service phát OrderCreated → Payment Service nghe được, giữ tiền, phát PaymentReserved → Inventory Service nghe được, trừ kho, phát StockReserved → Order Service nghe được, chuyển đơn sang CONFIRMED.

  • Ưu điểm: Loose coupling, không có điểm tập trung, dễ thêm service mới.
  • Nhược điểm: Khó theo dõi luồng ("cyclic dependency" — ai gọi ai rất rối), khó debug khi có nhiều service, logic bù trừ rải rác khắp nơi.
Orchestration (nhạc trưởng — tập trung): Có một Saga Orchestrator đóng vai nhạc trưởng, ra lệnh cho từng service theo thứ tự và xử lý bù trừ khi có lỗi. Orchestrator gọi Payment → nếu OK gọi Inventory → nếu lỗi thì gọi hoàn tiền.

  • Ưu điểm: Luồng nghiệp vụ tập trung một chỗ, dễ đọc, dễ theo dõi, dễ xử lý lỗi phức tạp.
  • Nhược điểm: Orchestrator có thể trở thành nút thắt logic, cần đầu tư xây dựng và vận hành state machine.
Quy tắc ngón tay cái: Saga đơn giản 2-4 bước dùng Choreography; Saga phức tạp, nhiều nhánh, nhiều điều kiện thì dùng Orchestration.

Tình huống thực tế

Ví dụ 1 — Sàn TMĐT Việt Nam: đơn hàng bị "kẹt tiền"

Một startup TMĐT ở TP.HCM (giả định gọi là "MuaNhanh") tách hệ thống thành Order, Payment, Inventory theo microservices. Ban đầu họ dùng cách gọi HTTP đồng bộ tuần tự: Order gọi Payment, Payment thành công thì Order gọi Inventory. Vào ngày sale 12/12, lưu lượng tăng gấp 8 lần. Inventory Service quá tải, timeout liên tục. Kết quả: hàng nghìn khách đã bị trừ tiền qua ví MoMo/VNPAY nhưng đơn không được xác nhận vì bước trừ kho fail. Bộ phận CSKH ngập trong khiếu nại.

Họ chuyển sang Saga theo Orchestration. Một Order Orchestrator điều phối: giữ tiền → giữ kho → xác nhận. Khi bước giữ kho fail (do timeout hoặc hết hàng), orchestrator tự động gọi compensating transaction RefundPayment. Quan trọng nhất, họ thêm cơ chế retry với backoff cho các lỗi tạm thời (timeout) trước khi quyết định bù trừ.

Bài học: Trong Saga, lỗi tạm thời (timeout, service quá tải) khác hoàn toàn với lỗi nghiệp vụ (hết hàng). Với lỗi tạm thời, hãy retry; chỉ khi lỗi nghiệp vụ thật sự mới chạy compensation. Nếu compensation cũng fail, phải có hàng đợi "dead letter" và cảnh báo để con người can thiệp — tiền của khách không được phép "bốc hơi".

Ví dụ 2 — Grab: đặt xe với Choreography

Xét luồng đặt xe (giả định dựa trên mô hình thực tế của các super-app như Grab). Khi khách đặt chuyến: Booking Service tạo chuyến → phát RideRequested. Driver-Matching Service nghe, tìm tài xế, phát DriverAssigned. Wallet Service nghe, tạm giữ (pre-authorize) một khoản tiền tương ứng cước dự kiến, phát FundsHeld. Notification Service nghe DriverAssigned để báo cho khách.

Nếu sau 60 giây không tìm được tài xế, Driver-Matching phát DriverNotFound. Booking Service nghe được, chạy compensation: hủy chuyến. Wallet Service cũng nghe DriverNotFound để giải phóng khoản tiền đã giữ (nếu đã giữ).

Vì luồng khá tuyến tính và mỗi service phản ứng độc lập với sự kiện, Choreography phù hợp: không cần một orchestrator trung tâm, các service loose coupling, thêm service mới (ví dụ Loyalty tích điểm) chỉ cần lắng nghe thêm sự kiện.

Bài học: Choreography tỏa sáng khi luồng đơn giản và bạn muốn dễ mở rộng. Nhưng khi số service tham gia vượt 5-6, việc "sự kiện nào kích hoạt cái gì" trở nên khó nắm bắt — đó là lúc nên cân nhắc chuyển sang Orchestration hoặc ít nhất phải có công cụ distributed tracing (ví dụ correlation ID xuyên suốt) để lần theo luồng.

Ví dụ 3 — Ngân hàng số: chuyển tiền liên hệ thống

Một ngân hàng số ở Đông Nam Á xử lý chuyển tiền giữa hai ví thuộc hai hệ thống ledger khác nhau. Bước 1: trừ tiền tài khoản A. Bước 2: cộng tiền tài khoản B. Nếu bước 2 fail, phải hoàn lại bước 1.

Điểm tinh tế: compensation ở đây không phải là "xóa giao dịch trừ tiền" (trong kế toán không được xóa bút toán đã ghi), mà là ghi một bút toán đảo ngược (reversal entry): cộng lại đúng số tiền vào A với ghi chú "hoàn do lỗi chuyển tiền". Đây là semantic compensation — bù trừ về mặt nghiệp vụ, không phải xóa vật lý.

Bài học: Compensation không phải lúc nào cũng là "undo hoàn hảo". Nhiều tình huống bạn không thể xóa dấu vết (email đã gửi, tiền đã ghi sổ). Compensation là hành động nghiệp vụ mới để trung hòa tác động cũ. Hãy thiết kế nghiệp vụ chấp nhận điều này.

Hướng dẫn từng bước

Đây là quy trình thiết kế một Saga cho bài toán của bạn:

  • Vẽ ra chuỗi các local transaction. Liệt kê từng bước theo thứ tự, mỗi bước thuộc service nào, tác động vào database nào. Ví dụ: Tạo đơn → Giữ tiền → Trừ kho → Xác nhận đơn.
  • Định nghĩa compensating transaction cho từng bước. Với mỗi bước, tự hỏi: "Nếu bước sau fail, tôi phải làm gì để trung hòa bước này?" Lưu ý bước cuối cùng thường không cần compensation (không có bước nào sau nó). Bước chỉ đọc dữ liệu cũng không cần bù trừ.
  • Chọn Choreography hay Orchestration. Đếm số service và độ phức tạp nhánh. Đơn giản → Choreography. Phức tạp, nhiều điều kiện → Orchestration.
  • Đảm bảo mỗi bước idempotent. Vì sẽ có retry, một request có thể đến hai lần. Trừ tiền hai lần là thảm họa. Mỗi bước phải nhận diện được "tôi đã xử lý request này rồi" qua một khóa idempotency (thường là Saga ID + step ID).
  • Lưu trạng thái Saga bền vững. Orchestrator (hoặc bảng theo dõi) phải ghi lại Saga đang ở bước nào vào database. Nếu tiến trình chết giữa chừng, khi khởi động lại phải biết tiếp tục hoặc bù trừ từ đâu.
  • Xử lý các trường hợp không hoàn thành được compensation. Đưa vào dead-letter queue, cảnh báo, và có quy trình con người can thiệp cho những ca hiếm nhưng nghiêm trọng (thường liên quan tiền).
  • Thêm observability. Gắn một correlation/Saga ID xuyên suốt mọi log và sự kiện để lần theo toàn bộ luồng khi debug.

Lỗi thường gặp & mẹo

  • Quên tính idempotency. Đây là lỗi số một. Không có Saga nào an toàn nếu các bước không idempotent, vì retry là điều chắc chắn xảy ra. Mẹo: mỗi bước lưu bản ghi (saga_id, step) đã xử lý; nếu gặp lại thì trả kết quả cũ thay vì thực thi lại.
  • Nhầm lỗi tạm thời với lỗi nghiệp vụ. Timeout không có nghĩa là hết hàng. Retry lỗi tạm thời trước khi chạy compensation, nếu không bạn sẽ hủy đơn oan uổng của khách chỉ vì mạng chập chờn.
  • Compensation không đối xứng hoàn hảo. Đừng giả định "giữ tiền" đảo ngược bằng "hoàn tiền" là sạch 100%. Có thể phát sinh phí giao dịch, tỷ giá thay đổi. Hãy làm việc với bộ phận nghiệp vụ để định nghĩa compensation đúng.
  • Vấn đề "read của dữ liệu chưa nhất quán". Trong lúc Saga đang chạy dở, đơn hàng ở trạng thái trung gian (PENDING). Nếu một truy vấn khác đọc và hành động dựa trên dữ liệu này, có thể sai. Mẹo: dùng các trạng thái rõ ràng (PENDING, CONFIRMED, CANCELLED) và không cho phép hành động nào dựa trên PENDING như thể đã hoàn tất.
  • Đặt bước "có thể fail nhiều nhất" lên sớm. Sắp xếp lại thứ tự: kiểm tra tồn kho (hay fail vì hết hàng) nên đặt trước bước trừ tiền nếu có thể, để giảm số lần phải compensation tốn kém.
  • Lạm dụng Saga khi không cần. Nếu nghiệp vụ của bạn thực sự cần consistency mạnh tức thời và nằm gọn trong một service, đừng tách ra rồi gánh Saga. Saga là chi phí — chỉ dùng khi đã thực sự có ranh giới service cứng.

Bài tập thực hành

Đề bài: Bạn thiết kế luồng đặt phòng cho một nền tảng du lịch kiểu Traveloka. Một đơn "combo" gồm ba bước xuyên ba service: đặt phòng khách sạn (Hotel Service), đặt vé máy bay (Flight Service), và trừ tiền qua ví (Payment Service). Yêu cầu: cả ba phải cùng thành công thì đơn mới hợp lệ.

  • Vẽ chuỗi các local transaction theo đúng thứ tự bạn cho là hợp lý, và giải thích vì sao chọn thứ tự đó (gợi ý: bước nào dễ fail nhất, bước nào tốn kém nhất khi bù trừ?).
  • Với mỗi bước, viết ra compensating transaction tương ứng. Lưu ý vé máy bay thường có chính sách hoàn hủy khác phòng khách sạn — điều này ảnh hưởng thế nào đến thiết kế bù trừ của bạn?
  • Bạn chọn Choreography hay Orchestration cho luồng này? Bảo vệ lựa chọn của mình bằng ít nhất hai lý do.
  • Mô tả một tình huống race condition: khách đặt combo lúc chỉ còn 1 phòng cuối cùng, và cùng lúc một khách khác cũng đặt. Idempotency và trạng thái Saga giúp bạn xử lý ra sao?
  • (Nâng cao) Nếu bước hoàn tiền (compensation của Payment) bị fail do ví khách tạm khóa, hệ thống của bạn xử lý thế nào để không "nuốt" tiền của khách?
Hãy viết câu trả lời dưới dạng một sơ đồ luồng ngắn kèm bảng liệt kê bước / compensation. So sánh với cách bạn từng thấy trong công việc thực tế.

Tóm tắt

  • Distributed transaction xuất hiện khi một nghiệp vụ phải thay đổi dữ liệu ở nhiều service với database riêng biệt. Transaction ACID truyền thống không ôm được ranh giới này.
  • 2PC cho consistency mạnh nhưng blocking, chậm, mong manh — không phù hợp microservices quy mô lớn.
  • Saga Pattern giải quyết bằng một chuỗi local transaction, mỗi bước commit ngay, và khi có lỗi thì chạy compensating transaction để bù trừ. Kết quả là eventual consistency.
  • Hai kiểu triển khai: Choreography (dựa trên sự kiện, phân tán, hợp cho luồng đơn giản) và Orchestration (nhạc trưởng trung tâm, hợp cho luồng phức tạp).
  • Ba nguyên tắc sống còn: idempotency cho mọi bước, phân biệt lỗi tạm thời vs lỗi nghiệp vụ, và lưu trạng thái Saga bền vững kèm cơ chế xử lý khi compensation thất bại.
  • Compensation là hành động nghiệp vụ đảo ngược, không phải rollback database — hãy thiết kế nó cùng bộ phận nghiệp vụ, đặc biệt khi liên quan đến tiền.
Nắm vững Saga, bạn không chỉ viết được code đúng mà còn ra được quyết định kiến trúc thuyết phục: khi nào tách service, khi nào chấp nhận eventual consistency, và khi nào một transaction đơn giản trong monolith vẫn là lựa chọn khôn ngoan hơn.