news 2026/10/10 12:51:53

MySQL事务隔离级别实战:脏读、不可重复读、幻读复现与锁机制解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL事务隔离级别实战:脏读、不可重复读、幻读复现与锁机制解析

事务隔离级别这个概念,面试里常问,但真正在数据库里亲手复现过三种并发问题的人并不多。我见过不少同事能准确背出四种隔离级别的名字,一遇到线上“这个事务读到的东西怎么跟预期不一样”就抓瞎。这篇文章直接从实战入手,把脏读、不可重复读、幻读一个个在现场复现出来,顺带把MVCC、间隙锁这些底层机制讲明白,然后再聊聊生产环境到底该用哪种隔离级别、锁超时和死锁怎么排查。环境以MySQL 8.0为主,5.7用户照样能照着操作,只要开了两个命令行会话,手上有数据库就能跟着做。

说明一点:下面的所有实验都在同一台MySQL实例上用两个会话窗口模拟并发,不需要额外的测试工具。我会把每个会话里敲的命令完整贴出来,你照着执行就行。

1. 为什么绕不开事务隔离级别

1.1 并发事务的三大隐患:脏读、不可重复读、幻读

先说结论:隔离级别不是DBA拍脑袋定出来的规则,它是用来处理“多个事务同时读写同一批数据”时必然出现的三类问题而设计的。

脏读最直观。事务A改了数据但还没提交,事务B就查到了这笔未提交的修改。如果事务A后来回滚了,事务B读到的就是一个现实里从未存在过的值。想象一下两个人合伙管账,甲往账上记了一笔收入还没确认,乙已经拿着这个数去做报表了,甲后来发现记错了又删掉,报表自然就废了。

不可重复读,指的是同一个事务内,两次读取同一行数据,结果却不一样。比如你在一个事务里先查余额是1000元,处理了一些业务逻辑后再次查询,余额变成了800元,因为另一个事务在这期间修改并提交了。最头疼的是,你基于第一次查询做的判断可能已经落库了,前后数据对不上。

幻读比不可重复读更进一步。不可重复读是同一行的值变了,幻读是查询结果的行数变了。第一次查出10条记录,第二次查出11条,多出来的那条“凭空出现”的数据就是幻影行。这类问题在处理范围数据、统计汇总时杀伤力特别大。

1.2 四种隔离级别横向对比

SQL标准按强度从低到高定义了四种隔离级别,它们能解决的问题、会遗留的问题不一样:

隔离级别脏读不可重复读幻读底层实现基础
READ UNCOMMITTED(读未提交)可能可能可能基本无隔离,读最新未提交版本
READ COMMITTED(读已提交)不可能可能可能每次查询生成新的快照
REPEATABLE READ(可重复读)不可能不可能InnoDB下基本不可能事务首次读创建快照,配合间隙锁
SERIALIZABLE(串行化)不可能不可能不可能所有读操作加共享锁

注意一个细节:SQL标准里REPEATABLE READ允许幻读,MySQL的InnoDB存储引擎通过MVCC和Next-Key Lock把这个缺口补上了,所以在绝大多数场景下InnoDB的RR级别并不会出现幻读。这一点是MySQL容易被误解的地方,后面实操里我会专门验证。

1.3 为什么MySQL默认是REPEATABLE READ

Oracle、PostgreSQL这些数据库默认的是READ COMMITTED,MySQL偏偏默认REPEATABLE READ,很多刚接触MySQL的人都不理解。

这个问题确实有历史原因。早期MySQL的binlog主流格式是STATEMENT,记录的是执行的SQL语句本身。主从复制时,从库要重放这些SQL。如果主库在READ COMMITTED级别下执行一个事务,事务内前后多次查询的结果可能不一致,而基于语句的复制会把这种不一致原样带到从库,造成主从数据对不上。REPEATABLE READ能保证事务内每次读取看到的是同一份快照,配合基于语句的复制才安全。后来ROW格式的binlog普及了,从库不再依赖SQL语义,READ COMMITTED的短板被补上,但MySQL默认值一直没有改。

所以你会看到很多互联网团队会主动把隔离级别改成READ COMMITTED,不是他们不认可RR,而是RR的间隙锁机制在部分高并发场景下更容易引发死锁,换RC加上ROW格式binlog既安全又省心。这个选型权衡我会在最后一章详细说。

2. 隔离级别的底层支撑机制

2.1 MVCC快照读:读不加锁的核心

