幂等性与重试最佳实践指南

通过幂等性设计,我们可以既保证系统的稳定性,又避免重复操作的风险,这是处理支付等关键业务的标准做法。

🚫 为什么不建议对支付操作使用重试

1. 核心问题:非幂等性

幂等性定义:同一个操作重复执行多次,结果应该与执行一次相同。

1
2
3
4
5
6
7
// 幂等操作示例
GET /api/user/123 // 多次查询同一用户,结果相同
PUT /api/user/123 // 多次更新同一用户为相同数据,结果相同

// 非幂等操作示例
POST /api/payment // 每次都会创建新的支付!
POST /api/order // 每次都会创建新的订单!

2. 支付重试的严重风险

时间线分析

1
2
3
4
5
6
7
8
T1: 客户端发起支付请求 100元
T2: 服务器接收请求,调用支付网关
T3: 支付网关扣款成功 ✓
T4: 网络故障,响应丢失 ❌
T5: 客户端超时,重试机制触发
T6: 服务器再次调用支付网关
T7: 再次扣款 100元 ❌
结果:用户损失 200元!

实际案例风险

  • 重复扣款:用户被多次扣除费用
  • 账务不一致:订单状态与实际支付状态不符
  • 用户体验极差:用户投诉、退款纠纷
  • 法律风险:可能涉及资金安全责任

✅ 推荐的解决方案

1. 幂等性令牌机制

实现原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class IdempotentPaymentService {
// 使用幂等性令牌确保同一请求只处理一次
public PaymentResult processPayment(String idempotencyKey, PaymentRequest request) {
// 1. 检查缓存
if (cache.contains(idempotencyKey)) {
return cache.get(idempotencyKey); // 返回原结果
}

// 2. 执行支付(不使用重试)
PaymentResult result = paymentGateway.process(request);

// 3. 缓存结果
cache.put(idempotencyKey, result);
return result;
}
}

幂等性令牌生成策略

1
2
3
4
5
6
7
8
// 方案1:客户端生成
String idempotencyKey = UUID.randomUUID().toString();

// 方案2:基于业务数据生成
String idempotencyKey = orderId + "_" + userId + "_" + timestamp;

// 方案3:哈希算法生成
String idempotencyKey = MD5(orderId + userId + amount + nonce);

2. 超时处理策略

客户端处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public PaymentResult handlePaymentWithTimeout(PaymentRequest request) {
String idempotencyKey = generateKey(request);

try {
// 设置合理超时时间(如30秒)
return paymentService.processPayment(idempotencyKey, request)
.timeout(30, TimeUnit.SECONDS);

} catch (TimeoutException e) {
// 超时后不要立即重试支付!
// 应该查询支付状态或提示用户
return handleTimeout(idempotencyKey, request);
}
}

private PaymentResult handleTimeout(String idempotencyKey, PaymentRequest request) {
// 方案1:查询支付状态
PaymentStatus status = queryPaymentStatus(idempotencyKey);
if (status != null) {
return convertToPaymentResult(status);
}

// 方案2:使用相同令牌重新发起(安全)
return paymentService.processPayment(idempotencyKey, request);

// 方案3:提示用户手动确认
// return promptUserForConfirmation(request);
}

3. 状态查询重试

查询操作是幂等的,可以安全重试:

1
2
3
4
5
6
7
8
9
10
public PaymentStatus queryPaymentStatus(String transactionId) throws Exception {
return RetryTemplate.create()
.maxAttempts(5)
.baseDelay(1000)
.strategy(RetryStrategy.EXPONENTIAL_BACKOFF)
.retryOn(NetworkException.class, TimeoutException.class)
.execute(() -> {
return paymentGateway.queryStatus(transactionId);
});
}

