高并发下如何保证幂等性?

1、引言

1.1 什么是接口幂等性?

幂等性我们最早的时候可能是在数学中接触到,就是幂函数,以下就是几个幂函数的图像。

 幂等不仅仅是一数学概念,也是一个计算机学概念,在计算机中应用也很广泛。幂等操作的特点是一次或者任意多次执行所产生的影响均与一次执行的影响相同,不会因为多次的请求而产生不一样的结果。换句话说,就是我使用相同的请求参数,去请求同一个接口,不管请求多少次获取到的响应数据应该是一样的(当然这里排查程序之外的其他如网络和设备等的异常所造成的不一样的结果)。

举个例子:我们常用的select 查询语句,以及update 根据某一条件更新字段信息的,就是我们最常见的幂等。

select * from table_name;
update table_name set name = "名称" where id = 1;

这两个SQL语句无论执行多少次,对数据的影响都是一致的。第一个select只是查询,不会对数据做任何变更,肯定是幂等的,这个比较好理解。第二个也是根据特定的id来更新固定的字段,这种绝对的更新是幂等的。有人可能会提出一个疑问,更新的时候如果设置了更新时间为系统当前时间,执行update 语句,更新时间是会变化的,这种也是属于幂等。

1.2 为什么接口需要满足幂等性?

假如在实际系统开发过程中,遇到以下这些场景:

1)在填写某些form表单时,保存按钮不小心快速点了两次,表中竟然产生了两条重复的数据,只是id不一样。

2)我们在项目中为了解决接口超时问题,通常会引入了重试机制。第一次请求接口超时了,请求方没能及时获取返回结果(此时有可能已经成功了),为了避免返回错误的结果(这种情况不可能直接返回失败吧?),于是会对该请求重试几次,这样也会产生重复的数据。

3)mq消费者在读取消息时,有时候会读取到重复消息,如果处理不好,也会产生重复的数据。

这些都是幂等性问题,如果接口不满足幂等性,可能会产生脏数据,甚至会影响业务功能,比如我已经支付过了,因为网络等原因,没有及时反馈,你重复多扣了我一笔钱,那肯定是不合理的。这类问题多发生于接口的写操作:

  • insert操作:这种情况下多次请求,可能会产生重复数据;
  • update操作:如果只是单纯的更新数据,比如:update user set status=1 where id=1,是没有问题的。如果还有计算,比如:update user set status=status+1 where id=1,这种情况下多次请求,可能会导致数据错误。

针对这些场景,为了保证系统的稳定性和业务的完整性,我们肯定需要在原有的基础上进行优化。实现方式有很多种,可以在前端页面使用节流和防抖等技术来控制重复提交,也可以在后端程序校验控制,还可以从数据库角度添加索引等等,结合实际的业务需求和改造的工作量,选择合适的方式,再通过调优和压测来进一步验证。

1.3 http请求的幂等性

说到接口操作,那就离不开数据的增删改查,说到增删改查,那我们就需要先了解几种请求方式 GET、DELETE、POSTPUT。接下来我们首先了解一下http请求的幂等性要求:

请求类型幂等性
GET方法幂等
DELETE方法幂等
POST方法非幂等
PUT方法幂等

GET 方法用于获取资源,不应该有副作用,所以是幂等的;

DELETE 方法用于删除资源,有副作用,但是它应该满足幂等性。调用一次和N次对系统产生的副作用是相同的,即删掉同一条或者一批数据。请注意,幂等性并不意味着服务器必须对每个请求以相同的方式进行响应。使用 DELETE 删除某个主键所在行的数据,第一次请求时,我们可能会收到 HTTP 200 状态代码,指示该数据已成功删除。如果我们再次发送此 DELETE 请求,则可能会收到 HTTP 404 作为响应,因为该项目已被删除。第二个请求没有更改服务器状态,因此即使我们得到不同的响应,DELETE操作也是幂等的。

GET 和 DELETE 是比较好理解的,POST 和 PUT 相对来说比较难理解为什么一个是幂等一个非幂等。

POST用于提交请求,可以更新或者创建资源,是非幂等的,因为一次请求添加一份新资源,二次请求则添加了两份新资源,多次请求会产生不同的结果,因此POST不是幂等操作。比如用户进行注册,每次提交都是创建一个新的用户账号,这个时候就用POST。

而PUT用于向指定URL传送更新资源,是幂等的,PUT将A修改为B,它第一次请求时值变为了B,再进行多次此操作,最终的结果还是B,与一次执行的结果是一样的,所以PUT是幂等操作。比如修改用户密码,虽然提交的还是账户名和密码这两个必填参数,但是每次提交都只是更新该用户密码。此时就比较适合使用PUT。

1.4 幂等性的解决方案

