跳到主要内容

闭包表的实现

· 阅读需 10 分钟

为什么要用闭包表?你来看这个:

Spring Boot+MyBatis构建无限层级组织架构设计|邻接表vs闭包表性能对比|树形结构资料存储方案 - jzssuanfa - 博客园

闭包表

闭包表(Closure Table)的核心思想其实非常简单:把“谁是谁的子孙”这个需要递归计算的关系,提前算好并存成一张扁平的二维表。

查询时,你不再需要“找爸爸、找爷爷、找太爷爷”,而是直接在这张表里问:“谁是 X 的所有后代?”或者“X 属于哪些祖先的管辖范围?”。

CREATE TABLE system_dept_closure (
ancestor_id BIGINT NOT NULL COMMENT '祖先部门ID',
descendant_id BIGINT NOT NULL COMMENT '后代部门ID',
depth INT NOT NULL COMMENT '层级深度(0表示自己)',
PRIMARY KEY (ancestor_id, descendant_id),
INDEX idx_descendant (descendant_id)
) ENGINE=InnoDB COMMENT='部门闭包表';

数据规则:

例如:部门 A(id=1) 下有 B(id=2),B 下有 C(id=3)。则表中包含:(1,1,0), (1,2,1), (1,3,2), (2,2,0), (2,3,1), (3,3,0)。

不太理解,举个例子?

  1. 假设有一个这样的部门树

部门架构:

总公司 (id=1)
├── 技术中心 (id=2)
│ ├── 后端组 (id=4)
│ └── 前端组 (id=5)
└── 市场部 (id=3)
└── 华东大区 (id=6)
  1. system_dept_closure 表长这样
ancestor_id (祖先)descendant_id (后代)depth (距离)含义解读
110总公司是自己的祖先(距离0)
121总公司是技术中心的父级(距离1)
131总公司是市场部的父级(距离1)
142总公司是后端组的祖父级(距离2)
152总公司是前端组的祖父级(距离2)
162总公司是华东大区的祖父级(距离2)
220技术中心是自己
241技术中心是后端组的父级
251技术中心是前端组的父级
330市场部是自己
361市场部是华东大区的父级
440后端组是自己
550前端组是自己
660华东大区是自己
💡关键理解

这张表一共有 14 行。每个节点都会有一条 depth=0 的记录指向自己。任意两个有血缘关系的节点之间,都有且仅有一行记录。

  1. 场景下怎么用

场景 A:查某个部门及其所有子部门的公告(向下查)

“技术中心(id=2) 发了一份公告,后端组和前端组都能看到”

-- 查出技术中心管辖的所有部门ID
SELECT descendant_id FROM system_dept_closure WHERE ancestor_id = 2;
-- 结果: 2, 4, 5 ✅ 正好是技术中心 + 后端组 + 前端组

然后拿这三个 ID 去关联公告可见范围表做分页,无需任何递归。

场景 B:查当前用户能看到哪些公告(向上查)

“后端组(id=4) 的员工登录系统,他能看到哪些公告?”

-- 查出后端组的所有祖先(即所有对他可见的公告发布方)
SELECT ancestor_id FROM system_dept_closure WHERE descendant_id = 4;
-- 结果: 1, 2, 4 ✅ 总公司发的、技术中心发的、后端组自己发的,都能看到

场景 C:只查直接子部门(不包含孙子辈)

SELECT descendant_id FROM system_dept_closure
WHERE ancestor_id = 1 AND depth = 1;
-- 结果: 2, 3 ✅ 只有技术中心和市场部,不包含后端组等

场景 D:判断两个部门是否有上下级关系

-- 判断总公司(1)是否是华东大区(6)的上级
SELECT COUNT(*) FROM system_dept_closure
WHERE ancestor_id = 1 AND descendant_id = 6;
-- 结果 > 0 → 是上级;结果 = 0 → 没有隶属关系
  1. 部门变动时怎么维护?

这是闭包表唯一的“代价”,但逻辑很固定:

操作维护方式
新增部门新节点插入一条 (self, self, 0);再把它父节点的所有祖先记录复制一份,descendant_id 改为新节点,depth+1
删除部门删除所有 descendant_id = 被删节点 以及 ancestor_id = 被删节点 的记录(级联删除子树)
移动部门先断开旧父节点链路,再接上新父节点链路(本质是删旧+插新的组合)
  1. 一句话总结

闭包表就是把“树”拍成了“表格”。写入时多花一点力气把关系算好存下来,读取时就把复杂的树遍历变成了简单的 WHERE ancestor_id = ? 索引查询。对于公告这种 “读远多于写” 的场景,这笔交易非常划算。

