分布式锁的演进和细节漫谈
分布式锁的演进和细节漫谈
在技术面试当中,我经常会被别人考察’你们项目中有没有使用到分布式锁?’、’你能给我们介绍一下分布式锁的原理吗’?’每次被问到这些问题,我发现自己虽然知道一些零散的概念,比如 SETNX、LUA脚本,但当面试官追问到”看门狗机制”的具体实现、锁的”可重入性”如何保证,甚至是主从架构下的”锁丢失”问题时,就常常会语塞。
这篇文章正是我为了彻底搞懂这些问题而进行的深度总结,系统性地梳理了从最基础的互斥锁,一步步演进到 Redisson 中复杂但完善的分布式锁实现的全过程,内容涵盖了原子性、死锁、锁续期、阻塞唤醒以及备受争议的红锁(RedLock)机制。
第一步:理解锁的本质——互斥性
要实现一个分布式锁,你首先要知道什么是锁。
锁的核心是互斥性:一个线程拿到了锁,在它释放之前,其他任何线程都无法获取这把锁,必须等待。
那么,如何用 Redis 实现互斥呢?非常简单,利用 SETNX
(SET if Not eXists) 命令。
SET key value NX
当一个线程执行这个命令时,如果 key
不存在,则设置成功并返回1,代表加锁成功。如果此时另一个线程也来执行,因为 key
已经存在,命令会执行失败并返回0,代表加锁失败。这就实现了最基础的互斥。
第二步:保证原子性与防止死锁
LUA脚本 - 解锁的原子性
加锁之后自然要解锁。解锁就是删除这个 key
。但为了安全,我们必须保证”谁加锁,谁解锁”,避免线程 A 错误地释放了线程 B 的锁。
通常的做法是,在 value
中存入一个唯一标识(比如当前线程ID + UUID)。解锁时,先 GET
key 的 value
,判断是否与自己的标识相等,如果相等,再执行 DEL
key。
这是一个”先判断,再释放”的两步操作,它不是原子的。在高并发场景下,可能会出现问题。怎么办?
答案是使用 LUA 脚本。将”判断”和”删除”两个操作写在一个 LUA 脚本里,让 Redis 一次性、原子地执行,确保操作的整体性。
1 | -- KEYS[1] 是锁的key |
过期时间 - 防止死锁
如果一个线程加锁成功后,业务代码出现异常或者服务直接宕机,没来得及解锁,这个锁就会永远留在那里,导致其他线程永远无法获取,造成死锁。
解决方案是:给锁加上一个过期时间。
SET key unique_value NX PX 30000
(PX 表示毫秒)
这样,即使加锁的线程宕机,锁也会在30秒后自动释放,其他线程就能重新获取。
第三步:锁续期与可重入性
“看门狗”(Watchdog)机制
新的问题又来了:如果一个线程的业务执行时间超过了锁的过期时间,锁被自动释放了,其他线程就会趁虚而入,导致并发安全问题。
为了解决这个问题,我们需要一个”看门狗”(Watchdog)机制。这个机制并非由业务线程A自己执行,而是由一个独立的后台守护线程来完成。
它的核心工作原理可以分解为:
- 启动时机:当业务线程A调用
lock()
成功获取锁,并且没有手动指定锁的超时时间时,框架就会在后台启动一个”看门狗”守护线程。 - 原子化续期:这个守护线程并非简单地”查询”锁是否存在。它会定期(例如,在Redisson中默认是每10秒,即锁默认过期时间30秒的1/3)执行一个LUA脚本,这个脚本将”判断锁的持有者是否是A”和”如果是,则刷新过期时间”两个操作捆绑在一起,成为一个原子操作。
我们可以用下面的伪代码来理解这个LUA脚本:
1 | -- KEYS[1] 是锁的key, ARGV[1] 是新的过期时间, ARGV[2] 是线程A的唯一标识 |
- 停止时机:当线程A的业务执行完毕,调用
unlock()
方法释放锁后,看门狗在下一个周期执行LUA脚本时,第一个if
判断就会失败,续期操作自然就停止了。
通过这种方式,业务线程A可以专注于处理业务,而锁的生命周期管理则完全交给了后台的看门狗,极大地增强了分布式锁的健壮性。
这正是 Redisson 框架的核心实现之一。
实现”可重入锁”
“可重入”指的是,同一个线程可以多次获取同一把锁,而不会被自己阻塞。如果我们的锁是不可重入的,那么当一个持有锁的线程再次尝试获取这把锁时,就会失败。
Java 中的 ReentrantLock
是如何实现可重入的呢?它内部有一个 state
字段作为计数器,每重入一次,计数器加一;每释放一次,计数器减一。当计数器归零时,锁才被真正释放。
我们可以借鉴这个思想,用两种方案在 Redis 中实现:
方案一(推荐):使用 Redis 的 Hash 结构
将锁的数据结构从 String 改为 Hash。例如,key
是锁的名字,field
是线程唯一标识,value
就是重入的次数。- 加锁:
HINCRBY lock_key thread_id 1
- 解锁:
HINCRBY lock_key thread_id -1
当value
减到 0 时,就可以删除这个field
或者整个key
。
以后谁再说 Redisson 底层用的是 String,你要知道,在实现可重入锁时,它用的是 Hash 结构。
- 加锁:
方案二:服务内部维护计数器
Redis 中依然使用 String 结构,但在应用服务内部,使用一个ThreadLocal<Map<String, Integer>>
之类的结构来维护当前线程持有的锁及其重入次数。每次加锁前,先检查本地计数器。这种方案将状态维护在了客户端,增加了复杂性。
第四步:实现阻塞与唤醒
当线程没有抢到锁时,是让它不断地循环重试(自旋),还是让它等待被唤醒?
- 自旋锁:不断循环尝试获取锁。这种方式会持续消耗 CPU,适用于锁占用时间极短的场景。一些公司内部的简单分布式锁就是这样实现的。
- 阻塞锁:获取不到锁就进入等待状态,直到持有锁的线程释放后,再被唤醒。
Redisson 是如何实现高效的阻塞与唤醒的呢?它巧妙地利用了 Redis 的发布/订阅(Pub/Sub)机制。
- 没有抢到锁的线程,会订阅(Subscribe)一个与该锁相关的特定 Channel。
- 该线程进入等待状态(可以配合一个超时时间)。
- 当持有锁的线程完成任务,释放锁时,它会向这个 Channel 发布(Publish)一条消息。
- 所有订阅了该 Channel 的等待线程都会收到消息,被唤醒,然后再次尝试抢锁。
这个过程不断重复,直到所有线程都完成任务。
知识点关联:
这里和Java当中的也就是 synchronized 关键字和 Object.wait()/notify()非常类似。区别无非就是作用于和底层实现原理有区别,但是相似之处非常类似,都是“资源获取不到就进入等待状态(分别是wait set和订阅同一个channel,逻辑上都是进入等待队列。)”
第五步:解决主从架构下的锁丢失问题
RedLock算法原理介绍
终于,我们的锁看起来很完美了。但如果你的 Redis 是主从(Master-Slave)架构呢,一般的企业级的缓存使用都是搭建的集群来保证高可用性。
当 Redis Master 节点宕机时,Sentinel 可以自动发现并进行主从切换,选举出新的 Master。意味着分布式锁服务不会因为单个 Redis Master 节点的故障而完全中断,从而保证了服务的高度可用性。
但是也需要考虑如下极端情况:
- 线程 A 向 Master 节点写入
SETNX
加锁成功。 - 此时,Master 节点还没来得及将这个新的锁数据同步给 Slave 节点,就突然宕机了。
- 哨兵(Sentinel)机制将一个 Slave 节点提升为新的 Master。
- 但这个新的 Master 上,根本没有锁的数据!
- 此时,线程 B 过来加锁,在新 Master 上也成功了。这就导致了多个线程同时持有同一把锁,分布式锁彻底失效。这就是主从架构带来的锁丢失问题。
图示如下:
1 | sequenceDiagram |
怎么办?为了解决这个问题,Redis 的作者提出了一种名为红锁(RedLock)的算法。在Redission里面也有对应的实现。
它的核心思想是:不再依赖单个主节点,而是部署多个(通常是奇数,如5个)独立的 Redis 主节点。加锁规则如下:
- 客户端向所有主节点发起加锁请求。
- 只有当客户端在超过半数(N/2 + 1)的节点上都成功加锁时,整个加锁操作才算成功。
- 加锁的总耗时需要小于锁的有效时间。
1 | graph TD |
为什么是半数以上?因为这样可以保证,即使有部分节点宕机,只要剩余的节点能形成多数派,锁的互斥性就能得到保障。当一个线程在半数以上节点加锁成功后,其他任何线程都不可能再在半数以上的节点上加锁成功了。
在SpringBoot上配置RedssionRedLock
要让 Redisson 的红锁机制生效,需要多个完全独立的 Redis Master 节点。Redisson 在 Spring Boot 中的配置,通常是通过注入 RedissonClient bean 来完成的。在引入依赖redisson-spring-boot-starter之后,我们需要一个配置去配置多个主节点(一般是奇数,最少3个,3、5、7都是可以的)。打个比方
1 |
|
在使用的地方,我们需要讲这些client引入到我们的代码,我们需要构建一个RedissonRedLock实例
1 | // 创建 RedissonRedLock,将所有 RLock 实例传入 |
红锁的问题与争议
然而,红锁并非银弹。它也存在许多问题:
- 时钟漂移:红锁的安全性强依赖于各个节点的系统时钟保持基本一致。如果发生严重的时钟漂移,可能会导致锁的安全性问题。
- 运维复杂:需要部署和维护多个独立的 Redis 主节点,成本更高。
- 性能牺牲:需要向多个节点发起网络请求,加锁的性能会低于单实例模式。
正因为这些争议,在实际生产中,除非对锁的可用性有极其严苛的要求,否则直接使用红锁的公司并不多。
结论
回顾全程,我们从一个简单的 SETNX
,一步步演进,解决了原子性、死锁、锁续期、可重入、阻塞唤醒以及主从失效等一系列问题,最终构建出一个相对完善的分布式锁。
在实际开发中,我们不必重复造轮子。业界成熟的开源框架 Redisson 已经为我们封装好了这一切,它内置了看门狗、可重入锁、公平锁、以及对红锁的支持。
所以,大多数公司的选择是:要么基于 Redis 的特性进行手动实现一个满足自身业务需求的、相对简化的分布式锁,如果不配置RedissionRedLock,很难根除这个隐患。
所以为什么很多企业里面的代码使用的还是传统的jedis client并且自己补了一些代码去实现分布式锁,就是因为这个考虑。往往相信了极端情况的“小概率发生”,所以依然还是传统的分布式锁实现,而没有去使用redission的redlock实现