分布式锁续期失败后还能继续工作吗:租约与围栏令牌
来源:17golang原创
时间:2026-10-07 15:23:10 333浏览 收藏
Redis 分布式锁续期失败后,旧客户端不应继续执行会修改共享资源的关键工作。失败意味着它已经无法证明租约仍属于自己;即使业务线程还活着,锁也可能已经过期并被另一个客户端取得。正确处理是立即把租约标记为失效、取消后续副作用,并让下游资源用单调递增的围栏令牌拒绝迟到的旧写入。
Redis 官方文档:https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
续期只是延长租约的手段,不是所有权的永久证明;围栏令牌才是共享资源判断“这个写入是否已经过期”的最后一道门。
规模上来后,续期失败会暴露什么问题
在任务量较小时,很多系统用一条 SET key value NX PX ttl 就能避免大多数重复执行。规模增大后,任务耗时开始抖动,客户端也会遇到长时间 GC、线程饥饿、网络分区、Redis 超时或进程暂停。此时,业务线程可能还在运行,但锁的 TTL 已经耗尽。
假设客户端 A 获得 15 秒租约,开始生成一份大报表。第 10 秒时续期请求超时,A 仍继续计算。第 15 秒锁自动释放,客户端 B 获得同一资源的新锁并完成写入。随后 A 从暂停中恢复,又把旧结果写进对象存储或数据库。Redis 里从未同时存在两个有效锁,但共享资源仍然被旧持有者覆盖。
这个问题说明:锁服务只能证明某个时间窗口内的互斥,不能替下游存储撤销已经在路上的旧请求。Redis 官方分布式锁文档也把锁的有效性限定在租约窗口内,并提醒不要假设进程存活就等于锁仍被持有。
只有随机令牌和 TTL 的旧方案缺少最后一道门
一个基本的 Redis 锁至少需要随机所有者令牌和过期时间:
# 只有锁键不存在时才写入,并设置 15 秒租约 redis-cli SET lock:report owner-random-token NX PX 15000 # 返回 OK 表示取得租约;空返回表示已有其他持有者
随机令牌的作用是识别所有者。释放和续期时都必须先比较锁值,只允许当前所有者操作自己的锁,避免 A 在锁过期后误删 B 的新锁。它解决的是“这把锁是不是我创建的”,却不能回答“我的写入是否比另一个持有者更新”。
TTL 则同时承担两件事:客户端崩溃后自动释放资源,以及限制客户端可以安全工作的时间。TTL 过短会频繁续期,过长会让故障后的恢复等待更久。无论取值多大,只要系统允许暂停或网络分区,就不能依赖“通常能及时续上”作为一致性保证。

