高级 SQL 实战教程(华为云 DWS / PostgreSQL 版)

📌 本教程不会只扔代码。

每一个知识点都会从真实的"业务问题"出发,先弄清楚为什么要用 ,再讲明白底层怎么运作 ,最后给出可运行的 SQL ,并指出新手最容易掉进去的坑

一、📊 窗口函数------不是"高大上",是解决问题的利器

窗口函数最让人困惑的地方不是语法,而是 "它到底看到了哪些行" 。这一节会花较多笔墨把"窗口帧"讲透。

1.1 累计计算(YTD / MTD)------老板想看"截至本月,今年一共卖了多少?"

🧠 业务场景

销售表里记录着每个月的销量。老板想要一张报表:每个产品、每个月,都要显示从1月累计到当月的总销量(YTD)。你不能只在最后一行加个总和,因为每个月都要看到累计值。

📖 原理解析

核心就是 SUM(quantity) OVER (...) ,但窗口函数的秘密藏在 OVER() 里的三个要素:

要素 作用 本例写法
PARTITION BY 把数据分成几个"小世界",互不干扰 PARTITION BY product_id, year (按产品+年份)
ORDER BY 在每个小世界里,按什么顺序排列 ORDER BY year_month
窗口帧 每一行计算时,能看到小世界里的哪些行 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

重点解释窗口帧:

如果只写 ORDER BY 而不写帧,SQL 标准会默认 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。但在实际中我们常显式写成 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,含义是:从分组内的第一行开始,一直累加到当前行

如果不写 ORDER BY,那么帧默认是整个分区,SUM 会变成全量总和,而不再有累计效果。

✍️ SQL 示例
SQL 复制代码
-- 按产品累计年初至今(YTD)销量(假设 year_month 格式 '202501' 等)
SELECT
    year_month,
    product_id,
    quantity,
    SUM(quantity) OVER (
        PARTITION BY product_id, SUBSTR(year_month, 1, 4)   -- 按产品+年份分区
        ORDER BY year_month                                 -- 按月排序
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW    -- 从年初第一行累加到当前月
    ) AS ytd_qty
FROM orders
WHERE year_month BETWEEN '202501' AND '202505';

运行逻辑示意(假设某产品):

year_month quantity 窗口包含的行(从开始到当前) ytd_qty
202501 100 只有第1行 100
202502 80 第1~2行 180
202503 120 第1~3行 300
⚠️ 常见坑
  • 忘了 ORDER BY,导致每个月的 ytd_qty 都变成全年总计,完全失去累计意义。
  • WHERE 里直接用 ytd_qty 过滤,因为窗口函数在 SELECT 阶段才计算,WHERE 看不到它。必须套一层子查询。

1.2 排名与占比(帕累托分析)------找出贡献80%销售额的核心产品

🧠 业务场景

"二八法则"分析:我们想找出哪些产品合起来贡献了 80% 的销售额,好把资源集中到它们身上。

📖 原理解析

需要两步:

  1. 按销售额从高到低排列产品。
  2. 计算每个产品的累计销售额占总销售额的百分比

关键技术点:

  • SUM(amt) OVER (ORDER BY amt DESC) 为什么会产生累计?

因为 ORDER BY 存在时,窗口帧默认RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(或我们理解为 ROWS ...)。于是它会把从第一名加到当前名的金额加起来。

  • 为了得到占比,再除以 SUM(amt) OVER () ,后者没有 ORDER BY,帧是整个分区,即总金额。
  • ROW_NUMBER() / RANK() / DENSE_RANK() 的区别在于处理并列值时的编号方式。
✍️ SQL 示例
SQL 复制代码
WITH product_amt AS (
    SELECT
        product_id,
        SUM(amount) AS amt
    FROM orders
    WHERE year_month BETWEEN '202501' AND '202505'
    GROUP BY product_id
),
ranked AS (
    SELECT
        product_id,
        amt,
        RANK() OVER (ORDER BY amt DESC) AS rank_num,           -- 有并列会跳号
        DENSE_RANK() OVER (ORDER BY amt DESC) AS dense_rank,   -- 不跳号
        ROW_NUMBER() OVER (ORDER BY amt DESC) AS row_num,      -- 绝对唯一
        SUM(amt) OVER (ORDER BY amt DESC) AS cum_amt,          -- 累计金额(默认帧导致)
        SUM(amt) OVER () AS total_amt,                         -- 全量总金额
        ROUND(
            SUM(amt) OVER (ORDER BY amt DESC) * 100.0 / SUM(amt) OVER (), 
            2
        ) AS cum_pct
    FROM product_amt
)
SELECT * FROM ranked WHERE cum_pct <= 80;   -- 只拿累计占比前80%的产品
🔍 过程数据模拟

