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ường | Luồng thanh toán của case |
|---|---|
| App quay rồi mất mạng | Lệ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ưa | Giao dịch mập mờ |
| Sợ bấm lại thành chuyển hai lần | Gử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ịch | API tra cứu trạng thái |
| Sao kê cuối tháng | File đối soát sáng hôm sau |
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ộc | Giá trị |
|---|---|
| Ngày thường | 400.000 lượt mỗi ngày |
| Đỉnh đợt sale | 3.000 lượt/giây trong 10 phút |
| Giá trị đơn trung bình | 250.000 đ |
| Kênh trang cổng | 70%; kết quả chỉ qua IPN hoặc tra cứu |
| Kênh một chạm | 30%; p50 700 ms, p99 5 s, timeout 30 s |
| Hạn trả trên trang cổng | 15 phút |
| Giữ hàng chờ thanh toán | 10 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 sau | Thẻ 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ứu | Tối đa 10 lượt/giây |
| IPN | Gửi lại tối đa 10 lần cách 5 phút, có thể trùng |
| File đối soát | 6 giờ sáng hôm sau, ngày chốt 0:00–23:59 |
| Hoàn tiền | Ví khoảng 1 ngày, ATM tới 7, thẻ quốc tế 5–30 ngày |
| Phí nhà cung cấp | 1,1%, sàn chịu, không hoàn |
| Phí sàn | 5% giá trị đơn |
| Bút toán mỗi lượt | 4 dòng |
| Đọc số dư người bán | 500 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 treo | Quá 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ần | Khoả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ừ 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ượng | Cách tính | Kế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 treo | 2.160 × 250.000 đ | 540 triệu đ |
| (d) Tổng đài | 2.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ờ.

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ản | Nợ | 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.
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:
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):
-- 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;
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.
| Script | TPS máy tác giả | TPS máy bạn |
|---|---|---|
one.sql, 1 kết nối | 1.640 đến 2.000 | ? |
one.sql, 50 kết nối | ≈ 1.200 | ? |
four.sql, 50 kết nối | 900 đế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.

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.
\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:
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.
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?
- 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.
- 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.
- 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
- pgbench (PostgreSQL 16): các cờ đã dùng, dòng
tps =, hàmrandomtrong\set. - Explicit Locking (PostgreSQL 16): khoá mức hàng, vì sao
UPDATEcùng hàng chờ tới khi transaction kia kết thúc. - Luật Kế toán 88/2015/QH13: Điều 41 khoản 5, nguồn của dòng "lưu vết 10 năm".
10. Liên hệ các bài khác
- Bài 02: Tin kết quả trả về: dùng bảng này để tái hiện bốn kiểu lệch tiền.
- Bài 04: Sổ cái chỉ ghi thêm: quay lại tỉ lệ 2,5 lần và 5,84 tỉ dòng.
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.sqlvàfour.sqlở 50 kết nối, rồi chia 3.000 cho trần của bạn.
12. Tự kiểm tra
- Q1Vì 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ý?
- Q2Lệ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?
- Q3Má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?
- 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?
- Q5Vì 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.
Thanh toán — tổng quan case không lệch một đồng
Bài tiếpTin kết quả trả về: tiền đi đâu khi khách đóng trình duyệt?
Thảo luận & góp ý
Hỏi đáp về bài này