当前位置: 首页 > 科技观察

说说高并发下如何保证接口的幂等性?

时间:2023-03-20 11:02:15 科技观察

前言接口幂等性问题对于开发者来说是一个与语言无关的公共问题。本文分享了一些非常实用的方法来解决这类问题。我在项目中实践的大部分内容,供有需要的朋友参考。不知道大家有没有遇到过这样的场景:有时候我们在填写一些表格的时候,不小心快速的点击了两次保存按钮,表格中产生了两条重复的数据,只是id不同。我们项目中为了解决接口超时问题,通常会引入重试机制。第一次请求接口超时,请求者未能及时获取返回结果(此时可能已经成功),为了避免返回错误的结果(这种情况下直接返回失败是不可能的?),所以请求会Retry几次,这样也会产生重复数据。mq消费者在读取消息时,有时会读取到重复的消息(这里不说原因,有兴趣的朋友可以私聊),如果处理不好,也会产生重复的数据。是的,这些都是幂等性问题。接口的幂等性是指用户对同一操作发起一次请求或多次请求的结果是一致的,不会因为多次点击而产生副作用。这类问题多发生在接口:insert操作,这种情况下多次请求可能会产生重复数据。如果更新操作只是更新数据,例如:updateusersetstatus=1whereid=1,是没有问题的。如果还有计算,比如:updateusersetstatus=status+1whereid=1,这种情况下多次请求可能会导致数据错误。那么如何保证接口的幂等性呢?这篇文章会告诉你答案。1、插入前选择通常,在保存数据的界面中,为了防止重复数据,我们通常会在插入前根据名称或代码字段选择数据。如果数据已经存在,则执行更新操作,如果不存在,则执行插入操作。这个方案可能是我们在防止产生重复数据时使用最多的方案。但是这种方案不适合并发场景。在并发场景下,必须和其他解决方案一起使用,否则也会产生重复数据。在这里提一下,避免大家踩坑。2、添加悲观锁支付场景下,用户A账户余额为150元,想转账100元。正常情况下,用户A的余额只有50元。一般来说,sql是这样的:updateuseramount=amount-100whereid=123;如果多次出现同一个请求,可能会导致用户A的余额变为负值。在这种情况下,用户A可能会哭。同时,系统开发人员也可能会哭,因为这是一个非常严重的系统错误。为了解决这个问题,可以加入悲观锁,对用户A的这一行数据进行加锁,同时只允许一个请求获取锁更新数据,其他请求等待。通常通过如下SQL锁定单行数据:select*fromuserid=123forupdate;具体过程如下:具体步骤:多个请求同时根据id查询用户信息。判断余额是否小于100,如果余额不足,直接退还不足的余额。如果余额足够,则通过forupdate再次查询用户信息,尝试获取锁。只有第一个请求可以获取到行锁,其余没有获取到锁的请求等待下一次获取锁的机会。第一次请求获取到锁后,判断余额是否小于100,如果余额充足,则进行更新操作。如果余额不足,则表示重复请求,直接返回成功。需要特别注意的是:如果你使用的是mysql数据库,存储引擎必须使用innodb,因为它只支持事务。另外,这里的id字段必须是主键或者唯一索引,否则整张表都会被锁住。悲观锁需要在同一个事务操作中锁定一行数据。如果事务耗时较长,会造成大量请求等待,影响接口性能。另外,很难保证每个请求接口的返回值相同,所以不适合幂等的设计场景,但可以用于反重载的场景。顺便说一句,反重设计和幂等设计其实是有区别的。防重设计主要是避免数据重复,对接口的返回要求不多。除了避免重复数据,幂等设计还要求每次请求返回相同的结果。3、添加乐观锁由于悲观锁存在性能问题,为了提高接口性能,我们可以使用乐观锁。需要将时间戳或版本字段添加到表中。这里我们以版本字段为例。更新数据前查询数据:selectid,amount,versionfromuserid=123;如果数据存在,假设找到的version等于1,然后将id和version字段作为查询条件更新数据:updateusersetamount=amount+100,version=version+1whereid=123andversion=1;更新数据时,version+1,然后判断这次更新操作影响的行数。如果大于0,说明本次更新成功。如果等于0,表示本次更新没有改变数据。.由于版本等于1的第一次请求可以成功,运行成功后版本变为2。这时候如果并发请求过来,再执行同样的sql:updateusersetamount=amount+100,version=version+1hereid=123andversion=1;更新操作不会真正更新数据,最终的sql执行结果会影响行数。因为version变成了2,where中的version=1肯定不满足条件。但是为了保证接口的幂等性,接口可以直接返回success,因为修改了version值,所以前面的success一定是做了一次,后面的请求都是重复的。具体过程如下:具体步骤:首先根据id查询用户信息,包括version字段,根据id和version字段值作为where条件的参数更新用户信息,同时version+1确定受操作影响的行数。如果1行受影响,则说明是一个请求,可以做其他数据操作。如果影响到0行,说明重复请求,直接返回成功。4、添加唯一索引大多数情况下,为了防止重复数据,我们都会给表添加唯一索引。这是一个非常简单有效的解决方案。altertable`order`addUNIQUEKEY`un_code`(`code`);添加唯一索引后,第一次请求数据可以插入成功。但是后面同样的请求,在插入数据的时候,会报Duplicateentry'002'forkey'order.un_code异常,说明uniqueindex有冲突。抛出异常虽然对数据没有影响,但也不会造成数据错误。但是为了保证接口的幂等性,我们需要捕获异常并返回成功。如果是java程序,需要捕获:DuplicateKeyException异常,如果使用spring框架,还需要捕获:MySQLIntegrityConstraintViolationException异常。具体流程图如下:具体步骤:用户通过浏览器发起请求,服务器采集数据。将数据插入mysql判断是否执行成功,如果成功则操作其他数据(可能还有其他业务逻辑)。如果执行失败,则捕获唯一索引冲突异常,直接返回成功。5.构建防重复表有时表中并不是所有的场景都不允许产生重复数据,只有一些特定的场景是不允许的。这个时候直接给表加唯一索引显然是不合适的。针对这种情况,我们可以通过构建反重表来解决问题。该表只能包含两个字段:id和唯一索引。唯一索引可以是姓名、代码等多个字段组合的唯一标识,例如:susan_0001。具体流程图如下:具体步骤:用户通过浏览器发起请求,服务器采集数据。将数据插入mysql反重表,判断是否执行成功。如果成功,再对mysql进行其他数据操作(可能是其他业务逻辑)。如果执行失败,则捕获唯一索引冲突异常,直接返回成功。特别要注意:防重表和业务表必须在同一个数据库中,操作必须在同一个事务中。6.根据状态机,业务表往往是有状态的。比如订单表有:1-下单,2-支付,3-完成,4-取消等状态。如果这些状态的取值是有规律的,我们就可以根据业务节点从小到大的规律,利用它来保证接口的幂等性。如果id=123的订单状态为已付款,则现在将变为已完成。update`order`setstatus=3whereid=123andstatus=2;第一次请求时,订单状态为已支付,值为2,所以update语句可以正常更新数据,SQL执行结果影响的行数为1,订单状态变为3.后面来了同样的请求,执行同样的sql时,订单状态变为3,以status=2为条件,查询不到要更新的数据,所以最终sql影响的行数执行结果为0,即不会真正更新数据。但是为了保证接口的幂等性,当受影响的行数为0时,接口也可以直接返回成功。具体流程图如下:具体步骤:用户通过浏览器发起请求,服务器采集数据。以id和当前状态为条件,更新到下一个状态。确定受操作影响的行数。如果有1行受影响,说明当前操作成功,可以进行其他数据操作。如果影响0行,说明重复请求,直接返回成功。主要要特别注意的是,这个方案仅限于待更新的表有state字段的特殊情况,state字段只需要更新即可,并不是所有场景都适用。7、加分布式锁其实前面介绍的加唯一索引或者反重表,本质上就是在数据库中使用分布式锁,这也是分布式锁的一种。但是因为数据库分布式锁的性能不是很好,我们可以改用redis或者zookeeper。鉴于很多公司的分布式配置中心都转用apollo或者nacos,而很少使用zookeeper,我们就以redis为例介绍一下分布式锁。目前redis分布式锁的实现方式主要有3种:setNx命令set命令Redis框架每种方案各有优缺点。具体流程图如下:具体步骤:用户通过浏览器发起请求,服务器会采集数据,生成订单号代码作为唯一的业务字段。使用redis的set命令,将命令码设置到redis中,同时设置超时时间。判断是否设置成功,如果设置成功,说明是第一次请求,进行数据操作。如果设置失败,则表示重复请求,直接返回成功。需要特别注意的是,必须为分布式锁设置一个合理的过期时间。如果设置太短,则不能有效防止重复请求。如果设置过长,可能会浪费redis的存储空间,需要根据实际业务情况确定。8.除了上述获取代币的方案外,还有最后一种使用代币的方案。这个解决方案与之前的所有解决方案都有点不同,需要两次请求才能完成一个业务操作。第一次请求拿到token,第二次请求带着这个token完成业务操作。具体流程图如下:第一步先获取token。第二步是做具体的业务操作。具体步骤:当用户访问页面时,浏览器自动发起token请求。服务器生成token,保存在redis中,返回给浏览器。用户通过浏览器发起请求时携带token。查询token在redis中是否存在。如果不存在,说明是第一次请求,再进行后续的数据操作。如果存在,则表示请求重复,直接返回成功。在redis中,token会在过期时间后自动删除。上面的方案是为了幂等性而设计的。如果是反重设计,需要改一下流程图:特别注意token必须是全局唯一的。