CTE更易懂的SQL风格

什么是CTE?

CTE (Common Table Expressions)公用表表达式 是一个临时的、命名的结果集,它存在于单个 SQL 语句(SELECT, INSERT, UPDATE, DELETE)的执行范围内。你可以把它看作是一个只在当前查询中有效的、一次性的"视图"或"临时表"。

CTE 使用 WITH 子句来定义,并且可以像普通的表一样在后续的查询中被引用。这使得复杂查询的逻辑可以被分解成多个简单、可读的步骤。

为什么使用CTE?(核心优势)

CTE 的出现主要是为了解决传统 SQL 查询中的一些痛点,尤其是在处理复杂逻辑时。它的核心优势体现在以下几个方面:

提升可读性

这是 CTE 最显著的优势。一个包含多层嵌套子查询的 SQL 语句,就像一个"俄罗斯套娃",阅读和维护起来非常困难。CTE 将这个复杂的查询拆解成一系列逻辑清晰的、有意义的步骤,简单理解就是将层层嵌套改为了扁平化的结构。
对比一下:

  • 传统嵌套子查询(难以阅读):

    sql 复制代码
    -- 查询1998年以来,平均交易金额大于10000的账户信息
    SELECT
        a.accountid,
        a.districtid,
        t.avg_amount
    FROM
        kdd99accounts a
    JOIN
        (
            SELECT
                accountid,
                AVG(amount) AS avg_amount
            FROM
                kdd99trans
            WHERE
                date > '1998-01-01'
            GROUP BY
                accountid
            HAVING
                AVG(amount) > 10000
        ) t ON a.accountid = t.accountid;
  • 使用CTE(逻辑清晰):

    sql 复制代码
    -- 步骤1:先找出所有在1998年后平均交易额超过10000的账户
    WITH HighValueAccounts AS (
        SELECT
            accountid,
            AVG(amount) AS avg_amount
        FROM
            kdd99trans
        WHERE
            date > '1998-01-01'
        GROUP BY
            accountid
        HAVING
            AVG(amount) > 10000
    )
    -- 步骤2:将这些高价值账户与账户主表关联起来
    SELECT
        a.accountid,
        a.districtid,
        h.avg_amount
    FROM
        kdd99accounts a
    JOIN
        HighValueAccounts h ON a.accountid = h.accountid;

    在 CTE 的版本中,代码的意图一目了然:先定义什么是"高价值账户",然后再去查询这些账户的详细信息。

代码复用与模块化

在一个复杂的查询中,你可能需要多次使用同一个子查询的结果。如果使用嵌套子查询,你就需要把这个子查询重复写好几遍,这不仅冗余,而且一旦需要修改,就要在多个地方进行修改,容易出错。

CTE 允许你将这个子查询定义一次,然后在主查询中多次引用它。
示例:

假设我们想找出交易额最高的账户,并计算其交易额占总交易额的百分比。

sql 复制代码
WITH TotalTransactionAmount AS (
    -- 定义一次:计算所有交易的总金额
    SELECT SUM(amount) AS total_sum FROM kdd99trans
),
AccountTransactionAmount AS (
    -- 定义一次:计算每个账户的交易总金额
    SELECT
        accountid,
        SUM(amount) AS account_sum
    FROM
        kdd99trans
    GROUP BY
        accountid
)
-- 在主查询中,可以多次引用上面定义的CTE
SELECT
    a.accountid,
    ata.account_sum,
    (ata.account_sum * 100.0 / tta.total_sum) AS percentage_of_total
FROM
    AccountTransactionAmount ata,
    TotalTransactionAmount tta
ORDER BY
    percentage_of_total DESC
LIMIT 1;

替代视图

有时你需要创建一个复杂的视图,但这个视图可能只会在一个查询中使用一次。为了数据库的整洁性,你不想创建一个永久性的数据库对象。CTE 就是完美的替代方案,它提供了视图的便利性,但作用域仅限于当前查询,用完即弃,不会在数据库中留下任何痕迹。

实现递归查询

这是 CTE 一个非常强大且独特的功能。标准 CTE 是非递归的,但通过添加 RECURSIVE 关键字,CTE 可以引用自身,从而实现对层次结构或图状数据的遍历,例如:

  • 查询公司的组织架构图(员工 -> 经理 -> 总裁)。
  • 查询物料清单的层级关系(产品 -> 部件 -> 子部件)。
  • 查找社交网络中的朋友关系链。
    这是传统 SQL 难以实现或实现起来非常复杂的任务。递归 CTE 由两部分组成:一个锚点成员(返回初始结果集)和一个递归成员(重复执行,直到不再返回新行)。
    简单递归示例(生成1到5的数字序列):
