比B+Tree更快的查询结构!!!

5,626 阅读8分钟

导读

我们都知道MySQL中的B+Tree索引结构,对于根据某个条件查找记录是非常快的。那么,在不断追求极致的驱动下,你有没有想过MySQL会不会有比B+Tree更快的数据结构,来加速查找记录的性能呢?答案是有的,MySQL为了让我们更快地获取自己想查找的记录,在InnoDB中,将查询频繁的条件和索引树结果做了一个Hash映射,这样,一个查询就不需要每次搜索B+Tree去定位结果了,这个Hash映射就叫做AHI,全称Adaptive Hash Index,自适应哈希索引

一听这名字,你或许已经猜出个一二了。没错!它其实就是一个HashTable,在大学学习《数据结构》的时候,我们都知道Hash Table在查找其中的一个节点的数据是非常快的,算法时间复杂度O(1),所以,相比B+Tree而言,它的查找性能一定是更快的。

但是,有个问题:为什么这个Hash Table叫做自适应哈希索引呢,这个“自适应”是什么概念?

今天,小k就从下面这个案例开始,详细讲解AHI,逐步带你明白AHI这个自适应是怎么一回事?

假设我们交友平台有个功能:筛选出年龄在15到23之间的用户。那么,通常我们会用下面这条SQL实现:

SELECT id, age, sex FROM user WHERE age >= 15 AND age <= 23

同时,我们给user表建了一个索引index_age_sex(age,sex),那么,现在我们再来看看这条SQL是如何使用AHI的?

AHI

既然AHI也是一个HashTable,那首先,你肯定会关心,它的Key是什么样的,Value又是什么样的?那么,我们就先来看看AHI的Key和Value。

我们看到《导读》中的语句的查询条件为age >= 15 AND age <= 23,按照上面我说的AHI的含义:将某一个查询条件和其结果做了一个Hash映射,那么,我们想象中的这个HashTable就类似下面这样:

image.png

图中上面的age >= 15 AND age <= 23代表查询条件,也就Key,下面为索引index_age_sex中满足查询条件的4条记录,也就是Value。其中,每条记录的结构为[age,sex,id]

Key

但是,从上面的图来看,如果查询条件的字段名很长,那么,Key存储的空间也就变得很大,对于MySQL这种内存敏感的系统而言肯定是不能接受的,因此,MySQL设计了下面的这种结构来存放Key:

image.png

如上图为查找索引index_age_sex时,查询条件为age >= 15 AND age <= 23的结构:

  • search_info::n_fields:MySQL使用n_fields来表达查询索引使用到的字段,图中1表示查询条件使用到了索引index_age_sex中的第一个字段,即age。(PS:如果n_fields=2表示查询条件使用到了索引index_age_sex中的agesex两个字段)。这样做的好处是我们在内存中存储数字就可以表达一个查询条件使用到的索引字段了,更节省存储空间。

  • dtuple_t:由于查询条件是一个范围查询,所以,MySQL使用两个dtuple_t结构来表示条件中的两个边界值。如上图,右边第一个dtuple_t中的15表示查询条件左边界值15,第二个dtuple_t中的23表示查询条件右边界值23

最终,MySQL通过两个search_info::n_fieldsdtuple_t的组合来表达查询条件age >= 15 AND age <= 23。如上图中的两个箭头表示的就是这种组合。

讲完Key,我们再来看看MySQL是如何设计HashTable的Value的?

Value

当然,如果按照上面的HashTable的结构,我们肯定认为查询条件age >= 15 AND age <= 23,其在HashTable中的Value就是上面图中1-1中的下面的记录。但是,我们现在来看下面一个场景:

假设现在我将查询条件变为age >= 15 AND age < 16,那么,这个HashTable就变成这样:

image.png

图中上面的age >= 15 AND age < 16代表查询条件,下面为索引index_age_sex中满足查询条件的2条记录,其中,每条记录的结构为[age,sex,id]

通过对比1-1和1-2-1上面两张图,我们发现2个查询条件对应的查询结果中有重复记录15,0,215,0,5。现在只有2个查询条件会出现重复记录,那么,如果将来有几十个,甚至上百个查询条件都包含重复记录,那么,如果每个条件和对应结果都存一份HashTable,是不是在存储空间上就很浪费了?

因此,为了节省查询结果的存储空间,我们可以将上面2个查询HashTable合并,变成下面这样的结构:

image.png

图中MySQL将条件age >= 15 AND age <= 23和条件age >= 15 AND age < 16对应的记录合并为4条:

  • 条件age >= 15 AND age <= 23映射前两条记录。如上图绿色箭头。
  • 条件age >= 15 AND age < 16映射后两条记录。如上图红色箭头。

