免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

MySQL事务隔离级别实战:脏读、幻读与MVCC锁机制详解

MySQL事务隔离级别实战:脏读、幻读与MVCC锁机制详解 如果只让我挑一个 MySQL 必须搞懂的概念我会选事务隔离级别。原因很直白并发环境下你写的代码能不能稳定出货往往不取决于 SQL 怎么写而取决于事务在什么样的一致性与隔离约束下运行。我自己经历过一次线上库存超卖排查到最后和 Java 代码没关系就是隔离级别配错——读到了未提交的中间态。那次之后我养成了一个习惯新项目启动先定清楚并发事务边界再谈建表。这篇文章从三个最常见的并发异常讲起解释 SQL 标准的四级隔离随后深入 InnoDB 的 MVCC 与锁实现最后给出一套可以直接复制运行的验证实验以及生产环境里真正用得上的选型经验。适合三类人看一是准备 MySQL 面试题的求职者二是正在写订单、库存、账务这类敏感业务的开发三是想搞明白一致性读和当前读到底差在哪的进阶玩家。1. 并发事务读数据的三类意外先不谈概念直接用业务例子把三个问题摆出来。这三类异常是所有隔离级别设计的出发点你把它记住了后面的标准定义就全通了。1.1 脏读读到别人还没决定的数据脏读指一个事务读到了另一个事务未提交的数据。假设订单表里有一行 statuspending事务 A 把它改成 paid 但还没有 COMMIT事务 B 此刻执行查询读到 paid。随后 A 执行 ROLLBACK那么 paid 这个状态从头到尾就没真正存在过。B 如果已经按已付款去安排发货这就成了事故。注意脏读发生的前提不是数据写到一半而是写入还没提交。事务本身是原子的对一行记录来说不存在物理上的改了一半状态但已修改未提交这个时间窗口是真实存在的。所以脏读本质上是把别人的草稿当成正式文档用问题不在于数据写坏了而在于你根本没资格看草稿却提前把它当真了。1.2 不可重复读同一条 SQL两次结果不一样不可重复读指同一事务内两次相同的 SELECT 读同一条记录得到的列值不同。原因在于另一个事务在两次查询之间提交了 UPDATE。举个例子。事务 A 先执行 SELECT balance FROM account WHERE id1得到 100事务 B 把这一行改成 balance50 并 COMMIT事务 A 在同一事务里再次执行同样的 SELECT得到 50。A 事务还没结束按理说它应该处在一个稳定的逻辑视图里结果同一行数据在它的两次查询之间悄悄变了。这种不一致对账务系统是致命的事务内部基于第一次查询做出的后续判断可能已经过期了。1.3 幻读记录的数量凭空变化幻读的特征不是某一行的值变了而是符合查询条件的记录数量变了最常见的触发操作是 INSERT。事务 A 查询订单表里 statuspending 的订单第一次查到 5 条事务 B 插入一条新的 pending 订单并 COMMIT事务 A 再查变成 6 条。多出来的那条对 A 而言就像幻觉一样出现。这里有个高频考点不可重复读和幻读怎么区分一句话——不可重复读是记住的记录内容被改动幻读是记录集合里多出来一个成员或者少了一个成员。前者看同一行的列值后者看结果集的行数。如果你用 SELECT COUNT(*) 做业务判断就一定要把幻读风险算进去比如任务队列里还有几条没处理这类逻辑最容易被幻读坑。1.4 这三类异常和锁是一对双生子隔离级别不是凭空设计的。它本质上是定义两个东西哪些读操作允许看见别人未提交或已提交的变化以及写操作用什么锁来保护记录区间。你可以把隔离级别看成一套规则而 MVCC 和锁是实现这套规则的两只手。后面讲的每一个级别本质上都是在回答脏读、不可重复读、幻读我各自允许哪一个。2. SQL 标准的四道隔离门槛和 MySQL 按自己规矩来的地方网上有不少资料直接甩表格但很少有人讲清楚为什么标准要这样设计。我们从定义出发再讲一个常见反直觉点为什么 MySQL 的默认级别不是更宽松的 READ COMMITTED。2.1 四个级别的标准定义SQL 标准按允许出现哪些异常由宽松到严格定义了四个隔离级别READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不可能可能可能REPEATABLE READ不可能不可能可能标准定义SERIALIZABLE不可能不可能不可能逐条解释。READ UNCOMMITTED 是所有级别里最危险的它允许脏读也就是允许读未提交数据适合文档里明确写明可以读到过期或临时数据的场景。READ COMMITTED 保证每次读到的都是已提交数据解决了脏读但同一事务两次读之间如果别人提交了更新结果仍可能不同。REPEATABLE READ 在标准层面保证同一事务内重复读的结果一致但不保证结果集不会多出行所以在标准定义里它仍然允许幻读。SERIALIZABLE 把三个异常全堵住代价也最大。2.2 一个反直觉的事实MySQL 默认是 REPEATABLE READ从 Oracle 或 SQL Server 切到 MySQL 的人经常会问一个问题为什么 MySQL 的默认隔离级别不是 READ COMMITTED别的数据库很多都用 RCMySQL 偏偏选了一个更严格的 RR图什么这个选择有历史原因。早期 MySQL 把 binlog_format 定位成 STATEMENT也就是基于 SQL 语句复制。如果主库用 READ COMMITTED一个事务内多次读的结果可能不一致binlog 记录这条 SQL 时从库回放执行同样的语句可能会产生不一样的数据主从就对不齐了。而 REPEATABLE READ 下事务内所有一致性读基于同一个快照从库回放相对安全。现在 binlog_format 普遍改成 ROW 了RC 也没有复制问题但默认值沿用了下来所以今天 InnoDB 的默认隔离级别仍然是 REPEATABLE READ。还有一个常被忽略的进阶知识点InnoDB 的 REPEATABLE READ 实际表现比 SQL 标准的可重复读更强。标准里 RR 不能防止幻读InnoDB 却通过间隙锁和 MVCC在绝大多数普通场景下挡住了幻读。这一点特别容易在面试时触发追问我建议你在自己电脑上把第四节的实验跑一遍知道它是怎么挡的。2.3 选级看三个维度隔离级别本质上是一个一致性、并发度、实现代价的三角权衡。级别越高一致性越强但锁范围更大、阻塞更多、吞吐量越低。真正做生产选型时要同时考虑业务能否容忍某个异常、事务里有几个读写操作、锁竞争是否严重。不要把隔离级别越高越好当成原则很多线上死锁和慢事务恰恰是过度使用高隔离级别导致锁冲突放大的结果。3. 揭开 InnoDB 的底牌MVCC、一致性读和当前读如何配合锁隔离级别是标准InnoDB 才是实现。想真正理解 MySQL 里的隔离表现必须搞清楚 MVCC 和锁是怎么配合的。这里顺便把热搜词里的mysql 锁的分类也一起覆盖了因为两者本来就不分家。3.1 MVCC 的本质多版本链和回滚段InnoDB 实现隔离级别的核心机制是 MVCC多版本并发控制。每一行除了我们建的字段还有隐藏字段包括事务 ID 和回滚指针。当 UPDATE 修改一行时InnoDB 不是直接覆盖旧值而是把旧值复制到回滚段新行通过回滚指针指向旧版本。这样同一行物理上存在多个版本按顺序连成一条版本链。READ COMMITTED 和 REPEATABLE READ 的差异本质上是 MVCC 在读哪个版本上的策略差异。READ COMMITTED 每次 SELECT 都生成一个新的一致性读视图所以能读到别的事务新提交的数据。REPEATABLE READ 只在第一次一致性读时生成视图后面的 SELECT 都基于同一个视图于是读到的数据始终如初。这也是为什么 RR 下同一事务内两次查询结果一致——不是没有新版本而是它承认旧快照不再去看新版本。3.2 快照读与当前读同一份数据两条路径把 InnoDB 里的 SELECT 分成两类理解几乎一半的隔离级别问题都能豁然开朗。快照读普通 SELECT 语句走 MVCC 版本链不加锁读的是某个时间点生成的快照。当前读SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT、UPDATE、DELETE读的是最新已提交版本并且会给记录加锁。面试里常见一个误区以为REPEATABLE READ 下所有读都一致。准确说法是RR 下的一致性读快照读一致如果事务内使用当前读比如 SELECT ... FOR UPDATE它依然会去拿最新已提交的行并加锁两次当前读之间如果其他事务提交了结果同样可能不同。这是实战里容易踩的坑后面实验里我会用具体例子演示。3.3 锁的分类如何支撑隔离级别MySQL 锁的话题很大但和隔离级别强相关的只有其中一部分。InnoDB 常用的行锁三类记录锁Record Lock锁住索引上的一个具体记录。间隙锁Gap Lock锁住两个索引记录之间的区间阻止其他事务在这个区间里插入新记录。临键锁Next-Key Lock记录锁加对应区间上的间隙锁是 InnoDB 在 REPEATABLE READ 下的默认锁算法。为什么 RR 能挡住幻读因为走索引条件的查询InnoDB 会对命中的记录加记录锁同时把记录之间的间隙也锁住。这样其他事务想在间隙里插入新行会被锁阻塞也就无法凭空多出一条记录。这正是 MyISAM 这类不支持行锁、不支持 MVCC 的存储引擎和 InnoDB 在事务能力上的根本差距。查隔离级别问题查到一半发现SQL 没走索引本质就在这间隙锁失效幻读防线就出现缝隙。3.4 间隙锁的副作用隐藏死锁间隙锁虽然解决了幻读却引入一个典型的副作用死锁。两个事务各持一部分锁又互相申请对方手里的锁范围InnoDB 检测到死锁后会回滚其中一个事务。很多团队反馈把隔离级别从 RR 调成 READ COMMITTED 后死锁变少了原因就是 RC 默认不启用间隙锁锁冲突面缩小。当然 RC 也可能死锁只是概率明显下降原因常常是两条 SQL 对记录的加锁顺序不一致。4. 亲手验证四种隔离级别在 MySQL 里的真实反应概念讲再多不如自己跑一遍。下面这套实验在 MySQL 8.0 上验证过5.7 行为一致。建议你打开两个终端会话一起操作亲手感受一下锁和快照是怎么作用的。4.1 初始化表与会话先建一张 account 表同时准备两个终端会话一个叫会话 A一个叫会话 B。CREATE TABLE account ( id INT PRIMARY KEY, balance INT NOT NULL ) ENGINEInnoDB; INSERT INTO account VALUES (1, 100), (2, 200);设置会话隔离级别的语法-- 当前会话 SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- 全局默认 SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;查看当前级别SELECT transaction_isolation;注意两个容易踩的细节。第一修改会话隔离级别要在开启事务之前完成否则已经开启的事务不会应用新设置。第二SET GLOBAL 对已有会话无效只影响之后新建的连接线上动态修改时连接池里的连接如果不重建等于没改。另外5.7 里查看变量用的是 tx_isolation8.0 改成了 transaction_isolation写脚本时别搞混。4.2 READ UNCOMMITTED 下的脏读在会话 A 执行START TRANSACTION; UPDATE account SET balancebalance-50 WHERE id1;此时 A 还没 COMMIT。去会话 B 执行SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id1;B 会看到 balance50。然后把会话 A 回滚这一行数据变回 100但 B 刚才确确实实读到过一个不存在的 50。这个实验直接证明了脏读存在也说明账户余额这一类的敏感查询绝不能放在 READ UNCOMMITTED 上。生产环境里哪怕数据仓库抽数也不建议用这个级别因为你不知道上游事务到底会不会回滚。4.3 READ COMMITTED vs REPEATABLE READ不可重复读对比先把会话 B 的隔离级别改成 READ COMMITTED重新开启事务SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id1; -- 返回 100让会话 A 执行START TRANSACTION; UPDATE account SET balance50 WHERE id1; COMMIT;会话 B 再次查询同一行SELECT balance FROM account WHERE id1; -- 返回 50两次查询从 100 变成 50不可重复读出现了。接下来把会话 B 改成 REPEATABLE READ跑同样流程SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT balance FROM account WHERE id1; -- 返回 100会话 A 执行同样的更新并提交会话 B 再查询返回的仍然是 100。这就是 RR 的快照机制第一个 SELECT 建立快照后续读和它保持一致外部提交不影响。对账务、报表这类需要事务内稳定读的业务这个特性非常关键。4.4 幻读在 REPEATABLE READ 下被挡住了吗用一个更贴近幻读的场景测试。仍然在 RR 级别下事务 A 先查询START TRANSACTION; SELECT * FROM account WHERE balance 0 FOR UPDATE;事务 B 尝试插入一行START TRANSACTION; INSERT INTO account VALUES (3, 300);这条 INSERT 会一直被阻塞直到事务 A 提交。原因是 InnoDB 在 balance0 这段范围上加了临键锁锁住了包括 id3 这个间隙。等 A 提交后B 的 INSERT 才成功。在绝大多数业务查询里这就是幻读被挡住的实际表现。但有一个细节值得写进你的排查手册如果事务 A 用的是普通 SELECT 而不是 FOR UPDATE它的快照读根本不会把新插入的行算进结果集从一致性读视角看不到幻读。真正需要警惕的是先 FOR UPDATE 查出范围另一个事务插入最后程序里 COUNT 少了一条这种当前读和插入并发交互的场景应用层最容易被忽略。4.5 SERIALIZABLE 的表现普通 SELECT 也会被锁最后把会话 B 设为 SERIALIZABLESET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION; SELECT * FROM account WHERE id1;此时这条 SELECT 会被 InnoDB 转成当前读对 id1 的记录加共享锁。会话 A 执行 UPDATE id1 时会被阻塞。如果 A 的 UPDATE 一直等不到锁B 也不提交两者就形成锁等待链。SERIALIZABLE 用锁把事务串起来隔离性最强并发度也接近零。作为对比在 RR 级别下普通 SELECT 是快照读只要快照可用就不会和写操作互斥。这也是为什么线上几乎没人全程用 SERIALIZABLE 扛业务吞吐的原因。5. 生产环境怎么选一致性、并发度和恢复之间的取舍看完实验自然会问线上到底用哪个我给不出一个放之四海皆准的答案但有完整的选型思路和踩坑记录。5.1 按业务场景给结论场景推荐级别理由订单、库存、账务等强一致写READ COMMITTED 或 RR 事务边界清晰先解决脏读能接受不可重复读尽量减少间隙锁死锁统计报表、对账、事务内多表一致性查询REPEATABLE READ快照读让所有查询基于同一个视图高并发读多写少、日志类写入READ COMMITTED减少间隙锁和死锁吞吐优先数据同步Flink CDC 读 MySQLREPEATABLE READ让同步任务拿到一致性快照避免抽取中途数据变更数据同步那行多说一句。现在很多实时数仓用 Flink 同步 MySQL 到 ClickHouse如果源端隔离级别设置不当同步窗口里的数据会看到不同时刻的状态抽出来的结果自然不齐。配合 binlog 的 ROW 格式和 RR 级别Flink 能拿到一致性快照后续再按 binlog 顺序回放事务整体链路才稳。网上搜flink 实现 mysql 同步到 clickhouse能看到大量踩坑帖十有八九最后都指向源端隔离级别和 binlog 配置这两件事。5.2 变更线上隔离级别的正确姿势如果评估后决定把某业务切到 READ COMMITTED我建议按这个顺序执行先在测试环境跑全量回归重点看依赖事务内多次查询一致的代码比如先查余额再扣款这类逻辑。线上先改全局参数但全局只影响新连接要让连接池重建连接后再观察。改完用 performance_schema 或 SHOW PROCESSLIST 观察锁等待是否下降、长事务是否减少。遇到死锁用 SHOW ENGINE INNODB STATUS 看最近死锁日志不要一遇到问题就降隔离级别。还有个习惯值得推广部署新应用时在连接串上显式设置隔离级别而不是依赖全局默认值。比如 Spring Boot 的 HikariCP可以通过 connection 属性单独设置 transaction-isolation。这样同一个 MySQL 实例上不同业务可以各用各的级别互不干扰。5.3 一定留意长事务这个隐形杀手隔离级别选得再对也扛不住一个长时间挂在那里的事务。长事务会让 MVCC 的老版本链一直保留回滚段不能及时清理导致 undo 膨胀、purge 线程压力大同时间隙锁长时间持有后面的写入全部排队。我排查过一次数据库突然慢得像蜗牛的线上问题最后定位到某个报表任务在一个 RR 事务里跑了 20 分钟把一批更新全堵在锁等待里。所以生产环境除了选隔离级别还应该养成两个习惯监控事务持续时间尽量把大事务拆小。一条 UPDATE 扫全表更新几万行从事务角度看就是锁了一个大范围和一条一条更新并发度是完全不同的。拆成多个小批量提交既降低锁粒度也减少异常回滚的代价。6. 面试和实战中都常踩的几个坑最后集中讲几个我在复盘和带新人时反复提到的坑每一个都对应过真实的线上问题。6.1 可重复读不等于整个世界都静止很多候选人把 RR 理解成事务内看到的所有数据永远不变。实际上 RR 只保证一致性读视角下的可重复读当前读、DDL、外部系统的可见性都不在它的管理范围内。同一个事务里如果先执行 UPDATE再去 SELECT看到的是自己更新后的新数据这是当前读的必然结果不是可重复读失效。把一致性读和当前读混为一谈是 MySQL 面试题里扣分最狠的地方。6.2 隔离级别控制的是读别和锁超时混在一起线上出现Lock wait timeout exceeded时不要第一反应就是隔离级别太高。锁等待绝大多数来自两个写事务的锁冲突而隔离级别更多决定读操作用快照还是当前读。只读事务在 RR 下走 MVCC 根本不加锁但如果它里面有一条 SELECT ... FOR UPDATE就会和写事务打架。排查锁问题时先看 SQL 里有没有当前读再看隔离级别这个顺序不能反。6.3 一个最基础的验证方法看 binlog 和复制配置如果团队还在用 STATEMENT 格式的 binlog把隔离级别调低之前务必三思。ROW 格式让主从复制对隔离级别的敏感度降低因为复制的是最终数据行而不是 SQL 语义。这也是为什么新装 MySQL 我会建议直接确认 binlog_formatROW配合默认的 RR 级别踩坑最少。网上关于 MySQL 安装配置的教程很多但真正影响数据安全的往往是这种读写一致性的底层链路而不是安装步骤本身。6.4 性能调优不要一上来就降级隔离级别有些压测报告显示降级到 READ COMMITTED 后吞吐提升很多人就照抄到自己系统里。真相是吞吐提升主要来自间隙锁减少和锁等待減少。如果业务本身并发冲突很低RR 和 RC 的表现差距并不明显反倒是 RR 的一致性快照在一些场景下能省不少锁。先通过慢查询日志、锁等待统计定位瓶颈再决定是否调级别才是稳妥的路线。这套实验我自己跑过很多次也在带新人和面试复盘时反复讲过。最后给你一个建议不要只背结论把 4.2 到 4.5 的脚本放到自己本机跑一遍亲眼观察每个级别下的锁动作和快照行为。跑通一次你对 MySQL 事务隔离级别的理解会比看十篇理论文章都深刻。数据库这东西纸上谈兵远不如亲手验证来得管用。
返回列表