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

Redis 分布式锁续期为什么会失效:租约、看门狗与释放边界

来源:17golang原创

时间:2026-08-27 06:49:19 419浏览 收藏

订单对账任务偶尔会出现两份结果同时写入,日志里却只看到两个 worker 都成功拿到过 Redis 锁。真正的问题通常不在第一次加锁,而在任务跑过租约之后:旧 worker 还在续期,锁却已经被新 worker 重新拿走,随后旧 worker 又把新 worker 的锁删掉。

要点速览
  • Redis 锁的 TTL 是租约,不是“进程活着就永远有效”的所有权证明。
  • 续期必须同时检查锁值仍是当前持有者的随机令牌,不能单独执行 PEXPIRE
  • 释放也要做值比较后删除,避免过期后误删后来者的锁。
  • 任务可能超过续期能力或要保护外部数据库时,还需要围栏令牌和业务侧版本校验。

先把 Redis 锁看成一张有期限的租约

最小加锁写法通常是:

SET lock:reconcile:20260827 worker-a-token NX PX 30000

NX 保证只有不存在时才创建,PX 30000 给这次占用设置 30 秒有效期,值里的随机令牌则用来标识“这次是谁拿到的锁”。Redis 官方文档也把随机值和过期时间放在同一个 SET 操作里,这是避免加锁与设过期时间之间出现空窗的关键。

这里的 30 秒只能说明 Redis 允许这把锁在这段时间内被当前持有者使用,不能证明 worker 一定在 30 秒内完成。GC 停顿、容器 CPU 被限流、下游接口卡住,都会让业务执行时间超过租约。

Redis 分布式锁租约从获取、续期到过期的时间窗口示意

续期模式适合什么压力

当任务由多个可分段的小步骤组成,可以把初始 TTL 设得相对短,再在剩余时间接近阈值时续租。例如 30 秒租约在剩余 10 秒时发起一次续期,并要求连续几次失败后立刻停止后续写入。这样做的价值是:worker 崩溃时锁能较快自动释放,正常长任务则不必把初始 TTL 拉到几小时。

续期线程或定时器不能只看“本地任务还活着”。它必须向 Redis 确认 key 仍存在,并且 value 仍等于本次持有者的令牌。Redis 官方分布式锁说明给出的续期思路也是用 Lua 在服务端原子地检查值再延长 TTL:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('pexpire', KEYS[1], ARGV[2])
else
    return 0
end

返回 1 只能说明这一次续期成功。业务代码还要把续期失败当成“失去锁”的信号,停止写库、停止提交消息,或把当前步骤标记为需要人工复核;不能继续跑完再凭经验猜测谁是主人。

看门狗不是永动机

很多客户端把自动续期叫作看门狗。它本质上只是一个后台续租循环,通常包含三个部分:持有者令牌、续租周期和失败收敛动作。它解决的是“任务正常运行但耗时超过初始 TTL”,解决不了网络隔离、Redis 故障、进程长时间暂停或客户端误判。

假设续租周期是 10 秒、TTL 是 30 秒,不能把 20 秒的网络抖动当作正常波动。连续失败后,旧 worker 可能在 Redis 里已经失去锁,但本地代码仍在修改订单。这里别急着调大 TTL,先定义失锁后的业务动作:是否可重试、已完成的子步骤如何幂等、外部系统是否支持撤销。

如果一次任务只能整体执行,续租也不能把它变成真正可抢占的事务。续租次数应有上限,或者由任务本身设置最长允许运行时间;否则一个失控 worker 会长期占住资源,活性反而变差。

释放锁时必须验证“还是我的吗”

错误的释放方式是直接删除:

DEL lock:reconcile:20260827

时间线可能是这样的:worker A 拿到锁后暂停,TTL 到期;worker B 拿到同名锁;A 恢复并执行 DEL。A 删除的已经不是自己的锁,B 的互斥保护就此失效。

正确的释放需要在 Redis 内完成比较和删除:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

脚本返回 1 表示这次删除确实匹配了当前令牌,返回 0 则说明锁已过期、被别人持有,或者 key 已经不存在。后两种情况都不要重试删除。

Redis 分布式锁在令牌匹配、令牌不匹配和锁已过期之间的释放边界

续期失效后,业务写入还安全吗

单实例 Redis 锁主要提供一个协作层面的互斥入口,并不替数据库或对象存储承担最终一致性责任。只要临界区里还有一个不能撤销的外部写操作,就要考虑“旧持有者晚到”的情况。

常见的补强方式是围栏令牌:每次成功获取锁时同时拿到一个递增序号,写入下游时带上这个序号;下游只接受大于已见序号的写入。这样即便旧 worker 在锁过期后恢复,较小的令牌也会被拒绝。围栏校验必须落在真正接受写入的那一侧,单独在 worker 内比较时间不能替代它。

如果下游没有围栏能力,至少把操作拆成可幂等步骤,记录任务版本和提交状态,并在每个不可逆动作前重新确认锁状态。这个办法不能消除所有竞态,但能把“重复写入”从静默覆盖变成可发现、可恢复的失败。

上线前按这张清单判断是否该用续期锁

  1. 加锁是否使用一次性 SET key token NX PX ttl,而不是分开的创建与过期命令?
  2. 续期是否在 Redis 内原子比较 token 后再执行 PEXPIRE
  3. 续期返回 0、连接失败和本地暂停时,业务是否会停止新的外部写入?
  4. 释放是否使用同样的 token 比较后删除,且不会对失败结果盲目重试?
  5. 最长任务时长、续租次数和 Redis 不可用时的恢复路径是否明确?
  6. 下游写入是否需要围栏令牌、版本号或幂等键来挡住迟到的旧 worker?

常见问题

把 TTL 调得足够长,是不是就不用续期了?

只能降低正常任务超过 TTL 的概率,不能处理进程暂停、网络隔离和异常重试。TTL 越长,锁在持有者已经消失后释放得越慢;仍要按业务可接受的恢复时间来选。

续期用 PEXPIRE,为什么还要 Lua?

单独的 PEXPIRE 不会验证 value。key 在检查和续期之间可能已经被别人重新创建,Lua 可以把比较与续期放在同一个 Redis 原子操作里。

释放脚本返回 0 要不要再删一次?

不要。返回 0 正说明当前 key 不再属于这个令牌;再次删除可能误伤后来者。记录失锁事件并让任务按幂等或补偿路径收敛更安全。

Redlock 能消除旧 worker 晚到的问题吗?

不能把它当成绝对保证。Redis 官方文档明确建议对需要强一致的资源考虑围栏令牌,并认真评估时钟、网络分区和故障恢复条件。锁算法只能覆盖锁服务本身的边界,不能自动约束所有下游写入。

把锁的边界写进验收结果

一把可维护的 Redis 分布式锁,最重要的不是“看门狗开了”,而是每个状态都有明确动作:拿不到锁就退出,续期失败就停止新的不可逆写入,释放前验证令牌,外部资源用版本或围栏令牌挡住迟到者。先用短 TTL、可观测的续期日志和故障注入测出真实边界,再决定是否需要更复杂的多节点方案。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>