SQL聚合全解析:从高频面试题到实战性能优化
2026/9/8 21:34:02 网站建设 项目流程

开头直接切入:SQL聚合是数据分析最常用的能力,也是高频面试题。分享自己刷“高频SQL 50题”中聚合部分的经验,拆解考点、常见场景、性能陷阱,并附实战SQL。

注意每段150字以上,总5000字以上。需要认真扩展。 换一个技术圈里比较有意思的现象:刷过LeetCode SQL题库或者各种“高频SQL 50题”的人,十有八九都会卡在“聚合”这一块。不是因为它难,而是因为平时写业务SQL时,大多数情况只是拿COUNT、SUM、AVG来糊弄个报表数字,真到了面试那种“给你一张表、一个业务场景、让你写出统计结果”的题目,才会发现聚合这玩意儿藏着大量细节。

聚合查询表面上是SQL里最“基础”的功能,但恰恰是它决定了你能不能高效处理数据。我见过不少工作三五年的开发,写复杂报表时还在用“一张临时表+多个子查询+各种JOIN”硬编,效率低且容易出错。这篇文章就把我刷“高频SQL 50题”里聚合部分的一些心得整理出来,同时结合日常工作踩过的坑,把聚合函数、分组逻辑、窗口函数、性能优化一次性讲透。不管你是准备面试、在做数据分析,还是每天跟报表打交道的后端开发,这篇内容应该都能帮你在聚合这条路上少走弯路。

1. 聚合SQL到底考什么:从高频50题看考点分布

先把“高频SQL 50题”里聚合相关的题拉出来看一眼,你会发现考点非常集中。拿我自己刷过的版本来说,聚合相关的题目大概占了三成,主要分布在几个方向:

1.1 最基础的三件套:COUNT、SUM、AVG

这三个函数看起来简单,实际上每个都有坑。COUNT()和COUNT(列)的区别,多少人面试第一问就被问倒了?COUNT()是统计所有行数,包括NULL值的行;COUNT(列)是统计该列非NULL值的行数。SUM(列)会忽略NULL,但如果整列都是NULL,SUM返回NULL而不是0。AVG同样忽略NULL,计算时不会把NULL当作0去参与总和,这跟很多人的直觉不一样。

高频题里常见套路是:给你一张订单表、一张用户表,让你统计每个用户的订单数、订单总额、平均订单金额。这题看似简单,但能考察的点很多:需不需要保留没有订单的用户?如果用LEFT JOIN,那COUNT字段时要不要做空值处理?深挖下去,就能看出你到底是在背SQL还是在理解SQL。

我建议入门者先把这三件套吃透,尤其搞清楚它们的NULL语义。别小看这个,很多线上统计Bug就是从这里来的。

1.2 分组聚合与HAVING的隐藏细节

有了聚合函数,自然会引出一个经典问题:如果要在聚合之后再过滤,用WHERE还是HAVING?这个考点几乎每套题都有。

标准答案是:WHERE在分组前过滤,HAVING在分组后过滤。很多人背下来了,但真写的时候还是会搞混。比如“查出订单数大于5的用户”,正确的写法一定是GROUP BY user_id之后用HAVING COUNT(*) > 5。如果你把条件写在WHERE里,SQL会直接报错“聚合函数不能出现在WHERE子句中”吗?有些数据库会报错,有些数据库语法上允许但结果完全不对。

除了这个基础点,高频题里还会考“GROUP BY多个字段”、“GROUP BY与DISTINCT的区别”、“分组后再排序取Top N”等变形。说实话,如果能把HAVING的过滤逻辑、GROUP BY的字段顺序、以及聚合函数作用于分组后的结果集这三件事理清楚,你基本就能PK掉80%的面试者。

2. 面试最爱出的聚合场景:从“按部门统计”到“连续问题”

刷题刷多了,你会发现聚合题从来不直接说“请用分组聚合”,它总是包装成一个业务需求。这时候,能不能把需求翻译成SQL逻辑,就是核心能力。

2.1 经典“分组统计”题的完整拆解

看一个最常见也最容易被问的题目:

有一张员工表Employee,字段包括emp_id, emp_name, dept_id, hire_date, salary。现在要统计每个部门的员工人数、平均工资、最高工资和最低工资,并且只显示平均工资大于5000的部门,按平均工资降序排列。

第一眼看过去,这不就是Hello World等级吗?很多人的写法是这样:

SELECT dept_id, COUNT(*) AS emp_cnt, AVG(salary) AS avg_sal, MAX(salary) AS max_sal, MIN(salary) AS min_sal FROM employee GROUP BY dept_id HAVING AVG(salary) > 5000 ORDER BY avg_sal DESC;

这确实能跑,但有一个隐藏问题:如果只想统计在职员工,或者只统计工资非空的员工,你要不要加WHERE?更关键的是,如果dept_id在另一张部门表里,你想显示部门名称而不是编号,就要JOIN部门表。而JOIN之后,会不会因为部门表里没有匹配记录导致数据变少?这些都是面试官希望你能主动提到的。

我的习惯是先把业务拆成三层:数据范围(WHERE)、分组维度(GROUP BY)、聚合后过滤(HAVING)。每一步都问自己一个问题:这一层过滤会不会改变聚合的基数?这样写出来的SQL才不容易翻车。

2.2 结合窗口函数:累计求和与移动平均

聚合题做到后面,一定会遇到窗口函数。因为普通GROUP BY会把多行压缩成一行,而很多业务要的是“既能保留明细行,又能看到分组统计值”,这时候就需要SUM() OVER()这类窗口语法。

窗口函数的核心逻辑是:它不减少行数,只是在每一行后面带上一个“窗口范围”的计算结果。比如计算每个部门内每个员工的工资占部门总工资的比例:

SELECT emp_id, emp_name, dept_id, salary, SUM(salary) OVER(PARTITION BY dept_id) AS dept_total_salary, ROUND(salary * 100.0 / SUM(salary) OVER(PARTITION BY dept_id), 2) AS pct FROM employee;

写到这里你就能感受到:同一个SUM函数,在GROUP BY里是“聚合行”,在窗口里是“计算列”。很多人分不清这两者,写报表时就容易出现“行数变少”或者“重复统计”的尴尬。

再升级一点,就是“求每个用户连续登录天数”。这题几乎是所有聚合高频题里最经典的题,解法也很有意思:先用ROW_NUMBER()按用户分组、按日期排序,得到一个序号;然后用登录日期减去序号得到一组“日期差值”。只要用户连续登录,日期减去序号后的值就是一样的;一旦断签,差值就会变。最后再按用户和差值分组聚合,就能统计出连续天数。这个套路我在后面实战部分会再展开,这里先记住一句话:窗口函数是聚合思维从“整体”走向“局部”的关键工具。

3. 聚合查询的性能陷阱与优化思路

学习聚合不能只盯着语法,性能同样重要。工作里我经常在慢SQL排查现场看到类似“SELECT COUNT(*) FROM 大表”这种直接把数据库CPU打满的查询。聚合操作天然要扫描大量数据,如果底子没打好,一张千万级表就能让你体验什么叫“卡死”。

3.1 为什么COUNT(*)比COUNT(列)快

很多老开发会推荐“能写COUNT()就别写COUNT(列)”,这背后不是迷信,而是有索引层面的道理。在多数数据库里,如果一张表没有定义主键或合适的二级索引,COUNT(列)需要判断每个值是否为NULL,而COUNT()是直接数行数,并不关心具体列的值。在InnoDB引擎(MySQL为例)里,COUNT(*)的优化空间更大,尤其当表上有覆盖索引时,它可以直接走索引扫描,而不需要回到聚簇索引去读整行数据。

反过来,如果你COUNT一个很小的非索引列,确实可能要全表一遍,但即便全表,也还是比COUNT(列)多一层“判断NULL”的开销。所以我的习惯是:统计总行数用COUNT(),统计某个字段有值的数量才用COUNT(字段)。很多人用COUNT(id)来数行数,如果id列非空,结果一样,但逻辑上不如COUNT()清晰。

3.2 聚合查询慢的常见原因与优化手段

慢SQL优化是一个很大的话题,但聚合查询的优化方向其实很固定。

第一,先看能不能下推过滤条件。WHERE过滤要尽早执行,让进入聚合的数据量最小化。千万不要在聚合前的子查询里把所有明细查出来,再在外面套一层聚合。一些新手写“SELECT COUNT(*) FROM (SELECT * FROM big_table) t”这种完全可以避免。

第二,合理利用索引。GROUP BY字段上如果建有索引,数据库就不需要额外排序。MySQL里GROUP BY默认会做排序操作,如果分组字段能用索引覆盖,性能会有质的提升。不过要注意,联合索引的字段顺序要匹配GROUP BY的顺序,否则依然要文件排序。

