Cách ly luồng gửi: OTP không kẹt sau năm triệu tin khuyến mãi
OTP xếp sau lô 500 nghìn tin khuyến mãi chờ hơn một phút, NCC push chậm thì gần 14 phút. Chọn hàng ưu tiên, giữ riêng worker hay giữ sàn, rồi tự đo.
TL;DR: OTP có hạn 15 giây, nhưng nếu đi chung một hàng vào trước ra trước với lô 500.000 tin khuyến mãi thì phải chờ khoảng 62 giây ngay cả khi nhà cung cấp khoẻ, và gần 14 phút khi nhà cung cấp push chậm. Bài này đặt ba cách cách ly cạnh nhau: hàng ưu tiên trên một pool worker chung (nhóm worker dùng chung cho mọi loại tin), giữ riêng worker cho OTP, và giữ sàn (một số worker chỉ nhận OTP) rồi cho OTP mượn thêm worker của pool chung. Mỗi cách vỡ dưới một kịch bản khác: push treo, SMS chậm, timeout bị nâng. Bạn chọn trước, rồi tự đo.
Tối thứ Sáu, bộ lập lịch chèn lô khuyến mãi đầu tiên, 500.000 job, vào bảng jobs. Ba giây sau, một người bấm "gửi mã". Tin OTP không mất, không trùng (bài 03 và 04 đã lo), nó chỉ đứng thứ 500.001 trong hàng và hơn một phút sau mới tới lượt. Câu hỏi của bài: cách ly hai luồng tới mức nào, và mỗi mức trả giá gì khi push hoặc SMS chậm.
1. Cấp cứu xếp hàng sau đoàn khám sức khoẻ
Cả công ty 500 người bốc số khám định kỳ, rồi một bệnh nhân cấp cứu bốc số 501. Phòng khám có thể gọi cấp cứu trước mọi số, nhưng người đó vẫn chờ một bác sĩ khám xong ca dở, hôm máy xét nghiệm chậm thì ca nào cũng nửa tiếng. Hoặc giữ bác sĩ trực riêng, đông thì gọi thêm khoa khác.
| Phòng khám | Gửi thông báo |
|---|---|
| Bác sĩ | Worker: tiến trình lấy một job, gọi nhà cung cấp, đánh dấu xong |
| Bệnh nhân cấp cứu | OTP, hạn 15 giây |
| Máy xét nghiệm chậm | Nhà cung cấp push chậm, lượt gọi nào cũng giữ worker tới timeout |
| Gọi cấp cứu trước, chờ ca dở | Hàng ưu tiên (phương án A) |
| Bác sĩ trực chỉ nhận cấp cứu | Giữ riêng worker cho OTP (phương án B) |
| Bác sĩ trực, đông thì gọi khoa khác | Giữ sàn worker cho OTP, cho mượn pool chung (phương án C) |
2. Ràng buộc và ba phương án
OTP qua SMS 200 lượt/giây, hạn 15 giây; tin giao dịch qua push 1.000 lượt/giây, hạn 1 phút; khuyến mãi qua push 5.000.000 tin trong 15 phút. Độ trễ nhà cung cấp p50 150 ms, timeout 2 giây.
OTP đi qua nhà cung cấp SMS, khuyến mãi qua nhà cung cấp push (gọi tắt là NCC như bài 01). Push chậm không làm SMS chậm, vậy mà OTP vẫn kẹt, vì hai luồng chung một hàng job và một pool worker.
Giả định riêng của bài: pool 1.200 worker, mỗi worker giữ một lượt gọi NCC tới khi có trả lời hoặc hết timeout; khuyến mãi chèn lô 500.000 job; NCC khoẻ thì một lượt giữ worker khoảng 150 ms. Định luật Little nói số việc đang nằm trong hệ bằng tốc độ việc đến nhân thời gian mỗi việc ở lại, L = λ × W. Số worker bận ở tải đỉnh là 6.756 × 0,15 ≈ 1.013, nên 1.200 worker dư khoảng 18% và xả được 1.200 / 0,15 = 8.000 lượt mỗi giây.
Trong hàng vào trước ra trước (FIFO), OTP đứng sau 500.000 job phải chờ 500.000 / 8.000 = 62,5 giây. Khi NCC push chậm, mỗi lượt push giữ worker tới hết timeout 2 giây, pool chỉ xả 1.200 / 2 = 600 lượt mỗi giây, và OTP chờ 500.000 / 600 ≈ 833 giây, gần 14 phút.

