数据模型和查询语言
我语言的局限意味着我的世界的局限。
路德维希·维特根斯坦,《逻辑哲学论》(1922)
数据模型可能是软件开发中最重要的部分,因为它们有着深远的影响:不仅影响软件的编写方式,还影响我们对所解决问题的思考方式。
大多数应用程序是通过将一个数据模型叠加在另一个数据模型之上来构建的。对于每一层,关键问题是:它是如何在下一层中表示的?例如:
-
作为应用程序开发者,您观察现实世界(其中有人员、组织、商品、行为、资金流动、传感器等),并将其建模为对象或数据结构,以及操作这些数据结构的 API。这些结构通常是特定于您的应用程序的。
-
当您想要存储这些数据结构时,您会将它们表达为通用数据模型,例如 JSON 或 XML 文档、关系数据库中的表,或图中的顶点和边。这些数据模型是本章的主题。
-
构建您数据库软件的工程师决定了一种以内存、磁盘或网络中的字节表示文档/关系/图数据的方法。该表示可能允许以各种方式查询、搜索、操作和处理数据。我们将在第 4 章讨论这些存储引擎设计。
-
在更低的层面上,硬件工程师已经找到了如何通过电流、光脉冲、磁场等方式表示字节的方法。
在复杂的应用程序中,可能会有更多的中介层,例如基于 API 构建的 API,但基本思想仍然相同:每一层通过提供一个干净的数据模型来隐藏其下层的复杂性。这些抽象使得不同的群体能够有效地协作,例如,数据库供应商的工程师和使用其数据库的应用程序开发人员。
在实践中,广泛使用几种不同的数据模型,通常用于不同的目的。某些类型的数据和某些查询在一种模型中容易表达,而在另一种模型中则显得笨拙。在本章中,我们将通过比较关系模型、文档模型、基于图的数据模型、事件溯源和数据框架来探讨这些权衡。我们还将简要查看允许您使用这些模型的查询语言。这种比较将帮助您决定何时使用哪种模型。
术语:声明式查询语言
本章中的许多查询语言(如 SQL、Cypher、SPARQL 或 Datalog)都是声明式的,这意味着您指定了所需数据的模式——结果必须满足的条件,以及您希望数据如何被转换(例如,排序、分组和聚合)——但不指定如何实现该目标。数据库系统的查询优化器可以决定使用哪些索引和连接算法,以及以何种顺序执行查询的各个部分。
相比之下,使用大多数编程语言,您必须编写一个算法——即告诉计算机以何种顺序执行哪些操作。声明式查询语言具有吸引力,因为它通常比显式算法更简洁且更易于编写。但更重要的是,它还隐藏了查询引擎的实现细节,这使得数据库系统能够在不需要对查询进行任何更改的情况下引入性能改进。1
例如,一个数据库可能能够在多个 CPU 核心和机器上并行执行声明性查询,而无需你担心如何实现这种并行性 2。在手动编写的算法中,自己实现这样的并行执行将会非常繁琐。
关系模型与文档模型
今天最著名的数据模型可能是 SQL 模型,它基于埃德加·科德在 1970 年提出的关系模型 3:数据被组织成关系(在 SQL 中称为表),每个关系是一个无序的元组集合(在 SQL 中称为行)。
关系模型最初是一个理论提案,当时许多人怀疑它是否能够高效实现。然而,到 1980 年代中期,关系数据库管理系统(RDBMS)和 SQL 已成为大多数需要以某种规则结构存储和查询数据的人的首选工具。几十年后,许多数据管理用例仍然由关系数据主导——例如,商业分析(参见“星型和雪花:分析的模式”)。
多年来,数据存储和查询有许多竞争的方法。在 1970 年代和 1980 年代初,网络模型和层次模型是主要的替代方案,但关系模型最终占据了主导地位。对象数据库在 1980 年代末和 1990 年代初出现又消失。XML 数据库在 2000 年代初出现,但仅在小众市场上得到应用。每一个与关系模型竞争的模型在其时代都引发了大量的炒作,但这种热度从未持续下来。相反,SQL 逐渐发展,除了其关系核心外,还纳入了其他数据类型——例如,增加对 XML、JSON 和图数据的支持。
在 2010 年代,NoSQL 是一个最新的流行词,试图推翻关系数据库的主导地位。NoSQL 并不是指单一技术,而是一组围绕新数据模型、模式灵活性、可扩展性以及向开源许可模式转变的松散理念。一些数据库自称为 NewSQL,因为它们旨在提供 NoSQL 系统的可扩展性,同时具备传统关系数据库的数据模型和事务保证。NoSQL 和 NewSQL 的理念在数据系统设计中产生了很大影响,但随着这些原则的广泛采用,这些术语的使用逐渐减少。
NoSQL 运动的一个持久影响是文档模型的流行,该模型通常将数据表示为 JSON。这个模型最初是由专门的文档数据库如 MongoDB 和 Couchbase 推广的,尽管现在大多数关系数据库也增加了对 JSON 的支持。与通常被视为具有刚性和不灵活模式的关系表相比,JSON 文档被认为更具灵活性。
文档数据和关系数据的优缺点已经被广泛讨论;让我们来看看这一辩论的一些关键点。
对象关系不匹配
如今,许多应用程序开发是在面向对象的编程语言中进行的,这导致了对 SQL 数据模型的一个常见批评:如果数据存储在关系表中,则在应用程序代码中的对象与数据库模型的表、行和列之间需要一个笨拙的转换层。这些模型之间的脱节有时被称为阻抗不匹配。
注意 阻抗不匹配这个术语源自电子学。每个电路在其输入和输出上都有一定的阻抗(对交流电的阻力)。当你将一个电路的输出连接到另一个电路的输入时,如果两个电路的输出和输入阻抗匹配,则通过连接的功率传输将达到最大。阻抗不匹配可能导致信号反射和其他问题。
对象关系映射(ORM)
像 ActiveRecord 和 Hibernate 这样的对象关系映射(ORM)框架减少了为这个转换层所需的样板代码量,但它们常常受到批评 4。一些常被提到的问题是:
-
ORM 是复杂的,无法完全掩盖这两种模型之间的差异,因此开发人员仍然需要考虑数据的关系表示和对象表示。
-
ORM 通常仅用于 OLTP 应用开发(参见“特征化事务处理和分析”);为了分析目的而使数据可用的数据工程师仍然需要处理底层的关系表示,因此在使用 ORM 时,关系模式的设计仍然很重要。
-
许多 ORM 仅与关系型 OLTP 数据库配合使用。拥有多样化数据系统的组织,例如搜索引擎、图数据库和 NoSQL 系统,可能会发现 ORM 支持不足。
-
一些 ORM 会自动生成关系模式,但对于直接访问关系数据的用户来说,这些模式可能显得笨拙,并且在底层数据库上可能效率低下。定制 ORM 的模式和查询生成可能会很复杂,并且会抵消使用 ORM 的初衷。
-
ORMs 使得意外编写低效查询变得容易,例如 N+1 查询问题 5。例如,假设你想在页面上显示用户评论的列表,因此你执行一个查询,返回 N 条评论,每条评论都包含其作者的 ID。为了显示评论作者的名字,你需要在用户表中查找该 ID。在手写 SQL 中,你可能会在查询中执行这个连接,并与每条评论一起返回作者的名字,但使用 ORM 时,你可能会为每条 N 条评论在用户表上执行一个单独的查询来查找其作者,最终导致总共 N+1 次数据库查询,这比在数据库中执行连接要慢。为了避免这个问题,你可能需要告诉 ORM 在获取评论的同时获取作者信息。
尽管如此,ORM 也有其优势:
-
对于适合关系模型的数据,持久关系与内存对象表示之间的某种转换是不可避免的,ORM 减少了进行这种转换所需的样板代码量。复杂的查询可能仍需在 ORM 之外处理,但 ORM 可以帮助处理简单和重复的情况。
-
一些 ORM 可以帮助缓存数据库查询的结果,这可以减少数据库的负载。
-
ORM 还可以帮助管理模式迁移和其他管理活动。
一对多关系的文档数据模型
并不是所有数据都适合用关系型表示;让我们看一个例子来探讨关系模型的局限性。图 3-1 展示了如何在关系模式中表达简历(LinkedIn 个人资料)。整个个人资料可以通过一个唯一标识符 user_id 来识别。像 first_name 和 last_name 这样的字段在每个用户中恰好出现一次,因此可以建模为 users 表中的列。
大多数人在职业生涯中有过不止一份工作(职位),而且人们可能有不同数量的教育经历和任意数量的联系信息。表示这种一对多关系的一种方法是将职位、教育和联系信息放在单独的表中,并通过外键引用 users 表,如图 3-1 所示。

表示相同信息的另一种方式,可能更自然,并且与应用代码中的对象结构更紧密地映射,是如示例 3-1 所示的 JSON 文档。
示例 3-1. 将 LinkedIn 个人资料表示为 JSON 文档
{
"user_id": 251,
"first_name": "Barack",
"last_name": "Obama",
"headline": "Former President of the United States of America",
"region_id": "us:91",
"photo_url": "/p/7/000/253/05b/308dd6e.jpg",
"positions": [
{ "job_title": "President", "organization": "United States of America" },
{ "job_title": "US Senator (D-IL)", "organization": "United States Senate" }
],
"education": [
{ "school_name": "Harvard University", "start": 1988, "end": 1991 },
{ "school_name": "Columbia University", "start": 1981, "end": 1983 }
],
"contact_info": {
"website": "https://barackobama.com",
"twitter": "https://twitter.com/barackobama"
}
}一些开发者认为,JSON 模型减少了应用代码与存储层之间的阻抗不匹配。然而,正如我们将在第 5 章中看到的,JSON 作为数据编码格式也存在问题。缺乏模式常被视为一种优势;我们将在“文档模型中的模式灵活性”中讨论这一点。
JSON 表示的局部性优于图 3-1 中的多表模式(参见“读取和写入的数据局部性”)。如果您想在关系示例中获取个人资料,您需要执行多个查询(通过 user_id 查询每个表)或在 users 表及其下属表之间执行复杂的多路连接 6。在 JSON 表示中,所有相关信息都在一个地方,使查询更快且更简单。
用户个人资料与用户的职位、教育历史和联系信息之间的一对多关系暗示了数据中的树结构,而 JSON 表示使这种树结构变得明确(参见图 3-2)。

