窗口函数是SQL中处理复杂计算的一把利器,尤其适用于需要在明细数据之上叠加汇总结果、排名结果或同比环比值的场景。销售目标达成率排名正是这种场景的典型代表:既要保留每位销售人员的明细数据,又要基于聚合后的数值进行横向比较。本文从一个可执行的示例出发,详细介绍如何用窗口函数实现达成率计算、分组排名以及进阶的数据分析。

一、窗口函数与传统聚合语句的差异
在没有窗口函数时,对销售人员按月份汇总销售额并计算达成率,通常需要先GROUP BY,再关联目标表,最后还要用子查询或临时表来排名。例如,假设我们有一张销售记录表和一张目标表,要得到每个销售人员每个季度的总销售额和达成率,最基础的写法如下:
SELECT
s.seller,
QUARTER(s.sale_date) AS quarter,
SUM(s.amount) AS total_amount,
t.target_amount,
SUM(s.amount) / t.target_amount AS achievement_rate
FROM sales_records s
JOIN sales_target t
ON s.seller = t.seller
AND QUARTER(s.sale_date) = t.quarter
GROUP BY s.seller, QUARTER(s.sale_date), t.target_amount;
这段SQL可以输出每个销售人员的季度汇总和达成率,但是想要在此基础上增加一个排名列,就显得比较麻烦。你可能需要先创建临时表,再用变量或自连接排序。而窗口函数可以直接在结果集中新增一列,例如使用ROW_NUMBER()或RANK(),将聚合计算和排名计算一并完成。这使得SQL的可读性和维护性都大大提升。
窗口函数的基本语法是函数名后紧跟OVER子句,形如聚合函数 OVER (PARTITION BY 分组字段 ORDER BY 排序字段)。PARTITION BY负责分组,ORDER BY负责组内排序。对于SUM、AVG、COUNT这类聚合函数,放到OVER子句中后,它们会为每一行计算所在分组内的聚合值,而不是像GROUP BY那样折叠行。理解这一点,是掌握窗口函数综合应用的基础。
二、计算销售目标达成率:从GROUP BY到窗口函数
假设销售明细表sales_records的字段有id、seller、region、amount、sale_date,目标表sales_target的字段有seller、quarter、target_amount。我们现在要做的是先按季度计算每个销售人员的销售额,再与目标比对得到达成率。上文已经展示了使用GROUP BY的基本写法,下面改用窗口函数来保留明细。
例如,我们希望查出来每一条销售记录后面都带上该销售当季的总销售额和总目标,可以这样写:
SELECT
id,
seller,
region,
amount,
sale_date,
SUM(amount) OVER (PARTITION BY seller, QUARTER(sale_date)) AS total_amount,
(SELECT target_amount FROM sales_target t
WHERE t.seller = s.seller AND t.quarter = QUARTER(s.sale_date)) AS target_amount
FROM sales_records s;
这里的子查询用于关联目标表。但在实际工作中,窗口函数里面不能直接关联另一张表,所以更常见的做法是先JOIN目标表,再用窗口函数计算聚合值。下面这段SQL就非常典型:
SELECT
id,
s.seller,
s.region,
s.amount,
s.sale_date,
SUM(s.amount) OVER (PARTITION BY s.seller, QUARTER(s.sale_date)) AS total_amount,
t.target_amount,
SUM(s.amount) OVER (PARTITION BY s.seller, QUARTER(s.sale_date)) / t.target_amount AS achievement_rate
FROM sales_records s
JOIN sales_target t
ON s.seller = t.seller
AND QUARTER(s.sale_date) = t.quarter;
这里需要注意,JOIN可能会让销售记录与季度目标形成关联,但由于目标表中每个员工每个季度只有一条记录,所以结果不会产生重复。窗口函数在JOIN后的结果集上执行,所以total_amount是每个销售在对应季度内的总销售额。达成率可以直接用SUM的窗口结果除以目标值得到。这样,明细行和汇总信息同时出现,后续再对achievement_rate计算排名就顺理成章了。
三、使用RANK与DENSE_RANK实现分组排名
有了达成率之后,排名就变得非常简单。SQL中常用的排名函数有ROW_NUMBER、RANK和DENSE_RANK。ROW_NUMBER无论如何都会返回连续的编号,排序值相同则随机分配;RANK遇到相同排序值会产生并列名次并跳号;DENSE_RANK也会并列,但不会跳号。如果业务要求达成率相同的人并列且后续名次连续,应该选DENSE_RANK。
下面的查询按季度分组,按达成率从高到低排名,同时对比三种排名函数的结果:
SELECT
seller,
quarter,
total_amount,
target_amount,
achievement_rate,
ROW_NUMBER() OVER (PARTITION BY quarter ORDER BY achievement_rate DESC) AS row_num,
RANK() OVER (PARTITION BY quarter ORDER BY achievement_rate DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY quarter ORDER BY achievement_rate DESC) AS dense_rnk
FROM (
SELECT
s.seller,
QUARTER(s.sale_date) AS quarter,
SUM(s.amount) AS total_amount,
t.target_amount,
SUM(s.amount) / t.target_amount AS achievement_rate
FROM sales_records s
JOIN sales_target t
ON s.seller = t.seller
AND QUARTER(s.sale_date) = t.quarter
GROUP BY s.seller, QUARTER(s.sale_date), t.target_amount
) a
ORDER BY quarter, rnk;
三种排名函数的差异可以用下面的表格快速理解:
| 函数名 | 名次是否连续 | 并列时的表现 |
|---|---|---|
| ROW_NUMBER | 连续 | 并列时随机分配序号 |
| RANK | 不连续 | 并列后跳过名次 |
| DENSE_RANK | 连续 | 并列后不跳过名次 |
在这个查询中,内层子查询先完成聚合,得到每个员工每个季度的达成率,外层窗口函数在固定结果集上进行排序。由于RANK、ROW_NUMBER等函数都是分析函数,不能在WHERE子句中直接使用,因此如果要筛选排名前几,需要将子查询再包一层,或者使用公共表表达式。例如,只取每个季度排名前三的员工时,可以用:
WITH quarterly_sales AS (
SELECT
t.seller,
t.quarter,
SUM(t.total_amount) AS total_amount,
MAX(t.target_amount) AS target_amount,
SUM(t.total_amount) / MAX(t.target_amount) AS achievement_rate
FROM (
SELECT
s.seller,
QUARTER(s.sale_date) AS quarter,
s.amount AS total_amount,
tg.target_amount
FROM sales_records s
JOIN sales_target tg
ON s.seller = tg.seller
AND QUARTER(s.sale_date) = tg.quarter
) t
GROUP BY t.seller, t.quarter
),
ranked AS (
SELECT
seller,
quarter,
achievement_rate,
RANK() OVER (PARTITION BY quarter ORDER BY achievement_rate DESC) AS rnk
FROM quarterly_sales
)
SELECT seller, quarter, achievement_rate
FROM ranked
WHERE rnk <= 3;
注意,代码中的<=在pre块里已经做了转义,实际执行时是小于等于号。如果没有转义,HTML解析器会把小于号当作标签开头,导致页面结构错乱。这种三层嵌套的写法虽然看起来复杂,但它清晰地将聚合、排名和过滤分开,非常适合维护。
四、进阶应用:累计达成率与百分位排名
排名之外的延伸需求也很常见。比如,销售总监想了解前一半员工包揽了总销售额的多少,或者某个员工处在所有员工中的哪个百分位。窗口函数中的PERCENT_RANK和CUME_DIST可以回答这类问题。PERCENT_RANK返回当前行在组内的相对排名,取值范围从0到1,而CUME_DIST返回累积分布值。
下面这段SQL在计算达成率的基础上,追加了统计排名百分位以及按达成率降序的累计销售额:
SELECT
seller,
quarter,
achievement_rate,
RANK() OVER (PARTITION BY quarter ORDER BY achievement_rate DESC) AS rnk,
PERCENT_RANK() OVER (PARTITION BY quarter ORDER BY achievement_rate DESC) AS pct_rank,
SUM(quarter_amount) OVER (
PARTITION BY quarter
ORDER BY achievement_rate DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_sales
FROM quarterly_sales qs;
这里假设quarterly_sales中已经包含每个员工每季度的达成率、季度销售额等字段。SUM的窗口形式实现了累计销售额,计算逻辑是按照达成率降序依次累加,这个值可以用于帕累托分析,帮助管理层快速识别关键贡献者。
需要特别说明的是,ORDER BY在窗口函数中会直接决定计算范围。如果不写ROWS子句,默认的范围是“从分组起点到当前行”,配合不同的边界条件可以得到滚动累计。这种能力是传统GROUP BY无法比拟的,也是窗口函数在进行销售分析时特别有用的原因。
五、性能优化与注意事项
窗口函数虽然语法简洁,但在数据量较大时需要注意性能。排名类函数通常需要将分组内的数据全部读入内存或临时表进行排序,所以分区字段和排序字段上的索引非常重要。比如上面的查询,如果经常按seller和sale_date过滤,可以考虑在sales_records表上建立(seller, sale_date)组合索引,在sales_target表上建立(seller, quarter)组合索引。
另外,窗口函数不能直接使用WHERE过滤窗口结果。如果需要过滤排名条件,必须先子查询或CTE。同时,避免在大结果集上使用SELECT DISTINCT配合窗口函数,因为这会破坏分布顺序,导致执行计划变得复杂。优先在聚合之前过滤掉无关数据,可以显著降低窗口函数的计算代价。
最后提醒一点:不同数据库对窗口函数的支持程度存在差异,比如MySQL从8.0开始才支持,SQL Server和Oracle早已支持。生产环境使用的SQL引擎版本可能会限制某些高级函数的使用,开发前应当确认官方文档。实际项目中,建议先编写出结构清晰的查询,再结合执行计划逐层优化,让窗口函数真正成为业务分析的得力助手。