事务隔离级别“我以为读到了最新数据”——脏读、不可重复读、幻读、MVCC原理

6 阅读7分钟

9年Java开发,事务隔离级别的坑,我踩过最痛的一次:以为读到了最新数据,结果读到的是还没提交的脏数据,业务逻辑全乱。  今天聊透四个隔离级别、三种读现象、以及MySQL的MVCC到底怎么工作的。


一、一个真实的“脏读”事故

场景:订单状态被“提前看到”

sql

-- 事务A(后台批量处理)
BEGIN;
UPDATE `order` SET status = '已发货' WHERE order_id = 123;
-- 还没COMMIT,中间要调物流接口,耗时2秒

-- 事务B(用户查询订单)
SELECT status FROM `order` WHERE order_id = 123;  -- 读到了'已发货'!

问题:  事务A还没提交,事务B就读到了。如果事务A最后回滚了,事务B读到的就是脏数据。

根本原因:隔离级别太低(READ UNCOMMITTED)


二、三种读现象:脏读、不可重复读、幻读

2.1 脏读(Dirty Read)

定义:  读到了另一个事务未提交的数据。

时间事务A事务B
T1BEGIN
T2UPDATE balance SET money=200 WHERE id=1
T3SELECT money FROM balance WHERE id=1 → 读到200(脏数据❌)
T4ROLLBACK(钱回到100)
T5业务逻辑基于200执行,全错

危害:  读到最终不存在的数据,业务逻辑崩溃。

解决:  隔离级别至少READ COMMITTED


2.2 不可重复读(Non-Repeatable Read)

定义:  同一个事务内,两次读同一条数据,结果不一样(因为被其他事务修改了)。

时间事务A事务B
T1BEGIN
T2SELECT money FROM balance WHERE id=1 → 100
T3BEGIN;UPDATE balance SET money=200 WHERE id=1;COMMIT
T4SELECT money FROM balance WHERE id=1 → 200(和T2不一样❌)
T5COMMIT

危害:  基于第一次读的结果做决策,第二次读变了,逻辑错乱。

解决:  隔离级别至少REPEATABLE READ


2.3 幻读(Phantom Read)

定义:  同一个事务内,两次查询记录数不一样(因为其他事务插入/删除了数据)。

时间事务A事务B
T1BEGIN
T2SELECT COUNT(*) FROM order WHERE user_id=1 → 3条
T3BEGIN;INSERT INTO order VALUES(...);COMMIT
T4SELECT COUNT(*) FROM order WHERE user_id=1 → 4条(多了一条❌)
T5COMMIT

危害:  分页查询、批量处理时,数据条数变化导致逻辑错误。

解决:  隔离级别SERIALIZABLE 或 加间隙锁(MySQL RR级别下部分解决)


三、MySQL的四种隔离级别

隔离级别脏读不可重复读幻读并发性能
READ UNCOMMITTED✅ 可能✅ 可能✅ 可能最高
READ COMMITTED(Oracle默认)✅ 可能✅ 可能
REPEATABLE READ(MySQL默认)⚠️ 部分解决
SERIALIZABLE最低(串行化)

查看和设置隔离级别

sql

-- 查看当前隔离级别
SELECT @@transaction_isolation;  -- MySQL 8.0
SELECT @@tx_isolation;           -- MySQL 5.7

-- 设置全局隔离级别
SET GLOBAL transaction_isolation = 'READ-COMMITTED';

-- 设置当前会话隔离级别
SET SESSION transaction_isolation = 'REPEATABLE-READ';

-- 设置下一个事务的隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

四、MySQL的REPEATABLE READ能防幻读吗?

答案是:部分能,但不是100%

MySQL的RR级别通过MVCC(多版本并发控制)+ 间隙锁解决了大部分幻读,但有一个边界情况:

sql

-- 事务A
BEGIN;
SELECT * FROM user WHERE age = 18;  -- 查到1条

-- 事务B
BEGIN;
INSERT INTO user (age) VALUES (18);  -- 插入一条age=18
COMMIT;

-- 事务A
SELECT * FROM user WHERE age = 18;  -- 还是1条(MVCC快照读,无幻读✅)
UPDATE user SET name = 'test' WHERE age = 18;  -- 影响了2条!❌ 幻读出现了
COMMIT;

解释:

  • SELECT(快照读)读的是MVCC快照,看不到新数据
  • UPDATE(当前读)读的是最新数据,能看见新插入的行

记住:RR级别下,快照读无幻读,当前读(SELECT ... FOR UPDATE/UPDATE/DELETE)可能有幻读。


五、MVCC原理:到底怎么做到“可重复读”?

5.1 核心思想:每个事务看到的是某个时间点的快照

MVCC让读不阻塞写,写不阻塞读

5.2 InnoDB的隐藏列

每行记录隐藏三个字段:

