樹是我們?nèi)粘i_發(fā)中經(jīng)常會用到的一種數(shù)據(jù)類型,常用的應用場景有:無限級分類、任務列表等層次數(shù)據(jù)。為了便于理解,我們首先假設需要存儲如下圖所示的樹結(jié)構(gòu):

對這樣一顆樹常用的樹操作有:
- 樹節(jié)點查詢:
- 查詢某節(jié)點下的所有子節(jié)點(子樹)
- 查詢某節(jié)點的所有父級(路徑查詢)
- 添加節(jié)點
- 移動節(jié)點
下面介紹兩種我個人常用的樹存儲模型以及對樹常用操作的實現(xiàn)。
- 鄰接表模型
我們在使用關聯(lián)數(shù)據(jù)庫存儲樹結(jié)構(gòu)的時候,通常使用的方法是一條數(shù)據(jù)存儲一個節(jié)點,在節(jié)點中添加一個parentId字段存儲當前節(jié)點的父節(jié)點ID來實現(xiàn)樹的存儲,這種方法我們稱為鄰接表模型。使用Mysql存儲這顆樹的數(shù)據(jù)表結(jié)構(gòu)可能類似以下結(jié)構(gòu):
| ID | name | parentId |
|---|---|---|
| 1 | root | 0 |
| 2 | A | 1 |
| 3 | B | 1 |
| 4 | C | 1 |
| 5 | A1 | 2 |
| 6 | A2 | 2 |
| 7 | C1 | 4 |
| 8 | C2 | 4 |
| 9 | A2.1 | 6 |
下面,我們通過樹的常用操作分析下這種存儲模型的性能。
2.1 查詢某節(jié)點下的所有子節(jié)點(子樹查詢)
假設我們現(xiàn)在需要查詢節(jié)點A的子樹,根據(jù)圖1所示我們知道節(jié)點A的直接子節(jié)點有A1、A2,而A2又有子節(jié)點A2.1。而在鄰接表模型中我們是通過parentId來管理樹的層次接口,那么我們要獲取A節(jié)點的子樹就需要:
- 根據(jù)A節(jié)點的ID 2獲取到A節(jié)點的直接子節(jié)點A1、A2。
- 遍歷A節(jié)點的直接子節(jié)點獲取所有孫級節(jié)點。
- 遍歷A節(jié)點的所有孫級節(jié)點獲取更下層節(jié)點,以次類推直到獲取到整顆子樹。
通過這個查詢流程我們發(fā)現(xiàn)要獲取A節(jié)點的子樹實際上需要進行很多次查詢性能是非常低的。當然,我們可以優(yōu)化擴展鄰接表模型以優(yōu)化這類查詢的性能。比如增加一個parentIds字段存儲一個節(jié)點的所有父節(jié)點路徑,例如A2.1的parentIds為”,0,2,6,”這時候我們可以通過如下SQL查詢到A節(jié)點的子樹:select * from true where parentIds like "%,2,%"。但是這種方法依然存在缺陷,首先,like查詢的性能在大表下會有一定的影響,其次是在插入新節(jié)點時需要預先獲取其所有父節(jié)點ID再寫入parentIds字段中。
2.2 查詢某節(jié)點的所有父級節(jié)點(路徑查詢)
假設我們現(xiàn)在需要查詢節(jié)點A2.1的路徑,根據(jù)圖1所示我們知道A2.1的路徑是A2.1 > A2 > A > root。類似2.1的查詢方法我們需要:
- 通過A2.1的parentId查詢到A2.1的直接父級A2
- 通過A2的parentId查詢到A2.1的爺級節(jié)點A
- 通過A的parentId查詢到A2.1的祖級節(jié)點root
同樣,要實現(xiàn)這樣的查詢性能也是非常低的。當然,我們也可以像2.1那樣優(yōu)化在節(jié)點中添加一個childrenId字段存儲節(jié)點的所有子節(jié)點ID來進行查詢優(yōu)化,但是很顯然的問題如果這是一個顆很大的樹,那么root節(jié)點的children字段將會非常大!噢No!
2.3 添加新節(jié)點
假設現(xiàn)在需要為A2.1添加一個子A2.1.1,很簡單只需要插入一條數(shù)據(jù)將parentId設置為9就可以了。
2.4 移動節(jié)點
移動節(jié)點也非常簡單快速,假設我們需要將A2.1節(jié)點移動到節(jié)點C下只需要更新A2.1節(jié)點的parentId字段為4就可以了。
- 先根遍歷樹模型
先根遍歷樹模型是另一種常用的樹存儲模型,實現(xiàn)方法是從根節(jié)點左側(cè)開始分別為每個節(jié)點進行編號直到樹的最底層,再從最底成節(jié)點右側(cè)反向編號直到回到根節(jié)點。如下圖所示:

這樣每個節(jié)點都獲得了一個左值和右值,使用Mysql存儲的表結(jié)構(gòu)類似下表:
| ID | name | leftValue | rightValue |
|---|---|---|---|
| 1 | root | 1 | 18 |
| 2 | A | 2 | 9 |
| 3 | B | 10 | 11 |
| 4 | C | 12 | 17 |
| 5 | A1 | 3 | 4 |
| 6 | A2 | 5 | 8 |
| 7 | C1 | 13 | 14 |
| 8 | C2 | 15 | 16 |
| 9 | A2.1 | 6 | 7 |
同樣,基于先根遍歷樹模型我們看下如何進行樹的常用操作。
3.1 查詢某節(jié)點下的所有子節(jié)點(子樹查詢)
同樣假設需要查詢節(jié)點A的子樹,通過觀察圖2中節(jié)點的左右值可以發(fā)現(xiàn)A節(jié)點的所有子節(jié)點左值肯定都大于A節(jié)點的左值,而右值肯定都小于A節(jié)點的右值!根據(jù)這條規(guī)則我們使用一條SQL就可以查詢到A節(jié)點的子樹,SQL如下:
select * from tree
where leftValue > a.leftValue
and rightValue < a.rightValue
order by leftValue asc;
可見使用這種存儲模型查詢子樹的性能遠遠高于鄰接表模型。
3.2 查詢某節(jié)點的所有父級節(jié)點(路徑查詢)
同樣假設需要查詢節(jié)點A2.1的路徑,通過觀察圖2中節(jié)點A2.1的所有父節(jié)點左右值可以發(fā)現(xiàn),A2.1的所有父節(jié)點左值肯定都小于A2.1的左值,而右值肯定都大于節(jié)點A2.1的右值!根據(jù)這條規(guī)則我們同樣可以使用一條SQL查詢到A2.1的所有父節(jié)點,SQL如下:
select * from tree
where leftValue < a21.leftValue
and rightValue > a21.rightValue
order by leftValue acs;
可見使用這種存儲模型查詢節(jié)點路徑的性能也是遠遠高于鄰接表模型。
3.3 添加新節(jié)點
通過前面兩種查詢操作可以看到先根遍歷樹模型在查詢數(shù)據(jù)方面性能非常卓越。可惜人無完人,方法也是!這種模型在進行數(shù)據(jù)插入方面可就不能直接一條SQL搞定了。
還是以向A2.1添加一個A2.1.1節(jié)點為例。因為這種模型下樹初始化后節(jié)點的左右值是連續(xù)的,所以如果想要添加一個節(jié)點就必須先給新節(jié)點騰出一個空位來。根據(jù)左右值的編碼規(guī)則我們可以推算出新節(jié)點A2.1.1的左值應該是7、右值應該是8。如果要騰出這樣一個位置的話,就必須將A2.1、A2、A、Root節(jié)點的右值加2,將B、C、C1、C2的左值和右值都加2。SQL如下:
update tree
set rightValue = rightValue+2
where leftValue <= a21.leftValue
and rightValue >= a21.rightValue;
update true
set leftValue = leftValue + 2,rightValue = rightValue + 2
where leftValue > a21.rightValue;
這種就為新節(jié)點A2.1.1空出了一個位置,接下來再使用insert語句將A2.1.1插入即可。由于需要更新A2.1所以右值節(jié)點和父節(jié)點的所以大一顆比較大的數(shù)下插入一個新節(jié)點性能消耗還是比較大的。
3.4 移動節(jié)點
使用先根遍歷數(shù)模型移動節(jié)點位置和插入新節(jié)點的方法基本相同,也是需要先在新位置為節(jié)點空出一個位置,然后再將節(jié)點的左右值修改為新位置的左右值。
總結(jié)
兩種樹的存儲模型以及它們分別實現(xiàn)樹的常用操作方法就介紹完成了,可以發(fā)現(xiàn)這兩種模型各有優(yōu)缺點。
鄰接表模型插入新節(jié)點、移動節(jié)點位置性能非常好,但是要進行復雜的樹查詢性能就很糟糕了。
先根遍歷樹模型在查詢方面性能突出,但是在插入新節(jié)點和移動節(jié)點時雖然SQL也只有兩三條但是在一個大表下考慮到數(shù)據(jù)量以及索引重建性能還是會有問題。
所以,世界上沒有最好的語言更沒有最好的方法!只有根據(jù)自己的業(yè)務特點選擇最合適存儲模型才是最佳實踐,如果對樹的操作更多是查詢應該使用先根遍歷樹模型;如果對樹的寫操作比較多則應該使用鄰接表模型。當然也可以將兩種模型同時使用,插入的時候使用鄰接表模型然后在后臺重建左右值結(jié)構(gòu),查詢的時候使用先根遍歷樹模型。結(jié)合現(xiàn)代的新技術(shù)還可以分析樹的規(guī)模,如果樹的規(guī)??隙ú粫艽髣t還可以使用redis存儲JSON對象的方式來進行存儲??傊痪湓挘菏紫确治鰳I(yè)務場景,再根據(jù)業(yè)務場景選擇最合適的方法!