Idempotent Consumer trong Event-Driven Production: chống duplicate processing mà không tự lừa mình bằng “exactly-once”

Nhiều team chỉ thật sự quan tâm đến duplicate processing sau khi đã trả giá: đơn hàng bị trừ kho hai lần, email gửi lặp, projection nhảy số sai, job replay làm bể dashboard, hoặc consumer restart xong “ăn lại” một loạt event cũ. Vấn đề là phần lớn hệ thống message-driven ngoài đời không sống trong thế giới sạch sẽ của “mỗi message chỉ xử lý đúng một lần”. Chúng sống với at-least-once delivery, retry, redelivery, replay, rebalance, timeout, network split và consumer crash.
Đó là lý do idempotent consumer không phải một pattern phụ. Nó là một lớp correctness bắt buộc nếu consumer của anh có side effect lên database, cache, search index, inventory, billing hay bất kỳ state nào quan trọng.
Bài này đi thẳng vào góc production: duplicate đến từ đâu, vì sao “Kafka exactly-once” không cứu toàn bộ business side effect, các chiến lược idempotency phổ biến, trade-off giữa bảng dedupe và embedded key, thời gian giữ dedupe window, observability, anti-pattern, và cách rollout để không biến idempotency thành một lớp check hình thức.
Duplicate processing đến từ đâu nếu producer chỉ publish một lần?

Ngay cả khi producer tương đối tử tế, duplicate vẫn có thể xuất hiện ở nhiều điểm:
- broker giao lại message vì consumer xử lý xong nhưng chưa ack/commit offset kịp;
- consumer crash sau khi ghi DB nhưng trước khi ack;
- retry ở consumer framework hoặc queue worker;
- replay lịch sử để rebuild projection;
- rebalance khiến partition được consumer khác nhận lại;
- upstream thực tế đã publish cùng business event nhiều hơn một lần;
- outbox relay hoặc CDC pipeline re-emit cùng logical event khi recovery.
Đây là bản chất của nhiều hệ thống queue/stream thực dụng. AWS SQS standard queue nói rõ mô hình at-least-once delivery và khuyên consumer phải idempotent. Chris Richardson trong microservices.io cũng mô tả chính failure mode kinh điển: database transaction đã commit nhưng message acknowledgement thất bại, khiến cùng message được giao lại.
Vì vậy, câu hỏi đúng không phải là “duplicate có thể xảy ra không?” mà là “khi duplicate xảy ra, business state của mình có còn đúng không?”
“Exactly-once” thường bị hiểu quá rộng
Đây là một trong những chỗ tôi thấy team tự tin sai nhiều nhất.
Một số nền tảng như Kafka có thể hỗ trợ exactly-once semantics trong một phạm vi nhất định. Nhưng điều đó không tự động có nghĩa mọi side effect business của consumer sẽ chỉ xảy ra đúng một lần. Nếu consumer đọc message rồi update PostgreSQL, Redis, Elasticsearch hoặc gọi service khác, correctness vẫn phụ thuộc vào cách anh ràng buộc side effect đó với message identity.
Nói ngắn gọn:
- broker-level semantics nói về chuyện message được lưu/chuyển ra sao;
- application-level idempotency nói về chuyện business state có bị cộng dồn sai khi cùng message chạy lại hay không.
Nếu còn cập nhật state ngoài broker, anh vẫn phải thiết kế consumer theo hướng idempotent.
Idempotent consumer thực chất là gì?
Một consumer là idempotent khi:
> xử lý cùng một message nhiều lần cho ra cùng trạng thái cuối cùng như xử lý nó đúng một lần.
Lưu ý: idempotent không đồng nghĩa với “không làm gì lần thứ hai”. Nó có thể:
- nhận ra duplicate và bỏ qua;
- ghi đè cùng giá trị final state;
- thực hiện upsert an toàn;
- reject side effect cũ nhờ ràng buộc key duy nhất;
- dùng compare-and-set theo event version hoặc sequence.
Điều quan trọng là state cuối không bị lệch.
Phân biệt naturally idempotent và accidentally idempotent
Có consumer vốn gần như idempotent tự nhiên.
Ví dụ event AccountSnapshotUpdated mang theo current_balance = 12_500_000. Consumer chỉ việc ghi đè projection thành đúng giá trị đó. Nếu xử lý lại event này 5 lần, projection vẫn như nhau.
Nhưng rất nhiều consumer lại không tự nhiên idempotent:
-
AccountDebited(amount=500_000)rồi consumer lấy số dư hiện tại trừ tiếp; -
InventoryReserved(quantity=2)rồi trừ kho thêm 2; -
LoyaltyPointEarned(points=100)rồi cộng dồn; -
EmailRequestedrồi gọi provider gửi thư; -
InvoiceGeneratedrồi tạo record thanh toán mới.
Những flow này nếu chạy lặp sẽ nhân side effect. Nhiều team tưởng mình idempotent chỉ vì “message ID nhìn có vẻ duy nhất”, nhưng thực tế code không hề dùng ID đó để chặn duplicate ở lớp state transition.
Chiến lược 1: bảng dedupe / processed_messages

