MySQL 索引

MySQL 索引

索引存储结构

InnoDB 的 B+ 树

每一个索引在 InnoDB 里面对应一棵 B+ 树。B+ 树能够很好地配合磁盘的读写特性,减少单次查询的磁盘访问次数。

根据叶子节点的内容,索引类型分为主键索引非主键索引

  • 主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)。
  • 非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)。

主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。

MyISAM 的 B+ 树

MyISAM 索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。

B 树和 B+ 树

B 树示例:

B+ 树示例:

B 和 B+ 树的区别在于,B+ 树的非叶子结点只包含导航信息,不包含实际的值,所有的叶子结点和相连的节点使用链表相连,便于区间查找和遍历。

B+ 树的优点在于:

  • 由于 B+ 树在内部节点上不好含数据信息,因此在内存页中能够存放更多的 key。 数据存放的更加紧密,具有更好的空间局部性。因此访问叶子几点上关联的数据也具有更好的缓存命中率。
  • B+ 树的叶子结点都是相链的,因此对整棵树的便利只需要一次线性遍历叶子结点即可。而且由于数据顺序排列并且相连,所以便于区间查找和搜索。而 B 树则需要进行每一层的递归遍历。相邻的元素可能在内存中不相邻,所以缓存命中性没有 B+ 树好。

但是 B 树也有优点,其优点在于,由于 B 树的每一个节点都包含 key 和 value,因此经常访问的元素可能离根节点更近,因此访问也更迅速

回表

回到主键索引树搜索的过程,我们称为回表。

select * from T where k between 3 and 5

覆盖索引

ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。

select ID from T where k between 3 and 5

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

最左前缀原则

B+ 树这种索引结构,可以利用索引的“最左前缀”,来定位记录。

上述联合索引对于如下查询请求,也是可以满足的:

  • 查到所有名字是“张三”的人
  • 查的是所有名字第一个字是“张”的人

不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符

在建立联合索引的时候,如何安排索引内的字段顺序 ?

  • 如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。
  • 考虑空间占用问题

索引下推

索引:联合索引(name, age)

mysql> select * from tuser where name like '张%' and age=10 and ismale=1;

这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足条件的记录 ID3。

  • 在 MySQL 5.6 之前,只能从 ID3 开始一个个回表。到主键索引上找出数据行,再对比字段值。
  • 而 MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

前缀索引

直接创建完整索引,这样可能比较占用空间;使用前缀索引 (定义字符串的一部分作为索引),定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本,但会增加查询扫描次数,并且不能使用覆盖索引。

mysql> alter table SUser add index index1(email);

mysql> alter table SUser add index index2(email(6));

当要给字符串创建前缀索引时,有什么方法能够确定我应该使用多长的前缀呢?

实际上,我们在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。

mysql> select 
  count(distinct left(email,4)) as L4,
  count(distinct left(email,5)) as L5,
  count(distinct left(email,6)) as L6,
  count(distinct left(email,7)) as L7,
from SUser;

使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如 5%。然后,在返回的 L4~L7 中,找出不小于 L * 95% 的值,假设这里 L6、L7 都满足,你就可以选择前缀长度为 6

优化语句步骤

通过 EXPLAIN 分析 SQL 执行计划

假设现在我们使用 EXPLAIN 命令查看当前 SQL 是否使用了索引,先通过 SQL EXPLAIN 导出相应的执行计划如下:

下面对图示中的每一个字段进行一个说明。

  • id:每个执行计划都有一个 id,如果是一个联合查询,这里还将有多个 id。
  • select_type:表示 SELECT 查询类型,常见的有 SIMPLE(普通查询,即没有联合查询、子查询)、PRIMARY(主查询)、UNION(UNION 中后面的查询)、SUBQUERY(子查询)等。
  • table:当前执行计划查询的表,如果给表起别名了,则显示别名信息。
  • partitions:访问的分区表信息。
  • type:表示从表中查询到行所执行的方式,查询方式是 SQL 优化中一个很重要的指标,结果值从好到差依次是:system > const > eq_ref > ref > range > index > ALL
  • possible_keys:可能使用到的索引。
  • key:实际使用到的索引。
  • key_len:当前使用的索引的长度。
  • ref:关联 id 等信息。
  • rows:查找到记录所扫描的行数。
  • filtered:查找到所需记录占总扫描记录数的比例。
  • Extra:额外的信息。

