parent_id、路径枚举与闭包表怎么选
后台系统里,菜单、部门、分类、权限资源都绕不开树形结构。很多系统一开始都会用 parent_id,因为它简单、直观、好维护。但一旦线上出现递归栈溢出、循环引用、查询变慢,就容易得出一个过度结论parent_id 不行,必须换闭包表。
这个结论并不准确。
树形结构的建模不是在选一个“高级方案”,而是在选择未来的复杂度落在哪里。小规模菜单用 parent_id 完全没问题,真正要补的是写入校验和查询兜底;大规模分类、组织架构、内容树才更适合闭包表。
这篇文章从 Spring Boot 后台菜单的场景出发,梳理三种常见建模方案,以及它们各自适合的边界。
问题通常不是 parent_id,而是循环引用
一个典型事故是这样的:
后台登录后白屏,服务端日志不断刷:
java.lang.StackOverflowError
at com.example.MenuService.buildTree(MenuService.java:88)
at com.example.MenuService.buildTree(MenuService.java:91)
at com.example.MenuService.buildTree(MenuService.java:91)
at com.example.MenuService.buildTree(MenuService.java:91)很多人第一反应是递归层级太深。但对于后台菜单来说,真正因为层级太深打爆 JVM 栈的概率很低。
后台菜单通常只有几层。真正危险的是数据形成了环:
A -> B -> C -> A
一旦某个节点的 parent_id 指向了自己的子孙节点,递归构建树时就会无限循环,直到栈被打爆。
所以这类问题的根因不是 parent_id 本身,而是没有在写入和查询两侧防住循环引用。
最少需要两道防线:
- 写入侧:修改父节点之前,确认目标父节点不是当前节点的后代。
- 查询侧:构建树时维护 visited 集合,发现重复访问立即中断。
只要这两道防线在,小规模菜单系统用 parent_id 是完全够的。
先判断你的菜单规模
在决定是否引入闭包表之前,先判断业务规模和查询模式。
大部分管理后台的菜单都属于前两类:节点数不多,登录后一次查全量菜单,再在内存里构建树。
这种场景下,一次 SELECT * FROM system_menu 加 O(n) 内存组装,通常已经足够快。引入闭包表反而会增加写入、移动、删除时的维护成本。
真正适合闭包表的通常是:
- 电商商品分类。
- 大型组织架构。
- 多层评论楼中楼。
- CMS 栏目树。
- 权限资源非常多且频繁做子树查询的系统。
方案一:邻接表 parent_id
邻接表是最常见的树形结构建模方式。每个节点只保存自己的父节点。
CREATE TABLE system_menu (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '菜单ID',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父菜单ID,0表示顶级',
menu_name VARCHAR(50) NOT NULL COMMENT '菜单名称',
type TINYINT NOT NULL COMMENT '类型:1目录 2菜单 3按钮',
sort INT NOT NULL DEFAULT 0 COMMENT '同级排序',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0未删除 1已删除',
INDEX idx_parent_id (parent_id)
) COMMENT '系统菜单表';优点:
- 表结构简单。
- 新增、修改、删除成本低。
- DBA 和开发都容易理解。
- 适合大部分后台菜单。
缺点:
- 查询任意子树需要递归。
- 不加防御时容易被循环引用打爆。
- 层级很深或节点很多时,递归查询体验较差。
写入侧防循环
修改父节点时,核心判断是:新的父节点不能是当前节点的后代。
对于邻接表,可以从目标父节点一路向上找父级。如果找到了当前节点,说明会形成环。
public boolean isInvalidParent(Long currentId, Long newParentId) {
Set<Long> visited = new HashSet<>();
Long cursor = newParentId;
while (cursor != null && cursor != 0) {
if (!visited.add(cursor)) {
return true;
}
if (Objects.equals(cursor, currentId)) {
return true;
}
cursor = menuMapper.selectParentId(cursor);
}
return false;
}查询侧防循环
构建树时也要兜底,避免历史脏数据把接口打爆。
private List<MenuTreeDTO> buildTree(Long parentId,
Map<Long, List<SystemMenu>> byParent,
Set<Long> visited) {
if (!visited.add(parentId)) {
log.error("菜单存在循环引用,节点ID:{}", parentId);
return Collections.emptyList();
}
return byParent.getOrDefault(parentId, Collections.emptyList())
.stream()
.map(menu -> {
MenuTreeDTO dto = toDTO(menu);
dto.setChildren(buildTree(menu.getId(), byParent, visited));
return dto;
})
.toList();
}小规模菜单系统parent_id + 写入校验 + 查询 visited + 缓存 是最稳妥的组合。
方案二:路径枚举
路径枚举会在每个节点上保存完整路径。
例如:
/1/2/3
查询某个节点的所有子孙时,可以使用:
SELECT *
FROM system_menu
WHERE menu_path LIKE '/1/2/%';优点:
- 查子树比纯 parent_id 直观。
- 查祖先路径比较方便。
缺点:
- 移动节点时,要批量更新所有子孙节点路径。
- 路径字段有长度限制。
- 路径更新容易出现并发一致性问题。
- LIKE 查询的索引利用有限。
路径枚举适合读多写少、层级稳定的老系统。如果是新系统,一般不建议优先选择它。
方案三:闭包表
闭包表会额外维护一张祖先和后代的关系表。
主表仍然可以保留 parent_id,关系表记录所有祖先后代关系:
CREATE TABLE system_menu_relation (
ancestor_id BIGINT NOT NULL COMMENT '祖先菜单ID',
descendant_id BIGINT NOT NULL COMMENT '后代菜单ID',
depth INT NOT NULL COMMENT '层级深度,自身=0,直接子节点=1',
PRIMARY KEY (ancestor_id, descendant_id),
INDEX idx_descendant (descendant_id),
INDEX idx_depth (depth)
) COMMENT '菜单关系表';假设有一条链:
1 -> 2 -> 3
关系表会存:
闭包表的本质是以空间换查询性能。
它适合:
- 频繁查询任意子树。
- 频繁查询祖先链。
- 节点规模较大。
- 查询性能比写入复杂度更重要。
它不适合:
- 菜单节点只有几十或几百个。
- 基本只查整棵树。
- 团队没有维护双表一致性的经验。
闭包表的四个核心动作
闭包表真正的复杂度在写入侧。新增、移动、删除都要同步维护关系表。
新增节点
新增节点时,需要插入:
- 自身到自身的关系。
- 父节点所有祖先到新节点的关系。
List<SystemMenuRelation> relations = new ArrayList<>();
relations.add(new SystemMenuRelation(newId, newId, 0));
relationMapper.selectByDescendant(parentId).forEach(parentRelation -> {
relations.add(new SystemMenuRelation(
parentRelation.getAncestorId(),
newId,
parentRelation.getDepth() + 1
));
});
relationMapper.batchInsert(relations);移动节点
移动节点前,先判断新父节点是否已经是当前节点的后代。
闭包表里这个判断只需要一条 SQL:
SELECT COUNT(*)
FROM system_menu_relation
WHERE ancestor_id = #{menuId}
AND descendant_id = #{newParentId};如果结果大于 0,说明移动后会形成环,必须拒绝。
移动时要先重建当前节点关系,再重建子节点关系。顺序不能反,因为子节点的祖先链依赖父节点的新关系。
rebuild(menuId):
删除当前节点旧关系
按当前 parent_id 重建当前节点关系
查询直接子节点
递归 rebuild(childId)
删除子树
闭包表查询子树很方便。
SELECT descendant_id
FROM system_menu_relation
WHERE ancestor_id = #{menuId};拿到所有后代 ID 后,可以批量删除主表,再删除关系表。
查询子树和祖先链
查所有子孙:
SELECT m.*
FROM system_menu m
JOIN system_menu_relation r ON m.id = r.descendant_id
WHERE r.ancestor_id = #{menuId}
AND r.depth > 0
AND m.deleted = 0
ORDER BY r.depth, m.sort;查直接子菜单:
SELECT m.*
FROM system_menu m
JOIN system_menu_relation r ON m.id = r.descendant_id
WHERE r.ancestor_id = #{menuId}
AND r.depth = 1
AND m.deleted = 0
ORDER BY m.sort;查祖先链:
SELECT m.*
FROM system_menu m
JOIN system_menu_relation r ON m.id = r.ancestor_id
WHERE r.descendant_id = #{menuId}
AND r.depth > 0
ORDER BY r.depth DESC;三种方案对比
生产环境容易踩的坑
循环引用检查不能省
不管用哪种模型,循环引用检查都必须做。
parent_id 不检查,会导致递归构建树时无限循环。
闭包表不检查,会污染关系表,而且污染范围比邻接表更大。邻接表通常只错一行,闭包表可能错一片祖先后代关系。
闭包表移动节点顺序不能错
移动节点时要先重建当前节点,再重建子节点。
如果先重建子节点,子节点会继承父节点的旧祖先链。这个错误通常不会立刻抛异常,但查询结果会悄悄变错。
idx_descendant 不能漏
闭包表主键 (ancestor_id, descendant_id) 只适合按祖先查后代。
如果要按后代查祖先,必须有 descendant_id 索引。
漏掉这个索引后,面包屑查询、循环检查、祖先链查询都会变慢。
缓存要和写入保持一致
后台菜单通常会加缓存。需要明确缓存失效时机:
- 新增菜单后失效。
- 修改父节点后失效。
- 修改排序后失效。
- 删除菜单后失效。
- 修改权限标识后失效。
缓存本身不是问题,问题是只缓存不设计失效策略。
推荐选型
如果你做的是普通管理后台菜单:
parent_id + 写入循环校验 + 查询 visited 兜底 + 菜单缓存
这是最简单、最容易维护、最适合团队协作的方案。
如果你做的是大型分类、组织架构、内容树:
主表 parent_id + 闭包表 + 事务维护关系 + 完整索引
这是更适合大规模查询的方案,但要接受写入侧复杂度。
如果你接手的是路径枚举老系统:
谨慎维护路径字段 + 控制移动节点频率 + 做好批量更新事务
不要轻易在路径枚举上继续堆复杂逻辑。如果系统还在快速演进,应该评估迁移到邻接表或闭包表。
结论
树形结构没有绝对正确的 schema。
parent_id 的问题通常不是它太弱,而是没有补齐循环引用检查和查询兜底。
闭包表也不是万能解法。它把查询复杂度转移到了写入侧,需要维护双表一致性、索引和移动节点重建顺序。
最终选择可以按一句话判断:
> 节点少、主要查整树,用 parent_id;节点多、频繁查任意子树和祖先链,用闭包表。
技术选型的关键不是用更复杂的方案,而是知道复杂度会落在哪里,并提前把防线放在那里。