Đây là cách dễ hiểu và đáng tin cậy nhất cho nhiều hệ thống.
Ý tưởng:
- mỗi message có
message_idhoặcevent_idổn định; - trong cùng transaction business, consumer ghi một record vào bảng như
processed_messages(subscriber_id, message_id); - nếu insert đụng unique constraint, coi như duplicate và bỏ qua side effect.
Ví dụ logic:
- begin transaction;
- insert
(subscriber_id, message_id); - nếu fail vì duplicate -> rollback phần side effect hoặc short-circuit an toàn;
- nếu thành công -> update business state;
- commit;
- ack message.
Ưu điểm:
- rõ ràng, audit được;
- tách biệt khỏi schema business;
- dễ áp dụng cho nhiều loại consumer;
- mạnh khi consumer cập nhật nhiều bảng.
Nhược điểm:
- thêm một bảng nóng dễ contention nếu throughput cao;
- cần chiến lược TTL/cleanup;
- phải cực kỳ cẩn thận để insert dedupe và business write nằm trong cùng transaction correctness.
Nếu insert dedupe ở transaction khác hoặc sau khi write business state, anh đã tạo race cho duplicate lọt qua.
Chiến lược 2: nhúng event identity vào chính business entity
Thay vì có bảng riêng, một số flow lưu last_processed_event_id, last_applied_sequence, hoặc set các event đã apply ngay trong entity/projection.
Ví dụ:
- bảng
orders_projectionlưulast_event_version; - chỉ apply event mới nếu
incoming_version > last_event_version; - hoặc bảng
payment_attemptscó unique key theoprovider_event_id.
Cách này hợp khi:
- aggregate có sequence rõ ràng;
- consumer update một entity chính yếu;
- business state vốn đã cần lưu version/order.
Ưu điểm là ít cần bảng dedupe riêng và tận dụng luôn invariant của domain. Nhưng nó khó hơn nếu một message chạm nhiều record hoặc side effect phân tán.
Chiến lược 3: biến write thành upsert / set-final-state
Một số projection consumer không cần “cộng thêm”, mà chỉ cần “đưa state về giá trị đúng theo event”. Đây là kiểu naturally idempotent dễ sống nhất.
Ví dụ tốt:
-
user_profile_updatedchứa toàn bộ trường cần render search index; -
shipment_status_changedcập nhật status hiện tại thànhdeliveredkèm timestamp/version; -
account_snapshot_rebuiltghi đè snapshot.
Lúc này duplicate message chủ yếu chỉ tăng cost tính toán, chứ không phá correctness.
Nhưng phải cảnh giác: nhiều team tưởng đang “upsert”, trong khi thực tế vẫn có hidden side effect như cộng metrics, append lịch sử, gửi notification, hoặc bump counter. Nếu các side effect đó không được thiết kế lại, consumer vẫn chưa idempotent hoàn chỉnh.
Idempotency key nên lấy gì?
Một idempotency design tốt thường bắt đầu bằng câu hỏi này. Key tệ thì toàn bộ lớp bảo vệ sẽ mong manh.
Key nên:
- ổn định qua retry/redelivery;
- đại diện cho logical event, không phải lần giao message vật lý;
- phân biệt được subscriber/consumer semantics nếu cùng event được nhiều consumer xử lý;
- đủ nhỏ để index tốt nhưng đủ ý nghĩa để audit.
Các lựa chọn thường gặp:
-
event_idtừ outbox/CDC; -
(stream_id, sequence_number); -
provider_event_idtừ webhook/broker upstream; -
(subscriber_name, message_id)cho bảng dedupe dùng chung.
Đừng dùng những thứ không ổn định như processing timestamp, random retry token, hay offset thuần nếu replay/cross-topic có thể thay đổi meaning.
Dedupe window không thể giữ vô hạn một cách ngây thơ
Nhiều bài nói “cứ lưu processed_messages là xong”, nhưng production thật có thêm bài toán retention.
Nếu giữ mãi mọi message ID:
- bảng dedupe sẽ phình nhanh;
- index nóng dần;
- vacuum/maintenance tăng cost;
- partitioning/cleanup trở thành việc thật sự phải vận hành.
Nếu xóa quá sớm:
- replay muộn hoặc duplicate trễ có thể lọt qua;
- data backfill hoặc recovery job làm side effect chạy lại.
Window nên bám vào thực tế hệ thống:
- broker có thể redeliver muộn tối đa bao lâu;
- team có replay lịch sử theo ngày/tuần không;
- business effect nào cần chống duplicate vĩnh viễn, effect nào chỉ cần chống duplicate ngắn hạn.
Ví dụ:
- gửi email marketing có thể chấp nhận window ngắn hơn;
- charge payment, reserve inventory, ledger mutation thì window thường phải rất bảo thủ, thậm chí dựa vào unique business key lâu dài chứ không chỉ TTL message ID.
Idempotency không thay thế ordering và concurrency control
Đây là chỗ dễ nhầm với distributed locks, optimistic concurrency, hay sequence guard.
Idempotency chỉ giải bài toán cùng một logical event chạy lại. Nó không tự động giải:
- event đến sai thứ tự;
- hai event khác nhau cùng sửa một entity theo quy tắc xung đột;
- stale event ghi đè state mới hơn;
- race giữa nhiều consumer instance trên các message khác nhau.
Nếu projection của anh cần thứ tự, hãy lưu event_version hoặc sequence và từ chối event cũ. Nếu mutation cần độc quyền theo aggregate, có thể phải kết hợp với compare-and-set, unique constraint, hay pattern coordination khác. Đừng bắt idempotency làm việc của ordering.
Email, webhook, external API: side effect ngoài DB mới là chỗ đau
Consumer update một bảng SQL còn dễ. Khó hơn là khi side effect đi ra ngoài:
- gửi email;
- push notification;
- gọi payment provider;
- tạo ticket ở hệ khác;
- emit tiếp event downstream.
Nếu chỉ dedupe trong DB nhưng call ngoài transaction theo kiểu ngây thơ, anh vẫn có thể gặp:
- DB đã commit “đã xử lý” nhưng external call thất bại;
- hoặc external call thành công rồi process crash trước khi ghi marker;
- hoặc marker có rồi nhưng downstream chưa nhận đủ.
Lúc này cần tách rõ:
- consumer hiện tại đang chịu trách nhiệm correctness ở đâu;
- external side effect có idempotency key riêng không;
- có cần outbox tiếp theo để phát side effect downstream an toàn hơn không.
Ví dụ, khi gửi email từ event InvoiceIssued, consumer có thể lưu unique business action như (template, recipient, invoice_id) thay vì chỉ trông vào broker message ID. Nếu downstream provider hỗ trợ idempotency key, phải tận dụng nó.
Anti-pattern rất phổ biến: check-then-insert ngoài transaction
Pseudo code nguy hiểm:
-
SELECTxem message đã xử lý chưa; - chưa thấy thì update business state;
- sau đó mới
INSERTmarker processed.
Flow này có race kinh điển:
- hai consumer cùng lúc đều
SELECTthấy chưa có; - cả hai cùng apply side effect;
- một thằng insert marker thành công, thằng kia fail;
- nhưng business state đã bị nhân đôi.
Giải pháp là để database giúp mình bằng unique constraint + atomic transaction, không debug correctness bằng niềm tin.
Anti-pattern khác: tin rằng “exactly-once broker” là đủ nên bỏ dedupe
Đây là kiểu sai lầm đẹp trên slide nhưng đắt ở production.
Ngay cả khi broker đảm bảo mạnh ở lớp messaging, consumer vẫn có thể:
- update DB hai lần theo cùng logical event;
- gọi REST downstream hai lần;
- chạy replay job đụng event cũ;
- đổi consumer group/subscriber semantics làm key cũ vô nghĩa.
Nếu side effect business không tự nhiên idempotent, broker semantics không đủ.
Observability: đo gì để biết duplicate đang được chặn hay đang âm thầm lọt?

