ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL事务隔离级别详解:MVCC与锁机制、并发问题、生产选型

MySQL事务隔离级别详解:MVCC与锁机制、并发问题、生产选型 聊到 MySQL 事务隔离级别这是面试题里的常客也是线上数据错乱事故的重灾区。我在排查问题的时候不止一次见过这样的场面一个事务里连续两条 SELECT 查同一个订单金额却对不上或者明明只更新了一行结果整个表都被锁住并发一高就直接打满连接池。搞不懂隔离级别写出来的 SQL 就像是在雷区里散步。这篇内容我会把 MySQL 的事务隔离级别一次性讲透彻包括四种级别分别能解决什么问题、底层靠什么机制实现、在终端里怎么验证、生产环境到底怎么选顺便把我自己踩过的坑和排查思路也一并整理出来。无论你是后端开发、DBA还是准备面试的候选人只要想真正把事务和锁这一块搞明白这篇文章值得你花十几分钟从头读到尾。1. 先把基础盘明白四种隔离级别与三个经典并发问题要说隔离级别先得搞清楚它到底为了保护什么。事务的 ACID 里隔离性Isolation要求多个事务并发执行时互不干扰但完全互不干扰代价极高。所以 SQL 标准给出了四个档位的隔离级别允许你在数据一致性和并发性能之间做取舍。1.1 脏读、不可重复读、幻读三种典型的并发异常脏读Dirty Read最容易理解就是读到别人还没提交的数据。举个库存场景。事务 A 扣减库存把某商品从 10 改成 9但还没 COMMIT事务 B 这时候查库存读到 9接着基于 9 做了一堆后续判断。结果事务 A 因为业务校验不通过 ROLLBACK 了库存又回到 10。事务 B 刚才读到的那个 9 就是脏数据它整条业务流程建立在了一个不存在的中间状态之上。不可重复读Non-Repeatable Read指的是同一个事务内两次读取同一行数据结果不一样。为什么不一样因为第二次读之前别的事务把这个行 UPDATE 并 COMMIT 了。比如事务 A 先查余额是 100事务 B 给这个账户打了 50 并提交事务 A 再查余额变成了 150。对事务 A 来说如果它期望的是事务期间数据保持稳定这个结果就破坏了可重复读的语义。幻读Phantom Read很多人和不可重复读搞混。区别在于不可重复读针对的是同一行记录的 UPDATE/DELETE而幻读针对的是 INSERT。事务 A 按条件查出来 2 条记录事务 B 插入了一条同样满足条件的新记录并提交事务 A 再次按相同条件查询时变成了 3 条。多出来的这一条就像幻觉一样凭空出现了。1.2 四大隔离级别与异常对照表SQL 标准定义的四个级别如下隔离级别English 写法脏读不可重复读幻读读未提交READ UNCOMMITTED可能可能可能读已提交READ COMMITTED不可能可能可能可重复读REPEATABLE READ不可能不可能可能InnoDB 下基本避免串行化SERIALIZABLE不可能不可能不可能这里有一个容易引起误解的地方SQL 标准里 REPEATABLE READ 并不能阻止幻读但 MySQL 的 InnoDB 存储引擎在可重复读级别下通过多版本并发控制和间隙锁的组合拳实际上把幻读也挡住了。所以你去面试如果说MySQL 可重复读会幻读这句话大概率会被面试官反问一句你用快照读还是当前读。还有一个认知要纠正有些人一听到串行化就觉得是锁表。其实 InnoDB 的 SERIALIZABLE 在实现上也会把普通 SELECT 隐式转成加共享锁的当前读相当于所有读操作都互斥。它能做到最严格的一致性和最差的并发度生产环境里除非万不得已一般不会碰它。注意隔离级别本质上是一种取舍隔离强度越高锁竞争越激烈吞吐量越低。把这四个级别理解成花多少钱办多少事有助于你做选型。2. 隔离级别到底是怎么实现的MVCC 与锁的双引擎光记住级别名字没意义关键要理解 InnoDB 是靠什么让读未提交读到脏数据、可重复读读不到新数据。答案就是 MVCC多版本并发控制和锁的配合。2.1 版本链像是带着时光机的数据行在 InnoDB 里聚簇索引的每一行记录除了业务字段还藏着三个隐藏字段DB_TRX_ID最近一次修改这一行的事务 IDDB_ROLL_PTR回滚指针指向该行在 undo log 里的上一版本DB_ROW_ID隐藏主键在没有显式主键时用来标识行。每执行一次 UPDATEInnoDB 不会直接覆盖旧值而是把旧值写入 undo log再把新值写入当前行同时让DB_ROLL_PTR指向旧版本。这样一来一行数据在物理上只有一个最新版本但逻辑上通过回滚指针串成了一条版本链。你可以把这条版本链想象成 Git 提交历史最新版本是 HEAD每往前翻一版就能看到这条记录过去的样子。MVCC 的核心工作就是在读取时根据事务的 ID 判断这条版本链上哪个版本对你可见。我还喜欢用电梯里的实时新闻来打比方高楼层的住户每层看到的新闻版本都不太一样而 MVCC 就是那个负责给每个住户按身份发对应版本新闻的物业。2.2 Read View事务读取时的快照滤镜MVCC 判断可见性靠的是 Read View可以理解成事务在某个时刻对版本链拍的一张快照。Read View 里有几个关键字段m_ids生成快照时当前系统中所有活跃未提交事务的 ID 列表min_trx_id活跃事务中的最小事务 IDmax_trx_id当前系统已生成过的最大事务 ID 加 1creator_trx_id创建这个 Read View 的事务自己的 ID。判断某行版本trx_id是否可见的规则大致是如果trx_id min_trx_id说明这个版本的事务早已提交可见如果trx_id max_trx_id说明这个版本是生成快照之后才出现的事务不可见如果trx_id在m_ids里说明它尚未提交不可见其他情况说明事务已提交可见。这里的重点是 Read View 的生成时机。在READ COMMITTED级别下每次执行 SELECT 都会生成一个新的 Read View所以只要别的事务提交了你当前事务立刻就能看到最新数据不可重复读由此产生。在REPEATABLE READ级别下事务里第一次执行 SELECT 时才生成 Read View之后这个事务内一直复用同一份快照后面你再怎么 SELECT看到的都是第一次查询时的数据状态这就实现了可重复读。2.3 当前读与 Next-Key Lock防幻读的物理屏障快照读只能解决读的问题那 UPDATE、DELETE、SELECT ... FOR UPDATE这类当前读怎么办它们必须读到最新版数据否则就无法在最新状态上做修改。为了保证修改过程中数据不被别人插足InnoDB 在可重复读级别下默认给当前读加的是Next-Key Lock临键锁也就是记录锁加间隙锁的结合体。间隙锁锁住的是一个范围区间比如你在 RR 级别下执行SELECT * FROM order WHERE order_id BETWEEN 10 AND 20 FOR UPDATEInnoDB 不仅会锁住命中的那些行记录还会把 10 到 20 之间不存在的空洞也锁掉这样别的事务就没法往这个区间里插入新数据从根上掐灭了幻读。注意同一句 SQL 在 READ COMMITTED 级别下InnoDB 只会给命中的行加记录锁不锁间隙。所以如果你的业务对幻读敏感但全局隔离级别设成了 RC就得靠应用层逻辑兜底或者改用串行化否则很容易出事故。3. 实操从查看、设置到亲手复现三种并发问题隔离级别这种东西光看概念容易飘我建议你在自己本地库上开两个终端窗口亲手把每种异常都复现一遍印象会深得多。3.1 查看和修改隔离级别的标准命令当前数据库的隔离级别可以通过下面这条 SQL 查看SELECT global.transaction_isolation, session.transaction_isolation;在 MySQL 5.7.20 之前的版本里变量名不是transaction_isolation而是tx_isolation注意区分。修改也分两个层面-- 会话级只影响当前连接 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 全局级影响之后新建的所有连接 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;如果你改了全局级别注意已经存在的连接不会立刻生效必须重连。另外千万别在事务已经开启之后再切换隔离级别因为 MySQL 会隐式提交当前事务很多人在这里吃过暗亏。如果希望长期生效就写在配置文件的[mysqld]段下注意这里用的是连字符写法[mysqld] transaction-isolation READ-COMMITTED改完配置文件需要重启实例。这里我还想多提醒一句生产库改全局隔离级别要非常谨慎尤其是从 RR 切到 RC间隙锁消失之后原本依赖间隙锁挡住的并发插入场景可能会瞬间涌进来大量行锁冲突建议先在测试环境压测一轮再上。3.2 亲手复现脏读、不可重复读、幻读复现脏读需要把会话调到 READ UNCOMMITTED-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN; SELECT stock FROM product WHERE sku SKU001; -- 此时看到 10 -- 会话 B SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN; UPDATE product SET stock 9 WHERE sku SKU001; -- 未提交 -- 回到会话 A SELECT stock FROM product WHERE sku SKU001; -- 看到 9这就是读到 B 未提交的脏数据 -- 会话 B 回滚你会发现会话 A 始终无法依赖自己读到的数据 ROLLBACK;复现不可重复读可以对比 RR 和 RC 两种级别下的表现-- 会话 ARR 级别 BEGIN; SELECT stock FROM product WHERE sku SKU001; -- 10 -- 会话 B 更新并提交 UPDATE product SET stock 5 WHERE sku SKU001; COMMIT; -- 会话 A 再次查询RR 下还是 10 SELECT stock FROM product WHERE sku SKU001; -- 如果会话 A 是 RC 级别第二次查询就会读到 5复现幻读要分快照读和当前读。RR 级别下快照读不会出现幻读-- 会话 ARR 级别 BEGIN; SELECT * FROM order WHERE user_id 1; -- 查到 2 条 -- 会话 B 插入一条 user_id 1 的新订单并提交 INSERT INTO order(user_id, amount) VALUES (1, 99); COMMIT; -- 会话 A 再次 SELECT看到的依然是 2 条 SELECT * FROM order WHERE user_id 1; -- 还是 2 条但你如果把会话 A 的第二次查询换成当前读比如SELECT * FROM order WHERE user_id 1 FOR UPDATE由于当前读不走快照而是读最新版本就会看到 3 条。更完整的防幻读演示是这样-- 会话 ARR 级别 BEGIN; SELECT * FROM order WHERE user_id 1 FOR UPDATE; -- 持有间隙锁阻止 user_id1 区间的插入 -- 会话 B INSERT INTO order(user_id, amount) VALUES (1, 99); -- 此时 B 会阻塞直到 A 提交才执行成功看到没有RR 级别下间隙锁把插入新行这件事直接拦住了幻读在并发执行层面根本无法发生。3.3 实操中的三个习惯我在这里特别想强调几个实操习惯都是现场吃过亏总结出来的开事务之前先看一眼隔离级别尤其是接了别人的库或者用了连接池工具比如 Navicat 可能会保留上次会话的状态命令行验证完记得ROLLBACK或者COMMIT不然事务一直挂着下一轮测试会互相干扰复现阻塞类问题时要留意系统变量innodb_lock_wait_timeout默认是 50 秒等待太久可能让人误以为数据库挂了。4. 生产环境隔离级别的选型与避坑建议理论说完了落地到业务才是最考验人的地方。很多人问MySQL 明明提供了四种级别默认却偏偏是 REPEATABLE READ其他数据库像 PostgreSQL 用 READ COMMITTED这是不是拍脑袋定的4.1 为什么 MySQL 默认用可重复读这里牵涉到一段历史遗留问题。早期 MySQL 用基于语句的二进制日志格式做主从复制binlog 里记录的是 SQL 语句本身。如果主库用 READ COMMITTED从库按同样的 SQL 重放时因为环境不同可能产生和主库不一致的数据。而 RR 级别下事务的读结果更容易保持稳定配合当前读的间隙锁能够更好地保证基于语句复制的主从数据一致。在 MySQL 官方文档里也明确说了REPEATABLE READ 是默认隔离级别它和基于语句的复制是匹配的。后来 binlog 的ROW格式逐渐普及RC 级别在主从复制上的短板被弥补了这也是为什么现在很多团队敢于把线上调到 READ COMMITTED。4.2 什么时候坚持 RR什么时候切 RC给出我的经验判断如果你处理的是账务、订单、对账这类对读一致性极其敏感的业务别折腾老老实实用默认的 RR至少它能兜底防幻读如果你的系统瓶颈明显在并发插入、大量行锁冲突而且业务上不太在意单事务内的重复读一致性像报表统计、消息推送这类场景可以考虑切 RC减少间隙锁带来的无谓阻塞SERIALIZABLE 基本只适合极低并发的对账场景绝大多数业务用它就是给自己找麻烦。另外如果你的服务用了连接池比如 HikariCP、Druid需要注意连接池会复用物理连接某些连接上可能残留上一次事务的会话级设置。所以如果你真的想全局切隔离级别优先用配置文件或者SET GLOBAL不要散落在各个业务代码里手动SET SESSION不然排查问题时会疯掉。4.3 与 Spring 事务注解的配合后端项目里最常见的写法是Transactional注解但很多人不知道 Spring 的隔离级别默认是DEFAULT意思就是沿用数据库当前的隔离级别。你可以在注解上强制指定Transactional(isolation Isolation.REPEATABLE_READ) public void doSomething() { ... }但这里有个隐藏问题Spring 只能在事务开启前通过 JDBC 连接设置隔离级别如果你的事务里调用了多个数据源、或者嵌套了一个已经在执行中的事务隔离级别并不会按外层注解重新切换。所以我一般建议要么全库统一用数据库默认级别要么全链路在配置层明确不要靠代码里零零散散的注解去拼。5. MySQL 锁分类速查隔离级别与锁机制的联动关系顺着隔离级别这条线MySQL 的锁分类也值得一并理清。热词里经常有人搜mysql 锁的分类这里我就把和隔离级别关系最紧密的部分整理成一张速查表。锁类型粒度典型场景和隔离级别的关系全局锁整个实例FTWRL 备份场景无关和隔离级别无关表锁整张表显式 LOCK TABLE与隔离级别无关行锁Record Lock单行记录UPDATE/DELETE所有级别下都使用间隙锁Gap Lock索引区间范围查询RR 及以上使用RC 禁用临键锁Next-Key Lock记录左边间隙范围当前读RR 下默认加锁方式意向锁表级别标记建立表锁前的快速判定与隔离级别无关插入意向锁间隙内的插入意图并发 INSERT受间隙锁影响RR 下常被阻塞这里有一个点特别容易踩坑next-key lock 在 RR 级别下是默认行为它虽然防了幻读但也放大了锁的范围。我曾遇到过一次线上事故一条UPDATE语句因为 where 条件没有走索引在 RR 级别下把全表几乎所有间隙都锁住了后台上千个事务全部排队等待服务瞬间被打挂。排查下来发现是索引没建对但隔离级别放大了这个错误的杀伤力。所以从这个角度说RC 级别确实让锁范围更小很多团队在高并发场景选择 RC 就是单纯为了少受间隙锁的累。做选型时要把这个代价想清楚。6. 面试高频追问与线上问题排查实录最后这部分我把自己在面试和实际运维中遇到的高频问题以及排查方法整理出来应该能直接用到你下一次的工作中。6.1 面试官最常追问的几个角度问题一为什么 READ COMMITTED 不会脏读因为查数据时会用 Read View 过滤掉未提交事务的版本所以它只能看到已提交版本而 READ UNCOMMITTED 直接读取最新版本不做可见性判断才会读到脏数据。问题二REPEATABLE READ 和 READ COMMITTED 的 Read View 有什么不同RC 每次 SELECT 都生成新的 Read View因此能看到其他事务新提交的数据RR 在事务第一次 SELECT 时生成 Read View 并复用所以整个事务内读到的数据保持一致。问题三MySQL 可重复读级别下还有幻读吗如果你说的是快照读InnoDB 通过 MVCC 避免了幻读如果是当前读通过 next-key lock 也挡住了幻读。严格来说只有在特定写法下比如先快照读再当前读的混合场景里业务上才可能观察到行数变化。回答时能把这个细节讲清楚面试官一般都会认可。问题四锁和隔离级别的关系低隔离级别下很少用到甚至完全不用间隙锁锁竞争更小高隔离级别下靠更强的锁机制换取更高的数据一致性。要把第 5 节那张表对照着回答。6.2 线上排查的几条实用命令排查事务和锁问题我最常用的是下面这组 SQL建议收藏-- 查看当前所有未结束的事务 SELECT * FROM information_schema.innodb_trx\G; -- 查看锁等待情况 SELECT * FROM sys.innodb_lock_waits; -- 查看死锁日志 SHOW ENGINE INNODB STATUS\G;如果发现innodb_trx里有一个事务从昨晚挂到现在而且trx_rows_locked特别大那基本可以断定是长事务没提交导致的。长事务最可怕的地方在于它会一直持有 Read View不仅阻塞其他事务的更新还会让 undo log 不断膨胀表空间越撑越大。遇到这种情况先确认业务代码里是不是忘了提交然后考虑能不能把大事务拆成小事务分批提交。排查死锁的思路是找到死锁日志中两条事务互相等待的锁分析它们的加锁顺序然后调整 SQL 的访问顺序或者加锁范围。实践里最容易解决死锁的办法就是让所有事务按相同顺序访问资源比如先更新 A 表再更新 B 表谁也不要反过来。6.3 来自现场的经验教训我最后想分享两个真实教训。第一个是关于事务里查询的坑。曾经有个同事在 RR 级别下开了一个事务里面先执行了一条 SELECT 用来读取参数然后又基于读取结果执行了一大串更新逻辑。结果另一个会话修改了那条参数数据并提交事务里后续的更新都是基于旧快照做的导致业务数据错乱得非常隐蔽。这类问题排查起来特别折磨人因为它不会报任何错只是结果不对。第二个是关于连接池复用隔离级别的坑。有一次线上从 RC 切成 RR结果在配置里只改了全局变量连接池里大量旧连接依然保持着 RC。那段时间的数据表现非常分裂一部分请求读到的快照是 RR 语义另一部分又是 RC 语义最后靠重启应用才平息。从那以后我判断隔离级别是否生效时都是直接用应用实际连库的连接去查询session.transaction_isolation而不是只看global。在我实际处理过的这些事故里最深刻的体会就是隔离级别不是一个可以背完了事的知识点它是一个和索引、锁、事务边界、连接池状态紧密耦合的决策点。搞懂了它的底层机制线上再遇到数据不一致、锁等待、死锁这类问题你至少能有一个清晰的排查方向而不是像无头苍蝇一样乱试。
返回列表