二. 数据模型与查询语言

多数应用程序使用层层叠加的数据模型构建,对于每层数据模型的关键问题是:它是如何用低一层的数据模型来表示的

关系模型与文档模型

数据被组织成关系,其中每个关系是元组(行)的无序集合,关系型模型致力于将数据库内部的数据表示的细节隐藏在更简洁的接口之后

NoSQL背后的几个驱动因素

  • 需要比关系型数据库更好的可伸缩性,包括非常大的数据集或非常高的写入吞吐量
  • 相比商业数据库,免费和开源软件更受偏爱
  • 关系模型不能很好地支持一些特殊的查询操作
  • 渴望一种,不受限于关系模型的,根据多动态性与表现力的数据模型

混合持久化:不同应用程序有不同的需求,一个用例的最佳技术选择可能不同于另一个用例的最佳技术选择,关系数据库与各种非关系数据库可能会一起使用

对象关系不匹配

  • 大多数应用程序都使用面向对象的编程语言来开发,如果数据存储在关系表中,那么需要一个笨拙的转换层,处于对象和表、行、列的数据库模型之间,这样模型之间的不连贯有时被称为阻抗不匹配
  • ORM可以减少这个转换层所需的样板代码的数量,但是不嫩个完全隐藏这两个模型之间的差异

image-20230804215232477

一对多关系

image-20230804215304669

  • JSON比多表的关系模式具有更好的局部性,JSON表示中所有相关信息都在同一个地方,一次查询就足够了,而多表关系模式需要执行多个查询,通过外键查询每个表

多对一和一对多关系

region_idindustry_id 是以 ID,而不是纯字符串 “Greater Seattle Area” 和 “Philanthropy” 的形式给出的。

如果用户界面用一个自由文本字段来输入区域和行业,那么将他们存储为纯文本字符串是合理的。另一种方式是给出地理区域和行业的标准化的列表,并让用户从下拉列表或自动填充器中进行选择,其优势如下:

  • 各个简介之间样式和拼写统一
  • 避免歧义(例如,如果有几个同名的城市)
  • 易于更新 —— 名称只存储在一个地方,如果需要更改(例如,由于政治事件而改变城市名称),很容易进行全面更新。
  • 本地化支持 —— 当网站翻译成其他语言时,标准化的列表可以被本地化,使得地区和行业可以使用用户的语言来显示
  • 更好的搜索 —— 例如,搜索华盛顿州的慈善家就会匹配这份简介,因为地区列表可以编码记录西雅图在华盛顿这一事实(从 “Greater Seattle Area” 这个字符串中看不出来)

使用ID的好处是,ID对人类没有任何意义,因而永远都不需要改变。任何对人类有意义的东西都可能需要在将来的某个时候发生改变,如果这些信息被复制,所哟荣誉副本都需要更新。这会导致写入开销,也存在不一致的风险。去除此类重复是数据库规范化的关键思想。

对这些数据库进行规范化需要多对一的关系,这与文档模型不太吻合,文档数据库中,一对多树结构没有必要使用连接,对连接的支持通常很弱。如果数据库本身不支持连接,则必须在应用程序代码中通过对数据库进行多个查询来模拟连接。

网状模型

  • 通过访问路径访问记录
  • 没有所需数据的路径,就会陷入困境
  • 改变访问路径困难,更改应用程序的数据模型是很难的

关系模型

  • 查询优化器自动决定查询部分以哪个顺序执行,想按新的方式查询数据可以尝试声明一个新的索引,关系模型使天机器应用程序新功能变得容易

对比

  • 文档模型的架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构
  • 应用程序中数据具有类似文档的结构,即一对多的子树关系时适合使用文档模型
  • 文档模型不能直接引用文档中的嵌套项目,而是需要说 “用户 251 的位置列表中的第二项”,但是,只要文件嵌套不太深,这通常不是问题。
  • 文档模型对连接的支持比较糟糕,需要多对多关系时不太方便,所以不适用与高关联的数据

文档数据库中的模式灵活性

  • 有时称为无模式,但实际上存在隐式模式,但不由数据库强制执行,一个更精确的描述是读时模式

读时模式:即 schema-on-read,数据的结构是隐含的,只有在数据被读取时才被解释

假设你把每个用户的全名存储在一个字段中,而现在想分别存储名字和姓氏【23】。在文档数据库中,只需开始写入具有新字段的新文档,并在应用程序中使用代码来处理读取旧文档的情况。

1
2
3
4
if (user && user.name && !user.first_name) {
// Documents written before Dec 8, 2013 don't have first_name
user.first_name = user.name.split(" ")[0];
}

写时模式:即 schema-on-write,传统的关系数据库方法中,模式明确,且数据库确保所有的数据都符合其模式

上面案例中,如果在“静态类型”数据库模式中,通常会执行迁移,进行模式变更。如果是一个大型表,模式变更的速度可能会很慢。

读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。就像静态和动态类型检查的相对优点具有很大的争议性一样【22】,数据库中模式的强制性是一个具有争议的话题,一般来说没有正确或错误的答案。

查询的数据局部性

  • 局部性可以减少查询次数,一次将所有文档检索出来
  • 局部性仅仅适用于同时需要文档绝大部分的内容的情况,数据库通常需要加载整个文档,即使只访问其中一小部分,这对于大型文档来说是浪费。更新文档是通常需要整个重写,只有不改变文档大小的修改才容易原地执行。因此通常建议保持相对较小的文档,并避免增加文档大小的写入

文档和关系数据库的融合:关系模型和文档模型逐渐拥有互相的特点

