news 2026/10/14 17:51:12

MySQL事务隔离级别详解:从脏读到MVCC与锁机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL事务隔离级别详解:从脏读到MVCC与锁机制

MySQL事务隔离级别

1. 为什么要聊事务隔离级别

做后端开发的朋友,大概率都遇到过这种场景:一个订单在扣库存、在改余额、在写日志,多个请求几乎同时进来,结果数据对不上。查了一圈,配置没问题、SQL没问题、索引没问题,最后把所有怀疑的目光投向一个词——事务隔离级别。

MySQL事务隔离级别,本质上就是数据库在面对多个并发事务时,愿意让它们之间“互相干扰”到什么程度的规则。它决定了你读到的是别人已经提交的数据、正在处理还没提交的数据,还是自己事务开始时的历史快照。很多刚入门的朋友把隔离级别当成纯理论概念,背四个名字、背三个问题就完事了,实际遇到线上问题时完全不知道从哪下手。这篇文章想把这件事讲透:隔离级别到底解决什么问题、MySQL是怎么实现的、不同场景该怎么选,以及我在实际运维中踩过的那些坑。

如果你是刚学MySQL的小白,这篇文章能帮你建立清晰的并发模型认知;如果你已经工作过一段时间,我建议重点看第4节和第5节,那部分是我在真实业务环境里反复试验、排查后总结出来的心得,平时公司文档里不会写这些。

2. 四种隔离级别逐个拆解

2.1 并发事务的三大“脏问题”

在正式介绍隔离级别之前,必须先弄清楚隔离级别要解决的“敌人”是什么。并发事务互相干扰,主要会产生三类问题:

脏读:事务A修改了一条数据还没提交,事务B去读,读到了这个“修改中”的值。如果A回滚了,B就相当于读了一条根本不存在的数据。类比一下:你看到一个同事在文档里改了方案,还没最终确认,你立刻按新方案去执行了,结果同事说改错了我撤销了。

不可重复读:同一个事务里,执行同一条SELECT语句两次,结果不一样。原因是有其他事务在这期间提交了修改。类比:第一次查余额是100块,第二次查余额变成200块,同一个事务里看到不同结果,数据就不“稳”了。

幻读:同一个事务里执行两次范围查询,第二次多出了一些原本不存在的行。不可重复读是“同一行内容变了”,幻读是“行数变了、凭空多出几行”。这个问题的处理难度比前两者大得多,后面会详细展开。

还有一类问题叫丢失更新,即两个事务同时读同一个值,各自修改后提交,后提交的覆盖了先提交的。严格来说,丢失更新在部分隔离级别下不能被完全解决,要靠额外的锁或乐观锁机制处理,这一点在面试时经常被追问,需要留意。

2.2 READ UNCOMMITTED:裸奔等级

这是最低的隔离级别。事务可以读取其他事务尚未提交的数据,也就是脏读在这个级别下是被允许的。

实际生产环境里几乎没人用这个级别,因为它的“收益”和“代价”严重不对等。它能带来最好的写入并发性能,但代价是你根本不知道读到的数据是真是假。我见过有开发者在统计类报表里图省事用了这个级别,结果报表数字在业务高峰期来回跳动,最后被运营同事投诉了好几次。

什么时候勉强可用?极端情况下对大表做全量统计、允许结果不精确的场景,比如某些数据仓库的预处理任务。但即便这样,我依然建议用READ COMMITTED替代,差别没有想象中那么大。

2.3 READ COMMITTED:提交后我才认

这个级别解决了脏读问题。事务只能读到其他事务已经提交的数据。也就是说,别人改了没提交,你读不到;一旦提交了,你立刻就能读到。

听起来很合理对吧?但它有新的问题:如果你在同一个事务里连续执行两次SELECT,第一次读到的是旧值,第二次读到了其他事务提交的新值,这就产生了不可重复读。

