分页查询的各种实现、优化
假设有表 user,需求是按照 create_time 倒序显示列表。
sql
create table user
(
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
# 其他字段
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_create_time (create_time)
)
limit 不是在 innodb 引擎层执行的,而是在 server 执行的
虽然没有在官方文档中说明 limit 是在 server 执行的,不是在 innodb 引擎层。但可以从侧面推测出来,我们知道 innodb 只能处理与索引相关的 where 条件(本例中只能处理与 id 和 create_time 相关的 where 条件),有时还需要依靠 index condition pushdown 才能尽可能的减少回表,如果 where 条件不完全在索引中(比如 where create_time > x and state = 1 中的 state = 1),需要先根据索引 idx_create_time 查询到 id,根据 id 回表查询出完整记录,将其从 innodb 引擎层发送到 server 层,server 层再进行剩余条件 state = 1 的判断。
limit 肯定是要在完全符合 where 条件的记录集合里挑选 N 条作为结果,但 innodb 引擎层无法完整的判断记录是否符合 where 条件,所以 limit 不是在 innodb 引擎层执行的,而是在 server 执行的。
使用Limit Offset下推提升分页查询性能-云原生数据库 PolarDB-阿里云 中提到
社区MySQL的Limit操作完全由SQL层完成,数据从引擎层读取后需要交给SQL层处理,然后过滤掉Offset。当查询二级索引,需要访问主表列的时候,引擎层还会先回表获取所有需要的列信息。而对于SQL层无条件过滤的场景(包含SQL层谓词完全下推到引擎层),Limit操作处理OffSet的数据不需要经过任何计算就会被过滤掉,引擎层与SQL层的交互和回表的代价会导致分页查询随着分页数增加而越来越慢。PolarDB MySQL版会把Limit Offset下推到引擎层,这些数据直接在引擎中扫描过滤,且选择二级索引时这些数据不需要进行回表。
简单 order by、limit
最简单、常见的实现方式是 select * from user order by create_time desc limit 10。但是会有深度分页的问题,select * from user order by create_time desc limit 1000000, 10。随着翻页的页数越来越大,SQL 执行会越来越慢。
深度分页慢的原因
有可能不走 idx_create_time 索引,而是全表扫描 + filesort,这样肯定很慢。
走 idx_create_time 索引的情况。已知 limit 是在 server 层执行。上述 SQL 的执行顺序是扫描 idx_create_time 索引得到 1000010 个 create_time、id 的元组,根据 id 回表查询出 1000010 条完整的记录(有可能利用 MRR 优化),将 1000010 条记录发送到 server 层,这些记录已经按 create_time 排好序了,不需要额外的 filesort,然后 server 层丢弃前 1000000 条,将最后 10 条发送给客户端。
其中 1000000 条的回表操作和 1000000 条完整记录发送到 server 层的操作是完全无效的,而且消耗大量的 IO 和时间,慢的原因就是这些无效操作。
缺点
-
如果有人删除了本页之前的数据,在翻到下一页时会导致有几条数据被跳过显示。
-
如果有人插入了新的数据,在翻到上一页时会导致有数据重复显示。
延迟回表
想要加速深度分页的速度,就要减少回表的次数和发送给 server 层的数据量。
下面的 SQL 会报错 [42000][1235] This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery',因为 MySQL 不允许在子查询中使用 limit,我们先忽略这个错误,只看思路。
sql
select *
from user as u
where u.id in (select id from user order by create_time desc limit 1000000, 10)
order by u.create_time desc;
先执行子查询扫描 idx_create_time 索引只查询 id 列,不回表查询完整记录,可以利用索引覆盖 use index 的优化 。
将 1000010 个 id 发送到 server 层,这些 id 已经按 create_time 排好序了,不需要额外的 filesort,server 层丢弃前 1000000 条,只留最后 10 个 id。
使用 10 个 id 执行 select * from user as u where u.id in (10 个 id) order by u.create_time desc,只取 10 条数据速度很快。最后返回客户端。
优化的效果
减少了回表的次数:优化前回表 1000010 次,优化后回表 10 次。
发送给 server 层的数据量变少了:优化前发送 1000010 条完整记录,优化后发送 1000010 个 id + 10 条完整记录。
使用 join 代替子查询解决报错,SQL 的执行结果是一样的:
sql
select *
from user as u
join (select id from user order by create_time desc limit 1000000, 10) as t
on u.id = t.id
order by u.create_time desc;
缺点
与简单 order by、limit 相同的缺点。
发送到 server 层的 id 个数还是随着页号 x 线性增长的 f(x) = nx,只是 n 变小了,变慢的速度减缓了,如果遇到超大页号还是会很慢。
create_time + id 游标查询
id 倒序
如果需求是按照 id 倒序展示列表,可以以本页的最后一条、第一条记录的 id 作为游标来查询下一页、上一页:
下一页 SQL 可以是 select * from user where id < lastId order by id desc limit 10,其中 lastId 是本页最后一条的 id,这里排序是 desc。
上一页 SQL 可以是 select * from user where id > firstId order by id asc limit 10,其中 firstId 是本页第一条的 id,这里排序是 asc,然后执行 Collections.reverse(res)。
create 倒序
问题:
- 根据
id倒序实现较为简单是因为id没有重复值,但是create_time可能存在重复值,在翻页时可能导致部分相同create_time的记录被跳过显示。 - 排序字段有重复值可能导致相同
create_time的记录每次的显示顺序不一样。
为了解决这些问题,可以在倒序排序时将 id 放在最后使用 order by create_time desc, id desc,这样的组合排序就不会存在重复值了。
排序逻辑就是如果 create_time 不相等就取 create_time 大的,如果 create_time 相等则再比较 id,取 id 大的。
在上下翻页时需要同时传入 create_time、id 两个条件。
下一页 SQL 如下,其中 lastCreateTime 是本页最后一条的 create_time, lastId 是本页最后一条的 id,两个排序都是 desc。
sql
select * from user
where create_time < lastCreateTime or (create_time = lastCreateTime and id < lastId)
order by create_time desc, id desc
limit 10
等价的写法:
sql
select * from user
where (create_time, id) < (lastCreateTime, lastId)
order by create_time desc, id desc
limit 10
上一页 SQL 如下,其中 firstCreateTime 是本页第一条的 create_time, firstId 是本页第一条的 id,两个排序都是 asc,然后执行 Collections.reverse(res)。
sql
select * from user
where create_time > firstCreateTime or (create_time = firstCreateTime and id > firstId)
order by create_time asc, id asc
limit 10
等价的写法:
sql
select * from user
where (create_time, id) > (firstCreateTime, firstId)
order by create_time asc, id asc
limit 10
第一页
sql
select * from user
order by create_time desc, id desc
limit 10
最后一页
SQL 结果需执行 Collections.reverse(res)。
sql
select * from user
order by create_time asc, id asc
limit 10
优点
执行时间几乎是常数,与页号无关,无论打开哪一页的执行速度都是一样的。
拿下一页举例,倒序扫描 idx_create_time 索引找到 create_time < lastCreateTime or (create_time = lastCreateTime and id < lastId) 的第一条记录,这个操作的执行时间只与 B+ tree 的高度有关,与 lastCreateTime、lastId 的取值无关。
然后取 10 个 id 发送到 server 层,id 是按照 create_time desc, id desc 排好序的,无效额外的 filesort。
从 innode 中获取这 10 个 id 对应的完整记录,返回客户端。
缺点
只能进行上下翻页、第一页、最后一页,无法直接跳到中间的第 N 页。
其他的异构方案
searchAfter 是 elasticsearch 中的游标查询方案