深度分页拖垮系统?三种存储分页优化全景拆解

深度分页性能瓶颈
深度分页的核心矛盾:为了返回第 N 页,必须先扫描并丢弃前 N 页的数据

分页是每个后端系统都绕不开的功能。列表页、搜索结果、订单流水、日志查询,几乎所有需要展示列表的地方都有分页。翻前几页时一切正常,但当用户跳到第 1000 页、第 10000 页时,响应时间从几十毫秒飙升到几秒甚至几十秒。这不是偶发故障,而是深度分页(deep pagination)的固有代价。

深度分页的核心矛盾在于:数据库为了返回第 N 页的数据,必须先扫描并丢弃前 N 页的数据。offset 越大,丢弃的代价越大。而这个代价在不同存储引擎里表现完全不同--MySQL 的 LIMIT 1000000, 20 会全表扫描百万行,Elasticsearch 的 from + size 默认限制 10000,Redis 的 ZRANGE 却能 O(logN+M) 秒级返回。本文从分页的基本模式出发,逐一拆解 MySQL/PostgreSQL、Redis、Elasticsearch 三类存储的深度分页问题与优化方案,覆盖搜索场景分页、缓存层策略与选型决策。

一、深度分页为什么是性能杀手

理解深度分页的性能问题,先要搞清楚 OFFSET ... LIMIT 在数据库内部到底做了什么。它不是"跳到第 N 条直接取",而是"从头扫描 N 条,丢弃,再取 M 条"。这个"扫描并丢弃"的动作,是所有性能问题的根源。

OFFSET 扫描代价
LIMIT 1000000, 20 不是只读 20 行,而是扫描 1000020 行后丢弃前 100 万行。offset 越大,无效 I/O 越多,响应时间线性恶化。
COUNT 的陷阱
分页通常要返回总数,SELECT COUNT(*) 在大表上是全表扫描或全索引扫描。一次分页查询触发两次重查询:数据 + 总数。
内存与排序压力
带 ORDER BY 的分页,数据库要把 offset+size 行加载到内存排序,再丢弃前 offset 行。百万级排序可能触发临时表落盘,性能断崖式下跌。
ES 的 10000 限制
Elasticsearch 的 from + size 默认上限 10000,超过直接报错。这不是 bug,而是保护机制--深度分页对 ES 的协调节点和分片是灾难。
数据漂移问题
分页查询期间数据发生变化(新增/删除),后续页的数据会偏移,导致重复或遗漏。offset 分页无法保证跨页一致性。
缓存命中率低
用户行为集中在前几页,深度页几乎没人翻。缓存深度页的投入产出比极低,而前几页缓存又面临数据更新失效问题。

这六个问题的共同特征是:它们都源于"offset 跳过"这个语义本身。只要分页模式是基于 offset 的,深度分页的代价就无法消除--只能缓解或规避。这就是为什么游标分页(cursor-based pagination)在越来越多场景中取代 offset 分页。

二、分页的两种基本模式

所有分页方案最终都归为两种模式:offset 跳过和 cursor 游标。理解这两种模式的本质差异,是选型的前提。

维度OFFSET / LIMITCursor 游标分页
原理跳过前 N 条,取 M 条从上次位置继续取 M 条
深度分页性能随 offset 线性恶化恒定性能
随机跳页支持不支持(仅向前/向后)
总数显示需要 COUNT估算或隐藏
数据一致性分页期间可能漂移基于游标稳定
实现复杂度简单中等
选型判断:大多数业务场景的"翻到第 N 页"需求其实是伪需求--用户真正想找的信息几乎都在前几页。如果产品上能接受"向下滚动加载更多"替代"跳到第100页",游标分页能从根本上消除深度分页问题。只有后台管理、数据导出等确实需要随机跳页的场景,才需要 offset 分页的优化方案。

三、MySQL 深度分页优化

MySQL 的 LIMIT offset, size 是深度分页问题的典型代表。当 offset 达到百万级,查询会从毫秒级退化到秒级甚至更慢。根因是 MySQL 的执行方式:先通过索引或全表扫描找到 offset+size 条记录,丢弃前 offset 条,再返回剩余 size 条。扫描的行数不会因为只取 20 条而减少。

3.1 三种优化方案

