Thứ Hai đầu tuần, team nhận ticket từ CSKH: “Khách đặt đơn thành công, app hiện ‘Đặt hàng thành công’, nhưng kho không trừ tồn kho, shipper không nhận đơn.” Mở log ra, order service ghi database thành công, trả 200 cho client. Nhưng dòng log ngay sau đó: "Failed to publish OrderCreated event to Kafka: broker not available". Kafka cluster vừa restart do maintenance window trùng giờ cao điểm. Đơn hàng nằm trong database nhưng không ai biết nó tồn tại, inventory service, shipping service, notification service đều im lặng vì không nhận được event.
Đây không phải edge case hiếm gặp. Đây là dual write problem, bài toán kinh điển của distributed systems mà bất kỳ hệ thống nào ghi dữ liệu vào database rồi publish event sang message broker đều phải đối mặt. Và transactional outbox pattern là giải pháp được chứng minh hiệu quả nhất cho bài toán này.
Dual write problem, hai hệ thống, không có giao dịch chung
Trong monolith truyền thống, mọi thứ nằm trong một database transaction. Tạo order, trừ tồn kho, ghi log, tất cả commit hoặc rollback cùng nhau. ACID đảm bảo consistency. Nhưng khi chuyển sang microservices hoặc event-driven architecture, mỗi service có database riêng, giao tiếp qua message broker (Kafka, RabbitMQ, NATS). Lúc này, một business operation cần ghi vào hai hệ thống khác nhau: database của service hiện tại và message broker để thông báo cho service khác.
Vấn đề cốt lõi: database và message broker là hai hệ thống độc lập, không chia sẻ transaction. Không có cách nào để atomic commit vào cả hai cùng lúc mà không dùng distributed transaction (2PC), và 2PC trong thực tế có quá nhiều nhược điểm về performance và availability để dùng cho mọi business operation.
Hai kịch bản thất bại
Kịch bản thứ nhất: DB commit thành công, publish thất bại. Đây là trường hợp phổ biến nhất và cũng là trường hợp mở đầu bài viết. Order được tạo trong database, client nhận response thành công, nhưng event OrderCreated không bao giờ đến Kafka. Downstream services không biết order tồn tại. Dữ liệu giữa các service trở nên inconsistent, order service nói “đơn đã tạo”, inventory service nói “không có đơn nào cả”.
Kịch bản thứ hai ít gặp hơn nhưng nguy hiểm hơn: publish thành công, DB rollback. Code publish event trước khi commit DB transaction. Event đến Kafka, inventory service trừ tồn kho. Nhưng DB transaction fail (constraint violation, deadlock, connection drop) và rollback. Giờ inventory đã trừ hàng cho một đơn không tồn tại trong database. Ghost event, event mô tả sự kiện chưa bao giờ thực sự xảy ra.
Cả hai kịch bản đều dẫn đến data inconsistency giữa các service, và inconsistency trong distributed system là loại bug khó phát hiện nhất vì nó không crash ngay, nó âm thầm tích luỹ cho đến khi ai đó nhận ra “số liệu không khớp” hoặc khách hàng phàn nàn.
Tại sao không đổi thứ tự?
Câu hỏi tự nhiên: nếu “DB trước, publish sau” có rủi ro publish fail, thì đổi thành “publish trước, DB sau” có giải quyết được không? Không, chỉ đổi loại failure. Publish trước thì có kịch bản ghost event như đã mô tả. Thực tế không có thứ tự nào an toàn khi hai hệ thống không chia sẻ transaction boundary.
Một số người đề xuất dùng distributed transaction (2PC, two-phase commit) giữa DB và broker. Về lý thuyết đúng, nhưng thực tế 2PC có ba vấn đề lớn. Thứ nhất, không phải mọi message broker đều hỗ trợ XA transaction, Kafka không hỗ trợ, RabbitMQ hỗ trợ hạn chế. Thứ hai, 2PC giảm throughput đáng kể vì cần coordinate giữa hai resource manager. Thứ ba, 2PC có failure mode riêng, nếu coordinator crash giữa prepare và commit, cả hai participant bị block cho đến khi coordinator recover. Trong production, 2PC cho mỗi business operation là con đường không ai muốn đi.
Transactional outbox, ý tưởng cốt lõi
Hiểu nôm na thì outbox giống như để lại mảnh giấy nhắn trên bàn trước khi ra ngoài, thay vì cố gọi điện và gửi email cùng lúc (dễ thất bại một cái), bạn viết giấy nhắn, đặt ngay trên bàn, rồi người ở nhà sẽ đọc và xử lý sau. Tờ giấy không bao giờ mất vì nó nằm trong nhà, không phụ thuộc vào mạng điện thoại.
Transactional outbox giải quyết dual write bằng một insight đơn giản: đừng ghi vào hai hệ thống, chỉ ghi vào một. Thay vì ghi database VÀ publish event, chỉ ghi database. Event cần publish được lưu vào một bảng đặc biệt gọi là outbox table trong cùng database, trong cùng transaction với business data. Sau đó, một process riêng biệt đọc outbox table và publish event sang broker.
Vì business data và outbox event nằm trong cùng một database transaction, chúng commit hoặc rollback cùng nhau, atomicity được đảm bảo bởi database engine mà không cần distributed transaction. Nếu transaction commit, cả order lẫn outbox record đều tồn tại. Nếu rollback, cả hai đều không tồn tại. Không bao giờ có trạng thái “order có nhưng event không có” hoặc ngược lại.
Outbox publisher, process đọc outbox table và publish sang broker, chạy độc lập. Nếu publisher crash hoặc broker không khả dụng, event vẫn an toàn trong database, chờ được publish khi hệ thống recover. Đây là điểm khác biệt quan trọng nhất so với publish trực tiếp: event không bao giờ mất vì nó đã được persist trong database.
Outbox table schema
Schema outbox table không phức tạp nhưng mỗi cột đều có lý do tồn tại:
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(255) NOT NULL, -- 'Order', 'Payment', 'User'
aggregate_id VARCHAR(255) NOT NULL, -- ID của entity gốc
event_type VARCHAR(255) NOT NULL, -- 'OrderCreated', 'OrderCancelled'
payload JSONB NOT NULL, -- nội dung event
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ, -- NULL = chưa publish
-- index cho publisher query
INDEX idx_outbox_unpublished (published_at) WHERE published_at IS NULL
);
aggregate_type và aggregate_id xác định entity gốc mà event thuộc về. Hai trường này quan trọng cho việc đảm bảo ordering, event của cùng một aggregate phải được publish theo đúng thứ tự tạo ra. event_type cho consumer biết loại event để deserialize đúng. payload chứa nội dung event dạng JSON, đủ thông tin để consumer xử lý mà không cần gọi ngược lại service gốc.
published_at là trường đánh dấu event đã được publish thành công hay chưa. Publisher query tất cả record có published_at IS NULL, publish lên broker, rồi update published_at. Partial index trên published_at IS NULL giữ cho query nhanh khi bảng outbox lớn, chỉ index những record chưa publish, không index hàng triệu record đã xử lý.
id dạng BIGSERIAL (auto-increment) đảm bảo ordering tự nhiên, record tạo trước có id nhỏ hơn. Publisher query ORDER BY id ASC là đủ để publish theo thứ tự thời gian. Nếu dùng UUID làm primary key thì mất ordering tự nhiên này, phải dựa vào created_at, nhưng timestamp có thể trùng khi nhiều transaction commit cùng lúc.
Ghi outbox trong business transaction
Code phía application trông như thế này (ví dụ với Node.js và PostgreSQL):
async function createOrder(orderData) {
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. Ghi business data
const order = await client.query(
`INSERT INTO orders (customer_id, total, status)
VALUES ($1, $2, 'CREATED') RETURNING *`,
[orderData.customerId, orderData.total]
);
// 2. Ghi outbox event trong cùng transaction
await client.query(
`INSERT INTO outbox_events
(aggregate_type, aggregate_id, event_type, payload)
VALUES ($1, $2, $3, $4)`,
[
"Order",
order.rows[0].id,
"OrderCreated",
JSON.stringify({
orderId: order.rows[0].id,
customerId: orderData.customerId,
total: orderData.total,
items: orderData.items,
createdAt: new Date().toISOString(),
}),
]
);
await client.query("COMMIT");
return order.rows[0];
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
Hai INSERT nằm trong cùng BEGIN/COMMIT. Database đảm bảo cả hai thành công hoặc cả hai rollback. Không có trạng thái trung gian. Không cần lo Kafka có available hay không tại thời điểm này, event đã an toàn trong database.
Polling publisher, đơn giản và hiệu quả
Polling publisher là cách đơn giản nhất để đưa event từ outbox table sang message broker. Một background worker chạy định kỳ (ví dụ mỗi 1-5 giây), query outbox table lấy các record chưa publish, publish lên broker, rồi đánh dấu đã publish.
async function publishOutboxEvents() {
const client = await pool.connect();
try {
// Lấy batch chưa publish, lock để tránh duplicate khi chạy nhiều instance
const events = await client.query(
`SELECT * FROM outbox_events
WHERE published_at IS NULL
ORDER BY id ASC
LIMIT 100
FOR UPDATE SKIP LOCKED`
);
for (const event of events.rows) {
await kafkaProducer.send({
topic: `${event.aggregate_type}.events`,
messages: [
{
key: event.aggregate_id, // partition key = aggregate_id
value: JSON.stringify({
eventId: event.id,
eventType: event.event_type,
aggregateType: event.aggregate_type,
aggregateId: event.aggregate_id,
payload: event.payload,
createdAt: event.created_at,
}),
},
],
});
await client.query(
"UPDATE outbox_events SET published_at = NOW() WHERE id = $1",
[event.id]
);
}
} finally {
client.release();
}
}
// Chạy mỗi 2 giây
setInterval(publishOutboxEvents, 2000);
FOR UPDATE SKIP LOCKED là chi tiết quan trọng khi chạy nhiều instance của publisher. Nếu không lock, hai instance có thể query cùng batch event, cả hai publish cùng event, gây duplicate. SKIP LOCKED nghĩa là nếu row đã bị instance khác lock, bỏ qua thay vì chờ, các instance xử lý batch khác nhau song song.
Partition key dùng aggregate_id đảm bảo mọi event của cùng một order đều vào cùng một partition trên Kafka. Kafka đảm bảo ordering trong một partition, nên event OrderCreated luôn đến trước OrderShipped cho cùng order, miễn là producer gửi theo thứ tự id trong outbox.
Ưu và nhược điểm của polling
Ưu điểm lớn nhất của polling publisher là đơn giản. Không cần thêm infrastructure nào ngoài database và broker đã có sẵn. Code dễ hiểu, dễ debug, dễ monitor. Khi có vấn đề, query outbox table là thấy ngay event nào chưa publish, kẹt ở đâu.
Nhược điểm thứ nhất là latency. Event nằm trong outbox chờ đến lượt poll tiếp theo mới được publish. Nếu poll interval là 2 giây, trung bình mỗi event chờ 1 giây trước khi được publish. Với nhiều use case, 1-2 giây latency hoàn toàn chấp nhận được. Nhưng nếu downstream service cần phản ứng trong millisecond, ví dụ hiển thị realtime notification, thì polling quá chậm.
Nhược điểm thứ hai là tải lên database. Polling query chạy liên tục, kể cả khi không có event mới. Với bảng outbox nhỏ và partial index, tải này không đáng kể. Nhưng nếu outbox tích luỹ hàng triệu record chưa cleanup (sẽ nói ở phần sau), query có thể chậm dần.
Nhược điểm thứ ba là trade-off giữa latency và tải. Poll thường xuyên (mỗi 100ms) thì latency thấp nhưng tải database cao. Poll ít (mỗi 10 giây) thì tải thấp nhưng event chờ lâu. Phải chọn điểm cân bằng phù hợp với yêu cầu business.
CDC với Debezium, đọc transaction log, không cần poll
Change Data Capture (CDC) là cách tiếp cận khác để đưa event từ outbox table sang broker, nhưng thay vì poll database bằng query SQL, CDC đọc trực tiếp transaction log của database, WAL (Write-Ahead Log) trong PostgreSQL, binlog trong MySQL. Mỗi khi có INSERT vào outbox table, transaction log ghi lại thay đổi đó, và CDC connector đọc log entry này rồi stream sang Kafka.
Debezium là CDC platform phổ biến nhất hiện tại, chạy trên Kafka Connect framework. Nó hỗ trợ PostgreSQL, MySQL, MongoDB, SQL Server, Oracle, và nhiều database khác. Với outbox pattern, Debezium có sẵn Outbox Event Router, một SMT (Single Message Transform) chuyên thiết kế cho use case này.
Cách Debezium hoạt động với outbox
Debezium connector đọc WAL/binlog của database, detect mọi thay đổi (INSERT, UPDATE, DELETE) trên bảng được cấu hình. Khi thấy INSERT vào outbox table, connector tạo một Kafka message từ record đó. Outbox Event Router transform message theo format mong muốn, route vào topic dựa trên aggregate_type, dùng aggregate_id làm message key, payload làm message value.
Cấu hình Debezium connector cho outbox pattern trên PostgreSQL trông như thế này:
{
"name": "order-service-outbox",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "db-host",
"database.port": "5432",
"database.user": "debezium",
"database.password": "${secrets:db-password}",
"database.dbname": "order_service",
"table.include.list": "public.outbox_events",
"plugin.name": "pgoutput",
"slot.name": "debezium_outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.fields.additional.placement": "event_type:header:eventType",
"transforms.outbox.route.topic.replacement": "${routedByValue}.events",
"transforms.outbox.route.by.field": "aggregate_type"
}
}
Connector đọc WAL qua replication slot debezium_outbox. Mỗi INSERT vào outbox_events được Event Router transform: topic là Order.events (dựa trên aggregate_type), message key là aggregate_id, message value là payload. event_type được đặt vào Kafka header để consumer có thể filter mà không cần deserialize body.
Ưu điểm của CDC so với polling
Latency thấp, event được publish gần như ngay khi transaction commit, thường trong vài trăm millisecond. Không chờ poll interval. Với hệ thống cần near-realtime event propagation, CDC vượt trội.
Không tải database bằng query, CDC đọc transaction log, không chạy SELECT trên outbox table. Transaction log được database ghi sẵn cho mục đích recovery và replication, CDC chỉ “ké” đọc thêm. Tải lên database gần như không đáng kể so với polling liên tục.
Không bỏ sót event, transaction log ghi lại mọi thay đổi theo thứ tự commit. CDC connector track offset trong log, nên kể cả khi connector restart, nó tiếp tục từ vị trí cuối cùng đã xử lý. Không có race condition giữa nhiều publisher instance như polling.
Nhược điểm và complexity
Nhưng CDC không miễn phí, nó đổi simplicity lấy latency.
Thêm infrastructure, Debezium chạy trên Kafka Connect, cần cluster Kafka Connect riêng (hoặc share với các connector khác). Phải monitor connector health, xử lý connector failure, manage replication slot trên PostgreSQL (slot không được cleanup tự động nếu connector chết, WAL tích luỹ, disk full).
Schema evolution phức tạp hơn, khi thay đổi schema outbox table (thêm cột, đổi tên cột), Debezium connector cần xử lý đúng. Với Avro schema registry thì có schema compatibility check. Với JSON thì consumer phải handle missing fields gracefully.
Debug khó hơn, khi event không xuất hiện trên Kafka, phải kiểm tra nhiều lớp: outbox record có trong DB không → WAL có ghi không → Debezium connector có đọc được không → transform có lỗi không → Kafka topic có nhận được không. So với polling publisher mà debug chỉ cần check outbox table và publisher log, CDC có nhiều điểm failure hơn.
Replication slot trên PostgreSQL là rủi ro cần đặc biệt chú ý. Nếu Debezium connector chết và không ai phát hiện, replication slot vẫn giữ WAL, PostgreSQL không xoá WAL segment mà slot chưa đọc. WAL tích luỹ, disk đầy, database crash. Monitoring replication slot lag là bắt buộc khi dùng CDC.
Ordering, đảm bảo thứ tự trong aggregate
Một câu hỏi thường gặp khi triển khai outbox: “Event có được publish theo đúng thứ tự không?” Câu trả lời phụ thuộc vào scope ordering mà hệ thống cần.
Ordering trong cùng aggregate, ví dụ mọi event của cùng một order (OrderCreated → OrderPaid → OrderShipped) phải theo đúng thứ tự. Outbox pattern đảm bảo điều này khi kết hợp với Kafka partitioning: dùng aggregate_id làm partition key, mọi event của cùng order vào cùng partition, Kafka đảm bảo ordering trong partition. Publisher (polling hoặc CDC) đọc outbox theo thứ tự id tăng dần, publish tuần tự, ordering được bảo toàn.
Ordering giữa các aggregate, ví dụ event OrderCreated của order A phải đến trước OrderCreated của order B nếu A tạo trước B. Đây là total ordering và outbox pattern không đảm bảo điều này ở mức cross-partition trên Kafka. Nếu hai order nằm trên hai partition khác nhau, thứ tự tiêu thụ phụ thuộc vào consumer. Thực tế, rất ít business use case cần total ordering giữa các aggregate khác nhau, nếu hệ thống cần điều này, cần xem lại thiết kế aggregate boundary.
Một lưu ý quan trọng với polling publisher: khi publish một batch event, nếu event thứ 3 trong batch fail (broker từ chối), không được skip nó và publish event thứ 4, vì sẽ phá vỡ ordering. Phải retry event thứ 3 cho đến khi thành công, hoặc dừng toàn bộ batch. Đây là lý do publisher nên publish tuần tự theo id trong mỗi partition key, không parallelize trong cùng partition.
Idempotency phía consumer, at-least-once là bắt buộc
Outbox pattern đảm bảo event không bị mất (at-least-once delivery), nhưng không đảm bảo event chỉ đến đúng một lần (exactly-once). Có nhiều kịch bản gây duplicate: publisher publish event thành công nhưng crash trước khi đánh dấu published_at, khi restart nó publish lại event đó. Hoặc Kafka producer retry do network timeout, broker đã nhận message nhưng producer không nhận được ACK, nên gửi lại.
Vì at-least-once là đặc tính inherent của pattern này, consumer phải xử lý duplicate. Hai cách phổ biến:
Idempotent processing, thiết kế logic xử lý sao cho chạy nhiều lần với cùng input cho cùng kết quả. Ví dụ: UPDATE inventory SET quantity = quantity - 5 WHERE order_id = 'abc' AND NOT EXISTS (SELECT 1 FROM processed_events WHERE event_id = 'xyz'). Nếu event đã xử lý, query không thay đổi gì.
Deduplication table, consumer lưu event_id của mỗi event đã xử lý vào bảng riêng. Trước khi xử lý event, check xem event_id đã tồn tại chưa. Nếu có, skip. Processing business logic và insert deduplication record phải nằm trong cùng transaction để đảm bảo atomicity.
BEGIN;
-- Check duplicate
SELECT 1 FROM processed_events WHERE event_id = 'outbox-event-42';
-- Nếu không tồn tại, xử lý
UPDATE inventory SET reserved = reserved + 1 WHERE product_id = 'P001';
INSERT INTO processed_events (event_id, processed_at) VALUES ('outbox-event-42', NOW());
COMMIT;
event_id ở đây chính là id của outbox record, unique, sequential, và có sẵn trong message payload. Consumer dùng giá trị này để deduplicate. Nếu dùng UUID cho outbox id thì cũng hoạt động tương tự, miễn là unique.
Deduplication table cũng cần cleanup định kỳ, giữ record trong 7-14 ngày đủ để cover mọi kịch bản retry, xoá record cũ hơn để bảng không phình. Thường dùng partition by date hoặc batch delete ngoài giờ cao điểm.
Cleanup outbox table
Outbox table mà không cleanup sẽ phình vô hạn. Mỗi business event tạo một record, với hệ thống vài nghìn event/giây thì một ngày đã hàng trăm triệu record. Bảng lớn ảnh hưởng performance, kể cả với partial index, vacuum trên PostgreSQL vẫn phải scan bảng, autovacuum chậm dần.
Hai chiến lược cleanup phổ biến:
Delete sau khi publish, đơn giản nhất. Publisher publish xong thì DELETE record thay vì UPDATE published_at. Ưu điểm: bảng luôn nhỏ, chỉ chứa event chưa publish. Nhược điểm: mất lịch sử, không thể audit “event nào đã publish cho order này” nếu cần debug.
Archive rồi delete, publisher đánh dấu published_at, một job riêng chạy hàng ngày move record có published_at cũ hơn N ngày sang bảng archive hoặc xoá hẳn. Mình thường giữ 7 ngày trong outbox, archive 90 ngày vào bảng outbox_events_archive hoặc xuất ra object storage. Đủ để debug incident gần đây mà không để bảng chính phình.
-- Xoá event đã publish quá 7 ngày, batch nhỏ tránh lock lâu
DELETE FROM outbox_events
WHERE published_at < NOW() - INTERVAL '7 days'
AND id IN (
SELECT id FROM outbox_events
WHERE published_at < NOW() - INTERVAL '7 days'
LIMIT 10000
);
Xoá batch nhỏ (10,000 record mỗi lần) thay vì DELETE ... WHERE published_at < ... không giới hạn, vì delete lớn lock bảng lâu, ảnh hưởng write throughput của business transaction. Chạy nhiều batch nhỏ với sleep giữa các batch là pattern an toàn hơn.
Với CDC (Debezium), cleanup có thêm một cân nhắc: Debezium có thể cấu hình để delete record ngay sau khi đọc (dùng Debezium outbox event router với table.expand.json.payload và delete mode). Hoặc đơn giản hơn: vì CDC đọc từ WAL chứ không phải từ table, record đã bị delete vẫn xuất hiện trong WAL cho đến khi WAL segment được recycle, nên delete không ảnh hưởng CDC. Tuy nhiên, cần test kỹ với version Debezium cụ thể vì hành vi có thể khác nhau tuỳ cấu hình.
Payload trong outbox, nhỏ gọn và self-contained
Thiết kế payload outbox event cần cân nhắc kỹ. Payload quá lớn làm outbox table phình, tăng IO khi publisher đọc batch, tăng network khi publish lên broker. Payload quá nhỏ (chỉ có ID) buộc consumer phải gọi ngược lại service gốc để lấy thông tin, tạo coupling và điểm failure mới.
Nguyên tắc mà nhiều team áp dụng hiệu quả: payload chứa đủ thông tin để consumer xử lý business logic cơ bản mà không cần callback. Với OrderCreated, payload nên có orderId, customerId, items (danh sách sản phẩm và số lượng), total, createdAt. Consumer inventory service nhận event, đọc items, trừ tồn kho, không cần gọi order service lấy thêm thông tin.
Nhưng tránh nhét cả object graph phức tạp vào payload, ví dụ toàn bộ customer profile, shipping address chi tiết, lịch sử đơn hàng cũ. Những thông tin này consumer có thể lấy từ source of truth khi thực sự cần, không phải embed vào mỗi event.
Kích thước payload hợp lý cho hầu hết use case là dưới vài KB. Nếu event cần carry binary data lớn (ảnh, file), lưu data vào object storage (S3), chỉ đặt URL reference trong payload. Outbox table không nên chứa blob data, nó là bảng transactional, cần nhanh.
Schema evolution của payload cũng cần nghĩ từ đầu. Khi thêm field mới vào event, consumer cũ phải handle được, JSON tự nhiên hỗ trợ điều này (ignore unknown fields). Khi bỏ field, consumer đang dùng field đó sẽ break, cần coordinate deprecation. Schema registry (Confluent Schema Registry với Avro/Protobuf) giúp enforce backward/forward compatibility, nhưng cho team nhỏ dùng JSON, convention “chỉ thêm field, không xoá, không đổi type” đã đủ an toàn.
Polling hay CDC, chọn cái nào?
Quyết định giữa polling publisher và CDC không phải “cái nào tốt hơn” mà là “cái nào phù hợp với constraint hiện tại”. Hai cách tiếp cận giải quyết cùng bài toán nhưng có trade-off khác nhau.
Polling publisher phù hợp khi team nhỏ, infrastructure đơn giản, latency vài giây chấp nhận được, và đội ngũ chưa có kinh nghiệm vận hành Kafka Connect. Code polling publisher chỉ vài chục dòng, debug bằng cách query outbox table, deploy cùng application hoặc tách thành worker process nhỏ. Đây là điểm khởi đầu mà hầu hết team nên chọn.
CDC với Debezium phù hợp khi cần latency sub-second, hệ thống đã có Kafka Connect infrastructure, team có kinh nghiệm vận hành connector, và outbox throughput cao đến mức polling tạo tải đáng kể lên database. CDC cũng phù hợp khi muốn capture changes từ nhiều bảng hoặc nhiều database, Debezium connector setup một lần cho cả cluster.
Một con đường mà nhiều team đi thành công: bắt đầu với polling publisher vì đơn giản (và thường không bao giờ cần migrate sang CDC vì polling đã đủ tốt, lý do thật sự chọn polling thường không phải kỹ thuật mà là không ai muốn vận hành thêm Kafka Connect), đo latency và database load thực tế. Khi latency không đáp ứng yêu cầu hoặc polling tải database quá nặng, migrate sang CDC. Interface giữa outbox table và consumer không thay đổi, chỉ thay cách đưa event từ table sang broker. Consumer code không cần sửa gì.
Không nên dùng cả hai cùng lúc cho cùng outbox table, sẽ gây duplicate event vì cả polling và CDC đều đọc cùng data.
Monitoring outbox lag
Outbox lag, khoảng cách thời gian giữa lúc event được tạo trong database và lúc nó được publish lên broker, là metric quan trọng nhất cần monitor khi triển khai outbox pattern.
Với polling publisher, lag tối thiểu bằng poll interval (ví dụ 2 giây), tối đa bằng poll interval cộng thời gian publish batch. Nếu lag tăng bất thường, ví dụ poll interval 2 giây nhưng lag đo được 30 giây, nghĩa là publisher không kịp xử lý, có thể do broker chậm, batch quá lớn, hoặc publisher bị stuck.
Cách đo đơn giản nhất: query MAX(NOW() - created_at) trên các record có published_at IS NULL. Nếu giá trị này vượt ngưỡng (ví dụ 10 giây), alert.
SELECT
COUNT(*) as pending_count,
MAX(NOW() - created_at) as max_lag,
AVG(NOW() - created_at) as avg_lag
FROM outbox_events
WHERE published_at IS NULL;
Với CDC, lag đo ở connector level, Debezium expose metric MilliSecondsBehindSource cho biết connector đang trễ bao lâu so với WAL hiện tại. JMX metrics hoặc Prometheus endpoint của Kafka Connect cho giá trị này. Alert khi lag vượt ngưỡng.
Ngoài lag, monitor thêm số record pending trong outbox. Nếu con số này tăng liên tục mà không giảm, publisher có vấn đề. Đặt dashboard show cả pending count, max lag, và publish throughput (events/second), ba con số này đủ để chẩn đoán hầu hết vấn đề outbox.
Anti-patterns
Business logic trong outbox publisher
Publisher chỉ nên làm một việc: đọc outbox record, publish lên broker, đánh dấu done. Không filter event, không transform payload, không gọi API khác, không chạy business rule. Mọi business logic thuộc về consumer. Nếu publisher chứa logic, nó trở thành single point of failure cho business flow, publisher crash nghĩa là business logic không chạy, dù event đã sẵn sàng.
Một ngoại lệ nhỏ: enrichment metadata không liên quan business, thêm correlation ID, thêm timestamp, thêm source service name, có thể chấp nhận trong publisher hoặc transform layer. Nhưng bất kỳ thứ gì cần query database hoặc gọi service khác thì không nên nằm trong publisher.
Outbox table quá lớn không cleanup
Đã nói ở phần trên nhưng nhấn mạnh lại vì đây là anti-pattern phổ biến nhất. Team setup outbox, mọi thứ chạy tốt, không ai nhớ cleanup. Sáu tháng sau outbox table có 200 triệu record, autovacuum PostgreSQL chạy hàng giờ, table bloat, index bloat, query chậm dần. Lúc đó cleanup gấp lại khó vì DELETE lớn lock bảng lâu, ảnh hưởng production.
Setup cleanup job từ ngày đầu, ngay khi tạo outbox table. Đừng đợi đến khi thấy vấn đề.
Payload quá lớn
Một số team nhét toàn bộ aggregate state vào outbox payload, order với 500 line items, customer profile với lịch sử mua hàng, file attachment base64 encoded. Outbox table trở thành database thứ hai, mỗi INSERT tốn IO lớn, publisher đọc batch chậm, network ra broker nặng.
Giữ payload nhỏ gọn (vài KB), reference data lớn qua URL hoặc ID, để consumer tự fetch khi cần.
Không monitor outbox lag
Outbox hoạt động âm thầm, không có user nhìn thấy trực tiếp. Nếu publisher chết hoặc kẹt, event tích luỹ trong outbox nhưng không ai biết cho đến khi downstream service phàn nàn “không nhận được event từ 2 giờ trước”. Lúc đó đã có hàng nghìn event pending, downstream state inconsistent, recovery phức tạp.
Dashboard và alert cho outbox lag không phải optional, nó là phần bắt buộc của outbox deployment, cũng như monitoring database connection pool hay API error rate.
Outbox trong event sourcing và CQRS
Trong event sourcing, mỗi state change đã được ghi dưới dạng event trong event store. Câu hỏi đặt ra: có cần outbox table nữa không, hay publish trực tiếp từ event store?
Nếu event store là database riêng (PostgreSQL, EventStoreDB) và broker là hệ thống riêng (Kafka), bài toán dual write vẫn tồn tại, ghi event store và publish broker vẫn là hai operation trên hai hệ thống. Outbox pattern vẫn áp dụng: ghi event vào event store (đóng vai trò cả business data lẫn outbox), publisher đọc event store và publish ra broker.
Với EventStoreDB, nó có subscription mechanism built-in, client subscribe vào stream, nhận event mới khi có. Đây là dạng polling/push hybrid và có thể dùng thay outbox publisher riêng. Nhưng nếu cần publish sang Kafka cho consumer ecosystem rộng hơn, vẫn cần bridge, và bridge đó chính là outbox publisher hoặc CDC.
Trong CQRS, command side ghi event qua outbox, query side (read model) là consumer cập nhật materialized view từ event. Outbox đảm bảo event không mất giữa command side và query side. Read model có thể rebuild bất kỳ lúc nào bằng cách replay toàn bộ event từ broker, miễn là event đã nằm trên broker, đó là nhiệm vụ của outbox.
Khi nào không cần outbox
Outbox pattern không phải silver bullet, có những trường hợp nó overkill hoặc không cần thiết.
Nếu business operation chỉ ghi database mà không cần notify service khác, không cần outbox. Không phải mọi write đều cần event. Chỉ tạo event cho state change mà downstream service thực sự quan tâm.
Nếu mất event không gây hậu quả nghiêm trọng, ví dụ notification “bạn có đơn hàng mới” mà mất thì user refresh trang vẫn thấy, có thể publish trực tiếp (fire-and-forget) mà không cần outbox. Đánh giá business impact trước khi thêm complexity.
Nếu dùng database có event streaming built-in, ví dụ Kafka vừa làm storage vừa làm broker (Kafka Streams + compacted topic), hoặc database có change stream tích hợp (MongoDB Change Streams, CockroachDB Changefeeds), thì có thể không cần outbox table riêng, vì database đã đảm bảo atomicity giữa write và event.
Outbox pattern giải một bài toán cụ thể và giải rất gọn: ghi event vào cùng transaction với business data, publish sau, không bao giờ mất event kể cả khi broker sập. Trade-off chính không nằm ở kỹ thuật mà ở vận hành, cleanup định kỳ, monitoring lag, idempotency phía consumer phải thiết kế từ ngày đầu, vì vá sau khi có incident thì đã muộn.