Ngăn trừ tiền hai lần: Thiết kế API thanh toán idempotent · Pattern Quest
#idempotency
#payment-system
#spring-boot
#go
#api
Ngăn trừ tiền hai lần: Thiết kế API thanh toán idempotentPrevent Double Charge: Designing Idempotent Payment APIs
Hướng dẫn cách tránh lỗi trừ tiền hai lần bằng cách áp dụng idempotency key và transactional outbox trong API thanh toán.A practical guide to prevent double charges by using idempotency keys and a transactional outbox in payment APIs.
Bạn có bao giờ nhận được tin nhắn từ khách hàng phàn nàn rằng họ bị trừ tiền hai lần cho cùng một giao dịch? Đây là một trong những lỗi nghiêm trọng nhất trong hệ thống thanh toán vì nó ảnh hưởng trực tiếp đến niềm tin và doanh thu. Trong bài viết này mình sẽ chỉ cho bạn cách thiết kế API thanh toán idempotent để lỗi này không còn xảy ra.
Khi client gọi endpoint /pay và mạng chậm, client có thể retry lại yêu cầu sau một thời gian. Nếu server không lưu trạng thái của lần gọi đầu tiên, mỗi retry sẽ tạo một transaction mới, dẫn tới việc ghi nhận hai lần khoản tiền. Thêm vào đó, việc xử lý bất đồng bộ (message queue, outbox) mà không có cơ chế đồng bộ cũng dễ gây trùng lặp.
Idempotency key là một chuỗi duy nhất do client sinh ra (ví dụ UUID) và gửi kèm trong header Idempotency-Key. Server sẽ lưu key này cùng với trạng thái giao dịch (đã xử lý, đang xử lý, lỗi). Khi nhận lại cùng một key, server chỉ trả về kết quả đã lưu mà không thực hiện lại nghiệp vụ. Đây là cách đơn giản nhưng rất hiệu quả để ngăn double charge.
. Nếu key chưa tồn tại, tạo bản ghi với trạng thái
IN_PROGRESS
, thực hiện transaction, sau đó cập nhật thành
COMPLETED
và lưu response. Dưới đây là một ví dụ ngắn gọn:
Java
import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.util.UUID;@Servicepublic class PaymentService { private final IdempotencyRepository repo; private final PaymentProcessor processor; public PaymentService(IdempotencyRepository repo, PaymentProcessor processor) { this.repo = repo; this.processor = processor; } @Transactional public PaymentResponse pay(PaymentRequest req, String idempotencyKey) { IdempotencyEntry entry = repo.findByKey(idempotencyKey) .orElseGet(() -> repo.save(new IdempotencyEntry(idempotencyKey, Status.IN_PROGRESS))); if (entry.getStatus() == Status.COMPLETED) { return entry.getResponse(); // trả về kết quả đã lưu } // Thực hiện thanh toán thực sự PaymentResponse resp = processor.process(req); // Cập nhật entry entry.setStatus(Status.COMPLETED); entry.setResponse(resp); repo.save(entry); return resp; }}
Trong ví dụ trên, IdempotencyRepository là một Spring Data JPA repository thao tác trên bảng idempotency_keys. Nhờ @Transactional, việc ghi key và thực hiện thanh toán nằm trong cùng một transaction, đảm bảo tính atomic.
Trong Go, chúng ta thường dùng middleware cho HTTP server (net/http hoặc chi). Middleware sẽ kiểm tra header Idempotency-Key, tra cứu Redis (hoặc PostgreSQL) và quyết định tiếp tục hay trả về kết quả lưu trước.
Go
package paymentimport ( "context" "database/sql" "encoding/json" "net/http" "time")type IdempotencyStore interface { Get(ctx context.Context, key string) (*Response, error) Set(ctx context.Context, key string, resp *Response, ttl time.Duration) error}type Response struct { Status string `json:"status"` OrderID string `json:"order_id"` Amount int64 `json:"amount"`}func IdempotencyMiddleware(store IdempotencyStore) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := r.Header.Get("Idempotency-Key") if key == "" { http.Error(w, "Missing Idempotency-Key", http.StatusBadRequest) return } if cached, _ := store.Get(r.Context(), key); cached != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(cached) return } // ghi lại response vào buffer rw := &responseWriter{ResponseWriter: w, body: &bytes.Buffer{}} next.ServeHTTP(rw, r) var resp Response json.Unmarshal(rw.body.Bytes(), &resp) // lưu vào store để các retry sau trả về ngay _ = store.Set(r.Context(), key, &resp, 24*time.Hour) }) }}type responseWriter struct { http.ResponseWriter body *bytes.Buffer status int}func (rw *responseWriter) Write(b []byte) (int, error) { rw.body.Write(b) return rw.ResponseWriter.Write(b)}func (rw *responseWriter) WriteHeader(statusCode int) { rw.status = statusCode rw.ResponseWriter.WriteHeader(statusCode)}
Middleware này dùng một IdempotencyStore (có thể là Redis) để lưu kết quả trong 24 giờ. Khi client retry, server chỉ trả về giá trị đã lưu mà không thực hiện lại logic thanh toán.
Transactional Outbox: đồng bộ thanh toán và event#
Trong nhiều hệ thống, sau khi thanh toán thành công chúng ta sẽ publish một sự kiện (ví dụ PaymentSucceeded) để các service khác (order, inventory, notification) xử lý. Nếu publish thất bại, hệ thống có thể mất đồng bộ và dẫn tới tình huống khách hàng đã trả tiền nhưng đơn hàng vẫn chưa được tạo, gây tranh cãi.
Giải pháp Transactional Outbox: lưu event vào một bảng outbox trong cùng transaction với việc cập nhật trạng thái payment. Sau đó một worker đọc bảng này và publish ra Kafka/RabbitMQ. Vì event và payment được ghi cùng lúc, không có khả năng mất dữ liệu.
SQL
CREATE TABLE outbox ( id BIGSERIAL PRIMARY KEY, aggregate_id UUID NOT NULL, payload JSONB NOT NULL, dispatched BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now());
Trong Spring, ta có thể dùng @Transactional để chèn record vào outbox ngay sau khi tạo payment, rồi một ScheduledExecutor hoặc Spring Cloud Stream để đẩy các bản ghi chưa gửi.
Idempotency key ngăn việc thực hiện lại cùng một transaction khi client retry.
Triển khai bằng filter/middleware, lưu key và response trong DB hoặc Redis.
Kết hợp Transactional Outbox để đồng bộ event mà không mất dữ liệu.
Áp dụng các quy tắc trên sẽ giảm đáng kể trường hợp trừ tiền hai lần và tăng độ tin cậy của hệ thống thanh toán.
Bạn đã từng gặp lỗi double charge nào chưa? Hãy chia sẻ cách bạn giải quyết trong phần bình luận nhé!
Have you ever gotten a frantic support ticket saying a customer was charged twice for the same purchase? That’s one of the most damaging bugs in any payment system because it hurts trust and revenue. In this post we’ll walk through how to design an idempotent payment API so that double‑charge bugs simply cannot happen.
When a client calls the /pay endpoint and the network is flaky, the client may retry the request after a timeout. If the server does not remember the first call, each retry creates a new transaction, resulting in two records of the same payment. Additionally, asynchronous processing (message queues, outbox) without proper coordination can also cause duplicate entries.
An idempotency key is a client‑generated unique string (often a UUID) sent in the Idempotency-Key header. The server stores this key together with the processing state (IN_PROGRESS, COMPLETED, FAILED). When the same key arrives again, the server simply returns the previously stored response instead of re‑executing the business logic. This lightweight guard is extremely effective at preventing duplicate charges.
In Spring Boot you can add a filter that extracts the header and looks it up in an idempotency_keys table. If the key does not exist, insert a row with status IN_PROGRESS, run the payment transaction, then update the row to COMPLETED and store the response. The whole flow runs inside a single @Transactional method, guaranteeing atomicity.
Java
import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.util.UUID;@Servicepublic class PaymentService { private final IdempotencyRepository repo; private final PaymentProcessor processor; public PaymentService(IdempotencyRepository repo, PaymentProcessor processor) { this.repo = repo; this.processor = processor; } @Transactional public PaymentResponse pay(PaymentRequest req, String idempotencyKey) { IdempotencyEntry entry = repo.findByKey(idempotencyKey) .orElseGet(() -> repo.save(new IdempotencyEntry(idempotencyKey, Status.IN_PROGRESS))); if (entry.getStatus() == Status.COMPLETED) { return entry.getResponse(); // return cached result } // Real payment processing PaymentResponse resp = processor.process(req); // Update the entry entry.setStatus(Status.COMPLETED); entry.setResponse(resp); repo.save(entry); return resp; }}
IdempotencyRepository is a Spring Data JPA repository that talks to the idempotency_keys table. Because the method is annotated with @Transactional, both the key insert and the payment update happen in the same atomic transaction.
In Go we typically write middleware for the standard net/http server (or a router like chi). The middleware extracts the Idempotency-Key header, checks a store (Redis or PostgreSQL), and either returns the cached response or lets the request continue. After the handler finishes, the middleware captures the response body and stores it for future retries.
The IdempotencyStore abstraction can be backed by Redis, PostgreSQL, or even an in‑memory map for testing. The middleware guarantees that a retry receives the exact same JSON payload that the original call produced.
Transactional Outbox: keeping payment and events in sync#
After a successful payment we usually publish an event (e.g., PaymentSucceeded) so that order, inventory, and notification services can react. If the publish step fails after the payment has been recorded, the system ends up in an inconsistent state – the user paid but the order never appears.
Transactional Outbox solves this by writing the event to an outbox table inside the same database transaction that updates the payment status. A separate worker later reads pending rows and pushes them to Kafka, RabbitMQ, or another broker. Because both the payment row and the outbox row are committed together, there is no scenario where one succeeds without the other.
SQL
CREATE TABLE outbox ( id BIGSERIAL PRIMARY KEY, aggregate_id UUID NOT NULL, payload JSONB NOT NULL, dispatched BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now());
In Spring you can simply insert an OutboxEvent entity after saving the Payment entity, all under the same @Transactional scope. A @Scheduled task or Spring Cloud Stream consumer then picks up rows where dispatched = FALSE and publishes them.