diff --git a/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md b/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md index aa8f51e..cf06729 100644 --- a/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md +++ b/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md @@ -258,8 +258,8 @@ sequenceDiagram alt 可见 UL-->>T: 返回该版本 else 仍不可见 - UL->>UL: 继续往前追溯 loop 直到找到可见版本或链表结束 + UL->>UL: 继续往前追溯 end end end diff --git a/hhs/MySQL/06-事务与并发控制/26-锁机制总览.md b/hhs/MySQL/06-事务与并发控制/26-锁机制总览.md index 39c9b91..1411e48 100644 --- a/hhs/MySQL/06-事务与并发控制/26-锁机制总览.md +++ b/hhs/MySQL/06-事务与并发控制/26-锁机制总览.md @@ -7,10 +7,22 @@ create time: 2026-05-16 00:00 ## 概述 -InnoDB 的锁体系是分层次的——从全局到表级到行级,每种锁粒度不同、适用场景不同。理解锁的分类和使用时机是排查锁冲突和设计高并发系统的核心。 +想象一个大型图书馆:**全局锁**相当于闭馆——所有人只能看不能借;**表锁**相当于某个楼层封闭维护;**行锁**则精确到某一本书被借走,其他人想看同一本得排队。 + +InnoDB 的锁体系就是这样的分层结构——从全局到表级到行级,**粒度越细,并发能力越强,但管理成本也越高**。理解锁的分类和使用时机,是排查锁冲突和设计高并发系统的核心。 + +> [!QUESTION] 在往下读之前,先想一个问题 +> 如果两个用户同时下单买同一件商品(库存只剩 1 件),数据库怎么保证不超卖? +> 带着这个问题往下读,你会在「行级锁」和「当前读」部分找到答案。 ## 锁分类全图 +MySQL 的锁种类看似很多,但本质上就是**两个维度**的组合: +- **按粒度**:锁的范围有多大?(全局 → 表 → 行) +- **按行为**:锁允许别人做什么?(共享读 vs 独占写) + +下面这张图把所有锁按层次关系展开: + ```mermaid graph BT subgraph "按粒度层次" @@ -48,6 +60,8 @@ graph BT ## 全局锁 +全局锁是最"暴力"的锁——锁住整个数据库实例,所有线程只能读不能写。就像商场打烊后,所有店铺都暂停营业。 + ```sql -- 对整个数据库加锁,不允许任何写入 FLUSH TABLES WITH READ LOCK; @@ -56,7 +70,7 @@ FLUSH TABLES WITH READ LOCK; UNLOCK TABLES; ``` -**典型场景**:逻辑备份(mysqldump)时确保数据一致,期间不允许任何写操作。 +**典型场景**:逻辑备份(mysqldump)时确保数据一致,期间不允许任何写操作。生产环境的数据库通常有几十 GB 甚至更大,如果备份过程中有人在写数据,备份出来的数据就会"前后矛盾"——所以需要全局锁来保证快照一致性。 > [!QUESTION] ❓ 思考:如果备份期间有人在做 DDL 怎么办? > @@ -70,7 +84,10 @@ mysqldump --single-transaction --routines db_name > backup.sql ## 元数据锁(MDL, Metadata Lock) -MDL 是自动管理的,用户无法手动控制。目的是防止「一边有人查表结构,另一边有人在改表结构」。 +MDL 是**自动管理**的,用户无法手动控制——你甚至感知不到它的存在。它的目的很简单:防止「一边有人查数据,另一边有人在改表结构」。 + +> [!QUESTION] 为什么需要这种保护? +> 想象一下:事务 A 正在查 `users` 表的 `name` 字段,此时事务 B 执行 `ALTER TABLE users DROP COLUMN name`。如果 InnoDB 不加保护,事务 A 读到一半发现字段没了——这不是 Bug,这是灾难。MDL 就是为了避免这种"读写冲突"。 > [!QUESTION] ❓ 为什么 MySQL 5.5+ 才引入 MDL? > @@ -96,7 +113,9 @@ ALTER TABLE users ADD COLUMN bio TEXT; ## 表级锁 -### 自研工具锁 +表级锁就是把整张表锁住——粒度比行锁粗,但开销小、加锁快。 + +### 显式表锁(LOCK TABLES) ```sql LOCK TABLES users WRITE, orders READ; @@ -106,13 +125,26 @@ LOCK TABLES users WRITE, orders READ; UNLOCK TABLES; -- 显式释放 ``` +> [!NOTE] 实际开发中很少用到 +> 显式表锁在 InnoDB 项目里几乎没有使用场景——InnoDB 的行锁已经足够精细。你会在一些老的 MyISAM 项目或特殊的数据迁移脚本中见到它。 + ### MyISAM 的自动表锁 -MyISAM 在执行每条语句前自动申请表锁,执行完毕后自动释放。InnoDB 则几乎不使用表锁——除了某些 DDL 操作。 +MyISAM 引擎的锁策略非常"粗犷":**执行每条语句前自动申请表锁,执行完毕后自动释放**。这意味着同一张表,同时只能有一个写操作——并发能力很差。这也是为什么 InnoDB 取代 MyISAM 成为默认引擎的核心原因之一。 + +> [!QUESTION] 💡 为什么 InnoDB "几乎不用"表锁? +> 因为 InnoDB 支持行级锁,锁粒度可以从"整张表"精确到"某一行"。行锁的并发能力远超表锁——就像一个会议室,表锁是"整个会议室一次只能进一组人",行锁是"每个座位可以分配给不同组"。InnoDB 只在执行 DDL(如 `ALTER TABLE`)时才会用到表锁。 ## 行级锁 — 核心中的核心 -InnoDB 的行锁都是**加在索引 record 上**的。没有索引 = 退化为表锁。 +终于来到最重要的部分了。行级锁是 InnoDB 的看家本领——锁的范围精确到单条记录,并发能力最强。 + +> [!IMPORTANT] 核心前提:行锁是加在索引上的! +> InnoDB 的行锁**不是锁在数据行上,而是锁在索引记录上**。这意味着: +> - 如果你的 `WHERE` 条件走索引 → 只锁匹配的行 +> - 如果 `WHERE` 条件**没有索引** → MySQL 扫描全表索引 → **退化为表锁**! +> +> 这是线上最常踩的坑之一,很多慢查询事故的根源就在于此。 > [!WARNING] ⚠️ 面试高频坑点:无索引 UPDATE > @@ -126,11 +158,16 @@ InnoDB 的行锁都是**加在索引 record 上**的。没有索引 = 退化为 ### 三种基本锁类型 -| 名称 | 符号 | 兼容 | 说明 | -|------|------|------|------| -| **共享锁(S 锁)** | `LOCK IN SHARE MODE` | S+S ✅, S+X ❌ | 读锁,多个事务可同时持有 | -| **排他锁(X 锁)** | `FOR UPDATE` | X+X ❌ | 写锁,独占 | -| **意向锁(IS/IX)** | 自动加 | 用于表级兼容检测 | 表明事务想在某行上加 S/X | +理解行锁,先从三种最基础的锁开始。用一个类比:**S 锁像"图书馆里多人共读一本书",X 锁像"只有你能翻这本书"**。 + +| 名称 | 符号 | 兼容性 | 通俗解释 | +|------|------|--------|----------| +| **共享锁(S 锁)** | `LOCK IN SHARE MODE` | S+S ✅, S+X ❌ | "我只读,你也可以读,但别改" | +| **排他锁(X 锁)** | `FOR UPDATE` | X+X ❌ | "我要改这行,你们都等着" | +| **意向锁(IS/IX)** | 自动加 | 用于表级兼容检测 | "我打算锁表里的某行" — 纯粹的效率优化 | + +> [!QUESTION] 为什么需要意向锁? +> 如果没有意向锁,当有人想给整张表加表锁时,需要**逐行扫描**才能知道有没有行锁存在——这对于百万行的表来说代价太高了。意向锁的作用就是:在表级做一个"标记",告诉后来者"这张表里有人在锁行",从而**快速判断兼容性,不用逐行扫描**。 ```sql -- 显式加共享锁 @@ -167,11 +204,18 @@ flowchart TD ## 记录锁、间隙锁、临键锁 -这是 InnoDB 最精妙的设计,也是理解并发控制的关键。 +这是 InnoDB 最精妙的设计,也是面试中的高频考点。三种锁解决的问题各不相同: + +- **Record Lock**:锁住已存在的记录 → 防止并发修改 +- **Gap Lock**:锁住记录之间的"空隙" → 防止并发插入 +- **Next-Key Lock**:前两者的组合 → RR 隔离级别下防幻读的终极武器 + +> [!NOTE] 为什么需要锁"空隙"? +> 在 RR 隔离级别下,同一个事务内两次范围查询应该返回相同的结果(防止幻读)。如果只锁记录不锁间隙,其他事务就能在间隙里插入新行,导致第二次查询多出"幽灵记录"。Gap Lock 就是为了堵住这个漏洞。 ### Record Lock(记录锁) -锁住**具体的索引记录**。 +锁住**具体的索引记录**——最简单直观的锁类型,就像在超市货架上贴一个"已有人要买"的标签。 ```sql -- idx_status 上有唯一索引 @@ -182,7 +226,10 @@ SELECT * FROM orders WHERE status = 'pending' FOR UPDATE; ### Gap Lock(间隙锁) -锁住**索引记录之间的间隙**,不包含记录本身。目的是阻止其他事务在间隙中插入新记录。 +锁住**索引记录之间的间隙**,不包含记录本身。目的只有一个:**阻止其他事务在这个间隙中插入新记录**。 + +> [!QUESTION] Gap Lock 和 Record Lock 最大的区别是什么? +> Record Lock 锁的是"已经存在的东西",Gap Lock 锁的是"还不存在但可能出现的东西"。一个是保护现在,一个是防止未来。 ```sql -- 假设索引上有以下值: 10, 20, 30, 40, 50 @@ -195,7 +242,9 @@ SELECT * FROM t WHERE id = 25 FOR UPDATE; ### Next-Key Lock = Record Lock + Gap Lock -**左开右闭区间** `(prev_value, value]`,是 RR 模式下默认的锁算法。 +Next-Key Lock 是 InnoDB 在 **RR(可重复读)** 隔离级别下的**默认行锁算法**。它锁住的是一个**左开右闭区间** `(prev_value, value]`——既锁住当前记录,也锁住左边的间隙。 + +**一句话总结:Record Lock + Gap Lock = Next-Key Lock,既能防并发修改,又能防并发插入。** ### 一图理解 Next-Key Lock @@ -288,7 +337,11 @@ sequenceDiagram ### ⚡ 当前读 vs 一致性读:谁触发了锁? -理解这一组概念是掌握锁机制的最终一步——**并不是所有 SELECT 都会加锁**。 +理解这一组概念是掌握锁机制的最终一步——**并不是所有 SELECT 都会加锁**。很多 Bug 和死锁排查,都源于对这两种读模式的混淆。 + +> [!QUESTION] 一个关键直觉 +> - **一致性读(快照读)**= "我只看历史照片" → 不需要加锁,因为看的是过去的数据快照 +> - **当前读**= "我要看最新现场" → 必须加锁,因为要保证看到的是"此刻最新的数据",不能让别人在我看的瞬间改掉它 | 读取方式 | SQL 示例 | 是否加锁 | 数据来源 | |---------|---------|---------|---------| @@ -323,7 +376,7 @@ flowchart TD Q -->|"精确等值(unique index)"| R1["Record Lock
仅锁那条记录"] Q -->|"精确等值(non-unique index)"| R2["Next-Key Lock
记录 + 左侧间隙"] - Q -->|"范围查询"
WHERE id > 10"> R3["每个匹配记录的
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"] @@ -341,7 +394,14 @@ flowchart TD ## 死锁与排查 -在生产环境中,死锁不是「会不会发生」的问题,而是「什么时候发生」。理解死锁的形成过程比避免死锁更重要。 +在生产环境中,死锁不是「会不会发生」的问题,而是「什么时候发生」。 + +> [!NOTE] 什么是死锁?一个生活类比 +> 两个人在狭窄的走廊迎面走来,都往左让→都往右让→又都往左……谁也过不去,这就是死锁。在数据库里,就是**两个事务互相等待对方释放锁,形成了一个环**。 +> +> MySQL InnoDB 会自动检测死锁(通过等待图算法),并**回滚代价较小的那个事务**,让另一个继续执行。所以死锁本身不会导致数据库崩溃,但会导致业务请求失败。 + +理解死锁的形成过程比避免死锁更重要: ```sql -- 查看最近的死锁信息 @@ -384,6 +444,27 @@ sequenceDiagram > 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 的基础知识