为什么需要“分布式锁”?
先来看看传统单体应用下的锁机制:
synchronized(lockObject) { // 临界区 }
在一个 JVM 中,这样的锁已经能完美地解决多线程并发访问的问题。但是,如果我们引入分布式系统架构,比如:
-
部署了多个服务实例;
-
实例之间通过负载均衡访问;
-
每个实例有自己独立的内存空间;
这时,问题就来了:
❗ Java 自带的锁是进程内的锁,多个实例间根本互不认识!
比如你用 synchronized
限制了一个商品的库存更新逻辑,A服务器上加锁了,B服务器却压根不知道,自顾自地也去更新——最终库存乱了。
分布式锁的作用
分布式锁解决的核心问题就是:
在分布式系统中,同一时间内,多个系统/服务节点对共享资源的访问要有“互斥”性。
常见场景:
-
商品秒杀时防止超卖;
-
同一个定时任务只允许一个节点执行;
-
控制接口幂等性;
-
控制资源竞争,如分布式爬虫任务
分布式锁的几种实现方式
-
zookeeper
-
Redis
-
数据库
Redisson,zookeeper分布式锁的技术对比
-
性能区别
-
redis基于内存,zk是基于磁盘的,所以性能上,redis要比zk好一点
-
-
自动释放
-
zk的锁是基于客户端和服务端来保证的,一旦连接断了,锁会自动被释放。而redis是需要自己主动加锁和解锁的,除非达到了超时时间,否则不会自动释放
-
zk出现死锁的风险低一点
-
-
一致性和可用性要求
-
zk是保证强一致性的(CP) 因为zookeeper主从数据同步是采用同步的方式,会导致主节点同步时被阻塞住了,无法返回客户端响应
CAP的C和事务的C有什么区别
cap的C是强调所有节点数据必须相同
事务的C是强调数据库处理前后结果应与其所抽象的客观世界中真实状况保持一致。这种一致性是一种需要管理员去定义的规则。管理员如何指定规则,数据库就严格按照这种规则去处理数据。
-
redis强调可用性AP
-
-
总结
-
对一致性要求高点,选择zk
-
对可用性要求高点,选择redis
-
-
经验之谈
-
尽量用redis,不要用zk
-
多实例场景下,RedLock和zk机制很像,都是通过半数以上提交来实现的
-
redis比较方便
-
业务上做好幂等校验就行了,业务没问题就行
-
而且用分布式锁时性能要求肯定高,如果不高的话,你直接用数据库的悲观锁就好了。没必要用分布式锁
为什么?
-
如果你实在是接受不了短暂的不一致性,重复加锁的问题,or项目强依赖于zk
-
zk和redis分布锁哪个对死锁友好
死锁友好:能尽可能地减少锁的时长,提供自动释放锁机制
zk,因为它如果客户端崩溃了,它是会自动释放锁的,因为它加锁是基于网络连接的,崩溃了,连接就断掉了,锁节点就自动释放了
redis虽然有key过期机制,但它在客户端崩溃后,得等到key自动过期了才会释放
如何用Redis实现乐观锁
所谓乐观锁,其实就是基于CAS的机制,本质就是compareAndSwap ,就是要知道一个key在修改前的值,去比较
可以依赖watch命令+事务
不只setnx,Redisson的redis分布式锁实现
-
加锁使用lua脚本,不同于原生的setnx
-
数据类型相比于setnx:采用hash,在里面有个count字段,表示锁的持有情况,主要是为了实现可重入锁
-
过期机制:默认30s,如果没主动设置续期时间,看门狗机制会生效,每隔10s检查一次(本质就是开一个定时任务去检查),为其续期,将锁的过期时间重置为30s
-
为了解决分布式问题,解决方法引入RedLock解决方案(有争议)
tryLock(100, 10, TimeUnit.SECONDS) 和 lock.lock(xxxtime) 这种就是不会续期的,因为指定了过期时间
redisson 看门狗机制的底层
lock的时候如果不指定过期时间,它默认的过期时间是30s嘛
本质就是开一个定时任务每隔10s去检查锁是否存在,如果还存在就进行续期(重置为30s)
如果我们手动设置了过期时间就不会触发看门狗机制
什么时候会停止续期
-
手动设置超时时间
-
解锁成功时
-
或者调用unlock时,即使因为某种原因没解锁成功,也会停止续期
//解锁 CompletionStage<Boolean> future = unlockInnerAsync(threadId); //解锁后的逻辑处理 CompletionStage<Void> f = future.handle((opStatus, e) -> { cancelExpirationRenewal()..... });
执行解锁逻辑后会执行cancelExpirationRenewal 方法(EXPIRATION_RENEWAL_MAP.remove()删除一个键值对)停止续期
为什么解锁失败也能取消续期呢
-
它是采用了CompletionStage的handle方法,它就是能允许你不管成功还是失败,抛异常,都会执行handle里定义的逻辑
如果不进行解锁操作,理论上它是会一直续期下去的
-
-
宕机后,因为Redisson的续期是基于Netty的时间轮的,并且操作都是基于jvm的。所以,宕机后,续期任务就没了(不续期的话,当锁的 30 秒超时时间 到了,Redis 自动删除该锁,释放资源)。这样也能一定程度上避免机器挂了而锁一直没释放的死锁问题
watchdog一直续期,那客户端挂了怎么办?
客户端挂了它就会停止续期的,因为续期是Redisson实现的,是jvm的后台线程
不会一直续期的,然后就等锁自动过期了。
Redisson的lock和tryLock有什么区别
总的来说,tryLock实现的是一个非阻塞锁(获取失败会直接返回如果没有指定waitTime),Lock则是一个阻塞锁(获取不到锁会一直阻塞)
redis基本的分布式锁实现
基于redis
1)setnx,设置锁,设置过期时间,保存线程标识
为什么setnx可以
首先它是一个原子命令
它可以实现key不存在才插入,来模拟上锁过程
px/nx 来设置过期时间,防止线程拿到锁后异常,锁没法解开,导致的死锁
需要线程唯一标识(value)来区分不同客户端的加锁操作,防止释放锁时导致的
2)释放锁的时候要先判断锁是否为当前线程持有的,需要用lua脚本来保证解锁的原子性
释放错锁的问题
1)拿到锁,判断锁是不是自己的,释放锁,这三步并不具备原子性,所以需要写在lua脚本里
如何实现Redisson可重入锁
由于自己写的锁使用了string的setnx,只要方法1上锁,方法1调用的方法二就没法再上锁,会直接返回false。
为啥?
redisson的可重入锁,使用了hash结构,key为lock, field为线程名,value为数字
-
加锁
-
方法一加锁后,value=1,方法一调用方法二
-
方法二加锁,Redisson会判断field是否为同一线程,若是,value++,value为2
-
同理....
-
-
解锁
-
每个方法执行完毕时依次value--
-
直到value为0,此时会删除redis的该数据,锁完全释放
-
如何实现锁的可重试/Redisson实现阻塞锁
redis的发布订阅机制实现 等待,唤醒,获取锁失败的重试机制
-
获取锁失败不是直接重试or返回,而是订阅一下,然后等待
-
当获取锁成功的线程释放锁后,会发布一条消息
-
其他线程得到该消息,就会去重新获取锁,获取失败,则继续等待
-
但也不是无限等待,超过一定时间,它就不会继续等而是返回false
redis的pub/sub只实现广播机制
业务没执行完成,锁过期了咋办/锁的续约机制
WatchDog机制来实现锁的续期,每隔一段时间,重置锁的超时时间
-
会创建一个守护线程,当锁快到期但是业务线程没执行完时为锁增加时间
-
当然也不是无限加,会有个上限
只要我们没主动给设置锁的超时时间,WatchDog才会续期,如果自己设置了超时时间,那么它是不会给你续期
怎么使用?
Redisson分布式锁的业务最佳实践
不要设置过期时间,利用Redisson的看门狗机制,但要保证代码块里的任务不会出现问题
https://juejin.cn/post/6844904134764658702
锁的主从一致性问题
redis为了保证高可用,会使用主从架构。
会出现以下问题
-
获取锁时,往redis主节点setnx。主节点加锁后宕机,没更新到从节点
-
某个从节点成为主节点后,但是新主节点没有锁信息。此时其他线程还是加锁
解决方案:Redisson联锁方案,使用多主or多主多从
redLock红锁
联锁方案还存在问题
-
所有节点都得上锁,才能获取锁成功。那么某个节点网络原因,就会导致加锁时间长,加锁失败
-
如果主节点宕机,也会加锁失败
使用redLock,它规定只要半数以上加锁成功,就算加锁成功。并且严格规定了加锁时间,该段时间内加锁失败,直接返回。
缺点:部署要求高,多主部署,不推荐哈
像我们常用的Redisson框架就已经把它废弃掉了
主要原因是官方不认可,虽然是redis创始人提出来的,但它也推荐不要在严格一致性的分布式环境下使用它
替代方案
业界没公认的方案
以下是我个人的一些见解
-
一个就是可能说出来会被喷啊,就是使用单实例的redis锁。虽然会面临单节点故障问题
-
业务方面做好唯一性校验
-
使用强一致性组件来实现分布式锁。如zookeeper,这些组件提供了更强大的一致性保证(采用raft等一致性算法)。