公司动态
数据库的脏读、不可重复读和幻读:从面试标准答案到源码级原理
考点分析是否真正理解事务隔离级别四个等级各自解决了什么问题。能否准确区分不可重复读与幻读的触发差异UPDATE/DELETE 针对已存在行INSERT 针对新增行。是否了解 MySQL InnoDB 默认隔离级别及其实际表现尤其是 RR 下普通快照读为何不会幻读。是否掌握 MVCC、ReadView、undo log 以及锁行锁、间隙锁、临键锁的底层机制。能否结合实际业务场景给出合理的隔离级别选型与性能权衡方案。一、标准回答总结脏读、不可重复读、幻读是数据库在并发事务场景下可能出现的三类一致性问题分别对应“读到未提交数据”“同一行数据前后不一致”“范围查询结果集发生增减”。它们共同构成了 SQL 标准中四种事务隔离级别所解决的核心问题。脏读Dirty Read指一个事务读取到另一个事务尚未提交的修改数据。特点是读到“脏数据”一旦对方回滚本事务读到的数据就从未真实存在过。其作用是揭示READ UNCOMMITTED隔离级别的风险以一致性为代价换取最高的并发能力。不可重复读Non-Repeatable Read指同一事务内两次读取同一行数据结果不同原因是另一个事务对该行执行了UPDATE 或 DELETE并提交。特点是针对单行记录前后读取的内容或存在性不一致。幻读Phantom Read指同一事务内两次执行范围查询结果集行数不同原因是另一个事务执行了INSERT并提交新增的行落入当前查询范围。特点是针对多行集合出现“幽灵行”。一句话概括脏读针对“未提交的错误数据”不可重复读针对“已有行被改”幻读针对“凭空多出来的行”。二、核心原理这三类问题的本质是事务隔离性Isolation与并发性能之间的权衡。SQL 标准定义了四种隔离级别从低到高依次是READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。隔离级别脏读不可重复读幻读实现要点READ UNCOMMITTED可能可能可能读不加锁直接读最新数据READ COMMITTED不可能可能可能MVCC 每次查询生成新 ReadViewREPEATABLE READ不可能不可能快照读不可能当前读可能事务内复用 ReadViewInnoDB 用 Next-Key Lock 解决当前读SERIALIZABLE不可能不可能不可能读写完全互斥事务串行执行脏读的产生在 READ UNCOMMITTED 下读操作不获取共享锁直接读取数据页上的最新值。事务 A 修改某行但未提交时事务 B 就能读到该未提交版本。如果事务 A 回滚事务 B 据此做出的决策就建立在不存在的数据之上。不可重复读与 MVCCMySQL InnoDB 通过MVCC多版本并发控制避免脏读。核心由undo log版本链和ReadView组成每条记录被修改时会生成新的版本旧版本存入 undo log并通过DB_TRX_ID、DB_ROLL_PTR等隐藏字段串成版本链。ReadView 记录当前活跃事务集合用于判断某个版本是否对当前事务可见。在 READ COMMITTED 级别每次查询都会生成一个新的 ReadView因此事务 A 第一次查到旧数据后事务 B 提交修改事务 A 第二次查询时新版本已提交且满足可见性条件于是读到新数据产生不可重复读。而在 REPEATABLE READ 级别事务内第一次查询生成 ReadView 后一直复用后续查询都按同一快照判断可见性因此避免了不可重复读。幻读与快照读/当前读幻读的解决比不可重复读更复杂因为它涉及范围而非单行。MySQL InnoDB 在 REPEATABLE READ 下普通SELECT属于快照读基于不变 ReadView 读取版本链因此事务 A 后续快照读不会再看到事务 B 新插入的行避免了幻读。但像SELECT ... FOR UPDATE、UPDATE、DELETE这类语句属于当前读始终读取记录的最新已提交版本。如果事务 B 在事务 A 的范围内插入了新行并提交事务 A 执行当前读就可能“凭空”多出行来。InnoDB 通过Next-Key Lock临键锁 行锁 间隙锁锁定索引区间阻止其他事务在该区间插入新记录从而在当前读场景下彻底解决幻读。官方文档指出InnoDB 的 REPEATABLE READ 通过 MVCC 保证快照读一致性并通过间隙锁与临键锁防止当前读产生幻读因此实际能力高于 SQL 标准对该级别的定义。三、应用场景日常开发场景电商商品详情页展示库存通常使用 READ COMMITTED 或 REPEATABLE READ展示快照数据即可无需追求强一致。金融账户余额查询与转账必须避免脏读和不可重复读通常采用 REPEATABLE READ 或 SERIALIZABLE。分页列表、数据统计、报表导出范围查询较多需要重点防范幻读导致的总数漂移。企业真实场景订单扣减库存扣库存是典型当前读场景若并发插入或更新影响范围查询可能出现超卖或库存统计错误需借助乐观锁、悲观锁或 Next-Key Lock 保证一致性。对账系统同一批次内多次读取账户余额必须保证可重复读否则对账单会出现两边金额对不上的情况。运营报表长时间运行的事务跨越多次范围统计若使用 READ COMMITTED可能因并发写入导致总数前后不一致RR 快照读可提供事务内一致性视图。四、使用方式4.1 环境准备以一个账户表为例CREATE TABLE account ( id INT PRIMARY KEY, name VARCHAR(50), balance DECIMAL(10,2) ); INSERT INTO account VALUES (1, Alice, 1000.00);4.2 JDBC 连接与隔离级别设置通过 JDBC 获取连接并显式设置事务隔离级别import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class IsolationLevelDemo { private static final String URL jdbc:mysql://localhost:3306/demo?useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; public static Connection getConnection(int isolationLevel) throws SQLException { Connection conn DriverManager.getConnection(URL, USER, PASSWORD); conn.setAutoCommit(false); conn.setTransactionIsolation(isolationLevel); return conn; } }4.3 脏读演示public static void dirtyReadDemo() throws Exception { Connection connA getConnection(Connection.TRANSACTION_READ_UNCOMMITTED); Connection connB getConnection(Connection.TRANSACTION_READ_UNCOMMITTED); // 事务 A更新余额但尚未提交 connA.createStatement().executeUpdate( UPDATE account SET balance 900 WHERE id 1); // 事务 B读到 900发生脏读 java.sql.ResultSet rs connB.createStatement().executeQuery( SELECT balance FROM account WHERE id 1); if (rs.next()) { System.out.println(事务B读到余额 rs.getBigDecimal(balance)); } connA.rollback(); // A 回滚后B 之前读到的数据从未真实存在 }4.4 不可重复读演示public static void nonRepeatableReadDemo() throws Exception { Connection connA getConnection(Connection.TRANSACTION_READ_COMMITTED); Connection connB getConnection(Connection.TRANSACTION_READ_COMMITTED); java.sql.ResultSet rs1 connA.createStatement().executeQuery( SELECT balance FROM account WHERE id 1); if (rs1.next()) { System.out.println(第一次读到余额 rs1.getBigDecimal(balance)); // 1000 } // 事务 B 修改同一行并提交 connB.createStatement().executeUpdate( UPDATE account SET balance 900 WHERE id 1); connB.commit(); java.sql.ResultSet rs2 connA.createStatement().executeQuery( SELECT balance FROM account WHERE id 1); if (rs2.next()) { System.out.println(第二次读到余额 rs2.getBigDecimal(balance)); // 900 } connA.commit(); }4.5 幻读演示public static void phantomReadDemo() throws Exception { Connection connA getConnection(Connection.TRANSACTION_REPEATABLE_READ); Connection connB getConnection(Connection.TRANSACTION_REPEATABLE_READ); // 事务 A 第一次快照读 java.sql.ResultSet rs1 connA.createStatement().executeQuery( SELECT COUNT(*) AS total FROM account); if (rs1.next()) { System.out.println(第一次快照读行数 rs1.getInt(total)); // 1 } // 事务 B 插入新行并提交 connB.createStatement().executeUpdate( INSERT INTO account VALUES (2, Bob, 500.00)); connB.commit(); // 事务 A 第二次快照读RR 下复用 ReadView仍为 1不幻读 java.sql.ResultSet rs2 connA.createStatement().executeQuery( SELECT COUNT(*) AS total FROM account); if (rs2.next()) { System.out.println(第二次快照读行数 rs2.getInt(total)); // 1 } // 当前读SELECT ... FOR UPDATE 读取最新已提交版本可能发现新增行 java.sql.ResultSet rs3 connA.createStatement().executeQuery( SELECT id FROM account WHERE id 0 FOR UPDATE); while (rs3.next()) { System.out.println(当前读可见 id rs3.getInt(id)); } connA.commit(); }执行流程说明每个示例都使用两个独立连接模拟两个并发事务通过setTransactionIsolation设置目标隔离级别再用UPDATE/INSERT/COMMIT/ROLLBACK控制提交时机。脏读依赖 READ UNCOMMITTED 下不加锁读不可重复读依赖 READ COMMITTED 每次生成新快照幻读示例则重点说明 RR 下快照读与当前读的差异。注意事项设置隔离级别必须在事务开启、执行 SQL 之前完成。SELECT ... FOR UPDATE会加锁并读取最新数据尽量避免在长事务中使用大范围当前读。生产环境建议通过连接池统一配置隔离级别避免手写琐碎参数。在 Spring 项目中可直接配置Transactional(isolation Isolation.REPEATABLE_READ) public void transferMoney() { // 转账业务逻辑 }五、扩展延伸5.1 技术对比问题触发操作作用对象典型危害脏读读未提交数据被他人修改的未提交行基于不存在的数据做决策不可重复读UPDATE / DELETE已存在的单行两次读同一行内容不一致幻读INSERT范围查询结果集范围统计数量漂移5.2 优缺点权衡低隔离级别优点并发度高、锁竞争少、吞吐量大缺点是一致性弱容易产生脏读、不可重复读、幻读。高隔离级别优点数据一致性强事务行为更符合直觉缺点是锁范围大、事务变长、吞吐下降。RR 是 InnoDB 的默认级别它在一致性、并发性和实现复杂度之间取得了较好平衡。5.3 实际开发注意事项不要盲目使用 SERIALIZABLE它会显著降低并发能力优先通过业务设计、乐观锁或局部加锁解决冲突。避免长事务长事务会长时间持有 ReadView 或锁导致 undo log 膨胀、锁等待和死锁风险上升。区分快照读与当前读对一致性要求高的写操作要注意当前读会读到最新数据必要时使用FOR UPDATE显式加锁。索引设计影响锁范围InnoDB 的间隙锁依赖索引若查询列无索引可能导致锁表或锁范围扩大。六、面试追问追问一MySQL InnoDB 默认隔离级别是什么为什么很少听说它发生幻读回答思路先明确默认级别是 REPEATABLE READ再拆成“快照读”和“当前读”回答。标准答案默认是 REPEATABLE READ。普通 SELECT 是快照读事务内复用同一个 ReadView所以并发 INSERT 不会反映到后续快照查询中因此不出现幻读。真正可能出现幻读的是当前读如FOR UPDATE、UPDATEInnoDB 通过 Next-Key Lock 锁定索引区间加以解决。追问二MVCC 是怎么工作的回答思路抓住三个要素undo log 版本链、ReadView、可见性规则。标准答案每次更新记录时InnoDB 将旧版本写入 undo log并通过回滚指针串联成版本链每个版本携带事务 ID。查询时构建 ReadView记录当前活跃事务集合然后按“版本事务 ID 是否小于最小活跃 ID、是否在本事务集内”等规则判断可见性从而在不加锁的情况下读到一致性快照。追问三当前读和快照读有什么区别回答思路从“读什么版本”和“是否加锁”两个维度对比。标准答案快照读读取 MVCC 一致性视图中的历史版本通常不加锁典型语句是普通 SELECT。当前读读取最新已提交版本并施加行锁或间隙锁典型语句是SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT。追问四实际项目如何选择隔离级别回答思路先给出默认推荐再按业务一致性要求分级。标准答案大多数业务默认使用 REPEATABLE READ 即可。对一致性要求极高、并发压力小的场景如资金清算可考虑 SERIALIZABLE对并发吞吐要求高且能容忍轻微不一致的日志、统计类场景可用 READ COMMITTED。尽量通过业务幂等、乐观锁和缩短事务来降低对高隔离级别的依赖。追问五Spring 事务传播行为会影响隔离级别吗回答思路先说明传播行为和隔离级别是两个正交维度再说明组合影响。标准答案传播行为决定事务如何跨方法传播如 REQUIRED、REQUIRES_NEW隔离级别决定事务内部的数据可见性。两者互不改变但组合时会相互影响例如 REQUIRES_NEW 会挂起外层事务并开启独立事务此时新事务使用自己的隔离级别可能读到外层事务未提交的数据需要结合业务确认是否接受。