關係型數據庫樹形結構的設計

背景

而後(story branch)咱們最近開發的一塊app,一個標題對應一個故事,每一個故事由一段一段的故事組成。每一段故事都由用戶編寫。每一個故事看做是一個節點。在故事的建立的時候便有一個節點。隨後,用戶能夠在任意一個節點後面再接一個節點,這樣,最終就會造成一棵樹。因爲關係型數據庫並無一個很好的樹形結構設計的解決方案。下面,就以而後這款產品爲例,列出幾種關係型數據庫的解決方案並討論他們的優點與劣勢。數據庫

解決方案

鄰接表

鄰接表就是把全部的節點都放在一張表中,而後用一個屬性來每一個節點的父節點記錄下來,簡化的建表語句以下:閉包

CREATE TABLE story (
    story_id INT NOT NULL PRIMARY KET AUTO_INCREMENT,
    father_id INT NOT NULL,
    subject_id INT NOT NULL,
    content VARCHAR(600) NOT NULL,
    FOREIGN KEY (father_id) REFERENCES story(story_id),
    FOREIGN KEY (subject_id) REFERENCES story_subject(subject_id)
)

優勢

維護起來比較方便
增長一個節點只須要:app

INSERT INTO story (father_id, content)
    VALUE (1, 'blablabla');

修改一個節點或者一顆子樹的位置:函數

UPDATE story SET father_id = 3 WHERE story_id = 4;

缺點

查詢會變得很噁心:

一次只能查詢有限層的節點,並且每查詢多一層都要套多一層鏈接語句:性能

SELECT story1.*, story2.*
    FROM story AS story1
    LEFT OUTER JOIN story AS story2
        ON story2.father_id = story1.story_id;

另一種查詢就是先把整棵樹的信息取出來,在外部用程序構造出樹再操做,然而這樣會變得很低效。編碼

SELECT * FROM story WHERE subject_id = 3;

刪除會變得很噁心

必須寫不少額外的代碼來進行屢次的查詢,得到後代節點的信息,而後再進行刪除spa

路徑枚舉

在story表中設置一個屬性,來存儲從根節點到當前結點的路徑,用分隔符隔開設計

建表SQL:code

CREATE TABLE story(
    story_id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    content VARCHAR(600) NOT NULL,
    subject_id INT NOT NULL,
    path VARCHAR(1000) NOT NULL,
    FOREIGN KEY (subject_id) REFERENCES story_subject(subject_id)
)

存儲的結果以下:遞歸

story_id content subject_id path
1 blabla 2 1/
2 blabla 2 1/2/
3 blabla 2 1/2/3/
4 blabla 2 1/4/
5 blabla 2 1/4/5/
6 blabla 2 1/4/6/
7 blabla 2 1/4/6/7/

優勢

能夠比較的查詢到一個節點的祖先和後代

能夠經過寫zheyang的一個比較路徑的查詢:

SELECT *
FROM story
WHERE '1/4/6/7/ LIKE story.path || '%';

這個查詢語句匹配到1/4/6/%, 1/4/%, 1/%這寫剛好爲節點7的祖先的節點

一樣也能夠寫這樣的一個查詢語句來得到他全部的後代:

SELECT *
FROM story
WHERE story.path LIKE '/1/4/6/7/' || '%';

有了這些祖先和後代,就能夠進一步的得到更多的數據,好比說一顆子樹全部節點的總和

插入一個節點也比較簡單

只須要複製一份父節點的path,加上本身這個節點的就能夠了,能夠用MySQL的函數LAST_INSERT_ID():

INSERT INTO story (content, subject_id)
    VALUES
    ('blabla', 2);


UPDATE story
    SET path = (SELECT path
        FROM story
        WHERE story_id = 7) || LAST_INSERT_ID() || '/'
    WHERE story_id = LAST_INSERT_ID();

缺點

  1. 數據庫沒有約束來確實保證路徑的格式老是正確

  2. 也不能保證路徑中的節點確實存在

  3. 依賴程序的邏輯代碼來維護路徑的字符串而且驗證字符串的正確性的開銷比較大

  4. VARCHAR的長度有限,所以樹不能無限擴展

嵌套集

作法是用兩個數字來編碼每一個節點,而不是記錄他的直接祖先。這兩個數字命名爲nsleft, nsright

建表以下:

CREATE TABLE story(
    story_id INT NOT NULL PRIMARY KEY,
    content VARCHAR(600) NOT NULL,
    subject_id INT NOT NULL,
    nsleft INT NOT NULL,
    nsright INT NOT NULL,
    FOREIGN KEY (subject) REFERENCES story_subject(subject_id)
);

nsleft與nsright的約束規則以下:

  1. nsleft 的數值小於該節點的全部後代的nsleft

  2. nsright 的數值大於該節點的全部後代nsright

  3. 具體的nsleft, nsright和該節點的id並無直接的關聯

要肯定這兩個值最簡單的方法就是,對這棵樹進行一次後序遍歷,設置一個變量i,每一棟一次就自增1,每次訪問一個節點的時候,就把i的值賦給nsleft,每次返回到這個節點的時候就把i的值賦給nsright,結果如圖:
嵌套集

圖片來自《SQL反模式》

優勢

能夠很方便的找到一個節點的祖先和後代

經過搜索nsleft在節點4的nsleft和nsright範圍之間來獲取節點4及其全部後代

SELECT story2.*
FROM story AS story1
JOIN story AS story2
    ON story2.nsleft BETWEEN story1.nsleft AND story1.nsright
WHERE story1.story_id = 4;

經過搜索節點4的nsleft在那些節點的nsleft和nsright範圍以內能夠獲取節點4的全部祖先

SELECT story2.*
FROM story AS story1
JOIN story AS story2
    ON story1.nsleft BETWEEN story2.nsleft AND story2.neright

能夠很方便的刪除節點

因爲嵌套集是經過大小範圍來肯定祖先-後代關係的,且不記錄具體的層級關係,因此當刪除一個節點的時候,他的直接後代就會直接的接到改節點的父節點上,而不用從新分配nsleft,nsright的值

缺點

有些很簡單的查詢(找老爸)就會變得很噁心

在嵌套集中,要這麼找老爸:
若是節點p是節點q的老爸,那他一定是q的一個祖先,而且這兩個節點之間不會有其餘節點。

  1. 令y爲根節點

  2. 不斷的去試一個點,使得他既是y的後代,又是q的祖先

  3. 令y=x

  4. 不斷重複2,3直到結果爲空

查詢結果爲空的那一刻的y就是p

SELECT father.*
FROM story AS s
JOIN story AS father
    ON s.nsleft BETWEEN father.nsleft AND father.nsright
LEFT OUTER JOIN story AS in_between
    ON s.nsleft BETWEEN in_between.nsleft AND in_between.nsright
WHERE s.story_id = 6 AND in_between.story_id ISNULL;

插入和移動節點也會變得很噁心

每次插入和移動節點都須要從新計算節點的nsleft,nsright值

閉包表

作法是在story表中不保存任何的關係,而是新開一個表ance_desc表
把全部節點的全部祖前後代關係都保存(包括跨節點的),再者,能夠加多一個屬性path_length來存儲祖先節點到後代節點的距離

建表以下

CREATE TABLE story(
    story_id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    subject_id INT NOT NULL,
    content VARCHAR(600) NOT NULL,
    FOREIGN KEY (subject_id) REFERENCES story_subject(subject_id)
);

CREATE TABLE ance_desc(
    ancestor INT NOT NULL,
    descendant INT NOT NULL,
    PRIMARY KEY (ancestor, descendant),
    FOREIGN KEY (ancestor) REFERENCES story(story_id),
    FOREIGN KEY (descendant) REFERENCES story(story_id);
);

如嵌套集那張圖的樹在ance_desc表中就會存儲以下

ancestor descendant
1 2
1 3
1 4
1 5
1 6
1 7
2 2
2 3
3 3
4 4
4 5
4 6
4 7
5 5
6 6
6 7
7 7

優勢

查詢與刪除很是方便

若是要找節點4的後代,只須要找ance_desc表中祖先是4的就能夠了

要獲取節點7的祖先,只須要找ance_desc表中後代爲7的就能夠了

要刪除葉子節點,就刪除後代爲節點7的的行

要刪除結點4的子樹,就刪除ance_desc表中和4有關的行。特別說明的是,若是僅僅是想刪除關係,而不想刪除具體數據,這種設計就很是到位。

缺點

移動雖不像嵌套那麼麻煩,但也不太方便

要插入一個葉子節點,先在ance_desc表中插入本身到本身的關係,而後找後代是節點5的節點,而後再插入表就好了

要移動一棵子樹的時候,要先刪除它的全部子節點和他全部祖先節點的關係:

DELETE FROM ance_desc
WHERE descendant IN (SELECT descendant
                    FROM ance_desc
                    WHERE ancestor = 6)
    AND ancester IN (SELECT ancestor
                    FROM ance_desc
                    WHERE descendant = 6
                    AND ancestor != descendant)

而後,將這個孤立的樹和他的祖先創建聯繫,可使用CROSS JOIN來實現:

INSERT INTO ance_desc (ancestor, descendant)
    SELECT supertree.ancestor, subtree.descendant
    FROM ance_desc AS supertree
    CROSS JOIN ance_desc AS subtree
    WHERE supertree.descendant = 3
        AND subtree.ancestor = 6;

總結

  1. 鄰接表是比較方便的設計,查詢單個節點,插入,刪除,比較簡單,同時能保證引用完整性,可是查詢一顆樹比較複雜。若是數據庫支持遞歸查詢,那麼鄰接表查詢效率會更高

  2. 枚舉路徑在查詢單個節點,查詢一個樹,插入,刪除,都比較見長,可是卻不能保證引用完整性,使得設計很脆弱,數據存儲也比較榮譽

  3. 嵌套集只適用於對於查詢性能要求很高的場景

  4. 閉包表是一個比較折中的方案,他沒有什麼是不擅長的,是一種用空間換時間的方案!

相關文章
相關標籤/搜索