Thông báo: không mất, không gửi trùng
6/8
Bài 6 / 8~13 phútKhông mất, không gửi trùngMiễn phí… lượt xem

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ámGử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ứuOTP, hạn 15 giây
Máy xét nghiệm chậmNhà 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ứuGiữ riêng worker cho OTP (phương án B)
Bác sĩ trực, đông thì gọi khoa khácGiữ 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.

Thang log thời gian OTP chờ worker sau lô 500.000 job: hạn 15 giây; hàng ưu tiên khi push chậm tối đa khoảng 2 giây; FIFO gấp đôi worker khoảng 31 giây; FIFO khi NCC khoẻ 62,5 giây; FIFO khi push chậm khoảng 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 đã.

Điểm quyết định

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

Nền: bảng ràng buộc của case

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.

Trục thời gian: lúc 0 giây push bắt đầu treo; lúc 0,05 giây cả 1.200 worker kẹt trong lượt push; lúc 0,3 giây OTP lên đầu hàng nhưng vẫn chờ; lúc 2 giây hết timeout, worker được nhả và nhận OTP ngay; lúc 2,15 giây SMS gửi xong

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.

Hai khung so sánh khi NCC SMS chậm, mỗi lượt OTP giữ worker 2 giây ở đỉnh 200 OTP mỗi giây: bên giữ riêng 60 worker chỉ xả 30 OTP mỗi giây, hàng OTP dài thêm 170 tin mỗi giây; bên giữ sàn 60 và cho mượn thì mượn thêm 340 từ 1.140 worker chung, xả đủ 200 mỗi giây và OTP kịp hạn

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:

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.

SQL
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:

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

Thử đoán trước

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ảnA: ưu tiên, pool chungB: giữ riêng 6C: sàn 6 + mượn
Push treo 3 giây0,37–0,71 / 2,82–2,930,01–0,02 / 0,18–0,360,01 / 0,03–0,15
SMS chậm 1,5 giây0,03–0,04 / 0,28–0,655,3–6,3 / 11,1–13,1, còn 41–71 OTP chưa tới lượt0,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ảnA: ưu tiênB: giữ riêng 60C: 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 workerTrượ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 treoTrượ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.

SQL
-- 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:

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

Plain Text
# 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ủ:

SQL
-- 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

📚 Đào sâu (optional)

Liên hệ các bài khác

Đọ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

Tự kiểm tra
0/5 câu đã trả lời
  1. 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.
  2. Q2
    Tin 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?
  3. Q3
    Hà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?
  4. Q4
    NCC 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?
  5. Q5
    Trong 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.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Đối chiếu thực tế: các hệ thống thông báo thật chống trùng thế nào?