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. 避坑指南:你必须知道的注意事项
游标泄漏防护:某金融系统曾因未及时清理Scroll导致集群内存耗尽
- 建议:采用try-with-resources模式管理Scroll会话
排序一致性:使用search_after时若插入新文档,可能引起分页错乱
- 解决方案:排序字段需包含唯一标识(如主键ID)
性能监控指标:
GET /_nodes/stats/indices/search?pretty # 查看查询耗时 GET /_search?profile=true
索引设计规范:某电商平台将分页字段设为
doc_values:false
导致分页性能下降30%
7. 总结:分页优化的三重境界
第一层:知道不能用from+size
做深分页
第二层:能根据场景选择合适的替代方案
第三层:在架构层面设计分页友好型数据模型
经过压力测试,某在线教育平台采用search_after+时间窗口优化后:
- 第100页查询耗时从1200ms降至200ms
- 集群CPU使用率下降40%
- 支持的最高并发量提升5倍