假设几个产品的金额:D:300, B:200, E:150, A:100, C:50

product amt 排名(row_number) cum_amt 计算 cum_pct
D 300 1 300 37.5%
B 200 2 500 62.5%
E 150 3 650 81.25%
A 100 4 750 93.75%
C 50 5 800 100%

cum_pct <= 80 会得到 D、B 两个产品,它们合计贡献了 62.5%,未到 80% 则再加上 E 就到 81.25%,所以最终可能还要微调逻辑,但这里演示了核心机制。

⚠️ 常见坑
  • ORDER BY 列有重复值时,累计算出可能"跳变" :因为 RANGE 模式会将相同值的行视为同一帧边界,导致它们的累计值一致。如果必须按物理行严格累计,请显式加 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ,并且排序字段加上唯一键(如 ORDER BY amt DESC, product_id)。
  • 使用 (cum_amt / total_amt) 时,记得 total_amt 要用 SUM(...) OVER () ,不能用普通聚合,否则会报错或结果错误。

1.3 前后行取值(环比 / 同比)------这个月比上个月涨了多少?

🧠 业务场景

月度销售报表里,经常要计算环比增长 (与上月比)和同比增长(与去年同月比)。

📖 原理解析

窗口函数 LAG(列, 偏移量, 默认值) 可以从当前行往回看 若干行;LEAD 则是向后看。配合 ORDER BY 按时间排序,就能轻松取到上月、去年同月的值。

与自连接相比,LAG/LEAD 不需要多次扫描表,性能更好,语法更简洁。

✍️ SQL 示例
SQL 复制代码
SELECT
    year_month,
    SUM(amount) AS total_amt,
    LAG(SUM(amount), 1) OVER (ORDER BY year_month) AS last_month_amt,
    LAG(SUM(amount), 12) OVER (ORDER BY year_month) AS last_year_same_month_amt,
    SUM(amount) - LAG(SUM(amount), 1) OVER (ORDER BY year_month) AS mom_diff,
    ROUND(
        (SUM(amount) - LAG(SUM(amount), 1) OVER (ORDER BY year_month)) 
        * 100.0 / NULLIF(LAG(SUM(amount), 1) OVER (ORDER BY year_month), 0),
        2
    ) AS mom_pct
FROM orders
GROUP BY year_month
ORDER BY year_month;
⚠️ 常见坑
  • 第一行(最早的月份)没有上一行LAG 返回 NULL,计算差值结果也是 NULL,这符合业务常识。
  • 注意除零错误:用 NULLIF(分母, 0) 避免。
  • 同比偏移量要根据实际数据跨度设置,这里是 12 个月。

1.4 分箱与分级(NTILE, PERCENT_RANK)------给产品打上"头部/腰部/尾部"标签

🧠 业务场景

运营想把产品按销售额分成 A、B、C 三级,或者分成 10 个等频段来观察分布。

📖 原理解析
  • NTILE(N) 把数据按 ORDER BY 排序后尽量均匀地切成 N 个桶,每桶行数大致相等。
  • PERCENT_RANK() 计算相对排名百分比,方便按比例划分(如前 5% 为 A 级)。
  • 这两种函数都依赖 ORDER BY,但不需指定窗口帧,因为它们本身就是排名类函数,会自行处理全分区。
✍️ SQL 示例
SQL 复制代码
SELECT
    product_id,
    amount,
    NTILE(10) OVER (ORDER BY amount DESC) AS decile,     -- 1~10,1是最高
    NTILE(4) OVER (ORDER BY amount DESC) AS quartile,
    CASE 
        WHEN PERCENT_RANK() OVER (ORDER BY amount DESC) <= 0.05 THEN 'A'
        WHEN PERCENT_RANK() OVER (ORDER BY amount DESC) <= 0.20 THEN 'B'
        ELSE 'C'
    END AS abc_class
FROM product_summary;

二、🧮 高级聚合与透视------把报表"掰弯"再"拉直"

2.1 条件聚合------一次分组算出多种指标,避免多次全表扫描

🧠 业务场景

