Sáu bài vừa rồi dựng lên một đường ống không mất dữ liệu: ack thủ công, confirms, DLX, thử lại có độ trễ. Mỗi cơ chế trong đó đều đánh đổi cùng một thứ: thông điệp có thể tới hai lần. Bài này đo cái giá đó và ba cách trả.
RabbitMQ hứa at-least-once
Một thông điệp "cộng 100 vào số dư". Consumer làm xong việc rồi chết trước khi kịp ack, ba lần liên tiếp:
1 thông điệp, consumer chết 3 lần -> đã xử lý 3 lần, số dư = 300 (đúng ra phải 100)
Không có gì hỏng ở đây. Broker làm đúng: nó không thấy ack nên nó giao lại — chính là cơ chế đã cứu 949 thông điệp trong phần 16. Cùng một tính năng, nhìn từ phía nghiệp vụ thì thành lỗi cộng tiền ba lần.
Ba cửa sổ sinh ra bản sao
| Cửa sổ | Khi nào |
|---|---|
| Consumer làm xong nhưng chưa ack | Tiến trình chết, mạng đứt, container bị thay |
| Thử lại sau lỗi tạm thời | Chính là phần 21 — mỗi vòng là một lần xử lý nữa |
| Bên gửi không chắc confirm đã về | Mất kết nối giữa lúc chờ; gửi lại là an toàn, không gửi lại là mất |
Cửa sổ thứ ba hay bị quên: khi waitForConfirms ném ngoại lệ vì đứt kết nối, bạn không biết broker đã nhận hay chưa. Lựa chọn duy nhất an toàn là gửi lại — và chấp nhận có thể trùng.
Ba cách làm lũy đẳng
Bảng chống trùng. Lưu messageId đã xử lý, gặp lại thì bỏ qua.
if (daXuLy.add(messageId)) { lamViec(); }
Ghi đè thay vì cộng dồn. Đổi soDu += 100 thành soDu = 500, đổi INSERT thành UPSERT. Việc lặp lại cho cùng kết quả nên không cần nhớ gì cả — đây là cách rẻ nhất và nên là lựa chọn đầu tiên.
Cập nhật có điều kiện. UPDATE don SET trang_thai='DA_GIAO' WHERE id=? AND trang_thai='DANG_GIAO' — lần thứ hai đổi 0 dòng, và bạn biết ngay đó là bản sao.
Đo thông lượng cả ba trên 20 000 thông điệp:
| Cách | Thông lượng |
|---|---|
| Không chống trùng (cộng dồn) | 131 119 msg/s |
Bảng chống trùng theo messageId |
135 608 msg/s |
| Ghi đè (UPSERT) | 139 764 msg/s |
Ba con số nằm trong nhiễu của nhau. Lũy đẳng không phải vấn đề thông lượng — nếu ai đó phản đối vì sợ chậm, bảng này là câu trả lời.
Cái giá thật là lưu trữ
Bảng chống trùng phải nhớ mọi khoá đã thấy. Đo trực tiếp bộ nhớ của tập khoá UUID:
| Số khoá | Bộ nhớ | Mỗi khoá |
|---|---|---|
| 100 000 | 11,2 MB | 118 byte |
| 1 000 000 | 116,5 MB | 122 byte |
Một triệu thông điệp mỗi ngày cần khoảng 116 MB chỉ để nhớ rằng mình đã thấy chúng — và đó là bản trong bộ nhớ, chưa tính chỉ mục nếu bạn để trong CSDL.
Đó là lý do "ghi đè" đáng thử trước: nó tốn 0 byte.
Giữ khoá bao lâu
Câu hỏi quyết định kích thước bảng. Khoá chỉ cần sống lâu hơn cửa sổ có thể sinh bản sao, và cửa sổ đó bằng tổng thang backoff của bạn cộng thời gian giữ trong hàng đợi.
Với thang 1 s → 5 s → 30 s → 2 phút → 10 phút của phần 21, giữ khoá 24 giờ là quá dư. Giữ vĩnh viễn là cách chắc chắn nhất để bảng chống trùng trở thành sự cố tiếp theo.
messageId không tự có
Điểm cuối, và nó làm hỏng mọi thứ ở trên nếu bỏ qua:
payload=không đặt messageId message_id=None
payload=có đặt messageId message_id='don-42'
RabbitMQ không sinh messageId. Không đặt thì nó là null, và bảng chống trùng của bạn sẽ chống trùng trên khoá null.
Và đừng dùng UUID ngẫu nhiên sinh lúc gửi: nếu bên gửi thử lại sau khi mất kết nối (cửa sổ thứ ba ở trên), nó sinh UUID mới và bản sao lọt qua. Khoá phải suy ra từ dữ liệu nghiệp vụ — mã đơn hàng, mã giao dịch — thứ không đổi giữa hai lần gửi cùng một sự việc.
Bài sau: khoảng trống lớn hơn — CSDL đã commit mà broker chưa nhận thông điệp, và mẫu outbox.
Thử ba mươi giây
# thong diep trong hang doi cua ban co messageId khong
curl -su guest:guest -X POST 'localhost:15672/api/queues/%2F/<ten>/get' \
-H 'Content-Type: application/json' \
-d '{"count":5,"ackmode":"ack_requeue_true","encoding":"auto"}' \
| jq '.[].properties.message_id'
ack_requeue_true nghĩa là xem rồi trả lại, không nuốt mất. Ra toàn null thì bạn chưa có gì để chống trùng cả.