阅读 13

各种树型结构模型分析与比较

假设我们正在设计一个带有评论的 Bug 记录网站(很像 stackoverflow.com/ ),网站的读者可以评论原文甚至可以相互之间回复,因此针对某一主题的讨论可能会延伸出很多分支。刚开始,我们可能会选择一个简单的方案:

CREATE TABLE Comments(
  comment_id SERIAL PRIMARY KEY,
  parent_id BIGNIT UNSIGNED,
  comment TEXT NOT NULL,
  FOREIGN KEY (parent_id) REFERENCES Comments(comment_id)
)
复制代码

这个方案中的每一条评论都可能都是另一个评论的子孙评论。因此要想使用一条简单的 SQL 语句去查询出一个很长的回复分支是困难的,因为帖子可能的深度是无限深的。

另一种想法是,一次性获取出这一主题的所有评论贴,然后再在应用中通过代码把它整合成传统的树型结构。但是我们的网站每天会发布几十篇文章,每篇文章可能会有数千条评论,因此如果每一次有人访问我们的网站都需要重新整合一次评论,这是不切实际的。

1 分层存储与查询

像树一样层级组织的、存在递归关系的数据在应用系统中很常见。在这样的结构中,每一个实例称为节点。每个节点有多个子节点和一个父节点。最上层的节点称为根节点,它没有父节点。最底层的节点称为叶节点,它没有子节点。所有的中间节点称为非叶节点

我们需要查询树型结构中与整棵树或者其子树相关的特定对象,树型结构的典型示例是:

  • 组织架构:比如一个公司有多个部门,每个部门有多名员工。
  • 评论链:比如某一主题有多个评论,每个评论有多个回复。

2 邻接表模型

最常见的解决方案就是在评论表中添加一个 parent_id 字段,关联同一张表中的其他评论:

假设的范例数据:

comment_id parent_id author comment
1 NULL Fran 为什么会发生这个 Bug ?
2 1 Ollie 我觉得是因为空指针
3 2 Fran 不是,我跟踪过了
4 1 Kukla 我觉得是因为无效输入
5 4 Ollie 是这个问题
6 4 Fran 确认下吧
7 6 Kukla 解决了

2.1 查询

这个模型的问题是,它无法查询出一个节点的所有后代。

可以使用关联查询来获取一条评论和它的直接后代:

SELECT c1.*, c2.*
FROM comments c1 LEFT OUTER comments c2
ON c2.parent_id = c1.comment_id
WHERE c1.comment_id = 1;
复制代码

上面这个查询只能获取两层的数据。而实际情况下,可能需要查询任意深度的数据,比如需要计算一个评论分支的评论数。在邻接表模型中,每增加一层,都需要一个额外的联结,而这在数据库中是有上限的。因此既不优雅又不实用,比如以下获取四层数据的 SQL 语句:

 SELECTc1.* , c2.* , c3.* , c4.*FROMcommentsc1-- 第一层 LEFTOUTERcommentsc2-- 第二层 ONc2.parent_id=c1.comment_idLEFTOUTERcommentsc3-- 第三层 ONc3.parent_id=c2.comment_idLEFTOUTERcommentsc4-- 第四层 ONc4.parent_id=c3.comment_idWHEREc1.comment_id=1; 
复制代码

这样的查询很笨,因为没增加一层,都必须同等增加联结的表,这使得执行聚集函数(比如 COUNT() )变得很困难。

另一种方法是,先查询出所有的行,然后在应用程序中自顶向下地构造出这棵树:

SELECT * FROM comments WHERE bug_id = 1;
复制代码

这种方法非常低效,因为大部分的查询可能只需要一棵子树或者只需要一些聚合信息而已。

2.2 维护

邻接表模型在某些操作上很方便,比如增加一个叶子节点:

INSERT INTO comments (bug_id, parent_id, author, comment)
VALUES (1, 7, 'Deniro', '多谢!');
复制代码

还比如修改一个节点的位置,或者一棵子树的位置也很简单:

UPDATE comments SET parent_id = 3 WHERE comment_id = 6;
复制代码

但是,删除一棵子树很复杂,因为必须多次查询来找到所有的后代节点,然后在从低级别处开始删除以满足之前定义的外键约束性:

SELECT comment_id FROM comments WHERE parent_id = 4;//5,6
SELECT comment_id FROM comments WHERE parent_id = 5;//没有
SELECT comment_id FROM comments WHERE parent_id = 6;//7
SELECT comment_id FROM comments WHERE parent_id = 7;//没有

