登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

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

来源:17golang原创

时间:2026-07-22 12:01:23 326浏览 收藏

之前我们维护的订单库存服务出过一次特别难复现的并发故障:日志追踪下来发现,请求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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>