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

相关推荐
GeminiJM2 小时前
Elasticsearch Dump 失败问题排查:Store: True 导致的字段数组化问题
大数据·elasticsearch·jenkins
G皮T19 小时前
【Elasticsearch】查询性能调优(四):计数的精确性探讨
大数据·elasticsearch·搜索引擎·全文检索·es·性能·opensearch
十月南城19 小时前
ES性能与可用性——分片、副本、路由与聚合的调度逻辑与成本
大数据·elasticsearch·搜索引擎
G皮T1 天前
【Elasticsearch】查询性能调优(三):track_total_hits 和 terminate_after 可能的冲突
大数据·elasticsearch·搜索引擎·全文检索·索引·性能·opensearch
QT 小鲜肉1 天前
【Linux命令大全】001.文件管理之lsattr命令(实操篇)
linux·运维·服务器·笔记·elasticsearch
递归尽头是星辰1 天前
Elasticsearch实战:检索优化、聚合分析与架构落地体系化
大数据·elasticsearch·架构·检索优化·聚合分析
Dxy12393102161 天前
Elasticsearch 8.13.4 动态同义词实战全解析
大数据·elasticsearch
AC赳赳老秦1 天前
农业智能化:DeepSeek赋能土壤与气象数据分析,精准预测病虫害,守护丰收希望
java·前端·mongodb·elasticsearch·html·memcache·deepseek
Huazzi.1 天前
使用Scoop安装Git
git·elasticsearch·gitee·ssh·github·scoop