Lỗi tạm thời là chuyện thường trong hệ phân tán: một timeout mạng, một 503 khi service bận, một 429 rate limit. Thử lại (retry) là phản xạ đúng — nhưng thử lại sai cách còn tệ hơn không thử. Thử lại ngay lập tức dồn tải service đang yếu; thử lại đồng loạt từ nhiều client tạo "thundering herd" đánh sập service vừa hồi. Bài này dựng retry đúng cách trong Go với hai kỹ thuật cốt lõi — exponential backoff và jitter — và đo thật vì sao chúng cần thiết.

Exponential backoff: chờ tăng gấp đôi

Ý tưởng: mỗi lần thử lại, chờ lâu hơn lần trước — thường gấp đôi. Điều này cho service lỗi ngày càng nhiều thời gian để hồi phục, thay vì bị nã liên tục:

func backoff(lan int, base, max time.Duration) time.Duration {
	d := time.Duration(float64(base) * math.Pow(2, float64(lan)))
	if d > max { d = max } // chặn trần, không tăng vô hạn
	return d
}

Đo thật (Go 1.23) với base 100ms, trần 2s:

lần 1: 100ms   lần 4: 800ms
lần 2: 200ms   lần 5: 1.6s
lần 3: 400ms   lần 6: 2s (chạm trần, không tăng nữa)

Trần (max) quan trọng: không có nó, backoff tăng vô hạn (1 phút, 1 giờ...) — vô nghĩa. Chặn trần giữ thời gian chờ trong khoảng hợp lý.

Ảnh chụp đoạn mã Go nền tối minh hoạ retry với exponential backoff và jitter thử lại thông minh tránh dồn tải, exponential backoff chờ tăng gấp đôi mỗi lần func backoff lan int base max time Duration time Duration d bằng time Duration float64 base nhân math Pow 2 float64 lan nếu d lớn hơn max d bằng max chặn trần không tăng vô hạn return d lần 0 100ms 1 200ms 2 400ms cho service lỗi thời gian hồi, full jitter ngẫu nhiên trong 0 backoff func fullJitter lan int base max time Duration time Duration d bằng backoff lan base max return time Duration rand Int63n int64 d cộng 1 0 d mỗi client chờ một khoảng khác nhau không bật dậy cùng lúc, vòng retry thử lại tới khi OK hoặc hết lượt func Retry maxLan int base max time Duration fn func error error for lan bằng 0 lan nhỏ hơn maxLan lan cộng cộng nếu err bằng fn err bằng nil return nil thành công nếu lan nhỏ hơn maxLan trừ 1 time Sleep fullJitter lan base max chờ rồi thử lại return err hết lượt vẫn lỗi, thundering herd vì sao cần jitter 10 client cùng gặp lỗi cùng backoff cùng thử lại một lúc service vừa hồi lại bị dồn 10 request cùng lúc sập tiếp jitter rải các lần thử ra tải trải đều service thở được

Hình 1: backoff tính thời gian chờ = base × 2^lần, chặn trần. fullJitter chọn ngẫu nhiên trong [0, backoff]. Vòng Retry thử lại tới khi thành công hoặc hết lượt. Không jitter thì mọi client bật dậy cùng lúc — thundering herd.

Jitter: rải các lần thử ngẫu nhiên

Đây là phần nhiều người bỏ sót. Nếu 10 client cùng gặp lỗi cùng lúc, chúng cùng backoff đúng 800ms, rồi cùng thử lại một lúc — dồn 10 request vào service vừa hồi, đánh sập nó tiếp. Đây là thundering herd. Giải pháp: thêm ngẫu nhiên (jitter). "Full jitter" chọn thời gian chờ ngẫu nhiên trong khoảng [0, backoff]:

func fullJitter(lan int, base, max time.Duration) time.Duration {
	d := backoff(lan, base, max)
	return time.Duration(rand.Int63n(int64(d) + 1)) // [0, d]
}

Đo thật, 10 client cùng retry lần 3:

  • không jitter: mọi client chờ đúng 800ms → bật dậy cùng lúc.
  • có jitter: 256ms 64ms 349ms 67ms 222ms 245ms 203ms 199ms 648ms 212ms — rải khắp 64-648ms.

Với jitter, tải trải đều theo thời gian thay vì dồn thành một đợt cục. Đây là lý do AWS và mọi hệ thống lớn khuyên dùng jitter — không có nó, retry đồng bộ có thể tự tạo ra đợt tải đánh sập chính service đang cố hồi phục.

Vòng retry hoàn chỉnh

Ghép lại thành một hàm Retry thử lại tới khi thành công hoặc hết lượt:

func Retry(maxLan int, base, max time.Duration, fn func() error) error {
	for lan := 0; lan < maxLan; lan++ {
		if err = fn(); err == nil { return nil } // thành công
		if lan < maxLan-1 {
			time.Sleep(fullJitter(lan, base, max)) // chờ rồi thử lại
		}
	}
	return err // hết lượt vẫn lỗi
}

