☰
MySQL树形表查询优化:递归CTE、索引与闭包表实战
2026/9/30 8:11:56 网站建设 项目流程

做后台管理系统的时候,十有八九会遇到树形结构的数据:商品分类、部门组织架构、权限菜单、评论回复、地区字典……它们在表里长得都一样:一条记录带着一个parent_id,指向上级。这种结构设计简单、插入方便,但等树深了、节点多了,查询就暴露问题了——要么递归循环写一堆代码,要么一次性SELECT *出来在内存里拼树,数据量一上来就卡得没法看。

这篇文章就把 MySQL 树形表的查询优化这件事一次说透:从最常见的邻接表模型讲起,到递归 CTE 的正确写法,再到索引怎么建、闭包表怎么设计,最后附上我这些年踩过的坑和排查思路。适合正在用 MySQL 5.7 还想办法绕弯子、或者已经升到 8.0 想用好递归查询的人,也适合准备面试被问到"树形结构怎么存怎么查"的同学。

1. 树形表建模:先搞清楚你用的是哪种方案

很多人一上来就盯着 SQL 怎么写,其实树形查询慢的根子往往在建模上。表结构决定了你能用什么方式查、索引能不能生效、数据变更的成本高不高。所以第一步不是写查询,是盘点自己这张树表到底属于哪类模型。

1.1 邻接表——最常见也最容易踩坑的模型

所谓邻接表,就是每行存一个parent_id指向父节点,顶级节点的parent_id为 0 或 NULL。这是国内业务系统里最主流的做法,因为建表简单、插入一条数据只需要知道父节点 ID,删除子节点也直接DELETE就行。

CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, parent_id INT NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB;

邻接表的优点很直白:模型直观、写入快、事务控制简单。但它的查询痛点同样直白——查询一棵完整子树没有一条 SQL 能搞定。MySQL 5.7 及以前版本不支持递归查询,常规做法是写存储过程循环查,或者直接在应用层多次查询后组装。即便 MySQL 8.0 支持了递归 CTE,深层次数据查询依然依赖临时表,性能上限取决于树深度和数据总量。

另一个容易忽略的问题:邻接表对"移动子树"操作其实是很方便的,只要改一个节点的parent_id。但对"查询某节点的所有祖先"和"查询某节点的所有后代"这类高频操作,邻接表天然不友好。如果你发现业务里 70% 的查询都是"给我这个分类下所有子分类的商品数",那你应该认真考虑要不要换模型了。

1.2 进阶方案对比:路径枚举、嵌套集与闭包表

树形建模不止邻接表一种。做优化之前,最好把几个候选方案放在一张表里看清楚差异。

方案核心字段查询子树查询祖先插入成本修改成本适用场景
邻接表parent_id递归 CTE递归 CTE极低低数据量小、层级浅、写频繁
路径枚举path 如 "001/002/003"前缀 LIKE前缀 LIKE低高(改父节点要批量 UPDATE)层级固定、查询按路径排序
嵌套集left_num / right_num范围查询范围查询很高(需平移大量节点)极高高频读、低频写、层级固定
闭包表独立关系表索引 JOIN索引 JOIN较高(需批量插入关系)中(删除/移动需维护关系)大数据量、频繁查子树和祖先

我这里直接给结论:如果你的树表数据量在几千到几万节点、层级不超过四五层,邻接表 + 递归 CTE + 合理索引就足够了,别为了一点性能把架构搞复杂。数据量到了几十万节点且查询子树是核心场景,老老实实上闭包表。路径枚举适合"层级深度固定"的场景,比如三级分销、固定的省市县关联,这时候前缀查询 + 索引的效率非常稳定。嵌套集最不推荐做动态树,因为每插入一个节点就要更新一大片 left/right 值,在并发写入场景下很容易锁冲突。

1.3 方案选型的判断依据

我选方案只看三件事:节点量级、树的深度、读写比例。节点少于 10 万、深度低于 6 层、读多写多都有的,就邻接表顶住。节点几十万往上、深度能到十几层甚至无限层、并且读远多于写的,闭包表更合适。另外要注意,闭包表往往要配合一张原数据表一起用,单纯一张关系表拿不到节点名称,查询时 JOIN 方式要提前设计好。

2. 邻接表递归查询:从递归 CTE 入手

