服务器之家:专注于服务器技术及软件下载分享
分类导航

Mysql|Sql Server|Oracle|Redis|MongoDB|PostgreSQL|Sqlite|DB2|mariadb|Access|数据库技术|

服务器之家 - 数据库 - Mysql - MySQL 自适应哈希索引—构造

MySQL 自适应哈希索引—构造

2023-08-01 03:44未知服务器之家 Mysql

曾经优化慢查询时,经常在日志中看到 truncate,当时一直疑惑 truncate 为什么会慢。 转到数据库方向之后,又碰到过几次 truncate 执行时间过长,导致 MySQL 短暂卡住的问题。 经过源码分析和同事测试验证,发现这几次的问题都跟自

MySQL 自适应哈希索引—构造

曾经优化慢查询时,经常在日志中看到 truncate,当时一直疑惑 truncate 为什么会慢。

转到数据库方向之后,又碰到过几次 truncate 执行时间过长,导致 MySQL 短暂卡住的问题。

经过源码分析和同事测试验证,发现这几次的问题都跟自适应哈希索引有关,所以,深入研究下自适应哈希索引就很有必要了。

自适应哈希索引大概会有 3 篇文章。第 1 篇,我们来看看自适应哈希索引的构造过程。

本文基于 MySQL 8.0.32 源码,存储引擎为 InnoDB。

1、准备工作

创建测试表:

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=utf8mb3

插入测试数据:

INSERT INTO `t1`(`id`, `i1`)
VALUES (10, 101), (20, 201), (30, 301);

示例 SQL:

SELECT * FROM `t1`
WHERE `i1` >= 101 AND `i1` < 301

2、AHI 有什么好处?

自适应哈希索引,英文全称 Adaptive Hash Index,简称 AHI。

根据上下文需要,后续内容会混用自适应哈希索引和 AHI,不再单独说明。

顾名思义,自适应哈希索引也是一种索引,使用哈希表作为存储结构,自适应的意思是我们无法干涉它的创建、使用、更新、删除,而是由 InnoDB 自行决定什么时候创建、怎么创建、什么时候更新和删除。

我们知道 InnoDB 的主键索引、二级索引都是 B+ 树结构。执行 SQL 语句时,以下几种场景都需要定位到索引叶子结点中的某条记录:

  • insert 需要定位到插入目标位置的前一条记录。
  • 查询优化阶段,select、update、delete 预估扫描区间记录数量时,需要定位到扫描区间的第一条最后一条记录。
  • 查询执行阶段,select、update、delete 需要定位到扫描区间的第一条记录。

得益于多叉结构,B+ 树的层级一般都比较少,通常情况下,从根结点开始,最多经过 3 ~ 4 层就能定位到叶子结点中的记录。

这个定位过程看起来挺快的,单次执行也确实比较快,但是对于频繁执行的场景,还是有一点优化空间的。

为了追求极致性能,InnoDB 实现了 AHI,目的就是能够根据搜索条件直接定位到索引叶子结点中的记录。

MySQL 自适应哈希索引—构造图片

上图是一棵高为 4 的 B+ 树示意图,定位索引叶子结点记录,需要从根结点开始,经过内结点、叶结点,最后定位到记录,路径为:① -> ② -> ③ -> ④

MySQL 自适应哈希索引—构造图片

AHI 会使用多个 hash 桶来存储哈希值到记录地址的映射,上图是一个 hash 桶的示意图。

橙色块表示记录地址组成的链表,因为多条记录可能被映射到 hash 桶中的同一个位置。

AHI 定位一条记录时,根据 where 条件中的某些字段计算出哈希值,然后直接到 hash 桶中找到对应的记录。

如果多条记录被映射到 hash 桶中的同一个位置,那么找到的是个链表,需要遍历这个链表以找到目标记录。

3、原理介绍

AHI 能提升定位记录的效率,但是,有得到必然有付出,天上掉馅饼的事在计算机世界里是不会发生的。

为了简洁,这里我们把定位索引叶子结点中的记录简称为定位记录,后面内容也依此定义,不再单独说明。

高效定位记录,付出的代价是存储哈希值到记录地址的映射占用的内存空间、以及构造映射花费的时间。

因为有时间、空间成本,所以,InnoDB 希望构造 AHI 之后,能够尽可能多的用上,做到收益大于成本。直白点说,就是需要满足一定的条件,才能构造 AHI。

AHI 采用的是组团构造逻辑,也就是以数据页为单位,满足一定条件之后,就会为数据页中的所有记录构造 AHI,主要流程如下:

MySQL 自适应哈希索引—构造图片

第 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 的信息,我们称之为构造信息。

MySQL 自适应哈希索引—构造图片

以上是定位记录时产生的下界、上界示意图。定位记录过程中,下界和上界会不断向目标记录靠近,最终,下界或上界的其中一个会指向目标记录。

如果某次定位记录时,基于下界或上界得到的构造信息,和索引对象中保存的构造信息一致,该构造信息计数加 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 的构造入口:

// 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);
  }
  ...
}

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() 进行索引计数。

// 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);
}

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` < 301

执行计划如下:

MySQL 自适应哈希索引—构造图片

通过执行计划可知,示例 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() 完成。

// 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);
  ...
}

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;
}

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
/****** 第 1 段 ******/
static void btr_search_info_update_hash(btr_cur_t *cursor) {
  dict_index_t *index = cursor->index;
  int cmp;
  ...
  // 索引中,通过几个字段能唯一确定一条记录?
  // 对于主键索引,n_unique = 主键段字数量
  // 对于二级索引,n_unique = 二级索引字段数量 + 主键字段数量
  const uint16_t n_unique =
      static_cast<uint16_t>(dict_index_get_n_unique_in_tree(index));
  const auto info = index->search_info;
  
  /****** if_1 ******/
  // 构造信息计数不等于 0
  // 说明之前已经确定过构造信息了
  if (info->n_hash_potential != 0) {
    // info->prefix_info 中
    // 保存了之前确定的构造信息
    const auto prefix_info = info->prefix_info.load();
    ...

    /****** if_2 ******/
    // prefix_info.n_fields
    //   表示之前确定的构造信息的字段数量
    // std::max(up_match, low_match)
    //   表示下界或上界字段数量中较大的那个
    if (prefix_info.n_fields == n_unique &&
        std::max(cursor->up_match, cursor->low_match) == n_unique) {
      // 两个条件都满足,说明:
      //   如果本次通过下界、上界确定构造信息
      //   会和之前确定的构造信息相同
      //   那么,构造信息计数加 1
      info->n_hash_potential++;

      return;
    }
    ...
    const bool low_matches_prefix =
        0 >= ut_pair_cmp(prefix_info.n_fields, prefix_info.n_bytes,
                         cursor->low_match, cursor->low_bytes);
    const bool up_matches_prefix =
        0 >= ut_pair_cmp(prefix_info.n_fields, prefix_info.n_bytes,
                         cursor->up_match, cursor->up_bytes);
    /****** if_3 ******/
    // 这里的构造信息指的是:
    //   索引对象中保存的之前确定的构造信息
    // prefix_info.left_side = true
    //   如果构造信息【大于】下界,且【小于等于】上界
    //   构造信息计数(n_hash_potential)加 1
    // prefix_info.left_side = false
    //   如果构造信息【小于等于】下界,且【大于】上界
    //   构造信息计数(n_hash_potential)加 1
    if (prefix_info.left_side ? (!low_matches_prefix && up_matches_prefix)
                              : (low_matches_prefix && !up_matches_prefix)) {
      info->n_hash_potential++;
      return;
    }
  }
  ...
}

如果构造信息计数(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 段代码,确定新的构造信息,并且重置构造信息计数。

// storage/innobase/btr/btr0sea.cc
/****** 第 2 段 ******/
static void btr_search_info_update_hash(btr_cur_t *cursor) {
  ...
  info->hash_analysis = 0;

  cmp = ut_pair_cmp(cursor->up_match, cursor->up_bytes, cursor->low_match,
                    cursor->low_bytes);
  /****** if_4 ******/
  if (cmp == 0) {
    // where 条件没有定位到匹配的记录
    // 构造信息计数清零
    info->n_hash_potential = 0;
    // 虽然给构造信息赋值了,但是这个信息不会被使用
    /* For extra safety, we set some sensible values here */
    info->prefix_info = {0, 1, true};

  /****** elseif_5 ******/
  } else if (cmp > 0) {
    // 上界(up_match、up_bytes)大于下界(low_match、low_bytes)
    // left_side 都会设置为 true
    // 构造信息计数重置为 1
    info->n_hash_potential = 1;

    ut_ad(cursor->up_match <= n_unique);
    // 如果上界字段数量等于 n_unique
    // 使用上界作为新的构造信息
    if (cursor->up_match == n_unique) {
      info->prefix_info = {
        /* n_bytes   */ 0,
        /* n_fields  */ n_unique,
        /* left_side */ true
      };

    // 下界字段数量(low_match)小于上界字段数量(up_match)
    // 使用下界作为新的构造信息
    } else if (cursor->low_match < cursor->up_match) {
      info->prefix_info = {
        /* n_bytes   */ 0,
        /* n_fields  */ static_cast<uint16_t>(cursor->low_match + 1),
        /* left_side */ true
      };

    // 下界字段数量(low_match)等于上界字段数量(up_match)
    // 下界字节数(low_bytes)小于上界字节数(up_bytes)
    // 使用下界作为新的构造信息
    } else {
      info->prefix_info = {
        /* n_bytes   */ static_cast<uint32_t>(cursor->low_bytes + 1),
        /* n_fields  */ static_cast<uint16_t>(cursor->low_match),
        /* left_side */ true
      };
    }

  /****** else_1 ******/
  } else {
    // 上界(up_match、up_bytes)小于下界(low_match、low_bytes)
    // left_side 都会设置为 false
    // 构造信息计数重置为 1
    info->n_hash_potential = 1;

    ut_ad(cursor->low_match <= n_unique);
    // 如果下界字段数量等于 n_unique
    // 使用下界作为新的构造信息
    if (cursor->low_match == n_unique) {
      info->prefix_info = {
        /* n_bytes   */ 0,
        /* n_fields  */ n_unique,
        /* left_side */ false
      };

    // 下界字段数量(low_match)大于上界字段数量(up_match)
    // 使用上界作为新的构造信息
    } else if (cursor->low_match > cursor->up_match) {
      info->prefix_info = {
        /* n_bytes   */ 0,
        /* n_fields  */ static_cast<uint16_t>(cursor->up_match + 1),
        /* left_side */ false
      };
    
    // 下界字段数量(low_match)等于上界字段数量(up_match)
    // 下界字节数(low_bytes)大于上界字节数(up_bytes)
    // 使用上界作为新的构造信息
    } else {
      info->prefix_info = {
        /* n_bytes   */ static_cast<uint32_t>(cursor->up_bytes + 1),
        /* n_fields  */ static_cast<uint16_t>(cursor->up_match),
        /* left_side */ false
      };
    }
  }
}

第 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 段代码:

// storage/innobase/btr/btr0sea.cc
/****** 第 1 段 ******/
static bool btr_search_update_block_hash_info(...) {
  ...
  const auto info = cursor->index->search_info;
  // last_hash_succ 用于判断 where 条件是否命中了 AHI
  // 先设置为 false
  info->last_hash_succ = false;
  ...
  // 数据页计数是否大于 0
  if (block->n_hash_helps > 0 && 
      // 构造信息计数是否大于 0
      info->n_hash_potential > 0 &&
      // 数据页对象中保存的构造信息
      //  (可能还没有用来构造过 AHI)
      // 和本次确定的构造信息是否相同
      block->ahi.recommended_prefix_info.load() == info->prefix_info.load()) {
    if (block->ahi.index &&
        block->ahi.prefix_info.load() == info->prefix_info.load()) {
      // 数据页对象中保存的构造信息(用来构造过 AHI)
      // 和本次确定的构造信息相同
      // 说明 where 条件命中了 AHI
      // 把 info->last_hash_succ 设置为 true
      // 下一篇文章讲 AHI 命中时会用到这个属性
      info->last_hash_succ = true;
    }
    // 构造信息没有变化,构造信息计数加 1
    block->n_hash_helps++;
  } else {
    // 构造信息变了,重置数据页计数
    block->n_hash_helps = 1;
    // 新确定的构造信息保存到数据页对象中
    block->ahi.recommended_prefix_info = info->prefix_info.load();
  }
  ...
}

如果以下 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)。

// storage/innobase/btr/btr0sea.cc
/****** 第 2 段 ******/
static bool btr_search_update_block_hash_info(...) {
  ...
  // BTR_SEARCH_BUILD_LIMIT = 100
  // 同一个索引的 AHI 构造信息
  // 连续 100 次相同
  if (info->n_hash_potential >= BTR_SEARCH_BUILD_LIMIT &&
      // BTR_SEARCH_PAGE_BUILD_LIMIT = 16
      // 同样的 AHI 构造信息
      // 命中同一个数据页的次数
      // 达到了该数据页中记录数量的十六分之一
      block->n_hash_helps >
          page_get_n_recs(block->frame) / BTR_SEARCH_PAGE_BUILD_LIMIT) {
    // 满足上面 2 个条件之后
    // 如果之前没有构造过 AHI,则返回 true
    // 表示本次需要构造 AHI
    if (!block->ahi.index ||
        // 如果之前已经构造过 AHI
        // 需要命中同一个数据页的次数
        //   达到该数据页中记录数量的 2 倍
        // 或者 AHI 构造信息发生了变化
        // 才会重新构造 AHI
        block->n_hash_helps > 2 * page_get_n_recs(block->frame) ||
        block->ahi.recommended_prefix_info.load() !=
            block->ahi.prefix_info.load()) {
      return true;
    }
  }

  // 不满足上面的一系列条件
  // 返回 false
  // 表示本次不需要构造 AHI
  return false;
}

第 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 了。

// storage/innobase/btr/btr0sea.cc
static void btr_search_build_page_hash_index(...) {
  ...
  // block 是本次需要构造 AHI 的数据页控制块
  // page 是数据页对象
  const auto page = buf_block_get_frame(block);
  // 数据页的 AHI 构造信息
  const auto prefix_info = block->ahi.recommended_prefix_info.load();
  const auto n_fields_for_offsets = btr_search_get_n_fields(prefix_info);

  // 删除之前为该数据页构造的 AHI 记录
  if (block->ahi.index && block->ahi.prefix_info.load() != prefix_info) {
    btr_search_drop_page_hash_index(block);
  }
  ...
  // 数据页中的记录数量
  const auto n_recs = page_get_n_recs(page);
  ...
  // page_get_infimum_rec(page) 读取 infimum 伪记录
  // page_rec_get_next() 读取数据页中的第 1 条用户记录
  auto rec = page_rec_get_next(page_get_infimum_rec(page));

  Rec_offsets offsets;
  ...
  // 用于构造第 1 条用户记录的 AHI 的 hash 种子
  const auto index_hash = btr_hash_seed_for_record(index);
  // 计算第 1 条用户记录的 AHI hash 值
  auto hash_value =
      rec_hash(rec, offsets.compute(rec, index, n_fields_for_offsets),
               prefix_info.n_fields, prefix_info.n_bytes, index_hash, index);

  size_t n_cached = 0;
  // left_side = true
  // 对于前缀相同的一组记录(可能有一条或多条记录)
  // 标记需要为这一组的第一条记录构造 AHI
  if (prefix_info.left_side) {
    hashes[n_cached] = hash_value;
    recs[n_cached] = rec;
    n_cached++;
  }

  // 循环,标记需要为数据页中第 2 条及以后的用户记录构造 AHI
  for (;;) {
    const auto next_rec = page_rec_get_next(rec);
    if (page_rec_is_supremum(next_rec)) {
      // left_side = false 时
      // 标记需要为 supremum 伪记录构造 AHI
      // 然后结束循环
      if (!prefix_info.left_side) {
        hashes[n_cached] = hash_value;
        recs[n_cached] = rec;
        n_cached++;
      }

      break;
    }
    // 为当前循环的记录计算用于构造 AHI 的 hash 值
    const auto next_hash_value = rec_hash(
        next_rec, offsets.compute(next_rec, index, n_fields_for_offsets),
        prefix_info.n_fields, prefix_info.n_bytes, index_hash, index);
    // hash_value != next_hash_value
    // 说明换了一组记录
    if (hash_value != next_hash_value) {
      /* Insert an entry into the hash index */
      // left_side = true
      // 为下一组的第一条记录构造 AHI
      if (prefix_info.left_side) {
        hashes[n_cached] = next_hash_value;
        recs[n_cached] = next_rec;
        n_cached++;
      } else {
        // left_side = false
        // 为本组的最后一条记录构造 AHI
        hashes[n_cached] = hash_value;
        recs[n_cached] = rec;
        n_cached++;
      }
    }

    rec = next_rec;
    hash_value = next_hash_value;
  }
  ...
  // 为数据页构造 AHI 之后
  // 重置数据页计数
  block->n_hash_helps = 0;
  // 已经用来构造过 AHI 构造信息保存到
  // 数据页对象的 ahi.prefix_info 属性中
  block->ahi.prefix_info = prefix_info;
  block->ahi.index = index;
  // 把每条记录的 AHI hash 值和记录地址
  // 插入到 AHI hash 表中
  const auto table = btr_get_search_table(index);
  for (size_t i = 0; i < n_cached; i++) {
    ha_insert_for_hash(table, hashes[i], block, recs[i]);
  }
  ...
}

为数据页构造 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。

本文转载自微信公众号「一树一溪」,可以通过以下二维码关注。转载本文请联系一树一溪公众号。

MySQL 自适应哈希索引—构造

延伸 · 阅读

精彩推荐