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

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:

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ề
- 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.
- 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.
- Chỉ retry lỗi tạm thời và cần idempotency: retry
timeout/503/429(không retry400), 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.