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

Tổng kết case thông báo: thiết kế hoàn chỉnh và các cái giá

Ráp bảng ràng buộc, phương án ngây thơ và ba quyết định thành một thiết kế gửi thông báo hoàn chỉnh, kèm bảng cái giá bạn đã chấp nhận ở từng bước.

TL;DR: Thiết kế gửi thông báo hoàn chỉnh của case có ba quyết định, mỗi quyết định đứng ở một chặng trên đường đi của một tin. Ở cửa bảng job, ràng buộc duy nhất trên khoá (người dùng, loại, mã nghiệp vụ) chặn job lặp. Ở chỗ worker nhặt job, OTP có sàn 60 worker riêng và được mượn pool chung. Quanh lượt gọi nhà cung cấp, vị trí COMMIT quyết định mức giao nhận: OTP và tin giao dịch gửi ít nhất một lần, tin khuyến mãi gửi tối đa một lần. Phần còn lại không xoá được: khoảng 0,25% lượt gửi vẫn mất hoặc trùng, tuỳ loại, vì nhà cung cấp SMS không chống trùng.

Bạn bắt đầu case bằng hai khiếu nại ngược chiều sau một tối thứ Sáu: "tôi nhận một tin khuyến mãi ba lần" và "tôi không nhận được mã OTP". Sáu bài sau, hai khiếu nại đó đã có số, có nguồn và có giá. Bài này không dạy thêm cơ chế nào. Việc của nó là ráp các mảnh lại, gom cái giá vào một bảng, rồi để bạn tự kiểm xem đổi một ràng buộc thì quyết định nào lật.

1. Ráp thành một thiết kế

Hình dung một bưu cục nhận hàng gửi đi qua hãng xe bên ngoài. Bưu cục kiểm soát được quầy nhận, các làn xếp hàng và cuốn sổ của mình, nhưng không kiểm soát được hãng xe:

Bưu cụcHệ gửi thông báo
Quầy nhận trả lại đơn trùng mã vận đơnKhoá chống trùng ở cửa bảng job
Làn hoả tốc có nhân viên riêng, đông thì gọi người làn thường sang phụSàn 60 worker cho OTP, được mượn pool chung
Ghi "đã giao" vào sổ trước hay sau khi trao hàng cho hãng xeCOMMIT đứng trước hay sau lượt gọi nhà cung cấp
Hãng xe không kiểm mã, bưu cục gửi hai lần là giao hai lầnNhà cung cấp SMS không nhận khoá gửi kèm

Đi theo đường của một tin, từ ngoài vào trong. Yêu cầu gửi tới từ API hoặc từ consumer xử lý sự kiện, và được chèn vào bảng jobs bằng INSERT ... ON CONFLICT DO NOTHING trên khoá (người dùng, loại, mã nghiệp vụ). Job lặp, dù do client gọi lại API hay consumer chạy lại sự kiện, chèn 0 hàng (bài 04). Worker nhặt job bằng FOR UPDATE SKIP LOCKED: 60 worker chỉ nhận OTP, 1.140 worker còn lại nhặt theo ORDER BY priority, id nên OTP vẫn được ưu tiên khi cần mượn (bài 05). Trước khi gọi nhà cung cấp, worker đọc bảng notif_type để biết COMMIT đứng ở đâu: khuyến mãi đánh dấu trước rồi mới gọi, OTP và tin giao dịch gọi trước rồi mới đánh dấu (bài 03). Gặp timeout, job ít nhất một lần quay về hàng kèm thời điểm chạy lại, giữ nguyên khoá và nội dung.

Đường đi một tin qua thiết kế: yêu cầu gửi vào bảng jobs có khoá người dùng, loại, mã, job lặp cùng khoá chèn 0 hàng; worker nhặt theo loại với sàn 60 cho OTP và mượn pool chung; bảng notif_type quyết định COMMIT trước hay sau lượt gọi, khuyến mãi đánh dấu trước và mất khoảng 0,25%; nhà cung cấp timeout 2 giây thì OTP và giao dịch quay về hàng, còn SMS để lọt khoảng 0,25% tin trùng

Vì sao ba quyết định tách được thành ba trục? Vì mỗi trục trả lời một câu hỏi mà hai trục kia không chạm tới. Khoá chống trùng hỏi "hai yêu cầu này có phải cùng một tin không", cách ly hỏi "tin này chờ sau ai", còn mức giao nhận hỏi "khi không biết kết quả thì sai về phía nào". Vòng gửi ngây thơ của bài 02 gộp cả ba vào một vòng lặp và trả lời cả ba bằng mặc định: không khoá, chung một hàng, thử lại mọi thứ.

Mọi quyết định ở trên đứng trên bảng của bài 01: OTP 200 lượt mỗi giây hạn 15 giây, tin giao dịch 1.000 lượt mỗi giây hạn 1 phút, 5 triệu tin khuyến mãi trong 15 phút, timeout 2 giây đặt ở p99,5 nên 0,5% lượt gọi không rõ kết quả, và nhà cung cấp không chống trùng. Đổi bảng đó thì xét lại thiết kế, đừng giữ thiết kế khi ràng buộc đã khác.

