邏輯數據庫設計 - 單純的樹(遞歸關係數據)

  相信有過開發經驗的朋友都曾碰到過這樣一個需求。假設你正在爲一個新聞網站開發一個評論功能,讀者能夠評論原文甚至相互回覆程序員

  這個需求並不簡單,相互回覆會致使無限多的分支,無限多的祖先-後代關係。這是一種典型的遞歸關係數據。數據庫

  對於這個問題,如下給出幾個解決方案,各位客觀可斟酌後選擇。閉包

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 簡單 簡單 簡單 簡單

  一、鄰接表是最方便的設計,而且不少軟件開發者都瞭解它。而且在遞歸查詢的幫助下,使得鄰接表的查詢更加高效。

  二、枚舉路徑可以很直觀地展現出祖先到後代之間的路徑,但因爲不能確保引用完整性,使得這個設計比較脆弱。枚舉路徑也使得數據的存儲變得冗餘。

  三、嵌套集是一個聰明的解決方案,但不能確保引用完整性,而且只能使用於查詢性能要求較高,而其餘要求通常的場合使用它。

  四、閉包表是最通用的設計,而且最靈活,易擴展,而且一個節點能屬於多棵樹,能減小冗餘的計算時間。但它要求一張額外的表來存儲關係,是一個空間換取時間的方案。

相關文章
相關標籤/搜索