专栏名称: SegmentFault思否
SegmentFault (www.sf.gg)开发者社区,是中国年轻开发者喜爱的极客社区,我们为开发者提供最纯粹的技术交流和分享平台。
目录
相关文章推荐
程序猿  ·  41岁DeepMind天才科学家去世:长期受 ... ·  昨天  
程序员小灰  ·  3个令人惊艳的DeepSeek项目,诞生了! ·  昨天  
OSC开源社区  ·  升级到Svelte ... ·  4 天前  
程序猿  ·  “未来 3 年内,Python 在 AI ... ·  4 天前  
51好读  ›  专栏  ›  SegmentFault思否

一次 group by + order by 性能优化分析

SegmentFault思否  · 公众号  · 程序员  · 2019-03-21 08:00

正文

原文:我的个人博客 https://mengkang.net/1302.html 工作了两三年,技术停滞不前,迷茫没有方向,不如看下我的直播:

PHP 进阶之路 (金三银四跳槽必考,一般人我不告诉他)。

最近通过一个日志表做排行的时候发现特别卡,最后问题得到了解决,梳理一些索引和 MySQL 执行过程的经验,但是最后还是有 5 个谜题没解开 ,希望大家帮忙解答下。

主要包含如下知识点:

  • 用数据说话证明慢日志的扫描行数到底是如何统计出来的

  • 从 group by 执行原理找出优化方案

  • 排序的实现细节

  • gdb 源码调试

背景

需要分别统计本月、本周被访问的文章的 TOP10。日志表如下:

  1. CREATE TABLE `article_rank` (

  2. `id` int(11) unsigned NOT NULL AUTO_INCREMENT,

  3. `aid` int(11) unsigned NOT NULL,

  4. `pv` int(11) unsigned NOT NULL DEFAULT '1',

  5. `day` int(11) NOT NULL COMMENT '日期 例如 20171016',

  6. PRIMARY KEY (`id`),

  7. KEY `idx_day_aid_pv` (`day`,`aid`,`pv`),

  8. KEY `idx_aid_day_pv` (`aid`,`day`,`pv`)

  9. ) ENGINE=InnoDB DEFAULT CHARSET=utf8

准备工作

为了能够清晰的验证自己的一些猜想,在虚拟机里安装了一个 debug 版的 mysql,然后开启了慢日志收集,用于统计扫描行数。

安装
  • 下载源码

  • 编译安装

  • 创建 mysql 用户

  • 初始化数据库

  • 初始化 mysql 配置文件

  • 修改密码

如果你兴趣,具体可以参考我的博客,一步步安装 https://mengkang.net/1335.html。

开启慢日志

编辑配置文件,在 [ mysqld ] 块下添加:

  1. slow_query_log=1

  2. slow_query_log_file=xxx

  3. long_query_time=0

  4. log_queries_not_using_indexes=1

性能分析

发现问题

假如我需要查询 2018 - 12 - 20 ~ 2018 - 12 - 24 这 5 天浏览量最大的 10 篇文章的 sql 如下,首先使用 explain 看下分析结果:

  1. mysql > explain select aid,sum(pv) as num from article_rank where day>=20181220 and day<=20181224 group by aid order by num desc limit 10;

  2. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+--------+----------+-----------------------------------------------------------+

  3. | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |

  4. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+--------+----------+-----------------------------------------------------------+

  5. | 1 | SIMPLE | article_rank | NULL | range | idx_day_aid_pv,idx_aid_day_pv | idx_day_aid_pv | 4 | NULL | 404607 | 100.00 | Using where; Using index; Using temporary; Using filesort |

  6. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+--------+----------+-----------------------------------------------------------+

系统默认会走的索引是 idx_day_aid_pv ,根据 Extra 信息我们可以看到,使用 idx_day_aid_pv 索引的时候,会走覆盖索引,但是会使用临时表,会有排序。

我们查看下慢日志里的记录信息:

  1. # Time: 2019-03-17T03:02:27.984091Z

  2. # User@Host: root[root] @ localhost [] Id: 6

  3. # Query_time: 56.959484 Lock_time: 0.000195 Rows_sent: 10 Rows_examined: 1337315

  4. SET timestamp=1552791747;

  5. select aid,sum(pv) as num from article_rank where day>=20181220 and day<=20181224 group by aid order by num desc limit 10;

为什么扫描行数是 1337315

我们查询两个数据,一个是满足条件的行数,一个是 group by 统计之后的行数。

  1. mysql> select count(*) from article_rank where day>=20181220 and day<=20181224;

  2. +----------+

  3. | count(*) |

  4. +----------+

  5. | 785102 |

  6. +----------+


  7. mysql> select count(distinct aid) from article_rank where day>=20181220 and day<=20181224;

  8. +---------------------+

  9. | count(distinct aid) |

  10. +---------------------+

  11. | 552203 |

  12. +---------------------+

发现:满足条件的总行数(785102) + group by 之后的总行数(552203)+ limit 的值 = 慢日志里统计的 Rows_examined

要解答这个问题,就必须搞清楚上面这个 sql 到底分别都是如何运行的。

执行流程分析