如果你的表就是邻接表并且数据量可控,MySQL 8.0 的递归 CTE 是首选方案,没有之一。MySQL 5.7 用户只能用存储过程模拟,或者升级 8.0,两条路二选一。新项目直接 8.0,老项目想办法说服业务方加索引,至少别在应用层搞递归循环——那是性能灾难。

2.1 递归 CTE 的写法与执行逻辑

递归 CTE 的语法结构分三块:锚点成员(seed)、递归成员(recursive member)、外层查询。锚点成员负责找到树根,递归成员负责一层层往下扩展,然后用UNION ALL把结果合并起来。

WITH RECURSIVE category_tree AS ( -- 锚点:查根节点 SELECT id, name, parent_id, 1 AS depth FROM category WHERE parent_id = 0 UNION ALL -- 递归:通过 parent_id 关联 CTE 结果集 SELECT c.id, c.name, c.parent_id, ct.depth + 1 FROM category c INNER JOIN category_tree ct ON c.parent_id = ct.id ) SELECT id, name, depth FROM category_tree ORDER BY depth, id;

要注意第一个SELECT是"种子",第二个SELECT会反复执行,直到某次 JOIN 查不到新数据为止。第二次 SELECT 里的category_tree指的是上一步刚产出的数据,而不是全量结果,这一点刚开始用很容易误解。执行过程可以理解成:先查根,再查根的子节点,再查子节点的子节点,一层一层往下走,每一层的结果都被写进临时表,直到没有新行产生。

有几个细节直接影响正确性和性能。第一,UNION ALL不要写成UNION,因为UNION会去重,可能把本应重复出现的节点干掉,而且去重本身有额外开销。第二,递归成员里 JOIN 的两侧字段必须类型一致,parent_id和id类型不一致的话,索引可能用不上,临时表也会变大。第三,外层ORDER BY是对最终全量结果排序,不是对每一层排序,如果只想让每一层内部有序,递归成员里就得加排序,但那开销更大,不如外层统一排。

2.2 递归的边界条件与深度控制

递归 CTE 有一个隐含的坑:如果数据里有环——比如 A 的父节点是 A 自己,或者 A 指向 B、B 又指向 A——递归会无限循环。MySQL 8.0 为了解决这个问题默认设置了递归上限,由cte_max_recursion_depth控制,默认 1000。超过这个迭代次数会直接报错ERROR 3636。所以哪怕数据坏掉了,查询也不会挂死,这一点要感谢数据库兜底。

-- 查看当前递归深度上限 SHOW VARIABLES LIKE 'cte_max_recursion_depth'; -- 会话级临时调大,不建议全局改 SET SESSION cte_max_recursion_depth = 100000;

我实测过 10 万节点的树,深度大概 10 层,默认 1000 的深度上限是够用的,因为它限制的是迭代轮数而不是节点数。但如果你的树是单链结构,也就是每个节点只有一个子节点,节点数 2000 就会触发 1000 上限。这时候要么调大这个变量,要么在查询里加一个显式的深度过滤条件:

WITH RECURSIVE category_tree AS ( SELECT id, name, parent_id, 1 AS depth FROM category WHERE parent_id = 0 UNION ALL SELECT c.id, c.name, c.parent_id, ct.depth + 1 FROM category c INNER JOIN category_tree ct ON c.parent_id = ct.id WHERE ct.depth < 20 -- 防止异常树导致过度递归 ) SELECT * FROM category_tree;

这种"限制深度"的条件是双保险。就算cte_max_recursion_depth没被调大,查询也会在 20 层停下来,避免生产环境出现一次错误的递归把数据库 CPU 打满。

3. 索引设计与查询改写实战

树形查询优化,索引占了至少一半的功劳。很多人的树表就只有一个主键索引和parent_id裸字段,查询子节点靠WHERE parent_id = ?,这种 SQL 在数据量上来之后必然全表扫描。千万别只看数据量小就忽略索引,树形表数据增长速度比想象中快得多。

3.1 索引怎么建:不只是 parent_id 加索引

parent_id加索引是基本操作,但真正好用的索引要考虑组合业务查询。比如电商分类的典型查询是"查某个父节点下的子分类,按排序值排好",那么(parent_id, sort_order)做复合索引比单列parent_id索引效率高很多,因为索引里已经包含排序字段,排序不用回表再做文件排序。

ALTER TABLE category ADD INDEX idx_parent_sort (parent_id, sort_order);

