Phần 18 đã nói: txCommit chỉ nguyên tử trong phạm vi broker, không liên quan gì tới giao dịch CSDL. Bài này đo hậu quả của điều đó bằng cách cho ứng dụng sập đúng vào khe hở giữa hai hệ thống, rồi dựng mẫu outbox và đo lại.

Khe hở

Hai lần commit tách rời so với outbox một lần commit

Mã ai cũng viết: lưu đơn hàng, rồi báo cho hệ thống khác biết.

donRepo.save(don);
db.commit();                                  // ← đơn đã chắc chắn
ch.basicPublish("", "don-moi", null, than);   // ← còn cái này thì chưa

Cho ứng dụng sập ngay giữa hai dòng đó, cứ 5 đơn một lần, chạy 500 đơn:

đơn trong CSDL = 500 | thông điệp trong hàng đợi = 400 | sập 100 lần
-> MẤT 100 thông điệp (20% số đơn không ai biết)

Một trăm đơn hàng tồn tại thật, khách đã trả tiền, và không hệ thống nào phía sau biết chúng có mặt. Không có lỗi nào được ghi, vì từ góc nhìn của CSDL mọi thứ đã thành công.

Đảo thứ tự không cứu được

Phản xạ đầu tiên là gửi trước rồi commit sau. Nó chỉ đổi sang kịch bản ngược lại: thông điệp đã ra ngoài, giao dịch rollback, và giờ hệ thống phía sau xử lý một đơn hàng không tồn tại. Đơn hàng ma khó chữa hơn đơn hàng thiếu.

@TransactionalEventListener(AFTER_COMMIT) của Spring — thứ tôi vẫn khen ở bài về sự kiện ứng dụng — chính xác cách một ở trên. Nó bảo đảm không gửi khi giao dịch rollback, đó là giá trị thật của nó. Nhưng nó không bảo đảm gì về việc gửi có tới hay không, vì lúc nó chạy thì giao dịch đã đóng rồi. Nó đóng một nửa khe hở.

Outbox: một lần commit duy nhất

Ý tưởng: đừng cố làm hai hệ thống commit cùng nhau. Ghi ý định gửi vào chính CSDL đó, trong cùng giao dịch.

insert into don(id, tien) values (?, ?);
insert into outbox(id, than) values (?, ?);   // cùng một giao dịch
db.commit();                                   // cả hai cùng vào hoặc cùng không

Rồi một tiến trình riêng đọc những dòng chưa gửi, publish, và đánh dấu đã gửi. Cho nó sập đúng vào chỗ hiểm nhất — sau khi publish, trước khi đánh dấu:

đơn trong CSDL = 500 | thông điệp trong hàng đợi = 600 | sập 100 lần sau khi gửi
-> MẤT 0 thông điệp, GỬI TRÙNG 100 thông điệp

Nó đổi mất thành trùng

Đây là điều quan trọng nhất của bài, và cũng là chỗ mẫu outbox hay bị bán quá lời:

Outbox không cho bạn exactly-once. Nó đổi một vấn đề không phát hiện được — thông điệp biến mất âm thầm — lấy một vấn đề có công cụ xử lý sẵn: bản sao. Và công cụ đó là tính lũy đẳng ở phần trước.

Nếu consumer của bạn chưa lũy đẳng, dựng outbox là biến 100 đơn bị mất thành 100 đơn bị tính tiền hai lần. Hai bài này phải đi cùng nhau.

Tiến trình đẩy: bốn chi tiết quyết định

Publish trước, đánh dấu sau. Đảo lại thì sập giữa chừng là mất thông điệp — đúng vấn đề ban đầu, chỉ dời sang chỗ khác.

Bật publisher confirms. Đánh dấu "đã gửi" khi mới chỉ ghi vào socket là tự lừa mình. Dùng chế độ bất đồng bộ: đánh dấu trong ConfirmListener chứ không phải ngay sau basicPublish.

Đặt messageId từ khoá nghiệp vụ. Phần 22 đo được rằng RabbitMQ không tự sinh khoá này. Dùng chính outbox.id — nó ổn định qua mọi lần gửi lại, đúng thứ bảng chống trùng cần.

Dọn bảng outbox. Dòng đã gửi phải bị xoá hoặc chuyển đi theo lịch. Đây là bảng ghi nhiều nhất trong hệ thống của bạn; để nó phình vô hạn là sự cố tiếp theo.

Khi nào không cần outbox

Khi mất thông điệp không sao — thông báo, số liệu, gợi ý. 20% mất mát trong bảng đầu bài nghe kinh khủng với đơn hàng, nhưng với sự kiện "người dùng vừa xem sản phẩm" thì không.

Khi thông điệp không đi kèm thay đổi dữ liệu. Không có giao dịch CSDL thì không có khe hở nào — chỉ cần confirms là đủ.

Khi bên nhận có thể hỏi lại. Nếu hệ thống phía sau quét được bảng đơn hàng để tự tìm cái nó chưa xử lý, thông điệp chỉ là tín hiệu tăng tốc chứ không phải nguồn sự thật, và mất một cái không gây hậu quả.

Đó là chặng cuối của phần độ tin cậy. Bài sau bắt đầu chặng mới: Spring Boot gặp RabbitMQ, và mọi thứ tám bài vừa rồi đo được sẽ xuất hiện lại dưới dạng một dòng cấu hình.

Thử ba mươi giây

-- bang outbox cua ban co dang un lai khong
select count(*) filter (where not da_gui) as chua_gui,
       count(*) filter (where da_gui)     as da_gui,
       min(tao_luc) filter (where not da_gui) as cai_cu_nhat
from outbox;

cai_cu_nhat lùi xa hơn vài giây nghĩa là tiến trình đẩy đã chết — và đó là loại sự cố im lặng, vì ứng dụng chính vẫn nhận đơn bình thường.