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 |
|---|---|---|
| T1 | BEGIN | |
| T2 | UPDATE balance SET money=200 WHERE id=1 | |
| T3 | SELECT money FROM balance WHERE id=1 → 读到200(脏数据❌) | |
| T4 | ROLLBACK(钱回到100) | |
| T5 | 业务逻辑基于200执行,全错 |
危害: 读到最终不存在的数据,业务逻辑崩溃。
解决: 隔离级别至少READ COMMITTED
2.2 不可重复读(Non-Repeatable Read)
定义: 同一个事务内,两次读同一条数据,结果不一样(因为被其他事务修改了)。
| 时间 | 事务A | 事务B |
|---|---|---|
| T1 | BEGIN | |
| T2 | SELECT money FROM balance WHERE id=1 → 100 | |
| T3 | BEGIN;UPDATE balance SET money=200 WHERE id=1;COMMIT | |
| T4 | SELECT money FROM balance WHERE id=1 → 200(和T2不一样❌) | |
| T5 | COMMIT |
危害: 基于第一次读的结果做决策,第二次读变了,逻辑错乱。
解决: 隔离级别至少REPEATABLE READ
2.3 幻读(Phantom Read)
定义: 同一个事务内,两次查询记录数不一样(因为其他事务插入/删除了数据)。
| 时间 | 事务A | 事务B |
|---|---|---|
| T1 | BEGIN | |
| T2 | SELECT COUNT(*) FROM order WHERE user_id=1 → 3条 | |
| T3 | BEGIN;INSERT INTO order VALUES(...);COMMIT | |
| T4 | SELECT COUNT(*) FROM order WHERE user_id=1 → 4条(多了一条❌) | |
| T5 | COMMIT |
危害: 分页查询、批量处理时,数据条数变化导致逻辑错误。
解决: 隔离级别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 READ | MySQL默认,大部分业务场景 | 热点行高并发更新(可能死锁) |
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,产假中持续输出。点个赞,收藏防丢❤️