面试专题

MySQL 的隔离级别都有哪些?如何避免幻读?

梳理 MySQL InnoDB 的四种事务隔离级别,解释脏读、不可重复读和幻读,并说明 MVCC、Read View、Gap Lock 与 Next-Key Lock 如何共同避免幻读。

难度:深入更新:2026-07-24

面试回答

MySQL InnoDB 支持四种事务隔离级别:读未提交、读已提交、可重复读和串行化。

  • 读未提交(READ UNCOMMITTED):可能发生脏读、不可重复读和幻读。
  • 读已提交(READ COMMITTED):只能读取已经提交的数据,但每次普通查询都会创建新的 Read View,因此可能发生不可重复读和幻读。
  • 可重复读(REPEATABLE READ):MySQL InnoDB 的默认隔离级别。同一事务中的普通快照读复用第一次查询建立的 Read View,避免不可重复读,并通过一致性快照避免看到后来插入的幻影行;对于 SELECT ... FOR UPDATESELECT ... FOR SHAREUPDATEDELETE 等当前读,则通过 Next-Key Lock 锁住索引记录及其前面的间隙,阻止其他事务在查询范围内插入新记录。
  • 串行化(SERIALIZABLE):隔离性最强,事务近似串行执行,可以避免脏读、不可重复读和幻读,但并发能力最低。

因此,回答“InnoDB 如何避免幻读”时,需要区分两类读取:

  1. 普通 SELECT 属于快照读,REPEATABLE READ 通过 MVCC 和同一个 Read View,让事务始终读取同一份一致性快照。
  2. 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 下,锁定读、UPDATEDELETE 通常只锁索引记录,不锁记录前面的间隙。除了外键约束检查和重复键检查等情况外,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,又阻止其他事务向 100200 之间插入新索引记录。

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 虽然让两个事务各自的查询结果保持一致,但没有阻止另一个事务执行插入。

因此,业务不变量不能只依赖普通快照读。常见方案是:

  1. 建立能够表达业务规则的唯一索引;
  2. 在 REPEATABLE READ 下使用合适的锁定读;
  3. 捕获重复键或死锁异常,并进行有限重试;
  4. 缩短事务时间,避免锁范围长期占用。

例如业务规则能够转换为唯一键时,优先让数据库约束兜底:

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 对锁定读、UPDATEDELETE 通常只锁索引记录,不锁前面的间隙。其他事务仍可以向记录间隙插入新行,因此范围查询仍可能出现幻读。

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 一定是最好的选择

它的隔离性最强,但会显著增加锁等待,降低并发能力。多数业务更适合通过合理隔离级别、索引、锁定读和数据库约束共同保证正确性。

参考资料

DISCUSSION

评论与讨论

留下你的想法