Xuyên nhiều hệ thống, bạn không có transaction chung. Không thể ROLLBACK cả WMS lẫn payment gateway trong một lệnh. Khi đã giữ hàng, đã tạo hóa đơn, mà bước thu tiền hỏng vĩnh viễn, bạn phải bù trừ (compensate): chạy các thao tác nghịch đảo để đưa hệ thống về trạng thái nhất quán. Mẫu thiết kế này gọi là Saga.
Ý tưởng Saga
Saga = một chuỗi giao dịch cục bộ T1, T2, ..., Tn, mỗi cái có một giao dịch bù trừ C1, C2, ..., Cn. Nếu Tk hỏng, ta chạy C(k-1), ..., C1 theo thứ tự ngược để hoàn tác những gì đã làm.
T1 reserve -> C1 release stock
T2 invoice -> C2 void invoice
T3 charge -> C3 refund
Hỏng ở T3 (permanent) => chạy C2 (void invoice), rồi C1 (release stock)
Kết quả: hàng được nhả, hóa đơn hủy, không ai bị trừ tiền — trạng thái cuối là COMPENSATED, nhất quán.
Bù trừ phải idempotent và "chịu được" đã-xong
Bù trừ cũng chạy qua retry, nên C1..Cn cũng phải idempotent (bài 5). Ngoài ra, bù trừ phải xử lý được cả trường hợp thao tác gốc chưa thực sự xảy ra: ví dụ charge timeout nhưng thực ra chưa trừ tiền — refund phải trả "không có gì để hoàn" một cách êm, không ném lỗi.
def compensate(row):
order = COMPENSATION_ORDER # ngược với STEPS
for state_reached, adapter in order:
if row.reached(state_reached):
adapter.undo(row.context()) # undo phải idempotent
row.state = "COMPENSATED"
db.save(row)
row.reached(...) đảm bảo chỉ hoàn tác những bước đã thực sự hoàn thành — không refund một khoản chưa charge.
Điểm không hoàn tác được (point of no return)
Một số hành động không thể bù: đã giao hàng vật lý, đã gửi email cho khách. Nguyên tắc:
- Đẩy các hành động không-hoàn-tác về càng cuối càng tốt, sau khi mọi thứ có-thể-hỏng đã xong.
- Nếu buộc phải làm sớm, dùng cơ chế "pending/confirm" hai pha (ví dụ đặt giữ chỗ rồi mới xác nhận).
Khung tư duy: bảng bù trừ
Lập bảng cho mỗi bước:
| Bước | Thao tác | Bù trừ | Hoàn tác được? |
|---|---|---|---|
| Giữ hàng | reserve | release | Có |
| Hóa đơn | create | void | Có |
| Thu tiền | charge | refund | Có (T+n) |
| Giao hàng | dispatch | recall (khó) | Gần như không |
Các bước triển khai
- Với mỗi
Tk, viếtCktương ứng trong adapter (undo()). - Ghi vào
step_datacác ID cần cho bù trừ ngay khiTkthành công. - Khi một bước vào
FAILEDpermanent (hoặc hết retry), kích hoạt saga bù trừ ngược. - Chỉ bù trừ các bước đã
reached; bỏ qua bước chưa chạy. - Đặt trạng thái cuối
COMPENSATEDvà thông báo cho ops/khách. - Test: ép bước charge hỏng, xác nhận stock được nhả và invoice bị void.
Saga có phối lại (retry) trước khi bù trừ
Bù trừ tốn kém và gây phiền (khách thấy đơn bị hủy). Vì vậy đừng vội saga ngay khi một bước lỗi lần đầu — hãy để retry+backoff xử lý lỗi transient trước. Chỉ khi một bước đạt trạng thái permanent (lỗi nghiệp vụ như hết hàng) hoặc cạn số lần retry thì mới kích hoạt saga. Trình tự đúng: Tk lỗi → phân loại → nếu transient thì retry → nếu permanent hoặc hết retry → bắt đầu bù trừ ngược từ bước đã hoàn thành gần nhất.
Bù trừ ngữ nghĩa (semantic compensation)
Bù trừ hiếm khi là "undo" tuyệt đối như DB ROLLBACK. Refund một khoản đã charge không xóa lịch sử giao dịch — nó tạo một bút toán ngược. Đây gọi là bù trừ ngữ nghĩa: bạn đưa trạng thái nghiệp vụ về nhất quán, chứ không xóa dấu vết. Điều này quan trọng cho kế toán và kiểm toán: cả charge lẫn refund đều phải hiển thị trong sổ, cùng một correlation ID, để sau này ai đó tra được vì sao tiền vào rồi ra.
Sổ giao dịch (đúng):
ORD-77 charge +500 PAY-19
ORD-77 refund -500 RFN-04 (compensation của PAY-19)
Khung tư duy: forward recovery vs backward recovery
Không phải lỗi nào cũng phải lùi. Đôi khi "tiến tới" (forward recovery) tốt hơn: nếu bước gửi email lỗi nhưng phần tiền đã xong, bạn có thể retry email hoặc chuyển sang kênh khác, thay vì hủy cả đơn đã thanh toán. Hỏi: "Lùi lại có rẻ và an toàn hơn tiến tới không?" Với bước phụ, không-tiền-bạc, forward recovery thường hợp lý hơn saga đầy đủ.
Sai lầm thường gặp
- Không lưu ID của bước thành công vào state → tới lúc bù trừ không biết hủy cái gì.
- Bù trừ không idempotent: retry refund hai lần, hoàn tiền dư.
- Bù trừ ném lỗi khi "không có gì để hoàn": làm saga kẹt thay vì đi tiếp.
- Đặt bước không hoàn tác (giao hàng, gửi mail) ở giữa rồi bước sau hỏng — không có đường lùi.
- Compensate sai thứ tự: nhả hàng trước khi void hóa đơn có thể vi phạm ràng buộc phía kế toán.