规范化、反规范化和连接
在前一节的示例 3-1 中, region_id 被作为 ID 给出,而不是作为纯文本字符串 "Washington, DC, United States" 。为什么?
如果用户界面有一个自由文本字段用于输入地区,那么将其存储为纯文本字符串是合理的。但拥有标准化的地理区域列表,并让用户从下拉列表或自动完成中选择也是有优势的:
-
在各个档案中保持一致的风格和拼写
-
避免歧义,如果有多个地方同名(如果字符串只是“华盛顿”,那是指华盛顿特区还是华盛顿州?)
-
便于更新——名称只存储在一个地方,因此如果需要更改(例如,由于政治事件而更改城市名称),可以轻松地进行全面更新
-
本地化支持——当网站翻译成其他语言时,标准化列表可以进行本地化,从而使地区能够以查看者的语言显示。
-
更好的搜索——例如,搜索美国东海岸的人可以匹配此个人资料,因为地区列表可以编码华盛顿位于东海岸的事实(这一点在字符串 "Washington, DC" 中并不明显)。
存储 ID 还是文本字符串是一个规范化的问题。当你使用 ID 时,你的数据更加规范化:对人类有意义的信息(例如文本华盛顿,DC)只存储在一个地方,所有引用它的内容都使用一个 ID(该 ID 仅在数据库内有意义)。当你直接存储文本时,你在每个使用它的记录中重复了对人类有意义的信息;这种表示是非规范化的。
使用 ID 的优点在于,由于它对人类没有意义,因此永远不需要更改:即使它所标识的信息发生变化,ID 也可以保持不变。任何对人类有意义的东西在未来可能需要更改——如果该信息被重复,所有冗余的副本都需要更新。这需要更多的代码、更多的写操作、更多的磁盘空间,并且存在不一致的风险(某些信息的副本被更新而其他副本却没有)。
规范化表示的缺点在于,每次想要显示包含 ID 的记录时,都必须进行额外的查找,以将 ID 解析为人类可读的内容。在关系数据模型中,这通常通过连接来完成,例如:
SELECT users.*, regions.region_name
FROM users
JOIN regions ON users.region_id = regions.id
WHERE users.id = 251;文档数据库可以存储规范化和非规范化的数据,但它们通常与非规范化相关联——部分原因是 JSON 数据模型使得存储额外的非规范化字段变得容易,部分原因是许多文档数据库对连接的支持较弱,使得规范化变得不方便。有些文档数据库根本不支持连接,因此你必须在应用程序代码中执行连接——也就是说,你首先获取一个包含 ID 的文档,然后执行第二个查询将该 ID 解析为另一个文档。在 MongoDB 中,也可以在聚合管道中使用 $lookup 运算符执行连接:
db.users.aggregate([
{ $match: { _id: 251 } },
{ $lookup: {
from: "regions",
localField: "region_id",
foreignField: "_id",
as: "region"
} }
])规范化的权衡
在简历示例中,虽然 region_id 字段是指向标准化区域集的引用,但 organization (该人工作的公司或政府)和 school_name (他们学习的地方)的名称只是字符串。这种表示是非规范化的:许多人可能在同一家公司工作过,但没有 ID 将他们链接在一起。
也许组织和学校应该作为实体,而个人资料应该引用它们的 ID 而不是名称?引用地区 ID 的相同论点在这里也适用。例如,假设我们想在名称之外包含学校或公司的标志:
-
在非规范化表示中,我们会在每个人的个人资料中包含标志的图像 URL;这使得 JSON 文档自包含,但如果我们需要更改标志,就会造成麻烦,因为我们现在需要找到所有旧 URL 的出现并更新它们7。
-
在规范化表示中,我们会创建一个表示组织或学校的实体,并在该实体上存储其名称、标志 URL,以及其他属性(描述、新闻源等)一次。每个提到该组织的简历将简单地引用其 ID,更新标志也很容易。
作为一般原则,规范化数据通常写入速度更快(因为只有一份副本),但查询速度较慢(因为需要连接);非规范化数据通常读取速度更快(连接较少),但写入成本更高(需要更新更多副本,使用更多磁盘空间)。你可能会发现将非规范化视为一种派生数据(“记录系统和派生数据”)是有帮助的,因为你需要建立一个更新冗余数据副本的过程。
除了执行所有这些更新的成本外,你还需要考虑如果一个过程在更新过程中崩溃,数据库的一致性。提供原子事务的数据库(见“原子性”)使保持一致性变得更容易,但并非所有数据库都在多个文档之间提供原子性。通过流处理也可以确保一致性,我们将在[链接即将到来]中讨论。
规范化通常更适合在线事务处理(OLTP)系统,在这些系统中,读取和更新都需要快速;而分析系统通常在非规范化数据上表现更好,因为它们以批量方式执行更新,并且只读查询的性能是主要关注点。此外,在小到中等规模的系统中,规范化的数据模型通常是最佳选择,因为您不必担心保持多个数据副本之间的一致性,并且执行连接的成本是可以接受的。然而,在非常大规模的系统中,连接的成本可能会变得成问题。
社交网络案例研究中的非规范化
在“案例研究:社交网络主页时间线”中,我们比较了规范化表示(图 2-1)和非规范化表示(预计算的物化时间线):在这里, posts 和 follows 之间的连接成本过高,而物化时间线是该连接结果的缓存。将新帖子插入关注者时间线的扩展过程是我们保持非规范化表示一致性的方法。
然而,X(前身为 Twitter)实施的物化时间线并不存储每个帖子的实际文本:每个条目实际上只存储帖子 ID、发布该帖子的用户 ID,以及一些额外的信息以识别转发和回复 9。换句话说,它是对(大致上)以下查询的预计算结果:
SELECT posts.id, posts.sender_id FROM posts
JOIN follows ON posts.sender_id = follows.followee_id
WHERE follows.follower_id = current_user
ORDER BY posts.timestamp DESC
LIMIT 1000这意味着每当读取时间线时,服务仍然需要执行两个连接:查找帖子 ID 以获取实际的帖子内容(以及统计信息,如点赞和回复的数量),并通过 ID 查找发送者的个人资料(以获取他们的用户名、头像和其他详细信息)。这个通过 ID 查找可读信息的过程称为“填充 ID”,本质上是在应用程序代码中执行的连接 9。
在预计算时间线中仅存储 ID 的原因是它们所指的数据变化迅速:在热门帖子上,点赞和回复的数量可能每秒变化多次,一些用户也会定期更改他们的用户名或头像。由于时间线在查看时应显示最新的点赞数和头像,因此将这些信息反规范化到物化时间线中是没有意义的。此外,这种反规范化会显著增加存储成本。
这个例子表明,在读取数据时必须执行连接操作并不是如有时所声称的那样,创建高性能、可扩展服务的障碍。填充帖子 ID 和用户 ID 实际上是一个相对容易扩展的操作,因为它可以很好地并行化,并且成本不依赖于你关注的账户数量或你拥有的粉丝数量。
如果您需要决定是否在应用程序中进行反规范化,社交网络案例研究表明,这个选择并不是显而易见的:最具可扩展性的方法可能涉及反规范化某些内容,同时保留其他内容的规范化。您需要仔细考虑信息变化的频率,以及读写的成本(在典型社交网络的情况下,这可能会被一些极端值主导,例如拥有许多关注/粉丝的用户)。规范化和反规范化本身并没有好坏之分——它们只是读写性能以及实现所需努力之间的权衡。
多对一和多对多关系
在图 3-1 中, positions 和 education 是一个对多或对少关系的例子(一个简历有多个职位,但每个职位仅属于一个简历),而 region_id 字段是多对一关系的例子(许多人生活在同一个地区,但我们假设每个人在任何时候只生活在一个地区)。
如果我们为组织和学校引入实体,并通过简历中的 ID 进行引用,那么我们也就有了多对多关系(一个人曾在多个组织工作,而一个组织有多个过去或现在的员工)。在关系模型中,这种关系通常表示为关联表或连接表,如图 3-3 所示:每个职位将一个用户 ID 与一个组织 ID 关联起来。

多对一和多对多关系不容易适应于一个自包含的 JSON 文档;它们更适合于规范化表示。在文档模型中,一种可能的表示方式在示例 3-2 中给出,并在图 3-4 中说明:每个虚线矩形内的数据可以组合成一个文档,但与组织和学校的链接最好表示为对其他文档的引用。
示例 3-2. 一个通过 ID 引用组织的简历。
{
"user_id": 251,
"first_name": "Barack",
"last_name": "Obama",
"positions": [
{"start": 2009, "end": 2017, "job_title": "President", "org_id": 513},
{"start": 2005, "end": 2008, "job_title": "US Senator (D-IL)", "org_id": 514}
],
...
}
多对多关系通常需要以“两个方向”进行查询:例如,查找某个人曾工作过的所有组织,以及查找在某个特定组织工作过的所有人。实现这种查询的一种方法是在两侧存储 ID 引用,即简历中包含该人曾工作过的每个组织的 ID,而组织文档中包含提到该组织的简历的 ID。这种表示是非规范化的,因为关系存储在两个地方,可能会彼此不一致。
规范化表示仅在一个地方存储关系,并依赖于辅助索引(我们在第 4 章中讨论)以便能够高效地双向查询该关系。在图 3-3 的关系模式中,我们会告诉数据库在 positions 表的 user_id 和 org_id 列上创建索引。
在示例 3-2 的文档模型中,数据库需要对 positions 数组中对象的 org_id 字段进行索引。许多文档数据库和支持 JSON 的关系数据库能够在文档内部的值上创建这样的索引。
星型和雪花型:分析的模式
数据仓库(见“数据仓库”)通常是关系型的,并且在数据仓库中有一些广泛使用的表结构约定:星型模式、雪花型模式、维度建模10和一个大表(OBT)。这些结构是针对业务分析师的需求进行优化的。ETL 过程将来自操作系统的数据转换为这种模式。
图 3-5 展示了一个可能出现在杂货零售商数据仓库中的星型模式示例。模式的中心是所谓的事实表(在这个例子中,它被称为 fact_sales )。事实表的每一行代表在特定时间发生的事件(在这里,每一行代表客户购买某个产品)。如果我们分析的是网站流量而不是零售销售,每一行可能代表用户的页面浏览或点击。

