深入理解数据库事务隔离级别:从脏读幻读到MVCC与锁机制

📅 发布时间:2026/10/13 7:59:30
深入理解数据库事务隔离级别:从脏读幻读到MVCC与锁机制
做后端开发这些年面试过不少人也在不少技术群里看到过讨论只要聊到数据库事务隔离级别这个话题几乎必被提起。问一句事务的隔离级别你知道几个大部分人都能脱口而出四个答案读未提交、读已提交、可重复读、可串行化。但你要是接着问一句为什么 MySQL 默认是可重复读而 Oracle 默认是读已提交或者可重复读之下到底还会不会出现幻读能干脆利落答上来的人就少了一大半。更麻烦的是这种背过概念但没吃透原理的状态一旦落到真实业务里就是事故现场。我见过有同事因为搞不清可重复读和幻读的关系在事务里先查了一遍订单状态再去做更新结果在并发场景下读到了过期数据导致库存超卖也见过有人为了彻底避免幻读把整个系统的隔离级别全部调成串行化结果线上接口耗时直接涨了一个数量级数据库连接池被打满。这篇文章不打算照着教科书念概念就从脏读、不可重复读、幻读这三种异常现象切入结合锁、MVCC、当前读和快照读这些底层机制把四种隔离级别彻底讲透。顺便把我这些年实际踩过的坑和排查思路也一并整理出来希望能帮你把这块知识真正串起来而不是停留在会背定义的层面。1. 先搞清楚事务隔离到底在解决什么问题1.1 从 ACID 说起隔离性为什么特殊要理解隔离级别得先从事务的四大特性入手。ACID 这四个字母大家都熟原子性、一致性、隔离性、持久性。原子性靠 undo log 保证持久性靠 redo log 保证一致性靠约束和业务逻辑兜底而隔离性就是靠我们今天要讲的这套锁和 MVCC 机制。隔离性的核心诉求其实很朴素多个事务同时在跑的时候大家最好各干各的谁也不影响谁。但现实是数据库要追求吞吐量不可能真的把并发事务一个个排队串行执行那样性能就废了。所以隔离性的本质是在并发执行和数据准确之间找平衡。你可以把数据库想象成一个多人共同编辑的在线文档。一个人正在改某个段落另一个人也打开同一个文档在看。如果第一个人还没保存第二个人就能看到修改前的版本还是修改后的版本如果第一个人删掉了一段文字第二个人看着看着文档内容突然变了会不会觉得很诡异事务隔离级别解决的就是这些类似的问题。1.2 三种典型的异常数据现象数据库领域把并发事务可能导致的异常数据现象总结成了三类名字大家应该都听过脏读、不可重复读、幻读。这三兄弟就是理解隔离级别的钥匙。先说脏读。脏读的意思是一个事务读到了另一个事务尚未提交的数据。注意关键词是未提交。比如事务 A 把某条记录的金额从 100 改成了 80但还没提交这时候事务 B 去读读到了 80。接着事务 A 反悔了执行回滚金额又变回 100。问题来了事务 B 刚才读到的是 80这个 80 根本不存在过因为 A 压根没提交成功。事务 B 拿着一个脏数据去做后续的逻辑结果可想而知。脏读是三种异常里最严重、最不可接受的。再说不可重复读。这个现象发生在同一个事务内同一条记录被读了两次两次的结果不一样的场景。事务 B 先查询了某条记录值是 100然后事务 A 修改了这条记录并提交值变成了 80事务 B 再次查询同一条记录发现值变成了 80。问题在哪事务 B 是同一个事务按道理应该看到一致的快照结果一次事务里看到两个不同的值所以叫不可重复读。最后是幻读。幻读和不可重复读有点像但有个关键区别不可重复读针对的是同一条记录而幻读针对的是一批记录。事务 B 执行了一次范围查询比如select * from orders where status pending查到 100 条记录。这时候事务 A 插入了一条新的 status 为 pending 的记录并提交。事务 B 再次执行同样的范围查询发现变成了 101 条记录。多出来的那一条就像幻觉一样凭空出现了所以叫幻读。这三种现象和隔离级别之间不是一对一的关系而是层层递进的关系。后面的内容会展开细讲。2. 四种隔离级别与它们的底层实现逻辑2.1 SQL 标准定义的隔离级别阶梯SQL 标准根据隔离程度从低到高定义了四个隔离级别读未提交、读已提交、可重复读、可串行化。读未提交是最宽松的级别它只保证不会出现物理层面的数据损坏脏读、不可重复读、幻读都有可能发生。虽然在实践中极少有人真的用这个级别但理解它有助于反衬其他级别的意义。读已提交比读未提交严格一层它禁止了脏读但不可重复读和幻读依然可能出现。这个级别是很多数据库的默认选择后面会展开讲。可重复读在读已提交的基础上进一步禁止了不可重复读也就是同一个事务里多次读取同一条记录结果必须是一致的。但在这个级别下幻读在 SQL 标准的定义里依然是可能发生的。可串行化是最严格的级别它要求所有事务的效果等同于串行执行三种异常全都不允许出现。代价是并发性能大幅下降。用一个表格来展示这个递进关系会更直观隔离级别脏读不可重复读幻读读未提交可能可能可能读已提交不可能可能可能可重复读不可能不可能可能SQL 标准定义可串行化不可能不可能不可能有一个细节值得注意这张表是 SQL 标准定义的语义但实际数据库厂商因为实现机制不同在某些级别下的实际表现会和这张表不太一样。典型代表就是 MySQL 的 InnoDB 引擎它在可重复读级别下其实已经通过 next-key lock 基本消除了幻读这一点后文会重点说明。2.2 锁机制最基础的隔离手段隔离级别从底层看无非是两种手段的组合锁和 MVCC。最原始的隔离方式就是加锁。锁大致分两类共享锁和排他锁。共享锁也叫读锁多个事务可以同时持有共享锁大家都只读不写。排他锁也叫写锁一旦某个事务持有了排他锁其他事务既不能加共享锁也不能加排他锁只能等待。如果一条记录被加了锁那么其他事务想读、想写都得按照锁的兼容性规则来。这种机制能保证数据访问的互斥性但代价也很明显锁竞争导致并发度降低而且很容易死锁。锁的粒度也有讲究。行锁只锁住某一行并发度高但可能出现某个范围里的多行被分散加锁管理成本高。表锁锁住整张表实现简单但并发度极低。间隙锁和临键锁则是专门为了解决幻读而设计的后面会有专章细说。2.3 MVCC让读写不互斥的关键设计如果隔离性只靠锁来实现那性能会非常难看。因为读要加读锁写要加写锁读和写互相阻塞数据库在并发场景下基本就废了。所以现代数据库普遍引入了 MVCC全称是多版本并发控制。MVCC 的核心思路是一条记录在数据库里不只保留当前版本还会通过 undo log 保留历史版本。每一个事务读取数据的时候会拿到一个一致性视图read view这个视图决定了它能看到哪些版本的数据。写入事务在修改数据时不是原地覆盖而是生成一个新的版本旧版本依然保存在版本链里。这里一个关键概念出来了快照读。在 MVCC 机制下普通的select语句不会去加锁而是生成一个一致性视图然后顺着版本链查找找到在视图可见范围内的版本。这就是所谓的一致性快照读。快照读的好处是读操作和写操作不互相阻塞读走的是版本链写走的是当前版本两条路并行不悖。与快照读相对的叫当前读也就是select ... for update、insert、update、delete这类操作。它们读的一定是记录的最新已提交版本并且会对读取的记录加锁防止其他事务并发修改。了解快照读和当前读的区别对后面的理解至关重要。我见过不少人踩坑就是因为在可重复读级别下以为快照读就万事大吉了结果写操作走的是当前读依然受到了锁的约束最后数据还是对不上问题恰恰出在这个地方。3. 从脏读到幻读逐个击破3.1 脏读发生的原因与消除机制脏读的发生条件其实就是隔离级别没有达到读已提交。在读未提交级别下事务 A 修改了数据但还没提交事务 B 就能读到修改后的结果。这背后没有任何机制去阻止读到未提交的数据因为读未提交级别的读操作压根不会理会事务的提交状态。怎么消除脏读答案很简单让读操作只读已提交的数据。在读已提交级别下每次执行 SQL 语句时数据库都会生成一个新的快照这个快照里包含了已提交事务的数据变更未提交事务的变更不可见。所以如果事务 A 改了数据但没提交事务 B 执行查询时快照里 A 的变更还不存在B 就读不到那份变化。这里有一个很关键的细节读已提交的实现方式是语句级快照也就是一个事务里的每条 SQL 语句都会单独生成快照。注意这个区别到了可重复读级别就变成了事务级快照同一个事务的所有语句共用同一个快照。这个差异正好解释了为什么读已提交会出现不可重复读而可重复读不会。3.2 不可重复读发生的原因与消除机制用一个具体的用户转账场景来说明不可重复读。假设账户表里有一条记录account_id10001balance500。事务 A 准备做一次转账第一步先查询这条记录的余额得到 500心里想可以转。这时候事务 B 发生了给这个账户入账了 200余额变成了 700事务 B 提交。事务 A 在同一个事务里再次查询这条账户记录发现余额已经变成了 700。问题就出在这里事务 A 是同一个事务前后两次查询得到的结果却不一样那么事务 A 内部的业务逻辑就可能出乱子。比如它基于第一次查询的 500 去做了某个判断再基于第二次查询的 700 又做了另一个判断两次判断互相矛盾代码就跑出了不符合预期的结果。在读已提交级别下为什么必然会产生这个问题因为每条 SQL 语句都生成新快照事务 B 提交后事务 A 的下一条 SQL 语句生成的新快照自然能看到事务 B 的变更。所以同一事务内前后读到不同值是读已提交的天然副作用。解决办法就是可重复读。在这个级别下快照不是每条语句生成的而是事务的第一条读语句执行时生成的整个事务期间都使用同一个一致性视图后续所有查询都基于这个视图。事务 B 提交得再早也不会影响事务 A 已经确立的快照视角所以不可重复读被消除了。3.3 幻读为什么最难搞幻读的棘手程度远超脏读和不可重复读。因为它涉及的不再是某一条记录的可见性问题而是是否有新的记录插进来的问题。举个例子。事务 A 执行一个范围查询select * from orders where order_date between 2024-01-01 and 2024-01-31快照读看到 100 条记录。事务 B 在这个时间范围内插入了一条新的订单并提交。事务 A 再次执行同样的范围查询如果按照快照读的逻辑它依然只看到 100 条记录看不到新插入的那一条。看起来幻读被避免了对吧问题在于如果事务 A 接下来要更新这个范围内的数据呢假设事务 A 执行update orders set status processed where order_date between 2024-01-01 and 2024-01-31。这个 update 属于当前读它不会用快照读的视图而是读取当前最新的已提交数据然后加锁修改。于是问题来了事务 B 插入的那条新记录因为不属于事务 A 的快照事务 A 一开始没看见但在当前读的阶段这条记录却实实在在地进入了 update 的处理范围被一起更新了。这就是幻读的完整面貌一个事务先看到了一个范围内的记录集合但另一事务插入新记录后这个事务在后续操作中凭空多处理了一条它之前没见过的记录导致数据逻辑出问题。更微妙的是如果业务先做了一次统计查询然后又做了一次写入操作两个操作基于的记录集合不一致那统计结果和实际更新范围就对不上这种数据错乱在并发业务里非常容易出现。4. 数据库厂商的具体实现差异4.1 MySQL 的 InnoDB 引擎为什么默认是可重复读很多人有疑问SQL 标准里可重复读明明还是可能出现幻读的为什么 MySQL 要把默认隔离级别设成可重复读难道不怕出问题吗关键在于MySQL 的 InnoDB 引擎在可重复读级别下通过引入间隙锁和临键锁实际上把 SQL 标准中可重复读允许的幻读也给基本解决掉了。换句话说MySQL 的可重复读实际表现非常接近串行化在防幻读上的效果但并发性能又比串行化好得多。具体实现机制是这样的。InnoDB 的索引结构是 B 树记录在物理存储上有序排列。间隙锁锁的是一个范围区间而不是具体的记录。临键锁则是记录锁和间隙锁的结合它锁住一条记录以及它前面的间隙。当在可重复读级别下执行一个范围查询的当前读时InnoDB 会使用临键锁锁住查询范围内已有的记录同时锁住它们之间的间隙以及其他事务可能插入新记录的空隙。这样其他事务想要在这个范围内插入新记录就必须先获取对应的插入意向锁而插入意向锁和间隙锁互斥所以插入操作会被阻塞。幻读的路径被彻底堵死。这里有一个典型例子。假设表里只有 id 为 1 和 5 两条记录事务 A 执行select * from t where id 2 for update。InnoDB 不仅会锁住 id5 这条记录还会锁住 (2, 5) 这个区间以及 (5, 正无穷) 这个区间。事务 B 如果想插入 id3 或者 id10 的新记录都会被间隙锁挡住必须等事务 A 提交后才能执行。这样事务 A 在整个事务期间用当前读看到的记录集合始终保持一致不可能出现多出来一条新记录的情况。4.2 PostgreSQL 和 Oracle 为什么默认选读已提交和 MySQL 不同PostgreSQL 和 Oracle 的默认隔离级别都是读已提交。这不是因为它们做得不如 MySQL而是设计哲学和适用场景不一样。PostgreSQL 的 MVCC 实现方式与 MySQL 略有差异它的多版本机制对读已提交级别支持得非常自然每条语句一个快照实现简单直接。而且在 PostgreSQL 里即便是在读已提交级别下通过在语句里使用FOR UPDATE配合适当的锁也能在特定场景达到防止幻读的效果。Oracle 是商业数据库的老牌厂商它的默认选择读已提交很大程度上是出于对性能和并发度的考量。在 Oracle 的设计体系里读已提交已经能消除绝大多数数据异常而且能够最大程度保证读写不阻塞这对于企业级高并发系统来说价值巨大。这里引出了一个重要的现实问题如果你所在的公司从 Oracle 迁移到 MySQL或者反之隔离级别的语义变化很容易被忽略进而引发数据一致性问题。我曾经参与过一个从 Oracle 迁移到 MySQL 的项目开发和测试阶段一切正常上线后却出现了大量数据对不上的故障。最终排查下来发现就是因为两个数据库的默认隔离级别不同Oracle 的读已提交在同样的业务代码下读到的数据快照语义和 MySQL 的可重复读不一样导致的业务逻辑在实际并发场景下表现不一致。所以跨数据库迁移的时候隔离级别的差异必须列为重点检查项。要逐条审视敏感的业务操作明确哪些语句是快照读、哪些语句是当前读并针对目标数据库的锁机制重新评估事务设计。5. 实战中的隔离级别选型与避坑建议5.1 业务场景怎么选没有最好只有合适聊完了原理和实现接下来是真正能落地的部分业务里到底怎么选隔离级别最粗暴的答案是一律上串行化保证万无一失。但这基本等于宣布系统的并发能力作废。串行化会把并发的写操作强制排队接口的响应时间会大幅上升数据库连接容易堆积高并发场景下系统很容易被拖垮。正确的思路是先明确业务的数据一致性诉求再选择能兜住底线的最宽松隔离级别。读未提交几乎不考虑除非你做的是完全无所谓的统计类任务还要能容忍数据错乱。读已提交适合大部分普通业务系统比如文章发布、消息记录、日志查询等这类场景对数据一致性要求不高读到了稍微旧一点的数据也不影响功能。可重复读适合那些一个事务内部需要反复读取同一批数据且数据不能变的场景。最典型的就是对账系统事务内先查询汇总金额接着用这个金额去做后续的核销或入账中间如果数据变了账就对不平了。可串行化只用在对一致性要求极端苛刻的场景比如库存扣减、账户余额变更等金额强敏感操作。但即便如此我依然建议优先考虑用乐观锁 数据库层面的唯一约束 事务重试机制来替代全局串行化这样既能保证一致性又不至于牺牲全部并发度。5.2 常见问题排查实录明明是可重复读为什么还是出了问题这里整理几个我实际遇到过的排查案例每个都很有代表性。第一个案例可重复读下依然出现幻读。一个订单系统使用了可重复读隔离级别某次晚上执行批量退款任务时出现了重复退款的故障。排查发现问题出在事务内先执行了一个普通的select查询快照读确认了一批待退款订单接着对这批订单执行update当前读。由于快照读和当前读的视野不同期间另一个事务新插入了一笔订单这笔订单不在快照读的结果里却被当前读的 update 处理了于是多退了一笔款。解决方式有两种方向。一种是把业务改成使用select ... for update做当前读从一开始就按最新数据加锁避免快照视野和当前视野错位另一种是调整事务逻辑确保所有读取都基于同一种读数模式不要混用快照读和当前读。第二个案例长事务引发 undo log 膨胀数据库磁盘暴涨。这个问题和隔离级别有直接关系。可重复读级别要求事务期间保留下创建快照时刻的版本数据如果事务长时间不提交历史版本就无法被清理。尤其在高并发写入的场景下undo log 会越积越多最终把磁盘撑爆整库性能雪崩。排查后发现问题的根源是有个定时任务在事务里做了大量计算事务长时间不提交。解决办法是拆分长事务把计算逻辑挪出事务或者缩短事务执行时间尽早提交。第三个案例间隙锁导致的死锁。两个事务同时做范围更新互相锁住了对方需要的间隙形成死锁。这类问题在可重复读下比较常见。排查建议是在业务代码里处理好锁顺序确保多个事务在更新数据时都遵循相同的加锁顺序同时合理利用索引尽量让行锁的范围精确减少锁冲突的概率。死锁发生后数据库会自动回滚其中一个事务但业务侧需要做好重试机制避免因为死锁回滚导致接口报错。5.3 不同隔离级别下的排查技巧速查表现象可能原因排查思路读到未提交数据隔离级别被设置成读未提交检查事务隔离级别配置确认是否有人误修改了全局或会话级别设置同一事务内多次读取同一记录值不同事务在读已提交级别下运行确认隔离级别评估是否需要可重复读如必须采用读已提交则业务上要接受较快照稍旧的数据同一事务内范围查询结果数量变化混用快照读和当前读或存在间隙锁覆盖不到的插入路径梳理事务内所有 SQL 的读模式确认目标范围是否完全被临键锁覆盖系统突然出现大量锁等待、超时长事务持有锁不释放或其他事务需要同范围间隙锁检查事务执行时间使用监控视图查看锁等待情况定位持有锁的源头事务数据库磁盘占用异常增长undo log 膨胀检查长事务和旧事务的快照保留情况尽早释放长时间不结束的事务6. 写在最后隔离级别是工具不是教条讲到这里事务隔离级别的整体脉络应该已经清晰了。从脏读、不可重复读到幻读三种异常现象对应了数据一致性的不同破坏程度从读未提交到可串行化四种隔离级别给出了不同的平衡点从锁到 MVCC再到各种临键锁、间隙锁底层机制为这些平衡提供了工程实现。我在实际工作中最深的一点体会是隔离级别不是背下来就够的要真正理解每种级别在特定数据库下的实际行为尤其要关注快照读和当前读的差异。很多线上事故的根源不是开发者不知道选哪个隔离级别而是没有搞清楚自己写的 SQL 到底走的是哪条读数路径。如果你现在要去排查一个数据一致性问题我建议按这个顺序来先确认事务的隔离级别再逐条分析事务内的 SQL判断哪些是快照读、哪些是当前读画出时间线看其他并发事务可能在哪个节点插入或修改数据最后根据具体机制选择调整事务逻辑、加锁方式或者隔离级别。这套排查路径远比盲目调高隔离级别更有效。最后再分享一个实用的小技巧在测试环境里模拟并发问题时不要只是简单地开两个数据库连接去执行 SQL那样很难复现真实场景。我会用压测工具或者并发脚本同时发起多个事务并在事务中间设置断点人为控制执行节奏这样才能真实地暴露隔离级别和锁机制带来的并发行为。多在这种环境下踩几次坑你对隔离级别的理解会比纯粹看书深刻得多。