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

Chốt và đối soát: không giao dịch nào mập mờ quá 24 giờ

3.600 giao dịch mập mờ mỗi đợt sale, API tra cứu chỉ 10 lượt mỗi giây, IPN ngừng gửi sau 50 phút, file đối soát tới sáng hôm sau. Chọn cách chốt trạng thái và so sổ.

TL;DR: Ba nguồn báo kết quả thanh toán bù chỗ yếu cho nhau, nên thiết kế ghép cả ba. IPN (lời nhà cung cấp gọi về server của bạn để báo kết quả) là đường chính nhưng mất hẳn khi endpoint của bạn chết quá 50 phút. API tra cứu chắc chắn nhưng chỉ 10 lượt mỗi giây, nên chỉ hỏi theo lịch hai mốc: phút 1 và phút 16, ngay sau khi trang cổng hết hạn. File đối soát (bản kê giao dịch của nhà cung cấp, để so từng dòng với sổ) tới 6 giờ sáng hôm sau, kịp hạn 24 giờ cho mọi giao dịch tạo sau 6 giờ sáng của ngày chốt. So sổ với file bằng FULL JOIN theo mã giao dịch, lọc ngày trong subquery chứ không ở WHERE.

Đợt sale đóng lúc 21 giờ 10. Sổ còn 3.600 giao dịch chưa rõ, khoảng 2.160 trong số đó khách đã bị trừ thật. Treo quá 30 phút thì 30% khách gọi tổng đài, quá 24 giờ thì họ khiếu nại qua ngân hàng. Ba nguồn để hỏi, nguồn nào cũng hở một chỗ. Bạn chọn cách chốt trước, rồi tự tính và tự chạy câu so sổ trên Postgres.

1. Ba nguồn kết quả, bạn chốt giao dịch mập mờ bằng nguồn nào?

Số trích từ bảng chuẩn của bài 01. IPN (Instant Payment Notification) là lời gọi HTTP nhà cung cấp gửi vào URL của bạn để báo kết quả, gửi lại tối đa 10 lần cách 5 phút. File đối soát liệt kê giao dịch thành công và hoàn tiền của ngày chốt 0:00–23:59 giờ Việt Nam.

Điểm quyết định

Ràng buộc: 3.600 giao dịch mập mờ mỗi đợt · 6 mới mỗi giây lúc đỉnh · tra cứu 10 lượt/giây · IPN gửi lại trong 50 phút · file 6 giờ sáng hôm sau · hạn 24 giờ

Với 10 lượt tra cứu mỗi giây và hạn 24 giờ, bạn 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: timeout không phân biệt được chết, chậm hay đã xong

Chọn một phương án trước khi đọc tiếp. Hình dung bạn đặt hàng trả tiền khi nhận và cần biết đơn giao chưa:

Đời thườngLuồng chốt kết quả
Shipper nhắn "đã giao"IPN: nhanh, nhưng máy hết pin thì tin trôi mất
Tự tra mã vận đơnAPI tra cứu: chắc, nhưng tra quá dày thì bị chặn
Bảng kê COD hôm sauFile đối soát: đủ và chính thức, nhưng muộn
Dò bảng kê với sổ bán hàngSo sổ với file, tìm dòng lệch
💡 Cách nhớ

IPN để biết sớm, tra cứu để vá chỗ IPN hở, file để chốt và soát cả hai nguồn kia.

2. Vì sao chỉ chờ IPN là không đủ?

Bình thường IPN tới trong vài giây. Chỗ hở nằm ở endpoint của bạn: quá 50 phút gửi lại, kết quả ấy không bao giờ tới nữa.

Bên gửi không phân biệt được bên kia đã chết, đang chậm hay đã xử lý xong mà câu trả lời bị mất. IPN không tới cũng vậy: nó không nói gì về chuyện tiền khách đã đi hay chưa.

Giả sử endpoint IPN chết một giờ, đúng từ lúc mở sale. Mọi IPN của 10 phút sale hết lượt gửi lại trong lúc endpoint còn chết, nên cả kênh trang cổng mất kết quả: 1.800.000 × 70% = 1.260.000 lượt. Tra cứu bù ở 10 lượt mỗi giây mất 126.000 giây, khoảng 35 giờ. API tra cứu là cái nhíp, không phải cái xẻng.