第三,监视执行计划。无论是MySQL还是PostgreSQL,都能用EXPLAIN看到聚合阶段是不是出现了Using temporary或者Using filesort。出现临时表不一定是坏事,但数据量一大就容易写磁盘,这时候可以考虑通过改写SQL结构,或者调整数据库参数(比如tmp_table_size、max_heap_table_size)来缓解。

第四,如果实时聚合实在跑不动,可以考虑用物化视图或者定时汇总表。这不是SQL语法层面的事,但却是实际工作中最常用的手段。常见做法是每天凌晨跑一个离线任务,把前一天的聚合结果存到中间表,前端查询直接查汇总表。这样虽然会有一定程度的数据延迟,但换来了查询速度的稳定。

4. 那些年我踩过的聚合坑:6个必看注意事项

我最近一次“翻车”是在做销售报表时,某个产品线的销售额怎么算都对不上。后来排查了半天,发现是SUM函数把NULL值直接忽略了,而那个字段在月初的很多订单里确实录的是NULL,导致计算结果差了一截。从此以后,我对聚合函数的“隐性行为”特别敏感。

4.1 NULL值对聚合结果的影响

先列一个我自己整理的规则表,建议刻在脑子里:

场景结果
COUNT(*)统计所有行,不考虑NULL
COUNT(字段)只统计字段非NULL的行
SUM(字段)忽略NULL;全NULL则返回NULL
AVG(字段)忽略NULL;全NULL则返回NULL
MAX/MIN忽略NULL;全NULL则返回NULL
GROUP BY 某列NULL会单独成为一组

实际开发中,前三种坑最致命。比如SUM返回NULL这件事,如果你是在Java后台直接用sumResult字段,很可能会因为null导致NPE;正确做法是用COALESCE(SUM(列), 0)包一层。AVG也一样,如果表里恰好没有符合条件的数据,返回NULL而不是0,前端一展示就变成“空白”,用户还以为系统坏了。

4.2 浮点数求和的精度问题

SQL聚合不只处理整数,更多时候处理的是金额、百分比、汇率这类浮点数。很多数据库的FLOAT/DOUBLE类型在计算二进制小数时会有误差,比如0.1加0.2得到0.30000000000000004。如果你直接拿这个结果跟0.3比较,结果是不相等的,报表上则可能看到一连串奇怪的小数。

金融计算一律用DECIMAL/NUMERIC类型,比如DECIMAL(10,2)或者DECIMAL(20,4)。MySQL里SUM(DECIMAL)的精度也是可控的,但仍然建议在最终输出前用ROUND处理一下。另外,等值时不要用“=0.3”,而是用ABS(SUM(x) - 0.3) < 0.000001这种比较方式。

4.3 去重聚合与GROUP BY的配合

COUNT(DISTINCT 字段)是另一个高频陷阱。它确实能统计唯一值个数,但性能很差,因为数据库需要去重后才能计数。当数据量大时,这几乎是所有聚合操作里最慢的。优化手段主要有两种:

一是先GROUP BY去重,再在外面COUNT:

SELECT COUNT(*) FROM ( SELECT DISTINCT user_id FROM event_log WHERE create_date = '2024-01-01' AND user_id IS NOT NULL ) t;

这种方式在数据量较大时往往比直接COUNT(DISTINCT user_id)要快。原因在于子查询里可以先走索引/分组,把结果集缩小后,再在外层计数。

二是如果业务中经常需要统计唯一用户数,干脆在ETL阶段就把唯一用户ID对应的明细表单独拉出来维护,查询时直接查一张已经去重的表。这也是“用空间换时间”的典型场景。

4.4 小心GROUP BY的隐式排序

MySQL 5.7和8.0的行为差异特别值得留意。5.7及之前,GROUP BY默认会按照分组字段排序,很多人写“GROUP BY dept_id LIMIT 1”就顺手取到了第一条。但8.0里默认排序行为变了,如果依赖这种隐式顺序,很可能得到完全不同的结果。正确做法是:需要排序就明确写ORDER BY,不要依赖任何隐式行为。

同理,HAVING也不要跟WHERE混淆。我曾经见过一个同事把租期在WHERE里写了“租期>5年”,又在HAVING里写了“租期>5年”,结果当然是没报错但多了一次过滤,数据没问题,但SQL读起来很别扭,维护成本很高。

