Đang tải nội dung…
Đang tải nội dung…
Một payment được giao lại, hai lần ghi sổ và ranh giới thật sự của exactly-once
Kafka Streams, CDC & Event Processing · Phần 1Bối cảnh Fortex là hư cấu. Log, số liệu và kết quả bên dưới đến từ Docker Lab có source và report đính kèm; chúng không phải dữ liệu production của một công ty có thật.
08:17 sáng, Hà đặt laptop xuống bàn Ben. Một merchant vừa đối soát và thấy cùng payment payment-401 xuất hiện hai lần, mỗi lần 590.000 đồng. Kafka chỉ có một record.
“Em commit offset sau khi ghi database rồi,” Ben nói. Lập luận nghe rất ổn: xử lý thành công trước, xác nhận sau, vậy làm sao mất dữ liệu?
Tom không phản bác. Anh kéo ghế ngồi cạnh Ben và hỏi: “Giữa hai việc đó có bao nhiêu dòng code?”
Chỉ vài dòng. Nhưng giữa COMMIT của PostgreSQL và offset commit của Kafka là một khe hở. Process chết trong khe ấy thì tiền đã được ghi, còn broker vẫn cho rằng message chưa xử lý. Consumer mới nhận lại cùng record và chạy business operation lần nữa.
Kafka mặc định cho consumer một cơ chế at-least-once khi offset chỉ được commit sau xử lý: record có thể được giao lại, nhưng không bị bỏ qua vì consumer đã xác nhận quá sớm. Tài liệu thiết kế Kafka mô tả exactly-once của Kafka bằng cách giao dịch hóa output records và consumer offsets. Ranh giới đó rất mạnh khi input và output đều ở Kafka.
Nó không tự biến câu INSERT ở PostgreSQL, cuộc gọi cổng thanh toán, email hay HTTP request thành một phần của Kafka transaction.
Tom viết hai ranh giới lên bảng:
Điều Fortex cần là ranh giới thứ hai.
Ben đề nghị đổi thứ tự: commit offset trước, rồi ghi database. Tom hỏi tiếp: “Nếu chết sau offset commit thì sao?”
Lần này Ben tự thấy lỗi. Message sẽ không quay lại, nhưng sổ cái chưa có khoản tiền. Đổi thứ tự chỉ đổi duplicate thành data loss.
Với một effect ở database, mẫu đơn giản và chắc hơn là transactional inbox:
BEGIN;
INSERT INTO consumer_inbox(event_id)
VALUES ('payment-401')
ON CONFLICT DO NOTHING
RETURNING event_id;
-- Chỉ chạy khi INSERT phía trên thật sự tạo row.
INSERT INTO ledger(event_id, amount)
VALUES ('payment-401', 590000);
COMMIT;
Khóa duy nhất ở consumer_inbox.event_id là lời cam kết của database: trong cùng transaction, hoặc marker và ledger cùng tồn tại, hoặc không cái nào tồn tại. Offset vẫn commit sau DB transaction. Nếu crash, Kafka replay; lần thứ hai ON CONFLICT không trả row nên effect được bỏ qua.
Điều kiện quan trọng là producer phải phát một event ID ổn định qua retry. Nếu mỗi lần retry sinh UUID mới, consumer không thể biết hai message mô tả cùng một operation.
Lab dùng Kafka 4.2.2, PostgreSQL 17.6 và confluent-kafka 2.15.1. Một payment được consume, ghi ledger rồi process kết thúc mà chưa commit offset. Consumer cùng group chạy lại và nhận lại record đó.
| Kịch bản | Lần xử lý | Row ledger | Tổng tiền ghi |
|---|---|---|---|
| Ghi thẳng, không guard | 2 | 2 | 1.180.000 |
| Transactional inbox | 2 | 1 | 590.000 |
Điểm đáng nhìn không phải consumer chỉ chạy một lần. Nó đã chạy hai lần trong cả hai kịch bản. Sự đúng đắn nằm ở sink: bản ghi inbox và effect được commit cùng nhau.
c3ee7d90-0c64-47c5-a907-34f04b2db2f9Nếu toàn bộ pipeline chỉ đọc Kafka, biến đổi và ghi về Kafka, Kafka transactions cùng read_committed có thể atomically commit output và input offsets. Cách này giảm phần tự xây, nhưng external database vẫn nằm ngoài transaction đó.
Nếu effect có API idempotency key, hãy gửi event_id sang hệ thống đích và để chính nó lưu kết quả lần đầu. Nếu API không hỗ trợ idempotency, bạn có thể cần state machine, reconciliation hoặc một outbox phía effect owner. Không có một bảng inbox đặt ở service A nào ngăn được service B thực hiện side effect hai lần nếu B không hiểu cùng identity.
Inbox cũng có chi phí: index tăng dần, retention, cleanup và contention nếu key thiết kế tệ. Có thể partition theo thời gian và chỉ xóa khi chắc chắn message cũ không còn replay. TTL ngắn hơn retention/recovery horizon sẽ mở lại cửa duplicate.
Ben xóa dòng “exactly once” khỏi tên consumer. Cậu đổi nó thành idempotent-payment-handler.
Tom nhìn commit rồi gật đầu: “Tên mới ít kêu hơn. Nhưng đúng hơn.”
Chương tiếp theo bắt đầu từ một bài toán khác của Hà: Fortex phải copy hàng triệu product sang search index trong lúc merchant vẫn sửa giá. Nếu snapshot và WAL stream không gặp nhau đúng chỗ, một thay đổi có thể biến mất ngay giữa hai thế giới.
Apache Kafka 4.2.2 + PostgreSQL 17.6 + confluent-kafka 2.15.1 · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT 570ccb6b3b38b8121074bb56c07f3db638bf0961
Đọ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.