Khi nào tránh distributed transaction — saga & idempotency
Vì 2PC đắt và mong manh, nhiều hệ tránh distributed transaction: saga (chuỗi giao dịch cục bộ + bước bù trừ), idempotency, outbox. Đánh đổi so với atomic thật.
TL;DR: Vì 2PC đắt và blocking (bài 03), phần lớn hệ hiện đại tránh distributed transaction, thay bằng saga: chuỗi giao dịch cục bộ (mỗi bước atomic trong một node/service), mỗi bước có hành động bù trừ (compensating action) để hoàn tác nếu bước sau fail. Saga cho atomicity nghiệp vụ — hoàn tất, hoặc bù trừ về mặt nghiệp vụ (không phải luôn sạch — vd email đã gửi không thu lại được) — đổi lại không cô lập (trạng thái trung gian có thể thấy được), và cần idempotency (chịu được retry) + outbox (không mất bước khi crash). Chọn saga khi chịu được trạng thái trung gian + bù trừ hợp lý; giữ 2PC khi cần cô lập tuyệt đối.
Ba bài trước cho bạn hiểu vì sao atomic xuyên node vừa cần vừa đắt. Bài này là kết luận thực dụng của cả module: đừng dùng 2PC nếu tránh được — và đây là cách tránh đúng, không phải "bỏ qua atomicity".
1. Analogy — đặt tour du lịch từng chặng, có thể huỷ từng chặng
Bạn tự đặt một chuyến đi: (1) mua vé máy bay, (2) đặt khách sạn, (3) thuê xe. Không có "một nút đặt-tất-cả nguyên khối". Bạn làm từng bước, mỗi bước xong ngay (giao dịch cục bộ). Nếu tới bước 3 hết xe, bạn không "rollback vũ trụ" — bạn huỷ từng cái đã đặt (bù trừ): huỷ khách sạn (bước bù cho 2), huỷ vé bay (bước bù cho 1), thường mất phí huỷ. Kết quả cuối: hoặc đi trọn chuyến, hoặc huỷ sạch — nhưng ở giữa có lúc bạn "đã có vé bay mà chưa có khách sạn" (trạng thái trung gian nhìn thấy được).
| Đặt tour | Saga |
|---|---|
| Mua vé / đặt KS / thuê xe từng bước | Chuỗi giao dịch cục bộ |
| Mỗi đặt chỗ xong ngay | Mỗi bước atomic trong một node |
| Hết xe → huỷ KS, huỷ vé | Compensating action |
| "Có vé mà chưa có KS" giữa chừng | Không isolation — trạng thái trung gian |
| Gọi đặt lại vì mạng lỗi, không đặt trùng | Idempotency |
2PC = giữ tất cả "treo" tới khi cùng chốt (đắt, blocking). Saga = làm từng bước xong luôn, sai thì bù trừ (rẻ, nhưng thấy trạng thái giữa). Chọn saga khi chịu được cái "thấy giữa".
flowchart LR T1["T1: tru ton (Kho)"] --> T2["T2: tru tien (Vi)"] T2 --> T3["T3: tao don (Ship)"] T3 -.->|"T3 FAIL -> bu tru nguoc"| C2["C2: hoan tien"] C2 --> C1["C1: tra ton"]
2. Saga — chuỗi giao dịch cục bộ + bù trừ
Thay vì một distributed transaction, saga là một chuỗi bước, mỗi bước là một giao dịch cục bộ (atomic trong service của nó). Mỗi bước Ti có một bước bù Ci "hoàn tác về mặt nghiệp vụ".
Saga dat hang:
T1: tru ton kho (local txn service Kho)
T2: tru tien (local txn service Vi)
T3: tao don giao hang (local txn service Ship)
Neu T3 FAIL:
chay bu tru nguoc: C2 (hoan tien) -> C1 (tra ton kho)
=> ket qua: nhu chua dat hang (atomicity NGHIEP VU)
Điểm khác 2PC: không giữ khoá xuyên node, không có coordinator giữ mọi thứ treo. Mỗi bước commit ngay tại chỗ. Bù trừ không phải "rollback kỹ thuật" (dữ liệu đã commit rồi) mà là một giao dịch mới đảo ngược hiệu ứng nghiệp vụ (hoàn tiền, trả tồn). Vì vậy Ci phải được thiết kế cẩn thận — có những thứ không bù trừ sạch được (email đã gửi không thu lại được), lúc đó saga phải xử bằng cách khác (gửi email xin lỗi).
Cái mất so với transaction thật: isolation. Giữa T1 và T3, hệ ở trạng thái "đã trừ tồn, chưa giao" — và giao dịch khác có thể thấy trạng thái trung gian này. Ứng dụng phải thiết kế chịu được (ví dụ đánh dấu đơn "đang xử lý" để không ai coi tồn đã trừ là "bán được cho người khác").
3. Idempotency — vì sao bắt buộc?
Service Vi nhận lệnh "trừ 100k". Nó trừ xong nhưng mạng rớt trước khi báo thành công về cho saga. Saga không nhận được ack nên gửi lại "trừ 100k". Bạn đoán: nếu bước này không idempotent, số dư bị trừ bao nhiêu — và làm sao chặn?
Trong hệ phân tán, mạng không tin cậy (Module 5): một request có thể thành công nhưng ack bị mất, khiến bên gửi retry. Nếu bước saga không idempotent, retry gây tác dụng kép — trừ tiền hai lần. Đáp án câu đoán: trừ 200k thay vì 100k, trừ khi bước idempotent.
Idempotency = chạy lại nhiều lần cho cùng một kết quả như chạy một lần. Cách phổ biến: mỗi thao tác mang một idempotency key duy nhất; service ghi nhớ key đã xử lý, lần thứ hai gặp cùng key thì bỏ qua (trả lại kết quả cũ) thay vì làm lại. Vì retry là chuyện bình thường trong saga (và mọi hệ phân tán), idempotency không phải tuỳ chọn — nó là điều kiện để saga đúng.
4. Outbox — không mất bước khi crash
Một chỗ tinh vi: sau khi service Kho commit "trừ tồn" (T1), nó phải kích hoạt bước tiếp (bảo Vi trừ tiền) — thường qua một message. Nhưng nếu nó commit T1 xong rồi crash trước khi gửi message, bước tiếp không bao giờ chạy → saga kẹt. Ngược lại, nếu gửi message trước khi commit rồi commit fail → gửi nhầm.
Outbox pattern giải: ghi "message cần gửi" vào một bảng outbox trong cùng transaction cục bộ với thay đổi dữ liệu. Vì cùng một giao dịch atomic (một node), hoặc cả thay đổi dữ liệu và message cùng bền, hoặc không cái nào. Một tiến trình riêng đọc outbox và gửi message (idempotent). Nhờ vậy không có kẽ hở "commit dữ liệu mà mất message".
Outbox/CDC là chi tiết hiện thực để saga chạy đáng tin cậy, không phải điều bạn cần cân ở mức quyết định "saga hay 2PC". Cho mục tiêu bài (chọn cách tiếp cận), nắm đúng một ý là đủ: ghi ý định-làm-tiếp cùng transaction cục bộ để không mất bước khi crash. Cơ chế CDC/change-data-capture + event log (Kafka) sẽ đào sâu ở Tier 2 (Phái sinh) — giờ chưa cần.
5. Pitfall — dùng saga mà quên isolation, hoặc quên idempotency
Hai lỗi hay đi cùng:
❌ Dung saga nhung code giả định khong ai thay trang thai trung gian
-> giao dich khac doc "ton da tru" -> tuong da ban -> logic sai
(saga KHONG co isolation nhu transaction that)
❌ Buoc saga khong idempotent + mang retry
-> tru tien 2 lan / tao 2 don -> sai du lieu, kho lan ra
Hệ quả: hoặc lỗi nghiệp vụ do đọc trạng thái trung gian, hoặc nhân đôi hiệu ứng do retry — cả hai "lúc có lúc không", cực khó debug.
Hướng đúng: khi chọn saga, thiết kế có ý thức cho hai thứ: (1) trạng thái trung gian hiển thị — đánh dấu trạng thái ("đang xử lý", "đã giữ chỗ") để phần khác của hệ diễn giải đúng, không coi là kết quả cuối; (2) mọi bước idempotent + retry an toàn (idempotency key). Và luôn kiểm: bước nào không bù trừ sạch được (gửi tiền ra ngoài, email) — xử riêng, đừng giả định mọi thứ hoàn tác được như rollback. Saga rẻ hơn 2PC nếu làm đúng hai điều kiện này; làm ẩu thì nó tệ hơn.
6. Tự quyết — đến lượt bạn
Bạn đã có đủ khung để chọn. Đọc tình huống sau, rồi tự quyết saga hay 2PC và tự liệt kê các bước bù trừ — viết ra (trong đầu hoặc ra giấy) trước khi mở đáp án mẫu.
Đặt vé concert. Một thao tác "mua vé" gồm ba bước ở ba service khác nhau, mỗi service một database riêng: (1) giữ ghế (service Ticket), (2) trừ ví đúng số tiền vé (service Wallet), (3) gửi email xác nhận kèm mã vé (service Mail). Yêu cầu nghiệp vụ: không được trừ tiền mà không giữ được ghế; chấp nhận trạng thái "đã giữ ghế, chưa trừ tiền xong" tồn tại trong vài giây.
Tự trả lời trước khi mở đáp án: (a) saga hay 2PC — vì sao? (b) chuỗi bước Ti và bước bù Ci cho mỗi bước là gì? (c) bước nào không bù trừ sạch được, xử thế nào? (d) bước nào cần idempotency key?
Q1Với thao tác "đặt vé concert" (giữ ghế → trừ ví → gửi email), bạn chọn saga hay 2PC? Liệt kê chuỗi bước và bước bù trừ, chỉ ra bước không hoàn tác sạch được và cách xử, và nói bước nào cần idempotency.▸
Chọn saga. Cả hai điều kiện chọn saga đều thoả: (a) thao tác chịu được trạng thái trung gian — đề cho phép "đã giữ ghế, chưa trừ tiền" tồn tại vài giây; (b) mỗi bước có bù trừ hợp lý. Không cần cô lập tuyệt đối, nên không đáng gánh blocking + chi phí của 2PC.
Chuỗi bước + bù trừ:
- T1 giữ ghế → C1 nhả ghế.
- T2 trừ ví → C2 hoàn tiền.
- T3 gửi email xác nhận → C3 không bù sạch được (email đã gửi không thu lại).
Nếu một bước sau fail, chạy bù ngược. Ví dụ T2 fail (ví không đủ tiền) → C1 nhả ghế → kết quả như chưa mua vé.
Bước không bù sạch: T3 (email). Xử bằng cách đặt nó cuối cùng — chỉ gửi email sau khi mọi bước đảo-ngược-được đã thành công, nên gần như không bao giờ phải bù nó. Nếu vẫn buộc phải "hoàn tác" (ví dụ huỷ vé sau đó), gửi email đính chính/huỷ thay vì cố thu hồi — chấp nhận đây là bù trừ nghiệp vụ không hoàn hảo.
Idempotency: T2 (trừ ví) bắt buộc — nếu trừ xong mà ack mất, saga retry sẽ trừ hai lần; gắn idempotency key cho lệnh trừ để lần hai bị bỏ qua. T3 cũng nên idempotent (retry không gửi hai email trùng), T1 nên idempotent theo cùng key để không giữ hai ghế. Nhớ: retry là chuyện *bình thường*, không phải ngoại lệ.
7. 📚 Deep Dive
- Designing Data-Intensive Applications (Kleppmann) — Chương 9 — bàn về giới hạn của distributed transaction/2PC (XA, coordinator failure, blocking); và Chương 11 (Tier sau) về event-driven + exactly-once.
- Hector Garcia-Molina & Kenneth Salem (1987), "Sagas" — paper gốc: long-lived transaction chia thành chuỗi giao dịch cục bộ + compensating transaction, chấp nhận không cô lập để đổi lấy không giữ khoá lâu.
- Outbox pattern + CDC là cách hiện thực saga đáng tin cậy trong microservices; exactly-once-effect nhờ idempotency — sẽ đào sâu ở Tier 2 (Phái sinh) khi học event log/Kafka.
Ghi chú: DDIA nhấn: saga không phải "atomicity yếu hơn" mà là mô hình khác — atomicity nghiệp vụ (bù trừ) thay cho atomicity kỹ thuật (rollback), đổi isolation lấy khả dụng + không blocking.
8. Liên hệ các bài khác
- Bài 03 — 2PC: saga là câu trả lời cho "2PC quá đắt/blocking" — tránh giữ khoá xuyên node bằng chuỗi giao dịch cục bộ + bù trừ.
- Module 5 — Mạng không tin cậy: retry (do mạng không tin cậy) là lý do idempotency bắt buộc trong saga.
- Module 2 — Multi-leader: idempotency + bù trừ cũng là công cụ xử lý ghi trùng/xung đột — cùng họ tư duy.
- Module 6 — Consensus: với số ít thao tác thật sự cần đảm bảo tuyệt đối, saga không đủ — cần consensus.
9. Tóm tắt
- Vì 2PC đắt + blocking, phần lớn hệ tránh distributed transaction, dùng saga: chuỗi giao dịch cục bộ + compensating action cho mỗi bước.
- Saga cho atomicity nghiệp vụ (cuối cùng hoàn tất, hoặc bù trừ về mặt nghiệp vụ — không phải luôn sạch, vd email đã gửi không thu lại được) mà không giữ khoá xuyên node; đổi lại mất isolation (thấy trạng thái trung gian).
- Idempotency bắt buộc: mạng retry → mỗi bước phải cho cùng kết quả dù chạy nhiều lần (idempotency key).
- Outbox: ghi "việc cần làm" cùng transaction cục bộ → không mất bước khi crash giữa chừng.
- Chọn saga khi chịu được trạng thái trung gian + có bù trừ hợp lý; giữ 2PC/atomic thật cho số ít thao tác cần cô lập tuyệt đối.
- Anti-pattern: saga nhưng giả định có isolation, hoặc bước không idempotent → lỗi "lúc có lúc không".
10. Tự kiểm tra
Q1Saga khác 2PC ở điểm cốt lõi nào? 'Bù trừ' (compensating action) khác 'rollback' của transaction thật thế nào?▸
Khác cốt lõi: 2PC giữ mọi participant "treo" (giữ khoá, chờ coordinator) tới khi cùng chốt — atomicity kỹ thuật, nhưng blocking + đắt. Saga làm từng bước commit ngay tại chỗ (giao dịch cục bộ), không giữ khoá xuyên node, không coordinator giữ mọi thứ treo.
Bù trừ ≠ rollback: rollback là hoàn tác kỹ thuật trước khi commit (dữ liệu chưa bền). Trong saga, mỗi bước đã commit rồi — không rollback được nữa; bù trừ là một giao dịch MỚI đảo ngược hiệu ứng nghiệp vụ (hoàn tiền, trả tồn). Vì thế có thứ không bù sạch được (email đã gửi) — phải thiết kế cách xử riêng.
Q2Saga đánh đổi gì để đổi lấy 'không giữ khoá xuyên node'? Cho ví dụ hệ quả nếu bỏ qua đánh đổi đó.▸
Đánh đổi isolation: giữa các bước, hệ ở trạng thái trung gian (đã trừ tồn, chưa giao hàng) mà giao dịch khác có thể thấy — khác transaction thật (che trạng thái giữa cho tới commit).
Hệ quả nếu bỏ qua: một phần khác của hệ đọc "tồn đã trừ" và diễn giải nhầm là "đã bán cho ai đó rồi" hoặc "còn hàng" sai — dẫn tới bán trùng hoặc từ chối nhầm. Phải thiết kế có ý thức: đánh dấu trạng thái ("đang xử lý", "đã giữ chỗ") để mọi phần diễn giải đúng trạng thái trung gian, không coi nó là kết quả cuối.
Q3Vì sao idempotency là BẮT BUỘC trong saga (không phải tuỳ chọn)? Cách hiện thực phổ biến?▸
Bắt buộc vì mạng phân tán không tin cậy: một bước có thể thành công nhưng ack bị mất, khiến saga retry bước đó. Nếu bước không idempotent, retry gây tác dụng kép — trừ tiền hai lần, tạo hai đơn. Vì retry là chuyện bình thường (không phải ngoại lệ hiếm), saga chỉ đúng nếu mọi bước chịu được retry.
Cách hiện thực: mỗi thao tác mang một idempotency key duy nhất; service ghi nhớ các key đã xử lý, lần thứ hai gặp cùng key thì bỏ qua (trả kết quả cũ) thay vì làm lại. Nhờ đó "trừ 100k với key X" chạy bao nhiêu lần cũng chỉ trừ một lần.
Q4Outbox pattern giải quyết kẽ hở nào, và vì sao 'ghi message vào cùng transaction cục bộ' là mấu chốt?▸
Kẽ hở: sau khi commit thay đổi dữ liệu (trừ tồn), service phải gửi message kích hoạt bước tiếp. Nếu commit xong rồi crash trước khi gửi → mất message → saga kẹt. Nếu gửi trước commit rồi commit fail → gửi nhầm.
Mấu chốt: ghi "message cần gửi" vào bảng outbox trong CÙNG transaction cục bộ với thay đổi dữ liệu. Vì đó là một giao dịch atomic trên một node, hoặc cả thay đổi dữ liệu lẫn message cùng bền, hoặc không cái nào — không còn khả năng "commit dữ liệu mà mất message". Một tiến trình riêng đọc outbox gửi message (idempotent). Nó biến bài toán "hai việc ở hai nơi" thành "một transaction cục bộ + đọc-gửi sau".
Q5Khi nào bạn VẪN nên dùng 2PC/atomic thật thay vì saga? Nêu tiêu chí quyết định.▸
Dùng saga khi: (a) thao tác chịu được trạng thái trung gian hiển thị, và (b) mỗi bước có bù trừ hợp lý (hoàn tác được về mặt nghiệp vụ).
Giữ 2PC/atomic thật khi một trong hai điều trên không thoả: thao tác cần cô lập tuyệt đối (không được để ai thấy trạng thái nửa vời — ví dụ ràng buộc bất biến cứng phải luôn đúng tại mọi thời điểm), hoặc không có bù trừ sạch (hiệu ứng không đảo ngược được và không chấp nhận sai tạm thời). Đây là số ít; mặc định nên là saga, chỉ nâng lên 2PC khi phân tích cho thấy buộc phải cô lập tuyệt đối — chấp nhận cái giá blocking + chậm.
Bài tiếp theo: Module 4 — Tổng kết & cheat sheet
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên