Thanh toán: không lệch một đồng
2/8
Bài 2 / 8~13 phútKhông lệch một đồngMiễn phí… lượt xem

Ràng buộc và ước lượng: bao nhiêu tiền đang treo?

Bảng ràng buộc luồng thanh toán: tự tính lượt mỗi giây, giao dịch mập mờ, tiền thực sự treo, tải lên ba tài khoản chung, rồi đo trần ghi một hàng.

TL;DR: Case này đặt đỉnh 3.000 lượt thanh toán mỗi giây trong 10 phút sale. Với 0,2% lượt không rõ kết quả sau một phút (gọi là giao dịch mập mờ), mỗi đợt để lại khoảng 3.600 giao dịch, trong đó chừng 2.160 giao dịch khách đã bị trừ thật: khoảng 540 triệu đồng đang treo mà hệ thống chưa biết. Mỗi lượt ghi bốn bút toán (bốn dòng ghi tiền vào sổ) và chạm cả ba tài khoản chung (tài khoản mà lượt thanh toán nào cũng ghi vào), nên mỗi tài khoản chung nhận 3.000 lượt ghi mỗi giây, trong khi một hàng Postgres bị ghi dồn chỉ chịu cỡ 1.200. Bạn sẽ tự đo trần đó bằng pgbench.

Đợt sale chạy từ 21 giờ tới 21 giờ 10. Sau đó tổng đài báo hơn 600 cuộc gọi trong nửa giờ, dashboard vẫn xanh: "App trừ tiền tôi rồi mà đơn vẫn chờ thanh toán". Mấy lệnh trừ tiền đó chưa có câu trả lời, chứ chưa thất bại.

Lệnh trừ tiền chạy ở nhà cung cấp: bạn không khoá được, không rollback được, có lúc không biết nó đã chạy chưa. Bài này công bố bảng ràng buộc của case rồi biến nó thành bốn con số, chưa có thiết kế nào.

1. Analogy: chuyển khoản lúc app quay mãi

Bạn chuyển khoản, app quay mãi rồi báo mất kết nối. Tiền đi chưa? Bấm lại thì sợ chuyển hai lần, nên bạn chờ tin nhắn số dư, mở lịch sử giao dịch, cuối tháng dò sao kê. Server của bạn gặp đúng chuyện này vài nghìn lần mỗi đợt sale.

Đời thườngLuồng thanh toán của case
App quay rồi mất mạngLệnh trừ tiền hết 30 giây timeout mà chưa có kết quả
Không biết tiền đi chưaGiao dịch mập mờ
Sợ bấm lại thành chuyển hai lầnGửi lại bằng mã giao dịch mới thì bị trừ lần hai
Tin nhắn biến động số dưIPN nhà cung cấp gọi về server của bạn
Mở lịch sử giao dịchAPI tra cứu trạng thái
Sao kê cuối thángFile đối soát sáng hôm sau
💡 Cách nhớ

Timeout chỉ nói bạn chưa biết.

2. Bảng ràng buộc: những con số cả case đứng trên

Bảng dưới là ràng buộc tự đặt, các bài sau trích lại đúng bộ số này. Trang cổng là trang thanh toán của nhà cung cấp, nơi khách tự quét QR hay nhập thẻ. IPN (Instant Payment Notification) là lời gọi HTTP nhà cung cấp chủ động gửi vào URL của bạn để báo kết quả. Một chạm là kiểu server của bạn tự gọi lệnh trừ tiền từ ví hay thẻ đã liên kết.

Ràng buộcGiá trị
Ngày thường400.000 lượt mỗi ngày
Đỉnh đợt sale3.000 lượt/giây trong 10 phút
Giá trị đơn trung bình250.000 đ
Kênh trang cổng70%; kết quả chỉ qua IPN hoặc tra cứu
Kênh một chạm30%; p50 700 ms, p99 5 s, timeout 30 s
Hạn trả trên trang cổng15 phút
Giữ hàng chờ thanh toán10 phút, hết hạn thì về kho
Giao dịch mập mờ0,2% lượt, không rõ sau 1 phút
Mập mờ mà đã trừKhoảng 60%
Gửi lại cùng mãBị từ chối là mã trùng; mã mới là lệnh trừ mới
Giữ tiền rồi thu sauThẻ quốc tế, một số ví; tối đa 7 ngày; khoảng một nửa lượt không giữ tiền được; huỷ trước khi thu thì không mất phí
API tra cứuTối đa 10 lượt/giây
IPNGửi lại tối đa 10 lần cách 5 phút, có thể trùng
File đối soát6 giờ sáng hôm sau, ngày chốt 0:00–23:59
Hoàn tiềnVí khoảng 1 ngày, ATM tới 7, thẻ quốc tế 5–30 ngày
Phí nhà cung cấp1,1%, sàn chịu, không hoàn
Phí sàn5% giá trị đơn
Bút toán mỗi lượt4 dòng
Đọc số dư người bán500 lượt/giây, p99 dưới 50 ms
Lưu vếtÍt nhất 10 năm, không sửa, không xoá
Giá tiền treoQuá 30 phút: 30% khách gọi, 15.000 đ/cuộc; quá 24 giờ thì khiếu nại
Giá một lần trừ hai lầnKhoảng 18.000 đ
Giá một khoản lệch sổ100.000 đ

p99 là mốc mà 99% lượt nhanh hơn nó. Dòng "10 năm" lấy từ Luật Kế toán 88/2015, Điều 41 khoản 5 điểm b, vốn quy định cho chứng từ dùng trực tiếp để ghi sổ; case áp mức đó cho cả bút toán trong sổ cái.

3. Lúc đỉnh có bao nhiêu lượt thanh toán mỗi giây?

Ước lượng thô (back-of-envelope), đủ để biết bậc độ lớn:

  • Ngày thường: 400.000 ÷ 86.400 giây ≈ 4,6, làm tròn 5 lượt mỗi giây.
  • Đỉnh sale: 3.000 lượt mỗi giây, gấp 600 lần ngày thường.

Khác flash sale, đỉnh ở đây kéo dài 10 phút, đủ lâu để mọi hàng đợi phía sau kịp đầy. 30% lượt là lệnh một chạm: 900 lệnh mỗi giây server tự gọi ra ngoài.

Thử ngẫmmột thiết kế chỉ hỏng khi tải gấp 600 lần ngày thường thì sẽ lộ ra ở ngày thường hay chỉ ở đêm sale, và vì sao đó là cái bẫy?

4. Bao nhiêu giao dịch mập mờ, bao nhiêu tiền thật sự treo?

Giao dịch mập mờ là giao dịch sau một phút vẫn chưa rõ kết quả: lệnh một chạm hết timeout, hoặc khách đã trả trên trang cổng mà IPN chưa tới.

Không nhận được trả lời thì bên gửi không phân biệt được bên kia đã chết, đang chậm, hay đã xử lý xong mà trả lời bị mất. Ở đây "xử lý xong" là tiền khách đã đi.

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

Từ bảng ở mục 2, tính cho một đợt sale: (a) số giao dịch mập mờ, (b) số giao dịch mập mờ đã trừ tiền, (c) số tiền đang treo, (d) tiền tổng đài nếu tất cả khách bị trừ đều chờ quá 30 phút. Viết ra giấy rồi mới đọc tiếp.

Lời giải:

Đại lượngCách tínhKết quả
(a) Mập mờ1.800.000 × 0,2%3.600
(b) Mập mờ đã trừ3.600 × 60%2.160
(c) Tiền treo2.160 × 250.000 đ540 triệu đ
(d) Tổng đài2.160 × 30% × 15.000 đ9,72 triệu đ

Dòng (d) chỉ tính khách đã bị trừ. Bạn đâu biết giao dịch nào chưa trừ, nên cả 3.600 đều phải được chốt.

Mất bao lâu để biết? Tra cứu một vòng: 3.600 ÷ 10 lượt mỗi giây = 6 phút. IPN gửi lại trong khoảng 50 phút, endpoint chết lâu hơn thì mất hẳn. File đối soát: giao dịch lúc 0:01 chờ khoảng 30 giờ.

Bốn thanh cùng thang tuyến tính theo phút: tra cứu 3.600 giao dịch mất 6 phút, IPN gửi lại 10 lượt trong khoảng 50 phút, hạn mập mờ tối đa 24 giờ, file đối soát cho giao dịch lúc 0:01 tới sau khoảng 30 giờ và vượt hạn

Nguồn nhanh nhất bị giới hạn lượt gọi, nguồn chắc nhất lại muộn hơn hạn. Lúc đỉnh, 6 giao dịch mập mờ mới mỗi giây đã ăn 60% hạn mức tra cứu.

Thử ngẫmlần tra cứu đầu trả về "đang xử lý". Với 6 giao dịch mập mờ mới mỗi giây, hạn mức 10 lượt còn đủ cho lần hỏi thứ hai không?

5. Ba tài khoản chung nhận bao nhiêu lượt ghi mỗi giây?

Bút toán là một dòng ghi số tiền vào một tài khoản kế toán, ở cột Nợ hoặc cột Có; nhóm bút toán của một lượt thanh toán phải có tổng Nợ bằng tổng Có. Đơn 250.000 đ, phí nhà cung cấp 1,1% do sàn chịu, phí sàn 5%:

Tài khoảnNợCó
Phải thu nhà cung cấp (chung)247.250
Phí nhà cung cấp (chung)2.750
Phải trả người bán (riêng)237.500
Doanh thu phí sàn (chung)12.500

Hai cột cùng ra 250.000. Tài khoản chung là tài khoản lượt nào cũng ghi vào; tài khoản người bán thì mỗi người một cái.

Lúc đỉnh, mỗi tài khoản chung nhận 3.000 lượt ghi mỗi giây. Mười năm ngày thường là 400.000 × 4 × 365 × 10 ≈ 5,84 tỉ dòng, chưa cộng đợt sale.

Nếu mỗi lượt UPDATE một cột số dư, ba hàng của ba tài khoản chung là ba hàng bị mọi lượt ghi dồn vào.

Thử đoán trước

Transaction ghi đủ một lượt (ba hàng chung cộng một hàng người bán) so với transaction chỉ ghi một hàng chung, ở 50 kết nối: TPS của four.sql bằng bao nhiêu phần trăm của one.sql? Ghi con số trước rồi đo ở mục 6.

6. Tự đo trần ghi một hàng tài khoản

pgbench là công cụ benchmark đi kèm Postgres: mở nhiều kết nối, chạy lặp một script SQL, rồi in TPS (transaction mỗi giây). Tạo 10.003 tài khoản, id 1 đến 3 là ba tài khoản chung:

Bash
createdb paylab
psql paylab -c "CREATE TABLE accounts (
  id int PRIMARY KEY, balance bigint NOT NULL);"
psql paylab -c "INSERT INTO accounts
  SELECT g, 0 FROM generate_series(1, 10003) g;"

Lưu hai file: one.sql chỉ ghi tài khoản phải thu, four.sql ghi đủ bốn dòng (dấu cộng trừ chỉ là quy ước):

SQL
-- one.sql
BEGIN;
UPDATE accounts SET balance = balance + 247250 WHERE id = 1;
COMMIT;

-- four.sql
\set seller random(4, 10003)
BEGIN;
UPDATE accounts SET balance = balance + 247250 WHERE id = 1;
UPDATE accounts SET balance = balance + 2750 WHERE id = 2;
UPDATE accounts SET balance = balance - 12500 WHERE id = 3;
UPDATE accounts SET balance = balance - 237500 WHERE id = :seller;
COMMIT;
Bash
pgbench -n -f one.sql  -c 1  -j 1 -T 10 paylab
pgbench -n -f one.sql  -c 50 -j 5 -T 10 paylab
pgbench -n -f four.sql -c 50 -j 5 -T 10 paylab

-c là số kết nối, -j số luồng, -T 10 chạy 10 giây, -n bỏ vacuum. Số tác giả: Postgres 16.15 trong Docker, Apple M4 10 nhân, hai lần chạy.

ScriptTPS máy tác giảTPS máy bạn
one.sql, 1 kết nối1.640 đến 2.000?
one.sql, 50 kết nối≈ 1.200?
four.sql, 50 kết nối900 đến 1.000?

Lời giải phép thử đoán: xấp xỉ bằng, thấp hơn khoảng 20%. Mọi transaction xếp hàng ngay ở hàng id 1; ai giữ được nó thì gần như đi thẳng qua hàng 2 và 3, vì người khác còn chờ ở hàng 1. Trần vẫn là trần của một hàng.

Mọi cách ghi lên một hàng Postgres đều xếp hàng sau khoá của hàng đó, nên 50 kết nối không nhanh hơn 1 kết nối. Hàng số dư phải thu là đúng hiện tượng ấy.

Lúc máy bận, one.sql ở 50 kết nối từng rơi xuống 680 TPS, nên mang theo tỉ lệ: 3.000 ÷ 1.200 = 2,5 lần.

Ba thanh cùng thang tuyến tính: ngày thường khoảng 5 lượt mỗi giây chỉ là một vệt, trần một hàng khoảng 1.200 lượt mỗi giây, đỉnh sale 3.000 lượt mỗi giây ghi vào mỗi tài khoản chung dài gấp 2,5 lần trần

2,5 lần nghe nhẹ hơn chừng 40 lần của flash sale, nhưng tải vượt trần kéo dài 10 phút và thứ xếp hàng là bút toán tiền. Ngày thường cách trần hơn hai trăm lần, nên cột số dư không lộ gì cho tới tối sale.

7. Hai lỗi ước lượng hay gặp

❌ Nhầm 1: đo trần bằng script ghi rải nhiều tài khoản.

SQL
\set seller random(4, 10003)
BEGIN;
UPDATE accounts SET balance = balance - 237500 WHERE id = :seller;
COMMIT;

✅ Script này đạt 14.000 đến 22.000 TPS ở 50 kết nối trên máy tác giả, vì lượt ghi rải ra 10.000 hàng. Lượt thanh toán thật luôn ghi cùng ba hàng chung, nên phải đo one.sql:

SQL
BEGIN;
UPDATE accounts SET balance = balance + 247250 WHERE id = 1;
COMMIT;

❌ Nhầm 2: tính tiền treo bằng cả 3.600 giao dịch mập mờ (ra 900 triệu đ).

✅ Chỉ 60% đã trừ, nên tiền thực sự treo là 540 triệu đ; 1.440 giao dịch còn lại vẫn phải chốt.

SQL
SELECT 3600 * 250000;        -- 900000000: wrong, counts uncharged too
SELECT 3600 * 0.6 * 250000;  -- 540000000: right, charged share only

8. Câu hỏi nào bảng số này chưa trả lời?

  1. Giữ hàng 10 phút mà hạn trả 15 phút: khách trả ở phút thứ 12 thì hàng đã về kho. Bài 02.
  2. Một hàng chịu cỡ 1.200 lượt ghi mỗi giây: số dư giữ ở đâu mà vẫn đọc được 500 lượt mỗi giây, p99 dưới 50 ms? Bài 04.
  3. File tới sau 30 giờ, hạn 24 giờ: ghép ba nguồn kết quả thế nào? Bài 05.

9. Đào sâu: nguồn để tự kiểm số của mình

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

11. Tóm tắt

  • Ước lượng tiền treo: nhân số giao dịch mập mờ với tỉ lệ đã trừ, rồi mới nhân giá đơn.
  • Đo trần cho sổ tiền: đo đúng hàng mà mọi lượt cùng ghi, đừng đo hàng rải đều.
  • Việc cần làm: chạy one.sql và four.sql ở 50 kết nối, rồi chia 3.000 cho trần của bạn.

12. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao tiền thực sự treo mỗi đợt sale là khoảng 540 triệu đồng chứ không phải 900 triệu, và vì sao 1.440 giao dịch chưa trừ vẫn phải xử lý?
  2. Q2
    Lệnh trừ tiền một chạm hết 30 giây timeout. Vì sao không được coi đó là thanh toán thất bại?
  3. Q3
    Máy bạn cho one.sql 1.500 TPS và four.sql 1.300 TPS ở 50 kết nối. Tỉ lệ vượt trần của tài khoản chung lúc đỉnh là bao nhiêu, và vì sao four.sql không chậm gấp ba?
  4. Q4
    Đồng nghiệp đo pgbench bằng script ghi ngẫu nhiên vào 10.000 tài khoản người bán, được 20.000 TPS, và kết luận cột số dư dư sức. Sai ở đâu?
  5. Q5
    Vì sao chỉ chờ file đối soát không giữ được ràng buộc không giao dịch nào mập mờ quá 24 giờ, dù file là nguồn chắc chắn nhất?

Bài tiếp theo: Tin kết quả trả về: tiền đi đâu khi khách đóng trình duyệ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

Tin kết quả trả về: tiền đi đâu khi khách đóng trình duyệt?