InnoDB索引底层原理

之前写过几篇关于mysql相关的文章,今天探讨一下索引底层原理。

慢sql优化思路及使用规范

Mysql引擎

MySQL优化策略

mysql多线程update死锁问题

Mysql表空间碎片释放

1 InnoDB 存储数据原理

MySQL 把数据存储和查询操作抽象成了存储引擎,不同的存储引擎,对数据的存储和读取方式各不相同。MySQL 支持多种存储引擎,并且可以以表为粒度设置存储引擎。因为支持事务,我们最常使用的是 InnoDB。

虽然数据保存在磁盘中,但其处理是在内存中进行的为了减少磁盘随机读取次数,InnoDB 采用页而不是行的粒度来保存数据,即数据被分成若干页,以页为单位保存在磁盘中。InnoDB 的页大小,一般是 16KB。

各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表;每一个数据页中有一个页目录,方便按照主键查询记录。数据页的结构如下:

image.png

页目录通过槽把记录分成不同的小组,每个小组有若干条记录。如图所示,记录中最前面的小方块中的数字,代表的是当前分组的记录条数,最小和最大的槽指向 2 个特殊的伪记录。有了槽之后,我们按照主键搜索页中记录时,就可以采用二分法快速搜索,无需从最小记录开始遍历整个页中的记录链表。

举一个例子,如果要搜索主键(PK)=15 的记录:

  • 先二分得出槽中间位是 (0+6)/2=3,看到其指向的记录是 12<15,所以需要从 #3 槽后继续搜索记录;
  • 再使用二分搜索出 #3 槽和 #6 槽的中间位是 (3+6)/2=4.5 取整 4,#4 槽对应的记录是 16>15,所以记录一定在 #4 槽中;
  • 再从 #3 槽指向的 12 号记录开始向下搜索 3 次,定位到 15 号记录。

2 聚簇索引和二级索引

说到索引,页目录就是最简单的索引,是通过对记录进行一级分组来降低搜索的时间复杂度。但,这样能够降低的时间复杂度数量级,非常有限。当有无数个数据页来存储表数据的时候,我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。

2.1 B+ 树

为了解决这个问题,InnoDB 引入了 B+ 树。如下图所示,B+ 树是一棵倒过来的树:

image.png

B+ 树的特点包括:

  • 最底层的节点叫作叶子节点,用来存放数据;

  • 其他上层节点叫作非叶子节点,仅用来存放目录项,作为索引;

  • 非叶子节点分为不同层次,通过分层来降低每一层的搜索量;

  • 所有节点按照索引键大小排序,构成一个双向链表,加速范围查找。

关于B+树的介绍,见之前的文章Java 二叉树、红黑树、B+树

2.2 B+树索引的插入过程 和 快速查找主键

  • B+ 树索引的插入过程
    B+Tree索引的数据存储在叶子节点上,每个叶子节点默认的大小是16KB。

    当新记录插入到InnoDB聚簇索引中时,如果按顺序插入索引记录(升序或降序),当达到叶子节点最大的容量时,下一条记录就会写到新的的页中。叶子节点可使用的容量为总容量的15/16,InnoDB会留1/16的空间,以备将来插入和更新索引记录时使用, 如果以随机顺序插入记录,则页面的容量为1/2到15/16之间。

    可以设置 innodb_page_size 来调整页的大小,支持 64KB, 32KB, 16KB (默认), 8KB, 和4KB。

  • B+ 树如何实现快速查找主键
    比如,我们要搜索 PK=4 的数据,通过根节点中的索引可以知道数据在第一个记录指向的 2 号页中,通过 2 号页的索引又可以知道数据在 5 号页,5 号页就是实际的数据页,然后再通过二分法查找页目录马上可以找到记录的指针。

2.3 聚簇索引

因此,InnoDB 使用 B+ 树,既可以保存实际数据,也可以加速数据搜索,这就是聚簇索引。如果把上图叶子节点下面方块中的省略号看作实际数据的话,那么它就是聚簇索引的示意图。由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个。

InnoDB 会自动使用主键(唯一定义一条记录的单个或多个字段)作为聚簇索引的索引键(如果没有主键,就选择第一个不包含 NULL 值的唯一列)。上图方框中的数字代表了索引键的值,对聚簇索引而言一般就是主键。

2.4 二级索引

为了实现非主键字段的快速搜索,就引出了二级索引,也叫作非聚簇索引、辅助索引。二级索引,也是利用的 B+ 树的数据结构,如下图所示:

image.png

