This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/06-事务与并发控制/26-锁机制总览.md
T
2026-05-21 20:00:46 +08:00

474 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<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)时确保数据一致,期间不允许任何写操作。生产环境的数据库通常有几十 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 这行会被阻塞
```
### 意向锁的作用
```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**:锁住已存在的记录 → 防止并发修改
- **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 锁的是"还不存在但可能出现的东西"。一个是保护现在,一个是防止未来。
```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
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["❌ 阻塞<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 都会加锁**。很多 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<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
## 死锁与排查
在生产环境中,死锁不是「会不会发生」的问题,而是「什么时候发生」。
> [!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 事务中的锁获取时机