Bỏ qua để đến nội dung
Knowledge Hub

Thiết kế hệ thống thanh toán: timeout làm trừ tiền hai lần, và ledger chứng minh điều đó Nâng cao

Thời gian đọc ước tính: 11 phút

Một shop online chung. Đơn 101 giá 450.000 VND. Payment service nhờ một payment gateway bên ngoài trừ tiền thẻ. Hôm nay gateway chậm: sau 10 giây lệnh gọi timeout, checkout báo “Thanh toán thất bại, vui lòng thử lại”.

Khách bấm Pay lần nữa và thành công. Một tuần sau, tài chính phát hiện thẻ bị trừ hai lần: lần đầu thật ra đã thành công, chỉ có phản hồi bị mất.

Payment service nên ghi nhận, retry và hạch toán thế nào cho một lần trừ tiền mà nó không biết kết quả?

Tình huống Chọn Điều kiện
Gọi gateway bị timeout hoặc trả 5xx Ghi unknown, không bao giờ ghi failed Chỉ failed khi gateway từ chối rõ ràng
Retry một lần trừ tiền Cùng idempotency key, hoặc query trạng thái trước Key được lưu trước lần gọi đầu; còn trong thời hạn gateway giữ key
Khách bấm Pay hai lần Mỗi đơn chỉ một payment đang hoạt động (partial UNIQUE) Trả lại payment cũ khi nó đang pending/unknown
Ghi nhận tiền Ledger bút toán kép, chỉ thêm Số nguyên minor unit + tiền tệ; sửa sai bằng journal mới
Webhook từ gateway Kiểm chữ ký, dedupe theo event id, chuyển trạng thái chỉ tiến Coi là gợi ý; payload mỏng thì gọi API lấy trạng thái
Thu thập số thẻ Hosted fields / iframe hoặc redirect sang gateway Server của mình chỉ thấy token

Các số dưới đây là giả định minh hoạ, không phải dữ liệu của shop hay gateway thật.

Giả định Giá trị
Đơn đã thanh toán mỗi ngày 2 triệu
Số lần thử thanh toán mỗi đơn 1,2 (bị từ chối, retry)
Hệ số đỉnh so với trung bình (ngày sale) 10x
Tỷ lệ lệnh gọi gateway bị timeout 0,1%
Số entry ledger mỗi payment thành công 5 (capture 2, settlement 3)
Một dòng entry kèm index ~150 byte
Đại lượng Cách tính Kết quả
Lần thử thanh toán 2,4 triệu / 86.400 ~28/s, đỉnh ~280/s
Kết quả unknown 2,4 triệu × 0,1% ~2.400 mỗi ngày
Entry ledger 2 triệu × 5 10 triệu dòng/ngày
Dung lượng ledger 10 triệu × 150 B ~1,5 GB/ngày, ~550 GB/năm

Throughput là phần dễ: ~280 lần ghi/s vừa sức một PostgreSQL primary. Con số quyết định là 2.400 kết quả unknown mỗi ngày. Đoán mò từng trường hợp nghĩa là ngày nào cũng có trừ tiền trùng hoặc mất doanh thu.

  • Gateway: service bên ngoài làm việc với tổ chức thẻ. Nó nắm sự thật về việc tiền đã di chuyển hay chưa.
  • Authorize / capture: authorize giữ số tiền; capture mới lấy tiền. Nhiều shop gộp cả hai trong một lệnh.
  • Idempotency key: giá trị duy nhất khiến các lần lặp của một request chỉ tạo một hiệu ứng (xem idempotency, Idempotency-Key).
  • Ledger: sổ ghi các lần di chuyển tiền, chỉ thêm, tách khỏi dòng trạng thái payment.
  • File settlement: báo cáo hằng ngày của gateway về capture, refund, chi trả và phí.

Đang tải diagram…

  1. failed nghĩa là gateway nói không. Timeout không cho biết gì về quyết định đó.
  2. Trạng thái chỉ đi tiến: captured không quay về pending, kể cả khi một event đến muộn nói vậy.
Ranh giới Key Chống lại
Client → payment service Header Idempotency-Key từ app, cộng mỗi đơn một payment đang hoạt động Bấm đúp, app retry sau khi mất phản hồi
Payment service → gateway payment.id (pay_101), lưu trước lần gọi đầu Chính các lần retry của mình sau timeout

Key thứ hai ngăn sự cố ở đầu bài. Nó được commit cùng trạng thái pending, rồi mới gửi đi, và mọi lần retry dùng lại đúng key này.

