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 的修改覆盖掉。

可交互时序图先预测,再逐步验证提交条件
Optimistic Offline Lock:提交时验证版本允许并行编辑,只有提交前的版本条件决定谁能写入事务事件数据库状态A 读取订单(version = 7)B 读取订单(version = 7)B 提交:7 → 8,条件满足A 提交:期望 7,实际 8,拒绝A 重读 8 → 合并修改 → 重试当前行version = 7B 成功后version = 8A 的旧条件失效错误模式:只按订单 ID 更新A 会覆盖 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% 的版本冲突,用户修改只涉及备注;另一个库存分配流程冲突频繁,每次失败都需要重新调用外部仓库。分别选择更适合的方向,并说明一条可观测证据。

名词解释

名词解释

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

业务事务

围绕一个业务目标、可能跨越多次请求,却需要被当作一个整体来完成的工作。

版本号

跟着订单一起递增的数字;保存时用它检查“我读到的版本是否仍是当前版本”。

乐观离线锁

先让多人并行编辑,提交时才检查版本并拒绝冲突写入的并发方案。

冲突检测

把读取时保存的版本和提交时的当前版本比较,以发现有人先改过同一数据。

回滚

放弃这次尚未安全提交的变化,让调用方回到可以重读或重新处理的状态。

前后导航

来源与改写范围

本章不复现原书正文、插图或代码;订单案例、TypeScript 条件更新、评审矩阵、练习、答案和交互图均为本课程独立重写。

资料与写作方式声明

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

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

讨论

评论区加载中…