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

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

Hàng giữ 10 phút mà khách có 15 phút để trả, hoàn tiền mất tới 30 ngày. Chọn giữ hàng rồi trừ, trừ rồi tạo đơn, hay giữ tiền rồi thu sau.

TL;DR: Lệnh trừ tiền ở nhà cung cấp không đảo được: đã thành công thì chỉ còn hoàn tiền, mất từ 1 tới 30 ngày và 2.750 đ phí không quay về. Vì vậy bước nào còn có thể trả lời "không" (giữ hàng, ghi mã giao dịch) phải đứng trước nó và có bước bù rẻ (bước bù là việc làm ngược lại để gỡ một bước đã chạy, như nhả hàng về kho); bước sau nó (chốt đơn, ghi sổ) phải không có lý do nghiệp vụ để hỏng, chỉ cần thử lại. Trạng thái thanh toán đi một chiều qua chờ trả (pending), chưa rõ (unknown) tới đã trả (paid) hoặc thất bại (failed), và hàng chỉ được nhả khi thanh toán đã thất bại chắc chắn, không theo đồng hồ 10 phút.

Khách mở trang cổng lúc 21:00:30, đi tìm điện thoại và trả xong lúc 21:12. Hàng của đơn hết hạn giữ lúc 21:10, đã bán cho người khác. Lệnh trừ thành công thì không huỷ tức thời được: 250.000 đ hoàn qua thẻ ATM nội địa mất tới 7 ngày, 2.750 đ phí thì sàn mất hẳn.

Không bước nào viết sai; thứ tự mới sai. Bài này bắt bạn chọn thứ tự bước quanh lệnh trừ tiền, rồi dựng máy trạng thái (state machine: tập trạng thái cộng các lối chuyển được phép) để tin tới trễ không kéo được đơn đã trả về trước.

1. Ba thứ tự quanh lệnh trừ tiền: bạn chấp nhận cái giá nào?

Các dòng của bảng ràng buộc ở bài 01 mà bài này dùng, giữ nguyên số:

Ràng buộcGiá trị
Hai kênhtrang cổng 70% (biết kết quả qua IPN hoặc API tra cứu); một chạm 30% (timeout 30 giây)
Hai hạngiữ hàng 10 phút; trang cổng mở 15 phút
Giao dịch mập mờkhoảng 3.600 mỗi đợt sale, 60% trong số đó tiền đã bị trừ
Gửi lại cùng mãbị từ chối là mã trùng; mã mới là một lệnh trừ mới
Giữ tiền rồi thu sauchỉ khoảng một nửa lượt hỗ trợ; giữ tối đa 7 ngày, huỷ trước khi thu thì không mất phí
Phí và hoàn tiềnphí 2.750 đ một đơn, không hoàn; hoàn tiền từ 1 ngày (ví) tới 30 ngày (thẻ quốc tế)

IPN (Instant Payment Notification) là lời gọi nhà cung cấp gửi vào server của bạn để báo kết quả, có thể tới trễ, trùng hoặc không tới. Giữ tiền rồi thu sau (authorize rồi capture): lệnh đầu chỉ khoá tiền của khách, lệnh sau mới chuyển đi.

Điểm quyết định

Ràng buộc: Giữ hàng 10 phút · trang cổng mở 15 phút · 3.600 giao dịch mập mờ mỗi đợt, 60% đã trừ · hoàn tiền tới 30 ngày, mất 2.750 đ phí · một nửa lượt không hỗ trợ giữ tiền

Lệnh trừ tiền không đảo được. Bạn đặt nó trước hay sau bước giữ hàng, và chấp nhận cái giá nào?

Chọn trước khi đọc tiếp — phần sau phân tích từng cái giá.

Nền: saga và bước bù

Chọn một phương án trước khi đọc tiếp. Bốn kiểu lệch tiền đã đếm ở bài 02 không đếm lại ở đây.

2. Quầy vé rạp phim: giữ ghế, quẹt thẻ, in vé

Ở quầy vé, nhân viên khoá ghế, đưa máy POS cho bạn quẹt thẻ, rồi in vé. POS treo ở "đang xử lý" thì họ không quẹt lại mà gọi ngân hàng hỏi, ghế vẫn khoá.

Quầy véLuồng thanh toán
Khoá ghếGiữ hàng; bù bằng nhả hàng về kho
Quẹt thẻLệnh trừ tiền, bước không đảo được
In véChốt đơn, ghi sổ: chỉ có thể chậm, không có lý do để từ chối
POS treo, hỏi ngân hàngTrạng thái unknown, tra cứu bằng mã cũ

