☰
MySQL CTE入门到精通:递归查询、WITH用法与性能优化实战
2026/10/14 4:38:05 网站建设 项目流程

从最早接触MySQL的CTE(Common Table Expression,公共表表达式)到现在,我最大的感受就是:别人还在写三层嵌套子查询的时候,你已经能用WITH把逻辑拆得明明白白。尤其在做数据分析、报表开发、递归查询这些场景时,WITH不只是语法糖,它直接改变了我们写SQL的思维方式。这篇就把我用下来的经验全部整理出来,从最简单的基础用法到递归查询、DML结合、性能调优的注意事项,配合可以直接抄的示例,一次性讲透。

1. WITH的基本用法:为什么它是"临时视图"而不是"变量表"

1.1 最简单的单CTE写法

先看一个最基础的场景。假设我们有一个订单表和一个订单明细表,要统计每个客户的总下单金额,并且只保留总金额超过10000的客户。传统写法通常是:

SELECT customer_id, SUM(total_amount) AS total FROM orders GROUP BY customer_id HAVING SUM(total_amount) > 10000;

这看起来不复杂,可一旦需要复用这个统计结果,比如关联客户信息表、再按区域汇总,SQL就开始膨胀了。用WITH改写是这样:

WITH customer_totals AS ( SELECT customer_id, SUM(total_amount) AS total FROM orders GROUP BY customer_id HAVING SUM(total_amount) > 10000 ) SELECT c.customer_name, ct.total FROM customer_totals ct JOIN customers c ON c.customer_id = ct.customer_id;

这里的关键是WITH customer_totals AS (...)这一段。它相当于把一段子查询的结果"命名"成一个临时的结果集,之后在整个查询里可以像引用普通表一样引用它。很多人会把它类比成"临时表",但更准确的理解是"临时视图"——因为它不实际存储数据,只是在执行时被MySQL展开。

注意:MySQL 8.0及以上版本才支持CTE,5.7及以下版本只能用派生表(子查询)或者变量曲线救国。如果你还在维护老版本,要么升级,要么老老实实用子查询。

1.2 CTE和派生表的核心区别

CTE和传统的派生表(from后面的子查询)最大的区别有两个:

第一是可读性命名的可复用性。一个CTE可以在同一查询中被引用多次,而派生表如果多出使用,就得写好几遍同样的子查询。第二是逻辑结构的清晰度。CTE可以一层层定义,再一层层引用,比如:

WITH base AS ( SELECT * FROM orders WHERE order_date >= '2024-01-01' ), aggregated AS ( SELECT customer_id, COUNT(*) AS order_count FROM base GROUP BY customer_id ) SELECT * FROM aggregated WHERE order_count > 5;

你看,base先做日期筛选,aggregated在base之上做聚合,最终查询再在aggregated上过滤。每一层都清晰命名,读代码的人不用去拆解缩进很深的子查询,直接从最底层往上读就行。

这一点在团队协作里非常有用。我有次接一个别人的报表任务,原来那个SQL嵌套了四层子查询,我硬是花了快一小时才捋清楚数据流向,后来用CTE重写之后,整个逻辑一目了然,后续维护的人再也不用对着括号数层数了。

1.3 多个CTE如何组合引用

多个CTE之间可以做依赖引用,也可以并列定义。MySQL支持在一个WITH语句里定义多个CTE,它们之间用逗号分隔。后面的CTE可以引用前面定义的CTE,但是前面的不能引用后面的。

WITH dept_avg AS ( SELECT department_id, AVG(salary) AS avg_salary FROM employees GROUP BY department_id ), overpaid AS ( SELECT e.employee_id, e.name, e.salary FROM employees e JOIN dept_avg d ON d.department_id = e.department_id WHERE e.salary > d.avg_salary * 1.2 ) SELECT * FROM overpaid;

这里overpaid依赖dept_avg的结果,整个结构像流水线一样。如果不用CTE,第二个子查询里就必须把第一个子查询完整地再写一遍。CTE让这种"先算中间结果,再基于中间结果做分析"的模式变得非常顺手。

