支付请求幂等性设计:从原理到落地,杜绝重复扣款
引言:支付幂等性的重要性
用户点击支付按钮后,页面卡住了,用户以为没成功,又点了好几次,结果被重复扣款?或者支付接口因为网络超时,前端重试,导致用户被扣了多次款?再或者系统异常重试,导致重复处理?
这些都是支付幂等性没处理好的典型例子。在支付场景中,幂等性是必须保证的,因为它直接关系到用户的钱包和公司的信誉。今天我们就来聊聊如何设计一个可靠的支付幂等性方案,彻底杜绝重复扣款。
什么是幂等性?
先说说什么是幂等性。简单来说,就是同一个操作执行一次和执行多次的结果是一样的。在支付场景中,就是同一笔支付请求,无论被调用多少次,都应该只扣一次款。
举个例子:
- 用户A向商家B支付100元
- 用户点击支付按钮,系统处理支付
- 无论这个支付请求被调用1次还是100次,用户A的账户都只能被扣100元
这就是幂等性要保证的效果。
为什么支付需要幂等性?
1. 网络超时重试
网络请求可能因为各种原因超时,客户端会自动重试,如果没有幂等性,就会导致重复扣款。
2. 用户重复点击
用户在支付页面等待时间过长,可能会重复点击支付按钮,导致多次请求。
3. 系统异常重试
服务端在处理过程中出现异常,可能会重试处理,没有幂等性就会重复处理。
4. 消息重复消费
在分布式系统中,消息可能会被重复消费,导致重复处理。
幂等性实现方案
方案一:唯一订单号 + 状态机
这是最常用的方案,也是最可靠的方案。
public class PaymentService {
public PaymentResult processPayment(PaymentRequest request) {
// 1. 检查订单是否已存在
PaymentRecord existingRecord = paymentRepository.findByOrderNo(request.getOrderNo());
if (existingRecord != null) {
// 订单已存在,根据状态返回不同结果
switch (existingRecord.getStatus()) {
case SUCCESS:
return PaymentResult.success(existingRecord.getPaymentId());
case PROCESSING:
return PaymentResult.processing();
case FAILED:
// 可以重新处理失败的订单
return processPaymentInternal(request);
default:
throw new PaymentException("Invalid payment status");
}
}
// 2. 创建新的支付记录
PaymentRecord paymentRecord = new PaymentRecord();
paymentRecord.setOrderNo(request.getOrderNo());
paymentRecord.setAmount(request.getAmount());
paymentRecord.setStatus(PaymentStatus.PROCESSING);
paymentRepository.save(paymentRecord);
// 3. 处理支付逻辑
return processPaymentInternal(request, paymentRecord);
}
}
方案二:分布式锁
使用Redis分布式锁来保证同一笔订单同时只能被处理一次:
public PaymentResult processPaymentWithLock(PaymentRequest request) {
String lockKey = "payment_lock:" + request.getOrderNo();
String lockValue = UUID.randomUUID().toString();
try {
// 尝试获取锁,设置过期时间
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(30));
if (!acquired) {
// 获取锁失败,说明正在处理中
return PaymentResult.processing();
}
// 检查是否已处理
PaymentRecord record = paymentRepository.findByOrderNo(request.getOrderNo());
if (record != null) {
return buildResultFromRecord(record);
}
// 处理支付逻辑
return processPaymentInternal(request);
} finally {
// 释放锁
releaseLock(lockKey, lockValue);
}
}
方案三:Token令牌机制
在前端生成唯一Token,后端验证并处理:
@RestController
public class PaymentController {
// 生成支付Token
@GetMapping("/payment/token")
public TokenResponse generateToken(@RequestParam Long userId) {
String token = UUID.randomUUID().toString();
String key = "payment_token:" + userId;
// 存储Token,设置过期时间
redisTemplate.opsForValue().set(key, token, Duration.ofMinutes(30));
return new TokenResponse(token);
}
// 处理支付请求
@PostMapping("/payment")
public PaymentResult processPayment(@RequestBody PaymentRequest request) {
String key = "payment_token:" + request.getUserId();
String storedToken = (String) redisTemplate.opsForValue().get(key);
if (!request.getToken().equals(storedToken)) {
throw new PaymentException("Invalid token");
}
// 标记Token已使用
redisTemplate.delete(key);
return paymentService.processPayment(request);
}
}
最佳实践方案
1. 组合使用多种方案
最可靠的方式是组合使用多种方案:
@Service
public class PaymentService {
public PaymentResult processPayment(PaymentRequest request) {
// 1. 验证请求参数
validateRequest(request);
// 2. 检查是否已处理
PaymentRecord existingRecord = checkExistingPayment(request.getOrderNo());
if (existingRecord != null) {
return handleExistingPayment(existingRecord);
}
// 3. 获取分布式锁
String lockKey = "payment_lock:" + request.getOrderNo();
if (!acquireLock(lockKey)) {
return PaymentResult.processing();
}
try {
// 4. 再次检查是否已处理(双重检查)
existingRecord = checkExistingPayment(request.getOrderNo());
if (existingRecord != null) {
return handleExistingPayment(existingRecord);
}
// 5. 创建待处理记录
PaymentRecord paymentRecord = createPaymentRecord(request);
// 6. 处理支付
return executePayment(request, paymentRecord);
} finally {
// 7. 释放锁
releaseLock(lockKey);
}
}
}
2. 状态机设计
设计清晰的支付状态机:
public enum PaymentStatus {
INIT(0, "初始化"),
PROCESSING(1, "处理中"),
SUCCESS(2, "成功"),
FAILED(3, "失败"),
CANCELLED(4, "已取消");
private final int code;
private final String description;
// 状态转换验证
public boolean canTransitionTo(PaymentStatus target) {
switch (this) {
case INIT:
return target == PROCESSING;
case PROCESSING:
return target == SUCCESS || target == FAILED;
case SUCCESS:
return false; // 成功状态不可变
case FAILED:
return target == PROCESSING; // 可以重试
case CANCELLED:
return false;
default:
return false;
}
}
}
3. 异常处理和补偿
public PaymentResult executePayment(PaymentRequest request, PaymentRecord record) {
try {
// 更新状态为处理中
record.setStatus(PaymentStatus.PROCESSING);
paymentRepository.update(record);
// 调用第三方支付
ThirdPartyResult result = thirdPartyPaymentService.pay(request);
if (result.isSuccess()) {
// 更新成功状态
record.setStatus(PaymentStatus.SUCCESS);
record.setPaymentId(result.getPaymentId());
record.setTransactionTime(new Date());
paymentRepository.update(record);
// 发送支付成功通知
notifyPaymentSuccess(record);
return PaymentResult.success(record.getPaymentId());
} else {
// 更新失败状态
record.setStatus(PaymentStatus.FAILED);
record.setFailureReason(result.getFailureReason());
paymentRepository.update(record);
return PaymentResult.failure(result.getFailureReason());
}
} catch (Exception e) {
// 异常处理,更新失败状态
record.setStatus(PaymentStatus.FAILED);
record.setFailureReason("System error: " + e.getMessage());
paymentRepository.update(record);
// 记录异常日志
log.error("Payment processing failed", e);
// 触发补偿机制
triggerCompensation(record);
return PaymentResult.failure("System error");
}
}
数据库设计
支付记录表设计
CREATE TABLE payment_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号',
payment_no VARCHAR(64) NOT NULL UNIQUE COMMENT '支付流水号',
amount DECIMAL(10,2) NOT NULL COMMENT '支付金额',
status TINYINT NOT NULL DEFAULT 0 COMMENT '支付状态',
user_id BIGINT NOT NULL COMMENT '用户ID',
merchant_id BIGINT NOT NULL COMMENT '商户ID',
payment_method VARCHAR(32) COMMENT '支付方式',
third_party_id VARCHAR(64) COMMENT '第三方支付ID',
failure_reason VARCHAR(255) COMMENT '失败原因',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
transaction_time TIMESTAMP NULL COMMENT '交易完成时间',
INDEX idx_order_no (order_no),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
);
高并发场景优化
1. Redis缓存优化
@Component
public class PaymentCacheManager {
// 缓存支付记录,减少数据库查询
public PaymentRecord getPaymentRecord(String orderNo) {
String cacheKey = "payment_cache:" + orderNo;
String cached = (String) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, PaymentRecord.class);
}
PaymentRecord record = paymentRepository.findByOrderNo(orderNo);
if (record != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(record),
Duration.ofMinutes(10));
}
return record;
}
}
2. 异步处理
@Service
public class AsyncPaymentService {
@Async("paymentExecutor")
public CompletableFuture<PaymentResult> processPaymentAsync(PaymentRequest request) {
try {
PaymentResult result = processPayment(request);
return CompletableFuture.completedFuture(result);
} catch (Exception e) {
return CompletableFuture.completedFuture(
PaymentResult.failure(e.getMessage()));
}
}
}
监控和告警
1. 关键指标监控
@Component
public class PaymentMetricsCollector {
private final MeterRegistry meterRegistry;
public void recordPaymentResult(String orderNo, PaymentResult result) {
Counter.builder("payment_attempts_total")
.tag("status", result.isSuccess() ? "success" : "failure")
.register(meterRegistry)
.increment();
Timer.builder("payment_processing_duration_seconds")
.tag("order_no", orderNo)
.register(meterRegistry)
.record(result.getProcessingTime(), TimeUnit.MILLISECONDS);
}
}
2. 重复支付告警
@Component
public class PaymentAlertService {
public void checkForDuplicatePayments(String orderNo) {
List<PaymentRecord> records = paymentRepository.findByOrderNo(orderNo);
if (records.size() > 1) {
// 发现重复支付,触发告警
alertService.sendAlert("Duplicate payment detected for order: " + orderNo);
}
}
}
测试策略
1. 单元测试
@Test
public void testIdempotentPayment() {
PaymentRequest request = new PaymentRequest();
request.setOrderNo("ORDER_123");
request.setAmount(new BigDecimal("100.00"));
// 第一次支付
PaymentResult result1 = paymentService.processPayment(request);
// 第二次相同请求
PaymentResult result2 = paymentService.processPayment(request);
// 验证结果
assertEquals(result1.getPaymentId(), result2.getPaymentId());
assertEquals(1, paymentRepository.countByOrderNo("ORDER_123"));
}
2. 集成测试
@SpringBootTest
public class PaymentIntegrationTest {
@Test
public void testConcurrentPayment() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(5);
PaymentRequest request = new PaymentRequest();
request.setOrderNo("CONCURRENT_ORDER");
// 模拟并发支付请求
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
paymentService.processPayment(request);
} finally {
latch.countDown();
}
}).start();
}
latch.await();
// 验证只有一笔成功的支付记录
List<PaymentRecord> records = paymentRepository.findByOrderNo("CONCURRENT_ORDER");
assertEquals(1, records.stream()
.filter(r -> r.getStatus() == PaymentStatus.SUCCESS).count());
}
}
最佳实践总结
- 唯一标识:使用订单号等唯一标识符
- 状态机:设计清晰的状态转换逻辑
- 分布式锁:防止并发处理
- 异常处理:完善的异常处理和补偿机制
- 监控告警:及时发现重复支付问题
- 测试验证:充分的测试确保幂等性
总结
支付幂等性是支付系统的核心要求,必须从设计阶段就考虑。通过合理的架构设计、状态管理、锁机制和异常处理,我们可以构建一个可靠的幂等支付系统。
记住,支付无小事,每一笔交易都关系到用户的信任。掌握了这些技巧,你就能确保支付系统的可靠性,让用户放心支付,让公司放心收款。
标题:支付请求幂等性设计:从原理到落地,杜绝重复扣款
作者:jiangyi
地址:http://www.jiangyi.space/articles/2025/12/31/1767162077301.html
0 评论