Vì vậy endpoint IPN phải mỏng tới mức khó chết: kiểm chữ ký, ghi nguyên văn vào một bảng, trả lời đã nhận. Ghi sổ, đổi trạng thái đơn chạy sau, đọc từ bảng đó.

3. Ngân sách 10 lượt mỗi giây: hỏi lúc nào?

3.1 Lịch hai mốc

Hỏi ngay một lần đã ăn 6 trong 10 lượt mỗi giây, nên câu hỏi thật là lượt thứ hai đặt ở đâu. Mốc có sẵn trong ràng buộc: trang cổng hết hạn sau 15 phút. Trước mốc đó, "chưa trả" còn có thể đổi vì khách đang quét QR. Sau mốc đó chỉ còn "đã trả" hoặc "hết hiệu lực". Lịch hai mốc vì thế là: hỏi ở phút 1, hỏi lại ở phút 16 tính từ lúc tạo, rồi mỗi giờ một lần với số ít còn "đang xử lý".

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

Đợt sale chạy từ phút 0 tới phút 10, mỗi giây sinh 6 giao dịch mập mờ. Tính: (a) lượt hỏi lần 1 tốn bao nhiêu lượt mỗi giây, trong khoảng phút nào; (b) lượt hỏi lần 2 rơi vào khoảng phút nào, có chồng lần 1 không; (c) giao dịch cuối của đợt được hỏi lần 2 ở phút nào; (d) nếu cả 3.600 giao dịch còn "đang xử lý" và hỏi mỗi giờ thì tốn bao nhiêu lượt mỗi giây.

Lời giải:

CâuCách tínhKết quả
(a) Hỏi lần 1tạo lúc s, hỏi ở s + 16 lượt/giây, phút 1 tới 11
(b) Hỏi lần 2hỏi ở s + 16phút 16 tới 26, không chồng
(c) Giao dịch cuối10 + 16phút 26, trước ngưỡng 30 phút
(d) Mỗi giờ3.600 ÷ 3.600 giây1 lượt/giây

Phương án B ở trường hợp xấu nhất: 3.600 giao dịch cùng chờ đủ 15 phút, hỏi mỗi phút là 3.600 ÷ 60 = 60 lượt mỗi giây.

Ba thanh cùng thang tuyến tính: lịch hai mốc 6 lượt mỗi giây nằm dưới giới hạn API tra cứu 10 lượt mỗi giây, còn hỏi lại mỗi phút ở trường hợp xấu nhất cần 60 lượt mỗi giây

Trục thời gian của một giao dịch mập mờ: tạo ở phút 0, hỏi lần 1 ở phút 1, trang cổng đóng ở phút 15, hỏi lần 2 ở phút 16, khách bắt đầu gọi tổng đài ở phút 30, IPN ngừng gửi lại ở phút 50

3.2 Hàng đợi tra cứu

Lịch hỏi sống trong một bảng query_schedule (txn_ref, created_at, attempt, next_check_at). Mỗi giây worker lấy tối đa 10 dòng đến hạn; SKIP LOCKED bỏ qua dòng worker khác đang giữ nên không hỏi trùng:

SQL
SELECT txn_ref FROM query_schedule
WHERE next_check_at <= now()
ORDER BY next_check_at
LIMIT 10
FOR UPDATE SKIP LOCKED;

Kết quả rõ thì xoá dòng. Còn "đang xử lý" thì dời next_check_at về created_at + interval '16 min' sau lần đầu, now() + interval '1 hour' sau các lần sau.

3.3 Ghi trạng thái một chiều

Ba nguồn có thể báo cùng một giao dịch theo thứ tự bất kỳ, nên câu ghi trạng thái chỉ đi một chiều:

SQL
UPDATE payments SET status = 'paid'
WHERE txn_ref = 'GD1000' AND status IN ('pending', 'unknown');

Kênh trang cổng chiếm 70% nên lúc IPN tới thanh toán thường còn pending, vì chỉ timeout hoặc phút 1 mới đẩy nó sang unknown; vế IN nhận cả hai. Lần đầu trả UPDATE 1; IPN trùng tới sau trả UPDATE 0. Nguồn báo failed sau khi đã paid cũng ra UPDATE 0: không ghi đè, nó thành dòng lệch để mục 5 bắt.

"Chưa rõ" chỉ đi tới thành công hoặc thất bại, không quay lại. Điều kiện AND status IN ('pending', 'unknown') là chỗ máy trạng thái đó chạm vào Postgres: ba nguồn cùng ghi mà không cần khoá thêm.