InnoDB实现READ COMMITTED和REPEATABLE READ靠的是MVCC,全称多版本并发控制。简单说,每行数据在更新时不会直接覆盖旧值,而是通过undo log把旧版本链保存下来,读操作可以选择读哪个版本。

每行记录有两个隐藏字段和一个回滚指针:最近修改这行的事务ID(DB_TRX_ID)、指向旧版本的回滚指针(DB_ROLL_PTR),以及一个自增的DB_ROW_ID。当你执行一条普通的SELECT,InnoDB会生成一个“一致性视图”,业界也叫Read View。视图里记录了当前有哪些活跃事务。

判断一条数据版本是否可见,就按下面四条规则来:

  1. 如果这一行的事务ID等于当前事务自己的ID,说明是自己改的,必须可见。
  2. 如果事务ID小于视图里最小活跃事务ID,说明这个版本在视图创建前已经提交,可见。
  3. 如果事务ID大于等于视图创建时最大的事务ID,说明这行是视图创建之后才被改的,不可见。
  4. 如果事务ID落在中间区间,要判断它是否还存在于活跃事务列表里。在列表里说明还没提交,不可见;不在列表里说明已经提交,可见。

这套规则理解透了,就能明白两种隔离级别的差异:READ COMMITTED每次执行SELECT都会新建一个视图,所以能看到其他事务新提交的修改;REPEATABLE READ只在事务第一次执行SELECT时创建视图,之后整个事务都用同一个视图,后提交的数据自然看不到了。

2.2 行锁三兄弟:Record Lock、Gap Lock、Next-Key Lock

MVCC只解决普通查询的隔离问题,UPDATE、DELETE、SELECT FOR UPDATE这类“当前读”必须读到最新数据并加锁,否则并发写就会乱套。InnoDB的行锁不是铁板一块,它根据锁定的范围分了三种:

Record Lock是记录锁,直接锁住索引上的一行。锁的是索引记录,不是数据行本身,这是理解InnoDB锁的前提,即使表上没有显式索引,InnoDB也会通过隐藏的主键索引来完成锁定。

Gap Lock是间隙锁,锁的是索引记录之间的“空隙”,目的是阻止其他事务在某个区间内插入新记录。注意间隙锁不锁记录本身,只锁“中间没有东西”的那段空间。

Next-Key Lock是前两者的组合,左开右闭区间,比如锁住(10, 20]这个范围,既锁了20这个记录,又不让其他事务在10到20之间插入任何数据。正是这种组合锁,让REPEATABLE READ下的当前读能防住幻读。

把隔离级别和锁对应起来:READ COMMITTED下普通语句只加Record Lock,不加Gap Lock,并发度更高;REPEATABLE READ下走的是Next-Key Lock,间隙也被锁住,并发度自然下降,但换来的是更强的一致性保证。脏读则是因为READ UNCOMMITTED连写都不太讲究,读操作直接读最新版本。

3. 环境准备与全场景复现

3.1 搭建测试环境

先建一张账户表,模拟最简单的转账场景:

CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0 ) ENGINE=InnoDB; INSERT INTO account (name, balance) VALUES ('张三', 1000), ('李四', 1000);

然后确认当前隔离级别:

SELECT @@transaction_isolation;

MySQL 8.0输出一般是:

+-------------------------+ | @@transaction_isolation | +-------------------------+ | REPEATABLE-READ | +-------------------------+

5.7及更早的版本变量名是@@tx_isolation,如果习惯用SHOW VARIABLES,可以写SHOW VARIABLES LIKE 'transaction_isolation';。

开启两个会话窗口,我这里统一叫会话A和会话B。每个实验我都会明确告诉你在哪个窗口执行。所有会话都保持默认的autocommit=1,我们用显式的START TRANSACTION控制事务。

3.2 READ UNCOMMITTED 下的脏读复现

会话A先切到读未提交:

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT @@transaction_isolation;

然后开启事务并查询张三的余额:

START TRANSACTION; SELECT balance FROM account WHERE id = 1; -- 结果:1000.00

此时切到会话B,开启一个事务,把张三余额减200,但注意不提交:

START TRANSACTION; UPDATE account SET balance = balance - 200 WHERE id = 1;

回到会话A再查一次:

SELECT balance FROM account WHERE id = 1; -- 结果:800.00

看到了吗?会话B的数据根本没有提交,会话A却读到了“已经修改后的800元”。这就是脏读。

接着让会话B回滚:

ROLLBACK;

