接口幂等方案设计
一、幂等性概念
1.1 定义
幂等性:同一操作执行多次,结果保持相同。
f(f(x)) = f(x)
1.2 应用场景
| 场景 | 说明 |
|---|---|
| 网络波动 | 用户重复点击提交按钮 |
| 超时重试 | 客户端收不到响应后重试 |
| 消息重发 | MQ消息重复投递 |
| 分布式事务 | 补偿操作重复执行 |
二、幂等令牌方案(推荐)
2.1 完整流程
幂等令牌流程图:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 前端页面 │ │ Redis │ │ 后端服务 │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 1. 请求获取令牌 │ │
│───────────────────>│ │
│ │ 生成UUID │
│ 2. 返回token │ │
│<───────────────────│ │
│ │ │
│ 3. 携带token请求 │ │
│─────────────────────────────────────────>│
│ │ │
│ │ 4. 查询token │
│ │<───────────────────│
│ │ │
│ │ token存在? │
│ │ │ │
│ │ 是 │ 否 │
│ │ ↓ ↓
│ │ 删除token 返回重复请求
│ │ │ │
│ │ ↓ │
│ │ 5. 执行业务 │
│ │<───────────────────│
│ │ │
│ 6. 返回成功 │ │
│<─────────────────────────────────────────│
2.2 代码实现
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class IdempotentTokenService {
private static final String PREFIX = "idempotent:";
private static final long TTL_SECONDS = 300; // 5分钟过期
@Autowired
private StringRedisTemplate redisTemplate;
// 生成幂等令牌
public String generateToken(String businessKey) {
String token = UUID.randomUUID().toString();
String key = PREFIX + businessKey + ":" + token;
redisTemplate.opsForValue().set(key, "true", TTL_SECONDS, TimeUnit.SECONDS);
return token;
}
// 验证并消费令牌
public boolean validateToken(String businessKey, String token) {
String key = PREFIX + businessKey + ":" + token;
// 使用Lua脚本保证原子性
String script = """
if redis.call('exists', KEYS[1]) == 1 then
redis.call('del', KEYS[1])
return 1
else
return 0
end
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key)
);
return result != null && result == 1;
}
}2.3 控制器层集成
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
@Autowired
private IdempotentTokenService tokenService;
@Autowired
private PaymentService paymentService;
// 获取支付令牌
@GetMapping("/token")
public ResponseEntity<Map<String, String>> getToken(Long orderId) {
String token = tokenService.generateToken("payment:" + orderId);
return ResponseEntity.ok(Map.of("token", token));
}
// 执行支付(幂等)
@PostMapping("/pay")
public ResponseEntity<?> pay(
@RequestParam Long orderId,
@RequestParam String token,
@RequestBody PaymentRequest request
) {
// 验证幂等令牌
if (!tokenService.validateToken("payment:" + orderId, token)) {
return ResponseEntity.badRequest().body("请勿重复支付");
}
// 执行支付业务
PaymentResult result = paymentService.processPayment(orderId, request);
return ResponseEntity.ok(result);
}
}三、其他幂等实现方案
3.1 数据库唯一约束
-- 创建唯一索引
CREATE UNIQUE INDEX idx_order_payment_unique
ON payment_records (order_id);
-- 插入时自动失败(重复订单号)
INSERT INTO payment_records (order_id, amount, status)
VALUES (123, 200, 'SUCCESS')
ON CONFLICT (order_id) DO NOTHING;3.2 状态机控制
public enum OrderStatus {
PENDING_PAYMENT, // 待支付
PAID, // 已支付
REFUNDED, // 已退款
CANCELLED; // 已取消
// 状态转移校验
public boolean canTransitionTo(OrderStatus target) {
return switch (this) {
case PENDING_PAYMENT -> target == PAID || target == CANCELLED;
case PAID -> target == REFUNDED;
case REFUNDED, CANCELLED -> false; // 终态不可转移
};
}
}3.3 乐观锁版本号
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Transactional
public boolean updateStatus(Long orderId, OrderStatus newStatus, int expectedVersion) {
Order order = orderRepository.findById(orderId).orElse(null);
if (order == null || order.getVersion() != expectedVersion) {
return false; // 版本不匹配,拒绝更新
}
order.setStatus(newStatus);
order.setVersion(order.getVersion() + 1);
orderRepository.save(order);
return true;
}
}3.4 分布式锁
@Service
public class DistributedLockService {
@Autowired
private RedissonClient redissonClient;
public boolean tryLock(String lockKey, long waitTime, long leaseTime) {
RLock lock = redissonClient.getLock(lockKey);
try {
return lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
public void unlock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}四、方案对比与选型
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 幂等令牌 | 支付、下单等关键操作 | 通用、可靠 | 需要额外存储 |
| 唯一约束 | 任意需要幂等的写入 | 数据库原生支持 | 只能防重复写入 |
| 状态机 | 订单状态流转 | 业务语义清晰 | 需要维护状态转移规则 |
| 乐观锁 | 更新操作 | 无锁竞争 | 需要版本字段 |
| 分布式锁 | 复杂业务流程 | 粒度可控 | 性能开销较大 |
推荐组合方案
推荐架构:
┌──────────────────────────────────────────────────────┐
│ 客户端请求 │
│ ↓ │
│ ┌──────────────────────────┐ │
│ │ 1. 幂等令牌校验 │ │
│ │ (Redis + Lua原子操作) │ │
│ └──────────┬───────────────┘ │
│ ↓ │
│ ┌──────────────────────────┐ │
│ │ 2. 数据库唯一约束 │ │
│ │ (order_id唯一索引) │ │
│ └──────────┬───────────────┘ │
│ ↓ │
│ ┌──────────────────────────┐ │
│ │ 3. 状态机校验 │ │
│ │ (防止非法状态跳转) │ │
│ └──────────┬───────────────┘ │
│ ↓ │
│ 业务逻辑执行 │
└──────────────────────────────────────────────────────┘
五、面试要点
5.1 核心概念
| 概念 | 说明 |
|---|---|
| 幂等性 | 同一操作执行多次结果相同 |
| 幂等令牌 | 一次性使用的唯一标识 |
| Redis原子操作 | Lua脚本保证check-then-delete原子性 |
| 状态机 | 状态转移规则约束 |
5.2 常见面试问题
Q:为什么需要幂等性?
A:分布式系统中网络不可靠,请求可能重复到达,幂等性保证系统稳定性。
Q:幂等令牌为什么要设置过期时间?
A:防止令牌无限占用内存,同时处理用户放弃操作的场景。
Q:为什么要用Lua脚本删除令牌?
A:保证”检查存在”和”删除”操作的原子性,防止并发问题。
Q:如果Redis宕机怎么办?
A:可以降级到数据库唯一约束,或使用本地缓存+定时同步。