Đo thật, service lỗi 2 lần đầu rồi thành công lần 3:

Ảnh chụp bảng kết quả đo thật nền tối backoff nhân đôi tới trần 2s jitter rải 10 client khắp 64-648ms retry OK sau 2 lỗi, go run Go 1.23 arm64 10 core, exponential backoff base 100ms trần 2s lần 1 chờ 100ms lần 2 chờ 200ms lần 3 chờ 400ms lần 4 chờ 800ms lần 5 chờ 1.6s lần 6 chờ 2s chạm trần không tăng nữa mỗi lần chờ gấp đôi cho service lỗi ngày càng nhiều thời gian hồi, full jitter 10 client cùng retry lần 3 không jitter mọi client chờ đúng 800ms bật dậy cùng lúc thundering herd dồn service vừa hồi có jitter mỗi client chờ khác nhau 256ms 64ms 349ms 67ms 222ms 245ms 203ms 199ms 648ms 212ms rải khắp 64-648ms tải trải đều không dồn cục, vòng retry chạy thật lỗi 2 lần đầu lần 3 OK lần 1 lỗi lỗi tạm thời chờ 13ms rồi thử lại lần 2 lỗi lỗi tạm thời chờ 83ms rồi thử lại kết quả sau 3 lần gọi err nil thành công không cần lần 4-5, cốt lõi backoff chờ bằng base nhân 2 mũ lần chặn trần service lỗi có thời gian hồi jitter ngẫu nhiên khoảng chờ tránh thundering herd retry loop thử tới khi OK hoặc hết lượt dừng sớm khi thành công chỉ retry lỗi tạm thời timeout 503 429 không retry lỗi logic 400 đánh đổi cần idempotency ghép circuit breaker cộng trần tổng thời gian

Hình 2: Backoff nhân đôi 100ms→2s (chạm trần). Jitter rải 10 client khắp 64-648ms thay vì cùng 800ms. Vòng retry: lần 1-2 lỗi (chờ 13ms, 83ms jittered) rồi lần 3 thành công — dừng sớm, không cần lần 4-5.

Vòng retry dừng ngay khi thành công (err=nil sau 3 lần gọi, không cần lần 4-5). Mỗi lần chờ là jittered (13ms, 83ms) nên nhiều client sẽ không đồng bộ.

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

Chỉ retry lỗi TẠM THỜI, không retry lỗi logic. Retry một timeout, 503, 429 hợp lý (chúng có thể tự hết). Nhưng retry một 400 Bad Request hay 404 là vô nghĩa — lỗi ở phía bạn, thử lại 5 lần vẫn sai, chỉ phí tài nguyên. Hàm retry phải phân biệt: kiểm loại lỗi và chỉ thử lại loại có thể phục hồi. Retry mù mọi lỗi làm hệ thống chậm và lãng phí.

Retry đòi hỏi idempotency (tính lũy đẳng). Nếu request đầu đã tới server và thực thi (nhưng response bị mất, gây timeout ở client), retry sẽ thực thi lần nữa — tạo hai đơn hàng, trừ tiền hai lần. Chỉ an toàn retry các thao tác lũy đẳng (đọc, hoặc ghi có khóa chống trùng — chủ đề bài sau). Retry thao tác không lũy đẳng mà không có bảo vệ là nguồn của lỗi dữ liệu nghiêm trọng.

Ghép retry với circuit breaker và trần tổng thời gian. Retry một mình có thể làm mọi thứ tệ hơn khi service sập hẳn (bài trước): mỗi client retry nhiều lần dồn tải. Ghép với circuit breaker (ngắt khi service chết) và một trần tổng thời gian/context deadline (ngừng retry khi đã quá lâu) để bộ máy resilience hoàn chỉnh. Ba mẫu — timeout, retry+backoff+jitter, circuit breaker — bổ trợ nhau.

Ba ý mang về

  1. Exponential backoff chờ tăng gấp đôi mỗi lần thử, chặn trần: đo thật 100ms→200→400→800→1.6s→2s (cap) — cho service lỗi ngày càng nhiều thời gian hồi phục thay vì bị nã liên tục.
  2. Jitter là bắt buộc để tránh thundering herd: đo thật, không jitter thì 10 client cùng chờ 800ms rồi bật dậy một lúc (dồn service vừa hồi), có jitter rải chúng khắp 64-648ms — tải trải đều, service thở được.
  3. Chỉ retry lỗi tạm thời và cần idempotency: retry timeout/503/429 (không retry 400), và chỉ an toàn với thao tác lũy đẳng vì request đầu có thể đã thực thi — ghép với circuit breaker và trần tổng thời gian cho bộ resilience đầy đủ.

Phần sau ta giải chính vấn đề an toàn mà retry đặt ra: Phần sau dựng idempotency key pattern trong Go — cách đảm bảo một thao tác chỉ thực thi đúng một lần dù client gửi (hoặc retry) nhiều lần.