# MySQL InnoDB 隔离级别与 MVCC 完全解析

张开发
2026/5/4 19:56:36 15 分钟阅读
# MySQL InnoDB 隔离级别与 MVCC 完全解析
一篇读懂 MySQL 的事务隔离、锁机制、MVCC 和间隙锁实现原理前言在日常开发中数据库事务隔离级别是一个绕不开的话题。很多开发人员对READ COMMITTED和REPEATABLE READ的区别一知半解对 MVCC 的原理更是云里雾里。本文将从实际场景出发深入浅出地讲解四种隔离级别及其解决的问题MVCC 的核心原理与实现间隙锁是什么如何实现生产环境如何选择隔离级别一、为什么需要隔离级别1.1 并发事务的三个问题当多个事务同时执行时会出现三种经典问题问题英文定义示例脏读Dirty Read读到其他事务未提交的数据事务A改了余额但未提交事务B读到了新值后来A回滚B读到了脏数据不可重复读Non-Repeatable Read同一事务内两次读同一条记录结果不同事务A第一次读到balance50事务B改成100并提交事务A再读变成100幻读Phantom Read同一事务内两次范围查询返回的行数不同事务A查到2行事务B插入1行事务A再查变成3行1.2 隔离级别概览SQL 标准定义了四种隔离级别MySQL InnoDB 全部支持隔离级别脏读不可重复读幻读并发性能READ UNCOMMITTED✅✅✅最高READ COMMITTED (RC)❌✅✅高REPEATABLE READ (RR)❌❌❌*中SERIALIZABLE❌❌❌最低*MySQL InnoDB 的 RR 通过间隙锁解决了幻读问题二、四种隔离级别详解2.1 READ UNCOMMITTED读未提交特点一个事务可以读取到其他事务尚未提交的修改。-- 事务1STARTTRANSACTION;UPDATEusersSETbalance100WHEREid1;-- 注意还没有 COMMIT-- 事务2SELECTbalanceFROMusersWHEREid1;-- 读到 100脏读问题脏读场景几乎不用数据准确性无法保证2.2 READ COMMITTED读已提交特点只能读取到其他事务已经提交的修改。-- 事务1STARTTRANSACTION;UPDATEusersSETbalance100WHEREid1;-- 未提交-- 事务2SELECTbalanceFROMusersWHEREid1;-- 读到旧值 50-- 事务1 COMMIT 后-- 事务2 再次查询SELECTbalanceFROMusersWHEREid1;-- 读到 100不可重复读问题不可重复读场景互联网高并发业务读写锁竞争少性能好2.3 REPEATABLE READ可重复读—— MySQL 默认特点同一事务内多次查询结果始终一致基于第一次查询时的快照。-- 事务1STARTTRANSACTION;SELECTbalanceFROMusersWHEREid1;-- 读到 50-- 事务2UPDATEusersSETbalance100WHEREid1;COMMIT;-- 事务1 再次查询SELECTbalanceFROMusersWHEREid1;-- 还是 50可重复读解决的问题脏读❌ 避免不可重复读❌ 避免幻读❌ 避免通过间隙锁场景金融、对账等对数据一致性要求高的业务2.4 SERIALIZABLE可串行化特点事务完全串行执行最高的隔离级别。-- 事务1STARTTRANSACTION;SELECTbalanceFROMusersWHEREid1;-- 自动加读锁-- 事务2UPDATEusersSETbalance100WHEREid1;-- ❌ 被阻塞场景几乎不用性能太差三、MVCC多版本并发控制3.1 为什么需要 MVCC没有 MVCC 的时代读写互斥-- 事务1读数据加读锁-- 事务2写数据被阻塞等待事务1释放锁有了 MVCC读写不互斥-- 事务1读数据读旧版本快照-- 事务2写数据写新版本✅ 立即成功不阻塞3.2 MVCC 的核心组件1. 隐藏字段每行都有隐藏字段含义作用DB_TRX_ID最后修改该行的事务ID知道这行是谁改的DB_ROLL_PTR回滚指针指向 Undo Log找到历史版本2. Undo Log版本链每次 UPDATE/DELETE 时旧数据写入 Undo Log形成版本链当前数据balance100, trx_id102 ↑ └── DB_ROLL_PTR 指向 ↓ 旧版本balance50, trx_id101 ↑ └── DB_ROLL_PTR 指向 ↓ 更旧版本balance30, trx_id1003. Read View读视图事务开始时生成一个 Read View记录当前活跃的事务Read View 包含 - m_ids当前所有活跃未提交的事务ID列表 - min_trx_idm_ids 中的最小值 - max_trx_id系统下一个要分配的事务ID - creator_trx_id当前事务自己的ID3.3 MVCC 如何判断数据是否可见条件可见性说明DB_TRX_ID min_trx_id✅ 可见修改该行的事务已提交DB_TRX_ID max_trx_id❌ 不可见修改发生在 Read View 之后DB_TRX_ID在m_ids中❌ 不可见修改该行的事务未提交DB_TRX_ID creator_trx_id✅ 可见自己修改的3.4 RC vs RRRead View 生成时机隔离级别Read View 生成时机效果RC每次 SELECT 都生成新的能看到其他事务已提交的修改RR事务第一次 SELECT 时生成整个事务复用重复读始终一致3.5 MVCC 的常见误解澄清误解MVCC 就像一个缓存机制将写操作异步执行。正确理解MVCC不是异步写而是同步写新版本 保留旧版本。方面误解实际情况读旧版本✅ 像读缓存读 Undo Log 中的历史版本写操作❌ “异步或延迟”写操作立即执行只是不阻塞读本质缓存写缓冲写新版本 保留旧版本链更准确的表述MVCC 让数据库拥有了时光倒流能力——每个事务都能看到属于自己的那个时间点的数据快照读写互不干扰。四、深入理解 RR间隙锁4.1 什么是间隙锁间隙锁Gap LockInnoDB 在 RR 级别下为了防止幻读对索引记录之间的间隙加的锁。假设users表的id列有索引现有数据10, 20, 30, 40, 50间隙包括 (负无穷, 10), (10, 20), (20, 30), (30, 40), (40, 50), (50, 正无穷)4.2 间隙锁如何防止幻读-- 事务1RR 级别STARTTRANSACTION;SELECT*FROMusersWHEREidBETWEEN20AND30FORUPDATE;-- InnoDB 会锁住-- 1. id20 和 id30 的记录行锁-- 2. 间隙 (20, 30)间隙锁-- 事务2同时执行INSERTINTOusers(id,name)VALUES(25,new);-- ❌ 被阻塞因为 25 在间隙 (20,30) 中COMMIT;-- 事务1 提交释放锁4.3 间隙锁的实现原理间隙锁不是锁住空隙这个抽象概念而是在内存中创建具体的锁对象。间隙锁如何挂载到 B树InnoDB 的索引是 B树结构间隙锁实际上挂载在叶子节点之间的指针上B树叶子节点双向链表 节点1 节点2 节点3 [10,20,30] ⇄ [40,50,60] ⇄ [70,80,90] ↑ ↑ ↑ │ │ │ 间隙锁A 间隙锁B 间隙锁C 锁住(30,40) 锁住(60,70) 锁住(90,∞)关键每个叶子节点包含多条记录节点之间有双向指针连接间隙锁就挂在这些指针上锁住两个节点之间的范围间隙锁的冲突检测当另一个事务尝试插入id25时INSERTINTOusers(id)VALUES(25);检测流程1. 通过 B树定位 id25 应该插入的位置20 和 30 之间 2. 检查该位置所在的间隙 (20,30) 是否有间隙锁 3. 查询内存中的锁哈希表 SELECT * FROM lock_table WHERE index_id idx_id AND gap_start 25 AND gap_end 25 4. 如果找到间隙锁 → 阻塞当前事务 5. 如果没有间隙锁 → 允许插入查看间隙锁MySQL 8.0-- 开启事务并加锁STARTTRANSACTION;SELECT*FROMusersWHEREidBETWEEN20AND30FORUPDATE;-- 在另一个会话中查看锁信息SELECT*FROMperformance_schema.data_locks;-- 输出示例简化------------------------------------------|ENGINE|LOCK_TYPE|LOCK_MODE|LOCK_DATA|------------------------------------------|InnoDB|TABLE|IX|NULL||InnoDB|RECORD|X|20||InnoDB|RECORD|X|30||InnoDB|RECORD|X,GAP|20|← 间隙锁|InnoDB|RECORD|X,GAP|30|← 间隙锁------------------------------------------4.4 间隙锁何时释放答案事务结束时COMMIT 或 ROLLBACK锁类型释放时机间隙锁事务提交或回滚时行锁事务提交或回滚时临键锁事务提交或回滚时重要间隙锁没有锁超时自动释放必须等到事务结束。4.5 间隙锁的危害-- 事务1糟糕的代码STARTTRANSACTION;SELECT*FROMordersWHEREstatuspendingFORUPDATE;-- 锁住了 pending 状态的范围-- 假设这里调用了外部 API耗时 30 秒CALLexternal_api();UPDATEordersSETstatusprocessingWHEREstatuspending;COMMIT;在这 30 秒内其他事务无法插入新的pending订单可能导致业务堆积、超时、死锁教训持有间隙锁的事务必须尽量短五、快照读 vs 当前读操作类型读取方式加锁场景快照读读 Undo Log 中的旧版本不加锁普通 SELECT当前读读数据的最新版本加锁SELECT FOR UPDATE、UPDATE、DELETE-- 快照读MVCCSELECT*FROMusersWHEREid1;-- 当前读加锁SELECT*FROMusersWHEREid1FORUPDATE;UPDATEusersSETbalance100WHEREid1;六、MVCC vs 间隙锁对比总结方面MVCC间隙锁目的解决读写互斥解决幻读阻止插入实现Undo Log Read ViewB树 内存锁对象存储位置Undo Log磁盘 内存内存中的锁哈希表生命周期事务结束 无其他事务需要时清理事务结束立即释放是否阻塞读不阻塞不阻塞快照读阻塞当前读是否阻塞写不阻塞写新版本阻塞阻止插入/更新七、生产环境如何选择隔离级别业务场景推荐级别原因互联网高并发电商、社交RC无间隙锁死锁少性能好金融、对账RR需要可重复读数据一致性要求高数据仓库、报表RC大查询多RR 的间隙锁容易死锁默认不确定RRMySQL 默认兼容性最好查看和设置隔离级别-- 查看当前隔离级别MySQL 8.0SELECTtransaction_isolation;-- 设置当前会话级别SETSESSIONtransaction_isolationREAD-COMMITTED;-- 设置全局级别影响新连接SETGLOBALtransaction_isolationREPEATABLE-READ;八、总结8.1 隔离级别对比隔离级别脏读不可重复读幻读间隙锁并发性能RU✅✅✅❌最高RC❌✅✅❌高RR❌❌❌✅中Serializable❌❌❌✅最低8.2 MVCC 核心公式MVCC 隐藏字段 Undo Log Read View8.3 间隙锁核心公式间隙锁 B树叶子节点指针 内存锁对象 哈希表冲突检测8.4 快速记忆RU啥都不防性能最高 RC只防脏读不防重复互联网最爱 RR防脏防重幻读也防MySQL默认 Serializable全都防住性能最低8.5 一句话总结RC 用 MVCC 每次生成新快照RR 用 MVCC 复用快照 间隙锁防止幻读。MVCC 是写新留旧实现读写不互斥间隙锁是内存锁对象挂载在 B树间隙上阻止插入。理解了这个你就掌握了 MySQL 并发控制的精髓。附录常见问题 FAQQ1RR 级别下普通 SELECT 会有间隙锁吗A不会。普通 SELECT 是快照读基于 MVCC不加任何锁。Q2间隙锁什么时候释放A事务提交或回滚时。间隙锁没有语句执行完就释放的机制。Q3为什么互联网公司偏爱 RCARC 没有间隙锁死锁概率低高并发下性能更好。很多业务如计数、点赞并不需要严格的 RR。Q4MVCC 能解决写写冲突吗A不能。MVCC 只解决读写冲突写写冲突仍然需要行锁。Q5MVCC 是缓存吗写操作是异步的吗A不是。MVCC 是同步写新版本 保留旧版本不是异步或延迟执行。Q6间隙锁是真实存在的锁吗A是的。间隙锁是在内存中创建的真实锁对象挂载在 B树的叶子节点指针上通过哈希表进行冲突检测。

更多文章