Redis 分布式锁

为什么使用分布式锁

场景

  Java 中 synchronized 锁只是 JVM 级别的,也就是进程级别。因此,在分布式系统中,当同一个服务,启动多次,在不同进程中,相同的同步代码块使用 synchronized,并不能达到想要的同步效果,也就是这个关键字管不到别的进程。

  此时,前端如果出现高并发场景,系统通过负载均衡,将同一个接口的请求,分发到不同节点中处理业务,这些请求都分别能拿到各自所在进程的锁,去处理业务,从而导致并发问题出现。

解决方案

  在需要同步的代码块前面加 redis 分布式锁,同步代码块执行完毕后,释放锁。从而达到同步操作的目的。

具体解决方案

Reids 命令参考地址

认识 redis 分布式锁实现的关键命令

SET key value [EX seconds] [PX milliseconds] [NX|XX]

参数解释:

EX seconds : 将键的过期时间设置为 seconds 秒。
PX milliseconds : 将键的过期时间设置为 milliseconds 毫秒。PSETEX key milliseconds value 。
NX : 只在键不存在时, 才对键进行设置操作。 执行 SET key value NX 的效果等同于执行 SETNX key value 。
XX : 只在键已经存在时, 才对键进行设置操作。

注意:

  众所周知,中括号参数是可选的,而一个中括号中出现管道符 “|” 时,表示符号两边的参数只能选一个。

 

实际应用

  在 java 中对应的 redis api 如下,使用此接口设置的 key 不能重复。也就是同时多个线程 set 同一个 key 时,只有第一个线程能成功,接口返回 true,后续的线程都将返回 false。

boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, timeout, timeUnit);

  所以,通过上面接口就能实现同步功能中的加锁操作,只要线程执行这段代码返回的是 false,就直接 return,后续具体业务逻辑代码不允许执行。

  而解锁方式,就是直接删除这个作为锁使用的 key

redisTemplate.delete(lockKey)

大致代码如下:

String lockKey = "lock";
String value = "locked";
long timeout = 10L;
// 加锁

boolean
result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, timeout, TimeUnit.SENCONDS); if (!result) { log.warn("已经有线程在执行此段代码,其他线程不允许调用"); return; } // 执行业务代码 ... ... 业务代码 ...
// 业务代码执行完毕,删除锁或者叫释放锁
redisTemplate.delete(lockKey)

 

上面这种简单加锁可能出现的问题

锁失效引起的问题 和 锁误删的问题

  高并发情况下。此时线程 A 执行加锁操作后,再执行完同步块中的业务代码需要花 15 秒钟。在线程 A 还未执行完毕,执行到第 10 秒的时候,锁失效时间到了,被 redis 清除了。

  然后线程 B 来了,执行加锁接口返回 true,也就是因为线程 A 锁失效了,所以线程 B 又能成功加锁了。

  线程 B 执行业务代码需要 10 秒,在线程 B 执行到第 5 秒时,线程 A 因为之前已经执行 10 秒,此时又经过 5 秒后,业务逻辑代码执行完毕,执行到删除锁的代码,因为大家使用的是值相同的 lockKey,所以线程 A 成功的误删了线程 B添加的锁。

  这时,线程 C 也来了,发现没锁,它也加锁,然后执行业务代码,它的锁又被 B 清除了......,以此类推,会出现锁永远失效的情况。

解决方案

  解决锁失效的问题:在执行业务代码前,另外开个线程,使用定时器,定时更新 lockKey 失效时间。例如上面设置10 秒失效,定时器这边取失效时间的 1/3 (Redisson 也是取1/3时间),作为定时去更新失效时间的时间间隔,只要判断这个 lockKey 还存在,就去更新,避免在线程还未执行完业务代码,锁就是失效了。

  解决锁误删的问题: 上面案例中 vlue 属性可以使用不同的值来区分是否是当前线程添加锁。具体就是,使用当前线程ID( Thread.currentThread().getId() )作为value的值。在释放锁,删除 lockKey 的时候,先取出缓存中 lockKey 的值,跟当前要释放锁的线程 ID 做对比,只有相同的情况下,才允许删除。

