别再死记 Record、Gap、Next-Key:沿一条 SQL 看懂 InnoDB 锁

源码基线:MySQL 5.7.44

本文只做一件事:沿着一条不断出现新矛盾的并发故事,逐步推导 InnoDB 如何保护现有记录、如何保护记录之间的范围,以及冲突后如何等待和检测死锁。


总览:一条 SQL 在哪里进入 InnoDB 锁系统

在进入库存故事前,先看一条 SQL 从客户端到数据页的大致路径。

这张图只回答"每种保护位于哪一层",暂时不要求记住图中的术语;后文会在问题真正出现时逐个解释。

把这张总图压缩成三条路径:

latex 复制代码
第一条:所有表访问
客户端 SQL
→ Server 解析、优化、执行
→ 先保证语句执行期间表定义稳定

第二条:普通 SELECT
执行器
→ InnoDB
→ 沿 B+Tree 找到当前记录
→ 根据可见性规则选择当前版本或 Undo 历史版本

第三条:FOR UPDATE / UPDATE / DELETE
执行器
→ InnoDB 沿 B+Tree 找到实际位置
→ 锁系统保护当前位置的记录或范围

还要先区分两个时间尺度:

本文的主角是 InnoDB 事务锁。Server 层的表定义保护和 Buffer Pool 页面的短时互斥只在总图中定位,不展开它们的内部实现。

全文会先从一条现有库存记录开始,然后依次遇到:

latex 复制代码
现有记录如何保护
→ 不存在的记录如何保护
→ 一个查询范围如何保护
→ INSERT 如何进入被保护的范围
→ SQL 如何沿 B+Tree 找到需要保护的位置
→ 冲突请求如何等待
→ 等待关系如何形成死锁

最后再回到这张总图,把所有细节重新合起来。


假设仓库里只剩最后一件商品。

sql 复制代码
CREATE TABLE inventory (
    sku_id       BIGINT PRIMARY KEY,
    warehouse_id BIGINT NOT NULL,
    stock        INT NOT NULL,
    status       TINYINT NOT NULL
) ENGINE=InnoDB;

INSERT INTO inventory
VALUES (101, 1, 1, 1);

现在有两个购买请求同时到达。

它们都想完成同一件事:

latex 复制代码
确认还有库存
→ 扣减库存
→ 提交

故事就从这里开始。


保护现有记录,才能让"检查并修改"成为一个整体

会话 T1:

sql 复制代码
BEGIN;

SELECT stock
FROM inventory
WHERE sku_id = 101;

会话 T2 几乎同时执行相同查询。

两边都读到:

latex 复制代码
stock = 1

随后两边都根据刚才的判断继续扣减。

如果应用把"读、判断、写"拆成彼此没有保护的步骤,就可能让两个请求都认为自己购买成功。

问题不在于 SQL 语法,而在于这段业务逻辑需要一个不可被其他事务穿过的时间区间:

latex 复制代码
我读到 stock=1
        ↓
在我完成扣减前
        ↓
别人不能改变这条记录

我们先不讨论 MySQL 已经有哪些锁,只从需求反推。

要解决这个问题,数据库至少要能表达:

事务 T1 正在独占使用 sku_id=101 这条当前记录;T1 结束前,其他事务不能修改它。

这就是最基本的排他记录锁。

在 MySQL 中,读取当前记录并取得这种保护可以写成:

sql 复制代码
BEGIN;

SELECT stock
FROM inventory
WHERE sku_id = 101
FOR UPDATE;

-- 根据结果完成后续数据库操作

COMMIT;

FOR UPDATE 的含义不是"我要立刻更新这条记录",而是:

我要读取当前记录,并取得排他保护,因为接下来的事务逻辑将依赖这个当前值。

这个保护当前已经存在记录的锁,叫作 Record Lock

到目前为止,我们只需要知道这一种锁:

latex 复制代码
Record Lock
  = 保护一条已经存在的当前记录

但这里很快会出现第一个疑问:

普通 SELECT 也读取同一条记录,为什么它经常不需要等待这个排他锁?

要回答它,需要先区分两种完全不同的"读"。


普通查询不等待写事务,而是读取可见的历史版本

先把库存重新设为 10。

T2 开启事务并修改库存,但暂时不提交:

sql 复制代码
BEGIN;

UPDATE inventory
SET stock = 9
WHERE sku_id = 101;

此时,InnoDB 主键索引中的当前记录已经是 stock=9

UPDATE 先写 Undo,再原地更新当前记录

InnoDB 的主键索引记录除了业务字段,还包含两个与多版本有关的隐藏字段:

latex 复制代码
DB_TRX_ID
  → 最后修改这条记录的事务 ID

DB_ROLL_PTR
  → 指向一条 Undo Record 的回滚指针

先把 UPDATE 前后的数据结构放在一张图中:

图中只有聚簇索引里的当前记录从 V1 变成了 V2。V1 没有作为另一条完整索引记录留在 B+Tree 中;恢复 V1 所需的信息被写进了 Undo U2。

T2 要把 stock 从 10 改成 9。InnoDB 不会先在主键索引中保留两条完整业务行,而是:

  1. 先写一条 Undo Record,保存恢复旧版本所需的信息。
  2. 再把主键索引中的当前记录改成新值。
  3. 让当前记录的 DB_ROLL_PTR 指向刚写的 Undo Record。

这里最重要的一点是:

Update Undo 通常不是把整个旧数据页复制一份,而是保存"怎样从新版本恢复出上一版本"所需的旧字段和旧事务信息。

当前记录与 Undo 的关系:

DB_ROLL_PTR 不是旧行本身。它是一段定位信息,使 InnoDB 能找到对应 Undo 页中的 Undo Record。

当前版本不可见时,InnoDB 用当前记录和 Undo 得到上一版本

现在 T1 执行普通查询:

sql 复制代码
SELECT stock
FROM inventory
WHERE sku_id = 101;

它通常不是在问:

latex 复制代码
谁正在修改最新值?

而是在问:

latex 复制代码
按照我的事务可见性规则,
我应该看到哪个已经存在的版本?

普通 SELECT 会使用一组事务可见性边界判断版本。MySQL 把这组边界称为 Read View。

Read View 不是把整张表复制一份,它主要保存:

latex 复制代码
创建视图时哪些事务还活跃
哪些事务 ID 一定可见
哪些事务 ID 一定不可见

T1 首先从主键索引读到当前版本 V2,并看到 DB_TRX_ID=T2。如果 Read View 判断 T2 的修改不可见,T1 就不能返回 stock=9

此时出现了一个很具体的问题:T1 需要较早的完整版本 V1,但 V1 并没有作为另一条完整记录保存在 B+Tree 中。

现在真正存在的材料只有两份:

latex 复制代码
主键索引当前记录 V2
  → 拥有完整业务字段,但 stock 已经是 9

Undo Record U2
  → 保存旧 stock=10、旧事务信息等恢复材料
  → 但它本身通常不是一条完整业务行

因此,V2 和 U2 单独都不能直接作为 T1 要返回的 V1。InnoDB 要把它们组合起来:

这个"以当前完整记录的副本为底板,再用 Undo 中的旧信息恢复上一状态"的过程,通常就叫作:

使用 Undo 构造或重建上一版本

这里的"重建"不是把 B+Tree 中的当前记录改回去,而是在查询使用的内存中临时得到一条完整旧记录。

把这个动作放回可见性判断,完整流程如下:

到这里先记住一个关系:

latex 复制代码
上一版本
= 当前版本的内存副本
+ 当前版本所指 Undo 中的旧信息

Undo 不是一条可以直接返回的完整旧行,而是一份"把当前版本退回到上一状态"的材料。

上图最后出现了"沿 R0 重复相同步骤"。这里不是反复使用 U2,而是通过恢复出来的旧回滚指针 R0,寻找更早的另一条 Undo。要理解这些 Undo 为什么能够一段接一段地连接起来,需要把记录连续更新两次。

一条 Undo Record 只能退回一次修改

假设这条库存记录又被 T3 修改了一次:

latex 复制代码
T0 提交 V1:stock=10
T2 产生 V2:stock=9
T3 产生 V3:stock=8

T3 更新时写入 U3。U3 保存的是 T3 修改之前的信息,因此它只能帮助 V3 退回 V2,不能跨过 V2 直接得到 V1。

同理,V2 要退回 V1,需要使用更早的 U2:

