SQL窗口函数是一种能在数据行的“动态窗口”内计算的高级函数,无需像GROUP BY
那样折叠查询结果,可保留原始数据结构。SQL窗口函数能高效实现累计统计、排名对比、前后数据关联等复杂分析,广泛应用在报表生成(如:部门薪资占比)、趋势分析(如:7日销售额均值)、数据分段(如:学生成绩排名)等应用场景,大幅简化传统多表关联或子查询的复杂逻辑,提升数据分析效率与可读性。(所有代码块与表格均可左右滚动)
一、窗口函数语法结构模板
函数名([参数]) -- 核心计算函数,如:SUM(salary)、RANK()
OVER (
-- 可选:按指定列分组,组内独立计算(类似GROUP BY但不折叠行)
[PARTITION BY 分组列1, 分组列2, ...]
-- 可选:指定分组内数据的排序规则,影响窗口范围和排名结果
[ORDER BY 排序列1 [ASC/DESC], 排序列2 [ASC/DESC], ...]
-- 可选:定义窗口的具体范围(物理行数或值范围),默认随排序动态变化
[ROWS/RANGE 窗口起始位置 AND 窗口结束位置]
) AS 结果列别名 -- 给窗口函数计算结果命名,便于后续引用
二、窗口函数语法元素详解
1、函数名([参数]):核心计算逻辑载体,不同函数实现不同需求,如:SUM()
用于求和,LAG()
用于获取前N行数据,无参数函数(如:ROW_NUMBER()
)可省略括号。
2、OVER()子句:窗口函数的标志,用于定义“计算窗口”,所有窗口相关的配置(分区、排序、范围)均在此括号内声明,无配置时表示对全表数据计算。
3、PARTITION BY:数据分组依据,将全表数据按指定列拆分为多个独立分区,窗口函数仅在分区内计算。例如:PARTITION BY department
表示按“部门”分组,分别计算每个部门的统计结果。
4、ORDER BY:分区内数据的排序规则,决定窗口内数据的顺序,影响排名函数的序号生成、取值函数的前后位置判断。例如:ORDER BY salary DESC
表示在分区内按“薪资”降序排列。
5、ROWS/RANGE:窗口范围定义,控制计算时包含的行。ROWS
基于物理行数(如:6 PRECEDING
表示前6行),RANGE
基于排序列的值范围(如:5 PRECEDING
表示排序列值比当前行小5的范围);常见范围值包括UNBOUNDED PRECEDING
(分区起始行)、CURRENT ROW
(当前行)、UNBOUNDED FOLLOWING
(分区结束行)。
三、窗口函数的常见类型
1、聚合类窗口函数
对窗口内的数据进行统计聚合,保留原始行数据,不折叠结果。
核心函数:SUM()
(求和)、AVG()
(平均值)、COUNT()
(计数)、MAX()
(最大值)、MIN()
(最小值)
示例:计算各部门员工薪资累计值与薪资占比
SELECT
department, -- 部门
employee, -- 员工姓名
salary, -- 员工薪资
-- 按部门分组,按入职日期排序,累计部门薪资
SUM(salary) OVER (PARTITION BY department ORDER BY hire_date) AS cum_dept_salary,
-- 按部门分组,计算个人薪资占部门总薪资的百分比
ROUND(salary / SUM(salary) OVER (PARTITION BY department) * 100, 2) AS salary_percent
FROM employees;
输出片段:
2、排名类窗口函数
为分区内的行生成排名序号,处理并列排名场景。
核心函数:
ROW_NUMBER()
:生成连续唯一序号(无并列,如:1,2,3)RANK()
:并列时跳过后续序号(如:1,1,3)DENSE_RANK()
:并列时不跳号(如:1,1,2)
示例:对各销售区域销售额排名
SELECT
region, -- 销售区域
sales, -- 销售额
ROW_NUMBER() OVER (ORDER BY sales DESC) AS rn, -- 唯一排名
RANK() OVER (ORDER BY sales DESC) AS rk, -- 并列跳号排名
DENSE_RANK() OVER (ORDER BY sales DESC) AS dr -- 并列不跳号排名
FROM regional_sales;
输出片段:
3、取值类窗口函数
获取窗口内其他行的数据,实现前后数据关联。
核心函数:
LAG(列, n)
:取当前行前n行的指定列值,n默认1LEAD(列, n)
:取当前行后n行的指定列值,n默认1FIRST_VALUE(列)
:取窗口内第一行的指定列值LAST_VALUE(列)
:取窗口内最后一行的指定列值
示例:计算月度销售额环比增长
SELECT
month, -- 月份
revenue, -- 当月销售额
-- 获取上月销售额,无上月数据时显示0
COALESCE(LAG(revenue, 1) OVER (ORDER BY month), 0) AS prev_month_revenue,
-- 计算环比增长额
revenue - COALESCE(LAG(revenue, 1) OVER (ORDER BY month), 0) AS mom_growth
FROM monthly_sales;
输出片段:
4、分布类窗口函数
计算数据在整体中的分布位置,用于分段分析。
核心函数:
PERCENT_RANK()
:计算百分比排名,公式为(当前排名-1)/(总行数-1)
,范围[0,1]CUME_DIST()
:计算累计分布,公式为≤当前值的行数/总行数
,范围(0,1]
示例:分析学生考试成绩分布
SELECT
student_id, -- 学生ID
score, -- 考试分数
-- 百分比排名(保留2位小数)
ROUND(PERCENT_RANK() OVER (ORDER BY score), 2) AS score_percentile,
-- 累计分布(保留2位小数)
ROUND(CUME_DIST() OVER (ORDER BY score), 2) AS score_cum_dist
FROM exam_scores;
输出片段:
5、分桶类窗口函数
将窗口内的数据均匀分配到指定数量的“桶”中,实现数据分组。
核心函数:NTILE(n)
,n为桶的数量,若数据无法均分,前几个桶会多1行数据
示例:将销售区域按销售额分为4个等级(桶)
SELECT
region, -- 销售区域
sales, -- 销售额
-- 按销售额降序,分4个桶
NTILE(4) OVER (ORDER BY sales DESC) AS sales_quartile
FROM regional_sales;
输出片段:
四、窗口函数的高级应用与复合应用示例
1、多级分区计算
在多个维度下分组计算,如:“部门+职级”双维度的薪资排名。
SELECT
department, -- 部门
job_level, -- 职级
employee, -- 员工姓名
salary, -- 薪资
-- 按部门和职级双分组,按薪资降序排名
DENSE_RANK() OVER (
PARTITION BY department, job_level
ORDER BY salary DESC
) AS dept_level_sal_rank
FROM employees;
输出片段:
2、滑动窗口分析
动态调整窗口范围,如:计算7日销售额移动平均(消除短期波动)。
SELECT
sale_date, -- 销售日期
amount, -- 当日销售额
-- 按日期排序,窗口范围为“前6行到当前行”(共7天)
ROUND(AVG(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS rolling_avg_7day
FROM sales;
输出片段:
3、缺失值填充
利用前后行数据填补缺失值,如:补齐天气表中的温度缺失数据。
SELECT
date, -- 日期
temperature, -- 原始温度(含NULL)
-- 若温度为NULL,用前1行的温度填充;仍为NULL则用后1行
COALESCE(
temperature,
LAG(temperature, 1) OVER (ORDER BY date),
LEAD(temperature, 1) OVER (ORDER BY date)
) AS filled_temperature
FROM weather;
输出片段:
五、窗口函数跨数据库语法兼容性参考
| | | |
---|
RANGE | | | |
GROUPS | | | |
NTILE(n) | | | |
NTH_VALUE(列,n) | | | |
MySQL示例(需8.0及以上版本,低版本不支持窗口函数):
SELECT
year, -- 年份
product, -- 产品
-- 按产品分组,按年份排序,累计产品销售额
SUM(sales) OVER (
PARTITION BY product
ORDER BY year
ROWS UNBOUNDED PRECEDING
) AS cum_product_sales
FROM product_sales;
六、窗口函数的常见误区与避坑指南
误区1:使用ROW_NUMBER()
时未指定唯一排序字段,导致排名结果随机。
- 错误代码:
ROW_NUMBER() OVER (ORDER BY sale_date)
(若sale_date有重复,排名会随机) - 避坑:添加唯一列(如:订单ID)确保排序唯一,修正代码:
ROW_NUMBER() OVER (ORDER BY sale_date, order_id)
误区2:混淆ROWS
与RANGE
的范围逻辑,导致计算结果不符预期。
- 错误场景:用
RANGE
计算“前5行平均值”(RANGE BETWEEN 5 PRECEDING AND CURRENT ROW
),实际会按排序列值范围计算,而非物理行数。 - 避坑:计算物理行数范围用
ROWS
,计算值范围用RANGE
,修正代码:AVG(price) OVER (ORDER BY price ROWS BETWEEN 5 PRECEDING AND CURRENT ROW)
误区3:忽略ORDER BY
对窗口范围的影响,默认窗口范围为“分区起始到当前行”。更准确的表述是:当OVER()
中无ORDER BY
时,窗口函数默认范围是 “整个分区”(聚合类函数直接返回分区总结果);仅当指定ORDER BY
后,默认范围才是 “分区起始到当前行”,例如:SUM(salary) OVER (PARTITION BY dept)
无ORDER BY
,返回部门总薪资;SUM(salary) OVER (PARTITION BY dept ORDER BY hire_date)
默认返回累计值。
- 错误场景:想计算分区内全量数据的平均值,却因未指定范围导致结果为累计值。
- 避坑:需显式指定全量范围,代码:
AVG(salary) OVER (PARTITION BY department ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
七、窗口函数的性能优化技巧
1、创建组合索引:在PARTITION BY
和ORDER BY
的列上创建组合索引,减少排序和分组的计算耗时。例如:CREATE INDEX idx_dept_sal ON employees(department, salary DESC);
2、剪裁窗口范围:避免使用UNBOUNDED PRECEDING
(全表扫描),仅保留必要的窗口范围。例如:计算“近30天销售额”,用ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
而非全量范围。
3、拆分复杂查询:将多窗口函数的复杂查询拆分为CTE(公共表表达式)分步处理,降低单次查询的计算压力。示例:
WITH dept_sal AS (
-- 第一步:计算部门总薪资
SELECT department, SUM(salary) AS dept_total_sal FROM employees GROUP BY department
)
-- 第二步:关联原表计算薪资占比(避免重复聚合)
SELECT
e.department, e.employee, e.salary,
ROUND(e.salary / d.dept_total_sal * 100, 2) AS salary_percent
FROM employees e
JOIN dept_sal d ON e.department = d.department;
八、窗口函数典型面试题及参考答案
面试题1:用SQL计算每个员工的薪资在其部门内的排名(需处理并列,不跳号),并显示部门内薪资最高的员工姓名。
参考答案:
WITH dept_sal_rank AS (
-- 第一步:计算每个员工的部门内薪资排名(不跳号)
SELECT
department,
employee,
salary,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS sal_rank
FROM employees
),
dept_top_sal AS (
-- 第二步:获取每个部门的最高薪资
SELECT
department,
MAX(salary) AS top_sal
FROM employees
GROUP BY department
),
dept_top_emp AS (
-- 第三步:获取每个部门薪资最高的员工姓名(可能多个)
SELECT
dts.department,
GROUP_CONCAT(dsr.employee SEPARATOR ', ') AS top_employees -- 用GROUP_CONCAT拼接多个最高薪资员工姓名
FROM dept_top_sal dts
JOIN dept_sal_rank dsr ON dts.department = dsr.department
AND dts.top_sal = dsr.salary
GROUP BY dts.department
)
-- 第四步:关联结果,显示员工排名与部门最高薪资员工
SELECT
dsr.department,
dsr.employee,
dsr.salary,
dsr.sal_rank,
dte.top_employees AS dept_top_sal_employees
FROM dept_sal_rank dsr
JOIN dept_top_emp dte ON dsr.department = dte.department
ORDER BY dsr.department, dsr.sal_rank;
输出逻辑:先通过DENSE_RANK()
实现部门内不跳号的薪资排名,再通过子查询获取各部门最高薪资及对应员工,最后关联展示完整信息,解决“并列排名”和“部门最高员工识别”双重需求。
面试题2:计算每月销售额的3个月滑动平均(即当月+前2个月的平均值),并对比当月销售额与滑动平均的差异。
参考答案:
SELECT
month, -- 月份
revenue AS monthly_revenue, -- 当月销售额
-- 滑动窗口:当前行+前2行(共3个月),按月份排序确保顺序正确
ROUND(AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 窗口范围:前2个月到当月
), 2) AS rolling_avg_3month,
-- 计算当月销售额与滑动平均的差异(保留2位小数)
ROUND(revenue - AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS revenue_diff_from_avg
FROM monthly_sales
ORDER BY month;
说明:使用ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
明确3个月的物理窗口范围,避免RANGE
因“月份值间隔不规律”导致的计算偏差;通过ORDER BY month
确保窗口内数据按时间顺序排列,滑动平均结果符合业务逻辑。
输出(片段):
面试题3:如何用窗口函数填充表中的缺失数据?请以“补齐用户连续登录记录中的缺失日期,并用前一次登录的设备信息填充”为例说明。
参考答案:
-- 步骤1:生成连续日期(假设需补齐2023-01-01至2023-01-10的登录记录)
WITH date_series AS (
SELECT DATE_ADD('2023-01-01', INTERVAL num DAY) AS login_date
FROM (
SELECT 0 AS num UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL
SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL
SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9
) AS nums
),
-- 步骤2:关联用户登录表,获取原始数据(含缺失日期)
user_login_raw AS (
SELECT
ds.login_date,
ul.user_id,
ul.device_type -- 登录设备(可能为NULL,即日期缺失)
FROM date_series ds
CROSS JOIN (SELECT DISTINCT user_id FROM user_logins) ul -- 确保每个用户都有连续日期
LEFT JOIN user_logins ul ON ds.login_date = ul.login_date
AND ds.user_id = ul.user_id -- 关联原始登录记录
)
-- 步骤3:用LAG()填充缺失的设备信息(取最近一次非NULL的设备值)
SELECT
login_date,
user_id,
-- 若当前设备为NULL,取前一次非NULL的设备值;窗口范围为用户的所有历史记录
LAST_VALUE(device_type) OVER (
PARTITION BY user_id
ORDER BY login_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- 从用户第一条记录到当前行
-- 注:部分数据库支持IGNORE NULLS,如:PostgreSQL;MySQL需用子查询辅助,此处简化示例
) AS filled_device_type
FROM user_login_raw
ORDER BY user_id, login_date;
避坑点:直接用LAG(device_type)
仅能取前1行数据,若连续多日缺失会失效;通过LAST_VALUE()
结合“全范围窗口”,可获取当前行之前最近一次非NULL的设备信息,更符合“连续缺失填充”需求(注意:MySQL无IGNORE NULLS
时,可通过子查询标记非NULL值位置,再用窗口函数定位填充)。
输出(片段)(用户ID=U001):
面试题4:现有员工表(employees)含部门(department)、入职日期(hire_date)、薪资(salary),请计算每个部门内“入职时间最早的3名员工”的平均薪资。
参考答案:
WITH dept_emp_hire_rank AS (
-- 第一步:按部门分组,按入职日期升序排名(最早入职为1)
SELECT
department,
employee,
hire_date,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY hire_date ASC -- 入职早的排名靠前
) AS hire_rank
FROM employees
),
-- 第二步:筛选每个部门入职最早的3名员工
dept_top3_early_hire AS (
SELECT
department,
salary
FROM dept_emp_hire_rank
WHERE hire_rank <= 3 -- 保留排名前3的员工
)
-- 第三步:计算每个部门前3早入职员工的平均薪资
SELECT
department,
ROUND(AVG(salary), 2) AS avg_salary_top3_early_hire -- 平均薪资保留2位小数
FROM dept_top3_early_hire
GROUP BY department
ORDER BY department;
说明:先用ROW_NUMBER()
按“部门+入职日期”排序,确保每个部门内入职时间唯一排序(避免并列导致人数超3);再筛选排名前3的员工,最后按部门聚合计算平均薪资,精准匹配“入职最早3人”的业务需求。
输出(片段):
| avg_salary_top3_early_hire |
---|
| |
| |
| |
面试题5:对比GROUP BY
与窗口函数的核心差异,并用同一需求(计算部门总薪资)分别写出两种实现方式。
参考答案: 1、核心差异对比
| | 窗口函数(如:SUM() OVER (PARTITION BY)) |
---|
| | |
| | |
| | 可同时实现多维度聚合(如:同一行显示部门总薪资+公司总薪资) |
2、实现方式对比(需求:计算每个员工薪资及所属部门的总薪资)
方式1:GROUP BY + 关联查询
-- 第一步:用GROUP BY计算部门总薪资
WITH dept_total_sal AS (
SELECT
department,
SUM(salary) AS dept_total
FROM employees
GROUP BY department
)
-- 第二步:关联原表获取员工个人薪资与部门总薪资
SELECT
e.department,
e.employee,
e.salary,
dts.dept_total
FROM employees e
JOIN dept_total_sal dts ON e.department = dts.department
ORDER BY e.department, e.salary DESC;
方式2:窗口函数(PARTITION BY)
SELECT
department,
employee,
salary,
-- 按部门分组计算总薪资,直接作为新增列返回
SUM(salary) OVER (PARTITION BY department) AS dept_total
FROM employees
ORDER BY department, salary DESC;
结果一致性:两种方式最终都会返回“员工个人薪资+部门总薪资”,但窗口函数无需额外子查询和关联,代码更简洁;当需同时展示原始数据与聚合结果时,窗口函数效率更高。
面试题6:用窗口函数计算“每个用户连续2个月及以上销售额下降的时间段”(用户表:user_sales,含user_id、month、revenue)。
参考答案:
WITH user_sales_diff AS (
-- 第一步:计算每个用户当月与上月销售额的差异(下降标记为1,否则为0)
SELECT
user_id,
month,
revenue,
-- 获取上月销售额
LAG(revenue, 1) OVER (
PARTITION BY user_id
ORDER BY month
) AS prev_month_revenue,
-- 标记是否下降:当月销售额 < 上月则为1,否则为0
CASE
WHEN revenue < LAG(revenue, 1) OVER (PARTITION BY user_id ORDER BY month)
THEN 1
ELSE 0
END AS is_decline
FROM user_sales
),
-- 第二步:对连续下降的记录分配同一“下降组ID”(中断时重置)
user_decline_group AS (
SELECT
user_id,
month,
revenue,
is_decline,
-- 用累计求和生成组ID:非下降时累计值不变,下降时累计值+1(实现连续分组)
SUM(CASE WHEN is_decline = 1 THEN 1 ELSE 0 END) OVER (
PARTITION BY user_id
ORDER BY month
ROWS UNBOUNDED PRECEDING
) AS decline_group_id
FROM user_sales_diff
WHERE prev_month_revenue IS NOT NULL -- 排除无上月数据的首月
),
-- 第三步:筛选连续2个月及以上下降的组(组内记录数≥2)
user_continuous_decline AS (
SELECT
user_id,
decline_group_id,
MIN(month) AS start_decline_month, -- 连续下降起始月
MAX(month) AS end_decline_month, -- 连续下降结束月
COUNT(*) AS continuous_months -- 连续下降月数
FROM user_decline_group
WHERE is_decline = 1 -- 仅保留下降记录
GROUP BY user_id, decline_group_id
HAVING COUNT(*) >= 2 -- 筛选连续2个月及以上的下降
)
-- 第四步:关联原始销售额,展示完整下降时间段信息
SELECT
ucd.user_id,
ucd.start_decline_month,
ucd.end_decline_month,
ucd.continuous_months,
-- 获取起始月和结束月的销售额
us_start.revenue AS start_month_revenue,
us_end.revenue AS end_month_revenue
FROM user_continuous_decline ucd
JOIN user_sales us_start
ON ucd.user_id = us_start.user_id
AND ucd.start_decline_month = us_start.month
JOIN user_sales us_end
ON ucd.user_id = us_end.user_id
AND ucd.end_decline_month = us_end.month
ORDER BY ucd.user_id, ucd.start_decline_month;
思路:通过LAG()
计算销售额环比差异,用SUM()
窗口函数实现“连续下降分组”(同一连续下降段分配相同组ID),最后筛选组内记录数≥2的分组,精准定位连续下降时间段,解决“连续趋势识别”的典型分析需求。
输出(片段)(用户ID=U002):
面试题7:现有订单表(orders)含订单ID(order_id)、用户ID(user_id)、订单金额(amount)、下单时间(order_time),请计算“每个用户的第3笔订单金额”以及“该用户前3笔订单的平均金额”。
参考答案:
WITH user_order_rank AS (
-- 第一步:按用户分组,按下单时间升序给订单排名(第1笔为1,第3笔为3)
SELECT
user_id,
order_id,
amount,
order_time,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_time ASC -- 按下单时间确定订单顺序
) AS order_rank
FROM orders
),
-- 第二步:计算每个用户前3笔订单的平均金额(无论是否有第3笔)
user_top3_avg AS (
SELECT
user_id,
ROUND(AVG(amount), 2) AS avg_top3_amount -- 前3笔订单平均金额
FROM user_order_rank
WHERE order_rank <= 3 -- 仅统计前3笔订单
GROUP BY user_id
),
-- 第三步:提取每个用户的第3笔订单金额(无第3笔则为NULL)
user_third_order AS (
SELECT
user_id,
amount AS third_order_amount -- 第3笔订单金额
FROM user_order_rank
WHERE order_rank = 3
)
-- 第四步:关联结果,展示用户第3笔订单金额与前3笔平均金额
SELECT
COALESCE(uto.user_id, uta.user_id) AS user_id, -- 确保所有用户都显示
uto.third_order_amount,
uta.avg_top3_amount
FROM user_third_order uto
FULL JOIN user_top3_avg uta ON uto.user_id = uta.user_id
ORDER BY user_id;
细节:用ROW_NUMBER()
确保每个用户的订单按时间唯一排序(避免同一时间多笔订单导致排名混乱);通过FULL JOIN
关联“第3笔订单”和“前3笔平均”结果,确保“无第3笔订单的用户”也能显示前3笔平均金额(若前3笔不足,按实际订单数计算平均)。
输出(片段):
面试题8:解释ROWS
与RANGE
的区别,并分别用两者实现“计算薪资不低于当前员工薪资10%范围内的平均薪资”。
参考答案:
1、ROWS
与RANGE
核心区别
2、实现方式对比(需求:薪资±10%范围内的平均薪资)
前提:员工表(employees)含employee
(姓名)、salary
(薪资),需按薪资排序后计算范围平均。
方式1:用RANGE
实现(推荐,精准匹配“数值范围”需求)
SELECT
employee,
salary,
-- 排序列为salary,范围为“当前薪资*0.9 到 当前薪资*1.1”
ROUND(AVG(salary) OVER (
ORDER BY salary
RANGE BETWEEN
(salary * 0.9) PRECEDING -- 下限:当前薪资的90%
AND (salary * 1.1) FOLLOWING -- 上限:当前薪资的110%
), 2) AS avg_salary_10pct_range
FROM employees
ORDER BY salary;
方式2:用ROWS
实现(需先确定行数,仅适用于薪资连续且均匀的场景)
-- 注:此方式需先确认“薪资±10%对应固定行数”,实际业务中不推荐,仅作对比
WITH sal_sorted AS (
-- 第一步:按薪资排序,给每行分配行号
SELECT
employee,
salary,
ROW_NUMBER() OVER (ORDER BY salary) AS sal_row_num
FROM employees
)
SELECT
employee,
salary,
-- 假设薪资±10%对应“前1行到后1行”(需根据实际数据调整行数)
ROUND(AVG(salary) OVER (
ORDER BY salary
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
), 2) AS avg_salary_10pct_rows
FROM sal_sorted
ORDER BY salary;
结果差异说明:若员工薪资分布不均(如:部分员工薪资差距大),ROWS
方式会包含薪资超出±10%的行,导致结果偏差;RANGE
方式直接基于薪资数值范围计算,结果更精准,符合业务需求。
修正:RANGE
的PRECEDING/FOLLOWING
仅支持常量(如:100 PRECEDING
),不支持动态值(如:salary * 0.9
),需先计算当前行薪资的 ±10% 阈值,再用RANGE
结合子查询实现,例如:
WITH sal_threshold AS (
SELECT employee, salary,
salary * 0.9 AS lower_bound,
salary * 1.1 AS upper_bound
FROM employees
)
SELECT employee, salary,
AVG(salary) OVER (
ORDER BY salary
RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
-- 结合WHERE筛选范围(实际需用子查询关联)
) AS avg_salary_10pct_range
FROM sal_threshold;
说明:RANGE
的动态值范围计算在SQL中需间接实现,直接写salary * 0.9
会报错,需特别注意。
面试题9:用窗口函数实现“按季度统计每个产品的销售额,并对比当季销售额与上季度销售额的增长率”。
参考答案:
WITH product_quarterly_sales AS (
-- 第一步:按产品和季度分组,计算季度销售额
SELECT
product_id,
product_name,
-- 提取年份和季度(如:2023Q1)
CONCAT(YEAR(order_time), 'Q', QUARTER(order_time)) AS sale_quarter,
SUM(amount) AS quarterly_revenue -- 季度销售额
FROM orders
JOIN products ON orders.product_id = products.product_id
GROUP BY product_id, product_name, YEAR(order_time), QUARTER(order_time)
)
-- 第二步:计算上季度销售额及增长率
SELECT
product_id,
product_name,
sale_quarter,
quarterly_revenue,
-- 获取上季度销售额(按产品和季度排序)
LAG(quarterly_revenue, 1) OVER (
PARTITION BY product_id
ORDER BY sale_quarter -- 按季度排序,确保时间顺序正确
) AS prev_quarter_revenue,
-- 计算增长率(保留2位小数,无上限时显示NULL)
ROUND(
(quarterly_revenue - LAG(quarterly_revenue, 1) OVER (
PARTITION BY product_id ORDER BY sale_quarter
)) / LAG(quarterly_revenue, 1) OVER (
PARTITION BY product_id ORDER BY sale_quarter
) * 100,
2
) AS revenue_growth_rate
FROM product_quarterly_sales
ORDER BY product_id, sale_quarter;
技巧:先通过YEAR()
和QUARTER()
函数聚合季度销售额,生成“产品-季度”维度的基础数据;再用LAG()
按产品分组、按季度排序,获取上季度销售额,最终通过增长率公式计算同比变化,满足“季度对比”的业务分析需求。
输出(片段)(产品ID=P001):
面试题10:现有学生成绩表(exam_scores)含学生ID(student_id)、科目(subject)、分数(score),请找出“每科分数排名前20%的学生”,并标注其所在科目和排名。
参考答案:
SELECT
student_id,
subject,
score,
-- 计算百分比排名((当前排名-1)/(总行数-1)),范围[0,1]
ROUND(PERCENT_RANK() OVER (
PARTITION BY subject
ORDER BY score DESC -- 分数降序,排名1为最高分
), 4) AS score_percent_rank,
-- 标注是否为前20%(百分比排名≤0.2)
CASE
WHEN PERCENT_RANK() OVER (
PARTITION BY subject
ORDER BY score DESC
) <= 0.2
THEN '是'
ELSE '否'
END AS is_top20_percent
FROM exam_scores
-- 筛选前20%的学生(也可在WHERE中过滤,此处用CASE标注更直观)
WHERE PERCENT_RANK() OVER (
PARTITION BY subject
ORDER BY score DESC
) <= 0.2
ORDER BY subject, score DESC;
说明:原真题WHERE PERCENT_RANK() OVER (...) <= 0.2
窗口函数不能在 WHERE中直接使用,应改为(示例):
SELECT * FROM (
SELECT ...,
PERCENT_RANK() OVER (...) AS pct_rank
FROM exam_scores
) t WHERE pct_rank <= 0.2
函数选择理由:PERCENT_RANK()
能直接计算学生成绩在科目内的相对排名比例,相比CUME_DIST()
(累计分布),其“前20%”的判定更精准(如:科目有50人,前10人恰好为20%,PERCENT_RANK()
≤0.2可准确筛选);若用NTILE(5)
分5个桶取第1桶,当人数无法均分(如:51人)时,第1桶会多1人,导致结果偏差。
输出片段(科目=数学):
面试题11:现有用户行为表(user_behavior)含用户ID(user_id)、行为类型(behavior_type,值为“浏览”、“加购”、“下单”)、行为时间(behavior_time),请计算“每个用户从‘加购’到‘下单’的平均间隔时间”(仅统计有完整加购-下单流程的用户)。
参考答案:
WITH user_cart AS (
-- 第一步:提取用户所有“加购”行为及时间
SELECT
user_id,
behavior_time AS cart_time,
-- 给同一用户的加购行为按时间排序(避免多笔加购对应多笔下单的匹配混乱)
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY behavior_time ASC
) AS cart_rank
FROM user_behavior
WHERE behavior_type = '加购'
),
user_purchase AS (
-- 第二步:提取用户所有“下单”行为及时间
SELECT
user_id,
behavior_time AS purchase_time,
-- 给同一用户的下单行为按时间排序
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY behavior_time ASC
) AS purchase_rank
FROM user_behavior
WHERE behavior_type = '下单'
),
-- 第三步:匹配加购与下单(按用户+排序序号一对一匹配,确保时间顺序)
cart_purchase_match AS (
SELECT
uc.user_id,
uc.cart_time,
up.purchase_time,
-- 计算加购到下单的间隔时间(单位:分钟,可按需改为小时/天)
TIMESTAMPDIFF(MINUTE, uc.cart_time, up.purchase_time) AS cart_to_purchase_minutes
FROM user_cart uc
JOIN user_purchase up
ON uc.user_id = up.user_id
AND uc.cart_rank = up.purchase_rank -- 按序号匹配,确保“先加购后下单”
WHERE uc.cart_time < up.purchase_time -- 排除下单时间早于加购的异常数据
)
-- 第四步:计算每个用户的平均加购-下单间隔
SELECT
user_id,
COUNT(*) AS valid_cart_purchase_count, -- 有效加购-下单次数
ROUND(AVG(cart_to_purchase_minutes), 1) AS avg_cart_to_purchase_minutes -- 平均间隔
FROM cart_purchase_match
GROUP BY user_id
ORDER BY avg_cart_to_purchase_minutes;
说明:通过ROW_NUMBER()
给同一用户的加购、下单行为分别排序,实现“按时间顺序一对一匹配”(避免多笔加购与下单交叉匹配);用TIMESTAMPDIFF()
计算时间间隔,过滤异常数据后聚合平均,精准反映用户“加购到下单”的转化效率。
输出(片段):
| valid_cart_purchase_count | avg_cart_to_purchase_minutes |
---|
| | |
| | |
| | |
面试题12:对比RANK()
、DENSE_RANK()
与ROW_NUMBER()
的差异,并用同一组数据(学生成绩)展示三种函数的排名结果。
参考答案:
1、三种排名函数核心差异
| | | |
---|
ROW_NUMBER() | | | |
RANK() | | | |
DENSE_RANK() | | | |
2、实例展示(学生成绩排名)
原始数据(学生成绩表exam_scores
):
SQL实现与结果:
SELECT
student_id,
score,
ROW_NUMBER() OVER (ORDER BY score DESC) AS rn,
RANK() OVER (ORDER BY score DESC) AS rk,
DENSE_RANK() OVER (ORDER BY score DESC) AS dr
FROM exam_scores
ORDER BY score DESC;
输出结果:
结果解读:S01和S03均为95分(并列第1),ROW_NUMBER()
给两者分配1、2的唯一序号,RANK()
跳过序号2直接到3,DENSE_RANK()
则紧凑到2,清晰体现三种函数的逻辑差异。
面试题13:现有店铺销售表(store_sales)含店铺ID(store_id)、日期(sale_date)、销售额(amount),请计算“每个店铺每月的销售额同比增长率”(同比=(当月销售额-去年同月销售额)/去年同月销售额*100%)。
参考答案:
WITH store_monthly_sales AS (
-- 第一步:按店铺和年月分组,计算月销售额
SELECT
store_id,
YEAR(sale_date) AS sale_year,
MONTH(sale_date) AS sale_month,
SUM(amount) AS monthly_amount -- 月销售额
FROM store_sales
GROUP BY store_id, YEAR(sale_date), MONTH(sale_date)
)
-- 第二步:计算同比增长率(获取去年同月销售额)
SELECT
store_id,
CONCAT(sale_year, '-', LPAD(sale_month, 2, '0')) AS sale_month, -- 格式化年月(如:2023-05)
monthly_amount,
-- 获取去年同月销售额(按店铺+月份匹配,年份-1)
LAG(monthly_amount, 12) OVER (
PARTITION BY store_id
ORDER BY sale_year, sale_month -- 按年份+月份排序,确保时间顺序
) AS last_year_same_month_amount,
-- 计算同比增长率(保留2位小数,无去年数据时显示NULL)
ROUND(
(monthly_amount - LAG(monthly_amount, 12) OVER (
PARTITION BY store_id ORDER BY sale_year, sale_month
)) / NULLIF(LAG(monthly_amount, 12) OVER (
PARTITION BY store_id ORDER BY sale_year, sale_month
), 0) * 100,
2
) AS yoy_growth_rate
FROM store_monthly_sales
ORDER BY store_id, sale_year, sale_month;
说明:NULLIF(LAG(...), 0)
建议改为(monthly_amount - last_year) / NULLIF(last_year, 0) * 100
,NULLIF应包裹分母避免除零错误。
技巧:用LAG(monthly_amount, 12)
按店铺分组、按时间排序,直接获取“12行前”(即去年同月)的销售额;通过NULLIF()
处理“去年销售额为0”的情况,避免除法报错;LPAD()
函数格式化月份为两位数(如:5月→05),提升结果可读性。
输出片段(店铺ID=ST001):
| | | last_year_same_month_amount | |
---|
| | | | |
| | | | |
| | | | |
| | | | |
| | | | |
面试题14:用窗口函数实现“查询每个部门薪资最高的2名员工,若有并列第2名则全部保留”。
参考答案:
WITH dept_sal_rank AS (
-- 第一步:按部门分组,按薪资降序用DENSE_RANK()排名(并列不跳号)
SELECT
department,
employee,
salary,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS sal_rank
FROM employees
)
-- 第二步:筛选排名≤2的员工(保留并列第2名)
SELECT
department,
employee,
salary,
sal_rank
FROM dept_sal_rank
WHERE sal_rank <= 2
ORDER BY department, sal_rank, salary DESC;
函数选择理由:若用RANK()
,当存在并列第1名时,会跳过第2名直接到第3名(如:薪资9000、9000、8500,排名为1、1、3),导致无法保留第2名;DENSE_RANK()
会将8500排名为2,若有多个8500(如:8500、8500),则均排名2,满足“并列第2全部保留”的需求。
输出片段(部门=IT):
面试题15:现有物流表(logistics)含订单ID(order_id)、物流状态(status,值为“已发货”、“运输中”、“已签收”)、状态更新时间(update_time),请计算“每个订单从‘已发货’到‘已签收’的总运输时长”(仅统计已完成签收的订单)。
参考答案:
WITH order_logistics AS (
-- 第一步:提取每个订单的“已发货”和“已签收”时间
SELECT
order_id,
-- 获取“已发货”时间(FIRST_VALUE取状态排序后的首行,此处需先过滤状态)
MAX(CASE WHEN status = '已发货' THEN update_time END) AS ship_time,
-- 获取“已签收”时间
MAX(CASE WHEN status = '已签收' THEN update_time END) AS sign_time
FROM logistics
-- 仅保留“已发货”和“已签收”的状态记录
WHERE status IN ('已发货', '已签收')
GROUP BY order_id
-- 过滤已完成签收的订单(需同时有发货和签收时间)
HAVING MAX(CASE WHEN status = '已发货' THEN update_time END) IS NOT NULL
AND MAX(CASE WHEN status = '已签收' THEN update_time END) IS NOT NULL
)
-- 第二步:计算运输时长(单位:小时,按需调整)
SELECT
order_id,
ship_time,
sign_time,
TIMESTAMPDIFF(HOUR, ship_time, sign_time) AS transport_hours -- 运输时长(小时)
FROM order_logistics
ORDER BY transport_hours DESC;
替代窗口函数实现(若需保留所有状态记录):
WITH logistics_rank AS (
SELECT
order_id,
status,
update_time,
-- 按订单分组,按更新时间排序
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY update_time ASC
) AS time_rank
FROM logistics
)
SELECT
lr1.order_id,
lr1.update_time AS ship_time,
lr2.update_time AS sign_time,
TIMESTAMPDIFF(HOUR, lr1.update_time, lr2.update_time) AS transport_hours
FROM logistics_rank lr1
JOIN logistics_rank lr2
ON lr1.order_id = lr2.order_id
AND lr1.status = '已发货'
AND lr2.status = '已签收'
ORDER BY lr1.order_id;
输出片段:
说明:HOUR单位仅返回整数部分,小数需通过分钟 / 秒换算,TIMESTAMPDIFF(HOUR, ...)
返回整数(如:29小时30分钟会计算为29小时),若需小数精度,需用TIMESTAMPDIFF(MINUTE, ...)/60
转换。
以上15道面试题覆盖了窗口函数的核心语法、分类应用、跨场景实战及与其他SQL功能的对比,可帮助我们全面掌握窗口函数在实际业务中的使用逻辑与避坑要点。
阅读原文:原文链接
该文章在 2025/9/1 11:18:04 编辑过