MySQL 分页优化

MySQL limit offset optimize

Posted by HaoDu on May 13, 2021

原理

1
2
3
 select *
 from user
 limit 10000,10;

执行过程:

1.全表扫描,取出 10000 + 10 行

2.舍弃掉前 10000 (offset) 行,留下 10 行

问题

当表的数据足够多,页数足够的大的时候,将造成性能浪费,降低效率。

优化

条件筛选(强制走索引)

laravel 中的 chuckById 就是这么做的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// src/Illuminate/Database/Concerns/BuildsQueries.php
/**
 * Chunk the results of a query by comparing IDs.
 *
 * @param  int  $count
 * @param  callable  $callback
 * @param  string|null  $column
 * @param  string|null  $alias
 * @return bool
 */
public function chunkById($count, callable $callback, $column = null, $alias = null)
{
    $column = $column ?? $this->defaultKeyName();

    $alias = $alias ?? $column;

    $lastId = null;

    $page = 1;

    do {
        $clone = clone $this;

        // We'll execute the query for the given page and get the results. If there are
        // no results we can just break and return from here. When there are results
        // we will call the callback with the current chunk of these results here.
        $results = $clone->forPageAfterId($count, $lastId, $column)->get();

        $countResults = $results->count();

        if ($countResults == 0) {
            break;
        }

        // On each chunk result set, we will pass them to the callback and then let the
        // developer take care of everything within the callback, which allows us to
        // keep the memory low for spinning through large result sets for working.
        if ($callback($results, $page) === false) {
            return false;
        }

        $lastId = $results->last()->{$alias};

        if ($lastId === null) {
            throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result.");
        }

        unset($results);

        $page++;
    } while ($countResults == $count);

    return true;
}

// src/Illuminate/Database/Query/Builder.php

/**
 * Constrain the query to the next "page" of results after a given ID.
 *
 * @param  int  $perPage
 * @param  int|null  $lastId
 * @param  string  $column
 * @return $this
 */
public function forPageAfterId($perPage = 15, $lastId = 0, $column = 'id')
{
    $this->orders = $this->removeExistingOrdersFor($column);

    if (! is_null($lastId)) {
        $this->where($column, '>', $lastId);
    }

    return $this->orderBy($column, 'asc')
                ->limit($perPage);
}

chuckById 每次都会记录本批次最后一个列 id 再通过 forPageAfterId 方法组成 如下 SQL:

1
2
3
4
select *
from user
where `id` > 9000
limit 1000;

id 通常设置为自增,我们认为其按顺序排列,那这样可以直接跳过前 9000 行

缺点:不能从第一页跳转到第 n 页

缺点解决方案:前 100 页不做优化,当到 101 页时采用该优化方案,并且不让用户从 101 页进行页面跳转到 1xx 页

子句

原查询:

1
2
3
 select *
 from user
 limit 10000,10;

改为子句:

1
2
3
4
 select *
 from user a,
      (select `id` from user limit 10000,10) b
 where a.id = b.id;

延迟关联

1
2
3
4
5
6
7
SELECT *
FROM user a
         inner join(
    select id
    from user b
    LIMIT 10000, 10
) as users using (id);