我个人的习惯是:如果子查询超过200行,或者同一个子查询要被引用两次以上,就一定要用CTE。这不仅仅是风格问题,更是维护成本的问题。SQL太长了以后,业务逻辑藏在堆叠的括号里,谁改谁头疼。

2. 递归CTE:在MySQL里写"树形查询"的正确姿势

2.1 递归CTE的工作原理

递归CTE是WITH最强大的功能之一,在8.0之前,MySQL里要实现递归查询只能靠存储过程或者应用层代码循环,非常痛苦。递归CTE的语法结构是:

WITH RECURSIVE cte_name AS ( -- 锚点成员(初始结果集) SELECT ... UNION ALL -- 递归成员(引用cte_name自身) SELECT ... ) SELECT * FROM cte_name;

执行过程分三步:第一步执行锚点成员,得到一个初始结果集;第二步反复执行递归成员,每次都把上一次的结果作为输入;第三步直到递归成员返回空结果集或达到迭代上限,就停止。最终结果是锚点+所有迭代结果的合集。

画个直观的类比:你把一块石头扔进井里,井里每一层的回声反弹出来,每一层都基于上一层的反弹结果,直到声音衰减到听不见为止。递归CTE就是这种"基于上一轮结果的链条式生成"。

2.2 数字序列生成与日期填充

最经典的递归CTE用法是生成连续数字或日期序列。比如生成1到10的数字:

WITH RECURSIVE num_seq AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM num_seq WHERE n < 10 ) SELECT n FROM num_seq;

稍微改一下,就能生成从今天起未来30天的日期序列:

WITH RECURSIVE date_seq AS ( SELECT CURDATE() AS d UNION ALL SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM date_seq WHERE d < DATE_ADD(CURDATE(), INTERVAL 30 DAY) ) SELECT d FROM date_seq;

这类序列在数据分析里的应用场景非常多。最典型的是补齐缺失日期:统计每天的订单数时,没有订单的日期在GROUP BY的结果里根本不会出现,但报表需求往往要求日期连续的完整列表。这时候把日期序列和订单统计结果做一个LEFT JOIN,就能把空天补成0。

WITH RECURSIVE dates AS ( SELECT '2024-06-01' AS d UNION ALL SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM dates WHERE d < '2024-06-30' ) SELECT dates.d, IFNULL(cnt.daily_count, 0) AS daily_orders FROM dates LEFT JOIN ( SELECT order_date, COUNT(*) AS daily_count FROM orders WHERE order_date BETWEEN '2024-06-01' AND '2024-06-30' GROUP BY order_date ) cnt ON cnt.order_date = dates.d;

这段代码我几乎每周都在用。注意递归CTE的终止条件必须写在递归成员里(WHERE d < ...),如果漏掉,MySQL默认会报一个"递归查询超过最大迭代次数"的错误。默认上限是1000次,如果你确实需要生成超过1000行的序列,可以用cte_max_recursion_depth参数调整。

注意:不要随意调大cte_max_recursion_depth,尤其是生产环境。递归CTE虽然方便,但每一层迭代都是真实的开销,设置合理上限是为了防住因为写错终止条件导致的全表风暴。

2.3 员工组织架构与层级树查询

递归CTE最典型的业务场景是查询树形结构,比如员工汇报链、分类树、菜单层级。假设有张员工表,每个员工有一个manager_id指向他的上级,要查某个员工及其所有下属(包含下属的下属,递归下去),写法是:

WITH RECURSIVE emp_tree AS ( -- 锚点:找出根节点 SELECT employee_id, name, manager_id, 1 AS level FROM employees WHERE employee_id = 1001 UNION ALL -- 递归:找下一级 SELECT e.employee_id, e.name, e.manager_id, et.level + 1 FROM employees e INNER JOIN emp_tree et ON e.manager_id = et.employee_id ) SELECT * FROM emp_tree;