图中的 V2、V1 是查询过程中临时得到的候选版本。B+Tree 里仍然只有当前版本 V3;U3 中恢复出的旧回滚指针 U2,负责把下一段历史继续串起来。

这条由"当前版本 → Undo → 上一版本 → 更早 Undo"连接起来的路径,通常叫作版本链。

Read View 要找的是最新的可见版本

假设 T1 的 Read View 判断:

latex 复制代码
T3 的修改不可见
T2 的修改也不可见
T0 的修改可见

InnoDB 不能看到 V3 不可见后就直接返回最老版本,因为 V2 有可能已经对 T1 可见。它必须按照从新到旧的顺序逐个判断:

所以"每得到一个上一版本就检查一次可见性"的原因是:

InnoDB 要返回的不是最老版本,而是沿版本链从新到旧遇到的第一个可见版本,也就是对当前 Read View 而言最新的可见版本。

如果 V2 已经可见,就立即返回 V2,没有必要继续读取 U2;只有 V2 仍不可见时,才继续得到 V1。

源码中的循环正是这个过程。

源码:mysql-server-5.7/storage/innobase/row/row0vers.cc:1158

cpp 复制代码
version = rec;
// 从主键索引中的当前版本开始

for (;;) {
    trx_undo_prev_version_build(
        rec, mtr, version, index, *offsets,
        heap, &prev_version, NULL, vrow, 0);
    // 根据当前版本的回滚指针和 Undo,
    // 在内存中构造上一版本 prev_version

    trx_id = row_get_rec_trx_id(
        prev_version, index, *offsets);

    if (view->changes_visible(trx_id, index->table->name)) {
        *old_vers = rec_copy(...);
        break;
        // 找到第一条可见旧版本,复制到查询结果内存并停止
    }

    version = prev_version;
    // 仍不可见,继续沿更老的回滚指针寻找
}

更底层的上一版本构造,首先读取当前版本的回滚指针,再取得 Undo Record。

源码:mysql-server-5.7/storage/innobase/trx/trx0rec.cc:2224

cpp 复制代码
roll_ptr = row_get_rec_roll_ptr(rec, index, offsets);
// 从当前版本取出 DB_ROLL_PTR

trx_undo_get_undo_rec(..., roll_ptr, ..., &undo_rec);
// 根据回滚指针定位并读取 Undo Record

trx_undo_update_rec_get_sys_cols(
    ptr, &trx_id, &roll_ptr, &info_bits);
// 取出上一版本的事务 ID、旧回滚指针等系统字段

于是 T1 的普通 SELECT 可以这样概括:

这种读取关注的是"哪个版本可见",叫作快照读

它不要求当前记录在后续事务过程中保持不变,因此通常无需加入事务记录锁的竞争。

T1 如果执行:

sql 复制代码
SELECT stock
FROM inventory
WHERE sku_id = 101
FOR UPDATE;

问题就变了。

它不是想看一个历史快照,而是想拿到当前最新状态,并保护后续决策:

因此可以得到第一条主线:

latex 复制代码
普通 SELECT
  → 快照读
  → 通过可见性规则和 Undo 选择历史版本

FOR UPDATE / UPDATE / DELETE
  → 当前读或当前写
  → 读取并保护主键索引中的当前记录

为什么不能给 Undo 重建出来的历史版本加锁?

因为它只是查询线程临时构造的旧行:

  1. 它不是主键索引中当前存在的记录。
  2. 它没有一个稳定的页面内位置。
  3. 查询结束后即可释放。
  4. 即使锁住它,也阻止不了其他事务继续修改当前记录。

所以"看历史"与"保护当前"必须由两套机制分别完成。

源码把这条分界线写得很清楚。

源码:mysql-server-5.7/storage/innobase/row/row0sel.cc:3647

cpp 复制代码
if (prebuilt->select_lock_type != LOCK_NONE) {
    err = lock_clust_rec_read_check_and_lock(...);
    // 锁定读:尝试锁住主键索引中的当前记录
} else {
    if (!lock_clust_rec_cons_read_sees(...)) {
        err = row_sel_build_prev_vers_for_mysql(...);
        // 快照读:当前版本不可见时,沿 Undo 重建旧版本
    }
}

现在 Record Lock 的职责已经明确:

latex 复制代码
它不是为了让普通 SELECT 能读取数据,
而是为了让事务安全地依赖或修改当前记录。

接下来必须回答第二个问题:

这个所谓的"记录",在 InnoDB 底层究竟是什么?


InnoDB 把锁落在索引页内的具体记录上

inventory 使用 InnoDB。它的主键索引是一棵 B+Tree,完整行保存在主键索引的叶子记录中。

先看整棵树,而不是只看叶子记录:

这张图只画 B+Tree 的父子关系:

latex 复制代码
根页 → 内部页 → 叶子页
  = B+Tree 的树形查找路径

到达叶子层以后,叶子页之间还存在按主键顺序的相邻连接。单独画出来是:

图中只画了向右扫描使用的 next 方向;实际叶子页也保存向前的相邻关系。

B+Tree 的读取方式决定会经过多少条记录

Server 层的优化器会根据查询条件和可用索引选择访问方式,然后让 InnoDB 在选中的 B+Tree 中读取记录。

这里说的"扫描"不是从根页开始一个节点一个节点地遍历整棵树,而是:

InnoDB 到达叶子层后,从某个位置开始,按照索引顺序检查一条或多条叶子记录。

先只看三种最基本的读取方式。下面都使用主键索引,换成二级索引时,树形定位和叶子层移动的基本方式相同。

完整唯一键等值查询只读取一个目标位置

例如:

sql 复制代码
SELECT *
FROM inventory
WHERE sku_id = 102;

主键是唯一索引,sku_id=102 最多只能对应一条记录。InnoDB 从根页逐层选择子页,到达叶子页 42 后,在页内定位 PRIMARY(102),读取完成便可以停止。

这叫作唯一键点查。它利用树形路径直接接近目标,不需要从 PRIMARY(101) 开始依次寻找。

源码中,InnoDB 的索引定位入口是 ha_innobase::index_read()

源码:mysql-server-5.7/storage/innobase/handler/ha_innodb.cc:8754

cpp 复制代码
if (key_ptr != NULL) {
    row_sel_convert_mysql_key_to_innobase(
        m_prebuilt->search_tuple,
        m_prebuilt->srch_key_val1,
        m_prebuilt->srch_key_val_len,
        index,
        (byte*) key_ptr,
        (ulint) key_len,
        m_prebuilt->trx);
    // 把 Server 传入的主键值 102
    // 转成 InnoDB 使用的搜索元组 search_tuple
}

page_cur_mode_t mode =
    convert_search_mode_to_innobase(find_flag);
// 把"精确查找、大于等于"等 Handler 查找方式
// 转成 InnoDB 的页面搜索模式

ret = row_search_mvcc(
    buf, mode, m_prebuilt, match_mode, 0);
// 进入 InnoDB 的 B+Tree 行搜索主流程

进入 row_search_mvcc() 后,持久位置指针从索引根向叶子层定位,并取得当前记录:

源码:mysql-server-5.7/storage/innobase/row/row0sel.cc:5201

cpp 复制代码
btr_pcur_open_with_no_init(
    index,
    search_tuple,
    mode,
    BTR_SEARCH_LEAF,
    pcur,
    0,
    &mtr);
// 根据 search_tuple 从根页逐层下降,
// 将位置指针 pcur 放到目标叶子位置

rec = btr_pcur_get_rec(pcur);
// 取得这个叶子位置上的索引记录

这两段源码对应的正是:

latex 复制代码
主键值 102
→ InnoDB 搜索元组
→ 从根页下降到叶子页
→ 取得 PRIMARY(102)

范围查询先定位起点,再沿叶子记录前进

例如:

sql 复制代码
SELECT *
FROM inventory
WHERE sku_id >= 102
  AND sku_id <= 201;

这次不能读取一条就停止,因为 102 到 201 之间可能有多条记录。

这里的下界是范围允许的最小值 102,上界是范围允许的最大值 201。

InnoDB 先像点查一样沿树下降,但定位的是第一条满足下界的记录,也就是第一条 >=102 的叶子记录。找到起点后,再按主键顺序继续读取,必要时跨到下一个叶子页:

这就是范围扫描:

latex 复制代码
先利用 B+Tree 定位范围起点
→ 再沿叶子层顺序读取
→ 遇到超过范围上界的第一条记录后停止

所以"范围扫描沿叶子页连接前进"有一个前提:树形查找已经先把读取位置放到了范围起点。它不是从整棵索引的第一条记录开始扫描。

Server 层的通用范围读取代码直接保存了起点和终点。第一次读取先按 start_key 定位;后续读取不断请求下一条,并用 end_range 判断是否越过上界。

源码:mysql-server-5.7/sql/handler.cc:7396

cpp 复制代码
int handler::read_range_first(
    const key_range* start_key,
    const key_range* end_key,
    bool eq_range_arg,
    bool sorted) {

    set_end_range(end_key, RANGE_SCAN_ASC);
    // 保存范围上界,后续每取得一条记录都要与它比较

    result = ha_index_read_map(
        table->record[0],
        start_key->key,
        start_key->keypart_map,
        start_key->flag);
    // 根据范围下界定位第一条候选记录

    if (compare_key(end_range) <= 0) {
        return 0;
        // 第一条记录仍在范围中,可以返回
    }
}

源码:mysql-server-5.7/sql/handler.cc:7450

cpp 复制代码
int handler::read_range_next() {
    result = ha_index_next(table->record[0]);
    // 请求存储引擎读取索引顺序中的下一条记录

    if (compare_key(end_range) <= 0) {
        return 0;
        // 没越过上界,继续返回
    }

    unlock_row();
    return HA_ERR_END_OF_FILE;
    // 已越过上界,本段范围扫描结束
}

ha_index_next() 进入 InnoDB 后,最终要求行搜索位置向右移动:

源码:mysql-server-5.7/storage/innobase/handler/ha_innodb.cc:9172

cpp 复制代码
int ha_innobase::index_next(uchar* buf) {
    return general_fetch(buf, ROW_SEL_NEXT, 0);
    // ROW_SEL_NEXT 表示沿当前索引顺序读取下一条
}

源码:mysql-server-5.7/storage/innobase/row/row0sel.cc:6233

cpp 复制代码
if (moves_up) {
    move = btr_pcur_move_to_next(pcur, &mtr);
    // 移到下一条叶子记录;当前页结束时可继续到相邻叶子页
}

所以范围扫描的源码主线正好对应前面的图:

latex 复制代码
read_range_first
→ 按下界定位第一条
→ read_range_next
→ pcur 沿索引向右移动
→ 每条记录与上界比较
→ 越过上界后停止

没有可用索引时只能从最左叶子页开始全扫描

例如 status 没有索引:

sql 复制代码
SELECT *
FROM inventory
WHERE status = 1;

主键 B+Tree 并不按 status 排序,因此无法根据 status=1 直接算出应该进入哪个叶子页。

InnoDB 只能先下降到最左侧叶子页,再沿叶子层读取所有聚簇记录,对每条完整行判断 status

对于 InnoDB,完整行位于聚簇索引叶子记录中,因此这种没有合适索引的全表扫描,本质上就是从左到右扫描整棵聚簇索引的叶子层。

源码中,全表扫描入口会先把活动索引切换到主键,也就是聚簇索引:

源码:mysql-server-5.7/storage/innobase/handler/ha_innodb.cc:9268

cpp 复制代码
int ha_innobase::rnd_init(bool scan) {
    if (m_prebuilt->clust_index_was_generated) {
        err = change_active_index(MAX_KEY);
    } else {
        err = change_active_index(m_primary_key);
    }
    // 表存在显式主键时,选择主键聚簇索引作为扫描对象

    m_start_of_scan = true;
    // 标记下一次 rnd_next() 要读取扫描起点

    return err;
}

第一次 rnd_next() 读取索引第一条,之后每次都沿索引顺序取得下一条:

源码:mysql-server-5.7/storage/innobase/handler/ha_innodb.cc:9313

cpp 复制代码
int ha_innobase::rnd_next(uchar* buf) {
    if (m_start_of_scan) {
        error = index_first(buf);
        // 第一次:定位聚簇索引的第一条用户记录

        m_start_of_scan = false;
    } else {
        error = general_fetch(buf, ROW_SEL_NEXT, 0);
        // 后续:沿聚簇索引叶子记录不断向右
    }

    return error;
}

index_first() 最终仍然复用 index_read(),只是搜索键为空,并要求定位索引起点。

源码:mysql-server-5.7/storage/innobase/handler/ha_innodb.cc:9219

cpp 复制代码
int ha_innobase::index_first(uchar* buf) {
    int error = index_read(
        buf, NULL, 0, HA_READ_AFTER_KEY);
    // key=NULL:不按某个业务键定位
    // HA_READ_AFTER_KEY:从索引最左端之后的第一条用户记录开始

    if (error == HA_ERR_KEY_NOT_FOUND) {
        error = HA_ERR_END_OF_FILE;
        // 索引中没有用户记录,转换为扫描结束
    }

    return error;
}

因此,全扫描并不是另一套完全独立的数据读取代码,而是:

latex 复制代码
选择聚簇索引
→ index_first 定位最左侧第一条用户记录
→ general_fetch(ROW_SEL_NEXT) 反复读取下一条
→ 直到索引结束

三种方式放在一起比较:

访问方式 如何找到起点 何时停止
唯一键点查 从根页下降到目标叶子位置 目标记录读取完成
范围扫描 从根页下降到第一条满足下界的记录 遇到超过上界的记录
全扫描 从根页下降到最左叶子页 到达最右叶子页末尾

这三条路径会经过不同数量的叶子记录,也为后面理解"锁为什么会随着实际扫描范围扩大"建立了前提。

Record Lock 使用叶子页坐标定位具体记录

再把叶子页 42 单独放大:

这张纵向图只表示一个叶子页内部的记录顺序,不表示整棵 B+Tree 是链表。

这棵"内部页负责导航、叶子记录保存完整行"的主键 B+Tree 叫作聚簇索引。

Record Lock 最终落在叶子页中的索引记录上,不会锁根页或内部页中的导航项。

当我们说"锁住 sku_id=101 这一行"时,InnoDB 实际能够稳定定位的是:

latex 复制代码
哪一棵索引
+ 哪个表空间
+ 哪个索引页
+ 页内哪条记录

每条页内记录都有一个页面内部编号,叫作 heap_no

它不是主键,也不是"查询结果的第几行",而是 InnoDB 在这个页面内识别记录使用的编号。

假设同一索引页中有三条用户记录:

索引记录 page_no heap_no
PRIMARY(101) 42 2
PRIMARY(102) 42 3
PRIMARY(103) 42 5

heap_no 不要求连续。对锁系统来说,page_no=42, heap_no=5 已经能定位这张页中的 PRIMARY(103)

因此一条记录锁可以用下面的物理坐标表示:

latex 复制代码
index
+ space_id
+ page_no
+ heap_no

这四级坐标可以像地址一样逐层缩小范围:

位图用一个 bit 标记一条页内记录

一个 bit 只有两个状态:

latex 复制代码
0:这个位置没有被当前锁对象选中
1:这个位置被当前锁对象选中

把多个 bit 连续排在一起,就得到位图,也就是 bitmap。

位图中的第 N 个 bit 对应 heap_no=N

heap_no 0 1 2 3 4 5 6 7
bit 0 0 1 0 0 1 0 0

把位图和页面中的记录连起来看:

这张位图表达:

latex 复制代码
bit 2 = 1
  → 当前锁对象选中 heap_no=2
  → 锁住 PRIMARY(101)

bit 5 = 1
  → 当前锁对象选中 heap_no=5
  → 锁住 PRIMARY(103)

bit 3 = 0
  → 当前锁对象没有锁 PRIMARY(102)

注意,n_bits=8 表示位图拥有 8 个可用位置,不表示锁住了 8 条记录;上例真正置为 1 的只有两个 bit。

同页记录共享锁头,位图区分具体记录

如果同一个事务以相同方式锁住同一索引页中的多条记录,公共信息其实完全一样:

latex 复制代码
同一个事务
同一棵索引
同一个 space_id
同一个 page_no
同一种锁语义

变化的只有 heap_no

所以 InnoDB 可以保存一份公共锁头,再用位图指出这一页中的哪些记录属于它:

它的内存布局可以简化成:

latex 复制代码
┌──────────────────┬──────────────────────┬───────────────────┐
│ lock_t 公共信息  │ lock_rec_t 页面坐标 │ bitmap 位图字节   │
└──────────────────┴──────────────────────┴───────────────────┘

bitmap 紧跟在锁结构后面

这样既减少了重复的锁对象头,也能通过检查一个 bit 快速判断本页某条记录是否属于这个锁对象。

对应的核心结构很小。

源码:mysql-server-5.7/storage/innobase/include/lock0priv.h:85

cpp 复制代码
struct lock_rec_t {
    ib_uint32_t space;
    // 表空间 ID

    ib_uint32_t page_no;
    // 索引页页号

    ib_uint32_t n_bits;
    // 紧跟锁对象之后的位图拥有多少个 bit
};

struct lock_t {
    trx_t* trx;
    // 这个锁属于哪个事务

    dict_index_t* index;
    // 这个锁属于哪棵索引 B+Tree

    ib_uint32_t type_mode;
    // 锁的具体语义编码
};

现在我们可以把 Record Lock 说得更准确:

Record Lock 不是锁住一个抽象 Java 对象或一段 SQL 结果,而是由某个事务在某棵索引的某个页面上,通过 heap_no 对应的位图位置选中当前记录。

这也解释了为什么"索引"会深刻影响锁:

latex 复制代码
SQL 选择哪棵索引
→ 游标扫描哪些索引记录
→ Record Lock 就可能落在哪些位置

到这里,已经存在的记录有了稳定坐标。

但新的矛盾马上出现了:

如果要保护的值根本不存在,应该锁住什么?

目标记录不存在时,必须保护它本应出现的位置

现在表中有这些主键:

latex 复制代码
101 → 102 → 103 → 104 → 201

事务 T1 执行:

sql 复制代码
BEGIN;

SELECT *
FROM inventory
WHERE sku_id = 105
FOR UPDATE;

结果为空,因为 105 不存在。

但 T1 可能正准备根据"不存在"做后续决策,例如创建这条库存记录。

如果查询返回空以后什么都不保护,T2 可以立即插入 105:

在 Repeatable Read 隔离级别下,InnoDB 的锁定范围读需要防止这样的新记录穿过已保护范围。

这里先把 Repeatable Read 理解成一个要求:

事务已经锁定读取过的当前范围,在事务结束前不能因为其他事务插入而凭空多出满足条件的新记录。

问题是:105 不存在,没有 PRIMARY(105) 可以加 Record Lock。

但 B+Tree 是有序的。105 虽然不存在,它应该出现的位置却是确定的:

因此数据库可以不锁"105 这条不存在的记录",而是锁住:

latex 复制代码
(104,201)

这个相邻索引记录之间的开区间叫作 Gap ,保护它的锁叫作 Gap Lock

Gap Lock 不保护左右端已经存在的记录。它只表达:

暂时不允许新的索引记录插入这个有序区间。

所以:

latex 复制代码
T2 修改 PRIMARY(201)
  → 不会仅因为这个纯 Gap Lock 被阻塞

T2 插入 PRIMARY(105)
  → 插入位置位于 (104,201),必须等待

Record Lock 和 Gap Lock 至此各自解决了一个问题:

latex 复制代码
Record Lock
  → 保护已经存在的记录

Gap Lock
  → 保护新记录可能出现的位置

接下来把查询从一个不存在的点扩大成一个范围。


范围查询必须同时保护已有记录和空白位置

T1 执行:

sql 复制代码
SELECT *
FROM inventory
WHERE sku_id >= 102
  AND sku_id <= 104
FOR UPDATE;

为了让这个当前范围在事务期间保持稳定,需要同时保护:

  1. 已经存在的 102、103、104,防止它们被修改或删除。
  2. 这些记录之间可以插入新主键的位置,防止出现新的匹配记录。

如果分别描述,会是:

latex 复制代码
Record Lock:102
Gap Lock:  (102,103)
Record Lock:103
Gap Lock:  (103,104)
Record Lock:104

InnoDB 使用一个更统一的表达方式:

latex 复制代码
每条记录
+ 它前面的 Gap

假设 A、B 是相邻索引记录,那么:

latex 复制代码
(A,B]

同时包含:

  • B 这条已经存在的记录。
  • B 前面的 (A,B) Gap。

这种"前方 Gap + 右端记录"的组合叫作 Next-Key Lock

于是连续的范围可以被一段段 Next-Key 覆盖:

现在三种锁的关系已经完整:

latex 复制代码
Record Lock
  → 只保护右端记录 B

Gap Lock
  → 只保护 B 前面的 (A,B)

Next-Key Lock
  → 同时保护 (A,B]

注意:Gap 在页中不是一条真实记录,没有自己的 heap_no

InnoDB 让锁位图仍然指向右端记录 B,再用锁对象中的语义标志解释到底保护哪一部分:

latex 复制代码
位图指向 B

只保护 B
  → Record Lock

只保护 B 前面的区间
  → Gap Lock

两者都保护
  → Next-Key Lock

换成一张结构图,就是同一个位图锚点配上三种不同解释:

现在再看 lock_t::type_mode 就不再陌生了。

源码:mysql-server-5.7/storage/innobase/include/lock0lock.h:962

cpp 复制代码
#define LOCK_ORDINARY 0
// 没有精确修饰时,普通记录锁表示 Next-Key Lock

#define LOCK_GAP 512
// 位图仍指向右端记录,但只保护它前面的 Gap

#define LOCK_REC_NOT_GAP 1024
// 只保护索引记录,不保护它前面的 Gap

把源码标志翻译回刚才的图:

latex 复制代码
LOCK_ORDINARY
  → (A,B]

LOCK_GAP
  → (A,B)

LOCK_REC_NOT_GAP
  → B

至此,type_mode 不再是一个突然出现的源码字段,而是刚才三种范围语义的编码。


页尾虚拟记录为最后一个间隙提供右端坐标

如果查询是:

sql 复制代码
SELECT *
FROM inventory
WHERE sku_id >= 201
FOR UPDATE;

除了 201,还要保护:

latex 复制代码
(201,+∞)

否则其他事务可以插入 202、300,产生新的匹配记录。

但是 +∞ 不是一条真实记录,位图应该指向谁?

InnoDB 在每个索引页中放了两条系统伪记录:

latex 复制代码
Infimum
  → 小于页中所有用户记录

Supremum
  → 大于页中所有用户记录

最右侧叶子页可以画成:

保护页尾 Gap 时,位图落在 Supremum 上。

这不表示系统要修改一条叫 Supremum 的业务数据,它只是让没有真实右端点的区间也能复用"右端记录作为锚点"的表示方式。

现在从普通相邻记录到正负无穷边界,都统一进了同一套模型。


Gap Lock 只负责禁止插入,彼此不必互斥

假设 T1、T2 都对 (104,201) 持有 Gap Lock。

直觉上,两个事务在同一区间持有"锁",似乎应该冲突。

但先回到 Gap Lock 的真实语义:

latex 复制代码
我不是要修改 Gap。
我只是要求暂时不要向这个 Gap 插入新记录。

T1 和 T2 可以同时提出这个要求:

T1 与 T2 没有必要互相等待,因为它们的要求一致。真正被阻止的是插入者 T3。

因此纯 Gap Lock 之间通常兼容。对应源码暂时不看,因为源码的兼容分支还涉及"插入如何进入 Gap";把这个问题讲清以后,再回来看完整判断。


Insert Intention 让同一间隙中的不同插入保持并发

现在索引中只有:

latex 复制代码
5 ───────────────────── 10
          一个 Gap

T2 想插入 6,T3 想插入 8。

它们进入同一个原始 Gap,但目标位置不同:

latex 复制代码
5 < 6 < 8 < 10

假设每次 INSERT 都先申请一个排他的 Gap Lock:

那么同一个大 Gap 中所有不同位置的插入都会被事务级锁串行化。

数据库真正需要的不是:

latex 复制代码
我要独占整个 Gap。

而是:

latex 复制代码
我要在这个 Gap 的某个具体位置插入。
如果 Gap 被范围锁保护,我愿意排队;
如果没有被保护,我不应该妨碍其他位置的插入。

为表达这个请求,InnoDB 使用 Insert Intention Lock

这个名字容易让人误以为它是"提前占坑"。更准确的理解是:

它是插入操作在锁系统中的等待请求,用来表达"我准备进入这个 Gap"。

Gap 未被保护时,不同插入位置可以并发