DELETE FROM comments WHERE comment_id IN(7);
DELETE FROM comments WHERE comment_id IN(5,6);
DELETE FROM comments WHERE comment_id IN(4);
复制代码

可以配置一个级联删除特性的外键约束来自动完成上面的操作。注意,这些节点是被删除而不只是被改动位置。

如果要删除一个非叶子节点的同时,提升它的子节点,或者将它的子节点移动到另一个子节点的情况,都必须先修改子节点的 parent_id,然后才能删除原来的非叶子节点。

SELECT parent_id FROM comments WHERE comment_id = 6;//4,获取当前节点的父节点 ID
UPDATE comments SET parent_id = 4 WHERE parent_id = 6;//把当前节点的子节点关联到父节点
DELETE FROM comments WHERE comment_id = 6;//删除当前节点
复制代码

这些都必须写很多代码,分多步完成。

2.3 合理使用邻接表模型

邻接表模型的优势在于能够快速获取一个给定节点的父节点,而且可以很容易插入新节点。如果这就是系统对于分层结构的全部操作,那么邻接表模型可以很好地工作。

有些数据库系统(SQL Server 2005、Oracle、DB2、PostgreSQL)可以直接进行递归查询,这使得邻接表模型可以变得更加好用。

可惜的是,MySQL、SQLite、Infomix 不支持直接的递归查询。

3 路径枚举模型

路径枚举是一个由连续的直接层级关系组成的完整路径。

我们在 Comments 表中,使用 path 字段来存储当前节点的最顶层祖先到它自己的路径序列,使用 “/” 作为路径的分隔符。

comment_id path author comment
1 1/ Fran 为什么会发生这个 Bug ?
2 1/2/ Ollie 我觉得是因为空指针
3 1/2/3/ Fran 不是,我跟踪过了
4 1/4/ Kukla 我觉得是因为无效输入
5 1/4/5/ Ollie 是这个问题
6 1/4/6/ Fran 确认下吧
7 1/4/6/7/ Kukla 解决了

3.1 查询

可以通过比较每个节点的路径来查找一个节点的祖先,比如这里要查找 comment_id 为 7 的所有祖先:

SELECT * 
FROM comments AS c
WHERE '1/4/6/7/' LIKE concat(c.path,'%');
复制代码

上面这条查询语句会匹配到路径 1/4/6/%、1/4/% 以及 1/% 的节点。

查询一个节点的所有后代,比如查找comment_id 为 4 的所有后代:

SELECT *
FROM comments AS c
WHERE c.path LIKE '1/4/%'
复制代码

一旦我们可以很简单地获取一个节点的所有祖先或者一个节点的所有后代信息,就可以很容易实现聚合操作,比如计算 comment_id 为 4 的评论 扩展出的所有子评论中,每个用户的评论数量:

SELECT COUNT(*)
FROM comments AS c
WHERE c.path LIKE '1/4/%'
GROUP BY c.author;
复制代码

3.2 维护

插入新节点(这里的 ID 假设是自动生成的,所以需要先插入再更新):

INSERT INTO comments (author, comment) VALUES('deniro','excellent!');

UPDATE comments SET path = CONCAT((SELECT path FROM comments WHERE comment_id = 7),LAST_INSERT_ID(),'/')
WHERE comment_id = LAST_INSERT_ID();
复制代码

LAST_INSERT_ID() 会返回当前会话中最新插入记录的 ID 值,注意在多线程下,不适用

3.3 优缺点

缺点:

  • 数据库无法确保路径的格式永远正确,也无法保证路径中的节点一定存在,只能由应用程序来验证。
  • 使用 LIKE 查询一个节点的所有祖先或者所有孩子的开销可能会很大。
  • 存在字段的长度限制,无法支持树型结构的无限扩展。

优点:通过比较路径字符串的长度,就可以知道对应层级的深浅。

4 嵌套集模型

嵌套集模型使用 nsleft 和 nsright 来存储子孙节点信息。

  • nsleft 的数值小于该节点所有后代的 ID。
  • nsright 的数值大于该节点所有后代的 ID。

确定这两个值的方法是对树进行一次深度优先遍历:

comment_id nsleft nsright author comment
1 1 14 Fran 为什么会发生这个 Bug ?
2 2 5 Ollie 我觉得是因为空指针
3 3 4 Fran 不是,我跟踪过了
4 6 13 Kukla 我觉得是因为无效输入
5 7 8 Ollie 是这个问题
6 9 12 Fran 确认下吧
7 10 11 Kukla 解决了

4.1 查询

搜索哪些节点的 ID 在评论 #4 的 nsleft 与 nsright 范围内,来获取评论 #4 以及它的所有后代:

SELECT  c2.*
FROM comments AS c1 JOIN comments AS c2
ON c2.nsleft BETWEEN c1.nsleft AND c1.nsright
WHERE c1.comment_id = 6;
复制代码

搜索评论 #6 的 nsleft 在哪些节点的nsleft 与 nsright 范围内,来获取评论 #6 以及它的所有祖先:

SELECT c2.*
FROM comments as c1 JOIN comments AS c2
ON c1.nsleft BETWEEN c2.nsleft AND c2.nsright
WHERE c1.comment_id = 6;
复制代码

嵌套集模型的好处就是,删除一个非叶子节点,它的后代会自动替代被删除的节点,成为其直接祖先节点的直接后代。

计算给定节点的深度,然后删除它的父节点,当再次计算深度时,它已经自动减少了一层,比如这里删除了评论 #6 前后,计算评论 #7 深度的情况:

-- 计算深度,3
SELECT c1.comment_id, COUNT(c2.comment_id) AS depth 
FROM comment AS c1 JOIN comment AS c2 -- 找出所有 c1 的祖先(包括它自己)
ON c1.nsleft BETWEEN c2.nsleft AND c2.nsright
WHERE c1.commnet_id = 7
GROUP BY c1.comment_id;

-- 删除评论 #6
DELETE FROM comment WHERE comment_id = 6;

-- 计算深度,2
SELECT c1.comment_id, COUNT(c2.comment_id) AS depth 
FROM comment AS c1 JOIN comment AS c2 -- 找出所有 c1 的祖先(包括它自己)
ON c1.nsleft BETWEEN c2.nsleft AND c2.nsright
WHERE c1.commnet_id = 7
GROUP BY c1.comment_id;
复制代码

但是查询一个节点的直接父节点或者直接子节点,很复杂。

如果要查询一个节点的直接父节点,可以这样考虑:给定一个节点 c 的直接父节点肯定是这个节点的祖先,且这两个节点之间绝不会有任何其他的节点。因此,可以使用一个递归的外连接来查询一个节点 x, 它既是 c 的祖先,同时也是另一个节点 y 的后代,然后我们让 y=x 并继续递归查询,直到查询返回空,即不存在这样的节点,此时的 y 便是 c1 的直接父节点:

比如要查询评论 #6 的直接父亲:

SELECT parent.*
FROM comment AS c JOIN comment AS parent
ON c.nsleft BETWEEN parent.nsleft AND parent.nsright -- 就是图例中的 y
LEFT OUTER JOIN comment AS in_between  -- 就是图例中的 x
ON c.nsleft BETWEEN in_between.nsleft AND in_between.nsright
AND in_between.nsleft BETWEEN parent.nsleft AND parent.nsright
WHERE c.comment_id = 6
AND in_between.comment_id IS NULL; -- 找到的祖先与 c 之间不存在其他节点
复制代码

4.2 维护

嵌套集模型在插入和移动节点的操作上,比其他模型复杂的多。插入一个新节点时,需要重新计算新插入节点的相邻兄弟节点、祖先节点和祖先节点的兄弟节点的左右值。

假设新插入的节点是一个叶子节点(插入到第 5 个节点下,左右值为8,9):

-- 重新计算受到影响的节点的左右值
UPDATE comment
SET nsleft = CASE WHEN nsleft >=8 THEN nsleft+2 ELSE nsleft END,
    nsright = nsright+2
WHERE nsright >=7;

-- 创建新节点
INSERT INTO comment (nsleft, nsright, author, comment)
VALUES (8,9,'Deniro','厉害');
复制代码

如果某个应用中,如果简单快速第查询是最重要的功能,那么可以使用嵌套集。然而,在嵌套集中插入和移动节点是复杂的,因为需要重新分配左右值,因此嵌套集不适合需要频繁插入和删除节点的应用场景。

5 闭包表模型

闭包表是一个简单、优雅模型,它记录了树中所有节点的关系。

