Danh sách bài viết

Bài 59: Retry Strategy — Exponential Backoff & Retry Limit

Khi một job thất bại, câu hỏi không phải là "có retry không" mà là "retry như thế nào". Retry ngay lập tức, retry mãi mãi, hay retry cả lỗi permanent đều là anti-pattern tốn tài nguyên và làm downstream thêm tệ. Bài này đi qua phân loại lỗi, công thức backoff, jitter để tránh thundering herd, kết hợp delayed queue từ bài 58 cho cold retry, và điều kiện tiên quyết là job idempotent.

28/05/2026
14 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

  • Phân biệt transient failure và permanent failure, biết khi nào nên retry và khi nào không.
  • Phân loại lỗi retryable / non-retryable và ánh xạ vào exception riêng trong code.
  • Hiểu công thức exponential backoff và tại sao cần cap max delay.
  • Giải thích thundering herd và cách jitter (full jitter / equal jitter) giải quyết.
  • Biết cách kết hợp delayed queue (bài 58) để thực hiện cold retry thay vì hot retry trong worker.
  • Biết chọn retry limit N phù hợp và khi nào đẩy sang Dead Letter Queue (bài 60).
  • Nhớ rằng retry đồng nghĩa duplicate execution — job phải idempotent trước khi bật retry.
2

Vì Sao Cần Retry

Trong hệ thống phân tán, lỗi tạm thời là bình thường: network mất kết nối trong tích tắc, downstream service tạm thời quá tải, database deadlock giải phóng sau vài mili-giây. Những lỗi này không cần can thiệp của con người — chờ rồi thử lại là đủ.

Phân biệt hai loại lỗi từ đầu:

  • Transient failure: nguyên nhân là tạm thời, tự khắc phục. Ví dụ: network blip, downstream service trả 503 vì tạm quá tải, DB lock timeout vì transaction khác đang giữ lock. Retry sau delay có xác suất cao thành công.
  • Permanent failure: nguyên nhân không tự khắc phục. Ví dụ: input không hợp lệ, user không tồn tại, business rule vi phạm. Retry sẽ cho cùng một kết quả thất bại — vô ích, chỉ tốn tài nguyên.

Nguyên tắc: chỉ retry transient failure. Permanent failure phải được xử lý khác (log, alert, đẩy DLQ, trả lỗi về producer).

3

Classify Error — Retryable vs Non-retryable

Phân loại lỗi phải được thể hiện rõ trong code bằng exception type riêng biệt, không phải bằng chuỗi if-else kiểm tra message lỗi.

Retryable

  • Network timeout, connection refused (service tạm không đến được).
  • HTTP 5xx transient: 503 Service Unavailable, 502 Bad Gateway.
  • HTTP 429 Too Many Requests (rate limit của downstream).
  • DB deadlock, lock wait timeout.
  • Distributed lock contention (bài 40-41): acquire thất bại vì key đang bị giữ.

Non-retryable

  • HTTP 4xx client error: 400 Bad Request (input sai), 401 Unauthorized (credentials sai), 403 Forbidden, 404 Not Found (resource không tồn tại).
  • Business validation fail: số tiền âm, email không hợp lệ, user đã bị xóa.
  • Data corruption: JSON không parse được, schema mismatch.
  • Logic error: divide by zero, null pointer — bug trong code, retry không sửa được bug.

Exception riêng trong code

class RetryableError(Exception):
    """Lỗi tạm thời — có thể retry sau delay."""
    pass

class NonRetryableError(Exception):
    """Lỗi vĩnh viễn — KHÔNG retry, đẩy DLQ."""
    pass

