读Designing Data-Intensive Applications Chapter 2

读Designing Data-Intensive Applications Chapter 2

埃德加·科德(Edgar Codd)1970年提出的关系模型(SQL模型):数据被组织成关系(SQL中称为表),其中每个关系是无序元组(SQL中的行)集合

大多数应用程序开发使用面向对象编程语言,导致了对SQL数据模型的批评:如果数据存储在关系表中,则需要在应用程序代码中的对象与数据库中的表、行和列的模型间有转换层。这种模型间的不连贯被称为impedance mismatch

  • 对象关系映射(ORM)框架减少了转换层所需的样板代码量,但它们无法完全隐藏两种模型间的差异
  • JSON模型可能减少了应用程序代码与存储层间的impedance mismatch。然而JSON作为数据编码格式存在问题。缺乏schema通常被认为是优势

JSON表示有更好的局部性。如想在关系示例中获取一个简历,需要执行多个查询(通过user_id查询每个表)或执行多方式连接,连接users表及其下属表。JSON表示中所有相关信息都在一个地方,一个查询就够

用户简历与用户的职位、教育历史和联系信息之间的一对多关系意味着数据中存在树状结构,JSON使这种树状结构明确化

这句讲json的在说什么?问问gpt得


many to one / many to many relationship

存储ID还是文本字符串? 使用ID,对人类有意义的信息只存储在一处,所有引用它的内容都使用ID(ID只在数据库内有意义); 直接存储文本时则在每个使用它的记录中复制了对人类有意义的信息

ID的优势在于由于它对人类无意义,因此永远不需更改,即使它标识的信息发生变化。任何对人类有意义的信息可能在将来需要更改。会带来写入开销且存在不一致性风险。消除这种复制是数据库规范化背后的关键思想

多对一关系不适合文档模型。关系型数据库中通过ID引用其他表中的行是正常的,因为连接很容易。文档型数据库中,对于一对多的树结构不需连接,且连接支持通常较弱

什么是多对一关系来着

如数据库不支持连接,必须通过对数据库进行多次查询在应用程序代码中模拟连接

即使应用程序初始版本适合无连接的文档模型,随着应用程序添加功能,数据倾向于变得更加互联


层次模型与文档型数据库使用的JSON模型有显著的相似之处。它将所有数据表示为嵌套在记录中的记录树

像文档型数据库一样,IMS很适合一对多关系,但它使多对多关系变得困难,并且不支持连接。开发者必须决定是复制(非规范化)数据还是手动解析从一个记录到另一个记录的引用。20世纪60年代和70年代的这些问题与今天开发者在文档型数据库中遇到的问题非常相似。

为了解决层次模型的局限性,提出了各种解决方案。两个最突出的是关系模型(后来成为SQL,占据了世界)和网络模型(最初有很大的追随者,但最终淡出了视线)

网络模型

也被称为CODASYL模型

CODASYL模型是层次模型的泛化。在层次模型的树结构中,每个记录都有一个父记录;在网络模型中,一个记录可以有多个父记录。例如,可以有一个代表“大西雅图地区”的记录,每个住在该地区的用户都可以与之链接。这允许建模多对一和多对多关系。

网络模型中记录之间的链接不是外键,而更像编程语言中的指针(同时存储在磁盘上)。访问记录的唯一方式是沿着这些链接链从根记录开始跟随路径。这被称为访问路径。

在最简单的情况下,访问路径可以像遍历链表一样:从链表头开始,一次查看一个记录,直到找到你想要的记录。但在多对多关系的世界中,有几条不同的路径可以通往同一记录,使用网络模型的程序员必须在头脑中跟踪这些不同的访问路径。

在CODASYL中,查询是通过在数据库中移动一个光标来执行的,通过迭代记录列表和跟随访问路径。如果一个记录有多个父记录(即,来自其他记录的多个传入指针),应用程序代码必须跟踪所有不同的关系。即使是CODASYL委员会成员也承认,这就像在n维数据空间中导航。

虽然手动选择访问路径能够最有效地利用20世纪70年代非常有限的硬件能力(如磁带驱动器,其寻道非常慢),但问题在于它们使查询和更新数据库的代码变得复杂和僵化。无论是层次模型还是网络模型,如果你没有通往所需数据的路径,你就会陷入困境。你可以更改访问路径,但那么你必须通过大量的手写数据库查询代码,并重写它以处理新的访问路径。更改应用程序的数据模型很困难。