生成闭包表数据

直接执行即可生成闭包表数据。

  • cte_max_recursion_depth:防止无限递归‌:当递归逻辑存在环状结构(如 A→B→C→A)或终止条件缺失时,该设置能强制在达到 50 层后报错停止,避免数据库陷入死循环或资源耗尽 。

  • system_dept_closure 闭包表名。

  • system_dept 闭包表维护的表(d.id,d.parent_id不多说)

SET SESSION cte_max_recursion_depth = 50;

INSERT INTO system_dept_closure (ancestor_id, descendant_id, depth)
WITH RECURSIVE dept_tree AS (
SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth
FROM system_dept WHERE deleted = b'0'
UNION ALL
SELECT dt.ancestor_id, d.id, dt.depth + 1
FROM dept_tree dt
INNER JOIN system_dept d ON d.parent_id = dt.descendant_id
WHERE d.deleted = b'0' AND dt.depth < 30
)
SELECT ancestor_id, descendant_id, depth FROM dept_tree;

如何维护闭包表数据?

抄作业!🐕‍🦺

为了保证数据绝对一致,所有操作必须在同一个数据库事务中完成。

抄作业
  • Service
public interface DeptClosureService {

/**
* 【新增部门】创建部门后调用
* @param newDeptId 新部门ID
* @param parentId 父部门ID(根节点传0或null)
*/
void onDeptCreate(Long newDeptId, Long parentId);
/**
* 【删除部门】逻辑删除部门前/后调用
* ⚠️ 注意:此方法仅删除单个节点的闭包关系
* 如果业务是级联删除子树,需要对每个子节点都调用此方法
* @param deptId 被删除的部门ID
*/
void onDeptDelete(Long deptId);
/**
* 【移动部门】修改部门parent_id时调用
* @param movedDeptId 被移动的部门ID
* @param newParentId 新的父部门ID(移到根节点传0或null)
*/
void onDeptMove(Long movedDeptId, Long newParentId);

}
  • ServiceImpl
