组织架构树怎么存:物化路径的取舍与级联

SailTrack
2026-07-01
点 赞
0
热 度
6
评 论
0
  1. 首页
  2. 后端开发
  3. 组织架构树怎么存:物化路径的取舍与级联

写在前面

组织架构是那种"看起来十分钟就能做完,做下去发现全是细节"的功能。一张部门表,加个 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 的树要存上千行关系。我的部门规模是几十个节点,为此维护一张膨胀表和它的同步逻辑,投入产出不成比例。

物化路径在"我的规模"下是甜点位:查询够快、写入可控、表结构简单。树形存储没有最好,只有和你的读写比例、数据规模最匹配——规模变了,方案就该重估。

五、小结

三点体会:

  1. 读多写少的树,尽管物化。 tree_path + dept_level 两个冗余字段,把"递归问题"降维成"字符串前缀问题"。写入时多付的那点成本,读取时千倍奉还。
  2. 移动子树的事务性是生死线。 前缀替换 + 差值传播 + 单事务,三件套缺一不可——物化路径的中间态错误不报错,只悄悄污染后续所有查询。
  3. 删除树节点,保守是美德。 只删空节点、报错引导用户先清理,看起来笨,实际上是把"删除的影响范围"控制在用户可理解的边界内。

现在通讯录页面打开的是一棵几百个节点的树,每次渲染是一次 LIKE 查询加一次内存组装。用户只觉得"这树展开还挺快"——这就够了。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数