InnoDB引擎的锁逻辑分析(一)

​这段时间在看Mysql 8.0.34索引的加锁和解锁逻辑,顺便写成文章整理一下,本文在一定程度上参考了《B+树并发控制机制的前世今生》http://mysql.taobao.org/monthly/2018/09/01/ ,有兴趣的小伙伴可以先看一看,下面是正文。

​写操作分为插入、更新、删除,在底层这些操作都会采用先乐观后悲观的模式。我们先分析底层的锁逻辑,然后再对比一下读+读、读+写、写+写时加锁和解锁的顺序,最后把锁冲突矩阵梳理出来,本文中的伪代码约定以下标记:

SL (Shared Lock): 共享锁 — 加锁
SU (Shared Unlock): 共享锁 — 解锁
XL (Exclusive Lock): 互斥锁 — 加锁
XU (Exclusive Unlock): 互斥锁 — 解锁
SXL (Shared Exclusive Lock): 共享互斥锁 — 加锁
SXU (Shared Exclusive Unlock): 共享互斥锁 — 解锁

1. InnoDB 锁与 MTR 核心机制

1.1 锁的类型定义

索引锁 (Index Lock): dict_index_get_lock(index)- 保护整个索引结构
页锁 (Page Lock): block->lock - 保护单个页面

enum rw_lock_type_t {
  RW_S_LATCH = 1,    // 共享锁 (读锁)
  RW_X_LATCH = 2,    // 排他锁 (写锁)  
  RW_SX_LATCH = 4,   // 共享排他锁 (意向写锁)
  RW_NO_LATCH = 8    // 无锁
};

1.2 B+树操作模式 (Latch Mode)

enum btr_latch_mode : size_t {
  BTR_SEARCH_LEAF = RW_S_LATCH,   // 搜索叶子页,S锁
  BTR_MODIFY_LEAF = RW_X_LATCH,   // 修改叶子页,X锁 (乐观写)
  BTR_NO_LATCHES = RW_NO_LATCH,   // 无锁
  BTR_MODIFY_TREE = 33,           // 修改整棵树 (悲观写)
  BTR_CONT_MODIFY_TREE = 34,      // 继续修改树
  BTR_SEARCH_PREV = 35,           // 搜索前驱记录
  BTR_MODIFY_PREV = 36,           // 修改前驱记录
  BTR_SEARCH_TREE = 37,           // 搜索整棵树
  BTR_CONT_SEARCH_TREE = 38       // 继续搜索树
};

1.3 MTR的作用

​MTR (Mini-Transaction) 是一个原子操作单元,用于保证对数据库物理结构(主要是B+树)修改的原子性和持久性。
1)锁管理器 (Lock Manager):MTR 维护一个 memo 栈,记录所有获取的锁(包括 latch 和 page fix),调试的时候可以从栈中查看持有的锁。

// mtr0mtr.h
struct mtr_t::Impl {
    mtr_buf_t m_memo;  // memo 栈,记录所有持有的锁
    mtr_buf_t m_log;   // redo log 缓冲区
    ...
};

当 MTR commit 时,自动释放所有持有的锁

void mtr_t::commit() {
    // 1. 写 redo log (如果有)
    // 2. 释放所有 memo 中的锁
    cmd.release_all();
    cmd.release_resources();
}

2)MTR 的生命周期

​mtr_t mtr;
mtr_start(&mtr);                           // 1. 开始 MTR

mtr_s_lock(lock, &mtr);                    // 2. 获取锁 (记录到 memo)
mtr_x_lock(lock, &mtr);
buf_page_get_gen(..., &mtr);               // 获取页面 (记录 page fix)

// ... 对页面进行修改 ...

mtr_commit(&mtr);                          // 3. 提交 MTR
                                           //    - 写 redo log
                                           //    - 释放所有锁

3)MTR的关键API

mtr_start(&mtr);                // 开始
mtr_commit(&mtr);               // 提交(释放所有锁)

mtr_s_lock(lock, &mtr);         // S锁 (记录到memo)
mtr_x_lock(lock, &mtr);         // X锁
mtr_sx_lock(lock, &mtr);        // SX锁

