Mạng thì thỉnh thoảng lỗi — một gói mất, một server chập chờn, một timeout. Phản ứng tự nhiên của mọi client là thử lại. Nhưng thử lại là con dao hai lưỡi: làm đúng thì cứu được request lẽ ra hỏng; làm ẩu thì biến một cú vấp ngắn của server thành một cú sập kéo dài, vì hàng loạt client cùng dội thêm tải vào đúng lúc nó yếu nhất. Bài này dựng 50 client thử lại một server đang lỗi trong container, đo cả hai mặt của con dao đó — và phát hiện cái mẹo "dùng exponential backoff" mà ai cũng biết chỉ giải quyết được một nửa vấn đề.

Thoái lui và jitter

Thử lại, thoái lui, và jitter

Có ba mức tinh vi dần khi thử lại:

  • Thử lại ngay (immediate): lỗi là gửi lại liền. Đơn giản, nhưng nếu server đang quá tải, bạn vừa đổ thêm dầu vào lửa — mỗi client hóa thành một cỗ máy phát request.
  • Thoái lui (backoff): chờ một khoảng giữa các lần thử, và thường tăng dần theo cấp số nhân — 1 giây, rồi 2, rồi 4, rồi 8. Ý tưởng: cho server thời gian thở, và nếu nó vẫn lỗi thì mình lùi ngày càng xa.
  • Jitter: thêm ngẫu nhiên vào khoảng chờ, để các client không đồng loạt thử lại đúng cùng một thời điểm.

Backoff nghe như đã đủ. Tôi vào đo với niềm tin đó, và jitter — mảnh ghép mà tôi xem là tùy chọn làm đẹp — hóa ra mới là thứ quyết định.

Đo: backoff không jitter vẫn thành sóng

Tôi cho 50 client cùng lúc gọi một server luôn trả lỗi, mỗi client thử lại 4 lần với backoff cấp số nhân (chờ 1, 2, 4 giây). Server ghi lại thời điểm nhận mỗi request; tôi gom vào các ô một giây để xem khi nào các request dội tới.

Backoff KHÔNG jitter cho phân bố này (số client gửi trong mỗi giây):

giây 1: ~50   giây 2: ~50   giây 4: ~50   giây 8: ~50
giây 3, 5, 6, 7: gần như TRỐNG

Các request dồn thành từng đợt — gần 50 client đập vào server cùng một lúc ở giây 1, rồi lại đồng loạt ở giây 2, giây 4, giây 8, với những khoảng trống hoàn toàn ở giữa. Backoff cấp số nhân không hề làm tan cái bầy đàn; nó chỉ khiến cả bầy cùng nhau đi theo sóng. Server đang ốm, thay vì bị dội đều, lại bị đấm từng cú 50 request đồng bộ.

Backoff CÓ jitter (khoảng chờ ngẫu nhiên từ 0 tới mức backoff) cho phân bố khác hẳn:

giây 1: 62   giây 2: 56   giây 3: 30   giây 4: 22
giây 5: 13   giây 6: 14   giây 7: 2    giây 8: 1

Giờ các lần thử trải mượt ra theo thời gian, thoải dần, không còn đợt dập nào và không còn khoảng trống. Cùng một backoff cấp số nhân, chỉ khác ở chỗ thêm ngẫu nhiên, mà tải ở server biến từ những cú đấm dồn cục thành một dòng chảy đều.

Một lần tôi đo hớ: backoff mới làm nửa việc

Tôi bước vào bài này đinh ninh rằng chỉ cần dùng exponential backoff là dập được "bão thử lại", và định coi jitter là thứ trang trí thêm nếu rảnh. Phép đo phân bố trên bác bỏ thẳng.

Lý do rõ khi nghĩ kỹ: nếu 50 client cùng gặp lỗi tại một thời điểm (điều rất thường xảy ra — server chớp tắt một cái là tất cả client đang gọi nó cùng nhận lỗi), thì chúng cùng khởi động bộ đếm backoff giống hệt nhau. Client nào cũng chờ đúng 1 giây, nên tất cả thử lại đúng ở giây 1. Cùng chờ 2 giây tiếp, nên lại đồng loạt ở giây 3. Backoff cấp số nhân giữ chúng đồng bộ — nó dời cả bầy đi, nhưng vẫn nguyên một bầy. Đây chính là "thundering herd" (bầy thú giẫm đạp) phiên bản thử lại, và nó có thể tự duy trì: mỗi đợt dập đồng bộ làm server lỗi tiếp, khiến cả bầy lại đồng bộ backoff, rồi lại dập — mãi mãi.

