ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Elasticsearch去重查询实战:collapse、terms聚合与cardinality选型指南

Elasticsearch去重查询实战:collapse、terms聚合与cardinality选型指南

1. 去重查询到底在解决什么问题

1.1 从一个真实场景说起

做过搜索或者日志分析的人,大概率都遇到过这种需求:索引里存了几十万条订单记录,同一个用户下了多单,现在要拉一份“下过单的用户名单”,每个用户只出现一次。或者商品库里有大量重复铺货的链接,运营要一份去重后的商品清单。再或者,埋点日志里同一个设备短时间内上报了多次,做统计时只希望每个设备算一次。

这些场景的共同点是:数据行本身是重复的或者有多个版本,但业务只关心“有哪些不同的值”。这时候如果直接写一个SELECT DISTINCT式的查询,在关系型数据库里很轻松,但在 Elasticsearch 里,思路完全不一样。

Elasticsearch 是一个分布式的搜索引擎,它的强项是全文检索和聚合分析,而不是传统意义上的行式去重。很多人第一次接触这个需求时,会本能地想“有没有类似 distinct 的关键字”,结果发现 ES 的查询 DSL 里根本没有这个东西。于是就开始踩坑:用collapse、用terms聚合、用脚本、用cardinality,每种方案出来的结果都不一样,性能也天差地别。

这篇内容就是把这几种主流方案掰开揉碎讲清楚,告诉你什么场景该用哪种,参数怎么调,坑在哪里。不管你是刚接触 ES 的新手,还是已经用过一段时间但没系统梳理过的开发者,都能从中找到可以直接抄作业的配置。

1.2 先搞清楚“去重”的两种语义

在动手之前,必须先把需求拆成两类,因为它们的实现路径完全不同。

第一类是去重后要拿到完整文档。比如“每个用户最新的那条订单记录”,你不仅要用户不重复,还要把这条记录的各个字段都取出来。这种叫“按字段折叠”,ES 里对应的是collapse功能。

第二类是只关心去重后的值本身或者数量。比如“一共有多少个不同的用户”“每个商品分类下有多少个不同的品牌”,这种不需要返回原始文档,只需要一个去重后的列表或者计数。这种对应的是聚合(aggregation),具体来说是terms聚合和cardinality聚合。

很多人把这两类混为一谈,导致选错方案。比如明明只要一个去重计数,却用了collapse把文档全捞出来再在应用层数一遍,数据量一大直接把内存打爆。反过来,明明要展示每个用户的最新订单详情,却只用了terms聚合,结果只拿到用户 ID,还得再回查一次,白白多一轮请求。

所以第一步永远是问自己:我到底要不要原始文档?要,走 collapse;不要,走聚合。这个判断决定了后面所有的技术选型。

1.3 为什么不用“先查出来再去重”

有些同学会说,我直接match_all把数据全查出来,然后在 Java 或者 Python 里用 Set 去重不就行了。小数据量确实可以,但一旦上了规模,这就是灾难。

假设索引里有 500 万条记录,每条文档平均 1KB,全量拉取就是 5GB 的数据要经过网络传输、反序列化、再进内存去重。先不说 ES 默认的from + size深分页限制(默认最多 10000 条),就算你用scroll或者search_after分批拉,应用层的 CPU 和内存也会被吃满。而且这种做法完全没有利用 ES 自身的分布式计算能力,相当于把一台集群干成了单机数据库。

ES 的聚合是在每个分片上先做局部计算,然后把结果汇总到协调节点做最终合并。这个过程中真正传输的是聚合后的中间结果,而不是原始文档。数据量可能从 500 万条降到几千条,网络和内存压力小了几个数量级。这就是为什么我们要用 ES 原生的去重能力,而不是把问题推给应用层。

提示:判断标准很简单,如果去重后的结果集大小远小于原始数据量,就应该让 ES 来做去重,而不是把原始数据搬到应用层。

2. 方案一:collapse 折叠查询拿完整文档

2.1 collapse 的工作原理