你需要一张报表,按品类列出:

  • 销量在 0~500 的产品数量、销售额
  • 500~1000 的、1000 以上的......

如果用多个子查询分别算再 JOIN,会多次扫表。条件聚合用一个查询就能搞定。

📖 原理解析

在聚合函数(COUNTSUM 等)内部使用 CASE WHEN,让函数只处理符合条件的行,不符合的返回 NULLCOUNT 会忽略 NULL)或 0(对 SUM 无害)。这样就能在一次 GROUP BY 中同时产出多个分层指标。

✍️ SQL 示例
SQL 复制代码
SELECT
    category_code,
    category_name,
    COUNT(DISTINCT CASE WHEN ytd_qty < 500 THEN product_id END) AS cnt_0_500,
    COUNT(DISTINCT CASE WHEN ytd_qty BETWEEN 500 AND 1000 THEN product_id END) AS cnt_500_1000,
    COUNT(DISTINCT CASE WHEN ytd_qty > 1000 THEN product_id END) AS cnt_1000_plus,
    SUM(CASE WHEN ytd_qty < 500 THEN ytd_amt ELSE 0 END) AS amt_0_500,
    SUM(CASE WHEN ytd_qty BETWEEN 500 AND 1000 THEN ytd_amt ELSE 0 END) AS amt_500_1000,
    SUM(CASE WHEN ytd_qty > 1000 THEN ytd_amt ELSE 0 END) AS amt_1000_plus
FROM product_ytd
GROUP BY category_code, category_name;
⚠️ 常见坑
  • COUNT 里如果不加 DISTINCT,可能会在一个产品出现多次时重复计数(如果数据源已去重则问题不大,但加 DISTINCT 更安全)。
  • ELSE 0SUM 是必须的,否则 NULL 会导致整个 SUM 变成 NULL

2.2 行转列(Pivot)------把月份从行变成列,做成二维报表

🧠 业务场景

给老板看的 Excel 表喜欢这样的格式:一行一个产品,后面跟着 1月销售额、2月销售额...... 但数据库里是每月一行。我们需要行转列

📖 原理解析

通用方法是条件聚合 :用 MAX(CASE WHEN month='01' THEN amount END) 等把多行压缩成单行,GROUP BY 产品。

PostgreSQL / 华为云 DWS 还支持 crosstab 函数,能更灵活地动态生成列,但需要安装 tablefunc 扩展。

✍️ SQL 示例(条件聚合)
SQL 复制代码
SELECT
    product_id,
    MAX(CASE WHEN year_month = '202501' THEN amount END) AS m01,
    MAX(CASE WHEN year_month = '202502' THEN amount END) AS m02,
    MAX(CASE WHEN year_month = '202503' THEN amount END) AS m03,
    MAX(CASE WHEN year_month = '202504' THEN amount END) AS m04,
    MAX(CASE WHEN year_month = '202505' THEN amount END) AS m05
FROM orders
WHERE year_month BETWEEN '202501' AND '202505'
GROUP BY product_id;

💡 如果想要动态列(不固定月份),需用存储过程或在应用层拼 SQL,DWS 的 crosstab 可参考官方文档。

⚠️ 常见坑
  • 必须用聚合函数(MAX/SUM)包裹 CASE,因为 GROUP BY 后每列只能有一个值。
  • 如果一个产品在某个月份有多条记录,要先在外面汇总好,或者用 SUM 汇总。

2.3 列转行(Unpivot)------把宽表变回干净的长表

🧠 业务场景

有人给了你一张列是各月份的表(如 m01, m02...),你需要分析趋势,就必须先把列转回行。

📖 原理解析

可以用 UNION ALL 将每一列单独查出后摞起来,也可以用 UNNEST 同时拆解多个数组。

✍️ SQL 示例
SQL 复制代码
-- 方法1:UNION ALL
SELECT product_id, '202501' AS month, m01 AS amount FROM wide_table WHERE m01 IS NOT NULL
UNION ALL
SELECT product_id, '202502', m02 FROM wide_table WHERE m02 IS NOT NULL
...;

-- 方法2:UNNEST(更简洁)
SELECT product_id,
       unnest(ARRAY['202501','202502','202503']) AS month,
       unnest(ARRAY[m01, m02, m03]) AS amount
FROM wide_table;

三、🌳 递归 CTE------处理树形结构,BOM 展开的灵魂

🧠 业务场景