Analogy gãy ở một chỗ: rạp khoá ghế bao lâu cũng được, còn sàn chỉ giữ hàng 10 phút.

3. Vì sao lệnh trừ tiền phải đứng sau mọi bước có thể hỏng?

Hướng dẫn saga của Microsoft gọi bước như lệnh trừ tiền là pivot transaction, "the point of no return": sau nó, bước bù hết ý nghĩa và mọi việc còn lại phải chạy tới cùng (Saga design pattern). Trước pivot là bước bù được (compensable), sau pivot là bước thử lại được (retryable), và bước thử lại được phải idempotent: chạy hai lần cho cùng kết quả như một lần.

Saga là chuỗi giao dịch cục bộ, mỗi bước có bước bù; có bước không bù sạch được, và retry chỉ an toàn khi bước idempotent. Lệnh trừ tiền là loại không bù sạch: bước bù duy nhất là hoàn tiền, một lệnh chuyển tiền mới.

Ba vùng quanh lệnh trừ tiền: bước trên có bước bù rẻ, lệnh trừ chỉ còn hoàn tiền, bước dưới chỉ thử lại

Luật rút ra: bước có thể hỏng vì nghiệp vụ (hết hàng, giá đã đổi) phải đứng trên lệnh trừ tiền, nơi hỏng chỉ tốn một bước bù rẻ. Bước dưới nó chỉ được hỏng vì lỗi tạm thời (DB bận, process chết), thứ mà thử lại là hết.

Thử ngẫmsàn thêm bước "kiểm giá cuối cùng" vì giá có thể đổi. Bước đó thuộc vùng nào?

4. Mổ từng cái giá dưới ràng buộc

4.1 B: trừ tiền rồi mới tạo đơn

B không giữ hàng cho ai bỏ đi, nhưng đặt bước duy nhất có thể nói "hết hàng" xuống dưới lệnh trừ tiền: mỗi lượt trả xong mà kho đã hết thành một lượt hoàn tiền, đúng lúc đợt sale làm hàng hết nhanh nhất.

4.2 A: giữ hàng rồi trừ tiền, và cái hạn 10 phút

Cái giá của A nằm ở chỗ bước bù "nhả hàng" chạy khi nào.

Thử đoán trước

Worker nhả hàng theo đồng hồ 10 phút như case flash sale. Trong 3.600 giao dịch mập mờ của một đợt, tối đa bao nhiêu lượt có thể thành "khách đã bị trừ mà hàng đã về kho", và sàn mất tối đa bao nhiêu tiền phí? Đoán hai con số trước khi đọc tiếp.

Lời giải: tối đa khoảng 2.160 lượt, phần 60% của 3.600 giao dịch mập mờ mà tiền đã bị trừ. Cái nào còn chưa rõ khi qua mốc 10 phút thì hàng đã về kho, và nếu đợt sale hết hàng thì cả 2.160 lượt thành hoàn tiền, mất tới 2.160 × 2.750 đ = 5.940.000 đ phí mỗi đợt.

Trang cổng còn một khe hở riêng:

Trục thời gian: hàng về kho lúc 10:00, khách trả xong lúc 12:00, trang cổng hết hạn lúc 15:30

Hạn 10 phút là của bạn, hạn 15 phút là của nhà cung cấp; nhả theo đồng hồ của bạn là đóng cửa kho khi cửa nhận tiền vẫn mở. Cách sửa đổi điều kiện của bước bù: khi khách bắt đầu thanh toán, chỗ giữ chuyển từ held sang paying cùng transaction ghi mã giao dịch, worker chỉ nhả chỗ còn held, và chỗ paying chỉ về kho khi thanh toán chốt thất bại.

Bài đó đã dựng bảng holds và ba cách xử lý người trả tiền sau hạn: hoàn tiền, trừ lại nếu còn hàng, hoặc gia hạn khi bắt đầu thanh toán. Ở đây hạn 15 phút của trang cổng chọn sẵn cách thứ ba.

Cái giá còn lại: 1.440 giao dịch mập mờ mà tiền chưa bị trừ (40% của 3.600) giữ hàng tới lúc chốt, việc của bài 05.

4.3 C: giữ tiền rồi thu sau

