MySQL树形表查询优化:邻接表、递归CTE与闭包表实战
发布时间:2026/10/1 11:32:28 作者:尧图编辑部 阅读量:1,286

最近在排查一个线上问题后台商品分类树一共四层三千多个节点就一个“查某个分类下所有子分类”的接口耗时居然超过两秒。分类表用的就是最常见的 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 循环查询。这种逐层查询的本质就是 N1 次 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 太多次”还是“单次查询全表扫”。比如下面这两条查询语义都是“查 id100 的直接子节点”但写法不同可能执行计划完全不同-- 走 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想查 id1 节点下的所有后代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 的父节点指向 BB 的父节点又指向 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 表里的记录就会包含ancestordescendantdepthAA0AB1AC2BB0BC1CC0这样查“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 层”的数据跑一遍你的核心查询看看数据库到底能扛到什么程度。很多项目就是在这种极端数据下才暴露真正的性能问题。提前打疫苗总比线上多花两小时排查要划算得多。