☰
SQL分组统计不丢明细:窗口函数与GROUP BY方案详解
2026/10/21 16:09:37 网站建设 项目流程

做数据统计的时候,有个需求特别容易让人卡壳:既要按分类统计数量,又要保留订单明细。直接写 group by 吧,分类汇总倒是出来了,但明细行全被压扁成一条条汇总,压根看不到原始订单;用窗口函数 count(*) over(partition by ...) 倒是干净利落,一键达到效果,可要是碰上 MySQL 5.7 这类的老环境,窗口函数直接报错,当场傻眼。这篇文章就是来聊透这个事的:所谓“group by 组内分组统计数量”,到底要怎么玩,才能跟窗口函数一样,把组内数量算出来,又不破坏明细结果。

这个需求在报表、后台管理系统、运营分析里太常见了。比如订单表有商品所在类目,领导要求每个类目旁边列出一共有多少件商品被购买,同时还要能看到每笔订单的完整信息。又比如人员名单里,要统计每个部门各个职级的人数,再把人名保留下来。这类需求的核心逻辑,就是“在分组的基础上做组内计数,并且保留原始行”。别看它听起来简单,实际写起来,不同数据库、不同版本,能用的方案差得挺远,性能差距更是能到几个数量级。今天我就把从窗口函数到老套路的各种实现方式,连同踩过的坑,一次性讲透。

1. 先搞清楚“组内分组统计数量”到底要什么

1.1 需求长什么样:三个典型场景

第一个场景,电商订单分析。销售订单表里有商品名称、商品类目、成交金额,运营想看每个类目下一共成交了多少笔,同时还要盯着订单明细逐条核对。这种“既要总数、又要明细”的诉求,用一句话概括就是:在明细行的基础上,增加一列“我属于哪个组、组里一共有多少条记录”。

第二个场景,人力资源统计。一张员工表,有部门和职级,HR想看每个部门里经理、主管、专员各有多少人,同时还想筛出某个职级下的具体名单。这就是典型的“组内再分组”:先按部门分,再在部门内部按职级统计数量。

第三个场景,财务对账。一笔一笔的流水,需要按月汇总笔数和金额,同时保留每笔流水的明细字段,方便追溯。这类需求在报表工具里尤其常见,Excel 透视表实现起来很容易,SQL 里反而让很多人犯迷糊——因为一旦心里想着“统计数量”,第一反应就是 group by,而 group by 恰恰做不到“保留明细”。

三个场景背后是同一个本质:分组聚合和明细展示,在 SQL 里天然是一对矛盾。想看合并结果,用 GROUP BY;想看明细,直接 SELECT。现在要的是“明细 + 合并结果同时出现”,这就是窗口函数被发明出来要解决的问题,也是用 GROUP BY 模拟窗口函数展开各种操作的根本原因。

1.2 GROUP BY 的天然限制

GROUP BY 的语义是做“行的压缩”:它把指定字段值相同的行组合到一起,每一组只输出一行结果。如果你 SELECT 了非分组字段,就必须用聚合函数包起来,因为那条明细行已经被组合逻辑吃掉,不再存在了。

用生活里的例子解释:GROUP BY 就像一个水果分拣筐,按“类目”这个标签把水果倒进不同的筐里。倒进去以后,筐里是一堆混合水果,你只能数个数、称总重,没办法再把某一个具体水果拿出来贴标签。SUM、COUNT、AVG 这些聚合函数就是干这个的。

所以当你写 SELECT 类目, COUNT(*) FROM 订单表 GROUP BY 类目 的时候,结果永远是每个类目一行,订单号、金额明细全都不见了。不是 SQL 语法不允许,而是 GROUP BY 的处理模型决定了它必须把明细行合并。这就是它的天然限制。知道这个限制,才能理解为什么后面要用 JOIN 把统计结果“拼回去”,或者用相关子查询“逐行去问”。

1.3 窗口函数为什么能做到

窗口函数的全称其实是“在线分析处理函数”,它和 GROUP BY 最大的不同在于:它不会折叠行。它只是在当前查询结果集的基础上,按 PARTITION BY 切分出一个个“窗口”,对每个窗口做聚合计算,然后把计算结果附加到每一行上。

