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

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.
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:
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:
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ại | Một tin mất tốn gì | Giá mất | Mộ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ỏi | 600 đ |
| 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ề sau | 200 đ |
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.
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ại | At-most-once: 2.500 tin mất | At-least-once: 2.500 tin trùng | Rẻ hơn |
|---|---|---|---|
| OTP | 2.500 × 5.000 = 12,5 triệu đ | 2.500 × 700 = 1,75 triệu đ | at-least-once |
| Giao dịch | 2.500 × 3.000 = 7,5 triệu đ | 2.500 × 600 = 1,5 triệu đ | at-least-once |
| Khuyến mãi | 2.500 × 20 = 50 nghìn đ | 2.500 × 200 = 500 nghìn đ | at-most-once |

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:
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ử:
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.
-- 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:
-- 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:
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
- Apache Kafka Design: Message Delivery Semantics: ba mức giao nhận, và vì sao exactly-once sang hệ thống ngoài cần bên đó hợp tác.
- AWS Builders' Library: Making retries safe with idempotent APIs: thử lại an toàn nhờ mã yêu cầu do client gửi kèm, hướng bài 04 đi tiếp.
Liên hệ các bài khác
- Bài 02: Gửi rồi đánh dấu: nguồn của hai loại sự cố mà bài này định giá.
- Bài 04: Khoá chống trùng: đã chọn at-least-once thì bước tiếp là chặn phần trùng phía bạn và thu hẹp phần trùng phía NCC.
- Bài 05: Cách ly luồng gửi: vì sao lượt thử lại của luồng khuyến mãi không được làm OTP trễ hạn.
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í
COMMITso 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
- Q1SMS 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.
- Q2DLR 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.
- Q3Worker 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?
- Q4Hệ 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?
- Q5Tin 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.
Gửi rồi đánh dấu: vòng gửi vừa gửi trùng vừa đánh rơi tin
Bài tiếpKhoá chống trùng: chặn được trùng nào, để lọt trùng nào?
Thảo luận & góp ý
Hỏi đáp về bài này