樹形結構的各個存取方案對比--《sql反模式》


樹
在這裏插入圖片描述

問題

需要存儲樹型結構的數據, 比如存儲公司組織架構, 或論壇的評論區. 如何設計庫表

下面提供多種方案並分析各方案的優缺點

  1. 鄰接表
  2. 遞歸查找
  3. 路徑枚舉
  4. 嵌套集
  5. 閉包集

(具體的庫表方案需要結合具體業務 , 充分考慮各個方案的優缺點後選擇 , 沒有萬能的方案 , 也不要過度設計)

鄰接表

簡介

最容易想到的方式就是鄰接表了(單表, 每行存儲一個parentId外鍵).
(因爲節點與父節點屬於多對一關係, 所以在多的一方存儲外鍵).
表結構如下:(tree表)

id node_info parent_id
1 根節點… 0
2 第一層節點… 1

優勢

  1. 庫表結構簡單直觀
  2. 查詢直接父節點或直接子節點簡單(inner join 或 子查詢等)
  3. 插入單個節點簡單(只需設置新節點的parentId, 並更新子節點的parentId)

劣勢

  1. 查詢某節點的 子樹 麻煩. 比如想一次性看到某部門的所有子部門
  2. 查詢某節點的 所有父節點 麻煩
  3. 刪除 子樹 麻煩, 比如想刪除某評論以及該評論延伸的所有子評論(有點繞)

鄰接表適用於層次比較少且基本不擴展的樹型結構.(比如省/市/縣的存取)

遞歸查詢

簡介

遞歸查詢就是在鄰接表的基礎上改良的(解決劣勢問題).
鄰接表 在查詢節點的子樹或節點的所有父節點麻煩, 那麼 遞歸查詢 的做法就是創建DB的函數實現遞歸查找.
下圖是查詢某個節點的所有孩子節點的DB函數(查詢節點的所有父節點類似)
遞歸查找
優勢

  1. 庫表結構簡單直觀
  2. 查詢直接父節點或直接子節點簡單
  3. 代碼層面簡潔(dao層調用簡單)

劣勢

  1. DB函數不直觀
  2. 業務邏輯遷移到DB , 調試定位問題不方便

路徑枚舉

簡介

(用的比較多)
存儲跟節點到當前節點的完整路徑.比如下面這棵樹:
樹

id path
1 1/
2 1/2/
3 1/3/
4 1/2/4/
5 1/2/5/

查詢
比如要查詢2號節點的所有子節點:

select * from tree where path like '1/2/' || '%'; // (最左前綴匹配)

這樣, 就能搜索到1/2/4/ , 1/2/5/路徑的節點;

要查找4號節點的所有父節點:

select * from tree where '1/2/4/' like path || '%';

這樣, 就能匹配到1/2/, 1/ 這幾個路徑的節點;

要查找某個節點的下兩層子節點, 只需在上面兩個sql基礎上添加過濾條件即可.

select * from tree where ( path like '1/2/' || '%') and (LENGTH(path) - LENGTH( REPLACE(path,'/','')) = 4);

注: (LENGTH(path) - LENGTH( REPLACE(path,’/’,’’)) = 4) 是判斷path的層次高度=4.
mysql沒有直接查詢某個字符在字符串中出現的次數的.囧…

刪除
刪除葉子節點只需要按照查詢方式定位到節點並刪除即可.
刪除非葉子節點時, 除了要刪除刪除對應的節點, 還需要更新該節點的所有子孫節點的路徑, 使用mysql的replace函數即可;
(業務場景:比如要在公司內解散某個中間部門實現扁平化管理)

新增
在某個節點下面插入葉子節點
思路:在父節點路徑基礎上添加自身路徑作爲新節點的路徑
如果插入的是中間節點, 除了需要插入當前節點外, 還需要修改子節點的路徑.(mysql的replace函數)

優勢

  1. 可以快速查找到節點的父節點/子節點或進行修改刪除
  2. 插入(葉子/非葉子)節點簡單

劣勢

  1. 需要業務代碼維護路徑, 沒法設置外鍵約束
  2. path的字段長度不好設置

嵌套集

(很少用到, 比較複雜)

簡介

其他方案的節點與節點之間都是強關聯的, 比如鄰接表直接存儲了parentId, 路徑枚舉的路徑直接包含了parent的id. 但嵌套集的節點與節點之間屬於弱關聯關係;
每個節點不僅包含節點自身信息, 還包含節點的左右腳(left, right) 來表示該節點的嵌套範圍.(節點的id和左右腳編號沒有關聯)
例如:
下面的庫表的記錄. 1號節點不僅包含自身信息, 還包含nsleft和nsright字段信息.
所以1號節點的區間是1~14.其他節點的左右腳在這個範圍內都屬於1號節點的子節點.比如2號節點的2 ~ 5就被嵌套了.(這是查詢子孫節點的思路)

同理, 查詢父節點只需要查找哪些節點包含了當前節點的左右腳 . 比如5號節點是7~8, 那麼嵌套了這個範圍的節點有1 ~14 , 6 ~ 13. 也就是1號節點和4號節點.(查詢祖先節點的思路)
(當然, 不存在只嵌套一個腳的情況, 因爲左右腳的編號採用深度遍歷的算法).

樹型結構如下:
在這裏插入圖片描述
庫表如下:
在這裏插入圖片描述
優勢

  1. 查詢所有孩子節點或所有祖先節點簡單.(按上面的嵌套就行)
  2. 刪除單個節點快 且 不斷層(就是刪除當前節點時, 該節點的子孫節點會自動成爲當前節點的父節點的子節點…有點繞)
//比如查詢節點的所有後代:
select 
	c2.*
from 
	Comment as c1 join Comment as c2 
	on (c2.nsleft between c1.nsleft and c1.nsright )
where 
	c1.id = 4;
//查詢某節點的所有祖先:
select 
	c2.*
from 
	Comment as c1 join Comment as c2 
	on (c1.nsleft between c2.nsleft and c2.nsright)
where c1.id = 7;

劣勢

  1. 查詢直接父節點/直接子節點困難
    因爲節點與父節點沒有強關聯, 都是通過嵌套來確定祖先後代節點的.
    如果需要找到直接父節點(直接子節點同理), 思路:
    找到當前節點的所有父節點, 然後假定父節點中的某個節點與當前節點中間已經沒有其他節點了. 那麼該節點就是當前節點的直接父節點.
    在這裏插入圖片描述

  2. 插入和移動節點複雜(涉及左右腳的編號調整)
    插入和移動節點時,涉及到左右腳編號的重新排序, 略複雜. 講下思路:
    比如要在3號節點下面插入一個節點, 最直觀的思路就是讓3號節點的左右腳從3~4變成3 ~6, 這樣3號節點下面就可以放一個節點, 新節點的左右腳爲4 ~5; 可以看到需要調整3號節點的左右腳, 但是因爲整個樹的左右腳都是通過深度遍歷來編號的. 因此, 需要調整涉及到的節點的左右腳.(這裏需要調整整棵樹的左右腳編號.更新的行數會比較多).
    在這裏插入圖片描述

閉包集

簡介

閉包, 顧名思義, 存儲了兩個相關聯節點的所有路徑.將有關聯的關係都存儲起來 , 跟上面的所有方案不同, 閉包集有兩個表, 一個表存儲每個節點的基礎信息 , 另外一個表存儲關聯關係.(有些關聯關係表還會爲每個路徑帶上該路徑的高度)
(關聯關係表的數據量增長很快, 慎重)

下面主要討論關聯關係表.
(閉包: 比如A->B,B->C , 那麼閉包集爲:{A->B, A->C,B->C}).
在這裏插入圖片描述
關聯關係表:(樹型圖的所有關係都會存儲到下面這個表裏)
表名:TreePaths

ancestor descendant height
1 1 0
1 2 1
1 3 2

優勢

  1. 查詢/更新 快
    如查詢某節點的高度爲2的子節點id
select 
	descendant 
from 
	TreePaths
where 
	ancestor = xx and height = 2;

查詢某節點的高度爲2的父節點id.(只有一個):

select 
	ancestor 
from 
	TreePaths 
where 
	descendant = xx and height = 2;

(查詢所有父節點id或者所有子節點id就將height條件去掉就行.)
(需要獲取節點的基礎信息再通過上面的查詢結果去查詢基礎信息表即可. 生產環境很少進行join操作)

  1. 插入快
    要在某個節點下面插入葉子節點, 只需要將指向父節點的所有節點都指向一遍新的節點(再加上自己指向自己).
    比如5號節點下面插入一個子節點, 首先插入一條自己指向自己的關係, 然後搜索TreePaths表的後代是5的節點, 增加該節點和新節點的"祖先-後代" 關係:
    閉包樹插入子節點
    如果是插入中間節點, 除了需要像插入葉子節點那樣操作之外, 還需要讓新節點指向所有孩子節點.(閉包)

  2. 刪除
    要刪除某個節點, 只需要刪除該節點指向孩子節點的路徑 並且刪除所有父節點指向該節點的路徑即可.

總結

上面的所有方案的優缺點濃縮到下面這個表中:
(如果層次結構比較簡單可以直接使用鄰接表, 複雜些的可以使用枚舉路徑/路徑枚舉. 嵌套集比較複雜, 閉包集在增加節點時關聯關係表的數據量增長速度很快.)
總結

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