高并发下保证接口的幂等性的几种方式

Posted 圆圆的球

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了高并发下保证接口的幂等性的几种方式相关的知识,希望对你有一定的参考价值。

高并发下保证接口的幂等性的几种方式

​ --洱涷Zz


场景

不知道你有没有遇到过这些场景:

  1. 有时我们在填写某些form表单时,保存按钮不小心快速点了两次,表中竟然产生了两条重复的数据,只是id不一样。
  2. 我们在项目中为了解决接口超时问题,通常会引入了重试机制。第一次请求接口超时了,请求方没能及时获取返回结果(此时有可能已经成功了),为了避免返回错误的结果(这种情况不可能直接返回失败吧?),于是会对该请求重试几次,这样也会产生重复的数据。
  3. MQ消费者在读取消息时,有时候会读取到重复消息,如果处理不好,也会产生重复的数据。

没错,这些都是幂等性问题。

什么是接口幂等性呢?

​ 接口幂等性是指用户对同一操作发起的一次或多次请求的结果是一致的,并不会因为用户一时的多次点击而产生错误消息。

​ 这种情况产生错误消息多发于接口的

  • insert操作

    ​ 这种情况下,多次请求,可能会产生重复数据

  • update操作

    ​ 如果只是单纯的更新数据,比如

    update user set status = 1 where id = 1  
    

    ​ 这种情况是没有问题的,但是如果涉及计算,举个栗子:

    update user set status = stauts + 1 where id = 1 
    

    ​ 这种情况下并发请求,可能会导致数据错误。这种情况下为什么会导致数据错误呢?我再举一个栗子:比如小陈的银行卡里现在有50,老李问小陈借了50,这个时候,小陈的余额应该是50-50=0,如果同时老唐也问小陈借了50,这两个操作同时进行,此时小陈的余额还需要减50,可是事实上,小陈余额是0,可是这两个操作同时进行,他们都以为小陈的余额是50,那么结果就有可能是小陈的余额变成了-50,这种场景是不能被允许发生的,所以这个操作在进行的时候我们只能保证完成一个,这个场景就是接口的幂等性

解决方案

1. 先selectupdate

​ 通常情况下,再保存数据的接口中,为了防止插入重复数据,会在insert前,先根据name或者code字段select一下数据,如果数据存在,则执行update逻辑,如果不存在则走insert逻辑

​ 逻辑如下:

​ 这种方案是在平时防止产生重复数据时最常用到的方案,可是该方案并不适用于并发场景,在并发场景中要配合其他方案一起使用,否则还是会产生重复数据,这里提一下的原因是想和其他方案对比来看。

2.加悲观锁

​ 同样用之前小陈余额50的那个栗子举栗,一般情况下,sql是这样的:

update user amount = amount - 50 where id = 'xiaochen'

​ 但是如果同时出现相同的请求,小陈的余额很可能为负数,这个bug可以通过悲观锁进行解决。

select * from user WHERE id = 'xiaochen' for update

​ 什么是悲观锁呢?

​ 顾名思义,悲观锁是一种基于悲观的态度来防止一切的数据冲突,他会以一种预防的姿态把在一个请求到来时将数据锁住,只给这一条请求操作数据,在它释放锁之前,任何人都不能对该数据进行操作,直到前一个人将数据操作完毕,然后释放锁,下一个人才可以给该数据加锁,然后操作,一般数据库本身锁的机制都是基于悲观锁的机制实现的。

​ 回到主题,具体逻辑如下:

注:

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

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

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

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

3.加乐观锁

​ 乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。. 因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作

​ 既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁,但是我们需要给表加一个类似于version的字段用于共同查询。

​ 以version举栗:

select amount,version from user where id = 'xiaochen'

​ 如果数据存在,假设查询到的version = 1,再使用id和version字段作为查询条件更新数据:

update user set amount = amount - 50,version = version + 1 where id = 'xiaochen' and version = 1

