当前位置: 首页 > news >正文

SQL中的CTE(公用表表达式)完全指南:从入门到精通

在SQL的世界里,有一个功能经常被初学者忽视,但却是高手们最爱用的工具,那就是CTE(Common Table Expression,公用表表达式)。今天我们就来深入探讨一下这个强大的功能,看看它为什么能让复杂的SQL查询变得清晰易懂。

什么是CTE?为什么要用它?

CTE说白了就是一个临时的、有名字的查询结果集。你可以把它想象成一个只在当前查询中存在的"临时视图"。一旦查询执行完毕,这个CTE就消失了,不会在数据库中留下任何痕迹。

可能有人会问,我用子查询不也能达到同样的效果吗?确实,从功能上来说,CTE和子查询很多时候是可以互换的。但是CTE的优势在于它让代码更容易阅读、理解和维护。想象一下,如果你有一个嵌套了三四层的子查询,读起来会有多痛苦?而用CTE,你可以把这个复杂的查询分解成几个清晰的步骤,每个步骤都有一个有意义的名字。

让我先给你看一个最简单的CTE例子:

WITH customer_totals AS (SELECT CustomerID,SUM(Amount) AS total_salesFROM OrdersGROUP BY CustomerID
)
SELECT *
FROM customer_totals
WHERE total_sales > 10000;

在这个例子中,我们先用CTE定义了一个叫customer_totals的临时结果集,它计算了每个客户的总销售额。然后在主查询中,我们就可以像使用普通表一样使用这个CTE,筛选出销售额超过10000的客户。

CTE的基本语法

CTE的语法其实很简单,就是用WITH关键字开头,后面跟着CTE的名字,然后用AS,最后在括号里写查询语句:

WITH cte_name AS (SELECT column1, column2FROM table_nameWHERE condition
)
SELECT *
FROM cte_name;

如果你需要定义多个CTE,用逗号分隔就可以了:

WITH first_cte AS (SELECT ...),second_cte AS (SELECT ...),third_cte AS (SELECT ...)
SELECT * FROM third_cte;

这里有一个重要的规则:后面定义的CTE可以引用前面定义的CTE,但反过来不行。这就像你在写程序时,后面的代码可以使用前面定义的变量,但前面的代码不能使用后面才定义的变量。

实战案例:订单支付分析

现在让我们看一个真实的业务场景。假设我们有一个电商系统,有订单表(Orders)和支付表(Payments)。一个订单可能会有多次支付记录(比如用户分多次付清一个订单),我们需要统计每个客户的总订单金额和未付清的订单数量。

这个问题听起来简单,但实现起来有几个难点:

第一,一个订单可能对应多条支付记录,我们需要先把这些支付记录汇总,算出每个订单一共付了多少钱。

第二,有些订单可能根本没有支付记录,我们需要把这些订单也考虑进去,把它们的已付金额当作0。

第三,我们要判断一个订单是否"未付清",就是比较总支付金额是否小于订单金额。

第四,最后还要按客户汇总,算出每个客户的总订单金额和未付清订单数。

让我们看看数据结构:

CREATE TABLE Orders (OID INT PRIMARY KEY,CustomerID INT NOT NULL,Amount DECIMAL(10,2) NOT NULL
);CREATE TABLE Payments (OID INT NOT NULL,Paid DECIMAL(10,2) NOT NULL
);

然后插入一些测试数据:

INSERT INTO Orders (OID, CustomerID, Amount) VALUES(100, 1, 120.00),  -- 客户1的订单,需要付120元(101, 1,  80.00),  -- 客户1的另一个订单,需要付80元(102, 2, 200.00),  -- 客户2的订单(103, 2,  50.00),  -- 客户2的另一个订单(104, 3,  75.00);  -- 客户3的订单,完全没付款INSERT INTO Payments (OID, Paid) VALUES(100, 70.00),      -- 订单100第一次付款70元(100, 50.00),      -- 订单100第二次付款50元,总共付清了120元(101, 20.00),      -- 订单101只付了20元,还差60元(102, 200.00),     -- 订单102付清了(103, 25.00);      -- 订单103只付了25元,还差25元
-- 注意订单104没有任何支付记录

方案一:使用CTE解决

使用CTE,我们可以把这个复杂的问题分解成三个清晰的步骤:

WITH pay AS (-- 第一步:把每个订单的多次支付汇总成一个总金额SELECT OID, SUM(Paid) AS paid_totalFROM PaymentsGROUP BY OID
),
per_order AS (-- 第二步:把订单表和支付汇总表连接起来,标记每个订单是否未付清SELECTo.CustomerID,o.OID,o.Amount,COALESCE(p.paid_total, 0) AS paid_total,CASE WHEN COALESCE(p.paid_total, 0) < o.Amount THEN 1 ELSE 0 END AS is_unpaidFROM Orders oLEFT JOIN pay p ON p.OID = o.OID
)
-- 第三步:按客户汇总
SELECTCustomerID,SUM(Amount) AS total_order_amount,SUM(is_unpaid) AS num_unpaid_orders
FROM per_order
GROUP BY CustomerID
ORDER BY CustomerID;

让我们一步一步分析这个查询:

第一个CTE叫pay,它的作用是把支付表中的多条记录汇总。比如订单100有两条支付记录(70元和50元),这个CTE会把它们加起来,得到120元。执行完这一步后,pay的结果会是这样:

OID | paid_total
----|------------
100 | 120.00
101 | 20.00
102 | 200.00
103 | 25.00

注意订单104不在这个结果里,因为它根本没有支付记录。

第二个CTE叫per_order,它把订单表和刚才的pay表用LEFT JOIN连接起来。LEFT JOIN很重要,因为它保证了即使一个订单没有支付记录,这个订单也会出现在结果里,只不过支付金额会是NULL。我们用COALESCE(p.paid_total, 0)把NULL转换成0,这样方便后续计算。然后用CASE语句判断这个订单是否未付清:如果已付金额小于订单金额,is_unpaid就是1,否则是0。这个CTE的结果会是:

CustomerID | OID | Amount  | paid_total | is_unpaid
-----------|-----|---------|------------|----------
1          | 100 | 120.00  | 120.00     | 0
1          | 101 | 80.00   | 20.00      | 1
2          | 102 | 200.00  | 200.00     | 0
2          | 103 | 50.00   | 25.00      | 1
3          | 104 | 75.00   | 0.00       | 1

最后一步就简单了,我们按CustomerID分组,把每个客户的订单金额加起来,把is_unpaid标记加起来就得到了未付清订单的数量。最终结果是:

CustomerID | total_order_amount | num_unpaid_orders
-----------|-------------------|------------------
1          | 200.00            | 1
2          | 250.00            | 1
3          | 75.00             | 1

客户1有两个订单,总共200元,其中订单101未付清。客户2也有两个订单,总共250元,订单103未付清。客户3有一个订单75元,完全没付款。

方案二:使用子查询解决

同样的问题,我们也可以用子查询来解决。不过你马上就会发现,代码的可读性会差很多:

SELECTo.CustomerID,SUM(o.Amount) AS total_order_amount,SUM(CASEWHEN (SELECT COALESCE(SUM(p.Paid), 0)FROM Payments pWHERE p.OID = o.OID) < o.Amount THEN 1 ELSE 0END) AS num_unpaid_orders
FROM Orders o
GROUP BY o.CustomerID
ORDER BY o.CustomerID;

这个查询在功能上和CTE方案完全等价,但你看出区别了吗?子查询方案把所有的逻辑都塞在了一个SELECT语句里。那个嵌套在CASE语句中的子查询,需要仔细读好几遍才能理解它在干什么。而且这个子查询是一个关联子查询,对于Orders表的每一行,它都要执行一次,这在某些情况下可能会影响性能。

我们还可以用另一种子查询的写法,在FROM子句中使用派生表:

SELECTo.CustomerID,SUM(o.Amount) AS total_order_amount,SUM(CASE WHEN COALESCE(p.paid_total, 0) < o.Amount THEN 1 ELSE 0 END) AS num_unpaid_orders
FROM Orders o
LEFT JOIN (SELECT OID, SUM(Paid) AS paid_totalFROM PaymentsGROUP BY OID
) p ON p.OID = o.OID
GROUP BY o.CustomerID
ORDER BY o.CustomerID;

这个版本比关联子查询好一些,至少把支付汇总的逻辑单独提取出来了。但问题是,这个派生表没有名字(或者说它的名字只是一个别名p),如果你的查询更复杂,有多个派生表,很容易搞混它们各自的作用。

对比一下CTE版本,每个步骤都有一个清晰的名字:pay表示支付汇总,per_order表示每个订单的详细信息。当你几个月后回来看这段代码时,CTE版本会让你立刻明白每一步在做什么。

多个CTE的链式使用

CTE的强大之处在于你可以定义多个CTE,后面的CTE可以引用前面的CTE。这就像搭积木一样,一步一步构建出复杂的查询逻辑。

