1. 当分页遇上高并发:为什么我们需要优化?

想象一下双十一的电商平台,每秒有上万用户同时搜索"羽绒服",系统需要返回第100页的搜索结果。此时如果用传统from=990&size=10的分页方式,ES需要将前990条数据全部加载到内存,这就像让快递员把整个仓库的货都搬到门口,只是为了取出最后十件包裹。

更糟的是,当并发请求达到5000QPS时,分页查询可能直接拖垮集群。去年某社交平台就曾因消息列表分页设计不当,导致ES节点OOM(内存溢出)引发全网故障。这种场景下,分页优化不再是锦上添花,而是生死攸关的技术决策。


2. 传统分页的致命缺陷:深分页之殇

示例1:经典from/size分页

(Elasticsearch 7.x)

// 获取第100页数据(每页10条)
SearchRequest request = new SearchRequest("products");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.from(990);  // 起始偏移量
sourceBuilder.size(10);   // 每页数量
request.source(sourceBuilder);

// 执行查询(此时ES需要计算前990条数据)
SearchResponse response = client.search(request, RequestOptions.DEFAULT);

问题分析

  • 每次查询都要计算from+size范围内的所有文档
  • 内存消耗与页码深度呈线性增长
  • from+size > index.max_result_window(默认10000)时直接报错

3. 破局之道:四种优化方案实战

3.1 游标滚动(Scroll API):大数据量场景的救星

适用场景:后台导出、全量数据遍历

// 初始化滚动查询(保持上下文1分钟)
SearchRequest searchRequest = new SearchRequest("logs");
SearchSourceBuilder searchSource = new SearchSourceBuilder();
searchSource.query(QueryBuilders.matchAllQuery());
searchSource.size(500);  // 每次获取500条
searchRequest.scroll(TimeValue.timeValueMinutes(1L));
searchRequest.source(searchSource);

// 首次查询获取Scroll ID
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
String scrollId = response.getScrollId();

// 后续通过scrollId循环获取(演示获取3次)
for (int i=0; i<3; i++) {
    SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId);
    scrollRequest.scroll(TimeValue.timeValueMinutes(1L));
    response = client.scroll(scrollRequest, RequestOptions.DEFAULT);
    // 处理结果...
    scrollId = response.getScrollId(); // 更新Scroll ID
}

// 最终清除滚动上下文
ClearScrollRequest clearRequest = new ClearScrollRequest();
clearRequest.addScrollId(scrollId);
client.clearScroll(clearRequest, RequestOptions.DEFAULT);

技术特点

  • 创建搜索上下文快照
  • 适合非实时的大数据遍历
  • 注意及时清理Scroll会话

3.2 Search After:实时分页的最佳选择

适用场景:用户实时翻页操作

// 首次查询(按时间排序)
SearchRequest request = new SearchRequest("messages");
SearchSourceBuilder source = new SearchSourceBuilder();
source.sort("createTime", SortOrder.DESC);
source.size(10);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);

// 获取最后一个文档的排序值
SearchHit[] hits = response.getHits().getHits();
Object[] lastSortValues = hits[hits.length-1].getSortValues();

// 后续分页查询
source.searchAfter(lastSortValues); // 关键设置
response = client.search(request, RequestOptions.DEFAULT);

优势分析

  • 避免全局排序计算
  • 支持实时数据更新
  • 必须指定唯一排序字段组合

3.3 时间窗口分页:日志系统的利器

适用场景:时序数据(如订单记录、操作日志)

// 构建时间范围查询
RangeQueryBuilder rangeQuery = QueryBuilders
    .rangeQuery("createTime")
    .gte("2023-01-01T00:00:00")
    .lte("2023-01-01T23:59:59");

// 按时间倒序分页
SearchSourceBuilder source = new SearchSourceBuilder()
    .query(rangeQuery)
    .sort("createTime", SortOrder.DESC)
    .size(100);

优化要点

  • 结合业务增加时间过滤条件
  • 配合search_after使用效果更佳
  • 需要合理的索引周期设计

4. 关联技术:缓存策略的巧妙运用

示例4:Redis缓存分页状态

(结合Search After)

// 存储分页状态(有效期5分钟)
String cacheKey = "user:123:searchAfter";
Object[] lastSortValues = getLastSortValues(); // 从ES查询获取
String json = objectMapper.writeValueAsString(lastSortValues);

redisTemplate.opsForValue().set(
    cacheKey, 
    json, 
    5, 
    TimeUnit.MINUTES
);

// 读取分页状态
String cachedValue = redisTemplate.opsForValue().get(cacheKey);
Object[] sortValues = objectMapper.readValue(cachedValue, Object[].class);
source.searchAfter(sortValues);

注意事项

  • 序列化排序字段值
  • 设置合理的过期时间
  • 需处理缓存穿透问题

5. 技术方案对比分析

方案 响应时间 内存消耗 实时性 适用场景
From/Size 极高 实时 浅分页(<100页)
Scroll 非实时 数据导出
Search After 实时 深度分页
时间窗口 最低 准实时 时序数据查询

6. 避坑指南:你必须知道的注意事项

  1. 游标泄漏防护:某金融系统曾因未及时清理Scroll导致集群内存耗尽

    • 建议:采用try-with-resources模式管理Scroll会话
  2. 排序一致性:使用search_after时若插入新文档,可能引起分页错乱

    • 解决方案:排序字段需包含唯一标识(如主键ID)
  3. 性能监控指标

    GET /_nodes/stats/indices/search?pretty
    
    # 查看查询耗时
    GET /_search?profile=true
    
  4. 索引设计规范:某电商平台将分页字段设为doc_values:false导致分页性能下降30%


7. 总结:分页优化的三重境界

第一层:知道不能用from+size做深分页
第二层:能根据场景选择合适的替代方案
第三层:在架构层面设计分页友好型数据模型

经过压力测试,某在线教育平台采用search_after+时间窗口优化后:

  • 第100页查询耗时从1200ms降至200ms
  • 集群CPU使用率下降40%
  • 支持的最高并发量提升5倍