READ COMMITTED在Oracle、PostgreSQL等数据库里是默认级别,但在MySQL里不是。它的优势在于兼顾了并发性能和一定的数据一致性,很多互联网公司在把并发压力做得很大之后,会把隔离级别从默认的REPEATABLE READ调整到READ COMMITTED,来降低锁竞争、减少死锁概率。这个调整在业务允许“读已提交数据”的场景下收益非常明显,也是我后面第5节要展开的重点。

2.4 REPEATABLE READ:MySQL的默认防线

REPEATABLE READ是MySQL的默认隔离级别。它保证在同一个事务里,多次读取同一份数据,结果一定是一致的,解决了不可重复读问题。

MySQL官方文档里有一句很重要的话:在InnoDB存储引擎下,REPEATABLE READ实际上也解决了绝大部分幻读问题。这一点和其他数据库很不一样,很多同学在Oracle或PostgreSQL上的经验搬到这里会出错。为什么MySQL能解决?因为这个级别不是靠“读的时候锁住整个范围”实现的,而是靠MVCC多版本并发控制实现的——每个事务拿到的是启动时刻的数据快照,后面别人改了、插入了,你都看不到,自然就不存在“前后不一致”了。

但“绝大部分”不是“全部”。在某些写场景下,幻读隐患依然存在,具体会在第4节实验里演示。

2.5 SERIALIZABLE:串行化,最重的代价

最高隔离级别,事务之间完全串行执行,相当于给所有读写操作都加了锁,谁也别插队。在这种级别下,脏读、不可重复读、幻读全部消失,但同时并发能力极端底下。

这个级别几乎不会被用于OLTP在线交易业务,更常见于某些对一致性要求极端敏感的场景,比如金融账户的极少数核心操作,或者跨系统的分布式事务协调。大部分业务为了极致一致性使用这个级别,结果就是数据库连接堆积、请求排队,体验反而更差。

四者对比一下:

隔离级别脏读不可重复读幻读并发性能
READ UNCOMMITTED可能可能可能最高
READ COMMITTED不会可能可能高
REPEATABLE READ不会不会基本不会中高
SERIALIZABLE不会不会不会极低

3. MySQL是怎么做到隔离的:MVCC与锁

3.1 MVCC的核心思路:不打码,复制一份给你看

很多新手不理解为什么REPEATABLE READ能保证多次读取一致,却还能让其他事务并发修改。我给一个生活化解释:想象你在看一份共享文档,后台系统在文档每次被提交修改时自动拍了一张快照,而你的事务启动时,系统把那一刻的快照指给你看。你手里拿着的是那个时刻的“复印版”,别人在原件上怎么改,都不影响你手里的复印件。这就是MVCC,多版本并发控制。

MVCC让读操作不需要加锁(不加锁的普通SELECT被称为快照读),从而大幅提升并发性能。这是InnoDB区别于早年数据库全靠锁来实现隔离的关键设计。

3.2 undo log与ReadView的工作机制

MVCC能成立,依赖两样东西:undo log和ReadView。

在InnoDB里,每行数据除了业务字段外,还隐藏着两个重要字段:trx_id(最近修改该行的事务ID)和roll_pointer(指向该行历史版本在undo log中的位置)。当事务修改某一行时,InnoDB并不会直接覆盖旧值,而是把修改前的旧记录写入undo log,然后更新数据行,再把roll_pointer指向刚写入的旧记录。这样同一行数据就在逻辑上形成了多个版本链条,像一根串起来的糖葫芦。

ReadView则是一个“视线过滤器”。事务进行快照读时,系统会生成一份ReadView,里面记录了当时所有活跃事务(还没提交的事务)的ID列表。判断某一行能否被读到,规则是这样的:如果该行的事务ID小于ReadView里的最小活跃事务ID,说明这个修改发生在快照之前,可见;如果该行的事务ID大于等于ReadView里的最大事务ID,说明是快照之后才改的,不可见;如果落在活跃列表里,也不可见。这个过程让事务“看见自己该看见的版本”。

