MySQL事务隔离级别有哪些?脏读、不可重复读与幻读详解

同一个账户,两个并发的转账请求同时读到余额一样,结果都通过了校验,最终超扣——这类问题是并发事务里非常典型的坑。要理解这些坑从哪来、怎么避免,就绕不开"事务隔离级别"这个概念。它是数据库在并发和数据一致性之间做的取舍:隔离得越严格,数据越安全,但并发性能越差。

这篇文章把四个隔离级别、三种典型并发问题讲透,再说明 MySQL 默认为什么选 REPEATABLE READ、MVCC 是怎么做到的、间隙锁又是怎么防幻读的,最后给出实际项目里怎么选。文中用两个会话交替执行的方式演示每一种现象,你可以直接在自己的测试库复现。示例基于常见 MySQL 5.7 与 8.0,实际行为以自己环境为准。

四个隔离级别

SQL 标准定义了四个隔离级别,按严格程度从低到高排列。隔离级别越高,能避免的并发问题越多,但并发能力通常越低。

隔离级别 脏读 不可重复读 幻读 并发性能
READ UNCOMMITTED(读未提交) 可能 可能 可能 最高
READ COMMITTED(读已提交) 不可能 可能 可能 较高
REPEATABLE READ(可重复读) 不可能 不可能 理论上可能,InnoDB 基本避免 中
SERIALIZABLE(串行化) 不可能 不可能 不可能 最低
# 查看当前隔离级别(MySQL 8.0 用 transaction_isolation)
mysql -uroot -p -e "SELECT @@transaction_isolation;"
# MySQL 5.7 用 tx_isolation
mysql -uroot -p -e "SELECT @@tx_isolation;"

# 查看全局与会话两个层级
mysql -uroot -p -e "SELECT @@global.transaction_isolation, @@session.transaction_isolation;"

三种并发问题的定义与示例

在讲之前,先约定一个复现用的表。

CREATE TABLE account (
  id INT PRIMARY KEY,
  balance INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, 1000);

脏读:读到了别人还没提交的数据

事务 A 修改了数据但还没提交,事务 B 就读到了这个"脏"值。此时如果 A 回滚,B 手里拿的是一份从未真正存在过的数据。

-- 会话 A(隔离级别 READ UNCOMMITTED)
BEGIN;
UPDATE account SET balance = 500 WHERE id = 1;   -- 未提交

-- 会话 B
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM account WHERE id = 1;         -- 读到 500,这是脏数据

-- 会话 A
ROLLBACK;                                          -- balance 回到 1000
-- 会话 B 手里的 500 从未真实存在过

脏读的危害在于基于错误数据进行后续决策,比如把没真正到账的余额当成可用余额。读已提交级别就能避免它。

不可重复读:同一事务内两次读结果不同

事务 B 在同一个事务里读两次同一行,中间事务 A 提交了修改,导致 B 两次读到的值不一样。

-- 会话 B(READ COMMITTED)
BEGIN;
SELECT balance FROM account WHERE id = 1;   -- 第一次读到 1000

-- 会话 A
BEGIN;
UPDATE account SET balance = 800 WHERE id = 1;
COMMIT;

-- 会话 B 继续
SELECT balance FROM account WHERE id = 1;   -- 第二次读到 800,前后不一致

不可重复读聚焦的是"同一行的值变了"。它在很多业务里是可以接受的,但也可能导致同一事务内基于不同快照做出矛盾判断。

幻读:同一条件下两次查询的行数不同

幻读针对的是"行集合"的变化。事务 B 按同一条件查询两次,中间 A 插入或删除了满足条件的行,第二次多出(或少掉)了行。

-- 会话 B(READ COMMITTED)
BEGIN;
SELECT COUNT(*) FROM account WHERE balance > 500;   -- 假设读到 1 行
-- 会话 A
BEGIN;
INSERT INTO account VALUES (2, 900);
COMMIT;
-- 会话 B 继续
SELECT COUNT(*) FROM account WHERE balance > 500;   -- 变成 2 行,多出一行"幻影"

