MySQL 事务、MVCC 与锁校招面试题
MySQL 事务、MVCC 与锁校招面试题
索引解决“怎样找到数据”,并发控制解决“多人同时读写时,看到什么、谁需要等待”。本章示例彼此独立:先恢复账户初始余额,再打开两个连接;不要把上一题遗留事务带入下一题。
本文示例数据与会话约定
环境为 MySQL 8.4、InnoDB。下面初始化只用于新练习库;库已存在时停止并换一个名字,不直接覆盖。A、B 表示两个独立连接,都先 USE mysql_chapter4_demo;按表中的步骤交错执行,不把两列分别一口气执行完。本文未实际连接数据库,结果均为语义推演。
CREATE DATABASE mysql_chapter4_demo CHARACTER SET utf8mb4;
USE mysql_chapter4_demo;
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance DECIMAL(12,2) NOT NULL
) ENGINE=InnoDB;
INSERT INTO accounts VALUES (1, 1000.00), (2, 1000.00);
本章练习连接以 REPEATABLE READ、autocommit=1 为起始设置,再按题目开启显式事务或改隔离级别。每个实验结束先让 A/B 都 COMMIT 或 ROLLBACK,再清理本章测试账户 3、恢复账户 1/2 各 1000。下面恢复语句只供此隔离练习库使用,不能对照搬到业务库:
USE mysql_chapter4_demo;
DELETE FROM accounts WHERE id = 3;
UPDATE accounts SET balance = 1000.00 WHERE id IN (1, 2);
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
恢复前必须结束所有实验事务,避免清理本身被未释放锁阻塞。第 9 题的 demo 表独立建,不将其键 10/20/30 混入 accounts。发生等待时从另一连接继续释放锁;不要在已被阻塞的同一连接里假装能继续执行下一条语句。
1. 什么是事务?ACID 分别解决什么问题?
相关问法:事务有什么特性?如何开启和回滚?依赖:accounts。
一句话理解与用途
事务把一组相关数据库操作作为一个工作单元管理,决定它们何时整体提交,或者撤销。转账不能只扣 A 的钱却没给 B 加钱,这就是需要事务的直接原因。
但“用了事务”不等于业务自动正确。转账金额、余额下限、账户是否存在,仍需要约束和程序逻辑判断。
用转账理解 ACID
初始两个账户余额都为 1000,转账 100:
START TRANSACTION;
UPDATE accounts SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
以上是成功路径,预期余额 900、1100。真实应用必须分别检查更新结果:扣款未成功、收款账户不存在或任一步报错时,应执行 ROLLBACK,不能继续盲目提交。SQL 文本并没有自动写出这些分支。
| 阶段 | 账户 1 | 账户 2 | 含义 |
|---|---|---|---|
| 初始已提交 | 1000 | 1000 | 总额 2000 |
| 本事务已扣、还没加 | 900 | 1000 | 本事务尚未完成的中间状态 |
| 两步完成但未提交 | 900 | 1100 | 等待统一提交或撤销 |
| 提交成功 | 900 | 1100 | 封闭转账例总额仍 2000 |
| 提交前失败并回滚 | 1000 | 1000 | 本事务修改都撤销 |
中间暂时少了 100,不意味着原子性要求每条 SQL 后都已满足整个转账的业务规则;关键是不能把半完成状态作为完整已提交转账留下。隔离规则又决定其他事务可以怎样观察这些修改。若只扣款语句就单独自动提交,随后加款失败,第二步的 ROLLBACK 不能替你撤销第一步已提交的数据。
- 原子性:这个事务的数据库修改应整体提交或整体撤销,不能留下半笔转账。
- 一致性:事务前后满足既定约束与业务规则,例如合法转账不凭空改变总金额。数据库提供约束与事务机制,应用必须正确使用。
- 隔离性:并发事务对彼此的观察和干扰受隔离级别控制,避免不受约束地读写同一状态。
- 持久性:成功提交的结果按配置与存储保证应能在故障后恢复,而不是只停留在易失内存。
配图:一笔转账的事务边界