这次二级索引的叶子节点中保存的不是实际数据,而是主键和对应的索引列,获得主键值后去聚簇索引中获得数据行。这个过程就叫作回表。

比如,有个索引是针对用户名字段创建的,索引记录上面方块中的字母是用户名,按照顺序形成链表。如果我们要搜索用户名为 b 的数据,经过两次定位可以得出在 #5 数据页中,查出所有的主键为 7 和 6,再拿着这两个主键继续使用聚簇索引进行两次回表得到完整数据。

2.5 额外创建二级索引的代价

创建二级索引的代价,主要表现在维护代价、空间代价和回表代价三个方面。

首先是维护代价。创建 N 个二级索引,就需要再创建 N 棵 B+ 树,新增数据时不仅要修改聚簇索引,还需要修改这 N 个二级索引。

页中的记录都是按照索引值从小到大的顺序存放的,新增记录就需要往页中插入数据,现有的页满了就需要新创建一个页,把现有页的部分数据移过去,这就是页分裂;如果删除了许多数据使得页比较空闲,还需要进行页合并。页分裂和合并,都会有 IO 代价,并且可能在操作过程中产生死锁。

其次是空间代价。虽然二级索引不保存原始数据,但要保存索引列的数据,所以会占用更多的空间。可以使用下面的 SQL 查看数据和索引占用的磁盘:

SELECT DATA_LENGTH, INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_NAME='person'

最后是回表的代价。二级索引不保存原始数据,通过索引找到主键后需要再查询聚簇索引,才能得到我们要的数据。

2.6 联合索引数据结构

联合索引中其实保存了多个索引列的值,对于页中的记录先按照字段 1 排序,如果相同再按照字段 2 排序,如图所示:

image.png

图中,叶子节点每一条记录的第一和第二个方块是索引列的数据,第三个方块是记录的主键。如果我们需要查询的是索引列索引或联合索引能覆盖的数据,那么查询索引本身已经“覆盖”了需要的数据,不再需要回表查询。因此,这种情况也叫作索引覆盖。

所以尽量不要在 SQL 语句中 SELECT *,而是 SELECT 必要的字段,甚至可以考虑使用联合索引来包含我们要搜索的字段,既能实现索引加速,又可以避免回表的开销。

一句话总结:考虑到索引的维护代价、空间占用和查询时回表的代价,不能认为索引越多越好。索引一定是按需创建的,并且要尽可能确保足够轻量。一旦创建了多字段的联合索引,我们要考虑尽可能利用索引本身完成数据查询,减少回表的成本。

2.7 为什么主键通常建议使用自增id呢?

聚簇索引的数据的物理存放顺序与索引顺序是一致的,即:只要索引是相邻的,那么对应的数据一定也是相邻地存放在磁盘上的,当前的页写满的时候,下一条记录就写在新的页中,当不断的插入数据是,它只需要顺序的一页一页地写,索引结构相对紧凑,磁盘碎片少,写入效率高;当读取数据的时候,也可以按顺序读取,充分利用了局部性的优势,大大提高了读取效率。
而如果主键不是自增id,那么可以想象,它将会不断地调整数据的物理地址、分页,当然也有其他一些措施来减少这些操作,但却无法彻底避免。但,如果是自增的,那就简单了,它只需要一页一页地写,索引结构相对紧凑,磁盘碎片少,效率也高。

3 索引开销的最佳实践

3.1 不是所有针对索引列的查询都能用上索引

第一,索引只能匹配列前缀。

//比如下面的 LIKE 语句,搜索 name 后缀为 name123 的用户无法走索引,执行计划的 type=ALL 代表了全表扫描
EXPLAIN SELECT * FROM person WHERE NAME LIKE '%name123' LIMIT 100
image.png
//把百分号放到后面走前缀匹配,type=range 表示走索引扫描,key=name_score 看到实际走了 name_score 索引
EXPLAIN SELECT * FROM person WHERE NAME LIKE 'name123%' LIMIT 100
image.png

原因很简单,索引 B+ 树中行数据按照索引值排序,只能根据前缀进行比较。如果要按照后缀搜索也希望走索引的话,并且永远只是按照后缀搜索的话,可以把数据反过来存,用的时候再倒过来

第二,条件涉及函数操作无法走索引。

//比如搜索条件用到了 LENGTH 函数,肯定无法走索引:
EXPLAIN SELECT * FROM person WHERE LENGTH(NAME)=7
image.png

