Hình dung một bác sĩ đang khám, gặp ca phải chờ kết quả xét nghiệm. Có hai cách xử. Cách một: ngồi lì trong phòng với đúng bệnh nhân đó, chờ lab trả kết quả, trong khi cả hàng người ngoài cửa đứng im. Cách hai: mời bệnh nhân ra phòng chờ, gọi người tiếp theo vào, khi nào có kết quả thì quay lại. Retry trong listener của Spring là cách một — luồng consumer ngồi ôm một thông điệp lỗi mà sleep, và cả hàng đợi phía sau đứng yên. Bài này đo cái giá của việc ngồi chờ đó.

Phần 21 dựng vòng thử lại bằng TTL và DLX — khá nhiều bộ phận. Spring AMQP cho bạn cùng ý tưởng bằng bốn dòng cấu hình. Bài này đo xem bốn dòng đó tốn gì.

Bốn dòng

spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true
          max-attempts: 3
          initial-interval: 500ms

Listener ném ngoại lệ thì Spring bắt lại, chờ, gọi lại — tối đa 3 lần, rồi mới trả về cho container để đẩy sang DLX.

Cùng kết quả, gấp một nghìn lần thời gian

Một trăm thông điệp, trong đó 10% luôn lỗi, một consumer:

Thời gian cả mẻ Listener được gọi Vào nghĩa địa
Có retry (3 lần, chờ 500 ms) 10,12 s 120 lần 10
Không retry, hỏng là sang DLX 0,01 s 100 lần 10

Cột cuối giống hệt nhau: mười thông điệp hỏng, cả hai cách đều đưa đủ mười cái sang nghĩa địa. Chỉ khác thời gian, và khác một nghìn lần.

Con số 10,11 giây chênh lệch không phải chi phí ẩn nào cả — nó là phép nhân đơn giản:

10 thông điệp hỏng × 2 lần thử thêm × 500 ms = 10 000 ms
Toàn bộ thời gian mà retry thêm vào là thời gian ngồi chờ trong luồng consumer. Không có công việc nào được làm trong 10 giây đó, và không thông điệp nào khác được xử lý.

Nó chặn cả những thông điệp phía sau

Đặt một thông điệp hỏng đứng đầu, năm thông điệp tốt xếp sau:

tot-0 sau 1.03 s
tot-4 sau 1.03 s

Thông điệp tốt đầu tiên phải đợi 1,03 giây — đúng bằng thời gian ba lần thử của cái hỏng. Đây chính là bức tranh mà phần 20 đã đo với client thuần, chỉ khác là lần này hàng đợi có tiến triển chứ không kẹt vĩnh viễn.

Tăng concurrency chỉ làm mờ vấn đề đi: với 4 consumer, một thông điệp hỏng chiếm một luồng trong 1 giây, còn ba luồng kia vẫn chạy. Nhưng nếu 10% thông điệp hỏng thì trung bình lúc nào cũng có một phần luồng của bạn đang ngồi chờ — và phần 25 đo được rằng thêm consumer không miễn phí.

Khi nào retry trong listener vẫn đúng

Khi độ trễ rất ngắn và lỗi rất hiếm. Chờ 50 ms để thử lại một truy vấn CSDL vừa timeout là hợp lý; nó rẻ hơn nhiều so với dựng hàng đợi chờ, và ở tần suất thấp thì phần luồng bị chiếm không đáng kể.

Khi thứ tự xử lý quan trọng. Đẩy thông điệp sang hàng đợi chờ nghĩa là nó quay lại sau những thông điệp đến sau nó. Nếu thứ tự trong một luồng nghiệp vụ phải giữ nguyên, chặn tại chỗ lại là hành vi đúng — và bạn phải chấp nhận cái giá ở bảng trên.

Khi đang dựng nguyên mẫu. Bốn dòng cấu hình vẫn hơn không có gì.

Khi nào chuyển sang hàng đợi chờ

Ngay khi thang backoff của bạn vượt quá một giây, hoặc tỉ lệ lỗi vượt quá vài phần trăm. Cách của phần 21 — reject sang DLX, hàng đợi chờ có TTL, quay về — giữ luồng consumer rảnh trong suốt thời gian chờ, nên thang 1 s → 5 s → 30 s không tốn gì cả.