但是,在讲述Key的结构时,我说了MySQL真实设计的Key结构如图1-1-1,对应到图1-2-2,显然图1-2-2中的Key不是MySQL真实存储的结构。那么,结合1-2-2的HashTable概念图,我们来看下MySQL到底是如何设计AHI的Key和Value的映射的?

image.png

如上图,是MySQL完整的AHI的存储结构。其中,Value上面的部分就是Key,上面我已经讲解过Key的结构,这里我就不再重述了。我们主要看下Value部分:

  • Cell:在AHI中叫做hash_cell_thash_cell_tuple的缩写。也就是图中的cell这部分。它是一个数组,如上图是一个包含2个cell的数组。每个查询条件的边界值通过hash运算可以定位到某一个cell。比如:

    • 图中条件age >= 15 AND age <= 23中的左边界值15,通过hash运算定位到了第1个cell。
    • 图中条件age >= 15 AND age < 16中的左边界值15,通过hash运算也定位到了第1个cell。
    • 图中条件age >= 15 AND age <= 23中的右边界值23,通过hash运算也定位到了第1个cell。
    • 图中条件age >= 15 AND age < 16中的右边界值16,通过hash运算定位到了第2个cell。
  • Node:在AHI中叫做ha_node_thash_node_tuple的缩写。一个cell下可以包含多个node,也就是说多个查询条件边界值通过hash运算可以定位到一个cell,该cell下就存放了每个边界值对应的node记录,该cell下的每个node还组成了一个单向链表。

    • 比如,图中查询条件age >= 15 AND age <= 23中的左边界值15,通过hash运算,定位到第1个cell,该cell下的第1个node保存了15对应的记录(15,0,2)相关信息。

    • 同理,图中查询条件age >= 15 AND age <= 23中的右边界值23,通过hash运算,也定位到第1个cell,该cell下的第2个node保存了23对应的记录相关信息。

    • 同理,图中查询条件age >= 15 AND age < 16中的右边界值16,通过hash运算,定位到第2个cell,该cell下的第1个node保存了16对应的记录(16,0,3)相关信息。

    • 这两个node组成了一个单向链表。

    Node核心元素主要是3个:

    • block:存储hash映射的结果对应的相关信息。其中,核心元素包含left_sidepage

      • 比如,图中左边block里的curr_left_side = true,表示该node中的记录<15,0,2>是查询条件age >= 15 AND age <= 23age >= 15 AND age < 16的最左边界记录。
      • 比如,图中左边block中的page(10)表示该node中的记录<15,0,2>在索引树index_age_sex的10号叶子节点内。
      • 比如,图中右边block里的curr_left_side = false,表示该node中的记录<16,0,3>是查询条件age >= 15 AND age < 16的最右边界记录。
      • 比如,图中右边block中的page(20)表示该node中的记录<16,0,3>在索引树index_age_sex的20号叶子节点内。
    • data:hash映射的结果。

      • 比如,图中第1个node中的<15,0,2>为条件age >= 15 AND age <= 23age >= 15 AND age < 16左边界值15对应的记录。
      • 比如,图中第3个node中的<16,0,3>为条件age >= 15 AND age < 16右边界值16对应的记录。

现在我们知道了AHI的完整结构,通过这个结构,我们发现MySQL没有直接将查询条件和结果做了映射,而是通过cell将条件和结果关联起来,这样做的好处就是相同条件边界值对应的node在内存中可以共享,节省了存储空间。

查询AHI

说了那么多,是不是发现好像这个AHI结构并没有完整存储查询条件对应的所有结果记录,(毕竟我要的可是4条满足条件的记录哦!),那MySQL又是怎么通过AHI找到所有满足条件的记录呢?下面我们就以age >= 15 AND age < 16这个查询条件为例,来看一下这个查找过程:

image.png

  1. 根据条件左边界值15,做hash运算,计算得到一个fold值,通过该值定位到第1个cell。

  2. 遍历第1个cell下的node,找到第1个node为边界值15对应的node。

  3. 根据第1个node找到对应的记录<15,0,2>page(10)curr_left_side=true

  4. 根据上一步得到的page编号10和记录<15,0,2>,在索引树index_age_sex中的10号叶子节点中找匹配<15,0,2>的记录<15,0,2>

  5. 根据条件右边界值16,做hash运算,计算得到一个fold值,通过该值定位到第2个cell。

  6. 遍历第2个cell下的node,找到第1个node为边界值16对应的node。

  7. 根据第1个node找到对应的记录<16,0,3>page(11)curr_left_side=false

  8. 根据上一步得到的page编号11和记录<16,0,3>,在索引树index_age_sex的11号叶子节点中找到匹配<16,0,3>的记录<16,0,3>

  9. 由于第3步中记录<15,0,2>所在node中curr_left_side=true,说明记录<15,0,2>为查询条件最左记录,因此,从索引树index_age_sex的10号叶子节点内<15,0,2>记录开始,向后遍历其他记录。

  10. 由于第7步中记录<16,0,3>所在node中curr_left_side=false,说明记录<16,0,3>为查询条件最右记录,故上一步遍历到记录<16,0,3>结束。

  11. 最终,在索引树index_age_sex中找到所有满足条件age >= 15 AND age < 16的记录。

