---
tags: [MySQL, 锁机制, Record Lock, Gap Lock, Next-Key Lock]
create time: 2026-05-16 00:00
---
# 锁机制总览
## 概述
想象一个大型图书馆:**全局锁**相当于闭馆——所有人只能看不能借;**表锁**相当于某个楼层封闭维护;**行锁**则精确到某一本书被借走,其他人想看同一本得排队。
InnoDB 的锁体系就是这样的分层结构——从全局到表级到行级,**粒度越细,并发能力越强,但管理成本也越高**。理解锁的分类和使用时机,是排查锁冲突和设计高并发系统的核心。
> [!QUESTION] 在往下读之前,先想一个问题
> 如果两个用户同时下单买同一件商品(库存只剩 1 件),数据库怎么保证不超卖?
> 带着这个问题往下读,你会在「行级锁」和「当前读」部分找到答案。
## 锁分类全图
MySQL 的锁种类看似很多,但本质上就是**两个维度**的组合:
- **按粒度**:锁的范围有多大?(全局 → 表 → 行)
- **按行为**:锁允许别人做什么?(共享读 vs 独占写)
下面这张图把所有锁按层次关系展开:
```mermaid
graph BT
subgraph "按粒度层次"
GL["全局锁 Global Lock"]
TL["表级锁 Table Lock
MDL / 显式表锁"]
RL["行级锁 Row Lock"]
end
subgraph "行锁三大子类"
IL["意向锁 Intention
IS / IX(自动)"]
RLock["记录锁 Record Lock
只锁具体索引行"]
GLock["间隙锁 Gap Lock
锁区间(不含记录)"]
NKLock["临键锁 Next-Key Lock
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
```
### 场景矩阵 — 查询条件 → 锁类型速查
```mermaid
flowchart TD
Q{"查询方式"}
Q -->|"精确等值(unique index)"| R1["Record Lock
仅锁那条记录"]
Q -->|"精确等值(non-unique index)"| R2["Next-Key Lock
记录 + 左侧间隙"]
Q -->|"范围查询
WHERE id > 10"| R3["每个匹配记录的
Next-Key Lock + 最大值右侧间隙"]
Q -->|"无索引条件"| R4["全表记录的 Next-Key Lock
退化为表锁效果 ⚠️"]
Q -->|"DELETE | UPDATE | INSERT"| R5["INSERT: Gap Lock on gap
DELETE/UPDATE: Record Lock"]
style R1 fill:#00B6BC,color:#fff
style R4 fill:#EE5A24,color:#fff
style R5 fill:#FF9F43,color:#000
```
> [!NOTE] 为什么需要三张图?
>
> - **第一张**展示锁的粒度层次:从全局→表→行
> - **第二张**展示行锁的子类关系:三种基础锁如何组合成临键锁
> - **第三张**展示不同查询条件下 InnoDB 实际选择哪种锁(场景矩阵)
>
> 三者互为补充,共同构成完整的锁选型全景。带着这三张图,你可以回答面试中「InnoDB 锁到底有哪些类型」的问题了。
## 全局锁
全局锁是最"暴力"的锁——锁住整个数据库实例,所有线程只能读不能写。就像商场打烊后,所有店铺都暂停营业。
```sql
-- 对整个数据库加锁,不允许任何写入
FLUSH TABLES WITH READ LOCK;
-- 释放锁
UNLOCK TABLES;
```
**典型场景**:逻辑备份(mysqldump)时确保数据一致,期间不允许任何写操作。生产环境的数据库通常有几十 GB 甚至更大,如果备份过程中有人在写数据,备份出来的数据就会"前后矛盾"——所以需要全局锁来保证快照一致性。
> [!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] 为什么需要这种保护?
> 想象一下:事务 A 正在查 `users` 表的 `name` 字段,此时事务 B 执行 `ALTER TABLE users DROP COLUMN name`。如果 InnoDB 不加保护,事务 A 读到一半发现字段没了——这不是 Bug,这是灾难。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;
> ```
## 表级锁
表级锁就是把整张表锁住——粒度比行锁粗,但开销小、加锁快。
### 显式表锁(LOCK TABLES)
```sql
LOCK TABLES users WRITE, orders READ;
-- 当前会话可以操作 users(写)和 orders(读)
-- 其他会话对这两个表的任何操作都被阻塞
UNLOCK TABLES; -- 显式释放
```
> [!NOTE] 实际开发中很少用到
> 显式表锁在 InnoDB 项目里几乎没有使用场景——InnoDB 的行锁已经足够精细。你会在一些老的 MyISAM 项目或特殊的数据迁移脚本中见到它。
### MyISAM 的自动表锁
MyISAM 引擎的锁策略非常"粗犷":**执行每条语句前自动申请表锁,执行完毕后自动释放**。这意味着同一张表,同时只能有一个写操作——并发能力很差。这也是为什么 InnoDB 取代 MyISAM 成为默认引擎的核心原因之一。
> [!QUESTION] 💡 为什么 InnoDB "几乎不用"表锁?
> 因为 InnoDB 支持行级锁,锁粒度可以从"整张表"精确到"某一行"。行锁的并发能力远超表锁——就像一个会议室,表锁是"整个会议室一次只能进一组人",行锁是"每个座位可以分配给不同组"。InnoDB 只在执行 DDL(如 `ALTER TABLE`)时才会用到表锁。
## 行级锁 — 核心中的核心
终于来到最重要的部分了。行级锁是 InnoDB 的看家本领——锁的范围精确到单条记录,并发能力最强。
> [!IMPORTANT] 核心前提:行锁是加在索引上的!
> InnoDB 的行锁**不是锁在数据行上,而是锁在索引记录上**。这意味着:
> - 如果你的 `WHERE` 条件走索引 → 只锁匹配的行
> - 如果 `WHERE` 条件**没有索引** → MySQL 扫描全表索引 → **退化为表锁**!
>
> 这是线上最常踩的坑之一,很多慢查询事故的根源就在于此。
> [!WARNING] ⚠️ 面试高频坑点:无索引 UPDATE
>
> ```sql
> -- ❌ 大忌!没有索引会锁全表
> UPDATE orders SET status = 'shipped' WHERE user_id = 42;
>
> -- ✅ 正确做法:确保 user_id 有索引,或者用主键查询
> UPDATE orders SET status = 'shipped' WHERE id = 1001;
> ```
### 三种基本锁类型
理解行锁,先从三种最基础的锁开始。用一个类比:**S 锁像"图书馆里多人共读一本书",X 锁像"只有你能翻这本书"**。
| 名称 | 符号 | 兼容性 | 通俗解释 |
|------|------|--------|----------|
| **共享锁(S 锁)** | `LOCK IN SHARE MODE` | S+S ✅, S+X ❌ | "我只读,你也可以读,但别改" |
| **排他锁(X 锁)** | `FOR UPDATE` | X+X ❌ | "我要改这行,你们都等着" |
| **意向锁(IS/IX)** | 自动加 | 用于表级兼容检测 | "我打算锁表里的某行" — 纯粹的效率优化 |
> [!QUESTION] 为什么需要意向锁?
> 如果没有意向锁,当有人想给整张表加表锁时,需要**逐行扫描**才能知道有没有行锁存在——这对于百万行的表来说代价太高了。意向锁的作用就是:在表级做一个"标记",告诉后来者"这张表里有人在锁行",从而**快速判断兼容性,不用逐行扫描**。
```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 这行会被阻塞
```
### 意向锁的作用
意向锁解决的核心问题是:**当一个事务想加表级锁时,怎么快速知道"表里有没有人在锁行"?**
没有意向锁 → 必须逐行扫描(百万行代价太高)。
有了意向锁 → 看一眼表级标记就知道。
#### 场景一:行级加锁流程
事务想对某行加锁时,InnoDB **自动**在表级申请意向锁:
```mermaid
flowchart TD
A["事务想在行级加 X 锁"] --> IX["🔒 自动在表级加 IX 锁
(声明:我打算锁表里的某些行)"]
IX --> B{"表级是否有
S 锁或 X 锁?"}
B -->|"有"| C["⏳ 等待表级锁释放"]
B -->|"无"| D["✅ IX 加锁成功
→ 继续在行级加 X 锁"]
style D fill:#00D866,color:#fff
style C fill:#EE5A24,color:#fff
style IX fill:#FF9F43,color:#000
```
> [!NOTE] IS 和 IX 之间、IX 和 IX 之间都**不冲突**
> 因为意向锁只是"声明意图",真正的冲突发生在**行级**。两个事务各自改不同行,表级意向锁完全可以共存。
>
> | 已持有 ↓ \ 请求 → | IS | IX | S | X |
> |---|---|---|---|---|
> | **IS** | ✅ | ✅ | ✅ | ❌ |
> | **IX** | ✅ | ✅ | ❌ | ❌ |
> | **S** | ✅ | ❌ | ✅ | ❌ |
> | **X** | ❌ | ❌ | ❌ | ❌ |
#### 场景二:表级加锁流程(意向锁的真正用途)
当有人想给整张表加 S 锁或 X 锁时,**只需检查表级意向锁**,不用逐行扫描:
```mermaid
flowchart TD
A["事务想给表加
S 锁 / X 锁"] --> Q{"表级有没有 IX 锁?
(有人在做行级写入)"}
Q -->|有| B["⏳ 等待 IX 释放
(说明有人在改行数据)"]
Q -->|无| R{"表级有没有 IS 锁?
(有人在做行级读取)"}
R -->|"X 锁请求 + 有 IS"| B2["⏳ 等待 IS 释放"]
R -->|"S 锁请求 + 有 IS"| C["✅ IS 兼容 S 锁,放行"]
R -->|无| D["✅ 放行,加表级锁"]
style D fill:#00D866,color:#fff
style C fill:#00D866,color:#fff
style B fill:#EE5A24,color:#fff
style B2 fill:#EE5A24,color:#fff
```
> [!TIP] 一句话记住
> 意向锁的真正价值不在于阻塞其他意向锁,而在于**让表级锁的兼容性判断变成 O(1)**——只看标记,不扫行。
## 记录锁、间隙锁、临键锁
这是 InnoDB 最精妙的设计,也是面试中的高频考点。三种锁解决的问题各不相同:
- **Record Lock**:锁住已存在的记录 → 防止并发修改
- **Gap Lock**:锁住记录之间的"空隙" → 防止并发插入
- **Next-Key Lock**:前两者的组合 → RR 隔离级别下防幻读的终极武器
> [!NOTE] 为什么需要锁"空隙"?
> 在 RR 隔离级别下,同一个事务内两次范围查询应该返回相同的结果(防止幻读)。如果只锁记录不锁间隙,其他事务就能在间隙里插入新行,导致第二次查询多出"幽灵记录"。Gap Lock 就是为了堵住这个漏洞。
### Record Lock(记录锁)
锁住**具体的索引记录**——最简单直观的锁类型,就像在超市货架上贴一个"已有人要买"的标签。
```sql
-- idx_status 上有唯一索引
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 只锁住 status='pending' 的那些具体记录
-- 不影响 status='shipped' 的记录
```
### Gap Lock(间隙锁)
锁住**索引记录之间的间隙**,不包含记录本身。目的只有一个:**阻止其他事务在这个间隙中插入新记录**。
> [!QUESTION] Gap Lock 和 Record Lock 最大的区别是什么?
> Record Lock 锁的是"已经存在的东西",Gap Lock 锁的是"还不存在但可能出现的东西"。一个是保护现在,一个是防止未来。
> [!QUESTION] 那 InnoDB 怎么知道该锁哪个间隙?
> 关键在于 B+ 树索引是**有序**的。当查询值不存在时,InnoDB 在索引上做查找,找到**刚好包围这个值的左右两条记录**——左边是 ≤ 查询值的最大记录,右边是 > 查询值的最小记录。这两条记录之间的区间,就是 InnoDB 要锁的间隙。
>
> 一句话总结:**"谁包围了这个值,间隙就是谁"**。
索引上的值 `10, 20, 30, 40, 50` 把整条数轴切成了若干个间隙,目标值落在哪个间隙,就锁哪个:
```mermaid
graph LR
subgraph Gaps["索引记录切分出的间隙"]
direction LR
G0["(-∞, 10)"] --- R1["10"] --- G1["(10, 20)"] --- R2["20"] --- G2["(20, 30)"] --- R3["30"] --- G3["(30, 40)"] --- R4["40"] --- G4["(40, 50)"] --- R5["50"] --- G5["(50, +∞)"]
end
TARGET["查询 id=25"] -.->|25 落入此区间| G2
style G2 fill:#C44569,color:#fff,stroke-width:3px
style TARGET fill:#FF9F43,color:#000
```
```sql
-- 假设索引上有以下值: 10, 20, 30, 40, 50
-- 锁定 (20, 30) 这个开区间
SELECT * FROM t WHERE id = 25 FOR UPDATE;
-- InnoDB 在 B+ 树上查找 25:
-- → 左边最近的记录: 20(≤ 25 的最大值)
-- → 右边最近的记录: 30(> 25 的最小值)
-- → 间隙 = (20, 30),锁住它!
-- 其他事务不能在 (20, 30) 区间内插入任何值
```
### Next-Key Lock = Record Lock + Gap Lock
Next-Key Lock 是 InnoDB 在 **RR(可重复读)** 隔离级别下的**默认行锁算法**。它锁住的是一个**左开右闭区间** `(prev_value, value]`——既锁住当前记录,也锁住左边的间隙。
**一句话总结:Record Lock + Gap Lock = Next-Key Lock,既能防并发修改,又能防并发插入。**
### 一图理解 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["❌ 阻塞
落入 (10, 20) 间隙"]
T2["INSERT id=20"] --> B2["❌ 阻塞
记录已被锁定"]
T3["INSERT id=25"] --> OK["✅ 允许
落在 (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
因为唯一索引已排除
间隙冲突的可能
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
防止别人在 (90,100) 之间
插入 uk=99 的记录
end
```
### ⚡ 当前读 vs 一致性读:谁触发了锁?
理解这一组概念是掌握锁机制的最终一步——**并不是所有 SELECT 都会加锁**。很多 Bug 和死锁排查,都源于对这两种读模式的混淆。
> [!QUESTION] 一个关键直觉
> - **一致性读(快照读)**= "我只看历史照片" → 不需要加锁,因为看的是过去的数据快照
> - **当前读**= "我要看最新现场" → 必须加锁,因为要保证看到的是"此刻最新的数据",不能让别人在我看的瞬间改掉它
| 读取方式 | 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
仅锁那条记录"]
Q -->|"精确等值(non-unique index)"| R2["Next-Key Lock
记录 + 左侧间隙"]
Q -->|"范围查询
WHERE id > 10"| R3["每个匹配记录的
Next-Key Lock + 最大值右侧间隙"]
Q -->|"无索引条件"| R4["全表记录的 Next-Key Lock
退化为表锁效果 ⚠️"]
Q -->|"DELETE | UPDATE | INSERT"| R5["INSERT: Gap Lock on gap
DELETE/UPDATE: Record Lock"]
style R1 fill:#00B6BC,color:#fff
style R4 fill:#EE5A24,color:#fff
style R5 fill:#FF9F43,color:#000
```
> [!TIP] 🧠 记忆卡片 1 — 锁类型三步判断法
>
> 看到一条 SQL,依次回答三个问题就能判断加锁类型:
>
> ```mermaid
> flowchart TD
> Start{"SQL 会加锁吗?
(当前读?)"}
> Start -->|"普通 SELECT"| Snap["❌ 一致性读
不加锁(MVCC)"]
> Start -->|"SELECT FOR UPDATE/SHARE
UPDATE / DELETE / INSERT"| Q1{"1. 有索引吗?"}
>
> Q1 -->|"❌ 无索引"| Full["⚠️ 全表 Next-Key Lock
退化为表锁"]
> Q1 -->|"✅ 有索引"| Q2{"2. 等值查询?"}
>
> Q2 -->|"是"| Q3{"3. 唯一索引?"}
> Q3 -->|"唯一 + 命中"| RL["📌 Record Lock"]
> Q3 -->|"唯一 + 未命中"| GL["🔒 Gap Lock"]
> Q3 -->|"非唯一"| NKL["🔑 Next-Key Lock"]
>
> Q2 -->|"范围查询"| NKL2["🔑 每条命中记录
Next-Key Lock
+ 最大值右侧 Gap"]
>
> style RL fill:#00B6BC,color:#fff
> style GL fill:#FF9F43,color:#000
> style NKL fill:#C44569,color:#fff
> style NKL2 fill:#C44569,color:#fff
> style Full fill:#EE5A24,color:#fff
> style Snap fill:#636e72,color:#fff
> ```
>
> **口诀:无索引全表炸,唯一命中只锁行,其余都带间隙锁。**
> [!TIP] 🧠 记忆卡片 2 — 锁兼容性速记
>
> | 已有 ↓ \ 请求 → | **S** | **X** |
> |---|---|---|
> | **S** | ✅ 共享读没问题 | ❌ 别人要写我得让 |
> | **X** | ❌ 我在写你别读 | ❌ 我在写你别碰 |
>
> **口诀:读读兼容,读写互斥,写写互斥。** 意向锁(IS/IX)之间永远不冲突——它们只是"挂牌子声明意图"。
> [!TIP] 🧠 记忆卡片 3 — RC vs RR 锁行为对比
>
> | | **RR(默认)** | **RC** |
> |---|---|---|
> | Gap Lock | ✅ 有(防幻读) | ❌ 无 |
> | Next-Key Lock | ✅ 默认算法 | 退化为 Record Lock |
> | 并发度 | 较低 | 较高 |
> | 死锁概率 | 较高(Gap Lock 多) | 较低 |
>
> **口诀:RC 没有间隙锁,所以不会防幻读,但并发更高、死锁更少。**
> [!TIP] 🧠 记忆卡片 4 — 死锁四板斧
>
> 1. **固定顺序** — 所有事务按同一顺序(如主键升序)访问资源
> 2. **一次锁完** — 减少持锁期间的等待窗口
> 3. **短事务** — 锁持有时间越短,冲突越少
> 4. **降级到 RC** — 砍掉 Gap Lock,死锁概率大降
## 死锁与排查
在生产环境中,死锁不是「会不会发生」的问题,而是「什么时候发生」。
> [!NOTE] 什么是死锁?一个生活类比
> 两个人在狭窄的走廊迎面走来,都往左让→都往右让→又都往左……谁也过不去,这就是死锁。在数据库里,就是**两个事务互相等待对方释放锁,形成了一个环**。
>
> MySQL InnoDB 会自动检测死锁(通过等待图算法),并**回滚代价较小的那个事务**,让另一个继续执行。所以死锁本身不会导致数据库崩溃,但会导致业务请求失败。
理解死锁的形成过程比避免死锁更重要:
```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,大幅降低死锁概率
## 总结:一张图记住锁机制的核心逻辑
回顾开篇的问题:两个用户同时买同一件商品,数据库怎么保证不超卖?
答案是:
1. `UPDATE inventory SET stock = stock - 1 WHERE product_id = 100` — 这是**当前读**,自动加 X 锁
2. 因为 `product_id` 是索引,InnoDB 用 **Record Lock** 锁住这一行
3. 第二个事务到达时发现行被锁住了,**等待**第一个事务提交或回滚
4. 第一个事务提交后,第二个事务拿到锁,此时 `stock` 已经是 0,`stock - 1 = -1` 由业务层判断拦截
> [!TIP] 面试速记
>
> | 问题 | 答案 |
> |------|------|
> | InnoDB 有哪些锁? | 全局锁、MDL、表锁、行锁(S/X/IS/IX/Record/Gap/Next-Key) |
> | 行锁加在哪? | 索引记录上,不是数据行上 |
> | 无索引会怎样? | 行锁退化为表锁 |
> | 什么是 Next-Key Lock? | Record Lock + Gap Lock,左开右闭区间 |
> | RC 和 RR 的锁区别? | RC 没有 Gap Lock,RR 有 |
> | 什么时候不加锁? | 一致性读(快照读),靠 MVCC |
## 关联笔记
- [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — Redo Log 和 Undo Log 的基础知识
- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — MVCC 与锁的配合(一致性读 vs 当前读)
- [[hhs/MySQL/06-事务与并发控制/27-死锁与排查]] — 完整的死锁诊断流程
- [[hhs/GORM/08-事务管理]] — GORM 事务中的锁获取时机