这个例子里的level字段可以直观地看出每个员工处在树的第几层,对于组织架构图渲染、权限继承计算、成本分摊汇总都很有用。还有一个反向场景:查某个员工的所有上级链,只要把锚点改成自己,递归的JOIN方向反过来即可:

WITH RECURSIVE mgr_chain AS ( SELECT employee_id, name, manager_id FROM employees WHERE employee_id = 2009 UNION ALL SELECT e.employee_id, e.name, e.manager_id FROM employees e INNER JOIN mgr_chain mc ON e.employee_id = mc.manager_id ) SELECT * FROM mgr_chain WHERE manager_id IS NOT NULL;

使用递归CTE做树形查询时,有个容易踩的坑:数据本身存在环。比如两条记录互相把对方设为manager_id,就会导致递归无限循环。虽然MySQL有最大迭代次数的保护,但报错之后排查起来很烦。我的做法是在查询前先写个一次性检查脚本,看看有没有闭环:

SELECT a.employee_id, b.employee_id FROM employees a JOIN employees b ON a.manager_id = b.employee_id WHERE b.manager_id = a.employee_id;

如果查出数据来,就先修数据再跑递归。这个排查脚本很简单,但能省掉很多折腾时间。

2.4 递归CTE与WITH RECURSIVE的权限控制

另一个值得注意的点:递归CTE必须显式写WITH RECURSIVE,如果漏了RECURSIVE关键字,MySQL会直接报语法错误。这是为了区分普通CTE和递归CTE,避免程序员无意间写出失控递归。普通CTE和递归CTE在同一个语句里也可以混用,但只有被标注了RECURSIVE的那个允许自引用。

3. WITH在增删改查(DML)中的实战用法

3.1 用CTE优化INSERT语句

CTE不仅可以出现在SELECT前面,还可以和INSERT、UPDATE、DELETE配合使用。最常见的是INSERT INTO ... WITH ... SELECT结构:

WITH high_value_orders AS ( SELECT customer_id, SUM(total_amount) AS total_spent FROM orders GROUP BY customer_id HAVING SUM(total_amount) > 50000 ) INSERT INTO vip_customers (customer_id, total_spent, created_at) SELECT customer_id, total_spent, NOW() FROM high_value_orders;

这种写法最大的好处是:把"筛选逻辑"和"写入动作"分开了。如果没有CTE,INSERT后面直接跟一个巨长的子查询,别人看代码的第一眼根本不知道要把什么数据写进去。有了CTE,一眼就能看出是"先算高价值客户,再写入vip表"。

我在实际工作中做过一个数据分析项目的表结构设计,就是定期把用户行为数据进行分层汇总,然后写入对应的层级表。整个逻辑用三段CTE拼接,非常清晰,后来交接给其他同事时他很快就看懂了。

3.2 用CTE配合UPDATE实现复杂条件更新

UPDATE和CTE结合的场景,通常是把"要更新哪些记录"这个复杂逻辑抽出来。比如我们要把连续三个月消费下滑的客户标记为"流失预警"。先把目标客户集合用CTE算出来,再UPDATE:

WITH churned_candidates AS ( SELECT customer_id FROM consumption_monthly GROUP BY customer_id HAVING MAX(CASE WHEN month = '2024-04' THEN amount END) > MAX(CASE WHEN month = '2024-05' THEN amount END) AND MAX(CASE WHEN month = '2024-05' THEN amount END) > MAX(CASE WHEN month = '2024-06' THEN amount END) ) UPDATE customers c JOIN churned_candidates cc ON c.customer_id = cc.customer_id SET c.risk_level = 'warning', c.updated_at = NOW();

这里注意一个细节:MySQL在UPDATE里使用CTE时,不能直接写UPDATE churned_candidates SET ...,因为CTE本质是一个只读的派生结果集,不会对应真实的表。正确的做法是把CTE作为JOIN的数据源,去UPDATE物理表。

同理,DELETE也可以用CTE先圈定要删除的主键集合,再执行删除,避免在DELETE里写超复杂的 WHERE 子查询:

WITH expired_rows AS ( SELECT id FROM temp_logs WHERE created_at < NOW() - INTERVAL 90 DAY LIMIT 10000 ) DELETE FROM temp_logs WHERE id IN (SELECT id FROM expired_rows);

注意我在子查询里加了LIMIT 10000,这是为了避免一次性删除太多数据而锁表太久。在慢查询和线上故障排查的实践中,分批删除永远是更稳妥的方案。

3.3 CTE在窗口函数配合中的组合拳

MySQL 8.0同时引入了窗口函数和CTE,它们组合起来可以做很复杂的分组内分析。一个经典需求是:找出每个部门薪资排名前三的员工:

WITH ranked_employees AS ( SELECT employee_id, name, department_id, salary, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employees ) SELECT employee_id, name, department_id, salary FROM ranked_employees WHERE rn <= 3;

这里的思路是先用窗口函数给每个部门内的员工按薪资排序编号,然后再在CTE结果上做过滤。窗口函数不能直接在WHERE里使用,一定要包一层子查询,而CTE正好提供了这个"中间层"。如果没有CTE,你就得写一个双层嵌套的SELECT,可读性低得多。

我还见过一个时间序列去重的需求,取每个用户每个产品线的最后一次活跃记录,同样用CTE+窗口函数解决,比老式的GROUP BY + MAX(id) 技巧可靠得多,也不用担心时区边界问题:

WITH active_dedup AS ( SELECT user_id, product_line, active_time, ROW_NUMBER() OVER (PARTITION BY user_id, product_line ORDER BY active_time DESC) AS rn FROM active_logs ) SELECT user_id, product_line, active_time FROM active_dedup WHERE rn = 1;

窗口函数+CTE这套组合,基本可以覆盖日常90%的复杂分组分析需求。如果你的业务里经常需要"分组后取第N条""分组后求累计值",建议优先往这个方向想。

4. 性能真相与常见问题排查

4.1 CTE会提升查询性能吗

这是很多人最大的误解。CTE不会自动帮你优化执行计划——在MySQL里,CTE本质上会被内联展开或者物化,具体走哪条路径由优化器决定。对于大部分简单查询,CTE和子查询的执行计划几乎相同,性能没有本质区别。

但是有一种情况下CTE确实能提升性能:同一个CTE被引用多次时。MySQL可能把CTE的结果物化成一张临时表,避免重复计算。比如:

WITH avg_salary AS ( SELECT department_id, AVG(salary) AS avg_sal FROM employees GROUP BY department_id ) SELECT e.* FROM employees e JOIN avg_salary a ON e.department_id = a.department_id AND e.salary > a.avg_sal WHERE EXISTS (SELECT 1 FROM avg_salary a2 WHERE a2.department_id = e.department_id AND a2.avg_sal > 10000);

这里avg_salary被引用了两次,优化器可会权衡是否物化。不过别高兴太早,物化也有代价:如果CTE结果集很大,物化成临时表再扫描,反而可能比直接计算两次更慢。最优策略取决于数据量和优化器的判断,最靠谱的做法永远是看EXPLAIN分析,不要凭空猜性能走向。

4.2 递归CTE的性能陷阱

递归CTE慢的问题往往出在迭代次数过多或者每一层都要扫描全表。上面员工树的例子,如果employees表非常大且没有在manager_id上建索引,每层递归都要全表扫描,性能会非常难看。

对策有两个:一是在关联字段上建立索引,比如CREATE INDEX idx_emp_mgr ON employees(manager_id);二是尽量缩小锚点结果集范围,减少递归层数。比如全公司2万人,如果你只查一个小团队的子树,锚点从团队根开始,可能只要递归3层;如果从CEO开始,可能要递归10层以上。

还有一点:递归CTE默认的UNION ALL会保留重复行,如果你用UNION DISTINCT,会额外增加去重的开销。绝大多数树形查询场景里,如果你确信不会产生重复节点,用UNION ALL就行。

4.3 常见报错与排查思路整理

