接口幂等性问题的场景与危害
微服务架构中,网络抖动、客户端重试、消息队列重复投递都会导致同一请求被多次执行。非幂等接口重复调用会产生重复扣款、重复下单等严重业务事故。幂等性要求:同一操作执行一次与执行多次的结果一致。
需要保证幂等性的典型场景:支付扣款、库存扣减、订单创建、数据写入。查询类接口天然幂等,写入类接口需要专门设计。
Token令牌机制实现幂等校验
Token机制的核心思路:客户端先申请令牌,携带令牌发起业务请求,服务端校验并消费令牌,同一令牌只能使用一次。
@RestController
@RequestMapping("/api/token")
public class IdempotentTokenController {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String TOKEN_PREFIX = "idempotent:token:";
@GetMapping("/generate")
public Result generateToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
TOKEN_PREFIX + token,
"1",
Duration.ofMinutes(10)
);
return Result.ok(token);
}
public boolean checkAndConsumeToken(String token) {
// DEL命令的原子性问题:get + del非原子
// 使用Lua脚本保证原子消费
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(TOKEN_PREFIX + token),
"1"
);
return result != null && result == 1L;
}
}
自定义注解@Idempotent配合AOP切面实现声明式幂等校验:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 令牌请求头名称
*/
String headerName() default "X-Idempotent-Token";
}
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private IdempotentTokenController tokenService;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.currentRequestAttributes()).getRequest();
String token = request.getHeader(idempotent.headerName());
if (token == null || !tokenService.checkAndConsumeToken(token)) {
throw new BusinessException("重复请求,请勿重复操作");
}
return joinPoint.proceed();
}
}
分布式锁方案应对并发重复请求
Token机制解决的是客户端主动重试场景,对于并发请求(如用户快速双击),分布式锁方案更直接。以Redisson为例:
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
private static final String LOCK_PREFIX = "order:lock:";
public OrderResult createOrder(OrderRequest request) {
String lockKey = LOCK_PREFIX + request.getUserId() + ":" + request.getBusinessId();
RLock lock = redissonClient.getLock(lockKey);
try {
// waitTime=0表示非阻塞获取,拿不到立即返回
boolean acquired = lock.tryLock(0, 5, TimeUnit.SECONDS);
if (!acquired) {
throw new BusinessException("操作处理中,请勿重复提交");
}
// 执行业务逻辑
return doCreateOrder(request);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("操作被中断");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
数据库唯一索引兜底方案
分布式锁和Token都可能因Redis故障失效,数据库唯一索引是最可靠的兜底防线。为业务幂等键创建唯一索引:
-- 订单表唯一索引
ALTER TABLE t_order ADD UNIQUE INDEX uk_idempotent (user_id, business_id);
-- 支付流水表唯一索引
ALTER TABLE t_payment_record ADD UNIQUE INDEX uk_idempotent (out_trade_no);
应用层捕获唯一索引冲突:
@Transactional(rollbackFor = Exception.class)
public OrderResult doCreateOrder(OrderRequest request) {
try {
Order order = new Order();
order.setUserId(request.getUserId());
order.setBusinessId(request.getBusinessId());
orderMapper.insert(order);
return OrderResult.success(order);
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明已处理
Order existing = orderMapper.findByBusinessId(
request.getUserId(), request.getBusinessId()
);
return OrderResult.success(existing);
}
}
三种方案的组合策略
生产环境推荐三层防护组合:Token机制拦截前端重复提交,分布式锁拦截并发请求,数据库唯一索引兜底保证数据一致性。优先级从高到低,每一层都是上一层的保障。监控层面记录每层的拦截率,Token拦截率异常高时排查前端按钮防重复逻辑,分布式锁拦截率高时排查网关超时配置,唯一索引命中时说明上层防线存在漏洞,需要复盘修复。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/springboot34-wei-fu-wu-jie-kou-mi-deng-xing-she-ji-fen-bu/