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
Vấn đề
Phần tiêu đề “Vấn đề”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ả?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| 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 lượng tải (giả định nêu rõ)
Phần tiêu đề “Ước lượng tải (giả định nêu rõ)”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.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”- 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í.
State machine của payment
Phần tiêu đề “State machine của payment”Đang tải diagram…
failednghĩa là gateway nói không. Timeout không cho biết gì về quyết định đó.- Trạng thái chỉ đi tiến:
capturedkhông quay vềpending, kể cả khi một event đến muộn nói vậy.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Hai idempotency key
Phần tiêu đề “Hai idempotency key”| 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.
Timeout → query trạng thái → đối soát
Phần tiêu đề “Timeout → query trạng thái → đối soát”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:
pay_101đangpendingphía mình; gateway chưa thấy nó.- Gateway đã capture 450.000 VND. Mình chưa biết.
- Mình ghi
unknown; checkout báo “đang xác nhận”, không báo “thất bại”. - 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. - Query trạng thái trả
captured. Trạng thái, journal ledger và event outbox commit cùng nhau. - Event id của webhook đến muộn được lưu;
captured → capturedlà no-op. - Đố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.
Webhook
Phần tiêu đề “Webhook”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ù.
Ledger bút toán kép
Phần tiêu đề “Ledger bút toán kép”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.
Outbox cho event phía sau
Phần tiêu đề “Outbox cho event phía sau”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.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- 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
unknownsớm, trong khoảng đó. - Sweeper cho unknown: query mọi
unknowncũ 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.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”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):
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.sqlOutput thật, rút gọn:
--- 2. unbalanced journal: rejected at COMMITERROR: journal 2 unbalanced in VND: debit 450000 <> credit 405000--- 5. editing an entry: rejectedERROR: 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 | 1800000Demo 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.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
unknownrõ 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
Checklist production
Phần tiêu đề “Checklist production”- Commit
paymentở trạng tháipendingkèm key gateway trước lần gọi gateway đầu tiên. - 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ànhfailed. - Mỗi đơn chỉ một payment đang hoạt động, ép bằng partial UNIQUE index.
- Chạy sweeper cho unknown; cảnh báo khi
unknownlâu nhất quá 15 phút. - Lưu tiền bằng
bigintminor unit kèm tiền tệ; ép journal cân bằng lúc commit. - Entry ledger chỉ thêm, ép ở tầng database chứ không chỉ trong code.
- Kiểm chữ ký và dedupe webhook; chỉ chuyển trạng thái tiến.
- Đối soát hằng ngày với file settlement và theo dõi số chỗ lệch về 0.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”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 đầuif (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ệ |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Query trạng thái cho một payment
unknowntrả “not found”. Vì sao retry với cùng key là an toàn, còn key mới thì không? - Vì sao “debit = credit” là trigger hoãn chứ không phải
CHECKconstraint? - Một lệnh refund bị timeout. Ghi trạng thái nào và entry ledger nào?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- The Idempotency-Key HTTP Header Field — IETF draft: ngữ nghĩa key khi retry request không idempotent.
- CREATE TRIGGER — PostgreSQL 17: constraint trigger phải là
AFTER ROWvà có thể hoãn tới cuối transaction. - Constraints — PostgreSQL 17: vì sao
CHECKkhông được tham chiếu dòng khác. - ISO 4217 data standards — SIX: danh sách tiền tệ kèm minor unit.
- FAQ clarifies new SAQ A eligibility criteria — PCI SSC: iframe và redirect theo PCI DSS v4.0.1.
- Document Library — PCI SSC: PCI DSS và các bộ câu hỏi tự đánh giá (SAQ).