索引对于MySQL数据库的重要性是不言而喻的:
因为缺乏合适的索引,一个稍大的表全表扫描,稍微来些并发,就可能导致DB响应时间急剧飙升,甚至导致DB性能的雪崩;
现在大家普遍使用的Innodb引擎的锁机制依赖于索引,缺乏适合的索引,会导致锁范围的扩大,甚至导致锁表的效果,严重影响业务SQL的并行执行,影响业务的可伸缩性,只有在合适的索引条件下,才是行锁的效果.
既然索引对MySQL数据库这么重要,那么在索引的设计上有什么需要注意的事项吗? 这篇文章就来聊聊这个.
既然涉及到索引,避免不了执行计划的对比,先简单说一下执行计划上的重要关注点
mysql> show create table novel_agg_info\G
*************************** 1. row ***************************
Table: novel_agg_info
Create Table: CREATE TABLE `novel_agg_info` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`rid` bigint(20) unsigned NOT NULL,
`book_name` varchar(128) NOT NULL,
`tag` varchar(128) NOT NULL,
`dir_id` bigint(20) unsigned NOT NULL DEFAULT '0',
`dir_url` varchar(512) NOT NULL DEFAULT '',
`public_status` int(2) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`),
UNIQUE KEY `rid` (`rid`),
KEY `book_name` (`book_name`)
) ENGINE=InnoDB AUTO_INCREMENT=12096483 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
mysql> select count(1) from novel_agg_info;
+----------+
| count(1) |
+----------+
| 4298257 |
+----------+
1 row in set (0.00 sec)
mysql> show table status like 'novel_agg_info'\G
*************************** 1. row ***************************
Name: novel_agg_info
Engine: InnoDB
Version: 10
Row_format: Compact
Rows: 4321842
Avg_row_length: 130
Data_length: 565182464
Max_data_length: 0
Index_length: 374095872
Data_free: 35651584
Auto_increment: 12096483
Create_time: 2017-05-10 11:55:30
Update_time: NULL
Check_time: NULL
Collation: utf8_general_ci
Checksum: NULL
Create_options:
Comment:
1 row in set (0.00 sec)
实际数据行数近430W,优化器估算Rows: 4321842 行记录(这是一个估算值,来自于动态采样,数量级没有大的误差即可,实际上多次执行show table status,得到的数据也是不同的)

where dir_id = 13301689388199959972 因为dir_id字段上没有索引可用,导致了全表扫描(type:ALL),优化器估算检索行数为(rows:4290581)
避免全表扫描 全表扫描(type:ALL),大的检索行数(rows:N,为估算值),这些都是我们应该尽量避免的.
再看下面的执行计划对比:

key:book_name代表执行使用了KEY book_name
(book_name
),检索行数为1,这很好,是我们想要的效果.
为什么第1个执行计划中出现了Using index,而第2个执行计划中却没有呢?
因为:第1个SQL中只需要检索id,book_name字段,这在KEY book_name
(book_name
)中都存在了(索引叶节点中都会存储PRIMARY KEY字段ID),不需要回访表去获取其它字段了,Using index即代表这个含义;而第2个SQL中还需要检索tag字段,这在KEY book_name
(book_name
)中并不存在,就需要回访表会获取这个字段内容,所以没有出现Using index.
key,Using index
key: 代表使用的索引名称
Extra部分的Using index,代表只使用了索引便完成了查询,并没有回访表去获取索引外的字段,也就是我们通常所说的使用了“覆盖索引”;如果使用了key,但没有出现Using index,说明索引并不能覆盖检索和核对的所有字段,需要回访表去获取其它字段内容,这相对于覆盖索引增加了回访表的成本,增加了随机IO的成本
对于复合索引INDEX(a,b,c) 我如何确定执行计划到底使用了几个索引字段呢? 这个需要通过key_len去确定.
*************************** 1. row ***************************
Table: operationMenuInfo
Create Table: CREATE TABLE `operationMenuInfo` (
`id` int(50) NOT NULL AUTO_INCREMENT,
`operationMenuName` varchar(200) NOT NULL,
`createTime` int(50) DEFAULT NULL,
`startTime` int(50) DEFAULT NULL,
`endTime` int(50) DEFAULT NULL,
`appId` int(50) NOT NULL,
`status` int(50) NOT NULL,
`fromPlat` varchar(200) DEFAULT NULL,
`appName` varchar(200) DEFAULT NULL,
`packageId` int(20) DEFAULT NULL,
`menuType` smallint(5) NOT NULL DEFAULT '0' COMMENT 'type',
`entityId` int(11) NOT NULL DEFAULT '0' COMMENT 'entityId',
`productId` int(11) NOT NULL DEFAULT '0' COMMENT 'pid',
PRIMARY KEY (`id`),
KEY `time_appid` (`appId`,`createTime`),
KEY `idx_startTime` (`startTime`),
KEY `idx_endTime` (`endTime`),
KEY `t_eId_pId` (`entityId`,`menuType`,`productId`),
KEY `idx_appId_createTime_fromPlat` (`appId`,`createTime`,`fromPlat`)
) ENGINE=InnoDB AUTO_INCREMENT=4656258 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
where appId=927 and createTime=1494492062 按我们的理解,应该是使用KEY idx_appId_createTime_fromPlat
(appId
,createTime
,fromPlat
)的前2个字段.
where appId=927 and fromPlat='dataman' 按我们的理解,应该是使用KEY idx_appId_createTime_fromPlat
(appId
,createTime
,fromPlat
)的第1个字段.因为where条件中缺少createTime字段,所以只能使用索引的第1个字段来access.
其实key_len反映的就是这些信息,不过没有那么直接(其实直接显示使用哪些字段来access了会更好),要对应到字段上还需要一些换算:
key_len的计算
通过key_len可以知道复合索引都使用了哪些字段.key_len的计算上:
当字段定义可以为空时,需要额外的1个字节来记录它是否为空,当字段定义为not null时,这额外的1个字节是不需要的.
当字段定义为变长数据类型(比如说varchar)时,需要额外的2个字节来记录它的长度; 当字段定义为定长数据类型(比如说int,char,datetime等),这额外的2个字节是不需要的.
对于字符型数据,varchar(n),char(n), n都是定义的最大字符长度, gbk的话:2*n ,utf8的话:3*n
int 4个字节,bigint 8个字节,这些定长类型占用的字节数,这里只列举这2个吧.
索引使用哪些字段,上述计算公式计算出的字节的和就是ken_len,就可以确定索引使用了哪些字段
第1个SQL,使用了索引的前2个字段,appId(4) + createTime(4+1 这个字段定义为可以为空,所以是4+1) =9 ,所以ken_len是9,标识索引使用了这2个字段.
第2个SQL,只使用了索引的第1个字段appId(4) =4,所以ken_len是4,标识索引只使用了第1个字段.
业务SQL经常会有order by,一般来说这需要真实的物理排序才能达到这个效果, 这就是我们所说的Using filesort,一般来说它需要检索出所有的符合where条件的数据记录,而后在内存/文件层面进行物理排序,所以一般是一个很耗时的操作,是我们极力想要避免的.
但其实对于MySQL来说,却不一定非得物理排序才能达到order by的效果,也可以通过索引达到order by的效果,却不需要物理排序.
因为索引通过叶节点上的双向链表实现了逻辑有序性,比如说对于where a=? order by b limit 1; 可以直接使用index(a,b)来达到效果,不需要物理排序,从索引的根节点,走到叶节点,找到a=?的位置,因为这时b是有序的,只要顺着链表向右走,扫描1个位置,就可以找到想要的1条记录,这样既达到了业务SQL的要求,也避免了物理的排序操作。这种情况下,执行计划的Extra部分就不会出现Using filesort,因为它只扫描了极少量的索引叶节点就返回了结果,所以一般而言,执行很快,资源消耗很少,是我们想要的效果.

因为存在KEY time_appid
(appId
,createTime
), 第1个SQL可以通过它快速的返回结果,因为没有物理排序,所以执行计划的Extra部分没有出现Using filesort.
而第2个SQL是无法通过任何索引达到上述效果的,必须扫描出所有的符合条件的记录行后物理排序再返回TOP1的记录,因为存在物理排序,所以执行计划的Extra部分出现了Using filesort.
执行时间上,第1个SQL瞬间返回结果,第2个SQL需要0.7秒左右才能返回结果(因为它要检索出符合条件的40W记录,而后还要排序,这2个操作导致了它执行时间偏长).
order by和Using filesort
索引本身是逻辑有序的,所以可以通过索引达到order by的效果要求,却不需要真正的物理排序操作. 如果业务SQL中有order by,但执行计划的Extra部分中却没有出现Using filesort,说明通过索引避免了物理的排序操作,对于TOPN SQL而言,这往往意味着通过索引快速的返回了结果,是我们想要的.
如果执行计划的Extra部分中出现了Using filesort,说明无法通过索引达到效果,而使用了物理排序操作,对TOPN SQL而言,这意味着虽然只是返回极少的N条记录,但需要检索出符合where条件的所有记录,而后物理排序,最终才能返回业务想要的N条记录,如果符合where条件的记录很多,这2个操作往往是很耗时的,是我们极力想要避免的.
关于索引的2个知识点 关于索引,首先说2个应该知道的事项(其实上面也已经提到了): 1.现在普遍使用的innodb存储引擎中,索引的叶节点中除了存储了索引定义中的字段外,还存储了primary key,从而可以找到对应的行记录,这样才能访问索引外的字段. 2.索引的叶节点通过双向链表实现了逻辑上的有序性,使得索引是有序的.
表设计层面,我们一般建议使用自增ID做PRIMARY KEY,业务主键做UNIQUE KEY,原因如下:
1.如果业务主键做PRIMARY KEY,业务主键的插入顺序比较随机,这样会导致插入时间偏长,而且聚簇索引叶节点分裂严重,导致碎片严重,浪费空间;而自增ID做PRIMARY KEY的情况下,顺序插入,插入快,而且聚簇索引比较紧凑,空间浪费小。
2.一般表设计上除了PRIMARY KEY外,还会有几个索引用来优化读写.而这些非PK索引叶节点中都要存储PRIMARY KEY,以指向数据行,从而关联非索引中的字段内容.这样自增ID(定义为bigint才占用8个字节)和业务主键(通常字符串,多字段,空间占用大)相比,做PRIMARY KEY在索引空间层面的优势也是很明显的(同时也会转换为时间成本层面的优势),表定义中的索引越多,这种优势越明显。
综上所述,我们一般建议使用自增ID做PRIMARY KEY,业务主键做UNIQUE KEY。
这里涉及到一个重要的概念:字段的选择性
select count(1)/count(distinct col) 这个结果越接近数据总行数,那么这个字段的选择性越低; 越接近1,那么这个字段的选择性越高. 简单举例说就是:身份证ID字段的选择性很高,而性别字段的选择性很低.
一般来说,高选择性字段上是适合创建索引的,而低选择性字段上是不适合创建索引的
一般来说,status,type这类枚举值很少的字段,就是低选择性字段(或者说低基数字段),是不适合单独作为索引字段的.
例外的情况就是: 这类字段数据分布特别不均衡,而你经常要定位的是数据量极少的字段值,这种情况下,还是适合在这个字段上创建索引的.
mysql> show create table novel_agg_info\G
*************************** 1. row ***************************
Table: novel_agg_info
Create Table: CREATE TABLE `novel_agg_info` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`rid` bigint(20) unsigned NOT NULL,
`book_name` varchar(128) NOT NULL,
`tag` varchar(128) NOT NULL,
`dir_id` bigint(20) unsigned NOT NULL DEFAULT '0',
`dir_url` varchar(512) NOT NULL DEFAULT '',
`public_status` int(2) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`),
UNIQUE KEY `rid` (`rid`),
KEY `book_name` (`book_name`),
KEY `idx_public_status` (`public_status`)
) ENGINE=InnoDB AUTO_INCREMENT=12096483 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
mysql> select public_status,count(1) from novel_agg_info group by public_status;
+---------------+----------+
| public_status | count(1) |
+---------------+----------+
| 0 | 3511945 |
| 1 | 367234 |
| 2 | 419062 |
| 12 | 16 |
+---------------+----------+
4 rows in set (1.35 sec)
mysql> explain select * from novel_agg_info where public_status = 12;
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+------+-------+
| 1 | SIMPLE | novel_agg_info | ref | idx_public_status | idx_public_status | 4 | const | 15 | NULL |
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+------+-------+
1 row in set (0.00 sec)
mysql> explain select * from novel_agg_info where public_status = 0;
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+
| 1 | SIMPLE | novel_agg_info | ref | idx_public_status | idx_public_status | 4 | const | 1955112 | NULL |
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+
1 row in set (0.00 sec)
mysql> select sql_no_cache count(1) from (select * from novel_agg_info where public_status = 12 ) tmp;
+----------+
| count(1) |
+----------+
| 16 |
+----------+
1 row in set (0.00 sec)
mysql> select sql_no_cache count(1) from (select * from novel_agg_info where public_status = 0 ) tmp;
+----------+
| count(1) |
+----------+
| 3511945 |
+----------+
1 row in set (11.60 sec)
可以看到状态值为12的数据量极少,所以where public_status = 12 使用索引,快速的返回了结果.但where public_status = 0 完全是另外一种情况了.
其实下面可以看到 where public_status = 0 不使用索引,使用全表扫描会更好些,但这里也依然是选择了使用索引的执行计划. 优化器应该基于数据分布的统计信息,对于不同的输入值,使用更合理的执行计划,而不是使用一个统一的执行计划,这也是优化器层面需要继续智能化,提升的地方.
它的一个典型的应用场景,就是任务处理表:
不断有新任务插入进来,任务状态初始化为"未处理",后台不断的扫描出"未处理"的任务,进行调度处理,完成后,更新任务状态为"已处理",任务数据仍然保留下来.
这里任务状态字段就是这种情况,不同值很少,但频繁查询的"未处理"状态极少,绝大部分为"已处理"状态,它们又基本上不会被查询,这种情况下,就适合在任务状态字段上创建索引.
为什么低选择性字段上不适合创建索引呢? 其实也涉及到另一个问题: 使用索引一定比全表扫描要好吗? 答案是否定的.
继续进行测试:
mysql> explain select * from novel_agg_info where public_status = 0; ---默认走索引idx_public_status
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+
| 1 | SIMPLE | novel_agg_info | ref | idx_public_status | idx_public_status | 4 | const | 1955112 | NULL |
+----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+
1 row in set (0.00 sec)
mysql> explain select * from novel_agg_info ignore index(idx_public_status) where public_status = 0; ---强制忽略索引idx_public_status,走全表扫描.
+----+-------------+----------------+------+---------------+------+---------+------+---------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------------+------+---------------+------+---------+------+---------+-------------+
| 1 | SIMPLE | novel_agg_info | ALL | NULL | NULL | NULL | NULL | 3910225 | Using where |
+----+-------------+----------------+------+---------------+------+---------+------+---------+-------------+
1 row in set (0.00 sec)
mysql> select sql_no_cache count(1) from (select * from novel_agg_info where public_status = 0) tmp;
+----------+
| count(1) |
+----------+
| 3511945 |
+----------+
1 row in set (11.59 sec)
mysql> select sql_no_cache count(1) from (select * from novel_agg_info ignore index(idx_public_status) where public_status = 0) tmp;
+----------+
| count(1) |
+----------+
| 3511945 |
+----------+
1 row in set (8.46 sec)
上面2个SQL的执行时间均取多次执行的平均执行时间,可以忽略BUFFER POOL的影响.
为什么全表扫描反而快了,使用索引反而慢了呢?
一定程度上是因为回访表的操作,使用索引,但提取了索引字段外的数据,所以需要回访表数据,这里符合条件的数据量特别大,所以导致了大量的回表操作,带来了大量的随机IO; 而全表扫描的话,虽然说表空间比索引空间大,但可以使用多块读特性,一定程度上使用顺序读; 此消彼长,导致全表扫描反而比使用索引还要快了.
这也解释了低选择性字段(低基数字段)为什么不适合创建索引(当然,使用覆盖索引,不需要回访表是另外一种情况了).
答案是否定的,因为索引是有代价的:
每次的写操作,都要维护索引,相应的调整索引数据,会在一定程度上降低写操作的速度.所以大量的索引必然会降低写性能,索引的创建要从整体考虑,在读写性能之间找到一个好的平衡点,在主要矛盾和次要矛盾之间找到平衡点.
所以说,索引并不是越多越好,无用的索引要删除,冗余的索引(这在后面会提到)要删除,因为它们只有维护上的开销,却没有益处,所以在业务逻辑,SQL,索引结构变更的时候,要及时删除无用/冗余的索引.
索引使用不合理的情况下,使用索引也不一定会比全表扫描快,上面也提到了.
总结说,索引不是万能的,要合理的创建索引.
select * from tab where id + 1 = 1000; 会导致全表扫描,应该修改为select * from tab where id = 1000 -1; 才可以高效返回.
select * from tab where from_unixtime(addtime) = '2017-05-11 00:00:00' 会导致index(addtime)不可用
应该调整为select * from tab where addtime = unix_timestamp('2017-05-11 00:00:00') 这样才可以使用index(addtime)
再比如说:
SELECT COUNT(*) FROM message WHERE (token = 'bed21e35b19fe40e71b3ba2ad080b10a') AND (date(create_time) = curdate());
会导致create_time上的索引不可用, 为了使得create_time上的索引可用,应转化为如下的等效形式:
SELECT COUNT(*) FROM message WHERE (token = 'bed21e35b19fe40e71b3ba2ad080b10a') AND create_time>=curdate() and create_time
这里的运算也包括隐式的运算,比如说隐式的类型转换..业务上经常有类型不匹配导致隐式的类型转换的情况.这里经常出现的情况是字符串和整型比较.
比如说表定义字段类型为BIGINT,但业务上传进来一个字符串的; 或者是表定义字段类型为varchar,但业务上传进来一个整型的.这个字段上存在索引时,索引也许是不可用的.
为什么说也许呢?这取决于这种隐式的类型转换发生在了哪侧?是表字段侧,还是业务传入数据侧?
整型和字符串比较,DB中和许多程序语言中的处理方式是一样的,都是字符串转换为整型后和整型比较.
所以表定义字段类型为BIGINT,但业务上传进来一个字符串,字段上的索引依然可用,因为隐式的类型转换发生在业务传入数据侧(这只能说是索引依然可用,没有大的性能影响,但隐式的类型转换照样是有性能损耗的,所以还是一致的好)。
表定义字段类型为varchar,但业务上传进来一个整型,会导致索引不可用,全表扫描.因为隐式的类型转换发生在表字段侧。
建议可以使用INT/BIGINT存储的,尽量定义为INT/BIGINT,这样相对于长的纯数字字符串的VARCHAR定义,INT/BIGINT不仅更节省空间(INT 4个字节,BIGINT 8个字节),性能更好;而且即使类型不匹配了,也不会导致索引不可用的问题.
还有表关联,关联字段上类型不一致,这种情况下,索引是否可用,是否存在严重的性能问题,取决于哪个表是驱动表,哪个表是被驱动表.这里不细论这个问题了.关联字段类型定义一致了,什么问题都没有.这也是表设计阶段需要注意的.
总结起来还是一句话,类型一致了,什么问题都没有,否则可能存在严重的性能问题.
索引字段类型定义改变时的调整顺序
这里单独的说一下这个,因为业务上确实存在字段类型调整的情况,存在int/bigint和varchar定义转换的情况,如果这个字段上还存在着高效索引的话,一定要注意是业务代码侧先调整,还是DB侧先调整,如果顺序弄反了,会导致这里提到的全表扫描问题的:
原定义为int/bigint,要修改为varchar的: 业务代码侧先调整,传入数据都按字符串处理,确认都调整完毕后,DB端再修改表定义.
原定义为varchar,要修改为 int/bigint的: DB端先修改表定义,DB端调整完毕,且确认从库也同步完毕之后,业务代码再调整,传入数据都按整型处理
再说一下区分大小写的字段比较
mysql的字符串比较默认是不区分大小写的.所以有些业务上为了严格匹配,区分大小写,在SQL中使用了binary,确实达到了区分大小写的目的,但导致索引不可用了(因为在字段侧进行了运算)
表定义中存在合适的索引 KEY `idx_app_name_status` (`appname`,`status`)
mysql> explain select * from tbl_rtlc_conf where binary appname='LbsPCommon' and status = 1;
+----+-------------+---------------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+---------------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | tbl_rtlc_conf | ALL | NULL | NULL | NULL | NULL | 9156 | Using where |
+----+-------------+---------------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.00 sec)
但因为binary的使用,导致了全表扫描.
那如何达到目的,又能高效呢?
mysql的字符串比较默认不区分大小写,是因为它们默认的collation是不区分大小写的
mysql> pager egrep -i "utf8|gbk|Default collation"
PAGER set to 'egrep -i "utf8|gbk|Default collation"'
mysql> show character set;
| Charset | Description | Default collation | Maxlen |
| gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 |
| utf8 | UTF-8 Unicode | utf8_general_ci | 3 |
| utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 |
gbk,utf8 字符集默认的collation分别为gbk_chinese_ci,utf8_general_ci, caseignore 它们都是忽略大小写的,导致字符串比较默认不区分大小写了.
区分大小写,且索引可用
解决的方案就是修改特定表/字段的collation,表collation的修改会影响到这个表的所有字段,所以一般都是只修改特定目标字段的collation
表字符集为utf8的话:appname
varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT 'appname'
表字符集为gbk的话:appname
varchar(255) CHARACTER SET gbk COLLATE gbk_bin NOT NULL COMMENT 'appname'
同时保证SQL中没有包含binary, 这样既达到了严格匹配的目的(utf8_bin ,gbk_bin这2个collation都是严格匹配的),也保证了索引的可用性.
where name like '%zhao%'
这种前后统配的模糊查询,会导致索引不可用,全表扫描,稍好的情况是,能使用覆盖索引的话,是索引全扫描,但也高效不了.
如果确实存在这样高频执行的模糊匹配的业务需求,建议走全文检索系统,不要使用MySQL来做这个事情.
但其实很多业务,使用模糊匹配是带有很大的随意性的,完全可以改为精确匹配,从而使用字段上的索引快速定位数据的.
另外where name like 'xxx%'
这种,不前统配,只后统配的,确实是可以使用索引的.
但它其实是一个范围匹配,下文会提到,这种范围匹配(非等值匹配)会导致后面的索引字段不能(高效)使用,会导致索引不能用于避免物理排序等问题.
所以还是要谨慎使用,如果可以改为精确匹配的话,还是建议使用精确匹配的好.
index(a,b,c) 能同时优化下面几类查询:
where a=? and b=? and c=?
where a=? and b=?
where a=?
也能优化如下的排序查询:
where a=? order by b[,c] limit
where a=? and b=? order by c limit
但不能优化 where b=? and c=? 因为索引定义index(a,b,c) 的前缀列a没有出现在where条件中.
更不能优化where c=?
对于where a=? and c=? 查询,它只能使用index(a,b,c)的第1个索引字段a.
所以,如果业务查询为如下2类:
where b=? and c=?
where c=?
**那么就应该定义索引为index(c,b),它能同时优化上面2类查询 **,而不应该定义索引index(b,c)的,因为索引index(b,c)优化不了where c=? 因为这个索引的前缀列b没有出现在where条件中.
也不建议创建2个索引: index(b,c) 和index(c) 因为前面提到了索引越少越好,可以用一个index(c,b) 来完成的,就不要创建2个索引来完成.
在存在索引index(a,b,c)的情况下,绝大多数情况下,下面的这些索引就冗余了,可以DROP掉的:
index(a)
index(a,b)
上面提到了,这2个索引能优化的查询,index(a,b,c)绝大多数情况下也都能优化,所以它们就冗余了,本着索引越少越好的原则,都可以DROP掉的.
上面提到了绝大多数情况下,冗余了,可以DROP了,但也存在例外的情况,它们的存在还是必要的:
那就是存在下面的查询:
where a=? order by id limit
这里index(a) ( 实际为index(a,id) ) 可以优化上面的查询,通过使用这个索引,避免物理排序而达到排序的实际效果.
但index(a,b,c) ( 实际为index(a,b,c,id) ) 和index(a,b) (实际为index(a,b,id)) 却达不到这样的效果.
这种情况下,存在index(a,b,c)的情况下,index(a) 是不冗余的,是需要保留的.
如果不存在这种情况,存在index(a,b,c)的情况下,index(a) ,index(a,b) 都是冗余的,建议drop掉.
但如果where a=? 后返回的数据行已经很少,也就是说对很少的数据进行order by id排序的话,也是可以使用index(a,b)或者index(a,b,c) 来过滤行的,只不过还需要进行物理排序,但代价已经很小了,是否还需要创建一个index(a)需要业务折中考虑了.
建议where条件中等值匹配的字段放到索引定义的前部,范围匹配的字段(> < between in等为范围匹配)放到索引定义的后面.
因为前缀索引字段使用了范围匹配后,会导致后续的索引字段不能高效的用于优化查询.
来看一个例子:
mysql> show create table opLog\G
*************************** 1. row ***************************
Table: opLog
Create Table: CREATE TABLE `opLog` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
`listId` int(11) unsigned NOT NULL COMMENT '对应id',
`listType` varchar(255) NOT NULL COMMENT '对应类型',
`opName` varchar(255) NOT NULL COMMENT '操作人id',
`operation` varchar(255) NOT NULL COMMENT '具体操作',
`content` varchar(255) NOT NULL COMMENT '内容',
`createTime` int(10) NOT NULL COMMENT '时间',
PRIMARY KEY (`id`),
KEY `idx_opName_createTime` (`opName`,`createTime`),
KEY `idx_createTime_opName` (`createTime`,`opName`)
) ENGINE=InnoDB AUTO_INCREMENT=2515923 DEFAULT CHARSET=utf8 COMMENT='操作记录表'
查询2017-04-23到2017-05-23 这一个月内某个op发起的操作数量:
select sql_no_cache count(1) from opLog where opName='zhangyu21' and createTime between 1492876800 and 1495468800;
+----------+
| count(1) |
+----------+
| 0 |
+----------+
这1个月内共有2.2W次的操作记录,对应2.2W行记录.
mysql> select count(1) from opLog where createTime between 1492876800 and 1495468800;
+----------+
| count(1) |
+----------+
| 22211 |
+----------+
我下面使用force index的hint强制走某个索引:
# Query_time: 0.009124 Lock_time: 0.000093 Rows_sent: 1 Rows_examined: 22211
select sql_no_cache count(1) from opLog force index(idx_createTime_opName) where opName='zhangyu21' and createTime between 1492876800 and 1495468800;
# Query_time: 0.000220 Lock_time: 0.000077 Rows_sent: 1 Rows_examined: 0
select sql_no_cache count(1) from opLog force index(idx_opName_createTime) where opName='zhangyu21' and createTime between 1492876800 and 1495468800;
可以看到第1个SQL,强制走KEY `idx_createTime_opName`(`createTime`,`opName`)时,检索的行数是22211行,这个行数刚好是这个时间段内的总行数.为什么是这样呢?
因为在前缀索引字段createTime上使用了范围匹配,所以导致索引定义中后面的字段opName不能作为高效的检索字段(Access),只能作为低效的过滤字段(Filter)了.
(在5.6推出ICP之前,这一点都很难满足,导致范围匹配后的索引字段基本是无用的)
说白了,就是说索引上定位到createTime的起止,对期间的索引条目一行行的检查是否满足opName='zhangyu21'的条件,满足的返回.
而第2个SQL,强制走KEY `idx_opName_createTime` (`opName`,`createTime`)时,这2个索引字段都是可以作为高效的Access条件的.
通过索引定位到opName='zhangyu21',createTime =1492876800 条目,向后扫描,直至opName='zhangyu21',createTime>1495468800或者opName!='zhangyu21'为止.
它是相当高效的,扫描的条目就是返回的条目.
没有带force index这类hint的话,mysql优化器会默认使用idx_opName_createTime这个索引.
前面提到了index(a,b) 逻辑上是有序的,所以可以用于优化where a=? order by b [asc/desc] [limit n] 特别是对这种topN操作的优化效果非常好.
mysql> show create table opLog\G
*************************** 1. row ***************************
Table: opLog
Create Table: CREATE TABLE `opLog` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
`listId` int(11) unsigned NOT NULL COMMENT '对应id',
`listType` varchar(255) NOT NULL COMMENT '对应类型',
`opName` varchar(255) NOT NULL COMMENT '操作人id',
`operation` varchar(255) NOT NULL COMMENT '具体操作',
`content` varchar(255) NOT NULL COMMENT '内容',
`createTime` int(10) NOT NULL COMMENT '时间',
PRIMARY KEY (`id`),
KEY `idx_opName_createTime` (`opName`,`createTime`)
) ENGINE=InnoDB AUTO_INCREMENT=2515923 DEFAULT CHARSET=utf8 COMMENT='操作记录表'
mysql> select count(1) from opLog where opName='';
+----------+
| count(1) |
+----------+
| 2511443 |
+----------+
1 row in set (1.08 sec)
一共有251W的匿名用户,要查找他们最近的5个操作记录:
# Query_time: 0.001566 Lock_time: 0.000084 Rows_sent: 5 Rows_examined: 5
select * from opLog where opName='' order by createTime desc limit 5;
从实际执行的统计信息看,它并没有扫描出251W的记录,排序,最终输出5条记录,而是只扫描了5条记录,就直接输出了,执行时间很短的.
看一下执行计划:
mysql> explain select * from opLog where opName='' order by createTime desc limit 5;
+----+-------------+-------+------+-----------------------+-----------------------+---------+-------+---------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+-----------------------+-----------------------+---------+-------+---------+-------------+
| 1 | SIMPLE | opLog | ref | idx_opName_createTime | idx_opName_createTime | 767 | const | 1252639 | Using where |
+----+-------------+-------+------+-----------------------+-----------------------+---------+-------+---------+-------------+
1 row in set (0.01 sec)
sql中有order by,但执行计划的Extra部分并没有出现Using filesort,说明通过KEY `idx_opName_createTime` (`opName`,`createTime`)这个索引达到了排序的效果,但避免了物理排序的操作.(rows部分的估算值可以忽略呀)
如果没有这个索引,就真的需要检索出251W记录(如何检索出这些记录,取决于其他的索引,如果没有合适的索引,可能需要全表扫描),对他们进行物理排序,并输出需要的5行记录.执行代价很大,执行时间很长.
但这里通过索引,利用索引本身的逻辑有序性,避免了物理排序操作,快速的返回了topN行记录.
mysql> show create table opLog\G
*************************** 1. row ***************************
Table: opLog
Create Table: CREATE TABLE `opLog` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
`listId` int(11) unsigned NOT NULL COMMENT '对应id',
`listType` varchar(255) NOT NULL COMMENT '对应类型',
`opName` varchar(255) NOT NULL COMMENT '操作人id',
`operation` varchar(255) NOT NULL COMMENT '具体操作',
`content` varchar(255) NOT NULL COMMENT '内容',
`createTime` int(10) NOT NULL COMMENT '时间',
PRIMARY KEY (`id`),
KEY `idx_opName_listType_createTime` (`opName`,`listType`,`createTime`)
) ENGINE=InnoDB AUTO_INCREMENT=2515923 DEFAULT CHARSET=utf8 COMMENT='操作记录表'
还是上面的表数据,我修改了一下表的索引结构.
mysql> select count(1) from opLog where opName='';
+----------+
| count(1) |
+----------+
| 2511443 |
+----------+
1 row in set (0.91 sec)
# Query_time: 3.188810 Lock_time: 0.000088 Rows_sent: 1 Rows_examined: 2511444
select * from opLog where opName='' and listType in ('cronJob','cronJobNew') order by createTime desc limit 1;
从执行统计信息看,这个查询并没有通过索引快速的返回结果.
mysql> explain select * from opLog where opName='' and listType in ('cronJob','cronJobNew') order by createTime desc limit 1;
+----+-------------+-------+------+--------------------------------+--------------------------------+---------+-------+---------+----------------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+--------------------------------+--------------------------------+---------+-------+---------+----------------------------------------------------+
| 1 | SIMPLE | opLog | ref | idx_opName_listType_createTime | idx_opName_listType_createTime | 767 | const | 1252640 | Using index condition; Using where; Using filesort |
+----+-------------+-------+------+--------------------------------+--------------------------------+---------+-------+---------+----------------------------------------------------+
执行计划来看,还是有Using filesort,还是需要物理排序的. 为什么不能通过这个索引避免物理排序,快速的返回结果呢?
原因就在于listType in ('cronJob','cronJobNew') 在这个索引字段上使用了范围匹配,从而导致索引层面上整体不再有序了.
在排序字段前的所有索引字段上都必须是等值匹配,才能通过索引保证有序性,才能通过索引避免物理排序,快速的返回结果.
所以上面的查询必须改造为等效的等值匹配才可以通过索引快速的返回结果的:
mysql> select *
-> from
-> (
-> select * from opLog where opName='' and listType = 'cronJob' order by createTime desc limit 1
-> union all
-> select * from opLog where opName='' and listType = 'cronJobNew' order by createTime desc limit 1
-> ) tmp
-> order by createTime desc limit 1;
ERROR 1221 (HY000): Incorrect usage of UNION and ORDER BY
这样还不行,必须再嵌套个外层,使用临时表才可以的:
select *
from
(
select * from
(
select * from opLog where opName='' and listType = 'cronJob' order by createTime desc limit 1
) tmp_1
union all
select * from
(
select * from opLog where opName='' and listType = 'cronJobNew' order by createTime desc limit 1
) tmp_2
) tmp
order by createTime desc limit 1;
这样就可以了.
改造后的SQL对应的执行统计信息如下:
# Query_time: 0.000765 Lock_time: 0.000332 Rows_sent: 1 Rows_examined: 4
经过改造为等效的等值匹配,使用索引避免了大的物理排序操作,快速的返回了结果.
说到通过索引优化排序查询,特别是TOPN操作,必须说一下MySQL在优化器层面的一个问题:
就是说在遇到order by时,myql会优先选择一个可以避免物理排序的索引来优化这个查询,有时候,这种优先选择是不合理的,会导致性能很差.
(特别在涉及到order by id limit N, 这里id是primary key,优化器选择使用PRIMARY KEY来避免物理排序时尤其要注意是否合理了)
mysql> show create table layer\G
*************************** 1. row ***************************
Table: layer
Create Table: CREATE TABLE `layer` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'layer的id',
`uuid` varchar(255) NOT NULL COMMENT 'layer的唯一标识',
`type` tinyint(4) NOT NULL COMMENT 'layer的类型',
`status` tinyint(4) NOT NULL COMMENT 'layer的状态',
`app_id` bigint(20) NOT NULL COMMENT 'layer所属的app id',
`src` varchar(1024) NOT NULL COMMENT 'layer的源地址',
`oais_src` varchar(1024) NOT NULL DEFAULT '' COMMENT 'layer存在于oais的地址',
`cmd` varchar(1024) NOT NULL DEFAULT '' COMMENT 'layer执行的命令',
`skip_download` tinyint(1) NOT NULL DEFAULT '0' COMMENT '默认为0,不跳过中转',
`extra` text NOT NULL COMMENT 'layer的额外信息',
`create_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'layer创建时间戳',
`last_update_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'layer更新时间戳',
`finish_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'layer完成时间戳',
`merge_latest_layer_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '该baseLayer最新merge的layerid',
PRIMARY KEY (`id`),
KEY `idx_uuid` (`uuid`),
KEY `idx_app_id` (`app_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2866980 DEFAULT CHARSET=utf8 COMMENT='layer表'
# Query_time: 2.586674 Lock_time: 0.000084 Rows_sent: 1 Rows_examined: 1986479
SELECT * FROM `layer` WHERE (app_id = 2183) ORDER BY `layer`.`id` ASC LIMIT 1;
输出的ID:1998941
# Query_time: 1.442171 Lock_time: 0.000071 Rows_sent: 1 Rows_examined: 1095035
SELECT * FROM `layer` WHERE (app_id = 139) ORDER BY `layer`.`id` ASC LIMIT 1;
输出的ID: 1107497
# Query_time: 0.597380 Lock_time: 0.000077 Rows_sent: 1 Rows_examined: 464929
SELECT * FROM `layer` WHERE (app_id = 1241) ORDER BY `layer`.`id` ASC LIMIT 1;
输出的ID:465532
mysql> explain SELECT sql_no_cache* FROM `layer` WHERE (app_id = 2183) ORDER BY `layer`.`id` ASC LIMIT 1;
+----+-------------+-------+-------+-----------------------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+-------+-----------------------------+---------+---------+------+------+-------------+
| 1 | SIMPLE | layer | index | idx_app_id | PRIMARY | 8 | NULL | 151 | Using where |
+----+-------------+-------+-------+-----------------------------+---------+---------+------+------+-------------+
1 row in set (0.00 sec)
可以看到扫描的数据行数是大不同的.为什么呢? 源于它的执行计划,使用了PRIMARY KEY (`id`)来避免物理排序操作.
说白了,就是顺着PRIMARY KEY (`id`)的索引链表,从小往大扫描,找到第1条满足app_id = ?的记录就返回了.
所以执行的时间长短,扫描的记录行数的多少,完全取决于app_id = ? 的总体数据量,数据分布情况.如果查找1个不存在的app_id最终的结果是扫描了整个表的数据行,也没有找到数据,返回0行记录,执行时间肯定长.
下面也可以验证这1点:
mysql> SELECT count(1) FROM `layer` WHERE (app_id = 2183) and id<1998941;
+----------+
| count(1) |
+----------+
| 0 |
+----------+
1 row in set (0.01 sec)
mysql> SELECT count(1) FROM `layer` WHERE id<=1998941;
+----------+
| count(1) |
+----------+
| 1986479 |
+----------+
1 row in set (0.79 sec) 就是检索的数据行数
mysql> SELECT count(1) FROM `layer` WHERE (app_id = 139) and id<1107497;
+----------+
| count(1) |
+----------+
| 0 |
+----------+
1 row in set (0.00 sec)
mysql> SELECT count(1) FROM `layer` WHERE id<= 1107497;
+----------+
| count(1) |
+----------+
| 1095035 |
+----------+
1 row in set (0.43 sec) 就是检索的数据行数
mysql> SELECT count(1) FROM `layer` WHERE (app_id = 1241) and id<465532;
+----------+
| count(1) |
+----------+
| 0 |
+----------+
1 row in set (0.00 sec)
mysql> SELECT count(1) FROM `layer` WHERE id<= 465532;
+----------+
| count(1) |
+----------+
| 464929 |
+----------+
1 row in set (0.18 sec) 就是检索的数据行数
这里虽然通过索引避免了物理排序,但扫描的行数很大,实际执行时间很长,执行效果很差.
那这个SQL应该如何优化呢?
KEY idx_app_id
(app_id
) 等价于index(app_id,id) 完全可以通过它来高效的返回前N行记录呀.但因为MySQL默认不选择它,只能使用force index这个hint来强制mysql选择这个索引了.
mysql> explain SELECT * FROM `layer` force index(idx_app_id) WHERE (app_id = 1241) ORDER BY `layer`.`id` ASC LIMIT 1;
+----+-------------+-------+------+---------------+------------+---------+-------+--------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------------+---------+-------+--------+-------------+
| 1 | SIMPLE | layer | ref | idx_app_id | idx_app_id | 8 | const | 111142 | Using where |
+----+-------------+-------+------+---------------+------------+---------+-------+--------+-------------+
1 row in set (0.00 sec)
表名后跟 force index(idx_app_id) 提示mysql强制选择这个索引. 通过这个索引也是可以避免物理排序的,而且真的可以快速的返回结果(即使这个app_id不存在,也会快速返回结果)
# Query_time: 0.000213 Lock_time: 0.000082 Rows_sent: 1 Rows_examined: 1
SELECT * FROM `layer` force index(idx_app_id) WHERE (app_id = 2183) ORDER BY `layer`.`id` ASC LIMIT 1;
# Query_time: 0.000202 Lock_time: 0.000075 Rows_sent: 1 Rows_examined: 1
SELECT * FROM `layer`force index(idx_app_id) WHERE (app_id = 139) ORDER BY `layer`.`id` ASC LIMIT 1;
# Query_time: 0.000222 Lock_time: 0.000075 Rows_sent: 1 Rows_examined: 1
SELECT * FROM `layer`force index(idx_app_id) WHERE (app_id = 1241) ORDER BY `layer`.`id` ASC LIMIT 1;
使用force index 这个hint强制走某个索引后,真的高效返回了.
在遇到MYSQL蒙圈,选择错误的执行计划时,需要使用一些hint给mysql一些提示,使用频率较高的hint有:
force index(index_name) 强制走某个索引
ignore index(index_name) 建议忽略某个索引不使用
但一般不建议使用这种hint,原因如下:
hint是和索引名称而不是索引字段绑定的,以后存在着很大的风险,把索引改名了,会导致提示无效的.
业务存在拼接SQL的情况下,代码考虑不周全,会导致一些不应该使用这种HINT的SQL也使用了这种HINT,导致它们的执行计划变差.
随着版本的升级,优化器的提升,数据量,数据分布特点的变化,MYSQL本可以选择更好的执行计划,但因为HINT导致MYSQL不能选择更好的执行计划.
所以使用这些提示前,请先和DBA沟通,也要进行详尽的测试,确认HINT的引入只带来了益处,没有带来坏处.
有时候会看到业务SQL是where a=? and b=? and c=?
但3个列上分别创建了一个单列索引:
index(a) index(b) index(c)
这种创建是否合理呢?
前面提到高选择性字段上适合创建索引,低选择性字段上不适合创建单列索引(但可以考虑作为复合索引定义的一部分)
**如果a字段上的选择性足够高,b,c的选择性低,完全可以只创建索引index(a) **, 这种情况下,当然也可以只创建index(a,b) 或者只创建index(a,b,c). (不要创建index(b), index(c) 这2个低选择性字段上的单列索引了).
需要考虑到index(a,b) index(a,b,c) 相对于index(a),提升的收益并不大,但可能空间占用却大出不少去,需要业务在时空的矛盾中做出平衡,看创建哪个索引更合适.
如果实际情况是a,b,c单独的选择性一般,都不是很高,但3个组合到一起的选择性很高的话,那就建议创建index(a,b,c)的组合索引,不要3个字段上都创建一个单列索引.
为什么呢? mysql确实可以使用index merge来使用多个索引,但很多时候是否比得上复合索引效率高呢?
简化一下: where a=? and b=?
a=? 返回1W行记录, b=? 返回1W行记录, where a=? and b=? 返回100行记录.
如果是两个单列索引: index(a) index(b) 的情况下,index_merge会是一个什么样的执行计划呢?
针对a=? 通过使用index(a) 返回1W行记录,带PRMIARY KEY
针对b=? 通过使用index(b) 返回1W行记录,带PRMIARY KEY
然后对primary key 取交集,不管是排序后取交集也好,还是通过嵌套循环,关联的方式取交集也好.都会是一个耗时耗费资源的操作.
综合来说,扫描各自的索引返回1W行记录,而后对这2W行记录取交集,肯定是一个耗时耗费资源的操作了.
但如果存在复合索引index(a,b) 通过索引的扫描定位,可以快速的返回这100行记录的.
所以针对这种情况,建议创建复合索引,不要创建多个单列索引.
补充说一下:
where a=? or b=? 这种查询, a列,b列上的选择性都很高,这时候需要index(a) index(b),缺少一个,都会导致全表扫描的.
ORACLE中有三种主要的表关联方式:NESTED LOOP , HASH JOIN 和 SORT MERGE JOIN
其中最常用的还是前两种,ORACLE的优化器会根据统计得到的表行数,数据分布情况等信息,对各种关联方式,关联顺序下的多个执行计划进行评估,分别计算它们的cost,最后选择一个cost最低(优化器认为的最优)执行计划作为最终的执行计划去执行.
但至少到mysql官方的5.6版本,依然只有NESTED LOOP(嵌套循环)这样一种关联方式.
NESTED LOOP说白了就是FOR循环实现:
比如说针对下面的关联查询:
select a.*, b *
from EMP a,DEPT b
where a.DEPTNO = b.DEPTNO;
它的嵌套循环的伪代码大意是这样的:
declare
begin
for outer_table in (select * from dept)
loop
for inner_table in (select *
from emp
where DEPTNO = outer_table.DEPTNO)
loop
dbms_output.put_line(inner_table.*, outer_table.*);
end loop;
end loop;
end;
NESTED LOOP的适用场景是什么?
外表(驱动表)经过过滤后返回较少的数据行(最好也可以通过索引快递的定位这些数据行,和表本身的数据行多少无关,只要求经过条件的过滤后返回较少的数据行),而内表(被驱动表)在表的关联字段上存在着高效的索引可用.
因为这种情况下,FOR循环的代价是小的,是适用NESTED LOOP的.
其它情况,使用NESTED LOOP都不合适,比如内外表经过过滤后都返回上万行甚至数十万,百万的记录,这种情况下,FOR循环的成本太高了(其实这种情况下,HASH JION是适用的)
因为这个原因(当然还有其它原因了,比如说mysql没有bitmap index等),mysql不适合做OLAP系统,不适合做复杂的多表关联:
多表关联,关联的表越多,返回的行数越多,他们作为外表,FOR循环的成本会越来越高,执行时间越来越长,很容易就超过业务设置的读超时时间,或者超过DB端设置的超时时间,稍微来点儿并发,就可能会耗尽DB的资源,会导致雪崩,DB响应不了任何的业务请求.
所以不建议在MySQL上进行复杂的多表关联查询,低频,基本无并发的查询,可以在线下库进行;执行频率稍高,存在并发的,就必须到hadoop,hbase等环境进行了.
因为mysql的表关联实现就是for循环,所以简单的表关联,业务也可以自己for循环实现.
可以使用percona公司的开源工具pt-query-digest来进行统计,它可以支持多种类型日志文件的分析,包括binlog,genlog,slowlog,tcpdump的输出进行统计.默认就是对slowlog进行分析的.
它也支持多种过滤条件,比如说执行时间,检索行数等的过滤输出,也支持过滤后裸数据的输出,支持多种聚合排序输出.
一般使用最简单的调用形式即可,都使用默认定义:
/usr/local/bin/pt-query-digest slow.log > slow.log.fenxi
slow.log 是待分析的慢查询日志文件,将分析的结果重定向到文件slow.log.fenxi中.
它是去除字面值后对SQL进行分类汇总,然后按照每类SQL总的执行时间降序排序输出的.并且每类SQL都给出了一个字面值SQL(期间执行时间最长的SQL).
我们一般重点分析执行时间占比大的SQL,也就是前排的一些SQL,它们的执行时间长,系统资源消耗大,对业务的影响也大.
以一个输出为例:
# Profile
# Rank Query ID Response time Calls R/Call V/M Item
# ==== ================== =============== ===== ======= ===== ============
# 1 0x426D0452190D3B9C 9629.1622 55.8% 5204 1.8503 0.01 SELECT queue_count
# 2 0x52A6A31F2F3F0692 2989.7074 17.3% 2224 1.3443 0.03 SELECT server_info
# 3 0x959209F179E16B2A 819.3819 4.8% 759 1.0796 0.00 SELECT server_info
第1类SQL总共耗时9629s,总的执行时间占日志中所有SQL执行时间的55.8%,在慢查询日志中出现了5204次,平均每次执行耗时为1.85s
下面有这类SQL的详尽信息,显示的字面值SQL是其中执行时间最长的SQL
# Query 1: 0.11 QPS, 0.20x concurrency, ID 0x426D0452190D3B9C at byte 4615533
# This item is included in the report because it matches --limit.
# Scores: V/M = 0.01
# Time range: 2017-05-24 23:56:03 to 2017-05-25 13:37:15
# Attribute pct total min max avg 95% stddev median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Count 52 5204
# Exec time 55 9629s 2s 3s 2s 2s 110ms 2s
# Lock time 23 185ms 20us 23ms 35us 40us 331us 25us
# Rows sent 0 5.65k 0 2 1.11 1.96 0.45 0.99
# Rows examine 83 18.54G 3.65M 3.65M 3.65M 3.50M 0 3.50M
# Query size 20 665.75k 131 131 131 131 0 131
# String:
# Databases queue_center
# Hosts 10.36.31.52 (696/13%), 10.36.31.31 (694/13%)... 6 more
# Users queue_center_w
# Query_time distribution
# 1us
# 10us
# 100us
# 1ms
# 10ms
# 100ms
# 1s ################################################################
# 10s+
# Tables
# SHOW TABLE STATUS FROM `queue_center` LIKE 'queue_count'\G
# SHOW CREATE TABLE `queue_center`.`queue_count`\G
# EXPLAIN /*!50100 PARTITIONS*/
select * from `queue_count` where `app_id` = '1' and `created_at` > '2017-05-25 11:42:01' and `created_at` <= '2017-05-25 11:43:01'\G
我们重点关注avg,95分位的 #Rows examine 和 # Rows sent
Rows examine / Rows sent 对非聚合SQL而言,代表返回1行数据所要检索的数据行数, 1 是想要的效果.
Rows examine检索行数偏大的,如果同时Rows sent返回的数据行数很少(聚合函数除外),一般是可以通过索引优化的。
对于update/delete类的写操作,慢查询日志中Rows_examined还是SQL执行过程中检索的行数,Rows_sent: 0 没有意义,慢查询日志中没有体现出来匹配/影响的行数来。
如果写操作Rows_examined很大,同时匹配/影响的行数极少,一般是全表扫描,写操作过程中持有表锁,影响并发的,而且执行时间长,容易导致同步延迟。但其实是可以通过索引优化这类写操作的。
如果Rows examin,Rows sent都很小,但总体执行时间长的话,特别是读取操作,很可能是受其它慢查询影响的,可以暂时先不管,把其它慢查询优化完毕之后,这类慢查询很可能也就消失了。
像上面这个SQL,3.65M/1.11 = 3.29M,也就是说平均需要扫描329W行数据才能返回1行记录,太低效了.
表结构中除了主键ID外没有任何的索引,其实业务都是查询最近1分钟内的数据,确实可以通过index(app_id,created_at)或者index(created_at)来优化这类查询。
mysql> show create table lc_day_channel_version\G
*************************** 1. row ***************************
Table: lc_day_channel_version
Create Table: CREATE TABLE `lc_day_channel_version` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`prodline` varchar(50) NOT NULL DEFAULT '' COMMENT '产品线标识',
`os` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '平台类型,1:Android_Phone 2:Android_Pad 3:IPhone',
`original_type` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '母包类型,1主线 3非主线',
`dtime` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'date time,yyyymmdd',
`version_name` varchar(50) NOT NULL DEFAULT '' COMMENT '来源版本号',
`channel` varchar(50) NOT NULL DEFAULT '' COMMENT '渠道号',
`request_pv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '请求量',
`request_uv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '请求用户量',
`response_pv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '请求成功量',
`response_uv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '请求成功用户量',
`download_pv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '下载量',
`download_uv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '下载用户量',
PRIMARY KEY (`id`),
UNIQUE KEY `UNIQUE_poouvc` (`prodline`,`os`,`original_type`,`dtime`,`version_name`,`channel`),
KEY `INDEX_d` (`dtime`)
) ENGINE=InnoDB AUTO_INCREMENT=135293125 DEFAULT CHARSET=utf8 COMMENT='升级版本渠道汇总信息'
1 row in set (0.00 sec)
mysql> explain select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime>=20170504 and dtime<=20170510 group by version_name order by request_pv desc;
+----+-------------+------------------------+-------+-----------------------+---------+---------+------+---------+--------------------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+------------------------+-------+-----------------------+---------+---------+------+---------+--------------------------------------------------------+
| 1 | SIMPLE | lc_day_channel_version | range | UNIQUE_poouvc,INDEX_d | INDEX_d | 4 | NULL | 2779470 | Using index condition; Using temporary; Using filesort |
+----+-------------+------------------------+-------+-----------------------+---------+---------+------+---------+--------------------------------------------------------+
1 row in set (0.00 sec)
业务反馈执行上面的SQL,有索引可用呀,为什么还这么慢呢?
问题在于:
mysql> select count(1) from lc_day_channel_version where dtime>=20170504 and dtime<=20170510;
+----------+
| count(1) |
+----------+
| 1462991 |
+----------+
1 row in set (0.58 sec)
对应146W记录,使用index(dtime),需要回访表获取version_name,request_pv字段,这样要对应146W的随机IO + 扫描的索引块数量的随机IO,
而后还要对这146W的结果集 group by version_name order by request_pv desc,代价还是很高的. 多次测试执行4.7s左右.
一种优化方案就是走覆盖索引,避免回访表:alter table lc_day_channel_version add key idx_dtime_version_name_request_pv(dtime,version_name,request_pv);
再看执行计划:
mysql> explain select sql_no_cache version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime>=20170504 and dtime<=20170510 group by version_name order by request_pv desc;
+----+-------------+------------------------+-------+---------------------------------------------------------+-----------------------------------+---------+------+---------+-----------------------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+------------------------+-------+---------------------------------------------------------+-----------------------------------+---------+------+---------+-----------------------------------------------------------+
| 1 | SIMPLE | lc_day_channel_version | range | UNIQUE_poouvc,INDEX_d,idx_dtime_version_name_request_pv | idx_dtime_version_name_request_pv | 4 | NULL | 2681154 | Using where; Using index; Using temporary; Using filesort |
+----+-------------+------------------------+-------+---------------------------------------------------------+-----------------------------------+---------+------+---------+-----------------------------------------------------------+
1 row in set (0.00 sec)
Using index 已经不需要回访表了,整体的执行时间也降低了一半,平均执行时间为2.35s左右.
再继续优化下去,能否避免对这么大量的数据(146W行记录)进行排序操作呀? 能利用索引避免排序操作吗? index(dtime,version_name,request_pv)为什么不能避免物理排序操作呢?(Using filesort显示确实存在物理排序动作)
原因就在于dtime上使用了范围匹配,使得索引数据整体上不再有序了. 那我改成等值匹配看看呢?
mysql> explain select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170504 group by version_name;
+----+-------------+------------------------+------+---------------------------------------------------------+-----------------------------------+---------+-------+--------+--------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+------------------------+------+---------------------------------------------------------+-----------------------------------+---------+-------+--------+--------------------------+
| 1 | SIMPLE | lc_day_channel_version | ref | UNIQUE_poouvc,INDEX_d,idx_dtime_version_name_request_pv | idx_dtime_version_name_request_pv | 4 | const | 402616 | Using where; Using index |
+----+-------------+------------------------+------+---------------------------------------------------------+-----------------------------------+---------+-------+--------+--------------------------+
1 row in set (0.00 sec)
确实没有出现Using filesort,说明通过这个索引能避免物理排序操作.
当然,业务逻辑还是不能变的,最终最初的SQL可以修改为如下等效的SQL:
select version_name,sum(request_pv) as request_pv
from
(
select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170504 group by version_name
union all
select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170505 group by version_name
union all
select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170506 group by version_name
union all
select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170507 group by version_name
union all
select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170508 group by version_name
union all
select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170509 group by version_name
union all
select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170510 group by version_name
) tmp
group by version_name order by request_pv desc;
每天对应21W左右的记录,每天的记录group by后对应2500行左右的记录,这样就将原来对146W记录排序,变成了对2500*7 行记录排序,排序量大幅下降,所以执行时间也有了提升,现在执行时间已经变为1.01s左右了
单纯从SQL的角度优化,似乎只能优化到这个地步了.而实际的业务SQL要比上面的还要复杂多变,比如说:
select version_name, sum(request_uv) as request_uv
from `lc_day_channel_version`
where `dtime` >= 20170505 and `dtime` <= 20170511
group by version_name order by request_uv desc;
select sql_calc_found_rows version_name, channel, sum(request_pv) as request_pv, sum(request_uv) as request_uv, sum(response_pv) as response_pv,
sum(response_uv) as response_uv, sum(download_pv) as download_pv, sum(download_uv) as download_uv
from `lc_day_channel_version`
where `dtime` >= 20170505 and `dtime` <= 20170511
group by version_name, channel
order by request_uv desc limit 0, 15
select version_name, sum(response_pv) as response_pv
from `lc_day_channel_version`
where `dtime` >= 20170505 and `dtime` <= 20170511
group by version_name order by response_pv desc
select sql_calc_found_rows version_name, channel, sum(request_pv) as request_pv, sum(request_uv) as request_uv,
sum(response_pv) as response_pv, sum(response_uv) as response_uv, sum(download_pv) as download_pv, sum(download_uv) as download_uv
from `lc_day_channel_version`
where `dtime` >= 20170505 and `dtime` <= 20170511
group by version_name, channel order by response_pv desc limit 0, 15
针对每类SQL都添加一个对应的索引? 那索引太多了,会严重影响写入性能的.
index(dtime,version_name,所有的统计项字段)
index(dtime,version_name,channel,所有的统计项字段)
这样全家桶式的索引,包含了所有的统计项字段,问题是每个索引太大了.
不要光想着SQL优化,其实最大的杀手锏: 业务优化还没有考虑呢。 那业务层面是否有优化的空间呢? 当然是有的,而且优化空间还不小.
每天的统计数据在插入后,基本就不再变动了.每天插入21W左右的记录,每天的数据统计后也就是2000多行的记录,这样在每天凌晨对前1天的数据进行异步统计,
统计结果放到一个中间表中去,每天的这种统计报表,不再扫描原始数据表,而扫描这类中间表,每天扫描的记录行数可以减少到1/100的数量级,再配以SQL层面的优化才是王道呀!
mysql> show create table mc_state\G
*************************** 1. row ***************************
Table: mc_state
Create Table: CREATE TABLE `mc_state` (
`state_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '机器的状态ID',
`transaction_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '维修周期ID',
`ip` int(10) unsigned NOT NULL COMMENT '机器的IP',
`state_name` varchar(255) NOT NULL COMMENT '状态名称',
`start_time` datetime NOT NULL COMMENT '状态开始时间',
`update_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '状态信息的更新时间',
`end_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '状态结束时间',
`create_ip` int(10) unsigned NOT NULL COMMENT '创建该状态的IP',
`error_status` text NOT NULL COMMENT '机器当前的状态错误信息(JSON)',
PRIMARY KEY (`state_id`),
KEY `idx_name` (`state_name`),
KEY `idx_time` (`start_time`),
KEY `idx_transaction_id` (`transaction_id`),
KEY `idx_ip` (`ip`),
KEY `idx_end_time` (`end_time`)
) ENGINE=InnoDB AUTO_INCREMENT=7614257 DEFAULT CHARSET=utf8 COMMENT='机器维修状态表'
mysql> show create table mc_machine\G
*************************** 1. row ***************************
Table: mc_machine
Create Table: CREATE TABLE `mc_machine` (
`ip` int(10) unsigned NOT NULL COMMENT '机器的IP',
`pool` varchar(255) NOT NULL COMMENT '机器所属维修策略',
`params` varchar(2047) NOT NULL DEFAULT '' COMMENT '其他PER机器的维修参数信息',
`create_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '机器信息的创建时间',
`update_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '机器信息的更新时间',
PRIMARY KEY (`ip`),
KEY `idx_pool` (`pool`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='机器信息表'
每天存在如下的慢查询:
# Query_time: 6.799199 Lock_time: 0.000124 Rows_sent: 148 Rows_examined: 8394700
SELECT mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_stat
e.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_stat
e.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status
FROM mc_state INNER JOIN
(SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip) AS anon_1
ON mc_state.state_id = anon_1.state_id INNER JOIN mc_machine ON mc_state.ip = mc_machine.ip
WHERE mc_machine.pool IN ('hadoop-repair-quick-repair')
LIMIT 0, 999999999999;
# Query_time: 6.826629 Lock_time: 0.000125 Rows_sent: 98 Rows_examined: 8394700
SELECT mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_stat
e.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_stat
e.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status
FROM mc_state INNER JOIN
(SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip) AS anon_1
ON mc_state.state_id = anon_1.state_id INNER JOIN mc_machine ON mc_state.ip = mc_machine.ip
WHERE mc_machine.pool IN ('kuorong_beehive')
LIMIT 0, 999999999999;
# Query_time: 7.824977 Lock_time: 0.000139 Rows_sent: 148 Rows_examined: 8394700
SELECT mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_stat
e.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_stat
e.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status
FROM mc_state INNER JOIN
(SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip) AS anon_1
ON mc_state.state_id = anon_1.state_id INNER JOIN mc_machine ON mc_state.ip = mc_machine.ip
LIMIT 0, 999999999999;
# Query_time: 7.899820 Lock_time: 0.000095 Rows_sent: 98 Rows_examined: 8394700
SELECT mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_stat
e.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_stat
e.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status
FROM mc_state INNER JOIN
(SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip) AS anon_1
ON mc_state.state_id = anon_1.state_id INNER JOIN mc_machine ON mc_state.ip = mc_machine.ip
WHERE mc_machine.pool IN ('kuorong_beehive')
LIMIT 0, 999999999999;
业务的逻辑是什么?获取每个池中,每台机器最新的状态数据.
其实最原始的业务需求是每天获得每台机器最新的状态数据,但一个SQL执行时间太长了,经常超时报错,所以最后修改为这样,按池获取.
但其实这样,每次执行都要执行SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip
大量这这个pool不相关的数据也要获取一遍,其实存在着明显的资源浪费的.
其实业务逻辑可以下面这样实现:
$last_ip = 0;
$result1 = $dbconn->prepare("select ip from mc_machine where pool='ps_diaoyan' and ip>? order by ip limit 1000");
$result2 = $dbconn->prepare("select mc_state.state_id AS mc_state_state_id,
mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip,
mc_state.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time,
mc_state.update_time AS mc_state_update_time,
mc_state.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip,
mc_state.error_status AS mc_state_error_status
from mc_state where ip=? order by state_id desc limit 1");
$v_file = fopen("matrix_mc1.result","w+");
$result1->bindParam(1,$last_ip,PDO::PARAM_INT);
$result2->bindParam(1,$this_ip,PDO::PARAM_INT);
while (true)
{
$result1->execute();
$iplist = $result1->fetchAll(PDO::FETCH_ASSOC);
foreach ( $iplist as $row )
{
$this_ip = intval($row["ip"]);
$result2->execute();
foreach ( $result2->fetchAll(PDO::FETCH_NUM) as $row2 )
{
$v_str = implode(",",$row2).PHP_EOL;
fwrite($v_file,$v_str);
}
}
if ( count($iplist) < 1000)
{
break;
}
$last_ip = intval($row["ip"]);
}
$result1 = null;
$result2 = null;
$dbconn = NULL;
fclose($v_file);
原始SQL的执行计划不在这里展示了.
优化后的方案只涉及到2类SQL,都是很简单的SQL,都可以通过高效的索引快速的返回结果:
mysql> explain select ip from mc_machine where ip>169524751 order by ip limit 1000;
+----+-------------+------------+-------+---------------+---------+---------+------+--------+--------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+------------+-------+---------------+---------+---------+------+--------+--------------------------+
| 1 | SIMPLE | mc_machine | range | PRIMARY | PRIMARY | 4 | NULL | 103043 | Using where; Using index |
+----+-------------+------------+-------+---------------+---------+---------+------+--------+--------------------------+
1 row in set (0.00 sec)
mysql> explain select mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id,
-> mc_state.ip AS mc_state_ip,mc_state.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time,
-> mc_state.update_time AS mc_state_update_time, mc_state.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip,
-> mc_state.error_status AS mc_state_error_status
-> from mc_state where ip=169524751 order by state_id desc limit 1;
+----+-------------+----------+------+---------------+--------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------+------+---------------+--------+---------+-------+------+-------------+
| 1 | SIMPLE | mc_state | ref | idx_ip | idx_ip | 4 | const | 1 | Using where |
+----+-------------+----------+------+---------------+--------+---------+-------+------+-------------+
1 row in set (0.00 sec)
这样通过SQL的拆分,通过循环的方式,将原来的一个复杂的自关联查询,变成2类简单的SQL循环执行,从而达到了优化的目的.
针对由此带来的应用端和DB端网络交互太多带来的时间成本,可以考虑使用multiquery一次发送执行多条SQL来减少频繁网络交互带来的影响(具体一次发送执行多少个SQL合适,需要业务层面进行测试确定).
当然,业务最终没有使用这里的方案,而是根据业务逻辑,变为简单的读取两个表的记录,而后代码层进行关联,也成功的消除了业务的读取压力问题.
这也说明了业务层面的优化是很重要的.
手百的夏逗活动, 是手百为了提升用户黏度推出的一个活动,鼓励用户通过手百搜索一些奇葩的问题,并奖励给用户一定的豆币,最终排名前1500名的用户,可以瓜分一笔现金大奖.
表结构如下:
mysql> show create table xiadou_user\G
*************************** 1. row ***************************
Table: xiadou_user
Create Table: CREATE TABLE `xiadou_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户 id',
`uid` bigint(20) NOT NULL COMMENT '百度账号 uid',
`uname` varchar(128) NOT NULL DEFAULT '' COMMENT '百度账号名称',
`displayname` varchar(128) NOT NULL DEFAULT '' COMMENT '百度账号显示名称',
`securemobil` varchar(50) NOT NULL DEFAULT '' COMMENT '绑定的手机号',
`score` int(10) NOT NULL DEFAULT '0' COMMENT '用户当前的豆子数',
`money` float NOT NULL DEFAULT '0' COMMENT '累积抽奖赚取的金额',
`last_sign_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '最近一次签到时间',
`sign_continue_days` tinyint(3) NOT NULL DEFAULT '0' COMMENT '签到连续天数',
`create_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`last_add_score_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '最近一次加豆子的时间',
`cuid` varchar(255) NOT NULL DEFAULT '' COMMENT '用户的 cuid 信息,可能包含多个,逗号分隔,最多存3个',
`win` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否是最后大奖中奖用户',
`awarded` tinyint(1) NOT NULL DEFAULT '0' COMMENT '表示是否领取过最后大奖',
`issafe` tinyint(2) NOT NULL DEFAULT '1' COMMENT '安全状态,1 正常,0高危',
`appos` varchar(100) NOT NULL DEFAULT '' COMMENT 'appos',
PRIMARY KEY (`id`),
UNIQUE KEY `uid_UNIQUE` (`uid`),
KEY `SCORE_TIME_INDEX` (`score`,`last_add_score_time`),
KEY `WIN_INDEX` (`win`),
KEY `idx_appos` (`appos`)
) ENGINE=InnoDB AUTO_INCREMENT=9891815 DEFAULT CHARSET=utf8 COMMENT='用户信息表'
1 row in set (0.00 sec)
具体的排名规则是: ORDER BY score DESC, last_add_score_time ASC 优先按照分数降序排名, 分数相同的, 早获得这个分数的用户排名靠前.
用户参与活动,搜索了奇葩问题后,很可能想查看自己目前的积分,距离瓜分大奖的资格还差多少分.
所以业务提供了这样一个功能:
SELECT score FROM xiadou_user ORDER BY score DESC, last_add_score_time ASC LIMIT 1500, 1;
查询目前第1500名(其实应该是limit 1499,1 的) 的分数,然后显示目前用户的分数距离它还差多少分数.
这个查询应用端是有CACHE的,但每次只要用户积分有变化,排名就可能发生变化,所以业务会del相关的CACHE,所以对于这个查询而言,CACHE是没有用的,白天时段,读取基本上还都是要实时的走DB的.
但是很快DB端CPU就打满了,DB端都是上面这个查询,为什么呢?
mysql> explain SELECT score FROM xiadou_user ORDER BY score DESC, last_add_score_time ASC LIMIT 1500, 1;
+----+-------------+-------------+-------+---------------+------------------+---------+------+---------+-----------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------------+-------+---------------+------------------+---------+------+---------+-----------------------------+
| 1 | SIMPLE | xiadou_user | index | NULL | SCORE_TIME_INDEX | 8 | NULL | 9255660 | Using index; Using filesort |
+----+-------------+-------------+-------+---------------+------------------+---------+------+---------+-----------------------------+
1 row in set (0.00 sec)
存在KEY SCORE_TIME_INDEX
(score
,last_add_score_time
) 但是它只能优化这2个字段的同向排序,都升序或者都降序都可以通过这个索引避免物理排序,快速的返回TOPN记录.
因为MYSQL本身只有升序索引,没有降序索引,但索引叶节点是通过双向链表来保证逻辑有序的,所以SQL层面两个排序字段都升序或者都降序,都是可以通过索引来优化的,就是正向扫描和逆向扫描索引而已.
但对于2个排序字段排序方向不同的情况,是无法通过索引优化的,只能进行物理排序了,所以执行计划中出现了Using filesort ,也就是说读取出几百W的记录,而后物理排序,最后输出第1500个记录,所以SQL性能很差的.
大并发的情况,情况进一步恶化,从而导致DB主机CPU打满的情况(随着数据的持续增加,情况只会是进一步的恶化).
优化方案:
因为排序时优先按分数排序,分数相同的,再按照时间排序,这里并不是要获得确切的第1500名的用户信息,而只是要获得第1500名的分数而已,所以上面的SQL在业务逻辑层其实等价于下面的SQL:SELECT score FROM xiadou_user ORDER BY score DESC LIMIT 1500, 1;
mysql> explain SELECT score FROM xiadou_user ORDER BY score DESC LIMIT 1500, 1;
+----+-------------+-------------+-------+---------------+------------------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------------+-------+---------------+------------------+---------+------+------+-------------+
| 1 | SIMPLE | xiadou_user | index | NULL | SCORE_TIME_INDEX | 8 | NULL | 1501 | Using index |
+----+-------------+-------------+-------+---------------+------------------+---------+------+------+-------------+
1 row in set (0.00 sec)
这个SQL是可以使用KEY SCORE_TIME_INDEX
(score
,last_add_score_time
) 来优化的,是不需要物理排序的.
业务改写为这个SQL,上线后,DB主机CPU恢复正常,而且业务响应时间大幅提升.
当然,最终的用户排名还是要调用上面的2个字段的排序SQL的.
不过业务21点结束活动,22点公布排名,这中间完全可以执行SQL,把结果插入到一个结果表去,而后只是读取这个结果表就可以了.而且这样也方便业务干预最终的排名结果.
当然,应用端使用cache缓存上面2个字段的排序SQL的执行结果,也是完全可行的,因为数据不再变动,完全可以通过cache挡住全部的读取流量.
总结:
原本并不等价的2个SQL,但在业务层面是完全等价的,通过SQL的改写,达到了优化的目的.