Với Spring, việc đó là một RepublishMessageRecoverer trỏ vào exchange của hàng đợi chờ, thay cho RejectAndDontRequeueRecoverer mặc định:

@Bean
MessageRecoverer recoverer(RabbitTemplate tpl) {
    return new RepublishMessageRecoverer(tpl, "x-cho-500", "");
}

Và nhớ điều phần 21 đo được: mỗi mức độ trễ phải có hàng đợi chờ riêng, nếu không thông điệp hẹn nửa giây sẽ nằm chờ sau thông điệp hẹn năm giây.

Bảng chọn

Tình huống Cách
Lỗi hiếm, chờ dưới 100 ms retry trong listener
Thang backoff nhiều giây trở lên hàng đợi chờ (phần 21)
Lỗi không bao giờ tự hết không retry, sang DLX ngay
Cần biết đã thử mấy lần x-death.count, không phải bộ đếm trong bộ nhớ

Dòng cuối đáng nhắc lại: retry của Spring là không trạng thái theo mặc định — bộ đếm sống trong luồng đang xử lý. Tiến trình chết là bộ đếm về 0, và thông điệp bắt đầu lại từ đầu.

Muốn biết consumer của mình có đang ngồi chờ trong retry không, nhìn unacked cao mà nhịp giao/ack gần 0:

# consumer cua ban co dang ngoi cho khong: so unacked cao ma deliver_get thap
curl -su guest:guest 'localhost:15672/api/queues/%2F/<ten>' \
  | jq '{unacked: .messages_unacknowledged,
         rate_giao: .message_stats.deliver_get_details.rate,
         rate_ack:  .message_stats.ack_details.rate}'

unacked đứng yên ở một con số nhỏ trong khi hai rate gần bằng 0 nghĩa là các luồng consumer đang ngủ trong Thread.sleep của retry — chứ không phải đang bận làm việc.

Mẫu số chung

Điểm cốt lõi: chờ tại chỗ đốt cạn thứ khan hiếm mà chẳng đổi lại gì. Thứ khan hiếm ở đây không phải thời gian — mà là cái luồng, cái worker. Retry trong listener bắt một luồng ngồi sleep ôm một thông điệp lỗi, và trong suốt 10 giây đó nó không làm được việc gì khác; kiểu "gói việc lại, thả luồng ra, khi tới hạn thì quay lại" của hàng đợi chờ tốn không thời-gian-luồng nào trong lúc chờ. Cùng một lằn ranh ấy chạy khắp lập trình: Thread.sleep so với một bộ định giờ, I/O chặn so với không chặn, giữ khư khư một kết nối CSDL suốt lúc ngồi chờ so với trả nó về pool, ôm một khoá qua một lời gọi mạng. Hỏi trước mỗi lần "chờ": trong lúc chờ, tôi có đang giam một tài nguyên đáng lẽ để phục vụ việc khác không? — nếu có, hãy nhả nó ra và quay lại sau, đừng ngồi lì.

Điều thứ hai: một mặc định tiện lợi thường đúng dưới một ngưỡng và sai trên ngưỡng đó, nên giá trị nằm ở chỗ biết ngưỡng chuyển ở đâu hơn là cãi cái nào "tốt hơn". Retry bốn dòng của Spring hoàn hảo khi lỗi hiếm và chờ dưới 100 ms; vượt vài giây hoặc vài phần trăm lỗi là nó lật sang sai. Kèm theo một cái bẫy trạng thái: bộ đếm retry của Spring sống trong bộ nhớ luồng, nên tiến trình chết là nó về 0 và thông điệp thử lại từ đầu — trạng thái phải sống sót qua một cú sập thì không được nằm trong một biến cục bộ, nó phải nằm ở chỗ bền (ở đây là x-death trên chính thông điệp). Cùng bài học ở một bộ đếm rate-limit in-memory mất sạch khi restart, một session chỉ nằm trong RAM một node, một checkpoint job giữ trong biến.

Bài sau: kiểm thử với Testcontainers — chạy broker thật trong test thay vì mock, và giữ thời gian chạy test ở mức chấp nhận được.