-- 原始慢查询:扫描 1000020 行,丢弃 100 万行 SELECT * FROM orders ORDER BY created_at DESC LIMIT 1000000, 20; -- 优化1:延迟关联(先走覆盖索引查主键,再回表) SELECT * FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY created_at DESC LIMIT 1000000, 20 ) t ON o.id = t.id; -- 优化2:覆盖索引(只查索引列,不回表) SELECT id, created_at FROM orders ORDER BY created_at DESC LIMIT 1000000, 20; -- 优化3:游标分页(推荐,恒定性能) SELECT * FROM orders WHERE created_at < '2026-08-01 12:00:00' ORDER BY created_at DESC LIMIT 20;
延迟关联
子查询只走覆盖索引(id + created_at),不回表,扫描速度快 5-10 倍。拿到 20 个主键后再回表取完整数据,回表只有 20 次随机 I/O。代价是 offset 仍需扫描索引,只是索引扫描比全表扫描快得多。
覆盖索引
如果查询只需要索引包含的列,MySQL 直接从索引返回数据,不回表。但实际业务通常需要更多列,覆盖索引的适用范围有限。适合"只查 ID"的轻量场景。
游标分页
记住上一页最后一条记录的排序值,下一页用 WHERE 条件直接定位。无论翻到第几页,都只扫描 20 行。代价是不支持随机跳页,且排序字段必须唯一(否则边界丢失数据)。
游标分页的排序陷阱:如果排序字段有重复值(如 created_at 相同的多条记录),用 WHERE created_at < last_value 会丢失边界记录。解法是用复合游标:WHERE (created_at, id) < (last_created_at, last_id)。MySQL 8.0+ 支持行值比较语法,PostgreSQL 原生支持。

四、PostgreSQL 分页与 keyset pagination

PostgreSQL 的 LIMIT/OFFSET 与 MySQL 存在同样的深度分页问题,但 PostgreSQL 在游标分页上有天然优势:原生支持行值比较(row value comparison),让 keyset pagination 的实现比 MySQL 更优雅。

4.1 keyset pagination 实现

-- PostgreSQL keyset pagination(复合游标) SELECT * FROM orders WHERE (created_at, id) < ('2026-08-01 12:00:00', 12345) ORDER BY created_at DESC, id DESC LIMIT 20; -- 对应的复合索引(必须与排序方向一致) CREATE INDEX idx_orders_created_id ON orders (created_at DESC, id DESC);

行值比较 (created_at, id) < (last_created_at, last_id) 的语义是:先比 created_at,created_at 相同则比 id。这天然解决了排序字段重复的边界问题,且 PostgreSQL 优化器能高效利用复合索引直接定位,不需要扫描 offset 行。

4.2 offset vs keyset 性能对比

场景OFFSET 分页Keyset 分页
第 1 页(20条)0.5ms0.5ms
第 1000 页120ms0.6ms
第 10000 页1200ms0.6ms
第 100000 页12000ms0.6ms
索引利用扫描 offset+size 行索引直接定位
PostgreSQL 优势:行值比较让 keyset pagination 的 SQL 更简洁,且优化器对复合索引的利用更充分。如果技术栈是 PostgreSQL,深度分页的首选就是 keyset pagination,几乎没有理由用 OFFSET。

五、Redis 分页

Redis 的分页与其他存储有本质不同:数据常驻内存,没有磁盘 I/O 的 offset 代价。但 Redis 的分页仍然有坑--数据结构选型、KEYS 的阻塞风险、与持久化存储的同步策略都需要仔细设计。

5.1 ZSET 与 LIST 的分页差异

# ZSET 分页(有序集合,按 score 排序,推荐) ZREVRANGE hot_articles 0 19 # 第1页,O(logN+M) ZREVRANGE hot_articles 100000 100019 # 第5000页,依然 O(logN+M) # LIST 分页(按插入顺序,适合追加型日志) LRANGE article_list 0 19 # 第1页,O(S+N) LRANGE article_list 100000 100019 # 第5000页,O(S+N) S=offset # SCAN 替代 KEYS(游标迭代,不阻塞主线程) SCAN 0 MATCH article:* COUNT 100 # 返回游标+匹配结果 SCAN <cursor> MATCH article:* COUNT 100 # 继续迭代
ZSET 深度分页无压力
ZSET 基于跳表实现,ZRANGE 的时间复杂度是 O(logN+M),M 是返回条数。无论 offset 多大,性能都恒定。这是 Redis 相对关系型数据库的最大分页优势。
LIST 的 offset 代价
LRANGE 的时间复杂度是 O(S+N),S 是 offset。虽然数据在内存,没有磁盘 I/O,但大 offset 仍有遍历代价。百万级 LIST 的深度分页不如 ZSET 高效。
KEYS 是禁忌
KEYS pattern 会遍历所有键,阻塞 Redis 主线程,生产环境绝对禁用。分页遍历键空间必须用 SCAN,它基于游标迭代,每次只处理少量键,不阻塞。
Redis 分页的正确姿势:用 ZSET 维护有序列表(如热门文章、最新动态),ZADD 写入时自动排序,ZREVRANGE 分页 O(logN+M)。如果需要按多个维度排序,维护多个 ZSET。Redis 作为分页缓存层时,ZSET 是最自然的选择。