2. Bảng cái giá bạn đã chấp nhận

Cột "lựa chọn" chỉ đúng dưới ràng buộc chuẩn của case. Đừng mang nó đi như khuyến nghị chung.

BướcLựa chọnCái giáSố đo bằng chứng
Ràng buộc (bài 01)Chốt bảng số, ước lượng theo 15 phút dồn tảiSố tự đặt, sai thì cả thiết kế sai theoTải đỉnh khoảng 6.756 lượt mỗi giây; 25.000 lượt mập mờ mỗi đợt khuyến mãi; ghi từng job cỡ 2.000 job mỗi giây, ghi lô cỡ 300.000
Ngây thơ (bài 02)Bỏ: gửi rồi đánh dấu, thử lại mọi thứVừa trùng vừa mất, mà bảng job không thấy tin trùng nàoTrên 100.000 job: 237 đến 272 tin trùng, 100 đến 122 job kẹt sending, 48 job failed
Mức giao nhận (bài 03)OTP, giao dịch ít nhất một lần; khuyến mãi tối đa một lầnHai đường code phải giữ đúng; mỗi loại tin mới cần người tính giá; khoảng 0,25% tin khuyến mãi mấtMỗi 1 triệu lượt: OTP mất tốn 12,5 triệu đ so với trùng 1,75 triệu đ; khuyến mãi mất 50 nghìn đ so với trùng 500 nghìn đ
Khoá chống trùng (bài 04)Ràng buộc duy nhất (người dùng, loại, mã nghiệp vụ)Loại phải chia đủ mịn, không thì chặn nhầm tin hợp lệ; trùng do timeout vẫn lọt ở kênh không nhận khoáLab 100.000 tin giao dịch: tổng tin trùng từ 3.202 xuống 262, phần còn lại là trùng do timeout
Cách ly (bài 05)Sàn 60 worker cho OTP, mượn pool chung theo ưu tiênHai kiểu worker phải đo và vận hành riêng; OTP chạy trên worker mượn chịu cảnh chờ như hàng ưu tiênFIFO để OTP chờ 62,5 giây lúc nhà cung cấp khoẻ, gần 14 phút lúc push chậm; giữ riêng 60 worker chỉ xả 30 trên 200 OTP mỗi giây khi SMS chậm

Đọc theo hàng ngang, cái giá nào cũng có một con số đứng sau. Có người hỏi "sao không gửi lại mọi thứ cho chắc" thì chỉ vào hàng bài 03: 25.000 lượt mập mờ mỗi đợt, khoảng 12.500 người nhận tin khuyến mãi hai lần, và khoảng 25.000 lượt gọi thêm dồn vào nhà cung cấp đúng lúc nó đang chậm.

Điều kiện chọn gửi ít nhất một lần là giá mất chia giá trùng lớn hơn q / (1 − q), với q là tỉ lệ lượt timeout mà nhà cung cấp thực ra đã gửi. Tỉ lệ timeout p rút gọn mất khỏi điều kiện: nó chỉ quyết định bạn mất bao nhiêu tiền, không quyết định bạn chọn phía nào.

Hàng bài 04 cần đọc kèm một chú thích. Khoá chống trùng chỉ thấy những gì đi qua cửa bảng job, nên phần trùng do timeout không giảm theo khoá nào. Phần đó chỉ thu hẹp được ở chặng nhà cung cấp, và bài 06 cho thấy đó là điều khoản hợp đồng (Resend nhớ khoá 24 giờ).

Nhớ lại — khoá chống trùng

Khoá gửi kèm nhà cung cấp phải sinh từ job, không từ lượt gọi: chuỗi kiểu giao_dich:812:55001 dẫn từ chính khoá chống trùng. Sinh UUID mới cho mỗi lượt thì nhà cung cấp thấy hai yêu cầu khác nhau và gửi cả hai.

3. Chỗ ba trục chạm nhau: lượt thử lại

Ba trục độc lập, trừ một chỗ. Luồng ít nhất một lần sinh ra lượt thử lại, và lượt thử lại tiêu tốn worker, đúng tài nguyên mà trục cách ly đang chia. Thử lại ngay trong worker, ngủ backoff 1, 2, 4 giây, thì khi push treo mỗi job giữ worker tới 15 giây, và OTP chạy trên worker mượn có thể chờ ngần ấy (bài 05).

Nhớ lại — cách ly luồng gửi

Hàng ưu tiên chỉ sắp lại job đang chờ; OTP vẫn đợi một worker được nhả, lâu nhất bằng thời gian một lượt gọi của luồng khác giữ worker. Sàn worker riêng chặn được chuyện đó cho phần OTP nằm trong sàn.

Lỗi hay tái phạm nhất khi ráp ba trục lại là viết một chính sách thử lại chung cho mọi loại tin:

SQL
-- moi loai cung mot luat: timeout thi thu lai, toi da 3 lan
UPDATE jobs SET status = 'pending', attempts = attempts + 1
WHERE id = :job_id AND attempts < 3;