collapse是 ES 5.0 之后引入的功能,专门用来解决“按某个字段去重后返回文档”的需求。它的语法很简单,在查询体里加一个collapse块,指定field即可。

{ "query": { "match_all": {} }, "collapse": { "field": "user_id" } }

这段查询的意思是:在所有文档中,按照user_id字段折叠,每个不同的user_id只返回一条文档。注意,返回的是一条完整的文档,而不是只有user_id这个字段。

它的底层实现逻辑是:每个分片先执行查询,然后对命中结果按照 collapse 字段做局部折叠,每个分片返回每个 key 的一条文档(默认是排序后的第一条),协调节点再把各分片的结果合并,再次按 key 去重。所以最终结果里每个user_id只会出现一次。

这里有个关键点:collapse 字段必须是keyword类型或者开启了fielddata的字段。如果你拿一个text类型的分词字段去 collapse,ES 会直接报错。因为折叠需要精确的 doc values,而分词字段的 doc values 是按词项存储的,没法直接用来做唯一标识。这个坑非常常见,很多人建索引时把用户 ID 建成了text,结果 collapse 用不了,只能重建索引或者加一个keyword子字段。

2.2 折叠后取哪一条:inner_hits 的妙用

默认情况下,collapse 返回的是每个 key 排序后的第一条文档。但很多时候我们想要的不是随便一条,而是“最新的那条”或者“金额最大的那条”。这时候就需要配合排序和inner_hits。

{ "query": { "match_all": {} }, "collapse": { "field": "user_id", "inner_hits": { "name": "latest_order", "size": 1, "sort": [ { "order_time": "desc" } ] } }, "sort": [ { "order_time": "desc" } ] }

这段查询做了两件事:外层sort保证每个分片折叠时取到的是该分片内order_time最新的那条;inner_hits则允许你在每个折叠结果里再嵌套返回若干条文档,这里size: 1表示每个用户只取一条,排序规则是时间倒序。

inner_hits的价值在于,它让你可以在一个请求里同时拿到“去重后的主文档”和“该分组下的明细”。比如做订单列表时,外层展示用户信息,inner_hits里展示该用户最近的一笔订单详情。不用再发第二次请求,省了一轮网络往返。

不过要注意,inner_hits的size不要设太大。它本质上是在每个折叠分组内再做一次小查询,如果设成 100,而你有 10 万个不同的用户,那就相当于要返回 1000 万条明细,协调节点的内存会直接告急。一般建议size控制在 1 到 10 之间,只取最关键的几条。

2.3 collapse 的性能特征与限制

collapse 的性能取决于两个因素:去重后的基数和每个分片内的文档分布。

如果user_id的基数很高(比如 100 万个不同的用户),那么协调节点最终要合并 100 万个 key,内存占用会比较大。ES 默认的search.max_buckets是 65536,虽然 collapse 不完全等同于聚合的 bucket,但协调节点合并时也有类似的内存压力。如果基数超过几十万,建议配合search_after做分页,不要一次性全取。

另一个限制是 collapse 不能和scroll一起用。因为 collapse 的结果是每个分片折叠后的合并,scroll 的游标机制和这个合并逻辑不兼容。所以如果你要遍历大量去重结果,只能用search_after,不能用 scroll。

还有一个容易忽略的点:collapse 的字段如果是高基数的,每个分片返回的文档数等于该分片内不同 key 的数量。如果数据分布不均匀,某个分片恰好包含了大量不同的用户,这个分片返回的结果就会特别多,成为整个查询的瓶颈。这种情况下可以考虑用路由(routing)把同一个用户的数据打到同一个分片,减少跨分片合并的压力。

注意:collapse 不支持from参数做深分页,只能配合search_after。如果你写了from: 100,ES 会直接报错。

3. 方案二:terms 聚合做去重列表与分组统计

3.1 terms 聚合的基本用法

如果不需要原始文档,只想要一个去重后的值列表,terms聚合是最直接的选择。

{ "size": 0, "aggs": { "unique_users": { "terms": { "field": "user_id", "size": 100 } } } }

