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

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

Ráp bảng ràng buộc, phương án ngây thơ và ba quyết định thành một thiết kế thanh toán hoàn chỉnh, kèm bảng cái giá bạn đã chấp nhận ở từng bước.

TL;DR: Thiết kế thanh toán hoàn chỉnh xoay quanh một điểm không quay lại là lệnh trừ tiền. Phía trên nó, một transaction ngắn giữ hàng và ghi mã giao dịch rồi mới gọi nhà cung cấp, và hàng chỉ được nhả khi thanh toán đã thất bại chắc chắn. Phía dưới nó, IPN, tra cứu hai mốc và file đối soát cùng đổ vào một câu UPDATE một chiều, ghi bút toán trong cùng transaction vào sổ cái chỉ ghi thêm. Không lớp nào miễn phí. Bài này gom cái giá của từng lớp vào một bảng, kèm số đo bạn đã tự lấy.

Bạn bắt đầu từ 540 triệu đồng đang treo mỗi đợt sale mà hệ thống không biết. Sáu bài sau, khoản tiền đó đã có chỗ đứng trong một máy trạng thái, một lịch tra cứu và một câu FULL JOIN. Bài này không dạy thêm cơ chế nào. Việc của nó là ráp các mảnh lại, rồi để bạn kiểm xem mình nói được cái giá của từng mảnh hay chưa.

1. Ráp thành một thiết kế

Đi theo một lượt thanh toán, từ lúc khách bấm nút tới lúc tiền nằm trong sổ.

Khách bấm thanh toán. Server mở một transaction ngắn: chuyển chỗ giữ hàng từ held sang paying và ghi mã giao dịch vào bảng payments, COMMIT, rồi mới chuyển khách sang trang cổng hoặc gọi lệnh trừ một chạm (bài 03). Thứ tự đó có lý do: hết hàng là câu "không" duy nhất có thể xảy ra, nên nó phải được hỏi trước khi tiền đi. Lệnh trừ tiền chạy xong thì không đảo được nữa. Timeout thì thanh toán sang unknown, không sang failed, vì với giao dịch mập mờ thì đoán "thất bại" sai khoảng 60% số lần.

Từ đây chỉ còn việc chốt. IPN là đường chính, endpoint mỏng tới mức khó chết. Tra cứu hỏi ở phút 1 và phút 16, tốn 6 trong 10 lượt mỗi giây lúc đỉnh. File đối soát tới 6 giờ sáng hôm sau, và một câu FULL JOIN so sổ với file trước 12 giờ trưa (bài 05). Nguồn nào tới trước cũng đi qua cùng một câu UPDATE ... WHERE status IN ('pending', 'unknown'), và chính transaction đó ghi bốn bút toán vào sổ cái chỉ ghi thêm; người bán có thêm số dư dẫn xuất cập nhật cùng lúc (bài 04).

Đường đi một lượt thanh toán: khách bấm thanh toán, giữ hàng và ghi mã trong một transaction có bước bù nhả hàng khi thất bại chắc chắn, rồi lệnh trừ tiền không đảo được, rồi chốt một chiều nhận kết quả từ ba nguồn IPN, tra cứu ở phút 1 và 16, file đối soát 6 giờ sáng, cuối cùng ghi vào sổ cái chỉ ghi thêm

Phương án ngây thơ của bài 02 không có mặt trong hình. Nó không có ô nào cho chữ "chưa biết", nên mọi giao dịch mập mờ đã trừ đều thành một khoản lệch, và dashboard vẫn báo 0 đơn failed.

Mỗi mảnh của thiết kế đã có một hình ảnh đời thường ở bài của nó. Đặt cạnh nhau, chúng cho thấy mỗi mảnh giữ một điều khác nhau:

Mảnh thiết kếHình ảnh đã dùngĐiều nó giữ
Giữ hàng trước lệnh trừQuầy vé: giữ ghế, quẹt thẻ, in véCâu "hết chỗ" được hỏi trước khi tiền đi
Sổ cái chỉ ghi thêmSổ cái ghi mực, không tẩy xoáSai thì ghi bút toán đảo, dấu vết cũ còn nguyên
Ba nguồn kết quảTin nhắn shipper, tra mã vận đơn, bảng kê CODNguồn nhanh hở thì có nguồn chắc, nguồn chắc muộn thì có nguồn nhanh

Mọi quyết định ở trên đứng trên bảng của bài 01: đỉnh 3.000 lượt mỗi giây, khoảng 3.600 giao dịch mập mờ mỗi đợt, 60% trong số đó đã trừ tiền, và mỗi tài khoản chung nhận tải gấp 2,5 lần trần ghi một hàng. Đổi bảng đó thì xét lại thiết kế, đừng giữ thiết kế khi ràng buộc đã khác.

2. Bảng cái giá bạn đã chấp nhận

Cột "lựa chọn" chỉ đúng dưới ràng buộc chuẩn của case, không phải khuyến nghị chung.

BướcLựa chọnCái giáSố đo bằng chứng
Ràng buộc (bài 01)Chốt bảng số, tính tiền treo theo đợt saleTự đặt số, sai thì sai cả thiết kế2.160 giao dịch đã trừ mà chưa rõ, khoảng 540 triệu đồng mỗi đợt; tài khoản chung gấp 2,5 lần trần
Ngây thơ (bài 02)Bỏ: tin trang chuyển về và câu trả lời đồng bộSinh đủ bốn kiểu lệch mà không lỗi nào lộ ra235 đến 280 đơn lệch mỗi 100.000 lượt mô phỏng, pgbench báo 0 transaction lỗi
Thứ tự bước (bài 03)Giữ hàng rồi trừ tiền, nhả hàng theo trạng tháiHàng nằm yên tới khi thanh toán rõ, lâu hơn hạn 10 phútNhả theo đồng hồ thì tối đa 2.160 lượt hoàn tiền, mất tới 5,94 triệu đồng phí mỗi đợt; UPDATE không điều kiện để khoảng một phần ba thanh toán đã nhận IPN kẹt ở unknown, bản một chiều 0
Cách ghi sổ (bài 04)Tài khoản chung chỉ ghi thêm, người bán giữ số dư dẫn xuấtCode đọc số dư phải biết tài khoản thuộc nhóm nào; người bán lớn quá thì phải chuyển nhómCó cập nhật hàng số dư chung: khoảng 1.000 lượt mỗi giây, dưới đỉnh 3.000; chỉ ghi thêm: khoảng 27.000; tổng trên 1 triệu bút toán: p99 7,2 giây
Chốt và đối soát (bài 05)Ghép ba nguồn, tra cứu hai mốc, FULL JOIN lọc ngày trong subqueryThêm bảng lịch tra cứu, job so sổ, và phải chịu ba nguồn tới trùng hoặc trái nhauHai mốc 6 lượt mỗi giây, hỏi mỗi phút cần 60; IPN chết một giờ thì tra cứu bù mất khoảng 35 giờ

Đọc cột cuối theo hàng ngang, mỗi cái giá đều có một con số đứng sau. Khi ai đó hỏi "sao không cập nhật số dư cho mọi tài khoản cho gọn", câu trả lời tốt là chỉ vào hàng thứ tư: khoảng 1.000 lượt mỗi giây so với đỉnh 3.000. Câu "vì nó phức tạp hơn" thì không thuyết phục được ai.

Bước nào còn có thể từ chối vì nghiệp vụ thì đặt trên lệnh trừ tiền và cho nó một bước bù rẻ. Bước dưới lệnh trừ chỉ được hỏng vì lỗi tạm thời và phải thử lại được. Giữ tiền rồi thu sau chỉ đáng khi có bước nói "không" mà chỉ chạy được sau khi khách đã trả.