# Ánh xạ lỗi từ external service
def call_payment_api(payload):
    try:
        resp = requests.post(PAYMENT_URL, json=payload, timeout=5)
    except requests.Timeout:
        raise RetryableError("Payment API timeout")
    except requests.ConnectionError:
        raise RetryableError("Connection refused to payment API")

    if resp.status_code == 429:
        raise RetryableError(f"Rate limited: {resp.headers.get('Retry-After')}s")
    if resp.status_code >= 500:
        raise RetryableError(f"5xx: {resp.status_code}")
    if resp.status_code == 400:
        raise NonRetryableError(f"Bad input: {resp.text}")
    if resp.status_code in (401, 403):
        raise NonRetryableError(f"Auth error: {resp.status_code}")
    if resp.status_code == 404:
        raise NonRetryableError("Resource not found — permanent")

    resp.raise_for_status()
    return resp.json()

Tách exception type giúp worker xử lý đơn giản: except RetryableError → schedule retry, except NonRetryableError → đẩy DLQ ngay.

4

Immediate Retry — Anti-pattern

Pattern thường thấy trong code thiếu kinh nghiệm:

MAX_RETRIES = 3

for attempt in range(MAX_RETRIES):
    try:
        result = process(payload)
        break
    except RetryableError:
        continue  # retry NGAY, không delay

Vấn đề:

  • Retry ngay khi downstream vừa fail nghĩa là downstream chưa có thời gian phục hồi. Ba lần retry trong 1ms đều sẽ fail nếu service đang quá tải.
  • 1.000 job cùng fail → cùng retry ngay → 1.000 request thêm gửi đến downstream đang yếu → tình trạng tệ hơn thay vì tốt hơn.
  • sleep() trong vòng lặp block worker thread — worker không xử lý được job khác trong lúc chờ.

Cần delay giữa các lần retry, và delay phải tăng theo thời gian để downstream có đủ cơ hội phục hồi.

5

Exponential Backoff

Exponential backoff là chiến lược tăng delay theo cấp số nhân sau mỗi lần fail:

  • retry_count = 0 → delay 1s
  • retry_count = 1 → delay 2s
  • retry_count = 2 → delay 4s
  • retry_count = 3 → delay 8s
  • retry_count = 4 → delay 16s
  • retry_count = 5 → delay 32s

Công thức: delay = base × 2^retry_count. Cần cap max để không chờ vô hạn:

def get_backoff(retry_count: int, base: float = 1.0, cap: float = 300.0) -> float:
    """
    Trả về delay (giây) cho retry_count lần thất bại liên tiếp.
    base: delay cơ sở (giây) cho lần retry đầu tiên (retry_count=0)
    cap:  delay tối đa (giây) — mặc định 5 phút
    """
    return min(base * (2 ** retry_count), cap)

# Ví dụ:
# get_backoff(0)  →  1.0s
# get_backoff(5)  → 32.0s
# get_backoff(10) → 300.0s  (bị cap)
# get_backoff(20) → 300.0s  (bị cap)

Tại sao cap? Nếu downstream down cả ngày, delay 16 giờ không có ý nghĩa thực tiễn. Max 5 phút thường là mức cân bằng giữa "cho downstream đủ thời gian" và "không đợi quá lâu".

Lưu ý: exponential backoff không giải quyết được thundering herd (xem bài tiếp theo). Khi nhiều job fail cùng thời điểm, tất cả đều tính cùng delay → cùng retry đồng loạt → vẫn spike.

6

Jitter — Chống Thundering Herd

Thundering herd: 1.000 job thất bại cùng lúc lúc 12:00:00. Tất cả tính backoff = 4s (retry_count = 1). Lúc 12:00:04, 1.000 job cùng retry → spike tải lên downstream → downstream fail lại → 1.000 job cùng fail, cùng tính backoff 8s → spike lúc 12:00:12... vòng lặp.

Jitter giải quyết bằng cách thêm ngẫu nhiên vào delay để các job retry ở các thời điểm khác nhau, dàn đều tải.

Full jitter

Delay = random trong khoảng [0, backoff]. Đơn giản nhất, dàn đều nhất.

import random