两个不同位置的插入不需要因为属于同一个原始 Gap 而持续到事务提交地互相等待。

修改同一个内存页面时,线程仍需要很短时间的页面互斥,但那只是保护页面内存结构,不是这里讨论的事务范围锁。

Gap 被保护时,Insert Intention 进入等待

T1 已持有 (5,10] Next-Key Lock,T2 准备插入 6:

Insert Intention 在这里保存了四件事:

latex 复制代码
T2 正在等待哪个 Gap
T2 被哪个事务阻塞
阻塞锁释放后应该唤醒谁
这条等待关系是否参与了死锁

它使用的源码标志:

源码:mysql-server-5.7/storage/innobase/include/lock0lock.h:980

cpp 复制代码
#define LOCK_INSERT_INTENTION 2048
// 插入因为其他事务的 Gap 锁而等待时,
// 在 Gap 类型锁请求上附加这个标志

最关键的冲突关系是有方向的:

latex 复制代码
Insert Intention
  → 会等待其他事务的 Gap / Next-Key

普通 Gap Lock
  → 不会反过来等待 Insert Intention

Insert Intention 之间
  → 通常也不互相等待

源码:mysql-server-5.7/storage/innobase/lock/lock0lock.cc:863

cpp 复制代码
if (lock_rec_get_insert_intention(lock2)) {
    return(FALSE);
    // 已存在的锁是 Insert Intention:
    // 其他请求不需要等待它,插入意向之间也不互相干扰
}

现在再回头看刚才暂缓的 Gap 兼容判断。

源码:mysql-server-5.7/storage/innobase/lock/lock0lock.cc:837

cpp 复制代码
if ((lock_is_on_supremum || (type_mode & LOCK_GAP))
    && !(type_mode & LOCK_INSERT_INTENTION)) {

    return(FALSE);
    // 新请求只是普通 Gap Lock,不是准备插入:
    // 它不需要等待其他事务在同一 Gap 上的锁
}

到这里,四种锁已经不是四个孤立名词:

latex 复制代码
Record Lock
  → 保护现有记录

Gap Lock
  → 保护新记录可能出现的位置

Next-Key Lock
  → 同时保护前方 Gap 与右端记录

Insert Intention
  → INSERT 进入 Gap 前的冲突检查与等待请求

下面不再引入新的锁名词,而是把这四种结构放回一条真实 SQL 的索引扫描过程。

一条 SQL 的锁范围由索引游标实际扫描决定

前面的例子都使用主键。现在为表增加一棵按"仓库、库存"排列的辅助索引:

sql 复制代码
ALTER TABLE inventory
ADD KEY idx_warehouse_stock(warehouse_id, stock);

这种主键之外的辅助索引叫作二级索引。

聚簇索引 PRIMARY 的叶子记录保存完整行;二级索引的叶子记录保存二级索引列,并额外携带主键,用于找到完整行。

因此,虽然索引定义只写了:

latex 复制代码
(warehouse_id, stock)

对于下面的数据:

sku_id warehouse_id stock
101 1 5
102 1 10
103 1 10
104 1 20
201 2 8

二级索引叶子的实际排序可以理解为:

latex 复制代码
(warehouse_id, stock, sku_id)

(1,5,101)
→ (1,10,102)
→ (1,10,103)
→ (1,20,104)
→ (2,8,201)

两棵索引分别保存什么,需要分开看。

先看二级索引 idx_warehouse_stock 的叶子记录顺序:

再看聚簇索引 PRIMARY 的叶子记录顺序:

二级索引记录与聚簇记录之间通常不是保存一根直接内存指针或磁盘地址。二级索引记录保存主键值,InnoDB 取出主键后,再到 PRIMARY B+Tree 中进行一次树形查找:

因此所谓"二级索引找到聚簇记录",不是图上四条跨索引直连线,而是对每条命中的二级索引记录,取出其中的主键,再执行一次主键 B+Tree 查找。

为什么主键也参与顺序?

因为 warehouse_id=1, stock=10 可以对应多条业务记录,主键让每条二级索引记录仍然拥有确定顺序,并提供回到聚簇索引的地址线索。

现在在 RR 下执行:

sql 复制代码
SELECT *
FROM inventory FORCE INDEX(idx_warehouse_stock)
WHERE warehouse_id = 1
  AND stock = 10
FOR UPDATE;

FORCE INDEX 只用于固定这次讲解路径。

在讨论第一条记录为什么加 Next-Key 前,必须先讲清楚:MySQL 是怎样找到这条记录的,以及"游标"到底是什么。

Server 选择访问路径,InnoDB 构造搜索键

客户端提交 SQL 后,Server 层先完成:

latex 复制代码
解析 WHERE 条件
→ 判断可用索引
→ 选择 idx_warehouse_stock
→ 确定可以用于定位的索引前缀

这里能参与二级索引定位的搜索前缀是:

latex 复制代码
(warehouse_id=1, stock=10)

sku_id 没有出现在 WHERE 中,所以搜索键只有两个字段;但叶子记录的完整排序仍然是三个字段:

latex 复制代码
搜索键:
  (1,10)

叶子记录:
  (1,10,sku_id)

Server 执行器通过 Handler 接口发起索引读取,InnoDB 把这个搜索前缀组织成内部搜索元组,然后进入 B+Tree。

持久游标记录 B+Tree 中的当前位置

这里的"游标"不是客户端声明的 SQL Cursor,也不是一次性装满所有结果的列表。

它是 InnoDB 内部的一个位置对象,可以理解为一根指向 B+Tree 当前位置的手指:

latex 复制代码
我正在搜索哪棵索引
我使用什么比较方式
我当前位于哪个页面
我正指向页面中的哪条记录
下一步向左还是向右移动

"持久游标"中的"持久"不是写入磁盘永久保存,而是说:即使一次小的页面访问结束,它也可以保存刚才的位置,之后恢复并继续扫描。

对应结构只看现在能理解的字段:

源码:mysql-server-5.7/storage/innobase/include/btr0pcur.h:514

cpp 复制代码
struct btr_pcur_t {
    btr_cur_t btr_cur;
    // 底层 B+Tree 游标,包含当前索引和页面内位置

    rec_t* old_rec;
    // 暂时离开页面时,保存最近所在记录的关键内容

    enum btr_pcur_pos_t rel_pos;
    // 原位置是在 old_rec 上、前面还是后面

    enum pcur_pos_t pos_state;
    // 游标当前是否已经定位,或位置是否已保存

    page_cur_mode_t search_mode;
    // 本次查找使用 >=、>、<= 或 < 中的哪一种
};

B+Tree 从根页逐层下降,而不是从头扫描

一棵索引通常有多层:

内部页不保存完整业务行,它保存用于分流的键和下一层子页号。

查找 (1,10) 时,InnoDB 重复做两件事:

latex 复制代码
在当前内部页比较分隔键
→ 选择可能包含 (1,10) 的子页

直到下降到叶子页。

索引页实际在 Buffer Pool 中访问。如果目标页不在内存,会先根据页号从表空间文件读入一个 Buffer Pool Frame,随后再在内存页中比较。

叶子页使用"大于等于"模式定位左边界

为什么不是寻找"一条等于 (1,10) 的记录"?

因为完整叶子记录是:

latex 复制代码
(1,10,102)
(1,10,103)

搜索键只给出前缀 (1,10),可能匹配多条记录。

InnoDB 使用"第一条大于等于搜索键"的模式,找到这个前缀在叶子记录链中的左边界:

源码中的 PAGE_CUR_GE 就表示寻找第一条大于等于搜索元组的记录。

打开游标并取得当前位置:

源码:mysql-server-5.7/storage/innobase/row/row0sel.cc:5201

cpp 复制代码
btr_pcur_open_with_no_init(
    index,
    search_tuple,
    mode,
    BTR_SEARCH_LEAF,
    pcur,
    0,
    &mtr);
// 从索引根向下搜索,把持久游标定位到目标叶子位置

rec = btr_pcur_get_rec(pcur);
// 取得游标当前指向的叶子记录

游标从第一条候选记录开始逐条向右移动

从第一条 (1,10,102) 开始,InnoDB 进入记录循环:

叶子页之间按索引顺序连接,所以重复值即使跨页,游标仍然可以继续向右。

移动到下一条记录的关键调用:

源码:mysql-server-5.7/storage/innobase/row/row0sel.cc:6240

