MySQL事务隔离级别底层实现:幻读是如何用Next-Key Lock解决的
MySQL事务隔离级别底层实现:幻读是如何用Next-Key Lock解决的
适读人群:Java后端开发、对MySQL锁机制感兴趣的工程师 | 阅读时长:约25分钟
开篇故事
2019年,我们的商城系统出了一个诡异的问题:用户投诉同一个商品被两个人同时购买成功,但库存明明只有1件。
查了半天,发现了一段这样的代码:
@Transactional
public boolean placeOrder(Long productId, Long userId) {
// 查库存
Product p = productMapper.selectById(productId);
if (p.getStock() <= 0) return false;
// 扣库存
productMapper.decreaseStock(productId);
// 创建订单
orderMapper.insert(new Order(productId, userId));
return true;
}看起来没什么问题?但问题就出在这里:在RR(可重复读)隔离级别下,第一步的selectById是快照读,读到的是事务开始时的快照,不是最新数据。两个并发事务几乎同时进入,都读到库存=1,都判断库存充足,然后都成功了。
这不是幻读,这是不可重复读的变种,但解决思路涉及到MySQL的锁机制和隔离级别的底层实现。
今天我把MySQL四个隔离级别的底层实现全部拆开,重点讲InnoDB如何用Next-Key Lock解决幻读,以及这个锁机制会带来哪些意想不到的问题。
一、四个隔离级别与对应的并发问题
标准SQL定义了四个隔离级别,每个级别解决不同的并发问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | InnoDB通过Next-Key Lock解决 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
脏读:读到其他事务未提交的数据 不可重复读:同一事务内,两次读同一行,结果不同(其他事务修改并提交了) 幻读:同一事务内,两次相同范围查询,第二次多出了新行(其他事务插入并提交了)
InnoDB默认隔离级别是REPEATABLE READ,并且通过Next-Key Lock在RR下也解决了幻读(快照读场景靠MVCC,当前读场景靠Next-Key Lock)。
二、底层原理:InnoDB的锁体系
2.1 InnoDB的三种行锁
Record Lock(记录锁):锁住单行记录
索引: [1] [5] [10] [15] [20]
^
锁住id=10这一行
Gap Lock(间隙锁):锁住两行之间的间隙(不含端点)
索引: [1] [5] [10] [15] [20]
( )
锁住(5,10)这个间隙,阻止插入id=6,7,8,9
Next-Key Lock(临键锁):Record Lock + 左侧Gap Lock
索引: [1] [5] [10] [15] [20]
( ]
锁住(5,10],既锁住记录10,也锁住间隙(5,10)Next-Key Lock是InnoDB防止幻读的核心机制。
2.2 为什么需要Gap Lock
先理解没有Gap Lock时,为什么会发生幻读:
时间轴 →
事务T1(RR,当前读):
t1: SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
返回: [{id=5, amount=200}, {id=10, amount=300}]
事务T2:
t2: INSERT INTO orders VALUES(7, 150); -- 插入amount=150的记录
COMMIT;
事务T1:
t3: SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
返回: [{id=5, amount=200}, {id=7, amount=150}, {id=10, amount=300}]
-- 幻读!多了id=7这行如果T1在t1时只加了Record Lock(锁住id=5和id=10),T2的INSERT不被阻塞,就会发生幻读。
Gap Lock的作用就是锁住5和10之间的间隙,阻止T2的插入。
2.3 Next-Key Lock的加锁范围规则
这是整篇文章最核心的内容,也是面试中最高频的考点。
规则一:等值查询,命中记录
-- 索引值: 1, 5, 10, 15, 20(主键)
SELECT * FROM t WHERE id = 10 FOR UPDATE;加锁范围:Record Lock on id=10(仅锁一行,退化为Record Lock)
规则二:等值查询,未命中记录
-- 查询不存在的id=8
SELECT * FROM t WHERE id = 8 FOR UPDATE;加锁范围:Gap Lock on (5, 10)(锁住8所在的间隙)
规则三:范围查询
-- 查询id > 10 AND id <= 15
SELECT * FROM t WHERE id > 10 AND id <= 15 FOR UPDATE;加锁范围:Next-Key Lock on (10, 15](锁住间隙和记录15)
规则四:范围查询到最大值
SELECT * FROM t WHERE id > 15 FOR UPDATE;加锁范围:Next-Key Lock on (15, +∞)(实际是锁到supremum伪记录)
用ASCII图展示完整的加锁范围:
索引: ... [1] [5] [10] [15] [20] ...
^^^^
SELECT * WHERE id > 10 FOR UPDATE
加锁范围:
(10 , 15] + (15 , +∞)
^Next-Key^ ^Next-Key^
实际锁定: 阻止往 (10,+∞) 区间内插入任何行三、完整解决方案与代码
3.1 开篇问题的正确解法
// 方案1:使用悲观锁(FOR UPDATE)
@Transactional
public boolean placeOrder(Long productId, Long userId) {
// FOR UPDATE:当前读 + 排他锁,确保读到最新数据
Product p = productMapper.selectByIdForUpdate(productId);
if (p.getStock() <= 0) return false;
// 此时其他事务无法修改这行,安全扣减
int affected = productMapper.decreaseStock(productId);
if (affected == 0) return false; // 额外保护
orderMapper.insert(new Order(productId, userId));
return true;
}对应的Mapper SQL:
<!-- 悲观锁查询 -->
<select id="selectByIdForUpdate" resultType="Product">
SELECT * FROM product WHERE id = #{id} FOR UPDATE
</select>
<!-- 带库存检查的更新(乐观锁方案) -->
<update id="decreaseStock">
UPDATE product SET stock = stock - 1
WHERE id = #{id} AND stock > 0
</update>// 方案2:乐观锁(推荐高并发场景)
@Transactional
public boolean placeOrder(Long productId, Long userId) {
// 不加锁,直接尝试CAS更新
int affected = productMapper.decreaseStock(productId);
if (affected == 0) {
// 库存不足或CAS失败
return false;
}
orderMapper.insert(new Order(productId, userId));
return true;
}3.2 验证Next-Key Lock的Java代码
import java.sql.*;
import java.util.concurrent.*;
/**
* Next-Key Lock行为验证
* 演示范围查询加锁后,其他事务的INSERT被阻塞
*/
public class NextKeyLockTest {
public static void main(String[] args) throws Exception {
// 准备数据: id = 1, 5, 10, 15, 20
prepareData();
System.out.println("=== 测试Next-Key Lock阻止幻读 ===");
CountDownLatch t1Locked = new CountDownLatch(1);
CountDownLatch testDone = new CountDownLatch(1);
// 事务T1:范围查询加锁
Thread t1 = new Thread(() -> {
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
// 当前读:加Next-Key Lock
try (PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM lock_test WHERE id > 5 AND id <= 15 FOR UPDATE")) {
ResultSet rs = ps.executeQuery();
System.out.println("[T1] 范围查询加锁成功,锁住 (5, 15]");
while (rs.next()) {
System.out.println("[T1] 锁定行: id=" + rs.getInt("id"));
}
}
t1Locked.countDown(); // 通知T2可以尝试插入了
testDone.await(10, TimeUnit.SECONDS); // 等待测试完成
conn.commit();
System.out.println("[T1] 事务提交,锁释放");
} catch (Exception e) {
e.printStackTrace();
}
});
// 事务T2:尝试插入被锁间隙内的数据
Thread t2 = new Thread(() -> {
try {
t1Locked.await(); // 等T1加锁完成
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
System.out.println("[T2] 尝试插入 id=8(在间隙(5,10)内)...");
long start = System.currentTimeMillis();
// 这个INSERT会被T1的Gap Lock阻塞
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO lock_test VALUES(8, 'new_row')")) {
ps.executeUpdate(); // 阻塞在这里
conn.commit();
long elapsed = System.currentTimeMillis() - start;
System.out.println("[T2] 插入成功(等待了" + elapsed + "ms)");
} catch (SQLException e) {
System.out.println("[T2] 插入失败(锁等待超时): " + e.getMessage());
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
testDone.countDown();
}
});
t1.start();
t2.start();
// 等5秒后让T2超时(innodb_lock_wait_timeout默认50秒)
Thread.sleep(3000);
testDone.countDown(); // T1提交,T2的INSERT可以执行了
t1.join();
t2.join();
}
static void prepareData() throws Exception {
try (Connection conn = getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS lock_test");
stmt.execute("CREATE TABLE lock_test (id INT PRIMARY KEY, name VARCHAR(50))");
stmt.execute("INSERT INTO lock_test VALUES(1,'a'),(5,'b'),(10,'c'),(15,'d'),(20,'e')");
System.out.println("[准备] 数据初始化完成");
}
}
static Connection getConnection() throws Exception {
return DriverManager.getConnection(
"jdbc:mysql://localhost:3306/test?useSSL=false",
"root", "password");
}
}3.3 监控死锁的SQL
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G
-- 输出中找到 LATEST DETECTED DEADLOCK 部分:
-- *** (1) TRANSACTION:
-- TRANSACTION 1234, ACTIVE 0 sec starting index read
-- MySQL thread id 50, query id 12345 localhost root
-- SELECT * FROM orders WHERE amount > 100 FOR UPDATE
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
-- RECORD LOCKS space id 123 page no 5 n bits 72 index PRIMARY of table `test`.`orders`
-- trx id 1234 lock_mode X locks gap before rec insert intention waiting
-- *** (2) TRANSACTION:
-- TRANSACTION 1235, ACTIVE 0 sec inserting
-- ...
-- *** (2) HOLDS THE LOCK(S):
-- ...
-- *** WE ROLL BACK TRANSACTION (1)
-- 查看当前锁等待
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;
-- MySQL 8.0+查看锁详情(替代旧的INNODB_LOCKS)
SELECT * FROM performance_schema.data_locks\G
SELECT * FROM performance_schema.data_lock_waits\G四、踩坑实录
坑1:Gap Lock导致的死锁,两边都在等对方
场景:两个事务都在对同一间隙做查询和插入。
-- 前提:表中只有 id=5 和 id=10
-- 事务T1(线程A)
BEGIN;
SELECT * FROM t WHERE id = 7 FOR UPDATE;
-- Gap Lock on (5, 10),阻止id=6,7,8,9插入
-- 事务T2(线程B,几乎同时执行)
BEGIN;
SELECT * FROM t WHERE id = 8 FOR UPDATE;
-- Gap Lock on (5, 10),同样阻止id=6,7,8,9插入
-- 注意:两个Gap Lock不互斥!都加成功了。
-- 事务T1继续
INSERT INTO t VALUES(7, 'data');
-- T1尝试在(5,10)间插入id=7
-- 但T2持有(5,10)的Gap Lock,T1被阻塞!
-- 事务T2继续
INSERT INTO t VALUES(8, 'data');
-- T2尝试在(5,10)间插入id=8
-- 但T1持有(5,10)的Gap Lock,T2被阻塞!
-- 死锁!T1等T2,T2等T1。MySQL检测到后回滚其中一个。报错:
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction解决方案:
// 方案1:使用INSERT IGNORE或ON DUPLICATE KEY(避免先查后插的模式)
@Transactional
public void upsertRecord(int id, String data) {
// 直接插入,失败就忽略(无需先SELECT加锁)
jdbcTemplate.update(
"INSERT IGNORE INTO t(id, data) VALUES(?, ?)", id, data);
}
// 方案2:捕获死锁异常并重试
@Retryable(
value = {DeadlockLoserDataAccessException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional
public void insertWithRetry(int id, String data) {
jdbcTemplate.update("INSERT INTO t(id, data) VALUES(?, ?)", id, data);
}
// 方案3:降低隔离级别为RC(RC下无Gap Lock)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void insertRC(int id, String data) {
jdbcTemplate.update("INSERT INTO t(id, data) VALUES(?, ?)", id, data);
}坑2:RR隔离级别下,MVCC不能解决所有幻读
-- 在RR隔离级别下,快照读(普通SELECT)确实不会幻读
-- 但当前读(FOR UPDATE/LOCK IN SHARE MODE)会产生幻读
-- 事务T1(RR隔离级别)
BEGIN;
SELECT * FROM orders WHERE amount > 100;
-- 快照读,返回:{id=1, amount=200}, {id=2, amount=300}
-- 事务T2
INSERT INTO orders VALUES(3, 150);
COMMIT;
-- 事务T1
SELECT * FROM orders WHERE amount > 100;
-- 快照读,仍然返回:{id=1, amount=200}, {id=2, amount=300}(MVCC保证)
-- 看起来没有幻读!
-- 但是...
UPDATE orders SET status = 'processed' WHERE amount > 100;
-- 当前读!MySQL更新了3行(包括T2插入的id=3)!
-- 再次快照读
SELECT * FROM orders WHERE amount > 100;
-- 返回:{id=1}, {id=2}, {id=3}
-- 幻读!id=3这行出现了,因为UPDATE操作让T1"看到"了这行(update会更新trx_id)这是MVCC和当前读混用导致的幻读,在纯快照读场景下不会出现。
记住这个规则:
- 快照读(普通SELECT):MVCC防幻读,同一事务内结果一致
- 当前读(FOR UPDATE/UPDATE/DELETE):Next-Key Lock防幻读,范围查询加锁
坑3:RR下间隙锁范围比预期大,导致不必要的锁冲突
-- 场景:想只锁id=10这一行,但实际锁住的范围更大
-- 表中数据:id = 1, 3, 5, 10, 15, 20
-- 用非主键索引查询
-- 表: CREATE INDEX idx_amount ON orders(amount);
-- 数据amount值: 100, 200, 300, 400
SELECT * FROM orders WHERE amount = 200 FOR UPDATE;实际加锁范围(非主键索引的Next-Key Lock更复杂):
非主键索引: ... [100] [200] [300] ...
主键索引: ... [3] [5] [8] ...
加锁范围:
- 非主键索引上: Gap Lock on (100, 200) + Record Lock on amount=200
- 主键索引上: 对所有amount=200对应的主键行加Record Lock
- 非主键索引上还加: Gap Lock on (200, 300)
总范围: (100, 300) 之间的新记录无法插入这个锁范围远比想象的大!
解决方案:
-- 方案1:尽量用主键查询(主键的Next-Key Lock范围最小)
SELECT * FROM orders WHERE id = 5 FOR UPDATE;
-- 只锁住 id=5 这一行,间隙锁范围是 (3, 5]
-- 方案2:降低隔离级别为RC(没有Gap Lock,但会有幻读)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 方案3:使用唯一索引(命中时退化为Record Lock,无Gap Lock)
-- 如果amount字段有唯一索引
SELECT * FROM orders WHERE amount = 200 FOR UPDATE;
-- 命中唯一索引:只加Record Lock,不加Gap Lock五、总结与延伸
今天把MySQL事务隔离级别的底层实现梳理完了:
MVCC解决的问题:读-写并发,通过版本链和ReadView让读不阻塞写。解决了脏读和不可重复读,以及快照读场景的幻读。
Next-Key Lock解决的问题:当前读场景的幻读。通过锁住记录和间隙,阻止其他事务在查询范围内插入新行。
两个关键结论:
- RR隔离级别下,普通SELECT是快照读(MVCC),FOR UPDATE是当前读(锁)
- 如果你的业务对幻读敏感,必须用FOR UPDATE;如果只是报表查询,普通SELECT足够
Gap Lock带来的新问题:间隙锁的范围比你想象的大,在高并发插入场景下容易引发死锁。减少Gap Lock的最佳方式是:用主键查询、用唯一索引、或者降级到RC隔离级别。
开篇的库存扣减问题,正确的解法是乐观锁(UPDATE ... WHERE stock > 0),既避免了锁冲突,又保证了不超卖。悲观锁(FOR UPDATE)在并发不高时也可以,但要注意锁范围。
