最近有用户反馈产品有些页面加载比较慢,刚好我在学习 Mysql 相关知识,所以先从 Mysql 慢查询日志开始定位:
首先通过 SHOW VARIABLES like 查看当前 Mysql 服务器关于慢查询的具体配置信息:
slow_query_log = ON # 慢查询日志处于开启状态,所以可以直接查询
slow_query_type = 1 # 根据运行时间将 SQL 语句中记录到 slow log 中,而不考虑逻辑 IO 次数
long_query_time = 5.000000 # 凡是超过 5 秒以上的 SQL 都会记录到 slow log 中
log_output = TABLE # slow log 记录到 mysql.slow_log 表中
log_queries_not_using_indexes = OFF # 没有使用索引的 SQL 不会记录到 slow_log 中,刚好我们只关心查询时间慢的 SQL
确认了 Mysql 服务器对慢查询的配置满足需求,我们不需要再修改任何配置,直接抓取对应时间点的慢查询日志:
-- 查看7月1日从9点半到10点半的slow log,并找出每条慢查询SQL的最长查询时间以及查询次数,并按照查询时间排序
SELECT
db,
start_time,
max(query_time) AS max_query_time,
CONVERT (sql_text USING utf8) AS sqlText, -- sql_text 是 blob 类型,我们需要 CONVERT 到 varchar 来识别具体 SQL
count(1) AS count
FROM
mysql.slow_log
WHERE
start_time > "2019-07-01 09:30:00.000000"
AND start_time < "2019-07-01 10:30:00.000000"
GROUP BY
sql_text
ORDER BY
max_query_time DESC
最终我们找到了服务器上四条不同的 slow log sql,最长查询时间分别是 9秒,8秒,7秒,6秒:
刚好上周末写了一篇 使用 explain 优化你的 mysql 性能,可以直接上手,先对第一条 SQL 作分析:
mysql> EXPLAIN SELECT
t.*, p.id AS projectId
FROM
table_extract t
LEFT JOIN data_connection dc ON dc.id = t.data_connection_id
LEFT JOIN project p ON p.id = dc.project_id
WHERE
p.id IN (
700201361,
700201360,
700201359,
700201358,
700201357,
700201356,
700201354,
700201353,
700201351,
700201350,
700201347
);
+----+-------------+-------+------------+--------+---------------------------------------------------------+---------+---------+------------------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+---------------------------------------------------------+---------+---------+------------------------------+------+----------+-------------+
| 1 | SIMPLE | t | NULL | ALL | NULL | NULL | NULL | NULL | 2159 | 100.00 | NULL |
| 1 | SIMPLE | dc | NULL | eq_ref | PRIMARY,index_data_connection_project_id,idx_project_id | PRIMARY | 4 | youdata.t.data_connection_id | 1 | 100.00 | Using where |
| 1 | SIMPLE | p | NULL | eq_ref | PRIMARY | PRIMARY | 4 | youdata.dc.project_id | 1 | 100.00 | Using index |
+----+-------------+-------+------------+--------+---------------------------------------------------------+---------+---------+------------------------------+------+----------+-------------+
3 行于数据集 (0.05 秒)
通过上述输出结果没发现什么大的问题,两次关联查询都使用了 type = eq_ref,并且都使用了索引,只是对于 table_extract 这张表的查询数据库走了全表扫描,这个确实没办法,我们需要获取该表中除了索引以外的其它字段,但是这张表的数据量也只有rows=2159行,所以理论上也不会有问题,所以这条 SQL 通过 explain 没有发现什么大问题,后面会继续分析。
接下来再看第二条 SQL:
mysql> EXPLAIN SELECT
date(create_time) AS days,
count(create_time) AS dayView
FROM
resource_operation_record
WHERE
resource_type IN ('NEW_REPORT', 'COCKPIT')
AND `action` = 'VIEW'
AND resource_id = 4539
AND create_time > '2019-06-25 00:00:00'
AND create_time < '2019-07-01 09:45:19'
GROUP BY
days;
+----+-------------+---------------------------+------------+------+-----------------+------+---------+------+---------+----------+----------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+---------------------------+------------+------+-----------------+------+---------+------+---------+----------+----------------------------------------------+
| 1 | SIMPLE | resource_operation_record | NULL | ALL | resource_id_idx | NULL | NULL | NULL | 1729523 | 0.02 | Using where; Using temporary; Using filesort |
+----+-------------+---------------------------+------------+------+-----------------+------+---------+------+---------+----------+----------------------------------------------+
1 行于数据集 (0.05 秒)
首先 possible_keys 字段告诉我们可能用到的索引 resource_id_idx,可是为什么 key 字段里没有真正用到索引呢?这应该是 Mysql 优化器认为使用索引对该查询优化空间不大,或者说可能会使性能更差。加上 Extra 字段里还有 Using filesort,Using temporary,在将近 rows = 200万 的数据里进行全表扫描,查询时间超过 5 秒再正常不过了。所以我们查看一下索引信息来定位一下为什么没有使用 resource_id_idx 索引:
mysql> show index from resource_operation_record;
+---------------------------+------------+-----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment |
+---------------------------+------------+-----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| resource_operation_record | 0 | PRIMARY | 1 | id | A | 1646744 | NULL | NULL | | BTREE | | |
| resource_operation_record | 1 | creator_id_idx | 1 | creator_id | A | 1169 | NULL | NULL | YES | BTREE | | |
| resource_operation_record | 1 | resource_id_idx | 1 | resource_id | A | 4228 | NULL | NULL | YES | BTREE | | |
+---------------------------+------------+-----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
3 行于数据集 (0.04 秒)
resource_operation_record 这张表上一共三个索引,id 是自增主键,这个可以先不用管。对于其它两个索引 creator_id_idx 和 resource_id_idx,首先看到 Cardinality 这个值和 id 聚集索引差距好大,Cardinality 这个值表示索引中不重复的预估值,该值很关键,它和表中总行数比值越接近 1 越好,而且优化器会根据该值选择是否使用索引优化,关于 InnoDB 索引和 Cardinality 相关内容可以看 InnoDB 存储引擎的索引和算法学习 这篇文章。resource_id_idx 的可选择太小了,比例只有 0.0025,看来优化器不选择该索引是正常的,所以我们大部分情况下要相信 Mysql 优化器。我们也可以使用 force index(resource_id_idx) 强制使用索引来观察效果:
SELECT
date(create_time) AS days,
count(create_time) AS dayView
FROM
resource_operation_record
force index(resource_id_idx) -- 使用 force index 强制使用索引
WHERE
resource_id = 4539
AND resource_type IN ('NEW_REPORT', 'COCKPIT')
AND `action` = 'VIEW'
AND create_time > '2019-06-25 00:00:00'
AND create_time < '2019-07-01 09:45:19'
GROUP BY
days;
+------------+---------+
| days | dayView |
+------------+---------+
| 2019-06-28 | 29 |
| 2019-06-29 | 2 |
| 2019-06-30 | 2 |
| 2019-07-01 | 5 |
+------------+---------+
4 行于数据集 (1.67 秒)
-- 查询要 1.67 秒,相同情况下,我不使用 force index 要 1.61 秒,比使用索引还要快,当然这个不同时间点查询也有关系
-- 总之,使用索引确实没有多大提升
再观察上述查询,我们发现 select 和 where 条件中用到了 create_time,而且这个 create_time 是数据插入的时间,理论上不会有太多重复的,尝试在 create_time 上创建索引:
-- 新建索引
ALTER TABLE `resource_operation_record` ADD INDEX `create_time_idx` USING BTREE (`create_time`);
-- 查看索引信息
show index from resource_operation_record;
+---------------------------+------------+-----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment |
+---------------------------+------------+-----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| resource_operation_record | 0 | PRIMARY | 1 | id | A | 1739371 | NULL | NULL | | BTREE | | |
| resource_operation_record | 1 | creator_id_idx | 1 | creator_id | A | 1002 | NULL | NULL | YES | BTREE | | |
| resource_operation_record | 1 | resource_id_idx | 1 | resource_id | A | 6988 | NULL | NULL | YES | BTREE | | |
| resource_operation_record | 1 | create_time_idx | 1 | create_time | A | 1246230 | NULL | NULL | YES | BTREE | | |
+---------------------------+------------+-----------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
4 行于数据集 (0.25 秒)
-- 查看执行计划
mysql> EXPLAIN SELECT
date(create_time) AS days,
count(create_time) AS dayView
FROM
resource_operation_record
WHERE
resource_type IN ('NEW_REPORT', 'COCKPIT')
AND `action` = 'VIEW'
AND resource_id = 4539
AND create_time > '2019-06-25 00:00:00'
AND create_time < '2019-07-01 09:45:19'
GROUP BY
days;
+----+-------------+---------------------------+------------+-------+---------------------------------+-----------------+---------+------+--------+----------+---------------------------------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+---------------------------+------------+-------+---------------------------------+-----------------+---------+------+--------+----------+---------------------------------------------------------------------+
| 1 | SIMPLE | resource_operation_record | NULL | range | resource_id_idx,create_time_idx | create_time_idx | 5 | NULL | 210240 | 0.20 | Using index condition; Using where; Using temporary; Using filesort |
+----+-------------+---------------------------+------------+-------+---------------------------------+-----------------+---------+------+--------+----------+---------------------------------------------------------------------+
1 行于数据集 (0.19 秒)
建立索引的基础上我们再查看执行计划和索引信息,create_time_idx 的 Cardinality 变为 1246230,选择性大于 0.7,优化器自然会选择该索引,果然 explain 出来的结果是 type = range,使用了范围索引查询,并且 extra 里增加了 Using index condition,表示会先条件过滤索引,过滤完索引后找到所有符合索引条件的数据行,随后用 WHERE 子句中的其他条件去过滤这些数据行。
最后执行查询看一下优化后的效果,相比以前 1.7 秒,速度提升了 5 倍左右,这个优化到此为止,因为这个表是对用户访问记录的统计,后面可以考虑针对时间分区进行优化。
SELECT
date(create_time) AS days,
count(create_time) AS dayView
FROM
resource_operation_record
WHERE
resource_id = 4539
AND resource_type IN ('NEW_REPORT', 'COCKPIT')
AND `action` = 'VIEW'
AND create_time > '2019-06-25 00:00:00'
AND create_time < '2019-07-01 09:45:19'
GROUP BY
days;
4 行于数据集 (0.35 秒)
接下来还有两个慢查询 SQL,这两个慢查询 SQL 和第一个 SQL 一样通过 explain 输出结果看不出什么效果,所以接下来我们通过 profile 查看这三个 SQL 性能
可以通过文章 学习如何统计 Mysql 服务器状态信息 来了解如何使用 SHOW STATUS,SHOW ENGINE INNODB STATUS,SHOW PROCESSLIST,SHOW PROFILE 来查看 Mysql 服务器状态信息。
Mysql 5.1 版本开始支持 SHOW PROFILE 功能,它可以高精度的记录每个查询语句在运行过程中各个操作的执行时间,这个功能可能会影响 Mysql 查询性能,所以默认情况下是关闭的,由于我们临时定位问题,可以短暂开启该功能:
-- 开启 profiling 功能
mysql> SET global profiling = ON;
Query OK, 0 rows affected, 1 warning (0.00 sec)
-- 执行第三条慢查询 SQL
SELECT
t.id AS id,
t. NAME AS NAME,
data_connection_id AS dataConnectionId,
QUERY,
init_sql AS initSql,
t.produced AS produced,
t.creator_id AS creatorId,
t.create_time AS createTime,
u.nick AS creatorName
FROM
custom_table AS t
LEFT JOIN bigviz_user AS u ON t.creator_id = u.id
WHERE
t.data_connection_id = 20;
800 行于数据集 (1.1 秒)
-- 根据 show profiles 找到对应的 Query_Id = 8,对应执行时间为 1.1 秒
show profiles
-- 具体查看每一步的耗时情况
mysql> show profile for query 8;
+----------------------+----------+
| Status | Duration |
+----------------------+----------+
| starting | 0.000222 |
| checking permissions | 0.000030 |
| checking permissions | 0.000027 |
| Opening tables | 0.000049 |
| init | 0.000062 |
| System lock | 0.000035 |
| optimizing | 0.000037 |
| statistics | 0.000063 |
| preparing | 0.000048 |
| executing | 0.000025 |
| Sending data | 1.101708 |
| end | 0.000090 |
| query end | 0.000034 |
| closing tables | 0.000088 |
| freeing items | 0.000055 |
| logging slow query | 0.000030 |
| Opening tables | 0.000159 |
| System lock | 0.000100 |
| cleaning up | 0.000041 |
+----------------------+----------+
19 rows in set, 1 warning (0.00 sec)
通过 show profile 返回数据可以发现,基本上所有的时间都花在了 “Sending data” 上,我们查看 Mysql 官方文档对 “Sending data” 的说明:
The thread is reading and processing rows for a SELECT statement, and sending data to the client.
Because operations occurring during this state tend to perform large amounts of disk access (reads),
it is often the longest-running state over the lifetime of a given query.
也就是说 “Sending data” 并不是单纯的发送数据,而是包括“收集 + 发送数据”,这个阶段一般是 query 中最耗时的阶段,那么为什么这个只有 800 行的查询会耗时这么久呢,难道这 800 行中平均每行数据量都很大?所以看一下该表定义:
mysql> show create table custom_table\G;
*************************** 1. row ***************************
Table: custom_table
Create Table: CREATE TABLE `custom_table` (
`name` varchar(2000) DEFAULT NULL,
`produced` varchar(255) DEFAULT 'UserDefinedSQL',
`query` longtext,
`project_id` int(11) DEFAULT NULL,
`data_connection_id` int(11) DEFAULT NULL,
`creator_id` int(11) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`modifier_id` int(11) DEFAULT NULL,
`modify_time` datetime DEFAULT NULL,
`id` int(11) NOT NULL AUTO_INCREMENT,
`init_sql` text COMMENT '初始化sql',
`rely_list` text COMMENT '表依赖关系',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=27975 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
上述表结构定义中发现有三个字段 query,init_sql,rely_list 都是 text 类型字段,而且 query 字段还是 longtext,从我们怀疑出发,试着在 select 查询中去掉 init_sql 和 query 的查询后再观察结果:
SELECT
t.id AS id,
t. NAME AS NAME,
data_connection_id AS dataConnectionId,
t.produced AS produced,
t.creator_id AS creatorId,
t.create_time AS createTime,
u.nick AS creatorName
FROM
custom_table AS t
LEFT JOIN bigviz_user AS u ON t.creator_id = u.id
WHERE
t.data_connection_id = 20;
800 行于数据集 (0.04 秒)
天哪,从 1 秒左右变成了 0.04 秒,完全不是一个数量级的,看来 text 类型的字段对整个查询影响太大了,我们先不急追究为什么,先看如何在当前业务上优化查询,由于考虑到业务场景是前端获取 custom_table 在某个 data_connection_id 下的列表,会返回 query,init_sql 这两个字段,这个两个字段用户确实会用到,但是只有在用户点击某个 custom_table 进行编辑或者查看详情时才会用到,那我们为什么不考虑延迟获取呢?只有当用户需要查看详情时再根据主键 ID 去获取对应的信息,这个时候属于 const 查询且只有一行数据,代价非常小。
而对于第一个慢查询 SQL,直接使用 select * 去查询,这个表里面包好了多个 text 字段,而该业务需求其实只需要 id 和 used_memory(biginit) 两个字段,所以我们优化成只选择其中两个字段进行查询。
对于第四个慢查询 SQL,对应的表结构里面也包含了一个 mediumtext 字段,前端界面上用户需要根据该字段里的文本信息进行搜索,但是该场景使用很少,只有当用户切换到对应的”按照字段名称搜索“时才会用到该字段,默认情况下不会用到该字段,所以我们可以在用户切换到对应的搜索时再返回该字段,默认情况下不返回即可。
针对以上三个慢查询 SQL 我们在不改变表结构的情况下,通过修改业务处理逻辑都成功解决了问题,下面是对于 Mysql 在使用 text 和 blob 类型时以及索引查询时的一些优化建议:
-- 虽然 id 上建立了索引,但是无法使用索引优化
select id from user where id + 1 =5;
- 创建了索引(A,B)再创建索引(A),那后者便是冗余索引
- 创建索引扩展为(A,ID),其中 ID 是主键,对于 InnoDB 来说主键已经包含在二级索引中了,所以这也是冗余的