再回到会话A查询,余额又变回1000元。如果你这时候已经基于800元做了后续业务处理,数据就全乱了。READ UNCOMMITTED就是把“读最新版本”贯彻到底,完全放弃隔离,实际上在业务里基本没人敢用。

3.3 READ COMMITTED 的不可重复读复现

把会话A的隔离级别改成已提交读:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

会话A开启事务,先查张三余额:

START TRANSACTION; SELECT balance FROM account WHERE id = 1; -- 结果:1000.00

切换到会话B,直接更新并提交:

START TRANSACTION; UPDATE account SET balance = 800 WHERE id = 1; COMMIT;

回到会话A的同一个事务里再查一次:

SELECT balance FROM account WHERE id = 1; -- 结果:800.00

同一个事务里,两次查询结果不一致,这是标准的不可重复读。为什么?因为READ COMMITTED在每条SELECT语句执行时都重新生成一份视图,B提交后新的视图能看到最新提交状态,所以A读到了新值。

3.4 REPEATABLE READ 如何压制不可重复读与幻读

把会话A切回默认的可重复读:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

再走一遍刚才的流程。会话A开启事务查询,余额是1000;会话B把余额改成800并提交;会话A再查,看到的结果仍然是1000。这个级别的关键点在于,事务第一次执行SELECT时就把视图固定住了,后续所有读取都基于这份“照片”,不管外面发生了什么。

更有意思的是,可重复读对幻读的处理。我们来试一个普通快照读的幻读场景。

会话A开启事务,执行一个范围查询:

START TRANSACTION; SELECT * FROM account WHERE id BETWEEN 1 AND 5; -- 结果:1、2 两行

会话B插入一条新记录并提交:

START TRANSACTION; INSERT INTO account (name, balance) VALUES ('王五', 500); COMMIT;

会话A再执行同样的查询:

SELECT * FROM account WHERE id BETWEEN 1 AND 5; -- 结果:仍然是 1、2 两行

普通SELECT看不到新插入的“王五”,这是MVCC快照隔离的效果。但如果我们改用当前读,情况又不一样了。会话A执行:

SELECT * FROM account WHERE id BETWEEN 1 AND 5 FOR UPDATE;

此时InnoDB会对id范围为(1, 5)的间隙加上Next-Key Lock。会话B的插入操作会被阻塞,直到会话A提交或回滚后才会执行。这就是REPEATABLE READ防止幻读的另一只手:快照读靠MVCC,当前读靠间隙锁。两者配合,InnoDB才能把SQL标准里允许幻读的空子堵住。

3.5 SERIALIZABLE 的效果验证

串行化是最强的隔离级别,代价也最大。它会把这些普通SELECT都隐式转成加共享锁的当前读,读和读不冲突,但读和写之间完全互斥。

把会话A和会话B都切到串行化:

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

会话A开启事务并执行查询:

START TRANSACTION; SELECT * FROM account WHERE id = 1;

此时会话B想更新张三的余额:

START TRANSACTION; UPDATE account SET balance = 900 WHERE id = 1;

这条UPDATE会一直卡住,直到会话A执行COMMIT或ROLLBACK释放共享锁,B的更新才能继续。串行化把并发降成了真正的串行执行,虽然数据一致性最强,但整体吞吐量会直线下降,生产环境里除了极少数强一致场景,我不会推荐它。

4. 实战中的常见问题与排查方案

4.1 锁等待超时:SQL明明很慢却报1205

线上最常见的报错之一是Lock wait timeout exceeded; try restarting transaction,错误码1205。含义是一条语句等锁的时间超过了锁等待阈值,默认50秒,配置项是innodb_lock_wait_timeout。

排查这类问题,先看当前有哪些事务在跑:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.INNODB_TRX;

trx_state为RUNNING但持续了很久的事务往往是元凶,记下trx_mysql_thread_id,这个对应的其实是MySQL的连接线程ID,可以在performance_schema.threads里找到对应的PROCESSLIST_ID,再进一步在sys.schema_table_lock_waits或performance_schema.data_lock_waits里定位阻塞关系。

快速止血的办法是找出还挂着的会话连接,把自己确认无用的长事务会话杀掉。但要小心,直接KILL连接可能让未提交的事务回滚,操作前必须确认是不是业务上可以放弃的那个会话。真正的根治思路是查业务代码,看是不是有人在事务里干了太多耗时间的活,比如远程调用、循环更新、大批量导入,这些都会拉长持锁时间。

4.2 死锁分析:information_schema 怎么用