type 具体示例

  • system/const:表中只有一行数据匹配,此时根据索引查询一次就能找到对应的数据。

  • eq_ref:使用唯一索引扫描,常见于多表连接中使用主键和唯一索引作为关联条件。

  • ref:非唯一索引扫描,还可见于唯一索引最左原则匹配扫描。

  • range:索引范围扫描,比如,<,>,between 等操作。

  • index:索引全表扫描,此时遍历整个索引树。

  • ALL:表示全表扫描,需要遍历全表来找到对应的行。

通过 Show Profile 分析 SQL 执行性能

上述通过 EXPLAIN 分析执行计划,仅仅是停留在分析 SQL 的外部的执行情况,如果我们想要深入到 MySQL 内核中,从执行线程的状态和时间来分析的话,这个时候我们就可以选择 Profile。

Profile 除了可以分析执行线程的状态和时间,还支持进一步选择 ALL、CPU、MEMORY、BLOCK IO、CONTEXT SWITCHES 等类型来查询 SQL 语句在不同系统资源上所消耗的时间。以下是相关命令的注释:

SHOW PROFILE [type [, type] ... ]
[FOR QUERY n]
[LIMIT row_count [OFFSET offset]]

type参数:
| ALL:显示所有开销信息
| BLOCK IO:阻塞的输入输出次数
| CONTEXT SWITCHES:上下文切换相关开销信息
| CPU:显示CPU的相关开销信息 
| IPC:接收和发送消息的相关开销信息
| MEMORY :显示内存相关的开销,目前无用
| PAGE FAULTS :显示页面错误相关开销信息
| SOURCE :列出相应操作对应的函数名及其在源码中的调用位置(行数) 
| SWAPS:显示swap交换次数的相关开销信息

Show Profiles 只显示最近发给服务器的 SQL 语句,默认情况下是记录最近已执行的 15 条记录,我们可以重新设置 profiling_history_size 增大该存储记录,最大值为 100。

获取到 Query_ID 之后,我们再通过 Show Profile for Query ID 语句,就能够查看到对应 Query_ID 的 SQL 语句在执行过程中线程的每个状态所消耗的时间了:

语句优化改写示例

最左前缀匹配

CREATE INDEX idx_a_b ON t1(a, b, c);

WHERE a = ? -- √
WHERE a = ? AND b = ? -- √
WHERE a = ? AND c = ? -- 仅仅用上了 a 索引

范围查找

WHERE ... BETWEEN ... AND ...
> -- √
< -- √
IN (...) -- IN 不属于范围查找的范畴

JOIN/ON/USING 列

-- ON、USING 确保存在索引

WHERE

CREATE INDEX idx_a_b_c ON t1(a, b, c);

--              ↓ (索引只会用上 a, b 索引)
WHERE a = ? AND b < 5000 AND c > 10000;
--                        ↓ (索引用上 a,b,c 索引)
WHERE a = ? AND b = ? AND c > 10000;

ORDER BY

CREATE INDEX idx_a_b_c ON t1(a, b, c);

ORDER BY a, b, c; -- 确定值
WHERE a = ? AND b = ? ORDER BY c; -- 确定值

-- 确保使用索引排序,如果没有用上,那么 EXPLAIN 会出现 filesort
-- 增加 sort_buffer_size:每个排序线程缓冲区大小

GROUP BY 避免排序

-- ORDER BY NULL 避免排序
GROUP BY cluster_id ORDER BY NULL LIMIT 10;

IN

-- MySQL 会排序 IN 列表、然后二分查找定位数据
-- IN 列表不易过长,200 个以内

-- IN 转化为多个 = 查询,例如
SELECT * FROM table_a WHERE id IN (SELECT id FROM table_b);

-- 程序实现
SELECT id FROM table_b;
SELECT id FROM table_a WHERE id = ?;

UNION

-- UNION 语句默认是移除重复记录的, 需要用到排序操作
-- 尽量使用 UNION ALL

LIKE

-- 《MySQL 管理之道》
LIKE 'xxx%' -- 可以用上索引
LIKE '%xxx%' -- 不可以用上索引

OR 语句

-- 《MySQL 管理之道》

-- OR 条件不会用上索引
SELECT * FROM USER WHERE name = 'd' OR age = 41;

-- 改为 UNION ALL
SELECT * FROM USER WHERE name = 'd'
UNION ALL
SELECT * FROM USER WHERE age = 41;

索引失效

-- (1)字段使用函数,索引失效
--                                      ↓ (采用了函数)
SELECT create_time FROM t1 WHERE DATE(create_time) = curdate();

SELECT create_time FROM t1 WHERE
-- ↓ (WHERE 后面的字段并未采用函数)
create_time > DATE_FORMAT(CURDATE, '%Y-%m-%d');

