深入理解MySQL事务隔离级别:MVCC机制与Next-Key Lock如何解决幻读问题?
在后端面试尤其是面高级开发或者架构岗时MySQL 的事务隔离级别几乎是必考题。但大多数人的回答还停留在背诵教科书的阶段“RR 级别解决了不可重复读但可能存在幻读……”呃理解有点不到位哈在 MySQL 的 InnoDB 引擎中Repeatable Read (RR)隔离级别下通过MVCC多版本并发控制和Next-Key Lock临键锁的配合实际上已经解决了绝大部分的幻读问题。为什么在某些极端场景下幻读依然会像幽灵一样出现甚至为什么你的代码明明加了事务却还是引发了死锁Deadlock如果对 MySQL 的“当前读”和“快照读”理解不到位可能你会在事务中混合使用了SELECT和UPDATE导致了逻辑上的幻读漏洞这是不懂底层原理写出来的代码就是线上的定时炸弹。很多开发者认为“数据库事务”是一个黑盒只要加了Transactional就万事大吉。殊不知MySQL 的 InnoDB 为了性能和一致性的平衡搞了一套极其复杂的机制。1. 核心原理解析MVCC 与 幻读的博弈首先我们要明确什么是幻读Phantom Read。 简单说同一个事务内前后两次查询第二次看到了第一次没看到的“新行”注意是新增不是修改。InnoDB 在 RR 级别下通过两套组合拳来解决这个问题对于普通查询快照读使用MVCC。对于锁定读/修改当前读使用Next-Key Lock。1.1 MVCC时光机般的快照读MVCC 的核心在于我不加锁但我能让你看到你应该看到的数据版本。它依赖于三个隐藏字段DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID和 Undo Log。当一个事务开启时InnoDB 会为它生成一个Read View一致性视图。你可以把它理解为一张“照片”。1.1.1 核心逻辑图解我们来看一下 MVCC 的版本链机制1.1.2 生产环境代码演示快照读的“欺骗性”为了演示我们使用 Java 21 Spring Boot 3 MyBatis-Plus。 假设我们有一个表course_inventory。示例 1环境准备// 实体类 public class CourseInventory { private Long id; private String courseName; private Integer stock; // 省略 getter/setter } // 对应的 SQL // CREATE TABLE course_inventory ( // id bigint PRIMARY KEY, // course_name varchar(50), // stock int // ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; // INSERT INTO course_inventory VALUES (1, 架构师训练营, 10);示例 2MVCC 快照读演示Service public class MvccDemoService { Autowired private CourseInventoryMapper mapper; /** * 模拟事务 A读取数据 * 此时隔离级别为 RR (MySQL 默认) */ Transactional(isolation Isolation.REPEATABLE_READ) public void transactionA(CountDownLatch latchA, CountDownLatch latchB) { // 第一次查询 var course1 mapper.selectById(1L); System.out.println(事务A - 第一次查询库存: course1.getStock()); // 预期: 10 latchA.countDown(); // 通知事务B开始执行 try { latchB.await(); // 等待事务B提交 } catch (InterruptedException e) { e.printStackTrace(); } // 第二次查询 (此时事务B已经修改并提交) var course2 mapper.selectById(1L); System.out.println(事务A - 第二次查询库存: course2.getStock()); // 预期: 10 (MVCC 生效读取的是快照) // 运行结果说明 // 即使 B 修改成了 5A 看到的依然是 10。 // 这就是 MVCC 保证的可重复读。 } /** * 模拟事务 B修改数据 */ Transactional(isolation Isolation.REPEATABLE_READ) public void transactionB(CountDownLatch latchA, CountDownLatch latchB) { try { latchA.await(); // 等待 A 开启并完成第一次读 } catch (InterruptedException e) { e.printStackTrace(); } var course mapper.selectById(1L); course.setStock(5); mapper.updateById(course); System.out.println(事务B - 修改库存为 5 并提交); latchB.countDown(); // 通知 A 继续 } }1.2 幻读的真面目与 Next-Key LockMVCC 很完美吗不。 MVCC 解决的是读写并发的问题。但如果是写写并发或者当前读select for update,update,delete,insertMVCC 就失效了必须上锁。幻读的真正痛点在于事务 A 想查 id 10 的所有记录并加锁如果不锁住“间隙”事务 B 悄悄插入了一条 id 11 的记录。事务 A 再执行更新操作时突然发现多了一行这时Next-Key Lock登场。 Next-Key Lock Record Lock (行锁)Gap Lock (间隙锁)。 它锁住的是一个左开右闭的区间。1.2.1 间隙锁原理图解1.2.2 生产陷阱当前读打破 MVCC 幻觉这是很多初级架构师容易踩的坑以为 RR 级别下绝对没有幻读。看下面的代码。示例 3当前读引发的“诡异”现象Transactional(isolation Isolation.REPEATABLE_READ) public void phantomReadTrap() { // 1. 快照读假设此时表中只有 id1 ListCourseInventory list1 mapper.selectList(new QueryWrapperCourseInventory().gt(id, 0)); System.out.println(第一次查询记录数: list1.size()); // 结果: 1 // --- 此时外部事务 B 插入了 id2 并提交 --- // 2. 再次快照读 ListCourseInventory list2 mapper.selectList(new QueryWrapperCourseInventory().gt(id, 0)); System.out.println(第二次查询记录数: list2.size()); // 结果: 1 (MVCC 保护没问题) // 3. 关键点执行 Update触发当前读 // update 语句必须读取最新的数据Current Read否则会覆盖别人的修改 mapper.update(null, new UpdateWrapperCourseInventory() .set(course_name, 被修改).eq(id, 2)); // 4. 再次快照读 ListCourseInventory list3 mapper.selectList(new QueryWrapperCourseInventory().gt(id, 0)); System.out.println(第三次查询记录数: list3.size()); // 运行结果说明 // 结果竟然是 2 // 为什么因为步骤3的 update 属于当前读它“看见”了 id2 的记录并更新了它。 // 更新后这条记录的 trx_id 变成了当前事务 A 的 id。 // 根据 MVCC 规则事务可以看到自己修改的记录。 // 所以幻读出现了 }结论MVCC 只能保证单纯SELECT的一致性。一旦你动手修改Update/Delete或者显式加锁For Update你就会穿透快照看到真实的世界。2. 进阶实战Next-Key Lock 的死锁陷阱Next-Key Lock 虽然解决了幻读但也带来了严重的副作用死锁Deadlock。 这是生产环境中最常见的事故来源之一。示例 4间隙锁导致的死锁场景两个事务同时想插入数据或者检查不存在则插入。/** * 模拟死锁场景Gap Lock 等待 * 表中有 id: 5, 10 */ public void deadlockDemo(JdbcTemplate jdbcTemplate) { // 线程 1 new Thread(() - { transactionTemplate.execute(status - { // 1. 锁住不存在的记录产生 Gap Lock (5, 10) jdbcTemplate.queryForList(SELECT * FROM course_inventory WHERE id 7 FOR UPDATE); sleep(1000); // 3. 尝试插入 id8 // 此时线程2持有 (5, 10) 的 Gap Lock线程1阻塞 jdbcTemplate.update(INSERT INTO course_inventory (id, stock) VALUES (8, 100)); return null; }); }).start(); // 线程 2 new Thread(() - { transactionTemplate.execute(status - { // 2. 同样锁住不存在的记录也获得 Gap Lock (5, 10) // 注意Gap Lock 之间是不冲突的两个事务可以同时持有同一个间隙的 Gap Lock jdbcTemplate.queryForList(SELECT * FROM course_inventory WHERE id 9 FOR UPDATE); sleep(1000); // 4. 尝试插入 id6 // 此时线程1持有 (5, 10) 的 Gap Lock线程2阻塞 // 死锁发生MySQL 抛出 Deadlock found when trying to get lock jdbcTemplate.update(INSERT INTO course_inventory (id, stock) VALUES (6, 100)); return null; }); }).start(); }运行结果说明这就是经典的“互斥等待”。Gap Lock 的特性是它不阻止其他事务获取同一个间隙的 Gap Lock但阻止其他事务在这个间隙插入数据。当两个事务都持有了 Gap Lock然后又都想往里插数据时死锁就诞生了。3. 架构师思维与“邪道”玩法3.1 架构层面的思考作为架构师我们不能只盯着 SQL 语句优化。面对高并发下的锁竞争我们有更高的视野尽量使用 RCRead Committed级别很多大厂如阿里、腾讯的 MySQL 默认隔离级别其实是 RC而不是 RR。优点没有 Gap Lock死锁概率大幅降低。缺点必须配合 Binlog 的row格式否则主从复制会不一致。解决幻读在应用层或者通过唯一索引约束来解决。避免长事务MVCC 的 Undo Log 是存储在系统表空间中的。如果有一个长事务一直不提交导致 Read View 一直存在MySQL 就无法清理旧版本的 Undo Log导致 ibdata1 文件无限膨胀磁盘爆满。悲观锁转乐观锁不要总是FOR UPDATE。示例 5乐观锁实践CAS 思想// 生产环境推荐写法 public boolean deductStock(Long id, Integer count) { // 1. 先查版本号或库存 CourseInventory course mapper.selectById(id); // 2. 内存计算 if (course.getStock() count) return false; // 3. 带条件更新 (CAS) // UPDATE course_inventory SET stock stock - ?, version version 1 // WHERE id ? AND version ? int rows mapper.updateStock(id, count, course.getVersion()); if (rows 0) { // 更新失败说明有并发修改可以选择重试或报错 throw new OptimisticLockException(手慢了库存被抢走了); } return true; }3.2 邪修版本架构设计利用 Gap Lock 做“防抖”⚠️高危操作非资深人士勿用有时候我们需要在极短时间内阻止某个范围内的数据写入比如在进行某种分片数据迁移时的校验。 通常我们会用分布式锁Redis但如果 Redis 挂了呢我们可以利用 Gap Lock 的特性构造一个“数据库级别的区间黑洞”。示例 6构造区间黑洞Transactional public void createBlackHole() { // 人为制造一个巨大的 Gap Lock阻止 id 在 1000 到 2000 之间的任何插入 // 假设 id1000 和 id2000 存在 mapper.selectList(new QueryWrapperCourseInventory() .ge(id, 1000) .le(id, 2000) .last(FOR UPDATE)); // 在这个事务提交前任何试图插入 1000-2000 的操作都会被数据库层阻塞 // 这比应用层的锁更底层、更强硬。 // 执行耗时的校验逻辑... slowValidationLogic(); }风险提示这会严重影响数据库吞吐量甚至搞挂数据库。但在某些不引入外部分布式组件的极端一致性场景下这是一种纯 MySQL 的解法。4. 总结与 Takeaway写到这里相信大家对 MVCC 和 Next-Key Lock 已经有了“体感”级别的认识。作为架构师我想给各位几个明确的结论MVCC 是为了读写不冲突它通过 Undo Log 实现了“快照读”解决了大部分读一致性问题。Next-Key Lock 是为了解决幻读它在 RR 级别下的当前读中生效通过锁住间隙来防止“无中生有”。RR 级别并不能 100% 解决幻读如果你在事务中先快照读再 update再快照读幻读依然存在。死锁是 Gap Lock 的孪生兄弟高并发插入场景下请务必小心 Gap Lock 导致的互斥等待。工程化建议如果业务允许考虑将隔离级别降为 RC配合binlog_formatrow这能减少 90% 的死锁问题。最后送大家一句话技术没有银弹。MVCC 牺牲了空间Undo LogNext-Key Lock 牺牲了并发度锁范围变大。架构设计的本质就是在他人的牺牲中寻找最适合你业务的那个平衡点。