SQL 执行顺序上,窗口函数在 GROUP BY 和 HAVING 之后执行,所以它既可以作用在原始明细行上,也可以作用在已经聚合过的结果上。这一点被很多人忽略,其实特别重要:如果你已经 GROUP BY 了地区,窗口函数仍然可以在地区的分组之上再做一层分区统计,这就是“组内的组”能够实现的原因。

窗口函数还有一个杀手锏,就是可以把 ORDER BY 加在 OVER 里面,实现累计求和、移动平均这类有顺序的统计。GROUP BY 做不到顺序相关的事,因为它的输出是无序的集合。这也是“达到窗口函数效果”这句话里最难模拟的部分。

2. 基准方案:窗口函数直接分区计数

2.1 最基础的 COUNT OVER 写法

先看一个极简的例子,后面所有方案都拿它做对照。假设有一张销售表:

CREATE TABLE sales ( id INT PRIMARY KEY, order_no VARCHAR(20), category VARCHAR(20), amount DECIMAL(10,2) ); INSERT INTO sales VALUES (1, 'SO001', '手机数码', 2999.00), (2, 'SO002', '手机数码', 4999.00), (3, 'SO003', '家用电器', 1899.00), (4, 'SO004', '手机数码', 1599.00), (5, 'SO005', '家用电器', 899.00);

需求是统计每个类目的订单数,同时保留原始明细:

SELECT order_no, category, amount, COUNT(*) OVER (PARTITION BY category) AS category_cnt FROM sales;

执行结果:

order_nocategoryamountcategory_cnt
SO001手机数码2999.003
SO002手机数码4999.003
SO003家用电器1899.002
SO004手机数码1599.003
SO005家用电器899.002

看到没有,每一行都保留下来了,同时多了一列 category_cnt,相同类目的行拿到相同的计数。这个结果就是“窗口函数效果”的标准答案。想统计金额,把 COUNT 换成 SUM(amount);想统计最大、最小、平均,同理换成 MAX、MIN、AVG。

2.2 组内再分组:多字段 PARTITION

“组内分组统计数量”在很多实际问题里其实是两层分组。比如员工表,先按部门分组,再在部门内部按职级统计数量:

SELECT emp_name, dept, emp_level, COUNT(*) OVER (PARTITION BY dept, emp_level) AS dept_level_cnt FROM employee;

PARTITION BY 后面跟多个字段,就是“先按 dept 分区,再按 emp_level 分区”,统计出来的是每个“部门+职级”组合的记录数。这一招解决了单纯 GROUP BY 很难一次性表达的问题——你用 GROUP BY 写,要么只得到一张汇总表格,要么必须把这张汇总表格再拼回明细。

顺带一提,窗口函数在 GROUP BY 之后执行的特性,这时候能玩出花活。比如你已经有一张按地区汇总的月度报表,还想在地区维度之上做全国统计:

SELECT region, SUM(month_amount) AS region_amount, SUM(SUM(month_amount)) OVER () AS total_amount FROM monthly_sales GROUP BY region;

注意内层的 SUM 是 GROUP BY 的聚合,外层的 SUM(...) OVER () 是在聚合结果之上再开窗口做全国合计。这就是“组内的组”在 SQL 里的真实形态,也是“group by 组内分组统计数量”最标准的窗口函数版本。

2.3 窗口函数不是万能的:两个注意点

第一个注意点:MySQL 8.0 之前的版本完全不支持窗口函数。很多公司的主力库还停留在 5.7,或者云数据库是历史版本,这时候窗口函数写法跑都跑不起来,必须回到 GROUP BY + JOIN 的老路子上。

第二个注意点:窗口函数里的 ORDER BY 会改变窗口统计的语义。比如 COUNT(*) OVER(PARTITION BY category ORDER BY amount),统计的不是“整个类目的总订单数”,而是“按金额排序后,当前行及之前行累计的行数”。两者结果完全不同,我刚接触窗口函数时就栽过这个跟头,把累计计数当成总量用,报表差点发出去。如果需要总量,ORDER BY 一定不要加在 OVER 里面。

