美文网首页
接口幂等性

接口幂等性

作者: 初心myp | 来源:发表于2019-12-17 17:08 被阅读0次

    什么是接口幂等性?

    幂等是数学和计算机学的概念,常见于抽象代数中,即f(f(x)) = f(x)。简单来讲就是接口被调用多次获得的结果和接口被调用一次获得的结果是一致的。在开发过程中,有很多操作天生就具有幂等性,比如数据库的select操作,无论查询多少次,与查询一次的结果都是一致的。很多情况的接口幂等性都需要我们自己处理的,特别是在分布式系统中,能不能保证接口幂等性对系统影响是非常大的。例如下单支付的操作场景,由于分布式系统环境的网络复杂性、用户误操作、网络抖动、消息重发、服务超时导致业务自动重试等等各种情况都可能造成线上数据不一致,导致事故。

    接口幂等性的典型案例

    在微服务架构下,我们在完成一个订单流程时经常遇到下面的场景:

    1. 一个订单创建接口,第一次调用超时了,然后调用方重试了一次
    2. 在订单创建时,我们需要去扣减库存,这时接口发生了超时,调用方重试了一次
    3. 当这笔订单开始支付,在支付请求发出之后,在服务端发生了扣钱操作,接口响应超时了,调用方重试了一次
    4. 一个订单状态更新接口,调用方连续发送了两个消息,一个是已创建,一个是已付款。但是你先接收到已付款,然后又接收到了已创建
    5. 在支付完成订单之后,需要发送一条短信,当一台机器接收到短信发送的消息之后,处理较慢。消息中间件又把消息投递给另外一台机器处理

    解决接口幂等性问题的方案

    首先,我们在开发过程中,一旦遇到所谓的高并发情况,第一时间想到的就是锁,有的时候认为加上分布式锁或者单机锁就可以解决问题了,但是并不是。确实有些时候加上锁可以解决问题,那么同时也让程序变成单线程执行,还得注意锁不要加错位置,需要先搞清楚程序同步的临界区是什么。否则不但没能解决问题还降低系统TPS造成性能影响,而说到锁很多人的第一个反应就是Jdk提供的同步锁synchronized,一般情况下同步锁确实能解决多线程访问临界区造成的数据安全问题即并发问题,同步锁的一般使用方式要么是锁住整个方法要么是方法内部锁住一个程序片段,不管哪一种先要明白锁的是当前这个类的实例对象,即多个线程同时访问代码片段时访问的是同一个对象(如果每个线程都会创建一个新的实例对象的话,加锁也就毫无意义了)比方说Spring受管的bean,默认情况下都是单实例的,也就是说多线程共享的,这个时候才需要考虑并发的问题。而我们平时在做项目的过程中,除了要完成业务开发之外,还得多想想业务之外的一些东西比如接口需不需要保证幂等,代码有没有很强的扩展性等等。

    接下来举例分享一下:
    假设我们数据库里现在有一张order表,字段有id,userId,planId(计划ID),money,createTime;假设有这么一个业务,前端在用户下单就是针对某一个计划进行提交一条数据。那么这个时候我们的业务伪代码是这样的:

    public void savePlan(userId,planId,money){
        boolean exist = select(userId,planId);
         if(exist){
             insertOrder(userId,planId,money);
         }
    }
    

    那么上面的情况下在加同步锁是可以保证高并发的情况下访问不会出现问题,但如果在insert之前没有先从db中select出来就直接insert了,那么加锁也是白加,因为锁的本质也是在排队,第一个请求执行完之后,紧接着等待队列中的第二个请求一样会执行。另外一个问题是单机锁无法解决系统集群或者分布式的场景,要知道现在大部分的互联网应用都是集群或分布式的,JDK的同步锁也只能锁住单个进程,系统由于负载均衡,并发的两个线程不一定就请求到同一台服务器,所以这种场景下加锁很大几率是无效的。当然分布式锁是可以解决这两个问题,之前的文章有使用redis实现的分布式锁,可以参考redis实现分布式锁(完善版)。在这里采用redis的setnx实现的一个简易版的锁,写一段伪代码进行演示:

     //加锁
       public static boolean acquiredLock(key,expired,timeout,timeUnit){
             try(Jedis jedis = getJedis()){
                  long time = System.nanoTime();
                  while (System.nanoTime() - time < timeUnit.toNanos(timeout)){
                     long lock = jedis.setnx(key, UUID.randomUUID().toString());
                     if (lock == 1) {
                         jedis.expire(key, expired);
                         return true;
                     }
                  }
             }
             return false;
       }
       //解锁
       public static void unLock(key) {
            try (Jedis jedis = getJedis()) {
                jedis.del(key);
            }
        }
    

    我们在程序中可以先定义一个字符串常量的key,并根据实际情况控制好timeout,那么当第一个线程进来的时候拿到了锁就执行下面的业务,另一个线程发现锁已经被拿走了,就执行返回失败或者给个友好的提示“不能重复提交”之类的,伪代码如下:

        public void saveOrder(userId,planId,money){
             if(acquiredLock(key,timeout)){
                insertOrder(userId,planId,money);
             }else{
                throw new RuntimeException("不能重复下单");
             }
        }
    

    以上这段代码初看好像也没什么问题,但是采用redis来做控制也是有很多坑的,比方说这个超时时间就很不好控制还得考虑redis挂掉了怎么处理,还要注意解锁,搞不好会变成死锁等等

    加锁这种方案在这里基本上是不适用的,那么该怎么做呢,其实方案很多,但首先我们得先分析出现数据问题的根源才好做出相应的解决方案。比如客户端bug,网络不稳定导致的服务超时,app闪退或者人工强退等等都是很常见的问题。事实上类似这种问题都无法仅仅通过客户单或者服务端就能解决的,我们项目出现的问题很可能就是服务端和客户端都没做处理。其实至少客户端需要做一个提交之后按钮的置灰功能吧,虽然对于很多人来讲这没什么用但是针对大量的小白用户来说已经可以阻止他们误操作了,所以说接口校验的原则(请求的合法性,参数的正确性等)应该是前后端一起做的。

    具体的解决方案之一,就是利用db的唯一索引约束结合客户端来保证接口的幂等性。
    我们可以在表字段userId和planId加上联合唯一索引约束dedup_key,然后再业务层显示捕获抛出的异常,在多做一层异常的封装,这样就可以返回客户端友好的提示了,伪代码如下:

    public void saveOrder(userId,planId,money) throws BusinessException {
        try {
              insertOrder(userId,planId,money);
        }catch (RuntimeException e) {
             if (e.getMessage().contains("Duplicate entry")
                 && e.getMessage().contains("dedup_key")){
                   throw new BusinessException ("不能重复下单");
             }else{
                   throw e;//其他类型的异常要往外抛出
             }
        }
    }
    

    上面的方法看起来比较简洁,但并不是很好,如果我们在订单插入之前做了一些其他关联表的插入或编辑的操作,那么如果一旦订单插入抛出异常,我们需要将之前的操作全部回滚。所以上面的方法会额外增加系统的复杂性。 相对更好一点的方法是不在业务表上加唯一索引,而是独立出一张token_table,通常称为排重表或者令牌表,表中主要有一个字段unique_key,并在字段上建一个unique index,那么这时候可以使用上面采用过的通用方案即并发时由数据库自动抛出异常,业务service来捕获最终返回给客户端友好的提示,或者我们还可以利用mysql的insert ignore特性来处理这个问题。我们借助mysql的insert ignore特性,假如token_table有一个主键taskId,第一次插入一条数据,会返回1,表示数据库没有这条数据,第二次插入不会抛出异常,而是返回0。说明数据库已经存在这条数据了,我们就可以给出友好的提示了。那我们的业务就可以这样:

    1. 先使用insert ignore插入一条数据到令牌表中,得到返回的值为0或者为1
    2. 在service方法中无需显示捕获异常,只需判断第一步获取到的结果,如果大于0则说明是第一次插入此时拿到令牌,则可以往下走,否则抛出重复提交的异常给客户端提示即可,代码也很简洁,伪代码如下所示:
    public void saveOrder(userId,planId,money){
          int token = insert ignore token_table(unique_key) value(uniqueKey);
          if(token>0){
               insertOrder(userId,planId,money);
          }else{
               throw new BusinessException ("不能重复下单");
          }
    }
    

    以上所列出来的方案都是属于业务本身存在唯一标示的字段(userId+planId),但如果业务本身不存在这样的字段来建unique index该怎么处理呢,一般有两种处理方式,第一种是由客户端来生成,而且每次生成之后要cache起来以便下次使用的时候能辨别出是否是重复的请求具体可参考开头提到的那篇文章,第二种则是由服务端根据业务具体情况来统一生成全局标示,做成一个全局的微服务,但需要考虑的东西比较多架构实现也比较复杂。
    还有一种解决方案是利用数据库的锁机制来处理即共享读锁+普通索引

    总结一下:
    接口幂等性和并发是有关联的。我们在单节点的情况下,解决并发问题一般都会使用synchronized同步锁,但是一旦变成分布式就不适用了,那么我们可能会考虑使用基于redis实现的分布式锁,但是原理也是排队,然后变成但线程访问,很大可能影响效率,还有就是实现比较麻烦,而且如果redis挂了,那么就无解了。所以我们这里说到的接口幂等性也是一样的,只不过是涉及到的是一个业务只需要执行一次,由于其他原因提交多次问题。和并发问题类似。所以我们解决接口幂等性的方案有几种:

    1. 由前后端配合解决,解决的是与前端交互的时候,前端重复提交问题。
    2. 服务端之间调用,上面案例提到的场景
    3. 消息重复消费,MQ消息队列,消息重复消费。

    (1) token机制:提交时客户端先请求后端获取token,服务端生成token先进行缓存,然后返回给客户端。接下来,客户端带着token来请求,服务端先进行token验证,判断token是否存在,如果存在则处理接下来的业务流程,然后删除token。如果不存在则提示客户端重复操作。
    (2) 去重表,和上述说的一样,表里需要一个主键或者unique index索引
    (3) redis,分布式锁方式
    (4) 状态机,通过固定的状态顺序判断幂等,比如订单的待提交、待支付、已支付、已支付待发货、已发货、已完成、已取消。待支付之前一定是待提交状态,处于待支付的订单,就不能再做提价操作了,这样也可以解决接口幂等的问题。
    (5) 全局ID,根据业务和操作生成全局id,执行操作之前先判断全局id是否存在,来判断该操作是否已经执行了
    (6) 插入或更新,适用于新增操作并且有唯一索引的情况
    (7) 多版本控制,适合于更新场景

    参考文章:
    https://www.cnblogs.com/sea520/p/10117729.html
    https://www.cnblogs.com/jajian/p/10926681.html

    相关文章

      网友评论

          本文标题:接口幂等性

          本文链接:https://www.haomeiwen.com/subject/xyqdnctx.html