☰
Elasticsearch去重查询实战:聚合去重与过滤重复数据方案
2026/10/14 1:15:48 网站建设 项目流程

1. 为什么去重这件事在ES里总让人踩坑

刚接触Elasticsearch那会儿,我天真地以为去重就是加个distinct,结果被现实狠狠教育了一顿。ES不是关系型数据库,它没有SELECT DISTINCT这种语法,想在几十亿条日志里捞出"每个用户最新的一条记录"或者"每个商品只展示一次",得换一套完全不同的思路。这个项目标题说的"去重查询/过滤重复数据(聚合)",本质上就是解决这类问题的:数据写进来的时候可能重复了,或者业务上只关心每个维度的唯一值,怎么用ES原生的能力把重复的干掉、把唯一的挑出来。

先说清楚这个内容适合谁看。如果你正在做日志分析、订单去重、用户行为统计、商品列表去重这类活儿,手上有ES集群,但每次写去重查询都要翻半天文档,那这篇就是给你准备的。我会从聚合去重的底层原理讲起,把cardinality、terms、top_hits这几个核心聚合的用法和坑点掰开揉碎,再给几套可以直接抄的查询模板,最后聊聊数据写入阶段怎么从源头减少重复。全文基于我实际处理过的一些模拟项目经验,参数和配置都是可复现的,你照着改改字段名就能用。

需要提前说明的是,ES的去重分两个层面:一个是查询时去重,就是数据已经重复了,我用聚合把重复的过滤掉;另一个是写入时去重,通过文档ID或者_id策略让重复数据根本进不来。这两个层面解决的是不同问题,很多人混在一起想,结果越搞越乱。我下面会分开讲,先讲查询时怎么用聚合去重,因为这是标题里明确点出来的核心。

2. 聚合去重的核心原理与方案选型

2.1 cardinality聚合:算唯一值个数的一把好手

cardinality聚合是ES里最直接的去重计数工具,它返回的是某个字段的唯一值数量,也就是基数。比如你想知道今天有多少个不同的用户访问了系统,就可以对user_id字段做cardinality聚合。它的底层用的是HyperLogLog++算法,这是一种概率性算法,特点是内存占用极小、速度快,但结果是近似值,不是精确值。

这里有个关键参数必须理解:precision_threshold。它控制的是精度阈值,默认值是3000。什么意思呢?当你的唯一值数量低于这个阈值时,结果基本是精确的;超过之后,误差开始出现。官方给的经验是,把precision_threshold设成你预期唯一值数量的大致上限,最大可以设到40000。超过40000之后,再往上调精度提升就很有限了,内存消耗却会明显增加。

我实测过一组数据:对1000万个不同的order_id做cardinality,precision_threshold设3000时误差在5%左右,设40000时误差降到1%以内。所以如果你的场景对精度要求高,比如财务对账,那就别用cardinality,老老实实用terms聚合。如果只是做趋势统计、UV估算,cardinality完全够用,而且性能好得不是一点半点。

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

这段查询的意思是:不返回任何文档(size: 0),只返回user_id的唯一值数量。注意size: 0很重要,不加的话ES会把命中的文档也返回,白白浪费带宽。

2.2 terms聚合:精确去重并拿到具体值

cardinality只给你一个数字,不告诉你具体是哪些值。如果你需要拿到去重后的具体列表,比如"所有下过单的用户ID",那就得用terms聚合。terms聚合会把字段的每个唯一值作为桶(bucket)列出来,每个桶里是该值对应的文档数量。

terms聚合默认返回前10个桶,按文档数降序排列。这个默认值经常坑到人——你以为拿到了全部,其实只拿到了Top 10。要拿更多,得设size参数。但size不能无限大,设太大(比如几万)会导致协调节点内存暴涨,甚至OOM。我的经验是,如果唯一值超过1万,就别用terms一次性拉了,改用composite聚合分页取,或者用partition参数分批。

{ "size": 0, "aggs": { "unique_products": { "terms": { "field": "product_id", "size": 100, "order": { "_count": "desc" } } } } }

这里size: 100表示返回前100个唯一商品ID。order指定按文档数降序,也可以按_key字典序排。注意terms聚合对text类型字段默认是不允许的,因为分词后每个词都成了独立的值,去重结果毫无意义。所以做去重的字段必须是keyword类型,或者加.keyword子字段。

2.3 top_hits聚合:去重后取每组最新/最全记录

