逻辑数据库设计 - 单纯的树(递归关系数据)

  相信有过开发经验的朋友都曾碰到过这样一个需求。假设你正在为一个新闻网站开发一个评论功能,读者能够评论原文甚至相互回复程序员

  这个需求并不简单,相互回复会致使无限多的分支,无限多的祖先-后代关系。这是一种典型的递归关系数据。数据库

  对于这个问题,如下给出几个解决方案,各位客观可斟酌后选择。闭包

1、邻接表:依赖父节点

  邻接表的方案以下(仅仅说明问题):函数

  CREATE TABLE Comments(
    CommentId  int  PK,
    ParentId   int,    --记录父节点
    ArticleId  int,
    CommentBody nvarchar(500),
    FOREIGN KEY (ParentId)  REFERENCES Comments(CommentId)   --自链接,主键外键都在本身表内
    FOREIGN KEY (ArticleId) REFERENCES Articles(ArticleId)
  )

  因为偷懒,因此采用了书本中的图了,Bugs就是Articles:
性能

  

  这种设计方式就叫作邻接表。这多是存储分层结构数据中最普通的方案了。优化

  下面给出一些数据来显示一下评论表中的分层结构数据。示例表:网站

  

  图片说明存储结构:
编码

  

  邻接表的优缺分析spa

  对于以上邻接表,不少程序员已经将其当成默认的解决方案了,但即使是这样,但它在从前仍是有存在的问题的。设计

  分析1:查询一个节点的全部后代(求子树)怎么查呢?

  咱们先看看之前查询两层的数据的SQL语句:

  SELECT c1.*,c2.*
  FROM Comments c1 LEFT OUTER JOIN Comments2 c2
  ON c2.ParentId = c1.CommentId

  显然,每须要查多一层,就须要联结多一次表。SQL查询的联结次数是有限的,所以不能无限深的获取全部的后代。并且,这种这样联结,执行Count()这样的聚合函数也至关困难。

  说了是之前了,如今什么时代了,在SQLServer 2005以后,一个公用表表达式就搞定了,顺带解决的还有聚合函数的问题(聚合函数如Count()也可以简单实用),例如查询评论4的全部子节点:

WITH COMMENT_CTE(CommentId,ParentId,CommentBody,tLevel)
AS
(
    --基本语句
    SELECT CommentId,ParentId,CommentBody,0 AS tLevel FROM Comment
    WHERE ParentId = 4
    UNION ALL  --递归语句
    SELECT c.CommentId,c.ParentId,c.CommentBody,ce.tLevel + 1 FROM Comment AS c 
    INNER JOIN COMMENT_CTE AS ce    --递归查询
    ON c.ParentId = ce.CommentId
)
SELECT * FROM COMMENT_CTE

  显示结果以下:

  

  那么查询祖先节点树又如何查呢?例如查节点6的全部祖先节点:

WITH COMMENT_CTE(CommentId,ParentId,CommentBody,tLevel)
AS
(
    --基本语句
    SELECT CommentId,ParentId,CommentBody,0 AS tLevel FROM Comment
    WHERE CommentId = 6
    UNION ALL
    SELECT c.CommentId,c.ParentId,c.CommentBody,ce.tLevel - 1  FROM Comment AS c 
    INNER JOIN COMMENT_CTE AS ce  --递归查询 ON ce.ParentId = c.CommentId
    where ce.CommentId <> ce.ParentId
)
SELECT * FROM COMMENT_CTE ORDER BY CommentId ASC

  结果以下:

  

  再者,因为公用表表达式可以控制递归的深度,所以,你能够简单得到任意层级的子树。

  OPTION(MAXRECURSION 2)

  看来哥是为邻接表平反来的。

   分析2:固然,邻接表也有其优势的,例如要添加一条记录是很是方便的。

  INSERT INTO Comment(ArticleId,ParentId)...    --仅仅须要提供父节点Id就可以添加了。

   分析3:修改一个节点位置或一个子树的位置也是很简单.

UPDATE Comment SET ParentId = 10 WHERE CommentId = 6  --仅仅修改一个节点的ParentId,其后面的子代节点自动合理。

  分析4:删除子树

  想象一下,若是你删除了一个中间节点,那么该节点的子节点怎么办(它们的父节点是谁),所以若是你要删除一个中间节点,那么不得不查找到全部的后代,先将其删除,而后才能删除该中间节点。

  固然这也能经过一个ON DELETE CASCADE级联删除的外键约束来自动完成这个过程。

   分析5:删除中间节点,并提高子节点

  面对提高子节点,咱们要先修改该中间节点的直接子节点的ParentId,而后才能删除该节点:

  SELECT ParentId FROM Comments WHERE CommentId = 6;    --搜索要删除节点的父节点,假设返回4
  UPDATE Comments SET ParentId = 4 WHERE ParentId = 6;  --修改该中间节点的子节点的ParentId为要删除中间节点的ParentId
  DELETE FROM Comments WHERE CommentId = 6;          --终于能够删除该中间节点了

  由上面的分析能够看到,邻接表基本上已是很强大的了。