@Service
@Validated
@Slf4j
public class DeptClosureServiceImpl implements DeptClosureService {

@Resource
private DeptClosureMapper closureMapper;



/**
* 【新增部门】创建部门后调用
* @param newDeptId 新部门ID
* @param parentId 父部门ID(根节点传0或null)
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.MANDATORY)
public void onDeptCreate(Long newDeptId, Long parentId) {
// 1. 插入自引用 (depth=0)
closureMapper.insertSelf(newDeptId);

// 2. 如果有父节点,复制父节点的整条祖先链路
if (parentId != null && parentId != 0L) {
closureMapper.copyAncestorChain(newDeptId, parentId);
}

log.info("闭包表新增完成: deptId={}, parentId={}", newDeptId, parentId);
}

/**
* 【删除部门】逻辑删除部门前/后调用
* ⚠️ 注意:此方法仅删除单个节点的闭包关系
* 如果业务是级联删除子树,需要对每个子节点都调用此方法
* @param deptId 被删除的部门ID
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.MANDATORY)
public void onDeptDelete(Long deptId) {
closureMapper.deleteByDescendant(deptId);
closureMapper.deleteByAncestor(deptId);

log.info("闭包表删除完成: deptId={}", deptId);
}

/**
* 【移动部门】修改部门parent_id时调用
* @param movedDeptId 被移动的部门ID
* @param newParentId 新的父部门ID(移到根节点传0或null)
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.MANDATORY)
public void onDeptMove(Long movedDeptId, Long newParentId) {
// 0. 防御性校验:不能把自己移到自己子树下(会造成死循环)
if (newParentId != null && newParentId != 0L) {
int count = closureMapper.existsRelation(movedDeptId, newParentId);
if (count > 0) {
throw new IllegalArgumentException("不能将部门移动到其自身的子部门下");
}
}

// 1. 分离旧树(剪断旧祖先与被移动子树的连线)
closureMapper.detachSubtree(movedDeptId);

// 2. 组合新树(仅当新父节点有效时)
if (newParentId != null && newParentId != 0L) {
closureMapper.attachSubtree(movedDeptId, newParentId);
}

log.info("闭包表移动完成: movedDeptId={}, newParentId={}", movedDeptId, newParentId);
}
}
  • Mapper/Repository
@Mapper
public interface DeptClosureMapper {

void insertSelf(@Param("deptId") Long deptId);

void copyAncestorChain(@Param("newDeptId") Long newDeptId, @Param("parentId") Long parentId);

void deleteByDescendant(@Param("deptId") Long deptId);

void deleteByAncestor(@Param("deptId") Long deptId);

void detachSubtree(@Param("movedDeptId") Long movedDeptId);

void attachSubtree(@Param("movedDeptId") Long movedDeptId, @Param("newParentId") Long newParentId);

int existsRelation(@Param("potentialAncestor") Long potentialAncestor, @Param("potentialDescendant") Long potentialDescendant);

}
  • Mapper.xml
<mapper namespace="cn.iocoder.yudao.module.system.dal.mysql.dept.DeptClosureMapper">



<!-- ==================== 新增部门 ==================== -->
<!-- 1. 插入自引用记录 -->
<insert id="insertSelf">
INSERT INTO system_dept_closure (ancestor_id, descendant_id, depth)
VALUES (#{deptId}, #{deptId}, 0)
</insert>

<!-- 2. 复制父节点的祖先链路 -->
<insert id="copyAncestorChain">
INSERT INTO system_dept_closure (ancestor_id, descendant_id, depth)
SELECT ancestor_id, #{newDeptId}, depth + 1
FROM system_dept_closure
WHERE descendant_id = #{parentId}
</insert>



<!-- ==================== 删除部门 ==================== -->
<!-- 删除该节点作为后代的所有记录(断开与祖先的联系) -->
<delete id="deleteByDescendant">
DELETE FROM system_dept_closure WHERE descendant_id = #{deptId}
</delete>

<!-- 删除该节点作为祖先的所有记录(断开与子孙的联系) -->
<delete id="deleteByAncestor">
DELETE FROM system_dept_closure WHERE ancestor_id = #{deptId}
</delete>



<!-- ==================== 移动部门 ==================== -->
<!-- 分离旧树:仅剪断跨越边界的连线,保留子树内部结构 -->
<delete id="detachSubtree">
DELETE c
FROM system_dept_closure c
INNER JOIN system_dept_closure sub ON c.descendant_id = sub.descendant_id
WHERE sub.ancestor_id = #{movedDeptId}
AND c.ancestor_id != sub.ancestor_id
</delete>

<!-- 组合新树:新祖先链路 × 被移动子树 = 新连线 -->
<insert id="attachSubtree">
INSERT INTO system_dept_closure (ancestor_id, descendant_id, depth)
SELECT
new_anc.ancestor_id,
sub_tree.descendant_id,
new_anc.depth + sub_tree.depth + 1
FROM system_dept_closure new_anc
CROSS JOIN system_dept_closure sub_tree
WHERE new_anc.descendant_id = #{newParentId}
AND sub_tree.ancestor_id = #{movedDeptId}
</insert>

<!-- 防环检测:检查 potentialDescendant 是否是 potentialAncestor 的后代 -->
<select id="existsRelation" resultType="int">
SELECT COUNT(1)
FROM system_dept_closure
WHERE ancestor_id = #{potentialAncestor}
AND descendant_id = #{potentialDescendant}
</select>

</mapper>

怎么用

  1. 调用它的方法,必须在事务里。@Transactional(rollbackFor = Exception.class)

  2. 上面的 onDeptDelete 只删单个节点。如果你的业务是"删除父部门时连带删除所有子部门",需要在遍历子树时对每个子节点 ID 都调用一次 onDeptDelete,或者写一个批量删除的 SQL。(建议有子节点的不允许删除,防止出现悬浮节点)

  3. 如果系统支持多人同时调整组织架构,建议在 onDeptMove 上加分布式锁 lock:dept:move:{movedDeptId},防止两次移动交叉执行导致闭包表错乱。

@Service
@RequiredArgsConstructor
public class SystemDeptServiceImpl implements ISystemDeptService {

private final SystemDeptMapper deptMapper;
private final DeptClosureService closureService; // 👈 注入闭包服务

@Override
@Transactional(rollbackFor = Exception.class)
public void createDept(SystemDept dept) {
deptMapper.insert(dept);
// 👇 紧接着维护闭包表(同一事务)
closureService.onDeptCreate(dept.getId(), dept.getParentId());
}

@Override
@Transactional(rollbackFor = Exception.class)
public void deleteDept(Long deptId) {
// 先逻辑删除部门本身
deptMapper.updateDeletedById(deptId);
// 👇 再清理闭包表
closureService.onDeptDelete(deptId);
}

@Override
@Transactional(rollbackFor = Exception.class)
public void moveDept(Long deptId, Long newParentId) {
// 先更新部门的 parent_id
deptMapper.updateParentId(deptId, newParentId);
// 👇 再维护闭包表
closureService.onDeptMove(deptId, newParentId);
}
}