写在前面
组织架构是那种"看起来十分钟就能做完,做下去发现全是细节"的功能。一张部门表,加个 parent_id,树不就出来了?
问题出在"用"上。通讯录要展示整棵树、权限要判断"我是不是这个部门的上级"、报表要统计"某个部门及其所有子部门的人"——每一样都在要求"快速拿到一棵子树",而裸的邻接表(只有 parent_id)对此一筹莫展:递归查询、应用层拼树、逐层下探,怎么写都别扭。
这篇记录我的方案:邻接表打底,物化路径(tree_path)加持,以及移动子树时的级联更新怎么做到不翻车。
一、先说结论:两个字段一起用
CREATE TABLE org_department (
id BIGINT PRIMARY KEY,
parent_id BIGINT,
dept_name VARCHAR(64),
dept_level INT,
tree_path VARCHAR(512),
...
);
parent_id 是逻辑关系的源头:找父部门、改归属、判断直接上下级,都是 O(1) 的一跳查询。
tree_path 是物化出来的全路径:格式是 /1/5/12/,从根到自己的完整 ID 链。建部门时拼出来:
private String buildTreePath(OrgDepartment parent, Long deptId) {
if (parent == null) {
return "/" + deptId + "/";
}
return parent.getTreePath() + deptId + "/";
}
dept_level 同步冗余深度(根是 0)。两个字段加起来,组织架构的高频查询全部变成了一次普通的索引扫描:
| 需求 | 邻接表 | 加了 tree_path |
|---|---|---|
| 某部门的所有子部门 | 递归 CTE 或逐层查 | WHERE tree_path LIKE '/1/5/%' |
| 判断 A 是否是 B 的后代 | 递归 | B.tree_path LIKE A.tree_path + '%' |
| 某部门的层级深度 | 数 parent 次数 | 读 dept_level |
| 面包屑(从根到当前) | 逐层向上查 | 按 / 切 tree_path 一次解析 |
物化路径的本质是用一点写入时的额外成本,买读取时的确定性。组织架构是典型的读多写少——一年改不了几次结构,但每天被通讯录、权限、报表查几万次,这笔账怎么算都划算。
二、真正的难点:移动子树
物化路径的代价在"改结构"时集中爆发:把一个部门从 A 移到 B,它自己和所有后代的 tree_path 都要重写。这是整个方案里最容易埋雷的地方,我的实现:
String oldTreePath = department.getTreePath();
String newTreePath = buildTreePath(parent, deptId);
Integer newLevel = parent == null ? 0 : parent.getDeptLevel() + 1;
...
department.setTreePath(newTreePath);
department.setDeptLevel(newLevel);
if (!oldTreePath.equals(newTreePath)) {
cascadeDepartmentTreePathChange(oldTreePath, newTreePath, oldLevel, newLevel, deptId, operatorId);
}
private void cascadeDepartmentTreePathChange(String oldTreePath, String newTreePath,
Integer oldLevel, Integer newLevel,
Long selfDeptId, Long operatorId) {
List<OrgDepartment> descendants =
departmentMapper.listByTreePathPrefixExcludingId(oldTreePath, selfDeptId);
int delta = newLevel - oldLevel;
for (OrgDepartment descendant : descendants) {
descendant.setTreePath(newTreePath + descendant.getTreePath().substring(oldTreePath.length()));
descendant.setDeptLevel(descendant.getDeptLevel() + delta);
descendant.setUpdatedBy(operatorId);
departmentMapper.updateById(descendant);
}
}
四个保证正确性的要点:
前缀替换,不是重建。 后代的新路径 = 新前缀 + 旧路径去掉旧前缀的部分。不是重新从 parent 一层层往下拼——后者要按层级排序逐个处理,前者是纯字符串手术,与遍历顺序无关。
level 是差值传播,不是重算。 子树的深度整体平移,delta = newLevel - oldLevel,每个后代的深度加同一个差值。避免了对每个后代各自"向上数父节点"的多余查询。
整个操作在同一个事务里。 本部门 + 全部后代 + 主管关系同步,要么全对要么全回滚。物化路径最怕的就是"父改了子没改"的中间态——那之后的所有 LIKE 查询都会返回错误结果,而且这种错不报错,只是悄悄不对。事务是唯一的防线。
结构变更后发事件。 更新完成、事务内最后一步是发布 OrgDepartmentStructureChangedEvent(旧父、新父、新旧主管都在事件里)。通讯录缓存、插件推送都靠这个事件感知变化——模块间协作用上了系列之前写的领域事件,这里不重复调用任何下游。
三、删除要保守:宁可不删
if (departmentMapper.countByParentId(deptId) > 0) {
throw new BizException(400, "部门存在子部门");
}
if (orgUserMapper.countByPrimaryDeptId(deptId) > 0) {
throw new BizException(400, "部门存在员工");
}
删除只允许作用于"空部门":没有子部门、没有在职员工。有员工的部门要删,先转移人员;有子部门的,先拆解子结构。
为什么不提供"级联删除整个子树"?因为部门不是孤立数据——员工的主属部门、审批链的路由、群成员的关系都挂在部门 ID 上。**级联删除树的代码一天就能写完,但它删掉的数据关系要在之后几个月里陆续以各种诡异 bug 的形式还回来。**对组织架构这种"牵一发动全身"的结构,保守的报错比聪明的级联安全十倍。
四、为什么不选另外两种方案
评估时认真考虑过两个备选,说说不选的理由:
纯邻接表 + MySQL 8 递归 CTE。 查询能力上完全够用,但每次"取子树"都要执行递归——通讯录是系统里最高频的页面之一,让首页依赖递归查询不舒服。而且递归深度上限、循环引用防护这些坑,物化路径方案天然免疫(路径是有限字符串)。
闭包表(closure table,存所有祖先-后代对)。 查询最优雅、移动子树也最简单,但存储膨胀严重:一棵 200 节点、深度 6 的树要存上千行关系。我的部门规模是几十个节点,为此维护一张膨胀表和它的同步逻辑,投入产出不成比例。
物化路径在"我的规模"下是甜点位:查询够快、写入可控、表结构简单。树形存储没有最好,只有和你的读写比例、数据规模最匹配——规模变了,方案就该重估。
五、小结
三点体会:
- 读多写少的树,尽管物化。 tree_path + dept_level 两个冗余字段,把"递归问题"降维成"字符串前缀问题"。写入时多付的那点成本,读取时千倍奉还。
- 移动子树的事务性是生死线。 前缀替换 + 差值传播 + 单事务,三件套缺一不可——物化路径的中间态错误不报错,只悄悄污染后续所有查询。
- 删除树节点,保守是美德。 只删空节点、报错引导用户先清理,看起来笨,实际上是把"删除的影响范围"控制在用户可理解的边界内。
现在通讯录页面打开的是一棵几百个节点的树,每次渲染是一次 LIKE 查询加一次内存组装。用户只觉得"这树展开还挺快"——这就够了。
默认评论
Halo系统提供的评论