一个复杂的应用程序可能会有更多的中间层次,比如基于 API 的 API,不过基本思想仍然是一样的:每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。
如 SQL、Cypher、SPARQL、Datalog 都是 声明式(declarative)的,只需指定所需数据的模式, 结果必须符合哪些条件以及数据如何转换(排序、分组、聚合),不必说明如何实现这一目标,背后数据库系统的 查询优化器决定。查询引擎隐藏了实现细节。
几十年过去,关系数据仍主导着许多数据管理场景, SQL 在关系模型这个核心之上不断吸收其他数据类型,例如增加了对 XML、JSON 和图数据的支持。
2010 年代,NoSQL 成了试图撼动关系数据库统治地位的最新流行语。NoSQL 并非某项特定技术,而是围绕新数据模型、模式灵活性、可伸缩性和开源许可模式形成的一组宽泛理念。另一些数据库则以 NewSQL 自居,力图在保留传统关系数据库的数据模型和事务保证的同时,提供 NoSQL 系统的可伸缩性。
NoSQL 留下的持久影响,通常以JSON表示数据的 文档模型(document model),最初由 MongoDB、Couchbase 等专用文档数据库推广,如今大多数关系数据库也加入了JSON支持。
数据若存储在关系表中,就需要一个笨拙的转换层,在应用代码中的对象与数据库的表、行、列模型之间来回转换。两种模型之间的这种脱节,有时称为 阻抗不匹配(impedance mismatch) 。
ActiveRecord、Hibernate 等对象关系映射(ORM)框架减少了转换层所需的样板代码,但也时常遭到批评 6。
例如,要获取用户评论列表 还需要显示每条评论的作者信息,手写SQL可能 检索评论表是直接连接用户表,一起返回。ORM 可能傻傻的对 N 条评论逐条查询评论作者信息。
有些 ORM 可以缓存数据库查询结果,减轻数据库负载,协助管理模式迁移如 Prisma。但是有一天 ORM 框架 无人维护停止更新了,给你带来的是灾难。可能有的 ORM 框架还有漏洞,你用的时候可能连源代码都没看过。
例如下面的,一对多关系(one-to-many relationship)
还可以表示为 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 表示则明确呈现了这种树状结构
上面模型中的地区,单独使用了一张表,用户表记录引用地区id。但直接在用户记录中存地区名称字符串也是可行的。
选择存储 ID 还是文本字符串,实质上是在决定是否 规范化(normalization) 。使用 ID 时,数据更加规范化:对人有意义的信息(如 Washington, DC 这段文字)只存储一份,其他地方都用仅在数据库内有意义的 ID 来引用它。若直接存储文本,这段有意义的信息就会复制到每条使用它的记录中;这样的表示便是 反规范化(denormalized) 的。
但是规范化也是有代价的,每次显示含有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"
} }
])规范化数据写入较快(因为只有一个副本),查询却较慢(因为需要连接);反规范化数据通常读取较快(连接更少), 写入代价却更高(需更新更多副本,也占用更多磁盘空间)。
还要考虑进程在更新中途崩溃时,数据库能否保持一致。支持原子事务的数据库更容易维护一致性,但并非所有数据库都能为跨多个文档的操作提供原子性。
中小规模系统通常也更适合规范化数据模型,既不必费心维持多个副本的一致,执行连接的成本也还可以接受。到了超大规模,连接的代价就可能成为问题。
如推特的物化时间线实际上并不保存每条帖子的正文,每个条目只存储帖子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(hydrating IDs) ,本质上就是在应用代码中完成连接。
可伸缩性最好的方案,可能是把一部分数据反规范化,同时让另一部分保持规范化。你必须仔细权衡信息的变化频率与读写成本;而成本又可能由极端情况主导。
规范化与反规范化本身无所谓好坏,不过是在读写性能和实现成本之间作取舍。
下面的用户表和职位表、组织表,体现出了 多对一和多对多的关系。
多对一和多对多关系很难塞进一个字包含的JSON文档,它们更适合规范化表示。
{
"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}
],
...
}
规范化表示只在一处存储关系,再依靠 二级索引 从两个方向高效查询,关系模式中,可以让数据库分别为 position 表的 userid 和 orgid 列建立索引。
文档模型中,数据库则需要索引 position 数组内各对象的 orgid 字段,许多文档数据库以及支持 JSON 的关系数据库,都能为文档内部的值建立这种索引。
数据仓库通常采用关系模型,其表结构有几种广泛使用的惯例:星型模式(star schema)、雪花模式(snowflake schema)、维度建模(dimensional modeling),以及 一张大表(OBT)。
通常会把每项事实记录为独立事件,因为这样能为日后的分析保留最大的灵活性。星型模式,这个名称源自表关系的可视化形状 事实表位于中央,周围环绕着维度表。
星型模式的变体还有 雪花模式,其中的维度会进一步分解成子维度,如让
dim_product 的每一行以外键引用品牌与类别。
雪花模式比星型模式更规范化,但星型模式通常更受青睐,因为分析师使用起来更简单。
有些数据仓库模式更进一步,完全省去维度表,把维度信息放进事实表的反规范化列中。实质上就是预先计算事实表与维度表的连接。这种方法称为 一张大表(OBT);它虽然占用更多存储空间,有时却能让查询更快。
支持文档数据模型的主要论据是模式灵活性、因局部性而拥有更好的性能,以及对于某些应用程序而言,它更接近应用程序使用的对象模型。
关系模型则以更好地支持连接、多对一和多对多关系作为回应。
应用程序中的数据具有类似文档的结构(即一对多关系树,通常一次性加载整棵树),那么使用文档模型可能是个好主意。
有些应用允许用户自行安排项目排序,如待办清单或问题跟踪器中,用户通过拖拽任务来重新排序,文档模型很适合这种,只需把项目或项目ID按顺序存入JSON数组即可。关系数据库没有表示这种可重新排序列表的标准方式,只能利用各种技巧。
大多数文档数据库以及关系数据库中的 JSON 支持,都不会强制文档中的数据采用何种模式。关系数据库的 XML 支持通常带有可选的模式验证。没有模式意味着可以向文档中添加任意键和值;读取时,客户端也无法确定文档究竟会包含哪些字段。
文档数据库有时称为 无模式(schemaless) ,但这具有误导性,因为读取数据的代码通常假定某种结构。即存在隐式模式,只是不由数据库强制执行。
当应用程序想改变数据格式时,如文档数据库和关系型数据库可能分别这么搞:
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文档通常以单个连续字符串的形式存储,编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON)。如果应用程序经常需要访问整个文档(例如把它渲染到网页上),这种 存储局部性 会带来性能优势。
关系型数据库分散在多张表中,就需要多次查找索引才能检索完整,可能产生更多磁盘寻道并花费更长时间。
局部性优势适用于同时需要修改文档大部分内容,即使只访问大型文档的一小部分,数据库通常也要加载整个文档,更新时一般还要重写整个文档。
关系型数据库通常用SQL,文档数据库则五花八门。
假设每当发现一种动物就向数据库添加一条观察记录,现在生成报告,每个月观察到多少条鲨鱼。
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;MongoDB 表示
db.observations.aggregate([
{ $match: { family: "Sharks" } },
{ $group: {
_id: {
year: { $year: "$observationTimestamp" },
month: { $month: "$observationTimestamp" }
},
totalAnimals: { $sum: "$numAnimals" }
} }
]);文档数据库和关系数据库最初采用截然不同的数据管理方法,随着时间推移,两者越来越相似。
关系数据库增加了对 JSON 类型和查询算子的支持,也能够为文档内部的属性建立索引;MongoDB、Couchbase、RethinkDB 等文档数据库,则增加了连接、二级索引和声明式查询语言。
一个图由两种对象组成:顶点(vertices,也称为 节点,即 nodes,或 实体,即 entities)和 边(edges,也称为 关系,即 relationships,或 弧,即 arcs)。多种数据都可以建模为图,典型的例子包括:
学过数据结构都知道,图可以使用 邻接表(adjacency list)、邻接矩阵(adjacency matrix)表示,邻接表适合图便利, 邻接矩阵适合机器学习。
属性图 property graph 模型,Neo4j、Memgraph 等。三元组存储 triple store 模型,Datomic、Blazegraph 等。
四种图查询语言(Cypher、SPARQL、Datalog 和 GraphQL),以及 SQL 对图查询的支持。
图中有两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已经结婚,现居伦敦。每个人和每个地点都表示为顶点,彼此之间的关系则表示为边。
把图存储看成两个关系表:一张存储顶点,另一张存储边
使用关系模式表示属性图
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);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..]-> (:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (:Location {name:'Europe'})
RETURN person.name可以在关系数据库中表示图数据,但用 SQL 查询它就很复杂。
使用递归公用表表达式,以 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;同一个查询用 Cypher 只需 4 行,用 SQL 却要写 31 行,这恰恰说明选对数据模型和查询语言会带来多大差别。而这还只是开始;还有更多细节需要考虑,例如如何处理环,以及选择广度优先还是深度优先遍历。
三元组存储模型大体上与属性图模型相同,只是用不同的词汇描述同样的思想。
在三元组存储中,所有信息都以非常简单的三部分陈述来存储:(主语、谓语、宾语)。例如,在三元组(Jim、喜欢、香蕉)中,Jim 是主语,喜欢 是谓语(动词),香蕉 是宾语。
三元组的主语相当于图中的一个顶点,宾语则是以下两者之一:
{"birthYear": 1989}。部分数据,以 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".简洁写法
@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".使用的 Turtle 语言,实际上是对 资源描述框架(RDF,Resource Description Framework)数据进行编码的一种方式;
RDF 是专为 语义网(Semantic Web)设计的数据模型。RDF 数据也可以采用其他编码,例如用更为冗长的 XML 表示。Apache Jena 等工具可以在不同 RDF 编码之间自动转换。
RDF XML 语法表示示例
<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>SPARQL 是一种面向 RDF 数据模型的三元组存储查询语言。(它是 SPARQL Protocol and RDF Query Language 的缩写,读作 “sparkle”。)SPARQL 早于 Cypher;
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. # SPARQLAmazon Neptune、AllegroGraph、Blazegraph、OpenLink Virtuoso、Apache Jena 以及其他多种三元组存储都支持 SPARQL。
Datalog 是比 SPARQL 和 Cypher 更古老的语言。
Datalog 实际上基于关系数据模型,而不是图模型;Datalog 尤其擅长对图进行递归查询。
数据的子集,表示为 Datalog 事实
location(1, "North America", "continent").
location(2, "United States", "country").
location(3, "Idaho", "state").
within(2, 1). /* 美国在北美 */
within(3, 2). /* 爱达荷州在美国 */
person(100, "Lucy").
born_in(100, 3). /* Lucy 出生在爱达荷州 */Datalog 是 Prolog 的一个子集。
用Datalog表示查询语句
within_recursive(LocID, PlaceName) :- location(LocID, PlaceName, _). /* 规则 1 */
within_recursive(LocID, PlaceName) :- within(LocID, ViaID), /* 规则 2 */
within_recursive(ViaID, PlaceName).
migrated(PName, BornIn, LivingIn) :- person(PersonID, PName), /* 规则 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"). /* 规则 4 */
/* us_to_europe 包含行 "Lucy"。 */了解一下就行了,这些东西99%此生用不倒。
GraphQL 的用途,是让运行在用户设备上的客户端软件(例如移动应用或 JavaScript Web 应用的前端)请求一份特定结构的 JSON 文档,其中恰好包含渲染用户界面所需的字段。借助 GraphQL 接口,开发者可以迅速修改客户端代码中的查询,而无须改动服务端 API。
采用 GraphQL 的组织通常需要一套工具,把 GraphQL 查询转换成对内部服务的请求,而这些内部服务往往使用 REST 或 gRPC。
此外还要应对授权、限流和性能等问题。
群聊应用的 GraphQL 查询示例
query ChatApp {
channels {
name
recentMessages(latest: 50) {
timestamp
content
sender {
fullName
imageUrl
}
replyTo {
content
sender {
fullName
}
}
}
}
}上面查询的一种可能响应
{
"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"}
}
},
...数据都以写入时的形式接受查询——无论它是 JSON 文档、表中的行,还是图中的顶点和边。然而在复杂应用中,有时很难找到一种数据表示,能够满足所有查询和展现数据的需求。在这种情况下,可以用一种形式写入数据,再从中派生出多种针对不同读取方式优化的表示。
写入数据最简单、最快且表意最清楚的方式,就是写入 事件日志(event log):每次写入数据时,都将它编码成一个自包含的字符串(也许是 JSON),其中带有时间戳,再追加到事件序列中。日志中的事件是 不可变的(immutable):你永远不会修改或删除它们,只会向日志追加更多事件(后来的事件可以取代早先事件的效力)。事件可以包含任意属性。
以事件作为权威数据源,并把每次状态变化都表达为事件,这种思路称为 事件溯源(event sourcing)。维护独立的读取优化表示,并从写入优化的表示中派生它们,这种原则称为 命令查询责任分离(Command Query Responsibility Segregation,CQRS)。
事件溯源可以构建在任何数据库之上,不过也有一些系统是专为这种模式设计的,例如 EventStoreDB、MartenDB(基于 PostgreSQL)和 Axon Framework。也可以使用 Apache Kafka 之类的消息代理来存储事件日志,并通过流处理器使物化视图保持最新;
上面数据模型,通常即用于事务处理,也用于分析。
还有一些数据模型常见于分析或科学场景,却很少出现在 OLTP 系统中:数据框(dataframe),以及矩阵等多维数值数组。
R 语言、Python 的 pandas 库、Apache Spark、ArcticDB 和 Dask 等系统,都支持数据框这种数据模型。数据科学家经常用它为训练机器学习模型准备数据;它也广泛用于数据探索、统计分析和数据可视化等场景。