vault backup: 2026-05-21 12:20:51

This commit is contained in:
hhs
2026-05-21 12:20:51 +08:00
parent e176563c0b
commit 2e84683d9c
69 changed files with 2008 additions and 6155 deletions
@@ -0,0 +1,296 @@
---
tags: [MySQL, ACID, Redo Log, Undo Log, durability, binlog, MVCC]
create time: 2026-05-16 00:00
---
# ACID 与原子性实现
## 概述
ACID 是事务的四大特性——Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。本节聚焦原子性和持久性的底层实现机制,回答「MySQL 如何保证你提交的修改不会丢失、回滚的操作真的撤销了」。
## ACID 全景
```mermaid
flowchart LR
A["Atomicity<br/>原子性"] -->|"Undo Log 回滚"| T1["要么全部完成<br/>要么全部不做"]
C["Consistency<br/>一致性"] -->|"约束 + 事务组合"| T2["数据库从一个合法状态到另一个合法状态"]
I["Isolation<br/>隔离性"] -->|"MVCC + Lock"| T3["并发事务互不干扰"]
D["Durability<br/>持久性"] -->|"Redo Log 刷盘"| T4["一旦提交永不丢失"]
style A fill:#C44569,color:#fff
style D fill:#EE5A24,color:#fff
```
> [!NOTE] Consistency 是目标,其他三者是手段
> Atomicity、Isolation、Durability 是 InnoDB 提供的技术保障,而 Consistency(业务层面的一致性)需要应用层通过合理的事务设计来实现。InnoDB 保证你提交的 SQL 不会丢、不会乱,但「转账后余额对不对」是你的责任。
### Consistency —— 一致性如何落地
Consistency 不是靠某个单独的日志机制实现的,而是多种约束机制叠加的结果:
| 层级 | 约束类型 | 示例 | 强制时机 |
|------|----------|------|----------|
| **表级** | PRIMARY KEY | `id INT AUTO_INCREMENT PRIMARY KEY` | INSERT / UPDATE |
| | UNIQUE | `email VARCHAR(255) UNIQUE` | INSERT / UPDATE |
| | NOT NULL | `balance DECIMAL(10,2) NOT NULL` | INSERT / UPDATE |
| | FOREIGN KEY | `user_id INT REFERENCES users(id)` | INSERT / UPDATE / DELETE |
| | CHECK | `balance >= 0` (MySQL 8.0.16+) | INSERT / UPDATE |
| **行级** | Row locks | `SELECT ... FOR UPDATE` | 查询时持锁 |
| **事务级** | SERIALIZABLE 隔离级别 | 整个事务串行化执行 | 事务执行期间 |
```sql
-- 约束是 Consistency 的第一道防线
CREATE TABLE accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
UNIQUE KEY uk_user (user_id),
CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 即使有约束,业务一致性仍需事务保障
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- A 转出
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- B 转入
COMMIT;
```
> [!QUESTION] 如果第二条 UPDATE 失败了怎么办?
> 单条 UPDATE 失败只影响本条语句,但如果放在事务中,整个事务可以 ROLLBACK —— 这正是原子性为一致性保驾护航的场景。**三者的关系:原子性是手段,一致性是结果。**
---
## Redo Log —— 保证持久性
Redo Log(重做日志)是 InnoDB 特有的**物理日志**,记录的是「在哪个数据页的什么位置改了什么字节」。
### Redo Log 的工作流程
```mermaid
sequenceDiagram
participant TX as 事务 A
participant BP as Buffer Pool
participant RL as Redo Log Buffer
participant Disk as Redo Log File(磁盘)
TX->>BP: UPDATE accounts SET balance=100 WHERE id=1
Note over BP: 找到数据页 → 修改内存中的值<br/>→ 标记为脏页(dirty page)
BP->>RL: 写入 Redo Log entry<br/>(LSN+1)
alt "innodb_flush_log_at_trx_commit = 1"
RL->>Disk: sync() 强制刷盘 ✅
else innodb_flush_log_at_trx_commit = 2
RL->>OS: 写入 OS Page Cache
Note over Disk: 每秒由后台线程刷盘
end
TX->>TX: COMMIT 返回成功
```
### LSN (Log Sequence Number)
每条 Redo Log 都有一个单调递增的 LSN,用于追踪和排序:
```go
// InnoDB 内部数据结构伪代码
type redo_log_entry struct {
lsn uint64 // 全局唯一的日志序号
page_id uint32 // 被修改的数据页编号
offset uint16 // 页内的偏移量
data []byte // 实际修改的字节(如: old_balance=500 → new_balance=100)
}
```
LSN 是 InnoDB 中最核心的序列号之一,它贯穿了 **Redo Log、Undo Log、数据页、Check Point** 等所有涉及「顺序」的场景。比较两个 LSN 就能判断哪个操作先发生——崩溃恢复全靠它排定先后。
> **理解关键**:Redo Log 记录的不是「把 balance 改成 100」这种 SQL 语义,而是「第 3 号数据页第 2048 字节,从 `0x1F4` 改成了 `0x64`」。这就是为什么叫物理日志 —— 恢复时直接按字节写回即可,不需要解析 SQL,也不需要重新做权限检查、约束校验。
### Redo Log 的两个关键属性
| 属性 | 说明 | 影响 |
|------|------|------|
| **循环写入** | 大小固定,写满后从头覆盖 | 不会无限增长占满磁盘 |
| **物理日志** | 记录页级别修改而非 SQL | 恢复速度快,不需要重执行 SQL |
```ini
# 配置文件
innodb_log_file_size = 512M # 每个 log file 大小,两个共 1GB
innodb_log_files_in_group = 2 # log group 中的文件数
innodb_max_undo_log_size = 1G # undo log 最大体积
```
> [!TIP] innodb_log_file_size 怎么设?
> - 默认各 48MB(太小!频繁 checkpoint)
> - 推荐 256M~2G(写入密集型可更大)
> - 越大意味着:checkpoint 间隔越长、崩溃恢复越慢、缓冲池可容纳更多脏页
> - **调大需要在停机窗口操作**(需要先删旧 log file,MySQL 会报错提示)
### 两阶段提交(Two-Phase Commit, 2PC)
这是保证 Binlog 和 Redo Log 一致性的核心协议:
```mermaid
sequenceDiagram
participant App as 应用
participant S as Server Layer
participant IB as InnoDB Engine
participant BGL as Binlog
participant RDL as Redo Log
App->>S: BEGIN; UPDATE ... COMMIT;
S->>IB: Prepare 事务
IB->>RDL: 写入 Redo Log (prepare 状态)
Note over S: Phase 1: Redo Log Prepared
S->>BGL: 写入 Binlog
Note over S: Phase 2: 确认提交
S->>IB: Commit 通知
IB->>RDL: 标记 Redo Log 为 committed
IB-->>S: 返回 OK
S-->>App: 事务提交成功
```
> [!QUESTION] 为什么要两阶段?
> 如果只写 Binlog 不写 Redo Log(或反过来),崩溃后会出现不一致:
>
> - **只有 Binlog 没有 Redo Log**:主从复制时,从库执行了 Binlog 但该事务从未在本库提交 → 数据错乱
> - **只有 Redo Log 没有 Binlog**:本库恢复了但主从不一致 → 从库缺少这条数据
>
> **中间态处理**:如果 Phase 1 写完准备日志后崩溃,恢复时会检查 Binlog 是否存在:
> - Binlog 存在 → Redo Log 标记为 committed → 恢复后提交
> - Binlog 不存在 → Redo Log 标记为 aborted → 恢复后回滚
## Undo Log —— 保证原子性
Undo Log(回滚日志)是 InnoDB 的**逻辑日志**,记录的是「数据修改前的样子」。
### Undo Log 的核心用途
| 用途 | 说明 |
|------|------|
| **事务回滚** | ROLLBACK 时读取 undo log 反向操作 |
| **MVCC 多版本读** | 构建 Read View 时的历史版本链 |
| **事务一致性快照** | 保证同一事务内多次读看到相同数据 |
```
原始数据:balance = 500
事务 A: UPDATE accounts SET balance = 100 WHERE id = 1
Undo Log 记录:
- undo_no: 1024
- prev_lsn: 12345678
- type: ROW_UPDATE
- table_id: 42
- row_id: 1
- before_image: {balance: 500} ← 修改前的值
rollback 时:用 before_image 恢复 balance = 500
```
Undo Log 本质上维护了一个**修改前镜像链(before-image chain)**。每次对某行做 UPDATE,InnoDB 会把修改前的整行数据(不只是变化的列)写入 undo log,然后在新行版本上应用修改。ROLLBACK 时,InnoDB 从 undo log 中沿着这个链逐个还原即可。
> **为什么是逻辑日志?** 因为 undo log 记录的是「对哪行的哪个字段做了什么操作」,而不是物理字节偏移。这使得 undo log 可以被重放到其他存储引擎,也是 MVCC 能够构建历史版本的基础。
### Rollback Segment
Undo Log 存储在专门的 Rollback Segment 中:
```ini
innodb_rollback_segments = 128 # 回滚段数量
innodb_max_purge_lag_delay = 0 # purge 延迟阈值(微秒)
```
> [!NOTE] Undo Log 的生命周期
> 1. 事务执行过程中持续追加 undo log
> 2. 事务 commit 后,undo log 不会立即删除
> 3. 直到**没有其他事务需要它做 MVCC 可见性判断**时,Purge Thread 才清理它
> 4. undo log 空间会被循环回收
## 崩溃恢复 (Crash Recovery) —— ACID 的最终保障
InnoDB 拥有强大的崩溃恢复机制:**每次重启,都会让数据库回到一致状态**。
### ARIES 恢复算法简述
```mermaid
flowchart TD
A[MySQL Start] --> B[Redo Analysis Stage]
B --> C[Scan Redo Log by LSN]
C --> D[TxId Page Dirty Flag Table]
D --> E{Committed?}
E -->|Yes: committed| F[Redo Forward Apply]
E -->|No: not committed| G[Undo Rollback]
F --> H[Data Pages Restored to Consistent State]
G --> H
H --> I[Recovery Complete<br/>DB Online]
style A fill:#4A90D9,color:#fff
style I fill:#50E3C2,color:#000
```
### 恢复流程图
```mermaid
sequenceDiagram
participant DB as Crash MySQL
participant RDL as Redo Log
participant UDL as Undo Log
participant DP as Data Pages(Disk)
Note over DB: Post-crash state<br/>dirty pages unflushed, tx status unclear
rect rgba(76, 175, 80, 0.15)
Note over DB,RDL: Phase 1 Analysis: rebuild tx state
DB->>DB: Build TxId Page Dirty Flag Table
end
rect rgba(255, 87, 34, 0.15)
Note over DB,RDL: Phase 2 Redo: forward-apply committed tx
loop by LSN order
DB->>DP: Reapply committed modifications
end
end
rect rgba(244, 67, 54, 0.15)
Note over DB,UDL: Phase 3 Undo: rollback uncommitted tx
loop reverse scan undo log
DB->>DP: Undo uncommitted changes
end
end
DB-->>DB: DB restored to consistent state
```
### 典型场景分析
| 崩溃时机 | Redo Log 状态 | Undo Log 状态 | 恢复行为 |
|----------|--------------|--------------|----------|
| **UPDATE 后、COMMIT 前** | 有 redo entry(未标记 committed) | 有 undo record | 回滚未提交事务 |
| **Prepare 后、Binlog 写入前** | prepare 状态 | 有 undo record | 检查 Binlog → 不存在则回滚 |
| **Prepare 后、Commit 通知前** | prepare 状态 | 有 undo record | 检查 Binlog → 存在则提交 |
| **Binlog 写完、Redo Commit 前** | Binlog 已写,redo uncommitted | 有 undo record | 通过 2PC 判断:提交 |
| ** COMMIT 返回后** | 已 committed,可能尚未刷盘 | 不再需要 | 若 redo 未刷盘:crash-safe 窗口内丢失(依赖 binlog replication) |
> [!TIP] crash-safe 的边界
>
> `innodb_flush_log_at_trx_commit = 1` 时,COMMIT 后 Redo Log 已刷盘,即使进程崩溃也不会丢数据(前提是操作系统没挂)。
>
> 但如果**整台机器断电**,且 `innodb_flush_log_at_trx_commit = 2`,最近一秒钟的数据可能丢失。这就是为什么金融系统必须设为 1。
---
## 关联笔记
- [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] — ACID 中 Isolation 的具体实现
- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — Undo Log 如何支撑多版本并发控制
- [[hhs/GORM/08-事务管理]] — GORM 的事务 API 与 MySQL 引擎层的对应关系
@@ -0,0 +1,218 @@
---
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; -- Read View #1
A->>RC_DB: SELECT * FROM t; -- 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; -- Read View #3
Note over RC_DB: Can see new rows committed by B!
A->>RR_DB: BEGIN;
A->>RR_DB: SELECT * FROM t; -- Read View #1
A->>RR_DB: SELECT * FROM t; -- 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-锁机制总览]] — 隔离级别与锁类型的对应关系
@@ -0,0 +1,287 @@
---
tags: [MySQL, MVCC, Read View, Undo Log, 多版本并发控制]
create time: 2026-05-16 00:00
---
# MVCC 原理
## 概述
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现非阻塞读的核心机制。它让读写不冲突——大多数情况下,`SELECT` 完全不需要等待 `UPDATE` 持有排他锁。
> [!TIP] 一句话理解 MVCC
> MVCC 的本质是让每个事务看到「一个时间点的快照」,而不是磁盘上实时的数据。就像给数据库拍照——你在照片里看到的是拍那一瞬间的样子,之后别人怎么改都不会影响你。
## 背后的三个支撑字段
每行记录额外隐藏了两个字段:
| 字段 | 类型 | 作用 |
|------|------|------|
| **DB_TRX_ID** | 6 bytes | 最近修改该行的事务 ID |
| **DB_ROLL_PTR** | 7 bytes | 回滚指针,指向对应的 Undo Log |
此外,二级索引的叶子节点还保存了**主键值**(而不是完整数据),这也是 MVCC 能够工作的基础。
```mermaid
flowchart LR
subgraph Row["行记录物理结构"]
User["用户定义列<br/>id, name, email..."]
TrxId["DB_TRX_ID<br/>事务 ID (6 bytes)"]
RollPtr["DB_ROLL_PTR<br/>回滚指针 (7 bytes)"]
RowId["DB_ROW_ID<br/>隐藏行 ID (6 bytes)"]
end
User --- TrxId --- RollPtr --- RowId
style TrxId fill:#C44569,color:#fff
style RollPtr fill:#FF9F43,color:#000
```
> [!NOTE] 为什么需要 DB_TRX_ID?
> 每一行被某个事务插入或修改时,InnoDB 都需要记住是哪个事务所改的。这样在查 Read View 的时候才知道:"哦,这行是 T2 改的,而我的 Read View 创建时 T2 还没提交"——于是就不给你看这行。
## Version Chain(版本链)
当一行被 UPDATE 时,InnoDB 不会直接修改原数据,而是:
1. 在 Undo Log 中保留旧版本
2. 在新行记录的 DB_ROLL_PTR 上链接到旧版本
3. 更新 DB_TRX_ID 为新事务 ID
```mermaid
flowchart LR
subgraph UndoLog["Undo Log(旧版本)"]
UL["name='Alice'\nTRX_ID=100\nROLL_PTR=NULL"]
end
subgraph CurrentRow["当前行(新版本)"]
CR["name='Alice_New'\nTRX_ID=200\nROLL_PTR->Undo#1"]
end
CR -.->|"ROLL_PTR"| UL
style CR fill:#C44569,color:#fff
style UL fill:#FF9F43,color:#000
```
> [!QUESTION] 为什么不能原地覆盖?
> 如果直接修改原行,之前的事务就看不到自己的"历史视角"了。把旧版本存到 Undo Log 并用指针串联起来,才能同时支持多个事务各自不同的可见性需求。
### 版本链的多次更新
```mermaid
flowchart LR
V1["V1: Alice\nTRX_ID=100"] -->|"Roll Ptr"| V2["V2: Alice_New\nTRX_ID=200"]
V2 -->|"Roll Ptr"| V3["V3: Alice_Latest\nTRX_ID=300"]
style V1 fill:#AAB7B8,color:#fff
style V2 fill:#FF9F43,color:#000
style V3 fill:#C44569,color:#fff
```
Version Chain 是一条按 TRX_ID 递增方向排列的链表:**越靠后的版本越新**。查找时从最新一行往前遍历,找到第一个对当前 Read View 可见的版本即可停止。
## Read View 的构成
> [!TIP] Read View 是什么?
> 你可以把 Read View 想象成事务执行 SELECT 时向数据库申请的「时间窗口」。InnoDB 根据这个时间窗口判断:哪些数据在我进入这个窗口之前就已经存在,哪些是在我进来之后才产生的。
Read View 是一个「可见性规则集合」,决定了哪些版本对当前事务可见:
```go
// InnoDB 内部 ReadView 结构(伪代码)
type ReadView struct {
m_ids []uint64 // 创建时活跃的事务 ID 列表
min_trx_id uint64 // m_ids 中的最小值
max_trx_id uint64 // 创建时下一个将被分配的 trx_id
creator_trx_id uint64 // 创建该 ReadView 的事务 ID
}
```
```mermaid
flowchart TD
A["事务 T5 执行 SELECT"] --> B["创建 ReadView"]
B --> C["m_ids = {T3, T5, T7}\n当前正在运行的事务"]
B --> D["min_trx_id = 3\n活跃事务中最小的 ID"]
B --> E["max_trx_id = 8\n下一个将分配的 ID"]
B --> F["creator_trx_id = 5\n我自己的事务 ID"]
style C fill:#00B6BC,color:#fff
```
> [!NOTE] min_trx_id / max_trx_id 的作用区间
> 这两个字段一起定义了一个半开区间 `[min_trx_id, max_trx_id)`。trx_id 落在这个区间之外的,可以直接通过两条边界判断得出结论;只有落在这个区间的,才需要进一步检查是否在 m_ids 中。这样可以减少不必要的列表扫描。
## 可见性判断规则
给定一行记录的 `trx_id`,判断它对某个 Read View 是否可见:
```mermaid
flowchart TD
A["行记录的 trx_id"] --> B{"trx_id < min_trx_id?"}
B -->|是| V1["✅ 可见\n事务在 ReadView 前已提交"]
B -->|否| C{"trx_id >= max_trx_id?"}
C -->|是| V2["✅ 可见\n事务在 ReadView 后才开始"]
C -->|否| D{"trx_id 在 m_ids 中?"}
D -->|否| V3["✅ 可见\n不在活跃列表 → 已提交"]
D -->|是| V4["❌ 不可见\n创建时还在运行"]
style V1 fill:#00D866,color:#fff
style V2 fill:#00D866,color:#fff
style V3 fill:#00D866,color:#fff
style V4 fill:#EE5A24,color:#fff
```
### 四个规则的速记口诀
| 规则 | 条件 | 结论 | 通俗解释 |
|------|------|------|---------|
| **早提交** | `trx_id < min_trx_id` | ✅ 可见 | 比你早起的人已经下班走了 → 你能看到 |
| **晚启动** | `trx_id >= max_trx_id` | ✅ 可见 | 比你晚来的人还没到办公室 → 你看不到他写的东西 |
| **已退出** | `trx_id ∉ m_ids` | ✅ 可见 | 名单上没有此人 → 他已经提交了 |
| **正活跃** | `trx_id ∈ m_ids` | ❌ 不可见 | 正在工位上坐着呢 → 别看他未完成的产出 |
> [!TIP] 判断顺序很重要
> 实际代码中先比较 `min_trx_id` 和 `max_trx_id` 这两条边界,因为这是 O(1) 的比较操作。只有在落在两个边界之间时,才去扫描 m_ids 列表做成员检查。这种设计在高并发场景下能显著减少 CPU 开销。
## RC 与 RR 的关键区别
```mermaid
flowchart LR
subgraph RC["RC: 每次 SELECT 新建 ReadView"]
RC1["第1次 SELECT → ReadView #1\nm_ids={T2, T4}"]
RC2["第2次 SELECT → ReadView #2\nm_ids={} ← T2 已提交!"]
RC3["第3次 SELECT → ReadView #3\n看到了 T2 的数据 ✨\n也看到了 T4 的数据 ✨"]
end
subgraph RR["RR: 首次 SELECT 创建一次,全事务复用"]
RR1["第1次 SELECT → ReadView #1\nm_ids={T2, T4}"]
RR2["第2次 SELECT → 复用 ReadView #1\nm_ids 不变"]
RR3["第3次 SELECT → 复用 ReadView #1\nT2 和 T4 始终不可见 🔒"]
end
style RC3 fill:#FF9F43,color:#000
style RR3 fill:#00D866,color:#fff
```
> [!IMPORTANT] 为什么 RR 叫"可重复读"?
> 核心原因就在这里——整个事务期间只有一份 ReadView,所以你每次看到的都是同一个快照。不管别的线程怎么改、怎么提交,你的视图始终冻结在第一次 SELECT 的那一刻。
## MVCC 与 DML 的关系
很多开发者只知道 MVCC 处理 SELECT,其实 INSERT、DELETE 同样深度依赖 MVCC 机制:
### INSERT — 写入新版本
INSERT 操作非常简单:插入的行自带当前事务的 `trx_id`,并设置 `ROLL_PTR = NULL`(因为是首个版本)。插入后,其他事务是否能看到这行,取决于它们的 Read View。
```sql
-- 会话 A 插入
START TRANSACTION; -- trx_id = 500
INSERT INTO users (name) VALUES ('Bob');
COMMIT;
-- 会话 B(在 A 提交后查询)
START TRANSACTION; -- trx_id = 600
SELECT * FROM users WHERE name = 'Bob';
-- ✅ 正常查到 Bob —— 因为 500 < min_trx_id(600),已提交
```
### DELETE — 标记删除而非物理移除
DELETE 并不真正删除行记录,而是在 undo log 中创建一个标记为"已删除"的版本,然后在新行的 `DB_TRX_ID` 上标记删除。后续 SELECT 遍历时发现该行被删除且不可见,就直接跳过。
```mermaid
flowchart LR
Before["DELETE 前的行\nname='Alice'\nTRX_ID=200\nROLL_PTR→旧版本"]
After["DELETE 后的行(仍存活)\nname='Alice' marked deleted\nTRX_ID=300\nROLL_PTR→Undo#1"]
Purg["Purge Worker 异步清理\n满足条件的不可见记录\n才会真正从磁盘删除"]
After --> Purg
style Before fill:#FF9F43,color:#000
style After fill:#EE5A24,color:#fff
style Purg fill:#AAB7B8,color:#fff
```
> [!WARNING] DELETE 不会立即释放空间
> 被 DELETE 的行仍然存储在磁盘中,直到 Purge Worker 异步回收。这就是为什么大量 DELETE 后需要用 OPTIMIZE TABLE 重建表来真正释放空间。
### UPDATE — INSERT + DELETE 的组合
UPDATE 本质上是先逻辑删除旧行(加 deleted flag),再插入新行。因此 UPDATE 比纯 INSERT 多出 undo log 的版本链维护开销。
```mermaid
flowchart TD
U1["原行: name='Alice'\nTRX_ID=100"] --> U2["逻辑删除旧版本\n(写 undo log)\n设置 deleted flag"]
U2 --> U3["插入新版本\nTRX_ID=200\nname='Alice_New'\nROLL_PTR→undo"]
style U1 fill:#FF9F43,color:#000
style U3 fill:#C44569,color:#fff
```
## 完整的版本查找过程
```mermaid
sequenceDiagram
participant T as 事务 T5
participant V as ReadView
participant Row as 行记录(最新版)
participant UL as Undo Log 版本链
T->>V: 获取当前 ReadView
V->>Row: 检查 row.trx_id
alt trx_id 可见
Row-->>T: 返回当前版本
else trx_id 不可见
V->>UL: Follow ROLL_PTR 找上一个版本
UL->>UL: 检查上一版的 trx_id
alt 可见
UL-->>T: 返回该版本
else 仍不可见
UL->>UL: 继续往前追溯
loop 直到找到可见版本或链表结束
end
end
end
```
> [!QUESTION] 版本链遍历会不会很慢?
> 理论上是的——如果一行被频繁 UPDATE,版本链会很长。但实践中很少出现这种情况,因为:
> - 大多数业务场景下一行数据的 UPDATE 频率不高
> - InnoDB 有 purge thread 定期清理不可见的旧版本,缩短版本链长度
> - 可以通过合理选择隔离级别(如 RC 模式下的 ReadView 更灵活)来减少回溯次数
## 实战验证
```sql
-- 会话 A(RR 模式,MySQL 默认)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1;
-- 读到 balance = 500
-- 会话 B
START TRANSACTION;
UPDATE accounts SET balance = 1000 WHERE id = 1;
COMMIT;
-- 会话 A(同一个事务内再次查)
SELECT * FROM accounts WHERE id = 1;
-- 仍然读到 500!← ReadView 不让看 T_B 的改动
-- 但如果用当前读呢?
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 读到 1000 ← 当前读不走 MVCC,直接拿最新版本
```
## 关联笔记
- [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] — 隔离级别的定义及 RC vs RR 的选择
- [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — Undo Log 的结构和生命周期
- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — MVCC 与锁的配合(一致性读 vs 当前读)
- [[hhs/MySQL/06-事务与并发控制/28-一致性读与当前读]] — 什么情况下走 MVCC、什么情况下需要加锁
@@ -0,0 +1,392 @@
---
tags: [MySQL, 锁机制, Record Lock, Gap Lock, Next-Key Lock]
create time: 2026-05-16 00:00
---
# 锁机制总览
## 概述
InnoDB 的锁体系是分层次的——从全局到表级到行级,每种锁粒度不同、适用场景不同。理解锁的分类和使用时机是排查锁冲突和设计高并发系统的核心。
## 锁分类全图
```mermaid
graph BT
subgraph "按粒度层次"
GL["全局锁 Global Lock"]
TL["表级锁 Table Lock<br/>MDL / 显式表锁"]
RL["行级锁 Row Lock"]
end
subgraph "行锁三大子类"
IL["意向锁 Intention<br/>IS / IX(自动)"]
RLock["记录锁 Record Lock<br/>只锁具体索引行"]
GLock["间隙锁 Gap Lock<br/>锁区间(不含记录)"]
NKLock["临键锁 Next-Key Lock<br/>Record Lock + Gap Lock"]
end
GL --> TL
TL --> RL
RL --> IL
RL --> RLock
RL --> GLock
RLock & GLock --> NKLock
style GL fill:#C44569,color:#fff
style TL fill:#FF9F43,color:#000
style NKLock fill:#00B6BC,color:#fff
```
> [!NOTE] 为什么需要三张图?
>
> - **第一张**展示锁的粒度层次:从全局→表→行
> - **第二张**展示行锁的子类关系:三种基础锁如何组合成临键锁
> - **第三张**(下方场景矩阵)展示不同查询条件下 InnoDB 实际选择哪种锁
>
> 三者互为补充,共同构成完整的锁选型全景。带着这三张图,你可以回答面试中「InnoDB 锁到底有哪些类型」的问题了。
## 全局锁
```sql
-- 对整个数据库加锁,不允许任何写入
FLUSH TABLES WITH READ LOCK;
-- 释放锁
UNLOCK TABLES;
```
**典型场景**:逻辑备份(mysqldump)时确保数据一致,期间不允许任何写操作。
> [!QUESTION] ❓ 思考:如果备份期间有人在做 DDL 怎么办?
>
> 答案是不影响 —— `FLUSH TABLES WITH READ LOCK` 只阻止写入,DDL 会阻塞等待全局锁释放。不过线上一般避免在备份窗口内做 DDL,因为全库锁住后所有请求都会排队。
```bash
# 使用 --single-transaction 可以避免全局锁
mysqldump --single-transaction --routines db_name > backup.sql
# 基于 MVCC,dump 过程中的写操作不会影响备份一致性
```
## 元数据锁(MDL, Metadata Lock)
MDL 是自动管理的,用户无法手动控制。目的是防止「一边有人查表结构,另一边有人在改表结构」。
> [!QUESTION] ❓ 为什么 MySQL 5.5+ 才引入 MDL?
>
> 在此之前,执行 `ALTER TABLE` 时其他会话的 `SELECT` 会被直接中断(抛出错误)。MDL 让 MySQL 改为「等待」而非「取消」,提升了生产环境稳定性。代价是可能出现排队现象——这就是下面要讲的雪崩问题。
```sql
-- 会话 A:执行查询,持有表的 MDL-SHARED 锁
SELECT * FROM users;
-- 持有 S 锁直到语句结束
-- 会话 B:尝试 ALTER TABLE
ALTER TABLE users ADD COLUMN bio TEXT;
-- 需要 MDL-EXCLUSIVE 锁
-- ⚠️ 阻塞!等待会话 A 释放 S 锁
```
> [!WARNING] MDL 锁导致的雪崩
> 大量长事务或未释放的连接持有 MDL-S 锁,可能导致 DDL 长期排队。这是 MySQL 线上最常见的隐性故障之一。
> ```sql
> -- 查看当前 MDL 等待情况
> SELECT * FROM performance_schema.metadata_locks;
> ```
## 表级锁
### 自研工具锁
```sql
LOCK TABLES users WRITE, orders READ;
-- 当前会话可以操作 users(写)和 orders(读)
-- 其他会话对这两个表的任何操作都被阻塞
UNLOCK TABLES; -- 显式释放
```
### MyISAM 的自动表锁
MyISAM 在执行每条语句前自动申请表锁,执行完毕后自动释放。InnoDB 则几乎不使用表锁——除了某些 DDL 操作。
## 行级锁 — 核心中的核心
InnoDB 的行锁都是**加在索引 record 上**的。没有索引 = 退化为表锁。
> [!WARNING] ⚠️ 面试高频坑点:无索引 UPDATE
>
> ```sql
> -- ❌ 大忌!没有索引会锁全表
> UPDATE orders SET status = 'shipped' WHERE user_id = 42;
>
> -- ✅ 正确做法:确保 user_id 有索引,或者用主键查询
> UPDATE orders SET status = 'shipped' WHERE id = 1001;
> ```
### 三种基本锁类型
| 名称 | 符号 | 兼容 | 说明 |
|------|------|------|------|
| **共享锁(S 锁)** | `LOCK IN SHARE MODE` | S+S ✅, S+X ❌ | 读锁,多个事务可同时持有 |
| **排他锁(X 锁)** | `FOR UPDATE` | X+X ❌ | 写锁,独占 |
| **意向锁(IS/IX)** | 自动加 | 用于表级兼容检测 | 表明事务想在某行上加 S/X |
```sql
-- 显式加共享锁
BEGIN;
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- 其他事务可以也加 S 锁,但不能加 X 锁
-- 不能 UPDATE 这行
-- 显式加排他锁
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 其他事务既不能加 S 也不能加 X
-- 别人 UPDATE 这行会被阻塞
```
### 意向锁的作用
```mermaid
flowchart TD
A["事务 A 想在行级加 X 锁"] --> B{"检查表级意向锁"}
B --> C{"表中是否有其他事务的<br/>意向共享锁 IS?"}
C -->|有| D["冲突!不能同时拥有 IX + IS"]
B -->|无| E{"表中是否有其他事务的<br/>意向排他锁 IX?"}
E -->|有| F["冲突!表级不支持多个 IX"]
E -->|无| G["✅ 放行,加行级 X 锁"]
style G fill:#00D866,color:#fff
style D fill:#EE5A24,color:#fff
style F fill:#EE5A24,color:#fff
```
> [!NOTE] 意向锁不是用户能控制的
> 当事务准备对某行加 S 锁时,InnoDB 自动在表级加 IS;加 X 锁时自动加 IX。它的作用是快速判断「这张表有没有人在用」,而不必逐行扫描。
## 记录锁、间隙锁、临键锁
这是 InnoDB 最精妙的设计,也是理解并发控制的关键。
### Record Lock(记录锁)
锁住**具体的索引记录**。
```sql
-- idx_status 上有唯一索引
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 只锁住 status='pending' 的那些具体记录
-- 不影响 status='shipped' 的记录
```
### Gap Lock(间隙锁)
锁住**索引记录之间的间隙**,不包含记录本身。目的是阻止其他事务在间隙中插入新记录。
```sql
-- 假设索引上有以下值: 10, 20, 30, 40, 50
-- 锁定 (20, 30) 这个开区间
SELECT * FROM t WHERE id = 25 FOR UPDATE;
-- id=25 不存在 → 不锁记录,锁住包含 25 的间隙 (20, 30)
-- 其他事务不能在 (20, 30) 区间内插入任何值
```
### Next-Key Lock = Record Lock + Gap Lock
**左开右闭区间** `(prev_value, value]`,是 RR 模式下默认的锁算法。
### 一图理解 Next-Key Lock
假设索引上有值 **10, 20, 30, 40, 50**,执行 `SELECT ... WHERE id = 20 FOR UPDATE;`:
```mermaid
graph LR
subgraph Data["📊 数据分布"]
direction TB
V1["10"] --- G1["Gap: (-∞, 10)"]
V1 --> NK["(10, 20] ⬅️ 锁定区域"]
NK --> V2["20"]
V2 --> G2["Gap: (20, 30)"]
V2 --> V3["30"]
end
subgraph Test["🧪 INSERT 测试"]
direction TB
T1["INSERT id=15"] --> B1["❌ 阻塞<br/>落入 (10, 20) 间隙"]
T2["INSERT id=20"] --> B2["❌ 阻塞<br/>记录已被锁定"]
T3["INSERT id=25"] --> OK["✅ 允许<br/>落在 (20, 30) 间隙外"]
end
V1 -.-> T1
V2 -.-> T2
V3 -.-> T3
style NK fill:#C44569,color:#fff,stroke-width:3px
style B1 fill:#EE5A24,color:#fff
style B2 fill:#EE5A24,color:#fff
style OK fill:#00D866,color:#fff
```
> [!TIP] 记忆口诀
>
> **「左开右闭」**:Next-Key Lock 锁定的是 `(前一个值, 当前值]`。
> - 插入 **等于** 锁定值的记录 → ❌ 阻塞(Record Lock 生效)
> - 插入 **落在开区间内** 的值 → ❌ 阻塞(Gap Lock 生效)
> - 插入 **落在右侧间隙外** 的值 → ✅ 允许
### RC vs RR:隔离级别对锁的影响
| 特性 | RR(默认) | RC(Read Committed) |
|------|-----------|---------------------|
| Record Lock | ✅ 有 | ✅ 有 |
| **Gap Lock** | ✅ 有 | ❌ **无** |
| **Next-Key Lock** | ✅ 默认使用 | ❌ 退化为 Record Lock |
| 幻读防护 | ✅ Gap Lock 阻止插入 | ❌ 可能发生幻读 |
| 并发度 | 较低(锁范围大) | 较高(不锁间隙) |
> [!IMPORTANT] 为什么 RC 能提升并发?
>
> Gap Lock 的存在意义是防止「幻读」——在同一个事务中两次 `SELECT` 读到不同行数。RC 模式下每次读取都生成快照(Snapshot Isolation),天然不需要 Gap Lock。代价是可能看到其他事务未提交的写入(不过 Read Committed 保证不会读到未提交的事务本身)。
>
> ```sql
> -- 显式设置会话隔离级别
> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
> ```
### Next-Key Lock 的特殊情形
| 场景 | 加锁方式 | 原因 |
|------|---------|------|
| **唯一索引等值查询命中** | 退化为 Record Lock | 已知不存在间隙中的冲突记录 |
| **唯一索引等值查询未命中** | Gap Lock | 锁住该值会落入的间隙(防止幻读) |
| **非唯一索引等值查询** | Next-Key Lock | 多个相同值,需保护左侧间隙 |
| **范围查询** | 每个匹配记录的 Next-Key Lock + 最大值的右边间隙 | 保护整个范围 |
| **INSERT** | Gap Lock on gap | 只锁插入位置的间隙,防冲突 |
| **主键等值更新且命中** | Record Lock | 等同于唯一索引精确查询 |
```mermaid
sequenceDiagram
participant T as 事务
participant DB as InnoDB
T->>DB: SELECT ... WHERE pk = 5 FOR UPDATE
Note over T,DB: 唯一索引、恰好命中
DB-->>T: 📌 Record Lock on pk=5
rect rgb(200, 255, 200)
Note right of DB: ✅ 不加 Gap Lock<br/>因为唯一索引已排除<br/>间隙冲突的可能
end
T->>DB: SELECT ... WHERE uk = 99 FOR UPDATE
Note over T,DB: 唯一索引、**未命中**
DB-->>T: 🔒 Gap Lock on (90, 100)
rect rgb(255, 230, 200)
Note right of DB: ⚠️ 加上 Gap Lock<br/>防止别人在 (90,100) 之间<br/>插入 uk=99 的记录
end
```
### ⚡ 当前读 vs 一致性读:谁触发了锁?
理解这一组概念是掌握锁机制的最终一步——**并不是所有 SELECT 都会加锁**。
| 读取方式 | SQL 示例 | 是否加锁 | 数据来源 |
|---------|---------|---------|---------|
| **一致性读(快照读)** | `SELECT * FROM t WHERE ...`(默认) | ❌ 不加锁 | Undo Log 历史版本(MVCC) |
| **当前读** | `SELECT ... FOR UPDATE/SHARE MODE` | ✅ 加锁 | 磁盘/缓冲池,读取最新提交数据 |
| **当前读** | `UPDATE / DELETE / INSERT` | ✅ 加锁 | 磁盘/缓冲池,读取并修改最新数据 |
> [!IMPORTANT] 为什么区分两种读?
>
> 想象以下场景:
> ```
> 事务 A: BEGIN; SELECT count(*) FROM orders; -- 读取了 100 条
> 事务 B: BEGIN; INSERT INTO orders ... COMMIT; -- 新增了 1 条
> 事务 A: SELECT count(*) FROM orders; -- 还是 100 条!🤔
> ```
>
> 第二次 `SELECT` 仍然看到 100 条,是因为一致性读使用的是事务启动时的快照。如果业务确实需要最新的计数,必须使用当前读:
> ```sql
> SELECT COUNT(*) FROM orders LOCK IN SHARE MODE;
> ```
>
> > [!NOTE] 深入阅读
> > 想进一步了解 MVCC 与锁的配合原理,参见 [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — 其中详细解释了 Read View 如何与锁协同工作。
## 锁的场景矩阵
面试和实际排障中,**「这条 SQL 到底加了什么锁?」**是最常见的问题。下面这张图按查询条件分类,帮你快速判断 InnoDB 的选择:
```mermaid
flowchart TD
Q{"查询方式"}
Q -->|"精确等值(unique index)"| R1["Record Lock<br/>仅锁那条记录"]
Q -->|"精确等值(non-unique index)"| R2["Next-Key Lock<br/>记录 + 左侧间隙"]
Q -->|"范围查询"<br/>WHERE id > 10"> R3["每个匹配记录的<br/>Next-Key Lock + 最大值右侧间隙"]
Q -->|"无索引条件"| R4["全表记录的 Next-Key Lock<br/>退化为表锁效果 ⚠️"]
Q -->|"DELETE | UPDATE | INSERT"| R5["INSERT: Gap Lock on gap<br/>DELETE/UPDATE: Record Lock"]
style R1 fill:#00B6BC,color:#fff
style R4 fill:#EE5A24,color:#fff
style R5 fill:#FF9F43,color:#000
```
> [!TIP] 实用记忆法
>
> 看到一条 SQL,依次回答三个问题就能判断加锁类型:
> 1. **有索引吗?** → 无索引 = 全表锁(❌)
> 2. **是等值查询吗?** → 是 → 再看是否有唯一索引
> 3. **是范围查询吗?** → 是 → 每个命中记录都加 Next-Key Lock
## 死锁与排查
在生产环境中,死锁不是「会不会发生」的问题,而是「什么时候发生」。理解死锁的形成过程比避免死锁更重要。
```sql
-- 查看最近的死锁信息
SHOW ENGINE INNODB STATUS\G
-- 在 LATEST DETECTED DEADLOCK 部分查看详细过程
-- 查看当前锁等待
SELECT * FROM sys.innodb_lock_waits;
-- MySQL 8.0+: 更直观的视图
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
```
```mermaid
sequenceDiagram
participant T1 as 事务 A
participant T2 as 事务 B
participant DB as InnoDB
T1->>DB: LOCK x (X)
DB-->>T1: ✅ 获得 x 锁
T2->>DB: LOCK y (X)
DB-->>T2: ✅ 获得 y 锁
T1->>DB: LOCK y (X)
DB-->>T1: ⏳ 等待 T2 释放 y
T2->>DB: LOCK x (X)
DB-->>T2: ⏳ 等待 T1 释放 x
Note over DB: 检测到环路!
DB->>T1: 💀 死锁! 回滚 T1
DB-->>T2: y 锁可用
T2->>DB: 获得 y 锁 ✅
```
> [!TIP] 避免死锁的策略
> 1. **固定顺序访问资源**:所有事务按相同的顺序加锁(如先用户表再订单表)
> 2. **一次性加所有锁**:减少持锁时间
> 3. **短事务原则**:尽可能少地参与竞争
> 4. **降低隔离级别**:RC 模式不使用 Gap Lock,大幅降低死锁概率
## 关联笔记
- [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — Redo Log 和 Undo Log 的基础知识
- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — MVCC 与锁的配合(一致性读 vs 当前读)
- [[hhs/MySQL/06-事务与并发控制/27-死锁与排查]] — 完整的死锁诊断流程
- [[hhs/GORM/08-事务管理]] — GORM 事务中的锁获取时机
@@ -0,0 +1,316 @@
---
tags: [MySQL, 死锁, Deadlock, 排查, 等待图]
create time: 2026-05-16 00:00
---
# 死锁与排查
## 概述
死锁是两个或多个事务互相等待对方释放锁资源的环形依赖。MySQL InnoDB 内置了死锁检测机制,会自动选择其中一个事务回滚来解除死锁。理解死锁的成因和排查方法是后端工程师的必备技能。
## 常见死锁场景
> [!TIP] 死锁的本质:环形等待
> 所有死锁都可以归结为同一个模式:**每个事务都持有对方需要的资源,同时又在等待对方持有的资源**。
> 想象两个人从桥的两端相向而行,桥只能容一人通过——谁也不退让,就卡住了。数据库里的"退让"就是回滚一个事务。
### 场景一:交叉顺序加锁
**最经典、最容易复现的场景。** 核心原因是两个事务对相同资源的访问顺序不一致。
```sql
-- 事务 A -- 事务 B
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 加 X 锁 user_id=1
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 尝试加 X 锁 user_id=2 → 被 T2 阻塞
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1; -- 尝试加 X 锁 user_id=1 → 被 T1 阻塞
UPDATE accounts SET balance = balance - 100 WHERE user_id = 2; -- 永远不会执行
-- 结果:T1 等 T2 的 user_id=2,T2 等 T1 的 user_id=1 → 死锁
```
> [!QUESTION] ❓ 思考一下
> 转账操作为什么容易出现这个问题?因为"扣款方"和"收款方"在两个事务中角色互换了:T_A 先转 1→2,T_B 先转 2→1。如果两笔转账都按 `MIN(user_id)` → `MAX(user_id)` 的顺序操作,就不可能出现循环等待。
**💡 修复方案**:在所有代码路径中统一加锁顺序(比如总是按 user_id 升序):
```sql
-- 统一按照 user_id 从小到大锁定
-- 无论转出还是转入,都是先 lock MIN(a,b),再 lock MAX(a,b)
```
### 场景二:范围查询 + 间隙锁
> [!WARNING] RC vs RR 的关键差异
> 在 **REPEATABLE READ(RR)** 隔离级别下,InnoDB 会启用 Gap Lock。这意味着范围查询"锁定了一段区间"——不仅锁了存在的记录,还锁了它们之间的"空隙"。
> 如果你不需要可重复读保证,**考虑降级到 READ COMMITTED(RC)**,Gap Lock 将不复存在。
```sql
-- 假设表中有 id = 1, 5, 10 三条记录
-- 事务 A -- 事务 B
BEGIN; BEGIN;
SELECT * FROM items INSERT INTO items (id, name)
WHERE id BETWEEN 2 AND 9 VALUES (5, 'item5');
FOR UPDATE; -- 拿到 X 锁(id=5) + Gap(1,5) + Gap(5,10)
-- Gap(5,10) 阻塞了下面的插入
INSERT INTO items (id, name) INSERT INTO items (id, name)
VALUES (5, 'test'); VALUES (7, 'new_item');
-- 尝试插入 id=5 → -- 尝试插入 id=7 → 落入 Gap(5,10)
-- 已被 T_A 的 NX 锁 -- 被 T_A 的 Gap Lock 阻塞!
-- T_A 试图插入 id=7 → 被 T_B 阻塞
-- → 死锁!
```
> [!NOTE] 为什么间隙锁会导致死锁?
> 很多人不理解:`SELECT ... FOR UPDATE` 只是查数据,为什么要锁"不存在的记录"?
> InnoDB 的设计哲学是:**防止幻读**。如果 T_A 查到 2~9 之间没有 id=7 的记录,等它稍后想插入时却被告知"已有人占了",这就是幻读。所以它在 5 和 10 之间也加了一把"虚拟钥匙"。
>
> 这个机制在大多数 OLTP 业务中是过度保护——换成 RC 就能彻底消除这类死锁。
### 场景三:批量操作的非确定性顺序
> [!TIP] 经验法则
> **任何涉及多条记录的批量操作,都应当在代码层面显式排序。** SQL 引擎的优化器可能会根据统计信息、成本模型或执行计划改变行锁定的实际顺序,这与你的预期并不一致。
```sql
-- 批量删除 ORDER BY 不确定导致锁顺序不一致
DELETE FROM orders WHERE id IN (1001, 1002, 1003); -- 实际锁顺序可能是 1003, 1001, 1002
DELETE FROM orders WHERE id IN (1003, 1002, 1001); -- 期望顺序 1003, 1002, 1001
-- IN 列表的顺序不一定等于锁定的顺序,尤其当 Optimizer 重排时
-- ✅ 推荐写法:子查询中显式 ORDER BY
DELETE FROM orders WHERE id IN (
SELECT id FROM (
SELECT id FROM orders WHERE status = 'cancelled' ORDER BY id ASC
) AS ordered
);
```
## SHOW ENGINE INNODB STATUS 分析
> [!IMPORTANT] 只保留最近一次
> `SHOW ENGINE INNODB STATUS` **只显示最新的死锁记录**。如果线上频繁发生死锁,旧记录会被覆盖。
> 这就是为什么必须提前开启 `innodb_print_all_deadlocks`,把全量死锁写入错误日志。
这是排查死锁的第一入口:
```sql
SHOW ENGINE INNODB STATUS\G
```
切换到 `trx` 段(向下滚动),定位到 `LATEST DETECTED DEADLOCK` 区域:
```
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-05-16 10:30:45.123
*** (1) TRANSACTION:
TRANSACTION 123456, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 7890, OS thread handle 140123, query id id 12345
*** (1) WAITING FOR THIS LOCK to be granted:
RECORD LOCKS space id 42 page no 12 n bits 72 index PRIMARY of table `app`.`orders`
trx id 123456 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: p8: 0x55 length 6: 1001
*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 3 sec inserting
mysql tables in use 1, locked 1
*** (2) HOLDING THE LOCK(S):
RECORD LOCKS space id 42 page no 12 n bits 72 index PRIMARY of table `app`.`orders`
trx id 123457 lock mode S locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: p8: 0x55 length 6: 1001
*** (2) WAITING FOR THIS LOCK to be granted:
RECORD LOCKS space id 42 page no 13 n bits 72 index idx_status of table `app`.`orders`
trx id 123457 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: p8: 0x44 length 6: 2001
*** WE ROLL BACK TRANSACTION (2)
```
### 解读模板
> [!CHECKLIST] 逐行拆解法
> 按以下顺序阅读,避免被大量信息干扰:
> 1. **看时间**:`2026-05-16 10:30:45.123` → 确认是不是当前线上问题
> 2. **定位两个事务**:`*** (1)` 和 `*** (2)`
> 3. **分别看各自主张**:"持有的锁" + "等待的锁"
> 4. **画等价图**:T1 holds A → wants B, T2 holds B → wants A → 环形依赖成立
> 5. **找根因**:哪个操作触发了什么锁?为什么需要那个锁?
```
Transaction 1: 持有的锁 → 等待的锁
Transaction 2: 持有的锁 → 等待的锁(将被回滚的那个)
关键信息提取:
- 哪个索引触发了死锁?(index PRIMARY / idx_status)
- 锁的类型?(X lock, S lock, gap lock)
- 涉及哪条记录?(heap no 5, physical record = 1001)
- 被回滚的是哪个事务?(WE ROLL BACK TRANSACTION 2)
```
## 进阶排查手段
### 使用 performance_schema 实时观察
当 `SHOW ENGINE INNODB STATUS` 不够用时,可以直接查询 InnoDB 的运行时锁视图:
```sql
-- 查看当前阻塞链(谁在等谁)
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.req_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM performance_schema.data_lock_waits AS w
INNER JOIN information_schema.innodb_trx AS b ON w.requesting_engine_tx_id = b.trx_id
INNER JOIN information_schema.innodb_trx AS r ON w.blocking_engine_tx_id = r.trx_id;
```
> [!NOTE] performance_schema vs SHOW ENGINE
> - `SHOW ENGINE`:**事后分析**——死锁已经发生、已经被回滚后,只能看到历史记录
> - `performance_schema`:**实时监控**——可以看到正在发生的锁等待链,帮你提前发现即将爆发的问题
>
> 生产环境建议两者配合:`innodb_print_all_deadlocks` 兜底 + 监控脚本轮询 `data_lock_waits`。
### 乐观锁替代方案
并非所有场景都需要排他锁。考虑使用乐观锁(Optimistic Locking)来从根本上消除冲突:
```sql
-- 悲观锁:先加锁再判断
BEGIN;
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE; -- 直接锁住
UPDATE accounts SET balance = 900 WHERE user_id = 1;
COMMIT;
-- ✅ 乐观锁:先改再校验(基于版本号)
UPDATE accounts SET balance = 900, version = version + 1
WHERE user_id = 1 AND version = 5; -- 只有版本仍为 5 时才更新
-- affected_rows = 1 → 成功;affected_rows = 0 → 重试
```
> [!QUESTION] ❓ 什么时候该用乐观锁?
> 记住一个原则:**读多写少用乐观锁,写多读少用悲观锁**。如果你的业务并发写冲突概率很低(比如 < 5%),乐观锁的性能远超悲观锁——因为它不需要数据库层面的锁开销。但一旦冲突变频繁,回滚和重试的成本会超过锁本身。
## 排查工作流
```mermaid
flowchart TD
A["发现死锁告警"] --> B["SHOW ENGINE INNODB STATUS"]
B --> C["提取涉及的事务 SQL"]
C --> D["复现死锁场景"]
D --> E{"根因分析"}
E -->|"加锁顺序不一致"| F["统一加锁顺序"]
E -->|"范围查询间隙锁"| G["缩小 WHERE 范围<br/>或改用 RC"]
E -->|"批量操作无序"| H["ORDER BY 主键后再操作"]
E -->|"大事务持锁时间长"| I["拆小事务<br/>减少持锁范围"]
F --> J["压测验证"]
G --> J
H --> J
I --> J
J --> K{"死锁消失?"}
K -->|是| L["🎉 上线监控"]
K -->|否| D
style L fill:#00D866,color:#fff
```
## Go 中的死锁处理
在 Go 应用层应对死锁的核心思路是:**捕获错误码 1213 → 回滚事务 → 重试**。
```go
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-sql-driver/mysql"
)
func WithRetry(db *sql.DB, retryTimes int, fn func(tx *sql.Tx) error) error {
for i := 0; i < retryTimes; i++ {
tx, err := db.Begin()
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
// MySQL 错误码 1213 = ER_LOCK_DEADLOCK
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1213 {
// 指数退避:第一次等 50ms,第二次 100ms,避免集体雪崩
time.Sleep(time.Millisecond * time.Duration(50*(1<<uint(i))))
continue // 死锁,重试整个事务
}
return err // 非死锁错误,直接返回
}
if err := tx.Commit(); err != nil {
return err
}
return nil // 成功
}
return fmt.Errorf("deadlock after %d retries", retryTimes)
}
```
**关键设计点:**
| 要点 | 说明 |
|------|------|
| **只重试 1213** | 不是所有 DB 错误都该重试。比如唯一键冲突(1062)重试不会有用,应直接返回 |
| **指数退避** | 两个事务同时死锁后各自立刻重试,可能再次死锁。加随机抖动可以分散重试风暴 |
| **先 Rollback 再判断** | 发生错误后必须先清理事务状态,否则后续操作都会失败 |
| **Commit 也需检查** | Commit 也可能因其他原因失败,不能忽略其返回值 |
> [!TIP] 实际生产建议
> - 重试次数建议 3~5 次,过多说明架构有根本问题
> - 加上 Prometheus/Grafana 监控死锁重试率,超过阈值应当告警而非无限重试
> - 对于支付、库存等敏感业务,考虑引入分布式乐观锁或消息队列串行化,而非依赖重试掩盖问题
## 预防策略汇总
| 策略 | 效果 | 实施难度 |
|------|------|---------|
| **固定加锁顺序** | 🏆 最有效 | 低 |
| **缩小事务范围** | 减少持锁窗口 | 低 |
| **使用 RC 隔离级别** | 消除 Gap Lock | 中(需评估业务影响)|
| **批量操作加 ORDER BY PK** | 确定性顺序 | 低 |
| **索引优化** | 减少范围扫描 | 中 |
| **死锁检测超时设置** | 缩短失败等待时间 | 低 |
```ini
# 相关配置
innodb_deadlock_detect = ON # 开启死锁检测(默认 ON)
innodb_lock_wait_timeout = 50 # 锁等待超时(秒)
innodb_print_all_deadlocks = ON # 记录所有死锁到错误日志
```
> [!WARNING] innodb_print_all_deadlocks
> 默认情况下,只有最新的死锁会在 `SHOW ENGINE INNODB STATUS` 中显示。开启此选项后,所有死锁都会记录到 MySQL 错误日志中——这对分析问题至关重要。
## 关联笔记
- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — 各种锁类型及其交互规则
- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — MVCC 如何通过一致性读避免不必要的锁
- [[hhs/DEV/Go-Database]] — Go 中数据库事务的最佳实践
@@ -0,0 +1,334 @@
---
tags: [MySQL, 一致性读, 当前读, snapshot read, current read]
create time: 2026-05-16 00:00
---
# 一致性读 vs 当前读
## 概述
> [!QUESTION] 思考题
> 事务 A 在执行 `SELECT * FROM orders WHERE id = 1`,同时事务 B 正在 `UPDATE orders SET amount = 100 WHERE id = 1` 并尚未 COMMIT——此时 A 应该读到旧值还是新值?
InnoDB 为此提供了两种读模式:**一致性读(Consistent Nonlocking Read)**和**当前读(Current Read)**。它们的核心区别在于是否使用 MVCC 快照、是否获取锁。这个区别直接影响你对并发行为的预期,也是理解 InnoDB 事务隔离的基石。
## 两种读的对比
| 特性 | 一致性读(快照读) | 当前读 |
|------|-------------------|--------|
| **语句** | `SELECT`(不加锁) | `SELECT ... FOR UPDATE/SHARE MODE` |
| | | `UPDATE` |
| | | `DELETE` |
| | | `INSERT` |
| **使用 MVCC?** | ✅ 是,读历史版本 | ❌ 否,读最新已提交数据 |
| **加锁?** | ❌ 不加锁 | ✅ 加 X 锁或 S 锁 |
| **看到的是?** | Read View 时刻的数据 | 实际的、最新的行记录 |
| **隔离级别依赖?** | ✅ 是(RR vs RC 行为不同)| ❌ 否(总是读最新)|
> [!TIP] 核心直觉
> - **一致性读** = 读「快照」→ 不阻塞别人,别人也阻塞不了你 → 适合报表、数据分析
> - **当前读** = 读「真实」→ 拿到最新一行并锁住 → 适合余额扣减、库存扣减等写前校验场景
```mermaid
sequenceDiagram
participant T1 as Transaction A
participant DB as InnoDB
participant T2 as Transaction B
T1->>DB: BEGIN;
T2->>DB: BEGIN;
T1->>DB: SELECT * FROM t WHERE id=1;
Note over T1,DB: 一致性读 → 创建 ReadView,<br/>读到 mvcc_version_A
T2->>DB: UPDATE t SET col="v2" WHERE id=1;
T2->>DB: COMMIT;
T1->>DB: SELECT * FROM t WHERE id=1;
Note over T1,DB: 一致性读 → 复用同一 ReadView,<br/>仍读到 mvcc_version_A ✅
T1->>DB: SELECT * FROM t WHERE id=1 FOR UPDATE;
Note over T1,DB: 当前读 → 绕过 ReadView,<br/>读到 v2 + 获取 X 锁
```
## 底层原理:Undo Log + Version Chain
一致性读之所以能读到「历史版本」,核心依赖 InnoDB 的 **undo log** 和行记录中的 **version chain**。
### Row Record 的结构(MVCC 视角)
每一行记录除了业务数据外,还隐藏了两个关键字段:
| 隐藏字段 | 说明 |
|----------|------|
| `DB_TRX_ID` | 最后修改这行记录的事务 ID(6 字节) |
| `DB_ROLL_PTR` | 回滚指针,指向 undo log 中这条记录的旧版本(20 字节) |
### Undo Log 链(Version Chain)的形成
```mermaid
flowchart LR
A["新写入的行"] --> B["旧版本 (undo_log_1)"]
B --> C["更早版本 (undo_log_2)"]
C --> D["再早版本 (undo_log_3)"]
style A fill:#00D866,color:#fff
style B fill:#FF9F43,color:#000
style C fill:#FF9F43,color:#000
style D fill:#FF9F43,color:#000
```
每次 UPDATE / DELETE 操作时,InnoDB 不会直接覆盖原行,而是:
1. **将旧行数据写入 undo log**(形成版本链上一个节点)
2. **在新行上记录前一个版本的回滚指针** (`DB_ROLL_PTR`)
3. 更新 `DB_TRX_ID` 为当前事务 ID
> [!QUESTION] 为什么是链表而不是数组?
> 因为版本数量在写入时无法预知。链表允许 O(1) 追加新版本,且通过 `DB_ROLL_PTR` 可以高效遍历——只沿着真正需要回溯的路径走。
### Read View 如何从版本链中找到可见版本
```mermaid
flowchart TD
A["SELECT 执行"] --> B["构建 ReadView<br/>m_ids, min_trx_id, max_trx_id"]
B --> C{"遍历 version chain"}
C --> D["检查当前版本的 DB_TRX_ID"]
D --> E{"trx_id 在 ReadView 中可见?"}
E -->|是| G["✅ 返回此版本"]
E -->|否| F["沿 DB_ROLL_PTR 到上一版本"]
F --> C
F --> H{"是否已无更多版本?"}
H -->|是| I["返回空 — 无可视数据"]
style B fill:#00B6BC,color:#fff
style G fill:#00D866,color:#fff
style I fill:#FF6B6B,color:#fff
```
> [!NOTE] 可见性判断规则
> - 若 `trx_id == ReadView.m_ids` 中的某个值 → **当前事务自己改的,可见**
> - 若 `trx_id < min_trx_id` → **提交于快照之前的,可见**
> - 若 `trx_id >= max_trx_id` → **提交于快照之后的,不可见**
> - 若在 `[min_trx_id, max_trx_id)` 之间且在 `m_ids` 列表中 → **可见(该事务在 ReadView 创建时未提交)**
> - 若在 `[min_trx_id, max_trx_id)` 之间但**不在** `m_ids` 列表中 → **不可见(该事务已提交)**
### 一致性读的完整流程
```mermaid
flowchart TD
A["普通 SELECT"] --> B{"目标行是否有锁?"}
B -->|无锁| C["直接读取当前行"]
B -->|有锁| D["跳过当前行"]
D --> E{"DB_ROLL_PTR 是否存在?"}
E -->|否| F["无可视版本, 返回空"]
E -->|是| G["跟随指针读取 undo log 旧版本"]
G --> H{"旧版本 trx_id 可见?"}
H -->|是| I["✅ 返回可见版本"]
H -->|否| J{"还有更早版本?"}
J -->|是| G
J -->|否| F
C --> K["返回结果"]
I --> K
F --> K
style B fill:#FF9F43,color:#000
style I fill:#00D866,color:#fff
style F fill:#FF6B6B,color:#fff
```
> [!TIP] 一致性读的性能优势
> 由于不需要加锁、不需要等待锁释放,一致性读在大量只读场景下几乎不产生并发开销。这就是为什么报表查询、数据导出都应该用普通 `SELECT`——既不影响业务写入,也避免了自己被阻塞。
## 深入理解一致性读
> [!NOTE] 为什么叫「快照读」?
> 因为它读的不是磁盘上的最新数据,而是某个时间点的数据「快照」。在 RR 模式下,这个快照在事务第一次 SELECT 时就冻结了;在 RC 模式下,每次 SELECT 都刷新快照。
## 深入理解当前读
```sql
-- 所有当前读语句
SELECT ... LOCK IN SHARE MODE; -- 加 S 锁(共享读锁)
SELECT ... FOR UPDATE; -- 加 X 锁(排他写锁)
UPDATE ... -- 隐式加 X 锁
DELETE ... -- 隐式加 X 锁
INSERT ... -- 隐式加 X 锁(对被插入的行)
```
### FOR UPDATE 的实际行为
```sql
-- 会话 A
BEGIN;
SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
-- 加 X 锁到 user_id=100 对应的所有索引记录
-- 包括聚簇索引记录和所有二级索引记录
-- 会话 B(尝试读取)
BEGIN;
SELECT * FROM orders WHERE user_id = 100;
-- ✅ 能读到!(一致性读,不走当前读路径)
-- 读到的是 FOR UPDATE 之前的版本
-- 会话 B(尝试修改)
UPDATE orders SET amount = 50 WHERE user_id = 100;
-- ⏳ 阻塞!需要 X 锁,被会话 A 持有
-- 直到 A COMMIT 或 ROLLBACK
```
### FOR SHARE MODE 的行为
```sql
-- 会话 A
BEGIN;
SELECT * FROM orders WHERE user_id = 100 LOCK IN SHARE MODE;
-- 加 S 锁(其他事务也可以加 S 锁,但都不能加 X 锁)
-- 会话 B
BEGIN;
SELECT * FROM orders WHERE user_id = 100 LOCK IN SHARE MODE;
-- ✅ 可以同时获得 S 锁(共享)
UPDATE orders SET amount = 50 WHERE user_id = 100;
-- ⏳ 阻塞!需要 X 锁,但有 S 锁在
```
## 二级索引的回表效应
当前读在二级索引上有一个特殊行为:不仅锁二级索引记录,还要**回表锁住聚簇索引记录**。
```mermaid
sequenceDiagram
participant T as 事务
participant SI as 二级索引 idx_user_id
participant CI as 聚簇索引 PK
T->>SI: UPDATE orders SET amount=? WHERE user_id=100
SI->>SI: 找到所有 user_id=100 的记录,<br/>得到 pk=[1,5,8]
loop 遍历每个 pk
SI->>CI: 回表加 X 锁
CI-->>SI: 已锁
end
SI-->>T: UPDATE 完成
```
> [!TIP] 这意味着什么?
> 用二级索引做 UPDATE/DELETE/FOR UPDATE 时,锁的范围比看起来更大——它覆盖了二级索引记录 + 回表后的聚簇索引记录。如果二级索引区分度不高(比如 gender 列),会导致大量行被锁住。
## 实战决策树
```mermaid
flowchart TD
Q["你需要读数据"] --> Decision{"要读最新提交的数据吗?"}
Decision -->|不需要 / 读旧数据也可接受| Snapshot["用普通 SELECT<br/>一致性读 ✅"]
Decision -->|必须读最新数据| CurrentRead["用当前读"]
CurrentRead --> Purpose{"接下来要修改这些数据吗?"}
Purpose -->|是| FU["SELECT ... FOR UPDATE<br/>拿 X 锁"]
Purpose -->|否,只是防别人改| FS["SELECT ... LOCK IN SHARE MODE<br/>拿 S 锁"]
style Snapshot fill:#00D866,color:#fff
style FU fill:#00B6BC,color:#fff
style FS fill:#FF9F43,color:#000
style Decision fill:#FF9F43,color:#000
```
## 实战场景与避坑指南
### ✅ 典型场景:扣减余额(防超卖)
```sql
-- 正确的做法:当前读 + FOR UPDATE
BEGIN;
SELECT balance FROM user_wallet WHERE user_id = ? FOR UPDATE;
-- ⏱️ 读到的是最新余额,同时锁住行
UPDATE user_wallet SET balance = balance - 50 WHERE user_id = ?;
COMMIT;
```
> [!WARNING] 为什么不能用普通 SELECT?
> 如果用了 `SELECT balance FROM user_wallet WHERE user_id = ?`(一致性读),两个并发事务可能都读到旧的 balance=200,然后各自减 50 → 最后余额变成 150 而不是 100。**数据丢失了**。这就是为什么写前校验必须用当前读。
### ✅ 典型场景:报表查询
```sql
-- 正确的做法:纯 SELECT,不加锁
SELECT product_name, SUM(quantity)
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY product_name;
```
这种场景不需要读「此刻的最新数据」,只要保证自身的事务内一致性即可。使用普通 SELECT 不会影响其他事务的写入性能。
### 🚫 常见误区:误以为 `FOR UPDATE` 会阻塞别人的 `SELECT`
很多开发者以为加了 `FOR UPDATE`,其他人就查不了这行了——**这是错误的**。
| 会话 A | 会话 B | 结果 |
|--------|--------|------|
| `SELECT ... FOR UPDATE` | `SELECT ...` | ✅ B 能读到(快照读)|
| `SELECT ... FOR UPDATE` | `SELECT ... FOR UPDATE` | ⏳ B 被阻塞 |
| `SELECT ... FOR UPDATE` | `UPDATE ...` | ⏳ B 被阻塞 |
| `SELECT ... FOR UPDATE` | `DELETE ...` | ⏳ B 被阻塞 |
**结论**:`FOR UPDATE` 只影响其他事务的**写操作**和**当前读**,不影响普通的 `SELECT`。
### 🚫 常见误区:忽略二级索引的回表锁
```sql
-- 假设 orders 表上有二级索引 idx_user_id(user_id)
BEGIN;
SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
```
这里不仅锁定了 `idx_user_id = 100` 的所有索引记录,还通过回表对每个对应的**聚簇索引主键行**加了 X 锁。
> [!QUESTION] 如果 user_id 上有 100 万条记录,这个 FOR UPDATE 会怎样?
> 会锁住 100 万个聚簇索引记录!在 RR 模式下还会加间隙锁,导致整个索引范围都被锁定。此时任何其他基于 `user_id` 或主键的 INSERT / UPDATE 都会被阻塞。**这就是为什么大范围的 FOR UPDATE 是线上事故的高发原因。**
### 🚫 陷阱:Undo Log 无限膨胀
每次 UPDATE 操作都会将旧版本写入 undo log。如果一个热点行被高频更新,version chain 会越来越长:
```
新值 ← v_n ← v_{n-1} ← ... ← v_2 ← v_1 ← 初始值
```
当一致性读需要遍历这条长链时,性能会逐渐下降。在极端情况下可能导致:
- **查询变慢**:遍历越长的 version chain 消耗越多 CPU
- **undo log 文件膨胀**:占用大量磁盘空间
**建议**:避免对同一行做高频的小幅度更新(例如计数器),考虑改为追加写入历史表。
## RC vs RR 下的差异总结
| 场景 | RR 下的行为 | RC 下的行为 |
|------|-----------|-----------|
| `SELECT`(普通) | 第一次 SELECT 创建 ReadView,之后复用 | 每次 SELECT 创建新 ReadView |
| `SELECT ... FOR UPDATE` | 读最新 + 锁记录 | 读最新 + 锁记录(与 RR 相同)|
| `UPDATE WHERE` 条件列 | 加 Next-Key Lock(含间隙)| 只加 Record Lock(不含间隙)|
| 同一个事务内多次读 | 始终看到一致快照 | 每次可能看到新提交的数据 |
> [!QUESTION] 什么时候 RC 和 RR 的行为会「打架」?
> 当你在同一个连接中混合使用了 RC 和 RR(通过 `SET SESSION` 切换隔离级别)。例如你的框架默认用 RR,但某个关键接口临时切了 RC。这时同一个事务内的 SELECT 行为不一致——前半段用旧 ReadView,后半段用新 ReadView。建议在事务开始时明确设置隔离级别,并在事务结束后恢复。
## 关联笔记
- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — ReadView 的构造和版本链查找
- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — 当前读涉及的 S 锁和 X 锁
- [[hhs/GORM/08-事务管理]] — GORM 中的 FirstForUpdate / SetLock 用法
@@ -0,0 +1,25 @@
---
tags: [MySQL, 事务, ACID, MVCC, 锁, 并发]
create time: 2026-05-20 23:55
---
# 六、事务与并发控制
## 概述
当多个用户同时操作数据时,如何保证数据不出错?本章从 ACID 原理出发,逐步深入隔离级别、MVCC 多版本并发控制、锁机制和死锁排查——这是理解 MySQL 并发行为的核心章节,也是面试的高频考点。
## 本章文档
| # | 主题 | 说明 |
|---|------|------|
| 23 | [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] | 什么是 ACID、Redo Log + Undo Log 怎么保证不出错 |
| 24 | [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] | 四个隔离级别各是什么意思,MySQL 默认的是哪个 |
| 25 | [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] | 不加锁也能并发读的秘密——多版本并发控制 |
| 26 | [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] | 从全局锁到行级锁,MySQL 在不同场景下锁什么 |
| 27 | [[hhs/MySQL/06-事务与并发控制/27-死锁与排查]] | 什么时候会发生死锁、怎么定位和避免 |
| 28 | [[hhs/MySQL/06-事务与并发控制/28-一致性读与当前读]] | 普通 SELECT 读到什么、加 FOR UPDATE 又读到什么 |
## 关联笔记
- [[hhs/MySQL/README]] — MySQL 知识库总目录