Câu này gửi lại cả tin khuyến mãi (đẩy 0,25% mập mờ về đúng phía đắt hơn), và gửi lại OTP cả khi mã đã quá hạn 15 giây. Bản đúng đọc notif_type, trả job về hàng kèm run_after để worker nhả ngay:

SQL
UPDATE jobs j SET status = 'pending', attempts = j.attempts + 1,
       run_after = clock_timestamp() + interval '2 seconds'
FROM notif_type t
WHERE j.id = :job_id AND t.code = j.kind
  AND t.delivery = 'at_least_once'
  AND j.attempts < t.max_tries
  AND (t.deadline IS NULL
       OR j.created_at + t.deadline > clock_timestamp());

Chạy trên PostgreSQL 16.14 với bốn job đang sending, câu này chỉ đưa đúng một job về hàng: OTP tạo 3 giây trước. Tin khuyến mãi, OTP đã quá 15 giây và tin giao dịch hết 5 lượt đều đứng yên, nên worker phải có một câu tiếp theo đánh chúng sang failed. Bỏ câu đó là tái tạo đúng ô kẹt sending của bài 02.

4. Đổi ràng buộc, quyết định nào đổi?

Ba tình huống dưới đây mỗi cái đổi một dòng của bảng ràng buộc. Tự viết câu trả lời trước khi mở đáp án mẫu: trục nào lật, trục nào giữ nguyên, và con số nào chứng minh.

Tự kiểm tra
0/3 câu đã trả lời
  1. Q1
    Nhà cung cấp SMS bắt đầu nhận idempotency key và nhớ khoá 24 giờ. Trục nào trong thiết kế đổi, trục nào giữ nguyên?
  2. Q2
    Tin khuyến mãi giờ kèm voucher: 5% người nhận dùng, mỗi lượt dùng lãi 40.000 đ. Quyết định nào lật, và nó kéo theo gì ở hai trục còn lại?
  3. Q3
    Marketing nâng đợt khuyến mãi từ 5 triệu lên 20 triệu tin, vẫn trong 15 phút. Phép so ở bài 03 có đổi kết luận không? Trục nào chịu sức ép?

Đào sâu

📚 Đọc thêm

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

Tóm tắt

  • Khi thiết kế một hệ gửi qua nhà cung cấp ngoài, tách ba câu hỏi ra ba chặng: cùng một tin hay không, chờ sau ai, sai về phía nào.
  • Khi có người đề xuất một luật thử lại chung, hỏi lại giá mất và giá trùng của từng loại tin.
  • Khi một ràng buộc đổi, đổi đúng dòng đó trong bảng rồi xem trục nào lật, đừng thiết kế lại từ đầu.

✅ Self-assessment

  • Ước lượng số lượt gửi mỗi giây theo loại, số lượt gửi mập mờ do timeout và cái giá của một tin mất so với một tin trùng từ bảng ràng buộc tự đặt, rồi so tải đỉnh với trần ghi job đo trên máy mình
    • Nếu chưa: đọc lại bài 01, mục 4 (phần đuôi sau timeout) và mục 5 (bảng giá).
  • Chẩn đoán được nguồn sinh tin trùng (timeout rồi retry) và tin mất (kẹt trạng thái đang gửi, hết lượt retry) trong vòng nhận job, gọi nhà cung cấp rồi đánh dấu xong
    • Nếu chưa: đọc lại bài 02, mục 3 và mục 4.
  • Chọn giữa gửi tối đa một lần và gửi ít nhất một lần cho từng loại thông báo bằng cách so tỉ lệ mất nhân giá mất với tỉ lệ trùng nhân giá trùng
    • Nếu chưa: đọc lại bài 03, mục 5 và điều kiện q / (1 − q).
  • Thiết kế khoá chống trùng chặn job lặp phía mình, kèm ranh giới của nó: phần trùng do timeout nằm ngoài tầm khoá đó và chỉ thu hẹp được bằng khoá gửi kèm nhà cung cấp
    • Nếu chưa: đọc lại bài 04, mục 3 (số đo ba khoá) và mục 4 (khoá gửi kèm).
  • Thiết kế cách ly luồng gửi theo loại để OTP vẫn tới trong hạn khi một trong hai nhà cung cấp (push hoặc SMS) chậm giữa đợt gửi khuyến mãi
    • Nếu chưa: đọc lại bài 05, mục 6 (bảng ba kịch bản).
  • Biện luận vì sao một hệ thống thông báo thật chọn khác thiết kế của bạn dựa trên ràng buộc của họ
    • Nếu chưa: đọc lại bài 06, mục 6 (bảng tổng hợp theo chặng).

Bài tiếp theo

Case thông báo khép lại ở đây. Muốn đào sâu hai nguyên lý cả case đứng trên, đọc lại Mạng không tin cậy trong khoá Phân tán, nơi timeout chỉ là phỏng đoán, và Stream join và exactly-once trong khoá Dữ liệu phái sinh, nơi exactly-once chỉ đứng được khi mọi bên cùng hợp tác.

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