对照同一笔转账的成功与撤销状态,检查两步是否共同生效。
提交、回滚与自动提交
MySQL 常见默认 autocommit=1,没有显式事务时,每条适用的语句通常自行构成事务。START TRANSACTION 开启多语句事务,COMMIT 提交,ROLLBACK 撤销未提交的修改。
回滚不是回到数据库历史上的任意时间。已经提交的误操作不能靠下一条 ROLLBACK 撤销,恢复需要备份、日志或补偿业务操作。
为什么还需要锁和日志
事务只是对外语义,内部需要机制支撑:Undo 帮助撤销,Redo 帮助崩溃恢复,锁和 MVCC 协调并发。不要机械地把一致性完全归于某一份日志;即使底层日志完好,程序把收款金额写错,业务仍会不一致。
隔离也不表示所有事务互不影响。两个事务扣同一账户,可能等待甚至死锁;不同隔离级别允许不同观察现象。
数据库能够保证的是正确使用其机制后的事务性质,不会自动检查应用把扣款100、加款90写错了。因此一致性不是“日志多一份就自然成立”:余额下限、目标账户存在、更新行数、金额相等都要在相应规则里落实;当源条件更新为0行时,必须停止,不应继续给目标加款。官方:InnoDB与ACID
边界
这里讨论 InnoDB DML。很多 DDL 会隐式提交,不能夹在转账里指望普通回滚保护。发短信、调用外部支付也不会随着 MySQL 回滚自动撤销,要单独协调外部副作用。
面试回答
事务是一组数据库操作的工作单元,解决多步业务需要整体生效的问题。ACID 中,原子性要求整体提交或撤销,一致性要求满足约束与业务规则,隔离性控制并发观察和干扰,持久性保证提交结果按配置可恢复。InnoDB 通过日志、锁和 MVCC 等机制实现这些能力,但应用仍须检查业务条件和执行结果,事务不能自动纠正错误业务逻辑。
2. 脏读、不可重复读和幻读是什么?
相关问法:并发事务会带来什么问题?依赖:accounts,每个实验独立恢复为两行、余额各 1000。
一句话理解与用途
这三个术语描述事务交错时可能看到的不同变化:脏读看到别人未提交的数据;不可重复读关注重复读同一记录时值变了;幻读关注按同一条件读取时结果集合变化。
理解它们,是为了选隔离级别并判断业务是否容忍变化,而不是单纯背“某语句对应某现象”。以下均为机制推演,非本机实测。
实验一:脏读
按编号在两个连接交替执行;A 使用 READ UNCOMMITTED,B 不提交更新。
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; | |
| 2 | START TRANSACTION; | START TRANSACTION; |
| 3 | UPDATE accounts SET balance=900 WHERE id=1; | |
| 4 | SELECT balance FROM accounts WHERE id=1; | |
| 5 | ROLLBACK; | |
| 6 | COMMIT; |
A 在步骤 4 可能读到尚未提交的 900,B 随后回滚,最终余额仍是 1000。A 曾把一个没有成为已提交事实的值当成依据,这就是危险所在。
“脏”不是数据格式损坏,也不是读得不够新,而是这个值还没有被所属事务确认为已提交结果。例如 A 根据900决定是否放行另一笔业务,后来 B回滚为1000,A此前决策就建立在一个被撤销的状态上。降低隔离级别不能用“总是最新”概括,它放宽的是未提交可见性。
实验二:不可重复读
A 设置 READ COMMITTED,再开始事务。先查 id=1 得到 1000;B 开始事务,把余额改为 900 并提交;A 在自己的同一事务里再次执行相同 SELECT,得到 900,最后提交。
B 的修改已经提交,所以不是脏读。问题在于 A 的两次读取没有固定在同一个历史视图上。若 A 以为“事务没结束,读到的值就不会变”,就会误判。
| 同样两次读 | B 是否提交 | A 的级别 | A 两次可能的观察 |
|---|---|---|---|
| 脏读例 | 尚未提交,随后回滚 | RU | 可读取900,最终事实仍1000 |
| 不可重复读例 | 两次读取之间已提交 | RC | 1000→900 |
两者不能只凭“看到900”区分,必须把B的提交/回滚时点放入时间线。这也提醒读者:事务的稳定性要依据隔离与读法,不能只看有没有 BEGIN。
实验三:幻读
A 在 READ COMMITTED 下开始事务,执行 SELECT id FROM accounts WHERE id BETWEEN 1 AND 3 ORDER BY id,得到 1、2。B 在独立事务执行 INSERT INTO accounts(id,balance) VALUES(3,1000) 并提交。A 再查得到 1、2、3,然后提交。
变化的是符合条件的记录集合,而不是原有两条记录的余额。实验结束后恢复数据,删除测试账户 3。
此处筛选条件始终是 id BETWEEN1 AND3,第一次是集合{1,2},第二次是{1,2,3}。若换成余额条件,另一个事务更新余额也可能让一行进入或离开集合;若删行,集合则可能减少。因此“值变化”和“集合变化”是观察角度,不是给 SQL 动词贴一个永远固定的标签。
配图:三种读异常不是一回事

