Spring Boot 3微服务接口幂等性设计:Redis Token机制与防重提交方案

接口幂等性的业务场景

微服务架构下,网络抖动导致客户端重试、消息队列重复消费、前端按钮重复点击——这些场景都会产生重复请求。订单创建接口被调两次就是两笔订单,支付接口被调两次就是两次扣款。幂等性设计不是可选项,是后端开发的底线要求。

幂等性的定义:同一操作执行一次和执行多次的效果完全相同。

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/

(0)
小编小编
上一篇 12小时前
下一篇 12小时前

相关推荐