一、树形结构存储,难在哪?
树形结构由节点和边组成,每个节点可以有零个或多个子节点,但只有一个父节点(根节点除外)。这种结构在现实中随处可见:公司的组织架构、电商的商品类目、论坛的帖子回复……但在关系型数据库中存储和查询它们,却并不直观。
常见的诉求无非就几类:查某个节点的所有子节点、查所有祖先节点、查两节点之间的距离、增删改节点。看似简单,但不同的存储方案在这几项操作上的表现天差地别。
二、四种常见方案速览
在正式介绍闭包表之前,我们先快速了解另外三种主流方案,这样才能理解闭包表到底“优”在哪里。
1. 邻接表
最直观的方案——每个节点记录一个parent_id指向父节点。
优点:结构简单,插入方便。
缺点:查询子树或祖先需要递归查询,层级深时性能极差。
2. 路径枚举
在每个节点中存储从根节点到该节点的完整路径,如/1/2/3。
优点:避免了递归查询,查询子树效率高。
缺点:移动节点时需要更新该节点及所有子孙的路径,维护成本极高;路径长度有上限。
3. 嵌套集
为每个节点赋予左右值,通过数值范围来判断祖先后代关系。
优点:查询子树极快。
缺点:插入、删除、移动节点时需要更新大量节点的左右值,模型复杂,维护困难。
4. 闭包表——今天的主角
单独创建一张关系表,存储树中所有节点对之间的祖先-后代关系(包括节点自身)。
三、闭包表的核心原理
闭包表的核心思想是空间换时间——用额外的存储空间,换取查询效率的大幅提升。
它通常需要两张表:
节点表(存储节点本身的信息):
CREATE TABLE nodes ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL );闭包关系表(存储所有祖先-后代关系):
CREATE TABLE node_paths ( ancestor_id INT, -- 祖先节点ID descendant_id INT, -- 后代节点ID depth INT, -- 两者之间的距离(层数差) PRIMARY KEY (ancestor_id, descendant_id), FOREIGN KEY (ancestor_id) REFERENCES nodes(id), FOREIGN KEY (descendant_id) REFERENCES nodes(id) );关键点:每个节点不仅要记录与所有祖先的关系,还要记录与自身的关系(即ancestor_id = descendant_id,depth = 0)。
举个例子
假设有这样一棵树:
1 ├── 2 │ └── 4 └── 3闭包表中存储的数据是这样的:
| ancestor_id | descendant_id | depth |
|---|---|---|
| 1 | 1 | 0 |
| 1 | 2 | 1 |
| 1 | 3 | 1 |
| 1 | 4 | 2 |
| 2 | 2 | 0 |
| 2 | 4 | 1 |
| 3 | 3 | 0 |
| 4 | 4 | 0 |
有了这张表,查询就变得异常简单:
查询节点1的所有后代:
SELECT * FROM node_paths WHERE ancestor_id = 1查询节点4的所有祖先:
SELECT * FROM node_paths WHERE descendant_id = 4查询节点2的直接子节点:
SELECT * FROM node_paths WHERE ancestor_id = 2 AND depth = 1
四、闭包表的优缺点
优点
查询效率极高:无论树有多深,查询任意节点的所有祖先或所有后代都只需要一次简单的索引查询,无需递归。
支持复杂查询:可以轻松查询两节点之间的距离、某个节点的所有子孙等。
节点移动方便:移动一个子树时,只需要删除该子树相关的旧路径,再插入新路径即可,操作相对可控。
缺点
存储空间较大:闭包表存储了所有节点对的关系,数据量约为 O(n²) 级别。树越大,关系表膨胀越明显。
插入成本较高:插入一个新节点时,需要为它和所有祖先节点各插入一条关系记录。
五、什么时候该用闭包表?
综合来看,闭包表最适合以下场景:
树形结构层级较深(比如超过5层),邻接表的递归查询难以承受。
查询操作远多于写入操作,愿意用存储空间换取查询性能。
需要频繁查询祖先/后代关系,比如权限系统中的部门归属查询、电商系统中的类目路径查询。
如果树结构非常浅、数据量很小,或者写入极其频繁,邻接表可能是更轻量的选择。没有银弹,只有最适合的方案。
六、总结
闭包表通过“空间换时间”的思路,用一张专门的关系表存储所有节点对的祖先-后代关系,将复杂的树形查询转化为简单的索引查询。虽然插入和存储成本有所增加,但在查询性能和维护便利性上优势明显。