1. 先从一次联调事故说起:第1000页数据为什么慢得离谱
我印象很深,去年做电商后台订单列表的时候,分页查询这个最不起眼的功能差点把联调搞崩。运营后台有个订单查询页面,默认按下单时间倒序,单表数据量大概五百多万行,过滤条件组合下来符合条件的订单也有两百多万条。测试同学在页面上连续翻页,翻到一千多页的时候,接口响应时间直接飙到1.2秒。前端转菊花转半天,运营那边截图质问"系统是不是挂了"。
其实页面上的分页组件就那么几个参数:当前页码pageNum、每页条数pageSize,后端对应的SQL基本长这样:
sql复制SELECT id, order_no, user_id, amount, status, create_time
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 1000, 20;
这种写法在大多数业务系统里都见过,第一页第二页跑得飞快,深翻页就原形毕露。问题出在哪?LIMIT 1000, 20在MySQL里执行的逻辑并不是"找到第1000条之后直接取20条",而是老老实实把前1020行全部扫描出来,然后丢弃前1000行,只返回最后20行。数据量小的时候大家没感觉,一旦表里几百万行、排序字段还没走到理想的索引,扫描成本会随着偏移量线性增长,表越大翻得越深,性能衰减越明显。
我当时先做了一轮基础排查,发现这条SQL的执行计划实际走了status字段的二级索引,然后对create_time做文件排序。也就是说,MySQL要先在status索引上捞出两千多行,再到聚簇索引回表取完整数据,最后排序、掐头去尾。整个过程大部分开销都耗在了"被丢弃的那1000行"上——它们被读出来了、被排序了,最终却连返回给客户端的机会都没有。
这可能就是分页查询最让人忽视的地方:它看起来是个极简单的功能,但真正深挖起来,背后藏着索引选择、排序策略、回表成本、偏移量累积、缓存一致性等一系列问题。这篇文章我想把这几年做分页查询积累的东西系统整理一遍,从最基础的LIMIT机制讲起,到深度分页的优化方案,再到Redis缓存的几种落地模型,最后用一个完整的排查案例把整个思路串起来。适合刚接触后端开发、对MySQL执行计划还处于"看得懂但不会用"阶段的朋友,也适合被线上深分页慢查询折磨过的同学。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种主流分页写法的机制拆解:LIMIT、游标和覆盖索引
不同的人写分页查询,风格差异非常大。我见过老项目里全是LIMIT加偏移量的写法,也见过架构师把游标分页定成团队红线,还有一些偏激进的方案直接上Redis。这些写法没有绝对的好坏,关键要看业务场景和数据特征。先把三种主流写法的底层机制搞清楚,后面优化才有依据。
2.1 最常见的LIMIT OFFSET分页:为什么越翻越慢
LIMIT OFFSET分页的核心逻辑就是"跳过N行、取M行"。很多开发对它的理解是"MySQL会直接跳到第N行开始读",这其实是最大的误解。默认情况下MySQL Server层拿到SQL后,优化器会生成执行计划,存储引擎负责读取数据,Server层再对结果做过滤和排序。LIMIT的语义是在所有结果集确定了之后才执行的,OFFSET越大,要生成的结果集就越大,被白白丢弃的数据就越多。
具体到执行流程,大概分成这几步:
- 根据WHERE条件定位到满足条件的记录,可能走索引也可能全表扫描。
- 对命中的行按照ORDER BY指定的字段排序。如果排序字段恰好是索引覆盖的,顺序扫描就行;否则就要在内存或磁盘上做filesort。
- 排序完成后,从结果集的第OFFSET行开始,取需要的pageSize行返回。
- 其余的行虽然在内存里被构造出来了,但全部丢弃。
所以LIMIT 10000, 20和LIMIT 10, 20在写法上只是数字不同,实际执行成本可能相差几十倍。原因就在于前者需要生成至少10020行的中间结果。MySQL官方文档对这块也有说明:偏移量越大,查询开销越大。它甚至建议,如果确实需要深分页,考虑用其他方式替代大偏移量的LIMIT。
这个机制在地表最直观的测试里能看出明显差异。我拿一张四百万行的订单表测过,在相同过滤条件下只改LIMIT的偏移量,执行时间大致是这样的:
| 分页写法 | 扫描行数 | 返回行数 | 实测耗时(约) |
|---|---|---|---|
| LIMIT 0, 20 | 20 | 20 | 8ms |
| LIMIT 1000, 20 | 1020 | 20 | 25ms |
| LIMIT 10000, 20 | 10020 | 20 | 90ms |
| LIMIT 100000, 20 | 100020 | 20 | 420ms |
| LIMIT 500000, 20 | 500020 | 20 | 超过1.5s |
从这个表可以看到,偏移量从0涨到50万,耗时涨了将近200倍。扫描的行数翻倍,耗时基本也是翻倍的状态。所以当运营说"翻到第1000页变慢了",那不是错觉,是执行机制决定了这种写法在高偏移量下根本无法保持平稳性能。
2.2 游标分页:基于排序字段定位而不是跳行
游标分页的核心思想是:把"跳过N行"换成"从上一页最后一条数据继续往后找"。它不关心前面有多少行,只记住上一页最后一条记录的唯一标识,然后用WHERE条件直接定位到那条记录之后的数据。
最常见的写法是这样的:
sql复制-- 假设上一页最后一条记录的create_time是'2024-03-10 14:23:45',id是168888
SELECT id, order_no, user_id, amount, status, create_time
FROM orders
WHERE status = 1
AND (create_time < '2024-03-10 14:23:45'
OR (create_time = '2024-03-10 14:23:45' AND id < 168888))
ORDER BY create_time DESC, id DESC
LIMIT 20;
这里用CREATE_TIME加ID做联合定位,
