Elasticsearch 实际工作中的分页方案详解

Elasticsearch 实际工作中的分页方案详解

这篇文档只讲分页 ,而且是"线上真实能扛流量、不炸 ES 的分页"。

如果你还在 from + size 翻 1000 页,这篇就是来救你的。


1. 为什么 Elasticsearch 的分页和 MySQL 完全不一样?

1.1 MySQL 的分页逻辑(你熟的)

sql 复制代码
SELECT * FROM t LIMIT 100 OFFSET 9900;

1.2 Elasticsearch 的分页逻辑(重点)

ES 底层是 倒排索引 + 多分片

  • 每个 shard 都要:
    1. 查询 from + size
    2. 排序
    3. 再合并结果
  • from 越大,每个 shard 做的无用功越多

👉 本质:
ES 的分页成本 ≈ O(shard × (from + size))


2. from + size(只能用在"浅分页")

2.1 基本用法

json 复制代码
GET product/_search
{
  "from": 0,
  "size": 10,
  "query": {
    "match": { "name": "iphone" }
  }
}

2.2 Spring Boot 示例

java 复制代码
Pageable pageable = PageRequest.of(page, size);
NativeSearchQuery query = new NativeSearchQueryBuilder()
        .withQuery(matchQuery("name", keyword))
        .withPageable(pageable)
        .build();

2.3 官方硬限制(非常重要)

text 复制代码
index.max_result_window = 10000
  • 默认最大只能翻到 10000 条
  • 超过直接报错

2.4 什么时候还能用?

只适合:

  • 前端搜索结果
  • 页数 ≤ 100(通常 ≤ 50)
  • 强交互、强实时

绝对不能用在:

  • 导出数据
  • 后台列表翻页
  • BI / 报表
  • 全量扫描

3. 深分页的三种正确姿势(核心)

场景 正确方案
前端搜索翻页 search_after
后台全量导出 scroll
实时 + 可跳页 search_after + 状态缓存

4. search_after(生产最推荐)

4.1 search_after 的核心思想

不要跳过前面的数据,而是"从上一次最后一条继续"

  • 不用 offset
  • 不会扫描前面的数据
  • 性能稳定

4.2 必须满足的条件(很重要)

  1. 必须排序
  2. 排序字段 值唯一或组合唯一
  3. 通常用:时间 + id

4.3 DSL 示例(第一次查询)

json 复制代码
GET product/_search
{
  "size": 10,
  "sort": [
    { "updatedAt": "desc" },
    { "id": "asc" }
  ],
  "query": {
    "match": { "name": "iphone" }
  }
}

返回结果里,每条都会带:

json 复制代码
"sort": ["2024-12-01T10:00:00", "12345"]

4.4 第二页查询(关键)

json 复制代码
GET product/_search
{
  "size": 10,
  "sort": [
    { "updatedAt": "desc" },
    { "id": "asc" }
  ],
  "search_after": ["2024-12-01T10:00:00", "12345"],
  "query": {
    "match": { "name": "iphone" }
  }
}

👉 search_after 的值 = 上一页最后一条的 sort 值


Spring Boot 项目的 search_after 分页示例(Spring Data Elasticsearch),包含:DSL、Service、Controller、返回结构(带 nextSearchAfter 给前端继续翻页)
1) ES 请求长啥样(对照用)

第一次

json 复制代码
GET product/_search
{
  "size": 20,
  "sort": [
    { "updatedAt": "desc" },
    { "id": "asc" }
  ],
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "iphone" } }
      ],
      "filter": [
        { "term": { "status": "ON" } }
      ]
    }
  }
}

第二页开始:把上一页最后一条的 sort 值塞进 search_after:

json 复制代码
"search_after": ["2025-12-01T10:00:00.000Z", "12345"]

2) Spring Boot 代码(可直接用)

2.1 Document(示例)
java 复制代码
@Data
@Document(indexName = "product")
public class ProductDoc {
    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "standard")
    private String name;

    @Field(type = FieldType.Keyword)
    private String status;

    @Field(type = FieldType.Date)
    private Instant updatedAt;
}
2.2 返回 DTO(重点:nextSearchAfter)
java 复制代码
@Data
@AllArgsConstructor
public class SearchAfterPage<T> {
    private List<T> items;
    private List<Object> nextSearchAfter; // 下一页继续传回
    private boolean hasNext;
}
2.3 Service:search_after 查询
java 复制代码
@Service
@RequiredArgsConstructor
public class ProductSearchService {

    private final ElasticsearchOperations ops;

