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

Đối chiếu thực tế: các hệ thanh toán thật giữ tiền khớp thế nào?

Đặt thiết kế của bạn cạnh các hệ thanh toán đã công bố kiến trúc: mỗi hệ đặt lệnh trừ tiền, sổ cái và đối soát ra sao, vì ràng buộc gì, và vì sao chọn khác bạn.

TL;DR: Bài này đặt thiết kế thanh toán của bạn cạnh các nguồn công khai: Stripe (2017, 2024 và tài liệu API), Square (2019), Modern Treasury (2024), cùng tài liệu tích hợp của MoMo, VNPAY, ZaloPay. Mỗi case chạm một trục của module: gửi lại lệnh trừ tiền, giữ số dư, chốt và đối soát. Phần lớn khác biệt đến từ ràng buộc cụ thể của từng case: API nhà cung cấp phát lại kết quả cũ hay chỉ chặn mã trùng, và tài khoản có nóng hay không. Stripe và MoMo trả lại kết quả cũ cho cùng khoá; nhà cung cấp trong bảng của bạn chỉ từ chối mã trùng, nên sàn của bạn phải tự tra cứu.

Bảng ràng buộc của bạn: đỉnh 3.000 lượt thanh toán mỗi giây, khoảng 3.600 giao dịch mập mờ mỗi đợt sale, nhà cung cấp từ chối mã trùng, API tra cứu chỉ 10 lượt mỗi giây. Đọc blog kỹ thuật của Stripe xong, câu hỏi tự nhiên là "sao họ không phải khổ thế?". Câu trả lời thường là một ràng buộc mà họ có và bạn không.

1. Analogy: hai bưu điện trả lời khác nhau

Hai bưu điện cùng nhận kiện. Hỏi lại cùng mã vận đơn, bưu điện thứ nhất đọc lại biên nhận cũ, còn bưu điện thứ hai chỉ nói "mã này đã dùng". Người gửi ở bưu điện thứ hai chỉ cầm biên nhận: muốn biết kiện tới chưa phải gọi tổng đài tra mã, hoặc chờ phiếu báo phát.

Đời thườngLuồng thanh toán
Bưu điện đọc lại biên nhận cũ khi hỏi lại mãNhà cung cấp trả kết quả cũ cho cùng khoá (Stripe, MoMo với requestId)
Bưu điện chỉ nói "mã này đã dùng"Nhà cung cấp trong bảng của bạn: mã trùng thì từ chối
Người gửi cầm biên nhậnSàn của bạn, phải tự đi hỏi kết quả
Mã vận đơnMã giao dịch, hay idempotency key
Gọi tổng đài tra mãAPI tra cứu trạng thái, 10 lượt mỗi giây
Phiếu báo phátIPN hoặc webhook, lời báo kết quả nhà cung cấp gọi vào server bạn
Bảng kê cuối kỳFile đối soát lúc 6 giờ sáng hôm sau
💡 Cách nhớ

Trước khi chép một case, hỏi: ràng buộc nào của họ mà bạn không có?

2. Cách đọc một case: tìm ràng buộc khác bạn

Module có ba trục quyết định. Bài 03 chọn thứ tự bước quanh lệnh trừ tiền và máy trạng thái có trạng thái "chưa rõ". Bài 04 chọn cách ghi sổ. Bài 05 chọn cách chốt giao dịch mập mờ và so sổ với file. Với mỗi case, bạn tách ràng buộc của họ, lựa chọn của họ, trục bị tác động, rồi mới hỏi thiết kế của mình đổi ở đâu.

Tài liệu tích hợp của MoMo, VNPAY, ZaloPay chỉ nói nhà cung cấp hứa gì với sàn, nên bài này không suy đoán kiến trúc nội bộ của họ.

3. Gửi lại cùng mã thì nhận về gì?

Thử đoán trước

Lệnh trừ tiền một chạm hết 30 giây timeout. Với API của Stripe, gửi lại kèm cùng một khoá. Với nhà cung cấp trong bảng ràng buộc của bạn, gửi lại cùng mã thì bị từ chối là mã trùng. Bước nào luồng của bạn cần mà luồng Stripe không cần?

