Redis 分布式锁为什么不能直接 DEL:锁归属与 Lua 原子释放
工程判断:Redis 分布式锁最危险的代码通常不是加锁,而是 finally 里的 DEL。线程持有的锁过期后,另一个线程可能已经拿到同名锁,此时前一个线程直接删除就会误删别人的所有权。
一个可靠的锁至少要绑定持有者标识,并让“判断所有者”和“删除 Key”保持原子;还要明确租期、续期、可重入和业务执行超时。简单 setnx 加 del 只覆盖了演示场景。
MetaLite 的 Redis 封装把锁的所有权校验和释放语义收敛到公共能力,避免业务重复实现危险版本。本文先复现误删时序,再结合 RedisClient 的实现说明这层浅封装为何具有工程价值。
一、锁值为什么必须每次唯一
MetaLite 获取锁时生成一个唯一lockValue:
StringlockValue=FastUniqueId.getNumber();Booleanresult=redisClient.vSetIfAbsent(lockKey,lockValue,expireMillis,TimeUnit.MILLISECONDS);成功后调用方同时持有:
lockKey → 锁定哪个资源 lockValue → 这次锁的所有者凭证同一个服务实例先后两次获取同一 Key,也应该拥有不同 Value。因为锁的所有权属于“一次获取”,不是某个进程永久拥有。
二、比较和删除为什么必须原子执行
如果 Java 代码先GET比较,再执行DEL,两条命令之间仍有竞争窗口:
GET 发现还是自己的值 ↓ 锁恰好过期,其他实例获得新锁 ↓ DEL 删除新锁MetaLite 使用 Lua 把判断与删除放进 Redis 一次执行:
ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end只有 Redis 中的值仍等于本次lockValue,才允许删除。
这条脚本解决的是“误释放其他所有者的锁”,不代表业务操作与 Redis 锁获得了数据库事务级原子性。
三、过期时间为什么不可省略
如果持锁进程崩溃、机器断电或网络中断,正常解锁代码不会执行。
没有 TTL 的锁会永久存在,后续请求全部无法进入。
当前实现要求过期时间:
大于 1 秒 小于 24 小时 默认 5 秒TTL 是防止死锁的最后保障,但也引入新的问题:业务执行时间超过 TTL 时,锁会提前失效。
因此过期时间不是越短越安全。它至少要覆盖正常执行时长、可接受抖动和基础设施延迟,并配合超时监控。
四、自动续期怎样确认自己仍是所有者
开启autoRenewal后,MetaLite 按过期时间的 30% 调度续期任务。
续期同样使用 Lua:
ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('expire',KEYS[1],ARGV[2])elsereturn0end它先验证锁值,再延长 TTL。若 Key 已不存在或值已变化,返回 0 并停止当前续期任务。
这避免旧所有者为新所有者的锁续命。
五、当前自动续期不是无限 watchdog
“自动续期”容易让人理解为:业务不结束,锁就一直续。
当前实现不是这样。
最大续期次数由下面的计算得到:
intmaxRenewalCount=(int)(1/RENEWAL_INTERVAL_RATIO)+1;比例为 0.3 时,得到 4;任务在 finally 中先加次数,再以>判断取消,因此实际还会经历一次边界续期。
无论如何,它是有上限的定期续期,不是随业务执行时长无限延续的 watchdog。
如果任务可能运行远超锁时间,不能只看autoRenewal=true就认为互斥一定覆盖完整执行期。需要根据代码精确计算最长保护窗口,或把续期改成“持锁且业务未结束就继续”的生命周期模型,同时设置总时长保护。
六、毫秒 TTL 为什么在续期时变成了秒
首次加锁使用毫秒单位。
续期脚本却调用 RedisEXPIRE,并传入:
expireMillis/1000这会截断不足一秒的部分。例如 1500 毫秒续期后会成为 1 秒,而不是 1.5 秒。
当前参数只要求大于 1 秒,并没有要求必须是 1000 的整数倍。
如果希望续期保持与首次加锁一致的精度,更合适的是使用PEXPIRE并直接传毫秒值。
这是典型的单位边界:配置字段写着毫秒,不代表整条执行链都保持毫秒精度。
七、注解怎样把锁与业务方法连接起来
MetaLite 提供@RedisLock:
@RedisLock(key="'lock:api:order:' + #orderId",expireMillis=10000,autoRenewal=true)publicResp<?>submit(StringorderId){// 业务逻辑}Key 可以是常量,也可以通过 SpEL 从方法参数计算。
切面拿到锁后,会把 Key、Value、过期时间和续期标记写进AspectInfo。方法结束后再从同一上下文解锁。
这种方式减少业务代码的 try/finally,但锁粒度仍由业务设计者负责。把所有订单共用一个 Key 会造成不必要串行;只用用户 ID 又可能无法保护具体订单资源。
八、异常时锁会不会释放
入口切面的通用逻辑在finally中执行所有 Handler 的postHandle。
SpringScheduledJobExecuteHandler.postHandle会调用解锁,因此业务方法抛出异常时也会进入释放路径。
如果 Redis 解锁失败,TTL 仍是最终兜底。自动续期任务只有解锁成功才立即取消;锁已过期或所有权变化时,旧续期任务会依靠 Lua 比较失败或次数上限停止。
这说明分布式锁需要多重退出路径:
正常 finally 解锁 所有权校验失败不误删 TTL 自动过期 续期任务检测失效后停止九、锁只能提供互斥,不能替代幂等
即使锁实现完全正确,仍可能发生:
- 锁过期后旧任务继续执行;
- Redis 主从切换带来时序窗口;
- 业务已提交,但响应丢失导致调用方重试;
- 续期线程长时间停顿;
- 数据库操作与解锁之间进程崩溃。
关键写操作仍应设计业务幂等,例如唯一约束、状态机、请求号或版本号。
分布式锁降低并发进入概率,业务幂等负责即使重复进入也不产生错误结果。两者不是替代关系。
十、分布式锁要监控什么
至少应观察:
- 获取成功率和等待/失败次数;
- 实际执行时间与配置 TTL 的分布;
- 续期失败次数;
- 解锁失败次数;
- 达到最大续期次数的锁;
- 热点 Key 和持锁最长任务。
MetaLite 当前会在执行超过配置时间、续期失败和达到次数上限时记录日志,为发现配置不合理提供线索。
正确的 Redis 锁不是SET NX加一个DEL。它必须把所有权、原子释放、过期、续期和业务幂等放在同一条故障时间线上理解。
框架简介
MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。
源码基线
JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目backend-bom为准。
作者简介
15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。
持续更新
MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。
在线演示
演示地址: https://admin.metalite.top/
演示账号: guess
演示密码: admin@2026