    public SearchAfterPage<ProductDoc> search(
            String keyword,
            List<Object> searchAfter, // 前端传:上一页最后一条 sort 值;第一页传 null
            int size
    ) {
        BoolQueryBuilder bool = QueryBuilders.boolQuery();

        if (keyword != null && !keyword.isBlank()) {
            bool.must(QueryBuilders.matchQuery("name", keyword));
        } else {
            bool.must(QueryBuilders.matchAllQuery());
        }

        bool.filter(QueryBuilders.termQuery("status", "ON"));

        NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder()
                .withQuery(bool)
                // search_after 必须要 sort,而且要稳定(建议:时间 + 唯一id)
                .withSorts(
                        SortBuilders.fieldSort("updatedAt").order(SortOrder.DESC),
                        SortBuilders.fieldSort("id").order(SortOrder.ASC)
                )
                // 注意:search_after 不用 page 号,Pageable 只用来塞 size
                .withPageable(PageRequest.of(0, size));

        if (searchAfter != null && !searchAfter.isEmpty()) {
            builder.withSearchAfter(searchAfter.toArray());
        }

        SearchHits<ProductDoc> hits = ops.search(builder.build(), ProductDoc.class);
        List<SearchHit<ProductDoc>> sh = hits.getSearchHits();

        List<ProductDoc> items = sh.stream()
                .map(SearchHit::getContent)
                .toList();

        List<Object> next = null;
        if (!sh.isEmpty()) {
            next = sh.get(sh.size() - 1).getSortValues(); // 关键:拿最后一条的 sortValues
        }

        boolean hasNext = sh.size() == size; // 简单判断:满 size 认为还有下一页(够用)
        return new SearchAfterPage<>(items, next, hasNext);
    }
}
2.4 Controller:对外接口
java 复制代码
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/products")
public class ProductController {

    private final ProductSearchService searchService;

    @GetMapping("/search")
    public SearchAfterPage<ProductDoc> search(
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) String searchAfter, // JSON 字符串,例如 ["2025-...","123"]
            @RequestParam(defaultValue = "20") int size
    ) throws Exception {
        List<Object> after = null;
        if (searchAfter != null && !searchAfter.isBlank()) {
            after = new ObjectMapper().readValue(searchAfter, new TypeReference<List<Object>>() {});
        }
        return searchService.search(keyword, after, size);
    }
}
3) 前端怎么用(你只需要记住这个)

第一次请求不传 searchAfter

拿到返回里的 nextSearchAfter

下一次请求把它原样传回去

比如:

复制代码
GET /api/products/search?keyword=iphone&size=20
返回 nextSearchAfter = ["2025-12-01T10:00:00.000Z","12345"]

GET /api/products/search?keyword=iphone&size=20&searchAfter=["2025-12-01T10:00:00.000Z","12345"]
4) 线上别翻车的 3 个硬规则
1.sort 必须稳定且唯一

✅ updatedAt desc + id asc

❌ 只用 updatedAt desc(会重复/丢数据)

2.排序字段必须是 keyword / date / 数值

text 字段别碰排序

3.search_after 不支持跳页

想"跳到第 50 页"= 产品交互要改(或做缓存状态,成本很高)

5. scroll(离线 / 导出专用)

5.1 scroll 是干嘛的?

  • 固定住一个快照
  • 一批一批拉数据
  • 不适合实时查询

👉 本质是 游标


6. search_after vs scroll 对比

维度 search_after scroll
实时性 低(快照)
性能 极稳 稳但重
是否支持导出 一般
是否适合前端

7. 一句话总结

前端分页用 search_after,
导出用 scroll,
from + size 只准用在浅分页。

相关推荐
Dxy123931021618 小时前
Elasticsearch 8.13.4 深度进阶指南:从底层架构到高阶实战的全维突围
大数据·elasticsearch·架构
a努力。19 小时前
中国电网Java面试被问:RPC序列化的协议升级和向后兼容
java·开发语言·elasticsearch·面试·职场和发展·rpc·jenkins
Hello.Reader19 小时前
Flink Elasticsearch Connector 从 0 到 1 搭一个高吞吐、可容错的 ES Sink
大数据·elasticsearch·flink
小雪_Snow21 小时前
Elasticsearch 安装教程【Windows,9.2.0 版本】
elasticsearch
Elastic 中国社区官方博客2 天前
使用 Elasticsearch 管理 agentic 记忆
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
七夜zippoe2 天前
Elasticsearch核心概念与Java客户端实战 构建高性能搜索服务
java·大数据·elasticsearch·集群·索引·分片
忍冬行者2 天前
Elasticsearch 超大日志流量集群搭建(网关 + 独立 Master + 独立 Data 纯生产架构,角色完全分离,百万级日志吞吐)
大数据·elasticsearch·云原生·架构·云计算
·云扬·3 天前
使用Prometheus+Grafana实现Elasticsearch监控的完整实践
elasticsearch·grafana·prometheus
阿杰 AJie3 天前
Git 分支与多人开发使用指南(Gitee + 本地 Git)
git·elasticsearch·gitee
海鸥813 天前
ArgoCD App of Apps 模式详解
java·elasticsearch·argocd