同样的原因,索引保存的是索引列的原始值,而不是经过函数计算后的值。如果需要针对函数调用走数据库索引的话,只能保存一份函数变换后的值,然后重新针对这个计算列做索引。

第三,联合索引只能匹配左边的列。

//虽然对 name 和 score 建了联合索引,但是仅按照 score 列搜索无法走索引:
EXPLAIN SELECT * FROM person WHERE SCORE>45678
image.png

原因也很简单,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。如果我们仅仅按照第二列搜索,肯定无法走索引。

//把搜索条件加入 name 列,可以看到走了 name_score 索引:

EXPLAIN SELECT * FROM person WHERE SCORE>45678 AND NAME LIKE 'NAME45%'
image.png

需要注意的是,因为有查询优化器,所以 name 作为 WHERE 子句的第几个条件并不是很重要。

第四,排序使用到索引

排序使用到索引,在执行计划中的体现就是 key 这一列。如果没有用到索引,会在 Extra 中看到 Using filesort,代表使用了内存或磁盘进行排序。而具体走内存还是磁盘,是由 sort_buffer_size 和排序数据大小决定的。

排序无法使用到索引的情况有:

  • 对于使用联合索引进行排序的场景,多个字段排序 ASC 和 DESC 混用;
  • a+b 作为联合索引,按照 a 范围查询后按照 b 排序;
  • 排序列涉及到的多个字段不属于同一个联合索引;

排序列使用了表达式。其实,这些原因都和索引的结构有关。

总结:

是不是建了索引一定可以用上?并不是,只有当查询能符合索引存储的实际结构时,才能用上。这里,只给出了三个肯定用不上索引的反例。其实,有的时候即使可以走索引,MySQL 也不一定会选择使用索引。

怎么选择建联合索引还是多个独立索引?如果你的搜索条件经常会使用多个字段进行搜索,那么可以考虑针对这几个字段建联合索引;同时,针对多字段建立联合索引,使用索引覆盖的可能更大。如果只会查询单个字段,可以考虑建单独的索引,毕竟联合索引保存了不必要字段也有成本。

不能认为建了索引就一定有效,对于后缀的匹配查询、查询中不包含联合索引的第一列、查询条件涉及函数计算等情况无法使用索引。此外,即使 SQL 本身符合索引的使用条件,MySQL 也会通过评估各种查询方式的代价,来决定是否走索引,以及走哪个索引。

3.2 数据库基于成本决定是否走索引

通过前面的案例,我们可以看到,查询数据可以直接在聚簇索引上进行全表扫描,也可以走二级索引扫描后到聚簇索引回表。看到这里,你不禁要问了,MySQL 到底是怎么确定走哪种方案的呢。

其实,MySQL 在查询数据之前,会先对可能的方案做执行计划,然后依据成本决定走哪个执行计划。

这里的成本,包括 IO 成本和 CPU 成本:

  • IO 成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的 IO 成本常数是 1(也就是读取 1 个页成本是 1)。
  • CPU 成本,是检测数据是否满足条件和排序等 CPU 操作的成本。默认情况下,检测记录的成本是 0.2。

全表扫描的成本

全表扫描,就是把聚簇索引中的记录依次和给定的搜索条件做比较,把符合搜索条件的记录加入结果集的过程。那么,要计算全表扫描的代价需要两个信息:

  • 聚簇索引占用的页面数,用来计算读取数据的 IO 成本;
  • 表中的记录数,用来计算搜索的 CPU 成本。

那么,MySQL 是实时统计这些信息的吗?其实并不是,MySQL 维护了表的统计信息,可以使用下面的命令查看:

SHOW TABLE STATUS LIKE 'person'
image.png

可以看到:

  • 总行数是 100086 行(之前 EXPLAIN 时,也看到 rows 为 100086)。你可能说,person 表不是有 10 万行记录吗,为什么这里多了 86 行?其实,MySQL 的统计信息是一个估算,其统计方式比较复杂我就不再展开了。但不妨碍我们根据这个值估算 CPU 成本,是 100086*0.2=20017 左右。
  • 数据长度是 4734976 字节。对于 InnoDB 来说,这就是聚簇索引占用的空间,等于聚簇索引的页面数量 * 每个页面的大小。InnoDB 每个页面的大小是 16KB,大概计算出页面数量是 289,因此 IO 成本是 289 左右。

所以,全表扫描的总成本是 20306 左右

接下来,我还是用 person 表这个例子,和你分析下 MySQL 如何基于成本来制定执行计划。现在,我要用下面的 SQL 查询 name>‘name84059’ AND create_time>‘2020-01-24 05:00:00’

