Đối chiếu mô hình: job bị xóa khi xong so với log được giữ lại
Mục tiêu bài học
- Phân biệt được vòng đời của một job trong BullMQ với vòng đời của một record trong Kafka.
- Giải thích được vì sao "job bị xóa khi xong" và "log được giữ lại" dẫn tới hai khả năng replay hoàn toàn khác nhau.
- Mô tả được hệ quả của hai mô hình lên thứ tự xử lý khi tăng concurrency.
- Xác định được khi nào cần fan-out cho nhiều hệ thống và vì sao BullMQ không làm được việc đó một cách tự nhiên.
- Nhận ra các cấu hình dễ gây sự cố:
removeOnCompletevàretention.
Ôn tập nhanh
Bài trước bạn đã nắm bốn khái niệm: topic là cái tên, partition là một log có thứ tự và là đơn vị song song hóa, offset là con trỏ vị trí bên trong partition, và consumer group chia nhau partition, mỗi group giữ offset riêng.
Còn BullMQ thì bạn đã biết: queue chứa job, worker lấy job ra xử lý. Hôm nay ta ghép hai bức tranh đó lại ở đúng một điểm: sau khi xử lý xong thì dữ liệu đi đâu.
Lý thuyết
BullMQ: job là một thực thể có vòng đời, xong là hết
Một job trong BullMQ đi qua các trạng thái: waiting → active → completed hoặc failed. Khi worker báo xong, job không còn được giao cho bất kỳ worker nào nữa. Nó nằm trong completed set cho tới khi bạn cấu hình removeOnComplete để dọn.
Điểm cốt lõi: BullMQ được thiết kế để dọn sạch. Redis là bộ nhớ, để job tồn tại mãi là tự bắn vào chân mình.
await queue.add('gui-email', { orderId: 42 }, {
removeOnComplete: { count: 1000 }, // giữ 1000 job gần nhất
removeOnFail: { count: 5000 },
});
Kafka: record được append, không ai xóa khi đọc
Kafka ghi record vào cuối partition. Consumer đọc xong chỉ tiến offset, record vẫn nằm nguyên trong log. Dữ liệu biến mất theo retention (retention.ms, retention.bytes) hoặc theo cleanup.policy=compact (giữ lại bản ghi mới nhất theo key).
Nói cách khác: consumer không phải "người sở hữu" dữ liệu, consumer chỉ là "người đang đọc tới đâu".
flowchart LR
subgraph bmq["BullMQ"]
Q["📥<br/>Queue"] --> W["👷<br/>Worker"]
W --> D["✅<br/>Job xong, không giao lại"]
end
subgraph kfk["Kafka"]
P["📜<br/>Partition append-only"] --> C["👥<br/>Consumer group"]
C --> O["📍<br/>Offset tiến, record vẫn còn"]
end
Bốn hệ quả thực tế
| Khía cạnh | BullMQ (job bị xóa khi xong) | Kafka (log được giữ lại) |
|---|---|---|
| Replay | Không có. Muốn chạy lại phải enqueue job mới | Có. Reset offset của consumer group là xong |
| Thứ tự | Không đảm bảo khi concurrency > 1 | Đảm bảo trong một partition |
| Fan-out | Một job chỉ tới một worker. Muốn nhiều bên nhận thì tạo nhiều queue | Nhiều consumer group trên cùng topic, mỗi group nhận đủ bản sao |
| Tăng tốc | Thêm worker | Thêm consumer, tối đa bằng số partition |
Replay là hệ quả lớn nhất. Với BullMQ, khi job đã xong và bị xóa thì sự thật duy nhất còn lại là database của bạn. Với Kafka, log chính là sự thật, và bạn có thể cho một hệ thống mới đọc lại từ offset 0 để dựng lại dữ liệu.
Thứ tự là cạm bẫy thứ hai. Kafka gắn thứ tự với partition, nên nếu bạn dùng orderId làm key, mọi event của cùng một đơn hàng rơi vào cùng partition và được xử lý theo đúng thứ tự. BullMQ không có khái niệm tương đương: hai job cho cùng một đơn hàng có thể chạy song song và hoàn tất ngược thứ tự.
Fan-out là khác biệt về mô hình, không phải về cấu hình. Trong Kafka, thêm một consumer group là thêm một "người đọc độc lập" mà không ảnh hưởng group khác. Trong BullMQ, bạn phải tạo thêm queue và enqueue thêm job.
Ví dụ từng bước
Ví dụ 1: Gửi email xác nhận đơn hàng
BullMQ:
- API enqueue job
gui-emailvới{ orderId: 42 }. - Worker lấy job, gọi dịch vụ email, thành công.
- Ba ngày sau, sếp hỏi "khách 42 có được gửi email không?". Bạn chỉ còn cách tra database hoặc log ứng dụng, vì job đã bị xóa.
Kafka:
- API produce record
order.createdvới key42vào topicorders. - Consumer group
email-serviceđọc, gửi email, commit offset. - Ba ngày sau, vẫn có thể tạo consumer group
auditđọc lại topic từ đầu để kiểm chứng. Record chưa bị xóa vì retention là 7 ngày.
Ví dụ 2: Thứ tự create rồi cancel
Hai event: order.created rồi order.cancelled cho đơn 42.
Kafka: cùng key 42 → cùng partition → consumer xử lý tuần tự đúng thứ tự. An toàn.
BullMQ: hai job, concurrency = 5. Worker A lấy cancel, worker B lấy create. cancel xong trước, create ghi đè trạng thái thành "đang xử lý". Đơn hàng bị "sống lại". Cách chữa: đặt concurrency = 1 cho queue đó, hoặc dùng BullMQ Flow / group để gom job theo orderId.
Ví dụ 3: Một event, ba hệ thống cần biết
Yêu cầu: order.created phải tới email, analytics và fraud-check.
Kafka: một topic, ba consumer group. Mỗi group nhận đủ bản sao, độc lập tốc độ và độc lập lỗi.
BullMQ: phải tạo ba queue (email, analytics, fraud) và enqueue ba job, hoặc dùng một queue với ba loại job nhưng mỗi job vẫn chỉ đi tới một worker. Không có chuyện "một job, nhiều worker cùng nhận".
Thảo luận và suy ngẫm
- Hệ thống của bạn có tình huống nào cần replay không? Nếu có, dữ liệu để replay đang nằm ở đâu: trong log, hay trong database?
- Với queue BullMQ hiện tại, điều gì xảy ra nếu bạn set
removeOnComplete: truecho mọi job? Debug sự cố sẽ khó hơn ở điểm nào? - Kafka giữ log 7 ngày. Nếu bạn cần điều tra sự cố của 30 ngày trước, kiến trúc cần thay đổi ra sao?
Góc chuyên gia
[!WARNING] Hai lỗi kinh điển khiến hệ thống production sập. Thứ nhất, quên cấu hình
removeOnCompletetrong BullMQ, completed set phình dần tới khi Redis hết RAM. Thứ hai, đặtretention.msquá ngắn trong Kafka, tới lúc cần replay thì log đã bị xóa từ lâu.
- Đừng nhầm "đã xử lý" với "đã lưu". BullMQ xóa job là chuyện bình thường, nhưng nghĩa là bạn phải tự lưu dấu vết nghiệp vụ ở nơi khác.
- Nhớ rằng BullMQ mặc định không xóa job completed ngay. Nó vẫn nằm trong set. Đây là nguồn rò rỉ bộ nhớ âm thầm, không báo lỗi.
- Reset offset là thao tác phá hoại nếu thiếu idempotency. Replay 1 triệu record nghĩa là 1 triệu lần gửi email có thể xảy ra. Đây là cầu nối sang module sau.
- Số consumer hữu ích tối đa bằng số partition. Thêm consumer thứ 7 vào topic 3 partition thì 4 consumer ngồi không.
- Muốn nhiều bên cùng nhận một sự kiện, hãy nghĩ tới Kafka trước. Với BullMQ, bạn đang tự xây một hệ fan-out bằng tay.
Tóm tắt và bước tiếp theo
Hai mô hình khác nhau ở chỗ dữ liệu được xử lý xong thì đi đâu. BullMQ coi job là một nhiệm vụ ngắn hạn: xong thì biến mất, đổi lại là hàng đợi nhẹ và dễ vận hành. Kafka coi record là lịch sử: cứ nằm đó tới khi hết retention, đổi lại là khả năng replay, thứ tự theo partition và fan-out tự nhiên.
Ba câu cần nhớ: BullMQ không replay được, Kafka replay được. BullMQ không đảm bảo thứ tự khi concurrency > 1, Kafka đảm bảo trong một partition. BullMQ một job một worker, Kafka một record nhiều consumer group.
Bài tiếp theo là checkpoint của module, nơi bạn sẽ tự kiểm tra lại toàn bộ topic, partition, offset, consumer group và đối chiếu với queue, worker, job trước khi bước sang module về độ tin cậy, retry và dead letter queue.
Trong BullMQ, sau khi job hoàn tất thì job đó không còn được giao lại cho worker nào, và bạn dọn nó bằng cấu hình {{c1::removeOnComplete}}.
Trong Kafka, consumer đọc xong chỉ tiến {{c2::offset}}, còn record vẫn nằm trong log cho tới khi hết {{c3::retention}}.
Muốn nhiều hệ thống cùng nhận trọn vẹn một event trong Kafka, ta tạo nhiều {{c4::consumer group}} trên cùng một topic.
Số consumer hữu ích tối đa trong một consumer group bằng {{c5::số partition}} của topic đó.
[
{ "front": "Trong BullMQ, chuyện gì xảy ra với một job sau khi worker xử lý xong?", "back": "Job không còn được giao lại cho worker nào nữa, nó nằm trong completed set cho tới khi bị dọn theo removeOnComplete." },
{ "front": "Trong Kafka, record có bị xóa khi consumer đã đọc không?", "back": "Không. Consumer chỉ tiến offset, record vẫn nằm trong log cho tới khi hết retention." },
{ "front": "Cấu hình nào của BullMQ quyết định việc dọn job đã hoàn tất?", "back": "removeOnComplete, thường đặt dạng { count: N } để giữ lại N job gần nhất." },
{ "front": "Cấu hình nào của Kafka quyết định record bị xóa sau bao lâu?", "back": "retention.ms hoặc retention.bytes, ngoài ra còn cleanup.policy=compact để giữ bản ghi mới nhất theo key." },
{ "front": "Vì sao BullMQ không replay được còn Kafka thì được?", "back": "Vì BullMQ xóa hoặc kết thúc vòng đời job khi xong, còn Kafka giữ log nên có thể reset offset để đọc lại." },
{ "front": "Muốn chạy lại một job đã hoàn tất trong BullMQ thì phải làm gì?", "back": "Phải enqueue một job mới, không có cơ chế replay từ dữ liệu cũ." },
{ "front": "Thứ tự xử lý trong Kafka được đảm bảo ở phạm vi nào?", "back": "Trong một partition. Dùng cùng key cho các event liên quan để chúng rơi vào cùng partition." },
{ "front": "Vì sao BullMQ có thể xử lý sai thứ tự create rồi cancel của cùng một đơn hàng?", "back": "Vì concurrency lớn hơn 1 khiến nhiều job chạy song song, job sau có thể xong trước job trước." },
{ "front": "Cách xử lý thứ tự cho cùng một thực thể trong BullMQ?", "back": "Đặt concurrency = 1 cho queue đó, hoặc gom job theo thực thể bằng BullMQ Flow / group." },
{ "front": "Một record trong Kafka được bao nhiêu consumer group nhận?", "back": "Mỗi consumer group nhận trọn vẹn một bản sao, các group độc lập với nhau." },
{ "front": "Trong một consumer group, mỗi partition được giao cho bao nhiêu consumer?", "back": "Đúng một consumer tại một thời điểm." },
{ "front": "Muốn một sự kiện tới ba hệ thống khác nhau bằng BullMQ thì phải làm gì?", "back": "Tạo ba queue và enqueue ba job, vì mỗi job BullMQ chỉ đi tới một worker." },
{ "front": "Số consumer hữu ích tối đa trong một consumer group là bao nhiêu?", "back": "Bằng số partition của topic, thêm nữa thì consumer thừa sẽ đứng không." },
{ "front": "Lỗi vận hành phổ biến khi không cấu hình removeOnComplete trong BullMQ?", "back": "Completed set phình dần và làm Redis cạn RAM." }
]