三个独立实验分别展示未提交值、同一行变化和匹配集合变化。
容易混淆的地方
不可重复读与幻读不能简单记成 UPDATE 对 INSERT。更新条件列也可能使记录进入或离开结果集合,删除也可能让集合缩小。应看观察对象:某条记录的值,还是某个谓词匹配的记录集合。
这些例子刻意选用 RU/RC 展示现象;不能直接搬到 RR 快照读或范围加锁读,后者机制不同,见第 7 题。各会话完成后可将隔离级别恢复为 REPEATABLE READ。
面试回答
脏读是读取其他事务尚未提交的修改;不可重复读是同一事务重复读取同一记录,看到其他事务提交后的不同值;幻读是同一条件的结果集合发生变化。三者要结合隔离级别和读取方式讨论,不能只按 UPDATE、INSERT 来区分。InnoDB 的快照和范围锁分别提供不同保护,需要具体分析。
3. MySQL 有哪些事务隔离级别?如何选择?
相关问法:RC 和 RR 有什么区别?默认隔离级别是什么?依赖:accounts。
一句话理解与用途
隔离级别是事务并发访问时的观察规则。越需要稳定视图或严格串行语义,就越需要相应版本管理或同步成本,不能只追求一个“最高”的名字。
同一业务也可能同时需要普通展示查询与防并发修改的加锁查询,所以选了隔离级别,还要选正确读取方式。
四种级别
| 级别 | 理解重点 | InnoDB 常见表现 |
|---|---|---|
| READ UNCOMMITTED | 允许读未提交数据 | 普通读取可能发生脏读 |
| READ COMMITTED | 每次一致性读看该次已提交状态 | 同一事务不同查询可能看到新提交 |
| REPEATABLE READ | 一致性读复用事务的读视图 | 普通快照读保持较稳定历史视图 |
| SERIALIZABLE | 更严格地约束并发,追求可串行化语义 | 可能增加读写等待,行为受自动提交设置影响 |
InnoDB 默认 RR。标准对隔离现象的描述与具体引擎实现要分开:例如不能仅凭教科书表格就断言 InnoDB RR 的普通快照读会反复看到新增行。
怎样设置与验证
在当前没有活动事务时执行:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT @@session.transaction_isolation, @@session.autocommit;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
COMMIT;
SET SESSION 影响该会话后续事务;不带 SESSION/GLOBAL 的 SET TRANSACTION ISOLATION LEVEL ... 可以指定下一事务。应在事务开始前设置,别把连接池复用后的隔离配置遗留给其他请求。
用相同场景比较
A 开始事务并查到余额 1000;B 改为 900 并提交;A 再查:RC 的新一致性读通常看到 900,RR 若已经建立快照则仍看到 1000。若 A 改为 FOR UPDATE,讨论的是加锁当前读,不能继续套用普通快照结论。
SERIALIZABLE 下,显式事务中的普通 SELECT 可能转成共享加锁读;自动提交的只读语句则有不同处理。不能简单说“串行化意味着数据库一次只能运行一个事务”。互不冲突的工作仍有并发空间。
更具体地说,InnoDB在 autocommit=0 条件下,会把普通 SELECT 转为类似 FOR SHARE 的锁定读;自动提交单条只读事务有不同处理。不要把这一点反向推广到 RC/RR 的普通一致性读。官方:隔离级别
配图:四级隔离的取舍

并列比较隔离策略,并在相同提交变化下检查 RC/RR 的读值。
如何选择
展示类查询能容忍每次看到新提交状态时,RC 可能便于降低部分范围锁冲突;需要同一事务稳定读取时可考虑 RR。关键并发规则还应通过唯一约束、条件更新和必要锁实现。SERIALIZABLE 要评估吞吐、等待与重试成本,而不是作为默认万能修复。
例如同一报表分两次查询相关汇总,希望两次来自一致视角时,可以在RR显式事务中用普通一致性读;只展示当前列表而允许前后变化,则不必仅为稳定名称升级隔离。要防止超扣余额,正确的条件 UPDATE 或同事务锁定读仍是直接约束手段,单靠把普通查询设成RR并不能让旧余额变成安全的写入依据。连接池还要将隔离级别作为连接状态管理,否则一次实验留下的RU/RC可能影响后来不知情的请求。
面试回答
MySQL 有 RU、RC、RR 和 SERIALIZABLE,InnoDB 默认 RR。RC 的一致性读通常每次建立新视图,RR 通常复用首次一致性读的视图,因此重复查询表现不同。隔离级别是业务正确性与并发成本的取舍,还要结合快照读、加锁读及约束;不能说级别越高任何场景锁就必然越多,也不能把串行化等同于整个数据库只能单事务执行。
4. 什么是快照读和当前读?
相关问法:普通 SELECT 与 FOR UPDATE 有什么区别?依赖:accounts,余额恢复为 1000。
一句话理解与用途
快照读根据可见性规则读取一个历史视图;当前读面向要锁定或修改的当前记录状态,必要时等待其他事务释放冲突锁。前者帮助读写并发,后者帮助安全地基于当前状态做决定。
“当前”不表示读取别人未提交的数据,也不是跳过锁直接看内存里最新字节。
常见语句
在 InnoDB RC/RR 下,普通 SELECT 通常是一致性快照读;SELECT ... FOR SHARE、SELECT ... FOR UPDATE 是加锁读,UPDATE、DELETE 也不能按普通历史快照简单理解。
FOR SHARE 常用于允许其他兼容读者、限制冲突修改;FOR UPDATE 获取用于修改的排他保护。应先用 START TRANSACTION 开启显式事务,或关闭自动提交,再把读取与后续修改放在同一事务内,相关锁在提交或回滚时释放。不要依赖自动提交模式下孤立的一条查询保护应用稍后的另一条语句。
一个能看到差别的时间线
两个会话都先设为 RR,A 开显式事务:
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | START TRANSACTION; | |
| 2 | SELECT balance FROM accounts WHERE id=1; → 1000 | |
| 3 | START TRANSACTION; | |
| 4 | UPDATE accounts SET balance=900 WHERE id=1; | |
| 5 | COMMIT; | |
| 6 | 相同普通 SELECT → 1000 | |
| 7 | SELECT balance FROM accounts WHERE id=1 FOR UPDATE; → 900 | |
| 8 | ROLLBACK; |
A 的普通读复用历史视图,当前加锁读却面对已提交的新状态。如果把步骤 7 放在 B 提交之前,它会等待冲突,而不是把 B 未提交的 900 直接当作可用数据。
表中的当前读是在B提交后发起,所以能够直接观察已提交900。若把它提前,A的那条语句先被阻塞,必须让B从另一连接COMMIT后才能继续;不能在A“正在等待”期间又安排A执行后面的普通SELECT。这是并发时间线必须区分的两个实验时序。官方:锁定读
配图:快照读与当前读的双轨

