目录
1 普通查询
普通的from size会出现的问题:
-
ES 结果窗口的限制默认为 10000 条,from + size 大于结果窗口时会抛异常。
-
es 数据存储在各个分片中,深分页的情况下需要缓存排序大量 from 之前的数据,再获取 from 之后的数据,会导致大量的内存和性能上的消耗。
GET /my_index/_search
{
"query": {
"match_all": {}
},
"from": 0,
"size": 5
}
Java示例:
SearchRequest seachRequest = new SearchRequest().indices(MY_INDEX);
// 条件查询
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
if(xxx != null) {
boolQueryBuilder.must(QueryBuilders.matchQuery("xx", xx));
}
if(sign == 0) {
boolQueryBuilder.must(QueryBuilders.rangeQuery(CONSTANTS.TIME).gt(startTime).lt(endTime));
}
SearchSourceBuilder builder = new SearchSourceBuilder();
builder.query(boolQueryBuilder);
builder.from(x.getCurrentPage()); // 起始页
builder.size(x.getPageSize()); // 页长
seachRequest.source(builder);
SearchResponse response = null;
try{
response = esClient.search(searchRequest, RequestOptions.DEFAULT);
} catch(IOException e) {
log.error("索引:{},Xx search error:{}", MY_INDEX, e);
}
long total = 0L;
List<Xx> list = new ArrayList<>();
if(response != null) {
SearchHits hits = response.getHits();
total = hits.getTotalHits().value;
for(Search hit : hits) {
Xx xx = JSONObject.parseObject(hit.getSourceAsString(), Xx.class);
xx.setDocId(hit.getId()); // 拿出文档id
list.add(xx);
}
}
return new PageData<>(list, total);
2 解决方案
Elasticsearch提供了两种替代的分页策略:
search_after
注意 search after 的作用和滚动查询相似,都是基于上一次的查询结果进行下一次的查询(区别在search after不需要维护状态),所以不能进行跳跃式查询。如果要支持 from size 的查询方式,可以将 search after 和 from size 进行结合。最后一个方案。
search_after
不需要缓存排序跳过大量的文档来获取下一页结果,它基于前一页查询结果中的最后一项的排序值来定位下一页数据的起始位置。这样可以使用上一页的结果来帮助检索下一页。
假设我们要在一个网页上显示文章列表,每页显示10篇文章。我们首先执行一个查询,不使用search_after
,以获取第一页的10篇文章:
GET /my_index/_search
{
"size": 10,
"sort": [
{ "publish_time": { "order": "desc" } }
]
}
现在,想要获取下一页的10篇文章,使用search_after
:
GET /my_index/_search
{
"size": 10,
"sort": [
{ "publish_time": { "order": "desc" } }
],
"search_after": [1626422400] // 这是上一页最后一篇文章的发布时间戳
}
search_after
参数告诉 Elasticsearch 从比指定时间戳晚的文章开始检索。这种方式不需要记住任何内部状态,只需要记住上一页最后一个文档的排序字段值即可。
Java示例:
public void searchAfterExample() throws IOException { // 真实场景需要捕获处理
List<String> searchAfterValues = new ArrayList<>(); // 存储排序字段的值,用于下一次查询
boolean hasMorePages = true;
while (hasMorePages) {
SearchRequest searchRequest = new SearchRequest("my_index");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder()
.size(PAGE_SIZE)
.query(QueryBuilders.matchAllQuery())
.sort("timestamp", false); // 按 timestamp 字段降序排序
if (!searchAfterValues.isEmpty()) {
searchSourceBuilder.searchAfter(searchAfterValues.toArray());
}
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
List<String> currentSearchAfterValues = new ArrayList<>();
for (SearchHit hit : searchResponse.getHits().getHits()) {
System.out.println(hit.getSourceAsString());
// 添加当前文档的排序字段值到列表,用于下一次查询
currentSearchAfterValues.add((String) hit.getSortValues()[0]);
}
// 更新用于下一次查询的排序字段值列表
searchAfterValues = currentSearchAfterValues;
// 检查是否有更多页面
hasMorePages = searchResponse.getHits().getHits().length == PAGE_SIZE;
}
}
scroll 滚动查询
滚动查询 会创建了一个临时的快照,返回读取的位置。下一次可以基于这个位置开始查询,减少对之前数据的查询和排序。所以此种方案比较适合连续数据的查询(上一页下一页)。但是如果跳页查询,就需要进行普通查询和排序。(只推荐用于单个滚动搜索请求中检索大量结果,且需要保留上下文需要足够的堆内存空间)
croll API
用于一次性检索大量数据,通常用于数据导出或后台数据处理。首先进行一个带有scroll
参数的查询,以获取文章数据,并创建一个滚动上下文:
GET /blogs/_search?scroll=1m
{
"size": 1000, // 或者你想每次获取的数量
"sort": [
{ "publish_time": { "order": "desc" } }
]
}
这个查询返回了最多1000篇文章,同时也返回了一个_scroll_id
,这是一个内部状态,表示滚动上下文的标识符。然后就可以使用这个_scroll_id
来获取下一批文章,直到没有更多文章为止:
GET /_search/scroll
{
"scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQpJbXJiYVlUWVdLQ0JxVXNvZ3d5a0F1MzNzRzZsQjBhZG1pbl91cGxvYWRfY29udGVudHN7fQ==",
"scroll": "1m"
}
每次使用_scroll_id
执行滚动查询时,Elasticsearch会返回下一批文章,并更新_scroll_id
。如果返回的结果为空,或者达到scroll
参数设定的时间限制,滚动上下文就会被清理。
Java示例:
public void scrollApiExample() throws IOException { // 真实场景需要捕获处理
// 初始化滚动查询
SearchRequest searchRequest = new SearchRequest("my_index");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.size(SCROLL_SIZE)
.query(QueryBuilders.matchAllQuery());
searchRequest.source(sourceBuilder.scroll(KEEP_ALIVE));
// 执行初始查询
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
String scrollId = response.getScrollId();
// 开始滚动检索
do {
for (SearchHit hit : response.getHits().getHits()) {
System.out.println("Found document with id: " + hit.getId());
// 处理文档
}
// 清空scrollId变量,以便在下一次查询中使用
SearchRequest scrollRequest = new SearchRequest();
scrollRequest.scroll(new Scroll(KEEP_ALIVE));
scrollRequest.scrollId(scrollId);
// 执行滚动查询
response = client.searchScroll(scrollRequest, RequestOptions.DEFAULT);
scrollId = response.getScrollId();
} while (response.getHits().getHits().length > 0); // 当没有更多文档时,跳出循环
// 清理滚动上下文
client.clearScroll(new ClearScrollRequest().addScrollId(scrollId), RequestOptions.DEFAULT);
}
search_after 和 scroll API 的区别:
-
使用场景:
search_after
更适合于用户界面的分页查询,而Scroll API
更适用于后台批量数据处理或数据导出任务。 -
状态管理:
search_after
不需要维护状态,而Scroll API
需要维护滚动上下文,存在生命周期,过期清除。 -
实时性:
search_after
具有更好的实时性,而Scroll API
更关注于数据的完整性和一致性。
3 Search After 优化普通查询
使用哈希表存储页码对应的排序值,用户每次进行 from size 请求时,通过判断是否存在上一页的排序值,存在则可直接使用 search after 查询。否则设置页码,正常普通查询。后将当前页码的排序值添加到哈希表中。
private Map<Integer, Object[]> searchAfterMap = new ConcurentHashMap<>(); // 记录页码的排序值
PageData<> searchPage() {
SearchRequest seachRequest = new SearchRequest().indices(MY_INDEX);
// 条件查询
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
if(xxx != null) {
boolQueryBuilder.must(QueryBuilders.matchQuery("xx", xx));
}
if(sign == 0) {
boolQueryBuilder.must(QueryBuilders.rangeQuery(CONSTANTS.TIME).gt(startTime).lt(endTime)) ;
}
SearchSourceBuilder builder = new SearchSourceBuilder();
builder.query(boolQueryBuilder);
builder.size(x.getPageSize()); // 页长
builder.sort(TIME, SortOrder.DESC); // 注意设置排序字段
if(searchAfterMap.containsKey(x.getCurrentPage() - 1)) { // 存在上一次查询的排序值,可利用search after
builder.searchAfter(searchAfterMap.get(x.getCurrentPage() - 1));
} else {
builder.from(x.getCurrentPage()); // 起始页
}
seachRequest.source(builder);
// 发送请求
SearchResponse response = null;
try{
response = esClient.search(searchRequest, RequestOptions.DEFAULT);
} catch(IOException e) {
log.error("索引:{},Xx search error:{}", MY_INDEX, e);
}
// 响应处理
long total = 0L;
List<Xx> list = new ArrayList<>();
if(response != null) {
SearchHits hits = response.getHits();
total = hits.getTotalHits().value;
// 如果需要,更新searchAfterMap
if (hits.getHits().length > 0) {
// 注意:这里需要确保hits列表不为空,并且至少有一个元素
SearchHit lastHit = hits.getAt(hits.getHits().length - 1);
if (lastHit.getSortValues() != null && lastHit.getSortValues().length > 0) {
Object[] sortValues = new Object[]{lastHit.getSortValues()[0]}; // 假设只有一个排序字段
searchAfterMap.put(x.getCurrentPage(), sortValues);
}
}
for(Search hit : hits) {
Xx xx = JSONObject.parseObject(hit.getSourceAsString(), Xx.class);
xx.setDocId(hit.getId()); // 拿出文档id
list.add(xx);
}
}
return new PageData<>(list, total);
}
4 总结
-
数据量小可以使用 from/size 分页。
-
数据量大,深度翻页,用户实时、高并发查询需求,使用 search after 方式。
-
数据量大,深度翻页,后台批处理任务(数据迁移)之类的任务,使用 scroll 方式。