size: 0表示不返回任何原始文档,只返回聚合结果。terms聚合会按照user_id分组,每个不同的值作为一个 bucket,bucket 里默认包含doc_count,也就是该值出现的次数。

这个方案的好处是结果非常干净,返回的就是一个 key 和计数的列表。适合做“有哪些不同的值”这种需求,比如“所有下过单的用户 ID 列表”“所有出现过的错误码”。

但这里有个大坑:terms聚合的size参数默认只返回 10 个 bucket。很多人第一次用的时候发现只返回了 10 个用户,以为是数据只有 10 个,其实是默认值太小。要拿到更多,必须显式设置size。但size也不能无限大,因为它受search.max_buckets限制,默认是 65536。如果你有 10 万个不同的用户,设size: 100000会直接报错。

3.2 突破 terms 聚合的数量限制

面对高基数去重,有三种主流解法。

第一种是调大search.max_buckets。在集群配置里把这个值改大,比如改成 200000。但这会带来内存风险,因为每个 bucket 都要在协调节点上占用内存,bucket 太多可能导致 OOM。所以只建议在数据量可控的情况下临时调大,不要作为长期方案。

第二种是用composite聚合做分页。composite聚合是专门为遍历大量 bucket 设计的,它支持after参数,可以像游标一样一批一批地取。

{ "size": 0, "aggs": { "my_buckets": { "composite": { "size": 1000, "sources": [ { "user_id": { "terms": { "field": "user_id" } } } ] } } } }

每次返回 1000 个 bucket,同时返回一个after_key。下一次请求把这个after_key带上,就能继续取下一批。这样无论有多少个不同的用户,都能稳定遍历完,而且每次内存占用可控。

第三种是用cardinality聚合只取数量。如果你只关心“有多少个不同的用户”,不关心具体是哪些,那cardinality是最省资源的。

{ "size": 0, "aggs": { "unique_count": { "cardinality": { "field": "user_id", "precision_threshold": 40000 } } } }

cardinality底层用的是 HyperLogLog++ 算法,这是一种概率性算法,用固定的内存估算基数。precision_threshold决定了精度,值越大越准,但内存占用也越高。默认是 3000,最大是 40000。在 40000 的时候,对于基数小于 40000 的集合,误差可以控制在 1% 以内;超过 40000 之后误差会逐渐增大。

所以cardinality适合做监控指标、大盘统计这种对精度要求不是 100% 的场景。如果你要的是精确的去重计数,还是得用terms或者composite把 bucket 全取出来再数。

3.3 多字段组合去重

有时候去重不是按单个字段,而是按多个字段的组合。比如“同一个用户同一个商品只算一次”,这时候就需要用multi_terms聚合(ES 7.12 之后支持)。

{ "size": 0, "aggs": { "unique_pairs": { "multi_terms": { "terms": [ { "field": "user_id" }, { "field": "product_id" } ], "size": 100 } } } }

multi_terms会把多个字段的组合作为一个 key 来分组,返回的 bucket 里包含每个字段的值和计数。这比用脚本拼接字符串再聚合要高效得多,因为它是原生支持的,不需要走脚本引擎。

如果 ES 版本低于 7.12,没有multi_terms,那就只能用script聚合把多个字段拼成一个字符串,再对这个字符串做terms聚合。但脚本聚合的性能很差,而且不能利用 doc values 的优化,数据量大时非常慢。所以如果经常有组合去重需求,建议升级到支持multi_terms的版本。

4. 方案三:字段折叠与聚合的组合拳

4.1 先聚合再去重的两阶段思路

有些复杂场景下,单纯用 collapse 或者单纯用聚合都不够。比如“每个商品分类下,每个品牌只展示一个商品”,这既需要按分类分组,又需要在组内按品牌去重。

这种需求可以用“先聚合分组,再在组内 collapse”的思路,但 ES 不支持在聚合内部直接 collapse。所以实际做法通常是分两步:先用terms聚合拿到分类和品牌的组合列表,然后再根据这些组合去查具体文档。或者反过来,先用 collapse 拿到去重后的文档,再用聚合做统计。