按时间区分快照返回旧值和当前读等待后读取,不画成脏读。
为什么业务需要分清
如果先普通查询库存,再按照应用内旧值写回,期间其他事务可能已经修改库存。可采用带条件的原子 UPDATE,或在同一显式事务中先加锁读取、校验再更新,而不是以为“快照稳定”就等于“没人能改”。
把同样问题换成账户:A读1000后在应用里算900;B也读1000,并成功扣100为900;若A随后直接把列设置为旧计算结果900,两次扣款最终只少100。用 SET balance=balance-100 WHERE balance>=100 时,引擎协调当前记录上的运算和条件,第二次会基于可处理的当前余额继续,而非无条件写回A保留的旧常量。此处仍须检查影响行数并正确处理失败。
反过来,单纯展示历史一致结果时,把每个 SELECT 都改为 FOR UPDATE,会引入不必要等待和死锁机会。
边界
普通 SELECT 的行为还受隔离级别影响,SERIALIZABLE 不能照搬上述规则。快照不是事务开始时复制整个数据库,也不要求 A 必须看不到自己刚写入的数据。混用两种读法可能观察到不同状态,这是两种机制的差异,见第 6 题。
面试回答
在 InnoDB RC/RR 中,普通 SELECT 通常通过 MVCC 做快照读,按读视图寻找可见版本;FOR SHARE、FOR UPDATE 等是加锁读,面向当前可处理的记录状态并可能等待冲突事务。当前读不是脏读,快照稳定也不代表阻止别人修改。需要读后更新时,要用条件更新或在同一事务中加锁,并注意持锁时间。
5. MVCC 如何工作?Undo 版本链有什么作用?
相关问法:读写怎样尽量不互相阻塞?依赖:accounts;本题是版本示意。
一句话理解与用途
MVCC 是多版本并发控制:当前记录已被修改时,数据库仍能利用历史信息重建某个事务应当看到的旧版本。这样读者不必为了每次普通查询都等写者结束。
假设报表已读到账户余额 1000,另一个事务把它改为 900。报表需要保持自己的历史视角,写者又希望继续提交,保留版本信息可以兼顾这两个需求。
必要概念
InnoDB 聚簇记录包含事务标识、回滚指针等内部信息。事务标识帮助判断哪个事务产生了版本;回滚指针连接到相关 Undo 信息;Undo 保存撤销或重建旧值所需的信息,而不是完整复制整个数据库。
Read View 则表示“这次一致性读允许看到哪些事务的修改”。版本链提供候选历史,读视图负责选择,不要把两者混为一物。
逐步推演
- 账户初始已提交余额为 1000。
- 事务 A 建立一致性读视图,能看见 1000。
- 事务 B 更新成 900,生成用于恢复旧值的 Undo,当前记录关联 B 的版本信息。
- 即使 B 随后提交,A 的 RR 视图也可能不允许看见 B 的修改。
- A 读取时先检查当前版本;不可见,就沿回滚信息重建更早版本,直到找到可见的 1000。
- 后来建立视图的新事务则可能直接看到已提交的 900。
可以用逻辑链表示:当前 900(B)→ 历史 1000(更早事务)。这只是便于理解的版本关系,不表示磁盘上一定存了两个完整行副本。
版本链先提供“有哪些可重建候选”,读视图再回答“其中哪个能给我看”。如果A发现900由自己的视图不允许的事务产生,就按Undo恢复上一版1000;它没有把全局当前页改回1000,B的提交仍是900。新事务C在B提交后建立视图,则可能直接接受当前900。这样读者各自得到一致观察,写者无需为了A的报表暂停全部修改。官方:多版本机制
配图:MVCC 沿版本链找可见记录