mtr_set_savepoint(&mtr);        // 设置保存点
mtr_release_s_latch_at_savepoint(); // 在保存点释放锁(提前释放)

mtr_memo_release();             // 手动释放特定锁

4)零碎知识
MTR内的所有操作要么全部持久化,要么全部回滚,即具有原子性
一个MTR对应B+树的一个原子操作,如插入/删除一条记录;页分裂/合并

2. 底层锁逻辑与执行路径解析

首先我们需要明白,上层函数row0ins.cc / row0upd.cc / row0sel.cc根据操作类型和当前状态,确定 btr_latch_mode参数,并将其传入btr_cur_search_to_nth_level(),由该函数根据 btr_latch_mode执行相应的加锁策略。

操作类型 上层函数 选择的 latch_mode 原因
乐观插入 row_ins_clust_index_entry_low() BTR_MODIFY_LEAF 尝试不分裂插入
悲观插入 row_ins_clust_index_entry_low() BTR_MODIFY_TREE 需要页面分裂
乐观更新 row_upd_clust_rec() BTR_MODIFY_LEAF 原地更新
悲观更新 row_upd_clust_rec() BTR_MODIFY_TREE 记录变大需分裂
标记删除 row_upd_del_mark_clust_rec() BTR_MODIFY_LEAF 只改 1 bit,不改页面结构
Purge删除 row_purge_remove_clust_if_poss() BTR_MODIFY_LEAF 或 BTR_MODIFY_TREE 视情况而定

因此,我们在看代码的时候只需要关注latch_mode的类型,就可以明白大概的加锁逻辑,除此之外,我还总结了一下乐观操作转悲观操作的情况,这应该有助于你理解latch_mode的选择。

操作 乐观失败条件 悲观需要做的事
插入 页面空间不足 页面分裂
删除 页面记录太少 页面合并
更新 新记录放不下 删除旧 + 分裂插入新

2.1 插入

函数调用链如下,btr_cur_search_to_nth_level()是加锁的地方,锁的释放在mtr_commit之后。

row_ins_clust_index_entry()
│
├── 第一阶段: 乐观插入 (BTR_MODIFY_LEAF)
│   │
│   │ row_ins_clust_index_entry_low(BTR_MODIFY_LEAF)
│   │   │
│   │   ├── mtr_start(&mtr)
│   │   ├── btr_cur_search_to_nth_level(BTR_MODIFY_LEAF)
│   │   │       └── 加锁: 叶子页X锁 (索引级无锁)
│   │   │
│   │   └── btr_cur_optimistic_insert()
│   │           ├── 成功? → mtr_commit() → 释放叶子页锁 → return
│   │           └── 失败(页满)? → return DB_FAIL
│   │
│   └── err == DB_FAIL? 继续悲观插入
│
└── 第二阶段: 悲观插入 (BTR_MODIFY_TREE)
    │
    │ row_ins_clust_index_entry_low(BTR_MODIFY_TREE)
    │   │
    │   ├── mtr_start(&mtr)
    │   ├── btr_cur_search_to_nth_level(BTR_MODIFY_TREE)
    │   │       └── 加锁: 索引SX锁 + 路径页锁
    │   │
    │   └── btr_cur_pessimistic_insert()
    │           │
    │           ├── btr_page_split_and_insert()  [页分裂]
    │           │       └── 分配新页、数据迁移、更新父节点指针、可能递归分裂   
    │           │
    │           └── mtr_commit() → 释放所有锁 → return
    │
    └── 完成插入

为方便阅读我将加锁流程写成了伪代码,注释中指明了所在行号(btr0cur.cc),需要阅读源码的时候可以根据行号定位,然后对应着看,比如说你想看下面这段代码的源码,那你可以找到row_ins_clust_index_entry()这个函数,然后根据上面的代码调用链一步步往深处看。

