前言
程序员避不开和数据库打交道,大数据更是如此,不管是 MySQL、Oracle、SQL Server 这些 OLTP 数据库,还是Greeplum、StarRocks、Hive、Spark SQL、Flink SQL、ClickHouse 等 OLAP 数据库,SQL 都是最基础最重要的能力,数据库知识也是每一个程序员必备的知识。
而当我们讨论 SQL 优化的时候,其实很大程度都是在围绕索引在做优化。
1、索引
1.1、索引概述
索引是帮助 MySQL 高效获取数据 的数据结构(有序)。下面我们以查询 age=45 岁的用户为例进行说明:
1.1.1、有无索引对比
1)无索引
没有索引,就相当于每次查找我们都需要遍历全表,时间复杂度:O(N)
2)有索引
如果对 age 列建立了索引,我们假设这个数据结构是一颗二叉树,那么我们就不需要遍历全表,时间复杂度:O(logN)
1.1.2、优缺点
1)优点
- 提高数据检索的效率,降低数据库 IO 成本(磁盘寻址、读写数据都是 IO 成本)
- 通过索引对数据进行排序,降低数据排序的成本,降低 CPU 消耗
2)缺点
- 索引列也需要占用磁盘空间
- 索引大大提高查询效率的同时,也降低了更新数据的速度,当对表进行 CUD 操作时(因为要对树的节点进行操作),效率会降低
一般当我们考虑对一张表添加索引时,这两个缺点都可以忽略。因为第一磁盘很便宜,第二大部分业务系统中,查询操作是最多的。
1.2、索引结构
上一节我们了解了,索引是在存储引擎层实现的,不同的存储引擎有不同的结构,主要有以下几种:
- B+ Tree🌳(面试重点)
- 最常见的索引类型,大部分引擎都支持 B+ 树索引
- Hash 索引
- 底层数据结构是用哈希表实现的,只有精确匹配索引列的查询才有效,不支持范围查询
- R- Tree🌳(空间索引)
- 空间索引是 MyISAM 引擎的一个特殊索引类型,主要用于地理空间数据类型,通常使用较少
- Full-text(全文索引)
- 是一种通过建立倒排索引,快速匹配文档的方式。类似于 Lucence,Solr,ES
1.2.1、B Tree
我们先假设使用二叉树来当做索引结构:
上面从左到右分别是二叉排序树、顺序插入的二叉排序树和红黑树(平衡二叉树),可以明显发现:
- 大数据量情况下,层级较深,检索速度慢
- 顺序插入时,会形成一个链表,查询性能大大下降(链表查询速度O(N))
要想解决大数据量情况下,层级较深的缺点,我们可以构造一个节点下有多个子节点的树,而非只有两个子节点,这就是我们说的 B Tree:
- B-Tree(多路平衡二叉树)
以一颗最大度数(max-degree)为5(5阶)的 b-tree 为例(每个节点最多存储 4 个key,5个指针):
树的度数指的是一个节点的子节点个数。
B-Tree 的演变过程:
我们构建一颗 5 阶 b-tree 树(每个节点最多存储 4 个 key,5 个指针),并先插入 4 个数字:
当插入第 5 个数字时,key 达到最大 4,发生裂变,中间的 key 上升为父节点:
继续插入数字:
1.2.2、B+Tree
以一颗最大度数为 4 的 b+tree 为例:
可以看到 B+Tree 有这样的特点:
- 所有的元素都会出现在叶子节点,上面的非叶子节点只起到索引的作用,而叶子节点才是存储数据的地方
- 所有叶子节点生成一个单向链表
B+Tree 的演变过程:
这里演示一颗 4 阶的 B+Tree 的演变过程,首先随意插入 3 个值:
此时的 key 为 3 达到最大(4-1),当插入第 4 个值的时候,会发生分裂
可以看到,当插入 3 之后,此时的排列为 1 3 5 7(排序后),下标为 (4/2) 的节点会上升为父节点,但同时叶子节点仍会保留它的值。
最后,可以看到,所有的数据都会出现在叶子节点,而且叶子节点形成了一个单向链表。
1.2.3、MySQL 中的 B+Tree
MySQL 对 B+Tree 进行了优化,在原来 B+Tree 的基础上,增加了一个指向相邻叶子节点的链表指针,就形成了带有顺序指针的 B+Tree,提高区间访问的性能。
1.2.4、Hash
哈希索引就是采用一定的 hash 算法,将键值换算成新的 hash 值,映射到对应的槽位上,然后存储在哈希表中。
哈希索引的特点:
- 只能用于对等比较(=,in),不支持范围查询(between,>,<,...)
- 无法利用索引完成排序(因为哈希运算结果是无序的)
- 查询效率高,通常一次检索就可以了,效率通常要高于B+Tree索引(毕竟哈希取值时间复杂度为O(1))
存储引擎支持:
- 在 MySQL 当中,只有 Memory 引擎支持 hash 索引,而 InnoDB 引擎中具有自适应 hash 功能,hash 索引是存储引擎根据 B+Tree 索引在指定条件下构建的。
1.2.5、面试题
1)为什么 InnoDB 存储引擎选择使用 B+Tree 数据结构?
回答:为什么不使用二叉树、红黑树、B-Tree 以及哈希索引。
- 如果二叉树顺序插入会形成链表,查找效率很低(O(N)),虽然可以通过红黑树来解决,但是红黑树的本质也是一颗平衡二叉树,大数据量情况下,它又会遇到层级深的问题,查询效率依然低;
- B-Tree 叶子节点和非叶子节点都会存放数据,一个叶子节点也就是一页(16 KB),相同数据量的情况下,B+Tree 能够存储的 key 和 指针更多,层级更少;
- 相对 hash 索引,它只支持等值匹配,不支持范围查询和排序操作;
1.3、索引分类
在 InnoDB 存储引擎中,根据索引的存储形式,又可以分为下面两种:
注意:这两种索引都是 B+Tree 的结构,聚集索引必须唯一,所以一般用主键索引或唯一索引作为聚集索引,叶子节点存放的是一行的数据;而二级索引的叶子节点存放的是主键 id
聚集索引选取规则:
- 默认主键索引就是聚集索引
- 如果没有主键,将使用第一个唯一索引(UNIQUE)作为聚集索引
- 如果没有主键也没有唯一索引,则 InnoDB 会自动生成一个 rowid 作为隐藏的聚集索引
查询数据流程:
假设我们现在执行 "SELECT * FROM user WHERE name = 'Arm'; "
- 首先不能直接走聚集索引,因为我们不知道主键信息,而 name 列我们之前为它创建了二级索引,所以应该走的是二级索引
- 二级索引中,我们可以根据姓名的字典序快速定位到姓名所在的叶子节点,而二级索引的叶子节点存储的是主键,所以我们得到了主键就可以进行回表查询(去聚集索引中去查询)
- 在聚集索引中,我们可以通过主键直接从叶子节点中得到整行数据
B+Tree 是有序的。在B+Tree中,所有的节点(包括非叶子节点和叶子节点)都按照键(key)的字典顺序进行排序,这确保了树中的数据保持有序性。
InnoDB 主键索引的 B+Tree 高度是多少?
所以,当高度为 3 的时候已经能够存储 2 千万左右的数据了。如果数据量再大,我们就需要考虑分库分表了。
1.4、索引语法
语法
sql
CREATE [UNIQUE|FULLTEXT] INDEX index_name ON table_name (index_col_name,...);
注意:
- 这里的索引类型中没有主键索引,因为主键索引早在建表的时候就自动生成了。
- 一次创建索引的字段可以是多个,这种叫做联合索引或者组合索引;否则叫做单列索引。
查看索引
sql
SHOW INDEX FROM table_name;
字段比较多也可以在命令行在 SQL 后面增加 '\G' 来展示:
删除索引
sql
DROP INDEX index_name ON table_name;
示例:
sql
-- 创建普通索引
CREATE INDEX idx_student_name ON student(name);
-- 创建唯一索引
CREATE UNIQUE INDEX idx_student_phone ON student(phone);
-- 创建联合索引
CREATE INDEX idx_pro_age_sta ON student(profession,age,status);
注意:
- 在 InnoDB 存储引擎中,创建的索引默认都是 B+Tree 结构;
- 创建联合索引时,字段的前后顺序都是有讲究的
1.5、SQL 性能分析
1.5.1、查看执行频次
SQL 的性能优化主要针对的是查询语句,所以我们可以通过查看数据库各类操作(CRUD)的访问频率,来决定是否需要进行 SQL 优化。
sql
SHOW [SESSION|GLOBAL] STATUS
下面我们通过 "show global status like 'Com_______'; "(7个下划线代表7个字符)来查看当前数据库各操作的频率:
当数据库查询频率占据绝大部分时,我们就应该考虑进行 SQL 优化了。
1.5.2、慢查询日志
上面我们只是知道了当前数据中 select 的权重比较高,具体是哪类 SQL 需要优化呢?这就需要使用慢查询日志来确定对哪些 SQL 进行优化。
慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认 10 秒 )的所有 SQL 语句的日志。
MySQL 的慢查询日志默认没有开启,需要在 MySQL 的配置文件(/etc/my.cnf)中配置:
- 首先通过下面的语句查看慢查询是否开启
sql
SHOW VARRIABLES LIKE 'slow_query_log';
- /etc/my.cnf 修改(注意:是加到 mysqld 下面,否则无法生效)
bash
# 开启慢查询日志开关
slow_query_log=1
# 设置慢查询日志的时间(单位:s)
long_query_time=2
- 查看慢查询日志文件的存储位置
bash
SHOW VARIABLES LIKE 'slow_query_log_file';
这样,当有 SQL 的查询耗时超过 2s 时,就会在这个日志文件中生成对应的记录(包括查询时间,锁行时间,返回记录数,数据库名,SQL 语句)
1.5.3、profile 详情
除了上面设置的慢查询日志之外,还有一类 SQL 它的执行时间接近我们设置的慢查询时间,我们也需要对它们进行优化。
sql
SHOW PROFILES;
通过 having_profiling 参数,可以查看当前数据库是否支持 profile 操作:
sql
SELECT @@have_peofiling; -- YES
默认 profiling 是关闭的,我们可以通过 set 语句在 session/global 级别开启 profling:
sql
SELECT @@profiling; -- 查询是否开启 0: 未开启/1: 开启
SET profiling = 1; -- 开启 profiling
这样我们就可以清楚地看到每一条 SQL 的耗时时间:
我们也可以查看特定语句的详细耗时情况:
sql
SHOW PROFILE FOR QUERY QUERY_ID;
1.5.4、explain 执行计划
EXPLAIN 或者 DESC 命令可以获取 MySQL 执行查询语句的信息,包括在查询语句中表的连接情况、连接顺序、是否使用索引等。
sql
EXPLAIN/DESC SQL;
这些字段分别代表:
- id
- 代表查询的序列号,表示查询中执行 SQL 子句或者操作表的顺序(id 相同,则执行顺序从上到下;id 不同,则值越大,越先执行)
- 多表查询时,就会有多个 id:
- select_type
- 查询类型,常见的取值有 SIMPLE (简单表,表示不需要使用表连接和子查询)、PRIMARY (主查询,也就是外层的查询)、UNION(UNION 语句中的第二个或者后面的查询语句)、SUBQUERY(SELECT/WHERE 之后包含了子查询)等。
- type
- 表示连接类型,性能由好到差的连接类型依次为 NULL、system、const、eq_ref、ref、range、index、all。
- 一般我们希望尽可能把 type 优化的更高,但是一般不可能优化到 NULL,因为只有不访问任何表才 type 会为 NULL;访问系统表 type 才会为 system;访问主键或者唯一索引时 type 为 const;访问非唯一索引 type 就是 ref;当使用了索引时,type 是 index,但是也会扫描整个索引树,性能也不会比 all 高多少。
- possible_key
- 在这张表上可能用到的索引,一个或多个
- key
- 实际用到的索引,如果为 NULL,则没有使用索引
- key_len
- 索引使用的字节数,索引字段的最大可能长度,并非实际使用长度,在不损失精度的情况下越短越好
- rows
- 执行查询的行数,在 InnoDB 表中,这是一个估计值
- filtered
- 返回结果占读取行数的百分比,filtered 的值越大越好
- extra
- 额外的信息
1.6、索引使用原则
1.6.1、最左前缀法则
如果索引了多列(联合索引),要遵守最左前缀法则。最左前缀法则指的是从索引最左列开始,并不能跳过索引中的列。如果跳过某一列,索引将部分失效(跳过的字段索引失效),索引列的顺序无所谓 (也就是说 where 后面的条件顺序无所谓,不是非要按着联合索引的列的顺序)。
1.6.2、范围查询
联合索引中,出现范围查询(>,<),范围查询右侧的列表索引失效
sql
select * from tb_user where profession = '数据科学与大数据技术' and age >30 and status = '01';
比如对于上面这条 SQL ,where 后的三个字段 profession 、age 和 status 三个列组成了联合索引,但是上面这条 SQL 中 status 的索引会失效,因为前面的 age 出现了 >。
这种情况也有避免的办法:也就是在业务允许的情况下,尽量使用 >= 或者 <= 。
注意:对于上面的 SQL ,并不是说把 age >30 和 status = '01' 交换位置使得范围查询右侧没有列就不会使 status 的索引失效了。因为这个顺序指的是联合索引的顺序,如果联合索引为 index_profession_age_status ,那么即使换了位置也没有意义,因为 age > 30 中有 > ,所以 age 索引之后的 status 索引就失效了。
1.6.3、索引列运算
不要在索引列上进行运算操作,否则索引将失效。
sql
-- 截取手机号后两位
select * from student tb_user where substring(phone,10,2) = '15';
1.6.4、字符串不加引号
字符串类型字段使用时,不加引号,索引将失效。
1.6.5、模糊匹配
如果仅仅是尾部模糊匹配,索引不会失效。但如果是头部模糊匹配,索引失效。
sql
-- 会失效
SELECT * FROM student WHERE profession like '%技术';
-- 不会失效
SELECT * FROM student WHERE profession like '技术%';
1.6.6、or 连接的条件
用 or 分开的条件,如果 or 前面的条件中的列有索引,而后面的列没有索引,那么设计的索引都不会被用到。也就是说,只有 or 两侧的列都有索引的时候索引才会生效。
1.6.7、数据分布影响
如果 MySQL 的优化器评估使用索引比扫描全表还慢,则不使用索引。
比如我们有 100 行数据(age 字段从0到99),即使我们给 age 列建立的索引,那么当查询过滤条件式是 age > 10时,MySQL 也是不会走索引的,因为大部分条件都满足这个条件,走索引效率更慢;当过滤条件是 age > 48 也是一样的;当过滤条件的结果占据全表的 1/2 以下时,才有可能会走索引。
所以,一条 SQL 语句会不会走索引并不取决于字段有没有索引,而是取决于数据的分布情况,如果过滤条件后的行数比全表数据行数一般都多的话是不会走索引的(包括比如 is null 或者 is not null 这种过滤条件)。
1.6.8、SQL 提示
SQL 提示,是优化数据库的一个重要手段。其实就是在 SQL 语句中加入一些人为的提示来达到优化操作的目的。
- use index
对于 use index 这种语法,MySQL 可能会听取意见,但也有可能依然我行我素,除非使用下面的 force index。
sql
SELECT * FROM table_name USE INDEX(index_name) WHERE XXX;
- ignore index
sql
SELECT * FROM table_name IGNORE INDEX(index_name) WHERE XXX;
- force index
sql
SELECT * FROM table_name FORCE INDEX(index_name) WHERE XXX;
比如一张表中,对于同一个字段它可能有单列索引,也有可能被包含在联合索引当中,那么当我们的过滤条件中包含该字段时(满足最左前缀原则),MySQL 就有可能走单列索引,也有可能走联合索引,这种情况下我们就可以通过上面的语法指定 MySQL 走哪个索引,从而起到优化的作用。
1.6.9、覆盖索引
尽量使用覆盖索引(查询使用了索引,并且需要返回的列在该索引中全部能够找到对应的值),减少 select * 。
说白了就是我们希望查询的字段都能在过滤条件中的索引列中找到,而 select * 的时候,因为查询的是所有字段,所以很可能会有部分字段未建立索引并且不在过滤条件当中。
比如我们有一张名为 tb_user 的表(主键是 id 字段),联合索引(index_user_pro_age_sta)由三个字段组成(profession、age、status),那么下面的两个 SQL 的 Extra 信息是不一样的:
sql
SELECT id,profession,age FROM tb_user WHERE profession='数据科学与大数据技术' AND age=18 AND status = '01';
这条 SQL 的 Extra 信息为:using where;using index ;表示查询使用了索引,而且需要的数据都在索引列中能找到(profession 和 age 是联合索引,所以 MySQL 会创建二级索引,而二级索引的叶子节点存储的正是主键),所以不需要回表查询。
sql
SELECT id,profession,age,status,name FROM tb_user WHERE profession='数据科学与大数据技术' AND age=18 AND status = '01';
这条 SQL 的 Extra 信息为:using index condition;表示查询使用了索引,但是因为 name 字段走二级索引(idx_user_pro_age_sta)是拿不到的,需要去二级索引的叶子节点找到 id 再去聚集索引,所以需要回表查询数据。
下面的例子是典型的覆盖索引,这条 SQL 返回的字段(id,name)通过索引 index_name 可以直接得到 name 列的数据,而 id 又是这个二级索引的叶子节点,所以不需要回表查询,性能很高:
而下面的这个 SQL 因为 gender 列无法获取,所以需要回表查询,性能要差一些:
综上,如果我们有一张表 user (id,username,password,status) ,当 SQL 语句是:
sql
SELECT id,username,password FROM user WHERE username = 'lidaxi';
最优的优化方案就是对 username 和 password 建立联合索引。
1.6.10、前缀索引
对于字段类型为 varchar、text 类型的字段,有时候需要索引很长的字符串(比如医院里的医嘱信息、查房记录等,字段类型常常是 varchar(1024) 或者 text 类型),这会让索引变得很大,查询时浪费大量的磁盘 IO,影响查询效率。所以这时候可以只将字符串的一部分前缀建立索引,这样可以大大节约索引空间,从而提高索引效率。
语法:
sql
-- 这里的 n 代表的是截取作为索引的字符串的长度
CREATE INDEX idx_name ON table_name(column_name(n));
而对于这里的前缀长度 n 的选择,可以参考下面:
可以根据索引的选择性(就相当于唯一性)来决定,选择性是指不重复的索引值和数据记录总数的比值,索引的选择性越高那查询效率当然越高(比如唯一索引的选择性为 1)。计算最佳的前缀长度可以参考下面的公式:
sql
-- 即使这种选择性高,但有时候需要考虑减小索引的体积选择使用前缀索引
SELECT COUNT(DISTINCT field_name) / COUNT(*) FROM table_name;
SELECT COUNT(DISTINCT SUBSTRING(field_name,1,5)) / COUNT(*) FROM table_name;
1.6.11、单列索引和联合索引
在业务场景中,如果存在多个查询条件,考虑针对于查询字段建立索引时,建议建立联合索引(毕竟建立了联合索引就不需要回表查询);而如果建立的是单列索引,那么 MySQL 在查询多列的时候只能使用一个效率比较高的单列索引去叶子节点查询出 id,然后再回表查询得到所有需要的字段的数据。
下面是一个使用 name 和 phone 建立的联合索引(因为 phone 是唯一的,所以联合索引肯定也是唯一的,就可以创建一个唯一索引):
这个二级索引的 B+Tree 会先按照 phone 的字典序进行排序,如果相同会按照 name 再进行排序。
1.7、索引设计原则
- 针对数据量比较大(100w+),且查询比较频繁的表建立索引;
- 针对常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引,而且尽量建立联合索引;
- 尽量选择区分度高的列作为索引,尽量建立唯一索引(比如身份证号、手机号);
- 对于比较长的字符串字段,建立前缀索引,节省磁盘 IO;
- 尽量建立联合索引,减少单列索引,因为联合索引很多时候可以覆盖索引,避免回表;
- 要控制索引的数量,索引并不是越多越好,索引越多维护代价越大,影响增删改的效率,而且占用磁盘空间;
- 如果索引列不能存储 NULL 值,请在建表时使用 NOT NULL 进行约束。这样优化器就可以更好地确定哪个索引最有效地用于查询;
总结
- 首先,索引是一种高效获取数据的数据结构(有序的);
- 索引的结构主要包括两种:
- B+Tree:所有的数据都出现在叶子节点,叶子节点是双向链表
- Hash:只有 Memory 存储引擎支持这种索引结构,它是一个哈希表,检索性能很高,只需要计算出字段的哈希值就可以直接定位到数据,如果存在哈希碰撞,只需要沿着链表寻找即可;但是不足之处就是仅支持精确匹配,不支持范围查询,无法利用索引完成排序(因为哈希运算结果是无序的)
- 索引的分类
- 索引可以分为主键索引、唯一索引、常规索引和全文索引
- 在 InnoDB 存储引擎中,根据存储格式索引又可以分为聚集索引(只能存在一个,一般是主键或者第一个唯一索引,如果主键和唯一索引都没有,MySQL 会自动生成一个隐藏的 row_id 作为聚集索引)和二级索引;区别在于聚集索引的叶子节点上存储的是一行的数据,而二级索引的叶子节点存储的是主键;
- 索引的语法:
- create [unique] index xxx on xxx(xx)
- show index from xxx
- drop index xxx on xxx
- SQL 性能分析
- 执行频次(查看当前数据库中哪类SQL执行的最多)、慢查询日志(开启之后,查询时间超过设置阈值的 SQL 会被记录)、profile(查看每条 SQL 语句每一阶段的执行情况)、explain(查看索引是否命中)
- 索引使用原则
- 联合索引(最左前缀法则)、尽量使用 >= 或 <=
- 索引失效(函数运算、字符串缺失引号、前缀模糊匹配、or 两侧一侧有索引列一侧没有、MySQL 评估全表比走索引还快(数据分布的影响))
- SQL 提示(当有多个索引时,MySQL 会自动选择索引,我们也可以给 MySQL 提示、忽略或者强制指定使用的索引)
- 覆盖索引(查询返回的列在索引结构当中都已经包含了,不需要回表查询)
- 前缀索引(长字符串)
- 单列/联合索引(推荐联合索引、很多时候避免回表查询)
- 索引设计原则,哪些表需要建立索引
- 表(数据量大100w+、查询频次较高)
- 字段(经常出现在 where、order by、group by 之后的)
- 索引(尽量唯一索引,尽量建立联合索引,大文本使用前缀索引)