Đang tải nội dung…
Đang tải nội dung…
Outbox giữ được ý định gửi. Cửa sổ gửi trùng vẫn cần một câu trả lời riêng.
Consistency, Reliability & Distributed Patterns · Phần 2Bối cảnh hư cấu, bằng chứng lab thật. Fortex trong tuyến truyện này là bối cảnh hư cấu; Tom và Ben là nhân vật. Các cuộc trao đổi minh họa quá trình suy nghĩ. Số đo và assertion thuộc lab Docker được dẫn trong bài, không phải số liệu production của Fortex.
Hành trình ở Fortex · Chương 6/8
Fortex cần chuyển đơn mới sang luồng xử lý kho. Ben đã sửa cách trừ tồn và không còn dùng cache làm nguồn quyết định. Cậu ghép tiếp hai bước rất ngay ngắn: commit đơn trong PostgreSQL, rồi phát OrderCreated.
“Chỉ gửi sau khi commit, nên không có event cho đơn bị rollback,” Ben giải thích.
Tom đọc hết nhánh lỗi. “Em đã xử lý được một chiều. Giờ giả sử tiến trình dừng ở đây.” Anh đặt con trỏ giữa commit và publish.
“Thì lần sau retry gửi lại.”
“Lần sau tìm đơn cần gửi ở đâu?”
Ben nhìn biến event đang nằm trong bộ nhớ. Câu trả lời retry vẫn đúng về ý định, nhưng chưa có gì sống sót để nhắc một tiến trình mới phải retry việc gì. Tom mở lab và để khoảng hở ấy hiện ra thành dữ liệu có thể kiểm tra.
Ben từng muốn thêm annotation transaction quanh cả hàm. Tom đề nghị cậu vẽ đường biên thật: transaction đang thuộc PostgreSQL, còn lời gọi broker đi tới một hệ thống khác. Đường viền quanh code không tự thành đường viền commit.
API tạo đơn ghi thành công vào PostgreSQL. Bước kế tiếp là gửi OrderCreated để dịch vụ khác xử lý. Tiến trình dừng ngay giữa hai bước. Khách nhìn thấy đơn hàng trong lịch sử, nhưng downstream chưa bao giờ nhận được sự kiện.
Bọc đoạn code bằng @Transactional ở ứng dụng không tự đưa broker vào transaction của PostgreSQL. Đổi thứ tự cũng chưa đủ: gửi event trước, rồi DB rollback, bạn có thể phát ra một sự kiện cho đơn hàng không tồn tại.
Transactional outbox giải quyết khoảng hở ấy bằng cách lưu ý định gửi cùng transaction với dữ liệu nghiệp vụ. Nó chưa xóa mọi khoảng hở còn lại.
“Vậy em lưu cả việc cần làm trong database,” Ben nói. “Cùng commit với đơn. Worker khởi động lại vẫn tìm thấy.”
“Đúng. Em vừa đổi một lời hứa trong bộ nhớ thành một bản ghi,” Tom đáp. “Còn việc gửi cuối cùng có đến nơi, mình phải theo tiếp đường đi.”
BEGIN;
INSERT INTO orders(id) VALUES (2);
INSERT INTO outbox(event_id, order_id, sent)
VALUES ('evt-2', 2, false);
COMMIT;
Nếu transaction rollback, cả đơn lẫn ý định gửi đều biến mất. Nếu commit, relay có thể tìm thấy ý định dù request tạo đơn đã kết thúc hoặc process ứng dụng đã restart. Để ý: điều này giữ được bản ghi cần gửi. Việc event cuối cùng đến nơi còn phụ thuộc relay, retry, retention và vận hành.
Một relay đọc outbox chưa gửi, publish sang broker, chờ xác nhận phù hợp rồi đánh dấu đã gửi. Nguồn tham khảo cho bài là mô tả transactional outbox của AWS. Ví dụ dưới đây dùng Redis Streams để quan sát, không dùng Kafka và không suy ra bảo đảm durability của Kafka từ kết quả này.
Ben đưa câu hỏi trước Tom: “Nếu gửi xong mà chưa đánh dấu thì worker gửi lại. Em chuyển đánh dấu lên trước?”
Tom để cậu tự dời con trỏ đến khoảng giữa mới. Ben thở ra: “Lại mất event. Mình phải nhận trùng để còn retry được.”
Relay gửi evt-2. Broker trả lời thành công. Relay dừng trước UPDATE outbox SET sent = true. Sau restart, row vẫn chưa gửi, nên relay publish lần nữa.
Nếu đánh dấu sent trước khi gửi, bạn lại có cửa sổ mất event. Nếu đánh dấu sau khi gửi, bạn chấp nhận cửa sổ gửi trùng. Outbox thường chọn khả năng retry và buộc downstream chịu được duplicate.
Event ID phải được tạo khi ghi ý định và giữ nguyên qua các lần gửi. ID do broker cấp cho mỗi lần publish có thể khác nhau; dùng nó để dedupe sẽ không nhận ra hai message đại diện cùng một event nghiệp vụ.
“Em nhớ bài tồn kho,” Ben nói. “Hai lần tăng hợp lệ vẫn có thể là một việc bị làm hai lần. Consumer phải nhận ra cùng event.”
Tom hỏi cậu đặt marker ở transaction nào. Ben gom marker và projection lại, rồi ghi thêm giới hạn: một lời gọi ra bên ngoài không được phép coi như đã nằm trong commit này.
Trong lab, consumer lưu event đã nhận và tăng projection trong cùng transaction:
BEGIN;
WITH accepted AS (
INSERT INTO inbox(event_id) VALUES ('evt-2')
ON CONFLICT DO NOTHING
RETURNING event_id
)
UPDATE projection SET total = total + 1
WHERE EXISTS (SELECT 1 FROM accepted);
COMMIT;
Unique key trên inbox.event_id là điểm phân xử. Chỉ transaction thực sự chèn được marker mới áp dụng tác động. Nếu ghi inbox ở một transaction, cập nhật nghiệp vụ ở transaction khác, crash ở giữa có thể khiến retry bị chặn dù nghiệp vụ chưa chạy.
Nếu tác động là gửi email hoặc gọi cổng thanh toán ngoài DB, transaction này không bao phủ tác động ấy. Cần cơ chế tương ứng ở phía nhận, chẳng hạn idempotency key, hoặc chấp nhận và thiết kế cách hòa giải. “Đã dùng outbox” chưa đủ để tuyên bố exactly once cho cả hệ thống.
Hai người giữ lab nhỏ bằng Redis Streams để quan sát message và ID. Tom yêu cầu viết rõ nó không phải phép thử Kafka của Fortex. Ben ghi từng điểm cố ý bỏ qua lệnh và từng lần gửi lại; câu chuyện dừng tiến trình không được biến thành tuyên bố đã kiểm thử mất điện.
Docker dựng PostgreSQL 17.6 và Redis Streams 7.4.2 trên network riêng. Nhánh đầu commit order rồi bỏ qua lệnh publish, mô hình hóa tiến trình dừng đúng tại điểm đó. Nhánh outbox commit hai row; relay gửi hai lần với cùng event ID, mô hình hóa restart trước khi đánh dấu sent. Đây là failpoint có kiểm soát, không phải kill máy hay thử failover broker.
| Nhánh / điểm đo | Kết quả quan sát |
|---|---|
| Dual write: broker messages | 0 |
| Outbox: pending after crash | 1 |
| Relay retry: broker messages | 2 |
| Inbox: applied side effects | 1 |
Hai lần XADD có hai stream ID khác nhau. Inbox vẫn chỉ có một evt-2, projection tăng đúng một. Một transaction khác tạo order 3 và evt-3 rồi rollback; cả hai row đều không tồn tại. Các assertion, ID thật và command đều nằm trong report và raw logs.
Redis của lab tắt persistence và dữ liệu dùng một lần. Nó giúp kiểm chứng ranh giới transaction và duplicate, không kiểm chứng dữ liệu sống sót sau mất điện. Source Docker và hướng dẫn make reproduce ở cuối bài.
Trong bản đề xuất, Ben đã có bảng outbox, relay và inbox. Tom không hỏi thêm pattern nào nữa. Anh hỏi ai nhận cảnh báo khi event cũ nhất chưa gửi cứ già đi.
Ben bổ sung chủ sở hữu, retention và đường replay vào quyết định. Lưu được ý định là điều kiện để sửa lỗi, chưa phải tự động có người sửa.
| Vấn đề | Hệ quả | Việc cần thiết kế |
|---|---|---|
| Relay chậm hoặc ngừng | DB đúng nhưng downstream trễ | Đo tuổi event chưa gửi lâu nhất, backlog và retry |
| Nhiều relay | Claim trùng hoặc đảo thứ tự | Claim có kiểm soát, lease/recovery; ordering theo aggregate khi cần |
| Outbox và inbox tăng mãi | Tốn storage, index và VACUUM | Retention khớp replay window; cleanup theo batch |
| Event lỗi không sửa được bằng retry | Chặn hàng hoặc retry vô hạn | Cách ly event, cảnh báo, quy trình replay |
| Schema event thay đổi | Consumer cũ có thể đọc sai | Version hợp đồng và tương thích dữ liệu |
FOR UPDATE SKIP LOCKED giúp nhiều worker chia việc nhưng không tự bảo đảm mọi event giữ thứ tự nghiệp vụ. Giữ lock DB trong khi chờ broker có thể đơn giản ở tải nhỏ, đổi lại connection và transaction sống lâu. Cách claim rồi nhả transaction sớm cần lease và xử lý owner chết. Lab chưa triển khai bộ relay nhiều worker để tránh đánh đồng ví dụ nguyên lý với một service đã đủ tính năng production.
Outbox cũng tạo thêm ghi và thêm độ trễ đến downstream. Với một job hoàn toàn nằm trong DB, có khi một bảng job đã đủ. Với hai hệ thống cần phối hợp bền vững, lưu được ý định là bước quan trọng. Nhưng sau đó vẫn cần một câu trả lời rõ ràng cho “nếu gửi lại thì sao?”.
Ben ghi vào sổ: “Retry cần tìm lại được việc. Dedupe cần nhận lại được cùng một việc.” Cậu đề nghị Fortex chấp nhận giao lại event và kiểm soát tác động ở consumer, thay vì hứa exactly once cho toàn bộ hành trình.
Tom để cậu trình bày phần đó. Nhưng một câu hỏi từ nhóm export lại kéo cả hai về database: ai được quyền thấy những đơn và hóa đơn ấy?
Chương trước: Một key hết hạn, 12 request cùng đổ xuống database · Chương tiếp: Quên WHERE tenant_id: RLS có chặn được rò dữ liệu? · Bắt đầu hành trình
PostgreSQL 17.6 + Redis 7.4.2 · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT e4881db7ab326ce553bbb0172c44fab421517f08
Đọc kỹ README trước khi chạy. Các lệnh thực nghiệm chuẩn trong Docker Lab:
make setupmake seedmake reproducemake verifymake cleanupMôi trường Docker độc lập, giới hạn tài nguyên và tự động dọn dẹp sau khi chạy. Số liệu trong bài viết là kết quả lần chạy chính thức; mỗi lần chạy mới đều lưu lịch sử riêng.