def backoff_with_full_jitter(retry_count: int, base: float = 1.0, cap: float = 300.0) -> float:
    exp = min(base * (2 ** retry_count), cap)
    return random.uniform(0, exp)

# 1000 job cùng retry_count=1, base=1:
# exp = 2.0
# mỗi job nhận delay ngẫu nhiên [0, 2.0]
# → trải đều trong 2 giây, không spike

Equal jitter

Delay = half backoff cố định + half ngẫu nhiên. Đảm bảo delay không bao giờ = 0 (job không retry quá sớm), vẫn có đủ spread.

def backoff_with_equal_jitter(retry_count: int, base: float = 1.0, cap: float = 300.0) -> float:
    exp = min(base * (2 ** retry_count), cap)
    half = exp / 2
    return half + random.uniform(0, half)

# retry_count=1, base=1: exp=2.0, half=1.0
# delay trong [1.0, 2.0] — không quá nhanh, không quá chậm

AWS Well-Architected Framework khuyến nghị full jitter cho hầu hết trường hợp. Equal jitter phù hợp hơn khi bạn cần đảm bảo minimum delay (tránh retry sớm hơn downstream cần để phục hồi).

7

Cold Retry Với Delayed Queue

Bài 58 trình bày cách dùng Sorted Set làm delayed queue: score là Unix timestamp (ms), worker poll bằng ZRANGEBYSCORE lấy job đến hạn rồi xử lý.

Kết hợp hai cơ chế: khi job fail retryable, không retry ngay trong worker mà schedule retry vào delayed queue với timestamp = now + backoff. Worker poll định kỳ sẽ pick up job khi đến hạn.

import json
import time
import redis

r = redis.Redis()
DELAYED_RETRY_ZSET = "delayed:retries"
MAX_RETRIES = 5

def handle_failed_job(job_id: str, payload: dict, retry_count: int, error: Exception):
    """Xử lý job fail: quyết định retry hay đẩy DLQ."""
    if not isinstance(error, RetryableError):
        # Permanent failure — đẩy DLQ (chi tiết ở bài 60)
        push_to_dlq(job_id, payload, str(error))
        return

    if retry_count >= MAX_RETRIES:
        # Hết lượt retry
        push_to_dlq(job_id, payload, f"max_retries={MAX_RETRIES} reached: {error}")
        return

    delay_sec = backoff_with_full_jitter(retry_count)
    execute_at_ms = int((time.time() + delay_sec) * 1000)

    retry_payload = {
        **payload,
        "retry_count": retry_count + 1,
        "original_job_id": job_id,
    }
    r.zadd(
        DELAYED_RETRY_ZSET,
        {json.dumps({"job_id": job_id, "payload": retry_payload}): execute_at_ms}
    )

Worker cho delayed queue (tương tự bài 58) poll mỗi giây, lấy job đến hạn, đẩy vào stream chính để consumer group xử lý lại:

MAIN_STREAM = "jobs:main"

def delayed_retry_scheduler():
    """Poll delayed queue, re-enqueue job đến hạn vào stream chính."""
    while True:
        now_ms = int(time.time() * 1000)
        due = r.zrangebyscore(DELAYED_RETRY_ZSET, 0, now_ms, start=0, num=50)
        if due:
            pipe = r.pipeline()
            for item in due:
                job = json.loads(item)
                pipe.xadd(
                    MAIN_STREAM,
                    {k: str(v) if not isinstance(v, str) else v
                     for k, v in job["payload"].items()}
                )
                pipe.zrem(DELAYED_RETRY_ZSET, item)
            pipe.execute()
        time.sleep(1)
8

Track retry_count Trong Message

Để quyết định "đây là lần retry thứ mấy?", worker cần biết retry_count. Có ba cách lưu:

1. Field trong message payload

Đơn giản nhất: mỗi lần schedule retry, tăng retry_count trong payload rồi XADD vào stream.

