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

分布式锁续期失败后还能继续工作吗:租约与围栏令牌

来源: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 过短会频繁续期,过长会让故障后的恢复等待更久。无论取值多大,只要系统允许暂停或网络分区,就不能依赖“通常能及时续上”作为一致性保证。

Redis锁键、随机所有者令牌、TTL租约、续期器与业务工作静态依赖图
图1:租约与所有权边界说明图。Redis 协调域保存锁键、随机所有者令牌和 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 锁键当前是否存在无关,因此能防住迟到的旧客户端。

Redis协调域、客户端围栏令牌与下游资源门禁静态关系图
图2:围栏令牌资源门禁说明图。客户端 A 的旧令牌 41 与客户端 B 的新令牌 42 都到达资源域,但资源门禁依据 last_fence 只接受更新令牌;这是静态结构图,不是运行结果。

接受更强安全边界带来的成本

资源必须能比较令牌。数据库表可以增加 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/

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