What is Message Queue?
hoanggg2110
Tác giả

Nếu bạn từng build một hệ thống mà có một tác vụ chạy chậm (gửi email, resize ảnh, tính toán nặng) làm cho cả API bị treo theo, thì gần như chắc chắn bạn sẽ được giới thiệu tới message queue như một "cây đũa thần". Nhưng queue không miễn phí — nó đánh đổi độ phức tạp lấy khả năng chịu tải, và phần đánh đổi đó chính là nơi hầu hết các bài blog "giới thiệu message queue" bỏ qua.
Bài này đi thẳng vào 5 câu hỏi mà bất kỳ ai dùng message queue trong production cũng sẽ đụng phải, từ cơ chế hoạt động bên dưới cho tới những failure mode kinh điển.
1. Message Queue là gì? Vì sao cần nó? Hoạt động ra sao?
Vấn đề trước khi có queue
Giả sử bạn có một API POST /orders. Khi user đặt hàng, server cần:
Ghi đơn hàng vào database
Gửi email xác nhận
Trừ kho
Bắn event cho hệ thống thống kê
Nếu làm tất cả đồng bộ (synchronous) trong cùng một request, thì:
Request sẽ chậm bằng tổng thời gian của bước chậm nhất (email server lag 3 giây → user đợi 3 giây).
Nếu bước gửi email fail, toàn bộ request có thể fail theo, dù đơn hàng đã ghi thành công.
Producer (API) và consumer (email service) bị coupling chặt: API phải biết email service đang sống hay chết, đang nhanh hay chậm.
Message Queue giải quyết gì?
Message queue là một lớp trung gian giúp tách rời (decouple) người gửi (producer) và người xử lý (consumer) theo thời gian và theo tốc độ. Producer chỉ cần "ném" message vào queue rồi trả response ngay cho user; consumer sẽ lấy message ra và xử lý bất cứ khi nào nó sẵn sàng.
Ba lợi ích cốt lõi:
Async hoá tác vụ nặng → API trả lời nhanh, trải nghiệm người dùng tốt hơn.
Buffer chống spike → nếu traffic tăng đột biến, message xếp hàng trong queue thay vì làm sập consumer.
Decoupling → producer và consumer có thể scale độc lập, deploy độc lập, thậm chí viết bằng ngôn ngữ khác nhau.
Cơ chế hoạt động (dưới lớp vỏ)
Về bản chất, một message queue là một cấu trúc dữ liệu kiểu FIFO (First In First Out) được host trên một service riêng, với ba thành phần: Producer → Queue/Broker → Consumer.
Bên trong broker, mỗi message thường đi qua các trạng thái:
Enqueue: producer gửi message, broker ghi message vào một log hoặc storage bền vững (không chỉ giữ trong RAM).
Ready: message nằm trong hàng đợi, sẵn sàng được lấy.
In-flight / Unacked: một consumer đã "lấy" message này ra để xử lý, nhưng broker chưa xoá nó — nó chỉ đánh dấu là "đang được ai đó xử lý" kèm theo một cái đồng hồ đếm ngược (visibility timeout).
Acked / Deleted: consumer báo "tôi xử lý xong rồi" (
ack), broker mới thực sự xoá message khỏi queue.Nacked / Requeued: consumer báo lỗi, hoặc hết giờ mà không ack → message quay lại trạng thái Ready để consumer khác lấy lại.
Điểm quan trọng nhất cần nhớ: message chỉ biến mất khỏi queue khi được ack tường minh. Đây chính là nền tảng cho "at-least-once delivery" — broker thà gửi message hai lần còn hơn làm mất nó.
Về mặt lưu trữ, hai mô hình phổ biến:
Queue truyền thống (RabbitMQ, SQS): message bị xoá sau khi ack, mỗi message chỉ được một consumer xử lý (trong cùng một queue).
Log-based (Kafka): message được ghi vào một log bất biến (append-only), không bị xoá ngay sau khi đọc. Consumer tự giữ một "con trỏ" (offset) để nhớ mình đã đọc tới đâu. Nhờ vậy nhiều consumer group khác nhau có thể đọc lại cùng một message độc lập với nhau.
2. Race Condition: Nhiều consumer cùng tranh nhau một message
Đây là bug kinh điển khi bạn scale consumer lên nhiều instance để tăng throughput.
Kịch bản lỗi
Giả sử bạn tự làm một hàng đợi job đơn giản trên một bảng dữ liệu, với mỗi record có một trạng thái (pending, done...). Nếu có 2 consumer cùng chạy gần như đồng thời:
Consumer A đọc record → thấy job #1 đang ở trạng thái
pending.Consumer B cũng đọc → thấy job #1 vẫn đang
pending(vì A chưa kịp cập nhật trạng thái).Cả A và B đều xử lý job #1 → gửi email trùng, trừ tiền hai lần, hoặc double-charge thẻ khách hàng.
Đây chính là race condition kiểu "double delivery do thiếu khóa" — không liên quan gì đến network, chỉ đơn thuần là hai luồng đọc cùng một dữ liệu trước khi nó kịp bị khoá.
Cách các message queue thật xử lý
a. Visibility timeout / lock ẩn (SQS, RabbitMQ)
Khi một consumer lấy message ra, broker ẩn message đó khỏi các consumer khác trong một khoảng thời gian (ví dụ 30 giây). Nếu consumer không ack trong thời gian đó (crash, treo, network lỗi), broker coi như xử lý thất bại và đưa message trở lại hàng đợi cho consumer khác lấy.
Vấn đề: nếu bạn set visibility timeout quá ngắn so với thời gian xử lý thực tế, message sẽ bị "phát lại" trong khi consumer đầu tiên vẫn đang chạy — dẫn đến xử lý trùng. Ngược lại nếu bạn không tuning timeout theo tải thực tế thì risk vẫn còn.
b. Khoá độc quyền + bỏ qua dòng đã khoá (tự build queue trên database)
Nếu bạn build queue thủ công trên một hệ quản trị dữ liệu quan hệ, pattern chuẩn là: khi một consumer chọn một record để xử lý, nó khoá độc quyền record đó trong phạm vi transaction — không consumer nào khác đọc/ghi được cho tới khi transaction commit. Đồng thời, nếu một consumer khác đang tìm record để lấy mà gặp record đang bị khoá, nó sẽ tự động bỏ qua và tìm record tiếp theo, thay vì đứng đợi.
Kết quả: nhiều consumer chạy song song, mỗi consumer luôn lấy được một job khác nhau, không ai giẫm chân ai, và không ai phải đợi ai.
c. Idempotency ở tầng consumer — lớp phòng thủ cuối cùng
Dù broker có cơ chế lock tốt tới đâu, at-least-once delivery vẫn có thể khiến một message được xử lý nhiều hơn 1 lần (ví dụ consumer xử lý xong, chuẩn bị ack thì bị kill). Vì vậy, thiết kế consumer handler nên idempotent: xử lý cùng một message N lần cho ra kết quả giống hệt như xử lý 1 lần. Cách làm phổ biến: dùng message_id hoặc idempotency_key để kiểm tra "đã xử lý chưa" trước khi làm, thường lưu trong một bộ nhớ đệm nhanh như Redis hoặc một bảng ghi nhận riêng.
Nguyên tắc: broker giúp giảm khả năng trùng lặp, nhưng chỉ có idempotency ở consumer mới triệt để loại bỏ hậu quả của việc trùng lặp.
3. Dead Letter Queue (DLQ)
Vấn đề: message "độc" làm nghẽn cả hệ thống
Tưởng tượng một message bị lỗi dữ liệu (ví dụ payload thiếu field bắt buộc) khiến consumer luôn luôn crash hoặc throw exception khi xử lý nó. Nếu broker cứ requeue message này mỗi khi consumer fail, bạn sẽ có một vòng lặp vô hạn:
lấy message → xử lý fail → requeue → lấy lại → xử lý fail → requeue → ...
Message này gọi là poison message. Nó không chỉ tự nó không bao giờ xử lý được, mà còn:
Chiếm slot xử lý, làm chậm các message hợp lệ phía sau.
Có thể làm consumer crash liên tục (nếu lỗi là do exception không bắt được), ảnh hưởng tới toàn bộ throughput.
Log spam liên tục vì cùng một lỗi lặp lại.
DLQ giải quyết như thế nào
Dead Letter Queue là một queue phụ, nơi message được chuyển tới sau khi:
Bị retry/nack quá N lần (max receive count), hoặc
Bị hết hạn (TTL — time to live) mà chưa được xử lý.
Lợi ích:
Cách ly message lỗi ra khỏi luồng chính, các message hợp lệ tiếp tục được xử lý bình thường, không bị nghẽn.
Cho phép inspect / debug thủ công: engineer có thể xem message trong DLQ, tìm nguyên nhân (thường là bug ở producer, schema thay đổi, hoặc dependency downstream bị down).
Cho phép replay: sau khi fix bug, có thể đẩy message từ DLQ quay lại main queue để xử lý lại, thay vì mất dữ liệu vĩnh viễn.
Một best practice đi kèm: cấu hình alert khi có message rơi vào DLQ, vì đây thường là dấu hiệu sớm của một lỗi hệ thống (schema mismatch, dependency down, bug logic) — không nên để DLQ âm thầm phình to mà không ai biết.
4. Bài toán Throughput: Producer sản xuất nhanh hơn Consumer tiêu thụ
Vấn đề
Queue giúp buffer traffic tăng đột biến — nhưng buffer không phải là vô hạn. Nếu tốc độ sản xuất trung bình của producer liên tục lớn hơn tốc độ tiêu thụ trung bình của consumer trong một thời gian dài (không chỉ là spike ngắn hạn), độ dài queue sẽ tăng dần vô hạn.
Hậu quả:
Bộ nhớ / storage của broker tăng dần, có thể dẫn tới hết dung lượng, broker chậm lại hoặc crash.
Độ trễ xử lý (latency) của mỗi message tăng dần — message gửi lúc 9h sáng có thể phải đợi tới 9h tối mới được xử lý.
Nếu queue có giới hạn cứng (bounded queue), producer sẽ bắt đầu bị reject hoặc block.
Các chiến lược xử lý
a. Scale consumer theo chiều ngang (horizontal scaling)
Cách đơn giản nhất: tăng số lượng consumer instance để tăng tổng throughput tiêu thụ. Nhiều hệ thống dùng auto-scaling dựa trên độ dài queue (ví dụ: queue vượt một ngưỡng nhất định → tự động tăng thêm consumer).
Giới hạn: consumer scale không vô hạn được — nếu downstream (database, API bên thứ ba) chỉ chịu được một mức tải nhất định, việc thêm consumer chỉ đẩy nghẽn từ queue sang downstream.
b. Backpressure — báo ngược cho producer "chậm lại"
Backpressure là cơ chế để hệ thống phía sau (consumer/queue) báo hiệu ngược lên cho phía trước (producer) biết "tôi đang quá tải, đừng gửi nữa (hoặc gửi chậm lại)", thay vì âm thầm để hàng đợi phình to không kiểm soát.
Vài cách áp dụng thực tế:
Bounded queue + block/reject: giới hạn queue tối đa N message. Khi đầy, producer gọi enqueue sẽ bị block (đợi có chỗ trống) hoặc nhận lỗi ngay lập tức (fail-fast) thay vì cứ nhận vô tội vạ.
Rate limiting ở producer: producer tự giới hạn tốc độ gửi (ví dụ token bucket), dựa trên tín hiệu từ consumer (queue depth, response time).
Load shedding: khi quá tải, chủ động từ chối một phần request mới thay vì cố nhận hết rồi sập toàn bộ.
Credit-based flow control (dùng trong AMQP, TCP, HTTP/2, gRPC streaming): consumer cấp một số "credit" cho producer, mỗi message gửi đi tốn 1 credit; producer chỉ được gửi tiếp khi còn credit, hết credit thì phải đợi consumer báo "tôi xử lý xong, cấp thêm credit".
c. Batching
Thay vì consumer xử lý từng message một, gom nhiều message thành một batch rồi xử lý cùng lúc (ví dụ ghi một lần cho cả trăm message thay vì ghi riêng lẻ từng cái). Batching giảm overhead trên mỗi message, tăng throughput hiệu dụng đáng kể — đặc biệt hiệu quả với các thao tác I/O như ghi dữ liệu, gọi API.
d. Priority queue / phân luồng theo mức độ quan trọng
Nếu không thể xử lý hết mọi message kịp thời, ít nhất đảm bảo message quan trọng (ví dụ giao dịch thanh toán) được xử lý trước message ít quan trọng (ví dụ log analytics), bằng cách tách thành nhiều queue với độ ưu tiên khác nhau.
Về bản chất, backpressure là cách hệ thống nói "chậm lại" một cách tường minh và có kiểm soát, thay vì để hệ thống tự sập một cách âm thầm và mất kiểm soát khi vượt ngưỡng.
5. Khi Message Queue crash hoặc hỏng — làm sao không mất message?
Đây là câu hỏi về durability (độ bền) — queue tồn tại để đảm bảo message không bị mất, vậy chính bản thân nó bị chết thì sao?
Vấn đề
Nếu broker chỉ giữ message trong RAM (in-memory queue) mà không ghi xuống đĩa, thì khi:
Process bị kill / crash
Server bị restart
Datacenter mất điện
→ Toàn bộ message đang nằm trong queue sẽ biến mất vĩnh viễn, kể cả những message đã được producer gửi thành công (broker đã trả ACK cho producer) nhưng chưa kịp được consumer xử lý.
Các lớp phòng thủ
a. Persistence — ghi message xuống disk
Broker nghiêm túc (RabbitMQ, Kafka, SQS) đều ghi message xuống disk (hoặc write-ahead log) trước khi báo "nhận thành công" cho producer, không chỉ giữ trong RAM.
Trade-off: ghi disk chậm hơn RAM → giảm throughput để đổi lấy durability. Đây là lý do các broker thường cho phép cấu hình mức độ persistence (ví dụ RabbitMQ có durable queue + persistent message; nếu không bật, message chỉ sống trong RAM và mất khi restart).
b. Replication — nhân bản dữ liệu ra nhiều node
Chỉ ghi disk trên 1 node vẫn chưa đủ, vì disk đó có thể hỏng, hoặc cả server đó chết.
Giải pháp: replicate message ra nhiều broker node (thường 3 node là tiêu chuẩn phổ biến).
Kafka: mỗi partition có 1 leader và N replica. Producer có thể yêu cầu broker chỉ báo thành công sau khi message đã được ghi vào tất cả (hoặc đủ số lượng tối thiểu) replica, không chỉ leader.
RabbitMQ (quorum queue): dùng thuật toán đồng thuận (Raft) để đảm bảo message được replicate tới đa số node trong cluster trước khi coi là "committed".
Nếu leader node chết, một trong các replica sẽ được bầu làm leader mới, và message vẫn còn nguyên vì đã được nhân bản trước đó.
c. Producer-side: chờ ACK trước khi coi là "đã gửi thành công"
Producer không nên coi message là "an toàn" ngay khi gọi hàm gửi — mà phải đợi broker ACK tường minh rằng message đã được ghi bền vững (persist + đủ replica). Nếu không nhận được ACK (timeout, network lỗi), producer cần retry gửi lại. Đây chính là điểm mà idempotency ở consumer (đã nói ở mục 2) lại phát huy tác dụng — vì retry ở producer có thể dẫn tới gửi trùng.
d. Consumer-side: không mất message đang xử lý dở
Kết hợp với cơ chế visibility timeout / ack ở mục 2: nếu consumer đang xử lý message mà crash giữa chừng (chưa kịp ack), broker sẽ tự động đưa message đó trở lại hàng đợi sau khi visibility timeout hết hạn, để consumer khác xử lý lại. Message không bị mất — nó chỉ bị trễ, và nhờ idempotency, việc xử lý lại không gây tác dụng phụ.
e. Backup / cross-region disaster recovery
Với hệ thống cực kỳ quan trọng (ví dụ giao dịch tài chính), ngoài replication trong cùng cluster, có thể cần backup snapshot định kỳ hoặc mirror sang region khác, để chống lại kịch bản cả cluster/datacenter chính bị sập hoàn toàn.
Tóm gọn nguyên tắc durability: persist trước khi ACK, replicate trước khi coi là an toàn, và luôn thiết kế consumer idempotent để retry không phải là nỗi sợ.
Tổng kết
Message queue nhìn bề ngoài đơn giản — chỉ là một hàng đợi FIFO. Nhưng để dùng nó đúng trong production, bạn cần hiểu và xử lý được 4 lớp vấn đề:
Hiểu rõ 4 lớp này không chỉ giúp bạn debug nhanh hơn khi có sự cố, mà còn giúp bạn chọn đúng công cụ (RabbitMQ, Kafka, SQS, hay tự build) tuỳ theo yêu cầu thực tế của hệ thống, thay vì chọn theo trend.
Thích bài viết này?
Nội dung trên Vết Mực luôn được chia sẻ miễn phí. Nếu bài viết mang lại giá trị cho bạn, hãy cân nhắc ủng hộ để chúng mình có thể duy trì máy chủ, phát triển thêm tính năng mới và tiếp tục xây dựng một không gian dành cho những người yêu viết lách. ✨
Các cách ủng hộ:
- •Viết và đăng bài trên Vết Mực
- •Chia sẻ bài viết với bạn bè
- •Góp ý để chúng mình cải thiện sản phẩm qua email: nsikhoa@gmail.com
Dù bạn chọn ủng hộ hay chỉ đơn giản là tiếp tục đọc và chia sẻ bài viết, đó đều là nguồn động lực rất lớn với chúng mình. ❤️
Bình luận
Đăng nhập để để lại bình luận.