# Lần đầu producer gửi: retry_count không có → mặc định 0
# Lần retry thứ 1: payload["retry_count"] = 1
# Lần retry thứ 2: payload["retry_count"] = 2
retry_count = int(fields.get(b"retry_count", b"0"))

Ưu điểm: tự chứa — message mang đủ thông tin. Nhược điểm: mỗi retry là message mới, không có history gốc liên kết.

2. Redis Hash riêng

JOB_META_KEY = "job:meta:{job_id}"

# Tăng retry_count atomically
new_count = r.hincrby(f"job:meta:{job_id}", "retry_count", 1)
r.expire(f"job:meta:{job_id}", 86400 * 3)  # TTL 3 ngày

Phù hợp khi cần track thêm metadata (first_fail_time, last_error, error history). Cần nhớ set TTL để tránh key tồn tại mãi.

3. Delivery count từ PEL (XPENDING)

Redis Streams tracking số lần re-deliver trong PEL (delivery-count field của XPENDING). Có thể dùng làm proxy cho retry_count trong một số trường hợp, nhưng không chính xác hoàn toàn vì delivery_count tăng khi XAUTOCLAIM re-deliver, không chỉ khi job fail. Không nên dùng làm retry_count duy nhất.

9

Retry Limit — Chọn N

Không có giá trị universal cho MAX_RETRIES. Chọn dựa trên:

  • 3 retry: phù hợp transient nhanh — network blip, rate limit dưới vài giây. Tổng thời gian chờ (full jitter, base=1): tối đa ~7s. Đủ cho nhiều trường hợp.
  • 5 retry: chịu được downtime ngắn của downstream (vài phút). Delay cuối cùng (retry_count=4): lên đến ~16s sau jitter.
  • 10 retry: downstream có thể down dài (vài chục phút). Với cap=300s, total wait có thể hơn 30 phút. Chỉ dùng khi bài toán chấp nhận chờ lâu.
  • >10 retry: hiếm khi đúng. Nếu cần retry >10 lần, thường nghĩa là downstream có vấn đề cần con người xử lý, không phải retry tiếp. Nên đẩy DLQ + alert.

Tổng thời gian chờ ước tính (full jitter, base=1s, cap=300s)

MAX_RETRIES Delay trung bình từng lần Tổng trung bình Dùng khi
3 0.5 + 1 + 2 = 3.5s ~3.5s Transient nhanh, SLA nhạy cảm
5 0.5 + 1 + 2 + 4 + 8 = 15.5s ~15s Downtime ngắn của downstream
8 ... + 32 + 64 + 128 ~4 phút Downtime trung bình
10 cap nhiều lần ở 300s ~30+ phút Downtime dài, chấp nhận trễ

Giá trị tổng là ước tính trung bình của full jitter. Worst case (jitter luôn ở đỉnh) sẽ gấp đôi.

10

Idempotency — Điều Kiện Tiên Quyết

Retry = chạy lại job đã fail. Nếu job đã chạy được một phần trước khi fail (ví dụ: ghi DB xong nhưng gửi email chưa xong), retry sẽ chạy lại từ đầu. Nếu job không idempotent, kết quả là side effect bị nhân đôi.

Job idempotent có nghĩa: chạy lần 2 với cùng input cho cùng state cuối cùng, không có effect thêm. Một số kỹ thuật:

  • Email: trước khi gửi, kiểm tra bảng sent_notifications theo (recipient_id, template_id, date). Nếu đã gửi hôm nay → skip.
  • Payment: dùng idempotency key (payment_id hoặc order_id). Trước khi charge, check xem key này đã được xử lý chưa. Nếu rồi → trả result cũ, không charge lại. Pattern này được đề cập ở bài 48.
  • DB write: dùng INSERT ... ON CONFLICT DO NOTHING hoặc ON CONFLICT DO UPDATE. Unique constraint trên DB ngăn duplicate record.
  • Inventory update: thay vì quantity -= 1 (không idempotent), dùng SET quantity = X WHERE quantity = X + 1 AND version = Y (optimistic lock).