4. Giao dịch tạo lúc mấy giờ thì file không cứu kịp?

Thử đoán trước

File cho ngày D tới lúc 6 giờ sáng ngày D + 1. Giao dịch tạo lúc mấy giờ ngày D thì file tới muộn hơn 24 giờ? Với 400.000 lượt mỗi ngày rải đều, khoảng bao nhiêu lượt rơi vào vùng đó?

Lời giải: giao dịch tạo lúc h giờ chờ file 30 − h giờ, vượt 24 giờ khi h nhỏ hơn 6. Đó là mọi giao dịch tạo trước 6:00: rải đều thì khoảng 100.000 lượt, cỡ 200 giao dịch mập mờ. Đợt sale lúc 21 giờ thì file tới sau 9 giờ.

Với vùng này, file chỉ là lưới an toàn đến muộn. Giao dịch nào tới giờ thứ 20 vẫn "đang xử lý" thì chuyển người mở yêu cầu tra soát với nhà cung cấp, còn 4 giờ trước hạn. Mốc 20 giờ là quyết định của bạn: kéo sớm thì tốn công người, đẩy muộn thì hết đường lùi.

Thử ngẫmnhà cung cấp đổi giờ gửi file từ 6 giờ sáng sang 2 giờ sáng. Vùng giờ cần tra cứu thay file co lại còn bao nhiêu?

5. So sổ với file trước 12 giờ trưa

File tới lúc 6 giờ, hạn là 12 giờ trưa: 6 tiếng. Máy so 2,2 triệu dòng của ngày có sale trong vài giây; thứ hết giờ là người. Mỗi khoản lệch máy không tự giải thích được tốn kế toán khoảng 30 phút, nên một kế toán xử lý tối đa 12 khoản trước hạn.

Đối soát (reconciliation) là so từng dòng của hai nguồn độc lập cùng ghi một sự việc để tìm chỗ không khớp. Hai nguồn ở đây là bảng payments của bạn và bảng settlement_file nạp từ file.

FULL OUTER JOIN giữ dòng không khớp ở cả hai phía, bù NULL vào phía thiếu. Lọc bảng nullable ở WHERE thì NULL cho UNKNOWN, dòng bị loại, OUTER JOIN thành INNER JOIN.

Tác giả nạp 400.000 giao dịch của ngày 18/10, cài sẵn năm dòng lệch, rồi chạy:

SQL
SELECT coalesce(g.txn_ref, f.txn_ref) AS txn_ref,
       g.status, g.amount, f.gross_amount, f.fee
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'
             AND kind = 'payment') f
  ON f.txn_ref = g.txn_ref
WHERE g.txn_ref IS NULL OR f.txn_ref IS NULL
   OR g.status <> 'paid'
   OR g.amount <> f.gross_amount
   OR f.fee <> round(f.gross_amount * 0.011);

Điều kiện ngày nằm trong hai subquery, nên mỗi phía lọc trước rồi mới join. Hoàn tiền so riêng cùng khuôn. Postgres 16.15 trong Docker, khoảng 110 ms:

Plain Text
 txn_ref | status  | amount | gross_amount | fee
---------+---------+--------+--------------+------
 GD1000  | unknown | 250000 |       250000 | 2750
 GD2000  | failed  | 250000 |       250000 | 2750
 GD3000  |         |        |       250000 | 2750
 GD4000  | paid    | 250000 |              |
 GD5000  | paid    | 250000 |       250000 | 2751

GD1000 sổ chưa rõ mà file có: IPN mất, giờ file chốt nó. GD2000 sổ ghi thất bại mà file nói thành công: khách bị trừ, đơn đã huỷ. GD3000 file có mà sổ không có dòng nào, tệ nhất. GD4000 chỉ có trong sổ. GD5000 phí lệch một đồng.

Cây quyết định cho một dòng lệch sau FULL JOIN: sổ chưa rõ mà file có thì chốt theo file; chỉ có trong sổ và sát nửa đêm thì chờ file hôm sau; mọi trường hợp còn lại là khoản lệch thật cho kế toán tra tay

Máy chỉ tự giải thích được hai kiểu: dòng như GD1000 thì chốt bằng câu UPDATE một chiều ở mục 3; dòng chỉ có trong sổ mà tạo sát nửa đêm thì có thể bị xếp sang ngày sau, chờ file hôm sau. Bảng này chỉ có kiểu đầu: GD4000 tạo lúc 15:00 nên không phải ca nửa đêm, vẫn là lệch thật. Bốn dòng còn lại là 2 giờ công kế toán, 400.000 đ theo giá một khoản lệch sổ.