让我们看另一个例子:课程先修课程检查。假设有一个选课系统,某些课程有先修课程要求。我们需要找出那些选了课但是没有完成先修课程的学生。

数据结构是这样的:

CREATE TABLE Enrollments (StudentID INT NOT NULL,CourseID  VARCHAR(16) NOT NULL,Semester  CHAR(6) NOT NULL
);CREATE TABLE Prereqs (CourseID    VARCHAR(16) NOT NULL,ReqCourseID VARCHAR(16) NOT NULL
);

Enrollments表记录了学生的选课信息,包括学生ID、课程ID和学期。Prereqs表定义了先修课程关系,比如DATA226需要先完成DATA201和DATA200。

测试数据:

INSERT INTO Prereqs (CourseID, ReqCourseID) VALUES('DATA226','DATA201'),('DATA226','DATA200'),('DATA300','DATA226');-- 学生1:选了DATA226(2025A学期),但只完成了DATA201,没完成DATA200
INSERT INTO Enrollments VALUES(1,'DATA201','2024A'),(1,'DATA201','2024B'),   -- 重修(1,'DATA226','2025A');-- 学生2:选了DATA226,两门先修课都完成了
INSERT INTO Enrollments VALUES(2,'DATA200','2024A'),(2,'DATA201','2024B'),(2,'DATA226','2025A');-- 学生3:选了DATA226,但没有任何先修课程记录
INSERT INTO Enrollments VALUES(3,'DATA226','2025A');

CTE方案:逐步构建逻辑

WITH current AS (-- 第一步:去重,得到当前所有选课记录SELECT DISTINCT StudentID, CourseID, SemesterFROM Enrollments
),
needs AS (-- 第二步:找出每个选课需要哪些先修课程SELECT c.StudentID, c.CourseID, c.Semester, r.ReqCourseIDFROM current cJOIN Prereqs r ON r.CourseID = c.CourseID
),
prior AS (-- 第三步:去重,得到所有历史完成的课程SELECT DISTINCT StudentID, CourseID, SemesterFROM Enrollments
),
joined AS (-- 第四步:检查每个先修课程要求是否满足SELECTn.StudentID, n.CourseID, n.Semester,n.ReqCourseID,MAX(CASE WHEN p.StudentID IS NOT NULL THEN 1 ELSE 0 END) AS has_priorFROM needs nLEFT JOIN prior pON p.StudentID = n.StudentIDAND p.CourseID = n.ReqCourseIDAND p.Semester < n.SemesterGROUP BY n.StudentID, n.CourseID, n.Semester, n.ReqCourseID
)
-- 第五步:找出有任何一个先修课程未完成的选课记录
SELECT DISTINCT StudentID, CourseID, Semester
FROM joined
GROUP BY StudentID, CourseID, Semester
HAVING SUM(CASE WHEN has_prior = 0 THEN 1 ELSE 0 END) > 0;

这个查询看起来比较长,但每一步都很清晰:

第一步current把选课记录去重,因为可能有重复数据。

第二步needs把每个学生的当前选课和它所需的先修课程连接起来。比如学生1选了DATA226,这一步会产生两条记录:学生1需要DATA201,学生1需要DATA200。

第三步prior得到所有历史完成的课程记录。

第四步joined是关键,它用LEFT JOIN检查每个先修课程要求是否被满足。如果学生在之前的学期完成了这门先修课程,p.StudentID就不是NULL,has_prior就是1;否则是0。

