MySQL 事务隔离级别深度解析——MVCC 原理、幻读实战复现与解决
MySQL 事务隔离级别深度解析——MVCC 原理、幻读实战复现与解决
适读人群:理解了事务基础、想深入吃透 MVCC 和隔离级别底层实现的后端工程师 | 阅读时长:约18分钟 | 核心价值:从版本链原理到幻读复现,建立对 MySQL 并发控制的完整认知
面试说得头头是道,线上还是踩坑
几乎所有后端工程师都能背出 MySQL 的四个隔离级别:读未提交、读已提交、可重复读、串行化。但很少有人真正理解 MVCC 的实现机制,更少有人在生产中正确地利用了它。
我就见过好几次这样的问题:开发同学在业务代码里用 SELECT ... WHERE status = 1 查询了一批订单,然后批量处理。但在高并发下,另一个事务恰好在这期间插入了新的 status=1 订单,导致本次处理遗漏了这些新插入的数据。
"不是可重复读(RR)隔离级别吗?怎么还有数据变化?"
这就是幻读问题。今天我们从 MVCC 的原理讲起,把这个问题彻底说清楚。
一、MVCC 原理:数据库的"时间机器"
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现读已提交(RC)和可重复读(RR)的核心机制。
1.1 三个关键组件
隐藏字段:InnoDB 在每行数据上隐式添加了两个字段:
trx_id:最近修改这行数据的事务 IDroll_ptr:回滚指针,指向该行的 undo log
undo log:记录行数据的历史版本。每次对行数据的修改,都会把旧版本写入 undo log,形成版本链:
当前版本(trx_id=100) → undo log v2(trx_id=80) → undo log v1(trx_id=50) → NULLRead View:读视图,用于判断一个事务能"看到"哪个版本的数据。Read View 包含:
creator_trx_id:创建这个 Read View 的事务 IDtrx_ids:创建时处于活跃(未提交)状态的事务 ID 列表min_trx_id:trx_ids 中的最小值max_trx_id:创建时下一个将分配的事务 ID(即当前已分配的最大 ID + 1)
1.2 版本可见性判断规则
当访问某行数据时,根据行的 trx_id 和 Read View 的信息判断是否可见:
- 如果
trx_id == creator_trx_id:该行是本事务修改的,可见 - 如果
trx_id < min_trx_id:该事务在 Read View 创建前已提交,可见 - 如果
trx_id >= max_trx_id:该事务在 Read View 创建后才开始,不可见 - 如果
min_trx_id <= trx_id < max_trx_id:- 如果
trx_id在trx_ids列表中:该事务创建 Read View 时还未提交,不可见 - 如果
trx_id不在trx_ids列表中:该事务已提交,可见
- 如果
1.3 RC 和 RR 的关键区别
Read Committed(RC):每次 SELECT 语句执行时,重新生成 Read View。
Repeatable Read(RR):事务的第一次 SELECT 时生成 Read View,之后整个事务内所有 SELECT 都使用同一个 Read View。
// RC 和 RR 行为差异示例(伪代码)
// 时间线:
// T=1: 事务 A 开始,SELECT status=1 的订单 → 查到 100 条
// T=2: 事务 B 开始,INSERT 一条 status=1 的订单,COMMIT
// T=3: 事务 A 再次 SELECT status=1 的订单
// RC 隔离级别下:
// T=3 的 SELECT 重新生成 Read View,能看到 B 的提交 → 查到 101 条(不可重复读)
// RR 隔离级别下(快照读):
// T=3 的 SELECT 使用 T=1 时的 Read View,看不到 B 的提交 → 查到 100 条(可重复读)二、幻读:RR 没有完全解决的问题
2.1 快照读 vs 当前读
快照读(Snapshot Read):普通的 SELECT 语句,通过 MVCC 读历史版本,不加锁。
当前读(Current Read):SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT、UPDATE、DELETE,读取最新版本,加锁。
幻读问题发生在当前读场景下:
-- 复现幻读(RR 隔离级别下)
-- 事务 A
BEGIN;
-- 快照读,生成 Read View
SELECT * FROM orders WHERE status = 1; -- 查到 100 条
-- 此时,事务 B 插入一条 status=1 的订单并提交
-- 事务 A 继续(当前读)
SELECT * FROM orders WHERE status = 1 FOR UPDATE; -- 查到 101 条!(幻读)
-- 明明是同一个事务,两次查询结果不同这是因为:快照读基于 Read View(MVCC),看不到其他事务提交的新数据;但当前读(FOR UPDATE)绕过了 MVCC,直接读最新数据,会看到其他事务新插入的行。
这就是很多人说"RR 解决了幻读"的错误之处:MVCC 解决了快照读场景下的幻读,但对当前读场景的幻读,需要间隙锁(Gap Lock)来解决。
2.2 用代码验证幻读
@SpringBootTest
@Transactional
public class PhantomReadTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
public void testPhantomReadWithSnapshotRead() throws Exception {
// 事务 A 的第一次快照读
long count1 = orderRepository.countByStatus(1);
System.out.println("第一次快照读,count=" + count1);
// 模拟事务 B 插入新数据(需要在另一个事务里提交)
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
jdbcTemplate.execute("INSERT INTO orders (user_id, status) VALUES (999, 1)");
});
future.get();
// 事务 A 的第二次快照读(RR 下,看不到 B 的提交)
long count2 = orderRepository.countByStatus(1);
System.out.println("第二次快照读(MVCC),count=" + count2);
// count1 == count2(快照读不出现幻读)
// 当前读(会看到 B 的提交)
long count3 = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status = 1 LOCK IN SHARE MODE", Long.class);
System.out.println("当前读,count=" + count3);
// count3 = count1 + 1(当前读看到了幻读)
}
}三、解决当前读场景的幻读
3.1 间隙锁(Gap Lock)的作用
在 RR 隔离级别下,当前读(FOR UPDATE)会加临键锁(Next-Key Lock),同时锁住记录本身和前一个间隙,阻止其他事务在锁定范围内插入新数据。
-- 事务 A 加锁
BEGIN;
SELECT * FROM orders WHERE status = 1 FOR UPDATE;
-- InnoDB 对 status=1 的索引范围加临键锁,阻止其他事务插入 status=1 的记录
-- 事务 B(此时)
BEGIN;
INSERT INTO orders (user_id, status) VALUES (999, 1);
-- 被阻塞,等待事务 A 释放间隙锁
-- 事务 A 提交后,事务 B 才能插入
COMMIT;3.2 实际业务中防幻读的代码实践
@Service
public class BatchOrderProcessor {
@Autowired
private OrderMapper orderMapper;
/**
* 批量处理 status=1 的订单
* 错误方式:先查后改,中间可能有新数据插入被遗漏
*/
@Transactional
public void processOrdersBad() {
// 快照读,基于 MVCC,不加锁
List<Order> orders = orderMapper.findByStatus(1);
// 在这之后,其他事务可能插入新的 status=1 订单
// 这些新插入的订单不在 orders 里,会被遗漏
orders.forEach(this::processOrder);
}
/**
* 正确方式:用当前读锁住查询范围
*/
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processOrdersCorrect() {
// 当前读(FOR UPDATE),锁住 status=1 的范围(临键锁)
// 其他事务无法插入新的 status=1 订单,直到本事务提交
List<Order> orders = orderMapper.findByStatusForUpdate(1);
orders.forEach(this::processOrder);
}
private void processOrder(Order order) {
// 处理订单逻辑...
orderMapper.updateStatus(order.getId(), 2);
}
}// Mapper 中的 FOR UPDATE 查询
@Mapper
public interface OrderMapper {
@Select("SELECT * FROM orders WHERE status = #{status}")
List<Order> findByStatus(@Param("status") Integer status);
@Select("SELECT * FROM orders WHERE status = #{status} FOR UPDATE")
List<Order> findByStatusForUpdate(@Param("status") Integer status);
@Update("UPDATE orders SET status = #{newStatus} WHERE id = #{id}")
int updateStatus(@Param("id") Long id, @Param("newStatus") Integer newStatus);
}踩坑:使用 FOR UPDATE 锁定范围时造成大范围锁等待
现象:处理 status=1 的订单时,间隙锁锁住了 status 索引的大范围,导致大量新订单插入被阻塞。
解法:缩小锁定范围,比如先查出要处理的 ID 列表,再按 ID 加锁(精确锁定,不产生间隙锁):
@Transactional
public void processOrdersOptimized() {
// 第一步:快照读,获取待处理的 ID 列表(不加锁,不影响新数据插入)
List<Long> orderIds = orderMapper.findIdsByStatus(1);
// 第二步:按主键加锁(精确到行,不产生间隙锁)
for (Long orderId : orderIds) {
Order order = orderMapper.findByIdForUpdate(orderId);
if (order != null && order.getStatus() == 1) {
processOrder(order);
}
}
}四、隔离级别选型建议
RC(读已提交):绝大多数互联网 OLTP 系统的最佳选择。没有间隙锁,并发插入不阻塞,死锁概率低。代价是不可重复读(同一事务内两次读到不同结果),但业务上通常可以接受。
RR(可重复读):MySQL 默认,适合对事务内一致性要求较高的场景。需要注意间隙锁导致的并发问题。
不推荐 Serializable:所有读操作自动变成当前读(加共享锁),并发性极差,仅适合数据量极小且并发极低的场景。
理解 MVCC,是理解 MySQL 并发行为的关键。很多看起来"莫名其妙"的并发 Bug,追溯到 MVCC 和隔离级别的层面,都有清晰的解释。
