面试题:

  • 索引底层使用了什么数据结构和算法?
  • 为什么 MySQL InnoDB 选择 B+Tree 作为索引的数据结构?
  • 什么时候适用索引?
  • 什么时候不需要创建索引?
  • 什么情况下索引会失效?
  • 有什么优化索引的方法?

索引是帮助存储引擎快速获取数据的一种数据结构,形象的说:索引是数据的目录,是以空间换时间的设计思想,索引和数据位于存储引擎中

索引的分类

可以按照四个角度来分类索引:

  • 按「数据结构」分类:B+Tree 索引、HASH 索引、Full-Text 索引
  • 按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)
  • 按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引
  • 按「字段个数」分类:单列索引、联合索引

按数据结构分类

InnoDB 引擎MyISAM 引擎Memory 引擎
B+Tree 索引YesYesYes
HASH 索引No(不支持,
但在内存结构中有一个自适应 hash 索引)
NoYes
Full-Text 索引Yes(5.6 版本后支持)YesNo

在创建表时,InnoDB 会根据不同的场景选择不同的列作为索引:

  • 如果有主键,默认会使用主键作为聚簇索引的索引键
  • 如果没有主键,就选择第一个不包含 NULL 值的唯一列作为聚簇索引的索引键
  • 上面两个都没有,将自动生成一个隐式自增 id 列作为聚簇索引的索引键

其它索引都属于辅助索引(Secondary Index),也称为二级索引或非聚簇索引

创建主键索引和二级索引默认使用的是 B+Tree 索引

B+Tree 是一种多叉树,叶子节点存放数据,非叶子节点只存放索引,而且每个节点中的数据按主键顺序存放。每一层父节点的索引值都会出现在下层子节点的索引值中,因此在叶子节点中,包括了所有的索引值和数据,每个叶子节点都有两个指针,分别指向下一个叶子节点和上一个叶子节点,形成一个双向链表

其实,InnoDB 里的 B+Tree 中的每个节点都是一个数据页

B+Tree 的特点:

  • 只有叶子节点才存放数据,非叶子节点仅用来存放索引
  • 非叶子节点分为不同层次,通过分层来降低每一层的搜索量
  • 所有节点按照索引键大小排序,构成一个双向链表,便于范围查询

在定位记录所在哪一个页时,也是通过二分法快速定位到包含该记录的页。定位到该页后,又会在该页内进行二分法快速定位记录所在的分组(槽),最后在分组内进行遍历查找

  • 由于数据在物理上只会保存一份,所以聚簇索引只能有一个
  • 二级索引可以有多个

通过主键查询数据

比如:

select * from product where id = 5;

B+Tree 会自顶向下逐层进行查找:

  • 将 5 与根节点的索引数据 (1, 10, 20) 比较,5 在 1 和 10 之间,所以找到第二层的索引数据 (1, 4, 7)
  • 在第二层的索引数据 (1, 4, 7) 中进行查找,5 在 4 和 7 之间,所以找到第三层的索引数据 (4, 5, 6)
  • 在叶子节点的索引数据 (4, 5, 6) 中进行查找,找到了索引值为 5 的行数据

数据库的索引和数据都存储在硬盘,可以把读取一个节点当作一次磁盘 I/O 操作。那么上面的整个查询过程一共经历了 3 个节点,也就是进行了 3 次 I/O 操作

B+Tree 存储千万级的数据只需要 3-4 层高度就可以满足,这意味着从千万级的表查询目标数据最多需要 3-4 次磁盘 I/O,所以 B+Tree 相比于 B 树和二叉树来说,最大的优势在于查询效率很高,因为即使在数据量很大的情况,查询一个数据的磁盘 I/O 依然维持在 3-4 次

为什么说 MySQL 单表不要超过 2000W 行?

根据大部分单行数据的大小,可以计算出大概 2000W 行左右,B+Tree 树层级会由 3 变为 4,会明显降低查询效率

当然,2000W 只是个预估值,实际要根据单行数据的大小适当调整

通过二级索引查询数据

  • 主键索引的 B+Tree 叶子节点存放的是实际数据
  • 二级索引的 B+Tree 叶子节点存放的是主键值,而不是实际数据

二级索引的 B+Tree 如下图:

通过二级索引查询数据会先检二级索引的 B+Tree 索引值,找到对应的叶子节点获取主键值,然后再通过主键索引的 B+Tree 树查询到对应的叶子节点,然后获取整行数据。这个过程叫「回表」,也就是说要查两个 B+Tree 才能查到数据

不过,当查询的数据能在二级索引的 B+Tree 叶子节点里查询到,就不用再查主键索引查,比如:

select id from product where product_no = '0002';

这种在二级索引的 B+Tree 就能查询到结果的过程就叫作「覆盖索引」,也就是只需要查一个 B+Tree 就能找到数据

为什么选择 B+Tree

B+Tree VS. B Tree

  • B+Tree 只在叶子节点存储数据,而 B Tree 的非叶子节点也要存储数据,所以 B+Tree 的单个非叶子节点能存储更多的索引,在相同的磁盘 I/O 次数下,能查询更多的节点
  • 插入和删除数据时,B 树可能发生复杂的树的变形;B+Tree 不会
  • B+Tree 叶子节点采用双链表连接,适合 MySQL 中常见的基于范围的顺序查找,而 B 树无法做到这一点

B+Tree vs 二叉树

  • 对于有 N 个叶子节点的 B+Tree,其搜索复杂度为 O(logdN),其中 d 表示节点允许的最大子节点个数。在实际应用中, d 值大于 100,保证了即使数据达到千万级别,B+Tree 的高度依然维持在 34 层左右,也就是说一次数据查询只需要做 34 次的磁盘 I/O
  • 而二叉树的每个节点最多只能有两个字节点,其搜索复杂度为 O(logN),比 B+Tree 高出很多,检索到目标数据所经历的磁盘 I/O 次数很多

B+Tree vs Hash

  • Hash 在做等值查询的搜索复杂度为 O(1)
  • 但是 Hash 表不适合做范围查询

按物理存储分类

从物理存储的角度来看,索引分为聚簇索引(主键索引)、二级索引(辅助索引)

这两个区别在前面提到过:

  • 主键索引的 B+Tree 叶子节点存放的是实际数据
  • 二级索引的 B+Tree 叶子节点存放的是主键值,而不是实际数据

按字段特性分类

从字段特性的角度来看,索引分为主键索引、唯一索引、普通索引、前缀索引

主键索引

建立在主键字段上的索引,通常在创建表时一起创建,一张表最多只有一个主键索引,索引列不允许有空值

CREATE TABLE table_name (
  ...
  PRIMARY KEY (index_column_1) USING BTREE
);

唯一索引

建立在 UNIQUE 字段上的索引,一张表可以有多个唯一索引,索引列的值必须唯一,但是允许有空值

建表时,创建唯一索引:

CREATE TABLE table_name (
  ...
  UNIQUE KEY(index_column_1, index_column_2, ...)
);

建表后,创建唯一索引:

CREATE UNIQUE INDEX index_name
ON table_name(index_column_1 ,index_column_2, ...);

普通索引

建立在普通字段上的索引,既不要求字段为主键,也不要求字段为 UNIQUE

建表时,创建普通索引:

CREATE TABLE table_name (
  ...
  INDEX(index_column_1, index_column_2, ...) 
);

建表后,创建普通索引:

CREATE INDEX index_name
ON table_name(index_column_1, index_column_2, ...);

前缀索引

对字符类型字段的前几个字符建立索引,而不是在整个字段上建立索引,前缀索引可以建立在字段类型为 char、 varchar、binary、varbinary 的列上

建表时,创建前缀索引:

CREATE TABLE table_name (
    column_list,
    INDEX(column_name(length))
); 

建表后,创建前缀索引:

CREATE INDEX index_name
ON table_name(column_name(length));

按字段个数分类

从字段个数的角度来看,索引分为单列索引、联合索引(复合索引)

  • 建立在单列上的索引称为单列索引,比如主键索引
  • 建立在多列上的索引称为联合索引

联合索引

将多个字段组合成一个索引,是二级索引

CREATE INDEX index_product_no_name ON product(product_no, name);

联合索引的 B+Tree:

可以看到,联合索引的非叶子节点用两个字段的值作为 B+Tree 的 key 值。当在联合索引查询时,先按 product_no 字段比较,在 product_no 相同的情况下再按 name 字段比较

也就是说,联合索引查询的 B+Tree 是先按 product_no 进行排序,然后再 product_no 相同的情况再按 name 字段排序

因此,使用联合索引时,存在最左匹配原则,在使用联合索引进行查询时,如果不遵循「最左匹配原则」,联合索引会失效,无法利用到索引快速查询的特性

比如,一个 (a, b, c) 联合索引,如果查询条件是以下这几种,就可以匹配上联合索引:

  • where a=1
  • where a=1 and b=2 and c=3
  • where a=1 and b=2

注意:因为有查询优化器,所以 a 字段在 where 子句的顺序并不重要

但是,如果查询条件是以下这几种,因为不符合最左匹配原则联合索引就会失效:

  • where b=2
  • where c=3
  • where b=2 and c=3

