Skip to content

mysql 证明为什么用limit时,offset很大会影响性能 #117

Open
@zhangyachen

Description

@zhangyachen
Owner

首先说明一下MySQL的版本:

mysql> select version();
+-----------+
| version() |
+-----------+
| 5.7.17    |
+-----------+
1 row in set (0.00 sec)

表结构:

mysql> desc test;
+--------+---------------------+------+-----+---------+----------------+
| Field  | Type                | Null | Key | Default | Extra          |
+--------+---------------------+------+-----+---------+----------------+
| id     | bigint(20) unsigned | NO   | PRI | NULL    | auto_increment |
| val    | int(10) unsigned    | NO   | MUL | 0       |                |
| source | int(10) unsigned    | NO   |     | 0       |                |
+--------+---------------------+------+-----+---------+----------------+
3 rows in set (0.00 sec)

id为自增主键,val为非唯一索引。

灌入大量数据,共500万:

mysql> select count(*) from test;
+----------+
| count(*) |
+----------+
|  5242882 |
+----------+
1 row in set (4.25 sec)

我们知道,当limit offset rows中的offset很大时,会出现效率问题:

mysql> select * from test where val=4 limit 300000,5;
+---------+-----+--------+
| id      | val | source |
+---------+-----+--------+
| 3327622 |   4 |      4 |
| 3327632 |   4 |      4 |
| 3327642 |   4 |      4 |
| 3327652 |   4 |      4 |
| 3327662 |   4 |      4 |
+---------+-----+--------+
5 rows in set (15.98 sec)

为了达到相同的目的,我们一般会改写成如下语句:

mysql> select * from test a inner join (select id from test where val=4 limit 300000,5) b on a.id=b.id;
+---------+-----+--------+---------+
| id      | val | source | id      |
+---------+-----+--------+---------+
| 3327622 |   4 |      4 | 3327622 |
| 3327632 |   4 |      4 | 3327632 |
| 3327642 |   4 |      4 | 3327642 |
| 3327652 |   4 |      4 | 3327652 |
| 3327662 |   4 |      4 | 3327662 |
+---------+-----+--------+---------+
5 rows in set (0.38 sec)

时间相差很明显。

为什么会出现上面的结果?我们看一下select * from test where val=4 limit 300000,5;的查询过程:

  • 查询到索引叶子节点数据。
  • 根据叶子节点上的主键值去聚簇索引上查询需要的全部字段值。

类似于下面这张图:

image

像上面这样,需要查询300005次索引节点,查询300005次聚簇索引的数据,最后再将结果过滤掉前300000条,取出最后5条。MySQL耗费了大量随机I/O在查询聚簇索引的数据上,而有300000次随机I/O查询到的数据是不会出现在结果集当中的。

肯定会有人问:既然一开始是利用索引的,为什么不先沿着索引叶子节点查询到最后需要的5个节点,然后再去聚簇索引中查询实际数据。这样只需要5次随机I/O,类似于下面图片的过程:

image

其实我也想问这个问题。

证实

下面我们实际操作一下来证实上述的推论:
为了证实select * from test where val=4 limit 300000,5是扫描300005个索引节点和300005个聚簇索引上的数据节点,我们需要知道MySQL有没有办法统计在一个sql中通过索引节点查询数据节点的次数。我先试了Handler_read_*系列,很遗憾没有一个变量能满足条件。

我只能通过间接的方式来证实:
InnoDB中有buffer pool。里面存有最近访问过的数据页,包括数据页和索引页。所以我们需要运行两个sql,来比较buffer pool中的数据页的数量。预测结果是运行select * from test a inner join (select id from test where val=4 limit 300000,5) b on a.id=b.id;之后,buffer pool中的数据页的数量远远少于select * from test where val=4 limit 300000,5;对应的数量,因为前一个sql只访问5次数据页,而后一个sql访问300005次数据页。

select * from test where val=4 limit 300000,5

mysql> select index_name,count(*) from information_schema.INNODB_BUFFER_PAGE where INDEX_NAME in('val','primary') and TABLE_NAME like '%test%' group by index_name;
Empty set (0.04 sec)