关系模型

关系模型做的是将所有数据公开放置:关系(表)仅仅是元组(行)的集合。没有错综复杂的嵌套结构,没有复杂的访问路径需要遵循,如果你想查看数据。你可以读取表中的任何行或所有行,选择那些符合任意条件的行。你可以通过指定某些列作为键并匹配这些键来读取特定的行。你可以在不担心与其他表的外键关系的情况下,将新行插入任何表中。

在关系型数据库中,查询优化器自动决定以哪种顺序执行查询的哪些部分,以及使用哪些索引。这些选择实际上是“访问路径”,但重要的区别在于它们是由查询优化器自动做出的,而不是由应用程序开发人员做出的,所以我们很少需要考虑它们。

如果你想以新的方式查询你的数据,你可以简单地声明一个新的索引,查询将自动使用最合适的索引。你不需要更改你的查询来利用新索引。(另见“数据的查询语言”。)因此,关系模型使为应用程序添加新功能变得更容易。

关系型数据库的查询优化器是复杂的野兽,它们消耗了多年的研究和开发工作。但关系模型的一个关键洞见是:你只需要构建一次查询优化器,然后使用数据库的所有应用程序都可以从中受益。如果你没有查询优化器,为特定查询手动编写访问路径比编写通用优化器要容易——但从长远来看,通用解决方案会赢。

与文档型数据库的比较

文档型数据库在一个方面回归到了层次模型:将嵌套记录(图2-1中的一对多关系,如职位、教育和联系信息)存储在其父记录中,而不是在单独的表中。

然而,当涉及到表示多对一和多对多关系时,关系型和文档型数据库并没有根本的不同:在两种情况下,相关项目都是通过唯一标识符引用的,这在关系模型中称为外键,在文档模型中称为文档引用。这个标识符在读取时通过使用连接或后续查询来解析。到目前为止,文档型数据库还没有走上CODASYL的道路。


支持文档数据模型的主要论点是模式灵活性、由于局部性而带来的更好性能,以及对于某些应用来说,它更接近应用所使用的数据结构。而关系模型则通过提供更好的连接支持、多对一和多对多关系来进行反驳。

文档模型有局限性:例如,你不能直接引用文档中的嵌套项,而需要说类似于“用户251的职位列表中的第二项”(类似于层次模型中的访问路径)。然而,只要文档不是太深层次嵌套,这通常不是问题。

文档型数据库对连接的支持较差可能是问题,这取决于应用。例如,在使用文档型数据库记录何时发生哪些事件的分析应用中,可能永远不需要多对多关系。

如果应用确实使用了多对多关系,文档模型就变得不那么吸引人。可以通过非规范化来减少对连接的需求,但应用程序代码需要做额外的工作以保持非规范化数据的一致性。可以通过向数据库发出多个请求在应用程序代码中模拟连接,但这也将复杂性转移到应用程序中,并且通常比数据库内专门代码执行的连接更慢。在这种情况下,使用文档模型可能导致更复杂的应用程序代码和更差的性能。

通常无法说哪种数据模型会导致更简单的应用程序代码;这取决于数据项之间存在的关系类型。对于高度互联的数据,文档模型是笨拙的,关系模型是可接受的,而图形模型(见“类图形数据模型”)是最自然的。

大多数文档型数据库,以及关系型数据库中的JSON支持,并不对文档中的数据强制实施任何模式。关系型数据库中的XML支持通常带有可选的模式验证。没有模式意味着可以向文档中添加任意键和值,在读取时客户端无法保证文档可能包含哪些字段。

文档型数据库有时被称为无模式(schemaless),但这是误导,因为读取数据的代码通常假定某种结构——即,存在隐含的模式,但不是由数据库强制执行。更准确的术语是读时模式(数据的结构是隐含的,只在读取数据时解释),与写时模式相对(关系型数据库的传统方法,其中模式是显式的,数据库确保所有写入的数据都符合它)。

读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。就像静态和动态类型检查的倡导者对它们的相对优点进行了大量辩论一样,数据库中模式的强制执行也是一个有争议的话题,总的来说没有对或错。

在应用想要改变其数据格式的情况下,两种方法之间的区别尤其明显