3. 用 GROUP BY + JOIN 模拟窗口效果

3.1 核心思路:把汇总结果拼回明细

既然 GROUP BY 会把明细折叠,那思路就反过来:先用 GROUP BY 算好每个组的数量,得到一张“组统计汇总表”,再用 JOIN 把这张汇总表拼回原来的明细表。每个明细行,通过分组键匹配,带出自己所属分组的统计数量。

这其实就是在手动做窗口函数的事,只不过窗口函数把“分区、聚合、回填”封装在一个操作里,而这里是用两步显式完成。兼容性最好,任何数据库版本都能跑,也是老版本环境下的主力方案。

3.2 完整写法与关键细节

针对上面的 sales 表,SQL 如下:

SELECT s.order_no, s.category, s.amount, t.category_cnt FROM sales s LEFT JOIN ( SELECT category, COUNT(*) AS category_cnt FROM sales GROUP BY category ) t ON s.category = t.category;

结果和窗口函数完全一样。这里有三个细节必须说清楚。

第一,JOIN 要用 LEFT JOIN,不要用 INNER JOIN。因为 INNER JOIN 会把子查询里没有匹配到的明细行全部滤掉。比如 category 字段存在 NULL 的情况,GROUP BY 会把所有 NULL 归为一组并统计数量,但 JOIN 匹配 NULL 时等号不成立,INNER JOIN 就会把这些行丢掉。用 LEFT JOIN 至少保证右侧没匹配上时,左侧明细还在,统计列显示为 NULL。如果你希望 NULL 也能正确归组统计,可以先把 NULL 转成默认值,JOIN 键两边都做同样的 COALESCE 处理。

第二,子查询里 GROUP BY 的字段,必须和 JOIN ON 的字段完全一致。比如你想按“区域+类目”统计,子查询就必须 GROUP BY region, category,ON 条件同样要带上这两个字段。少写一个字段,匹配就会错位,最典型的现象是数量列出现重复或者总和不对。

第三,如果明细表很大,子查询先做分组聚合再 JOIN,本质上是把一张表扫描了两遍。第一遍在子查询里做全表分组,第二遍 JOIN 回原表取明细。数据量大时,要确保分组键上有索引,否则性能会明显下滑。

3.3 这种方法的天花板在哪里

GROUP BY + JOIN 的写法虽然兼容老版本,但也有明显边界。

第一个边界是“顺序类统计”。你想算每个订单在其所属类目金额中的累计占比,即按金额从大到小累计,GROUP BY + JOIN 是写不出来的,因为 JOIN 回填的是一个静态汇总值,它无法感知业务行在分组内部的顺序。要模拟带 ORDER BY 的窗口语义,就得用后面讲的会话变量技巧,复杂度直接上一个台阶。

第二个边界是代码可读性。业务规则一多,一个查询里嵌套四五个这样的子查询 JOIN,SQL 会变得又长又难维护。我之前维护过一张统计报表,从左到右嵌套了三层这样的 JOIN,后来接手的同学改需求,光是定位某个字段来自哪个子查询就花了小半天。

第三个边界是“在同一结果集上多次开窗”。窗口函数可以同时写好几个 COUNT OVER、SUM OVER,互不干扰;GROUP BY + JOIN 每多一个统计维度,就要多写一个子查询、多一次 JOIN,SQL 膨胀速度很快。

所以我的建议是:只要能确认运行环境支持窗口函数,优先用窗口函数;GROUP BY + JOIN 保留给老版本环境,或者作为排查数据时的对照写法。

4. 相关子查询:最直白但最容易踩性能坑

4.1 实现原理与写法

相关子查询的思路更直观:每一行明细,都去原表里数一数“和我属于同一个分组的记录有多少条”:

SELECT s.order_no, s.category, s.amount, (SELECT COUNT(*) FROM sales s2 WHERE s2.category = s.category) AS category_cnt FROM sales s;

这个写法在逻辑上没有任何问题,返回结果和窗口函数、GROUP BY + JOIN 完全一致。而且它有一个优点:想加过滤条件非常容易。比如统计“当前订单所属类目的总订单数,但只看金额大于 500 的”,在子查询的 WHERE 里直接加条件就行。

