1. 问题根源:事务隔离级别与锁机制的内在矛盾
先说结论:脏读、不可重复读、幻读这些问题,本质上是数据库在并发控制和性能之间做的取舍导致的。MySQL 不会无缘无故出现这些异常,它们都是在多个事务同时操作同一批数据时,由于隔离级别设置不同、锁的粒度不同而产生的。
要理解这个问题,先得搞清楚一个基础概念:事务的 ACID 特性。其中I(Isolation,隔离性)要求多个事务并发执行时,彼此之间不能互相干扰。但“完全不干扰”在工程上是做不到的,因为那意味着所有事务必须串行执行,性能会低到没法用。所以 SQL 标准定义了四种事务隔离级别,从松到严分别是:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 |
| READ COMMITTED(读已提交) | 不会 | 可能 | 可能 |
| REPEATABLE READ(可重复读) | 不会 | 不会 | 可能(InnoDB下不会) |
| SERIALIZABLE(串行化) | 不会 | 不会 | 不会 |
1.1 脏读:读到别人还没提交的数据
脏读(Dirty Read)指的是一个事务读到了另一个事务尚未提交的数据。如果那个事务最终回滚了,你读到的就是“不存在”的数据,这就是“脏”的含义。
举个例子:事务A把某条记录的金额从100改成200,但还没提交。此时事务B去读这条记录,读到的是200。接着事务A因为某种原因回滚了,金额变回100。事务B刚才读到的200就是一个脏数据,基于这个数据做的任何业务判断都是错的。
脏读只在 READ UNCOMMITTED 级别下才会发生。这个级别就是“啥也不管”,读数据的时候不加任何锁,也不检查别人有没有加锁,直接读最新版本。实际业务中几乎不会用这个级别,除非你做的是一些对数据一致性完全无感的统计类查询。
1.2 不可重复读:同一条记录,两次读不一样
不可重复读(Non-Repeatable Read)说的是:在同一个事务内,同一条记录被读取两次,结果却不一样。原因是另一个事务在这期间提交了更新操作。
场景是这样的:事务A先读了一条记录,值是100。事务B这时把这条记录改成了200并提交。事务A再次读同一条记录,发现变成了200。对于事务A来说,它在自己事务内部看到的数据“不可重复”,这会导致业务逻辑混乱。
注意,不可重复读和脏读的区别在于:不可重复读读到的数据是已经提交的,只是两次读取的时机不同,数据被别的事务改了。这通常发生在 READ COMMITTED 级别下,因为这个级别每次读都生成一个快照,但下一次读会重新生成快照。
1.3 幻读:记录数量变化了
幻读(Phantom Read)是三种异常里最微妙的一个。它指的是:同一个事务内,用同一条查询条件执行两次查询,返回的记录数量不一样。第二次查询多出来几行,像“幻觉”一样。
具体场景:事务A执行SELECT * FROM orders WHERE amount > 100,返回了10条记录。事务B插入了一条 amount=200 的新订单并提交。事务A再次执行同样的查询,返回了11条记录。多出来的那条就是“幻影记录”。
幻读和不可重复读的区别很关键:不可重复读关注的是同一条记录的值变化,幻读关注的是记录集合的变化,重点在于有新的行插入进来。这个区分对后续理解锁机制非常重要。
2. 深入底层:MySQL InnoDB 是怎么避免这些问题的
MySQL 默认的存储引擎是 InnoDB,它的默认隔离级别是 REPEATABLE READ。在 InnoDB 的实现下,这个级别不仅能避免脏读和不可重复读,连理论上该级别可能出现的幻读也能解决掉。这里面的核心机制就是MVCC(多版本并发控制)和Next-Key Lock(临键锁)。
2.1 MVCC:快照读的版本链
MVCC 的全称是 Multi-Version Concurrency Control,中文叫多版本并发控制。它的核心思想是:读操作不阻塞写操作,写操作也不阻塞读操作,通过保存数据的多个历史版本来实现并发控制。
InnoDB 在每一行记录后面隐藏了两个字段(实际上还有更多,但这两个最关键):
DB_TRX_ID:最近一次修改这行记录的事务ID。DB_ROLL_PTR:回滚指针,指向这行记录在 undo log 中的上一个版本。
当一个事务要读数据时,它会根据当前事务的隔离级别生成一个ReadView(读视图)。这个 ReadView 里记录了生成时刻所有“活跃事务”的ID列表。判断一条记录是否对当前事务可见,规则是这样的:
- 如果记录的
DB_TRX_ID比 ReadView 里最小的活跃事务ID还小,说明这个版本在 ReadView 生成之前就已经提交了,可见。 - 如果
DB_TRX_ID比 ReadView 里最大的活跃事务ID还大,说明这个版本是在 ReadView 生成之后才产生的,不可见。 - 如果
DB_TRX_ID在活跃事务ID列表里,说明这个事务还没提交,不可见。 - 如果
DB_TRX_ID不在活跃事务ID列表里,但小于最大ID,说明这个事务在 ReadView 生成前已经提交了,可见。
在 REPEATABLE READ 级别下,ReadView 是事务第一次执行查询时生成,之后整个事务内都复用同一个 ReadView。这就保证了事务内所有查询看到的都是同一个数据快照,自然就避免了不可重复读。
而在 READ COMMITTED 级别下,每次查询都会重新生成 ReadView,所以能看到其他事务已经提交的最新数据,就会出现不可重复读。
用生活化的类比来解释:REPEATABLE READ 相当于你进入商场时拍了一张全景照片,之后无论别人怎么改价签、换商品,你看的都是自己手里这张照片;READ COMMITTED 则是每看一件商品就重新拍一张照,别人刚改过价格你立刻就能看到。
2.2 当前读与 Next-Key Lock
MVCC 解决的是快照读的问题,也就是普通的SELECT语句。但如果你执行的是当前读(Current Read),比如:
SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT
这些操作必须读取数据的最新版本,并且要对涉及的数据加锁。这时候 MVCC 就帮不上忙了,需要靠锁机制来避免并发问题。
InnoDB 的锁有三种主要类型:
- Record Lock:记录锁,锁住某一行记录。
- Gap Lock:间隙锁,锁住一个区间,但不包括记录本身。目的是防止其他事务在这个区间内插入新记录。
- Next-Key Lock:临键锁,是记录锁和间隙锁的组合,锁住一个左开右闭的区间,既包含记录本身,也包含记录前面的间隙。
幻读的产生就是因为:你查询一批记录时,另一个事务往结果集里插入了新行。为了堵住这个口子,InnoDB 在执行当前读时,不仅锁住已经存在的记录行,还会锁住这些记录之间的间隙,让别人没法往里面插数据。
比如你执行:
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;假设表中 amount 有90、110、130三档数据,InnoDB 除了给110和130这两行加记录锁,还会给 (110, 130) 这个区间以及 (130, +∞) 这个区间加间隙锁。另一个事务想插入 amount=120 的记录时,会因为命中 (110, 130) 的间隙锁而阻塞,直到当前事务提交。
REPEATABLE READ 级别下,间隙锁是默认开启的,所以幻读被天然遏制了。READ COMMITTED 级别下,间隙锁基本不起作用(只保留记录锁用于外键检查和唯一性检查),这就是为什么该级别下幻读仍然可能发生。
注意:InnoDB 的间隙锁有一个特例——在唯一索引上,如果查询条件是等值匹配且能定位到唯一记录,间隙锁会被优化掉,退化为记录锁。因为唯一索引已经保证了不会插入重复值,不需要锁间隙。
3. 实操验证:用具体SQL复现三类问题
光讲理论不够,必须亲手复现一遍才能建立直观感受。下面我给出完整的验证步骤,你可以在自己的 MySQL 环境里跑一遍。测试前先确认一下版本和隔离级别:
SELECT VERSION(); SHOW VARIABLES LIKE 'transaction_isolation';我用的是 MySQL 8.0,默认隔离级别就是 REPEATABLE READ。测试用的表结构很简单:
CREATE TABLE `account` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) DEFAULT NULL, `balance` int DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB; INSERT INTO account (name, balance) VALUES ('Alice', 100), ('Bob', 200), ('Charlie', 300);3.1 复现脏读:需要把隔离级别降到最低
在 REPEATABLE READ 下是复现不了脏读的,必须把隔离级别改成 READ UNCOMMITTED。两个会话一起操作:
事务A(会话1):
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; UPDATE account SET balance = 150 WHERE id = 1;此时事务A还没提交,注意观察SHOW PROCESSLIST能看到这个事务处于未提交状态。
事务B(会话2):
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id = 1;执行结果:balance 显示为 150。而实际上事务A还没提交,如果事务A现在执行ROLLBACK,这条记录的 balance 会回到 100。事务B刚才读到的 150 就是脏数据。
验证结论:脏读只有在SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED时才会出现。MySQL 8.0 默认隔离级别下不存在这个问题。
3.2 复现不可重复读:挂在 READ COMMITTED 级别下
事务A(会话1):
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id = 1;此时返回 100。
事务B(会话2):
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; UPDATE account SET balance = 500 WHERE id = 1; COMMIT;事务B把 Alice 的余额改成了500并提交。
事务A(会话1)再次查询:
SELECT balance FROM account WHERE id = 1;此时返回 500。同一个事务里,两次读取同一条记录,值从100变成了500,不可重复读出现了。
如果事务A把隔离级别改成 REPEATABLE READ,同样操作再跑一遍,第二次查询依然返回100,因为 ReadView 复用了第一次查询时的快照。
3.3 复现幻读:注意InnoDB的特殊性
如果你在纯 InnoDB 且 REPEATABLE READ 级别下,通过常规SELECT语句复现幻读是复现不出来的,因为快照读机制已经挡住了。但这不代表幻读这个概念不存在,它在你切换到 READ COMMITTED 级别时就会出现。
事务A(会话1):
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT COUNT(*) FROM account WHERE balance > 100;此时返回2(Bob和Charlie)。
事务B(会话2):
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; INSERT INTO account (name, balance) VALUES ('David', 400); COMMIT;事务A(会话1)再次查询:
SELECT COUNT(*) FROM account WHERE balance > 100;此时返回3,多了一个David。这就是幻读。
在 REPEATABLE READ 级别下,如果你想通过SELECT看到这种数量变化,需要把查询改成当前读,比如加FOR UPDATE。但更常见的情况是:当你执行UPDATE或SELECT ... FOR UPDATE时,InnoDB 会用间隙锁阻止新数据插入,你在事务提交前看到的记录集合始终保持一致。
3.4 一个容易踩的坑:先快照读再当前读
这里有个特别容易让人困惑的细节:在 REPEATABLE READ 级别下,如果你在事务里先做了快照读,然后再做当前读,是有可能看到其他事务刚提交的新数据的。因为当前读不走 ReadView,它读的是最新版本并加锁。
顺序是这样:
- 事务A执行
SELECT * FROM account WHERE balance > 100,用快照读生成 ReadView,看到2条记录。 - 事务B插入一条新记录并提交。
- 事务A执行
UPDATE account SET balance = balance + 10 WHERE balance > 100,这是当前读,能看到事务B插入的新记录,并把它也更新了。
这个现象在严格意义上也算一种“幻读”,但因为查询方式从快照读切到了当前读,很多人会忽略它。如果你恰好是数据库开发人员,而且对隔离级别有很高要求,一定要注意这种“切换读模式”带来的不一致问题。实际业务中如果要避免,思路是:在事务一开始就用SELECT ... FOR UPDATE锁定范围,或者把隔离级别提升到 SERIALIZABLE。
4. 如何避免:隔离级别选型与锁策略实战
4.1 隔离级别怎么选:业务场景决定一切
很多初学者一上来就问:哪个隔离级别最好?答案是:没有绝对的好,只有适不适合。隔离级别越高,并发性能越差;隔离级别越低,出现异常数据的风险越高。关键看业务对数据一致性的容忍度。
下面是不同业务场景的选型参考:
| 隔离级别 | 适用场景 | 理由 |
|---|---|---|
| READ UNCOMMITTED | 几乎不用 | 除非做非关键性的统计、报表,能容忍读到未提交数据 |
| READ COMMITTED | 大多数互联网业务、分布式系统 | 每次读都能看到最新已提交数据,Oracle默认此级别,适合分库分表场景 |
| REPEATABLE READ | MySQL 默认,金融类、订单类业务 | 事务内数据一致,保证财务对账等场景的准确性 |
| SERIALIZABLE | 强一致性要求、金额极高敏感操作 | 完全串行,基本没有并发,性能代价最大 |
注意一个实际工程中的选择问题:如果你要做分库分表,或者使用一些分布式事务中间件(比如 Seata),很多中间件默认假设数据库隔离级别是 READ COMMITTED。因为 REPEATABLE READ 下的间隙锁在分布式环境下会成为死锁和性能瓶颈的高发点。这属于架构层面的取舍。
4.2 从应用层避免并发问题的三个套路
隔离级别是数据库层面的防御。但工程实践中,光靠数据库是不够的,还需要在应用层做好并发控制。有三个常用套路:
套路一:悲观锁
在更新前先SELECT ... FOR UPDATE锁定记录,直到事务结束才释放。适合并发冲突频繁的场景。代价是阻塞时间长,容易死锁。
-- 事务A START TRANSACTION; SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 做一些业务判断 UPDATE account SET balance = balance - 50 WHERE id = 1; COMMIT;在此期间事务B执行SELECT * FROM account WHERE id = 1 FOR UPDATE会被阻塞,必须等事务A提交。
套路二:乐观锁
在表里加一个版本号字段,更新时比对版本号,版本号不匹配则更新失败,需要重试。适合并发冲突少的场景。MVCC 本身就是这种思路的体现。
-- 事务A UPDATE account SET balance = balance - 50, version = version + 1 WHERE id = 1 AND version = 5;如果影响行数为0,说明 version 已经变了,需要重读数据、重新计算。
套路三:唯一约束兜底
针对并发插入导致的幻读问题,可以在业务表上建唯一索引,让数据库在底层拒绝重复插入。这比应用层判断“是否存在”要可靠得多。
4.3 间隙锁的几个实用细节
关于间隙锁,有几个实操经验值得记录下来:
- 间隙锁只在 REPEATABLE READ 级别生效。如果你在 READ COMMITTED 级别下执行
SELECT ... FOR UPDATE,InnoDB 不会锁间隙,而是靠其他机制避免并发问题。 - 间隙锁锁的是区间,不是单行。比如
WHERE id > 5 AND id < 10,锁住的是 (5, 10) 这个范围,其他事务往这个范围插入 id=7 时会被阻塞。 - 死锁高发区。因为间隙锁和记录锁是分开的,两个事务可能互相持有对方需要的锁。比如事务A锁了 id=5 的记录、准备插入 id=6 的数据,事务B锁了 id=6 的记录、准备插入 id=5 的数据,互相等待就会死锁。MySQL 会自动检测死锁并回滚其中一个事务,但应用需要做好重试。
- 大范围更新时尽量减少锁范围。SQL 的 WHERE 条件越精确,锁的范围越小,并发度越高。如果一条 UPDATE 的 WHERE 条件没走索引,InnoDB 会退化成锁全表(实际上是对所有扫描到的记录加锁),这是非常常见的性能事故。
5. 常见问题速查与排查思路
实际操作中遇到这些问题,别急着改配置,按下面这个排查顺序走一遍。
5.1 排查脏读
出现脏读,先检查隔离级别:
SHOW VARIABLES LIKE 'transaction_isolation';如果显示READ-UNCOMMITTED,那就是根因。修改方式:
SET GLOBAL transaction_isolation = 'REPEATABLE READ'; SET SESSION transaction_isolation = 'REPEATABLE READ';注意:SET GLOBAL只影响新连接,已经存在的连接不受影响。而且 MySQL 重启后全局配置会被重置,如果要持久化,需要修改配置文件my.cnf或my.ini,在[mysqld]段下加transaction-isolation = REPEATABLE READ。
5.2 排查不可重复读
如果应用里发生了不可重复读,排查思路:
- 确认隔离级别。如果是 READ COMMITTED,这属于该级别的正常行为,不是bug。
- 确认是否同一个事务。很多线上“不可重复读”问题其实是应用代码里每次查询都自动提交了事务,导致前后两次查询根本不在同一个事务里。
- 确认是否有隐式提交。
DDL语句(CREATE、ALTER、DROP)、SET操作、START TRANSACTION都可能触发隐式提交,导致事务提前结束。
5.3 排查幻读
在 REPEATABLE READ 下还遇到“幻读”类问题,优先考虑:
- 是否切换了读模式。前面提到的“先快照读、再当前读”是最典型的隐性触发场景。
- 是否使用了非 InnoDB 引擎。比如 MyISAM 不支持事务和行锁,隔离级别形同虚设。
- 是否使用了
SELECT ... LOCK IN SHARE MODE但没加索引。如果查询条件没有索引,锁会覆盖所有扫描到的记录,效果和锁表差不多,反而更容易阻塞。
5.4 隔离级别修改的版本坑
MySQL 5.7 及之前,参数名是tx_isolation;MySQL 8.0 开始,参数名改成了transaction_isolation。很多老教程还写着SET tx_isolation = '...',在 8.0 上执行不会生效,会报Unknown system variable 'tx_isolation'。这是版本升级踩坑的高频问题。
检查当前会话的隔离级别:
SELECT @@transaction_isolation; SELECT @@global.transaction_isolation;5.5 一条重要的实操心得
根据我个人经验,大部分“脏读、幻读、不可重复读”问题,不是数据库配置出了问题,而是应用的事务边界设置不合理。最常见的情况有三种:
- 事务开启得太早,提交得又太晚,导致锁持有时长过大,并发度下降,死锁概率上升。
- 事务里混入了远程调用(RPC)、外部接口请求,一个事务里还去调别人的接口,这种事务基本注定会出问题。
- 读取和更新不在同一个事务里,先查后改的场景没有用
FOR UPDATE锁住,导致更新时数据已经变了。
正确的做法是:事务要短、要精准。只把必要的查询和更新包含在事务里,任何非数据库操作(发消息、调接口、文件读写)都要放到事务外面。这一点在排查问题的时候往往比调隔离级别更有效。
6. 写在最后:别只背概念,要上手验证
MySQL 的并发控制是一个理论性和实践性都很强的主题。面试题里喜欢问脏读、幻读、不可重复读的区别,但真正到了生产环境,你遇到的问题是各种因素交织在一起的:隔离级别设置不合适、索引没建好、事务边界过长、锁等待超时、死锁回滚……每一个都能让线上服务抖三抖。
我的建议是:把文中的实验在自己本地跑一遍,亲眼看到三种异常的发生,再亲手用隔离级别和锁机制把它们挡住。只有亲自动手验证过,你才会真正理解 MVCC 和间隙锁的设计精妙之处。
最后再分享一个小技巧:排查锁相关问题的时候,打开SHOW ENGINE INNODB STATUS\G看看LATEST DETECTED DEADLOCK部分,里面会详细记录死锁的 SQL 和涉及的索引,这是定位问题最快的路径。希望这篇文章能帮你把这块知识真正啃下来。