Cuộc gọi API thanh toán của bạn thất bại lúc 2 giờ sáng. Đó là do sự cố mạng tạm thời, giới hạn tốc độ truy cập, hay máy chủ chết? Câu trả lời quyết định liệu việc thử lại có cứu được giao dịch hay khiến khách hàng bị tính phí hai lần.
Việc thử lại là mẫu hình khả năng phục hồi phổ biến nhất trong các hệ thống phân tán, và cũng là mẫu hình bị thực hiện sai nhiều nhất. Một vòng lặp bao quanh một cuộc gọi HTTP có vẻ giống như lập trình phòng thủ. Nếu làm sai, nó biến một sự cố mất điện 30 giây thành một sự cố 30 phút, bởi vì hàng ngàn client cùng lúc 'tấn công' một máy chủ đang gặp khó khăn. Nếu làm đúng, việc thử lại sẽ hấp thụ các lỗi tạm thời một cách gọn gàng đến mức người dùng của bạn không bao giờ nhận thấy chúng.
Hướng dẫn này bao gồm logic thử lại mà các hệ thống sản xuất phụ thuộc vào: những mã trạng thái nào nên thử lại, công thức lùi thời gian theo cấp số nhân (exponential backoff) với độ trễ ngẫu nhiên đầy đủ (full jitter), các tiêu đề Retry-After, khóa bất biến (idempotency keys), ngân sách thử lại (retry budgets), và bộ ngắt mạch (circuit breakers). Bạn cũng sẽ thấy cách chứng minh client của mình hoạt động đúng bằng cách mô phỏng các lỗi 429 và 503 bằng máy chủ giả lập Apidog, bởi vì một mẫu thử lại mà bạn chưa bao giờ kiểm thử với một máy chủ bị lỗi chỉ là một sự phỏng đoán, không phải một thiết kế. Các nhóm xây dựng logic thử lại API tài chính (fintech) học được điều này một cách tốn kém; bạn thì không cần.
Tại sao các lần thử lại đơn giản lại làm cho sự cố tồi tệ hơn
Hãy hình dung một dịch vụ xử lý 1.000 yêu cầu mỗi giây. Nó gặp sự cố trong năm giây. Mọi client thử lại ngay lập tức, mỗi client ba lần. 1.000 yêu cầu/giây ban đầu của bạn trở thành 4.000 yêu cầu/giây nhắm vào một máy chủ vốn đã quá tải. Nó sụp đổ hoàn toàn. Bây giờ mọi client lại thử lại một lần nữa.
Vòng lặp phản hồi đó có một tên gọi: bão thử lại (retry storm). Sự đồng loạt tấn công khi máy chủ hoạt động trở lại được gọi là 'đàn súc vật giẫm đạp' (thundering herd). Sách SRE của Google chỉ ra mẫu hình này trong chương về xử lý các lỗi dây chuyền: việc thử lại mà không có backoff sẽ khuếch đại tải đúng vào thời điểm hệ thống không thể chịu đựng nổi, và có thể khiến một dịch vụ ngừng hoạt động rất lâu sau khi lỗi ban đầu đã được khắc phục.
Hai lỗi thiết kế gây ra hầu hết các cơn bão thử lại:
- Không có độ trễ giữa các lần thử. Việc thử lại ngay lập tức làm tăng tải trong khoảng thời gian tồi tệ nhất có thể.
- Độ trễ cố định. Nếu mọi client đều đợi chính xác một giây, tất cả chúng sẽ quay lại cùng lúc. Máy chủ nhận được các làn sóng lưu lượng đồng bộ thay vì một sự gia tăng mượt mà.
Giải pháp không phải là “không bao giờ thử lại.” Giải pháp là thử lại một cách có chọn lọc, với độ trễ ngẫu nhiên tăng dần, và với một giới hạn cứng về lượng tải bổ sung mà các lần thử lại của bạn gây ra.
Thử lại các lỗi này, không bao giờ thử lại các lỗi kia
Trước bất kỳ tính toán backoff nào, client của bạn cần một bảng quyết định. Việc thử lại một yêu cầu mà máy chủ đã từ chối là không hợp lệ sẽ lãng phí dung lượng và làm ô nhiễm nhật ký. Mục đích chính là thử lại một lỗi tạm thời.
Thử lại những trường hợp sau:
| Tín hiệu | Ý nghĩa |
|---|---|
| 429 Too Many Requests | Bạn đã đạt giới hạn tốc độ. Hãy lùi lại và quay lại chậm hơn. |
| 502 Bad Gateway | Một thiết bị trung gian trả về dữ liệu rác. Thường là tạm thời. |
| 503 Service Unavailable | Máy chủ đang quá tải hoặc đang khởi động lại. |
| 504 Gateway Timeout | Một phụ thuộc phía trên quá chậm. |
| Đặt lại kết nối, lỗi DNS, thời gian chờ socket | Yêu cầu có thể chưa bao giờ đến nơi. |
Lỗi 504 gateway timeout cần được đặc biệt quan tâm: máy chủ gốc có thể đã xử lý yêu cầu của bạn mặc dù cổng đã từ bỏ việc chờ đợi. Sự khác biệt này rất quan trọng khi chúng ta nói về tính bất biến (idempotency).
Không bao giờ thử lại những trường hợp sau:
| Tín hiệu | Ý nghĩa |
|---|---|
| 400 Bad Request | Dữ liệu gửi đi của bạn bị lỗi định dạng. Lần sau cũng sẽ bị lỗi định dạng. |
| 401 Unauthorized | Thông tin đăng nhập của bạn sai hoặc đã hết hạn. Hãy làm mới token, đừng lặp lại. |
| 403 Forbidden | Bạn không có quyền. Việc thử lại sẽ không cấp quyền đó. |
| 422 Unprocessable Entity | Xác thực thất bại. Hãy sửa dữ liệu, không phải thời gian. |
Quy tắc: thử lại khi lỗi liên quan đến trạng thái của máy chủ hoặc mạng. Thất bại nhanh chóng khi lỗi liên quan đến yêu cầu của bạn. Lỗi 429 nằm ở giữa; nó có thể thử lại, nhưng nó cũng là một tín hiệu cho thấy tốc độ yêu cầu tổng thể của bạn cần được cải thiện, đây là một vấn đề giới hạn tốc độ cần được giải quyết trước bất kỳ vòng lặp thử lại nào.
Công thức lùi thời gian theo cấp số nhân (exponential backoff), và tại sao độ trễ ngẫu nhiên (jitter) lại quan trọng
Lùi thời gian theo cấp số nhân có nghĩa là mỗi lần thử lại sẽ chờ lâu hơn lần trước, mặc định là gấp đôi:
delay = base * 2^retry_count
Với base là 500 ms, đó là 0.5 giây, 1 giây, 2 giây, 4 giây, 8 giây. Thêm một giới hạn (ví dụ 30 giây) để độ trễ không kéo dài đến vài phút:
delay = min(cap, base * 2^retry_count)
Điều này giải quyết vấn đề “tấn công” nhưng không giải quyết vấn đề đồng bộ hóa. Nếu 5.000 client thất bại cùng một lúc, backoff theo cấp số nhân thông thường sẽ khiến tất cả 5.000 client quay lại vào thời điểm t=0.5 giây, sau đó t=1 giây, sau đó t=2 giây. Vẫn là những làn sóng. Vẫn là một đàn, chỉ là lịch sự hơn một chút.
Độ trễ ngẫu nhiên (Jitter) phá vỡ sự đồng bộ bằng cách ngẫu nhiên hóa thời gian chờ. Blog Kiến trúc AWS đã phân tích các con số trong phân tích backoff theo cấp số nhân và jitter của mình, mô phỏng các client cạnh tranh chống lại một tài nguyên bị tranh chấp. Backoff không có jitter vẫn tạo ra các đỉnh cuộc gọi bị nhóm lại. Full jitter, chọn một độ trễ ngẫu nhiên giữa 0 và giới hạn cấp số nhân, tạo ra cả số lượng cuộc gọi ít nhất và thời gian hoàn thành gần như ngắn nhất:
delay = random_between(0, min(cap, base * 2^retry_count))
Kết quả đó làm mọi người ngạc nhiên. Việc ngẫu nhiên hóa hoàn toàn xuống đến 0 có vẻ cẩu thả so với một lịch trình nhân đôi gọn gàng. Nhưng việc phân bổ client đồng đều trong khoảng thời gian chờ chính là điều giúp giữ tải máy chủ ổn định. Phân tích của AWS cũng đã thử nghiệm “equal jitter” (một nửa cố định, một nửa ngẫu nhiên) và “decorrelated jitter”; full jitter và decorrelated jitter đã vượt trội, và full jitter là cách đơn giản nhất để viết đúng. Hãy sử dụng nó làm mẫu thử lại mặc định của bạn trừ khi bạn có các số liệu cho thấy điều ngược lại.
Hãy tuân thủ Retry-After khi máy chủ yêu cầu
Backoff là khi client của bạn đoán xem nên đợi bao lâu. Đôi khi máy chủ loại bỏ sự phỏng đoán đó. Tiêu đề Retry-After, được định nghĩa cho các phản hồi 429 và 503, mang theo số giây hoặc một ngày HTTP:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Khi tiêu đề này xuất hiện, nó sẽ ghi đè lên thời gian backoff đã tính toán của bạn. Máy chủ biết khi nào cửa sổ giới hạn tốc độ của nó được đặt lại hoặc khi nào việc bảo trì của nó kết thúc; lịch trình theo cấp số nhân của bạn thì không. Các client bỏ qua Retry-After là một lý do khiến các nhà cung cấp leo thang từ việc điều tiết sang cấm hoàn toàn. Hãy phân tích, tôn trọng nó và vẫn áp dụng giới hạn và số lần thử lại tối đa của bạn để một Retry-After: 86400 độc hại hoặc bị lỗi không thể làm treo tiến trình của bạn trong một ngày.
Tính bất biến (Idempotency): điều kiện tiên quyết để thử lại POST
Đây là cái bẫy trong lỗi 504 đã nói ở trên. GET, PUT và DELETE là bất biến (idempotent) theo hợp đồng: gửi chúng hai lần vẫn để lại hệ thống trong cùng một trạng thái. POST thì không. Nếu POST /v1/payments hết thời gian chờ sau khi máy chủ đã xử lý nó, việc bạn thử lại sẽ tạo ra một khoản thanh toán thứ hai. Xin chúc mừng, bạn đã xây dựng một cỗ máy tính phí hai lần với thời gian hoạt động tuyệt vời.
Giải pháp là một khóa bất biến (idempotency key): một ID duy nhất do client tạo (thường là UUID) được gửi dưới dạng tiêu đề trên mỗi thao tác logic. Máy chủ lưu trữ khóa cùng với phản hồi đầu tiên và phát lại phản hồi đã lưu đó cho bất kỳ bản sao nào. Các yêu cầu bất biến của Stripe hoạt động chính xác theo cách này, và hầu hết các API thanh toán và cung cấp dịch vụ đã làm theo.
Hai quy tắc để các khóa hoạt động hiệu quả:
- Cùng một thao tác, cùng một khóa. Mỗi lần thử lại của một thanh toán logic sẽ tái sử dụng một khóa. Một hành động mới của người dùng sẽ nhận được một khóa mới.
- Tạo khóa trước khi gửi lần đầu, không phải bên trong vòng lặp thử lại. Nếu không, mỗi lần thử lại sẽ trông giống như một thao tác mới và sự bảo vệ sẽ biến mất.
Nếu API bạn đang gọi không hỗ trợ khóa bất biến, đừng tự động thử lại các thao tác ghi không bất biến. Hãy báo lỗi và để con người hoặc một tác vụ đối soát quyết định.
Ngân sách thử lại (Retry budgets) và bộ ngắt mạch (circuit breakers): lối thoát hiểm
Backoff định hình thời điểm các lần thử lại xảy ra. Nó không giới hạn số lần thử lại. Trong một sự cố kéo dài, ngay cả các client có jitter tốt vẫn tích lũy tải thử lại, và các lần thử lại xếp lớp sẽ nhân lên: nếu gateway API của bạn thử lại 3 lần và client dịch vụ của bạn thử lại 3 lần, một cú nhấp chuột của người dùng có thể biến thành 9 yêu cầu.
Hai cơ chế giới hạn thiệt hại:
Ngân sách thử lại (Retry budgets). Thay vì “3 lần thử lại cho mỗi yêu cầu,” hãy thực thi “các lần thử lại chỉ được phép thêm tối đa 10% tải bổ sung,” được đo trên một cửa sổ trượt. Khi ngân sách hết, các lỗi sẽ trả về ngay lập tức. Điều này giữ cho sự khuếch đại thử lại bị giới hạn cho dù có bao nhiêu yêu cầu thất bại cùng một lúc. Linkerd và Envoy đều cung cấp tính năng này dưới dạng cấu hình hạng nhất.
Bộ ngắt mạch (Circuit breakers). Theo dõi tỷ lệ lỗi cho mỗi phụ thuộc phía dưới. Khi tỷ lệ này vượt qua một ngưỡng, bộ ngắt mạch sẽ mở: các cuộc gọi thất bại ngay lập tức mà không cần chạm vào mạng. Sau một thời gian nguội, một vài yêu cầu thăm dò sẽ kiểm tra xem phụ thuộc đã phục hồi chưa trước khi bộ ngắt mạch đóng lại. Nơi backoff lịch sự làm chậm sự giẫm đạp, bộ ngắt mạch sẽ hủy bỏ nó. Mọi thiết kế thử lại nghiêm túc đều kết hợp cả hai, vì backoff một mình vẫn sẽ gửi mọi yêu cầu cuối cùng.
Một ví dụ sẵn sàng cho môi trường sản xuất bằng Python
Đây là toàn bộ mẫu hình được tổng hợp: lọc mã trạng thái có thể thử lại, độ trễ ngẫu nhiên đầy đủ (full jitter), hỗ trợ Retry-After, khóa bất biến (idempotency key) và giới hạn cứng số lần thử lại.
import random
import time
import uuid
import requests
RETRYABLE = {429, 502, 503, 504}
BASE = 0.5 # giây
CAP = 30.0 # giới hạn cho bất kỳ độ trễ nào
MAX_RETRIES = 5
def create_payment(payload):
idempotency_key = str(uuid.uuid4()) # một khóa cho mỗi thanh toán logic
headers = {"Idempotency-Key": idempotency_key}
for retry_count in range(MAX_RETRIES + 1):
try:
resp = requests.post(
"https://api.acmepay.com/v1/payments",
json=payload, headers=headers, timeout=10,
)
if resp.status_code < 400:
return resp.json()
if resp.status_code not in RETRYABLE:
resp.raise_for_status() # 400/401/403/422: thất bại nhanh chóng
retry_after = resp.headers.get("Retry-After")
except (requests.ConnectionError, requests.Timeout):
retry_after = None # lỗi mạng: chuyển sang backoff
if retry_count == MAX_RETRIES:
raise RuntimeError("payment failed after all retries") # thanh toán thất bại sau tất cả các lần thử lại
if retry_after and retry_after.isdigit():
delay = min(CAP, float(retry_after))
else:
delay = random.uniform(0, min(CAP, BASE * 2 ** retry_count))
time.sleep(delay)
Điều đáng chú ý: khóa được tạo ra một lần, bên ngoài vòng lặp. Retry-After ưu tiên hơn thời gian backoff đã tính toán nhưng vẫn tôn trọng giới hạn. Các trạng thái không thể thử lại sẽ ngay lập tức gây lỗi. Nếu bạn đang làm việc với JavaScript, thư viện axios-retry cung cấp cho bạn cấu trúc tương tự với các hook retryCondition và retryDelay; bảng quyết định vẫn giữ nguyên.
Cách kiểm thử hành vi thử lại trước khi môi trường sản xuất làm điều đó cho bạn
Hầu hết các nhóm phát hành mã thử lại mà chưa bao giờ thực thi nhánh lỗi của nó. Đường dẫn thành công đã được kiểm thử; đường dẫn 503 chỉ chạy lần đầu tiên trong một sự cố thực tế. Bạn có thể làm tốt hơn với hai tính năng của Apidog.
Mô phỏng lỗi bằng máy chủ giả lập (mock servers). Tính năng smart mock của Apidog cho phép bạn định nghĩa một endpoint như /v1/payments và viết script cho các phản hồi của nó. Hãy khiến nó trả về 503 cho hai cuộc gọi đầu tiên và 200 ở cuộc gọi thứ ba, hoặc trả về 429 với Retry-After: 5, hoặc thêm độ trễ 15 giây để kích hoạt thời gian chờ của client. Hướng client của bạn đến URL giả lập và xem vòng lặp thử lại xử lý từng kịch bản, không cần sự cố sản xuất nào cả.
Kiểm tra hành vi của client bằng các kịch bản kiểm thử (test scenarios). Các kịch bản kiểm thử của Apidog xâu chuỗi các yêu cầu với các khẳng định (assertions) và kiểm tra thời gian. Xây dựng một kịch bản chạy với máy chủ giả lập không ổn định của bạn và khẳng định cuộc gọi cuối cùng thành công, tổng thời gian đã trôi qua nằm trong khoảng backoff dự kiến của bạn và chính xác một tài nguyên đã được tạo (chứng minh khóa bất biến của bạn đã hoạt động). Kết nối kịch bản này vào CI và logic thử lại của bạn sẽ được thực hành trên mỗi lần commit thay vì mỗi lần xảy ra sự cố.
Đây là sự khác biệt giữa “chúng tôi đã thêm chức năng thử lại” và “chúng tôi đã xác minh client của mình vẫn hoạt động khi một phụ thuộc bị giới hạn tốc độ và ngừng hoạt động một phần.” Tải xuống Apidog miễn phí và bạn có thể có một máy chủ giả lập bị lỗi đang chạy để kiểm tra client của mình chỉ trong khoảng mười phút.
Câu hỏi thường gặp
Có nên thử lại lỗi 429 không?
Có, và đây là một mã trạng thái mà máy chủ thường cho bạn biết cách xử lý. Hãy đọc tiêu đề Retry-After và đợi ít nhất thời gian đó; chuyển sang lùi thời gian theo cấp số nhân với độ trễ ngẫu nhiên nếu tiêu đề bị thiếu. Ngoài ra, hãy coi các lỗi 429 lặp đi lặp lại là tín hiệu để điều chỉnh tốc độ yêu cầu của bạn bằng cách điều tiết hoặc lưu trữ phía client, chứ không phải là hoạt động bình thường.
Full jitter là gì?
Full jitter chọn mỗi độ trễ thử lại một cách ngẫu nhiên đồng đều giữa 0 và giới hạn cấp số nhân: random(0, min(cap, base * 2^n)). Nó ngăn chặn các làn sóng thử lại đồng bộ từ nhiều client. Trong các mô phỏng của AWS, nó đã vượt trội hơn backoff thông thường và equal jitter về cả tổng số cuộc gọi được thực hiện và thời gian hoàn thành, đó là lý do tại sao nó là mặc định trong các SDK của AWS.
Có an toàn khi thử lại các yêu cầu POST không?
Chỉ khi yêu cầu đó thực sự bất biến (idempotent) trên thực tế, nghĩa là đối với POST, bạn phải gửi một khóa bất biến (idempotency key) mà máy chủ sẽ sử dụng để chống trùng lặp. Nếu không có khóa này, việc thử lại sau khi hết thời gian chờ có thể nhân đôi một khoản thanh toán, đơn hàng hoặc bản ghi, bởi vì máy chủ có thể đã xử lý yêu cầu mà bạn nghĩ là đã thất bại. Các tác nhân AI gọi API ghi thường xuyên gặp phải điều này; các mẫu phục hồi lỗi tác nhân cũng giống như những gì được đề cập ở đây: ghi có khóa (keyed writes), thử lại có giới hạn và bộ ngắt mạch.
Nên thử lại bao nhiêu lần?
Ba đến năm lần thử thường xử lý được hầu hết các lỗi tạm thời; vượt quá số đó, tỷ lệ thành công sẽ chững lại trong khi tải và độ trễ tiếp tục tăng. Hãy kết hợp giới hạn cho mỗi yêu cầu với một ngân sách thử lại toàn cầu (ví dụ: các lần thử lại có thể thêm tối đa 10% lưu lượng truy cập) để một sự cố ngừng hoạt động hoàn toàn không thể làm tăng tải của bạn. Nếu một phụ thuộc vẫn ngừng hoạt động sau lần thử lại cuối cùng của bạn, đó là lãnh địa của bộ ngắt mạch, chứ không phải lãnh địa của thử lại.
