先想想这些问题,你能清晰回答吗?
-
为什么需要分布式锁?
-
如何正确实基于 Redis 的分布式锁
-
Redis 分布式锁中的死锁预防机制
-
如何防止锁被其他线程释放
-
如何解决分布式锁过期时间的不确定性
-
Redlock:它真的安全吗?
-
Redis 和 ZooKeeper 在分布式锁中的对比:该选哪一个?
-
构建容错的分布式锁:完整的CheckList包含哪些
看完本文,你不仅能彻底搞懂分布式锁,还会对分布式系统有更深的理解。
为什么需要分布式锁?
在我们深入聊分布式锁细节前,先得明白为什么需要它。
与分布式锁相对的是单机锁。写多线程程序时,为防止多个线程同时操作共享变量导致数据问题,我们通常用锁实现互斥,确保共享变量的正确性。但这种锁的作用范围仅限于同一进程。
如果多个进程需要同时操作共享资源,如何实现互斥?
比如现代业务应用常采用微服务架构,一个应用可能部署在多个进程中。如果这些进程要修改MySQL同一行数据,为避免乱序操作导致数据错误,就需要引入分布式锁。实现分布式锁必须依赖一个外部系统,所有进程都向它申请锁。这个外部系统必须提供互斥能力,即同时收到两个请求时,只让其中一个成功,另一个返回失败(或强制等待)。
这个外部系统可以是MySQL、Redis或ZooKeeper。但为性能考虑,我们通常选Redis或ZooKeeper。下面我会用Redis来剖析分布式锁的各种安全性问题,相信能帮助你从基础概念到高阶的设计考量,彻底搞懂分布式锁。
如何实现分布式锁?
先看最简单的方案。
用Redis实现分布式锁,必须支持互斥。可以用SETNX命令,全称SET if Not eXists——仅当键不存在时才设置值,否则则忽略。
这里模拟两个客户端进程执行这个命令实现互斥,完成基本的分布式锁。
客户端1申请锁,成功:
127.0.0.1:6379> SETNX lock 1
(integer) 1 // 客户端1,加锁成功
客户端2申请锁,失败:
127.0.0.1:6379> SETNX lock 1
(integer) 0 // 客户端2,加锁失败
成功获得锁的客户端就可以操作共享资源了,比如修改MySQL某行数据或调用API。操作完成后必须及时释放锁,让其他客户端能访问共享资源。
那么问题来了,怎么释放?
很简单,用DEL命令删除键:
127.0.0.1:6379> DEL lock
(integer) 1
逻辑很简单,整体流程就是:加锁→操作共享资源→释放锁。
但有个大问题:如果客户端1拿到锁后遇到以下情况,会导致死锁:
-
程序处理业务逻辑时异常,没来得及释放锁
-
进程崩溃,没机会释放锁
这样客户端会永久持有锁,其他客户端永远拿不到。怎么解决呢?
如何避免死锁?
直观的方案是给锁设置 “租期” 。在Redis中就是给键设置过期时间。假设操作共享资源最多需要10秒,加锁时把键设为10秒后过期:
127.0.0.1:6379> SETNX lock 1
(integer) 1
127.0.0.1:6379> EXPIRE lock 10 // 10秒后自动过期
(integer) 1
这样无论客户端是否异常,锁都会在10秒后自动释放,其他客户端就能拿到锁了。
但这真的万无一失吗?并不完全。
目前加锁(SETNX)和设置过期(EXPIRE)是两个独立命令。如果第一条命令成功,第二条因意外没执行怎么办?比如:
-
SETNX成功,但EXPIRE因网络问题失败
-
SETNX成功,但Redis在EXPIRE执行前突然崩溃
-
SETNX成功,但客户端在发EXPIRE前崩溃
总之,这两个命令不保证原子性(无法确保同时成功),有过期时间设置失败的风险,导致同样的死锁问题。
怎么修复这一问题?
Redis 2.6.12之前,开发者得写复杂的逻辑来确保SETNX + EXPIRE原子执行,还得处理边界情况。但从Redis 2.6.12起,SET命令扩展了新参数,用单条命令原子实现加锁和设置过期:
// 单条命令保证原子执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
这相对简单地解决了死锁问题。
让我们再深入分析:还有什么问题?
考虑这个场景:
-
客户端1成功加锁,开始操作共享资源
-
客户端1操作耗时 “超过” 锁的过期时间,锁被 "自动释放"
-
客户端2成功加锁,开始操作共享资源
-
客户端1完成操作后释放锁(实际上释放了客户端2的锁)
看出两个严重问题了吗?
锁过期:客户端1操作太久导致锁自动过期,随后被客户端2拿到 释放别人持有的锁:客户端1操作完成后释放锁,但释放的是客户端2的锁
这两个问题的根源是什么?逐个看。
第一个问题可能是对共享资源操作时间的评估不准确。
比如操作最慢可能需要15秒,但我们只设置了10秒过期,就有锁提前释放的风险。如果过期时间太短,干脆把时间设宽裕点,比如20秒?够吗?
这确实能"缓解"问题,降低发生概率,但无法"彻底解决"。因为客户端拿到锁后操作共享资源时可能遇到各种复杂情况:程序内部异常、网络请求超时等。既然是"估算",那只能是近似值。除非你能预测并覆盖所有导致延迟增加的场景,而这实际上很难。
有更好的解决方案吗?别急,后面我会详细讨论这个问题的对应方案。
接着说第二个问题。
第二个问题在于一个客户端释放了另一个客户端持有的锁。
导致这个问题的关键点是什么?关键在于每个客户端释放锁时都 “盲目” 操作,不检查锁是否还 “由自己持有” 。这就有释放别人锁的风险。这种解锁过程太过粗糙,怎么解决?
锁被其他线程释放了怎么办?
解决方案是:客户端加锁时设置一个 “只有自己知道的唯一标识” 。
可以是自己的线程ID,或UUID(随机且唯一)。这里用UUID举例:
// 把锁的VALUE设为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
这里假设20秒完全够操作共享资源,暂不考虑锁自动过期。后面释放锁时,必须先验证锁是否还由自己持有。伪代码可以这样写:
# 只能自己释放自己的锁if redis.get("lock") == $uuid:
redis.del("lock")
这里释放锁用了GET + DEL命令,又出现前面讨论过的原子性问题。
客户端1执行GET确认锁属于自己 客户端2执行SET强行拿到锁(虽然概率低,但必须严谨考虑锁安全性) 客户端1执行DEL,意外释放了客户端2的锁
说明这两个命令必须原子执行。
如何实现原子执行?
用Lua脚本。可以把逻辑封装在Lua脚本里让Redis执行。由于Redis是单线程处理请求,执行Lua脚本时其他请求必须等待直到脚本完成,这确保GET和DEL之间不会插入其他命令。
这是安全释放锁的Lua脚本:
-- 仅当锁属于自己时才释放if redis.call("GET",KEYS[1]) == ARGV[1]thenreturn redis.call("DEL",KEYS[1])elsereturn 0end
经过这一系列优化,加锁和解锁的整个流程变得更严谨了。
总结一下,基于Redis实现分布式锁的严谨流程是:
加锁:SET $lock_key $unique_id EX $expire_time NX 操作共享资源 释放锁:用Lua脚本——先GET检查锁是否由当前持有者持有,再DEL释放
+----------------+ +-------------------------+ +----------------+
| Lock | ----> | Operate shared resources | ----> | Release lock |
+----------------+ +-------------------------+ +----------------+
现在有了这个完整的锁模型,我们回到前面提到的第一个问题:如果很难评估合适的锁过期时间怎么办?
锁过期时间难以评估怎么办?
前面提到,如果锁过期时间评估不当,有提前过期的风险。当时给出的折中方案是让过期时间尽量宽裕,降低提前过期的概率。但这方案不完美。那该怎么办?
能不能设计这样的方案:加锁时先设过期时间,然后启动一个守护线程定期检查锁剩余时间。如果锁快过期了但共享资源操作还没完成,线程自动续期重置过期时间。
这确实是更好的方案。如果用Java技术栈,很幸运,已经有库封装了所有这些工作:Redisson。Redisson是Java实现的Redis客户端。使用分布式锁时采用自动续期方式防止锁过期,这个守护线程通常叫 看门狗(watchdog)线程。
此外,这个SDK封装了很多好用功能:
-
可重入锁
-
乐观锁
-
公平锁
-
读写锁
-
Redlock(后面会讲)
-
…
这个SDK提供的API很友好,让你操作分布式锁就像操作本地锁一样。这里不重点介绍Redisson用法,可以去GitHub看看,很简单。
现在总结一下Redis分布式锁遇到的问题及对应方案:
-
死锁:设置过期时间
-
过期时间估算不准导致提前过期:用守护线程自动续期
-
被别人释放锁:锁里嵌入唯一标识,释放前验证所有权
还有其他可能破坏Redis锁安全性的场景吗?
之前分析的问题都假设锁在单个Redis实例中运行,不涉及Redis部署架构细节。实际应用中,Redis常部署为主从集群+哨兵(Sentinel)模式。好处是主节点意外故障时,哨兵可自动故障转移,将从节点提升为主节点保证可用性。
但发生主从切换时,这个分布式锁还安全吗?
考虑这个场景:
-
客户端1在主节点执行SET命令成功加锁
-
此时主节点意外崩溃,SET命令还没同步到从节点(主从复制是异步的)
-
从节点被哨兵提升为新主节点,锁在新主节点上丢失了
显然,引入Redis复制后,分布式锁仍会受影响。
怎么解决?为此,Redis创始人提出了一个方案,我们常听到的:Redlock(红锁)。
这是什么?这又真能解决上面描述的问题吗?
鉴于本文已经很长,我会在下一篇文章中介绍这个话题。希望你认真读懂刚才提到的所有知识点,这对后续学习很有帮助!欢迎继续关注下一篇文章。
543

被折叠的 条评论
为什么被折叠?