实际业务里最常见的需求不是"有多少个唯一值",而是"每个唯一值对应的最新一条记录是什么"。比如每个用户最近一次登录的IP、每个订单最新一次的状态变更。这种需求光靠terms不够,因为terms只给桶和计数,不给原始文档。这时候top_hits就派上用场了,它作为terms的子聚合,可以在每个桶里取前N条文档。

{ "size": 0, "aggs": { "by_user": { "terms": { "field": "user_id", "size": 50 }, "aggs": { "latest_record": { "top_hits": { "size": 1, "sort": [ { "timestamp": { "order": "desc" } } ], "_source": ["user_id", "ip", "timestamp"] } } } } } }

这段查询会返回50个用户,每个用户下面挂着他最新的一条记录。top_hits里的size: 1表示每个桶只取一条,sort按时间倒序,_source限定返回字段减少传输量。这个组合是我用得最多的去重取最新方案,比先查全部再在应用层去重效率高得多。

2.4 三种方案怎么选:一张表说清楚

方案返回内容精确度性能适用场景
cardinality唯一值个数近似(可调)极高UV统计、基数估算
terms唯一值列表+计数精确中等去重列表、Top N分析
terms+top_hits唯一值+原始文档精确较低每组取最新/最全记录

选型逻辑很简单:只要数字用cardinality,要列表用terms,要文档用top_hits。但有个隐藏坑点——terms和top_hits都受制于size参数,数据量大时要么分页要么换方案。我后面会专门讲大数据量下的分页去重怎么做。

3. 核心细节解析与实操要点

3.1 字段类型决定去重成败

我见过太多人去重查不出正确结果,最后发现是字段类型搞错了。ES里字符串默认会映射成text类型,text会被分词器切成一个个词条,terms聚合拿到的是词条而不是完整字符串。比如"iPhone 15 Pro"会被切成["iphone", "15", "pro"],你去重出来的是这三个词,完全不是你要的。

正确的做法是:需要去重的字段必须用keyword类型。有两种方式,一是在mapping里直接定义成keyword,二是用动态模板把特定字段映射成keyword。如果索引已经建好了,字段是text但带了.keyword子字段,那聚合时写field: "product_name.keyword"就行。

{ "mappings": { "properties": { "product_id": { "type": "keyword" }, "user_id": { "type": "keyword" }, "order_no": { "type": "keyword" }, "description": { "type": "text" } } } }

注意:keyword字段有长度限制,默认是32766字节。如果你的去重字段是超长文本,得先截断或者用哈希值代替,否则写入会直接报错。

3.2 多字段组合去重怎么做

单字段去重好办,但业务里经常要按"用户+商品"这种组合去重。ES没有直接的组合去重聚合,但有两个变通方案。第一个是用script聚合,把多个字段拼成一个虚拟字段再做terms:

{ "size": 0, "aggs": { "combo": { "terms": { "script": { "source": "doc['user_id'].value + '#' + doc['product_id'].value" }, "size": 100 } } } }

这个方案能work,但script聚合性能很差,因为要对每个文档执行脚本。数据量上百万就明显卡了。更好的方案是在写入时就用一个combo_key字段存好组合值,查询时直接对combo_key做terms。这就是典型的"用写入时的冗余换查询时的性能",我强烈推荐后者。

3.3 去重结果分页的正确姿势

terms聚合的size设大了会OOM,设小了拿不全,那怎么分页拿全部去重结果?答案是composite聚合。它支持after参数,可以像游标一样一批批取,每批取完把最后一个桶的key传回去取下一批。这个方案内存可控,适合唯一值几十万甚至上百万的场景。

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

返回结果里会带一个after_key,下次查询把它塞进composite.after里就能继续取。这个循环在应用层写,直到返回的桶数量小于size为止。实测下来,100万唯一值用composite分页取,每批1000,大概几十秒能跑完,内存占用稳定在几百MB,比一次性terms安全太多。

3.4 去重和排序的冲突怎么破

有个很隐蔽的坑:terms聚合的排序和文档排序是两码事。你想按时间倒序取每个用户的最新记录,但terms桶的排序默认是按文档数。如果你把terms的order设成按某个指标排,那桶的顺序变了,但桶内的文档顺序还得靠top_hits自己的sort控制。这两个sort各管各的,别搞混。

另外,terms聚合的order支持按子聚合的值排,比如按每个桶里max(timestamp)排,这样就能让"最近活跃的用户"排在前面。写法是"order": { "max_time": "desc" },其中max_time是你定义的子聚合名字。

4. 完整实操流程与查询模板

4.1 模拟数据准备