Stripe 2017 và tài liệu API: cùng khoá, kết quả cũ

Idempotency key là chuỗi duy nhất client sinh cho một ý định và gửi kèm mọi lần thử, để server biết lần sau là gửi lại chứ không phải lệnh mới. Bài "Designing robust and predictable APIs with idempotency" (Stripe, 22/2/2017) viết rằng khi kết nối đứt, kết quả "is ambiguous from the perspective of the client"; lần thử lại cùng khoá nhận về kết quả đã lưu. Tài liệu xử lý lỗi HTTP của Stripe (tra 19/10/2026) giữ khoá ít nhất 24 giờ. Quá hạn mà vẫn chưa rõ thì tài liệu bảo dừng thử lại, tra đối tượng và dùng webhook để đối soát.

Ràng buộc khác: Stripe chạy lệnh trừ tiền, nên trả lại response cũ chỉ là một lần đọc phía họ. Nhà cung cấp trong bảng của bạn hứa ít hơn: từ chối mã trùng mà không nói lần đầu đã trừ chưa.

MoMo, VNPAY: hai loại mã, hai hành vi

Trang Idempotency của MoMo ghi rằng gửi lại cùng requestId thì "MoMo returns the latest status of the request", và requestId có hiệu lực tối thiểu 31 ngày. Nhưng bảng mã kết quả MoMo có mã 41 "Duplicated orderId.", còn tài liệu VNPAY yêu cầu vnp_TxnRef "Không được trùng lặp trong ngày."

Bảng của bạn bám kiểu orderId và vnp_TxnRef: không trừ lần hai, nhưng cũng không nói lần đầu đã trừ chưa. Vì thế luồng của bạn có thêm trạng thái "chưa rõ" và hàng chờ tra cứu. Hỏi lại 3.600 giao dịch mập mờ, mỗi giao dịch một lượt, ở 10 lượt mỗi giây mất 360 giây, tức 6 phút.

Hai cột cùng bốn bước: bên Stripe, gửi lại cùng Idempotency-Key sau timeout thì nhận lại kết quả cũ trong ít nhất 24 giờ, quá hạn mới tra cứu và webhook; bên bảng của bạn, gửi lại cùng mã bị từ chối là mã trùng mà không nói đã trừ chưa, nên phải gọi API tra cứu tối đa 10 lượt mỗi giây

Nhớ lại — Retry cần idempotency

Timeout chỉ là phỏng đoán: request tưởng thất bại có thể đã chạy xong. Idempotency key làm retry vô hại. Case này thêm một tầng: vô hại chưa có nghĩa là biết được kết quả.

Cả hai phía đều coi lệnh trừ tiền là pivot transaction, bước mà sau nó không còn bù ngược được. Trang Saga pattern của Microsoft Azure (25/2/2025) định nghĩa: "Pivot transactions serve as the point of no return in the saga." Stripe cũng không thoát điểm đó. Họ giữ kết quả của nó ở phía mình.

Thử ngẫmNếu nhà cung cấp của bạn trả lại kết quả cũ cho mã trùng như MoMo với requestId, bạn bỏ được trạng thái "chưa rõ", hay chỉ rút ngắn thời gian nằm trong đó?

4. Số dư nằm ở đâu khi tài khoản bị ghi dồn?

Thử đoán trước

Lúc đỉnh, mỗi tài khoản chung của bạn nhận 3.000 lượt ghi mỗi giây, trong khi một hàng Postgres bị ghi dồn chịu cỡ 1.200 trên máy tác giả. Một công ty bán sổ cái cho nhiều khách, không biết trước tài khoản nào sẽ nóng. Họ cập nhật số dư cùng transaction, hay tách ra?

Square Books 2019: bút toán chỉ insert, số dư vẫn cập nhật

