编辑
2023-12-25
编程
00

目录

1. 前言
2. 关于锁
3. redis 分布式锁
3.1 简单的 redis 锁设计
3.2 redis 分布式锁:死锁问题
3.3 redis 分布式锁:锁续期
3.5 redis 分布式锁:RedLock
4. 分布式锁与事务

1. 前言

我们时常依赖于我们的习惯和认知,而导致不时有的偏见。就像《三体》中描写叶文洁年轻时伐木故事里的小人物看法一样,对于他来说伐木讲究的是越快越好,树龄的多少,并不影响锯它的速度。我总以为,在如今,微服务当道,分布式横行的后端开发场景,分布式锁,对于任何层级的程序员来讲都是了如指掌。事实证明并非如此,于是今天抽空写下这篇文章,通过 redis 讲述分布式锁的使用和要点,望你我共勉。

2. 关于锁

与锁相关联的,就是我们常说的并发有关,我们在单个应用中由于使用了多线程,基于原子性可见性有序性三个原则导致的并发安全性问题。而在分布式系统上,不同的节点被访问与不同的线程访问是类似的。

我们常说并发安全,线程安全,其本质上就是指正确性,现象就是程序按照我们期望的执行。

而一般会出现线程安全问题只有一种情况,就是多条线程同时读写同一条数据。类比到分布式上讲,就是多个服务同时读写同一个数据。

在多线程中,我们通常通过互斥,来保证程序的原子性,互斥即代表同一时间只有一个线程可以访问。而分布式锁,主要也是通过控制互斥来实现的,“同一时间,只有一个节点能执行这段代码”

在 java 多线程中,为了保证数据的可见性,通常我们会在使用 volatile 关键字修饰,通过禁用缓存直接从内存中读取或写入来达到所有线程可见。而在分布式系统上,通常通过中间件(如 redis,mysql之类)来读取数据,以此来保证数据的变化在所有应用中都是可见的。而更复杂的是如果没有中间件,又怎么去保证数据的可见性(比如kafka集群间,zk集群间),这就涉及到分布式重的数据一致性问题,这里暂不讨论。

在多线程,锁是跨不同线程可见的,那么分布式锁也是一样的,需要跨节点可见的,所以在日常工作中,我们常常借用中间件,来构建分布式锁。常见的比如 redis 分布式锁,zk 分布式锁,mysql 锁 等。我们这里主要讨论 redis 分布式锁

3. redis 分布式锁

3.1 简单的 redis 锁设计

为了保证互斥,我们需要每个节点在执行前判断是否有节点正在执行,如果有,等待或者退出,如果没有,获取锁,并执行。

示例一伪代码如下

java
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>(); String redisLockKey = "biz_lock"; // 1.判断 key 是否存在 Boolean isHas = redisTemplate.hasKey(redisLockKey); // 2.如果已经存在直接返回 if(isHas != null && isHas) { return; } // 3.如果不存在,占有 key redisTemplate.opsForValue().set(redisLockKey, "节点名"); // .... 4.业务流程 // 5.释放锁 redisTemplate.delete(redisLockKey);

这段代码其实就是简单的 redis 锁的实现,不过有很多问题,原理上这样。

在代码 【1】,【2】,【3】 这个过程中,其实存在原子性问题,比如线程A走到 【3】 这段代码还没执行,而线程B走到【2】也是判断出为空。这是A和B都会去占有锁,而且都会认为自己占有锁成功。执行业务流程,发送并发问题。所以我们一般会是这样写的:

(示例二)

java
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>(); String redisLockKey = "biz_lock"; // 如果不存在则set,如果存在则返回失败 Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(redisLockKey, "节点标识"); if (Boolean.FALSE.equals(isSuccess)) { System.out.println("获取锁失败"); return; } // .... 业务流程 // 释放锁 redisTemplate.delete(redisLockKey);

这里的的 setIfAbsent 方法调用的其实就是 redis 的 setnx key value, 或者是 set key value NX,其实就是通过把 【示例一】的 【1】【2】【3】逻辑封装在一条指令上,来让这三步操作达到原子性。类似于 原子类(Atomic)的 compareAndSet 。

3.2 redis 分布式锁:死锁问题

通过上述,我们简单讲了 redis 锁的构造,但是这样的锁存在一个问题,就是 死锁