mtr_start() /* Algorithm1 乐观插入 */
savepoint = mtr_set_savepoint(mtr)      // L812
SL(index)                               // L862
Travel down with SL on internal nodes   // L936, L954-960
XL(leaf)                                // L940, L954-960
SU(index)                               // L1146-1147
SU(all internal nodes)                  // L1168-1169
result = try_optimistic_insert(leaf)
if result == SUCCESS:
    XU(leaf)                            // mtr_commit()
    return SUCCESS
XU(leaf)                                // mtr_commit(), 乐观失败
mtr_start() /* Algorithm2 悲观插入 */
savepoint = mtr_set_savepoint(mtr)      // L812
SXL(index)                              // L831
Travel down, release safe ancestors     // L1439-1455
XL(leaf->prev, leaf, leaf->next)        // L237-280: btr_cur_latch_leaves()
SXL(root)                               // L1464-1465
XL(unsafe path nodes)                   // L1469-1470
split_and_insert()                      // 分裂并插入
XU(all), SXU(index)                     // mtr_commit()
return SUCCESS

在Algorithm1中,乐观插入传入的是BTR_MODIFY_LEAF,首先会对整个B+树加S锁(H3),其次遍历到需要进行插入的叶节点,并沿路径将所遍历过的内部节点加S锁(H4),然后对该叶节点加X锁(H5),释放B+树和所有内部节点的S锁(H6 H7),尝试乐观插入,如果成功返回SUCCESS(H8),最后释放仍还持有的锁(H10),如果失败则走悲观插入。

在Algorithm2中,悲观插入传入的是BTR_MODIFY_TREE,首先会对整个B+树加SXL锁(H15),接着向下遍历B+树,和插入不同的是,不安全的节点会被记录到tree_blocks[]数组(H16),随后对目标叶节点及其前驱节点(leaf->prev)、后继节点(leaf->next)加XL锁(H17),再对B+树的根节点加SXL锁(H18),并对遍历路径中不安全的节点加XL锁(H19),随后执行节点分裂与数据插入操作(H20),最后释放所有持有的锁(包括各类XL锁与SXL锁)并返回SUCCESS(H21 H22)。

乐观更新失败通常是因为叶空间不足,节点可能需要分裂,所以悲观更新需要获取叶节点及其兄弟节点的XL锁,而且显而易见的是,叶节点的父节点不是一个safe node(因为分裂时父节点会接收一个元素),因此存入tree_blocks[]数组,并在H19时上XL锁。

如果你在下文看到传latch_mode的操作,就能明白实际上加锁的逻辑都是相同的,我就不再多赘述。

2.2 更新

函数调用链如下,当主键不变时才会走原地更新的路径。

问题1:为什么有两次乐观更新

可以看到这段代码逻辑走了两次乐观更新(H10 H38),原因在于第二次乐观更新时,页面状态可能已变化,并发 purge 可能删除了一些记录。
因此如果现在有空间了,则成功,避免不必要的分裂;如果仍然没空间,则继续执行真正的悲观逻辑。

​row_upd_clust_step()
│
├── row_upd_clust_rec_by_insert()  [主键变更时:删除旧记录+插入新记录]
│   │
│   ├── btr_cur_del_mark_set_clust_rec()  [标记删除旧记录]
│   └── row_ins_clust_index_entry()       [插入新记录,流程同插入]
│
└── row_upd_clust_rec()  [主键不变时:原地更新]
    │
    ├── 第一阶段: 乐观更新 (BTR_MODIFY_LEAF)
    │   │
    │   ├── mtr_start(&mtr)
    │   ├── pcur->restore_position(BTR_MODIFY_LEAF)
    │   │       └── btr_cur_search_to_nth_level(BTR_MODIFY_LEAF)
    │   │               └── 加锁: 索引S锁 → 叶子页X锁 → 释放索引S锁
    │   │
    │   ├── [字段大小不变?]
    │   │   └── btr_cur_update_in_place()
    │   │           ├── 成功? → mtr_commit() → 释放叶子页X锁 → return
    │   │           └── (原地更新不会失败)
    │   │
    │   └── [字段大小变化?]
    │       └── btr_cur_optimistic_update()
    │               ├── 成功? → mtr_commit() → 释放叶子页X锁 → return
    │               └── 失败(DB_OVERFLOW/DB_UNDERFLOW)? → mtr_commit()
    │
    │   └── err != DB_SUCCESS? 继续悲观更新
    │
    └── 第二阶段: 悲观更新 (BTR_MODIFY_TREE)
        │
        ├── mtr_start(&mtr)
        ├── pcur->restore_position(BTR_MODIFY_TREE)
        │       └── btr_cur_search_to_nth_level(BTR_MODIFY_TREE)
        │               └── 加锁: 索引SX锁 + 三兄弟叶子页X锁
        │
        └── btr_cur_pessimistic_update()
                │
                ├── 内部先尝试 btr_cur_optimistic_update()
                │
                ├── page_cur_delete_rec()     [删除旧记录]
                │
                └── [页空间不足?]
                    └── btr_cur_pessimistic_insert()  [页分裂]
                            │
                            ├── btr_page_split_and_insert()
                            │       └── 分配新页、数据迁移、更新父节点指针
                            │
                            └── mtr_commit() → 释放所有锁 → return

