坦白说,在 MySQL 8.0 正式发布之前,我写复杂 SQL 一直有个心病:遇到多层嵌套的场景,可读性会直线下降。有次在需求评审会上,同事指着我写的一屏子查询问:"这 SQL 是你上周写的,你现在能一眼看懂吗?"我盯着它看了三十秒,只能承认需要一点时间。后来 MySQL 8.0 把 WITH 语法带进来了,这类问题才算真正有了干净的解法。
WITH 的正式名称叫 Common Table Expression,公用表表达式,国内一般简称为 CTE。核心思路很简单:你可以把一段子查询先取个名字,像定义临时变量一样,然后在后面继续用它,甚至可以递归调用自己。这篇文章我打算把 WITH 的常规用法、递归 CTE、实战场景以及性能真相一次讲透,适合已经会用 SELECT 和 JOIN、但每次写复杂 SQL 都要靠嵌套子查询硬撑的朋友。
1. 从一段读不懂的 SQL 说起:嵌套子查询的痛点
1.1 三层嵌套子查询的"阅读灾难"
很多人写复杂查询时下意识就会用子查询,比如查出每个部门里薪资最高的员工。传统写法往往会演变成这样:
sql复制SELECT d.dept_name, e.emp_name, e.salary
FROM departments d
JOIN employees e ON e.dept_id = d.dept_id
WHERE e.salary = (
SELECT MAX(e2.salary)
FROM employees e2
WHERE e2.dept_id = d.dept_id
);
这段 SQL 还不算最夸张的,但你已经能感受到问题了:MAX(e2.salary) 里面的子查询和外部查询共同使用 d.dept_id,这时候你是靠"人眼匹配"来确认它们之间的关联关系的。如果这个子查询再套一层,比如先找出每个部门的平均薪资,再找出每个部门里高于平均薪资的员工,最后再按部门排名,你的 SQL 会膨胀成三层、四层嵌套。每层缩进越来越深,括号层层堆叠,阅读时必须在脑子里维护一个"调用栈"。
更麻烦的是,这种写法一旦中间某层逻辑需要调整,比如把"平均薪资"改成"中位数",你得先找到那层子查询在哪个括号里,然后小心翼翼地把上下文理清楚。我见过很多线上事故,就是改 SQL 时动错了一层括号导致的。
1.2 WITH 到底改了什么
WITH 解决的就是这个"读不懂"的问题。它允许你从 SQL 的"正题"里拆出一个个命名片段,让每条中间结果都有自己的名字,像拼积木一样一层层搭上去。上面这个需求,用 CTE 改写之后会变成这样:
sql复制WITH dept_avg AS (
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
),
above_avg AS (
SELECT e.dept_id, e.emp_name, e.salary
FROM employees e
JOIN dept_avg d ON d.dept_id = e.dept_id
WHERE e.salary > d.avg_salary
)
SELECT dept_id, emp_name, salary
FROM above_avg
ORDER BY dept_id, salary DESC;
你看,两个 CTE 的名字分别是 dept_avg 和 above_avg,每个 CTE 内部做什么一目了然。主查询过来的时候,above_avg 已经是一个"准备好的数据集合"了,逻辑链条非常清楚。
这背后其实是一种"自顶向下、分而治之"的思维方式:先把大问题拆成多个小片段,每个片段只解决一件事,最后拼装。对刚接触 CTE 的人来说,会感觉像是把 SQL 当成了管道一样,前一步的输出是后一步的输入。
另外,CTE 还有一点和子查询完全不同的能力:它可以在一条语句里被多次引用。传统子查询如果你要引用同一个结果集三次,就得复制粘贴三遍,CTE 只要定义一次,后面反复用,维护成本低很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础语法:把中间结果先"命名"出来
2.1 最基础的 CTE 写法
WITH 的基础语法非常克制,只有三块:
sql复制WITH cte_name AS (
SELECT ...
)
SELECT ...
FROM cte_name;
cte_name 是给中间结果起的名字,后面可以跟一个可选的列名列表。例如:
sql复制WITH monthly_amount(col_name) AS (
SELECT SUM(amount) FROM orders
)
SELECT col_name FROM monthly_amount;
不写列名的时候,CTE 直接沿用 SELECT 里输出的列名;如果 SELECT 里两个列表表达式有重名,或者你想在外部换一个更直观的名字,就可以用这个列表显式命名。
CTE 名字的可见范围非常明确:只在当前这一条 SQL 语句里有效。它既不会污染数据库的系统表,也不会像变量一样跨语句保留。这一点和临时表有本质区别。
2.2 一条语句定义多个 CTE:像搭积木一样串联
多个 CTE 用逗号分隔,后面的 CTE 可以引用前面已经定义好的 CTE。比如我想要"最近三个月销售额超过 10 万的客户名单",可以先选出订单表里的有效订单,再按客户汇总:
sql复制WITH recent_orders AS (
SELECT customer_id, amount
FROM orders
WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH)
AND status = 'paid'
),
customer_stats AS (
SELECT customer_id, SUM(amount) AS total_amount
FROM recent_orders
GROUP BY customer_id
)
SELECT c.customer_name, s.total_amount
FROM customers c
JOIN customer_stats s ON s.customer_id = c.customer_id
WHERE s.total_amount > 100000;
这种写法对我来说最大的价值是"每个 CTE 都是一层业务含义",后期维护时只需要检查对应 CTE 的条件是否合理,比如改时间窗口就改 recent_orders,改金额阈值就改 customer_stats,完全不用翻到主查询最底部去核对逻辑。
需要注意,CTE 的引用方向是从前往后的。你可以引用前面已经定义过的 CTE,但不能引用后面还没定义的名字,这其实是所有编程语言里"先声明后使用"的常规约束。
2.3 CTE 和 INSERT、UPDATE、DELETE 的搭配
很多人以为 WITH 只能写在 SELECT 前面,其实它也可以和写操作语句配合。比如在插入数据之前,先通过 CTE 算好目标数据集:
sql复制WITH active_users AS (
SELECT id FROM users WHERE last_login_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
)
INSERT INTO user_daily_snapshot (user_id, snap_date)
SELECT id, CURDATE() FROM active_users;
DELETE 也类似。比如要清理一张日志表,但只删除那些"用户已经注销"的日志,可以这样做:
sql复制WITH closed_user_logs AS (
SELECT l.id FROM logs l
JOIN users u ON u.id = l.user_id
WHERE u.status = 'closed'
)
DELETE FROM logs WHERE id IN (SELECT id FROM closed_user_logs);
在存储过程或者事件调度器里,这样的写法非常常见。CTE 先圈定要处理的范围,后面的写操作语句变得极其清爽。
这里有一个小细节:如果你在 DELETE 中引用 CTE,而 CTE 又查询了同一个目标表,MySQL 可能会报"不能修改目标表"之类的错误。和普通子查询的限制一样,修改语句的目标表不能同时出现在 CTE 子查询中。遇到这种情况,我一般会把 CTE 的结果先放进一个临时表,或者用多表 DELETE 语法绕开。
3. 递归 WITH:处理层级数据的利器
3.1 递归 CTE 的语法结构
普通 CTE 已经很香了,但 WITH 真正能够"封神"的地方在于递归。语法上有两个关键词:WITH RECURSIVE,然后 CTE 内部由两个部分通过 UNION ALL 连起来。第一部分叫锚点成员(anchor member),先把初始数据查出来;第二部分叫递归成员(recursive member),反复引用 CTE 自身去扩展结果集,直到条件不再命中为止。
标准模板是这样:
sql复制WITH RECURSIVE cte_name AS (
-- 锚点成员:查询初始集合
SELECT ...
UNION ALL
-- 递归成员:引用 cte_name 继续扩展
SELECT ... FROM cte_name WHERE ...
)
SELECT * FROM cte_name;
打个比方,这就像查家谱:先找到你爷爷这一代(锚点),然后往下找他的儿子(递归一次),再找儿子的儿子(再递归一次),直到遇到没有子嗣的节点就停下来。
3.2 用递归生成连续数字和日期
递归 CTE 经常被用来生成连续序列,比如你需要在某张月度报表里补全缺失的月份。传统做法是建一张数字表,或者用存储过程循环插入,但有了递归 CTE 之后一行 SQL 就能搞定。
从 1 生成到 10:
sql复制WITH RECURSIVE seq AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM seq WHERE n < 10
)
SELECT n FROM seq;
实际业务里更常见的是生成连续的日期序列。比如我做过一个订单趋势报表,订单表里有些日期没有成交记录,但报表要求缺失日期也要展示为 0。做法是先定义一个日期序列 CTE,然后 LEFT JOIN 订单表:
sql复制WITH RECURSIVE date_range AS (
SELECT DATE('2025-01-01') AS d
UNION ALL
SELECT DATE_ADD(d, INTERVAL 1 DAY)
FROM date_range
WHERE d < DATE('2025-03-31')
)
SELECT dr.d, COALESCE(SUM(o.amount), 0) AS daily_amount
FROM date_range dr
LEFT JOIN orders o ON DATE(o.order_date) = dr.d
GROUP BY dr.d
ORDER BY dr.d;
这个查询在业务日报里非常实用,数据仓库里缺失日期补零的问题,一句递归 CTE 就解决了。
3.3 实际业务里的树形结构查询
递归 CTE 最大的用武之地是树形结构。典型场景是员工组织架构、分类树、评论回复链。以员工表为例,每个员工有一个 manager_id 指向自己的上级,总经理的 manager_id 是 NULL。我想查出某个部门下的所有成员,就可以这样写:
sql复制WITH RECURSIVE emp_tree AS (
-- 锚点:先找出根节点
SELECT emp_id, emp_name, manager_id, 1 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 递归:通过 manager_id 关联上一层
SELECT e.emp_id, e.emp_name, e.manager_id, et.level + 1
FROM employees e
JOIN emp_tree et ON e.manager_id = et.emp_id
)
SELECT emp_id, emp_name, manager_id, level
FROM emp_tree
ORDER BY level, emp_id;
这里的 level 字段可以帮你直观看出当前员工处于组织架构的第几层,非常方便后续做权限设计或报表分组。类似的逻辑,把表换成商品分类表,就可以从某一级分类往下展开所有子分类。
我实际使用时的经验是:递归 CTE 的递归条件和业务思路上最好保持严格一致。比如查"某个节点下的所有子孙节点",锚点就是当前节点,递归条件就是parent_id = 某个已找到的节点ID。这个条件写错,很容易出现漏数据和循环引用的问题。
3.4 递归 CTE 的限制
递归 CTE 有两个限制需要特别记住,否则会在上线前踩坑。
第一,递归成员里不能包含聚合函数、窗口函数、GROUP BY、ORDER BY、DISTINCT 和 LIMIT 等操作。这些操作本身就带有"去重"或"排序"的语义,放在递归过程中无法保证逐层叠加的正确性。如果需要去重或排序,通常的做法是在递归结束后,再由外层查询统一处理。
第二,MySQL 默认的递归深度上限是 1000,防止人为失误造成无限递归。如果业务数据层级很深,可以通过系统变量调高:
sql复制SET SESSION cte_max_recursion_depth = 5000;
不过在调高之前,建议先确认递归终止条件是否正确,否则一个死循环查询直接打满数据库 CPU,这个锅可不好背。
4. 实战:几个可以直接抄进业务的写法
4.1 分组 Top N:把排序交给窗口函数
这种需求非常高频:"每个部门薪资前三名"、"每门课成绩前五名"。以前要用变量或者复杂的自关联来实现,现在可以用 WITH 配合窗口函数 ROW_NUMBER() 轻松搞定:
sql复制WITH ranked AS (
SELECT
dept_id,
emp_name,
salary,
ROW_NUMBER() OVER (
PARTITION BY dept_id
ORDER BY salary DESC
) AS rn
FROM employees
)
SELECT dept_id, emp_name, salary
FROM ranked
WHERE rn <= 3;
CTE 在这里的作用是先把排名算好,然后主查询只需要一个简单的 WHERE rn <= 3 过滤条件。逻辑瞬间变得直白,这也是我在面试时最喜欢让候选人写的 SQL 之一。它考察的不只是语法,而是一种思路:把"先排序编号"和"再过滤"分成两步,而不是硬塞在一个查询里。
4.2 累计统计:月度销售 Running Total
很多业务都需要计算累计值,比如每个月的销售额 + 前面所有月份的销售额之和。用窗口函数做累计非常顺手,而 WITH 可以先把月维度数据算好,再交给窗口函数:
sql复制WITH monthly_sales AS (
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
SUM(amount) AS month_total
FROM orders
WHERE status = 'paid'
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
)
SELECT
month,
month_total,
SUM(month_total) OVER (ORDER BY month) AS running_total
FROM monthly_sales
ORDER BY month;
这里的 SUM(...) OVER (ORDER BY month) 就是一个移动累加,每一行都会带上截止到当前月份的总额。用 WITH 把月度汇总单独抽出来,主查询里只关注累计逻辑,两件事各归各的,读代码的人不会晕。
4.3 数据去重:只保留每组里最大 ID
这是运维和数仓建设里经常遇到的问题:一张表因为历史 bug 或者重复导入,出现了完全一样的记录,你希望每组相同记录只保留 ID 最大的一条。传统写法可能要用多条 SQL 或者临时表,而 WITH + 窗口函数可以一次搞定:
sql复制WITH numbered AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY name, phone, email
ORDER BY id
) AS rn
FROM customers
)
SELECT id FROM numbered WHERE rn = 1;
上面这条先查出"每条记录在当前重复分组里的序号",rn = 1 的就是每组里 ID 最小的那条。如果你要删除重复数据,可以先把这些保留的 ID 查出来,再从表里删除不在该列表中的记录。实测下来,百万级数据里做去重,这种方式比用 GROUP BY + MIN/MAX 自连接要清晰得多。
注意:实际 DELETE 时,MySQL 不允许 CTE 和 DELETE 的目标表在同一个语句里直接互相引用,建议先查出保留或删除的 ID 清单,再分两步执行。宁可慢一点,也不要在生产环境里因为一条过于"聪明"的 SQL 导致误删。
4.4 连续登录天数的经典面试题
这类分析题是 MySQL 面试高频考点,也是 WITH 展示实力的经典场景。需求是:找出连续登录 7 天以上的用户。思路可以拆解为:先对每个用户的登录日期去重,然后用窗口函数按日期排序并编号,再用登录日期减去编号,得到"分组标记",最后按标记分组统计天数。
sql复制WITH login_dedup AS (
SELECT DISTINCT user_id, DATE(login_time) AS login_date
FROM login_logs
),
dated AS (
SELECT
user_id,
login_date,
DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY login_date
) DAY) AS grp
FROM login_dedup
)
SELECT
user_id,
MIN(login_date) AS start_date,
MAX(login_date) AS end_date,
COUNT(*) AS consecutive_days
FROM dated
GROUP BY user_id, grp
HAVING COUNT(*) >= 7;
这个思路的精髓在于"日期减去行号"这一步。连续日期的行号是递增的,所以 login_date - 行号 会在连续区间内保持不变;一旦日期不连续,这个差值就会跳变。CTE 在这里帮我把"去重"、"计算差值"、"分组统计"三个步骤拆得清清楚楚,每一步都有名字,即使一个月后回来看这段 SQL,也不需要重新推演半天。
5. 性能真相与踩坑记录
5.1 CTE 是不是每次引用都会执行一遍?
这是很多人最关心的问题,也是网上说法最乱的。要回答这个问题,得先区分 CTE 在优化器视角下的两种处理方式:一是把 CTE 当作一个"物化"的中间结果集,先算出来存着,后面多次引用都读同一份;二是把 CTE 当作一个"内联视图",每次都把它的子查询合并到外层查询里重新执行。
MySQL 8.0 里的 CTE 并不保证每次都物化。优化器会根据代价决定是把 CTE 合并到外层查询(类似视图展开),还是物化成一个内部临时表。对同一语句里出现多次引用的 CTE,MySQL 可能会物化,也可能会展开多次。想确认实际执行计划,唯一靠谱的手段就是 EXPLAIN:
sql复制EXPLAIN
WITH tmp AS (
SELECT * FROM orders WHERE status = 'paid'
)
SELECT * FROM tmp t1
JOIN tmp t2 ON t1.customer_id = t2.customer_id;
观察执行计划里 tmp 对应的派生表是出现了两次,还是只出现一次物化结果。如果 CTE 被反复引用,且数据量较大,可以先把结果写入临时表再参与后续 JOIN,这样能强制物化,避免重复扫描。
从我的经验看,CTE 最大的性能收益不在"少算了一遍",而在于"让优化器更好地理解你的意图"。以前用多层嵌套子查询,优化器偶尔会有奇怪的选择;CTE 把中间结果明确表达出来之后,反而更容易形成合理的执行计划。当然这不绝对,具体 SQL 还是要以 EXPLAIN 为准。
5.2 默认 1000 层递归上限,到底够不够用
很多开发第一次写递归 CTE 时,都会遇上 Recursive query aborted after 1001 iterations 这个报错。初次遇到会有点慌,以为写错了,其实只是触发了 MySQL 默认的递归深度上限。
这里的 1000 是"迭代次数",不是"数据行数"。像员工组织架构这种场景,几千人的公司最多也就十层左右,1000 层完全够用。但如果你用递归 CTE 生成一张几十万的日期维表,一次递归就超过 1000 次,这个时候就需要手动调大深度:
sql复制SET SESSION cte_max_recursion_depth = 100000;
我个人建议是:任何递归 CTE 上线前,先在测试库里试跑一遍,确认递归终止条件。不要依赖默认上限来做最终保护,因为如果条件写错,调大上限等于给死循环开了绿灯。
5.3 踩过的两个坑
第一个坑:CTE 里引用了后面才定义的 CTE。新手很容易把两个 CTE 的顺序写反,MySQL 直接报 Table 'xxx' doesn't exist。这个报错信息有点误导,其实是因为名字还没定义。遇到这个报错时,第一反应应该看看是不是引用顺序反了。
第二个坑:CTE 的名字和真实表名冲突。如果一个 CTE 命名和物理表一致,MySQL 在同一条语句的可见范围内会优先使用 CTE 的定义。这种行为虽然符合标准,但实际项目里很容易让人误会。比如原本以为查的是真实表,结果被 CTE 遮蔽了。我的习惯是 CTE 命名时统一加前缀或明确业务含义,比如 tmp_、stats_、agg_,避免和真实表名混淆。
5.4 什么时候别用 CTE
CTE 不是万能的,有些场景硬上反而自找麻烦。
第一个场景是超大结果集的多次引用。如果 CTE 产生了几千万行数据,又在外层 JOIN 里引用多次,优化器无论物化还是展开都可能产生较大开销。这种情况下,我一般直接把结果写入临时表,加好索引再继续用,执行计划更可控。
第二个场景是"只需要用一次"的简单子查询。一个简单的 SELECT ... WHERE ...,直接写在 FROM 子句里即可,没必要为了用 CTE 而用 CTE。CTE 最有价值的地方在于"多次引用"、"递归"和"拆解复杂逻辑",如果这三个都不沾边,子查询更直接。
第三个场景是 CTE 可以被更简单的手段取代的时候。比如一些可以通过视图复用的逻辑,视图比会话级 CTE 更适合长期维护。CTE 是一次性语句内的利器,视图是跨语句复用的基础设施,两者定位不同。
我在实际项目里总结出来的判断标准是:如果这段 SQL 超过八行,或者要嵌套两层以上子查询,又或者需要在同一语句里反复使用同一段中间结果,那就优先考虑用 WITH。它不一定能让执行计划更快,但一定能让查问题的人更快。
如果你手头正好有一段又长又臭的嵌套子查询,建议花几分钟拆成 CTE 试试。拆完之后你会发现,SQL 的表达能力其实比想象中强很多,只是以前缺少一个合适的组织工具。
