曾经优化慢查询时,经常在日志中看到 truncate,当时一直疑惑 truncate 为什么会慢。
转到数据库方向之后,又碰到过几次 truncate 执行时间过长,导致 MySQL 短暂卡住的问题。
经过源码分析和同事测试验证,发现这几次的问题都跟自适应哈希索引有关,所以,深入研究下自适应哈希索引就很有必要了。
自适应哈希索引大概会有 3 篇文章。第 1 篇,我们来看看自适应哈希索引的构造过程。
本文基于 MySQL 8.0.32 源码,存储引擎为 InnoDB。
1、准备工作
创建测试表:
插入测试数据:
示例 SQL:
2、AHI 有什么好处?
自适应哈希索引,英文全称 Adaptive Hash Index,简称 AHI。
根据上下文需要,后续内容会混用自适应哈希索引和 AHI,不再单独说明。
顾名思义,自适应哈希索引也是一种索引,使用哈希表作为存储结构,自适应的意思是我们无法干涉它的创建、使用、更新、删除,而是由 InnoDB 自行决定什么时候创建、怎么创建、什么时候更新和删除。
我们知道 InnoDB 的主键索引、二级索引都是 B+ 树结构。执行 SQL 语句时,以下几种场景都需要定位到索引叶子结点中的某条记录:
- insert 需要定位到插入目标位置的前一条记录。
- 查询优化阶段,select、update、delete 预估扫描区间记录数量时,需要定位到扫描区间的
第一条
和最后一条
记录。 - 查询执行阶段,select、update、delete 需要定位到扫描区间的
第一条
记录。
得益于多叉结构,B+ 树的层级一般都比较少,通常情况下,从根结点开始,最多经过 3 ~ 4 层就能定位到叶子结点中的记录。
这个定位过程看起来挺快的,单次执行也确实比较快,但是对于频繁执行的场景,还是有一点优化空间的。
为了追求极致性能,InnoDB 实现了 AHI,目的就是能够根据搜索条件直接定位到索引叶子结点中的记录。
图片
上图是一棵高为 4 的 B+ 树示意图,定位索引叶子结点记录,需要从根结点开始,经过内结点、叶结点,最后定位到记录,路径为:① -> ② -> ③ -> ④
图片
AHI 会使用多个 hash 桶来存储哈希值到记录地址的映射,上图是一个 hash 桶的示意图。
橙色块表示记录地址组成的链表,因为多条记录可能被映射到 hash 桶中的同一个位置。
AHI 定位一条记录时,根据 where 条件中的某些字段计算出哈希值,然后直接到 hash 桶中找到对应的记录。
如果多条记录被映射到 hash 桶中的同一个位置,那么找到的是个链表,需要遍历这个链表以找到目标记录。
3、原理介绍
AHI 能提升定位记录的效率,但是,有得到必然有付出,天上掉馅饼的事在计算机世界里是不会发生的。
为了简洁,这里我们把定位索引叶子结点中的记录简称为定位记录,后面内容也依此定义,不再单独说明。
高效定位记录,付出的代价是存储哈希值到记录地址的映射占用的内存空间、以及构造映射花费的时间。
因为有时间、空间成本,所以,InnoDB 希望构造 AHI 之后,能够尽可能多的用上,做到收益大于成本。直白点说,就是需要满足一定的条件,才能构造 AHI。
AHI 采用的是组团构造逻辑,也就是以数据页为单位,满足一定条件之后,就会为数据页中的所有记录构造 AHI,主要流程如下:
图片
第 1 步,为数据页所属的索引计数(index->search_info->hash_analysis)。
SQL 执行过程中,每次定位某个索引叶子结点中的记录,该索引的计数都会加 1。
如果索引计数达到 17,进入第 2 步,否则,执行流程到此结束。
因为第 2、3 步为构造信息计数、为数据页计数也是需要时间成本的,所以,这里设置了第 1 道槛,只有索引被使用一定次数之后,才会执行第 2、3 步。
第 2 步,为构造信息计数(index->search_info->n_hash_potential)。
对于 select、insert、update、delete,定位记录时,搜索条件和叶子结点中的记录比较,会产生两个边界,左边为下界,右边为上界,基于下界和上界可以得到用于构造 AHI 的信息,我们称之为构造信息。
图片
以上是定位记录时产生的下界、上界示意图。定位记录过程中,下界和上界会不断向目标记录靠近,最终,下界或上界的其中一个会指向目标记录。
如果某次定位记录时,基于下界或上界得到的构造信息,和索引对象中保存的构造信息一致,该构造信息计数加 1。否则,该索引计数清零,构造信息计数清零或重置为 1(具体见下一小节的介绍)。
第 3 步,为数据页计数(block->n_hash_helps)。
定位到索引叶子结点记录之后,就知道了该记录所属的数据页,如果本次得到的构造信息和数据页对象中保存的构造信息相同,数据页计数加 1,否则数据页计数重置为 1。
第 4 步,为数据页构造 AHI。
如果满足以下两个条件,第 3 步的数据页就具备了构造 AHI 的资格:
- 构造信息计数大于等于 100。
- 数据页计数大于数据页中记录数量的十六分之一。
具备构造 AHI 的资格之后,对于以下三种情况之一,会为数据页构造 AHI:
- 该数据页之前没有构造过 AHI。
- 该数据页之前构造过 AHI,并且构造 AHI 之后,数据页会从零开始重新计数,重新计数大于数据页中记录数量的两倍。
- 该数据页之前构造过 AHI,但是本次确定的构造信息和之前不一样了。
注意:从各个条件判断,到最终构造 AHI 的整个流程,并不是在执行一条 SQL 的过程中完成的,而是在执行多条 SQL 的过程中完成的。
到这里,构造 AHI 的主要流程就介绍完了,构造过程的具体细节,请继续往下看。
4、构造过程
定位记录会调用 btr_cur_search_to_nth_level(),这也是 AHI 的构造入口:
btr_search_enabled = true(默认值),表示 InnoDB 启用了 AHI。
!index->disable_ahi = true,即 index->disable_ahi = false,表示 index 对应的索引没有禁用 AHI。
只有内部临时表、没有主键的表禁用了 AHI。
也就是说,对于正常的用户表、系统表,!index->disable_ahi = true,会调用 btr_search_info_update(),进入 AHI 的构造流程。
(1)索引计数
构造 AHI 的第 1 步,就是调用 btr_search_info_update() 进行索引计数。
btr_search_info_update() 每次被调用都会增加索引计数(++index->search_info->hash_analysis)。
自增之后,如果索引计数小于 17,不需要进入 AHI 构造流程的下一步,直接返回。
如果索引计数大于等于 17,调用 btr_search_info_update_slow(),进入 AHI 构造流程的下一步。
看到这里,大家是否会有疑问:对于一条能使用索引的 select 语句,如果 where 条件只有一个扫描区间,执行过程中,btr_search_info_update() 最多会被调用几次?
我们通过 1. 准备工作小节的示例 SQL 来揭晓答案,SQL 如下:
执行计划如下:
图片
通过执行计划可知,示例 SQL 执行过程中,会使用索引 idx_i1。
查询优化阶段,MySQL 需要定位到扫描区间的第一条和最后一条记录,用于计算扫描区间覆盖的记录数量:
- 定位扫描区间的第一条记录,即满足 id >= 101 的第一条记录,第 1 次调用 btr_cur_search_to_nth_level()。
- 定位扫描扫描区间的最后一条记录,即满足 id < 301 的最后一条记录,第 2 次调用 btr_cur_search_to_nth_level()。
查询执行阶段,从存储引擎读取第一条记录之前,需要定位扫描区间的第一条记录,即满足 id >= 101 的第一条记录,第 3 次调用 btr_cur_search_to_nth_level()。
定位扫描区间的第一条和最后一条记录,都是定位索引叶子结点中的记录。
到这里,我们就得到了前面那个问题的答案:3 次。
(2)构造信息计数
如果某个索引的计数达到了 17,就会进入 AHI 构造流程的第 2 步,根据本次定位记录过程中得到的下界和上界,确定使用索引的前几个字段构造 AHI,以及对于索引中前几个字段值相同的一组记录,构造 AHI 时选择这组记录的第一条还是最后一条。
3.原理介绍小节的第 2 步,我们已经把这些信息命名为构造信息。
基于定位记录时得到的下界和上界确定构造信息、为构造信息计数的逻辑由 btr_search_info_update_hash() 完成。
btr_search_info_update_hash() 的代码有点长,我们分 2 段介绍。
介绍第 1 段代码之前,我们先来看看表示构造信息的结构体定义(index->search_info->prefix_info):
btr_search_prefix_info_t 包含 3 个属性:
- n_fields、n_bytes:索引的前 n_fields 个字段,第 n_fields + 1 个字段的前 n_bytes 字节用于构造 AHI。
- left_side:如果索引中多条记录的前 n_fields 个字段内容、第 n_fields + 1 个字段前 n_bytes 字节的内容相同,我们把这样的一组记录称为前缀相同的记录。对于前缀相同的记录:left_side = true 时,选择最左边的记录(第一条记录)构造 AHI。left_side = false 时,选择最右边的记录(最后一条记录)构造 AHI。
接下来,我们开始介绍 btr_search_info_update_hash() 的第 1 段代码逻辑。
如果构造信息计数(info->n_hash_potential)不等于 0,if_1 条件成立,说明索引对象中已经保存了之前确定的构造信息。
但是,确定索引的 AHI 构造信息之后,还需要该索引的构造信息计数、某个数据页的计数满足条件,InnoDB 才会为该索引的该数据页构造 AHI。
所以,索引对象中已经保存了之前确定的构造信息对应两种情况:
- 已经用索引中保存的构造信息为某个(些)数据页构造了 AHI。
- 只确定了构造信息,还没有用它构造过 AHI。
构造信息计数被命名为 n_hash_potential(潜在的),就是因为存在第 2 种情况。
if_2、if_3 用于判断:通过本次定位记录时产生的下界或上界得到构造信息,是否和索引对象中保存的构造信息一致,如果一致,则增加构造信息计数。
if_2 包含两个表达式,如果值都为 true,说明上面的判断结果为 true,构造信息计数加 1(info->n_hash_potential++)。
如果 if_2 不成立,再判断 if_3 是否成立。
前面介绍过,对于前缀相同的一组记录,构造 AHI 时,由 left_side 决定选择最左边还是最右边的记录。
对于本次定位记录得到的下界、上界,left_side 决定它们怎么和索引对象中保存的构造信息比较。
left_side = true 时,如果以下两个条件同时满足,构造信息计数(n_hash_potential)加 1:
- prefix_info.n_fields、n_bytes 大于下界(low_match、low_bytes)。
- prefix_info.n_fields、n_bytes 小于等于上界(up_match、up_bytes)。
left_side = false 时,如果以下两个条件同时满足,构造信息计数(n_hash_potential)加 1:
- prefix_info.n_fields、n_bytes 小于等于下界(low_match、low_bytes)。
- prefix_info.n_fields、n_bytes 大于上界(up_match、up_bytes)。
如果 if_3 成立,说明本次定位记录得到的下界或上界的字段数量、字节数,和索引对象中保存的构造信息的字段数量(n_fields)、字节数(n_bytes)一致,构造信息计数加 1(info->n_hash_potential++)。
如果 if_3 不成立,说明构造信息变了,需要执行第 2 段代码,确定新的构造信息,并且重置构造信息计数。
第 2 段代码用于首次或者重新确定构造信息,主要逻辑如下:
- 索引计数(info->hash_analysis)清零,下次调用 btr_search_info_update_hash() 时重新开始计数。
- 如果 left_side = true,并且本次定位记录得到的下界字段数量等于 n_unique,使用下界作为新的构造信息。
- 如果 left_side = false,并且本次定位记录得到的上界字段数量等于 n_unique,使用上界作为新的构造信息。
- 否则,选择本次定位记录得到的上界、下界中较小的那个作为新的构造信息。
ut_pair_cmp(up_match, up_bytes, low_match, low_bytes) 比较本次定位记录得到的上界、下界,比较结果保存到 cmp 中。
如果 cmp 等于 0,命中 if_4,说明 btr_cur_search_to_nth_level() 定位记录时,上界、下界相同(up_match 等于 low_match、up_bytes 等于 low_bytes),也就是没有找到匹配的记录,构造信息计数(info->n_hash_potential)重置为 0。
如果 cmp 大于 0,命中 elseif_5,说明本次定位记录时,下界小于上界,构造信息(prefix_info)的 left_side 属性都会被设置为 true。
if (cursor->up_match == n_unique) 条件成立,说明搜索条件能够唯一确定索引中的一条记录,使用 up_match 作为新构造信息的字段数量,构造信息计数(info->n_hash_potential)重置为 1,重新开始计数。
否则,剩下两种情况,取下界(因为比上界小)作为新构造信息。
cursor->low_match、low_bytes 都从 0 开始,变成数量时需要加 1。
如果 cmp 小于 0,命中 else_1,说明本次定位记录时,下界大于上界,构造信息(prefix_info)的 left_side 属性都会被设置为 false。
if (cursor->low_match == n_unique) 条件成立,说明搜索条件能够唯一确定索引中的一条记录,使用 low_match 作为新构造信息的字段数量,构造信息计数(info->n_hash_potential)重置为 1,重新开始计数。
否则,剩下两种情况,取上界(因为比下界小)作为新构造信息。
cursor->up_match、up_bytes 都从 0 开始,变成数量时需要加 1。
(3)数据页计数
btr_search_update_block_hash_info() 的主要逻辑分为 2 段:
- 第 1 段:更新数据页计数。
- 第 2 段:根据构造信息计数、数据页计数,决定是否需要为 block 对应的数据页构造或重新构造 AHI。
先来看第 1 段代码:
如果以下 3 个条件都满足:
- 数据页计数(block->n_hash_helps)大于 0。
- 构造信息计数(info->n_hash_potential)大于 0。
- 数据页对象中保存的构造信息(block->ahi.recommended_prefix_info)和本次确定的构造信息相同。
说明数据页的构造信息没有变化,数据页计数加 1(block->n_hash_helps++)。
否则,数据页计数重置为 1,并且保存新的构造信息到数据页对象中(block->ahi.recommended_prefix_info)。
第 2 段代码,用于判断是否需要为数据页(block)构造或重新构造 AHI,满足以下两个条件,说明具备了为该数据页构造 AHI 的资格:
- 构造信息计数(info->n_hash_potential)大于等于 100。
- 数据页计数(block->n_hash_helps)大于数据页中记录数量的十六分之一。
数据页(block)具备构造 AHI 的资格之后,只有以下三种情况会构造或重新构造 AHI:
- 该数据页之前没有构造过 AHI(!block->ahi.index 为 true)。
- 该数据页之前构造过 AHI,构造信息没有发生变化,但是数据页计数大于数据页中记录数量的两倍(2 * page_get_n_recs(block->frame))。
- 该数据页之前构造过 AHI,但是构造信息变了。
(4)构造 AHI
满足前面一系列条件之后,就可以为数据页构造 AHI 了。
为数据页构造 AHI 主要分为三大步骤:
- 循环读取数据页中的记录,每读取一条记录,根据 AHI 构造信息计算记录的哈希值,把哈希值保存到 hashes 数组中、记录地址保存到 recs 数组中,一条记录在 hashs、recs 数组中的下标一样,都是 n_cached。这一步有个特殊逻辑需要处理,就是对于前缀相同的一组记录,根据 left_side 决定为第一条还是最后一条记录构造 AHI。
- 数据页计数(block->n_hash_helps)重置为 0,重新开始计数,用于为该数据页重新构造 AHI 作准备。然后,把构造信息(prefix_info)、数据页所属的索引(index)分别保存到数据页对象的 ahi.prefix_info、ahi.index 属性中,btr_search_update_block_hash_info() 会用到这两个属性。
- 把 hashs、recs 数组中的哈希值、记录地址一一对应的插入到哈希表中,每条记录的哈希值映射到该记录的地址。
4、总结
AHI 构造流程的前三步都是在判断是否满足某些条件,这些条件的范围从大到小。
先是索引级别,判断索引被命中的次数。
然后,是索引级别的构造信息计数。
构造信息来源于定位记录过程中产生的下界、上界,其源头是 where 条件,我们可以把它看成对 where 条件的抽象,或者更具体点,把它看成 where 条件的分类。
某个构造信息的计数达到指定次数,意味着如果根据这个构造信息(或者说这类 where 条件)构造 AHI,命中率会比较高。
InnoDB 以数据页为单位,一次性为某个数据页中的所有记录构造 AHI。
构造信息计数满足条件之后,还需要进一步决定为哪些数据页构造 AHI,于是就有了数据页计数(实际上是数据页级别的构造信息计数)。
当索引计数、构造信息计数、数据页计数都满足条件之后,某个数据页就初步具备了构造 AHI 的资格,最后,还会根据该数据页是否构造过 AHI、构造信息是否发生变化等条件,做出终极决定:是否为该数据页构造 AHI。
本文转载自微信公众号「一树一溪」,可以通过以下二维码关注。转载本文请联系一树一溪公众号。