Đó là head-of-line blocking (nghẽn đầu hàng): việc gấp kẹt sau việc không gấp chỉ vì đứng sau. Chọn một phương án trước đã.
Ràng buộc: OTP 200/giây qua SMS, hạn 15 giây · khuyến mãi theo lô 500.000 job qua push · pool 1.200 worker · timeout 2 giây · NCC push có thể chậm giữa đợt
Bạn cho OTP dùng chung với khuyến mãi tới đâu: chung cả worker, tách hẳn worker, hay giữ sàn rồi cho mượn?
Chọn trước khi đọc tiếp — phần sau phân tích từng cái giá.
3. Hàng ưu tiên có đưa OTP tới trong 15 giây không?
Trên Postgres, hàng ưu tiên chỉ là thêm cột priority (OTP 0, tin giao dịch 1, khuyến mãi 2) và lấy job theo ORDER BY priority, id. OTP vừa vào là đứng đầu hàng.
Nhưng đầu hàng chưa phải là được gửi. Lúc NCC push treo, cả 1.200 worker đang nằm trong lượt gọi push, chỉ được nhả khi NCC trả lời hoặc hết timeout. Có hai chỗ xếp hàng: hàng job và hàng chờ worker. Ưu tiên chỉ sắp lại chỗ thứ nhất.

Với timeout 2 giây, A vẫn đạt hạn: OTP chờ tối đa khoảng 2 giây, cộng 0,15 giây gửi SMS. Worker đã nhận OTP thì xong sau 0,15 giây rồi lấy OTP kế tiếp, nên OTP chỉ cần khoảng 30 worker.
Cái giá nằm ở điều kiện vừa dùng: hạn của OTP giờ buộc vào một tham số của luồng khác. Ai đó nâng timeout push lên 30 giây cho đỡ lượt mập mờ là OTP có thể chờ 30 giây, mà không ai đụng vào code OTP.
Consumer đọc một partition Kafka theo offset tăng dần, trong partition không có ưu tiên. Luồng gấp muốn khỏi đứng sau luồng thường thì phải ở topic khác.
Vậy A chỉ tồn tại khi hàng job cho lấy job không theo thứ tự chèn: bảng Postgres làm được, topic Kafka thì không.
4. Giữ riêng worker: vách ngăn cũng là trần
Phương án B là mẫu bulkhead (vách ngăn), tên lấy từ vách chia khoang thân tàu: thủng một khoang thì chỉ khoang đó ngập. Sáu mươi worker (gấp đôi 30 worker OTP cần) chỉ nhận OTP, push treo bao lâu cũng không chạm tới. Lúc bình thường giá rẻ: 1.140 worker còn lại xả 7.600 lượt mỗi giây, đủ cho phần còn lại.
Cái giá thật lộ ra khi chính NCC SMS chậm. Mỗi lượt OTP giữ worker 2 giây thì OTP cần 200 × 2 = 400 worker, mà 60 worker chỉ xả 30 OTP mỗi giây. Sau 10 giây, 1.700 OTP dồn lại, OTP mới chờ khoảng 57 giây, trong khi 1.140 worker bên kia rảnh.

