SQL Antipattern 樹之反模式(評論回覆 數據庫表設計)

我們通常在SQL中實現數,都使用了鄰接表。但是事實上鄰接表卻有相當多的不足,相信屬性SQL的開發者也應該清楚了。例如在設置評論的表我們如如下設計:

CREATE TABLE comments(comment_id INT PRIMARY KEY AUTO_INCREMENT,
              comment_text VARCHAR(300),
              user_id INT,
              reply_comment_id INT,
        FOREIGN KEY(reply_comment_id) REFERENCES comments(comment_id),
        FOREIGN KEY(user_id) REFERENCES users(user_id));

但是事實上這樣做也有非常多的不足,例如查詢整個樹的信息我們就需要觸發多條SQL去滿足這一需求。如果是設計這樣的表比較適合呢?路徑枚舉和閉包表是常用的解決方案,以下就看看什麼叫路徑枚舉和閉包表。

路徑枚舉非常簡答,就是講整個路徑存儲在一個字段當中,然後通過路徑字符串查詢的方式去獲得相應的數數據
我們創建如下表去說明問題:

CREATE TABLE comments(comment_id INT PRIMARY KEY AUTO_INCREMENT,
            comment_text VARCHAR(300),user_id INT,
            comment_path VARCHAR(500),
            FOREIGN KEY(user_id) REFERENCES users(user_id));

我們現在實現一個比較常見的業務,就是像朋友圈那樣互相評論,然後我會將一個評論數查出,先插入評論語句:

INSERT INTO comments(comment_text,user_id) VALUES('TONY:發起的第一條評論!',1);
UPDATE comments SET comment_path = concat(last_insert_id(),'/' ) WHERE comment_id = last_insert_id() ;

INSERT INTO comments(comment_text,user_id) VALUES('YAN: 回覆TONY發起的第一條評論',2);
SET @comment_path=concat( (SELECT comment_path FROM comments WHERE comment_id = 1 ) , concat(last_insert_id() , '/' )) ;
UPDATE comments 
SET comment_path = @comment_path
WHERE comment_id = last_insert_id();

INSERT INTO comments(comment_text,user_id) VALUES('TONY:回覆YAN的評論,即回覆ID爲2的評論',1);
SET @comment_path=concat( (SELECT comment_path FROM comments WHERE comment_id = 2 ) , concat(last_insert_id() , '/' )) ;
UPDATE comments 
SET comment_path = @comment_path
WHERE comment_id = last_insert_id();


INSERT INTO comments(comment_text,user_id) VALUES('CHAO:評論TONY發起的第一條評論',3);
SET @comment_path=concat( (SELECT comment_path FROM comments WHERE comment_id = 1 ) , concat(last_insert_id() , '/' )) ;
UPDATE comments 
SET comment_path = @comment_path
WHERE comment_id = last_insert_id();

然後我們看看插入數據後的數據庫comments表內容:
comments表內容
我們可以看見路徑之間的關係,第一條評論是評論數的根節點,然後通過路徑字符串的方式把路徑存儲在表中的字段當中,例如ID:3的評論是回覆了ID:2的評論,而ID:2的評論是回覆了ID:1,而ID:4回覆的是評論ID:1的評論。這樣是最簡單的層次關係描述,在查詢過程當中也非常容易操作,例如我想獲得評論ID:1的所有評論:

SELECT * FROM comments WHERE comment_path LIKE '1/%';

例如我想獲得ID:3評論的所有父節點:
在程序棧當中使用split函數對coment_path進行轉換成數組,然後通過IN語句獲得所有的父節點

SELECT * FROM comments WHERE comment_id in (1,2,3);

in中的ID通過程序或者自定義函數截獲。
通過這種做法我們如果想刪除一條評論,然後將他的子節點評論一同刪除變得異常簡單:

DELETE FROM comments WHERE comment_path LIKE '%/2/%';

這種刪除可能會出現錯誤,錯誤原因可能是因爲 開啓了安全更新模式。

但是這種的樹處理所出現的問題顯然易見,因爲我們是通過一個VARCHAR字段去存儲這個數路徑的,所以這個數節點數量存儲,存在相當大的限制。其限制主要是在於字段的長度以及存儲的節點ID長度有關。如果我們希望不受到限制,我們可以使用閉包表去實現,但是相對的我們就會提高了整個功能的SQL查詢難度。

閉包表
首先我們考慮的是閉包表的表結構應該如何去修改,同時我們會按照之前路徑枚舉的評論數據模式去演示其原理(評論關係圖,字醜將就看):
評論數據關係

以下是閉包表的評論表結構,這裏我們需要兩張表:

CREATE TABLE comments(comment_id INT PRIMARY KEY AUTO_INCREMENT
            ,comment_text VARCHAR(300),user_id INT
            ,FOREIGN KEY(user_id) REFERENCES users(user_id));

CREATE TABLE comment_tree_paths(ancestor INT  NOT NULL
                ,descendant INT  NOT NULL
                ,PRIMARY KEY(ancestor,descendant)
                ,FOREIGN KEY(ancestor) REFERENCES comments(comment_id)
                ,FOREIGN KEY(descendant) REFERENCES comments(comment_id));      

可以看到我們多出了一張名爲comment_tree_paths 的表,裏面我們有兩個主要的字段分別是 ancestor(祖先) 和 descendant (後代)。那我們應該如何評論路徑進入這張表呢?看下圖,雖然比較醜但是基本上演示了表中的數據關係按照:
comment_tree_paths數據關係

按照這種結構我們先插入評論數據:

INSERT INTO comments(comment_text,user_id) VALUES('TONY:發起的第一條評論!',1);
SET @NEW_COMMENT_ID = last_insert_id();
INSERT INTO comment_tree_paths(ancestor,descendant) VALUES(@NEW_COMMENT_ID,@NEW_COMMENT_ID);


INSERT INTO comments(comment_text,user_id) VALUES('YAN: 回覆TONY發起的第一條評論',2);
SET @NEW_COMMENT_ID = last_insert_id();
INSERT INTO comment_tree_paths(ancestor,descendant) 
SELECT t.ancestor,@NEW_COMMENT_ID FROM comment_tree_paths as t WHERE t.descendant = 1
UNION ALL SELECT @NEW_COMMENT_ID,@NEW_COMMENT_ID;


INSERT INTO comments(comment_text,user_id) VALUES('TONY:回覆YAN的評論,即回覆ID爲2的評論',1);
SET @NEW_COMMENT_ID = last_insert_id();
INSERT INTO comment_tree_paths(ancestor,descendant) 
SELECT t.ancestor,@NEW_COMMENT_ID FROM comment_tree_paths as t WHERE t.descendant = 2
UNION ALL SELECT @NEW_COMMENT_ID,@NEW_COMMENT_ID;


INSERT INTO comments(comment_text,user_id) VALUES('CHAO:評論TONY發起的第一條評論',3);
SET @NEW_COMMENT_ID = last_insert_id();
INSERT INTO comment_tree_paths(ancestor,descendant) 
SELECT t.ancestor,@NEW_COMMENT_ID FROM comment_tree_paths as t WHERE t.descendant = 1
UNION ALL SELECT @NEW_COMMENT_ID,@NEW_COMMENT_ID;

其實也沒有想象中複雜,關鍵是要把結構關係搞懂,目前還沒有明白不要緊,在查詢的時候就明白了,下面是插入後的comment_treee_paths表的內容:
comment_treee_paths表的內容

如果我想查詢獲得評論ID:2之後的回覆評論:

SELECT * FROM  comments c WHERE c.comment_id IN (SELECT c.descendant FROM comment_tree_paths c where c.ancestor = 2);
#或者
SELECT c.* FROM comments c LEFT JOIN comment_tree_paths p on c.comment_id = p.descendant WHERE p.ancestor = 2;

事實上我們也能夠非常好地獲得評論樹的上游節點(獲得評論ID:3的所有上游節點):

SELECT * FROM comments c WHERE c.comment_id IN (SELECT p.ancestor FROM comment_tree_paths p WHERE p.descendant = 3 );
#或者
SELECT c.* FROM comments c LEFT JOIN comment_tree_paths p on c.comment_id = p.ancestor WHERE p.descendant = 3;

如果我們想刪除子節點,並且將旗下的子節點一同刪除:

DELETE FROM comment_tree_paths WHERE descendant IN (SELECT descendant FROM comment_tree_paths WHERE ancestor = 2);

但是需要注意的是,如果我們刪除了paths中的關聯記錄就會失去其關聯數據,導致無法刪除comments表中的數據,所以我們需要在刪除paths的關聯數據之前先保存關聯信息到程序當中,然後在刪除paths中的關聯信息。

SELECT descendant FROM comment_tree_paths WHERE ancestor = 2;

然後我們會發現一個問題就是我們無法去定位我們的直系上游節點或者下游節點,我們可以採用以下的優化手段,添加一個字段存儲層級關係:

ALTER TABLE comment_tree_paths ADD path_length INT;

然而插入的時候也需要進行修改:

INSERT INTO comments(comment_text,user_id) VALUES('TONY:發起的第一條評論!',1);
SET @NEW_COMMENT_ID = last_insert_id();
INSERT INTO comment_tree_paths(ancestor,descendant,path_length) VALUES(@NEW_COMMENT_ID,@NEW_COMMENT_ID,0);


INSERT INTO comments(comment_text,user_id) VALUES('YAN: 回覆TONY發起的第一條評論',2);
SET @NEW_COMMENT_ID = last_insert_id();
SET @LENGTH_COUNT = 0;
INSERT INTO comment_tree_paths(ancestor,descendant,path_length) 
SELECT * FROM (SELECT t.ancestor,@NEW_COMMENT_ID,@LENGTH_COUNT:=@LENGTH_COUNT+1
FROM comment_tree_paths as t WHERE t.descendant = 1 order by t.path_length asc) as temp_sort_table
UNION ALL SELECT @NEW_COMMENT_ID,@NEW_COMMENT_ID,0 ;


INSERT INTO comments(comment_text,user_id) VALUES('TONY:回覆YAN的評論,即回覆ID爲2的評論',1);
SET @NEW_COMMENT_ID = last_insert_id();
SET @LENGTH_COUNT = 0;
INSERT INTO comment_tree_paths(ancestor,descendant,path_length) 
SELECT * FROM (SELECT t.ancestor,@NEW_COMMENT_ID,@LENGTH_COUNT:=@LENGTH_COUNT+1
FROM comment_tree_paths as t WHERE t.descendant = 2 order by t.path_length asc) as temp_sort_table
UNION ALL SELECT @NEW_COMMENT_ID,@NEW_COMMENT_ID,0 ;


INSERT INTO comments(comment_text,user_id) VALUES('CHAO:評論TONY發起的第一條評論',3);
SET @NEW_COMMENT_ID = last_insert_id();
SET @LENGTH_COUNT = 0;
INSERT INTO comment_tree_paths(ancestor,descendant,path_length) 
SELECT * FROM (SELECT t.ancestor,@NEW_COMMENT_ID,@LENGTH_COUNT:=@LENGTH_COUNT+1
FROM comment_tree_paths as t WHERE t.descendant = 1 order by t.path_length asc) as temp_sort_table
UNION ALL SELECT @NEW_COMMENT_ID,@NEW_COMMENT_ID,0 ;

當然這樣是比較複雜的,需要注意的是,由於UNION ALL 語句對order by 的某些限制 所以我在外面再套了一層SELECT 否則 排序會出現問題,導致你的LENGTH_COUNT計算的順序出錯,以下是插入結果:
comment_tree_paths表數據

查詢還是一樣的不過我們可以使用order by 對path_length 進行層級排序,從而可以獲得直接的上游節點。

SELECT c.* FROM comments  c JOIN comment_tree_paths p on p.ancestor = c.comment_id WHERE p.descendant = 3 order by p.path_length;

獲得上游節點的結果

我們可以使用通過這種方式獲得其直接子節點

SELECT c.*,p.path_length FROM comments  c JOIN comment_tree_paths p on p.descendant = c.comment_id WHERE p.ancestor = 1 order by p.path_length;

獲得下游節點的結果
但是如果出現分叉我們就需要在進一步的語句進行數的分支關聯了,這裏就不再解說了。

總結:其實無論鄰接表也好,路徑枚舉或者是閉表表也好。我們歸根到底也是需要選擇適合我們業務的方法。如果我們限制了數的節點數,我們當然可以使用路徑枚舉,這樣方便。但是如果我們不限制而且需要獲得上游或者下游的間接或者直接的節點,我們就應該用閉包表去實現。如果我們根本沒有必要去知道所有的間接上游或者下游節點,我們可以使用鄰接表。其實SQL的設計是一樣非常需要技巧的東西,在我們的工作當中不停的會出現這種反模式,其實這個方式我是在SQL Antipattern一書中獲得的技巧,如果有對SQL設計有興趣的同學也可以讀讀,相信獲益匪淺。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章