最后一步找出那些至少有一个先修课程未完成的记录(SUM(CASE WHEN has_prior = 0 ...)。

子查询方案:双重NOT EXISTS

同样的逻辑,用子查询可以这样写:

SELECT DISTINCT e.StudentID, e.CourseID, e.Semester
FROM Enrollments e
WHERE EXISTS (SELECT 1FROM Prereqs rWHERE r.CourseID = e.CourseIDAND NOT EXISTS (SELECT 1FROM Enrollments priorWHERE prior.StudentID = e.StudentIDAND prior.CourseID = r.ReqCourseIDAND prior.Semester < e.Semester)
);

这个查询用了双重嵌套的EXISTS子查询。外层的EXISTS检查这门课是否有先修课程要求,内层的NOT EXISTS检查学生是否完成了该先修课程。如果有任何一个先修课程没完成,NOT EXISTS就为真,整个条件就满足。

从逻辑上说,这个子查询版本其实更简洁,也可能性能更好。但问题是它的可读性较差,特别是双重嵌套的EXISTS让人需要花点时间理解。而CTE版本虽然代码行数更多,但每一步都清清楚楚,就像在讲一个故事。

递归CTE:处理层级数据

CTE还有一个杀手级功能,那就是递归查询。在处理树形结构或者层级数据时,递归CTE简直是救命稻草。

什么是递归CTE?简单说就是一个CTE在定义时引用了自己。这听起来有点绕,但实际上很有用。比如你有一个员工表,每个员工都有一个经理ID,你想要查询整个公司的组织架构,从CEO开始一层一层往下,这时候递归CTE就派上用场了。

递归CTE的语法是这样的:

WITH RECURSIVE cte_name AS (-- 基础查询(锚点成员)SELECT ...WHERE base_conditionUNION ALL-- 递归查询(递归成员)SELECT ...FROM cte_nameWHERE recursive_condition
)
SELECT * FROM cte_name;

让我们看一个实际例子。假设有这样的员工表:

CREATE TABLE Employees (EmpID INT PRIMARY KEY,Name VARCHAR(50),ManagerID INT
);INSERT INTO Employees VALUES
(1, 'Nia', NULL),      -- CEO,没有经理
(2, 'Omar', 1),        -- 向Nia汇报
(3, 'Priya', 1),       -- 向Nia汇报
(4, 'Quinn', 2),       -- 向Omar汇报
(5, 'Rui', 2);         -- 向Omar汇报

现在我们想要查询整个管理层级,标记每个员工在第几层:

WITH RECURSIVE ManagementHierarchy AS (-- 基础情况:找到最顶层的管理者(没有经理的人)SELECT EmpID,Name,ManagerID,0 AS LevelFROM EmployeesWHERE ManagerID IS NULLUNION ALL-- 递归情况:找到下一层的员工SELECT e.EmpID,e.Name,e.ManagerID,mh.Level + 1FROM Employees eJOIN ManagementHierarchy mh ON e.ManagerID = mh.EmpID
)
SELECT EmpID,Name,Level,CASE WHEN Level = 0 THEN 'CEO'WHEN Level = 1 THEN 'Manager'ELSE 'Employee'END AS Position
FROM ManagementHierarchy
ORDER BY Level, EmpID;

这个查询是怎么工作的呢?

首先执行基础查询,找到ManagerID为NULL的员工,也就是Nia,她的Level是0。

然后开始递归。第一次递归,从Employees表中找那些ManagerID等于Nia的EmpID的员工,也就是Omar和Priya,他们的Level是0+1=1。

第二次递归,找那些经理是Omar或Priya的员工,也就是Quinn和Rui,他们的Level是1+1=2。

第三次递归,没有找到任何员工的经理是Quinn或Rui,递归结束。

最后把所有层级的结果用UNION ALL合并起来,就得到了完整的组织架构:

EmpID | Name  | Level | Position
------|-------|-------|----------
1     | Nia   | 0     | CEO
2     | Omar  | 1     | Manager
3     | Priya | 1     | Manager
4     | Quinn | 2     | Employee
5     | Rui   | 2     | Employee

递归CTE在处理这种层级数据时真的非常优雅。如果不用递归,你可能需要写多次自连接,而且只能查询固定的层数。用递归CTE,无论你的组织架构有多少层,一个查询就搞定了。

CTE vs 子查询 vs 视图:该用哪个?

到这里你可能会问:既然子查询也能完成同样的功能,我什么时候该用CTE,什么时候该用子查询呢?还有,CTE和视图(VIEW)又有什么区别?

让我给你一些实用的建议:

当你的查询逻辑比较复杂,需要多个步骤来完成,用CTE。CTE能让你的代码结构清晰,就像写程序时把复杂功能拆分成多个函数一样。

当你需要在一个查询中多次引用同一个子查询结果,用CTE。虽然用子查询也能做到,但你需要把子查询重复写多次,而CTE定义一次就可以多次使用。

当你的查询逻辑很简单,就是一个简单的过滤或者存在性检查,用子查询就够了。比如WHERE id IN (SELECT ...)或者WHERE EXISTS (SELECT ...),这种情况下用CTE反而显得小题大做。

当你需要在多个查询中重复使用同一段逻辑,或者需要给不同的用户提供不同的数据视图,用VIEW。VIEW是持久化的数据库对象,可以跨查询、跨会话使用,而CTE只在当前查询中存在。

从性能的角度来说,现代数据库的查询优化器通常会把CTE和等价的子查询优化成相同的执行计划。所以大多数情况下,性能不应该是你选择CTE还是子查询的主要考虑因素。更重要的是代码的可读性和可维护性。

实用技巧和注意事项

在实际使用CTE时,有一些小技巧可以让你的SQL写得更好。

第一个技巧是给CTE起一个有意义的名字。不要用cte1cte2这种没有意义的名字,而要用customer_totalspayment_summary这种一看就知道在做什么的名字。好的命名本身就是最好的注释。

第二个技巧是合理控制CTE的数量。虽然CTE能让代码更清晰,但如果一个查询定义了十几个CTE,反而会让人眼花缭乱。一般来说,三到五个CTE是比较合适的。如果你发现需要更多的CTE,可能需要考虑是不是可以简化业务逻辑,或者把某些步骤做成视图。

第三个技巧是利用CTE来调试复杂查询。当你写一个复杂查询遇到问题时,可以先单独运行每个CTE,检查中间结果是否正确。这比在一个大的嵌套查询中找问题要容易得多。

还有一个需要注意的地方是递归CTE的终止条件。递归查询如果写得不好,可能会陷入无限循环。大多数数据库都有最大递归深度的限制来防止这种情况,但你还是应该确保递归条件最终会变为假。

关于性能,虽然我前面说了CTE和子查询通常性能相当,但在某些特殊情况下还是有区别的。如果你的CTE被多次引用,某些数据库可能会物化(materialize)这个CTE,也就是把结果存储在临时表中。这可能是好事(避免重复计算),也可能是坏事(占用内存)。如果你关心性能,应该用EXPLAIN命令查看实际的执行计划。

总结

CTE是SQL中一个非常强大的功能,它能让复杂的查询变得清晰易懂。通过把复杂的逻辑分解成多个有名字的步骤,CTE让你的SQL代码更像是在讲一个故事,而不是一堆难以理解的嵌套查询。

在我们的实战案例中,你看到了CTE如何处理订单支付分析、课程先修课程检查、组织架构查询等各种实际业务场景。对比子查询方案,CTE的优势在于可读性和可维护性,虽然在简单场景下子查询也足够用。

递归CTE更是处理层级数据的神器,让原本需要多次自连接或者存储过程才能完成的任务变得简单直接。

掌握CTE不仅能提升你的SQL技能,更重要的是它能改变你思考问题的方式。下次当你面对一个复杂的数据查询需求时,不妨试着用CTE把问题分解成几个清晰的步骤,你会发现很多看似困难的问题其实都可以迎刃而解。

记住,好的SQL代码不仅要能正确运行,还要让其他人(包括三个月后的你自己)能够轻松理解。而CTE正是实现这个目标的最佳工具之一。

http://www.dtcms.com/a/432139.html

相关文章:

  • x86架构和arm架构的区别
  • 如何建设学校网站论坛网站模板下载
  • 服务器可以做网站美食网站案例
  • 洛阳市副市长到省建设厅网站共享经济型网站开发
  • springboot基础配置、整合技术
  • 现在建网站网页设计素材在哪找
  • 移动网站程序wordpress 返利
  • 企业网站php源码宿州保洁公司有哪些
  • 湘潭建设公司网站建筑网片用途
  • 从零开始构建股票交易体系
  • 网文封面制作网站wordpress 标签云
  • h5网站建设+北京只买域名可以做自己的网站嘛
  • 做网站费用滁州深圳常桉网站建设
  • 论网站建设技术的作者是谁免费建站的平台
  • 网站首页图片怎么更换typecho wordpress比较
  • 南京做网站优化的公司重庆沙坪坝区房价
  • 珠海网站营销网站模板psd
  • 网站首页 动画案例我的网站百度找不到
  • 做网络推广的网站建设营销型网站的要素
  • 网站首页被k 不恢复下列关于网站开发中网页上传和
  • 盐城网站建设流程创办一个网站需要多少钱
  • 上海网站空间租用青海城乡建设厅网站 官网
  • vps网站能打开wordpress geek theme
  • 暗影精灵6plus如何关闭键盘灯
  • 微服务架构深度实战:Spring Cloud Alibaba全栈指南
  • 国外设计网站大全互动营销公司
  • 手表网站背景宁皓网 wordpress
  • 告别 403 Forbidden!详解爬虫如何模拟浏览器头部(User-Agent)
  • 广东网站建设公司xywdl语言网站开发企业
  • 在国外建设网站什么网址可以玩大型游戏