对于REPEATABLE READ,同一个事务只在第一次快照读时生成ReadView,后续所有读取都复用这一个视图,这就保证了多次读的结果始终一致。而READ COMMITTED每次SELECT都会生成新的ReadView,所以它能看到其他事务在两次SELECT之间提交的修改,这也是两种级别在实现层面的根本差异。

3.3 锁机制:共享锁、排他锁、间隙锁与next-key lock

MVCC解决了快照读的隔离问题,但写入操作互斥的问题还得靠锁。

锁大致分两类:共享锁(读锁,多个事务可以同时持有,互不阻塞)和排他锁(写锁,同一资源同一时刻只允许一个事务持有)。普通的SELECT就是不加锁的快照读,但如果用了SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE,就走加锁读模式,读的是当前最新已提交版本,同时给记录加锁。

在REPEATABLE READ级别下,InnoDB还有一个杀手锏叫next-key lock,它是记录锁和间隙锁的组合。间隙锁锁住的是一个范围区间而不是具体某行记录,它能阻止其他事务在这个区间内插入新行。这正是一开始说到的幻读的解决办法。执行一个范围查询时,比如WHERE id BETWEEN 10 AND 20,InnoDB不仅锁住命中的行,还会锁住这个范围附近不存在的“空隙”,其他事务要想往这个空隙里插入数据就得排队等。

这套机制就是InnoDB在REPEATABLE READ下能阻绝大多数幻读的原因。而READ COMMITTED级别下,类似锁只对已存在的行生效,间隙锁被弱化,所以幻读发生的可能性会高一些。

3.4 一个事务执行过程的全景速览

把流程串起来看:事务A修改一行数据时,先在当前行上加排他锁,同时把旧版本写入undo log,更新trx_id;事务B此时执行快照读,通过ReadView判断不能直接读取最新版,就顺着undo log版本链找到合适的历史版本返回。此时两个事务一个在改,一个在读,互不阻塞,真正做到了读写分离、各取所需。

数据更新时的行锁冲突处理、日志写入、binlog与redo log的一致性问题,单独拿出来可以再写几篇文章。这里先给个总览,实际生产环境那么复杂,绝大部分并发问题都能在这套机制里找到原因。

4. 实操验证:亲手做一次并发事务实验

4.1 建表与初始化数据

理论说再多不落地都是空的。下面我带你完整跑一遍实验,直观看清楚隔离级别到底怎么影响行为。

先建一张极其简单的账户表,只有id和balance两个字段:

CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, balance INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO account (id, balance) VALUES (1, 100), (2, 200); SELECT * FROM account;

建议开启两个MySQL客户端会话窗口,一个模拟事务A,一个模拟事务B。先确认当前隔离级别:

SELECT @@transaction_isolation;

MySQL 8.0之后用transaction_isolation,5.7及更早版本用tx_isolation。如果显示REPEATABLE-READ,就是默认状态。

4.2 实验一:验证REPEATABLE READ下的不可重复读被阻

在会话A中开启事务并读取数据:

-- 会话A BEGIN; SELECT * FROM account WHERE id = 1;

此时能看到balance为100。在会话B中把余额改掉并提交:

-- 会话B BEGIN; UPDATE account SET balance = 50 WHERE id = 1; COMMIT;

现在会话B里已经提交。回到会话A,再执行一次同样的查询:

-- 会话A SELECT * FROM account WHERE id = 1;

结果依然是100。这就是可重复读:同一个事务里,SELECT看到的始终是事务启动时的快照,不受外部提交影响。如果当前把隔离级别改成READ COMMITTED,第二次查询结果就会变成50。改动隔离级别的命令:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

注意这个设置只对当前会话生效,不影响其他连接。想要全局生效需要改配置文件或使用SET GLOBAL,但SET GLOBAL对已有连接也不生效,必须新建连接才会用到新配置。

