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

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

Job lặp và tin trùng do timeout là hai nguồn khác nhau. Chọn khoá chống trùng, đo phần trùng bị chặn trên Postgres, chỉ ra phần chỉ nhà cung cấp thu hẹp được.

TL;DR: Tin trùng trong luồng gửi ít nhất một lần đến từ hai chỗ. Job lặp sinh ra trước bảng job (client gọi lại API, consumer xử lý lại sự kiện), và ràng buộc duy nhất trên khoá (người dùng, loại, mã nghiệp vụ) cộng ON CONFLICT DO NOTHING chặn được toàn bộ. Tin trùng do timeout sinh ra sau lời gọi nhà cung cấp nên khoá phía bạn không thấy. Phần đó chỉ thu hẹp được khi nhà cung cấp nhận khoá gửi kèm; với SMS không nhận khoá, khoảng 0,25% tin trùng vẫn còn nguyên.

Bài 03 đã chọn gửi ít nhất một lần cho tin giao dịch: thà trùng còn hơn mất. Cho 100.000 tin giao dịch chạy qua vòng gửi của bài 02, người dùng nhận về 103.202 tin. Gắn một khoá chống trùng là xong? Tuỳ khoá: khoá sai còn nuốt luôn tin thật.

1. Hai loại tin trùng, hai chỗ sinh ra

Hai nhân viên cùng nhập một đơn vào sổ thì sổ chặn được bằng mã đơn: dòng thứ hai bị từ chối. Gọi điện báo khách mà máy rớt giữa chừng thì sổ bó tay: không biết khách đã nghe hay chưa, sổ chỉ thấy "chưa có xác nhận", và gọi lại có thể làm khách nghe hai lần. Chỉ khi khách nhớ đã được báo đơn đó rồi thì mới hết trùng.

Sổ đơn và cuộc gọi báo kháchLuồng gửi thông báo
Sổ đơn, mã đơn không được lặpbảng job trong Postgres, có ràng buộc duy nhất
Hai nhân viên cùng nhập một đơnjob lặp: client gọi lại API, consumer xử lý lại sự kiện
Cuộc gọi rớt máy giữa chừngworker gặp timeout 2 giây, nhà cung cấp có thể đã gửi
Gọi lại cho chắcworker thử lại sau timeout
Khách nhớ đã được báo đơn nàynhà cung cấp nhận khoá gửi kèm

Những dòng của bảng ràng buộc chuẩn mà bài này dùng:

Ràng buộcGiá trị
Nhà cung cấpclient timeout 2 giây; 0,5% lượt mập mờ ở đỉnh
Giá một tin giao dịch mất20% thành cuộc gọi chăm sóc khách hàng (CSKH) khoảng 15.000đ
Giả định nhà cung cấpSMS không nhận khoá chống trùng; push có khoá gộp phía thiết bị
Nguồn sinh job lặpAPI bị client gọi lại; consumer xử lý lại sau crash

Job lặp tới trước bảng job: hai hàng cùng mô tả một tin. Trùng do timeout sinh ra sau: bảng chỉ có một job, nhưng job đó được gửi hai lần, vì trong lượt timeout có những lượt nhà cung cấp đã gửi rồi (bài 03 giả định một nửa).

Sơ đồ tuần tự bốn bên: nguồn sự kiện gửi job lần 1 và job lặp vào bảng job có khoá chống trùng, ON CONFLICT DO NOTHING làm job lặp chèn 0 hàng; worker nhặt job, gửi lần 1 tới nhà cung cấp, quá 2 giây không có phản hồi dù nhà cung cấp đã gửi, worker gửi lại và tin thứ hai tới máy mà bảng job không thấy

Mỗi chỗ chỉ thấy một nửa: bảng job thấy mọi yêu cầu đi vào nhưng mù với mạng của nhà cung cấp, còn nhà cung cấp thấy mọi lượt gửi nhưng không biết hai yêu cầu khác id là cùng một tin nghiệp vụ.

2. Ba ứng viên khoá: mỗi khoá chặn được job lặp nào?

Khoá chống trùng là tổ hợp cột có ràng buộc duy nhất trên bảng job: yêu cầu sau cùng khoá thì chèn 0 hàng.

Nhớ lại — UPSERT và ON CONFLICT

INSERT ... ON CONFLICT DO NOTHING đóng được cửa sổ race mà cặp SELECT-rồi-INSERT để hở: phát hiện trùng và ghi nằm trong một câu lệnh, dựa trên UNIQUE hoặc PRIMARY KEY. Ở đây bạn chỉ phải chọn cột nào làm khoá.

Điểm quyết định

Ràng buộc: Tin giao dịch, gửi ít nhất một lần · job lặp đến từ client gọi lại API và consumer xử lý lại sự kiện · nhà cung cấp timeout 0,5% lượt gọi

Với ràng buộc này, 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: UPSERT và ON CONFLICT

Mỗi khoá định nghĩa "cùng một tin" bằng thứ khác. Khoá A tin vào id do người gửi sinh, nên chỉ nhận ra trùng khi id được tái dùng. Khoá B tin vào nghĩa nghiệp vụ: "người dùng 812 nhận xác nhận đơn 55.001" chỉ tồn tại một lần, bất kể yêu cầu đi đường nào. Khoá C đoán từ bề ngoài, và đoán sai thì đắt: người chuyển 50.000đ hai lần trong một phút cho hai người bạn đáng nhận hai tin.

3. Đo trên Postgres: mỗi khoá chặn được bao nhiêu?

Lab tái hiện một giờ tin giao dịch, với giả định riêng của lab: 100.000 tin thật, trong đó 300 tin là giao dịch "sinh đôi" (cùng người, cùng số tiền, cách một phút); 2% sự kiện bị consumer xử lý lại, trễ tới 30 phút; 1% bị client gọi lại API sau 3 giây.

SQL
SELECT setseed(0.42);
CREATE TABLE msg AS
SELECT g AS order_id, (random() * 500000)::int AS user_id,
       (10 + (random() * 490)::int) * 1000 AS amount,
       timestamptz '2026-10-17 20:00+07'
         + random() * interval '1 hour' AS at
FROM generate_series(1, 99700) g;
INSERT INTO msg  -- 300 legit twins, one minute later
SELECT 99700 + order_id, user_id, amount, at + interval '1 minute'
FROM msg WHERE order_id <= 300;
SQL
CREATE TABLE req AS
SELECT gen_random_uuid() AS event_id, order_id, user_id, at,
       format('Ban vua chuyen %s d', amount) AS body
FROM msg;
INSERT INTO req  -- replay keeps event_id; API retry gets a new one
SELECT event_id, order_id, user_id,
       at + random() * interval '30 minutes', body
FROM req WHERE random() < 0.02
UNION ALL
SELECT gen_random_uuid(), order_id, user_id,
       at + interval '3 seconds', body
FROM req WHERE random() < 0.01;

Mỗi khoá là một bảng job riêng. Khoá C dùng ô 10 phút cố định, vì ràng buộc duy nhất không biểu diễn được cửa sổ trượt.

SQL
CREATE TABLE jobs_a (event_id uuid PRIMARY KEY, order_id int);
CREATE TABLE jobs_b (user_id int, kind text, order_id int,
                     PRIMARY KEY (user_id, kind, order_id));
CREATE TABLE jobs_c (user_id int, body_hash text, bucket bigint,
                     order_id int,
                     PRIMARY KEY (user_id, body_hash, bucket));
INSERT INTO jobs_a SELECT event_id, order_id FROM req ORDER BY at
ON CONFLICT DO NOTHING;
INSERT INTO jobs_b SELECT user_id, 'giao_dich', order_id FROM req
ORDER BY at ON CONFLICT DO NOTHING;
INSERT INTO jobs_c
SELECT user_id, md5(body),
       floor(extract(epoch FROM at) / 600)::bigint, order_id
FROM req ORDER BY at ON CONFLICT DO NOTHING;
Thử đoán trước

Với khoá B, mọi job lặp bị chặn. Với 0,5% lượt timeout và một nửa trong đó nhà cung cấp đã gửi, người dùng còn nhận khoảng bao nhiêu tin trùng trên 100.000 tin? Ghi con số trước khi xem bảng.

Câu đo giả lập: mỗi job gửi một lần, thêm một lần khi timeout mà nhà cung cấp đã gửi.

SQL
WITH j AS (
  SELECT 'none' AS k, order_id FROM req
  UNION ALL SELECT 'A', order_id FROM jobs_a
  UNION ALL SELECT 'B', order_id FROM jobs_b
  UNION ALL SELECT 'C', order_id FROM jobs_c
), sent AS (
  SELECT k, order_id,
         1 + (random() < 0.005 AND random() < 0.5)::int AS n
  FROM j
)
SELECT k, count(*) - count(DISTINCT order_id) AS job_dups,
       sum(n) - count(*) AS timeout_dups,
       100000 - count(DISTINCT order_id) AS lost
FROM sent GROUP BY k ORDER BY k;

Lần chạy của tác giả trên Postgres 16.14, setseed(0.42) (hai seed khác chênh vài chục đơn vị):

KhoáJob lặp còn lọtTrùng do timeoutTin thật bị nuốt
Không khoá2.9832190
A · event_id1.0152580
B · người, loại, mã02620
C · băm nội dung1.617265264

Đáp án: 0,5% × 50% × 100.000 = 250 tin. Cột "trùng do timeout" dao động 219–265 vì mỗi dòng bốc thăm riêng; nó không đổi theo khoá.

Khoá A chặn 1.968 trên 2.983 job lặp, khoảng 66%: đúng phần consumer chạy lại. Khoá C chỉ chặn khoảng 46%, vì sự kiện chạy lại trễ hơn 10 phút rơi sang ô khác, mà còn nuốt 264 trên 300 tin sinh đôi: khoảng 53 cuộc gọi CSKH mỗi 100.000 tin.

Thang tuyến tính số tin trùng tới máy trên 100.000 tin giao dịch: không khoá 3.202, khoá id sự kiện 1.273, khoá băm nội dung 1.882 và nuốt 264 tin thật, khoá người dùng loại mã 262 là phần trùng do timeout còn lại

Khoá B kéo tổng tin trùng từ 3.202 xuống 262; phần còn lại sinh ra ở chỗ khoá không nhìn tới.

4. Vì sao phần trùng do timeout chỉ thu hẹp được ở nhà cung cấp?

Khi timeout, bảng job vẫn một hàng, trạng thái vẫn "đang gửi". Thêm ràng buộc nào cũng không đổi được, vì thông tin "đã gửi" nằm trong hệ thống của người khác.

Idempotency key: thao tác mang một khoá duy nhất, bên nhận nhớ khoá đã xử lý và lần thứ hai trả kết quả cũ thay vì làm lại. Điều kiện ngầm là bên nhận chịu nhớ khoá, mà ở đây bên nhận là nhà cung cấp.

Nên phần trùng do timeout thu hẹp được tới đâu là do từng kênh quyết định:

Loại kênhKhoá gửi kèmPhần trùng do timeout
Kênh nhận khoá gửi kèm (API cho bạn gửi một khoá, và nhà cung cấp nhớ khoá một khoảng thời gian)khoá do bạn sinh, gắn vào từng lượt gọicùng khoá, cùng payload trong thời gian họ nhớ thì không gửi lần hai
Kênh chỉ gộp hiển thị (push có khoá gộp)khoá gộp, xử lý ở phía thiết bịvẫn có thể tới hai lần, hiển thị thành một hoặc chỉ thay tin chưa tới máy
Kênh không có gì (SMS, giả định của case)không cógiữ nguyên khoảng 0,25%

Thời gian nhớ khoá và mã lỗi khi payload lệch khác nhau theo từng nhà cung cấp; số liệu thật nằm ở bài 06. SMS thì chịu: luồng SMS chạy ít nhất một lần thì 0,25% tin trùng, mỗi tin 700đ, là giá đã chọn ở bài 03.

Tự điền trước khi xem

Worker gọi API của một kênh nhận khoá gửi kèm cho một job trong jobs_b. Điền header để lượt gửi lại sau timeout, có thể trên worker khác, bị nhà cung cấp nhận ra là trùng:

Plain Text
POST /messages
Idempotency-Key: ______

Lời giải: khoá sinh từ job, không từ lượt gọi, ví dụ Idempotency-Key: giao_dich:812:55001. Chuỗi kind:user_id:order_id chính là khoá B, nên worker nào gửi lần thứ mấy cũng mang cùng khoá. UUID mới mỗi lượt thì nhà cung cấp gửi cả hai. Payload cũng phải y hệt vì nhà cung cấp thường từ chối cùng khoá khác payload, nên render nội dung một lần và lưu vào job.

