分页查询的各种实现、优化

分页查询的各种实现、优化

    • [limit 不是在 innodb 引擎层执行的,而是在 server 执行的](#limit 不是在 innodb 引擎层执行的,而是在 server 执行的)
    • [简单 order by、limit](#简单 order by、limit)
    • 延迟回表
    • [create_time + id 游标查询](#create_time + id 游标查询)
    • 其他的异构方案

假设有表 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 条件(本例中只能处理与 idcreate_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_timeid 两个条件。

下一页 SQL 如下,其中 lastCreateTime 是本页最后一条的 create_timelastId 是本页最后一条的 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_timefirstId 是本页第一条的 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 中的游标查询方案

相关推荐
winfredzhang1 个月前
Node.js + SQLite 实战:本地 Markdown 阅读书架源码深度解析
sqlite·node.js·safari·分页·多媒体·md文档
小小工匠1 个月前
Redis - 实现分页 + 多条件模糊查询:一套完整可落地的组合方案
数据库·redis·缓存·分页·模糊查询
sulikey1 个月前
ELF文件中的“节“与“段“,如何与虚拟地址空间中的“分页“和“分段“产生联系?
linux·服务器·elf·虚拟地址空间·分页·分段·elf文件
七夜zippoe2 个月前
DolphinDB海量数据查询:分页与采样
分页·查询·采样·dolphindb·海量
尽兴-4 个月前
ES 深度分页问题及其解决方案详解
大数据·elasticsearch·搜索引擎·滚动分页·游标分页·基础分页
jfqqqqq5 个月前
minIO分页请求maxKeys无效的问题
java·sdk·minio·分页
一只小阿乐6 个月前
vue-web端网站 滑动进行分页
前端·javascript·vue.js·vue·分页
牛魔王_17 个月前
SqlServer 大数据量分页查询
数据库·sqlserver·分页·查询·翻页
CodeAmaz7 个月前
Elasticsearch 实际工作中的分页方案详解
elasticsearch·分页·深度分页