MySQL聚合函数与窗口函数:数据分析的双重魔法
1. 引言:从“数数”到“看趋势”,数据分析的两种思维
刚接触数据库那会儿,我觉得SQL最酷的功能就是“数数”。比如,老板问我:“咱们这个月有多少订单?”我就能用一句 SELECT COUNT(*) FROM orders 潇洒地甩给他一个数字。这种感觉,就像手里有了一把万能钥匙,能打开数据仓库的大门,看到总数、总和、平均值这些宏观指标。聚合函数,比如 COUNT、SUM、AVG,就是干这个的,它们把一堆数据“压缩”成一个有意义的统计值,给你一个高度概括的结论。
但很快,我就遇到了更棘手的问题。老板不再满足于“总数是多少”,他开始问:“这个月的销售额,每天是怎么变化的?哪个客户的累计消费最先突破了一万元?和上个月同期比,每个产品的销量排名有什么变化?” 这时候,只靠“压缩”数据的聚合函数就有点力不从心了。我需要的是在保留每一行数据细节的同时,还能进行跨行的计算和比较。这就像是看电影,你不仅要知道总票房(聚合),还想知道每一分钟的票房走势(窗口)。
这就是 窗口函数 登场的时刻。它不像聚合函数那样把多行“拍扁”成一行,而是为每一行数据都开一个“窗口”,透过这个窗口,它能看见同一组内的其他行,然后进行灵活的计算。你可以把它想象成Excel里的公式,比如在每一行旁边计算一个累计和、一个排名,或者和上一行做个比较。
所以,今天我想和你聊的,就是MySQL里这对“黄金搭档”——聚合函数和窗口函数。单独用,它们各自强大;结合起来用,那才是真正的“数据分析双重魔法”,能解决许多单靠一种思维搞不定的复杂问题。我会用几个我实际踩过坑、填过坑的案例,带你看看它们怎么协同作战,让你从“会写SQL”升级到“善用SQL”。
2. 聚合函数:数据的“宏观望远镜”
我们先来好好认识一下这位老朋友——聚合函数。它就像数据分析里的“宏观望远镜”,帮你拉远视角,看清森林的全貌。
2.1 核心五虎将:COUNT, SUM, AVG, MAX, MIN
这几个函数是SQL的基石,几乎每个数据分析查询都离不开它们。
-
COUNT():最基础的计数器。COUNT(*)数所有行,COUNT(column)数该列非空的行数。这里有个小坑我踩过:如果你想统计有多少个不同的客户下单,记得用COUNT(DISTINCT customer_id),直接用COUNT(customer_id)可能会重复计算同一个客户的多次订单。-- 统计总订单数 SELECT COUNT(*) AS total_orders FROM orders; -- 统计有多少个不同的客户下了单 SELECT COUNT(DISTINCT customer_id) AS unique_customers FROM orders; -
SUM()与AVG():总和与均值。SUM很简单,就是加总。但AVG要注意,它默认会忽略NULL值。如果你的数据里NULL代表0,那计算结果可能和预期不符,需要先用IFNULL或COALESCE函数处理一下。-- 计算总销售额和平均订单金额(忽略金额为NULL的订单) SELECT SUM(amount) AS total_sales, AVG(amount) AS avg_order_amount FROM orders; -- 如果想把NULL当0算进平均值 SELECT AVG(COALESCE(amount, 0)) AS avg_order_amount_with_null FROM orders; -
MAX()与MIN():极值探索者。它们不仅能找数字的最大最小值,还能找日期最晚/最早,甚至文本按字母排序的最大/最小。有一次我用它快速找出了系统里最早注册的一批用户,做了一次怀旧营销,效果出奇的好。
2.2 GROUP BY:让聚合“分而治之”
单独使用聚合函数,得到的是全局统计。但真实世界的数据需要分类讨论。这时 GROUP BY 就来了。它把数据分成不同的“组”,然后在每个组内分别进行聚合计算。
比如,老板问:“每个产品类别的总销售额和平均售价是多少?” 这个问题就需要先按类别分组。
SELECT
category,
SUM(amount) AS category_sales,
AVG(price) AS avg_price,
COUNT(*) AS order_count
FROM orders
JOIN products ON orders.product_id = products.id
GROUP BY category;
这里有个非常重要的点:SELECT 后面跟着的列,要么被包含在 GROUP BY 子句里,要么被聚合函数包裹。 这就是所谓的“单值规则”,违反它数据库会报错,因为引擎不知道非聚合的列该返回组里的哪一行。
2.3 HAVING:对聚合结果进行筛选
WHERE 子句是在分组前对原始行进行过滤,而 HAVING 子句是在分组后对聚合结果进行过滤。这是另一个容易混淆的地方。
举个例子:找出总销售额超过10万元的产品类别。
SELECT
category,
SUM(amount) AS category_sales
FROM orders
JOIN products ON orders.product_id = products.id
GROUP BY category
HAVING category_sales > 100000; -- 这里过滤的是聚合后的结果
你不能用 WHERE SUM(amount) > 100000,因为 WHERE 执行时,分组和聚合还没发生呢。记住这个顺序:WHERE -> GROUP BY -> 聚合计算 -> HAVING。
聚合函数这套“宏观望远镜”用熟了,你能快速回答关于规模、总量、平均水平的问题。但当你需要深究“每个个体的趋势如何”、“在组内的相对位置怎样”时,就需要请出另一位魔法师了。
3. 窗口函数:数据的“微观显微镜”
如果说聚合函数是望远镜,那窗口函数就是一台高倍率的“显微镜”。它允许你仔细观察每一行数据,同时又能感知它所在的“环境”(窗口)。
3.1 OVER()子句:定义你的观察窗口
所有窗口函数的魔法都始于 OVER() 子句。它定义了计算发生的“窗口范围”。这个窗口可以很简单,包含整个结果集,也可以很精细,通过 PARTITION BY 和 ORDER BY 来划分和排序。
-
整个结果集作为窗口:
OVER()里面什么都不写,意思就是“以所有行为窗口”。比如计算每一行订单金额占总销售额的比例:SELECT order_id, amount, amount / SUM(amount) OVER () AS sales_ratio FROM orders;这里的
SUM(amount) OVER ()会计算所有订单的总金额,然后这个总值被用于每一行的除法计算。你看到了吗?我们既保留了每一行的细节(order_id,amount),又得到了一个基于全局的衍生值(sales_ratio)。这是聚合函数做不到的。 -
使用 PARTITION BY 分组:这相当于在窗口内再建“隔间”。比如,我想看每个客户内部的订单累计金额,而不是全局累计。
SELECT customer_id, order_date, amount, SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS customer_running_total FROM orders;PARTITION BY customer_id意味着为每个客户单独开一个窗口,累计和的计算在每个客户内部独立进行,遇到新客户就清零重算。这非常适用于分析用户生命周期价值、消费轨迹等。 -
使用 ORDER BY 排序:这决定了窗口内行的计算顺序。在上面的例子里,
ORDER BY order_date保证了累计和是按时间顺序累加的。ORDER BY对于计算移动平均、排名等至关重要。
3.2 三大类窗口函数实战
窗口函数家族庞大,主要分三类:聚合类、排名类、取值类。
1. 聚合类窗口函数:就是 SUM, AVG, COUNT, MAX, MIN 这些老朋友,但加上 OVER() 子句后,它们不再压缩行,而是为每一行生成一个聚合值。
-- 计算每个订单的金额,以及该订单所属客户的平均订单金额
SELECT
customer_id,
order_id,
amount,
AVG(amount) OVER (PARTITION BY customer_id) AS avg_customer_order
FROM orders;
这样你一眼就能看出某个订单是高于还是低于该客户的平均水平。
2. 排名类函数:ROW_NUMBER, RANK, DENSE_RANK 这三个函数都用于排名,但处理“并列”情况的方式不同。
ROW_NUMBER():不管值是否相同,都给出连续的唯一序号(1,2,3,4...)。RANK():值相同的行排名相同,但会跳过后续名次(1,2,2,4...)。DENSE_RANK():值相同的行排名相同,且名次连续不跳过(1,2,2,3...)。
实战场景:找出每个销售区域销售额前三名的销售员。
SELECT *
FROM (
SELECT
region,
salesperson,
sales_amount,
RANK() OVER (PARTITION BY region ORDER BY sales_amount DESC) AS sales_rank
FROM sales_records
) AS ranked_sales
WHERE sales_rank <= 3;
这里用 RANK() 很合适,因为它允许并列(比如两个销售员并列第一),并且后续排名会跳过(并列第一后,下一个是第三名)。如果你希望并列第一后下一个是第二名,就用 DENSE_RANK()。
3. 取值类函数:LAG 和 LEAD 这是我个人最爱的窗口函数之一,用于访问“前面”或“后面”行的数据,在分析时间序列数据(如日活、销售额)时无敌好用。
-- 计算每日销售额,以及与前一天相比的增长率
SELECT
sale_date,
daily_sales,
LAG(daily_sales, 1) OVER (ORDER BY sale_date) AS previous_day_sales,
(daily_sales - LAG(daily_sales, 1) OVER (ORDER BY sale_date)) / LAG(daily_sales, 1) OVER (ORDER BY sale_date) * 100 AS growth_rate_percent
FROM daily_sales_summary;
LAG(column, n) 获取当前行之前第n行的值,LEAD(column, n) 则获取之后第n行的值。有了它们,环比、同比分析变得异常简单。
4. 双剑合璧:聚合与窗口的协同实战案例
单独使用它们已经很强了,但真正的魔法在于结合。下面我分享两个真实的复合场景。
4.1 案例一:计算“贡献度”与“组内排名”
假设你是一个电商数据分析师,老板想知道:每个商品类别的总销售额(聚合思维),以及每个类别内部,单个商品销售额占该类别总销售额的比例(窗口思维),同时还要给类别内的商品按销售额排个名(窗口思维)。
用一句SQL搞定:
SELECT
category,
product_name,
product_sales,
-- 聚合函数 + OVER(PARTITION BY ...): 计算类别总销售额
SUM(product_sales) OVER (PARTITION BY category) AS category_total_sales,
-- 窗口计算:单个商品贡献度
ROUND(product_sales * 100.0 / SUM(product_sales) OVER (PARTITION BY category), 2) AS contribution_percent,
-- 窗口函数:类别内排名
RANK() OVER (PARTITION BY category ORDER BY product_sales DESC) AS rank_in_category
FROM product_sales_summary;
在这个查询里:
SUM(product_sales) OVER (PARTITION BY category)是一个聚合窗口函数,它为每一行计算了其所属类别的销售总和。- 利用这个“类别总和”,我们轻松算出了贡献百分比。
RANK()则在每个类别分区内,根据销售额进行排名。
这就是协同的威力:聚合函数(SUM)在窗口的上下文中,为每一行提供了关键的上下文信息(组总和),使得更精细的分析成为可能。
4.2 案例二:识别“头部客户”与“连续增长”
另一个常见需求:找出累计消费金额最高的前10%的客户(头部客户),并分析这些头部客户最近3个月是否有连续消费增长的趋势。
这个需求需要分两步,但每一步都融合了两种思维。
第一步:用窗口函数计算累计百分比,识别头部客户。
WITH customer_totals AS (
SELECT
customer_id,
SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id -- 这里是聚合函数的GROUP BY
),
ranked_customers AS (
SELECT
customer_id,
total_spent,
-- 关键:用窗口函数计算累计百分比
SUM(total_spent) OVER (ORDER BY total_spent DESC) AS running_total,
SUM(total_spent) OVER () AS grand_total,
(SUM(total_spent) OVER (ORDER BY total_spent DESC)) / SUM(total_spent) OVER () AS cumulative_ratio
FROM customer_totals
)
SELECT *
FROM ranked_customers
WHERE cumulative_ratio <= 0.1; -- 筛选出累计贡献在前10%的客户
这里,我们先通过聚合(GROUP BY + SUM)得到每个客户的总消费。然后,在第二个CTE里,我们使用窗口函数 SUM() OVER (ORDER BY ...) 对客户总消费进行降序累计求和,并用另一个 SUM() OVER () 得到全局总和,从而算出累计占比。这是一个典型的先聚合再开窗的模式。
第二步:对筛选出的头部客户,用LAG分析月度消费趋势。
WITH head_customers AS (/* 上面识别头部客户的SQL */),
monthly_sales AS (
SELECT
hc.customer_id,
DATE_FORMAT(o.order_date, '%Y-%m') AS sale_month,
SUM(o.amount) AS monthly_amount
FROM head_customers hc
JOIN orders o ON hc.customer_id = o.customer_id
WHERE o.order_date >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH)
GROUP BY hc.customer_id, DATE_FORMAT(o.order_date, '%Y-%m') -- 再次聚合,得到月消费
)
SELECT
customer_id,
sale_month,
monthly_amount,
LAG(monthly_amount, 1) OVER (PARTITION BY customer_id ORDER BY sale_month) AS prev_month_amount,
CASE
WHEN LAG(monthly_amount, 1) OVER (PARTITION BY customer_id ORDER BY sale_month) IS NOT NULL
THEN monthly_amount > LAG(monthly_amount, 1) OVER (PARTITION BY customer_id ORDER BY sale_month)
ELSE NULL
END AS is_growth
FROM monthly_sales
ORDER BY customer_id, sale_month;
这一步,我们先对头部客户的近期订单按月聚合(GROUP BY)。然后,对每个客户(PARTITION BY customer_id)按月序(ORDER BY sale_month)使用 LAG 函数,获取其上个月的消费额,从而判断本月是否增长。这又是一个聚合后开窗的经典组合。
通过这两个案例,你会发现,很多复杂的业务分析,其实就是“聚合”和“窗口”两种思维交替或嵌套使用的过程。聚合帮你提炼摘要,窗口帮你洞察细节和关系。
5. 性能陷阱与优化心法
功能强大,随之而来的就是性能挑战。窗口函数和聚合函数用不好,很容易写出拖垮数据库的查询。
5.1 窗口函数的性能杀手:全表排序与大规模分区
窗口函数的核心操作之一是排序(如果使用了 ORDER BY)。OVER (PARTITION BY A ORDER BY B) 这个子句,数据库底层通常需要先按A分组,然后在每个组内按B排序。如果A的取值很少(分区很大)或者B上没有索引,就会导致大量的全表扫描和文件排序(Using filesort),在MySQL中尤其消耗资源。
优化心法1:利用索引覆盖
为 OVER() 子句中 PARTITION BY 和 ORDER BY 用到的列建立复合索引,可以极大提升性能。例如,对于 OVER (PARTITION BY user_id ORDER BY created_at),建立一个 (user_id, created_at) 的索引,数据库可以直接利用索引的有序性来避免额外的排序操作。
优化心法2:减少窗口范围
默认情况下,OVER (ORDER BY ...) 的窗口范围是从结果集开头到当前行(RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)。如果你只需要最近几行的数据(比如计算3期移动平均),一定要明确指定窗口框架(FRAME),减少计算量。
-- 计算最近3个订单的平均金额(包括当前行)
SELECT
order_id,
amount,
AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3
FROM orders;
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW 明确告诉数据库只关注当前行及前两行,而不是从头开始的所有行。
5.2 聚合函数与GROUP BY的优化:警惕中间结果集膨胀
GROUP BY 操作会产生中间结果集。如果分组键(GROUP BY的列)基数很大(即唯一值很多),或者SELECT中包含了大量未被聚合的、非索引的列,可能导致临时表巨大,内存放不下就要写磁盘,速度骤降。
优化心法3:只SELECT需要的列
在GROUP BY查询中,尽量避免 SELECT *。只选择你真正需要聚合的列和分组列。多余的列不仅增加I/O,还可能迫使MySQL使用更慢的临时表算法。
优化心法4:考虑使用派生表(子查询)提前过滤
如果原始表很大,但你需要聚合的数据只是其中一小部分,可以先在子查询中用WHERE条件过滤掉无关数据,再进行聚合和开窗。
-- 不好的写法:先对所有数据开窗再过滤
SELECT * FROM (
SELECT ... , ROW_NUMBER() OVER (PARTITION BY ...) as rn
FROM huge_table
) t WHERE rn = 1 AND create_date > '2023-01-01';
-- 更好的写法:先过滤数据,减少处理量
SELECT * FROM (
SELECT ... , ROW_NUMBER() OVER (PARTITION BY ...) as rn
FROM huge_table
WHERE create_date > '2023-01-01' -- 提前过滤
) t WHERE rn = 1;
5.3 组合使用时的执行顺序理解
理解SQL的执行顺序对优化至关重要。当聚合函数和窗口函数出现在同一个查询时,记住:
- FROM / JOIN 确定数据源
- WHERE 过滤行
- GROUP BY 分组
- 聚合函数(如
SUM(column))计算 - HAVING 过滤组
- 窗口函数 计算(
OVER子句中的计算发生在此刻!) - SELECT 选择列
- DISTINCT
- ORDER BY
- LIMIT
关键点:窗口函数是在GROUP BY和聚合发生之后才计算的。这意味着,窗口函数中引用的列,已经是聚合后的结果(如果前面有GROUP BY的话)。这也解释了为什么我们可以在窗口函数里使用聚合函数的结果列。
6. 思维跃迁:从“写查询”到“设计分析”
掌握了这些技术细节后,最重要的转变是思维上的:从“我该怎么写出这个查询”变成“我该用哪种数据视角来解决这个问题”。
当你拿到一个新的分析需求时,可以快速在脑子里过一遍:
- 如果问题只关心总体概括(多少、总和、平均),用聚合函数 + GROUP BY。
- 如果问题需要在保留每一行细节的同时,考察它在上下文中的关系(排名、累计、前后对比),用窗口函数。
- 如果问题两者兼有(既要知道组的总量,又要看组内个体的相对位置),那就组合使用。通常的模式是:先用子查询或CTE进行必要的聚合和过滤,再对结果使用窗口函数进行精细加工。
我自己的经验是,在报表开发和数据探查中,窗口函数的使用频率越来越高。它让很多原本需要多次查询、在应用层拼接逻辑的复杂分析,得以在数据库一层用一句SQL优雅地完成。这不仅仅是代码的简化,更是性能的提升和逻辑的清晰化。刚开始可能会觉得OVER()子句有点绕,但多写几次,习惯了这种“为每一行打开一个窗口”的思维方式后,你会发现数据分析的视野一下子被打开了。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)