Hình dung một chiếc xe tải dỡ hàng ở bến. Có ba kiểu làm. Kiểu thứ nhất: tài xế quăng pallet xuống rồi phóng đi, không cần ai ký — nhanh, nhưng mất một pallet cũng chẳng ai hay. Kiểu thứ hai: dỡ một pallet, rồi đứng chờ bạn ký xong giấy cho đúng pallet đó mới dỡ cái tiếp theo, máy xe nổ không tắt — an toàn, mà chậm đến phát khóc. Kiểu thứ ba: dỡ hết cả xe, rồi bạn đưa lại một tờ biên nhận gộp ghi "đã nhận đủ tới pallet số 227". Ba kiểu ấy chính là ba cách một publisher chờ RabbitMQ xác nhận, và khoảng cách giữa chúng lớn hơn bạn tưởng nhiều.

Phần 3 đo được 424 291 msg/s và tôi đã ghi ngay bên dưới rằng con số đó là tốc độ phía client — basicPublish chỉ ghi vào socket rồi trả về, broker chưa hứa gì. Bài này trả nốt lời hứa đó: đo xem biết chắc broker đã nhận thì tốn bao nhiêu.

basicPublish không hứa gì cả

Mặc định, gửi xong là xong. Broker chết trong lúc thông điệp còn trên đường, mạng đứt, hàng đợi đầy — bên gửi không hay biết. Phần 4 đã cho một nửa cách chữa (mandatory báo khi không có đường đi), nhưng nó không nói gì về việc thông điệp có được ghi xuống hay không.

Publisher confirms là nửa còn lại:

ch.confirmSelect();                    // bật cho channel này
ch.basicPublish("", "cf", props, body);
ch.waitForConfirmsOrDie(30_000);       // chờ broker xác nhận

Bốn cách chờ, đo cạnh nhau

20 000 thông điệp persistent vào hàng đợi durable, trung vị 3 lượt:

Cách Thông lượng So với không confirm
Không confirm (chỉ ghi vào socket) 326 083 msg/s 100%
Confirm đồng bộ, từng thông điệp 2 453 msg/s 1%
Confirm theo lô 100 73 575 msg/s 23%
Confirm theo lô 1 000 150 482 msg/s 46%
Confirm bất đồng bộ (ConfirmListener) 167 617 msg/s 51%
Cách viết trực giác nhất — gửi một cái, chờ xác nhận, gửi cái tiếp — chậm hơn 133 lần. Đây không phải chi phí của độ tin cậy; đó là chi phí của việc chờ một vòng mạng cho mỗi thông điệp. Cùng mức độ đảm bảo, cách bất đồng bộ nhanh gấp 68 lần.

Vì sao đồng bộ từng cái thảm hại

waitForConfirms chặn cho tới khi broker trả lời. Với thông điệp persistent, broker chỉ trả lời sau khi đã ghi xuống đĩa. Nghĩa là mỗi thông điệp phải đi trọn một vòng: client → broker → đĩa → client, và trong suốt vòng đó không có thông điệp nào khác đang bay.

2 453 msg/s tương đương khoảng 0,4 mili giây mỗi vòng — con số hợp lý cho một vòng mạng nội bộ cộng một lần ghi. Vấn đề không phải nó chậm, mà là bạn đã biến một đường ống thành một chuỗi tuần tự.

Ba cách còn lại đều dựa trên cùng một ý: giữ nhiều thông điệp đang bay cùng lúc.

Vì sao bất đồng bộ nhanh

Đăng ký một ConfirmListener và tự theo dõi những số thứ tự chưa được xác nhận:

ch.confirmSelect();
ch.addConfirmListener((tag, multiple) -> {
    if (multiple) chuaXong.headSet(tag + 1).clear();   // xác nhận cả lô tới tag này
    else          chuaXong.remove(tag);
}, (tag, multiple) -> { /* nack: gửi lại */ });

chuaXong.add(ch.getNextPublishSeqNo());
ch.basicPublish("", "cf", props, body);

Phép đo cho một chi tiết tôi thấy rất đáng nhớ:

ConfirmListener được gọi 88 lần cho 20 000 thông điệp

Broker không xác nhận từng cái một. Nó gom lại và gửi về một xác nhận kèm cờ multiple=true, nghĩa là "mọi thông điệp có số thứ tự nhỏ hơn hoặc bằng cái này đều đã xong". Trung bình mỗi lần gọi xác nhận 227 thông điệp — đúng cái tờ biên nhận gộp "đã nhận đủ tới pallet 227" ở đầu bài.

