40 · 深分页与性能(from/size、search_after、scroll、PIT)
阶段:第五阶段 / 进阶与实战
ES:
from/size上限、search_after、scroll、PIT | PostgreSQL:LIMIT/OFFSET+ 键集分页
1. 概念
翻页有三条路线:
| 方式 | 适用场景 | SQL 对照 |
|---|---|---|
from + size |
前几页、浅分页(默认上限 10000) | LIMIT ? OFFSET ? |
search_after |
深分页、无限下拉、导出 | 键集分页(keyset) |
| scroll | 大批量一次性导出(旧方案) | 游标 cursor |
PIT + search_after |
深分页导出(推荐新方案) | 一致性快照 + 键集 |
2. PostgreSQL 对照
sql
-- 浅分页(OFFSET 越大越慢)
SELECT * FROM salesdata ORDER BY id LIMIT 20 OFFSET 100000;
-- 键集分页(推荐,稳定快)
SELECT * FROM salesdata
WHERE id > :lastId
ORDER BY id
LIMIT 20;
3. ES DSL
from/size(浅分页)
GET salesdata_idx/_search
{ "from": 20, "size": 10, "sort": [ { "record_id": "asc" } ] }
search_after(深分页)
GET salesdata_idx/_search
{
"size": 1000,
"sort": [ { "record_id": "asc" } ],
"search_after": [ "last-record-id-from-prev-page" ]
}
PIT + search_after(一致性深分页)
POST salesdata_idx/_pit?keep_alive=1m # 拿到 pit id
GET _search
{
"pit": { "id": "<pit-id>", "keep_alive": "1m" },
"size": 1000,
"sort": [ { "record_id": "asc" } ],
"search_after": [ "..." ]
}
4. Spring Boot 实现
java
@Component
public class Doc40DeepPaging {
@Autowired
private ElasticsearchClient elasticsearchClient;
/** search_after 全量遍历:稳定深分页,适合导出 */
public void scanAll(String indexName, Consumer<Map<String, Object>> consumer) throws IOException {
List<FieldValue> searchAfter = null;
while (true) {
List<FieldValue> after = searchAfter;
SearchResponse<Map> resp = elasticsearchClient.search(s -> {
s.index(indexName)
.size(1000)
.sort(so -> so.field(f -> f.field("record_id").order(SortOrder.Asc)));
if (after != null) {
s.searchAfter(after);
}
return s;
}, Map.class);
var hits = resp.hits().hits();
if (hits.isEmpty()) {
break;
}
hits.forEach(h -> { if (h.source() != null) consumer.accept(h.source()); });
// 用最后一条的排序值作为下一页游标
searchAfter = hits.get(hits.size() - 1).sort();
}
}
/** PIT + search_after:一致性快照下的全量遍历(推荐新方案) */
public void scanAllWithPit(String indexName, Consumer<Map<String, Object>> consumer)
throws IOException {
// 1) 开 PIT,拿到一致性快照,遍历期间数据变化不会漏/重
String pitId = elasticsearchClient.openPointInTime(p -> p
.index(indexName)
.keepAlive(t -> t.time("2m"))).id();
try {
List<FieldValue> searchAfter = null;
while (true) {
List<FieldValue> after = searchAfter;
SearchResponse<Map> resp = elasticsearchClient.search(s -> {
s.size(1000)
.pit(p -> p.id(pitId).keepAlive(t -> t.time("2m"))) // 注意:带 PIT 时不要再写 index
.sort(so -> so.field(f -> f.field("record_id").order(SortOrder.Asc)));
if (after != null) {
s.searchAfter(after);
}
return s;
}, Map.class);
var hits = resp.hits().hits();
if (hits.isEmpty()) {
break;
}
hits.forEach(h -> { if (h.source() != null) consumer.accept(h.source()); });
searchAfter = hits.get(hits.size() - 1).sort();
}
} finally {
// 2) PIT 用完必须释放,异常也要清理
String finalPit = pitId;
elasticsearchClient.closePointInTime(c -> c.id(finalPit));
}
}
}
import:
co.elastic.clients.elasticsearch._types.FieldValue、...SortOrder。带 PIT 时索引信息已在 PIT 里,
SearchRequest不要再写.index(...)。
5. 坑与最佳实践
from + size上限 10000 :超过报错,深翻页必须换search_after。search_after需要稳定排序 :排序字段要唯一(加record_id兜底),否则会漏/重。- scroll 已不推荐新用 :占资源、快照久;导出优先 PIT +
search_after。 - PIT 用完要释放 :
DELETE _pit,否则占用段资源。 - 深分页本质贵:能用条件缩小范围就别硬翻页;导出场景用批量扫描。