关系型数据库树形结构的设计

背景

而后(story branch)咱们最近开发的一块app,一个标题对应一个故事,每一个故事由一段一段的故事组成。每一段故事都由用户编写。每一个故事看做是一个节点。在故事的建立的时候便有一个节点。随后,用户能够在任意一个节点后面再接一个节点,这样,最终就会造成一棵树。因为关系型数据库并无一个很好的树形结构设计的解决方案。下面,就以而后这款产品为例,列出几种关系型数据库的解决方案并讨论他们的优点与劣势。数据库

解决方案

邻接表

邻接表就是把全部的节点都放在一张表中,而后用一个属性来每一个节点的父节点记录下来,简化的建表语句以下:闭包

CREATE TABLE story (
    story_id INT NOT NULL PRIMARY KET AUTO_INCREMENT,
    father_id INT NOT NULL,
    subject_id INT NOT NULL,
    content VARCHAR(600) NOT NULL,
    FOREIGN KEY (father_id) REFERENCES story(story_id),
    FOREIGN KEY (subject_id) REFERENCES story_subject(subject_id)
)

优势

维护起来比较方便
增长一个节点只须要:app

INSERT INTO story (father_id, content)
    VALUE (1, 'blablabla');

修改一个节点或者一颗子树的位置:函数

UPDATE story SET father_id = 3 WHERE story_id = 4;

缺点

查询会变得很恶心:

一次只能查询有限层的节点,并且每查询多一层都要套多一层链接语句:性能

SELECT story1.*, story2.*
    FROM story AS story1
    LEFT OUTER JOIN story AS story2
        ON story2.father_id = story1.story_id;

另一种查询就是先把整棵树的信息取出来,在外部用程序构造出树再操做,然而这样会变得很低效。编码

SELECT * FROM story WHERE subject_id = 3;

删除会变得很恶心

必须写不少额外的代码来进行屡次的查询,得到后代节点的信息,而后再进行删除spa

路径枚举

在story表中设置一个属性,来存储从根节点到当前结点的路径,用分隔符隔开设计

建表SQL:code

CREATE TABLE story(
    story_id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    content VARCHAR(600) NOT NULL,
    subject_id INT NOT NULL,
    path VARCHAR(1000) NOT NULL,
    FOREIGN KEY (subject_id) REFERENCES story_subject(subject_id)
)

存储的结果以下:递归

story_id content subject_id path
1 blabla 2 1/
2 blabla 2 1/2/
3 blabla 2 1/2/3/
4 blabla 2 1/4/
5 blabla 2 1/4/5/
6 blabla 2 1/4/6/
7 blabla 2 1/4/6/7/

优势

能够比较的查询到一个节点的祖先和后代

能够经过写zheyang的一个比较路径的查询:

SELECT *
FROM story
WHERE '1/4/6/7/ LIKE story.path || '%';

这个查询语句匹配到1/4/6/%, 1/4/%, 1/%这写刚好为节点7的祖先的节点

一样也能够写这样的一个查询语句来得到他全部的后代:

SELECT *
FROM story
WHERE story.path LIKE '/1/4/6/7/' || '%';

有了这些祖先和后代,就能够进一步的得到更多的数据,好比说一颗子树全部节点的总和

插入一个节点也比较简单

只须要复制一份父节点的path,加上本身这个节点的就能够了,能够用MySQL的函数LAST_INSERT_ID():

INSERT INTO story (content, subject_id)
    VALUES
    ('blabla', 2);


UPDATE story
    SET path = (SELECT path
        FROM story
        WHERE story_id = 7) || LAST_INSERT_ID() || '/'
    WHERE story_id = LAST_INSERT_ID();

缺点

  1. 数据库没有约束来确实保证路径的格式老是正确

  2. 也不能保证路径中的节点确实存在

  3. 依赖程序的逻辑代码来维护路径的字符串而且验证字符串的正确性的开销比较大

  4. VARCHAR的长度有限,所以树不能无限扩展

嵌套集

作法是用两个数字来编码每一个节点,而不是记录他的直接祖先。这两个数字命名为nsleft, nsright

建表以下:

CREATE TABLE story(
    story_id INT NOT NULL PRIMARY KEY,
    content VARCHAR(600) NOT NULL,
    subject_id INT NOT NULL,
    nsleft INT NOT NULL,
    nsright INT NOT NULL,
    FOREIGN KEY (subject) REFERENCES story_subject(subject_id)
);

nsleft与nsright的约束规则以下:

  1. nsleft 的数值小于该节点的全部后代的nsleft

  2. nsright 的数值大于该节点的全部后代nsright

  3. 具体的nsleft, nsright和该节点的id并无直接的关联

要肯定这两个值最简单的方法就是,对这棵树进行一次后序遍历,设置一个变量i,每一栋一次就自增1,每次访问一个节点的时候,就把i的值赋给nsleft,每次返回到这个节点的时候就把i的值赋给nsright,结果如图:
嵌套集