4.5 聚合大小与行转列/列转行的坑

用聚合做行转列(Pivot)时,很多人会写成多个SUM加CASE WHEN。比如统计每个月的销售额,把12个月变成12列。写法上没错,但列一多SQL就会很臃肿,动态月份更是难以维护。更通用的做法是用CASE WHEN配合GROUP BY,但如果你用的数据库支持FILTER子句(PostgreSQL),那语法会简洁很多:

SELECT user_id, COUNT(*) FILTER (WHERE status = 'success') AS success_cnt, COUNT(*) FILTER (WHERE status = 'fail') AS fail_cnt FROM orders GROUP BY user_id;

MySQL没有FILTER,只能写CASE WHEN加NULL或0。注意SUM(CASE WHEN ... THEN 1 ELSE 0 END)和COUNT(CASE WHEN ... THEN 1 END)效果一样,但后者如果CASE条件不满足就会返回NULL,COUNT会忽略NULL,所以等价;有人会写成COUNT(CASE WHEN ... THEN 1 ELSE NULL END),也没问题,但不推荐,直接用COUNT(CASE WHEN ... THEN 1 END)更简洁。

4.6 聚合的无限延伸:ROLLUP与GROUPING SETS

做报表时经常遇到“既要每个部门总人数,又要所有部门合计”的情况。传统写法是两个查询用UNION ALL拼在一起,后来我发现如果数据库支持GROUP BY WITH ROLLUP,一行搞定:

SELECT dept_id, COUNT(*) AS emp_cnt FROM employee GROUP BY dept_id WITH ROLLUP;

结果里会多出一行dept_id为NULL的记录,那就是全部门合计。PostgreSQL和SQL Server支持GROUPING SETS,能更灵活地控制组合维度。这些都是聚合题里比较少直接考,但实际工作却很加分的点。

5. 高频聚合SQL实战:从需求到SQL的完整推演

纸上谈兵再多,不如直接上手跑几个完整需求。这里我用自己整理过的三个高频案例,带大家完整走一遍从需求到SQL的推演过程,你会发现聚合的核心不外乎“逻辑拆分”和“语法细节”。

5.1 需求1:统计各部门各职位平均薪资

表结构很简单:employee(emp_id, emp_name, dept_id, job_title, salary)。

需求是:统计每个部门、每个职位的平均薪资,并显示平均薪资排名前3的记录(按平均薪资降序)。

第一步,先写基础分组:

SELECT dept_id, job_title, AVG(salary) AS avg_salary FROM employee GROUP BY dept_id, job_title;

第二步,加排名。SQL Server支持DENSE_RANK(),MySQL 8.0、PostgreSQL也支持。我们直接把上面的结果作为子查询,外面套一个窗口排名:

SELECT dept_id, job_title, avg_salary FROM ( SELECT dept_id, job_title, AVG(salary) AS avg_salary, DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY AVG(salary) DESC) AS rk FROM employee GROUP BY dept_id, job_title ) t WHERE rk <= 3;

看到这里你会发现,其实“每个部门内排名前3”这个需求,核心是先按部门分组算出平均薪资,再在部门内部做窗口排序,两个维度分工明确。

有人会问:能不能不用子查询,直接在原表上用窗口函数?可以,但写法会重复聚合逻辑。比如:

SELECT dept_id, job_title, avg_salary, rk FROM ( SELECT dept_id, job_title, AVG(salary) OVER (PARTITION BY dept_id, job_title) AS avg_salary, DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY AVG(salary) OVER (PARTITION BY dept_id, job_title) DESC) AS rk FROM employee ) t WHERE rk <= 3 GROUP BY dept_id, job_title, avg_salary, rk;

这种写法虽然也能跑,但窗口函数嵌套窗口函数,可读性极差,而且有些数据库对“ORDER BY后面直接跟窗口聚合函数”的写法支持并不好。我个人的习惯是:优先用GROUP BY做好聚合,再用子查询包一层处理排名,逻辑清晰,也方便加WHERE条件。

5.2 需求2:求每个用户连续登录天数

这题在面试题里出镜率极高。给一张login_log(user_id, login_date),一天可能有多条登录记录,求每个用户的最大连续登录天数。

解题套路分三步:

第一步,按用户和日期去重。同一天登录多次只算一天有效,所以要先DISTINCT:

SELECT DISTINCT user_id, login_date FROM login_log;

第二步,用ROW_NUMBER()给每个用户按日期编号:

SELECT user_id, login_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn FROM ( SELECT DISTINCT user_id, login_date FROM login_log ) t;

第三步,核心公式:login_date - rn。因为rn是连续递增的,如果登录日期也是连续的,那么日期减序号得到的值一定相同。所以:

WITH daily AS ( SELECT DISTINCT user_id, login_date FROM login_log ), numbered AS ( SELECT user_id, login_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn FROM daily ), groups AS ( SELECT user_id, login_date, DATE_SUB(login_date, INTERVAL rn DAY) AS grp FROM numbered ) SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS consecutive_days FROM groups GROUP BY user_id, grp ORDER BY user_id, start_date;

想要最大连续天数,就再用MAX(consecutive_days)按用户分组取一次。

我在讲这个题时总会强调一个点:SQL解连续问题,本质上就是“把连续的日期归到同一个组”。日期减去行号这个技巧,是理解“组的概念”最直观的案例。一旦理解了,碰到“连续3天活跃”“连续签到7天”这类问题都能举一反三。

5.3 需求3:同比环比计算(用LAG/LEAD)

业务方常要求“本月销售额相比上月增长多少、相比去年同期增长多少”。如果只有一张按月汇总的销售表sales_monthly(month_date, sales_amount),用LAG函数非常方便。

SELECT month_date, sales_amount, LAG(sales_amount) OVER (ORDER BY month_date) AS prev_month_sales, sales_amount - LAG(sales_amount) OVER (ORDER BY month_date) AS mom_growth, ROUND( (sales_amount - LAG(sales_amount) OVER (ORDER BY month_date)) / LAG(sales_amount) OVER (ORDER BY month_date) * 100, 2 ) AS mom_growth_rate FROM sales_monthly ORDER BY month_date;

同比需要往前推12个月,就用LAG(sales_amount, 12)。但这里有个常见大坑:如果某个月份没有数据,LAG会直接跳到再往前一行,而不是跳过空洞找12个月前的值。要更严谨,你得先用ORDER BY month_date保证每月一行,如果原表缺月份,要先通过LEFT JOIN日期维度表补齐。

另外,LAG取不到值时返回NULL,所以计算增长率时一定要用COALESCE或NULLIF避免除零。NULLIF(prev_month_sales, 0)这个函数很实用,我一般会配合COALESCE输出NULL,再在展示层处理。

用窗口函数做同比环比,本质上是聚合查询的另一种延续:聚合是“把多行压成一行”,窗口是“让每一行走读相邻行的值”。理解了这层关系,你就不容易再把窗口函数和GROUP BY混为一谈。

6. 刷题与实战之间的最后一公里

最后聊一点很多人不太重视、但我觉得很关键的内容。

刷“高频SQL 50题”时,我们通常关注的是“答案对不对”。但在实际工作里,SQL的好坏除了正确性,还要看可读性、执行效率、可维护性。同一个需求,有人写出的SQL一眼看懂,有人写出的SQL嵌套八层子查询,跑起来倒是不慢,但三个月后没人敢动。

我个人的实践是:在本地建一套和线上结构相同的测试库,专门用来跑各种SQL实验。刷题的时候,也不要只满足于通过测试用例,多问问自己:如果这个表有1000万行,这条SQL会不会挂?如果业务方突然要加一个过滤条件,我改起来方不方便?如果把这段逻辑做成一个视图,别人看代码能不能看懂?

聚合是所有SQL能力中最不能“只背不练”的一部分。你可以在网上找到无数条“语法正确”的SQL,但只有真正处理过脏数据、NULL和浮点精度之后,才会明白为什么很多老手会坚持用COALESCE包裹可能为NULL的聚合结果,为什么统计用户数要用COUNT(DISTINCT user_id)而不是COUNT(user_id)。

如果你正准备面试,建议把聚合相关的50题按上面说的几个分类去刷:函数语义、分组过滤、窗口排序、连续问题、行转列。每类找到2到3个代表性题目,彻底吃透比刷满100题更有用。

最后再分享一个小习惯:每次写完一条聚合SQL,我都会强制自己多看一眼那两样东西——执行计划里有没有出现Using temporary/Using filesort,以及统计结果里有没有意外的NULL值。这两个检查点,救了我非常多线上事故。

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

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

立即咨询