cpp 复制代码
move = btr_pcur_move_to_next(pcur, &mtr);
// 当前记录处理完后,沿索引顺序移动到下一条;
// 必要时跨到下一张叶子页

把整个记录查找流程合起来:

现在"游标先找到第一条匹配记录"可以精确展开为:

latex 复制代码
优化器选择 idx_warehouse_stock
→ InnoDB 收到搜索元组 (1,10)
→ 从根页按分隔键逐层下降
→ 缺页时先从表空间读入 Buffer Pool
→ 到达叶子页
→ 以 >= 模式定位前缀左边界
→ pcur 指向 (1,10,102)

下面再讨论:游标落到第一条匹配记录后,为什么它前面的 Gap 也要保护。

左边界前的 Gap 也属于查询范围

此时游标指向:

第一条匹配记录 (1,10,102) 前面为什么也需要 Gap?

因为当前数据中完全可以插入一个排序更靠前、但仍满足查询条件的新记录,例如:

latex 复制代码
(1,10,100)

所以第一段使用 Next-Key:

这里出现一个很重要的现象:

latex 复制代码
查询逻辑上只关心 stock=10,
但 (1,5,101) 与 (1,10,102) 之间当前只有一个完整物理 Gap。

所以这段锁也会阻止向其中插入 stock=6、7、8、9 的记录。

InnoDB 没有把一个物理 Gap 再切成许多不存在的小区间。锁范围由当前相邻索引记录决定,因此会受到数据分布影响。

重复值通过主键后缀保持确定顺序

idx_warehouse_stock 不是唯一索引。找到第一条 stock=10 后,不能断言已经结束。

下一条 (1,10,103) 仍然匹配:

到这里已经保护:

latex 复制代码
((1,5,101),(1,10,103)]

但游标仍然不能停。它还不知道后面是否有第三条 stock=10

第一条不匹配记录确定扫描右边界

继续向右,遇到:

latex 复制代码
(1,20,104)

它不满足查询条件,不会返回给用户,却证明重复值扫描已经结束。

最后一条匹配记录与它之间的 Gap 必须被保护,否则可以插入:

latex 复制代码
(1,10,105)
(1,10,200)

因此二级索引上的整体范围为:

latex 复制代码
((1,5,101),(1,20,104))

两端都不包含。

现在可以理解一句经常让人困惑的话:

SQL 最终只返回两行,不代表只扫描两条索引记录,也不代表只锁两行。

返回结果只包含 102、103;为了定位起点、扫描重复值并确认终点,锁还覆盖了前后 Gap。

二级索引命中后仍需回表锁定聚簇记录

二级索引记录中没有完整的 status 等列。InnoDB 从二级索引拿到主键 102、103,再去聚簇索引读取完整行。

这个过程通常叫"回表"。这里不把它当成一个需要背诵的新名词,只需看路径:

最终锁集合不是一处,而是两棵索引上的组合:

latex 复制代码
idx_warehouse_stock:
  ((1,5,101),(1,20,104))

PRIMARY:
  PRIMARY(102) Record Lock
  PRIMARY(103) Record Lock

这条 SQL 给出了全文最重要的推导方法:

latex 复制代码
先确定实际使用哪棵索引
→ 写出完整索引排序键
→ 找到游标起点
→ 沿叶子记录逐条前进
→ 找到真正停止位置
→ 把扫描记录和边界转成 Record / Gap / Next-Key
→ 如果从二级索引读取完整行,再补聚簇索引记录锁

锁范围属于执行路径,不属于 SQL 文本本身。


改变一个条件,最终锁集合就会不同

上一节推导的是一个固定场景:

latex 复制代码
Repeatable Read
+ 二级索引等值范围
+ SELECT * 回表
+ FOR UPDATE

它得到的是这个具体执行路径的锁集合,而不是所有 SQL 都会得到的固定答案。

接下来不再跳到互不相关的新主题,而是每次只改变一个条件,观察最终锁集合怎样变化:

这四个实验分别回答:

latex 复制代码
查询条件有多精确?
隔离级别是否要求阻止幻读?
实际访问路径会扫描多远?
当前记录要共享保护还是排他保护?

查询条件:完整唯一键命中只需保护现有记录

先保持 RR 和 FOR UPDATE 不变,只把"二级索引范围"换成"完整主键等值查询"。

执行:

sql 复制代码
SELECT *
FROM inventory
WHERE sku_id = 102
FOR UPDATE;

主键唯一,完整等值条件最多命中一条记录。

其他事务不可能在旁边再插入另一条 PRIMARY(102)

因此,要保护"102 这条记录的当前状态",只锁现有记录就够了,不必锁它前面的 Gap。

源码对"完整唯一搜索"的判断包含三个关键条件:

源码:mysql-server-5.7/storage/innobase/row/row0sel.cc:4868

cpp 复制代码
if (match_mode == ROW_SEL_EXACT
    && dict_index_is_unique(index)
    && dtuple_get_n_fields(search_tuple)
       == dict_index_get_n_unique(index)
    && (dict_index_is_clust(index)
        || !dtuple_contains_null(search_tuple))) {

    unique_search = TRUE;
    // 等值搜索 + 唯一索引 + 提供全部唯一字段,
    // 才能确定最多匹配一条记录
}

所以即使存在唯一联合索引 UNIQUE(a,b)

latex 复制代码
WHERE a=1 AND b=2
  → 完整唯一搜索

WHERE a=1
  → 只给了索引前缀,仍然可能扫描多条记录

但是,如果完整唯一键不存在,情况又回到了前面讨论过的"目标记录不存在":

sql 复制代码
SELECT *
FROM inventory
WHERE sku_id = 105
FOR UPDATE;

没有 Record 可锁,只能保护 105 应该出现的 (104,201) Gap。

所以不是"唯一索引永远没有 Gap Lock",而是:

latex 复制代码
完整唯一键命中
  → Record Lock 有落点,而且同键不可能再插入

完整唯一键不存在
  → Record Lock 没有落点,只能保护目标 Gap

隔离级别:Read Committed 减少间隙保护

第一个实验改变了查询条件。现在恢复前面的二级索引范围查询,保持 SQL 和索引不变,只把隔离级别从 Repeatable Read 改成 Read Committed。

Repeatable Read 要求锁定范围在事务期间不被其他事务插入新的匹配记录,所以普通范围搜索需要 Gap 与 Next-Key。

Read Committed 的目标不同:

每条语句读取开始时已经提交的数据;下一条语句允许看到期间新提交的记录。

既然下一条语句允许看到新提交的"幻影",普通搜索就没有必要长期保护周围 Gap。

对同一条查询:

sql 复制代码
SELECT *
FROM inventory
WHERE warehouse_id = 1
  AND stock = 10
FOR UPDATE;

可以简化比较为:

RC 并不表示 UPDATE 或 FOR UPDATE 不加锁。匹配到的当前记录仍然要保护;减少的主要是普通搜索中的 Gap 部分。

源码在选择 Record 还是 Next-Key 时直接检查隔离级别。

源码:mysql-server-5.7/storage/innobase/row/row0sel.cc:5574

cpp 复制代码
if (!set_also_gap_locks
    || srv_locks_unsafe_for_binlog
    || trx->isolation_level <= TRX_ISO_READ_COMMITTED
    || (unique_search && !rec_get_deleted_flag(rec, comp))) {

    lock_type = LOCK_REC_NOT_GAP;
    // RC 普通搜索或完整唯一命中:通常只锁记录
} else {
    lock_type = LOCK_ORDINARY;
    // RR 普通范围扫描:使用 Next-Key
}

唯一性检查、外键检查等特殊路径仍可能需要 Gap;这里讨论的是普通搜索主线。

访问路径:缺少合适索引会扩大扫描与锁范围

第二个实验说明隔离级别决定是否长期保护 Gap。现在切回 RR 并继续使用 FOR UPDATE,只改变访问路径:查询一个没有索引的列。

执行:

sql 复制代码
SELECT *
FROM inventory
WHERE status = 1
FOR UPDATE;

status 没有索引。

InnoDB 无法直接跳到所有 status=1 的记录,只能沿聚簇索引逐条读取,再判断每行的 status:

"没有索引"真正改变的是游标访问路径:

在 RR 的锁定读中,锁会沿真实扫描轨迹铺开。

结果可能接近:

latex 复制代码
整棵 PRIMARY 的记录和间隙都被保护

