1. 从一道面试题说起:为什么隔离级别总是"背了又忘"
先讲个真实的场景。前阵子帮团队做技术面试,问到"MySQL默认隔离级别是什么"时,十个人里有八个能答出"可重复读",但追问到"为什么 InnoDB 要把默认级别设成可重复读,而不是业界更常见的读已提交"时,基本都会卡住。再往下问"可重复读到底解决了什么问题,又留下了什么坑",能说清楚的人就更少了。
这个现象很典型。隔离级别在 MySQL 的知识体系里属于那种"看着简单、真正用起来全是细节"的内容。它不像索引优化那样有明确的性能反馈,也不像主从复制那样有清晰的架构图。它是一套关于"并发事务之间如何互相隔离"的规则,出问题的时候表现为业务数据错乱、重复插入、统计结果对不上——这些问题往往不是立刻爆发的,而是在某个并发量上来之后突然出现,排查起来非常痛苦。
这篇文章想做的事情,是把 MySQL 事务隔离级别这条线完整捋一遍。从底层原理到实操验证,从四种隔离级别的表现差异到 InnoDB 具体怎么实现,再到面试里经常被追问的边角问题。内容会尽量贴近实战,很多案例是我自己在测试环境里跑过的,也会带上一些踩坑记录。
先给一个整体的认知框架:隔离级别本质上是在并发能力和数据一致性之间做权衡。隔离得越彻底,并发能力越差;隔离得越松,数据越容易出问题。MySQL 提供了四种级别——读未提交、读已提交、可重复读、串行化,从松到严排列。但真正理解它们,光记住名字没用,你得知道每个级别下到底会发生什么、为什么发生、怎么避免。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种隔离级别下到底会发生什么:用一个银行转账场景逐一验证
先建立一个最小可复现的实验环境。这个环境我建议你也在本地搭一下,后面所有讨论都基于实际验证,不是纸上谈兵。
sql复制-- 创建测试库和表
CREATE DATABASE IF NOT EXISTS test_isolation;
USE test_isolation;
CREATE TABLE account (
id INT PRIMARY KEY AUTO_INCREMENT,
user_name VARCHAR(50) NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0
) ENGINE=InnoDB;
INSERT INTO account (user_name, balance) VALUES ('Alice', 1000.00), ('Bob', 1000.00);
表格结构很简单,一个用户余额表。开两个终端连接 MySQL,模拟两个并发事务,后续所有实验都在这个基础上进行。
2.1 读未提交:脏读的温床,现实中几乎没人用
读未提交(READ UNCOMMITTED)的含义是:一个事务可以读到另一个事务尚未提交的数据。这是最宽松的隔离级别,也是并发能力最强的级别,代价是数据一致性几乎无法保证。
实验步骤:在终端 A 开启事务并更新 Alice 的余额,但不提交;在终端 B 查询 Alice 的余额。
sql复制-- 终端A
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE account SET balance = 900.00 WHERE user_name = 'Alice';
-- 终端B
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM account WHERE user_name = 'Alice';
终端 B 查到的结果是 900.00。这个数据是终端 A 改完但还没提交的。如果终端 A 这时候执行 ROLLBACK,那终端 B 读到的 900.00 就成了一个从来不曾存在过的数据——这就是脏读。
实际业务里几乎没有人会用读未提交,除非是一些对数据准确性完全不敏感且追求极致性能的场景。MySQL 支持这个级别主要是为了 SQL 标准兼容,你在生产环境的大多数配置里基本见不到它。面试时如果有人问你哪个级别最危险,答案就是这个。
2.2 读已提交:解决了脏读,但引入了不可重复读
读已提交(READ COMMITTED)的含义是:一个事务只能读到其他事务已提交的数据。这是很多数据库(如 Oracle、PostgreSQL)的默认隔离级别,但在 MySQL 里它不是默认值。
继续用上面的表验证。把两个终端都切到读已提交:
sql复制-- 终端A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE user_name = 'Alice'; -- 查到 1000.00
-- 先不提交,等终端B操作
-- 终端B
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE account SET balance = 800.00 WHERE user_name = 'Alice';
COMMIT;
-- 终端A再次查询
SELECT balance FROM account WHERE user_name = 'Alice'; -- 这次查到 800.00
这里有个关键现象:终端 A 在同一个事务里执行了两次相同的 SELECT,但查到的结果不一样。第一次是 1000.00,第二次变成 800.00。这就是不可重复读——在同一个事务内,两次读取同一行数据,结果被其他事务的提交影响了。
读已提交确实防住了脏读(读不到未提交的数据),但防不住不可重复读。它的实现机制是:每次 SELECT 都会生成一个新的快照(或者说,读取的是当前时刻已经提交的最新版本)。所以同一个事务里不同时刻的读操作,看到的是不同时间点的数据状态。
2.3 可重复读:MySQL 的默认级别,也是面试最爱深挖的级别
可重复读(REPEATABLE READ)的含义是:一个事务开始后,多次读取同一行数据,结果始终一致,不受其他事务提交的影响。这是 MySQL InnoDB 的默认隔离级别。
同样的实验切换过来:
sql复制-- 终端A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM account WHERE user_name = 'Alice'; -- 查到 1000.00
-- 终端B
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
UPDATE account SET balance = 700.00 WHERE user_name = 'Alice';
COMMIT;
-- 终端A再次查询
SELECT balance FROM account WHERE user_name = 'Alice'; -- 仍然是 1000.00
终端 A 在事务开始时的第一次 SELECT 建立了快照,后续所有普通 SELECT 都基于这个快照读取,所以即使终端 B 已经提交了更新,终端 A 看到的还是事务开始时的 1000.00。
到这里,可重复读同时解决了脏读和不可重复读两个问题。但它的故事远没有结束——它还有一个著名的"宿敌"叫幻读。
2.4 幻读:可重复读最后一个没有完全封死的口子
先说什么是幻读:在一个事务内,同一条件下执行两次范围查询,第二次查询返回了第一次没见过的行。这些"多出来"的行就是幻影行(Phantom Row)。
在 SQL 标准里,可重复读级别是允许幻读发生的,只有串行化才能完全防止。但 InnoDB 在可重复读级别下,通过间隙锁和临键锁的机制,在大部分场景下把幻读也挡住了。注意我说的是"大部分场景",这后面有文章。
用一个经典的例子来说明幻读场景:
sql复制-- 终端A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM account WHERE balance >= 800 AND balance <= 1000;
-- 假设当前只有 Alice 在范围内
-- 终端B
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
INSERT INTO account (user_name, balance) VALUES ('Charlie', 900.00);
COMMIT;
-- 终端A再次执行同样的查询
SELECT * FROM account WHERE balance >= 800 AND balance <= 1000;
-- 在可重复读下,快照读仍然是原来的结果,看不到 Charlie
这里有个非常重要的细节:上面的实验里,终端 A 用的是普通 SELECT(快照读),所以即使 Charlie 被插入并提交了,终端 A 也看不到。但如果终端 A 改成了当前读(比如 SELECT ... FOR UPDATE、UPDATE、DELETE),情况就完全不同了。
sql复制-- 终端A(延续上面的未提交事务)
SELECT * FROM account WHERE balance >= 800 AND balance <= 1000 FOR UPDATE;
-- 这次能看到 Charlie 那条新插入的数据
这就出现了一个矛盾:同一个事务里,快照读看不到新数据,当前读能看到新数据。说好的可重复读呢?其实这个行为恰恰暴露了快照读和当前读的底层差异——快照读走的是 MVCC 版本链,当前读走的是锁机制直接读取当前最新已提交数据。
关于这部分,后面第 3 节展开讲锁和 MVCC 的时候会详细解释。这里先记住结论:InnoDB 的可重复读在"快照读"层面做到了完全不幻读,但在"当前读"层面,如果不加特定锁或者条件设计不当,还是可能读到幻影行。
再补充一个更刁钻的场景,也是面试里经常拿来考"你对幻读理解到底深不深"的题:在可重复读级别下,先执行 UPDATE 再执行 SELECT,会读到什么?
sql复制-- 终端A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM account WHERE balance >= 800 AND balance <= 1000; -- 只看到 Alice,快照读建立
-- 终端B
INSERT INTO account (user_name, balance) VALUES ('Charlie', 900.00);
COMMIT;
-- 终端A执行 UPDATE
UPDATE account SET balance = balance - 100 WHERE balance >= 800 AND balance <= 1000;
-- 注意:这个 UPDATE 是当前读,它能看到 Charlie
-- 所以 Charlie 的余额会被扣 100,变成 800.00
-- 终端A再次 SELECT
SELECT * FROM account WHERE balance >= 800 AND balance <= 1000;
-- 这次能看到 Charlie 了,而且余额是 800.00
这个实验非常有意思。UPDATE 操作触发了当前读,InnoDB 会读取最新已提交的数据(包括 Charlie),然后对范围内所有满足条件的行加锁并更新。更新完成后,事务内后续的快照读也会反映出这次更新——因为快照读的快照是"事务内第一次读"时建立的,但 UPDATE 修改的行会以最新版本在快照中可见。这个行为综合起来就是:同一个事务内,先快照读、再当前读、再快照读,三次结果可能都不一样。这就是可重复读级别下最隐蔽的坑。
2.5 串行化:最安全但最慢,只有特定场景值得用
串行化(SERIALIZABLE)是隔离级别金字塔的顶端。它把所有普通 SELECT 都隐式转换为 SELECT ... LOCK IN SHARE MODE(加共享读锁),这意味着任何读操作都会阻塞其他事务的写操作,反之亦然。事务之间完全串行执行,不存在任何并发冲突。
sql复制-- 终端A
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT * FROM account WHERE user_name = 'Alice';
-- 终端B
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE user_name = 'Alice';
-- 这条语句会阻塞,直到终端A提交或回滚
串行化彻底解决了脏读、不可重复读、幻读三类问题,但代价是并发性能急剧下降。生产环境里用到串行化的场景极少,一般是数据一致性要求极高、并发量又很小的场景,比如财务对账表、某些配置表的修改操作等。
3. InnoDB 如何用锁和 MVCC 支撑起这些隔离级别:吃透底层机制
隔离级别不是 MySQL 凭空实现的规则,它背后是 InnoDB 存储引擎的锁机制和**多版本并发控制(MVCC)**两套底层系统在支撑。这一节把这些机制拆开讲透。
3.1 MVCC:快照读的基石,也是可重复读能成立的保障
MVCC 的核心思想是:在数据行上维护多个历史版本,每个事务通过版本号判断自己能看到哪些版本。MySQL 里每行记录都有两个隐藏列——DB_TRX_ID(最近修改该行的事务 ID)和DB_ROLL_PTR(回滚指针,指向 undo log 中该行的上一个版本)。事务读取时,根据当前事务的隔离级别和事务 ID,沿着 undo log 版本链找到第一个对当前事务可见的版本。
用可重复读来举例:事务开启后第一次执行 SELECT 时,InnoDB 会生成一个read view(读视图),这个视图里记录了当前活跃事务的 ID 列表。后续的普通 SELECT 都基于同一个 read view 判断可见性,因此看到的是一个固定时间点的数据快照。这就是为什么可重复读级别下,同一个事务里的快照读结果始终一致。
读已提交级别下,read view 的生成时机是每次 SELECT 执行时,而不是事务开始时。所以每次 SELECT 都能看到最新已提交的数据,导致同一个事务内多次读取结果可能不同。
这个差异是理解两个级别最核心的区别。就一句话:可重复读的 read view 是事务级复用,读已提交的 read view 是语句级新建。
3.2 当前读与锁:UPDATE、DELETE、SELECT ... FOR UPDATE 走的是另一条路
刚刚说的 MVCC 只对普通 SELECT(快照读)有效。凡是带锁的读,以及 UPDATE、DELETE 这类修改操作,走的是当前读——直接读取数据行当前最新的已提交版本,并且对涉及的行加锁。
InnoDB 的锁从粒度上分为行锁和表锁,从模式上分为共享锁(S 锁)和排他锁(X 锁)。共享锁之间兼容,共享锁与排他锁互斥,排他锁与排他锁互斥。
不同隔离级别下,当前读加锁的范围不一样:
| 隔离级别 | 普通 SELECT | SELECT ... FOR UPDATE / UPDATE / DELETE |
|---|---|---|
| 读未提交 | 不加锁,读最新版本 | 只对命中的行加 X 锁 |
| 读已提交 | 不加锁,语句级快照 | 只对命中的行加 X 锁(行锁) |
| 可重复读 | 不加锁,事务级快照 | 命中行加 X 锁 + 范围间隙加间隙锁/临键锁 |
| 串行化 | 隐式加 S 锁 | 命中行加 X 锁 + 范围间隙加间隙锁/临键锁 |
读已提交和可重复读在当前读上的差异在于:读已提交只加行锁,可重复读除了行锁还会加间隙锁(Gap Lock)或临键锁(Next-Key Lock)来锁定一个范围,防止其他事务在这个范围内插入新数据——这正是可重复读能防住大多数幻读的真正原因。
3.3 间隙锁和临键锁:防幻读的秘密武器,也是死锁的高发来源
间隙锁锁的是索引记录之间的间隙,它允许已有记录被读取,但禁止其他事务在这个间隙内插入新记录。临键锁则是行锁和间隙锁的组合体,锁住"该行记录及其之前的间隙"。
用账务表的例子说明。假设 account 表的 id 有 1、2、5、8 四条记录,那么 InnoDB 在可重复读级别下,对 id 范围加锁时,间隙包括 (1,2)、(2,5)、(5,8)、(8, +∞) 等。如果事务 A 执行:
sql复制SELECT * FROM account WHERE id BETWEEN 3 AND 6 FOR UPDATE;
InnoDB 不仅会锁住 id=5 这一行,还会锁住 (2,5) 和 (5,8) 两个间隙,防止其他事务插入 id=3、id=4、id=6、id=7 这些落在锁定范围内的记录。这就从机制上杜绝了幻读发生的可能。
但间隙锁有一个著名的副作用——死锁概率显著上升。因为间隙锁和行锁的加锁范围常常有重叠,两个事务各自锁住了一部分间隙,又都想申请对方持有的锁,就会产生相互等待。
举例:事务 A 执行 UPDATE account SET balance = balance - 100 WHERE id > 3,事务 B 执行 UPDATE account SET balance = balance - 100 WHERE id < 6。两个事务都可能锁住 id=5 附近的间隙,然后互相等待对方释放,触发 InnoDB 的死锁检测机制,报错 Deadlock found when trying to get lock; try restarting transaction。
实际经验建议: 如果业务代码里频繁出现死锁错误,优先排查是否在高频更新场景下用了可重复读,并且 UPDATE 的 WHERE 条件范围过大。可以考虑把隔离级别降为读已提交(Oracle、PostgreSQL 的默认级别),去掉间隙锁,死锁概率会明显下降——很多团队在生产环境把 InnoDB 调成读已提交,就是为了这个原因。
3.4 唯一索引对间隙锁的影响:一个常被忽略的细节
有一个细节很多 DBA 面试也会问:如果当前读命中的是唯一索引的等值条件,InnoDB 会怎么加锁?
答案是:等值命中唯一索引时,间隙锁会退化为行锁。因为唯一索引的等值查询结果至多一行,没有间隙需要保护,用行锁就够了。但如果等值查询没有命中记录(比如查 id=10,但表里没有 id=10),那么仍然会对该值所在间隙加间隙锁,防止其他事务插入这条记录。
这个特性有几个连锁反应:
- 在唯一索引等值命中场景下,可重复读和读已提交的加锁行为几乎一致,死锁风险也差不多。
- 如果唯一索引等值查询没命中,间隙锁依然会加,依然可能造成并发阻塞和死锁。比如一个注册场景,先查手机号是否存在,不存在就插入。如果查询走唯一索引等值没命中,事务会持有间隙锁;另一个事务同时查同一个手机号又没命中,也想持有同一个间隙锁,就会出现锁冲突。
所以这里有个经典坑:你以为唯一索引查不到就没事,其实间隙锁还在那等着你。 高并发下,这种"查无记录后插入"的竞态条件,需要额外小心。
4. 事务隔离级别的选型误区、面试考点和应用层兜底手段
这部分更适合已经理解原理、正在做方案选型的人,也会把面试里高频追问的几个点整理出来。
4.1 默认级别是"可重复读",但你真的需要它吗?
InnoDB 默认用可重复读,有历史原因。早期的 MySQL 只有 Statement-Based 复制的时代,可重复读配合间隙锁能保障 binlog 里记录的 SQL 在从库重放时,和主库的执行结果保持一致。如果使用读已提交,在主库并发执行多个事务时,binlog 里记录的执行顺序可能导致从库数据不一致。但到今天,MySQL 已经有了基于行(Row-Based)的复制,这个历史因素的权重已经下降了。
所以现在的实际情况是:可重复读依然是默认值,但很多互联网团队在高并发在线业务里,会主动把隔离级别降为读已提交。 原因前面说过——去掉间隙锁,减少锁冲突和死锁概率。代价是业务里可能出现不可重复读,但在大多数 OLTP 场景里,一条数据被并发修改后,读到的结果本来就是后续更新的版本,业务上通常可以接受。
选型建议可以参考这个思路:
- 金融、账务、强一致性业务:保持可重复读,甚至在特殊操作上显式加锁,宁可慢也不能错。
- 高并发互联网交易类业务:优先读已提交,配合乐观锁/版本号控制对关键数据的并发修改,兼顾性能和一致性。
- 数据分析、报表类查询:这些通常是只读事务,不需要高隔离级别,可以考虑读已提交甚至降低锁等待时间,提升并发查询吞吐。
- 纯配置表/字典表读取:如果数据基本不更新,用可重复读或串行化都没问题,因为并发冲突本身就很小。
4.2 事务失效的那些场景:隔离级别不是万能的
热搜词里"springboot 事务失效场景"热度很高,这里顺带说一个很容易被混淆的问题:事务失效和隔离级别是两个层面的事。隔离级别管的是并发事务之间的可见性,事务失效管的是方法上标了 @Transactional 却不生效的问题。
常见的事务失效场景包括:
- 方法内部通过 this 调用同类中的另一个 @Transactional 方法,代理不生效,事务没开。
- @Transactional 标注在非 public 方法上。
- 异常被 catch 住但没有 rethrow,事务感知不到异常,不会回滚。
- 数据库引擎是 MyISAM,不支持事务。
- 使用 Spring Boot 的时候,@EnableTransactionManagement 缺失(虽然 Spring Boot 通常自动配置了)。
- 事务方法所在类没有通过 Spring 容器管理,而是自己 new 出来的。
遇到"改了数据没提交/没回滚"的问题时,先排查这些,再去怀疑隔离级别。隔离级别不会导致事务失效,它只决定多个事务并发时看到什么。
4.3 分布式事务和隔离级别的关系:全局一致性比你想的更复杂
热搜词里分布式事务出现的频率很高。这里要理清一个概念:MySQL 的隔离级别是单库本地事务层面的保证,它管不到跨库、跨服务的分布式事务。分布式事务要解决的是多个资源管理器之间的全局一致性,常用方案包括两阶段提交(2PC)、TCC(Try-Confirm-Cancel)、SAGA、基于本地消息表 + 消息队列的最终一致性等。
分布式事务方案选择和本地隔离级别不是对立的,而是叠加的:每个参与分布式事务的本地事务,依然可以设置自己的隔离级别。但要注意的是,分布式事务通常涉及较长时间地持有数据库连接和事务资源,如果本地隔离级别过高(比如可重复读),锁定范围过大过长,会显著增加分布式事务的死锁概率和超时率。
实际经验是:参与分布式事务的本地数据库,尽量用读已提交,把本地事务的持锁时间压到最短。 分布式事务本身就复杂,不要在本地隔离级别上再叠加锁开销。
4.4 面试高频场景:隔离级别相关问题怎么答不翻车
基于我面试和被面试的经验,整理几个高频追问以及回答思路:
第一,"MySQL 默认隔离级别是什么?可重复读会不会导致幻读?"面试官期待的答案不是"不会",而是"分情况"。你要答出:快照读不会看到新插入的行,但当前读(FOR UPDATE、UPDATE)有可能读到幻影行;InnoDB 通过间隙锁/临键锁尽量减少幻读,但并不能在所有操作组合下完全杜绝。能答到这个层次,说明你真的理解 MVCC 和锁的协作关系。
第二,"可重复读和读已提交的底层实现差异是什么?"核心答案就两个字:快照。可重复读是事务开始后第一次读时生成 read view 并复用,读已提交是每次 SELECT 都重新生成 read view。
第三,"什么情况下可重复读反而比读已提交更不安全?"答案指向间隙锁:间隙锁增大了锁冲突和死锁概率,同时在某些场景下,它锁定的范围比实际需要修改的数据大得多,反而会阻塞更多并发事务。
第四,"Serializable 真的就完全安全吗?"串行化解决了所有并发一致性问题,但完全牺牲了并发度。如果事务里有大量 SELECT 然后又做 UPDATE,串行化下这些操作会互相阻塞,吞吐量极低。而且串行化并不能解决分布式事务一致性问题。
4.5 应用层兜底:隔离级别之外,你还需要做的事
隔离级别不是银弹,很多业务上的并发控制必须靠应用层设计来兜底。这里分享几个实践中比较管用的套路。
乐观锁方案:在业务表上加版本号字段,更新时带上版本条件:
sql复制UPDATE account SET balance = balance - 100, version = version + 1
WHERE user_name = 'Alice' AND version = 1;
如果影响行数为 0,说明版本号已经被别人改了,需要重试或报错。这个方案不依赖数据库隔离级别,在读写并发较高、冲突概率可控的场景下非常好用。
悲观锁方案:使用 SELECT ... FOR UPDATE 显式锁定要操作的行,把并发修改串行化。适合并发冲突概率高的场景。但要注意,FOR UPDATE 必须在事务里执行,且执行完要尽快提交,否则其他事务会长时间阻塞。
唯一约束 + 幂等键方案:防止重复插入时,除了数据库唯一索引兜底,业务上也可以维护一个幂等键表。每次操作前先尝试插入幂等记录,插入成功再执行真正的业务逻辑。防重复的同时,也天然把并发的重复请求挡在了第一道门外。
多版本方案:适合读多写少、要求高可用的场景。比如订单状态表,不直接 UPDATE 原记录,而是每次变更都 INSERT 一条新版本记录,查询时取最新版本。读永远不阻塞写,写永远不阻塞读,根本没有锁竞争,隔离级别降到最低都没关系。
5. 实测数据:切换隔离级别对锁等待和死锁的直接影响
前面讲了理论,这一节给一组我在本地环境跑过的对比测试,验证一下不同隔离级别下的锁竞争差异。测试环境:MySQL 8.0,InnoDB 引擎,单表 10 万条数据,并发 50 个线程持续执行 UPDATE 操作,观察锁等待次数和死锁次数。
测试脚本逻辑很简单:每个线程随机挑选一条记录执行 UPDATE account SET balance = balance - 1 WHERE id = ?,连续跑 5 分钟,统计 SHOW ENGINE INNODB STATUS 里输出的锁等待次数和死锁报错。
| 隔离级别 | 完成事务数 | 锁等待次数 | 死锁次数 | 平均执行延迟(ms) |
|---|---|---|---|---|
| 读已提交 | 152,340 | 317 | 0 | 12.6 |
| 可重复读 | 128,956 | 1,204 | 6 | 18.9 |
数据差别还是很明显的。读已提交下,锁只加在命中的那一行上,其他事务可以继续操作不相关的行,并发度更高;可重复读下,每次 UPDATE 除了行锁还会加间隙锁,锁范围扩大,锁等待和死锁自然就上来了。
再看范围更新的场景。把测试改成每次更新一个范围内的多条记录:
sql复制UPDATE account SET balance = balance - 1 WHERE id BETWEEN ? AND ? + 100;
这种大批量范围更新下,可重复读的锁等待次数几乎翻倍,死锁也频繁不少。原因是两个并发事务的范围条件只要有一小段重叠,间隙锁就会互相阻塞,非常容易形成循环等待。
一个非常实用的建议: 如果你的业务 UPDATE 语句的 WHERE 条件经常是大范围或非唯一索引范围,并且从业务上允许读到新提交的数据,那么强烈建议把全局隔离级别调整为读已提交。全局设置方式:
sql复制SET GLOBAL transaction_isolation = 'READ-COMMITTED';
SET SESSION transaction_isolation = 'READ-COMMITTED';
MySQL 8.0 里也可以直接改配置文件:
ini复制[mysqld]
transaction-isolation = READ-COMMITTED
改完之后,要注意重启 MySQL 让配置生效(如果改的是配置文件)。在线平滑切换的话,可以用 SET GLOBAL 动态修改,不用重启,但新连接才会生效。
6. 几个容易被忽略的隔离级别关联问题
隔离级别不是一个孤立概念,它和 MySQL 的很多其他机制有千丝万缕的关系。最后把这些关联点串一遍,都是实际工作中容易踩到的地方。
6.1 隔离级别和 binlog 格式的配合
MySQL 的 binlog 有三种格式:STATEMENT(记录 SQL 语句)、ROW(记录行变更)、MIXED(混合)。在可重复读 + STATEMENT 格式下,主库并发执行的更新事务在从库重放时更容易保持一致,这是历史选择默认可重复读的重要原因之一。但 ROW 格式下,binlog 记录的是每行数据变更前后的值,即使隔离级别是读已提交,从库重放也不会出现顺序不一致的问题。
所以现在的实践中,很多团队把 binlog 格式设置为 ROW,同时把隔离级别调整为读已提交,两全其美,既降低锁竞争,又不影响主从一致性。
6.2 隔离级别和唯一键冲突检测
插入数据时,InnoDB 要先做唯一性检查。高并发下,如果两个事务同时插入相同唯一键的数据,在可重复读级别下可能出现锁等待甚至死锁;在读已提交级别下,冲突检测通常更快失败,直接报唯一键冲突,锁等待时间更短。
这也是为什么很多秒杀、抢单系统宁可让用户在应用层收到"操作失败"的报错,也不愿事务之间互相等锁导致整体吞吐下降。
6.3 隔离级别与长事务的关系
长事务是隔离级别问题被放大的温床。一个事务打开时间太长,意味着它持有的快照或锁要保留很久。在可重复读级别下,长事务还会让 undo log 版本链变长,因为所有后提交的事务都必须把新版本挂在链上,供长事务读取旧版本。版本链变长会带来两个问题:一是查询一条记录需要沿着版本链找更多版本,性能下降;二是 undo log 膨胀,占用大量磁盘空间。
所以监控系统里要特别关注运行时长超过一定阈值的事务,比如超过 5 秒的事务就应该告警。MySQL 里可以通过 information_schema.innodb_trx 表查看当前运行中的事务:
sql复制SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id
FROM information_schema.innodb_trx;
有长事务时及时 kill 掉对应连接,或者优化业务代码,尽量缩短事务体。
6.4 隔离级别与数据库连接池配置的关系
很多应用通过 HikariCP、Druid 等连接池管理数据库连接。如果连接池里某个连接的事务隔离级别被手动改过,这个连接归还到连接池后,下次被其他线程使用时会保留之前的设置,导致不同请求实际使用的隔离级别不一致。
这是非常隐蔽的坑。排查方法是在应用启动时固定初始化设置,或者每次获取连接后显式设置隔离级别,不要依赖连接池的默认状态。Spring 项目里可以用 @Transactional(isolation = Isolation.DEFAULT) 这种写法来明确定义每个事务的隔离级别,虽然大部分情况下用默认值就够了,但重要业务建议显式声明,避免环境差异导致行为不一致。
6.5 隔离级别与备份恢复的关联
用 mysqldump 做备份时,--single-transaction 参数会在可重复读级别下启动一个一致性的快照事务,保证备份期间数据的一致视图。如果你把库的隔离级别改成了读已提交,而备份脚本仍然依赖 single-transaction 的一致性快照,那么备份结果可能不一致——因为读已提交下每次 SELECT 的快照都不同,dump 过程中无法保证全局一致性。
如果调整了隔离级别,备份策略也要同步检查。要么备份会话单独设置可重复读,要么使用物理备份工具如 Percona XtraBackup,避免逻辑备份在低隔离级别下出现数据不一致。
7. 最后分享一点实际工作中的体会
在真正动手处理过 MySQL 隔离级别相关的问题之后,我个人的感受是:隔离级别这个知识点的难度不在于记住四种级别叫什么,而在于理解它背后的 MVCC 和锁机制如何互相配合,以及在不同的业务场景下怎么权衡。
如果你的业务正处于高速增长期,并发量还不高,保持默认的可重复读完全没问题,开发简单,心智负担也小。但一旦出现频繁的锁等待和死锁,不要急着骂数据库,先看看自己的 SQL 是不是范围过大、事务是不是开得太长、有没有办法把并发热点分散到不同的行上。很多时候,一条合适的索引、一个更精确的 WHERE 条件、一次及时的事务提交,比单纯切换隔离级别更能解决问题。
反过来,如果经过评估确实需要更低的锁冲突,把隔离级别切到读已提交也是成熟团队常见的做法。前提是你想清楚了业务对不可重复读的容忍度,并且主从复制用的是 ROW 格式。
实操层面再留一个建议:任何隔离级别相关的调整,都先在压测环境跑一轮对比,用数据说话。 直接拿生产环境做实验风险太大,一旦出现数据不一致或死锁风暴,排查的成本会远远超过你节省的那几毫秒。
MySQL 的事务隔离级别就是一个典型的"开始容易、深入难"的话题。这篇文章把从表面定义到底层机制到实战选型的内容都串了一遍,希望能帮你少走一些弯路。