3. Ba câu SQL giữ cả thiết kế đúng

Phần lớn khoản lệch trong case đến từ vài dòng SQL viết thiếu một vế. Ba cặp dưới đây là những chỗ bạn đã đo được cái giá.

❌ Chốt trạng thái không kèm điều kiện trên trạng thái hiện tại.

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

✅ Timeout tới muộn hay IPN trùng sẽ ghi đè trạng thái cuối. Thêm vế trạng thái thì tin tới sau chỉ trả 0 row:

SQL
UPDATE payments SET status = 'paid'
WHERE id = :payment_id AND status IN ('pending', 'unknown');

❌ Worker nhả mọi chỗ giữ quá hạn.

SQL
UPDATE holds SET status = 'released'
WHERE expires_at < now();

✅ Hạn 10 phút là của bạn, trang cổng mở 15 phút là của nhà cung cấp. Chỉ nhả chỗ chưa bắt đầu trả; chỗ paying về kho cùng transaction chốt thất bại:

SQL
UPDATE holds SET status = 'released'
WHERE status = 'held' AND expires_at < now();

❌ Lọc ngày ở WHERE sau FULL JOIN khi so sổ. Điều kiện ngày gặp NULL ở phía thiếu dòng, nên đúng hai kiểu lệch nguy hiểm nhất (file có mà sổ không có, sổ có mà file không có) bị loại; trên dữ liệu thử, câu so sổ trả 3 dòng thay vì 5.

✅ Lọc ngày trong subquery của từng phía, mốc viết kèm +07:

SQL
FROM (SELECT * FROM payments
      WHERE created_at >= '2026-10-18 00:00+07'
        AND created_at <  '2026-10-19 00:00+07') g
FULL JOIN (SELECT * FROM settlement_file
           WHERE settle_date = '2026-10-18') f
  ON f.txn_ref = g.txn_ref

Khoản lệch mà so sổ tìm ra không được sửa bằng UPDATE trên bút toán cũ. Nó đi vào sổ như một nhóm bút toán điều chỉnh mới, tổng Nợ bằng tổng Có, trỏ về giao dịch gốc, và giữ mười năm như mọi bút toán khác.

4. Đổi ràng buộc, quyết định nào đổi?

Ba quyết định nằm trên ba trục riêng, nên một thay đổi ràng buộc thường chỉ lật một trục. Điểm nối là câu UPDATE một chiều: thứ tự bước tạo ra trạng thái unknown, ba nguồn chốt nó, và sổ ghi bút toán trong cùng transaction chốt. Đổi cách chốt mà bỏ vế điều kiện là kéo cả sổ lệch theo. Giờ tự thử với ba tình huống, rồi so với đáp án mẫu.

Nhớ lại — chốt và đối soát

Ba nguồn hở ba chỗ khác nhau: IPN mất hẳn khi endpoint chết quá 50 phút, API tra cứu chỉ 10 lượt mỗi giây, file tới 6 giờ sáng hôm sau nên giao dịch tạo trước 6 giờ không kịp hạn 24 giờ.

Tự kiểm tra
0/3 câu đã trả lời
  1. Q1
    Sàn thêm bước người bán xác nhận còn hàng trong 2 giờ sau khi khách trả, vì hàng nằm ở kho người bán. Quyết định nào lật, và cái giá mới rơi vào đâu?
  2. Q2
    Nhà cung cấp nâng giới hạn API tra cứu từ 10 lên 100 lượt mỗi giây. Bạn còn cần file đối soát không?
  3. Q3
    Sàn chỉ có đỉnh 300 lượt mỗi giây, các dòng khác của bảng ràng buộc giữ nguyên. Trục nào lật?

5. Đào sâu

