什么是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(账户表)生成一份年度客户财务健康度报告。报告需要包含以下指标,按账户和年份分组:
- 年度交易总额:每个账户每年的总交易金额。
- 年度交易次数:每个账户每年的交易笔数。
- 年末账户余额:每年最后一笔交易后的账户余额。
- 年度余额峰值:每年内账户达到的最高余额。
- 年度余额谷值:每年内账户达到的最低余额。
- 余额波动率 :
(年度余额峰值 - 年度余额谷值) / 年末账户余额,用于衡量账户的稳定性。 - 客户财富排名 :根据年末账户余额,对所有客户在同一年的财富水平进行排名(百分位)。
- 年度交易额环比增长率:计算本年度交易总额相对于上一年度的增长率(%)。
数据准备与思路
我们将主要使用 Kdd99trans 表,因为它包含了交易日期、金额和余额。Kdd99accounts 表可以用来关联账户信息,但在这个场景中,Kdd99trans 表已经足够。
核心思路:
- 按账户和年份分组 :使用
GROUP BY accountid, YEAR(date)来创建年度聚合的中间数据。 - 计算基础指标 :在第一步的基础上,使用
SUM(amount)计算年度交易总额,COUNT(*)计算交易次数,MAX(balance)计算余额峰值,MIN(balance)计算余额谷值。 - 获取年末余额 :这是一个关键点。我们不能简单地用
MAX(balance),因为余额最高的交易不一定发生在年末。我们需要找到每年最后一天的交易记录。这可以通过窗口函数ROW_NUMBER()来实现。 - 应用窗口函数 :
- 财富排名 :使用
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 rnPARTITION 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来解决。