
一、搜索分页为什么不能只用 pageNum
普通业务分页经常这么写:
text
page=1&size=20
page=2&size=20
page=3&size=20
底层对应 MySQL:
sql
LIMIT 20 OFFSET 40
但在 Elasticsearch 中,如果搜索结果很多,使用 from + size 做深分页会有明显问题:
- 越往后翻,ES 需要跳过的数据越多;
- 多分片场景下排序合并成本高;
- 深分页性能不稳定;
- 数据变化时,结果容易重复或遗漏。
所以搜索系统采用的是:
text
search_after 游标分页
它不再告诉后端"我要第几页",而是告诉后端:
text
从上一次最后一条结果之后继续查
二、接口中的 after 参数
搜索接口中有一个参数:
java
@RequestParam(value = "after", required = false) String after
完整接口如下:
java
@GetMapping
public SearchResponse search(@RequestParam("q") @NotBlank String q,
@RequestParam(value = "size", required = false, defaultValue = "20") @Min(1) int size,
@RequestParam(value = "tags", required = false) String tagsCsv,
@RequestParam(value = "after", required = false) String after,
@AuthenticationPrincipal Jwt jwt) {
Long userId = (jwt == null) ? null : jwtService.extractUserId(jwt);
return searchService.search(q, size, tagsCsv, after, userId);
}
第一次搜索时,前端不传 after:
text
GET /api/v1/search?q=Redis&size=20
后端返回:
json
{
"items": [],
"nextAfter": "xxxx",
"hasMore": true
}
下一页请求时,前端带上 nextAfter:
text
GET /api/v1/search?q=Redis&size=20&after=xxxx
这就是典型的游标分页。
三、search_after 依赖稳定排序
search_after 不是随便传一个 ID 就能翻页,它依赖完整的排序字段。
项目中定义的排序字段是:
java
List<SortOptions> sorts = new ArrayList<>();
sorts.add(SortOptions.of(s -> s.score(o -> o.order(SortOrder.Desc))));
sorts.add(SortOptions.of(s -> s.field(f -> f.field("publish_time").order(SortOrder.Desc))));
sorts.add(SortOptions.of(s -> s.field(f -> f.field("like_count").order(SortOrder.Desc))));
sorts.add(SortOptions.of(s -> s.field(f -> f.field("view_count").order(SortOrder.Desc))));
sorts.add(SortOptions.of(s -> s.field(f -> f.field("content_id").order(SortOrder.Desc))));
也就是说,每一条搜索结果都会有一组 sort values:
text
_score
publish_time
like_count
view_count
content_id
下一页查询时,把上一页最后一条的这组排序值传给 ES:
java
b = b.searchAfter(afterValues);
ES 就会从这条数据之后继续返回。
四、为什么必须加 content_id 兜底排序
前几个排序字段可能重复。
比如两篇文章:
text
_score 相同
publish_time 相同
like_count 相同
view_count 相同
如果没有最后的唯一字段,ES 很难保证分页边界稳定。
所以项目中追加了:
java
content_id DESC
它的作用不是提升业务相关性,而是让排序有确定性。
可以理解为:
text
前面的字段决定谁更应该靠前
最后的 content_id 保证顺序不会摇摆
这就是 search_after 能稳定工作的关键。
五、解析 after 游标
前端传来的 after 是 Base64URL 字符串,后端需要解码成 ES 可识别的 FieldValue 列表。
代码如下:
java
private List<FieldValue> parseAfter(String after) {
if (after == null || after.isBlank()) {
return null;
}
try {
String decoded = new String(Base64.getUrlDecoder().decode(after));
String[] parts = decoded.split(",");
List<FieldValue> out = new ArrayList<>(parts.length);
for (int i = 0; i < parts.length; i++) {
String p = parts[i];
if (i == 0) {
out.add(FieldValue.of(Double.parseDouble(p)));
} else if (i == 1) {
out.add(FieldValue.of(Long.parseLong(p)));
} else {
out.add(FieldValue.of(Long.parseLong(p)));
}
}
return out;
} catch (Exception e) {
return null;
}
}
这里有两个点:
- 第一个排序字段
_score是double; - 后面的时间、计数、ID 都按
long处理。
如果游标为空或者解析失败,就返回 null,相当于从第一页开始查。
六、把 search_after 加入 ES 查询
在真正构建 ES 查询时,项目会判断是否存在游标:
java
if (afterValues != null && !afterValues.isEmpty()) {
b = b.searchAfter(afterValues);
}
完整逻辑可以理解为:
text
没有 after:
查第一页
有 after:
从上一页最后一条记录之后继续查
这比 from + size 更适合搜索结果页的"加载更多"场景。
七、生成 nextAfter
每次查询结束后,后端会取最后一条命中的 sort values:
java
if (!hits.isEmpty()) {
List<FieldValue> sv = hits.getLast().sort();
if (sv != null && !sv.isEmpty()) {
List<String> parts = sv.stream()
.map(this::fieldValueToString)
.collect(Collectors.toList());
nextAfter = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(String.join(",", parts).getBytes());
}
}
也就是说:
text
当前页最后一条结果的 sort values
↓
转成字符串数组
↓
用逗号拼接
↓
Base64URL 编码
↓
返回给前端
前端不需要理解游标内部结构,只需要把 nextAfter 原样传回来。
八、FieldValue 转字符串
为了生成游标,项目中写了一个通用转换方法:
java
private String fieldValueToString(FieldValue fv) {
if (fv.isDouble()) {
return String.valueOf(fv.doubleValue());
}
if (fv.isLong()) {
return String.valueOf(fv.longValue());
}
if (fv.isString()) {
return fv.stringValue();
}
if (fv.isBoolean()) {
return String.valueOf(fv.booleanValue());
}
return String.valueOf(fv._get());
}
这段代码的作用是把 ES 返回的排序值统一转成字符串,便于编码成游标。
九、hasMore 的判断
返回对象中还有一个字段:
java
boolean hasMore
项目中判断方式是:
java
boolean hasMore = items.size() >= size;
如果本次返回数量达到了请求的 size,就认为后面可能还有更多数据。
这是一种简单实用的判断方式,适合"加载更多"式搜索页。
十、一次完整分页流程
把整个流程串起来就是:
text
第一次请求:
GET /api/v1/search?q=Redis&size=20
后端:
按照 _score、publish_time、like_count、view_count、content_id 排序
返回前 20 条
取第 20 条 sort values 生成 nextAfter
前端:
保存 nextAfter
第二次请求:
GET /api/v1/search?q=Redis&size=20&after=nextAfter
后端:
解析 after
调用 search_after
从上一页最后一条之后继续返回
前端并不关心当前是第几页,只关心有没有下一批数据。
十一、search_after 适合什么场景
search_after 很适合这种内容流搜索场景:
text
搜索结果页
下拉加载更多
移动端无限滚动
不要求随机跳到第 100 页
它不适合传统后台表格那种:
text
跳转到第 37 页
输入页码跳页
但内容社区里,用户通常是不断向下浏览,所以游标分页更自然。
十二、本篇小结
这一篇主要分析了搜索系统中的 search_after 游标分页。
核心点是:
text
不用 from + size 做深分页
使用多字段排序保证结果稳定
使用 content_id 作为最终兜底排序
把上一页最后一条 sort values 编码成 nextAfter
下一页请求时通过 search_after 继续查询
整体设计可以总结成一句话:
text
搜索结果不是按页码翻,而是沿着稳定排序链路继续向后走。
下一篇继续看搜索索引是如何构建和更新的,以及项目中如何用 ES 的 completion suggester 实现低延迟标题联想。