4、分组层级与嵌套:多级分组的实现原理与数据库设计
设备分组这件事,说白了就是给一堆设备贴标签、分门别类。但一旦你的设备数量上了万,分组层级深到四五层,事情就没那么简单了。
我记得刚带团队那会儿,接手过一个 IoT 平台。设备分组最多支持三级,用的是最朴素的 parent_id 方案。结果客户一上来就要五级分组,查询某个父组下的所有设备,SQL 写得跟俄罗斯套娃似的——一层套一层,性能直接崩了。
嗯,今天我们就来聊聊多级分组的两种主流数据库设计方案:邻接表和闭包表。我会把各自的原理、优缺点、适用场景都掰开揉碎了讲清楚。
4.1 邻接表:最直观的 parent_id 方案
邻接表,就是每个分组记录一个 parent_id,指向它的父分组。这是最符合直觉的设计,也是大多数人上手就会用的方案。
表结构大概长这样:
CREATE TABLE device_group (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
parent_id INT DEFAULT NULL,
level INT DEFAULT 0, -- 冗余字段,缓存层级深度
path VARCHAR(255) DEFAULT '', -- 冗余字段,缓存完整路径
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES device_group(id)
);
你看,核心字段就三个:id、name、parent_id。后面两个 level 和 path 是我后来加的冗余字段,后面会讲为什么加。
4.1.1 邻接表的优点
- 结构简单:一张表搞定,CRUD 操作非常直观。
- 插入/更新快:新增一个分组,只需要指定 parent_id,一条 INSERT 搞定。
- 移动子树方便:把一个分组从 A 父节点移到 B 父节点,改一行 parent_id 就行。
4.1.2 邻接表的痛点
但邻接表有个硬伤——查询子树。你想查某个父组下的所有子孙分组,用纯 SQL 几乎做不到。要么递归查询(MySQL 8.0 支持 CTE 了,但早期版本不行),要么在应用层循环查。
我曾经在一个项目里,用邻接表查三级分组下的所有设备。代码里写了个递归函数,每次查一层,拼 SQL。数据量一上来,接口响应时间直接飙到 3 秒以上。后来没办法,只能加缓存,但缓存一致性又成了新问题。
4.2 闭包表:用空间换时间的经典方案
闭包表(Closure Table)是另一种思路。它不只在原表里存 parent_id,而是单独用一张关联表,记录所有祖孙关系。
表结构设计:
-- 分组主表,只存自身信息
CREATE TABLE device_group (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 闭包表,记录所有祖孙关系
CREATE TABLE group_closure (
ancestor INT NOT NULL, -- 祖先节点 ID
descendant INT NOT NULL, -- 后代节点 ID
depth INT NOT NULL, -- 层级深度(ancestor 到 descendant 的步数)
PRIMARY KEY (ancestor, descendant),
FOREIGN KEY (ancestor) REFERENCES device_group(id),
FOREIGN KEY (descendant) REFERENCES device_group(id)
);
闭包表的核心思想是:预计算所有路径。比如分组 A 是根节点,B 是 A 的子节点,C 是 B 的子节点。那么闭包表里会存:
- A → A(depth=0)
- A → B(depth=1)
- A → C(depth=2)
- B → B(depth=0)
- B → C(depth=1)
- C → C(depth=0)
你看,每个节点到自己的关系也存了,depth=0。这样设计是为了查询方便。
4.2.1 闭包表的优势
查询子树变得极其简单:
-- 查分组 ID=5 下的所有子孙分组
SELECT d.*
FROM device_group d
JOIN group_closure gc ON d.id = gc.descendant
WHERE gc.ancestor = 5;
一条 SQL,没有递归,没有循环。性能非常稳定,不管深度是 3 层还是 10 层,查询时间几乎不变。
查父路径也一样简单:
-- 查分组 ID=5 的所有祖先
SELECT a.*
FROM device_group a
JOIN group_closure gc ON a.id = gc.ancestor
WHERE gc.descendant = 5
ORDER BY gc.depth DESC;
4.2.2 闭包表的代价
代价也很明显——存储空间。假设有 N 个分组,闭包表最多会存 N² 条记录。虽然实际场景中不会那么夸张(因为树不是完全图),但数据量还是比邻接表大得多。
另外,插入和移动的成本变高了。新增一个分组,需要插入多条闭包记录。移动一个子树,需要先删除旧的闭包关系,再重新插入新的关系。操作起来比邻接表麻烦不少。
4.3 两种方案的对比
我把两种方案的核心差异整理成了表格,方便你对照选择:
| 对比维度 | 邻接表 | 闭包表 |
|---|---|---|
| 表数量 | 1 张 | 2 张(主表 + 闭包表) |
| 查询子树 | 递归查询或应用层循环 | 单条 SQL,性能稳定 |
| 查询父路径 | 递归查询 | 单条 SQL |
| 插入节点 | 快(1 条 INSERT) | 较慢(多条 INSERT) |
| 移动子树 | 快(改 parent_id) | 慢(先删后插) |
| 存储空间 | 小 | 较大(N² 级别) |
| 适用深度 | ≤ 3 层 | 任意深度 |
| 实现复杂度 | 低 | 中 |
4.4 我的选择建议
说实话,没有银弹。我一般这样选:
- 分组层级 ≤ 3 层,且查询子树不频繁:用邻接表。简单够用,别过度设计。
- 分组层级 ≥ 4 层,或查询子树是核心操作:用闭包表。前期多写点代码,后期省心。
- 分组树频繁变动(增删改多):慎用闭包表。移动子树的开销可能让你后悔。
另外,还有一个折中方案:邻接表 + 冗余字段。就是在邻接表里加 level 和 path 字段,每次更新时维护好这些冗余数据。这样查询子树时可以用 LIKE 'path%' 来模糊匹配,性能比纯递归好一些。但缺点是维护成本高,容易数据不一致。
4.5 知识体系总览
下面这张图,帮你把两种方案的逻辑关系理清楚:
4.6 实战中的避坑指南
最后,分享几个我踩过的坑:
- 别在邻接表上做深度递归:我曾经用 MySQL 5.7 的存储过程做递归查询,深度到 5 层时,一次查询要 2 秒。后来改成闭包表,降到 5 毫秒。
- 闭包表别忘了 depth 字段:没有 depth,你查直接子节点时还得额外计算。加上 depth=1 的条件,直接就能拿到子节点。
- 移动子树时注意事务:闭包表移动子树需要先删后插,一定要放在事务里。我见过有人忘了加事务,移动到一半系统崩了,闭包表数据全乱套。
- 分组 ID 别用 UUID:闭包表的主键是 (ancestor, descendant),如果用 UUID,索引体积会大得吓人。自增 INT 或 BIGINT 就够用。
嗯,关于多级分组的数据库设计,今天就聊到这儿。两种方案各有千秋,关键看你的业务场景偏读还是偏写。选对了,后面省心一大半。