1. 并发问题的根源:你真的搞懂事务了吗
脏读、不可重复读、幻读这哥仨,是数据库面试里雷打不动的“老三样”。说实话,我在带团队面试候选人时,十个人里有八个能说得出这三个名词,但你再追问一句“你们库默认隔离级别是什么?它到底有没有解决幻读?”,立马就哑火一半。今天我不打算背教科书,就从实际开发中遇到的场景出发,把这几个问题掰开揉碎了讲清楚,顺便把 MySQL InnoDB 底层的那些实现机制也一并说了。
要说清楚并发问题,得先明确一个前提:这三个问题全部发生在多个事务并行操作同一批数据的场景里。单事务跑数据,天塌下来也不会出这些乱子。所以先花点时间把事务的底层模型讲透,后面理解隔离级别会顺很多。
1.1 事务的原子性和持久性,是并发问题的“地基”
事务有 ACID 四个特性:原子性、一致性、隔离性、持久性。其中和本文最相关的就是隔离性,但要说清楚隔离性为什么难做,得先看另外两个——原子性和持久性。
原子性保证一个事务要么全做,要么全不做。实现原子性的核心机制是undo log(回滚日志)。事务执行过程中,每修改一条记录,InnoDB 就会生成一条对应的反向操作记录:UPDATE 就记一条反向 UPDATE,DELETE 就记一条 INSERT。事务一旦回滚,就直接执行这些反向日志把数据还原回去。
持久性靠的是redo log(重做日志)。InnoDB 采用的是 WAL(Write-Ahead Logging)机制,事务提交时数据页不一定要立刻刷盘,但 redo log 必须先写盘。这样即使数据库突然宕机,重启后也能从 redo log 把已经提交但还没落盘的数据恢复出来。
有意思的事情来了。undo log 记录的是“历史版本”,redo log 记录的是“最新操作”。这两个日志接下来会和隔离级别的实现产生直接关系——MVCC(多版本并发控制)就是建立在 undo log 版本链之上的。
1.2 锁和版本链:解决并发的两条路线
数据库解决并发冲突,思路无非两条:悲观锁和乐观锁,对应到底层就是锁机制和MVCC。
锁机制好理解,就是“我干活的时候,你就在门口等着”。读锁(共享锁)和写锁(排他锁)互相排斥,写写之间、读写之间都不能并行,数据安全,但是并发性能直线下降。
MVCC 的思路完全不同:读的人去读历史版本,写的人写最新版本,两边各干各的互不干扰。每个事务在启动时会生成一个 Read View(读视图),里面记录了当前所有活跃事务的 ID 列表。当读取某条记录时,会根据 undo log 版本链上每个版本的事务 ID 与 Read View 的比较结果,决定这个版本对当前事务是否“可见”。
脏读、不可重复读、幻读这三个问题的产生,本质就是在并发场景下,“读操作该看到哪个版本的数据”这个规则没有被定义清楚。而隔离级别,就是在定义这个规则。
2. 三大并发问题逐个击破
下面进入正题。我不会只给定义,会用同一个业务场景把三个问题串起来演示,这样对比效果最直观。
假设有一张用户余额表,结构很简单:
CREATE TABLE `account` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int DEFAULT NULL, `balance` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB;初始数据就一条:user_id = 1,balance = 1000.00。
2.1 脏读:读到别人没提交的数据
脏读的定义是:事务 A 读到了事务 B 未提交的数据。注意“未提交”这三个字是重点。
演示过程如下:
| 时间线 | 事务 A | 事务 B |
|---|---|---|
| T1 | 启动事务,读 balance,得到 1000 | |
| T2 | 启动事务,UPDATE balance SET balance = 500 | |
| T3 | 再次读 balance,得到 500(脏数据!) | |
| T4 | ROLLBACK,余额恢复为 1000 |
在脏读的场景下,事务 B 把余额从 1000 改成了 500,但这一步操作还没有提交。此时事务 A 去读,如果读到的是 500,就产生了脏读。因为 B 随时可能回滚,一旦回滚,这 500 就是一条根本不存在的数据,A 等于拿着一个幻影数据在做业务判断。
举一个现实的例子:A 事务在跑报表,统计用户资产总额。B 事务正在给一批用户扣款,扣到一半还没提交。如果 A 能读到 B 未提交的修改,报表里的总额就乱了,而且这个乱是“凭空出现又凭空消失”的乱,排查起来极其痛苦。
2.2 不可重复读:同一条记录,两次读取结果不一样
不可重复读的定义是:事务 A 内两次读取同一条记录,第二次读到的值和第一次不一致。和脏读的区别在于——这次读到的是其他事务已提交的数据。
还是用余额表的例子:
| 时间线 | 事务 A | 事务 B |
|---|---|---|
| T1 | 启动事务,读 balance = 1000 | |
| T2 | 启动事务,UPDATE balance SET balance = 800 | |
| T3 | COMMIT | |
| T4 | 再次读 balance,得到 800(和 T1 的值不一致) |
事务 A 在 T1 时刻读到余额是 1000,事务 B 在 T2 提交了修改,A 在 T4 再读就变成了 800。同一个事务里,同一条记录,两次读取结果不同,这就叫不可重复读。
为什么它会带来麻烦?想想转账的场景。事务 A 要做两笔扣款操作,先检查余额是否足够,够则执行扣款。如果两次读取的余额不一致,第一笔扣款按照 1000 的余额做了资格校验,第二笔却发现余额只剩 800,业务逻辑就出现了自相矛盾。更严重的是,这种不一致会导致账务对不上,对账系统跑出来永远是 0.01 的差异,但你就是找不出差异在哪。
2.3 幻读:查询结果集的行数变了
幻读的定义是:事务 A 两次执行同一条查询语句,第二次返回的结果集行数和第一次不一样。注意,幻读强调的是行数的变化,而不可重复读强调的是同一条记录的值变化。
演示场景换一下,事务 A 要查询所有 balance > 500 的用户:
| 时间线 | 事务 A | 事务 B |
|---|---|---|
| T1 | 启动事务,SELECT COUNT(*) FROM account WHERE balance > 500,得到 1 | |
| T2 | 启动事务,INSERT INTO account (user_id, balance) VALUES (2, 600) | |
| T3 | COMMIT | |
| T4 | 再次执行同一条 SELECT,得到 2(多了一条!) |
事务 A 第一次查到的是 1 条记录,事务 B 插入了一条新的符合条件的记录并提交,A 第二次查询就变成了 2 条。多出来的那条记录就像幽灵一样凭空出现,所以叫“幻读”。
这里有一个非常容易混淆的点:如果事务 B 插入的是一条 balance = 300 的记录,不满足 WHERE 条件,那么 A 的两次查询结果集行数不变,这就不属于幻读。幻读针对的是结果集行数的变化,而不是范围内的所有数据变化。
3. SQL 标准里的四种隔离级别
SQL 标准定义了四种隔离级别,层层递进,越往后隔离越严格,但并发性能也在逐级牺牲。
3.1 四种隔离级别的定义与对比
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 |
| READ COMMITTED(读已提交) | 不可能 | 可能 | 可能 |
| REPEATABLE READ(可重复读) | 不可能 | 不可能 | 可能(标准定义下) |
| SERIALIZABLE(串行化) | 不可能 | 不可能 | 不可能 |
四种隔离级别其实是按照“允许什么并发操作”来划分的:
- READ UNCOMMITTED:啥都不管,读操作不加锁,写操作加锁。性能最好,但脏读、不可重复读、幻读一个不落。
- READ COMMITTED:用 MVCC 保证每次读只能看到已提交的数据,脏读没了,但两次独立的读之间如果有其他事务提交了修改,依然会读到新值。
- REPEATABLE READ:用 MVCC + Read View 复用机制,事务启动时创建一次 Read View,后续所有快照读都复用这一个视图,保证同一条记录在事务内多次读取结果一致。
- SERIALIZABLE:所有操作强制加锁,读写互斥,相当于所有事务排队执行。隔离性最强,并发能力基本为零。
3.2 为什么说 REPEATABLE READ 在标准定义下不解决幻读
这里要特别关注 REPEATABLE READ 这一行——标准定义下它是可能产生幻读的。原因在于标准只规定了“读操作要看到一致的快照”,但没有规定写操作的行为。
幻读的产生场景通常是:事务 A 做了一次快照读,得到一个结果集;事务 B 插入了一条新数据并提交;事务 A 再次做快照读,理论上 MVCC 会复用旧的 Read View,新数据是不可见的,所以结果集行数应该还是和第一次一样。
但问题出在“当前读”上。如果事务 A 执行的是SELECT ... FOR UPDATE、UPDATE、DELETE这类当前读操作,它读的不是快照,而是数据库的最新已提交数据。此时事务 B 刚插入的数据就会出现在当前读的结果集里,幻读就发生了。
简单说:快照读下不幻读,当前读下会幻读。SQL 标准里对隔离级别的定义比较粗,没有区分快照读和当前读,所以它给的结论是“REPEATABLE READ 可能产生幻读”。
3.3 各数据库的默认隔离级别
不同数据库的默认隔离级别有很大差异,这是一个必备的常识:
| 数据库 | 默认隔离级别 |
|---|---|
| MySQL(InnoDB) | REPEATABLE READ |
| PostgreSQL | READ COMMITTED |
| Oracle | READ COMMITTED |
| SQL Server | READ COMMITTED(默认),可配置 |
MySQL 把可重复读当作默认级别,很多人以为是因为它比读已提交更安全,其实只猜对了一半。另一个理由是历史原因:MySQL 的主从复制在早期基于 binlog 的 STATEMENT 格式时,只有在 RR 级别下才能保证主从数据一致。虽然现在 ROW 格式的 binlog 在 RC 级别下也能正确复制了,但默认值一直没变。
4. MySQL InnoDB 底层是怎么实现隔离级别的
前面讲的是标准层面的理论,这一节进入实操层面。因为标准是一套,各家数据库的实现是另一套。你用 MySQL,就必须理解 InnoDB 的具体行为。
4.1 MVCC 与 undo log 版本链
InnoDB 的每一行记录上隐藏着几个关键字段:DB_TRX_ID(最近一次修改该记录的事务 ID)、DB_ROLL_PTR(指向 undo log 中该记录上一个版本的指针)、DB_ROW_ID(行 ID)。
当一条记录被多次修改时,undo log 会形成一个版本链:
最新版本 <- 版本2 <- 版本1 <- 初始版本每个版本都记录了产生它的事务 ID。MVCC 读数据时,做的事情就是沿着版本链往回找,找到第一个对当前事务可见的版本。
那“可见”怎么判断?靠 Read View。Read View 里最关键的有两个字段:
m_low_limit_id:当前系统中最大的事务 ID + 1。事务 ID 大于等于这个值的版本,说明是当前事务启动之后才开始的,不可见。m_up_limit_id:当前活跃事务中的最小事务 ID。事务 ID 小于这个值的版本,说明事务已经提交了,可见。
判断规则大概是这样:版本的事务 ID 在 [up_limit_id, low_limit_id) 区间内,且不在活跃事务列表里,说明该事务已提交,这个版本可见;否则继续往前找上一个版本。
4.2 READ COMMITTED 和 REPEATABLE READ 的核心差异
这两种隔离级别的代码实现差别其实就一行:每次读的时候要不要新建 Read View。
- READ COMMITTED:每条快照读语句执行时都会创建一个新的 Read View。
- REPEATABLE READ:事务启动时创建一次 Read View,整个事务内后续所有快照读都复用这一个。
就这么一个差别,造成了行为上的巨大不同。RC 级别下,事务 A 两次 SELECT 之间,事务 B 提交了新修改,A 第二次读就会走新的 Read View,发现 B 的版本已经可见,于是读到了新值——不可重复读。
RR 级别下,A 的 Read View 是第一次读时创建的,后面无论 B 提交了什么,哪怕 B 的事务 ID 比 A 的 Read View 里的判断阈值小(已提交),A 也看不见,因为版本链上找到 B 的版本后,判断它的事务 ID 是否在自己事务启动的快照之后——由于 A 复用旧 Read View,B 的修改版本对 A 来说永远是“未来事务”,不可见。于是同一条记录在 A 内反复读,结果始终一致。
这里注意一个细节:RR 下的 Read View 是“第一次读”时创建的,不是“事务开始”时创建的。如果一个 RR 事务先执行了一个写操作(当前读),再执行快照读,Read View 的创建时机是以第一次读为准。这个细节在排查问题时会非常关键。
4.3 InnoDB 如何解决幻读:Next-Key Lock
前面说了,标准定义下 RR 不解决幻读,但 MySQL 的 RR 级别实际上解决了绝大部分幻读场景。靠的就是Next-Key Lock(临键锁),它就是记录锁和间隙锁的组合。
- Record Lock:锁定单条索引记录。
- Gap Lock:间隙锁,锁定一个范围,但不锁定范围内的具体记录。它不和记录本身冲突,只和“往这个间隙插入新记录”的操作冲突。
- Next-Key Lock:左开右闭区间,比如
(1, 10],既锁住了记录 10,又锁住了 1 到 10 之间的间隙。
回到幻读的演示场景。事务 A 执行SELECT * FROM account WHERE balance > 500 FOR UPDATE,InnoDB 在扫描过程中,会对扫过的索引范围加 Next-Key Lock。事务 B 想往这个范围里插入一条 balance = 600 的新记录,插入时需要先检查目标位置是否有间隙锁——有,于是被阻塞,直到 A 提交或回滚。
注意,这里有两个限制条件:
- 必须通过索引条件加锁。如果 WHERE 条件没有索引,InnoDB 会对整个表加锁,性能和并发量直接崩。
- 快照读不需要加锁也能防幻读。因为 MVCC 已经保证快照的一致性,只有当前读才需要 Next-Key Lock。
还有一点:如果对balance列没有建立索引,事务 A 的SELECT ... FOR UPDATE会触发全表扫描,把全表所有记录和间隙全部加锁,这基本等同于串行化。
5. 现在能动手了:一套完整的验证方案
理论讲了一大堆,不如亲手验证一遍。下面把每个问题在 MySQL 8.0 里的复现步骤写出来,你照着操作一遍,理解立刻不一样。
5.1 环境准备和初始化脚本
建议直接使用 Docker 起一个 MySQL 8.0 容器,干净利落不污染宿主机器:
docker run --name mysql-test \ -e MYSQL_ROOT_PASSWORD=123456 \ -p 3306:3306 \ -d mysql:8.0然后创建测试表:
CREATE DATABASE IF NOT EXISTS isolation_test; USE isolation_test; CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, balance DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB; INSERT INTO account (user_id, balance) VALUES (1, 1000.00);5.2 复现脏读
两个终端分别打开 mysql 客户端,都执行以下命令手动管理事务:
-- 终端 A(事务 A) SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT * FROM account WHERE user_id = 1;-- 终端 B(事务 B) START TRANSACTION; UPDATE account SET balance = 500.00 WHERE user_id = 1; -- 不要执行 COMMIT此时回到终端 A 再执行一次查询:
SELECT * FROM account WHERE user_id = 1;如果看到 balance = 500.00,脏读复现成功。这时候数据其实还没提交。回到终端 B 执行ROLLBACK,再回到 A 查询,余额又变回 1000.00——凭空多出来又消失掉的 500 就是脏数据。
5.3 复现不可重复读
把两个终端都改成 READ COMMITTED 级别:
-- 终端 A(事务 A) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT * FROM account WHERE user_id = 1;-- 终端 B(事务 B) START TRANSACTION; UPDATE account SET balance = 800.00 WHERE user_id = 1; COMMIT;回到终端 A 再次查询,会看到 balance 变成了 800.00。两次查询结果不一致——不可重复读复现成功。
5.4 复现幻读
MySQL 默认的 RR 级别下,想复现幻读要绕个弯,因为快照读已经被 MVCC 管住了。最标准的复现姿势是用当前读:
-- 终端 A(事务 A) SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM account WHERE balance > 500 FOR UPDATE; -- 此时只查到了 user_id = 1 这一条-- 终端 B(事务 B) START TRANSACTION; INSERT INTO account (user_id, balance) VALUES (2, 600.00); COMMIT;如果 B 的 INSERT 被阻塞了,说明 Next-Key Lock 已经生效,幻读被挡在了门外。但如果 B 顺利插入并提交成功,回到 A 再执行同一条SELECT ... FOR UPDATE,结果集里就会多出 user_id = 2 这条记录——幻读发生。
在这个测试里,事务 A 的WHERE balance > 500如果没有走索引,InnoDB 会对全表加锁,B 的插入必然被阻塞。为了演示幻读发生的场景,可以让balance条件的筛选没命中索引,或者调整隔离级别到 READ COMMITTED。
5.5 实操过程中我踩过的坑
第一个坑:忘记START TRANSACTION。MySQL 默认 autocommit = 1,每条 SQL 自动提交。如果不手动开启事务,你的验证语句等于一条语句一个事务,隔离级别再高也看不到效果。
第二个坑:隔离级别的设置位置。用SET SESSION TRANSACTION ISOLATION LEVEL只对当前会话生效,如果开了新终端忘了设置,级别就回到了默认值。我习惯在每条验证语句前面都加上一句确认:
SELECT @@transaction_isolation;第三个坑:客户端工具干扰。用 Navicat 这类图形工具做并发测试很容易出问题,因为它可能自己包了一层连接池,你把终端 A 的查询和终端 B 的查询放在同一个连接里执行,结果完全不是你以为的那样。建议老老实实开两个终端,用命令行客户端操作。
6. 事务隔离级别的选择策略与实战建议
理论讲完,验证也做完了,最后落到实际问题上:我到底该把数据库配成哪个隔离级别?
6.1 不同业务场景下的推荐配置
默认就用 MySQL 的 REPEATABLE READ,不用改。这是绝大多数 Web 项目的正确姿势。原因如下:
- RR 级别下快照读完全一致,对开发者最友好,不容易写出逻辑错误的代码。
- InnoDB 的 RR 已经通过 Next-Key Lock 解决了幻读,你不太会遇到标准定义下 RR 的经典坑。
- 主从复制的 binlog 兼容性最好,历史包袱少。
有强一致需求的账务系统,建议直接上 SERIALIZABLE,但要做降级预案。不要一听到串行化就摇头。如果你的业务量不大,比如内部管理系统、后台运营系统,SERIALIZABLE 带来的排队开销完全可以接受,但它换来的“零并发问题”能让对账逻辑简单一个数量级。使用的时候可以配合超时设置,避免锁等待无限蔓延。
追求高并发的互联网业务,可以考虑 READ COMMITTED。如果你们的数据库团队有足够的 DBA 支撑,业务代码对“一事务内多次读看同一份快照”没有硬性要求,RC 级别能减少间隙锁的加锁范围,提升并发吞吐量。但前提是你能接受那个事务里读两次可能结果不一样。
6.2 排查并发问题的三个独家技巧
最后分享三个实战排查经验。
第一个技巧:先确认隔离级别,再看锁等待。遇到莫名其妙的并发相关问题,第一步永远不要猜业务代码,先查隔离级别和当前的事务状态:
SELECT @@transaction_isolation; SELECT * FROM information_schema.innodb_trx\G SELECT * FROM performance_schema.data_lock_waits\G第二个技巧:区分快照读和当前读。很多初学者排查半天发现“明明加了事务,怎么还是会读到新数据”,原因就是用了SELECT * FROM table WHERE ...(快照读)而不是SELECT * FROM table WHERE ... FOR UPDATE(当前读)。前者走 MVCC,后者走锁。如果你业务上需要“读取最新已提交数据”,必须用当前读,别指望 MVCC 给你新数据。
第三个技巧:用 SHOW ENGINE INNODB STATUS 看死锁日志。一条命令能查到最后一次死锁的详细信息,包含涉及的事务、持有和等待的锁、SQL 语句。生产环境遇到难缠的死锁问题,靠这个命令比靠代码 review 高效得多:
SHOW ENGINE INNODB STATUS\G6.3 再聊两句心里话
数据库隔离级别这套东西,刚接触的时候容易觉得枯燥,名词又多又绕。但我自己的体会是,一旦亲手把脏读、不可重复读、幻读都复现了一遍,这四个隔离级别就不再是考点,而是实实在在的工具——你知道系统里哪个地方会发生哪种冲突,知道该用什么级别才能压住它,也知道了代价是什么。
面试的时候我最喜欢问的一个问题是:“你线上用的什么隔离级别?为什么?”能答上来 MySQL 默认 RR、InnoDB 的 RR 和标准 RR 的区别、以及 RC 的历史包袱这三个点的人,基本就是个靠谱的数据库开发者了。希望这篇写完之后,你也能胸有成竹地把这三个问题讲明白。