在数据库中存储一棵树,实现无限级分类

原文发表于个人博客: 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

相关文章
相关标签/搜索