redis — 分布式环境下Redis防重复提交方案(六)

分布式环境下Redis防重复提交

1、何为重复提交
重复提交是在第一次请求已经在进行处理或处理成功的情况下,人为的进行多次操作,导致不满足幂等要求的服务多次改变状态。
2、何为幂等
幂等是其任意多次执行所产生的影响均与一次执行的影响相同(不用担心重复执行会对系统造成改变)。更多的情况是第一次请求不知道结果(比如超时)或者失败的异常情况下,发起多次请求,目的是多次确认第一次请求成功,却不会因多次请求而出现多次的状态变化。
3、保证幂等策略
幂等需要通过唯一的业务标识来保证。也就是说相同的业务标识,认为是同一笔业务。使用这个唯一的业务标识来确保,后面多次的相同的业务标识的处理逻辑和执行效果是一致的。 下面以支付为例,在不考虑并发的情况下,实现幂等很简单:
1、先查询一下订单是否已经支付过;
2、如果已经支付过,则返回支付成功;如果没有支付,进行支付流程,修改订单状态为‘已支付’。
4、防重复提交策略
上述的保证幂等方案是分成两步的,第2步依赖第1步的查询结果,无法保证原子性的。在高并发下就会出现下面的情况:第二次请求在第一次请求第2步订单状态还没有修改为‘已支付状态’的情况下到来。既然得出了这个结论,余下的问题也就变得简单:把查询和变更状态操作加锁,将并行操作改为串行操作。
4.1、防重表
使用订单号orderNo做为去重表的唯一索引,每次请求都根据订单号向去重表中插入一条数据。第一次请求查询订单支付状态,当然订单没有支付,进行支付操作,无论成功与否,执行完后更新订单状态为成功或失败,删除去重表中的数据。后续的订单因为表中唯一索引而插入失败,则返回操作失败,直到第一次的请求完成(成功或失败)。可以看出防重表作用是加锁的功能。
4.2、分布式锁
这里使用的防重表可以使用分布式锁代替,比如Redis。订单发起支付请求,支付系统会去Redis缓存中查询是否存在该订单号的Key,如果不存在,则向Redis增加Key为订单号。查询订单支付已经支付,如果没有则进行支付,支付完成后删除该订单号的Key。通过Redis做到了分布式锁,只有这次订单订单支付请求完成,下次请求才能进来。相比去重表,将并发放到了缓存中,较为高效。思路相同,同一时间只能完成一次支付请求。

分布式锁

1、分布式锁场景
举例:

  • 成员变量 A 存在 JVM1、JVM2、JVM3 三个 JVM 内存中
  • 成员变量 A 同时都会在 JVM 分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的
  • 不是同时发过来,三个请求分别操作三个不同 JVM 内存区域的数据,变量 A 之间不存在共享,也不具有可见性,处理的结果也是不对的

注:该成员变量 A 是一个有状态的对象

如果我们业务中确实存在这个场景的话,我们就需要一种方法解决这个问题,这就是分布式锁要解决的问题。
2、分布式锁应该具备哪些条件

  • 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
  • 高可用的获取锁与释放锁
  • 高性能的获取锁与释放锁
  • 具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误)
  • 具备锁失效机制,防止死锁
  • 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

3、分布式锁的实现—Redis
实现步骤:

  • 通过redis的setnx方式(不存在则设置),往redis上设置一个带有过期时间的key,如果设置成功,则获得了分布式锁。这里设置过期时间,是防止在释放锁的时候出现异常导致锁释放不掉。
  • 执行完业务操作之后,删除该锁。

实现方式基本是setnx+lua,或者set key value px milliseconds nx。这种实现方式有3大要点

  • set命令要用set key value px milliseconds nx;
  • value要具有唯一性;
  • 释放锁时要验证value值,不能错误释放锁;

代码如下图所示,基于redisTemplate实现:
在这里插入图片描述
事实上这类琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  • 在Redis的master节点上拿到了锁;
  • 但是这个加锁的key还没有同步到slave节点;
  • master故障,发生故障转移,slave节点升级为master节点;

4、分布式锁实现—Redisson
实现了Redlock算法,代码如下所示,基于redisson实现:
在这里插入图片描述
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

redisson也存在风险,如发生了GC

  • ClientA 获取锁,发生了 GC,超过超时时间,Redis 释放锁
  • ClientB 获取到锁,此时 ClientA 唤醒,两个客户端都获取到锁
  • ClientB 执行了 Read-Write-Update 模型之后 ClientA 再次覆盖了 ClientB 的数据,造成了数据错误

还有一种情况:

  • clientA 获取到 A,B,C 三个节点,由于网络故障,无法访问 D,E 节点
  • 由于 C 节点时钟向前偏移,导致锁过期
  • clientB 获取到 C,D,E,由于网络故障,无法访问 A,B 节点
  • 在此时,clientA 和 clientB 都获取到了锁

5、防止重复提交处理流程
综上所述,分布式锁只是防止了并发操作带来的风险,最终的防止重复提交还是要通过数据库保证的,具体流程如下:

  • 获取业务流程中的唯一标示,比如一笔支出中,支付流水可以当做一个唯一标示,用来区别多次的支付过程。
  • 通过这个唯一标识获取分布式锁,如果获取成功,继续业务流程,否则返回失败。
  • 查询redis中是否有此唯一标识信息。如果有,则认为已经处理过,无需再次处理。
  • 将唯一标识插入防重表中,并且存入redis中。(此处插入防重表失败,需要进行数据库回滚,即保证此表中的数据与业务数据一致)
  • 释放分布式锁资源,流程解锁。

流程图如下所示:
在这里插入图片描述
5.1、redis示例代码
在这里插入图片描述
5.2、redisson示例代码
在这里插入图片描述


版权声明:本文为qq_38658567原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。