​ 更新数据的同时version + 1,然后判断此次更新的行数,如果大于0,则此次更新成功,如果等于0,那么说明本次更新没有让数据变更。

​ 由于第一次请求version=1是可以成功的,操作成功后version变为了2,如果此时并发的请求过来,再执行相同的sql,那么该操作不会真正的更新数据,因为version=1!=2,最终sql的的影响结果为0。

​ 具体流程如下:

4.加唯一索引

​ 绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单,并且有效的方案。

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

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

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

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

具体流程图如下:

5.建防重表

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

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

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

特定场景下具体流程图如下:

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

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

6.根据状态机

​ 这种场景其实可以类比上面的乐观锁

​ 很多时候业务表是有状态的,比如订单表中有: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时,接口也可以直接返回成功。

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

7.加分布式锁

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

​ 我们先以redis为例介绍使用分布式锁的几种方案:

方案1

​ 使用Redis实现分布式锁最简单的方案是在获取锁之前先查询一下以该锁为key对应的value存不存在,如果存在,则说明该锁被其他客户端获取了,否则的话就尝试获取锁,获取锁的方法很简单,只要以该锁为key,设置一个随机的值就行了。比如,我们有一批任务需要由多个分布式线程处理,每个任务都有一个taskId,为了保证每个任务只被执行一次,在工作线程执行任务之前,先获取该任务的锁,锁的key可以为taskId。因此,获取锁的过程可以用如下伪代码实现:

function boolean getLock(taskId)
if(existsKey(taskId))
	return false;
	else
	setKey(taskId);
	return true;
	

​ 上述就是最简单的获取锁的方案了,但是大家可以想想这个方案有什么问题呢?有没有什么潜在的坑?在分析这种方案的优缺点之前,先说一下获取锁后我们一般是怎么使用锁,并且又是如何释放锁的,以Java为例,我们一般获取锁后会将释放锁的代码放在finally块中,这样做的好处是即使在使用锁的过程中出现异常,也能顺利将锁释放掉。用伪代码描述如下:

boolean lock=false;
try
	lcok=getLock(taskId);   //获取锁
if(lock)
	doSomething();   //业务逻辑
	
finally
    if(lock)
    releaseLock(taskId);   //释放锁
	

​ 上述使用锁的代码乍一看是没有什么问题的,即使try代码块中抛出异常,最终也会执行finally代码块,然而这样就能保证锁一定会被释放吗?考虑这样一种情况:代码执行到doSomething()方法的时候,服务器宕机了,这个时候finally代码块就没法被执行了,因此在这种情况下,该锁不会被正常释放,在上述案例中,可能会导致任务漏算。因此,这种方案的第一个问题是会出现锁无法正常释放的风险,解决这个问题的方法也很简单,Redis设置key的时候可以指定一个过期时间,只要获取锁的时候设置一个合理的过期时间,那么即使服务器宕机了,也能保证锁被正确释放。

​ 该方案的另外一个问题是,获取到的锁不一定是排他锁,也就是说同一把锁同一时间可能被不同客户端获取到。仔细分析一下getLock方法,该方法并不是原子性的,当一个客户端检查到某个锁不存在,并在执行setKey方法之前,别的客户端可能也会检查到该锁不存在,并也会执行setKey方法,这样一来,同一把锁就有可能被不同的客户端获取到了。

方案2

​ 上述的方案有2个缺点,一个是获取的锁可能无法释放,另一个是同一把锁在同一时间可能被不同线程获取到。通过查看Redis文档,可以找到Redis提供了一个只有在某个key不存在的情况下才会设置key的值的原子命令,该命令也能设置key值过期时间,因此使用该命令,不存在上述方案出现的问题,该命令为:

SET my_key my_value NX PX milliseconds

​ 其中,NX表示只有当键key不存在的时候才会设置key的值,PX表示设置键key的过期时间,单位是毫秒。

​ 如此一来,获取锁的过程可以用如下伪代码描述:

function boolean getLock(taskId,timeout)
return setKeyOnlyIfNotExists(taskId,timeout);

​ 其中,setKeyOnlyIfNotExists方法表示的是原子命令SET my_key my_value NX PX milliseconds。

​ 虽然解决了上述的问题,但是与此同时诞生了一个新的问题:试想这个场景,客户小陈在获取锁的时候设置锁的过期时间为2s,但是其中doSomething()执行了3s或更长时间,但是第2s的时候这个锁就已被释放了,此时可能会有客户小董获取到了这个锁,可是小陈在执行完业务方法的时候会释放锁,那么此时就会出现一个问题,小陈释放的锁不是他自己的而是其他客户的。

方案3:

​ 针对方案二可能会释放其他客户锁的问题,我们可以通过设置一个随机的value进行改进,我们设置key的时候,将value设置为一个随机值r,当释放锁,也就是删除key的时候,不是直接删除,而是先判断该key对应的value是否等于先前设置的随机值,只有当两者相等的时候才删除该key,由于每个客户端产生的随机值是不一样的,这样一来就不会误释放别的客户端申请的锁了

​ 新的释放锁的方案用伪代码描述如下:

function void releaseLock(taskId,random_value)
	if(getKey(taskId)==random_value)
	deleteKey(taskId);
	

其中,getKey方法就是Redis的查询key值的方法,deleteKey就是Redis的删除key值的方法

那么这种方案就没有问题了吗?其实这种方案也是有问题的。原因在于上述释放锁的操作不是原子性的,不是原子性操作意味着当一个客户端执行完getKey方法并在执行deleteKey方法之前,也就是在这2个方法执行之间,其他客户端是可以执行其他命令的。考虑这样一种情况,在客户端A执行完getKey方法,并且该key对应的值也等于先前的随机值的时候,接下来客户端A将会执行deleteKey方法。假设由于网络或其他原因,客户端A执行getKey方法之后过了1秒钟才执行deleteKey方法,那么在这1秒钟里,该key有可能也会因为过期而被Redis清除了,这样一来另一个客户端就有可能在这期间获取到锁,然后接下来客户端A就执行到deleteKey方法了,如此一来就又出现误释放别的客户端申请的锁的问题了。

方案4:

​ 由于redis中并没有相关的原子性的操作,我们可以通过lua脚本进行原子性操作的实现

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

其中ARGV[1]表示设置key时指定的随机值。

由于Lua脚本的原子性,在Redis执行该脚本的过程中,其他客户端的命令都需要等待该Lua脚本执行完才能执行,所以不会出现方案三所说的问题。至此,使用Redis实现分布式锁的方案就相对完善了。

redis分布式锁总结一哈

上述分布式锁的实现方案中,都是针对单节点Redis而言的,然而在生产环境中,我们使用的通常是Redis集群,并且每个主节点还会有从节点。由于Redis的主从复制是异步的,因此上述方案在Redis集群的环境下也是有问题的。由于篇幅限制,后续文章继续更新

回到主题:业务上如何在高并发下保证接口的幂等性:

​ 通过上述的分布式锁描述,我们可以得到一个步骤:

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

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

8.设置token

该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。

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

具体流程图如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GAl9tufe-1628939090082)

  1. 用户访问页面时,浏览器自动发起获取token请求。

  2. 服务端生成token,保存到redis中,然后返回给浏览器。

  3. 用户通过浏览器发起请求时,携带该token。

  4. 在redis中查询该token是否存在,如果不存在,说明是第一次请求,做则后续的数据操作。

  5. 如果存在,说明是重复请求,则直接返回成功。

  6. 在redis中token会在过期时间之后,被自动删除。

    需要特别注意的是:token必须是全局唯一的。

以上是关于高并发下保证接口的幂等性的几种方式的主要内容,如果未能解决你的问题,请参考以下文章

高并发下保证接口的幂等性的几种方式

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

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

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

谈谈高并发下的幂等性处理

高并发下的幂等策略分析