在數據庫中存儲一棵樹,實現無限級分類

原文發表於個人博客: https://blog.kaciras.net/article/36java

在一些系統中,對內容進行分類是必需的功能。好比電商就須要對商品作分類處理,以便於客戶搜索;論壇也會分爲不少板塊;門戶網站、也得對網站的內容作各類分類。git

分類對於一個內容展現系統來講是不可缺乏的,本博客也須要這麼一個功能。衆所周知,分類每每具備從屬關係,好比鉛筆盒鋼筆屬於筆,筆又是文具的一種,固然鋼筆還能夠按品牌來細分,每一個品牌下面還有各類系列...github

這個例子中從屬關係具備5層,從上到下依次是:文具-筆-鋼筆-XX牌-A系列,但實際中分類的層數倒是沒法估計的,好比生物中的界門綱目科屬種有7級。顯然對分類的級數作限制是不合理的,一個良好的分類系統,其應當能實現無限級分類。sql

本博客的分類標籤

在寫本身的博客網站時,恰好須要這麼一個功能,聽起來很簡單,可是在實現時卻發現,用關係數據庫存儲無限級分類並不是易事。數據庫

1.需求分析

首先分析一下分類之間的關係是怎樣的,很明顯,一個分類下面有好多個下級分類,好比筆下面有鉛筆和鋼筆;那麼反過來,一個下級分類可以屬於幾個上級分類呢?這其實並不肯定,取決於如何對類型的劃分。好比有辦公用品和傢俱,那麼辦公桌能夠同時屬於這二者,不過這會帶來一些問題,好比:我要顯示從頂級分類到它之間的全部分類,那麼這種狀況下就很難決定辦公用品和傢俱顯示哪個,而且若是是多對一,實現上將更加複雜,因此這裏仍是限定每一個分類僅有一個上級分類。編程

如今,分類的關係能夠表述爲一父多子的繼承關係,正好對應數據結構中的樹,那麼問題就轉化成了如何在數據庫中存儲一棵樹,而且對分類所須要的操做有較好的支持。緩存

對於本博客來講,分類至少須要如下操做:數據結構

  1. 對單個分類的增刪改查等基本操做
  2. 查詢一個分類的直屬下級和全部下級,在現實某一分類下全部文章時須要使用
  3. 查詢出由頂級分類到文章所在分類之間的全部分類,而且是有序的,用於顯示在博客首頁文章的簡介的左下角
  4. 查詢分類是哪一級的,好比頂級分類是1,頂級分類的直屬下級是2,再往下依次遞增
  5. 移動一個分類,換句話說就是把一個子樹(或者僅單個節點)移動到另外的節點下面,這個在分類結構不合理,須要修改時使用
  6. 查詢某一級的全部分類

在性能的衡量上,這些操做並非平等的。查詢操做使用的更加頻繁,畢竟分類不會沒事就改着玩,性能考慮要以查詢操做優先,特別是2和3分別用於搜索文章和在文章簡介中顯示其分類,因此是重中之重。閉包

另外,每一個分類除了繼承關係外,還有名字,簡介等屬性,也須要存儲在數據庫中。每一個分類都有一個id,由數據庫自動生成(自增主鍵)。app

無限級多分類多存在於企業應用中,好比電商、博客平臺等,這些應用裏通常都有緩存機制,對於分類這種不頻繁修改的數據,即便在底層數據庫中存在緩慢的操做,只要上層緩存可以命中,同樣有很快的響應速度。可是對於抽象的需求:在關係數據庫中存儲一棵樹,並不只僅存在於有緩存的應用中,因此設計一個高效的存儲方案,仍然有其意義。

下面就以這個賣文具的電商的場景爲例,針對這6條需求,設計一個數據庫存儲方案(對過程沒興趣能夠直接轉到第4節)。

2.一些常見設計方案的不足

2.1 直接記錄父分類的引用

