高性能MySQL实战(二):索引 | 京东物流技术团队

2023年 9月 12日 97.0k 0

我们在上篇 高性能MySQL实战(一):表结构 中已经建立好了表结构,这篇我们则是针对已有的表结构和搜索条件为表创建索引。

1. 根据搜索条件创建索引

我们还是先将表结构的初始化 SQL 拿过来:

CREATE TABLE `service_log` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
  `service_type` smallint NOT NULL DEFAULT -1 COMMENT '接口类型',
  `service_name` varchar(30) DEFAULT '' COMMENT '接口名称',
  `service_method` tinyint NOT NULL DEFAULT -1 COMMENT '接口方式 1-HTTP 2-TCP',
  `serial_no` int DEFAULT -1 COMMENT '消息序号',
  `service_caller` tinyint DEFAULT -1 COMMENT '调用方',
  `service_receiver` tinyint DEFAULT -1 COMMENT '接收方',
  `status` tinyint DEFAULT 10 COMMENT '状态 10-成功 20-异常',
  `error_message` varchar(200) DEFAULT '' COMMENT '异常信息',
  `message` varchinar(1000) DEFAULT '' COMMENT '报文内容',
  `create_user` varchar(50) DEFAULT '' COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_user` varchar(50) DEFAULT '' COMMENT '更新者',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
  `is_delete` tinyint NOT NULL DEFAULT 0 COMMENT '刪除标志',
  `ts` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '时间戳',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口调用日志';

现有如下搜索条件:

  • 根据 接口名称 来搜索对应的调用日志
  • 根据 状态 查询成功或异常的调用日志
  • 根据 接口名称 和 状态 查询调用日志
  • 根据 消息序号 来获取一组调用日志
  • 根据 创建时间 的时间范围查询调用日志
  • 根据 报文内容 查询调用日志
  • 索引是提升查询性能最有效的手段,它可以快速定位到记录,大大减少需要扫描的数据量,将随机 I/O 变为顺序 I/O,而且 B+ Tree 索引会根据索引值顺序存储,所以也能够应用于 排序和分组。

    为了在查看这些接口调用日志时有更好的体验,那么我们就需要根据搜索条件来创建索引。

    索引的类型尽量小

    我们先关注下接口名称的搜索条件,我们可以发现:接口名称和接口类型这两个字段都能查询到相同类型接口的日志数据,只不过它们的类型不同,前者是字符串类型,后者是整型。

    这时我们需要注意:选择创建索引的列的类型要尽量小。因为每创建一个索引就相当于创建了“一棵 B 树”,数据类型越小,那么索引占用的存储空间就越少,在一个数据页内就能存放更多的记录,因此磁盘 I/O 带来的性能损耗也就越少。

    除此之外,在 MySQL 内部整型数据的比较相比于字符串类型的比较更简单,效率也更高。所以,我们会选择为接口类型创建索引而不是为接口名称创建索引。

    为 接口类型 列添加索引的 SQL 如下:

    alter table service_log add index index_service_type(`service_type`);
    

    根据条件 4,消息序号列也需要添加索引:

    alter table service_log add index index_serial_no(`serial_no`);
    

    冗余和重复索引

    同样地,根据搜索条件 2,我们为状态列添加上索引:

    alter table service_log add index index_status(`status`);
    

    这时,我们再看看条件 3,需要为接口类型和状态添加 联合索引。不过需要注意的是:该联合索引和已经添加的接口类型索引是重复索引,根据联合索引的 最左匹配原则,第一列为接口类型的联合索引同样能为查询条件只有接口类型的查询服务,所以我们需要将原有为接口类型添加的索引删掉,再创建新的接口类型和状态的联合索引。

    -- 删除 index_service_type
    alter table service_log drop index index_service_type;
    
    -- 添加联合索引
    alter table service_log add index index_service_type_status(`service_type`, `status`);
    

    创建联合索引时有一个重要的经验性法则:将列值重复率最低的放到索引的最前列。如果重复的值过多,那么扫描到的数据行数也就越多,这样就会使得回表的压力很大。

    通常情况下,把 WHERE 条件里面的列都独立地创建多个单列索引,在大部分情况下并不能提高MySQL的查询性能。我们应该尽可能的去考虑 索引列的顺序 或者创建一个 全覆盖索引。

    为重复率低的列创建索引

    在我们的实际业务中,接口调用的状态几乎所有都是成功,很少会出现失败的情况,所以这时我们为状态列创建索引并不是很合适。因为如果我们查询所有状态为成功的数据,那么它可能会执行太多次的回表操作,导致查询效率下降,可能还不如执行全表扫描来的快。但是我们再考虑另一种情况,有时我们会根据状态为失败的记录做业务分析或排查问题,失败的数据是比较少的,如果我们通过索引查询就会非常高效,所以该列索引还有必要保留。

    只不过我们在这里需要做一个处理:如果状态为成功时,我们为生成的 SQL 语句添加上忽略索引的关键字 ignore index(index_name) ,那么这样我们就能达到在查询成功状态的数据时全表扫描,而在查询失败状态的数据时使用索引了。

    select * from service_log ignore index(index_status)
    where status = 10;
    

    全值匹配和按值范围匹配的时间列

    条件 5 根据创建时间来进行全值匹配和按值范围匹配 非常适合创建索引:

    alter table service_log add index index_create_time(`create_time`);
    

    全文索引

    FULLTEXT 全文索引是一种特殊类型的索引,它查找的是文本中的关键词,而不是直接比较索引中的值,更类似于搜索引擎所做的事情。在查询时适用于 MATCH AGAINST 操作,而不是普通的 WHERE 条件。

    对于条件 5,我们需要在接口请求的报文中根据关键字,比如说包裹号来查询特定的数据,这就使得我们需要为报文内容列创建全文索引,SQL 如下:

    alter table service_log add fulltext fulltext_message(`message`);
    
    -- 执行查询时的语句
    select * from service_log where match(message) against('123456');
    

    全文索引在日常使用的并不多,它有许多需要注意的细节,如停用词、词干、复数和布尔搜索等,具体的详情信息可以查看文末的参考文献。

    那么,最终初始化表结构的 DDL 语句如下:

    CREATE TABLE `service_log` (
      `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
      `service_type` smallint NOT NULL DEFAULT -1 COMMENT '接口类型',
      `service_name` varchar(30) DEFAULT '' COMMENT '接口名称',
      `service_method` tinyint NOT NULL DEFAULT -1 COMMENT '接口方式 1-HTTP 2-TCP',
      `serial_no` int DEFAULT -1 COMMENT '消息序号',
      `service_caller` tinyint DEFAULT -1 COMMENT '调用方',
      `service_receiver` tinyint DEFAULT -1 COMMENT '接收方',
      `status` tinyint DEFAULT 10 COMMENT '状态 10-成功 20-异常',
      `error_message` varchar(200) DEFAULT '' COMMENT '异常信息',
      `message` varchar(1000) DEFAULT '' COMMENT '报文内容',
      `create_user` varchar(50) DEFAULT '' COMMENT '创建者',
      `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `update_user` varchar(50) DEFAULT '' COMMENT '更新者',
      `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
      `is_delete` tinyint NOT NULL DEFAULT 0 COMMENT '刪除标志',
      `ts` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '时间戳',
      PRIMARY KEY (`id`),
      index index_serial_no(`serial_no`),
      index index_status(`status`),
      index index_create_time(`create_time`),
      index index_service_type_status(`service_type`, `status`),
      fulltext fulltext_message(`message`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口调用日志';
    

    前缀索引

    一般情况下,对于 VARCHAR、BLOB 和 TEXT 等相关类型的列创建索引时,为了提升索引的性能和节省索引空间,会只对字段的前一部分字符进行索引,不过这样做的缺点是使得索引的选择性降低。

    索引的选择性是指不重复的索引值和记录总数的比值,可以理解为重复率越低选择性越高,唯一索引的选择性为 1。

    在我们的数据库表示例中,并没有字段适合建立前缀索引。其中报文内容列也并不适合创建前缀索引,因为这些报文的前缀都很相似,而且我们在执行查询时并不会带上前缀,而是只使用关键词信息查询。

    但是,前缀索引比较重要,所以我们在这里也对创建前缀索引的方法介绍一下。

    MySQL 并不支持对这些长字符类型列的完整内容进行索引,我们选择前缀长度的关键点在于:既要保证选择足够长的前缀使得选择性较高,同时又不能太长防止占用太多的空间。

    可以根据如下方法来确定前缀的长度:

    首先,查看要添加索引的列出现最频繁的一些值:

    select count(0) as c, specific_column
    from specific_table
    group by specific_column
    order by c desc
    limit 10;
    

    之后先从 3 个前缀字母开始匹配尝试:

    select count(0) as c, left(specific_column, 3) as pref
    from specific_table
    group by pref
    order by c desc
    limit 10;
    

    慢慢地增加前缀长度,直到这个前缀的选择性接近我们首次查询的完整列的选择性即可。

    或者,采用如下的方法,先计算出完整列的选择性:

    select count(distinct specific_column) / count(0)
    from specific_table;
    

    然后分别计算不同前缀的选择性,直到找到与完整列接近的选择性前缀长度即可:

    select count(distinct left(specific_column, 3)) / count(0) as sel3,
    count(distinct left(specific_column, 4)) / count(0) as sel4,
    count(distinct left(specific_column, 5)) / count(0) as sel5,
    count(distinct left(specific_column, 6)) / count(0) as sel6,
    count(distinct left(specific_column, 7)) / count(0) as sel7
    from specific_table;
    

    不过,也有例外的情况,那就是即使现在我们选择了比较接近完整列选择性的前缀,但数据的分布仍然很不均匀。

    这时我们需要用该前缀执行如下查询,并与完整列查询出的数目作比较,观察这些出现频率最高的前缀值与完整列出现频率是否接近,否的话需要再将前缀值调大。

    select count(0) as c, left(specific_column, 5) as pref
    from specific_table
    group by pref
    order by c desc
    limit 10;
    
    -- 完整列的出现频率
    select count(0) as c, specific_column
    from specific_table
    group by specific_column
    order by c desc
    limit 10;
    

    最后,找到合适的前缀数创建前缀索引可以使用如下 SQL:

    alter table specific_table add index index_specific_column(specific_column(7));
    

    虽然前缀索引能够使索引更小,更快,但是我们不能使用前缀索引做 ORDER BY 和 GROUP BY 操作,也无法使用前缀索引做索引覆盖。

    2. 关于索引必须知道的事儿

    下文中我们所说的索引如果没有特别指明类型,那么就代表我们说的是 B+ Tree 索引,它使用 B+ Tree 数据结构来保存数据。

    B+ Tree 会将所有的数据保存在叶子节点上,并且通过双向链表将叶子节点连接起来。

    聚簇索引

    聚簇索引并不是一种单独的索引类型,而是一种数据存储方式,InnoDB 聚簇索引在数据页中同时保存索引和数据行,这使得它的数据访问相比于非聚簇索引(二级索引)要快。

    聚簇的意思是说 数据行和相邻的键值紧凑的存储在一起,因为无法同时把数据行放在两个不同的地方,所以一个表只能有一个聚簇索引。InnoDB 根据 主键 聚簇数据,如果没有定义主键,InnoDB 会自动生成一个唯一的隐式主键作为聚簇索引。

    我们创建一个简单的表,并插入一些数据,来看一下 B+ Tree 索引的数据结构图:

    create table demo (
        c1 int,
        c2 int,
        c3 char(1),
        primary key(c1)
    )engine=InnoDB;
    

    MySQL 是通过 数据页 来保存数据的,每个页的大小默认为 16KB,在每个数据页中都默认有最小记录 Infimum 和最大记录 Supremum,如下图所示:

    BTree.png

    我们可以发现在叶子节点中保存了所有数据行,每个页之间通过页文件头部(File Header)记录的双向链表指针进行连接,数据记录之间通过单向链表连接,单向链表的指针记录在每行数据记录的 记录头信息 中。

    在非叶子节点中,我们可以发现记录的信息只有 主键值和对应的页号,因此数据页能存放的数据更多,B+ Tree 也就能更加 “矮胖”,这样就能使得磁盘 I/O 更少。一般情况下我们用到的 B+ Tree 不会超过 4 层。

    B+ Tree 按照索引列数据的大小顺序排序存储,所以很适合按照范围来查询。每次搜索数据都从索引的根节点开始,通过比较节点中的值和要查找的值来找到合适的指针进入下层子节点,最终在叶子节点中找到或找不到对应的记录。

    聚簇索引能够加快我们访问数据的速度,但是它也有一些局限性我们需要了解一下:

    • 聚簇索引最大限度地提高了 I/O 密集型应用的性能,但如果 数据全部都放在内存中,则访问的顺序就没那么重要了,聚簇索引也就没什么优势了

      随着 RAM 变得更便宜,而且许多数据集不是那么大,所以将它们全部保存在内存中是非常可行的,包括可能分布在多个服务器上,这也促进了内存数据库的发展。

    • 插入速度严重依赖于插入顺序。按照主键的顺序插入行是将数据加载到 InnoDB 表中最快的方式。但如果不是按照主键的顺序插入,会因页分裂影响插入速度。最好避免随机的聚簇索引,特别是对于 I/O 密集型的应用

    • 聚簇索引列更新的代价很高,因为它会强制 InnoDB 将每个被更新的行移动到新的位置,这也会发生页分裂,导致性能下降

    二级索引

    二级索引是非聚簇索引,InnoDB 引擎在 B+ Tree 的叶子节点存储的不是完成的数据记录,而只是 索引列和主键列的值。如果在查询时没有发生覆盖索引的话,需要根据主键值进行回表操作以获取需要的结果。

    二级索引是关系型数据库的基础,并且在文档数据库中也很普遍。许多键值存储(如 HBase 和 Volde-mort)为了减少实现的复杂度而放弃了二级索引,但是一些(如 Riak)已经开始添加它们,因为它们对于数据模型实在是太有用了。并且次级索引也是 Solr 和 Elasticsearch 等搜索服务器的基石。

    实际上,有两种用二级索引对文档数据库进行分区的方法:基于文档(document-based)  的分区 和 基于关键词(term-based)  的分区。

    *基于文档的分区

    假设我们有一个汽车销售网站,每条数据都有唯一的 ID,我们称之为文档 ID。我们使用文档 ID 进行分区,并为汽车颜色字段创建二级索引,分区结果如下图所示:

    文档分区.png

    这样的二级索引分配方法,使得每个分区都是独立的:每个分区自己维护自己的索引,它不关心其他分区的数据,这种文档分区索引也被称为 本地索引。

    当我们查询红色的汽车时,需要将请求发布到所有的分区,并合并所有返回的结果,这种查询数据库的方法被称为 分散/聚集,可能会使得二级索引查询数据比较耗时。

    *基于关键词的分区

    我们也可以构建一个覆盖所有分区数据的 全局索引,比如我们将 a 到 r 开头的颜色的二级索引保存在分区 0 中,将 s 到 z 的保存在分区 1 中,如下图所示:

    关键词分区.png

    我们将这种分区方法称为 关键词分区,根据关键词本身分区对于范围扫描非常有用,比如说我现在想获取 a 到 r 开头的颜色的所有汽车数据;而对关键词的哈希分区又能够提供分区负载均衡的能力。

    基于关键词分区的全局索引优于文档分区索引的地方在于它的读取更加高效,并不需要将请求打到所有分区上,只需要将请求发送到含有对应关键词的分区即可,而它的缺点在于对单个分区文档的写入可能会产生多个分区的索引的数据变更,需要协调跨分区的分布式事务。

    覆盖索引

    覆盖索引可以简单地理解成 查询只需要访问索引列而无需访问其他数据列。

    优秀的索引设计不单单只考虑 WHERE 条件,也会根据想要查询的列去综合分析。如果只需要索引列的话,那么覆盖索引是非常有用的工具,它能避免回表操作,这样 MySQL 就会极大地减少数据访问量,而且索引占用的空间很小,将这些数据缓存在内存中的压力远小于缓存所有相关数据行。

    如果业务无需查询其他列,那么我们最好把业务需要的列放在查询列表中,以实现覆盖索引,而不是简单地以 * 来替代;在某些情况下,可以根据想要查询的列,对所使用的索引进行扩展,即增加想要查询的列达到覆盖索引的目的。

    当执行一个覆盖索引的查询时,在 EXPLAIN 的 Extra 列可以看到 Using index 的信息。

    自适应哈希索引

    它是 InnoDB 的一个特性,当 InnoDB 发现某些索引值被非常频繁的访问时,它会在原有的 B+ Tree 索引之上,再在内存中构建一个哈希索引,以此来加快对应数据的访问。这个过程是自动化的,我们无法进行干预,不过可以通过参数配置将其关闭。

    巨人的肩膀

    • 《数据密集型应用系统设计》:第三章、第六章
    • 《高性能 MySQL 第四版》:第七章
    • 《MySQL 是怎样运行的》:第四、五、六、七章
    • 14.6.2.4 InnoDB Full-Text Indexes

    作者:京东物流 王奕龙

    来源:京东云开发者社区 自猿其说Tech 转载请注明来源

    相关文章

    Oracle如何使用授予和撤销权限的语法和示例
    Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
    下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
    社区版oceanbase安装
    Oracle 导出CSV工具-sqluldr2
    ETL数据集成丨快速将MySQL数据迁移至Doris数据库

    发布评论