Nếu job không thể làm idempotent (rất hiếm), phải dùng distributed lock xung quanh logic để đảm bảo chỉ một lần chạy tại một thời điểm — phức tạp hơn nhiều và có chi phí throughput.

11

Worker Pattern Hoàn Chỉnh

Kết hợp tất cả phần trên thành worker xử lý stream với retry strategy đầy đủ:

import json
import time
import redis

r = redis.Redis(decode_responses=True)
STREAM     = "jobs:main"
GROUP      = "workers"
CONSUMER   = "worker-1"
MAX_RETRIES = 5

def worker():
    # Tạo group nếu chưa có
    try:
        r.xgroup_create(STREAM, GROUP, id="0", mkstream=True)
    except redis.exceptions.ResponseError:
        pass  # group đã tồn tại

    while True:
        msgs = r.xreadgroup(GROUP, CONSUMER, {STREAM: ">"}, count=10, block=5000)
        if not msgs:
            continue

        for _stream, entries in msgs:
            for msg_id, fields in entries:
                retry_count = int(fields.get("retry_count", "0"))
                try:
                    process(fields)
                    r.xack(STREAM, GROUP, msg_id)

                except NonRetryableError as e:
                    # Permanent failure — đẩy DLQ, XACK để ra khỏi PEL
                    push_to_dlq(msg_id, fields, str(e))
                    r.xack(STREAM, GROUP, msg_id)

                except RetryableError as e:
                    if retry_count >= MAX_RETRIES:
                        push_to_dlq(msg_id, fields, f"max_retries: {e}")
                        r.xack(STREAM, GROUP, msg_id)
                    else:
                        # Schedule cold retry
                        handle_failed_job(
                            job_id=msg_id,
                            payload=fields,
                            retry_count=retry_count,
                            error=e,
                        )
                        # XACK để message ra khỏi PEL
                        # (retry sẽ được re-enqueue qua delayed queue)
                        r.xack(STREAM, GROUP, msg_id)

Điểm quan trọng: luôn gọi XACK sau khi quyết định (dù success, DLQ, hay schedule retry). Message ở trong PEL (chưa XACK) nghĩa là worker đang chịu trách nhiệm xử lý nó. Nếu worker crash trước XACK, XAUTOCLAIM (bài 57) sẽ re-deliver về worker khác — dẫn đến retry không kiểm soát. Sau khi schedule cold retry, XACK là đúng: responsibility đã chuyển sang delayed queue.

12

Hot Retry vs Cold Retry

Tiêu chí Hot retry Cold retry
Cơ chế Vòng lặp retry ngay trong cùng worker thread (sleep + loop) Schedule lại vào delayed queue; worker khác pick up sau delay
Worker bị block Có — không xử lý job khác trong lúc sleep Không — worker tiếp tục job tiếp theo ngay
Throughput Giảm khi có nhiều lỗi đồng thời Không ảnh hưởng throughput worker chính
Worker crash trong delay Retry bị mất Retry an toàn trong queue (persist nếu Redis bật AOF)
Độ phức tạp Thấp Cao hơn (cần delayed queue + scheduler)
Dùng khi Retry rất ít lần, delay rất ngắn (<100ms), không critical Delay >1s, số lượng retry nhiều, hoặc job critical

Đối với hầu hết production job queue, cold retry là lựa chọn đúng: scalable, không block worker, và recovery được khi worker crash.

13

XAUTOCLAIM Kết Hợp Delay Queue

XAUTOCLAIM (bài 57) tự động re-deliver message đang kẹt trong PEL khi consumer crash. Tuy nhiên, XAUTOCLAIM không có backoff: nó claim lại message ngay khi đủ min-idle-time. Nếu bản thân message là poison pill (job luôn fail nhanh), XAUTOCLAIM + crash loop dẫn đến vòng lặp claim liên tục.