在許多編程語言中,繼承關係都是一個類僅繼承於一個父類,添加這種繼承關係僅須要聲明一下父類便可,好比JAVA中extends xxx。根據這種思想,咱們僅須要在每一個分類上添加上直屬上級的id,便可存儲它們之間的繼承關係。

父id字段存儲繼承關係

表中parent即爲直屬上級分類的id,頂級分類設爲0。這種方案簡單易懂,僅存在一張表,而且沒有冗餘字段,存儲空間佔用極小,在數據庫層面是一種很好的設計。

那麼再看看對操做的支持狀況怎麼樣,第一條單個增改查都是一句話完事就很少說了,刪除的話記得把下級分類的id所有改爲被刪除分類的上級分類便可,也就多一個UPDATE。

第二條可就麻煩了,好比我要查文具的下級分類,預期結果是筆、鉛筆、鋼筆三個,可是並無辦法經過文具一次性就查到鉛筆盒鋼筆,由於這二者的關係間接存儲在筆這個分類裏,須要先查出直屬下級(筆),纔可以往下查詢,這意味着須要遞歸,性能上一會兒就差了不少。

第三條一樣須要遞歸,由於經過一個分類,數據庫中只存儲了其直屬父類,須要經過遞歸到頂級分類才能獲取到它們之間的全部分類信息。

綜上所述,最關鍵的兩個需求都須要使用性能最差的遞歸方式,這種設計確定是不行的。但仍是繼續看看剩下的幾條吧。

第4個需求:查詢分類是哪一級的?這個仍是得須要遞歸或循環,查出全部上級分類的數量即爲分類的層級。

移動分類卻是很是簡單,直接更新父id便可,這也是這種方案的惟一優點了吧...若是你的分類修改比查詢還多不妨就這麼作吧。

最後一個查詢某一級的全部分類,對於這個設計簡直是災難,它須要先找出全部一級分類,而後循環一遍,找出全部一級分類的子類就是二級分類...如此循環直到所需的級數爲之。因此這種設計裏,這個功能基本是廢了。

這個方式也是一開始就能想到的,在數據量不大(層級不深)的狀況下,由於其簡單直觀的特色,不失爲一個好的選擇,不過對於本項目來講還不夠(本項目立志成爲一流博客平臺!!!)。

2.2 路徑列表

從2.1節中能夠看出,__之因此速度慢,就是由於在分類中僅僅存儲了直屬上級的關係,而需求卻要查詢出非直屬上級。__針對這一點,咱們的表中不只僅記錄父節點id,而是將它到頂級分類之間全部分類的id都保存下來。這個字段所保存的信息就像文件樹裏的路徑同樣,因此就叫作path吧。

路徑列表設計

如圖所示,每一個分類保存了它全部上級分類的列表,用逗號隔開,從左往右依次是從頂級分類到父分類的id。

查詢下級時使用Like運算符來查找,好比查出全部筆的下級:

SELECT id,name FROM pathlist WHERE path LIKE '1,%'

一句話搞定,LIKE的右邊是筆的path字段的值加上模糊匹配,而且左聯接可以使用索引,的效率也過得去。

查詢筆的直屬下級也一樣能夠用LIKE搞定:

SELECT id,name FROM pathlist WHERE path LIKE '%2'

而找出全部上級分類這個需求,直接查出path字段,而後在應用層裏分割一下便可得到得到全部上級,而且順序也能保證。

查詢某一級的分類也簡單,由於層級越深,path就越長,使用LENGTH()函數做爲條件便可篩選出合適的結果。反過來,根據其長度也可以計算出分類的級別。

移動操做須要遞歸,由於每個分類的path都是它父類的path加上父類的id,將分類及其全部子分類的path設爲其父類的path並在最後追加父類的id便可。

