1. 从传统分页到SearchAfter的技术演进
在Elasticsearch的实际应用中,深分页问题一直是开发者面临的典型挑战。传统分页方式(from+size)在数据量达到百万级别时,性能损耗会呈指数级增长。这是因为每次请求都需要从每个分片上获取前N条数据,然后在协调节点进行全局排序,最后再截取指定范围的结果。
举个例子,假设我们有一个包含1000万条记录的索引,执行from=999990, size=10的查询时:
- 每个分片需要先本地排序并收集999990+10条记录
- 协调节点需要合并所有分片返回的100万条记录(假设有10个分片)
- 最终只返回最后10条给客户端
这种机制不仅消耗大量内存和CPU资源,在分布式环境下还可能引发OOM问题。SearchAfter的设计正是为了解决这一痛点,它采用"书签"机制记录上一页最后一条记录的位置信息,下次查询时直接从这个位置开始获取数据。
重要提示:SearchAfter必须与sort参数配合使用,且sort字段组合应该能唯一标识一条记录(通常包含_id字段)。如果sort字段不唯一,可能导致分页结果出现重复或遗漏。
2. 新版Java SDK的核心变化解析
Elasticsearch在7.17版本推出了全新的Java客户端elasticsearch-java,与之前的RestHighLevelClient相比,主要差异体现在:
-
API设计范式转变:
- 旧版:基于方法链式调用(Method Chaining)
- 新版:采用Builder模式+不可变对象
java复制// 旧版 SearchSourceBuilder sourceBuilder = new SearchSourceBuilder() .query(QueryBuilders.matchAllQuery()) .from(0) .size(10); // 新版 SearchRequest request = new SearchRequest.Builder() .query(q -> q.matchAll(m -> m)) .from(0) .size(10) .build(); -
类型系统强化:
- 引入强类型的
FieldValue体系处理排序值 - 使用
JsonData类型处理动态JSON字段 - 自动生成的模型类(如
UserListResVo)
- 引入强类型的
-
序列化机制:
- 不再直接依赖Jackson的
ObjectMapper - 提供类型安全的序列化/反序列化接口
- 对
SearchAfter的排序值有特殊处理要求
- 不再直接依赖Jackson的
3. SearchAfter完整实现方案
3.1 首次查询与排序值提取
首次查询时需要明确指定排序规则,这是SearchAfter工作的基础。以下是一个完整的查询构建示例:
java复制// 构建排序条件(必须包含唯一性字段如_id)
SortOptions sortById = new SortOptions.Builder()
.field(f -> f.field("_id").order(SortOrder.Asc))
.build();
SortOptions sortByScore = new SortOptions.Builder()
.field(f -> f.field("score").order(SortOrder.Desc))
.build();
SearchRequest request = new SearchRequest.Builder()
.index("users")
.query(q -> q.matchAll(m -> m))
.size(10)
.sort(sortByScore, sortById) // 多字段排序
.build();
// 执行查询
SearchResponse<UserListResVo> response = elasticsearchClient.search(
request,
UserListResVo.class
);
// 处理结果
List<Hit<UserListResVo>> hits = response.hits().hits();
if (!hits.isEmpty()) {
// 获取最后一条记录的排序值
List<FieldValue> lastSortValues = hits.get(hits.size() - 1).sort();
// 转换为可序列化对象(详见3.2节)
}
3.2 排序值的序列化方案
新版SDK中的FieldValue是一个密封接口(sealed interface),直接使用JSON序列化会遇到类型信息丢失的问题。我们需要设计专门的DTO来处理:
java复制@Data
@AllArgsConstructor
@NoArgsConstructor
public class SortValueWrapper {
/**
* 值类型:Double|Long|Boolean|String|Null
*/
private String type;
/**
* 字符串形式的值
*/
private String value;
/**
* 将FieldValue转换为包装对象
*/
public static SortValueWrapper from(FieldValue fieldValue) {
return fieldValue._kind().switch()
.onDouble(() -> new SortValueWrapper("Double", String.valueOf(fieldValue.doubleValue())))
.onLong(() -> new SortValueWrapper("Long", String.valueOf(fieldValue.longValue())))
.onBoolean(() -> new SortValueWrapper("Boolean", String.valueOf(fieldValue.booleanValue())))
.onString(() -> new SortValueWrapper("String", fieldValue.stringValue()))
.onNull(() -> new SortValueWrapper("Null", null))
.onAny(() -> new SortValueWrapper("Any", fieldValue.any().toString()));
}
/**
* 转换回FieldValue
*/
public FieldValue toFieldValue() {
switch (this.type) {
case "Double": return FieldValue.of(Double.parseDouble(value));
case "Long": return FieldValue.of(Long.parseLong(value));
case "Boolean": return FieldValue.of(Boolean.parseBoolean(value));
case "String": return FieldValue.of(value);
case "Any": return FieldValue.of(JsonData.fromJson(value));
default: return FieldValue.NULL;
}
}
}
实际应用时的序列化过程:
java复制// 序列化
List<SortValueWrapper> wrappers = lastSortValues.stream()
.map(SortValueWrapper::from)
.collect(Collectors.toList());
String serialized = JsonUtils.toJson(wrappers); // 使用项目中的JSON工具
// 反序列化
List<SortValueWrapper> deserialized = JsonUtils.fromJson(
serialized,
new TypeReference<List<SortValueWrapper>>() {}
);
List<FieldValue> fieldValues = deserialized.stream()
.map(SortValueWrapper::toFieldValue)
.collect(Collectors.toList());
3.3 分页请求的完整处理流程
结合前后端交互,完整的深分页处理流程如下:
-
前端首次请求:
http复制
GET /api/users?pageSize=10 -
后端响应(包含下一页的定位信息):
json复制{ "data": [...], "nextPageToken": "W3sidHlwZSI6IkRvdWJsZSIsInZhbHVlIjoiODkuNSJ9LHsidHlwZSI6IlN0cmluZyIsInZhbHVlIjoiMTIzYWJjIn1d" } -
前端后续请求:
http复制
GET /api/users?pageSize=10&pageToken=W3sidHlwZSI6IkRvdWJsZSIsInZhbHVlIjoiODkuNSJ9LHsidHlwZSI6IlN0cmluZyIsInZhbHVlIjoiMTIzYWJjIn1d -
后端处理逻辑:
java复制@GetMapping("/api/users") public PageResult<UserListResVo> getUsers( @RequestParam int pageSize, @RequestParam(required = false) String pageToken) { SearchRequest.Builder builder = new SearchRequest.Builder() .index("users") .size(pageSize) .sort(/* 排序条件 */); if (StringUtils.isNotBlank(pageToken)) { List<SortValueWrapper> wrappers = JsonUtils.fromJson( URLDecoder.decode(pageToken, StandardCharsets.UTF_8), new TypeReference<>() {} ); builder.searchAfter( wrappers.stream() .map(SortValueWrapper::toFieldValue) .collect(Collectors.toList()) ); } SearchResponse<UserListResVo> response = elasticsearchClient.search( builder.build(), UserListResVo.class ); List<UserListResVo> data = response.hits().hits().stream() .map(Hit::source) .collect(Collectors.toList()); String nextPageToken = null; if (!response.hits().hits().isEmpty()) { List<SortValueWrapper> nextWrappers = response.hits().hits().get( response.hits().hits().size() - 1 ).sort().stream() .map(SortValueWrapper::from) .collect(Collectors.toList()); nextPageToken = URLEncoder.encode( JsonUtils.toJson(nextWrappers), StandardCharsets.UTF_8 ); } return new PageResult<>(data, nextPageToken); }
4. 实战中的疑难问题排查
4.1 排序值不一致问题
现象:翻页后出现重复记录或记录缺失
原因分析:
- 排序字段组合不具备唯一性
- 索引正在发生变更(新增/删除文档)
- 跨多个索引查询时各索引的映射不一致
解决方案:
- 确保排序字段包含
_id或其他唯一字段java复制.sort(s -> s.field(f -> f.field("create_time").order(SortOrder.Desc))) .sort(s -> s.field(f -> f.field("_id").order(SortOrder.Asc))) - 使用
preference参数确保查询路由一致性java复制.preference("user123") // 固定用户会话的查询分片 - 对多索引查询添加映射校验
java复制GetFieldMappingRequest request = new GetFieldMappingRequest.Builder() .index("index1,index2") .fields("sort_field1", "sort_field2") .build();
4.2 性能优化技巧
-
并行预取:当检测到可能需要进行深分页时,提前异步加载后续几页数据
java复制
CompletableFuture<SearchResponse<User>> future = elasticsearchClient .async() .search(builder.build(), User.class); -
缓存策略:对高频访问的页码建立缓存
java复制CaffeineCache cache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); String cacheKey = "page:" + pageToken; List<User> cached = cache.getIfPresent(cacheKey); if (cached != null) { return cached; } -
索引设计优化:
- 为排序字段建立doc_values
- 避免在排序字段上使用analyzed text类型
- 考虑使用
time_series索引模式处理时间序列数据
4.3 监控与告警
建议对以下指标进行监控:
| 指标名称 | 监控阈值 | 检查频率 |
|---|---|---|
| 分页查询延迟 | >500ms | 实时 |
| 单次查询返回文档数 | >1000 | 实时 |
| SearchAfter转换失败率 | >1% | 每分钟 |
| 深分页请求比例 | >总查询量的20% | 每小时 |
对应的告警处理代码示例:
java复制SearchTemplateRequest request = new SearchTemplateRequest.Builder()
.id("scroll_monitor")
.params(Map.of("threshold", JsonData.of(500)))
.build();
SearchTemplateResponse<Object> response = elasticsearchClient
.searchTemplate(request, Object.class);
if (response.hits().total().value() > 0) {
alertService.send("深分页性能告警",
"检测到"+response.hits().total().value()+"个慢查询");
}
5. 扩展应用场景
5.1 无限滚动列表实现
现代Web应用常采用无限滚动(Infinite Scroll)代替传统分页,SearchAfter非常适合这种场景:
javascript复制// 前端实现示例
let lastSortValues = null;
async function loadMore() {
const params = { size: 20 };
if (lastSortValues) {
params.pageToken = encodeURIComponent(JSON.stringify(lastSortValues));
}
const response = await fetch(`/api/items?${new URLSearchParams(params)}`);
const data = await response.json();
// 渲染数据...
lastSortValues = data.nextPageToken
? JSON.parse(decodeURIComponent(data.nextPageToken))
: null;
}
// 滚动事件监听
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
loadMore();
}
});
5.2 与游标查询(Point In Time)结合
Elasticsearch 7.10+引入了Point In Time(PIT)API,可以与SearchAfter组合使用:
java复制// 创建PIT(有效期5分钟)
CreatePitRequest createPitRequest = new CreatePitRequest.Builder()
.index("users")
.keepAlive(Time.of(t -> t.time("5m")))
.build();
CreatePitResponse pitResponse = elasticsearchClient.createPit(createPitRequest);
// 使用PIT+SearchAfter查询
SearchRequest request = new SearchRequest.Builder()
.size(100)
.pit(p -> p.id(pitResponse.id()).keepAlive("5m"))
.searchAfter(lastSortValues)
.build();
// 最后记得关闭PIT
ClosePointInTimeRequest closeRequest = new ClosePointInTimeRequest.Builder()
.id(pitResponse.id())
.build();
elasticsearchClient.closePointInTime(closeRequest);
5.3 多维度分页策略
根据业务场景选择合适的分页策略:
| 策略 | 适用场景 | 实现要点 |
|---|---|---|
| SearchAfter | 深度分页、实时性要求高 | 确保排序字段唯一且稳定 |
| Scroll API | 全量导出、离线分析 | 注意及时清理scroll上下文 |
| PIT+SearchAfter | 长时间运行的稳定视图 | 合理设置keep_alive时间 |
| 复合策略 | 前几页用from/size,后面用SA | 设置切换阈值(如page>100) |
实际项目中,我推荐采用混合策略。例如电商商品列表:
- 前5页使用传统分页(用户体验更友好)
- 第6页开始自动切换为SearchAfter
- 导出功能使用Scroll API
实现代码示例:
java复制public PageResult<User> getUsers(int page, int size) {
if (page <= 5) {
// 传统分页
return traditionalPagination(page, size);
} else {
// SearchAfter分页
return searchAfterPagination(page, size);
}
}
private PageResult<User> traditionalPagination(int page, int size) {
SearchRequest request = new SearchRequest.Builder()
.from((page - 1) * size)
.size(size)
.build();
// ...执行查询
}
private PageResult<User> searchAfterPagination(int page, int size) {
// 需要先从缓存或存储中获取上一页的sort值
List<FieldValue> lastSortValues = pageTokenService.get(page - 1);
SearchRequest request = new SearchRequest.Builder()
.size(size)
.searchAfter(lastSortValues)
.build();
// ...执行查询
}