沿历史版本检查可见性,解释不同读视图为什么返回不同余额。
MVCC 为什么仍需要锁
两个写者同时扣同一账户,不能各自随意覆盖对方。写写冲突仍需要锁等同步。普通快照读可以减少一些读写冲突,但加锁读、约束检查及 DDL 等仍可能等待。
这也说明“MVCC 是无锁数据库”是错误的。它服务一致性读,不取代所有并发控制。
历史什么时候清理
历史信息不可能无限保留。提交后,如果仍有旧快照可能需要相关 Undo,就不能立即清理;满足条件后由 purge 等清理机制回收。
长事务可能长期保留旧视图,拖延历史清理、增加存储与遍历成本。因此,即使事务只读,长时间不结束也可能影响系统。不要把开启事务后等待用户操作当成低成本习惯。
例如A的报表事务开着不结束,B之后多次更新同一账户,A所需的早期版本仍可能不能清理;后来查询沿版本重建也可能增加工作。将只读操作放事务里可以提供一致性,但“只读所以没有资源成本”是另一条错误结论。应把需要统一视角的读取组织在合理时长内,而不是用一个事务跨越用户长时间浏览。
边界
这里重点讨论 RC/RR 的一致性读。RU 允许脏读,不应描述成“通过加锁当前读实现”;SERIALIZABLE 也有额外规则。删除标记、二级索引访问和可见性存在更多实现细节,面试先抓住“当前记录、Undo、视图”三者分工。
面试回答
MVCC 通过版本信息让不同事务读取各自可见的数据。InnoDB 使用记录的事务标识、回滚指针和 Undo 历史信息,再由 Read View 判断可见性;当前版本不可见时,可沿历史重建旧版本。它减少普通读与写之间的冲突,但写写仍需要锁。旧 Undo 还要等不再被快照需要时才能清理,所以长事务也有成本。
6. Read View 怎样判断可见性?RC 与 RR 为什么表现不同?
相关问法:BEGIN 就创建快照吗?为什么能看见自己的修改?依赖:逻辑事务 ID 示例。
一句话理解与用途
Read View 记录建立一致性读视图时,哪些事务尚未完成,以及事务 ID 的边界。它帮助区分“视图之前已完成的修改”和“当时仍未完成或以后才产生的修改”。
事务 ID 大小表示分配关系,不是简单的提交时间排序。较早得到 ID 的事务也可能很晚才提交,所以只比较大小还不够,必须检查活动集合。
用固定数字理解规则
假设某读写事务自己的 ID 为 12,视图建立时:活动读写事务集合是 {10,12},最小活动 ID 为 10,当时尚未分配的下一个 ID 为 15。13、14 已经提交,因此不在活动集合中。
不同资料对上下界字段的命名容易产生混淆,这里直接用含义说明:
| 被检查版本的事务 ID | 判断 | 原因 |
|---|---|---|
| 12 | 自己的修改可见 | 事务需要读到自己的写入 |
| 9 | 可见 | 早于最小活动边界,已完成 |
| 10 | 不可见 | 建视图时仍活动,即使后来提交也不改变这张视图 |
| 13 | 可见 | 在两个边界之间,但不在活动集合,说明当时已完成 |
| 15 或更大 | 不可见 | 属于视图建立后才可能分配的事务 |
上述讨论以相应已提交历史版本和正常版本链为前提;已回滚修改不会成为应返回的已提交业务版本。只读事务也不必都分配这类读写事务 ID。
沿版本链怎样找
假设当前余额 700 属于事务 15,前一版 800 属于事务 10,再前一版 900 属于事务 9。对上述视图,15 太新,10 在活动集合中,都不可见;继续重建到 9,返回 900。
如果中间存在事务 13 的已提交版本,它不在活动集合,便可以成为可见结果。遗漏这条分支,就会把“边界之间全部不可见”讲错。
判断顺序也重要:先检查“是不是自己的版本”,否则活动集合包含12时会误判自己不可见;再检查确定早于边界和晚于边界的情况;最后才检查中间ID是否仍活动。建视图时10还未完成,后来10提交也不会改写这张旧视图的记录。活动ID不是“哪些行被锁”的清单,Read View不负责替所有这些事务加锁。
配图:Read View 怎样判断可见

逐步检查自身、活动边界及集合成员,避免把中间区域一律排除。
RC 与 RR 的区别在视图何时创建
RC 通常每次一致性读建立新视图,所以第二次查询可能纳入别人新提交的修改。RR 通常在事务第一次一致性读时建立视图,后续复用;仅执行 BEGIN,不代表已经复制或固定全库状态。
RR 下 START TRANSACTION WITH CONSISTENT SNAPSHOT 可在事务开始时请求一致性快照,其适用性受隔离级别约束。不要把 BEGIN 时刻、读视图创建时刻、读写事务 ID 分配时刻当成同一件事。官方一致性读说明
配图:RC 与 RR 何时创建读视图