Pattern tốt hơn

  • Worker xử lý job → fail → quyết định retry/DLQ → luôn XACK → message ra khỏi PEL.
  • Retry được schedule vào delayed queue → sau delay → re-enqueue vào stream như job mới.
  • XAUTOCLAIM chỉ can thiệp khi worker crash hoàn toàn trước khi kịp XACK — không phải khi job fail có kiểm soát.

Khi XAUTOCLAIM re-deliver một message (worker crash case), worker nhận được message với delivery-count > 1. Nên kiểm tra delivery_count để phát hiện crash loop và đưa vào DLQ nếu đã claim quá nhiều lần:

def check_delivery_count(msg_id: str) -> int:
    """Lấy delivery count từ PEL trước khi xử lý."""
    pending = r.xpending_range(STREAM, GROUP, msg_id, msg_id, count=1)
    if pending:
        return pending[0]["times-delivered"]
    return 1

# Trong worker, khi nhận message từ XAUTOCLAIM:
delivery_count = check_delivery_count(msg_id)
if delivery_count > MAX_RETRIES + 2:
    # Đây là crash loop — đẩy DLQ ngay
    push_to_dlq(msg_id, fields, "delivery_count exceeded")
    r.xack(STREAM, GROUP, msg_id)
14

Monitoring Retry

Retry rate tăng đột ngột là dấu hiệu downstream có vấn đề. Không monitor là không biết khi nào cần can thiệp.

Metric cần thu thập

  • retry_rate (counter): số lần retry scheduled, phân theo job_type. Alert khi tỉ lệ retry/total > ngưỡng (ví dụ 5%).
  • retry_count distribution (histogram): hầu hết job cần bao nhiêu retry để thành công? Nếu median = 3 retry thì downstream thường trễ 4-8s — có thể là dấu hiệu cần tăng timeout hoặc scale downstream.
  • dlq_ingestion_rate (counter): số message vào DLQ mỗi phút. Alert khi tăng đột biến — có thể là poison message hoặc bug release mới.
  • delayed_queue_size (gauge): ZCARD delayed:retries. Queue build-up nghĩa là downstream down hoặc retry rate lớn hơn processing rate.

Redis command để check

# Kích thước delayed retry queue
ZCARD delayed:retries

# Số message trong PEL (đang chờ XACK)
XPENDING jobs:main workers - + 10

# Số message trong DLQ stream
XLEN jobs:dlq
15

Anti-patterns & Best Practices

Anti-patterns

  • Immediate retry không backoff: retry ngay khi downstream vừa fail → gửi thêm tải đến service đang yếu → kéo dài thời gian downtime.
  • Backoff không jitter: 1.000 job cùng fail → cùng backoff → cùng retry → thundering herd.
  • Retry không giới hạn: vòng lặp vô tận tốn tài nguyên, message không bao giờ ra khỏi queue, che khuất vấn đề thực.
  • Retry non-retryable error: 400 Bad Request retry 5 lần → 5 lần fail → chiếm slot retry không cần thiết, làm đầy DLQ bằng cùng error.
  • Hot retry với sleep trong worker: worker thread bị block, không xử lý job khác trong lúc chờ, giảm throughput tổng thể.
  • Job không idempotent khi bật retry: email gửi 3 lần, payment charge 3 lần, duplicate record trong DB.
  • XACK sau khi schedule retry bị bỏ quên: message kẹt trong PEL mãi, XAUTOCLAIM re-deliver song song với delayed queue → cùng job chạy hai nơi.

Best practices

  • Classify error retryable / non-retryable bằng exception type — không dùng string matching.
  • Exponential backoff với full jitter. Base = 1s, cap = 300s là điểm khởi đầu hợp lý.
  • MAX_RETRIES 3-5 cho hầu hết trường hợp. Hơn 10 lần cần lý do rõ ràng.
  • Cold retry — schedule vào delayed queue thay vì hot retry trong worker.
  • Luôn XACK sau khi xử lý xong (dù success, DLQ, hay scheduled retry).
  • Đảm bảo job idempotent trước khi bật retry.
  • Monitor retry rate, DLQ ingestion rate, và kích thước delayed queue.
