跳到主要内容
约 11 分钟6 张卡

乐观并发控制

乐观并发控制(Optimistic Concurrency Control,OCC)先允许调用者读取状态并计算结果,提交时再检查计算所依据的状态是否仍然有效。条件不成立,就放弃这次提交,重新读取、重新计算,或者把冲突交给调用者处理。

它解决的是“我读到之后,别人也改了”的问题。两个人同时编辑一份文档时,后提交的人不能仅凭自己手里的旧内容覆盖前一个人的修改。版本列、Redis WATCH 和 HTTP 条件请求都能参与这类协议,但检查什么、由谁原子提交、保护到哪里,各不相同。

1. 面试核心​

先记住这几条,再读正文
  • 乐观并发控制采用先读取和计算、提交时校验的策略;悲观方式则先取得相应锁,再在保护范围内读取和修改。选择取决于冲突频率、计算成本与等待代价,不是读多就一定选某一种。卡 · 读取与提交之间的竞争
  • 版本列方案要求比较旧版本、修改业务数据、推进版本在同一次原子更新中完成。先单独查询版本,再无条件写入,仍然存在竞争窗口。卡 · 数据库版本列
  • Redis WATCH 监视键是否发生变化,变化会使之后的 EXEC 放弃执行队列。它不会阻止其他客户端写入,也不是 SET NX 加锁。卡 · Redis WATCH
  • HTTP If-Match 用强实体标签比较限制更新条件。客户端带上标签还不够,服务端必须把条件检查与更新连接成原子操作。卡 · HTTP 条件请求
  • 发现冲突后需要重新读取并重新计算;自动重试要有次数或时间上限,外部副作用还需要单独处理,不能跟着重复执行。卡 · 重试、ABA 与外部副作用
  • 需要检测变化历史时,版本必须覆盖相关写入且不能被错误重用。只比较当前值,无法发现 A → B → A;版本号也要考虑回绕、删除后重建和绕过协议的写入。卡 · 重试、ABA 与外部副作用

2. 读取与提交之间的竞争​

丢失更新是一个调用者根据旧状态写回结果,覆盖了另一个调用者已经完成的修改。假设文档初始内容是 draft,Alice 和 Bob 都读到了这一份内容:

顺序AliceBob数据库内容
1读取 draft读取 draftdraft
2提交 alice继续编辑旧内容alice
3已经提交无条件提交 bobbob

Bob 的写入本身可以是原子的,但这并不能说明他的计算依据仍有效。问题出在“读取、编辑、写回”这一整段过程,而不是某一次赋值只写了一半。

悲观方式可以在事务中先锁定所需记录,再读取并更新,让需要取得冲突锁的其他操作等待。MySQL 8.0 的 SELECT ... FOR UPDATE 可以参与这种流程,锁在事务提交或回滚后释放;事务与锁的具体关系见事务。

乐观方式则不为了用户编辑的整个过程一直持有数据库行锁,而是记住读取时的版本,到写入时验证。如果编辑需要几分钟,长时间持锁会让其他操作等待;但如果同一热点记录不断被修改,乐观重试又可能反复浪费计算。两种策略是在不同阶段承担冲突成本。

“乐观”不等于底层完全不用锁。MySQL InnoDB 执行条件 UPDATE 时仍要取得相应写锁;应用层没有提前锁住整个读取和计算过程,与存储引擎如何实现一次原子写入,是两个层面的问题。

3. 数据库版本列​

版本列记录一条业务记录的修订标记。调用者读取数据时同时取得版本,写回时要求数据库中的版本仍等于该值,并在同一次更新中推进版本。

下面用通用 SQL 表达这个机制。创建一份版本为 7 的文档:

CREATE TABLE document (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL,
version INTEGER NOT NULL
);
INSERT INTO document (id, body, version) VALUES (1, 'draft', 7);

Alice 和 Bob 都先读取:

SELECT body, version FROM document WHERE id = 1;

两人都取得 draft 和 7。随后 Alice 提交,Bob 再用自己的旧版本提交:

-- Alice:匹配版本 7,写入内容并推进到 8。
UPDATE document
SET body = 'alice', version = version + 1
WHERE id = 1 AND version = 7;

-- Bob:仍带着旧版本 7,不能覆盖已经提交的版本 8。
UPDATE document
SET body = 'bob', version = version + 1
WHERE id = 1 AND version = 7;

第一条更新影响 1 行,第二条影响 0 行,最终内容为 alice、版本为 8。版本比较与字段修改都在同一条语句内完成,没有留出“检查成功后再被别人修改”的窗口。本例 SQL 的谓词与受影响行数可用 SQLite 复现;这不代表 SQLite 与 MySQL 具有相同的锁和隔离实现。

实际代码应使用参数绑定,把读取时的版本传入条件。若在事务中执行,语句影响 1 行之后仍需成功提交事务,不能在事务最终提交前就宣布业务完成。

