- #idempotency
- #api
- #spring-boot
- #go
- #payment-system
Tránh trừ tiền hai lần: Idempotency trong API thanh toánPrevent Double Charge: API Idempotency for Payments
Giải thích lỗi idempotency gây trừ tiền 2 lần và cách thiết kế API để đảm bảo chỉ thực hiện một lần duy nhất.Explains the double‑charge bug caused by missing idempotency and shows how to design payment APIs that execute exactly once.
Mục lụcContents· 7
Khi nào lỗi trừ tiền hai lần xuất hiện?#
Bạn đã bao giờ nhận được báo cáo từ khách hàng rằng họ bị trừ tiền đôi cho một giao dịch duy nhất chưa? Thường thì nguyên nhân là client gửi duplicate request – ví dụ do timeout, người dùng nhấn nút "Thanh toán" nhanh quá, hoặc proxy tự động retry. Nếu server không có cơ chế idempotency, mỗi request sẽ được xử lý độc lập và gây ra hai lần giảm số dư.
Idempotency là gì?#
Idempotency (độ không thay đổi) nghĩa là kết quả của một request không thay đổi dù thực hiện bao nhiêu lần. Trong thanh toán, chúng ta muốn hành động "charge $10" chỉ xảy ra một lần, bất kể client gửi bao nhiêu request giống nhau.
Tip: Idempotency không phải là “không có lỗi”, mà là “đảm bảo không gây side‑effect lặp lại”.
Cách thiết kế API idempotent – ví dụ Spring Boot#
- Yêu cầu client gửi một
Idempotency-Key(UUID) trong headerIdempotency-Keyhoặc trong body. - Kiểm tra key trước khi thực hiện giao dịch. Nếu key đã tồn tại, trả về kết quả đã lưu trước đó.
- Lưu kết quả và key trong một transaction để tránh race condition.
// IdempotencyKey entity
@Entity
@Table(name = "idempotency_keys", uniqueConstraints = @UniqueConstraint(columnNames = "key"))
public record IdempotencyKey(@Id @GeneratedValue Long id,
@Column(name = "key", nullable = false, unique = true) String key,
@Column(name = "response_body", columnDefinition = "TEXT") String responseBody,
@Column(name = "created_at") Instant createdAt) {}
// Service layer
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepo;
private final IdempotencyKeyRepository idempotencyRepo;
@Transactional
public String charge(String userId, BigDecimal amount, String idempotencyKey) {
// 1. Kiểm tra key
var existing = idempotencyRepo.findByKey(idempotencyKey);
if (existing.isPresent()) {
return existing.get().responseBody(); // trả về kết quả cũ
}
// 2. Thực hiện giao dịch thực tế
var payment = new Payment(userId, amount, Instant.now());
paymentRepo.save(payment);
// 3. Lưu key + response (ở đây đơn giản là payment id)
var keyEntity = new IdempotencyKey(null, idempotencyKey, payment.getId().toString(), Instant.now());
idempotencyRepo.save(keyEntity);
return payment.getId().toString();
}
}