死锁是RR隔离级别下最容易遇到的事。经典场景是两个事务按相反顺序更新同一批记录。

会话A执行:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 然后再去更新 id=2 UPDATE account SET balance = balance + 100 WHERE id = 2;

会话B同时执行:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 2; -- 然后再去更新 id=1 UPDATE account SET balance = balance + 100 WHERE id = 1;

两个事务各自持有了一行的锁,又都在等对方释放另一行的锁,谁都不让,死锁就形成了。InnoDB检测到死锁后,会主动回滚其中一个事务,让另一个继续执行,所以你会看到某个事务报错Deadlock found when trying to get lock; try restarting transaction。

查看死锁现场的方式:

SHOW ENGINE INNODB STATUS\G

重点关注输出里的LATEST DETECTED DEADLOCK段,里面会列出两个事务各自执行的SQL、持有的锁、等待的锁。我处理过的多数死锁都是业务代码里更新多行时顺序不固定造成的,解决办法也简单:所有事务都按同一个顺序更新,比如先更新id小的,再更新id大的,或者把多行更新拆成更短的事务。

4.3 长事务与主从延迟

长事务的危害不是一时半会能看出来的。只要事务不结束,InnoDB就要保留这个事务开始前的undo日志版本链,历史版本清理不掉,回滚段越撑越大。如果主从复制用的是从库并行复制,主库上一个长时间未提交的事务还会阻塞binlog的某些清理动作,甚至放大主从延迟。

排查长事务可以直接看INNODB_TRX里的事务启动时间:

SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_seconds, trx_state, trx_mysql_thread_id FROM information_schema.INNODB_TRX;

如果run_seconds超过几十秒,就要警惕了。我踩过最深的一个坑是业务代码在方法入口用注解开事务,方法里异常被捕获后没有抛出,事务一直挂到超时,结果整个表的更新都被堵住。后来我们给事务方法加了个严格的try-catch-finally,异常路径必须回滚,finally里再确认一次事务状态,这个坑才算真正堵上。

4.4 隔离级别的正确切换姿势

隔离级别可以不重启MySQL动态修改,但理解生效范围很重要。语法分三种:

-- 只影响当前会话 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 影响之后新建的所有会话,已存在的会话不变 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 只影响当前事务中尚未开始的下一事务 SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

注意,如果当前会话已经在一个事务里,执行SET SESSION不会影响正在运行的事务,只对后续事务生效。生产环境要改全局建议走配置文件,比如my.cnf里加:

[mysqld] transaction-isolation = READ-COMMITTED

这样重启后保持稳定,不会因为一个同事临时改了全局变量导致所有连接行为变化。

5. 进阶思考与性能调优

5.1 为什么很多项目改用READ COMMITTED

我在一线团队的实战里看到过一个规律:新项目落库时,架构评审经常会把默认的REPEATABLE READ显式改成READ COMMITTED。背后原因不是RR不好,而是RC在特定业务下更省心。

首先,RC只使用记录锁,不使用间隙锁,锁的范围更小,高并发下等锁和死锁的概率明显下降。其次,RC语义更简单直接:每个语句都能读到已提交的最新数据,排查问题时不用反复琢磨“这个事务的快照到底是什么时候建立的”。最后,RC配合ROW格式的binlog在复制一致性上已经没问题,等于没有历史包袱。

但RC的代价也很明显:事务内两次读可能结果不同,业务代码里如果依赖“先查后算再落库”的模式,就必须自己处理数据变化。我的建议是,拿不准就先用默认的RR,除非你能说出RC带来的具体收益,否则不要为了“跟风”去改隔离级别。

5.2 隔离级别与binlog、主从复制的关系

隔离级别和复制的关系是很多人在生产环境栽跟头的地方。早期基于SQL语句的复制对隔离级别很敏感,REPEATABLE READ能确保事务里的查询结果稳定,基于语句复制才能得到与主库一致的结果。现在8.0默认的binlog格式是ROW,记录的是每一行数据的前后镜像,主从一致性不再依赖隔离级别,这也是RC能被广泛使用的基础。

想确认当前binlog格式可以执行:

SHOW VARIABLES LIKE 'binlog_format';

如果业务用了基于语句的格式,又想把隔离级别改成RC,必须评估所有涉及读写的事务是否会产生复制不一致。稳妥的做法是先把binlog切到ROW,再动隔离级别。顺序反了很容易出大事。

5.3 高并发下的选型建议