Theo bài "Books, an immutable double-entry accounting database service" (Square, 16/10/2019), cách cũ "stopped scaling with our payments business": không tách được một lượt thanh toán thành nhiều lần chi trả. Books bắt mọi bút toán cân về 0 và các bảng bút toán chỉ có insert. Riêng bảng books thì "inherently mutable (current balance is updated on every operation)".

Đặt cạnh ba phương án ở bài 04, Books gần nhất với sổ cái kèm số dư dẫn xuất. Bài viết không nói số dư có cập nhật cùng transaction với lệnh insert không, nên đừng suy ra điều đó. Ràng buộc của họ là bài toán chi trả nhiều lần; hướng đi thì giống bạn: không bao giờ sửa bút toán đã ghi.

Lưu chuỗi sự kiện bất biến thay vì ghi đè trạng thái, rồi dựng trạng thái bằng cộng dồn. Với sổ cái, số dư là kết quả cộng dồn; cột số dư chỉ là bản dẫn xuất để đọc nhanh.

Modern Treasury 2024: tài khoản nóng và gom lô

Bài "Behind the scenes: how we built Ledgers for high throughput" (Modern Treasury, 25/1/2024) gọi tên đúng vấn đề của bạn. Tài khoản nóng (hot account) là tài khoản có mặt trong phần lớn giao dịch. Khi tải cao, "contention for the single balance cache row causes Transaction write latency to spike dramatically".

Họ cho client chọn trên từng bút toán. Bút toán cần khoá số dư chạy tuần tự bằng row lock của Postgres; bút toán còn lại vào hàng đợi, một job gom lô rồi mới cập nhật số dư. Mục tiêu là xong trong 60 giây, p90 đo được 1 giây. Cái giá cũng được nêu: lô dài thì trễ, lô ngắn thì lại tranh chấp.

Hai cột cùng 3.000 lượt mỗi giây: cập nhật số dư cùng transaction thì mọi lượt dồn vào một hàng số dư, vượt trần khoảng 1.200; gom lô bất đồng bộ thì chỉ INSERT bút toán, job gom lô cập nhật số dư một lần mỗi lô

Ràng buộc khác bạn: họ không biết trước tài khoản nào nóng, nên đẩy quyết định xuống từng bút toán. Bạn thì biết đúng ba tài khoản: phải thu nhà cung cấp, phí nhà cung cấp, doanh thu phí sàn, và không bước nào đọc số dư của chúng lúc sale. Số dư người bán thì bị đọc 500 lượt mỗi giây trước khi chi trả và duyệt hoàn tiền. Bạn chọn được theo tài khoản.

Thử ngẫmMột người bán lớn chiếm 40% đơn của đợt sale. Tài khoản phải trả của người đó có thành tài khoản nóng không, và khi đó bạn gom lô nó được không?

5. Vì sao Stripe chịu được bốn ngày mà bạn thì không?

Thử đoán trước

Sổ cái Stripe đo độ khớp theo tỉ lệ tiền và chấp nhận vài ngày. Bạn phải chốt mọi giao dịch trong 24 giờ. Ràng buộc nào khiến bạn không được chậm như họ?

Stripe Ledger 2024: tài khoản trung chuyển phải về 0

Bài "Ledger: Stripe's system for tracking and validating money movement" (Stripe, 16/2/2024) mô tả Ledger là "an immutable log of events" và viết: "Each day, Ledger sees five billion events and 99.99% of our dollar volume is fully ingested and verified within four days." Cơ chế phát hiện lệch là tài khoản trung chuyển (clearing account), nơi tiền chỉ đi qua và ở trạng thái ổn định phải về 0. Luồng nào đứt giữa chừng thì số dư nằm lại, và truy vấn tìm lệch chỉ là tìm tài khoản trung chuyển có số dư khác 0.

Ràng buộc khác bạn: Stripe đo theo tỉ lệ tiền trên năm tỉ sự kiện mỗi ngày; chỉ số đó không đo một khách phải chờ bao lâu mới biết đơn mình ra sao. Bạn gắn giá cho từng giao dịch: treo quá 30 phút thì 30% khách gọi tổng đài, mỗi cuộc 15.000 đ; quá 24 giờ thì khách khiếu nại qua ngân hàng. Bốn ngày là 96 giờ, gấp bốn lần hạn của bạn.