4. 数据库事务保护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Transactional
public PaymentResult processPaymentSafely(String idempotencyKey, PaymentRequest request) {
// 1. 检查幂等性记录
PaymentRecord existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
if (existing != null) {
return existing.toPaymentResult();
}

// 2. 创建幂等性记录(防止并发)
PaymentRecord record = new PaymentRecord(idempotencyKey, "PROCESSING");
paymentRepository.save(record);

try {
// 3. 执行支付
PaymentResult result = paymentGateway.process(request);

// 4. 更新记录状态
record.setStatus("SUCCESS");
record.setTransactionId(result.getTransactionId());
paymentRepository.save(record);

return result;

} catch (Exception e) {
// 5. 记录失败状态
record.setStatus("FAILED");
record.setErrorMessage(e.getMessage());
paymentRepository.save(record);
throw e;
}
}

📋 操作分类指南

✅ 可以安全重试的操作(幂等)

操作类型 示例 说明
查询操作 GET /api/user/123 不改变系统状态
状态查询 查询支付状态、订单状态 只读操作
幂等更新 PUT /api/user/123 多次执行结果相同
删除操作 DELETE /api/user/123 删除不存在的资源也是成功

❌ 不应该重试的操作(非幂等)

操作类型 示例 风险
支付操作 POST /api/payment 重复扣款
订单创建 POST /api/order 重复下单
资金转账 POST /api/transfer 重复转账
消息发送 POST /api/sms 重复发送
库存扣减 POST /api/inventory/reduce 过度扣减

⚠️ 需要特殊处理的操作

操作类型 推荐方案
文件上传 使用文件哈希作为幂等性标识
批量操作 拆分为多个幂等的单项操作
定时任务 使用分布式锁防止重复执行

🛠️ 实现最佳实践

1. 缓存策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 使用Redis实现分布式幂等性缓存
@Service
public class IdempotencyCache {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final int CACHE_EXPIRE_HOURS = 24;

public PaymentResult getFromCache(String key) {
return (PaymentResult) redisTemplate.opsForValue().get(key);
}

public void putToCache(String key, PaymentResult result) {
redisTemplate.opsForValue().set(key, result,
CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
}
}

2. 并发控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 使用分布式锁防止并发问题
public PaymentResult processWithLock(String idempotencyKey, PaymentRequest request) {
String lockKey = "payment_lock:" + idempotencyKey;

try (DistributedLock lock = lockService.acquire(lockKey, 30, TimeUnit.SECONDS)) {

// 双重检查模式
PaymentResult cached = cache.get(idempotencyKey);
if (cached != null) {
return cached;
}

// 执行支付逻辑
return doProcessPayment(idempotencyKey, request);

} catch (LockAcquisitionException e) {
throw new PaymentException("系统繁忙,请稍后重试");
}
}

3. 监控和告警

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 添加监控指标
@Component
public class PaymentMetrics {

private final Counter duplicateRequestCounter = Counter.build()
.name("payment_duplicate_requests_total")
.help("重复支付请求计数")
.register();

private final Histogram paymentDuration = Histogram.build()
.name("payment_processing_duration_seconds")
.help("支付处理耗时")
.register();

public void recordDuplicateRequest() {
duplicateRequestCounter.inc();
}

public Timer.Sample startPaymentTimer() {
return Timer.start(paymentDuration);
}
}

🎯 关键要点总结

  1. 永远不要对非幂等操作使用自动重试
  2. 使用幂等性令牌确保请求的唯一性
  3. 将支付操作与查询操作分离
  4. 建立完善的超时处理机制
  5. 实施适当的监控和告警
  6. 提供清晰的用户反馈

💡 常见问题解答

Q: 如果网络超时了,用户应该怎么办? A: 不要让用户重新支付!应该:

  • 查询支付状态
  • 使用相同幂等性令牌重试
  • 或引导用户到订单状态页面确认

Q: 幂等性令牌应该由谁生成? A: 推荐由客户端生成,这样可以确保在客户端重试时使用相同的令牌。

Q: 缓存应该保存多长时间? A: 建议至少24小时,对于重要业务可以考虑7天。要平衡存储成本和用户体验。

Q: 如何处理支付网关的重复回调? A: 同样使用幂等性机制,以回调中的交易ID作为幂等性标识。

通过遵循这些原则和实践,可以有效避免支付等关键业务操作的重复执行问题,确保系统的稳定性和用户资金的安全。