这不是 InnoDB 先创建了一个神秘的"全表行锁",而是:

latex 复制代码
没有可用于定位的索引
→ 只能扫描整棵聚簇索引
→ 当前锁定读沿扫描路径加锁
→ 大量连续 Record 与 Gap 最终覆盖近乎全表

于是索引和锁之间的关系终于可以完整表达:

索引不仅决定查询要读多少数据,也决定锁系统用哪些有序记录作为坐标、游标经过多长区间。

前三个实验已经确定"锁落在哪些索引位置,以及是否包含 Gap"。还剩最后一个维度:事务以什么方式保护这些位置。

为避免范围扫描干扰,这个实验重新回到最简单的 PRIMARY(101) 点查,只改变锁模式。

锁模式:当前读既可以共享保护,也可以排他保护

到目前为止,我们主要使用 FOR UPDATE,它申请排他保护。

但有些事务只想读取并保护当前值,不准备修改它,同时允许其他事务也进行同样的保护读取:

sql 复制代码
SELECT stock
FROM inventory
WHERE sku_id = 101
LOCK IN SHARE MODE;

这种保护使用共享模式,简称 S;FOR UPDATEUPDATEDELETE 使用排他模式,简称 X。

同一条 Record 上的兼容关系只有四种:

已持有 新请求 S 新请求 X
S 可以共存 必须等待
X 必须等待 必须等待

这里的 S/X 回答:

latex 复制代码
事务以共享还是排他的方式保护记录?

Record/Gap/Next-Key 回答:

latex 复制代码
保护的是记录、间隙,还是二者?

它们是两个不同维度。例如:

latex 复制代码
X Record Lock
X Next-Key Lock
S Record Lock
S Next-Key Lock

表级意向 IS/IX 让表锁无需遍历全部行锁

事务准备对一条记录加 S 或 X 前,会先在表级留下一个意向:

latex 复制代码
IS:表内存在或准备存在 S 记录锁
IX:表内存在或准备存在 X 记录锁

为什么需要这层声明?

假设一个事务已经在表中锁住十万条记录,另一个操作申请表级排他锁。

没有表级意向时,数据库可能要遍历大量记录锁才能确定表内是否还有行级使用者。

有了 IS/IX,只需先看表级兼容关系:

IS/IX 不会把表内所有记录锁成一个整体。它只是向上层声明下层锁的存在。

对应的基础模式定义:

源码:mysql-server-5.7/storage/innobase/include/lock0types.h:46

cpp 复制代码
enum lock_mode {
    LOCK_IS,
    // 表内存在或准备存在共享记录保护

    LOCK_IX,
    // 表内存在或准备存在排他记录保护

    LOCK_S,
    // 共享保护

    LOCK_X
    // 排他保护
};

现在已经能够判断一条新请求与已有锁是否兼容。

如果不兼容,数据库不能让请求凭空消失,也不能让线程永远空转。它需要保存请求、挂起线程,并在将来准确唤醒。

锁请求冲突后,会进入等待、唤醒或死锁处理

前面四个实验最终确定了一把锁的三个关键属性:

latex 复制代码
锁属于哪棵索引、哪个页面和哪条记录
+ 保护 Record、Gap 还是 Next-Key
+ 使用 S 还是 X

从这里开始不再改变 SQL 的访问方式,而是追踪一条已经确定的锁请求,在发生冲突后会经历什么状态:

先从最普通、最终可以被唤醒的一条等待开始。

一个冲突请求先进入等待队列

T1 执行:

sql 复制代码
BEGIN;

UPDATE inventory
SET stock = 9
WHERE sku_id = 101;

T1 已持有 PRIMARY(101) 的 X Record Lock。

T2 随后执行:

sql 复制代码
UPDATE inventory
SET stock = 8
WHERE sku_id = 101;

T2 也需要 X Record Lock。根据刚才的兼容关系,X 与 X 不能共存。

数据库接下来不能只返回一句"冲突了"。T2 可能在 T1 提交后继续成功,所以系统需要保存这个尚未授予的请求。

T2 的请求仍然是一个 lock_t,但会增加 LOCK_WAIT 标志,表示:

latex 复制代码
请求对象已经存在,
但锁的权利还没有授予。

事务 T2 还会保存一个反向指针,指向自己正在等待的锁请求:

源码:mysql-server-5.7/storage/innobase/include/trx0trx.h:725

cpp 复制代码
struct trx_lock_t {
    trx_que_t que_state;
    // 当前查询线程是运行还是等待锁

    lock_t* wait_lock;
    // 等待时指向尚未授予的锁请求

    time_t wait_started;
    // 从什么时间开始等待

    que_thr_t* wait_thr;
    // 因此被挂起的查询线程
};

设置这条双向关系的源码:

源码:mysql-server-5.7/storage/innobase/lock/lock0lock.cc:724

cpp 复制代码
trx->lock.wait_lock = lock;
// 事务指向自己当前等待的锁请求

lock->type_mode |= LOCK_WAIT;
// 锁请求标记为"等待授予"

这两个方向各有用途:

latex 复制代码
从记录的锁队列出发
  → 能找到谁已经持有、谁正在排队

从事务出发
  → 能找到它当前正在等待哪个请求

等待事务休眠,而不是持续轮询

如果 T2 不断执行:

latex 复制代码
锁释放了吗?
锁释放了吗?
锁释放了吗?

就会持续浪费 CPU。

InnoDB 会把查询线程挂起:

等待过程的入口是 lock_wait_suspend_thread()

源码:mysql-server-5.7/storage/innobase/lock/lock0wait.cc:195

这个函数的主线可以概括为:

latex 复制代码
登记等待线程
→ 记录等待开始时间
→ 让线程休眠
→ 等待锁释放、超时、中断或死锁结果

阻塞事务提交后,等待请求被授予并唤醒

T1 执行 COMMIT,事务锁被释放。

锁系统重新检查 PRIMARY(101) 上的等待请求:

为什么 T1 的锁要一直持有到提交,而不是 UPDATE 语句结束就释放?

回到故事开头:

latex 复制代码
T1 读到当前库存
→ 完成若干依赖该库存的数据库操作
→ COMMIT

如果第一条语句结束就释放,T1 的整个业务临界区还没完成,T2 就可以改变 T1 所依赖的状态。

所以事务锁通常覆盖完整事务,而不是单条 SQL 的函数调用时间。

到这里,普通等待已经可以正常结束:

latex 复制代码
T2 等 T1
→ T1 仍然能继续运行
→ T1 提交
→ T2 被唤醒

但如果 T1 也在等待 T2,事情就不同了。

阻塞者也在等待时,普通等待可能变成死锁

表中存在两条库存记录:

latex 复制代码
PRIMARY(101)
PRIMARY(102)

T1 先修改 101:

sql 复制代码
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 101;

T2 同时先修改 102:

sql 复制代码
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 102;

此刻:

latex 复制代码
T1 持有 X(101)
T2 持有 X(102)

随后 T1 修改 102,T2 修改 101:

普通等待时,阻塞者最终可以提交。

这里却变成:

latex 复制代码
T1 想提交前,需要先获得 102
102 要等 T2 释放

T2 想提交前,需要先获得 101
101 要等 T1 释放

两边都无法自行继续。

把事务画成节点,把"等待谁"画成有向边:

这张图叫作 Wait-For Graph

latex 复制代码
节点
  → 事务

边
  → 等待者指向阻塞者

图中没有环,只是普通等待;图中出现环,参与环的事务就无法自行推进,这才是死锁。

等待图由现有锁队列即时推导

前面已经有两类信息:

  1. 每个事务的 wait_lock 指出它正在等待什么。
  2. 每个资源的锁队列指出谁持有与该请求冲突的锁。

从 T1 出发:

latex 复制代码
T1.wait_lock
→ 找到阻塞它的事务 T2
→ 查看 T2.wait_lock
→ 找到阻塞 T2 的事务 T1
→ 回到起点

所以等待图可以在需要时沿现有对象动态遍历出来。

事务锁状态中还有两个只为这个遍历服务的字段。

源码:mysql-server-5.7/storage/innobase/include/trx0trx.h:738

cpp 复制代码
ib_uint64_t deadlock_mark;
// 遍历等待关系时,标记事务是否已经访问

bool was_chosen_as_deadlock_victim;
// 检测到环后,这个事务是否被选为牺牲者