其中,第4,8 ~ 11步的细节过程,可以参考文章《InnoDB是顺序查找B+Tree叶子节点的吗?》

构建AHI时机

现在我们知道了MySQL如何通过AHI找到满足条件的记录了,那么,这个AHI又是在什么时候创建的,如何创建的呢?

在《导读》中我讲过,MySQL对使用频繁的查询条件才构建AHI,即条件与结果的映射关系。因此,我们就要看看MySQL是如何判断这个查询条件是否频繁使用的?

为了统计一个条件使用的频率,MySQL设计了下面这样一种结构。

image.png

是不是有点眼熟?其实,图中search_info就是查询信息的结构,在图1-1-1中,我讲了search_info中的一个属性n_fields,现在,我再讲另一个属性hash_analysis

当一次查询成功后,MySQL通过累加该属性,记录该次查询成功的次数。比如,初始hash_analysis=0,那么,条件age >= 15 AND age < 16查询成功一次,hash_analysis + 1 = 1,再成功一次,hash_analysis + 1 = 2,依次类推,成功多少次,hash_analysis就是多少。

hash_analysis值超过17时,MySQL就会对该查询构建AHI。

但是,查询成功,就一定能够构建AHI吗?答案是不一定!我们来看下面这个场景:

SELECT age, sex FROM user WHERE age >= 15 AND age <= 18

上面这条语句,MySQL在索引index_age_sex中的叶子节点找到满足条件的记录为下面4条:

<15,0,2><16,0,3><18,0,4><18,0,5>

这时候,我们再看下这个条件查找AHI的过程:

image.png

  1. 根据条件左边界值15,做hash运算,计算得到一个fold值,通过该值定位到第1个cell。

  2. 遍历第1个cell下的node,找到第1个node为边界值15对应的node。

  3. 根据得到的node找到对应的记录<15,0>page(10)curr_left_side=true

  4. 根据上一步得到的page编号10和记录,在索引树index_age_sex中10号叶子节点中找到匹配node记录<15,0>的第一条记录<15,0,2>

  5. 根据条件右边界值18,做hash运算,计算得到一个fold值,通过该值定位到第2个cell。

  6. 遍历第2个cell下的node,找到第1个node为边界值18对应的node。

  7. 根据得到的node找到对应的记录<18,0>page(11)curr_left_side=false

  8. 根据上一步得到的page编号11和记录,在索引树index_age_sex11号叶子节点中找到匹配node记录<18,0>的第一条记录<18,0,4>

  9. 由于第3步中记录<15,0>所在node中curr_left_side=true,说明记录<15,0,2>为查询条件最左记录,因此,从索引树index_age_sex的10号叶子节点内<15,0,2>记录开始,向后遍历其他记录。

  10. 由于第7步中记录<18,0>所在node中curr_left_side=false,说明记录<18,0,4>为查询条件最右记录,故上一步遍历到记录<18,0,4>结束。

其中,第4,8 ~ 10步的细节过程,可以参考文章《InnoDB是顺序查找B+Tree叶子节点的吗?》

从上面的过程,我们发现一个问题:明明11号叶子节点中的记录<18,0,5>也满足条件age >= 15 AND age <= 18,但是,AHI查询却忽略这条记录。如上图,虚线标出的记录。PS:上面第9步,现在就2条件满足右边界值18的记录,如果满足的记录超过1k、1w条呢?让MySQL遍历1k、1w次叶子节点记录来定位最大记录嘛?这个性能可想而知。。。

因此,我们发现这类查询是不能支持AHI的,我们不能简单地认为只要查询成功,就等于可以构建AHI。

为此,MySQL在search_info中引入了一个新的属性,我们来看下:

image.png

如上图中的n_hash_potential就是这个新属性,它表示一次查询潜在可以成功构建AHI的次数。用它来解决上面那个场景的问题:

只有查询得到的结果中,最大的记录中的select字段值(比如:select age,sex)唯一,n_hash_potential才会累加。

这样一来,MySQL就在真正构建AHI之前做了两次拦截:

  • 通过hash_analysis将该属性值小于17的查询拦截,只有该属性值大于等17,这次查询才能构建AHI
  • 如果hash_analysis大于等于17,那么,再检查n_hash_potential属性,如果该属性值小于100,查询拦截,反之,这次查询才能构建AHI

那么,下一个问题来了:既然我都已经知道上面那个场景是不可能构建AHI的,我为什么还要让查询处理进入上面两次拦截检查呢?

因此,为了避免进入上面的拦截检查,MySQL又在search_info中引入了一个属性:

image.png

图中last_hash_succ属性,它表示上一次是否成功构建AHI。

有了这个属性,MySQL只要发现上面这个场景压根er不能构建AHI,因此,直接就设置last_hash_succ=false,那么,在下次相同查询进来后,直接发现last_hash_succ=false,就不再进行后面两次的拦截检查。

通过上面的分析,我们就得出了一次查询触发AHI构建的检查过程:

  1. 如果last_hash_succ=false,该查询不能构建AHI,反之进入下一步检查

  2. 如果hash_analysis < 17,该查询不能构建AHI,反之进入下一步检查

  3. 如果n_hash_potential < 100,该查询不能构建AHI,反之可以构建AHI

构建AHI

讲完AHI构建的触发条件,我们最后来看看MySQL是如何构建AHI的?

通过《查询AHI》部分的讲解,我们知道查询AHI的过程中,AHI中的Node中包含几个核心元素blockleft_sidepage,因此,我们只要知道这几个核心元素是如何构建的,也就能够描述清楚AHI的构建过程了。

我以下面这条语句为例,看下AHI构建的过程:

SELECT id, age, sex FROM user WHERE age >= 15 AND age <= 18

image.png

关注图中红线部分:

  1. 根据条件左边界值15,在索引树index_age_sex中的10号叶子节点中找到满足边界值的第一条记录<15,0,2>

  2. 由于找到满足左边界值15的记录只有一条,因此,MySQL将up_match + 1 = 1,表示只有一条记录满足左边界值。由于up_match > low_match,因此,search_info中的left_side设置为true。

  3. 根据条件左边界值15,对其做hash运算,定位到AHI中的第1个cell。

  4. 发现cell中没有node节点,创建一个node。即图中灰色的node节点。

  5. 在node中创建一个block。如上图浅蓝色的block。

  6. 将索引树index_age_sex中的10号叶子节点信息写入block中的page属性。

  7. 将第2步得到的left_side写入block中的curr_left_side

  8. 将第1步得到的记录<15,0,2>写入node。

  9. 同理,条件右边界值18构建AHI的过程相同。

AHI锁

了解完AHI的构建过程后,我们进一步会想,如果并发构建AHI,会出现node覆盖的问题。因此,为了解决这个问题,MySQL就必须给AHI加一把锁,避免并发构建时产生node覆盖的问题。

当然,我们不能给整个AHI加全局锁吧,因为这样会非常影响查询的性能,因此,MySQL是这样设计锁的。

image.png

MySQL在启动时,从innodb_buffer_pool中划分出若干个Hash Table,作为AHI,图中,我画了2个HashTable:HashTable[0]和HashTable[1]。假设MySQL通过4个查询条件hash运算得到4个fold,如上图4个fold值分别为1、2、9和17。

那么,MySQL对AHI加锁的方式为fold % 8取模:

  • 1和9取模后,得到0,因此,这两个fold对应的查询在HashTable[0]中构建AHI,同时,加同一把锁Lock0。
  • 2和17取模后,得到1,因此,这两个fold对应的查询在HashTable[1]中构建AHI,同时,加同一把锁Lock1。

通过这种方式,MySQL就可以将锁分散加在不同的HashTable上,尽可能减少并发导致的HashTable构建锁死造成的性能问题。

AHI调优

MySQL是默认开启AHI的,既然我们知道MySQL通过fold取模将AHI锁打散到多个HashTable上,也就意味着打散后的HashTable越多,AHI锁就打得更散,锁的粒度就更细,并发查询后构建AHI的性能就更好。

因此,MySQL给我们留了一个参数,用来将锁粒度打得更细,这个参数叫做innodb_adaptive_hash_index_parts:HashTable分片个数,默认为8。

我们只需要执行下面的语句就可以调大这个参数,将锁粒度打散得更细:

set global innodb_adaptive_hash_index_parts=16;

小结

本章中,小k详细讲解AHI的结构、查询、构建、加锁等原理,同时,提供了AHI参数调优的方法。

现在回答文章开头的问题:为什么MySQL把这个HashTable叫做自适应哈希索引呢?

通过AHI构建的过程,我们发现,多个查询构建cell中的node,是可以变大或缩小的,正是这个原因,MySQL才把这样一个HashTable叫做AHI,即自适应哈希索引。

思考题

最后留一个思考题:在文中《构建AHI》部分中,我有提到up_matchlow_match属性,我们明明可以通过查询条件是><来判断left_side为true还是false,为什么还要通过up_matchlow_match来判断呢?

提示:结合索引多列查询场景思考一下。

最后,小k努力把艰深的技术讲透讲俗,哪怕你不是这个领域的,也希望你有所收获,如果你觉得文章还不错,记得点赞、关注哦~~

如果有看不懂的地方,欢迎在评论区提问哦~