4.2 性能与适用边界

问题出在性能上。相关子查询是“逐行驱动”的执行方式:外层表有多少行,内层子查询就被执行多少次。外层 10 万行,子查询就可能被触发 10 万次,每次还要对 sales 表做一次分类字段的查找。

如果分类字段没有索引,复杂度基本是 O(N*M),N 是外层行数,M 是表总行数。数据量一上去,这条 SQL 能跑得让人怀疑服务器挂了。我以前在一张 30 万行的订单表上用这种写法做测试,查一次花了将近 40 秒,后来改成 GROUP BY + JOIN,1 秒以内出结果。

所以相关子查询的适用场景非常窄:数据量小(几千行以内)、临时排查、或者子查询里能通过索引快速命中。生产环境的大表统计,不要首选它。

4.3 子查询 + GROUP BY 的组合用法

还有一种情况容易被搞混:在子查询里做 GROUP BY,再在外面做关联。比如统计每个类目的订单数:

SELECT s.order_no, s.category, t.category_cnt FROM sales s LEFT JOIN ( SELECT category, COUNT(*) AS category_cnt FROM sales GROUP BY category ) t ON s.category = t.category;

这个其实已经是第 3 节的方法,注意它不是相关子查询,而是派生表 JOIN。区分两者的关键:相关子查询里能引用外层表的字段,而派生表是独立的、先执行完再拼进来。在实际报表开发中,派生表 JOIN 是比相关子查询稳妥得多的选择,逻辑等价,性能却好很多。

5. 老版本 MySQL 的过渡技巧:会话变量

5.1 用变量实现组内连续编号

在 MySQL 5.7 及更早的版本里,既没有窗口函数,又不想写多重 JOIN,老一辈开发者发明了“会话变量”的土办法。核心思路是:按分组字段排序后逐行遍历,用变量记录“当前所在分组”和“当前分组的行号”。

组内连续编号的经典写法:

SELECT order_no, category, @row_num := IF(@current_category = category, @row_num + 1, 1) AS row_num, @current_category := category AS current_category FROM sales ORDER BY category, order_no;

注意一个关键点:你最好不要在一个 SELECT 里同时给一个变量赋值又引用它的新值,MySQL 的表达式求值顺序在不同版本里有差异。稳妥的做法是把变量赋值语句放在最后一个 SELECT 列,或者用子查询包一层,避免优化器改变求值顺序导致结果错乱。

5.2 用变量模拟组内总数量

如果需要的是“组内总数”而不是行号,用变量也可以做,但非常绕:先按分组字段倒序遍历,让每个组最后一行先被赋值为组内数量,再在外层取对应值。这个写法很不直观,也不稳定,我在真实项目里基本不会用。

相比会话变量,在老版本上统计组内数量,更推荐 GROUP BY + JOIN,语义清楚、性能可控、不怕执行计划改变结果。会话变量的写法更适合“组内行号”这一类确实没有替代方案的场景,而且必须接受它对排序极其敏感的事实。

5.3 这个方法的坑与替代建议

会话变量最大的坑,就是 MySQL 优化器可能改变子查询的执行顺序,导致变量赋值和读取的顺序失控。同一个查询,在测试环境跑得好好的,上了生产,由于统计信息不同,执行计划变了,结果就错了。这种问题极其隐蔽,数据对不上时很难排查。

另一个坑是排序稳定性。变量方法是靠“排序后遍历”来识别分组边界的,如果排序字段不唯一,同一分组内的相对顺序可能每次查询都不一样,行号结果就飘忽不定。这时要在 ORDER BY 里加上主键或唯一字段,保证排序完全确定。

所以给老版本用户的建议很简单:能用 GROUP BY + JOIN 解决的事,别碰会话变量;只有在带顺序的语义实在绕不开时,才考虑变量,并且务必在子查询外层加一层封装,同时加注释说明这个查询的稳定性边界。

6. 性能实测与选型建议

6.1 三种方案执行原理对比

