✦

Saga Pattern — giao dịch phân tán không cần 2PC

•Phong Hy

Khách bấm đặt hàng. Hệ thống trừ kho, trừ ví, tạo vận đơn. Ba service, ba database. Bước thứ ba lỗi — giờ hoàn kho cho khách kiểu gì? Không có ROLLBACK nào chạy xuyên service cả, vì chẳng ai giữ chung một transaction để mà rollback.

Bài này nói về Saga Pattern: cách chia giao dịch phân tán thành nhiều bước nhỏ, và những chỗ mình từng sập khi áp dụng nó.

Vì sao không xài 2PC cho gọn

Two-phase commit nghe rất hợp lý: bảo tất cả service "chuẩn bị đi", ai cũng OK thì mới commit. Nhưng đem vào microservice là khổ:

  • Coordinator giữ lock trên mọi participant tới lúc commit xong — throughput tụt không phanh.
  • Coordinator chết đúng lúc commit → participant treo lock vô thời hạn, thành in-doubt transaction. Phải có người vào dọn tay.
  • Mỗi service một kiểu lưu trữ (Postgres, Mongo, Redis, service của bên thứ ba) — không có XA chung để nói chuyện.
  • Latency bằng tổng các bước cộng hai vòng round-trip, không cắt được.

Nên với hệ thống nhiều service, người ta đi đường khác.

Saga: chuỗi local transaction + hành động bù

Saga chia giao dịch lớn thành N local transaction. Mỗi bước xong là commit ngay, không chờ ai. Nếu bước thứ k lỗi, hệ thống chạy ngược các bước 1..k-1 bằng compensating transaction — hành động bù, chứ không phải rollback.

Chỗ này dễ hiểu sai nhất: bù không xoá dấu vết, bù ghi thêm một sự kiện mới. Hoàn tiền không làm dòng trừ tiền biến mất, nó tạo thêm một dòng cộng tiền. Nghĩa là trạng thái trung gian luôn lộ ra ngoài — đó là tính chất của Saga, không phải bug cần fix.

Choreography hay orchestration

Choreography — mỗi service nghe event rồi tự quyết định bước kế tiếp, không có nhạc trưởng. Nhẹ, ít hạ tầng. Nhưng tới bước thứ sáu thì không ai đọc ra luồng nghiệp vụ nằm ở đâu nữa, debug phải grep từng topic.

Orchestration — một orchestrator gọi tuần tự và giữ state của saga trong database. Code dài hơn, đổi lại luồng nằm một chỗ: dễ trace, dễ thêm timeout, dễ retry. Trên bốn bước thì mình chọn orchestration gần như luôn.

Code Go: orchestrator giữ state

type Step struct {
	Name       string
	Do         func(ctx context.Context) error // local transaction
	Compensate func(ctx context.Context) error // hành động bù
}

func (s *Saga) Run(ctx context.Context, steps []Step) error {
	var done []Step
	for _, st := range steps {
		if err := st.Do(ctx); err != nil {
			s.log.Warn("bước lỗi, chạy bù", "step", st.Name, "err", err)
			for i := len(done) - 1; i >= 0; i-- { // bù ngược, LIFO
				if cErr := done[i].Compensate(ctx); cErr != nil {
					s.log.Error("bù thất bại, cần người xử lý",
						"step", done[i].Name, "err", cErr)
				}
			}
			return err
		}
		done = append(done, st)
		s.saveCheckpoint(ctx, st.Name) // ghi state xuống DB sau mỗi bước
	}
	return nil
}

Hai điều bắt buộc: bù theo thứ tự ngược, và mọi Compensate phải idempotent — retry một hành động bù là chuyện xảy ra hằng ngày.

Bốn cái bẫy mình từng dính

1. Saga không có isolation. Bước một đã commit, bước hai chưa xong, người khác đọc vào là thấy trạng thái nửa vời. Cách chữa mình dùng là semantic lock: đánh dấu bản ghi PENDING ngay khi bắt đầu, coi PENDING như đang bị chiếm, và mọi luồng đọc phải biết xử lý nó.

2. Bù thất bại. Đơn vị vận chuyển sập đúng lúc cần huỷ đơn. Không có phép màu nào ở đây. Mình retry có backoff, hết lượt thì đẩy vào dead-letter queue kèm alert cho người xử lý. Saga không thay thế được con người trong case xấu nhất.

3. Bước không bù được. Gửi email, gọi API bên thứ ba, đẩy tiền ra ngân hàng. Đừng đặt mấy bước này ở giữa luồng — dời ra cuối cùng, hoặc tách thành luồng bất đồng bộ riêng.

4. Publish event không đáng tin. Orchestrator commit state rồi mới publish, crash giữa hai việc là mất event. Kèm Outbox Pattern: ghi event chung transaction với state, worker riêng đọc bảng outbox rồi đẩy ra queue.

Khi nào chưa cần Saga

Nếu các bước cùng nằm trong một database thì dùng transaction thường, đừng tự làm khổ mình. Nếu nghiệp vụ chỉ cần retry với idempotency key là đủ thì cũng chưa cần. Saga chỉ đáng khi có từ ba service trở lên và bước bù thực sự tồn tại. Và nếu không viết nổi Compensate cho một bước, câu hỏi đúng không phải "saga thế nào" mà là "bước đó có nên nằm trong giao dịch này không".

Saga lo tính nhất quán dài hạn, không lo tính cô lập. Nhớ đúng hai chữ đó là đỡ đau đầu khi thiết kế.

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích
Saga Giao dịch phân tán gồm nhiều local transaction, lỗi thì bù ngược
Compensating transaction Hành động bù cho một bước đã commit, không phải rollback
2PC Two-phase commit: prepare rồi commit, giữ lock toàn cục
Choreography Các service phối hợp qua event, không có nhạc trưởng
Orchestration Một orchestrator điều khiển tuần tự và giữ state của saga
Semantic lock Đánh dấu bản ghi PENDING để cảnh báo trạng thái đang dở dang
Pivot step Bước quyết định: sau nó thì saga chỉ có thể đi tiếp, không bù được
Outbox Pattern Ghi event chung transaction với state để không mất event khi crash
Dead-letter queue Hàng đợi chứa message xử lý mãi không được, để người xem lại
✦