读完这篇,你能做什么
能画出旧持有者恢复的时间线,并明确令牌校验应由哪个资源执行。
学习理由、准备要求与知识点
为什么学它
分布式锁最危险的窗口发生在租约过期后,持锁线程仍可能写入旧结果。
先读这些内容
知识点清单
- SET NX PX
- 唯一持有者令牌
- 比较删除
- 租约过期
- 暂停与续租
- 隔离失败
- fencing token
- 数据库幂等约束
运行前先确认Redis 7.4 单实例租约命令与 Jedis 5.2.0 参数源码;比较删除使用 Lua。Java 17 完整程序只模拟资源端 fencing 与幂等检查,不实现跨节点锁服务或持久存储。
01先弄懂它是什么
考虑分布式锁之前,先说清重复执行会造成什么后果。两个实例同时重建缓存,可能只是多算一次;两个实例同时扣款或覆盖文件,则可能把数据改错。先写出必须保证的规则,例如不能重复加余额、旧任务不能覆盖新结果,再选择与风险和成本相适应的保护办法。
锁常带有效期,这叫租约:持有者崩溃后,到期能让别人继续工作。但到期不会让原线程自动停止。假如 A 拿锁后长时间暂停,锁到期,B 拿新锁更新了数据;A 恢复后仍可能发送旧写入。因此 Redis 中只有一把有效锁,也不能单独保证外部资源不会收到新旧两批任务的请求。
一套方案里常出现三个编号:随机串用来核对这次锁是不是自己拿到的,释放时据此避免删掉别人的锁;不断递增的 fencing token 是代次,真正接收写入的资源据此拒绝过时持有者;业务操作 ID 用来认出重试是否还是同一笔操作。三者不能混用。随机串不能比较先后,代次较新也可能重复提交已经做过的操作;同一操作 ID 还必须对应同样的业务内容。
文中这几个词,先认一认
- 租约
- 有有效期的占用许可;到期后别人可以竞争,不代表旧任务会自动停止执行。
- Fencing token
- 按新旧顺序递增的代次编号,必须由可靠协议产生,并由资源端检查,才能拒绝旧持有者的写入。
- 幂等记录
- 保存业务操作编号、请求内容和结果,重试时识别已经处理过的请求并复用结果。
02看看它怎么工作
单实例入门加锁使用一条 SET lock:export 随机令牌 NX PX 30000,同时完成不存在检查与到期设置。释放时用 Lua 在同一次执行中比较保存的令牌并删除。先 GET 再 DEL 有检查与使用之间的空隙,A 检查后暂停,B 已经换锁,A 再删除就可能误删 B。这个修复守住的是锁记录,并未解决外部业务写入。
续租也应比较持有者令牌后再延长有效期,并对失败与未知响应作明确处理。更长 TTL 会减慢故障恢复,更频繁续租会增加依赖与调度成本。任何预计耗时都不能为所有暂停和网络延迟提供绝对上界,因此关键资源仍需在自己的原子写入点检查代次,不能只在客户端执行前查询一次锁是否存在。
下面的模型让资源端记住已接受的最大代次,并把业务修改、更新代次和保存幂等结果放在同一段同步操作内。代次更旧就拒绝;同代次的不同业务操作可以继续;重复业务操作返回原结果。模型假定代次由可信服务发放,并未证明 Redis INCR 在任意故障切换后都不会倒退。真实存储必须保证这些状态一起生效或一起不生效,并能在重启后恢复已经提交的结果;还要防止调用者伪造一个极大代次阻止后续请求。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["A 获得代次十一后暂停"] --> B["租约到期 B 获得代次十二"]
B --> C["资源接受十二并保存结果"]
C --> D["A 恢复提交十一"]
D --> E["资源比较代次并拒绝旧请求"]
C --> F["十二的同一操作重试返回原结果"]按顺序看,每一步做了什么
- 明确受保护资源和允许或禁止的重复行为。
- 使用原子加锁与比较释放,识别每次租约。
- 在资源写入点检查代次,同时实现业务幂等。
- 演练暂停、续租失败、响应丢失和资源重启后的行为。
03对照实现代码
Jedis v5.2.0 · SetParams#nx;SetParams#px;SetParams#addParams
下面是原创命令参数教学模型,展示一个不可拆分的 SET 请求;不复制 Jedis 源码,不实现 Redis 服务端锁,也不包含可靠代次生成器。
import java.util.*;
class SourceModel {
static List<String> acquire(String key, String randomOwner, long ttlMillis) {
if (key.isBlank() || randomOwner.isBlank() || ttlMillis <= 0)
throw new IllegalArgumentException("invalid lease");
return List.of("SET", key, randomOwner, "NX", "PX", Long.toString(ttlMillis));
// 参数作为一次命令发送,不能先 SETNX 再单独设置到期
}
static String releaseScript() {
return """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""";
// 比较和删除处于同次 Lua 执行,避免删除后续持有者的锁
}
static List<String> releaseArguments(String key, String randomOwner) {
return List.of("EVAL", releaseScript(), "1", key, randomOwner);
// KEYS 和 ARGV 显式分开;外部资源仍需独立 fencing 检查
}
}真实 nx 设置存在性条件,px 设置毫秒到期类型和值;addParams 按协议追加参数。正确组合只是表达请求语义的起点。
构造器对象并不证明服务端已接受租约,应用仍需解释 SET 的成功、未取得与连接结果未知。
教学模型省略编码、网络和连接管理;它不会把随机所有者值变成单调代次,业务层必须分清两个概念。
Lua 能让 Redis 内的操作连续执行,不能同时把远程数据库更新或文件写入也包进去。这里只分析客户端怎样表达 Redis 请求,不能把几层方法封装当成已经实现跨系统事务。
代码先调用谁,再调用谁
- 生成本次租约的随机持有者值
- 构造 SET NX PX 参数
- 读取加锁结果
- 资源端校验代次及操作标识
- 比较释放本次租约
04动手写一个例子
资源端单调代次与幂等结果的原子状态机
对一个非负计数资源处理 token、operationId 和 delta。拒绝旧代次;相同操作标识与相同内容只执行一次,返回首次结果;相同标识换内容拒绝。代次、计数和结果记录的修改必须同时发生。
- 输入 1
11/A/+5;12/B/+3;11/C/+2- 输出 1
5;8;旧代次被拒绝,当前值8
- 输入 2
13/A/+5,A此前成功结果5- 输出 2
返回历史结果5,当前值仍8,最高代次变13
先检查请求格式与代次是否落后,确保旧持有者即使使用新操作标识也无法改变状态。
已有操作标识需要核对 delta;一致时只推进可接受代次并返回已记录结果,不再加数。
新操作先计算溢出与非负约束,再一次更新计数、代次和结果表。生产中应把该边界映射为同一可靠事务或等价原子协议。
为什么这样写是对的最高代次从不下降;每个操作标识至多产生一次计数变化;已记录结果和请求内容保持一致。
哈希表查找与插入平均为常数;m 为保留的幂等操作数。持久数据库、日志和复制成本不包含在内。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Receipt(long delta, long result) {}
static final class Resource {
private long highest = 0, value = 0;
private final Map<String, Receipt> applied = new HashMap<>();
synchronized long apply(long token, String operation, long delta) {
if (token <= 0 || operation == null || operation.isBlank())
throw new IllegalArgumentException("request");
if (token < highest) throw new IllegalStateException("stale token"); // 旧代次先拒绝
Receipt previous = applied.get(operation);
if (previous != null) {
if (previous.delta() != delta) throw new IllegalArgumentException("operation reused");
highest = token; // 新代次重试相同业务可以推进隔离线,但不重复修改值
return previous.result();
}
long next = Math.addExact(value, delta);
if (next < 0) throw new IllegalArgumentException("negative value");
value = next;
highest = token;
applied.put(operation, new Receipt(delta, next)); // 三份状态处于同一个原子边界
return next;
}
synchronized long value() { return value; }
synchronized long highest() { return highest; }
}
static void check(boolean ok) { if (!ok) throw new AssertionError(); }
static void bad(Class<? extends RuntimeException> type, Runnable action) {
try { action.run(); }
catch (RuntimeException e) { if (type.isInstance(e)) return; throw e; }
throw new AssertionError("expected rejection");
}
public static void main(String[] args) {
Resource r = new Resource();
check(r.apply(11,"A",5)==5);
check(r.apply(11,"A",5)==5 && r.value()==5); // 相同请求不累计
check(r.apply(12,"B",3)==8);
bad(IllegalStateException.class, () -> r.apply(11,"C",2));
check(r.apply(12,"C",2)==10); // 同一有效代次可承载不同操作
check(r.apply(13,"A",5)==5 && r.value()==10 && r.highest()==13);
bad(IllegalStateException.class, () -> r.apply(12,"D",1));
bad(IllegalArgumentException.class, () -> r.apply(13,"A",6));
bad(IllegalArgumentException.class, () -> r.apply(14,"E",-11));
check(r.value()==10 && r.highest()==13); // 无效请求不留下半更新
System.out.println("10 fencing and idempotency checks passed");
}
}这些特殊情况也要试一试
- 同一 token 不代表同一操作,不能用代次替代
operationId去重。 - 返回的历史结果可能不同于资源当前值,接口应说明它是该操作的收据。
- 内存模型重启会丢状态,不能作为真实持久 fencing 服务。
进阶选读 · 生产拓展:持久化资源检查与幂等记录回收
场景资源服务重启或幂等表清理后,旧请求仍可能重新到达。
做法把最高代次、业务变化和操作收据放入一个持久事务;回收收据前定义最大重试窗口、外部回放入口和归档查询。若代次发放与锁获取分离,必须通过协议确保代次对应实际授予顺序。
复杂度索引查询通常随数据结构增长,事务日志与复制带来额外 I/O;保留窗口决定状态容量。
好处与代价更强保证需要存储和协调成本,无法靠更长租约或定期清空去重表免费获得。
05项目里怎么用
真正被修改的资源必须自己检查代次。例如数据库要把代次比较和业务条件放在同一事务及正确保护下,不能先 SELECT 看一眼,留出并发空隙后再无条件 UPDATE。若文件存储没有条件写入能力,仅把 token 传过去也没人能核验,应改为经支持版本控制的服务写入。token 只记日志,不能阻止旧请求。
代次编号还要经得起崩溃和切换。一个节点上一直递增,不代表换到落后副本后仍不会倒退;若任意调用者能提交极大编号,也可能让正常请求一直被拒绝。生成与授予方式要覆盖系统准备应对的故障,并把调用权限检查和代次检查一起落实。
先选择能直接守住业务约束的机制。数据库唯一键可以限制同一用户同一券的重复订单,条件更新可以保护库存下界,状态机可以拒绝过时状态跳转。租约常用于降低竞争和减少重复工作,但不能替代这些约束。验证时让旧任务明确暂停至新任务写入之后,再恢复旧任务,比只压测正常并发更能发现边界错误。
容易踩的坑
- 把随机所有者字符串称为递增 fencing token。
- 先 GET 再 DEL,留下误删新锁的窗口。
- 续租线程仍活着就认为租约始终有效。
- 资源端只在日志中记录代次,写入时没有原子检查。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 租约减少重复工作 | 实现相对直接且可自动到期 | 暂停和切换仍可能产生重复执行 | 重复代价可控的缓存重建 |
| 资源端 fencing | 能拒绝已经落后的任务写入 | 需要可靠代次与资源协议支持 | 较旧任务不可覆盖新结果的流程 |
| 业务约束与幂等状态机 | 直接保护重复订单和重复变更 | 需要设计唯一性、事务和结果查询 | 库存、订单等关键业务事实 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01安全释放为什么仍挡不住旧业务写入?
展开参考答案与推导
比较删除只影响锁键;暂停线程仍可向外部资源发送请求,资源必须在修改点检查代次。
02Fencing 是否自动提供业务幂等?
展开参考答案与推导
不提供。同一代次可有多个操作,甚至新代次重试旧业务;需要独立操作标识和结果记录。
03为什么不能只把 token 放进请求日志?
展开参考答案与推导
记录没有拒绝能力,受保护资源必须原子地比较代次后再决定是否更新。
换个情况,还会处理吗
01A暂停后B完成,A恢复时应发生什么?
展开参考答案与推导
A的旧代次在资源端被拒绝;即使它误以为仍持有锁,也不能覆盖B已接受的结果。
02请求返回前断网,新持有者重试同一操作怎么办?
展开参考答案与推导
使用稳定操作标识和相同内容查询或提交,资源返回原收据而不重复修改。
03资源无法做版本检查,延长TTL足够吗?
展开参考答案与推导
不足。应考虑条件写服务、数据库约束或幂等状态机;TTL无法约束所有暂停时长。
写代码试一试
01最高代次12收到11的新操作,如何处理?
- 输入 / 条件
highest=12,token=11- 输出 / 预期
拒绝且状态不变
提示 先检查代次。
展开参考答案与推导
新操作标识不能让旧持有者越过隔离线。
02同一操作A首次加5,重试改为加6,怎么办?
- 输入 / 条件
A/+5后A/+6- 输出 / 预期
拒绝标识复用
提示 幂等键必须绑定请求内容。
展开参考答案与推导
静默返回旧结果会隐藏调用错误,再执行则造成重复业务;应显式报冲突。
03代次13重试已完成A,当前值10而A结果5,应返回什么?
- 输入 / 条件
A的收据结果5- 输出 / 预期
返回5,当前值保持10
提示 区分操作结果和当前快照。
展开参考答案与推导
推进最高代次至13并返回原收据;不再应用delta。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能画出旧持有者恢复的时间线,并明确令牌校验应由哪个资源执行。
本篇重点加练
fencing 排除旧持有者写入,幂等识别重复业务,两者需要独立身份与持久状态。
- fencing token
- 数据库幂等约束
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
两个进程竞争同一锁,进程 A 的获取请求结果丢失,稍后执行释放。为每次请求记录持有者令牌与租约信息,审查释放依据。
- 提交内容
- 请求轨迹与释放契约
- 怎样算完成
- 区分未获得与结果未知
- 覆盖释放请求重复
练到的知识点SET NX PX · 唯一持有者令牌 · 比较删除
- 作业 02原理问答
工作任务通常耗时 100 毫秒,但可能发生 2 秒暂停。列出租约续期线程与业务线程之间仍可能出现的时序,并说明暂停证据如何获得。
- 提交内容
- 暂停时间线与假设表
- 怎样算完成
- 包含续期失败和迟到结果
- 不把定时器计划等同于实际执行
练到的知识点租约过期 · 暂停与续租
- 作业 03代码推演
资源端已接受较高代次写入,但自身重启后代次记录丢失。评审后续拒绝旧写入的前提,列出代次生成方与校验方各需保存什么。
- 提交内容
- 重启前后状态契约
- 怎样算完成
- 区分令牌唯一性和单调性
- 包含资源端恢复
练到的知识点fencing token
综合应用
- 作业 04面试场景
锁服务认为 A 的租约结束,实际存储接口却仍接受 A 的写入。设计资源端需要检查的协议字段及其顺序含义。
- 提交内容
- 资源接口契约
- 怎样算完成
- 明确检查发生在何处
- 说明代次来源和持久化要求
练到的知识点隔离失败 · fencing token
- 作业 05设计评审
两个合法租约周期都处理同一个订单扣款事件。审查锁代次与数据库唯一约束分别识别什么,并定义相同事件参数变化的处理。
- 提交内容
- 身份及约束对照表
- 怎样算完成
- 区分持有者、代次、业务事件
- 覆盖合法重放与冲突重用
练到的知识点数据库幂等约束
- 作业 06编程实践
写本地 Java 租约与受保护资源模拟器,以可控时钟安排续期、暂停、接管、写入和重复请求;输出每次接受或拒绝的证据。
- 提交内容
- 源码、事件脚本与状态日志
- 怎样算完成
- 保护规则由资源端验证
- 注明模型不证明真实网络分区下的可用性
练到的知识点SET NX PX · 唯一持有者令牌 · 比较删除 · 租约过期 · 暂停与续租 · 隔离失败 · fencing token · 数据库幂等约束
- 作业 07设计评审
同一个操作 ID 第一次由租户甲使用,之后被租户乙以相同参数使用。设计幂等身份域以及历史结果的访问权限。
- 提交内容
- 去重身份与返回契约
- 怎样算完成
- 不泄露其他租户结果
- 区分同业务重试与身份碰撞
练到的知识点数据库幂等约束
- 作业 08编程实践
实现本地受保护资源的重启演示,允许分别丢失或恢复代次状态与业务去重状态,比较四种恢复组合可支持的承诺。
- 提交内容
- 程序、持久快照与报告
- 怎样算完成
- 每次运行使用独立临时数据
- 不模拟真实共识或网络分区协议
练到的知识点fencing token · 数据库幂等约束
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
文件导出服务只能向旧式共享目录写文件,无法原子核验代次。评估引入分布式锁后仍未满足的约束,并提出可验证的交付契约。
- 提交内容
- 风险评审与验收计划
- 怎样算完成
- 承认资源接口能力限制
- 区分输出重复和内容被覆盖
练到的知识点租约过期 · 暂停与续租 · 隔离失败 · fencing token · 数据库幂等约束
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 跨域迁移
将 fencing 与幂等迁移到远程文档编辑:旧编辑器恢复后可能提交更旧内容,同一保存请求也会重发。分别定义文档版本和保存操作身份。
- 提交内容
- 跨域协议与边界样例
- 怎样算完成
- 区分冲突编辑和重复保存
- 包含合法的新版本重用旧操作 ID
练到的知识点fencing token · 数据库幂等约束
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
Redis:Distributed Locks (新标签页)↗
阅读租期、唯一令牌、安全释放和故障模型,识别锁过期后的并发风险。
- 实践参考文档英文
Redis:SET (新标签页)↗
阅读 NX、PX 与 KEEPTTL,明确条件写入、租期和覆盖语义。
- 深入教程英文
Redis:Lua 脚本入门 (新标签页)↗
阅读脚本原子执行、阻塞与显式 KEYS,控制脚本复杂度并验证写入前提。
- 深入参考文档英文
Redis:Replication (新标签页)↗
阅读异步复制、故障切换与 WAIT 的限制,避免把副本等同于备份。
- 深入参考文档英文
Jedis 5.2.0 SetParams (新标签页)↗
核对 Java 参数封装没有承担代次生成与资源隔离。