缓存击穿时分布式锁为什么没生效:租约、过期与幂等的三个边界

架构解释图(确定性渲染)|租约、过期与幂等三段边界各自的负责范围
摘要| 缓存过期的瞬间,几个节点同时回源,接口引擎的分布式锁明明开着,数据库里却出现了重复写入。现场通常被归成“锁没生效”,拆开当前实现会发现三个各自独立的边界:锁租期与执行预算共用同一个秒数、租期可能在回调中途到期、以及 fencing token 没有被写进条件更新。
✦ 01|现场:锁开关是打开的,缓存还是被重建了三次
凌晨的促销开场,商品详情缓存刚过 300 秒有效期。三个节点几乎同时回源,数据库里留下三次重复写入,客户端拿到最后一份,库存字段却已经被覆盖了两轮。
值班同学先去确认分布式锁:开关是打开的,Key 也没配错。问题于是被归成“锁没生效”。这个结论只对了一半——锁生效了,只是它保证的东西和业务以为它保证的东西不是同一件。
- 租约边界:锁在 Redis 上活多久,由接口的执行预算直接派生,而不是由业务自己声明。
- 过期边界:租期在临界区中间到期时,第二个持有者可以立刻进入,两位执行者都以为自己独占。
- 幂等边界:即使两个持有者先后进入,副作用能不能只发生一次,取决于条件更新而不是锁。
边界视角| 锁只承诺同一时刻只有一个持有者。它不承诺临界区一定跑完,也不承诺副作用只发生一次。
下面按当前 Microi吾码AI 的分布式锁与接口引擎实现拆这件事:锁在哪一段生效、租期从哪里来、到期之后平台会怎么处理,以及业务侧到底还缺哪一步。
✦ 02|直觉方案为什么不够:SETNX 加 TTL 不是锁
不用平台的锁,改用自己的缓存拼一把,是最常见的替代方案:先判断 Key 是否存在,再写入自己的标识,用完删除。单机单线程下它能跑通,集群里从第一步就不成立。
检查与写入之间没有互斥,两个请求可以同时判定“可以加锁”。释放环节更危险:如果只能按 Key 删除,锁过期后旧持有者会删掉新持有者的锁,第二个持有者随即失去保护。
- 检查与写入不原子:判断与写入之间可被插入,互斥也就不存在。
- 没有唯一持有者令牌:释放无法区分是自己还是别人持有的锁。
- 没有续租与最长租期:节点暂停、网络分区和滚动发布都无法收敛。
平台缓存接口提供了原子的 SetIfNotExists,但它只解决第一步:一次“不存在才写入”加 TTL,没有持有者令牌、没有续租、也没有仅持有者释放。官方约定明确禁止用它拼分布式锁。
源码对得上| SetIfNotExists 的实现就是一次带过期时间的 SET NX,返回 true 或 false,没有任何令牌可以交给后续业务。
✦ 03|调用链:接口引擎的锁在哪一段生效
打开接口引擎的分布式锁开关以后,Microi吾码AI 会在调用 V8 之前先解析锁参数:锁 Key 默认取接口 Key,配置了 LockKey 时改成该请求参数的值,两者都会落到当前租户的命名空间里。
接着进入取锁循环,拿到锁才执行回调,而回调就是接口引擎里那段 V8 代码。所以锁在 V8 之外,V8 里看不到任何加锁动作。
// 接口引擎:读不到就回源,再写回缓存
var cacheKey = 'Product:Detail:' + V8.Param.ProductId;
var cached = V8.Cache.Get(cacheKey);
if (cached) {
return { Code: 1, Data: JSON.parse(cached) };
}
var rows = V8.FormEngine.GetTableData('Product', {
_Where: [['Id', '=', V8.Param.ProductId]],
_PageSize: 1
});
if (rows.Code !== 1) {
return { Code: 0, Msg: '回源失败:' + rows.Msg };
}
var detail = rows.Data[0];
V8.Cache.Set(cacheKey, JSON.stringify(detail), 300);
return { Code: 1, Data: detail };
这段代码本身没有任何互斥能力。它的读、回源、写回三步如果被多个节点同时执行,缓存会被写多次,旧数据也可能覆盖新数据。