sql 复制代码
WITH RECURSIVE numbers(n) AS (
    -- 1. 锚点成员:从1开始
    SELECT 1
    UNION ALL
    -- 2. 递归成员:将n加1,并与之前的numbers表连接
    SELECT n + 1 FROM numbers WHERE n < 5
)
-- 3. 最终查询:从递归CTE中获取结果
SELECT n FROM numbers;

结果:

复制代码
n
---
1
2
3
4
5

CTE 的基本语法

sql 复制代码
WITH cte_name (column1, column2, ...) AS (
    -- CTE的查询定义
    SELECT column_a, column_b
    FROM some_table
    WHERE condition
)
-- 主查询,可以引用上面定义的CTE
SELECT
    column1,
    column2,
    ...
FROM
    cte_name
JOIN
    another_table ON ...;
  • WITH: 关键字,表示开始定义一个或多个CTE。
  • cte_name: 你为这个临时结果集起的名字。
  • (column1, column2, ...): (可选)为CTE的输出列指定别名。如果省略,则会使用内部查询的列名。
  • AS (...): 定义CTE的查询语句。
  • 多个CTE: 如果需要,可以定义多个CTE,它们之间用逗号分隔,并且后定义的CTE可以引用先定义的CTE。

CTE 与派生表(子查询)的对比

特性 CTE (公用表表达式) 派生表 (子查询)
可读性 。逻辑分步,结构清晰。 。嵌套时难以阅读和维护。
可复用性 。可在同一查询中被多次引用。 。只能被引用一次,无法复用。
作用域 在同一语句中,定义后即可引用。 仅在它被定义的那个FROM子句中有效。
递归 支持 。通过RECURSIVE关键字实现。 不支持
性能 通常与派生表性能相当,因为优化器通常会将它们处理成相同的执行计划。在某些情况下,CTE能让优化器生成更好的计划。 性能通常与CTE相当。

CTE 是一个用于组织和简化复杂 SQL 查询的强大工具。它通过将查询分解为逻辑清晰的、可复用的步骤,极大地提升了代码的可读性和可维护性。虽然它本身不一定会带来性能上的巨大提升,但它让开发者能够写出更清晰、更易于优化的 SQL 代码。特别是其递归查询的能力,解决了传统 SQL 在处理层次数据时的难题。

案例

需求背景

银行的风险控制部门希望对客户的年度财务行为进行一次全面分析,以识别潜在的风险客户和优质客户。他们不仅仅关心客户的总交易额,更关心客户的行为模式,例如:

  • 客户稳定性:客户的账户余额是稳定增长、剧烈波动还是持续下降?
  • 客户活跃度:客户是否频繁进行交易?
  • 客户价值:客户在所有客户中的财富水平处于什么位置?
  • 年度趋势 :客户的交易频率和金额相比上一年是增长了还是下降了?
    传统的GROUP BY查询可以告诉我们每个账户的年度总交易额,但无法告诉我们账户余额在一年内的变化趋势,也无法将单个账户的表现与所有账户进行比较。这正是窗口函数大显身手的地方。

具体分析需求

我们需要基于 Kdd99trans(交易表)和 Kdd99accounts(账户表)生成一份年度客户财务健康度报告。报告需要包含以下指标,按账户和年份分组

  1. 年度交易总额:每个账户每年的总交易金额。
  2. 年度交易次数:每个账户每年的交易笔数。
  3. 年末账户余额:每年最后一笔交易后的账户余额。
  4. 年度余额峰值:每年内账户达到的最高余额。
  5. 年度余额谷值:每年内账户达到的最低余额。
  6. 余额波动率(年度余额峰值 - 年度余额谷值) / 年末账户余额,用于衡量账户的稳定性。
  7. 客户财富排名 :根据年末账户余额,对所有客户在同一年的财富水平进行排名(百分位)。
  8. 年度交易额环比增长率:计算本年度交易总额相对于上一年度的增长率(%)。

数据准备与思路