须知:

  上面解决方案实现起来,代码不少,所以可以使用 Redisson 来实现加锁,解锁的操作。Redisson 内部就使用了上面的解决方案,来避免锁失效引起的问题,且保证线程释放锁时,判断是否是自己的锁释放锁这两个步骤的原子性。

  未保证原子性导致并发问题的案例

    线程 A 判断是否为自己的锁返回 true 后,JVM  发生 Full GC 时,线程阻塞,此时,Redis 并不会收到影响,所以可能触发线程 A 的锁超时释放。

    此时线程 B 出现了,它处于另一个进程当中,不受线程 A 所在 JVM 因 Full GC 导致线程阻塞的影响。所以线程 B 成功添加了自己锁,继续执行业务逻辑代码。

    当线程 A 阻塞结束继续执行释放锁的代码时,就会删除了线程 B 的锁(注意:上面解决误删问题中,说的是使用不同的锁 value 值来区分判断是否为自己的锁,所以锁的 key 是一样的。因此不同线程之间,只要越过判断是否为自己添加的锁这一层逻辑,就会出现误删的情况)

    因为线程 B 的锁被线程 A 删了,新的线程 C 来时,检测到没有锁,然后加上锁,就去执行同步业务代码块了,这样,并发的问题就又出现了。

解决上面未保证原子性导致并发问题的案例的问题,有两种方式:1.使用 Lua 脚本;2. 使用 Redisson 框架。

使用 Redision 解决大致代码

// 引入 Redisson
@Autowrite
Redisson redisson;

************************

// 使用 Redisson
String lockKey = "lock";
RLock redissonLock = redisson.getLock(lockKey)

// 加锁操作。只有无锁的线程能执行下面代码加锁,其他线程都会被阻塞,所以不需要前面的 if 判断来处理
redissonLock.lock(30, TimeUnit.SECONDS);

// 执行业务代码
...
... 业务代码
...
// 业务代码执行完毕,释放锁(一定要在 finally 中执行)
redissonLock.unlock();

 

主节点宕机,锁没来得及向从节点同步问题

  基于上面 redisson 的加锁场景,在 redis 集群中,如果刚加的锁,主节点还未来的及向从节点同步,就挂了。此时,从节点在选举出新的主节点后,里面并没有刚刚加的锁,这个问题该如何解决?(简而言之,主节点没来得及将锁同步到从节点,就宕机了,发生故障转移,锁丢失了)

  * 使用 redlock 的方式:至少有 (N/2 + 1) 个 redis 实例,也就是半数以上主节点成功获取锁,且申请多个节点的锁时间总和不能操作锁失效的时间,才算真正的获取锁成功。否则算失败,此时需要回滚,删除之前在所有节点上获取的锁。(向没个主节点申请锁可以设置超时时间,避免某个主节点宕机导致长时间阻塞)

  * zookeeper 解决过这个问题,可以参考。

 

分布式锁性能优化问题(秒杀场景)

  秒杀场景流程:

    * 库存判断,使用String 数据结构,也就是普通 key-value 模式存储,记录库存数量,如果库存 > 0,才能继续后面的逻辑。

    * 记录已经购买过产品的用户,使用 set 数据结构,一个 key,多个 value。如果用户为购买过产品,那么记录用户ID,且库存 -1,创建下单任务,且将任务放到队列。

    * 读取下单任务队列,创建订单。

  优化:

  1、秒杀场景存在库存判断问题,这个肯定需要加锁,如何提升效率?

    假设库存为 10000,按照上面加锁的方式可以设置多个 lockKey。例如此处设置 10 个不同的 lockkKey,每个 lockKey 仅锁定 100 个库存值,那么就可以有十个不同线程同时去判断和操作库存量,互不影响,从而达到提升效率的目的。库存量分的越细,能操作的线程越多,线程越多,占用资源也会越多,酌情考虑进行分段。这个逻辑类似于 concurrentHashMap 分段锁的逻辑。

    

 

 

posted @ 2023-06-22 17:56    阅读(11)  评论(0编辑  收藏  举报