Ở phần 3 loạt này, retry với backoff là người hùng: một lỗi mạng tạm thời, thử lại vài lần là qua. Nhưng retry có một mặt tối mà nếu không chuẩn bị, nó sẽ biến người hùng thành kẻ phá hoại. Kịch bản kinh điển: client gửi request "trừ 100k", server xử lý xong, trừ tiền thành công, nhưng đúng lúc gửi phản hồi về thì mạng đứt. Client không nhận được 200 OK, tưởng request thất bại, và... retry. Server nhận request y hệt, và trừ tiền lần thứ hai. Khách bị trừ 200k cho một giao dịch.

Vấn đề gốc: retry an toàn với thao tác đọc, nguy hiểm với thao tác có side-effect. Một GET gọi 5 lần vẫn cho cùng kết quả, không hại gì. Một POST /charge gọi 5 lần trừ tiền 5 lần. Lời giải là làm cho thao tác idempotent: gọi nhiều lần cũng chỉ gây một side-effect, đúng như gọi một lần. Bài này (phần 9 loạt Hệ thống chịu tải) đo thật cả hai mặt trong Go.

Cơ chế: idempotency key và store phân biệt trạng thái

Bản thân "trừ tiền" không thể tự idempotent — không có cách nào để doCharge() biết lần gọi này là mới hay là retry của lần trước. Thông tin đó phải đến từ client: client sinh một idempotency key duy nhất cho mỗi ý định giao dịch (không phải mỗi lần gửi), rồi gửi kèm key đó trong mọi lần retry. Server dùng key này để nhận ra "à, giao dịch này tôi làm rồi".

Server cần một store ánh xạ key → kết quả. Nhưng có một chi tiết tinh tế mà nhiều bản cài ẩu bỏ qua: giữa các lần retry, request đầu tiên có thể vẫn đang chạy. Nếu ta chỉ kiểm "key đã có kết quả chưa", hai request cùng key đến gần nhau đều thấy "chưa có" và cùng chạy side-effect — hỏng. Store phải phân biệt ba trạng thái: chưa thấy key này, đang xử lý (chờ), và đã xong (trả kết quả cũ).

Ảnh chụp đoạn mã nền tối minh hoạ cơ chế idempotency, phần side-effect thật hàm doCharge nhận amount tăng biến đếm sideEffects rồi trừ vào balance trả về chuỗi charged retry 5 lần cùng 1 giao dịch dẫn tới trừ tiền 5 lần là lỗi, phần store phân biệt đang xử lý và đã xong hàm Do nhận key và fn khoá mutex nếu key đã thấy thì mở khoá chờ wg Wait rồi trả về false không chạy side-effect nếu chưa thì tạo result mới wg Add một gán vào map mở khoá chỉ request đầu của key chạy fn xong wg Done trả về true lần đầu

Hình 1: doCharge là side-effect thật (trừ số dư, đếm số lần bị gọi); IdemStore.Do khoá map một nhịp — nếu key đã thấy thì chờ và trả kết quả đã lưu (không chạy lại), nếu chưa thì chỉ request đầu tiên chạy side-effect. WaitGroup xử lý đúng trường hợp request trùng tới khi cái đầu chưa xong.

Đây chính là mẫu store idempotency, và nó gần như trùng với singleflight ở phần 8 — khác biệt về ý định: singleflight gộp để giảm tải nguồn, idempotency store dedup để đảm bảo đúng một side-effect. Bản cài tối giản:

type result struct {
	wg   sync.WaitGroup
	val  string
	done bool
}
type IdemStore struct {
	mu sync.Mutex
	m  map[string]*result
}

func (s *IdemStore) Do(key string, fn func() string) (string, bool) {
	s.mu.Lock()
	if s.m == nil {
		s.m = map[string]*result{}
	}
	if r, ok := s.m[key]; ok { // key đã thấy: đang xử lý hoặc đã xong
		s.mu.Unlock()
		r.wg.Wait()         // đang xử lý -> chờ; đã xong -> qua ngay
		return r.val, false // false = KHÔNG chạy side-effect (trùng)
	}
	r := &result{}
	r.wg.Add(1)
	s.m[key] = r
	s.mu.Unlock()

	r.val = fn() // chỉ request ĐẦU của key này chạy side-effect
	r.done = true
	r.wg.Done()
	return r.val, true // true = đã chạy side-effect (lần đầu)
}

Đo thật trong go-lab

Mình chạy trong go-lab (golang 1.23) ba tình huống với cùng một side-effect doCharge (trừ 100 vào số dư 1000, đếm số lần trừ thật). Quan trọng: chạy bằng go run -race để chắc chắn không có data race.

Ảnh chụp bảng kết quả chạy thật trong go-lab output thật golang 1.23 go run -race không có data race, mục một KHÔNG idempotency retry 5 lần cùng 1 thanh toán số lần trừ tiền thật 5 số dư 500 đúng ra chỉ nên trừ 1 lần về 900 lỗi khách bị trừ tiền 5 lần cho 1 giao dịch, mục hai CÓ idempotency key retry 5 lần cùng key số lần trừ tiền thật 1 số dư 900 lần chạy side-effect 1 đúng chỉ trừ 1 lần 4 lần sau trả kết quả đã lưu, mục ba Race 1000 goroutine gửi cùng key đồng thời số lần trừ tiền thật 1 số dư 900 lần chạy side-effect 1 mutex đảm bảo đúng 1 side-effect dù 1000 request đua nhau