模式更改有着执行缓慢和需要停机的坏名声。这个名声并不完全是应得的:大多数关系型数据库系统执行ALTER TABLE语句只需几毫秒。MySQL是一个显著的例外——它在ALTER TABLE时复制整个表,这可能意味着在更改大表时需要几分钟甚至几小时的停机时间——尽管有各种工具可以绕过这个限制。

在任何数据库上,对大表运行UPDATE语句可能都会很慢,因为每一行都需要重写。如果这是不可接受的,应用程序可以让first_name保持默认的NULL,并在读取时填充,就像它在文档型数据库中所做的那样。

一个文档通常存储为一个连续的字符串,编码为JSON、XML或其二进制变体(如MongoDB的BSON)。如果您的应用程序经常需要访问整个文档(例如,在网页上渲染它),那么这种存储本地性具有性能优势。如果数据分散在多个表中,像图2-1那样,需要进行多次索引查找才能检索到所有数据,这可能需要更多的磁盘寻道并且花费更多时间。

本地性优势仅在您需要同时获取文档的大部分时适用。数据库通常需要加载整个文档,即使您只访问其中一小部分,这对大型文档来说可能是浪费的。在对文档进行更新时,通常需要重写整个文档——只有不改变文档编码大小的修改才能轻松地就地执行​​。出于这些原因,通常建议保持文档相对较小,并避免增加文档大小的写操作。这些性能限制显著降低了文档数据库有用的情况集合

为了本地性将相关数据组合在一起的想法不仅限于文档模型。例如,谷歌的Spanner数据库在关系数据模型中提供了相同的本地性属性,允许通过声明表的行应该交错(嵌套)在一个父表中来实现。Oracle也允许使用一种叫做多表索引聚簇表的功能来实现同样的目的​​。Bigtable数据模型(在Cassandra和HBase中使用)中的列族概念有着类似的管理本地性的目的

关系和文档模型的混合是数据库未来的一个好方向

声明式语言通常更适合并行执行。如今,CPU通过增加更多核心而不是显著提高时钟速度来提高速度​​。命令式代码很难在多个核心和多台机器上并行化,因为它指定了必须按特定顺序执行的指令。声明式语言更有可能在并行执行中变得更快,因为它们只指定结果的模式,而不是用来确定结果的算法。如果合适,数据库可以自由使用查询语言的并行实现

MapReduce是一种编程模型,用于在多台机器上批量处理大量数据,由谷歌推广而流行。MapReduce的一种有限形式得到了一些NoSQL数据存储的支持,包括MongoDB和CouchDB,作为在多个文档上执行只读查询的机制。

MapReduce既不是声明式查询语言,也不是完全的命令式查询API,而是介于两者之间:查询逻辑通过代码片段表达,这些代码片段被处理框架反复调用。它基于许多函数式编程语言中存在的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;
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"
    }
);
{
    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函数将被调用reduce("1995-12", [3, 4]),返回7。

map和reduce函数在它们允许做的事情上有一些限制。它们必须是纯函数,这意味着它们只使用作为输入传递给它们的数据,它们不能执行额外的数据库查询,也不能有任何副作用。这些限制允许数据库在任何地方,以任何顺序运行函数,并在失败时重新运行它们。然而,它们仍然是强大的:它们可以解析字符串,调用库函数,进行计算等。

MapReduce是一种相当低级的编程模型,用于在一群机器上进行分布式执行。像SQL这样的高级查询语言可以实现为MapReduce操作的管道(见第10章),但也有许多不使用MapReduce的分布式SQL实现。请注意,SQL没有限制它只能在单台机器上运行,MapReduce也没有分布式查询执行的垄断

能够在查询中使用JavaScript代码是高级查询的一个很棒的特性,但它不仅限于MapReduce——一些SQL数据库也可以用JavaScript函数扩展

MapReduce的一个可用性问题是,你必须编写两个精心协调的JavaScript函数,这通常比编写单个查询更难。此外,声明式查询语言为查询优化器提供了更多改进查询性能的机会。出于这些原因,MongoDB 2.2增加了对称为聚合管道的声明式查询语言的支持【9】。在这种语言中,同样的鲨鱼计数查询看起来像这样

db.observations.aggregate([
    { $match: { family: "Sharks" } },
    { $group: {
        _id: {
            year:  { $year:  "$observationTimestamp" },
            month: { $month: "$observationTimestamp" }
        },
        totalAnimals: { $sum: "$numAnimals" }
    } }
]);

聚合管道语言在表达能力上类似于SQL的一个子集,但它使用基于JSON的语法而不是SQL的英语句式语法;区别可能是一种口味问题。这个故事的寓意是,一个NoSQL系统可能会发现自己在无意中重新发明SQL,尽管是以伪装的形式。

如果你的数据中多对多关系非常常见呢?关系模型可以处理多对多关系的简单情况,但随着数据中的连接变得更加复杂,开始将数据建模为图形就变得更加自然。

图的同样强大的用途是提供一种在单个数据存储中存储完全不同类型对象的一致方式。例如,Facebook维护了一个包含许多不同类型的顶点和边的单一图:顶点代表人、位置、事件、签到和用户发表的评论;边表示哪些人是朋友,哪次签到发生在哪个位置,谁评论了哪篇帖子,谁参加了哪个事件,等等。

在属性图模型中,每个顶点包括:

  • 一个唯一标识符
  • 一组外出边
  • 一组进入边
  • 一组属性(键值对)

每条边包括:

  • 一个唯一标识符
  • 边的起点(尾顶点)
  • 边的终点(头顶点)
  • 用于描述两个顶点之间关系的标签
  • 一组属性(键值对)

你可以将图存储视为包含两个关系表,一个用于顶点,一个用于边

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是为属性图设计的一种声明式查询语言,为Neo4j图形数据库创建

数据的子集,表示为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)