在許多系統中都使用了這種方案,其各方面都具備能夠接受的性能,理解起來也比較容易。可是其有兩點不足:1.就是不遵照數據庫範式,將列表數據直接做爲字符串來存儲,這將致使一些操做須要在上層解析path字段的值;2.就是字段長度是有限的,不能真正達到無限級深度,而且大字段對索引不利。若是你不在意什麼範式,分類層級也遠達不到字段長度的限制,那麼這種方案是可行的。

2.3 前序遍歷樹

這是一種在數據庫裏存儲一棵樹的解決方案。它的思想不是直接存儲父節點的id,而是之前序遍歷中的順序來判斷分類直接的關係。

前序遍歷樹

假設從根節點開始之前序遍歷的方式依次訪問這棵樹中的節點,最開始的節點(「Food」)第一個被訪問,將它左邊設爲1,而後按照順序到了第二個階段「Fruit」,給它的左邊寫上2,每訪問一個節點數字就遞增,訪問到葉節點後返回,在返回的過程當中將訪問過的節點右邊寫也寫上數字。這樣,在遍歷中給每一個節點的左邊和右邊都寫上了數字。最後,咱們回到了根節點「Food」在右邊寫上18。下面是標上了數字的樹,同時把遍歷的順序用箭頭標出來了。

咱們稱這些數字爲左值和右值(如,「Meat」的左值是12,右值是17),這些數字包含了節點之間的關係。由於「Red」有3和6兩個值,因此,它是有擁有1-18值的「Food」節點的後續。一樣的,能夠推斷全部左值大於2而且右值小於11的節點,都是有2-11的「Fruit」 節點的後續。這樣,樹的結構就經過左值和右值儲存下來了。

這裏就不貼表結構了,這種方式不如前面兩種直觀。效率上,查詢所有下級的需求被很好的解決,而直屬下級也能夠經過添加父節點id的parent字段來解決。

由於左值更大右值更小的節點是下級節點,反之左值更小、右值更大的就是上級,故需求3:查詢兩個分類直接的全部分類能夠經過左右值做爲條件來解決,一樣是一次查詢便可。

添加新分類和刪除分類須要修改在前序遍歷中全部在指定節點以後的節點,甚至包括非父子節點。而移動分類也是如此,這個特性就很是不友好,在數據量大的狀況下,改動一下但是很要命的。

查詢某一級的全部分類,和查詢分類是哪一級的,這兩個需求沒法解決,只能經過parent字段想第一種方式同樣慢慢遍歷。

綜上所述,對於本項目而言,它還不如第二種,因此這個很複雜的方案也得否決掉。

3.新方案的思考

上面幾種方案最接近理想的就是第二種,若是能解決字段長度問題和不符合範式,以及須要上層參與處理的問題就行了。不過不要急,先看看第二種方案的的優缺點的本質是什麼。

在分析第二種方案的開頭就提到,要確保效率,必需要在分類的信息中包含全部上級分類的信息,而不能僅僅只含有直屬上級,因此纔有了用一個varchar保存列表的字段。但反過來想一想,數據庫的表自己不就是用來保存列表這樣結構化數據集合的工具嗎,爲什麼不能作一張關聯表來代替path字段呢?

在路徑列表的設計中,關鍵字段path的本質是存儲了兩種信息:一是全部上級分類的id,二是從頂級分類到每一個父分類的距離。 因此另增一張表,含有三個字段:一個是本分類的全部上級的id,一個是本分類的id,再加上該分類到每一個上級分類的距離。這樣這張表就可以起到與path字段相同的做用,並且還不違反數據庫範式,最關鍵的是它不存在字段長度的限制!

通過一番折騰,終於找到了這個比較完美的方案。事實上在以後的查閱資料中,發現這個方案早就在一些系統中使用了,名叫ClosureTable。

4.基於ClosureTable的無限級分類存儲

ClosureTable直譯過來叫閉包表?不過不重要,ClosureTable以一張表存儲節點之間的關係、其中包含了任何兩個有關係(上下級)節點的關聯信息

ClosureTable演示

