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 必须发生在锁内,save 和 release 的先后要能被日志重放。生产实现还要补上租约续期、失主检测、幂等处理和锁服务不可用时的拒绝策略。
三步可视化实验
猜一猜:把“持锁时间”调长会发生什么?预期是 A 更容易完成一组原子业务决定,但 B 的等待和超时风险同时上升。
从获取到恢复的三步证据链
1. 读取时取得排他锁
先预测:客服 A 读取订单 #42 后,客服 B 的写入请求应该出现什么状态?B 不能绕过锁直接写入,应记录等待开始时间和超时截止点。
常见误区
选择与拒绝矩阵
| 评审问题 | 选择悲观离线锁的证据 | 应拒绝或改用其他方案的信号 |
|---|---|---|
| 冲突 | 写入冲突会造成高额业务损失 | 冲突少且合并或重试成本低 |
| 等待 | 用户能接受有上限的排队 | 事务长、用户经常离线且不能等待 |
| 所有权 | 获取、租约、释放和恢复都有责任人 | 只能说“框架会自动解锁” |
| 粒度 | 一把锁正好覆盖一个业务不变量 | 只能锁整库,或必须同时维护大量无关资源 |
| 替代 | 已比较乐观离线锁与粗粒度锁 | 因团队习惯而跳过冲突概率和死锁测量 |
何时拒绝这个模式
悲观离线锁不是“数据重要”四个字就足够的理由。若客服会长时间断网、编辑过程需要跨多个外部系统,持锁时间会远超用户可接受范围;此时应比较乐观离线锁、草稿合并或更细的业务不变量,而不是把等待时间无限放大。
相反,当订单修改冲突少但一次覆盖会触发不可逆的库存或合规后果时,乐观重试可能让用户反复填写同一表单。选择悲观锁的证据应来自冲突概率、损失、等待预算和恢复成本,而不是来自某个框架默认提供了锁 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。如何拒绝这次旧提交?
前后导航
出处声明
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 悲观离线锁
- 在业务编辑开始前先占住修改资格,让竞争者等待或超时的并发模式。
- 排他锁
- 同一时刻只允许一个持有者写入某个资源的资格。
- 锁粒度
- 一把锁覆盖的资源范围,范围越大,可能一起等待的请求越多。
- 租约
- 带过期时刻的临时锁凭证,用来处理持有者失联。
- 死锁
- 多个持有者互相等待对方释放资源,谁都无法继续的状态。
- 幂等键
- 重复提交时仍只产生一次业务结果的稳定请求标识。