4.3 实验二:验证脏读是否真的被禁止

把会话A(或一个全新会话)改成:

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN; SELECT * FROM account WHERE id = 2;

在会话B中开启事务,但不要提交,直接修改:

-- 会话B BEGIN; UPDATE account SET balance = 999 WHERE id = 2;

此时在会话A里再次查询:

SELECT * FROM account WHERE id = 2;

只要会话B没提交,你就能读到999这个值,READ UNCOMMITTED级别的脏读现象就这样真实发生了。接着把会话A的级别改回REPEATABLE READ或READ COMMITTED,重新在会话A里开事务,再查询,就看不到999了,看到的还是200。脏读被成功拦截。

4.4 实验三:幻读是怎么“漏网”的

多数人对幻读的理解停留在理论描述上,真正的隐患往往出在写场景。来看一个典型例子。

保持两个会话都在REPEATABLE READ级别。会话A先开启事务,执行范围加锁读:

-- 会话A BEGIN; SELECT * FROM account WHERE id BETWEEN 1 AND 10 FOR UPDATE;

这个查询会尝试锁住id在1到10区间内的记录及间隙。接着在会话B中往这个区间插入一条新记录:

-- 会话B BEGIN; INSERT INTO account (id, balance) VALUES (3, 300);

尝试插入时会话B会阻塞。这就说明next-key lock生效了,阻止了幻读的发生。把它COMMIT掉或者等超时释放,然后再试一种更隐蔽的情况。

假设对一个已经存在的行做更新操作:

-- 会话A 开事务 BEGIN; SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 会话B 执行 UPDATE account SET balance = balance + 10 WHERE id = 1;

会话B会阻塞。因为会话A对id=1的行加了排他锁,会话B的更新必须等A提交或回滚。这个场景本身没问题,但开发时如果有人在REPEATABLE READ下用“先SELECT再UPDATE”做并发控制,会发现两个事务同时读到同一个旧值,然后各自修改,后提交的覆盖先提交的,这就是丢失更新问题。隔离级别解决不了它,只能通过更新时加条件(比如WHERE balance = 旧值)或者用SELECT ... FOR UPDATE提前锁住来规避。

4.5 如何查看死锁和当前锁等待

实验过程中很可能触发锁等待超时,这时想看现场,执行:

SHOW ENGINE INNODB STATUS;

重点看LATEST DETECTED DEADLOCK和TRANSACTIONS两个段落。前者展示最近一次死锁的双方事务及其持有的锁,后者展示当前正在等待锁的事务状态。遇到死锁时,MySQL不会无限等下去,默认innodb_lock_wait_timeout是50秒,超时后自动回滚代价更小的那个事务,报错deadlock found when trying to get lock。

5. 实践经验:隔离级别的选型与避坑

5.1 互联网业务默认怎么选

很多团队面临一个现实问题:线上MySQL到底用默认的REPEATABLE READ,还是改成READ COMMITTED?

我的建议分两种情况。如果你的业务是典型的金融、订单、账户体系,对数据一致性要求极高,尽量保留默认REPEATABLE READ。MySQL在RR下用MVCC解决了读一致问题,又用next-key lock挡掉了插入场景的幻读,留给开发者的坑已经很少了。

但如果你的业务以高并发写入、宽表更新为主,死锁频繁、锁等待严重,隔离级别改成READ COMMITTED往往立竿见影。RC级别下间隙锁被弱化,锁粒度更小,死锁概率显著下降。我手里一个订单中心系统,隔离级别从RR切到RC后,死锁告警从每周十几次直接降到一两次。代价是一致性弱化,需要业务层接受“事务内多次读可能不一致”的事实。

还有一种折中:核心账务库保持RR,外围查询、报表库切RC,两者用同步链路分离。库按用途拆开,比在同一套库里折腾参数要稳健得多。

5.2 不要用隔离级别解决所有问题