我把实际用CTE过程中常见的报错和对应解法整理成了一个速查表,希望能帮大家少走弯路:

报错信息出现原因解决思路
Recursive query aborted after 1000 iterations递归没有正确收敛,或迭代次数超限检查递归成员里有没有写终止条件;必要时调大cte_max_recursion_depth
Unknown table in WITH clauseCTE名称拼写错误或引用顺序不对检查CTE定义是否在前,靠后的CTE才能引用前面的
WITH RECURSIVE ... requires UNION递归成员没有使用UNION ALL/UNION递归CTE必须用UNION连接锚点成员和递归成员
Query rewritten successfully but no such columnCTE内部字段名和外部引用不一致检查CTE SELECT列表的字段别名,外部引用必须完全匹配
Can't reopen table在同一个查询里对同一张物理表使用了多次CTE关联给表起不同别名或者换用派生子查询变通

4.4 一个值得警惕的坑:CTE与外层同名表

我踩过最郁闷的一个坑:CTE的名字和外层查询的表名一样,导致引用歧义。比如:

WITH orders AS ( SELECT * FROM orders WHERE status = 'paid' ) SELECT * FROM orders;

这行得通吗?在MySQL里,这会报类似"无法在WITH里引用同名的表"的问题,因为CTE的名字遮蔽了物理表名,而CTE定义内部又引用了同名物理表,形成循环引用。解决办法是给物理表起别名:

WITH orders AS ( SELECT * FROM raw_orders WHERE status = 'paid' ) SELECT * FROM orders;

这个坑在把老SQL迁移到CTE写法时特别容易碰见,尤其是表名比较通用的时候,比如orders、users、logs这种。写CTE前先扫一遍当前查询里有没有同名表,能省不少事。

4.5 什么时候别用CTE

虽然我大力推荐CTE,但它也不是万能的。以下几种场景我会刻意绕开CTE:

  • 超大结果集反复引用:如果CTE的结果集有几百万行,又被同一条语句引用多次,物化临时表的开销可能让查询直接变慢。这种情况先物化成临时物理表,在临时表上建索引,再关联,往往更快。
  • 只需要用一次的简单子查询:硬套CTE反而多了一层结构,读起来不必要地绕。直接用子查询就行,别为了用而用。
  • 递归层数很深且中间需要排序:这种情况递归CTE效率很差,数据量大的话考虑改用存储过程或应用层处理。
  • 和MySQL 5.7以下版本兼容:CTE不是标准SQL在旧版本里的变通写法,如果目标环境是5.7,就别写CTE,否则上线直接语法错误。

5. 我的实战建议与扩展思考

聊完了用法、原理和坑,最后说几点我个人的心得。

CTE最大的价值不在性能,而是在代码的可读性、可维护性和团队协作效率上。我见过太多报表SQL写成"俄罗斯套娃"式的三层以上嵌套子查询,每次改需求都要重新梳理括号匹配。用CTE重写之后,每一层都有名字、有业务含义,新同事接手时对照着CTE名称就能讲出数据流转的完整故事。

我的标准写法是:按照业务分析的步骤,把SQL拆成3到5个CTE链,每个CTE负责一个明确的逻辑步骤。比如"先过滤基础数据,再聚合指标,再关联维度,再做排名,最后输出结果"。这样写出来的SQL几乎不需要额外注释,CTE的名字本身就是注释。

另外一个小技巧:CTE在开发调试的时候也很好用。你可以先只跑第一个CTE,确认这层的数据没问题,再逐步把后面的CTE一个个加上去。传统嵌套子查询没法这样渐进式调试,只能一次性写完再删改,效率差很多。

如果你正在做数据分析、报表开发、或者维护复杂的业务查询逻辑,我强烈建议把CTE用起来。尤其是遇到树形结构、序列生成、分组去重、多次引用同一结果集这些场景时,CTE几乎都是最优雅的解法。MySQL 8.0已经是一个非常成熟稳定的版本,生产环境完全可以用,不要在旧版本的泥潭里继续挣扎了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询