16.4 隐含锁

让框架或层超类型自动取得离线锁,降低调用者遗忘锁定协议的风险。

学习目标

  • 能用 TypeScript 写出从读取、修改到提交的隐式锁责任链,并指出锁实际在哪里取得。
  • 能修改框架边界的故障开关与重试策略,用覆盖率、等待时间和冲突结果判断方案是否可靠。
  • 能回答:当某个修改入口绕过超类型而直接写库时,系统如何证明它不再满足隐含锁的保护承诺?

为什么 16.4 隐含锁 值得单独学习

想象两名客服同时打开同一张订单。A 正在确认库存,B 也看到了旧金额;如果两个人都能直接保存,最后一次点击可能悄悄抹掉前一次决定。这里真正需要解决的是:谁负责把“这次修改必须先获得保护”这条规则放进每一条入口。

没有这层安排时,页面和接口都可能返回成功,订单金额、库存承诺和审计记录却互相矛盾。我们先把保护藏进所有修改都会经过的共同路径,再观察这种便利会不会让失败变得不透明。

先建立直觉:把保护放在必经路径

先猜一猜:如果业务代码只写 order.setTotal(597),却没有出现 lock(),谁应该负责让它安全提交?若答案是“每个调用者自己记得补上”,新入口、批处理和异常重试迟早会漏掉;若答案是“共同的提交层负责”,就必须证明所有写入确实经过它。

这就是本章的取舍:调用者更干净,规则更集中,但锁行为也更隐藏。隐藏不是免检通行证;只读路径、嵌套调用、绕过超类型的写入,以及锁失败后的返回结果,都要能从日志和测试中被看见。

概念模型:隐含锁不是“自动成功”

把并发协议放在必经的共享层。业务对象只表达订单变化,提交层负责在正确时机取得保护、检查快照并释放资源;如果存在第二条写入路径,模式就已经出现缺口。

共享层通常是或数据映射器的超类型。它不应该只包装最后一个 UPDATE,而要包住读取到提交的业务窗口;否则 B 仍可能拿着旧快照作决定,再在提交时才发现保护没有覆盖前面的工作。

这里还需要作为可见证据。即使框架自动取得锁,也要记录期望版本、实际版本和最终裁决;锁服务或客户端故障时,旧草稿不能凭“保存成功”覆盖新决定。

三个边界必须写进契约

第一,所有修改入口都必须通过同一个提交层;第二,嵌套调用要复用当前保护,而不是悄悄再取一把;第三,失败必须返回可区分的忙碌、冲突或拒绝,而不是变成无穷重试。只读查询可以旁路锁,但一旦它返回可变对象,就必须明确它不能直接落库。

决定等待的扩散范围。订单总额与付款状态必须一起改变时,订单根可能是合理边界;若一条无关备注也被迫等待,说明保护范围过大,应回到业务不变量而不是继续扩大锁。

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

固定观察订单 #42:客服 A 和 B 都读取版本 7,随后分别修改金额。业务层没有显式调用锁;共同的 UnitOfWork 在提交时拦截写入,按订单键取得保护,检查版本并决定谁可以推进到版本 8。这里的 UnitOfWork 是本章代码中的教学名称,不绑定任何具体框架。

type SaveResult =
  | { status: "saved"; version: number }
  | { status: "busy" | "conflict"; retryable: true };
 
class OrderUnitOfWork {
  async commit(order: Order, expectedVersion: number): Promise<SaveResult> {
    const lease = await this.lockLayer.acquire(`order:${order.id}`, 2_000);
    if (!lease) return { status: "busy", retryable: true };
 
    try {
      const current = await this.orders.read(order.id);
      if (current.version !== expectedVersion) {
        return { status: "conflict", retryable: true };
      }
      await this.orders.save({ ...order, version: expectedVersion + 1 });
      return { status: "saved", version: expectedVersion + 1 };
    } finally {
      await this.lockLayer.release(lease.id);
    }
  }
}

调用者只负责构造变更并交给共同提交层,锁的取得、读取、版本检查、保存和释放都在一个可审计的路径里。示例省略了依赖注入和数据库细节,但保留了关键的 finally;真正的实现还要证明批处理、后台任务和恢复接口也使用同一个超类型。

若 B 在 A 完成前提交,结果不能只看 HTTP 状态。日志至少要关联订单键、入口名、快照版本、锁等待时长、提交裁决和释放结果;这样才能区分“保护生效后 B 忙碌”“版本落后而冲突”和“B 绕过框架直接写入”。

三步交互实验:看见隐藏的保护

猜一猜:把图中的“绕过共同提交层”开关打开后,为什么业务代码仍然看起来很干净,但系统已经不能声称所有修改都受保护?先在主图中逐步播放,再打开错误模式,观察哪一条证据链断开;点击“重置演示”应回到第一步。

