terms 聚合的计数和排名不对
主线位置:查询主线第四、五步。
AggregationCollector在每个段上算出中间结果,merge_fruits再把各段合起来。terms 聚合在段这一步就截断了,合并时看不到被截掉的桶。整条流程和一个小规模的实测见聚合。
flowchart TB s1["段 1 的 31 个值"] --> c1["只交前 30 个"] s2["段 2 的 31 个值"] --> c2["只交前 30 个"] sn["其余 18 个段"] --> cn["各交前 30 个"] c1 --> m["按值合并"] c2 --> m cn --> m m --> top["取前 3"] top --> miss["G 每段第 31 全部丢失"]
实验在 exp-tantivy/pitb-lab,bin p27_agg(release):k 是 STRING | FAST;20 次提交(NoMergePolicy)得到 20 个段,每个段有 30 个本段独有的值各 6 篇,外加值 G 5 篇。G 全局 100 篇,是最多的值,但在每个段里都只排第 31。这是为了把问题放大而构造的数据。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。
现象
请求是 {"terms": {"field": "k", "size": 3}},其余全用默认值:
segments=20 docs=3700 true count of G=100
default : {"t":{"buckets":[{"key":"s00v00","doc_count":6},{"key":"s00v01","doc_count":6},{"key":"s00v02","doc_count":6}],"sum_other_doc_count":3682,"doc_count_error_upper_bound":100}}
全局最多的 G(100 篇)不在结果里,前三名是三个只有 6 篇的值。没有报错。
原因
TermsAggregation 的 segment_size 没写时,from_req 取 size * 10,至少等于 size。每个段收完文档后,into_intermediate_bucket_result 按计数选出前 segment_size 个桶,交给 cut_off_buckets 截断,其余的只留下两个数:被截掉的第一个桶的计数(term_doc_count_before_cutoff),和被截掉的文档总数(sum_other_doc_count)。
这里 size 是 3,每个段只交 30 个桶。G 在每个段都是第 31,20 个段全部把它截掉,合并时它一篇都没有。返回结果里有两个信号:
doc_count_error_upper_bound是各段「被截掉的第一个桶」的计数之和,20 × 5 = 100,表示返回的计数最多可能少算 100。sum_other_doc_count是 3682 = 3700 − 18,G的 100 篇就在里面。
这和 Elasticsearch 在分片上的近似是同一个道理,tantivy 的「分片」是段。单机索引同样有很多段:写入线程数、提交次数、合并策略都会改变段的数量和每个段里的值分布,所以同一份数据、同一个请求,结果可能随段的布局变化。
修法
调大 segment_size。 每个段交出的桶数不少于这个段里不同值的个数,结果就是精确的:
segment_size 31 : {"t":{"buckets":[{"key":"G","doc_count":100},{"key":"s00v00","doc_count":6},{"key":"s00v01","doc_count":6}],"sum_other_doc_count":3588,"doc_count_error_upper_bound":0}}
segment_size 1000 : {"t":{"buckets":[{"key":"G","doc_count":100},{"key":"s00v00","doc_count":6},{"key":"s00v01","doc_count":6}],"sum_other_doc_count":3588,"doc_count_error_upper_bound":0}}
代价是每个段交出、合并的桶更多,值的种类很多时内存和时间都会涨;聚合有默认 65000 个桶、500 MB 的上限(聚合)。它也认 Elasticsearch 的 shard_size 这个名字。
合并成更少的段。 把 20 个段合成 1 个:
merged (1 segment): {"t":{"buckets":[{"key":"G","doc_count":100},{"key":"s00v02","doc_count":6},{"key":"s00v03","doc_count":6}],"sum_other_doc_count":3588,"doc_count_error_upper_bound":6}}
G 回来了,计数准确。
问: 只剩一个段了,为什么 doc_count_error_upper_bound 还是 6?第二、三名也从 s00v00、s00v01 换成了 s00v02、s00v03。
答: 一个段里有 601 个不同的值,仍然多于 segment_size 的 30,截断照做,被截掉的第一个桶有 6 篇,上界就报 6。这时它只是一个保守的上界:只有一个段,没有别的段的计数需要合并,留下的每个桶的计数都是准的。第二、三名换了人,是因为 600 个值都是 6 篇,截断用的是 select_nth_unstable,同分的谁留下没有保证。
怎么检查
- 看每次结果里的
doc_count_error_upper_bound:大于 0,说明有段截断过,返回的计数可能偏小,排名可能有误;和返回的最后一名的计数比一比,上界比它还大时,排名就不可靠。 show_term_doc_count_error只在按计数降序(默认)时默认打开;按键或按子聚合排序时要显式设为true才看得到这个数。- 对重要的报表,拿一个大
segment_size的请求和默认请求对比一次。
小结
- terms 聚合每个段只交前
segment_size个桶(默认size × 10),合并时看不到被截掉的部分;全局最多的值可能在每个段都刚好落选,从结果里消失。 doc_count_error_upper_bound是各段被截掉的第一个桶的计数之和,sum_other_doc_count包含被截掉的文档。- 修法:调大
segment_size(不少于每段的不同值个数时精确),或合并段;同分时的取舍没有保证。