5000字 吃透 | 高并发下如何保证接口的幂等性?

5000字 吃透 | 高并发下如何保证接口的幂等性? 接口幂等性问题对于开发人员来说是一个跟语言无关的公共问题。本文分享了一些解决这类问题非常实用的办法绝大部分内容我在项目中实践过的给有需要的小伙伴一个参考。不知道你有没有遇到过这些场景有时我们在填写某些form表单时保存按钮不小心快速点了两次表中竟然产生了两条重复的数据只是id不一样。我们在项目中为了解决接口超时问题通常会引入了重试机制。第一次请求接口超时了请求方没能及时获取返回结果此时有可能已经成功了为了避免返回错误的结果这种情况不可能直接返回失败吧于是会对该请求重试几次这样也会产生重复的数据。mq消费者在读取消息时有时候会读取到重复消息至于什么原因这里先不说有兴趣的小伙伴可以找我私聊如果处理不好也会产生重复的数据。没错这些都是幂等性问题。接口幂等性是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的不会因为多次点击而产生了副作用。这类问题多发于接口的insert操作这种情况下多次请求可能会产生重复数据。update操作如果只是单纯的更新数据比如update user set status1 where id1是没有问题的。如果还有计算比如update user set statusstatus1 where id1这种情况下多次请求可能会导致数据错误。那么我们要如何保证接口幂等性本文将会告诉你答案。1. insert前先select通常情况下在保存数据的接口中我们为了防止产生重复数据一般会在insert前先根据name或code字段select一下数据。如果该数据已存在则执行update操作如果不存在才执行insert操作。该方案可能是我们平时在防止产生重复数据时使用最多的方案。但是该方案不适用于并发场景在并发场景中要配合其他方案一起使用否则同样会产生重复数据。我在这里提一下是为了避免大家踩坑。2. 加悲观锁在支付场景中用户A的账号余额有150元想转出100元正常情况下用户A的余额只剩50元。一般情况下sql是这样的update user amount amount-100 where id123;如果出现多次相同的请求可能会导致用户A的余额变成负数。这种情况用户A来可能要哭了。于此同时系统开发人员可能也要哭了因为这是很严重的系统bug。为了解决这个问题可以加悲观锁将用户A的那行数据锁住在同一时刻只允许一个请求获得锁更新数据其他的请求则等待。通常情况下通过如下sql锁住单行数据select * from user id123 for update;具体流程如下具体步骤多个请求同时根据id查询用户信息。判断余额是否不足100如果余额不足则直接返回余额不足。如果余额充足则通过for update再次查询用户信息并且尝试获取锁。只有第一个请求能获取到行锁其余没有获取锁的请求则等待下一次获取锁的机会。第一个请求获取到锁之后判断余额是否不足100如果余额足够则进行update操作。如果余额不足说明是重复请求则直接返回成功。需要特别注意的是如果使用的是mysql数据库存储引擎必须用innodb因为它才支持事务。此外这里id字段一定要是主键或者唯一索引不然会锁住整张表。悲观锁需要在同一个事务操作过程中锁住一行数据如果事务耗时比较长会造成大量的请求等待影响接口性能。此外每次请求接口很难保证都有相同的返回值所以不适合幂等性设计场景但是在防重场景中是可以的使用的。在这里顺便说一下防重设计和幂等设计其实是有区别的。防重设计主要为了避免产生重复数据对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外还要求每次请求都返回一样的结果。3. 加乐观锁既然悲观锁有性能问题为了提升接口性能我们可以使用乐观锁。需要在表中增加一个timestamp或者version字段这里以version字段为例。在更新数据之前先查询一下数据select id,amount,version from user id123;如果数据存在假设查到的version等于1再使用id和version字段作为查询条件更新数据update user set amountamount100,versionversion1where id123 and version1;更新数据的同时version1然后判断本次update操作的影响行数如果大于0则说明本次更新成功如果等于0则说明本次更新没有让数据变更。由于第一次请求version等于1是可以成功的操作成功后version变成2了。这时如果并发的请求过来再执行相同的sqlupdate user set amountamount100,versionversion1where id123 and version1;该update操作不会真正更新数据最终sql的执行结果影响行数是0因为version已经变成2了where中的version1肯定无法满足条件。但为了保证接口幂等性接口可以直接返回成功因为version值已经修改了那么前面必定已经成功过一次后面都是重复的请求。具体流程如下具体步骤先根据id查询用户信息包含version字段根据id和version字段值作为where条件的参数更新用户信息同时version1判断操作影响行数如果影响1行则说明是一次请求可以做其他数据操作。如果影响0行说明是重复请求则直接返回成功。4. 加唯一索引绝大数情况下为了防止重复数据的产生我们都会在表中加唯一索引这是一个非常简单并且有效的方案。alter table order add UNIQUE KEY un_code (code);加了唯一索引之后第一次请求数据可以插入成功。但后面的相同请求插入数据时会报Duplicate entry 002 for key order.un_code异常表示唯一索引有冲突。虽说抛异常对数据来说没有影响不会造成错误数据。但是为了保证接口幂等性我们需要对该异常进行捕获然后返回成功。如果是java程序需要捕获DuplicateKeyException异常如果使用了spring框架还需要捕获MySQLIntegrityConstraintViolationException异常。具体流程图如下具体步骤用户通过浏览器发起请求服务端收集数据。将该数据插入mysql判断是否执行成功如果成功则操作其他数据可能还有其他的业务逻辑。如果执行失败捕获唯一索引冲突异常直接返回成功。5. 建防重表有时候表中并非所有的场景都不允许产生重复的数据只有某些特定场景才不允许。这时候直接在表中加唯一索引显然是不太合适的。针对这种情况我们可以通过建防重表来解决问题。该表可以只包含两个字段id和唯一索引唯一索引可以是多个字段比如name、code等组合起来的唯一标识例如susan_0001。具体流程图如下具体步骤用户通过浏览器发起请求服务端收集数据。将该数据插入mysql防重表判断是否执行成功如果成功则做mysql其他的数据操作可能还有其他的业务逻辑。如果执行失败捕获唯一索引冲突异常直接返回成功。需要特别注意的是防重表和业务表必须在同一个数据库中并且操作要在同一个事务中。6. 根据状态机很多时候业务表是有状态的比如订单表中有1-下单、2-已支付、3-完成、4-撤销等状态。如果这些状态的值是有规律的按照业务节点正好是从小到大我们就能通过它来保证接口的幂等性。假如id123的订单状态是已支付现在要变成完成状态。update order set status3 where id123 and status2;第一次请求时该订单的状态是已支付值是2所以该update语句可以正常更新数据sql执行结果的影响行数是1订单状态变成了3。后面有相同的请求过来再执行相同的sql时由于订单状态变成了3再用status2作为条件无法查询出需要更新的数据所以最终sql执行结果的影响行数是0即不会真正的更新数据。但为了保证接口幂等性影响行数是0时接口也可以直接返回成功。具体流程图如下具体步骤用户通过浏览器发起请求服务端收集数据。根据id和当前状态作为条件更新成下一个状态判断操作影响行数如果影响了1行说明当前操作成功可以进行其他数据操作。如果影响了0行说明是重复请求直接返回成功。主要特别注意的是该方案仅限于要更新的表有状态字段并且刚好要更新状态字段的这种特殊情况并非所有场景都适用。7. 加分布式锁其实前面介绍过的加唯一索引或者加防重表本质是使用了数据库的分布式锁也属于分布式锁的一种。但由于数据库分布式锁的性能不太好我们可以改用redis或zookeeper。鉴于现在很多公司分布式配置中心改用apollo或nacos已经很少用zookeeper了我们以redis为例介绍分布式锁。目前主要有三种方式实现redis的分布式锁setNx命令set命令Redission框架每种方案各有利弊具体实现细节我就不说了有兴趣的朋友可以加我微信找我私聊。具体流程图如下具体步骤用户通过浏览器发起请求服务端会收集数据并且生成订单号code作为唯一业务字段。使用redis的set命令将该订单code设置到redis中同时设置超时时间。判断是否设置成功如果设置成功说明是第一次请求则进行数据操作。如果设置失败说明是重复请求则直接返回成功。需要特别注意的是分布式锁一定要设置一个合理的过期时间如果设置过短无法有效的防止重复请求。如果设置过长可能会浪费redis的存储空间需要根据实际业务情况而定。8. 获取token除了上述方案之外还有最后一种使用token的方案。该方案跟之前的所有方案都有点不一样需要两次请求才能完成一次业务操作。第一次请求获取token第二次请求带着这个token完成业务操作。具体流程图如下第一步先获取token。第二步做具体业务操作。具体步骤用户访问页面时浏览器自动发起获取token请求。服务端生成token保存到redis中然后返回给浏览器。用户通过浏览器发起请求时携带该token。在redis中查询该token是否存在如果不存在说明是第一次请求做则后续的数据操作。如果存在说明是重复请求则直接返回成功。在redis中token会在过期时间之后被自动删除。以上方案是针对幂等设计的。如果是防重设计流程图要改改需要特别注意的是token必须是全局唯一的。最后说一句(求关注别白嫖我)如果这篇文章对您有所帮助或者有所启发的话帮忙扫描下发二维码关注一下您的支持是我坚持写作最大的动力。