-- (2)类型转换,索引失效
--                                        ↓ (应该加上引号)
SELECT * FROM player_info WHERE name = 104515967;
SELECT * FROM player_info WHERE name = '104515967';

-- (3)取出的数据量超过表中的数据 20% 的时候,索引失效
-- MYSQL 认为全表扫描更快

In 和 Exists 效率

--                        ↓ (查出 B 表所有 ID 并放到内存)
--             B 表数据量大,不适合用 in 语句
--             in 语句适合,B 表数据量比 A 表数据量小的情况
select * from A where id in (select id from B);
--                       ↓ (不缓存 exist 结果集)
--             B 表数据量大,适合用 exist 语句,因为执行 O(A) 次
--   通常情况,exist 效率高于 in,in 不走索引
--
--   EXISTS 用于检查子查询是否至少会返回一行数据,
--   该子查询实际上并不返回任何数据,
--   而是返回值 True 或 False
select * from A where exists (select 1 from B where A.id=B.id);

子查询优化分页查询

通常我们是使用 + 合适的 order by 来实现分页查询,这种实现方式在没有任何索引条件支持的情况下,需要做大量的文件排序操作(file sort),性能将会非常得糟糕。如果有对应的索引,通常刚开始的分页查询效率会比较理想,但越往后,分页查询的性能就越差。

这是因为我们在使用 LIMIT 的时候,偏移量 M 在分页越靠后的时候,值就越大,数据库检索的数据也就越多。例如 LIMIT 10000,10 这样的查询,数据库需要查询 10010 条记录,最后返回 10 条记录。也就是说将会有 10000 条记录被查询出来没有被使用到。

select * from `demo`.`order` order by order_no limit 10000, 20;

通过 EXPLAIN 分析可知:该查询使用到了索引,扫描行数为 10020 行,但所用查询时间为 0.018s,相对来说时间偏长了。

以上分页查询的问题在于,我们查询获取的 10020 行数据结果都返回给我们了,我们能否先查询出所需要的 20 行数据中的最小 ID 值,然后通过偏移量返回所需要的 20 行数据给我们呢?我们可以通过索引覆盖扫描,使用子查询的方式来实现分页查询:

select * from `demo`.`order` where id > (select id from `demo`.`order` order by order_no limit 10000, 1)  limit 20;

通过 EXPLAIN 分析可知:子查询遍历索引的范围跟上一个查询差不多,而主查询扫描了更多的行数,但执行时间却减少了,只有 0.004s。这就是因为返回行数只有 20 行了,执行效率得到了明显的提升。

using filesort

-- (1)
-- 只有 pid 一个索引是不行的,会存在 filesort
-- 必须建立联合索引 (pid, change_date) 才可以
SELECT * FROM t1 WHERE pid = 12345 ORDER BY change_date;

-- (2)多个字段排序,一个降序,一个升序也会出现 filesort
-- 将 ASC 改为 DESC 即可。顺序必须一致
SELECT * FROM t1 WHERE pid = 12345 
ORDER BY change_date DESC, delta_rmb ASC;

表设计优化

存储手机号

char(11); -- 一般字符集是 utf8,utf8 占用 3 个字节,11 * 3 = 33 字节
bigint(20); -- 宽度 20,只占用 8 字节,所以推荐 bigint

IP 地址

INET_ATON(); -- IP 地址转为数字
INET_NTOA(); -- 数字转为 IP 地址

INT(11) UNSIGNED; -- 必须采用 UNSIGNED 否则会溢出

增加/删除/更新字段

-- VARCHAR(5) -> VARCHAR(10)
-- 参考自《MySQL 管理之道》
-- 注意此种方法对 DECIMAL 类型无效
-- (1) 创建临时表
CREATE TABLE T1_TMP(
  	ID INT,
  	NAME VARCHAR(10)
);
-- (2) 替换 .frm 表结构文件
-- 锁定表,防止表被打开
FLUSH TABLES WITH READ LOCK;
cp /usr/local/mysql/data/book/t1_tmp.frm
	/usr/local/mysql/data/book/t1.frm
-- (3) 测试
UNLOCK TABLES;
INSERT INTO t1 VALUES (2, 'zhaokun');
-- (4) 观察表结构
SHOW CREATE TABLE t1\G
-- (5) 如果有主从同步,那么 slave 也需要执行,否则会报错

反范式设计

反范式化是针对范式化而言的,在前面介绍了数据库设计的范式,所谓的反范式化就是为了性能和读取效率的考虑而适当的对数据库设计范式的要求进行违反,而允许存在少量的数据冗余,换句话来说反范式化就是使用空间来换取时间