字段作用
DB_TRX_ID最后修改该行的事务ID
DB_ROLL_PTR指向undo log的指针(回滚/版本链)
DB_ROW_ID行ID(没有主键时使用)

5.3 Read View(读视图)

事务执行快照读时,生成一个Read View,包含:

text

- m_ids:当前活跃(未提交)的事务ID列表
- min_trx_id:m_ids中的最小值
- max_trx_id:下一个将要分配的事务ID
- creator_trx_id:当前事务自己的ID

5.4 可见性判断规则

一条记录的DB_TRX_ID = trx_id,是否可见?

text

if trx_id == creator_trx_id:
    return 可见(自己的修改)
    
if trx_id < min_trx_id:
    return 可见(事务已提交)
    
if trx_id >= max_trx_id:
    return 不可见(事务在未来)
    
if trx_id in m_ids:
    return 不可见(事务未提交)
else:
    return 可见(事务已提交但不在m_ids)

不可见时,通过DB_ROLL_PTR找到上一个版本,继续判断。

5.5 图示:版本链

text

事务A(ID=100)修改了name='张三'
事务B(ID=200)修改了name='李四'

版本链(最新→最旧):
┌─────────────────────────────────────┐
│ name='李四', trx_id=200, roll_ptr ──┼──→ ┌─────────────────────────────┐
│ 当前记录                            │    │ name='张三', trx_id=100     │
└─────────────────────────────────────┘    │ roll_ptr ──→ 更早版本       │
                                           └─────────────────────────────┘

事务C(ID=300)查询时,根据Read View判断应该看到哪个版本。

六、实际场景:隔离级别选错了,出大事了

场景1:READ UNCOMMITTED导致资金错乱

sql

-- 转账业务
-- 事务A:从A账户扣100
BEGIN;
UPDATE account SET balance = balance - 100 WHERE user = 'A';
-- 事务B:查询A账户余额做风控
SELECT balance FROM account WHERE user = 'A';  -- 读到扣完后的值
-- 事务A因为余额不足回滚了

-- 结果:风控系统以为A还有钱,实际上扣款失败了

解决:  至少用READ COMMITTED


场景2:READ COMMITTED导致统计报表错误

sql

-- 生成报表,需要两次统计
-- 事务A:统计总金额
BEGIN;
SELECT SUM(amount) FROM orders WHERE date = '2024-01-01';  -- 得到10000

-- 事务B:插入新订单
BEGIN;
INSERT INTO orders VALUES(...);
COMMIT;

-- 事务A:统计订单数
SELECT COUNT(*) FROM orders WHERE date = '2024-01-01';  -- 多了一条❌

解决:  用REPEATABLE READ(MySQL默认)或报表单独在从库跑


场景3:RR级别的当前读导致间隙锁死锁

sql

-- 事务A
BEGIN;
SELECT * FROM user WHERE age = 18 FOR UPDATE;  -- 加了间隙锁,锁定age=18的范围

-- 事务B
BEGIN;
INSERT INTO user (age) VALUES (18);  -- 被间隙锁阻塞,等待

-- 事务A
UPDATE user SET name = 'test' WHERE age = 18;  -- 可能死锁

解决:

  • 业务允许的话,降级到READ COMMITTED
  • 减少SELECT ... FOR UPDATE的使用
  • 控制事务大小,尽快提交

七、各隔离级别的适用场景

隔离级别适用场景慎用场景
READ UNCOMMITTED几乎不用任何需要数据准确的场景
READ COMMITTED互联网高并发、对一致性要求不极端报表、财务、对账
REPEATABLE READMySQL默认,大部分业务场景热点行高并发更新(可能死锁)
SERIALIZABLE对一致性要求极高、并发极低任何高并发场景

八、总结速查表

问题原因解决方案
读到未提交数据隔离级别=READ UNCOMMITTED升到READ COMMITTED
同一事务读两次不一样隔离级别=READ COMMITTED升到REPEATABLE READ
查询条数不一致幻读RR+间隙锁 或 SERIALIZABLE
RR级别下INSERT被阻塞间隙锁降级到RC 或 优化SQL
死锁频繁间隙锁+当前读用RC隔离级别

九、一句话避坑口诀

text

RU脏读不能忍,RC防脏不禁幻。
RR可重复读最常用,间隙锁防幻有代价。
快照读版本链,当前读锁区间。
线上慎用FOR UPDATE,死锁排查要记牢。

十、互动一下

你因为隔离级别选错过吗?

有没有遇到过“可重复读”下还是出现幻读的场景?

评论区聊聊👇


下期预告:  避坑8——Redis“我以为持久化了”(RDB vs AOF、持久化丢了数据、缓存雪崩/击穿/穿透)


我是小李,9年Java,产假中持续输出。点个赞,收藏防丢❤️