目录
5.4 redisson实现的分布式锁能解决主从一致性的问题吗?
1.为什么不能使用JVM本地锁?
JVM本地锁是单机锁,只对单个JVM有效
2. 分布式锁使用的场景
集群情况下的定时任务、抢单、幂等性场景
例如:项目如果部署到多台服务器,则定时任务启动时,所有服务器都会执行定时任务,会造成性能资源浪费和写入脏数据等问题,为了控制定时任务在同一时间只有 1 个服务器能执行。
在某些场景中,多个进程必须以互斥的方式独占共享资源,这时用分布式锁是最直接有效的。
随着技术快速发展,数据规模增大,分布式系统越来越普及,一个应用往往会部署在多台机器上(多节点),在有些场景中,为了保证数据不重复,要求在同一时刻,同一任务只在一个节点上运行,即保证某一方法同一时刻只能被一个线程执行。在单机环境中,应用是在同一进程下的,只需要保证单进程多线程环境中的线程安全性,通过 JAVA 提供的 volatile、ReentrantLock、synchronized 以及 concurrent 并发包下一些线程安全的类等就可以做到。而在多机部署环境中,不同机器不同进程,就需要在多进程下保证线程的安全性了。因此,分布式锁应运而生。
以往的工作中看到或用到几种实现方案,有基于zk的,也有基于redis的。由于实现上逻辑不严谨,线上时不时会爆出几个死锁case。那么,究竟什么样的分布式锁实现,才算是比较好的方案?
结合我自己的项目说明 为什么使用分布式锁(仅供参考)
1.单机锁,只对单个JVM有效
2.项目如果后期有多台服务器,则定时任务启动时,所有服务器都会执行定时任务,会造成性能资源浪费和写入脏数据等问题,为了控制定时任务在同一时间只有 1 个服务器能执行。以及使用redisson的读写锁保证双写一致性问题
3. 实现分布式锁的三种方式
基于数据库实现分布式锁;
基于缓存(Redis等)实现分布式锁;
基于Zookeeper实现分布式锁;
3.1 基于数据库实现分布式锁
3.1.1 核心思想
基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
3.1.2 案例
(1)创建一个表:
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
`desc` varchar(255) NOT NULL COMMENT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
(2)想要执行某个方法,就会把这个方法名也作为数据插入表中
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');
因为我们在
method_name
字段上创建了一个唯一索引uidx_method_name
。这个索引确保了任何时候都只有一个记录可以拥有给定的method_name
值。(即method_name方法名具有唯一性,表中不能重复只能存在一条)
(3)成功插入则获取锁,执行完成后删除对应的行数据释放锁:
delete from method_lock where method_name ='methodName';
注意:这只是使用基于数据库的一种方法,使用数据库实现分布式锁还有很多其他的玩法!
详解上述过程:
创建表
method_lock
: 这个表是用来存储当前被锁定的方法名。表中包含四个字段:id
、method_name
、desc
和update_time
。
id
:这是一个自增的主键,用于唯一标识表中的每一条记录。method_name
:这是一个字符串类型的字段,用于存储想要锁定的方法名。在这个字段上创建了一个唯一索引uidx_method_name
,这意味着同一个方法名不能在同一时间被两个不同的事务获取锁。desc
:这是一个字符串类型的字段,可以用来存储关于这个锁的额外描述信息,比如哪个客户端或者进程获取了锁。update_time
:这是一个时间戳字段,用于记录行数据的最后更新时间。每次行数据被更新时,这个字段会自动设置为当前时间。唯一索引
uidx_method_name
: 在method_name
字段上创建了一个唯一索引uidx_method_name
。这个索引确保了任何时候都只有一个记录可以拥有给定的method_name
值。这是实现分布式锁的关键,因为当多个进程或线程尝试插入相同的method_name
时,只有第一个成功插入的进程或线程会获取到锁,其他的则会失败,因为它们无法插入重复的method_name
。获取锁的过程:
- 当一个分布式系统的实例想要执行某个方法时,它会尝试向
method_lock
表中插入一条新的记录,其中method_name
字段设置为想要执行的方法名。- 如果这个插入操作成功了,那么这个实例就认为它已经获取了锁,可以安全地执行方法。
- 如果插入操作失败了(因为唯一索引约束冲突),那么这个实例就知道另一个实例已经持有了锁,它需要等待或者执行其他的逻辑。
释放锁的过程:
- 一旦方法执行完成,持有锁的实例会从
method_lock
表中删除对应的记录。- 这样,其他等待的实例就可以再次尝试获取锁。
3.1.3 基于数据库实现分布式锁存在的问题
1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;
2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。
5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。
3.2基于Redis的实现方式
redis实现分布式锁redis的setnx命令。setnx是SET if not exists(如果不存在,则 SET)的简写。
3.2.1 选用Redis实现分布式锁原因
(1)Redis有很高的性能;
(2)Redis命令对此支持较好,实现起来比较方便
3.2.2 使用命令介绍及实现
(1)获取锁 SETNX
SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
例如:
# 添加锁,NX是互斥、EX是设置超时时间
SET lock value NX EX 10
(2)设置过期时间 expire
expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
(3)释放锁 delete
delete key:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。
3.2.3 实现思想
(1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
(2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
(3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放
注意使用redis命令实现的分布式锁不可重入,redisson实现的分布式锁可以
3.2.4 使用redission实现分布式锁 ★
1. redission介绍
Redisson是一个java操作Redis的客户端,提供了大量的分布式数据集来简化对Redis的操作和使用,可以让开发者像使用本地集合一样使用Redis,完全感知不到Redis的存在。
2. redis实现分布式锁如何合理的控制锁的有效时长?
根据业务执行时间预估,给锁续期
但是这实现起来太麻烦,还需要开一个线程去监听业务的执行情况,好在这不用我们自己实现,可以使用redission客户端的watchdog看门狗机制实现监听
3. Watch dog (看门狗)机制
主要用于锁续费服务。只要当一个线程A一旦加锁成功,就会启动一个 Watch Dog。也就是说
leaseTime
必须是 -1 才会开启 Watch Dog 机制,这个线程每隔(releaseTime/3)的时间就为A续期时间,即重置过期时间。一般这个续期时间为锁的有效时间的一半,也可以自己额外设置。在该锁有效期间,如果有其他线程想要试图加锁,那么就会不断while循环重试。
看门狗机制的锁需要最后手动释放释放,释放完成后还需要通知看门狗线程。
需要注意的是,加锁、设置过期时间等操作都是基于lua脚本完成的,因为能保证操作的原子性
代码模板:
public void redisLock() throws InterruptedException{
RLock lock = redissonClient.getLock("heimalock");
try{
boolean isLock = lock.tryLock(10, TimeUnit.SECONDS);
if(isLock){
System.out.println("执行业务……");
}
}finally{
lock.unlock();
}
}
5. redission实现分布式锁的执行流程
redisson底层 加锁、设置过期时间等操作都是基于lua脚本完成
lua脚本最大的作用就是能够调用redis多条命令来保证命令执行的原子性
6. 可重入性
注意使用redis实现的分布式锁不可重入,使用redission实现的分布式锁可重入
锁的可重入性指的是同一个线程在持有锁的情况下,能够多次获取该锁而不会发生死锁或阻塞的情况。可重入锁的一个典型应用场景是在一个方法中调用另一个加锁的方法,譬如以下代码。如果方法A在获取锁后调用了方法B,而方法B也需要获取同一个锁,那么如果锁是可重入的,方法B可以直接获取到锁,而不会因为锁已被方法A持有而发生死锁。
public void A(){
RLock lock = redissonClient.getLock(“heimalock");
boolean isLock = lock.tryLock();
//执行业务
B();
//释放锁
lock.unlock();
}
public void B(){
RLock lock = redissonClient.getLock(“heimalock");
boolean isLock = lock.tryLock();
//执行业务
//释放锁
lock.unlock();
}
重入的逻辑(原理)
根据线程id判断是否是同一个线程
基于redisson实现的分布式锁是拥有可重入性的。
其中的KEY一般称为大键,可以根据自己的业务来命名。
field一般称为小键,是当前线程的唯一标识。
value记录着当前线程重入的次数。获取锁是value+1,释放时-1,value=0删除锁信息
7.主从一致性
对于多线程的主从一致性,实际上redisson没办法很好地保障。
像是原本如下运行,一个主库,两个从库,且java应用程序对主库进行加锁。
因为某种原因,主库短暂宕机,此时根据哨兵模式选举了下面的从库为新的主库。
此时又有个新的java应用来对这个新的主库进行加锁。那么就会出现两个线程同时持有一把锁的情况。
redlock红锁(很少使用)
为此,redisson提出了一个叫RedLock,红锁。
RedLock(红锁):不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁(n / 2 + 1)避免在一个redis实例上加锁。即在超过一半的实例上(redis节点数)进行加锁。(例如上面图中有3个节点,若使用redlock红锁则应该在(n / 2 + 1)即3/2 +1=2.5,即至少2个节点上加锁)
但是这种机制实现复杂,性能较差,运维起来也很繁琐。所以实际开发中很少用。
AP思想和CP思想
综上,对于多线程的主从一致性,实际上redis没办法很好地保障。因为redis本身是AP思想。优先保证的是高可用性,(我们可想办法在最终保证数据一致性)
redis:AP思想(可用性与分区容忍):
AP思想注重系统的可用性和分区容忍性,即在遇到网络分区或节点故障的情况下,系统仍然能够提供服务并保持可用性。这意味着系统可以容忍一定程度的数据不一致或冲突,以保证用户的访问体验。如果想要强一致性,建议使用CP思想的zookeeper。
zookeeper:CP思想(一致性与分区容忍):
CP思想注重系统的一致性和分区容忍性,即在分布式环境中保持数据的一致性,并且即使发生网络分区,系统也能保持一致性。这种设计思想追求强一致性,确保所有节点中的数据都是一致的,但可能会导致一些节点不可用。
3.3 基于ZooKeeper的实现分布式锁
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。基于ZooKeeper实现分布式锁的步骤如下:
(1)创建一个目录mylock;
(2)线程A想获取锁就在mylock目录下创建临时顺序节点;
(3)获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
(4)线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
(5)线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。
这里推荐一个Apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。
优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。
缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。
4.实现分布式锁的 3种常见方案对比
分类 | 方案 | 实现原理 | 优点 | 缺点 |
基于数据库 | 基于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 | 同上;实现逻辑上也更严谨,除了单点问题,生产环境采用用这种方案,问题也不大。 | 不支持锁重入,不支持阻塞等待 |
5. 面试题
5.1 Redis分布式锁如何实现 ?
分布式锁主要用于集群情况下的定时任务、抢单、幂等性等场景,而我的项目中使用了定时任务来缓存主页userlist信息,后来我考虑到如果我的项目部署到多台服务器,则定时任务启动时,所有服务器都会执行定时任务,会造成性能资源浪费和写入脏数据等问题,为了控制定时任务在同一时间只有 1 个服务器能执行。所以我使用了redisson实现分布式锁
Redis分布式锁的实现依赖于redis中的setnx(SET if not exists)命令
由于redis的单线程的,用了命令之后,只能有一个客户端对某一个key设置值,在没有过期或删除key的时候是其他客户端是不能设置这个key的
5.2 如何控制Redis实现分布式锁有效时长呢?
候选人:嗯,的确,redis的setnx指令不好控制这个问题,我们当时采用的redis的一个框架redisson实现的。
在redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间,当锁住的一个业务还没有执行完成的时候,在redisson中引入了一个看门狗机制,就是说每隔一段时间就检查当前业务是否还持有锁,如果持有就增加加锁的持有时间,当业务执行完成之后需要使用释放锁就可以了
还有一个好处就是,在高并发下,一个业务有可能会执行很快,先客户1持有锁的时候,客户2来了以后并不会马上拒绝,它会自旋不断尝试获取锁,如果客户1释放之后,客户2就可以马上持有锁,性能也得到了提升。
5.3 redisson实现的分布式锁是可重入的吗?
候选人:嗯,是可以重入的。这样做是为了避免死锁的产生。这个重入其实在内部就是判断是否是当前线程持有的锁,如果是当前线程持有的锁就会计数,如果释放锁就会在计算上减一。在存储数据的时候采用的hash结构,大key可以按照自己的业务进行定制,其中小key是当前线程的唯一标识,value是当前线程重入的次数
5.4 redisson实现的分布式锁能解决主从一致性的问题吗?
候选人:这个是不能的,比如,当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。
我们可以利用redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。
但是,如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁
5.5 如果业务非要保证数据的强一致性,这个该怎么解决呢?
候选人:redis本身就是支持高可用的,做到强一致性,就非常影响性能,所以,如果有强一致性要求高的业务,建议使用zookeeper实现的分布式锁,它是可以保证强一致性的。
文章参考:
https://www.cnblogs.com/liuqingzheng/p/11080501.html
https://blog.youkuaiyun.com/zhiaidaidai/article/details/135064906