最近在排查一个线上问题:后台商品分类树一共四层,三千多个节点,就一个“查某个分类下所有子分类”的接口,耗时居然超过两秒。分类表用的就是最常见的 parent_id 邻接表,建了普通索引,逻辑上也就是一层层往下找,怎么想都不该这么慢。最后把问题分析到底,发现根本不是 SQL 写得不对,而是整张表的模型和索引设计从一开始就埋了雷。
树形表在 MySQL 里是个特别容易被低估的问题。很多人觉得无非就是 parent_id 关联自己,写完就完事了。可实际一上数据量、一上深度,各种递归查询慢、内存爆、死锁、锁等待就全来了。这篇文章我打算把树形表查询优化这件事从根上讲清楚,包括数据模型选型、MySQL 8.0 递归 CTE 的正确姿势、闭包表的取舍,以及几个真实业务场景里的建模决策。如果你正在为“层级分类/Menu 树/组织架构/评论回复”这类型查询发愁,这篇应该能帮你省不少弯路。
1. 邻接表为什么总在深层次查询上翻车:根因与索引盲点
1.1 一张 parent_id 表的问题远不是“多联一次”
邻接表是最自然的树形表:每个节点记录自己的 parent_id,根节点的 parent_id 为 NULL。它的优势是插入新节点只需要写一条记录,改父节点也就更新一个字段,结构非常直观。但查询一个子树的思路就不那么友好了,因为你要先找到第一层子节点,再用这一层的结果去找第二层,依此类推。
在 MySQL 5.7 及更早的版本里,没有递归查询原语,很多团队都是写存储过程或者用 PHP/Java 循环查询。这种逐层查询的本质就是 N+1 次 DB 往返:层数多、每层节点多,响应时间就是线性甚至加速恶化。我在那个商品分类案例里看到最极端的情况是,一个中间层节点下挂了 150 个直接子节点,每个子节点再往下找,又触发了 150 次查询,一条链路下来光 SQL 就有两百多次。
也许你会说:那我一次性查出全表,然后在内存里拼树,数据库压力不就小了?这确实是个常用土办法,但同样有瓶颈。全表数据少的时候没问题,一旦节点数到几十万、字段里再带些描述信息和图片 URL,单次全表查询的内存和网络开销就非常难受,而且每来一个请求都要重复载入一遍整棵树的原始数据,显然不是可持续方案。
所以邻接表的“病根”在于:它存的是点的连接关系,而不是路径。查询时你总是得沿着指针一步一步走,数据库没法用一条语句直接告诉你“某个节点下面所有后代”。
1.2 很多人真正栽在索引设计上
我见过大量项目,树形表建索引时只在 parent_id 上加了一个单列索引,然后觉得万事大吉。这能解决“找某个父节点的直接子节点”这一类查询,但面对递归查询,只靠这一层索引是完全不够的。
问题出在两个层面。第一,递归的每一层都会用 parent_id 去查子节点,如果父节点数量多而每个父节点的子节点很少,索引效率尚可;但如果树的形态是“宽树”,某个中间节点有几千上万个子节点,InnoDB 通过二级索引回表查一次也可能产生大量随机 IO。第二,如果查询条件里还要带排序,比如按 sort_no 排序展示子分类,那单独 parent_id 索引也帮不上忙,MySQL 需要把所有符合 parent_id 的行都拿到内存里 filesort。更好的做法是建立一个联合索引(parent_id, sort_no),让每层的子节点取出来时已经有序;如果还需要过滤status = 1,那么(parent_id, status, sort_no)这种索引顺序又会更好。这里没有银弹,要先看查询 pattern。
还有一个很容易被忽略的地方:parent_id 这一列不允许使用 NULL 去建索引的“陷阱”。InnoDB 是允许索引中有 NULL 的,但 WHERE parent_id IS NULL 想走索引时,优化器有时会把范围判断转成全表扫描,尤其当 NULL 占比很小时。经验做法是给根节点设置一个特殊值,比如 0,然后给 parent_id 加NOT NULL DEFAULT 0,这样所有查询条件都是一个干净的WHERE parent_id = ?,索引命中率会稳定很多。
1.3 判断代码是否慢,先看执行计划的“递归”打法
很多同学优化树查询一头雾水,上来就改 SQL,改来改去没效果。我的建议是先在 EXPLAIN 里看清楚每一层递归的驱动表在哪、访问方式是 index 还是 ALL、有没有 filesort。MySQL 8.0 以后可以直接用EXPLAIN ANALYZE,它会把每一层的实际执行时间和行数打出来,配合performance_schema这把尺子,你很快就能定位是“每一层 SQL 太多次”还是“单次查询全表扫”。
比如下面这两条查询,语义都是“查 id=100 的直接子节点”,但写法不同可能执行计划完全不同:
-- 走 parent_id 索引 SELECT * FROM category WHERE parent_id = 100; -- 不推荐:对每行做函数处理,索引失效 SELECT * FROM category WHERE CAST(parent_id AS CHAR) = '100';树形表优化在很多时候不是写出一个神奇 SQL,而是把每一层递归都变成“索引点查 + 尽量少的行数 + 避免回表”。理解了这句话,后面递归 CTE 怎么调,你心里才有谱。
2. 建表选型的天平:闭包表、路径枚举、嵌套集,到底谁更适合
2.1 四种模型一张表对比
邻接表只是树形存储的一种。做优化之前,先把模型选型的牌摊开看:
| 模型 | 表结构示例 | 查询子树 | 查询路径/祖先 | 插入 | 删除 | 移动 | 典型场景 |
|---|---|---|---|---|---|---|---|
| 邻接表 | id, parent_id | 递归逐层查 | 递归逐层上查 | 极快 | 需要递归清理 | 只需改父节点 | 深度较浅、读写均衡 |
| 路径枚举 | id, path(如 /1/3/7/) | LIKE 'path/%' | 取 path 内祖先 | 快 | 需要清理后代 | 要批量改 path | 评论楼中楼、定长路径树 |
| 嵌套集 | id, lft, rgt | 利用范围一次查 | 范围包含 | 慢(需调整多数节点) | 慢 | 极慢 | 几乎只读的稳定树 |
| 闭包表 | ancestor, descendant, depth | 一条 join | 一条 join | 需要批量插入 | 需要批量删除 | 很麻烦 | 读多写少、深层频繁查询 |
路径枚举的优点是查询路径非常快,比如“查某个节点的所有祖先”在邻接表里要循环好几轮,在路径枚举里可能只需要一个FIND_IN_SET或者把 path 拆出来就行。但它最大的坑是 path 长度有限,而且用 LIKE 做前缀匹配很难走常规索引。如果树的深度能控制在 10 层以内,每个层级 id 用定长数字(比如0000000111/0000000222/),再给 path 建一个升序前缀索引,还是能勉强应付的。但在我看来,路径枚举更适合“不多变、可预期层级”的场景。
嵌套集现在真的很少见了,因为它靠维护 lft/rgt 区间来代表树,插入一个节点可能影响几百个节点的序号,更新负担极其沉重。除非是那种数据量不大、极少写入、但查询特别频繁且要覆盖“子树范围”统计的场景,否则我不建议新项目使用嵌套集。
闭包表则是把“树关系”提前物化成一张路径表:任意两个有祖先—后代关系的节点之间都保存一条记录,同时记录 depth。它的查询可以完全脱离递归,代价是存储膨胀,节点数一旦上万,关系记录可能数十上百万。
2.2 我的选型口诀:看深度、看更新频率、看查询 pattern
模型选型从来不是数学上最优解的问题,而是业务行为匹配的问题。我自己的评估顺序是这样的:
- 先看树的深度。如果深度基本不超过 3 层,邻接表完全够用,别给自己加戏。
- 再看更新频率。节点插入、删除、迁移很频繁时,闭包表的维护成本会高到让你想砸键盘。
- 最后看查询 pattern。是“按父节点查直接子节点”多一点,还是“按节点查整棵子树”多一点?前者邻接表配合索引没问题,后者闭包表更甜。
举个例子:一个论坛的“圈子—版块—帖子”三级树,深度固定为 3,更新不频繁,但每页都要显示“当前版块属于哪个圈子”,用邻接表直接两次 join 就够了。如果是一个“联合分类查询某分类下全部商品”的后台系统,分类只有四层但商品要按整个子树汇总,邻接表每次递归几层性能很差,这时候闭包表的价值就体现出来了。
2.3 别看到复杂就绕开:一张报表树案例中的选择
去年我做过一个区域销售报表系统,区域表一共五层,大约八千多个节点,要求按任意层级收起/展开区域,并且每个层级实时汇总下属区域销售额。起初用的邻接表,从根节点查某个省的所有城市、再查城市下的区县,每次汇总要递归三层,区域多了以后报表接口频繁超过 5 秒。
当时有人提议上 Redis 缓存,缓存整棵树结构。但是汇总销售额没法全部缓存,因为销售额随时在变。最后还是换成闭包表:region 表只管区域基础信息,region_closure 表保存ancestor_id、descendant_id、depth。查询某省下所有区县汇总时,一条 join 就把所有下属区域 id 拿到,再和销售事实表 group by,报表接口从 5 秒降到了 300 毫秒。代价是进入新区域时,所有祖先都要往闭包表里插一条记录。好在区域变更频率低,每天最多几十次,完全能接受。这个案例很好地说明:优化树形查询,有时候要先改模型,而不是改 SQL。
3. 递归CTE的实战姿势:WITH RECURSIVE提速要点与深坑
3.1 从存储过程循环到递归CTE的演进
如果你维护过 MySQL 5.7 时代的项目,一定见过这种存储过程:定义一个临时表,把初始节点塞进去,然后用循环往临时表里插入子节点,直到没有新的子节点为止。逻辑上没错,但性能、可维护性都一般。MySQL 8.0 引入了WITH RECURSIVE,终于可以用一条标准 SQL 完成树形递归,代码也清爽了很多。
递归 CTE 的原理可以理解为:先查“种子”行,然后不断递归查询之前的输出结果,直到结果集不再变化。每次递归会把结果继续加入临时表,最终返回全部行。与存储过程循环相比,它的执行计划是数据库内部控制的,也更容易被优化器整体评估。
3.2 一条能用的递归查询长什么样
比如分类表有id、parent_id、name,想查 id=1 节点下的所有后代,MySQL 8.0 可以这样写:
WITH RECURSIVE category_tree AS ( SELECT id, parent_id, name, 1 AS depth FROM category WHERE id = 1 UNION ALL SELECT c.id, c.parent_id, c.name, ct.depth + 1 FROM category c INNER JOIN category_tree ct ON c.parent_id = ct.id ) SELECT * FROM category_tree;注意几个细节:
- 递归部分必须用
UNION ALL,不能用UNION,否则去重操作会带来额外开销,而且可能中断梳理层级。 - 初始部分必须能定位到根节点或起始节点,否则全表递归就是灾难。
depth列不是必须的,但建议保留,做层级缩进、计算最大深度都很方便。
这张 SQL 能够在一个语句内完成整棵子树的收集,再用外层查询做聚合、排序都更加灵活。相比存储过程,至少不会出现“连接断开导致临时表丢失”这种尴尬。
3.3 让递归CTE不慢的三个关键
第一,给递归路径上的两个字段都建立合适索引。递归 CTE 的执行通常是 layer-by-layer 的,每层都需要通过parent_id定位子节点,所以(parent_id, id)联合索引是基本配置。如果你也需要按排序字段展平,加入sort_no。
第二,留意递归深度上限。MySQL 默认的cte_max_recursion_depth是 1000,超过就报错“Recursive query aborted after 1001 iterations”。如果树的深度确实很大,可以全局调大,但更建议在数据库层面做防御性限制,写死 200 层还是 500 层,免得某条脏数据形成循环。注意不要让业务无限递归下去,树表中出现闭环(A 的父节点指向 B,B 的父节点又指向 A)是递归查询最怕的脏数据。我的做法是在写入时校验不能把父节点设为自己的后代,防止死循环。
第三,不要在递归体内出现开销很大的标量函数或类型转换。比如对parent_id做CAST,每层递归都会重复执行,索引就直接失效。递归体越简单,每层求值就越快。我还见过在递归体里直接做SUBSTRING拼接的,结果 200 层跑出来十几秒,把逻辑移到递归外部之后,耗时立刻掉到个位数毫秒级。
给一个我优化过的线上例子:一张组织表 200 万行,树深度平均 6 层。原先递归 CTE 查询某个大部门下全部员工用了 4.8 秒。排查后发现组织表的parent_id没有索引,而且递归体里做了WHERE status = 'active'但status没进索引,导致每层都要回表。改成索引(parent_id, status, id)之后,同样的查询耗时 0.9 秒,差别巨大。
3.4 旧版本MySQL的妥协方案
如果你还在维护 MySQL 5.7 或者更老的环境,没有递归 CTE 也不用慌,可以用临时表模拟“广度优先”遍历。常见姿势是这样的:
CREATE TEMPORARY TABLE tree_tmp ( id INT PRIMARY KEY, depth INT ) ENGINE = MEMORY; INSERT INTO tree_tmp VALUES (1, 0); REPEAT INSERT INTO tree_tmp (id, depth) SELECT c.id, t.depth + 1 FROM category c JOIN tree_tmp t ON c.parent_id = t.id WHERE c.id NOT IN (SELECT id FROM tree_tmp); UNTIL ROW_COUNT() = 0 END REPEAT;这里最关键的是循环终止条件:每次只插入“上一批新节点”的子节点,并用 NOT IN 去重。如果数据量大,NOT IN 子查询可能成为瓶颈,可以改用临时表做 left join 或增加插入标记。但无论如何,这只是过渡方案,能升 8.0 还是尽早升。
4. 闭包表的双刃剑:查询秒回,维护成本该怎么算
4.1 闭包表是“查询视角”的树
闭包表的核心思想是把“父子路径”全部存下来。假设有三级分类 A -> B -> C,则 closure 表里的记录就会包含:
| ancestor | descendant | depth |
|---|---|---|
| A | A | 0 |
| A | B | 1 |
| A | C | 2 |
| B | B | 0 |
| B | C | 1 |
| C | C | 0 |
这样查“A 的所有后代”就变成:
SELECT descendant_id FROM category_closure WHERE ancestor_id = 'A';如果要连混凝土的表去查名称,再 join 一次即可。查“C 的所有祖先”则变成:
SELECT ancestor_id FROM category_closure WHERE descendant_id = 'C';从索引设计上看,category_closure表的主键建议为(ancestor_id, descendant_id),同时给descendant_id也建一个索引。因为只需要覆盖两列,这个表非常紧凑,一条查询基本就是索引点查,速度快得离谱。
4.2 插入和删除的代价,其实是一次批量写
闭包表查询爽,维护的时候就要还债。插入一个新节点 X,假设它的父节点是 A,我们需要把所有“包含 A 的祖先关系”都复制一遍,再把 X 和自己的关系插进去。比如插入节点 X 作为 A 的子节点,SQL 大概是这样:
INSERT INTO category_closure (ancestor_id, descendant_id, depth) SELECT ancestor_id, 'X', depth + 1 FROM category_closure WHERE descendant_id = 'A' UNION ALL SELECT 'X', 'X', 0;注意这里从“A 的所有祖先”出发,为 X 建立到所有祖先的路径。如果树的深度是 5,插入一个新叶节点也不过写 6 条记录,成本并不高。真正麻烦的是删除。删除一个节点时,不仅要删除它自己,还要删除“所有以它为祖先的关系”,如果它有一棵大子树,可能删除上万条闭包记录。这个删除是 SQL 范围删除,虽然比逐条删快,但在大表上也是个不小的开销,而且必须和节点表放同一个事务里保证原子性。
4.3 如果树会移动,闭包表需要三思
闭包表最怕“移动节点”。把一个子树从 A 节点下移到 B 节点下,所有“该子树与外部节点”的关系都要调整:旧的祖先要删掉,新的祖先要插入,还要同步更新 depth。逻辑非常容易出错。
我遇到过把闭包表用在“组织架构”上的项目,总部每年要做一次大的组织调整,一个部门整体并到另一个部门下面,结果迁移脚本写了 200 多行 SQL,跑一次要几分钟,还出过两回数据不一致。后来我只能加了一个快照表,把所有闭包记录按版本号管理,才把事情兜住。所以如果你的树会频繁调整,“闭包表存量关系 + 快照版本”是必须要考虑的,否则盲目用闭包表就是给自己挖坑。
4.4 给闭包表上保险:触发器还是应用层事务
维护闭包表有两种做法:一种是完全靠业务代码在事务里先写主表再写闭包表,另一种是用 MySQL 触发器自动维护。我的建议是:能不在数据库里写太复杂的逻辑,就别写触发器。因为触发器一旦出现错误,排查起来很费劲,而且触发器里的 SQL 不容易做批量优化。
更稳的方案是:主表和闭包表在同一个数据库事务里,通过应用层显式事务保证一致性。比如插入节点,先 insert 主表拿到新 id,再执行那条为所有祖先生成路径的 insert 语句,最后 commit。这样逻辑清晰,还能在事务里加业务校验,比如检查父节点是否存在、是不是自己的后代。
如果真要用触发器,至少要把失败重试和幂等设计好,否则一次“半路失败”就让整张闭包表乱了。我在生产环境更倾向于写一个“重建闭包表”的兜底存储过程,定期从主表全量重建,用来对账和修复。毕竟闭包表是冗余数据,允许从主表重放重建,才有持续稳定的底气。
5. 真实业务里的“树”不止一颗:必须分开建模的场景
5.1 商品分类树:读多写少,闭包表是甜点区
商品分类树是我遇到最多的树形需求。它有几个明显特征:
- 分类数量通常不大,但附近每次范围查询都要把某个分支下所有商品带出来。
- 分类层级稳定,几年难得变一次。
- 读取频率远大于写入频率。
这种场景几乎就是闭包表的甜点区。我建议在做商品级联筛选时,不要只保存商品的直接 category_id,而是在商品表里冗余一个category_path_id或者“所属的最深分类闭包路径”,查询时用闭包表快速拉出所有子孙分类 id,再配合商品表的分区或索引去扫。这么做能避免把所有分类都拿出来在应用层拼树。
5.2 评论楼中楼:按时间排序的树,路径枚举更顺手
评论系统里经常需要显示“某条评论下的所有回复”,而且通常不是按树深度展示的,往往是按“楼层”聚合,最新的回复在最前面。这种场景用邻接表递归的话,既要维护父子关系又要全局排序,很容易头痛。
路径枚举的优势在这里很明显:每条评论保存一个path,比如根评论是/1/3/7/,它的回复就在path LIKE '/1/3/7/%'。因为需要按时间倒排序,我们还可以把 path 和创建时间和评论 id 拼接成“排序键”,虽然方案糙一点,但确实能绕开多级递归。更要紧的是,评论树的深度通常不高,路径长度可控,用 varchar(255) 完全够。
如果你希望查询更快,还可以在评论表里冗余一个root_id(最顶层父评论 id)。查询某条顶层评论下的所有回帖时,直接WHERE root_id = ?,这才是真正的“没有任何递归”。至于楼层结构,应用层拿数据后拼一下即可。这种方法简单、可控,强烈建议在评论等流量大的场景优先考虑。
5.3 组织架构树:频繁划转,邻接表+CTE其实够了
和商品分类不同,组织架构是一个非常“动态”的树。人员入职、离职、调岗、部门合并,每天都在发生。如果你硬上闭包表,每一次人员变动都可能带来闭包表批量更新,DBA 会很想打人。所以我的默认选择反而是邻接表,配合 MySQL 8.0 递归 CTE。
至于性能,组织架构表本身行数不会特别多,比如一个万人公司,部门节点也只有几百个。查询“某个 VP 名下的所有下级部门”用递归 CTE,几百个节点最多跑个三四层,索引设计合理的话,完全可以在 5ms 内返回。完全没必要为了这种小树用闭包表引火烧身。实战中我唯一建议的是,把人员放在另外一张表上,组织节点表只维护部门树,避免把每个员工都当成树节点,否则递归查询会扫大量数据。
5.4 BOM和权限树:从节点分裂到多父级问题
BOM(物料清单)和权限树一定要特别小心,因为它们的结构往往不是“一颗标准树”,而是“有向无环图”(DAG)。一个物料可能出现在多个父级产品里,一个权限也可能同时挂多个角色下。用 parent_id 表来存 DAG 会发生严重问题:一个节点有多个父节点,parent_id 字段根本存不下。
所以遇到 DAG,直接用闭包表是明智的,因为闭包表本身可以允许同一个 descendant 对应多个 ancestor 路径,它天然支持“多入口子节点”。权限继承查询里,SELECT ancestor_id FROM role_permission_closure WHERE descendant_id = ?就能把当前用户所有角色、所有权限祖先都取出来,再 join 权限表,一条 SQL 就能把权限全量拿到,比从前做多次递归甚至循环查询高一个量级。
但如果业务必须要树形结构且每个节点只能有一个父节点,那就老老实实检查数据的唯一性,不要因为偶尔出现脏数据就换模型。
6. 最后想说的:优化树形表,先把“树”看清楚
6.1 我踩过最亏的一个坑:把“树”当普通列表优化
前两年我接手过一个报表项目,最初的表只有id, parent_id, name,但通过“路径拼接”把多级分类渲染到了界面上。后来查询越来越慢,我一直在优化 SQL、加索引、改缓存,结果收效甚微。直到我画出业务树才发现,业务流程里其实不是把“一个分类节点”查出来,而是要把“该分类下几十万商品在五天内的每日汇总”一次算出来。这个时候不管你怎么优化parent_id的递归,本质都绕不过“范围展开”这层。后来我在商品表上冗余了path_ids字段,相当于把每个商品的完整分类链路都存了进去,再用FIND_IN_SET和 IN 去匹配,查询瞬间快了起来。
这个教训让我明白:先搞清楚树的“使用姿势”,再决定要不要把树“拍平”。拍平不一定非要用闭包表,很多时候在业务主表上加冗余字段,比在树表上死磕 SQL 更有效。
6.2 一套压测树形查询的最小Web工程
如果你是第一次搞树形表优化,我建议不要一上来就上生产数据。弄一个本地 MySQL 8.0,准备一张 10 万行的分类表,再准备一张 100 万行级联商品表,用递归 CTE 和闭包表分别去压测“任意节点查全子树”“任意节点查全链路”这两类最典型的查询。压测时重点看三件事:
EXPLAIN ANALYZE里各行 actual time 是否均匀,有没有哪一层突然变成全表扫描。- 频繁查询下,
SHOW GLOBAL STATUS LIKE 'Handler_read%'和Innodb_buffer_pool_reads是否异常。 - 如果用了闭包表,插入一批节点时事务耗时是否超过 100ms。
这种最小实验环境会帮你建立直觉:什么时候该用递归,什么时候该直接用闭包表。我甚至会把实验数据保留下来,作为团队评审 SQL 的基准参考。
6.3 给同行们的最后建议
试过这么多种方案之后,我已经不再迷信某一种模型了。一棵深度只有两三层的树,邻接表加两个普通索引就是你最快最省心的选择;一棵需要频繁整体查询、更新又极少的树,闭包表才是合理答案;一棵又深又变动频繁的树,往往不是表设计的问题,而是业务本身就应该重新组织数据模型,比如引入冗余路径、物化视图或者专用搜索服务。
我的实操习惯是:每张树形表都会带上path或root_id这类冗余字段,用来承接“最热”的查询,而不是把所有查询都压到递归 CTE 和闭包表身上。另外,所有递归查询都要加好最大深度保护,哪怕是开玩笑,也要防住“管理员把父节点改成了自己的子节点”这类回环灾难。
最后分享一个小技巧:只要树形表超过 10 万行,务必在测试环境里插一条“深度超过 15 层”的数据,跑一遍你的核心查询,看看数据库到底能扛到什么程度。很多项目就是在这种极端数据下才暴露真正的性能问题。提前打疫苗,总比线上多花两小时排查要划算得多。