分布式锁
什么是分布式锁
我们知道,多个线程对共享资源的访问和操作必须是互斥的,原子的,才能线程安全的独占共享资源,但是,多个进程呢?在某些场景中,多个进程必须以互斥的方式独占共享资源,这时用分布式锁是最直接有效的。
在单机系统中,我们可以用
synchronized或Lock接口实现本地锁,保证同一JVM内的线程互斥,但是多机情况下呢?
分布式系统越来越普及,一个应用往往会部署在多台机器上,在有些场景中,为了保证数据不重复,要求在同一时刻,同一任务只在一个节点上运行,即保证某一方法同一时刻只能被一个线程执行。分布式环境下,不同机器不同进程,就需要在多进程下保证线程的安全性了。因此,分布式锁应运而生。
假设张三和李四都同时下单某件商品,由于系统是分布式部署的,下单操作被负载均衡到了两台不同的服务器上,下单系统A和下单系统B同时对数据库中的该款商品的库存进行扣减。如果此时不加任何控制就会导致库存错误,超卖等问题。
因为我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥。
这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁。
分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程并行,让程序串行执行,这就是分布式锁的核心思路。
分布式锁能够在不同的机器之间实现互斥控制,确保共享资源在高并发环境下的安全访问。
所以说,分布式锁是一种在分布式系统中用于协调多个独立节点对共享资源访问的机制。它确保在任意时刻只有一个节点能够执行特定的操作,从而避免数据不一致性和竞争条件。
分布式锁的实现方式有很多种,这里我只讲 redis 的,它轻量而且实现简单
常见分布式锁方案
- 基于数据库
- 基于 MySQL 表唯一索引或者乐观锁
- 建一张锁表,表中包含方法名等字段,在方法名字段上 唯一索引 约束,抢锁就是 insert 一条记录,成功则获取锁,若报错,则表明加锁失败,解锁就是 delete 语句删除对应的行数据来释放锁
- 或用
version字段做乐观锁:update … where version = ? - 完全利用DB现有能力,实现简单,但是开销较大,而且不支持锁重入,没有天然超时释放,容易死锁
- 基于MongoDB findAndModify 原子操作
- 加锁:执行 findAndModify 原子命令查找 document,若不存在则新增
- 解锁就是删除document
- 实现也很容易,较基于MySQL唯一索引的方案,性能要好很多,而且依旧是锁无超时自动失效机制,不能重入
- 基于 MySQL 表唯一索引或者乐观锁
- 基于分布式协调系统
- 基于 ZooKeeper
- 加锁:在
/lock目录下创建临时有序节点,加锁就是创建有序节点,判断自己是不是最小 - 释放锁:删除节点,触发下一个监听
- 它强一致性而且天然解决死锁、阻塞、公平、重入,但是部署维护重,依赖 ZK 集群,性能还不如 Redis
- 加锁:在
- 基于 ZooKeeper
- 基于缓存
- 基于 redis
- 加锁:SET lock_key unique_value NX PX 30000,一般 NX(只在不存在时设置),PX(过期时间)都要设置
- 解锁就是执行 delete 命令
- 当然解锁还可以进行进一步的优化,解锁时候执行Lua脚本,释放锁时判断值再删除,保证原子性
- 它性能高延迟低,而且相对简单,但是主从切换可能丢锁, 而且是非严格公平锁,而且不支持锁重入,不支持阻塞等待
- 基于 redis
一般情况下,Redis + Lua 脚本是最常用的了,然而,也有 Redisson 分布式锁实现方案,相比以上方案,Redisson 保持了简单易用、支持锁重入、支持阻塞等待、Lua脚本原子操作,不禁佩服作者精巧的构思和高超的编码能力。
Redis 原生分布式锁
Redis 实现分布式锁的核心是利用其单线程特性和原子命令,保证同一时间只有一个客户端能获取到锁,别忘了
- 互斥
- 超时防死锁
- 只能释放自己持有的锁,不能误删别人的锁
通过字符串,基于 SET NX EX 实现 是主流的 Redis 原生下的分布式锁实现方案
当一个客户端需要获取锁时,就向Redis设置一个锁键;释放锁时,就删除这个锁键。其他客户端只有在锁键不存在时,才能获取到锁。
加锁
它的核心逻辑特别简单,用一个唯一的 Redis key 当锁,谁能成功创建这个 key,谁就拿到锁;操作完资源后,再删除这个 key 代表释放锁。
Redis 原生加锁必须用 SET
命令的组合参数,而非用SETNX加锁,再用EXPIRE设过期时间,因为它们这两步操作不是原子的,如果SETNX成功后,服务突然宕机,EXPIRE没执行,这个锁就会永久存在,很容易导致死锁
所以说,我们需要使用 SET 命令的多参数组合命令,这样 加锁+设过期时间 就能成为一个原子操作,一般来说,我们的加锁语句这样写
1 | SET lock_key unique_value NX PX 30000 |
lock_key:锁的唯一标识,比如order:1001表示订单 1001 的锁;unique_value:客户端的唯一标识,比如 UUID + 线程 ID,用于解锁时校验;- 对于
unique_value,一般是UUID.randomUUID().toString() + ":" + Thread.currentThread().getId(),确保每个客户端 + 线程的唯一性
- 对于
NX:Only if Not Exists,只有键不存在时才设置,保证互斥;PX 30000:设置键的过期时间为 30000 毫秒(30 秒),防止死锁;- 锁过期了,任务还没执行完怎么办?这就涉及到了锁续命,Redis 原生情况下需要自己实现,所以这部分放到 Redisson 讲解,Watch Dog 机制处理了这一问题
- 返回值:成功加锁返回
OK,失败返回nil。
其中,NX保证互斥,这样才能同一时间只有一个人能加锁,EX 避免死锁,这样超时自动释放才能避免死锁,它们是一个原子操作
引入 Lua 脚本释放锁
释放锁时,直接DEL命令行吗?
这肯定不行,比如:服务A加的锁过期了,Redis自动删除了key;这时候服务B成功加锁,开始执行业务;但服务A恢复后,直接用DEL命令删除了锁,相当于服务A误删了服务B的锁,导致多个服务同时操作资源,锁彻底失效。
正确的释放流程应该是先判断是不是来自一个进程的锁、再删除,且这两个操作必须原子执行
那么,如何判断当前锁的持有者是自己?其实就是判断即锁键的值是否等于自己的客户端标识,如果是,就删除锁键;如果不是,就不做操作。
Redis中可以用Lua脚本保证这两个步骤的原子性,因为Redis会将Lua脚本作为一个整体执行,中间不会被其他命令打断
最简单的解锁脚本
1 | -- KEYS[1]是锁键,ARGV[1]是客户端标识 |
调用时,通过Redis客户端执行该脚本,以Java的Jedis为例
1 | <!-- Jedis 依赖 --> |
1 | /** |
Redis集群下锁会有丢失的情况
Redis主从架构下,分布式锁有什么风险?怎么解决?
Redis 主从架构下分布式锁的核心风险是主从切换时锁可能丢失,导致多个客户端同时持有锁,破坏互斥性。这个问题本质是 Redis 异步复制机制与锁的强一致性要求之间的矛盾。
首先,Redis 主从同步是异步的,那么就容易引发这样的问题,就好像 A 服务在主节点加锁成功了,拿到了锁,但是主节点这时候还没把这个锁给同步到其他从节点,锁同步之前主节点就宕机了,而此时某个从节点升级成了新的主节点,新的主节点因为没同步到锁信息,其他的服务就能成功加锁,这时候同一个业务就出现了两把锁,也就是脑裂问题
值得注意的是,Redis 作者 antirez 对 Redlock 也有争议,认为在极端场景下仍可能存在问题。此时,等待主从同步也可以是一个方案,它加锁后等待数据完全同步到从节点再返回加锁成功
用 Redlock(红锁)算法,它的核心思想是向多个独立的 Redis 实例请求锁,大多数成功才算加锁成功,核心逻辑是从时间入手的:
- 部署至少3个独立的Redis节点,它们之间没有主从关系
- 加锁时,同时向这3个节点发送SET NX EX命令;
- 统计成功获取锁的节点数,只有当超过半数节点加锁成功,且总耗时 < 锁有效期,才认为整体加锁成功;
- 释放锁时,向所有节点发送释放命令。那么这样,锁的有效时间 = 设定时间 - 获取锁耗时,你需要多留出一些时间给网络请求统计的开销
直接用 Redisson 组件的 RedissonRedLock 即可。
Redisson 分布式锁的实现
Redisson是一个在Redis的基础上实现的Java驻内存数据网格,它不仅提供了对分布式和可伸缩数据结构的支持,还提供了多种分布式服务
Redisson 支持单点模式、主从模式、哨兵模式、集群模式,这里以单点模式为例
一般情况下,在业务需要分布式锁的时候,我们通常会使用 Redisson 来管理 Redis 并且使用其中提供的分布式锁,而且,Redisson 为我们提供了很多现成的分布式锁来使用,而且使用起来比较贴合 JUC 的锁对象
| 锁类型 | 特点 | 适用场景 |
|---|---|---|
| RLock | 标准分布式锁,可重入 | 一般互斥场景 |
| FairLock | 公平锁,按请求顺序获取 | 需要公平性的场景 |
| MultiLock | 多锁组合,原子操作 | 需要同时锁定多个资源 |
| ReadWriteLock | 读写分离,多读单写 | 读多写少场景 |
| Semaphore | 信号量,控制并发数 | |
| PermitExpirableSemaphore | 可过期许可信号量 | 需要许可自动过期 |
| CountDownLatch | 倒计时门闩 | 等待多个任务完成 |
| SpinLock | 自旋锁,指数退避 | 高并发短锁场景 |
| FencedLock | 围栏锁,带令牌验证 | 需要防止旧锁持有者误操作 |
很多人接触 Redisson,是因为分布式锁。网上一搜,三行代码就能加锁:
1
2
3
4 RLock lock = redissonClient.getLock("myLock");
lock.lock();
// 业务逻辑
lock.unlock();看起来简单又可靠。但用了一段时间后才发现:Redisson 的能力远不止加锁。它更像是把 Java 并发包(JUC)里的那些工具,ReentrantLock、Semaphore、CountDownLatch、BlockingQueue,搬到了分布式环境,并用 Redis 作为底层支撑。
而这一切,都通过一个核心对象:
RedissonClient。
加锁
加锁、解锁Lua脚本是redisson分布式锁实现最重要的组成部分。
其中,Redisson 为我们提供了很多的现成的锁来使用,我们在这里只讲解使用 RLock 来在 Redis 处理分布式锁的情况
Redisson 实现了一个分布式的可重入锁 RLock,你可与把它理解为 ReentrantLock,而且基于看门狗机制,它支持自动续租。
它通过利用 Redis 的特性和 Lua 脚本,有效地完成锁定和解锁操作
Redisson 和 Redis 字符串原生 SET 实现分布式锁的原理上还是有差异的,Redisson 中使用 Hash 数据结构 存储锁信息,思想倒是一样,认为锁其实也是一种资源,各线程争抢锁操作对应到 redisson 中就是争抢着去创建一个 Hash 结构,Hash 结构里面内容仅包含一条键值对,键为 redisson 客户端唯一标识+持有锁线程id,值为锁重入计数;给 Hash 设置的过期时间就是锁的过期时间。
对于加锁和解锁,使用的都是 Lua 脚本,而且加锁和解锁过程中还巧妙地利用了 redis 的发布订阅功能
那么加锁流程就三步:
- 尝试获取锁,这一步是通过执行加锁Lua脚本来做
- 若第一步未获取到锁,则去订阅解锁消息,当获取锁到剩余过期时间后,调用信号量方法阻塞住,直到被唤醒或等待超时
- 一旦持有锁的线程释放了锁,就会广播解锁消息。于是,第二步中的解锁消息的监听器会释放信号量,获取锁被阻塞的那些线程就会被唤醒,并重新尝试获取锁。
加锁Lua脚本中的脚本入参如下
那么,加锁的流程就是这样
当且仅当返回nil,才表示加锁成功;客户端需要感知加锁是否成功的结果
解锁
解锁流程相对比较简单,完全就是执行解锁 Lua 脚本
脚本入参如下
我倒是真没找到两个脚本的内容,倒是解锁的流程大概如下,和 ReentrantLock 别无二致
广播解锁消息是为了通知其他因为争抢锁阻塞住的线程,从阻塞中解除,并再次去参与争抢锁。
如果把加锁解锁串接起来,而且模拟下多个线程争抢锁的情况,流程会变成这样
’
锁续命
上面我们以 Redis 的原生分布式锁的方案可以看出,它们大都满足互斥、防止死锁的特性,但是,这样的 Redis 分布式锁无法自动续期,比如,一个锁设置了1分钟超时释放,如果拿到这个锁的线程在一分钟内没有执行完毕,那么这个锁就会被其他线程拿到,可能会导致严重的线上问题
还是以 RLock 来进行详细的分析,对于这段内容,文档有这样的描述
https://redisson.pro/docs/data-and-services/locks-and-synchronizers/
基于 Valkey 或 Redis 的分布式可重入锁对象,适用于 Java 并实现 Lock 接口。使用发布/订阅(pub/sub)通道通知所有 Redisson 实例中等待获取锁的其他线程。
如果获取锁的 Redisson 实例崩溃,则该锁可能会永远处于已获取状态。为避免这种情况,Redisson 维护了锁看门狗(watchdog),只要锁持有者的 Redisson 实例存活,它就会延长锁的过期时间。默认锁看门狗超时时间为 30 秒,可通过
Config.lockWatchdogTimeout设置进行更改。
整体情况下,Redisson 锁的加锁机制如下图所示,线程去获取锁,获取成功则执行lua加锁脚本,保存数据到redis数据库,获取失败就等待订阅的自旋重试
而且可以看到,Redisson 提供的看门狗机制,能够使得 Redisson 提供的分布式锁支持锁自动续命,也就是说,如果线程仍旧没有执行完,那么 Redisson 会自动给 Redis 中的目标的锁 key 延长超时时间,这在 Redisson 中称之为 Watch Dog 机制。同时 Redisson 还有公平锁、读写锁的实现。
Redisson 提供的监控锁的看门狗机制,它的作用是在 Redisson 实例被关闭前(准确的来说是在客户端实例存活且持有锁期间),不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放,而真正的超时逻辑不会被影响。看门狗随节点宕机停止,不会因为节点宕机等其他情况影响锁的正常超时,不会影响锁本身的过期时间
顺便看看源码吧
所以说,WatchDog 机制启动之后,等待时间到了,且代码中没有释放锁操作时,WatchDog 会不断的给锁续期,默认是每10s给分布式锁的key续期 30s,WatchDog 机制最终还是通过 lua 脚本来进行延时,而且 WatchDog 通过 类似 Netty 的 Future 功能来实现异步延时
而且如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally 块中;
实例演示
我们来编写一个例子看看如何来进行加锁解锁,别忘了加 Redisson 依赖
首先是创建客户端,这里以单机模式进行示例,无论你要用锁、队列、还是分布式集合,第一步永远是创建
RedissonClient,之后的所有操作,都是它的方法
1 | private static RedissonClient createRedissonClient() { |
对于 RLcok,我们先加锁
1 | private static void reentrantLockExample(RedissonClient redissonClient) { |
Redisson 实现分布式红锁
这里以三个单机模式为例,需要特别注意的是他们完全互相独立,不存在主从复制或者其他集群协调机制。
根据 Redisson 官方文档,RedLock 对象已弃用,所以针对 Redisson 实现分布式红锁RedLock算法,我们可以使用 RLock,按照 RedLock 红锁算法,我们需要构建多个 RLock,然后根据多个 RLock 构建成一个 RedissonRedLock
1 | public class RedissonRedlockExample { |







