艾体宝干货 | 【Redis实用技巧#3】Redis分布式锁真的安全吗?可靠性深度剖析

先想想这些问题,你能清晰回答吗?

  • 为什么需要分布式锁?

  • 如何正确实基于 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操作耗时 “超过” 锁的过期时间,锁被 "自动释放"

  3. 客户端2成功加锁,开始操作共享资源

  4. 客户端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. 客户端1在主节点执行SET命令成功加锁

  2. 此时主节点意外崩溃,SET命令还没同步到从节点(主从复制是异步的)

  3. 从节点被哨兵提升为新主节点,锁在新主节点上丢失

显然,引入Redis复制后,分布式锁仍会受影响。

怎么解决?为此,Redis创始人提出了一个方案,我们常听到的:Redlock(红锁)

这是什么?这又真能解决上面描述的问题吗?

鉴于本文已经很长,我会在下一篇文章中介绍这个话题。希望你认真读懂刚才提到的所有知识点,这对后续学习很有帮助!欢迎继续关注下一篇文章。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值