数据查询语言

命令式语言告诉计算机以特定顺序执行,某些操作。

  • SQL

声明式语言中,你只需要指定所需数据的模式-结果必须符合哪些条件,以及如何将数据转换,但不用指定如何实现这一目标。

  • CSS
  • 聚合管道

处于两者之间的查询

  • MapReduce查询

声明式语言更适合并行执行,因为它仅指定结果的模式,而不指定用于确定结果的算法,在适当情况下,数据库可以自由使用查询语言的并行实现

命令代码很难在多个核心和多个机器之间并行化,因为它指定了指令必须以特定顺序执行

图数据库

image-20230804215332957

适用于多对多关系,多种数据可以被建模为一个图形,典型例子包括:

  • 社交图谱
  • 网络图谱
  • 公路或铁路网络

属性图

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

  • 唯一的标识符
  • 一组出边(outgoing edges)
  • 一组入边(ingoing edges)
  • 一组属性(键值对)

每条边(edge)包括:

  • 唯一标识符
  • 边的起点(尾部顶点,即 tail vertex)
  • 边的终点(头部顶点,即 head vertex)
  • 描述两个顶点之间关系类型的标签
  • 一组属性(键值对)

可以将图存储看作由两个关系表组成,一个存储顶点,另一个存储边

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);

Cypher查询语言

(声明式查询语言)

1
2
3
4
5
6
7
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)
  • 每个顶点都有一个像 USAIdaho 这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:(Idaho) - [:WITHIN] ->(USA) 创建一条标记为 WITHIN 的边,Idaho 为尾节点,USA 为头节点
1
2
3
4
MATCH
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
RETURN person.name

例:查找所有从美国移民到欧洲的人的Cypher查询

找到满足以下两个条件的所有顶点(称之为 person 顶点):

  1. person 顶点拥有一条到某个顶点的 BORN_IN 出边。从那个顶点开始,沿着一系列 WITHIN 出边最终到达一个类型为 Locationname 属性为 United States 的顶点。
  2. person 顶点还拥有一条 LIVES_IN 出边。沿着这条边,可以通过一系列 WITHIN 出边最终到达一个类型为 Locationname 属性为 Europe 的顶点。

对于这样的 Person 顶点,返回其 name 属性。

WITHIN*0 :沿着 WITHIN 边,零次或多次

SQL中的图查询

SQL中可以实现图查询,但语法非常的笨拙,不同数据模型是为了不同的场景而设计的,选择不同的数据模型非常重要

三元存储和SPARQL

信息以简单的三部分组成:(主语,谓语,宾语)

三元组的主语相当于图中的一个顶点,宾语是下面两者之一:

  1. 原始数据类型中的值,例如字符串或数字。在这种情况下,三元组的谓语和宾语相当于主语顶点上的属性的键和值。例如,(lucy, age, 33) 就像属性 {“age”:33} 的顶点 lucy。
  2. 图中的另一个顶点。在这种情况下,谓语是图中的一条边,主语是其尾部顶点,而宾语是其头部顶点。例如,在 (lucy, marriedTo, alain) 中主语和宾语 lucyalain 都是顶点,并且谓语 marriedTo 是连接他们的边的标签。

SPARQL的一个例子:

1
2
3
4
5
6
PREFIX : <urn:example:>
SELECT ?personName WHERE {
?person :name ?personName.
?person :bornIn / :within* / :name "United States".
?person :livesIn / :within* / :name "Europe".
}

Datalog

1
2
3
4
5
6
7
8
9
10
11
12
13
name(namerica, 'North America').
type(namerica, continent).

name(usa, 'United States').
type(usa, country).
within(usa, namerica).

name(idaho, 'Idaho').
type(idaho, state).
within(idaho, usa).

name(lucy, 'Lucy').
born_in(lucy, idaho).

总结

层次模型不利于表示多对多的关系,所以发明了关系模型

一些应用程序不适合采用关系模型,出现新型非关系型“NoSQL”,主要分为两个方向

  1. 文档数据库 主要关注自我包含的数据文档,而且文档之间的关系非常稀少。
  2. 图形数据库 用于相反的场景:任意事物之间都可能存在潜在的关联。

关系、文档、图形三种模型在今天都被广泛应用。一个模型可以用另一个模型来模拟,但结果往往是糟糕的,所以我们需要针对不同目的使用不同的系统。

文档数据库和图数据库有一个共同点,那就是它们通常不会将存储的数据强制约束为特定模式,这可以使应用程序更容易适应不断变化的需求。但是应用程序很可能仍会假定数据具有一定的结构;区别仅在于模式是明确的(写入时强制)还是隐含的(读取时处理)。

一些没有提到的数据模型:

  • 使用基因组数据的研究人员通常需要执行 序列相似性搜索,这意味着需要一个很长的字符串(代表一个 DNA 序列),并在一个拥有类似但不完全相同的字符串的大型数据库中寻找匹配。这里所描述的数据库都不能处理这种用法,这就是为什么研究人员编写了像 GenBank 这样的专门的基因组数据库软件的原因。
  • 粒子物理学家数十年来一直在进行大数据类型的大规模数据分析,像大型强子对撞机(LHC)这样的项目现在会处理数百 PB 的数据!在这样的规模下,需要定制解决方案来阻止硬件成本的失控。
  • 全文搜索 可以说是一种经常与数据库一起使用的数据模型。信息检索是一个很大的专业课题,我们不会在本书中详细介绍,但是我们将在第三章和第三部分中介绍搜索索引。