如何实现接口幂等性

一.什么是幂等性

接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致性的,不会因为
多次点击而产生了副作用,比如支付场景,用户购买了商品支付扣款成功,但是返回结果的时候
网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户
查询余额发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。

二. 哪些情况需要防止出现幂等性

1、用户多次点击提交按钮
2、用户页面回退再次提交
3、微服务互相调用,由于网络抖动,导致请求失败,feign触发重试机制等。

三. 哪些情况需要幂等

1、以SQL为例,有些操作是天然幂等的。比如:为t_user表的手机号phone字段添加唯一约束,从数据库层面保证幂等。
2、但是在SQL更新的场景下,update t_user set age=age+1 where userId=1;每次执行的结果都会发生变化,
它不是幂等的。 再例如:inser into t_user(userId, name) values(1, 'doinb');如果userId不是主键,它是
可以重复插入,这样会产生多条一模一样的数据,此时它不具备幂等性。

四. 幂等解决方案

一、token机制
    1、服务端提供了发送token的接口。我们在分析业务的时候,那些业务存在幂等性问题的,就必须在执行业务前,
    先去获取token,服务器会把token保存到redis中。
    2、然后调用业务接口请求时,把token携带过去,一般放在请求头部。
    3、服务器判断token是否与redis中的token匹配,匹配成功,然后删除token继续执行业务。
    4、如果判断token不存在redis中,就表示重复操作,直接返回重复标记给client,用这样的方式来保证业务代码
    的幂等性。 
    注意事项:
        先删除token还是先处理业务?必须先删除令牌,避免并发情况下收到多请求执行通过。 
        获取token,校验token,删除token三个动作必须保证原子性,使用redis的Lua脚本完成这个操作。
       (如果不知道Lua脚本为什么能保证原子性,推荐去了解redis的线程模型)  
if redis.call('get',KEYS[1])==ARG[1] then return redis.call('del',KEYS[1]) else return 0 end
二、各种锁机制
    1、数据库悲观锁
        select * from t_user where user_id=1 for update;
        悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。
        另外需要注意的是,Id字段是主键或者唯一索引,不然可能造成锁表的结果,处理起来会非常麻烦。
    2、数据库乐观锁
        这种方法适合在更新场景中,update t_user set age=age+1, version=version+1 where user_id=1 and version=1;
        根据version版本,也就是在操作库存前先获取当前商品的version版本号,然后操作的时候带上此version号。
        我们第一次操作库存时,得到version为1,调用库存服务version变为2;但返回给订单服务出现了问题,订单又一次发起
        调用库存服务,当订单服务传入version还是1,再执行上面的SQL语句时,因为version已经变成2了,where条件不成立。
        这样就保证不了不管调用几次,只会真正的处理一次。
        而且,乐观锁主要使用与处理读多写少的场景。
    3、业务层分布式锁
        如果多个机器可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数据,我们就可以加分布式锁,
        锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过。
 
三、各种唯一约束
    1、数据库唯一约束
        插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单号就不可能有两条记录插入、
        我们在数据库层面防止重复。
        这个机制就是利用了数据的主键唯一约束的特性,解决了在insert场景时幂等问题。但主键的
        要求不是自增的主键,这样就需要业务生成全局唯一的主键。
        如果是在分库分表的场景下,路由规则要保证相同请求下,落地在同一个数据库中和同一表中,
        要不然数据库主键约束就不起作用了,因为是不同的数据库和表主键不相关联。
    2、redis 防重
        很多数据需要处理,只能被处理一次,比如我们可以计算数据的MD5将其放入redis的缓存中,
        每次处理数据,先判断这个MD5是否已经存在,存在就不处理。
    3、防重表
        使用订单号orderNo作为去重表的唯一索引,把唯一索引插入去重表,在进行业务操作,且他们在
        同一个事务当中。这样来保证重复请求时,因为防重表有唯一约束,导致请求失败,避免了幂等问题。
        这里要注意的是,去重表应该在同一库中,这样就保证了在同一个事务,即使业务操作失败了,它也
        会把去重表的数据回滚。这个很好的保证了数据一致性。
    4、全局请求唯一ID
        调用接口时,生成一个唯一id,redis将数据保存到集合中(去重),存在即处理过,可以使用Nginx
        设置每个请求的唯一id; proxy_set_header X-Request-Id $request_id; 
        缺点:nginx层面生成的id不区分数据并不能保证幂等,需从页面调用时由后台生成唯一id。

五. 解决方案示例

本次采用token机制,开发者根据经验判断哪一个接口需要保证幂等的时候,应当在提交保存之前需要向服务端发起请求,获取到本次执行的token,
在保存数据的时候把token一并提交到后台,后台通过lua脚本执行校验动作,大致实现如下:
    public R submitOrder(OrderSubmitVo vo) {
    
    
        // lua脚本包含校验和删除key(在同一事务里面完成), 0-执行失败, 1-执行成功
        String script = "if redis.call('get',KEYS[1])==ARG[1] then return redis.call('del',KEYS[1]) else return 0 end";
        // 前端传进来的token
        String inputToken = "vo.getToken()";
        DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<>(script, Long.class);
        // 需要验证的key, 提前约定好,比如: KEY = USER_ORDER_TOKEN_PREFIX + vo.getOrderId()
        List<String> keys = Arrays.asList("KEY");
        // 原子验证令牌和删除令牌
        Long result = redisTemplate.execute(defaultRedisScript, keys, inputToken);
        if (result == 1){
    
    
            System.out.println("令牌删除成功,继续往下执行业务。");
            // 继续往下执行业务...
        } else {
    
    
            System.out.println("令牌删除失败,终止执行业务。");
            // 终止业务,抛出异常或者响应异常信息
        }
    }

广告时间

我总结的个人面经以及面试资料,非常实用,欢迎点赞和Fork!

猜你喜欢

转载自blog.csdn.net/doinbb/article/details/108438937