幻读常出现在"先查询再插入"的逻辑里,比如统计后做汇总、批量校验后批量写入。它和不可重复读的区别是:不可重复读是同一行的内容变了,幻读是满足条件的行数变了。

MySQL 默认为什么是 REPEATABLE READ

MySQL 的默认隔离级别是 REPEATABLE READ,比很多数据库(比如 Oracle、PostgreSQL 默认读已提交)更严格。原因主要有两层。

第一,REPEATABLE READ 配合 MVCC 已经能避免脏读和不可重复读,大多数业务需要的稳定性它都能提供。第二,InnoDB 的可重复读通过间隙锁在很大程度上避免了幻读,这在不少业务场景里能省去应用层的额外加锁。所以对多数业务来说,默认级别是一个比较省心的平衡点。

MVCC 是怎么实现可重复读的

MVCC 全称是多版本并发控制。InnoDB 在每行记录上隐式维护了两个字段:创建该版本的事务 ID 和删除该版本的事务 ID(实际实现里还有回滚指针,指向 undo log 中的旧版本)。一个查询在开始时会生成一个一致性读视图(Read View),记录了当前活跃事务的集合。

读取某一行时,InnoDB 会根据这行的版本链和 Read View 判断哪个版本对当前事务可见:如果该版本是当前事务自己改的,可见;如果是本事务开始前就提交的,可见;如果是本事务开始后才提交的,不可见,就顺着回滚指针找更早的版本。这样一来,同一个事务在开始时的快照就固定了,后续再怎么读都看到同一个版本,于是实现了可重复读。

这里有个容易混的点:MVCC 解决的是快照读(普通 SELECT)的一致性,而当前读(SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE)读的是最新数据并加锁,不受快照影响。

间隙锁如何防幻读

在可重复读下,要防止"查询之后再插入"的幻读,光靠 MVCC 快照读是不够的,因为当前读和写操作必须看到最新状态。InnoDB 用的是间隙锁(Gap Lock)加上记录锁组成的 Next-Key Lock。

间隙锁锁的不是某一行,而是行与行之间的"空隙"。比如索引上有值 5 和 10,间隙锁可以锁住 (5, 10) 这段区间,让别的事务无法在这个区间里插入新行,从而阻止"幻影"出现。

-- 表 t,id 索引上有 5、10
-- 会话 A
BEGIN;
SELECT * FROM t WHERE id BETWEEN 5 AND 10 FOR UPDATE;  -- 锁住记录和间隙

-- 会话 B(默认可重复读)
BEGIN;
INSERT INTO t VALUES (7);   -- 被阻塞,因为 7 落在被锁住的间隙里

-- 会话 A 提交后,会话 B 才能插入

间隙锁的副作用是它之间可以互相兼容——两个事务可以同时持有同一个间隙的间隙锁,但插入意向锁会互相等待,因此更容易发生死锁。这也是可重复读下死锁比读已提交更常见的原因之一。

# 观察锁的粒度需要具备相应权限,可查看以下视图
mysql -uroot -p -e "SHOW VARIABLES LIKE 'innodb_locks_unsafe_for_binlog';"  # 已废弃参数,仅作了解
mysql -uroot -p -e "SELECT * FROM performance_schema.data_locks\G"          # 8.0

隔离级别怎么设置

设置分两个层级:全局和对当前会话。全局改会影响之后新建的连接,会话改只影响当前连接。

# 设置当前会话的隔离级别(立即生效,仅影响本连接)
mysql -uroot -p -e "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;"

# 设置全局隔离级别(影响之后的新连接,重启失效或持久化取决于版本)
mysql -uroot -p -e "SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;"

风险提示:SET GLOBAL 影响所有新建连接,改之前确认业务是否依赖当前隔离语义,尤其是依赖可重复读快照或间隙锁的逻辑。修改后要持续观察锁等待、死锁和业务一致性。要永久生效需写入配置文件:

