第三章 数据模型与查询语言
我的语言的界限,意味着我的世界的界限。
——路德维希·维特根斯坦,《逻辑哲学论》(1922)
早期版本读者须知
通过早期版本电子书,您可以在作者写作过程中获取原始未编辑的内容,从而在正式出版前提前利用这些技术。
这将是本书最终版本的第三章。本书的 GitHub 仓库地址为:https://github.com/ept/ddia2-feedback
如果您希望积极参与本草案的审阅和评论,请在 GitHub 上与我们联系。
数据模型可能是软件开发中最重要的部分,因为它们产生如此深远的影响:不仅体现在软件编写方式上,还体现在我们如何思考所解决的问题上。
大多数应用程序都是通过将一层数据模型叠加在另一层之上构建的。对于每一层,关键问题是:它在下一层中是如何表示的?例如:
-
应用开发者观察现实世界(其中有人、组织、商品、行为、资金流、传感器等),并将其建模为对象或数据结构,以及操作这些数据结构的 API。这些结构通常特定于您的应用程序。
-
当您想要存储这些数据结构时,您使用通用数据模型来表达它们,例如 JSON 或 XML 文档、关系数据库中的表,或图中的顶点和边。这些数据模型是本章的主题。
-
构建数据库软件的工程师决定如何将文档/关系/图数据表示为内存、磁盘或网络中的字节。这种表示可能允许以各种方式查询、搜索、操作和处理数据。我们将在第四章讨论这些存储引擎设计。
-
在更低的层次上,硬件工程师已经弄清楚了如何用电流、光脉冲、磁场等来表示字节。
在复杂的应用程序中,可能有更多的中间层,例如基于 API 构建的 API,但基本思想仍然相同:每一层都通过提供清晰的数据模型来隐藏其下层的复杂性。这些抽象允许不同的人群——例如数据库供应商的工程师和使用其数据库的应用开发者——有效地协同工作。
几种不同的数据模型在实践中被广泛使用,通常用于不同的目的。某些类型的数据和一些查询在一种模型中很容易表达,而在另一种模型中却很别扭。在本章中,我们将通过比较关系模型、文档模型、基于图的数据模型、事件溯源和 DataFrame 来探讨这些权衡。我们还将简要介绍允许您使用这些模型的查询语言。这种比较将帮助您决定何时使用哪种模型。
术语:声明式查询语言
本章中的许多查询语言(如 SQL、Cypher、SPARQL 或 Datalog)都是声明式的,这意味着您指定想要的数据模式——结果必须满足什么条件,以及您希望数据如何转换(例如排序、分组和聚合)——而不是如何实现该目标。数据库系统的查询优化器可以决定使用哪些索引和连接算法,以及以什么顺序执行查询的各个部分。
相比之下,使用大多数编程语言时,您必须编写算法——即告诉计算机按什么顺序执行哪些操作。声明式查询语言具有吸引力,因为它通常比显式算法更简洁、更容易编写。但更重要的是,它还隐藏了查询引擎的实现细节,这使得数据库系统可以在不需要对查询进行任何更改的情况下引入性能改进。
例如,数据库可能能够在多个 CPU 核心和机器上并行执行声明式查询,而您无需担心如何实现这种并行性。在手写算法中,自己实现这种并行执行将是一项繁重的工作。
关系模型与文档模型
当今最著名的数据模型可能是基于埃德加·科德 1970 年提出的关系模型的 SQL:数据被组织成关系(在 SQL 中称为表),其中每个关系是无序的元组集合(在 SQL 中称为行)。
关系模型最初是一个理论提议,当时许多人怀疑它是否能被有效实现。然而,到 20 世纪 80 年代中期,关系数据库管理系统(RDBMS)和 SQL 已成为需要存储和查询具有一定规则结构数据的人的首选工具。几十年后,许多数据管理用例仍然由关系数据主导——例如商业分析(参见”星型与雪花型:分析模式”)。
多年来,有许多竞争性的数据存储和查询方法。在 20 世纪 70 年代和 80 年代初,网络模型和层次模型是主要的替代方案,但关系模型战胜了它们。对象数据库在 20 世纪 80 年代末和 90 年代初出现又消失。XML 数据库在 21 世纪初出现,但只获得了小众采用。关系模型的每个竞争对手在其时代都产生了大量炒作,但都没有持续。相反,SQL 已经发展到包含其关系核心之外的其他数据类型——例如,添加了对 XML、JSON 和图数据的支持。
在 2010 年代,NoSQL 是试图推翻关系数据库主导地位的最新流行词。NoSQL 不是指单一技术,而是围绕新数据模型、模式灵活性、可扩展性以及向开源许可模式转变的一套松散思想。一些数据库将自己品牌化为 NewSQL,因为它们旨在提供 NoSQL 系统的可扩展性以及传统关系数据库的数据模型和事务保证。NoSQL 和 NewSQL 的思想对数据系统设计产生了很大影响,但随着这些原则被广泛采用,这些术语的使用已经消退。
NoSQL 运动的一个持久影响是文档模型的流行,它通常将数据表示为 JSON。这个模型最初由 MongoDB 和 Couchbase 等专业文档数据库推广,尽管现在大多数关系数据库也添加了 JSON 支持。与通常被认为具有严格和不灵活模式的关系表相比,JSON 文档被认为更灵活。
文档和关系数据的优缺点已被广泛争论;让我们审视这场辩论的一些关键点。
对象-关系不匹配
当今许多应用开发都是使用面向对象编程语言完成的,这导致了对 SQL 数据模型的一个常见批评:如果数据存储在关系表中,就需要在应用代码中的对象与数据库的表、行和列模型之间建立一个别扭的转换层。模型之间的这种脱节有时被称为阻抗不匹配。
注:阻抗不匹配一词借自电子学。每个电路在其输入和输出上都有一定的阻抗(对交流电的电阻)。当您将一个电路的输出连接到另一个电路的输入时,如果两个电路的输出和输入阻抗匹配,则跨连接的能量传输最大化。阻抗不匹配可能导致信号反射和其他问题。
对象关系映射(ORM)
像 ActiveRecord 和 Hibernate 这样的对象关系映射(ORM)框架减少了这种转换层所需的样板代码,但它们经常受到批评。一些常被提及的问题是:
-
ORM 很复杂,不能完全隐藏两种模型之间的差异,因此开发者最终仍然需要同时考虑数据的关系表示和对象表示。
-
ORM 通常仅用于 OLTP 应用开发;为使数据可用于分析目的的数据工程师仍需要处理底层关系表示,因此在使用 ORM 时,关系模式的设计仍然很重要。
-
许多 ORM 仅适用于关系 OLTP 数据库。拥有搜索引擎、图数据库和 NoSQL 系统等多样化数据系统的组织可能会发现 ORM 支持不足。
-
一些 ORM 自动生成关系模式,但这些模式对于直接访问关系数据的用户来说可能很别扭,而且在底层数据库上可能效率低下。自定义 ORM 的模式和查询生成可能很复杂,并抵消了使用 ORM 的好处。
-
ORM 使得意外编写低效查询变得容易,例如 N+1 查询问题。例如,假设您想在页面上显示用户评论列表,因此您执行一个返回 N 条评论的查询,每条评论包含其作者 ID。要显示评论作者的名字,您需要在用户表中查找 ID。在手写 SQL 中,您可能会在查询中执行此连接并随每条评论返回作者名字,但使用 ORM 时,您可能会最终为 N 条评论中的每一条都在用户表上执行单独的查询以查找其作者,总共产生 N+1 次数据库查询,这比在数据库中执行连接要慢。为避免此问题,您可能需要告诉 ORM 在获取评论的同时获取作者信息。
然而,ORM 也有优势:
-
对于非常适合关系模型的数据,持久关系表示和内存对象表示之间的某种转换是不可避免的,ORM 减少了这种转换所需的样板代码。复杂查询可能仍需要在 ORM 之外处理,但 ORM 可以帮助处理简单和重复的情况。
-
一些 ORM 有助于缓存数据库查询结果,这有助于减少数据库负载。
-
ORM 还可以帮助管理模式迁移和其他管理活动。
用于一对多关系的文档数据模型
并非所有数据都适合关系表示;让我们看一个例子来探讨关系模型的局限性。图 3-1 说明了如何使用关系模式表示简历(LinkedIn 个人资料)。整个个人资料可以通过唯一标识符 user_id 识别。像 first_name 和 last_name 这样的字段每个用户只出现一次,因此它们可以被建模为用户表上的列。
大多数人在职业生涯中有过多份工作(职位),人们可能有不同数量的教育时期和任意数量的联系信息。表示这种一对多关系的一种方法是将职位、教育和联系信息放在单独的表中,通过外键引用用户表,如图 3-1 所示。
图 3-1 使用关系模式表示 LinkedIn 个人资料
表示相同信息的另一种方式,可能更自然且更接近应用代码中的对象结构,是如图 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 模型减少了应用代码与存储层之间的阻抗不匹配。然而,正如我们将在第五章看到的,JSON 作为数据编码格式也存在问题。缺乏模式通常被引用为优势;我们将在”文档模型中的模式灵活性”中讨论这一点。
JSON 表示比图 3-1 中的多表模式具有更好的数据局部性。如果您想获取关系示例中的个人资料,您需要执行多个查询(按 user_id 查询每个表)或执行用户表与其从属表之间的混乱多路连接。在 JSON 表示中,所有相关信息都在一个地方,使查询既更快又更简单。
从用户个人资料到用户职位、教育历史和联系信息的一对多关系意味着数据中的树结构,JSON 表示使这种树结构明确(参见图 3-2)。
图 3-2 形成树结构的一对多关系
注:这种关系有时被称为一对少而非一对多,因为简历通常只有少量职位。在可能有真正大量相关项目的情况下——例如,名人社交媒体帖子上的评论,可能有数千条——将它们全部嵌入同一文档可能过于笨重,因此图 3-1 中的关系方法更合适。
规范化、反规范化和连接
在前面部分的示例 3-1 中,region_id 以 ID 形式给出,而不是纯文本字符串”Washington, DC, United States”。为什么?
如果用户界面有一个用于输入区域的自由文本字段,将其存储为纯文本字符串是有意义的。但是拥有标准化地理区域列表并让用户从下拉列表或自动完成器中选择有优势:
- 跨个人资料的一致风格和拼写
- 如果有多个同名地方,避免歧义(如果字符串只是”Washington”,它是指 DC 还是州?)
- 易于更新——名字只存储在一个地方,因此如果需要更改(例如由于政治事件更改城市名称),很容易全面更新
- 本地化支持——当网站翻译成其他语言时,可以本地化标准化列表,以便以查看者的语言显示区域
- 更好的搜索——例如,搜索美国东海岸的人可以匹配此个人资料,因为区域列表可以编码华盛顿位于东海岸的事实(从字符串”Washington, DC”看不出来)
无论您存储 ID 还是文本字符串,都是规范化的问题。当您使用 ID 时,您的数据更加规范化:对人类有意义的信息(如文本 Washington, DC)只存储在一个地方,所有引用它的地方都使用 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 字段是对标准化区域集的引用,但组织名称(个人工作的公司或政府)和 school_name(他们学习的地方)只是字符串。这种表示是反规范化的:许多人可能在同一家公司工作,但没有 ID 链接他们。
也许组织和学校应该是实体,个人资料应该引用它们的 ID 而不是名称?引用区域 ID 的相同论点也适用于此。例如,假设我们想要在名称之外包含学校或公司的标志:
-
在反规范化表示中,我们会在每个人的个人资料上包含标志的图像 URL;这使 JSON 文档自包含,但如果我们需要更改标志,就会产生麻烦,因为现在需要找到旧 URL 的所有出现并更新它们。
-
在规范化表示中,我们会创建一个表示组织或学校的实体,并在该实体上存储其名称、标志 URL,以及可能的其他属性(描述、新闻源等)。提及该组织的每个简历只需引用其 ID,更新标志就很容易。
作为一般原则,规范化数据通常写入更快(因为只有一份副本),但查询更慢(因为需要连接);反规范化数据通常读取更快(更少的连接),但写入更昂贵(需要更新更多副本,使用更多磁盘空间)。您可能会发现将反规范化视为派生数据的一种形式会有所帮助,因为您需要设置一个流程来更新数据的冗余副本。
除了执行所有这些更新的成本之外,您还需要考虑如果进程在更新过程中崩溃,数据库的一致性。提供原子事务的数据库(参见”原子性”)使保持一致更容易,但并非所有数据库都提供跨多个文档的原子性。也可以通过流处理确保一致性,我们将在第十二章讨论。
规范化往往更适合 OLTP 系统,其中读取和更新都需要快速;分析系统通常更适合反规范化数据,因为它们批量执行更新,只读查询的性能是主要关注点。此外,在中小规模系统中,规范化数据模型通常是最好的,因为您不必担心保持多份数据彼此一致,而且执行连接的成本是可以接受的。然而,在非常大规模的系统中,连接的成本可能变得有问题。
社交网络案例研究中的反规范化
在”案例研究:社交网络主页时间线”中,我们比较了规范化表示(图 2-1)和反规范化表示(预计算、物化时间线):在这里,帖子与关注之间的连接太昂贵,物化时间线是该连接结果的缓存。将新帖子插入关注者时间线的扇出过程是我们保持反规范化表示一致的方式。
然而,X(原 Twitter)的物化时间线实现并不存储每条帖子的实际文本:每个条目实际上只存储帖子 ID、发帖用户 ID,以及少量额外信息以识别转发和回复。换句话说,它大致是以下查询的预计算结果:
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 水合,它本质上是在应用代码中执行的连接。
在预计算时间线中只存储 ID 的原因是它们引用的数据变化很快:热门帖子的点赞和回复数量可能每秒变化多次,一些用户经常更改用户名或头像。由于时间线应在查看时显示最新的点赞数和头像,因此将这些信息反规范化到物化时间线中没有意义。此外,这种反规范化会显著增加存储成本。
这个例子表明,在读取数据时执行连接并不像有时声称的那样是创建高性能、可扩展服务的障碍。水合帖子 ID 和用户 ID 实际上是一个相当容易扩展的操作,因为它并行化良好,而且成本不取决于您关注的账户数量或关注者数量。
如果您需要决定是否在应用程序中反规范化某些内容,社交网络案例研究表明选择并不立即明显:最可扩展的方法可能涉及反规范化某些内容而保持其他内容规范化。您必须仔细考虑信息更改的频率,以及读取和写入的成本(这可能由异常值主导,例如典型社交网络中具有许多关注/关注者的用户)。规范化和反规范化本质上没有好坏之分——它们只是在读取和写入性能以及实现工作量方面的权衡。
多对一和多对多关系
虽然图 3-1 中的职位和教育是一对多或一对少关系的例子(一份简历有几个职位,但每个职位只属于一份简历),region_id 字段是多对一关系的例子(许多人住在同一地区,但我们假设每个人在任何时候只住在一个地区)。
如果我们引入组织和学校实体,并从简历中通过 ID 引用它们,那么我们也有多对多关系(一个人为多个组织工作过,一个组织有多个过去或现在的员工)。在关系模型中,这种关系通常表示为关联表或连接表,如图 3-3 所示:每个职位将一个用户 ID 与一个组织 ID 关联。
图 3-3 关系模型中的多对多关系
多对一和多对多关系不容易适应一个自包含的 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}
],
...
}
图 3-4 文档模型中的多对多关系:每个虚线框内的数据可以分组为一个文档
多对多关系通常需要”双向”查询:例如,查找特定人员工作过的所有组织,以及查找在特定组织工作过的所有人员。启用这种查询的一种方法是在两边存储 ID 引用,即简历包含个人工作过的每个组织的 ID,组织文档包含提及该组织的简历 ID。这种表示是反规范化的,因为关系存储在两个地方,可能彼此不一致。
规范化表示将关系只存储在一个地方,并依赖二级索引(我们将在第四章讨论)允许关系在双向高效查询。在图 3-3 的关系模式中,我们会告诉数据库在职位表的 user_id 和 org_id 列上创建索引。
在示例 3-2 的文档模型中,数据库需要对职位数组内对象的 org_id 字段建立索引。许多文档数据库和支持 JSON 的关系数据库能够在文档内的值上创建此类索引。
星型与雪花型:分析模式
数据仓库(参见”数据仓库”)通常是关系型的,数据仓库中表结构有一些广泛使用的约定:星型模式、雪花型模式、维度建模和一大张表(OBT)。这些结构针对业务分析师的需求进行了优化。ETL 流程将数据从操作系统转换为此模式。
图 3-5 显示了杂货零售商数据仓库中星型模式的示例。模式的中心是所谓的事实表(在此示例中称为 fact_sales)。事实表的每一行代表在特定时间发生的事件(此处,每一行代表客户购买产品)。如果我们分析网站流量而非零售销售,每一行可能代表用户的页面浏览或点击。
图 3-5 数据仓库中星型模式的示例
通常,事实被捕获为单个事件,因为这允许以后最大的分析灵活性。然而,这意味着事实表可能变得极其庞大。大型企业可能在其数据仓库中有许多 PB 的交易历史,主要以事实表表示。
事实表中的一些列是属性,例如产品销售价格和从供应商处购买的成本(允许计算利润率)。事实表中的其他列是外键引用其他表,称为维度表。由于事实表中的每一行代表一个事件,维度代表事件的谁、什么、哪里、何时、如何和为什么。
例如,在图 3-5 中,其中一个维度是销售的产品。dim_product 表中的每一行代表一种待售产品类型,包括其库存单位(SKU)、描述、品牌名称、类别、脂肪含量、包装尺寸等。fact_sales 表中的每一行使用外键指示该特定交易中销售了哪种产品。查询通常涉及与多个维度表的多个连接。
甚至日期和时间通常也使用维度表表示,因为这允许编码关于日期的额外信息(如公共假期),允许查询区分假日和非假日的销售。
图 3-5 是星型模式的示例。名称来源于当可视化表关系时,事实表在中间,被其维度表包围;与这些表的连接就像星星的光芒。
这种模板的一种变体称为雪花型模式,其中维度进一步分解为子维度。例如,品牌和商品类别可能有单独的表,dim_product 表中的每一行可以将品牌和类别引用为外键,而不是在 dim_product 表中将它们存储为字符串。雪花型模式比星型模式更规范化,但星型模式通常更受青睐,因为它们对分析师来说更简单。
在典型的数据仓库中,表通常相当宽:事实表通常有 100 多列,有时几百列。维度表也可以很宽,因为它们包含可能与分析相关的所有元数据——例如,dim_store 表可能包括每家店提供哪些服务、是否有店内面包店、平方英尺、店铺首次开业日期、上次装修日期、离最近高速公路多远等详细信息。
星型或雪花型模式主要由多对一关系组成(例如,许多销售发生在特定产品、特定商店),表示为事实表有外键指向维度表,或维度指向子维度。原则上,可能存在其他类型的关系,但它们通常被反规范化以简化查询。例如,如果客户一次购买几种不同的产品,该多项目交易没有明确表示;相反,事实表中为购买的每个产品有单独的一行,这些事实恰好具有相同的客户 ID、商店 ID 和时间戳。
一些数据仓库模式将反规范化更进一步,完全省略维度表,将维度中的信息折叠到事实表的反规范化列中(本质上,预计算事实表与维度表之间的连接)。这种方法称为一大张表(OBT),虽然它需要更多存储空间,但有时能实现更快的查询。
在分析的背景下,这种反规范化没有问题,因为数据通常代表不会更改的历史数据日志(除了偶尔纠正错误)。OLTP 系统中反规范化出现的数据一致性和写入开销问题在分析中不那么紧迫。
何时使用哪种模型
文档数据模型的主要论点是模式灵活性、由于局部性带来的更好性能,以及对于某些应用程序它更接近应用程序使用的对象模型。关系模型通过提供更好的连接、多对一和多对多关系支持来反驳。让我们更详细地审视这些论点。
如果应用程序中的数据具有文档结构(即一对多关系的树,通常整个树一次性加载),那么使用文档模型可能是个好主意。关系技术的拆分——将文档结构拆分为多个表(如图 3-1 中的职位、教育和联系信息)——可能导致繁琐的模式和不必要的复杂应用程序代码。
文档模型有局限性:例如,您不能直接引用文档中的嵌套项,而必须说类似”用户 251 的职位列表中的第二项”。如果您确实需要引用嵌套项,关系方法效果更好,因为您可以通过 ID 直接引用任何项。
一些应用程序允许用户选择项目顺序:例如,想象一个待办事项列表或问题跟踪器,用户可以拖放任务来重新排序。文档模型很好地支持这种应用程序,因为项目(或其 ID)可以简单地存储在 JSON 数组中以确定其顺序。在关系数据库中,没有表示这种可重新排序列表的标准方式,使用各种技巧:按整数列排序(在中间插入时需要重新编号)、ID 链表或分数索引。
文档模型中的模式灵活性
大多数文档数据库以及关系数据库中的 JSON 支持,不对文档中的数据强制执行任何模式。关系数据库中的 XML 支持通常带有可选的模式验证。无模式意味着可以向文档添加任意键和值,读取时,客户端无法保证文档可能包含哪些字段。
文档数据库有时被称为无模式的,但这是误导性的,因为读取数据的代码通常假设某种结构——即存在隐式模式,但数据库不强制执行。更准确的术语是读时模式(数据的结构是隐式的,仅在读取数据时解释),与写时模式(关系数据库的传统方法,模式是显式的,数据库确保所有数据在写入时符合它)相对。
读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。正如静态和动态类型检查的支持者就它们的相对优点进行激烈辩论一样,数据库中模式的强制执行是一个有争议的话题,通常没有正确或错误的答案。
当应用程序想要更改其数据格式时,这两种方法之间的差异尤其明显。例如,假设您目前将每个用户的全名存储在一个字段中,而您想改为分别存储名字和姓氏。在文档数据库中,您只需开始用新字段写入新文档,并在应用程序中编写代码处理读取旧文档的情况。例如:
if (user && user.name && !user.first_name) {
// 2023年12月8日之前写入的文档没有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 语句可能很慢,因为需要重写每一行,其他模式操作(如更改列的数据类型)通常也需要复制整个表。
存在各种工具允许在后台执行此类模式更改而无需停机,但在大型数据库上执行此类迁移在操作上仍然具有挑战性。通过仅添加默认值为 NULL 的 first_name 列(这是快速的),并在读取时填充它,就像使用文档数据库一样,可以避免复杂的迁移。
如果集合中的项目由于某种原因并非都具有相同结构(即数据是异构的),读时模式方法是有利的——例如,因为:
- 有许多不同类型的对象,将每种类型的对象放在自己的表中是不切实际的。
- 数据结构由您无法控制的外部系统决定,这些系统可能随时更改。
在这种情况下,模式可能弊大于利,无模式文档可能是更自然的数据模型。但在所有记录预期具有相同结构的情况下,模式是记录和强制执行该结构的有用机制。我们将在第五章更详细地讨论模式和模式演变。
读写数据局部性
文档通常作为单个连续字符串存储,编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON)。如果您的应用程序经常需要访问整个文档(例如,在网页上渲染它),这种存储局部性具有性能优势。如果数据像图 3-1 那样跨多个表拆分,检索所有数据需要多次索引查找,这可能需要更多磁盘寻道并花费更多时间。
局部性优势仅适用于您同时需要文档的大部分时。数据库通常需要加载整个文档,如果您只需要访问大文档的一小部分,这可能是浪费的。在文档更新时,通常需要重写整个文档。因此,通常建议保持文档相当小,避免对文档进行频繁的小更新。
然而,将相关数据存储在一起以获得局部性的想法并不限于文档模型。例如,Google 的 Spanner 数据库通过在关系数据模型中允许模式声明表的行应该交错(嵌套)在父表中,提供了相同的局部性属性。Oracle 使用称为多表索引集群表的功能允许相同。Google Bigtable 推广的宽列数据模型,在 HBase 和 Accumulo 等中使用,具有列族的概念,具有类似的局部性管理目的。
文档的查询语言
关系数据库和文档数据库之间的另一个区别是您用于查询它的语言或 API。大多数关系数据库使用 SQL 查询,但文档数据库更加多样化。有些仅允许通过主键进行键值访问,而其他还提供二级索引来查询文档内的值,有些提供丰富的查询语言。
XML 数据库通常使用 XQuery 和 XPath 查询,它们旨在允许复杂查询,包括跨多个文档的连接,并将结果格式化为 XML。JSON Pointer 和 JSONPath 为 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)函数确定包含时间戳的日历月份,并返回表示该月开始的另一个时间戳。换句话说,它将时间戳向下舍入到最近的月份。
此查询首先将观察过滤为仅显示鲨鱼科的物种,然后按发生的日历月份对观察进行分组,最后将该月所有观察中看到的动物数量相加。相同的查询可以使用 MongoDB 的聚合管道表达如下:
db.observations.aggregate([
{ $match: { family: "Sharks" } },
{ $group: {
_id: {
year: { $year: "$observationTimestamp" },
month: { $month: "$observationTimestamp" }
},
totalAnimals: { $sum: "$numAnimals" }
} }
]);
聚合管道语言在表达性上与 SQL 的子集相似,但它使用基于 JSON 的语法而不是 SQL 的英语句子风格语法;差异可能只是口味问题。
文档和关系数据库的融合
文档数据库和关系数据库最初是数据管理的非常不同的方法,但随着时间的推移它们变得越来越相似。关系数据库添加了对 JSON 类型和查询操作符的支持,以及索引文档内属性的能力。一些文档数据库(如 MongoDB、Couchbase 和 RethinkDB)添加了对连接、二级索引和声明式查询语言的支持。
模型的这种融合对应用开发者来说是好消息,因为当关系模型和文档模型可以在同一数据库中结合时,它们工作得最好。许多文档数据库需要关系风格的引用到其他文档,许多关系数据库有模式灵活性有益的部分。关系-文档混合是一种强大的组合。
注:科德对关系模型的原始描述实际上允许关系模式中类似 JSON 的东西。他称之为非简单域。想法是行中的值不必只是原始数据类型如数字或字符串,它也可以是嵌套关系(表)——因此您可以拥有任意嵌套的树结构作为值,很像 30 多年后添加到 SQL 的 JSON 或 XML 支持。
类图数据模型
我们之前看到,关系类型是区分不同数据模型之间的重要特征。如果您的应用程序主要有一对多关系(树结构数据)和记录之间很少的其他关系,文档模型是合适的。
但如果多对多关系在您的数据中非常常见呢?关系模型可以处理简单的多对多关系情况,但随着数据内的连接变得更复杂,开始将数据建模为图变得更自然。
图由两种对象组成:顶点(也称为节点或实体)和边(也称为关系或弧)。许多类型的数据可以建模为图。典型示例包括:
- 社交图:顶点是人,边表示哪些人彼此认识。
- 网页图:顶点是网页,边表示指向其他页面的 HTML 链接。
- 道路或铁路网络:顶点是交叉点,边表示它们之间的道路或铁路线。
众所周知的算法可以在这些图上运行:例如,地图导航应用搜索道路网络中两点之间的最短路径,PageRank 可用于网页图以确定网页的受欢迎程度,从而确定其在搜索结果中的排名。
图可以用几种不同方式表示。在邻接列表模型中,每个顶点存储距离一条边的邻居顶点的 ID。或者,您可以使用邻接矩阵,一个二维数组,其中每行和每列对应一个顶点,当行顶点与列顶点之间没有边时值为零,当有边时值为 1。邻接列表适合图遍历,矩阵适合机器学习(参见”DataFrame、矩阵和数组”)。
在上述示例中,图中的所有顶点都代表相同类型的东西(分别是人、网页或道路交叉点)。然而,图不限于这种同质数据:图的同样强大的用途是在单个数据库中以一致的方式存储完全不同类型的对象。例如:
-
Facebook 维护一个具有许多不同类型顶点和边的单一图:顶点代表人、地点、事件、签到和用户评论;边表示哪些人彼此是朋友、哪个签到发生在哪个地点、谁评论了哪个帖子、谁参加了哪个事件等。
-
知识图被搜索引擎用于记录经常出现在搜索查询中的实体的事实,如组织、人和地点。这些信息通过爬取和分析网站上的文本获得;一些网站,如 Wikidata,也以结构化形式发布图数据。
有几种不同但相关的在图中构造和查询数据的方式。在本节中,我们将讨论属性图模型(由 Neo4j、Memgraph、KùzuDB 等实现)和三元组存储模型(由 Datomic、AllegroGraph、Blazegraph 等实现)。这些模型在它们能表达的内容上相当相似,一些图数据库(如 Amazon Neptune)支持两种模型。
我们还将看四种图的查询语言(Cypher、SPARQL、Datalog 和 GraphQL),以及 SQL 对查询图的支持。其他图查询语言存在,如 Gremlin,但这些将给我们一个代表性的概览。
为了说明这些不同的语言和模型,本节使用图 3-6 所示的图作为运行示例。它可能来自社交网络或家谱数据库:它显示两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已婚并住在伦敦。每个人和每个地点都表示为一个顶点,它们之间的关系表示为边。这个示例将帮助演示一些在图数据库中容易但在其他模型中困难的查询。
图 3-6 图结构数据示例(框代表顶点,箭头代表边)
属性图
在属性图(也称为标记属性图)模型中,每个顶点包括:
- 唯一标识符
- 描述此顶点代表什么类型对象的标签(字符串)
- 一组出边
- 一组入边
- 一组属性(键值对)
每条边包括:
- 唯一标识符
- 边开始的顶点(尾顶点)
- 边结束的顶点(头顶点)
- 描述两个顶点之间关系类型的标签
- 一组属性(键值对)
您可以将图存储看作由两个关系表组成,一个用于顶点,一个用于边,如示例 3-3 所示(此模式使用 PostgreSQL jsonb 数据类型存储每个顶点或边的属性)。为每条边存储头顶点和尾顶点;如果您想要顶点的入边或出边集,您可以分别通过 head_vertex 或 tail_vertex 查询边表。
示例 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 所示。该图显示了一些在传统关系模式中难以表达的东西,如不同国家不同类型的区域结构(法国有省和大区,而美国有县和州)、历史的怪癖如国中之国(暂时忽略主权国家和国家的复杂性),以及数据的不同粒度(Lucy 的当前居住地指定为城市,而她的出生地仅指定为州级别)。
您可以想象扩展图以包含关于 Lucy 和 Alain 或其他人的许多其他事实。例如,您可以用它来表示他们有任何食物过敏(通过为每个过敏原引入一个顶点,以及人与过敏原之间的边来表示过敏),并将过敏原与显示哪些食物包含哪些物质的一组顶点链接起来。然后您可以编写查询来找出每个人可以安全吃什么。图适合可演化性:随着您向应用程序添加功能,图可以轻松扩展以适应应用程序数据结构的变化。
Cypher 查询语言
Cypher是属性图的查询语言,最初为 Neo4j 图数据库创建,后来作为 openCypher 开发成开放标准。除 Neo4j 外,Cypher 还支持 Memgraph、KùzuDB、Amazon Neptune、Apache AGE(使用 PostgreSQL 存储)等。它以电影《黑客帝国》中的角色命名,与密码学中的密码无关。
示例 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 查询它吗?
答案是肯定的,但有些困难。您在图查询中遍历的每条边实际上都是与边表的连接。在关系数据库中,您通常事先知道查询中需要哪些连接。另一方面,在图查询中,您可能需要遍历可变数量的边才能找到您要查找的顶点——即,连接的数量不是事先固定的。
在我们的示例中,这发生在 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是美国境内所有位置的顶点ID集合
in_usa(vertex_id) AS (
SELECT vertex_id FROM vertices
WHERE label = 'Location' AND 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 label = 'location' AND 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是所有在美国出生的人的顶点ID集合
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是所有在欧洲居住的人的顶点ID集合
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;
4 行 Cypher 查询需要 31 行 SQL 的事实表明了选择正确的数据模型和查询语言可以产生多大的差异。而这只是开始;还有更多细节需要考虑,例如处理循环,以及在广度优先或深度优先遍历之间选择。Oracle 有不同的 SQL 扩展用于递归查询,它称之为层次查询。
然而,情况可能正在改善:在撰写本文时,有计划将称为 GQL 的图查询语言添加到 SQL 标准中,它将提供受 Cypher、GSQL 和 PGQL 启发的语法。
三元组存储和 SPARQL
三元组存储模型与属性图模型基本等价,使用不同的词描述相同的想法。然而,它仍然值得讨论,因为三元组存储有各种工具和语言,可以成为构建应用程序工具箱的宝贵补充。
在三元组存储中,所有信息都以非常简单的三部分语句形式存储:(主语,谓语,宾语)。例如,在三元组(Jim, likes, bananas)中,Jim 是主语,likes 是谓语(动词),bananas 是宾语。
三元组的主语等价于图中的顶点。宾语是两种东西之一:
-
原始数据类型的值,如字符串或数字。在这种情况下,三元组的谓语和宾语等价于主语顶点上的属性的键和值。使用图 3-6 中的示例,(lucy, birthYear, 1989)就像属性为
{"birthYear": 1989}的顶点 lucy。 -
图中的另一个顶点。在这种情况下,谓语是图中的边,主语是尾顶点,宾语是头顶点。例如,在(lucy, marriedTo, alain)中,主语和宾语 lucy 和 alain 都是顶点,谓语 marriedTo 是连接它们的边的标签。
注:精确地说,提供类似三元组数据模型的数据库通常需要在每个元组上存储一些额外的元数据。例如,AWS Neptune 通过向每个三元组添加图 ID 使用四元组(4 元组);Datomic 使用 5 元组,用事务 ID 和指示删除的布尔值扩展每个三元组。由于这些数据库保留了上述基本的主语-谓语-宾语结构,本书仍称它们为三元组存储。
示例 3-7 显示了与示例 3-4 中相同的数据,以称为 Turtle 的格式写成三元组,是 Notation3(N3)的子集。
示例 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 年代初的一项努力,旨在通过不仅以人类可读的网页形式发布数据,而且以标准化的机器可读格式发布数据,来促进互联网范围的数据交换。尽管最初设想的语义网没有成功,但语义网项目的遗产存在于一些特定技术中:链接数据标准如 JSON-LD、生物医学科学中使用的本体、Facebook 的开放图协议(用于链接展开)、知识图如 Wikidata,以及 schema.org 维护的结构化数据标准词汇表。
三元组存储是另一种在其原始用例之外找到用途的语义网技术:即使您对语义网没有兴趣,三元组也可以成为应用程序的良好内部数据模型。
RDF 数据模型
我们在示例 3-8 中使用的 Turtle 语言实际上是一种在**资源描述框架(RDF)**中编码数据的方式,RDF 是为语义网设计的数据模型。RDF 数据也可以用其他方式编码,例如(更冗长地)用 XML,如示例 3-9 所示。像 Apache Jena 这样的工具可以自动在不同的 RDF 编码之间转换。
示例 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 数据模型的三元组存储的查询语言。(它是 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". # SPARQL
SPARQL 受 Amazon Neptune、AllegroGraph、Blazegraph、OpenLink Virtuoso、Apache Jena 和各种其他三元组存储支持。
Datalog:递归关系查询
Datalog比 SPARQL 或 Cypher 古老得多:它起源于 1980 年代的学术研究。它在软件工程师中不太知名,在主流数据库中也不广泛支持,但它应该更广为人知,因为它是一种非常有表现力的语言,对复杂查询特别强大。几个小众数据库,包括 Datomic、LogicBlox、CozoDB 和 LinkedIn 的 LIquid,使用 Datalog 作为其查询语言。
Datalog 实际上基于关系数据模型,而不是图,但它出现在本书的图数据库部分,因为图上的递归查询是 Datalog 的特定优势。
Datalog 数据库的内容由事实组成,每个事实对应关系表中的一行。例如,假设我们有一个包含位置的 location 表,它有三列:ID、name 和 type。美国是一个国家的事实可以写成location(2, "United States", "country"),其中 2 是美国的 ID。一般来说,语句table(val1, val2, …)意味着表包含一行,其中第一列包含 val1,第二列包含 val2,依此类推。
示例 3-11 显示了如何用 Datalog 写图 3-6 左侧的数据。图的边(within、born_in 和 lives_in)表示为两列连接表。例如,Lucy 的 ID 是 100,Idaho 的 ID 是 3,因此关系”Lucy 出生在 Idaho”表示为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 的子集,如果您学习过计算机科学,您可能以前见过 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-7 使用示例 3-12 中的 Datalog 规则确定 Idaho 在 North America
现在规则 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 Web 应用前端)请求具有特定结构的 JSON 文档,包含渲染其用户界面所需的字段。GraphQL 接口允许开发者在不更改服务器端 API 的情况下快速更改客户端代码中的查询。
GraphQL 的灵活性是有代价的。采用 GraphQL 的组织通常需要工具将 GraphQL 查询转换为对内部服务的请求,这些服务通常使用 REST 或 gRPC(参见第五章)。授权、速率限制和性能挑战是额外的担忧。GraphQL 的查询语言也是有限的,因为 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 中,第二条消息是对第一条的回复,内容(“Hey!…”)和发送者 Aaliyah 在 replyTo 下重复。可以改为返回被回复消息的 ID,但如果该 ID 不在返回的 50 条最近消息中,客户端就需要向服务器发出额外请求。重复内容使处理数据变得简单得多。
服务器的数据库可以以更规范化的形式存储数据,并执行必要的连接来处理查询。例如,服务器可能存储消息以及发送者的用户 ID 和它所回复消息的 ID;当它收到上述查询时,服务器将解析这些 ID 以查找它们引用的记录。然而,客户端只能要求服务器执行 GraphQL 模式中明确提供的连接。
尽管 GraphQL 查询的响应看起来类似于文档数据库的响应,尽管它的名字中有”图”,GraphQL 可以在任何类型的数据库之上实现——关系型、文档型或图型。
事件溯源和 CQRS
在我们目前讨论的所有数据模型中,数据以与写入相同的形式被查询——无论是 JSON 文档、表中的行,还是图中的顶点和边。然而,在复杂应用程序中,有时很难找到能够满足数据需要被查询和呈现的所有不同方式的单一数据表示。在这种情况下,以一种形式写入数据,然后从中派生出针对不同类型读取优化的几种表示可能是有益的。
我们之前在”记录系统和派生数据”中看到了这个想法,ETL(参见”数据仓库”)是这种派生过程的一个示例。现在我们将进一步采用这个想法。如果我们无论如何都要从一个数据表示派生另一个,我们可以选择分别针对写入和读取优化的不同表示。如果您只想针对写入优化数据,而高效查询无关紧要,您将如何建模数据?
也许写入数据的最简单、最快、最具表现力的方式是事件日志:每次您想写入一些数据时,将其编码为自包含的字符串(可能是 JSON),包括时间戳,然后将其附加到事件序列中。此日志中的事件是不可变的:您从不更改或删除它们,您只向日志追加更多事件(可能取代早期事件)。事件可以包含任意属性。
图 3-8 显示了一个可能来自会议管理系统的示例。会议可能是一个复杂的业务领域:不仅个人与会者可以注册和刷卡支付,公司还可以批量订购座位,通过发票支付,然后稍后将座位分配给个人。一些座位可能为演讲者、赞助商、志愿者助手等保留。预订也可能被取消,同时,会议组织者可能通过将活动转移到不同的房间来更改活动容量。在所有这些情况下,简单地计算可用座位数就成为一个具有挑战性的查询。
图 3-8 使用不可变事件日志作为真相来源,并从中派生物化视图。
在图 3-8 中,会议状态的每次更改(如组织者开放注册,或与会者进行和取消预订)首先存储为事件。每当事件附加到日志时,几个物化视图(也称为投影或读取模型)也会更新以反映该事件的效果。在会议示例中,可能有一个物化视图收集与每次预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为打印与会者徽章的打印机生成文件。
使用事件作为真相来源,并将每个状态更改表示为事件的想法,称为事件溯源。维护单独的读取优化表示并从写入优化表示派生它们的原则称为命令查询职责分离(CQRS)。这些术语起源于领域驱动设计(DDD)社区,尽管类似的想法已经存在很长时间,例如在状态机复制中(参见”使用共享日志”)。
当来自用户的请求进入时,它被称为命令,首先需要验证。只有当命令已执行并被确定为有效时(例如,请求的预订有足够可用座位),它才成为事实,相应的事件被添加到日志中。因此,事件日志应仅包含有效事件,构建物化视图的事件日志消费者不允许拒绝事件。
以事件溯源风格建模数据时,建议您用过去时态命名事件(例如,“座位已预订”),因为事件是某事在过去发生的记录。即使用户后来决定更改或取消,他们以前持有预订的事实仍然成立,更改或取消是稍后添加的单独事件。
事件溯源和星型模式事实表之间的相似之处在于,两者都是过去发生的事件的集合。然而,事实表中的行都有相同的列集,而在事件溯源中可能有许多不同类型的事件,每种都有不同的属性。此外,事实表是无序集合,而在事件溯源中事件的顺序很重要:如果预订先进行然后取消,以错误顺序处理这些事件是没有意义的。
事件溯源和 CQRS 有几个优势:
-
对于开发系统的人来说,事件更好地传达了某事发生的原因的意图。例如,理解事件”预订已取消”比理解”预订表的 active 列在第 4001 行被设置为 false,与该预订相关的三行从 seat_assignments 表中删除,代表退款的行被插入 payments 表”更容易。当物化视图处理取消事件时,这些行修改可能仍然发生,但当它们由事件驱动时,更新的原因变得更加清晰。
-
事件溯源的一个关键原则是物化视图以可重现的方式从事件日志派生:您应该始终能够删除物化视图,并通过使用相同代码以相同顺序处理相同事件来重新计算它们。如果视图维护代码中有错误,您只需删除视图并用新代码重新计算。也更容易找到错误,因为您可以根据需要多次重新运行视图维护代码并检查其行为。
-
您可以有多个针对应用程序所需的特定查询优化的物化视图。它们可以存储在与事件相同的数据库中,也可以根据不同的需要存储在不同的数据库中。它们可以使用任何数据模型,并且可以反规范化以实现快速读取。您甚至可以只在内存中保留视图而避免持久化它,只要服务重启时从事件日志重新计算视图是可以接受的。
-
如果您决定以新方式呈现现有信息,从现有事件日志构建新的物化视图很容易。您还可以通过添加新类型的事件,或向现有事件类型添加新属性来演进系统以支持新功能(任何旧事件保持未修改)。您还可以将新行为链接到现有事件(例如,当会议与会者取消时,他们的座位可以提供给等待列表上的下一个人)。
-
如果事件写入错误,您可以再次删除它,然后可以在没有删除事件的情况下重建视图。另一方面,在您直接更新和删除数据的数据库中,已提交的事务通常难以逆转。因此,事件溯源可以减少系统中不可逆操作的数量,使其更容易更改(参见”可演化性:使更改容易”)。
-
事件日志还可以作为系统中发生的一切的审计日志,这在要求此类可审计性的受监管行业中很有价值。
然而,事件溯源和 CQRS 也有缺点:
-
如果涉及外部信息,您需要小心。例如,假设一个事件包含以一种货币给出的价格,对于其中一个视图需要将其转换为另一种货币。由于汇率可能波动,在处理事件时从外部来源获取汇率会有问题,因为如果您在另一个日期重新计算物化视图,您会得到不同的结果。为了使事件处理逻辑确定性,您要么需要在事件本身中包含汇率,要么有一种方法查询事件时间戳指示的历史汇率,确保此查询对同一时间戳始终返回相同结果。
-
事件不可变的要求如果事件包含用户的个人数据会产生问题,因为用户可能行使他们的权利(例如根据 GDPR)要求删除他们的数据。如果事件日志是按用户的,您可以只删除该用户的整个日志,但如果事件日志包含与多个用户相关的事件,这就不起作用。您可以尝试将个人数据存储在实际事件之外,或使用您可以稍后选择删除的密钥加密它(一种称为加密粉碎的技术),但这也使在需要时重新计算派生状态变得更加困难。
-
如果有外部可见的副作用,重新处理事件需要小心——例如,您可能不想在每次重建物化视图时都重新发送确认电子邮件。
您可以在任何数据库之上实现事件溯源,但也有一些专门设计支持此模式的系统,如 EventStoreDB、MartenDB(基于 PostgreSQL)和 Axon Framework。您还可以使用 Apache Kafka 等消息代理来存储事件日志,流处理器可以保持物化视图最新;我们将在第十二章回到这些主题。
唯一重要的要求是事件存储系统必须保证所有物化视图按照它们在日志中出现的完全相同顺序处理事件;正如我们将在第十章看到的,这在分布式系统中并不总是容易实现。
DataFrame、矩阵和数组
我们在本章中目前看到的数据模型通常用于事务处理和分析目的(参见”分析与操作系统”)。在分析或科学环境中您可能遇到但很少出现在 OLTP 系统中的还有一些数据模型:DataFrame和数字的多维数组,如矩阵。
DataFrame是 R 语言、Python 的 Pandas 库、Apache Spark、ArcticDB、Dask 和其他系统支持的数据模型。它们是数据科学家为训练机器学习模型准备数据的流行工具,但也广泛用于数据探索、统计数据分析和数据可视化等目的。
乍一看,DataFrame 类似于关系数据库中的表或电子表格。它支持对 DataFrame 内容执行批量操作的关系类操作符:例如,对所有行应用函数、基于某些条件过滤行、按某些列分组行并聚合其他列,以及基于某个键将一个 DataFrame 中的行与另一个 DataFrame 中的行连接(关系数据库称为 join,DataFrame 通常称为 merge)。
DataFrame 通常通过一系列修改其结构和内容的命令来操作,而不是声明式查询如 SQL。这符合数据科学家的典型工作流程,他们逐步将数据”整理”成允许他们找到所提问题答案的形式。这些操作通常在数据科学家私有数据集副本上进行,通常在他们本地机器上,尽管最终结果可能与其他用户共享。
DataFrame API 还提供远远超出关系数据库提供的各种操作,数据模型通常以与典型关系数据建模非常不同的方式使用。例如,DataFrame 的常见用途是将数据从关系类表示转换为矩阵或多维数组表示,这是许多机器学习算法期望的输入形式。
图 3-9 显示了这种转换的一个简单示例。左侧是一个关系表,显示不同用户如何评价各种电影(按 1 到 5 的等级),右侧数据已转换为矩阵,其中每列是一部电影,每行是一个用户(类似于电子表格中的数据透视表)。矩阵是稀疏的,这意味着许多用户-电影组合没有数据,但这没关系。此矩阵可能有数千列,因此不太适合关系数据库,但 DataFrame 和提供稀疏数组的库(如 Python 的 NumPy)可以轻松处理此类数据。
图 3-9 将电影评分的关系数据库转换为矩阵表示
矩阵只能包含数字,使用各种技术将非数字数据转换为矩阵中的数字。例如:
-
日期(从图 3-9 的示例矩阵中省略)可以缩放为某个合适范围内的浮点数。
-
对于只能取少量固定值集合之一的列(例如电影数据库中电影的类型),通常使用独热编码:我们为每个可能的值创建一列(一列用于”喜剧”,一列用于”剧情”,一列用于”恐怖”等),对于代表电影的每一行,我们在对应该电影类型的列中放 1,在所有其他列中放 0。这种表示也很容易推广到适合多种类型的电影。
一旦数据以数字矩阵的形式存在,它就适合线性代数操作,这是许多机器学习算法的基础。例如,图 3-9 中的数据可能是用于推荐用户可能喜欢的电影的系统的一部分。DataFrame 足够灵活,允许数据逐渐从关系形式演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示。
还有一些数据库,如 TileDB,专门存储大型多维数字数组;它们称为数组数据库,最常用于科学数据集,如地理空间测量(规则间隔网格上的栅格数据)、医学成像或天文望远镜的观测。DataFrame 也用于金融行业,表示时间序列数据,如资产价格和随时间的交易。
总结
数据模型是一个巨大的主题,在本章中,我们快速浏览了各种不同的模型。我们没有空间深入每个模型的所有细节,但希望这个概述足以激发您了解更多关于最适合您应用程序需求的模型的兴趣。
关系模型尽管已有半个多世纪的历史,但对许多应用程序来说仍然是一个重要的数据模型——特别是在数据仓库和商业分析中,关系型星型或雪花型模式以及 SQL 查询无处不在。然而,在其他领域,关系数据的几种替代方案也变得流行:
-
文档模型针对数据以自包含 JSON 文档形式出现,且文档之间关系很少的使用场景。
-
图数据模型走向相反方向,针对任何内容都可能与一切相关,且查询可能需要遍历多跳以找到感兴趣数据的使用场景(可以使用 Cypher、SPARQL 或 Datalog 中的递归查询来表达)。
-
DataFrame将关系数据推广到大量列,从而提供数据库与构成许多机器学习、统计数据分析和科学计算基础的多维数组之间的桥梁。
在某种程度上,一种模型可以用另一种模型的术语来模拟——例如,图数据可以表示在关系数据库中——但结果可能很别扭,正如我们在 SQL 中对递归查询的支持中看到的那样。
因此,为每个数据模型开发了各种专业数据库,提供针对特定模型优化的查询语言和存储引擎。然而,也存在数据库通过添加对其他数据模型的支持扩展到邻近领域的趋势:例如,关系数据库以 JSON 列的形式添加了对文档数据的支持,文档数据库添加了关系类连接,SQL 中对图数据的支持正在逐步改善。
我们讨论的另一个模型是事件溯源,它将数据表示为不可变事件的仅追加日志,对于建模复杂业务领域中的活动可能是有利的。仅追加日志适合写入数据(正如我们将在第四章看到的);为了支持高效查询,事件日志通过 CQRS 转换为读取优化的物化视图。
非关系数据模型的一个共同点是它们通常不强制执行存储数据的模式,这可以使应用程序更容易适应不断变化的需求。然而,您的应用程序很可能仍然假设数据具有某种结构;这只是模式是显式的(写入时强制执行)还是隐式的(读取时假设)的问题。
尽管我们已经涵盖了很多内容,但仍有一些数据模型未被提及。仅举几个简要例子:
-
研究基因组数据的研究人员经常需要执行序列相似性搜索,这意味着取一个非常长的字符串(代表 DNA 分子)并将其与大量相似但不完全相同的字符串数据库匹配。这里描述的数据库都无法处理这种用法,这就是研究人员编写了 GenBank 等专业基因组数据库软件的原因。
-
许多金融系统使用复式记账的账簿作为其数据模型。这种类型的数据可以表示在关系数据库中,但也有专门化此数据模型的数据库,如 TigerBeetle。加密货币和区块链通常基于分布式账簿,其价值转移也内置在其数据模型中。
-
全文搜索可以说是经常与数据库一起使用的一种数据模型。信息检索是一个我们不会详细涵盖的大型专业主题,但我们将在”全文搜索”中触及搜索索引和向量搜索。
我们现在必须到此为止。在下一章中,我们将讨论实现本章描述的数据模型时出现的一些权衡。
脚注
参考文献
[1] Jamie Brandon. Unexplanations: query optimization works because sql is declarative. scattered-thoughts.net, February 2024.
[2] Neel Krishnaswami. What Declarative Languages Are. semantic-domain.blogspot.com, July 2013.
[3] Joseph M. Hellerstein. The Declarative Imperative: Experiences and Conjectures in Distributed Logic. Tech report UCB/EECS-2010-90, 2010.
[4] Edgar F. Codd. A Relational Model of Data for Large Shared Data Banks. Communications of the ACM, 1970.
[5] Michael Stonebraker and Joseph M. Hellerstein. What Goes Around Comes Around. In Readings in Database Systems, 4th edition, MIT Press, 2005.
[6] Markus Winand. Modern SQL: Beyond Relational. modern-sql.com, 2015.
[7] Martin Fowler. OrmHate. martinfowler.com, May 2012.
[8] Vlad Mihalcea. N+1 query problem with JPA and Hibernate. vladmihalcea.com, January 2023.
[9] Jens Schauder. This is the Beginning of the End of the N+1 Problem: Introducing Single Query Loading. spring.io, August 2023.
[10] William Zola. 6 Rules of Thumb for MongoDB Schema Design. mongodb.com, June 2014.
[11] Sidney Andrews and Christopher McClister. Data modeling in Azure Cosmos DB. learn.microsoft.com, February 2023.
[12] Raffi Krikorian. Timelines at Scale. At QCon San Francisco, November 2012.
[13] Ralph Kimball and Margy Ross. The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling, 3rd edition. John Wiley & Sons, 2013.
[14] Michael Kaminsky. Data warehouse modeling: Star schema vs. OBT. fivetran.com, August 2022.
[15] Joe Nelson. User-defined Order in SQL. begriffs.com, March 2018.
[16] Evan Wallace. Realtime Editing of Ordered Sequences. figma.com, March 2017.
[17] David Greenspan. Implementing Fractional Indexing. observablehq.com, October 2020.
[18] Martin Fowler. Schemaless Data Structures. martinfowler.com, January 2013.
[19] Amr Awadallah. Schema-on-Read vs. Schema-on-Write. At Berkeley EECS RAD Lab Retreat, 2009.
[20] Martin Odersky. The Trouble with Types. At Strange Loop, September 2013.
[21] Conrad Irwin. MongoDB—Confessions of a PostgreSQL Lover. At HTML5DevConf, October 2013.
[22] Percona Toolkit Documentation: pt-online-schema-change. docs.percona.com, 2023.
[23] Shlomi Noach. gh-ost: GitHub’s Online Schema Migration Tool for MySQL. github.blog, August 2016.
[24] Shayon Mukherjee. pg-osc: Zero downtime schema changes in PostgreSQL. shayon.dev, February 2022.
[25] Carlos Pérez-Aradros Herce. Introducing pgroll: zero-downtime, reversible, schema migrations for Postgres. xata.io, October 2023.
[26] James C. Corbett et al. Spanner: Google’s Globally-Distributed Database. At 10th USENIX OSDI, October 2012.
[27] Donald K. Burleson. Reduce I/O with Oracle Cluster Tables. dba-oracle.com.
[28] Fay Chang et al. Bigtable: A Distributed Storage System for Structured Data. At 7th USENIX OSDI, November 2006.
[29] Priscilla Walmsley. XQuery, 2nd Edition. O’Reilly Media, 2015.
[30] Paul C. Bryan et al. JavaScript Object Notation (JSON) Pointer. RFC 6901, IETF, April 2013.
[31] Stefan Gössner et al. JSONPath: Query Expressions for JSON. RFC 9535, IETF, February 2024.
[32] Michael Stonebraker and Andrew Pavlo. What Goes Around Comes Around… And Around…. ACM SIGMOD Record, 2024.
[33] Lawrence Page et al. The PageRank Citation Ranking: Bringing Order to the Web. Stanford University InfoLab, 1999.
[34] Nathan Bronson et al. TAO: Facebook’s Distributed Data Store for the Social Graph. At USENIX ATC, June 2013.
[35] Natasha Noy et al. Industry-Scale Knowledge Graphs: Lessons and Challenges. Communications of the ACM, 2019.
[36] Xiyang Feng et al. KÙZU Graph Database Management System. At CIDR 2023, January 2023.
[37] Maciej Besta et al. Demystifying Graph Databases: Analysis and Taxonomy of Data Organization, System Designs, and Graph Queries. arxiv.org, October 2019.
[38] Apache TinkerPop 3.6.3 Documentation. tinkerpop.apache.org, May 2023.
[39] Nadime Francis et al. Cypher: An Evolving Query Language for Property Graphs. At SIGMOD, 2018.
[40] Emil Eifrem. Twitter correspondence, January 2014.
[41] Francesco Tisiot. Explore the new SEARCH and CYCLE features in PostgreSQL 14. aiven.io, December 2021.
[42] Gaurav Goel. Understanding Hierarchies in Oracle. towardsdatascience.com, May 2020.
[43] Alin Deutsch et al. Graph Pattern Matching in GQL and SQL/PGQ. At SIGMOD, 2022.
[44] Alastair Green. SQL… and now GQL. opencypher.org, September 2019.
[45] Alin Deutsch et al. Seamless Syntactic and Semantic Integration of Query Primitives over Relational and Graph Data in GSQL. tigergraph.com, November 2018.
[46] Oskar van Rest et al. PGQL: a property graph query language. At GRADES, 2016.
[47] Amazon Web Services. Neptune Graph Data Model. Amazon Neptune User Guide.
[48] Cognitect. Datomic Data Model. Datomic Cloud Documentation.
[49] David Beckett and Tim Berners-Lee. Turtle – Terse RDF Triple Language. W3C Team Submission, March 2011.
[50] Sinclair Target. Whatever Happened to the Semantic Web? twobithistory.org, May 2018.
[51] Gavin Mendel-Gleason. The Semantic Web is Dead – Long Live the Semantic Web! terminusdb.com, August 2022.
[52] Manu Sporny. JSON-LD and Why I Hate the Semantic Web. manu.sporny.org, January 2014.
[53] University of Michigan Library. Biomedical Ontologies and Controlled Vocabularies.
[54] Facebook. The Open Graph protocol, ogp.me.
[55] Matt Haughey. Everything you ever wanted to know about unfurling but were afraid to ask. medium.com, November 2015.
[56] W3C RDF Working Group. Resource Description Framework (RDF). w3.org, February 2004.
[57] Steve Harris et al. SPARQL 1.1 Query Language. W3C Recommendation, March 2013.
[58] Todd J. Green et al. Datalog and Recursive Query Processing. Foundations and Trends in Databases, 2013.
[59] Stefano Ceri et al. What You Always Wanted to Know About Datalog (And Never Dared to Ask). IEEE TKDE, 1989.
[60] Serge Abiteboul et al. Foundations of Databases. Addison-Wesley, 1995.
[61] Scott Meyer et al. LIquid: The soul of a new graph database, Part 2. engineering.linkedin.com, September 2020.
[62] Matt Bessey. Why, after 6 years, I’m over GraphQL. bessey.dev, May 2024.
[63] Dominic Betts et al. Exploring CQRS and Event Sourcing. Microsoft Patterns & Practices, 2012.
[64] Greg Young. CQRS and Event Sourcing. At Code on the Beach, August 2014.
[65] Greg Young. CQRS Documents. cqrs.wordpress.com, November 2010.
[66] Brent Robinson. Crypto shredding: How it can solve modern data retention challenges. medium.com, January 2019.
[67] Devin Petersohn et al. Towards Scalable Dataframe Systems. PVLDB, 2020.
[68] Stavros Papadopoulos et al. The TileDB Array Data Storage Manager. PVLDB, 2016.
[69] Florin Rusu. Multidimensional Array Data Management. Foundations and Trends in Databases, 2023.
[70] Ed Targett. Bloomberg, Man Group team up to develop open source “ArcticDB” database. thestack.technology, March 2023.
[71] Dennis A. Benson et al. GenBank. Nucleic Acids Research, 2007.