当前位置:   article > 正文

接口幂等性问题和常见解决方案

接口幂等性问题

1.什么是接口幂等性问题

幂等性: 用户同一操作发起的一次多次请求的结果是一致的

在增删改查4个操作中, 查询不会修改数据, 删除进行一次或者多次的产生的结果一致, 所以只需要关注修改新增操作, 修改和新增在重复提交的场景下会产生接口幂等性问题

1.1 会产生接口幂等性的问题

  • 定时任务重复执行
  • 使用了失效或超时的重试机制, 发起的重试
  • 第三方平台的接口, 因为异常导致多次异步回调
  • 中间件、应用服务根据自身特性, 也有可能进行重试
  • 使用浏览器后退按钮重复之前的操作, 导致重复提交表单
  • 网络波动等异常, 未收到反馈后发起重复请求, 页面重复刷新
  • 用户在使用的时候无意多次点击(重复操作),或者没有响应而导致多次下单或者交易。

1.2 解决思路

解决思路分为两个方向:

  • 客户端防止重复调用
  • 服务端防止重复调用

2.接口幂等性的解决方案

2.1 唯一索引解决方案

根据业务需求, 对数据表中字段设置唯一索引, 可以是单一索引, 也可以是联合索引, 防止新增时出现脏数据

例如: 新增用户数据, 具体流程:

  1. 给表中的手机号设置唯一索引
  2. 第一次请求, 插入成功
  3. 后续请求, 抛出唯一索引冲突异常(DuplicateKeyException), 插入失败

优缺点: 操作简单, 只要对字段建立唯一索引即可, 但是只适用于新增操作, 而且效率不高, 基于数据库机制去防止重复新增, 相当于把压力都给到了数据库, 在高并发情况下会出现性能问题

2.2 乐观锁解决方案

根据业务需求, 给数据表添加一个版本字段(version), 执行更新操作时, 比较版本号. 如果版本号相同, 则可以更新成功, 并在更新时增加版本号, 如果版本号不同, 则更新失败

例如: 更新账户余额, 具体流程:

  1. 给表中添加版本号字段(version), 默认为0
  2. 第一次请求, 开启事务, 将id为1的用户的账户余额+10
start transaction;
update account set money = money + 10, version = version + 1 where id = 1 and version = 1;
  • 1
  • 2
  1. 第二次请求, 开启事务, 将id为1的用户的账户余额更新-20
start transaction;
update account set money = money - 20, version = version + 1 where id = 1 and version = 1;
  • 1
  • 2
  1. 第一次请求, 提交事务, 更新成功
  2. 第二次请求, 提交事务, 更新失败, 因为version = 1这个条件已经不符合了
    在这里插入图片描述

缺点:

  • 只适用于更新操作
  • 无法完全保证幂等性, 例如第一个请求已经完成并提交事务, 那么第二个请求即使是相同的请求, 仍然会修改数据

2.3 分布式锁解决方案

这里演示使用Redis + 自定义注解 + AOP解决

  1. 浏览器请求接口时, 携带一个唯一标识(前端生成, 可以是UUID或者类似的唯一标识符), 短时间内重复点击, 唯一标识相同
  2. 将唯一标识缓存到Redis中, 并设置超时时间, 例如500毫秒
  3. 第一次请求, 设置成功(setNx方法), 继续操作数据
  4. 第二次请求, 设置失败, 代表已经有线程在执行同一个请求了, 直接返回, 不进行重复操作

代码实现:

  • 自定义注解(实现更灵活的接口幂等性校验)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
 
    /**
     * 过期时长(毫秒)
     */
    long expire();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 针对添加了Idempotent注解的接口, 进行AOP
@Aspect
@Component
@Slf4j
public class IdempotentAspect{

    @Resource
    private RedisTemplate<String,String> redisTemplate;

    @Pointcut("@annotation(com.itheima.annotation.Idempotent)")
    public void execute(){}

    @Around("execute()")
    public Object around(ProceedingJoinPoint joinPoint) {
    	HttpServletRequest request =((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
    	
    	// 获取本次请求唯一标识
		String token = request.getHeader("token");
		
        // 获取注解对象
        Idempotent annotation = method.getAnnotation(Idempotent.class);
        
        // 缓存设置(setNx方法), key为唯一标识, value为随机值, 过期时间为注解的设置, 单位是毫秒
        Boolean b = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", nnotation.expireMillis(), TimeUnit.MILLISECONDS);
        if (b != null && b) {
  			// 放行, 执行业务方法
  			Object obj = joinPoint.proceed();
  			
  			// 删除缓存
  			redisTemplate.opsForValue().delete(redisKey);
            return obj;
        }else {
        	// 友好提示 
            throw new RuntimeException("您操作的太快,请稍后再试");;
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

缺点:

  • 浏览器快速点击, 产生了两次请求, 第一次请求先到服务器, 因为某些原因, 第二次请求达到服务器时, 第一次请求已经执行完毕并释放了锁, 此时第二次请求仍然可以加锁成功, 并执行业务逻辑, 这种情况下幂等性失效

客户端连续发起多次请求,这多次请求同时到达服务端,此时开始争抢锁,谁抢到锁谁就执行,其他没有抢到锁的请求都统统不执行。这种情况能保证幂等性。

2.4 Token解决方案(最优方案)

解决幂等性的思路: 为每一次操作(即使发生多次请求)提供一个唯一Token, 我们确保Token的一次性唯一性, 唯一性很好理解, 每次产生的Token都是UUID(类似技术), 一次性可以想象为一个待消费的二维码, 扫描一次后即失效(一次性凭证)

  1. 服务端要记录这个一次性凭证, 所以Token需要在服务端生成, 在服务端提供一个返回Token的接口, 每次请求都会将Token写入Redis缓存(记录凭证, 后期验证), 并响应给浏览器(凭证发放), 这个Token相当于一次性凭证, 例如消费券的二维码

  2. 后端提供一个返回Token的接口, 后端会将Token写入缓存, 并响应给前端(这个token等于是一个一次性的钥匙, 例如二维码)

  3. 浏览器携带Token发起请求

  4. 服务端在拦截器(或者AOP)中校验Token的有效性, 实质就是判断Redis中是否存在这个Token

  5. 有这个token, 就开门放行, 并删除Redis中token(一次性凭证已使用), 然后执行相应的业务逻辑

  6. 如果没有这个token, 可能是因为token已经过期了(有过期时间)、伪造的token、token已经销毁了(delete)这些情况都属于访问失败, 服务器会拒绝请求

PS: 一般来说,服务端会在验证通过后立即删除Token,以确保后续的请求都被认为是无效的(更接近原子性)

简单来说: 为每一次操作生成一个待使用的一次性凭证, 第一次请求使用凭证, 开门放行, 后续请求再携带凭证, 但凭证已经失效了, 无法放行

3 Token解决方案落地

3.1 token获取、token校验

@RestController
@RequestMapping("token")
public class TokenController {

    @Autowired
    private TokenService TokenService;

    @GetMapping("get")
    public String getToken() {
        return TokenService.getToken();
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • getToken: 执行获取Token的业务
  • check: 对请求中携带的Token进行校验
// 业务接口
public interface TokenService {

    String getToken();

    void check(HttpServletRequest request);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 获取Token
    • 使用签证标识+UUID作为Token, 并设置过期时间5分钟(5分钟没有请求操作, 则Token过期)
    • 将Token存入Redis, 这个操作是记录凭证, 用于后期验证
  • 校验Token
    • 浏览器在请求头中携带Token, 服务端获取到Token后, 判断Redis中是否存在Redis
    • 如果有, 则开门放行, 并删除Token(凭证已使用)
    • 如果没有, 则拒绝请求
// 业务实现
@Service
public class TokenServiceImpl implements TokenService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public String getToken() {
        // 使用UUID作为Token
        String token = UUID.randomUUID().toString().replace("-", "");

        // 给Token加个前缀, 意思是进行幂等性校验的Token
        token = "API_IDEMPOTENT_TOKEN:" + token;

        // 将Token缓存到redis中, key是token, value是随机值, 过期时间为300(5分钟), 过期单位为秒
        // 如果5分钟之内, 客户端不携带token进行一次请求, 则token过期, 访问目标接口需要重新获取Token
        redisTemplate.opsForValue().set(token, "0", 5 * 60, TimeUnit.SECONDS);
        return token;
    }

    @Override
    public void check(HttpServletRequest request) {
        // 从请求头里拿到Toekn
        String token = request.getHeader("idempotentToken");
        if (StringUtil.isBlank(token)) {
            // 请求头中不存在, 就从请求参数中拿
            token = request.getParameter("idempotentToken");
            if (StringUtil.isBlank(token)) {
                throw new RuntimeException("参数不合法");
            }
        }

        // 判断redis中是否存在token
        if (!redisTemplate.hasKey(token)) {
            // 不存在, 其实分为好几种情况. 1-过期了、2-伪造、3-已经被消费了, 我们同一回复
            throw new RuntimeException("请勿重复操作");
        }

        // 校验通过, redis中存在token, 一次性消费成功, 任务放行. 凭证过期
        redisTemplate.delete(token);
    }
    
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44

3.2 自定义注解, 标识哪些接口需要幂等性校验

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

}
  • 1
  • 2
  • 3
  • 4
  • 5

3.3 目标接口上添加注解

@RestController
@RequestMapping("api")
public class ApiController {

    @ApiIdempotent
    @GetMapping("test")
    public void test() {
        System.out.println("执行业务, 模拟一个比较耗时的操作");
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

3.4 拦截器

拦截器中怎么使用@Autowired!!!
SpringBoot常使用的拦截器案例, 是无法在拦截器中直接使用@Autowired的

在这里插入图片描述

原因: 拦截器并非是Spring容器中的Bean, Spring无法对其进行自动装配,
问题扩展: 如果给拦截器上添加@Component注解, 依旧无法使用@Autowired, 因为被@Component注解的类确实被Spring容器管理了, 但你注册到SpringMVC容器中的是new ApiIdempotentInterceptor(), 它们都不是同一个

解决方案:

  1. 在拦截器上添加@Component注解, 让IOC容器管理这个拦截器
  2. 在MVC的配置中通过IOC容器获取拦截器(使用@Autowired注入), 然后再注入到MVC容器中
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private ApiIdempotentInterceptor apiIdempotentInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(apiIdempotentInterceptor)
                .addPathPatterns("/**"); // 可以指定拦截的路径,/** 表示拦截所有路径
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

之后就可以在拦截器中, 直接使用@Autowired

@Component
public class ApiIdempotentInterceptor implements HandlerInterceptor {

    // 注入Spring容器
    @Autowired
    private TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        try {
            // 转换为可处理的method对象
            HandlerMethod handlerMethod = (HandlerMethod) handler;

            // 校验方法上是否添加了幂等性校验注解
            Method method = handlerMethod.getMethod();
            ApiIdempotent annotation = method.getAnnotation(ApiIdempotent.class);

            if (annotation != null) {
                // 方法上添加了自定义注解
                tokenService.check(request);
            }
        } catch (Exception e) {
            // 为统一异常处理对象设置code和msg, 并将其转换为JSON字符串
            ErrorResponse errorResponse = new ErrorResponse(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
            String json = JSON.toJSONString(errorResponse);

            // response返回统一异常处理对象
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            // 设置响应类型为JSON
            response.setContentType("application/json");
            // 设置响应编码为UTF-8
            response.setCharacterEncoding("UTF-8");
            // 将JSON字符串写入响应输出流
            response.getWriter().write(json);
            // 不放行
            return false;
        }

        // 在请求处理前执行的逻辑
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // 在请求处理后,视图渲染前执行的逻辑
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 在请求处理完成后执行的逻辑,可以用于资源清理等操作
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/花生_TL007/article/detail/663962
推荐阅读
相关标签
  

闽ICP备14008679号