如果你想看下面这段代码的源码,那你可以找到row_upd_clust_rec()这个函数,然后根据上面的代码调用链一步步往深处看。

mtr_start() /* Algorithm3 乐观更新 */
pcur->restore_position(BTR_MODIFY_LEAF):  //内部调用 btr_cur_search_to_nth_level
    SL(index)                                         // 索引 S 锁
    XL(leaf)                                          // 叶子页 X 锁
    SU(index)                                         // 释放索引 S 锁
if node->cmpl_info & UPD_NODE_NO_SIZE_CHANGE:         // L2831
    err = btr_cur_update_in_place()                   // L2832-2833: 原地更新
else:
    err = btr_cur_optimistic_update()                 // L2836-2838
if err == DB_SUCCESS:
    XU(leaf)                                          // mtr_commit()
    return SUCCESS
XU(leaf)                                              // mtr_commit() L2844
mtr_start() /* Algorithm4 悲观更新 */                  // L2856
pcur->restore_position(BTR_MODIFY_TREE):             // L2870
    SXL(index)                                        // 索引 SX 锁
    XL(leaf->prev, leaf, leaf->next)                  // 三兄弟 X 锁
err = btr_cur_pessimistic_update()                    // L2884-2887
if big_rec:
    btr_store_big_rec_extern_fields()                 // L2893-2894
XU(all), SXU(index)                                   // mtr_commit()
return err

在Algorithm3中,乐观更新传入的是BTR_MODIFY_LEAF,首先会定位到需要更新的叶节点并完成加锁(H3 H4 H5),其实就是Algorithm1的逻辑,如果更新不改变记录大小则原地更新(H7),否则走乐观更新(H9),更新成功则释放叶节点的X锁(H11),否则转为悲观更新。

悲观更新传入的是BTR_MODIFY_TREE,加锁逻辑和悲观插入是相同的。

乐观/悲观更新会选择删除旧记录,插入一条新记录(在同一页),因此后续的代码逻辑就会走删除/插入这一块,并且此时我们已经持有了叶节点的锁,因此插入/删除时我们是不需要重复的获取相关的锁,即不用重复执行btr_cur_search_to_nth_level()。

2.3 删除

2.3.1 标记删除

函数调用链如下,删除时先标记,再由Purge线程执行。
​```cpp
row_upd_del_mark_clust_rec() [用户线程: 标记删除]

├── mtr_start(&mtr)
├── pcur->restore_position(BTR_MODIFY_LEAF)
│ └── btr_cur_search_to_nth_level(BTR_MODIFY_LEAF)
│ └── 加锁: 索引S锁 → 叶子页X锁 → 释放索引S锁

├── btr_cur_del_mark_set_clust_rec() [设置 delete_flag = 1]

└── mtr_commit() → 释放叶子页X锁 → return
└── 记录仅被标记删除,物理删除由Purge线程执行

​​```cpp
mtr_start() /* Algorithm5 标记删除 */
btr_cur_search_to_nth_level(BTR_MODIFY_LEAF):
    SL(index)                                      // L862: 索引 S 锁
    Travel down with SL on internal nodes          // L937, L955-958
    XL(leaf)                                       // L940, L955-958: 叶子页 X 锁
    SU(index)                                      // L1145-1146: 释放索引 S 锁
    SU(all internal nodes)                         // L1167-1168: 释放非叶子页锁
