Redis 分布式锁为什么会误删:唯一令牌与 Lua 原子释放的边界
来源:17golang原创
时间:2026-07-22 12:01:23 326浏览 收藏
之前我们维护的订单库存服务出过一次特别难复现的并发故障:日志追踪下来发现,请求A明明已经成功拿到Redis分布式锁,但执行业务逻辑耗时超出了锁的过期时间,等锁自动释放后,请求B顺利拿到了同一个锁Key。几百毫秒后A的收尾逻辑跑完,直接把B手里正在用的锁给删掉了。后面涌进来的请求完全没有锁保护,两个库存扣减操作直接同时执行,很容易就出现超卖问题。
- 锁值不能写成固定字符串,必须带上本次请求独有的 lock_token。
- 释放锁要在 Redis 内完成“比对值再删除”,不能把 GET 和 DEL 拆成两次往返。
- PX 只解决持锁者失联后的自动兜底,不会自动覆盖业务最长处理时间。
- 续期前要确认令牌仍归自己所有,超时后应让业务结果走幂等或补偿流程。

一个 DEL 为什么会删错锁
最简化的初阶加锁写法通常是:
SET order:lock:10086 lock-owner NX PX 8000
它能保证同一时刻只有一个请求写入 Key,但 lock-owner 是固定值,释放时完全无法证明“现在的锁还是我之前拿到的那一把”。更糟的是,很多代码会先查值,再单独发起删除指令:
value := redis.Get(ctx, key).Val()
if value == "lock-owner" {
redis.Del(ctx, key)
}
假设 A 在第一行读到自己写入的锁值,刚好此时锁自然过期,B 抢到了新锁。A 再往下执行删除操作,此时它看到的锁已经不属于自己的生命周期,这段代码没有任何机制可以感知到这个变化。
先把锁的所有权写进值里
每次加锁都生成全新的独立令牌,例如 req-7f3c。值里不需要暴露用户信息,随机字节或者 UUID 都可以满足需求。Go 场景下可以把加锁的结果和生成的令牌一起保存下来:
type LockLease struct {
Key string
Token string
TTL time.Duration
}
func acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*LockLease, error) {
token := uuid.NewString()
ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("lock busy")
}
return &LockLease{Key: key, Token: token, TTL: ttl}, nil
}
令牌的作用只有一个:确认当前 Redis Key 的值仍然属于这次持锁动作。它不是用户 ID,也不是订单号,日志里可以记录哈希后的短值,既能方便排查问题,也不会泄露完整的敏感内容。
释放动作必须在 Redis 内完成校验
正确的释放逻辑是“值和我的令牌完全相等才执行删除”,并且比较和删除必须是一个不会被其他命令插入的原子动作。Lua 脚本可以把这个校验边界放在 Redis 服务端执行:
const releaseScript = redis.NewScript(`
local current = redis.call('get', KEYS[1])
if current == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
`)
func release(ctx context.Context, rdb *redis.Client, lease *LockLease) error {
n, err := releaseScript.Run(ctx, rdb, []string{lease.Key}, lease.Token).Int()
if err != nil {
return err
}
if n == 0 {
return errors.New("lease lost")
}
return nil
}
这里返回 0 不是普通的“删除失败”。它可能表示锁已经过期、被故障处理逻辑清理,或者当前锁的值已经属于另一个新的请求。业务层应该把它记录成租约丢失事件,而不是继续重试删除。