Phản hồi bị mất khiến pay_101 thành unknown; cùng key, webhook và file settlement đều chốt về một lần trừ tiền
Phản hồi bị mất khiến pay_101 thành unknown; cùng key, webhook và file settlement đều chốt về một lần trừ tiềnBước 1/7: Trước khi gọi gateway, service lưu pay_101 ở trạng thái pending; key có trước khi tiền di chuyển.thời gianINSERT pendingkey = pay_101Payment servicelệnh từ checkoutPayments DBpay_101Gatewaybên ngoàiJob đối soáthằng ngày
Bước 1/7

Trước khi gọi gateway, service lưu pay_101 ở trạng thái pending; key có trước khi tiền di chuyển.

Trạng thái sau từng bước:

  1. pay_101 đang pending phía mình; gateway chưa thấy nó.
  2. Gateway đã capture 450.000 VND. Mình chưa biết.
  3. Mình ghi unknown; checkout báo “đang xác nhận”, không báo “thất bại”.
  4. Lần bấm thứ hai tìm thấy pay_101 đang hoạt động và trả lại nó. Không có lần trừ tiền thứ hai.
  5. Query trạng thái trả captured. Trạng thái, journal ledger và event outbox commit cùng nhau.
  6. Event id của webhook đến muộn được lưu; captured → captured là no-op.
  7. Đối soát thấy pay_101 đúng một lần trong file settlement và một lần trong ledger.

Nếu gateway chưa từng thấy pay_101, retry với cùng key là an toàn. Nếu chính query cũng timeout, payment vẫn unknown và một worker query lại có backoff.

Không bao giờ giải quyết unknown bằng một lệnh trừ tiền mới với key mới. Đó chính là trừ tiền trùng.

Event có chữ ký như charge.captured xử lý theo chữ ký và dedupe webhook: kiểm chữ ký trên raw body, insert event id dưới UNIQUE constraint trong cùng transaction với việc đổi trạng thái, chỉ chuyển trạng thái tiến, rồi trả 2xx.

Thiếu webhook là bình thường; query và đối soát sẽ bù.

Mỗi sự kiện tiền là một journal gồm hai entry trở lên; mỗi entry ghi nợ (debit) hoặc ghi có (credit) một tài khoản. Trong mỗi journal, debit bằng credit. Số dư là tổng các entry, không bao giờ là một cột lưu sẵn.

Tiền là bigint theo minor unit cộng mã tiền tệ. ISO 4217 liệt kê minor unit: VND là 0 (450.000 VND = 450000), USD là 2 ($49,99 = 4999). Không dùng float8; xem bẫy kiểu dữ liệu.

entry có CHECK (amount_minor > 0), CHECK (direction IN ('D','C')) và foreign key ghép (account_id, currency). CHECK chỉ thấy một dòng, nên “debit = credit” là một constraint trigger hoãn, chạy lúc COMMIT:

CREATE CONSTRAINT TRIGGER entry_balanced
AFTER INSERT ON entry
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_journal_balanced();

Một trigger thứ hai chặn UPDATE và DELETE. Sai sót được sửa bằng journal điều chỉnh đảo ngược nó, nên lịch sử còn nguyên.

Order, email và fulfilment biết về lần capture qua một dòng PaymentCaptured ghi vào outbox trong cùng transaction với trạng thái và journal (xem transactional outbox). Consumer dedupe theo event id.

  • Deadline: deadline của lệnh gọi gateway ngắn hơn của checkout, để service kịp trả “đang xác nhận” (xem timeout budget).
  • Thời hạn giữ key: gateway giữ idempotency key trong một khoảng tuỳ nhà cung cấp. Giải quyết mọi unknown sớm, trong khoảng đó.
  • Sweeper cho unknown: query mọi unknown cũ hơn 1 phút, có backoff. Cảnh báo theo số lượng và tuổi lâu nhất.
  • Đối soát: hằng ngày, khớp dòng settlement ↔ payment ↔ ledger theo mã tham chiếu gateway và số tiền. Mỗi chỗ lệch (thiếu ở một phía, lệch tiền hoặc phí) thành một ticket, rồi một journal điều chỉnh.
  • Phạm vi PCI DSS: với redirect hoặc iframe của gateway (hosted fields), số thẻ đi từ trình duyệt thẳng tới gateway; mình giữ token. Theo PCI SSC, merchant như vậy có thể dùng SAQ A; từ v4.0.1, trang nhúng iframe còn phải chống tấn công script. Acquirer xác nhận SAQ.

