Tin kết quả trả về: tiền đi đâu khi khách đóng trình duyệt?
Tin trang chuyển về và kết quả đồng bộ, timeout thì báo thất bại rồi thử lại. Mô phỏng khách đóng trình duyệt, lệnh trừ timeout; đếm đơn lệch tiền.
TL;DR: Luồng thanh toán ngây thơ nhất tin hai thứ: trang chuyển về sau khi khách trả trên trang cổng, và câu trả lời đồng bộ của lệnh trừ một chạm; timeout thì báo thất bại cho khách bấm lại. Mô phỏng 100.000 lượt trên Postgres cho thấy nó sinh đủ bốn kiểu lệch tiền: khách đã trả mà đơn hết hạn, khách bị trừ hai lần, đơn đã thanh toán mà sổ thiếu bút toán, đơn thanh toán xong khi hàng đã về kho. Bảng orders không lộ kiểu nào; chỉ đối chiếu với phía nhà cung cấp mới đếm được.
Sáng sau đợt sale, kế toán gửi file: 1.440 khách có tiền về mà đơn ghi "hết hạn", 720 khách bị trừ hai lần. Cả đêm không một cảnh báo. Bài này cho bạn tự chạy phương án ngây thơ, tự đếm bốn kiểu lệch, rồi tự chỉ ra mỗi kiểu sinh ra ở bước nào trước khi đọc lời giải.
1. Ràng buộc và phương án ngây thơ
Bài này không đặt số mới, chỉ dùng lại mấy dòng của bảng ràng buộc ở bài 01:
| Ràng buộc | Giá trị |
|---|---|
| Đỉnh đợt sale | 1.800.000 lượt mỗi đợt |
| Kênh trang cổng / một chạm | 70% / 30%; timeout một chạm 30 giây |
| Giữ hàng / hạn trả trên trang cổng | 10 phút / 15 phút |
| Giao dịch mập mờ | 0,2% lượt; khoảng 60% trong số đó đã trừ tiền |
| Gửi lại cùng mã | bị từ chối là mã trùng; mã mới là lệnh trừ mới |
Trang chuyển về (return URL) là địa chỉ trên site của bạn mà trang cổng đẩy trình duyệt của khách quay về sau khi trả, kèm kết quả trên query string. IPN là lời gọi HTTP nhà cung cấp gửi thẳng vào server của bạn, không qua trình duyệt.
Hình dung bạn trả tiền ở quầy siêu thị, máy quẹt thẻ quay mãi rồi báo mất kết nối:
| Ở quầy | Luồng thanh toán ngây thơ |
|---|---|
| Thu ngân chờ biên lai in ra mới ghi "đã thu" | Server chỉ cập nhật đơn khi trang chuyển về tới |
| Khách trả xong bỏ đi trước khi in biên lai | Khách trả trên trang cổng rồi đóng tab |
| Máy treo, thu ngân bảo "quẹt lại đi" | Timeout, báo thất bại, khách bấm lại bằng mã mới |
Phương án ngây thơ:
khách bấm Thanh toán:
1. giữ hàng 10 phút, tạo đơn 'pending'
2a. trang cổng: trang chuyển về báo thành công -> đơn 'paid'
2b. một chạm: gọi lệnh trừ với mã mới, chờ tối đa 30 giây
OK -> 'paid'; timeout hoặc lỗi -> 'failed', mời "thử lại"
3. đơn vừa 'paid' -> ghi 4 bút toán vào ledger
mỗi phút: đơn 'pending' quá 10 phút -> 'expired', trả hàng về kho
Endpoint IPN chỉ trả 200 cho nhà cung cấp khỏi gửi lại. Bước 2b sinh mã mới mỗi lần, vì gửi lại mã cũ thì bị lỗi mã trùng.
2. Tự chạy: mô phỏng 100.000 lượt thanh toán
Bảng provider giả phía nhà cung cấp, mỗi hàng là một lần tiền khách thật sự bị trừ; đời thật bạn không có nó. Chạy setup.sql trên database paylab của bài 01:
DROP TABLE IF EXISTS orders, provider, ledger;
CREATE TABLE orders (
id bigserial PRIMARY KEY,
status text NOT NULL DEFAULT 'pending',
released boolean NOT NULL DEFAULT false -- hold returned to stock
);
-- provider side: one row per real charge; you do not have this table
CREATE TABLE provider (txn_id bigserial PRIMARY KEY, order_id bigint NOT NULL);
CREATE INDEX ON provider (order_id);
CREATE TABLE ledger (order_id bigint NOT NULL, account text NOT NULL,
debit bigint NOT NULL, credit bigint NOT NULL);
CREATE INDEX ON ledger (order_id);
Mỗi lượt thanh toán là một lần chạy pay.sql. Khối đầu để random(1, 10000) quyết định chuyện xảy ra bên ngoài server:
\set r random(1, 10000)
INSERT INTO orders DEFAULT VALUES RETURNING id AS oid \gset
\if :r <= 4
-- r 1..4
INSERT INTO provider (order_id) VALUES (:oid);
UPDATE orders SET status = 'failed' WHERE id = :oid;
\elif :r <= 12
-- r 5..12
INSERT INTO provider (order_id) VALUES (:oid);
\elif :r <= 15
-- r 13..15
UPDATE orders SET status = 'failed' WHERE id = :oid;
\elif :r <= 20
-- r 16..20
\elif :r <= 30
-- r 21..30
UPDATE orders SET status = 'expired', released = true
WHERE id = :oid;
\endif
Khối sau, ghép liền bên dưới, là việc server làm khi kết quả thành công tới nơi:
\if :r <= 4 or :r >= 13 and :r <= 15 or :r >= 21
-- success reaches the server: retry with a NEW txn id, or return URL
INSERT INTO provider (order_id) VALUES (:oid);
UPDATE orders SET status = 'paid' WHERE id = :oid;
\if :r < 31 or :r > 35
INSERT INTO ledger VALUES
(:oid, 'psp_receivable', 247250, 0), (:oid, 'psp_fee', 2750, 0),
(:oid, 'seller_payable', 0, 237500), (:oid, 'platform_fee', 0, 12500);
\endif
\endif
Các khoảng r trong code ứng với kịch bản sau:
Lượt r | Kênh | Chuyện xảy ra |
|---|---|---|
| 1 đến 4 | một chạm | tiền đã trừ, câu trả lời mất, timeout 30 giây |
| 5 đến 12 | trang cổng | khách trả xong rồi đóng trình duyệt trước trang chuyển về |
| 13 đến 15 | một chạm | timeout, nhà cung cấp chưa trừ |
| 16 đến 20 | trang cổng | khách bỏ ngang, không có gì xảy ra |
| 21 đến 30 | trang cổng | hàng hết hạn giữ ở phút 10, khách trả ở phút 12 |
Lượt 1 tới 20 trên 10.000 là 0,2% mập mờ, trong đó lượt 1 tới 12 đã trừ, đúng 60% của bài 01. Lab giả định khách timeout nào cũng bấm lại. Hai nút cố ý đặt cao cho dễ thấy: 0,1% khách trả trong phút 10 tới 15, và 0,05% lượt tiến trình chết (deploy, hết bộ nhớ) giữa lúc đánh dấu paid và lúc ghi sổ.
psql paylab -f setup.sql
pgbench -c 8 -j 4 -t 12500 --no-vacuum -f pay.sql paylab
psql paylab -c "UPDATE orders SET status = 'expired', released = true
WHERE status = 'pending'"
psql paylab -c "SELECT status, count(*) FROM orders GROUP BY status"
Câu UPDATE giả 10 phút trôi qua; câu cuối là dashboard. count.sql đặt orders cạnh phía nhà cung cấp và sổ:
SELECT
count(*) FILTER (WHERE p.n > 0 AND o.status <> 'paid') AS paid_unmarked,
count(*) FILTER (WHERE p.n > 1) AS double_charged,
count(*) FILTER (WHERE o.status = 'paid' AND l.n = 0) AS ledger_missing,
count(*) FILTER (WHERE o.status = 'paid' AND o.released) AS stock_released
FROM orders o
CROSS JOIN LATERAL (
SELECT count(*) AS n FROM provider WHERE order_id = o.id) p
CROSS JOIN LATERAL (
SELECT count(*) AS n FROM ledger WHERE order_id = o.id) l;
Trước khi chạy count.sql: dashboard có bao nhiêu đơn failed, và bao nhiêu phần trăm đơn expired là khách đã bị trừ tiền? Viết ra hai con số kèm lý do.
Kết quả của tác giả qua ba lần chạy (Postgres 16.15 trong Docker, Apple M4 10 nhân, mỗi lần 2 tới 3 phút):
| Bảng | Cột | Số đơn |
|---|---|---|
| Dashboard | paid | 99.859 đến 99.871 |
| Dashboard | expired | 129 đến 141 |
| Dashboard | failed | 0 |
count.sql | paid_unmarked: đã trả, đơn không paid | 75 đến 89 |
count.sql | double_charged: bị trừ hai lần | 37 đến 41 |
count.sql | ledger_missing: paid mà sổ trống | 37 đến 52 |
count.sql | stock_released: paid khi hàng đã về kho | 86 đến 98 |
pgbench báo number of failed transactions: 0, vậy mà mỗi lần chạy có 235 tới 280 đơn lệch tiền.
Trước khi sang mục 3, viết bốn câu: mỗi kiểu lệch sinh ra ở bước nào của luồng ở mục 1. Đáp án mẫu ở câu đầu phần tự kiểm tra.
3. Vì sao timeout rồi bấm lại trừ tiền hai lần?
Đáp án phần đầu phép thử đoán: 0 đơn failed. Khách bấm lại và lần sau thành công, nên đơn đi failed rồi paid; dấu vết duy nhất của timeout bị ghi đè. Với lượt 1 tới 4, lần đầu tiền đã đi, nên khách bị trừ hai lần: 0,04% của 100.000 là khoảng 40, khớp 37 đến 41.
Bài đó đã chỉ ra: timeout không cho biết bên kia đã làm xong chưa, và thử lại một thao tác có tác dụng phụ là chạy nó lần hai nếu phía nhận không chống trùng.
Cái mới: nhà cung cấp ở đây có chống trùng theo mã giao dịch. Gửi lại T1 thì bị từ chối, không trừ lần hai. Chính phương án ngây thơ đã tắt cơ chế đó khi sinh mã mới cho mỗi lần bấm.