通常,事实被捕获为单独的事件,因为这允许后续分析的最大灵活性。然而,这意味着事实表可能会变得非常庞大。一个大型企业可能在其数据仓库中拥有数 PB 的交易历史,主要以事实表的形式表示。
事实表中的一些列是属性,例如产品销售价格和从供应商处购买的成本(允许计算利润率)。事实表中的其他列是对其他表的外键引用,这些表称为维度表。由于事实表中的每一行代表一个事件,因此维度表示事件的谁、什么、在哪里、何时、如何和为什么。
例如,在图 3-5 中,其中一个维度是销售的产品。 dim_product 表中的每一行代表一种待售产品,包括其库存单位(SKU)、描述、品牌名称、类别、脂肪含量、包装大小等。 fact_sales 表中的每一行使用外键指示在特定交易中销售的产品。查询通常涉及多个维度表的多次连接。
即使日期和时间通常也使用维度表表示,因为这允许对日期的附加信息(例如公共假期)进行编码,从而使查询能够区分假期和非假期的销售。
图 3-5 是星型模式的一个例子。这个名称来源于当表关系被可视化时,事实表位于中间,周围是其维度表;与这些表的连接就像星星的光芒。
这种模板的一个变体被称为雪花模式,其中维度进一步细分为子维度。例如,可能会有单独的品牌和产品类别表,而 dim_product 表中的每一行可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 dim_product 表中。雪花模式比星型模式更规范化,但星型模式通常更受欢迎,因为它们对分析师来说更简单易用10。
在一个典型的数据仓库中,表通常相当宽:事实表通常有超过 100 列,有时甚至有几百列。维度表也可能很宽,因为它们包含所有可能与分析相关的元数据——例如, dim_store 表可能包括每个商店提供的服务的详细信息,是否有店内面包店,面积,商店首次开业的日期,最后一次翻新的时间,距离最近高速公路的距离等。
星型或雪花型模式主要由多对一关系组成(例如,许多销售发生在一个特定产品上,在一个特定商店中),表示为事实表具有指向维度表的外键,或维度指向子维度。原则上,其他类型的关系也可能存在,但它们通常被非规范化以简化查询。例如,如果一个客户一次购买了几种不同的产品,那么该多项交易并没有被明确表示;相反,事实表中每个购买的产品都有一行单独的记录,而这些事实恰好具有相同的客户 ID、商店 ID 和时间戳。
一些数据仓库模式进一步推进非规范化,完全省略维度表,将维度中的信息折叠到事实表的非规范化列中(本质上是预计算事实表与维度表之间的连接)。这种方法被称为一个大表(OBT),虽然它需要更多的存储空间,但有时可以实现更快的查询 11。
在分析的背景下,这种非规范化是没有问题的,因为数据通常代表的是不会改变的历史数据日志(除了偶尔纠正错误)。在分析中,非规范化所带来的数据一致性和写入开销问题并没有那么紧迫。
何时使用哪种模型
支持文档数据模型的主要论点是模式灵活性、由于局部性带来的更好性能,以及对于某些应用程序来说,它更接近于应用程序使用的对象模型。关系模型则通过提供对连接、多对一和多对多关系的更好支持来反驳。让我们更详细地审视这些论点。
如果您应用中的数据具有文档式结构(即,一种一对多关系的树形结构,通常整个树一次性加载),那么使用文档模型可能是个好主意。关系型技术中的拆分——将文档式结构分割成多个表(如图 3-1 中的 positions 、 education 和 contact_info )——可能导致繁琐的模式和不必要复杂的应用代码。
文档模型有其局限性:例如,您不能直接引用文档中的嵌套项,而是需要说类似“用户 251 的职位列表中的第二项”。如果您确实需要引用嵌套项,关系型方法会更有效,因为您可以通过 ID 直接引用任何项。
一些应用程序允许用户选择项目的顺序:例如,想象一个待办事项列表或问题跟踪器,用户可以拖放任务以重新排序。文档模型很好地支持此类应用程序,因为项目(或其 ID)可以简单地存储在 JSON 数组中以确定其顺序。在关系数据库中,没有标准的方法来表示这种可重新排序的列表,因此使用了各种技巧:通过整数列排序(在中间插入时需要重新编号)、ID 的链表或分数索引 [14, 15, 16]。
文档模型中的模式灵活性
大多数文档数据库以及关系数据库中的 JSON 支持,不会对文档中的数据强制执行任何模式。关系数据库中的 XML 支持通常带有可选的模式验证。没有模式意味着可以将任意键和值添加到文档中,并且在读取时,客户端无法保证文档可能包含哪些字段。
文档数据库有时被称为无模式,但这有些误导,因为读取数据的代码通常假设某种结构——即,存在一个隐式模式,但数据库并不强制执行 12。一个更准确的术语是按读模式(数据的结构是隐式的,只有在读取数据时才会被解释),与按写模式(关系数据库的传统方法,其中模式是显式的,数据库确保所有数据在写入时符合该模式)相对 13。
按读模式类似于编程语言中的动态(运行时)类型检查,而按写模式类似于静态(编译时)类型检查。正如静态和动态类型检查的支持者之间就其相对优缺点进行激烈辩论一样14,数据库中模式的强制执行也是一个有争议的话题,通常没有绝对的对错答案。
在应用程序想要更改其数据格式的情况下,这两种方法之间的差异尤为明显。例如,假设您当前将每个用户的全名存储在一个字段中,而您希望将名字和姓氏分别存储15。在文档数据库中,您只需开始编写带有新字段的新文档,并在应用程序中编写处理读取旧文档的情况的代码。例如:
if (user && user.name && !user.first_name) {
// Documents written before Dec 8, 2023 don't have first_name
user.first_name = user.name.split(" ")[^0];
}这种方法的缺点是,您应用程序中每个从数据库读取数据的部分现在都需要处理可能很久以前写入的旧格式文档。另一方面,在写时模式数据库中,您通常会执行类似于以下的迁移:
ALTER TABLE users ADD COLUMN first_name text DEFAULT NULL;
UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL
UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL在大多数关系数据库中,添加一个带有默认值的列是快速且没有问题的,即使在大型表上也是如此。然而,在大型表上运行 UPDATE 语句可能会很慢,因为每一行都需要被重写,其他模式操作(例如更改列的数据类型)通常也需要复制整个表。
存在各种工具可以在后台执行这种类型的模式更改而不需要停机 [21, 22, 23, 24],但在大型数据库上执行此类迁移仍然具有操作上的挑战。可以通过仅添加 first_name 列并将其默认值设置为 NULL (这很快)来避免复杂的迁移,并在读取时填充,就像使用文档数据库一样。
如果集合中的项目由于某种原因并不都具有相同的结构(即数据是异构的),那么按读取模式的方法是有利的——例如,因为:
存在许多不同类型的对象,将每种类型的对象放在自己的表中并不切实际。
数据的结构由您无法控制的外部系统决定,并且这些系统可能随时发生变化。
在这种情况下,模式可能弊大于利,而无模式文档可以成为一种更自然的数据模型。但在所有记录都期望具有相同结构的情况下,模式是记录和强制执行该结构的有用机制。我们将在第 5 章中更详细地讨论模式和模式演进。
读取和写入的数据局部性
文档通常作为一个连续的字符串存储,编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON)。如果您的应用程序经常需要访问整个文档(例如,在网页上呈现它),这种存储局部性会带来性能优势。如果数据分散在多个表中,如图 3-1 所示,则需要进行多次索引查找才能检索所有数据,这可能需要更多的磁盘寻道时间并耗费更多时间。
局部优势仅在您需要同时访问文档的大部分内容时适用。数据库通常需要加载整个文档,如果您只需要访问大型文档的一小部分,这可能会造成浪费。在对文档进行更新时,通常需要重写整个文档。基于这些原因,通常建议您保持文档相对较小,并避免对文档进行频繁的小更新。
然而,将相关数据一起存储以实现局部性的想法并不限于文档模型。例如,谷歌的 Spanner 数据库在关系数据模型中提供相同的局部性特性,允许模式声明一个表的行应在父表中交错(嵌套)16。Oracle 也允许这样做,使用一种称为多表索引集群表的功能17。谷歌的 Bigtable 所推广的宽列数据模型,例如在 HBase 和 Accumulo 中使用,具有列族的概念,其目的与管理局部性相似18。
文档的查询语言
关系数据库和文档数据库之间的另一个区别是用于查询的语言或 API。大多数关系数据库使用 SQL 进行查询,但文档数据库则更加多样化。有些仅允许通过主键进行键值访问,而其他一些则提供二级索引以查询文档中的值,还有一些提供丰富的查询语言。
XML 数据库通常使用 XQuery 和 XPath 进行查询,这些查询语言旨在允许复杂的查询,包括跨多个文档的连接,并将结果格式化为 XML 19。JSON Pointer 20 和 JSONPath 21 提供了 JSON 的 XPath 等效物。MongoDB 的聚合管道,其在“规范化、非规范化和连接”中看到的用于连接的 $lookup 运算符,是一个用于 JSON 文档集合的查询语言示例。
让我们看另一个例子,以便感受这种语言——这次是聚合,这在分析中尤其需要。想象一下你是一名海洋生物学家,每次在海洋中看到动物时,你都会向数据库添加一条观察记录。现在你想生成一份报告,说明你每个月看到多少只鲨鱼。在 PostgreSQL 中,你可能会这样表达这个查询:
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的日历月份,并返回另一个表示该月份开始的时间戳。换句话说,它将时间戳向下舍入到最近的月份。
这个查询首先过滤观察记录,只显示 Sharks 科的物种,然后按发生的日历月份对观察记录进行分组,最后统计该月份所有观察记录中看到的动物数量。相同的查询可以使用 MongoDB 的聚合管道表示如下:
db.observations.aggregate([
{ $match: { family: "Sharks" } },
{ $group: {
_id: {
year: { $year: "$observationTimestamp" },
month: { $month: "$observationTimestamp" }
},
totalAnimals: { $sum: "$numAnimals" }
} }
]);聚合管道语言在表达能力上与 SQL 的一个子集相似,但它使用基于 JSON 的语法,而不是 SQL 的英语句子风格语法;这种差异或许只是个人口味的问题。
文档数据库与关系数据库的融合
文档数据库和关系数据库最初是两种截然不同的数据管理方法,但随着时间的推移,它们变得越来越相似 22。关系数据库增加了对 JSON 类型和查询操作符的支持,以及在文档内部索引属性的能力。一些文档数据库(如 MongoDB、Couchbase 和 RethinkDB)增加了对连接、二级索引和声明式查询语言的支持。
这种模型的融合对应用程序开发者来说是个好消息,因为关系模型和文档模型在同一个数据库中结合使用时效果最佳。许多文档数据库需要对其他文档的关系型引用,而许多关系数据库在某些部分则需要模式灵活性。关系-文档混合体是一种强大的组合。
注意 Codd 对关系模型的最初描述3实际上允许在关系模式中使用类似 JSON 的内容。他称之为非简单域。这个想法是,行中的一个值不必仅仅是一个原始数据类型,比如数字或字符串,而可以是一个嵌套关系(表)——因此你可以将一个任意嵌套的树结构作为值,这与 30 多年后添加到 SQL 中的 JSON 或 XML 支持非常相似。
图形数据模型
我们之前看到,关系的类型是不同数据模型之间一个重要的区分特征。如果你的应用主要有一对多关系(树状数据),而记录之间的其他关系很少,那么文档模型是合适的。
但是如果在你的数据中多对多关系非常常见呢?关系模型可以处理简单的多对多关系,但随着数据内部连接变得更加复杂,开始将数据建模为图形会更加自然。
图由两种对象组成:顶点(也称为节点或实体)和边(也称为关系或弧)。许多类型的数据可以建模为图。典型的例子包括:
社交图 顶点是人,边表示哪些人彼此认识。
网络图 顶点是网页,边表示指向其他页面的 HTML 链接。
道路或铁路网络 顶点是交叉口,边表示它们之间的道路或铁路线路。
众所周知的算法可以在这些图上运行:例如,地图导航应用程序在道路网络中搜索两个点之间的最短路径,而 PageRank 可以用于网络图,以确定网页的受欢迎程度,从而影响其在搜索结果中的排名23。
图可以以几种不同的方式表示。在邻接表模型中,每个顶点存储与其相邻的顶点的 ID,这些顶点与之相距一条边。或者,您可以使用邻接矩阵,这是一种二维数组,其中每一行和每一列对应一个顶点,当行顶点与列顶点之间没有边时,值为零;如果存在边,则值为一。邻接表适合图的遍历,而矩阵适合机器学习(见“数据框、矩阵和数组”)。
在刚才给出的例子中,图中的所有顶点代表同一种事物(分别是人、网页或路口)。然而,图并不限于这种同质数据:图的一个同样强大的用途是提供一种一致的方式,将完全不同类型的对象存储在一个数据库中。例如:
-
Facebook 维护着一个包含多种不同类型顶点和边的单一图:顶点代表人、地点、事件、签到和用户的评论;边表示哪些人是彼此的朋友,哪个签到发生在什么地点,谁对哪个帖子进行了评论,谁参加了哪个事件,等等24。
-
知识图谱被搜索引擎用来记录在搜索查询中经常出现的实体的事实,例如组织、人物和地点25。这些信息是通过爬取和分析网站上的文本获得的;一些网站,如 Wikidata,也以结构化的形式发布图数据。
在图中有几种不同但相关的数据结构和查询方式。在本节中,我们将讨论属性图模型(由 Neo4j、Memgraph、KùzuDB 26等实现)和三元组存储模型(由 Datomic、AllegroGraph、Blazegraph 等实现)。这些模型在表达能力上相当相似,一些图数据库(如 Amazon Neptune)支持这两种模型。
我们还将查看四种图查询语言(Cypher、SPARQL、Datalog 和 GraphQL),以及用于查询图的 SQL 支持。还有其他图查询语言,例如 Gremlin 27,但这些将为我们提供一个代表性的概述。
为了说明这些不同的语言和模型,本节使用图 3-6 所示的图作为运行示例。它可以来自社交网络或家谱数据库:它展示了两个人,来自爱达荷州的露西和来自法国圣洛的阿兰。他们已婚并居住在伦敦。每个人和每个地点都表示为一个顶点,它们之间的关系则表示为边。这个例子将帮助演示一些在图数据库中容易实现的查询,而在其他模型中则较为困难。