Demo ledger chạy lại một lần trùng như vậy, cùng các lần ghi bị từ chối, trên PostgreSQL 17.11 (examples/docker-compose.yml, cổng 5433):

Terminal window
docker compose -f examples/docker-compose.yml exec -T postgres \
psql -U hub -d hub -v ON_ERROR_STOP=0 < examples/src/payment-system/ledger.sql

Output thật, rút gọn:

--- 2. unbalanced journal: rejected at COMMIT
ERROR: journal 2 unbalanced in VND: debit 450000 <> credit 405000
--- 5. editing an entry: rejected
ERROR: ledger entries are immutable; post a correcting journal instead
--- 8. balances are sums of entries, never a stored column
account | kind | balance_minor
---------------------+-----------+---------------
bank_cash | asset | 441000
customer_refundable | liability | 0
gateway_fees | expense | 9000
gateway_receivable | asset | 0
sales | revenue | 450000
--- 9. trial balance: total debits = total credits
debits | credits
---------+---------
1800000 | 1800000

Demo không bao gồm: trigger không bắt được journal không có entry nào, kiểm soát đồng thời trên số dư (ví dụ hạn mức chi), và phân quyền; production còn REVOKE quyền UPDATE/DELETE của role ứng dụng.

Ưu điểm

  • unknown rõ ràng biến đoán mò thành một hàng đợi cần giải quyết.

  • Mỗi payment một key giúp retry an toàn mà không phải tin mạng.

  • Ledger chỉ thêm giải thích được mọi số dư; đối soát bắt phần còn sót.

  • < slot="cons">
  • Khách đôi khi thấy “đang xác nhận” thay vì kết quả ngay.

  • Nhiều thành phần hơn: sweeper, webhook handler, đối soát, outbox relay.

  • Số dư là query trên bảng ngày càng lớn; tài khoản lớn cần snapshot.

Nhược điểm

  1. Commit payment ở trạng thái pending kèm key gateway trước lần gọi gateway đầu tiên.
  2. Mọi timeout, mất kết nối và 5xx đều thành unknown; chỉ từ chối rõ ràng mới thành failed.
  3. Mỗi đơn chỉ một payment đang hoạt động, ép bằng partial UNIQUE index.
  4. Chạy sweeper cho unknown; cảnh báo khi unknown lâu nhất quá 15 phút.
  5. Lưu tiền bằng bigint minor unit kèm tiền tệ; ép journal cân bằng lúc commit.
  6. Entry ledger chỉ thêm, ép ở tầng database chứ không chỉ trong code.
  7. Kiểm chữ ký và dedupe webhook; chỉ chuyển trạng thái tiến.
  8. Đối soát hằng ngày với file settlement và theo dõi số chỗ lệch về 0.

Sai lầm đầu tiên chính là sự cố ở mục Vấn đề, viết thành code.

Retry một lần trừ tiền bị timeout

Hay gặp

try { await gw.charge({ amount, key: randomUUID() }); }
catch (e) { if (isTimeout(e)) await gw.charge({ amount, key: randomUUID() }); }

Nên viết

await markUnknown(pay.id); // không bao giờ "failed"
const s = await gw.getCharge(pay.id); // cùng key với lần gọi đầu
if (s === 'not_found') await gw.charge({ amount, key: pay.id });

Vì sao

Key mới báo cho gateway đây là lần trừ tiền mới. Cùng key thì gateway trả lại kết quả của lần đầu.

Sai lầm thứ hai nằm trong ledger, nơi một lần sửa trông vô hại.

Cách hiểu sai Hệ quả Cần làm
Timeout nghĩa là trừ tiền thất bại Khách trả lại; trừ tiền trùng unknown + query trạng thái
Webhook là nguồn sự thật Event mất hoặc muộn làm payment kẹt Webhook, query trạng thái và đối soát cùng lúc
Cột số dư cập nhật tại chỗ Lệch số không ai giải thích được Số dư = tổng entry
Số tiền dạng float8 hoặc chuỗi thập phân Sai số làm tròn, lẫn tiền tệ bigint minor unit + tiền tệ
  1. Query trạng thái cho một payment unknown trả “not found”. Vì sao retry với cùng key là an toàn, còn key mới thì không?
  2. Vì sao “debit = credit” là trigger hoãn chứ không phải CHECK constraint?
  3. Một lệnh refund bị timeout. Ghi trạng thái nào và entry ledger nào?