索引示例

为了便于理解,我按照索引的规则先模拟 idx_day_aid_pv 索引的一小部分数据:

  1. day | aid | pv | id

  2. -------- | -------- | ------- | -------

  3. 20181220 | 1 | 23 | 1234

  4. 20181220 | 3 | 2 | 1231

  5. 20181220 | 4 | 1 | 1212

  6. 20181220 | 7 | 2 | 1221

  7. 20181221 | 1 | 5 | 1257

  8. 20181221 | 10 | 1 | 1251

  9. 20181221 | 11 | 8 | 1258

因为索引 idx_day_aid_pv 最左列是 day ,所以当我们需要查找 20181220 ~ 20181224 之间的文章的 pv 总和的时候,我们需要遍历 20181220 ~ 20181224 这段数据的索引。

查看 optimizer trace 信息

  1. # 开启 optimizer_trace

  2. set optimizer_trace='enabled=on';

  3. # 执行 sql

  4. select aid,sum(pv) as num from article_rank where day >=20181220 and day<=20181224 group by aid order by num desc limit 10;

  5. # 查看 trace 信息

  6. select trace from `information_schema`.`optimizer_trace`\G;

摘取里面最后的执行结果如下:

  1. {

  2. "join_execution": {

  3. "select#": 1,

  4. "steps": [

  5. {

  6. "creating_tmp_table": {

  7. "tmp_table_info": {

  8. "table": "intermediate_tmp_table",

  9. "row_length": 20,

  10. "key_length": 4,

  11. "unique_constraint": false,

  12. "location": "memory (heap)",

  13. "row_limit_estimate": 838860

  14. }

  15. }

  16. },

  17. {

  18. "converting_tmp_table_to_ondisk": {

  19. "cause": "memory_table_size_exceeded",

  20. "tmp_table_info": {

  21. "table": "intermediate_tmp_table",

  22. "row_length": 20,

  23. "key_length": 4,

  24. "unique_constraint": false,

  25. "location": "disk (InnoDB)",

  26. "record_format": "fixed"

  27. }

  28. }

  29. },

  30. {

  31. "filesort_information": [

  32. {

  33. "direction": "desc",

  34. "table": "intermediate_tmp_table",

  35. "field": "num"

  36. }

  37. ],

  38. "filesort_priority_queue_optimization": {

  39. "limit": 10,

  40. "rows_estimate": 1057,

  41. "row_size": 36,

  42. "memory_available": 262144,

  43. "chosen": true

  44. },

  45. "filesort_execution": [

  46. ],

  47. "filesort_summary": {

  48. "rows": 11,

  49. "examined_rows": 552203,

  50. "number_of_tmp_files": 0,

  51. "sort_buffer_size": 488,

  52. "sort_mode": ""

  53. }

  54. }

  55. ]

  56. }

  57. }

分析临时表字段

mysql gdb 调试更多细节:https://mengkang.net/1336.html。

通过 gdb 调试确认临时表上的字段是 aid num

  1. Breakpoint 1, trace_tmp_table (trace=0x7eff94003088, table=0x7eff94937200) at /root/newdb/mysql-server/sql/sql_tmp_table.cc:2306

  2. warning: Source file is more recent than executable.

  3. 2306 trace_tmp.add("row_length",table->s->reclength).

  4. (gdb) p table->s->reclength

  5. $1 = 20

  6. (gdb) p table->s->fields

  7. $2 = 2

  8. (gdb) p (*(table->field+0))->field_name

  9. $3 = 0x7eff94010b0c "aid"

  10. (gdb) p (*(table->field+1))->field_name

  11. $4 = 0x7eff94007518 "num"

  12. (gdb) p (*(table->field+0))->row_pack_length()

  13. $5 = 4

  14. (gdb) p (*( table->field+1))->row_pack_length()

  15. $6 = 15

  16. (gdb) p (*(table->field+0))->type()

  17. $7 = MYSQL_TYPE_LONG

  18. (gdb) p (*(table->field+1))->type()

  19. $8 = MYSQL_TYPE_NEWDECIMAL

  20. (gdb)

通过上面的打印,确认了字段类型,一个 aid MYSQL_TYPE_LONG ,占 4 字节, num MYSQL_TYPE_NEWDECIMAL ,占 15 字节。

The SUM() and AVG() functions return a DECIMAL value for exact-value arguments (integer or DECIMAL), and a DOUBLE value for approximate-value arguments (FLOAT or DOUBLE). (Before MySQL 5.0.3, SUM() and AVG() return DOUBLE for all numeric arguments.)

但是通过我们上面打印信息可以看到两个字段的长度加起来是 19,而 optimizer_trace 里的 tmp_table_info . reclength 是 20。通过其他实验也发现 table -> s -> reclength 的长度就是 table -> field 数组里面所有字段的字段长度和再加 1。

