按用户 ID 分组统计时,我遇到过超过 10 万个分组的需求。terms 聚合只返回有限数量的桶,简单把 size 加到 10000 仍然不够;文档查询的 from、search_after 和 scroll 也不能直接用来翻聚合桶。

当时怎样按分组键推进

当时分组字段是可比较大小的 Snowflake ID,于是按键升序读取一批桶,再把最后一个键作为下一次文档过滤的下界。核心思路如下,属于旧 Java High Level REST Client 风格片段:

String groupField = "user_id";

if (lastSortValue != null) {
    boolQueryBuilder.filter(
        QueryBuilders.rangeQuery(groupField).gt(lastSortValue)
    );
}

searchSourceBuilder.query(boolQueryBuilder).size(0);
searchSourceBuilder.aggregation(
    AggregationBuilders.terms("group_cnt")
        .field(groupField)
        .order(BucketOrder.key(true))
        .size(1000)
);

处理桶后保存最后一个键。循环还要处理空结果、失败重试和停止条件,不能把这个片段当作完整程序。

这个办法有严格前提

过滤字段必须与分组字段相同,排序与 range 比较必须一致;还应确保字段的单值语义和读取期间的数据边界。按地区分组却按 user_id 推进,会改变统计范围,不能复用。

Snowflake ID 如果以字符串处理,要避免字典顺序与数值顺序混淆;在 JavaScript 等环境中也不能丢失大整数精度。多值字段、并发更新或删除,都可能破坏原来设想的跨页语义。

原生聚合分页:composite

需要遍历全部桶时,可以优先评估 composite aggregation。下面以可聚合的单值 user_id 为例,使用 ES 7.3 文档中的接口形式:

GET /example_users/_search
{
  "size": 0,
  "aggs": {
    "users": {
      "composite": {
        "size": 1000,
        "sources": [
          { "user_id": { "terms": { "field": "user_id", "order": "asc" } } }
        ]
      }
    }
  }
}

下一页保持 query 和 sources 等条件不变,把响应中的 aggregations.users.after_key 整个对象作为 composite 的 after。例如返回的游标为:

{ "user_id": 123456789 }

则将下面对象里的 after 属性合并到下一次请求的 composite 对象中,保留原来的 size、sources 和其他查询条件:

{
  "after": { "user_id": 123456789 }
}

应使用服务端返回的游标,而不是自行猜测。按响应推进直至遍历结束,并记录已处理位置。ES 7.3 Composite Aggregation

composite 不是按任意统计值排序的通用分页,也不会自动冻结多次请求之间的数据。上线前仍需在固定测试数据上核对桶数、计数、重复与遗漏,并针对真实数据规模评估成本。