几乎每个 PHP 开发者,都至少写过一次这样的分页查询:
在 1 万行数据 的表上,这样写通常没有问题。
在 10 万行数据 的表上,性能影响已经可以感知,但多数情况下仍然可以接受。
但如果这张表有 5000 万行数据,这条查询就不再是普通分页,而是一件瞄准数据库的武器。
原因很简单:
MySQL 并不会“瞬移”到第 2,000,000 行,然后返回后面的 20 行。
它会先读取 2,000,020 行,丢弃前 2,000,000 行,再返回最后 20 行。
也就是说,越往后翻页,数据库要扫描和丢弃的数据就越多。
第 1 页可能只需要 50 ms。
第 50,000 页可能需要 45 秒。
而且在这 45 秒内,数据库连接会一直被占用。
在规模化场景下,这已经不只是性能问题,而是 正确性、内存和可用性 问题的叠加。
慢查询会长期占用连接。连接耗尽后,其他正常请求也会被拖慢,甚至开始出现 500 错误。
本文会从最基础的问题开始,解释为什么 offset 分页在大型数据集上会失效,然后介绍真正适合规模化场景的几种方案:
• Cursor 分页 • Keyset 分页 • 管理后台中的混合分页方案 • 流式导出与批处理 • 索引设计 • 一致性与生产环境注意事项
所有方案都会给出 PHP 或 Laravel 示例代码。
一、快速结论
如果只看结论,可以记住下面几条。
Offset 分页不适合大型数据集。
OFFSET N LIMIT K 在数据库层面的复杂度是:
在 5000 万行数据表上,第 100,000 页可能需要读取 5000 万行,只为了返回 20 行。
Keyset / Cursor 分页更适合大数据量场景。
它的复杂度接近:
无论翻到多深,查询耗时基本保持稳定。
只要数据集可能超过几千行,就应该优先考虑它。
Cursor 分页必须依赖稳定排序。
单独使用 created_at 并不可靠,因为多行数据可能拥有相同时间戳。
正确做法是增加一个唯一列作为平局裁决列,例如:
也就是说,cursor 中不只保存 created_at,还要保存 id。
管理后台需要页码时,可以使用混合方案。
不一定要强行保留传统页码。
可以使用:
• Cursor 分页浏览最近数据 • 日期筛选跳转到某个时间段 • 缓存或近似统计展示总量 • 物化统计表处理精确计数
导出 5000 万行数据时,不要一次性加载到内存。
必须使用:
• 生成器 • 非缓冲查询 • chunk• chunkById• 流式处理
否则 PHP 进程很容易因为内存耗尽而崩溃。
很多时候,数据库并不是真正瓶颈,PHP 内存才是。
如果 5000 万行数据中,每行只占用 500 字节 PHP 内存,也需要:
这远远超过大多数 PHP 进程的可用内存。
所以,处理大结果集时只有两个选择:
要么流式处理,要么进程崩溃。
二、你将学到什么
读完本文,你会理解:
• 为什么 offset分页会在规模化场景下失效• 数据库真实执行 OFFSET查询时发生了什么• 如何在 PHP 中实现 Keyset / Cursor 分页 • 如何在 Laravel 中使用 cursorPaginate()• 为什么单独使用 ORDER BY created_at会导致数据重复或丢失• 如何使用 PDO 非缓冲查询处理巨大结果集 • 如何用生成器降低 PHP 内存占用 • 管理后台如何兼顾页码体验和深度分页性能 • 什么时候应该使用搜索引擎或预聚合表 • 生产环境中如何处理 cursor、不一致数据和双向分页
三、为什么 Offset 分页会失效
先看一个典型查询。
假设 MySQL 中有一张包含 5000 万行数据的 orders 表:
即使 created_at 上已经有索引,执行计划中通常也会出现类似下面的结果:
这意味着数据库需要按索引顺序读取约 200 万行,跳过它们,然后返回最后 20 行。
更糟糕的是,如果 SELECT * 中包含未被索引覆盖的列,那么这 200 万行中的每一行,都可能触发一次回表查询。
于是,一个看起来只返回 20 行的分页查询,最终变成了 200 万行级别的扫描。
1. PHP 看不到这些细节
从 PHP 应用视角看,它只是发送了一条查询:
然后等待数据库返回结果。
但数据库可能正在做的是:
1. 扫描 2,000,020 行; 2. 丢弃前 2,000,000 行; 3. 返回最后 20 行; 4. 在整个过程中持续占用数据库连接。
如果这个查询耗时 45 秒,那么这 45 秒里,连接池中的一个连接就会一直被占用。
如果有人刷新页面,就会再占用一个连接。
如果管理员同时导出报表,又会占用更多连接。
最终,其他用户的登录、下单、查询等正常请求,也会因为连接池耗尽而开始排队。
2. Offset 分页还有正确性问题
Offset 分页不仅慢,还可能导致数据重复或跳过。
假设用户先打开第一页,然后准备点击第 5 页。
在这个过程中,如果有新订单插入,并且排序方式是:
新数据会把原本的数据整体向后推。
于是:
• 原本在第 3 页的数据,可能移动到第 4 页; • 某些数据可能被用户重复看到; • 某些数据可能被直接跳过。
因此,Offset 分页在规模化场景下会以三种方式失效:
慢查询、连接占用、并发写入下结果不一致。
而且随着数据量增长,这三个问题都会持续恶化。
四、Keyset 分页才是真正答案
Keyset 分页也叫:
• Cursor 分页 • Seek 分页 • Where Pagination
它的核心思想是:
不再告诉数据库“跳过 N 行,返回接下来的 K 行”,而是告诉数据库“从某个具体位置之后,返回接下来的 K 行”。
这个“具体位置”就是 cursor。
Cursor 通常由上一页最后一行的排序字段组成。
例如第一页查询:
拿到第一页后,把最后一行的 created_at 和 id 作为 cursor。
下一页查询变成:
这里的关键是:
这是行比较。
MySQL 和 PostgreSQL 都支持这种写法。它表示:返回 (created_at, id) 这个组合值小于给定 cursor 的数据,并按照字典序比较。
只要存在合适的复合索引:
数据库就可以直接定位到 cursor 附近,然后读取后面的 20 行。
它不需要扫描并丢弃前面几百万行。
因此,第 1 页和第 1,000,000 页的查询耗时可以保持在同一量级。
五、原生 PHP 最小实现
下面是一个无框架依赖的 Cursor 分页实现。
使用方式如下:
如果要持续处理完整数据集,可以这样写:
六、这段代码的四个重点
1. Cursor 同时包含 created_at 和 id
Cursor 不能只包含 created_at。
原因是多个订单可能拥有相同的创建时间。
如果只用 created_at 作为 cursor,分页过程中就可能出现数据跳过或重复。
所以需要把 id 作为平局裁决列。
正确排序方式是:
对应的 cursor 查询条件是:
2. Cursor 对客户端保持不透明
这里使用 base64 编码 JSON:
这样做的目的不是加密,而是让客户端不要关心 cursor 的内部结构。
客户端只需要把服务端返回的 cursor 原样传回来。
这样以后你可以调整 cursor 格式,例如:
而不破坏已有客户端。
如果是公开 API,还可以使用签名 cursor,防止客户端伪造或篡改。
3. 查询 perPage + 1 行
代码中实际查询的是:
这是判断是否还有下一页的常见技巧。
例如每页 20 条,如果查到了 21 条,就说明还有下一页。
返回前把第 21 条移除即可。
这样可以避免额外执行一次 COUNT(*)。
4. 没有下一页时返回 null
当没有更多数据时:
客户端只需要判断 cursor 是否为空即可。
七、稳定排序问题:最容易被忽略的坑
稳定排序是 Cursor 分页中最容易被忽略的问题。
假设只按照 created_at 排序:
现在有 5 条订单创建于同一秒:
第一页使用:
数据库可能返回:
此时 cursor 是:
第二页查询如果写成:
那么 103 和 104 会被直接跳过。
因为它们的 created_at 等于 cursor,而不是小于 cursor。
如果改成:
又会导致 100、101、102 在第二页重复出现。
正确做法:加入唯一平局裁决列
解决方式始终相同:
然后使用:
这样,即使 created_at 相同,也可以用 id 决定稳定顺序。
兼容写法
PostgreSQL 原生支持行比较语法。
MySQL 5.7+ 也支持类似写法。
如果需要兼容更早版本,或者希望语义更明确,可以展开成:
这与下面的行比较语义一致:
UUID 可以作为平局裁决列吗?
可以。
即使主键是 UUID,也可以作为平局裁决列。
但要注意:
UUID 只能提供稳定顺序,不代表时间顺序。
所以仍然应该先按 created_at 排序,再用 UUID 作为平局裁决列:
如果是新项目,可以考虑 UUIDv7。它具备更好的时间排序能力。
八、Laravel 中的实现
Laravel 从 8.x 开始内置了 Cursor 分页能力。
可以直接使用:
返回 JSON 时,可以这样处理:
后续请求中传入 cursor:
Laravel 的 cursorPaginate() 会自动处理:
• Cursor 编码 • 下一页 cursor • 上一页 cursor • 分页 URL • 基础的排序字段处理
在生产环境中,如果数据集可能增长到几千行以上,建议优先使用:
而不是:
Laravel Cursor 分页注意事项
cursorPaginate() 要求排序字段真实存在于模型或查询结果中。
如果你使用的是:
或者按照 JSON 提取字段、计算字段排序,就可能无法直接使用 cursor 分页。
这种情况下通常需要:
• 增加物化列; • 使用生成列; • 改用搜索引擎; • 或者手动实现 cursor 查询。
九、管理后台如何处理深度分页
很多人会说:
Cursor 分页很好,但管理后台需要页码。
这个需求很常见,但要先区分用户真正想要的是什么。
管理后台通常有三类访问模式。
1. 查看最近几页数据
这是最常见的场景。
大多数用户只会看第 1 到第 5 页。
例如:
• 最近订单 • 最新用户 • 最新支付记录 • 最新日志
这种场景完全适合 Cursor 分页。
界面上可以把传统页码改成:
• 下一页 • 上一页 • 更早 • 更新
2. 跳转到某个时间段
很多用户说想跳到第 47,832 页,本质上并不是真的关心页码。
他们真正想要的是:
查看 2024 年 3 月的订单。
这种需求应该通过筛选器完成:
然后继续使用 Cursor 分页。
这样查询依然可以保持高效。
3. 展示大概总量
有些后台页面确实需要显示:
这并不一定需要执行真实的:
在大型表上,实时精确计数可能非常慢。
可以使用缓存或近似统计。
示例:
EXPLAIN 返回的是估算值,不是精确值。
但它通常非常快,足够用于展示:
如果某些业务确实需要精确计数,例如财务、审计、结算,建议使用:
• 统计表 • 物化视图 • 定时任务预计算 • 事件驱动更新 • 触发器维护计数
不要在用户请求中实时执行大型表的 COUNT(*)。
十、流式处理大型数据集
有些场景中,分页不是答案。
例如:
• 导出全部订单 • 批量迁移用户 • 重建索引 • 批量发送通知 • 数据清洗任务
这些任务确实需要处理全部 5000 万行数据。
但绝不能一次性加载到 PHP 内存。
1. 为什么不能使用 fetchAll()
默认情况下,很多代码会这样写:
如果结果集是 5000 万行,这意味着 PHP 会尝试把所有数据一次性放进内存。
即使每行只占 500 字节:
这还没有计算数组结构、字符串、对象和 zval 的额外开销。
所以实际内存占用可能更高。
结果通常只有一个:
PHP 进程被 OOM killer 杀掉。
2. MySQL 非缓冲查询
解决方案是使用非缓冲查询。
这样 PHP 同一时间只持有一行数据。
无论结果集是 20 行还是 2000 万行,内存都能保持在较低水平。
但它也有代价:
在结果集读取完成前,这个 MySQL 连接会一直处于忙碌状态。
因此,非缓冲查询适合 CLI 脚本、队列任务、批处理任务,不适合普通 Web 请求。
3. 用生成器封装流式处理
可以把流式读取封装成生成器:
使用时:
yield 会让函数变成生成器。
它不会一次性构建完整数组,而是每次迭代时取出一行、处理一行,然后继续读取下一行。
4. PostgreSQL 游标读取
PostgreSQL 的流式处理方式略有不同,可以使用数据库游标:
这里每次读取 1000 行。
这种方式在网络往返次数和内存占用之间取得了平衡。
十一、Laravel 中的流式处理方案
Laravel 已经封装了几种常用方式。
1. cursor():最低内存占用
cursor() 每次只读取一条数据,内存占用最低。
适合内存极度敏感的任务。
2. lazy():生成器式接口
lazy() 对外表现类似生成器,但内部会按块读取。
它通常是一个更平衡的选择。
3. chunk():批量处理
chunk() 每次读取一批数据。
它的网络往返次数更少,适合大多数批处理任务。
4. chunkById():可恢复处理
如果任务中途失败,可以基于最后处理的 ID 继续执行。
对于长时间运行的批处理任务,chunkById() 通常是更稳妥的默认选择。
十二、Chunk-ID 模式:让批处理任务可恢复
非缓冲查询有一个问题:
如果进程中途崩溃,当前读取位置就丢失了。
对于处理几百万甚至几千万行数据的任务来说,这是不能接受的。
更稳妥的方式是按主键分块处理。
每个 chunk 都是一条独立查询。
它的优势是:
• 不会长时间占用同一个连接; • 出错后可以从最后处理的 ID 恢复; • 可以把进度存入数据库、Redis 或文件; • 更适合长时间运行的后台任务。
例如,可以把 checkpoint 存起来:
下次启动任务时读取:
这比逐行非缓冲查询略慢,但健壮性更强。
对于任何运行时间超过几分钟的任务,可恢复性通常比极限吞吐量更重要。
十三、索引是所有优化的前提
无论 Cursor 分页、Keyset 分页还是 Chunk-ID 模式,都依赖正确索引。
如果没有命中索引,这些模式都无法发挥作用。
1. Cursor 分页索引设计
Cursor 分页的索引通常要匹配过滤条件和排序方式。
例如查询是:
索引应该是:
如果查询是:
索引可以是:
复合索引的基本规则是:
等值过滤列在前,排序列在后。
例如:
2. 验证索引是否生效
使用 MySQL 时,可以执行:
重点关注:
• type是否为range或ref• 是否出现 Using index condition• rows估算是否接近LIMIT• 是否出现 Using filesort• 是否出现 type: ALL
如果看到:
或者:
通常说明索引没有正确匹配。
这时应该先修复索引,而不是继续改 PHP 代码。
3. PostgreSQL 中的检查方式
PostgreSQL 使用:
重点关注是否出现:
• Index Scan• Index Only Scan
如果看到:
说明可能发生了全表扫描。
4. 覆盖索引可以进一步优化
如果表非常宽,而分页查询只需要少数字段,可以考虑覆盖索引。
PostgreSQL 示例:
覆盖索引的好处是:
查询可以只访问索引,不必回表读取主表数据。
宽表中,这个优化通常非常明显。
MySQL 中可以把需要覆盖的列直接放进复合索引中,但要注意索引体积和写入成本。
十四、分页解决不了的问题
Cursor 分页并不是银弹。
有些查询无论如何优化分页,都不适合直接压在主数据库上。
典型场景包括:
• 5000 万行上的全文搜索; • 多字段模糊查询; • 十几个维度的组合筛选; • 复杂分面过滤; • 按相关性评分排序; • 大规模 GROUP BY聚合;• 每个分类取 Top N 商品。
这类问题的本质不是“分页写得不够好”,而是:
不应该继续用事务数据库做这类查询。
1. 全文搜索应该使用搜索引擎
适合的工具包括:
• Elasticsearch • Meilisearch • OpenSearch • Typesense
常见架构是:
1. 主数据库负责事务写入; 2. 通过 outbox 或 CDC 同步数据; 3. 搜索引擎负责搜索、排序、过滤; 4. PHP 应用通过 HTTP 查询搜索服务; 5. 必要时再回主库补充最新数据。
2. 聚合查询应该预计算
例如:
如果每次请求都扫描数百万行,就会非常慢。
更好的方式是维护物化汇总表:
由定时任务或事件驱动更新。
查询时只需要读取已经聚合好的结果,响应时间可以从秒级降低到毫秒级。
3. 判断是否该迁移出去
如果分页查询中出现下面几类特征,就应该重新考虑架构:
• 包含 GROUP BY• 包含全文搜索 • 包含 LIKE '%keyword%'• 包含大量动态筛选条件 • 排序字段不是简单数据库列 • 需要相关性评分 • 需要复杂分面统计
这些场景通常应该使用搜索引擎、物化视图或预聚合表。
十五、并发写入下的一致性问题
长期分页时,还需要考虑一个问题:
用户翻页过程中,如果有新数据插入或旧数据删除,会发生什么?
1. Cursor 分页如何处理插入
Cursor 分页对插入的处理相对自然。
如果新数据排序在当前 cursor 之前,用户当前分页过程中可能看不到它,但刷新第一页后可以看到。
如果新数据排序在 cursor 之后,它可能会出现在后续页面中。
关键是:
Cursor 分页不会因为 offset 移动而导致大面积重复或跳过。
2. 删除数据时会怎样
如果 cursor 对应的那一行被删除,分页仍然可以继续。
因为 cursor 本质上只是一个位置:
数据库会从这个位置之后继续查找下一批数据。
用户不会看到被删除的行,这通常也是符合预期的。
3. 绝对一致性场景需要快照
某些场景中,一致性比实时性更重要,例如:
• 财务报表 • 审计日志 • 数据导出 • 对账任务
这时应该使用事务快照。
示例:
MySQL InnoDB 在 REPEATABLE READ 隔离级别下,会在事务开始时创建一致性快照。
PostgreSQL 的 REPEATABLE READ 和 SERIALIZABLE 也可以提供类似能力。
4. 长事务也有代价
一致性快照不是免费午餐。
长事务可能导致:
• PostgreSQL autovacuum 被阻塞; • MySQL undo log 膨胀; • 连接长时间占用; • 复制延迟增加。
因此,一致性快照适合分钟级任务,不适合小时级任务。
如果任务非常大,通常应该使用:
• 分块处理; • checkpoint; • 可恢复任务; • 数据版本号; • 离线快照表。
十六、常见问题
1. Cursor 分页可以跳到第 X 页吗?
不能直接做到。
Cursor 分页只知道:
• 下一页 • 上一页 • 从某个位置继续
它不知道“第 X 页”对应哪个位置。
如果一定要近似跳转,可以通过日期或 ID 构造 cursor。
例如:
可以转化为:
实践中,用户说“跳到第 47,832 页”,通常真正想表达的是:
按某个条件筛选数据。
这时应该提供筛选器,而不是坚持页码。
2. UUID 是随机的,还能做 Keyset 分页吗?
可以。
只要 UUID 被纳入排序和 cursor,就可以提供稳定分页。
例如:
其中 id 可以是 UUID。
但 UUIDv4 不具备时间顺序,所以不要单独用它表示“最新数据”。
如果希望主键本身具备时间排序能力,可以考虑 UUIDv7。
3. 总数统计可以使用 COUNT(*) 吗?
小表可以。
大表中,尤其是 Web 请求内,不建议直接执行:
对于 5000 万行表,这可能是多秒级操作,并且会占用数据库连接。
更推荐:
• 缓存计数; • 定时任务更新统计表; • 使用近似统计; • 使用 EXPLAIN估算;• 使用 MySQL information_schema.TABLES.TABLE_ROWS;• 使用 PostgreSQL pg_class.reltuples。
如果业务必须精确计数,应把计数维护为数据模型的一部分,而不是每次请求实时扫描。
4. 复杂 Join 或视图可以做 Cursor 分页吗?
可以,但要谨慎。
如果排序字段来自单张主表,并且索引正确,通常可以工作。
更稳妥的方式是:
1. 先在根表上分页; 2. 拿到当前页 ID; 3. 再查询关联数据; 4. 组合返回结果。
如果是物化视图,可以把它当作普通表处理,并为排序字段建立索引。
5. 软删除数据如何处理?
如果基础条件中包含:
那么索引也要考虑这个条件。
否则数据库虽然可以定位 cursor,但仍然可能扫描大量已删除数据。
PostgreSQL 可以使用部分索引:
MySQL 中可以考虑组合索引:
具体顺序要结合实际过滤条件和数据分布判断。
6. 可以对 PHP 计算出来的字段做 Cursor 分页吗?
不能直接做。
Keyset 分页需要数据库层面的确定性排序。
如果排序字段只存在于 PHP 计算结果中,数据库就无法基于索引定位 cursor。
解决方式通常是:
• 把计算结果物化到数据库列; • 使用生成列; • 使用搜索引擎; • 预计算排序分数; • 放弃深度分页。
7. 如何同时支持上一页和下一页?
需要 cursor 同时支持两个方向。
常见做法是每页返回:
下一页使用最后一行作为 cursor。
上一页使用第一页第一行作为 cursor,并反转比较符号。
例如当前是降序:
下一页:
上一页:
同时查询时可能需要反转排序,取出结果后再恢复展示顺序。
Laravel 的 cursorPaginate() 已经处理了大部分细节。
十七、快速参考卡片
1. 应避免的大型数据集分页方式
问题:
2. 推荐的 Cursor 分页方式
优势:
3. 推荐索引
如果有租户过滤:
4. Laravel Cursor 分页
5. Laravel 大数据处理
十八、核心规则总结
处理大型数据集分页时,记住这些规则:
1. 不要在大型表上使用深度 Offset 分页。 这种写法会随着页码变深越来越慢。
2. 优先使用 Cursor / Keyset 分页。 3. 排序必须稳定。 不要只用:
应该使用:
4. Cursor 中必须包含唯一平局裁决列。 通常是:
5. 索引要匹配过滤和排序。 例如:
6. 使用 perPage + 1判断是否还有下一页。避免额外执行
COUNT(*)。7. Cursor 应该对客户端不透明。 使用 base64 JSON、签名 token 或版本化 cursor。
8. 导出和批处理任务应使用流式处理。 不要一次性
fetchAll()。9. 长任务优先考虑 chunkById()。因为它更容易恢复进度。
10. 搜索和复杂聚合不要硬压在主数据库上。 该用搜索引擎或预聚合表时,就不要继续优化分页。
十九、什么时候说明 Cursor 分页已经不够用了?
如果你的查询出现下面这些特征,说明问题可能已经超出 Cursor 分页的能力范围:
• 需要全文搜索; • 需要相关性排序; • 有复杂 GROUP BY;• 有大量动态筛选条件; • 使用 LIKE '%keyword%';• 需要复杂分面统计; • 排序字段来自 PHP 计算; • 用户真的需要任意跳到第 N 页; • 查询需要跨多张大表复杂 Join。
对应处理方式通常是:
| 场景 | 推荐方案 |
| | - |
| 全文搜索 | Elasticsearch / Meilisearch / OpenSearch |
| 复杂聚合 | 物化汇总表 |
| 多维筛选 | 搜索引擎 |
| 大规模导出 | 流式处理 / chunkById |
| 精确统计 | 统计表 / 预计算 |
| 任意页码跳转 | 重新设计 UX,改为筛选器 |
二十、结语
分页看起来是一个很小的问题。
一行代码就能完成:
但在数据量变大后,它隐藏的是一个 O(N+M) 级别的性能灾难。
幸运的是,修复方式并不复杂。
大多数场景只需要把 Offset 分页替换为 Cursor 分页:
再配合正确的复合索引,就能让原本几十秒的深度分页请求,重新回到毫秒级响应。
更重要的是,要把“如何遍历一个数据集”视为一个设计决策,而不是默认选择。
用户是在浏览最近数据,还是在跳转到某个时间段?
任务是在导出全部数据,还是只展示当前页面?
业务需要绝对一致性,还是可以接受近似统计?
这些问题的答案不同,选择的方案也应该不同。
工具其实早就在技术栈里:
• Cursor • Keyset • Generator • Chunk • ChunkById • 非缓冲查询 • 复合索引 • 部分索引 • 物化汇总表
关键是在第 5000 万行数据到来之前,就开始正确使用它们。
把分页模式设计好,并在项目中复用。
原本需要 45 秒的请求,可以变成 30 毫秒。
连接池不会再被慢查询长期占满。
凌晨 3 点的数据库告警也会少很多。
这就是把分页做对之后的复利价值。

评论 (0)