最近遇到業務的一個類似文件系統的存儲需求,對於如何在mysql中存儲一顆樹進行了一些討論,分享一下,看看有沒有更優的解決方案。
一、現有情況
首先,先假設有這么一顆樹,一共9個節點,1是root節點,一共深3層。(當然實際業務不會這么簡單)
原有的表結構如下:
id | parents_id | name | full_path |
1 | 0 | a | /a |
2 | 1 | b | /a/b |
3 | 1 | c | /a/c |
4 | 1 | d | /a/d |
5 | 4 | e | /a/d/e |
6 | 4 | f | /a/d/f |
7 | 5 | g | /a/d/e/g |
8 | 5 | h | /a/d/e/h |
9 | 5 | i | /a/d/e/i |
需要滿足的幾個基本需求為:
1、從上到下逐層展開目錄層級
2、知道某一個目錄反查其全路徑
3、rename某一個路徑的名字
4、將某一個目錄挪到其他目錄下
現有的表結構可以滿足以上的需求:
1、select id from table where parents_id=$id;(可以查出所有直接子節點) 2、select full_path from table where id=$id;(通過全路徑字段獲取) 3、update table set name=$newname where id=$id;(將需要修改的id的name字段修改) 4、update table set parents_id=$new_parents_id,full_path=$new_full_path where id=$id;(修改父子關系到新的關系上)
但是現有的表結構會遇到的問題就是,第3和第4個需求,其並不是只更新一行即可,由於有full_path的存在,所有被修改的節點,其下面的所有節點的full_path都需要修改。這就無形之間增加了很多寫操作,如果這顆樹比較深,比如有100層,那么修改第3層的數據,那么就意味着其下面97層的所有數據都需要被修改,這個產生的寫操作就有些恐怖了。
以列子所示,如果4的name被修改,都會影響4,5,6,7,8,9一共6行數據的更新,這個寫邏輯放大的有點厲害。
update table set name=x,full_path='/a/x' where id=4; update table set full_path='/a/x/e' where id=5; update table set full_path='/a/x/f' where id=6; update table set full_path='/a/x/e/g' where id=7; update table set full_path='/a/x/e/h' where id=8; update table set full_path='/a/x/e/i' where id=9;
那么如何解決這個問題呢?
二、優化方案
1、去除full_path字段
上面所述問題最嚴重的就是寫邏輯放大的問題,采用去除full_path字段后,6條update就變成1條update的了。
這個優化看起來完美解決寫邏輯放大問題,但是引入了另一個問題,那就是需求2的滿足費勁了。
原有SQL是:
select full_path from table where id=$id;
但是去除full_path字段之后,變為:
select parents_id from table where id =$id; select parents_id from table where id = $parents_id;
以示例來說,如果要得到9的全路徑,那么就需要如下SQL
select parents_id,name from table where id = 9; select parents_id,name from table where id = 5; select parents_id,name from table where id = 4; select parents_id,name from table where id = 1;
當最后判斷到parents_id=0的時候結束,然后將所有name聚合在一起。
如果所有操作都需要前端實現,那么就需要前端和DB交互4次,這期間消耗的鏈接成本會極大的延長總體的響應時間,基本是不可接收的。
如果要采用這種方案,目前看來只有使用存儲過程,將所有計算都在本地完成之后在返回給端,保證一次請求,一次返回,這樣才最效率,但是mysql的存儲過程個人不太建議使用,風險較大。
2、產品規范
我們的問題會發生在樹的層級特別多的情況下,那么可以通過產品規范來進行限制,比如最深只能有4層,這樣就將問題遏制在發生之前了。(當然,有些時候這種最有效的優化方案是最不可能實現的,產品不會那么容易妥協)
3、增加cache
問題既然是寫邏輯放大,那么如果我們將優化思路從降低寫入次數,改為提高寫入性能呢?
我們引入redis這種nosql存儲,將id和full_path存放在redis中,每次更新數據庫之后在更新redis,redis的寫入性能遠高於mysql,這樣問題也可以得到解決。
只不過由於更新不是同步的,采用異步更新之后,數據會最終一致,但是在某一個特殊時間點可能會存在不一致。
並且由於存儲架構變化,需要代碼方面做出一定的妥協,無論是讀操作還是寫操作。
4、整體改變存儲結構
以上方案都是在不大改現有表結構的基礎上做出的,那么有沒有可能修改表結構之后情況會不一樣呢?
我們對所示例子的存儲結構引入層級的概念,去除full_path,看看是否可以解決問題。
新的表結構如下:
id_name(id和name映射關系)
id | name |
1 | a |
2 | b |
3 | c |
4 | d |
5 | e |
6 | f |
7 | g |
8 | h |
9 | i |
relation(父子關系)
id | chailds | depth |
1 | 1 | 0 |
1 | 2 | 1 |
1 | 3 | 1 |
1 | 4 | 1 |
1 | 5 | 2 |
1 | 6 | 2 |
1 | 7 | 3 |
1 | 8 | 3 |
1 | 9 | 3 |
2 | 2 | 0 |
3 | 3 | 0 |
4 | 4 | 0 |
4 | 5 | 1 |
4 | 7 | 2 |
4 | 8 | 2 |
4 | 9 | 2 |
5 | 5 | 0 |
5 | 7 | 1 |
5 | 8 | 1 |
5 | 9 | 1 |
6 | 6 | 0 |
7 | 7 | 0 |
8 | 8 | 0 |
9 | 9 | 0 |
這兩張新表第一張不用解釋了,第二張id字段存放節點id,chailds字段存放其所有子節點(並不是直接chaild,而是不論層級都存放),depth字段存放其子節點和本節點的層級關系。
我們看下這么設計是否可以滿足最初的4個需求:
需求1:逐層展開目錄
select id,depth from table2 where id=$id; select name from table1 where id=$id;
由於每個id都存放了其所有的子節點,所以如果查詢4的所有下屬目錄,直接select id,depth from table2 where id = 4;一條SQL即可獲得所有結果,只要前端在處理一下即可。
id | chailds | depth |
4 | 4 | 0 |
4 | 5 | 1 |
4 | 6 | 1 |
4 | 7 | 2 |
4 | 8 | 2 |
4 | 9 | 2 |
需求2:根據某一個目錄獲知其全路徑
select id,depth from table2 where chailds = $id;
由於每個id都存放了所有子節點,所以反差也是一條sql的事情。比如查詢9的全路徑,那么select id,depth from table2 where chailds=9;得到的結果應該是
id | chailds | depth |
9 | 9 | 0 |
5 | 9 | 1 |
4 | 9 | 2 |
1 | 9 | 3 |
通過上述結果,前端進行計算處理就可以得到9的全路徑了,並且是一條sql獲得,不需要前端和db多次交互,也不需要引入存儲過程。
需求3:更改目錄名稱
update table1 set name = $new_name where id = $id ;
這個最簡單了,只需要更改映射表即可。
需求4:更改節點的父子關系
select id from table2 where id=$id and depth > 0; delete from table2 where id = $sql1_id; select id from table2 where id = $new_father_id; inset into table2 values ($sql2_id,$id,$depth+1);
這個需求目前看來最麻煩,我們以示例所示,如果將5挪到3下面需要經過哪些操作?
I:先查出來5都屬於哪些節點的子節點。
select id from table 2 where id=5 and depth > 0;
id |
chailds | depth |
1 | 5 | 2 |
4 | 5 | 1 |
II:刪除這些記錄。
delete from table2 where id=1 and chailds=5;
delete from table2 where id=4 and chailds=5;
III:查出新父節點是哪些節點的子節點。
select id,depth from table where chailds=3 and depth > 0 ;
id | chailds | depth |
1 | 3 | 1 |
IIII:
根據III的結果插入新的關系。
insert into table2 values (1,5,2);
由於新父節點只是1的子節點,故只需要在增加5和1個關系既可,同時由於3是5的新父節點,那么5和1的深度關系應該比3的關系“+1”。
而所有5下面的節點都不需要更改,因為這么設計所有節點都是自己子節點的root,所以只要修改5的關系即可。
但是這個解決方案明顯可以看出來,需要存儲的關系比原有情況多了很多倍,尤其是關系層級深的情況下。
三、總結
方案1:解決寫邏輯放大問題,但是引入了讀邏輯放大問題,並需要引入存儲過程解決。
方案2:產品規范解決,最徹底的解決方法,但需要和PM溝通確認,業務很難妥協。
方案3:引入cache解決寫入性能,但是需要代碼進行修改,並存在數據不一致的風險。
方案4:解決寫邏輯放大問題,也沒有引入讀邏輯放大問題,僅僅只是在更改目錄的時候稍微麻煩一些,但是引入了初始存儲容量暴增的問題。
目前看來,並沒有什么特別優秀的方案,都需要付出一定的代價。
ps:本文的思路參考了《SQL反模式》,如果有興趣的讀者可以去研讀一下。