“直接用 mysql limit 分页不就行了,分页查询用得着四种写法吗?”
这或许是不少开发者的第一反应。诚然,LIMIT OFFSET 是数据库分页最基础的实现方式,但在不同的业务场景下,其他方案往往能带来更优的性能与体验。
大家最熟悉的便是如下这种分页形式,它能够返回总页数并支持任意页数的跳转。

Limit Offset分页
假设每页展示10条数据,查询第三页时,SQL中的 LIMIT 子句通常写作:LIMIT 20, 10;。
前端请求需携带“每页数量”和“当前页码”参数,由后端拼接成SQL查询语句。
LIMIT OFFSET 分页具备两个显著特点:
- 支持随机跳页:用户可直接跳转到任意指定页面。
- 返回数据总量:便于前端展示总页数和总记录数。
尽管实现简单,LIMIT OFFSET 存在一个明显的性能缺陷:在深度分页(即 OFFSET 值非常大)时,MySQL 需要扫描并跳过大量数据才能定位到目标页,极易引发慢查询。这不仅会增加数据库的CPU与内存负载,若此类查询的QPS较高,还可能在高流量时段拖垮数据库,进而影响其他业务查询。
这里有一个重要原则:分页查询必须明确指定排序方式(ORDER BY)。若未指定,则无法保证各页数据的一致性,可能出现记录重复。若无业务排序字段,使用主键ID排序是最稳妥的选择。笔者曾在从 Lucene 迁移至 ElasticSearch 时,因未指定排序导致分页结果不一致,排查良久才意识到此问题。
那么,LIMIT OFFSET 是否有方法规避深度分页问题呢?答案是肯定的。
Limit + 主键Id过滤
一种优化思路是在查询条件中引入主键ID进行范围过滤。
改进前:select * from students where [查询条件] order by id desc limit 1000, 20;
改进后:select * from students where [查询条件] AND id < lastMinId order by id desc limit 20;
优化后,我们在原有查询条件基础上,额外指定了 lastMinId(即上一页结果中最小的ID)。查询下一页时,将此值作为参数传入,确保只获取ID小于该值的后续记录。这相当于从源头缩小了检索范围,每次仅获取固定数量的数据。
这种方式是否就高枕无忧了?并非如此。
它的前提是排序字段必须为主键ID。若业务要求按其他字段(如时间、价格)排序,此法则不适用。
它同样适用于从数据源批量拉取全量数据的场景。通过按主键排序,并记录每次获取的最大或最小ID,进行连续批次查询,即可高效、无深度分页压力地获取全部数据。
HasMore 滚动查询
在某些用户端场景(如App内的购买记录列表),用户只需逐页浏览,无需知晓总订单数。针对此类“无限滚动”场景,可以进行优化。
传统 LIMIT OFFSET 方案需要执行一次 SELECT COUNT(*) 以获取总数。而滚动分页则无需此步骤。
具体实现是:每次查询时,请求的条数比实际展示数多1条。例如,每页展示10条,则查询11条。若实际返回11条,则设置 hasMore = true,提示前端还有更多数据;若返回条数≤10,则设置 hasMore = false,前端停止请求。这减少了一次昂贵的全表计数查询,提升了性能。
ElasticSearch 分页查询
ElasticSearch 适用于检索条件复杂、对实时性要求相对宽松的场景,例如B端后台的复杂筛选或C端的订单关键词搜索。这类查询耗时通常在百毫秒甚至秒级。
需要注意的是,ES的数据通常由MySQL等数据库/中间件异构同步而来,从数据产生到进入ES索引存在一定延迟(通常秒级)。
ES也支持类似 LIMIT OFFSET 的分页查询(通过 from 和 size 参数),并且同样强烈建议在分页时指定排序方式。
SearchRequest searchRequest = new SearchRequest(index);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
// 计算起始记录下标
int from = (pageNum - 1) * pageSize; // 起始记录下标,从0开始
sourceBuilder.from(from);
sourceBuilder.size(pageSize); // 每页显示的记录数
与MySQL类似,ES也存在深度分页的性能压力。其默认设置 max_result_window 为10000,即 from + size 不能超过此阈值。在低频的B端查询场景中,可根据需要适当调大此参数。
总结
以上四种分页方案各有优劣,没有绝对的“最佳”,关键在于根据具体的业务场景、数据量级、性能要求和用户体验来选择最合适的那一种。