16.3 粗粒度锁

用一个锁保护同一业务边界内的相关对象,把完整性、等待范围与冲突恢复放在同一份协议中。

学习目标

  • 能解释粗粒度锁如何让订单边界内的读取、校验和提交共享同一份并发责任
  • 能修改订单锁协议的超时、版本检查和释放路径,并用可观察证据验证修改
  • 能回答:客服 A 与客服 B 同时编辑一个订单时,什么信号说明锁的边界已经过大?

为什么这一章需要一把大伞

想象两名客服同时打开同一张订单。订单金额、商品数量和付款状态都看起来可以分别编辑,但一次保存可能同时改变它们。若每个人只保护自己正在填写的那一格,最后保存的人就可能把另一人的决定覆盖掉。

没有一条共同规则时,两个请求都可能返回成功,订单却已经不再自洽。我们需要先说清哪些东西必须一起被保护,再决定等待、失败和重试怎样让用户看得见。

概念模型:边界、责任与证据

把保护范围放在一次业务决定需要的整体上,而不是放在每个字段或每一行上。它不是“锁得越大越安全”:范围必须刚好覆盖同一个业务决定,否则无关修改也会被迫排队。

锁的入口通常是。在本章的订单案例中,Order 是根,LineItem 和 Payment 是它需要共同协调的对象。所有写入都从根取得锁,再在锁内读取、校验和提交;子对象不能绕过根单独保存。

决定两种代价:太细会漏掉跨对象的业务关系,太粗会让互不相关的订单一起等待。评审时要把“同一个业务决定”写成可检查的边界,而不是直接照搬数据库表的边界。

订单能否保存,还要靠来验收。一次成功的数据库写入只能证明存储动作完成,不能证明整个不变量从读取到提交一直受到同一把锁保护。

三个阶段的代码责任

把并发协议拆成“取得、锁内工作、提交释放”三段。负责识别旧快照;锁边界负责防止同一业务决定被两个写入者同时改写。

阶段必须记录的证据失败时的可见结果
取得订单键、持有者、等待开始和超时截止返回忙碌或可重试,而不是无限等待
锁内工作快照版本、校验结果、修改对象发现边界外写入时停止提交
提交释放新版本、提交结果、释放结果成功、冲突和释放失败可以分别重放

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

下面的代码是可运行协议的最小骨架。锁包住读取和业务校验,版本检查提供第二道证据,finally 保证正常异常路径都尝试释放。真实系统还应把持有者、超时和恢复事件写入审计日志。

async function updateOrder(orderId: string, patch: OrderPatch) {
  const lease = await orderLocks.acquire(orderId, { timeoutMs: 2_000 });
  if (!lease) return { status: "busy" as const };
 
  try {
    const snapshot = await orders.read(orderId);
    const next = validateAndApply(snapshot, patch);
    if (snapshot.version !== lease.version) {
      return { status: "conflict" as const };
    }
    await orders.save({ ...next, version: snapshot.version + 1 });
    return { status: "saved" as const };
  } finally {
    await orderLocks.release(lease.id);
  }
}

这里的关键不是某个锁服务的 API,而是责任顺序:先取得同一订单的锁,再读取完整快照;保存成功后释放;冲突和忙碌都返回给调用者。若只锁住 orders.save,两名客服仍可能基于不同快照作出互相矛盾的决定。

三步可视化实验

猜一猜:把锁从一个订单扩大到一批订单,会让不变量更安全吗,还是只会让等待变多?先在图中切换阶段,再打开“注入错误边界”,观察第二名客服能否绕过保护范围。

