PostgreSQL实战:窗口函数详解

文章目录

    • [一、为什么窗口函数是 SQL 的"核武器"?](#一、为什么窗口函数是 SQL 的“核武器”?)
      • [1.1 传统聚合的局限](#1.1 传统聚合的局限)
      • [1.2 窗口函数 vs 普通聚合](#1.2 窗口函数 vs 普通聚合)
      • [1.3 优化点 checklist](#1.3 优化点 checklist)
      • [1.4 窗口函数语法:三大核心组件](#1.4 窗口函数语法:三大核心组件)
      • [1.5 窗口函数使用建议](#1.5 窗口函数使用建议)
    • 二、五大类窗口函数实战
      • [2.1 聚合函数(最常用)](#2.1 聚合函数(最常用))
      • [2.2 排名函数(Top-N 分析)](#2.2 排名函数(Top-N 分析))
      • [2.3 偏移函数(时间序列分析)](#2.3 偏移函数(时间序列分析))
      • [2.4 分布函数(百分位分析)](#2.4 分布函数(百分位分析))
      • [2.5 值函数(高级分析)](#2.5 值函数(高级分析))
    • [三、Frame Clause(帧范围)深度解析](#三、Frame Clause(帧范围)深度解析)
      • [3.1 默认帧范围规则](#3.1 默认帧范围规则)
      • [3.2 帧类型详解](#3.2 帧类型详解)
      • [3.3 经典案例:修复 LAST_VALUE()](#3.3 经典案例:修复 LAST_VALUE())
    • 四、高性能窗口函数技巧
      • [4.1 索引优化](#4.1 索引优化)
      • [4.2 避免全表扫描](#4.2 避免全表扫描)
    • 五、综合实战案例
      • [5.1 案例 1:用户留存分析(经典漏斗)](#5.1 案例 1:用户留存分析(经典漏斗))
      • [5.2 案例 2:股票技术指标(移动平均线)](#5.2 案例 2:股票技术指标(移动平均线))
      • [5.3 案例 3:会话化用户行为](#5.3 案例 3:会话化用户行为)
    • 六、常见陷阱与避坑指南
      • [6.1 陷阱 1:混淆 ROWS 和 RANGE](#6.1 陷阱 1:混淆 ROWS 和 RANGE)
      • [6.2 陷阱 2:在 WHERE/HAVING 中使用窗口函数](#6.2 陷阱 2:在 WHERE/HAVING 中使用窗口函数)
    • [七、PostgreSQL 特色功能](#七、PostgreSQL 特色功能)
      • [7.1 FILTER 子句(条件聚合)](#7.1 FILTER 子句(条件聚合))
      • [7.2 IGNORE NULLS(PostgreSQL 11+)](#7.2 IGNORE NULLS(PostgreSQL 11+))

适用版本 :PostgreSQL 9.0+(推荐 12+)
目标读者 :数据分析师、后端开发、DBA
核心价值:掌握窗口函数,用一行 SQL 替代百行代码,解锁高级分析能力

一、为什么窗口函数是 SQL 的"核武器"?

1.1 传统聚合的局限

sql 复制代码
-- 需求:计算每个部门的平均工资,同时保留员工明细
SELECT 
    dept, 
    AVG(salary)  -- 错误!非聚合列不能与聚合函数共存
FROM employees;

解决方案

  • 方案1:子查询(嵌套复杂)
  • 方案2:应用层处理(性能差)
  • 方案3:窗口函数(优雅高效)
sql 复制代码
SELECT 
    name,
    dept,
    salary,
    AVG(salary) OVER (PARTITION BY dept) AS dept_avg  -- 保留明细 + 聚合
FROM employees;

1.2 窗口函数 vs 普通聚合

特性 普通聚合 (GROUP BY) 窗口函数 (OVER())
行数 减少(每组1行) 不变(每行保留)
明细数据 丢失 保留
多维度分析 需多次查询 单次查询完成
排序敏感 是(支持 ORDER BY

1.3 优化点 checklist

执行计划检查

sql 复制代码
EXPLAIN ANALYZE 
SELECT ..., ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary)
FROM employees;

关键优化点

  • PARTITION BY 列有索引
  • ORDER BY 列包含在索引中
  • 避免在大表上无分区的窗口函数
  • 使用 ROWS 而非 RANGE(除非需要逻辑范围)
  • 复杂窗口用 CTE 分步计算

1.4 窗口函数语法:三大核心组件

sql 复制代码
function_name (expression) OVER (
    [PARTITION BY partition_expression, ...]
    [ORDER BY sort_expression [ASC|DESC], ...]
    [frame_clause]
)

(1) PARTITION BY:分组(类似 GROUP BY)

  • 定义窗口的分组边界
  • 空值视为同一组

(2) ORDER BY:排序(窗口内排序)

  • 决定计算顺序默认帧范围
  • 对排名类函数至关重要

(3) Frame Clause:帧范围(计算窗口大小)

  • 定义当前行关联的行范围
  • 默认行为因 ORDER BY 存在与否而异

1.5 窗口函数使用建议

场景 推荐函数 关键要点
分组聚合+明细 SUM() OVER (PARTITION BY ...) 保留原始行
Top-N 分析 ROW_NUMBER() + CTE 避免 RANK() 跳跃
时间序列 LAG()/LEAD() 处理 NULL 值
分布分析 PERCENT_RANK()/NTILE() 理解分位含义
移动计算 AVG() OVER (ROWS ...) 显式指定帧范围

建议
"当你发现自己写了很多子查询或应用层循环时,停下来想想------窗口函数能解决吗?"

掌握窗口函数,你将从 SQL 初学者进阶为数据处理大师

二、五大类窗口函数实战

2.1 聚合函数(最常用)

支持所有聚合函数:SUM(), AVG(), COUNT(), MAX(), MIN(), STDDEV()...

场景:部门工资分析

sql 复制代码
SELECT 
    name,
    dept,
    salary,
    -- 部门总工资
    SUM(salary) OVER (PARTITION BY dept) AS dept_total,
    -- 部门平均工资
    ROUND(AVG(salary) OVER (PARTITION BY dept), 2) AS dept_avg,
    -- 公司总人数
    COUNT(*) OVER () AS company_size
FROM employees;

💡 技巧OVER () 表示整个结果集为一个窗口


2.2 排名函数(Top-N 分析)

函数 特点 并列处理
ROW_NUMBER() 连续唯一 无并列(强制排序)
RANK() 跳跃排名 相同值同排名,下一名跳过
DENSE_RANK() 密集排名 相同值同排名,下一名连续

场景:销售排行榜

sql 复制代码
SELECT 
    salesperson,
    region,
    amount,
    ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num,
    RANK() OVER (ORDER BY amount DESC) AS rank_num,
    DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rank_num
FROM sales;

输出示例

复制代码
amount | row_num | rank_num | dense_rank_num
-------|---------|----------|---------------
 5000  |    1    |    1     |       1
 4000  |    2    |    2     |       2
 4000  |    3    |    2     |       2   ← 并列
 3000  |    4    |    4     |       3   ← RANK跳过3, DENSE_RANK连续

实战:每个区域 Top 3 销售

sql 复制代码
WITH ranked_sales AS (
    SELECT 
        *,
        ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn
    FROM sales
)
SELECT * FROM ranked_sales WHERE rn <= 3;

2.3 偏移函数(时间序列分析)

函数 作用
LAG(column, offset, default) 获取N行的值
LEAD(column, offset, default) 获取N行的值
FIRST_VALUE(column) 窗口第一行的值
LAST_VALUE(column) 窗口最后一行的值

场景:计算日环比增长率

sql 复制代码
SELECT 
    date,
    revenue,
    LAG(revenue) OVER (ORDER BY date) AS prev_revenue,
    ROUND(
        (revenue - LAG(revenue) OVER (ORDER BY date)) 
        / NULLIF(LAG(revenue) OVER (ORDER BY date), 0) * 100, 
        2
    ) AS mom_growth_pct
FROM daily_revenue
ORDER BY date;

场景:用户会话识别(事件间隔 > 30分钟 = 新会话)

sql 复制代码
SELECT 
    user_id,
    event_time,
    -- 判断是否新会话起点
    CASE WHEN 
        EXTRACT(EPOCH FROM (
            event_time - LAG(event_time) OVER (
                PARTITION BY user_id 
                ORDER BY event_time
            )
        )) > 1800  -- 30分钟=1800秒
        OR LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) IS NULL
    THEN 1 ELSE 0 END AS is_new_session
FROM user_events;

2.4 分布函数(百分位分析)

函数 作用
PERCENT_RANK() 当前行的相对排名(0~1)
CUME_DIST() 累积分布(≤当前值的比例)
NTILE(n) 将窗口分为N个桶(等深分箱)

场景:学生成绩分位分析

sql 复制代码
SELECT 
    student,
    score,
    -- 百分等级(0=最低, 1=最高)
    ROUND(PERCENT_RANK() OVER (ORDER BY score), 4) AS pct_rank,
    -- 累积分布(如0.8=80%学生分数≤当前)
    ROUND(CUME_DIST() OVER (ORDER BY score), 4) AS cume_dist,
    -- 四分位分组(1=最低25%, 4=最高25%)
    NTILE(4) OVER (ORDER BY score) AS quartile
FROM exam_scores;

2.5 值函数(高级分析)

函数 作用
NTH_VALUE(column, n) 窗口第N行的值
RATIO_TO_REPORT(expression) 当前行值占窗口总和的比例

场景:计算产品销售额占比

sql 复制代码
SELECT 
    product,
    sales,
    ROUND(
        RATIO_TO_REPORT(sales) OVER (), 
        4
    ) AS sales_ratio
FROM products;

三、Frame Clause(帧范围)深度解析

帧范围定义当前行参与计算的行集合,是窗口函数最易混淆的部分。

3.1 默认帧范围规则

条件 默认帧范围
ORDER BY RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
ORDER BY RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING

陷阱LAST_VALUE() 在默认帧下永远返回当前行!

3.2 帧类型详解

(1) ROWS:基于物理行数

sql 复制代码
-- 移动平均(前2天+当天+后1天)
AVG(price) OVER (
    ORDER BY date 
    ROWS BETWEEN 2 PRECEDING AND 1 FOLLOWING
)

(2) RANGE:基于逻辑值范围

sql 复制代码
-- 所有相同日期的平均价格(处理重复日期)
AVG(price) OVER (
    ORDER BY date 
    RANGE BETWEEN INTERVAL '1 day' PRECEDING AND CURRENT ROW
)

(3) GROUPS:基于对等组(PostgreSQL 11+)

sql 复制代码
-- 每个排名组的平均值
AVG(salary) OVER (
    ORDER BY rank 
    GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING
)

3.3 经典案例:修复 LAST_VALUE()

sql 复制代码
-- 错误:默认帧导致 LAST_VALUE = 当前行
SELECT 
    id, 
    value,
    LAST_VALUE(value) OVER (ORDER BY id)  -- 总是返回value本身!
FROM t;

-- 正确:显式指定完整帧
SELECT 
    id, 
    value,
    LAST_VALUE(value) OVER (
        ORDER BY id 
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS last_val
FROM t;

四、高性能窗口函数技巧

4.1 索引优化

  • PARTITION BY → 建复合索引
  • ORDER BY → 索引需包含排序字段
sql 复制代码
-- 查询:按部门分组,按工资排序
CREATE INDEX idx_emp_dept_salary ON employees(dept, salary DESC);

-- 窗口函数
SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC)
FROM employees;

4.2 避免全表扫描

sql 复制代码
-- 低效:先计算全表窗口,再过滤
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
    FROM employees
) t WHERE rn <= 3;

-- 高效:用 LATERAL JOIN(PostgreSQL 9.3+)
SELECT e.*
FROM (SELECT DISTINCT dept FROM employees) d
CROSS JOIN LATERAL (
    SELECT * 
    FROM employees e2 
    WHERE e2.dept = d.dept 
    ORDER BY salary DESC 
    LIMIT 3
) e;

💡 何时用 LATERAL:当分组数量少但每组数据量大时


五、综合实战案例

5.1 案例 1:用户留存分析(经典漏斗)

需求:计算次日/7日/30日留存率

sql 复制代码
WITH first_login AS (
    SELECT 
        user_id,
        MIN(login_date) AS first_date
    FROM logins
    GROUP BY user_id
),
retention_days AS (
    SELECT 
        f.user_id,
        f.first_date,
        l.login_date,
        l.login_date - f.first_date AS days_since_first
    FROM first_login f
    JOIN logins l ON f.user_id = l.user_id
),
cohort_size AS (
    SELECT 
        first_date,
        COUNT(DISTINCT user_id) AS cohort_users
    FROM first_login
    GROUP BY first_date
),
retention_counts AS (
    SELECT 
        r.first_date,
        r.days_since_first,
        COUNT(DISTINCT r.user_id) AS retained_users
    FROM retention_days r
    WHERE r.days_since_first IN (0, 1, 7, 30)
    GROUP BY r.first_date, r.days_since_first
)
SELECT 
    c.first_date,
    c.cohort_users,
    MAX(CASE WHEN r.days_since_first = 1 THEN r.retained_users END) AS day1_retained,
    ROUND(100.0 * MAX(CASE WHEN r.days_since_first = 1 THEN r.retained_users END) / c.cohort_users, 2) AS day1_rate,
    -- ... 类似计算7日/30日
FROM cohort_size c
LEFT JOIN retention_counts r ON c.first_date = r.first_date
GROUP BY c.first_date, c.cohort_users
ORDER BY c.first_date;

5.2 案例 2:股票技术指标(移动平均线)

sql 复制代码
SELECT 
    date,
    close_price,
    -- 5日简单移动平均
    AVG(close_price) OVER (
        ORDER BY date 
        ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
    ) AS ma5,
    -- 20日指数移动平均(需递归CTE,此处简化)
    AVG(close_price) OVER (
        ORDER BY date 
        ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
    ) AS ma20
FROM stock_prices
ORDER BY date;

5.3 案例 3:会话化用户行为

sql 复制代码
WITH sessionized AS (
    SELECT 
        user_id,
        event_time,
        -- 标记新会话(间隔>30分钟)
        SUM(CASE WHEN 
            EXTRACT(EPOCH FROM (event_time - LAG(event_time) OVER w)) > 1800 
            OR LAG(event_time) OVER w IS NULL 
        THEN 1 ELSE 0 END) OVER w AS session_id
    FROM user_events
    WINDOW w AS (PARTITION BY user_id ORDER BY event_time)
)
SELECT 
    user_id,
    session_id,
    MIN(event_time) AS session_start,
    MAX(event_time) AS session_end,
    COUNT(*) AS events_count
FROM sessionized
GROUP BY user_id, session_id;

六、常见陷阱与避坑指南

6.1 陷阱 1:混淆 ROWS 和 RANGE

sql 复制代码
-- 数据:相同日期多条记录
date       | price
-----------|------
2023-01-01 | 100
2023-01-01 | 200  -- 同一天两条记录

-- ROWS:基于行数
AVG(price) OVER (ORDER BY date ROWS UNBOUNDED PRECEDING) 
-- 第二行结果 = (100+200)/2 = 150

-- RANGE:基于值范围
AVG(price) OVER (ORDER BY date RANGE UNBOUNDED PRECEDING) 
-- 第二行结果 = (100+200)/2 = 150 (相同)

-- 但如果 ORDER BY 多列:
ORDER BY date, price

-- ROWS:严格按行
-- RANGE:相同(date,price)视为一组

6.2 陷阱 2:在 WHERE/HAVING 中使用窗口函数

sql 复制代码
-- 错误!窗口函数在 SELECT 阶段计算,WHERE 无法访问
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (...) AS rn
    FROM t
) WHERE rn <= 10;  -- 必须用子查询或CTE

七、PostgreSQL 特色功能

7.1 FILTER 子句(条件聚合)

sql 复制代码
-- 计算部门中高薪员工(>10000)的比例
SELECT 
    dept,
    COUNT(*) FILTER (WHERE salary > 10000) * 100.0 / COUNT(*) AS high_earner_pct
FROM employees
GROUP BY dept;

-- 结合窗口函数
SELECT 
    name,
    dept,
    salary,
    COUNT(*) FILTER (WHERE salary > 10000) OVER (PARTITION BY dept) AS high_earners_in_dept
FROM employees;

7.2 IGNORE NULLS(PostgreSQL 11+)

sql 复制代码
-- 跳过空值获取前一个非空值
LAG(salary IGNORE NULLS) OVER (ORDER BY date)
相关推荐
狂龙骄子2 小时前
MySQL表字段批量修改SQL实战技巧
数据库·sql·mysql·alter table·批量修改·sql实战技巧
catchadmin2 小时前
2026 年 PHP 函数式编程 优势与实际应用
数据库·php
roman_日积跬步-终至千里2 小时前
【SQL】SQL 语句的解析顺序:理解查询执行的逻辑
java·数据库·sql
ascarl20102 小时前
达梦与 Oracle 的关系及数据库架构差异
数据库·oracle·数据库架构
Mao.O2 小时前
Redis三大缓存问题及布隆过滤器详解
数据库·redis·缓存
悟能不能悟2 小时前
在Oracle中,包分为包头(PACKAGE)和包体(PACKAGE BODY),存储过程的实现代码在包体中。以下是几种查找方法
数据库·oracle
铉铉这波能秀2 小时前
如何在arcmap中将shp等文件类型导出为表格(四种方法)
数据库·arcgis·数据分析·arcmap·地理信息·shp
廋到被风吹走2 小时前
【数据库】【Redis】缓存监控体系深度解析:从 BigKeys 到慢查询
数据库·redis·缓存
张乔242 小时前
spring boot项目中设置默认的方法实现
java·数据库·spring boot