MySQL 的隔离级别都有哪些?如何避免幻读?
梳理 MySQL InnoDB 的四种事务隔离级别,解释脏读、不可重复读和幻读,并说明 MVCC、Read View、Gap Lock 与 Next-Key Lock 如何共同避免幻读。
面试回答
MySQL InnoDB 支持四种事务隔离级别:读未提交、读已提交、可重复读和串行化。
- 读未提交(READ UNCOMMITTED):可能发生脏读、不可重复读和幻读。
- 读已提交(READ COMMITTED):只能读取已经提交的数据,但每次普通查询都会创建新的 Read View,因此可能发生不可重复读和幻读。
- 可重复读(REPEATABLE READ):MySQL InnoDB 的默认隔离级别。同一事务中的普通快照读复用第一次查询建立的 Read View,避免不可重复读,并通过一致性快照避免看到后来插入的幻影行;对于
SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE和DELETE等当前读,则通过 Next-Key Lock 锁住索引记录及其前面的间隙,阻止其他事务在查询范围内插入新记录。 - 串行化(SERIALIZABLE):隔离性最强,事务近似串行执行,可以避免脏读、不可重复读和幻读,但并发能力最低。
因此,回答“InnoDB 如何避免幻读”时,需要区分两类读取:
- 普通
SELECT属于快照读,REPEATABLE READ 通过 MVCC 和同一个 Read View,让事务始终读取同一份一致性快照。 SELECT ... FOR UPDATE等属于当前读,REPEATABLE READ 通过 Gap Lock 和 Next-Key Lock 阻止其他事务向锁定范围插入新记录。
如果业务逻辑是“先查询是否存在,再决定是否插入或更新”,仅依赖快照读通常不够,应在 REPEATABLE READ 下使用合适索引和锁定读,并通过唯一索引等数据库约束作为最终兜底。
一句话总结
InnoDB 默认使用 REPEATABLE READ:普通查询靠 MVCC 的一致性快照避免看见幻影行,当前读靠 Next-Key Lock 锁住记录和间隙,阻止幻影行被插入。
详细讲解
一、事务隔离级别解决什么问题
多个事务并发执行时,一个事务的写入可能影响另一个事务的读取。隔离级别用于规定:
一个事务能够看到其他事务的哪些修改,以及什么时候能够看到这些修改。
隔离级别越高,事务之间的影响越小,但加锁和等待通常越多,并发能力也可能越低。
MySQL InnoDB 支持 SQL 标准定义的四种隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发能力 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 最高 |
| READ COMMITTED | 避免 | 可能 | 可能 | 较高 |
| REPEATABLE READ | 避免 | 避免 | InnoDB 通过 MVCC 和 Next-Key Lock 处理 | 较高 |
| SERIALIZABLE | 避免 | 避免 | 避免 | 最低 |
需要注意:
“REPEATABLE READ 可能发生幻读”是 SQL 标准层面的常见结论;MySQL InnoDB 在该级别上又实现了 MVCC 和 Next-Key Lock,对快照读和当前读分别处理幻读问题。
二、三种并发读取问题
1. 脏读
事务 A 读取了事务 B 尚未提交的数据。如果事务 B 最后回滚,事务 A 读到的就是从未真正生效的数据。
事务 A 事务 B
修改余额 100 → 0
读取余额,得到 0
ROLLBACK
这里的 0 就是脏数据。
2. 不可重复读
同一事务两次读取同一行,得到的字段值不同。
事务 A 事务 B
读取 id=1,余额为 100
修改余额为 80
COMMIT
再次读取 id=1,余额为 80
不可重复读关注的是:
同一行记录的内容发生了变化。
3. 幻读
同一事务使用相同查询条件执行两次查询,第二次返回的结果集合中出现了第一次不存在的记录,或者原有记录消失。
事务 A 事务 B
查询 amount > 100,返回 3 行
插入一条 amount=150 的记录
COMMIT
再次查询 amount > 100,返回 4 行
新增的第 4 行就是幻影行。
幻读关注的是:
满足查询条件的记录集合发生了变化。
所以,不可重复读和幻读的区别可以记为:
- 不可重复读:同一行的值变了。
- 幻读:符合条件的行数或记录集合变了。
三、四种隔离级别
1. READ UNCOMMITTED:读未提交
事务可以读取其他事务尚未提交的修改,因此可能发生:
- 脏读;
- 不可重复读;
- 幻读。
它的隔离性最低,实际业务系统很少使用。
2. READ COMMITTED:读已提交
事务只能读取已经提交的数据,因此不会发生脏读。
但在 InnoDB 中,每一次普通一致性读都会创建一个新的 Read View:
第一次 SELECT → Read View 1
第二次 SELECT → Read View 2
第三次 SELECT → Read View 3
如果其他事务在两次查询之间提交了修改,后一次查询就可能看到新数据,因此可能发生:
- 不可重复读;
- 幻读。
另外,在 READ COMMITTED 下,锁定读、UPDATE 和 DELETE 通常只锁索引记录,不锁记录前面的间隙。除了外键约束检查和重复键检查等情况外,Gap Lock 被禁用,因此其他事务仍可能向间隙插入新记录。
3. REPEATABLE READ:可重复读
这是 InnoDB 的默认隔离级别。
对于普通 SELECT,事务中的第一次一致性读会建立 Read View,之后的普通一致性读继续使用该快照:
第一次普通 SELECT → 创建 Read View
第二次普通 SELECT → 复用同一个 Read View
第三次普通 SELECT → 复用同一个 Read View
因此,其他事务之后提交的更新、删除或插入不会出现在当前事务的旧快照中。
对于锁定读和数据修改语句,InnoDB 会读取最新可用数据,并根据查询使用的索引范围加锁。范围条件通常会使用 Gap Lock 或 Next-Key Lock,阻止其他事务向被锁定的范围插入新记录。
4. SERIALIZABLE:串行化
SERIALIZABLE 是隔离性最强的级别。
在关闭自动提交的情况下,InnoDB 会把普通 SELECT 隐式转换为共享锁定读,效果类似:
SELECT ... FOR SHARE;
这样事务之间会产生更多锁等待,能够避免脏读、不可重复读和幻读,但吞吐量通常明显下降。
它适合一致性要求极高、并发量较低,或者确实需要事务串行执行的场景。
四、快照读与当前读
理解 InnoDB 如何处理幻读,必须先区分快照读和当前读。
1. 快照读
在 READ COMMITTED 和 REPEATABLE READ 下,普通 SELECT 通常属于快照读:
SELECT * FROM orders WHERE amount BETWEEN 100 AND 200;
快照读:
- 不对查询记录加行锁;
- 通过 MVCC 读取某个时间点的一致性数据;
- 其他事务可以继续修改、删除或插入数据。
REPEATABLE READ 下,同一事务中的普通快照读复用同一个 Read View,所以后来提交的记录对当前快照不可见。
2. 当前读
下面这些操作需要读取最新可用的数据,并对数据加锁:
SELECT ... FOR UPDATE;
SELECT ... FOR SHARE;
UPDATE ...;
DELETE ...;
它们通常被称为当前读或锁定读。
当前读不能只依靠旧快照,因为业务接下来可能要修改数据。它必须在最新数据状态上进行判断,并锁住相关记录或范围,避免判断完成后数据立即被并发事务改变。
五、快照读如何避免幻读
假设当前使用 REPEATABLE READ:
START TRANSACTION;
SELECT * FROM orders
WHERE amount BETWEEN 100 AND 200;
第一次普通查询建立 Read View。此后事务 B 插入一条 amount=150 的记录并提交:
INSERT INTO orders(user_id, amount)
VALUES (10, 150);
COMMIT;
事务 A 再次执行相同的普通查询:
SELECT * FROM orders
WHERE amount BETWEEN 100 AND 200;
由于事务 A 仍然使用原来的 Read View,新插入的记录不在该快照的可见范围内,因此第二次查询不会看到它。
这里需要准确理解:
MVCC 没有阻止事务 B 插入记录,只是让这条后来提交的记录对事务 A 的旧快照不可见。
因此,快照读解决的是“当前事务看到什么”,而不是“其他事务能不能写入”。
六、当前读如何避免幻读
如果业务需要先锁定一个范围,再根据查询结果修改数据,可以使用:
START TRANSACTION;
SELECT * FROM orders
WHERE amount BETWEEN 100 AND 200
FOR UPDATE;
假设 amount 上存在普通索引:
CREATE INDEX idx_orders_amount ON orders(amount);
在 REPEATABLE READ 下,InnoDB 扫描这个索引范围时,通常会对索引记录及其间隙加 Next-Key Lock。
此时另一个事务尝试插入:
INSERT INTO orders(user_id, amount)
VALUES (10, 150);
由于 150 落在已经锁定的索引范围内,该插入通常会被阻塞,直到前一个事务提交或回滚。
这次不是“让新记录不可见”,而是:
阻止新记录进入已经锁定的查询范围。
七、Record Lock、Gap Lock 与 Next-Key Lock
1. Record Lock
Record Lock 锁住的是索引记录。
例如:
SELECT * FROM orders
WHERE id = 100
FOR UPDATE;
如果 id 是唯一索引,并且使用唯一等值条件准确定位一条记录,InnoDB 通常只锁找到的索引记录,不需要锁前面的间隙。
2. Gap Lock
Gap Lock 锁住的是两个索引记录之间的间隙,而不是某一条已经存在的记录。
假设索引中已有:
100
200
300
那么其中存在:
(-∞, 100)
(100, 200)
(200, 300)
(300, +∞)
Gap Lock 的主要作用是限制其他事务在对应间隙插入新索引记录。
3. Next-Key Lock
Next-Key Lock 可以理解为:
Next-Key Lock = Record Lock + 该记录前面的 Gap Lock
例如对索引记录 200 加 Next-Key Lock,可以概念性地理解为锁住:
(100, 200]
它既保护记录 200,又阻止其他事务向 100 与 200 之间插入新索引记录。
InnoDB 还可以锁住最后一条记录之后的间隙,从而保护开放区间查询:
SELECT * FROM orders
WHERE amount > 200
FOR UPDATE;
八、索引为什么很重要
InnoDB 的行锁本质上是索引记录锁,Next-Key Lock 也是围绕索引扫描范围建立的。
如果查询能够使用合适索引:
SELECT * FROM orders
WHERE amount BETWEEN 100 AND 200
FOR UPDATE;
InnoDB 可以更准确地锁定相关索引范围。
如果缺少合适索引,InnoDB 可能需要扫描并锁定大量索引记录,锁的范围会变大,并发能力明显下降。
这里不要简单表述为:
没有索引就一定加表锁。
更准确的说法是:
InnoDB 仍然基于索引记录加锁;缺少可用业务索引时,可能扫描并锁住大量记录,使效果接近大范围锁定。
因此,使用锁定读避免幻读时,需要同时关注:
- 查询条件是否命中索引;
- 实际执行计划选择了哪个索引;
- 锁定范围是否超出预期;
- 事务是否足够短;
- 是否可能形成死锁。
九、为什么不能只依赖快照读完成“先查后写”
假设业务要求同一用户只能存在一条有效申请:
SELECT * FROM applications
WHERE user_id = 100
AND status = 'ACTIVE';
两个事务可能同时通过快照读发现“记录不存在”,然后各自插入一条有效申请。
REPEATABLE READ 虽然让两个事务各自的查询结果保持一致,但没有阻止另一个事务执行插入。
因此,业务不变量不能只依赖普通快照读。常见方案是:
- 建立能够表达业务规则的唯一索引;
- 在 REPEATABLE READ 下使用合适的锁定读;
- 捕获重复键或死锁异常,并进行有限重试;
- 缩短事务时间,避免锁范围长期占用。
例如业务规则能够转换为唯一键时,优先让数据库约束兜底:
CREATE UNIQUE INDEX uk_user_active
ON applications(user_id, active_flag);
具体索引设计仍要根据数据模型确定,不能为了唯一约束直接照搬示例。
十、混用快照读和当前读为什么容易困惑
在 REPEATABLE READ 事务中,普通 SELECT 使用旧快照,而锁定读读取最新可用的数据。
可能出现:
1. 普通 SELECT 没看到某条新记录
2. 另一个事务插入该记录并提交
3. SELECT ... FOR UPDATE 却看到了这条记录
原因不是隔离级别突然失效,而是两条语句使用了不同的读取机制:
- 普通
SELECT:读取 Read View 中的一致性快照; SELECT ... FOR UPDATE:读取最新可用数据并加锁。
MySQL 官方文档也不建议在同一个 REPEATABLE READ 事务中随意混用锁定语句和非锁定查询,因为它们可能呈现两个不同时间状态的数据。
如果业务必须基于最新结果做写入判断,应统一使用锁定读;如果必须获得更严格、容易理解的串行语义,可以评估 SERIALIZABLE,但要接受更高的锁竞争。
十一、READ COMMITTED 下加 FOR UPDATE 能否避免幻读
不能笼统地说可以。
在 READ COMMITTED 下,InnoDB 对锁定读、UPDATE 和 DELETE 通常只锁索引记录,不锁前面的间隙。其他事务仍可以向记录间隙插入新行,因此范围查询仍可能出现幻读。
Gap Lock 在该级别主要保留给:
- 外键约束检查;
- 重复键检查。
如果业务需要锁住一个尚不存在的范围,应评估:
- 使用 REPEATABLE READ,并通过合适索引和 Next-Key Lock 锁定范围;
- 使用 SERIALIZABLE;
- 把业务不变量转换为唯一约束;
- 调整数据模型,避免依赖无法锁定的“空结果”。
十二、如何选择隔离级别
选择 READ COMMITTED
适合:
- 希望每次查询都看到最新已提交数据;
- 可以接受同一事务内两次读取结果不同;
- 希望减少 Gap Lock 和锁冲突;
- 业务通过版本号、唯一约束或其他机制保证并发正确性。
选择 REPEATABLE READ
适合:
- 希望事务中的普通查询保持一致视图;
- 需要使用 Next-Key Lock 保护范围;
- 能够控制索引、锁顺序和事务时长;
- 接受 InnoDB 默认隔离级别。
选择 SERIALIZABLE
适合:
- 并发量较低;
- 一致性要求极高;
- 业务逻辑难以通过更细粒度约束实现;
- 能够接受更多阻塞、超时和死锁重试。
隔离级别不是越高越好。选择时需要综合评估:
- 一致性要求;
- 读写比例;
- 热点数据;
- 事务持续时间;
- 可接受的锁等待;
- 数据库吞吐量。
十三、查看和设置隔离级别
查看当前会话隔离级别:
SELECT @@SESSION.transaction_isolation;
查看全局默认隔离级别:
SELECT @@GLOBAL.transaction_isolation;
修改当前会话后续事务的隔离级别:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
设置下一个事务的隔离级别:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
开始事务:
START TRANSACTION;
线上修改隔离级别前,需要评估现有事务代码、锁行为、复制方式和监控指标,不应只根据理论吞吐量直接切换。
十四、常见误区
误区 1:MVCC 会阻止其他事务插入
不会。MVCC 主要决定一条记录对当前 Read View 是否可见,不会因为快照读而阻塞其他事务写入。
误区 2:REPEATABLE READ 只靠 MVCC 避免所有幻读
不完整。
- 快照读靠 MVCC 和固定 Read View;
- 当前读靠 Gap Lock 和 Next-Key Lock。
误区 3:只要加 FOR UPDATE 就一定不会幻读
不一定。是否锁住间隙与隔离级别、查询条件、索引类型和实际扫描范围有关。READ COMMITTED 下通常不会为普通范围查询使用 Gap Lock。
误区 4:Next-Key Lock 锁的是数据页
不是。它围绕索引记录和索引记录之间的间隙建立。
误区 5:没有索引就一定升级为表锁
这个表述不准确。InnoDB 仍然基于索引进行记录锁定,但缺少合适索引可能导致扫描和锁定大量记录,使并发效果接近大范围锁定。
误区 6:SERIALIZABLE 一定是最好的选择
它的隔离性最强,但会显著增加锁等待,降低并发能力。多数业务更适合通过合理隔离级别、索引、锁定读和数据库约束共同保证正确性。
评论与讨论
回复 :
留下你的想法