翻到第 5000 页越来越慢?聊聊数据库深分页的 4 种优化方案

1 阅读7分钟

分页几乎是每个后台系统都会遇到的需求。

第一页很快,第十页也还可以,但数据量起来之后,用户翻到几千页时,接口突然从几十毫秒变成几秒。更麻烦的是,慢的不一定只是数据库,还可能是整个请求链路。

最常见的 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;

它的思路是:

  1. 先用覆盖索引扫描出 20 个主键;
  2. 再用主键回表查询完整字段;
  3. 尽量避免为了排序和分页去读取大量不必要的列。

如果存在一个合适的联合索引:

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 组合字段保持稳定且顺序一致
  • 检查索引是否覆盖过滤、排序和分页字段
  • 深页场景评估延迟关联或游标分页
  • 限制最大页数,避免无边界查询
  • 对高并发接口增加超时、限流和缓存策略

深分页优化没有一种万能写法。

真正有效的方案,通常来自三个问题的共同答案:

  1. 用户是否必须跳转到任意页?
  2. 数据库是否必须返回精确总数?
  3. 当前排序和过滤条件能否被稳定索引支持?

先把业务问题问清楚,再决定是优化 SQL,还是改分页协议。

如果你也在处理数据库性能问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。