可以看出,目前buffer pool中没有关于test表的数据页。

mysql> select * from test where val=4 limit 300000,5;
+---------+-----+--------+
| id      | val | source |
+---------+-----+--------+
| 3327622 |   4 |      4 |
| 3327632 |   4 |      4 |
| 3327642 |   4 |      4 |
| 3327652 |   4 |      4 |
| 3327662 |   4 |      4 |
+---------+-----+--------+
5 rows in set (26.19 sec)

mysql> select index_name,count(*) from information_schema.INNODB_BUFFER_PAGE where INDEX_NAME in('val','primary') and TABLE_NAME like '%test%' group by index_name;
+------------+----------+
| index_name | count(*) |
+------------+----------+
| PRIMARY    |     4098 |
| val        |      208 |
+------------+----------+
2 rows in set (0.04 sec)

可以看出,此时buffer pool中关于test表有4098个数据页,208个索引页。

select * from test a inner join (select id from test where val=4 limit 300000,5) b on a.id=b.id

为了防止上次试验的影响,我们需要清空buffer pool,重启mysql。

mysqladmin shutdown
/usr/local/bin/mysqld_safe &
mysql> select index_name,count(*) from information_schema.INNODB_BUFFER_PAGE where INDEX_NAME in('val','primary') and TABLE_NAME like '%test%' group by index_name;
Empty set (0.03 sec)

运行sql:

mysql> select * from test a inner join (select id from test where val=4 limit 300000,5) b on a.id=b.id;
+---------+-----+--------+---------+
| id      | val | source | id      |
+---------+-----+--------+---------+
| 3327622 |   4 |      4 | 3327622 |
| 3327632 |   4 |      4 | 3327632 |
| 3327642 |   4 |      4 | 3327642 |
| 3327652 |   4 |      4 | 3327652 |
| 3327662 |   4 |      4 | 3327662 |
+---------+-----+--------+---------+
5 rows in set (0.09 sec)

mysql> select index_name,count(*) from information_schema.INNODB_BUFFER_PAGE where INDEX_NAME in('val','primary') and TABLE_NAME like '%test%' group by index_name;
+------------+----------+
| index_name | count(*) |
+------------+----------+
| PRIMARY    |        5 |
| val        |      390 |
+------------+----------+
2 rows in set (0.03 sec)

我们可以看明显的看出两者的差别:第一个sql加载了4098个数据页到buffer pool,而第二个sql只加载了5个数据页到buffer pool。符合我们的预测。也证实了为什么第一个sql会慢:读取大量的无用数据行(300000),最后却抛弃掉。
而且这会造成一个问题:加载了很多热点不是很高的数据页到buffer pool,会造成buffer pool的污染,占用buffer pool的空间。

遇到的问题

  • 为了在每次重启时确保清空buffer pool,我们需要关闭innodb_buffer_pool_dump_at_shutdowninnodb_buffer_pool_load_at_startup,这两个选项能够控制数据库关闭时dump出buffer pool中的数据和在数据库开启时载入在磁盘上备份buffer pool的数据。

参考资料:

欢迎大家关注我的知乎账号:https://www.zhihu.com/people/zhangyachen

或者可以关注下我的公众号,获得最及时的更新:

image

Activity

changed the title [-]为什么用limit时,offset很大会影响性能[/-] [+]mysql 为什么用limit时,offset很大会影响性能[/+] on Apr 2, 2017
changed the title [-]mysql 为什么用limit时,offset很大会影响性能[/-] [+]mysql 证明为什么用limit时,offset很大会影响性能[/+] on Apr 2, 2017
getive

getive commented on Apr 6, 2017

@getive

不知用完整的字段列表取代*,结果会不会有变化。

zhangyachen

zhangyachen commented on Apr 6, 2017

@zhangyachen
OwnerAuthor

木有懂,完整的字段和*有什么区别

beiyoufx

beiyoufx commented on Apr 10, 2017

@beiyoufx

mark

Stephen-Z

Stephen-Z commented on Apr 12, 2017

@Stephen-Z

get~但是好想知道查询原理 为什么用inner join那个方法会快这么多

zhangyachen