EXPLAIN SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00'
image.png

只要把 create_time 条件中的 5 点改为 6 点就变为走索引了,并且走的是 create_time 索引而不是 name_score 联合索引:

image.png

可以得到两个结论:

  • MySQL 选择索引,并不是按照 WHERE 条件中列的顺序进行的;

  • 即便列有索引,甚至有多个可能的索引方案,MySQL 也可能不走索引。

原因就是,MySQL 并不是猜拳决定是否走索引的,而是根据成本来判断的。虽然表的统计信息不完全准确,但足够用于策略的判断了。

不过,有时会因为统计信息的不准确或成本估算的问题,实际开销会和 MySQL 统计出来的差距较大,导致 MySQL 选择错误的索引或是直接选择走全表扫描,这个时候就需要人工干预,使用强制索引了。比如,像这样强制走 name_score 索引:

EXPLAIN SELECT * FROM person FORCE INDEX(name_score) WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00' 

查看优化器生成执行计划的整个过程

MySQL 会根据成本选择执行计划,通过 EXPLAIN 知道优化器最终会选择怎样的执行计划,但 MySQL 如何制定执行计划始终是一个黑盒。那么,有没有什么办法可以了解各种执行计划的成本,以及 MySQL 做出选择的依据呢?

在 MySQL 5.6 及之后的版本中,我们可以使用 optimizer trace 功能查看优化器生成执行计划的整个过程。有了这个功能,我们不仅可以了解优化器的选择过程,更可以了解每一个执行环节的成本,然后依靠这些信息进一步优化查询。

如下代码所示,打开 optimizer_trace 后,再执行 SQL 就可以查询 information_schema.OPTIMIZER_TRACE 表查看执行计划了,最后可以关闭 optimizer_trace 功能:

SET optimizer_trace="enabled=on";
SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace="enabled=off";

对于按照 create_time>'2020-01-24 05:00:00’条件走全表扫描的 SQL,我从 OPTIMIZER_TRACE 的执行结果中,摘出了几个重要片段来重点分析:

  • 使用 name_score 对 name84059<name 条件进行索引扫描需要扫描 25362 行,成本是 30435,因此最终没有选择这个方案。这里的 30435 是查询二级索引的 IO 成本和 CPU 成本之和,再加上回表查询聚簇索引的 IO 成本和 CPU 成本之和,我就不再具体分析了:

    {
      "index": "name_score",
      "ranges": [
        "name84059 < name"
      ],
      "rows": 25362,
      "cost": 30435,
      "chosen": false,
      "cause": "cost"
    },
    
  • 使用 create_time 进行索引扫描需要扫描 23758 行,成本是 28511,同样因为成本原因没有选择这个方案:

    {
      "index": "create_time",
      "ranges": [
        "0x5e2a79d0 < create_time"
      ],
      "rows": 23758,
      "cost": 28511,
      "chosen": false,
      "cause": "cost"
    }
    
  • 最终选择了全表扫描方式作为执行计划。可以看到,全表扫描 100086 条记录的成本是 20306,和我们之前计算的一致,显然是小于其他两个方案的 28511 和 30435:

    {
      "considered_execution_plans": [{
        "table": "`person`",
        "best_access_path": {
          "considered_access_paths": [{
            "rows_to_scan": 100086,
            "access_type": "scan",
            "resulting_rows": 100086,
            "cost": 20306,
            "chosen": true
          }]
        },
        "rows_for_plan": 100086,
        "cost_for_plan": 20306,
        "chosen": true
      }]
    }
    

    把 SQL 中的 create_time 条件从 05:00 改为 06:00,再次分析 OPTIMIZER_TRACE 可以看到,这次执行计划选择的是走 create_time 索引。因为是查询更晚时间的数据,走 create_time 索引需要扫描的行数从 23758 减少到了 16588。这次走这个索引的成本 19907 小于全表扫描的 20306,更小于走 name_score 索引的 30435:

    {
      "index": "create_time",
      "ranges": [
        "0x5e2a87e0 < create_time"
      ],
      "rows": 16588,
      "cost": 19907,
      "chosen": true
    }
    

在尝试通过索引进行 SQL 性能优化的时候,务必通过执行计划或实际的效果来确认索引是否能有效改善性能问题,否则增加了索引不但没解决性能问题,还增加了数据库增删改的负担。如果对 EXPLAIN 给出的执行计划有疑问的话,你还可以利用 optimizer_trace 查看详细的执行计划做进一步分析。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容