
曾经优化慢查询时,适应索引经常在日志中看到 truncate,哈希当时一直疑惑 truncate 为什么会慢。构造
转到数据库方向之后,适应索引又碰到过几次 truncate 执行时间过长,哈希导致 MySQL 短暂卡住的构造问题。
经过源码分析和同事测试验证,适应索引发现这几次的哈希问题都跟自适应哈希索引有关,所以,构造深入研究下自适应哈希索引就很有必要了。适应索引
自适应哈希索引大概会有 3 篇文章。哈希第 1 篇,构造我们来看看自适应哈希索引的适应索引构造过程。
本文基于 MySQL 8.0.32 源码,哈希存储引擎为 InnoDB。构造
创建测试表:
复制CREATE TABLE `t1` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `i1` int DEFAULT 0, PRIMARY KEY (`id`) USING BTREE, KEY `idx_i1` (`i1`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb31.2.3.4.5.6.插入测试数据:
复制INSERT INTO `t1`(`id`, `i1`) VALUES (10, 101), (20, 201), (30, 301);1.2.示例 SQL:
复制SELECT * FROM `t1` WHERE `i1` >= 101 AND `i1` < 3011.2.自适应哈希索引,英文全称 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 定位一条记录时,云南idc服务商根据 where 条件中的某些字段计算出哈希值,然后直接到 hash 桶中找到对应的记录。
如果多条记录被映射到 hash 桶中的同一个位置,那么找到的是个链表,需要遍历这个链表以找到目标记录。
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 的主要流程就介绍完了,构造过程的具体细节,请继续往下看。
定位记录会调用 btr_cur_search_to_nth_level(),这也是 AHI 的构造入口:
复制// storage/innobase/btr/btr0cur.cc void btr_cur_search_to_nth_level(...) { ... if (btr_search_enabled && !index->disable_ahi) { // AHI 的构造入口 btr_search_info_update(cursor); } ... }1.2.3.4.5.6.7.8.9.10.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 的构造流程。
构造 AHI 的第 1 步,就是调用 btr_search_info_update() 进行索引计数。
复制// storage/innobase/include/btr0sea.ic static inline void btr_search_info_update(btr_cur_t *cursor) { const auto index = cursor->index; ... // 索引计数加 1 const auto hash_analysis_value = ++index->search_info->hash_analysis; // BTR_SEARCH_HASH_ANALYSIS = 17(硬编码) if (hash_analysis_value < BTR_SEARCH_HASH_ANALYSIS) { /* Do nothing */ return; } ... btr_search_info_update_slow(cursor); }1.2.3.4.5.6.7.8.9.10.11.12.13.14.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 如下:
复制SELECT * FROM `t1` WHERE `i1` >= 101 AND `i1` < 3011.2.执行计划如下:
图片
通过执行计划可知,示例 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 次。
如果某个索引的计数达到了 17,就会进入 AHI 构造流程的第 2 步,根据本次定位记录过程中得到的下界和上界,确定使用索引的前几个字段构造 AHI,以及对于索引中前几个字段值相同的一组记录,构造 AHI 时选择这组记录的第一条还是最后一条。
3.原理介绍小节的第 2 步,我们已经把这些信息命名为构造信息。
基于定位记录时得到的下界和上界确定构造信息、为构造信息计数的逻辑由 btr_search_info_update_hash() 完成。
复制// storage/innobase/btr/btr0sea.cc void btr_search_info_update_slow(btr_cur_t *cursor) { ... const auto block = btr_cur_get_block(cursor); ... btr_search_info_update_hash(cursor); ... }1.2.3.4.5.6.7.8.btr_search_info_update_hash() 的代码有点长,我们分 2 段介绍。
介绍第 1 段代码之前,我们先来看看表示构造信息的结构体定义(index->search_info->prefix_info):
复制// storage/innobase/include/buf0buf.h // 为了方便阅读,以下结构体定义对源码做了删减 struct btr_search_prefix_info_t { /** recommended prefix: number of bytes in an incomplete field */ uint32_t n_bytes; /** recommended prefix length for hash search: number of full fields */ uint16_t n_fields; /** true or false, depending on whether the leftmost record of several records with the same prefix should be indexed in the hash index */ bool left_side; }1.2.3.4.5.6.7.8.9.10.11.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 段代码逻辑。
复制// storage/innobase/btr/btr0sea.cc /(责任编辑:系统运维)