在处理海量数据时,深度分页是Elasticsearch用户经常遇到的性能杀手。本文将深入剖析传统分页的性能瓶颈,并详细讲解Search After和Scroll API这两种高效解决方案,帮助你在实际应用中做出合理的技术选型。
从一个常见的业务场景说起:假设你正在开发一个电商平台,需要展示商品列表并且支持分页。当用户浏览到第1000页时,页面加载速度变得极其缓慢,甚至超时。这背后就是 Elasticsearch 深度分页的典型性能问题。
传统的From/Size分页看似简单,但其背后的执行机制却隐藏着巨大的性能隐患:
json GET /products/_search { "from": 10000, "size": 10, "query": { "match_all": {} }, "sort": [ { "create_time": "desc" } ] }
执行流程分析:
| 分页深度(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,这是为了防止深度分页导致的集群性能问题。虽然可以通过以下方式调整:
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" } } ] }
如果排序字段不唯一,分页时可能出现数据丢失或重复的问题。解决方案:
在动态索引中,直接使用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的优势:
前端配合改造:
排序策略优化:
json "sort": [ { "timestamp": "desc" }, { "user_id": "asc" }, { "_id": "asc" } ]
错误处理:
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实现并行处理:
json POST /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); } }
数据导出任务:
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); } }
某电商平台商品搜索面临的问题:
前端改造:
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倍 |
分层分页策略:
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; // 超深分页/导出 } } }
监控与告警:
通过合理运用这些分页方案,你可以在不同业务场景下实现最佳的性能表现,为用户提供流畅的搜索体验。
技术选型的核心不是寻找银弹,而是根据具体场景找到最适合的解决方案。在分页这个看似简单的功能背后,藏着分布式系统设计的深刻智慧。
本文作者:张豪
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!