2、路径枚举

  路径枚举的设计是指经过将全部祖先的信息联合成一个字符串,并保存为每一个节点的一个属性。

  路径枚举是一个由连续的直接层级关系组成的完整路径。如"/home/account/login",其中home是account的直接父亲,这也就意味着home是login的祖先。

  仍是有刚才新闻评论的例子,咱们用路径枚举的方式来代替邻接表的设计:

  CREATE TABLE Comments(
    CommentId  int  PK,
    Path      varchar(100),    --仅仅改变了该字段和删除了外键
    ArticleId  int,
    CommentBody nvarchar(500),
    FOREIGN KEY (ArticleId) REFERENCES Articles(ArticleId)
  )

   简略说明问题的数据表以下:

  CommentId  Path    CommentBody

  1       1/        这个Bug的成因是什么

  2       1/2/     我以为是一个空指针

  3       1/2/3     不是,我查过了

  4       1/4/     咱们须要查无效的输入

  5       1/4/5/    是的,那是个问题

  6       1/4/6/    好,查一下吧。

  7       1/4/6/7/   解决了

  路径枚举的优势:

  对于以上表,假设咱们须要查询某个节点的所有祖先,SQL语句能够这样写(假设查询7的全部祖先节点):

SELECT * FROM Comment AS c
WHERE '1/4/6/7/' LIKE c.path + '%'

  结果以下:

  

  假设咱们要查询某个节点的所有后代,假设为4的后代:

SELECT * FROM Comment AS c
WHERE c.Path LIKE '1/4/%'

  结果以下:

  

  一旦咱们能够很简单地获取一个子树或者从子孙节点到祖先节点的路径,就能够很简单地实现更多查询,好比计算一个字数全部节点的数量(COUNT聚合函数)

  

   插入一个节点也能够像和使用邻接表同样地简单。能够插入一个叶子节点而不用修改任何其余的行。你所须要作的只是复制一份要插入节点的逻辑上的父亲节点路径,并将这个新节点的Id追加到路径末尾就能够了。若是这个Id是插入时由数据库生成的,你可能须要先插入这条记录,而后获取这条记录的Id,并更新它的路径。

  路径枚举的缺点:

  一、数据库不能确保路径的格式老是正确或者路径中的节点确实存在(中间节点被删除的状况,没外键约束)。

  二、要依赖高级程序来维护路径中的字符串,而且验证字符串的正确性的开销很大。

  三、VARCHAR的长度很难肯定。不管VARCHAR的长度设为多大,都存在不可以无限扩展的状况。

  路径枚举的设计方式可以很方便地根据节点的层级排序,由于路径中分隔两边的节点间的距离永远是1,所以经过比较字符串长度就能知道层级的深浅。

3、嵌套集

  嵌套集解决方案是存储子孙节点的信息,而不是节点的直接祖先。咱们使用两个数字来编码每一个节点,表示这个信息。能够将这两个数字称为nsleft和nsright。

  仍是以上面的新闻-评论做为例子,对于嵌套集的方式表能够设计为:

  CREATE TABLE Comments(
    CommentId  int  PK,
    nsleft    int,  --以前的一个父节点
       nsright   int,  --变成了两个
    ArticleId  int,
    CommentBody nvarchar(500),
    FOREIGN KEY (ArticleId) REFERENCES Articles(ArticleId)
  )

  nsleft值的肯定:nsleft的数值小于该节点全部后代的Id。

  nsright值的肯定:nsright的值大于该节点全部后代的Id。

  固然,以上两个数字和CommentId的值并无任何关联,肯定值的方式是对树进行一次深度优先遍历,在逐层入神的过程当中依次递增地分配nsleft的值,并在返回时依次递增地分配nsright的值。

  采用书中的图来讲明一下状况:

  

  一旦你为每一个节点分配了这些数字,就可使用它们来找到给定节点的祖先和后代。

  嵌套集的优势:

  我以为是惟一的优势了,查询祖先树和子树方便。

  例如,经过搜索那些节点的ConmentId在评论4的nsleft与nsright之间就能够得到其及其全部后代:

  SELECT c2.* FROM Comments AS c1
   JOIN Comments AS c2  ON cs.neleft BETWEEN c1.nsleft AND c1.nsright
  WHERE c1.CommentId = 1;

  结果以下:

  

  经过搜索评论6的Id在哪些节点的nsleft和nsright范围之间,就能够获取评论6及其全部祖先:

  SELECT c2.* FROM Comment AS c1
  JOIN Comment AS c2 ON c1.nsleft BETWEEN c2.nsleft AND c2.nsright
  WHERE c1.CommentId = 6;

  

  这种嵌套集的设计还有一个优势,就是当你想要删除一个非叶子节点时,它的后代会自动地代替被删除的节点,称为其直接祖先节点的直接后代。

  嵌套集设计并没必要须保存分层关系。所以当删除一个节点形成数值不连续时,并不会对树的结构产生任何影响。

  嵌套集缺点:

  一、查询直接父亲。

  在嵌套集的设计中,这个需求的实现的思路是,给定节点c1的直接父亲是这个节点的一个祖先,且这两个节点之间不该该有任何其余的节点,所以,你能够用一个递归的外联结来查询一个节点,它就是c1的祖先,也同时是另外一个节点Y的后代,随后咱们使y=x就查询,直到查询返回空,即不存在这样的节点,此时y即是c1的直接父亲节点。

  好比,要找到评论6的直接父节点:老实说,SQL语句又长又臭,行确定是行,但我真的写不动了。

  二、对树进行操做,好比插入和移动节点。

  当插入一个节点时,你须要从新计算新插入节点的相邻兄弟节点、祖先节点和它祖先节点的兄弟,来确保它们的左右值都比这个新节点的左值大。同时,若是这个新节点是一个非叶子节点,你还要检查它的子孙节点。

  够了,够了。就凭查直接父节点都困难,这个东西就很冷门了。我肯定我不会使用这种设计了。