Jitter phá vỡ sự đồng bộ đó. Bằng cách cho mỗi client chờ một khoảng ngẫu nhiên (ví dụ từ 0 tới mức backoff hiện tại), hai client cùng lỗi một lúc sẽ thử lại ở hai thời điểm khác nhau, và cả bầy tãi ra thành dòng chảy đều. Đây không phải chi tiết làm đẹp — nó là mảnh ghép thiết yếu.

Bài học đo lường: một giải pháp "gần đúng" phổ biến có thể chỉ giải quyết một nửa vấn đề, và nửa còn lại chỉ lộ ra khi đo ở đúng quy mô nhiều client. Nếu tôi chỉ thử với một client, backoff trông hoàn hảo — có một client thì làm gì có bầy để mà giẫm đạp. Phải có 50 client cùng lỗi mới thấy backoff-không-jitter để lộ cái đuôi đồng bộ của nó. Đo một client rồi kết luận cho cả hệ là cách chắc chắn bỏ sót đúng vấn đề mà hệ thật gặp.

Khuếch đại tải: thử lại có thể giết server

Mặt kia của con dao đo được rõ không kém. Tôi cho server chỉ lỗi 2 giây rồi hồi phục, và đếm tổng số request mà 50 client dội vào nó:

Chiến lược Tổng request server nhận
Thử lại ngay (mỗi 0,05s) 2000
Exponential backoff 300

Với thử lại ngay, mỗi client bắn ~40 request trong 2 giây server còn ốm — 50 client thành 2000 request dội vào đúng lúc nó yếu nhất. Đây là khuếch đại tải: một sự cố chỉ đáng 2 giây bị chính cơ chế thử lại thổi thành một cơn bão, và server có thể không bao giờ ngóc đầu dậy được để hồi phục — một cú vấp ngắn hóa thành sập kéo dài (kiểu hỏng "metastable"). Backoff cắt con số đó xuống 300 (ít hơn ~6,7 lần), cho server khoảng lặng để tự đứng lên.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là luôn dùng exponential backoff CỘNG jitter, không phải chỉ một trong hai. Backoff giảm tổng tải dội vào server ốm; jitter phá sự đồng bộ giữa các client. Thiếu jitter, một đội client đông sẽ tự tổ chức thành các đợt dập nhịp nhàng, và mỗi đợt có thể làm server lỗi tiếp — vòng luẩn quẩn. Công thức chuẩn (kiểu "full jitter" mà AWS khuyến nghị) đơn giản: chờ = ngẫu_nhiên(0, min(trần, cơ_sở × 2^lần_thử)). Vài dòng, nhưng là khác biệt giữa một hệ tự hồi phục và một hệ tự dìm mình.

Hệ quả thứ hai là đặt trần số lần thử và tôn trọng tín hiệu từ server. Thử lại vô hạn là công thức giữ cho một server chết mãi không dậy nổi. Giới hạn số lần (rồi báo lỗi lên trên), và nếu server trả Retry-After hay 429 Too Many Requests, hãy nghe theo — nó đang nói thẳng cho bạn biết khi nào quay lại. Cũng nên phân biệt lỗi đáng thử lại (timeout, 503, mất kết nối tạm) với lỗi không nên thử lại (400, 401, 404 — thử lại chỉ phí công vì kết quả sẽ y hệt).

Hệ quả thứ ba là một bài học đo lường xuyên suốt sê-ri: hành vi ở quy mô nhiều client khác hẳn hành vi một client, và chỉ đo ở đúng quy mô mới thấy. Con số mang theo: thử lại ngay dội 2000 request vào một server chỉ lỗi 2 giây (gấp 6,7 lần backoff, đủ giết nó); và exponential backoff KHÔNG jitter vẫn khiến các client thử lại thành từng đợt ~50 gói đồng bộ ở giây 1, 2, 4, 8 — jitter mới tãi chúng ra mượt. Thử lại là cần thiết, nhưng thử lại có kỷ luật — backoff, jitter, có trần, tôn trọng tín hiệu — mới là thứ cứu được request mà không giết server.

Thử ba mươi giây

Mở đoạn code thử lại trong dự án của bạn (hoặc trong thư viện HTTP client bạn dùng) và soi ba thứ: nó chờ bao lâu giữa các lần (cố định hay tăng dần?), có ngẫu nhiên hóa khoảng chờ không (jitter?), và có trần số lần không. Nếu thấy "chờ cố định 1 giây, thử 5 lần" — bạn có một cỗ máy tạo bầy đàn đồng bộ; thêm cấp số nhân và jitter. Nếu thấy backoff cấp số nhân mà không jitter — bạn đã đi được nửa đường, và nửa còn thiếu chính là cái làm 50 client thôi giẫm đạp lên nhau. Chỉ ba dòng kiểm tra đó, và bạn biết code của mình đang cứu server hay đang rình giết nó.