16.2 悲观离线锁

在业务事务开始时取得排他锁,把高损失冲突前移为等待、超时与失主恢复。

学习目标

  • 能用“两个客服并行修改订单”写出获取、持有、释放和失主恢复的代码责任链
  • 能修改锁协议的超时与重试策略,并用等待时间、死锁次数和冲突损失判断是否适用
  • 能在练习中回答:客服 A 失联且不释放锁时,客服 B 何时可以安全接管订单?

为什么 16.2 悲观离线锁 值得单独学习

想象两名客服同时打开同一张订单。客服 A 正在确认库存,客服 B 也看到了旧金额;如果两个人都能直接保存,最后一次点击可能悄悄覆盖前一次决定。这个问题不是“谁的页面更快”,而是要先确定谁拥有修改资格。

没有这条规则时,系统可能在请求层面都返回成功,却让订单金额、库存承诺和审计记录互相矛盾。我们先把修改资格变成一张可见的通行证,再讨论等待多久、通行证怎样失效,以及失主回来后如何收场。

先建立直觉:把冲突前移为等待

先猜一猜:如果一张高价值订单只能由一个人修改,系统应该让两个人都先写完再比较,还是让第二个人先等一等?前者把冲突成本留到提交时,后者把冲突成本变成可观测的等待或超时。

这个选择有明确代价。等待太久会拖慢客服,等待太短又会让正常操作频繁失败;持有者失联还会留下无人负责的占用。因此,锁不是一个按钮,而是一份必须写清生存期和恢复路径的协议。

概念模型:四个状态必须可观察

把冲突检测从提交时前移到读取或编辑开始时。它适合冲突损失高、并发修改必须排队的场景,但不代表所有长事务都应该无限等待。

锁管理器发放的是。协议至少要记录四个状态:谁获取、何时超时、何时主动释放、持有者消失后谁负责恢复。只写“获取成功”而没有后三项,故障时仍然无法判断谁可以继续。

锁粒度决定等待范围

过细,会让一个业务决定需要协调多把锁;粒度过粗,则会把互不相关的订单也排进同一条队列。第 57 章只保护订单 #42,评审时仍要问清订单明细和库存是否必须同锁。

租约与失主恢复

替代“永不失效”的锁,可以把失联变成可处理的恢复事件。过期前不能擅自接管;过期后也要重新读取并校验版本,避免旧客户端回来覆盖新决定。

如果事务 A 等待事务 B,而 B 又等待 A,就形成。统一加锁顺序、设置超时和记录等待图,才有机会把它从偶发卡死变成可重放的测试结果。

专属代码案例:两个客服并行修改订单

案例固定为订单 #42:客服 A 先读取并取得锁,客服 B 随后请求同一资源。业务代码要把锁包住真正需要保护的读取、校验和写入,而不是只包住一次数据库调用;同时必须在 finally 中释放,避免正常异常路径遗留占用。

async function updateOrder(orderId: string, patch: OrderPatch) {
  const lease = await locks.acquire(`order:${orderId}`, { timeoutMs: 2_000 });
  if (!lease) return { status: "busy" as const };
 
  try {
    const order = await orders.read(orderId);
    const next = validateAndApply(order, patch);
    await orders.save(next);
    return { status: "saved" as const, leaseId: lease.id };
  } finally {
    await locks.release(lease.id);
  }
}

这段代码表达的是教学协议,不绑定某个 ORM。acquire 的超时决定 B 如何失败,read 必须发生在锁内,saverelease 的先后要能被日志重放。生产实现还要补上租约续期、失主检测、幂等处理和锁服务不可用时的拒绝策略。

三步可视化实验

猜一猜:把“持锁时间”调长会发生什么?预期是 A 更容易完成一组原子业务决定,但 B 的等待和超时风险同时上升。

专属代码协议图 · 责任先占住
Pessimistic Offline Lock:先占责任,再修改读取时取得锁 · 客服 A 读取订单时先取得排他锁,其他写入者不能绕过同一入口。1. 读取时取得锁A → 锁管理器2. 持锁完成业务A 持锁 · B 等待3. 提交并恢复所有权提交 · 释放 · 恢复客服 A · 当前持有者锁管理器 · 唯一裁决点客服 B · 竞争者读取订单 #42取得排他锁 ✓等待业务处理提交后释放锁owner = Aresource: order#42owner: Alease: 30s等待获取请求timeout + release + recovery请求 order#42 的排他锁尚未竞争不能绕过锁直接写入等待 A 的释放信号wait ≤ timeout验收证据:锁的获取、超时、释放和失主恢复都可被记录;只有数据库一次提交成功还不够。选择信号:冲突损失高且可接受等待;拒绝信号:长事务、死锁或锁粒度无法量化。悲观离线锁把冲突处理前移:先取得排他责任,再允许业务修改
交互验证:切换三个阶段,再注入失主故障;重置后应回到“读取时取得锁”。