Hình 2: Kết quả thật — không idempotency: retry 5 lần → trừ tiền 5 lần (số dư 1000 → 500); có idempotency key: 1 lần (số dư 900); 1000 goroutine cùng key đồng thời: vẫn 1 lần, race detector không báo lỗi nào.

Ba con số:

  • Không idempotency: 5 lần trừ tiền, số dư 500. Đây là bug đúng nghĩa — retry 5 lần biến một giao dịch 100 thành 500. Trên hệ thống thật, đây là loại lỗi khiến khách gọi tổng đài và làm sổ sách sai lệch.
  • Có idempotency key: đúng 1 lần, số dư 900. 4 lần retry sau đều thấy key đã có, trả về kết quả đã lưu mà không chạm số dư. Chính xác như gọi một lần.
  • 1000 goroutine cùng key đồng thời: vẫn đúng 1 lần. Đây là bài kiểm tra khắc nghiệt: nếu logic kiểm-rồi-ghi (check-then-act) không được khoá đúng, một số goroutine sẽ lọt qua khe và cùng chạy side-effect. mutex bao quanh thao tác đọc-ghi map đảm bảo chỉ một goroutine tạo được entry mới; 999 cái còn lại thấy key đã tồn tại và chờ. go run -race xác nhận không có truy cập bộ nhớ đồng thời không an toàn.

Điểm mấu chốt từ tình huống (3): idempotency không chỉ là chuyện "đã thấy key chưa" — nó là bài toán đồng thời. Store phải xử lý đúng cả khi các request trùng đến cùng lúc, không chỉ tuần tự.

Đánh đổi cần cân nhắc

Store cần TTL — nhưng chọn TTL là đánh đổi thật. Giữ mọi idempotency key mãi mãi là rò rỉ bộ nhớ (và với store phân tán như Redis là phình dung lượng). Nhưng xoá key quá sớm thì một retry đến muộn (client timeout dài, hàng đợi chậm) sẽ bị coi là request mới và chạy lại side-effect. TTL phải dài hơn cửa sổ retry tối đa của client — thường vài giờ tới 24h cho thanh toán. Đây là lý do idempotency key production hầu như luôn nằm ở store bền (database, Redis) chứ không phải map trong RAM như demo: RAM mất khi restart, và nhiều instance server không chia sẻ map.

"Đang xử lý" cần timeout riêng, kẻo một request treo khoá cả key. Trong demo, nếu fn() treo mãi, mọi retry của key đó sẽ wg.Wait() treo theo. Production phải có: side-effect có timeout (phần 4), và entry "đang xử lý" tự hết hạn sau một khoảng để nếu request đầu chết giữa chừng (server crash), retry sau còn có cơ hội hoàn tất giao dịch. Ghép idempotency với timeout + retry, không dùng lẻ.

Exactly-once là ảo tưởng; thực chất là at-least-once + dedup. Không có hệ thống phân tán nào đảm bảo một message được xử lý đúng một lần ở tầng truyền tải — mạng luôn có thể mất phản hồi và buộc gửi lại. Cái ta xây được là at-least-once (retry đảm bảo không mất) cộng dedup ở phía nhận (idempotency đảm bảo không thừa). Hiểu điều này quan trọng: đừng tìm "kênh exactly-once thần kỳ", hãy làm mọi thao tác có side-effect trở nên idempotent và để retry lo phần còn lại. Với thao tác vốn đã idempotent tự nhiên (SET x=5, DELETE id=7) thì không cần key; chỉ thao tác tích luỹ (x += 5, trừ tiền, gửi email) mới cần.

Ba ý mang về

  1. Retry an toàn cho đọc, nguy hiểm cho side-effect: đo thật, một handler thanh toán không idempotent bị retry 5 lần trừ tiền 5 lần (số dư 1000 → 500) — mất phản hồi khiến client gửi lại một giao dịch đã thành công.
  2. Idempotency key + store phân biệt trạng thái giải quyết triệt để: đo thật, thêm key do client sinh, con số về đúng 1 lần (số dư 900), và với 1000 goroutine gửi cùng key đồng thời vẫn đúng 1 lần (mutex khoá check-then-act, go run -race sạch) — nhớ phân biệt "đang xử lý" với "đã xong" để request trùng đến sớm không lọt.
  3. Exactly-once là at-least-once + dedup: retry đảm bảo không mất, idempotency đảm bảo không thừa; store cần TTL dài hơn cửa sổ retry và nên bền (Redis/DB) chứ không phải RAM, entry "đang xử lý" cần timeout kẻo một request treo khoá cả key.

Nguồn

Phần sau ta bàn về bulkhead: cô lập tài nguyên giữa các phần của hệ thống, để một phụ thuộc chậm không hút cạn toàn bộ luồng và kéo sập những phần vốn khỏe mạnh.