如果你业务里还经常按level或status过滤,比如"查某个父节点下所有启用状态的分类",可以考虑(parent_id, status, sort_order)。但要注意,不要盲目加一堆复合索引,树表如果写入频繁,索引过多会拖慢插入。我的习惯是:先收集慢查询日志里出现频率最高的几个 WHERE 组合,再针对性建索引,而不是一开始就铺满。

有些场景还会用到ORDER BY depth或者ORDER BY path,这种排序在递归 CTE 外层做,走不了索引。如果排序需求确实很重,考虑在物化路径或闭包表方案里解决,邻接表里强行排序没有太多优化空间。

3.2 查询改写:排序、分页、聚合场景的处理

递归 CTE 的结果是一个临时结果集,对它的排序和分页其实是在临时表上操作的。节点数多到一定程度,临时表会落到磁盘,性能立刻下降。这里有个经验:不要对全树做递归后再 LIMIT 分页,而是先把树的骨架算出来,再回表补业务字段。

举个例子。分类表里除了树结构还有商品数量统计字段product_count,页面要展示"包含商品数量最多的前 10 个叶子节点"。如果先递归出所有叶子再做聚合排序,整体开销很大。更好的方式是先通过parent_id查叶子节点,再用商品数排序:

WITH RECURSIVE category_tree AS ( SELECT id, parent_id, 1 AS depth FROM category WHERE parent_id = 0 UNION ALL SELECT c.id, c.parent_id, ct.depth + 1 FROM category c INNER JOIN category_tree ct ON c.parent_id = ct.id ) SELECT ct.id, ct.depth, cg.name, cg.product_count FROM category_tree ct INNER JOIN category cg ON cg.id = ct.id WHERE NOT EXISTS ( SELECT 1 FROM category child WHERE child.parent_id = ct.id ) ORDER BY cg.product_count DESC LIMIT 10;

这种"先递归骨架、再回表补数据"的写法,比在递归成员里疯狂 JOIN 业务表要快得多,因为递归部分只需要访问两个字段id和parent_id,内存占用小,临时表不容易落盘。

另一个常见的场景是"查某节点下的所有叶子节点聚合统计",比如统计整个分类树的商品总额。这个需求在有product_count字段后,可以改成先递归出后代 ID,再SUM商品数:

WITH RECURSIVE descendants AS ( SELECT id FROM category WHERE id = ? UNION ALL SELECT c.id FROM category c INNER JOIN descendants d ON c.parent_id = d.id ) SELECT SUM(cg.product_count) FROM descendants d INNER JOIN category cg ON cg.id = d.id;

这里把SUM放在递归之后做,MySQL 会把递归结果物化到临时表后再聚合。如果节点数上万,聚合速度依然不错,但要注意临时表大小。实际业务中,如果这个聚合查询极其频繁,我更建议直接在category表里冗余一个root_id字段,标记每个节点属于哪棵顶级树,把"整棵树统计"变成"按 root_id 分组统计",查询效率完全不在一个量级。

4. 大数据量下的闭包表实践

如果你的树表超过几十万节点,邻接表加递归 CTE 怎么优化都有限。递归的本质是逐层扫描,节点层级一深,扫描次数成倍增长。这时候闭包表是更靠谱的方案:用一张独立的表,把每一个祖先-后代关系都存下来。查询子树和祖先变成了纯索引查找,不再依赖递归。

4.1 闭包表的设计与查询优势

闭包表的核心是一张关系表category_closure,记录每个节点与其所有祖先的关系,同时包含节点自身到自身的关系(深度为 0)。

CREATE TABLE category_closure ( ancestor_id INT NOT NULL, descendant_id INT NOT NULL, depth INT NOT NULL, PRIMARY KEY (ancestor_id, descendant_id), KEY idx_descendant (descendant_id) ) ENGINE=InnoDB;

举个例子,A 是顶级节点,B 是 A 的子节点,C 是 B 的子节点。闭包表里会存在 A-A(深度 0)、A-B(深度 1)、A-C(深度 2)、B-B(深度 0)、B-C(深度 1)、C-C(深度 0)这六条关系。查询 C 的所有祖先,一句 SQL 就出来了:

SELECT ancestor_id, depth FROM category_closure WHERE descendant_id = C_ID ORDER BY depth DESC;

查询 A 的所有后代,同样简单:

SELECT descendant_id, depth FROM category_closure WHERE ancestor_id = A_ID;

和递归 CTE 相比,闭包表没有任何"逐层扩展"的过程,全部命中主键索引或二级索引。实测二十万节点、平均深度十五层的树,查询某个节点下所有后代的耗时尚且能控制在几十毫秒内,这在邻接表递归方案里基本做不到。

