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