Idempotency Key trong thanh toán: Bấm 10 lần vẫn chỉ trừ tiền một lần
Hướng dẫn triển khai Idempotency Key cho thanh toán với PostgreSQL: unique constraint, request hash, state machine, xử lý request đồng thời, retry, timeout và chống double charge.
ShanverseCập nhật 10 phút đọc— lượt xemĐang tải lượt xem
Payment Systems
Transactions & Concurrency
API Security
Vì sao chỉ disable nút Thanh toán là chưa đủ?
Disable nút là UX tốt nhưng không phải hàng rào an toàn. Duplicate request vẫn có thể xuất hiện vì:
Người dùng double-click trước khi giao diện kịp cập nhật.
Trình duyệt, mobile SDK, gateway hoặc load balancer tự retry.
Request đầu đã commit nhưng response bị mất do timeout.
Ứng dụng crash rồi khôi phục và gửi lại command.
Queue giao message theo cơ chế at-least-once.
Hai tab hoặc hai thiết bị cùng submit một checkout.
Hệ thống phải xử lý duplicate ở server và nguồn dữ liệu bền vững, không dựa vào trạng thái của một nút trên trình duyệt.
Idempotency Key là gì?
Idempotency key là định danh duy nhất cho
Bình luận
một ý định nghiệp vụ
, ví dụ “thanh toán đơn hàng O-123 số tiền 500.000đ”. Client sinh key một lần, thường bằng UUID v4, rồi dùng lại chính key đó cho mọi retry của cùng ý định.
Nếu người dùng hủy rồi chủ động tạo một lần thanh toán mới, client phải sinh key mới. Không tái sử dụng một key cho hai ý định khác nhau.
Luồng đúng từ frontend
Key phải được giữ ổn định qua timeout, reconnect và retry. Với mobile/offline, lưu pending intent vào storage bền vững. Không sinh key mới trong mỗi lần hàm retry chạy.
Hợp đồng API cần định nghĩa rõ
Key bắt buộc cho endpoint tạo side effect tài chính.
Phạm vi key, ví dụ (merchant_id, idempotency_key) hoặc (customer_id, operation, key).
Cùng key + cùng payload: trả lại cùng payment/result.
Cùng key + payload khác: trả 409 Conflict hoặc lỗi validation.
Key đang xử lý: trả trạng thái processing, 202 Accepted, hoặc chờ trong timeout ngắn.
Key đã thành công: trả receipt cũ, không gọi charge lần nữa.
Chính sách retention phải dài hơn cửa sổ retry thực tế.
Không đưa email, số thẻ hoặc PII vào key. Key cần đủ entropy và không được dùng như credential.
Mô hình dữ liệu PostgreSQL
Unique constraint là hàng rào chống race condition. Một câu SELECT kiểm tra key rồi INSERT riêng rẽ không an toàn: hai request có thể cùng thấy “chưa tồn tại”.
Request hash ngăn tái sử dụng key sai payload
Chuẩn hóa các trường có ý nghĩa nghiệp vụ rồi băm:
Không băm JSON thô nếu thứ tự field, whitespace hoặc giá trị mặc định có thể khác nhau. Hãy canonicalize payload. Khi cùng key đi với hash khác, từ chối thay vì trả nhầm receipt của giao dịch cũ.
Thuật toán xử lý request đồng thời
Bước 1: claim key bằng thao tác nguyên tử
row_count = 1: request này là owner đầu tiên.
row_count = 0: key đã tồn tại; load row và xử lý theo state.
Bước 2: phân nhánh theo trạng thái
succeeded: trả lại response_code và response_body đã lưu.
processing: không charge thêm; trả processing hoặc chờ owner hoàn tất.
failed: chỉ retry nếu lỗi được phân loại là retryable và state machine cho phép.
Hash khác: trả conflict ngay.
Bước 3: commit payment và kết quả
Nếu debit và payment nằm trong cùng database, thực hiện trong một ACID transaction:
Chỉ trả thành công sau commit. Request thứ 2 đến sau đó đọc lại cùng receipt mà không lặp side effect.
Khi payment provider là dịch vụ bên ngoài
Không thể giữ database transaction mở trong lúc gọi PSP. Dùng state machine:
Claim idempotency row và tạo payment processing trong local transaction.
Commit local state.
Gọi PSP với một provider idempotency key ổn định, thường dẫn xuất từ payment ID.
Lưu provider reference và kết quả.
Append ledger/outbox theo boundary được thiết kế.
Nếu timeout, query trạng thái PSP hoặc retry bằng cùng provider key—không tạo key mới.
Idempotency phải xuyên suốt từng hop. API của bạn idempotent nhưng worker gọi PSP bằng key mới mỗi lần vẫn có thể double charge.
Trường hợp nguy hiểm nhất: đã charge nhưng mất response
Timeout không có nghĩa là thất bại. Có ba khả năng:
Request chưa tới PSP.
PSP nhận nhưng chưa hoàn tất.
PSP đã charge thành công nhưng response bị mất.
Vì vậy không được tự động tạo charge mới sau timeout. Giữ payment ở trạng thái processing hoặc unknown, retry/query bằng cùng key, nhận webhook idempotently và chạy reconciliation. Chỉ chuyển sang failed khi có bằng chứng chắc chắn.
Có nên cache mọi response?
Endpoint cần chính sách theo failure class:
Success/terminal business failure: lưu và replay ổn định.
Validation fail trước khi bắt đầu thực thi: có thể không claim key, tùy hợp đồng.
Transient infrastructure failure: không được biến thành duplicate side effect; giữ state đủ để recover.
Unknown outcome: không báo failed giả; trả processing/unknown và reconcile.
Một số API lưu cả status code và body đầu tiên, kể cả lỗi máy chủ. Nếu chọn cách này, phải công bố rõ. Quan trọng nhất là response cache không được tách rời khỏi durable operation state.
Lock, lease và owner bị crash
Row processing không thể bị khóa vĩnh viễn khi worker chết. locked_until tạo lease, nhưng worker mới chỉ được takeover sau khi:
Xác minh lease đã hết.
Kiểm tra payment/ledger/provider xem side effect đã xảy ra chưa.
Dùng compare-and-swap để chỉ một worker giành quyền.
Tiếp tục cùng payment ID và provider key.
Không đơn giản xóa row processing rồi bắt đầu lại.
Idempotency không phải exactly-once thần kỳ
Mạng và broker thường giao ít nhất một lần. Mục tiêu thực tế là:
Duplicate request được nhận nhiều lần.
Durable uniqueness chọn một operation logic duy nhất.
Mỗi consumer xử lý event idempotently.
Ledger và reconciliation phát hiện mọi sai lệch.
Exactly-once ở một broker không bảo đảm side effect ở database hoặc PSP xảy ra đúng một lần.
Webhook và consumer cũng phải idempotent
Trong một transaction, insert event ID rồi áp dụng state transition. Nếu conflict, ACK webhook cũ mà không ghi ledger hay gửi thông báo lần nữa.
Retention và scope
Nếu xóa key quá sớm, một retry đến muộn sẽ bị xem là payment mới. Chọn TTL dựa trên:
Timeout và retry policy của client/gateway.
Queue delay và thời gian offline của mobile.
Cửa sổ webhook/provider.
Quy định lưu trữ và khả năng tra soát.
Có thể archive response body nặng nhưng vẫn giữ tombstone gồm key, request hash, payment ID và terminal state đủ lâu. Scope key theo tenant để tránh collision và lộ dữ liệu chéo khách hàng.
Retry đúng cách
Chỉ retry lỗi transient đã phân loại.
Dùng cùng key và cùng payload.
Exponential backoff, jitter và giới hạn số lần.
Tôn trọng Retry-After.
Không retry vô hạn lỗi 4xx không thay đổi.
Có deadline tổng cho workflow.
Tránh retry ở nhiều tầng cùng lúc gây khuếch đại tải.
Security
Idempotency không thay thế authentication, authorization hay chống gian lận.
Ràng buộc key với tenant/user/operation.
So sánh request hash trước khi trả cached response.
Không cho attacker đoán key để đọc receipt của người khác.
Không log full token thẻ, credential hoặc PII.
Rate limit vẫn cần thiết; duplicate có cùng key cũng tiêu tốn tài nguyên.
Ký và xác minh webhook theo hướng dẫn của provider.
Kiểm thử bắt buộc
Gửi 10–100 request cùng key đồng thời qua barrier.
Xác nhận chỉ có một payment, một debit và một journal transaction.
Gửi cùng key với amount khác và chờ conflict.
Kill process trước/sau provider call và trước/sau local commit.
Làm mất response sau khi commit rồi retry.
Gửi webhook trùng lặp và đảo thứ tự.
Cho lease hết hạn khi owner đang chậm.
Retry sau thời gian retention boundary.
Kiểm tra nhiều tenant dùng cùng chuỗi key không đọc chéo dữ liệu.
Invariant cuối test:
Observability
Theo dõi:
Tỷ lệ request mới so với replay.
Concurrent duplicate và thời gian chờ processing.
Key/payload conflict.
Payment ở processing hoặc unknown quá lâu.
Provider timeout, webhook lag và reconciliation mismatch.
Số lần worker takeover lease.
Duplicate charge/debit invariant violation—phải alert mức cao nhất.
Log payment_id, scope, hash/key đã che, attempt, provider reference và state transition; không log dữ liệu thanh toán nhạy cảm.
Checklist production
Client sinh một key cho một payment intent và tái dùng qua retry.
Server có unique constraint theo scope + key.
Cùng key nhưng khác payload bị từ chối.
Payment, ledger, idempotency result và outbox có transaction boundary rõ.
PSP nhận cùng provider idempotency key ở mọi retry.
Timeout được xem là unknown cho đến khi xác minh.
Webhook và consumer idempotent.
Lease recovery không lặp side effect.
TTL dài hơn toàn bộ retry/delivery window.
Retry có backoff, jitter, budget và deadline.
Có reconciliation và cảnh báo duplicate charge.
Đã chạy concurrency và crash test.
Kết luận
Để “bấm 10 lần chỉ trừ một lần”, cần nhiều lớp phối hợp: frontend giữ nguyên key, API claim key bằng unique constraint, payment có state machine bền vững, debit/ledger commit atomically, PSP nhận cùng key, webhook idempotent và hệ thống có reconciliation.
Idempotency key không ngăn request trùng đi vào hệ thống; nó khiến mọi bản sao của cùng một ý định hội tụ về cùng một kết quả, thay vì tạo thêm giao dịch tài chính.
1BEGIN2 claim or lock idempotency row3 validate canonical request hash4 create payment5 debit account / append balanced ledger entries6 store durable receipt7 mark idempotency row succeeded8 insert outbox event9COMMIT
Codeplain text
1client key -> local payment_id2local payment_id -> provider idempotency key3provider charge id -> reconciliation reference
Codeplain text
1count(effective charges for one payment intent) = 12count(effective debits for one payment intent) = 13returned receipt is stable for every identical retry
Hướng dẫn chống race condition và double spending trong chuyển tiền với PostgreSQL: ACID, SELECT FOR UPDATE, optimistic versioning, idempotency, deadlock, retry và kiểm thử đồng thời.
Hướng dẫn tích hợp VietQR và QR Code động với webhook/IPN từ ngân hàng hoặc cổng thanh toán: xác minh chữ ký, chống replay, matching giao dịch, idempotency và reconciliation.
Giải thích kiến trúc Event Sourcing và CQRS trong core banking, từ immutable ledger, double-entry, balance projection, idempotency đến replay, reconciliation và eventual consistency.