在 SELECT 处标建视图时机,区分 RC 重建与 RR 复用。
容易混淆的地方
自己的更新通常可见。因此 RR 并不是任何 SQL 永远返回第一次的字节结果;自己写入、当前读和快照读混用,都要分别分析。视图规则决定版本可见性,并不负责阻止别人在间隙插入。
用一个独立分支理解自身写入:A的RR视图先读1000;B改900并提交;A随后执行 UPDATE accounts SET balance=balance-10 WHERE id=1,写入当前状态得890;A普通查询会看见自己的890,而不是继续强行返回1000。A回滚后只撤销自己的10,B已提交的900保留。这里的890不是初始快照值减10,因为UPDATE不是沿原历史快照写回。该分支结束后再恢复本章初值。
面试回答
Read View 用活动事务集合和事务 ID 边界判断版本是否可见:自身修改可见,早于活动边界的版本可见,未来分配的不可见,中间区域要看是否仍在活动集合。不可见时沿 Undo 找历史。RC 通常每次一致性读重新建视图,RR 通常复用首次一致性读的视图,这解释了重复读取差异;BEGIN 不等于立即建立快照。
7. InnoDB 的 RR 如何处理幻读?
相关问法:RR 是否完全没有幻读?依赖:accounts,独立实验前只保留账户 1、2。
一句话理解与用途
RR 下要把两种保护分开:普通一致性读用同一视图维持历史结果;范围加锁读可以锁住相关索引间隙,阻止其他事务插入冲突范围。一个是“我不看新版本”,一个是“暂时不允许你插进来”。
这一区分解决了常见困惑:为什么一个事务查不到新行,但另一事务却能把它成功插入?因为历史视图不需要阻止所有写入。
实验一:快照稳定,但插入没有被阻止
两个会话设为 RR,按以下顺序执行:
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | START TRANSACTION; | |
| 2 | SELECT id FROM accounts WHERE id BETWEEN 1 AND 3 ORDER BY id; → 1、2 | |
| 3 | START TRANSACTION; | |
| 4 | INSERT INTO accounts(id,balance) VALUES(3,1000); | |
| 5 | COMMIT; | |
| 6 | 重复步骤 2 → 仍为 1、2 | |
| 7 | COMMIT; |
B 可以插入并提交,A 只是通过旧视图看不到这条新记录。A 开新事务再查,就可以看见 3。
实验二:范围加锁阻止插入
恢复为两条账户,A 在 RR 显式事务中执行:
SELECT id FROM accounts
WHERE id BETWEEN 1 AND 3
FOR UPDATE;
A 未提交时,B 在自己的事务插入账户 3,会因该访问范围上的冲突间隙保护而等待。A 提交或回滚释放锁后,B 才能继续;随后 B 回滚,清理实验插入。锁的具体边界要根据实际索引扫描分析,本例使用主键范围。
| 机制 | A为什么仍得到1、2 | B插入3能否立即推进 |
|---|---|---|
| RR普通一致性读 | 旧Read View不纳入新事务版本 | 本例不因这次普通读被阻止 |
| RR范围锁定读 | 冲突插入先被范围保护挡住 | 先等待相关锁释放 |
左边的稳定结果来自读取规则,右边来自写入协调。比如需要“检查这个编号不存在,再创建它”,最终唯一性更适合由唯一约束兜底,而不是认为普通RR查询没看到记录,就保证另一个请求也不能插入。范围锁还会影响相关区间的其他写入,应明确只为哪条业务规则持锁。
配图:RR 快照读与锁定读:两个机制

对照旧版本可见性与范围锁阻止插入,它们不是同一个机制。
为什么混合读法可能看到不同集合
回到实验一,如果 A 的第二次查询改成 FOR UPDATE,它可以看到 B 已经提交的账户 3,因为当前读不复用普通 SELECT 的历史视图来选记录。
同一事务里既有历史读又有当前读,观察不相同并不证明快照坏了。更不能把“RR 可以稳定快照读”扩展成“事务内所有语句始终只能看到第一次结果”。自身更新也可能改变后续观察。
适用场景与代价
报表只需要一致观察时,快照读通常减少对写入的干扰;业务要求在检查期间禁止竞争者插入特定范围时,需要相应锁或更直接的唯一约束设计。范围锁可能扩大等待和死锁机会,不能所有查询都随意加 FOR UPDATE。
面试回答
InnoDB RR 主要通过两种机制处理相关问题:普通一致性读复用 Read View,看不到后来提交的新记录;加锁范围读通过 Next-Key、间隙锁等保护扫描范围,阻止冲突插入。前者控制可见性,后者限制并发写。混用快照读与当前读或自己修改数据,仍可能观察到变化,所以不能说 RR 下所有读法都绝对不变。
8. 全局锁、表锁和 MDL 分别是什么?
相关问法:为什么 ALTER TABLE 一直等待?备份一定要锁全库吗?依赖:accounts;锁命令只在隔离环境学习。
一句话理解与用途
这三类锁保护的对象不同:全局读锁限制实例范围内的相关写操作;显式表锁限制指定表的访问;MDL 元数据锁防止使用表期间表结构被不兼容地修改。
行锁保护数据,不足以单独解决“查询执行到一半,列被删了”的问题,因此还需要元数据层面的协调。
全局读锁
FLUSH TABLES WITH READ LOCK 常简称 FTWRL,会建立全局读锁,阻塞相关数据更新、表结构修改及更新事务提交等操作,普通读取通常仍可进行;通过 UNLOCK TABLES 或连接结束释放。获取锁本身也可能等待,不能认为执行命令就立即冻结在一个无成本时刻。
它曾常用于获得备份所需的一致位置,但长时间持有会严重影响写业务。对事务型表可以利用一致性快照做在线逻辑备份;不过非事务表和并发 DDL 等仍需额外处理,不能说所有备份场景从此都无需锁协调。
显式表锁
LOCK TABLES ... READ/WRITE 对指定表施加显式限制。READ 表锁允许相应读取、阻止冲突写;WRITE 表锁通常排斥其他会话对该表的读写。与 InnoDB 常规事务内自动获得的记录锁相比,粒度更大。
不要把 LOCK TABLES 随意夹进普通事务实验,它有隐式提交及使用限制。正常 InnoDB 业务通常优先依靠事务和合适索引控制锁范围,不必为了“线程安全”手工锁整张表。
MDL 的最小时间线
会话 A:
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
A 暂不结束。会话 B:
ALTER TABLE accounts ADD COLUMN demo_note VARCHAR(20) NULL;
B 需要获得不兼容的元数据锁,可能等待 A 的事务结束。A 即使只查询、不改数据,也持有与表访问有关的 MDL。A 执行 ROLLBACK 后 B 才能继续;实验完成后删除测试列。应设置合理的实验等待上限,不在生产库复现。
这个等待保护的是“表结构在使用期间怎样变化”,不是余额1000是否被A改过。若只去找某个排他行锁,可能根本找不到真正阻塞来源。MDL等待上限涉及 lock_wait_timeout,不要与行锁等待的 innodb_lock_wait_timeout 混称;实验若改会话参数,完成后恢复原值。清理只针对本题新建列,可在B完成且所有事务结束后,在本练习库执行 ALTER TABLE accounts DROP COLUMN demo_note。官方:元数据锁
配图:全局、表与元数据锁作用范围

