qinghuan
← 数据系统 / 全部笔记

单元 105 / 数据系统

P1 · 重点提升L3 · 深入

分布式锁到期后:怎样挡住旧任务的写入

用旧线程暂停后又恢复的例子,理解锁到期为什么不等于任务停止,以及资源端怎样拒绝旧写入。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能画出旧持有者恢复的时间线,并明确令牌校验应由哪个资源执行。

学习理由、准备要求与知识点

为什么学它

分布式锁最危险的窗口发生在租约过期后,持锁线程仍可能写入旧结果。

先读这些内容

知识点清单

  • 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["十二的同一操作重试返回原结果"]

按顺序看,每一步做了什么

  1. 明确受保护资源和允许或禁止的重复行为。
  2. 使用原子加锁与比较释放,识别每次租约。
  3. 在资源写入点检查代次,同时实现业务幂等。
  4. 演练暂停、续租失败、响应丢失和资源重启后的行为。

03对照实现代码

SetParams 构建 NX 与 PX 参数,而非完成锁正确性 (新标签页)↗

Jedis v5.2.0 · SetParams#nx;SetParams#px;SetParams#addParams

下面是原创命令参数教学模型,展示一个不可拆分的 SET 请求;不复制 Jedis 源码,不实现 Redis 服务端锁,也不包含可靠代次生成器。

核心机制代码 · java
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 请求,不能把几层方法封装当成已经实现跨系统事务。

代码先调用谁,再调用谁

  1. 生成本次租约的随机持有者值
  2. 构造 SET NX PX 参数
  3. 读取加锁结果
  4. 资源端校验代次及操作标识
  5. 比较释放本次租约

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;一致时只推进可接受代次并返回已记录结果,不再加数。

新操作先计算溢出与非负约束,再一次更新计数、代次和结果表。生产中应把该边界映射为同一可靠事务或等价原子协议。

为什么这样写是对的最高代次从不下降;每个操作标识至多产生一次计数变化;已记录结果和请求内容保持一致。

时间 平均每次 O(1)额外空间 O(m)

哈希表查找与插入平均为常数;m 为保留的幂等操作数。持久数据库、日志和复制成本不包含在内。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 48 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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
  • 数据库幂等约束

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    两个进程竞争同一锁,进程 A 的获取请求结果丢失,稍后执行释放。为每次请求记录持有者令牌与租约信息,审查释放依据。

    提交内容
    请求轨迹与释放契约
    怎样算完成
    • 区分未获得与结果未知
    • 覆盖释放请求重复

    练到的知识点SET NX PX · 唯一持有者令牌 · 比较删除

  2. 作业 02原理问答

    工作任务通常耗时 100 毫秒,但可能发生 2 秒暂停。列出租约续期线程与业务线程之间仍可能出现的时序,并说明暂停证据如何获得。

    提交内容
    暂停时间线与假设表
    怎样算完成
    • 包含续期失败和迟到结果
    • 不把定时器计划等同于实际执行

    练到的知识点租约过期 · 暂停与续租

  3. 作业 03代码推演

    资源端已接受较高代次写入,但自身重启后代次记录丢失。评审后续拒绝旧写入的前提,列出代次生成方与校验方各需保存什么。

    提交内容
    重启前后状态契约
    怎样算完成
    • 区分令牌唯一性和单调性
    • 包含资源端恢复

    练到的知识点fencing token

综合应用

  1. 作业 04面试场景

    锁服务认为 A 的租约结束,实际存储接口却仍接受 A 的写入。设计资源端需要检查的协议字段及其顺序含义。

    提交内容
    资源接口契约
    怎样算完成
    • 明确检查发生在何处
    • 说明代次来源和持久化要求

    练到的知识点隔离失败 · fencing token

  2. 作业 05设计评审

    两个合法租约周期都处理同一个订单扣款事件。审查锁代次与数据库唯一约束分别识别什么,并定义相同事件参数变化的处理。

    提交内容
    身份及约束对照表
    怎样算完成
    • 区分持有者、代次、业务事件
    • 覆盖合法重放与冲突重用

    练到的知识点数据库幂等约束

  3. 作业 06编程实践

    写本地 Java 租约与受保护资源模拟器,以可控时钟安排续期、暂停、接管、写入和重复请求;输出每次接受或拒绝的证据。

    提交内容
    源码、事件脚本与状态日志
    怎样算完成
    • 保护规则由资源端验证
    • 注明模型不证明真实网络分区下的可用性

    练到的知识点SET NX PX · 唯一持有者令牌 · 比较删除 · 租约过期 · 暂停与续租 · 隔离失败 · fencing token · 数据库幂等约束

  4. 作业 07设计评审

    同一个操作 ID 第一次由租户甲使用,之后被租户乙以相同参数使用。设计幂等身份域以及历史结果的访问权限。

    提交内容
    去重身份与返回契约
    怎样算完成
    • 不泄露其他租户结果
    • 区分同业务重试与身份碰撞

    练到的知识点数据库幂等约束

  5. 作业 08编程实践

    实现本地受保护资源的重启演示,允许分别丢失或恢复代次状态与业务去重状态,比较四种恢复组合可支持的承诺。

    提交内容
    程序、持久快照与报告
    怎样算完成
    • 每次运行使用独立临时数据
    • 不模拟真实共识或网络分区协议

    练到的知识点fencing token · 数据库幂等约束

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    文件导出服务只能向旧式共享目录写文件,无法原子核验代次。评估引入分布式锁后仍未满足的约束,并提出可验证的交付契约。

    提交内容
    风险评审与验收计划
    怎样算完成
    • 承认资源接口能力限制
    • 区分输出重复和内容被覆盖

    练到的知识点租约过期 · 暂停与续租 · 隔离失败 · fencing token · 数据库幂等约束

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    将 fencing 与幂等迁移到远程文档编辑:旧编辑器恢复后可能提交更旧内容,同一保存请求也会重发。分别定义文档版本和保存操作身份。

    提交内容
    跨域协议与边界样例
    怎样算完成
    • 区分冲突编辑和重复保存
    • 包含合法的新版本重用旧操作 ID

    练到的知识点fencing token · 数据库幂等约束

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