MySQL 的事务隔离级别,光面试题里就够聊一小时的:默认级别是什么、RC 和 RR 的区别、MVCC 怎么实现、幻读到底解决没有。但真正到了生产环境,很多人其实只在两个时候会想起它——一个是并发发券时出现超发,另一个是线上突然死锁报警。我在排查这些问题的过程中,把隔离级别相关的机制完整梳理了一遍,包括 InnoDB 底层的版本链、ReadView 生成规则、快照读和当前读的差异、间隙锁的死锁副作用,以及主从复制里 binlog 格式和隔离级别之间的历史纠葛。这篇就把这部分内容一次性讲透,适合后端开发、DBA,也适合准备 MySQL 面试的读者。
1. 并发事务的混乱现场:隔离级别到底在防什么
先说一个我实际遇到过的问题。一个多线程扣减余额的定时任务,代码里明明做了事务控制,结果线上还是出现了余额变负数的情况。后来排查发现,问题出在两个事务并发修改同一行数据时,互相没有隔离好——一个事务读到的是另一个事务尚未提交的中间结果。这就是事务隔离性没守住导致的。
1.1 先看一个真实场景
假设账户表里有一个字段 balance = 100,两个事务同时执行:
sql复制-- 事务A
START TRANSACTION;
SELECT balance FROM account WHERE id = 1; -- 读到 100
UPDATE account SET balance = balance - 50 WHERE id = 1;
-- 事务B(并发执行)
START TRANSACTION;
UPDATE account SET balance = balance - 30 WHERE id = 1;
COMMIT;
事务 B 把这行改成了 70 并提交。此时事务 A 还没提交,如果 A 在 UPDATE 之后又执行一次 SELECT balance FROM account WHERE id = 1,它看到的是 50 还是 70?答案取决于隔离级别。在低隔离级别下,A 可能读到 B 刚提交的 70,导致 A 后面基于“余额只有 50”做的判断全部失效。更严重的情况是,A 在 B 提交之前就读到了 B 更新的中间状态,然后基于脏数据继续操作。
这类问题在电商、支付、库存系统里尤其常见。促销场景下多个订单同时扣减库存,如果隔离级别设置不当,就会出现超卖或负库存。隔离级别本质上是事务之间的一道“看不见的墙”,决定了一个事务能多大程度地看到其他事务未提交或刚提交的数据。
1.2 隔离性与三种并发异常
要理解隔离级别,先理解它要解决的三个经典异常。这三个异常分别对应数据可见性的不同错乱程度:
- 脏读(Dirty Read):事务 A 读取了事务 B 尚未提交的数据,之后 B 回滚了,A 读到的就是根本不存在的数据。类比一下就是你看同事在共享文档里打字,但他还没保存,等他说“刚才那段删掉重写”,你已经被错误内容带偏了。
- 不可重复读(Non-Repeatable Read):事务 A 在两次查询之间,事务 B 修改了同一行并提交,A 两次读到同一行的值不一样。类比是你问同事某文件报价,第一次他说 100 块,过五分钟再问变成 90 块,同一个事务内结果不稳定。
- 幻读(Phantom Read):事务 A 按条件查出一批数据,事务 B 插入了一条满足条件的新数据并提交,A 再次按同样条件查询时,结果集里多了一行。注意,幻读和不可重复读的区别在于:不可重复读针对的是同一行的值被改,幻读针对的是结果集的行数变化。
这三种异常的严重程度是递减的:脏读最严重,读到了未提交的假数据;不可重复读其次,读到了同一行数据的不同版本;幻读相对更隐蔽,涉及的是数据集合的变化。隔离级别的设计,就是为了在这三种异常和并发性能之间做取舍。
1.3 一个容易混淆的点:隔离和锁的关系
很多初学者会把隔离级别和锁混为一谈,觉得隔离级别越高锁越多。这个理解方向对,但不够准确。隔离级别是一个“策略层”的概念,而锁和 MVCC 是“实现层”的机制。InnoDB 用两种手段去实现隔离级别:一是加锁,通过锁的互斥来阻止并发访问;二是多版本并发控制,通过数据的多个版本来让读写互不阻塞。
后面讲 MVCC 的时候会看到,MySQL 之所以能在默认级别下保持不错的并发能力,正是因为大部分读取操作走的是 MVCC,只有写操作才真正加锁。隔离级别决定了什么时候该用 MVCC 的快照,什么时候该升级到加锁读。如果把隔离级别理解为“规则”,那锁和 MVCC 就是“执行规则的工具”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四个隔离级别逐个拆解
SQL 标准定义了四种隔离级别,MySQL 的 InnoDB 都支持,并且每个级别的表现和标准定义略有差异。我对每一个级别最深刻的感受是:级别越低,写并发越宽松,但数据可见性越混乱;级别越高越安全,但并发能力越受限制。
2.1 READ UNCOMMITTED:裸奔级别
读未提交是最低级别的隔离,一个事务可以读到另一个事务尚未提交的数据。也就是说,事务 A 修改了一行但没提交,事务 B 立刻就能看到修改后的值。如果事务 A 最后回滚了,事务 B 基于这个值做的一切操作就都建立在脏数据上了。
这个级别在实际生产环境中几乎没人用,因为它把“脏读”直接放行了。不过它有个好处:读操作完全不加锁,也不用生成 MVCC 快照,性能确实是最高的。InnoDB 里虽然提供了这个级别,但官方文档也基本是劝退态度。我自己只有在做纯日志表、对数据一致性零要求的场景下才会考虑它,实际上连这都没用过。
2.2 READ COMMITTED:每次读都拿最新
读已提交解决的是脏读问题。一个事务只能读到自己已经提交的数据和其他事务已经提交的数据,未提交的数据永远不可见。这个级别在 Oracle 和 PostgreSQL 里是默认级别。
RC 级别有一个很典型的特点:同一个事务里,两次 SELECT 拿到的结果可能不一样。因为不需要读已提交,所以事务 B 提交了新的数据后,事务 A 下一次查询能立刻看到。这就产生了“不可重复读”。很多从 Oracle 转 MySQL 的开发会踩这个坑,默认以为每次读到的数据在事务内部应该是一致的,结果发现不是。
RC 级别下,InnoDB 每次执行普通 SELECT 都会生成一个新的 ReadView,所以能看到最新已提交的数据。后面的 MVCC 部分会详细讲这个机制。
2.3 REPEATABLE READ:一把快照吃到底
可重复读是 MySQL InnoDB 的默认隔离级别。它的核心特性是:在同一个事务里,第一次执行普通 SELECT 时生成一份快照,之后的所有普通查询都基于这份快照来读,不受其他事务提交的影响。也就是说,事务 A 启动后第一次查余额是 100,之后事务 B 把余额改成 70 并提交,事务 A 再查还是 100。
这解决了不可重复读的问题。对于幻读,InnoDB 在 RR 级别下还额外做了处理——通过 next-key lock 锁住记录和间隙,让当前读(加锁的读)也无法插入新数据。这个机制让它比 SQL 标准里的 RR 级别更强,基本上可以说 InnoDB 的 RR 解决了标准定义下的幻读问题。但这里有个细节:如果事务先执行普通快照读,再执行当前读,两者之间还是可能看到不一致的结果集,后面细讲。
2.4 SERIALIZABLE:用并发换一致性
可串行化是最高的隔离级别。它把所有事务的执行效果变成“串行”的:一个事务执行完,另一个事务才能执行。在其实现方式上,InnoDB 把所有普通 SELECT 都隐式地变成了加锁读,相当于每个读操作都加了共享锁,写操作则要等锁。
这个级别能彻底避免脏读、不可重复读、幻读,但代价是并发性能断崖式下跌。读与读之间都要互斥,业务里只要读多,基本上等于把并发改成串行。我在生产环境几乎没见过有人用这个级别,除非是报表类的低并发场景,或者某些极强一致性的对账流程。需要注意,可串行化并不是“不会死锁”,它只是让并发冲突变得更频繁,死锁反而可能更容易出现。
2.5 四种隔离级别一表对比
把四个级别的核心特性放在一起看,对比会更直观:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 读操作实现方式 | 适用场景 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 直接读最新版本,不生成快照 | 几乎不用 |
| READ COMMITTED | 不会 | 可能 | 可能 | 每次快照读重新生成 ReadView | 需要及时看到已提交数据的场景 |
| REPEATABLE READ | 不会 | 不会 | InnoDB 基本解决 | 首次快照读固定 ReadView | MySQL 默认,大多数业务 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 普通查询也加锁,读读互斥 | 低并发、强一致场景 |
这个表格里有一列需要单独强调:RR 级别下“幻读”写的是“InnoDB 基本解决”。因为标准里 RR 级别理论上是不防幻读的,InnoDB 是靠 MVCC 和 next-key lock 这两套机制把它压下去的。这个机制理解到位了,后面的很多问题都能串联起来。
3. InnoDB的MVCC实现:隔离级别背后的功臣
MySQL 的默认级别是 RR,却能保持高并发读性能,靠的就是 MVCC。MVCC 的核心思路是:不通过加锁阻挡读操作,而是让读操作去读一个“历史版本”,写操作仍然基于当前版本进行修改。这样读和写之间就不互相阻塞了。
3.1 版本链:一行数据的多个历史版本
InnoDB 会给每行记录增加几个隐藏字段,其中两个最关键:DB_TRX_ID(最近一次修改这条记录的事务 ID)和 DB_ROLL_PTR(回滚指针)。每次有事务修改这行数据,InnoDB 不会直接覆盖旧值,而是把旧值放到 undo log 里,新值成为最新版本,并通过回滚指针串起一条版本链。
可以这样理解:一行数据就像一份文档的修订历史,每次修改都生成一个新版本,并指向上一版。事务读取时,通过 DB_ROLL_PTR 顺着版本链往回找,直到找到这个事务“应该看到”的版本。这种机制让快照读不需要加锁,只需要沿着版本链找到合适的版本即可。
用文本示意一下版本链的结构:
text复制最新版本 (trx_id=20, 已提交)
↓ 回滚指针
历史版本 (trx_id=19, 已提交)
↓ 回滚指针
更早版本 (trx_id=18, 未提交或已回滚)
3.2 ReadView:事务怎么看数据
有了版本链,还要有一套规则来决定某个事务能看到哪些版本,这就是 ReadView 的作用。ReadView 是快照读生成的一个视图,里面记录了生成时的一些关键信息,包括活跃事务 ID 列表 m_ids、这个列表里的最小事务 ID min_trx_id、下一个将分配的事务 ID max_trx_id,以及当前事务自己的 ID creator_trx_id。
判断一条记录版本是否可见的规则可以概括为:
- 如果版本的事务 ID 等于
creator_trx_id,说明是自己改的,可见。 - 如果版本的事务 ID 小于
min_trx_id,说明这条记录在 ReadView 生成之前就已经提交了,可见。 - 如果版本的事务 ID 大于等于
max_trx_id,说明这条记录是 ReadView 生成之后才启动的事务修改的,不可见。 - 如果
min_trx_id <= trx_id < max_trx_id,看这个 ID 是否在m_ids活跃事务列表里:在列表中说明还没提交,不可见;不在列表中说明已经提交,可见。
看起来规则不少,但本质就一句话:快照生成那一刻起,只有已经提交的和自己修改的版本才对当前事务可见。顺着版本链从新到旧逐个判断,找到第一个可见版本就返回。
3.3 RC和RR的差异:ReadView生成时机
RC 和 RR 都使用 ReadView,区别在于生成时机。
- READ COMMITTED:每次执行普通
SELECT都生成一个新的 ReadView。所以事务 B 一旦提交,事务 A 下一次查询就能看到 B 的修改,因为新的 ReadView 里已经没有 B 的事务 ID 了。 - REPEATABLE READ:只在事务里第一次执行普通
SELECT时生成 ReadView,之后一直复用。所以即使事务 B 提交了,事务 A 后续的查询依然使用第一次生成的 ReadView,看不到 B 的修改。
这个区别是 RC 和 RR 最核心的分水岭。很多 MySQL 优化文章里提到的“RR 级别下长事务不释放旧版本”问题,根源也在这里:ReadView 迟迟不更新,undo log 里对应版本链的清理就会受限,事务越长,历史版本积累越多,最终导致 undo log 膨胀和性能下降。
3.4 快照读与当前读
MVCC 说的是普通 SELECT 走快照读,不需要加锁。但 UPDATE、DELETE、INSERT,以及 SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE 这类操作,都是当前读——它们必须读取并锁定当前最新版本的数据。
举个例子,RR 级别下:
sql复制-- 事务A
START TRANSACTION;
SELECT * FROM account WHERE balance > 50; -- 快照读,结果是固定的历史快照
-- 事务B
START TRANSACTION;
INSERT INTO account VALUES (100, 10);
COMMIT;
-- 事务A 继续执行
UPDATE account SET balance = balance - 1 WHERE balance > 50; -- 当前读,会看到事务B插入的新行
这个例子很典型:事务 A 的 SELECT 看不到 B 插入的记录,但 UPDATE 却能看到。因为 UPDATE 是当前读,走的是最新数据版本。这种快照读和当前读混合的场景,是生产环境里最容易让人困惑的地方之一。
4. 幻读的真相与next-key lock
幻读这个概念在 MySQL 面试里被问得最多,也最容易答偏。很多人直接背结论“InnoDB 的 RR 解决了幻读”,但深入问一层“怎么解决的”“还有没有残留问题”,就答不上来了。我建议把这个问题拆成两条线来看:快照读和当前读。
4.1 快照读为什么没有幻读
快照读本身就是基于 ReadView 的固定快照,不管其他事务插入了多少新数据,ReadView 里根本没有这些新事务的版本,所以按同样条件反复查询,结果集不会变化。从这个角度说,幻读在快照读里天然不存在,这不是 RR 级别的专利,RC 级别下如果两次查询之间没有新数据提交,同样查不到。但 RC 因为每次都重新生成 ReadView,所以这种“稳定”保持不了太长时间。
4.2 当前读为什么需要next-key lock
当前读要操作的是最新数据,如果只锁住已有的记录行,另一个事务往条件范围内插入一条新记录,当前事务重新执行 SELECT ... FOR UPDATE 或 UPDATE 时,就会发现结果集里多出了一行,这就形成了标准意义上的幻读。
InnoDB 的解决办法是引入 next-key lock。这个锁是记录锁和间隙锁的组合:记录锁锁住已经存在的行,间隙锁锁住记录与记录之间的“空隙”,让其他事务无法在范围内插入新行。这样一来,当前读不仅锁住了现有记录,还锁住了“未来可能插入新记录的间隙”,从源头上堵死了幻读。
间隙锁起作用的一个场景是范围条件。执行 SELECT * FROM user WHERE id > 100 FOR UPDATE 时,InnoDB 会锁住 id 大于 100 的所有已存在记录,同时锁住最后一条记录之后的间隙,另一个事务想插入 id 为 101 的记录,必须等待这个锁释放。这就是为什么 RR 级别下这类范围更新很容易出现锁等待。
4.3 RR下幻读还存在吗?——存在,这两个场景要小心
严格来说,InnoDB 的 RR 级别确实没有完全消灭幻读。最常见的一个残留场景就是前面 3.4 节提到的:先快照读,再当前读,两者之间的结果集不一致。更完整的复现路径是:
- 事务 A 执行
SELECT * FROM t WHERE id > 10,结果为空(快照读)。 - 事务 B 插入一条 id=11 的记录并提交。
- 事务 A 执行
SELECT * FROM t WHERE id > 10 FOR UPDATE,能查到这条新记录的锁。 - 或者事务 A 执行
UPDATE t SET ... WHERE id > 10,会更新到这条事务 B 插入的记录。
这种“读取时不在集里,更新时却被包含”的现象,本质上就是快照读与当前读在 RR 级别下各自为政造成的。如果是完全基于快照读的隔离一致性,是做不到的;如果想彻底避免这类问题,要么把隔离级别升到 SERIALIZABLE,要么调整代码逻辑,让查询和更新走同一种读模式。
4.4 间隙锁的代价:死锁
间隙锁解决幻读的能力很强,但它带来的副作用也相当明显:锁的范围比实际需要大得多,而且很容易造成死锁。两个事务分别锁住一段互有交集的间隙,然后各自再往对方锁定的间隙里插入数据,就会互相等待。
举个例子:
sql复制-- 事务A
SELECT * FROM user WHERE id > 10 FOR UPDATE;
-- 事务B
SELECT * FROM user WHERE id < 20 FOR UPDATE;
如果用户表里目前没有 id 在 10 到 20 之间的记录,A 会锁住 10 之后到某个值的间隙,B 会锁住某个值到 20 之前的间隙,这两个间隙可能有重叠。接着 A 想插入 id=15 的记录,B 也想插入 id=15 的记录,两个事务都会等待对方释放间隙锁,死锁就会发生。这类死锁在 RR 级别下非常经典,解决思路通常是把隔离级别调整为 RC,或者仔细梳理索引设计,让锁的范围落在更小的区间内。
5. 生产环境选型与踩坑经验
前面理论部分讲完了,这部分结合我自己的运维和开发经验,聊聊实际项目中怎么选隔离级别、怎么排查相关问题。
5.1 默认RR,但别滥用
如果你的业务没有特殊需求,直接用默认的 RR 是稳妥的。大部分后端框架拿到数据库连接后都没有主动修改隔离级别,所以生产环境大概率就是 RR。这个级别下的 MVCC 能让普通读性能很好,业务代码也基本感知不到。
但有两种情况我会主动考虑改成 RC:
一是对“读到最新已提交数据”有强需求的场景。比如某些实时统计页面,希望每次刷新都能看到最新的数据,RC 会更符合直觉。RR 下因为快照固定,长事务里可能一直看到旧数据。
二是锁冲突和死锁频繁、且业务本身允许读取稍旧数据的场景。RC 没有间隙锁,锁范围小很多,死锁概率明显下降,并发写入能力也会提升。改 RC 之前先做一次死锁日志分析,确认死锁是不是间隙锁引起的,避免盲目调整。
5.2 隔离级别、binlog格式与主从复制
这是很多人忽视的一个知识点,而且和主从复制数据一致性直接相关。MySQL 的 binlog 有三种格式:STATEMENT(记录 SQL 语句)、ROW(记录数据变更前后的值)、MIXED(根据情况自动选择)。
在早期 MySQL 版本中,binlog 默认是 STATEMENT 格式。这种格式下,如果主库使用 RC 隔离级别,同一个 SQL 在主库执行时看到的行集和从库重放时看到的行集可能不同,导致主从数据不一致。比如一条 UPDATE ... WHERE 条件涉及的范围数据,在主库执行时某些数据还没被别的事务提交,到了从库重放时那些事务已经提交,影响的行数就不同了。
所以当时的解决方案是:如果要用 STATEMENT 格式的 binlog,主库隔离级别必须设置为 RR,才能保证 SQL 重放的可重复性。这也是 InnoDB 把默认隔离级别设计成 RR 的原因之一,带有很强的历史背景。到了 MySQL 8.0,binlog 默认已经是 ROW 格式,主从一致性不再依赖隔离级别,但很多老系统仍然沿用 RR,这套逻辑还是需要知道的,面试里也常考。
5.3 和Spring事务一起用时的注意点
Spring 的 @Transactional 注解支持通过 isolation 属性指定隔离级别:
java复制@Transactional(isolation = Isolation.REPEATABLE_READ)
public void updateBalance(Long userId, BigDecimal amount) {
// 业务代码
}
这里有个容易踩的坑:Isolation.DEFAULT 表示使用数据库默认级别,如果你的项目部署环境比较杂,不同库可能默认级别不一致。生产环境里最好在数据库连接层面或者事务管理器层面统一一下,避免同样的代码在不同的库上行为不同。
另外,Spring 的 @Transactional 还有一个和隔离级别经常混淆的概念:传播行为。传播行为决定的是事务边界怎么划分,比如一个方法是在现有事务里运行还是新开一个事务;隔离级别决定的是事务之间的数据可见性。两者是不同维度的问题,但实际配置时往往要一起考虑。如果一个事务里嵌套了多个子查询,各自使用不同的传播行为,隔离级别的作用范围也会变得难以捉摸,排查问题时一定先把事务边界理清楚。
实际项目中还有一种常见错误:事务方法内部调用同类里的另一个 @Transactional 方法,由于 Spring 代理机制,内层注解不生效,导致隔离级别和传播行为都没按预期工作。这个问题表面上看是事务失效,但排查起来很容易怀疑到隔离级别上,建议遇到“隔离级别好像没生效”的诡异情况时,先排查走的是不是代理方法。
5.4 线上锁等待排查:一条命令看清现状
如果你在线上遇到锁等待或者死锁,第一步永远是拿到现场信息。InnoDB 提供了两个很实用的工具:
sql复制-- 查看最近的死锁日志
SHOW ENGINE INNODB STATUS\G
-- 查看当前事务和锁信息(MySQL 8.0 推荐)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
SHOW ENGINE INNODB STATUS 里有“LATEST DETECTED DEADLOCK”段,会打印出两个事务执行过的 SQL、持有的锁、等待的锁,非常直观。以前排查死锁,我基本都是靠这段日志定位到具体是哪条 UPDATE 语句的间隙锁冲突。
data_locks 表更细粒度,可以看到每个事务持有哪些表锁、行锁、间隙锁。如果发现一个事务持有了大范围的 next-key lock,而另一个事务又想去同一个区间插入数据,基本就能确定是 RR 级别下的间隙锁问题。
另外提醒一句:长事务是锁冲突和 MVCC 版本堆积的温床。我在监控平台里都会加一个“事务执行超过 5 秒”的告警,因为很多锁等待的根因不是一个锁本身,而是某个事务长时间不提交,把锁一直攥在手里。排查时先看有没有长事务,再分析锁,效率会高很多。
我在实际项目里最大的体会是:隔离级别不是配置完就一劳永逸的,它和索引设计、事务长度、binlog 格式都有着微妙的联动。改一个隔离级别,可能影响锁范围、主从一致性甚至死锁频率,动手之前最好把当前事务的 SQL 和锁情况先摸清楚。如果你正准备优化线上事务,建议先把 information_schema.innodb_trx 打开,看看活跃事务的执行时长,再决定要不要动隔离级别,这比我当年直接拍脑袋改配置要稳妥得多。
