目录
随着系统架构逐渐从单机走向分布式,如何在分布式环境下保证线程同步执行成为一个不可忽视的问题。分布式锁作为解决这一问题的关键技术,为分布式系统中的资源共享和任务协调提供了重要支持。选择合适的分布式锁实现方式,可以有效提高系统的可靠性和一致性,确保业务逻辑的正确执行。历 史相关文章回顾:
一、简单了解分布式锁
在多线程环境下,为了保证同一时间只有一个线程能够执行某段代码,Java 提供了 synchronized
关键字和 ReentrantLock
类作为本地锁的解决方案。这些机制在单个应用或单个 JVM 实例中运行良好,确保了同一进程内的线程同步。但是,随着分布式架构的广泛应用,应用程序通常运行在多个节点上,并且每个节点都有多个线程同时处理任务。在这种情况下,传统的本地锁机制已经无法满足分布式环境下的同步需求。
(一)分布式锁:应对分布式环境的同步挑战
在分布式系统中,应用程序可能运行在多个物理或虚拟的节点上,这意味着相同的资源可能会被不同节点上的多个线程同时访问。为了确保这些线程在不同节点上同步执行,防止资源竞争和数据不一致问题,我们需要使用一种能够跨节点的同步机制——分布式锁。
分布式锁是一种用于控制在分布式环境中,某个共享资源在同一时刻只能被一个节点或线程使用的机制。它类似于传统的本地锁,但具有跨节点的协调能力。分布式锁通常由外部的分布式系统组件(如 Redis、Zookeeper、Tair 等)来实现,这些组件提供了高可用的锁服务,确保即使在节点故障或网络分区的情况下,锁的状态依然能够保持一致。
(二)分布式锁的实现方式
分布式锁可以通过多种方式实现,每种方式都有其适用的场景和优缺点。以下是几种常见的分布式锁实现方式(简单直接的实现方式见:分布式锁的几种简单实现方式分析):
-
基于 Redis 的分布式锁: Redis 是一种常用的内存数据库,可以通过
SETNX
命令(Set if Not Exists)来实现分布式锁。Redis 锁具有高性能、低延迟的优点,适用于大部分需要快速锁定的场景。通过设置锁的过期时间,可以防止死锁问题。 -
基于 Zookeeper 的分布式锁: Zookeeper 是一个分布式协调服务,提供了严格的一致性保证。它通过创建临时有序节点实现分布式锁。Zookeeper 锁的优点是可靠性高,适用于对数据一致性要求高的场景,如分布式事务。
-
基于数据库的分布式锁: 可以利用数据库的行级锁来实现分布式锁,通过在数据库表中插入一条记录或更新记录的状态来表示加锁。虽然这种方式实现简单,但性能较低,适用于锁争用不激烈的场景。
-
基于 Tair 的分布式锁: Tair 是一种高性能分布式缓存系统,也支持分布式锁功能,适用于需要高并发和高可用的场景。
(三)分布式锁的使用场景
分布式锁在分布式系统中有广泛的应用,典型的使用场景如:
- 分布式任务调度: 确保某个任务在某个时间点只由一个节点执行,防止重复调度。
- 分布式事务控制: 在多服务参与的分布式事务中,确保事务的各个阶段按照预定顺序执行。
- 资源竞争: 防止多个节点同时修改相同的资源(如数据库记录、缓存数据)导致的数据不一致问题。
(四)分布式锁需满足的特点
特点 | 描述 |
---|---|
互斥性 | 确保同一时刻只有一个线程能持有锁,防止多个节点或线程对共享资源的并发访问,保证资源的独占使用。 |
可重入性 | 允许同一节点上的同一个线程在已持有锁的情况下,能够再次成功获取该锁,避免锁重入时产生死锁。 |
锁超时 | 通过为锁设置过期时间,防止因线程异常或故障未释放锁而导致的死锁情况,确保系统的稳定性和健壮性。 |
高性能与高可用性 | 锁的加锁与解锁操作需要高效,以满足高并发需求,并且要确保在节点故障或网络分区等情况下,锁服务依然可用,保障系统的持续运行。 |
阻塞与非阻塞性 | 支持锁的阻塞和非阻塞模式。在阻塞模式下,线程在锁不可用时等待锁的释放,并在锁可用时及时被唤醒;在非阻塞模式下,线程可以立即返回继续执行其他逻辑。 |
可扩展性 | 锁机制能够随着系统规模的增长而扩展,支持更多节点和更高并发量,保持系统的性能和可靠性。 |
二、Redis 实现分布式锁的基本思路(粗糙实现版本)
Redis 是一个高性能的键值存储系统,适合用于实现分布式锁,因为它能够在高并发的场景下提供快速的读写操作。借助 Redis 的 SET
命令及其 NX
(不存在则插入)参数,我们可以构建一个简单的分布式锁机制。
(一)实现步骤
-
获取锁:通过
SET key value NX EX seconds
命令尝试获取锁。如果key
不存在,则插入成功,并设置过期时间(EX
参数),表示锁定成功;如果key
已存在,则表示锁已经被其他客户端持有,获取锁失败。 -
解锁:当持有锁的线程完成任务后,可以通过
DEL key
命令删除该key
来释放锁,从而让其他等待锁的线程有机会获得锁。 -
防止死锁:为了防止死锁,在获取锁时设置一个合理的过期时间(TTL),即使由于程序异常未能显式释放锁,锁也会在 TTL 到期后自动释放。
(二)基本代码展示
// 尝试获取锁
if (set(key, 1, "NX", "EX", 30)) {
try {
// 执行需要加锁的业务逻辑
} finally {
// 释放锁
del(key);
}
}
(三)上述实现的缺陷
尽管这种方法简单易用,但它存在几个严重的问题,使得其无法成为一个健壮的分布式锁实现:
-
非原子性操作:锁的获取与锁的过期时间设置不是原子操作。假设在
SETNX
成功后,但在设置过期时间之前,程序崩溃或出现异常,那么锁将一直存在,导致其他线程无法获取锁,从而产生死锁。 -
锁误解除:当持有锁的线程被阻塞或出现延迟,锁的过期时间到期后自动释放,此时如果有其他线程获取了同一个锁,原本持有锁的线程执行完毕后仍然会执行
DEL
操作,从而误解锁,破坏了其他线程的业务逻辑。 -
业务超时自动解锁导致并发问题:由于业务执行时间不确定,如果锁的 TTL 到期,锁会自动释放,可能导致多个线程同时执行临界区代码,从而引发并发问题。
-
不可重入性:该实现不支持可重入性,即同一线程无法多次获得同一把锁。如果线程因递归或重复调用需要再次获取锁,会因为锁已经存在而获取失败。
三、健壮分布式锁聚焦
(一)误删问题的分析
问题说明
在分布式锁的实现中,存在一种潜在的风险,即线程在解锁时误删了其他线程持有的锁。具体情况如下:
- 线程1持有锁:线程1成功获取了锁并执行了一段业务逻辑。
- 线程1阻塞:线程1在执行过程中由于某种原因被阻塞,未能及时释放锁,导致锁的TTL(过期时间)到期,锁自动释放。
- 线程2获取锁:此时,线程2尝试获取锁,并成功获得了已经释放的锁。
- 线程1解除阻塞:线程1解除阻塞,继续执行并尝试释放锁。
- 误删锁:由于线程1并不知道锁已经由线程2重新获取,因此直接执行
DEL
操作,误删了属于线程2的锁。
解决方案
为了解决上述问题,可以在锁中存储一个唯一标识符(例如线程ID或UUID),并在释放锁时检查该标识符是否匹配,从而确保只有持有锁的线程才能成功释放锁。
具体实现步骤
-
获取锁时存储标识符:在获取锁时,使用 Redis 的
SET key value NX PX milliseconds
命令,其中value
是一个唯一标识符(如线程ID或UUID)。这样可以确保在锁存储时记录锁的所有者信息。 -
释放锁时校验标识符:在释放锁时,先检查当前锁的值是否与线程的唯一标识符匹配。只有当标识符匹配时,才执行
DEL
操作以释放锁。
具体代码实现
String threadId = UUID.randomUUID().toString(); // 生成唯一标识符
if (set(key, threadId, "NX", "EX", 30)) {
try {
// 执行业务逻辑
} finally {
if (threadId.equals(get(key))) {
del(key); // 释放锁
}
}
}
同时,这种方式也能够将分布式锁改造成可重入的分布式锁,在获取锁的时候判断一下是否是当前线程获取的锁,锁标识自增便可。
(二)原子性保证
在分布式锁中,SETNX
和 EXPIRE
操作不是原子性的,可能导致死锁等并发问题。为了解决这个问题,我们可以使用 Lua 脚本来确保这些操作的原子性。
问题场景
- 非原子性操作:
SETNX
成功后,如果EXPIRE
操作未执行(例如由于服务器故障或网络问题),锁可能没有超时时间,从而导致死锁。 - 误删锁:线程在判断标识符一致后,如果因阻塞导致锁过期,其他线程可能获取锁,而原线程仍然执行解锁操作,误删了新的锁。
解决方案:使用 Lua 脚本
Lua 脚本可以将多个 Redis 操作封装为一个原子操作,确保获取锁、设置过期时间、判断标识符和删除锁的操作按预期执行。Lua 脚本示例:
设置锁并设置过期时间(原子操作)
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1) then
return 0; -- 获取锁失败
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2])); -- 设置过期时间
return 1; -- 获取锁成功
释放锁(原子操作)
if (redis.call('get', KEYS[1]) == ARGV[1]) then
return redis.call('del', KEYS[1]); -- 释放锁
end;
return 0; -- 当前线程不是锁持有者
Java 调用 Lua 脚本
通过 Java 调用 eval
方法执行上述 Lua 脚本,确保 Redis 操作的原子性:
// 获取锁
Object result = jedis.eval(luaScriptForSet,
Collections.singletonList(key),
Arrays.asList(threadId, "30"));
// 释放锁
Object result = jedis.eval(luaScriptForDel,
Collections.singletonList(key),
Collections.singletonList(threadId));
使用 Lua 脚本可以确保分布式锁的关键操作在 Redis 中实现原子性,避免了由于非原子性操作导致的死锁和误删锁等并发问题,从而提升系统的可靠性。
(三)超时自动解锁
超时自动解锁的问题虽然在某些场景下不可避免,但可以通过一些机制来缓解,比如延长 TTL 或者增加锁续期机制。
问题描述
在分布式锁的使用中,如果线程的执行时间超过了锁的 TTL(过期时间),锁会自动释放,这时其他线程可能会获取到锁,而原线程还未执行完毕,可能导致数据不一致或业务逻辑错误。
传统解决方案
延长 TTL:可以通过将 TTL 设置得足够长来避免这种情况,但这可能导致其他线程长时间等待锁,特别是在发生意外宕机时,下一个线程将会阻塞很长时间,这并不优雅。
改进方案:锁续期机制
为了更优雅地解决这个问题,可以给获取锁的线程单独开一个守护线程,检测当前线程的运行情况。当发现锁的 TTL 即将到期时,守护线程可以自动为该锁续期,从而保证业务逻辑能够顺利执行完毕。
具体实现步骤
-
启动守护线程:在获取锁后,启动一个守护线程定期检查锁的 TTL 是否即将过期。
-
续期机制:守护线程在锁即将过期时,自动向 Redis 发送
PEXPIRE
命令,延长锁的有效时间。 -
停止守护线程:在业务逻辑执行完毕并释放锁后,守护线程应该被及时停止,避免不必要的资源消耗。
Java 实现示例
// 获取锁并启动守护线程
if (set(key, threadId, "NX", "EX", 30)) {
// 启动守护线程
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
if (threadId.equals(get(key))) {
expire(key, 30); // 续期锁
}
}, 25, 25, TimeUnit.SECONDS);
try {
// 执行业务逻辑
} finally {
if (threadId.equals(get(key))) {
del(key); // 释放锁
}
scheduler.shutdown(); // 停止守护线程
}
}
注意事项
-
续期间隔:设置合理的续期间隔,通常可以设置为略小于 TTL,例如在 TTL 为 30 秒时,每 25 秒续期一次。
-
可靠性:确保守护线程可靠地执行续期操作,避免续期失败导致锁过期。
通过为分布式锁增加一个守护线程来实现锁续期机制,可以避免由于线程阻塞导致的超时自动解锁问题,从而确保业务逻辑能够完整执行。这种方法比简单延长 TTL 更加优雅和灵活。
四、总结
分布式锁确保在分布式环境中,某个共享资源在同一时刻只能被一个节点或线程访问,避免了传统本地锁在多节点环境中的同步问题。分布式锁通常由外部组件(如 Redis、Zookeeper)实现,这些组件提供了高可用的锁服务,确保锁在节点故障或网络分区情况下的可靠性。
常见实现方式:
- 简单实现利用 Redis 的 SET 命令和 NX(不存在则插入)参数,可以快速实现分布式锁。然而,这种实现存在误删和超时处理等问题。
- 健壮实现通过使用唯一标识符和 Lua 脚本来确保操作的原子性,可以解决误删和超时问题,提高分布式锁的可靠性。Lua 脚本将锁的设置和释放操作封装为原子操作,避免了非原子性操作带来的并发问题。
- 看门狗机制通过监控锁的有效期,并在锁即将过期时自动续期,确保业务逻辑在锁的有效期内顺利执行,避免了因锁超时导致的数据不一致问题。
关键特点:
- 互斥性、可重入性、锁超时、高性能与高可用性、阻塞与非阻塞性、可扩展性是分布式锁需要满足的基本特点。这些特点确保了锁的有效性和系统的稳定性。
通过选择合适的分布式锁实现方式,可以有效提升系统的可靠性和一致性,确保业务逻辑的正确执行。在实际应用中,需要根据具体场景选择合适的实现方式,并进行适当的优化和调整,以应对分布式环境下的复杂挑战。