架构解释图(确定性渲染)|接口引擎 → 锁参数 → Redis 租约 → V8 回调 → 副作用提交
易混点| 锁在接口引擎外层,缓存在 V8 之内,条件更新在最里层。三层各有各的边界,不能相互替代。
✦ 04|边界一:Timeout 同时是执行预算、锁租期和排队上限
接口引擎的 Timeout 字段本来只是执行预算。开启锁以后,平台按它派生锁参数:锁在 Redis 上的存活时间等于这个秒数,取锁失败后继续等待的上限也等于这个秒数。
也就是说,持锁上限、排队上限和失败面被同一个数字绑在了一起。
- 持锁上限:回调超过 Timeout,Redis 上的锁就会到期,新请求可以立刻拿到同一把锁。
- 排队上限:取锁失败会重试到等待时间耗尽,而等待时间同样来自这个值。
- 失败面:等不到锁的请求返回锁获取失败,接口看起来像加锁失败,现场却表现为缓存被击穿。
把 Timeout 直接调大,等于把三个上限一起调大,锁的回收窗口也跟着变长。更稳的方向是让单片任务变短:接口只处理一个分片,把进度写进业务表,返回 HasMore 续跑。
默认值| 接口引擎的默认执行预算是 10 分钟、最大 1 小时;普通调用不会自动续租,锁不会因为回调还在跑而延长。
✦ 05|边界二:租期会在回调中间到期
回调跑得比租期长时,Redis 上的锁先到期,第二个请求随后拿到同一把锁。此时两位持有者都认为自己独占临界区,平台并不知情。
平台不会永远沉默,只是发现得晚:回调开始前和回调结束后各做一次所有权确认,其中任何一次失败,本次调用都会被判为失败,并抛出“分布式锁租约已丢失”。
- 确认时机在回调之后,因此副作用动作可能已经发生。
- 走事务的数据库改动会随接口失败回滚,已经写入的缓存和已经发出的消息不会。
- 后台续租只对平台建立的可信后台执行上下文开放,普通调用没有续租循环。

架构解释图(确定性渲染)|令牌不匹配、达到最长租约、Redis 确认失败三种丢失来源的处理动作
所以长任务不能靠调大租期兜底。平台给出的做法是分片:单片控制在租期内,进度持久化,再由后台任务继续;调用方则必须把租约丢失当成硬失败向上暴露。
// 调用方:租约丢失必须向上暴露,不能吞掉后返回成功
var rebuild = V8.ApiEngine.Run('product_cache_rebuild', {
ProductId: V8.Param.ProductId
});
if (rebuild.Code !== 1) {
var msg = String(rebuild.Msg || '');
if (msg.indexOf('分布式锁租约已丢失') >= 0) {
return { Code: 0, Msg: '缓存重建未持有有效租约,结果不可信:' + msg };
}
return { Code: 0, Msg: '缓存重建失败:' + msg };
}
return { Code: 1, Data: rebuild.Data };
失败关闭| 租约丢失必须失败关闭。调用方把这类错误吞掉再返回成功,等于把一次已经不可信的写入标成可信。
✦ 06|边界三:fencing token 只有写进条件更新才算数
每次成功取锁,平台都会生成一个单调递增的 fencing token,并把持有者身份记成“令牌:实例标识”的形式。旧持有者的令牌一定小于新持有者,这个顺序就是判据。
关键在最后一步:平台把这个数字交给回调,却不替你比较。只有业务把它写进条件更新,过期的执行者才会被真正拒绝。
- 缓存侧没有原子的“比较并写入”,所以条件更新要落到数据库或状态机上。
- 条件更新返回 0 行就是一次被拒绝的旧执行者,应当当成正常业务分支处理。
- 锁的 Key 与条件更新的 Key 必须指向同一个业务对象,否则两道闸门会各自放行。
// 幂等边界:真正保证只写一次的是条件更新,而不是锁
var affected = V8.Db.FromSql(
'UPDATE Product SET Stock = @p0, Version = @p1 WHERE Id = @p2 AND Version < @p1')
.AddInParameter('@p0', V8.Param.Stock)
.AddInParameter('@p1', V8.Param.Version)
.AddInParameter('@p2', V8.Param.ProductId)
.ExecuteNonQuery();
if (affected === 0) {
return { Code: 1, Msg: '版本更旧,本次更新已被拒绝' };
}
V8.Cache.Remove('Product:Detail:' + V8.Param.ProductId);
return { Code: 1 };
三道闸| 锁负责减少并发,租约负责限制持有时间,fencing token 与条件更新负责拒绝过期执行者。缺最后一道,前两道都只是概率。
回到开头的现场,那三次重复写入里至少有一次来自租期到期后的旧执行者。这类写入不会被锁拦住,只会被条件更新拦住。
✦ 07|聚焦验证与生产取舍
上面的结论不是推断出来的。当前仓库里有两组聚焦测试专门锁定租约配置:可信后台任务的租约固定为 60 秒、最长续租边界 12 小时,普通调用保持固定租期,等待时间回落到租期本身,续租间隔始终小于租期。
本地跑一遍过滤测试,10 个用例全部通过、0 失败、退出码 0,耗时约 5.6 秒。这组测试不连接 Redis,只验证配置派生与持有者令牌解析这两段纯逻辑。
- 短请求走普通调用:Timeout 设成真实执行时间的上限,而不是希望它宽松一点更安全。
- 长任务走可信后台任务:单片控制在租期内,进度落表,用 HasMore 续跑。
- 所有副作用都带幂等键与条件更新:锁和租期只减少并发,不负责“只做一次”。

本地验证截图|聚焦过滤测试真实运行输出:10 项通过、0 失败、退出码 0
这三道闸门的顺序也不能颠倒。先用锁减少并发,再用租期限制每次持有多久,最后用条件更新保证无论谁进来都只写一次。
取舍| 把锁当互斥原语用,把条件更新当幂等原语用。两者混着用,缓存击穿就会以“锁没生效”的样子复发。
文中已标注的概念图由AI生成;源码与实测证据均来自当前工作区。
更多推荐




所有评论(0)