写代码时千万不要抱着“我把隔离级别调到最高,就不会有并发bug”的想法。SERIALIZABLE是最安全,但它把所有写操作串行化,性能惨不忍睹,高并发下等于自杀。正确的做法是:在业务逻辑层面设计好并发控制策略。

扣库存的经典场景,很多人写:

UPDATE stock SET count = count - 1 WHERE sku_id = 100;

这条SQL本身是原子的,在高并下发下也不会超卖,因为UPDATE会加行锁。问题则出现在先SELECT再UPDATE的写法,读取和修改之间隔着好几行Java代码,并发窗口巨大。凡涉及余额、库存、积分这类数值变更,宁可一条UPDATE搞定,也不要拆成两步。这个经验我在不少项目里反复强调过,它比折腾隔离级别更根本。

5.3 长事务是隐形杀手

不管用哪种隔离级别,长事务都是数据库健康的大敌。一个事务从BEGIN到COMMIT之间,如果夹杂大量查询和外部调用,那些锁会一直持有着,其他事务被卡在后面排队。更麻烦的是,长事务会让undo log不断膨胀,导致数据库体量异常增长,并且ReadView持续不释放,历史版本一直保留。

排查长事务有一个实用的SQL:

SELECT trx_id, trx_started, trx_rows_modified, trx_state FROM information_schema.innodb_trx ORDER BY trx_started ASC;

trx_started越早隐患越大。实际运维中我经常半夜被告警叫醒,一查就是某个开发任务里的循环代码没提交事务,一条一条改数据,拖了几小时。定位后把那个事务KILL掉,再清理业务代码。

5.4 事务提交姿势会影响锁释放

有次一个同事的接口频繁出现锁等待,排查半天发现他发现一个让人意外的细节:大量请求都用事务包住只读查询,还挂着不提交。其实普通SELECT是快照读,根本不需要显式事务。如果事务里只有SELECT,建议直接别开事务;如果确实是先读后写的业务操作,务必缩短读与写之间的业务逻辑耗时,把外部HTTP调用挪到事务外面,LOCK表的时间越短越好。

事务里不要干这些事:调用远程接口、发消息、等待用户输入。事务内代码执行时间请压缩到毫秒级,否则锁等待只会把数据库拖垮。

6. 常见问题与排查技巧实录

6.1 设置了RC,为什么还是会读到重复内容

有同学反馈说,我把隔离级别改成了READ COMMITTED,为什么同一个事务里连续读同一行数据,两次结果还是相同?这种情况下结论很容易误判。先确认你的修改是否真正生效:RC级别下,每次SELECT都会生成新的ReadView,所以在事务里第二次SELECT时应该能看到其他事务已提交的修改。如果看不到,大概率是当前事务自己也处于未提交状态,InnoDB对自身事务修改后的行会直接可见,这属于设计使然。另外,还存在连接池里的旧连接没有刷新隔离级别的问题,SHOW出来的值不等于实际生效。要善用SELECT @@transaction_isolation确认当前会话值。

6.2 如何快速定位锁等待源头

遇到Lock wait timeout exceeded报错,我有几个固定排查步骤:

先看当前哪些事务正在运行:

SELECT * FROM information_schema.innodb_trx\G

再看哪些事务在等待锁、被谁阻塞:

SELECT * FROM sys.innodb_lock_waits\G

blocking_trx_id和blocking_lock_id能直接告诉我谁在卡住谁。定位之后该回滚回滚,该杀掉杀掉的,把源头事务找出来,往往是一段忘了提交事务的调试代码或一个异常退出没走COMMIT的接口。

6.3 为什么避免了死锁,业务一侧仍在报错

