记录个人对 InnoDB 的一些浅薄的理解,欢迎讨论。基于 MySQL 8.0.18
记录
记录的存在形式分为物理记录(record)和逻辑记录(tuple),物理记录即是记录在文件中(磁盘)的存储形式。这里只考虑普通的记录,非大记录(blob)
物理记录(record)
即记录在磁盘文件上的格式。如上图:
- column offset list:每个列的偏移列表,包括三个系统列(DB_ROW_ID / DB_TRX_ID / DB_ROLL_PTR)
- record header:记录头部(6 字节),最后的「next record」指向下一个记录的「original offset」处
- original offset:该记录实际数据的开始位置
CREATE TABLE t (
t1 VARCHAR(10),
t2 VARCHAR(10),
t3 CHAR(10),
t4 VARCHAR(10),
) ENGINE = InnoDB
INSERT INTO t VALUES ('a', 'bb', 'bb', 'ccc');
// hexdump ...
/* Column offset list,7个列(逆序存放):
第一列(DB_ROW_ID):0x00 - 0x06,左闭右开区间(0x06是第二列起始地址)、6个字节
第二列(DB_TRX_ID):0x06 - 0x0c,6个字节
第三列(DB_ROLL_PTR):0x0c - 0x13,7个字节
第四列(t1):0x13 - 0x14,1个字节
第五列(t2):...
*/
23 20 16 14 13 0c 06
00 00 10 0f 00 ba /* Record Header,6 Bytes */
00 00 00 2b 68 0b /* DB_ROW_ID */
00 00 00 00 06 53 /* DB_TRX_ID */
80 00 00 00 32 01 01 /* DB_ROLL_PTR */
61 /* t1 'a' */
62 62 /* t2 'bb' */
62 62 20 20 20 20 20 20 20 20 20 20 /* t3 'bb',CHAR类型 */
63 63 63 /* t4 'ccc' */
逻辑记录(tuple)
逻辑记录即是在内存中的表示(data structure),如上图:
- n_fields:列的数量
- fields:列(dfield_t)的数组,存储指向每一列真实数据的指针
- tuple_list:连接多个 tuple(每个索引都有一个 tuple,比如 INSERT SQL 需要向主键索引和二级索引中都插入时)
/** Structure for an SQL data field */
struct dfield_t{
void* data; // 真实列数据的指针
unsigned ext:1; // 如果是大记录(blob),则在外部页存储
unsigned len:32; // 列数据的长度
dtype_t type; // 列数据的类型
};
基于 B-tree 的索引
InnoDB的索引全貌(B+Tree index structures in InnoDB):
其中:
- 树高度的定义:叶子节点是 0,父节点高度是子节点高度 +1
- 每一个 record 由 key + value 组成:
- 中间节点的 key:保存的是其子节点中最小的 key
- 中间节点的 value:是一个 pointer(page_no),指向子节点(可能是中间节点,可能是叶子节点)
- 叶子节点的 key:能唯一确定该数据行的若干列(对于不同索引不一样,主键列、或唯一键列、或二级索引列+主键列)
- 叶子节点的 value:其余列
- 同一高度的 page 连接成 双向链表,称作 page list
- 同一 page 的 record 连接成 单向链表,称作 record list(按 record key 升序连接)。数据页内的 record 逻辑上相邻,物理上不一定相邻
- Infimum record(下确界)/ supremum record(上确界):两个系统记录(具有固定页内偏移量),为一个 page 内 record list 的头/尾节点
B-tree 是一种加速查找的数据结构,从主键索引或二级索引里查找一个记录(tuple)需要进行 B-tree 遍历。通过 btr_cur_search_to_nth_level 这个函数完成
void btr_cur_search_to_nth_level(
dict_index_t *index, /*!< in: index */
ulint level, /*!< in: the tree level of search */
const dtuple_t *tuple, /*!< in: data tuple; NOTE: n_fields_cmp in
tuple must be set so that it cannot get
compared to the node ptr page number field! */
page_cur_mode_t search_mode, /*!< in: PAGE_CUR_L, ...;
Inserts should always be made using
PAGE_CUR_LE to search the position! */
ulint latch_mode, /*!< in: BTR_SEARCH_LEAF, ..., ORed with
at most one of BTR_INSERT, BTR_DELETE_MARK,
BTR_DELETE, or BTR_ESTIMATE;
cursor->left_block is used to store a pointer
to the left neighbor page, in the cases
BTR_SEARCH_PREV and BTR_MODIFY_PREV;
NOTE that if has_search_latch
is != 0, we maybe do not have a latch set
on the cursor page, we assume
the caller uses his search latch
to protect the record! */
btr_cur_t *cursor, /*!< in/out: tree cursor; the cursor page is
s- or x-latched, but see also above! */
ulint has_search_latch,
/*!< in: info on the latch mode the
caller currently has on search system:
RW_S_LATCH, or 0 */
const char *file, /*!< in: file name */
ulint line, /*!< in: line where called */
mtr_t *mtr) /*!< in: mtr */
在查找过程中:
- 记录定位:根据 tuple(参数中的 tuple)和 search mode(参数中的 mode)最终会定位到某个数据页上的某个 record
- 并发控制:根据 latch mode(参数中的 latch_mode)的不同,在遍历过程中采用不同的加锁(latch)策略
记录定位
二分查找算法与四种 search mode
InnoDB 在数据页内使用的是二分查找。对于 B-tree 数据页上的二分查找有四种 search mode,这四种 search mode 决定了最后定位哪个记录(具体可见 page_cur_search_with_match 函数)
- PAGE_CUR_G(>,大于):SELECT * WHERE column > 1
- PAGE_CUR_GE(>=,大于等于):UPDATE / DELETE / SELECT * WHERE column = 1
- PAGE_CUR_L(<,小于):SELECT * WHERE column < 1
- PAGE_CUR_LE(<=,小于等于):INSERT ...
这里先介绍一个概念:cursor(游标)。最后定位哪个记录,我们就说 cursor 置于哪个记录上,类似于一个 record 指针,更具体的关于 cursor 的内容下文会再介绍。上图1中,SELECT * WHERE id > 40 使用 PAGE_CUR_G 定位到(将 cursor 置于)第一个大于 40 的 record 上(记录 50),与该 SQL 的语义符合。其他 search mode 同理。
中间节点的 search mode
但对于中间节点只使用两种模式,PAGE_CUR_L / PAGE_CUR_LE,转换关系在函数 btr_cur_search_to_nth_level 之中
// page_mode 即为中间节点使用的搜索模式
switch (mode) {
case PAGE_CUR_GE:
page_mode = PAGE_CUR_L;
break;
case PAGE_CUR_G:
page_mode = PAGE_CUR_LE;
break;
default:
page_mode = mode;
break;
}
这是为什么?为什么 PAGE_CUR_GE 对应于 PAGE_CUR_L,而不是 PAGE_CUR_LE?
如下图,假设 PAGE_CUR_GE 对应于 PAGE_CUR_LE,在二级索引中我们执行:
- Trx1:SELECT >= 50
根据索引页内的二分查找算法,PAGE_CUR_LE 的选择路径如图中虚线所示,最终会定位在 Leaf 3 的第一个 50 上,导致在叶子节点既需要向左移动,有需要向右移动,这并不是一个好的做法。而如果使用 PAGE_CUR_L 呢?因为使用的模式是 PAGE_CUR_L,会导致选择中间节点10。但此种情况下最终会将 cursor 置于 leaf1 内的 supremum record 中,进而找到 Leaf2。因此使用 PAGE_CUR_L 要优于 PAGE_CUR_LE
中间节点查找溢出
这里还需要注意一个 corner case。首先要注意中间节点记录的 key 保存的是其子节点中最小的 key。那么比如右图 SELECT >=5,难道在 root page 二分查找法会选择 infimum record?在每一层的最左节点都会被置一个标记 REC_INFO_MIN_REC_FLAG(record info bits),可以发现该记录是中间节点的最小记录(图中的 10),便不会选择 infimum record 了
基于锁(latch)的并发控制
我们基于MySQL 8.0.17。在B+ tree上共有三种操作:
- 读(BTR_SEARCH_LEAF / BTR_SEARCH_LEAF):分为点查询和范围查询
- 乐观写(BTR_MODIFY_LEAF):仅影响到一个索引页的增删改
- 悲观写(BTR_MODIFY_TREE):影响到超过一个索引页的增删改(e.g. DELETE SQL导致了页合并)
并发控制使用两种 latch:index latch / page latch
我们再插入一些说明:数据库里的 latch / lock 的不同,latch 与 lock 都可以用来完成 2PL(2 Phase Locking),使用 latch 的是 mini transaction(物理事务),使用 lock 的是用户事务(逻辑事务)。因此这里 latch 和 mini transaction 都是物理意义的(操作数据页),lock 与 用户事务(Transaction)都是逻辑意义上的(操作记录)。某种程度上可以把 mini transaction 和用户事务看做是 multi level transaction。这里再多说一句为什么需要 multi level transaction。
在形式化的描述中,事务(transaction)是由一系列操作(operation)组成的序列。 比如下面的
而对于一个调度,我们往往隐含一个假设:
在一个调度中,任何两个操作之间只有两种排列:或
而在实际的数据库系统中可未必保证该 假设。因为操作也是 可分割的单元。
我们以 B-tree index 的分裂为例:若记录
- 创建一个空的 Page Z
- 在 Page Y 中增加 pointer 指向 Page Z
- 将 Page X 中的部分记录移动至 Page Z
期间不允许其他动作访问 Page X/Y/Z,因此步骤 1~3 构成了一个 广义上的事务,为了与前文的事务相区分,称作 mtr(mini transaction)
九种 latch mode
search mode 决定在 B-tree 查找时返回什么记录,latch mode 决定在 B-tree 查找时如何对路径加锁。InnoDB 目前定义了九种 latch mode,每一种都有不同在不同场景下使用:
/** Latching modes for btr_cur_search_to_nth_level(). */
enum btr_latch_mode : size_t {
/** Search a record on a leaf page and S-latch it. */
BTR_SEARCH_LEAF,
/** (Prepare to) modify a record on a leaf page and X-latch it. */
BTR_MODIFY_LEAF,
/** Obtain no latches. */
BTR_NO_LATCHES,
/** Start modifying the entire B-tree. */
BTR_MODIFY_TREE = 33,
/** Continue modifying the entire B-tree. */
BTR_CONT_MODIFY_TREE = 34,
/** Search the previous record. */
BTR_SEARCH_PREV = 35,
/** Modify the previous record. */
BTR_MODIFY_PREV = 36,
/** Start searching the entire B-tree. */
BTR_SEARCH_TREE = 37,
/** Continue searching the entire B-tree. */
BTR_CONT_SEARCH_TREE = 38
};
1. BTR_SEARCH_LEAF(点查询)
- 获得索引的 S lock
- 由上至下依次获取搜索路径节点的 S lock,直至叶子节点
- 释放索引和中间节点的 S lock,仅保留叶子节点
- 读叶子节点的内容 ......
- 释放叶子节点 S lock
附:范围查询怎么做?
我们知道范围查询的第一步是点查询(如SELECT id>40,先定位到记录10,然后沿着叶子节点向右扫描),在叶节点切换时,我们尤其要注意。
正序方向(move_up = true)因为符合 latch order 很简单:
- 获得右兄弟 S lock
- 释放自身的 S lock
逆序方向(move_up = false)因为不符合 latch order 比较复杂,等介绍完 cursor store / restore 再讲
2. BTR_MODIFY_LEAF(乐观写)
这个 latch mode 对应于一次不会引起 SMO 的 IUD
- 获得索引的S lock
- 由上至下 依次获取搜索路径节点的 S lock
- 叶子节点获得 X lock
- 释放索引和中间节点的 S lock,仅保留叶子节点
- 修改叶子节点的内容 ......
- 释放叶子节点 X lock
3. BTR_MODIFY_TREE(悲观写)
这个 latch mode 对应于一次会引起 SMO 的 IUD。
下图展示了插入记录 40 引起叶节点分裂的情况
- 获得索引的 SX lock
- 由上至下 依次获取 可能引起分裂或合并 的节点(如P1)的 X lock
- 依次获得叶子节点 左兄弟、自身、右兄弟 的 X lock
- 修改索引结构和叶子节点的内容 ......
- 释放所有 X lock
右兄弟节点:INSERT 的优化及麻烦
当 INSERT 可能导致索引页(中间节点或叶节点)分裂时,如果 INSERT 的位置在索引页的最后,而且右兄弟指针不为空,试着直接插入到右兄弟(btr_insert_into_right_sibling)。我们来详细分析这个过程
|- btr_insert_into_right_sibling(
// Step-1:得到右兄弟的父节点
// next_father_cursor 指向了父节点对应的 node_ptr。会调用 btr_search_to_nth_level,
// 搜索到 next_block 上一层,latch mode 是 BTR_CONT_MODIFY_TREE。
// BTR_CONT_MODIFY_TREE 的模式下不会对 index 及沿途路径加锁,只在返回前对目标索引页加
// X latch
|- btr_page_get_father(cursor->index, next_block, mtr, &next_father_cursor);
// Step-2:在 next_block 寻找放置 tuple 的位置(就是第一个 record)
|- page_cur_search(next_block, cursor->index, tuple, PAGE_CUR_LE,
&next_page_cursor);
// Step-3:把 tuple 插入到 next_page_cursor 指向的位置
|- page_cur_tuple_insert(next_page_cursor, mtr ...)
// Step-4:修改上级节点的 node ptr
// 删除父节点中指向该页(next_block)的 node_ptr,当删除 node_ptr 后尝试压缩父节点
// 然后在父节点中插入新的 node_ptr。在 btr_cur_pessimistic_delete 如果要删除的是
// 中间节点的第一个 node pointer record 简称 node ptr,区别于叶子节点的 user record
// 需要处理两种特殊情况:
// 1)如果该中间节点没有左兄弟,把删除之后的第一个 node pointer record 设置
// REC_INFO_MIN_REC_FLAG 标记,表示是这一层的最左 node ptr
// 2)删除其父节点对应的 node ptr,过程同上:
// - btr_page_get_father
// - btr_cur_pessimistic_delete
// - btr_cur_compress_if_useful
// - btr_insert_on_non_leaf_level
// 这便可能导致级联效果
|- btr_cur_pessimistic_delete
|- btr_cur_compress_if_useful
|- btr_insert_on_non_leaf_level
当然,悲观写有两种操作:INSERT / DELETE,分别可能引起 B-tree 节点的分裂或合并,或者即分裂又合并。这三种情况在代码里对应三种 lock intention:
- BTR_INTENTION_INSERT(只引起节点分裂)
- BTR_INTENTION_DELETE(只引起节点合并)
- BTR_INTENTION_BOTH(可能都会出现)
btr_cur_will_modify_tree 里有一个规则:
- 【规则1】当 lock_intention <= BTR_INTENTION_BOTH 时,而且 cursor 指向数据页的最后一个 record 且右兄弟节点不为空,则不释放其父节点 latch
我们看一个例子来理解这个规则。事务 T1 的行为是:
- B-tree 的查找路径 root→P1→P4→P8
- 调用 btr_insert_into_right_sibling,准备插入右兄弟节点P9
- 修改 P5 的 node ptr record
- 修改 P2 的 node ptr record
- 修改 root 的 node ptr record
在第4步后如果有一个事务 T2 试图查找 P9,而且已得到 root latch,则会与事务 T1 形成死锁
当对 P1 使用 btr_insert_into_right_sibling 时,会因为需要路由的 node ptr record(指向 P4)是 P1 的第一个 record,而不会释放 A 父节点(root)的 latch,因而避免上述的死锁发生
我们提出:【规则1】至少会锁住 btr_insert_into_right_sibling 涉及到的两兄弟节点的 最近公共祖先。证明如下:
假设目标叶子节点是 P1,右兄弟是 P2
- P1 P2 共用父节点(下图中图 1),则根据 btr_cur_search_to_nth_level 中对 BTR_MODIFY_TREE 的加锁规则,会锁住 A
- 设最近公共祖先是 A。A 的子节点中 P1 和 P2 的祖先是分别是 B 和 C。则对 B 使用 btr_cur_will_modify_tree 时,因为拿到的 node ptr record 一定是 B 的 last record(下图中图 2和图 3),原因很简单,P1 如果不是 B 的最右子节点。,则 B 将会有其余的子节点作为 P1 的右兄弟,而 P1 的右兄弟是 P2,则 P2 将会是(B)的子节点,等价于第一种情况
InnoDB 不使用 latch coupling 的原因
我们再解释 InnoDB 不使用经典的 latch coupling 的原因。latch coupling 就是说锁住子节点之后会释放父节点的 latch。而 InnoDB 目前会在 BTR_SEARCH_LEAF 时持有一路中间节点的 latch,直至叶子节点才释放。原因是与上述的 btr_insert_into_right_sibling 避免死锁。还是之前的例子:
事务 T1 B-tree 的查找路径 root→P1→P4→P8,需要路由的 node ptr record(指向 P4)是 P1 的第一个 record,所以会把 root / P1 / P4 / P8 都锁住。然后调用 btr_insert_into_right_sibling:
- 插入右兄弟节点 P9
- 修改 P5 的 node ptr record
- 修改 P2 的 node ptr record
- 修改 root 的 node ptr record
如果使用 latch coupling,比如上述第1步后可能存在事务 T2 只锁住了 P5,准备拿 P9 的锁。和事务 T1 形成死锁
4. BTR_CONT_MODIFY_TREE
使用于在 B-tree 分裂过程中,搜索得到叶子节点的父节点,或向父节点中插入新的 node ptr 的情况。见 BTR_MODIFY_TREE
5. BTR_SEARCH_PREV(向前读)
这种 latch mode 使用于逆向遍历 B-tree 叶子节点时,见下文 例1- B-tree 逆序遍历
当检索到叶子节点时,会 latch 住叶子节点与左兄弟节点。当然加锁顺序是先左兄弟节点而后该节点
但这里要注意一个加锁规则:左兄弟的父节点(中间节点)同样需要持有 latch,即上面右图。原因是什么?我们注意到在 B-tree search 到叶子节点时(图中的 P5),如果是 BTR_SEARCH_PREV 则需要:
- 拿到左兄弟的 page no(P4)
- 获得左兄弟的 latch(P4)
- 获得自身的 latch(P5)
如果不持有着左兄弟父节点的 latch,那么在第1步和第2步之间如果 P4 发生分裂导致 P5 的左兄弟不再是 P4,则最后函数返回时便不会是 latch 住叶子节点(P5)与左兄弟节点(P4 不再是 P5 的左兄弟)
因此这个规则是为了避免与 BTR_MODIFY_TREE(悲观插入 / 删除)产生 race condition。
6. BTR_MODIFY_PREV
与 BTR_SEARCH_PREV 的区别在于对叶子节点加锁是 x latch。只有在向 Change Buffer 里插入时会使用这个 latch mode
7. BTR_SEARCH_TREE
在扫描 B-tree 收集统计信息时用到,e.g dict_stats_analyze_index_level
8. BTR_CONT_SEARCH_TREE
仅在 btr_validate_level 中获得父节点时使用,进行 B-tree 结构合法性的检验
9. BTR_NO_LATCHES
只对 intrinsic tables 的访问时用到,intrinsic tables 的介绍请看 MySQL 5.7: InnoDB Intrinsic Tables
持久化游标(persistent cursor)
Skywalker:InnoDB:B-tree index(2)zhuanlan.zhihu.com参考
- B+Tree index structures in InnoDB
- 关于innodb中查询的定位方法
- innodb对B树游标的定位过程以及对“小于(等于)B树最小记录”的特殊处理
- MySQL:一个简单insert语句的大概流程
- [MySQL 源码] Innodb Pessimistic Insert流程
- 从MySQL Bug#67718浅谈B+树索引的分裂优化
- WL#6326: InnoDB: fix index->lock contention
- MySQL · 引擎特性 · B+树并发控制机制的前世今生
- MySQL · 引擎特性 · InnoDB index lock前世今生
- 从MySQL Bug#67718浅谈B+树索引的分裂优化
- B-Tree Visualizationwww.
- 悲观插入死锁问题定位
- InnoDB btree latch 优化历程