4、闭包表

  闭包表是解决分层存储一个简单而又优雅的解决方案,它记录了表中全部的节点关系,并不只仅是直接的父子关系。
  在闭包表的设计中,额外建立了一张TreePaths的表(空间换取时间),它包含两列,每一列都是一个指向Comments中的CommentId的外键。

CREATE TABLE Comments(
  CommentId int PK,
  ArticleId int,
  CommentBody int,
  FOREIGN KEY(ArticleId) REFERENCES Articles(Id)
)

  父子关系表:

CREATE TABLE TreePaths(
  ancestor    int,
  descendant int,
  PRIMARY KEY(ancestor,descendant),    --复合主键
  FOREIGN KEY (ancestor) REFERENCES Comments(CommentId),
  FOREIGN KEY (descendant) REFERENCES Comments(CommentId)
)

  在这种设计中,Comments表将再也不存储树结构,而是将书中的祖先-后代关系存储为TreePaths的一行,即便这两个节点之间不是直接的父子关系;同时还增长一行指向节点本身,理解不了?就是TreePaths表存储了全部祖先-后代的关系的记录。以下图:

  

  Comment表:

  

  TreePaths表:

  

  优势:

  一、查询全部后代节点(查子树):

SELECT c.* FROM Comment AS c
    INNER JOIN TreePaths t on c.CommentId = t.descendant
    WHERE t.ancestor = 4

  结果以下:

  

  二、查询评论6的全部祖先(查祖先树):

SELECT c.* FROM Comment AS c
    INNER JOIN TreePaths t on c.CommentId = t.ancestor
    WHERE t.descendant = 6

  显示结果以下:

  

   三、插入新节点:

  要插入一个新的叶子节点,应首先插入一条本身到本身的关系,而后搜索TreePaths表中后代是评论5的节点,增长该节点与要插入的新节点的"祖先-后代"关系。

  好比下面为插入评论5的一个子节点的TreePaths表语句:

INSERT INTO TreePaths(ancestor,descendant)
    SELECT t.ancestor,8
    FROM TreePaths AS t
    WHERE t.descendant = 5
    UNION ALL
    SELECT 8,8

  执行之后:

  

  至于Comment表那就简单得不说了。

  四、删除叶子节点:

  好比删除叶子节点7,应删除全部TreePaths表中后代为7的行:

  DELETE FROM TreePaths WHERE descendant = 7

  五、删除子树:

  要删除一颗完整的子树,好比评论4和它的全部后代,可删除全部在TreePaths表中的后代为4的行,以及那些以评论4的后代为后代的行:

  DELETE FROM TreePaths
  WHERE descendant 
  IN(SELECT descendant FROM TreePaths WHERE ancestor = 4)

  另外,移动节点,先断开与原祖先的关系,而后与新节点创建关系的SQL语句都不难写。

  另外,闭包表还能够优化,如增长一个path_length字段,自我引用为0,直接子节点为1,再一下层为2,一次类推,查询直接自子节点就变得很简单。

总结

  其实,在以往的工做中,曾见过不一样类型的设计,邻接表,路径枚举,邻接表路径枚举一块儿来的都见过。

  每种设计都各有优劣,若是选择设计依赖于应用程序中哪一种操做最须要性能上的优化。 

  下面给出一个表格,来展现各类设计的难易程度:

设计 表数量 查询子 查询树 插入 删除 引用完整性
邻接表 1 简单 简单 简单 简单
枚举路径 1 简单 简单 简单 简单
嵌套集 1 困难 简单 困难 困难
闭包表 2 简单 简单 简单 简单

  一、邻接表是最方便的设计,而且不少软件开发者都了解它。而且在递归查询的帮助下,使得邻接表的查询更加高效。

  二、枚举路径可以很直观地展现出祖先到后代之间的路径,但因为不能确保引用完整性,使得这个设计比较脆弱。枚举路径也使得数据的存储变得冗余。

  三、嵌套集是一个聪明的解决方案,但不能确保引用完整性,而且只能使用于查询性能要求较高,而其余要求通常的场合使用它。

  四、闭包表是最通用的设计,而且最灵活,易扩展,而且一个节点能属于多棵树,能减小冗余的计算时间。但它要求一张额外的表来存储关系,是一个空间换取时间的方案。

相关文章
相关标签/搜索