Circuit breaker (bài 2) bảo vệ khi service chết hẳn. Nhưng nhiều lỗi chỉ thoáng qua: mạng chớp một cái, service restart trong tích tắc, một request lạc. Với lỗi tạm thời, thử lại (retry) là phản xạ đúng. Vấn đề: retry sai cách còn tệ hơn không retry. Nếu hàng nghìn client cùng gặp lỗi rồi cùng thử lại ngay và đều, chúng dội vào service đúng lúc nó đang gượng dậy — tạo bão retry (retry storm / thundering herd) đánh sập nó lần nữa. Bài này (phần 3 loạt Hệ thống chịu tải) chạy thật để thấy retry đúng cách với exponential backoff và jitter.

Cơ chế: giãn dần + tản ngẫu nhiên

Exponential backoff: sau mỗi lần thất bại, thời gian chờ tăng gấp đôi — 100ms, 200ms, 400ms, 800ms... Điều này giãn các lần thử ra, giảm tải lên service đang hồi phục thay vì đập liên tục.

backoff := base * time.Duration(1 << (attempt-1))  // 100, 200, 400ms...

Jitter: chỉ backoff thôi chưa đủ. Nếu 1000 client cùng lỗi tại t=0, tất cả cùng chờ đúng 200ms rồi cùng thử lại tại t=200ms — vẫn dội cùng lúc. Jitter thêm ngẫu nhiên vào thời gian chờ để mỗi client thử lại tại thời điểm khác nhau, tản đều tải:

if jitter {
    backoff = time.Duration(rand.Int63n(int64(backoff)))  // full jitter: [0, backoff)
}

Ảnh chụp đoạn mã Go nền tối minh hoạ retry backoff jitter, vấn đề retry ngây thơ gây bão retry lỗi thoáng qua mạng chớp service restart thử lại là đúng nhưng retry ngay và đều của nhiều client cùng lúc service vừa gượng dậy bị 1000 retry dội cùng thời điểm sập tiếp thundering herd retry storm, exponential backoff chờ tăng gấp đôi mỗi lần backoff bằng base nhân 1 dịch trái attempt trừ 1 100 200 400 800ms giãn các lần thử giảm tải lên service đang hồi, jitter thêm ngẫu nhiên để tản các client if jitter backoff bằng time Duration rand Int63n backoff full jitter 0 tới backoff không jitter mọi client chờ đúng 200ms retry cùng thời điểm có jitter mỗi client chờ khác nhau tản đều không dội cùng lúc, vòng retry hoàn chỉnh for attempt 1 tới maxAttempts if err bằng op nil return nil thành công thoát if attempt bằng maxAttempts break hết lượt bỏ time Sleep backoffWithJitter attempt chờ rồi thử lại

Hình 1: Exponential backoff giãn các lần thử (100/200/400ms); jitter thêm ngẫu nhiên để nhiều client không retry cùng thời điểm — chống bão retry.

Đo thật: backoff và sức mạnh của jitter

Mình đo hai thứ: một thao tác lỗi 3 lần rồi thành công (retry backoff), và 50 client cùng lỗi (đo độ tụ của retry):

Ảnh chụp bảng kết quả chạy thật retry backoff jitter output thật, một retry exponential backoff lỗi 3 lần đầu rồi OK số lần thử 4 tổng thời gian 722 ms kết quả thành công chờ 100ms 200ms 400ms giữa các lần giãn tải không đập liên tục, hai 50 client cùng lỗi lúc t bằng 0 thời điểm retry lần 1 không jitter đều 200ms đỉnh 50 trên 50 client retry trong cùng cửa sổ 20ms dội cùng lúc có jitter random 0-400ms đỉnh chỉ 5 trên 50 client trong 1 cửa sổ 20ms tản đều jitter cắt đỉnh tải retry 10 lần 50 xuống 5, ý nghĩa không jitter cả 50 client dội service cùng thời điểm retry storm service vừa gượng dậy lại bị đánh sập có jitter các retry tản ra nhiều thời điểm tải đỉnh thấp hơn nhiều service có cơ hội hồi phục thật sự, kết luận backoff giãn lần thử giảm tải đổi lại tăng độ trễ tổng jitter chống đồng bộ cắt đỉnh retry storm đo được 50 xuống 5 giới hạn số lần thử đừng retry vô hạn chỉ retry lỗi tạm thời và thao tác idempotent bài 9 kết hợp circuit breaker bài 2