5. Giữ khoá bao lâu, và tốn bao nhiêu?

Khoá chỉ chặn được khi hàng cũ còn trong bảng; xoá sớm thì sự kiện chạy lại muộn lọt qua. Consumer lag (thời gian một sự kiện nằm chờ trước khi được xử lý) sau sự cố tính bằng giờ, nên giữ khoá tính bằng ngày. Dung lượng thì rẻ: index khoá chính của 100.000 hàng jobs_b khoảng 4,9 MB, cỡ 50 MB mỗi triệu tin.

Dọn bằng partition theo ngày thì Postgres 16 báo unique constraint on partitioned table must include all partitioning columns. Thêm created_on vào khoá thì chạy được, nhưng sự kiện chạy lại vắt qua nửa đêm sẽ lọt. Hoặc giữ khoá trong bảng thường và xoá theo lô, hoặc partition và gọi tên phần lọt đó.

6. Bẫy hay gặp

❌ Kiểm trùng bằng SELECT rồi mới INSERT:

SQL
SELECT 1 FROM jobs_b
WHERE user_id = 812 AND kind = 'giao_dich' AND order_id = 55001;
-- 0 rows -> INSERT; two replaying consumers can both see 0 rows

✅ Một câu INSERT ... ON CONFLICT DO NOTHING trên ràng buộc duy nhất; hai consumer cùng chạy lại thì một bên chèn 0 hàng:

SQL
INSERT INTO jobs_b (user_id, kind, order_id)
VALUES (812, 'giao_dich', 55001)
ON CONFLICT DO NOTHING;
-- 0 rows inserted: the replay is dropped, no race window

❌ Sinh idempotency key mới cho mỗi lượt gọi nhà cung cấp: toàn bộ 0,25% trùng do timeout vẫn tới máy. ✅ Dẫn khoá từ job (Idempotency-Key: giao_dich:812:55001), như mục 4.

❌ Băm nội dung cho tin giao dịch "cho khỏi truyền mã đơn": nuốt 264 giao dịch thật, vẫn lọt 1.617 job lặp. ✅ Chỉ đáng cân nhắc khi tin không có mã nghiệp vụ và giá mất gần bằng 0.

📚 Đào sâu

Tài liệu chính thức:

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

Tóm tắt

  • Thấy tin trùng thì hỏi trước: nó sinh ra trước bảng job hay sau lời gọi nhà cung cấp? Hai nguồn cần hai cách chặn.
  • Chọn khoá theo nghĩa nghiệp vụ, không theo id người gửi sinh hay bề ngoài nội dung.
  • Kênh nào nhận khoá gửi kèm thì dẫn khoá từ job; phần trùng của kênh không nhận khoá thì ghi vào giá.
  • Giữ khoá lâu hơn độ trễ tối đa của job lặp, kể cả consumer lag sau sự cố.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao khoá event_id chặn được job lặp do consumer xử lý lại nhưng không chặn được job lặp do client gọi lại API?
  2. Q2
    Với khoá (người dùng, loại, mã nghiệp vụ), người dùng vẫn nhận khoảng 250 tin trùng trên 100.000 tin. Vì sao đổi khoá hay đổi cách chèn không làm con số này giảm?
  3. Q3
    Đồng nghiệp đề xuất băm nội dung trong cửa sổ 10 phút cho tin giao dịch vì khỏi phải truyền mã đơn hàng. Bạn phản biện bằng cái giá nào?
  4. Q4
    Worker gửi email với Idempotency-Key là UUID sinh ngay trước mỗi lượt gọi, và tỉ lệ trùng không giảm. Giải thích cơ chế và cách sửa.
  5. Q5
    Tình huống mới: thêm tin nhắc thanh toán, gửi lần 1 khi đơn quá 1 giờ chưa trả và lần 2 khi quá 24 giờ, qua push và SMS. Thiết kế khoá chống trùng và nói rõ phần trùng nào còn lại. Ghi đáp án của bạn trước khi mở.

Bài tiếp theo: Cách ly luồng gửi: OTP không kẹt sau năm triệu tin khuyến mãi

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

Cách ly luồng gửi: OTP không kẹt sau năm triệu tin khuyến mãi