接口幂等性防重复提交令牌

wen java案例 2

本文目录导读:

接口幂等性防重复提交令牌

  1. 什么是令牌机制
  2. 实现原理
  3. 完整代码实现
  4. Redis Lua脚本(保证原子性)
  5. 高级优化方案
  6. 注意事项

我来详细讲解接口幂等性防重复提交的令牌机制(Token Pattern),这是一种最常用的解决方案。

什么是令牌机制

核心思想:客户端在提交请求前,先获取一个唯一令牌(Token),提交时必须携带此令牌,服务端处理请求时,检查这个令牌是否存在且有效。

实现原理

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    participant DB as Redis
    C->>S: 1. 请求获取令牌
    S->>DB: 2. 生成唯一令牌并存储
    DB-->>S: 3. 返回令牌
    S-->>C: 4. 返回令牌
    C->>S: 5. 请求处理(携带令牌)
    S->>DB: 6. 检查并删除令牌
    alt 令牌存在
        DB-->>S: 7. 删除成功
        S->>S: 8. 执行业务逻辑
        S-->>C: 9. 返回成功
    else 令牌不存在
        DB-->>S: 7. 删除失败(已用/过期)
        S-->>C: 8. 返回重复请求
    end

完整代码实现

令牌生成与存储

@Service
public class TokenService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    // 生成令牌
    public String createToken(String key) {
        // 1. 生成唯一令牌
        String token = UUID.randomUUID().toString();
        // 2. 存储到Redis,设置有效期(比如30分钟)
        String redisKey = buildRedisKey(key, token);
        redisTemplate.opsForValue()
            .set(redisKey, "1", 30, TimeUnit.MINUTES);
        return token;
    }
    // 校验并删除令牌
    public boolean checkAndDeleteToken(String key, String token) {
        String redisKey = buildRedisKey(key, token);
        // 使用Lua脚本保证原子性
        String script = "if redis.call('get', KEYS[1]) == '1' then " +
                       "return redis.call('del', KEYS[1]) " +
                       "else return 0 end";
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(redisKey)
        );
        return result != null && result > 0;
    }
    private String buildRedisKey(String key, String token) {
        return "token:prevent:" + key + ":" + token;
    }
}

自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
    // 令牌获取的key,支持SpEL表达式
    String key();
    // 令牌来源,默认从请求头获取
    String tokenSource() default "header";
    // 自定义验证失败的消息
    String message() default "请勿重复提交";
}

AOP拦截器

@Aspect
@Component
public class PreventDuplicateAspect {
    @Autowired
    private TokenService tokenService;
    @Around("@annotation(preventDuplicate)")
    public Object around(ProceedingJoinPoint point, 
                         PreventDuplicateSubmit preventDuplicate) throws Throwable {
        // 1. 解析SpEL表达式获取key
        String key = parseKey(preventDuplicate.key(), point);
        // 2. 从请求中获取令牌
        String token = getTokenFromRequest(preventDuplicate.tokenSource());
        // 3. 校验令牌
        if (!tokenService.checkAndDeleteToken(key, token)) {
            throw new RuntimeException(preventDuplicate.message());
        }
        // 4. 执行业务逻辑
        return point.proceed();
    }
    private String getTokenFromRequest(String source) {
        HttpServletRequest request = ((ServletRequestAttributes) 
            RequestContextHolder.getRequestAttributes()).getRequest();
        if ("header".equals(source)) {
            return request.getHeader("idempotent-token");
        } else {
            return request.getParameter("idempotent-token");
        }
    }
}

控制器实现

@RestController
public class OrderController {
    @Autowired
    private TokenService tokenService;
    // 获取令牌接口
    @GetMapping("/token")
    public Response<String> getToken() {
        String token = tokenService.createToken("order");
        return Response.success(token);
    }
    // 提交订单(防止重复提交)
    @PostMapping("/order/create")
    @PreventDuplicateSubmit(
        key = "'order:' + #order.userId",
        tokenSource = "header",
        message = "订单正在处理中,请勿重复提交"
    )
    public Response<String> createOrder(@RequestBody OrderDTO order) {
        // 业务逻辑
        return Response.success("订单创建成功");
    }
}

前端使用示例

// Vue.js 示例
export default {
  data() {
    return {
      idempotentToken: '',
      submitting: false
    }
  },
  methods: {
    async submitOrder(orderData) {
      this.submitting = true;
      try {
        // 第一步:获取令牌
        const tokenResponse = await axios.get('/api/token');
        this.idempotentToken = tokenResponse.data.data;
        // 第二步:携带令牌提交请求
        const result = await axios.post('/api/order/create', orderData, {
          headers: {
            'idempotent-token': this.idempotentToken
          },
          timeout: 30000
        });
        console.log('提交成功', result);
      } catch (error) {
        if (error.response?.status === 400) {
          alert(error.response.data.message); // "请勿重复提交"
        } else {
          alert('提交失败,请重试');
        }
      } finally {
        this.submitting = false;
      }
    }
  }
}

Redis Lua脚本(保证原子性)

-- check_and_delete.lua
-- KEYS[1]: token key
-- ARGV[1]: expected value
local token = redis.call('GET', KEYS[1])
if token == false or token ~= ARGV[1] then
    return 0  -- 令牌不存在或不匹配
end
redis.call('DEL', KEYS[1])
return 1  -- 校验成功并删除

高级优化方案

批量请求优化

@Component
public class BatchTokenService {
    // 批量生成令牌
    public Map<String, String> createBatchTokens(int count) {
        Map<String, String> tokens = new HashMap<>();
        // 使用pipeline批量写入
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for (int i = 0; i < count; i++) {
                String token = UUID.randomUUID().toString();
                connection.setEx(
                    buildRedisKey(token).getBytes(),
                    1800, // 30分钟
                    "1".getBytes()
                );
                tokens.put("token_" + i, token);
            }
            return null;
        });
        return tokens;
    }
}

分布式环境支持

public interface DistributedTokenService {
    // 使用分布式锁保证token获取的互斥性
    default boolean acquireTokenWithLock(String key, String token) {
        // 1. 先尝试获取分布式锁
        String lockKey = "lock:" + key;
        String lockValue = UUID.randomUUID().toString();
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(locked)) {
            try {
                // 2. 执行业务逻辑
                return processToken(key, token);
            } finally {
                // 3. 释放锁(使用Lua脚本保证原子性)
                releaseLock(lockKey, lockValue);
            }
        }
        return false;
    }
}

注意事项

令牌有效期

# application.yml
token:
  prevent:
    # 令牌有效期(分钟)
    expire-time: 30
    # Redis key前缀
    key-prefix: "idempotent:token:"
    # 清理过期令牌的间隔(分钟)
    clean-interval: 60

异常处理

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(DuplicateSubmitException.class)
    public Response<String> handleDuplicateSubmit(DuplicateSubmitException e) {
        return Response.error(400, e.getMessage());
    }
    @ExceptionHandler(TokenExpiredException.class)
    public Response<String> handleTokenExpired(TokenExpiredException e) {
        return Response.error(401, "令牌已过期,请重新获取");
    }
}

性能优化

  • 使用Redis pipeline批量操作
  • 令牌预生成(提前生成一批令牌)
  • 使用本地缓存结合Redis(Caffeine + Redis)

这个令牌机制是实现接口幂等性最可靠的方式之一,适用于金融转账、订单创建、支付等关键业务场景。

抱歉,评论功能暂时关闭!