mysql(多級(jí)分銷)無(wú)限極數(shù)據(jù)庫(kù)設(shè)計(jì)方法

相信有過開發(fā)經(jīng)驗(yàn)的朋友都曾碰到過這樣一個(gè)需求。假設(shè)你正在為一個(gè)新聞網(wǎng)站開發(fā)一個(gè)評(píng)論功能,讀者可以評(píng)論原文甚至相互回復(fù)。

這個(gè)需求并不簡(jiǎn)單,相互回復(fù)會(huì)導(dǎo)致無(wú)限多的分支,無(wú)限多的祖先-后代關(guān)系。這是一種典型的遞歸關(guān)系數(shù)據(jù)。

對(duì)于這個(gè)問題,以下給出幾個(gè)解決方案,各位客觀可斟酌后選擇。

一、鄰接表:依賴父節(jié)點(diǎn)

鄰接表的方案如下(僅僅說(shuō)明問題):

CREATE TABLE Comments(
    CommentId  int  PK,
    ParentId   int,    --記錄父節(jié)點(diǎn)
        ArticleId  int,
    CommentBody nvarchar(500),
    FOREIGN KEY (ParentId)  REFERENCES Comments(CommentId)   --自連接,主鍵外鍵都在自己表內(nèi) 
     FOREIGN KEY (ArticleId) REFERENCES Articles(ArticleId)
  )

由于偷懶,所以采用了書本中的圖了,Bugs就是Articles:

這種設(shè)計(jì)方式就叫做鄰接表。這可能是存儲(chǔ)分層結(jié)構(gòu)數(shù)據(jù)中最普通的方案了。

下面給出一些數(shù)據(jù)來(lái)顯示一下評(píng)論表中的分層結(jié)構(gòu)數(shù)據(jù)。示例表:

image

圖片說(shuō)明存儲(chǔ)結(jié)構(gòu):

image

****鄰接表的優(yōu)缺分析****

對(duì)于以上鄰接表,很多程序員已經(jīng)將其當(dāng)成默認(rèn)的解決方案了,但即便是這樣,但它在從前還是有存在的問題的。

  分析1:查詢一個(gè)節(jié)點(diǎn)的所有后代(求子樹)怎么查呢?

我們先看看以前查詢兩層的數(shù)據(jù)的SQL語(yǔ)句:

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

顯然,每需要查多一層,就需要聯(lián)結(jié)多一次表。SQL查詢的聯(lián)結(jié)次數(shù)是有限的,因此不能無(wú)限深的獲取所有的后代。而且,這種這樣聯(lián)結(jié),執(zhí)行Count()這樣的聚合函數(shù)也相當(dāng)困難。

說(shuō)了是以前了,現(xiàn)在什么時(shí)代了,在SQLServer 2005之后,一個(gè)公用表表達(dá)式就搞定了,順帶解決的還有聚合函數(shù)的問題(聚合函數(shù)如Count()也能夠簡(jiǎn)單實(shí)用),例如查詢?cè)u(píng)論4的所有子節(jié)點(diǎn):

WITH COMMENT_CTE(CommentId,ParentId,CommentBody,tLevel)AS(    --基本語(yǔ)句
    SELECT CommentId,ParentId,CommentBody,0 AS tLevel FROM Comment    WHERE ParentId = 4
    UNION ALL  --遞歸語(yǔ)句
    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

顯示結(jié)果如下:

image

那么查詢祖先節(jié)點(diǎn)樹又如何查呢?例如查節(jié)點(diǎn)6的所有祖先節(jié)點(diǎn):

WITH COMMENT_CTE(CommentId,ParentId,CommentBody,tLevel)AS(    --基本語(yǔ)句
    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

結(jié)果如下:

再者,由于公用表表達(dá)式能夠控制遞歸的深度,因此,你可以簡(jiǎn)單獲得任意層級(jí)的子樹。

OPTION(MAXRECURSION 2)

看來(lái)哥是為鄰接表平反來(lái)的。

分析2:****當(dāng)然,鄰接表也有其優(yōu)點(diǎn)的,例如要添加一條記錄是非常方便的。

INSERT INTO Comment(ArticleId,ParentId)...    --僅僅需要提供父節(jié)點(diǎn)Id就能夠添加了。

分析3:修改一個(gè)節(jié)點(diǎn)位置或一個(gè)子樹的位置也是很簡(jiǎn)單.

UPDATE Comment SET ParentId = 10 WHERE CommentId = 6  --僅僅修改一個(gè)節(jié)點(diǎn)的ParentId,其后面的子代節(jié)點(diǎn)自動(dòng)合理。

分析4:刪除子樹

想象一下,如果你刪除了一個(gè)中間節(jié)點(diǎn),那么該節(jié)點(diǎn)的子節(jié)點(diǎn)怎么辦(它們的父節(jié)點(diǎn)是誰(shuí)),因此如果你要?jiǎng)h除一個(gè)中間節(jié)點(diǎn),那么不得不查找到所有的后代,先將其刪除,然后才能刪除該中間節(jié)點(diǎn)。

當(dāng)然這也能通過一個(gè)ON DELETE CASCADE級(jí)聯(lián)刪除的外鍵約束來(lái)自動(dòng)完成這個(gè)過程。

分析5:刪除中間節(jié)點(diǎn),并提升子節(jié)點(diǎn)

面對(duì)提升子節(jié)點(diǎn),我們要先修改該中間節(jié)點(diǎn)的直接子節(jié)點(diǎn)的ParentId,然后才能刪除該節(jié)點(diǎn):

SELECT ParentId FROM Comments WHERE CommentId = 6;    --搜索要?jiǎng)h除節(jié)點(diǎn)的父節(jié)點(diǎn),假設(shè)返回4  
UPDATE Comments SET ParentId = 4 WHERE ParentId = 6;  --修改該中間節(jié)點(diǎn)的子節(jié)點(diǎn)的ParentId為要?jiǎng)h除中間節(jié)點(diǎn)的ParentId  
DELETE FROM Comments WHERE CommentId = 6;          --終于可以刪除該中間節(jié)點(diǎn)了

由上面的分析可以看到,鄰接表基本上已經(jīng)是很強(qiáng)大的了。

二、路徑枚舉

路徑枚舉的設(shè)計(jì)是指通過將所有祖先的信息聯(lián)合成一個(gè)字符串,并保存為每個(gè)節(jié)點(diǎn)的一個(gè)屬性。

路徑枚舉是一個(gè)由連續(xù)的直接層級(jí)關(guān)系組成的完整路徑。如"/home/account/login",其中home是account的直接父親,這也就意味著home是login的祖先。

還是有剛才新聞評(píng)論的例子,我們用路徑枚舉的方式來(lái)代替鄰接表的設(shè)計(jì):

CREATE TABLE Comments(
    CommentId  int  PK,
    Path      varchar(100),    --僅僅改變了該字段和刪除了外鍵    ArticleId  int,
    CommentBody nvarchar(500),
    FOREIGN KEY (ArticleId) REFERENCES Articles(ArticleId)
  )

簡(jiǎn)略說(shuō)明問題的數(shù)據(jù)表如下:

CommentId  Path    CommentBody

1       1/     這個(gè)Bug的成因是什么

2       1/2/     我覺得是一個(gè)空指針

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

4       1/4/     我們需要查無(wú)效的輸入

5       1/4/5/    是的,那是個(gè)問題

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

7       1/4/6/7/   解決了

路徑枚舉的優(yōu)點(diǎn):

對(duì)于以上表,假設(shè)我們需要查詢某個(gè)節(jié)點(diǎn)的全部祖先,SQL語(yǔ)句可以這樣寫(假設(shè)查詢7的所有祖先節(jié)點(diǎn)):

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

結(jié)果如下:

假設(shè)我們要查詢某個(gè)節(jié)點(diǎn)的全部后代,假設(shè)為4的后代:

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

結(jié)果如下:

一旦我們可以很簡(jiǎn)單地獲取一個(gè)子樹或者從子孫節(jié)點(diǎn)到祖先節(jié)點(diǎn)的路徑,就可以很簡(jiǎn)單地實(shí)現(xiàn)更多查詢,比如計(jì)算一個(gè)字?jǐn)?shù)所有節(jié)點(diǎn)的數(shù)量(COUNT聚合函數(shù))

插入一個(gè)節(jié)點(diǎn)也可以像和使用鄰接表一樣地簡(jiǎn)單??梢圆迦胍粋€(gè)葉子節(jié)點(diǎn)而不用修改任何其他的行。你所需要做的只是復(fù)制一份要插入節(jié)點(diǎn)的邏輯上的父親節(jié)點(diǎn)路徑,并將這個(gè)新節(jié)點(diǎn)的Id追加到路徑末尾就可以了。如果這個(gè)Id是插入時(shí)由數(shù)據(jù)庫(kù)生成的,你可能需要先插入這條記錄,然后獲取這條記錄的Id,并更新它的路徑。

路徑枚舉的缺點(diǎn):

1、數(shù)據(jù)庫(kù)不能確保路徑的格式總是正確或者路徑中的節(jié)點(diǎn)確實(shí)存在(中間節(jié)點(diǎn)被刪除的情況,沒外鍵約束)。

2、要依賴高級(jí)程序來(lái)維護(hù)路徑中的字符串,并且驗(yàn)證字符串的正確性的開銷很大。

3、VARCHAR的長(zhǎng)度很難確定。無(wú)論VARCHAR的長(zhǎng)度設(shè)為多大,都存在不能夠無(wú)限擴(kuò)展的情況。

路徑枚舉的設(shè)計(jì)方式能夠很方便地根據(jù)節(jié)點(diǎn)的層級(jí)排序,因?yàn)槁窂街蟹指魞蛇叺墓?jié)點(diǎn)間的距離永遠(yuǎn)是1,因此通過比較字符串長(zhǎng)度就能知道層級(jí)的深淺。

三、嵌套集

嵌套集解決方案是存儲(chǔ)子孫節(jié)點(diǎn)的信息,而不是節(jié)點(diǎn)的直接祖先。我們使用兩個(gè)數(shù)字來(lái)編碼每個(gè)節(jié)點(diǎn),表示這個(gè)信息??梢詫⑦@兩個(gè)數(shù)字稱為nsleft和nsright。

還是以上面的新聞-評(píng)論作為例子,對(duì)于嵌套集的方式表可以設(shè)計(jì)為:

CREATE TABLE Comments(
    CommentId  int  PK,
    nsleft    int,  --之前的一個(gè)父節(jié)點(diǎn)
       nsright   int,  --變成了兩個(gè)  
     ArticleId  int,
    CommentBody nvarchar(500),
    FOREIGN KEY (ArticleId) REFERENCES Articles(ArticleId)
  )

nsleft值的確定:nsleft的數(shù)值小于該節(jié)點(diǎn)所有后代的Id。

nsright值的確定:nsright的值大于該節(jié)點(diǎn)所有后代的Id。

當(dāng)然,以上兩個(gè)數(shù)字和CommentId的值并沒有任何關(guān)聯(lián),確定值的方式是對(duì)樹進(jìn)行一次深度優(yōu)先遍歷,在逐層入神的過程中依次遞增地分配nsleft的值,并在返回時(shí)依次遞增地分配nsright的值。

采用書中的圖來(lái)說(shuō)明一下情況:

image

一旦你為每個(gè)節(jié)點(diǎn)分配了這些數(shù)字,就可以使用它們來(lái)找到給定節(jié)點(diǎn)的祖先和后代。

嵌套集的優(yōu)點(diǎn):

我覺得是唯一的優(yōu)點(diǎn)了,查詢祖先樹和子樹方便。

例如,通過搜索那些節(jié)點(diǎn)的ConmentId在評(píng)論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;

結(jié)果如下:

通過搜索評(píng)論6的Id在哪些節(jié)點(diǎn)的nsleft和nsright范圍之間,就可以獲取評(píng)論6及其所有祖先:

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