我们将主要使用 Kdd99trans 表,因为它包含了交易日期、金额和余额。Kdd99accounts 表可以用来关联账户信息,但在这个场景中,Kdd99trans 表已经足够。
核心思路:

  1. 按账户和年份分组 :使用 GROUP BY accountid, YEAR(date) 来创建年度聚合的中间数据。
  2. 计算基础指标 :在第一步的基础上,使用 SUM(amount) 计算年度交易总额,COUNT(*) 计算交易次数,MAX(balance) 计算余额峰值,MIN(balance) 计算余额谷值。
  3. 获取年末余额 :这是一个关键点。我们不能简单地用 MAX(balance),因为余额最高的交易不一定发生在年末。我们需要找到每年最后一天的交易记录。这可以通过窗口函数 ROW_NUMBER() 来实现。
  4. 应用窗口函数
    • 财富排名 :使用 PERCENT_RANK() 窗口函数,按年份 (PARTITION BY YEAR(date)) 进行分区,根据年末余额 (ORDER BY year_end_balance DESC) 进行排序。
    • 环比增长率 :使用 LAG() 窗口函数,按账户 (PARTITION BY accountid) 进行分区,按年份 (ORDER BY year) 排序,获取上一年的交易总额,然后计算增长率。

代码实现

我们将使用 Common Table Expressions (CTEs) 来分步构建查询,使逻辑更清晰。

sql 复制代码
-- 步骤1: 计算每个账户每年的基础聚合指标
WITH YearlyAccountStats AS (
    SELECT
        accountid,
        YEAR(date) AS transaction_year,
        SUM(amount) AS total_yearly_amount,
        COUNT(*) AS transaction_count,
        MAX(balance) AS peak_balance,
        MIN(balance) AS min_balance
    FROM
        Kdd99trans
    GROUP BY
        accountid,
        YEAR(date)
),
-- 步骤2: 获取每个账户每年的年末余额
YearEndBalances AS (
    SELECT
        accountid,
        YEAR(date) AS transaction_year,
        balance AS year_end_balance
    FROM (
        SELECT
            accountid,
            date,
            balance,
            ROW_NUMBER() OVER(PARTITION BY accountid, YEAR(date) ORDER BY date DESC) as rn
        FROM
            Kdd99trans
    ) ranked
    WHERE rn = 1 -- 只保留每年最后一条记录
),
-- 步骤3: 合并前两个步骤的结果,并计算财富排名和环比增长率
FinalReport AS (
    SELECT
        yas.accountid,
        yas.transaction_year,
        yas.total_yearly_amount,
        yas.transaction_count,
        yeb.year_end_balance,
        yas.peak_balance,
        yas.min_balance,
        -- 计算余额波动率
        CASE 
            WHEN yeb.year_end_balance = 0 THEN NULL -- 避免除以零
            ELSE (yas.peak_balance - yas.min_balance) * 1.0 / yeb.year_end_balance 
        END AS balance_volatility,
        -- 计算财富排名 (百分位)
        PERCENT_RANK() OVER(PARTITION BY yas.transaction_year ORDER BY yeb.year_end_balance DESC) AS wealth_percentile,
        -- 计算环比增长率
        (yas.total_yearly_amount - LAG(yas.total_yearly_amount, 1, 0) OVER(PARTITION BY yas.accountid ORDER BY yas.transaction_year)) * 100.0 /
        NULLIF(LAG(yas.total_yearly_amount, 1, 0) OVER(PARTITION BY yas.accountid ORDER BY yas.transaction_year), 0) AS yoy_growth_rate
    FROM
        YearlyAccountStats yas
    JOIN
        YearEndBalances yeb ON yas.accountid = yeb.accountid AND yas.transaction_year = yeb.transaction_year
)
-- 最终查询:展示结果
SELECT
    accountid,
    transaction_year,
    ROUND(total_yearly_amount, 2) AS total_yearly_amount,
    transaction_count,
    ROUND(year_end_balance, 2) AS year_end_balance,
    ROUND(peak_balance, 2) AS peak_balance,
    ROUND(min_balance, 2) AS min_balance,
    ROUND(balance_volatility, 4) AS balance_volatility,
    ROUND(wealth_percentile * 100, 2) AS wealth_percentile_rank, -- 转换为百分比
    ROUND(yoy_growth_rate, 2) AS yoy_growth_rate_percent
FROM
    FinalReport
ORDER BY
    accountid,
    transaction_year;

代码解释

