Ở quầy bảo hành, người ta lặng lẽ chia khiếu nại vào hai cái hộp. Hộp "chịu, không sửa được" — máy ngấm nước, hỏng do rơi vỡ — thì trả thẳng cho khách, mang lại lần nữa cũng vô ích. Hộp "để lại thử sau" — máy chỉ hết pin, hay đang chờ linh kiện — thì giữ lại và làm tiếp. Điều đáng nói là khách không được thấy luật phân loại đó: cùng một quầy, cùng một nhân viên, mà cái điện thoại của bạn đi vào hộp nào là do một quy tắc ngầm quyết định. @RabbitListener cư xử y hệt. Phần 25 đo được rằng ném ngoại lệ trong listener dựng lại vòng lặp thông điệp độc — 6 010 vòng mỗi giây. Bài này đo tiếp và tìm ra một chuyện tôi không ngờ: điều đó chỉ đúng với một số loại ngoại lệ.
Hai ngoại lệ, hai số phận
Cùng một hàng đợi có DLX, cùng một container mặc định, chỉ đổi loại ngoại lệ mà listener ném ra:
| Ngoại lệ | Số lần giao trong 2 giây | Còn ở hàng đợi | Vào nghĩa địa |
|---|---|---|---|
IllegalStateException (nghiệp vụ) |
11 189 | 1 | 0 |
MessageConversionException |
1 | 0 | 1 |
Một cái quay vô hạn, một cái ra khỏi đường đi ngay lần đầu. Cùng cấu hình.
Vì sao: danh sách lỗi "chí mạng"
Container mặc định dùng ConditionalRejectingErrorHandler. Nó phân loại ngoại lệ thành hai nhóm — đúng hai cái hộp ở quầy bảo hành:
Chí mạng — thử lại bao nhiêu lần cũng vô ích: lỗi chuyển đổi thông điệp, lỗi không tìm thấy phương thức listener, lỗi ép kiểu tham số. Những cái này bị reject không requeue bất kể defaultRequeueRejected đang bật hay tắt.
Không chí mạng — mọi thứ khác, tức là toàn bộ ngoại lệ nghiệp vụ của bạn. Những cái này đi theo defaultRequeueRejected, mà phần 25 đo được mặc định là true.
Tắt requeue là đủ cho phần lớn trường hợp
container.setDefaultRequeueRejected(false);
defaultRequeueRejected=false -> 1 lần giao | nghĩa địa 1
Ngoại lệ nghiệp vụ giờ hành xử như ngoại lệ chuyển đổi: ra khỏi hàng đợi ngay, không chặn ai. Đây là thay đổi một dòng có giá trị cao nhất trong toàn bộ Spring AMQP.
Thông điệp trong nghĩa địa mang theo:
x-death = [{reason=rejected, count=1, exchange=, queue=le-viec, ...}]
x-first-death-reason = rejected
x-first-death-queue = le-viec
x-last-death-reason = rejected
x-last-death-queue = le-viec
Ngoài x-death mà phần 14 đã mổ, RabbitMQ hiện đại còn thêm bốn header x-first-death-* và x-last-death-* — tiện hơn nhiều khi viết truy vấn hay cảnh báo, vì không phải bới vào mảng lồng nhau.
DLX cho biết chuyện gì, không cho biết vì sao
Nhìn kỹ bảng header ở trên: có reason=rejected, có count, có hàng đợi gốc. Không có gì về ngoại lệ. Bạn biết thông điệp bị từ chối, không biết vì sao — phải đi lục log ứng dụng, khớp theo thời gian.
RepublishMessageRecoverer sinh ra để lấp chỗ đó. Nó không dùng DLX; nó gửi lại thông điệp sang một exchange khác, kèm chẩn đoán:
thân giữ nguyên: than-goc
x-exception-message = CSDL khong tra loi
x-original-routingKey = le-viec
x-original-exchange =
x-exception-stacktrace = java.lang.IllegalStateException: CSDL khong tra loi
at lab.Loi.lambda$main$4(...
Cái giá của stack trace
tổng kích thước header: 1 688 byte (thân chỉ 8 byte)
Header lớn gấp 211 lần thân thông điệp. Với thông điệp thật vài trăm byte thì tỉ lệ dễ chịu hơn, nhưng con số tuyệt đối vẫn thế: mỗi thông điệp hỏng kéo theo khoảng 1,5 KB stack trace nằm trong RAM của broker và trên đĩa.
Sự cố lớn — hạ nguồn chết, mười nghìn thông điệp cùng hỏng — là 15 MB stack trace gần như giống hệt nhau đổ vào hàng đợi lỗi, ngay lúc broker đang chịu tải bất thường.
Chọn cái nào
| DLX thuần | RepublishMessageRecoverer | |
|---|---|---|
| Cấu hình | một tham số hàng đợi | một bean + một exchange |
| Cho biết vì sao hỏng | không | có, kèm stack trace |
| Chi phí mỗi thông điệp hỏng | vài chục byte header | ~1,5 KB |
| Giữ nguyên thân | có | có |
Thực dụng: DLX cho hàng đợi thông lượng cao, Republish cho hàng đợi nghiệp vụ quan trọng nơi mỗi thông điệp hỏng đáng được điều tra riêng. Và dù chọn cái nào cũng nhớ tắt defaultRequeueRejected — không có nó thì hai cơ chế trên đều không bao giờ được gọi tới.
Bài sau: thử lại có backoff ngay trong listener, và phép đo cho thấy nó làm một mẻ việc chậm đi gấp một nghìn lần.
Muốn biết hàng đợi lỗi của mình đang nặng thế nào so với số thông điệp trong đó, hỏi broker một câu:
# hang doi loi cua ban dang nang bao nhieu so voi so thong diep
curl -su guest:guest 'localhost:15672/api/queues/%2F/<hang-doi-loi>' \
| jq '{messages, message_bytes, byte_moi_thong_diep: (.message_bytes / (.messages+0.001))}'
Nếu mỗi thông điệp lỗi nặng hơn thông điệp gốc vài trăm lần, bạn đang dùng RepublishMessageRecoverer — hãy chắc rằng hàng đợi đó có giới hạn độ dài, kẻo một sự cố hạ nguồn sẽ lấp đầy đĩa của broker.
Mẫu số chung
Cái bẫy đầu tiên: hành vi của cùng một thao tác lại do một bảng phân loại mà bạn không viết quyết định. Cùng listener, cùng cấu hình, mà IllegalStateException quay 11.189 lần còn MessageConversionException ra đi ngay — chỉ vì cái đầu nằm ngoài danh sách "chí mạng" của Spring còn cái sau nằm trong. Ai gỡ lỗi mà không biết cái danh sách đó tồn tại sẽ đi tìm nhầm chỗ hàng giờ. Cùng loại "phân loại ngầm chi phối số phận" ở khắp nơi: HTTP 4xx client tự sửa được còn 5xx đáng thử lại, Postgres serialization_failure nên retry còn unique_violation thì đừng, lỗi DNS tạm thời khác lỗi tên miền không tồn tại, checked exception của Java bắt buộc khai còn unchecked thì không. Nguyên tắc: khi một hệ thống xử lý lỗi "thông minh", việc đầu tiên là tìm ra bảng phân loại của nó — vì bạn đang lập trình dựa trên bảng đó dù có biết hay không, và chọn đúng lớp ngoại lệ chính là cách bạn ra lệnh cho nó.
Điều thứ hai, về chẩn đoán: thông tin gỡ lỗi càng giàu thì càng tuyệt cho một sự cố lẻ và càng nguy hiểm khi sự cố xảy ra hàng loạt. Một stack trace 1,5 KB gắn lên mỗi thông điệp hỏng là món quà trời cho khi bạn điều tra một ca; nhưng đúng khoảnh khắc mười nghìn thông điệp cùng hỏng — tức đúng lúc broker đang oằn mình — nó thành 15 MB gần như trùng lặp đổ vào đĩa. Cùng nghịch lý ấy ở việc bật log mức DEBUG để soi một lỗi rồi quên tắt và ngập log lúc cao tải, ở việc chụp full heap dump mỗi lần OOM khiến ổ đĩa đầy giữa cơn khủng hoảng, ở việc lưu request/response đầy đủ cho mọi lỗi. Nguyên tắc: chi phí chẩn đoán phải tính theo lúc-tệ-nhất, không phải lúc-bình-thường — dữ liệu điều tra đắt nhất luôn được sinh ra đông nhất đúng vào lúc bạn ít đủ sức chứa nó nhất, nên hãy đặt trần (giới hạn độ dài hàng đợi, log lấy mẫu, xoay vòng dump) trước khi cần tới.