3.1 影响 0 行一定是版本冲突吗?​

不一定。它表示没有记录满足这次更新条件:记录可能已删除,版本可能变化,也可能不符合额外的业务条件。调用者应按业务需要区分“已不存在”“需要重新编辑”或“状态不允许”,不能把所有情况都无限重试。

本例每次成功更新都会推进 version,所以即使新旧 body 相同,成功匹配仍有实际字段变化。其他 SQL 如果只是把字段设为原值,就要区分驱动返回的匹配行数与实际修改行数,不能直接套用这里的判断。

3.2 一行的版本能保护整个业务吗?​

只能保护参与这次检查的状态。判断依赖两条记录,却只校验其中一条的版本,另一条就可能在判断后被修改。需要把相关约束放进同一事务并选择合适的并发方案;版本列不会自动把跨行、跨库或外部接口操作变成一个原子事务。

MVCC 也不能代替应用版本协议。MVCC 主要让读取看到符合隔离规则的版本;“当前编辑是否允许覆盖别人修改”是业务提交条件,需要明确表达。相关读取机制见 MVCC。

4. Redis WATCH​

WATCH 把当前连接与要观察的键关联起来。如果键在观察之后、EXEC 执行之前被修改,Redis 就拒绝执行这次事务队列。它监视的是键的变化,不要求业务在值里额外存储一个整数版本。

假设 doc:1 初始值为 draft,客户端 A 在同一条连接中执行:

WATCH doc:1
GET doc:1
MULTI
SET doc:1 alice
EXEC

GET 在 MULTI 之前实际取得内容。没有其他修改时,SET 先入队,EXEC 执行后返回结果数组。如果客户端 B 在 A 的 WATCH 与 EXEC 之间把 doc:1 改为 bob,A 的 EXEC 就返回空结果标记,队列中的 SET 不执行;客户端库可能把它表示为特定返回值或冲突异常。

需要先 WATCH 再读取用于计算的值,避免遗漏读完到开始观察之间的修改。整个过程也必须固定使用同一连接;连接池如果把 WATCH、GET 和 EXEC 分配给不同连接,就没有建立预期的观察关系。一次 EXEC 或 DISCARD 结束后,要按新的尝试重新建立观察,不能沿用已经清除的状态。

Redis 7 中,被观察键过期也会使相应事务放弃执行。键被改成 B 后又改回 A,同样可能触发观察失效;WATCH 不只是到最后比较一下当前值是否还相等。

WATCH 冲突与事务内命令运行时报错也不同。前者使整个队列不执行;如果队列已经开始执行,其中一条命令因类型错误失败,Redis 不会因此把其他已成功命令回滚。需要逐项检查 EXEC 的返回结果,不能只看到它返回了数组就认定每个操作都成功。

SET key value NX 则是仅在键不存在时写入的原子命令。拿它创建锁键,是尝试取得互斥资格,不等于对之前读取的业务数据实施版本校验;分布式互斥还需要处理持有者身份和过期边界,见 分布式锁。

5. HTTP 条件请求​

实体标签(ETag)是服务端为资源表示提供的校验标记。客户端读取文档时可以拿到 ETag: "v7",编辑后在更新请求中带上相同标签:

PUT /documents/1 HTTP/1.1
Host: example.test
If-Match: "v7"
Content-Type: text/plain
Content-Length: 5

alice

If-Match 要求当前表示与给定标签强匹配。服务端可以把 "v7" 对应到数据库版本 7,再通过条件更新提交;若在校验与写入之间允许另一请求插入修改,单独读取并比较 ETag 仍然会丢失更新。

本例约定提交成功后版本推进,返回新标签;旧标签更新返回 412 Precondition Failed,不执行这次写入。客户端应重新获取内容并处理冲突。HTTP 规范还允许服务端在确认所请求的修改已经成功应用时返回成功状态,所以不能把所有响应丢失后的重复请求都解释为必须失败。

强比较要求双方标签都不是弱标签,并且标签值一致,W/"v7" 不能与 "v7" 在 If-Match 中匹配。If-Match: * 只要求资源存在,不检查是不是客户端读到的那个版本。ETag 也不是权限凭据:服务端应先完成身份与权限等常规检查,再评估请求的前置条件。

ETag 不一定是递增数字,也可以来自内容或其他修订标识。若业务需要检测“中间发生过修改”,就要选择能表达修订历史的标记,不能把只依赖当前内容的标签自动当成永不重复的版本号。

6. 重试、ABA 与外部副作用​

冲突重试需要从新的状态重新开始。仍拿着旧版本、重复提交同一个计算结果,只会继续失败;忽略版本条件强行写入,则把冲突检测取消了。像文档编辑这样的场景,还需要用户决定如何合并内容,不能总由程序覆盖或重算。

可自动重算的操作应限制重试次数或总时长,并在冲突密集时考虑退避、排队或调整数据模型。热点记录不断变化时,乐观方式可能花费大量时间重复计算;加上版本号并不保证低延迟或每个请求都能及时成功。