Cypher查询用于查找从美国移民到欧洲的人

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属性

在关系型数据库中,你通常会事先知道你的查询需要哪些连接。在图查询中,你可能需要遍历可变数量的边,才能找到你正在寻找的顶点,也就是说,连接的数量并不是预先固定的

自 SQL:1999 以来,查询中可变长度遍历路径的想法可以使用所谓的递归公共表表达式(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 是在美国出生的所有人的顶点 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;

三元存储模型在很大程度上等同于属性图模型,使用不同的词汇来描述相同的概念。然而,讨论三元存储仍然很有价值,因为有各种工具和语言可以作为构建应用程序的工具箱中的有价值补充。

在三元存储中,所有信息都以非常简单的三部分语句形式存储:(主体, 谓词, 宾语)。例如,在三元组 (Jim, likes, bananas) 中,Jim 是主体,likes 是谓词(动词),而 bananas 是宾语

一个三元组的主体相当于图中的一个顶点。宾语是以下两种情况之一:

  • 原始数据类型中的一个值,比如字符串或数字。在这种情况下,三元组的谓词和宾语相当于主体顶点上的属性的键和值。例如,(lucy, age, 33) 就像是具有属性 {"age":33} 的顶点 lucy。
  • 图中的另一个顶点。在这种情况下,谓词是图中的一条边,主体是尾顶点,宾语是头顶点。例如,在 (lucy, marriedTo, alain) 中,主体和宾语 lucy 和 alain 都是顶点,而谓词 marriedTo 是连接它们的边的标签。

三元存储数据模型完全独立于语义网络——例如,Datomic 是一个三元存储,但并不声称与语义网络有任何关系。但由于在许多人的心目中这两者紧密联系

语义网络的基本理念是简单且合理的:网站已经以文本和图片的形式发布信息供人类阅读,那为什么不也以机器可读的数据形式发布信息供计算机阅读呢?资源描述框架 (RDF) 旨在作为不同网站以一致格式发布数据的机制,允许从不同网站的数据自动组合成一个数据网络——一种互联网范围内的“万物数据库”。到目前为止还没有任何实现的迹象

由于 RDF 设计用于互联网范围内的数据交换,因此它有一些怪癖。一个三元组的主体、谓词和宾语通常是 URIs。例如,一个谓词可能是像 http://my-company.com/namespace#withinhttp://my-company.com/namespace#lives_in 这样的 URI,而不仅仅是 WITHIN 或 LIVES_IN。这种设计背后的推理是,你应该能够将你的数据与他人的数据结合起来,如果他们对 within 或 lives_in 这个词附加了不同的含义,你不会因为他们的谓词实际上是不同的而产生冲突

SPARQL 是一种用于使用 RDF 数据模型的三元存储的查询语言。(它是 SPARQL 协议和 RDF 查询语言的缩写,发音为“sparkle”。)它早于 Cypher,由于 Cypher 的模式匹配借鉴了 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

未完待续