干货:在关系型数据库中优雅地存储树形结构

我们时常会遇到这样的场景,如:组织结构图、回复评论的评论链、用于组织资源的树形资源组。

如图:

而作为一名程序员如果你特别纠结于类似这样的问题“我们的需求方想要支持多少层”, “我觉得管理或者维护树形结构的代码简直烦透了”,那么你应该试一试我下面提到的第二种树形结构的解决方案。

到目前为止我在实战中曾使用过三种方式来实现这种hierarchical-data:

Adjacency list (邻接表)

Closure table (闭包表)

Path enumeration (路径枚举)

因为不想篇幅太长,本文仅详细介绍Closure table,并与Adjacency list做少许对比,文章附录中会有资料链接,里面有四种hierarchical-data详解(上面三种 + Nested Set )

我这里以一个网易评论树来做讲解【已编号(1)-(9)】:

两种设计共有的数据:

设计1:Adjacency list(邻接表)

comment table data:

这种设计非常直观,在表comment上添加一个parent_id字段, 每一条记录都知道自己的父记录是谁. comment之间数据结构关系如下图所示:

设计2:Closure table (闭包表)

comment table data:

comment_path table data:

这种设计, comment table本身并不保存 评论与评论之间的关系, 而将该关系用另外一张表(comment_path)保存起来, 理论上讲需要O(n²)的空间来存储关系,但现实中并不会需要这么多。

comment之间数据结构关系如下图所示:

每根红线都是comment_path中的一条数据, 线条上的数字为depth。

我们直接上需求

查询直接回复4号comment的comment(父查子)

Adjacency :

select * from comment c1 where c1.parent_id=4;

Closure :

select c.* from comment c join comment_path cp on (c.id = cp.descendant) where cp.ancestor = 4 and depth = 1;

查询所有回复4号的子comment(父查所有子)

Adjacency :

思路为递归查询(使用SQL99的CTE 或 oracle的connect by语法),不支持递归查询的数据库(如mysql) 将无法使用一条sql语句满足不固定层级数量的需求.

Closure :

select c.* from comment c join comment_path cp on (c.id = cp.descendant) where cp.ancestor = 4;

--如果你需要保留层级关系, 则将cp中的值也返回即可

查询所有7号的 父comment(子查所有父)

Adjacency :

方案与需求2类似

Closure :

select c.* from comment c JOIN comment_path cp on (c.id = cp.ancestor) where cp.descendant = 7;

添加一条子回复到 6号comment上(新增)

Adjacency :

insert into comment (value, parent_id, topic_id, user_id) values('(10)我以gin食阼啦', 6, 1, 2);

Closure :

step a: insert into comment(value, topic_id, user_id) values('(10)我以gin食阼啦', 1, 2);

-- 拿到该句返回的id, 假设为10

step b: insert into comment_path (ancestor, descendant, depth) select cp.ancestor, 10, cp.depth+1 from comment_path as cp where cp.descendant=6 union all select 10, 10, 0;

-- 只要拥有子comment_id为6作为子节点的节点,全都新增一个id为10,depth+1的子节点 并且插入一个10, 10, 0的节点.

从评论链中删除4号comment及其子comment(删除子或者子树)

Adjacency :

由于parent_id是可以为空的foreign key约束 ==> 递归将所有4号的子节点从栈底开始 删除到栈顶。

Closure :

delete a from comment_path a join comment_path b on (a.descendant = b.descendant) where b.ancestor=4;

-- 这句话等价于 "delete from comment_path where descendant in (select descendant from comment_path where ancestor = 4);”, 但mysql会报from句子中的表不能用于update的错误.

将6号comment的父comment更改为2号(移动子或者子树)

Adjacency :

update comment set parent_id = 2 where id = 6;

Closure :

step a:

delete a from comment_path as a join comment_path as d on a.descendant = d.descendant left join comment_path as x on x.ancestor = d.ancestor and x.descendant = a.ancestor where d.ancestor = 6 and x.ancestor is null;

--这样删除的原因和需求5一致.

step b:

insert into comment_path (ancestor, descendant, depth) select supertree.ancestor, subtree.descendant, supertree.depth+subtree.depth+1 from comment_path as supertree join comment_path as subtree where subtree.ancestor = 6 and supertree.descendant = 2;

以上6种需求覆盖了最为常用的几种情况,解决了我基本上所有的问题,想要更多的例子各位看官可以后台小纸条我。

结束语

这是多层级关系解决方案中非常好的一种,希望能帮助各位解决一些问题, 找到适合自己项目的解决方案。下面的最后一个link是我这篇文章github地址,其中有我做例子用的sql语句,看官可直接参考。

更多相关资料:

http://www.slideshare.net/billkarwin/models-for-hierarchical-data

https://www.percona.com/blog/2011/02/14/moving-subtrees-in-closure-table/

http://stackoverflow.com/questions/23030755/depth-in-mysql-and-closure-table-trees

http://mp.weixin.qq.com/s?__biz=MjM5NjAwNzI0MA==&mid=2651941866&idx=2&sn=158d0b9b6ed93c15c6876126352d48d9&scene=23&srcid=0511Oe59cZtONNzdntkXuqaw#rd

https://detail.tmall.com/item.htm?spm=a230r.1.14.9.axx0DG&id=45220665214&cm_id=140105335569ed55e27b&abbucket=8

https://github.com/Agileaq/Hierarchical_Design

补充延伸

有朋友想要了解 controller 是怎样转换数据库数据以及前端js是如何控制和展现该数据, 我这里补充一下大致的思路:

从数据库中增删改查节点,上面的6种case都覆盖了, 那么我的java后端是怎么做的呢?

准备工作:

做一个entity映射, 里面包含commentId, commentValue

抽象出一个叫做“Node”的返回bean class, json形式如下:

{

id: “数据库中的唯一标识”,

label: “界面上应该显示的一段文本”,

type: “该节点的类型, 对于comment来说可以是cmt”, //该字段可以用作界面显示每一个节点的图片类型判断

children: “子node”,

……(其他您想加上去的字段)

}

step 1.  从db拉出所需数据

在Service中 getCommentsById 查询comment表, -- 根据我想要拉出的root节点的id查到该root及该root下的所有节点信息

e.g. select comment.id, comment.value

from comment join comment_path on (comment.id = comment_path.descendant) where comment_path.ancestor = ?;

在Service中 getCommentsLinks, -- 根据我拉出的节点id, 找到其作为depth=1的子节点的comment_path

e.g.

select t1.descendant, t1.ancestor from comment_path as t1

join (select comment.id, comment.value

from comment join comment_path on (comment.id = comment_path.descendant)

where comment_path.ancestor = ?) as t2 on t1.descendant = t2.id where t1.depth=1 and t1.descendant <> ?;

step 2. 构建即将返回的对象

foreach 数据库映射的comment entity对象即可将其转化为”node” bean, 与此同时用一个map将node的id 和 转化的bean保存在内存中。

step 3. 组装返回对象

foreach step1中装comment_path的对象list, 根据里面保存的ancestorId和descendantId可以从step2中的map取出父和子的”node”bean, 并且将它们组装起来。

step 4. 返回根node(框架会将该node对象序列化成json给前端)。

step 5. 前端展现这些数据的同时,往每一个展现节点上添加一些js事件即可。


本文作者:钱晟龙 Arc_Qian(点融黑帮),现任点融网架构组产品研发工程师,主要任务是思考并尝试解决各类点融网迈出第一公里之后遇到的现实问题。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容