2284 字
约 7 分钟
14
S13-X07 · 聚合与分页:terms、cardinality 与 scroll 场景

分页与聚合是 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. 自测

  1. from=10000&size=10 在 5 分片下,协调节点实际搬运多少条文档?为什么?
  2. search_after 为什么必须配唯一 tiebreaker?不配会出现什么现象?
  3. scroll 与 PIT 都提供快照,资源占用差在哪?谁需要显式清理?
  4. doc_count_error_upper_bound 为 0 意味着什么?为 0 就一定是精确值吗?
  5. cardinality 的 precision_threshold 调大,误差与内存各怎么变?
  6. post_filter 为什么会让聚合数字与列表结果对不上?
S13-X07 · 聚合与分页:terms、cardinality 与 scroll 场景
https://www.clxhxhhr.top/posts/5823/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。