图片来自《SQL反模式》

优势

能够很方便的找到一个节点的祖先和后代

经过搜索nsleft在节点4的nsleft和nsright范围之间来获取节点4及其全部后代

SELECT story2.*
FROM story AS story1
JOIN story AS story2
    ON story2.nsleft BETWEEN story1.nsleft AND story1.nsright
WHERE story1.story_id = 4;

经过搜索节点4的nsleft在那些节点的nsleft和nsright范围以内能够获取节点4的全部祖先

SELECT story2.*
FROM story AS story1
JOIN story AS story2
    ON story1.nsleft BETWEEN story2.nsleft AND story2.neright

能够很方便的删除节点

因为嵌套集是经过大小范围来肯定祖先-后代关系的,且不记录具体的层级关系,因此当删除一个节点的时候,他的直接后代就会直接的接到改节点的父节点上,而不用从新分配nsleft,nsright的值

缺点

有些很简单的查询(找老爸)就会变得很恶心

在嵌套集中,要这么找老爸:
若是节点p是节点q的老爸,那他一定是q的一个祖先,而且这两个节点之间不会有其余节点。

  1. 令y为根节点

  2. 不断的去试一个点,使得他既是y的后代,又是q的祖先

  3. 令y=x

  4. 不断重复2,3直到结果为空

查询结果为空的那一刻的y就是p

SELECT father.*
FROM story AS s
JOIN story AS father
    ON s.nsleft BETWEEN father.nsleft AND father.nsright
LEFT OUTER JOIN story AS in_between
    ON s.nsleft BETWEEN in_between.nsleft AND in_between.nsright
WHERE s.story_id = 6 AND in_between.story_id ISNULL;

插入和移动节点也会变得很恶心

每次插入和移动节点都须要从新计算节点的nsleft,nsright值

闭包表

作法是在story表中不保存任何的关系,而是新开一个表ance_desc表
把全部节点的全部祖前后代关系都保存(包括跨节点的),再者,能够加多一个属性path_length来存储祖先节点到后代节点的距离

建表以下

CREATE TABLE story(
    story_id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    subject_id INT NOT NULL,
    content VARCHAR(600) NOT NULL,
    FOREIGN KEY (subject_id) REFERENCES story_subject(subject_id)
);

CREATE TABLE ance_desc(
    ancestor INT NOT NULL,
    descendant INT NOT NULL,
    PRIMARY KEY (ancestor, descendant),
    FOREIGN KEY (ancestor) REFERENCES story(story_id),
    FOREIGN KEY (descendant) REFERENCES story(story_id);
);

如嵌套集那张图的树在ance_desc表中就会存储以下

ancestor descendant
1 2
1 3
1 4
1 5
1 6
1 7
2 2
2 3
3 3
4 4
4 5
4 6
4 7
5 5
6 6
6 7
7 7

优势

查询与删除很是方便

若是要找节点4的后代,只须要找ance_desc表中祖先是4的就能够了

要获取节点7的祖先,只须要找ance_desc表中后代为7的就能够了

要删除叶子节点,就删除后代为节点7的的行

要删除结点4的子树,就删除ance_desc表中和4有关的行。特别说明的是,若是仅仅是想删除关系,而不想删除具体数据,这种设计就很是到位。

缺点

移动虽不像嵌套那么麻烦,但也不太方便

要插入一个叶子节点,先在ance_desc表中插入本身到本身的关系,而后找后代是节点5的节点,而后再插入表就好了

要移动一棵子树的时候,要先删除它的全部子节点和他全部祖先节点的关系:

DELETE FROM ance_desc
WHERE descendant IN (SELECT descendant
                    FROM ance_desc
                    WHERE ancestor = 6)
    AND ancester IN (SELECT ancestor
                    FROM ance_desc
                    WHERE descendant = 6
                    AND ancestor != descendant)

而后,将这个孤立的树和他的祖先创建联系,可使用CROSS JOIN来实现:

INSERT INTO ance_desc (ancestor, descendant)
    SELECT supertree.ancestor, subtree.descendant
    FROM ance_desc AS supertree
    CROSS JOIN ance_desc AS subtree
    WHERE supertree.descendant = 3
        AND subtree.ancestor = 6;

总结

  1. 邻接表是比较方便的设计,查询单个节点,插入,删除,比较简单,同时能保证引用完整性,可是查询一颗树比较复杂。若是数据库支持递归查询,那么邻接表查询效率会更高

  2. 枚举路径在查询单个节点,查询一个树,插入,删除,都比较见长,可是却不能保证引用完整性,使得设计很脆弱,数据存储也比较荣誉

  3. 嵌套集只适用于对于查询性能要求很高的场景

  4. 闭包表是一个比较折中的方案,他没有什么是不擅长的,是一种用空间换时间的方案!

相关文章
相关标签/搜索