Bút toán giữ mười năm, không sửa, không xoá. Sửa GD2000 hay GD5000 cũng đi đường đó: 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.

Thử ngẫmmột sáng có 40 dòng lệch thật mà chỉ một kế toán trực. Bạn xếp thứ tự xử lý theo tiêu chí nào để khoản đau nhất không trễ hạn?

6. Hai cái bẫy đã đo được

❌ Lọc ngày ở WHERE sau FULL JOIN.

SQL
SELECT coalesce(g.txn_ref, f.txn_ref) AS txn_ref, g.status
FROM payments g
FULL JOIN settlement_file f ON f.txn_ref = g.txn_ref
WHERE g.created_at >= '2026-10-18 00:00+07'
  AND g.created_at <  '2026-10-19 00:00+07'
  AND f.settle_date = '2026-10-18'
  AND (g.txn_ref IS NULL OR f.txn_ref IS NULL
       OR g.status <> 'paid'
       OR f.fee <> round(f.gross_amount * 0.011));

✅ Câu này trả 3 dòng thay vì 5, mất GD3000 và GD4000: dòng chỉ có một phía mang NULL ở cột ngày của phía kia, và điều kiện ngày loại nó trước khi IS NULL kịp có tác dụng. Câu so sổ báo "sạch" đúng ở hai kiểu lệch nó sinh ra để bắt. Lọc ngày trong subquery của từng phía:

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

❌ Cắt ngày theo múi giờ của server.

SQL
SET timezone = 'UTC';
SELECT count(*) FROM (SELECT * FROM payments
  WHERE created_at::date = '2026-10-18') g
FULL JOIN (SELECT * FROM settlement_file
  WHERE settle_date = '2026-10-18') f ON f.txn_ref = g.txn_ref
WHERE g.txn_ref IS NULL OR f.txn_ref IS NULL;

✅ Container postgres:16 của tác giả có TimeZone là Etc/UTC. timestamptz ép sang ngày theo múi giờ của phiên, nên ngày UTC lệch 7 giờ so với ngày chốt của file. Trên dữ liệu của tác giả, câu này ra 116.666 dòng "chỉ có trong file", đúng 7/24 của 400.000. Viết mốc kèm +07 hoặc dùng AT TIME ZONE 'Asia/Ho_Chi_Minh', đừng trông vào cấu hình server:

SQL
SELECT count(*) 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
WHERE g.txn_ref IS NULL OR f.txn_ref IS NULL;
📚 Đào sâu (optional)

Tài liệu chính thức của PostgreSQL 16:

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

8. Tóm tắt

  • Khi có cả IPN, tra cứu và file: IPN làm đường chính, endpoint mỏng để không chết quá 50 phút.
  • Khi API tra cứu có giới hạn lượt: hỏi ở mốc mà câu trả lời thành chốt, rồi tính tải lúc đỉnh so với giới hạn.
  • Khi file tới sáng hôm sau mà hạn là 24 giờ: tìm vùng giờ file không cứu kịp, giao vùng đó cho tra cứu và người xử lý.
  • Khi so sổ: FULL JOIN theo mã, lọc ngày trong subquery với múi giờ tường minh.

9. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao lượt hỏi thứ hai đặt ở phút 16 chứ không ở phút 2, khi hỏi sớm thì khách biết kết quả sớm hơn?
  2. Q2
    Endpoint IPN chết 40 phút giữa đợt sale rồi sống lại. Bạn có mất kết quả nào không? Nếu nó chết 70 phút thì sao?
  3. Q3
    Câu so sổ của đồng nghiệp đặt điều kiện ngày ở WHERE sau FULL JOIN và báo 0 dòng chỉ có trong file suốt một tháng. Có đáng tin không?
  4. Q4
    Giao dịch tạo lúc 3 giờ sáng, tra cứu trả "đang xử lý" suốt nhiều giờ. Vì sao không thể chờ file, và bạn làm gì để giữ hạn 24 giờ?
  5. Q5
    Tình huống mới: nhà cung cấp chuyển sang gửi file theo ngày UTC, tới lúc 9 giờ sáng giờ Việt Nam. Thiết kế lại câu so sổ, và hạn 12 giờ trưa còn giữ được không?

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

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

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