Ý tưởng trung chuyển thì chép được. Tài khoản phải thu nhà cung cấp ghi Nợ 247.250 đ mỗi đơn trung bình rồi được ghi Có khi tiền quyết toán về. So xong file của một ngày, phần phải thu của ngày đó phải về 0.

VNPAY và ZaloPay: sàn tự đi hỏi

VNPAY gọi IPN (Instant Payment Notification, lời báo kết quả server nhà cung cấp gọi vào server bạn) với "Tổng số lần gọi tối đa: 10 lần", cách nhau 5 phút, và ghi về Return URL: "Không cập nhật kết quả giao dịch tại địa chỉ này". ZaloPay ghi "Callback endpoints may receive duplicate events", và 15 phút chưa có callback thì sàn phải tự gọi QueryOrder.

Webhook của Stripe thì gửi lại "for up to three days with an exponential back off in live mode". IPN của VNPAY hết lượt sau khoảng 45 phút (10 lần gọi tổng cộng, cách nhau 5 phút), cùng cỡ với ràng buộc tự đặt khoảng 50 phút của bảng bạn, nên endpoint của bạn chết một tiếng là mất lời báo. Đó là lý do bài 05 ghép IPN, tra cứu định kỳ và file đối soát thay vì chờ một nguồn.

6. Bảng tổng hợp: ràng buộc nào đổi lựa chọn nào

Tự điền trước khi xem đáp án

Với hai dòng Square Books và Modern Treasury, tự điền hai cột "Ràng buộc khác bạn" và "Trục" từ mục 4, viết ra giấy rồi mới đối chiếu bảng dưới.

CaseNămRàng buộc khác bạnLựa chọnTrục
Stripe, idempotency2017, tài liệu tra 2026Chính họ chạy lệnh trừ tiềnCùng khoá trả kết quả cũ, ít nhất 24 giờThứ tự bước
MoMo, VNPAYtài liệu tra 2026Mã request trả trạng thái, mã đơn từ chối trùngPhải biết đang gửi lại loại mã nàoThứ tự bước
Square Books2019Một lượt thanh toán tách nhiều lần chi trảBút toán chỉ insert, bảng số dư cập nhật mỗi thao tácSổ cái
Modern Treasury2024Không biết trước tài khoản nào nóngChọn tuần tự hay gom lô trên từng bút toánSổ cái
Stripe Ledger2024Năm tỉ sự kiện mỗi ngày, đo theo tiềnTài khoản trung chuyển về 0, 99,99% trong bốn ngàyChốt và đối soát
VNPAY, ZaloPaytài liệu tra 2026IPN hết lượt sau khoảng 45 phút, callback có thể trùngSàn tự tra cứuChốt và đối soát

Bốn trong sáu dòng (Stripe idempotency, Square Books, Modern Treasury, Stripe Ledger) là nhà cung cấp hoặc bên bán hạ tầng sổ cái cho người khác. Họ có thứ bạn không có (kết quả của lệnh, quy mô dữ liệu) và thiếu thứ bạn có (biết trước tài khoản nào nóng). Hai dòng tài liệu Việt Nam không phải lựa chọn; chúng là ràng buộc bạn nhận về.

7. Ba cách chép case sai

❌ Nhầm 1: chép "cứ gửi lại cùng khoá" của Stripe sang nhà cung cấp từ chối mã trùng. Nhận lỗi mã trùng, code coi là thất bại rồi tạo mã mới:

SQL
-- provider answers "duplicate txn_ref" after a timeout
UPDATE payments SET status = 'failed'
WHERE txn_ref = 'GD1001';
-- then a NEW txn_ref is created and the charge is sent again