C dời điểm không quay lại xuống lệnh thu: hết hàng sau khi giữ tiền thì huỷ, không ai mất đồng nào.

2PC cần participant có pha prepare: ghi bền, hứa yes, chờ coordinator. Lệnh trừ thẳng không có pha đó, nên bạn chỉ còn cách sắp thứ tự bước. Giữ tiền giống một pha prepare, chỉ khác là lời hứa hết hạn sau 7 ngày nên không ai treo mãi.

Dưới ràng buộc này C mua được ít: A đã đưa bước "còn hàng không" lên trên lệnh trừ, phía dưới chẳng còn gì cần huỷ. Còn cái giá thì đủ: 900.000 trong 1.800.000 lượt mỗi đợt không giữ tiền được nên bạn nuôi hai luồng, và lệnh thu cũng có thể timeout.

4.4 Kết luận dưới ràng buộc này

Chọn A, với bước bù "nhả hàng" khoá theo trạng thái thanh toán thay vì đồng hồ. B bị loại vì bước có thể hết hàng nằm dưới lệnh trừ. C chỉ đáng khi có bước có thể nói "không" mà chỉ chạy được sau khi khách đã trả.

5. Máy trạng thái một chiều có trạng thái chưa rõ

Một chạm timeout ở giây thứ 30 thì bạn không biết tiền đã bị trừ chưa; ghi failed là đoán, và với giao dịch mập mờ thì sai khoảng 60% số lần. Nên "chưa biết" phải là trạng thái thật, unknown.

Máy trạng thái pending, unknown, paid, failed; mọi cạnh đi về hai trạng thái cuối, không cạnh nào quay lui

Hai trạng thái cuối chỉ nhận tin chắc chắn: IPN đã xác thực hoặc kết quả tra cứu. Bảng payments đặt cạnh bảng holds của case flash sale, có hold_id, txn_ref (UNIQUE, gửi cho nhà cung cấp và dùng lại khi thử lại) và status mặc định pending. Bắt đầu thanh toán là một transaction ngắn, commit trước khi gọi nhà cung cấp:

SQL
BEGIN;
UPDATE holds SET status = 'paying'
WHERE id = :hold_id AND status = 'held' AND expires_at > now();
-- 0 rows: hold already expired -> ROLLBACK, do not charge
INSERT INTO payments (hold_id, txn_ref) VALUES (:hold_id, :txn_ref);
COMMIT;
-- only now: redirect to gateway page, or call the one-touch charge

Gọi trước mà process chết trước khi ghi thì khoản tiền nằm ở nhà cung cấp mà DB không có mã để tra cứu.

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

IPN báo thành công. Điền vế còn thiếu để IPN trùng không làm gì, timeout tới trước không chặn được nó, và thanh toán đã failed không bị kéo sang paid:

UPDATE payments SET status = 'paid' WHERE id = :payment_id AND ______;

Lời giải, trong transaction chốt thành công:

SQL
BEGIN;
UPDATE payments SET status = 'paid'
WHERE id = :payment_id AND status IN ('pending', 'unknown');
-- 0 rows: duplicate IPN or already final -> ROLLBACK, reply OK
UPDATE holds SET status = 'sold'
WHERE id = :hold_id AND status = 'paying';
-- ledger entries of lesson 04 go here, same transaction
COMMIT;

Vế IN nhận cả hai trạng thái chưa cuối, nên tin nào tới trước thì cuối cùng vẫn là paid. 0 row mà status là failed thì hai nguồn chắc chắn mâu thuẫn nhau, đưa vào danh sách lệch cho bài 05. Chốt thất bại đối xứng: failed, chỗ giữ sang released, cộng trả kho, cùng một COMMIT.

Thử ngẫmbỏ unknown, để timeout giữ nguyên pending thì máy vẫn một chiều. Worker nhả hàng khi đó có phân biệt được khách chưa trả với khách trả mà chưa rõ không?

6. Tự đo: IPN và timeout tới lệch thứ tự

Tạo 10.000 thanh toán pending và bảng ipn_log(payment_id bigint), rồi cho hai script pgbench bắn sự kiện ngẫu nhiên:

SQL
-- ipn_naive.sql (the other script sets 'unknown' without INSERT)
\set id random(1, 10000)
BEGIN;
INSERT INTO ipn_log VALUES (:id);
UPDATE payments SET status = 'paid' WHERE id = :id;
COMMIT;