但闭包表也有代价。第一,数据量会膨胀。每条数据都会在闭包表里对应多条关系,整体数量级大约是节点数 × 平均深度。所以节点多但深度极浅的树,闭包表优势不明显;节点多且深度深,闭包表才是王者。第二,增删改需要同步维护关系表,不能单独只改主表。这就是很多人不敢用闭包表的真正原因——写逻辑变复杂了。

4.2 插入、删除、移动节点时的增量维护

闭包表的写入不能直接 INSERT 一条记录,插入一个新节点时,需要同时给它和它的所有祖先建立关系。正确做法是查一遍当前新节点的父节点的所有祖先,然后批量插入。

INSERT INTO category_closure (ancestor_id, descendant_id, depth) SELECT ancestor_id, NEW_ID, depth + 1 FROM category_closure WHERE descendant_id = PARENT_ID UNION ALL SELECT NEW_ID, NEW_ID, 0;

这条 SQL 的原理:父节点的所有祖先也是新节点的祖先,但深度都要加一;同时新节点自己到自己的关系单独插入。如果没有闭包表,这一步就是一个简单的INSERT INTO category ... VALUES (...),有了闭包表就变成了两条 SQL 的组合,而且必须在事务里执行,保证主表和关系表一致。

删除节点的时候,要做两件事:物理删除或者逻辑删除主表记录,然后删除闭包表里所有与这个节点相关的行。

DELETE FROM category_closure WHERE descendant_id = DELETED_ID OR ancestor_id = DELETED_ID;

如果删除的是父节点且希望子节点一并删除(级联删除),闭包表会复杂很多。我的建议是生产环境尽量不要用物理级联删除树,改为逻辑删除标志位,然后在应用层处理子树的状态变更,这样闭包表维护成本可控。

移动节点是闭包表最繁琐的操作。把一个子树从 A 节点移到 B 节点下,需要先把子树相关关系全部删除,再按新路径重新生成。这里面稍有不慎就会留下孤儿关系。实践中我把这个逻辑封装成存储过程,在事务里分步执行,每一步都有ROW_COUNT()校验,任何一步不匹配就ROLLBACK。

DELIMITER // CREATE PROCEDURE move_category(IN node_id INT, IN new_parent_id INT) BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 删除节点自身与其所有祖先的关系 DELETE FROM category_closure WHERE descendant_id IN ( SELECT descendant_id FROM ( SELECT descendant_id FROM category_closure WHERE ancestor_id = node_id ) tmp ) AND ancestor_id NOT IN ( SELECT ancestor_id FROM ( SELECT ancestor_id FROM category_closure WHERE descendant_id = node_id ) tmp2 ); -- 重新建立新路径关系,逻辑同插入 INSERT INTO category_closure (ancestor_id, descendant_id, depth) SELECT c1.ancestor_id, c2.descendant_id, c1.depth + c2.depth + 1 FROM category_closure c1, category_closure c2 WHERE c1.descendant_id = new_parent_id AND c2.ancestor_id = node_id; -- 更新主表 parent_id UPDATE category SET parent_id = new_parent_id WHERE id = node_id; COMMIT; END// DELIMITER ;

闭包表的维护逻辑一定要和业务写入封装在一起,不要散落在各个 service 方法里,否则早晚会出数据不一致的问题。我见过不止一次线上事故,都是因为在某个角落直接 INSERT 了 category 表,忘了同步闭包表,导致整棵树的查询结果缺胳膊少腿。

5. 常见问题与排查技巧实录

树形表优化的坑,写代码是一回事,上线跑起来又是另一回事。这一部分是我自己排查慢查询和数据异常时总结的经验,不一定每个都与你遇到的一致,但大概率能提供思路。

5.1 递归查询超时卡死怎么查

现象是页面打开分类管理转圈,数据库 CPU 飙到 100%。第一反应先看是不是递归上环了。如果数据里存在 A 指向 B、B 又指向 A 的环,递归 CTE 会反复迭代直到cte_max_recursion_depth上限,这期间临时表越来越大,CPU 和内存都被吃光。

排查方法是在测试环境复现后跑一下 EXPLAIN:

EXPLAIN ANALYZE WITH RECURSIVE category_tree AS ( SELECT id, parent_id, 1 AS depth FROM category WHERE parent_id = 0 UNION ALL SELECT c.id, c.parent_id, ct.depth + 1 FROM category c INNER JOIN category_tree ct ON c.parent_id = ct.id ) SELECT * FROM category_tree;

