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

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ộcGiá trị
Đỉnh đợt sale1.800.000 lượt mỗi đợt
Kênh trang cổng / một chạm70% / 30%; timeout một chạm 30 giây
Giữ hàng / hạn trả trên trang cổng10 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ầyLuồ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 laiKhá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ơ:

Plain Text
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:

SQL
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:

SQL
\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:

SQL
\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 rKênhChuyện xảy ra
1 đến 4một chạmtiền đã trừ, câu trả lời mất, timeout 30 giây
5 đến 12trang cổngkhách trả xong rồi đóng trình duyệt trước trang chuyển về
13 đến 15một chạmtimeout, nhà cung cấp chưa trừ
16 đến 20trang cổngkhách bỏ ngang, không có gì xảy ra
21 đến 30trang cổnghà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ổ.

Bash
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ổ:

SQL
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;
Thử đoán trước

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ảngCộtSố đơn
Dashboardpaid99.859 đến 99.871
Dashboardexpired129 đến 141
Dashboardfailed0
count.sqlpaid_unmarked: đã trả, đơn không paid75 đến 89
count.sqldouble_charged: bị trừ hai lần37 đến 41
count.sqlledger_missing: paid mà sổ trống37 đến 52
count.sqlstock_released: paid khi hàng đã về kho86 đế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.

Sơ đồ tuần tự khách, server, nhà cung cấp: server gửi lệnh trừ mã T1, nhà cung cấp trừ 250.000 đồng nhưng câu trả lời mất, sau 30 giây server báo khách thất bại, khách bấm thử lại, server gửi lệnh trừ mã T2 và nhà cung cấp trừ lần hai vì T2 là lệnh mới

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.

Sơ đồ tuần tự server, trình duyệt khách, nhà cung cấp: server mở trang cổng, khách quét QR và bị trừ tiền, nhà cung cấp chuyển trình duyệt về return URL nhưng khách đã đóng tab nên server không nhận được gì; IPN báo thành công gửi thẳng từ nhà cung cấp tới server nhưng bị bỏ qua, đơn vẫn pending

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?

SQL
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ệchTỉ lệMỗi đợtCái giá
Đã trả, đơn hết hạn0,08%1.440360 triệu đ treo, không gì tự chốt
Trừ hai lần0,04%720720 × 18.000 đ = 12,96 triệu đ, cộng 180 triệu đ phải hoàn
Sổ thiếu bút toán0,05%900900 × 100.000 đ = 90 triệu đ công tra tay
Trả khi hàng đã về kho0,1%1.800hoà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ề.

SQL
-- 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.

SQL
-- 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.

SQL
-- timeout: customer clicks again, which sends a NEW txn id
UPDATE orders SET status = 'failed' WHERE id = :oid;
SQL
-- 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

Đọc thêm

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 paid mà sổ trống: một sự kiện đang được ghi bằng hai lệnh riêng.
  • Đơn paid khi hàng đã về kho: hạn giữ hàng ngắn hơn cửa trả tiền.

11. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Chẩ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?
  2. Q2
    Vì sao dashboard đếm theo status của bảng orders không phát hiện được kiểu lệch nào?
  3. 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?
  4. Q4
    Lệ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?
  5. Q5
    Trong 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.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Thứ tự bước: đặt lệnh trừ tiền không đảo được ở đâu?