✅ Mã trùng chỉ nói lần đầu đã tới nơi. Nếu 720 giao dịch một chạm đã trừ của một đợt đều bị gửi lại bằng mã mới, mỗi lần trừ hai lần tốn khoảng 18.000 đ, cộng lại cỡ 12,96 triệu đồng mỗi đợt. Chuyển sang "chưa rõ" rồi đưa vào hàng tra cứu:

SQL
UPDATE payments SET status = 'unknown'
WHERE txn_ref = 'GD1001' AND status = 'pending';
INSERT INTO query_schedule (txn_ref, created_at, attempt, next_check_at)
VALUES ('GD1001', now(), 0, now());

❌ Nhầm 2: chép gom lô cho mọi tài khoản. Số dư người bán chậm tới khoảng 60 giây, trong khi bước duyệt hoàn tiền đọc nó.

✅ Chỉ gom lô ba tài khoản chung. Tài khoản người bán bị ghi rải ra nhiều hàng, nên cập nhật cùng transaction vẫn dưới trần:

SQL
BEGIN;
INSERT INTO entries (txn_id, account_id, amount)
VALUES (:txn_id, :seller, -237500);  -- credit leg, negative
UPDATE accounts SET balance = balance - 237500
WHERE id = :seller;
COMMIT;

❌ Nhầm 3: lấy "99,99% trong bốn ngày" làm mục tiêu đối soát. Chỉ số tính theo tiền nên vài giao dịch nhỏ treo lâu không làm nó nhúc nhích. Quy ra số lượt, 0,01% của 1.800.000 lượt một đợt vẫn là cỡ 180 giao dịch được phép trễ.

✅ Đếm theo giao dịch: số giao dịch "chưa rõ" quá 24 giờ phải bằng 0, và phải thu của hôm qua phải về 0 trước 12 giờ trưa.

8. Đào sâu

📚 Đào sâu: nguồn để tự đọc

Tài liệu API viết cho sàn tích hợp, nên cho bạn biết ràng buộc mình phải chịu. Blog kỹ thuật viết từ phía nhà cung cấp hoặc bên giữ sổ. Hãy lấy ý tưởng của họ và tự tính lại con số theo ràng buộc của bạn.

9. Liên hệ các bài khác

10. Tóm tắt

  • Đọc blog của một công ty thanh toán: hỏi trước ràng buộc nào của họ mà bạn không có, như kết quả của lệnh trừ, quy mô hay độ nóng của tài khoản.
  • Nhà cung cấp từ chối mã trùng: coi là "chưa rõ" và đi tra cứu, đừng coi là thất bại.
  • Tài khoản có mặt trong mọi lượt: gom lô số dư được, miễn không bước nào cần đọc nó ngay.
  • Mỗi giao dịch treo có giá riêng: đặt mục tiêu đối soát theo giao dịch, không theo tỉ lệ tiền.

11. Tự kiểm tra

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Vì sao Stripe trả lại được kết quả cũ cho cùng Idempotency-Key, còn nhà cung cấp trong bảng ràng buộc của bạn chỉ báo mã trùng?
  2. Q2
    Modern Treasury cho client chọn tuần tự hay gom lô trên từng bút toán. Vì sao bạn chọn được theo tài khoản, và chọn thế nào?
  3. Q3
    Khi nào một hệ thanh toán được đặt mục tiêu 99,99% tiền khớp trong bốn ngày như Stripe Ledger, và vì sao bạn thì không?
  4. Q4
    Vì sao ý tưởng tài khoản trung chuyển phải về 0 của Stripe chép được sang thiết kế của bạn, dù quy mô khác hẳn?
  5. Q5
    Stripe gửi lại webhook tới ba ngày, VNPAY gửi lại IPN 10 lần cách 5 phút. Khác biệt đó đổi gì trong cách chốt giao dịch của bạn?
  6. Q6
    Đồng nghiệp đề xuất: 'Square chỉ insert bút toán, vậy mình bỏ hẳn cột số dư cho giống họ.' Lập luận hỏng ở đâu?

Bài tiếp theo: Tổng kết module

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

Tổng kết case thanh toán: thiết kế hoàn chỉnh và các cái giá