ClickHouse 去重查询:LIMIT 1 BY 与 SELECT DISTINCT 怎么选?

📅 发布时间:2026/10/15 10:13:42
ClickHouse 去重查询:LIMIT 1 BY 与 SELECT DISTINCT 怎么选?
判断一个用户有没有做过某行为在 ClickHouse 里是个高频到不能再高频的需求。运营要圈选人群、客服要查历史记录、风控要核验名单最后的 SQL 往往都会落到同一个问题上怎么从一张行为表里又快又准地拿到“存在性”。常有人在这时候纠结LIMIT 1 BY和SELECT DISTINCT该用哪个或者干脆两个都试一遍发现结果有时候一样、有时候不一样越试越迷糊。这篇文章不绕弯子直接从“用户有没有某行为”这个场景出发把这两个语法放在同一个业务背景下拆开讲清楚顺带说几种更适合的替代写法。不管你是刚上手 ClickHouse 的初级分析还是天天写复杂查询的老手这组对比应该都能帮你少踩两个坑。我会先讲需求本质再拆语法原理最后给一组同样场景下的实测数据和选型建议全程都是我在真实任务里反复验证过的写法可以直接照着试。1. 判断“有没有某行为”的三种需求模型1.1 需求没拆清楚SQL 一定走弯路先别急着写 SQL。“判断用户有没有某行为”这句话看着简单在不同岗位嘴里说出来根本不是同一个需求。我自己接需求时习惯先问三句你要的是一个用户 ID 列表还是要带一条行为样例你是要判断某几个指定用户还是要全量用户过一遍你对“有没有”的判定里需不需要带上首次或最后一次时间这三个问题往往决定了 SQL 往哪个方向写。举个例子。运营来问“帮我把近 30 天有 purchase 行为的用户圈出来发给活动组”这其实是全量求集合的布尔判断另一个同事说“从有 click 行为的用户里每人给我挑一条记录看看长啥样”这其实是样例型需求再来一个“算一下近 30 天每个用户的 purchase 次数”这又是度量型需求。三句话听起来都是“看看用户有没有行为”但如果第一句你用SELECT DISTINCT第二句你用GROUP BY第三句你用LIMIT 1 BY全都会错位。1.2 布尔型、样例型、度量型的核心套路我习惯把这个需求分成三类每个类别对应一组 SQL 倾向。用表格整理出来会很清楚需求类型典型问法核心输出首选思路布尔型哪些用户有购买行为给定用户是否有此行为用户 ID 集合 / 0-1 标记存在性判断、GROUP BY countIf样例型有购买行为的用户每人展示一条记录整行样本数据LIMIT 1 BY 或窗口函数度量型每人购买了几次最近一次什么时候次数、时间、人数GROUP BY 聚合函数这个表列出来之后你会发现SELECT DISTINCT真正适合的只有第一行而且还得是“只要用户 ID 集合、不要其它字段”的子集场景。如果你既想要用户列表又想要最近一次时间那LIMIT 1 BY和GROUP BY max(event_time)反而更合适。很多性能问题和结果漂移都是因为没先归类就动手写 SQL。1.3 为什么两个关键词总被放到一起比再看最初的问题判断用户有没有某行为为什么要拿LIMIT 1 BY和SELECT DISTINCT对比因为在这类场景里它们都能实现“拿到去重后的用户集合”LIMIT 1 BY取了每个用户一行之后结果集中 user_id 自然唯一SELECT DISTINCT直接把重复的 user_id 合并。表面上是一个需求的两个写法内里却是两种完全不同的路径——一个是“分组后取首行”一个是“全局哈希去重”。搞清楚差异的第一步就是别再拿结果集相等来反推语法等价。2. LIMIT 1 BY组内取一行的正确打开方式2.1 语法和语义LIMIT 1 BY 是怎么工作的LIMIT 1 BY是 ClickHouse 的扩展语法标准 SQL 里没有这个东西。它的意思是按某个表达式分组然后在每个组内只保留一行。语法形如下面这样SELECT user_id, action, event_time FROM user_events ORDER BY user_id, event_time DESC LIMIT 1 BY user_id注意LIMIT出现在整个查询的最后1表示每个组取一行BY user_id表示分组依据。执行时引擎先按 user_id 分组每个组内按照ORDER BY的顺序取第一行作为该组的代表。实际跑出来的效果是结果集的 user_id 不重复且每一行都带着完整的字段值。如果你把ORDER BY写成event_time DESC那每组拿到的就是事件时间最新的那一行。这个能力在“判断有没有某行为”的场景里很值钱因为你不但知道这个用户有行为还顺手拿到了行为的时间、渠道、设备等上下文。很多运营报表里要的“最近一次购买时间”“最后登录设备”用LIMIT 1 BY一条 SQL 就能带出来。2.2 不写 ORDER BY 会怎样这里必须敲黑板LIMIT 1 BY本身不保证组内顺序。如果你不写ORDER BY引擎会从每个组里挑一行返回具体挑哪一行由执行计划、数据块分布等因素决定不是一个确定的结果。我在一次定时任务里踩过这个坑。当时只是想从有 purchase 行为的用户里找一条记录展示所以写了这样的查询SELECT * FROM user_events WHERE action purchase LIMIT 1 BY user_id一开始任务跑得好好的某天数据量上来之后输出结果里某些用户的样例从“app”变成了“web”导致下游同事误以为数据口径变了。排查下来就是没排序惹的祸。后来把ORDER BY event_time DESC加上结果就稳定了。所以我的建议很简单只要LIMIT 1 BY后面的结果会被拿来当“某一行的真实样本”必须写ORDER BY如果只是“判断用户有没有行为”你的关注点只是 user_id 是否出现在结果集里那排序可能没那么关键但为了可复现性最好还是养成排序的习惯。2.3 和窗口函数 row_number 怎么选ClickHouse 从较新版本开始支持窗口函数每个用户分组排序取第一条也可以用窗口函数实现SELECT * FROM ( SELECT *, row_number() OVER (PARTITION BY user_id ORDER BY event_time DESC) AS rn FROM user_events WHERE action purchase ) WHERE rn 1但实测下来同样是“每组取一行”LIMIT 1 BY的开销通常更小。窗口函数需要为每个分区维护完整的窗口上下文而LIMIT 1 BY只要排序到达某个组的第一条就可以继续推进内存压力明显更低。如果你的表有几十万个用户分区这个差异很容易感受出来。所以在 ClickHouse 里我默认优先用LIMIT 1 BY只有需要更复杂的组内排序逻辑比如每组取前三条时才转向窗口函数。3. SELECT DISTINCT全局去重的边界在哪里3.1 列式存储下的去重开销SELECT DISTINCT的语义和标准 SQL 完全一致对所有输出行做全局去重。它在 ClickHouse 里的执行特点是引擎会按照 SELECT 列表去读取所需列然后对这些列的组合值进行哈希去重不会去读那些没被选中的列。这个特点在列式存储里是很大的优势。比如SELECT DISTINCT user_id FROM user_events WHERE action purchase如果 user_events 表有 20 个字段这个查询实际读取的就只有 user_id 和 action 两列剩下的 18 列全部跳过。相比之下如果你用LIMIT 1 BY且最后SELECT *那么所有满足条件的行都要把整行宽字段读出来IO 和内存开销完全不在一个量级。所以当你只需要一个用户 ID 集合、不需要任何附加字段时DISTINCT是比LIMIT 1 BY更“吝啬”的写法。这也是为什么在第 4 章的实测里DISTINCT的表现往往最稳。3.2 单字段去重与多字段组合去重很多人用DISTINCT只排除重复觉得“去了重就只剩一份”。可一旦 SELECT 后面跟了多个字段情况就变了。比如SELECT DISTINCT user_id, device FROM user_events WHERE action purchase这个查询去重的是(user_id, device)这个二元组。同一个 user_id如果既在 app 上买过又在 web 上买过结果里会出现两行。这不是 bug是组合去重的标准行为但业务方往往会误以为“按用户去重”。要避免这个坑可以先把意图确认清楚。如果你确实需要每个用户只有一行但又想带设备信息那应该考虑LIMIT 1 BY user_id或者GROUP BY user_id后配合any(device)之类的聚合函数。如果你的目标就是导出每个用户每个设备的组合行为列表那DISTINCT写法没问题只是别把它当成“用户唯一”的操作。3.3 什么时候该用 GROUP BY 而不是 DISTINCTSELECT DISTINCT user_id和SELECT user_id GROUP BY user_id在 ClickHouse 里基本等价很多版本里走的执行路径也一样。选哪个更多是风格问题。区别在于GROUP BY可以顺路带上聚合字段。比如你要“用户集合 最近一次购买时间”直接SELECT user_id, max(event_time) AS last_time FROM user_events WHERE action purchase GROUP BY user_id这就比DISTINCT再回表或者再 JOIN 要高效得多。所以我的经验判断是仅仅需要集合时用DISTINCT需求里只要带任何一个聚合指标直接转向GROUP BY。DISTINCT更像是一个轻量工具箱装不下太多附加需求。4. 实测对比同一个场景下五种写法的表现4.1 测试环境与数据构造原理讲一百遍不如跑一遍。我搭了一个模拟用户行为表数据量和真实业务接近字段结构也很常规CREATE TABLE user_events ( user_id UInt32, action LowCardinality(String), event_time DateTime, device String ) ENGINE MergeTree PARTITION BY toYYYYMM(event_time) ORDER BY (user_id, event_time);表里放了一千万行数据user_id 大约两百万个action 有 login、click、purchase 三种取值purchase 相关的行大约五十万行。模拟的需求是找出最近一个月有过 purchase 行为的用户集合能带出每个用户最后一次购买时间更好。环境是单机测试不用分布式主要关注相对趋势。4.2 五种写法的资源表现针对这个需求我对比了五种候选写法。第一个是SELECT DISTINCTSELECT DISTINCT user_id FROM user_events WHERE action purchase AND event_time 2024-11-01 AND event_time 2024-12-01第二个是纯GROUP BYSELECT user_id FROM user_events WHERE action purchase AND event_time 2024-11-01 AND event_time 2024-12-01 GROUP BY user_id第三个是GROUP BY max(event_time)用来带出最近一次购买时间SELECT user_id, max(event_time) AS last_time FROM user_events WHERE action purchase AND event_time 2024-11-01 AND event_time 2024-12-01 GROUP BY user_id第四个是ORDER BY LIMIT 1 BYSELECT user_id, event_time, device FROM user_events WHERE action purchase AND event_time 2024-11-01 AND event_time 2024-12-01 ORDER BY user_id, event_time DESC LIMIT 1 BY user_id第五个是不排序的LIMIT 1 BYSELECT user_id FROM user_events WHERE action purchase AND event_time 2024-11-01 AND event_time 2024-12-01 LIMIT 1 BY user_id观察了几个核心指标我把它整理成表。耗时以SELECT DISTINCT为 1.0 基准的相对值内存也是相对级别真实数值受硬件影响看趋势就好写法相对耗时相对内存能否带出样本字段结果确定性SELECT DISTINCT1.0低否确定GROUP BY user_id约 1.1低否确定GROUP BY max(event_time)约 1.2中低可以带时间确定不排序 LIMIT 1 BY约 0.6很低可以带整行不确定ORDER BY LIMIT 1 BY约 1.9中高可以带整行确定这轮对比有几个有价值的发现。第一如果只看“用户 ID 集合”DISTINCT和GROUP BY表现接近怎么顺手怎么来。第二不排序的LIMIT 1 BY由于不需要维护顺序而且只要每组第一条很多块可以提前跳过所以看起来反而最快但这是以确定性为代价的。第三一旦给LIMIT 1 BY加上ORDER BY排序成本立马上来比DISTINCT高出近一倍所以用它时要想清楚排序是不是必须的。提示上面表格里的耗时、内存都是同一台测试机上的相对表现数据分布、硬件、MergeTree 分区都会影响绝对值。最有意义的结论是“趋势”不是具体数字。4.3 选型判断三步走跑完对比之后我总结出一个三问决策法挺好用的。第一个问题你到底要不要结果里带其他字段只要用户 ID那DISTINCT或GROUP BY性价比最高要字段才考虑LIMIT 1 BY。第二个问题如果要带字段你要的是不是组内排序后第一条必须取最新/最早那就ORDER BY LIMIT 1 BY或窗口函数不挑顺序随便找一条样本不排序的LIMIT 1 BY最快。第三个问题要不要算次数、时间这类指标要的话直接GROUP BY加聚合函数别再用DISTINCT绕路。把这三个问题问完基本能覆盖这类需求 80% 的选型。5. 进阶优化与避坑实录5.1 布尔判断的终极写法别把全集拉出来如果只是想知道“某个用户有没有 purchase 行为”最优雅的写法不是DISTINCT也不是LIMIT 1 BY而是直接SELECT 1 FROM user_events WHERE user_id 123456 AND action purchase LIMIT 1这个查询扫描到第一条命中就可以返回配合主键索引速度可以非常快。如果要批量判断一批用户也可以写成SELECT u.id, countIf(e.action purchase) 0 AS has_purchase FROM users AS u LEFT JOIN user_events AS e ON u.id e.user_id GROUP BY u.id但注意 ClickHouse 的 JOIN 会把右表加载到内存如果 user_events 很大这个写法压力不小。更稳妥的是改成IN子查询SELECT id FROM users WHERE id IN ( SELECT user_id FROM user_events WHERE action purchase )IN子查询的结果同样会物化如果子查询返回的集合特别大依然要注意内存但通常比直接 JOIN 一张大表要温和。5.2 高基数列去重的注意事项这里再说一个容易翻车的地方count(DISTINCT user_id)这种统计写法。之前有人给我看一条查询跑了半天不出结果。看 SQL 是SELECT count(DISTINCT user_id) FROM user_events WHERE action purchase这个写法要求引擎在内存里维护一个包含所有 user_id 的精确去重集合在千万行、百万级 user_id 的表上往往意味着几 GB 到十几 GB 的哈希结构。如果节点内存不够查询会被 OOM或者把磁盘 swap 打爆。处理高基数场景建议换用近似去重函数SELECT uniq(user_id) FROM user_events WHERE action purchaseuniq在 ClickHouse 里用的是精度受限但误差很小的算法内存开销远小于精确去重。如果你确实要精确值可以用uniqExact但要给它留足内存余量。记住一句话判断“有没有”用布尔逻辑统计“有多少”用近似去重不要动不动count(DISTINCT)。5.3 表设计层面的根治方案如果“判断用户有没有某行为”是你们线上最常打的一个点我再强烈建议一种更长期的玩法把行为度量物化下来。比如建一张用户行为汇总表CREATE TABLE user_behavior_summary ( user_id UInt32, has_purchase UInt8, purchase_count UInt32, last_purchase_time DateTime, version UInt32 ) ENGINE ReplacingMergeTree(version) ORDER BY user_id然后通过物化视图或者固定的 ETL 任务把明细行为表里的数据按 user_id 聚合进去。这样运营每次圈人、风控每次核验都直接查汇总表毫秒级就能返回根本不用再去扫千万行的明细表。这套思路的本质是用空间换时间把“需要实时扫明细才能回答的问题”变成“提前算好、查询时只读结果”的问题。数据量大到一定程度后这是比任何 SQL 微调都有效的解法。不过物化数据的时效性需要考虑清楚明细表是实时的汇总表通常有分钟级延迟对实时性要求高的场景要权衡。5.4 两个真实翻车案例最后分享两个我在实际工作中遇到的错误写法你大概率也会碰见。第一个是LIMIT 1 BY不排序导致的用户集漂移前面已经提过。再补充一个细节这种漂移不一定会复现因为和数据块分布、并发调度都有关系排查时如果拿不到明确报错很容易被当成环境问题忽略。我的建议是所有LIMIT 1 BY的查询默认都写ORDER BY除非你明确不关心取哪一行。第二个是DISTINCT多字段组合去重的认知偏差也就是同一个 user_id 因为设备不同输出多行。这个问题在导出报表时尤其隐蔽因为行数对业务方来说总是“感觉不太对”但又说不出哪里不对。其实只要先确认“唯一键”是什么就不会走偏。返回结果里想要的唯一键是 user_id那 SELECT 里就只能放 user_id其它字段用聚合函数或子查询去取。这两个案例看着简单但据我观察在团队协作里反复出现的频率相当高。写完 SQL 之后多问一句“这个结果里的唯一键是什么”能省掉大量返工。我个人折腾 ClickHouse 这么多年最大的体会是去重这种基础操作恰恰是决定查询性能的分水岭。判断用户有没有行为这件事看起来只是加不加一个DISTINCT的区别实际上背后是 IO 读取范围、内存结构、排序成本和业务语义的一整套取舍。遇到这种需求先别急着敲键盘把“要不要整行、要不要确定顺序、要不要带指标”三个问题过一遍再回来看LIMIT 1 BY和SELECT DISTINCT思路会清晰很多。最后再分享一个小习惯我会在一张经常要判断行为存在性的表上给 action 字段加布隆过滤器索引让WHERE action purchase这类过滤能跳过更多颗粒块。配合前面说的物化汇总表整套方案在千万到亿级行数的场景下基本都够用。如果你也有类似的场景不妨先在测试表上把这几种写法跑一遍数据会告诉你答案。