At-least-once, exactly-once và idempotency trong thực tế
Mục tiêu học tập
- Phân biệt được at-most-once, at-least-once và exactly-once qua hành vi thực tế của consumer.
- Giải thích được vì sao at-least-once là mặc định thực dụng của cả BullMQ lẫn Kafka.
- Nêu được điều kiện để Kafka đạt exactly-once và giới hạn của nó.
- Viết được consumer idempotent với dedup key cho cả hai công cụ.
Ôn nhanh (5 phút)
Bài trước đã chốt một khác biệt: BullMQ xóa job khi xong, còn Kafka giữ log. Kéo theo đó, worker BullMQ báo completed để job biến mất, còn consumer Kafka commit offset để đánh dấu tiến độ đọc.
Chính khoảng thời gian giữa "đã làm xong việc" và "đã báo xong" là nơi sinh ra toàn bộ vấn đề của bài này.
Lý thuyết
Ba mức đảm bảo
| Mức | Cách làm | Đánh đổi |
|---|---|---|
| at-most-once | Ack/commit trước khi xử lý | Nhanh, crash là mất việc |
| at-least-once | Xử lý xong mới ack/commit | Không bao giờ mất, nhưng có thể trùng |
| exactly-once | Làm đúng một lần về mặt kết quả | Đắt, và có điều kiện |
BullMQ mặc định là {{c1::at-least-once}} vì job chỉ bị xóa sau khi worker xử lý thành công.
Vì sao at-least-once là mặc định thực dụng
Bài toán hai vị tướng: bạn không thể vừa chắc chắn việc đã xong, vừa chắc chắn thông báo đã tới nơi. Phải chọn mất hay chọn trùng. Với hầu hết nghiệp vụ, trùng thì sửa được, mất thì không. Nên mặc định là at-least-once.
Cửa sổ sinh trùng trông như thế này: worker hoàn thành việc, rồi crash trước khi ghi nhận. Broker thấy "chưa ack", giao lại job. Bạn làm hai lần.
Trong Kafka, consumer {{c2::commit offset}} sau khi xử lý xong; nếu crash trước bước đó, record sẽ được giao lại.
Exactly-once: có thật, nhưng có điều kiện
Kafka đạt exactly-once trong luồng read-process-write nhờ idempotent producer cộng transaction: ghi output và commit offset trong cùng một transaction. Nếu transaction abort, cả hai cùng biến mất.
Điều kiện khá chặt: input và output đều phải là Kafka, và bạn dùng Kafka Streams hoặc transactional producer.
[!WARNING] Exactly-once của Kafka không bao trùm side effect ra ngoài Kafka. Gửi email, gọi API ngân hàng, ghi Postgres, những thứ đó nằm ngoài transaction. Nói "chúng tôi dùng exactly-once" mà vẫn gửi email hai lần là chuyện rất thường gặp.
Exactly-once của Kafka chỉ đúng trong phạm vi {{c3::read-process-write}} giữa các topic, không bao gồm {{c4::side effect}} ra hệ thống ngoài.
Idempotency: chốt chặn cuối cùng
Một thao tác là idempotent nếu chạy nhiều lần cho cùng kết quả như chạy một lần. Consumer idempotent là consumer mà khi nhận trùng thì kết quả cuối không đổi.
Bốn kỹ thuật, xếp theo mức độ dùng thực tế:
- Idempotency key (dedup key): mỗi message mang một id ổn định. Bảng
processed_events(event_id UNIQUE)là mẫu kinh điển. Chèn được thì xử lý, lỗi unique thì bỏ qua. - Upsert thay vì insert:
INSERT ... ON CONFLICT DO UPDATE, hoặcSET trạng thái = 'đã gửi'thay vìSET counter = counter + 1. - State-based: ghi trạng thái cuối thay vì delta. "Đơn hàng đã shipped" repeat được; "tăng shipped_count lên 1" thì không.
- Key tự nhiên: với BullMQ, dùng
jobIdlà mã đơn hàng để queue tự chặn job trùng.
Mẫu dedup kinh điển là bảng có cột {{c5::event_id}} với ràng buộc {{c6::UNIQUE}}.
Ví dụ mẫu từng bước
Ví dụ 1: Gửi email xác nhận bằng BullMQ
Kịch bản: worker nhận job sendEmail, gọi API mail mất 2 giây, rồi crash ngay trước khi BullMQ đánh dấu completed. Job được đưa lại. Khách nhận hai email.
Các bước sửa:
- Tạo bảng
sent_emails(order_id TEXT PRIMARY KEY, sent_at TIMESTAMPTZ). - Trong worker,
INSERT INTO sent_emails ...trước khi gọi API. Lỗi unique nghĩa là đã gửi, return luôn và coi như thành công. - Khi
queue.add, đặtjobId: 'order-' + orderIdđể BullMQ chặn job trùng ngay ở tầng queue.
Điểm mấu chốt: bước 2 bảo vệ trước cả retry lẫn duplicate do crash, bước 3 chỉ chặn trùng lúc thêm job. Cần cả hai.
Ví dụ 2: Kafka consumer xử lý thanh toán
Bối cảnh: consumer group đọc topic payments, ghi vào bảng ledger, rồi commit offset. Một rebalance xảy ra sau khi ghi ledger nhưng trước khi commit. Partition được giao cho consumer khác, đọc lại từ offset cũ. Kết quả: ghi ledger hai lần, tiền bị trừ hai lần.
Cách sửa:
- Dedup: bảng
ledger(payment_id TEXT PRIMARY KEY, amount BIGINT), ghi bằngINSERT ... ON CONFLICT (payment_id) DO NOTHING. - Tốt hơn nữa: dùng transaction của Kafka trong read-process-write, ghi output và commit offset trong cùng transaction. Cách này chỉ khả thi khi output cũng là Kafka, còn ghi Postgres thì vẫn phải làm bước 1.
- Đặt
enable.auto.commit = falsevà commit sau khi xử lý xong. Không bao giờ commit trước.
Ví dụ 3: Khác nhau một chữ
Giảm tồn kho khi có đơn hàng:
-- Non-idempotent, chạy lại là trừ thêm
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A';
-- Idempotent, chạy lại không đổi kết quả
INSERT INTO inventory_deduction(order_id, sku)
VALUES ('o-123', 'A')
ON CONFLICT (order_id) DO NOTHING;
Sau đó tính tồn kho bằng số nhập - count(deduction). Phép trừ biến thành phép ghi có khoá, nên repeat an toàn.
Thao tác counter = counter + 1 là {{c7::non-idempotent}}; thao tác SET trạng thái = 'đã gửi' là idempotent.
Thảo luận & suy ngẫm
- Trong hệ thống bạn đang làm, consumer nào an toàn khi chạy lại, consumer nào không?
- Nếu buộc phải chọn, bạn chấp nhận trùng ở luồng nào và không chấp nhận ở luồng nào?
- Dedup key nên lấy từ id do producer sinh hay từ id nghiệp vụ? Mỗi lựa chọn hỏng ở tình huống nào?
Expert Corner
- Cạm bẫy 1: nhầm exactly-once của Kafka thành "không bao giờ trùng". Nó chỉ đúng trong biên của Kafka.
- Cạm bẫy 2: dedup bằng cache in-memory. Restart là mất dấu. Dùng nơi lưu bền, và TTL phải dài hơn cửa sổ retry.
- Cạm bẫy 3: bảng dedup phình vô hạn. Cần job dọn dẹp theo cửa sổ thời gian, ví dụ partition theo ngày và xóa sau 30 ngày.
- Cạm bẫy 4: ack/commit sớm cho nhanh. Đó chính là cách biến at-least-once thành at-most-once mà không ai nhận ra.
- Quy tắc ngón tay cái: giả định mọi message sẽ tới ít nhất hai lần. Viết consumer theo giả định đó thì at-least-once trở nên vô hại.
Tóm tắt & bước tiếp theo
- at-least-once là mặc định thực dụng; exactly-once đạt được trong read-process-write của Kafka và không bao phủ side effect ngoài.
- Vì vậy idempotency ở tầng consumer là bắt buộc, không phải tuỳ chọn.
- Bốn kỹ thuật: idempotency key, upsert, state-based, key tự nhiên.
Bài tiếp theo sẽ đi vào retry, backoff và dead letter queue: khi message cứ lỗi mãi, bạn cần biết lúc nào dừng retry, đẩy vào DLQ và làm sao phát hiện DLQ đang đầy.
[
{ "front": "At-least-once nghĩa là gì?", "back": "Xử lý xong mới ack/commit, nên có thể trùng nhưng không mất việc." },
{ "front": "At-most-once nghĩa là gì?", "back": "Ack/commit trước khi xử lý; crash là mất job, đổi lại không bao giờ trùng." },
{ "front": "Thứ tự đúng để tránh at-most-once trong BullMQ là gì?", "back": "Xử lý job xong rồi mới để worker báo completed, không ack trước." },
{ "front": "Kafka consumer nên commit offset khi nào?", "back": "Sau khi xử lý record thành công, và phải tắt auto commit." },
{ "front": "Exactly-once của Kafka đạt được nhờ đâu?", "back": "Idempotent producer cộng transaction, ghi output và commit offset trong cùng một transaction." },
{ "front": "Giới hạn lớn nhất của exactly-once trong Kafka là gì?", "back": "Nó không bao phủ side effect ra ngoài Kafka như email, API, database." },
{ "front": "Idempotency là gì?", "back": "Chạy cùng một thao tác nhiều lần cho kết quả giống như chạy một lần." },
{ "front": "Mẫu dedup kinh điển gồm bảng gì?", "back": "Bảng processed_events với cột event_id có ràng buộc UNIQUE." },
{ "front": "Trong BullMQ, dùng gì để chặn job trùng ngay ở tầng queue?", "back": "Đặt jobId ổn định, ví dụ theo mã đơn hàng, khi gọi queue.add." },
{ "front": "Vì sao SET counter = counter + 1 gây hại khi retry?", "back": "Đó là phép cộng dồn, chạy lại là cộng thêm, không idempotent." },
{ "front": "Cách viết idempotent cho thao tác trừ tồn kho?", "back": "Ghi một bản ghi deduction có khoá duy nhất, rồi tính tồn kho từ số nhập trừ số bản ghi." },
{ "front": "Vì sao không nên dedup bằng cache in-memory?", "back": "Restart là mất dấu, message trùng sẽ lọt qua; cần nơi lưu bền." },
{ "front": "Quy tắc ngón tay cái khi viết consumer là gì?", "back": "Giả định mọi message sẽ tới ít nhất hai lần và viết consumer an toàn với điều đó." }
]