多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL事务隔离级别详解:MVCC与锁如何解决并发异常

MySQL事务隔离级别详解:MVCC与锁如何解决并发异常 做了这么多年MySQL我越来越觉得事务隔离级别是一个特别值得花时间吃透的东西。你可能平时只是写完INSERT、UPDATE就结束了根本没有考虑过隔离级别这回事但线上一旦出现事务A查到旧数据、事务B插入数据导致结果集突变、死锁日志显示gap lock冲突这类问题最后追根溯源都会落到隔离级别和底层锁机制上。更何况MySQL的隔离级别几乎是Java后端面试绕不开的必考题从脏读、不可重复读、幻读的区别到为什么MySQL默认用可重复读每一层都能看出候选人到底是背了概念还是真在项目里踩过坑。这篇文章我就把MySQL事务隔离级别的能力边界、MVCC与锁的底层实现、完整的查看设置方式、生产环境选型思路以及面试和实战中容易被忽略的细节一次讲清楚。1. 四个隔离级别的能力边界和三种并发异常1.1 从读未提交到串行化每个级别到底隔离了什么SQL标准定义了四种隔离级别隔离能力从低到高排读未提交READ UNCOMMITTED、读已提交READ COMMITTED、可重复读REPEATABLE READ、串行化SERIALIZABLE。先看每个级别最直观的行为差异。假设订单系统里有一个库存表事务A执行了扣减库存的UPDATE但还没有提交此时事务B去查询这条库存记录在读未提交级别下B能直接读到A尚未提交的那个扣减后数值跟业务上还没定论的数据发生交互非常危险。这个级别平时基本没人会用除了少数做统计、对一致性完全无要求的场景我基本不推荐碰它。在读已提交级别下B只能读到已经提交的数据。如果A还没提交B读到的是旧库存如果A提交了B再查就变成扣减后的新值。这个级别能有效避免脏读也是Oracle、PostgreSQL等数据库的默认级别。在可重复读级别下B在事务内第一次执行查询时相当于给自己拍了一张事务启动时刻的快照这个事务后续所有普通查询都基于这张快照不会受到其他事务提交的影响。所以哪怕A提交了扣减B在这个事务里反复查库存看到的始终是快照里那个旧值。MySQL InnoDB的默认隔离级别就是可重复读。在串行化级别下事务之间的并发被压到最低读写都要排队拿锁隔离性最强但是吞吐量通常也是最低的。这里有个很重要的认知隔离级别决定的是数据可见性边界不是是否加锁。串行化也并不是完全没有锁读未提交也一样有锁来保证写操作本身不冲突只是它允许读到未提交数据。很多人把这个概念混在一起后面看锁机制时会容易绕晕。1.2 脏读、不可重复读、幻读是如何发生的三个经典的并发异常本质上都是事务之间互相看到了不该看到的数据状态。脏读对应的是未提交的数据。事务A改了库存但没提交事务B读到了这个修改值。过一会儿A回滚了这行数据恢复原状B之前读到的那个中间状态在业务上根本没发生过所以叫脏数据。防止脏读的最低要求就是读已提交。不可重复读对应的是已提交的被修改数据。同一个事务里第一次SELECT查询某一行得到值X另一个事务对这个行做了UPDATE并提交当前事务第二次SELECT同一行得到了值Y。两次读取的结果对不上这在账务核对、报表统计这类业务里是致命的。防止不可重复读需要把级别提升到可重复读。幻读对应的是已提交的新插入数据。事务内两次执行同一条范围查询第一次查出5行另一个事务插入了一条符合条件的新记录并提交第二次查出6行。多出来的那行像幽灵一样凭空出现所以叫幻读。不可重复读关注的是同一行的值变了幻读关注的是结果集的行数多了这是两者最核心的区别。我用一句话总结这三者的关系脏读是读到了还没定论的数据不可重复读是同一行数据前后不一致幻读是同一范围的行集合前后不一致。1.3 一张表理清异常和隔离级别的对应关系隔离级别脏读不可重复读幻读读未提交可能发生可能发生可能发生读已提交不会发生可能发生可能发生可重复读不会发生不会发生InnoDB下基本不会发生串行化不会发生不会发生不会发生注意表格里可重复读那列SQL标准里可重复读并不能避免幻读但MySQL InnoDB通过MVCC配合间隙锁把可重复读级别的幻读问题基本堵住了这一点也是后面要说到的实现层面的重点。记住这个表格面试时先答标准定义再补充InnoDB的实现差异会显得你既有理论又有实战认知。2. MVCC和锁的配合隔离级别在底层是怎么实现的2.1 快照读和当前读是两套完全不同的路径想理解InnoDB下隔离级别的行为必须先分清两条读取路径快照读snapshot read和当前读current read。平时写的普通SELECT不带FOR UPDATE、不带LOCK IN SHARE MODE、也不在UPDATE或DELETE里依赖读取走的就是快照读。快照读不加锁通过MVCC机制去读取undo log版本链上符合可见性规则的历史版本。因为不加锁它的并发性能非常好这也是MySQL事务处理吞吐量高的一个底层原因。UPDATE、DELETE、INSERT以及SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE走的是当前读。当前读必须读取行记录的最新已提交版本同时给命中的记录加锁保证在操作期间没有其他事务干扰。这两条路径的区别直接决定了隔离级别行为上的微妙差异。比如在可重复读级别下一个事务里频繁执行普通SELECT反复读到的都是同一个快照但如果这个事务里执行了UPDATEUPDATE走的是当前读它会看到比快照更新的已提交数据。这就可能出现一种看似矛盾的现象明明两次SELECT结果一致一旦中间执行了UPDATE再回查数据的可见范围就变了。这不是bug是快照读和当前读各自遵循了不同的规则。2.2 undo log版本链和Read View如何支撑可重复读InnoDB中每行数据除了业务字段还藏着几个关键的系统列DB_TRX_ID最近一次修改这行数据的事务ID、DB_ROLL_PTR回滚指针以及隐藏主键等。一行数据每次被更新修改前的旧版本都会被写进undo logDB_ROLL_PTR则把同一行的多个版本串成一条链表这就是版本链。快照读时InnoDB通过一个叫Read View读视图的机制来判断版本链上哪个版本对当前事务可见。Read View本质上是在生成那一刻对活跃事务的一张快照里面记录了几个关键值活跃事务ID列表、最小活跃事务ID、最大事务ID1。判断规则大致是版本的事务ID比最小活跃事务ID还小说明这个版本在Read View生成前就已经提交可见。版本的事务ID落在活跃事务列表里说明生成快照时它还没提交不可见。版本的事务ID大于等于最大事务ID1说明它是在快照生成之后才启动的事务不可见。如果当前版本不可见就沿着版本链往上找找到第一个可见版本为止。可重复读和读已提交在Read View生成策略上的差别正是两种隔离级别行为差异的根源。可重复读下事务第一次执行快照读时生成Read View之后整个事务期间所有的快照读都复用这一个Read View所以无论别的会话提交多少次修改当前事务看到的始终是最初那一版世界。读已提交下每次执行快照读都会重新生成一个Read View所以同一个事务内两次SELECT可能看到不同数据——第一次查完之后其他事务提交更新第二次再查就能看到最新的已提交值这就是不可重复读产生的底层过程。2.3 间隙锁如何从根源上阻断幻读说到幻读只有MVCC还不够。MVCC主要解决快照读场景下的可见性但当前读场景下事务执行SELECT ... FOR UPDATE或者UPDATE时如果其他事务往范围里插入了新行还是可能造成行数突变。InnoDB用间隙锁gap lock来处理这个问题。间隙锁锁的不是某行记录而是索引记录之间的区间。比如一段索引里分布着值10、20、30间隙锁会锁住(10,20)、(20,30)这些开区间其他事务无法在这个区间插入11、21这类新记录。在可重复读级别下InnoDB在执行当前读时除了给命中的记录加行锁还会给扫描范围涉及的间隙加间隙锁这样并发事务往同一个范围插数据就会被阻塞幻读从锁的层面被堵住了。这个设计也带来一些副作用比如间隙锁之间互相兼容但间隙锁与插入意向锁冲突两个事务互相在对方的间隙里插入数据时容易触发死锁。生产环境里我用MySQL遇到过不少死锁日志排查下来基本都指向可重复读当前读范围扫描并发插入的组合。这也是为什么有些团队宁可把隔离级别改成读已提交、牺牲一点隔离性换取更低的死锁概率和更高的并发度。3. 查看、设置和验证隔离级别的完整实操3.1 查看当前隔离级别的几种方式开发中随时需要确认当前会话或整个实例用的是什么隔离级别。MySQL里最常用的查看语句是SELECT transaction_isolation;在MySQL 5.7及更早版本这个变量名是tx_isolation8.0开始变成了transaction_isolation。老版本升级之后如果还习惯写前者会得到一个空值。-- MySQL 8.0 / 5.7 SELECT global.transaction_isolation; -- 全局级别 SELECT session.transaction_isolation; -- 会话级别 SELECT transaction_isolation; -- 当前生效值也可以直接用SHOW VARIABLESSHOW VARIABLES LIKE transaction_isolation;需要注意的是会话级别的隔离级别只对当前连接生效一旦断开重连就会恢复全局设置。如果你在排查某个连接的行为异常先确认这个连接是不是被应用层设置过会话级隔离级别这是一个很容易忽略的排查点。3.2 修改隔离级别的三种方式修改隔离级别有三个作用范围全局、会话、下一次事务。-- 全局生效新连接生效已有连接不受影响 SET GLOBAL transaction_isolation READ-COMMITTED; -- 当前会话生效影响之后所有事务 SET SESSION transaction_isolation REPEATABLE-READ; -- 只对下一个事务生效 SET transaction_isolation SERIALIZABLE;全局修改不用重启数据库但对已经存在的连接不生效实际业务里如果要做全局切换要注意连接池里的连接未必会立马切到新配置很多老连接可能还带着旧隔离级别跑很久这是生产变更里一个非常隐蔽的坑。除了SET语句MySQL还支持配置文件方式设置[mysqld] transaction-isolation READ-COMMITTED设置完成后建议用status或information_schema里的表确认变更是否真正生效特别是线上环境变更前后都查一遍避免出现改了但没起作用的假象。3.3 用SQL复现一次不可重复读理论知识讲再多不如亲手跑一遍理解深刻。这里用一个最经典的两连接实验来复现不可重复读。准备一张表CREATE TABLE account ( id INT PRIMARY KEY, balance DECIMAL(10,2) ) ENGINEInnoDB; INSERT INTO account VALUES (1, 100.00);会话A先设置成读已提交-- 会话A SET SESSION transaction_isolation READ-COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1; -- 结果 100.00这时候别提交打开会话B执行-- 会话B UPDATE account SET balance 200.00 WHERE id 1; COMMIT;再回会话A执行第二次查询SELECT balance FROM account WHERE id 1; -- 结果 200.00同一个事务里两次查询结果从100.00变成了200.00不可重复读完美复现。把会话A的隔离级别改成REPEATABLE-READ再跑一遍第二次查询结果仍然是100.00。如果你想复现幻读可以把实验从单行查询改成范围查询在会话B里插入一条id2的记录并提交然后在可重复读级别下看结果集行数是否会变化——普通快照读下不会变但加上FOR UPDATE的当前读会被间隙锁挡住这些细节跑一遍就全明白了。4. 生产环境选型为什么MySQL默认可重复读实际又该怎么选4.1 默认可重复读是历史包袱还是刻意设计MySQL InnoDB的默认隔离级别是可重复读而Oracle、PostgreSQL默认是读已提交这一点经常被拿出来讨论。MyISAM时代没有事务转而InnoDB引入事务时为了兼容某些历史行为默认选择了可重复读。但发展到今天我倾向于认为这是InnoDB在MVCC和间隙锁加持下的一种刻意默认它让想要更高隔离性的业务在默认配置下就能获得较好的保护同时MVCC又让它保持了足够好的并发性能不会因为默认级别高一点就让吞吐量崩盘。需要特别说明的是SQL标准里可重复读是不能完全防幻读的但InnoDB在可重复读下通过MVCC解决了快照读的幻读问题通过间隙锁解决了当前读的幻读问题。所以在MySQL语境里可重复读能防幻读这个说法在绝大多数场景下是成立的。这也是面试官最爱追问的点标准定义和MySQL实现之间的差异一定要能讲清楚。4.2 隔离级别、锁范围与并发性能的权衡隔离级别越高事务之间越互不干扰但代价是锁的范围更大、持有时间更长、并发度更差。可重复读要用间隙锁防幻读间隙锁本身就是并发插入的绊脚石改成读已提交后InnoDB只保留行锁不加间隙锁并发插入能力显著提升死锁概率下降。真实业务里很多互联网团队会把默认隔离级别改成读已提交因为大部分业务并不依赖事务内多次读取同一快照的一致性而更在意高并发写入时的吞吐量。比如订单、库存、日志类系统宁可接受读已提交下同一事务两次范围查询可能看到不同数据也不愿意高频死锁拖垮线上。这里没有绝对的对错只有取舍。我的建议是如果业务里存在事务开始后多次查询必须基于同一份快照做一致性判断的强需求比如账务核对、对账单生成、复杂统计报表保持可重复读如果业务以高并发写入为主范围查询并发插入频繁、死锁率高可以考虑切到读已提交但要在应用层设计好补偿逻辑。4.3 从隔离级别看到分布式事务的边界隔离级别解决的是单个数据库实例内部事务并发的问题但如今订单系统和库存系统经常拆成两个独立服务各自有独立的数据库这时候就需要分布式事务。很多人把MySQL事务隔离级别和分布式事务混在一起谈实际上它们的层次完全不同。隔离级别管的是一个库内多个事务之间的数据可见性分布式事务管的是多个资源管理器多个库、或者库加消息队列之间的数据一致性。分布式事务最终要保证的通常是全局的原子提交比如订单系统扣减了库存消息队列也记录了事件要么全部成功要么全部回滚。这时候即使每个库都设置成串行化也不能解决跨库一致性问题因为协调机制在数据库之外。可以换一个角度理解隔离级别决定了一件事务内部能看到什么分布式事务决定了多个事务在跨系统边界时如何协调。理解了隔离级别的边界你就知道线下数据库调成可重复读就能解决分布式问题是不成立的分布式场景需要分布式事务协调方案来完成跨库的最终一致或强一致。5. 面试和实战中高频出现的问题与边界细节5.1 可重复读级别下为什么还会有新鲜数据这是很多开发在实际项目中产生困惑的经典问题。可重复读明明说事务内多次读取结果一致为什么有时候执行了UPDATE之后再SELECT能看到其他事务提交的新数据答案是快照读和当前读的差异。可重复读的一致性读快照读依赖事务启动后的第一个Read View但UPDATE走的是当前读它必须读取最新已提交版本。当前读会把新值写回来之后事务内的快照读再读取这行就可能会跟随这个最新版本而不是最初的那份快照。这一点在一些事务里执行先查后改再查的业务逻辑时特别容易踩坑搞清楚快照读和当前读的区别这类问题就迎刃而解了。5.2 谈隔离级别必谈锁记录锁、间隙锁和意向锁四个隔离级别不是四条独立的路它们是在是否加锁、加什么锁、何时释放锁这几张表上做选择。记录锁锁的是索引记录本身间隙锁锁的是记录之间的空隙防插入临键锁next-key lock是记录锁加间隙锁的组合InnoDB在可重复读级别下做范围扫描时默认用的就是临键锁这也是防幻读的关键。此外还有表级意向锁比如事务准备给某行加锁时先要在表上加意向锁与表锁做冲突判断避免锁升级导致全局等待。面试时如果能在谈隔离级别时把记录锁、间隙锁、临键锁、意向锁各自的作用串起来讲会明显体现出你对InnoDB并发控制的完整理解。5.3 隔离级别和事务日志的关系线上经常碰到一类报错提示某个数据库的事务日志已满。这类问题和隔离级别虽然不直接相关但都落在同一个大的事务体系里。事务执行过程中产生的undo log和redo log都是持久化到磁盘文件的如果事务长时间不提交、操作量巨大或者并发事务过多日志文件可能被写满。我遇到过一次典型的案例一个批量更新任务在一个事务里UPDATE了几十万行长时间不提交把所有空闲日志空间耗尽导致其他正常事务也被阻塞。排查下来发现真正的问题不是隔离级别配置错了而是大事务拆分不充分。这类经验告诉我们理解事务隔离级别不能只盯着SELECT和UPDATE的可见性还要把事务长度、日志空间、锁持有时间放在一起考量。重构为分批提交的小事务往往比调隔离级别更能解决线上的性能和阻塞问题。5.4 我的一点实际操作心得最后分享一个我自己的习惯。上线前我会把每条关键SQL在目标隔离级别下做一次并发场景验证不只看返回值对不对还要看锁等待、死锁日志和事务隔离级别相关的状态变量变化。比如用SHOW ENGINE INNODB STATUS查看最近的事务和锁信息用performance_schema里的表监控等待事件。这些东西平时不起眼一旦线上出了并发问题它们就是定位的第一手素材。以后再遇到同一个事务查两次结果不一样的问题先别急着怀疑数据库出bug第一反应去查当前连接的隔离级别第二反应复盘事务里是快照读还是当前读第三反应看有没有大事务挤占日志空间。把这三个点排查下来九成以上的事务一致性问题都能找到根因。当然手工去执行这些验证步骤确实需要一点耐心但一旦你亲手复现过脏读、不可重复读或者幻读再看官方文档里那些抽象描述就会发现原来它们说的每一句话都是你在实验里看到过的真实现象。
返回列表