当前位置:首页 > 文章列表 > 数据库 > Redis > Redis 分布式锁为什么会误删:唯一令牌与 Lua 原子释放的边界

Redis 分布式锁为什么会误删:唯一令牌与 Lua 原子释放的边界

来源:17golang原创 2026-07-22 12:01:23 0浏览 收藏

之前我们维护的订单库存服务出过一次特别难复现的并发故障:日志追踪下来发现,请求A明明已经成功拿到Redis分布式锁,但执行业务逻辑耗时超出了锁的过期时间,等锁自动释放后,请求B顺利拿到了同一个锁Key。几百毫秒后A的收尾逻辑跑完,直接把B手里正在用的锁给删掉了。后面涌进来的请求完全没有锁保护,两个库存扣减操作直接同时执行,很容易就出现超卖问题。

实践要点
  • 锁值不能写成固定字符串,必须带上本次请求独有的 lock_token。
  • 释放锁要在 Redis 内完成“比对值再删除”,不能把 GET 和 DEL 拆成两次往返。
  • PX 只解决持锁者失联后的自动兜底,不会自动覆盖业务最长处理时间。
  • 续期前要确认令牌仍归自己所有,超时后应让业务结果走幂等或补偿流程。

Redis 分布式锁过期后旧请求误删新请求锁的时间线证据图

一个 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 不是普通的“删除失败”。它可能表示锁已经过期、被故障处理逻辑清理,或者当前锁的值已经属于另一个新的请求。业务层应该把它记录成租约丢失事件,而不是继续重试删除。

Redis 分布式锁使用 lock_token 在服务端原子校验后释放的前后对比图

真正的边界在锁过期和业务耗时之间

锁的 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,而是把所有权校验逻辑贯穿加锁、续期和释放三个动作:每次持锁生成独立的独有令牌,服务端完成原子校验后才允许执行删除,租约丢失之后停止继续写入共享资源,关键业务再由数据库幂等和补偿机制兜底。这样哪怕请求中途卡住、锁先过期,后面拿到新锁的请求也不会被旧请求误删掉手里的锁。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Go 重试循环为什么会越跑越慢:用 timer.Reset 控制退避与取消Go 重试循环为什么会越跑越慢:用 timer.Reset 控制退避与取消
上一篇
Go 重试循环为什么会越跑越慢:用 timer.Reset 控制退避与取消
MySQL JSON_EXTRACT 查询为什么慢:用生成列索引做一次可验证优化实验
下一篇
MySQL JSON_EXTRACT 查询为什么慢:用生成列索引做一次可验证优化实验
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • SuperCLUE中文大模型评测基准:功能、能力维度与应用指南
    SuperCLUE
    SuperCLUE是权威的中文大语言模型综合评测基准,涵盖语言理解、知识应用、AI Agent智能体及安全性等12项核心能力。通过多轮对话与客观测试,定期发布榜单与技术报告,为模型研发、优化及行业选型提供科学依据。
    173次使用
  • C-Eval中文评测基准:大语言模型多学科能力评估指南
    C-Eval
    深入了解C-Eval中文评估套件,涵盖52个学科与4级难度。本文详解其功能特点、Zero-shot/Few-shot使用方法及代码示例,助您全面评测LLM中文理解与泛化能力。
    106次使用
  • AI Prompt Library:免费AI提示词库,助力ChatGPT高效创作与营销
    AI Prompt Library
    探索AI Prompt Library免费资源库,涵盖营销、写作及多场景AI提示词。兼容ChatGPT、Claude等工具,一键复制优化输出,提升工作效率。
    32次使用
  • LangGPT提示词框架:结构化Prompt设计方法与开源工具指南
    LangGPT
    LangGPT是一种受编程语言启发的结构化提示词设计工具,提供双层框架、模块化模板及变量功能,帮助用户高效编写高质量Prompt。该项目已在GitHub免费开源,适用于内容创作、编程辅助等多场景。
    42次使用
  • ClickPrompt:AI提示词生成与优化工具,支持Stable Diffusion、ChatGPT及代码辅助
    ClickPrompt
    ClickPrompt是一款专为AI提示词编写者设计的开源在线工具,支持Stable Diffusion绘图、ChatGPT对话及GitHub Copilot代码辅助。提供Prompt自动生成、一键运行、社区分享及可视化优化功能,帮助用户高效获取精准AI输出。
    78次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议隐私政策
返回登录
  • 重置密码