- 背景和价值
- 一、核心问题:为什么
from/size
不适合深度分页? - 二、优化方案:按场景选择替代机制
- 1. 场景:“滚动分页”(Scroll API)—— 适合批量导出/后台任务
- 2. 场景:“搜索_after”(Search After)—— 适合用户前台分页(支持跳页但需连续)
- 3. 场景:“预计算分页标记”—— 适合支持随机跳页的业务
- 4. 场景:“限制最大分页深度”—— 业务层面规避
- 三、辅助优化:提升分页查询基础性能
- 四、方案选择决策树
- 总结
- 一、核心问题:为什么
- 参考资料
背景和价值
Elasticsearch(ES)的深度分页(如查询第1000页以后的数据)会面临性能瓶颈,主要原因是ES的分页机制(from/size
)需要在内存中聚合所有匹配结果并排序,当from
值过大时(如from=10000
),会导致严重的内存消耗和性能下降。优化深度分页需从机制替换和查询设计两方面入手,以下是具体方案:
一、核心问题:为什么from/size
不适合深度分页?
ES的from/size
分页原理是:
- 从每个分片查询前
from+size
条数据; - 将所有分片的结果拉取到协调节点,合并排序后取第
from
到from+size
条数据。
当from
很大时(如from=10000
,size=10
),每个分片需返回10010条数据,协调节点需处理10010×分片数
条数据,内存和网络开销呈指数级增长,甚至可能触发OOM(内存溢出)。
二、优化方案:按场景选择替代机制
1. 场景:“滚动分页”(Scroll API)—— 适合批量导出/后台任务
原理:生成一个临时快照(scroll_id),记录查询结果的位置,后续分页通过scroll_id获取下一批数据,避免重复计算。
适用场景:全量数据导出(如导出所有订单)、后台批处理任务(非实时用户交互)。
示例:
// 1. 初始化滚动查询,保留快照1分钟
POST /order_index/_search?scroll=1m
{"size": 100, // 每次返回100条"query": { "match_all": {} },"sort": [{ "order_time": "desc" }] // 必须指定排序(通常按唯一字段)
}// 2. 后续分页,使用返回的scroll_id
POST /_search/scroll
{"scroll": "1m", // 延长快照有效期"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAA...(上一步返回的scroll_id)"
}
优势:性能稳定,支持海量数据分页;
局限:
- 不支持随机跳页(只能顺序翻页);
- 快照会占用集群资源,需及时清理(
DELETE /_search/scroll
)。
2. 场景:“搜索_after”(Search After)—— 适合用户前台分页(支持跳页但需连续)
原理:通过上一页最后一条数据的“排序值”作为锚点,查询下一页数据,避免计算from
之前的所有数据。
适用场景:用户前台分页(如电商商品列表页),支持“下一页”但不支持“直接跳至第100页”。
示例:
// 1. 第一页查询,按order_time和order_id排序(确保唯一排序)
GET /order_index/_search
{"size": 10,"query": { "term": { "user_id": "u300" } },"sort": [{ "order_time": "desc" },{ "order_id": "desc" } // 用唯一字段作为第二排序,避免排序值重复]
}// 2. 第二页查询,使用第一页最后一条的sort值作为search_after
GET /order_index/_search
{"size": 10,"query": { "term": { "user_id": "u300" } },"sort": [{ "order_time": "desc" },{ "order_id": "desc" }],"search_after": [1622505600000, "order_1000"] // 第一页最后一条的order_time和order_id
}
优势:性能好(无需计算from
前的数据),支持海量数据深度分页;
局限:
- 只能基于上一页的锚点顺序翻页,不支持随机跳页(如从第1页直接跳至第100页);
- 排序字段必须唯一(通常组合时间+ID,避免数据重复或遗漏)。
3. 场景:“预计算分页标记”—— 适合支持随机跳页的业务
原理:在数据写入时,预先计算分页标记(如按时间/ID范围划分“页”),查询时通过标记直接定位分页位置。
适用场景:需支持随机跳页(如“第100页”)的业务,且数据有明确的排序维度(如按时间递增)。
实现思路:
- 按排序字段(如
order_time
)将数据分段,每100条记录为一页,记录每页的起始order_time
和order_id
; - 存储分段信息(如在另一个索引
pagination_marks
中); - 查询第N页时,先从
pagination_marks
获取第N页的起始标记,再用range
查询直接定位:
// 查询第100页(假设第100页起始时间为1622505600000,起始ID为order_10000)
GET /order_index/_search
{"size": 100,"query": {"bool": {"must": [{ "term": { "user_id": "u300" } }],"filter": [{ "range": { "order_time": { "lte": 1622505600000 } } // 基于预计算的标记]}},"sort": [{ "order_time": "desc" },{ "order_id": "desc" }]
}
优势:支持随机跳页,性能接近search_after
;
局限:
- 需额外存储分页标记,增加写入复杂度;
- 数据更新(如删除、新增)可能导致标记失效,需定期重建。
4. 场景:“限制最大分页深度”—— 业务层面规避
原理:从业务设计上限制分页深度(如最多支持前100页),超过则提示“数据量过大,请缩小查询范围”。
适用场景:用户实际很少访问深度分页的业务(如搜索引擎通常只显示前100页)。
实现方式:
- 在应用层判断
from
值,若超过阈值(如from >= 10000
),直接返回错误; - 引导用户通过筛选条件(如时间范围、分类)缩小查询结果集,减少分页压力。
优势:简单直接,从源头避免深度分页问题;
局限:需业务方接受功能限制。
三、辅助优化:提升分页查询基础性能
无论采用哪种分页机制,以下优化能进一步提升性能:
-
合理设计排序字段
排序字段尽量使用数字型或日期型(如order_time
、id
),避免对text
类型字段排序(需额外启用fielddata
,内存消耗大)。 -
添加查询过滤条件
减少匹配结果总量(如按时间范围range
、用户term
过滤),结果集越小,分页压力越小。 -
优化索引分片
分片数量需合理(单分片数据量建议50GB以内),分片过多会增加协调节点的合并开销。 -
禁用
_source
或按需返回字段
分页查询时,通过_source
指定所需字段(如只返回order_id
、price
),减少数据传输量:GET /order_index/_search {"_source": ["order_id", "price"], // 只返回必要字段"size": 10,"from": 100 }
四、方案选择决策树
业务需求 | 推荐方案 | 核心原因 |
---|---|---|
批量导出/后台任务 | Scroll API | 支持全量数据顺序分页 |
用户前台分页(下一页) | Search After | 性能好,适合深度分页 |
必须支持随机跳页 | 预计算分页标记 | 平衡跳页需求和性能 |
可接受功能限制 | 限制最大分页深度 | 简单直接,避免性能问题 |
总结
ES深度分页的核心优化思路是避免使用from/size
做深度分页,而是根据业务场景选择:
- 顺序分页用
Search After
(用户交互场景)或Scroll API
(批量任务); - 随机跳页需通过预计算标记在业务层实现;
- 结合查询过滤、字段裁剪等辅助手段进一步提升性能。
最终目标是:在满足业务需求的前提下,最小化ES的计算和内存开销。