六、Elasticsearch 分页

Elasticsearch 的深度分页是分布式系统里最棘手的问题。ES 的数据分布在多个分片(shard)上,一次分页查询需要每个分片都返回 from+size 条数据,协调节点再从 N*(from+size) 条中全局排序取 from+size 条。from 越大,每个分片返回的无效数据越多,网络和内存开销越大。

6.1 三种分页方案

// 方案1:from/size(浅分页,默认上限 10000) GET /articles/_search { "from": 0, "size": 20, "query": { "match": { "title": "AI" } } } // 方案2:search_after(游标分页,推荐) GET /articles/_search { "size": 20, "query": { "match": { "title": "AI" } }, "sort": [{ "created_at": "desc" }, { "_id": "asc" }], "search_after": ["2026-08-01T12:00:00", "abc123"] } // 方案3:PIT + search_after(数据一致性保障) POST /articles/_pit?keep_alive=5m // 创建时间点快照 GET /articles/_search { "size": 20, "pit": { "id": "pit_id_from_above", "keep_alive": "5m" }, "sort": [{ "created_at": "desc" }, { "_id": "asc" }], "search_after": ["2026-08-01T12:00:00", "abc123"] }

6.2 三种方案对比

方案适用场景性能数据一致性限制
from/size浅分页(前500页)随 from 增大恶化实时from+size ≤ 10000
scroll数据导出/全量遍历快照后恒定快照冻结占内存,不适合实时查询
search_after实时深度分页恒定实时(可能漂移)只能向前,排序字段需唯一
PIT + search_after实时深度分页 + 一致性恒定PIT 期内一致需管理 PIT 生命周期
ES 分页选型:前 10000 条用 from/size 足矣。超过 10000 条的实时分页用 search_after,配合 PIT 保障数据一致性。scroll 仅用于批量导出场景,不要用于用户交互式分页--它占用大量堆内存且快照会过期。search_after 的排序字段必须包含 _id 作为兜底,否则文档排序值相同时会丢数据。

七、搜索场景的特殊分页

搜索场景的分页比普通列表分页更复杂。搜索结果按相关性评分排序,总数动态变化,还经常伴随聚合(facets)和过滤。这些特性让分页的优化策略与普通列表分页有显著差异。

总数估算而非精确 COUNT
搜索结果总数动辄百万,精确 COUNT 代价极高。ES 提供 track_total_hits=false,只返回"是否还有更多"。前端显示"约 12,000 条结果"比显示精确数字更务实。
相关性评分的冲突
搜索结果按 _score 排序,但大量文档的 _score 相同。用 _score 做 search_after 游标会丢数据。解法:sort 里加 _id 兜底,确保排序值唯一。
聚合与分页的交互
搜索通常带聚合(按分类统计数量),聚合是在全量结果上计算的,不受分页影响。用户翻到第 100 页时,聚合结果不变,但分页数据变了。两者不要混在一个查询里。
深度搜索分页的伪需求
用户搜索后翻 10 页以上仍没找到,说明搜索词或排序策略有问题,而非继续翻页。搜索场景应限制最大翻页深度(如 20 页),引导用户优化搜索词。
搜索分页推荐方案:ES 中用 from/size 处理前 20 页(足够覆盖 99% 的用户行为),超过 20 页引导用户优化搜索词而非继续翻。必须支持深度遍历(如后台审计)时用 PIT + search_after。排序字段必须包含 _id 兜底:"sort": [{"_score": "desc"}, {"_id": "asc"}]

八、缓存层分页策略

分页优化策略对比
多级缓存分页:CDN 缓存静态页、Redis 缓存查询结果、数据库只承担穿透请求

分页是高频读场景,缓存是性能优化的第一道防线。但分页缓存的难点在于:缓存粒度、失效策略和数据一致性。缓存设计不当,要么命中率低形同虚设,要么数据过期用户看到脏数据。

8.1 多级缓存架构

CDN / 浏览器层
静态分页结果直接缓存在 CDN。不常变的列表(如文章列表、商品分类)的第一页,设置短 TTL(1-5 分钟)缓存在 CDN。用户刷新时命中 CDN,完全不回源。
Redis 缓存层
按查询条件 + 页码缓存结果。缓存 key 设计为 page:{query_hash}:{page}:{size},TTL 5-10 分钟。数据变更时按 query_hash 批量清除相关缓存。只缓存前 N 页(如前 50 页),深度页不缓存。
数据库层
只承担缓存未命中的请求。配合游标分页或延迟关联优化。对于无法缓存的实时查询(如个性化搜索结果),数据库层是最后的性能保障。

