16.1 乐观离线锁
允许并行业务事务先工作,在提交时用版本或时间戳检测冲突并回滚。
学习目标
- 能沿着两个客服修改同一订单的时间线,指出读取、业务处理、提交检查、拒绝和重试各自的责任。
- 能修改 TypeScript 的条件更新代码,让版本不一致时返回可断言的冲突结果,而不是覆盖先提交者的修改。
- 能回答:当冲突频繁或单次业务处理很长时,为什么应比较悲观离线锁或其他方案,而不是继续盲目重试?
先从两个客服都看到了旧订单说起
两个客服同时打开订单 42。客服 A 把收货地址改成新地址,客服 B 把配送方式改成加急;两个人看到的都是“上一版订单”。如果 A 保存后,B 又把自己看到的旧版本整笔写回,A 的修改就会悄悄消失。
这类问题发生在一次工作跨越多个请求时:人会思考、等待确认、暂时断网,不能让数据库连接一直替整段工作占着位置。我们需要让两个人先工作,同时在真正保存的瞬间确认“我依据的那一版仍然是最新的”;确认失败时,系统必须告诉后提交者如何重读、合并或放弃。
本章用一个可重复的订单实验建立判断标准:先预测谁会成功,再逐步点亮读取、提交与冲突路径,最后用一段 TypeScript 条件更新代码验证同一个不变量。
核心机制:提交前再确认
一次跨多个请求、但业务上必须整体完成的工作,称为 ↡把一次业务工作拆成多个请求或系统事务后,仍要作为一个整体完成的工作。。例如客服打开订单、修改几项字段、等待客户确认,最后才点击保存;这不是一次持续占用数据库连接的长操作。
订单行上额外保存一个 ↡保存对象上一次成功提交的版本,用来判断读取后是否有人先写过。,初始值可以是 7。客服读取时记下 7;任何成功提交都会把它递增到 8。提交时把“我读到的是 7”作为条件:数据库里的版本仍是 7,才应用变化并把版本改为 8;已经变成 8,就拒绝这次写入。
这一步就是 ↡默认冲突不常发生,先允许并行编辑,提交时才验证并拒绝冲突写入的模式。的核心:不在编辑期间阻塞其他人,而是把竞争检查推迟到提交前。它的前提不是“绝对不会冲突”,而是冲突足够少,失败一次的代价低于让所有人排队等待。
提交前比较读取版本与当前版本的动作叫 ↡提交前比较读取版本与当前版本,发现有人先写就拒绝本次写入的检查。。检测和实际更新必须在同一个数据库系统事务中完成;应用层先 SELECT,等待一会儿再单独 UPDATE,仍可能在两条语句之间被其他写入插入。
如果版本不一致,当前修改不能安全地写入旧数据,系统应执行 ↡放弃本次未能安全提交的修改,让数据回到可继续处理的状态。或等价的失败处理,并返回明确的冲突结果。回滚只撤销这一次未提交的本地变化,不应假装把另一个客服已经成功提交的业务决定也撤销掉。
把规则落到条件更新
下面的 TypeScript 只表达持久化边界,不绑定 ORM。UPDATE 的版本条件和版本递增必须由同一个原子数据库操作完成;返回 0 行时,调用方才能可靠地把它判定为冲突。
type Order = { id: string; status: "open" | "paid"; version: number };
type Commit = { id: string; expectedVersion: number; nextStatus: Order["status"] };
type Db = { execute(sql: string, params: readonly unknown[]): Promise<number> };
async function commitOrder(db: Db, change: Commit) {
const affected = await db.execute(
"UPDATE orders SET status = ?, version = version + 1 WHERE id = ? AND version = ?",
[change.nextStatus, change.id, change.expectedVersion],
);
return affected === 1 ? { ok: true } : { ok: false, reason: "conflict" };
}这段代码的可验证不变量只有一个:同一订单、同一旧版本最多有一个成功更新者。客服 A 和 B 都带 expectedVersion = 7 时,第一条更新把版本从 7 变成 8;第二条更新影响 0 行,并且必须向界面返回“订单已变化,请重读”的分支。若要支持合并,合并器应比较字段级意图并重新提交新的版本,不能直接把旧对象再写一次。
五步订单实验
把两个客服的动作固定为 A 先读、B 后读、B 先提交、A 被拒绝、A 重读后重试。**猜一猜:**如果把 A 的提交放在 B 之前,哪一个客服会收到冲突?再用下面的步进控制验证你的预测;切换“错误模式”后,观察忽略版本条件会怎样把 B 的修改覆盖掉。
第 1 / 5 步 · A 读取订单,记下 version = 7
时间线展示正常冲突路径;步点、播放和拖动都停在可解释的提交状态。
图中的“拒绝”不是网络异常,也不是用户权限错误,而是一个可恢复的并发结果。重试前要让用户看到当前版本和自己的修改差异;对于不可自动合并的字段,应让用户选择保留哪一方,而不是自动采用最后写入值。
如何选择,何时拒绝
乐观离线锁适合冲突概率低、一次失败的业务代价可接受、并且可以重读或合并的场景。它不承诺所有工作都成功,只承诺不会静默覆盖已经提交的变化。单次数据库更新成功,只能证明这一条系统事务满足了版本条件,不能证明整个业务事务没有冲突。
| 评审问题 | 采用乐观离线锁的证据 | 应拒绝或比较其他方案的信号 |
|---|---|---|
| 冲突 | 热点订单的冲突率低,失败可重试 | 冲突频繁,用户每次都做到最后才被退回 |
| 工作时长 | 编辑过程短,重做成本可接受 | 业务处理很长,重做一次会浪费大量外部工作 |
| 恢复 | 字段有明确合并规则,用户能看懂差异 | 金融或库存决定不能自动合并,也没有人工裁决路径 |
| 替代 | 已比较悲观离线锁、粗粒度锁的等待与锁表成本 | 团队只因框架默认支持版本字段就跳过场景评审 |
悲观离线锁会在工作开始时排他地占住资源,能减少最后一刻失败,但会引入等待、超时和失主恢复;粗粒度锁则用一个锁覆盖一组相关对象,简化获取却可能扩大争用范围。选择应由冲突率、工作时长、恢复成本和锁粒度的运行证据共同决定。
常见误区
本章小结
- 业务事务可以跨多个请求,但提交检查必须保护整体数据一致性。
- 版本条件更新把“读取后是否有人先写”变成可断言的结果。
- 版本一致才提交并递增;版本不一致就拒绝旧写入并提供恢复路径。
- 冲突率高或重做代价大时,应比较悲观锁、粗粒度锁或人工裁决。
练习与答案
练习
问题 1:改写提交代码。 把下面的错误更新改成条件更新,并写出两个可断言的结果:一次成功提交、一次因为旧版本被拒绝。
await db.execute(
"UPDATE orders SET status = ? WHERE id = ?",
[nextStatus, orderId],
);问题 2:定位首个差异。 A 和 B 都读取 version=7;B 提交成功后版本变成 8;A 的更新影响 0 行。A 应该重试、回滚,还是报告数据库故障?请写出下一步至少包含的两个输入。
问题 3:做场景选择。 一个审批流程通常只有 0.2% 的版本冲突,用户修改只涉及备注;另一个库存分配流程冲突频繁,每次失败都需要重新调用外部仓库。分别选择更适合的方向,并说明一条可观测证据。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 业务事务
围绕一个业务目标、可能跨越多次请求,却需要被当作一个整体来完成的工作。
- 版本号
跟着订单一起递增的数字;保存时用它检查“我读到的版本是否仍是当前版本”。
- 乐观离线锁
先让多人并行编辑,提交时才检查版本并拒绝冲突写入的并发方案。
- 冲突检测
把读取时保存的版本和提交时的当前版本比较,以发现有人先改过同一数据。
- 回滚
放弃这次尚未安全提交的变化,让调用方回到可以重读或重新处理的状态。
前后导航
来源与改写范围
- Martin Fowler:Optimistic Offline Lock:核对模式定义、提交前验证与低冲突前提。
- Martin Fowler:Patterns of Enterprise Application Architecture:核对原书主题与第 16 章范围。
- Pearson 出版社页面:交叉核对英文版出版信息。
本章不复现原书正文、插图或代码;订单案例、TypeScript 条件更新、评审矩阵、练习、答案和交互图均为本课程独立重写。