起步
在大多數的系統中,對內容進行分類是必要的。比如電商的商品分類;論壇的板塊等。
需求分析
分類之間的關系是怎樣的? 很明顯,一個分類下面可以是多個下級分類。反過來呢,一個下級分類能夠屬於幾個上級分類呢?這個並不確定,得看具體的業務需求。如果是多個實現上會更加復雜,為了討論層級設計,這里先限定每個分類僅有一個上級分類。
對於某個分類,需要支持的操作如下:
- 對單個分類的 CURD;
- 查詢該分類的直屬下級或所有下級分類;
- 查詢該分類的上級分類至頂級分類中的所有分類,並且是有序的;
- 移動該分類,就是將節點移動到另一個節點下面,這是當分類結構不合理時,允許修改時使用;
- 查詢某一級的所有分類(很少會用到)。
從使用頻率來看,查詢是大多數,畢竟分類也不會改來改去。所以性能考慮以查詢操作優先,特別是操作2 和操作 3。
方案一:記錄父分類的引用
這是一種比較常見且維護的一個方案,添加一個 pid
指向父分類的 id
:
表中 pid
即直屬上級分類的 id,頂級分類設為 0.這種方案簡單易懂,僅用一個字典,沒有冗余信息,存儲空間占用極小,在數據庫層面是一個很好的設計。(本博客系統的分類就是采用這種結構)
對於分類的移動也非常友好,只要修改 pid
即可。而刪除分類也只需將其直屬子分類的 pid
改成該分類的 pid
即可。但對於查詢該分類的所有上級至頂級分類或查詢就不友好了,需要進行遞歸。
查找所有父分類至頂級分類
SELECT
ID.LEVEL,
t.*
FROM
(
SELECT
@id AS _id,
( SELECT @id := pid FROM lab_goods WHERE id = @id) AS _pid,
@l := @l + 1 AS LEVEL
FROM
lab_goods,
(SELECT @id := 100, @l := 0) as b
WHERE
@id > 0
) as ID,
lab_goods as t
WHERE
ID._id = t.id
ORDER BY `LEVEL`;
經實驗該語句可用,但當表中數據有一萬條,當查詢的層級有1000時,耗時 2 秒。也就是當分類的數量很多的時候,這個方案的性能並不好,但真實業務上會有這么多的分類嗎?
查詢某分類的所有下級分類為:
SELECT
ID. LEVEL,
DATA .*
FROM
(
SELECT
@ids AS _ids,
(
SELECT @ids := GROUP_CONCAT(id) FROM lab_goods WHERE FIND_IN_SET(pid, @ids)
) AS cids,
@l := @l + 1 AS LEVEL
FROM
lab_goods,
(SELECT @ids := 10000, @l := 0) b
WHERE
@ids IS NOT NULL
) ID,
lab_goods DATA
WHERE
FIND_IN_SET(DATA .id, ID._ids)
ORDER BY `LEVEL`, id
當層級太多的時候性能是不佳的。而對於查詢某一級的所有分類,這個設計簡直是災難。
其實這個方案也是一開始就能想到的,在層級不深的情況下,這個方案不失為一個好的選擇。
方案二:添加路徑列表
針對方案一的短板,我們表中不僅僅記錄父分類id,還將它到頂級分類所有分類的id都保存下來。
id | name | pid | path |
---|---|---|---|
1 | 家用電器 | 0 | - |
2 | 電腦辦公 | 0 | - |
3 | 大家電 | 1 | 1 |
4 | 生活電器 | 1 | 1 |
5 | 電風扇 | 4 | 1,4 |
6 | 電腦整機 | 2 | 1,2 |
每個分類都保存從頂級分類到父分類的所有id。查詢某某分下的所有下級就可以用 like
進行匹配:
SELECT id,name FROM pathlist WHERE path LIKE '1,%'
查詢某個分類的所有下級:
SELECT id,name FROM pathlist WHERE path LIKE '%2'
這個方案在各個方面都具有可以接受的性能,在上述例子中 1w 的數據,1k 的層級,僅耗時 0.0009s 。
但具有兩點不足:1. 不遵守數據庫范式,DBA看了想打人;2. 就是字段長度是有限的,但 longtext 最大能存儲 4G 的文本,我想沒有這么變態的層級數。所以這個分類在許多系統中使用。
方案三:基於ClosureTable的無限級分類存儲
另建一張表存儲節點之間的關系,其中包含了任何兩個有關系的節點的關聯信息:
定義關系表 CategoryTree
,其包含3個字段:
ancestor
祖先:上級節點的iddescendant
子代:下級節點的iddistance
距離:子代到祖先中間隔了幾級
這三個字段的組合是唯一的,因為在樹中,一條路徑可以標識一個節點,所以可以直接把它們的組合作為主鍵。以圖為例,節點6到它上一級的節點(節點4)距離為1在數據庫中存儲為ancestor=4,descendant=6,distance=1
,到上兩級的節點(節點1)距離為2,於是有 ancestor=1,descendant=6,distance=2
,到根節點的距離為3也是如此,最后還要包含一個到自己的連接,當然距離就是0了。
這樣一來,不盡表中包含了所有的路徑信息,還在帶上了路徑中每個節點的位置(距離),對於樹結構常用的查詢都能夠很方便的處理。下面看看如何用用它來實現我們的需求。
子節點查詢
查詢id為5的節點的直屬子節點:
SELECT descendant FROM CategoryTree WHERE ancestor=5 AND distance=1
查詢所有子節點:
SELECT descendant FROM CategoryTree WHERE ancestor=5 AND distance>0
查詢某個上級節點的子節點,換句話說就是查詢具有指定上級節點的節點,也就是 ancestor
字段等於上級節點id即可,第二個距離 distance
決定了查詢的對象是由上級往下那一層的,等於1就是往下一層(直屬子節點),大於0就是所有子節點。這兩個查詢都是一句完成。
路徑查詢
查詢由根節點到id為10的節點之間的所有節點,按照層級由小到大排序
SELECT ancestor FROM CategoryTree WHERE descendant=10 ORDER BY distance DESC
查詢id為10的節點(含)到id為3的節點(不含)之間的所有節點,按照層級由小到大排序
SELECT ancestor FROM CategoryTree WHERE descendant=10 AND
distance<(SELECT distance FROM CategoryTree WHERE descendant=10 AND ancestor=3)
ORDER BY distance DESC
查詢路徑,只需要知道 descendant
即可,因為 descendant
字段所在的行就是記錄這個節點與其上級節點的關系。如果要過濾掉一些則可以限制 distance
的值。
查詢節點所在的層級(深度)
查詢id為5的節點是第幾級的
SELECT distance FROM CategoryTree WHERE descendant=5 AND ancestor=0
查詢id為5的節點是id為10的節點往下第幾級
SELECT distance FROM CategoryTree WHERE descendant=5 AND ancestor=10
查詢層級(深度)非常簡單,因為distance字段就是。直接以上下級的節點id為條件,查詢距離即可。
查詢某一層的所有節點
查詢所有第三層的節點
SELECT descendant FROM CategoryTree WHERE ancestor=0 AND distance=3
這個就不詳細說了,非常簡單。
插入
插入和移動就不是那么方便了,當一個節點插入到某個父節點下方時,它將具有與父節點相似的路徑,然后再加上一個自身連接即可。
所以插入操作需要兩條語句,第一條復制父節點的所有記錄,並把這些記錄的 distance
加一,因為子節點到每個上級節點的距離都比它的父節點多一。當然 descendant
也要改成自己的。
例如把id為10的節點插入到id為5的節點下方(這里用了Mysql的方言)
INSERT INTO CategoryTree(ancestor,descendant,distance) (SELECT ancestor,10,distance+1 FROM CategoryTree WHERE descendant=5)
然后就是加入自身連接的記錄。
INSERT INTO CategoryTree(ancestor,descendant,distance) VALUES(10,10,0)
刪除
如果刪除分類同時也刪除其所有下級分類那好辦,先找出該節點所有子級節點逐個刪除:
DELETE FROM CategoryTree WHERE descendant in (select descendant from CategoryTree where ancestor=4)
...
而如果節點刪除后是需要將所有下級分類都划分到該節點的直系上級。比如刪除節點4,那么需要把4 的所有子節點都歸到該節點的直接上級:
select descendant from CategoryTree where ancestor=4 // 查詢4的所有子節點
// 當子節點的父節點中超過該節點到 4節點距離時,距離- 1
update CategoryTree set distance = distance-1 where descendant=6 and distance > (select b.distance from (select * from CategoryTree where ancestor=4 and descendant=6 ) b);
...
DELETE FROM CategoryTree WHERE ancestor=4 // 待下級節點的距離都修復完畢后刪除節點與下級節點的聯系
DELETE FROM CategoryTree WHERE descendant=4 // 刪除4節點本身
移動
節點的移動沒有很好的解決方法,因為新位置所在的深度、路徑都可能不一樣,這就導致移動操作不是僅靠UPDATE語句能完成的,這里選擇刪除+插入實現移動。
另外,在有子樹的情況下,上級節點的移動還將導致下級節點的路徑改變,所以移動上級節點之后還需要修復下級節點的記錄,這就需要遞歸所有下級節點。
刪除id=5節點的所有記錄
DELETE FROM CategoryTree WHERE descendant=5
然后配合上面一節的插入操作實現移動。
總結
ClosureTable是一種比較完美的解決方案,對於無限分層有很好的適應性,比較適用於大型系統。