当我们在获取锁后,在释放锁前,服务宕机,或者业务流程出现异常(可以通过 try finally,这里主要讲宕机)。就会造成锁被永久的占有了。我们当然可以通过连接 redis,手动 delete 掉它,哈哈哈。

为了死锁这个问题,也是为了减少我们运维的工作量,我们通常会在加锁的时候为它设置一个超时时间。

(示例三)

java
// 1.如果不存在则set,如果存在则返回失败 Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(redisLockKey, "节点标识"); // 2.设置超时时间 redisTemplate.expire(redisLockKey, 3, TimeUnit.SECONDS);

当然,这样也是存在死锁问题的,和【示例一】一样,这里因为【1】和【2】的操作不是原子性 ,所以有可能【1】执行完就宕机了,这可不还是死锁吗?为啥反复举这种例子,因为原子性真的很容易被忽略。设计上我们一般会通过下面代码来实现:

(示例四)

java
// Redis 2.6.12 后 Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(redisLockKey, "节点标识", 3, TimeUnit.SECONDS);

这样,如果出现正常执行,业务流程过后就会释放锁了,但如果在这个过程中宕机了,等到超时时间到达,也内自动解锁了。

3.3 redis 分布式锁:锁续期

死锁问题的解决方案【示例四】延伸出一个问题,超时时间要多长合适呢?

  1. 时间设置得太短,业务处理过程中,A请求锁过期,释放锁。别的请求能获取到锁,并且可能会被A请求释放。
  2. 时间设置得太长,宕机死锁的情况下,死锁等待释放时间过长。

相对于第一点的结果来讲,设置长时间的超时时间,无疑是更具安全性,大不了我等一下嘛?

然而,聪明的工程师针对时间设置得太短,提出了一个解决方案,即可续期,常用称呼 看门狗(watch dog)

Redisson (redis 分布式锁封装)是这样做的,加锁时先设置一个过期时间,然后开启一个”守护线程“,定时去检查锁的失效时间,快失效时,如果线程还未结束,那么自动对锁进行”续期“,重新设置过期时间。

虽然这里所得是”守护线程“,其实只是为了便于理解,阅读器 Redisson 可以知道,在开启看门狗,获取到锁后,会每30s自动续期一次,释放锁的时候会再缓存里面去删除需要续期的对象,然后再去 lua 脚本释放锁。如果释放锁出现异常,缓存里面也没有了续期对象,不会再执行续期。如果程序宕机,也不会执行续期。

值得注意的是,Redisson 获取锁的方法有好几种,但是只有 leaseTime 默认,或者传入为 -1 时,才会开启看门狗机制

redisson 源码阅读指引:

xml
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.16.6</version> </dependency>
java
org.redisson.RedissonLock#tryAcquireAsync // 获取锁,其中 leaseTime == -1 分支为开启看门狗代码 org.redisson.RedissonBaseLock#scheduleExpirationRenewal // 看门狗机制 org.redisson.RedissonBaseLock#renewExpiration // 续期 org.redisson.RedissonBaseLock#unlockAsync(long) // 锁释放 org.redisson.RedissonBaseLock#cancelExpirationRenewal // 取消续期

3.5 redis 分布式锁:RedLock

这里就稍微提一嘴,redlock 在大部分日常使用场景都不会用到,它是为了应付,redis “主从切换” 时,主从数据不一致导致虚假的加锁成功问题。

即可,A请求加锁,在redis master节点加锁成功并返回了,master节点同步数据给slave前宕机了,slave 节点上升为 master 节点,这时redis并没有A的加锁信息当时A请求认为成功了操作共享资源,其他请求这时也能申请加锁成功,出现并发问题。

RedLock 是通过对5个以上的redis实例(相互隔离)去加锁,先加锁大于等于三个的节点的请求获取到锁。感兴趣可以自行查看。

这里提下 redLock 主要是想说,问题是无穷尽的,但是考虑成本和场景,够用就可以了。就像为了防止 主从切换的问题用了 redlock,那看场景可能休数据的成功更低呢。但是话又说回来了,正是因为无穷尽的挖掘,才有现如今的盛况。反正,理论探索无极限,落地选择最合适。

4. 分布式锁与事务

分布式锁的加锁和解锁姿势不对,可能引发各种千奇百怪的问题。事务,就是最常见的问题。由于篇幅有限,会单独开一篇,敬请期待吧。

本文作者:Yui_HTT

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!