高并发场景下隔离级别的选择不是孤立的,要跟整个事务设计打组合拳:

  1. 事务能短则短。事务时间越短,持锁时间越短,锁冲突概率越小,这个收益比纠结用RR还是RC大得多。
  2. 让UPDATE和DELETE尽量走索引。InnoDB锁的是索引记录,如果更新语句没走索引,扫描到多少行就锁多少行,极端情况下会升级成大量行锁,严重拖垮并发。
  3. 控制热点行更新。像库存扣减这种高并发写同一行的场景,无论什么隔离级别都会遇到锁竞争,合理方案是异步排队、分批处理,而不是让所有请求都卡在行锁上。
  4. 读多写少且允许一定延迟的报表查询,优先用RR级别的普通SELECT,靠MVCC快照读避免加锁,完全不影响写入。

有人会问,高并发是不是直接上SERIALIZABLE最保险?我的看法是,SERIALIZABLE对绝大多数业务是负优化,它彻底牺牲了并发能力。数据库层面能接受的底线一般是RC或RR,更强的约束应该在业务代码里实现,而不是让数据库把所有操作串行化。

6. 实践中的个人体会

折腾了几年MySQL,最大的感受是隔离级别这东西光背概念永远学不会,一定要亲手把脏读、不可重复读、幻读在线上一一“造”出来,你才会真正理解MVCC和锁为什么存在。我早期带新人的时候,总会让他们先搭一个双会话环境,把本文的实验完整跑一遍,再回来看线上死锁日志,效果比讲十页PPT都好。

最后分享一个能救命的小习惯:上线前把生产环境每个库的隔离级别、binlog格式、锁等待超时时间都列成清单,跟业务负责人确认一遍。大多数线上事故都不是因为某个机制设计得不够好,而是团队里根本没人知道当前环境是哪种隔离级别、事务里跑了多久的SQL。把这两件事搞清楚,你已经能躲开不少并发大坑了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 12:51:52

分页查询原理与优化:从LIMIT/OFFSET到游标分页的实践指南

干后台开发这些年,“分页查询”大概是写过的最高频的一类SQL,需求听起来也永远很简单:列表接口返回前N条,前端点下一页再取N条。我第一次接触分页时,也觉得这是最没有技术含量的活,直到线上一个千万级的流水…

作者头像 李华
网站建设 2026/10/10 12:50:42

从零开发理发店会员管理系统:数据库设计与业务闭环实战

去年夏天我第一次去朋友的理发店帮忙看店,就撞上了最尴尬的一幕:一个老顾客进门问“我卡里还剩多少钱”,收银的小姑娘翻开一本硬壳笔记本,翻了三页报出一个数字,顾客摇头说不对,她又翻到前面重新加了一遍&a…

作者头像 李华
网站建设 2026/10/10 12:49:20

RSMA速率拆分原理与MATLAB/Python仿真实操指南

简介:本资源是一套面向通信工程专业高年级本科生、研究生及5G/6G系统研发工程师的RSMA(速率拆分多址接入)仿真代码包,聚焦有限反馈场景下MMSE预编码与速率拆分策略的联合实现,解决多用户MIMO系统中因CSI不完美导致的干…

作者头像 李华
网站建设 2026/10/10 12:45:00

Hadoop集群部署与MapReduce开发:从环境配置到数据倾斜实战

简介:大数据入门学习者和需要搭建开发环境的开发者,可借助该doc文档快速完成Hadoop集群部署与MapReduce开发的完整流程。内容从VM虚拟机中安装Ubuntu Kylin系统开始,依次覆盖SSH免密登录、Java环境、Hadoop安装、集群网络与分布式配置&#x…

作者头像 李华
网站建设 2026/10/10 12:44:03

通信原理习题答案全集:从熵到香农公式的刷题避坑指南

简介:本资源为李晓峰《通信原理》教材的习题答案全集,面向通信工程、电子信息类专业本科生及考研备考者,用于课后练习核对与期末、考研复习阶段的查漏补缺。压缩包内共1个PDF文件,大小约2.14MB,内容按章节顺序编排&…

作者头像 李华
网站建设 2026/10/10 12:42:19

华为USG防火墙序列号查询:display esn命令与运维实战

做网络设备运维这些年,华为USG防火墙的出镜率一直很高。不管是企业园区出口、分支机构边界,还是数据中心的业务区域,总能见到它的身影。而只要设备上了资产清单,就绕不开一个问题——查序列号。无论你是要做维保续期、提故障单、核…

作者头像 李华