看actual time和rows是不是按指数级增长。正常树的节点数增长是收敛的,有环时会一直翻倍直到报错。另外可以检查performance_schema的语句事件表,找出对应语句消耗的资源是否集中在Creating temp table阶段。

另一个常见原因是递归成员查询没有走索引。parent_id如果没建索引,每一层递归都要全表扫描,数据量稍大就原地爆炸。这种问题 EXPLAIN 里能看到type: ALL。加上索引后应该变成ref或者eq_ref。

如果确认没环、索引也建了,还是慢,那就是临时表落盘了。递归 CTE 的中间结果会写到临时表,节点数太大时临时表从内存转磁盘,性能骤降。查看SHOW STATUS LIKE 'Created_tmp_disk_tables'如果数值在查询前后明显增加,就说明落盘了。这种情况下要么调整tmp_table_size和max_heap_table_size(只对会话级有效,全局改有风险),要么换闭包表。

5.2 树结构数据维护的几个深坑

先说自关联外键。很多人建表时给parent_id加了外键约束,这在树形结构里相当危险。删除一个父节点时,如果还有子节点引用它,外键约束直接报错;如果级联删除,一不小心删掉一整棵子树。生产环境我强烈建议去掉外键约束,只在应用层做逻辑校验,必要时用定时任务扫描孤儿节点。

再说批量导入数据。从 Excel 导入分类树时,数据顺序有时候是打乱的,父子关系可能还没建立就插入了子节点,这时候parent_id指向的记录不存在。导入脚本必须做两层校验:先插所有根节点,再插子节点,或者导入完成后跑一条"找孤儿"的 SQL:

SELECT c.id, c.name, c.parent_id FROM category c LEFT JOIN category p ON p.id = c.parent_id WHERE c.parent_id != 0 AND p.id IS NULL;

这条 SQL 也适合做日常巡检,我习惯放到每周的定时任务里,发现孤儿就告警,避免问题积累到最后连修复都无从下手。

第三个坑是深度字段的冗余。如果你在 category 表里冗余了level或depth字段,插入和移动节点时要记得同步更新。这个字段一旦不一致,前端展示的层级缩进就会错乱,而且很难排查。为了避免这种情况,我一般不在邻接表里冗余 depth,真的需要深度信息就现场用递归 CTE 算,或者用闭包表的关系表里现成的 depth 字段。

5.3 实用排查命令与建议速查

整理一个速查表,遇到对应问题直接照着做。

症状可能原因排查方式解决方案
递归查询报错 3636数据成环或树过深查日志定位报错语句修复数据环,或临时调大 cte_max_recursion_depth
查询越来越慢parent_id 无索引EXPLAIN 看 type=ALL给 parent_id 建索引,或建复合索引
分类树缺节点闭包表漏维护对比主表和闭包表数量重建闭包表,或触发增量修复任务
子节点找不到父节点导入顺序错乱跑孤儿检测 SQL修正导入顺序,补全缺失父节点
移动节点后数据混乱闭包表删除不全检查关系条数回滚后使用封装好的存储过程
临时表落盘导致慢查询节点数过大SHOW STATUS 查临时表落盘次数优化递归范围或迁移闭包表

闭包表如果历史数据已经乱了,最稳的修复方式是"全量重建"。停写窗口内,清空闭包表,然后用递归 CTE 遍历主表所有节点重新生成关系。节点量大时重建很耗时,需要提前规划好窗口。我的经验是一百万节点的树,重建闭包表大概需要几分钟到十几分钟,具体看服务器磁盘速度。

最后说一句我自己的实际体会。树形表的优化方案不是越高级越好,而是越匹配业务场景越好。我做过一个核心链路是"查整棵后代树"的数据系统,邻接表怎么调都差一口气,换成闭包表后查询从秒级降到毫秒级,收益巨大。但另一个项目树节点只有几百个,用闭包表反而让代码变复杂,后来回退到邻接表,简单又稳定。如果要在新项目里做设计,我的建议是:默认用邻接表把业务跑通,等数据量真正涨上来了,再评估是否引入闭包表或物化路径,并且一定要用压测数据验证,不要凭感觉拍板。毕竟方案换了,周边所有代码逻辑都要跟着动,这个成本往往比大多数人预想得高。

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

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

立即咨询