📚 Đào sâu (optional)

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

  • Bài 06: Đối chiếu thực tế cho thấy hệ thanh toán thật chọn khác bạn vì ràng buộc cụ thể của họ khác bảng của bạn, như API nhà cung cấp phát lại kết quả cũ hay chỉ chặn mã trùng, hay độ nóng của tài khoản. Bảng cái giá ở mục 2 là công cụ để làm phép so sánh đó.
  • Bài 01 là nơi mọi con số trong bảng bắt đầu.

Tóm tắt

  • Khi luồng có một bước không đảo được, hỏi từng bước khác "nó có thể từ chối vì nghiệp vụ không?" rồi xếp nó trên hoặc dưới bước đó.
  • Khi nhiều nguồn cùng báo kết quả cho một giao dịch, cho chúng đi qua đúng một câu UPDATE có điều kiện trên trạng thái hiện tại.
  • Việc cần làm sau bài này: đổi một dòng trong bảng ràng buộc, rồi xem trục nào lật và trục nào đứng yên.

✅ Self-assessment

  • Ước lượng số lượt thanh toán mỗi giây lúc đỉnh, số giao dịch mập mờ, số tiền thực sự bị treo và số lượt ghi vào ba tài khoản chung từ bảng ràng buộc tự đặt, rồi so với trần ghi một hàng đo trên máy bạn
    • Nếu chưa: đọc lại bài 01, mục 4 về tiền treo và mục 6 tự đo trần bằng pgbench.
  • Chẩn đoán các kiểu lệch tiền (khách đã trả mà đơn vẫn chưa thanh toán, khách bị trừ hai lần, đơn đã thanh toán mà sổ thiếu bút toán, hàng đã về kho trong khi khách vẫn trả thành công) khi luồng tin kết quả trả về đồng bộ gặp timeout, khách đóng trình duyệt hoặc tiến trình chết
    • Nếu chưa: đọc lại bài 02, mục 2 mô phỏng 100.000 lượt và mục 3 đến 5.
  • Thiết kế thứ tự bước quanh lệnh trừ tiền không đảo được (giữ hàng rồi trừ tiền, trừ tiền rồi tạo đơn, hay giữ tiền rồi thu sau), kèm trạng thái chưa rõ và bước bù mà thứ tự đó đòi hỏi
    • Nếu chưa: đọc lại bài 03, mục 4 mổ từng cái giá và mục 5 máy trạng thái.
  • Chọn cách ghi sổ cho từng nhóm tài khoản (cột số dư kèm bảng lịch sử, sổ cái bút toán kép chỉ ghi thêm lấy số dư bằng tổng, hay sổ cái kèm số dư dẫn xuất cập nhật cùng transaction) bằng cách so tranh chấp ghi trên tài khoản chung và độ trễ đọc số dư
    • Nếu chưa: đọc lại bài 04, mục 5 đến 7 và hình chọn theo từng tài khoản.
  • Thiết kế cơ chế chốt và đối soát với nhà cung cấp (IPN, tra cứu định kỳ trong giới hạn API, so sổ với file đối soát) sao cho không giao dịch nào mập mờ quá 24 giờ và mọi khoản lệch lộ ra trước 12 giờ trưa hôm sau
    • Nếu chưa: đọc lại bài 05, mục 3 về ngân sách tra cứu và mục 5 so sổ trước 12 giờ trưa.
  • Giải thích vì sao một hệ thanh toán thật chọn khác thiết kế của bạn dựa trên ràng buộc của họ
    • Nếu chưa: đọc lại bài 06, mục 2 cách đọc một case và bảng tổng hợp ở mục 6.

Bài tiếp theo

Case thanh toán là case cuối của tuyến Thiết kế Hệ thống, và tuyến khép lại ở đây. Muốn đào sâu nguyên lý đứng sau hai mảnh bạn vừa ráp, hãy ôn lại hai bài ở khoá tiên quyết: Khi nào tránh distributed transaction trong data-phan-tan cho saga và bước bù, và Event sourcing trong data-phai-sinh cho ý tưởng sổ chỉ ghi thêm rồi suy ra trạng thái.

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