CTE 1: YearlyAccountStats

这是一个常规的聚合查询,为每个账户的每一年计算出最基础的统计信息:总交易额、交易次数、余额峰值和谷值。这是后续分析的数据基础。

CTE 2: YearEndBalances

这是第一个使用窗口函数的地方。

  • 内层查询 : ROW_NUMBER() OVER(PARTITION BY accountid, YEAR(date) ORDER BY date DESC) as rn
    • PARTITION BY accountid, YEAR(date): 这是窗口函数的核心。它告诉数据库将数据分成多个窗口",每个窗口是特定账户在特定年份的所有交易记录。
    • ORDER BY date DESC: 在每个窗口内部,按交易日期降序排列。
    • ROW_NUMBER(): 为每个窗口内的行分配一个序号,从1开始。由于是按日期降序,每年的最后一笔交易将获得 rn = 1
  • 外层查询 : WHERE rn = 1 简单地筛选出每个窗口的第一行,也就是我们需要的年末余额记录。

CTE 3: FinalReport

这是整个分析的核心,将所有指标整合在一起。

  • JOIN: 将基础统计数据和年末余额数据通过账户ID和年份关联起来。
  • balance_volatility : 使用简单的算术计算波动率,并用 CASE WHEN 处理了除以零的错误情况。
  • wealth_percentile : PERCENT_RANK() OVER(...)
    • PARTITION BY transaction_year: 按年份创建窗口。这意味着排名是每年独立计算的。1993年的客户只和1993年的其他客户比。
    • ORDER BY year_end_balance DESC: 在每年的窗口内,按年末余额从高到低排序。
    • PERCENT_RANK(): 返回一个介于0和1之间的值,表示该行在其分区中的百分位排名。余额最高的客户排名为0,最低的为1(或接近1)。
  • yoy_growth_rate : LAG(...) OVER(...)
    • PARTITION BY accountid: 按账户ID创建窗口。这意味着我们只在同一个账户的历史记录中进行查找。
    • ORDER BY transaction_year: 在每个账户的窗口内,按年份升序排列。
    • LAG(total_yearly_amount, 1, 0): 这是关键。LAG() 函数可以获取当前行之前 某一行的数据。total_yearly_amount 是要获取的列,1 表示往前偏移1行(即上一年的数据),0 是默认值,如果找不到上一年的数据(比如该账户第一年),则用0代替。
    • 外层的算术运算就是标准的环比增长率公式:(本期 - 上期) / 上期。我们使用 NULLIF(..., 0) 来避免上期为0时的除以零错误。

最终查询

最终的查询结果将为每个账户的每一年生成一行记录,包含了所有需求的指标。业务分析师可以基于这份报告轻松地识别出:

  • 高价值稳定客户 : wealth_percentile_rank 很高,balance_volatility 很低,yoy_growth_rate 稳定为正。
  • 潜在风险客户 : balance_volatility 剧烈上升,year_end_balance 持续下降,yoy_growth_rate 为负。
  • 新兴潜力客户 : wealth_percentile_rank 中等但 yoy_growth_rate 非常高。
    在这个案例中完美地展示了窗口函数如何将复杂的、需要多次自连接或子查询才能解决的问题,用一种更高效、更易读、更优雅的方式CTES来解决。
相关推荐
kaico20188 小时前
MySQL的索引
数据库·mysql
清水白石0089 小时前
解构异步编程的两种哲学:从 asyncio 到 Trio,理解 Nursery 的魔力
运维·服务器·数据库·python
资生算法程序员_畅想家_剑魔9 小时前
Mysql常见报错解决分享-01-Invalid escape character in string.
数据库·mysql
PyHaVolask9 小时前
SQL注入漏洞原理
数据库·sql
ptc学习者9 小时前
黑格尔时代后崩解的辩证法
数据库
代码游侠9 小时前
应用——智能配电箱监控系统
linux·服务器·数据库·笔记·算法·sqlite
!chen10 小时前
EF Core自定义映射PostgreSQL原生函数
数据库·postgresql
霖霖总总10 小时前
[小技巧14]MySQL 8.0 系统变量设置全解析:SET GLOBAL、SET PERSIST 与 SET PERSIST_ONLY 的区别与应用
数据库·mysql
马克学长10 小时前
SSM校园食堂订餐系统531p9(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面
数据库·ssm 框架·ssm 校园食堂订餐系统