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 只准用在浅分页。

相关推荐
老纪的技术唠嗑局1 天前
告别OpenClaw配置丢失——Mindkeeper内测版邀测
大数据·elasticsearch·搜索引擎
Elasticsearch1 天前
使用 Elasticsearch + Jina embeddings 进行无监督文档聚类
elasticsearch
勇哥的编程江湖1 天前
flinkcdc streaming 同步数据到es记录过程
大数据·elasticsearch·flink·flinkcdc
曾阿伦1 天前
Elasticsearch 7.x 常用命令备忘录
大数据·elasticsearch·搜索引擎
斯特凡今天也很帅1 天前
Elasticsearch数据库专栏(二)DSL语句总结(更新中)
大数据·elasticsearch·搜索引擎
要记得喝水1 天前
适用于 Git Bash 的脚本,批量提交和推送多个仓库的修改
git·elasticsearch·bash
二十七剑1 天前
Elasticsearch的索引问题
大数据·elasticsearch·搜索引擎
A__tao2 天前
Elasticsearch Mapping 一键生成 Java 实体类(支持嵌套 + 自动过滤注释)
java·python·elasticsearch
A__tao2 天前
Elasticsearch Mapping 一键生成 Proto 文件(支持嵌套 + 注释过滤)
大数据·elasticsearch·jenkins
Devin~Y2 天前
高并发电商与AI智能客服场景下的Java面试实战:从Spring Boot到RAG与向量数据库落地
java·spring boot·redis·elasticsearch·spring cloud·kafka·rag