Phương án C sửa đúng chỗ đó. Sáu mươi worker vẫn chỉ nhận OTP, đó là sàn; 1.140 worker chung lấy job theo ORDER BY priority, id, nên SMS chậm thì OTP mượn thêm 340 worker chung. Cái giá là hai kiểu worker phải đo riêng, và OTP chạy trên worker mượn chịu đúng cảnh của A nếu push treo cùng lúc.
5. Tự đo: OTP chờ bao lâu dưới mỗi phương án?
Lab thu nhỏ: 20 worker, giữ 6, OTP 10 lượt mỗi giây qua SMS 300 ms (cần 3 worker), lô 20.000 job. Tạo database lab riêng, chạy setup.sql:
DROP TABLE IF EXISTS jobs;
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
kind text NOT NULL, -- otp | promo
priority smallint NOT NULL, -- 0 = otp, 2 = promo
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
picked_at timestamptz
);
CREATE INDEX ON jobs (priority, id) WHERE status = 'pending';
INSERT INTO jobs (kind, priority)
SELECT 'promo', 2 FROM generate_series(1, 20000);
worker.sql dùng lại vòng nhận job SKIP LOCKED của bài 02, thêm lo, hi để chọn mức ưu tiên worker được nhận; \sleep giả lượt gọi NCC.
WITH c AS (
UPDATE jobs SET status = 'sending', picked_at = clock_timestamp()
WHERE id = (SELECT id FROM jobs
WHERE status = 'pending' AND priority BETWEEN :lo AND :hi
ORDER BY priority, id LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING id, priority)
SELECT coalesce(max(id), 0) AS job_id,
coalesce(max(priority), 9) AS p FROM c \gset
\if :p = 0
\sleep :sms_ms ms
\elif :p = 2
\sleep :push_ms ms
\else
\sleep 20 ms
\endif
UPDATE jobs SET status = 'sent' WHERE id = :job_id;
Bản FIFO chỉ đổi thành ORDER BY id; otp_in.sql là một dòng INSERT INTO jobs (kind, priority) VALUES ('otp', 0);. Push treo, phương án A:
pgbench -n -c 20 -j 20 -T 25 -f worker.sql -D lo=0 -D hi=2 \
-D push_ms=3000 -D sms_ms=300 lab &
sleep 3; pgbench -n -c 1 -R 10 -T 15 -f otp_in.sql lab; wait
B chạy song song 14 worker -D lo=1 -D hi=2 (không nhận OTP) và 6 worker -D lo=0 -D hi=0; C đổi 14 worker kia thành -D lo=0 -D hi=2. SMS chậm: -D push_ms=50 -D sms_ms=1500. Đo p50 và max của picked_at - created_at trên job otp.
Push treo 3 giây: OTP dưới A chờ lâu nhất bao nhiêu? SMS chậm 1,5 giây: 6 worker của B xả bao nhiêu OTP mỗi giây, so với 10 OTP đến? Ghi ra trước khi chạy.
Số tác giả đo trên Apple M4, Postgres 16.14 trong Docker, ba lần chạy mỗi ô (p50 / max, giây):
| Kịch bản | A: ưu tiên, pool chung | B: giữ riêng 6 | C: sàn 6 + mượn |
|---|---|---|---|
| Push treo 3 giây | 0,37–0,71 / 2,82–2,93 | 0,01–0,02 / 0,18–0,36 | 0,01 / 0,03–0,15 |
| SMS chậm 1,5 giây | 0,03–0,04 / 0,28–0,65 | 5,3–6,3 / 11,1–13,1, còn 41–71 OTP chưa tới lượt | 0,01–0,02 / 0,33–0,63 |
FIFO cả ba lần cho 0 OTP tới lượt trong 25 giây. Máy bạn ra số khác; giữ hình dạng: A kẹt vì luồng khác giữ worker lâu (max xấp xỉ 3 giây), B kẹt vì chỉ có 6 worker (xả 4 OTP mỗi giây, 10 OTP đến).
6. Chọn theo kịch bản nào làm bạn mất hạn
Ở quy mô thật (1.200 worker, sàn 60, OTP 200 mỗi giây):
| Kịch bản | A: ưu tiên | B: giữ riêng 60 | C: sàn 60 + mượn |
|---|---|---|---|
| Push chậm, mỗi lượt giữ worker 2 giây | Đạt, chờ tối đa ~2 giây | Đạt | Đạt |
| SMS chậm, mỗi lượt OTP giữ worker 2 giây | Đạt, mượn được 400 worker | Trượt, xả 30 trên 200 OTP/giây | Đạt, sàn 60 + mượn 340 |
| Timeout push nâng lên 30 giây, push treo | Trượt, chờ tới ~30 giây | Đạt | Đạt, trừ phần OTP vượt sàn |
Không cột nào đạt mọi dòng mà không tốn gì. Chọn theo kịch bản có thể xảy ra với hệ của bạn và cái bạn chịu trả: một tham số timeout phải canh (A), một trần cứng cho OTP (B), hay hai kiểu worker phải vận hành (C).
❌ Nhầm 1: OTP trễ thì thêm worker vào pool chung, giữ FIFO.
-- FIFO: an OTP waits behind every earlier promo job
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
✅ Gấp đôi lên 2.400 worker thì OTP sau lô 500.000 vẫn chờ khoảng 31 giây, gấp đôi hạn. Thêm worker chia nhỏ thời gian chờ chứ không đổi thứ tự. Thứ cần đổi là câu lấy job:
-- priority first: an OTP jumps to the head of the queue
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY priority, id LIMIT 1 FOR UPDATE SKIP LOCKED;
❌ Nhầm 2: thử lại tin giao dịch ngay trong worker, ngủ backoff (khoảng chờ tăng dần giữa các lượt thử) 1, 2, 4 giây. Khi push treo, worker bị giữ 2 + 1 + 2 + 2 + 2 + 4 + 2 = 15 giây, OTP dưới A có thể chờ ngần ấy.
# the worker keeps its slot while it sleeps
for wait in (1, 2, 4):
call provider (up to 2 s)
on timeout: sleep(wait)
Bản đúng trả job về hàng thay vì ngủ:
-- free the worker now, retry later through the queue
UPDATE jobs SET status = 'pending',
run_after = clock_timestamp() + interval '2 seconds'
WHERE id = :job_id;
-- the pick query adds: AND run_after <= clock_timestamp()
✅ Trả job về hàng kèm thời điểm chạy lại (cột run_after), worker nhả ngay; một lượt giữ worker quay về tối đa một timeout.
7. Đào sâu
- Bulkhead pattern, Azure Architecture Center: chia pool theo từng dịch vụ được gọi, và khi nào không nên dùng.
- RabbitMQ: Priority Queue Support: tin đã giao cho consumer không được sắp lại.
- pgbench:
\gset,\if,\sleep :var msvà-Ddùng trong lab.
Liên hệ các bài khác
- Bài 01: Ràng buộc và ước lượng: nguồn của hạn 15 giây và tải đỉnh.
- Bài 02: Gửi rồi đánh dấu: vòng nhận job
SKIP LOCKEDmàworker.sqlở mục 5 dùng lại. - Bài 03: Mất hay trùng theo loại: OTP và tin giao dịch có lượt thử lại; Nhầm 2 nói lượt đó phải đi qua hàng.
- Bài 04: Khoá chống trùng: lượt thử lại tăng khi nhà cung cấp chậm, và phần trùng không chặn được cũng tăng theo.
Đọc thêm: Fan-out chạy trễ, thêm worker cho hàng đợi cũng lợi giảm dần.
Tóm tắt
- OTP chung hàng FIFO với khuyến mãi thì chờ bằng số job phía trước chia tốc độ xả: 62,5 giây lúc NCC khoẻ.
- Dùng hàng ưu tiên khi thời gian dài nhất một lượt gọi giữ worker nhỏ hơn hạn OTP, và nhớ rằng tham số đó thuộc luồng khác.
- Dùng giữ riêng worker khi cần chặn hẳn luồng khác, và định cỡ bằng định luật Little ở kịch bản NCC của OTP chậm.
- Giữ sàn rồi cho mượn đạt cả hai kịch bản, đổi lại hai kiểu worker phải đo và vận hành riêng.
Tự kiểm tra
- Q1Đội vận hành nâng timeout gọi NCC push lên 30 giây để giảm lượt mập mờ. Giữa đợt khuyến mãi, push treo. Phương án nào còn giữ OTP trong 15 giây, và bạn phải canh thêm điều gì? Tự quyết trước khi mở đáp án.
- Q2Tin giao dịch (1.000 lượt mỗi giây, hạn 1 phút) cũng đi qua NCC push. Khi push chậm, mỗi lượt giữ worker 2 giây. Cách ly theo loại có cứu được tin giao dịch không?
- Q3Hàng job chuyển sang Kafka: một topic notifications, 12 partition, mọi loại tin chung topic. Vì sao A không áp được, và bạn thiết kế lại thế nào?
- Q4NCC SMS đổi hạ tầng, độ trễ trung bình lên 300 ms và giữ ở đó. Sàn 60 worker của B còn đủ không? Vì sao chạy sát số worker tính đúng theo Little lại nguy hiểm?
- Q5Trong lab, B với SMS 1,5 giây cho p50 lên 5 đến 6 giây và còn hàng chục OTP chưa tới lượt. Vì sao, và vì sao chạy lâu hơn thì p50 còn tăng?
Bài tiếp theo: Đối chiếu thực tế
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Khoá chống trùng: chặn được trùng nào, để lọt trùng nào?
Bài tiếpĐối chiếu thực tế: các hệ thống thông báo thật chống trùng thế nào?
Thảo luận & góp ý
Hỏi đáp về bài này