记一次关于 Mysql 中 text 类型和索引问题引起的慢查询的定位及优化

5,877 阅读15分钟

最近有用户反馈产品有些页面加载比较慢,刚好我在学习 Mysql 相关知识,所以先从 Mysql 慢查询日志开始定位:

step1:通过慢查询日志定位具体 SQL

首先通过 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秒:

image

step2:使用 explain 分析 SQL 执行计划

刚好上周末写了一篇 使用 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 性能

step3:使用 show profile 继续定位

可以通过文章 学习如何统计 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 类型时以及索引查询时的一些优化建议:

step4: 如何优化 Mysql 中 text 和 blob 类型:

什么是行溢出数据?
  • InnoDB 会将一些大对象数据存放在数据页之外的 BLOB 页中,然后在查询时根据指针去对应的 BLOB 页中查询。
  • 要不要将数据放在 BLOB 页中,取决于当前页中是否可以存放下至少两行数据,对于默认是 16 KB 大小的页,这个阈值长度是 8098,大于该值的会存放在 BLOB 页中。
  • BLOB 不只存放 text 和 blob 类型,varchar 类型的数据也有可能被存放在 BLOB 页中,而 blob 类型和 text 类型的数据也有可能不被存放在 BLOB 页中。
  • 对于 Compact 和 Redundant 行存储格式存放的数据,采用的是部分行溢出存储,前 768 字节还是会存放在当前数据页中的。
  • 对于 Compressed 和 Dynamic 行存储格式存放的数据,采用的完全行溢出存储,只用 20 个字节存放指针,其余所有数据都放在行溢出数据中。
为什么要尽量少使用 text 和 blob 类型?
  • 首先对于 text 和 blob 类型,在遇到使用临时表的情况时,无法使用内存临时表,只能在磁盘上创建临时表。
  • 对于行溢出数据,InnoDB 一次只会为一个列分配一页的空间,但是当该列超过 32 个页后会一次性分配 64 个页面,存储空间有一定的浪费。
  • 行溢出数据禁用了自适应哈希索引,如果作为 where 条件时必须完整的比较整个列。
  • 对于 text 和 blob 字段进行排序时,只能使用部分前缀进行排序,默认是 1024 字节,可以通过 max_sort_length 进行设置。
  • 数据量太大,会导致 InnoDB 每个数据页中存放的行数减少,从而影响对页面的缓存。
  • 如果存放在行溢出数据中,每次会根据指针去对应的溢出页进行查询,增加页面访问次数,而且每次查询都是随机 IO,text 字段越多查询次数越多。
如何优化查询?
  • 如果有许多大字段,可以考虑合并这些字段到一个字段,存储一个大的 200kb 比存储 20 个 10kb 更高效,检查随机页面访问次数。
  • 查询时尽量避免对大字段查询,尤其是获取列表时,杜绝使用 select * 查询。
  • 可以考虑将大字段专门放在另外一张表中,只有在需要时再关联查询,增加 InnoDB 的当前表缓存命中率。
  • 如果只需要获取大字段的部分数据,可以使用 SUBSTRING( ) 函数,这样可以避免使用磁盘临时表。
  • 如果必须使用到磁盘临时表,可以考虑将磁盘临时表指向在基于内存的文件系统中,可以通过修改 tmpdir 参数实现。
  • 必要时可以考虑对大字段进行压缩后再存储到表中。
  • 尽量不要使用大字段作为 where 中的查询条件。

step5: 如何正确使用索引

  • 创建索引时尽量选择 Cardinality 值比较大的字段,你可以通过 explain 观察自己创建的索引到底有没有被使用
  • order by 中的排序的列如果建了索引,则可以使用直接索引进行排序,优化性能
  • 在使用索引时对应的索引列必须独立,不能是表达式的一部分也不能是函数的参数,否则不能使用索引:
-- 虽然 id 上建立了索引,但是无法使用索引优化
select id from user where id + 1 =5;
  • 当服务器出现多个列做 AND 操作查询时,通常需要建了一个多列索引,而不是多个独立的单列索引
  • 当不需要考虑排序和分组时,将选择性最高的列放在前面通常是最好的,因为可以很快的过滤出需要的行
  • 如果索引包含了需要查询的所有字段值,那么就是可以使用覆盖索引查询,只需要读取索引,极大地减少了数据访问量,在 EXPLAIN 分析的 Extra 字段中可以看到 “Using index” 信息
  • 如果查询中某个列是范围查询,那么其右边的所有列将无法使用索引优化,索引尽量将范围条件放在右边或者使用多个等值条件来代替范围查询
  • 查询时尽量不要返回多余的列,第一可以减少网络流量,第二增加使用覆盖索引的可能性
  • 多列索引时只有当索引的列和 ORDER BY 子句的顺序完全一致且所有列的排序方向一致时才能使用索引做排序
  • 不要创建冗余的索引,Mysql 不仅需要单独维护索引列,并且在优化器查询时也需要逐个索引进行过滤,会影响性能,下面是创建冗余索引的几个例子:
- 创建了索引(A,B)再创建索引(A),那后者便是冗余索引
- 创建索引扩展为(A,ID),其中 ID 是主键,对于 InnoDB 来说主键已经包含在二级索引中了,所以这也是冗余的
  • 有一些索引可能服务器永远都不会用到,建议考虑删除,在 percona 版本或 marida 中可以通过 information_schea.index_statistics 查看得到索引的使用情况,在官方版本中 可以使用 performance_schema.table_io_waits_summary_by_index_usage 查看索引使用情况

参考文献