解决方案主要分为两个方向,一个方向是客户端防止重复调用,一个是服务端进行校验。客户端防止重复提交并不是绝对可靠的,优点是实现起来比较简单。我们这里只讨论服务端的校验,下面主要介绍保证幂等的8种实现方案。

2、insert前先select

通常情况下,在保存数据的接口中,为了防止产生重复数据,一般会在写入之前,先查询一下数据是否已经存在。如果该数据已存在,则执行update操作,如果不存在,才执行insert操作。

该方案可能是我们平时在防止产生重复数据时,使用最多的方案。但是该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。

3、加唯一索引

 绝大数情况下,为了防止重复数据的产生,根据业务需要,我们都会在表中加唯一索引,可以是单一索引,也可以是联合索引。这是一个非常简单,并且有效的方案。

alter table `order` add UNIQUE KEY `un_code` (`code`);

加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry '002' for key 'order.un_code异常,表示唯一索引有冲突。

虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。

如果是java程序需要捕获:DuplicateKeyException异常,如果使用了spring框架还需要捕获:MySQLIntegrityConstraintViolationException异常。

具体流程图如下:

具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据;
  2. 将该数据插入mysql;
  3. 判断是否执行成功,如果成功,则操作其他数据(可能还有其他的业务逻辑);
  4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。

优缺点分析:

优点:使用起来比较简单,使用SQL脚本建立唯一索引即可,可以使用以下SQL脚本创建索引。

CREATE INDEX index_name ON table_name (column_list); 

ALTER TABLE table_name ADD INDEX index_name (column_list); 

缺点:

  • 编码上比较麻烦,因为每个需要保证幂等的插入类型的接口都需要去做捕获DuplicateKeyException异常的操作,代码上比较冗余;
  • 适用面不广,只能适用于插入操作;
  • 效率不高,基于数据库的唯一key去做防重和保证插入幂等,那么相当于把压力放到了数据库上。在高并发的情况下很可能出现性能问题。

综上,这种方式也不适合多场景的使用。

 4、加悲观锁

在支付场景中,用户A的账号余额有150元,想转出100元,正常情况下用户A的余额只剩50元。一般情况下,sql是这样的:

update user amount = amount-100 where id=123;

如果出现多次相同的请求,可能会导致用户A的余额变成负数。这种情况,用户A来可能要哭了。于此同时,系统开发人员可能也要哭了,因为这是很严重的系统bug。

为了解决这个问题,可以加悲观锁,将用户A的那行数据锁住,在同一时刻只允许一个请求获得锁,更新数据,其他的请求则等待。

通常情况下通过如下sql锁住单行数据:

select * from user where id=123 for update;

具体流程如下:

 具体步骤:

  1. 多个请求同时根据id查询用户信息。
  2. 判断余额是否不足100,如果余额不足,则直接返回余额不足。
  3. 如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。
  4. 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。
  5. 第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。
  6. 如果余额不足,说明是重复请求,则直接返回成功。

需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里id字段一定要是主键或者唯一索引,不然会锁住整张表。

悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。

此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以使用的。

在这里顺便说一下,防重设计 和 幂等设计,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果

 5、加乐观锁

既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个timestamp或者version字段,这里以version字段为例。

在更新数据之前先查询一下数据:

select id,amount,version from user where id=123;

如果数据存在,假设查到的version等于1,再使用idversion字段作为查询条件更新数据:

update user set amount=amount+100, version=version+1 where id=123 and version=1;

更新数据的同时version+1,然后判断本次update操作的影响行数,如果大于0,则说明本次更新成功,如果等于0,则说明本次更新没有让数据变更。

由于第一次请求version等于1是可以成功的,操作成功后version变成2了。这时如果并发的请求过来,再执行相同的sql:

update user set amount=amount+100, version=version+1 where id=123 and version=1;

update操作不会真正更新数据,最终sql的执行结果影响行数是0,因为version已经变成2了,where中的version=1肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为version值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。

具体流程如下:

具体步骤:

  1. 先根据id查询用户信息,包含version字段;
  2. 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1;
  3. 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作;
  4. 如果影响0行,说明是重复请求,则直接返回成功。

优缺点:

  • 实现简单,只需要在表中增加一个字段即可;
  • 适用面不广,只能适用于更新相关的场景;
  • 效率不高,适用数据库来保证幂等性,这样就是把压力放到数据库去了,本来数据库就是很多项目的性能瓶颈。

综上,这种方式跟数据库唯一key解决方案类似,也不适合多场景的使用。

6、建防重表

有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。

针对这种情况,我们可以通过建防重表来解决问题。

该表可以只包含两个字段:id 和 唯一索引,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:susan_0001。

