ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PostgreSQL GROUP BY DISTINCT:彻底讲清分组集去重与报表汇总翻倍的解法

PostgreSQL GROUP BY DISTINCT:彻底讲清分组集去重与报表汇总翻倍的解法 你有没有遇到过这种情况报表里的地区维度合计怎么算都比上一版多了一倍我印象最深的一次是整个BI报表的“总体汇总”行凭空翻倍排查了一下午才发现罪魁祸首是SQL里一个多余的GROUPING SETS重复项。后来我仔细研究PostgreSQL的分组集语法才发现官方其实给了一个专门解决这个问题的写法——GROUP BY DISTINCT。这篇文章想把它彻底讲透它到底去掉了什么为什么不能把它和SELECT DISTINCT等同以及在真实的报表和数据处理场景里我们应该怎么正确使用它。1. 一个让人误会的语法GROUP BY DISTINCT到底在去什么重1.1 一个最小可复现示例先建一个简单的销售流水表后面的所有例子都会基于它CREATE TABLE sales ( region text, channel text, amount numeric ); INSERT INTO sales VALUES (华东, 线上, 100), (华东, 线上, 200), (华东, NULL, 50), (华北, 线下, 300), (华北, 线下, 400);注意我特意插入了一行channel为NULL的数据它是后面理解语义的关键。现在看第一段查询用最原始的GROUPING SETS写法SELECT region, channel, SUM(amount) AS total FROM sales GROUP BY GROUPING SETS ((region, channel), (region, channel));这个查询里我故意把(region, channel)写了两次。结果会是什么两行还是四行实测结果是(华东, 线上, 300)出现两次(华北, 线下, 700)出现两次(华东, NULL, 50)也出现两次。总行数直接翻倍。因为GROUPING SETS列表里有两个分组集虽然它们一模一样数据库还是会老老实实把每个分组集各执行一遍聚合再把结果UNION ALL拼起来。分组集重复 → 输出行翻倍这个因果关系非常直接。改成GROUP BY DISTINCT之后SELECT region, channel, SUM(amount) AS total FROM sales GROUP BY DISTINCT GROUPING SETS ((region, channel), (region, channel));结果立刻恢复正常每个分组只输出一行。也就是说DISTINCT把GROUPING SETS列表里重复的分组集定义合并了整个查询等价于GROUP BY GROUPING SETS ((region, channel))也就是普通的两列分组聚合。1.2 关键洞察它去的是“分组集”不是“结果行”很多人看到GROUP BY DISTINCT第一反应是“对分组后的结果行去重”这是最容易被带偏的理解。我做一个更极端的实验。把查询改成两个不同的分组集(region, channel)和(region)。这两个分组集都不重复GROUP BY DISTINCT显然不会消除任何东西。但有趣的是两个分组集可能会输出外表看起来完全一样的行。SELECT region, channel, SUM(amount) AS total FROM sales GROUP BY DISTINCT GROUPING SETS ((region, channel), (region));执行结果regionchanneltotal华东线上300华东NULL50华北线下700华东NULL50注意最后两行(华东, NULL, 50)出现了两次。第一次来自(region, channel)分组集里对华东、NULL这一组的聚合第二次来自(region)分组集里对华东地区的聚合。两个不同的分组集产生了完全相同的输出行但GROUP BY DISTINCT并不会把其中一行去掉。为什么因为DISTINCT作用在GROUPING SETS的列表层面它只会合并“定义完全相同”的分组集。(region, channel)和(region)是两个不同的分组集定义即便它们在特定数据下生成了相同的查询结果语义上依然是两种分组方式不会被合并。这一点是整个语法最核心的分界线GROUP BY DISTINCT去重的是分组集本身而不是最终输出的行。如果你想要的是“输出行去重”应该用SELECT DISTINCT那是另一套机制。2. 分组集模型为什么会出现“重复的分组集”2.1 GROUPING SETS、ROLLUP、CUBE是如何展开的要理解重复分组集从哪来必须先把分组集的展开规则弄清楚。GROUPING SETS是PostgreSQL 9.5引入的核心特性它允许一次查询里定义多个分组方式数据库会对每个分组集分别执行聚合最后用UNION ALL语义合并结果。GROUP BY GROUPING SETS ((a, b), (a), ())等价于三条SQL的UNION ALLSELECT a, b, SUM(...) FROM t GROUP BY a, b UNION ALL SELECT a, NULL, SUM(...) FROM t GROUP BY a UNION ALL SELECT NULL, NULL, SUM(...) FROM tROLLUP和CUBE是GROUPING SETS的简写。ROLLUP (a, b)展开为GROUPING SETS ((a, b), (a), ())CUBE (a, b)展开为GROUPING SETS ((a, b), (a), (b), ())。关键在于展开结果是一组分组集而不是一组输出行。如果你在GROUPING SETS里嵌套了ROLLUP、CUBE或者通过代码动态生成了分组集列表那么“展开后产生两个完全相同的分组集”是完全可能出现的。2.2 冗余从哪来嵌套、组合与动态生成最常见的情况有三种。第一种是嵌套展开后重合。比如GROUP BY DISTINCT GROUPING SETS ( ROLLUP (region, channel), ROLLUP (region, channel) )两个ROLLUP (region, channel)各自展开为((region, channel), (region), ())如果不用DISTINCT六个分组集里每个都会重复一次查询结果直接膨胀一倍。这种写法看起来像是手误但在ORM、报表模板、代码生成器里极其容易出现。第二种是跨层组合时重合。比如CUBE (a, b)和ROLLUP (a, b)嵌套在同一个GROUPING SETS列表里时两者展开后的部分分组集会重叠但这种重叠通常只发生在恰好定义的组合完全相同的情况下实际不多见。第三种是动态拼接SQL时的重复。报表平台里用户勾选了维度后端用字符串拼接生成GROUPING SETS列表如果维度组合在多层循环里被重复追加SQL文本层面就自带冗余。这种情况在真实生产环境里远比手写SQL常见。2.3 与其他数据库的对比PostgreSQL不是唯一遇到分组集问题的数据库但它是为数不多把解决方式做成标准语法的数据库。数据库GROUPING SETSROLLUPCUBEGROUP BY DISTINCTPostgreSQL 9.5支持支持支持支持MySQL 8.x不支持支持不支持不支持SQL Server支持支持支持需要结合GROUPING IDOracle支持支持支持不支持该写法Oracle和SQL Server的解决办法是手动保证分组集不重复或者在应用中层做好消重。PostgreSQL直接用DISTINCT修饰GROUP BY把这件事交还给SQL引擎处理。对于从其他数据库迁移到PostgreSQL的开发来说这是一个非常容易忽略但又很友好的差异点。3. 从语法到执行计划GROUP BY DISTINCT的真实处理链路3.1 语法形式与生效位置PostgreSQL的GROUP BY子句完整语法是GROUP BY [ ALL | DISTINCT ] grouping_element [, ...]其中grouping_element可以是()表示整体聚合也就是没有分组键的汇总行expression单列、多列表达式组成的一个分组集GROUPING SETS ( ... )递归定义的一组分组集ROLLUP ( ... )CUBE ( ... )ALL是默认行为保留所有分组集。DISTINCT则要求PostgreSQL在执行聚合前先把重复的分组集定义合并。需要注意语法位置DISTINCT必须紧跟GROUP BY写在GROUP BY DISTINCT GROUPING SETS (...)这种位置不能塞进GROUPING SETS括号内部。写GROUP BY GROUPING SETS (DISTINCT ...)是非法的这一点和SELECT里的DISTINCT惯用位置完全不同。这个去重发生在什么阶段从语义分析的角度看它发生在查询解析和重写阶段而不是执行阶段。PostgreSQL在做完语法树分析、逻辑重写之后会把GROUP BY DISTINCT的分组集列表做一次集合化处理最终交给执行器的就是一个精简后的分组集列表。3.2 EXPLAIN能看到什么直接看执行计划会更直观EXPLAIN (VERBOSE, COSTS OFF) SELECT region, channel, SUM(amount) AS total FROM sales GROUP BY DISTINCT GROUPING SETS ((region, channel), (region, channel));输出大概是GroupAggregate Group Key: region, channel - Seq Scan on public.sales如果把DISTINCT去掉再跑一次执行计划几乎完全一样但Worker节点或聚合节点可能出现两次分组集计算行数翻倍。实际上在同一个版本里加了DISTINCT后的计划会退化为普通的两列分组聚合因为重复的分组集在校验阶段就被合并掉了。这里有一个被普遍低估的结论GROUP BY DISTINCT不会引入任何额外的运行时开销。它的工作发生在计划生成之前不是执行器里多跑了一个去重节点。所以担心“加DISTINCT会不会变慢”是完全多余的恰恰相反它省下了重复分组集那部分计算。如果分组集列表里既有重复项又有非重复项比如GROUP BY DISTINCT GROUPING SETS ((region, channel), (region, channel), (region))执行计划会保留两个不同的分组集(region, channel)和(region)。PostgreSQL在多个分组集场景下会展开多个GroupAggregate分支或者走HashAggregate的多次分组但无论如何重复的(region, channel)不会再出现第二次计算。3.3 与GROUPING()函数的配合使用GROUPING()函数时也能验证DISTINCT的语义作用点SELECT region, channel, GROUPING(region) AS grp_region, GROUPING(channel) AS grp_channel, SUM(amount) AS total FROM sales GROUP BY DISTINCT GROUPING SETS ((region, channel), (region, channel));结果里不会出现grp_region 0, grp_channel 0重复多行的情况。因为重复的分组集被合并了GROUPING()函数对应的只是“分组集定义里的位置”而不是“行的唯一性”。如果两个分组集定义不同但输出行相同GROUPING()函数的值反而很可能不同这正是区分它们来源的关键手段。4. 与SELECT DISTINCT、聚合去重的分工三个最容易混淆的场景4.1 SELECT DISTINCT是输出行去重SELECT DISTINCT作用于查询结果集对整个结果行的组合做去重这是大家最熟悉的语义。SELECT DISTINCT region, channel FROM sales;这段SQL会把region和channel相同的结果行合并成一行。它和GROUP BY无关也和分组集无关纯粹是“最终输出的行不要重复”。如果拿SELECT DISTINCT和GROUP BY DISTINCT互换思维就会犯我开头说的那种错误——把分组集翻倍问题当成输出行问题来处理。报表里汇总行翻倍有人直接在外面套一层SELECT DISTINCT ... FROM (...) x结果小计行和总计行因为聚合值不同没被合并问题依然存在就算合并了也只是掩盖了错误计算并没有修正分组逻辑。4.2 COUNT(DISTINCT ...)是聚合内去重COUNT(DISTINCT column)是聚合函数的参数级去重作用域是“每个分组内部”统计的是某一列在分组内的唯一值个数。SELECT region, COUNT(DISTINCT channel) AS channel_cnt FROM sales GROUP BY region;它既不是对输出行去重也不是对分组集去重只是聚合计算过程中的一个特殊参数处理。这三个语义恰好都带“DISTINCT”字样放在一起最容易混。4.3 GROUP BY DISTINCT是分组集定义去重三者对比起来看写法去重作用点典型场景SELECT DISTINCT最终结果行消除重复行输出COUNT(DISTINCT col)聚合函数入参分组内统计唯一值个数GROUP BY DISTINCT分组集定义列表消除重复分组集防止汇总翻倍简单记SELECT DISTINCT输出无重复COUNT(DISTINCT)组内无重复GROUP BY DISTINCT分组集无重复。作用对象一个比一个更靠底层。有一个边界情况需要特别点出来如果我只写普通的GROUP BY DISTINCT region, channel也就是后面没有GROUPING SETS、ROLLUP、CUBE这个写法从语法上是允许的但语义上等于GROUP BY region, channel。因为单组分组集本身不可能重复。所以只有在分组集列表是“集合”的时候DISTINCT才真正发挥价值。5. 实战思考什么时候值得用什么时候别画蛇添足5.1 代码生成器和动态SQL是主战场手写SQL时正常开发不会给自己挖坑写两个一模一样的GROUPING SETS。但在动态SQL生成器里情况完全不同。我维护过一个报表查询模块用户在前端勾选“维度组合”后端根据勾选项生成GROUPING SETS列表。用户先勾了“华东线上”又勾了“华东地区小计”后端在拼接时如果不小心把维度组合重复追加进列表GROUP BY DISTINCT就是兜底防线。SELECT region, channel, SUM(amount) AS total FROM sales GROUP BY DISTINCT GROUPING SETS ( (region, channel), (region), (region, channel) )如果没有DISTINCT(region, channel)会被计算两次前端看到的明细行翻倍而且很难一眼发现。有了DISTINCT生成器即使偶尔拼出重复项执行层面也不会出错。这类系统通常还有一个特点分组集由配置文件或前端参数驱动不可能每次都在SQL里做人工审查。语法层的防御性设计此时比任何外围校验都可靠。5.2 可以等价改写的场景既然GROUP BY DISTINCT只是消除重复分组集那么等价改写其实很简单直接把重复项从GROUPING SETS列表里删掉就行。-- 原始写法 GROUP BY DISTINCT GROUPING SETS ((region, channel), (region, channel), (region)) -- 等价写法 GROUP BY GROUPING SETS ((region, channel), (region))两者的执行计划完全一样因为语法分析阶段已经把冗余项合并了。所以在代码审查或者迁移旧SQL时如果看到有人写了GROUP BY DISTINCT不要急着删先判断它保护的是不是动态分组集。如果是手写的静态SQL且列表里没有任何重复项那它确实是冗余的可以清理掉。这里要特别提醒GROUP BY DISTINCT不等于“把所有输出结果去重”。如果你发现报表里有重复行但分组集定义本身没有任何重复那说明问题出在数据源或者SQL逻辑上套GROUP BY DISTINCT不会起任何作用应该回到结果集层面去排查。5.3 判断原则我总结了一套简单的判断流程遇到问题时可以照着过一遍先看重复出现在哪一层。如果是SELECT输出的行重复属于结果行问题考虑SELECT DISTINCT或窗口函数。如果同一个聚合键出现了N次而分组集定义完全相同属于分组集重复问题用GROUP BY DISTINCT修正。如果分组集完全不同但输出行恰好相似这是数据层面的巧合既不要用SELECT DISTINCT强行合并也不要指望GROUP BY DISTINCT应该回到业务口径去判断。如果使用了ROLLUP、CUBE嵌套优先考虑在生成SQL的源头消除重复GROUP BY DISTINCT只做兜底。5.4 我踩过的一个相关的坑回到文章开头那个案例。当初报表汇总翻倍我排查的路径是这样的先是怀疑数据源被重复加载查了一圈没发现问题然后怀疑JOIN产生的笛卡尔积把SQL拆开明细对不上最后才注意到分组集模板里开发把“明细行”和“地区小计”两组维度组合写重了。修复方式有两种一种是把模板字符串里的重复项删掉一劳永逸另一种是在公共查询入口加GROUP BY DISTINCT。当时我两个都做了前者解决当下的错后者防止以后代码生成逻辑再犯。后来我在其他项目里也养成了习惯只要SQL文本是由程序拼接出来的GROUP BY子句里就尽量用DISTINCT修饰成本几乎为零收益却是在未来某次发布时可能替你挡掉一个诡异的报表Bug。代价只是代码风格上多敲一个词没有执行计划上的额外开销没有排序语义的变化甚至不会影响普通GROUP BY的使用。所以我的建议是如果你在写手写报表SQL把GROUP BY DISTINCT当作一个清晰的防御性工具来用如果你在维护动态SQL生成器把它接入模板层能省掉以后很多排查重复汇总的苦恼。但如果你只是想去掉结果中的重复行请老老实实去学SELECT DISTINCT和窗口函数不要把语义搞混了。
返回列表