深入理解 Mysql——高性能索引与高性能 SQL(转)
原文地址 张硕的博客
文章目录
从理解磁盘 IO 开始
主轴让磁盘盘片转动,然后传动手臂可伸展让读取头在盘片上进行读写操作。每个盘片有两面,都可记录信息,所以一张盘片对应着两个磁头。
磁盘物理结构如下图:
** 扇区:** 盘片被分为许多扇形的区域,每个区域叫一个扇区,硬盘中每个扇区的大小固定为 512 字节
** 磁道:** 盘片表面上以盘片中心为圆心,不同半径的同心圆环称为磁道。
一个 I/O 请求所花费的时间 = 寻道时间 + 旋转延迟 + 数据传输时间(约 10ms)
当需要从磁盘读取数据时,系统会将数据逻辑地址传给磁盘,磁盘的控制电路按照寻址逻辑将逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。为了读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点,磁头需要移动对准相应磁道,这个过程叫做寻道, 所耗费时间叫做寻道时间,然后磁盘旋转将目标扇区旋转到磁头下,这个过程耗费的时间叫做旋转时间。
寻道时间 (Tseek) :
将读写磁头移动至正确的磁道上所需要的时间。寻道时间越短,I/O 操作越快,目前磁盘的平均寻道时间一般在 3-15ms。
旋转延迟 (Trotation)
指盘片旋转将请求数据所在的扇区移动到读写磁盘下方所需要的时间。旋转延迟取决于磁盘转速,通常用磁盘旋转一周所需时间的 1/2 表示。比如:7200rpm 的磁盘平均旋转延迟大约为 60*1000/7200/2 = 4.17ms,而转速为 15000rpm 的磁盘其平均旋转延迟为 2ms。
数据传输时间 (Transfer)
是指完成传输所请求的数据所需要的时间,它取决于数据传输率,其值等于数据大小除以数据传输率。数据传输时间通常远小于前两部分消耗时间。简单计算时可忽略。
预读
当一次 IO 时,不光把当前磁盘地址的数据,而是把相邻的数据也都读取到内存缓冲区内,因为局部预读性原理告诉我们,当计算机访问一个地址的数据的时候,与其相邻的数据也会很快被访问到,所以每次读取页的整数倍 (通常一个节点就是一页)。每一次 IO 读取的数据我们称之为一页 (page)。
page
预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为 4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
IOPS 与吞吐量
连续读写性能很好,但随机读写性能很差
机械硬盘的连续读写性能很好,但随机读写性能很差,这主要是因为磁头移动到正确的磁道上需要时间,随机读写时,磁头需要不停的移动,时间都浪费在了磁头寻址上,所以性能不高。
IOPS
IOPS(Input/Output Per Second)即指每秒内系统能处理的 I/O 请求数量。
随机读写频繁的应用,如小文件存储等,关注随机读写性能,IOPS 是关键衡量指标。
可以推算出磁盘的 IOPS = 1000ms / (Tseek + Trotation + Transfer)
常见磁盘的随机读写最大 IOPS 为:
- 7200rpm 的磁盘 IOPS = 76 IOPS
- 10000rpm 的磁盘 IOPS = 111 IOPS
- 15000rpm 的磁盘 IOPS = 166 IOPS
磁盘吞吐量
指单位时间内可以成功传输的数据数量。
磁盘阵列与服务器之间的数据通道对吞吐量影响很大。
顺序读写频繁的应用,如视频点播,关注连续读写性能、数据吞吐量是关键衡量指标。
InnoDB 索引——B+Tree 索引
索引是什么?
MySQL 官方对索引的定义为:索引是帮助 MySQL 高效获取数据的数据结构。简而言之, 索引是数据结构。
B+Tree,简单来说就是一种为磁盘或者其他存储设备而设计的一种平衡二叉树, 在 B+tree 中所有记录都按照 key 的大小存放在叶子结点上,各叶子结点直接用指针连接。
由二叉树,平衡二叉树,BTree 演化而来。
二叉树 要保证父节点大于左子结点,小于右子节点。
平衡二叉树 在二叉树的基础上, 还要保证任一结点的两个儿子字树高度差不大于 1。
BTree 是一种自平衡二叉树, 继承了上述平衡二叉树的特性,另外并保证了每个叶子结点到根节点的距离相同。
BTree vs B+Tree:
B+ 树与 BTree 主要不同就是 data 的存放位置,以及叶子结点的指针构成链表。
- 键值的拷贝被存储在内部节点(或称非叶子结点);键值和记录存储在叶子节点;
- 一个叶子节点可以包含一个指针,指向另一个叶子节点以加速顺序存取。
二叉树
平衡二叉树
BTree
B+Tree
可以在这个网站上查看动画进行操作 Algorithms。
索引为什么使用 B+Tree?
- ** 每个页的叶子结点通常包含较多的记录,具有较高的扇出性 (可理解为每个节点对应的下层节点较多),因此树的高度较低 (3~4),而树的高度也决定了磁盘 IO 的次数,从而影响了数据库的性能。** 一般情况下,IO 次数与树的高度是一致的
- 对于组合索引,B+tree 索引是按照索引列名 (从左到右的顺序) 进行顺序排序的,因此可以将随机 IO 转换为顺序 IO 提升 IO 效率; 并且可以支持 order by \group 等排序需求; 适合范围查询
聚集索引 与 非聚集索引
聚集索引:
InnfoBD 引擎是索引组织表,所有数据都存放在聚集索引中。
准确来说聚集索引并不是某种单独的索引类型,而是一种数据存储方式。就是指在同一个结构中保存了 B+tree 索引以及数据行。InnoDB 中通常主键就是一个聚集索引。
innoDB 中,用户如果没有设置主键索引,会随机选择一个唯一的非空索引替代,
如果没有这样的索引,会隐式的定义一个主键作为隐式的聚集索引。
通常将主键设置为一个与业务无关的自增数字,这样能保证按照主键顺序插入数据,避免页分裂以及碎片问题。
主键索引的非叶子结点存放的是 <.key,address.>,address 就是指向下一层的指针。
主键索引的叶子结点保存了所有列的信息,因此通过主键索引可以快速获取数据。
辅助索引
(或称为非聚集索引、二级索引)
辅助索引的叶子结点并没有存放数据,而是存放了主键值。
因为二级索引叶子页中存放了主键索引的值信息,如果主键索引很大的话,会导致所有索引都比较大。因此主键索引尽可能要小
也就是说使用辅助索引查询,会通过叶子结点找到对应的主键,在主键索引中找到最终的数据。
为什么要使用索引
- 使用索引可以大大减少服务器需要扫描的数据量。
- 使用索引可以帮助服务器避免排序或者临时表
- 索引是随机 I\O 变为 顺序 I\O.
索引的适用范围
索引并不是适用于任何情况。对于中型、大型表适用。对于小型表全表扫描更高效。而对于特大型表,考虑 "分区" 技术。
高性能索引策略(其他类型索引:略)
以下讲解使用如下表作为示例:
mysql> show create table people \G
*************************** 1. row ***************************
Table: people
Create Table: CREATE TABLE `people` (
`last_name` varchar(50) NOT NULL,
`first_name` varchar(50) NOT NULL,
`dob` date NOT NULL,
`gender` enum('m','f') NOT NULL,
KEY `last_name` (`last_name`,`first_name`,`dob`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
最左前缀匹配原则
对于 KEY last_name
(last_name
,first_name
,dob
),where 后的谓词必须包含 last_name(组合索引的最左列),否则无法使用这个索引.
示例:
select ... from people where .... last_....
这个语句将使用 last_name
(last_name
,first_name
,dob
) 索引。
无法跳过某个列使用后续索引列
示例:
SELECT ... FROM people WHERE last_
这个语句只使用了last_name
(last_name
,first_name
,dob
) 的 last_name 列,因为缺少 first_name,所以后续 dob 列也无法从索引中搜索。
范围查询后的列无法使用索引
示例:
SELECT ... FROM people WHERE last_name > "..." AND first_
这个语句只使用了last_name
(last_name
,first_name
,dob
) 的 last_name 列,因为 last_name 使用了范围查询,所以后续索引的两个列无法使用。
什么事范围查询:
使用了范围查询的语句。范围查询指使用 “>” 、"<"、“between” “like" 的查询。注意 “in” 不算范围查询,属于多值查询条件。
列作为函数参数或表达式的一部分
列作为函数参数或表达式的一部分无法正常使用索引。
示例 1:
SELECT ... FROM people WHERE last_name+1 = "1001"
示例 2:
SELECT ... FROM people FORCE INDEX(last_name) WHERE LEFT(last_name,3) = "..."
以上示例均无法使用last_name
(last_name
,first_name
,dob
) 索引。explain 展示位全表扫描。
前缀索引与索引选择性
什么是索引的选择性:
索引的基数(Cardinality) / 表的总记录数(#T)
select count(Distinct columnName)/count(*) from Table
范围从 1#T ~ 1 ,值越高查询效率越高。唯一索引的选择性是:1.
注:索引的基数(Cardinality)不重复的索引值。此处计算的基数(Cardinality),与 SHOW INDEX 语句中的 Cardinality 并不一致!SHOW INDEX 语句中的只是预估值。
一般情况,将选择性高的列放在左边,选择性高代表这个列的过滤性较好,尽可能的尽快过滤掉无用的数据。
前缀索引
对于 较大的 Varchar 类型、Text 类型、Blob 类型,需要建立索引时必须使用前缀索引,因为 mysql 不允许索引完整大小,而且索引字段越大效率越差。
可以索引开始的部分字符串(取代全部),大大节约索引空间,提高索引效率。但这样会降低索引的选择性。
所以,对比较长的 (Varchar、Text、BLOB 等等数据类型)列查询,要保证索引的选择性,又要不能太长以节省空间。所以 “前缀” 需要选的恰到好处:
“前缀索引” 的基数应该接近完整的列索引的基数。
示例:前 7 个字符的前缀索引
mysql> select count(Distinct last_name)/count(*) from people ;
+------------------------------------+
| count(Distinct last_name)/count(*) |
+------------------------------------+
| 0.7059 |
+------------------------------------+
1 row in set (0.07 sec)
------------------------------------------------------------
mysql> select count(Distinct left(last_name,5))/count(*), count(Distinct left(la
st_name,6))/count(*) ,count(Distinct left(last_name,7))/count(*) from people \G
*************************** 1. row ***************************
count(Distinct left(last_name,5))/count(*): 0.6471
count(Distinct left(last_name,6))/count(*): 0.7059
count(Distinct left(last_name,7))/count(*): 0.7059
1 row in set (0.00 sec)
mysql> alter table people add key (last_name(6))
所以使用前 6 个字符即可达到完整字段的过滤性。
注意:
前缀索引是能够使索引更小,更快的方法,但是无法使用前缀索引做 Group By\Order By, 也不能用前缀索引做覆盖查询(Using Index)。
除了使用前缀索引的方式处理这类大字段索引的情况,还有如下方式:
伪哈希索引
- step1 建表语句
-- step1建表语句
CREATE TABLE `people` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`last_name` VARCHAR(50) COLLATE utf8_bin NOT NULL,
`first_name` VARCHAR(50) COLLATE utf8_bin NOT NULL,
`dob` DATE NOT NULL,
`gender` ENUM('m','f') COLLATE utf8_bin NOT NULL,
`blog_url` VARCHAR(128) COLLATE utf8_bin DEFAULT NULL,
`crc32_url` BIGINT(20) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `last_name` (`last_name`,`first_name`,`dob`),
KEY `crc32_url` (`crc32_url`)
) ENGINE=INNODB
因为 blog_url 是一个较长的字符串,所以直接将 blog_url 作为索引列会影响索引的整体效率。现在,尝试用一个伪 hash 值做一个伪 hash 索引。
- step2 建立触发器,用于每次插入 \ 更新维护 hash 值
-- step2 建立触发器,用于维护hash值
DELIMITER //
CREATE TRIGGER pseudohash_crc32_ins BEFORE INSERT
ON people FOR EACH ROW
BEGIN
SET New.crc32_url = CRC32(New.blog_url);
END //
CREATE TRIGGER pseudohash_crc32_upd BEFORE UPDATE
ON people FOR EACH ROW
BEGIN
SET New.crc32_url = CRC32(New.blog_url);
END //
DELIMITER;
效果如下,非常明显:
SELECT COUNT(1) FROM people ;
/**
*/
SELECT blog_url FROM people WHERE blog_url="http://www.-588141732.com" AND crc32_url=1790086969
/**
sql执行时间:
执行耗时 : 0.004 sec
传送时间 : 0 sec
总耗时 : 0.004 sec
explain:
"id" "select_type" "table" "partitions" "type" "possible_keys" "key" "key_len" "ref" "rows" "filtered" "Extra"
"1" "SIMPLE" "people" \N "ref" "crc32_url" "crc32_url" "9" "const" "1" "10.00" "Using where"
*/
EXPLAIN SELECT blog_url FROM people WHERE blog_url="http://www.-588141732.com"
/**
sql执行时间:
执行耗时 : 0.413 sec
传送时间 : 0 sec
总耗时 : 0.413 sec
explain:
"id" "select_type" "table" "partitions" "type" "possible_keys" "key" "key_len" "ref" "rows" "filtered" "Extra"
"1" "SIMPLE" "people" \N "ALL" \N \N \N \N "630928" "10.00" "Using where"
*/
crc 函数当数据量达到 93000 时,会产生 1% 的冲突。
如果要避免 hash 冲突的概率可以使用 MD5() 截取的方式取代 crc()。
如:
SELECT CONV(RIGHT(MD5("http://www.-aqq3feaeaff41732fff.com"),16),16,10) AS hashCode;
三星索引 (关系型数据库索引设计及优化)
- 一星 使用 where 后的谓词列,按照选择性构造索引。
- 二星 如果语句中有排序操作,使用索引自带的顺序的排序(消除 fileSort)。
- 三星 如果可以的话,将 select 后的还不在索引中的列名放到索引后边,可以覆盖索引(using index),而不需要读取表数据。
如下这个 sql 就是一个三星索引。
SELECT last_name,first_name,dob FROM people WHERE last_name=“101899” AND first_name=“10189900” AND dob=“2017-08-03” ORDER BY dob
在实际应用中,无法保证三个星每个星都满足。需要权衡取舍。
冗余与重复索引
- 重复索引:相同列上按照相同顺序创建的相同类型的索引。
- 冗余索引:已有索引(A,B), 现在 创建索引 (A) 就是一个冗余索引,因为,索引(A)完全可以被 (A,B) 替代。然而,(B,A)、(B) 并不是 (A,B)的冗余索引。
另外当 Id 列是主键,(A,Id)是冗余索引,因为二级缓存的叶子节点包含了主键值。直接使用(A)作为索引即可。
未使用的索引 也是累赘。建议删除。
Explain output \Profile
关于 explain 的详细解释,请参考:
explain 详解
或者查看官网文档
explain-output
除了 explain, 还可以查看 sql 耗时分布情况。
Show Profiles \Show profile queryId
SHOW PROFILES;
/**
"Query_ID" "Duration" "Query"
...
"19" "0.00007350" "select ...."
"20" "0.00026150" "select state, round(sum(duration),5) ....."
...
*/
SHOW PROFILE FOR QUERY 24
/**
"Status" "Duration"
"Creating sort index" "0.748448"
"freeing items" "0.001355"
"starting" "0.000029"
"cleaning up" "0.000019"
"init" "0.000015"
"statistics" "0.000012"
"end" "0.000008"
"preparing" "0.000007"
"Opening tables" "0.000007"
"closing tables" "0.000006"
"Sending data" "0.000005"
"query end" "0.000005"
"optimizing" "0.000004"
"System lock" "0.000004"
"Sorting result" "0.000003"
"checking permissions" "0.000001"
"checking permissions" "0.000001"
"executing" "0.000001"
*/
高性能 SQL
理解 sql 执行过程
Step1: 客户端向 Mysql 服务器发送 SQL 语句。
使用 "半双工" 通信方式,客户端或服务端在一个连接上同一时刻只允许一方进行数据传输,并且直到数据传输完成,另一方才能执行传输。
当语句太长,超过 max_allowed_packet , 服务端会拒绝接收。
通常建议加上 limit,可以减少不必要的数据从服务端发送到客户端。
Step2: 服务器收到后先查询” 查询缓存 “, 如果命中,从缓存中直接返回 sql 执行的结果集。否则,进入 Step3。
这个缓存通过一个对大小写敏感的 hash 算法实现,及时只有一个字节不匹配,那也无法命中。
Step3: 服务器解析、预处理、优化 sql 执行计划,然后将处理好的 sql 放入查询的执行计划中。
在这个阶段,sql 会被转换为一个执行计划,使用这个执行计划于具体的存储引擎进行交换。这个阶段包括,解析、预处理、优化 sql 执行计划这三个子任务。
Step4: 执行引擎通过调用 "存储引擎"(如,innodb、myisam 等) 提供的 API 去执行这个计划。
Step5:服务器返回结果给客户端
慢 SQL 优化步骤
Step1:explain 查看 (show profile 可以查看耗时分布)
Step2: 确认优化目标 \ 方向,对于复杂 sql 需要理清执行步骤
目标 1. type 是否能够按照 const>eq_reg>ref>range>index>ALL 的顺序优化,最差也要达到 range 级别。
目标 2. 避免 filesort 的出现、避免 rows 数据量太大等负面字段、索引选择性是否足够、对于关联查询尽量保证关联字段在第二张表上有可用索引(原因:NestLoop)。
Step3: 遵照 SQL 索引原则增加或调整 SQL,常见如下 (可参考上文去理解)
- 保证 where 后的谓词尽可能出现在索引中,并且组合索引按选择性顺序排序,范围查询条件尽量放在后边
- (如果 sql 中有排序语句) 是否能够通过索引解决排序问题
- 是否能使用 use index,全部通过索引获取数据
NestLoop
除了 full Join,其他所有类型的查询 SQL,都以类似的方式执行。
NestLoop (内嵌套循环)算法,简单来说就是逐行查询处理,或者内嵌逐行查询。对于高版本的使用 join buffer 对上层表数据缓存,无需多次遍历上层表,下层表直接使用(Block NestLoop)。
以下以两个示例详细说明执行计划,其他 join 以及单表查询原理也是类似的!
Join 执行顺序伪代码演示
示例 1:内关联 inner join
SELECT
people.id,user.id
FROM
user
INNER JOIN
people ON user.name = people.name
WHERE
user.enumType = 'orange'
AND people.enumType = 'orange';
示例 1 对应伪代码:
//先扫描先执行的表,优化器通常选择关联的较小的表,explain第一行。
people_iterator=people_table.iterator();
//逐行遍历,并且丢弃where筛选不通过的行
while(people_iterator.hashNext()){
people_item=people_iterator.next();
if(people_item.enumType=='orange'){
//筛选通过后,在进入第二个嵌套
user_iterator=user_table.iterator()
//逐行遍历第二个表
while(user_iterator.hashNext()){
user_item=user_iterator.next();
//过滤:on 的条件匹配以及当前表的where条件
if(user_item.name==people_item.name&&user_item.enumType=='orange' ){
output(people.id,user.id);
}
}
}
}
示例 2:非内关联
SELECT
people.id,user.id
FROM
user
LEFT JOIN
people ON user.name = people.name
WHERE
user.enumType = 'orange'
AND people.enumType = 'orange';
示例 2 伪代码
//先扫描先执行的表,优化器通常选择关联的较小的表,explain第一行。
people_iterator=people_table.iterator();
//逐行遍历,并且丢弃where筛选不通过的行
while(people_iterator.hashNext()){
people_item=people_iterator.next();
if(people_item.enumType=='orange'){
//筛选通过后,在进入第二个嵌套
user_iterator=user_table.iterator()
//逐行遍历第二个表
while(user_iterator.hashNext()){
user_item=user_iterator.next();
//过滤:on 的条件匹配以及当前表的where条件
if(user_item.name==people_item.name&&user_item.enumType=='orange' ){
output(people.id,user.id);
}
//与innerjoin不同的,leftJoin需要即使on条件不成立,也要保留左边数据
else if(!is_innerJoin){
output(null,user.id);//保留左边数据
}
}
}
}
注意:尽量保证关联字段在第二张表上有可用索引。
(因为第一张表示全表扫描,然后会对第二张表用关联字段查询,详情请看 NestLoop 理解关联过程)
SQL 使用常用策略
1. 通常情况下,使用一个性能好的 sql 去做更多的事情,而不是使用多个 sql。
除非这个 sql 过长效率低下或者对于 delete 这种语句,过长的 delete 会导致太多的数据被锁定,耗尽资源,阻塞其他 sql。
2. 分解关联查询。
将关联 (** join…) 放在应用中处理,执行小而简单的 sql,好处是:
- 分解后的 sql 通常由于简单固定,能更好的使用 mysql 缓存。
- 执行拆分后的 sql,可以减少锁的竞争。
- 程序具备更强的扩展性
- 关联 sql 使用的是内嵌循环算法 nestloop,而应用中可以使用 hashmap 等结构处理数据,效率更高
关于 Count()
count() 函数有两种含义: 统计行数、统计列数。
比如:count(*) 代表统计的行数;count(talbe.cloumn) 代表统计的是这个列不为 null 的数量。
关于 Limit
在使用 Limit 1000,20 这种操作的时候,mysql 会扫描偏移量 (1000 条无效查询) 数据,而只取后 20 条,尽量避免这种写法,想办法规避。
关于 Union
需要将 where、order by、limit 这些限制放入到每个子查询,才能重分提升效率。另外如非必须,尽量使用 Union all,因为 union 会给每个子查询的临时表加入 distinct,对每个临时表做唯一性检查,效率较差。
查看 MYSQL 使用情况:
/*1.查看索引
(1)单位是GB*/
SELECT CONCAT(ROUND(SUM(index_length)/(1024*1024*1024), 6), ' GB') AS 'Total Index Size'
FROM information_schema.TABLES WHERE table_schema LIKE 'database';
/*
+------------------+
| Total Index Size |
+------------------+
| 1.70 GB |
+------------------+
*/
/*
(2)单位是MB
*/
SELECT CONCAT(ROUND(SUM(index_length)/(1024*1024), 6), ' MB') AS 'Total Index Size'
FROM information_schema.TABLES WHERE table_schema LIKE 'database';
/*
其中“database”为你所要查看的数据库
*/
/*
2.查看表空间
*/
SELECT CONCAT(ROUND(SUM(data_length)/(1024*1024*1024), 6), ' GB') AS 'Total Data Size'
FROM information_schema.TABLES WHERE table_schema LIKE 'database';
/*
+-----------------+
| Total Data Size |
+-----------------+
| 3.01 GB |
+-----------------+
*/
/*
3.查看数据库中所有表的信息
*/
SELECT CONCAT(table_schema,'.',table_name) AS 'Table Name',
table_rows AS 'Number of Rows',
CONCAT(ROUND(data_length/(1024*1024*1024),6),' G') AS 'Data Size',
CONCAT(ROUND(index_length/(1024*1024*1024),6),' G') AS 'Index Size' ,
CONCAT(ROUND((data_length+index_length)/(1024*1024*1024),6),' G') AS'Total'
FROM information_schema.TABLES
WHERE table_schema LIKE 'database';
相关资料
关于 SQL 执行顺序可参考:
https://www.2cto.com/database/201512/453280.html
https://huoding.com/2013/06/04/261其余推荐文章:
http://blog.codinglabs.org/articles/index-condition-pushdown.html
https://segmentfault.com/a/1190000003072424
https://tech.meituan.com/mysql-index.html
https://tech.meituan.com/about-desk-io.html
http://www.orczhou.com/index.php/2013/04/how-mysql-choose-index-in-a-join/推荐书籍:
Mysql 内核——innoDB 存储引擎;
高性能 mysql(https://dev.mysql.com/doc/index-other.html);