Họ đang đo gì
Bạn có nhận ra đây là bài toán không giải được theo cách trực tiếp không, và có biết mẫu thiết kế chuẩn cho nó.
Trả lời ngắn~30 giây
Không thể commit hai hệ thống một cách nguyên tử mà không có transaction phân tán, mà cái đó thì đắt và mong manh. Mẫu outbox: trong CÙNG transaction với thay đổi nghiệp vụ, ghi thêm một hàng vào bảng outbox. Một tiến trình riêng đọc bảng đó và đẩy sang message broker, đánh dấu đã gửi. Nếu tiến trình chết giữa chừng thì nó gửi lại — nên bên nhận phải chịu được trùng lặp, tức là at-least-once cộng với consumer idempotent.
Giải thích sâu
Lý do phải làm vậy là bốn thứ tự có thể xảy ra khi bạn ghi hai nơi, và ba trong bốn đều sai. Ghi DB rồi gửi message: message có thể mất nếu tiến trình chết ngay sau commit. Gửi message rồi ghi DB: DB có thể lỗi và bạn đã thông báo một sự kiện chưa từng xảy ra. Bọc cả hai trong try/catch không giúp gì, vì tiến trình có thể chết ở giữa. Outbox chuyển bài toán về một transaction duy nhất trên một hệ thống duy nhất, và đó là toàn bộ mẹo.
Có hai cách đọc bảng outbox. Polling: một job quét hàng chưa gửi mỗi vài trăm mili giây — đơn giản, dễ vận hành, độ trễ vài trăm ms. Change data capture: đọc thẳng WAL bằng Debezium — độ trễ thấp hơn, không tạo tải truy vấn, nhưng thêm một thành phần hạ tầng phải vận hành. Với phần lớn hệ thống, polling với FOR UPDATE SKIP LOCKED là đủ và ít việc hơn nhiều.
Điều bắt buộc phải nói kèm: outbox cho at-least-once, KHÔNG cho exactly-once. Bên nhận sẽ thấy message trùng, sớm hay muộn. Nên mọi consumer phải idempotent — lưu id message đã xử lý, hoặc thiết kế thao tác sao cho lặp lại không đổi kết quả. Ai trả lời outbox mà không nhắc tới phần này là mới đọc chứ chưa vận hành nó.
Chi tiết vận hành cuối: thứ tự. Nếu nghiệp vụ cần các sự kiện của cùng một thực thể đến đúng thứ tự thì phải phân mảnh theo id thực thể và xử lý tuần tự trong mỗi mảnh; đẩy song song toàn bộ bảng outbox sẽ đảo thứ tự. Đây là chỗ rất hay bị phát hiện muộn, khi một sự kiện updated tới trước created.
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = $1;
INSERT INTO outbox (aggregate_id, type, payload)
VALUES ($1, 'OrderPaid', $2); -- cùng transaction, cùng số phận
COMMIT;
-- Tiến trình đẩy, chạy song song nhiều worker mà không giẫm chân nhau:
SELECT * FROM outbox WHERE sent_at IS NULL
ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED;Câu hỏi tiếp theo họ sẽ hỏi
?Two-phase commit thì sao?
Về lý thuyết giải được, thực tế thì hiếm dùng: nó đòi hỏi mọi bên hỗ trợ XA, khoá tài nguyên suốt hai pha, và nếu coordinator chết giữa chừng thì các bên bị treo ở trạng thái nghi vấn. Kafka và phần lớn broker hiện đại không hỗ trợ XA, nên câu hỏi thường tự đóng lại ở đó.
?Bảng outbox có phình to không?
Có, nếu không dọn. Xoá theo lô những hàng đã gửi quá một khoảng giữ lại, hoặc dùng bảng phân vùng theo ngày rồi drop cả phân vùng — rẻ hơn DELETE rất nhiều và không tạo tuple chết.
Trả lời thế này là mất điểm
- “Gửi message trong
@Transactional, Spring lo phần còn lại.” Spring không commit được broker cùng database. - Hứa exactly-once mà không nói tới consumer idempotent. Không có exactly-once trong giao hàng, chỉ có xử lý hiệu quả một lần.