vault backup: 2026-05-21 12:20:51
This commit is contained in:
@@ -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 知识库总目录
|
||||
Reference in New Issue
Block a user