在上一篇文章 通过某瓣真实案例看 Elasticsearch 优化 写了最近获得的一些优化 Elasticsearch (以下简称 ES) 的经验,也把这些分享给厂内使用 ES 的同事和萨 (SA)。
讨论中萨同事提了一个问题:
话说项目有 topK 这种聚合请求么?记得多分片情况下请求参数不合理可能出现不准确的聚合结果
我当时看完的第一反应是「啥?」,但是同事一提我突然隐约想起来曾经在什么地方看过这个问题。然后一顿搜索找到了官方文档的说明 (延伸阅读链接 1),我详细的说说
聚合的结果不准确的原因
我们假设要聚合符合某要求的 N 个结果 (也就是请求参数中的 size),ES 集群分片数为 S。
ES 分发聚合请求到所有的分片上单独处理,最后汇总结果。在单个分片的聚合过程中会把每个要聚合的字段的值作为键 (key) 放在一个桶 (bucket),如果文档的这个字段中包含这个值,则桶的文档数 (doc_count)+1。最终把每个分片符合要求的前 N 个桶作为结果返回,ES 汇总这S * N
结果,最终从中找文档数最高的 N 个结果。但是这个逻辑有个问题,大家先思考下,看看能不能找到。
其实文档中举个一个非常清晰的例子,大家可以仔细去理解。其实原因简单说:
由于 N 的限制,如果某个 (些) 分片包含的某个 key 很少 (但是其他分片包含的 key 的文档数多),没有进入前 N,最终计算时就没有考虑这个 (些) 分片这个 key 的那部分而引起数据不准。
这个数据不准分 2 种,我分别举例 (3 个分片,取前 5)
漏
Shard 1 | Shard 2 | Shard 3 |
---|---|---|
A(100) | A(100) | A(100) |
B(99) | B(99) | B(99) |
C(98) | C(98) | C(98) |
D(97) | D(97) | D(97) |
E(10) | F(12) | F(14) |
F(8) | E(10) | E(10) |
为了明确效果,前 4 名没有争议,主要看第 5 和第 6。由于 size 是 5,所以Shard 1
的 F 没有进入前 5 被忽略了。但实际上 F (8 + 12 + 14 = 34) 要比最终入选的第 5 名 E (10 + 10 + 10 = 30) 要高,但是由于 size 的限制F(8)
被彻底忽略了,造成漏了 F 而选了 E。
文档数不对
Shard 1 | Shard 2 | Shard 3 |
---|---|---|
A(100) | A(100) | A(100) |
B(99) | B(99) | B(99) |
C(98) | C(98) | C(98) |
D(97) | D(97) | D(97) |
E(10) | F(18) | F(14) |
F(8) | E(10) | E(10) |
还是差不多的效果,只动了F(12) -> F(18)
,这样最终 F 依然是第 5 (F32> E30),它的入选是有在Shard 2
和Shard 3
的总分太高了,但是 F 的文档数 32 (18 + 14) 是不对的,因为根本就没数Shard 1
的 8 个。
现在大家理解问题所在了么?
解决方案
看完文档我第一感觉就是这个功能有问题,和对应产品开发一聊才知道之前有用户反馈过包含某标签的条目数不对的问题,我用自己的账号带着问题用了一下这个功能,发现问题确实很明显,竟然有高达 1/4 的数据不准。仔细想想,这个功能从一上线就是有问题的 (当时是5 Shards
)。
以我对用户的了解,绝大部分用户确实不太会关注这个条目数,所以有所误差是可以接受的,不过本着做好每件事的心态,我门是应该做到最好的,于是开始想解决方案:
- 单个分片。当所有数据在一个 Shard 上时聚合是完全准确的。
- 使用更高的
shard_size
值。ES 提供了shard_size
参数,默认是size * 1.5 + 10
,可以加大这个值使每一个分片节点返回更多的冗余数据,这样就能提高聚合结果的准确性。这个值越大结果会越接近准确,直到这个值能覆盖全部数据就可以做到完全准确了。
在上篇文章做的优化中,为了减少超时量改成了9 Shards + 1 Replica
,这个方案比之前尝试的1 Shard + 1 Replica
效果要好。但是现在遇到了数据不准确的问题,那么单个分片这个效果差一些的方案又成了备选,而且在之前我也做过功课,单分片最大占用空间在 40-50G,不同场景要具体测试。对于我们这个例子单分片在目前和未来 2 年内这个容量是安全的。不过这是最后一条路,我还是先试试改大shard_size
看看效果。
之前请求 body 时这样的:
{ '_source': False, 'aggs': { 'by_standard_tags': { 'terms': { 'execution_hint': 'map', 'field': 'standard_tags.keyword', 'size': 100 } } }, 'query': { 'bool': { 'filter': [ {'ids': {'values': IDS}} ] } } } |
改之后是这样的:
{ '_source': False, 'aggs': { 'by_standard_tags': { 'terms': { 'execution_hint': 'map', 'field': 'standard_tags.keyword', 'shard_size': max(len(IDS) * 4, 160), # 加了这句 'size': 100 } } }, 'query': { 'bool': { 'filter': [ {'ids': {'values': IDS}} ] } } } |
max(len(IDS) * 4, 160)
这句里面 160 是默认值(100 * 1.5. + 10)
,为了让单个分片返回更多数据shard_size
的值是动态的:IDS长度 * 4
,这个值是解决我和几个比较典型的用户的 badcase 之后找到的一个比较合理的方案,我打算先上线看看效果再调优,结果上线后:
- 没有增加超时。也就是加大了
shard_size
值之后基本没有影响 ES 集群负载,不过应该占了用更多的内存。 - 效果明显。看了一些用户数据,基本完美解决。效果差的地方主要在于一些标记量少 (两位数级别) 的用户的尾部数据 (第 80-100 位) 有问题,符合文档数在 2-4 个时容易有问题,标记量多的用户由于头部数据量整齐基本没有问题。
虽然这个方案没有完美,但是已经让 99.9% 的数据正确了。这里要引出我的一个观点:
对于大多数业务场景,能够实时返回高度准确的结果要比完全精确结果重要得多
考虑 API 请求耗时的差别和用户体验的影响等影响,这整体约 0.1%(甚至可以更低) 的误差是可以接受的。
另外我也找对应产品开发沟通,由于这个值的改动对于超时基本无影响,未来再收到用户反馈我们还可以任性的再提高shard_size
的值,相信可以让正确率变得更高,不过暂时看,道路是光明的。
关于文档计数错误
在延伸阅读链接 1 中有一个小节专门介绍文档计数错误 (Calculating Document Count Error),其中提到了在返回结果的 aggregations 中包含了doc_count_error_upper_bound
(没有在这次聚合中返回、但是可能存在的潜在聚合结果) 和sum_other_doc_count
(聚合中没有统计到的文档数)。要注意其中的doc_count_error_upper_bound
键名有「上界」的意思,也就是表示在预估的最坏情况下沒有被算进最终结果的值,当然doc_count_error_upper_bound
的值越大,最终数据不准确的可能性越大,能确定的是,它的值为 0 表示数据完全正确,但是它不为 0,不代表这次聚合的数据是错误的,大家要记住。
另外还可以在请求里面加show_term_doc_count_error=true
参数,这样返回的结果中,以桶 (bucket) 为单位显示一个错误数,表示最大可能的误差。