📢📢📢📣📣📣
哈喽!大家好,我是【一心同学】,一位上进心十足的【Java领域博主】!😜😜😜
✨【一心同学】的写作风格:喜欢用【通俗易懂】的文笔去讲解每一个知识点,而不喜欢用【高大上】的官方陈述。
✨【一心同学】博客的领域是【面向后端技术】的学习,未来会持续更新更多的【后端技术】以及【学习心得】。
✨如果有对【后端技术】感兴趣的【小可爱】,欢迎关注【一心同学】💞💞💞
❤️❤️❤️感谢各位大可爱小可爱!❤️❤️❤️
目录
一、什么是分布式锁?
二、分布式锁实现方案
🌴 常见的分布式锁方案
🌴 推演实现分布式锁
🌵 问题:发生死锁
🌵 解决——死锁
🌵 解决——锁被别人释放了
🌵 解决——锁的过期时间
三、redisson实现分布式锁
🌴 依赖
🌴 配置
🌴 业务类使用
一、什么是分布式锁?
分布式锁可以理解为:控制分布式系统有序的去对共享资源进行操作,通过互斥来保持一致性。
例如共享的资源就是一个房子,里面有各种书,分布式系统就是要进屋看书的人, 分布式锁就是保证这个房子只有一个门并且一次只有一个人可以进,而且门只有一把钥匙。 然后许多人要去看书,进行排队,第一个人拿着钥匙把门打开进屋看书并且把门锁上, 然后第二个人没有钥匙,那就等着,等第一个出来,然后你在拿着钥匙进去,然后就是以此类推。
在单机环境中,应用是在同一进程下的,只需要保证单进程多线程环境中的线程安全性,通过 JAVA 提供的 volatile、ReentrantLock、synchronized 以及 concurrent 并发包下一些线程安全的类等就可以做到。而在多机部署环境中,不同机器不同进程,就需要在多进程下保证线程的安全性了。因此,分布式锁应运而生。
二、分布式锁实现方案
🌴 常见的分布式锁方案
分类 | 方案 | 实现原理 | 优点 | 缺点 |
基于数据库 | 基于mysql 表唯一索引 | 1.表增加唯一索引 2.加锁:执行insert语句,若报错,则表明加锁失败 3.解锁:执行delete语句 | 完全利用DB现有能力,实现简单 | 1.锁无超时自动失效机制,有死锁风险 2.不支持锁重入,不支持阻塞等待 3.操作数据库开销大,性能不高 |
基于MongoDB findAndModify原子操作 | 1.加锁:执行findAndModify原子命令查找document,若不存在则新增 2.解锁:删除document | 实现也很容易,较基于MySQL唯一索引的方案,性能要好很多 | 1.大部分公司数据库用MySQL,可能缺乏相应的MongoDB运维、开发人员 2.锁无超时自动失效机制 | |
基于分布式协调系统 | 基于ZooKeeper | 1.加锁:在/lock目录下创建临时有序节点,判断创建的节点序号是否最小。若是,则表示获取到锁;否,则则watch /lock目录下序号比自身小的前一个节点 2.解锁:删除节点 | 1.由zk保障系统高可用 2.Curator框架已原生支持系列分布式锁命令,使用简单 | 需单独维护一套zk集群,维保成本高 |
基于缓存 | 基于redis命令 | 1. 加锁:执行setnx,若成功再执行expire添加过期时间 2. 解锁:执行delete命令 | 实现简单,相比数据库和分布式系统的实现,该方案最轻,性能最好 | 1.setnx和expire分2步执行,非原子操作;若setnx执行成功,但expire执行失败,就可能出现死锁 2.delete命令存在误删除非当前线程持有的锁的可能 3.不支持阻塞等待、不可重入 |
基于redis Lua脚本能力 | 1. 加锁:执行SET lock_name random_value EX seconds NX 命令 2. 解锁:执行Lua脚本,释放锁时验证random_value -- ARGV[1]为random_value, KEYS[1]为lock_name if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end | 同上;实现逻辑上也更严谨,除了单点问题,生产环境采用用这种方案,问题也不大。 | 不支持锁重入,不支持阻塞等待 |
在以上的方案中,redis+lua基本可应付工作中分布式锁的需求,但是还有一种解决方案——redisson分布式锁,相比以上方案,redisson保持了简单易用、支持锁重入、支持阻塞等待、Lua脚本原子操作。
🌴 推演实现分布式锁
分布式锁实现条件:
1、互斥性。在任意时刻,只有一个客户端能持有锁。
2、不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
3、解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
4、具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。
想要实现分布式锁,必须要求Redis有互斥的能力。可以使用SETNX命令,其含义是SET IF NOT EXIST,即如果key不存在,才会设置它的值,否则什么也不做。两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。
我们可以将整体流程写成以下伪代码:
// 加锁
SETNX lock_key 1
// 业务逻辑
DO SOMETHING
// 释放锁
DEL lock_key
🌵 问题:发生死锁
不难发现上面的代码在某些场景下是会发生死锁的,如:
1、程序处理业务逻辑异常,没及时释放锁。
2、进程挂了,没机会释放锁。
🌵 解决——死锁
为了解决以上死锁问题,最容易想到的方案是在申请锁时,在Redis中实现时,给锁设置一个过期时间,假设操作共享资源的时间不会超过10s,那么加锁时,给这个key设置10s过期即可。
如何加锁?
加锁、设置过期时间是2条命令,有可能只执行了第一条,第二条却执行失败,例如:
- SETNX执行成功,执行EXPIRE时由于网络问题,执行失败
- SETNX执行成功,Redis异常宕机,EXPIRE没有机会执行
- SETNX执行成功,客户端异常崩溃,EXPIRE没有机会执行
之这两条命令如果不能保证是原子操作,就有潜在的风险导致过期时间设置失败,依旧有可能发生死锁问题。幸好在Redis 2.6.12之后,Redis扩展了SET命令的参数,可以在SET的同时指定EXPIRE时间,这条操作是原子的,例如以下命令是设置锁的过期时间为10秒。
SET lock_key 1 EX 10 NX
新问题:
但是,过期时间也带来了新的问题,如下:
- 客户端1加锁成功,开始操作共享资源。
- 客户端1操作共享资源耗时太久,超过了锁的过期时间,锁失效(锁被自动释放)。
- 客户端2加锁成功,开始操作共享资源。
- 客户端1操作共享资源完成,在finally块中手动释放锁,但此时它释放的是客户端2的锁。
我们把上面归为两个主要问题:
1、锁过期
2、释放了别人的锁
第1个问题是评估操作共享资源的时间不准确导致的,客户端在拿到锁之后,在操作共享资源时,遇到的场景是很复杂的,既然是预估的时间,也只能是大致的计算,不可能覆盖所有导致耗时变长的场景。
第2个问题是释放了别人的锁,原因在于释放锁的操作是无脑操作,并没有检查这把锁的归属,这样解锁不严谨。
🌵 解决——锁被别人释放了
客户端在加锁时,设置一个只有自己知道的唯一标识进去,如可以是自己的线程ID,如果是redis实现,就是SET key unique_value EX 10 NX。之后在释放锁时,要先判断这把锁是否归自己持有,只有是自己的才能释放它。
//释放锁 比较unique_value是否相等,避免误释放
if redis.get("key") == unique_value then
return redis.del("key")
但是释放锁使用的是GET + DEL两条命令,这时又会遇到原子性问题了。这时候我们就需要Lua脚本帮我们将其转为原子命令,因为Redis处理每个请求是单线程执行的,在执行一个Lua脚本时其它请求必须等待,直到这个Lua脚本处理完成,这样一来GET+DEL之间就不会有其他命令执行了。
以下是使用Lua脚本(unlock.script)实现的释放锁操作的伪代码,其中,KEYS[1]表示lock_key,ARGV[1]是当前客户端的唯一标识,这两个值都是我们在执行 Lua脚本时作为参数传入的。
//Lua脚本语言,释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
最后在redis客户端执行以下命令即可:
redis-cli --eval unlock.script lock_key , unique_value
到此,我们的流程都是非常严谨的,目前流程如下:
1、加锁时要设置过期时间SET lock_key unique_value EX expire_time NX
2、操作共享资源
3、释放锁:Lua脚本,先GET判断锁是否归属自己,再DEL释放锁
现在只剩一个问题了,确定锁的过期时间。
🌵 解决——锁的过期时间
方案:加锁时,先设置一个预估的过期时间,然后开启一个守护线程,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行续期,重新设置过期时间。
这种方案是比较好的,而且有一个库把这些工作都封装好了,它就是Redisson,Redisson是一个Java语言实现的Redis SDK客户端,在使用分布式锁时,它就采用了自动续期的方案来避免锁过期,这个守护线程我们一般叫它看门狗线程。客户端一旦加锁成功,就会启动一个watch dog看门狗线程,它是一个后台线程,会每隔一段时间(这段时间的长度与设置的锁的过期时间有关)检查一下,如果检查时客户端还持有锁key(也就是说还在操作共享资源),那么就会延长锁key的生存时间。而且使用redisson来实现分布式锁,由于其加锁,解锁都封装好了,思路也是我们一路推导过来的思路,所以使用起来非常友好。
三、redisson实现分布式锁
我们先来看看不使用redisson的话加解锁过程是有多复杂:
加锁:
//加锁之后返回锁的持有者(锁的value使用唯一时间戳标志每个客户端,保证只有锁的持有者才可以释放锁)
public static String lock(Jedis jedis, String key,Long waitEnd,String requestId) {
try {
// 1秒内数次加锁如果失败,则不断请求重新获取锁,超过1秒还没能加锁,就加锁失败(为了每个线程拥有公平的机会获取锁)
while (System.currentTimeMillis() < waitEnd) {// 1秒类不断尝试加锁(加锁之后返回锁的持有者)
String result = jedis.set(key, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, EXPIRE_TIME);
if (LOCK_SUCCESS.equals(result)) {
return requestId;
}
}
} catch (Exception ex) {
log.error("lock error", ex);
}
return null;
}
解锁:
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean unLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
是不是过程十分繁琐,现在开始我们的redisson来实现:
🌴 依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.8</version>
</dependency>
🌴 配置
package com.yixin.config;
import org.redisson.Redisson;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOError;
@Configuration
public class MyRedisConfig {
@Bean
public Redisson redisson() throws IOError{
//1、创建配置
Config config = new Config();
//2、根据Config 创建出RedissonClient示例
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return (Redisson) Redisson.create(config);
}
}
🌴 业务类使用
package com.yixin.service;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AccountService {
@Autowired
private Redisson redisson;
public void submit(){
String lockKey = "product_order";
RLock redissionLock=redisson.getLock(lockKey);
redissionLock.lock(); // 相当于 setIfAbsent(lockKey,clientId,30,TimeUnit.SECONDS);
//业务操作。。。
redissionLock.unlock(); //底层是已经封装lua脚本了
}
}
这样就完成好了,看着是不是特别友好。
如果这篇【文章】有帮助到你,希望可以给【一心同学】点个赞👍,创作不易,相比官方的陈述,我更喜欢用【通俗易懂】的文笔去讲解每一个知识点,如果有对【后端技术】感兴趣的小可爱,也欢迎关注❤️❤️❤️ 【一心同学】❤️❤️❤️,我将会给你带来巨大的【收获与惊喜】💕💕!