数据库表设计,没有最好只有最适合

作者:小小情意
www.cnblogs.com/xiaoxiaoqingyi/p/6954349.html

我们在设计数据库的时候,是否会突破常规,找到最适合自己需求的设计方案,下面来举个例子:

常用的邻接表设计,都会添加 一个 parent_id 字段,比如区域表(国、省、市、区):

CREATE TABLE Area (
    [id] [int]  NOT NULL,
    [name] [nvarchar]  (50) NULL,
    [parent_id] [int]  NULL,
    [type] [int]  NULL 
);

name:地域的名称, parent_id 是父 ID,省的父 ID 是国,市的父 ID 为省,以此类推。

type 是区域的阶级: 1:国,2:省,3:市,4:区

在层级比较确定的情况下,这么设计表格没有什么问题,调用起来也很方便。

但是使用这种邻接表设计方式,并不能满足所有的需求,当我们不确定层级的情况下,假设我有下面一个评论结构:

评论结构

用邻接表记录这个评论的数据(comments 表):

comments 表

大家有没发现,这么设计表,如果要查询一个节点的所有后代,是很难实现的,你可以使用关联查询来获取一条评论和他的后代:

SELECT c1.*, c2.* FROM comments c1 LEFT OUTER JOIN comments c2 ON c2.parent_id = c1.comment_id;

然而这个查询只能获取两层的数据。这种树的特性就是可以任意深地拓展,你需要有相应的方法来获取它的深度数据。比如,可能需要计算一个评论分支的数量,或者计算一个机械设备的所有的总开销。

某些情况下,在项目中使用邻接表正好适用。邻接表设计的优势在于能快速的获取一个给定节点的直接父子节点,它也很容易插入新节点。如果这样的需求就是你的项目对于分层数据的全部操作,那使用邻接表就可以很好的工作了。

遇到上述的树模型,有几种方案是可以考虑下的:路径枚举、嵌套集以及闭包表。这些解决方案通常看上去比邻接表复杂很多,但它们的确使得某些使用邻接表比较复杂或很低效的操作变得更简单。如果你的项目确实需要提供这些操作,那么这些设计会是邻接表更好的选择。

一、路径枚举

在 comments 表中,我们使用类型 varchar 的 path 字段来替代原来的
parent_id 字段。这个 path 字段所存储的内容为当前节点的最顶层祖先到它的自己的序列,就像 UNIX 的路径一样,你甚至可以使用 ‘/’ 作为路径的分隔符。

你可以通过比较每个节点的路径来查询一个节点祖先。比如:要找到评论
#7, 路径是 1/4/5/7一 的祖先,可以这么做:

SELECT * FROM comments AS c WHERE '1/4/5/7' LIKE c.path || '%' ;

这句话查询语句会匹配到路径为 1/4/5/%,1/4/% 以及 1/% 的节点,而这些节点就是评论 #7 的祖先。

同时还可以通过将LIKE 关键字两边的参数互换,来查询一个给定节点的所有后代。比如查询评论 #4,路径path为 ‘1/4’ 的所有后代,可以使用如下语句:

SELECT * FROM comemnts AS c WHERE c.path LIKE '1/4' || '%' ;

这句查询语句所有能找到的后台路径分别是:1/4/5、1/4/5/6、1/4/5/7。

一旦你可以很简单地获取一棵子树或者从子孙节点到祖先节点的路径,你就可以很简单地实现更多的查询,如查询一颗子树所有节点上值的总和。

插入一个节点也可以像使用邻接表一样地简单。你所需要做的只是复制一份要插入节点的父亲节点路径,并将这个新节点的 ID 追加到路径末尾即可。

路径枚举也存在一些缺点,比如数据库不能确保路径的格式总是正确或者路径中的节点确实存在。依赖于应用程序的逻辑代码来维护路径的字符串,并且验证字符串的正确性开销很大。无论将 varchar 的长度设定为多大,依旧存在长度的限制,因而并不能够支持树结构无限扩展。

二、 嵌套集

嵌套集解决方案是存储子孙节点的相关信息,而不是节点的直接祖先。我们使用两个数字来编码每个节点,从而表示这一信息,可以将这两个数字称为 nsleft 和 nsright。

每个节点通过如下的方式确定 nsleft 和 nsright 的值:nsleft的数值小于该节点所有后代 ID,同时 nsright 的值大于该节点的所有后代的 ID。这些数字和 comment_id 的值并没有任何关联。

确定这三个值(nsleft,comment_id,nsright)的简单方法是对树进行一次深度优先遍历,在逐层深入的过程中依次递增地分配 nsleft 的值,并在返回时依次递增地分配 nsright 的值。得到数据如下:

一旦你为每个节点分配了这些数字,就可以使用它们来找到指定节点的祖先和后代。比如搜索评论 #4 及其所有后代,可以通过搜索哪些节点的 ID 在评论 #4 的 nsleft 和 nsright 范围之间,例:

SELECT c2.* FROM comments AS c1 JOIN comments AS c2 ON c2.nsleft BETWEEN c1.nsleft
AND c1.nsright WHERE c1.comment_id = 4;

比如搜索评论 #6 及其所有祖先,可以通过搜索 #6 的 ID 在哪些节点的
nsleft 和 nsright 范围之间,例:

SELECT c2.* FROM comments AS c1 JOIN comments AS c2 ON c1.nsleft BETWEEN c2.nsleft
AND c2.nsright WHERE c1.comment_id = 6;

