Saga pattern: hoàn tác một đơn đặt tour trải qua bốn service Trung cấp
Thời gian đọc ước tính: 11 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”Khách đặt gói tour saga-123. Bốn service, mỗi service một database: Seat giữ ghế 12A, Hotel giữ phòng 204, Payment trừ tiền thẻ, Ticket xuất vé điện tử.
Seat và Hotel chạy xong và commit. Payment bị từ chối. Giờ ghế 12A và phòng 204 đang bị giữ cho một khách sẽ không bao giờ trả tiền, và khách khác thấy chúng là đã có người.
Trong một database, việc này là một lệnh ROLLBACK. Qua bốn database thì không có transaction chung nào để rollback.
Hệ thống hoàn tác phần việc đã commit ở service khác thế nào, theo thứ tự nào, và trong lúc đó người khác thấy gì?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Mọi bước ghi vào một database | Transaction cục bộ, không cần saga | Constraint và lock làm phần việc |
| 2–3 service, luồng thẳng, một team | Saga choreography (event) | Mỗi service có consumer idempotent và publish qua outbox |
| Từ 4 bước, có nhánh, timeout, nhiều team | Saga orchestration | Orchestrator lưu trạng thái và tự khởi động lại được |
| Có bước không hoàn tác được (đã gửi email, tiền đã chuyển đi) | Đặt sau pivot | Bước đó phải retry được tới khi thành công |
| Luồng kéo dài hàng giờ, hàng ngày, nhiều timer | Durable execution engine (Temporal, Step Functions) | Activity idempotent; code workflow theo quy tắc của engine |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Saga là chuỗi transaction cục bộ T1 … Tn. Mỗi Ti commit trong một service và có một bước bù trừ (compensation) Ci. Bài báo năm 1987 của Garcia-Molina và Salem bảo đảm: hoặc chạy T1 … Tn, hoặc chạy T1 … Tj, Cj … C1, tức bù trừ theo thứ tự ngược.
Vì sao không dùng transaction database hay 2PC?
Phần tiêu đề “Vì sao không dùng transaction database hay 2PC?”- Transaction database dừng ở biên của database đó; Seat và Hotel là hai database khác nhau.
- Two-phase commit (2PC) bắt các bên tham gia prepare và giữ lock tới khi coordinator quyết định. Nhiều store và broker trong kiến trúc database-per-service không hỗ trợ, và coordinator chết thì các bên bị treo.
- Giữ lock ở bốn database trong lúc chờ cổng thanh toán trả lời chính là bài toán “long lived transaction” mà bài báo saga muốn tránh.
Ba loại bước
Phần tiêu đề “Ba loại bước”| Loại | Nghĩa | Trong saga đặt tour |
|---|---|---|
| Compensable | Có bước hoàn tác theo nghĩa nghiệp vụ | T1 ReserveSeat → C1 ReleaseSeat; T2 ReserveHotelRoom → C2 CancelHotelRoom |
| Pivot | Điểm không quay lại: thành công thì saga phải chạy tới đích | T3 ChargePayment |
| Retriable | Sau pivot; không được thất bại vì lý do nghiệp vụ, chỉ retry | T4 IssueTicket |
Vị trí các bước là một quyết định thiết kế:
- Giữ chỗ thì nhả rẻ, nên đi trước.
- Trừ tiền đứng cuối trong nhóm bước rủi ro, vì refund chậm và khách nhìn thấy.
- Xuất vé đi sau pivot, vì “đã trả tiền mà không có vé” không có cách bù trừ nào chấp nhận được.
Bù trừ là hoàn tác nghiệp vụ, không phải rollback
Phần tiêu đề “Bù trừ là hoàn tác nghiệp vụ, không phải rollback”ROLLBACK xoá thay đổi như chưa từng xảy ra. Bước bù trừ là một transaction nghiệp vụ mới có tác dụng ngược lại. Giữa hai lúc đó đã có transaction khác chạy, nên nó không thể khôi phục snapshot cũ.
ReleaseSeatchỉ lật lần giữ chỗ của saga này; nó không ghi lại số ghế cũ.- Refund vẫn để lại một dòng trừ tiền và một dòng hoàn tiền trên sao kê, có thể mất vài ngày, và phí có thể không được trả lại. Đã hoàn tiền khác với chưa từng trừ tiền.
Choreography hay orchestration
Phần tiêu đề “Choreography hay orchestration”| Tiêu chí | Choreography | Orchestration |
|---|---|---|
| Ai quyết định bước tiếp | Mỗi service phản ứng theo event | Orchestrator gửi command |
| Luồng nằm ở đâu | Rải trong các consumer | Một state machine |
| Thêm một bước | Sửa nhiều service | Sửa orchestrator |
| Hỏi “saga-123 đang ở đâu?” | Ghép event từ nhiều service | Đọc một row |
| Rủi ro | Event phụ thuộc vòng, khó test tích hợp | Orchestrator là thành phần quan trọng phải luôn sẵn sàng |
Phần còn lại của bài dùng orchestration, vì saga đặt tour có bốn bước, một pivot và timeout.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Orchestrator chạy T1, T2, nhận PaymentDeclined ở T3, rồi bù trừ theo thứ tự ngược.
ReserveSeatT1: orchestrator lưu saga-123 là RUNNING rồi gửi ReserveSeat. Ghế 12A được giữ.
| Sau bước | Ghế 12A | Phòng 204 | Trạng thái saga | Crash xong chạy tiếp được không? |
|---|---|---|---|---|
| T2 | đang giữ | đang giữ | RUNNING, bước charge_payment |
Được, gửi lại lệnh trừ tiền (cùng key) |
| Đã lưu PaymentDeclined | đang giữ | đang giữ | COMPENSATING, bước cancel_hotel |
Được, gửi lại CancelHotelRoom |
| C2 đã xác nhận | đang giữ | trống | COMPENSATING, bước release_seat |
Được, gửi lại ReleaseSeat |
| C1 đã xác nhận | trống | trống | COMPENSATED |
Trạng thái cuối, không còn gì để chạy |
Vì sao thứ tự ngược: sau C2, hệ thống ở trạng thái từng có khi chạy tiến (giữ ghế, chưa có khách sạn). Nhả ghế trước sẽ tạo ra “phòng khách sạn cho hành khách không có ghế”, trạng thái mà không validation nào được viết cho.
Trạng thái saga là một state machine được lưu xuống DB
Phần tiêu đề “Trạng thái saga là một state machine được lưu xuống DB”Orchestrator có thể crash giữa hai bước bất kỳ, nên row trong database mới là saga, không phải bộ nhớ process.
Đang tải diagram…
Mỗi lần chuyển trạng thái và command tiếp theo được ghi trong một transaction cục bộ: row trạng thái cộng một row trong transactional outbox. Nếu không, trạng thái có thể là COMPENSATING trong khi CancelHotelRoom chưa từng được gửi, tức một dual write.
BEGIN;UPDATE saga_instance SET state = 'COMPENSATING', step = 'cancel_hotel', version = version + 1, updated_at = now() WHERE saga_id = 'saga-123' AND state = 'RUNNING' AND version = 3; -- 0 row: đã có worker khác chuyểnINSERT INTO saga_outbox (saga_id, type, payload)VALUES ('saga-123', 'CancelHotelRoom', '{"idempotencyKey": "saga-123:hotel:cancel"}');COMMIT;Điều kiện version ngăn hai instance orchestrator cùng điều khiển một saga.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- Bước và bù trừ đều idempotent. Relay và broker giao ít nhất một lần, nên mỗi command mang key ổn định (
saga-123:hotel:cancel) và bên nhận khử trùng lặp theo key, xem idempotent consumer và idempotency. - Bù trừ cho việc có thể chưa xảy ra.
ReserveHotelRoombị timeout có thể đã commit hoặc chưa.CancelHotelRoomphải coi “không có lần giữ nào” là thành công, và để lại tombstone đểReserveHotelRoomtới muộn không làm gì. - Bù trừ phải thành công ở cuối cùng. Retry có backoff với cùng key, không bao giờ “bỏ qua rồi đi tiếp”. Hết lượt retry thì chuyển
NEEDS_ATTENTIONvà đưa vào hàng đợi xử lý tay. - Timeout cho từng bước. Timeout nghĩa là “chưa biết”, không phải “thất bại”: hỏi lại theo key hoặc retry đúng command đó trước khi bù trừ.
- Saga bị kẹt. Quét các saga chưa ở trạng thái cuối có
updated_atcũ và cảnh báo. Ngưỡng ví dụ (minh hoạ): 15 phút cho luồng này. - Durable execution. Engine như Temporal lưu tiến độ workflow và retry activity, nhưng activity vẫn có thể chạy hơn một lần và nên idempotent.
Isolation: người khác thấy saga đang dở dang
Phần tiêu đề “Isolation: người khác thấy saga đang dở dang”Saga không có chữ “I” của ACID. Mỗi Ti commit và hiện ra cho mọi người trước khi saga xong.
Saga A commit bước hotel cục bộ: phòng 204 = BOOKED. Không lock nào kéo dài suốt saga.
- Saga A commit phòng 204 =
BOOKED; không gì khoá nó trong phần còn lại của saga. - Saga B đọc
BOOKEDvà coi như hết phòng: một dirty read ở mức nghiệp vụ. - Thanh toán của A bị từ chối, A bù trừ.
- Phòng 204 trống lại, nhưng quyết định của B đã đưa ra rồi.
Biện pháp bù cho việc thiếu isolation (Azure, microservices.io):
| Biện pháp | Trong saga đặt tour |
|---|---|
| Semantic lock | Lần giữ có trạng thái PENDING(saga-123) kèm hạn, không phải BOOKED; trang tìm kiếm hiện “đang giữ chỗ, xem lại sau 10 phút” |
| Sắp lại bước (pessimistic view) | Bước rủi ro và hoàn tác được đi trước; cập nhật không đảo ngược chỉ nằm ở bước retriable sau pivot |
| Đọc lại giá trị | Trước T4, đọc lại lần giữ và kiểm tra nó vẫn thuộc saga-123 |
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo trên PostgreSQL 17 (chuyển trạng thái, bản trùng cũ, tombstone, quét saga kẹt):
docker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/saga-pattern/demo.sqlKết quả: lần chuyển trạng thái trả UPDATE 1, bản trùng với version cũ trả UPDATE 0, lệnh huỷ giao lại và lệnh giữ phòng tới muộn đều trả INSERT 0 0, và lượt quét tìm thấy saga-123 ở COMPENSATING.
Trade-offs
Phần tiêu đề “Trade-offs”Saga mang lại gì
- Mỗi service giữ database riêng và transaction cục bộ ngắn; không có lock xuyên service.
- Lỗi giữa chừng kết thúc ở một trạng thái nghiệp vụ rõ ràng (
COMPENSATED), không phải tour đặt dở. - Orchestration cho một chỗ để xem và chạy tiếp mọi saga.
Saga tốn gì
- Mỗi bước compensable cần một bước bù trừ được thiết kế và test.
- Không có isolation: trạng thái trung gian lộ ra, cần semantic lock hoặc sắp lại bước.
- Nhiều thành phần hơn: outbox, idempotency key, timeout, quét saga kẹt, hàng đợi xử lý tay.
Checklist production
Phần tiêu đề “Checklist production”- Gắn nhãn từng bước là compensable, pivot hay retriable; dời hành động không đảo ngược ra sau pivot.
- Lưu trạng thái saga có cột
version; commit mỗi lần chuyển trạng thái cùng command trong outbox. - Mỗi command và bước bù trừ có key ổn định; lệnh huỷ gặp “không tìm thấy” thì coi là thành công.
- Đặt timeout cho từng bước; khi timeout, hỏi lại hoặc retry cùng key trước khi bù trừ.
- Dùng
PENDING+ hạn giữ cho mọi thứ người khác có thể đọc khi saga đang chạy. - Cảnh báo saga nằm lâu ở
COMPENSATINGvà mọi saga ởNEEDS_ATTENTION. - Test lỗi ở từng bước, kể cả crash giữa lúc gửi command và lúc nhận phản hồi.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Sai lầm hay gặp nhất là coi bù trừ như rollback của database.
Ghi lại giá trị cũ thay vì áp thay đổi ngược
Hay gặp
-- C1: ghi lại số ghế đã đọc trước T1UPDATE flight SET seats_left = 37 WHERE id = 'FL-123';Nên viết
-- C1: chỉ hoàn tác lần giữ của saga này, idempotentUPDATE seat_hold SET status = 'RELEASED' WHERE saga_id = 'saga-123' AND status = 'HELD';Vì sao
Đơn khác đã đổi seats_left sau T1. Ghi lại 37 sẽ xoá mất các đơn đó; lật lần giữ này chỉ hoàn tác đúng phần saga đã làm.
Cái bẫy thứ hai nằm ngay trong code orchestrator.
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| “Refund là xoá lần trừ tiền” | Cả lần trừ và lần hoàn đều hiện ra | Trừ tiền làm pivot, sau các bước giữ chỗ rẻ |
| “Timeout = thất bại” | Bước commit muộn không bao giờ được bù trừ | Hỏi lại hoặc retry theo key; huỷ thì để tombstone |
| “Bộ nhớ process là trạng thái” | Crash là mất saga | Row trạng thái + outbox trong một transaction |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”ReserveHotelRoomcho phòng 204 bị timeout và saga bù trừ.CancelHotelRoomphải làm gì nếu lệnh giữ phòng tới sau lệnh huỷ?- Vì sao
ChargePaymentlà pivot chứ không phảiReserveSeat? - Saga B đọc phòng 204 là
BOOKEDkhi saga A còn đang chạy. Biện pháp nào thay đổi thứ B nhìn thấy, và B thấy gì thay vào đó?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Garcia-Molina & Salem — Sagas (1987): định nghĩa gốc, bù trừ theo nghĩa nghiệp vụ, bảo đảm thứ tự ngược.
- Microservices.io — Pattern: Saga: choreography so với orchestration, publish nguyên tử.
- Azure — Saga design pattern: bước pivot và retryable, anomaly, biện pháp xử lý.
- AWS — Saga orchestration: vì sao 2PC không hợp với database-per-service; ví dụ Step Functions.
- Temporal — Activity Definition: activity có thể chạy hơn một lần.