2023-12-21
中间件
0

目录

深度分页:为什么成为性能陷阱?
From/Size分页的底层原理
性能瓶颈的数学分析
Elasticsearch的自我保护机制
Search After:实时深度分页的终极方案
核心设计思想
基础使用示例
确保排序唯一性的重要性
PIT(Point In Time):保证数据一致性
Search After的最佳实践
Scroll API:大数据处理的专业工具
设计理念与适用场景
完整使用流程
Sliced Scroll:并行加速技术
Scroll的局限性
技术方案对比与选型指南
三种方案特性对比
业务场景选型指南
实战案例:电商平台分页优化
问题背景
优化方案实施
优化效果对比
总结与最佳实践
核心要点总结
架构设计建议

在处理海量数据时,深度分页是Elasticsearch用户经常遇到的性能杀手。本文将深入剖析传统分页的性能瓶颈,并详细讲解Search After和Scroll API这两种高效解决方案,帮助你在实际应用中做出合理的技术选型。

深度分页:为什么成为性能陷阱?

从一个常见的业务场景说起:假设你正在开发一个电商平台,需要展示商品列表并且支持分页。当用户浏览到第1000页时,页面加载速度变得极其缓慢,甚至超时。这背后就是 Elasticsearch 深度分页的典型性能问题。

From/Size分页的底层原理

传统的From/Size分页看似简单,但其背后的执行机制却隐藏着巨大的性能隐患:

json
GET /products/_search { "from": 10000, "size": 10, "query": { "match_all": {} }, "sort": [ { "create_time": "desc" } ] }

执行流程分析:

  • 查询阶段:每个分片需要本地查询出10010条数据(from + size)

  • 收集阶段:协调节点从所有分片收集数据(假设有5个分片,则收集50050条)

  • 排序阶段:协调节点对50050条数据进行全局排序

  • 截断阶段:丢弃前10000条,返回剩余的10条

性能瓶颈的数学分析

分页深度(from值)响应时间(单分片)内存占用协调节点总处理量
10015ms2MB5 × 110 = 550条
1,000120ms18MB5 × 1010 = 5050条
10,0001.2s180MB5 × 10010 = 50050条
50,0006.8s900MB5 × 50010 = 250050条

Elasticsearch的自我保护机制

Elasticsearch默认设置了 index.max_result_window 参数,值为10000,这是为了防止深度分页导致的集群性能问题。虽然可以通过以下方式调整:

bash
curl -XPUT http://127.0.0.1:9200/my_index/_settings -d '{ "index": { "max_result_window": 50000 } }'

但强烈不建议盲目修改此参数,因为这只会推迟问题发生的时间,而不能从根本上解决问题。

核心设计思想

Search After采用游标分页的理念,基于上一页的最后一条记录来定位下一页的起始位置。这种方式完全避免了全局遍历和排序,实现了常数时间复杂度的分页查询。

基础使用示例

首次查询:

json
GET /products/_search { "size": 10, "sort": [ { "create_time": { "order": "desc" } }, { "_id": { "order": "asc" } } ] }

获取下一页(使用上一页最后一条的排序值):

json
GET /products/_search { "size": 10, "search_after": [ "2023-07-20T12:00:00", "product_12345" ], "sort": [ { "create_time": { "order": "desc" } }, { "_id": { "order": "asc" } } ] }

确保排序唯一性的重要性

如果排序字段不唯一,分页时可能出现数据丢失或重复的问题。解决方案:

  • 使用多字段组合排序:确保组合唯一

  • 使用内置唯一字段:如_id

  • 使用业务主键:如商品ID、用户ID等

PIT(Point In Time):保证数据一致性

在动态索引中,直接使用Search After可能因数据变更导致分页不一致。PIT机制解决了这个问题:

创建PIT:

json
POST /products/_pit?keep_alive=5m

使用PIT查询:

json
GET /_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 API专为大数据量处理场景设计,如:

  • 数据导出:全量数据导出到文件或数据仓库

  • 数据迁移:集群间数据迁移或索引重建

  • 批量处理:离线数据分析或批量计算

完整使用流程

初始化Scroll:

json
POST /products/_search?scroll=5m { "size": 1000, "query": { "range": { "create_time": { "gte": "2023-01-01" } } }, "sort": [ { "_id": "asc" } ] }

后续遍历:

json
POST /_search/scroll { "scroll": "5m", "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ==" }

资源清理:

json
DELETE /_search/scroll { "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ==" }

Sliced Scroll:并行加速技术

对于超大数据集,可以使用Sliced Scroll实现并行处理:

json
POST /bigdata/_search?scroll=10m { "slice": { "id": 0, "max": 5 }, "size": 1000, "query": { "match_all": {} } }

Scroll的局限性

  • 实时性牺牲:基于查询时刻的快照,不反映后续数据变更

  • 资源占用:保持搜索上下文,占用文件句柄和内存

  • 交互性差:不适合用户实时交互场景

技术方案对比与选型指南

三种方案特性对比

特性From/SizeSearch AfterScroll 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); } }

数据导出任务:

python
def 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页响应时间420ms45ms9.3倍
第1000页响应时间8200ms52ms157倍
内存占用峰值850MB45MB95%降低
99分位响应时间6500ms85ms76倍

总结与最佳实践

核心要点总结

  • 理解问题本质:From/Size的性能问题源于协调节点的全局排序和数据收集

  • 正确选择方案:根据业务场景选择合适的分页策略

  • 注意实现细节:Search After需要确保排序唯一性,Scroll需要及时清理资源

架构设计建议

分层分页策略:

java
public 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 许可协议。转载请注明出处!