16

Tổng Kết & Quiz

Tổng kết

  • Chỉ retry transient failure. Permanent failure phải vào DLQ, không retry.
  • Phân loại lỗi bằng exception type (RetryableError / NonRetryableError) ngay tại điểm gọi external service.
  • Exponential backoff: delay = min(base × 2^retry_count, cap). Cap để tránh chờ vô hạn.
  • Full jitter: delay = random(0, backoff). Dàn đều retry, tránh thundering herd.
  • Cold retry (schedule lại qua delayed queue) tốt hơn hot retry (sleep trong worker): không block worker, survive crash.
  • Luôn XACK sau khi quyết định (kể cả khi schedule retry) để message ra khỏi PEL.
  • Job phải idempotent trước khi bật retry — retry = duplicate execution.
  • MAX_RETRIES 3-5 cho đa số trường hợp. Hết retry → DLQ + alert.

Quiz

  1. Phân biệt transient failure và permanent failure. Tại sao retry permanent failure là lãng phí tài nguyên?
  2. Công thức exponential backoff là gì? Nếu không có jitter, 1.000 job fail cùng lúc với retry_count=2, base=1s sẽ xảy ra điều gì?
  3. Giải thích full jitter và equal jitter. Trường hợp nào bạn chọn equal jitter thay vì full jitter?
  4. Trong worker, sau khi quyết định schedule cold retry, tại sao phải gọi XACK? Nếu không XACK, điều gì xảy ra khi XAUTOCLAIM chạy?
  5. Job gửi email không idempotent. Hệ thống bật retry MAX_RETRIES=3. Email API trả 500 trong lần đầu nhưng thực ra email đã gửi thành công. Điều gì xảy ra với user?

Đáp án gợi ý

  1. Transient failure: nguyên nhân tạm thời, tự khắc phục (network blip, 503 service, DB lock). Permanent failure: nguyên nhân không đổi (400 bad input, auth error, business rule vi phạm). Retry permanent failure: cùng input → cùng kết quả fail → chỉ tốn CPU/network, không có ích, làm đầy DLQ bằng same error.
  2. Công thức: min(base × 2^retry_count, cap). Với retry_count=2, base=1s → delay=4s. 1.000 job cùng tính ra delay=4s → cùng retry lúc T+4s → 1.000 request đổ vào downstream đồng thời → thundering herd → downstream fail lại → chu kỳ lặp lại.
  3. Full jitter: delay = random(0, backoff) — phân bố đều từ 0 đến max. Equal jitter: delay = backoff/2 + random(0, backoff/2) — minimum delay = backoff/2. Dùng equal jitter khi cần đảm bảo job không retry quá sớm (ví dụ downstream cần ít nhất X giây để phục hồi).
  4. XACK sau khi schedule retry: message ra khỏi PEL, không còn "pending". Nếu không XACK, message vẫn trong PEL. Khi XAUTOCLAIM chạy sau min-idle-time, nó re-deliver message về worker khác — dẫn đến job chạy song song cả ở delayed queue lẫn worker mới nhận (duplicate execution không kiểm soát).
  5. Email API trả 500 nhưng đã gửi: worker coi là RetryableError, schedule retry. Lần retry 2: API gọi lại → email gửi lần 2 → user nhận 2 email. Có thể lên đến 4 email (1 gốc + 3 retry). Fix: trước khi gửi, check bảng sent_notifications — nếu đã có record (recipient, template, date) thì skip gọi API, chỉ XACK.

Bài tiếp theo

Bài 60 đi sâu vào Dead Letter Queue (DLQ): cấu trúc DLQ bằng Redis Streams, tại sao cần DLQ thay vì chỉ log, và workflow inspect + replay message từ DLQ.

Tham khảo