Bỏ qua để đến nội dung
Knowledge Hub

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

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ì?

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

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.
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ũ.

  • ReleaseSeat chỉ 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.
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.

Orchestrator chạy T1, T2, nhận PaymentDeclined ở T3, rồi bù trừ theo thứ tự ngược.

Thanh toán bị từ chối: ghi COMPENSATING trước, rồi huỷ khách sạn, nhả ghế, cuối cùng mới COMPENSATED
Thanh toán bị từ chối: ghi COMPENSATING trước, rồi huỷ khách sạn, nhả ghế, cuối cùng mới COMPENSATEDBước 1/7: T1: orchestrator lưu saga-123 là RUNNING rồi gửi ReserveSeat. Ghế 12A được giữ.Service tham gia (mỗiservice một DB)Orchestratorsaga-123saga_instance+ outboxSeatghế 12Ađang giữHotelphòng 204PaymentTicketReserveSeat
Orchestrator → SeatReserveSeat
Bước 1/7

T1: 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ển
INSERT 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.

  • 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. ReserveHotelRoom bị timeout có thể đã commit hoặc chưa. CancelHotelRoom phải coi “không có lần giữ nào” là thành công, và để lại tombstone để ReserveHotelRoom tớ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_ATTENTION và đư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_at cũ 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 B quyết định dựa trên phòng 204 khi saga A chưa xong, rồi A bù trừ
Saga B quyết định dựa trên phòng 204 khi saga A chưa xong, rồi A bù trừBước 1/5: 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.thời giantrạng thái dở dang, lộ ra …ReserveHotelRoomcommit: 204 = BOOKEDSaga Akhách AHotel DBphòng 204, phòng cuốiSaga Bkhách B
Bước 1/5

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.

  1. Saga A commit phòng 204 = BOOKED; không gì khoá nó trong phần còn lại của saga.
  2. Saga B đọc BOOKED và coi như hết phòng: một dirty read ở mức nghiệp vụ.
  3. Thanh toán của A bị từ chối, A bù trừ.
  4. 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

Demo trên PostgreSQL 17 (chuyển trạng thái, bản trùng cũ, tombstone, quét saga kẹt):

Terminal window
docker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/saga-pattern/demo.sql

Kế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.

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.
  1. 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.
  2. 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.
  3. 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.
  4. Đặt timeout cho từng bước; khi timeout, hỏi lại hoặc retry cùng key trước khi bù trừ.
  5. Dùng PENDING + hạn giữ cho mọi thứ người khác có thể đọc khi saga đang chạy.
  6. Cảnh báo saga nằm lâu ở COMPENSATING và mọi saga ở NEEDS_ATTENTION.
  7. 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 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 T1
UPDATE 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, idempotent
UPDATE 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
  1. ReserveHotelRoom cho phòng 204 bị timeout và saga bù trừ. CancelHotelRoom phải làm gì nếu lệnh giữ phòng tới sau lệnh huỷ?
  2. Vì sao ChargePayment là pivot chứ không phải ReserveSeat?
  3. Saga B đọc phòng 204 là BOOKED khi 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 đó?