這種嵌套集的設(shè)計(jì)還有一個(gè)優(yōu)點(diǎn),就是當(dāng)你想要?jiǎng)h除一個(gè)非葉子節(jié)點(diǎn)時(shí),它的后代會(huì)自動(dòng)地代替被刪除的節(jié)點(diǎn),稱為其直接祖先節(jié)點(diǎn)的直接后代。

嵌套集設(shè)計(jì)并不必須保存分層關(guān)系。因此當(dāng)刪除一個(gè)節(jié)點(diǎn)造成數(shù)值不連續(xù)時(shí),并不會(huì)對(duì)樹的結(jié)構(gòu)產(chǎn)生任何影響。

嵌套集缺點(diǎn):

1、查詢直接父親。

在嵌套集的設(shè)計(jì)中,這個(gè)需求的實(shí)現(xiàn)的思路是,給定節(jié)點(diǎn)c1的直接父親是這個(gè)節(jié)點(diǎn)的一個(gè)祖先,且這兩個(gè)節(jié)點(diǎn)之間不應(yīng)該有任何其他的節(jié)點(diǎn),因此,你可以用一個(gè)遞歸的外聯(lián)結(jié)來(lái)查詢一個(gè)節(jié)點(diǎn),它就是c1的祖先,也同時(shí)是另一個(gè)節(jié)點(diǎn)Y的后代,隨后我們使y=x就查詢,直到查詢返回空,即不存在這樣的節(jié)點(diǎn),此時(shí)y便是c1的直接父親節(jié)點(diǎn)。

比如,要找到評(píng)論6的直接父節(jié)點(diǎn):老實(shí)說(shuō),SQL語(yǔ)句又長(zhǎng)又臭,行肯定是行,但我真的寫不動(dòng)了。

2、對(duì)樹進(jìn)行操作,比如插入和移動(dòng)節(jié)點(diǎn)。

當(dāng)插入一個(gè)節(jié)點(diǎn)時(shí),你需要重新計(jì)算新插入節(jié)點(diǎn)的相鄰兄弟節(jié)點(diǎn)、祖先節(jié)點(diǎn)和它祖先節(jié)點(diǎn)的兄弟,來(lái)確保它們的左右值都比這個(gè)新節(jié)點(diǎn)的左值大。同時(shí),如果這個(gè)新節(jié)點(diǎn)是一個(gè)非葉子節(jié)點(diǎn),你還要檢查它的子孫節(jié)點(diǎn)。

夠了,夠了。就憑查直接父節(jié)點(diǎn)都困難,這個(gè)東西就很冷門了。我確定我不會(huì)使用這種設(shè)計(jì)了。

四、閉包表

閉包表是解決分層存儲(chǔ)一個(gè)簡(jiǎn)單而又優(yōu)雅的解決方案,它記錄了表中所有的節(jié)點(diǎn)關(guān)系,并不僅僅是直接的父子關(guān)系。
  在閉包表的設(shè)計(jì)中,額外創(chuàng)建了一張TreePaths的表(空間換取時(shí)間),它包含兩列,每一列都是一個(gè)指向Comments中的CommentId的外鍵。

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

父子關(guān)系表:

CREATE TABLE TreePaths(
  ancestor    int,
  descendant int,
  PRIMARY KEY(ancestor,descendant),    --復(fù)合主鍵 
   FOREIGN KEY (ancestor) REFERENCES Comments(CommentId),
  FOREIGN KEY (descendant) REFERENCES Comments(CommentId)
)

在這種設(shè)計(jì)中,Comments表將不再存儲(chǔ)樹結(jié)構(gòu),而是將書中的祖先-后代關(guān)系存儲(chǔ)為TreePaths的一行,即使這兩個(gè)節(jié)點(diǎn)之間不是直接的父子關(guān)系;同時(shí)還增加一行指向節(jié)點(diǎn)自己,理解不了?就是TreePaths表存儲(chǔ)了所有祖先-后代的關(guān)系的記錄。如下圖:

Comment表:

TreePaths表:

優(yōu)點(diǎn):

1、查詢所有后代節(jié)點(diǎn)(查子樹):


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

結(jié)果如下:

2、查詢?cè)u(píng)論6的所有祖先(查祖先樹):


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

顯示結(jié)果如下:

3、插入新節(jié)點(diǎn):

要插入一個(gè)新的葉子節(jié)點(diǎn),應(yīng)首先插入一條自己到自己的關(guān)系,然后搜索TreePaths表中后代是評(píng)論5的節(jié)點(diǎn),增加該節(jié)點(diǎn)與要插入的新節(jié)點(diǎn)的"祖先-后代"關(guān)系。

比如下面為插入評(píng)論5的一個(gè)子節(jié)點(diǎn)的TreePaths表語(yǔ)句:

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

執(zhí)行以后:

至于Comment表那就簡(jiǎn)單得不說(shuō)了。

4、刪除葉子節(jié)點(diǎn):

比如刪除葉子節(jié)點(diǎn)7,應(yīng)刪除所有TreePaths表中后代為7的行:

DELETE FROM TreePaths WHERE descendant = 7

5、刪除子樹:

要?jiǎng)h除一顆完整的子樹,比如評(píng)論4和它的所有后代,可刪除所有在TreePaths表中的后代為4的行,以及那些以評(píng)論4的后代為后代的行:

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

另外,移動(dòng)節(jié)點(diǎn),先斷開與原祖先的關(guān)系,然后與新節(jié)點(diǎn)建立關(guān)系的SQL語(yǔ)句都不難寫。

另外,閉包表還可以優(yōu)化,如增加一個(gè)path_length字段,自我引用為0,直接子節(jié)點(diǎn)為1,再一下層為2,一次類推,查詢直接自子節(jié)點(diǎn)就變得很簡(jiǎn)單。

總結(jié)

其實(shí),在以往的工作中,曾見過不同類型的設(shè)計(jì),鄰接表,路徑枚舉,鄰接表路徑枚舉一起來(lái)的都見過。

每種設(shè)計(jì)都各有優(yōu)劣,如果選擇設(shè)計(jì)依賴于應(yīng)用程序中哪種操作最需要性能上的優(yōu)化。

下面給出一個(gè)表格,來(lái)展示各種設(shè)計(jì)的難易程度:

設(shè)計(jì) 表數(shù)量 查詢子 查詢樹 插入 刪除 引用完整性
鄰接表 1 簡(jiǎn)單 簡(jiǎn)單 簡(jiǎn)單 簡(jiǎn)單
枚舉路徑 1 簡(jiǎn)單 簡(jiǎn)單 簡(jiǎn)單 簡(jiǎn)單
嵌套集 1 困難 簡(jiǎn)單 困難 困難
閉包表 2 簡(jiǎn)單 簡(jiǎn)單 簡(jiǎn)單 簡(jiǎn)單

1、鄰接表是最方便的設(shè)計(jì),并且很多軟件開發(fā)者都了解它。并且在遞歸查詢的幫助下,使得鄰接表的查詢更加高效。

2、枚舉路徑能夠很直觀地展示出祖先到后代之間的路徑,但由于不能確保引用完整性,使得這個(gè)設(shè)計(jì)比較脆弱。枚舉路徑也使得數(shù)據(jù)的存儲(chǔ)變得冗余。

3、嵌套集是一個(gè)聰明的解決方案,但不能確保引用完整性,并且只能使用于查詢性能要求較高,而其他要求一般的場(chǎng)合使用它。

4、閉包表是最通用的設(shè)計(jì),并且最靈活,易擴(kuò)展,并且一個(gè)節(jié)點(diǎn)能屬于多棵樹,能減少冗余的計(jì)算時(shí)間。但它要求一張額外的表來(lái)存儲(chǔ)關(guān)系,是一個(gè)空間換取時(shí)間的方案。

轉(zhuǎn)自:https://www.phpbloger.com/article/50.html

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容