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