支付请求幂等性设计:从原理到落地,杜绝重复扣款

引言:支付幂等性的重要性

用户点击支付按钮后,页面卡住了,用户以为没成功,又点了好几次,结果被重复扣款?或者支付接口因为网络超时,前端重试,导致用户被扣了多次款?再或者系统异常重试,导致重复处理?

这些都是支付幂等性没处理好的典型例子。在支付场景中,幂等性是必须保证的,因为它直接关系到用户的钱包和公司的信誉。今天我们就来聊聊如何设计一个可靠的支付幂等性方案,彻底杜绝重复扣款。

什么是幂等性?

先说说什么是幂等性。简单来说,就是同一个操作执行一次和执行多次的结果是一样的。在支付场景中,就是同一笔支付请求,无论被调用多少次,都应该只扣一次款。

举个例子:

  • 用户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());
    }
}

最佳实践总结

  1. 唯一标识:使用订单号等唯一标识符
  2. 状态机:设计清晰的状态转换逻辑
  3. 分布式锁:防止并发处理
  4. 异常处理:完善的异常处理和补偿机制
  5. 监控告警:及时发现重复支付问题
  6. 测试验证:充分的测试确保幂等性

总结

支付幂等性是支付系统的核心要求,必须从设计阶段就考虑。通过合理的架构设计、状态管理、锁机制和异常处理,我们可以构建一个可靠的幂等支付系统。

记住,支付无小事,每一笔交易都关系到用户的信任。掌握了这些技巧,你就能确保支付系统的可靠性,让用户放心支付,让公司放心收款。


标题:支付请求幂等性设计:从原理到落地,杜绝重复扣款
作者:jiangyi
地址:http://www.jiangyi.space/articles/2025/12/31/1767162077301.html

    0 评论
avatar