利用索引的前提是索引里的 key 是有序的,b 和 c 是全局无序,局部相对有序,所以在没有遵循最左匹配原则的情况下,无法利用到索引

联合索引范围查询和索引下推

索引区分度

建立联合索引时的字段顺序对索引效率有很大影响。越靠前的字段被用于索引过滤的概率越高,实际开发工作中建立联合索引时,要把区分度大的字段排在前面,这样区分度大的字段越有可能被更多的 SQL 使用到

区分度 = 某个字段 column 不同值的个数 / 表的总行数

比如,性别的区分度就很小,不适合建立索引或不适合排在联合索引列的靠前位置,而 UUID 这类字段就比较适合

因为如果索引的区分度很小,假设字段的值分布均匀,那么无论搜索哪个值都可能得到一半的数据。在这些情况下,还不如不要索引,因为 MySQL 的查询优化器发现某个值出现在表的数据行中的百分比很高时(惯用界线是 30%),会忽略索引,进行全表扫描

联合索引进行排序

针对下面这条 SQL,怎么通过索引来提高查询效率?

select * from order where status = 1 order by create_time asc

有的同学会认为,单独给 status 建立一个索引就可以了

但是更好的方式是给 statuscreate_time 建立一个联合索引,利用索引的有序性,根据 status 筛选后的数据就是按照 create_time 排好序的,这样可以避免 MySQL 数据库发生文件排序

在查询时,如果只用到 status 的索引,但是这条语句还要对 create_time 排序,这时就要用文件排序,在 SQL 执行计划中,Extra 列会出现 Using filesort

是否需要创建索引

索引最大的好处是提高查询速度,但是也有缺点:

  • 需要占用物理空间,数量越大,占用空间越大
  • 创建索引和维护索引要耗费时间,这种时间随数据量的增加而增大
  • 会降低表的增删改效率,因为每次增删改索引,B+Tree 为了维护索引有序性,都需要进行动态维护

需要索引的情况:

  • 字段有唯一性限制,如:id、商品编码
  • 经常用于 WHERE 查询条件的字段,如果查询条件不是一个字段,可以建立联合索引
  • 经常用于 GROUP BY 和 ORDER BY 的字段,在查询时就不需要再去做一次排序

无需索引的情况:

  • WHEREGROUP BYORDER BY 中用不到的字段
  • 索引区分度低的字段,如:存在大量重复数据
  • 表数据太少时,不需要创建索引
  • 经常更新的字段不用创建索引

优化索引的方法

这里说一下几种常见优化索引的方法:

  • 前缀索引优化;
  • 覆盖索引优化;
  • 主键索引最好是自增的;
  • 防止索引失效;

前缀索引优化

如果某个字段中字符串很长,而且需要索引,则使用前缀索引。可以减小索引字段大小,有效提高索引的查询速度

不过,前缀索引有一定的局限性:

  • order by 无法使用前缀索引
  • 无法把前缀索引用作覆盖索引

覆盖索引优化

覆盖索引是指查询的所有字段,在二级索引 B+Tree 的叶子节点上都能找得到,而不需要通过聚簇索引查询获得,可以避免回表的操作

假设需要查询商品的名称、价格,有什么方式可以避免回表?

可以建立一个「商品 ID、名称、价格」的联合索引,如果索引中存在这些数据,查询将不会再次检索主键索引,从而避免回表

主键索引最好是自增的

InnoDB 创建主键索引默认为聚簇索引,同一个叶子节点内的各个数据是按主键顺序存放的,每当有一条新的数据插入时,数据库会根据主键将其插入到对应的叶子节点中

  • 使用自增主键:每次插入的新数据会按顺序添加到当前索引节点的最后位置,不需要移动已有的数据,当页面写满,自动开辟一个新页面
  • 使用非自增主键:每次插入新的数据时,可能会插入到现有数据页中间的某个位置,这将不得不移动其它数据来满足新数据的插入,甚至需要从一个页面复制数据到另外一个页面。这种情况称为页分裂,有可能造成大量的内存碎片,导致索引结构不紧凑,影响查询效率

另外,主键的长度不要太大,因为长度越小,非叶子节点存储的索引就越多,二级索引的叶子节点也越小

因此,如果没有特别的业务需求(如:反爬虫),建议使用自增字段作为主键

索引最好设置为 NOT NULL

索引列存在 NULL 会导致优化器在做索引选择时更复杂、更加难以优化,因为可为 NULL 的列会使索引、索引统计和值比较都更复杂,如:进行索引统计时,count 会省略值为 NULL 的行

防止索引失效

索引失效