Timeout, Retry, Backoff + Jitter, Deadline

4 câu hỏi~30 phútTừ khóa: transient failure, exponential backoff, jitter, retry storm, retry amplification, retry budget, deadline propagation, idempotency key

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

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ếtNếu thiếu
TimeoutKhông chờ một call quá lâuThread bị giữ, cascading failure
RetryLỗi tạm thờiLỗi thoáng qua thành lỗi của user
BackoffKhông retry dồn dậpRetry storm
JitterKhông retry cùng thời điểmSpike đồng loạt (thundering herd)
Retry budget / một tầngChặn khuếch đại3 tầng × 3 = 27 lần
DeadlineGiới hạn tổng thời gianTầng dưới vẫn làm khi tầng trên đã bỏ cuộc
Idempotency keyRetry không lặp business operationTrừ tiền 2 lần

7. Khi nào nên dùng / không nên dùng

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

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


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.