制造业的物料清单(BOM)是典型的多层树:一个成品由多个半成品组成,半成品又由原料组成...... 我们想要从某个成品出发,展示它所有的子物料及层级。

📖 原理解析

递归 CTE 分为两部分:

  1. 种子查询(锚点):不递归的部分,通常是顶层节点。
  2. 递归查询:引用 CTE 自身,每次迭代都找出下一层子节点。
  3. 使用 UNION ALL 连接两部分,需加终止条件(如限制层级)防止死循环。

华为云 DWS 使用 WITH RECURSIVE 语法。

✍️ SQL 示例
SQL 复制代码
WITH RECURSIVE bom_tree (parent_id, child_id, qty, lvl) AS (
    -- 锚点:成品
    SELECT parent_id, child_id, qty, 1
    FROM product_bom
    WHERE parent_id = 'FG-001'
    
    UNION ALL
    
    -- 递归:找下一层
    SELECT b.parent_id, b.child_id, b.qty, t.lvl + 1
    FROM product_bom b
    JOIN bom_tree t ON b.parent_id = t.child_id
    WHERE t.lvl < 10   -- 安全出口
)
SELECT * FROM bom_tree ORDER BY lvl, parent_id;
⚠️ 常见坑
  • 如果不加层级限制,数据中存在闭环时会无限递归,导致查询失败。
  • 递归部分的 JOIN 条件要写对:父物料 = 上一层查出的子物料。
  • 递归 CTE 内部不支持聚合和 DISTINCT,所以不能在里面直接做汇总。

四、🤝 高级 JOIN 技巧

4.1 不等值 JOIN------把数值匹配到区间(折扣档位)

🧠 业务场景

促销折扣按购买数量分档:0-100 件无折扣,100-500 件 5% 折扣,500 以上 8% 折扣。我们要给每条订单匹配到对应的折扣。

📖 原理解析

不能用等值连接,要用 >=< 组合成区间条件。

✍️ SQL 示例
SQL 复制代码
SELECT 
    o.order_id,
    o.quantity,
    d.discount_pct
FROM orders o
JOIN discount_tier d 
    ON o.quantity >= d.min_qty 
    AND o.quantity < d.max_qty;
⚠️ 常见坑
  • 区间必须设计得无缝覆盖,且无重叠,否则可能一行匹配多条。
  • 如果某数量没有匹配到折扣(低于最小门槛),需要使用 LEFT JOIN 并设置默认值。

4.2 自 JOIN------找出连续三个月销量下滑的产品

🧠 业务场景

库存管理需要警惕"连续下滑"的产品,及时调整采购。

📖 原理解析

如果数据库不支持窗口函数,可通过自 JOIN 将相邻月份的记录对齐。但有了 LAG 后,用窗口函数更优雅。我们展示两种方式。

✍️ SQL 示例(窗口函数法,推荐)
SQL 复制代码
WITH monthly AS (
    SELECT product_id, year_month, SUM(quantity) AS qty
    FROM orders
    GROUP BY product_id, year_month
),
lagged AS (
    SELECT *,
        LAG(qty, 1) OVER (PARTITION BY product_id ORDER BY year_month) AS prev_qty,
        LAG(qty, 2) OVER (PARTITION BY product_id ORDER BY year_month) AS prev2_qty
    FROM monthly
)
SELECT DISTINCT product_id
FROM lagged
WHERE qty < prev_qty AND prev_qty < prev2_qty;
自 JOIN 版本(兼容旧系统)
SQL 复制代码
SELECT a.product_id
FROM monthly a
JOIN monthly b ON a.product_id = b.product_id 
    AND b.year_month = to_char(to_date(a.year_month,'YYYYMM') - interval '1 month', 'YYYYMM')
JOIN monthly c ON a.product_id = c.product_id 
    AND c.year_month = to_char(to_date(a.year_month,'YYYYMM') - interval '2 month', 'YYYYMM')
WHERE a.qty < b.qty AND b.qty < c.qty;

五、⚡ 性能优化------不止让代码跑通,还要跑得快

5.1 SQL 执行顺序(必背)

Plain 复制代码
FROM → WHERE → GROUP BY → HAVING → SELECT → WINDOW → ORDER BY → LIMIT

核心影响

  • WHERE 看不见窗口函数的结果,因为窗口在 SELECT 阶段才计算。
  • GROUP BY 里不能直接写窗口函数。
  • 如果需要对窗口函数结果再过滤,必须用子查询。

