【知识获取与分享社区项目 | 项目日记第 20 天】search_after 游标分页:解决 Elasticsearch 深分页稳定性问题

一、搜索分页为什么不能只用 pageNum

普通业务分页经常这么写:

text 复制代码
page=1&size=20
page=2&size=20
page=3&size=20

底层对应 MySQL:

sql 复制代码
LIMIT 20 OFFSET 40

但在 Elasticsearch 中,如果搜索结果很多,使用 from + size 做深分页会有明显问题:

  1. 越往后翻,ES 需要跳过的数据越多;
  2. 多分片场景下排序合并成本高;
  3. 深分页性能不稳定;
  4. 数据变化时,结果容易重复或遗漏。

所以搜索系统采用的是:

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;
    }
}

这里有两个点:

  1. 第一个排序字段 _scoredouble
  2. 后面的时间、计数、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 实现低延迟标题联想。


相关推荐
Wenzar_1 小时前
GeoHash+Redis Streams实时围栏系统实战
java·数据库·redis·junit
字节高级特工1 小时前
C++11(二) 革新:引用折叠与lambda表达式
java·开发语言·c++·算法
萨小耶1 小时前
[Java学习日记11】聊聊深拷贝和浅拷贝
java·开发语言·学习
Mr.朱鹏1 小时前
基于 postgres_fdw 的跨库查询方案
java·数据库·spring boot·sql·spring·postgresql
敲个大西瓜1 小时前
Java并发实用干货
java
1368木林森1 小时前
【Spring源码17·完结篇】SpringBoot核心注解+高频坑点+失效场景万字全集!收官Spring全家桶源码系列
java·spring boot·后端
南山十一少1 小时前
基于 Quartz 组件在 Spring Boot 框架下的周期任务调度实验
java·spring boot·spring
zhongerzixunshi1 小时前
标准化能源管控,赋能企业双碳落地
大数据·人工智能·能源
罗超驿1 小时前
14.LeetCode 438 题解:滑动窗口+哈希表找所有字母异位词
java·算法·leetcode