☰
MySQL事务隔离级别详解:从脏读、不可重复读到MVCC与锁实现
2026/10/13 9:18:28 网站建设 项目流程

先说我自己的经历。前阵子一个订单系统出了个诡异的线上问题:两个支付请求几乎同时打进来,一个用户在页面上看到的价格和最终记账金额差了十几块钱。我第一反应是SQL写错了,翻了一圈代码没找到问题,后来用两个终端窗口把事务场景复现了一遍,这才发现根源在事务隔离级别的配置上——多个事务同时操作同一批数据,彼此之间能看到对方什么状态,这个规则没搞对,代码写得再漂亮也没用。

这篇文章不谈抽象的大道理,就把MySQL事务隔离级别这件事从头到尾说清楚:它到底在解决什么问题、四种隔离级别各自有什么特性、InnoDB底层是靠什么实现的、以及你在实际项目里怎么配置和排查。刚接触事务的开发者可以把它当入门资料看,用了很多年RR隔离级别但始终说不清原理的老手,也能在这里找到一些被忽略的细节。

1. 先看三个并发问题,再谈隔离级别

1.1 ACID里的“隔离性”到底在管什么

事务有四个核心特性,原子性、一致性、隔离性、持久性,也就是常说的ACID。前三个都好理解:原子性保证一个事务要么全做要么全不做,一致性保证数据从一种合法状态流转到另一种合法状态,持久性保证提交后不会丢。唯独“隔离性”最微妙,因为它不是绝对的,而是分等级的。

隔离性说的是多个事务同时执行时,彼此之间不能互相干扰。但完全互不干扰理想吗?理想。现实吗?不现实。如果每个事务都严格串行执行,数据绝对不会乱,但并发性能会跌到惨不忍睹。所以数据库在一致性和性能之间做了一系列妥协,每一个妥协档位就是一种隔离级别。你配置的隔离级别,本质上就是你愿意接受多大程度的数据错乱,来换取多高的并发处理能力。

很多开发者在面试时能脱口而出四种隔离级别,但到了实际调优时往往一脸懵,就是因为没把“隔离性不是绝对的”这句话刻在脑子里。隔离级别不是越高越好,也不是越低越快,它是你在业务一致性需求和数据库吞吐量之间做的取舍。理解了这一点,后面所有内容都顺了。

1.2 脏读、不可重复读、幻读:每个都要用一个能复现的例子说清楚

先准备一张很简单的订单表,模拟日常业务:

CREATE TABLE `orders` ( `id` int NOT NULL, `status` varchar(20) DEFAULT NULL, `amount` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; INSERT INTO `orders` VALUES (1, '待支付', 100.00); INSERT INTO `orders` VALUES (2, '待支付', 200.00); INSERT INTO `orders` VALUES (3, '已支付', 300.00);

基于这张表,我把三个并发问题一个个演给你看。

脏读(Dirty Read)

事务A修改了一条数据但还没提交,事务B这时候读到了修改后的值。后来事务A回滚了,事务B刚才读到的就是个不存在的“脏数据”。

具体过程是这样:

-- 事务A START TRANSACTION; UPDATE orders SET amount = 50.00 WHERE id = 1; -- 此时不提交,事务B去查询 -- 事务B START TRANSACTION; SELECT amount FROM orders WHERE id = 1; -- 读到 50.00,但事务A尚未提交,这个值随时可能消失

我把脏读理解成“你听了一个没定论的传言就当成了事实”。日常业务里脏读非常危险,比如统计报表读到一笔还没确认的退款,就会产生错误的汇总数据。

不可重复读(Non-Repeatable Read)

同一个事务内,两次相同的查询返回了不同的内容。事务A第一次查询时金额是100,事务B把金额改成了200并提交,事务A在同一个事务里第二次查询,金额变成了200。前后不一致,而且第二次读到的确实是别人已提交的合法数据,没有脏读的问题,但对你这个事务而言,两次查询结果对不上。

-- 事务A START TRANSACTION; SELECT amount FROM orders WHERE id = 1; -- 第一次读,100.00 -- 事务B同时执行 START TRANSACTION; UPDATE orders SET amount = 200.00 WHERE id = 1; COMMIT; -- 事务A再次查询 SELECT amount FROM orders WHERE id = 1; -- 第二次读,200.00

这个问题的核心在于“同一个事务内的快照不稳定”。想象你在写一份报告,第一次查了一个参考数据,写到一半再查一次,发现参考数据变了,整份报告的论证逻辑就乱了。

幻读(Phantom Read)

同一个事务内,两次相同的查询返回的记录数量不同。事务A第一次查询“待支付”状态的订单有2条,事务B插入了一条新的“待支付”订单并提交,事务A第二次查询变成了3条。多出来的那条记录就是“幻影行”。

-- 事务A START TRANSACTION; SELECT * FROM orders WHERE status = '待支付'; -- 2条记录 -- 事务B同时执行 START TRANSACTION; INSERT INTO orders VALUES (4, '待支付', 400.00); COMMIT; -- 事务A再次查询 SELECT * FROM orders WHERE status = '待支付'; -- 3条记录

幻读和不可重复读的区别,很多人容易搞混。我用一句话区分:不可重复读是同一行数据的“内容变了”,幻读是整个查询结果集的“行数变了”。前者是修改问题,后者是插入或删除问题。

这三个问题就是理解隔离级别的钥匙。四种隔离级别,本质上就是看你怎么处理这三种“读异常”。

2. 四种隔离级别逐个拆解:每个级别的特性、代价与适用场景

2.1 读未提交:能读到你没提交的数据

READ UNCOMMITTED,中文叫读未提交。这个级别下,一个事务可以读到另一个事务未提交的数据,也就是说,脏读、不可重复读、幻读全都不设防。它连最基本的“数据稳定性”都不保证,读到的内容完全取决于其他事务当时的执行状态。

这个级别几乎不会在生产环境使用,因为它的性能优势并没有比下一个级别高多少,但数据正确性的代价高到离谱。偶尔会有人把它用在一些“只求速度、结果错了也无所谓”的场景,比如统计一个不需要精确的在线人数、海量日志的粗略扫描。但说实话,我见过真正在生产环境配置READ UNCOMMITTED的项目屈指可数,绝大多数情况下的正确选择是从后三个级别里挑。

值得一提的是,即便在这个最宽松的级别下,InnoDB的更新操作依然要加行锁,否则连数据写入都会乱套。更多的锁细节后面讲,这里你先记住一个结论:隔离级别主要控制的是“读”的规则,“写”的逻辑在InnoDB里始终是严格串行化的。

2.2 读已提交:每次读都是最新的已提交数据

READ COMMITTED,中文叫读已提交。这个级别解决了脏读问题——你只能读到已经提交的数据,没提交的修改对你不可见。但它有一个明显短板:不可重复读依然存在,幻读也依然存在。

很多从Oracle转过来的人会对这个级别特别亲切,因为Oracle默认的隔离级别就是READ COMMITTED。它非常适合一套系统里“每次查询都希望能看到最新提交结果”的业务,典型场景比如交易状态查询、库存余量显示。用户刷新一次页面,就想看到最新的数据,这个级别正好满足。

READ COMMITTED有一个重要的实现特点:事务里的每次普通SELECT都会生成一个新的快照来读取数据,所以你在同一个事务里两次查询,永远看到的是“上次查询时点最新已提交的数据”。这个特征在对比可重复读时非常关键,后面实操篇我还会演示。

很多高并发互联网团队会主动把隔离级别从默认的RR改成RC,很大一部分原因是RC下InnoDB不需要维护那么多间隙锁,死锁概率显著降低。代价就是你要接受事务内结果集“前后不一致”。

2.3 可重复读:MySQL默认级别的底气和隐患

REPEATABLE READ,中文叫可重复读。这是MySQL默认的隔离级别,也是很多人“用了一辈子但没想过为什么”的配置。

可重复读保证同一事务内,你第一次执行普通SELECT之后,后续所有普通SELECT读到的都是同一份快照,不管其他事务在这期间提交了多少次修改,你看到的数据始终不变。这就把不可重复读的问题解决掉了。

那幻读呢?在标准SQL的定义里,RR级别是不解决幻读的,但MySQL的InnoDB工程实现把这个“标准缺陷”补上了,通过MVCC让快照读不再看到其他事务后来插入的数据。不过这事有前提,也不能一概而论,在3.3节我会单独讲清楚,这里先不展开。

MySQL为什么要把默认级别设为RR?除了业务一致性考量,还有一个历史原因:早期的binlog基于statement格式复制,在RC级别下,同一个SQL在主库和从库执行时的数据上下文可能不同,导致主从数据不一致,而RR级别在绝大多数场景下可以规避这个问题。所以你可以这么理解,RR是MySQL在综合考虑一致性和复制安全之后给出的默认答案。

RR的隐患也很明确:因为要实现“可重复”,InnoDB需要在索引记录之间维护更多的间隙锁,高并发写入场景下锁竞争加剧,死锁概率变高。这个问题在4.3节我会用实际案例讲。

2.4 串行化:牺牲并发换来的绝对一致

SERIALIZABLE,中文叫串行化。这是隔离级别的天花板,所有事务按顺序一个接一个执行,彻底杜绝脏读、不可重复读、幻读,代价是并发能力几乎归零。

在这个级别下,普通的SELECT也会被加上共享锁,其他事务的更新操作会被阻塞,整个数据库的并发处理能力被降到了最低点。生产环境很少会用这个级别,更常见的做法是在应用层用悲观锁、分布式锁控制关键路径,而不是把整个库拖进串行化的泥潭。

如果某个操作真的强一致到必须串行,我通常会优先选择在SQL语句层面处理,比如用SELECT ... FOR UPDATE锁住必要的行,或者引入分布式锁,这比把全局隔离级别调到SERIALIZABLE灵活得多,影响面也小得多。

2.5 隔离级别对照表:问题、性能、一致性一目了然

四种隔离级别对付三个并发问题的能力,我直接整理成表格:

隔离级别脏读不可重复读幻读并发性能
READ UNCOMMITTED可能发生可能发生可能发生最高
READ COMMITTED解决可能发生可能发生较高
REPEATABLE READ解决解决大体解决(有边界)中等
SERIALIZABLE解决解决解决最低

从这张表可以看出,隔离级别阶梯式地解决数据一致性问题,也阶梯式地消耗并发性能。大多数生产系统在RC和RR之间选择,不是没道理的。至于具体业务应该选什么,我的建议写在最后一段,这里先把“每种级别到底怎么实现的”这个问题搞清楚,因为只有懂了原理,配置时才有底气。

3. InnoDB的事务隔离实现:MVCC与锁的配合

3.1 快照读和当前读,先分清这两种读

要理解InnoDB的隔离级别实现,第一件事就是分清两种读模式:快照读和当前读。

快照读就是普通的SELECT语句,它读的是历史版本数据,不加锁。当前读则是SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这些操作,它们必须读取最新版本的已提交数据,并且对涉及的行加锁。

为什么必须区分?因为MySQL的RR隔离级别能“大体解决”幻读,靠的就是这两套机制各管一摊:快照读用MVCC保证你始终看到一致的历史快照,当前读用间隙锁阻止其他事务往你的锁定范围里插入新数据。两者缺一不可。

我举个容易翻车的例子。假设你在RR级别下开了一个事务,先用普通SELECT查询“待支付”订单,查到2条。与此同时另一个事务插入了一条新订单并提交。你在同一个事务里再次执行普通SELECT,仍然只会看到2条,这就是MVCC快照的功劳。但如果你第二次执行的是SELECT ... FOR UPDATE,那它读取的是最新已提交数据,大概率能查到3条,而且这个过程中如果另一个事务还没提交插入操作,它会被阻塞。这也就是我在2.3节卖关子的地方——RR不是物理上让幻读消失,而是通过两种手段分别把快照读和当前读路径上的幻读概率压到了最低。

很多线上事故的根源就是开发者没分清这两条路径,以为RR就万事大吉,结果在同一个事务里快照读和当前读混着用,查出来的结果集数量不一致,一通排查才发现是自己对隔离级别的理解有盲区。

3.2 MVCC的版本链与Read View可见性判断

MVCC全称是Multi-Version Concurrency Control,多版本并发控制。它的核心思路是:数据库里一行数据不只有一个版本,每次被修改,都会在undo log里留下一个旧版本记录,新版本和旧版本通过版本链串起来,形成一个链表。

我在实际调试时喜欢把版本链想象成一行数据的历史档案袋:当前值是档案袋最上面那一页,之前的每一次修改都被压在里面。当一个事务发起快照读时,它不是直接拿当前值,而是根据一条“可见性规则”从这个版本链里挑一个它应当看得见的版本。

这条规则就是Read View,翻译成大家都懂的说法叫“读取视图”。生成Read View时,InnoDB会记录下当前系统里活跃事务的ID列表等关键信息。判断一个数据版本是否可见,大致按下面几步来:

  1. 如果版本的创建事务ID等于当前事务ID,说明这个版本是自己改的,必须可见。
  2. 如果版本的创建事务ID小于Read View里最小的活跃事务ID,说明这个版本在快照生成前就已经提交了,可见。
  3. 如果版本的创建事务ID大于或等于Read View里下一个待分配的事务ID,说明这个版本是在快照生成之后才产生的,不可见。
  4. 如果版本的创建事务ID落在活跃事务列表区间内,就去查它是否还在活跃事务列表里,如果在说明还没提交,不可见;如果不在说明已经提交,可见。

READ COMMITTED和REPEATABLE READ之间的核心差异就在生成Read View的时机:RC级别每次执行快照读都会生成一个新的Read View,所以能读到别的事务最新提交的数据;RR级别只在事务里第一次执行查询时生成Read View,后续所有查询都复用这一个视图,所以整个事务期间看到的数据都保持一致。我个人觉得,把这一条彻底想明白,比死记硬背概念有用得多。

3.3 间隙锁与Next-Key Lock:RR级别为什么能挡住“幻觉行”

MVCC解决了快照读路径上的幻读问题,但当前读路径上还有漏洞。比如事务A执行SELECT ... FOR UPDATE锁定了一批记录,事务B如果往这批记录的空隙里插入一条新数据,就会破坏事务A的锁定语义。InnoDB的解决方案是间隙锁(Gap Lock)和它升级版的伴生锁Next-Key Lock。

间隙锁锁的不是某一行,而是两个索引记录之间的间隙,确保这个范围内不能插入新的记录。Next-Key Lock则是记录锁和间隙锁的合体,它既锁住记录本身,也锁住记录前面的间隙,这就堵死了“往锁定范围内插新数据”这条路。

举一个非常典型的例子:

-- 事务A START TRANSACTION; SELECT * FROM orders WHERE id BETWEEN 1 AND 5 FOR UPDATE;

如果表里现有id为1、2、3的记录,这条语句在RR级别下不仅会锁住这三行,还会把id=3和id=5之间的间隙也锁上。此时事务B想插入id=4的记录,就会被阻塞,直到事务A提交或回滚。在RC级别下,同样这条语句只锁已存在的三行记录,间隙不设防,id=4的插入可以顺利进行。

这也解释了为什么RR级别下删除、更新操作要尽量走索引。如果更新条件没有合适的索引,InnoDB会对聚簇索引上的所有记录都加上Next-Key Lock,相当于把整张表锁住,并发性能瞬间塌方。在线上环境,我见过不止一次因为UPDATE语句没走索引,RR级别下直接把一张热点表锁出告警的情况。

4. 实操篇:查看、设置与常见问题排查

4.1 查看与修改隔离级别

动手之前,先知道你的MySQL当前处于什么隔离级别。MySQL 5.7及以下用tx_isolation变量,MySQL 8.0改名为transaction_isolation,执行下面的SQL就能看到:

-- MySQL 8.0 SELECT @@transaction_isolation; -- MySQL 5.7及以下 SELECT @@tx_isolation;

设置隔离级别有几种粒度,我建议你区分清楚再动手。第一种是全局设置,影响之后新建的所有连接:

SET GLOBAL transaction_isolation = 'READ-COMMITTED';

第二种是会话设置,只影响当前连接:

SET SESSION transaction_isolation = 'READ-COMMITTED';

第三种是在事务内临时指定,只对当前事务生效:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

注意MySQL的参数值写法,中间是连字符:READ-UNCOMMITTED、READ-COMMITTED、REPEATABLE-READ、SERIALIZABLE,不是下划线。我最初调试时在这里踩过坑,写成了下划线,MySQL直接报语法错误。

还有一个容易误判的点:SET GLOBAL只对新连接生效,已经存在的连接池连接不会自动刷新。你改完全局配置,发现应用还是老行为,十有八九是因为连接池里存的还是旧会话。

4.2 用两个会话复现隔离级别差异

理论知识说再多,不如自己动手跑一遍。我建议你开两个MySQL终端窗口,分别代表事务A和事务B,按下面的步骤操作,能直观看到不同隔离级别下的表现差异。

先把两个会话都设为同一个隔离级别,这里以READ COMMITTED为例:

-- 两个会话都执行 SET SESSION transaction_isolation = 'READ-COMMITTED';

窗口A执行:

START TRANSACTION; SELECT amount FROM orders WHERE id = 1;

此时窗口B执行:

START TRANSACTION; UPDATE orders SET amount = 250.00 WHERE id = 1; COMMIT;

回到窗口A再执行一次查询:

SELECT amount FROM orders WHERE id = 1;

你会发现第二次查到的金额变了,这就是READ COMMITTED下不可重复读的直观体现。然后你把两个会话都改成REPEATABLE READ,重新执行一模一样的步骤,第二次查询得到的还是第一次查到的值,这就是RR的快照复用效果。

这个实验我强烈建议你亲手做一遍,因为它能帮你把“隔离级别配置”和“事务内查询结果变化”之间的关系建立成直觉。以后遇到数据不一致问题,第一反应就是去验证隔离级别和事务边界,而不是瞎改代码。

4.3 我踩过的几个隔离级别相关的坑

第一个坑:连接池连接复用导致“隔离级别不生效”。我曾经把全局隔离级别改成RC,应用层代码确认没设置过会话级别,但线上查询行为还是RR。查了半天,最后一想,连接池在初始化时建立了一堆连接,这些连接的隔离级别还是老值,应用重启后才统一生效。所以改完配置记得滚动重启应用,或者让连接池主动刷新连接。

第二个坑:RR级别下高并发更新导致死锁。有一回压测一个积分兑换接口,并发一上来就频繁报死锁错误。排查的时候先SHOW ENGINE INNODB STATUS\G,在输出里找到LATEST DETECTED DEADLOCK这段,能清楚看到两个事务各自持有哪把锁、又在等待哪把锁。根因是两个事务以不同顺序更新同一批商家记录,RR下间隙锁互相咬死。后来统一了更新顺序,并把隔离级别降到RC,死锁基本消失。

第三个坑:长事务把undo log撑爆。MVCC要求数据版本链保留老版本,以便RR级别下的老快照还能读取。如果一个事务长时间不提交,它持有的Read View会阻止InnoDB清理旧版本数据,undo log会一直膨胀。我在一个报表系统里见过某条定时任务的事务跑了半个多小时,直接把undo表空间撑到几十GB。解决办法是拆分长事务,尽量做到事务短平快,提交完毕再处理其他逻辑。

第四个坑:隔离级别和主从复制格式的配合。RC级别虽然香,但binlog如果是statement格式,主从复制有可能因为执行上下文不同产生不一致。我自己的惯例是使用RC时,把binlog_format设置为ROW或MIXED,这样复制安全性和排查粒度都有保障。

再补充一个排查技巧:如果怀疑某个SQL执行时的锁等待太久,可以直接看performance_schema.data_lock_waits表,它能反映当前锁等待关系,比死等应用超时日志高效得多。这个表在MySQL 8.0里很好用,值得列入你的排查工具箱。

隔离级别不是越高越保险,也不是越低越快。它本质上是一次取舍,而取舍的前提是你能看清每种级别背后的行为边界和实现代价。MySQL默认给你REPEATABLE READ是有历史渊源的,多数场景下不用动它;但如果你业务并发上来了,被间隙锁折磨得苦不堪言,业务又能接受“每次查询看到最新已提交数据”,那转到READ COMMITTED也是完全合理的方案。关键不在于抄别人用什么,而是你自己能判断出问题出在锁、快照还是事务边界。把这套逻辑吃透,下次再遇到诡异的数据不一致,你就不会急着改配置了,而是会自然地去看版本链、锁等待和事务边界。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询