死锁是数据库主动选择了代价最小的受害者并回滚它,靠的是MySQL自身处理机制,但上层应用并不总能接得住这个回滚异常。很多后端代码写SQL时没有对死锁异常做重试,导致用户收到失败请求。解决方案是在业务层对死锁报错做两到三次重试,幂等性好的接口尤其适合。尤其是在批量更新多个表时,多个事务加锁顺序要保持一致,否则死锁概率按几何级数上升。我见过大量死锁,归根到底是两个事务A先锁表1再锁表2,B事务先锁表2再锁表1,互相等待。约定业务代码统一按同一顺序加锁,问题自然消解。

6.4 隔离级别查询与诊断SQL汇总

把平时用得最多的几条SQL整理成一个小清单:

用途SQL
查看当前会话隔离级别SELECT @@transaction_isolation;
查看全局隔离级别SELECT @@global.transaction_isolation;
设置当前会话隔离级别SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
查看活动事务SELECT * FROM information_schema.innodb_trx;
查看锁等待SELECT * FROM sys.innodb_lock_waits;
查看InnoDB状态SHOW ENGINE INNODB STATUS;
查看最大锁等待时间SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

6.5 一个亲历的线上故障复盘

最后分享一个我自己处理过的真实案例,用来把前面所有知识点串起来。

某个旧的积分商城项目,某次大促前网关压测,突然出现严重的数据库锁等待。监控图上Tps断崖式下跌,MySQL的Threads_running飙到两三百。排查发现业务方把所有积分入账操作都包在一个大事务里,事务内还有几个远程优惠校验的HTTP调用,每个用户入账事务平均耗时近2秒。大量用户同时抢积分时,行锁排队直接击穿数据库连接池。

当时的处理分了三步。第一步,紧急把隔离级别从REPEATABLE READ调整为READ COMMITTED,降低锁竞争。第二步,把远程校验调用全部移出事务,事务内只保留数据库写入操作。第三步,把大事务拆成多条小事务,每处理一个用户就提交一次。结果:压测数据从每秒约80笔提升到500笔以上,数据库线程数恢复正常。

这个案例本身并不复杂,难的是定位链路里那些隐蔽的事务边界。数据库层面的指标只告诉你“锁等待严重”,但锁的根源往往在应用代码的事务设计上。

在实际操作中,我逐渐形成了一套固定的检查顺序:遇到并发导致的数据问题,先确认当前隔离级别,再查activity事务,再看锁等待,最后落回业务代码找事务边界。隔离级别是数据库提供的“基础规则”,但真正决定数据一致性的,永远是应用层对并发场景的理解和控制。把这两层弄明白,MySQL并发这块的坑,你基本就能绕开大半了。

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

Apriori算法核心解析:从原理到Python实现与优化

Apriori算法,但凡接触过数据挖掘或者做过推荐系统相关项目的朋友,应该都不陌生。它是关联规则挖掘里最经典的算法,没有之一。很多人学这个算法时的感受是:原理书上一看就懂——"这不就是找频繁项集嘛",可真要…

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

递归求解24点:从四张牌到表达式树的Python实战

24点问题算得上很多人在算法路上的第一个“硬茬子”。四张数字牌,加、减、乘、除任意组合,目标是凑出24,规则简单到一句话能说完,可真想用Python写一个自动求解器,第一反应往往是“把所有括号情况全枚举出来”&#xf…

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

9块9云计算入门课笔记:云服务器选型与高可用架构实战

9块9能买到什么?一杯奶茶,或者三天的云计算入门课程。说实话,我买之前是抱着“看看你怎么忽悠”的心态去的,结果三天课程跟完,发现整个内容把云计算最核心的骨架给搭完了——云服务器怎么选、存储和数据库怎么用、计费…

作者头像 李华
网站建设 2026/10/14 17:49:14

SAP MTO委外加工:移动类型与批次策略的库存转移实战

简介:SAP按单生产委外加工是围绕客户定制需求组织的常见生产模式。围绕这一主题的总结文档专为SAP实施顾问、生产计划及物料管理人员编写,聚焦MTO外包流程中的核心控制点。内容系统性对比了两个典型场景:两层BOM结构下外包自身销售成品&#…

作者头像 李华