Loading

自定义注解+Redis解决接口幂等性问题

接口幂等性问题

在计算机中,幂等性是指:用户对于同一个操作发起一次请求或者多次请求,获得的结果都是一致的。不会因为请求多次出现异常情况。

导致接口出现幂等性的原因有很多,可能是网络超时导致自动重试请求的原因,也有可能是用户多次点击过快导致请求重复发送。

今天本文就尝试实现一个自定义注解,在该注解中会用到Redis来控制实现锁机制,进而解决幂等性问题。

自定义注解

先定义一个自定义注解,name参数比较重要,用来标识不同接口(毕竟这个注解是要放到多个接口上使用的)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotence {

    // 锁定时间(默认3秒)
    long lockTime() default 3000;

    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;

    // 错误提示信息
    String message() default "请勿重复请求";

    String name();
}

具体的实现逻辑:

@Component
@Aspect
public class IdempotentAspect {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Around("@annotation(idempotence)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotence idempotence) throws Throwable {

        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        // 1. 获取幂等令牌(可从header、param或body中获取)
        String jwt = request.getHeader("MyToken");
        Claims claim = JwtUtils.getClaimByToken(jwt);
        assert claim != null;
        String username = claim.getSubject();

        if (username == null || username.isEmpty()) {
            throw new RuntimeException("token缺失关键信息");
        }
        // 2. 创建Redis key
        String key = idempotence.name() + ":" + username;

        // 3. 使用Lua脚本保证原子性(设置值并设置过期时间)
        String luaScript = "if redis.call('setnx', KEYS[1], 'idempotence') == 1 then " +
                "redis.call('pexpire', KEYS[1], ARGV[1]) " +
                "return 1 " +
                "else " +
                "return 0 end";

        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
        Long result = redisTemplate.execute(redisScript, Collections.singletonList(key),
                String.valueOf(idempotence.timeUnit().toMillis(idempotence.lockTime())));

        System.err.println("【result】" + result);

        // 4. 判断结果
        if (result != null && result == 1) {
            // 首次请求,执行业务
            return joinPoint.proceed();
        } else {
            // 重复请求
            throw new RuntimeException(idempotence.message());
        }
    }
}

接口

@Idempotence(lockTime = 1000, message = "订单重复提交", name = "createOrder")
@PostMapping("/createOrder")
public ResponseEntity<?> createOrder() {
    System.err.println("订单创建成功");
    // 业务逻辑处理...
    return ResponseEntity.ok("订单创建成功");
}

先讲一下实现的逻辑:

  1. 当大量请求发送到后端时,会先执行自定义注解里面的逻辑
  2. 而自定义注解部分的逻辑就是,向redis中存入一条数据,这条数据的特点就是同一个用户向同一个接口发送请求时,需要存入redis的数据是一样的。基于这个逻辑,本文是将token中的用户名作为唯一标识放入redis的。
  3. 当第一个请求执行到这里时,就会向redis中存入对应数据(createOrder:admin),redis会返回一个1,表示执行成功,即result为1,会继续执行接口中的业务逻辑。
  4. 当下一个请求也执行到这里时,也会尝试向redis中存入相同的数据,但是此时redis中已经有该数据了,所以result为0,就会报错。

注意:luaScript 为 Lua 脚本,保证了操作的原子性,避免多线程并发的情况下,出现不可控结果。

测试

我这里用ApiFox做压测,每秒钟发送20给请求,连续发送60秒,测试结果如下:

有且只有这一个请求成功了,其他请求均失败。

但是这里有个很明显的问题就是:如果第一个请求(假设是第一个请求执行成功了,实际上并发情况下,不确定是哪个请求)设置的redis数据过期了,redis会自动删除这个数据,那么其他请求就可以继续往redis里面存入数据,进而执行业务逻辑。

但是一般情况下是没问题的,因为一个接口的响应时间往往是数十或上百毫秒,特别是创建订单这一类业务比较复杂的接口,响应时间普遍是数百毫秒。基于这样的情况下,只要将redis数据过期时间设置的长一点(一般是接口响应时间的3到5倍),基本就可以避免上面的问题。

扩展

那上面的方案出现的问题有没有解决办法呢?

当然有!有些同学也可以尝试在发送正式请求(比如创建订单请求)之前,先发送一个请求,获取到一个唯一标识,然后再将这个唯一标识放到请求头中,发送创建订单请求。具体是实现逻辑如下:

  • 前端点击创建订单后,先发送一个请求(拿到标识请求),后端生成一个唯一标识符,并将其存放到redis中,然后返回给前端;
  • 前端拿到一个唯一标识符后,再次发送请求(创建订单请求),将该唯一标识符放到请求头中;
  • 后端接收到请求时,先获取到这个唯一标识符,然后去redis里面找到并删除这个数据;
  • 删除成功后就可以继续执行业务端的逻辑;
  • 此时如果因为各种原因,有多个请求也拿着这个唯一标识符创建订单的话,就会因为删除redis数据失败,而无法执行业务代码。

这种方案可以完美解决上面提到的redis中数据过期,导致其他请求仍然可以继续执行的问题。

注意:上面说的“去redis里面找到并删除这个数据”,最好是保证操作的原子性,这里还是建议用Lua脚本封装整个命令。

但是这也有个问题,就是这种方法不能避免因多次点击导致的多次请求问题,因为重复点击的情况本质上就是发送了多个请求,每个请求都会拿到唯一的标识符,每个请求也都能在redis中存入对应数据。所以如果用这种方法的话,就需要重新考虑逻辑了。

参考文献

深入架构原理与实践——幂等性设计

posted @ 2025-06-06 09:09  maoxianjia  阅读(55)  评论(0)    收藏  举报