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,两名客服仍可能基于不同快照作出互相矛盾的决定。
三步可视化实验
猜一猜:把锁从一个订单扩大到一批订单,会让不变量更安全吗,还是只会让等待变多?先在图中切换阶段,再打开“注入错误边界”,观察第二名客服能否绕过保护范围。
从取得到释放的三步证据链
1. 取得订单边界的锁
先预测:客服 A 读取订单 #42 后,客服 B 请求修改其中一条明细,应该看到什么?B 应等待同一个订单边界,而不能因为目标是子对象就直接写入。
常见误区
选择与拒绝矩阵
| 评审问题 | 选择粗粒度锁的证据 | 应拒绝或改用其他方案的信号 |
|---|---|---|
| 边界 | 一个订单内的对象共同维护同一个不变量 | 订单之间没有关系,却被迫共用一把锁 |
| 冲突 | 冲突损失高,且等待预算可接受 | 冲突少、合并便宜,或用户常常离线很久 |
| 责任 | 取得、锁内工作、释放和恢复都有日志 | 只能说“框架会自动解锁” |
| 替代 | 已与细粒度锁和版本重试比较 | 因为数据库提供锁 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 的三条明细互不共享规则,但付款状态必须与订单总额同时改变。你会把它们放进同一把锁吗?请指出至少一个应监控的拒绝信号。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 粗粒度锁
- 把同一个业务决定涉及的多个对象放在一把锁下,减少漏保护的机会,但会增加等待范围。
- 聚合根
- 代表一组相关对象的入口;外部修改先找它,由它维护整组对象的规则。
- 锁粒度
- 一把锁覆盖多大范围;范围越大,可能一起等待的请求越多。
- 不变量
- 无论谁修改,业务都必须保持成立的关系,例如付款状态不能脱离订单总额。
- 版本检查
- 提交前比较读取时的版本和当前版本,用来发现旧快照,而不是默默覆盖新修改。