8.2 缓存 Key 设计与失效

# Redis 缓存 key 设计 cache_key = f"page:{hash(query)}:{page}:{size}" # 查询流程:缓存优先 data = redis.get(cache_key) if data: return data # 缓存命中,直接返回 # 缓存未命中,查数据库 data = db.query(sql, offset, size) redis.setex(cache_key, 300, data) # 写缓存,TTL 5分钟 # 数据变更时的失效策略 def on_data_changed(table): # 按表名清除所有相关分页缓存 keys = redis.scan(f"page:{table}:*") redis.delete(*keys) # 或用 Redis Keyspace Notification
缓存策略要点:只缓存前 N 页(用户 99% 的行为集中于此),深度页不缓存(命中率低浪费内存)。数据变更时用 SCAN + 批量删除清除相关缓存,不要用 KEYS。如果数据实时性要求高,用"短 TTL + 写时刷新"组合,而非主动失效。

九、分页性能优化通用原则

无论用哪种存储,分页优化都遵循一些通用原则。这些原则是跨存储的"第一性原理",在具体技术方案之上。

原则 1
游标优先
能用游标分页就不用 offset。游标分页的性能恒定,从根本上消除深度分页问题。产品上用"加载更多"替代"跳到第N页"。
原则 2
估算总数
不返回精确总数,用估算值("约 12,000 条")。ES 关闭 track_total_hits,MySQL 用 EXPLAIN 估算行数。精确 COUNT 是深度分页的隐形杀手。
原则 3
限制深度
限制最大翻页深度(如 100 页)。超过深度引导用户细化搜索条件。ES 的 10000 限制就是这种思路的工程化体现。
原则 4
缓存前N页
用户行为集中在前面几页,缓存投入产出比最高。深度页不缓存,把缓存空间留给高频访问的前几页。
原则 5
流式加载
前端用无限滚动或虚拟列表替代传统分页器。IntersectionObserver 触底加载,天然适配游标分页,体验也更流畅。

十、选型决策对比

不同存储的分页能力差异巨大,选对存储和分页方案,性能上限就确定了。下面这张表汇总了四类存储在不同场景下的推荐方案。

存储类型浅分页(前100页)深度分页搜索分页首选方案
MySQLLIMIT offset延迟关联 / 游标-游标分页(WHERE id > last_id)
PostgreSQLLIMIT offsetkeyset pagination-keyset(行值比较)
RedisZRANGEZRANGE(无压力)-ZSET + ZREVRANGE
Elasticsearchfrom/sizesearch_after + PITfrom/size + search_afterPIT + search_after
一句话选型:MySQL/PostgreSQL 用游标分页,配合延迟关联兜底 offset 场景;Redis 用 ZSET 天然规避深度分页;ES 浅分页用 from/size,深度分页用 PIT + search_after。所有存储都应遵循:估算总数、限制深度、缓存前 N 页。

结语

分页看似是 LIMIT 20 OFFSET 0 这样简单的语法,但深度分页是一个涉及存储引擎、索引设计、缓存策略和产品交互的系统工程。同一个"翻到第 1000 页"的需求,在 MySQL 里是百万行扫描的灾难,在 Redis ZSET 里是 O(logN+M) 的轻松操作,在 ES 里则是 from+size 限制下的禁区。

核心原则只有五条:游标优先于 offset、估算总数替代精确 COUNT、限制最大翻页深度、缓存前 N 页、前端流式加载。无论技术栈怎么变,这五条原则都适用。具体方案则因存储而异:MySQL 的延迟关联、PostgreSQL 的 keyset pagination、Redis 的 ZSET、ES 的 search_after + PIT,都是这些原则在不同存储上的落地形态。

最终,深度分页优化的本质不是让"翻到第 10000 页"变快,而是让用户不需要翻到第 10000 页。好的搜索、好的排序、好的产品交互,比任何技术优化都更有效地解决深度分页问题。技术方案是兜底,产品设计才是上游。

参考资料

  1. MySQL 8.0 - LIMIT Optimization https://dev.mysql.com/doc/refman/8.0/en/limit-optimization.html
  2. PostgreSQL Wiki - Keyset Pagination https://wiki.postgresql.org/wiki/KeysetPagination
  3. Elasticsearch - Paginate search results https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html
  4. Redis - ZRANGE https://redis.io/commands/zrange/
  5. Use the Index, Luke - Pagination https://use-the-index-luke.com/no-offset