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

20 KiB
Raw Blame History

tags, create time
tags create time
MySQL
锁机制
Record Lock
Gap Lock
Next-Key Lock
2026-05-16 00:00

锁机制总览

概述

想象一个大型图书馆:全局锁相当于闭馆——所有人只能看不能借;表锁相当于某个楼层封闭维护;行锁则精确到某一本书被借走,其他人想看同一本得排队。

InnoDB 的锁体系就是这样的分层结构——从全局到表级到行级,粒度越细,并发能力越强,但管理成本也越高。理解锁的分类和使用时机,是排查锁冲突和设计高并发系统的核心。

[!QUESTION] 在往下读之前,先想一个问题 如果两个用户同时下单买同一件商品(库存只剩 1 件),数据库怎么保证不超卖? 带着这个问题往下读,你会在「行级锁」和「当前读」部分找到答案。

锁分类全图

MySQL 的锁种类看似很多,但本质上就是两个维度的组合:

  • 按粒度:锁的范围有多大?(全局 → 表 → 行)
  • 按行为:锁允许别人做什么?(共享读 vs 独占写)

下面这张图把所有锁按层次关系展开:

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 锁到底有哪些类型」的问题了。

全局锁

全局锁是最"暴力"的锁——锁住整个数据库实例,所有线程只能读不能写。就像商场打烊后,所有店铺都暂停营业。

-- 对整个数据库加锁,不允许任何写入
FLUSH TABLES WITH READ LOCK;

-- 释放锁
UNLOCK TABLES;

典型场景:逻辑备份(mysqldump)时确保数据一致,期间不允许任何写操作。生产环境的数据库通常有几十 GB 甚至更大,如果备份过程中有人在写数据,备份出来的数据就会"前后矛盾"——所以需要全局锁来保证快照一致性。

[!QUESTION] ❓ 思考:如果备份期间有人在做 DDL 怎么办?

答案是不影响 —— FLUSH TABLES WITH READ LOCK 只阻止写入,DDL 会阻塞等待全局锁释放。不过线上一般避免在备份窗口内做 DDL,因为全库锁住后所有请求都会排队。

# 使用 --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 改为「等待」而非「取消」,提升了生产环境稳定性。代价是可能出现排队现象——这就是下面要讲的雪崩问题。

-- 会话 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 线上最常见的隐性故障之一。

-- 查看当前 MDL 等待情况
SELECT * FROM performance_schema.metadata_locks;

表级锁

表级锁就是把整张表锁住——粒度比行锁粗,但开销小、加锁快。

显式表锁(LOCK TABLES)

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

-- ❌ 大忌!没有索引会锁全表
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] 为什么需要意向锁? 如果没有意向锁,当有人想给整张表加表锁时,需要逐行扫描才能知道有没有行锁存在——这对于百万行的表来说代价太高了。意向锁的作用就是:在表级做一个"标记",告诉后来者"这张表里有人在锁行",从而快速判断兼容性,不用逐行扫描。

-- 显式加共享锁
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 这行会被阻塞

意向锁的作用

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(记录锁)

锁住具体的索引记录——最简单直观的锁类型,就像在超市货架上贴一个"已有人要买"的标签。

-- 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 锁的是"还不存在但可能出现的东西"。一个是保护现在,一个是防止未来。

-- 假设索引上有以下值: 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;:

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 保证不会读到未提交的事务本身)。

-- 显式设置会话隔离级别
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 等同于唯一索引精确查询
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 条,是因为一致性读使用的是事务启动时的快照。如果业务确实需要最新的计数,必须使用当前读:

SELECT COUNT(*) FROM orders LOCK IN SHARE MODE;

[!NOTE] 深入阅读 想进一步了解 MVCC 与锁的配合原理,参见 hhs/MySQL/06-事务与并发控制/25-MVCC 原理 — 其中详细解释了 Read View 如何与锁协同工作。

锁的场景矩阵

面试和实际排障中,**「这条 SQL 到底加了什么锁?」**是最常见的问题。下面这张图按查询条件分类,帮你快速判断 InnoDB 的选择:

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 会自动检测死锁(通过等待图算法),并回滚代价较小的那个事务,让另一个继续执行。所以死锁本身不会导致数据库崩溃,但会导致业务请求失败。

理解死锁的形成过程比避免死锁更重要:

-- 查看最近的死锁信息
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;
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

关联笔记