方案扫描次数资源消耗是否支持顺序统计兼容性
窗口函数1 次 + 分区排序内存或临时表支持,OVER 里加 ORDER BYMySQL 8.0+ 等
GROUP BY + JOIN2 次(分组 + 回贴)排序、临时表不支持所有版本
相关子查询N+1 次取决于索引可改 SQL 实现所有版本

窗口函数最优秀的地方在于语义直接:一次扫描,分区聚合,行不会被折叠。它需要的额外资源主要是排序,如果 PARTITION BY 字段有索引,或者结果集能放进内存的排序缓冲,性能通常是最好的。

GROUP BY + JOIN 多了一次全表扫描,但实际差距没有想象中大。子查询里的 GROUP BY 同样是一次全表扫描加一次排序,JOIN 回去时如果分组键有索引,性能完全可以接受。在百万行以内,窗口函数和 GROUP BY + JOIN 的差别很多时候用秒表根本测不出来。

相关子查询是真正的性能天坑,尤其当外层表大且分组字段没有索引时,N+1 次查询会放大到恐怖的量级。数据量小的时候,它的灵活性和简单性还能加分;数据量一大,直接出局。

6.2 索引和数据量如何影响选择

分组字段上有没有索引,影响非常大。窗口函数如果 PARTITION BY 字段有索引,MySQL 可以利用索引的有序性避免额外的排序过程;GROUP BY + JOIN 的子查询同样可以利用索引减少分组排序开销;相关子查询更是严重依赖索引,没有索引就是灾难。

数据量在万级以下,怎么选都差不多,差别在毫秒级。数据量到百万级,优先窗口函数;没有窗口函数的环境,优先 GROUP BY + JOIN。数据量到千万级,GROUP BY + JOIN 的两次全表扫描会开始吃力,这时候要么升级到支持窗口函数的版本,要么把统计结果落成中间表,而不是在应用每次实时计算。

6.3 最终选型决策表

环境情况需求类型推荐方案
MySQL 8.0+ / PostgreSQL / Oracle常规分组计数,保留明细窗口函数
MySQL 8.0+需要顺序累计、组内占比窗口函数 + OVER ORDER BY
MySQL 5.7 及以下常规分组计数,保留明细GROUP BY + JOIN
MySQL 5.7 及以下组内行号 / 顺序逻辑会话变量(谨慎使用)
任意环境,仅临时排查少量数据快速验证相关子查询

选型的原则就一句话:能用窗口函数优先用窗口函数,它是在 SQL 标准层面解决了“聚合与明细并存”的问题;老版本用 GROUP BY + JOIN 作为主力,其他奇技淫巧尽量少碰。

7. 完整实战:订单销售统计

7.1 建表与造数据

造一张稍微完整点的订单表,方便观察不同写法的差异:

CREATE TABLE sales_order ( id INT PRIMARY KEY, region VARCHAR(20), product_name VARCHAR(50), category VARCHAR(20), amount DECIMAL(10,2), sale_date DATE ); INSERT INTO sales_order VALUES (1, '华东', '手机A', '手机数码', 2999.00, '2024-01-05'), (2, '华东', '手机B', '手机数码', 4999.00, '2024-01-12'), (3, '华北', '冰箱', '家用电器', 1899.00, '2024-01-18'), (4, '华东', '平板', '手机数码', 1599.00, '2024-02-02'), (5, '华北', '洗衣机', '家用电器', 899.00, '2024-02-10'), (6, '华南', '手机A', '手机数码', 2899.00, '2024-02-15'), (7, '华南', '空调', '家用电器', 3299.00, '2024-03-01'), (8, '华北', '手机B', '手机数码', 4599.00, '2024-03-08'), (9, '华东', '笔记本', '电脑办公', 5699.00, '2024-03-20');

7.2 需求一:类目维度订单量并保留明细

窗口函数写法:

SELECT order_no, region, category, amount, COUNT(*) OVER (PARTITION BY category) AS category_order_cnt FROM sales_order;

GROUP BY + JOIN 写法:

SELECT s.order_no, s.region, s.category, s.amount, t.category_order_cnt FROM sales_order s LEFT JOIN ( SELECT category, COUNT(*) AS category_order_cnt FROM sales_order GROUP BY category ) t ON s.category = t.category;