Hình 2: Chạy thật — retry backoff: 4 lần thử (lỗi 3 + OK), 722ms (chờ 100/200/400ms); 50 client cùng lỗi: không jitter đỉnh 50/50 trong cùng cửa sổ 20ms (dội cùng lúc), có jitter đỉnh chỉ 5/50 — jitter cắt đỉnh ~10 lần.

Đọc kết quả đo được:

  • Backoff hoạt động, đổi lại tăng độ trễ: thao tác lỗi 3 lần rồi thành công cần 4 lần thử, tổng 722ms — trong đó ~700ms là thời gian chờ backoff (100+200+400). Backoff giãn các lần thử ra thay vì đập liên tục, cho service thời gian hồi. Cái giá: request thành công chậm hơn (722ms thay vì tức thì).
  • Không jitter = bão retry: 50 client cùng lỗi tại t=0, nếu chờ đều 200ms thì cả 50/50 cùng thử lại trong một cửa sổ 20ms — một cú dội đồng loạt. Với service đang gượng dậy, đây chính là cú đánh bồi làm nó sập lại. Backoff không cứu được điều này vì mọi client backoff giống hệt nhau.
  • Jitter cắt đỉnh ~10 lần: thêm jitter ngẫu nhiên (0-400ms), đỉnh chỉ còn 5/50 client trong bất kỳ cửa sổ 20ms nào — tải retry tản đều ra nhiều thời điểm. Đây là điểm mấu chốt mà nhiều người bỏ qua: jitter quan trọng ngang backoff. Backoff giãn theo thời gian, jitter tản theo client — cần cả hai.

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

Chỉ retry lỗi tạm thời, và chỉ với thao tác idempotent. Retry một lỗi vĩnh viễn (400 Bad Request, 404, lỗi logic) là vô ích — nó sẽ lỗi lại mãi, chỉ tốn thời gian và tài nguyên. Chỉ retry lỗi tạm thời: timeout, 503 Service Unavailable, lỗi kết nối. Và quan trọng: chỉ retry thao tác idempotent (làm lại nhiều lần cho cùng kết quả) — retry một lệnh "trừ tiền" không idempotent có thể trừ hai lần. Idempotency là chủ đề bài 9.

Luôn giới hạn số lần thử và tổng thời gian. Retry vô hạn biến một lỗi tạm thời thành một vòng lặp tốn tài nguyên vĩnh viễn. Đặt maxAttempts (thường 3-5) và/hoặc một deadline tổng (dùng context, bài 4). Backoff mũ cũng cần trần (cap) — nếu không, lần thử thứ 20 sẽ chờ hàng giờ. Thực tế: min(base * 2^n, maxBackoff).

Retry và circuit breaker bổ sung nhau, không thay thế. Retry xử lý lỗi thoáng qua (thử lại thì qua); circuit breaker (bài 2) xử lý lỗi kéo dài (ngừng thử để service hồi). Kết hợp: retry cho vài lần đầu, nhưng nếu breaker đã mở thì đừng retry (retry vào breaker đang mở chỉ tổ vô ích). Nếu chỉ có retry mà không có breaker, retry storm vẫn có thể xảy ra khi service chết hẳn — backoff+jitter giảm nhẹ nhưng breaker mới chặn triệt để.

Ba ý mang về

  1. Exponential backoff giãn các lần thử, giảm tải lên service đang hồi: đo thật thao tác lỗi 3 lần rồi OK cần 4 lần thử trong 722ms (chờ 100/200/400ms) — đổi độ trễ lấy việc không đập service liên tục.
  2. Jitter quan trọng ngang backoff — nó chống bão retry: đo thật 50 client cùng lỗi, không jitter đỉnh 50/50 dội cùng lúc, có jitter đỉnh chỉ 5/50 (cắt ~10 lần); backoff giãn theo thời gian, jitter tản theo client, cần cả hai.
  3. Retry có kỷ luật: giới hạn số lần, chỉ lỗi tạm thời + idempotent, kết hợp breaker: đặt maxAttempts + cap backoff + deadline; chỉ retry lỗi tạm thời và thao tác idempotent (bài 9); dùng cùng circuit breaker (bài 2) để chặn khi service chết hẳn.

Nguồn

Phần sau ta gặp thứ nền tảng cho mọi mẫu trên: timeout và context cancellation — đặt hạn cho mỗi thao tác và hủy sạch khi quá hạn để không rò rỉ tài nguyên; đo thật việc giải phóng goroutine/kết nối khi request bị hủy.