Files
cs-note/hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性.md
T

224 lines
9.3 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [MySQL, 隔离级别, READ COMMITTED, REPEATABLE READ, SERIALIZABLE]
create time: 2026-05-16 00:00
---
# 隔离级别与可见性
## 概述
SQL 标准定义了四种事务隔离级别,它们控制着并发事务之间的可见性程度。理解每个级别的语义和可能产生的问题,是正确设计高并发系统的前提。
## 四种隔离级别
```mermaid
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 拿到的数据就永远失去了意义。
```sql
-- 隔离级别: 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 的修改给「污染」了。
```sql
-- 隔离级别: 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 再次查询时"无中生有"地多出了几行。
```sql
-- 隔离级别: 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 行为
```mermaid
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
// 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(记录锁 + 间隙锁的组合):
```sql
-- 假设 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-锁机制总览]] — 隔离级别与锁类型的对应关系