按实例、表和表定义区分锁对象,观察 MDL 等待何时解除。
边界与排查
MDL 通常由服务器自动管理,不要求应用显式加锁。长事务会拖住 DDL,等待中的 DDL 还可能影响后续访问,形成排队。排查应查看元数据锁与事务状态,而不是只找某一行是否被锁。
常见故障形态是A的长期事务占着定义使用权,B的ALTER等待不兼容锁,后来的访问又排在这次DDL请求后面。这时“先加大DDL超时”没有解决A为何不结束;应识别持有者、请求者和业务风险,再安排释放或改变变更窗口。本节只是解释及隔离练习,不授权结束生产会话或获取全局读锁。
全局、表、MDL 与记录锁不能仅按“大中小”统一排名,它们保护的语义和兼容关系也不同。
面试回答
全局读锁用于限制实例范围内相关写入,典型命令是 FTWRL;显式表锁通过 LOCK TABLES 管理指定表访问;MDL 自动保护表定义,防止访问期间发生不兼容结构修改。长事务即使只读也可能让 DDL 等待。备份和结构变更要根据事务表、非事务表及并发 DDL 的条件设计,不能不分场景长期锁全库。
9. 记录锁、间隙锁和 Next-Key Lock 如何工作?
相关问法:查不到数据为什么也有锁?意向锁和插入意向锁是什么?依赖:独立表。
一句话理解与用途
记录锁保护已有索引记录;间隙锁限制向两个键之间插入;Next-Key Lock 将记录与它前面的间隙一起保护。仅锁住已有行,无法阻止别人插入一条新的匹配行,这就是范围保护存在的原因。
锁作用于索引访问路径,不是按 SQL 中“看起来返回了几行”简单计算数量。
准备固定键值
CREATE TABLE demo_lock_keys (
id INT NOT NULL PRIMARY KEY,
value INT NOT NULL
) ENGINE = InnoDB;
INSERT INTO demo_lock_keys(id,value) VALUES (10,1),(20,1),(30,1);
把键画成 10 —— 20 —— 30:锁记录 20 只保护这个键;间隙 (10,20) 针对两者之间;关联记录 20 的 Next-Key 范围示意为 (10,20]。最小键之前、最大键之后也存在边界范围。
三个场景逐个看
- RR 唯一等值找到行:A 显式事务执行
SELECT * FROM demo_lock_keys WHERE id=20 FOR UPDATE,典型情况下对目标采用记录锁,不必把前方整个间隙都锁住。B 修改 20 会等待。 - RR 唯一等值未找到行:A 查询
id=15 FOR UPDATE,没有返回记录,仍可能锁定 15 所在间隙。B 插入 15 会等待;其他位于同一受保护间隙的插入也可能被影响。 - RR 范围加锁查询:需要保护扫描记录和相关间隙,防止竞争插入改变范围。实际锁边界受索引、谓词和扫描方式影响,可能大于最终返回集合。
每个场景独立开始:A 设置 RR 后 START TRANSACTION;B 的冲突操作放在另一显式事务;观察等待后 A ROLLBACK,B 继续执行后也 ROLLBACK。不要同时叠加三个场景。
第一个场景中锁住20的记录,不代表连15也作为已有行被锁住;第二个场景恰恰没有15这条记录,但仍能保护它所在间隙,解释“结果0行却让插入等待”。图中的 (10,20) 与 (10,20] 是锁类型的边界示意,不是断言任意范围SQL只会锁这一段。对返回很少却等待很多的SQL,要看真正扫描了哪些索引项,不只看最终结果。
配图:记录锁、间隙锁、Next-Key