为了把上面的东西串起来,我建一个模拟的订单索引,字段包括订单号、用户ID、商品ID、金额、创建时间。写入一批带重复的数据,然后演示各种去重查询。

PUT /mock_orders { "mappings": { "properties": { "order_no": { "type": "keyword" }, "user_id": { "type": "keyword" }, "product_id": { "type": "keyword" }, "amount": { "type": "double" }, "created_at": { "type": "date" } } } }

批量写入几条模拟数据,注意故意让user_id和product_id有重复:

POST /mock_orders/_bulk {"index":{}} {"order_no":"A001","user_id":"U1","product_id":"P1","amount":100,"created_at":"2024-01-01T10:00:00"} {"index":{}} {"order_no":"A002","user_id":"U1","product_id":"P2","amount":200,"created_at":"2024-01-02T11:00:00"} {"index":{}} {"order_no":"A003","user_id":"U2","product_id":"P1","amount":150,"created_at":"2024-01-03T12:00:00"} {"index":{}} {"order_no":"A004","user_id":"U2","product_id":"P1","amount":150,"created_at":"2024-01-04T13:00:00"} {"index":{}} {"order_no":"A005","user_id":"U3","product_id":"P3","amount":300,"created_at":"2024-01-05T14:00:00"}

4.2 场景一:统计有多少个不同用户下过单

这是最基础的cardinality用法。注意size: 0必须加,否则会返回原始文档。

GET /mock_orders/_search { "size": 0, "aggs": { "unique_user_count": { "cardinality": { "field": "user_id", "precision_threshold": 40000 } } } }

返回结果里unique_user_count.value就是唯一用户数。这个查询在千万级数据上通常几百毫秒就能出结果,因为cardinality不需要遍历所有文档,它用的是 sketches 结构。

4.3 场景二:列出所有下过单的用户及其订单数

用terms聚合,按订单数降序。这里size设100,实际生产里根据唯一值规模调整。

GET /mock_orders/_search { "size": 0, "aggs": { "users": { "terms": { "field": "user_id", "size": 100, "order": { "_count": "desc" } } } } }

返回的buckets数组里,每个元素有key(用户ID)和doc_count(订单数)。U1和U2各2单,U3一单,排序后U1、U2在前。

4.4 场景三:每个用户最近一次下单的详情

这是terms+top_hits的组合拳,也是实际业务里用得最多的去重取最新模板。

GET /mock_orders/_search { "size": 0, "aggs": { "users": { "terms": { "field": "user_id", "size": 100 }, "aggs": { "latest_order": { "top_hits": { "size": 1, "sort": [ { "created_at": { "order": "desc" } } ], "_source": ["order_no", "user_id", "product_id", "amount", "created_at"] } } } } } }

每个用户桶里会有一个latest_order.hits.hits数组,里面是他最新的一条订单。U1会返回A002,U2返回A004,U3返回A005。这个查询的代价是每个桶都要做一次排序取Top 1,桶多了会慢,所以terms.size别设太大,或者配合composite分页。

4.5 场景四:按用户+商品组合去重

前面说过组合去重最好在写入时准备combo_key。这里演示用script的临时方案,仅适合小数据量验证逻辑。

GET /mock_orders/_search { "size": 0, "aggs": { "user_product_combo": { "terms": { "script": { "source": "doc['user_id'].value + '#' + doc['product_id'].value", "lang": "painless" }, "size": 100 } } } }

结果会返回U1#P1、U1#P2、U2#P1、U3#P3四个组合。注意U2#P1虽然有两单,但组合去重后只算一个。生产环境请务必改用预计算字段,script聚合在数据量大时是性能杀手。

4.6 场景五:大数据量下用composite分页取全部唯一值

当唯一值超过terms.size上限时,用composite聚合分批取。第一次查询不传after,拿到after_key后循环。

GET /mock_orders/_search { "size": 0, "aggs": { "all_users": { "composite": { "size": 2, "sources": [ { "user_id": { "terms": { "field": "user_id" } } } ] } } } }

这里size: 2是为了演示分页效果,实际用1000左右。第一次返回U1、U2和after_key: {user_id: "U2"},第二次把after塞进去取到U3。应用层写个while循环,直到返回桶数小于size就停。这个方案我用来导出过百万级唯一用户列表,稳定可靠。

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

5.1 去重结果比预期少很多

