如果你写SQL的时候还被三层嵌套子查询逼疯过,那这篇值得看完。我入行做数据分析和后端开发那几年,最怕接手那种 SELECT 里套 SELECT、WHERE 里再塞 EXISTS 的"千层饼"SQL。后来 MySQL 8.0 终于引入了 CTE(Common Table Expression),我才算真正感受到什么叫"SQL 清爽神器"。这篇我会从最基本的语法讲起,但重点放在真实业务场景、性能真相和踩坑记录上,目标很直接:让你看完就能把以前那种绕来绕去的嵌套子查询改成一眼能读懂的 CTE,而且知道什么时候该用、什么时候别硬用。
1. 嵌套子查询的三大痛点:为什么你读SQL会头疼
说实话,我刚开始干数据分析那会儿,最怕的就是从前辈手里接手带着四五层括号的SQL。那玩意儿第一眼看过去确实能跑,但你想改个指标、加一个过滤条件,整个人就像拆炸弹一样。你可能觉得我在夸张,但真正经历过线上报表数据对不上、凌晨还在扒拉一条三层子查询的人,一定懂我说的滋味。
1.1 可读性:越套越深,越看越懵
嵌套子查询的本质是把一个查询的结果当作另一个查询的输入。这本身是很好的逻辑抽象,但问题在于SQL的书写顺序和阅读顺序不是一回事。人在读SQL时习惯从上往下、从左往右,但子查询往往是"从里往外"读。三层嵌套的时候,你脑子里要维护三张虚拟表,还要记住每一层引用的是哪张表、哪个别名。我见过最夸张的一次,一个报表SQL嵌套了六层,中间还混着 EXISTS、IN、NOT IN,当时整个团队没人愿意动它,因为动一处错三处。
这里有一个容易被低估的点:缩进。很多写嵌套子查询的人不缩进,括号一多,连哪里开始哪里结束都分不清。MySQL 又没有像 Python 那样强制的缩进语法,所以等读到第30行时,你根本不知道这个右括号到底是哪个子查询的。我后来给团队定的规矩是:超过两层嵌套必须拆开写,要么用临时表,要么用视图,要么直接上 CTE。
1.2 调试:中间结果取不出来
嵌套子查询最大的硬伤是你没法单独验证中间结果。比如我要先算出每个部门的平均薪资,再找出平均薪资超过公司整体均线的部门。如果直接写一句话嵌套查询,我想看看"第二步之前的中间结果"怎么办?只能把子查询单独拆出来,复制到另一个查询编辑窗口,跑完确认无误,再塞回去。这个过程极度单调,而且拆的时候还容易漏条件、写错别名。
调试对于一个复杂的SQL太重要了。我相信很多人和我一样,面对一个结果不对的查询,第一反应不是猜,而是想"先看看中间数据长什么样"。嵌套子查询在这个需求面前基本是无能为力的,要么拆,要么干脆相信运气。运气不好时,你会陷入"改一行、跑一次、结果还是不对"的死循环,最后发现是子查询里某个字段类型隐式转换导致索引失效,这种消耗是最让人崩溃的。
1.3 复用:同样的逻辑只能复制粘贴
一个真实场景:报表里要计算"本月新增客户"这个指标,你在一处写了带有 DISTINCT 和聚合的子查询。过两天另一个报表也要这个口径,你没有别的办法,只能把这段子查询再复制一份。复制粘贴本身问题不大,可怕的是口径一改,你要改的地方可能不止两处,而是五处八处。漏改一处,数据对不上,又得花半天排查是不是缓存问题。
复用问题还有另一个变种:同一段SQL里,同一个子查询可能要用两次。比如既要在 WHERE 里用它过滤,又要在 SELECT 里用它做对比,那就真的只能重复两遍。代码长度翻倍,而且两遍写得不一致的风险也在翻倍。这种时候,你要的不是"能不能跑",而是"能不能干净地跑"。
2. CTE基础:一句话理解,一个骨架学会
CTE 的全称是 Common Table Expression,中文一般叫公共表表达式。它做的事情其实特别简单:把一个查询先起个名字,存在查询的"最前面",然后再去引用。用生活化的类比,它就像你做饭之前先把葱姜蒜切好,放在小碗里,后面炒菜的时候随时取用,而不是每次炒到一半再跑去切菜。理解这一点,你就已经理解了 CTE 百分之七十的价值。
2.1 什么是CTE,和派生表有什么区别
很多人第一次看 CTE 都觉得这玩意儿不就是派生表(Derived Table)吗?也就是 FROM 后面的括号子查询。确实在功能上有重叠,但使用体验差别很大。派生表必须嵌在 FROM 子句里,所以你不能先声明一个派生表再在 WHERE 或者 SELECT 里引用它;CTE 则是用 WITH 在语句最前面声明,后面的整条语句都能用,位置灵活得多。
还有一个很重要的差异:CTE 可以自我引用,也就是递归。这是派生表永远做不到的。递归在遍历树形结构、生成序列、展开层级数据时是刚需,后面我会展开讲。简单来说,派生表是一次性工具,CTE 是名正言顺的复用单元。
MySQL 是从 8.0 开始正式支持 CTE 的,8.0.1 之后语法就稳定了。如果你还在用 5.7,抱歉,CTE 用不了,那只能继续忍受子查询或者想别的办法。现在8.0已经是主流,该升级就升级。如果你负责老项目迁移,这也可以作为升级 MySQL 的重要理由之一。
2.2 基本语法与多个CTE
一个最简单的 CTE 长这样:
WITH avg_salary_cte AS ( SELECT dept_id, AVG(salary) AS avg_salary FROM employee GROUP BY dept_id ) SELECT * FROM avg_salary_cte;注意几个细节。第一,WITH 后面跟的是 CTE 的名字,名字之后用 AS,括号里是完整的查询。第二,主查询可以放在 SELECT、INSERT、UPDATE、DELETE 前面,但最终结果还是由最外层语句决定。第三,如果你有多个 CTE,可以用逗号隔开,后面一个可以引用前面一个。这种"流水线"式的写法才是 CTE 的精髓:
WITH dept_avg AS ( SELECT dept_id, AVG(salary) AS avg_salary FROM employee GROUP BY dept_id ), high_dept AS ( SELECT dept_id FROM dept_avg WHERE avg_salary > 8000 ) SELECT e.emp_id, e.emp_name FROM employee e WHERE e.dept_id IN (SELECT dept_id FROM high_dept);这种"后面的CTE引用前面的CTE"的写法,本质上就是在表达一个数据加工流水线。第一步算什么,第二步基于第一步继续算什么,每一步都有名字、有边界,读起来跟读一个流程文档差不多。我经常给团队打比方:这就像做菜谱,第1步切菜,第2步炒菜,第3步装盘,每一步都写清楚,而不是把所有动作都塞进一个括号里。
2.3 什么时候应该用CTE
我的个人标准是下面几条,满足任意一条就把子查询改成 CTE:
- 子查询数量超过一个;
- 嵌套层级超过两层;
- 同一个中间结果要被引用两次以上;
- 查询里需要递归遍历;
- 你希望别人(包括三个月后的自己)能快速看懂这段SQL。
反过来,如果一个非常简单的标量子查询就能搞定,比如 WHERE salary > (SELECT AVG(salary) FROM employee),那也没必要强行套个 CTE。过度封装同样是可读性杀手,适度就好。我对"适度"的理解是:CTE 把复杂变简单,而不是把简单变复杂。如果一个查询十行以内能说明白,直接写就好,不需要为了用 CTE 而用 CTE。
3. 从嵌套子查询到CTE:一个改写案例看懂全部
前面讲理论可能还不够直观,我们直接来一个实际改造案例。我尽量模拟日常报表中会真实出现的需求,而不是教科书里的清新例子。
3.1 场景背景与原始SQL
需求是:找出"部门平均薪资高于全公司平均薪资的部门里,薪资最高的人是谁"。这个需求脱胎于绩效分析,实际工作中很常见。先看嵌套子查询版本:
SELECT e.emp_id, e.emp_name, e.dept_id, e.salary FROM employee e WHERE e.dept_id IN ( SELECT d.dept_id FROM ( SELECT dept_id, AVG(salary) AS avg_salary FROM employee GROUP BY dept_id ) d WHERE d.avg_salary > ( SELECT AVG(salary) FROM employee ) ) ORDER BY e.salary DESC LIMIT 1;这段 SQL 只有三层嵌套,但你已经能感受到问题了:第一个子查询在算"部门平均薪资",第二个标量子查询在算"全公司平均薪资",最外层又负责找薪资最高的人。如果你字段一多、过滤条件再复杂一点,这玩意儿基本没法维护。
3.2 改写为CTE的完整过程
改成 CTE 之后,我们把中间步骤拆开。先说清楚:这个版本的改写重点在"拆层",至于"组内排名"的语义细节我下一章专门讲,这里先看框架。
WITH company_avg AS ( SELECT AVG(salary) AS avg_salary FROM employee ), dept_avg AS ( SELECT dept_id, AVG(salary) AS avg_salary FROM employee GROUP BY dept_id ), target_dept AS ( SELECT d.dept_id FROM dept_avg d CROSS JOIN company_avg c WHERE d.avg_salary > c.avg_salary ) SELECT e.emp_id, e.emp_name, e.dept_id, e.salary FROM employee e INNER JOIN target_dept t ON e.dept_id = t.dept_id ORDER BY e.salary DESC LIMIT 1;这一步一步拆下来,逻辑清楚多了:先算公司平均线,再算部门平均线,再筛出高于公司平均线的部门,最后拿员工表和目标部门做 JOIN。每一步 CTE 的职责都很单一,一旦结果不对,你可以单独运行其中任何一段去检查。这就像查一个复杂 bug,你不需要盯着整个报错堆栈看,而是把每一层函数单独测一遍,很快就能定位问题。
这个版本还有个隐藏的进阶点:company_avg 只写了一次,但它出现在整个查询的开头,后面如果还要拿它和其他部门维度做对比,随时可以再用。你不再需要为了算平均值把同一段 AVG 语句复制三遍。
3.3 解读:为什么CTE版本更爽
说实话,光从性能看,这两个版本在 MySQL 优化器眼里很可能最终执行计划差别不大,因为优化器会做子查询展开。真正拉开差距的是人力成本。CTE 版本有几个实打实的好处:
第一,每一步都可以单独验证。我经常在 Navicat 里先把第一个 WITH 单独选中跑一遍,确认 avg_salary 对不对,再把第二个跑一遍。嵌套子查询做不到这件事,你只能小心翼翼地复制。
第二,条件逻辑从"括号嵌套关系"变成了"命名引用关系"。人的工作记忆是有限的,大概能同时记住七加减二件事。三个 CTE 名字调用彼此,比三层括号叠加轻松太多。
第三,改需求的时候命中了"单一改动点"。比如公司平均线的计算口径变了,从"所有员工"改成"在职员工",你只需要改 company_avg 这个 CTE 里的 WHERE 条件,其他部分一点不用动。如果是在嵌套子查询里改,你得先定位到第几行括号里的第几个小括号,这个定位本身就是事故高发区。
4. CTE进阶实战:树形递归、分组TopN、滚动汇总
如果 CTE 只是让代码好看一点,那还不足以被称为"神器"。真正让 CTE 不可替代的是递归能力,以及和窗口函数组合后的表现力。这一章我们从实际需求出发,把三种高频场景都过一遍。
4.1 递归CTE遍历组织架构
先看最常见的递归场景:组织架构树。假设 employee 表里有 manager_id 表示直属领导,现在要拿到从 CEO 开始往下每一层的完整路径。
WITH RECURSIVE org_tree AS ( SELECT emp_id, emp_name, manager_id, 1 AS depth FROM employee WHERE manager_id IS NULL UNION ALL SELECT e.emp_id, e.emp_name, e.manager_id, ot.depth + 1 FROM employee e INNER JOIN org_tree ot ON e.manager_id = ot.emp_id ) SELECT emp_id, emp_name, depth FROM org_tree ORDER BY depth;递归 CTE 的语法分三块:初始查询(锚点)、递归查询、终止条件。锚点负责找到树根;递归部分通过 JOIN 自己来一层层往下走;终止条件由两个机制保证,一是递归查询的结果集不断增长但路径总会在某个点走到尽头,二是超出深度限制会报错。
需要注意的关键点是 UNION ALL 和 UNION DISTINCT 的选择。树形结构不会出现重复路径,用 UNION ALL 性能更好;但如果你的关系数据有环,比如 A 的上级是 B,B 的上级又是 A,那就必须做好环路检测,否则会陷入死循环直到触发递归深度上限。我实际做组织架构树的时候还会在递归里加一个 depth 字段,方便前端渲染成缩进结构。你也可以在递归里拼接 path 字段,比如 CONCAT(ot.path, ' -> ', e.emp_name),这样每个节点能直接看到自己的完整汇报链。
4.2 分组TopN:窗口函数加CTE组合
接着 3.2 遗留的问题:如果我要找"部门平均薪资高于公司平均线的每个部门中,薪资最高的前两名",单靠 CTE 拆分层级还不够,因为 LIMIT 2 只能作用于最终结果集,没法按部门分组取 TopN。解决办法是 CTE 加窗口函数:
WITH target_dept AS ( SELECT dept_id FROM employee GROUP BY dept_id HAVING AVG(salary) > ( SELECT AVG(salary) FROM employee ) ), ranked AS ( SELECT e.emp_id, e.emp_name, e.dept_id, e.salary, ROW_NUMBER() OVER (PARTITION BY e.dept_id ORDER BY e.salary DESC) AS rn FROM employee e INNER JOIN target_dept t ON e.dept_id = t.dept_id ) SELECT emp_id, emp_name, dept_id, salary FROM ranked WHERE rn <= 2;这段已经把我要的"每个部门各自的前两名,并且这些部门还得满足平均薪资高于公司平均线"完全表达清楚了。先 HAVING 筛部门,再 ROW_NUMBER 在部门内排名,最后 WHERE rn <= 2 取前两。没有一层多余的嵌套,每一步都是独立的命名块。
窗口函数里的 PARTITION BY 和 ORDER BY 是关键。PARTITION BY 指定分组维度,ORDER BY 指定排序规则,ROW_NUMBER() 在这个窗口内从 1 开始递增。如果你希望并列的名次也占上榜名额,可以换成 RANK() 或 DENSE_RANK(),这是我踩过坑的地方:排行榜需求里"相同成绩并列"和"相同成绩顺延"是两种完全不同的口径。RANK() 会在并列后跳号,比如 1、1、3;DENSE_RANK() 不跳号,是 1、1、2。决定用哪个函数,必须和业务方确认清楚。
4.3 累计汇总与按月滚动统计
做经营分析时,另一个高频能力是滚动累计。比如按月份统计销售额,再算一个截至当月的历史累计值。CTE 可以先按月聚合,再在外面用窗口函数做累计:
WITH monthly AS ( SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(amount) AS total_amount FROM orders WHERE order_date >= '2024-01-01' GROUP BY DATE_FORMAT(order_date, '%Y-%m') ) SELECT month, total_amount, SUM(total_amount) OVER (ORDER BY month) AS running_total FROM monthly ORDER BY month;这里的关键是 SUM(...) OVER (ORDER BY month)。在没有 GROUP BY 的窗口语境下,它会在所有行之间做累计,ORDER BY 决定累加顺序。CTE 的作用是先把原始的明细订单流转成月度汇总流,让外层的窗口函数专注做"累计"这一件事。
你甚至可以用 CTE 构建一个完整的日期序列,然后用 LEFT JOIN 把缺失月份补零。生成日期序列本身就用到递归 CTE:
WITH RECURSIVE all_months AS ( SELECT DATE('2024-01-01') AS month_start UNION ALL SELECT DATE_ADD(month_start, INTERVAL 1 MONTH) FROM all_months WHERE month_start < DATE('2024-12-01') ) SELECT month_start FROM all_months;这个技巧在没有日历表的环境里尤为好用。以前要先生成一张临时数字表,现在一个递归 CTE 就搞定,而且写在同一段 SQL 里,语义非常清晰。把日期主表和业务数据 LEFT JOIN,再配合 COALESCE 补齐缺失月份的值,一份连续的月度趋势图就出来了。
4.4 数据去重检查的实际案例
最后说一个我非常常用的场景:按某个业务键查重。登录日志表里同一个用户同一天可能因为各种原因产生多条记录,要去重只保留最新一条。先查出重复记录:
WITH ranked AS ( SELECT id, user_id, login_time, ROW_NUMBER() OVER ( PARTITION BY user_id, DATE(login_time) ORDER BY login_time DESC ) AS rn FROM user_login_log ) SELECT id, user_id, login_time FROM ranked WHERE rn > 1;当你想删除这些重复记录时,稳妥做法是先用上面的 SELECT 确认影响范围,再执行 DELETE。我个人的经验是不要贸然直接在 DELETE 里写窗口函数,虽然 MySQL 8.0 语法上允许 WITH 和 DELETE 配合,但版本不同、行为细节有差异,线上谨慎为上。最稳妥的做法是把重复 id 集合捞出来,再配合条件删除。CTE 的价值同样体现在这里:你可以先在 SELECT 分支里反复验证 CTE 的逻辑,确认无误后再把整套 SQL 挪到 DELETE 场景,排查成本低很多。
5. CTE性能真相与常见坑:别把语法糖当银弹
看到这里,很多朋友会自然产生一个问题:CTE 这么好用,性能会不会有问题?我的回答是:CTE 并不是银弹,它主要解决的是"人读不懂"的问题,不是"数据库跑得慢"的问题。这一章我们聊聊你必须知道的性能真相和坑。
5.1 CTE性能和嵌套子查询比到底如何
先说结论:在绝大多数场景下,CTE 和嵌套子查询的执行计划是等价的。MySQL 优化器在生成执行计划时会把 CTE 当作派生表或者内联视图来处理,能合并的就合并,不能合并的就物化成临时表。所以如果你把一段嵌套子查询原样改写成 CTE,除非优化器做了不同选择,否则性能不会翻天覆地地变好,也不会明显变差。
那 CTE 有没有可能更慢?有。当 CTE 被引用多次时,MySQL 可能选择物化 CTE 的结果,也就是把中间结果落地到内存中的临时表。这个物化过程有成本,如果中间结果集特别大,物化成本反而可能高于重复扫描原表。早年我做过一个报表,把一个 CTE 引用了三次,数据量几百万行,结果物化开销明显。后来我把 CTE 拆成不同的查询分别跑,配合索引优化,总耗时才降下来。
所以正确的态度是:CTE 是"可读性工具",不是"性能优化工具"。很多慢 SQL 的根因在表结构、索引、JOIN 策略上,把这个锅甩给 CTE 是不公平的。
5.2 多次引用CTE的物化问题
关于多次引用,我再说细一些。如果你在一个查询里对同一个 CTE 引用了两次,MySQL 通常不会把这段 CTE 重复执行两遍,而是倾向于先物化成临时表再复用。这听起来是好事,但也带来两个隐患。
第一,物化临时表没有索引,除非 MySQL 自动建立索引,否则你在外层多次 JOIN 这个 CTE 的时候,可能要走全表扫描,速度比直接查原表慢。第二,物化需要临时空间,在内存不够时会写到磁盘,一旦写盘,性能下降成指数级。我自己观察过 behavior:一个 500 万行的 CTE,内存模式下可能几十毫秒,一旦转成磁盘临时表,直接秒级起步。
我自己的习惯是:同一个 CTE 如果要用两次以上,我就会认真看 EXPLAIN,看它到底是 merge 还是 materialize。如果物化了而且结果集很大,我会考虑能否把这段逻辑独立成一张正式表,或者用临时表手动物化并加索引。CTE 写起来清爽,不等于可以随便写个大的。
5.3 递归CTE的四个常见错误
递归 CTE 是写作事故的高发区,我总结四个高频错误。
第一个错误是递归部分忘了加终止条件,或者条件写错,导致无限递归。MySQL 有一个保护机制:cte_max_recursion_depth,默认是 1000,超过就报错。如果你确实需要递归很深,可以临时调大,但一般超过 1000 就要反思数据模型了。
SET SESSION cte_max_recursion_depth = 5000;第二个错误是递归部分使用了聚合函数或窗口函数。CTE 递归部分的 SELECT 不允许使用聚合函数和窗口函数,这是语法层面的限制,不是优化层面的。假设你想在递归里统计每层人数,对不起,写不了,你得先递归出所有节点,再在外面做聚合。
第三个错误是 UNION 和 UNION ALL 用错。关系数据有环的时候,UNION ALL 会让递归无限延伸,必须做好环路检测或用去重语义。树形结构没有环才可以用 UNION ALL,而且在语义上"保留所有路径"才是你真正想要的,这时候贸然上 UNION DISTINCT 还可能意外丢数据。
第四个错误是搞不清锚点查询和递归查询的顺序。递归 CTE 的结构必须先是锚点查询,然后 UNION [ALL/DISTINCT],最后是递归查询。递归查询里一定要引用 CTE 本身,否则它不是递归,就只是一个普通查询。很多人把锚点和递归部分写反了,结果跑出来只有一行,怎么查都查不全。
5.4 版本支持与线上检查
版本支持这块很少有人写清楚。MySQL 8.0.1 开始支持 CTE,8.0.14 前后语法趋于完善,但如果你连接的是 MariaDB,版本支持情况又不一样,MariaDB 支持 WITH 但语法细节可能有差异。上线之前我习惯做三件事:
- 确认生产环境 MySQL 大版本 >= 8.0;
- 执行 EXPLAIN 看 CTE 是被 merge 还是 materialize;
- 在测试环境造一份接近真实规模的数据跑一遍,观察临时表大小和耗时。
另外要提醒一个现实的点:很多公司有 SQL 审查工具或者团队规范,对线上查询能写什么、不能写什么有纪律性约定。动手重构之前先问一下团队约定,别把好工具变成了吵架的理由。毕竟工具只是手段,团队协作和线上稳定才是目的。
6. 常见问题与排查技巧实录
最后这部分,我把自己维护 SQL 过程中遇到的高频问题整理成速查形式,方便你直接照着排查。
6.1 报错速查表
| 报错信息 | 常见原因 | 解决思路 |
|---|---|---|
| You have an error in your SQL syntax... near 'WITH' | 版本低于 8.0 或写错位置 | 确认 MySQL 版本,把 WITH 放在整条语句最前面 |
| Recursive query without termination | 递归部分缺少终止条件或条件不可达 | 检查递归 WHERE 条件的表达式,确保能收敛 |
| Recursive Common Table Expression can't contain aggregate function | 递归部分使用了聚合/窗口函数 | 把聚合挪到外层,递归里只做行级展开 |
| Can't create temporary table | 物化临时表过大,内存磁盘都撑不住 | 该 CTE 逻辑物化太贵,考虑建实体表或重构 |
| Exceeded max recursion depth | 超过 cte_max_recursion_depth 默认 1000 | 先确认数据没有环,再按需调大会话参数 |
这个表并不能覆盖所有场景,但覆盖了我和团队同事踩过的八成问题。遇到没见过的报错,第一件事永远是单独运行 CTE 的各个子查询,缩小问题范围。千万别在一条几百行的 SQL 里从头到尾猜是哪里的括号没闭合。
6.2 优化与排查清单
排查复杂查询时,我习惯按下面顺序走,而不是直接抓瞎优化:
- 先看 EXPLAIN 的 type 列,是否存在全表扫描,重点关注被 CTE 引用的主表有没有走索引;
- 单独跑一遍 CTE 里的查询,确认中间结果量级,如果中间结果有几百万行,后续 JOIN 的性能就要警惕;
- 检查 JOIN 字段的类型和字符集是否一致,不一致会导致索引失效,这个坑常出在跨库 JOIN 上;
- 如果 CTE 被引用多次,观察临时表大小,必要时用优化器相关参数做微调;
- 最终用 profiling 或 performance_schema 对比改造前和改造后的耗时,用真实数据说话。
这里单独说一下 EXPLAIN。在 MySQL 8.0 里执行 EXPLAIN SELECT ... FROM 一个引用 CTE 的查询,你会看到 CTE 出现在 derived table 相关行里。如果你看到 Materialize 关键字,说明它物化了;如果看到 Using temporary 也不要慌,关键看数据量和是否走索引。只有把这些细节都掌握清楚,你才能真正做到又快又清。
6.3 我的一些真实体会
文章写到这里,最后分享一点我个人的操作习惯。我现在写任何查询,默认流程都是"先拆解业务逻辑,再画信息流,最后写 SQL"。信息流里的每一个中间节点,我基本都会写成 CTE。这么做的好处是,SQL 越来越像一份可以读给业务同事听的执行说明。有一次需求评审,我直接把 SQL 里的 CTE 名称和注释念给业务方听,对方居然能边听边对口径,这是嵌套子查询永远不可能实现的事。
CTE 也不是万能的。真遇到特别复杂的 ETL 逻辑,我依然会用临时表分步落库,而不是硬塞一个超级大的 WITH 语句。清洗了大量数据之后你会发现:工具的关键不在于"能不能用",而在于"在什么粒度上用"。CTE 的粒度适合把"一个查询内部"拆清楚,跨查询、跨任务的时候,就交给视图、临时表和存储过程。
如果你们团队还没用上 CTE,我强烈建议从重构一条模糊的子查询开始,看看跑完再给别人读的效果。清爽不仅是一种感觉,它直接决定了你半小时后开会时,能不能讲清楚这段数的逻辑。