Bản một chiều thêm đúng các vế điều kiện của mục 5. Chạy pgbench -n -c 20 -j 4 -t 1000 -f ipn_naive.sql -f timeout_naive.sql, rồi đếm thanh toán có trong ipn_log mà status <> 'paid'.

Tác giả chạy trên Postgres 16.15 trong Docker, máy Apple Silicon. Bản không điều kiện: 2.009 trên 6.364 và 2.004 trên 6.303 thanh toán đã nhận IPN mà cuối cùng là unknown, khoảng một phần ba (tỉ lệ này do bốc ngẫu nhiên). Bản một chiều, ba lần chạy: 0.

7. Bẫy hay gặp

❌ Coi timeout là thất bại rồi cho khách bấm trả lại. Lần trả lại mang mã mới, tức lệnh trừ mới: mỗi khách đã bị trừ mà bấm lại là một lần trừ hai lần, khoảng 18.000 đ.

SQL
-- timeout: customer clicks again, which sends a NEW txn_ref
UPDATE payments SET status = 'failed' WHERE id = :payment_id;

✅ Timeout thì unknown, tra cứu bằng mã cũ; khách thấy "đang xác nhận thanh toán".

SQL
-- timeout: keep the old txn_ref, look it up later
UPDATE payments SET status = 'unknown'
WHERE id = :payment_id AND status = 'pending';

❌ Gọi nhà cung cấp bên trong transaction ghi mã. Khoá trên chỗ giữ nằm suốt p99 5 giây, và COMMIT hỏng sau khi lệnh trừ đã chạy thì khoản trừ không có dòng nào trong DB.

SQL
BEGIN;
UPDATE holds SET status = 'paying' WHERE id = :hold_id AND status = 'held';
INSERT INTO payments (hold_id, txn_ref) VALUES (:hold_id, :txn_ref);
-- call the provider here: the row lock is held for p99 5 s
COMMIT;

✅ Transaction ngắn ghi mã và chuyển chỗ giữ, COMMIT, rồi mới gọi ra ngoài.

SQL
BEGIN;
UPDATE holds SET status = 'paying' WHERE id = :hold_id AND status = 'held';
INSERT INTO payments (hold_id, txn_ref) VALUES (:hold_id, :txn_ref);
COMMIT;
-- only now: call the provider

8. Đào sâu

📚 Đào sâu (optional)

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

  • Bài 02: bốn kiểu lệch tiền mà bài này chặn lại.
  • Bài 04: bút toán đi vào transaction chốt paid.
  • Bài 05: đưa giao dịch unknown về trạng thái cuối.

10. Tóm tắt

  • Khi xếp luồng có bước không đảo được, hỏi từng bước "nó có thể từ chối vì nghiệp vụ không?": có thì đặt trên điểm không quay lại, không thì đặt dưới và cho thử lại.
  • Khi đồng hồ của bước bù ngắn hơn đồng hồ phía bên kia, khoá bước bù theo trạng thái phía bên kia.
  • Khi lời gọi ra ngoài có thể timeout, cho nó trạng thái "chưa rõ" riêng; chỉ tin chắc chắn mới đưa về trạng thái cuối.

11. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao bước chốt đơn và ghi sổ phải không có nhánh hỏng vì nghiệp vụ, và nếu nó lỡ chứa bước kiểm còn hàng thì sao?
  2. Q2
    Đồng nghiệp đề xuất: lệnh một chạm timeout 30 giây thì ghi failed và hiện nút trả lại. Ước lượng hậu quả trên 3.600 giao dịch mập mờ mỗi đợt, và nói cách sửa.
  3. Q3
    IPN thành công commit paid; hai giây sau handler timeout mới chạy. Trạng thái cuối là gì nếu handler dùng UPDATE payments SET status = 'unknown' WHERE id = :id, và nếu nó thêm AND status = 'pending'?
  4. Q4
    Tình huống mới: khách dùng điểm thưởng để giảm giá (điểm có thể không đủ vì vừa dùng ở đơn khác), và sàn gửi email xác nhận sau khi trả. Đặt hai bước này ở đâu, bước bù của mỗi bước là gì?
  5. Q5
    Nêu một ràng buộc cụ thể làm C (giữ tiền rồi thu sau) thắng A, dù phải nuôi hai luồng.

Bài tiếp theo: Sổ tiền: sửa số dư tại chỗ hay chỉ ghi thêm bút toán?

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

Sổ tiền: sửa số dư tại chỗ hay chỉ ghi thêm bút toán?