使用嵌套集设计的主要优势是,当你想要删除一个非叶子节点时,它的后代会自动替代被删除的节点,成为其直接祖先节点的直接后代。就是说已经自动减少了一层。

然而,某些在邻接表的设计中表现得很简单的查询,比如获取一个节点的直接父亲或者直接后代,在嵌套集设计中会变得比较复杂。在嵌套集中,如果需要查询一个节点的直接父亲,我们会这么做,比如要找到评论 #6 的直接父亲:

SELECT parent.* FROM comments AS c JOIN comments AS parent ON c.nsleft BETWEEN parent.nsleft AND parent.nsright
LEFT OUTER JOIN comments AS in_between ON c.nsleft BETWEEN in_between.nsleft AND in_between.nsright
AND in_between.nsleft BETWEEN parent.nsleft AND parent.nsright WHERE c.comment_id = 6
AND in_between.comment_id IS NULL;

总之有些复杂。

对树进行操作,比如插入和移动节点,使用嵌套集会比其它设计复杂很多。当插入一个新节点时,你需要重新计算新插入节点的相邻兄弟节点、祖先节点和它祖先节点的兄弟,来确保他们的左右值都比这个新节点的左值大。同时,如果这个新节点时一个非叶子节点,你还要检查它的子孙节点。

如果简单快速查询是整个程序中最重要的部分,嵌套集是最好的选择,比操作单独的节点要方便快捷很多。然而,嵌套集的插入和移动节点是比较复杂的,因为需要重新分配左右值,如果你的应用程序需要频繁的插入、删除节点,那么嵌套集可能并不合适。

三、闭包表

闭包表是解决分级存储的一个简单而优雅的解决方案,它记录了树中所有节点间的关系,而不仅仅只有那些直接的父子节点。

在设计评论系统时,我们额外创建了一个叫 tree_paths 表,它包含两列,每一列都指向 comments 中的外键。

我们不再使用 comments 表存储树的结构,而是将树中任何具有(祖先 一 后代)关系的节点对都存储在 treepaths 表里,即使这两个节点之间不是直接的父子关系;同时,我们还增加一行指向节点自己。

通过 treepaths 表来获取祖先和后代比使用嵌套集更加的直接。例如要获取评论#4的后代,只需要在 treepaths 表中搜索祖先是评论 #4的行就行了。同样获取后代也是如此。

要插入一个新的叶子节点,比如评论#6的一个子节点,应首先插入一条自己到自己的关系,然后搜索 treepaths 表中后代是评论 #6 的节点,增加该节点和新插入节点的“祖先一后代”关系(新节点 ID 应该为 8):

INSERT INTO treepaths (ancestor, descendant)
SELECT t.ancestor, 8
FROM treepaths AS t
WHERE t.descendant = 6
UNION ALL SELECT 8, 8;

要删除一个叶子节点,比如评论 #7, 应删除所有 treepaths 表中后代为评论 #7 的行:

DELETE FROM treepaths WHERE descendant = 7;

要删除一颗完整的子树,比如评论 #4 和它所有的后代,可删除所有在 treepaths 表中后代为 #4 的行,以及那些以评论 #4 后代为后代的行。

闭包表的设计比嵌套集更加的直接,两者都能快捷地查询给定节点的祖先和后代,但是闭包表能更加简单地维护分层信息。这两个设计都比使用邻接表或者路径枚举更方便地查询给定节点的直接后代和祖先。

然而你可以优化闭包表来使它更方便地查询直接父亲节点或者子节点: 在 treepaths 表中添加一个 path_length 字段。一个节点的自我引用的
path_length 为 0,到它直接子节点的 path_length 为 1,再下一层为 2,以此类推。这样查询起来就方便多了。

总结:你该使用哪种设计?

每种设计都各有优劣,如何选择设计,依赖于应用程序的哪种操作是你最需要性能上的优化。

层级数据设计比较

  1. 邻接表是最方便的设计,并且很多程序员都了解它

  2. 如果你使用的数据库支持 WITH 或者 CONNECT BY PRIOR 的递归查询,那能使得邻接表的查询更高效。

  3. 枚举路径能够很直观地展示出祖先到后代之间的路径,但同时由于它不能确保引用完整性,使得这个设计非常脆弱。枚举路径也使得数据的存储变得比较冗余。

  4. 嵌套集是一个聪明的解决方案,但可能过于聪明,它不能确保引用完整性。最好在一个查询性能要求很高而对其他要求一般的场合来使用它。

  5. 闭包表是最通用的设计,并且以上的方案也只有它能允许一个节点属于多棵树。它要求一张额外的表来存储关系,使用空间换时间的方案减少操作过程中由冗余的计算所造成的消耗。

这几种设计方案只是我们日常设计中的一部分,开发中肯定会遇到更多的选择方案。选择哪一种方案,是需要切合实际,根据自己项目的需求,结合方案的优劣,选择最适合的一种。

我遇到一些开发人员,为了敷衍了事,在设计数据库表时,只考虑能否完成眼下的任务,不太注重以后拓展的问题,不考虑查询起来是否耗性能。可能前期数据量不多的时候,看不出什么影响,但数据量稍微多一点的话,就已经显而易见了(例如:可以使用外联接查询的,偏偏要使用子查询)。

我觉得设计数据库是一个很有趣且充满挑战的工作,它有时能体现你的视野有多宽广,有时它能让你睡不着觉,总之痛并快乐着。

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

推荐阅读更多精彩内容