把锁重构成可取消租约
续期操作必须是原子的:只有锁键仍存在且值仍等于当前随机令牌时,才延长 TTL。下面的 Lua 片段表达了这个条件,示例用于说明原子边界,不代表必须自行替代成熟客户端库:
-- 只有当前客户端仍是锁所有者时才延长租约
if redis.call('get', KEYS[1]) == ARGV[1] then
-- ARGV[2] 是新的毫秒级租约长度
return redis.call('pexpire', KEYS[1], ARGV[2])
end
-- 返回 0 表示锁已丢失、已过期或已属于其他客户端
return 0
应用收到续期失败、超时或无法确认结果时,应按“租约已经丢失”处理,而不是继续重试到成功。一个实用状态机包含 ACTIVE、LOST 和 RELEASED:续期成功保持 ACTIVE;续期失败立即进入 LOST,触发取消信号;业务自然完成后,仅在仍为 ACTIVE 时执行最终提交,然后按所有者令牌安全释放。
// 租约丢失时取消业务上下文,阻止后续可取消工作继续推进
func watchLease(ctx context.Context, cancel context.CancelFunc, renew func(context.Context) bool) {
ticker := time.NewTicker(renewInterval)
defer ticker.Stop()
for {
select {
case
核对点有两个。第一,续期应在租约剩余时间进入危险区之前发起,并为网络抖动保留余量;第二,业务代码必须真正响应取消。只设置一个 lost = true 标志却让数据库写入、对象存储上传和消息发送继续进行,并没有缩小风险。
让围栏令牌在资源侧拒绝旧持有者
对于不能及时取消的 I/O,或者已经发到下游的请求,需要围栏令牌补上资源侧保护。每次成功获得锁时,协调系统同时分配一个单调递增整数,例如 A 获得 41,B 后来获得 42。下游资源保存最近接受的 last_fence,只接受比它更大的令牌。
围栏令牌和随机所有者令牌不能互换。随机值适合验证“释放的是不是自己的锁”,但不能比较新旧;围栏值必须可排序,用来判断哪个持有者更新。Redis 的普通锁命令不会自动替所有共享资源完成这个检查,资源侧也必须参与。
在单 Redis 协调点的简化设计中,可以把“锁不存在时分配序列并写入所有者”放进一个 Lua 脚本。若使用 Redis Cluster,脚本涉及的键还需要满足同一哈希槽要求;更复杂的多节点锁方案则应单独设计可靠的全局序列来源。
-- 锁已存在时返回空,调用方不能进入受保护工作
if redis.call('exists', KEYS[1]) == 1 then
return nil
end
-- 只有成功取得锁的客户端才获得新的单调递增围栏号
local fence = redis.call('incr', KEYS[2])
redis.call('psetex', KEYS[1], ARGV[2], ARGV[1])
return fence
下游数据库可以把令牌检查与业务写入放在同一条原子语句里:
-- 仅接受比当前 last_fence 更新的持有者写入
UPDATE report_state
SET result_uri = :result_uri,
last_fence = :fence
WHERE report_id = :report_id
AND last_fence
回到前面的例子:B 持有 42 并先写入后,资源侧记录 last_fence = 42。A 即使从暂停中恢复并携带 41 发起写入,也会因条件不满足被拒绝。这个拒绝与 Redis 锁键当前是否存在无关,因此能防住迟到的旧客户端。

接受更强安全边界带来的成本
资源必须能比较令牌。数据库表可以增加 last_fence,对象存储或第三方 API 如果没有条件写能力,就需要在前置服务中串行化或换用支持条件更新的存储。围栏只在最终副作用执行点检查才有效。
令牌分配要保持单调。进程内计数器、时间戳和随机数都不满足要求。序列来源重置、回档或多活冲突会破坏“更大就是更新”的判断,因此序列的持久化与故障模型必须单独评估。
取消不是回滚。租约丢失后取消上下文,只能阻止尚未开始或支持取消的操作。已经提交的数据库事务、已发送的消息和外部 API 调用仍需依靠幂等键、条件更新或补偿逻辑处理。
续期次数要有限。Redis 官方文档指出,续期可以延长锁的生命周期,但不应无限重获,否则会损害活性。长任务应拆分检查点、限制最大持锁时间,并允许失败后从稳定状态重试。
用运行信号判断方案是否生效
架构上线后,不能只看“有没有重复任务”。建议至少记录四组信号:
| 信号 | 说明 | 异常时先查什么 |
|---|---|---|
| 续期失败率 | 租约无法确认或所有者不匹配的比例 | Redis 延迟、超时、线程暂停 |
| 取消传播延迟 | 检测 LOST 到业务停止新副作用的时间 | 阻塞调用是否支持取消 |
| 围栏拒绝量 | 资源侧拒绝旧令牌的次数 | 长暂停、任务重叠、重试风暴 |
| 提交时租约余量 | 关键提交时距离 TTL 到期的剩余时间 | 租约是否过短、任务是否应拆分 |
这些指标的目标不是追求全部为零。围栏拒绝恰恰说明最后一道门在工作;真正需要警惕的是持续增长、取消传播过慢,或者大量任务在租约即将到期时才提交。
继续改进时优先处理的边界
第一,获取失败应随机退避,避免多个客户端同步重试。第二,释放锁必须比较随机所有者令牌,不能直接 DEL。第三,不要把服务器墙上时钟当作绝对安全边界;Redis 官方文档提醒 TTL 过期并非使用单调时钟,时钟变化可能影响锁安全。第四,关键任务同时需要幂等设计,因为围栏令牌解决的是新旧持有者顺序,不会自动消除同一持有者的重复请求。
常见问题
续期请求超时,但可能已经在 Redis 成功了,能继续吗?
不能按成功处理。客户端无法确认租约状态时,应进入 LOST 并停止新的受保护副作用。之后可以查询状态用于诊断,但不能用不确定结果继续执行关键写入。
只用看门狗自动续期够不够?
不够。看门狗降低正常慢任务过期的概率,却无法消除长暂停、网络分区和迟到请求。对一致性敏感的共享资源仍应实现围栏检查。
围栏令牌一定要存在 Redis 吗?
不一定。它可以来自能提供单调序列的数据库或协调系统。关键是每次新租约拿到更大的令牌,并由最终资源原子地比较和保存。
任务已经不可取消怎么办?
把不可取消阶段推迟到最后,并在进入前再次检查租约余量;最终提交必须带围栏令牌。若外部系统不支持条件写,则需要加一层能够执行门禁的代理或改造业务流程。
参考资料:Redis Distributed Locks:https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/;Redis SET:https://redis.io/docs/latest/commands/set/
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习