Đó là lý do cách bất đồng bộ nhanh, và cũng là lý do bạn bắt buộc phải xử lý cờ multiple. Bỏ qua nó — chỉ remove(tag) — thì 19 912 thông điệp trong phép đo trên sẽ nằm mãi trong danh sách chờ, và mã gửi lại của bạn sẽ gửi lại toàn bộ chúng.

Chọn cái nào

Tình huống Cách
Dữ liệu mất được không confirm
Gửi từng thông điệp rời rạc, độ trễ không quan trọng đồng bộ từng cái — đơn giản, và 2 453/s vẫn thừa cho một API
Gửi hàng loạt, muốn ít mã theo lô 1 000
Đường ống thông lượng cao bất đồng bộ

Đừng bỏ qua dòng thứ hai vì con số xấu. Nếu ứng dụng của bạn gửi vài chục thông điệp mỗi giây, waitForConfirmsOrDie ngay sau basicPublish là mã đúng đắn nhất bạn viết được — nó biến mọi lỗi thành một ngoại lệ ngay tại dòng gửi. Con số 133 lần chỉ có ý nghĩa khi bạn thật sự cần thông lượng.

Confirm hứa gì và không hứa gì

Hứa: broker đã nhận trách nhiệm với thông điệp. Với thông điệp persistent vào hàng đợi durable, nó đã nằm trên đĩa. Với thông điệp gửi vào exchange không có đích, confirm vẫn báo thành công — vì broker đã xử lý xong, và việc không ai nhận là chuyện của mandatory hoặc alternate exchange.

Không hứa: consumer đã nhận, càng không phải đã xử lý. Đó là chuyện của acknowledgement ở đầu bên kia.

Hai cơ chế ghép lại mới thành một đường không mất dữ liệu, và giữa chúng vẫn còn một khoảng: broker đã nhận nhưng chưa ghi xong lúc mất điện. Bài 33 về quorum queue nói về khoảng đó.

Muốn biết một channel đang bật confirm chưa và đang tồn đọng bao nhiêu, hỏi thẳng broker:

# channel nao dang bat confirm, va co bao nhieu thong diep dang cho xac nhan
docker exec -u rabbitmq rmq rabbitmqctl -q list_channels \
    name confirm messages_unconfirmed

Cột confirm là false trên một channel gửi dữ liệu quan trọng nghĩa là bên gửi đang không biết gì cả. Cột messages_unconfirmed lớn và không tụt nghĩa là broker đang không theo kịp.

Mẫu số chung

Con số 133 lần không phải giá của độ tin cậy — cách bất đồng bộ cũng an toàn y hệt mà chỉ chậm hơn cách quăng-đi-là-xong hai lần. Nó là giá của một quyết định thiết kế sai: chờ một vòng khứ hồi cho từng đơn vị. Cứ dừng lại chờ trả lời sau mỗi món là bạn biến một đường ống thành một chuỗi xếp hàng một người, và thông lượng tụt xuống bằng nghịch đảo của độ trễ mạng — bất kể mạng nhanh bao nhiêu. Cách chữa luôn là giữ nhiều món đang bay, tách bạch "nó tới chưa" khỏi "gửi cái tiếp theo đi". Đây đúng là lý do TCP có cửa sổ trượt thay vì stop-and-wait, là lý do chèn 1 000 dòng một câu INSERT bỏ xa vòng lặp chèn từng dòng (bài toán N+1 kinh điển), là lý do HTTP/2 ghép nhiều request trên một kết nối. Hễ thấy "gửi → chờ → gửi → chờ", hãy nghi ngờ: cái chờ đó có cần tuần tự không, hay chỉ cần biết kết quả rốt cuộc?

Điều thứ hai, cụ thể hơn: xác nhận cộng dồn — "xong hết tới số N" — rẻ hơn hẳn xác nhận từng cái, nhưng đổi lại bên nhận phải hiểu đúng ngữ nghĩa gộp đó, nếu không sổ sách của nó vỡ. Bỏ cờ multiple ở đây là tưởng broker báo lẻ trong khi nó báo gộp, và cả vạn thông điệp kẹt lại trong danh sách chờ. Cùng cái bẫy ngữ-nghĩa-cộng-dồn ở một ACK của TCP (báo nhận tới byte N, không phải riêng byte N), ở một offset commit của Kafka (chốt offset N là ngầm chốt mọi cái ≤ N). Một tín hiệu gộp tiết kiệm rất nhiều, với điều kiện bạn đọc nó đúng như nó nói.

Bài sau: transaction AMQP — cơ chế cũ hơn confirms, và phép đo giải thích vì sao tài liệu chính thức khuyên tránh nó.