检测过程从新加入等待的事务开始:

核心源码:

源码:mysql-server-5.7/storage/innobase/lock/lock0lock.cc:7455

cpp 复制代码
if (!lock_has_to_wait(m_wait_lock, lock)) {
    lock = get_next_lock(lock, heap_no);
    // 当前锁不冲突,继续检查同一资源上的下一个锁

} else if (lock->trx == m_start) {
    notify(lock);
    return(select_victim());
    // 沿等待关系回到起始事务:发现环

} else if (lock->trx->lock.que_state == TRX_QUE_LOCK_WAIT) {
    m_wait_lock = lock->trx->lock.wait_lock;
    // 阻塞者也在等待,继续沿它的等待请求向下寻找
}

检测到环后必须回滚一个事务

只发现死锁还不够。要让系统继续,至少有一个事务必须放弃已经完成的工作并释放锁。

InnoDB 会估算事务的回滚代价。

MySQL 5.7 主要考虑:

latex 复制代码
已经产生多少修改
+ 持有多少锁结构

源码:mysql-server-5.7/storage/innobase/include/trx0trx.h:480

cpp 复制代码
#define TRX_WEIGHT(trx) \
    ((trx)->undo_no \
     + UT_LIST_GET_LEN((trx)->lock.trx_locks))

// undo_no:事务已经产生的修改量
// trx_locks:事务当前持有的锁结构数量

选择逻辑:

源码:mysql-server-5.7/storage/innobase/lock/lock0lock.cc:7421

cpp 复制代码
if (trx_weight_ge(m_wait_lock->trx, m_start)) {
    return(m_start);
    // 另一个事务更重,回滚相对较轻的起始事务
}

return(m_wait_lock->trx);
// 否则回滚相对较轻的阻塞事务

所以死锁牺牲者不一定是:

latex 复制代码
后启动的事务
最后执行 SQL 的事务
等待时间最短的事务

目标是尽量减少回滚代价。

牺牲者回滚后,它持有的锁被释放,环被打破,其他事务就可以继续。

Gap 与 Insert Intention 的冲突同样进入等待图

前面已经知道,两个事务可以同时持有同一 Gap 的 Gap Lock。

假设:

latex 复制代码
T1 持有 Gap(5,10)
T2 持有 Gap(5,10)

随后 T1 插入 6,T2 插入 8。

T1 的 Insert Intention 要等待 T2 的 Gap Lock;T2 的 Insert Intention 又要等待 T1 的 Gap Lock:

锁类型看起来比两个 Record Lock 复杂,但死锁本质没有变化:

latex 复制代码
事务节点之间形成了等待环。

共享锁升级也可能形成等待环

T1、T2 都对 PRIMARY(101) 持有 S Record Lock,然后都想升级为 X:

无论冲突来自 Record、Gap 还是共享到排他的升级,死锁检测器看到的最终都是:

latex 复制代码
事务
→ 等待请求
→ 冲突持有者
→ 另一个事务

没有环的等待由超时结束,有环由死锁检测打破

普通等待:

latex 复制代码
T2 → T1

没有环。T1 只是持锁时间过长,T2 可能最终等到锁,也可能超过等待上限。

超过 innodb_lock_wait_timeout 时,MySQL 返回:

latex 复制代码
1205 Lock wait timeout exceeded

死锁:

latex 复制代码
T1 → T2
T2 → T1

存在环。继续等待没有意义,InnoDB 可以在检测到环后立即选择牺牲者,返回:

latex 复制代码
1213 Deadlock found

死锁牺牲者需要回滚整个事务,因为只有释放它已经持有的全部锁,才能可靠地打破等待环。

而 5.7 默认配置下,普通锁等待超时通常只回滚当前语句;事务此前获得的其他锁可能仍然存在,直到事务最终提交或回滚。

这是两种错误在事务状态上的重要区别。


总结:回到 MySQL 总体架构,所有路径终于闭合

现在回到开篇的全景图。

开篇时,图中的 Undo、B+Tree、游标、记录位图和等待关系还只是一些名字。经过前面的故事,现在可以沿一条 SQL 的真实路径,把它们重新连接起来。

这张图可以从左到右分成五步。

第一步,Server 层负责理解和组织这条 SQL:解析条件、选择访问路径,再通过 Handler 接口反复向 InnoDB 请求记录。锁并不是优化器凭空算出来的;真正的记录和间隙,要到 InnoDB 沿实际索引搜索时才能确定。

第二步,无论最终读取历史版本还是保护当前状态,InnoDB 都先根据搜索键进入实际 B+Tree,从根页下降到叶子页,再由游标定位并逐条移动。目标页不在 Buffer Pool 时,先从表空间文件读入内存。

第三步,如果是普通快照读,InnoDB 不需要用事务锁阻止别人修改。它检查游标当前记录是否对本事务可见;不可见时,使用记录中的 DB_ROLL_PTR 找到 Undo,在查询使用的内存中恢复出上一个版本,直到找到可见版本。

第四步,如果是锁定读或写操作,锁系统把游标经过的位置变成锁。已经存在的索引记录可以直接成为保护坐标;记录之间没有实体记录,因此由相邻索引记录表达间隙。对同一页上的多条记录,锁对象可以共用一段头部,再用位图标记具体是哪些 heap_no

第五步,新请求与已有锁兼容,就立即获得保护;不兼容,就保存为等待中的锁请求并挂起事务。等待关系没有环,只是普通等待;等待关系出现环,才是死锁,InnoDB 必须回滚其中一个事务来断开这个环。

到这里,全文的主线可以压缩成一句话:

Server 决定怎样访问,InnoDB 游标在 B+Tree 中确定真实位置;快照读依靠 Undo 选择版本,锁定读和写操作依靠索引位置建立锁,发生冲突后再由等待与死锁机制决定谁能够继续。

下面再沿最小的一条索引链,把锁的四种表现形式重走一遍。

回到这条有序索引:

如果请求不兼容:

latex 复制代码
创建等待中的 lock_t
→ 事务 wait_lock 指向它
→ 查询线程休眠
→ 阻塞锁释放后授予并唤醒

如果阻塞者自己也在等待:

latex 复制代码
沿 wait_lock 和锁队列继续寻找
→ 构造 Wait-For Graph
→ 出现环就是死锁
→ 回滚相对较轻的事务
→ 释放锁并打破环

把它放回一条 SQL:

因此,判断一条 SQL 会锁什么,不应该从锁名词开始,而应该按这个顺序:

latex 复制代码
它是快照读,还是保护当前状态?
        ↓
实际使用哪棵索引?
        ↓
完整索引记录按什么顺序排列?
        ↓
游标从哪里开始,到哪里真正停止?
        ↓
扫描位置对应 Record、Gap 还是 Next-Key?
        ↓
是否还要从二级索引读取聚簇记录?
        ↓
与已有锁不兼容时,等待关系是否形成环?

MySQL 锁的全部主线,就建立在三样东西上:

latex 复制代码
有序的 B+Tree 坐标
+ 持有到事务结束的锁对象
+ 事务之间的等待关系

理解这三样东西以后,Record、Gap、Next-Key、Insert Intention、锁等待和死锁不再是彼此割裂的术语,而是同一套并发控制机制在不同问题上的自然延伸。

相关推荐
晚安code2 小时前
Java四大函数式接口一篇讲透:配上Stream流式计算处理集合
后端
晴殇i2 小时前
最近在 Github 名字叫“马尾辫”,这个真的很有趣看到头像
前端·后端·开源
IT_陈寒3 小时前
Vite打包时静态资源404?加个斜杠就能解决
前端·人工智能·后端
程序员爱钓鱼3 小时前
Go 输入输出详解
后端·面试·go
程序员爱钓鱼3 小时前
Rust 生命周期详解:生命周期标注、约束与真实项目应用
后端·面试·rust
MoonBit月兔3 小时前
MoonBit 受邀亮相 SPLASH/ISSTA 2026,与 Java、Rust、Eiffel、D 语言核心贡献者同场交流
开发语言·后端·rust
swordbob4 小时前
mysql数据库,明明有索引为啥不用
数据库·mysql
维克兜率天13 小时前
【维克】ARMA模型:AR与MA的完美结合
后端·restful
是未才14 小时前
从输入 URL 到页面返回:DNS、路由、TLS 与 HTTP 完整链路
java·后端·计算机网络