树形结构存储方法的选择
简单的方法跟踪多级回复构成的树形分支:parent_id
一开始的思路
- 使用parent_id跟踪分支
- 使用先找出所有节点,按照一定顺序整合成树形结构
缺陷:
- 在深度过深时仅用parent_id需要执行很多次SQL才能获取给定主题的所有数据
- 每天的数据变动可能非常大,每访问一次整合一次过于浪费时间且不切实际
目标:一个更好的存储多级结构及简单高效获取一个完整分支的方法
分层存储合查询
树形结构举例:组织架构图、线程化讨论
解决方案
邻接表
依赖父节点构成的邻接表,由parent_id指向另一个元素的id作为父节点
缺陷:无法查询一个节点的所有后代
可使用关联查询来获取一条评论和它的直接后代
select * from c1.*,c2.*
from Comments c1 left outer join Comments c2
on c2.parent_id = c1.id
只能获取两层数据
树的特性:可以任意深地扩展
缺陷:
-
每想多查一层就得多一个连接,而SQL查询最多4层
-
聚合函数的使用很困难例如count()
-
可能只需部分子树信息/聚合信息
-
删除困难,要删除某个节点困难必须删除某颗子树,通过多次查询找到所有后代 ,再从最低级逐个删除满足外键完整性,parent_id也是一个外键,只不过连接的表还是本表
- 如果想删除某个节点并提升其子节点就必须修改子节点parent_id再删除这个节点
优势:
-
增加一个叶子节点
insert into Comments(bug_id,parent_id,author,comment) values(1234,7,'kukla','Thanks!')
-
修改一个节点的位置或者一颗子树的位置
update comments set parent_id = 3 where comment_id = 6
小结
简单,但有些操作例如删除、查询、提升子节点要付出额外代价,增加/修改简单
如果有一下问题,说明需要考虑其他实现方案
-
我们的树结构支持多少层
在纠结不适用递归查询获取一个给定节点的所有祖先或者后代
只支持有限层级操作,但多少层才能满足需求
-
我总是很怕解除那些管理树结构的代码
每一项技术都能使一些任务变得很简单,但通常是以其他任务变得复杂为代价的,选择方案应该应对合适的场景
-
我需要一个脚本定期清理树种孤立节点的数据
因为删除非叶子节点产生了一些迷失节点,采用其他数据结构后也要保证树结构的数据一致性
合理使用反模式
如果获取直接父子节点/插入新节点 如果这两个操作能满足现下所有需求,那么使用邻接表就能很好的工作了
不要过度设计
其他解决方案
路径枚举
解决邻接表获取祖先节点困难的问题
使用path字段将层级信息组合,例如/usr/local/lib这种类似的存储结构
可通过比较每个节点的路径来查询一个节点的祖先
缺陷:
- 数据库不能确保路径的格式总是正确或者路径种的节点确实存在。
- 依赖于应用程序的逻辑代码来维护路径的字符串,并且验证字符串的正确性的开销很大。
- 无论path设置为多大都存在长度限制,无法达到树形结构无限扩展的特性
优势:
- 很好的支持祖先的查找
- 支持深度查询
嵌套集
存储子孙节点的相关信息而不是节点的直接祖先。通过两个数字nsleft及nsright来存储
nsleft:所有小于该节点的后代id
nsright:所有大于该节点的后代id
这些数字和comment_id没有任何关系,通过深度遍历逐层分配nsleft的值,并在返回时依次递增地分配nsleft的值并在返回时一次递增地分配nsright的值。
根据他们来找到给定节点的祖先/后裔
栗子:
寻找4的所有后裔
select * from comment c1 join comment c2
on c2.nsleft between c1.nsleft and c2.nsright
where c1.comment_id = 4
优势:
- 想删除某个非叶子节点,它的后代会自动代替被删除的节点,称为其直接祖先节点的直接后代。节点虽然是有序分配,但删除一个并不会队范围查询造成影响。
缺陷:
- 获取直接父节点/后代变得比较复杂
- 对树进行操作,比如插入和移动比其他设计复杂得多,需要重新遍历计算n左右值
优势:
如果简单快速的查询是重要部门,嵌套集是最佳选择
频繁的插入、删除就不适合
闭包表
额外创建TreePaths的表,包含两列,每一列都是指向Comments种comment_id的外键
获取祖先/后代更加直接
获取评论4的后代:
等同于查询所有祖先节点是4的节点信息
select c.*
from comments as c
join treepaths as t on c.comment_id = t.descendant
where t.ancestor = 4;
获取6的祖先:
查询所有后裔为6的节点
select c.*
from comments c join treepaths t on c.comment_id = t.ancestor
where t.descendant = 6;
插入一个5的新的叶子节点:
首先插入一条自己到自己的关系,然后搜索treepaths 表中后代是评论5的节点,增加该节点和新插入节点的“祖先-后代”关系
然后插入新节点“祖先-后代”的关系
insert into treepaths (ancestor,decendant)
select t.ancestor,8
from treepaths as t
where t.descendant = 5
union all
select 8,8
删除一颗完整子树
删除评论4及其后代
删除所有在treepaths表中后代为4的行以及那些以评论#4的后代为后代的行
删除4及其子节点的关系,删除其他节点到4的子节点的关系
delete from treepaths
where descendant in (
select descendant from treepaths where ancestor = 4
)
这样的删除并非删除掉节点,只是删除了关系,把层次结构分离使得设计更加灵活
移动
要移动树则先端开子树与祖先们的关系:找到这颗子树的顶点,删除它的所有子节点和它的所有祖先节点之间的关系。
比如把6移动到3下
首先把自我引用删掉
delete from treepaths where descendant in (
select descendant from treepaths where ancestor = 6
)and ancestor in (
select ancestor from treepaths where descendant = 6 and ancestor != descendant
)
查询评论6的祖先(不包括自身)以及评论6的后代(包括自身),然后删除他们之间的关系,正确地移除评论6的祖先到评论6和它的后代之间的路径。删除 (1,6 )(1,7 )(4,6 )(4,7 )不会删除(6,6 )(6,7 )
再建立关系
对比
设计 | 表 | 查询子 | 查询树 | 插入 | 删除 | 引用完整性 |
---|---|---|---|---|---|---|
邻接表 | 1 | 简单 | 困难 | 简单 | 简单 | 是 |
递归查询 | 1 | 简单 | 简单 | 简单 | 简单 | 是 |
枚举路径 | 1 | 简单 | 简单 | 简单 | 简单 | 否 |
嵌套集 | 1 | 困难 | 简单 | 困难 | 困难 | 否 |
闭包表 | 2 | 简单 | 简单 | 简单 | 简单 | 是 |
总结
- 邻接表最方便
- 如果支持with/connect by prior的递归查询能使邻接表更为高效
- 枚举路径直观展示出祖先到后代之间的路径,但同时由于它不能确保引用完整性,设计非常脆弱。存储也冗余
- 嵌套集是一个聪明的解决方案,但不能确保引用完整性。最好在一个查询性能要求高而对其他要求一般的场合使用
- 闭包表是最通用的设计,并且上述的设计中只有它能允许一个节点属于多棵树。它要求一张额外的表来存储关系,使用空间换时间的方案减少操作过程中由冗余的计算锁造成的消耗
如果简单就用邻接表但如果复杂,要查询整个树结构就用闭包表
跟着需求进行分析与设计