Bài 11 kết luận rằng at-least-once cộng xử lý idempotent là tổ hợp khả thi duy nhất. Message queue là nơi tổ hợp đó không còn là lựa chọn mà là điều kiện bắt buộc: khi một job có thể được giao lại bất cứ lúc nào, consumer phải idempotent — nếu không, mỗi lần redeliver là một side-effect nữa.
Lab của bài này dựng producer/consumer thật trên Redis Streams. Hai phép đo đáng chú ý. Thứ nhất, tăng số
consumer từ 1 lên 4 chỉ làm nhanh 2,68 lần chứ không phải 4 — vì nút cổ chai đã chuyển từ
consumer sang chính Redis. Thứ hai, ack sai thời điểm làm 352 job biến mất không dấu vết:
số job khôi phục được là 0 thay vì 352.
worker/queue.js (đổi vai qua
biến ROLE), mỗi container giới hạn 1 CPU, không dependency — chỉ dùng XADD,
XREADGROUP, XACK, XPENDING.Chỉ số then chốt là
msActive: thời gian từ lúc bắt đầu tới job cuối cùng được xử
lý, không tính thời gian ngồi chờ stream rỗng. Nếu tính cả phần đó thì "rate" chỉ phản
ánh tham số DURATION_MS chứ không phản ánh năng lực tiêu thụ thật.
12.1 Vì sao chuyển sang bất đồng bộ
| Lợi ích | Cụ thể là gì | Cái giá |
|---|---|---|
| Tách độ trễ người dùng khỏi việc nặng | Trả lời trong 20 ms rồi xử lý video trong 5 phút ở nền | Người dùng nhận 202 Accepted — bạn phải cho họ cách biết khi nào xong |
| Decouple producer và consumer | Consumer sập thì producer vẫn nhận việc; deploy hai bên độc lập | Thêm một hạ tầng phải vận hành, theo dõi và hiểu |
| Hấp thụ burst | Queue làm bộ đệm cho đỉnh tải — 10× lưu lượng trong 30 giây không làm sập gì | Đỉnh tải biến thành tồn đọng, và tồn đọng có cái giá riêng (mục 12.5) |
| Thử lại tự nhiên | Job thất bại nằm lại trong queue thay vì mất | Bắt buộc consumer phải idempotent (Bài 11) |
202 Accepted, họ sẽ ngồi đoán: thành công chưa? thẻ có bị trừ không? bấm lại được không?Câu hỏi kiểm tra: người dùng có thể làm việc tiếp mà không cần biết kết quả không? Gửi email thông báo thì được. Xác nhận đặt vé thì không. Nếu câu trả lời là "không" thì hoặc làm đồng bộ, hoặc phải đầu tư thêm một cơ chế cho họ theo dõi trạng thái — và cơ chế đó thường tốn nhiều công hơn chính việc xử lý.
12.2 Ngữ nghĩa ack — và 352 job biến mất
Toàn bộ độ tin cậy của một message queue nằm ở thời điểm gửi ack. Ack nghĩa là "job này xong rồi, đừng giao lại nữa" — nên gửi nó quá sớm là tự tay bỏ mất khả năng khôi phục.
Cơ chế đứng sau chuyện đó có tên: khi broker giao một job, nó không xoá job đi mà chuyển sang danh sách treo (pending) và bấm giờ. Nếu ack tới trước khi hết giờ, job coi như xong. Nếu không — worker chết, hoặc chỉ đơn giản là quá chậm — broker kết luận worker đã hỏng và giao job cho người khác. Khoảng thời gian chờ đó gọi là visibility timeout, và nó chính là thứ biến "worker chết" từ mất dữ liệu thành một lần giao lại.
| At-most-once (ack khi nhận) | At-least-once (ack sau khi xong) | |
|---|---|---|
| Ack lúc nào | Ngay sau khi đọc, trước khi xử lý | Sau khi xử lý thành công |
| Worker chết giữa chừng | Job biến mất vĩnh viễn | Job còn trong danh sách treo, được giao lại |
| Job có thể xử lý hai lần? | Không | Có — nên consumer phải idempotent |
| Dùng khi | Gần như không bao giờ — mất job im lặng là hỏng tệ nhất | Mặc định đúng cho hầu hết mọi trường hợp |
Lab đo được thiệt hại. Consumer đọc trước 500 job mỗi lần (prefetch 500), mỗi job xử lý 5 ms — nên một lô
mất khoảng 2,5 giây. Ta docker kill consumer sau 1,2 giây, tức là giữa lô:
| Chế độ ack | Job còn khôi phục được (pending) | Nghĩa là gì |
|---|---|---|
| Ack khi nhận (auto-ack cả lô) | 0 | Khoảng 352 job đã được đọc nhưng chưa xử lý — và đã bị ack. Chúng biến mất không dấu vết: không lỗi, không log, không ai biết |
| Ack sau khi xong | 352 |
Toàn bộ phần chưa xử lý nằm trong danh sách treo và lấy lại được bằng
XAUTOCLAIM
|
Điều này biến prefetch từ một tham số hiệu năng thành một tham số độ tin cậy. Prefetch lớn giảm số round-trip tới broker (nên nhanh hơn), nhưng nếu ack sai thời điểm thì nó cũng chính là số job bạn mất mỗi lần một worker chết.
Và đây là chỗ mình phải sửa chính mình khi làm lab: bản đầu tiên của
ACK_MODE=on-receive ack từng message ngay trước khi xử lý nó, và kết quả đo ra gần
như y hệt chế độ đúng (pending 359 so với 351). Cái bẫy thật trong thực tế không phải vậy — nó là
auto-ack toàn bộ lô ngay sau khi đọc, mặc định của khá nhiều client. Sửa lại cho đúng
thì chênh lệch hiện ra: 0 so với 352.
// BLOCK 1000: wait up to 1s if the stream is empty, instead of spinning and burning CPU.
const res = await redis.cmd('XREADGROUP', 'GROUP', GROUP, CONSUMER,
'COUNT', String(BATCH), 'BLOCK', '1000', 'STREAMS', STREAM, '>');
for (const [id, fields] of entries) {
const f = {};
for (let i = 0; i < fields.length; i += 2) f[fields[i]] = fields[i + 1];
// Dedup on event_id BEFORE causing any side effect. SET NX is atomic, so if two
// workers receive the same event only one wins (the precondition of at-least-once).
const fresh = await redis.cmd(
'SET', `lab:q:seen:${f.event_id}`, '1', 'NX', 'EX', '3600');
if (fresh !== 'OK') {
await redis.cmd('XACK', STREAM, GROUP, id); // already processed -> ack and skip
continue;
}
await doWork(f);
// ACK LAST. This single line decides the reliability of the whole system.
await redis.cmd('XACK', STREAM, GROUP, id);
}
12.3 Poison message và DLQ — và một điều cần nói cho đúng
Poison message là job không bao giờ xử lý được: payload sai định dạng, tham chiếu tới bản ghi đã bị xoá, hoặc chạm đúng một bug. Nó thất bại, được giao lại, thất bại lại — mãi mãi.
Cách xử lý tiêu chuẩn là DLQ — viết tắt của dead letter queue, "hàng đợi thư chết". Nó chỉ là một queue thứ hai: sau khi một job đã thất bại quá số lần cho phép, thay vì để nó quay lại mãi, ta chuyển nó sang queue đó rồi ack ở queue chính. Job không mất, nhưng cũng không còn làm phiền luồng bình thường — nó nằm chờ một con người nhìn tới.
Lab bơm 5.000 job, cứ 500 job chèn một job độc (tổng 10 job độc), một consumer:
| Cấu hình | Job xử lý xong | Job thất bại | Còn treo (pending) | Trong DLQ |
|---|---|---|---|---|
| Không có DLQ | 4.990 | 10 | 10 — tồn đọng vĩnh viễn | 0 |
Có DLQ (MAX_ATTEMPTS=1) |
4.990 | 10 | 0 | 10 |
Với Redis Streams thì không: số đo ở trên cho thấy 4.990 job khác vẫn xử lý bình thường. Message hỏng chỉ nằm lại trong danh sách treo (pending). Thiệt hại vì thế âm thầm hơn: không có sự cố nào để phát hiện, chỉ có một con số lớn dần mà không ai nhìn.
Vì vậy hai việc phải làm khác nhau theo loại broker. Với log có thứ tự: bắt buộc có DLQ, nếu không một bản ghi hỏng làm đứng cả pipeline. Với Redis Streams: bắt buộc có alert theo độ dài danh sách treo, nếu không bạn sẽ mất job mà không biết — và đó là lý do mục 12.5 gọi queue depth là chỉ số sức khoẻ số một.
Ba thứ phải có cùng lúc: alert khi DLQ khác rỗng, người chịu trách nhiệm xem nó, và cách đưa job trở lại sau khi sửa bug. Thiếu cái thứ ba là lý do phổ biến nhất khiến người ta bỏ mặc DLQ — vì không có cách đưa job về nên xem nó cũng chẳng làm được gì.
12.4 Thứ tự và consumer group — thêm consumer là phá thứ tự
Trước hết là cái tên trong tiêu đề. Consumer group là cơ chế để nhiều consumer cùng đọc
một queue mà không giẫm lên nhau: broker nhớ từng job đã giao cho consumer nào, và mỗi job chỉ
giao cho đúng một thành viên trong nhóm. Đó là lý do lệnh ở mục 12.2 là
XREADGROUP chứ không phải XREAD — thêm một consumer vào nhóm là thêm năng lực xử
lý, không phải nhân đôi công việc. Đổi lại chính là vấn đề của mục này.
Thứ tự chỉ được đảm bảo trong phạm vi một partition (hoặc một stream key). Ngay khi bạn thêm consumer thứ hai để scale, các job được xử lý song song và không còn thứ tự toàn cục nào.
Số đo scaling ở lab cho thấy chính xác điều đó — và một điều nữa không ai mong đợi:
| Số consumer | msActive (thời gian tiêu thụ hết 20.000 job) |
Tổng throughput | Tốc độ mỗi consumer | Nhanh lên |
|---|---|---|---|---|
| 1 | 1.764 ms | 11.338/s | 11.338/s | 1,00× |
| 2 | 1.200–1.202 ms | 16.652/s | ~8.300/s | 1,47× |
| 4 | 649–664 ms | 30.375/s | ~7.600/s | 2,68× |
Gấp bốn số consumer chỉ nhanh lên 2,68 lần. Và cột "tốc độ mỗi consumer" giải thích lý do: nó tụt từ 11.338 xuống ~7.600. Nút cổ chai đã chuyển từ consumer sang Redis — Redis xử lý lệnh trên một luồng duy nhất, nên bốn consumer đang tranh nhau một tài nguyên tuần tự.
Quy tắc chung: scale ngang tuyến tính chỉ tới thời điểm nút cổ chai dịch sang thành phần dùng chung. Nên trước khi thêm worker, hãy trả lời: worker có thật đang là nút cổ chai không? Cách kiểm tra rẻ nhất là nhìn tốc độ mỗi worker — nếu nó tụt khi bạn thêm worker thì bạn đang chia lại một chiếc bánh cố định, không phải làm bánh to hơn.
Trong lab, cách vượt qua là phân vùng: nhiều stream key thay vì một, mỗi key một consumer. Nó cũng chính là cơ chế cho phép giữ đúng thứ tự ở nơi cần — xem ngay dưới.
OrderCancelled hoàn toàn có thể được xử lý trước
OrderCreated của cùng đơn hàng đó. Hệ thống rơi vào trạng thái vô nghĩa: huỷ một đơn chưa
tồn tại, rồi tạo một đơn mà lẽ ra đã bị huỷ.Cách chữa không phải là bỏ scale mà là chọn partition key theo thực thể: mọi sự kiện của cùng một
order_id đi vào cùng một partition, nên chúng luôn được xử lý tuần tự
với nhau, trong khi các đơn hàng khác nhau vẫn song song. Bạn đổi "thứ tự toàn cục" — thứ bạn
không cần — lấy "thứ tự trong phạm vi thực thể" — thứ bạn thật sự cần.Đây cũng là kỹ thuật đã gặp ở Bài 10 mục 10.5 dưới một cái tên khác: phân vùng theo key làm cho lock trở nên không cần thiết, vì mỗi key chỉ có một worker chạm vào.
12.5 Backpressure và queue depth
Queue depth là chỉ số sức khoẻ số một của hệ bất đồng bộ, và lý do nằm ở Bài 1: nếu tốc độ vào $\lambda$ lớn hơn tốc độ ra $\mu$ thì depth tăng không giới hạn, và theo Little's Law thời gian chờ cũng vậy. Không có mức tải nào là "an toàn" nếu $\lambda > \mu$ — chỉ có thời gian trước khi hậu quả xuất hiện.
| Tình huống | Depth theo thời gian | Người dùng thấy gì |
|---|---|---|
| $\lambda < \mu$ (consumer đủ) | Dao động quanh 0 | Kết quả tới trong vài giây |
| Burst ngắn, $\lambda \gg \mu$ trong 30 giây | Tăng vọt rồi về 0 | Chậm một lúc — đây chính là giá trị của queue |
| $\lambda > \mu$ liên tục | Tăng vô hạn | Kết quả tới sau nhiều giờ — của hành động họ đã quên từ lâu |
Hệ quả tệ nhất không phải sự chậm trễ mà là công việc trở nên vô nghĩa: email "đơn hàng của bạn đã được xác nhận" gửi sau 6 giờ, thông báo về một phiên live đã kết thúc, hoặc một job huỷ đơn chạy sau khi đơn đã được giao.
Bốn van xả, theo thứ tự nên thử: scale consumer (nếu nút cổ chai thật ở đó — xem mục 12.4); giới hạn độ dài queue và từ chối job mới khi vượt, để thất bại nhanh thay vì hứa hẹn; load shedding — bỏ job không quan trọng để giữ job quan trọng; và giảm tốc producer (rate limit, Bài 13). Điểm chung của cả bốn: chúng đều là cách nói không — và một hệ bất đồng bộ không có cách nói không thì chỉ đang trì hoãn việc nói không.
Tự làm lại các phép đo
cd blog/sysdesign/sysdesign-lab
docker compose --profile queue up -d redis
# ./tools/queue-test.sh COUNT N_CONSUMER [WORK_MS] [POISON_EVERY] [MAX_ATTEMPTS] \
# [IDEMPOTENT] [ACK_MODE] [DUR] [BATCH]
# --- Scaling: 20,000 jobs, 1 / 2 / 4 consumers. Read `msActive`, not `rate`. ---
./tools/queue-test.sh 20000 1 0 0 0 0 after 15000 # msActive 1764ms · 11,338/s
./tools/queue-test.sh 20000 2 0 0 0 0 after 15000 # msActive ~1201ms · total 16,652/s
./tools/queue-test.sh 20000 4 0 0 0 0 after 15000 # msActive ~658ms · total 30,375/s
# --- Poison messages: 5,000 jobs, one poison job every 500 ---
./tools/queue-test.sh 5000 1 0 500 0 0 after 15000 # pending=10, stuck forever
./tools/queue-test.sh 5000 1 0 500 1 0 after 15000 # dlq=10 · pending=0
# --- Acking at the wrong moment: kill the consumer MID-BATCH of 500 jobs ---
docker compose exec -T redis redis-cli DEL lab:jobs
docker compose run --rm --no-deps -e ROLE=producer -e COUNT=3000 queueworker queue.js
docker compose run --rm --name qkill --no-deps -e ROLE=consumer -e WORK_MS=5 -e BATCH=500 \
-e ACK_MODE=on-receive -e DURATION_MS=30000 queueworker queue.js &
sleep 1.2 && docker kill qkill
docker compose exec -T redis redis-cli XPENDING lab:jobs g1 | head -1
# prints 0 -> the 352 unprocessed jobs are gone for good
# Switch ACK_MODE to `after` and repeat: it prints 352, all recoverable
Tóm lại
Bất đồng bộ tách độ trễ người dùng khỏi việc nặng và hấp thụ được burst, nhưng nó đổi hình dạng của vấn đề chứ không xoá vấn đề: đỉnh tải thành tồn đọng, và tồn đọng có cái giá riêng.
Toàn bộ độ tin cậy nằm ở thời điểm ack. Đo thật với prefetch 500 và worker bị kill giữa lô: ack-khi-nhận cho 0 job khôi phục được, ack-sau-khi-xong cho 352. Số job mất tối đa đúng bằng kích thước prefetch — nên prefetch là tham số độ tin cậy, không chỉ là tham số hiệu năng. Và vì at-least-once nghĩa là job có thể xử lý hai lần, consumer bắt buộc phải idempotent (Bài 11).
Về poison message, mình sửa lại một điều thường được nói sai: với Redis Streams nó không chặn message phía sau — 4.990 job khác vẫn chạy bình thường, message hỏng chỉ nằm lại trong danh sách treo. Điều đó làm thiệt hại âm thầm hơn, nên với broker kiểu này thứ bắt buộc phải có là alert theo độ dài danh sách treo, không chỉ là DLQ.
Cuối cùng, gấp bốn số consumer chỉ nhanh lên 2,68 lần — lần thứ tư trong series nút cổ chai dịch sang thành phần dùng chung ngay khi ta scale. Cách kiểm tra rẻ nhất là nhìn tốc độ mỗi worker: nếu nó tụt khi bạn thêm worker, bạn đang chia lại một chiếc bánh cố định.
Bài 13 đi vào van xả cuối cùng trong danh sách ở mục 12.5: rate limiting. Đó là cơ chế để hệ thống nói không một cách có kiểm soát — thay vì nhận hết rồi vỡ, hoặc nhận hết rồi hứa một tồn đọng sáu giờ.
📖 Tài liệu tham khảo
-
Redis — Streams: consumer group, danh sách treo (PEL) và
XAUTOCLAIM, toàn bộ cơ chế mà lab dùng ở mục 12.2 và 12.4 -
Redis —
XREADGROUP: ý nghĩa của>so với một ID cụ thể, và vì saoNOACKchính là chế độ at-most-once đo ở mục 12.2 -
Redis —
XPENDING: cách đọc danh sách treo, chỉ số dùng làm alert ở mục 12.3 - AWS SQS — Visibility timeout: cùng cơ chế, giải thích rõ ràng nhất, kèm hậu quả khi đặt ngắn hơn thời gian xử lý
- AWS SQS — Dead-letter queues, kể cả redrive (đưa job trở lại) — chính là "thứ thứ ba" mà cạm bẫy ở mục 12.3 nói là hay bị thiếu
- Apache Kafka — partition và thứ tự: nguồn của khẳng định "thứ tự chỉ trong một partition" ở mục 12.4, và là loại broker mà poison message có chặn hàng đợi
- Wikipedia — Little's Law, cơ sở của lập luận về queue depth ở mục 12.5 (đã gặp ở Bài 1)
Tải mã nguồn thực hành
Producer và consumer trong cùng một file, nói trực tiếp giao thức Redis Streams không qua thư viện nào. Cả ba chế độ đo trong bài — scaling, poison/DLQ, và ack sai thời điểm — đều bật tắt bằng biến môi trường trong file này:
Tải về queue.js (Redis Streams, 0 dependency)
Bình luận