zhangyachen commented on Apr 12, 2017

@zhangyachen
OwnerAuthor

额,看来我讲的不清楚:sob:
在文中的情况下,inner join只会取limit pn,rn中的rn次数据页。而第一个sql会取pn + rn次数据页。
而取数据页是随机I/O,所以inner join的随机I/O少,比较快。

qq332984152

qq332984152 commented on Apr 12, 2017

@qq332984152

学习了,虽然没遇到过,挺好的。

Michaelrsxie

Michaelrsxie commented on Apr 12, 2017

@Michaelrsxie

为什么inner join只会取limit pn,rn中的rn次数据页,不是子查询的吗,有点不太理解这里

liulian-lero

liulian-lero commented on Apr 13, 2017

@liulian-lero

能用explain 看下两个sql的执行计划吗?
我猜测是select * from test where val=4 limit 300000,5; 执行过程中没有用到val字段的索引,导致检索的时候每行数据都取出来再进行比较。而mysql> select * from test a inner join (select id from test where val=4 limit 300000,5) b on a.id=b.id;子查询中指定了id字段,所以执行计划是用到val索引,并且通过val索引的叶子节点上的主键值,再通过聚簇索引取出5行的所有字段。
求大神信息以及解释~

zhangyachen

zhangyachen commented on Apr 13, 2017

@zhangyachen
OwnerAuthor
mysql> explain select * from test where val=4 limit 300000,5;
+----+-------------+-------+------------+------+---------------+------+---------+-------+--------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref   | rows   | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+-------+--------+----------+-------+
|  1 | SIMPLE      | test  | NULL       | ref  | val           | val  | 4       | const | 899712 |   100.00 | NULL  |
+----+-------------+-------+------------+------+---------------+------+---------+-------+--------+----------+-------+
1 row in set, 1 warning (0.01 sec)

mysql> explain select * from test a inner join (select id from test where val=4 limit 300000,5) b on a.id=b.id;
+----+-------------+------------+------------+--------+---------------+---------+---------+-------+--------+----------+-------------+
| id | select_type | table      | partitions | type   | possible_keys | key     | key_len | ref   | rows   | filtered | Extra       |
+----+-------------+------------+------------+--------+---------------+---------+---------+-------+--------+----------+-------------+
|  1 | PRIMARY     | <derived2> | NULL       | ALL    | NULL          | NULL    | NULL    | NULL  | 300005 |   100.00 | NULL        |
|  1 | PRIMARY     | a          | NULL       | eq_ref | PRIMARY       | PRIMARY | 8       | b.id  |      1 |   100.00 | NULL        |
|  2 | DERIVED     | test       | NULL       | ref    | val           | val     | 4       | const | 899712 |   100.00 | Using index |
+----+-------------+------------+------------+--------+---------------+---------+---------+-------+--------+----------+-------------+
3 rows in set, 1 warning (0.00 sec)

select * from test where val=4 limit 300000,5;用到了索引,并且查一个索引节点查一次聚簇索引,往复300005次。
select * from test a inner join (select id from test where val=4 limit 300000,5) b on a.id=b.id;覆盖索引,先查300005次索引节点,再利用最后5个索引节点的id查聚簇索引。

@linfasfasf

Michaelrsxie

Michaelrsxie commented on Apr 14, 2017

@Michaelrsxie

思考一天后,说一下我的理解,那个子查询快的原因主要是select的内容是索引,查找的内容是可以直接通过索引得到,是比较快的;没有子查询的那个select *的话虽然用到了索引,但是select了包含不是索引的内容,无法直接索引直接获得,是需要通过索引间接查找相关的内容。
两者的区别在于一个是通过索引直接得到索引内容,一个是通过索引间接得到内容,虽然都用到了索引,但获取索引外的其他内容还需要再通过索引查找一遍。

zhangyachen

zhangyachen commented on Apr 14, 2017

@zhangyachen
OwnerAuthor

26 remaining items

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @getive@gdgy@yanpeipan@song0071000@zhangyinhao1234

        Issue actions

          mysql 证明为什么用limit时,offset很大会影响性能 · Issue #117 · zhangyachen/zhangyachen.github.io