Elasticsearch 实际工作中的分页方案详解
这篇文档只讲分页 ,而且是"线上真实能扛流量、不炸 ES 的分页"。
如果你还在
from + size翻 1000 页,这篇就是来救你的。
1. 为什么 Elasticsearch 的分页和 MySQL 完全不一样?
1.1 MySQL 的分页逻辑(你熟的)
sql
SELECT * FROM t LIMIT 100 OFFSET 9900;
1.2 Elasticsearch 的分页逻辑(重点)
ES 底层是 倒排索引 + 多分片:
- 每个 shard 都要:
- 查询
from + size条 - 排序
- 再合并结果
- 查询
- 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 必须满足的条件(很重要)
- 必须排序
- 排序字段 值唯一或组合唯一
- 通常用:
时间 + 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 只准用在浅分页。