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

Mất hay trùng: chọn mức giao nhận cho từng loại thông báo

Đánh dấu trước thì có thể mất tin, gửi trước thì có thể trùng tin. Với OTP, tin giao dịch và tin khuyến mãi, tự tính tỉ lệ nhân giá rồi chọn mức giao nhận cho từng loại.

TL;DR: Khi lượt gọi nhà cung cấp SMS hay push bị timeout, bạn không biết tin đã đi hay chưa, và nhà cung cấp không chống trùng nên không có "gửi đúng một lần". Bạn chỉ chọn được ai trả giá. Gửi tối đa một lần (at-most-once: đánh dấu trước, không thử lại) biến phần mập mờ thành tin mất; gửi ít nhất một lần (at-least-once: gửi trước, timeout thì thử lại) biến nó thành tin trùng. Cách chọn là so tỉ lệ mất nhân giá một tin mất với tỉ lệ trùng nhân giá một tin trùng, riêng cho từng loại. Bài này cho bạn tự tính phép so đó với giá tự đặt cho OTP, tin giao dịch và tin khuyến mãi.

Tối thứ Sáu, 5 triệu tin khuyến mãi dồn vào 15 phút, và cùng lúc ấy vẫn có người đang chờ mã OTP, hạn 15 giây.

Bài 02 đã chỉ ra vòng gửi đơn giản vừa trùng vừa mất. Với nhà cung cấp ngoài, bạn không xoá được cả hai; bạn chọn cho từng loại thông báo chịu mất hay chịu trùng, bằng phép nhân chứ không bằng cảm giác.

1. Vì sao không chọn được "gửi đúng một lần"?

0,5% × 5 triệu tin = 25.000 lượt mập mờ, và mỗi lượt worker phải chọn sai về phía mất hoặc phía trùng. Không có lựa chọn thứ ba "gửi đúng một lần", vì câu trả lời của NCC nằm ngoài tầm với của worker.

Nhà cung cấp (NCC) là bên ngoài nhận lệnh gửi SMS hoặc push từ hệ thống của bạn. Timeout nghĩa là worker chờ phản hồi quá hạn (2 giây, theo bảng ràng buộc bài 01) rồi bỏ cuộc. Chỉ lỗi chắc chắn xảy ra trước khi NCC nhận lệnh (không phân giải được tên miền, không mở được kết nối, 4xx từ chối rõ ràng) mới an toàn để thử lại. Timeout và lỗi ngắt giữa chừng, như kết nối bị reset sau khi gửi request hay 502, 504 từ gateway đứng trước NCC, đều mập mờ vì NCC có thể đã xử lý.

Tài liệu thiết kế Kafka đặt tên ba mức giao nhận (delivery semantics): at-most-once là tin có thể mất nhưng không bao giờ gửi lại; at-least-once là tin không bao giờ mất nhưng có thể gửi lại; exactly-once là mỗi tin được xử lý đúng một lần.

Nhớ lại — Exactly-once trong Kafka

Exactly-once của Kafka dựa vào hai thứ trong cùng hệ thống: broker loại bản trùng theo producer ID và sequence number, còn transaction gói việc ghi output với commit offset thành một khối nguyên tử.

Tài liệu Kafka cũng nói exactly-once sang hệ thống ngoài cần chính hệ thống đó hợp tác. NCC SMS không nằm trong transaction của bạn, cũng không nhận khoá chống trùng. Vậy câu hỏi thật là: với từng loại tin, bạn chịu mất hay chịu trùng?

2. Hai mức giao nhận khác nhau ở đâu trong vòng gửi?

Khác đúng một chỗ: COMMIT đánh dấu job đứng trước hay sau lượt gọi NCC, nên vị trí COMMIT quyết định phía sai.

Vòng ngây thơ nhận job, gọi NCC, rồi mới đánh dấu xong. Timeout rồi thử lại sinh tin trùng; job kẹt ở trạng thái đang gửi hoặc hết lượt thử lại sinh tin mất.

Vòng ở bài 02 vốn đã là at-least-once, chỉ chưa ai gọi tên. Đảo thứ tự là ra at-most-once:

SQL
-- at-most-once: danh dau truoc, commit, roi moi goi NCC
BEGIN;
UPDATE notif_job SET status = 'sent'
WHERE id = :job_id AND status = 'queued';
COMMIT;
-- goi NCC o day; timeout thi bo qua, khong thu lai
Thử đoán

Giả sử trong 100.000 lượt gọi có 0,5% timeout, và trong số đó NCC đã gửi với xác suất 50%. At-most-once mất bao nhiêu tin, at-least-once gửi trùng bao nhiêu tin? Viết hai con số trước khi đọc tiếp.

Đặt 500 lượt timeout vào hai vòng này. Mỗi lượt, NCC hoặc đã đẩy tin đi, hoặc chưa; giả định nửa nọ nửa kia.

Hai khung cạnh nhau cho khoảng 500 lượt timeout mập mờ trên 100.000 lượt gọi. Khung đánh dấu trước, không thử lại: khoảng 250 lượt NCC đã gửi thì người dùng nhận đúng một lần, khoảng 250 lượt NCC chưa gửi thì mất. Khung gửi trước, timeout thì thử lại: khoảng 250 lượt NCC đã gửi thành tin trùng, khoảng 250 lượt NCC chưa gửi được thử lại và nhận một lần

At-least-once cứu nửa "NCC chưa gửi" bằng lượt thử lại, nhưng chính lượt thử lại ấy biến nửa "NCC đã gửi" thành tin trùng. Worker chết cũng chia đôi đúng kiểu đó: chết sau COMMIT mà chưa gọi NCC thì job đã sent, không ai gửi lại; chết sau khi NCC trả 200 mà chưa COMMIT thì job vẫn queued, worker khác nhặt lại và gửi lần hai.

3. Tự đo: bao nhiêu tin mất, bao nhiêu tin trùng?

Mô phỏng trên Postgres: 100.000 job, mỗi lượt gọi timeout với xác suất 0,5%, và trong số đó NCC đã gửi với xác suất 50%. At-least-once thử tối đa ba lượt: ba lượt timeout 2 giây là 6 giây, còn chừa chỗ cho backoff (khoảng chờ tăng dần giữa các lượt thử) trong hạn 15 giây của OTP.

Bạn vừa tự tính hai con số ở mục 2; giờ chạy để so với chúng.

SQL
SELECT setseed(0.42);
CREATE TEMP TABLE tries AS
SELECT j AS job, a AS attempt,
       random() < 0.005 AS timed_out,
       random() < 0.5   AS sent_anyway
FROM generate_series(1, 100000) j, generate_series(1, 3) a;

Mỗi lượt có kết cục giao tin hay không, kèm cờ cho biết mọi lượt trước đó đã timeout hết chưa:

SQL
CREATE TEMP TABLE t AS
SELECT job, attempt,
       NOT timed_out OR sent_anyway AS delivered,
       bool_and(timed_out) OVER (
         PARTITION BY job ORDER BY attempt
         ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
       ) AS all_prev_timed_out
FROM tries;

Rồi đếm riêng cho từng mức giao nhận:

SQL
WITH amo AS (
  SELECT job, count(*) FILTER (WHERE delivered) AS n
  FROM t WHERE attempt = 1 GROUP BY job
),
alo AS (
  SELECT job, count(*) FILTER (WHERE delivered) AS n
  FROM t WHERE attempt = 1 OR all_prev_timed_out GROUP BY job
)
SELECT muc, count(*) FILTER (WHERE n = 0) AS tin_mat,
            count(*) FILTER (WHERE n > 1) AS tin_trung
FROM (SELECT 'at-most-once' AS muc, n FROM amo
      UNION ALL SELECT 'at-least-once', n FROM alo) x
GROUP BY muc;

all_prev_timed_out đúng khi mọi lượt trước đều timeout, tức worker còn lý do thử lượt này. Trên PostgreSQL 16.14, ba seed 0.42, 0.1, 0.7 cho at-most-once mất 259, 266, 233 tin, không trùng tin nào; at-least-once không mất tin nào, trùng 277, 271, 238 tin.

Cả hai quanh 250, tức 0,25%. Đổi 0.5 thành 0.9 rồi chạy lại: tin mất tụt còn 38 tới 54, tin trùng vọt lên cỡ 450. Tỉ lệ NCC đã gửi trong vùng timeout, gọi là q, quyết định nửa nào to hơn. Bạn ước lượng q từ báo cáo giao nhận (delivery report, DLR: bản tin NCC gửi ngược về cho biết tin đã tới máy chưa), không đoán.