定義關係表CategoryTree,其包含3個字段:

  • ancestor 祖先:上級節點的id
  • descendant 子代:下級節點的id
  • distance 距離:子代到祖先中間隔了幾級

這三個字段的組合是惟一的,由於在樹中,一條路徑能夠標識一個節點,因此能夠直接把它們的組合做爲主鍵。以圖爲例,節點6到它上一級的節點(節點4)距離爲1在數據庫中存儲爲ancestor=4,descendant=6,distance=1,到上兩級的節點(節點1)距離爲2,因而有ancestor=1,descendant=6,distance=2,到根節點的距離爲3也是如此,最後還要包含一個到本身的鏈接,固然距離就是0了。

這樣一來,不盡表中包含了全部的路徑信息,還在帶上了路徑中每一個節點的位置(距離),對於樹結構經常使用的查詢都可以很方便的處理。下面看看如何用用它來實現咱們的需求。

4.1 子節點查詢

查詢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就是全部子節點。這兩個查詢都是一句完成。

4.2 路徑查詢

查詢由根節點到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的值。

4.3 查詢節點所在的層級(深度)

查詢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爲條件,查詢距離便可。

4.4 查詢某一層的全部節點

查詢全部第三層的節點

SELECT descendant FROM CategoryTree WHERE ancestor=0 AND distance=3

這個就不詳細說了,很是簡單。

4.5 插入

插入和移動就不是那麼方便了,當一個節點插入到某個父節點下方時,它將具備與父節點類似的路徑,而後再加上一個自身鏈接便可。

因此插入操做須要兩條語句,第一條複製父節點的全部記錄,並把這些記錄的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)

4.6 移動

節點的移動沒有很好的解決方法,由於新位置所在的深度、路徑均可能不同,這就致使移動操做不是僅靠UPDATE語句能完成的,這裏選擇刪除+插入實現移動。

另外,在有子樹的狀況下,上級節點的移動還將致使下級節點的路徑改變,因此移動上級節點以後還須要修復下級節點的記錄,這就須要遞歸全部下級節點。

刪除id=5節點的全部記錄

DELETE FROM CategoryTree WHERE descendant=5

而後配合上面一節的插入操做實現移動。具體的實現直接上代碼吧。

ClosureTableCategoryStore.java是主要的邏輯,這裏只展現部分代碼

