事务并发三大隐患:脏读、不可重复读、幻读,到底是什么?(二)

📅 2026/7/11 21:37:44
事务并发三大隐患:脏读、不可重复读、幻读,到底是什么?(二)
数据库事务系列 · 第 2 篇引言一个银行柜台的真实困境先回忆一下第 1 篇的核心内容。我们讲了事务的四大特性ACID其中隔离性Isolation是最复杂的一个。它的目标是多个事务同时执行时互不干扰。但「互不干扰」说起来容易做起来难。让我们回到一个真实的银行场景张三的账户余额是100 元。柜员小张终端A正在处理一笔转账张三要给李四转 100 元。操作分两步① 张三账户减 100② 李四账户加 100。就在小张刚执行完第一步张三余额变为 0、正准备执行第二步时柜员小李终端B接到一个查询请求张三的余额是多少问题来了——小李应该看到什么如果看到0 元但小张的事务最后失败了回滚了那小李看到的 0 元就是个「假数据」。如果看到100 元事务开始前的值那说明小李被「隔离」了看不到中间状态。如果小张的事务已经提交了小李查到的结果变了那小李之前做的决策可能就错了。这就是隔离性要解决的核心矛盾既要让多个事务并发执行提高效率又要保证每个事务看到的数据是正确的保证准确。为了解决这个矛盾数据库提供了不同的「隔离级别」。级别越高数据越准确但效率越低级别越低效率越高但数据越容易出错。在正式介绍隔离级别之前我们必须先搞清楚一件事如果不做隔离控制会发生什么问题这就是我们今天要讲的——并发事务的三大隐患。一、三大并发隐患1. 脏读Dirty Read——读到「临时变卦」的数据一句话定义一个事务读取了另一个尚未提交的事务修改过的数据。「尚未提交」意味着这个数据随时可能被回滚。读到这种数据就像看了一张还没定稿的草稿纸——你当真了但它其实是「脏」的。场景再现把上面的银行场景细化一下时间柜员小张终端A柜员小李终端B张三余额T1BEGIN;100T2UPDATE account SET balance 0 WHERE id 1;扣钱0未提交T3BEGIN;T4SELECT balance FROM account WHERE id 1;读到 0T5ROLLBACK;转账失败回滚恢复为 100T6SELECT balance FROM account WHERE id 1;读到 100小李在 T4 时刻读到了 0 元但小张在 T5 回滚了。0 元从未真正存在过——它是一个「脏数据」。为什么叫「脏读」因为这个数据是「不洁的」——它来自于一个还未确定的事务随时可能被擦掉回滚。就像从垃圾桶里捡起一张写了半截的支票以为能兑付结果人家根本没签完。对业务的影响这部分最重要请仔细看柜员小李读到余额为 0 后可能做了以下事情告诉客户「您的余额不足转账失败」→ 客户投诉拒绝了一笔贷款申请 → 银行失去一个客户在系统里记录了一条「余额不足」的日志 → 后续审计出问题而这一切都建立在一个「从未真实存在过」的数据之上。总结脏读的本质是「读到未提交的数据」。解决脏读就是禁止读取未提交的数据。2. 不可重复读Non-Repeatable Read——两次读取「翻脸不认人」一句话定义一个事务内两次读取同一行数据得到的结果不同。「不可重复读」这个名字很直白——同样的查询重复做读出来的东西不一样。场景再现时间柜员小李终端B柜员小张终端A张三余额T1BEGIN;100T2SELECT balance FROM account WHERE id 1;读到100T3BEGIN;T4UPDATE account SET balance 0 WHERE id 1;T5COMMIT;提交0T6SELECT balance FROM account WHERE id 1;读到0同一个事务小李的事务内两次读取同一行数据结果从 100 变成了 0。脏读 vs 不可重复读重点区分脏读不可重复读读到的是其他事务未提交的数据其他事务已提交的数据关键问题数据可能被回滚读的是「假数据」数据已提交但两次读结果不一样谁的问题其他事务还没决定好你就读了其他事务决定好了你前后不一致打个比方脏读你看到同事屏幕上写了一半的文档以为定稿了 —— 结果他删掉了。不可重复读你打印了一份文档同事修改后重新打印了一份你手上两份不一样。对业务的影响小李第一次查询余额为 100 元据此做了一份财务报告。第二次查询发现是 0 元报告作废。更严重的场景小李第一次查询后批准了一笔以「余额 100 元」为担保的贷款。第二次查询发现余额为 0贷款已经放出去了。总结不可重复读的本质是「事务内多次读取不一致」。解决不可重复读就是保证事务内多次读取同一数据的结果不变。3. 幻读Phantom Read——数据「凭空出现」或「突然消失」一句话定义一个事务内两次查询同一范围的数据结果集的行数不同。「幻读」这个名字很形象——多出来的那行数据就像「幻影」一样凭空出现。场景再现时间柜员小李终端B柜员小张终端A账户数量T1BEGIN;3 个账户T2SELECT COUNT(*) FROM account WHERE balance 0;结果为3T3BEGIN;T4INSERT INTO account VALUES (4, 赵六, 50);T5COMMIT;4 个账户T6SELECT COUNT(*) FROM account WHERE balance 0;结果为4同一个事务内两次查询同一条件行数从 3 变成了 4。多出来的那一行就像「幻影」一样。不可重复读 vs 幻读重点区分不可重复读幻读关注点同一行数据的值变了结果集的行数变了引发操作其他事务的UPDATE其他事务的INSERT / DELETE结果同一行内容不同多了一行或少了一行打个比方继续用文档的比喻不可重复读文档内容被修改了你手上的版本和最新版本不一样。幻读文档里凭空多了一段话或者少了一段话你数了数段落数对不上。对业务的影响小李统计「余额大于 0 的账户数」为 3 个据此做了资金分配方案。执行时发现多了一个账户赵六方案需要重新制定。电商场景运营人员统计「待发货订单」为 10 笔准备批量处理。处理过程中用户又下了 1 笔新订单结果漏掉了这笔。总结幻读的本质是「事务内数据行数变化」。解决幻读就是防止事务执行期间其他事务插入或删除影响结果集的数据。二、三大问题对比总结✨✨对比维度脏读不可重复读幻读一句话定义读到未提交的数据两次读同一行结果不同两次查同一个范围行数不同根本原因读取了其他事务的中间状态数据被其他事务修改并提交数据行被其他事务增删涉及操作SELECTUPDATE未提交SELECTUPDATE已提交SELECTINSERT/DELETE对业务的影响基于「假数据」做决策前后判断不一致统计不准漏掉或重复解决思路禁止读未提交的数据保证事务内多次读取一致禁止或锁定范围内的增删一张图总结三大问题三、解决方案四种隔离级别前面讲的三个问题是「没有隔离控制」时会发生的。数据库当然不会坐视不管——它提供了四种隔离级别逐一解决这些问题。隔离级别的整体阶梯从低到高依次是安全性从低到高性能从高到低——这是一个经典 trade-off权衡。⚠️ 关于 Repeatable Read 和幻读MySQL 的 InnoDB 引擎通过「间隙锁Gap Lock」机制在 Repeatable Read 级别下也基本避免了幻读。这个我们会在第 3 篇详细讲。各个隔离级别详解级别 1Read Uncommitted读未提交规则允许读取其他事务未提交的数据。优点并发性能最高基本不加锁缺点脏读、不可重复读、幻读都可能发生一句话评价几乎不用只有在完全不在乎数据准确性的场景才可能考虑级别 2Read Committed读已提交—— Oracle / PostgreSQL / SQL Server 默认规则只能读取其他事务已提交的数据。优点避免了脏读缺点不可重复读和幻读仍可能发生一句话评价大多数数据库的默认选择在「安全性」和「性能」之间取得了较好平衡典型场景社交平台、内容管理系统等对「可重复读」不敏感的业务级别 3Repeatable Read可重复读—— MySQL 默认规则事务内多次读取同一数据结果始终一致。优点避免了脏读和不可重复读缺点幻读理论上仍可能发生一句话评价MySQL 的默认选择通过 MVCC 实现了「事务内读取一致性」——即使其他事务提交了当前事务读到的还是事务开始时的数据典型场景订单系统、库存管理等对数据一致性要求较高的业务级别 4Serializable串行化—— 最强但最慢规则所有事务串行执行一个做完下一个才能开始。优点避免了所有并发问题缺点性能极低等于把并发请求变成了排队一句话评价极少使用只有在对一致性要求极高且并发量极小的场景才考虑典型场景银行核心账务的某些关键操作不同数据库的默认隔离级别常识积累数据库默认隔离级别MySQLInnoDBRepeatable Read可重复读OracleRead Committed读已提交PostgreSQLRead Committed读已提交SQL ServerRead Committed读已提交面试中常问「为什么 MySQL 用 Repeatable Read而 Oracle 用 Read Committed」——历史原因 MySQL 的 InnoDB 通过间隙锁在 RR 下也能避免幻读所以选择 RR 作为默认值。四、实操演示光看理论不够我们来实际操作亲眼看看每种隔离级别的表现。准备工作查看当前隔离级别-- MySQL 8.x 查看当前会话的隔离级别SELECTtransaction_isolation;-- 或者SHOWVARIABLESLIKEtransaction_isolation%;设置隔离级别-- 设置当前会话的隔离级别推荐不影响其他连接SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;-- 设置全局隔离级别影响所有新连接需要权限SETGLOBALTRANSACTIONISOLATIONLEVELREADCOMMITTED;⚠️致命提醒设置隔离级别必须在开启事务之前一旦执行了BEGIN;再修改隔离级别就不生效了。数据准备-- 确保只有一条记录DELETEFROMaccount;INSERTINTOaccountVALUES(1,张三,100);演示一Read Uncommitted —— 亲眼看到脏读目标演示一个事务如何读到另一个事务未提交的数据。Step 1两个终端都设置隔离级别SETSESSIONTRANSACTIONISOLATIONLEVELREADUNCOMMITTED;Step 2终端A 开启事务修改张三余额但不提交BEGIN;UPDATEaccountSETbalance0WHEREid1;现在张三的余额在终端A中变成了 0但事务还没提交。Step 3终端B 查询张三余额SELECT*FROMaccountWHEREid1;关键来了终端B 读到的 0是终端A 还没确定的数据可能回滚。Step 4终端A 回滚事务ROLLBACK;Step 5终端B 再次查询SELECT*FROMaccountWHEREid1;结果展示终端B 第一次查询balance 0 ← 读到脏数据 终端B 第二次查询balance 100 ← 回滚后恢复结论Read Uncommitted 允许脏读。终端B 读到的是一个「从未真实存在过」的数据。对业务的影响柜员小李看到余额为 0拒绝了客户的取款请求。但实际上张三是 100 元只是因为小张的操作还没完成。这就是脏读带来的真实业务风险。演示二Read Committed —— 脏读消失但不可重复读出现目标演示 Read Committed 如何避免脏读以及它为什么仍会有不可重复读。Step 1两个终端切换隔离级别SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;Step 2终端B 开启事务第一次查询BEGIN;SELECT*FROMaccountWHEREid1;Step 3终端A 修改并提交BEGIN;UPDATEaccountSETbalance0WHEREid1;COMMIT;Step 4终端B 同一个事务内第二次查询SELECT*FROMaccountWHEREid1;结果展示终端B 第一次查询事务内balance 100 终端B 第二次查询同一事务内balance 0 ← 不可重复读发生结论Read Committed 避免了脏读读不到未提交的数据但无法避免不可重复读同一事务内两次查询结果不同。对业务的影响柜员小李在同一个事务里第一次看到 100 元第二次看到 0 元。如果她第一次查询后做了财务记录第二次查询后需要推翻重来。演示三Repeatable Read —— 不可重复读消失目标演示 Repeatable Read 如何让事务内多次读取结果一致。Step 1两个终端切换隔离级别SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;Step 2终端B 开启事务第一次查询BEGIN;SELECT*FROMaccountWHEREid1;Step 3终端A 修改并提交BEGIN;UPDATEaccountSETbalance0WHEREid1;COMMIT;Step 4终端B 同一个事务内第二次查询SELECT*FROMaccountWHEREid1;Step 5终端B 提交事务再次查询COMMIT;SELECT*FROMaccountWHEREid1;结果展示终端B 事务内第一次查询balance 100 终端B 事务内第二次查询balance 100 ← 没变 终端B 提交后再查balance 0 ← 事务结束后才看到新数据结论Repeatable Read 保证了事务内多次读取同一数据结果一致——即使其他事务已经提交了修改。对业务的影响柜员小李在整个事务期间看到的是一个「稳定快照」——无论小张怎么改小李看到的数据始终是事务开始时的状态。这保证了决策的一致性。她提交事务后才能看到最新的数据。演示四Serializable —— 最强隔离完全串行目标演示 Serializable 如何通过「排队执行」避免所有并发问题。Step 1两个终端切换隔离级别SETSESSIONTRANSACTIONISOLATIONLEVELSERIALIZABLE;Step 2终端B 开启事务查询数据BEGIN;SELECT*FROMaccountWHEREid1;Step 3终端A 尝试修改数据BEGIN;UPDATEaccountSETbalance0WHEREid1;此时终端A被阻塞等待中因为终端B 的读锁还没释放。Step 4终端B 提交事务COMMIT;终端A 的UPDATE立即执行成功。结果展示终端B 持有读锁 → 终端A 的 UPDATE 被阻塞 终端B 提交 → 终端A 的 UPDATE 才执行结论Serializable 通过强制串行执行避免了所有并发问题但代价是并发性能极低——所有操作排队。对业务的影响银行核心账务中的某些操作宁可慢也不能错。Serializable 提供了「绝对正确」但代价是其他人需要等待。五、附录完整操作命令速查隔离级别相关命令-- 查看当前会话隔离级别SELECTtransaction_isolation;-- 设置当前会话隔离级别SETSESSIONTRANSACTIONISOLATIONLEVELREADUNCOMMITTED;SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;SETSESSIONTRANSACTIONISOLATIONLEVELSERIALIZABLE;演示中使用的数据-- 初始化数据DELETEFROMaccount;INSERTINTOaccountVALUES(1,张三,100);-- 查看数据SELECT*FROMaccount;-- 清空数据重新演示用TRUNCATEaccount;六、全文总结核心知识点回顾问题定义解决级别脏读读到其他事务未提交的数据Read Committed不可重复读同一事务内两次读同一行结果不同Repeatable Read幻读同一事务内两次查同一范围行数不同Serializable隔离级别速记表隔离级别脏读不可重复读幻读性能Read Uncommitted✅✅✅最高Read Committed❌✅✅↓Repeatable Read❌❌⚠️↓Serializable❌❌❌最低一句话记住全部脏读是读到了「未决定」的数据不可重复读是「同一行数据变了」幻读是「行数变了」。Read Uncommitted 全都不防Read Committed 防脏读Repeatable Read 防脏读不可重复读Serializable 全都防。下篇预告本文我们用「张三的余额」作为线索演示了四种隔离级别的差异。但你可能会问Repeatable Read 是怎么做到的为什么终端A已经提交了终端B还是读不到MySQL 在 Repeatable Read 下到底有没有解决幻读怎么解决的锁和MVCC到底是什么关系mitted | ✅ | ✅ | ✅ | 最高 || Read Committed | ❌ | ✅ | ✅ | ↓ || Repeatable Read | ❌ | ❌ | ⚠️ | ↓ || Serializable | ❌ | ❌ | ❌ | 最低 |一句话记住全部脏读是读到了「未决定」的数据不可重复读是「同一行数据变了」幻读是「行数变了」。Read Uncommitted 全都不防Read Committed 防脏读Repeatable Read 防脏读不可重复读Serializable 全都防。下篇预告本文我们用「张三的余额」作为线索演示了四种隔离级别的差异。但你可能会问Repeatable Read 是怎么做到的为什么终端A已经提交了终端B还是读不到MySQL 在 Repeatable Read 下到底有没有解决幻读怎么解决的锁和MVCC到底是什么关系