Dual write và Outbox pattern: ghi dữ liệu và phát sự kiện một cách nguyên tử
Xem thửTình huống: hai kiểu lỗi, và bạn buộc phải chọn một
Ở các chương trước, mọi luồng bất đồng bộ đều được mô tả như sau: "Order Service ghi đơn hàng vào database, rồi publish sự kiện OrderPlaced."
Đây là code thật:
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order); // ghi vào database
eventPublisher.publish(new OrderPlaced(order.getId())); // gửi tới broker
}
Nhật ký sự cố trong một tháng:
Sự cố A — ngày 4:
Broker không truy cập được trong 90 giây.
Kết quả: 412 đơn hàng được tạo thành công, KHÔNG có sự kiện nào được phát.
Hậu quả: 412 khách không nhận email xác nhận, số liệu phân tích thiếu 412 đơn.
Sự cố B — ngày 19:
Một ràng buộc dữ liệu bị vi phạm khiến transaction rollback SAU khi
sự kiện đã được phát.
Kết quả: 27 sự kiện OrderPlaced cho những đơn hàng KHÔNG TỒN TẠI.
Hậu quả: 27 email xác nhận cho đơn hàng không có thật.
Dừng lại. Trả lời ba câu:
- Nếu đổi thứ tự — publish trước, commit sau — thì sự cố nào biến mất và sự cố nào xuất hiện?
- Có thứ tự nào loại bỏ được cả hai không?
- Vấn đề gốc ở đây là gì?
Cách đọc hình kiến trúc. Hình trên: commit database trước rồi publish sau, nếu broker không truy cập được thì đơn hàng đã tồn tại nhưng sự kiện không bao giờ được phát, mất 412 email xác nhận và số liệu analytics. Hình dưới: publish trước rồi commit sau, nếu transaction rollback sau khi đã phát thì có sự kiện cho đơn không tồn tại, gây 27 email ma. Không có thứ tự nào loại bỏ được cả hai vì bạn đang ghi vào hai hệ thống mà không có transaction nào bao trùm. Lỗi này khó phát hiện vì không có exception ở đường đi chính và người dùng vẫn thấy màn hình thành công.
Đang tải sơ đồ…
Đáp án: đây là bài toán dual write, và đổi thứ tự không cứu được
Câu 1 — đổi thứ tự chỉ đổi kiểu lỗi.
Đọc sơ đồ: bạn có đúng hai lựa chọn và mỗi lựa chọn có một chế độ thất bại riêng.
| Thứ tự | Nếu bước hai thất bại | Hậu quả |
|---|---|---|
| Commit database trước, publish sau | Sự kiện không bao giờ được phát | Đơn hàng tồn tại nhưng không ai biết — mất email, mất analytics |
| Publish trước, commit sau | Transaction rollback sau khi đã phát | Sự kiện tồn tại cho đơn hàng không tồn tại — email ma, dữ liệu rác |
Câu 2 — không có thứ tự nào loại bỏ được cả hai.
Câu 3 — vấn đề gốc: bạn đang ghi vào HAI hệ thống, và không có transaction nào bao trùm cả hai.
Database có transaction của nó. Broker có cơ chế của nó. Không có gì đảm bảo hai thao tác cùng thành công hoặc cùng thất bại. Đây gọi là dual write problem, và nó là một trong những nguồn lỗi âm thầm phổ biến nhất trong kiến trúc hướng sự kiện.
Mỗi lần bạn ghi vào hai hệ thống trong cùng một thao tác nghiệp vụ, bạn đã tạo ra một cửa sổ mà hai hệ thống bất đồng — và cửa sổ đó sẽ biến thành sự cố thật khi có đủ lưu lượng.
Điều đáng nói: loại lỗi này rất khó phát hiện. Không có exception nào ở đường đi chính, đơn hàng vẫn được tạo, người dùng vẫn thấy màn hình thành công. Chỉ có 412 khách hàng không nhận được email — và họ thường không báo cáo, họ chỉ mất niềm tin.
Lời giải: đừng ghi vào hai hệ thống. Hãy ghi vào một.
Đang tải Animation…
Cách đọc hình kiến trúc. Thay vì gửi sự kiện tới broker, ghi đơn hàng và một dòng sự kiện vào bảng outbox trong cùng một transaction của database nên hoặc cả hai tồn tại hoặc không cái nào tồn tại. Một tiến trình relay riêng đọc các dòng chưa gửi theo thứ tự, publish tới broker rồi đánh dấu đã gửi. Nếu relay chết sau khi publish mà trước khi đánh dấu thì dòng đó được publish lại, nên relay là at-least-once và consumer vẫn phải idempotent. Cần giữ thứ tự theo khóa nghiệp vụ, dọn bảng định kỳ và cảnh báo theo tuổi của dòng chưa gửi cũ nhất.
Đang tải sơ đồ…
Outbox pattern: biến sự kiện thành một dòng dữ liệu
Đọc sơ đồ: ý tưởng đơn giản tới mức thanh lịch.
Thay vì gửi sự kiện tới broker, bạn ghi sự kiện vào một bảng trong chính database đó — bảng outbox — trong cùng transaction với dữ liệu nghiệp vụ.
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (event_type, payload, created_at)
VALUES ('OrderPlaced', '{...}', now());
COMMIT;
Bây giờ chỉ còn một hệ thống được ghi, nên transaction của database đảm bảo: hoặc cả đơn hàng lẫn sự kiện cùng tồn tại, hoặc không cái nào tồn tại. Cả hai chế độ thất bại ở đầu bài biến mất cùng lúc.
Sau đó, một tiến trình riêng — gọi là relay — đọc các dòng chưa gửi trong bảng outbox, publish chúng tới broker, rồi đánh dấu đã gửi.
Điểm mấu chốt phải hiểu rõ: relay là at-least-once, không phải exactly-once. Nếu nó publish thành công rồi chết trước khi kịp đánh dấu, vòng chạy sau sẽ publish lại chính sự kiện đó.
Outbox giải quyết bài toán "sự kiện có được phát hay không". Nó không giải quyết bài toán "sự kiện có bị phát trùng hay không" — consumer idempotent vẫn là yêu cầu bắt buộc, đúng như đã học ở chương message queue.
Ba chi tiết vận hành quan trọng:
| Chi tiết | Vì sao |
|---|---|
| Giữ thứ tự theo khoá nghiệp vụ khi relay đọc | Nếu hai sự kiện của cùng một đơn hàng bị phát sai thứ tự, consumer sẽ xử lý "đã giao" trước "đã thanh toán" |
| Dọn bảng outbox định kỳ | Nếu không, nó tăng vô hạn và làm chậm chính truy vấn của relay; thường xoá các dòng đã gửi sau vài ngày |
| Giám sát tuổi của dòng chưa gửi cũ nhất | Đây là chỉ số cho biết relay có đang hoạt động không — đúng kiểu chỉ số "độ tươi" đã học ở chương trước, và nó bắt được lỗi im lặng |
Đang tải Animation…
Đang tải Animation…
Cách đọc hình kiến trúc. Polling định kỳ truy vấn các dòng chưa gửi: đơn giản, dễ debug, chạy với mọi database, đổi lại thêm tải truy vấn và độ trễ bằng chu kỳ polling, và cần chỉ mục đúng khi bảng lớn. CDC đọc transaction log: không tạo tải truy vấn và độ trễ rất thấp, đổi lại thêm một thành phần phải vận hành và gắn chặt với cơ chế nội bộ của database. Với hệ thống quy mô vừa, polling chu kỳ một giây là quá đủ cho email hay analytics; CDC xứng đáng khi khối lượng sự kiện rất lớn hoặc cần độ trễ dưới một giây.
Đang tải Animation…
Hai cách hiện thực relay
Đọc hình phía trên: có hai cách đưa sự kiện từ bảng outbox ra broker, và chúng khác nhau về chi phí vận hành.
Cách 1 — Polling: một tiến trình định kỳ chạy truy vấn kiểu SELECT * FROM outbox WHERE sent = false ORDER BY id LIMIT 100, publish, rồi cập nhật cờ.
| Ưu điểm | Nhược điểm |
|---|---|
| Đơn giản, không cần thêm hạ tầng | Thêm tải truy vấn lên database |
| Dễ hiểu, dễ debug, dễ viết test | Có độ trễ bằng chu kỳ polling |
| Hoạt động với mọi loại database | Cần chỉ mục đúng, nếu không truy vấn sẽ chậm dần khi bảng lớn |
Cách 2 — Change Data Capture (CDC): một công cụ đọc transaction log của database và phát ra sự kiện mỗi khi có dòng mới trong bảng outbox.
| Ưu điểm | Nhược điểm |
|---|---|
| Không tạo tải truy vấn | Thêm một thành phần phải vận hành và giám sát |
| Độ trễ rất thấp | Gắn chặt với cơ chế nội bộ của database |
| Không bỏ sót dòng nào | Nâng cấp phiên bản database cần kiểm tra tương thích |
Chọn cái nào? Với phần lớn hệ thống quy mô vừa, polling là lựa chọn đúng: chu kỳ một giây là quá đủ cho việc gửi email hay cập nhật analytics, và bạn không phải vận hành thêm gì. CDC xứng đáng khi lượng sự kiện rất lớn khiến tải polling trở nên đáng kể, hoặc khi độ trễ dưới một giây là yêu cầu thật.
Một lựa chọn thứ ba đáng cân nhắc: bỏ hẳn dual write bằng cách đảo chiều. Nếu sự kiện chính là nguồn sự thật — như trong Event Sourcing ở Chương 10 — thì không có hai lần ghi nào cả: bạn chỉ ghi sự kiện, và trạng thái là projection được dựng từ đó. Đây là lý do Event Sourcing và Outbox thường được nhắc cùng nhau: cả hai đều giải quyết bài toán "làm sao dữ liệu và sự kiện luôn khớp nhau", chỉ bằng hai cách khác nhau về mức độ triệt để.
Cách đọc hình kiến trúc. Cần Outbox khi mất sự kiện có hậu quả thật: OrderPlaced tới trừ tồn kho (bán vượt hoặc giữ hàng vĩnh viễn), PaymentConfirmed tới giao hàng (khách trả tiền mà không nhận hàng). Không cần khi mất sự kiện là chấp nhận được hoặc có cơ chế đối chiếu: ProductViewed tới analytics (thiếu vài lượt xem trong hàng triệu), cập nhật cache (lần đọc sau tự nạp lại). Job đối chiếu, ví dụ tìm đơn hơn 10 phút chưa có bản ghi email rồi gửi bù, là lớp bảo vệ cuối cùng đáng có kể cả khi đã dùng Outbox vì nó bắt được lỗi bạn chưa lường trước.
Khi nào cần Outbox, và khi nào không
Outbox thêm một bảng, một tiến trình relay, và một thứ nữa phải giám sát. Không phải luồng bất đồng bộ nào cũng xứng đáng.
Cần Outbox khi hậu quả của việc mất sự kiện là thật:
| Luồng | Mất sự kiện nghĩa là gì |
|---|---|
OrderPlaced để trừ tồn kho | Bán vượt hàng, hoặc hàng bị giữ vĩnh viễn |
PaymentConfirmed để kích hoạt giao hàng | Khách trả tiền nhưng không bao giờ nhận hàng |
UserDeleted để xoá dữ liệu ở các hệ thống khác | Vi phạm cam kết về quyền riêng tư |
Không cần Outbox khi mất sự kiện là chấp nhận được, hoặc khi có cơ chế đối chiếu:
| Luồng | Vì sao chấp nhận được |
|---|---|
ProductViewed để ghi analytics | Thiếu vài lượt xem trong số hàng triệu không ảnh hưởng kết luận |
| Cập nhật cache | Lần đọc sau sẽ tự nạp lại, và TTL là lưới an toàn |
| Gửi thông báo marketing | Có job chạy định kỳ đối chiếu và gửi bù |
Dòng cuối chỉ ra một lựa chọn thay thế quan trọng: job đối chiếu (reconciliation). Thay vì đảm bảo mọi sự kiện đều tới, bạn chạy định kỳ một job so sánh hai hệ thống và sửa chênh lệch — ví dụ "tìm mọi đơn hàng đã tạo hơn 10 phút mà chưa có bản ghi email".
Với dữ liệu quan trọng, một job đối chiếu là lớp bảo vệ cuối cùng đáng có kể cả khi bạn đã dùng Outbox — vì nó bắt được cả những lỗi mà bạn chưa lường trước.
Hiểu lầm cần phá bỏ
Hiểu lầm 1 — "Ghi database rồi publish event là đủ, chỉ cần bọc trong transaction"
Transaction của database không bao trùm broker. Nếu publish thất bại sau khi commit, sự kiện mất vĩnh viễn và không có exception nào ở đường đi chính.
Hiểu lầm 2 — "Publish trước rồi commit sau thì an toàn hơn"
Nó chỉ đổi kiểu lỗi: thay vì mất sự kiện, bạn có sự kiện ma cho dữ liệu không tồn tại — thường tệ hơn, vì hệ thống hạ nguồn hành động dựa trên thứ không có thật.
Hiểu lầm 3 — "Outbox đảm bảo exactly-once"
Outbox đảm bảo sự kiện không bị mất. Relay vẫn là at-least-once, nên consumer idempotent vẫn bắt buộc.
Hiểu lầm 4 — "Có Outbox rồi thì không cần giám sát nữa"
Relay có thể chết âm thầm. Cần giám sát tuổi của dòng chưa gửi cũ nhất — nếu nó tăng dần, relay đã ngừng hoạt động dù mọi chỉ số khác bình thường.
Hiểu lầm 5 — "Bảng outbox cứ để đó, không cần dọn"
Bảng tăng vô hạn sẽ làm chậm chính truy vấn của relay, và vấn đề này xuất hiện từ từ sau nhiều tháng nên rất dễ bị bỏ qua.
Hiểu lầm 6 — "Mọi luồng bất đồng bộ đều nên dùng Outbox"
Với analytics hay cập nhật cache, mất một phần sự kiện là chấp nhận được và Outbox chỉ thêm chi phí vận hành không cần thiết.
Góc nhìn phỏng vấn System Design
Đây là câu hỏi rất hay được dùng để phân biệt ứng viên đã vận hành hệ thống hướng sự kiện thật hay chưa.
Interviewer có thể hỏi
- "Bạn ghi đơn hàng rồi publish sự kiện. Điều gì xảy ra nếu broker không truy cập được?"
- "Publish trước hay commit trước?" — bẫy: câu trả lời tốt nói rằng cả hai đều sai và giải thích vì sao.
- "Outbox có đảm bảo exactly-once không?"
- "Bạn giám sát gì để biết relay còn sống?"
- "Luồng nào trong hệ thống của bạn không cần Outbox?"
Câu trả lời yếu và câu trả lời tốt hơn
Yếu: "Em ghi đơn hàng vào database rồi publish sự kiện OrderPlaced để các service khác xử lý bất đồng bộ."
Tốt hơn: "Ghi database rồi publish là dual write: hai hệ thống, không có transaction chung, nên luôn tồn tại một trong hai chế độ thất bại — hoặc mất sự kiện khi broker chết, hoặc sinh sự kiện ma khi transaction rollback sau lúc đã publish. Đổi thứ tự chỉ đổi kiểu lỗi chứ không loại bỏ được. Nên với các luồng quan trọng như trừ tồn kho và kích hoạt giao hàng, em dùng Outbox: ghi bản ghi đơn hàng và bản ghi sự kiện trong cùng một transaction, rồi một relay đọc bảng outbox và publish. Relay là at-least-once nên consumer vẫn phải idempotent theo order_id. Em giám sát tuổi của dòng chưa gửi cũ nhất để biết relay còn sống, vì nó có thể chết âm thầm mà mọi chỉ số khác vẫn bình thường. Với analytics thì em không dùng Outbox — mất vài lượt xem trong hàng triệu không ảnh hưởng kết luận, nên chi phí vận hành không xứng đáng."
Tự kiểm tra — không nhìn lại bài
- Mô tả hai chế độ thất bại của dual write, tương ứng với hai thứ tự ghi.
- Vì sao đổi thứ tự publish và commit không giải quyết được vấn đề?
- Vì sao loại lỗi này đặc biệt khó phát hiện?
- Outbox biến hai lần ghi thành mấy lần ghi, và điều gì đảm bảo tính nguyên tử?
- Outbox giải quyết bài toán nào và không giải quyết bài toán nào?
- Kể ba chi tiết vận hành bắt buộc khi dùng Outbox.
- So sánh polling và CDC theo hai tiêu chí: chi phí vận hành và độ trễ. Cái nào phù hợp với hệ thống quy mô vừa?
- Nêu hai luồng cần Outbox và hai luồng không cần, kèm lý do.
- Job đối chiếu bổ sung điều gì mà Outbox không cho?
Chuyển sang bài toán khác
gdev-feed có luồng: khi người dùng đăng bài, hệ thống lưu bài vào database và publish sự kiện PostCreated để các worker dựng feed cho người theo dõi.
- Nếu sự kiện
PostCreatedbị mất, người dùng thấy gì? Đây có phải luồng cần Outbox không? - Một tài khoản có 500.000 người theo dõi đăng bài. Bản ghi outbox cho sự kiện này nên chứa gì — toàn bộ danh sách người theo dõi, hay chỉ
post_id? Giải thích theo hướng kích thước và tính đúng đắn. - Nếu relay bị chậm 10 phút, người dùng nhìn thấy triệu chứng gì, và chỉ số nào cảnh báo cho bạn trước khi họ báo cáo?
Đáp án chi tiết cho các câu hỏi trong bài
Đáp án cho phần Tự kiểm tra
- Commit database trước, publish sau: nếu publish thất bại (broker không truy cập được) thì đơn hàng tồn tại nhưng sự kiện không bao giờ được phát (412 đơn không có email và analytics). Publish trước, commit sau: nếu transaction rollback sau khi đã phát thì có sự kiện ma cho đơn hàng không tồn tại (27 email xác nhận cho đơn không có thật).
- Vì bạn ghi vào hai hệ thống không có transaction chung: đổi thứ tự chỉ đổi bước nào có thể thất bại sau cùng, tức đổi kiểu lỗi chứ không loại bỏ được; luôn tồn tại một trong hai chế độ thất bại.
- Vì không có exception nào ở đường đi chính: đơn hàng vẫn được tạo và người dùng vẫn thấy thành công. Khách bị ảnh hưởng thường không báo cáo mà chỉ mất niềm tin một cách âm thầm.
- Outbox biến hai lần ghi (database và broker) thành một lần ghi vào một hệ thống: bản ghi đơn hàng và bản ghi sự kiện trong bảng
outboxcùng một transaction. Tính nguyên tử do transaction của database đảm bảo: cả hai cùng tồn tại hoặc không cái nào tồn tại. - Outbox giải quyết sự kiện có được phát hay không (không mất sự kiện, không có sự kiện ma). Nó không giải quyết phát trùng: relay là at-least-once nên consumer idempotent vẫn bắt buộc.
- Ba chi tiết: (1) giữ thứ tự theo khóa nghiệp vụ khi relay đọc; (2) dọn bảng outbox định kỳ (xóa dòng đã gửi sau vài ngày) để không làm chậm truy vấn của relay; (3) giám sát tuổi của dòng chưa gửi cũ nhất để biết relay còn sống.
- Polling: chi phí vận hành thấp (không cần hạ tầng thêm, dễ debug), đổi lại thêm tải truy vấn và độ trễ bằng chu kỳ polling. CDC: không có tải truy vấn và độ trễ rất thấp, đổi lại phải vận hành thêm một thành phần và gắn với cơ chế nội bộ của database. Hệ thống quy mô vừa phù hợp với polling.
- Cần Outbox:
OrderPlacedtới trừ tồn kho (bán vượt hoặc giữ hàng vĩnh viễn),PaymentConfirmedtới giao hàng (khách trả tiền mà không nhận hàng); cũng cóUserDeleted(vi phạm quyền riêng tư). Không cần:ProductViewedtới analytics (thiếu vài lượt xem trong hàng triệu không ảnh hưởng kết luận), cập nhật cache (lần đọc sau tự nạp lại, TTL là lưới an toàn), thông báo marketing (có job đối chiếu gửi bù). - Job đối chiếu so sánh định kỳ hai hệ thống và sửa chênh lệch, nên bắt được cả những lỗi bạn chưa lường trước kể cả khi đã dùng Outbox (ví dụ tìm mọi đơn hàng tạo hơn 10 phút mà chưa có bản ghi email). Đó là lớp bảo vệ cuối cùng.
Đáp án cho bài toán chuyển sang gdev-feed
- Mất
PostCreated: tác giả vẫn thấy bài của mình nhưng người theo dõi không bao giờ thấy bài trong feed, một lỗi im lặng ở chức năng cốt lõi. Đáng dùng Outbox (hoặc ít nhất một job đối chiếu quét các bài chưa có dấu hiệu đã fan-out), vì mất sự kiện không tự lành và không ai báo lỗi. - Nội dung bản ghi outbox: chỉ nên chứa
post_id(cùngauthor_idvà thời điểm), không chứa danh sách 500.000 người theo dõi. Lý do về kích thước: một dòng khổng lồ làm phình transaction và bảng outbox; lý do về tính đúng đắn: danh sách người theo dõi thay đổi giữa lúc ghi và lúc xử lý, nên consumer nên tra danh sách hiện tại khi xử lý (và duyệt theo từng lô cho tài khoản lớn). - Relay chậm 10 phút: triệu chứng là bài mới xuất hiện trong feed người theo dõi trễ khoảng 10 phút, người dùng báo bài của họ không hiện. Chỉ số cảnh báo: tuổi của dòng chưa gửi cũ nhất trong outbox (cảnh báo khi vượt vài chục giây), số dòng tồn đọng và độ trễ đầu cuối từ lúc đăng tới lúc bài xuất hiện.
Đáp án gợi ý cho các câu hỏi phỏng vấn
- "Broker không truy cập được thì sao?" Đơn hàng đã commit nhưng sự kiện bị mất, và không có exception ở đường đi chính. Dùng Outbox để sự kiện nằm trong cùng transaction rồi được relay phát sau.
- "Publish trước hay commit trước?" Cả hai đều sai: commit trước thì mất sự kiện, publish trước thì sinh sự kiện ma khi rollback; giải pháp là ghi vào một hệ thống (Outbox).
- "Outbox có đảm bảo exactly-once không?" Không: relay là at-least-once, Outbox chỉ đảm bảo không mất sự kiện, nên consumer vẫn phải idempotent.
- "Giám sát gì để biết relay còn sống?" Tuổi của dòng chưa gửi cũ nhất, số dòng tồn đọng và tín hiệu sống của relay; relay có thể chết âm thầm trong khi mọi chỉ số khác bình thường.
- "Luồng nào không cần Outbox?" Analytics, cập nhật cache và thông báo marketing có job đối chiếu, vì mất một phần sự kiện chấp nhận được và Outbox chỉ thêm chi phí vận hành.
Nếu chỉ nhớ 3 điều
- Ghi vào hai hệ thống không bao giờ nguyên tử — luôn tồn tại một trong hai chế độ thất bại, và đổi thứ tự chỉ đổi kiểu lỗi.
- Outbox biến sự kiện thành một dòng dữ liệu trong cùng transaction, nên hoặc cả hai cùng tồn tại hoặc không cái nào tồn tại.
- Relay là at-least-once: Outbox đảm bảo không mất sự kiện, còn chống trùng vẫn là trách nhiệm của consumer.