# /etc/my.cnf 或 /etc/mysql/my.cnf 的 [mysqld] 段
[mysqld]
transaction-isolation = READ-COMMITTED

改完需要重启 MySQL 才生效。重启属于高风险操作,务必先备份、安排维护窗口,并确认能快速回滚到原配置。不同发行版的配置文件位置略有差异:

# RHEL/CentOS/Rocky 常见路径
sudo systemctl restart mysqld
# Debian/Ubuntu 常见路径
sudo systemctl restart mysql

怎么选择隔离级别

选择的核心是"业务需要多强的一致性"和"能接受多少并发损失"之间的权衡。

业务场景 建议隔离级别 理由
报表、日志、后台统计 READ COMMITTED 对快照一致性要求不高,追求并发
普通电商、内容系统 REPEATABLE READ(默认) 兼顾一致性与并发,省心
强一致账务、对账 REPEATABLE READ 或 SERIALIZABLE 对不可重复读、幻读敏感
大量范围插入的批量作业 READ COMMITTED 减少间隙锁,降低死锁概率

实际经验是:绝大多数业务用默认的 REPEATABLE READ 就够了。如果系统里出现了大量因间隙锁引发的死锁或者锁等待,且业务确实不依赖可重复读快照,可以评估切到 READ COMMITTED。切之前一定要在测试环境用真实并发复现一轮,确认没有逻辑漏洞,比如"先查后改"的代码在快照变化后是否还成立。

另外需要注意,即使隔离级别很高,应用侧的并发控制也不能完全依赖数据库。像"检查余额再扣款"这种逻辑,比较稳妥的方式是用原子更新或乐观锁:

-- 原子更新:把校验和更新合并到一条语句里,避免查完再改的时间窗
UPDATE account SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- 检查影响行数,为 0 说明余额不足,更新失败

常见问题

MySQL 默认隔离级别是什么?

MySQL 默认是 REPEATABLE READ。相比 Oracle、PostgreSQL 默认的 READ COMMITTED 更严格,主要为了在快照读和间隙锁的配合下更好地保证一致性。

可重复读真的能完全避免幻读吗?

在快照读场景下,可重复读依赖 MVCC 能保证两次读到的行集合一致。在当前读场景下,InnoDB 通过间隙锁阻止其他事务插入,也基本避免了幻读。严格来说仍有边界情况,需要极致保证时可考虑串行化。

读已提交下还会有间隙锁吗?

读已提交在大部分情况下只加记录锁,不加间隙锁(外键约束和唯一键冲突检查等场景除外),因此并发更好、死锁更少,但可能出现不可重复读和幻读。

修改隔离级别需要重启吗?

SET SESSION 和 SET GLOBAL 都是立即生效不需要重启,但只对新连接或当前会话生效。写入配置文件才永久生效,那一项需要重启 MySQL。

MVCC 会不会导致看不到最新数据?

快照读看到的是事务开始时的视图,这在设计上就是如此。如果确实需要读最新数据,用当前读(FOR UPDATE 等)或把事务拆开,在需要读新值的时点重新开启事务。

总结

  • 四个隔离级别从低到高是读未提交、读已提交、可重复读、串行化,隔离越强并发越弱。
  • 脏读读到未提交数据,不可重复读是同行的值变了,幻读是满足条件的行数变了,三者对应不同级别的防护。
  • MySQL 默认可重复读,靠 MVCC 实现快照一致性,靠间隙锁在当前读场景下防幻读。
  • MVCC 只保证快照读的一致性,当前读(FOR UPDATE、UPDATE)读最新数据并加锁。
  • 多数业务用默认级别即可;死锁和锁等待严重且业务不依赖可重复读时,可评估切到读已提交。
  • 隔离级别不能替代应用侧的并发控制,"先查后改"要尽量改成原子更新或乐观锁。