Tối thiểu tôi muốn có các tín hiệu sau:
-
consumer_duplicate_detected_totaltheo topic/subscriber; - tỷ lệ duplicate trên tổng message;
- số lần unique constraint reject ở bảng dedupe;
- latency xử lý duplicate vs non-duplicate;
- backlog/lag khi duplicate tăng đột biến;
- số side effect external bị retry do uncertain outcome;
- số event out-of-order bị reject nếu có sequence guard.
Ngoài ra, log nên giữ:
-
message_id/event_id; -
subscriber; - dedupe decision:
new,duplicate,stale,replayed; - business key liên quan như
order_id,payment_id,invoice_id.
Khi có incident, những field này giúp anh trả lời rất nhanh: duplicate đến từ broker redelivery, replay job, hay producer publish trùng.
Khi nào nên dùng bảng riêng, khi nào nên dùng business unique key?
Tôi thường chọn như sau:
Dùng bảng dedupe riêng khi
- một consumer chạm nhiều bảng;
- message semantics đa dạng;
- cần audit rõ message nào đã qua subscriber nào;
- chưa có natural unique business key đủ mạnh.
Dùng business unique key / version guard khi
- domain đã có identity rất rõ như
payment_provider_event_id,shipment_id,aggregate_version; - side effect thực tế là tạo đúng một record business;
- muốn để invariant nằm ngay trong domain model thay vì bảng phụ.
Nhiều hệ thống tốt dùng cả hai lớp: business unique key để chặn duplicate ở domain quan trọng, và processed table để quan sát cùng quản trị consumer behavior.
Rollout thực dụng cho hệ thống đang chạy
Đừng đập toàn bộ consumer cũ rồi hy vọng mọi thứ sẽ sạch ngay. Cách an toàn hơn:
- inventory các consumer có side effect không naturally idempotent;
- phân loại theo mức rủi ro: payment, inventory, ledger, email, projection, analytics;
- thêm message identity rõ ràng nếu event hiện tại chưa có;
- triển khai dedupe ở một consumer trước, đo duplicate hit rate;
- backfill observability và dashboard;
- chạy shadow/replay nhỏ để test duplicate path;
- chỉ sau đó mới mở rộng sang các consumer còn lại.
Nếu team chưa biết message nào là duplicate, thường không nên nhảy thẳng vào TTL tuning. Hãy đo thật trước.
Checklist production cho idempotent consumer
- message có identity ổn định theo logical event chưa?
- duplicate detection có nằm trong transaction correctness không?
- unique constraint nào đang thực thi invariant?
- side effect ngoài DB có idempotency key riêng chưa?
- replay job có đi qua cùng guardrail hay đang bypass?
- dedupe retention có khớp redelivery/replay window không?
- observability có phân biệt duplicate thật với out-of-order event không?
- đã test crash-after-commit-before-ack chưa?
Kết luận
Idempotent consumer không phải món trang trí cho kiến trúc event-driven. Nó là lớp bảo vệ để hệ thống sống tử tế trong một thế giới nơi duplicate delivery là chuyện bình thường, replay là chuyện tất yếu, còn “exactly-once” hiếm khi bao trọn hết side effect business.
Nếu phải rút gọn thành một nguyên tắc: đừng hỏi broker hứa gì trước, hãy hỏi business state của anh sẽ ra sao nếu cùng event chạy lại hai lần. Khi trả lời được câu đó bằng unique constraint, transaction đúng, idempotency key rõ và observability đủ tốt, anh mới thật sự có một consumer đáng tin ở production.