两组 SQL 输出的 category_order_cnt 完全一致:手机数码有 4 笔,家用电器有 3 笔,电脑办公有 1 笔。注意“电脑办公”这个类目只有一条数据,它的统计数量就是 1,不管用哪种写法都能正确显示,不会因为组内只有一行而消失。

7.3 需求二:按区域 + 类目双重分组统计

现在需求升级:想看每个区域下各个类目的订单数,同时保留订单明细。这就是标准的“组内再分组”——先按 region 分,再按 category 统计。

窗口函数写法,PARTITION BY 跟两个字段:

SELECT order_no, region, category, amount, COUNT(*) OVER (PARTITION BY region, category) AS region_category_cnt FROM sales_order;

GROUP BY + JOIN 写法,子查询同样 GROUP BY 两个字段:

SELECT s.order_no, s.region, s.category, s.amount, t.region_category_cnt FROM sales_order s LEFT JOIN ( SELECT region, category, COUNT(*) AS region_category_cnt FROM sales_order GROUP BY region, category ) t ON s.region = t.region AND s.category = t.category;

作用到造的数据上,华东的手机数码是 3 笔,华东的家用电器是 0 笔(所以装不下),华南的每类 1 笔,华北的家用电器 2 笔。这种双重分组统计在报表里极其常见,也是容易被 JOIN 键写漏的高发区,一定要记得 ON 条件里两个字段都要带。

7.4 需求三:组内金额占比与累计排序

最后一个需求最接近“非窗口函数不可”的场景:计算每条订单金额占所属类目总金额的百分比,并且按金额降序看累计占比。

窗口函数版本:

SELECT order_no, category, amount, SUM(amount) OVER (PARTITION BY category) AS category_total, ROUND(amount / SUM(amount) OVER (PARTITION BY category) * 100, 2) AS pct, SUM(amount) OVER (PARTITION BY category ORDER BY amount DESC) AS cum_amount FROM sales_order;

amount 除以组内总金额得到百分比,覆盖了聚合和明细的混合计算;累计金额那一列则依靠 OVER 里的 ORDER BY 实现,这是 GROUP BY + JOIN 无法直接做到的。

GROUP BY + JOIN 可以完成百分比部分:

SELECT s.order_no, s.category, s.amount, t.category_total, ROUND(s.amount / t.category_total * 100, 2) AS pct FROM sales_order s LEFT JOIN ( SELECT category, SUM(amount) AS category_total FROM sales_order GROUP BY category ) t ON s.category = t.category;

但累计金额 cum_amount 写不出来,只能先查出来,在应用层再算,或者用会话变量硬凑。这就是为什么我一直在强调:遇到“组内顺序相关”的统计,窗口函数基本没有替代品。

8. 容易踩的坑:问题排查与避坑技巧

8.1 GROUP BY 后明细丢失的补救

很多人第一次遇到 GROUP BY 丢明细,是在 MySQL 5.7 开启 ONLY_FULL_GROUP_BY 之后:SELECT 里的非聚合列不在 GROUP BY 中,直接报错。即使某些数据库允许这种写法,返回的也是组内任意一条记录,结果随机,完全不可控。

补救方案就两个:一个是回到窗口函数,让明细和聚合并存;一个是用 GROUP BY + JOIN 回填。如果只是想要“每个分组的第一条”,可以用窗口函数 ROW_NUMBER(),那又是一套独立玩法了。

8.2 JOIN 导致数据翻倍

GROUP BY + JOIN 的写法里,翻倍通常不是主表的问题,而是子查询分组粒度写错了。比如想按“类目”统计,子查询却写 GROUP BY category, product_name,这时每个类目下会有多行子查询结果。JOIN 时如果只 ON category,原表里一个类目下有 3 个产品,那么每条明细就会被匹配 3 次,数量直接翻 3 倍。

排查方法很简单:先单独跑子查询,看它返回的行数是不是“去重后的分组数”。如果子查询自己就多行了,JOIN 翻倍是必然的。记住一条规则:JOIN 键的粒度必须和统计粒度完全一致,多一分组字段就多一个 JOIN 条件。

8.3 NULL 分组与空值处理

