在数据库中存储一棵树,实现无限级分类
来源:SegmentFault
时间:2023-02-24 13:00:45 233浏览 收藏
哈喽!今天心血来潮给大家带来了《在数据库中存储一棵树,实现无限级分类》,想必大家应该对数据库都不陌生吧,那么阅读本文就都不会很困难,以下内容主要涉及到MySQL、Java、mybatis,若是你正在学习数据库,千万别错过这篇文章~希望能帮助到你!
原文发表于我的博客: https://blog.kaciras.com/article/6/store-tree-in-database
在一些系统中,对内容进行分类是必需的功能。比如电商就需要对商品做分类处理,以便于客户搜索;论坛也会分为很多板块;门户网站、也得对网站的内容做各种分类。
分类对于一个内容展示系统来说是不可缺少的,本博客也需要这么一个功能。众所周知,分类往往具有从属关系,比如铅笔盒钢笔属于笔,笔又是文具的一种,当然钢笔还可以按品牌来细分,每个品牌下面还有各种系列...
这个例子中从属关系具有5层,从上到下依次是:文具-笔-钢笔-XX牌-A系列,但实际中分类的层数却是无法估计的,比如生物中的界门纲目科属种有7级。显然对分类的级数做限制是不合理的,一个良好的分类系统,其应当能实现无限级分类。
在写自己的博客网站时,刚好需要这么一个功能。
完整的代码以及动态演示见 https://github.com/Kaciras/ClosureTable
1.需求分析
首先分析一下分类之间的关系是怎样的,很明显,一个分类下面有好多个下级分类,比如笔下面有铅笔和钢笔;那么反过来,一个下级分类能够属于几个上级分类呢?这其实并不确定,取决于如何对类型的划分。比如有办公用品和家具,那么办公桌可以同时属于这两者,不过这会带来一些问题,比如:我要显示从顶级分类到它之间的所有分类,那么这种情况下就很难决定办公用品和家具显示哪一个,并且如果是多对一,实现上将更加复杂,所以这里还是限定每个分类仅有一个上级分类。
这样一来,分类的关系可以表述为一父多子的继承关系,正好对应数据结构中的树,那么问题就转化成了如何在数据库中存储一棵树,并且对分类所需要的操作有较好的支持。
对于本博客来说,分类至少需要以下操作:
- 对单个分类的增删改查。
- 查询一个分类的直属下级和所有下级,在现实某一分类下所有文章时需要使用。
- 查询出由顶级分类到文章所在分类之间的所有分类,并且是有序的。
- 查询分类是哪一级的,比如顶级分类是1,顶级分类的直属下级是2,再往下依次递增。
- 移动一个分类,换句话说就是把一个子树(或者仅单个节点)移动到另外的节点下面,这个在分类结构不合理,需要修改时使用。
在性能的衡量上,这些操作并不是平等的。查询操作使用的更加频繁,毕竟分类不会没事就改着玩,性能考虑要以查询操作优先,特别是2和3分别用于搜索文章和在文章简介中显示其分类,所以是重中之重。
另外,每个分类除了继承关系外,还有名字,简介等属性,也需要存储在数据库中。每个分类都有一个id,由数据库自动生成(自增主键)。
无限级多分类多存在于企业应用中,比如电商、博客平台等,这些应用里一般都有缓存机制,对于分类这种不频繁修改的数据,即使在底层数据库中存在缓慢的操作,只要上层缓存能够命中,一样有很快的响应速度。但是对于抽象的需求:在关系数据库中存储一棵树,并不仅存在于有缓存的应用中,所以设计一个高效的存储方案,仍然有其意义。
下面就以这个卖文具的电商的场景为例,针对这6条需求,设计一个数据库存储方案(对过程没兴趣可以直接转到第4节)。
2.一些常见设计方案的不足
2.1 直接记录父分类的引用
在许多编程语言中,继承关系都是一个类仅继承于一个父类,添加这种继承关系仅需要声明一下父类即可,比如JAVA中extends xxx。根据这种思想,我们仅需要在每个分类上添加上直属上级的id,即可存储它们之间的继承关系。
表中
SELECT id,name FROM pathlist WHERE path LIKE '1,%'
一句话搞定,
SELECT id,name FROM pathlist WHERE path LIKE '%2'
而找出所有上级分类这个需求,直接查出
SELECT descendant FROM CategoryTree WHERE ancestor=5 AND distance=1
查询所有子节点:
SELECT descendant FROM CategoryTree WHERE ancestor=5 AND distance>0
查询某个上级节点的子节点,换句话说就是查询具有指定上级节点的节点,也就是
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=5 AND ancestor=0
查询id为5的节点是id为10的节点往下第几级
SELECT distance FROM CategoryTree WHERE descendant=5 AND ancestor=10
查询层级(深度)非常简单,因为
SELECT descendant FROM CategoryTree WHERE ancestor=0 AND distance=3
这个就不详细说了,非常简单。
4.5 插入
插入和移动就不是那么方便了,当一个节点插入到某个父节点下方时,它将具有与父节点相似的路径,然后再加上一个自身连接即可。
所以插入操作需要两条语句,第一条复制父节点的所有记录,并把这些记录的
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是主要的逻辑,这里只展示部分代码
/** * 将一个分类移动到目标分类下面(成为其子分类)。被移动分类的子类将自动上浮(成为指定分类 * 父类的子分类),即使目标是指定分类原本的父类。 ** 例如下图(省略顶级分类): * 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); } /** * 将一个分类移动到目标分类下面(成为其子分类),被移动分类的子分类也会随着移动。 * 如果目标分类是被移动分类的子类,则先将目标分类(连带子类)移动到被移动分类原来的 * 的位置,再移动需要被移动的分类。 *
* 例如下图(省略顶级分类): * 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是一种比较完美的解决方案。
今天关于《在数据库中存储一棵树,实现无限级分类》的内容介绍就到此结束,如果有什么疑问或者建议,可以在golang学习网公众号下多多回复交流;文中若有不正之处,也希望回复留言以告知!
-
499 收藏
-
244 收藏
-
235 收藏
-
157 收藏
-
101 收藏
-
368 收藏
-
227 收藏
-
306 收藏
-
418 收藏
-
339 收藏
-
279 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 542次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 507次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 497次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 484次学习
-
- 知性的酸奶
- 细节满满,已收藏,感谢大佬的这篇文章内容,我会继续支持!
- 2023-04-23 03:43:24
-
- 积极的草丛
- 这篇技术贴太及时了,太详细了,真优秀,已加入收藏夹了,关注博主了!希望博主能多写数据库相关的文章。
- 2023-04-03 01:30:51
-
- 矮小的花瓣
- 这篇文章太及时了,作者加油!
- 2023-03-18 05:51:41
-
- 故意的中心
- 很详细,已收藏,感谢博主的这篇技术贴,我会继续支持!
- 2023-03-18 00:20:21
-
- 拉长的老师
- 这篇文章太及时了,太详细了,写的不错,mark,关注老哥了!希望老哥能多写数据库相关的文章。
- 2023-03-16 19:41:04
-
- 畅快的豆芽
- 赞 👍👍,一直没懂这个问题,但其实工作中常常有遇到...不过今天到这,看完之后很有帮助,总算是懂了,感谢大佬分享技术贴!
- 2023-03-10 10:01:23
-
- 甜美的心锁
- 这篇博文真及时,作者加油!
- 2023-03-05 14:29:17
-
- 着急的帅哥
- 这篇博文真及时,细节满满,真优秀,已加入收藏夹了,关注大佬了!希望大佬能多写数据库相关的文章。
- 2023-03-04 12:40:13
-
- 火星上的含羞草
- 这篇技术贴出现的刚刚好,太细致了,赞 👍👍,已收藏,关注老哥了!希望老哥能多写数据库相关的文章。
- 2023-02-25 06:06:51
-
- 贤惠的蜻蜓
- 真优秀,一直没懂这个问题,但其实工作中常常有遇到...不过今天到这,看完之后很有帮助,总算是懂了,感谢大佬分享文章内容!
- 2023-02-25 05:43:19