大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!
你有没有遇到过这种场景:一个事务里查了两次同样的数据,结果不一样;或者查了两次同样的范围条件,结果集多了一行。很多开发遇到这种情况第一反应是“是不是缓存有问题”,但其实大概率是事务隔离级别没选对。
事务隔离级别决定了多个事务同时运行时,彼此能看到多少对方的数据。选得太低,数据可能被“污染”;选得太高,并发性能会急剧下降。今天我们从四个隔离级别出发,把脏读、不可重复读、幻读这三个概念彻底讲透。
一、先搞懂四个隔离级别
SQL标准定义了四种隔离级别,从低到高依次是:
1. READ UNCOMMITTED(读未提交) ——最低级别。事务可以读取其他事务尚未提交的数据。几乎不提供并发控制,实际生产中极少使用。
2. READ COMMITTED(读已提交,简称RC) ——只能读取其他事务已经提交的数据。避免了脏读,但无法避免不可重复读。
3. REPEATABLE READ(可重复读,简称RR) ——保证同一事务内多次读取同一数据结果一致。MySQL InnoDB的默认隔离级别。
4. SERIALIZABLE(可串行化) ——最高级别,事务完全串行执行,避免所有并发问题,但性能开销最大。
不同隔离级别能解决的问题,可以浓缩成一张表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 避免 | 可能 | 可能 |
| REPEATABLE READ | 避免 | 避免 | InnoDB基本避免 |
| SERIALIZABLE | 避免 | 避免 | 避免 |
二、三种并发问题,用真实SQL讲清楚
脏读:读到别人“还没想好”的数据
脏读指的是一个事务读取了另一个事务尚未提交的修改。如果那个事务最终回滚了,读到的就是“脏数据”——从未真正存在过的数据。
用电商库存场景来理解:事务A开始扣减库存,把商品X的库存从100改为99,但还没提交;此时事务B查询库存,读到了99。如果事务A因支付失败回滚了,库存恢复为100,但事务B已经基于99做了后续操作——超卖了。
-- 事务A(未提交)
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 1; -- stock从100变99
-- 此时事务B读取到stock=99(未提交的数据)
-- 事务A回滚
ROLLBACK; -- stock恢复为100,但事务B已经用了99的数据
解决脏读,至少要用READ COMMITTED级别。
不可重复读:同一事务内,数据“变了”
不可重复读指在同一个事务内,多次读取同一数据时结果不一致。它与脏读的关键区别在于:脏读读的是未提交的数据,不可重复读读的是已提交的数据。
用财务场景理解:财务人员在事务A中查询员工张三的工资,第一次看到5000元;此时HR在事务B中给张三加薪至6000元并提交;事务A再次查询时,工资变成了6000元。同一个事务里两次查询结果不一样——这就是不可重复读。
-- 事务A(隔离级别READ COMMITTED)
START TRANSACTION;
SELECT salary FROM employees WHERE id = 1; -- 返回5000
-- 事务B(同时执行)
START TRANSACTION;
UPDATE employees SET salary = 6000 WHERE id = 1;
COMMIT; -- 提交修改
-- 事务A再次查询
SELECT salary FROM employees WHERE id = 1; -- 返回6000,和第一次不一样
COMMIT;
READ COMMITTED级别无法避免这个问题。需要升级到REPEATABLE READ。
幻读:结果集“凭空多了一行”
幻读指在同一个事务内,多次查询某个范围的数据时,结果集的数量发生了变化——第一次查有5行,第二次查变成了6行。
打个比方:你在图书馆按分类找书,第一次看到书架上有5本书;等你转了一圈回来再数,变成了6本——多出来那本就是你不在的时候别人放上去的。
-- 事务A(隔离级别REPEATABLE READ)
START TRANSACTION;
SELECT * FROM orders WHERE status = 'pending'; -- 返回5行
-- 事务B(同时执行)
START TRANSACTION;
INSERT INTO orders (id, status) VALUES (100, 'pending');
COMMIT;
-- 事务A再次查询
SELECT * FROM orders WHERE status = 'pending'; -- 返回6行,多了一行!
COMMIT;
三、MySQL的RR为什么能“基本避免”幻读?
根据SQL标准,REPEATABLE READ级别应该允许幻读。但MySQL InnoDB通过MVCC(多版本并发控制)+ Gap Lock(间隙锁) 的组合,在大多数场景下实际避免了幻读。
- MVCC:事务开始时拍一次快照,后面的普通SELECT都读这个快照,别人提交了也看不见
- Gap Lock(间隙锁) :锁定索引记录之间的空隙,阻止其他事务在范围内插入新行
但需要注意的是,DML语句(UPDATE/DELETE)不遵循快照读,可能看到其他事务刚提交的行。所以在某些边缘场景下,RR级别仍可能出现幻读。
四、MySQL vs PostgreSQL:默认隔离级别为什么不同?
这是一个容易被忽略但很重要的差异。
- MySQL InnoDB默认使用REPEATABLE READ(RR)
- PostgreSQL默认使用READ COMMITTED(RC)
为什么不同?因为两者的MVCC实现机制不同:
| 对比维度 | MySQL InnoDB(RR) | PostgreSQL(RC) |
|---|---|---|
| 快照时机 | 事务开始时拍一次快照 | 每条SELECT重新生成快照 |
| 不可重复读 | 避免 | RC级别会出现 |
| 幻读 | 基本避免(MVCC+间隙锁) | RC级别会出现 |
| 适用场景 | 高并发CRUD | 复杂查询、数据分析 |
MySQL选择RR作为默认,是为了在并发性能和数据一致性之间取得平衡,适合大多数互联网业务场景。PostgreSQL选择RC作为默认,则是因为它在RR级别下对幻读的处理更加严格,可能导致更多死锁。
五、业务场景中怎么选隔离级别?
| 业务场景 | 推荐级别 | 理由 |
|---|---|---|
| 金融账务、库存扣减 | SERIALIZABLE 或 RR | 数据一致性要求极高 |
| 电商订单、用户中心 | RR(MySQL默认) | 平衡性能与一致性 |
| 报表查询、数据分析 | RC | 查询为主,可接受不可重复读 |
| 高并发日志写入 | RC 或 READ UNCOMMITTED | 写入为主,对一致性要求低 |
选型原则:不是隔离级别越高越好。隔离级别越高,并发性能越低。应该根据业务对一致性和性能的要求做权衡。
六、实战避坑:两个常见的隔离级别陷阱
陷阱1:在RC级别下做“先查后改”
很多业务逻辑是“先查询状态,再根据状态做更新”。在RC级别下,两次查询之间数据可能被其他事务修改,导致“查的时候是A,改的时候已经变成B了”——数据被“吞”了。解决方案:要么用RR级别,要么在查询时加FOR UPDATE锁住数据。
陷阱2:RR级别下的间隙锁导致死锁
RR级别通过间隙锁避免幻读,但间隙锁也是死锁的常见来源。在RR级别下做范围删除或范围更新,可能锁住大量不存在的“间隙”,多个事务互相等待形成死锁。如果业务场景不需要防止幻读,可以考虑降级到RC级别,牺牲一点一致性换取更高的并发性能。
总结
事务隔离级别是数据库并发控制的核心机制,直接影响数据的正确性和系统的吞吐量。理解脏读、不可重复读、幻读的本质差异,知道MySQL为什么选择RR、PostgreSQL为什么选择RC作为默认,你就能在业务场景中做出更合理的选择,写出更可靠的事务代码。
小耶在手,SQL 不愁
还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~