GROUP BY 会把 NULL 视为一个分组并正常统计数量,但 JOIN 匹配 NULL 时等号不成立,这是最容易忽视的差异。同一张表,窗口函数的结果里 NULL 组的计数有效,换成 GROUP BY + JOIN 后,NULL 组的行可能因为匹配不上而变成 NULL 计数。

解决方式是统一空值标签,JOIN 两侧都做转换:

COALESCE(s.category, '未分类') = COALESCE(t.category, '未分类')

子查询里也要同步处理。如果不希望“未分类”参与统计,也可以在 WHERE 里先排除空值,但要记得窗口函数和 JOIN 两侧的过滤条件保持一致。

8.4 去除重复统计的技巧

有些业务表存在重复行,比如订单表被多次同步导致同一个订单号出现多遍。直接 COUNT(*) 会把重复记录也算进去,这时候需要计数维度明确。两种常见修正:一是 COUNT(DISTINCT order_no),统计唯一订单数;二是先对源表去重,再统计。

这里有个隐藏的版本差异:MySQL 8.0 目前不支持 COUNT(DISTINCT ...) OVER(),直接写会报语法错误,而 PostgreSQL 支持。所以如果你在 MySQL 8.0 上想把“每个类目下唯一订单数”算到每一行上,窗口函数反而做不到,只能退回到 GROUP BY + JOIN:

SELECT s.*, t.distinct_order_cnt FROM sales_order s LEFT JOIN ( SELECT category, COUNT(DISTINCT order_no) AS distinct_order_cnt FROM sales_order GROUP BY category ) t ON s.category = t.category;

这种“窗口函数做不到、GROUP BY + JOIN 反而能做”的场景,也是我在实际工作中才发现的,值得记一笔。

8.5 版本升级迁移窗口函数的注意事项

如果是从老版本升级到 MySQL 8.0,把旧的 GROUP BY + JOIN 换成窗口函数时,最容易出错的不是语法,而是过滤条件的位置。

看这个对比:

-- 老写法:子查询先过滤,再统计 LEFT JOIN ( SELECT category, COUNT(*) AS cnt FROM sales_order WHERE amount > 1000 GROUP BY category ) t ... -- 新写法:主查询过滤,再开窗 COUNT(*) OVER (PARTITION BY category) AS cnt FROM sales_order WHERE amount > 1000;

细看会发现,两种写法的统计范围其实一致:都是先过滤出金额大于 1000 的行,再按类目计数。但迁移时如果把主查询的 WHERE 写错了,或者漏掉了和子查询 WHERE 的一致性,统计结果就会对不上。我曾经就犯过只改窗口函数、忘了同步主查询过滤条件的错,结果整张报表的类目基数比老系统少了三分之一,排查了大半天才找到原因。

迁移的原则就一条:先用一个“明细完整版”的查询跑一遍,把窗口函数的结果和 GROUP BY + JOIN 的结果并排放在一起比对,确认每一行的统计列完全一致,再上线。

特别注意:如果你的老 SQL 里子查询 JOIN 的过滤条件放在子查询内部,而主查询还有别的过滤,窗口函数版需要把两组条件拆清楚。主查询 WHERE 只控制“哪些行进结果集”,窗口的统计范围由最终结果集决定。要让统计范围一致,所有过滤都必须发生在开窗之前。

我个人在实际操作中的体会是:别把问题想复杂了。90% 的“组内分组统计数量”需求,用窗口函数十秒钟就能写出来,剩下的 10% 老版本环境,GROUP BY + JOIN 也足够稳妥。真正需要折腾会话变量的场景极少,属于了解原理、但别轻易上生产的范畴。

最后再分享一个实用小技巧:判断自己写的几条 SQL 是否等价,不要只看结果集的行数,要把“分组键、统计列、明细列”三列拼成一个对比表,用 EXCEPT 或者 NOT EXISTS 做全量比对。我每次重构统计 SQL 都用这招,能快速抓到 WHERE 条件不一致、JOIN 粒度错位这些隐蔽问题。等你能熟练地在窗口函数和 GROUP BY + JOIN 之间来回切换,再复杂的统计需求到你手里都是一行 SQL 的事。

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

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

立即咨询