5.2 常见性能陷阱与解法

  • 窗口函数+大表无索引 :确保 ORDER BYPARTITION BY 的列上有索引或分布键设计良好。
  • 条件聚合优于多次子查询 JOIN :一个 GROUP BY 搞定多个分层。
  • 避免在 ORDER BY 中使用非唯一字段导致窗口帧行为异常:加上主键或唯一业务键使排序稳定。

5.3 DWS 特殊建议

  • 利用分布键让需要关联或分区内计算的数据落在同一节点,减少数据重分布。
  • 对于超大表,按时间分区,查询时带上分区键可以裁剪大量数据。

六、🧪 完整实战:产品多维分层分析

需求:按商品类别,统计 YTD 销量在 0-500、500-1000 的产品数和销售额;同时按销售额从高到低,找出贡献前 5%、5%-10% 的产品数和销售额。

SQL 复制代码
WITH monthly_agg AS (
    -- 基础月度汇总
    SELECT
        year_month,
        category_code, category_name,
        product_id,
        SUM(quantity) AS qty,
        SUM(amount) AS amt
    FROM orders
    WHERE year_month BETWEEN '202501' AND '202505'
    GROUP BY year_month, category_code, category_name, product_id
),
ytd_agg AS (
    -- 产品YTD累计
    SELECT
        category_code, category_name,
        product_id,
        SUM(qty) AS ytd_qty,
        SUM(amt) AS ytd_amt
    FROM monthly_agg
    GROUP BY category_code, category_name, product_id
),
ranked AS (
    -- 计算累计金额占比
    SELECT
        *,
        -- 注意:为了防止相同金额导致累计占比异常,ORDER BY 加上 product_id 保证唯一
        SUM(ytd_amt) OVER (
            PARTITION BY category_code 
            ORDER BY ytd_amt DESC, product_id
        ) / NULLIF(SUM(ytd_amt) OVER (PARTITION BY category_code), 0) AS cum_pct
    FROM ytd_agg
)
SELECT
    category_code,
    category_name,
    -- 按销量分层
    COUNT(CASE WHEN ytd_qty < 500 THEN 1 END) AS cnt_qty_0_500,
    COUNT(CASE WHEN ytd_qty BETWEEN 500 AND 1000 THEN 1 END) AS cnt_qty_500_1000,
    SUM(CASE WHEN ytd_qty < 500 THEN ytd_amt ELSE 0 END) AS amt_qty_0_500,
    SUM(CASE WHEN ytd_qty BETWEEN 500 AND 1000 THEN ytd_amt ELSE 0 END) AS amt_qty_500_1000,
    
    -- 按销售额分层
    COUNT(CASE WHEN cum_pct <= 0.05 THEN 1 END) AS cnt_top5,
    COUNT(CASE WHEN cum_pct > 0.05 AND cum_pct <= 0.10 THEN 1 END) AS cnt_top5_10,
    SUM(CASE WHEN cum_pct <= 0.05 THEN ytd_amt ELSE 0 END) AS amt_top5,
    SUM(CASE WHEN cum_pct > 0.05 AND cum_pct <= 0.10 THEN ytd_amt ELSE 0 END) AS amt_top5_10
FROM ranked
GROUP BY category_code, category_name;

这段 SQL 演示了前面几乎所有知识点的组合:条件聚合、累计窗口、分层统计。

你可以直接拿到 DWS 里跑,只需根据实际表名和字段微调。

相关推荐
林夕074 小时前
Qt QML与C++混合编程实战指南
java·开发语言·数据库
phltxy5 小时前
Redis 缓存
数据库·redis·缓存
雪度娃娃5 小时前
行为型设计模式——备忘录模式
服务器·c++·设计模式·备忘录模式
lljss20205 小时前
Arm GNU 工具链 命名规则
服务器·arm开发·gnu
小此方5 小时前
Re:Linux系统篇(十七)进程篇·二:深入浅出 [进程概念与进程父子关系]:从底层原理到实战应用
linux·运维·驱动开发
KnowSafe5 小时前
如何用OpenSSL生成CSR文件?
服务器·https·ssl
hjjdebug5 小时前
ubuntu系统 usbmouse 驱动代码分析
linux·ubuntu·usbmouse
认真的薛薛5 小时前
Linux运维:Jenkins+Argocd
linux·运维·jenkins
顾凌陵5 小时前
SQL注入漏洞
数据库·sql·oracle