更优雅的方式是用top_hits聚合。top_hits可以在每个 bucket 内返回若干条文档,相当于在聚合内部做了“取前 N 条”。

{ "size": 0, "aggs": { "by_category": { "terms": { "field": "category", "size": 10 }, "aggs": { "by_brand": { "terms": { "field": "brand", "size": 5 }, "aggs": { "top_product": { "top_hits": { "size": 1, "sort": [ { "sales": "desc" } ] } } } } } } } }

这个查询做了三层:第一层按分类分组,第二层在每个分类内按品牌分组,第三层在每个品牌内取销量最高的一个商品。最终返回的结构是分类 -> 品牌 -> 商品详情,完全满足“每个分类下每个品牌只展示一个商品”的需求。

top_hits的size同样不建议设大,因为它本质上是在每个 bucket 内做一次小查询,bucket 数量乘以size就是最终要返回的文档数。如果分类有 100 个,每个分类下有 50 个品牌,size设成 10,那就是 50000 条文档,协调节点压力很大。

4.2 用 pipeline 聚合做去重后的二次计算

聚合的结果还可以继续做二次计算,这靠的是 pipeline 聚合。比如“去重后每个用户的订单总金额”,可以先按用户分组,再对每组求和。

{ "size": 0, "aggs": { "by_user": { "terms": { "field": "user_id", "size": 100 }, "aggs": { "total_amount": { "sum": { "field": "amount" } }, "avg_amount": { "avg": { "field": "amount" } } } } } }

这里terms聚合负责去重(每个用户一个 bucket),sum和avg是子聚合,负责在每个 bucket 内做计算。这样一次请求就能拿到“每个去重后的用户及其订单总金额和平均金额”。

pipeline 聚合还能做更复杂的操作,比如bucket_script可以在 bucket 级别做算术运算,bucket_sort可以对 bucket 排序,bucket_selector可以过滤 bucket。这些组合起来,能在一次请求里完成相当复杂的去重加统计逻辑。

4.3 聚合的内存控制与性能调优

聚合是内存大户,尤其是terms聚合。每个 bucket 都要在协调节点上维护,bucket 越多内存越大。所以有几个参数必须关注。

search.max_buckets是硬限制,默认 65536。超过就报错。如果确实需要更多 bucket,可以调大,但要同步增加协调节点的堆内存。

shard_size是terms聚合的一个关键参数。它控制每个分片返回给协调节点的候选 bucket 数量。默认情况下,shard_size是size * 1.5 + 10。协调节点会从所有分片返回的候选 bucket 中选出最终的size个。如果shard_size太小,可能会漏掉一些本该进入前 N 的 bucket,导致结果不准确。

{ "aggs": { "unique_users": { "terms": { "field": "user_id", "size": 100, "shard_size": 500 } } } }

把shard_size调大可以提高准确性,但会增加每个分片的内存和网络开销。一般建议shard_size是size的 2 到 5 倍,具体取决于数据分布的均匀程度。如果数据倾斜严重,某个分片包含了大量不同的值,shard_size还要再调大。

另外,terms聚合默认按doc_count降序排列。如果你只关心去重后的值列表,不关心计数,可以用order参数改成按 key 排序,这样在某些情况下能利用索引的排序特性,稍微快一点。但提升有限,主要还是受 bucket 数量影响。

提示:如果发现聚合查询经常超时或者 OOM,优先检查是不是size设得太大,或者shard_size不够导致协调节点要处理过多候选 bucket。

5. 常见问题与排查技巧实录

5.1 为什么 collapse 报错说字段类型不对

这是最高频的问题。错误信息通常是Field [xxx] of type [text] does not support collapse。原因就是 collapse 字段必须是keyword或者数值类型,不能是text。

解决办法有两个:一是重建索引,把该字段的 mapping 改成keyword;二是不重建索引,在现有text字段上加一个keyword子字段,然后 collapse 时用xxx.keyword。

{ "mappings": { "properties": { "user_id": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } } } }

加子字段需要重建索引或者用_mappingAPI 更新(但已有数据的子字段不会自动填充,需要 reindex)。所以最好的做法是一开始建索引时就把需要去重的字段设计成keyword,如果既要分词又要去重,就同时建text和keyword两个子字段。

5.2 terms 聚合结果不准是怎么回事

terms聚合返回的计数有时候会比实际少,尤其是当shard_size不够大的时候。因为协调节点是从每个分片返回的候选 bucket 中选前 N 个,如果某个分片的候选 bucket 里没有包含某个 key,这个 key 就不会出现在最终结果里。

举个例子:你有 3 个分片,size设成 10,shard_size默认是 25。每个分片返回自己前 25 个 bucket,协调节点从这 75 个里选前 10 个。如果某个 key 在每个分片里都排在第 30 位,那它就不会出现在任何分片的候选里,最终结果里就漏掉了。

解决办法就是调大shard_size,让它足够覆盖所有可能进入前 N 的 key。经验值是shard_size = size * 分片数 * 2,但这样内存开销会很大。更稳妥的方式是用composite聚合做全量遍历,不依赖shard_size。

5.3 cardinality 计数误差太大怎么调

cardinality的误差来自 HyperLogLog 算法的概率性质。默认precision_threshold是 3000,对于基数在 3000 以内的集合,误差很小;但基数上万之后,误差可能达到 5% 甚至更高。

调大precision_threshold可以降低误差,最大 40000。但即使调到 40000,对于基数超过 40000 的集合,误差仍然存在。所以如果业务要求精确计数,就不要用cardinality,改用terms或composite把 bucket 全取出来数。

另外,cardinality对内存的占用和precision_threshold成正比。40000 的时候每个分片大约占用几十 KB 到几百 KB,具体取决于数据分布。这个开销通常可以接受,但如果有大量并发的 cardinality 查询,累积起来也不小。

5.4 去重查询慢的排查思路

去重查询慢,通常从三个方向排查。

第一,看是不是返回了太多文档。collapse 如果基数很高,协调节点要合并大量 key,这时候应该用search_after分页,而不是一次性全取。聚合如果size太大,也要考虑用composite分批。

第二,看字段类型是否合理。如果 collapse 或聚合的字段是text而不是keyword,ES 可能要走 fielddata,性能差很多。用_mappingAPI 确认字段类型,必要时加keyword子字段。

第三,看分片分布是否均匀。如果数据倾斜,某个分片特别大,聚合时这个分片会成为瓶颈。可以用_cat/shards查看分片大小,必要时调整 routing 或者重建索引时增加分片数。

下面这张表可以快速对照常见现象和可能原因:

现象可能原因排查方向
collapse 报字段类型错误字段是 text 类型检查 mapping,加 keyword 子字段
terms 聚合结果数量偏少shard_size 太小调大 shard_size 或改用 composite
cardinality 计数偏差大precision_threshold 太低调大阈值或改用精确方案
去重查询超时返回文档太多或 bucket 太多用 search_after 或 composite 分页
聚合内存占用高size 或 shard_size 过大降低 size,分批查询

5.5 几个容易忽略的实操细节

第一个细节:collapse和sort一起用时,sort的字段最好和 collapse 字段有某种关联,否则每个分片折叠时取的“第一条”可能不是全局最优的。比如按时间倒序折叠用户,每个分片取自己分片内最新的那条,协调节点合并时再按时间排序,最终结果才是全局最新的。如果sort字段和 collapse 字段无关,结果可能不符合预期。

第二个细节:terms聚合的min_doc_count默认是 1,也就是只返回至少出现一次的 key。如果你想要包含出现 0 次的 key(比如补全所有可能的分类),需要配合missing参数或者用filters聚合手动指定。这个在报表场景里很常见,容易漏掉。

第三个细节:composite聚合的after_key是分页游标,但它不是稳定的。如果在遍历过程中有新的文档写入,可能会导致某些 bucket 被跳过或重复。所以composite适合在数据相对静态的场景下做全量遍历,不适合实时性要求很高的场景。

