MySQL 递归 CTE 组织树:终止条件、环检测与路径类型
组织树查询停不下来时,把递归深度从一千调到一万,通常只是让同一个数据问题跑得更久。父子表里只要有一个环,从某个节点往下走就可能反复回到已经经过的位置。
递归 CTE 应同时说明两条终止规则:业务最多允许走多深,当前路径是否已经遇到同一个节点。前者限制工作量,后者识别结构错误。它们不能互相替代。
先建立一棵可以核对结果的小树
本例使用 MySQL 8.4。自引用外键只保证父节点存在,不保证无环。
CREATE DATABASE org_walk_lab;
USE org_walk_lab;
CREATE TABLE org_unit (
id BIGINT NOT NULL PRIMARY KEY,
parent_id BIGINT NULL,
unit_name VARCHAR(40) NOT NULL,
KEY idx_parent (parent_id, id),
CONSTRAINT fk_org_parent FOREIGN KEY (parent_id)
REFERENCES org_unit(id)
) ENGINE=InnoDB;
INSERT INTO org_unit VALUES (1, NULL, 'Engineering');
INSERT INTO org_unit VALUES (2, 1, 'Backend'), (3, 1, 'Frontend');
INSERT INTO org_unit VALUES (4, 2, 'Database'), (5, 2, 'Platform');
先从指定节点 1 向下走。把显示路径宽度在锚点处明确展开,避免递归结果被最短的根名称限制。
WITH RECURSIVE tree AS (
SELECT id, parent_id, unit_name,
0 AS depth,
CAST(unit_name AS CHAR(400)) AS display_path
FROM org_unit WHERE id = 1
UNION ALL
SELECT c.id, c.parent_id, c.unit_name,
p.depth + 1,
CONCAT(p.display_path, '/', c.unit_name)
FROM tree AS p
JOIN org_unit AS c ON c.parent_id = p.id
WHERE p.depth < 10
)
SELECT id, parent_id, depth, display_path
FROM tree
ORDER BY display_path, id;
根节点深度为零,子节点依次增加。p.depth < 10 允许产生深度十的节点,但不会再从它往下扩展;如果业务把根定义为第一层,应同步调整解释和边界,不能只改显示数字。
递归产生记录的先后顺序不是界面展示顺序。最终 ORDER BY 才定义输出,别把某次客户端碰巧显示的层次顺序当成数据库永久保证。示例按显示路径排序适合阅读,不承担复杂的同级业务排序规则。
路径列为什么要在锚点扩大
递归 CTE 的列类型由非递归部分推导。锚点只选择原始短名称,后面不断 CONCAT,结果可能超出推导长度。在严格模式下表现为数据过长,不能靠递归部分再 CAST 一次补回锚点已经确定的类型。
本例 400 字符只是与样本和十层限制相容的演示容量,不是通用上限。真实名称可包含分隔符、长度也可能更长,路径列要有明确预算。如果仅需要祖先标识,不应为方便显示而携带大段名称文本参与每一步递归。
显示路径也不适合判断是否已经访问某个 id。字符串 1/11/111 直接用模糊搜索检查数字 1,会产生边界歧义;名称路径更可能重名。循环判断应基于稳定标识,并使用能保留元素边界的表达。
人为加一个环,观察外键为什么不阻止
UPDATE org_unit SET parent_id = 4 WHERE id = 1;
此时父节点 4 存在,因此外键检查并不会因为这条关系形成环就拒绝它。按子节点方向走,会出现 1→2→4→1。刚才带深度限制的查询终究会停,但会重复返回已经走过的节点。
一个结果只有十层,不表示这棵树最多十层。它可能被保护阈值截断,也可能因为环被反复展开。接口若直接返回这份结果并标记“全部下属”,就把不完整数据说成了完整结果。
下面在路径中携带 JSON id 数组,遇到已出现的节点时把该行标成环,然后停止从它继续扩展:
WITH RECURSIVE walk AS (
SELECT id, parent_id, unit_name,
0 AS depth,
JSON_ARRAY(id) AS path_ids,
0 AS is_cycle
FROM org_unit WHERE id = 1
UNION ALL
SELECT c.id, c.parent_id, c.unit_name,
p.depth + 1,
JSON_ARRAY_APPEND(p.path_ids, '$', c.id),
JSON_CONTAINS(p.path_ids, JSON_ARRAY(c.id))
FROM walk AS p
JOIN org_unit AS c ON c.parent_id = p.id
WHERE p.depth < 20 AND p.is_cycle = 0
)
SELECT id, parent_id, depth, path_ids, is_cycle
FROM walk
ORDER BY depth, id;
最后出现的重复节点保留下来,并带 is_cycle=1,因此诊断能看到闭环证据。若只把重复节点过滤掉,查询虽然不再死循环,调用者却可能不知道数据已经损坏。
这里的判断针对“当前路径”,不是全查询共享的已访问集合。对允许同一节点经不同路径到达的图,两个分支出现同一节点不一定是环;路径重复才说明这一条路径回到了自己。
为什么 UNION DISTINCT 不一定替你去环
如果递归结果只有节点 id,去重有机会限制重复;一旦结果同时带 depth 或 path,第二次到达同一个 id 时这一整行已经不同。UNION DISTINCT 对整行去重,不会替你理解“这些行代表同一个组织节点”。
本例每次绕环深度增加、路径数组增长,因此换一个集合操作符并不等于建立了正确环检测。更糟的是,它可能给人“已经用了去重所以安全”的错觉,让终止条件从评审里消失。
深度上限与服务器 cte_max_recursion_depth 也应分开看。业务上限用于定义可接受查询范围,服务器限制是运行保护。异常触及服务器上限时,应该检查数据与终止条件,不应把自动提高配置作为第一步。
如果确实需要遍历更深的合法层次,应先估计每层分支、路径载荷和总行数。十层每层只有一条边,与十层每层分叉十个节点,工作量完全不同。深度数字不能单独代表查询成本。
把截断和环作为显式结果
一个诊断接口至少需要区分正常完成、达到业务深度上限、发现循环。到达上限的节点是否还有子节点,可以再结合源表检查;如果仍有子节点,就不能声称已经返回完整子树。
对于交互页面,也未必需要一次递归取出所有后代。按父节点惰性加载可以限制每次读取范围,但不替代存储时的结构校验。组织移动操作尤其要防止把节点移到自己的后代下面,检查与写入还要考虑并发修改窗口。
本例的路径检测是读取防护,不会自动修复错误关系。确认环之后,应根据真实组织归属修正父节点,而不是随便删除一条边让 SQL 能跑完。修复记录应保留原关系和修改依据,便于解释权限继承或归属变化。
对权限计算这类敏感用途,遇到结构异常时要有明确失败策略。悄悄返回一部分节点,可能让“没有查到”被误认为“没有权限”或“没有下属”,从技术异常变成错误业务判断。
从所有根出发,未必能发现孤立的环
本例故意从已知 id=1 开始,而不是只用 parent_id IS NULL 选锚点。把根节点移到自己的后代下面以后,这个连通部分可能不再有空父节点;如果诊断程序只从所有合法根向下走,整片错误结构会直接不见,而不是返回一条明显的环记录。
所以,“从根走出的结果没有环”只证明已访问部分没有检测到环,不能证明表里每个节点都属于一棵合法树。数据核验还需要比较已访问节点集合与源表集合,找出从根不可达的节点,再对这些节点的父链做单独诊断。
同样,自引用外键存在也不等于每个节点都能到达根。它只验证一次引用的目标存在,对长链最终是否有根没有全局知识。组织导入工具若允许任意指定父节点,就应在导入验收里包含可达性,而不只是确认没有外键错误。
需要显示多棵树时,锚点可以携带根 id 贯穿递归,避免后续聚合把不同根下的结果混在一起。若数据模型保证每个节点只有一个父节点,可达范围比较就有清晰含义;如果模型允许多父节点,则应该按图的规则重新定义访问和重复结果。
恢复样本后,复验同一查询
UPDATE org_unit SET parent_id = NULL WHERE id = 1;
重新执行携带 path_ids 的 walk 查询,应不再出现 is_cycle=1。再核对节点集合与最初五条种子记录,确认修复没有意外丢掉分支。不要只看查询执行成功就结束,成功也可能是锚点条件写错,直接返回了空结果。
这个示例的父子索引让按 parent_id 扩展有合适入口,但它不保证任何规模下都便宜。需要完整遍历的大树,应观察实际行数与每步读取;反复输出大 JSON 路径也会增加开销。只有展示层需要路径名称时,可考虑在最终结果组织,而不是每一步重复拼接。
完成复验后删除独立库:
DROP DATABASE org_walk_lab;