通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也先以 Redis 为例介绍分布式锁的实现。
基于 Redis 实现分布式锁
如何基于 Redis 实现一个最简易的分布式锁?
不论是本地锁还是分布式锁,核心都在于“互斥”。
在 Redis 中, SETNX
命令是可以帮助我们实现互斥。SETNX
即 SET if Not eXists (对应 Java 中的 setIfAbsent
方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX
啥也不做。
SETNX lockKey uniqueValue
(integer) 1
SETNX lockKey uniqueValue
(integer) 0
释放锁的话,直接通过 DEL
命令删除对应的 key 即可。
DEL lockKey
(integer) 1
为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。
选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。
为什么要给锁设置一个过期时间?
为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个 key(也就是锁) 设置一个过期时间 。
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
- lockKey:加锁的锁名;
- uniqueValue:能够唯一标识锁的随机字符串;
- NX:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
- EX:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。
这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。
你或许在想:如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!
如何实现锁的优雅续期?
对于 Java 开发的小伙伴来说,已经有了现成的解决方案:Redisson 。
Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
Redission
使用Redission实现分布式锁的基本操作
要在 Java 中使用 Redisson 实现分布式锁,首先需要在项目中引入 Redisson 的依赖。接下来,我们可以通过 RLock
对象来操作锁。以下是一个完整的示例,展示如何使用 Redisson 实现分布式锁的基本操作。
步骤 1:添加 Redisson 依赖
在 Maven 中添加以下依赖:
<dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.16.2</version> <!-- 使用最新版本 -->
</dependency>
步骤 2:配置 Redisson 客户端
首先,你需要配置 Redisson 客户端连接到 Redis 实例。如果使用的是单节点 Redis,可以如下配置:
import org.redisson.api.RedissonClient;
import org.redisson.api.RLock;
import org.redisson.config.Config;
import org.redisson.Redisson;public class RedissonLockExample {public static void main(String[] args) {// 配置 Redisson 客户端Config config = new Config();// 配置单节点 Redis 连接config.useSingleServer().setAddress("redis://127.0.0.1:6379");// 创建 Redisson 客户端RedissonClient redisson = Redisson.create(config);// 获取锁RLock lock = redisson.getLock("lock:resource");// 使用分布式锁try {// 尝试获取锁,最多等待 10 秒,锁自动释放时间为 30 秒if (lock.tryLock(10, 30, java.util.concurrent.TimeUnit.SECONDS)) {System.out.println("获得锁,开始执行任务");// 执行你的业务逻辑Thread.sleep(5000); // 模拟任务执行中} else {System.out.println("未能获得锁,任务已跳过");}} catch (InterruptedException e) {e.printStackTrace();} finally {// 确保锁最终被释放if (lock.isHeldByCurrentThread()) {lock.unlock();System.out.println("锁已释放");}}// 关闭 Redisson 客户端redisson.shutdown();}
}
解释代码
-
配置 Redisson 客户端:
Config
对象用于配置 Redisson 客户端。我们在这个例子中配置了连接到单节点 Redis 实例。你可以根据需要更改为集群配置或者哨兵配置。
-
获取锁:
- 通过
redisson.getLock("lock:resource")
获取一个分布式锁。锁的名称是"lock:resource"
,你可以根据实际需求修改为其他名称。
- 通过
-
加锁和释放锁:
- 使用
lock.tryLock()
来尝试获取锁。tryLock
方法带有超时和自动释放锁时间参数。如果获取锁成功,它会执行锁内的业务逻辑。这里设置了最多等待 10 秒,锁的自动释放时间为 30 秒。 - 锁的释放是通过
lock.unlock()
来完成的。如果锁是由当前线程持有的(lock.isHeldByCurrentThread()
),则调用unlock
来释放锁。
- 使用
-
锁的自动释放:
- Redisson 会在锁持有时间到期后自动释放锁,这避免了开发者忘记手动释放锁的情况。
-
异常处理:
tryLock
方法抛出的InterruptedException
异常会被捕获和处理。InterruptedException
一般用于处理中断信号。
Redisson 优势:
-
易用性: Redisson 提供了更为简洁和方便的 API,减少了手动操作 Redis 命令的复杂性。你可以通过 Java 对象轻松获取和释放锁,而不需要直接使用
SETNX
或SET
命令。示例:
RLock lock = redisson.getLock("lock:resource");
lock.lock();
try {// 执行业务逻辑
} finally {lock.unlock();
}
-
自动锁过期管理: Redisson 会自动管理锁的过期时间,避免了手动设置锁的过期时间的麻烦。它还提供了自动延长锁过期时间的功能,保证在业务逻辑执行过程中锁不会过期。
-
支持多种锁类型:
- 公平锁:Redisson 提供了公平锁(
RLock
)的支持,确保请求锁的顺序按照请求的顺序来分配锁,避免了“饥饿现象”。 - 读写锁:提供了读写锁(
RReadWriteLock
)的实现,允许多个线程同时进行读操作,但写操作是互斥的。 - 红锁(Redlock):Redisson 实现了
Redlock
算法,提供跨多个 Redis 实例的分布式锁,比传统的单节点锁更加可靠,尤其适用于大规模分布式系统。 - 信号量(Semaphore)、计数器(CountDownLatch) 等分布式同步工具。
- 公平锁:Redisson 提供了公平锁(
-
Redlock 实现: 对于多个 Redis 节点的分布式锁,Redisson 内建了
Redlock
算法。Redlock
通过多个 Redis 实例来增加分布式锁的可靠性,它通过在多个节点上获取锁来解决单个节点故障的问题。Redisson 会在多个 Redis 实例上分别尝试获取锁,只有当锁在大多数实例上成功获取时,才认为加锁成功。如果锁在多数节点上失败,Redisson 会回滚并释放已获得的锁。
-
事务性操作: Redisson 支持 Redis 的事务(
MULTI
/EXEC
),这意味着你可以将多个 Redis 命令打包成一个事务进行执行,以保证操作的一致性。 -
可扩展性: Redisson 提供了集群和哨兵支持,能够应对 Redis 集群的高可用和扩展需求。它能够自动感知 Redis 集群拓扑的变化,保证客户端在面对 Redis 实例宕机时仍然能够正确工作。
Redisson 与 Redis 原生分布式锁对比:
特性 | Redis 原生锁 | Redisson 锁 |
---|---|---|
易用性 | 需要手动实现命令和逻辑 | 提供简洁的 Java API |
锁过期管理 | 需要手动设置和处理锁的过期时间 | 自动管理锁的过期时间及自动续期 |
多种锁类型 | 只能实现基础锁(SETNX) | 支持公平锁、读写锁、Redlock 等 |
Redlock 支持 | 需要手动实现 | 内建对 Redlock 的支持 |
事务性操作 | 无 | 支持 Redis 事务和多操作原子性 |
集群和高可用 | 需要手动处理高可用和集群 | 自动处理 Redis 集群和哨兵拓扑变化 |
开发复杂度 | 较高,需要管理 Redis 连接和逻辑 | 较低,封装了 Redis 的连接和操作细节 |
Redission续期机制
Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
看门狗名字的由来于 getLockWatchdogTimeout()
方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30 秒(redisson-3.17.6)。
//默认 30秒,支持修改
private long lockWatchdogTimeout = 30 * 1000;public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {this.lockWatchdogTimeout = lockWatchdogTimeout;return this;
}
public long getLockWatchdogTimeout() {return lockWatchdogTimeout;
}
renewExpiration()
方法包含了看门狗的主要逻辑:
private void renewExpiration() {//......Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {@Overridepublic void run(Timeout timeout) throws Exception {//......// 异步续期,基于 Lua 脚本CompletionStage<Boolean> future = renewExpirationAsync(threadId);future.whenComplete((res, e) -> {if (e != null) {// 无法续期log.error("Can't update lock " + getRawName() + " expiration", e);EXPIRATION_RENEWAL_MAP.remove(getEntryName());return;}if (res) {// 递归调用实现续期renewExpiration();} else {// 取消续期cancelExpirationRenewal(null);}});}// 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);ee.setTimeout(task);}
默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。
Watch Dog 通过调用 renewExpirationAsync()
方法实现锁的异步续期:
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,// 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认)"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +"redis.call('pexpire', KEYS[1], ARGV[1]); " +"return 1; " +"end; " +"return 0;",Collections.singletonList(getRawName()),internalLockLeaseTime, getLockName(threadId));
}
可以看出, renewExpirationAsync
方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。
我这里以 Redisson 的分布式可重入锁 RLock
为例来说明如何使用 Redisson 实现分布式锁:
// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lock");
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁
lock.unlock();
只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。
// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
lock.lock(10, TimeUnit.SECONDS);
如果使用 Redis 来实现分布式锁的话,还是比较推荐直接基于 Redisson 来做的。
如何实现可重入锁?
所谓可重入锁指的是在一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入 ,而无需重新获得锁。像 Java 中的 synchronized
和 ReentrantLock
都属于可重入锁。
不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。
可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。
实际项目中,我们不需要自己手动实现,推荐使用我们上面提到的 Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。
Redis 如何解决集群情况下分布式锁的可靠性?为了避免单点故障,生产环境下的 Redis 服务通常是集群化部署的。Redis 集群下,上面介绍到的分布式锁的实现会存在一些问题。由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。