一、背景
Elasticsearch 有三种分页方式:
| 方式 | 适用场景 | 深度分页 | 一致性快照 |
|---|---|---|---|
from + size |
前端翻页,浅层 | ❌ 默认最多 10000 条 | ❌ 每次查询数据可能变 |
scroll |
后台全量导出 | ✅ | ✅ 快照时间点冻结 |
PIT + search_after |
实时深度分页 / 全量遍历 | ✅ | ✅ 快照时间点冻结 |
其中 Scroll 和 PIT + search_after 都能解决"深度分页 + 快照一致性"问题,但设计思路和适用场景差异明显。Scroll 从 ES 7.x 开始官方不再推荐,PIT + search_after 已成为主流。
二、Scroll 是什么
2.1 工作原理
- 首次查询时传
scroll=1m,ES 为本次查询在服务端保留一份快照上下文 (scroll context),返回_scroll_id - 后续每次请求带上
_scroll_id,ES 从快照位置继续往下拉取 - 快照在 TTL(如 1 分钟)内不会释放,期间即使原始索引发生 CRUD,也不影响本次遍历
关键词:服务端有状态 、上下文存活于 TTL 内 、快照冻结所有段(segments)。
2.2 代码示例(Java High Level REST Client)
java
import org.elasticsearch.action.search.*;
import org.elasticsearch.client.*;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.builder.SearchSourceBuilder;
public class ScrollExample {
public void exportAllSkus(RestHighLevelClient client) throws Exception {
// 1. 首次查询,建立 scroll 上下文
SearchRequest searchRequest = new SearchRequest("store_sku_index");
searchRequest.scroll(TimeValue.timeValueMinutes(1L));
SearchSourceBuilder ssb = new SearchSourceBuilder();
ssb.query(QueryBuilders.termQuery("storeId", 1017395374L));
ssb.size(1000);
searchRequest.source(ssb);
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
String scrollId = response.getScrollId();
try {
// 2. 处理首批数据
handleHits(response.getHits().getHits());
// 3. 循环拉取剩余数据
while (response.getHits().getHits().length > 0) {
SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId);
scrollRequest.scroll(TimeValue.timeValueMinutes(1L)); // 续期 TTL
response = client.scroll(scrollRequest, RequestOptions.DEFAULT);
scrollId = response.getScrollId();
handleHits(response.getHits().getHits());
}
} finally {
// 4. 主动释放 scroll 上下文,避免占用节点资源
ClearScrollRequest clearRequest = new ClearScrollRequest();
clearRequest.addScrollId(scrollId);
client.clearScroll(clearRequest, RequestOptions.DEFAULT);
}
}
private void handleHits(SearchHit[] hits) {
for (SearchHit hit : hits) {
// 业务处理
}
}
}
2.3 缺点
- 不适合并发:同一个 scroll_id 只能串行消费
- 资源开销大:节点上要冻结整片段文件,占用磁盘 inode/文件句柄;并发多个 scroll 会拖垮集群
- 无法跳转:只能顺序往后翻,不能"从第 100 页跳到第 500 页"
- 官方不推荐用于实时业务:ES 7.10 引入 PIT 后,scroll 定位为纯粹的后台批量导出工具
三、PIT + search_after 是什么
3.1 工作原理
- 通过
POST /{index}/_pit?keep_alive=1m单独创建 一个时间点视图(Point In Time),返回pit_id - 查询请求不带
indexpath,只在 body 里带pit.id和search_after(上一批最后一条命中的 sort 值) - ES 从
search_after之后的位置继续查,保持在同一个 pit 快照上 - 处理完主动
DELETE /_pit释放
关键词:pit_id 独立创建 、每次查询无状态(客户端持有游标) 、多个客户端可并行使用同一个 pit。
3.2 代码示例(Java High Level REST Client)
java
import org.elasticsearch.action.search.*;
import org.elasticsearch.client.*;
import org.elasticsearch.client.core.*;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.builder.PointInTimeBuilder;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortOrder;
public class PitSearchAfterExample {
public void exportAllSkus(RestHighLevelClient client) throws Exception {
// 1. 创建 PIT
OpenPointInTimeRequest openReq =
new OpenPointInTimeRequest("store_sku_index")
.keepAlive(TimeValue.timeValueMinutes(1L));
OpenPointInTimeResponse openResp = client.openPointInTime(openReq, RequestOptions.DEFAULT);
String pitId = openResp.getPointInTimeId();
try {
Object[] searchAfter = null;
while (true) {
SearchSourceBuilder ssb = new SearchSourceBuilder();
ssb.query(QueryBuilders.termQuery("storeId", 1017395374L));
ssb.size(1000);
// ⚠️ 排序字段必须是 unique 且稳定(通常业务字段 + _shard_doc 或 _id 兜底)
ssb.sort("skuId", SortOrder.ASC);
ssb.sort("_shard_doc", SortOrder.ASC);
// ⚠️ 使用 PIT 时,SearchRequest 不指定 index
ssb.pointInTimeBuilder(
new PointInTimeBuilder(pitId).setKeepAlive(TimeValue.timeValueMinutes(1L)));
if (searchAfter != null) {
ssb.searchAfter(searchAfter);
}
SearchRequest req = new SearchRequest();
req.source(ssb);
SearchResponse resp = client.search(req, RequestOptions.DEFAULT);
SearchHit[] hits = resp.getHits().getHits();
if (hits.length == 0) break;
for (SearchHit hit : hits) {
// 业务处理
}
// 用最后一条命中的 sort 值作为下一页的游标
searchAfter = hits[hits.length - 1].getSortValues();
// PIT 可能会自动续期(ES 服务端在 keep_alive 内每次使用会续期)
pitId = resp.pointInTimeId(); // ES 7.12+ 支持自动返回新的 pit_id
}
} finally {
// 2. 主动关闭 PIT
ClosePointInTimeRequest closeReq = new ClosePointInTimeRequest(pitId);
client.closePointInTime(closeReq, RequestOptions.DEFAULT);
}
}
}
3.3 优点
- 无服务端会话:pit_id 只是快照标识,查询本身无状态,天然支持并发/断点续查
- 资源占用小:pit 不冻结段文件,只在 refresh 时保留必要的引用
- 可跳转:只要有 sort 值,就能从任意位置继续,不必顺序遍历
- 可组合聚合、可控 sort:比 scroll 更灵活
3.4 缺点
- sort 字段必须全局唯一稳定 :如果 sort 值有重复,可能漏数据或重复。通常用业务字段 +
_shard_doc兜底 - 写入频繁的索引会看到"旧版本":PIT 期间新写入的文档不会出现在遍历中(和 scroll 一样)
- 需要客户端管理游标:每次调用要传上一批的 sort 值,断线要重传
四、核心差异对比
| 维度 | Scroll | PIT + search_after |
|---|---|---|
| 服务端状态 | 有 scroll context | 只有 pit,查询本身无状态 |
| 并发消费 | ❌ 单线程串行 | ✅ 多线程/多节点可并行 |
| 游标持有方 | 服务端 (_scroll_id) |
客户端 (search_after 数组) |
| 资源开销 | 高(冻结段) | 低(只保留引用) |
| 能否跳转 | ❌ 只能顺序 | ✅ 只要有 sort 值 |
| 推荐版本 | 逐步废弃(ES 7+ 建议迁移) | ES 7.10+ 主流方案 |
| 续期方式 | 每次请求带 scroll TTL | keep_alive 由每次请求自动续期 |
| 适用场景 | 后台一次性全量导出 | 深度分页、实时数据 sync、并行导出 |
五、实际应用场景
5.1 用 Scroll 的场景
- 定时任务全量导出:每晚 T+1 把某索引 dump 到数仓/Excel,串行、单机、TTL 够长即可
- 数据迁移 reindex:从旧集群/旧索引一次性拉取全量数据
- 对并发无要求 + 逻辑简单
5.2 用 PIT + search_after 的场景
-
实时业务的深度分页:C 端下拉列表翻页(如京东到家门店 SKU 翻页 100+ 页)
-
并行数据导出:多个 worker 各自持有 sort 起点,并行消费同一个 pit
-
失败重试友好:客户端保存 sort 值就能断点续传,不需要重新遍历
这里的接口实际只用
searchAfter而未使用pitId(见前文实验结果),说明该接口选择了 无 PIT 的 search_after 变种------牺牲快照一致性,换取 pit 生命周期管理成本,适合"允许少量脏读"的分页场景。
5.3 变种:无 PIT 的 search_after
如果业务能容忍"分页期间数据被写入导致的漂移",可以完全不用 pit,只用 sort + search_after。ES 本身也支持这种模式:
java
SearchSourceBuilder ssb = new SearchSourceBuilder();
ssb.query(...);
ssb.sort("skuId", SortOrder.ASC);
ssb.size(50);
if (searchAfter != null) ssb.searchAfter(searchAfter);
// 注意:不设置 pointInTimeBuilder,SearchRequest 保留 index
SearchRequest req = new SearchRequest("store_sku_index");
req.source(ssb);
代价:分页期间新写入的文档可能穿插进结果,或旧文档删除后跳过。收益:零快照开销,pit 生命周期不用管。京东到家 searchAfterStoreSkus 用的就是这种。
六、迁移建议
如果历史代码还在用 scroll,考虑以下迁移路径:
- 一次性后台任务 → 保留 scroll(简单够用)
- C 端翻页 / 实时业务 → 迁移到 PIT + search_after,添加
_shard_doc兜底 sort - 允许脏读的分页 → 用无 PIT 的 search_after(sort + search_after)
迁移时注意事项:
- Scroll 是服务端游标,客户端只要 scroll_id 即可继续
- PIT + search_after 需要客户端保存 sort 值,重启/失败后能否恢复取决于是否持久化了 sort 值
- 排序字段一定要唯一稳定,推荐:业务主键 +
_shard_doc(或_id)
七、快速决策清单
- 你要导全量、串行 OK、离线任务 ? → Scroll
- 你要实时翻页、并发消费、支持断点续传 ? → PIT + search_after
- 你要分页但允许期间数据漂移、不想管快照生命周期 ? → 无 PIT 的 search_after
- 你要只翻前几页(< 10000) ? → from + size 就够了