Một thao tác lũy đẳng (idempotent) là thao tác làm mấy lần cũng ra một kết quả — như bật công tắc đèn lên ON: bấm một lần hay năm lần, đèn vẫn sáng. Ngược lại là cái nút "+100": mỗi lần bấm là một lần cộng, năm lần bấm ra năm trăm. RabbitMQ giao thông điệp ít nhất một lần — có thể hai — nên câu hỏi sống còn với mỗi consumer là: việc bạn làm khi nhận thông điệp giống cái công tắc, hay giống cái nút +100?

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.

Không có cấu hình nào biến RabbitMQ thành exactly-once. "Đúng một lần" là thứ đạt được ở consumer, không phải ở broker — bằng cách làm việc xử lý chịu được lặp lại.

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.

Muốn biết ngay hàng đợi của mình có messageId để mà chống trùng hay không, soi thử vài thông điệp:

# 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ả.

Mẫu số chung

Giao-đúng-một-lần trên một hệ có mạng lưới là điều gần như bất khả: đường mạng không bao giờ phân biệt được cho bạn giữa "thông điệp chưa tới" và "thông điệp tới rồi nhưng cái ack bị lạc" — nên bên gửi buộc phải chọn giữa gửi lại và có thể trùng (at-least-once) hay không gửi lại và có thể mất (at-most-once), không có cửa thứ ba. Cái người ta gọi là "exactly-once" thật ra luôn là at-least-once + xử lý lũy đẳng ghép lại — "effectively once". Đúng một khuôn ấy ở khắp nơi: TCP truyền lại gói rồi khử trùng theo số thứ tự; Kafka mặc định at-least-once, "exactly-once" của nó là idempotent producer cộng consumer giao dịch; Idempotency-Key của Stripe, PUT khử trùng của HTTP, retry của gRPC — tất cả đẩy trách nhiệm "đừng làm hai lần" về phía nhận. Một khi chấp nhận rằng thông điệp sẽ có ngày đến hai lần, bạn ngừng cố chặn bản sao ở đường truyền và bắt đầu thiết kế để bản sao không gây hại.

Điều thứ hai, và là thứ dùng được ngay: hãy làm cho chính thao tác đó lặp-lại-vô-hại, đừng đi ghi sổ để nhớ mình đã làm. "Ghi đè" (set) thắng "cộng dồn" (add) vì nó là công tắc chứ không phải nút +100 — cùng lý do PUT lũy đẳng còn POST thì không, UPSERT an toàn còn INSERT thì trùng khoá, UPDATE ... WHERE trạng_thái=cũ tự biết lần thứ hai là thừa. Cách này tốn 0 byte và không có trạng thái nào để hết hạn; bảng chống trùng chỉ nên là phương án khi thao tác thật sự không thể tái cấu trúc thành lũy đẳng — và khi buộc phải dùng, khoá phải là một định danh nghiệp vụ tự nhiên (mã đơn, mã giao dịch), không phải một id ngẫu nhiên sinh lúc gửi, vì cái ngẫu nhiên đổi theo mỗi lần thử lại đúng lúc bạn cần nó đứng yên. Chọn đúng hình dạng phép toán rẻ hơn và bền hơn mọi cơ chế nhớ-đã-thấy.

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.