4. Giá một tin mất so với một tin trùng là bao nhiêu?

Tỉ lệ bằng nhau thì quyết định nằm ở giá. Bảng dưới là giá tự đặt cho case, quy ra đồng để so được:

LoạiMột tin mất tốn gìGiá mấtMột tin trùng tốn gìGiá trùng
OTP (SMS)1/10 người bỏ giao dịch, mỗi giao dịch lãi 50.000 đ5.000 đthêm một SMS, cùng mã700 đ
Giao dịch (push)khách không biết bị trừ tiền, 20% người gọi tổng đài, mỗi cuộc 15.000 đ3.000 đthấy "-500.000 đ" hai lần, 4% người gọi tổng đài hỏi600 đ
Khuyến mãi (push)tỉ lệ mua 0,1% nhân lãi 20.000 đ20 đ1% người tắt thông báo, mất 20.000 đ về sau200 đ

Giá kỳ vọng (expected cost) là tỉ lệ sự cố nhân giá mỗi sự cố, tức số tiền trung bình mất trên mỗi lượt gửi.

Điểm quyết định

Ràng buộc: OTP hạn 15 giây · tin giao dịch · 5 triệu tin khuyến mãi trong 15 phút · 0,5% lượt gọi timeout, q = 0,5 · NCC không chống trùng · giá mất/trùng: OTP 5.000/700 đ, giao dịch 3.000/600 đ, khuyến mãi 20/200 đ

Với giá mất và giá trùng của từng loại, bạn chấp nhận cái giá nào?

Chọn trước khi đọc tiếp — phần sau phân tích từng cái giá.

Nền: ba mức giao nhận

5. Tính ra: loại nào chịu mất, loại nào chịu trùng?

Gọi p là tỉ lệ timeout. At-most-once mất p × (1 − q) số tin, at-least-once trùng p × q. Với p = 0,5% và q = 0,5, cả hai là 2.500 sự cố trên mỗi 1 triệu lượt gửi:

LoạiAt-most-once: 2.500 tin mấtAt-least-once: 2.500 tin trùngRẻ hơn
OTP2.500 × 5.000 = 12,5 triệu đ2.500 × 700 = 1,75 triệu đat-least-once
Giao dịch2.500 × 3.000 = 7,5 triệu đ2.500 × 600 = 1,5 triệu đat-least-once
Khuyến mãi2.500 × 20 = 50 nghìn đ2.500 × 200 = 500 nghìn đat-most-once

Thang log giá kỳ vọng trên mỗi 1 triệu lượt gửi: OTP mất 12,5 triệu đồng so với trùng 1,75 triệu; giao dịch mất 7,5 triệu so với trùng 1,5 triệu; khuyến mãi mất 50 nghìn so với trùng 500 nghìn. Thanh cam đánh dấu phía đắt hơn của từng loại: phía mất với OTP và giao dịch, phía trùng với khuyến mãi

Phía đắt đổi chiều ở tin khuyến mãi: một tin đáng 20 đ, còn một lần làm phiền có thể mất luôn người nhận. Gộp lại, chọn at-least-once khi p × q × giá trùng nhỏ hơn p × (1 − q) × giá mất. Chia hai vế cho p:

Plain Text
giá mất / giá trùng  >  q / (1 - q)

p biến mất. Tỉ lệ timeout chỉ quyết định bạn mất bao nhiêu tiền, không quyết định chọn bên nào. Với q = 0,5 vế phải bằng 1, chỉ cần mất đắt hơn trùng. Với q = 0,9 vế phải bằng 9, và loại nào có tỉ số giá dưới 9 sẽ lật. Kết luận chỉ đứng vững bằng q bạn đo được.

OTP còn ràng buộc mà phép nhân chưa thấy: hạn 15 giây. Thử lại sau hạn là trả tiền SMS cho một mã đã chết, nên at-least-once của OTP là "ít nhất một lần trong hạn".

Cấu hình theo loại gói trong một bảng; worker đọc delivery để đặt COMMIT trước hay sau lượt gọi, đọc deadline để biết khi nào thôi thử:

SQL
CREATE TABLE notif_type (
  code      text PRIMARY KEY,
  delivery  text NOT NULL
            CHECK (delivery IN ('at_most_once', 'at_least_once')),
  max_tries int  NOT NULL CHECK (max_tries >= 1),
  deadline  interval            -- NULL: khong co han cung
);
INSERT INTO notif_type VALUES
  ('otp',        'at_least_once', 3, interval '15 seconds'),
  ('giao_dich',  'at_least_once', 5, NULL),
  ('khuyen_mai', 'at_most_once',  1, NULL);

6. Những chỗ hay chọn sai

❌ Nhầm 1: mỗi lượt thử lại OTP sinh một mã mới.

SQL
-- moi lan thu sinh ma moi, ma cu bi vo hieu
UPDATE otp_job
SET code = :new_code, tries = tries + 1
WHERE id = :job_id;

✅ Sinh mã một lần lúc tạo job, worker chỉ đọc:

SQL
-- code is generated once, when the job is created
INSERT INTO otp_job (user_id, code, tries)
VALUES (:uid, :code_once, 0);
-- the worker only reads it; every retry resends the same code
SELECT code FROM otp_job WHERE id = :job_id;

Lý do: lượt đầu timeout nhưng NCC đã gửi mã A, lượt hai gửi mã B và hệ thống chỉ nhận B. Người dùng gõ mã A tới trước và bị báo sai: tin trùng 700 đ thành tin mất 5.000 đ.

❌ Nhầm 2: gửi khuyến mãi at-least-once "cho chắc". Giữa đợt, NCC chậm thì timeout có thể lên 5%: 5 triệu × 5% × 0,5 = 125.000 người nhận trùng, nhân 200 đ là 25 triệu đ một đợt.

✅ Giữ khuyến mãi ở dòng cấu hình tối đa một lần, không thử lại:

SQL
UPDATE notif_type
SET delivery = 'at_most_once', max_tries = 1
WHERE code = 'khuyen_mai';

Tệ hơn tiền là 250.000 lượt thử lại dồn vào NCC đúng lúc nó nghẹt, tranh chỗ với OTP (bài 05).

7. Đào sâu

📚 Đào sâu (optional)

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

Tóm tắt

  • Với NCC không chống trùng, câu hỏi là "loại tin này chịu mất hay chịu trùng".
  • Vị trí COMMIT so với lượt gọi NCC quyết định mức: đánh dấu trước là at-most-once, gửi trước rồi đánh dấu là at-least-once.
  • Khi chọn, so giá mất / giá trùng với q / (1 − q), và đo q từ DLR thay vì đoán.
  • Loại tin có hạn như OTP thì giới hạn lượt thử theo hạn.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    SMS nhắc lịch khám gửi lúc 20 giờ hôm trước. Lỡ hẹn thì trống một suất khám 150.000 đ, và 1 trong 5 người không được nhắc sẽ quên. Một SMS 700 đ. Chọn mức giao nhận, số lượt thử và hạn.
  2. Q2
    DLR cho thấy 90% lượt timeout thực ra đã tới máy người dùng (q = 0,9). Với OTP giá mất 5.000 đ, giá trùng 700 đ, p = 0,5%, kết luận at-least-once còn đứng không? Tính trên 1 triệu lượt.
  3. Q3
    Worker at-most-once vừa COMMIT trạng thái sent cho 200 job thì bị kill trước khi gọi NCC. Worker at-least-once nhận 200 OK cho 200 job khác rồi bị kill trước khi COMMIT. Sau khi chạy lại, mỗi bên mất bao nhiêu, trùng bao nhiêu, vì sao?
  4. Q4
    Hệ thống đọc job từ Kafka bằng transaction và bật enable.idempotence. Đồng nghiệp nói luồng OTP đã exactly-once nên khỏi chọn mất hay trùng. Vì sao chưa đúng?
  5. Q5
    Tin giao dịch có tỉ số giá mất / giá trùng chỉ khoảng 5. Làm sao cho kết luận at-least-once vững hơn mà không đổi mức giao nhận, và cách đó tác động vào vế nào của điều kiện?

Bài tiếp theo: Khoá chống trùng

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

Khoá chống trùng: chặn được trùng nào, để lọt trùng nào?