【第18天】每天一个MySQL知识点,百日打怪升级
事务隔离级别详解:RC、RR、Serializable 到底怎么选?
大家好,我是一名拥有10年以上经验的DBA老兵,没有那多。
做这个系列,源于一个朴素的愿望:把踩过的坑、总结的经验系统化输出,希望能帮到刚入行或想进阶的兄弟们。
让我们开始今天的第18天内容。
背景引入
💡 同一个 SELECT 查两次,结果不一样——这是 bug 吗?
有个开发同事跑过来找我,说发现了一个"bug"。
他的代码逻辑是这样:先查一下订单状态,如果是"待支付"就执行后续逻辑。但有时候第一次查是"待支付",第二次查就变成了"已支付"——中间隔了不到 100 毫秒。
他问我:"是不是 MySQL 缓存有问题?"
我说:"不是缓存问题,是隔离级别的问题。"
问了一下——他们用的 READ COMMITTED(RC)。RC 下每条 SELECT 读的是最新已提交的数据,所以另一个事务 COMMIT 了支付之后,他的第二次 SELECT 自然就读到了新状态。
这就是不可重复读:同一个事务里两次 SELECT,结果不一样。
这其实不是 bug,而是 READ COMMITTED 隔离级别的"特性"。如果换成 REPEATABLE READ(RR,MySQL 默认),就不会有这个问题——事务开始时拍一次快照,后面所有普通 SELECT 都读这个快照,哪怕别人提交了也看不见。
这就是隔离级别要解决的问题:多个事务同时跑,你允许它们互相看到多少?
今天的目标:搞懂四个隔离级别,以及它们在 MySQL 里的实际行为。
核心概念
隔离级别解决的问题
多个事务并发执行,会出现三个经典问题:
| 问题 | 现象 | 通俗解释 |
|---|---|---|
| 脏读 | 读到别的事务还没提交的数据 | A 改了数据没提交,B 读到了;A 回滚了,B 拿着一个不存在的脏数据 |
| 不可重复读 | 同一条 SELECT 两次执行,读到不同的已提交数据 | 第一次读余额 100,别人转进来 50 并提交了,第二次读余额变成 150 |
| 幻读 | 同一个条件查两次,行数不一样 | 第一次查 WHERE status='pending' 返回 5 行,别人插入了一条并提交了,第二次返回 6 行 |
三个问题严重程度:脏读 > 不可重复读 > 幻读。
四个隔离级别怎么对应
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED(RC) | 避免 | 可能 | 可能 |
| REPEATABLE READ(RR) | 避免 | 避免 | InnoDB 基本避免① |
| SERIALIZABLE | 避免 | 避免 | 避免 |
这张表面试必考,背下来。
注意一个关键差异:SQL 标准说 RR 不防幻读,但 InnoDB 的 RR 大多数情况下防幻读。 因为 InnoDB 在 RR 下用了 Gap Lock(间隙锁,锁住索引记录之间的空隙) + MVCC(多版本并发控制,通过 Undo Log 实现多版本数据共存),能阻止幻行插入。
① 大部分场景下防住了,但有一个边缘情况:DML 语句(UPDATE/DELETE)不遵循快照读,可能看到其他事务刚提交的行,导致"快照读和 DML 看到的数据不一致"。详见后面实战案例。
MySQL 里各隔离级别的实现差异
| 特性 | RC | RR(默认) | Serializable |
|---|---|---|---|
| 快照读时机 | 每条语句执行时重新拍快照 | 事务开始时拍一次快照 | 没有快照读,全部当前读(locking read) |
| Gap Lock | 关闭(只锁行) | 开启 | 开启(更严) |
| 性能 | 高 | 中等 | 低 |
| 适用场景 | 互联网高并发业务 | 默认选择,通用 | 强一致性场景 |
RC 下:每条 SELECT 都是独立的快照,能看到其他事务已提交的修改。所以同一个事务里两次 SELECT 可能结果不同(不可重复读)。
RR 下:第一条 SELECT 拍下快照,之后整个事务里所有普通 SELECT 都读这个快照。所以两次 SELECT 结果永远一致——代价就是事务开始后看不到其他事务新提交的数据。
Serializable 下:所有 SELECT 自动变成 SELECT ... FOR SHARE(共享锁),读也要锁行,并发基本归零。线上几乎不用,除非你做的业务一丁点并发都不能忍。
一个经典面试题:RC 和 RR 到底怎么选?
这个借用一个前同事的原话,我觉得说得特别好——"如果你要确保事务内多次读结果一致,选 RR;如果你更在意并发性能,而且能接受读到最新的已提交数据,选 RC。"
具体来说:
- 选 RR:报表统计(同一个事务内多次 SUM/COUNT 要一致)、需要 Gap Lock 防幻读的场景
- 选 RC:大部分互联网业务(订单、用户、商品),并发高,能接受不可重复读但必须避免脏读
很多互联网公司甚至默认就是 RC,配合 binlog_format=ROW,并发好不少。
实战案例
场景一:事务里两次 SELECT 结果不同——不可重复读
-- 事务 A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 结果: 100
-- 此时事务 B 执行了 UPDATE account SET balance = 200 WHERE id = 1; COMMIT;
SELECT balance FROM account WHERE id = 1; -- 结果: 200(不一样了!)
COMMIT;
这就是不可重复读。同一个事务里,两次查同一个 id,结果变了。在 RR 下不会发生:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 结果: 100
-- 事务 B 提交了 UPDATE
SELECT balance FROM account WHERE id = 1; -- 结果: 100(跟第一次一样,读的是快照)
COMMIT;
场景二:幻读——InnoDB 的 RR 大部分情况下防得住,但不是绝对的
-- 事务 A(RR 级别)
BEGIN;
SELECT * FROM orders WHERE status = 'pending'; -- 返回 5 行
-- 此时事务 B INSERT 了一条 status='pending' 的订单并提交
SELECT * FROM orders WHERE status = 'pending'; -- 还是 5 行(MVCC 快照读,看不到新插入的)
但如果 A 在第二次 SELECT 之前执行了当前读(FOR UPDATE):
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE; -- Gap Lock 阻止了 B 的插入,等 A 提交后 B 才能 INSERT
因为 FOR UPDATE 走的是当前读,而且 Gap Lock 在 status 的索引间隙上加了锁,事务 B 的 INSERT 会被阻塞,直到 A 提交。
但有一个容易被忽略的边缘情况:快照读和 DML 看到的数据不一致。
MySQL 官方文档明确记录了这个问题:在 RR 下,快照(Snapshot)只适用于 SELECT 语句,不适用于 DML 语句(UPDATE/DELETE)的读取部分。 当你的事务执行 DML 时,InnoDB 会走当前读(locking read)来读取数据,而不是事务开始时的那个快照。
具体表现:
-- 事务 A(RR)
BEGIN;
SELECT * FROM orders WHERE status = 'pending'; -- 返回 0 行(没有待支付的订单)
-- 此时事务 B INSERT 了一条 status='pending' 的订单并提交
-- (因为没有加锁,B 的 INSERT 不会被阻塞)
DELETE FROM orders WHERE status = 'pending'; -- 居然删掉了 B 刚插入的那行!
-- 为什么?因为 DELETE 的读部分走的是当前读,看得见 B 提交的数据
SELECT * FROM orders WHERE status = 'pending'; -- 依然返回 0 行(快照读还是旧的)
-- 但这并不能查询到刚才删除的行——因为那些行已经被当前事务删除了,而新的快照读看不到其他事务的数据
官方文档的原话(Consistent Nonlocking Reads):
The snapshot of the database state applies to SELECT statements within a transaction, not necessarily to DML statements. If you insert or modify some rows and then commit that transaction, a DELETE or UPDATE statement issued from another concurrent REPEATABLE READ transaction could affect those just-committed rows, even though the session could not query them.
翻译一下:快照只对 SELECT 有效,不一定对 DML 有效。你用 SELECT 查不到的行,DELETE 可能删得掉。
这种现象虽然不是严格意义上的"幻读"(因为两次 SELECT 结果仍然一致),但本质上属于同一类问题——快照读和当前读之间的数据裂缝。
用人话说就是:你眼睛看到的数据(SELECT)和你动手操作的数据(DELETE/UPDATE)可能不是同一套——因为你眼睛看的是旧地图,手摸到的却是新地形。
如果你需要彻底杜绝这个问题,唯一的办法是 Serializable,或者在 RR 下给所有查询加 FOR UPDATE(但这相当于放弃了快照读的优势)。
避坑指南
⚠️ 真实踩过的坑:
RR 下事务内的 UPDATE 会影响后续 SELECT 的结果
- RR 的快照读是在事务内第一个 SELECT 时建立,但是当前读(UPDATE/DELETE/FOR UPDATE)会读取最新的已提交数据,并在此基础上进行操作
- 之后事务的快照读会看到自己修改后的结果,看起来像是"快照被更新了"
- 坑:事务里先
SELECT看到旧数据,然后UPDATE了同一行,再SELECT——结果变了。很多人以为 RR 是"从头到尾读一样",实际上当前读和快照读用的是两套数据 - 这个面试也常问,记一下
RC 下 binlog 必须用 ROW 格式
- MySQL 官方文档有一句:
binlog_format=STATEMENT下如果用了 RC,会有警告 - 因为 STATEMENT 格式记录的 SQL 在从库回放时可能产生不同的结果(不可重复读导致的行差异)
- 最佳实践:RC + ROW 是一对
- MySQL 官方文档有一句:
Serializable 几乎不用,但面试爱问
- 线上很少见,因为性能太差
- 但如果你的业务要求"所有读都是当前读"(比如金融对账),可以考虑
- 更多人是用
SELECT ... FOR UPDATE在 RR 下实现类似效果
SET TRANSACTION和SET SESSION的区别SET SESSION TRANSACTION ISOLATION LEVEL ...只影响当前会话SET GLOBAL TRANSACTION ISOLATION LEVEL ...影响所有新会话- 改了全局配置后,已有连接不会生效——要重连
- 线上改隔离级别记得分批重启连接,不要一把全踢
思考题
🤔 互动时间:
RR 下,事务 A 先 SELECT 了一行,然后事务 B 修改并提交了这行,事务 A 再 UPDATE 这行——UPDATE 会成功吗?UPDATE 之后 A 再 SELECT 会看到什么?
提示:假设事务 B 已经提交。RR 下当前读会读到最新版本的数据,之后事务的快照读会看到自己修改后的结果。想想 UPDATE 前后数据的变化。为什么 MySQL 官方推荐 RC + ROW 格式 binlog?为什么不推荐 RR + STATEMENT?
一张表,没有任何并发写,只有纯查询——这时候用哪个隔离级别有区别吗?
提示:想想 MVCC 快照的时机。
总结
🎯 面试考点
- 四个隔离级别的演进关系:
RU ──────> RC ──────> RR ──────> Serializable
弱隔离/高并发 强隔离/低并发
- 三个问题:脏读(未提交的数据)、不可重复读(已提交的数据变化)、幻读(行数变化)
- InnoDB 的 RR 大部分场景防幻读,但 DML 语句可能"看到"快照之外的数据(快照只对 SELECT 有效)
- RC vs RR:RC 每次 SELECT 新快照,适合高并发;RR 事务开始拍一次快照,适合一致性要求高的场景
- RC 必须配合 ROW 格式 binlog
- Serializable 线上慎用,面试常考
今天就试一下:查一下你数据库的 transaction_isolation 参数:
SHOW VARIABLES LIKE 'transaction_isolation';
看看你的业务跑在哪个隔离级别上,想一想——如果你的业务是 RR,改成 RC 会出问题吗?反过来呢?
下期预告:脏读、不可重复读、幻读 —— 三个现象一次讲透!
有问题欢迎评论区交流,明天见!