真正的边界在锁过期和业务耗时之间
锁的 PX 时间不能凭感觉随便设置成 8 秒。先统计实际的业务处理耗时:如果库存事务 P95 是 1.2 秒、P99 是 4.6 秒,还要把 Redis 往返耗时、数据库提交耗时和 GC 抖动的余量算进去,8 秒可能够用,也可能在流量尖峰的时候出现余量不足的问题。不用急着下最终结论,至少要把持锁时长、业务耗时和租约丢失次数分开埋点统计。
常见的续期方案是每隔一段固定间隔刷新锁的 TTL,但续期本身也必须携带令牌做校验,不能对一个已经换了持有者的 Key 直接延长过期时间。续期失败的时候,当前请求要立刻停止继续修改共享资源;如果已经往数据库写入了部分数据,就要依赖订单幂等键、状态机或者补偿任务把结果收口。
| 现场 | 正确判断 | 处理动作 |
|---|---|---|
| SET NX 返回 false | 已有持锁者 | 短暂等待或直接返回繁忙 |
| 释放脚本返回 0 | 租约不再属于自己 | 停止重试删除,记录 lock_lost |
| 续期校验失败 | 不能继续相信这把锁 | 停止后续写入,走幂等/补偿 |
| Redis 短暂不可用 | 锁状态不可确认 | 按业务风险选择失败或降级 |
几个看似合理但不够稳的写法
固定值加锁,再直接 DEL
这种方式完全无法识别锁的生命周期。哪怕删除操作本身执行速度很快,也挡不住“旧请求延迟到达”的情况。
GET 之后在客户端判断,再 DEL
客户端判断只能保证读到某一个瞬间的锁值,不能把判断结果和后续的删除操作绑定成原子动作。中间任何一次锁过期、主从切换或者网络延迟都会打开竞态窗口。
锁丢了还继续把库存扣完
锁只是并发控制的其中一层,不应该替代数据库层面的约束。关键写操作仍然要用到库存条件更新、唯一约束或者幂等记录做兜底,不然哪怕锁逻辑写得完全正确,超时重试也可能造成重复扣减的问题。
上线前用时间线验证,而不是只看单元测试
测试的时候至少要安排两个模拟请求:A 拿到锁之后暂停超过 TTL,B 能顺利拿到新的令牌;之后再恢复 A 的执行,让它尝试释放手里的锁。预期结果是 A 的释放脚本返回 0,B 持有的锁 Key 仍然正常存在。
request A: SET order:lock:10086 req-a NX PX 1500
request B: SET order:lock:10086 req-b NX PX 1500
request A: release(req-a) => 0
check: GET order:lock:10086 => req-b
线上还要重点观测三类指标:锁竞争等待时长、租约丢失次数、持锁业务耗时分位数。如果租约丢失是偶发的,但是集中在发布或者 GC 峰值时段,优先排查 TTL 设置、续期间隔和业务临界区的逻辑,不要简单粗暴把 TTL 直接调到几分钟。
相关问题
lock_token 可以直接使用订单号吗?
不建议。订单号可能被日志、接口或者重试流程复用,令牌应该代表一次具体的持锁尝试,使用随机值更容易区分不同的锁生命周期。
释放脚本返回 0 要不要再删一次?
不要。返回 0 已经说明当前请求没办法证明自己的锁所有权,再执行一次删除反而可能扩大误删的风险。
有了 Redis 锁还需要数据库幂等吗?
需要。Redis 锁会遇到过期、故障和网络分区问题,数据库的唯一约束、条件更新或者幂等表负责最终保障业务结果不会出错。
把“谁能删锁”写成可验证的规则
Redis 分布式锁最重要的不是把 Key 成功写进 Redis,而是把所有权校验逻辑贯穿加锁、续期和释放三个动作:每次持锁生成独立的独有令牌,服务端完成原子校验后才允许执行删除,租约丢失之后停止继续写入共享资源,关键业务再由数据库幂等和补偿机制兜底。这样哪怕请求中途卡住、锁先过期,后面拿到新锁的请求也不会被旧请求误删掉手里的锁。
-
366 收藏
-
117 收藏
-
426 收藏
-
171 收藏
-
113 收藏
-
数据库 · Redis | 16小时前 | Redis · go · Pipeline · 批处理 · 重试 · 幂等性 · 重试 go-redis 幂等键 批量写入 Redis Pipeline 逐条结果391 收藏
-
154 收藏
-
386 收藏
-
127 收藏
-
422 收藏
-
494 收藏
-
数据库 · Redis | 3天前 | Redis · 缓存 · 限流 · Redis 8.8 · INCREX · Redis 8.8 INCREX Redis窗口限流 Redis计数器 ENX UBOUND123 收藏
-
数据库 · Redis | 5天前 | Redis · 缓存 · go · Redis Cluster · 排错 · Redis Cluster CROSSSLOT Hash Tag MGET CLUSTER KEYSLOT259 收藏
-
183 收藏
-
413 收藏
-
数据库 · Redis | 1星期前 | Redis · 安全配置 · 数据库运维 · ACL · 网络隔离 · Redis公网暴露 Redis protected-mode Redis ACL Redis安全配置 Redis审计364 收藏
-
250 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习