总结执行流程

  1. 尝试在堆上使用 memory 的内存临时表来存放 group by 的数据,发现内存不够;

  2. 创建一张临时表,临时表上有两个字段, aid num 字段( sum ( pv ) as num );

  3. 从索引 idx_day_aid_pv 中取出1行,插入临时表。插入规则是如果 aid 不存在则直接插入,如果存在,则把 pv 的值累加在 num 上;

  4. 循环遍历索引 idx_day_aid_pv 20181220 ~ 20181224 之间的所有行,执行步骤 3;

  5. 对临时表根据 num 的值做优先队列排序;

  6. 取出最后留在堆(优先队列的堆)里面的 10 行数据,作为结果集直接返回,不需要再回表;

补充说明优先队列排序执行步骤分析:

  1. 在临时表(未排序)中取出前 10 行,把其中的 num aid 作为 10 个元素构成一个小顶堆,也就是最小的 num 在堆顶。

  2. 取下一行,根据 num 的值和堆顶值作比较,如果该字大于堆顶的值,则替换掉。然后将新的堆做堆排序。

  3. 重复步骤 2 直到第 552203 行比较完成。

优化

方案1 使用 idx aid day_pv 索引
  1. # Query_time: 4.406927 Lock_time: 0.000200 Rows_sent: 10 Rows_examined: 1337315

  2. SET timestamp=1552791804;

  3. select aid,sum(pv) as num from article_rank force index(idx_aid_day_pv) where day>=20181220 and day<=20181224 group by aid order by num desc limit 10;

扫描行数都是 1337315 ,为什么执行消耗的时间上快了 12 倍呢?

索引示例

为了便于理解,同样我也按照索引的规则先模拟 idx_aid_day_pv 索引的一小部分数据:

  1. aid | day | pv | id

  2. -------- | -------- | ------- | -------

  3. 1 | 20181220 | 23 | 1234

  4. 1 | 20181221 | 5 | 1257

  5. 3 | 20181220 | 2 | 1231

  6. 3 | 20181222 | 22 | 1331

  7. 3 | 20181224 | 13 | 1431

  8. 4 | 20181220 | 1 | 1212

  9. 7 | 20181220 | 2 | 1221

  10. 10 | 20181221 | 1 | 1251

  11. 11 | 20181221 | 8 | 1258

group by 不需要临时表的情况

为什么性能上比 SQL1 高了,很多呢,原因之一是 idx_aid_day_pv 索引上 aid 是确定有序的,那么执行 group by 的时候,则不会创建临时表,排序的时候才需要临时表。如果印证这一点呢,我们通过下面的执行计划就能看到。

使用 idx_day_aid_pv 索引的效果:

  1. mysql> explain select aid,sum(pv) as num from article_rank force index(idx_day_aid_pv) where day>=20181220 and day<=20181224 group by aid order by null limit 10;

  2. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+--------+----------+-------------------------------------------+

  3. | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |

  4. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+--------+----------+-------------------------------------------+

  5. | 1 | SIMPLE | article_rank | NULL | range | idx_day_aid_pv,idx_aid_day_pv | idx_day_aid_pv | 4 | NULL | 404607 | 100.00 | Using where; Using index; Using temporary |

  6. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+--------+----------+-------------------------------------------+

注意我上面使用了 order by null 表示强制对 group by 的结果不做排序。如果不加 order by null ,上面的 sql 则会出现 Using filesort

使用 idx_aid_day_pv 索引的效果:

  1. mysql> explain select aid,sum(pv) as num from article_rank force index(idx_aid_day_pv) where day>=20181220 and day<=20181224 group by aid order by null limit 10;

  2. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+------+----------+--------------------------+

  3. | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |

  4. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+------+----------+--------------------------+

  5. | 1 | SIMPLE | article_rank | NULL | index | idx_day_aid_pv,idx_aid_day_pv | idx_aid_day_pv | 12 | NULL | 10 | 11.11 | Using where; Using index |

  6. +----+-------------+--------------+------------+-------+-------------------------------+----------------+---------+------+------+----------+--------------------------+

查看 optimizer trace 信息

  1. # 开启optimizer_trace

  2. set optimizer_trace='enabled=on';

  3. # 执行 sql

  4. select aid,sum(pv) as num from article_rank force index(idx_aid_day_pv) where day>=20181220 and day<=20181224 group by aid order by num desc limit 10;

  5. # 查看 trace 信息

  6. select trace from `information_schema`.`optimizer_trace`\G;

摘取里面最后的执行结果如下:

  1. {

  2. "join_execution": {

  3. "select#": 1,

  4. "steps": [

  5. {

  6. "creating_tmp_table": {

  7. "tmp_table_info": {

  8. "table": "intermediate_tmp_table",

  9. "row_length": 20,

  10. "key_length": 0,

  11. "unique_constraint": false,

  12. "location": "memory (heap)",

  13. "row_limit_estimate": 838860

  14. }

  15. }

  16. },

  17. {

  18. "filesort_information": [

  19. {

  20. "direction": "desc",

  21. "table": "intermediate_tmp_table",

  22. "field": "num"

  23. }

  24. ],

  25. "filesort_priority_queue_optimization": {

  26. "limit": 10,

  27. "rows_estimate": 552213,

  28. "row_size": 24,

  29. "memory_available": 262144,

  30. "chosen": true

  31. },







请到「今天看啥」查看全文