本文目录导读:

- 方案一:传统
$skip+$limit(简单但效率低) - 方案二:基于游标/上一页最后一条(推荐用于大数据)
- 方案三:预计算分页键(适合固定分组)
- 方案四:ES 聚合分页(
composite聚合) - 实用建议
分页展示聚合结果是一个常见需求,但直接对聚合结果进行分页(如 $skip 和 $limit)存在性能瓶颈,尤其是数据量大时。
以下是几种主流方案及其实现脚本(以 MongoDB 和 Elasticsearch 为例,因为它们最常处理聚合分页):
传统 $skip + $limit(简单但效率低)
适用场景:数据量小(<1万条)、页码跳转频繁。
// MongoDB Aggregation Pipeline
db.collection.aggregate([
{ $match: { status: "active" } },
{ $group: { _id: "$category", count: { $sum: 1 }, totalAmount: { $sum: "$amount" } } },
{ $sort: { count: -1 } },
{ $skip: (page - 1) * pageSize }, // 跳过的文档数
{ $limit: pageSize } // 每页返回数
])
缺点:
- 页码越靠后,
$skip越慢(需要遍历并丢弃大量数据)。 - 无法获取总页数(需额外执行
$count阶段)。
基于游标/上一页最后一条(推荐用于大数据)
适用场景:无限滚动、实时数据流、数据量大(千万级)。
原理:记住上一页最后一个分组的 _id 或排序字段,下一页直接从该位置开始。
// 假设按 count 降序排列,_id 是分组键
// 第一页:获取前 20 条
let lastDoc = null;
function nextPage(lastDoc) {
let matchStage = {};
if (lastDoc) {
// 游标条件:count 小于上一页最后一条 或 等于且 _id 更大(处理并列)
matchStage = {
$match: {
$or: [
{ count: { $lt: lastDoc.count } },
{ count: lastDoc.count, _id: { $gt: lastDoc._id } }
]
}
};
}
return db.collection.aggregate([
{ $match: { status: "active" } },
{ $group: { _id: "$category", count: { $sum: 1 }, totalAmount: { $sum: "$amount" } } },
{ $sort: { count: -1, _id: 1 } },
...(matchStage ? [matchStage] : []), // 条件过滤
{ $limit: 20 }
]);
}
// 使用:
let page1 = await nextPage(null);
let page2 = await nextPage(page1[page1.length - 1]);
优点:
- 稳定高效,不随页码增长变慢。
- 适合实时数据(新数据不会导致旧数据偏移)。
缺点:
- 无法直接跳转到任意页码(页码无意义)。
- 需要前端配合保存游标。
预计算分页键(适合固定分组)
适用场景:分组数量固定且较少(如省市、月份),且需要快速跳页。
思路:将所有分组键存入一个数组,用数组索引模拟页码。
// 1. 先执行一次聚合,只提取分组键
let allGroups = await db.collection.aggregate([
{ $group: { _id: "$department" } },
{ $sort: { _id: 1 } },
{ $group: { _id: null, keys: { $push: "$_id" } } }
]).toArray();
let totalPages = Math.ceil(allGroups[0].keys.length / pageSize);
// 2. 根据页码取对应的分组键
let start = (page - 1) * pageSize;
let end = start + pageSize;
let pageKeys = allGroups[0].keys.slice(start, end);
// 3. 用这些键再次聚合(精准过滤)
let pageData = await db.collection.aggregate([
{ $match: { department: { $in: pageKeys } } },
{ $group: { _id: "$department", totalSales: { $sum: "$amount" } } },
{ $sort: { _id: 1 } }
]).toArray();
优点:
- 任意页码直接跳转。
- 聚合速度快(只处理少量分组)。
缺点:
- 需要两次查询(获取键 + 获取数据),但键查询极快。
- 如果分组数量本身巨大(如百万个),不适用。
ES 聚合分页(composite 聚合)
Elasticsearch 提供了原生支持游标分页的 composite 聚合。
脚本示例(Python + elasticsearch-py):
from elasticsearch import Elasticsearch
es = Elasticsearch()
# 第一页
page1 = es.search(index="orders", body={
"size": 0, # 不返回原始文档
"aggs": {
"my_composite": {
"composite": {
"size": 20, # 每页20个桶
"sources": [
{ "category": { "terms": { "field": "category.keyword" } } }
]
},
"aggs": {
"avg_price": { "avg": { "field": "price" } }
}
}
}
})
# 获取下一页的 after_key
after_key = page1['aggregations']['my_composite']['after_key']
# 下一页
page2 = es.search(index="orders", body={
"size": 0,
"aggs": {
"my_composite": {
"composite": {
"size": 20,
"sources": [{"category": {"terms": {"field": "category.keyword"}}}],
"after": after_key # 关键:传入上一页的 after_key
},
"aggs": {"avg_price": {"avg": {"field": "price"}}}
}
}
})
| 方法 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
$skip+$limit |
实现简单,支持任意页码 | 深度分页极慢 | 小数据、后台管理 |
| 游标分页 | 高效,稳定 | 不能跳页 | 无限滚动、API |
| 预计算键 | 快速跳页 | 两次查询,分组数少 | 分组固定(100以内) |
| ES composite | ES原生支持,高效 | 仅限于ES | 大数据聚合 |
实用建议
- 对于常见的后台管理系统(数据<10万):用
$skip+$limit完全够用,配合$facet一次性返回总条数:
let result = await db.collection.aggregate([
{ $match: { ... } },
{ $group: { _id: "$category", count: { $sum: 1 } } },
{ $facet: {
total: [{ $count: "count" }],
data: [{ $sort: { count: -1 } }, { $skip: skip }, { $limit: pageSize }]
}}
]).toArray();
-
对于用户可见的实时数据(如社交动态):游标分页是最佳选择,前端记录
_id或时间戳。 -
如果需要静态报表(历史数据):预计算分页键可以做到毫秒级跳页。
如果需要针对特定数据库或语言(如 Java、Python、JS)的代码示例,可以告诉我你的技术栈。