我们将树中任何具有祖先与后代关系的节点对,都存储在 TreePaths 中,同时我们也把指向节点自身的关系也存储在这张表;为了方便查询某个节点直接父节点或直接子节点,我们还增加一个 path_length 字段,自我引用的节点该值为 0,直接子节点为 1,以此类推:

祖先 后代 路径长度
1 1 0
1 2 1
1 3 2
1 4 1
1 5 2
1 6 2
1 7 3
2 2 0
2 3 1
3 3 0
4 4 0
4 5 1
4 6 1
4 7 2
5 5 0
5 6 0
6 6 0
6 7 1

  • 实线表示父子关系。
  • 不同颜色表示某个节点与其他节点的关系。

这样画只是为了理解方便,对于 TreePaths 表它们都是祖先与后代的关系(包括指向自己的关系)。

5.1 查询

搜索评论 #4 的所有后代:

SELECT c.*
FROM comments AS c
JOIN TreePaths AS t ON c.comment_id = t.descendant
WHERE t.ancestor = 4;
复制代码

搜索评论 #6 的所有祖先:

SELECT c.*
FROM comments AS c
JOIN TreePaths AS t ON c.comment_id = t.ancestor
WHERE t.descendant = 6;
复制代码

搜索评论 #4 的直接子节点:

SELECT *
FROM TreePaths
WHERE ancestor = 4 AND path_length = 1;
复制代码

5.2 维护

插入新的叶子节点,比如在评论 #5 下新增一个叶子节点,假设叶子节点的 ID 为 8:

INSERT INTO TreePaths(ancestor, descedant)
SELECT t.ancestor, 8 -- 所有后代为 5 的节点,都添加关系
FROM TreePaths AS t
WHERE t.descendant = 5
UNION ALL
SELECT 8,8;-- 指向自己的关系
复制代码

删除一个叶子节点,比如评论 #7:

DELETE FROM TreePaths WHERE descendant = 7;
复制代码

删除一棵完全子树,比如评论 #4 和它的所有后代:

DELETE FROM TreePaths
WHERE descendant IN (
    SELECT descendant FROM TreePaths WHERE ancestor = 4
);
复制代码

注意: 这里的删除只是删除关系,实际的节点并没有删除,因为我们把关系路径存储在一个独立的表中,这种灵活的设计特别适合用于员工组织架构变动的场景。


把一棵子树移动到其他地方:首先找到这棵子树的顶点,然后删除它的所有子节点与它的所有祖先节点之间的关系,比如把评论 #6 移动到评论 #3 下:

-- 删除与评论 #6 相关的所有祖先和后代的关系
DELETE FROM TreePaths
WHERE descendant IN (SELECT descendant FROM TreePaths WHERE ancestor = 6) -- 评论 #6 的所有后代(包括评论 #6 自身)
AND ancestor IN (SELECT ancestor FROM TreePaths WHERE descendant = 6); -- 评论 #6 的所有祖先

-- 把这棵子树与它的新节点以及祖先建立关系
INSERT INTO TreePaths (ancestor,descendant)
SELECT 
FROM TreePaths AS supertree
    CROSS JOIN TreePaths AS subtree
WHERE supertree.descedant = 3
AND subtree.ancestor = 6;
复制代码

5 最佳实践

每种模型各有优劣,到底选择哪一种取决于具应该体场景,问问自己哪一种操作执行的最频繁?对性能的要求是否能否满足?

模型 表数量 查询直接节点 查询树 插入 删除 保证引用完整性
邻接表(不支持递归) 1 简单 困难 简单 简单
邻接表(支持递归) 1 简单 简单 简单 简单
枚举路径 1 困难 简单 困难 困难
嵌套集 1 困难 简单 困难 困难
闭包表 2 简单 简单 简单 简单

总结如下:

  • 如果数据库(比如 Oracle)支持递归查询,建议使用邻接表模型。
  • 如果数据库(比如 MySQL)不支持递归查询,建议使用闭包表模型。它需要一张额外的表来存储关系,是一种典型的采用空间来换时间的方案。
  • 枚举路径模型不能保证引用完整性,因此很脆弱,而且也使得存储的数据变得冗余,所以不建议使用。
  • 嵌套集模型也不能保证引用完整性,建议只在一个对查询性能要求非常高,而对其他操作要求一般的场景下才使用它。
关注下面的标签,发现更多相似文章
评论