第四个细节:如果去重字段的值特别长(比如 URL 或者长文本),terms聚合的 bucket key 会占用较多内存。这时候可以考虑对字段做哈希处理,存一个哈希值字段专门用于聚合,减少内存占用。但哈希会有碰撞概率,需要根据数据量评估是否可接受。

6. 不同场景下的选型建议

6.1 按需求特征快速选型

选型其实就三个问题:要不要原始文档?去重基数大不大?要不要精确计数?

要原始文档,基数不大(几万以内),用collapse配合inner_hits。要原始文档,基数很大,用collapse配合search_after分页。

不要原始文档,只要去重列表,基数不大,用terms聚合设size。基数很大,用composite聚合分批遍历。

只要去重计数,对精度要求不高,用cardinality。要求精确,用terms或composite取全量 bucket 再数。

多字段组合去重,ES 7.12 以上用multi_terms,以下用脚本聚合但要做好性能差的准备。

6.2 一个综合案例的完整配置

假设有一个订单索引,字段包括user_id(keyword)、product_id(keyword)、amount(double)、order_time(date)。需求是:每个用户每个商品只算一次,返回去重后的组合列表,并统计每个组合的总金额。

{ "size": 0, "aggs": { "unique_pairs": { "multi_terms": { "terms": [ { "field": "user_id" }, { "field": "product_id" } ], "size": 1000 }, "aggs": { "total_amount": { "sum": { "field": "amount" } } } } } }

如果组合数超过 1000,把size调大或者改用composite。如果只要计数,把multi_terms换成cardinality配合脚本,但性能不如multi_terms。

这个配置在实际项目中跑过,100 万条订单数据,去重后大约 5 万个组合,size设 1000 配合composite分页,单次请求响应在 200ms 以内。如果直接设size: 100000,协调节点内存会飙到几个 GB,响应时间超过 10 秒。所以分批永远是高基数场景的首选。

6.3 版本差异与兼容性提醒

collapse从 ES 5.0 开始支持,search_after从 5.0 开始支持,composite聚合从 6.1 开始支持,multi_terms从 7.12 开始支持。如果你的集群版本较老,有些方案可能用不了。

比如 6.x 版本没有multi_terms,组合去重只能用脚本。7.x 早期版本没有multi_terms,需要 7.12 以上。所以做方案设计时,先确认集群版本,再决定用哪种聚合。

另外,cardinality的precision_threshold最大值在不同版本可能略有差异,但 40000 是长期以来的上限。search.max_buckets默认值在 7.x 是 65536,6.x 可能更小,需要根据实际版本确认。

我在实际项目里踩过最深的坑,就是没确认版本就写了multi_terms,结果测试环境是 7.10,直接报未知聚合类型。后来改成脚本聚合,性能掉了好几倍,最后推动升级到 7.17 才解决。所以版本确认这一步千万别省。

6.4 监控与容量规划

去重查询对集群的压力主要体现在协调节点的 CPU 和内存上。建议对以下几类指标做监控:协调节点的堆内存使用率、聚合查询的 P99 延迟、search.max_buckets的触发次数、cardinality查询的并发数。

如果发现协调节点内存经常超过 75%,要么降低聚合的size,要么增加协调节点数量做负载均衡。如果 P99 延迟持续偏高,考虑把去重查询路由到专门的协调节点,避免影响写入和其他查询。

容量规划上,一个经验值是:每个termsbucket 大约占用 100 到 500 字节的内存,具体取决于 key 的长度和子聚合的数量。如果预计有 10 万个 bucket,协调节点至少需要预留 50MB 到 100MB 的堆内存给这个查询。如果有多个并发去重查询,这个数字还要乘以并发数。

最后再分享一个小技巧:如果去重字段的基数在写入时就能确定,可以在索引设计阶段就把它设为keyword并关闭norms和index_options,只保留 doc values。这样既能支持 collapse 和聚合,又能减少索引体积,提升查询速度。这个优化在日志类索引上效果特别明显,索引体积能减少 20% 到 30%。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进