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 绕过框架直接写入”。
三步交互实验:看见隐藏的保护
猜一猜:把图中的“绕过共同提交层”开关打开后,为什么业务代码仍然看起来很干净,但系统已经不能声称所有修改都受保护?先在主图中逐步播放,再打开错误模式,观察哪一条证据链断开;点击“重置演示”应回到第一步。
第 1 / 3 步 · A 与 B 读取同一份版本 7 快照
按步骤验证:读取不等于占有,提交层才是保护与裁决的共同边界。
从读取到裁决的三步证据链
1. 读取快照,留下版本证据
先预测:客服 A 和 B 都读到订单 #42 的版本 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:选择边界。 一个系统有网页、批处理、恢复任务三类写入入口。网页和批处理都经过共同层,但恢复任务可直接更新数据库;你会立即采用隐含锁吗?请给出拒绝条件和一个修复顺序。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 隐含锁
- 业务代码不手写锁调用,由框架或共同提交层替所有写入口取得保护。
- 单元工作
- 把一次修改集中起来,在统一提交点完成读取、检查、保存和清理。
- 版本检查
- 提交前比较旧快照和当前记录的版本,发现过期写入就拒绝。
- 锁粒度
- 一把保护覆盖的资源范围;范围越大,可能一起等待的请求越多。
- 冲突概率
- 两个修改在时间上相遇并产生竞争或覆盖的可能性。
- 幂等键
- 重复收到同一个请求时仍只产生一次业务结果的稳定标识。