隐含锁证据图暂时没有唯一写入者
Implicit Lock:共同提交层隐藏保护责任读取快照 · A、B 都拿到 order#42 / version 71. 读取快照A / B 各自持有 version 72. 共同提交层uow.commit() 自动取得保护3. 裁决结果saved(version 8) / conflict业务入口 · 没有显式 lock()A: order.setTotal(597)B: order.setTotal(620)两份快照都写着 version = 7责任被委托给共同提交层共同提交层 · 唯一裁决点lock: order#42 · owner: A → Bexpected: 7 · current: 7uow.commit() → 检查版本 → 保存证据:锁、版本、裁决、释放都可被重放暂时没有唯一写入者① 读取:A / B 快照版本相同② 保护:共同层取得锁并重读版本③ 裁决:成功推进或返回冲突后释放验收信号:所有修改入口都能回到同一裁决点选择信号:业务代码保持简单,但锁事件与版本裁决完整可见。隐含锁隐藏调用细节,但不能隐藏保护覆盖与失败证据

第 1 / 3 步 · A 与 B 读取同一份版本 7 快照

按步骤验证:读取不等于占有,提交层才是保护与裁决的共同边界。

切换三步时序并注入绕过入口;重置后回到两个客服读取版本 7 的状态。

从读取到裁决的三步证据链

分步1 / 3

1. 读取快照,留下版本证据

先预测:客服 A 和 B 都读到订单 #42 的版本 7 时,系统现在还不能宣布谁会成功。图中只应出现两个独立快照,不能假装读取动作已经取得了唯一写入权。

隐含锁证据图暂时没有唯一写入者
Implicit Lock:共同提交层隐藏保护责任读取快照 · A、B 都拿到 order#42 / version 71. 读取快照A / B 各自持有 version 72. 共同提交层uow.commit() 自动取得保护3. 裁决结果saved(version 8) / conflict业务入口 · 没有显式 lock()A: order.setTotal(597)B: order.setTotal(620)两份快照都写着 version = 7责任被委托给共同提交层共同提交层 · 唯一裁决点lock: order#42 · owner: A → Bexpected: 7 · current: 7uow.commit() → 检查版本 → 保存证据:锁、版本、裁决、释放都可被重放暂时没有唯一写入者① 读取:A / B 快照版本相同② 保护:共同层取得锁并重读版本③ 裁决:成功推进或返回冲突后释放验收信号:所有修改入口都能回到同一裁决点选择信号:业务代码保持简单,但锁事件与版本裁决完整可见。隐含锁隐藏调用细节,但不能隐藏保护覆盖与失败证据
切换三步时序并注入绕过入口;重置后回到两个客服读取版本 7 的状态。

选择与拒绝矩阵

评审问题选择隐含锁的证据应拒绝或改用其他方案的信号
覆盖所有写入入口都依赖共同提交层批处理、迁移或恢复接口可以直接改库
责任超类型记录锁、版本、裁决与释放团队只能说“框架应该会自动处理”
失败忙碌、冲突、绕过和重复提交有不同结果只有一个笼统的保存失败,无法定位责任
粒度锁范围刚好覆盖一个业务不变量无关订单一起等待,等待预算持续超标
替代与显式锁、乐观版本重试比较过因为框架默认开启而跳过覆盖率测试

常见误区

何时拒绝这个模式

隐含锁适合“所有修改都能穿过共同层,且集中策略确实能降低漏锁率”的系统。若写入路径由多个团队独立维护、数据库脚本经常直接运行,或者只读对象会被随手变更,隐藏的便利会变成审计盲区。此时应先收紧边界,或选用更显式的锁协议。

还要衡量与恢复成本。冲突低且合并便宜时,显式版本重试可能更容易解释;冲突高且损失大时,隐含锁可以把责任集中到框架层,但前提仍是每个写入入口都能被枚举和测试。

对外部重试还需要。它不能替代锁:幂等键防止重复执行同一请求,隐含锁解决多个请求同时修改同一订单;两者都缺一时,网络超时就可能制造无法归属的结果。

本章小结

  • 隐含锁把保护责任放进所有修改必经的共同层。
  • 单元工作必须覆盖读取、版本检查、保存和释放。
  • 入口覆盖率、锁粒度与失败可见性共同决定可信度。
  • 绕过超类型的写入是最直接的拒绝信号。
  • 隐含锁不是默认成功,所有裁决都要能从证据重放。

可验证练习

练习

问题 1:证据链。 A 与 B 都读取订单 #42 的版本 7,A 返回 saved(version=8),B 返回 conflict。请列出四类日志证据,证明 B 没有被静默覆盖。

问题 2:修改 Demo 代码。 下面的入口绕过了共同提交层。请改成只允许 OrderUnitOfWork.commit 写入,并为直接仓储调用补一个会失败的测试断言。

async function saveFromBatch(order: Order) {
  await orders.save(order);
  return "saved";
}

问题 3:选择边界。 一个系统有网页、批处理、恢复任务三类写入入口。网页和批处理都经过共同层,但恢复任务可直接更新数据库;你会立即采用隐含锁吗?请给出拒绝条件和一个修复顺序。

名词解释

名词解释

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

隐含锁
业务代码不手写锁调用,由框架或共同提交层替所有写入口取得保护。
单元工作
把一次修改集中起来,在统一提交点完成读取、检查、保存和清理。
版本检查
提交前比较旧快照和当前记录的版本,发现过期写入就拒绝。
锁粒度
一把保护覆盖的资源范围;范围越大,可能一起等待的请求越多。
冲突概率
两个修改在时间上相遇并产生竞争或覆盖的可能性。
幂等键
重复收到同一个请求时仍只产生一次业务结果的稳定标识。

前后导航

参考资料

资料与写作方式声明

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

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

讨论

评论区加载中…