专属代码协议图 · 取得边界
Coarse-Grained Lock:用一个业务边界保护一组对象取得边界 · A 先取得 Order 的锁1. 取得边界A 先取得 Order 的锁2. 锁内工作读取、校验与修改共享边界3. 提交释放推进版本,再释放并处理冲突完整保护范围 · Order 聚合根、明细与付款状态共享同一把锁Order 根version = 3LineItem #1金额与数量Payment付款状态证据:一次业务决定由同一把锁从读取保护到提交锁管理器 · 唯一裁决点resource: order#42owner: Aboundary: order#42B 记录等待开始timeout + version + release验收证据:同一订单的锁范围、快照版本、提交结果和释放结果都可被重放。选择信号:边界刚好覆盖一个不变量,等待与冲突损失都在预算内。粗粒度锁把同一个业务决定的对象放进一把可审计的锁
切换三个阶段并注入错误边界;重置后应回到取得订单边界的初始状态。

从取得到释放的三步证据链

分步1 / 3

1. 取得订单边界的锁

先预测:客服 A 读取订单 #42 后,客服 B 请求修改其中一条明细,应该看到什么?B 应等待同一个订单边界,而不能因为目标是子对象就直接写入。

专属代码协议图 · 取得边界
Coarse-Grained Lock:用一个业务边界保护一组对象取得边界 · A 先取得 Order 的锁1. 取得边界A 先取得 Order 的锁2. 锁内工作读取、校验与修改共享边界3. 提交释放推进版本,再释放并处理冲突完整保护范围 · Order 聚合根、明细与付款状态共享同一把锁Order 根version = 3LineItem #1金额与数量Payment付款状态证据:一次业务决定由同一把锁从读取保护到提交锁管理器 · 唯一裁决点resource: order#42owner: Aboundary: order#42B 记录等待开始timeout + version + release验收证据:同一订单的锁范围、快照版本、提交结果和释放结果都可被重放。选择信号:边界刚好覆盖一个不变量,等待与冲突损失都在预算内。粗粒度锁把同一个业务决定的对象放进一把可审计的锁
切换三个阶段并注入错误边界;重置后应回到取得订单边界的初始状态。

常见误区

选择与拒绝矩阵

评审问题选择粗粒度锁的证据应拒绝或改用其他方案的信号
边界一个订单内的对象共同维护同一个不变量订单之间没有关系,却被迫共用一把锁
冲突冲突损失高,且等待预算可接受冲突少、合并便宜,或用户常常离线很久
责任取得、锁内工作、释放和恢复都有日志只能说“框架会自动解锁”
替代已与细粒度锁和版本重试比较因为数据库提供锁 API 就直接采用

粗粒度锁适合“必须一起决定”的对象集合,不适合用来代替所有业务建模。若等待时间主要来自互不相关的订单,应该先缩小边界;若用户会长时间离线,则应比较乐观版本重试、草稿合并或显式的业务队列。

本章小结

  • 粗粒度锁保护一次业务决定,而不是任意扩大保护范围。
  • 聚合根负责统一取得锁,子对象不能绕过根写入。
  • 读取、校验、提交和释放必须形成可重放的责任链。
  • 锁粒度、等待分布和冲突损失共同决定是否拒绝该方案。

可验证练习

练习

问题 1:记录证据。 客服 A 和 B 同时编辑订单 #42,A 成功提交,B 返回冲突。请列出判断这次结果是否正确所需的四条日志证据。

问题 2:修改 Demo 代码。 下面的实现先读取再取得锁。请改成有限等待、锁内读取、版本检查和 finally 释放,并说明 B 应看到什么结果。

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

问题 3:选择边界。 订单 #42 的三条明细互不共享规则,但付款状态必须与订单总额同时改变。你会把它们放进同一把锁吗?请指出至少一个应监控的拒绝信号。

名词解释

名词解释

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

粗粒度锁
把同一个业务决定涉及的多个对象放在一把锁下,减少漏保护的机会,但会增加等待范围。
聚合根
代表一组相关对象的入口;外部修改先找它,由它维护整组对象的规则。
锁粒度
一把锁覆盖多大范围;范围越大,可能一起等待的请求越多。
不变量
无论谁修改,业务都必须保持成立的关系,例如付款状态不能脱离订单总额。
版本检查
提交前比较读取时的版本和当前版本,用来发现旧快照,而不是默默覆盖新修改。

前后导航

参考资料

资料与写作方式声明

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

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

讨论

评论区加载中…