分页与聚合是 ES 里最容易被"能跑就行"糊过去的两个功能。翻到第 100 页才报错、terms 取 1000 条以为拿到全集、cardinality 当成精确去重——这三件事的共同点是:结果不报错,但数字是错的。
下面按"翻页 → 遍历 → 聚合 → 准确性 → 内存"走:四种分页 → 深分页机制 → search_after → scroll/PIT → 聚合模型 → terms 误差 → cardinality → composite → 成本。
1. 四种分页方式的适用面
| 方式 | 能跳页 | 一致性 | 资源占用 | 适用 |
|---|---|---|---|---|
from + size |
能 | 无快照,翻页期间写入会漂移 | 每分片取 from+size 条 |
前 10 页、后台列表 |
search_after |
不能 | 配合 PIT 才有快照 | 轻,只取一页 | 无限下拉、API 游标 |
scroll |
不能 | 有快照 | 重,每分片挂一个上下文 | 全量导出、离线任务 |
PIT + search_after |
不能 | 有快照 | 中,快照有 keep_alive | 8.x 推荐的深分页 |
GET /article/_search
{ "from": 20, "size": 10,
"query": { "term": { "status": "published" } },
"sort": [ { "created": "desc" } ] }
2. from + size 与 max_result_window
from: 10000 会报 Result window is too large,因为 index.max_result_window 默认 10000。机制不是"只取一页":协调节点向每个分片都要 from+size 条,归并排序后切出 [from, from+size)。所以 from=10000&size=10 在 5 分片下要搬 50050 条文档,堆与网络都按这个量级计费。
| 做法 | 代价 | 结论 |
|---|---|---|
调大 max_result_window |
每分片搬运量线性上升,堆风险 | 只适合临时救急 |
改 search_after |
需要唯一排序键 | 首选 |
改 PIT + search_after |
需要管理 keep_alive | 首选(有一致性要求时) |
| 前端限制最大页数 | 用户体验受限 | 列表页的务实解 |
3. search_after:排序键必须唯一
GET /article/_search
{ "size": 10,
"sort": [ { "created": "desc" }, { "_id": "asc" } ],
"search_after": [ "2026-11-09T10:00:00Z", "aXbYcZ" ] }
三个硬约束:排序字段必须全局唯一可定序,否则同值文档会重复或漏掉(用 _id、_seq_no 或业务唯一键做 tiebreaker);不能跳页,只能"下一页";翻页期间有写入时需要 PIT 固定视图,否则新文档插入会让游标错位。
_id 排序在 8.x 可用但会走 fielddata 语义,成本高于普通字段。高吞吐场景更推荐 _seq_no(每分片单调递增,天然唯一)。
4. scroll 与 PIT:遍历全量
POST /article/_search?scroll=1m
{ "size": 1000, "query": { "match_all": {} }, "sort": [ "_doc" ] }
POST /_search/scroll
{ "scroll": "1m", "scroll_id": "DXF1ZXJ5..." }
DELETE /_search/scroll
{ "scroll_id": "DXF1ZXJ5..." }
scroll 首次请求就在每分片保留一个搜索上下文(segment 快照 + 文件句柄),直到超时或显式清理。忘记 DELETE 的后果是堆和句柄一起涨——导出任务务必在 finally 里清理。
PIT 是 8.x 的替代路线:
POST /article/_pit?keep_alive=2m
GET /_search
{ "size": 1000,
"pit": { "id": "46ToAw...", "keep_alive": "2m" },
"sort": [ { "created": "asc" }, { "_seq_no": "asc" } ] }
PIT 的好处是可以用普通 _search 配合 search_after 反复翻页,不必走 _search/scroll;代价是要自己管 keep_alive 与关闭。
5. 聚合模型:bucket、metric、pipeline
| 类别 | 代表 | 回答的问题 |
|---|---|---|
| bucket | terms、date_histogram、range、filter |
分成哪些组、每组多少 |
| metric | avg、sum、stats、percentiles、cardinality |
组内的数值统计 |
| pipeline | derivative、cumulative_sum、bucket_script、bucket_sort |
在已有桶上再算 |
GET /article/_search
{ "size": 0,
"query": { "range": { "created": { "gte": "now-30d" } } },
"aggs": {
"by_author": { "terms": { "field": "author", "size": 20 },
"aggs": { "avg_len": { "avg": { "field": "word_count" } } } }
} }
size: 0 表示只要聚合结果、不要 hits,能省掉取 _source 与打分的开销(打分仍会做,只是不返回)。
6. terms 聚合的准确性陷阱
terms 是分布式近似:每个分片只上报自己前 shard_size 个桶,协调节点归并后取前 size 个。所以:
doc_count_error_upper_bound:未上报桶的真实计数可能达到的最大偏差;sum_other_doc_count:被截掉的桶的文档总数——它不为 0 就说明"top N 之外还有东西"。
| 参数 | 默认 | 调大的后果 |
|---|---|---|
size |
10 | 返回更多桶,不改变精度上限 |
shard_size |
size × 1.5 + 10(启发式) |
精度上升,内存与网络上升 |
doc_count_error_upper_bound |
只读 | 为 0 才代表结果确定 |
要精确计数只有三条路:把 shard_size 调到远大于基数(不现实);用 composite 全量遍历;或让同值文档落在同一分片(按该字段 routing),但会牺牲分布均衡。
7. cardinality:HLL 的近似
"aggs": { "uniq_users": { "cardinality": { "field": "user_id", "precision_threshold": 40000 } } }
cardinality 走 HyperLogLog:低于 precision_threshold(默认 3000,上限 40000)时误差极小,超过之后误差通常在百分之几量级(按规模核对),且只减不增不成立——它是估计,不是计数。
| 需求 | 做法 |
|---|---|
| 看趋势、做监控 | cardinality 足够 |
| 精确 UV、计费 | 换 composite 遍历、或外部去重存储 |
| 需要按维度去重计数 | cardinality 支持子聚合,但仍是近似 |
8. composite:全量分桶的替代
GET /article/_search
{ "size": 0,
"aggs": { "pages": { "composite": { "size": 1000,
"sources": [ { "day": { "date_histogram": { "field": "created", "calendar_interval": "day" } } },
{ "user": { "terms": { "field": "user_id" } } } ] } } } }
返回里带 after_key,下一次把它塞进 after 继续翻,直到没有 after_key。特点:每页结果确定、支持多字段组合、按字典序稳定;代价是不能排序、不能按 doc_count 取 top N。
composite 是"取全量分桶"的正解,也是替代"terms 取 1000 条"的唯一稳妥方式。
9. 内存与超时
| 限制 | 默认 | 触发后的表现 |
|---|---|---|
search.max_buckets |
65536 | too_many_buckets_exception |
indices.breaker.request.limit |
堆的 60% | circuit_breaking_exception |
search.default_search_timeout |
无 | 需显式设置,否则长查询一直跑 |
降桶数的顺序:先加 query 过滤缩小命中文档 → 用 date_histogram 替代高基数 terms → 给 terms 设合理 size → 最后才动 max_buckets。
10. 先过滤再聚合
聚合只对 query 命中的文档计算,所以能放进 query 的条件不要放进 filter 聚合内部,更不要放进 post_filter。
| 位置 | 影响 hits | 影响 aggs |
|---|---|---|
query.bool.filter |
是 | 是(聚合前生效) |
post_filter |
是 | 否(聚合后才过滤) |
aggs.filter |
否 | 只影响该子聚合 |
post_filter 是给"高亮命中但结果按分面收窄"的场景准备的,拿它当普通过滤用,会让聚合统计包含被过滤掉的文档——这是分面数字对不上的常见原因。
nested 聚合更贵:每个子对象是一个隐藏文档,聚合要走 block join,再 reverse_nested 回到父级。子对象数量大时,优先考虑反范式。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
Result window is too large |
from+size 超 max_result_window |
改 search_after + PIT |
| 翻页出现重复文档 | 排序键不唯一、翻页期间有写入 | 加 tiebreaker、用 PIT |
terms 结果数字每次不同 |
shard_size 太小,分片抽样不一致 |
调大 shard_size 或改 composite |
sum_other_doc_count 很大 |
top N 之外还有大量桶 | 说明"取 top 1000 = 全集"是错的 |
too_many_buckets_exception |
高基数 terms/date_histogram 爆桶 |
先过滤、降基数、分批 |
| 分面数字与列表不一致 | 用了 post_filter |
条件前移到 query |
| 去重数与实际差几个百分点 | cardinality 是 HLL 估计 |
要精确就换方案 |
12. 边界
| 场景 | 该用什么 | 理由 |
|---|---|---|
| 列表前几页 | from + size |
简单,成本可控 |
| 无限滚动、API 游标 | PIT + search_after |
有快照、无深分页开销 |
| 全量导出到文件 | scroll 或 PIT 分批 |
顺序遍历,可断点 |
| 全量分桶统计 | composite |
每页确定,可翻完 |
| 精确 UV | 外部去重 / composite |
cardinality 只是估计 |
| 实时看板 | 加过滤的 terms + 容忍误差 |
延迟比精度更重要 |
一句话:分页问题的本质是"每分片都要多取一份",聚合问题的本质是"分片只上报自己看到的 top N"。
13. 自测
from=10000&size=10在 5 分片下,协调节点实际搬运多少条文档?为什么?search_after为什么必须配唯一 tiebreaker?不配会出现什么现象?scroll与 PIT 都提供快照,资源占用差在哪?谁需要显式清理?doc_count_error_upper_bound为 0 意味着什么?为 0 就一定是精确值吗?cardinality的precision_threshold调大,误差与内存各怎么变?post_filter为什么会让聚合数字与列表结果对不上?