属性图
在属性图(也称为标记属性图)模型中,每个顶点由以下部分组成:
-
一个唯一标识符
-
一个标签(字符串)用于描述这个顶点代表的对象类型
-
一组出边
-
一组入边
-
一组属性(键值对)
每条边由以下部分组成:
-
一个唯一标识符
-
边的起始顶点(尾顶点)
-
边的结束顶点(头顶点)
-
一个标签,用于描述两个顶点之间的关系类型
-
一组属性(键值对)
你可以将图存储视为由两个关系表组成,一个用于顶点,一个用于边,如示例 3-3 所示(该模式使用 PostgreSQL jsonb 数据类型来存储每个顶点或边的属性)。每条边都存储了头顶点和尾顶点;如果你想获取某个顶点的入边或出边集合,可以分别通过 head_vertex 或 tail_vertex 查询 edges 表。
示例 3-3. 使用关系模式表示属性图
CREATE TABLE vertices (
vertex_id integer PRIMARY KEY,
label text,
properties jsonb
);
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 jsonb
);
CREATE INDEX edges_tails ON edges (tail_vertex);
CREATE INDEX edges_heads ON edges (head_vertex);该模型的一些重要方面包括:
-
任何顶点都可以与任何其他顶点通过边连接。没有任何模式限制可以或不能关联的事物类型。
-
给定任何顶点,您可以高效地找到其所有入边和出边,从而遍历图——即,沿着一系列顶点的路径前进和后退。(这就是为什么示例 3-3 在 tail_vertex 和 head_vertex 列上都有索引的原因。)
-
通过对不同类型的顶点和关系使用不同的标签,您可以在单个图中存储几种不同类型的信息,同时仍然保持一个清晰的数据模型。
-
边缘表类似于我们在“多对一和多对多关系”中看到的多对多关联表/连接表,经过概括可以在同一表中存储多种不同类型的关系。标签和属性上也可能有索引,从而允许高效查找具有特定属性的顶点或边缘。
注意 图模型的一个限制是,边缘只能将两个顶点相互关联,而关系连接表可以通过在单行上具有多个外键引用来表示三元关系甚至更高阶的关系。这种关系可以通过为连接表的每一行创建一个额外的顶点,并与该顶点建立边缘,或者通过使用超图来表示。
这些特性为数据建模提供了极大的灵活性,如图 3-6 所示。该图展示了一些在传统关系模式中难以表达的内容,例如不同国家的不同区域结构(法国有省和大区,而美国有县和州)、历史上的一些特例,如一个国家内有一个国家(暂时不考虑主权国家和民族的复杂性),以及数据的不同粒度(露西目前的居住地被指定为一个城市,而她的出生地仅在州的层面上被指定)。
你可以想象将图扩展到包括关于露西和阿兰的许多其他事实,或者其他人。例如,你可以用它来指示他们是否有食物过敏(通过为每种过敏原引入一个顶点,并在一个人和过敏原之间建立一条边以指示过敏),并将过敏原与一组顶点连接,这些顶点显示哪些食物含有哪些物质。然后你可以写一个查询,以找出每个人可以安全食用的食物。图形在可演化性方面表现良好:随着你向应用程序添加功能,图形可以轻松扩展以适应应用程序数据结构的变化。
Cypher 查询语言
Cypher 是一种用于属性图的查询语言,最初为 Neo4j 图数据库创建,后来发展为开放标准 openCypher 28。除了 Neo4j,Cypher 还被 Memgraph、KùzuDB 26、Amazon Neptune、Apache AGE(在 PostgreSQL 中存储)等支持。它的名称来源于电影《黑客帝国》中的一个角色,与密码学中的密码无关 29。
示例 3-4 显示了将图 3-6 的左侧部分插入图形数据库的 Cypher 查询。其余部分的图形可以类似地添加。每个顶点都有一个符号名称,如 usa 或 idaho 。该名称不会存储在数据库中,而仅在查询内部使用,以使用箭头表示法在顶点之间创建边: (idaho) -[:WITHIN]-> (usa) 创建一个标记为 WITHIN 的边, idaho 作为尾节点, usa 作为头节点。
示例 3-4。图 3-6 中数据的一个子集,以 Cypher 查询表示
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)当图 3-6 的所有顶点和边都添加到数据库中时,我们可以开始提出有趣的问题:例如,找出所有从美国移民到欧洲的人的名字。也就是说,找到所有与美国境内某个位置有 BORN_IN 边,并且与欧洲境内某个位置有 LIVING_IN 边的顶点,并返回每个顶点的 name 属性。
示例 3-5 展示了如何在 Cypher 中表达该查询。在 MATCH 子句中使用相同的箭头表示法来查找图中的模式: (person) -[:BORN_IN]-> () 匹配任何两个通过标记为 BORN_IN 的边相关联的顶点。该边的尾顶点绑定到变量 person ,而头顶点则未命名。
示例 3-5. Cypher 查询以查找从美国移民到欧洲的人
MATCH
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (: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 中的图查询
示例 3-3 表明图数据可以在关系数据库中表示。但如果我们将图数据放入关系结构中,是否也可以使用 SQL 查询它?
答案是肯定的,但有一定的难度。在图查询中,你遍历的每条边实际上都是与 edges 表的连接。在关系数据库中,你通常提前知道查询中需要哪些连接。另一方面,在图查询中,你可能需要遍历可变数量的边,才能找到你要找的顶点——也就是说,连接的数量并不是提前固定的。
在我们的例子中,这发生在 Cypher 查询中的 () -[:WITHIN*0..]-> () 模式。一个人的 LIVES_IN 边缘可以指向任何类型的位置:街道、城市、地区、区域、州等。一个城市可以是 WITHIN 一个区域,一个区域 WITHIN 一个州,一个州 WITHIN 一个国家等。 LIVES_IN 边缘可以直接指向您要查找的位置顶点,或者可能在位置层次结构中相隔几个层级。
在 Cypher 中, :WITHIN*0.. 非常简洁地表达了这一事实:它的意思是“跟随一个 WITHIN 边缘,零次或多次。”它就像正则表达式中的 * 运算符。
自 SQL:1999 以来,这种在查询中可变长度遍历路径的想法可以使用称为递归公共表表达式的东西来表达( WITH RECURSIVE 语法)。示例 3-6 展示了同样的查询——查找从美国移民到欧洲的人的名字——使用这种技术在 SQL 中表达。然而,与 Cypher 相比,这种语法显得非常笨拙。
示例 3-6. 与示例 3-5 相同的查询,使用递归公共表表达式在 SQL 中编写
WITH RECURSIVE
-- in_usa is the set of vertex IDs of all locations within the United States
in_usa(vertex_id) AS (
SELECT vertex_id FROM vertices
WHERE label = 'Location' AND properties->>'name' = 'United States' 1
UNION
SELECT edges.tail_vertex FROM edges 2
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'within'
),
-- in_europe is the set of vertex IDs of all locations within Europe
in_europe(vertex_id) AS (
SELECT vertex_id FROM vertices
WHERE label = 'location' AND properties->>'name' = 'Europe' 3
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 is the set of vertex IDs of all people born in the US
born_in_usa(vertex_id) AS ( 4
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 is the set of vertex IDs of all people living in Europe
lives_in_europe(vertex_id) AS ( 5
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 to find those people who were both born in the US *and* live in Europe
JOIN born_in_usa ON vertices.vertex_id = born_in_usa.vertex_id 6
JOIN lives_in_europe ON vertices.vertex_id = lives_in_europe.vertex_id;-
首先找到其 name 属性值为 "United States" 的顶点,并将其作为顶点集合 in_usa 的第一个元素。
-
从集合 in_usa 中的顶点开始,跟随所有入边 within ,并将它们添加到同一集合中,直到所有入边 within 都被访问过。
-
同样,从其 name 属性值为 "Europe" 的顶点开始,构建顶点集合 in_europe 。
-
对于集合 in_usa 中的每个顶点,跟随入边 born_in ,找到在美国某地出生的人。
-
同样,对于集合 in_europe 中的每个顶点,沿着进入的 lives_in 边查找居住在欧洲的人。
-
最后,通过将出生在美国的人与居住在欧洲的人进行交集。
一个 4 行的 Cypher 查询在 SQL 中需要 31 行,这表明选择合适的数据模型和查询语言之间的差异有多大。这仅仅是个开始;还有更多细节需要考虑,例如处理循环,以及选择广度优先或深度优先遍历 30。Oracle 有一个不同的 SQL 扩展用于递归查询,称为层次查询 31。
然而,情况可能正在改善:在撰写时,有计划将一种名为 GQL 的图查询语言添加到 SQL 标准中3233,它将提供一种受 Cypher 启发的语法,GSQL 34 和 PGQL 35。
三元组存储和 SPARQL
三元组存储模型大致等同于属性图模型,只是用不同的词汇来描述相同的概念。然而,讨论它仍然是值得的,因为有各种工具和语言可用于三元组存储,这些工具和语言可以成为构建应用程序时的有价值补充。
在三元组存储中,所有信息以非常简单的三部分语句形式存储:(主题,谓词,宾语)。例如,在三元组(Jim,likes,bananas)中,Jim 是主题,likes 是谓词(动词),而 bananas 是宾语。
三元组的主题相当于图中的一个顶点。对象是两种事物之一:
原始数据类型的值,例如字符串或数字。在这种情况下,三元组的谓词和对象等同于主题顶点上属性的键和值。使用图 3-6 中的示例,(lucy, birthYear, 1989)就像一个具有属性 {"birthYear": 1989} 的顶点 lucy 。
图中的另一个顶点。在这种情况下,谓词是图中的一条边,主题是尾顶点,对象是头顶点。例如,在(lucy, marriedTo, alain)中,主题和对象 lucy 和 alain 都是顶点,而谓词 marriedTo 是连接它们的边的标签。
示例 3-7 显示了与示例 3-4 相同的数据,以称为 Turtle 的格式编写为三元组,这是 Notation3 (N3)的一个子集38。
示例 3-7. 图 3-6 中数据的一个子集,以 Turtle 三元组表示
@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" 。
重复同一个主题是相当乏味的,但幸运的是,你可以使用分号来对同一个主题说多个事情。这使得 Turtle 格式相当易读:见示例 3-8。
示例 3-8. 更简洁地书写示例 3-7 中的数据
@prefix : <urn:example:>.
_:lucy a :Person; :name "Lucy"; :bornIn _:idaho.
_:idaho a :Location; :name "Idaho"; :type "state"; :within _:usa.
_:usa a :Location; :name "United States"; :type "country"; :within _:namerica.
_:namerica a :Location; :name "North America"; :type "continent".语义网 对三元组存储的部分研究和开发工作是受到语义网的启发,语义网是 2000 年代初期的一项努力,旨在通过以标准化的机器可读格式发布数据,不仅以人类可读的网页形式,还促进互联网范围内的数据交换。尽管最初设想的语义网并未成功 [ 49, 50],但语义网项目的遗产在一些特定技术中得以延续:如 JSON-LD [ 51] 的链接数据标准、用于生物医学科学的本体 [ 52]、Facebook 的开放图谱协议 [ 53](用于链接展开 [ 54])、维基数据等知识图谱,以及由 schema.org 维护的结构化数据标准词汇。
三元组存储是另一种语义网技术,它在其原始用例之外找到了应用:即使您对语义网没有兴趣,三元组也可以作为应用程序的良好内部数据模型。
RDF 数据模型
我们在示例 3-8 中使用的 Turtle 语言实际上是一种在资源描述框架 (RDF) 39 中编码数据的方式,这是一种为语义网设计的数据模型。RDF 数据也可以以其他方式编码,例如(更冗长地)使用 XML,如示例 3-9 所示。像 Apache Jena 这样的工具可以自动在不同的 RDF 编码之间转换。
Example 3-9. The data of Example 3-8, expressed using RDF/XML syntax 示例 3-9. 使用 RDF/XML 语法表达的示例 3-8 的数据
<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>由于 RDF 是为互联网范围内的数据交换而设计的,因此它有一些独特之处。三元组的主题、谓词和宾语通常是 URI。例如,谓词可能是一个 URI,如 <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> 。
URL <http://my-company.com/namespace> 不一定需要解析为任何内容——从 RDF 的角度来看,它仅仅是一个命名空间。为了避免与 http:// URL 产生潜在的混淆,本节中的示例使用了不可解析的 URI,例如 urn:example:within 。幸运的是,您只需在文件顶部指定一次这个前缀,然后就可以忘记它。
SPARQL 查询语言
SPARQL 是一种用于使用 RDF 数据模型的三元组存储的查询语言 40。(它是 SPARQL 协议和 RDF 查询语言的缩写,发音为“sparkle”。)它早于 Cypher,并且由于 Cypher 的模式匹配是借鉴自 SPARQL,因此它们看起来非常相似。
与之前相同的查询——查找从美国迁移到欧洲的人——在 SPARQL 中同样简洁,如同在 Cypher 中一样(见示例 3-10)。
示例 3-10. 与示例 3-5 相同的查询,以 SPARQL 表达
PREFIX : <urn:example:>
SELECT ?personName WHERE {
?person :name ?personName.
?person :bornIn / :within* / :name "United States".
?person :livesIn / :within* / :name "Europe".
}结构非常相似。以下两个表达式是等价的(在 SPARQL 中,变量以问号开头):
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (location) # Cypher
?person :bornIn / :within* ?location. # SPARQL由于 RDF 不区分属性和边,而是将谓词用于两者,因此可以使用相同的语法来匹配属性。在以下表达式中,变量 usa 绑定到任何具有 name 属性且其值为字符串 "United States" 的顶点:
(usa {name:'United States'}) # Cypher
?usa :name "United States". # SPARQLSPARQL 得到了 Amazon Neptune、AllegroGraph、Blazegraph、OpenLink Virtuoso、Apache Jena 和其他各种三元组存储的支持 [ 36]。
Datalog:递归关系查询
Datalog 是一种比 SPARQL 或 Cypher 更古老的语言:它起源于 1980 年代的学术研究[57, 58, 59]。它在软件工程师中不太知名,并且在主流数据库中支持不广泛,但它应该更为人所知,因为它是一种非常表达丰富的语言,特别适合复杂查询。一些小众数据库,包括 Datomic、LogicBlox、CozoDB 和 LinkedIn 的 LIquid41,使用 Datalog 作为其查询语言。
Datalog 实际上是基于关系数据模型,而不是图形,但它出现在本书的图形数据库部分,因为在图形上进行递归查询是 Datalog 的一个特别强项。
Datalog 数据库的内容由事实组成,每个事实对应于关系表中的一行。例如,假设我们有一个包含位置的表 location,它有三列:ID、名称和类型。美国是一个国家这一事实可以写作 location(2, "United States", "country") ,其中 2 是美国的 ID。一般来说,语句 table(val1, val2, …) 意味着 table 包含一行,其中第一列包含 val1 ,第二列包含 val2 ,依此类推。
示例 3-11 展示了如何在 Datalog 中写出图 3-6 左侧的数据。图的边( within 、 born_in 和 lives_in )被表示为两列的连接表。例如,露西的 ID 是 100,爱达荷的 ID 是 3,因此关系“露西出生在爱达荷”被表示为 born_in(100, 3) 。
示例 3-11。图 3-6 中数据的一个子集,以 Datalog 事实表示
location(1, "North America", "continent").
location(2, "United States", "country").
location(3, "Idaho", "state").
within(2, 1). /* US is in North America */
within(3, 2). /* Idaho is in the US */
person(100, "Lucy").
born_in(100, 3). /* Lucy was born in Idaho */现在我们已经定义了数据,可以像之前一样写出相同的查询,如示例 3-12 所示。它看起来与 Cypher 或 SPARQL 中的等效查询有些不同,但不要因此而感到困惑。Datalog 是 Prolog 的一个子集,如果你学习过计算机科学,可能见过这种编程语言。
示例 3-12。与示例 3-5 相同的查询,以 Datalog 表达
within_recursive(LocID, PlaceName) :- location(LocID, PlaceName, _). /* Rule 1 */
within_recursive(LocID, PlaceName) :- within(LocID, ViaID), /* Rule 2 */
within_recursive(ViaID, PlaceName).
migrated(PName, BornIn, LivingIn) :- person(PersonID, PName), /* Rule 3 */
born_in(PersonID, BornID),
within_recursive(BornID, BornIn),
lives_in(PersonID, LivingID),
within_recursive(LivingID, LivingIn).
us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* Rule 4 */
/* us_to_europe contains the row "Lucy". */Cypher 和 SPARQL 立即使用 SELECT ,而 Datalog 则是一步一步地进行。我们定义规则,从基础事实中推导出新的虚拟表。这些派生表就像(虚拟)SQL 视图:它们并不存储在数据库中,但你可以像查询包含存储事实的表一样查询它们。
在示例 3-12 中,我们定义了三个派生表: within_recursive 、 migrated 和 us_to_europe 。虚拟表的名称和列由每个规则的 :- 符号之前的内容定义。例如, migrated(PName, BornIn, LivingIn) 是一个具有三列的虚拟表:一个人的名字、他们出生地的名称以及他们居住地的名称。
虚拟表的内容由规则中 :- 符号后面的部分定义,我们试图在表中找到与某种模式匹配的行。例如, person(PersonID, PName) 匹配行 person(100, "Lucy") ,变量 PersonID 绑定到值 100 ,变量 PName 绑定到值 "Lucy" 。如果系统能够找到 :- 操作符右侧所有模式的匹配,则规则适用。当规则适用时,就好像 :- 的左侧被添加到数据库中(变量被替换为它们匹配的值)。
因此,应用规则的一种可能方式是(如图 3-7 所示):
location(1, "North America", "continent") 存在于数据库中,因此规则 1 适用。它生成 within_recursive(1, "North America") 。
within(2, 1) 存在于数据库中,前一步生成了 within_recursive(1, "North America") ,因此规则 2 适用。它生成了 within_recursive(2, "North America") 。
within(3, 2) 存在于数据库中,前一步生成了 within_recursive(2, "North America") ,因此规则 2 适用。它生成了 within_recursive(3, "North America") 。
通过反复应用规则 1 和规则 2, within_recursive 虚拟表可以告诉我们数据库中包含的北美(或任何其他位置)的所有位置。

现在规则 3 可以找到出生在某个位置 BornIn 并居住在某个位置 LivingIn 的人。规则 4 调用规则 3,使用 BornIn = 'United States' 和 LivingIn = 'Europe' ,并仅返回与搜索匹配的人的姓名。通过查询虚拟 us_to_europe 表的内容,Datalog 系统最终得到了与之前的 Cypher 和 SPARQL 查询相同的答案。
与本章讨论的其他查询语言相比,Datalog 方法需要不同的思维方式。它允许通过逐条规则构建复杂查询,一条规则可以引用其他规则,类似于将代码分解为相互调用的函数。就像函数可以是递归的,Datalog 规则也可以调用自身,就像示例 3-12 中的规则 2,这使得在 Datalog 查询中能够进行图遍历。
GraphQL
GraphQL 是一种查询语言,其设计上比我们在本章中看到的其他查询语言要严格得多。GraphQL 的目的是允许在用户设备上运行的客户端软件(例如移动应用或 JavaScript 网页应用前端)请求具有特定结构的 JSON 文档,包含渲染其用户界面所需的字段。GraphQL 接口允许开发人员在客户端代码中快速更改查询,而无需更改服务器端 API。
GraphQL 的灵活性是有代价的。采用 GraphQL 的组织通常需要工具将 GraphQL 查询转换为对内部服务的请求,而这些服务通常使用 REST 或 gRPC(见第 5 章)。授权、速率限制和性能挑战是额外的关注点42。由于 GraphQL 来自不受信任的来源,其查询语言也受到限制。该语言不允许执行任何可能代价高昂的操作,否则用户可能通过运行大量代价高昂的查询对服务器发起拒绝服务攻击。特别是,GraphQL 不允许递归查询(与 Cypher、SPARQL、SQL 或 Datalog 不同),也不允许任意搜索条件,例如“查找在美国出生并且现在居住在欧洲的人”(除非服务所有者特别选择提供此类搜索功能)。
然而,GraphQL 是有用的。示例 3-13 展示了如何使用 GraphQL 实现一个群聊应用程序,例如 Discord 或 Slack。该查询请求用户可以访问的所有频道,包括频道名称和每个频道中最近的 50 条消息。对于每条消息,它请求时间戳、消息内容以及发送者的姓名和头像 URL。此外,如果一条消息是对另一条消息的回复,查询还会请求发送者的姓名和被回复消息的内容(这可能会以较小的字体显示在回复上方,以提供一些上下文)。
示例 3-13。群聊应用程序的 GraphQL 查询示例
query ChatApp {
channels {
name
recentMessages(latest: 50) {
timestamp
content
sender {
fullName
imageUrl
}
replyTo {
content
sender {
fullName
}
}
}
}
}示例 3-14 显示了对示例 3-13 中查询的响应可能是什么样的。响应是一个 JSON 文档,反映了查询的结构:它包含了正好是请求的那些属性,没有更多也没有更少。这种方法的优点在于服务器不需要知道客户端需要哪些属性来渲染用户界面;相反,客户端可以简单地请求它所需的内容。例如,这个查询并没有请求 replyTo 消息发送者的头像 URL,但如果用户界面被更改以添加该头像,客户端可以轻松地将所需的 imageUrl 属性添加到查询中,而无需更改服务器。
示例 3-14。对示例 3-13 中查询的可能响应
{
"data": {
"channels": [
{
"name": "#general",
"recentMessages": [
{
"timestamp": 1693143014,
"content": "Hey! How are y'all doing?",
"sender": {"fullName": "Aaliyah", "imageUrl": "https://..."},
"replyTo": null
},
{
"timestamp": 1693143024,
"content": "Great! And you?",
"sender": {"fullName": "Caleb", "imageUrl": "https://..."},
"replyTo": {
"content": "Hey! How are y'all doing?",
"sender": {"fullName": "Aaliyah"}
}
},
...在示例 3-14 中,消息发送者的名称和图像 URL 直接嵌入在消息对象中。如果同一用户发送多条消息,这些信息会在每条消息中重复。原则上,可以减少这种重复,但 GraphQL 做出了设计选择,接受更大的响应大小,以便更简单地根据数据渲染用户界面。
replyTo 字段类似:在示例 3-14 中,第二条消息是对第一条的回复,内容(“嘿!... ”)和发送者 Aaliyah 在 replyTo 下被重复。可以选择返回被回复消息的 ID,但如果该 ID 不在返回的 50 条最近消息中,客户端就必须向服务器发出额外请求。重复内容使得处理数据变得简单得多。
服务器的数据库可以以更规范化的形式存储数据,并执行必要的连接以处理查询。例如,服务器可能会存储一条消息以及发送者的用户 ID 和它所回复的消息的 ID;当它接收到像上面那样的查询时,服务器会解析这些 ID 以找到它们所指的记录。然而,客户端只能要求服务器执行在 GraphQL 架构中明确提供的连接。
尽管对 GraphQL 查询的响应看起来类似于文档数据库的响应,尽管它的名称中有“图”,GraphQL 可以在任何类型的数据库之上实现——关系型、文档型或图形型。
事件溯源和 CQRS
在我们迄今讨论的所有数据模型中,数据的查询形式与其写入形式相同——无论是 JSON 文档、表中的行,还是图中的顶点和边。然而,在复杂应用中,有时很难找到一种单一的数据表示形式,能够满足数据需要被查询和呈现的所有不同方式。在这种情况下,将数据以一种形式写入,然后从中派生出几种针对不同类型读取进行优化的表示形式,可能会带来好处。
我们之前在“记录系统和派生数据”中看到了这个想法,ETL(见“数据仓库”)就是这种派生过程的一个例子。现在我们将进一步探讨这个想法。如果我们无论如何都要从一种数据表示派生出另一种,我们可以选择不同的表示形式,分别针对写入和读取进行优化。如果你只想优化写入,而高效查询并不重要,你会如何建模你的数据?
也许写入数据最简单、最快速且最具表现力的方式就是事件日志:每当你想写入一些数据时,你将其编码为一个自包含的字符串(可能是 JSON 格式),包括时间戳,然后将其附加到事件序列中。这个日志中的事件是不可变的:你永远不会更改或删除它们,你只会将更多事件附加到日志中(这些事件可能会取代早期的事件)。一个事件可以包含任意属性。
图 3-8 展示了一个可以来自会议管理系统的示例。会议可以是一个复杂的业务领域:不仅个人与会者可以注册并通过信用卡付款,公司也可以批量订购座位,通过发票付款,然后再将座位分配给个人。某些座位可能会保留给演讲者、赞助商、志愿者等。预订也可能被取消,同时,会议组织者可能会通过将活动移至不同的房间来改变活动的容量。在这一切发生的情况下,简单地计算可用座位的数量就变成了一个具有挑战性的查询。

在图 3-8 中,会议状态的每一次变化(例如组织者开启注册,或与会者进行和取消注册)首先被存储为一个事件。每当一个事件被追加到日志中时,几个物化视图(也称为投影或读取模型)也会被更新,以反映该事件的影响。在会议的例子中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个计算会议组织者仪表板的图表,还有一个生成用于打印与会者徽章的文件。
将事件作为真相来源,并将每个状态变化表示为事件的想法被称为事件溯源4344。维护单独的读优化表示并从写优化表示中推导它们的原则称为命令查询责任分离 (CQRS) 45。这些术语起源于领域驱动设计 (DDD) 社区,尽管类似的想法早已存在,例如在状态机复制中(见 [Link to Come])。
当用户的请求到达时,它被称为命令,首先需要进行验证。只有在命令被执行并确定其有效(例如,请求的预订有足够的可用座位)后,它才成为事实,相应的事件被添加到日志中。因此,事件日志应仅包含有效事件,构建物化视图的事件日志消费者不允许拒绝事件。
在以事件溯源风格建模数据时,建议将事件命名为过去式(例如,“座位已被预订”),因为事件是某事在过去发生的事实的记录。即使用户后来决定更改或取消,事实仍然是真实的,即他们曾经持有一个预订,而更改或取消是一个单独的事件,稍后添加。
事件溯源与星型模式事实表之间的相似之处,如《星与雪花:分析的模式》中所讨论的,是两者都是过去发生事件的集合。然而,事实表中的行都有相同的一组列,而在事件溯源中可能有许多不同的事件类型,每种类型都有不同的属性。此外,事实表是一个无序集合,而在事件溯源中,事件的顺序是重要的:如果一个预订首先被创建然后被取消,以错误的顺序处理这些事件是没有意义的。
事件溯源和 CQRS 有几个优点:
-
对于开发系统的人来说,事件更好地传达了某件事情发生的意图。例如,“预订已被取消”这个事件比“ bookings 表中第 4001 行的 active 列被设置为 false ,与该预订相关的三行从 seat_assignments 表中删除,并且一行表示退款的记录被插入到 payments 表中”更容易理解。当物化视图处理取消事件时,这些行修改仍然可能发生,但当它们由事件驱动时,更新的原因变得更加清晰。
-
事件溯源的一个关键原则是,物化视图是以可重现的方式从事件日志中派生的:你应该始终能够删除物化视图,并通过以相同的顺序处理相同的事件,使用相同的代码重新计算它们。如果视图维护代码中存在错误,你只需删除视图并使用新代码重新计算它。找到错误也更容易,因为你可以随时重新运行视图维护代码并检查其行为。
-
你可以拥有多个针对应用程序所需特定查询优化的物化视图。它们可以存储在与事件相同的数据库中,也可以存储在不同的数据库中,具体取决于你的需求。它们可以使用任何数据模型,并且可以进行非规范化以实现快速读取。你甚至可以仅在内存中保留一个视图,避免持久化,只要在服务重启时可以从事件日志重新计算该视图即可。
-
如果您决定以新的方式呈现现有信息,可以很容易地从现有事件日志构建一个新的物化视图。您还可以通过添加新类型的事件或为现有事件类型添加新属性来发展系统以支持新功能(任何旧事件保持不变)。您还可以在现有事件的基础上链式添加新行为(例如,当会议参与者取消时,他们的座位可以提供给等待名单上的下一个人)。
-
如果事件是错误写入的,您可以将其删除,然后可以在没有已删除事件的情况下重建视图。另一方面,在直接更新和删除数据的数据库中,已提交的事务通常很难逆转。因此,事件溯源可以减少系统中不可逆操作的数量,从而使更改变得更容易(参见“可演变性:使更改变得简单”)。
-
事件日志还可以作为系统中发生的所有事件的审计日志,这在需要此类审计能力的受监管行业中非常有价值。
然而,事件溯源和 CQRS 也有其缺点:
-
如果涉及外部信息,您需要小心。例如,假设一个事件包含以一种货币给出的价格,而在某个视图中需要将其转换为另一种货币。由于汇率可能会波动,在处理事件时从外部来源获取汇率会出现问题,因为如果在另一个日期重新计算物化视图,您会得到不同的结果。为了使事件处理逻辑具有确定性,您需要在事件本身中包含汇率,或者有一种方法可以查询事件中指示的时间戳的历史汇率,确保该查询在相同时间戳下始终返回相同的结果。
-
事件不可变的要求会造成问题,特别是当事件包含用户的个人数据时,因为用户可能会行使他们的权利(例如,根据 GDPR)请求删除他们的数据。如果事件日志是按用户划分的,你可以直接删除该用户的整个日志,但如果你的事件日志包含与多个用户相关的事件,这种方法就行不通了。你可以尝试将个人数据存储在实际事件之外,或者用一个你可以选择稍后删除的密钥进行加密,但这也会使得在需要时重新计算派生状态变得更加困难。
-
如果存在外部可见的副作用,重新处理事件需要谨慎——例如,你可能不希望每次重建物化视图时都重新发送确认电子邮件。
你可以在任何数据库上实现事件源,但也有一些系统专门设计来支持这种模式,例如 EventStoreDB、MartenDB(基于 PostgreSQL)和 Axon Framework。你还可以使用消息中间件,如 Apache Kafka,来存储事件日志,流处理器可以保持物化视图的最新状态;我们将在[链接待续]中回到这些主题。
唯一重要的要求是事件存储系统必须保证所有物化视图以与日志中出现的顺序完全相同的顺序处理事件;正如我们将在[链接即将到来]中看到的,这在分布式系统中并不总是容易实现。
数据框、矩阵和数组
我们在本章中看到的数据模型通常用于事务处理和分析目的(参见“分析系统与操作系统”)。还有一些数据模型,您可能会在分析或科学环境中遇到,但在 OLTP 系统中很少出现:数据框和多维数字数组,如矩阵。
数据框是 R 语言、Python 的 Pandas 库、Apache Spark、ArcticDB、Dask 和其他系统支持的一种数据模型。它们是数据科学家为训练机器学习模型准备数据的热门工具,但也广泛用于数据探索、统计数据分析、数据可视化和类似目的。
乍一看,数据框类似于关系数据库中的表或电子表格。它支持类似关系的操作符,可以对数据框的内容执行批量操作:例如,对所有行应用一个函数,根据某些条件过滤行,按某些列对行进行分组并聚合其他列,以及根据某个键将一个数据框中的行与另一个数据框中的行连接(关系数据库中称为连接的操作在数据框中通常称为合并)。
与 SQL 等声明性查询不同,数据框通常通过一系列命令来操作,这些命令修改其结构和内容。这与数据科学家的典型工作流程相匹配,他们逐步“整理”数据,使其形成能够回答他们所提问题的形式。这些操作通常在数据科学家私有的数据集副本上进行,通常是在他们的本地机器上,尽管最终结果可能会与其他用户共享。
数据框 API 还提供了多种操作,远远超出了关系数据库的功能,并且数据模型的使用方式通常与典型的关系数据建模大相径庭 46。例如,数据框的一个常见用途是将数据从类似关系的表示形式转换为矩阵或多维数组表示形式,这是许多机器学习算法所期望的输入形式。
这样的转换的一个简单示例如图 3-9 所示。左侧是一个关系表,显示了不同用户对各种电影的评分(评分范围为 1 到 5),右侧的数据已被转换为一个矩阵,其中每一列代表一部电影,每一行代表一个用户(类似于电子表格中的数据透视表)。该矩阵是稀疏的,这意味着许多用户-电影组合没有数据,但这没关系。这个矩阵可能有成千上万的列,因此不适合放入关系数据库,但数据框和提供稀疏数组的库(如 Python 的 NumPy)可以轻松处理这样的数据。

矩阵只能包含数字,使用各种技术将非数字数据转换为矩阵中的数字。例如:
-
日期(在图 3-9 的示例矩阵中被省略)可以缩放为某个合适范围内的浮点数。
-
对于只能取小的固定值集合中的一个值的列(例如,电影数据库中电影的类型),通常使用独热编码:我们为每个可能的值创建一列(一个用于“喜剧”,一个用于“剧情”,一个用于“恐怖”等),对于每一行表示一部电影,我们在对应于该电影类型的列中放置 1,在所有其他列中放置 0。这种表示法也很容易推广到适合多种类型的电影。
一旦数据以数字矩阵的形式存在,就可以进行线性代数运算,这些运算构成了许多机器学习算法的基础。例如,图 3-9 中的数据可能是一个推荐用户可能喜欢的电影的系统的一部分。数据框架足够灵活,允许数据逐渐从关系形式演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示方式。
还有一些数据库,如 TileDB 47,专门用于存储大型多维数字数组;它们被称为数组数据库,最常用于科学数据集,如地理空间测量(规则间隔网格上的栅格数据)、医学成像或天文望远镜的观测数据 48。数据框架在金融行业中也用于表示时间序列数据,例如资产价格和随时间变化的交易 49。
总结
数据模型是一个庞大的主题,在本章中我们快速浏览了各种不同的模型。我们没有足够的空间深入探讨每个模型的所有细节,但希望这个概述足以激发你对最符合你应用需求的模型的进一步了解。
关系模型尽管已有超过半个世纪的历史,但仍然是许多应用的重要数据模型——特别是在数据仓库和商业分析中,关系星型或雪花型模式以及 SQL 查询无处不在。然而,几种替代关系数据的模型在其他领域也变得流行:
-
文档模型针对的是数据以自包含的 JSON 文档形式出现的用例,并且文档之间的关系很少。
-
图数据模型则朝相反的方向发展,针对的是任何事物都有可能与一切相关的用例,并且查询可能需要跨越多个跳跃才能找到感兴趣的数据(这可以通过在 Cypher、SPARQL 或 Datalog 中使用递归查询来表达)。
-
数据框架将关系数据推广到大量列,从而在数据库与构成许多机器学习、统计数据分析和科学计算基础的多维数组之间提供了一座桥梁。
在某种程度上,一个模型可以通过另一个模型进行模拟——例如,图数据可以在关系数据库中表示——但结果可能会很尴尬,正如我们在 SQL 中看到的对递归查询的支持。
因此,为每种数据模型开发了各种专业数据库,提供针对特定模型优化的查询语言和存储引擎。然而,数据库也有向邻近领域扩展的趋势,通过添加对其他数据模型的支持:例如,关系数据库增加了对以 JSON 列形式存储的文档数据的支持,文档数据库增加了类似关系的连接,而 SQL 对图数据的支持也在逐渐改善。
我们讨论的另一个模型是事件溯源,它将数据表示为不可变事件的追加日志,这在复杂业务领域的活动建模中可能具有优势。追加日志适合写入数据(正如我们将在第 4 章中看到的);为了支持高效查询,事件日志通过 CQRS 转换为读优化的物化视图。
非关系数据模型有一个共同点,即它们通常不强制执行存储数据的模式,这使得应用程序更容易适应变化的需求。然而,您的应用程序很可能仍然假设数据具有某种结构;这只是一个问题,即模式是显式的(在写入时强制执行)还是隐式的(在读取时假定)。
尽管我们已经覆盖了很多内容,但仍然有一些数据模型未被提及。举几个简短的例子:
-
研究人员在处理基因组数据时,常常需要进行序列相似性搜索,这意味着将一个非常长的字符串(代表一个 DNA 分子)与一个包含相似但不完全相同字符串的大型数据库进行匹配。这里描述的数据库都无法处理这种用法,这就是为什么研究人员编写了像 GenBank 50这样的专用基因组数据库软件。
-
许多金融系统使用双重记账的分类账作为其数据模型。这种类型的数据可以在关系数据库中表示,但也有像 TigerBeetle 这样的数据库专门针对这种数据模型。加密货币和区块链通常基于分布式分类账,这种分类账的数据模型中也内置了价值转移。
-
全文搜索可以说是一种常与数据库一起使用的数据模型。信息检索是一个庞大的专业主题,我们在本书中不会详细讨论,但我们将在“全文搜索”中提及搜索索引和向量搜索。
我们暂时就到这里。在下一章中,我们将讨论在实现本章所描述的数据模型时所涉及的一些权衡。
Footnotes
-
Jamie Brandon. Unexplanations: query optimization works because sql is declarative. scattered-thoughts.net, February 2024. Archived at perma.cc/P6W2-WMFZ ↩
-
Joseph M. Hellerstein. The Declarative Imperative: Experiences and Conjectures in Distributed Logic. Tech report UCB/EECS-2010-90, Electrical Engineering and Computer Sciences, University of California at Berkeley, June 2010. Archived at perma.cc/K56R-VVQM ↩
-
Edgar F. Codd. A Relational Model of Data for Large Shared Data Banks. Communications of the ACM, volume 13, issue 6, pages 377–387, June 1970. doi:10.1145/362384.362685 ↩ ↩2
-
Martin Fowler. OrmHate. martinfowler.com, May 2012. Archived at perma.cc/VCM8-PKNG ↩
-
Vlad Mihalcea. N+1 query problem with JPA and Hibernate. vladmihalcea.com, January 2023. Archived at perma.cc/79EV-TZKB ↩
-
Jens Schauder. This is the Beginning of the End of the N+1 Problem: Introducing Single Query Loading. spring.io, August 2023. Archived at perma.cc/6V96-R333 ↩
-
William Zola. 6 Rules of Thumb for MongoDB Schema Design. mongodb.com, June 2014. Archived at perma.cc/T2BZ-PPJB ↩ ↩2
-
Sidney Andrews and Christopher McClister. Data modeling in Azure Cosmos DB. learn.microsoft.com, February 2023. Archived at archive.org ↩
-
Raffi Krikorian. Timelines at Scale. At QCon San Francisco, November 2012. Archived at perma.cc/V9G5-KLYK ↩ ↩2
-
Ralph Kimball and Margy Ross. The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling, 3rd edition. John Wiley & Sons, July 2013. ISBN: 9781118530801 ↩ ↩2
-
Michael Kaminsky. Data warehouse modeling: Star schema vs. OBT. fivetran.com, August 2022. Archived at perma.cc/2PZK-BFFP ↩
-
Martin Fowler. Schemaless Data Structures. martinfowler.com, January 2013. ↩
-
Amr Awadallah. Schema-on-Read vs. Schema-on-Write. At Berkeley EECS RAD Lab Retreat, Santa Cruz, CA, May 2009. Archived at perma.cc/DTB2-JCFR ↩
-
Martin Odersky. The Trouble with Types. At Strange Loop, September 2013. Archived at perma.cc/85QE-PVEP ↩
-
Conrad Irwin. MongoDB—Confessions of a PostgreSQL Lover. At HTML5DevConf, October 2013. Archived at perma.cc/C2J6-3AL5 ↩
-
James C. Corbett, Jeffrey Dean, Michael Epstein, Andrew Fikes, Christopher Frost, JJ Furman, Sanjay Ghemawat, Andrey Gubarev, Christopher Heiser, Peter Hochschild, Wilson Hsieh, Sebastian Kanthak, Eugene Kogan, Hongyi Li, Alexander Lloyd, Sergey Melnik, David Mwaura, David Nagle, Sean Quinlan, Rajesh Rao, Lindsay Rolig, Dale Woodford, Yasushi Saito, Christopher Taylor, Michal Szymaniak, and Ruth Wang. Spanner: Google’s Globally-Distributed Database. At 10th USENIX Symposium on Operating System Design and Implementation (OSDI), October 2012. ↩
-
Donald K. Burleson. Reduce I/O with Oracle Cluster Tables. dba-oracle.com. Archived at perma.cc/7LBJ-9X2C ↩
-
Fay Chang, Jeffrey Dean, Sanjay Ghemawat, Wilson C. Hsieh, Deborah A. Wallach, Mike Burrows, Tushar Chandra, Andrew Fikes, and Robert E. Gruber. Bigtable: A Distributed Storage System for Structured Data. At 7th USENIX Symposium on Operating System Design and Implementation (OSDI), November 2006. ↩
-
Priscilla Walmsley. XQuery, 2nd Edition. O’Reilly Media, December 2015. ISBN: 9781491915080 ↩
-
Paul C. Bryan, Kris Zyp, and Mark Nottingham. JavaScript Object Notation (JSON) Pointer. RFC 6901, IETF, April 2013. ↩
-
Stefan Gössner, Glyn Normington, and Carsten Bormann. JSONPath: Query Expressions for JSON. RFC 9535, IETF, February 2024. ↩
-
Michael Stonebraker and Andrew Pavlo. What Goes Around Comes Around… And Around…. ACM SIGMOD Record, volume 53, issue 2, pages 21–37. doi:10.1145/3685980.3685984 ↩
-
Lawrence Page, Sergey Brin, Rajeev Motwani, and Terry Winograd. The PageRank Citation Ranking: Bringing Order to the Web. Technical Report 1999-66, Stanford University InfoLab, November 1999. Archived at perma.cc/UML9-UZHW ↩
-
Nathan Bronson, Zach Amsden, George Cabrera, Prasad Chakka, Peter Dimov, Hui Ding, Jack Ferris, Anthony Giardullo, Sachin Kulkarni, Harry Li, Mark Marchukov, Dmitri Petrov, Lovro Puzar, Yee Jiun Song, and Venkat Venkataramani. TAO: Facebook’s Distributed Data Store for the Social Graph. At USENIX Annual Technical Conference (ATC), June 2013. ↩
-
Natasha Noy, Yuqing Gao, Anshu Jain, Anant Narayanan, Alan Patterson, and Jamie Taylor. Industry-Scale Knowledge Graphs: Lessons and Challenges. Communications of the ACM, volume 62, issue 8, pages 36–43, August 2019. doi:10.1145/3331166 ↩
-
Xiyang Feng, Guodong Jin, Ziyi Chen, Chang Liu, and Semih Salihoğlu. KÙZU Graph Database Management System. At 3th Annual Conference on Innovative Data Systems Research (CIDR 2023), January 2023. ↩ ↩2
-
Apache TinkerPop 3.6.3 Documentation. tinkerpop.apache.org, May 2023. Archived at perma.cc/KM7W-7PAT ↩
-
Nadime Francis, Alastair Green, Paolo Guagliardo, Leonid Libkin, Tobias Lindaaker, Victor Marsault, Stefan Plantikow, Mats Rydberg, Petra Selmer, and Andrés Taylor. Cypher: An Evolving Query Language for Property Graphs. At International Conference on Management of Data (SIGMOD), pages 1433–1445, May 2018. doi:10.1145/3183713.3190657 ↩
-
Emil Eifrem. Twitter correspondence, January 2014. Archived at perma.cc/WM4S-BW64 ↩
-
Francesco Tisiot. Explore the new SEARCH and CYCLE features in PostgreSQL® 14. aiven.io, December 2021. Archived at perma.cc/J6BT-83UZ ↩
-
Gaurav Goel. Understanding Hierarchies in Oracle. towardsdatascience.com, May 2020. Archived at perma.cc/5ZLR-Q7EW ↩
-
Alin Deutsch, Nadime Francis, Alastair Green, Keith Hare, Bei Li, Leonid Libkin, Tobias Lindaaker, Victor Marsault, Wim Martens, Jan Michels, Filip Murlak, Stefan Plantikow, Petra Selmer, Oskar van Rest, Hannes Voigt, Domagoj Vrgoč, Mingxi Wu, and Fred Zemke. Graph Pattern Matching in GQL and SQL/PGQ. At International Conference on Management of Data (SIGMOD), pages 2246–2258, June 2022. doi:10.1145/3514221.3526057 ↩
-
Alastair Green. SQL... and now GQL. opencypher.org, September 2019. Archived at perma.cc/AFB2-3SY7 ↩
-
Alin Deutsch, Yu Xu, and Mingxi Wu. Seamless Syntactic and Semantic Integration of Query Primitives over Relational and Graph Data in GSQL. tigergraph.com, November 2018. Archived at perma.cc/JG7J-Y35X ↩
-
Oskar van Rest, Sungpack Hong, Jinha Kim, Xuming Meng, and Hassan Chafi. PGQL: a property graph query language. At 4th International Workshop on Graph Data Management Experiences and Systems (GRADES), June 2016. doi:10.1145/2960414.2960421 ↩
-
Amazon Web Services. Neptune Graph Data Model. Amazon Neptune User Guide, docs.aws.amazon.com. Archived at perma.cc/CX3T-EZU9 ↩
-
Cognitect. Datomic Data Model. Datomic Cloud Documentation, docs.datomic.com. Archived at perma.cc/LGM9-LEUT ↩
-
David Beckett and Tim Berners-Lee. Turtle – Terse RDF Triple Language. W3C Team Submission, March 2011. ↩
-
W3C RDF Working Group. Resource Description Framework (RDF). w3.org, February 2004. ↩
-
Steve Harris, Andy Seaborne, and Eric Prud’hommeaux. SPARQL 1.1 Query Language. W3C Recommendation, March 2013. ↩
-
Scott Meyer, Andrew Carter, and Andrew Rodriguez. LIquid: The soul of a new graph database, Part 2. engineering.linkedin.com, September 2020. Archived at perma.cc/K9M4-PD6Q ↩
-
Matt Bessey. Why, after 6 years, I’m over GraphQL. bessey.dev, May 2024. Archived at perma.cc/2PAU-JYRA ↩
-
Dominic Betts, Julián Domínguez, Grigori Melnik, Fernando Simonazzi, and Mani Subramanian. Exploring CQRS and Event Sourcing. Microsoft Patterns & Practices, July 2012. ISBN: 1621140164, archived at perma.cc/7A39-3NM8 ↩
-
Greg Young. CQRS and Event Sourcing. At Code on the Beach, August 2014. ↩
-
Greg Young. CQRS Documents. cqrs.wordpress.com, November 2010. Archived at perma.cc/X5R6-R47F ↩
-
Devin Petersohn, Stephen Macke, Doris Xin, William Ma, Doris Lee, Xiangxi Mo, Joseph E. Gonzalez, Joseph M. Hellerstein, Anthony D. Joseph, and Aditya Parameswaran. Towards Scalable Dataframe Systems. Proceedings of the VLDB Endowment, volume 13, issue 11, pages 2033–2046. doi:10.14778/3407790.3407807 ↩
-
Stavros Papadopoulos, Kushal Datta, Samuel Madden, and Timothy Mattson. The TileDB Array Data Storage Manager. Proceedings of the VLDB Endowment, volume 10, issue 4, pages 349–360, November 2016. doi:10.14778/3025111.3025117 ↩
-
Florin Rusu. Multidimensional Array Data Management. Foundations and Trends in Databases, volume 12, numbers 2–3, pages 69–220, February 2023. doi:10.1561/1900000069 ↩
-
Ed Targett. Bloomberg, Man Group team up to develop open source “ArcticDB” database. thestack.technology, March 2023. Archived at perma.cc/M5YD-QQYV ↩
-
Dennis A. Benson, Ilene Karsch-Mizrachi, David J. Lipman, James Ostell, and David L. Wheeler. GenBank. Nucleic Acids Research, volume 36, database issue, pages D25–D30, December 2007. doi:10.1093/nar/gkm929 ↩