分布式锁实现的三种方式
本文文字内容摘自:https://zhuanlan.zhihu.com/p/516473640
一、什么时候需要加锁
-
有并发,多线程(这里指的是资源的使用者多,也就是在多任务环境下才可能需要锁的存在,多个任务想同时使用一个资源才有竞争的可能)
-
有写操作(这里指的是资源的使用目的,如果是多个任务都是读请求的话,那反正这个资源就在那里,没有人改它,不同任务来读取的结果都是一样的,也就没有必要去控制谁先读谁后读)
-
有竞争关系(这里指的是对资源的访问方式是互斥的,我们这个资源虽然是共享的,同但一时刻只能有一个任务占用它,不能同时共用,只能有一个任务占有它,这个时候我们需要给它上锁)
二、如何加锁?
在单机环境下,也就是单个JVM环境下多线程对共享资源的并发更新处理,我们可以简单地使用JDK提供的ReentrantLock对共享资源进行加锁处理。
单体架构下如何加锁?
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author huangdh
* @version 1.0
* @description:
* @date 2022-10-15 16:58
*/
@RestController
public class GoodController2 {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
private final Lock lock = new ReentrantLock();
String goodsKey = "goods:001";
@GetMapping("/buyGoods")
public String buyGoods() throws InterruptedException {
/**
* 单机版的时候,在高并发情况下,需要对程序进行加锁
* 加锁:synchronized和ReentrantLock区别?
* synchronized:java关键字,jvm层面的锁,加synchronized锁时,必须等待该线程执行完毕之后其他线程才能进入,没有执行完成之前,其他现在只能排队阻塞等待,
* ReentrantLock:java类,可在给定时间内进行排队等待,如果拿到锁,则进入线程,如果拿不到,则放弃等待
* 根据业务需求选择其一
*/
/*if (lock.tryLock()) {
try {
// manipulate protected state
} finally {
lock.unlock();
}
} else {
// perform alternative actions }
}*/
synchronized (this) {
// 获取库存数量
String goods = stringRedisTemplate.opsForValue().get(goodsKey);
int goodsNum = goods == null ? 0: Integer.parseInt(goods);
if (goodsNum > 0){
int realNum = goodsNum - 1;
stringRedisTemplate.opsForValue().set(goodsKey,String.valueOf(realNum));
System.out.println("成功买到商品,库存剩余:" + realNum + "件" + "\t 服务端口为:" + serverPort);
return "成功买到商品,库存剩余:" + realNum + "件" + "\t 服务端口为:" + serverPort;
}else {
System.out.println("商品已售罄,欢迎下次光临!" + "\t 服务端口为:" + serverPort);
}
return "商品已售罄,欢迎下次光临!" + "\t 服务端口为:" + serverPort;
}
}
}
微服务架构多实例环境下如何加锁?
在微服务架构多实例的环境下,每一个服务都有多个节点,我们如果还是按照之前的方式来做,就会出现这样的情况:
这个时候再用ReentrantLock就没办法控制了,因为这时候这些任务是跨JVM的,不再是简单的单体应用了,需要协同多个节点信息,共同获取锁的竞争情况。
这时候就需要另一种形式的锁——分布式锁:
通常是把锁和应用分开部署,把这个锁做成一个公用的组件,然后多个不同应用的不同节点,都去共同访问这个组件(这个组件有多种实现方式,有些可能并不是严格意义上的分布式锁,这里为了方便演示,我们暂不做严格区分,统称为分布式锁)。
分布式锁实现方式
上面了解锁概念和原理之后,接下来我们就来看一看,分布式锁比较常见的实现方式有哪些,看一看它们之间具体有什么差异,理解它们各自的优缺点,知道哪种实现方式更容易、哪种性能更高、哪种运行更稳定,我们才能够在实际应用中选择合适的实现方式。还是像我们前面说的那样,并非一定要使用哪一种方式,合适最重要。
1)基于数据库实现的分布式锁
第一种方式,我们可以利用数据库来实现,比如说我们创建一张表,每条记录代表一个共享资源的锁,其中有一个status字段代表锁的状态,L 代表 Locked ,U 代表 Unlocked。
那比如有一个线程要来更新商品库存,它先根据商品ID找到代表该共享资源的锁,然后执行下面这个语句
update T t
set t.status = 'L'
where t.resource_id = '123456'
and t.owner = 'new owner'
and t.status = 'U';
如果这条语句执行成功了并且返回的影响记录数是1,那么说明了获取锁成功了,就可以继续执行更新商品库存的操作,然后释放锁时,则将status从 L 改为 U 即可.
我们上面只说了上锁和解锁操作,那如果这个锁已经被其他任务占用了,也就是 status = ‘L’,这个时候这个语句就更新不到数据,也就意味着获取不到锁,程序是不是只能等着,那要怎么等?这是我们面临的一个问题,因为数据库和我们的应用程序之间,除了发出执行语句和返回结果,基本就没有其他交互了,它很难给应用程序发出通知,这样就很难做到通过事件监听机制来获取到释放锁的事件,所以程序只能轮询地去尝试获取锁.
这会导致一个致命的问题,就是这种类似自旋锁的阻塞方式,对数据库资源消耗极大,原本数据库的性能相对较差,即便加上连接池,性能也远无法跟一些缓存中间件相比,而现在程序为了抢锁拼命发出update语句,对数据库性能来说更是雪上加霜,而在分布式环境中,尤其需要使用分布式锁的场景,基本上都是要求支持高并发的,这就出现一个悖论了,这一点基本上也宣告了数据库在大部分需要分布式锁的场景中都用不上。
2)基于单机版Redis实现的分布式锁
既然数据库性能不够好,我们看一下用缓存中间件,也就是我们最经常使用的Redis,如果用来实现锁要怎么样做。Redis的特点就是性能非常好,拿它跟数据库比的话,你会发现它的性能好到爆炸。有些同学平时可能也有用过Redis来实现锁,但是你采用的实现方式很有可能并不是真正的分布式锁,通常我们称它为单机版的Redis锁更合适,我们先来了解这个单机版的锁,因为这种实现方式在实际的应用中也用的很多。后面再对比一下它与Redis作者提出的Redlock的具体区别。
对于单机版Redis锁的实现主要有以下几个步骤
第一步会先向Redis获取锁,然后返回是否获取成功,如果获取成功了那就可以开始操作共享资源,这段时间这个锁就被占用了。操作完成之后就可以释放锁,最后判断一下锁是否释放成功。大体就分为获取锁、使用锁、还有释放锁这三大步骤。
那么这三个步骤使用Redis是如何实现呢?
首先获取锁,获取锁只需要下面这一条命令即可
-
SET key value NX PX|EX time
变量 解析 key 这个是作为锁的唯一标识,用于获取和释放锁,为了在不同使用者之间保持一致,直接以共享资源命名会更好。 value 这个是作为使用者的唯一标识,用来表示当前持有锁的是具体哪个使用者,可以起到一个标记的作用,为什么要这样呢,一会我们看一下锁的释放就知道。 NX 它是Redis的语义,表示这个key不存在的时候才能set成功,这里起到了互斥性的保证,满足一个锁最基本的特性。(如果不加这个语义限制,那么第一个线程获取锁之后,任务还没执行完,第二个线程再来获取,就会把值给覆盖掉,那么就起不到互斥的效果。) PX|EX 是缓存过期时间的设置,表示多少毫秒或者多少秒过期,是一个时间单位的区别 那如何释放锁呢,通常我们会使用引入Lua脚本,我们看一下下面这个语句块
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end
那么这个lua脚本的语义是执行这个脚本时,当输入的KEYS[1]在Redis里面的值等于输入的AEGV[1]时,则删除这个原有的KEY,即代表释放锁操作(这里查不到返回0,也可能是因为锁已经过期了,前面我们获取锁的时候设置了过期时间)
- KEYS[1]:它代表的是获取锁时输入的key,也就是共享资源名称
- ARGV[1]:它代表的是获取锁时输入的value,这个value的唯一性决定了使用者只能删除自身已经获取的锁,不会误删除别人的。
我们可以看到上面代码里面有一个判断,要保证获取锁和释放锁是同一个使用者。比如说有这种情况:
有一个客户端A获取到锁之后去执行业务操作,然后由于某些原因,这个操作的时间,耗时比较长,超过了锁的有效期,这个时候锁就自动释放了,那么这个时候另一个客户端B可能马上就获取到锁,然后也去执行业务逻辑,在它还没执行完的时候,客户端A的流程处理完了,然后就执行到释放锁的步骤,这个时候如果没有上面说的那个判断,那么就有可能发生这样的情况:客户端A,把客户端B持有的锁,给释放掉了!
那么除了正常的获取锁和释放锁之外,单机版的Redis锁有没有哪些地方需要注意的呢?我们先来思考一下这个问题:
为什么需要设置缓存的过期时间?这里是作为锁的有效期
定义了这个锁,它对应的操作在正常情况下所需要的操作时间,如果超过了这个时间,锁就会被自动释放掉
我们想象一下这种场景,当一个使用者获取锁成功之后,假如它崩溃了(导致它崩溃有很多原因比如发生网络分区,应用发生GC把业务执行流程给阻塞住了,或者时钟发生变化导致它无法和Redis节点进行通信,发生这些情况我们就简单说它崩溃了)这时会发生什么情况呢,这个时候这个对应的锁就一直不会过期了,因为有互斥的机制所以其他使用者尝试获取锁都set不成功,也无办法释放,因为释放时会判断使用者是否是锁的持有者。因此我们可以看到,获取锁一定要给它设置过期时间,也就是这个锁是有租期的,使用者必须在这个规定的租期内完成对共享资源的操作,租期一到,如果使用者没有主动释放,那么锁也会自动过期。
那第二个问题,为什么释放锁的时候,要引入Lua脚本?
这里我们先说一下结论,再来解释一下为什么。其实这里是为了保证操作原子性。包括获取锁的set命令,也需要原子性的保障。
假如不考虑原子性,我们上面的获取锁和释放锁,按照功能逻辑的话,是不是换成以下的写法也可以:
SET key value NX PX|EX time => set key value; expire key time; code => get key == value del key
这样会有什么问题呢,我们先看看获取锁的命令,使用者执行第一条成功了才会执行第二条,那如果执行第一条成功之后使用者崩溃了,当它再连上的时候是不是就变成了我们上面说的那种情况,没有设置锁的过期时间。
那释放锁的过程,拆成两条命令之后,又会导致什么问题呢,我们来看一下这种场景
假如使用者A完成任务之后准备释放自己持有的锁,它先通过get key得到一个值,用来判断出这个锁确实是自己持有的锁,并且还没有释放,这时候A由于某种原因,它还是崩溃了,造成崩溃的原因我们上面说了有多种情况,就阻塞了一段时间,在这段时间锁恰好因为超时自动释放掉了,然后,使用者B刚好来获取锁,也就是执行了上面的set命令,然后呢使用者A恢复了,比如GC完成,然后 就开始执行它的第二步操作,也就是del key操作,那是不是刚好,就把使用者B的锁给删除掉了。相当于锁的持有者和释放者就不一致了,从而导致了锁状态出现错乱。
前面我们从锁的获取和释放流程,结合Redis命令的特性,分析了单机版Redis为什么要这么实现,分析了这种实现方式的必要性以及可能出现的异常场景。那么我们再从更宏观的维度来看,这种单机版Redis锁最大的风险是什么呢?
如果这个Redis实例挂了,那就意味着整个锁机制失效了,这时使用者无法获取和释放锁,进一步导致使用者无法正常使用共享资源,从而出现阻塞、访问失败或者访问冲突等异常;还有可能因为共享资源失去了锁的保护 ,引起数据不一致,导致业务上一系列连锁反应。
添加分布式锁时,需要注意以下几个问题:
-
解锁:出现异常时,要确保能够删除锁,因此必须放在finally里面
-
如果部署的微服务jar包的服务挂掉了,代码层面根本没有走到finally代码块时,没办法保证解锁,这个key没有被删掉。因此,需要加入一个过期时间限制key
-
设置key和key的过期时间不能分开,必须要保证两个之间的具备原子性
-
误删锁:如果A线程进来了,并设置过期时间为10s,在此期间其他线程不断尝试获取锁,10s之后,线程A业务流程还没有处理完毕,仍在继续执行,但是此时设置的过期时间已结束,此时redis会自动把REDIS_LOCK删掉,而B线程刚好获取到锁,并执行相应的业务流程,此时线程A也刚好执行完毕,执行delete方法,把线程B的锁给误删掉,那么线程B执行完毕之后删除锁时找不到对应的锁,因此,在删除锁时需要进行校验,每个线程只能删除自己添加的锁。
-
判断加锁和解释是不是同一个客户端,如果不是同一客户端,则会误解锁,因此要保证finally代码块的判断和删除的原子性,可使用Luna脚本或者redis事务
-
如何确保设置的REDIS_LOCK的过期时间大于业务执行的时间?Redis分布式锁如何续期?
-
redis主从复制异常,导致锁丢失问题
import com.atguigu.springboot.redis.utils.RedisUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import redis.clients.jedis.Jedis; import java.util.Collections; import java.util.UUID; import java.util.concurrent.TimeUnit; /** * @author huangdh * @version 1.0 * @description: * @date 2022-10-15 16:58 */ @RestController @RequestMapping(value = "/redis") public class GoodController5 { @Autowired private StringRedisTemplate stringRedisTemplate; @Value("${server.port}") private String serverPort; private final String goodsKey = "goods:001"; private static final String REDIS_LOCK = "redis_lock"; /** * 添加分布式锁时,需要注意一下几个问题: * 1.解锁:出现异常时,要确保能够删除锁,因此必须放在finally里面 * 2.如果部署的微服务jar包的服务挂掉了,代码层面根本没有走到finally代码块时,没办法保证解锁,这个key没有被删掉。因此,需要加入一个国企时间限制key * 3.设置key和key的过期时间不能分开,必须要保证两个之间的具备原子性 * 4.误删锁:如果A线程进来了,并设置过期时间为10s,在此期间其他线程不断尝试获取锁,10s之后,线程A业务流程还没有处理完毕,仍在继续执行,但是此时设置的过期时间已结束,此时 * redis会自动把REDIS_LOCK删掉,而B线程刚好获取到锁,并执行相应的业务流程,此时线程A也刚好执行完毕,执行delete方法,把线程B的锁给误删掉,那么线程B执行完毕之后删除锁时 * 找不到对应的锁,因此,在删除锁时需要进行校验,每个线程只能删除自己添加的锁。 * 5.判断加锁和解释是不是同一个客户端,如果不是同一客户端,则会误解锁,因此要保证finally代码块的判断和删除的原子性,可使用Luna脚本或者redis事务 * 6.如何确保设置的REDIS_LOCK的过期时间大于业务执行的时间?Redis分布式锁如何续期? * 7.redis主从复制异常,导致锁丢失问题 * @return */ @GetMapping("/buyGoods") public String buyGoods() throws Exception { String redisKey = UUID.randomUUID().toString() + Thread.currentThread().getName(); try { // 加锁 // Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, redisKey); // 设置过期时间 // stringRedisTemplate.expire(REDIS_LOCK,10L, TimeUnit.SECONDS); Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, redisKey, 10, TimeUnit.SECONDS); if (!flag){ return "抢锁失败!!!"; } // 获取库存数量 String goods = stringRedisTemplate.opsForValue().get(goodsKey); int goodsNum = goods == null ? 0: Integer.parseInt(goods); if (goodsNum > 0){ int realNum = goodsNum - 1; stringRedisTemplate.opsForValue().set(goodsKey,String.valueOf(realNum)); System.out.println("成功买到商品,库存剩余:" + realNum + "件" + "\t 服务端口为:" + serverPort); return "成功买到商品,库存剩余:" + realNum + "件" + "\t 服务端口为:" + serverPort; }else { System.out.println("商品已售罄,欢迎下次光临!" + "\t 服务端口为:" + serverPort); } return "商品已售罄,欢迎下次光临!" + "\t 服务端口为:" + serverPort; } finally { /*while (true){ stringRedisTemplate.watch(REDIS_LOCK); if (stringRedisTemplate.opsForValue().get(REDIS_LOCK).equalsIgnoreCase(redisKey)){ // 开始事务支持 stringRedisTemplate.setEnableTransactionSupport(true); // 开启事务 stringRedisTemplate.multi(); stringRedisTemplate.delete(REDIS_LOCK); // 执行事务 List<Object> list = stringRedisTemplate.exec(); if (list == null){ continue; } } stringRedisTemplate.unwatch(); break; }*/ Jedis jedis = RedisUtils.getJedis(); String script = "if redis.call('get', KEYS[1]) == ARGV[1]" + "then " + "return redis.call('del', KEYS[1])" + "else " + "return 0 " + "end"; try { Object object = jedis.eval(script, Collections.singletonList(REDIS_LOCK), Collections.singletonList(redisKey)); if ("1".equals(object.toString())){ System.out.println("*********** del redis lock success ***********"); }else { System.out.println("*********** del redis lock fail ***********"); } }finally { if (jedis != null){ jedis.close(); } } } } }
那如何规避这种单点的问题呢?
基于Redis的高可用分布式锁——RedLock
RedLock基本情况
-
Redis作者提出来的高可用分布式锁
-
由多个完全独立的Redis节点组成,注意是完全独立,而不是主从关系或者集群关系,并且一般是要求分开机器部署的
-
利用分布式高可用以系统中大多数存活即可用的原则来保证锁的高可用
-
针对每个单独的节点,获取锁和释放锁的操作,完全采用我们上面描述的单机版的方式
RedLock工作流程
获取锁
- 获取当前时间T1,作为后续的计时依据;
- 按顺序地,依次向5个独立的节点来尝试获取锁
- (SET resource_name my_random_value NX PX 30000)
3. 计算获取锁总共花了多少时间,判断获取锁成功与否
-
- 时间:T2-T1
- 多数节点的锁(N/2+1)
4. 当获取锁成功后的有效时间,要从初始的时间减去第三步算出来的消耗时间
5. 如果没能获取锁成功,尽快释放掉锁。
这里需要注意两点:
- 为什么要顺序地向节点发起命令,那么我们反过来想,假如不顺序地发起命令会产生什么问题?那么我们想一下假如有3个客户端同时来抢锁,客户端A先获取到1号和2号节点,客户端B先获取到3号4号节点,客户端C先获取到5号节点,那么这时候就满足不了多数原则,5个节点的情况下,最少需要3个节点都获取到锁,才可以满足
- 客户端在向每个节点尝试获取锁的时候,有一个超时时间限制,而且这个时间远小于锁的有效期,比如说几毫秒到几十毫秒之间,这样的机制是为了防止在向某一个节点获取锁的时候,等待的时间过长,从而导致获取锁的整体时间过长。比如说在获取锁的时候,有的节点会出现问题导致连接不上,那么这个时候就应该尽快地转移到下一个节点继续尝试,因为最终的结果我们只需要满足多数可用原则即可
释放锁
向所有节点发起释放锁的操作,不管这些节点有没有成功设置过
正常情况下RedLock的运行状态
client1和client2,对Redis节点A-E进行抢锁操作,如图,client1先抢到节点ABC,超过半数,因此持有分布式锁,在持有锁期间,client2抢锁都是失败的,当时序=6时,client1才处理完业务流程释放分布式锁,这时候client2才有可能抢锁成功。
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @author huangdh
* @version 1.0
* @description:
* @date 2022-10-15 16:58
*/
@RestController
@RequestMapping(value = "/redis")
public class GoodController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private Redisson redisson;
@Value("${server.port}")
private String serverPort;
private final String goodsKey = "goods:001";
private static final String REDIS_LOCK = "redis_lock";
@GetMapping("/buyGoods")
public String buyGoods() throws Exception {
String redisKey = UUID.randomUUID().toString() + Thread.currentThread().getName();
RLock redissonLock = redisson.getLock(REDIS_LOCK);
redissonLock.lock();
try {
// 获取库存数量
String goods = stringRedisTemplate.opsForValue().get(goodsKey);
int goodsNum = goods == null ? 0: Integer.parseInt(goods);
if (goodsNum > 0){
int realNum = goodsNum - 1;
stringRedisTemplate.opsForValue().set(goodsKey,String.valueOf(realNum));
System.out.println("成功买到商品,库存剩余:" + realNum + "件" + "\t 服务端口为:" + serverPort);
return "成功买到商品,库存剩余:" + realNum + "件" + "\t 服务端口为:" + serverPort;
}else {
System.out.println("商品已售罄,欢迎下次光临!" + "\t 服务端口为:" + serverPort);
}
return "商品已售罄,欢迎下次光临!" + "\t 服务端口为:" + serverPort;
} finally {
redissonLock.unlock();
}
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本