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.

ℹ️ Cấu hình máy đo
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Container native arm64 (Node v22.23.2), Redis 7-alpine. Producer và consumer là cùng một file 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)
⚠️ Cạm bẫy: đẩy vào queue những việc người dùng cần kết quả ngay
Bất đồng bộ giải quyết vấn đề độ trễ của server, không giải quyết vấn đề người dùng cần biết kết quả. Nếu người dùng bấm "Thanh toán" và bạn trả 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ý.
Cùng một việc — khác nhau ở chỗ người dùng được thả ra lúc nào ĐỒNG BỘ nhận xử lý video · 5 phút — NGƯỜI DÙNG NGỒI CHỜ 200 OK có kết quả ngay nhưng chờ 5 phút Kết nối bị giữ 5 phút · timeout của mọi tầng đều phải > 5 phút · một burst là hết connection pool BẤT ĐỒNG BỘ nhận đẩy vào queue 202 · 20 ms người dùng được thả ra ở ĐÂY worker xử lý video ở nền · 5 phút Đổi lại: phải có cách cho họ biết khi nào xong Câu hỏi quyết định KHÔNG phải "việc này nặng không" mà là "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: được. Xác nhận đặt vé: không.
Bất đồng bộ không làm việc nặng nhẹ đi — nó chỉ chuyển chỗ chờ từ kết nối HTTP sang hàng đợi.

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 — 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
🔬 Số job mất đúng bằng kích thước prefetch
Con số 352 không phải hằng số — nó là phần còn lại của lô prefetch tại thời điểm worker chết. Nghĩa là công thức thiệt hại tối đa rất gọn: số job có thể mất = kích thước prefetch.

Đ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.
Worker chết giữa lô — ack sai thời điểm là mất job không dấu vết ACK KHI NHẬN (auto-ack cả lô) — at-most-once đọc 500 job ACK cả 500 ngay xử lý được 148 job 💥 worker chết 352 job đã ack nhưng CHƯA xử lý ⇒ broker coi như xong ⇒ không bao giờ giao lại Đo thật: pending = 0 · khôi phục được 0 job · không lỗi, không log, không ai biết ACK SAU KHI XONG — at-least-once đọc 500 job xử lý → ack từng job 💥 worker chết XAUTOCLAIM lấy lại Phần chưa ack nằm trong danh sách treo · worker khác nhận lại sau visibility timeout Đo thật: pending = 352 · khôi phục được toàn bộ · đổi lại: job có thể xử lý HAI lần Số job mất tối đa = KÍCH THƯỚC PREFETCH. Prefetch biến từ tham số hiệu năng thành tham số độ tin cậy. Và "job có thể xử lý hai lần" chính là lý do at-least-once BẮT BUỘC consumer phải idempotent (Bài 11).
Không có lựa chọn "vừa không mất vừa không trùng" — đó là bài toán hai vị tướng ở Bài 11 nhìn từ phía consumer.
worker/queue.js — vòng lặp consumer
// 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
⚡ Sửa một điều thường được nói sai: poison message KHÔNG chặn queue ở đây
Câu "một poison message chặn toàn bộ message phía sau" được nhắc rất nhiều, nhưng nó chỉ đúng với log có thứ tự và offset tuần tự — kiểu Kafka, khi consumer không commit được offset của message hỏng nên không đi tiếp được trong partition đó.

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.
⚠️ Cạm bẫy: có DLQ nhưng không ai nhìn nó
DLQ chỉ là một cái hộp. Nó giải quyết vấn đề "message hỏng làm nghẽn pipeline" nhưng không giải quyết vấn đề "message này chưa được xử lý". Một DLQ không có alert và không có người chịu trách nhiệm sẽ tích tụ trong sáu tháng, rồi đến ngày ai đó mở ra và phát hiện 40.000 đơn hàng chưa xử lý.

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ự.

🔬 Bài học lặp lại: scale ngang chỉ tuyến tính tới khi nút cổ chai dịch chỗ
Đây là lần thứ tư trong series cùng một hiện tượng xuất hiện. Ở Bài 3, thêm replica app không giúp gì khi database là nút cổ chai. Ở Bài 5, single-flight không cải thiện p99 vì nút cổ chai không nằm ở đó. Ở Bài 8, hai shard trên một máy còn chậm hơn một shard. Và ở đây, bốn consumer chỉ nhanh 2,68 lần vì Redis đơn luồng.

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.
⚠️ Cạm bẫy: giả định thứ tự toàn cục
Với hai consumer, sự kiện 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.

Queue depth theo thời gian — chỉ số sức khoẻ số một depth t burst bắt đầu burst kết thúc λ < μ · depth ≈ 0 λ > μ · TĂNG VÔ HẠN Little's Law: chờ cũng tăng theo thêm consumer ở đây tiêu hết tồn đọng rồi về 0 Đường đỏ không có điểm bão hoà. Không có mức tải nào là "an toàn" khi λ > μ — chỉ có thời gian trước khi hậu quả xuất hiện. Và hậu quả tệ nhất không phải chậm, mà là công việc trở nên VÔ NGHĨA: email xác nhận gửi sau 6 giờ.
Hình dạng ba đường lấy từ quan hệ λ/μ và Little's Law ở Bài 1 — đây là minh hoạ khái niệm, không phải số đo của lab.
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
⚠️ Cạm bẫy: coi queue là "vô hạn nên an toàn"
Queue không làm sự cố biến mất — nó đổi hình dạng của sự cố. Một sự cố 5 phút với $\lambda = 2\mu$ tạo ra tồn đọng phải mất thêm 5 phút nữa mới tiêu hết. Nhưng nếu consumer chết 30 phút trong lúc lưu lượng vẫn vào, bạn có một tồn đọng mà theo Little's Law sẽ mất hàng giờ để tiêu — và trong suốt thời gian đó, mọi job mới cũng phải chờ sau hàng đợi cũ.

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

reproduce_measurements.sh
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

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ài viết liên quan trong series

Bài 11: Idempotency & Retry An Toàn Bài 13: Rate Limiting & Backpressure Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận