(转)关于接口幂等性的总结

前言

  元旦放假哪也没去一个人在家里闷得慌,突然间想写点东西打发打发时间,刚好想起前几天在公司听到一些同事在讨论线上数据库出现数据重复的问题,据说是因为接口与前端都没有做重复提交的约束导致的问题,因为我没有参与到相关业务的开发中,所以具体情况不了解,只是听他们在讨论过程中知道一点就是有可能是用户误操作导致接口出现并发问题,还猜测有可能是用户端通过程序脚本的方式来刷接口,虽说后端API使用了Https协议加密传输,但还是有被刷的可能性,也有同学表示说接口要加个同步锁来排重等等。初一听好像没有什么问题,仔细想下也是有问题的,加上之前有在简书上看过一篇关于怎么处理接口幂等的文章,觉得写的很赞(如何避免重复下单),这里就再结合自己过去在一些项目中对于这种重复提交的做法做下记录总结吧。

什么是接口幂等

  幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中,即f(f(x)) = f(x) 简单的来说就是一个操作多次执行产生的结果与一次执行产生的结果一致。有些系统操作天生就具有幂等性例如数据库的select语句,但更多时候是需要程序员来做保证的,尤其是在分布式系统环境中,接口能不能做到保证幂等性对系统的影响可能是非常大的,例如很常见的支付下单等场景,由于分布式环境中网络的复杂性,用户误操作,网络抖动,消息重复,服务超时导致业务自动重试等等各种情况都可能会使线上数据产生了不一致,造成生产事故。

如何解决幂等性问题

  首先我发现很多人在做项目的过程中一旦发现程序出现了所谓高并发问题(其实很多时候是在意淫哈,哪有那么多高并发),第一个反应就是加锁,不管是分布式锁还是单机锁,好像加了锁并发问题就真的不存在了似的,确实在很多情况下加锁是能解决问题的,但程序也变成了单线程执行, 还得注意锁不要加错了地方(先要搞清楚程序需要同步的临界区是什么)否则不但没能解决问题还降低系统TPS造成性能影响。而说到锁很多人的第一个反应就是Jdk提供的同步锁synchronized,一般情况下同步锁确实能解决多线程访问临界区造成的数据安全问题即并发问题,同步锁的一般使用方式要么是锁住整个方法要么是方法内部锁住一个程序片段,不管哪一种先要明白锁的是当前这个类的实例对象,即多个线程同时访问代码片段时访问的是同一个对象(如果每个线程都会创建一个新的实例对象的话,加锁也就毫无意义了)比方说Spring受管的bean,默认情况下都是单实例的,也就是说多线程共享的,这个时候才需要考虑并发的问题。而我们平时在做项目的过程中,除了要完成业务开发之外,还得多想想业务之外的一些东西比如接口需不需要保证幂等,代码有没有很强的扩展性等等,我觉得这也是高级程序员和初级程序员之间的区别吧。回到文章开头提到的问题,假设我们的数据库里面有一张表是order 字段有id,userId,planId(计划ID),money.createTime这几个字段,前端用户在下单的同时就是针对某一个计划提交了一条数据(业务要保证只能提交一次,不了解真实情况所以这业务是假设的),那么这个时候如果说我们的业务伪代码中是这么写的:

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

  那么这种情况下加同步锁是可以保证多个线程并发访问不会有问题,但如果在insert之前没有先从db中select出来就直接insert了,那么加锁也是白加,因为锁的本质也是在排队,第一个请求执行完之后,紧接着等待队列中的第二个请求一样会执行。另外一个问题是单机锁无法解决系统集群或者分布式的场景,要知道现在大部分的互联网应用都是集群或分布式的,JDK的同步锁也只能锁住单个进程,系统由于负载均衡,并发的两个线程不一定就请求到同一台服务器,所以这种场景下加锁很大几率是无效的,当然分布式锁可以解决这两个问题比方说下面这个采用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的唯一索引约束结合客户端来保证接口的幂等.比如做法可以是:

  1. 我们可以给表字段userId和planId加上联合唯一索引约束dedup_key.
  2. 在业务层中要显示的去捕获抛出来的异常,再做多一层异常的包装,这样子返回给客户端的才是友好的提示信息,伪代码如下:
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;//其他类型的异常要往外抛出
         }
    }
}

  上面这种实现虽然可以解决问题而且代码看起来也挺简洁,但并不是一种好的做法,因为如果我们在插入订单表之前又做了其他的一些关联表的插入或修改,那么一旦发现订单表的插入失败这个时候是不是还要去回滚之前所做的一些操作呢又比如说之前使用过MQ发送过消息那又要如何处理消息回滚呢,所以上面这种做法会额外增加系统复杂性,更好的实现应该是不在业务表上面建唯一索引了,而是独立出一张表token_table,通常称为排重表或者令牌表,表中主要有一个字段unique_key,并且在这个字段上面建一个unique index,那么这时候可以使用上面采用过的通用方案即并发时由数据库自动抛出异常,业务service来捕获最终返回给客户端友好的提示,或者我们还可以利用mysql的insert ignore特性来处理这个问题。
  顺便简单普及下mysql的insert ignore特性,这是mysql提供的三种可以防止重复插入数据的方式之一(另外两种是Replace和ON DUPLICATE KEY UPDATE 这两种也能解决重复提交问题,这里不展开描述,具体可以参考MySql避免重复插入记录方法),如果表table中有主键pkId那么重复插入相同的pkId不会抛出异常[Err] 1062 - Duplicate entry ‘1‘ for key ‘PRIMARY‘,而是直接返回结果0,如果表中某个字段建有唯一索引,同样的除了第一次插入返回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起来以便下次使用的时候能辨别出是否是重复的请求具体可参考开头提到的那篇文章,第二种则是由服务端根据业务具体情况来统一生成全局标示,做成一个全局的微服务,但需要考虑的东西比较多架构实现也比较复杂,可参考美团的技术文章分布式系统互斥性与幂等性问题的分析与解决
  还有一种解决方案是利用数据库的锁机制来处理即共享读锁+普通索引。
  下面这个截图来自MySQL技术内幕InnoDB存储引擎这本书,这种方式我没在生产中使用过,但理论上来说应该也是可行的。

  最后再说说类似那种使用程序来使接口无限被重放的情况,其实我一直都认为这不算是接口的主要职能了,接口的主要职责是处理业务逻辑,其他的安全措施应该交给框架层面来统一解决,但对于小系统来讲可能没有那么完善的基础设施,所以该做的还是要做的比方说接口必须要登录认证,可以结合nginx,redis做限制访问,后台还可以按照制定好的规则算法来对接口参数做排序和加密,防止 客户端构造出非法的请求等等。

最后总结

  1. 同步锁(单线程,集群可能会失效)
  2. 分布式锁如redis(实现复杂)
  3. 业务字段加唯一约束(简单)
  4. 令牌表+唯一约束(简单推荐)
  5. mysql的insert ignore或者on duplicate key update(简单)
  6. 共享锁+普通索引(简单)
  7. 利用MQ或者Redis扩展(排队)
  8. 其他方案如多版本控制MVCC 乐观锁 悲观锁 状态机等。。。

  对客户端请求排队或者单线程都可以处理幂等问题,需要根据具体业务选择合适的方案但必须前后端一起做,前端做了可以提升用户体验,后端则可以保证数据安全。

  原文

原文地址:https://www.cnblogs.com/sueyyyy/p/12050485.html

时间: 2025-01-17 15:10:26

(转)关于接口幂等性的总结的相关文章

Springboot + redis + 注解 + 拦截器来实现接口幂等性校验

Springboot + redis + 注解 + 拦截器来实现接口幂等性校验 1. SpringBoot 整合篇 2. 手写一套迷你版HTTP服务器 3. 记住:永远不要在MySQL中使用UTF-8 4. Springboot启动原理解析 一.概念 幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次比如: 订单接口, 不能多次创建订单 支付接口, 重复支付同一笔订单只能扣一次钱 支付宝回调接口, 可能会多次回调, 必须处理重复回调 普通表单提交接口, 因为网络超时

分布式接口幂等性

https://www.cnblogs.com/huaixiaonian/p/9577567.html 最近跟朋友聊起这个话题,想深入了解下,于是学习总结,记录下来,此文章参考以下博客综合而来表示感谢: http://blog.brucefeng.info/post/api-idempotent http://825635381.iteye.com/blog/2276077 https://www.cnblogs.com/leechenxiang/p/6626629.html 1. 接口调用存在

实现接口幂等性的几种方案

抢微信红包的时候我们都知道:一个红包一旦你抢过之后,以后无论你点多少次都是一样的结果.红包会提示你已经抢过此红包,而不会让你再抢一次. 抢红包接口就是一个非常典型的幂等接口,抢一次和抢多次具有一样的效果.类似的接口在我们的开发过程中会有很多,比如在电商的下单过程中: 订单创建接口,第一次调用返回超时了,重试机制一般会再次调用这个接口,此时我们不能因为这个接口被调了两次就创建两个一样的订单: 库存扣减接口,支付接口也是类似的逻辑: 今天的文章就来讲讲什么是接口的幂等性,并介绍几种实现接口幂等性的方

micro-service(3):分布式事务和接口幂等性

分布式服务需要满足CAP原则,Consistency(一致性). Availability(可用性).Partition tolerance(分区容错性),但三者不可得兼:一般都会优先保证可用性和分区容错性,并且保证最终一致性.BASE理论是对CAP原则的补充,Basically Available,Soft state,Eventually Consistent,也就是当CAP三者不能兼得的时候,可以做到最终一致性. 大型应用一般需要做业务分拆以便于提升业务的可维护性和扩展性,但由于业务分布于

高并发下接口幂等性解决方案

一.幂等性概念 在编程中.一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同.幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数.这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变.例如,“getUsername()和setTrue()”函数就是一个幂等函数. 更复杂的操作幂等保证是利用唯一交易号(流水号)实现. 我的理解:幂等就是一个操作,不论执行多少次,产生的效果和返回的结果都是一样的. 二.幂等性场景1.查询操作:查询一次和查询多次,在数

保证接口幂等性的解决方案(后台)

假如有个服务提供一个接口(服务部署在多个服务机器),接着有个接口是付款接口.用户在前端上操作的时候,一个订单不小心发起了两次支付请求,然后这两个请求分散在了这个服务部署的不同的机器上,结果一个订单扣款扣两次.这样的场景,就是接口没有保证幂等性的结果. 保证幂等性的核心 1.对于每个请求必须有一个唯一的标识. 2.每次处理完请求之后,必须有一个记录标识这个请求处理过了. 3.每次接收请求需要进行判断之前是否处理过的逻辑处理. 常见解决方案 1.业务表内唯一索引 如果要对创建销售出库单的接口保证幂等

一文讲解高并发下的接口幂等性怎么实现?

实际系统中有很多操作,是不管做多少次,都应该产生一样的效果或返回一样的结果.例如: 前端重复提交选中的数据,应该后台只产生对应这个数据的一个反应结果. 我们发起一笔付款请求,应该只扣用户账户一次钱,当遇到网络重发或系统bug重发,也应该只扣一次钱: 发送消息,也应该只发一次,同样的短信发给用户,用户会哭的: 创建业务订单,一次业务请求只能创建一个,创建多个就会出大问题. 等等很多重要的情况,这些逻辑都需要幂等的特性来支持. 幂等性概念 幂等(idempotent.idempotence)是一个数

接口幂等性

一.背景 我们实际系统中有很多操作,是不管做多少次,都应该产生一样的效果或返回一样的结果.例如: 1. 前端重复提交选中的数据,应该后台只产生对应这个数据的一个反应结果. 2. 我们发起一笔付款请求,应该只扣用户账户一次钱,当遇到网络重发或系统bug重发,也应该只扣一次钱: 3. 发送消息,也应该只发一次,同样的短信发给用户,用户会哭的: 4. 创建业务订单,一次业务请求只能创建一个,创建多个就会出大问题. 等等很多重要的情况,这些逻辑都需要幂等的特性来支持. 二.幂等性概念 幂等(idempo

API接口幂等性框架设计

表单重复提价问题 rpc远程调用时候 发生网络延迟  可能有重试机制 MQ消费者幂等(保证唯一)一样 解决方案: token 令牌 保证唯一的并且是临时的  过一段时间失效 分布式: redis+token 注意在getToken() 这种方法代码一定要上锁  保证只有一个线程执行  否则会造成token不唯一 步骤 调用接口之前生成对应的 token,存放在redis中 调用接口的时候,将该令牌放到请求头中 (获取请求头中的令牌) 接口获取对应的令牌,如果能够获取该令牌 (将当前令牌删除掉),