分页几乎是每个后台系统都会遇到的需求。
第一页很快,第十页也还可以,但数据量起来之后,用户翻到几千页时,接口突然从几十毫秒变成几秒。更麻烦的是,慢的不一定只是数据库,还可能是整个请求链路。
最常见的 SQL 往往长这样:
SELECT id, title, author_id, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 100000, 20;
这条语句看起来只返回 20 行,为什么还会慢?
因为 LIMIT 100000, 20 并没有让数据库“直接跳到第 100000 行”。在很多执行计划里,它仍然需要按照排序顺序扫描或定位前面的 100000 行,然后丢掉,再返回后面的 20 行。
真正的问题不是 LIMIT,而是 OFFSET 越大,数据库需要付出的无效工作越多。
下面整理 4 种常见的深分页优化方案,以及它们各自适合的场景。
方案一:先降低“精确总数”的成本
很多分页接口会同时返回:
{
"items": [],
"page": 5000,
"pageSize": 20,
"total": 12345678
}
其中 total 经常通过下面这种方式计算:
SELECT COUNT(*)
FROM articles
WHERE status = 'published';
如果过滤条件复杂,COUNT(*) 本身就可能比列表查询还慢。
但用户真的需要知道精确的“第 5000 页,总共 12345678 条”吗?
很多场景其实不需要。可以把总数策略拆开:
- 前几页返回精确总数;
- 数据量大时返回“约 1200 万条”;
- 只返回“是否有下一页”;
- 后台管理系统保留精确总数,用户端使用游标分页。
例如,列表接口只需要返回:
{
"items": [],
"nextCursor": "2026-10-04T08:00:00Z:938271",
"hasMore": true
}
优化深分页的第一步,往往不是改 SQL,而是重新确认接口是否真的需要 OFFSET 和精确总数。
方案二:用延迟关联减少回表
如果业务必须使用页码分页,可以考虑延迟关联。
原始查询:
SELECT id, title, author_id, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 100000, 20;
优化后的 SQL:
SELECT a.id, a.title, a.author_id, a.created_at
FROM articles AS a
JOIN (
SELECT id
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 100000, 20
) AS page_ids ON page_ids.id = a.id
ORDER BY a.created_at DESC;
它的思路是:
- 先用覆盖索引扫描出 20 个主键;
- 再用主键回表查询完整字段;
- 尽量避免为了排序和分页去读取大量不必要的列。
如果存在一个合适的联合索引:
CREATE INDEX idx_articles_status_created_at
ON articles (status, created_at DESC, id DESC);
子查询可以只扫描索引,不需要每一行都回表,整体成本会下降。
但要注意:延迟关联只是减少回表,并不能消除 OFFSET 本身的扫描成本。页数极深时,效果仍然有限。
方案三:使用覆盖索引
如果列表只需要很少的字段,可以考虑让索引覆盖这些字段:
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 100000, 20;
创建组合索引:
CREATE INDEX idx_articles_status_created_title
ON articles (status, created_at DESC, id DESC, title);
不同数据库对“覆盖索引”的优化方式不同,但核心目标一致:
让查询尽量在索引中完成,避免随机回表。
不过,覆盖索引有几个现实约束:
- 字段越多,索引越大;
- 更新频率高的列不适合放进大索引;
- 字符串字段作为索引列会显著增加存储;
- 排序方向必须和查询模式匹配;
- 数据分布不均时,优化器可能不会选择预期索引。
因此,看到慢查询时不能机械地“给 ORDER BY 加索引”,而应该确认:
EXPLAIN
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 100000, 20;
重点观察:
- 是否真的走索引;
- 扫描了多少行;
- 是否有临时表或文件排序;
- 执行计划中的估算行数和实际差距大不大。
方案四:用游标分页替代页码分页
如果业务允许“下一页/上一页”,而不是必须直接跳到第 5000 页,那么游标分页通常是最稳定的方案。
假设排序规则是:
ORDER BY created_at DESC, id DESC
第一页:
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;
下一页请求携带上一页最后一条记录的游标:
cursor = createdAt|id
查询变成:
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
AND (created_at, id) < ('2026-10-04 08:00:00', 938271)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这样数据库可以从复合索引的指定位置继续向后扫描,不再需要跳过前面所有记录。
对应的索引可以设计为:
CREATE INDEX idx_articles_cursor
ON articles (status, created_at DESC, id DESC);
游标分页的优点很明显:
- 翻页越深,性能不会线性恶化;
- 结果不会因为前面插入新数据而整体后移;
- 更适合信息流、消息流、日志列表等持续追加的数据。
但它也有代价:
- 不能直接跳到任意页;
- 游标需要编码并保证稳定可比较;
- 排序字段变化时要同步调整索引;
- 业务接口需要从 page/pageSize 迁移到 cursor/limit。
如果产品允许,游标分页往往比继续优化 OFFSET 更划算。
四种方案怎么选
可以用下面的顺序做判断:
用户端信息流
优先游标分页,只返回 nextCursor 和 hasMore,不要强行提供总页数。
后台管理系统
如果必须支持跳页,可以保留页码分页,但限制最大页数,并使用延迟关联或覆盖索引控制成本。
报表和导出
不要用分页接口逐页拉取百万数据。应该采用异步导出、服务端游标扫描或流式输出。
搜索聚合
如果总数查询本身很贵,考虑独立统计、近似计数或异步构建统计结果,而不是每次请求实时 COUNT(*)。
深分页优化最容易踩的坑
1. 只加 LIMIT,不关注 ORDER BY
没有稳定排序的深分页不仅慢,还可能重复或漏数据。
ORDER BY created_at DESC, id DESC
排序字段组合必须稳定,并且和索引顺序一致。
2. 忽略数据分布
如果 status = 'published' 已经覆盖了 99% 的数据,这个条件几乎没有过滤能力,优化器可能选择全表扫描。
索引设计必须结合选择性和实际数据分布,不能只看字段名。
3. 把 OFFSET 当成免费跳过
OFFSET 不是跳表,也不是缓存命中。
页码越大,数据库需要跳过的数据通常越多。不要用过深的 OFFSET 支撑核心业务接口。
4. 只优化数据库,不优化接口协议
如果前端每次都请求精确总数,后端就很难真正优化。
接口协议决定了数据库能不能采用游标、近似计数或懒加载。性能问题往往需要从前端交互一起改。
最后检查清单
- 确认接口是否真的需要 OFFSET 和精确总数
- 使用
EXPLAIN查看实际扫描行数 - ORDER BY 组合字段保持稳定且顺序一致
- 检查索引是否覆盖过滤、排序和分页字段
- 深页场景评估延迟关联或游标分页
- 限制最大页数,避免无边界查询
- 对高并发接口增加超时、限流和缓存策略
深分页优化没有一种万能写法。
真正有效的方案,通常来自三个问题的共同答案:
- 用户是否必须跳转到任意页?
- 数据库是否必须返回精确总数?
- 当前排序和过滤条件能否被稳定索引支持?
先把业务问题问清楚,再决定是优化 SQL,还是改分页协议。
如果你也在处理数据库性能问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。