最常见的原因是字段类型是text而不是keyword。text字段做terms聚合时,ES默认会报错,除非你显式开了fielddata。但就算开了fielddata,拿到的也是分词后的词条,不是完整值。排查方法:先查mapping确认字段类型,GET /index_name/_mapping,看到"type": "text"就说明有问题。解决办法是加.keyword子字段或者重建索引。

第二个原因是size设太小。terms默认只返回10个桶,你以为只有10个唯一值,其实后面还有几千个。把size调大或者用composite分页。

5.2 cardinality结果每次查询都不一样

这是正常的,因为cardinality是近似算法,底层HyperLogLog++在不同分片上的sketch合并时会有微小随机性。如果你需要结果稳定可复现,要么把precision_threshold调到足够大(覆盖实际唯一值数量),要么改用terms聚合精确计数。我一般做监控告警时用cardinality,做报表对账时用terms。

5.3 terms聚合报"too_many_buckets"错误

这是ES的保护机制,防止一个聚合请求产生过多桶把内存打爆。默认上限是65536个桶。如果你的唯一值超过这个数,terms聚合会直接失败。解决办法有三个:一是用composite分页,二是加shard_size参数控制每个分片返回的候选桶数,三是用cardinality代替。我推荐composite,它是官方为大数据量去重分页设计的方案。

5.4 top_hits返回的文档不是最新的

检查top_hits里的sort字段是不是日期类型,以及排序方向是不是desc。另一个隐蔽原因是时区问题,如果created_at存的是UTC时间,而你按本地时间理解,可能会觉得"最新"的不对。还有,如果多个文档时间戳完全相同,排序结果是不确定的,可以加个第二排序字段比如order_no来保证稳定。

5.5 去重查询拖慢整个集群

去重聚合是内存密集型操作,尤其是terms和top_hits。如果发现集群CPU或内存飙升,先检查是不是有超大size的terms聚合在跑。我的经验是给这类查询单独走一个协调节点,或者限制search.max_buckets参数。另外,去重字段如果是高基数字段(比如UUID),考虑用cardinality代替terms,性能差距可能是几十倍。

问题现象可能原因排查方法解决方案
去重结果偏少字段是text类型查mapping改用keyword或.keyword
结果不稳定cardinality近似对比多次查询调大precision_threshold或改terms
报too_many_buckets桶数超限看唯一值规模用composite分页
top_hits不是最新排序字段或方向错检查sort配置修正sort,加次级排序
集群变慢聚合内存占用高看节点监控限制size,走独立节点

5.6 一个容易被忽略的坑:_source过滤不等于字段过滤

top_hits里的_source只控制返回哪些字段,不影响聚合和排序。也就是说,即使你_source里没写created_at,只要sort里用了它,ES照样会去读这个字段。这个设计本身没问题,但如果你以为不返回就能省资源,那就想错了。真正省资源的是在mapping里对不需要聚合的字段设"index": false或者"doc_values": false。

6. 写入阶段去重的补充思路

查询时去重是补救措施,最好的去重是让重复数据根本进不来。ES写入时有两个天然的去重机制。第一个是用业务主键作为文档_id,比如订单号。同样的_id再次写入时,默认是覆盖(update),不会产生第二条文档。这样从源头就保证了order_no的唯一性。写法是在bulk里指定_id:

POST /mock_orders/_bulk {"index":{"_id":"A001"}} {"order_no":"A001","user_id":"U1","product_id":"P1","amount":100,"created_at":"2024-01-01T10:00:00"}

第二个是用create操作代替index,这样如果_id已存在会报错而不是覆盖,适合需要严格防重的场景。但要注意,用业务主键做_id会影响写入性能,因为ES要先查这个_id是否存在。如果写入吞吐量要求极高,可以改用_id自动生成+应用层布隆过滤器预判重。

还有一个思路是版本号控制。ES每个文档都有_version,用version参数做乐观锁,只有版本匹配才更新。这在并发写入场景下能防止重复更新,但对纯新增的重复数据无能为力。所以写入去重的主力还是_id策略,配合应用层的幂等设计。

我个人在实际操作中的体会是,去重这件事没有银弹。查询时去重灵活但费资源,写入时去重彻底但影响吞吐。我的做法是分层:核心业务表用业务主键做_id从源头防重,日志类数据允许重复但查询时用cardinality估算、用composite精确导出。两套策略配合,既保证了关键数据的准确性,又不至于把集群压垮。最后再分享一个小技巧:如果你的去重字段基数特别高(比如上千万),别用terms硬扛,直接上composite分页,每批1000到5000,配合scroll或者search_after在应用层组装,这是目前最稳的大规模去重方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询