具体流程图如下:

 具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据;
  2. 将该数据插入mysql防重表;
  3. 判断是否执行成功,如果成功,则做mysql其他的数据操作(可能还有其他的业务逻辑);
  4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。

需要特别注意的是:防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中。 

7、根据状态机

很多时候业务表是有状态的,比如订单表中有:1-下单、2-已支付、3-完成、4-撤销等状态。如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等性。

假如id=123的订单状态是已支付,现在要变成完成状态。

update `order` set status=3 where id=123 and status=2;

第一次请求时,该订单的状态是已支付,值是2,所以该update语句可以正常更新数据,sql执行结果的影响行数是1,订单状态变成了3

后面有相同的请求过来,再执行相同的sql时,由于订单状态变成了3,再用status=2作为条件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是0,即不会真正的更新数据。但为了保证接口幂等性,影响行数是0时,接口也可以直接返回成功。

具体流程图如下:

 具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据;
  2. 根据id和当前状态作为条件,更新成下一个状态;
  3. 判断操作影响行数,如果影响了1行,说明当前操作成功,可以进行其他数据操作;
  4. 如果影响了0行,说明是重复请求,直接返回成功。

主要特别注意的是,该方案仅限于要更新的表有状态字段,并且刚好要更新状态字段的这种特殊情况,并非所有场景都适用。 

8、加分布式锁

其实前面介绍过的加唯一索引或者加防重表,本质是使用了数据库分布式锁,也属于分布式锁的一种。但由于数据库分布式锁的性能不太好,我们可以改用:rediszookeeper

鉴于现在很多公司分布式配置中心改用apollonacos,已经很少用zookeeper了,我们以redis为例介绍分布式锁。

目前主要有三种方式实现redis的分布式锁:

  • setNx命令
  • set命令
  • Redission框架

每种方案各有利弊,具体实现细节我就不说了,有兴趣的朋友可以阅读《(万字长文)深入理解redis核心技术》。

具体流程图如下:

 具体步骤:

  1. 用户通过浏览器发起请求,服务端会收集数据,并且生成订单号code作为唯一业务字段;
  2. 使用redis的set命令,将该订单code设置到redis中,同时设置超时时间;
  3. 判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作;
  4. 如果设置失败,说明是重复请求,则直接返回成功。

需要特别注意的是:分布式锁一定要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费redis的存储空间,需要根据实际业务情况而定。

我们对特定场景分析一下:

场景一:用户快速点击提交,连续发起两次请求。第一次请求先到达服务端,然后第二次请求由于某些原因过了一会儿才到达服务端。等第二次请求达到服务端的时候,第一次请求已经执行完毕并且释放了锁。此时第二次请求仍然能加锁成功,并且执行业务逻辑,这种情况下幂等性失效

场景二:客户端发起第一次请求,服务端正常执行完毕并释放了分布式锁,但由于网络原因客户端没有正常收到服务端的响应,此时客户端再次发起请求。由于第一次请求所加的分布式锁已经过期所以第二次请求仍然能够加锁成功,然后执行业务逻辑,此时幂等性失效

场景三:客户端连续发起多次请求,这多次请求同时到达服务端,此时开始争抢锁,谁抢到锁谁就执行,其他没有抢到锁的请求都统统不执行。这种情况能保证幂等性

综合上述几个场景,这种方案具有一定的缺陷性

9、获取token

除了上述方案之外,还有最后一种使用token的方案。该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。

  • 第一次请求获取token;
  • 第二次请求带着这个token,完成业务操作。

具体流程如下:

  1. 服务端需要提供一个token获取接口(该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串,要求保证唯一性),客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。
  2. 然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。
  3. 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。
  4. 客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。
  5. 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。
  6. 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。

问题分析:

这种方案需要前端多发一次请求去请求token,如果客户端连续发起调用,只要每次使用的token是一样的,那么这些连续的请求只会被处理一次。由此可以正常保证幂等性。

如果某个客户端第一次发起请求,然后服务端收到后将token从Redis中删除,接着去执行业务逻辑,但是业务逻辑执行失败了,此时有两种可能:

  • 如果此时服务端向客户端返回执行失败,客户端收到该返回后自动重新请求一个token,然后再次发起请求重试,这样没有任何问题。
  • 如果此时服务端向客户端返回执行失败的过程中,由于网络或其他什么原因导致客户端无法接收到服务端返回的执行失败响应。那么此时客户端会再次使用第一次申请的token再次向服务端发送请求,但是此时服务端返回的确却是重复请求或执行成功。

总的来说这种方案实用性较强,没有明显的缺陷。

10、结语

一般来说没有任何一种幂等方案可以适用于所有场景,我们需要根据实际的业务情况来选择合适的方案即可。也可以采用多种方案组合使用来保证幂等性(比如可以使用token方案 + 数据库唯一key方案组合使用)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值