9.3 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-05-16 00:00 |
隔离级别与可见性
概述
SQL 标准定义了四种事务隔离级别,它们控制着并发事务之间的可见性程度。理解每个级别的语义和可能产生的问题,是正确设计高并发系统的前提。
四种隔离级别
flowchart LR
RU["Read Uncommitted<br/>读未提交"] --> RC["Read Committed<br/>读已提交"]
RC --> RR["Repeatable Read<br/>可重复读"]
RR --> S["Serializable<br/>串行化"]
RU -->|"无保护"| W1["脏读 / 不可重复读 / 幻影读"]
RC -->|"防脏读"| W2["不可重复读 / 幻影读"]
RR -->|"防不可重复读"| W3["防幻影读"]
S -->|"完全串行"| W4["一切正常"]
style RU fill:#EE5A24,color:#fff
style RC fill:#FF9F43,color:#000
style RR fill:#00D866,color:#fff
style S fill:#00B6BC,color:#fff
速查表
| 隔离级别 | 脏读 | 不可重复读 | 幻影读 | InnoDB 默认 |
|---|---|---|---|---|
| Read Uncommitted | ❌ 可能出现 | ❌ 可能出现 | ❌ 可能出现 | ❌ 不用 |
| Read Committed (RC) | ✅ 防止 | ❌ 可能出现 | ❌ 可能出现 | Java 项目常用 |
| Repeatable Read (RR) | ✅ 防止 | ✅ 防止 | ✅ 防止* | MySQL 默认 |
| Serializable | ✅ 防止 | ✅ 防止 | ✅ 防止 | 极端场景 |
* RR 下 InnoDB 通过 Next-Key Lock 解决了大部分幻影读问题,但不是所有场景都能完全避免。
三大并发问题详解
1. 脏读(Dirty Read)
[!QUESTION] 最危险的问题:读到「还没来得及后悔」的数据 脏读的本质是事务 B 直接看到了事务 A 未提交的中间状态。一旦 A 回滚,B 拿到的数据就永远失去了意义。
-- 隔离级别: Read Uncommitted
BEGIN; -- 事务 A
UPDATE accounts SET balance = 1000 WHERE user_id = 1;
-- 此时 balance=1000,但事务 A 尚未提交
-- 事务 B 在此刻读取
BEGIN; -- 事务 B
SELECT balance FROM accounts WHERE user_id = 1;
-- 读到 balance=1000 ← 脏数据!
-- 事务 A 回滚
ROLLBACK; -- balance 恢复到原来的 500
-- 事务 B 读到的 1000 永远消失了
2. 不可重复读(Non-Repeatable Read)
[!QUESTION] 同一个事务里,为什么前后读到的不一样? 不可重复读关注的是 UPDATE/DELETE 操作的影响:事务 A 在同一事务内两次读取同一行,结果被事务 B 的修改给「污染」了。
-- 隔离级别: Read Committed
BEGIN; -- 事务 A
SELECT balance FROM accounts WHERE user_id = 1; -- 读到 500
-- 事务 B 在此期间修改并提交
BEGIN; -- 事务 B
UPDATE accounts SET balance = 1000 WHERE user_id = 1;
COMMIT;
-- 事务 A 再次读取(在同一个事务内)
SELECT balance FROM accounts WHERE user_id = 1;
-- 读到 1000 ← 同一次事务里读到了不同值!
[!TIP] 不可重复读 ≠ 幻影读 不可重复读针对的是 同一行数据 被修改后读到的变化;幻影读针对的是 行数范围 的变化——多出了几行或少了几行。这是两个不同的维度。
3. 幻影读(Phantom Read)
[!QUESTION] 「幽灵行」从哪来? 幻影读发生在基于范围的查询中。事务 A 根据某个条件筛选数据,事务 B 恰好插入了符合该条件的新行,导致 A 再次查询时"无中生有"地多出了几行。
-- 隔离级别: Read Committed / Repeatable Read
BEGIN; -- 事务 A
SELECT COUNT(*) FROM orders WHERE amount > 100;
-- 结果: 10
-- 事务 B 插入新订单
BEGIN; -- 事务 B
INSERT INTO orders (amount) VALUES (200);
COMMIT;
-- 事务 A 再次查询
SELECT COUNT(*) FROM orders WHERE amount > 100;
-- RC: 读到 11 ← 出现了「幻影行」
-- RR(InnoDB): 仍读到 10 ← 幻影像被挡住了
[!QUESTION] 为什么 InnoDB 能在 RR 下挡住幻影? RC 每次 SELECT 都创建新的 Read View,所以能看到后来提交的新行。而 RR 下第一个 SELECT 就创建了 Read View,整个事务期间都用这个视图——后来的 INSERT 在 Read View 中不可见。再加上 Next-Key Lock 锁住插入间隙,INSERT 也会被阻塞。详见 hhs/MySQL/06-事务与并发控制/25-MVCC 原理。
各隔离级别下的 Read View 行为
sequenceDiagram
participant A as Transaction A
participant RC_DB as RC 模式
participant RR_DB as RR 模式
participant B as Transaction B
A->>RC_DB: BEGIN;
A->>RC_DB: SELECT * FROM t
Note over A,RC_DB: Read View #1
A->>RC_DB: SELECT * FROM t
Note over A,RC_DB: Read View #2 (new each time)
B->>RC_DB: BEGIN;
B->>RC_DB: INSERT INTO t VALUES (...)
B->>RC_DB: COMMIT;
A->>RC_DB: SELECT * FROM t
Note over A,RC_DB: Read View #3
Note over RC_DB: Can see new rows committed by B!
A->>RR_DB: BEGIN;
A->>RR_DB: SELECT * FROM t
Note over A,RR_DB: Read View #1
A->>RR_DB: SELECT * FROM t
Note over A,RR_DB: Reuses Read View #1
B->>RR_DB: BEGIN;
B->>RR_DB: INSERT INTO t VALUES (...)
B->>RR_DB: COMMIT;
A->>RR_DB: SELECT * FROM t
Note over RR_DB: Cannot see B's rows - RV#1 was created before B committed
实际生产中的选择
// Go 连接 MySQL 时,通过 DSN 参数指定隔离级别
dsn := "user:pass@tcp(localhost:3306)/db?charset=utf8mb4&parseTime=True&loc=Local&transaction_isolation=READ-COMMITTED"
db, err := sql.Open("mysql", dsn)
// transaction_isolation 设置的是该连接所有事务的默认隔离级别
// 也可以在执行 BeginTx() 时单独指定 txOptions := &sql.TxOptions{Isolation: sql.LevelReadCommitted}
RC vs RR 在 InnoDB 中的核心差异
| 维度 | RC(读已提交) | RR(可重复读) |
|---|---|---|
| Read View 创建时机 | 每次 SELECT 前都创建全新的 Read View | 第一次 SELECT 时创建一次,整个事务复用 |
| UNDO Log 读取路径 | 同一行可能存在多个版本,需要从最新往前追溯第一个符合当前 Read View 的版本 | 只需沿着 undo log 找到第一个符合事务启动时 Read View 的版本即可 |
| 锁定策略 | 普通的行级共享锁 / 排他锁 | 除了行锁外,还有 Next-Key Lock(Record Lock + Gap Lock)用于范围查询 |
| 并发性能 | 较高——锁范围小,等待时间短 | 较低——Gap Lock 会阻塞其他事务的 INSERT |
| 一致性保障 | 只保证读到最近提交的值 | 保证整个事务看到一致的数据快照 |
[!TIP] 为什么 Java 项目偏爱 RC? JVM 本身就有多线程并发模型作为"天然隔离层"。Spring 等框架的 @Transactional 在大多数场景下只需要 RC 级别的保护,再加上应用层的悲观锁(SELECT FOR UPDATE)或乐观锁(version 字段),就足以覆盖大部分业务需求。RC 让 InnoDB 少做很多工作。
Next-Key Lock 如何挡幻影
InnoDB 在 RR 模式下对 范围查询 使用 Next-Key Lock(记录锁 + 间隙锁的组合):
-- 假设 orders.amount 上有索引
BEGIN; -- 事务 A(RR 模式)
-- 查找 amount > 100 的记录
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
-- InnoDB 会对以下区间加 Next-Key Lock:
-- (-∞, 100] —— 锁住这个范围内的记录以及插入间隙
-- (100, +∞) —— 同样加间隙锁,阻止符合条件的插入
这意味着在事务 A 的范围内,任何其他事务尝试插入 amount = 50 或 amount = 200 的行时,都会被阻塞直到事务 A 提交。这就是 RR 下幻影读被解决的核心机制。
Next-Key Lock 的例外情况
Next-Key Lock 并非万能,有几种场景 RR 仍无法阻止幻影读:
[!IMPORTANT] RC 模式下无论怎么加锁,幻影读都无法避免 RC 的语义就是「每次查询都能看到最新提交的行」,所以不存在 Gap Lock。如果你选了 RC 级别,InnoDB 连间隙锁都不加,任何 INSERT 都不会被挡住。
[!NOTE] 唯一索引(UNIQUE KEY)的特殊行为 当查询条件使用了唯一索引时,InnoDB 会退化到 Record Lock(只做 record lock,不做 gap lock)。因为唯一索引保证了不会有重复值,理论上不存在插入间隙的问题。但这只在等值查询且走唯一索引时成立。
推荐场景速查
| 场景 | 推荐隔离级别 | 理由 |
|---|---|---|
| 金融/支付 | Serializable 或 RR + FOR UPDATE | 强一致性优先 |
| 电商下单 | RR | 库存扣减、订单创建需要完整一致性 |
| 内容 CMS | RC | 允许最终一致,性能更好 |
| 高并发读 | RC | 减少 RR 下的锁竞争 |
| Java/Spring 生态 | RC | Spring 默认就是 RC |
隔离级别选择的注意事项
[!WARNING] MySQL 默认的 RR 不是免费的午餐 虽然 RR 提供了最强的隔离性,但也带来了更严格的锁定策略和更高的冲突概率。如果你用的是 Java/Ruby/Python 等语言,这些框架通常以 RC 级别与 MySQL 通信。混用 RC + RR 时需要特别注意事务语义是否与设计预期一致。
关联笔记
- hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现 — ACID 的基础概念和 Redo/Undo Log
- hhs/MySQL/06-事务与并发控制/25-MVCC 原理 — Read View 的详细构造算法
- hhs/MySQL/06-事务与并发控制/26-锁机制总览 — 隔离级别与锁类型的对应关系