接口幂等性的业务场景
微服务架构下,网络抖动导致客户端重试、消息队列重复消费、前端按钮重复点击——这些场景都会产生重复请求。订单创建接口被调两次就是两笔订单,支付接口被调两次就是两次扣款。幂等性设计不是可选项,是后端开发的底线要求。
幂等性的定义:同一操作执行一次和执行多次的效果完全相同。
Redis Token令牌机制实现
Token方案的核心流程:客户端请求Token → 服务端生成并存入Redis → 客户端携带Token提交业务 → 服务端验证并删除Token → 处理业务。
Redis操作Token必须保证原子性,用Lua脚本实现:
-- 幂等Token验证Lua脚本
local token = KEYS[1]
local requestId = ARGV[1]
-- 检查Token是否存在
local exists = redis.call('GET', token)
if exists ~= requestId then
return 0
end
-- 删除Token(原子操作保证只有一个请求能成功删除)
local deleted = redis.call('DEL', token)
if deleted == 1 then
return 1
else
return 0
end
Spring Boot 3实现:
@Service
public class IdempotentTokenService {
private static final String TOKEN_PREFIX = "idempotent:token:";
private static final long TOKEN_EXPIRE_SECONDS = 600;
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<Long> tokenScript;
public IdempotentTokenService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.tokenScript = new DefaultRedisScript<>();
this.tokenScript.setScriptSource(new ResourceScriptSource(
new ClassPathResource("scripts/idempotent_token.lua")));
this.tokenScript.setResultType(Long.class);
}
public String createToken() {
String token = UUID.randomUUID().toString().replace("-", "");
String key = TOKEN_PREFIX + token;
redisTemplate.opsForValue().set(key, "1", TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);
return token;
}
public boolean validateAndRemoveToken(String token, String requestId) {
String key = TOKEN_PREFIX + token;
Long result = redisTemplate.execute(tokenScript,
Collections.singletonList(key), requestId);
return result != null && result == 1L;
}
}
自定义注解与AOP拦截
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
String key() default "";
IdempotentType type() default IdempotentType.TOKEN;
long expireSeconds() default 5;
enum IdempotentType {
TOKEN, DEDUP
}
}
AOP切面实现:
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {
private final IdempotentTokenService tokenService;
private final StringRedisTemplate redisTemplate;
private static final String DEDUP_PREFIX = "idempotent:dedup:";
@Before("@annotation(idempotent)")
public void check(JoinPoint joinPoint, Idempotent idempotent) {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
if (idempotent.type() == Idempotent.IdempotentType.TOKEN) {
String token = request.getHeader("X-Idempotent-Token");
String requestId = request.getHeader("X-Request-Id");
if (token == null || requestId == null) {
throw new BusinessException(400, "缺少幂等Token或请求ID");
}
if (!tokenService.validateAndRemoveToken(token, requestId)) {
throw new BusinessException(409, "请勿重复提交");
}
} else {
String paramFingerprint = buildFingerprint(joinPoint, idempotent.key());
String dedupKey = DEDUP_PREFIX + paramFingerprint;
Boolean setSuccess = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", idempotent.expireSeconds(), TimeUnit.SECONDS);
if (setSuccess == null || !setSuccess) {
throw new BusinessException(429, "操作过于频繁,请稍后再试");
}
}
}
private String buildFingerprint(JoinPoint joinPoint, String spelExpression) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
EvaluationContext context = new StandardEvaluationContext();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < paramNames.length; i++) {
((StandardEvaluationContext) context).setVariable(paramNames[i], args[i]);
}
ExpressionParser parser = new SpelExpressionParser();
String value = parser.parseExpression(spelExpression).getValue(context, String.class);
return DigestUtils.md5DigestAsHex(value.getBytes());
}
}
Controller层使用示例
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final IdempotentTokenService tokenService;
private final OrderService orderService;
@GetMapping("/token")
public Result<String> getToken() {
return Result.success(tokenService.createToken());
}
@PostMapping
@Idempotent(type = Idempotent.IdempotentType.TOKEN)
public Result<OrderVO> createOrder(@RequestBody OrderCreateDTO dto) {
return Result.success(orderService.create(dto));
}
@PostMapping("/payment")
@Idempotent(type = Idempotent.IdempotentType.DEDUP,
key = "#dto.orderNo + ':' + #dto.paymentChannel",
expireSeconds = 10)
public Result<PaymentVO> payment(@RequestBody PaymentDTO dto) {
return Result.success(orderService.payment(dto));
}
}
分布式环境下的注意事项
Redis集群模式下,Lua脚本中DEL操作在分片间不一定保证原子性。解决方案:使用Hash Tag强制Token落在同一分片——{token}作为key前缀,确保同一Token的所有操作路由到同一Redis节点。
消息队列消费端的幂等需要额外考虑消费位移提交时机。推荐模式:业务处理成功后再ACK,消费失败则NACK并进入重试队列,重试超过阈值转入死信队列人工处理。
幂等性设计是防御性编程的体现。Token机制适合前端触发的写操作,DEDUP指纹适合服务间调用——两种方案组合使用,基本覆盖微服务场景下的全部防重需求。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/springboot3-wei-fu-wu-jie-kou-mi-deng-xing-she-ji/