现代应用程序经常需要处理包含数百万条记录的数据集。无论您是构建拥有庞大产品目录的电子商务平台、社交媒体信息流,还是分析仪表板,最终都会面临如何在不使服务器或用户不堪重负的情况下展示大量数据的问题。分页是标准解决方案,但随着数据量的增长,并非所有分页方法都能保持相同的性能。
本文探讨了使用 Laravel 和 MongoDB 时两种分页方法:基于偏移量的分页和基于偏移量的分页。
skip()
和
limit()
以及使用文档指针的基于游标的分页。您将了解每种方法的内部工作原理,为什么偏移分页在大规模应用中性能会下降,以及何时基于游标的分页是更好的选择。最后,您将获得实际的实现示例和清晰的指导,帮助您为应用程序选择合适的分页方法。
偏移分页:机制和性能问题
偏移分页是大多数开发者最先学习的传统方法。其概念很简单:告诉数据库跳过一定数量的记录,然后返回下一批记录。例如,如果你想要第 5 页,每页显示 20 条记录,则跳过前 80 条记录,然后获取接下来的 20 条记录。
偏移分页的工作原理
在 MongoDB 中,偏移分页使用
skip()
设定起点和
limit()
控制返回的文档数量。Laravel 的 Eloquent 提供了这种控制方式。
paginate()
可以使用自动处理此问题的方法,或者您可以使用
skip()
和
take()
手动操作可实现更多控制。
以下是一个基本实现:
使用
应用程序\模型\产品
;班级
产品控制器
延伸
控制器{
民众
功能
指数
(
要求
$请求){$页
=
最大限度
((
整数
)$request
->
输入
(
'页'
,
1
),
1
(英文):每页美元
=
20
;跳过
=
(每页)
-
1
)
*
每页价格;$产品
=
产品
::
orderBy
(
'created_at'
,
'desc'
)
->
跳过
(跳过)
->
拿
每页价格
->
得到
();
返回
回复
()
->
json
($产品)}}
跳跃值的计算很简单:
($page - 1) * $perPage
对于第 1 页,跳过 0 条记录。对于第 2 页,跳过 20 条记录。对于第 100 页,跳过 1,980 条记录。
Laravel 内置的
paginate()
该方法封装了此逻辑,并添加了总页数和导航链接等元数据:
$产品
=
产品
::
orderBy
(
'created_at'
,
'desc'
)
->
分页
(
20
(英文):
这将返回一个分页器对象,其中包含结果以及有关总记录数、当前页码以及上一页和下一页 URL 的信息。
为什么偏移分页在大规模应用中会失败
问题
skip()
当你查看 MongoDB 执行查询的具体操作时,这一点就显而易见了。当你调用
skip(1000000)
MongoDB 不会直接跳转到第 1,000,001 条记录。它必须先扫描全部一百万条文档才能返回结果。数据库会读取并丢弃每个跳过的文档,这意味着读取第 10,000 页所需的时间比读取第 1 页要长得多。
查询时间随偏移量线性增长。如果第 1 页耗时 5 毫秒,那么第 1000 页可能需要 500 毫秒,而第 10000 页可能需要几秒钟。无论你的索引做得多么完善,这种性能下降都会发生,因为跳过操作本身就需要遍历文档。
您可以使用 MongoDB 的 explain 功能来观察这种行为:
db.产品。
寻找
()。
种类
({ created_at:
-
1
})。
跳过
(
1000000
)。
限制
(
20
)。
解释
(
“执行统计”
)
这
docsExamined
输出中的字段将显示已检查超过一百万份文档,即使您只返回 20 份。
计数问题
偏移分页通常在界面上显示“第 X 页,共 Y 页”,这需要知道集合中的文档总数。在大集合上获取此计数成本很高。MongoDB 必须扫描整个集合才能返回准确的计数,而且此操作无法像过滤查询那样从索引中受益。
以下几种策略可以帮助降低成本:
- 缓存计数 将总数存储在单独的位置并定期刷新,而不是在每次请求时都重新计算。
- 使用估计计数。
MongoDB 的
estimatedDocumentCount()返回近似计数比精确计数快得多 - 避免显示总计。 显示“下一页”和“上一页”按钮,但不显示总页数
// 快速估算计数估计计数
=
数据库
::
联系
(
'mongodb'
)
->
收藏
(
'产品'
)
->
生的
(
功能
($collection) {
返回
$集合
->
估计文档数
();});
偏移分页何时仍然适用
尽管偏移分页存在局限性,但在某些情况下它仍然有效:
- 中小型数据集 记录数少于 10 万条的数据集很少出现明显的性能问题。
- 管理面板 :内部工具,其便利性优先于性能考量,数据集通常会被筛选到易于管理的大小。
- 任意页面访问 当用户需要直接跳转到第 50 页或第 200 页时,偏移分页是唯一可行的选择。
如果您的应用符合这些条件,偏移分页的简洁性使其成为理想之选。性能问题仅在用户浏览大型集合中的深层页面时才会出现。
光标分页:可扩展的替代方案
基于游标的分页采用了一种不同的方法。它不是计算要跳过多少条记录,而是使用指向最后一条记录的指针,并请求获取其后的所有记录。这种技术也称为键集分页或查找分页。
什么是基于光标的分页
游标是一个唯一标识排序结果集中某个位置的值。当您请求下一页时,您会传递此游标,数据库会查询排序字段值大于(或小于,取决于方向)游标值的文档。
假设有一个按创建日期排序的帖子时间线。与其说“跳过前100个帖子”,不如说“给我显示这个时间戳之后创建的帖子”。数据库可以使用索引直接跳转到该位置,而无需扫描之前的文档。
光标分页的工作原理
流程通常遵循以下模式:
- 首次请求 获取前 N 条记录并记录最后一条记录的游标值
- 后续请求 获取排序字段大于游标值的 N 条记录
- 重复 每个响应都包含指向下一页的新光标。
光标通常是
_id
字段,例如时间戳
created_at
或者,当按非唯一字段排序时,会得到一个复合值。
// 第一页 - 无需光标$产品
=
产品
::
orderBy
(
'_ID'
,
'升序'
)
->
限制
(
20
)
->
得到
();$lastId
=
$产品
->
最后的
()
->
_ID;// 第二页 - 使用光标$产品
=
产品
::
在哪里
(
'_ID'
,
'>'
,$lastId)
->
orderBy
(
'_ID'
,
'升序'
)
->
限制
(
20
)
->
得到
();
因为
_id
该查询始终在 MongoDB 中建立索引,无论您在数据集中导航到多深,该查询都能以一致的性能执行。
为什么游标效率高
关键区别在于数据库执行查询的方式。使用游标条件时,例如
where('_id', '>', $lastId)
MongoDB 使用索引直接跳转到起始位置,不会扫描或丢弃任何文档。查询只会检查它返回的文档。
这使得游标分页对于任何“页”都具有 O(1) 的时间复杂度。无论您获取的是相当于第 1 页还是第 10,000 页的数据,查询所需时间都相同。影响性能的唯一因素是返回的文档数量,而不是文档在数据集中的位置。
权衡取舍
光标分页存在一些局限性,会影响用户界面设计:
- 禁止随意跳页 用户只能按顺序前进或后退,不能直接跳转到第 50 页。
- 不显示“第 X 页,共 Y 页” 如果没有跳页系统,就没有页码的概念。
- 光标管理 客户端必须跟踪请求之间的光标位置
这些限制使得光标分页不适用于需要传统页码的界面。然而,许多现代应用程序根本不需要页码。无限滚动界面、“加载更多”按钮以及移动应用的 API 分页都与基于光标的方法完美兼容。
理想应用案例
在以下场景下,光标分页表现出色:
- 无限滚动 社交媒体信息流、新闻网站和内容发现界面,用户在这些界面上持续滚动浏览。
- 移动应用和单页应用 :为前端提供“加载更多”功能的 API
- 实时信息流 活动日志、通知和事件流,其中经常会有新内容到达。
- 大规模 API :在需要防止深度分页攻击的公共 API 中
使用 Laravel MongoDB 实现
Laravel 通过以下方式提供了内置的光标分页支持:
cursorPaginate()
该方法适用于 Laravel MongoDB 包。本节将介绍内置方法以及针对更复杂需求的自定义实现。
Laravel 的 cursorPaginate 方法
自 Laravel 8 起可用
cursorPaginate()
自动处理光标编码、解码和导航链接生成:
使用
应用程序\模型\产品
;班级
产品控制器
延伸
控制器{
民众
功能
指数
(){$产品
=
产品
::
orderBy
(
'_ID'
)
->
光标分页
(
15
(英文):
返回
回复
()
->
json
($产品)}}
响应中包含客户端可用于导航的光标元数据:
{
“数据”
:[
...
],
“小路”
:
"http://example.com/products"
,
“每页”
:
15
,
“下一个光标”
:
"eyJfaWQiOiI2NTBhYjEyMzQ1NiIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0"
,
"next_page_url"
:
"http://example.com/products?cursor=eyJfaWQiOiI2NTBhYjEyMzQ1NiIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0"
,
"prev_cursor"
:
无效的
,
"prev_page_url"
:
无效的}
游标是一个经过 base64 编码的 JSON 对象,其中包含上一个文档的排序字段值。Laravel 会在后续请求中自动解码此对象并构建相应的查询。
对于 API 响应,您可能需要采用不同的输出格式:
民众
功能
指数
(
要求
$请求){$产品
=
产品
::
orderBy
(
'created_at'
,
'desc'
)
->
orderBy
(
'_ID'
,
'desc'
)
->
光标分页
(
20
(英文):
返回
回复
()
->
json
([
'产品'
=>
$产品
->
项目
(),
'元'
=>
[
'next_cursor'
=>
$产品
->
下一个光标
()
?->
编码
(),
'prev_cursor'
=>
$产品
->
上一个光标
()
?->
编码
(),
'has_more'
=>
$产品
->
有更多页面
(),],]);}
自定义光标分页
在某些情况下,你需要比……更多的控制权
cursorPaginate()
提供。或许您正在处理复杂的排序,需要为前端指定特定的光标格式,或者想要与现有的 API 约定集成。
以下是一个基本的自定义实现:
使用
应用程序\模型\订单
;使用
照亮\Http\请求
;班级
订单控制器
延伸
控制器{
民众
功能
指数
(
要求
$请求){每页美元
=
20
;$光标
=
$请求
->
输入
(
'光标'
(英文):$查询
=
命令
::
orderBy
(
'_ID'
,
'升序'
(英文):
如果
($cursor) {$decodedCursor
=
base64解码
(光标)$查询
->
在哪里
(
'_ID'
,
'>'
,$decodedCursor);}
// 再获取一个值,以检查是否存在更多页面
// 然后在返回前将其删除$订单
=
$查询
->
限制
每页美元
+
1
)
->
得到
();$hasMore
=
$订单
->
数数
()
>
每页价格;
如果
($hasMore) {$订单
->
流行音乐
();}$nextCursor
=
$hasMore
?
base64_编码
(订单)
->
最后的
()
->
_ID)
:
无效的
;
返回
回复
()
->
json
([
“命令”
=>
订单,
'next_cursor'
=>
$nextCursor,
'has_more'
=>
$hasMore,]);}}
获取额外文档的技巧(
$perPage + 1
) 允许您在不运行单独的计数查询的情况下确定是否存在更多页面。
处理复合排序订单
当按非唯一字段排序时,例如
created_at
多个文档可能共享相同的值。这会造成文档顺序不明确,难以确定下一个文档应该是什么。解决方法是添加一个唯一字段作为判定依据,通常情况下
_id
。
民众
功能
指数
(
要求
$请求){每页美元
=
20
;$光标
=
$请求
->
输入
(
'光标'
(英文):$查询
=
命令
::
orderBy
(
'created_at'
,
'desc'
)
->
orderBy
(
'_ID'
,
'desc'
(英文):
如果
($cursor) {已解码
=
json_decode
(
base64解码
($cursor),
真的
(英文):$查询
->
在哪里
(
功能
($q)
使用
($解码) {$q
->
在哪里
(
'created_at'
,
'<'
,$decoded[
'created_at'
])
->
或哪里
(
功能
($q2)
使用
($解码) {$q2
->
在哪里
(
'created_at'
,
'='
,$decoded[
'created_at'
])
->
在哪里
(
'_ID'
,
'<'
,$decoded[
'_ID'
]);});});}$订单
=
$查询
->
限制
每页美元
+
1
)
->
得到
();$hasMore
=
$订单
->
数数
()
>
每页价格;
如果
($hasMore) {$订单
->
流行音乐
();}$nextCursor
=
无效的
;
如果
($hasMore
&&
$订单
->
不为空
()){$lastOrder
=
$订单
->
最后的
();$nextCursor
=
base64_编码
(
json_encode
([
'created_at'
=>
$lastOrder
->
创建于
->
转换为 ISOString
(),
'_ID'
=>
(
细绳
) $lastOrder
->
_ID,]));}
返回
回复
()
->
json
([
“命令”
=>
订单,
'next_cursor'
=>
$nextCursor,
'has_more'
=>
$hasMore,]);}
复合光标条件表示:“给我指定位置的文档”
created_at
小于光标时间戳,或者
created_at
等于光标的时间戳 AND
_id
小于光标的 ID。”这样即使时间戳冲突,也能确保顺序一致。
选择正确的方法
选择偏移分页还是游标分页取决于您的数据集大小、用户界面要求和性能限制。
对比概览
| 因素 | 偏移分页 | 光标分页 |
|---|---|---|
| 规模化性能 | 尺寸偏移时性能下降 | 无论位置如何,都保持一致 |
| 实现复杂性 | 简单的 | 缓和 |
| 页面跳转 | 支持 | 不支持 |
| “第 X 页,共 Y 页”显示 | 支持 | 不支持 |
| 合适的数据集大小 | 记录数低于 10 万条 | 任何尺寸 |
选择偏移分页时
- 您的数据集规模较小(少于 10 万条记录)。
- 用户需要直接跳转到特定页面。
- 您需要在界面中显示“第 3 页,共 47 页”。
- 你正在构建的管理面板,便捷性比性能更重要。
- 即使总数据集很大,过滤后的结果集也始终是可控的。
选择光标分页时
- 您的数据集很大,或者正在无限增长。
- 您正在构建无限滚动或“加载更多”界面
- 您正在为移动应用程序或单页应用程序创建 API。
- 性能稳定性比随机页面访问更重要
- 你想防止对公共 API 的深度分页攻击
混合方法
有些应用场景结合这两种方法会更有优势:
- 早期页面偏移,光标用于深度导航 允许前 10-20 页显示页码,然后切换到基于光标的“加载更多”模式,供想要深入浏览的用户使用。
- 带游标分页的缓存计数 使用游标分页进行数据获取,但维护一个定期刷新的缓存总数,使用户能够了解数据集的大小,而不会影响性能。
性能提示
除了选择合适的分页策略之外,还有几种优化方法可以提高这两种方法的性能。
对光标字段进行索引
只有当用于游标的字段已建立索引时,游标分页才能高效工作。对于按以下方式排序的查询:
created_at
和
_id
创建复合索引:
使用
照亮\支持\立面\模式
;使用
MongoDB\Laravel\Schema\Blueprint
;架构
::
创造
(
“命令”
,
功能
(
蓝图
$collection) {$集合
->
指数
([
'created_at'
=>
-
1
,
'_ID'
=>
-
1
]);});
MongoDB 会自动创建一个升序索引
_id
对于每个集合。如果您仅按以下方式排序
_id
如果是升序排列,内置索引可以处理。但对于复合排序,例如
created_at
然后下降
_id
如果要使用降序排列,则需要上面所示的显式复合索引。MongoDB 无法高效地将多个单独的单字段索引组合起来以适应这种查询模式。
如果没有这个索引,即使使用游标分页,MongoDB 也会回退到扫描文档。索引顺序很重要:它应该与你的排序顺序完全一致。如果你按以下方式排序:
created_at
下降,然后
_id
降序排列时,索引方向应保持一致。
对于需要分页的筛选查询,请考虑创建包含筛选字段的复合索引。如果您经常按状态查询订单并按日期分页,则需要在状态字段上创建索引。
['status' => 1, 'created_at' => -1, '_id' => -1]
让 MongoDB 高效地同时满足过滤器和游标条件。
使用投影
仅获取应用程序所需的字段。包含嵌套数组或嵌入对象的大型文档传输时间更长,且会消耗更多内存:
$产品
=
产品
::
选择
([
'姓名'
,
'价格'
,
'类别'
,
'created_at'
])
->
orderBy
(
'_ID'
)
->
光标分页
(
20
(英文):
这可以降低网络开销,并加快数据库和应用程序端的序列化速度。当文档包含大型文本字段、包含大量元素的数组或深度嵌套结构时,这种差异尤为显著。一个完全加载时为 50KB 的文档,如果只显示实际需要的字段,可能只有 2KB。
战略性缓存
对于访问频率较高的首页,可以考虑缓存:
第一页
=
缓存
::
记住
(
'products:first_page'
,
现在
()
->
添加分钟
(
5
),
功能
(){
返回
产品
::
orderBy
(
'created_at'
,
'desc'
)
->
限制
(
20
)
->
得到
();});
首页通常访问量最大,因此缓存首页可以显著降低数据库负载。而较深层的页面访问频率较低,可以每次都新鲜获取。
如果要显示总数,请分别缓存总数并设置适当的生存时间 (TTL),而不是在每次请求时都进行计数:
产品总数
=
缓存
::
记住
(
'products:count'
,
现在
()
->
添加营业时间
(
1
),
功能
(){
返回
产品
::
数数
();});
当插入新文档时,考虑使缓存的首页失效,因为过期的首页比过期的深层页面更容易被用户注意到。您可以使用缓存标签或事件监听器来处理此问题:
// 在你的模型或观察者中受保护
静止的
功能
已启动
(){
静止的::
创建
(
功能
(){
缓存
::
忘记
(
'products:first_page'
(英文):});}
结论
分页是处理大型数据集的基本模式,但你选择的实现方式会对性能和用户体验产生实际影响。使用偏移分页
skip()
和
limit()
它易于实现,适用于较小的数据集或需要任意页面访问的管理界面。然而,随着用户深入浏览大型数据集,其性能会线性下降。
游标分页通过使用索引查找而非文档扫描,无论页面位置如何,都能保持一致的性能。Laravel MongoDB 开箱即用地支持这两种方法。
paginate()
用于基于偏移量的分页和
cursorPaginate()
用于基于光标的分页。
在选择分页方式时,请考虑数据集的大小和增长趋势、用户是否需要跳转到任意页面,以及性能一致性对您的用例有多重要。对于大型应用程序,游标分页通常是更好的选择。对于规模较小且需要传统分页 UI 的数据集,偏移分页仍然实用。
无论你选择哪种方法,都要记住为排序字段建立索引,使用投影来最大限度地减少数据传输,并在确定策略之前使用生产规模的数据进行基准测试。在 1 万条记录下看起来可以接受的性能特征,在 1 千万条记录下往往会截然不同。如果你正在构建一个处理大型或不断增长的数据集的新功能,请从以下方面入手:
cursorPaginate()
这样可以避免日后迁移的麻烦。





