Message Queues
Xem thửKhách phải chờ một email
Khi khách bấm "Đặt hàng", gdev-shop thực hiện ba việc: lưu đơn vào database (30 ms), gọi dịch vụ gửi email xác nhận (thường 300 ms, nhưng giờ cao điểm có khi 8 giây), và gọi dịch vụ xuất hóa đơn PDF (khoảng 1 giây). Nếu làm cả ba đồng bộ trong request, khách phải chờ hơn 1,3 giây ở điều kiện tốt và đến cả chục giây khi dịch vụ email chậm. Tệ hơn, nếu dịch vụ email sập, đơn hàng đã lưu thành công nhưng khách nhận lỗi, bấm lại, và tạo đơn thứ hai.
Gốc của vấn đề: người dùng chỉ cần biết đơn đã được ghi nhận. Việc gửi email và xuất hóa đơn có thể làm sau. Message queue là công cụ cho phép việc đó.
Message queue là gì?
Message queue (hàng đợi thông điệp) là một thành phần trung gian nhận các thông điệp (message) từ bên gửi, lưu lại một cách bền vững, và giao cho bên xử lý khi họ sẵn sàng. Nhờ vậy bên gửi và bên xử lý không cần hoạt động cùng lúc hoặc cùng tốc độ.
Các vai trò:
- Producer: bên gửi thông điệp vào queue (ở đây là API).
- Queue (broker): nơi lưu thông điệp cho đến khi được xử lý. Ví dụ RabbitMQ, Amazon SQS, Kafka (ở dạng log, sẽ học sau).
- Consumer hoặc Worker: bên lấy thông điệp ra xử lý.
- Message: một gói dữ liệu mô tả công việc cần làm.
Một thông điệp ví dụ:
{
"messageId": "msg_7f3a",
"type": "SendOrderConfirmationEmail",
"orderId": "ord_1001",
"customerEmail": "an@mail.com"
}
Đồng bộ và bất đồng bộ: ai đợi ai?
Cách đọc hình
Phần đồng bộ (nét liền, khách đang chờ):
- Client gửi
POST /orders. - API lưu đơn vào database. Đây là phần bắt buộc phải thành công trước khi trả lời.
- API đưa một công việc vào queue ("gửi email xác nhận cho đơn 1001"). Thao tác này rất nhanh, vài mili giây.
- API trả
202 Acceptedcho client: "đơn đã được ghi nhận". Tổng thời gian khoảng 40 ms, không phụ thuộc vào dịch vụ email.
Phần bất đồng bộ (nét đứt, chạy nền, không ai chờ):
- Queue giao công việc cho một Worker khi Worker sẵn sàng.
- Worker gọi dịch vụ email bên ngoài, chậm cũng được vì không ai đang chờ.
- Worker gửi
ack(xác nhận đã xử lý xong) về queue, và queue xóa thông điệp. Job lỗi liên tục (poison message) sau một số lần thử được chuyển sang dead-letter queue để điều tra thay vì chặn hàng đợi.
Ba điều cần nhớ: queue tách rời thời gian giữa bên gửi và bên xử lý; tốc độ có thể khác nhau (queue làm đệm khi tải đột biến); và API không biết email đã gửi xong hay chưa. Đó là cái giá: kết quả cuối cùng chỉ có sau một khoảng thời gian. Vì giao ít nhất một lần nên worker phải idempotent.
Bottleneck: độ dài hàng đợi (số thông điệp chờ) và tốc độ xử lý của Worker. Nếu tốc độ vào lớn hơn tốc độ xử lý lâu dài, hàng đợi tăng mãi. Có thể thêm nhiều Worker để xử lý song song, đây là cách mở rộng chính của mô hình này.
Đang tải Animation…
Đang tải Animation…
Điều gì xảy ra khi Worker lỗi?
Đây là lý do quan trọng nhất để hiểu queue đúng cách. Worker có thể chết giữa chừng, hoặc dịch vụ email có thể lỗi.
Đang tải sơ đồ…
Đang tải Animation…
Cách đọc sơ đồ
- Queue giao job
msg_7f3acho Worker. Từ lúc này job ở trạng thái "đang xử lý" và tạm thời ẩn với các Worker khác, trong một khoảng thời gian gọi là visibility timeout. - Worker gọi dịch vụ email nhưng bị timeout (mũi tên có dấu
x). Worker không biết email đã gửi hay chưa. - Worker không gửi
ack. Khi visibility timeout kết thúc mà chưa cóack, queue coi như xử lý thất bại và giao lại job. - Lần giao lại thành công, Worker gửi
ackvà job bị xóa.
Hệ quả quan trọng: hầu hết queue đảm bảo giao ít nhất một lần (at-least-once). Không bao giờ mất thông điệp (nếu chưa có ack thì giao lại), nhưng có thể trùng: nếu email đã gửi thành công nhưng Worker chết ngay trước khi ack, job được giao lại và email bị gửi lần hai. Queue không tự đảm bảo "đúng một lần".
Vì vậy Worker phải idempotent: xử lý hai lần cho kết quả như một lần. Một cách làm: ghi lại ID thông điệp đã xử lý với ràng buộc duy nhất:
INSERT INTO processed_messages (message_id) VALUES ('msg_7f3a')
ON CONFLICT (message_id) DO NOTHING;
Nếu câu lệnh thêm được một dòng thì đây là lần đầu, tiến hành gửi email. Nếu không thêm được dòng nào (đã tồn tại) thì bỏ qua vì đã xử lý. Chú ý thứ tự và sự nguyên tử: ghi nhận trong cùng giao dịch với tác dụng phụ khi có thể. Với việc gọi dịch vụ ngoài (email) thường không thể nằm chung giao dịch, nên nhiều nhà cung cấp hỗ trợ idempotency key để chính họ chặn trùng.
Retry, dead-letter queue và những cơ chế đi kèm
- Retry (thử lại): giao lại job thất bại, thường với khoảng cách tăng dần (backoff) để không dồn dập vào dịch vụ đang lỗi.
- Giới hạn số lần thử: sau N lần vẫn thất bại thì ngừng thử.
- Dead-letter queue (DLQ): nơi chứa các job vượt quá số lần thử. Con người hoặc một quy trình riêng kiểm tra vì sao chúng hỏng (dữ liệu sai, bug), sửa rồi đưa lại. Nếu không có DLQ, một poison message (job luôn lỗi) sẽ bị thử lại mãi và có thể chặn hàng đợi.
- Thứ tự: nhiều queue không đảm bảo thứ tự giao khi có nhiều Worker song song hoặc khi có retry. Nếu nghiệp vụ cần thứ tự (các thay đổi trên cùng một đơn), cần thiết kế riêng (ví dụ phân nhóm theo khóa).
- Backpressure: khi hàng đợi quá dài, cần giám sát, cảnh báo, và có thể giới hạn tốc độ gửi vào hoặc thêm Worker.
Khi nào dùng, khi nào chưa cần?
Nên dùng khi: việc không cần hoàn tất trước khi trả lời người dùng (email, thumbnail, hóa đơn); phụ thuộc ngoài chậm hoặc không ổn định; cần hấp thụ đợt tải đột biến; cần tách rời các thành phần.
Chưa cần khi: kết quả cần ngay để trả lời (kiểm tra mã giảm giá, tính tổng tiền); tải thấp và thao tác nhanh; nhóm chưa sẵn sàng vận hành thêm một hệ thống và xử lý các vấn đề trùng, thứ tự, DLQ.
Những vấn đề mới mà queue tạo ra
- Nhất quán sau một khoảng thời gian: trạng thái cuối cùng đến muộn, giao diện phải thể hiện trạng thái trung gian.
- Trùng lặp: cần idempotency.
- Khó gỡ lỗi: một yêu cầu trải ra nhiều bước ở nhiều nơi. Cần mã định danh để lần theo.
- Một hệ thống nữa cần vận hành, giám sát độ dài hàng đợi, tỷ lệ lỗi, DLQ.
- Khoảng hở giữa lưu database và đẩy vào queue: nếu lưu đơn xong nhưng chết trước khi đẩy, công việc bị mất. Giải pháp là outbox pattern đã học.
Những hiểu lầm phổ biến
- "Queue đảm bảo xử lý đúng một lần." Thường chỉ at-least-once. Phải thiết kế idempotent.
- "Queue làm hệ thống nhanh hơn." Nó làm phản hồi nhanh hơn bằng cách dời việc ra sau. Tổng công việc không giảm.
- "Cứ dùng queue cho mọi lời gọi." Dùng cho việc không cần kết quả ngay. Dùng sai làm mọi thứ phức tạp vô ích.
- "Có DLQ là xong chuyện lỗi." DLQ chỉ cách ly job lỗi. Cần quy trình xem và xử lý nó, nếu không job lỗi nằm đó mãi.
Must Know, Good to Know, Advanced
- Must Know: producer, queue, worker; phần đồng bộ và bất đồng bộ; API trả lời ngay sau khi lưu và enqueue; ack, visibility timeout, giao lại; at-least-once và idempotency; retry và DLQ.
- Good to Know: backoff, poison message; vấn đề thứ tự; backpressure; khoảng hở lưu và enqueue (outbox).
- Advanced: Kafka và log phân vùng, exactly-once trong phạm vi hẹp. Chỉ cần biết rằng có hai họ hệ thống: hàng đợi xóa sau xử lý và log lưu lâu có thể đọc lại.
Tự kiểm tra
- Trong luồng đặt hàng, phần nào đồng bộ, phần nào bất đồng bộ, và khi nào API trả response?
- Worker gửi email xong nhưng chết trước khi ack. Điều gì xảy ra và cần thiết kế gì để khách không nhận hai email?
- Vì sao không có DLQ có thể làm cả hàng đợi bị kẹt?
Đáp án gợi ý
- Đồng bộ: xác thực, kiểm tra tồn kho cần thiết, lưu đơn vào database và đưa job vào queue. Bất đồng bộ: gửi email, cập nhật điểm thưởng, các bước hậu xử lý do Worker làm. API trả response ngay sau khi lưu đơn và enqueue thành công, không chờ Worker.
- Worker chết trước khi ack nên queue không biết job đã xong và sau visibility timeout sẽ giao lại cho worker khác, nên khách có thể nhận hai email. Cần thiết kế idempotent: ghi lại trạng thái "đã gửi email cho đơn này" (khóa duy nhất theo mã đơn và loại thư) và kiểm tra trước khi gửi.
- Một poison message (job luôn lỗi) bị giao lại, thất bại, giao lại... mãi, và chiếm worker. Nếu không có DLQ và giới hạn số lần thử, nó có thể chặn hoặc làm chậm cả hàng đợi, đặc biệt khi hàng đợi giữ thứ tự. DLQ chuyển job lỗi sang nơi riêng để điều tra mà không chặn các job còn lại.
Nếu chỉ nhớ 3 điều
- Queue tách rời thời gian: API lưu đơn, đẩy job rồi trả lời ngay; Worker xử lý nền với tốc độ riêng.
- Queue thường giao ít nhất một lần: không mất nhưng có thể trùng, nên Worker phải idempotent.
- Cần retry có backoff, giới hạn số lần và DLQ; kết quả cuối cùng đến sau một khoảng thời gian.