Timeout, Retry, Backoff + Jitter, Deadline
Tiếp đi: timeout rồi thì retry thế nào cho đúng?
Đây là phần hay bị hỏi tiếp ngay sau câu "C fail thì sao?". Bài trước ta có:
order-service (A) → payment-service (B) → bank/PSP (C)
B gọi C với timeout 2s → TimeoutException
1. Bài toán
Timeout đã giải phóng thread của B. Nhưng user vẫn nhận lỗi. Nhiều lỗi chỉ là tạm thời (network chập chờn, C restart, một pod của C quá tải):
Request #1 → ❌
Request #2 → ❌
Request #3 → ✅
Retry giúp hệ thống tự phục hồi với transient failure, lỗi tạm thời.
2. Vấn đề
(a) Không phải lỗi nào cũng nên retry.
C → 400 Bad Request { "amount": -100 } retry 100 lần vẫn ❌
C → 503 / connection reset / timeout có thể chỉ tạm thời
Đừng học thuộc "5xx retry, 4xx không". Ví dụ 429 Too Many Requests có thể retry (theo Retry-After), còn 500 do bug thì retry vô ích. Phụ thuộc API contract.
(b) Retry ngay lập tức làm C chết nặng hơn (retry storm).
C overload → timeout → clients retry → traffic tăng
↑ │
└──────── C overload nặng hơn ◄──────────┘
(c) Retry nhân lên qua nhiều tầng (retry amplification). Mỗi tầng retry 3 lần:
Client retry 3 ─► A retry 3 ─► B retry 3 ─► C
1 click của user = 3 × 3 × 3 = 27 request tới C
C đang chết mà nhận gấp 27 lần traffic
(d) Retry thao tác có side effect gây trừ tiền 2 lần.
Request #1 Pay $100 → C: -$100 ✅ → response lost → B timeout → retry
Request #2 Pay $100 → C: -$100 ✅
Khách bị trừ $200 💀
3. Giải pháp
- Chỉ retry lỗi transient và chỉ retry thao tác idempotent (GET, PUT theo ID) hoặc có idempotency key.
- Exponential backoff: delay tăng dần giữa các lần thử: 1s → 2s → 4s → 8s (có trần).
- Jitter: thêm ngẫu nhiên để 10.000 client không cùng retry đúng 12:00:01. "Full jitter":
sleep = random(0, min(cap, base × 2^attempt)). - Retry ở một tầng thôi: thường tầng gần dependency nhất (B gọi C). Tầng trên không retry, hoặc chỉ retry khi lỗi được đánh dấu retryable.
- Retry budget: retry tối đa ~10% so với request gốc trong một cửa sổ thời gian. Khi C chết thật, số retry bị chặn thay vì nhân 3.
- Deadline: toàn bộ request có một hạn chót tuyệt đối, truyền xuống các tầng. Hết hạn thì không retry nữa.
4. Code từng bước
Bước 1: Dependency: spring-boot-starter-web (RestClient). Ở đây viết tay để thấy cơ chế; config bằng Resilience4j xem Bài 4.6.6.
Bước 2: Config
bank:
read-timeout: 1s # mỗi attempt
max-attempts: 3
base-backoff: 200ms
max-backoff: 2s
Bước 3: Code Java: retry có backoff + full jitter + deadline + idempotency key.
@Component
public class BankCaller {
private final RestClient bank;
private final BankProps props;
public BankCaller(RestClient bank, BankProps props) {
this.bank = bank;
this.props = props;
}
public BankResult charge(String paymentId, BigDecimal amount, Instant deadline) {
for (int attempt = 0; ; attempt++) {
Duration left = Duration.between(Instant.now(), deadline);
if (left.isNegative() || left.isZero()) {
throw new DeadlineExceededException(paymentId);
}
try {
return bank.post().uri("/charges")
.header("Idempotency-Key", paymentId) // cùng key mọi attempt
.header("X-Deadline", deadline.toString()) // truyền deadline xuống
.body(new ChargeRequest(paymentId, amount))
.retrieve()
.body(BankResult.class);
} catch (HttpClientErrorException e) {
if (e.getStatusCode().value() != 429) throw e; // 4xx: không retry
if (attempt + 1 >= props.maxAttempts()) throw e;
} catch (ResourceAccessException | HttpServerErrorException e) {
if (attempt + 1 >= props.maxAttempts()) throw e; // timeout, 5xx: retry
}
sleepWithJitter(attempt, deadline);
}
}
private void sleepWithJitter(int attempt, Instant deadline) {
long cap = props.maxBackoff().toMillis();
long exp = Math.min(cap, props.baseBackoff().toMillis() * (1L << attempt));
long sleep = ThreadLocalRandom.current().nextLong(exp + 1); // full jitter
long left = Duration.between(Instant.now(), deadline).toMillis();
try {
Thread.sleep(Math.max(0, Math.min(sleep, left)));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IllegalStateException(ie);
}
}
}
Phía C (hoặc chính B khi A gọi B) xử lý idempotency key:
@Transactional
public BankResult handle(String key, ChargeRequest req) {
return repo.findByIdempotencyKey(key)
.map(ChargeRecord::toResult) // đã xử lý → trả kết quả cũ
.orElseGet(() -> {
ChargeRecord rec = repo.save(ChargeRecord.of(key, req)); // UNIQUE(idempotency_key)
return rec.toResult();
});
}
Cột idempotency_key phải có UNIQUE constraint: hai request trùng đến cùng lúc thì một cái bị lỗi duplicate key thay vì cả hai cùng trừ tiền.
Bước 4: Test bằng curl
# gửi 2 lần cùng key → chỉ trừ 1 lần
curl -s -X POST localhost:8083/charges -H 'Idempotency-Key: PAY-123' \
-H 'Content-Type: application/json' -d '{"amount":100}'
curl -s -X POST localhost:8083/charges -H 'Idempotency-Key: PAY-123' \
-H 'Content-Type: application/json' -d '{"amount":100}'
# → cả 2 trả {"chargeId":"CH-1","status":"APPROVED"}, DB chỉ có 1 dòng
5. Cách hoạt động
Backoff không jitter: 10.000 client fail lúc 12:00:00
12:00:01 ↑↑↑↑↑↑↑↑↑↑ 10.000 retry cùng lúc → spike
12:00:03 ↑↑↑↑↑↑↑↑↑↑ spike lần 2
Backoff + full jitter:
12:00:00 → 12:00:02 ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ rải đều → C kịp thở
Idempotency:
Request #1 PAY-123 → C lưu PAY-123 → SUCCESS → -$100
response lost, B timeout, retry
Request #2 PAY-123 → C: đã xử lý → trả kết quả cũ, không trừ nữa
6. So sánh
| Cơ chế | Giải quyết | Nếu thiếu |
|---|---|---|
| Timeout | Không chờ một call quá lâu | Thread bị giữ, cascading failure |
| Retry | Lỗi tạm thời | Lỗi thoáng qua thành lỗi của user |
| Backoff | Không retry dồn dập | Retry storm |
| Jitter | Không retry cùng thời điểm | Spike đồng loạt (thundering herd) |
| Retry budget / một tầng | Chặn khuếch đại | 3 tầng × 3 = 27 lần |
| Deadline | Giới hạn tổng thời gian | Tầng dưới vẫn làm khi tầng trên đã bỏ cuộc |
| Idempotency key | Retry không lặp business operation | Trừ tiền 2 lần |
7. Khi nào nên dùng / không nên dùng
- Nên retry: GET, thao tác idempotent, lỗi timeout/503/connection reset, số lần nhỏ (2–3).
- Không nên retry: lỗi validation 4xx, POST không có idempotency key, khi circuit đang OPEN, khi deadline sắp hết.
- Không nên để mọi tầng cùng retry.
Trước khi qua phần mới: mình thấy retry làm tăng thời gian. Vậy lúc đó B phải chờ tổng cộng rất lâu mới thật sự failed à?
Đúng. Bạn bắt đúng một trade-off quan trọng của retry. Nếu B retry ngay trong cùng request thì tổng thời gian khá lâu:
Timeout mỗi lần = 2s, retry tối đa 3 lần, backoff 1s → 2s
B → C
├─ attempt #1 chờ 2s → TIMEOUT
├─ backoff 1s
├─ attempt #2 chờ 2s → TIMEOUT
├─ backoff 2s
├─ attempt #3 chờ 2s → TIMEOUT
└─ FAILED
Tổng ≈ 2 + 1 + 2 + 2 + 2 = 9 giây
Và A cũng đang chờ B ~9s. Nếu A cũng retry 3 lần thì user chờ gần 30s. Vậy phải nghĩ theo deadline / timeout budget từ trên xuống:
Client cho A tối đa: 5s
A không thể cho B: 10s ❌
Client
└─ A: budget 5s
└─ B: budget 4s (còn lại sau khi A tự xử lý)
└─ C: timeout 1s + tối đa 2 retry ngắn, dừng khi hết 4s
- Timeout tầng dưới phải nhỏ hơn tầng trên. Nếu A timeout 3s mà B vẫn retry C đến 9s thì 6s cuối là công cốc: không ai còn chờ kết quả, nhưng C vẫn bị đập.
- Deadline propagation: truyền thời điểm hết hạn tuyệt đối (header
X-Deadline, gRPC làm sẵn bằng deadline). Mỗi tầng tính phần còn lại và bỏ việc khi hết. - Backoff không nhất thiết phải
sleep()giữ thread. Với async/non-blocking có thể schedule retry sau. Nhưng request end-to-end vẫn chưa xong, user vẫn thấy chậm.
Retry nhiều
↑
Khả năng recover │ nhưng latency tăng, giữ resource lâu
▼
Retry ít
Và đây chính là lý do Circuit Breaker cần tồn tại: C chỉ lỗi thoáng qua thì retry cứu được; C chết thật thì retry rất tốn. Biết C đang chết thì request tiếp theo nên fail ngay, không mất 9s.
Idempotency key thì ai sinh ra? Mỗi lần retry tạo key mới được không?
Không được. Đây là lỗi hay gặp nhất. Key phải gắn với một ý định nghiệp vụ (một lần thanh toán), không gắn với một HTTP request.
SAI: attempt #1 key=uuid-1, attempt #2 key=uuid-2 → C thấy 2 thao tác khác nhau → trừ 2 lần
ĐÚNG: payment PAY-123 được tạo 1 lần, mọi attempt đều dùng key=PAY-123
- Bên khởi tạo thao tác sinh key: client sinh khi user bấm "Thanh toán" (để chống double click), hoặc B sinh khi tạo bản ghi payment rồi lưu DB trước khi gọi C.
- Bên nhận lưu key với UNIQUE constraint và kết quả, trả lại kết quả cũ khi gặp key trùng. Nên kiểm tra cả payload: cùng key mà amount khác thì trả 422, vì đó là bug.
- Key có TTL (ví dụ 24h) vì không cần giữ mãi.
Chi tiết idempotent consumer cho message (Kafka/RabbitMQ) xem Kafka delivery semantics.
Phỏng vấn hỏi về retry, backoff và idempotency thì trả lời sao?
Q1. Khi nào nên retry và retry thế nào cho an toàn?
🇻🇳 Tiếng Việt:
Chỉ retry lỗi tạm thời như timeout, 503, connection reset, không retry lỗi validation. Thao tác phải idempotent hoặc có idempotency key. Số lần retry nhỏ, dùng exponential backoff có trần và jitter để các client không retry cùng lúc. Chỉ retry ở một tầng và có retry budget để tránh retry storm.
🇬🇧 English:
I only retry transient errors such as timeouts, 503s or connection resets, never validation errors. The operation must be idempotent or carry an idempotency key. I keep the number of attempts small and use capped exponential backoff with jitter so clients don't retry in sync. I retry at one layer only and use a retry budget to avoid retry storms.
Q2. Retry storm / retry amplification là gì?
🇻🇳 Tiếng Việt:
Khi dependency quá tải, mọi client retry làm traffic tăng thêm, dependency càng quá tải và càng nhiều timeout. Nếu nhiều tầng cùng retry thì số request nhân lên, ví dụ ba tầng mỗi tầng ba lần là 27 request cho một lần bấm. Cách chống là backoff, jitter, retry một tầng, retry budget và circuit breaker.
🇬🇧 English:
When a dependency is overloaded, every client retrying adds more traffic, which causes more timeouts and even more retries. If several layers retry, the load multiplies: three layers with three attempts each turn one click into 27 requests. Backoff, jitter, retrying at a single layer, retry budgets and circuit breakers prevent it.
Q3. Deadline propagation là gì, khác timeout chỗ nào?
🇻🇳 Tiếng Việt:
Timeout là thời gian chờ của một call. Deadline là hạn chót tuyệt đối của cả request, được truyền xuống các service phía dưới. Mỗi tầng chỉ dùng phần thời gian còn lại, và dừng retry hoặc bỏ việc khi hết hạn, để không làm việc vô ích khi caller đã bỏ cuộc.
🇬🇧 English:
A timeout bounds a single call. A deadline is an absolute cutoff for the whole request and is propagated to downstream services. Each layer only uses the remaining time and stops retrying or abandons work once it expires, so nobody keeps working after the caller has given up.
Cách nhớ ngắn gọn
- Timeout → Retry (transient) → Backoff → Jitter → Idempotency.
- Retry một tầng, có budget; 3 × 3 × 3 = 27.
- Timeout tầng dưới < tầng trên; truyền deadline xuống.
- Idempotency key theo ý định nghiệp vụ, giữ nguyên qua mọi attempt, UNIQUE trong DB.
Tự kiểm tra
Vì sao backoff không thôi chưa đủ, cần thêm jitter?
Mọi client cùng fail lúc đó sẽ cùng retry sau đúng 1s, tạo spike. Jitter rải các lần retry theo thời gian.
Timeout 2s, 3 attempt, backoff 1s rồi 2s. Tổng thời gian tệ nhất?
2 + 1 + 2 + 2 + 2 = 9 giây.
Retry ở mọi tầng gây ra điều gì?
Retry amplification: số request tới dependency nhân theo cấp số (3 tầng × 3 lần = 27).
Mỗi attempt sinh idempotency key mới thì sao?
Mất tác dụng: bên nhận coi là thao tác khác nhau, vẫn trừ tiền nhiều lần.
Vì sao bảng idempotency cần UNIQUE constraint?
Để hai request trùng đến đồng thời không cùng vượt qua bước "đã xử lý chưa?" rồi cùng thực hiện.