Hình dung bạn phải làm hai việc mà không có cách nào khoá chúng thành một: ghi đơn vào sổ cái và bỏ một lá thư báo vào thùng thư. Ghi sổ xong rồi mới bỏ thư — kẹt đúng giữa hai bước là sổ ghi "đã xong" mà thư chẳng bao giờ đi. 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 khe hở đó bằng cách cho ứng dụng sập đúng vào giữa hai hệ thống, rồi dựng mẫu outbox và đo lại.
Khe hở
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.
Và @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 là 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:
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ả.
Muốn biết tiến trình đẩy của mình còn sống không, hỏi thẳng bảng outbox:
-- 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.
Mẫu số chung
Bạn không thể làm hai hệ thống độc lập commit nguyên tử với nhau — luôn có một khe hở, và sập trong khe hở đó làm mất dữ liệu âm thầm, vì mỗi hệ đều thấy phần của mình đã xong. Lời giải không phải cố ép hai lần commit đồng bộ chặt hơn (two-phase commit vừa mong manh vừa hiếm ai dùng) mà là gộp chúng thành một lần ghi nguyên tử trong một hệ: ý định gửi nằm chung giao dịch với dữ liệu, rồi một tiến trình riêng đối chiếu sau. Đây là bài toán ghi-kép (dual write) kinh điển, gặp lại ở mọi cặp "ghi CSDL rồi cập nhật cache", "ghi CSDL rồi đánh chỉ mục tìm kiếm", "ghi CSDL rồi gọi webhook". Nguyên tắc: hoặc cho hai sự thật cùng commit như một, hoặc cho cái này suy ra từ cái kia — đừng để lại hai lần ghi phải-cùng-thành-công mà không-thể-nguyên-tử.
Điều thứ hai: một mẫu độ-tin-cậy hiếm khi xoá một kiểu hỏng — nó đổi kiểu hỏng lấy một kiểu bạn ưa hơn. Outbox biến mất-trong-im-lặng (không phát hiện được) thành gửi-trùng (phát hiện và chữa được bằng lũy đẳng); đó là cải thiện thực sự chỉ vì bản sao thì thấy được và dọn được, còn mất âm thầm thì không. Nên khi chọn một cơ chế, hỏi nó đổi cái hỏng này lấy cái hỏng nào — và chọn cái bạn nhìn thấy và hồi phục được. Hệ quả bắt buộc: outbox và lũy đẳng phải đi cùng nhau, y như at-least-once + xử-lý-lũy-đẳng = "effectively once"; dựng nửa mẫu là biến 100 đơn mất thành 100 đơn tính tiền hai lần — đổi một cái hỏng xấu lấy một cái hỏng khác cũng xấu, mà lại tưởng mình đã xong.
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.