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 层时,数据库连接池被打满。如果你的分组层级超过 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² 条记录。虽然实际场景中不会那么夸张(因为树不是完全图),但数据量还是比邻接表大得多。

另外,插入和移动的成本变高了。新增一个分组,需要插入多条闭包记录。移动一个子树,需要先删除旧的闭包关系,再重新插入新的关系。操作起来比邻接表麻烦不少。

我的经验:如果你的分组树比较稳定(增删改不频繁),但查询子树是高频操作,闭包表是首选。我后来那个 IoT 平台重构时,就把邻接表换成了闭包表,查询性能提升了 10 倍以上。

4.3 两种方案的对比

我把两种方案的核心差异整理成了表格,方便你对照选择:

对比维度 邻接表 闭包表
表数量 1 张 2 张(主表 + 闭包表)
查询子树 递归查询或应用层循环 单条 SQL,性能稳定
查询父路径 递归查询 单条 SQL
插入节点 快(1 条 INSERT) 较慢(多条 INSERT)
移动子树 快(改 parent_id) 慢(先删后插)
存储空间 小 较大(N² 级别)
适用深度 ≤ 3 层 任意深度
实现复杂度 低 中

4.4 我的选择建议

说实话,没有银弹。我一般这样选:

  • 分组层级 ≤ 3 层,且查询子树不频繁:用邻接表。简单够用,别过度设计。
  • 分组层级 ≥ 4 层,或查询子树是核心操作:用闭包表。前期多写点代码,后期省心。
  • 分组树频繁变动(增删改多):慎用闭包表。移动子树的开销可能让你后悔。

另外,还有一个折中方案:邻接表 + 冗余字段。就是在邻接表里加 level 和 path 字段,每次更新时维护好这些冗余数据。这样查询子树时可以用 LIKE 'path%' 来模糊匹配,性能比纯递归好一些。但缺点是维护成本高,容易数据不一致。

核心原则:读多写少用闭包表,写多读少用邻接表。没有绝对的好坏,只有适不适合你的业务场景。

4.5 知识体系总览

下面这张图,帮你把两种方案的逻辑关系理清楚:

多级分组数据库设计:邻接表 vs 闭包表 邻接表方案 核心:parent_id 指向父节点 优点:结构简单 缺点:查询子树难 插入/移动快 深度受限 适用:层级 ≤ 3,写多读少 闭包表方案 核心:ancestor + descendant 预存路径 优点:查询快 缺点:存储大 支持任意深度 移动成本高 适用:层级 ≥ 4,读多写少 选择依据:读多写少用闭包表,写多读少用邻接表 没有银弹,只有适不适合你的业务场景

4.6 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 别在邻接表上做深度递归:我曾经用 MySQL 5.7 的存储过程做递归查询,深度到 5 层时,一次查询要 2 秒。后来改成闭包表,降到 5 毫秒。
  • 闭包表别忘了 depth 字段:没有 depth,你查直接子节点时还得额外计算。加上 depth=1 的条件,直接就能拿到子节点。
  • 移动子树时注意事务:闭包表移动子树需要先删后插,一定要放在事务里。我见过有人忘了加事务,移动到一半系统崩了,闭包表数据全乱套。
  • 分组 ID 别用 UUID:闭包表的主键是 (ancestor, descendant),如果用 UUID,索引体积会大得吓人。自增 INT 或 BIGINT 就够用。

嗯,关于多级分组的数据库设计,今天就聊到这儿。两种方案各有千秋,关键看你的业务场景偏读还是偏写。选对了,后面省心一大半。