btr_cur_del_mark_set_clust_rec()                   // 设置 delete flag
XU(leaf)                                           // mtr_commit()

​Algorithm5标记删除的逻辑很好理解,因为标记删除只需要修改记录中的1bit标志位,所以对整个B+Tree加S锁(H3),对叶节点加X锁(H5)。

2.3.2 物理删除

物理删除的代码调用链如下

​row_purge_del_mark()  [Purge线程: 物理删除]
│
└── row_purge_remove_clust_if_poss()  [删除聚簇索引记录]
    │
    ├── 第一阶段: 乐观删除 (BTR_MODIFY_LEAF)
    │   │
    │   ├── mtr_start(&mtr)
    │   ├── row_purge_reposition_pcur(BTR_MODIFY_LEAF)
    │   │       └── btr_cur_search_to_nth_level(BTR_MODIFY_LEAF)
    │   │               └── 加锁: 索引S锁 → 叶子页X锁 → 释放索引S锁
    │   │
    │   └── btr_cur_optimistic_delete()
    │           │
    │           ├── btr_cur_can_delete_without_compress()
    │           │       └── 检查删除后是否需要页合并
    │           │
    │           ├── 成功(无需合并)? 
    │           │       ├── lock_update_delete()
    │           │       ├── page_cur_delete_rec()  [物理删除记录]
    │           │       └── mtr_commit() → 释放叶子页X锁 → return true
    │           │
    │           └── 失败(需要合并)? → return false
    │
    │   └── success == false? 继续悲观删除
    │
    └── 第二阶段: 悲观删除 (BTR_MODIFY_TREE | BTR_LATCH_FOR_DELETE)
        │
        ├── mtr_start(&mtr)
        ├── row_purge_reposition_pcur(BTR_MODIFY_TREE)
        │       └── btr_cur_search_to_nth_level(BTR_MODIFY_TREE)
        │               └── 加锁: 索引SX锁 + 三兄弟叶子页X锁
        │
        └── btr_cur_pessimistic_delete()
                └── mtr_commit() → 释放所有锁 → return
​```
​```cpp
mtr_start()/* Algorithm6 物理删除*/       // L170
row_purge_reposition_pcur(BTR_MODIFY_LEAF)             // L172
SL(index)                                              // 索引 S 锁
XL(leaf)                                               // 叶子页 X 锁
SU(index)                                              // 释放索引 S 锁
if roll_ptr matches:                                   // L182: 验证版本
success = btr_cur_optimistic_delete()                  // L189
if success:
XU(leaf)                                               // mtr_commit() L227
return true
XU(leaf)                                               // mtr_commit()
/* 第二阶段: 悲观删除 */
mtr_start()
row_purge_reposition_pcur(BTR_MODIFY_TREE | BTR_LATCH_FOR_DELETE)  // L193
SXL(index)                                             // 索引 SX 锁
XL(leaf->prev, leaf, leaf->next)                       // 三兄弟 X 锁
btr_cur_pessimistic_delete()                           // L203-205
XU(all), SXU(index)                                    // mtr_commit() L227
return success

Algorithm6物理删除的逻辑也是先乐观再悲观。

乐观删除传入的是BTR_MODIFY_LEAF,即只需要对叶节点加X锁,因为删除不需要进行合并,所以不会影响其他节点,这个很好理解。

悲观删除传入的是BTR_MODIFY_TREE | BTR_LATCH_FOR_DELETE,合并时可能会把记录移到兄弟节点,所以对B+Tree加SX锁(H15),对叶节点和兄弟节点加X锁(H16)。

posted @ 2025-12-31 16:28  Monlity  阅读(0)  评论(0)    收藏  举报