第二章:数据模型与查询语言
第二章:数据模型与查询语言

语言的边界就是思想的边界。
—— 路德维奇・维特根斯坦
, 《逻辑哲学》 (1922)
数据模型可能是软件开发中最重要的部分了,因为它们的影响如此深远:不仅仅影响着软件的编写方式,而且影响着我们的 解题思路。
多数应用使用层层叠加的数据模型构建。对于每层数据模型的关键问题是:它是如何用低一层数据模型来 表示 的?例如:
- 作为一名应用开发人员,你观察现实世界(里面有人员、组织、货物、行为、资金流向、传感器等
) ,并采用对象或数据结构,以及操控那些数据结构的API 来进行建模。那些结构通常是特定于应用程序的。 - 当要存储那些数据结构时,你可以利用通用数据模型来表示它们,如
JSON 或XML 文档、关系数据库中的表或图模型。 - 数据库软件的工程师选定如何以内存、磁盘或网络上的字节来表示
JSON / XML/ 关系/ 图数据。这类表示形式使数据有可能以各种方式来查询,搜索,操纵和处理。 - 在更低的层次上,硬件工程师已经想出了使用电流、光脉冲、磁场或者其他东西来表示字节的方法。
一个复杂的应用程序可能会有更多的中间层次,比如基于
数据模型种类繁多,每个数据模型都带有如何使用的设想。有些用法很容易,有些则不支持如此;有些操作运行很快,有些则表现很差;有些数据转换非常自然,有些则很麻烦。
掌握一个数据模型需要花费很多精力(想想关系数据建模有多少本书
在本章中,我们将研究一系列用于数据存储和查询的通用数据模型(前面列表中的第
关系模型与文档模型
现在最著名的数据模型可能是
关系模型曾是一个理论性的提议,当时很多人都怀疑是否能够有效实现它。然而到了
关系数据库起源于商业数据处理,在
当时的其他数据库迫使应用程序开发人员必须考虑数据库内部的数据表示形式。关系模型致力于将上述实现细节隐藏在更简洁的接口之后。
多年来,在数据存储和查询方面存在着许多相互竞争的方法。在
随着电脑越来越强大和互联,它们开始用于日益多样化的目的。关系数据库非常成功地被推广到业务数据处理的原始范围之外更为广泛的用例上。你今天在网上看到的大部分内容依旧是由关系数据库来提供支持,无论是在线发布,讨论,社交网络,电子商务,游戏,软件即服务生产力应用程序等等内容。
NoSQL 的诞生
现在
采用
- 需要比关系数据库更好的可伸缩性,包括非常大的数据集或非常高的写入吞吐量
- 相比商业数据库产品,免费和开源软件更受偏爱
- 关系模型不能很好地支持一些特殊的查询操作
- 受挫于关系模型的限制性,渴望一种更具多动态性与表现力的数据模型【5】
不同的应用程序有不同的需求,一个用例的最佳技术选择可能不同于另一个用例的最佳技术选择。因此,在可预见的未来,关系数据库似乎可能会继续与各种非关系数据库一起使用
对象关系不匹配
目前大多数应用程序开发都使用面向对象的编程语言来开发,这导致了对
像

图
例如,图user_id
来标识。像 first_name
和 last_name
这样的字段每个用户只出现一次,所以可以在
- 传统
SQL 模型(SQL:1999 之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User 表提供外键引用,如 图2-1 所示。 - 后续的
SQL 标准增加了对结构化数据类型和XML 数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在Oracle ,IBM DB2,MS SQL Server 和PostgreSQL 中都有不同程度的支持【6,7】 。JSON 数据类型也得到多个数据库的支持,包括IBM DB2 ,MySQL 和PostgreSQL 【8】 。 - 第三种选择是将职业,教育和联系信息编码为
JSON 或XML 文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。
对于一个像简历这样自包含文档的数据结构而言,
例
{
"user_id": 251,
"first_name": "Bill",
"last_name": "Gates",
"summary": "Co-chair of the Bill & Melinda Gates... Active blogger.",
"region_id": "us:91",
"industry_id": 131,
"photo_url": "/p/7/000/253/05b/308dd6e.jpg",
"positions": [
{
"job_title": "Co-chair",
"organization": "Bill & Melinda Gates Foundation"
},
{
"job_title": "Co-founder, Chairman",
"organization": "Microsoft"
}
],
"education": [
{
"school_name": "Harvard University",
"start": 1973,
"end": 1975
},
{
"school_name": "Lakeside School, Seattle",
"start": null,
"end": null
}
],
"contact_info": {
"blog": "http://thegatesnotes.com",
"twitter": "http://twitter.com/BillGates"
}
}
有一些开发人员认为
user_id
查询每个表
从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而

图
多对一和多对多的关系
在上一节的 例region_id
和 industry_id
是以
如果用户界面用一个自由文本字段来输入区域和行业,那么将他们存储为纯文本字符串是合理的。另一方式是给出地理区域和行业的标准化的列表,并让用户从下拉列表或自动填充器中进行选择,其优势如下:
- 各个简介之间样式和拼写统一
- 避免歧义(例如,如果有几个同名的城市)
- 易于更新 —— 名称只存储在一个地方,如果需要更改(例如,由于政治事件而改变城市名称
) ,很容易进行全面更新。 - 本地化支持 —— 当网站翻译成其他语言时,标准化的列表可以被本地化,使得地区和行业可以使用用户的语言来显示
- 更好的搜索 —— 例如,搜索华盛顿州的慈善家就会匹配这份简介,因为地区列表可以编码记录西雅图在华盛顿这一事实(从 “Greater Seattle Area” 这个字符串中看不出来)
存储
使用
数据库管理员和开发人员喜欢争论规范化和非规范化,让我们暂时保留判断吧。在本书的 第三部分,我们将回到这个话题,探讨系统的方法用以处理缓存,非规范化和衍生数据。
不幸的是,对这些数据进行规范化需要多对一的关系(许多人生活在一个特定的地区,许多人在一个特定的行业工作
如果数据库本身不支持连接,则必须在应用程序代码中通过对数据库进行多个查询来模拟连接
此外,即便应用程序的最初版本适合无连接的文档模型,随着功能添加到应用程序中,数据会变得更加互联。例如,考虑一下对简历例子进行的一些修改:
-
组织和学校作为实体
在前面的描述中
, organization
(用户工作的公司)和school_name
(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织、学校或大学都可以拥有自己的网页(标识,新闻提要等) 。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(请参阅 图2-3 ,来自LinkedIn 的一个例子) 。 -
推荐
假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何推荐都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。

图
图

图
文档数据库是否在重蹈覆辙?
在多对多的关系和连接已常规用在关系数据库时,文档数据库和
同文档数据库一样,
那时人们提出了各种不同的解决方案来解决层次模型的局限性。其中最突出的两个是 关系模型(relational model,它变成了
那两个模式解决的问题与当前的问题相关,因此值得简要回顾一下那场辩论。
网状模型
网状模型由一个称为数据系统语言会议(CODASYL)的委员会进行了标准化,并被数个不同的数据库厂商实现;它也被称为
网状模型中记录之间的链接不是外键,而更像编程语言中的指针(同时仍然存储在磁盘上
最简单的情况下,访问路径类似遍历链表:从列表头开始,每次查看一条记录,直到找到所需的记录。但在多对多关系的情况中,数条不同的路径可以到达相同的记录,网状模型的程序员必须跟踪这些不同的访问路径。
尽管手动选择访问路径能够最有效地利用
关系模型
相比之下,关系模型做的就是将所有的数据放在光天化日之下:一个 关系(表) 只是一个 元组(行) 的集合,仅此而已。如果你想读取数据,它没有迷宫似的嵌套结构,也没有复杂的访问路径。你可以选中符合任意条件的行,读取表中的任何或所有行。你可以通过指定某些列作为匹配关键字来读取特定行。你可以在任何表中插入一个新的行,而不必担心与其他表的外键关系 3。
在关系数据库中,查询优化器自动决定查询的哪些部分以哪个顺序执行,以及使用哪些索引。这些选择实际上是 “访问路径”,但最大的区别在于它们是由查询优化器自动生成的,而不是由程序员生成,所以我们很少需要考虑它们。
如果想按新的方式查询数据,你可以声明一个新的索引,查询会自动使用最合适的那些索引。无需更改查询来利用新的索引(请参阅 “数据查询语言”
关系数据库的查询优化器是复杂的,已耗费了多年的研究和开发精力【18
与文档数据库相比
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录(图positions
,education
和 contact_info
但是,在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都被一个唯一的标识符引用,这个标识符在关系模型中被称为 外键,在文档模型中称为 文档引用【9
关系型数据库与文档数据库在今日的对比
将关系数据库与文档数据库进行比较时,可以考虑许多方面的差异,包括它们的容错属性(请参阅 第五章)和处理并发性(请参阅 第七章
支持文档数据模型的主要论据是架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。
哪种数据模型更有助于简化应用代码?
如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树positions
、education
和 contact_info
)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说 “用户
文档数据库对连接的糟糕支持可能是个问题,也可能不是问题,这取决于应用程序。例如,如果某分析型应用程序使用一个文档数据库来记录何时何地发生了何事,那么多对多关系可能永远也用不上
但如果你的应用程序确实会用到多对多关系,那么文档模型就没有那么诱人了。尽管可以通过反规范化来消除对连接的需求,但这需要应用程序代码来做额外的工作以确保数据一致性。尽管应用程序代码可以通过向数据库发出多个请求的方式来模拟连接,但这也将复杂性转移到应用程序中,而且通常也会比由数据库内的专用代码更慢。在这种情况下,使用文档模型可能会导致更复杂的应用代码与更差的性能【15
我们没有办法说哪种数据模型更有助于简化应用代码,因为它取决于数据项之间的关系种类。对高度关联的数据而言,文档模型是极其糟糕的,关系模型是可以接受的,而选用图形模型(请参阅 “图数据模型”)是最自然的。
文档模型中的模式灵活性
大多数文档数据库以及关系数据库中的
文档数据库有时称为 无模式(schemaless
读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。就像静态和动态类型检查的相对优点具有很大的争议性一样【22
在应用程序想要改变其数据格式的情况下,这些方法之间的区别尤其明显。例如,假设你把每个用户的全名存储在一个字段中,而现在想分别存储名字和姓氏【23
if (user && user.name && !user.first_name) {
// Documents written before Dec 8, 2013 don't have first_name
user.first_name = user.name.split(" ")[0];
}
另一方面,在 “静态类型” 数据库模式中,通常会执行以下 迁移(migration) 操作:
ALTER TABLE users ADD COLUMN first_name text;
UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL
UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
模式变更的速度很慢,而且要求停运。它的这种坏名誉并不是完全应得的:大多数关系数据库系统可在几毫秒内执行 ALTER TABLE
语句。ALTER TABLE
时会复制整个表,这可能意味着在更改一个大型表时会花费几分钟甚至几个小时的停机时间,尽管存在各种工具来解决这个限制【24,25,26
大型表上运行 UPDATE
语句在任何数据库上都可能会很慢,因为每一行都需要重写。要是不可接受的话,应用程序可以将 first_name
设置为默认值 NULL
,并在读取时再填充,就像使用文档数据库一样。
当由于某种原因(例如,数据是异构的)集合中的项目并不都具有相同的结构时,读时模式更具优势。例如,如果:
- 存在许多不同类型的对象,将每种类型的对象放在自己的表中是不现实的。
- 数据的结构由外部系统决定。你无法控制外部系统且它随时可能变化。
在上述情况下,模式的坏处远大于它的帮助,无模式文档可能是一个更加自然的数据模型。但是,要是所有记录都具有相同的结构,那么模式是记录并强制这种结构的有效机制。第四章将更详细地讨论模式和模式演化。
查询的数据局部性
文档通常以单个连续字符串形式进行存储,编码为
局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档,即使只访问其中的一小部分,这对于大型文档来说是很浪费的。更新文档时,通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此,通常建议保持相对小的文档,并避免增加文档大小的写入【9
值得指出的是,为了局部性而分组集合相关数据的想法并不局限于文档模型。例如,
在 第三章 将还会看到更多关于局部性的内容。
文档和关系数据库的融合
自
从
在文档数据库中,
随着时间的推移,关系数据库和文档数据库似乎变得越来越相似,这是一件好事:数据模型相互补充 4,如果一个数据库能够处理类似文档的数据,并能够对其执行关系查询,那么应用程序就可以使用最符合其需求的功能组合。
关系模型和文档模型的混合是未来数据库一条很好的路线。
数据查询语言
当引入关系模型时,关系模型包含了一种查询数据的新方法:
许多常用的编程语言是命令式的。例如,给定一个动物物种的列表,返回列表中的鲨鱼可以这样写:
function getSharks() {
var sharks = [];
for (var i = 0; i < animals.length; i++) {
if (animals[i].family === "Sharks") {
sharks.push(animals[i]);
}
}
return sharks;
}
在关系代数中:
�ℎ����=�������=“�ℎ����”(�������)sharks=σfamily=“sharks”(animals)
σ(希腊字母西格玛)是选择操作符,只返回符合条件的动物,family="shark"
。
定义
SELECT * FROM animals WHERE family ='Sharks';
命令式语言告诉计算机以特定顺序执行某些操作。可以想象一下,逐行地遍历代码,评估条件,更新变量,并决定是否再循环一遍。
在声明式查询语言(如
声明式查询语言是迷人的,因为它通常比命令式
例如,在本节开头所示的命令代码中,动物列表以特定顺序出现。如果数据库想要在后台回收未使用的磁盘空间,则可能需要移动记录,这会改变动物出现的顺序。数据库能否安全地执行,而不会中断查询?
最后,声明式语言往往适合并行执行。现在,
Web 上的声明式查询
声明式查询语言的优势不仅限于数据库。为了说明这一点,让我们在一个完全不同的环境中比较声明式和命令式方法:一个
假设你有一个关于海洋动物的网站。用户当前正在查看鲨鱼页面,因此你将当前所选的导航项目 “鲨鱼” 标记为当前选中项目。
<ul>
<li class="selected">
<p>Sharks</p>
<ul>
<li>Great White Shark</li>
<li>Tiger Shark</li>
<li>Hammerhead Shark</li>
</ul>
</li>
<li>
<p>Whales</p>
<ul>
<li>Blue Whale</li>
<li>Humpback Whale</li>
<li>Fin Whale</li>
</ul>
</li>
</ul>
现在想让当前所选页面的标题具有一个蓝色的背景,以便在视觉上突出显示。使用
li.selected > p {
background-color: blue;
}
这里的li.selected > p
声明了我们想要应用蓝色样式的元素的模式:即其直接父元素是具有selected
的 <li>
元素的所有 <p>
元素。示例中的元素 <p>Sharks</p>
匹配此模式,但 <p>Whales</p>
不匹配,因为其 <li>
父元素缺少 class="selected"
。
如果使用
<xsl:template match="li[@class='selected']/p">
<fo:block background-color="blue">
<xsl:apply-templates/>
</fo:block>
</xsl:template>
这里的li[@class='selected']/p
相当于上例中的li.selected > p
。
想象一下,必须使用命令式方法的情况会是如何。在
var liElements = document.getElementsByTagName("li");
for (var i = 0; i < liElements.length; i++) {
if (liElements[i].className === "selected") {
var children = liElements[i].childNodes;
for (var j = 0; j < children.length; j++) {
var child = children[j];
if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "P") {
child.setAttribute("style", "background-color: blue");
}
}
}
}
这段
- 如果选定的类被移除(例如,因为用户点击了不同的页面
) ,即使代码重新运行,蓝色背景也不会被移除- 因此该项目将保持突出显示,直到整个页面被重新加载。使用CSS ,浏览器会自动检测li.selected > p
规则何时不再适用,并在选定的类被移除后立即移除蓝色背景。 - 如果你想要利用新的
API (例如document.getElementsByClassName("selected")
甚至document.evaluate()
)来提高性能,则必须重写代码。另一方面,浏览器供应商可以在不破坏兼容性的情况下提高CSS 和XPath 的性能。
在
MapReduce 查询
关于
map
(也称为 collect
)和 reduce
(也称为 fold
或 inject
)函数,两个函数存在于许多函数式编程语言中。
最好举例来解释
在
SELECT
date_trunc('month', observation_timestamp) AS observation_month,
sum(num_animals) AS total_animals
FROM observations
WHERE family = 'Sharks'
GROUP BY observation_month;
date_trunc('month',timestamp)
函数用于确定包含 timestamp
的日历月份,并返回代表该月份开始的另一个时间戳。换句话说,它将时间戳舍入成最近的月份。
这个查询首先过滤观察记录,以只显示鲨鱼家族的物种,然后根据它们发生的日历月份对观察记录果进行分组,最后将在该月的所有观察记录中看到的动物数目加起来。
同样的查询用
db.observations.mapReduce(
function map() {
var year = this.observationTimestamp.getFullYear();
var month = this.observationTimestamp.getMonth() + 1;
emit(year + "-" + month, this.numAnimals);
},
function reduce(key, values) {
return Array.sum(values);
},
{
query: {
family: "Sharks",
},
out: "monthlySharkReport",
}
);
- 可以声明式地指定一个只考虑鲨鱼种类的过滤器(这是
MongoDB 特定的MapReduce 扩展) 。 - 每个匹配查询的文档都会调用一次
JavaScript 函数map
,将this
设置为文档对象。 map
函数发出一个键(包括年份和月份的字符串,如"2013-12"
或"2014-1"
)和一个值(该观察记录中的动物数量) 。map
发出的键值对按键来分组。对于具有相同键(即,相同的月份和年份)的所有键值对,调用一次reduce
函数。reduce
函数将特定月份内所有观测记录中的动物数量相加。- 将最终的输出写入到
monthlySharkReport
集合中。
例如,假设 observations
集合包含这两个文档:
{
observationTimestamp: Date.parse( "Mon, 25 Dec 1995 12:34:56 GMT"),
family: "Sharks",
species: "Carcharodon carcharias",
numAnimals: 3
}
{
observationTimestamp: Date.parse("Tue, 12 Dec 1995 16:17:18 GMT"),
family: "Sharks",
species: "Carcharias taurus",
numAnimals: 4
}
对每个文档都会调用一次 map
函数,结果将是 emit("1995-12",3)
和 emit("1995-12",4)
。随后,以 reduce("1995-12",[3,4])
调用 reduce
函数,将返回 7
。
能够在查询中使用
db.observations.aggregate([
{ $match: { family: "Sharks" } },
{
$group: {
_id: {
year: { $year: "$observationTimestamp" },
month: { $month: "$observationTimestamp" },
},
totalAnimals: { $sum: "$numAnimals" },
},
},
]);
聚合管道语言的表现力与(前述
图数据模型
如我们之前所见,多对多关系是不同数据模型之间具有区别性的重要特征。如果你的应用程序大多数的关系是一对多关系(树状结构化数据
但是,要是多对多关系在你的数据中很常见呢?关系模型可以处理多对多关系的简单情况,但是随着数据之间的连接变得更加复杂,将数据建模为图形显得更加自然。
一个图由两种对象组成:顶点(vertices,也称为 节点,即
-
社交图谱
顶点是人,边指示哪些人彼此认识。
-
网络图谱
顶点是网页,边缘表示指向其他页面的
HTML 链接。 -
公路或铁路网络
顶点是交叉路口,边线代表它们之间的道路或铁路线。
可以将那些众所周知的算法运用到这些图上:例如,汽车导航系统搜索道路网络中两点之间的最短路径,
在刚刚给出的例子中,图中的所有顶点代表了相同类型的事物(人、网页或交叉路口
在本节中,我们将使用 图

图
有几种不同但相关的方法用来构建和查询图表中的数据。在本节中,我们将讨论属性图模型(由
属性图
在属性图模型中,每个顶点(vertex)包括:
- 唯一的标识符
- 一组出边(outgoing edges)
- 一组入边(ingoing edges)
- 一组属性(键值对)
每条边(edge)包括:
- 唯一标识符
- 边的起点(尾部顶点,即
tail vertex ) - 边的终点(头部顶点,即
head vertex ) - 描述两个顶点之间关系类型的标签
- 一组属性(键值对)
可以将图存储看作由两个关系表组成:一个存储顶点,另一个存储边,如 例head_vertex
或 tail_vertex
来查询 edges
表。
例
CREATE TABLE vertices (
vertex_id INTEGER PRIMARY KEY,
properties JSON
);
CREATE TABLE edges (
edge_id INTEGER PRIMARY KEY,
tail_vertex INTEGER REFERENCES vertices (vertex_id),
head_vertex INTEGER REFERENCES vertices (vertex_id),
label TEXT,
properties JSON
);
CREATE INDEX edges_tails ON edges (tail_vertex);
CREATE INDEX edges_heads ON edges (head_vertex);
关于这个模型的一些重要方面是:
- 任何顶点都可以有一条边连接到任何其他顶点。没有模式限制哪种事物可不可以关联。
- 给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动(这就是为什么 例
2-2 在tail_vertex
和head_vertex
列上都有索引的原因) 。 - 通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息,同时仍然保持一个清晰的数据模型。
这些特性为数据建模提供了很大的灵活性,如 图
你可以想象该图还能延伸出许多关于
Cypher 查询语言
例USA
或 Idaho
这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:(Idaho) - [:WITHIN] ->(USA)
创建一条标记为 WITHIN
的边,Idaho
为尾节点,USA
为头节点。
例
CREATE
(NAmerica:Location {name:'North America', type:'continent'}),
(USA:Location {name:'United States', type:'country' }),
(Idaho:Location {name:'Idaho', type:'state' }),
(Lucy:Person {name:'Lucy' }),
(Idaho) -[:WITHIN]-> (USA) -[:WITHIN]-> (NAmerica),
(Lucy) -[:BORN_IN]-> (Idaho)
当 图name
属性:该顶点拥有一条连到美国任一位置的 BORN_IN
边,和一条连到欧洲的任一位置的 LIVING_IN
边。
例(person) -[:BORN_IN]-> ()
可以匹配 BORN_IN
边的任意两个顶点。该边的尾节点被绑定了变量 person
,头节点则未被绑定。
例
MATCH
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
RETURN person.name
查询按如下来解读:
找到满足以下两个条件的所有顶点(称之为
person 顶点) :
person
顶点拥有一条到某个顶点的BORN_IN
出边。从那个顶点开始,沿着一系列WITHIN
出边最终到达一个类型为Location
,name
属性为United States
的顶点。person
顶点还拥有一条LIVES_IN
出边。沿着这条边,可以通过一系列WITHIN
出边最终到达一个类型为Location
,name
属性为Europe
的顶点。对于这样的
Person
顶点,返回其name
属性。
执行这条查询可能会有几种可行的查询路径。这里给出的描述建议首先扫描数据库中的所有人,检查每个人的出生地和居住地,然后只返回符合条件的那些人。
等价地,也可以从两个 Location
顶点开始反向地查找。假如 name
属性上有索引,则可以高效地找到代表美国和欧洲的两个顶点。然后,沿着所有 WITHIN
入边,可以继续查找出所有在美国和欧洲的位置(州,地区,城市等BORN_IN
或 LIVES_IN
入边到那些位置顶点的人。
通常对于声明式查询语言来说,在编写查询语句时,不需要指定执行细节:查询优化程序会自动选择预测效率最高的策略,因此你可以专注于编写应用程序的其他部分。
SQL 中的图查询
例
答案是肯定的,但有些困难。在关系数据库中,你通常会事先知道在查询中需要哪些连接。在图查询中,你可能需要在找到待查找的顶点之前,遍历可变数量的边。也就是说,连接的数量事先并不确定。
在我们的例子中,这发生在() -[:WITHIN*0..]-> ()
规则中。一个人的 LIVES_IN
边可以指向任何类型的位置:街道、城市、地区、国家等。一个城市可以在(WITHIN)一个地区内,一个地区可以在(WITHIN)在一个州内,一个州可以在(WITHIN)一个国家内,等等。LIVES_IN
边可以直接指向正在查找的位置,或者一个在位置层次结构中隔了数层的位置。
在WITHIN*0..
非常简洁地表述了上述事实WITHIN
边,零次或多次”。它很像正则表达式中的 *
运算符。
自WITH RECURSIVE
语法)的东西来表示。例
例
WITH RECURSIVE
-- in_usa 包含所有的美国境内的位置 ID
in_usa(vertex_id) AS (
SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'United States'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'within'
),
-- in_europe 包含所有的欧洲境内的位置 ID
in_europe(vertex_id) AS (
SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'Europe'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'within' ),
-- born_in_usa 包含了所有类型为 Person,且出生在美国的顶点
born_in_usa(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'born_in' ),
-- lives_in_europe 包含了所有类型为 Person,且居住在欧洲的顶点。
lives_in_europe(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'lives_in')
SELECT vertices.properties ->> 'name'
FROM vertices
JOIN born_in_usa ON vertices.vertex_id = born_in_usa.vertex_id
JOIN lives_in_europe ON vertices.vertex_id = lives_in_europe.vertex_id;
- 首先,查找
name
属性为United States
的顶点,将其作为in_usa
顶点的集合的第一个元素。 - 从
in_usa
集合的顶点出发,沿着所有的with_in
入边,将其尾顶点加入同一集合,不断递归直到所有with_in
入边都被访问完毕。 - 同理,从
name
属性为Europe
的顶点出发,建立in_europe
顶点的集合。 - 对于
in_usa
集合中的每个顶点,根据born_in
入边来查找出生在美国某个地方的人。 - 同样,对于
in_europe
集合中的每个顶点,根据lives_in
入边来查找居住在欧洲的人。 - 最后,把在美国出生的人的集合与在欧洲居住的人的集合相交。
同一个查询,用某一个查询语言可以写成
三元组存储和SPARQL
三元组存储模式大体上与属性图模型相同,用不同的词来描述相同的想法。不过仍然值得讨论,因为三元组存储有很多现成的工具和语言,这些工具和语言对于构建应用程序的工具箱可能是宝贵的补充。
在三元组存储中,所有信息都以非常简单的三部分表示形式存储(主语,谓语,宾语
三元组的主语相当于图中的一个顶点。而宾语是下面两者之一:
- 原始数据类型中的值,例如字符串或数字。在这种情况下,三元组的谓语和宾语相当于主语顶点上的属性的键和值。例如,
(lucy, age, 33)
就像属性{“age”:33}
的顶点lucy 。 - 图中的另一个顶点。在这种情况下,谓语是图中的一条边,主语是其尾部顶点,而宾语是其头部顶点。例如,在
(lucy, marriedTo, alain)
中主语和宾语lucy
和alain
都是顶点,并且谓语marriedTo
是连接他们的边的标签。
例
例
@prefix : <urn:example:>.
_:lucy a :Person.
_:lucy :name "Lucy".
_:lucy :bornIn _:idaho.
_:idaho a :Location.
_:idaho :name "Idaho".
_:idaho :type "state".
_:idaho :within _:usa.
_:usa a :Location
_:usa :name "United States"
_:usa :type "country".
_:usa :within _:namerica.
_:namerica a :Location
_:namerica :name "North America"
_:namerica :type :"continent"
在这个例子中,图的顶点被写为:_:someName
。这个名字并不意味着这个文件以外的任何东西。它的存在只是帮助我们明确哪些三元组引用了同一顶点。当谓语表示边时,该宾语是一个顶点,如 _:idaho :within _:usa.
。当谓语是一个属性时,该宾语是一个字符串,如 _:usa :name"United States"
一遍又一遍地重复相同的主语看起来相当重复,但幸运的是,可以使用分号来说明关于同一主语的多个事情。这使得
例
@prefix : <urn:example:>.
_:lucy a :Person; :name "Lucy"; :bornIn _:idaho.
_:idaho a :Location; :name "Idaho"; :type "state"; :within _:usa
_:usa a :Loaction; :name "United States"; :type "country"; :within _:namerica.
_:namerica a :Location; :name "North America"; :type "continent".
语义网
如果你深入了解关于三元组存储的信息,可能会陷入关于语义网的讨论漩涡中。三元组存储模型其实是完全独立于语义网存在的,例如,Datomic【40】作为一种三元组存储数据库 6,从未被用于语义网中。但是,由于在很多人眼中这两者紧密相连,我们应该简要地讨论一下。
从本质上讲,语义网是一个简单且合理的想法:网站已经将信息发布为文字和图片供人类阅读,为什么不将信息作为机器可读的数据也发布给计算机呢
不幸的是,语义网在二十一世纪初被过度炒作,但到目前为止没有任何迹象表明已在实践中应用,这使得许多人嗤之以鼻。它还饱受眼花缭乱的缩略词、过于复杂的标准提案和狂妄自大的困扰。
然而,如果从过去的失败中汲取教训,语义网项目还是拥有很多优秀的成果。即使你没有兴趣在语义网上发布
RDF 数据模型
例
例
<rdf:RDF xmlns="urn:example:"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<Location rdf:nodeID="idaho">
<name>Idaho</name>
<type>state</type>
<within>
<Location rdf:nodeID="usa">
<name>United States</name>
<type>country</type>
<within>
<Location rdf:nodeID="namerica">
<name>North America</name>
<type>continent</type>
</Location>
</within>
</Location>
</within>
</Location>
<Person rdf:nodeID="lucy">
<name>Lucy</name>
<bornIn rdf:nodeID="idaho"/>
</Person>
</rdf:RDF>
<http://my-company.com/namespace#within>
或 <http://my-company.com/namespace#lives_in>
,而不仅仅是 WITHIN
或 LIVES_IN
。这个设计背后的原因为了让你能够把你的数据和其他人的数据结合起来,如果他们赋予单词 within
或者 lives_in
不同的含义,两者也不会冲突,因为它们的谓语实际上是 <http://other.org/foo#within>
和 <http://other.org/foo#lives_in>
。
从<http://my-company.com/namespace>
http://URL
混淆,本节中的示例使用不可解析的urn:example:within
。幸运的是,你只需在文件顶部对这个前缀做一次声明,后续就不用再管了。
SPARQL 查询语言
与之前相同的查询 —— 查找从美国移民到欧洲的人 —— 使用
例
PREFIX : <urn:example:>
SELECT ?personName WHERE {
?person :name ?personName.
?person :bornIn / :within* / :name "United States".
?person :livesIn / :within* / :name "Europe".
}
结构非常相似。以下两个表达式是等价的(
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (location) # Cypher
?person :bornIn / :within* ?location. # SPARQL
因为usa
被绑定到任意 name
属性为字符串值 "United States"
的顶点:
(usa {name:'United States'}) # Cypher
?usa :name "United States". # SPARQL
图形数据库与网状模型相比较
在 “文档数据库是否在重蹈覆辙?” 中,我们讨论了
CODASYL 和关系模型如何竞相解决IMS 中的多对多关系问题。乍一看,CODASYL 的网状模型看起来与图模型相似。CODASYL 是否是图形数据库的第二个变种?不,他们在几个重要方面有所不同:
- 在
CODASYL 中,数据库有一个模式,用于指定哪种记录类型可以嵌套在其他记录类型中。在图形数据库中,不存在这样的限制:任何顶点都可以具有到其他任何顶点的边。这为应用程序适应不断变化的需求提供了更大的灵活性。- 在
CODASYL 中,达到特定记录的唯一方法是遍历其中的一个访问路径。在图形数据库中,可以通过其唯一ID 直接引用任何顶点,也可以使用索引来查找具有特定值的顶点。- 在
CODASYL 中,记录的子项目是一个有序集合,所以数据库必须去管理它们的次序(这会影响存储布局) ,并且应用程序在插入新记录到数据库时必须关注新记录在这些集合中的位置。在图形数据库中,顶点和边是无序的(只能在查询时对结果进行排序) 。- 在
CODASYL 中,所有查询都是命令式的,难以编写,并且很容易因架构变化而受到破坏。在图形数据库中,你可以在命令式代码中手写遍历过程,但大多数图形数据库都支持高级声明式查询,如Cypher 或SPARQL 。
基础:Datalog
实践中,
例
name(namerica, 'North America').
type(namerica, continent).
name(usa, 'United States').
type(usa, country).
within(usa, namerica).
name(idaho, 'Idaho').
type(idaho, state).
within(idaho, usa).
name(lucy, 'Lucy').
born_in(lucy, idaho).
既然已经定义了数据,我们可以像之前一样编写相同的查询,如 例
例
within_recursive(Location, Name) :- name(Location, Name). /* Rule 1 */
within_recursive(Location, Name) :- within(Location, Via), /* Rule 2 */
within_recursive(Via, Name).
migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */
born_in(Person, BornLoc),
within_recursive(BornLoc, BornIn),
lives_in(Person, LivingLoc),
within_recursive(LivingLoc, LivingIn).
?- migrated(Who, 'United States', 'Europe'). /* Who = 'Lucy'. */
within_recursive
和 migrated
。这些谓语不是存储在数据库中的三元组中,而是从数据或其他规则派生而来的。规则可以引用其他规则,就像函数可以调用其他函数或者递归地调用自己一样。像这样,复杂的查询可以借由小的砖瓦构建起来。
在规则中,以大写字母开头的单词是变量,谓语则用name(Location, Name)
通过变量绑定 Location = namerica
和 Name ='North America'
可以匹配三元组 name(namerica, 'North America')
。
要是系统可以在 :-
操作符的右侧找到与所有谓语的一个匹配,就运用该规则。当规则运用时,就好像通过 :-
的左侧将其添加到数据库(将变量替换成它们匹配的值
因此,一种可能的应用规则的方式是:
- 数据库存在
name (namerica, 'North America')
,故运用规则1 。它生成within_recursive (namerica, 'North America')
。 - 数据库存在
within (usa, namerica)
,在上一步骤中生成within_recursive (namerica, 'North America')
,故运用规则2 。它会产生within_recursive (usa, 'North America')
。 - 数据库存在
within (idaho, usa)
,在上一步生成within_recursive (usa, 'North America')
,故运用规则2 。它产生within_recursive (idaho, 'North America')
。
通过重复应用规则within_recursive
谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如 图

图
现在规则BornIn
的人,并住在某个地方 LivingIn
。通过查询 BornIn ='United States'
和 LivingIn ='Europe'
,并将此人作为变量 Who
,让Who
会出现哪些值。因此,最后得到了与早先的
相对于本章讨论的其他查询语言,我们需要采取不同的思维方式来思考
本章小结
数据模型是一个巨大的课题,在本章中,我们快速浏览了各种不同的模型。我们没有足够的篇幅来详述每个模型的细节,但是希望这个概述足以激起你的兴趣,以更多地了解最适合你的应用需求的模型。
在历史上,数据最开始被表示为一棵大树(层次数据模型
- 文档数据库 主要关注自我包含的数据文档,而且文档之间的关系非常稀少。
- 图形数据库 用于相反的场景:任意事物之间都可能存在潜在的关联。
这三种模型(文档,关系和图形)在今天都被广泛使用,并且在各自的领域都发挥很好。一个模型可以用另一个模型来模拟 —— 例如,图数据可以在关系数据库中表示 —— 但结果往往是糟糕的。这就是为什么我们有着针对不同目的的不同系统,而不是一个单一的万能解决方案。
文档数据库和图数据库有一个共同点,那就是它们通常不会将存储的数据强制约束为特定模式,这可以使应用程序更容易适应不断变化的需求。但是应用程序很可能仍会假定数据具有一定的结构;区别仅在于模式是明确的(写入时强制)还是隐含的(读取时处理
每个数据模型都具有各自的查询语言或框架,我们讨论了几个例子:SQL,MapReduce,
虽然我们已经覆盖了很多层面,但仍然有许多数据模型没有提到。举几个简单的例子:
- 使用基因组数据的研究人员通常需要执行 序列相似性搜索,这意味着需要一个很长的字符串(代表一个
DNA 序列) ,并在一个拥有类似但不完全相同的字符串的大型数据库中寻找匹配。这里所描述的数据库都不能处理这种用法,这就是为什么研究人员编写了像GenBank 这样的专门的基因组数据库软件的原因【48】 。 - 粒子物理学家数十年来一直在进行大数据类型的大规模数据分析,像大型强子对撞机(LHC)这样的项目现在会处理数百
PB 的数据!在这样的规模下,需要定制解决方案来阻止硬件成本的失控【49】 。 - 全文搜索 可以说是一种经常与数据库一起使用的数据模型。信息检索是一个很大的专业课题,我们不会在本书中详细介绍,但是我们将在第三章和第三部分中介绍搜索索引。
让我们暂时将其放在一边。在 下一章 中,我们将讨论在 实现 本章描述的数据模型时会遇到的一些权衡。
-
关于关系模型的文献区分了几种不同的规范形式,但这些区别几乎没有实际意义。一个经验法则是,如果重复存储了可以存储在一个地方的值,则模式就不是 规范化(normalized) 的。 ↩︎
-
在撰写本文时,
RethinkDB 支持连接,MongoDB 不支持连接,而CouchDB 只支持预先声明的视图。 ↩︎ -
外键约束允许对修改进行限制,但对于关系模型这并不是必选项。即使有约束,外键连接在查询时执行,而在
CODASYL 中,连接在插入时高效完成。 ↩︎ -
Codd 对关系模型【1】的原始描述实际上允许在关系模式中与JSON 文档非常相似。他称之为 非简单域(nonsimple domains) 。这个想法是,一行中的值不一定是一个像数字或字符串一样的原始数据类型,也可以是一个嵌套的关系(表) ,因此可以把一个任意嵌套的树结构作为一个值,这很像30 年后添加到SQL 中的JSON 或XML 支持。 ↩︎ -
IMS 和CODASYL 都使用命令式API 。应用程序通常使用COBOL 代码遍历数据库中的记录,一次一条记录【2,16】 。 ↩︎ -
从技术上讲,
Datomic 使用的是五元组而不是三元组,两个额外的字段是用于版本控制的元数据↩︎ -
Datomic 和Cascalog 使用Datalog 的Clojure S 表达式语法。在下面的例子中使用了一个更容易阅读的Prolog 语法,但两者没有任何功能差异。 ↩︎