从获取到恢复的三步证据链

分步1 / 3

1. 读取时取得排他锁

先预测:客服 A 读取订单 #42 后,客服 B 的写入请求应该出现什么状态?B 不能绕过锁直接写入,应记录等待开始时间和超时截止点。

专属代码协议图 · 责任先占住
Pessimistic Offline Lock:先占责任,再修改读取时取得锁 · 客服 A 读取订单时先取得排他锁,其他写入者不能绕过同一入口。1. 读取时取得锁A → 锁管理器2. 持锁完成业务A 持锁 · B 等待3. 提交并恢复所有权提交 · 释放 · 恢复客服 A · 当前持有者锁管理器 · 唯一裁决点客服 B · 竞争者读取订单 #42取得排他锁 ✓等待业务处理提交后释放锁owner = Aresource: order#42owner: Alease: 30s等待获取请求timeout + release + recovery请求 order#42 的排他锁尚未竞争不能绕过锁直接写入等待 A 的释放信号wait ≤ timeout验收证据:锁的获取、超时、释放和失主恢复都可被记录;只有数据库一次提交成功还不够。选择信号:冲突损失高且可接受等待;拒绝信号:长事务、死锁或锁粒度无法量化。悲观离线锁把冲突处理前移:先取得排他责任,再允许业务修改
交互验证:切换三个阶段,再注入失主故障;重置后应回到“读取时取得锁”。

常见误区

选择与拒绝矩阵

评审问题选择悲观离线锁的证据应拒绝或改用其他方案的信号
冲突写入冲突会造成高额业务损失冲突少且合并或重试成本低
等待用户能接受有上限的排队事务长、用户经常离线且不能等待
所有权获取、租约、释放和恢复都有责任人只能说“框架会自动解锁”
粒度一把锁正好覆盖一个业务不变量只能锁整库,或必须同时维护大量无关资源
替代已比较乐观离线锁与粗粒度锁因团队习惯而跳过冲突概率和死锁测量

何时拒绝这个模式

悲观离线锁不是“数据重要”四个字就足够的理由。若客服会长时间断网、编辑过程需要跨多个外部系统,持锁时间会远超用户可接受范围;此时应比较乐观离线锁、草稿合并或更细的业务不变量,而不是把等待时间无限放大。

相反,当订单修改冲突少但一次覆盖会触发不可逆的库存或合规后果时,乐观重试可能让用户反复填写同一表单。选择悲观锁的证据应来自冲突概率、损失、等待预算和恢复成本,而不是来自某个框架默认提供了锁 API。

重试还必须可区分“仍在等待”和“已经完成”。为提交请求分配,可以防止网络超时后客户端重发导致重复扣减;它不能替代排他锁,却能补上提交结果不确定的边界。

本章小结

  • 悲观离线锁把高损失冲突前移为等待或超时。
  • 排他锁必须有获取、持有、释放和恢复责任。
  • 锁粒度决定哪些无关请求会被一起阻塞。
  • 租约、统一顺序和等待图把故障变成可验证证据。
  • 只有冲突损失、等待预算和恢复成本共同支持时才采用。

可验证练习

练习

问题 1: 客服 A 持有订单 #42 的锁,客服 B 等待 2 秒后超时。请写出 B 的用户可见结果,并列出服务端必须保存的三条证据。

问题 2: 改 Demo 代码:下面的协议把锁包在保存调用之外,且没有超时。请改成“先获取、锁内读取与校验、finally 释放”的结构,并说明 B 会观察到什么。

async function saveOrder(orderId: string, patch: OrderPatch) {
  const order = await orders.read(orderId);
  const next = validateAndApply(order, patch);
  await locks.acquire(`order:${orderId}`);
  await orders.save(next);
}

问题 3: A 失联后,租约已经过期,B 取得锁并把订单金额改为 120。A 恢复在线后又提交旧草稿 100。如何拒绝这次旧提交?

前后导航

出处声明

资料与写作方式声明

本章以Martin Fowler《企业应用架构模式》与公开模式目录权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

悲观离线锁
在业务编辑开始前先占住修改资格,让竞争者等待或超时的并发模式。
排他锁
同一时刻只允许一个持有者写入某个资源的资格。
锁粒度
一把锁覆盖的资源范围,范围越大,可能一起等待的请求越多。
租约
带过期时刻的临时锁凭证,用来处理持有者失联。
死锁
多个持有者互相等待对方释放资源,谁都无法继续的状态。
幂等键
重复提交时仍只产生一次业务结果的稳定请求标识。

讨论

评论区加载中…