/**
     * 將一個分類移動到目標分類下面(成爲其子分類)。被移動分類的子類將自動上浮(成爲指定分類
     * 父類的子分類),即便目標是指定分類本來的父類。
     * <p>
     * 例以下圖(省略頂級分類):
     *       1                                     1
     *       |                                   / | \
     *       2                                  3  4  5
     *     / | \             move(2,7)               / \
     *    3  4  5         --------------->          6   7
     *         / \                                 /  / | \
     *       6    7                               8  9  10 2
     *      /    /  \
     *     8    9    10
     *
     * @param id 被移動分類的id
     * @param target 目標分類的id
     * @throws IllegalArgumentException 若是id或target所表示的分類不存在、或id==target
     */
    public void move(int id, int target) {
        if(id == target) {
            throw new IllegalArgumentException("不能移動到本身下面");
        }
        moveSubTree(id, categoryMapper.selectAncestor(id, 1));
        moveNode(id, target);
    }

    /**
     * 將一個分類移動到目標分類下面(成爲其子分類),被移動分類的子分類也會隨着移動。
     * 若是目標分類是被移動分類的子類,則先將目標分類(連帶子類)移動到被移動分類原來的
     * 的位置,再移動須要被移動的分類。
     * <p>
     * 例以下圖(省略頂級分類):
     *       1                                     1
     *       |                                     |
     *       2                                     7
     *     / | \           moveTree(2,7)         / | \
     *    3  4  5         --------------->      9  10  2
     *         / \                                   / | \
     *       6    7                                 3  4  5
     *      /    /  \                                     |
     *     8    9    10                                   6
     *                                                    |
     *                                                    8
     *
     * @param id 被移動分類的id
     * @param target 目標分類的id
     * @throws IllegalArgumentException 若是id或target所表示的分類不存在、或id==target
     */
    public void moveTree(int id, int target) {
        /* 移動分移到本身子樹下和無關節點下兩種狀況 */
        Integer distance = categoryMapper.selectDistance(id, target);
        if (distance == null) {
            // 移動到父節點或其餘無關係節點,不須要作額外動做
        } else if (distance == 0) {
            throw new IllegalArgumentException("不能移動到本身下面");
        } else {
            // 若是移動的目標是其子類,須要先把子類移動到本類的位置
            int parent = categoryMapper.selectAncestor(id, 1);
            moveNode(target, parent);
            moveSubTree(target, target);
        }

        moveNode(id, target);
        moveSubTree(id, id);
    }

    /**
     * 將指定節點移動到另某節點下面,該方法不修改子節點的相關記錄,
     * 爲了保證數據的完整性,須要與moveSubTree()方法配合使用。
     *
     * @param id 指定節點id
     * @param parent 某節點id
     */
    private void moveNode(int id, int parent) {
        categoryMapper.deletePath(id);
        categoryMapper.insertPath(id, parent);
        categoryMapper.insertNode(id);
    }

    /**
     * 將指定節點的全部子樹移動到某節點下
     * 若是兩個參數相同,則至關於重建子樹,用於父節點移動後更新路徑
     *
     * @param id     指定節點id
     * @param parent 某節點id
     */
    private void moveSubTree(int id, int parent) {
        int[] subs = categoryMapper.selectSubId(id);
        for (int sub : subs) {
            moveNode(sub, parent);
            moveSubTree(sub, sub);
        }
    }

其中的categoryMapper 是Mybatis的Mapper,這裏只展現部分代碼

/**
     * 查詢某個節點的第N級父節點。若是id指定的節點不存在、操做錯誤或是數據庫被外部修改,
     * 則可能查詢不到父節點,此時返回null。
     *
     * @param id 節點id
     * @param n 祖先距離(0表示本身,1表示直屬父節點)
     * @return 父節點id,若是不存在則返回null
     */
    @Select("SELECT ancestor FROM CategoryTree WHERE descendant=#{id} AND distance=#{n}")
    Integer selectAncestor(@Param("id") int id, @Param("n") int n);

    /**
     * 複製父節點的路徑結構,並修改descendant和distance
     *
     * @param id 節點id
     * @param parent 父節點id
     */
    @Insert("INSERT INTO CategoryTree(ancestor,descendant,distance) " +
            "(SELECT ancestor,#{id},distance+1 FROM CategoryTree WHERE descendant=#{parent})")
    void insertPath(@Param("id") int id, @Param("parent") int parent);

    /**
     * 在關係表中插入對自身的鏈接
     *
     * @param id 節點id
     */
    @Insert("INSERT INTO CategoryTree(ancestor,descendant,distance) VALUES(#{id},#{id},0)")
    void insertNode(int id);

    /**
     * 從樹中刪除某節點的路徑。注意指定的節點可能存在子樹,而子樹的節點在該節點之上的路徑並無改變,
     * 因此使用該方法後還必須手動修改子節點的路徑以確保樹的正確性
     *
     * @param id 節點id
     */
    @Delete("DELETE FROM CategoryTree WHERE descendant=#{id}")
    void deletePath(int id);

5.結語

在分析推論後,終於找到了一種既有查詢簡單、效率高等優勢,也符合數據庫設計範式,並且是真正的無限級分類的設計。本方案的寫入操做雖然須要遞歸,但相比於前序遍歷樹效率仍高出許多,而且在本博客系統中分類不會頻繁修改。可見對於在關係數據庫中存儲一棵樹的需求,ClosureTable是一種比較完美的解決方案。

完整的JAVA實現代碼見 https://github.com/Kaciras/ClosureTableCateogryStore

相關文章
相關標籤/搜索