ABA 是状态从 A 变成 B 后又回到 A 的变化。只比较 body = 'draft' 会接受变回来的 draft;若同时比较修订版本,修改过程从版本 7 推进到 8 再到 9,手里的旧版本 7 就不再匹配。内存 CAS 的相关例子见 CAS。

版本协议的前提是相关写入都推进版本,而且版本没有被重用。某条管理脚本直接修改 body 而不改 version、删除记录后用相同 ID 和初始版本重建,或者有限整数回绕,都可能让旧条件再次匹配。需要按业务生命周期选择足够的版本空间和重建标识。

发送邮件、扣款或调用远程接口也不在一行版本校验的原子范围里。若先发送邮件再提交,版本冲突后重试就可能重复发送;若先提交再发送,又可能在两步之间崩溃。需要把业务状态与待执行动作协调起来,并通过幂等、重试或对账处理外部效果,见分布式流程恢复。

7 · 面试题卡片​

本页题目与参考回答

7.1 MySQL 乐观锁与悲观锁有什么区别?​

考察重点:冲突处理时机、保护范围与成本,区分应用策略和引擎内部锁。

相关内容:读取与提交之间的竞争。

参考回答

**乐观方式先读取和计算,提交时再验证条件;悲观方式先取得相应锁,再在锁的保护范围内读取和修改。**版本列条件更新属于前者,事务中的 SELECT ... FOR UPDATE 可以用于后者。

乐观方式避免为整个计算过程一直持锁,但冲突后可能需要重新计算;悲观方式让冲突操作等待,也有锁占用与死锁等成本。MySQL 的条件 UPDATE 本身仍会取得写锁,所以应用采用乐观策略不代表数据库完全无锁。

7.2 MySQL 中怎样用版本号实现乐观并发控制?​

考察重点:数据与版本共同读取、原子条件更新、受影响行数与事务提交。

相关内容:数据库版本列、零行更新的含义。

参考回答

**读取数据时取得版本,更新时把旧版本写进 WHERE 条件,并在同一次更新中修改数据、推进版本。**例如 WHERE id = 1 AND version = 7,同时执行 version = version + 1。

本例成功影响 1 行;0 行说明没有记录满足条件,需要考虑版本变化、记录不存在或其他业务条件。事务中的更新还要等待提交成功。先查版本、再无条件更新并不能实现同样的保护。

7.3 Redis 怎样实现乐观并发控制?​

考察重点:WATCH、读取、MULTI、EXEC 的顺序与连接范围,以及冲突与互斥的区别。

相关内容:Redis WATCH。

参考回答

**可以用 WATCH 观察相关键,读取后计算结果,再通过 MULTI 和 EXEC 尝试提交。**这些步骤必须使用同一连接;被观察键在提交前发生修改时,EXEC 放弃整个队列,调用者重新读取或报告冲突。

WATCH 不阻止别人写入,也不同于用 SET NX 创建互斥锁。它的冲突放弃也不是通用回滚:队列开始执行后的某条命令错误,不会撤销其他命令的成功结果。

7.4 请求带了 If-Match,就能避免覆盖其他人的修改吗?​

考察重点:强实体标签比较、条件失败响应与服务端原子更新。

相关内容:HTTP 条件请求。

参考回答

**还需要服务端正确校验,并把条件与更新放进同一个原子提交过程。**如果只是先比较标签、随后无条件写入,两步之间仍可能被其他请求修改。

If-Match 使用强比较,弱标签不能匹配;旧版本在本例中返回 412,不执行修改。If-Match: * 只检查资源存在,不能替代精确版本校验。

7.5 乐观更新失败后,为什么不能无限重试并重复执行整个业务?​

考察重点:重新读取与计算、冲突成本,以及外部副作用的原子边界。

相关内容:重试、ABA 与外部副作用、版本保护的范围。

参考回答

**冲突说明旧依据可能失效,重试必须先重新读取并重新计算,还要有次数或时间上限。**热点持续变化时,无限重试可能长期占用资源,也不能保证及时成功。

业务中的邮件、扣款等外部动作不受版本列自动保护,重复整段执行可能产生重复效果。应把可重复计算与外部动作分开设计,并用幂等或恢复流程处理跨系统结果。

7.6 给记录增加版本号,是否就彻底解决了 ABA?​

考察重点:当前值与变化历史的差别,版本推进与生命周期约束。

相关内容:重试、ABA 与外部副作用。

参考回答

**只有相关修改都推进版本,并且旧版本不会被错误重用时,版本比较才能检测这段变化。**内容从 A 到 B 再回到 A,版本从 7 到 8 再到 9,旧版本 7 的提交就会失败。

如果写入绕过版本更新、版本整数回绕,或删除重建后再次使用相同标识与版本,旧条件仍可能匹配。需要把版本空间和记录生命周期纳入协议,而不只是增加一个字段。

8. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明