在处理海量数据时,深度分页是Elasticsearch用户经常遇到的性能杀手。本文将深入剖析传统分页的性能瓶颈,并详细讲解Search After和Scroll API这两种高效解决方案,帮助你在实际应用中做出合理的技术选型。
从一个常见的业务场景说起:假设你正在开发一个电商平台,需要展示商品列表并且支持分页。当用户浏览到第1000页时,页面加载速度变得极其缓慢,甚至超时。这背后就是 Elasticsearch 深度分页的典型性能问题。
传统的From/Size分页看似简单,但其背后的执行机制却隐藏着巨大的性能隐患:
jsonGET /products/_search
{
"from": 10000,
"size": 10,
"query": {
"match_all": {}
},
"sort": [
{
"create_time": "desc"
}
]
}
执行流程分析:
查询阶段:每个分片需要本地查询出10010条数据(from + size)
收集阶段:协调节点从所有分片收集数据(假设有5个分片,则收集50050条)
排序阶段:协调节点对50050条数据进行全局排序
截断阶段:丢弃前10000条,返回剩余的10条
| 分页深度(from值) | 响应时间(单分片) | 内存占用 | 协调节点总处理量 |
|---|---|---|---|
| 100 | 15ms | 2MB | 5 × 110 = 550条 |
| 1,000 | 120ms | 18MB | 5 × 1010 = 5050条 |
| 10,000 | 1.2s | 180MB | 5 × 10010 = 50050条 |
| 50,000 | 6.8s | 900MB | 5 × 50010 = 250050条 |
Elasticsearch默认设置了 index.max_result_window 参数,值为10000,这是为了防止深度分页导致的集群性能问题。虽然可以通过以下方式调整:
bashcurl -XPUT http://127.0.0.1:9200/my_index/_settings -d '{
"index": {
"max_result_window": 50000
}
}'
但强烈不建议盲目修改此参数,因为这只会推迟问题发生的时间,而不能从根本上解决问题。
Search After采用游标分页的理念,基于上一页的最后一条记录来定位下一页的起始位置。这种方式完全避免了全局遍历和排序,实现了常数时间复杂度的分页查询。
首次查询:
jsonGET /products/_search
{
"size": 10,
"sort": [
{
"create_time": {
"order": "desc"
}
},
{
"_id": {
"order": "asc"
}
}
]
}
获取下一页(使用上一页最后一条的排序值):
jsonGET /products/_search
{
"size": 10,
"search_after": [
"2023-07-20T12:00:00",
"product_12345"
],
"sort": [
{
"create_time": {
"order": "desc"
}
},
{
"_id": {
"order": "asc"
}
}
]
}
如果排序字段不唯一,分页时可能出现数据丢失或重复的问题。解决方案:
使用多字段组合排序:确保组合唯一
使用内置唯一字段:如_id
使用业务主键:如商品ID、用户ID等
在动态索引中,直接使用Search After可能因数据变更导致分页不一致。PIT机制解决了这个问题:
创建PIT:
jsonPOST /products/_pit?keep_alive=5m
使用PIT查询:
jsonGET /_search
{
"size": 10,
"pit": {
"id": "z9_qAwELdGVzdC0wMDAwMDQWVGxjUUVIUzhRQktTTkJRU3VQQXlodwAWWGlMYTRUQ2VUaE9PVlJHNzRTdHBVdwAAAAAAAAauuRZ3bEkwVkx1MlR6YVlsMUZ4MHpUV05nAAEWVGxjUUVIUzhRQktTTkJRU3VQQXlodwAA",
"keep_alive": "5m"
},
"sort": [
{
"create_time": {
"order": "desc"
}
}
]
}
PIT的优势:
数据一致性:在分页过程中保持索引状态快照
资源管理:自动过期机制避免资源泄漏
实时性可控:通过keep_alive平衡一致性与实时性
前端配合改造:
将sort值作为游标传递给前端
下一页请求时原样传回
避免传统的pageNumber方式
排序策略优化:
json"sort": [
{ "timestamp": "desc" },
{ "user_id": "asc" },
{ "_id": "asc" }
]
错误处理:
游标过期处理机制
数据变更的边界情况处理
Scroll API专为大数据量处理场景设计,如:
数据导出:全量数据导出到文件或数据仓库
数据迁移:集群间数据迁移或索引重建
批量处理:离线数据分析或批量计算
初始化Scroll:
jsonPOST /products/_search?scroll=5m
{
"size": 1000,
"query": {
"range": {
"create_time": {
"gte": "2023-01-01"
}
}
},
"sort": [
{
"_id": "asc"
}
]
}
后续遍历:
jsonPOST /_search/scroll
{
"scroll": "5m",
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
资源清理:
jsonDELETE /_search/scroll
{
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
对于超大数据集,可以使用Sliced Scroll实现并行处理:
jsonPOST /bigdata/_search?scroll=10m
{
"slice": {
"id": 0,
"max": 5
},
"size": 1000,
"query": {
"match_all": {}
}
}
实时性牺牲:基于查询时刻的快照,不反映后续数据变更
资源占用:保持搜索上下文,占用文件句柄和内存
交互性差:不适合用户实时交互场景
| 特性 | From/Size | Search After | Scroll API |
|---|---|---|---|
| 性能表现 | 深度分页时急剧下降 | 常数时间复杂度 | 线性时间复杂度,但稳定 |
| 实时性 | 实时 | 实时 | 快照隔离 |
| 内存消耗 | 随深度线性增长 | 固定少量内存 | 固定,但持续占用 |
| 使用复杂度 | 简单 | 中等 | 复杂 |
| 跳页支持 | 支持 | 不支持 | 不支持 |
| 数据一致性 | 实时,可能变化 | 实时,可能变化 | 快照,保持一致性 |
| 适用数据量 | 小数据量(<10,000) | 大数据量 | 超大数据集 |
用户交互式分页:
java// 推荐:Search After
public PageResult searchProducts(SearchRequest request) {
if (request.getPage() > 100) {
// 深度分页场景,强制使用Search After
return searchAfterService.search(request);
} else {
// 浅分页场景,可使用From/Size
return fromSizeService.search(request);
}
}
数据导出任务:
pythondef export_large_data(index_name, query, output_file):
"""
大数据量导出场景推荐使用Scroll API
"""
scroll_id = init_scroll(index_name, query, "30m")
try:
with open(output_file, 'w') as f:
while True:
data = next_scroll(scroll_id, "30m")
if not data:
break
process_and_write_batch(f, data)
finally:
cleanup_scroll(scroll_id)
实时数据分析:
java// 需要实时性且数据量大的场景推荐Search After
public void realTimeDataAnalysis() {
// 使用PIT + Search After保证数据一致性
String pitId = createPointInTime("products", "5m");
try {
// 分页处理实时数据
processDataWithSearchAfter(pitId);
} finally {
closePointInTime(pitId);
}
}
某电商平台商品搜索面临的问题:
商品总数:2,000万+
用户投诉:浏览到50页后加载缓慢
技术指标:第1000页查询耗时超过8秒
前端改造:
javascript// 传统分页参数 → 游标分页参数
// 优化前
const request = {
page: 1000,
size: 20,
sort: 'create_time,desc'
};
// 优化后
const request = {
size: 20,
search_after: ['2023-06-15T10:30:00', 'product_123456'],
sort: 'create_time,desc|_id,asc'
};
后端改造:
java@Service
public class ProductSearchService {
public SearchResult searchProducts(ProductSearchRequest request) {
if (request.hasCursor()) {
// 使用Search After进行深度分页
return searchWithSearchAfter(request);
} else if (request.getPage() <= 10) {
// 浅分页使用From/Size
return searchWithFromSize(request);
} else {
// 深度分页强制使用Search After
throw new BusinessException("深度分页请使用游标方式");
}
}
private SearchResult searchWithSearchAfter(ProductSearchRequest request) {
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 构建排序(必须包含唯一字段)
List<SortBuilder<?>> sorts = new ArrayList<>();
sorts.add(SortBuilders.fieldSort("create_time").order(SortOrder.DESC));
sorts.add(SortBuilders.fieldSort("_id").order(SortOrder.ASC));
queryBuilder.withSorts(sorts)
.withPageable(PageRequest.of(0, request.getSize()));
if (request.hasSearchAfter()) {
queryBuilder.withSearchAfter(request.getSearchAfter());
}
// 执行查询
SearchHits<Product> searchHits = elasticsearchTemplate.search(
queryBuilder.build(), Product.class);
return buildSearchResult(searchHits);
}
}
| 指标 | 优化前(From/Size) | 优化后(Search After) | 提升幅度 |
|---|---|---|---|
| 第100页响应时间 | 420ms | 45ms | 9.3倍 |
| 第1000页响应时间 | 8200ms | 52ms | 157倍 |
| 内存占用峰值 | 850MB | 45MB | 95%降低 |
| 99分位响应时间 | 6500ms | 85ms | 76倍 |
理解问题本质:From/Size的性能问题源于协调节点的全局排序和数据收集
正确选择方案:根据业务场景选择合适的分页策略
注意实现细节:Search After需要确保排序唯一性,Scroll需要及时清理资源
分层分页策略:
javapublic class PaginationStrategy {
public PaginationType determineStrategy(int page, int size,
long totalHits) {
if (page <= 10) {
return PaginationType.FROM_SIZE; // 浅分页
} else if (page <= 1000) {
return PaginationType.SEARCH_AFTER; // 深度分页
} else {
return PaginationType.SCROLL; // 超深分页/导出
}
}
}
监控与告警:
监控Search After游标过期情况
监控Scroll上下文数量,避免超过search.max_open_scroll_context限制
设置分页深度告警阈值
通过合理运用这些分页方案,你可以在不同业务场景下实现最佳的性能表现,为用户提供流畅的搜索体验。
技术选型的核心不是寻找银弹,而是根据具体场景找到最适合的解决方案。在分页这个看似简单的功能背后,藏着分布式系统设计的深刻智慧。
本文作者:柳始恭
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!