树形结构在数据库中如何设计?邻接表、路径枚举和闭包表怎么选?
简化版
树形结构常见设计有邻接表、路径枚举、嵌套集合和闭包表。邻接表最简单,适合组织架构、分类等层级不深的场景;路径枚举适合快速查子树;闭包表适合频繁查祖先/后代但写入更复杂的场景。选择时看读写比例、树深度和移动节点频率。
详细版
最常见方案是表里存 id 和 parent_id,也就是邻接表。它写入和移动节点简单,但查整棵子树需要递归查询或多次查询。
如果需要频繁查子树,可以增加 path 字段,例如 /1/5/9/,用前缀查所有后代。缺点是移动节点时要批量更新子孙路径。
闭包表会单独存祖先和后代关系,如 (ancestor_id, descendant_id, depth),查询祖先和后代很快,但新增、移动节点时维护成本更高。
面试回答要说明没有银弹:简单层级用邻接表,读多查子树用路径,复杂权限/组织关系可考虑闭包表。
完整版教学
一、树形结构的难点是查询方向不同
树结构看似简单:每个节点有一个父节点。但业务查询方向很多:查直接子节点、查所有后代、查所有祖先、查同级节点、移动子树、统计节点数量。
不同设计对这些操作的成本差异很大。如果只查菜单的下一层,邻接表足够;如果权限系统经常查某部门下所有人,查整棵子树就很重要。
所以设计树表前要先问三个问题:树有多深、读多还是写多、是否经常移动节点。
记忆钩子:树表不是先选模型,而是先看“查父、查子、查祖先、搬节点”哪个最频繁。
二、邻接表最简单,也最常用
邻接表就是每行保存自己的父节点 ID。
create table category (
id bigint primary key,
parent_id bigint null,
name varchar(128) not null,
sort_no int not null default 0
);
它的优点是直观、写入简单、移动节点容易,只要改当前节点的 parent_id。缺点是查所有后代比较麻烦,需要递归 CTE 或应用层循环查询。
如果树深度只有 3 到 5 层,数据量也不大,邻接表是最稳的默认选择。很多菜单、部门、分类都可以从它开始。
三、路径枚举适合快速查子树
路径枚举会在节点上保存从根到当前节点的路径,比如根节点 1,子节点 5,孙节点 9 的路径是 /1/5/9/。
查某个节点的所有后代时,可以用路径前缀:
select *
from category
where path like '/1/5/%';
路径枚举的查询很直观,适合读多写少、经常查子树的场景。代价是移动节点时,所有子孙节点的 path 都要更新。假设一个节点下面有 5000 个后代,移动一次就要批量更新 5000 行。
四、闭包表适合频繁查祖先和后代
闭包表会单独维护所有祖先和后代关系。例如 A -> B -> C,闭包表里不只存 (A,B) 和 (B,C),还存 (A,C)。
ancestor_id | descendant_id | depth
1 | 1 | 0
1 | 5 | 1
1 | 9 | 2
5 | 9 | 1
查祖先、查后代都很快:
select c.*
from category_closure cc
join category c on c.id = cc.descendant_id
where cc.ancestor_id = ?;
闭包表的代价是维护复杂。新增节点要插入它和所有祖先的关系;移动子树更麻烦,要删除旧路径关系并插入新路径关系。
五、嵌套集合适合读极多写极少
嵌套集合用左右边界 lft 和 rgt 表示树。一个节点的所有后代都落在它的左右边界之间。
A(1,6)
B(2,3)
C(4,5)
查子树很快:
select *
from category
where lft between 1 and 6;
但新增、删除、移动节点会导致大量节点的左右值变化。它适合内容分类这类读多写少场景,不太适合频繁调整组织架构。
六、不同方案要按场景选
可以用一张表快速对比:
| 方案 | 查直接子节点 | 查整棵子树 | 移动节点 | 适合场景 |
|---|---|---|---|---|
| 邻接表 | 快 | 中等或慢 | 快 | 普通层级、树不深 |
| 路径枚举 | 快 | 快 | 慢 | 分类、目录、读多 |
| 闭包表 | 快 | 快 | 复杂 | 权限、组织、祖先后代查询多 |
| 嵌套集合 | 中 | 快 | 很慢 | 写少读多的静态树 |
面试时如果不确定业务,默认回答“邻接表起步,读多查子树时加 path 或闭包表”比较稳。
七、常见误区与追问
- 误区:树形结构都用 parent_id 就够了。 层级浅可以,频繁查整棵子树或祖先链时可能性能不够。
- 误区:路径枚举移动节点也很轻。 移动节点要更新所有子孙路径,子树大时成本很高。
- 误区:闭包表查询快所以一定最好。 闭包表写入和移动维护复杂,不适合简单低频查询场景。
- 追问:怎么防止树出现环? 移动节点前检查目标父节点不能是当前节点的后代,闭包表或递归查询都能做校验。
- 追问:根节点 parent_id 怎么存? 常见存
null或0,要全系统统一,并配合索引查询。 - 追问:树节点排序怎么做? 增加
sort_no字段,同一父节点下按(parent_id, sort_no)排序。
八、面试中可以这样落地
组织架构可以用邻接表加路径字段的混合方案:parent_id 支持移动和直接子节点查询,path 支持快速查子树。
create table department (
id bigint primary key,
parent_id bigint null,
path varchar(1024) not null,
name varchar(128) not null,
sort_no int not null default 0,
index(parent_id),
index(path)
);
如果数据库支持递归 CTE,树不深时可以先用邻接表。后续子树查询压力变大,再冗余 path 或引入闭包表。
九、加强记忆
树表设计记住四种模型:邻接表简单,路径枚举查子树快,闭包表查祖先后代快,嵌套集合适合静态读多。真正的选择依据是树深、读写比例、是否频繁移动节点。面试时用“菜单用邻接表,分类用 path,权限组织可用闭包表”来举例,基本就讲透了。