用同刻度数轴核对开闭区间,区分记录、间隙与 Next-Key。
RC 与两类“意向”
RC 通常不为普通搜索和索引扫描保留用于防幻的间隙锁,但外键、重复键检查等存在例外。
意向锁是表级协调信息:事务准备在表内加共享或排他记录锁时,以 IS/IX 表明意图,方便与整表锁判断兼容,不代表把全表每行都锁了。
插入意向锁则是插入前的间隙锁类型,用来协调插入。不同位置的插入在条件允许时可以并发,不应因为同处一个大间隙就全部串行。官方锁类型说明
IS/IX让服务器不用逐条扫描行锁清单,就能判断某些整表锁与表内加锁意图是否兼容;它们本身不表示每条行都禁止其他写者。间隙锁则主要抑制插入,两个事务的某些间隙锁可能共存;不能把记录S/X锁的兼容表不加区别套到所有名字带“锁”的机制。
容易混淆的地方
普通一致性读可以通过 MVCC 看旧版本,不会仅因目标行有排他锁就必然阻塞。间隙锁之间的兼容规则也不同于普通记录共享/排他锁,主要作用是抑制插入,不能把所有锁都当作互相排斥。
面试回答
InnoDB 的记录锁保护索引记录,间隙锁限制范围内插入,Next-Key Lock 结合记录和前方间隙。RR 的唯一等值命中通常可使用记录锁,范围或未命中查询则可能保护间隙;RC 的常规间隙保护较少但有约束检查例外。锁范围取决于实际访问路径,查不到行也可能有锁,普通快照读则可借 MVCC 读取可见版本。
10. InnoDB 如何检测死锁?锁等待超时有什么区别?
相关问法:死锁怎样复现与优化?依赖:accounts,两个账户均存在。
一句话理解与用途
死锁是事务形成循环等待,谁都无法仅靠继续等待完成。普通锁等待只是暂时拿不到锁,持锁者结束后还能继续,二者不是同义词。
检测机制寻找等待关系中的环,选择一个事务回滚,让其他事务有机会推进。牺牲一个事务不是数据库随意丢数据,而是用可恢复的失败打破僵局。
双会话推演
两个会话在默认启用死锁检测的 InnoDB 环境中,按顺序操作:
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | START TRANSACTION; | START TRANSACTION; |
| 2 | UPDATE accounts SET balance=balance+1 WHERE id=1; | |
| 3 | UPDATE accounts SET balance=balance+1 WHERE id=2; | |
| 4 | UPDATE accounts SET balance=balance+1 WHERE id=2;,等待 | |
| 5 | UPDATE accounts SET balance=balance+1 WHERE id=1;,形成环 |
A 等 B 释放账户 2,B 等 A 释放账户 1。检测后一个事务收到死锁错误并整体回滚,另一个可继续。不要写死必定牺牲 A 或 B,选择与实现及事务代价有关。最后两个会话都执行 ROLLBACK,保证成功继续的一方也不留下实验改动。
若只有步骤4而B不再申请账户1,只是稍后结束,等待关系就是A→B,没有环,A可以在B释放后继续。步骤5增加B→A后,两个事务都不能通过等待对方自行前进,这才需要选一方撤销来释放资源。“等待时间很长”不是死锁定义,环和持有资源的对应关系才是。
超时与死锁的回滚范围
死锁错误通常是 1213,InnoDB 回滚被选中的整个事务。锁等待超时常见错误为 1205,默认通常只回滚当前等待语句;启用 innodb_rollback_on_timeout 时会改变超时回滚行为。因此应用不能把两个错误当成“数据库都已经帮我清理完整事务”。官方错误处理说明
当死锁检测关闭时,某些等待环需要依靠超时退出;超时也可能只是持锁事务太慢,不足以证明死锁。
配图:环形等待才是死锁

观察等待边是否形成环,再区分死锁牺牲与普通等待超时。
怎样减少与处理
让同类事务按统一顺序访问记录,例如都先账户 1 再账户 2;缩短事务,避免持锁期间调用慢外部服务;建立合适索引,减少无关扫描与锁;控制批量规模。
排查可结合 SHOW ENGINE INNODB STATUS 中的近期死锁信息,以及 Performance Schema 的锁等待数据,检查双方语句和索引,而不是只看报错的最后一条 SQL。
应用应明确回滚后按完整业务事务有限重试,结合退避和幂等。只重试最后一条语句,可能丢掉同事务前面已被回滚的必要步骤。
例如业务事务本来要先写请求记录、再扣余额,收到1213后只重发扣余额,会遗漏已随整笔回滚撤销的请求记录;后续重试就缺少幂等依据。收到1205时又不能反过来假定此前语句都已撤销。应用可以统一在已识别失败后显式结束本次事务,再按业务边界有限重试,同时记录失败类型,避免死循环掩盖真实锁设计问题。
面试回答
死锁是循环等待,InnoDB 在启用检测时会选择事务作为受害者整体回滚。普通锁等待不一定是死锁,超时默认通常仅回滚当前语句,需结合配置处理。优化重点是统一访问顺序、缩短事务和减少扫描锁范围;应用则应按完整业务事务设计有限重试与幂等,不能假定只重发最后一条 SQL 就安全。
阅读导航