Lỗi gốc là câu "timeout thì thất bại", trong khi timeout chỉ nghĩa là chưa biết.
4. Khách đã trả mà đơn vẫn chưa thanh toán
4.1 Trang chuyển về đi vòng qua trình duyệt
Lượt 5 tới 12: khách quét QR, tiền đã trừ, rồi đóng tab trước khi trang chuyển về kịp tải. Mười phút sau, tiến trình dọn đổi đơn sang expired. Khoảng 80 đơn, khớp 75 đến 89.

Kết quả về server bằng hai đường. Trang chuyển về đi qua trình duyệt, và khách cắt được nó bằng một lần đóng tab, mất sóng hay hết pin. IPN đi thẳng, thế mà phương án ngây thơ lại bỏ đúng đường đó.
Đáp án phần sau phép thử đoán: khoảng 80 trong 129 tới 141 đơn expired, tức khoảng 60%, là khách đã trả. Dashboard đọc chúng là bỏ giỏ.
4.2 Trả xong khi hàng đã về kho
Lượt 21 tới 30: hàng về kho ở phút 10, khách trả ở phút 12, server đánh dấu paid. Khoảng 100 đơn paid với released = true, cùng bậc với 86 đến 98 đo được; hàng đó có thể đã bán cho người khác.
Thêm điều kiện thì sao?
UPDATE orders SET status = 'paid'
WHERE id = :oid AND status = 'pending'; -- 0 rows if already expired
Đơn giữ expired mà tiền khách vẫn đã đi: kiểu lệch thứ tư biến thành kiểu thứ nhất. Điểm vỡ nằm ở hai đồng hồ lệch nhau, giữ hàng 10 phút trong khi cửa trả tiền mở 15 phút.
Thử ngẫmIPN tới ở phút 12 vẫn không cứu được kiểu ở mục 4.2. Muốn khách trả ở phút 12 vẫn còn hàng thì bạn chỉnh đồng hồ nào, và mất gì?
5. Đơn đã thanh toán mà sổ thiếu bút toán
Lượt 31 tới 35: UPDATE orders SET status = 'paid' đã commit, tiến trình chết trước INSERT INTO ledger. Hai lệnh là hai transaction riêng nên không rollback nào cứu được. Khoảng 50 đơn, cùng bậc với 37 đến 52 đo được.
Bài đó đã dạy partial failure: một phần đã làm xong, phần kia hỏng, không phần nào có bức tranh đầy đủ. Ở đây nó thu nhỏ vào một server: ghi đơn xong, ghi sổ chưa, khởi động lại thì không gì nhắc còn nợ bốn bút toán.
Tiền khách ở kiểu này không sai. Cái sai nằm trong sổ của sàn: thiếu 247.250 đ phải thu nhà cung cấp và 237.500 đ phải trả người bán, mỗi đơn là một khoản lệch kế toán tra tay.
6. Đặt lên một đợt sale
Đổi tỉ lệ của lab sang 1.800.000 lượt, nhân với giá trong bảng ràng buộc:
| Kiểu lệch | Tỉ lệ | Mỗi đợt | Cái giá |
|---|---|---|---|
| Đã trả, đơn hết hạn | 0,08% | 1.440 | 360 triệu đ treo, không gì tự chốt |
| Trừ hai lần | 0,04% | 720 | 720 × 18.000 đ = 12,96 triệu đ, cộng 180 triệu đ phải hoàn |
| Sổ thiếu bút toán | 0,05% | 900 | 900 × 100.000 đ = 90 triệu đ công tra tay |
| Trả khi hàng đã về kho | 0,1% | 1.800 | hoàn tiền, mất phí 1.800 × 2.750 đ = 4,95 triệu đ |
Hai dòng đầu cộng lại đúng 2.160 giao dịch mập mờ đã trừ của bài 01: phương án này biến mọi giao dịch như vậy thành một khoản lệch, vì nó không có trạng thái nào cho chữ "chưa biết". Hai dòng sau là nút vặn của lab, số thật có thể nhỏ hơn. Dòng đầu đắt nhất, vì không có gì tự chốt nó.
7. Pitfall
❌ Nhầm 1: cập nhật đơn ở trang chuyển về.
-- return URL handler, naive
UPDATE orders SET status = 'paid' WHERE id = :oid;
✅ Trang chuyển về chỉ để hiện cho khách "đang xác nhận". Kết quả chốt đơn phải tới từ đường không qua trình duyệt: IPN và API tra cứu.
-- return URL handler: only read, show "confirming payment"
SELECT status FROM orders WHERE id = :oid;
-- the paid decision comes from IPN or lookup, not from here
Ghép hai nguồn đó trong hạn 24 giờ là việc của bài 05.
❌ Nhầm 2: timeout thì failed và mời khách bấm lại với mã mới.
-- timeout: customer clicks again, which sends a NEW txn id
UPDATE orders SET status = 'failed' WHERE id = :oid;
-- timeout: keep the old txn id, ask the provider for its result
UPDATE orders SET status = 'unknown' WHERE id = :oid;
✅ Timeout là "chưa rõ". Đơn ở trạng thái đó không nhả hàng, không nhận lệnh trừ mới; gửi lại thì giữ mã cũ, và lỗi mã trùng nghĩa là đi tra cứu chứ không sinh mã khác. Máy trạng thái có ô "chưa rõ" là việc của bài 03.
8. Đào sâu
- VNPAY, Thanh toán PAY: mục trang chuyển về ghi "Không cập nhật kết quả giao dịch tại địa chỉ này".
- Stripe, Advanced error handling: mục Network errors, vì sao mất kết nối không cho biết lệnh đã chạy chưa và vì sao gửi lại phải giữ key cũ.
- PostgreSQL 16, pgbench:
\gset,\ifvàrandom(lb, ub)dùng ở mục 2.
9. Liên hệ các bài khác
- Bài 01: nguồn của 2.160 giao dịch mập mờ đã trừ.
- Bài 03: sắp lại thứ tự giữ hàng, trừ tiền, ghi sổ.
- Bài 05: chốt giao dịch mập mờ.
10. Tóm tắt
- Khách đã trả mà đơn chưa thanh toán: hỏi server biết kết quả qua đường nào, đường đó có đi qua trình duyệt không.
- Trừ hai lần: tìm chỗ đổi timeout thành thất bại và chỗ sinh mã giao dịch mới.
- Đơn
paidmà sổ trống: một sự kiện đang được ghi bằng hai lệnh riêng. - Đơn
paidkhi hàng đã về kho: hạn giữ hàng ngắn hơn cửa trả tiền.
11. Tự kiểm tra
- Q1Chẩn đoán phương án ngây thơ: mỗi kiểu lệch trong bốn kiểu sinh ra ở bước nào của luồng?
- Q2Vì sao dashboard đếm theo status của bảng orders không phát hiện được kiểu lệch nào?
- Q3Đồng nghiệp sửa bằng cách chỉ nghe IPN, bỏ hẳn trang chuyển về. Kiểu lệch nào hết, kiểu nào còn?
- Q4Lệnh một chạm timeout. Bạn gửi lại đúng mã T1 và nhận lỗi mã trùng. Đơn nên ở trạng thái nào, và vì sao không phải failed?
- Q5Trong lab, bạn đổi giả định để chỉ 30% khách gặp timeout một chạm bấm lại. Dashboard và hai cột paid_unmarked, double_charged thay đổi thế nào?
Bài tiếp theo: Thứ tự bước: đặt lệnh trừ tiền không đảo được ở đâu?
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.
Ràng buộc và ước lượng: bao nhiêu tiền đang treo?
Bài tiếpThứ tự bước: đặt lệnh trừ tiền không đảo được ở đâu?
Thảo luận & góp ý
Hỏi đáp về bài này