polish ch1 - ch4

This commit is contained in:
Feng Ruohang 2025-08-10 19:32:31 +08:00
parent d6eb35ba92
commit 59cab3178f
8 changed files with 890 additions and 904 deletions

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ public/
.hugo_build.lock
.claude
CLAUDE.md
content/cn/
zh.md
en.md

View File

@ -1,7 +1,7 @@
# 设计数据密集型应用(第二版) - 中文翻译版
[![Webite: ddia](https://img.shields.io/badge/在线阅读-v2-slategray?style=flat)](https://ddia.vonng.com)
[![Webite: ddia](https://img.shields.io/badge/在线阅读-v1-slategray?style=flat)](https://ddia.vonng.com/v1)
[![Webite: ddia](https://img.shields.io/badge/在线阅读-第二版-slategray?style=flat)](https://ddia.vonng.com)
[![Webite: ddia](https://img.shields.io/badge/在线阅读-第一版-slategray?style=flat)](https://ddia.vonng.com/v1)
[![GitHub Stars](https://img.shields.io/github/stars/Vonng/ddia?style=flat&logo=github&logoColor=black&color=slategray)](https://star-history.com/#Vonng/ddia&Date)
**作者** [Martin Kleppmann](https://martin.kleppmann.com)[《Designing Data-Intensive Applications 2nd Edition》](https://learning.oreilly.com/library/view/designing-data-intensive-applications/9781098119058/ch01.html) 英国剑桥大学分布式系统研究员,演讲者,博主和开源贡献者,软件工程师和企业家,曾在 LinkedIn 和 Rapportive 负责数据基础架构。
@ -13,7 +13,7 @@
**阅读**:访问 [https://ddia.vonng.com](https://ddia.vonng.com) 阅读本书在线版本,或使用 [hugo](https://gohugo.io/documentation/) / [hextra](https://imfing.github.io/hextra/zh-cn/) 主题自行构建。
> [!NOTE]
> **通知**DDIA [**第二版**](https://ddia.vonng.com) 预览版正在翻译中 ([`main`](https://github.com/Vonng/ddia/tree/main) 分支),欢迎加入并提出您的宝贵意见![点击此处阅览第一版](https://ddia.vonng.com/v1)。
> [**DDIA 第二版**](https://ddia.vonng.com) 正在翻译中(翻译至至第十章),欢迎阅览并提出您的宝贵意见![点击此处阅览第一版](https://ddia.vonng.com/v1)。
---------
@ -66,13 +66,14 @@
- [8. 事务](https://ddia.vonng.com/ch8)
- [9. 分布式系统的麻烦](https://ddia.vonng.com/ch9)
- [10.一致性与共识](https://ddia.vonng.com/ch10)
* [第三部分:派生数据](https://ddia.vonng.com/part-iii
* [第三部分:派生数据](https://ddia.vonng.com/part-iii)
- [11. 批处理](https://ddia.vonng.com/ch11) (尚未发布)
- [12. 流处理](https://ddia.vonng.com/ch12) (尚未发布)
- [13. 做正确的事](https://ddia.vonng.com/ch13) (尚未发布)
- [13. 做正确的事](https://ddia.vonng.com/ch13)(尚未发布)
* [术语表](https://ddia.vonng.com/glossary)
* [后记](https://ddia.vonng.com/colophon)
![](static/title.jpg)
---------
@ -85,7 +86,9 @@
译者保留对此版本译文的署名权,其他权利以原作者和出版社的主张为准。
本译文只供学习研究参考之用,不得公开传播发行或用于商业用途。有能力阅读英文书籍者请购买正版支持。
本译文只供学习研究参考之用,不得公开发行或用于商业用途,有能力阅读英文书籍者请购买正版支持,本书英文原版在 [O'REILLY](https://learning.oreilly.com/api/v1/continue/9781098119058/) 平台上提供在线免费试预览。
---------

View File

@ -4,36 +4,41 @@ weight: 101
breadcrumbs: false
---
> *没有解决方案,只有利弊权衡。[…] 但你要努力获得最好的权衡,这就是你所能期望的全部。*
> *没有完美的解决方案,只有权衡取舍。[…] 你能做的就是努力获得最佳的权衡,这就是你所能期望的一切。*
>
> [Thomas Sowell](https://www.youtube.com/watch?v=2YUtKr8-_Fg),接受 Fred Barnes 采访2005
数据是当今许多应用程序开发的核心。随着 Web 和移动应用、软件即服务SaaS以及云服务的出现将来自许多不同用户的数据存储在共享的基于服务器的数据基础设施中已经变得很正常。来自用户活动、业务交易、设备和传感器的数据需要被存储并可供分析使用。当用户与应用程序交互时他们既读取存储的数据也生成更多的数据。
> [!TIP] 早期读者注意事项
> 通过早期发布电子书,您可以在书籍最早期的形式中获得内容——作者在撰写时的原始和未经编辑的内容——以便您可以在这些技术正式发布之前就充分利用它们。
>
> 这将是最终书籍的第 1 章。本书的 GitHub 仓库是 https://github.com/ept/ddia2-feedback。
> 如果您想积极参与审阅和评论本草稿,请在 GitHub 上联系我们。
少量数据可以在单台机器上存储和处理,通常相当容易处理。然而,随着数据量或查询速率的增长,需要将其分布在多台机器上,这带来了许多挑战。随着应用程序需求变得更加复杂,仅将所有内容存储在一个系统中已经不够,可能需要组合多个提供不同功能的存储或处理系统。
数据是当今许多应用程序开发的核心。随着 Web 和移动应用、软件即服务SaaS以及云服务的兴起将来自不同用户的数据存储在共享的基于服务器的数据基础设施中已成为常态。来自用户活动、业务交易、设备和传感器的数据需要被存储并可供分析使用。当用户与应用程序交互时他们既读取已存储的数据也生成更多的数据
如果数据管理是开发应用程序的主要挑战之一,我们称该应用程序为 **数据密集型**data-intensive[^1]。
虽然在 **计算密集型**compute-intensive系统中的挑战是并行化某些非常大的计算但在数据密集型应用中我们通常更担心诸如存储和处理大数据量、管理数据变更、确保在故障和并发情况下的一致性以及确保服务高可用等问题。
少量的数据可以存储和处理在单台机器上,通常相当容易处理。然而,随着数据量或查询速率的增长,数据需要分布在多台机器上,这带来了许多挑战。随着应用程序需求变得更加复杂,将所有内容存储在一个系统中已经不够,可能需要组合多个提供不同能力的存储或处理系统。
如果数据管理是开发应用程序的主要挑战之一,我们就称应用程序为 **数据密集型data-intensive** 的 [^1]。虽然在 **计算密集型compute-intensive** 系统中,挑战是并行化某些非常大规模的计算,但在数据密集型应用中,我们通常更关心诸如存储和处理大量数据、管理数据变更、在面对故障和并发时确保一致性,以及确保服务高可用等问题。
这些应用程序通常由提供常用功能的标准构建块构建而成。例如,许多应用程序需要:
* 存储数据,以便它们或另一个应用程序以后可以再次找到它**数据库**
* 存储数据,以便它们或其他应用程序以后能再次找到**数据库**
* 记住昂贵操作的结果,以加快读取速度(**缓存**
* 允许用户按关键字搜索数据或以各种方式过滤数据(**搜索索引**
* 处理事件和数据变更**流处理**
* 定期处理大量累积的数据(**批处理**
* 一旦事件和数据变更发生就立即处理**流处理**
* 定期处理累积的大量数据(**批处理**
在构建应用程序时,我们通常会采用几个软件系统或服务,例如数据库或 API并用一些应用程序代码将它们粘合在一起。如果你正在做这些数据系统设计的目标,那么这个过程可能会很容易。
在构建应用程序时,我们通常会采用几个软件系统或服务,例如数据库或 API并用一些应用程序代码将它们粘合在一起。如果你正在做数据系统设计的工作,那么这个过程可能会相当容易。
然而,随着你的应用程序变得更加雄心勃勃,挑战就会出现。有许多具有不同特性的数据库系统,适合不同的目的——你如何选择使用哪一个?有各种缓存方法、构建搜索索引的几种方法等等——你如何权衡它们的利弊?你需要找出哪些工具和哪些方法最适合手头的任务,当需要做单个工具无法单独完成的事情时,组合工具可能会很困难。
然而,随着你的应用程序变得更加雄心勃勃,挑战就会出现。有许多具有不同特性的数据库系统,适合不同的目的——你如何选择使用哪一个?有各种缓存方法、构建搜索索引的几种方式等等——你如何推理它们的权衡?你需要找出哪些工具和哪些方法最适合手头的任务,当需要做单个工具无法单独完成的事情时,组合工具可能会很困难。
本书是一指南,帮助你决定使用哪些技术以及如何组合它们。正如你将看到的,没有一种方法从根本上优于其他方法;每种方法都有利弊。通过本书,你将学会提出正确的问题来评估和比较数据系统,这样你就能找出哪种方法最能满足你特定应用程序的需求。
本书是一指南,帮助你决定使用哪些技术以及如何组合它们。正如你将看到的,没有一种方法从根本上优于其他方法;一切都有利弊。通过本书,你将学会提出正确的问题来评估和比较数据系统,以便你能找出哪种方法最能满足你特定应用程序的需求。
我们将通过查看当今组织中数据的一些典型使用方式来开始我们的旅程。这里的许多想法起源于 **企业软件**(即大型组织(如大公司和政府)的软件需求和工程实践),因为历史上只有大型组织拥有需要复杂技术解决方案的大数据量。如果你的数据量足够小,你可以简单地将其保存在电子表格中!然而,最近小公司和初创企业管理大数据量并构建数据密集型系统也变得很常见。
我们将通过观察当今组织中数据的一些典型使用方式来开始我们的旅程。这里的许多想法起源于 **企业软件**(即大型组织(如大公司和政府)的软件需求和工程实践),因为历史上只有大型组织拥有需要复杂技术解决方案的大数据量。如果你的数据量足够小,你可以简单地将其保存在电子表格中!然而,最近小公司和初创公司管理大数据量并构建数据密集型系统也变得很常见。
数据系统的关键挑战之一是不同的人需要用数据做非常不同的事情。如果你在一家公司工作,你和你的团队会有一套优先事项,而另一个团队可能有完全不同的目标,即使你们可能使用相同的数据集!此外,这些目标可能没有明确表达,这可能导致关于正确方法的误解和分歧。
数据系统的关键挑战之一是不同的人需要用数据做非常不同的事情。如果你在一家公司工作,你和你的团队将有一套优先事项,而另一个团队可能有完全不同的目标,即使你们可能在处理相同的数据集!此外,这些目标可能没有明确阐述,这可能导致对正确方法的误解和分歧。
为了帮助你解可以做出哪些选择,本章比较了几个对比概念,并探讨了它们的权衡:
为了帮助你解可以做出哪些选择,本章比较了几个对比概念,并探讨了它们的权衡:
* 事务型系统和分析型系统之间的区别(["分析型与事务型系统"](/ch1#sec_introduction_analytics)
* 云服务和自托管系统的利弊(["云服务与自托管"](/ch1#sec_introduction_cloud)
@ -44,50 +49,48 @@ breadcrumbs: false
> [!TIP] 术语:前端和后端
本书中我们将讨论的大部分内容都与 **后端开发** 有关。为了解释这个术语:对于 Web 应用程序,在 Web 浏览器中运行的客户端代码称为 **前端**处理用户请求的服务器端代码称为 **后端**。移动应用类似于前端,因为它们提供用户界面,通常通过互联网与服务器端后端通信。前端有时在用户设备上本地管理数据 [^2],但最大的数据基础设施挑战通常在于后端:前端只需要处理一个用户的数据,而后端代表 **所有** 用户管理数据。
本书中我们将讨论的大部分内容都与 **后端开发** 有关。为了解释这个术语:对于 Web 应用程序,在 Web 浏览器中运行的客户端代码称为 **前端**,处理用户请求的服务器端代码称为 **后端**。移动应用类似于前端,它们提供用户界面,通常通过互联网与服务器端后端通信。前端有时在用户设备上本地管理数据 [^2],但最大的数据基础设施挑战通常在于后端:前端只需要处理一个用户的数据,而后端代表 **所有** 用户管理数据。
后端服务通常可通过 HTTP有时是 WebSocket访问它通常由一些应用程序代码组成这些代码在一个或多个数据库中读写数据有时还与其他数据系统如缓存或消息队列接口我们可能统称为 **数据基础设施**)。应用程序代码通常是 **无状态的**(即,当它完成处理一个 HTTP 请求时,它会忘记关于该请求的所有内容),任何需要从一个请求持续到另一个请求的信息都需要存储在客户端或服务器端数据基础设施中。
后端服务通常可通过 HTTP有时是 WebSocket访问它通常由一些应用程序代码组成这些代码在一个或多个数据库中读取和数据,有时还与其他数据系统(如缓存或消息队列)接口(我们可能将其统称为 **数据基础设施**)。应用程序代码通常是 **无状态的**(即,当它完成处理一个 HTTP 请求时,它会忘记关于该请求的所有内容),任何需要从一个请求持续到另一个请求的信息都需要存储在客户端或服务器端数据基础设施中。
## 分析型与事务型系统 {#sec_introduction_analytics}
如果你在企业中从事数据系统工作,你可能会遇到几种不同类型的数据工作人员。第一类是构建处理读取和更新数据请求的服务的 **后端工程师**;这些服务通常直接或通过其他服务间接地为外部用户提供服务(参见["微服务和无服务器"](/ch1#sec_introduction_microservices))。有时服务是供组织其他部分内部使用的。
如果你在企业中从事数据系统工作,你可能会遇到几种不同类型的数据工作者。第一类是 **后端工程师**,他们构建处理读取和更新数据请求的服务;这些服务通常直接或间接地通过其他服务为外部用户提供服务(参见["微服务与 Serverless"](/ch1#sec_introduction_microservices))。有时服务是供组织其他部分内部使用的。
除了管理后端服务的团队外,通常还有另外两组人需要访问组织的数据:**业务分析师**,他们生成关组织活动的报告,以帮助管理层做出更好的决策(**商业智能** 或 **BI**以及 **数据科学家**,他们在数据中寻找新的见解或创建由数据分析和机器学习/AI 支持的面向用户的产品功能(例如,电子商务网站上的"购买了 X 的人也购买了 Y"推荐、预测分析如风险评分或垃圾邮件过滤,以及搜索结果排名)。
除了管理后端服务的团队外,通常还有两类人需要访问组织的数据:**业务分析师**,他们生成关组织活动的报告,以帮助管理层做出更好的决策(**商业智能** 或 **BI**以及 **数据科学家**,他们在数据中寻找新的见解或创建由数据分析和机器学习/AI 支持的面向用户的产品功能(例如,电子商务网站上的"购买了 X 的人也购买了 Y"推荐、风险评分或垃圾邮件过滤等预测分析,以及搜索结果排名)。
虽然业务分析师和数据科学家倾向于使用不同的工具并以不同的方式操作,但他们有一些共同点:两者都执行 **分析**,这意味着他们查看用户和后端服务生成的数据,但他们通常不修改这些数据(除了可能修复错误)。他们可能会创建派生数据集,其中原始数据已经以某种方式处理过。这导致了两种系统类型之间的分离——这种区别将贯穿本书
尽管业务分析师和数据科学家倾向于使用不同的工具并以不同的方式操作,但他们有一些共同点:两者都执行 **分析**,这意味着他们查看用户和后端服务生成的数据,但他们通常不修改这些数据(除了可能修复错误)。他们可能创建衍生数据集,其中原始数据已经以某种方式处理过。这导致了两种类型系统之间的分离——我们将在本书中使用这种区别:
* **事务型系统** 由后端服务和数据基础设施组成,在这里创建数据,例如通过为外部用户提供服务。在这里,应用程序代码基于用户执行的操作读取和修改其数据库中的数据。
* **事务型系统** 由后端服务和数据基础设施组成,在这里创建数据,例如通过服务外部用户。在这里,应用程序代码基于用户执行的操作读取和修改其数据库中的数据。
* **分析型系统** 服务于业务分析师和数据科学家的需求。它们包含来自事务型系统的只读数据副本,并针对分析所需的数据处理类型进行了优化。
正如我们将在下一节看到的,事务型和分析型系统通常由于充分的理由而保持分离。随着这些系统的成熟,出现了两个新的专门角色:**数据工程师** 和 **分析工程师**。数据工程师是知道如何集成事务型和分析型系统的人,并且更广泛地负责组织的数据基础设施 [^3]。
分析工程师对数据进行建模和转换,使其对组织中的业务分析师和数据科学家更有用 [^4]。
正如我们将在下一节中看到的,事务型系统和分析型系统通常出于充分的理由而保持分离。随着这些系统的成熟,出现了两个新的专业角色:**数据工程师** 和 **分析工程师**。数据工程师是知道如何集成事务型系统和分析型系统的人,并更广泛地负责组织的数据基础设施 [^3]。分析工程师对数据进行建模和转换,使其对组织中的业务分析师和数据科学家更有用 [^4]。
许多工程师专门从事事务型或分析型方面。然而,本书涵盖了事务型和分析型数据系统,因为两者在组织内数据的生命周期中都发挥着重要作用。我们将深入探讨用于向内部和外部用户提供服务的数据基础设施,以便你能更好地与分界线另一边的同事合作。
许多工程师专注于事务型或分析型方面。然而,本书涵盖了事务型和分析型数据系统,因为两者在组织内数据生命周期中都扮演着重要角色。我们将深入探讨用于向内部和外部用户提供服务的数据基础设施,以便你能更好地与这一分界线另一边的同事合作。
### 事务处理与分析的特征 {#sec_introduction_oltp}
在商业数据处理的早期,对数据库的写入通常对应于正在发生的 **商业交易**:进行销售、向供应商下订单、支付员工工资等。随着数据库扩展到不涉及金交换的领域,术语 **事务** 仍然保留了下来,指的是形成逻辑单元的一组读写操作
在商业数据处理的早期,对数据库的写入通常对应于发生的 **商业交易**:进行销售、向供应商下订单、支付员工工资等。随着数据库扩展到不涉及金交换的领域,**事务** 这个术语仍然保留了下来,指的是形成逻辑单元的一组读取和写入
> [!NOTE]
> [第 8 章](/ch8#ch_transactions) 详细探讨了我们所说的事务的含义。本章宽泛地使用该术语来指代低延迟的读写操作
> [第 8 章](/ch8#ch_transactions) 详细探讨了我们所说的事务的含义。本章松散地使用该术语来指代低延迟的读取和写入
尽管数据库开始用于许多不同类型的数据——社交媒体上的帖子、游戏中的动、地址簿中的联系人等等——基本访问模式仍然类似于处理商业交易。事务型系统通常通过某个键查找少量记录(这称为 **点查询**)。基于用户的输入插入、更新或删除记录。因为这些应用程序是交互式的,这种访问模式被称为 **联机事务处理**OLTP
尽管数据库开始用于许多不同类型的数据——社交媒体上的帖子、游戏中的动、地址簿中的联系人等等——基本访问模式仍然类似于处理商业交易。事务型系统通常通过某个键查找少量记录(这称为 **点查询**)。基于用户的输入插入、更新或删除记录。因为这些应用程序是交互式的,这种访问模式被称为 **联机事务处理**OLTP
然而,数据库也越来越多地用于分析,与 OLTP 相比具有非常不同的访问模式。通常,分析查询会扫描大量记录,并计算聚合统计信息(如计数、和或平均值),而不是将单个记录返回给用户。例如,超市连锁店的业务分析师可能想要回答以下分析查询:
然而,数据库也越来越多地用于分析,与 OLTP 相比,分析具有非常不同的访问模式。通常,分析查询会扫描大量记录,并计算聚合统计信息(如计数、和或平均值),而不是将单个记录返回给用户。例如,超市连锁店的业务分析师可能想要回答以下分析查询:
* 我们每家商店在一月份的总收入是多少?
* 在我们最近的促销期间,我们卖出的香蕉比平时多了多少
* 哪个品牌的婴儿食品最常与品牌 X 纸尿裤一起购买?
* 在我们最近的促销期间,我们卖出了比平时多多少香蕉
* 哪个品牌的婴儿食品最常与 X 品牌尿布一起购买?
这些查询产生的报告对商业智能很重要,帮助管理层决定下一步做什么。为了将这种使用数据库的模式与事务处理区分开来,它被称为 **联机分析处理**OLAP[^5]。
OLTP 和分析之间的区别并不总是明确的,但一些典型特征列在[表 1-1](/ch1#tab_oltp_vs_olap) 中。
这些类型查询产生的报告对商业智能很重要,帮助管理层决定下一步做什么。为了将这种使用数据库的模式与事务处理区分开来,它被称为 **联机分析处理**OLAP[^5]。OLTP 和分析之间的区别并不总是很明确,但[表 1-1](/ch1#tab_oltp_vs_olap) 列出了一些典型特征。
{{< figure id="tab_oltp_vs_olap" title="表 1-1. 比较事务型和分析型系统特征" class="w-full my-4" >}}
{{< figure id="tab_oltp_vs_olap" title="表 1-1. 事务型系统和分析型系统特征比较" class="w-full my-4" >}}
| 属性 | 事务型系统OLTP | 分析型系统OLAP |
|--------|-----------------|---------------|
| 主要读取模式 | 点查询(按键获取单个记录) | 对大量记录进行聚合 |
|-----------------|----------------------------------------|-----------------------------------|
| 主要读取模式 | 点查询(通过键获取单个记录) | 对大量记录进行聚合 |
| 主要写入模式 | 创建、更新和删除单个记录 | 批量导入ETL或事件流 |
| 人类用户示例 | Web/移动应用程序的最终用户 | 内部分析师,用于决策支持 |
| 机器使用示例 | 检查操作是否被授权 | 检测欺诈/滥用模式 |
@ -96,91 +99,86 @@ OLTP 和分析之间的区别并不总是明确的,但一些典型特征列在
| 数据集大小 | GB 到 TB | TB 到 PB |
> [!NOTE]
> **OLAP****联机** 的含义不清楚;它可能指的是查询不仅用于预定义的报告,而且分析师使用 OLAP 系统交互式地进行探索性查询。
> OLAP 中 **online** 的含义不明确;它可能指的是查询不仅用于预定义的报告,而且分析师可以交互式地使用 OLAP 系统进行探索性查询。
对于事务型系统,通常不允许用户构建自定义 SQL 查询并在数据库上运行它们,因为这可能会允许他们读取或修改他们无权访问的数据。此外,他们可能会编写执行成本昂贵的查询从而影响其他用户的数据库性能。出于这些原因OLTP 系统主要运行嵌入到应用程序代码中的固定查询集,偶尔使用一次性自定义查询进行维护或故障排除。另一方面,分析数据库通常赋予用户手动编写任意 SQL 查询的自由,或使用数据可视化或仪表板工具(如 Tableau、Looker 或 Microsoft Power BI自动生成查询。
在事务型系统中,通常不允许用户构建自定义 SQL 查询并在数据库上运行它们,因为这可能会允许他们读取或修改他们没有权限访问的数据。此外,他们可能编写执行成本高昂的查询从而影响其他用户的数据库性能。出于这些原因OLTP 系统主要运行嵌入到应用程序代码中的固定查询集,偶尔使用一次性自定义查询进行维护或故障排除。另一方面,分析数据库通常让用户可以自由地手动编写任意 SQL 查询,或使用 Tableau、Looker 或 Microsoft Power BI 等数据可视化或仪表板工具自动生成查询。
还有一种专为分析工作负载(聚合多条记录的查询)设计但嵌入到面向用户产品中的系统类型。这一类别被称为 **产品分析****实时分析**,专为此类用途设计的系统包括 Pinot、Druid 和 ClickHouse [^6]。
还有一种类型的系统是为分析工作负载(对许多记录进行聚合的查询)设计的,但嵌入到面向用户的产品中。这一类别被称为 **产品分析****实时分析**,为这种用途设计的系统包括 Pinot、Druid 和 ClickHouse [^6]。
### 数据仓库 {#sec_introduction_dwh}
起初,相同的数据库用于事务处理和分析查询。SQL 在这方面相当灵活:它对两种类型的查询都很有效。然而,在 20 世纪 80 年代末和 90 年代初,公司停止将其 OLTP 系统用于分析目的,转而在单独的数据库系统上运行分析的趋势出现了。这个单独的数据库被称为 **数据仓库**
起初,相同的数据库既用于事务处理,也用于分析查询。SQL 在这方面相当灵活:它对两种类型的查询都很有效。然而,在 20 世纪 80 年代末和 90 年代初,公司倾向于停止使用其 OLTP 系统进行分析目的,而是在单独的数据库系统上运行分析。这个单独的数据库被称为 **数据仓库**
一家大型企业可能有几十个甚至几百个联机事务处理系统:为面向客户的网站提供支持的系统、控制实体店中的销售点(收银台)系统、跟踪仓库库存、规划车辆路线、管理供应商、管理员工以及执行许多其他任务的系统。这些系统中的每一个都很复杂,需要一个团队来维护它,因此这些系统最终大多独立于彼此运行。
一家大型企业可能有几十个,甚至上百个联机事务处理系统:为面向客户的网站提供动力的系统、控制实体店中的销售点(收银台)系统、跟踪仓库中的库存、规划车辆路线、管理供应商、管理员工以及执行许多其他任务。这些系统中的每一个都很复杂,需要一个团队来维护它,因此这些系统最终主要是相互独立地运行。
于几个原因,业务分析师和数据科学家直接查询这些 OLTP 系统通常是不可取的:
于几个原因,业务分析师和数据科学家直接查询这些 OLTP 系统通常是不可取的:
* 感兴趣的数据可能分在多个事务型系统中,使得在单个查询中组合这些数据集变得困难(这个问题被称为 **数据孤岛**
* 适合 OLTP 的模式和数据布局类型不太适合分析(参见["用于分析的星型和雪花型模式"](/ch3#sec_datamodels_analytics)
* 感兴趣的数据可能分在多个事务型系统中,使得在单个查询中组合这些数据集变得困难(称为 **数据孤岛** 的问题
* 适合 OLTP 的模式和数据布局不太适合分析(参见["星型和雪花型:分析模式"](/ch3#sec_datamodels_analytics)
* 分析查询可能相当昂贵,在 OLTP 数据库上运行它们会影响其他用户的性能;以及
* OLTP 系统可能位于用户出于安全或合规原因不允许直接访问的单独网络中。
相比之下,**数据仓库** 是一个单独的数据库,分析师可以随心所欲地查询,而不会影响 OLTP 操作 [^7]。
正如我们将在[第 4 章](/ch4#ch_storage)中看到的,数据仓库通常以与 OLTP 数据库非常不同的方式存储数据,以优化分析中常见的查询类型。
相比之下,**数据仓库** 是一个单独的数据库,分析师可以随心所欲地查询,而不会影响 OLTP 操作 [^7]。正如我们将在[第 4 章](/ch4#ch_storage)中看到的,数据仓库通常以与 OLTP 数据库非常不同的方式存储数据,以优化分析中常见的查询类型。
数据仓库包含公司中所有各种 OLTP 系统中数据的只读副本。数据从 OLTP 数据库中提取(使用定期数据转储或连续更新流),转换为分析友好的模式,清理,然后加载到数据仓库中。将数据导入数据仓库的过程称为 **提取-转换-加载**ETL如[图 1-1](/ch1#fig_dwh_etl) 所示。有时 **转换****加载** 步骤的顺序会交换(即,转换在数据仓库中完成,在加载之后),导致 **ELT**
数据仓库包含公司中所有各种 OLTP 系统中数据的只读副本。数据从 OLTP 数据库中提取(使用定期数据转储或连续更新流),转换为分析友好的模式,清理,然后加载到数据仓库中。这种将数据导入数据仓库的过程称为 **提取-转换-加载**ETL如[图 1-1](/ch1#fig_dwh_etl) 所示。有时 **转换****加载** 步骤的顺序会互换(即,在加载后在数据仓库中进行转换),从而产生 **ELT**
{{< figure src="/fig/ddia_0101.png" id="fig_dwh_etl" caption="图 1-1. ETL 到数据仓库的简化概述。" class="w-full my-4" >}}
在某些情况下ETL 过程的数据源是外部 SaaS 产品如客户关系管理CRM、电子邮件营销或信用卡处理系统。在这些情况下你无法直接访问原始数据库因为它只能通过软件供应商的 API 访问。将这些外部系统的数据带入你自己的数据仓库可以实现通过 SaaS API 无法进行的分析。用于 SaaS API 的 ETL 通常由专门的数据连接器服务(如 Fivetran、Singer 或 AirByte实现。
在某些情况下ETL 过程的数据源是外部 SaaS 产品如客户关系管理CRM、电子邮件营销或信用卡处理系统。在这些情况下你无法直接访问原始数据库因为它只能通过软件供应商的 API 访问。将这些外部系统的数据导入你自己的数据仓库可以实现通过 SaaS API 无法实现的分析。SaaS API 的 ETL 通常由专门的数据连接器服务(如 Fivetran、Singer 或 AirByte实现。
一些数据库系统提供 **混合事务/分析处理**HTAP旨在在单个系统中启用 OLTP 和分析,而无需从一个系统到另一个系统的 ETL [^8] [^9]。
然而,许多 HTAP 系统内部由一个 OLTP 系统和一个单独的分析系统组成,隐藏在一个公共接口后面——因此两者之间的区别对于理解这些系统的工作原理仍然很重要。
一些数据库系统提供 **混合事务/分析处理**HTAP旨在在单个系统中启用 OLTP 和分析,而无需从一个系统 ETL 到另一个系统 [^8] [^9]。然而,许多 HTAP 系统内部由一个 OLTP 系统与一个单独的分析系统耦合组成,隐藏在公共接口后面——因此两者之间的区别对于理解这些系统如何工作仍然很重要。
此外,即使 HTAP 存在,由于不同的目标和要求,事务型和分析型系统之间的分离也很常见。特别是,每个事务型系统都有自己的数据库被认为是良好的实践(参见["微服务和无服务器"](/ch1#sec_introduction_microservices)),导致数百个独的事务型数据库;另一方面,企业通常有一个数据仓库,以便业务分析师可以在单个查询中组合来自多个事务型系统的数据。
此外,尽管 HTAP 存在,但由于目标和要求不同,事务型系统和分析型系统之间的分离是常见的。特别是,每个事务型系统拥有自己的数据库被认为是良好的做法(参见["微服务与 Serverless"](/ch1#sec_introduction_microservices)),导致数百个独的事务型数据库;另一方面,企业通常有一个单一的数据仓库,以便业务分析师可以在单个查询中组合来自多个事务型系统的数据。
因此HTAP 并不能取代数据仓库。相反,它在同一个应用程序既需要执行扫描大量行的分析查询,又需要以低延迟读取和更新单个记录的场景中很有用。例如,欺诈检测可能涉及此类工作负载 [^10]。
因此HTAP 不会取代数据仓库。相反,它在同一应用程序需要既执行扫描大量行的分析查询,又以低延迟读取和更新单个记录的场景中很有用。例如,欺诈检测可能涉及此类工作负载 [^10]。
事务型和分析型系统之间的分离是更广泛趋势的一部分:随着工作负载变得更加苛刻,系统变得更加专业化并针对特定工作负载进行优化。通用系统可以轻松处理小数据量,但规模越大,系统往往变得越专业化 [^11]。
事务型系统和分析型系统之间的分离是更广泛趋势的一部分:随着工作负载变得更加苛刻,系统变得更加专业化并针对特定工作负载进行优化。通用系统可以舒适地处理小数据量,但规模越大,系统往往变得越专业化 [^11]。
#### 从数据仓库到数据湖 {#from-data-warehouse-to-data-lake}
数据仓库通常使用通过 SQL 查询的 **关系** 数据模型(参见[第 3 章](/ch3#ch_datamodels)),可能使用专门的商业智能软件。这个模型非常适合业务分析师需要进行的查询类型,但不太适合数据科学家的需求,他们可能需要执行以下任务:
数据仓库通常使用通过 SQL 查询的 **关系** 数据模型(参见[第 3 章](/ch3#ch_datamodels)),可能使用专门的商业智能软件。这个模型适合业务分析师需要进行的查询类型,但不太适合数据科学家的需求,他们可能需要执行以下任务:
* 将数据转换为适合训练机器学习模型的形式;通常需要将数据库表的行和列转换为称为 **特征** 的数值向量或矩阵。以最大化训练模型性能的方式执行转换的过程称为 **特征工程**,它通常需要难以使用 SQL 表达的自定义代码。
* 将数据转换为适合训练机器学习模型的形式;通常需要将数据库表的行和列转换为称为 **特征** 的数值向量或矩阵。以最大化训练模型性能的方式执行这种转换的过程称为 **特征工程**,它通常需要难以用 SQL 表达的自定义代码。
* 获取文本数据(例如,产品评论)并使用自然语言处理技术尝试从中提取结构化信息(例如,作者的情感或他们提到的主题)。同样,他们可能需要使用计算机视觉技术从照片中提取结构化信息。
虽然已经努力将机器学习运算符添加到 SQL 数据模型 [^12] 并在关系基础上构建高效的机器学习系统 [^13],但许多数据科学家更喜欢不在关系数据库(如数据仓库)中工作。相反,许多人更喜欢使用 Python 数据分析库(如 pandas 和 scikit-learn、统计分析语言如 R以及分布式分析框架如 Spark[^14]。
我们将在["数据框、矩阵和数组"](/ch3#sec_datamodels_dataframes)中进一步讨论这些。
尽管已经努力将机器学习算子添加到 SQL 数据模型 [^12] 并在关系基础上构建高效的机器学习系统 [^13],但许多数据科学家不喜欢在数据仓库等关系数据库中工作。相反,许多人更喜欢使用 Python 数据分析库(如 pandas 和 scikit-learn、统计分析语言如 R和分布式分析框架如 Spark[^14]。我们将在["数据框、矩阵和数组"](/ch3#sec_datamodels_dataframes)中进一步讨论这些。
因此,组织面临着以适合数据科学家使用的形式提供数据的需求。答案是 **数据湖**:一个中央数据存储库,保存可能对分析有用的任何数据的副本,通过 ETL 过程从事务型系统获得。与数据仓库的区别在于,数据湖只包含文件,而不强加任何特定的文件格式或数据模型。数据湖中的文件可能是数据库记录的集合,使用文件格式(如 Avro 或 Parquet编码参见[第 5 章](/ch5#ch_encoding)),但它们同样可以包含文本、图像、视频、传感器读数、稀疏矩阵、特征向量、基因组序列或任何其他类型的数据 [^15]。
除了更加灵活之外,这通常也比关系数据存储更便宜,因为数据湖可以使用商品化的文件存储,如对象存储(参见["云原生系统架构"](/ch1#sec_introduction_cloud_native))。
因此,组织面临着以适合数据科学家使用的形式提供数据的需求。答案是 **数据湖**:一个集中的数据存储库,保存任何可能对分析有用的数据副本,通过 ETL 过程从事务型系统获得。与数据仓库的区别在于,数据湖只是包含文件,而不强制任何特定的文件格式或数据模型。数据湖中的文件可能是数据库记录的集合,使用 Avro 或 Parquet 等文件格式编码(参见[第 5 章](/ch5#ch_encoding)),但它们同样可以包含文本、图像、视频、传感器读数、稀疏矩阵、特征向量、基因组序列或任何其他类型的数据 [^15]。除了更灵活之外,这通常也比关系数据存储更便宜,因为数据湖可以使用商品化的文件存储,如对象存储(参见["云原生系统架构"](/ch1#sec_introduction_cloud_native))。
ETL 过程已经被推广**数据管道**,在某些情况下,数据湖已成为从事务型系统到数据仓库路径上的中间站。数据湖包含事务型系统产生的"原始"形式的数据,没有转换为关系数据仓库模式。这种方法的优点是数据的每个消费者都可以将原始数据转换为最适合其需求的形式。它被称为 **寿司原则**"原始数据更好"[^16]。
ETL 过程已经泛化**数据管道**,在某些情况下,数据湖已成为从事务型系统到数据仓库路径上的中间站。数据湖包含事务型系统产生的"原始"形式的数据,没有转换为关系数据仓库模式。这种方法的优势在于,每个数据消费者都可以将原始数据转换为最适合其需求的形式。它被称为 **寿司原则**"数据更好"[^16]。
除了将数据从数据湖加载到单独的数据仓库之外还可以直接在数据湖中的文件上运行典型的数据仓库工作负载SQL 查询和业务分析),以及数据科学/机器学习工作负载。这种架构被称为 **数据湖仓**,它需要一个查询执行引擎和一个元数据(例如,模式管理)层来扩展数据湖的文件存储 [^17]。
除了从数据湖加载数据到单独的数据仓库之外还可以直接在数据湖中的文件上运行典型的数据仓库工作负载SQL 查询和业务分析),以及数据科学/机器学习工作负载。这种架构被称为 **数据湖仓**,它需要一个查询执行引擎和一个元数据(例如,模式管理)层来扩展数据湖的文件存储 [^17]。
Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例。
Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例
#### 超越数据湖 {#beyond-the-data-lake}
随着分析实践的成熟,组织越来越关注分析系统和数据管道的管理和操作,例如 DataOps 宣言中所捕获的 [^18]。
其中一部分是治理、隐私以及符合 GDPR 和 CCPA 等法规的问题,我们将在["数据系统、法律与社会"](/ch1#sec_introduction_compliance)和[待补充链接]中讨论。
随着分析实践的成熟,组织越来越关注分析系统和数据管道的管理和运营,例如 DataOps 宣言所捕获的 [^18]。其中一部分是治理、隐私和遵守 GDPR 和 CCPA 等法规的问题,我们将在["数据系统、法律与社会"](/ch1#sec_introduction_compliance)和[待补充链接]中讨论。
此外,分析数据越来越多地不仅作为文件和关系表提供,还作为事件流提供(参见[待补充链接])。使用基于文件的数据分析,你可以定期(例如,每天)重新运行分析以响应数据的变化,但流处理允许分析系统以秒级的速度响应事件。根据应用程序及其时间敏感性,流处理方法可能很有价值,例如识别和阻止潜在的欺诈或滥用活动。
此外,分析数据越来越多地不仅作为文件和关系表提供,还作为事件流(参见[待补充链接])。使用基于文件的数据分析,你可以定期(例如,每天)重新运行分析以响应数据的变化,但流处理允许分析系统以秒级的速度响应事件。根据应用程序及其时间敏感性,流处理方法可能很有价值,例如识别和阻止潜在的欺诈或滥用活动。
在某些情况下,分析系统的输出被提供给事务型系统(这个过程有时被称为 **反向 ETL** [^19])。例如,在分析系统上训练的机器学习模型可能会部署到生产环境中,以便它可以为最终用户生成推荐,例如"购买了 X 的人也购买了 Y"。这种分析系统的部署输出也被称为 **数据产品** [^20]。
机器学习模型可以使用专门的工具(如 TFX、Kubeflow 或 MLflow部署到事务型系统。
在某些情况下,分析系统的输出被提供给事务型系统(这个过程有时被称为 **反向 ETL** [^19])。例如,在分析系统中训练的机器学习模型可能会部署到生产环境中,以便为最终用户生成推荐,例如"购买了 X 的人也购买了 Y"。这种分析系统的部署输出也被称为 **数据产品** [^20]。机器学习模型可以使用 TFX、Kubeflow 或 MLflow 等专门工具部署到事务型系统。
### 权威数据源与派生数据 {#sec_introduction_derived}
与事务型和分析型系统的区别相关,本书还区分了 **权威记录系统****派生数据系统**。这些术语很有用,因为它们可以帮助你理清数据在系统中的流动:
与事务型系统和分析型系统之间的区别相关,本书还区分了 **权威记录系统****衍生数据系统**。这些术语很有用,因为它们可以帮助你澄清数据在系统中的流动:
权威记录系统
: 权威记录系统SoR, System of Record,也称为 **权威数据源**,保存某些数据的权威或 **规范** 版本。当新数据进入时,例如作为用户输入,它首先写入这里。每个事实只表示一次(表示通常是 **规范化**;参见["规范化、反规范化和连接"](/ch3#sec_datamodels_normalization))。如果另一个系统和权威记录系统之间存在任何差异,那么权威记录系统中的值(按定义)是正确的。
: 权威记录系统,也称为 **权威数据源**,保存某些数据的权威或 **规范** 版本。当新数据进入时,例如作为用户输入,它首先写入这里。每个事实只表示一次(表示通常是 **规范化**;参见["规范化、反规范化和连接"](/ch3#sec_datamodels_normalization))。如果另一个系统与权威记录系统之间存在任何差异,那么权威记录系统中的值(根据定义)是正确的。
生数据系统
: 派生系统中的数据是通过获取另一个系统中的一些现有数据并以某种方式转换或处理它的结果。如果你丢失了派生数据,你可以从原始源重新创建它。一个经典的例子是缓存:如果存在,可以从缓存提供数据,但如果缓存不包含你需要的内容,你可以回退到底层数据库。反规范化值、索引、物化视图、转换的数据表示以及在数据集上训练的模型也属于这一类别。
生数据系统
: 衍生系统中的数据是从另一个系统获取一些现有数据并以某种方式转换或处理它的结果。如果你丢失了衍生数据,你可以从原始源重新创建它。一个经典的例子是缓存:如果存在,可以从缓存提供数据,但如果缓存不包含你需要的内容,你可以回退到底层数据库。反规范化值、索引、物化视图、转换的数据表示在数据集上训练的模型也属于这一类别。
从技术上讲,派生数据是 **冗余的**,因为它复制了现有信息。然而,它对于在读查询上获得良好性能通常是必不可少的。你可以从单个源派生几个不同的数据集,使你能够从不同的"视角"查看数据。
从技术上讲,衍生数据是 **冗余** 的,因为它复制了现有信息。然而,它通常对于在读取查询上获得良好性能至关重要。你可以从单个源派生几个不同的数据集,使你能够从不同的"视角"查看数据。
分析系统通常是生数据系统,因为它们是在其他地方创建的数据的消费者。事务型服务可能包含权威记录系统和派生数据系统的混合。权威记录系统是数据首次写入的主要数据库,而派生数据系统是加速常见读取操作的索引和缓存,特别是对于权威记录系统无法有效回答的查询。
分析系统通常是生数据系统,因为它们是在其他地方创建的数据的消费者。事务型服务可能包含权威记录系统和衍生数据系统的混合。权威记录系统是首先写入数据的主数据库,而衍生数据系统是加速常见读取操作的索引和缓存,特别是对于权威记录系统无法有效回答的查询。
大多数数据库、存储引擎和查询语言本质上不是权威记录系统或派生系统。数据库只是一个工具:如何使用它取决于你。权威记录系统和派生数据系统之间的区别不取决于工具,而取决于你在应用程序中如何使用它。通过明确哪些数据是从哪些其他数据派生的,你可以为原本令人困惑的系统架构带来清晰度。
大多数数据库、存储引擎和查询语言本质上不是权威记录系统或衍生系统。数据库只是一个工具:如何使用它取决于你。权威记录系统和衍生数据系统之间的区别不取决于工具,而取决于你如何在应用程序中使用它。通过明确哪些数据是从哪些其他数据派生的,你可以为原本混乱的系统架构带来清晰度。
当一个系统中的数据是从另一个系统中的数据派生的时,你需要一个过程来在权威记录系统中的原始数据发生变化时更新派生数据。不幸的是,许多数据库的设计基于你的应用程序只需要使用那一个数据库的假设,它们不容易集成多个系统以传播此类更新。在[待补充链接]中,我们将讨论 **数据集成** 的方法,这允许我们组合多个数据系统来实现单个系统无法完成的事情。
当一个系统中的数据源自另一个系统中的数据时,你需要一个过程来在权威记录系统中的原始数据发生变化时更新衍生数据。不幸的是,许多数据库的设计基于这样的假设:你的应用程序只需要使用那一个数据库,它们不易于集成多个系统以传播此类更新。在[待补充链接]中,我们将讨论 **数据集成** 的方法,这允许我们组合多个数据系统来实现单个系统无法做到的事情。
这就结束了我们对分析和事务处理的比较。在下一节中,我们将研究另一个你可能已经看到多次争论的权衡。
这就是我们对分析和事务处理比较的结束。在下一节中,我们将研究另一个你可能已经看到多次辩论的权衡。
@ -188,243 +186,226 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的示例。
对于组织需要做的任何事情,首要问题之一是:应该在内部完成,还是应该外包?应该自建还是购买?
最终,这是一个关于业务优先级的问题。管理智慧认为,作为组织核心竞争力或竞争优势的事情应该在内部完成,而非核心、常规或普通的事情应该留给供应商 [^21]。
举一个极端的例子,大多数公司不自己发电(除非他们是能源公司,撇开紧急备用电源不谈),因为从电网购买电力更便宜。
归根结底,这是一个关于业务优先级的问题。公认的管理智慧是,作为组织核心竞争力或竞争优势的事物应该在内部完成,而非核心、例行或常见的事物应该留给供应商 [^21]。举一个极端的例子,大多数公司不会自己发电(除非他们是能源公司,而且不考虑紧急备用电源),因为从电网购买电力更便宜。
对于软件,需要做出两个重要决定:谁构建软件以及谁部署它。外包每个决定的程度有多种可能性,如[图 1-2](/ch1#fig_cloud_spectrum) 所示。一个极端是你自己编写和运行的定制软件另一个极端是广泛使用的云服务或软件即服务SaaS产品由外部供应商实施和运营你只能通过 Web 界面或 API 访问。
对于软件,需要做出的两个重要决定是谁构建软件和谁部署它。有一系列可能性,每个决定都在不同程度上外包,如[图 1-2](/ch1#fig_cloud_spectrum) 所示。一个极端是你自己编写并在内部运行的定制软件另一个极端是广泛使用的云服务或软件即服务SaaS产品由外部供应商实施和运营你只能通过 Web 界面或 API 访问。
{{< figure src="/fig/ddia_0102.png" id="fig_cloud_spectrum" caption="图 1-2. 软件类型及其操作的范围。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0102.png" id="fig_cloud_spectrum" caption="图 1-2. 软件类型及其运营的范围。" class="w-full my-4" >}}
中间地带是你 **自托管** 的现成软件(开源或商业),即自己部署——例如,如果你下载 MySQL 并将其安装在你控制的服务器上。这可能在你自己的硬件上(通常称为 **本地部署**,即使服务器实际上在租用的数据中心机架中而不是字面上在你自己的场所),或者在云中的虚拟机上(**基础设施即服务** 或 IaaS。沿着这个范围还有更多的点例如采用开源软件并运行它的修改版本。
中间地带是你 **自托管** 的现成软件(开源或商业),即自己部署——例如,如果你下载 MySQL 并将其安装在你控制的服务器上。这可能在你自己的硬件上(通常称为 **本地部署**,即使服务器实际上在租用的数据中心机架中而不是字面上在你自己的场所),或者在云中的虚拟机上(**基础设施即服务** 或 IaaS。沿着这个范围还有更多的点例如采用开源软件并运行修改版本。
与这个范围分开的还有 **如何** 部署服务的问题,无论是在云中还是本地——例如,你是否使用像 Kubernetes 这样的编排框架。然而,部署工具的选择超出了本书的范围,因为其他因素对数据系统的架构有更大的影响。
与这个范围分开的还有 **如何** 部署服务的问题,无论是在云中还是在本地——例如,是否使用 Kubernetes 等编排框架。然而,部署工具的选择超出了本书的范围,因为其他因素对数据系统的架构有更大的影响。
### 云服务的利弊 {#sec_introduction_cloud_tradeoffs}
使用云服务而不是自己运行类似的软件,本质上是将该软件的操作外包给云提供商。有支持和反对云服务的充分论据。云提供商声称使用他们的服务可以节省你的时间和金钱,并且与设置自己的基础设施相比,可以让你更快地行动。
使用云服务而不是自己运行可比较的软件,本质上是将该软件的运营外包给云提供商。使用云服务有充分的支持和反对理由。云提供商声称,使用他们的服务可以节省你的时间和金钱,并允许你比设置自己的基础设施更快地移动。
云服务是否实际上比自托管更便宜、更容易,很大程度上取决于你的技能和系统的工作负载。如果你已经有设置和操作所需系统的经验,并且你的负载相当可预测(即,你需要的机器数量不会剧烈波动),那么购买自己的机器并自己运行软件通常更便宜 [^22] [^23]。
云服务实际上是否比自托管更便宜、更容易,很大程度上取决于你的技能和系统的工作负载。如果你已经有设置和运营所需系统的经验,并且你的负载相当可预测(即,你需要的机器数量不会剧烈波动),那么购买自己的机器并自己在上面运行软件通常更便宜 [^22] [^23]。
另一方面,如果你需要一个你还不知道如何部署和操作的系统,那么采用云服务通常比自己学习管理系统更容易、更快。如果你必须专门雇用和培训员工来维护和操作系统,那可能会变得非常昂贵。当你使用云时,你仍然需要一个运维团队(参见["云时代的运维"](/ch1#sec_introduction_operations)),但外包基本系统管理可以让你的团队专注于更高层次的问题。
另一方面,如果你需要一个你还不知道如何部署和运营的系统,那么采用云服务通常比学习自己管理系统更容易、更快。如果你必须专门雇用和培训员工来维护和运营系统,那可能会变得非常昂贵。使用云时你仍然需要一个运维团队(参见["云时代的运维"](/ch1#sec_introduction_operations)),但外包基本系统管理可以让你的团队专注于更高层次的问题。
当你将系统的操作外包给专门运行该服务的公司时,这可能会带来更好的服务,因为提供商从为许多客户提供服务中获得了运营专业知识。另一方面,如果你自己运行服务,你可以配置和调整它以在你的特定工作负载上表现良好;云服务不太可能愿意代表你进行此类自定义
当你将系统的运营外包给专门运行该服务的公司时,这可能会带来更好的服务,因为提供商从向许多客户提供服务中获得了运营专业知识。另一方面,如果你自己运行服务,你可以配置和调整它以在你的特定工作负载上表现良好;云服务不太可能愿意代表你进行此类定制
如果你的系统负载随时间变化很大,云服务特别有价值。如果你配置机器以能够处理峰值负载,但这些计算资源大部分时间都处于闲置状态,系统就变得不太划算。在这种情况下,云服务的优势在于它们可以更容易地根据需求变化扩大或缩小你的计算资源。
如果你的系统负载随时间变化很大,云服务特别有价值。如果你配置机器以能够处理峰值负载,但这些计算资源大部分时间都处于空闲状态,系统就变得不太具有成本效益。在这种情况下,云服务的优势在于它们可以更容易地根据需求变化向上或向下扩展你的计算资源。
例如,分析系统通常具有极其可变的负载:快速运行大型分析查询需要并行的大量计算资源,但一旦查询完成,这些资源就会闲置,直到用户进行下一个查询。预定义的查询(例如,用于每日报告)可以排队和调度以平滑负载,但对于交互式查询,你希望它们完成得越快,工作负载就越可变。如果你的数据集如此之大,以至于快速查询需要大量的计算资源,使用云可以节省资金,因为你可以将未使用的资源返回给提供商,而不是让它们闲置。对于较小的数据集,这种差异不太显著。
例如,分析系统通常具有极其可变的负载:快速运行大型分析查询需要并行使用大量计算资源,但一旦查询完成,这些资源就会处于空闲状态,直到用户进行下一个查询。预定义的查询(例如,每日报告)可以排队和调度以平滑负载,但对于交互式查询,你希望它们完成得越快,工作负载就变得越可变。如果你的数据集如此之大,以至于快速查询需要大量的计算资源,使用云可以节省资金,因为你可以将未使用的资源返回给提供商,而不是让它们闲置。对于较小的数据集,这种差异不太显著。
云服务的最大缺点是你无法控制它:
* 如果它缺少你需要的功能,你所能做的就是礼貌地询问供应商是否会添加它;你通常不能自己实现它。
* 如果服务宕机,你所能做的就是等待它恢复。
* 如果你使用服务的方式触发了错误或导致性能问题,你将很难诊断问题。使用你自己运行的软件,你可以从操作系统获取性能指标和调试信息,以帮助你理解其行为,并且你可以查看服务器日志,但使用供应商托管的服务,你通常无法访问这些内部信息。
* 此外,如果服务关闭或变得不可接受地昂贵,或者如果供应商决定以你不喜欢的方式更改他们的产品,你将任由他们摆布——继续运行软件的旧版本通常不是一个选项,因此你将被迫迁移到替代服务 [^24]。
如果有其他服务公开兼容的 API这种风险会得到缓解但对于许多云服务没有标准 API这提高了切换成本使供应商锁定成为一个问题。
* 如果你以触发错误或导致性能问题的方式使用服务,你将很难诊断问题。对于你自己运行的软件,你可以从操作系统获取性能指标和调试信息来帮助你理解其行为,你可以查看服务器日志,但对于供应商托管的服务,你通常无法访问这些内部信息。
* 此外,如果服务关闭或变得无法接受地昂贵,或者如果供应商决定以你不喜欢的方式更改他们的产品,你就受制于他们——继续运行软件的旧版本通常不是一个选项,所以你将被迫迁移到替代服务 [^24]。如果有暴露兼容 API 的替代服务,这种风险会得到缓解,但对于许多云服务,没有标准 API这增加了切换成本使供应商锁定成为一个问题。
* 云提供商需要被信任以保持数据安全,这可能会使遵守隐私和安全法规的过程复杂化。
尽管存在所有这些风险,组织在云服务之上构建新应用程序或采用混合方法(云服务用于系统的某些方面)变得越来越流行。然而,云服务不会取代所有内部数据系统:许多较旧的系统早于云,对于任何具有现有云服务无法满足的专业要求的服务,内部系统仍然是必要的。例如,对延迟非常敏感的应用程序(如高频交易)需要对硬件的完全控制。
尽管所有这些风险,组织在云服务之上构建新应用程序或采用混合方法(其中云服务用于系统的某些方面)变得越来越流行。然而,云服务不会取代所有内部数据系统:许多较旧的系统早于云,对于任何具有现有云服务无法满足的专业要求的服务,内部系统仍然是必要的。例如,对延迟非常敏感的应用程序(如高频交易)需要完全控制硬件
### 云原生系统架构 {#sec_introduction_cloud_native}
除了具有不同的经济模型(订阅服务而不是购买硬件和许可软件在其上运行),云的兴起还对数据系统在技术层面上的实现产生了深远的影响。术语 **云原生** 用于描述旨在利用云服务的架构。
除了具有不同的经济模型(订阅服务而不是购买硬件和许可软件在其上运行)之外,云的兴起也对数据系统在技术层面的实现产生了深远的影响。术语 **云原生** 用于描述旨在利用云服务的架构。
原则上,几乎任何你可以自托管的软件也可以作为云服务提供,实际上,许多流行的数据系统现在都有托管服务。然而,从头开始设计为云原生的系统已被证明具有几个优势:在相同硬件上具有更好的性能、更快地从故障中恢复、能够快速扩展计算资源以匹配负载,以及支持更大的数据集 [^25] [^26] [^27]。
[表 1-2](/ch1#tab_cloud_native_dbs) 列出了两种类型系统的一些示例。
原则上,几乎任何你可以自托管的软件也可以作为云服务提供,实际上,许多流行的数据系统现在都有托管服务。然而,从头开始设计为云原生的系统已被证明具有几个优势:在相同硬件上具有更好的性能、从故障中更快恢复、能够快速扩展计算资源以匹配负载,以及支持更大的数据集 [^25] [^26] [^27]。[表 1-2](/ch1#tab_cloud_native_dbs) 列出了两种类型系统的一些示例。
{{< figure id="#tab_cloud_native_dbs" title="表 1-2. 自托管和云原生数据库系统示例" class="w-full my-4" >}}
{{< figure id="#tab_cloud_native_dbs" title="表 1-2. 自托管和云原生数据库系统示例" class="w-full my-4" >}}
| 类别 | 自托管系统 | 云原生系统 |
|----------|---------------------------|---------------------------------------------------------------------|
|------------------|----------------------------|----------------------------------------------------------------------|
| 事务型/OLTP | MySQL、PostgreSQL、MongoDB | AWS Aurora [^25]、Azure SQL DB Hyperscale [^26]、Google Cloud Spanner |
| 分析型/OLAP | Teradata、ClickHouse、Spark | Snowflake [^27]、Google BigQuery、Azure Synapse Analytics |
#### 云服务的分层 {#layering-of-cloud-services}
许多自托管数据系统具有非常简单的系统要求:它们在传统操作系统(如 Linux 或 Windows上运行将数据作为文件存储在文件系统上,并通过标准网络协议(如 TCP/IP进行通信。少数系统依赖于特殊硬件,如 GPU用于机器学习或 RDMA 网络接口但总的来说自托管软件倾向于使用非常通用的计算资源CPU、RAM、文件系统和 IP 网络。
许多自托管数据系统的系统要求非常简单:它们在传统操作系统(如 Linux 或 Windows上运行将数据存储为文件系统上的文件,并通过 TCP/IP 等标准网络协议进行通信。少数系统依赖于特殊硬件,如 GPU用于机器学习或 RDMA 网络接口但总的来说自托管软件倾向于使用非常通用的计算资源CPU、RAM、文件系统和 IP 网络。
在云中,这种类型的软件可以在基础设施即服务环境中运行,使用一个或多个具有一定 CPU、内存、磁盘和网络带宽分配的虚拟机**实例**)。与物理机器相比,云实例可以更快地配置,并且有更多种类的大小,但在其他方面它们类似于传统计算机:你可以在其上运行任何你喜欢的软件,但你负责自己管理它。
在云中,这种类型的软件可以在基础设施即服务环境中运行,使用一个或多个虚拟机(或 **实例**),分配一定的 CPU、内存、磁盘和网络带宽。与物理机器相比云实例可以更快地配置并且有更多种类的大小但除此之外它们与传统计算机类似你可以在上面运行任何你喜欢的软件,但你负责自己管理它。
相比之下,云原生服务的关键思想不仅使用由你的操作系统管理的计算资源,还基于较低级别的云服务构建更高级别的服务。例如:
相比之下,云原生服务的关键思想不仅使用由操作系统管理的计算资源,还基于较低级别的云服务构建更高级别的服务。例如:
* **对象存储** 服务(如 Amazon S3、Azure Blob Storage 和 Cloudflare R2存储大文件。它们提供比典型文件系统更有限的 API基本文件读写但它们的优势在于隐藏了底层物理机器服务自动将数据分布在许多机器上因此你不必担心任何一台机器上的磁盘空间用完。即使某些机器或其磁盘完全故障也不会丢失数据。
* 许多其他服务建立在对象存储和其他云服务之上例如Snowflake 是一个基于云的分析数据库(数据仓库),它依赖于 S3 进行数据存储 [^27],而其他一些服务又建立在 Snowflake 之上。
* 许多其他服务反过来建立在对象存储和其他云服务之上例如Snowflake 是一个基于云的分析数据库(数据仓库),依赖于 S3 进行数据存储 [^27],而一些其他服务反过来建立在 Snowflake 之上。
与计算中的抽象一样,没有一个你应该使用的正确答案。作为一般规则,更高级别的抽象往往更面向特定用例。如果你的需求与为其设计更高级别系统的情况相匹配,使用现有的更高级别系统可能会以比从较低级别系统自己构建它少得多的麻烦提供你需要的东西。另一方面,如果没有满足你需求的高级系统,那么从较低级别的组件自己构建它是唯一的选择。
与计算中的抽象一样,没有一个正确的答案告诉你应该使用什么。作为一般规则,更高级别的抽象往往更面向特定用例。如果你的需求与为其设计更高级别系统的情况相匹配,使用现有的更高级别系统可能会以比从较低级别系统自己构建它少得多的麻烦提供你需要的东西。另一方面,如果没有满足你需求的高级系统,那么从较低级别的组件自己构建它是唯一的选择。
#### 存储与计算的分离 {#sec_introduction_storage_compute}
在传统计算中,磁盘存储被认为是持久的(我们假设一旦某些东西被写入磁盘,它就不会丢失)。为了容忍单个硬盘的故障,RAID独立磁盘冗余阵列通常用于在连接到同一台机器的多个磁盘上维护数据副本。RAID 可以在硬件中或由操作系统在软件中执行,并且它对访问文件系统的应用程序是透明的。
在传统计算中,磁盘存储被认为是持久的(我们假设一旦某些东西被写入磁盘,它就不会丢失)。为了容忍单个硬盘的故障,通常使用 RAID独立磁盘冗余阵列在连接到同一台机器的几个磁盘上维护数据副本。RAID 可以在硬件中执行,也可以由操作系统在软件中执行,它对访问文件系统的应用程序是透明的。
在云中,计算实例(虚拟机)也可能有本地磁盘连接,但云原生系统通常将这些磁盘更像临时缓存,而不像长期存储。这是因为如果关联的实例发生故障,或者如果实例被更大或更小的实例(在不同的物理机器上)替换以适应负载变化,本地磁盘变得不可访问。
在云中,计算实例(虚拟机)也可能有本地磁盘连接,但云原生系统通常将这些磁盘更多地视为临时缓存,而不是长期存储。这是因为如果相关实例失败,或者如果实例被更大或更小的实例(在不同的物理机器上)替换以适应负载变化,本地磁盘就会变得不可访问。
作为本地磁盘的替代方案,云服务还提供虚拟磁盘存储,可以从一个实例分离并附加到另一个实例Amazon EBS、Azure 托管磁盘和 Google Cloud 中的持久磁盘)。这样的虚拟磁盘实际上不是物理磁盘,而是由一组单独的机器提供的云服务,它模拟磁盘(**块设备**,其中每个块通常为 4 KiB 大小)的行为。这项技术使得在云中运行传统的基于磁盘的软件成为可能,但块设备仿真引入了可以在从头开始为云设计的系统中避免的开销 [^25]。它还使应用程序对网络故障非常敏感,因为虚拟块设备上的每个 I/O 实际上都是网络调用 [^28]。
作为本地磁盘的替代方案,云服务还提供可以从一个实例分离并附加到另一个实例的虚拟磁盘存储Amazon EBS、Azure 托管磁盘和 Google Cloud 中的持久磁盘)。这虚拟磁盘实际上不是物理磁盘,而是由一组单独的机器提供的云服务,它模拟磁盘的行为**块设备**,其中每个块通常为 4 KiB 大小)。这项技术使得在云中运行传统的基于磁盘的软件成为可能,但块设备仿真引入了在从头为云设计的系统中可以避免的开销 [^25]。它还使应用程序对网络故障非常敏感,因为虚拟块设备上的每个 I/O 实际上都是网络调用 [^28]。
为了解决这个问题,云原生服务通常避免使用虚拟磁盘,而是建立在针对特定工作负载优化的专用存储服务上。对象存储服务(如 S3专为长期存储相当大的文件而设计,大小从数百千字节到几千兆字节不等。存储在数据库中的单个行或值通常比这小得多;因此,云数据库通常在单独的服务中管理较小的值,并在对象存储中存储较大的数据块(包含许多单个值)[^26] [^29]。我们将在[第 4 章](/ch4#ch_storage)中看到这样做的方法。
为了解决这个问题,云原生服务通常避免使用虚拟磁盘,而是建立在针对特定工作负载优化的专用存储服务之上。对象存储服务(如 S3设计用于长期存储相当大的文件,大小从数百千字节到几千兆字节不等。数据库中存储的单个行或值通常比这小得多;因此,云数据库通常在单独的服务中管理较小的值,并将较大的数据块(包含许多单个值)存储在对象存储中 [^26] [^29]。我们将在[第 4 章](/ch4#ch_storage)中看到这样做的方法。
在传统系统架构中同一台计算机负责存储磁盘和计算CPU 和 RAM但在云原生系统中这两个职责已经在某种程度上分离或 **解耦** [^9] [^27] [^30] [^31]
例如S3 只存储文件,如果你想分析该数据,你必须在 S3 之外的某个地方运行分析代码。这意味着通过网络传输数据,我们将在["分布式与单节点系统"](/ch1#sec_introduction_distributed)中进一步讨论。
在传统的系统架构中同一台计算机负责存储磁盘和计算CPU 和 RAM但在云原生系统中这两个职责已经在某种程度上分离或 **解耦** [^9] [^27] [^30] [^31]例如S3 只存储文件,如果你想分析该数据,你必须在 S3 之外的某个地方运行分析代码。这意味着通过网络传输数据,我们将在["分布式与单节点系统"](/ch1#sec_introduction_distributed)中进一步讨论。
此外,云原生系统通常是 **多租户** 的,这意味着不是每个客户都有一个单独的机器,而是来自多个不同客户的数据和计算由同一服务在同一共享硬件上处理 [^32]。
此外,云原生系统通常是 **多租户** 的,这意味着不是每个客户都有一台单独的机器,而是来自几个不同客户的数据和计算由同一服务在同一共享硬件上处理 [^32]。
多租户可以实现更好的硬件利用率、更容易的可扩展性以及云提供商更容易的管理,但它还需要仔细的工程设计,以确保一个客户的活动不会影响其他客户的系统性能或安全性 [^33]。
多租户可以实现更好的硬件利用率、更容易的可伸缩性和云提供商更容易的管理,但它也需要仔细的工程设计,以确保一个客户的活动不会影响其他客户的系统性能或安全性 [^33]。
### 云时代的运维 {#sec_introduction_operations}
传统上,管理组织服务器端数据基础设施的人被称为 **数据库管理员**DBA**系统管理员**sysadmins。最近许多组织试图将软件开发和运维的角色整合到具有后端服务和数据基础设施共同责任的团队中;**DevOps** 理念指导了这一趋势。**站点可靠性工程师**SRE是 Google 对这一想法的实现 [^34]。
传统上,管理组织服务器端数据基础设施的人被称为 **数据库管理员**DBA**系统管理员**sysadmins。最近许多组织已经尝试将软件开发和运维的角色整合到团队中,共同负责后端服务和数据基础设施;**DevOps** 理念引导了这一趋势。**站点可靠性工程师**SRE是 Google 对这个想法的实现 [^34]。
运维的作用是确保服务可靠地交付给用户(包括配置基础设施和部署应用程序),并确保稳定的生产环境(包括监控和诊断可能影响可靠性的任何问题)。对于自托管系统,运维传统上涉及在单个机器级别大量工作,例如容量规划(例如,监控可用磁盘空间并在空间用完之前添加更多磁盘)、配置新机器、将服务从一台机器移动到另一台机器以及安装操作系统补丁。
运维的作用是确保服务可靠地交付给用户(包括配置基础设施和部署应用程序),并确保稳定的生产环境(包括监控和诊断可能影响可靠性的任何问题)。对于自托管系统,运维传统上涉及大量在单个机器级别的工作,例如容量规划(例如,监控可用磁盘空间并在空间用完之前添加更多磁盘)、配置新机器、将服务从一台机器移动到另一台机器以及安装操作系统补丁。
许多云服务提供了一个隐藏实际实现服务的单个机器的 API。例如,云存储用 **计量计费** 取代了固定大小的磁盘,你可以存储数据而无需提前规划容量需求,然后根据实际使用的空间收费。此外,许多云服务保持高可用,即使单个机器发生故障(参见["可靠性容错"](/ch2#sec_introduction_reliability))。
许多云服务提供了一个 API隐藏实际实现服务的单个机器。例如,云存储用 **计量计费** 替换固定大小的磁盘,你可以存储数据而无需提前规划容量需求,然后根据实际使用的空间收费。此外,许多云服务保持高可用,即使单个机器发生故障(参见["可靠性容错"](/ch2#sec_introduction_reliability))。
从单个机器到服务的重点转移伴随着运维角色的变化。提供可靠服务的高级目标保持不变但流程和工具已经发展。DevOps/SRE 理念更加强调:
* 自动化——偏好可重复的流程而不是手动一次性作业
* 偏好短暂的虚拟机和服务而不是长期运行的服务器,
* 实现频繁的应用程序更新,
* 自动化——优先考虑可重复的流程而不是手动的一次性工作
* 优先考虑短暂的虚拟机和服务而不是长期运行的服务器,
* 启用频繁的应用程序更新,
* 从事件中学习,以及
* 保留组织关于系统的知识,即使个人来来去去 [^35]。
随着云服务的兴起,角色出现了分歧:基础设施公司的运维团队专门为大量客户提供可靠服务的细节,而服务的客户在基础设施上花费尽可能少的时间和精力 [^36]。
随着云服务的兴起,角色出现了分叉:基础设施公司的运维团队专门研究向大量客户提供可靠服务的细节,而服务的客户在基础设施上花费尽可能少的时间和精力 [^36]。
云服务的客户仍然需要运维,但他们专注于不同的方面,例如为给定任务选择最合适的服务、将不同服务相互集成以及从一个服务迁移到另一个服务。即使计量计费消除了传统意义上的容量规划需求,了解你为哪个目的使用哪些资源仍然很重要,这样你就不会在不需要的云资源上浪费金钱:容量规划变成了财务规划,性能优化变成了成本优化 [^37]。
云服务的客户仍然需要运维,但他们专注于不同的方面,例如为给定任务选择最合适的服务、将不同服务相互集成以及从一个服务迁移到另一个服务。即使计量计费消除了传统意义上的容量规划需求,了解你为哪个目的使用哪些资源仍然很重要,这样你就不会在不需要的云资源上浪费金钱:容量规划变成了财务规划,性能优化变成了成本优化 [^37]。
此外,云服务确实有资源限制或 **配额**(例如你可以同时运行的最大进程数),你需要在遇到它们之前了解并规划它们 [^38]。
此外,云服务确实有资源限制或 **配额**(例如你可以同时运行的最大进程数),你需要在遇到它们之前了解并规划这些 [^38]。
采用云服务可能比运行自己的基础设施更容易、更快,尽管即使在这里,学习如何使用它也有成本,也许还要解决其限制。随着越来越多的供应商提供针对不同用例的越来越广泛的云服务,不同服务之间的集成成为一个特别的挑战 [^39] [^40]。
采用云服务可能比运行自己的基础设施更容易、更快,尽管即使在这里,学习如何使用它也有成本,也许还要解决其限制。随着越来越多的供应商提供针对不同用例的广泛的云服务,不同服务之间的集成成为一个特别的挑战 [^39] [^40]。
ETL参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部分;事务型云服务也需要相互集成。目前,缺乏促进这种集成的标准,因此它通常涉及大量的手动工作。
其他不能完全外包给云服务的运维方面包括维护应用程序及其使用的库的安全性、管理你自己的服务之间的交互、监控服务的负载以及追踪问题的原因,例如性能下降或中断。虽然云正在改变运维的角色,但对运维的需求仍然很大。
无法完全外包给云服务的其他运维方面包括维护应用程序及其使用的库的安全性、管理你自己的服务之间的交互、监控服务的负载以及追踪问题的原因,例如性能下降或中断。虽然云正在改变运维的角色,但对运维的需求比以往任何时候都大。
## 分布式与单节点系统 {#sec_introduction_distributed}
涉及多台通过网络通信的机器的系统称为 **分布式系统**。参与分布式系统的每个进程称为一个 **节点**。你可能希望系统分布式的原因有多:
涉及多台机器通过网络通信的系统称为 **分布式系统**。参与分布式系统的每个进程称为 **节点**。你可能希望系统分布式的原因有多
固有分布式系统
固有分布式系统
: 如果应用程序涉及两个或多个交互用户,每个用户使用自己的设备,那么系统不可避免地是分布式的:设备之间的通信必须通过网络进行。
云服务之间的请求
: 如果数据存储在一个服务中但在另一个服务中处理,则必须通过网络从一个服务传输到另一个服务。
容错/高可用
: 如果你的应用程序需要继续工作,即使一台机器(或几台机器、网络或整个数据中心)宕机,你可以使用多台机器来提供冗余。当一个失败时,另一个可以接管。参见["可靠性和容错"](/ch2#sec_introduction_reliability)和[第 6 章](/ch6#ch_replication)关于复制。
容错/高可用
: 如果你的应用程序需要在一台机器(或几台机器、网络或整个数据中心)发生故障时继续工作,你可以使用多台机器为你提供冗余。当一台故障时,另一台可以接管。参见["可靠性与容错"](/ch2#sec_introduction_reliability)和[第 6 章](/ch6#ch_replication)关于复制的内容
扩展
: 如果你的数据量或计算需求增长超过单台机器可以处理的范围,你可以潜在地将负载分散到多台机器上。参见["可扩展性"](/ch2#sec_introduction_scalability)。
伸缩
: 如果你的数据量或计算需求增长超过单台机器的处理能力,你可以潜在地将负载分散到多台机器上。参见["可伸缩性"](/ch2#sec_introduction_scalability)。
延迟
: 如果你在世界各地都有用户,你可能希望在全球各个地区都有服务器,以便每个用户都可以从地理上靠近他们的服务器获得服务。这避免了用户必须等待网络数据包在世界各地传输才能回答他们的请求。参见["描述性能"](/ch2#sec_introduction_percentiles)。
: 如果你在世界各地都有用户,你可能希望在全球各个地区都有服务器,以便每个用户都可以从地理位置接近他们的服务器获得服务。这避免了用户必须等待网络数据包绕地球半圈才能回答他们的请求。参见["描述性能"](/ch2#sec_introduction_percentiles)。
弹性
: 如果你的应用程序在某些时候繁忙而在其他时候空闲,云部署可以根据需求扩大或缩小,因此你只需为正在积极使用的资源付费。这在单台机器上更困难,它需要配置以处理最大负载,即使在几乎不使用时也是如此。
: 如果你的应用程序在某些时候很忙,在其他时候很空闲,云部署可以根据需求向上或向下扩展,因此你只需为实际使用的资源付费。这在单台机器上更困难,它需要配置以处理最大负载,即使在几乎不使用也是如此。
使用专用硬件
: 系统的不同部分可以利用不同类型的硬件来匹配其工作负载。例如,对象存储可能使用具有许多磁盘但很少 CPU 的机器,而数据分析系统可能使用具有大量 CPU 和内存但没有磁盘的机器,机器学习系统可能使用具有 GPU 的机器(对于训练深度神经网络和其他机器学习任务GPU 比 CPU 高效得多)。
: 系统的不同部分可以利用不同类型的硬件来匹配其工作负载。例如,对象存储可能使用具有许多磁盘但很少 CPU 的机器,而数据分析系统可能使用具有大量 CPU 和内存但没有磁盘的机器,机器学习系统可能使用具有 GPU 的机器(GPU 在训练深度神经网络和其他机器学习任务方面比 CPU 效率高得多)。
法律合规
: 一些国家有数据驻留法,要求关于其管辖范围内人员的数据必须在该国家地理范围内存储和处理 [^41]。
这些规则的范围各不相同——例如,在某些情况下,它仅适用于医疗或金融数据,而其他情况则更广泛。因此,在多个此类管辖区拥有用户的服务必须将其数据分布在多个位置的服务器上。
: 一些国家有数据驻留法律,要求其管辖范围内的人员数据必须在该国地理范围内存储和处理 [^41]。这些规则的范围各不相同——例如,在某些情况下,它仅适用于医疗或金融数据,而其他情况则更广泛。因此,在几个这样的管辖区域中拥有用户的服务将不得不将他们的数据分布在几个位置的服务器上。
可持续性
: 如果你在何时何地运行作业方面有灵活性,你可能能够在可再生电力充足的时间和地点运行它们,并避免在电网承受压力时运行它们。这可以减少你的碳排放,并让你在电力便宜时利用便宜的电力 [^42] [^43]。
: 如果你对运行作业的地点和时间有灵活性,你可能能够在可再生电力充足的时间和地点运行它们,并避免在电网紧张时运行它们。这可以减少你的碳排放,并允许你在电力可用时利用廉价的电力 [^42] [^43]。
这些原因既适用于你自己编写的服务(应用程序代码),也适用于由现成软件(如数据库)组成的服务。
### 分布式系统的问题 {#sec_introduction_dist_sys_problems}
分布式系统也有缺点。通过网络进行的每个请求和 API 调用都需要处理失败的可能性:网络可能中断,或者服务可能过载或崩溃,因此任何请求都可能超时而收到响应。在这种情况下,我们不知道服务是否收到了请求,简单地重试它可能不安全。我们将在[第 9 章](/ch9#ch_distributed)中详细讨论这些问题。
分布式系统也有缺点。通过网络进行的每个请求和 API 调用都需要处理失败的可能性:网络可能中断,或者服务可能过载或崩溃,因此任何请求都可能超时而没有收到响应。在这种情况下,我们不知道服务是否收到了请求,简单地重试它可能不安全。我们将在[第 9 章](/ch9#ch_distributed)中详细讨论这些问题。
虽然数据中心网络很快,但调用另一个服务仍然比在同一进程中调用函数慢得多 [^44]。
尽管数据中心网络很快,但调用另一个服务仍然比在同一进程中调用函数慢得多 [^44]。
在处理大量数据时,与其将数据从存储传输到处理它的单独机器,不如将计算带到已经拥有数据的机器上可能更快 [^45]。
更多的节点并不总是更快:在某些情况下,一台计算机上的简单单线程程序可以比拥有超过 100 个 CPU 核心的集群表现得明显更好 [^46]。
更多的节点并不总是更快:在某些情况下,单台计算机上的简单单线程程序可以比具有 100 多个 CPU 核心的集群表现得更好 [^46]。
故障排除分布式系统通常很困难:如果系统响应缓慢,你如何找出问题所在?在 **可观测性** 的标题下开发了诊断分布式系统问题的技术 [^47] [^48]
这涉及收集有关系统执行的数据,并允许以允许分析高级指标和单个事件的方式对其进行查询。**追踪** 工具(如 OpenTelemetry、Zipkin 和 Jaeger允许你跟踪哪个客户端为哪个操作调用了哪个服务器以及每个调用花费了多长时间 [^49]。
故障排除分布式系统通常很困难:如果系统响应缓慢,你如何找出问题所在?在 **可观测性** [^47] [^48] 的标题下开发了诊断分布式系统问题的技术,这涉及收集有关系统执行的数据,并允许以允许分析高级指标和单个事件的方式进行查询。**追踪** 工具(如 OpenTelemetry、Zipkin 和 Jaeger允许你跟踪哪个客户端为哪个操作调用了哪个服务器以及每次调用花费了多长时间 [^49]。
数据库提供了各种机制来确保数据一致性,正如我们将在[第 6 章](/ch6#ch_replication)和[第 8 章](/ch8#ch_transactions)中看到的。然而,当每个服务都有自己的数据库时,维护这些不同服务之间数据的一致性就成了应用程序的问题。
分布式事务(我们在[第 8 章](/ch8#ch_transactions)中探讨)是确保一致性的一种可能技术,但它们很少在微服务环境中使用,因为它们与使服务彼此独立的目标背道而驰,并且许多数据库不支持它们 [^50]。
数据库提供了各种机制来确保数据一致性,正如我们将在[第 6 章](/ch6#ch_replication)和[第 8 章](/ch8#ch_transactions)中看到的。然而,当每个服务都有自己的数据库时,维护这些不同服务之间的数据一致性就成了应用程序的问题。分布式事务(我们在[第 8 章](/ch8#ch_transactions)中探讨)是确保一致性的一种可能技术,但它们在微服务上下文中很少使用,因为它们违背了使服务彼此独立的目标,而且许多数据库不支持它们 [^50]。
由于所有这些原因,如果你可以在单台机器上做某事,与设置分布式系统相比,这通常要简单得多,成本也低得多 [^23] [^46] [^51]。
CPU、内存和磁盘已经变得更大、更快、更可靠。与单节点数据库如 DuckDB、SQLite 和 KùzuDB结合使用时许多工作负载现在可以在单个节点上运行。我们将在[第 4 章](/ch4#ch_storage)中更多地探讨这个主题。
出于所有这些原因,如果你可以在单台机器上做某事,与设置分布式系统相比,这通常要简单得多,成本也更低 [^23] [^46] [^51]。CPU、内存和磁盘已经变得更大、更快、更可靠。当与 DuckDB、SQLite 和 KùzuDB 等单节点数据库结合使用时,许多工作负载现在可以在单个节点上运行。我们将在[第 4 章](/ch4#ch_storage)中进一步探讨这个主题。
### 微服务与 Serverless {#sec_introduction_microservices}
将系统分布在多台机器上的最常见方法是将它们分为客户端和服务器并让客户端向服务器发出请求。最常见的是HTTP 用于此通信,正如我们将在["通过服务的数据流REST 和 RPC"](/ch5#sec_encoding_dataflow_rpc)中讨论的。同一进程可能既是服务器(处理传入请求)又是客户端(向其他服务发出出站请求)。
在多台机器上分布系统的最常见方式是将它们分为客户端和服务器,并让客户端向服务器发出请求。最常见的是使用 HTTP 进行此通信,正如我们将在["通过服务的数据流REST 和 RPC"](/ch5#sec_encoding_dataflow_rpc)中讨论的。同一进程可能既是服务器(处理传入请求)又是客户端(向其他服务发出出站请求)。
这种构建应用程序的方式传统上被称为 **面向服务的架构**SOA最近这个想法已经被细化为 **微服务** 架构 [^52] [^53]。
在这种架构中,服务有一个明确定义的目的(例如,在 S3 的情况下,这将是文件存储);每个服务公开一个可以由客户端通过网络调用的 API每个服务有一个负责其维护的团队。因此复杂的应用程序可以分解为多个交互服务每个服务由单独的团队管理。
这种构建应用程序的方式传统上被称为 **面向服务架构**SOA最近这个想法已经被细化为 **微服务** 架构 [^52] [^53]。在这种架构中,服务有一个明确定义的目的(例如,在 S3 的情况下,这将是文件存储);每个服务公开一个可以由客户端通过网络调用的 API每个服务有一个负责其维护的团队。因此复杂的应用程序可以分解为多个交互服务每个服务由单独的团队管理。
将复杂的软件分解为多个服务有几个优点:每个服务可以独立更新,减少团队之间的协调工作;每个服务可以分配它需要的硬件资源;通过将实现细节隐藏在 API 后面,服务所有者可以自由更改实现而不影响客户端。在数据存储方面,每个服务拥有自己的数据库并且不在服务之间共享数据库是很常见的:共享数据库将有效地使整个数据库结构成为服务 API 的一部分,然后该结构将难更改。共享数据库还可能导致一个服务的查询对其他服务的性能产生负面影响。
将复杂的软件分解为多个服务有几个优点:每个服务可以独立更新,减少团队之间的协调工作;每个服务可以分配它需要的硬件资源;通过将实现细节隐藏在 API 后面,服务所有者可以自由更改实现而不影响客户端。在数据存储方面,每个服务通常有自己的数据库,而不在服务之间共享数据库:共享数据库实际上会使整个数据库结构成为服务 API 的一部分,然后该结构将难更改。共享数据库还可能导致一个服务的查询对其他服务的性能产生负面影响。
另一方面,拥有许多服务本身可能会滋生复杂性:每个服务都需要基础设施来部署新版本、调整分配的硬件资源以匹配负载、收集日志、监控服务健康状况,并在出现问题时向值班工程师发出警报。**编排** 框架(如 Kubernetes已成为部署服务的流行方式因为它们为这种基础设施提供了基础。在开发期间测试服务可能很复杂,因为你还需要运行它所依赖的所有其他服务。
另一方面,拥有许多服务本身可能会带来复杂性:每个服务都需要用于部署新版本、调整分配的硬件资源以匹配负载、收集日志、监控服务健康状况以及在出现问题时向值班工程师发出警报的基础设施。**编排** 框架(如 Kubernetes已成为部署服务的流行方式因为它们为这种基础设施提供了基础。在开发期间测试服务可能很复杂因为你还需要运行它所依赖的所有其他服务。
微服务 API 的演进可能具有挑战性。调用 API 的客户端期望 API 具有某些字段。开发人员可能希望随着业务需求的变化向 API 添加或删除字段,但这样做可能会导致客户端失败。更糟糕的是,这种失败通常直到开发周期的后期才被发现,当更新的服务 API 部署到暂存或生产环境时。API 描述标准(如 OpenAPI 和 gRPC有助于管理客户端和服务器 API 之间的关系;我们将在[第 5 章](/ch5#ch_encoding)中进一步讨论这些。
微服务 API 的演进可能具有挑战性。调用 API 的客户端期望 API 具有某些字段。开发人员可能希望根据业务需求的变化向 API 添加或删除字段,但这样做可能会导致客户端失败。更糟糕的是,这种失败通常直到开发周期的后期才被发现,当更新的服务 API 部署到暂存或生产环境时。API 描述标准(如 OpenAPI 和 gRPC有助于管理客户端和服务器 API 之间的关系;我们将在[第 5 章](/ch5#ch_encoding)中进一步讨论这些。
微服务主要是人员问题的技术解决方案:允许不同的团队独立取得进展,而无需相互协调。这在大公司中很有价值,但在没有很多团队的小公司中,使用微服务可能是不必要的开销,最好以最简单的方式实现应用程序 [^52]。
**无服务器** 或 **函数即服务**FaaS是部署服务的另一种方法其中基础设施的管理外包给云供应商 [^33]。
使用虚拟机时,你必须明确选择何时启动或关闭实例;相比之下,使用无服务器模型,云提供商根据对你的服务的传入请求自动分配和释放硬件资源 [^54]。
无服务器部署将更多的运营负担转移到云提供商,并通过使用而不是机器实例实现灵活计费。为了提供这些好处,许多无服务器基础设施提供商对函数执行施加时间限制、限制运行时环境,并且在首次调用函数时可能会遭受缓慢的启动时间。术语"无服务器"也可能具有误导性每个无服务器函数执行仍在服务器上运行但后续执行可能在不同的服务器上运行。此外BigQuery 和各种 Kafka 产品等基础设施已采用"无服务器"术语来表示其服务自动扩展,并且它们按使用而不是机器实例计费。
**Serverless** 或 **函数即服务**FaaS是部署服务的另一种方法其中基础设施的管理外包给云供应商 [^33]。使用虚拟机时,你必须明确选择何时启动或关闭实例;相比之下,使用 serverless 模型,云提供商根据对你服务的传入请求自动分配和释放硬件资源 [^54]。Serverless 部署将更多的运营负担转移到云提供商,并通过使用量而不是机器实例实现灵活的计费。为了提供这些好处,许多 serverless 基础设施提供商对函数执行施加时间限制,限制运行时环境,并且在首次调用函数时可能会遭受缓慢的启动时间。术语"serverless"也可能具有误导性:每个 serverless 函数执行仍然在服务器上运行但后续执行可能在不同的服务器上运行。此外BigQuery 和各种 Kafka 产品等基础设施已经采用"serverless"术语来表示他们的服务自动扩展,并且他们按使用量而不是机器实例计费。
就像云存储用计量计费模型取代了容量规划(提前决定购买多少磁盘)一样,无服务器方法正在为代码执行带来计量计费:你只为应用程序代码实际运行的时间付费,而不必提前配置资源。
就像云存储用计量计费模型取代了容量规划提前决定购买多少磁盘一样serverless 方法正在为代码执行带来计量计费:你只为应用程序代码实际运行的时间付费,而不必提前配置资源。
### 云计算与超级计算 {#id17}
云计算不是构建大规模计算系统的唯一方式;另一种选择是 **高性能计算**HPC也称为 **超级计算**虽然有重叠但与云计算和企业数据中心系统相比HPC 通常有不同的优先级并使用不同的技术。其中一些差异是:
云计算不是构建大规模计算系统的唯一方式;另一种选择是 **高性能计算**HPC也称为 **超级计算**尽管有重叠但与云计算和企业数据中心系统相比HPC 通常有不同的优先级并使用不同的技术。其中一些差异是:
* 超级计算机通常用于计算密集型科学计算任务,如天气预报、气候建模、分子动力学(模拟原子和分子的运动)、复杂优化问题和求解偏微分方程。另一方面,云计算往往用于在线服务、业务数据系统以及需要以高可用性为用户请求提供服务的类似系统。
* 超级计算机通常运行大型批处理作业,这些作业会定期将其计算状态检查点到磁盘。如果节点发生故障,常见的解决方案是简单地停止整个集群工作负载,修复故障节点,然后从最后一个检查点重新启动计算 [^55] [^56]。
对于云服务,通常不希望停止整个集群,因为服务需要以最小的中断持续为用户提供服务。
* 超级计算机节点通常通过共享内存和远程直接内存访问RDMA进行通信这支持高带宽和低延迟但假设系统用户之间具有高度信任 [^57]。
在云计算中,网络和机器通常由相互不信任的组织共享,需要更强的安全机制,如资源隔离(例如,虚拟机)、加密和身份验证。
* 云数据中心网络通常基于 IP 和以太网,以 Clos 拓扑排列以提供高二分带宽——网络整体性能的常用度量 [^55] [^58]。
超级计算机通常使用专门的网络拓扑,如多维网格和环面 [^59]
这些拓扑为具有已知通信模式的 HPC 工作负载产生更好的性能。
* 云计算允许节点分布在多个地理区域,而超级计算机通常假设其所有节点都紧密相邻。
* 超级计算机通常用于计算密集型科学计算任务,例如天气预报、气候建模、分子动力学(模拟原子和分子的运动)、复杂优化问题和求解偏微分方程。另一方面,云计算往往用于在线服务、业务数据系统和需要以高可用性为用户请求提供服务的类似系统。
* 超级计算机通常运行大型批处理作业,定期将其计算状态检查点到磁盘。如果节点发生故障,常见的解决方案是简单地停止整个集群工作负载,修复故障节点,然后从最后一个检查点重新启动计算 [^55] [^56]。对于云服务,通常不希望停止整个集群,因为服务需要以最小的中断持续为用户提供服务。
* 超级计算机节点通常通过共享内存和远程直接内存访问RDMA进行通信这支持高带宽和低延迟但假设系统用户之间有高度的信任 [^57]。在云计算中,网络和机器通常由相互不信任的组织共享,需要更强的安全机制,如资源隔离(例如,虚拟机)、加密和身份验证。
* 云数据中心网络通常基于 IP 和以太网,以 Clos 拓扑排列以提供高对分带宽——这是网络整体性能的常用度量 [^55] [^58]。超级计算机通常使用专门的网络拓扑,例如多维网格和环面 [^59],这为具有已知通信模式的 HPC 工作负载产生更好的性能。
* 云计算允许节点分布在多个地理区域,而超级计算机通常假设它们的所有节点都靠近在一起。
大规模分析系统有时与超级计算共享一些特征,如果你在这个领域工作,了解这些技术可能是值得的。然而,本书主要关注需要持续可用的服务,如["可靠性容错"](/ch2#sec_introduction_reliability)中所讨论的。
大规模分析系统有时与超级计算共享一些特征,如果你在这个领域工作,了解这些技术可能是值得的。然而,本书主要关注需要持续可用的服务,如["可靠性与容错"](/ch2#sec_introduction_reliability)中所讨论的。
## 数据系统、法律与社会 {#sec_introduction_compliance}
到目前为止,你已经在本章中看到,数据系统的架构不仅受技术目标和要求的影响,还受它们支持的组织的人类需求的影响。越来越多的数据系统工程师意识到,仅为自己的业务需求服务是不够的:我们还对整个社会负有责任。
到目前为止,你已经在本章中看到,数据系统的架构不仅受到技术目标和要求的影响,还受到它们所支持的组织的人力需求的影响。越来越多的数据系统工程师认识到,仅服务于自己企业的需求是不够的:我们还对整个社会负有责任。
一个特别关注的问题是存储关于人及其行为的数据的系统。自 2018 年以来,**通用数据保护条例**GDPR赋予许多欧洲国家居民对其个人数据更大控制和法律权利,类似的隐私法规已在世界各地的各个国家和州采用,例如加州消费者隐私法CCPA。关于 AI 的法规,如 **欧盟 AI 法案**,对个人数据的使用方式施加了进一步的限制。
一个特别的关注点是存储有关人员及其行为数据的系统。自 2018 年以来,**通用数据保护条例**GDPR赋予许多欧洲国家居民对其个人数据更大控制和法律权利,类似的隐私法规已在世界各地的各个国家和州采用,包括例如加州消费者隐私法CCPA。关于 AI 的法规,**欧盟 AI 法案**,对个人数据的使用方式施加了进一步的限制。
此外,即使在不直接受监管的领域,人们也越来越认识到计算机系统对人和社会的影响。社交媒体改变了个人消费新闻的方式,这影响了他们的政治观点,因此可能影响选举结果。自动化系统越来越多地做出对个人产生深远影响的决定,例如决定谁应该获得贷款或保险、谁应该被邀请参加工作面试,或者谁应该被怀疑犯罪 [^60]。
此外,即使在不直接受法规约束的领域,人们也越来越认识到计算机系统对人和社会的影响。社交媒体改变了个人消费新闻的方式,这影响了他们的政治观点,因此可能影响选举结果。自动化系统越来越多地做出对个人产生深远影响的决策,例如决定谁应该获得贷款或保险覆盖,谁应该被邀请参加工作面试,或者谁应该被怀疑犯罪 [^60]。
从事此类系统工作的每个人都有责任考虑道德影响并确保它们符合相关法律。没有必要让每个人都成为法律和道德专家,但对法律和道德原则的基本认识与对分布式系统的一些基础知识一样重要。
每个从事此类系统工作的人都有责任考虑道德影响并确保他们遵守相关法律。没有必要让每个人都成为法律和道德专家,但对法律和道德原则的基本认识与分布式系统中的一些基础知识同样重要。
法律考虑正在影响数据系统设计的基础 [^61]。
例如GDPR 授予个人根据要求删除其数据的权利(有时称为 **被遗忘权**)。然而,正如我们将在本书中看到的,许多数据系统依赖于不可变构造(如仅追加日志)作为其设计的一部分;我们如何确保删除应该是不可变的文件中间的某些数据?我们如何处理已纳入派生数据集(参见["权威记录系统与派生数据"](/ch1#sec_introduction_derived))的数据的删除,例如机器学习模型的训练数据?回答这些问题会带来新的工程挑战。
法律考虑正在影响数据系统设计的基础 [^61]。例如GDPR 授予个人在请求时删除其数据的权利(有时称为 **被遗忘权**)。然而,正如我们将在本书中看到的,许多数据系统依赖不可变构造(如仅追加日志)作为其设计的一部分;我们如何确保删除应该不可变的文件中间的某些数据?我们如何处理已被纳入衍生数据集(参见["权威数据源与派生数据"](/ch1#sec_introduction_derived))的数据删除,例如机器学习模型的训练数据?回答这些问题会带来新的工程挑战。
目前,我们没有关于哪些特定技术或系统架构应被视为"符合 GDPR"的明确指导方针。该法规故意不强制要求特定技术,因为随着技术的进步,这些技术可能会迅速变化。相反,法律文本阐述了需要解释的高级原则。这意味着如何遵守隐私法规的问题没有简单的答案,但我们将通过这个视角来看待本书中的一些技术。
目前,我们对于哪些特定技术或系统架构应被视为"符合 GDPR"没有明确的指导方针。法规故意不强制要求特定技术,因为随着技术的进步,这些技术可能会迅速变化。相反,法律文本规定了需要解释的高级原则。这意味着如何遵守隐私法规的问题没有简单的答案,但我们将通过这个视角来看待本书中的一些技术。
一般来说,我们存储数据是因为我们认为其价值大于存储它的成本。然而,值得记住的是,存储成本不仅仅是你为 Amazon S3 或其他服务支付的账单:成本效益计算还应考虑到如果数据被泄露或被对手破坏的责任和声誉损害风险,以及如果数据的存储和处理被发现不符合法律的法律成本和罚款风险 [^51]。
一般来说,我们存储数据是因为我们认为其价值大于存储它的成本。然而,值得记住的是,存储成本不仅仅是你为 Amazon S3 或其他服务支付的账单:成本效益计算还应该考虑到如果数据被泄露或被对手入侵的责任和声誉损害风险,以及如果数据的存储和处理被发现不符合法律的法律成本和罚款风险 [^51]。
政府或警察部队也可能迫使公司交出数据。当存在数据可能揭示犯罪行为的风险时(例如,在几个中东和非洲国家的同性恋,或在几个美国州寻求堕胎),存储该数据会给用户带来真正的安全风险。例如,前往堕胎诊所的旅行很容易通过位置数据揭示,甚至可能通过用户 IP 地址随时间的日志(表示大致位置)。
政府或警察部队也可能迫使公司交出数据。当存在数据可能暴露犯罪行为的风险时(例如,在几个中东和非洲国家的同性恋,或在几个美国州寻求堕胎),存储该数据会为用户创造真正的安全风险。例如,去堕胎诊所的旅行很容易被位置数据揭示,甚至可能通过用户 IP 地址随时间的日志(表示大致位置)。
一旦考虑到所有风险,可能合理地决定某些数据根本不值得存储,因此应该删除。这种 **数据最小化** 原则(有时用德语术语 **Datensparsamkeit** 称呼)与"大数据"理念相反,后者是推测性地存储大量数据,以防将来有用 [^62]。
但它符合 GDPR该法规要求个人数据只能为指定的明确目的收集该数据以后不得用于任何其他目的并且数据不得保留超过收集目的所需的时间 [^63]。
一旦考虑到所有风险,可能合理地决定某些数据根本不值得存储,因此应该删除。这个 **数据最小化** 原则(有时以德语术语 **Datensparsamkeit** 为人所知)与"大数据"哲学相反,后者是投机性地存储大量数据,以防将来有用 [^62]。但它符合 GDPR该法规要求个人数据只能为指定的、明确的目的收集这些数据以后不得用于任何其他目的并且数据不得保留超过收集目的所需的时间 [^63]。
企业也注意到了隐私和安全问题。信用卡公司要求支付处理企业遵守严格的支付卡行业PCI标准。处理经常接受独立审计师的评估,以验证持续合规性。软件供应商也受到了更多的审查。许多买家现在要求他们的供应商遵守服务组织控制SOC第 2 类标准。与 PCI 合规性一样,供应商接受第三方审计以验证遵守情况。
企业也注意到了隐私和安全问题。信用卡公司要求支付处理企业遵守严格的支付卡行业PCI标准。处理经常接受独立审计师的评估,以验证持续合规性。软件供应商也受到了更多的审查。现在许多买家要求他们的供应商遵守服务组织控制SOC类型 2 标准。与 PCI 合规性一样,供应商接受第三方审计以验证遵守情况。
一般来说,重要的是平衡你的业务需求与你正在收集和处理其数据的人的需求。这个话题还有很多内容;在[待补充链接]中,我们将更深入地探讨道德和法律合规主题,包括偏见和歧视问题。
一般来说,重要的是平衡你的业务需求与你收集和处理其数据的人的需求。这个话题还有很多内容;在[待补充链接]中,我们将更深入地探讨道德和法律合规性的主题,包括偏见和歧视问题。
## 总结 {#summary}
本章的主题是理解权衡:也就是说,认识到对于许多问题没有一个正确的答案,而是有几种不同的方法,每种方法都有各种利弊。我们探讨了影响数据系统架构的一些最重要的选择,并介绍了本书其余部分所需的术语。
本章的主题是理解权衡:也就是说,认识到对于许多问题没有一个正确的答案,而是有几种不同的方法,每种方法都有各种利弊。我们探讨了影响数据系统架构的一些最重要的选择,并介绍了本书其余部分所需的术语。
我们首先区分了事务型事务处理OLTP和分析型OLAP系统并看到了它们的不同特征不仅管理具有不同访问模式的不同类型的数据,而且服务于不同的受众。我们遇到了数据仓库和数据湖的概念,它们通过 ETL 从事务型系统接收数据馈送。在[第 4 章](/ch4#ch_storage)中,我们将看到事务型和分析型系统由于需要服务的不同类型的查询,通常使用非常不同的内部数据布局。
我们首先区分了事务型事务处理OLTP和分析型OLAP系统并看到了它们的不同特征不仅管理不同类型的数据和不同的访问模式,而且服务于不同的受众。我们遇到了数据仓库和数据湖的概念,它们通过 ETL 从事务型系统接收数据馈送。在[第 4 章](/ch4#ch_storage)中,我们将看到事务型和分析型系统通常使用非常不同的内部数据布局,因为它们需要服务的查询类型不同
然后,我们将云服务(一个相对较新的发展)与传统的自托管软件范式进行了比较,后者以前主导着数据系统架构。这些方法中哪一个更具成本效益在很大程度上取决于你的特定情况,但不可否认的是,云原生方法正在为数据系统的架构带来重大变化,例如它们分离存储和计算的方式。
然后,我们将云服务(一个相对较新的发展)与传统的自托管软件范式进行了比较,后者以前主导了数据系统架构。这些方法中哪一种更具成本效益在很大程度上取决于你的特定情况,但不可否认的是,云原生方法正在为数据系统的架构带来重大变化,例如它们分离存储和计算的方式。
云系统本质上是分布式的,我们简要地研究了分布式系统与使用单台机器相比的一些权衡。在某些情况下,你无法避免分布式,但如果可能将其保留在单台机器上,建议不要急于使系统分布式。在[第 9 章](/ch9#ch_distributed)中,我们将更详细地介绍分布式系统的挑战。
云系统本质上是分布式的,我们简要地研究了分布式系统与使用单台机器相比的一些权衡。有些情况下你无法避免分布式,但如果可能在单台机器上保持系统,建议不要急于使系统分布式。在[第 9 章](/ch9#ch_distributed)中,我们将更详细地介绍分布式系统的挑战。
最后,我们看到数据系统架构不仅由部署系统的业务需求决定,还由保护其数据被处理的人的权利的隐私法规决定——这是许多工程师倾向于忽视的一个方面。我们如何将法律要求转化为技术实现还不太清楚,但在我们浏览本书的其余部分时,记住这个问题很重要。
最后,我们看到数据系统架构不仅由部署系统的企业的需求决定,还由保护数据被处理的人的权利的隐私法规决定——这是许多工程师容易忽视的一个方面。我们如何将法律要求转化为技术实现还不太了解,但在我们阅读本书的其余部分时,记住这个问题很重要。
### 参考
@ -492,3 +473,4 @@ CPU、内存和磁盘已经变得更大、更快、更可靠。与单节点数
[^61]: Supreeth Shastri, Vinay Banakar, Melissa Wasserman, Arun Kumar, and Vijay Chidambaram. [Understanding and Benchmarking the Impact of GDPR on Database Systems](https://www.vldb.org/pvldb/vol13/p1064-shastri.pdf). *Proceedings of the VLDB Endowment*, volume 13, issue 7, pages 10641077, March 2020. [doi:10.14778/3384345.3384354](https://doi.org/10.14778/3384345.3384354)
[^62]: Martin Fowler. [Datensparsamkeit](https://www.martinfowler.com/bliki/Datensparsamkeit.html). *martinfowler.com*, December 2013. Archived at [perma.cc/R9QX-CME6](https://perma.cc/R9QX-CME6)
[^63]: [Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 (General Data Protection Regulation)](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679&from=EN). *Official Journal of the European Union* L 119/1, May 2016.

View File

@ -1,43 +1,42 @@
---
title: "2. 定义非功能性需求"
weight: 102
math: true
breadcrumbs: false
---
![](/map/ch01.png)
> *互联网做得如此之好,以至于大多数人认为它是一种自然资源,就像太平洋一样,而不是人造的东西。上一次有这种规模的技术如此没有错误是什么时候?*
> *互联网做得如此之好,以至于大多数人都把它想象成像太平洋一样的自然资源,而不是人造的东西。上一次出现这种规模且无差错的技术是什么时候?*
>
> [Alan Kay](https://www.drdobbs.com/architecture-and-design/interview-with-alan-kay/240003442)
> 在接受 *Dr Dobb's Journal* 采访时2012
> [艾伦・凯](https://www.drdobbs.com/architecture-and-design/interview-with-alan-kay/240003442)
> 在接受 *Dr Dobb's Journal* 采访时2012
如果你正在构建一个应用程序,你将受到一系列需求的驱动。在你的列表顶部,最可能的是应用程序必须提供的功能:你需要什么屏幕和什么按钮,以及每个操作应该做什么以实现你的软件目的。这些是你的 **功能性需求**。
如果你正在构建一个应用程序,你将会被一系列需求所驱动。在你的需求列表中,最重要的可能是应用程序必须提供的功能:需要哪些界面和按钮,以及每个操作应该做什么,以实现软件的目的。这些是你的 *功能性需求*。
此外,你可能还有一些 **非功能性需求**:例如,应用程序应该快速、可靠、安全、合规,并且易于维护。这些需求可能没有明确写下来,因为它们可能看起来有些显而易见,但它们和应用程序的功能一样重要:一个慢得难以忍受或不可靠的应用程序可能等同于不存在。
此外,你可能还有一些 *非功能性需求*:例如,应用程序应该快速、可靠、安全、合规,并且易于维护。这些需求可能没有明确写下来,因为它们看起来有些显而易见,但它们与应用程序的功能同样重要:一个慢得让人无法忍受或不可靠的应用程序还不如不存在。
许多非功能性需求,如安全性,超出了本书的范围。但是有一些非功能性需求我们将考虑,本章将帮助你为自己的系统阐明它们:
许多非功能性需求,比如安全性,超出了本书的范围。但我们将考虑一些非功能性需求,本章将帮助你为自己的系统阐明它们:
* 如何定义和衡量系统的 **性能**(参见 ["描述性能"](/ch2#sec_introduction_percentiles)
* 服务 **可靠** 意味着什么——即使在出现问题时也能继续正确工作(参见 ["可靠性和容错"](/ch2#sec_introduction_reliability)
* 通过在系统负载增长时有效添加计算能力的方法,使系统具有 **可扩展性**(参见 ["可扩展性"](/ch2#sec_introduction_scalability));以及
* 使系统长期易维护(参见 ["可性"](/ch2#sec_introduction_maintainability))。
* 如何定义和衡量系统的 *性能*(参见 ["描述性能"](/ch2#sec_introduction_percentiles)
* 服务 *可靠* 意味着什么——即即使出现问题也能继续正确工作(参见 ["可靠性与容错"](/ch2#sec_introduction_reliability)
* 通过在系统负载增长时添加计算能力的有效方法,使系统具有 *可伸缩性*(参见 ["可伸缩性"](/ch2#sec_introduction_scalability));以及
* 使系统长期更易维护(参见 ["可维性"](/ch2#sec_introduction_maintainability))。
本章介绍的术语在后续章节中也很有用,当我们深入了解数据密集型系统的实现细节时。然而,抽象定义可能相当枯燥;为了让这些想法更具体,我们将以一个社交网络服务如何工作的案例研究开始本章,这将提供性能和可扩展性的实际示例。
本章介绍的术语在后续章节中也很有用,当我们深入研究数据密集型系统的实现细节时。然而,抽象定义可能相当枯燥;为了使这些想法更具体,我们将从一个案例研究开始本章,研究社交网络服务可能如何工作,这将提供性能和可伸缩性的实际示例。
## 案例研究:社交网络首页时间线 {#sec_introduction_twitter}
想象一下,你被赋予实现一个 X前 Twitter风格的社交网络的任务用户可以发布消息并关注其他用户。这将是对这种服务实际工作方式的巨大简化 [^1] [^2] [^3],但它将有助于说明大规模系统中出现的一些问题。
想象一下,你被赋予实现一个类似 X身为 Twitter风格的社交网络的任务用户可以发布消息并关注其他用户。这将是对这种服务实际工作方式的巨大简化 [^1] [^2] [^3],但它将有助于说明大规模系统中出现的一些问题。
假设用户每天发布 5 亿条消息,平均每秒 5,700 条帖子。偶尔,速率可能飙升至每秒 150,000 条帖子 [^4]。我们还假设平均用户关注 200 人,并有 200 个粉丝(尽管范围很广:大多数人只有少数粉丝,而少数名人如巴拉克·奥巴马有超过 1 亿粉丝)。
假设用户每天发布 5 亿条帖子,或平均每秒 5,700 条帖子。偶尔,速率可能飙升至每秒 150,000 条帖子 [^4]。我们还假设平均每个用户关注 200 人并有 200 个粉丝(尽管范围非常广泛:大多数人只有少数粉丝,而少数名人如巴拉克・奥巴马有超过 1 亿粉丝)。
### 表示用户、帖子与关注关系 {#id20}
想象我们将所有数据保存在关系数据库中,如 [图 2-1](/ch2#fig_twitter_relational) 所示。我们有一个用户表,一个帖子表,和一个关注关系表。
假设我们将所有数据保存在关系数据库中,如 [图 2-1](/ch2#fig_twitter_relational) 所示。我们有一个用户表、一个帖子表和一个关注关系表。
{{< figure src="/fig/ddia_0201.png" id="fig_twitter_relational" caption="图 2-1. 用户可以相互关注的社交网络的简单关系模式。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0201.png" id="fig_twitter_relational" caption="图 2-1. 社交网络的简单关系模式,用户可以相互关注。" class="w-full my-4" >}}
假设我们的社交网络必须支持的主要读取操作是 **主页时间线**,它显示你关注的人最近的帖子(为简单起见,我们将忽略广告、来自你未关注的人的建议帖子和其他扩展)。我们可以编写以下 SQL 查询来获取特定用户的主页时间线:
假设我们的社交网络必须支持的主要读取操作是 *首页时间线*,它显示你关注的人最近发布的帖子(为简单起见,我们将忽略广告、来自你未关注的人的推荐帖子和其他扩展)。我们可以编写以下 SQL 查询来获取特定用户的首页时间线:
```sql
SELECT posts.*, users.* FROM posts
@ -48,103 +47,103 @@ SELECT posts.*, users.* FROM posts
LIMIT 1000
```
要执行此查询,数据库将使用 `follows``current_user` 关注的所有人,查找这些用户最近帖子,并按时间戳排序以获取任何被关注用户的最新 1,000 条帖子。
要执行此查询,数据库将使用 `follows` 表找 `current_user` 关注的所有人,查找这些用户最近帖子,并按时间戳排序以获取被关注用户的最新 1,000 条帖子。
帖子应该是及时的,所以假设在某人发布帖子后,我们希望他们的粉丝能够在 5 秒内看到它。一种方法是用户的客户端在用户在线时每 5 秒重复上述查询(这称为 **轮询**)。如果我们假设有 1000 万用户同时在线并登录,那意味着每秒运行查询 200 万次。即使你增加轮询间隔,这也是很多的
帖子应该是及时的,所以假设在某人发布帖子后,我们希望他们的粉丝能够在 5 秒内看到它。一种方法是让用户的客户端每 5 秒重复上述查询(这称为 *轮询*)。如果我们假设有 1000 万用户同时在线登录,这意味着每秒运行 200 万次查询。即使增加轮询间隔,这也是很大的负载
此外,上述查询相当昂贵:如果你关注 200 人,它需要获取这 200 人中每个人的最近帖子列表,并合并这些列表。每秒 200 万次时间线查询意味着数据库需要每秒从某个发送者查找最近的帖子 4 亿次——一个巨大的数字。这是平均情况。一些用户关注数万个帐户;对他们来说,这个查询执行起来非常昂贵,并且很难快速执行
此外,上述查询相当昂贵:如果你关注 200 人,它需要获取这 200 人中每个人的最近帖子列表,并合并这些列表。每秒 200 万次时间线查询意味着数据库需要每秒查找某个发送者的最近帖子 4 亿次——这是一个巨大的数字。这是平均情况。一些用户关注数万个账户;对他们来说,这个查询执行起来非常昂贵,而且很难快速完成
### 时间线的物化与更新 {#sec_introduction_materializing}
我们如何做得更好?首先,服务器主动将新帖子推送给当前在线的任何粉丝,而不是轮询,会更好。其次,我们应该预先计算上述查询的结果,以便可以从缓存中提供用户对其主页时间线的请求。
我们如何做得更好?首先,与其轮询,不如服务器主动向当前在线的任何粉丝推送新帖子。其次,我们应该预先计算上述查询的结果,以便可以从缓存中提供用户的首页时间线请求。
想象一下,对于每个用户,我们存储一个包含其主页时间线的数据结构,即他们关注的人的最近帖子。每次用户发布帖子时,我们查找他们的所有粉丝,并将该帖子插入每个粉丝的主页时间线——就像将消息投递到邮箱一样。现在当用户登录时,我们可以简单地给他们我们预先计算的主页时间线。此外,要接收有关其时间线上任何新帖子的通知,用户的客户端只需订阅添加到其主页时间线的帖子流。
想象一下,我们为每个用户存储一个包含其首页时间线的数据结构,即他们关注的人的最近帖子。每次用户发布帖子时,我们查找他们的所有粉丝,并将该帖子插入到每个粉丝的首页时间线中——就像向邮箱投递消息一样。现在当用户登录时,我们可以简单地给他们这个预先计算的首页时间线。此外,要接收时间线上任何新帖子的通知,用户的客户端只需订阅添加到其首页时间线的帖子流。
这种方法的缺点是,每次用户发布帖子时,我们现在需要做更多的工作,因为主页时间线是需要更新的派生数据。该过程如 [图 2-2](/ch2#fig_twitter_timelines) 所示。当一个初始请求导致执行几个下游请求时,我们使用术语 **扇出** 来描述请求数量增加的因子。
这种方法的缺点是,现在每次用户发布帖子时我们需要做更多的工作,因为首页时间线是需要更新的衍生数据。该过程如 [图 2-2](/ch2#fig_twitter_timelines) 所示。当一个初始请求导致几个下游请求被执行时,我们使用术语 *扇出* 来描述请求数量增加的因子。
{{< figure src="/fig/ddia_0202.png" id="fig_twitter_timelines" caption="图 2-2. 扇出:将新帖子递给发布帖子的用户的每个粉丝。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0202.png" id="fig_twitter_timelines" caption="图 2-2. 扇出:将新帖子递给发布帖子的用户的每个粉丝。" class="w-full my-4" >}}
以每秒 5,700 条帖子的速率,如果平均帖子到达 200 个粉丝(即扇出因子为 200我们将需要每秒执行超过 100 万次主页时间线写入。这很多,但与我们否则必须执行的每秒 4 亿次按发送者的帖子查找相比,这仍然是一个显著的节省。
以每秒 5,700 条帖子的速率,如果平均帖子到达 200 个粉丝(即扇出因子为 200我们将需要每秒执行超过 100 万次首页时间线写入。这很多,但与我们本来需要的每秒 4 亿次每个发送者的帖子查找相比,这仍然是一个显著的节省。
如果由于某些特殊事件导致帖子速率激增,我们不必立即进行时间线投递——我们可以将它们排队,并接受帖子暂时需要更长时间才能显示在粉丝的时间线中。即使在这种负载峰值期间,时间线仍然可以快速加载,因为我们只是从缓存中提供它们。
如果由于某些特殊事件导致帖子速率激增,我们不必立即进行时间线交付——我们可以将它们排队,并接受帖子在粉丝的时间线中显示会暂时花费更长时间。即使在这种负载峰值期间,时间线仍然可以快速加载,因为我们只是从缓存中提供它们。
这种预先计算和更新查询结果的过程称为 **物化**,时间线缓存是 **物化视图** 的一个例子(我们将在 [Link to Come] 中进一步讨论这个概念)。物化视图加速了读取,但作为回报,我们必须在写入时做更多的工作。对于大多数用户来说,写入成本是适度的,但社交网络还必须考虑一些极端情况:
这种预先计算和更新查询结果的过程称为 *物化*,时间线缓存是 *物化视图* 的一个例子(我们将在 [后续章节] 中进一步讨论这个概念)。物化视图加速了读取,但作为回报,我们必须在写入时做更多的工作。对于大多数用户来说,写入成本是适度的,但社交网络还必须考虑一些极端情况:
* 如果用户关注了大量帐户,并且这些帐户发布了很多内容,那么该用户的物化时间线将有很高的写入率。然而,在这种情况下,用户实际上不太可能阅读其时间线中的所有帖子,因此可以简单地丢弃其时间线写入的一部分,只向用户显示他们关注的帐户的帖子样本 [^5]。
* 当拥有大量粉丝的名人帐户发布帖子时,我们必须做大量工作将该帖子插入其数百万粉丝的主页时间线。在这种情况下,不能丢弃其中一些写入。解决此问题的一种方法是将名人帖子与其他人的帖子分开处理:我们可以通过单独存储名人帖子并在读取物化时间线时将它们合并,从而节省将它们添加到数百万时间线的工作。尽管有这些优化,在社交网络上处理名人可能需要大量基础设施 [^6]。
* 如果用户关注非常多的账户,并且这些账户发布很多内容,该用户的物化时间线将有很高的写入率。然而,在这种情况下,用户实际上不太可能阅读其时间线中的所有帖子,因此可以简单地丢弃其时间线的一些写入,只向用户显示他们关注的账户的帖子样本 [^5]。
* 当拥有大量粉丝的名人账户发布帖子时,我们必须做大量工作将该帖子插入到他们数百万粉丝的每个首页时间线中。在这种情况下,丢弃一些写入是不可接受的。解决这个问题的一种方法是将名人帖子与其他人的帖子分开处理:我们可以通过将名人帖子单独存储并在读取时与物化时间线合并,来节省将它们添加到数百万时间线的工作。尽管有这些优化,在社交网络上处理名人仍然需要大量基础设施 [^6]。
## 描述性能 {#sec_introduction_percentiles}
大多数关于软件性能的讨论考虑两种主要类型的指标
大多数关于软件性能的讨论考虑两种主要的度量类型:
响应时间
: 从用户发出请求到收到请求答案的经过时间。测量单位是秒(或毫秒,或微秒)。
: 从用户发出请求到收到请求答案的经过时间。测量单位是秒(或毫秒,或微秒)。
吞吐量
: 系统正在处理的每秒请求数,或每秒数据量。对于给定的硬件资源分配,存在可以处理的 **最大吞吐量**。测量单位是"每秒的某物"。
: 系统正在处理的每秒请求数,或每秒数据量。对于给定的硬件资源分配,存在可以处理的 *最大吞吐量*。测量单位是"每秒某物"。
在社交网络案例研究中,"每秒帖子数"和"每秒时间线写入数"是吞吐量指标,而"加载主页时间线所需的时间"或"帖子投递给粉丝所需的时间"是响应时间指标。
在社交网络案例研究中,"每秒帖子数"和"每秒时间线写入数"是吞吐量指标,而"加载首页时间线所需的时间"或"帖子传递给粉丝的时间"是响应时间指标。
吞吐量和响应时间之间通常存在联系;[图 2-3](/ch2#fig_throughput) 勾画了在线服务的这种关系示例。当请求吞吐量低时,服务的响应时间低,但随着负载增加,响应时间增加。这是因为 **排队**当请求到达高负载系统时CPU 很可能已经在处理较早的请求,因此传入的请求需要等待较早的请求完成。随着吞吐量接近硬件可以处理的最大值,排队延迟急剧增加。
吞吐量和响应时间之间通常存在联系;在线服务的这种关系示例如 [图 2-3](/ch2#fig_throughput) 所示。当请求吞吐量较低时,服务具有较低的响应时间,但随着负载增加,响应时间也会增加。这是因为 *排队*当请求到达高负载系统时CPU 很可能已经在处理先前的请求,因此传入请求需要等待先前请求完成。随着吞吐量接近硬件可以处理的最大值,排队延迟急剧增加。
{{< figure src="/fig/ddia_0203.png" id="fig_throughput" caption="图 2-3. 当服务的吞吐量接近其容量时,由于排队,响应时间急剧增加。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0203.png" id="fig_throughput" caption="图 2-3. 随着服务的吞吐量接近其容量,由于排队,响应时间急剧增加。" class="w-full my-4" >}}
--------
> [!TIP] 当过载系统无法恢复时
如果系统接近过载,吞吐量被推到接近极限,它有时会进入恶性循环,变得效率更低,因此更加过载。例如,如果有一长串请求等待处理,响应时间可能会增加到客户端超时并重新发送请求的程度。这导致请求率进一步增加,使问题变得更糟——**重试风暴**。即使负载再次降低,这样的系统也可能保持过载状态,直到重新启动或以其他方式重置。这种现象被称为 **亚稳态故障**,它可能导致生产系统中的严重中断 [^7] [^8]。
如果系统接近过载,吞吐量被推到极限附近,它有时会进入恶性循环,变得效率更低,从而更加过载。例如,如果有很长的请求队列等待处理,响应时间可能会增加到客户端超时并重新发送请求的程度。这导致请求率进一步增加,使问题变得更糟——*重试风暴*。即使负载再次降低,这样的系统也可能保持过载状态,直到重新启动或以其他方式重置。这种现象称为 *亚稳态故障*,它可能导致生产系统的严重中断 [^7] [^8]。
为了避免重试使服务过载,你可以在客户端增加并随机化连续重试之间的时间(**指数退避** [^9] [^10]),并暂时停止向最近返回错误或超时的服务发送请求(使用 **熔断器** [^11] [^12] 或 **令牌桶** 算法 [^13])。服务器还可以检测何时接近过载并开始主动拒绝请求(**负载卸除** [^14]),并发送要求客户端减速的响应**背压** [^1] [^15])。排队和负载均衡算法的选择也可能产生影响 [^16]。
为了避免重试使服务过载,你可以在客户端增加并随机化连续重试之间的时间(*指数退避* [^9] [^10]),并暂时停止向最近返回错误或超时的服务发送请求(使用 *熔断器* [^11] [^12] 或 *令牌桶* 算法 [^13])。服务器还可以检测何时接近过载并开始主动拒绝请求(*负载卸除* [^14]),并发送响应要求客户端减速(*背压* [^1] [^15])。排队和负载均衡算法的选择也可能产生影响 [^16]。
--------
就性能指标而言,响应时间通常是用户最关心的,而吞吐量决定了所需的计算资源(例如,你需要多少服务器),因此决定了服务特定工作负载的成本。如果吞吐量可能增长超过当前硬件可以处理的范围,则需要扩展容量;如果通过添加计算资源可以显著增加其最大吞吐量,则称系统是 **可扩展的**。
就性能指标而言,响应时间通常是用户最关心的,而吞吐量决定了所需的计算资源(例如,你需要多少服务器),因此决定了服务特定工作负载的成本。如果吞吐量可能会增长超出当前硬件可以处理的范围,则需要扩展容量;如果系统的最大吞吐量可以通过添加计算资源显著增加,则称系统为 *可伸缩的*。
在本节中,我们将主要关注响应时间,我们将在 ["可扩展性"](/ch2#sec_introduction_scalability) 中回到吞吐量和可扩展性。
在本节中,我们将主要关注响应时间,我们将在 ["可伸缩性"](/ch2#sec_introduction_scalability) 中回到吞吐量和可伸缩性。
### 延迟与响应时间 {#id23}
"延迟"和"响应时间"有时可互换使用,但在本书中我们将以特定方式使用这些术语(如 [图 2-4](/ch2#fig_response_time) 所示):
"延迟"和"响应时间"有时可互换使用,但在本书中我们将以特定方式使用这些术语(如 [图 2-4](/ch2#fig_response_time) 所示):
* **响应时间** 是客户端看到的;它包括系统中任何地方产生的所有延迟。
* **服务时间** 是服务主动处理用户请求的持续时间。
* **排队延迟** 可能发生在流程的几个点:例如,在收到请求后,它可能需要等到 CPU 可用才能处理;如果同一台机器上的其他任务通过出站网络接口发送大量数据,响应数据包可能需要在发送到网络之前进行缓冲。
* **延迟** 是请求未被主动处理的时间的总称,即它是 **潜在的** 时间。特别是,**网络延迟** 或 **网络延迟** 是指请求和响应在网络中传输所花费的时间。
* *响应时间* 是客户端看到的;它包括系统中任何地方产生的所有延迟。
* *服务时间* 是服务主动处理用户请求的持续时间。
* *排队延迟* 可能发生在流程中的几个点:例如,在收到请求后,它可能需要等待直到 CPU 可用才能处理;如果同一台机器上的其他任务通过出站网络接口发送大量数据,响应数据包可能需要在发送之前进行缓冲。
* *延迟* 是一个涵盖请求未被主动处理时间的总称,即在此期间它是 *潜在的*。特别是,*网络延迟* 或 *网络延迟* 指的是请求和响应在网络中传输所花费的时间。
{{< figure src="/fig/ddia_0204.png" id="fig_response_time" caption="图 2-4. 响应时间、服务时间、网络延迟和排队延迟。" class="w-full my-4" >}}
在 [图 2-4](/ch2#fig_response_time) 中,时间从左到右流动,每个通信节点显示为水平线,请求或响应消息显示为从一个节点到另一个节点的粗对角箭头。你将在本书的过程中经常遇到这种风格的图表。
在 [图 2-4](/ch2#fig_response_time) 中,时间从左到右流动,每个通信节点显示为水平线,请求或响应消息显示为从一个节点到另一个节点的粗对角箭头。你将在本书中经常遇到这种风格的图表。
响应时间可能从一个请求到下一个请求显著变化,即使你一遍又一遍地发出相同的请求。许多因素可能会增加随机延迟:例如,上下文切换到后台进程、网络数据包丢失和 TCP 重传、垃圾回收暂停、强制从磁盘读取的页错误、服务器机架中的机械振动 [^17],或许多其他原因。我们将在 ["超时无界延迟"](/ch9#sec_distributed_queueing) 中更详细地讨论这个主题。
响应时间可能会因请求而异,即使你一遍又一遍地发出相同的请求。许多因素可能会增加随机延迟:例如,上下文切换到后台进程、网络数据包丢失和 TCP 重传、垃圾回收暂停、强制从磁盘读取的页错误、服务器机架中的机械振动 [^17],或许多其他原因。我们将在 ["超时无界延迟"](/ch9#sec_distributed_queueing) 中更详细地讨论这个主题。
排队延迟通常占响应时间变化的很大一部分。由于服务器只能并行处理少量事物(例如,受其 CPU 核心数量的限制),只需要少量慢速请求就可以阻止后续请求的处理——这种效应称为 **队头阻塞**。即使那些后续请求具有快速的服务时间,由于等待先前请求完成的时间,客户端也会看到缓慢的整体响应时间。排队延迟不是服务时间的一部分,因此在客户端测量响应时间很重要。
排队延迟通常占响应时间变化的很大一部分。由于服务器只能并行处理少量事务(例如,受其 CPU 核心数的限制),只需要少量慢请求就可以阻塞后续请求的处理——这种效应称为 *队头阻塞*。即使那些后续请求的服务时间很快,由于等待先前请求完成的时间,客户端仍会看到缓慢的整体响应时间。排队延迟不是服务时间的一部分,因此在客户端测量响应时间很重要。
### 平均值、中位数与百分位数 {#id24}
因为响应时间从一个请求到下一个请求各不相同,我们需要将其视为不是单个数字,而是可以测量的值的 **分布**。在 [图 2-5](/ch2#fig_lognormal) 中,每个灰色条表对服务的请求,其高度显示该请求花费了多长时间。大多数请求相当快,但偶尔有 **异常值** 需要更长时间。网络延迟的变化也称为 **抖动**。
因为响应时间因请求而异,我们需要将其视为值的 *分布*,而不是单个数字。在 [图 2-5](/ch2#fig_lognormal) 中,每个灰色条表对服务的请求,其高度显示该请求花费的时间。大多数请求相当快,但偶尔会有 *异常值* 需要更长时间。网络延迟的变化也称为 *抖动*。
{{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="图 2-5. 说明平均值和百分位数:对服务的 100 个请求样本的响应时间。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="图 2-5. 说明平均值和百分位数:100 个服务请求的响应时间样本。" class="w-full my-4" >}}
报告服务的 **平均** 响应时间是很常见的(技术上,**算术平均值**:即,将所有响应时间相加,然后除以请求数)。平均响应时间对于估计吞吐量限制很有用 [^18]。然而,如果你想知道你的"典型"响应时间,平均值不是一个很好的指标,因为它不告诉你有多少用户实际经历了那种延迟。
报告服务的 *平均* 响应时间是常见的(技术上是 *算术平均值*:即,将所有响应时间相加,然后除以请求数)。平均响应时间对于估计吞吐量限制很有用 [^18]。然而,如果你想知道你的"典型"响应时间,平均值不是一个很好的指标,因为它不告诉你有多少用户实际经历了那种延迟。
通常使用 **百分位数** 会更好。如果你将响应时间列表从最快到最慢排序,那么 **中位数** 是中间点:例如,如果你的中位数响应时间是 200 毫秒,这意味着你的一半请求在不到 200 毫秒内返回,一半请求需要更长时间。这使中位数成为一个很好的指标,如果你想知道用户通常需要等待多长时间。中位数也称为 **第 50 百分位数**,有时缩写为 **p50**。
通常使用 *百分位数* 更好。如果你将响应时间列表从最快到最慢排序,那么 *中位数* 就在中间:例如,如果你的中位响应时间是 200 毫秒,这意味着一半的请求在不到 200 毫秒内返回,一半的请求花费的时间更长。这使得中位数成为了解用户通常需要等待多长时间的良好指标。中位数也称为 *第 50 百分位*,有时缩写为 *p50*。
为了弄清楚你的异常值有多糟糕,你可以查看更高的百分位数:**第 95**、**第 99** 和 **第 99.9** 百分位数是常见的(缩写为 **p95**、**p99** 和 **p999**)。它们是响应时间阈值,95%、99% 或 99.9% 的请求比该特定阈值快。例如,如果第 95 百分位响应时间是 1.5 秒,这意味着 100 个请求中有 95 个需要不到 1.5 秒100 个请求中有 5 个需要 1.5 秒或更长时间。这在 [图 2-5](/ch2#fig_lognormal) 中有所说明。
为了弄清异常值有多糟糕,你可以查看更高的百分位数:*第 95*、*99* 和 *99.9* 百分位数很常见(缩写为 *p95*、*p99* 和 *p999*)。它们是 95%、99% 或 99.9% 的请求比该特定阈值快的响应时间阈值。例如,如果第 95 百分位响应时间是 1.5 秒,这意味着 100 个请求中的 95 个花费不到 1.5 秒100 个请求中的 5 个花费 1.5 秒或更长时间。这在 [图 2-5](/ch2#fig_lognormal) 中有所说明。
响应时间的高百分位数,也称为 **尾延迟**,很重要,因为它们直接影响用户对服务的体验。例如,亚马逊根据第 99.9 百分位数描述内部服务的响应时间要求,即使它只影响 1,000 个请求中的 1 个。这是因为请求最慢的客户通常是那些在其帐户上拥有最多数据的客户,因为他们进行了许多购买——也就是说,他们是最有价值的客户 [^19]。通过确保网站对他们来说速度快,保持这些客户满意很重要。
响应时间的高百分位数,也称为 *尾部延迟*,很重要,因为它们直接影响用户的服务体验。例如,亚马逊在描述内部服务的响应时间要求时使用第 99.9 百分位,即使它只影响 1,000 个请求中的 1 个。这是因为请求最慢的客户通常是那些账户上数据最多的客户,因为他们进行了许多购买——也就是说,他们是最有价值的客户 [^19]。确保网站对他们来说速度快对于保持这些客户的满意度很重要。
另一方面,优化第 99.99 百分位10,000 个请求中最慢的 1 个)被认为对亚马逊的目的来说太昂贵且收益不足。在非常高的百分位数下减少响应时间很困难,因为它们容易受到你无法控制的随机事件的影响,而且收益递减。
另一方面,优化第 99.99 百分位10,000 个请求中最慢的 1 个)被认为太昂贵,对亚马逊的目的没有足够的好处。在非常高的百分位数上减少响应时间很困难,因为它们很容易受到你无法控制的随机事件的影响,而且收益递减。
--------
> [!TIP] 响应时间对用户的影响
直觉上似乎很明显,快速服务比慢速服务对用户更好 [^20]。然而,令人惊讶的是,很难获得可靠的数据来量化延迟对用户行为的影响。
直觉上似乎很明显,快速服务比慢速服务对用户更好 [^20]。然而,获得可靠的数据来量化延迟对用户行为的影响是令人惊讶地困难的
一些经常被引用的统计数据是不可靠的。2006 年,谷歌报告说,搜索结果从 400 毫秒减慢到 900 毫秒与流量和收入下降 20% 相关 [^21]。然而2009 年谷歌的另一项研究报告说,延迟增加 400 毫秒导致每天搜索减少 0.6% [^22],同年必应发现加载时间增加两秒使广告收入减少 4.3% [^23]。来自这些公司的较新数据似乎没有公开。
一些经常被引用的统计数据是不可靠的。2006 年,谷歌报告说,搜索结果从 400 毫秒减慢到 900 毫秒与流量和收入下降 20% 相关 [^21]。然而2009 年谷歌的另一项研究报告说,延迟增加 400 毫秒导致每天搜索减少 0.6% [^22],同年必应发现加载时间增加两秒将广告收入减少 4.3% [^23]。这些公司的较新数据似乎没有公开。
Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒使电子商务网站的转化率降低多达 7%;然而,仔细检查后,同一研究显示,非常 **快** 的页面加载时间也与较低的转化率相关!这个看似矛盾的结果可以解释为加载最快的页面通常是那些没有有用内容的页面例如404 错误页面)。然而,由于该研究没有努力将页面内容的影响与加载时间的影响分开,其结果可能没有意义。
Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒电子商务网站的转化率降低多达 7%;然而,仔细检查后,同一研究显示,非常 *快* 的页面加载时间也与较低的转化率相关!这个看似矛盾的结果是因为加载最快的页面通常是那些没有有用内容的页面例如404 错误页面)。然而,由于该研究没有努力将页面内容的影响与加载时间的影响分开,其结果可能没有意义。
雅虎的一项研究 [^25] 比较了快速加载与慢速加载搜索结果的点击率,控制了搜索结果的质量。它发现当快速和慢速响应之间的差异为 1.25 秒或更多时,快速搜索的点击次数增加 20-30%。
@ -152,271 +151,270 @@ Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒使电子
### 响应时间指标的应用 {#sec_introduction_slo_sla}
高百分位数在后端服务中尤其重要,这些服务在服务单个最终用户请求时被多次调用。即使你并行进行调用,最终用户请求仍然需要等待最慢的并行调用完成。只需要一个慢速调用就能使整个最终用户请求变慢,如 [图 2-6](/ch2#fig_tail_amplification) 所示。即使只有一小部分后端调用很慢,如果最终用户请求需要多个后端调用,获得慢速调用的机会也会增加,因此更高比例的最终用户请求最终会变慢(一种称为 **尾延迟放大** 的效应 [^26])。
高百分位数在被多次调用作为服务单个最终用户请求的一部分的后端服务中尤其重要。即使你并行进行调用,最终用户请求仍然需要等待最慢的并行调用完成。只需要一个慢调用就可以使整个最终用户请求变慢,如 [图 2-6](/ch2#fig_tail_amplification) 所示。即使只有一小部分后端调用很慢,如果最终用户请求需要多个后端调用,获得慢调用的机会就会增加,因此更高比例的最终用户请求最终会变慢(这种效应称为 *尾部延迟放大* [^26])。
{{< figure src="/fig/ddia_0206.png" id="fig_tail_amplification" caption="图 2-6. 当需要几个后端调用来服务请求时,只需要一个慢速后端请求就能减慢整个最终用户请求。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0206.png" id="fig_tail_amplification" caption="图 2-6. 当需要几个后端调用来服务请求时,只需要一个慢的后端请求就可以减慢整个最终用户请求。" class="w-full my-4" >}}
百分位数通常用于 **服务级别目标**SLO**服务级别协议**SLA作为定义服务预期性能和可用性的方式 [^27]。例如SLO 可能为服务设定目标,使中位数响应时间小于 200 毫秒,第 99 百分位数低于 1 秒,并且至少 99.9% 的有效请求导致非错误响应。SLA 是一份合同,规定如果未满足 SLO 会发生什么(例如,客户可能有权获得退款)。至少这是基本思想;在实践中,为 SLO 和 SLA 定义良好的可用性指标并不简单 [^28] [^29]。
百分位数通常用于 *服务级别目标*SLO*服务级别协议*SLA作为定义服务预期性能和可用性的方式 [^27]。例如SLO 可能设定服务的中位响应时间小于 200 毫秒且第 99 百分位低于 1 秒的目标,以及至少 99.9% 的有效请求导致非错误响应的目标。SLA 是一份合同,规定如果不满足 SLO 会发生什么(例如,客户可能有权获得退款)。这至少是基本想法;实际上,为 SLO 和 SLA 定义良好的可用性指标并不简单 [^28] [^29]。
--------
> [!TIP] 计算百分位数
如果你想将响应时间百分位数添加到服务的监控仪表板中,你需要持续效地计算它们。例如,你可能希望保留过去 10 分钟内请求响应时间的滚动窗口。每分钟,你计算该窗口中值的中位数和各种百分位数,并将这些指标绘制在图表上。
如果你想将响应时间百分位数添加到服务的监控仪表板中,你需要持续效地计算它们。例如,你可能希望保留过去 10 分钟内请求响应时间的滚动窗口。每分钟,你计算该窗口中值的中位数和各种百分位数,并在图表上绘制这些指标
最简单的实现是保留时间窗口内所有请求的响应时间列表,并每分钟对该列表进行排序。如果这对你来说效率太低,有一些算法可以以最小的 CPU 和内存成本计算百分位数的良好近似值。开源百分位数估计库包括 HdrHistogram、t-digest [^30] [^31]、OpenHistogram [^32] 和 DDSketch [^33]。
最简单的实现是在时间窗口内保留所有请求的响应时间列表,并每分钟对该列表进行排序。如果这对你来说效率太低,有一些算法可以以最小的 CPU 和内存成本计算百分位数的良好近似值。开源百分位数估计库包括 HdrHistogram、t-digest [^30] [^31]、OpenHistogram [^32] 和 DDSketch [^33]。
请注意,对百分位数求平均值,例如,减少时间分辨率或组合来自多台机器的数据,在数学上是没有意义的——聚合响应时间数据的正确方法是添加直方图 [^34]。
请注意,平均百分位数,例如,减少时间分辨率或组合来自多台机器的数据,在数学上是没有意义的——聚合响应时间数据的正确方法是添加直方图 [^34]。
--------
## 可靠性与容错 {#sec_introduction_reliability}
每个人都对什么是可靠或不可靠有直观的想法。对于软件,典型的期望包括:
每个人都对某物是否可靠或不可靠有直观的想法。对于软件,典型的期望包括:
* 应用程序执行用户期望的功能。
* 它可以容忍用户犯错误或以意方式使用软件。
* 它的性能对于所需的用例、预期的负载和数据量来说足够好
* 它可以容忍用户犯错误或以意想不到的方式使用软件。
* 在预期的负载和数据量下,其性能足以满足所需的用例
* 系统防止任何未经授权的访问和滥用。
如果所有这些事情加在一起意味着"正确工作",那么我们可以将 **可靠性** 理解为大致意思是"即使出现问题也能继续正确工作"。为了更准确地说明出现问题,我们将区分 **故障****失效** [^35] [^36] [^37]
如果所有这些加在一起意味着"正确工作",那么我们可以将 *可靠性* 大致理解为"即使出现问题也能继续正确工作"。为了更准确地说明出现问题,我们将区分 *故障**失效* [^35] [^36] [^37]
故障
: 故障是系统的特定 **部分** 停止正确工作:例如,如果单个硬盘驱动器出现故障,或单台机器崩溃,或系统依赖的外部服务出现中断。
: 故障是指系统的某个特定 *部分* 停止正确工作:例如,如果单个硬盘驱动器发生故障,或单台机器崩溃,或外部服务(系统所依赖的)发生中断。
失效
: 失效是系统 **作为一个整体** 停止向用户提供所需的服务换句话说当它不满足服务级别目标SLO时。
: 失效是*整个* 系统停止向用户提供所需的服务换句话说当它不满足服务级别目标SLO时。
故障和失效之间的区别可能令人困惑,因为它们是同一件事,只是在不同的级别。例如,如果硬盘驱动器停止工作,我们说硬盘驱动器已经失效:如果系统仅包含那一个硬盘驱动器,它已经停止提供所需的服务。然而,如果你谈论的系统包含许多硬盘驱动器,那么从更大系统的角度来看,单个硬盘驱动器的失效只是一个故障,更大的系统可能能够通过在另一个硬盘驱动器上拥有数据副本来容忍该故障。
故障和失效之间的区别可能令人困惑,因为它们在不同层面上是同一件事。例如,如果硬盘驱动器停止工作,我们说硬盘驱动器已失效:如果系统仅由该一个硬盘驱动器组成,它已停止提供所需的服务。然而,如果你正在谈论的系统包含许多硬盘驱动器,那么从更大系统的角度来看,单个硬盘驱动器的失效只是一个故障,并且更大的系统可能能够通过在另一个硬盘驱动器上拥有数据副本来容忍该故障。
### 容错 {#id27}
如果系统尽管发生某些故障仍继续向用户提供所需的服务,我们称该系统为 **容错的**。如果系统无法容忍某个部分出现故障,我们称该部分为 **单点故障**SPOF因为该部分的故障会升级导致整个系统的失效。
如果系统在发生某些故障时仍继续向用户提供所需的服务,我们称系统为 *容错的*。如果系统不能容忍某个部分变得有故障,我们称该部分为 *单点故障*SPOF因为该部分的故障会升级导致整个系统的失效。
例如,在社交网络案例研究中,可能发生的故障是在扇出过程中,参与更新物化时间线的机器崩溃或变得不可用。为了使此过程容错,我们需要确保另一台机器可以接管此任务,而不会错过任何应该投递的帖子,也不会复制任何帖子。(这个想法被称为 **精确一次语义**,我们将在 [Link to Come] 中详细研究它。)
例如,在社交网络案例研究中,可能发生的故障是在扇出过程中,参与更新物化时间线的机器崩溃或变得不可用。为了使这个过程容错,我们需要确保另一台机器可以接管这项任务,而不会错过任何应该交付的帖子,也不会复制任何帖子。(这个想法被称为 *精确一次语义*,我们将在 [后续章节] 中详细研究它。)
容错总是限于一定数量的某些类型的故障。例如,系统可能能够容忍最多两个硬盘驱动器同时故障,或最多三个节点中的一个崩溃。容忍任何数量的故障是没有意义的:如果所有节点都崩溃,就无能为力了。如果整个地球(及其上的所有服务器)被黑洞吞噬,容忍该故障将需要太空网络托管——祝你获得该预算项目的批准好运。
容错总是限于某些类型的某些数量的故障。例如,系统可能能够容忍最多两个硬盘驱动器同时故障,或最多三个节点中的一个崩溃。如果所有节点都崩溃,没有什么可以做的,这没有意义容忍任何数量的故障。如果整个地球(及其上的所有服务器)被黑洞吞噬,容忍该故障将需要太空中进行网络托管——祝你获得批准该预算项目的好运。
违反直觉的是,在这种容错系统中,通过故意触发故障来 **增加** 故障率可能是有意义的——例如,通过在没有警告的情况下随机杀死单个进程。这称为 **故障注入**。许多关键错误实际上是由于错误处理不当造成的 [^38];通过故意引发故障,你确保容错机制得到持续锻炼和测试,这可以增加你对故障自然发生时将被正确处理的信心。**混沌工程** 是一门学科,旨在通过故意注入故障等实验来提高对容错机制的信心 [^39]。
反直觉地,在这种容错系统中,通过故意触发故障来 *增加* 故障率是有意义的——例如,在没有警告的情况下随机杀死单个进程。这称为 *故障注入*。许多关键错误实际上是由于错误处理不当造成的 [^38];通过故意引发故障,你确保容错机制不断得到锻炼和测试,这可以增加你对故障自然发生时将被正确处理的信心。*混沌工程* 是一门旨在通过故意注入故障等实验来提高对容错机制的信心的学科 [^39]。
尽管我们通常更喜欢容忍故障而不是预防故障,但有些情况下预防比治疗更好(例如,因为不存在治疗方法)。例如,安全事项就是这种情况:如果攻击者已经入侵了系统并获得了对敏感数据的访问,该事件无法撤消。然而,本书主要处理可以治愈的故障类型,如以下各节所述。
尽管我们通常更喜欢容忍故障而不是预防故障,但在预防比治疗更好的情况下(例如,因为不存在治疗方法)。安全问题就是这种情况:如果攻击者已经破坏了系统并获得了对敏感数据的访问,该事件无法撤消。然而,本书主要涉及可以治愈的故障类型,如以下部分所述。
### 硬件与软件故障 {#sec_introduction_hardware_faults}
当我们考虑系统故障的原因时,硬件故障很快就会浮现在脑海中:
当我们想到系统失效的原因时,硬件故障很快就会浮现在脑海中:
* 大约 2-5% 的磁性硬盘驱动器每年故障 [^40] [^41];在拥有 10,000 个磁盘的存储集群中,我们因此应该期平均每天有一个磁盘故障。最近的数据表明磁盘变得更可靠,但故障率仍然很 [^42]。
* 大约 0.5-1% 的固态驱动器SSD每年故障 [^43]。少量的位错误会自动纠正 [^44],但不可纠正的错误大约每年每个驱动器发生一次,即使在相当新的驱动器中(即,经历很少磨损);这个错误率高于磁性硬盘驱动器 [^45]、[^46]。
* 其他硬件组件如电源、RAID 控制器和内存模块也会故障,尽管频率低于硬盘驱动器 [^47] [^48]。
* 大约千分之一的机器有一个 CPU 核心偶尔计算错误的结果,可能是由于制造缺陷 [^49] [^50] [^51]。在某些情况下,错误的计算会导致崩溃,但在其他情况下,它会导致程序简单地返回错误的结果。
* RAM 中的数据也可能被损坏,要么是由于宇宙射线等随机事件,要么是由于永久性物理缺陷。即使使用带有纠错码ECC的内存超过 1% 的机器在给定年份遇到不可纠正的错误,这通常会导致机器崩溃和受影响的内存模块需要更换 [^52]。此外,某些病理内存访问模式可以高概率翻转位 [^53]。
* 整个数据中心可能变得不可用(例如,由于停电或网络配置错误)甚至被永久破坏(例如,因火灾、洪水或地震 [^54])。太阳风暴在太阳喷射大量带电粒子时在长距离电线中感应大电流,可能会损坏电网和海底网络电缆 [^55]。尽管这种大规模故障很少见,但如果服务无法容忍数据中心的丢失,它们的影响可能是灾难性的 [^56]。
* 大约 2-5% 的磁性硬盘驱动器每年发生故障 [^40] [^41];在拥有 10,000 个磁盘的存储集群中,我们因此应该期平均每天有一个磁盘故障。最近的数据表明磁盘变得更可靠,但故障率仍然很显著 [^42]。
* 大约 0.5-1% 的固态硬盘SSD每年发生故障 [^43]。少量位错误会自动纠正 [^44],但不可纠正的错误大约每年每个驱动器发生一次,即使在相当新的驱动器中(即,经历很少磨损);这个错误率高于磁性硬盘驱动器 [^45]、[^46]。
* 其他硬件组件如电源、RAID 控制器和内存模块也会发生故障,尽管频率低于硬盘驱动器 [^47] [^48]。
* 大约千分之一的机器有一个 CPU 核心偶尔计算错误的结果,可能是由于制造缺陷 [^49] [^50] [^51]。在某些情况下,错误的计算会导致崩溃,但在其他情况下,它会导致程序简单地返回错误的结果。
* RAM 中的数据也可能被损坏要么是由于宇宙射线等随机事件要么是由于永久性物理缺陷。即使使用纠错码ECC的内存超过 1% 的机器在给定年份遇到不可纠正的错误,这通常会导致机器崩溃和受影响的内存模块需要更换 [^52]。此外,某些病理内存访问模式可以高概率翻转位 [^53]。
* 整个数据中心可能变得不可用(例如,由于停电或网络配置错误)甚至被永久摧毁(例如,由火灾、洪水或地震 [^54])。太阳风暴,当太阳喷射大量带电粒子时,会在长距离电线中感应出大电流,可能会损坏电网和海底网络电缆 [^55]。尽管这种大规模故障很少见,但如果服务不能容忍数据中心的丢失,它们的影响可能是灾难性的 [^56]。
这些事件足够罕见,以至于在处理小型系统时你通常不需要担心它们,只要你可以轻松更换有故障的硬件。然而,在大规模系统中,硬件故障发生得足够频繁,以至于它们成为正常系统操作的一部分。
这些事件足够罕见,你在处理小型系统时通常不需要担心它们,只要你可以轻松更换变得有故障的硬件。然而,在大规模系统中,硬件故障发生得足够频繁,以至于它们成为正常系统运行的一部分。
#### 通过冗余容忍硬件故障 {#tolerating-hardware-faults-through-redundancy}
我们对不可靠硬件的第一反应通常是向个硬件组件添加冗余,以降低系统的故障率。磁盘可以设置为 RAID 配置(将数据分布在同一台机器的多个磁盘上,以便故障磁盘不会导致数据丢失),服务器可能有双电源和可热插拔的 CPU数据中心可能有电池和柴油发电机作为备用电源。这种冗余通常可以使机器无中断运行多年。
我们对不可靠硬件的第一反应通常是向个硬件组件添加冗余,以降低系统的故障率。磁盘可以设置为 RAID 配置(将数据分布在同一台机器的多个磁盘上,以便故障磁盘不会导致数据丢失),服务器可能有双电源和可热插拔的 CPU数据中心可能有电池和柴油发电机作为备用电源。这种冗余通常可以使机器不间断运行多年。
当组件故障独立时,冗余最有效,即一个故障的发生不会改变另一个故障发生的可能性。然而,经验表明,组件故障之间通常存在显著相关性 [^41] [^57] [^58];整个服务器机架或整个数据中心的不可用仍然比我们希望的更频繁。
当组件故障独立时,冗余最有效,即一个故障的发生不会改变另一个故障发生的可能性。然而,经验表明,组件故障之间通常存在显著相关性 [^41] [^57] [^58];整个服务器机架或整个数据中心的不可用仍然比我们希望的更频繁地发生
硬件冗余增加了单台机器的正常运行时间;然而,如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 中所讨论的,使用分布式系统有一些优势,例如能够容忍一个数据中心的完全中断。出于这个原因,云系统倾向于较少关注单个机器的可靠性,而是旨在通过在软件级别容忍故障节点来使服务高度可用。云提供商使用 **可用区** 来识别哪些资源在物理上位于同一位置;同一地方的资源比地理上分离的资源更可能同时故障。
硬件冗余增加了单台机器的正常运行时间;然而,如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 中所讨论的,使用分布式系统有一些优势,例如能够容忍一个数据中心的完全中断。出于这个原因,云系统倾向于较少关注单个机器的可靠性,而是旨在通过在软件级别容忍故障节点来使服务高度可用。云提供商使用 *可用区* 来识别哪些资源在物理上位于同一位置;同一地方的资源比地理上分离的资源更可能同时发生故障。
我们在本书中讨论的容错技术旨在容忍整个机器、机架或可用区的丢失。它们通常通过允许一个数据中心的机器在另一个数据中心的机器故障或变得无法访问时接管来工作。我们将在 [第 6 章](/ch6#ch_replication)、[第 10 章](/ch10#ch_consistency) 以及本书的其他各个点讨论这种容错技术。
我们在本书中讨论的容错技术旨在容忍整个机器、机架或可用区的丢失。它们通常通过允许一个数据中心的机器在另一个数据中心的机器发生故障或变得不可达时接管来工作。我们将在 [第 6 章](/ch6)、[第 10 章](/ch10) 以及本书的其他各个地方讨论这种容错技术。
可以容忍整个机器丢失的系统也具有操作优势:如果你需要重新启动机器(例如,应用操作系统安全补丁),单服务器系统需要计划停机时间,而多节点容错系统可以通过一次重新启动一个节点来打补丁,而不会影响用户的服务。这称为 **滚动升级**,我们将在 [第 5 章](/ch5#ch_encoding) 中进一步讨论它。
能够容忍整个机器丢失的系统也具有运营优势:如果你需要重新启动机器(例如,应用操作系统安全补丁),单服务器系统需要计划停机时间,而多节点容错系统可以一次修补一个节点,而不影响用户的服务。这称为 *滚动升级*,我们将在 [第 5 章](/ch5) 中进一步讨论它。
#### 软件故障 {#software-faults}
尽管硬件故障可能是弱相关的,但它们仍然大多是独立的:例如,如果一个磁盘故障,同一台机器中的其他磁盘很可能在一段时间内仍然正常。另一方面,软件故障通常高度相关,因为许多节点运行相同的软件并因此具有相同的错误是很常见的 [^59] [^60]。这种故障更难预测,它们往往比不相关的硬件故障导致更多的系统故障 [^47]。例如:
尽管硬件故障可能是弱相关的,但它们大多仍然是独立的:例如,如果一个磁盘发生故障,同一台机器中的其他磁盘很可能在一段时间内还能正常工作。另一方面,软件故障通常高度相关,因为许多节点运行相同的软件并因此具有相同的错误是常见的 [^59] [^60]。这种故障比不相关的硬件故障更难预料,并且它们往往导致比硬件故障更多的系统失效 [^47]。例如:
* 导致每个节点在特定情况下同时故障的软件错误。例如2012 年 6 月 30 日,闰秒导致许多 Java 应用程序由于 Linux 内核中的错误而同时挂起,导致许多互联网服务瘫痪 [^61]。由于固件错误,某些型号的所有 SSD 在精确运行 32,768 小时(不到 4 年)后突然故障,使其上的数据无法恢复 [^62]。
* 使用某些共享、有限资源的失控进程,例如 CPU 时间、内存、磁盘空间、网络带宽或线程 [^63]。例如,处理大请求时消耗过多内存的进程可能会被操作系统杀死。客户端库中的错误可能导致比预期更高的请求量 [^64]。
* 系统依赖的服务变慢、无响应或开始返回损坏的响应。
* 不同系统之间的交互导致在每个系统单独测试时不会发生的紧急行为 [^65]。
* 级联故障,其中一个组件中的问题导致另一个组件过载并减速,这反过来又导致另一个组件瘫痪 [^66] [^67]。
* 在特定情况下导致每个节点同时失效的软件错误。例如2012 年 6 月 30 日,闰秒导致许多 Java 应用程序由于 Linux 内核中的错误而同时挂起 [^61]。由于固件错误,某些型号的所有 SSD 在精确运行 32,768 小时(不到 4 年)后突然失效,使其上的数据无法恢复 [^62]。
* 使用某些共享、有限资源如 CPU 时间、内存、磁盘空间、网络带宽或线程)的失控进程 [^63]。例如,处理大请求时消耗过多内存的进程可能会被操作系统杀死。客户端库中的错误可能导致比预期更高的请求量 [^64]。
* 系统依赖的服务变慢、无响应或开始返回损坏的响应。
* 不同系统之间的交互导致在隔离测试每个系统时不会发生的紧急行为 [^65]。
* 级联故障,其中一个组件中的问题导致另一个组件过载和减速,这反过来又导致另一个组件崩溃 [^66] [^67]。
导致这些软件故障的错误通常会潜伏很长时间,直到它们被一组不寻常的情况触发。在这些情况下,会发现软件对其环境做出了某种假设——虽然该假设通常是正确的,但由于某种原因它最终不再正确 [^68] [^69]。
导致这些类型软件故障的错误通常会潜伏很长时间,直到它们被一组不寻常的环境触发。在这些情况下,软件对其环境做出了某种假设——虽然该假设通常是正确的,但它最终由于某种原因不再成立 [^68] [^69]。
软件中系统故障的问题没有快速解决方案。许多小事情可以帮助:仔细考虑系统中的假设和交互;彻底测试;进程隔离;允许进程崩溃重新启动;避免反馈循环,如重试风暴(参见 ["当过载系统无法恢复时"](/ch2#sidebar_metastable));测量、监控和分析生产中的系统行为。
软件中系统故障没有快速解决方案。许多小事情可以帮助:仔细考虑系统中的假设和交互;彻底测试;进程隔离;允许进程崩溃重新启动;避免反馈循环,如重试风暴(参见 ["当过载系统无法恢复时"](/ch2#sidebar_metastable));测量、监控和分析生产中的系统行为。
### 人类与可靠性 {#id31}
人类设计和构建软件系统,保持系统运行的操作员也是人类。与机器不同,人类不只是遵循规则;他们的力量在于在完成工作时具有创造性和适应性。然而,这一特性也导致了不可预测性,有时尽管有最好的意图,但会犯错误,导致故障。例如,一项对大型互联网服务的研究发现,操作员的配置更改是中断的主要原因,而硬件故障(服务器或网络)仅在 10-25% 的中断中发挥作用 [^70]。
人类设计和构建软件系统,保持系统运行的操作员也是人类。与机器不同,人类不只是遵循规则;他们的力量是创造性和适应性地完成工作。然而,这一特征也导致不可预测性,有时会导致失效的错误,尽管有最好的意图。例如,一项对大型互联网服务的研究发现,操作员的配置更改是中断的主要原因,而硬件故障(服务器或网络)仅在 10-25% 的中断中发挥作用 [^70]。
将这些问题标记为"人为错误"并希望通过更严格的程序和规则合规来更好地控制人类行为来解决它们是很诱人的。然而,为错误责怪人们是适得其反的。我们所说的"人为错误"实际上不是事件的原因,而是人们尽力完成工作的社会技术系统中问题的症状 [^71]。通常,复杂系统具有紧急行为,其中组件之间的意外交互也可能导致故障 [^72]。
将这些问题标记为"人为错误"并希望通过更严格的程序和规则合规来更好地控制人类行为来解决它们是很诱人的。然而,责怪人们的错误是适得其反的。我们所说的"人为错误"实际上不是事件的原因,而是人们在社会技术系统中尽力做好工作的问题的症状 [^71]。通常,复杂系统具有紧急行为,组件之间的意外交互也可能导致故障 [^72]。
各种技术措施可以帮助最小化人为错误的影响,包括彻底测试(手写测试和对大量随机输入的 **属性测试**[^38]、用于快速恢复配置更改的回滚机制、新代码的逐步推出、详细而清晰的监控、用于诊断生产问题的可观察性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems)),以及设计良好的接口,鼓励"正确的事情"并阻止"错误的事情"。
各种技术措施可以帮助最小化人为错误的影响,包括彻底测试(手写测试和对大量随机输入的 *属性测试*[^38]、快速回滚配置更改的回滚机制、新代码的逐步推出、详细和清晰的监控、用于诊断生产问题的可观察性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems)),以及鼓励"正确的事情"并阻止"错误的事情"的精心设计的界面
然而,这些事情需要时间和金钱的投资,在日常业务的实用现实中,组织通常优先考虑创收活动,而不是增加其对错误的弹性的措施。如果在更多功能和更多测试之间有选择,许多组织可以理解地选择功能。鉴于这种选择,当不可避免地发生可预防的错误时,责怪犯错误的人是没有意义的——问题是组织的优先事项。
然而,这些事情需要时间和金钱的投资,在日常业务的务实现实中,组织通常优先考虑创收活动而不是增加其抵御错误的韧性的措施。如果在更多功能和更多测试之间有选择,许多组织可以理解地选择功能。鉴于这种选择,当可预防的错误不可避免地发生时,责怪犯错误的人是没有意义的——问题是组织的优先事项。
越来越多的组织正在采用 **无责备事后分析** 的文化:在事件发生后,鼓励参与的人员分享关于发生的事情的全部细节,而不必担心受到惩罚,因为这使组织中的其他人能够学习如何在未来防止类似的问题 [^73]。这个过程可能会发现需要改变业务优先级、需要投资被忽视的领域、需要改变相关人员的激励措施,或者需要引起管理层注意的其他系统性问题。
越来越多的组织正在采用 *无责备事后分析* 的文化:事件发生后,鼓励相关人员充分分享发生的事情的细节,而不用担心惩罚,因为这允许组织中的其他人学习如何在未来防止类似的问题 [^73]。这个过程可能会发现需要改变业务优先级、需要投资被忽视的领域、需要改变相关人员的激励措施,或者需要引起管理层注意的其他一些系统性问题。
作为一般原则,在调查事件时,你应该对简单的答案持怀疑态度。"鲍勃在部署该更改时应该更加小心"是没有成效的,但"我们必须用 Haskell 重写后端"也不是。相反,管理层应该借此机会从每天使用它的人的角度了解社会技术系统如何工作的细节,并根据这些反馈采取措施改进它 [^71]。
作为一般原则,在调查事件时,你应该对简单的答案持怀疑态度。"鲍勃在部署该更改时应该更加小心"是没有成效的,但"我们必须用 Haskell 重写后端"也不是。相反,管理层应该借此机会从每天与之合作的人的角度了解社会技术系统如何工作的细节,并根据这些反馈采取措施改进它 [^71]。
--------
> [!TIP] 可靠性有多重要?
可靠性不仅适用于核电站和空中交通管制——更平凡的应用程序也应该可靠地工作。业务应用程序中的错误会导致生产力损失(如果数字报告不正确,还会带来法律风险),电子商务网站的中断可能会在收入损失和声誉损害方面造成巨大成本。
可靠性不仅适用于核电站和空中交通管制——更平凡的应用程序也应该可靠地工作。业务应用程序中的错误会导致生产力损失(如果数字报告不正确,还会法律风险),电子商务网站的中断可能会在收入和声誉损害方面造成巨大成本。
在许多应用程序中,几分钟甚至几小时的临时中断是可以容忍的 [^74],但永久数据丢失或损坏将是灾难性的。考虑一位父母,他将所有孩子的照片和视频存储在你的照片应用程序中 [^75]。如果该数据库突然损坏,他们会有什么感觉?他们会知道如何从备份中恢复吗?
在许多应用程序中,几分钟甚至几小时的临时中断是可以容忍的 [^74],但永久数据丢失或损坏将是灾难性的。考虑一位家长在你的照片应用程序中存储他们孩子的所有照片和视频 [^75]。如果该数据库突然损坏,他们会有什么感觉?他们会知道如何从备份中恢复吗?
作为不可靠软件如何伤害人们的另一个例子,考虑邮局地平线丑闻。1999 年至 2019 年间,数百名管理英国邮局分支机构的人因盗窃或欺诈而被定罪,因为会计软件显示他们的账户有短缺。最终,很明显,这些短缺中的许多是由于软件中的错误造成的,许多定罪此后已被推翻 [^76]。导致这一可能是英国历史上最大的司法不公的是,英国法律假计算机正确运行(因此,计算机产生的证据是可靠的),除非有相反的证据 [^77]。软件工程师可能会嘲笑软件可能永远没有错误的想法,但这对那些因不可靠的计算机系统而被错误监禁、宣布破产甚至因错误定罪而自杀的人来说是很少的安慰。
作为不可靠软件如何伤害人们的另一个例子,考虑邮局地平线丑闻。在 1999 年至 2019 年期间,管理英国邮局分支机构的数百人因会计软件显示其账户短缺而被判盗窃或欺诈罪。最终变得清楚,许多这些短缺是由于软件中的错误,许多定罪已被推翻 [^76]。导致这一可能是英国历史上最大的司法不公的是,英国法律假计算机正确运行(因此,计算机产生的证据是可靠的),除非有相反的证据 [^77]。软件工程师可能会嘲笑软件可能错误的想法,但这对那些因不可靠的计算机系统而被错误监禁、宣布破产甚至自杀的人来说,这是很少的安慰。
在某些情况下,我们可能选择牺牲可靠性以降低开发成本(例如,在为未经证实的市场开发原型产品时)——但我们应该非常清楚何时在偷工减料,并牢记潜在的后果。
在某些情况下,我们可能选择牺牲可靠性以降低开发成本(例如,在为未经证实的市场开发原型产品时)——但我们应该非常清楚何时走捷径并牢记潜在的后果。
--------
## 可伸缩性 {#sec_introduction_scalability}
即使系统今天可靠地工作,这并不意味着它将来一定会可靠地工作。降级的一个常见原因是负载增加:也许系统已经从 10,000 个并发用户增长到 100,000 个并发用户,或者从 100 万增长到 1000 万。也许它正在处理比以前大得多的数据量。
即使系统今天可靠地工作,这并不意味着它将来必然会可靠地工作。降级的一个常见原因是负载增加:也许系统已经从 10,000 个并发用户增长到 100,000 个并发用户,或者从 100 万增长到 1000 万。也许它正在处理比以前大得多的数据量。
**可扩展性** 是我们用来描述系统处理负载增加的能力的术语。有时,在讨论可扩展性时,人们会发表类似"你不是谷歌或亚马逊。停止担心规模,只使用关系数据库"的评论。这个格言是否适用于你取决于你正在构建的应用程序类型。
*可伸缩性* 是我们用来描述系统应对负载增加能力的术语。有时,在讨论可伸缩性时,人们会发表评论,如"你不是谷歌或亚马逊。停止担心规模,只使用关系数据库"这个格言是否适用于你取决于你正在构建的应用程序类型。
如果你正在构建一个目前只有少数用户的新产品,也许在一家初创公司,那么首要的工程目标通常是保持系统尽可能简单和灵活,以便你可以轻松修改和调整产品的功能,因为你了解更多关于客户需求的信息 [^78]。在这样的环境中,担心未来可能需要的假设规模是适得其反的:在最好的情况下,对可扩展性的投资是浪费的努力和过早的优化;在最坏的情况下,它们会将你锁定在不灵活的设计中,并使你更难发展你的应用程序。
如果你正在构建一个目前只有少数用户的新产品,也许在初创公司,首要的工程目标通常是保持系统尽可能简单和灵活,以便你可以在了解更多关于客户需求时轻松修改和调整产品的功能 [^78]。在这种环境中,担心未来可能需要的假设规模是适得其反的:在最好的情况下,对可伸缩性的投资是浪费的努力和过早的优化;在最坏的情况下,它们会将你锁定在不灵活的设计中,并使你的应用程序更难发展
原因是可扩展性不是一维标签:说"X 是可扩展的"或"Y 不扩展"是没有意义的。相反,讨论可扩展性意味着考虑以下问题:
原因是可伸缩性不是一维标签:说"X 是可伸缩的"或"Y 不伸缩"是没有意义的。相反,讨论可伸缩性意味着考虑诸如以下问题:
* "如果系统以特定方式增长,我们有哪些选择来应对增长?"
* "如果系统以特定方式增长,我们有什么选择来应对增长?"
* "我们如何添加计算资源来处理额外的负载?"
* "根据当前的增长预测,我们何时会达到当前架构的极限?"
* "基于当前的增长预测,我们何时会达到当前架构的极限?"
如果你成功地使你的应用程序流行,因此处理越来越多的负载,你将了解你的性能瓶颈在哪里,因此你将知道你需要在哪些维度上扩展。那时是开始担心可扩展性技术的时候了
如果你成功地使你的应用程序受欢迎,因此处理越来越多的负载,你将了解你的性能瓶颈在哪里,因此你将知道需要沿着哪些维度进行伸缩。那时是开始担心可伸缩性技术的时候
### 描述负载 {#id33}
首先,我们需要简洁地描述系统上的当前负载;只有这样我们才能讨论增长问题(如果我们的负载翻倍会发生什么?)。通常这将是吞吐量的度量:例如,服务的每秒请求数、每天到达的新数据的千兆字节数,或每小时购物车结账的数量。有时你关心某些可变数量的峰值,例如 ["案例研究:社交网络主页时间线"](/ch2#sec_introduction_twitter) 中同时在线用户的数量。
首先,我们需要简洁地描述系统上的当前负载;只有这样我们才能讨论增长问题(如果我们的负载翻倍会发生什么?)。通常这将是吞吐量的度量:例如,对服务的每秒请求数、每天到达多少千兆字节的新数据,或每小时购物车结账的数量。有时你关心某个变量数量的峰值,例如 ["案例研究:社交网络首页时间线"](/ch2#sec_introduction_twitter) 中同时在线用户的数量。
通常,负载的其他统计特征也会影响访问模式,从而影响可扩展性要求。例如,你可能需要知道数据库中读取与写入的比率、缓存的命中率或每个用户的数据项数(例如,社交网络案例研究中的粉丝数)。也许平均情况对你很重要,或者你的瓶颈可能由少数极端情况主导。这一切都取决于你特定应用程序的细节。
通常还有其他影响访问模式并因此影响可伸缩性要求的负载统计特征。例如,你可能需要知道数据库中的读写比率、缓存的命中率或每个用户的数据项数(例如,社交网络案例研究中的粉丝数)。也许平均情况对你很重要,或者也许你的瓶颈由少数极端情况主导。这一切都取决于你特定应用程序的细节。
一旦你描述了系统上的负载,你就可以研究负载增加时会发生什么。你可以从两个方面来看:
一旦你描述了系统上的负载,你就可以调查当负载增加时会发生什么。你可以从两个方面来看待它
* 当你以某种方式增加负载并保持系统资源CPU、内存、网络带宽等不变时你的系统性能如何受到影响?
* 当你以某种方式增加负载并保持系统资源CPU、内存、网络带宽等不变时系统性能如何受到影响?
* 当你以某种方式增加负载时,如果你想保持性能不变,你需要增加多少资源?
通常我们的目标是在满足 SLA 要求的同时保持系统的性能(参见 ["响应时间指标的使用"](/ch2#sec_introduction_slo_sla),同时最小化运行系统的成本。所需的计算资源越多,成本就越高。可能某些类型的硬件比其他类型更具成本效益,随着新型硬件的出现,这些因素可能会随着时间而改变
通常我们的目标是在最小化运行系统成本的同时保持系统性能在 SLA 的要求范围内(参见 ["响应时间指标的应用"](/ch2#sec_introduction_slo_sla))。所需的计算资源越多,成本就越高。可能某些类型的硬件比其他类型更具成本效益,这些因素可能会随着新类型硬件的出现而随时间变化
如果你可以加倍资源以处理两倍的负载,同时保持性能相同,我们说你有 **线性可扩展性**,这被认为是一件好事。偶尔可以用少于两倍的资源处理两倍的负载,这是由于规模经济或峰值负载的更好分布 [^79] [^80]。更有可能的是,成本增长速度快于线性,效率低下可能有很多原因。例如,如果你有大量数据,那么处理单个写请求可能涉及比你有少量数据时更多的工作,即使请求的大小相同。
如果你可以将资源翻倍以处理两倍的负载,同时保持性能不变,我们说你有 *线性可伸缩性*,这被认为是好事。偶尔,由于规模经济或峰值负载的更好分布,可以用不到两倍的资源处理两倍的负载 [^79] [^80]。更可能的是,成本增长速度快于线性,并且效率低下可能有许多原因。例如,如果你有大量数据,那么处理单个写请求可能涉及比你有少量数据时更多的工作,即使请求的大小相同。
### 共享内存、共享磁盘与无共享架构 {#sec_introduction_shared_nothing}
增加服务硬件资源的最简单方法是将其移动到更强大的机器。单个 CPU 核心不再变得显更快,但你可以购买(或租用云实例)具有更多 CPU 核心、更多 RAM 和更多磁盘空间的机器。这种方法称为 **垂直扩展****纵向扩展**。
增加服务硬件资源的最简单方法是将其移动到更强大的机器。单个 CPU 核心不再变得显更快,但你可以购买一台机器(或租用云实例)具有更多 CPU 核心、更多 RAM 和更多磁盘空间。这种方法称为 *纵向伸缩**向上扩展*。
你可以通过使用多个进程或线程在单台机器上获得并行性。属于同一进程的所有线程都可以访问相同的 RAM因此这种方法也称为 **共享内存架构**。共享内存方法的问题是成本增长速度快于线性:具有两倍硬件资源的高端机器通常成本远远超过两倍。由于瓶颈,两倍大小的机器通常可以处理不到两倍的负载。
你可以通过使用多个进程或线程在单台机器上获得并行性。属于同一进程的所有线程都可以访问相同的 RAM因此这种方法也称为 *共享内存架构*。共享内存方法的问题是成本增长速度快于线性:具有两倍硬件资源的高端机器通常成本远远超过两倍。由于瓶颈,两倍大小的机器通常可以处理不到两倍的负载。
另一种方法是 **共享磁盘架构**,它使用具有独立 CPU 和 RAM 的几台机器,但将数据存储在机器之间共享的磁盘阵列上,它们通过快速网络连接:**网络附加存储**NAS**存储区域网络**SAN。这种架构传统上用于本地数据仓库工作负载但争用和锁定的开销限制了共享磁盘方法的可扩展性 [^81]。
另一种方法是 *共享磁盘架构*,它使用几台具有独立 CPU 和 RAM 的机器,但将数据存储在机器之间共享的磁盘阵列上,这些机器通过快速网络连接:*网络附加存储*NAS*存储区域网络*SAN。这种架构传统上用于本地数据仓库工作负载但争用和锁定的开销限制了共享磁盘方法的可伸缩性 [^81]。
相比之下,**无共享架构** [^82](也称为 **水平扩展****横向扩展**)已经获得了很大的普及。在这种方法中,我们使用具有多个节点的分布式系统,每个节点都有自己的 CPU、RAM 和磁盘。节点之间的任何协调都在软件级别通过传统网络完成。
相比之下,*无共享架构* [^82](也称为 *横向伸缩**向外扩展*)已经获得了很大的流行。在这种方法中,我们使用具有多个节点的分布式系统,每个节点都有自己的 CPU、RAM 和磁盘。节点之间的任何协调都在软件级别通过传统网络完成。
无共享的优点是它具有线性扩展的潜力,它可以使用提供最佳性价比的任何硬件(特别是在云中),它可以在负载增加或减少时更轻松地调整其硬件资源,并且它可以通过在多个数据中心和区域分布系统来实现更高的容错性。缺点是它需要显式分片(参见 [第 7 章](/ch7#ch_sharding)),并且会产生分布式系统的所有复杂性([第 9 章](/ch9#ch_distributed))。
无共享的优点是它有线性伸缩的潜力,它可以使用提供最佳性价比的任何硬件(特别是在云中),它可以随着负载的增加或减少更容易地调整其硬件资源,并且它可以通过在多个数据中心和地区分布系统来实现更大的容错。缺点是它需要显式分片(参见 [第 7 章](/ch7)),并且会产生分布式系统的所有复杂性([第 9 章](/ch9))。
一些云原生数据库系统使用单独的服务进行存储和事务执行(参见 ["存储和计算分离"](/ch1#sec_introduction_storage_compute)),多个计算节点共享对同一存储服务的访问。该模型与共享磁盘架构有一些相似之处,但它避免了旧系统的可扩展性问题:它不提供文件系统NAS或块设备SAN抽象而是提供专门为数据库特定需求设计的专用 API [^83]。
一些云原生数据库系统为存储和事务执行使用单独的服务(参见 ["存储与计算分离"](/ch1#sec_introduction_storage_compute)),多个计算节点共享对同一存储服务的访问。这个模型与共享磁盘架构有一些相似之处,但它避免了旧系统的可伸缩性问题:它不是提供文件系统NAS或块设备SAN抽象而是存储服务提供专门为数据库特定需求设计的 API [^83]。
### 可伸缩性原则 {#id35}
大规模运行的系统架构通常高度特定于应用程序——没有通用的、一刀切的可扩展架构(非正式地称为 **万金油**)。例如,设计用于处理每秒 100,000 个请求(每个 1 kB 大小)的系统与设计用于每分钟 3 个请求(每个 2 GB 大小的系统看起来非常不同——即使两个系统具有相同的数据吞吐量100 MB/秒)。
在大规模运行的系统架构通常对应用程序高度特定——没有通用的、一刀切的可伸缩架构(非正式地称为 *万金油*)。例如,设计用于处理每秒 100,000 个请求(每个 1 kB 大小)的系统与设计用于每分钟 3 个请求(每个 2 GB 大小的系统看起来非常不同——即使两个系统具有相同的数据吞吐量100 MB/秒)。
此外,适合一个负载级别的架构不太可能应对 10 倍的负载。如果你正在处理快速增长的服务,因此你可能需要在每个数量级负载增加时重新考虑你的架构。由于应用程序的需求可能会发展,通常不值得提前规划未来的扩展需求超过一个数量级
此外,适合一个负载级别的架构不太可能应对 10 倍的负载。如果你正在开发快速增长的服务,因此很可能你需要在每个数量级的负载增加时重新考虑你的架构。由于应用程序的需求可能会演变,通常不值得提前规划超过一个数量级的未来伸缩需求
扩展性的一个良好通用原则是将系统分解为可以在很大程度上相互独立运行的较小组件。这是微服务背后的基本原则(参见 ["微服务和无服务器"](/ch1#sec_introduction_microservices))、分片([第 7 章](/ch7#ch_sharding))、流处理([Link to Come])和无共享架构。然而,挑战在于知道在哪里划定应该在一起的事物和应该分开的事物之间的界限。微服务的设计指南可以在其他书籍中找到 [^84],我们在 [第 7 章](/ch7#ch_sharding) 中讨论无共享系统的分片。
伸缩性的一个良好通用原则是将系统分解为可以在很大程度上相互独立运行的较小组件。这是微服务背后的基本原则(参见 ["微服务与无服务器"](/ch1#sec_introduction_microservices))、分片([第 7 章](/ch7))、流处理([后续章节])和无共享架构。然而,挑战在于知道在哪里划分应该在一起的事物和应该分开的事物之间的界限。微服务的设计指南可以在其他书籍中找到 [^84],我们在 [第 7 章](/ch7) 中讨论无共享系统的分片。
另一个好原则是不要让事情变得比必要的更复杂。如果单机数据库可以完成工作,它可能比复杂的分布式设置更可取。自动扩展系统(根据需求自动添加或删除资源)很酷,但如果你的负载相当可预测,手动扩展的系统可能会有更少的操作意外(参见 ["操作:自动或手动再平衡"](/ch7#sec_sharding_operations))。具有五个服务的系统比具有五十个服务的系统更简单。良好的架构通常涉及方法的实混合。
另一个好原则是不要让事情变得比必要的更复杂。如果单机数据库可以完成工作,它可能比复杂的分布式设置更可取。自动伸缩系统(根据需求自动添加或删除资源)很酷,但如果你的负载相当可预测,手动伸缩的系统可能会有更少的操作意外(参见 ["操作:自动或手动再平衡"](/ch7#sec_sharding_operations))。具有五个服务的系统比具有五十个服务的系统更简单。良好的架构通常涉及方法的实混合。
## 可运维性 {#sec_introduction_maintainability}
软件不会磨损或遭受材料疲劳,因此它不会像机械物体那样破坏。但是应用程序的需求经常变化,软件运行的环境发生变化(例如其依赖项和底层平台),并且它有需要修复的错误。
软件不会磨损或遭受材料疲劳,因此它不会像机械物体那样以同样的方式损坏。但应用程序的要求经常变化,软件运行的环境发生变化(例如其依赖项和底层平台),并且它有需要修复的错误。
人们普遍认识到,软件的大部分成本不在于其初始开发,而在于其持续维护——修复错误、保持系统运行、调查故障、使其适应新平台、为新用例修改它、偿还技术债务以及添加新功能 [^85] [^86]。
人们普遍认为,软件的大部分成本不在其初始开发中,而在其持续维护中——修复错误、保持其系统运行、调查故障、将其适应新平台、为新用例修改它、偿还技术债务和添加新功能 [^85] [^86]。
然而,维护也很困难。如果系统已成功运行很长时间,它可能会使用今天很少有工程师理解的过时技术(例如大型机和 COBOL 代码);关于系统如何以及为什么以某种方式设计的机构知识可能会随着人们离开组织而丢失;可能有必要修复其他人的错误。此外,计算机系统通常与它支持的人类组织交织在一起,这意味着这种 **遗留** 系统的维护既是人的问题,也是技术问题 [^87]。
然而,维护也很困难。如果系统已成功运行很长时间,它可能使用今天不多工程师理解的过时技术(如大型机和 COBOL 代码);关于系统如何以及为何以某种方式设计的机构知识可能已经随着人们离开组织而丢失;可能需要修复其他人的错误。此外,计算机系统通常与它支持的人类组织交织在一起,这意味着此类 *遗留* 系统的维护既是人的问题,也是技术问题 [^87]。
我们今天创建的每个系统如果有足够的价值能够存活很长时间,有一天会成为遗留系统。为了最大限度地减少未来需要维护我们软件的几代人的痛苦,我们应该在设计时考虑维护问题。尽管我们不能总是预测哪些决定可能在未来造成维护麻烦,但在本书中,我们将关注几个广泛适用的原则:
如果我们今天创建的每个系统都足够有价值以长期生存,它有一天将成为遗留系统。为了最小化需要维护我们软件的未来几代人的痛苦,我们应该在设计时考虑维护问题。尽管我们不能总是预测哪些决定可能会在未来造成维护难题,但在本书中,我们将注意几个广泛适用的原则:
运维
: 使组织能够轻松地保持系统平稳运行。
操作
: 使组织容易保持系统平稳运行。
简单性
: 通过使用易于理解、一致的模式和结构来实现系统,并避免不必要的复杂性,使新工程师能够轻松理解系统。
: 通过使用易于理解、一致的模式和结构来实施它,并避免不必要的复杂性,使新工程师容易理解系统。
可演化性
: 使工程师能够轻松地在未来对系统进行更改,随着需求的变化而调整和扩展它以适应意外的用例。
: 使工程师将来容易对系统进行更改,随着需求变化而适应和扩展它以用于未预料的用例。
### 可运维性:让运维更轻松 {#id37}
我们之前在 ["云时代的运维"](/ch1#sec_introduction_operations) 中讨论了运维的作用,我们看到人类流程对于可靠运维至少与软件工具一样重要。事实上,有人建议"良好的运维通常可以解决糟糕(或不完整)软件的限制,但良好的软件无法通过糟糕的运维可靠地运行" [^60]。
我们之前在 ["云时代的运维"](/ch1#sec_introduction_operations) 中讨论了运维的角色,我们看到人类流程对于可靠运维至少与软件工具一样重要。事实上,有人建议"良好的运维通常可以解决糟糕(或不完整)软件的局限性,但良好的软件无法与糟糕的运维可靠地运行" [^60]。
在由数千台机器组成的大规模系统中,手动维护将是不合理的昂贵,自动化是必不可少的。然而,自动化可能是一把双刃剑:总会有边缘情况(如罕见的故障场景)需要运维团队的手动干预。由于无法自动处理的情况是最复杂的问题,更大的自动化需要一个 **更** 熟练的运维团队来解决这些问题 [^88]。
在由数千台机器组成的大规模系统中,手动维护将是不合理地昂贵的,自动化是必不可少的。然而,自动化可能是一把双刃剑:总会有边缘情况(如罕见的故障场景)需要运维团队的手动干预。由于无法自动处理的情况是最复杂的问题,更大的自动化需要一个 *更* 熟练的运维团队来解决这些问题 [^88]。
此外,如果自动化系统出错,通常比依赖操作员手动执行某些操作的系统更难排除故障。出于这个原因,更多的自动化并不总是对可操作性更好。然而,一定量的自动化很重要,最佳点将取决于你特定应用程序和组织的具体情况
此外,如果自动化系统出错,通常比依赖操作员手动执行某些操作的系统更难排除故障。出于这个原因,更多的自动化并不总是对可操作性更好。然而,一定程度的自动化很重要,最佳点将取决于你特定应用程序和组织的细节
良好的可操作性意味着使例行任务变得容易,允许运维团队将精力集中在高价值活动上。数据系统可以做各种事情来使例行任务变得容易,包括 [^89]
良好的可操作性意味着使常规任务变得容易,使运维团队能够将精力集中在高价值活动上。数据系统可以做各种事情来使常规任务变得容易,包括 [^89]
* 允许监控工具检查系统的关键指标,并支持可观察性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems))以深入了解系统的运行时行为。各种商业和开源工具可以在这里提供帮助 [^90]。
* 避免对单个机器的依赖(允许在系统作为一个整体继续不间断运行的同时关闭机器进行维护)
* 提供良好的文档和易于理解的操作模型("如果我做 XY 将发生"
* 避免对单个机器的依赖(允许在系统整体继续不间断运行的同时关闭机器进行维护)
* 提供良好的文档和易于理解的操作模型("如果我做 XY 将发生"
* 提供良好的默认行为,但也给管理员在需要时覆盖默认值的自由
* 在适当的情况下自我修复,但也在需要时为管理员提供对系统状态的手动控制
* 表现出可预测的行为,最大限度地减少意外
* 在适当的地方自我修复,但也在需要时给管理员手动控制系统状态
* 表现出可预测的行为,最小化意外
### 简单性:管理复杂度 {#id38}
小型软件项目可以拥有令人愉快的简单和富有表现力的代码,但随着项目变大,它们通常变得非常复杂且难以理解。这种复杂性减慢了每个需要在系统上工作的人的速度,进一步增加了维护成本。陷入复杂性的软件项目有时被描述为 **大泥球** [^91]。
小型软件项目可以有令人愉快地简单和富有表现力的代码,但随着项目变大,它们通常变得非常复杂且难以理解。这种复杂性减慢了需要在系统上工作的每个人,进一步增加了维护成本。陷入复杂性的软件项目有时被描述为 *大泥球* [^91]。
当复杂性使维护变得困难时,预算和时间表经常超支。在复杂软件中,进行更改时引入错误的风险也更大:当系统对开发人员来说更难理解和推理时,隐藏的假设、意外的后果和意外的交互更容易被忽视 [^69]。相反,降低复杂性极大地提高了软件的可维护性,因此简单性应该是我们构建的系统的关键目标。
当复杂性使维护困难时,预算和时间表经常超支。在复杂软件中,进行更改时引入错误的风险也更大:当系统对开发人员来说更难理解和推理时,隐藏的假设、意外的后果和意外的交互更容易被忽视 [^69]。相反,降低复杂性极大地提高了软件的可维护性,因此简单性应该是我们构建的系统的关键目标。
简单系统更容易理解,因此我们应该尝试以最简单的方式解决给定的问题。不幸的是,说起来容易做起来难。某事是否简单通常是主观品味问题,因为没有客观的简单性标准 [^92]。例如,一个系统可能会在简单接口后面隐藏复杂的实现,而另一个系统可能有一个向用户公开更多内部细节的简单实现——哪个更简单?
简单系统更容易理解,因此我们应该尝试以尽可能简单的方式解决给定问题。不幸的是,这说起来容易做起来难。某物是否简单通常是主观的品味问题,因为没有客观的简单性标准 [^92]。例如,一个系统可能在简单界面后面隐藏复杂的实现,而另一个系统可能有一个向用户公开更多内部细节的简单实现——哪个更简单?
推理复杂性的一种尝试是将其分为两类,**基本** 和 **偶然** 复杂性 [^93]。想法是基本复杂性是应用程序问题域中固有的,而偶然复杂性仅由于我们工具的限制而产生。不幸的是,这种区别也是有缺陷的,因为随着我们的工具发展,基本和偶然之间的界限会发生变化 [^94]。
推理复杂性的一种尝试是将其分为两类,*本质* 和 *偶然* 复杂性 [^93]。这个想法是,本质复杂性是应用程序问题域中固有的,而偶然复杂性仅由于我们工具的限制而产生。不幸的是,这种区别也有缺陷,因为本质和偶然之间的边界随着我们工具的发展而变化 [^94]。
我们管理复杂性的最佳工具之一是 **抽象**。良好的抽象可以在干净、易于理解的外观后面隐藏大量实现细节。良好的抽象也可以用于各种不同的应用程序。这种重用不仅比多次重新实现类似的东西更有效,而且还导致更高质量的软件,因为抽象组件中的质量改进使所有使用它的应用程序受益。
我们管理复杂性的最佳工具之一是 *抽象*。良好的抽象可以在干净、易于理解的外观后面隐藏大量实现细节。良好的抽象也可以用于各种不同的应用程序。这种重用不仅比多次重新实现类似的东西更有效,而且还导致更高质量的软件,因为抽象组件中的质量改进使所有使用它的应用程序受益。
例如高级编程语言是隐藏机器代码、CPU 寄存器和系统调用的抽象。SQL 是隐藏复杂的磁盘和内存数据结构、来自其他客户端的并发请求以及崩溃后不一致的抽象。当然,在高级语言编程时,我们仍在使用机器代码;我们只是不 **直接** 使用它,因为编程语言抽象使我们免于考它。
例如高级编程语言是隐藏机器代码、CPU 寄存器和系统调用的抽象。SQL 是一种隐藏复杂的磁盘和内存数据结构、来自其他客户端的并发请求以及崩溃后不一致的抽象。当然,在高级语言编程时,我们仍在使用机器代码;我们只是不 *直接* 使用它,因为编程语言抽象使我们免于考它。
应用程序代码的抽象,旨在降低其复杂性,可以使用 **设计模式** [^95] 和 **领域驱动设计**DDD[^96] 等方法创建。本书不是关于这种特定于应用程序的抽象,而是关于你可以在其上构建应用程序的通用抽象,例如数据库事务、索引和事件日志。如果你想使用 DDD 等技术,你可以在本书中描述的基础之上实现它们。
应用程序代码的抽象,旨在降低其复杂性,可以使用诸如 *设计模式* [^95] 和 *领域驱动设计*DDD[^96] 等方法创建。本书不是关于此类特定于应用程序的抽象,而是关于你可以在其上构建应用程序的通用抽象,例如数据库事务、索引和事件日志。如果你想使用像 DDD 这样的技术,你可以在本书中描述的基础之上实现它们。
### 可演化性:让变化更容易 {#sec_introduction_evolvability}
你的系统需求极不可能永远保持不变。它们更有可能处于不断变化中:你学习新事实,以前意料之外的用例出现,业务优先级发生变化,用户请求新功能,新平台取代旧平台,法律或监管要求发生变化,系统的增长迫使架构变化等。
你的系统需求将保持不变的可能性极小。它们更可能处于不断变化中:你学习新事实、以前未预料的用例出现、业务优先级发生变化、用户请求新功能、新平台取代旧平台、法律或监管要求发生变化、系统增长迫使架构变化等。
就组织流程而言,**敏捷** 工作模式为适应变化提供了一个框架。敏捷社区还开发了在频繁变化的环境中开发软件时有用的技术工具和流程例如测试驱动开发TDD和重构。在本书中我们寻找在由几个具有不同特征的不同应用程序或服务组成的系统级别提高敏捷性的方法。
在组织流程方面,*敏捷* 工作模式为适应变化提供了框架。敏捷社区还开发了在频繁变化的环境中开发软件时有用的技术工具和流程例如测试驱动开发TDD和重构。在本书中我们搜索在由具有不同特征的几个不同应用程序或服务组成的系统级别增加敏捷性的方法。
你可以修改数据系统并使其适应不断变化的需求的易程度与其简单性及其抽象密切相关:松散耦合、简单系统通常比紧密耦合、复杂系统更容易修改。由于这是一个如此重要的想法,我们将使用不同的词来指代数据系统级别的敏捷性:**可演化性** [^97]。
你可以修改数据系统并使其适应不断变化的需求的易程度与其简单性及其抽象密切相关:松散耦合、简单系统通常比紧密耦合、复杂系统更容易修改。由于这是一个如此重要的想法,我们将使用不同的词来指代数据系统级别的敏捷性:*可演化性* [^97]。
使变化变得困难的一个主要因素是当某些行动是不可逆的,因此需要非常小心地采取该行动 [^98]。例如,假设你正在从一个数据库迁移到另一个数据库:如果你无法在新数据库出现问题时切换回旧系统,那么风险比你可以轻松返回时要高得多。最小化不可逆性可以提高灵活性。
使大型系统中的变化困难的一个主要因素是某些操作不可逆,因此需要非常谨慎地采取该操作 [^98]。例如,假设你正在从一个数据库迁移到另一个数据库:如果在新数据库出现问题时无法切换回旧系统,风险就会高得多,而如果你可以轻松返回。最小化不可逆性提高了灵活性。
## 总结 {#summary}
在本章中,我们研究了几个非功能性需求的示例:性能、可靠性、可扩展性和可维护性。通过这些主题,我们还遇到了我们在本书其余部分所需的原则和术语。我们从一个如何在社交网络中实现主页时间线的案例研究开始,这说明了大规模出现的一些挑战。
在本章中,我们研究了几个非功能性需求的例子:性能、可靠性、可伸缩性和可维护性。通过这些主题,我们还遇到了我们在本书其余部分需要的原则和术语。我们从社交网络中首页时间线如何实现的案例研究开始,这说明了规模上出现的一些挑战。
我们讨论了如何衡量性能(例如,使用响应时间百分位数)、系统上的负载(例如,使用吞吐量指标)以及它们如何在 SLA 中使用。可扩展性是一个密切相关的概念:即,确保负载增长时性能保持不变。我们看到了一些可扩展性的一般原则,例如将任务分解为可以独立运行的较小部分,我们将在以下章节中深入探讨可扩展性技术的技术细节。
我们讨论了如何衡量性能(例如,使用响应时间百分位数)、系统上的负载(例如,使用吞吐量指标),以及它们如何在 SLA 中使用。可伸缩性是一个密切相关的概念:即,在负载增长时确保性能保持不变。我们看到了可伸缩性的一些一般原则,例如将任务分解为可以独立运行的较小部分,我们将在以下章节中深入研究可伸缩性技术的技术细节。
为了实现可靠性,你可以使用容错技术,即使某组件(例如,磁盘、机器或其他服务)出现故障,系统也可以继续提供其服务。我们看到了可能发生的硬件故障的例,并将它们与软件故障区分开来,软件故障可能更难处理,因为它们通常高度相关。实现可靠性的另一个方面是建立对人类犯错误的韧性,我们看到无责备事后分析作为从事件中学习的技术。
为了实现可靠性,你可以使用容错技术,即使某组件(例如,磁盘、机器或其他服务)出现故障,系统也可以继续提供其服务。我们看到了可能发生的硬件故障的例,并将它们与软件故障区分开来,软件故障可能更难处理,因为它们通常是强相关的。实现可靠性的另一个方面是建立对人类犯错误的韧性,我们看到无责备事后分析作为从事件中学习的技术。
最后,我们研究了可维护性的几个方面,包括支持运维团队的工作、管理复杂性以及使应用程序的功能随着时间的推移易于发展。关于如何实现这些事情没有简单的答案,但有一件事可以帮助,那就是使用经过验证在实践中有价值的易于理解的构建块来构建应用程序。本书的其余部分将涵盖一系列已被证明在实践中有价值的构建块。
最后,我们研究了可维护性的几个方面,包括支持运维团队的工作、管理复杂性以及随着时间的推移使应用程序功能易于演化。实现这些目标没有简单的答案,但有一件事可以帮助,那就是使用提供有用抽象的易于理解的构建块来构建应用程序。本书的其余部分将涵盖一系列在实践中被证明有价值的构建块。
### 参考
### 参考 {#参考}
[^1]: Mike Cvet. [How We Learned to Stop Worrying and Love Fan-In at Twitter](https://www.youtube.com/watch?v=WEgCjwyXvwc). At *QCon San Francisco*, December 2016.
[^2]: Raffi Krikorian. [Timelines at Scale](https://www.infoq.com/presentations/Twitter-Timeline-Scalability/). At *QCon San Francisco*, November 2012. Archived at [perma.cc/V9G5-KLYK](https://perma.cc/V9G5-KLYK)
[^3]: Twitter. [Twitters Recommendation Algorithm](https://blog.twitter.com/engineering/en_us/topics/open-source/2023/twitter-recommendation-algorithm). *blog.twitter.com*, March 2023. Archived at [perma.cc/L5GT-229T](https://perma.cc/L5GT-229T)
[^3]: Twitter. [Twitter's Recommendation Algorithm](https://blog.twitter.com/engineering/en_us/topics/open-source/2023/twitter-recommendation-algorithm). *blog.twitter.com*, March 2023. Archived at [perma.cc/L5GT-229T](https://perma.cc/L5GT-229T)
[^4]: Raffi Krikorian. [New Tweets per second record, and how!](https://blog.twitter.com/engineering/en_us/a/2013/new-tweets-per-second-record-and-how) *blog.twitter.com*, August 2013. Archived at [perma.cc/6JZN-XJYN](https://perma.cc/6JZN-XJYN)
[^5]: Jaz Volpert. [When Imperfect Systems are Good, Actually: Blueskys Lossy Timelines](https://jazco.dev/2025/02/19/imperfection/). *jazco.dev*, February 2025. Archived at [perma.cc/2PVE-L2MX](https://perma.cc/2PVE-L2MX)
[^6]: Samuel Axon. [3% of Twitters Servers Dedicated to Justin Bieber](https://mashable.com/archive/justin-bieber-twitter). *mashable.com*, September 2010. Archived at [perma.cc/F35N-CGVX](https://perma.cc/F35N-CGVX)
[^5]: Jaz Volpert. [When Imperfect Systems are Good, Actually: Bluesky's Lossy Timelines](https://jazco.dev/2025/02/19/imperfection/). *jazco.dev*, February 2025. Archived at [perma.cc/2PVE-L2MX](https://perma.cc/2PVE-L2MX)
[^6]: Samuel Axon. [3% of Twitter's Servers Dedicated to Justin Bieber](https://mashable.com/archive/justin-bieber-twitter). *mashable.com*, September 2010. Archived at [perma.cc/F35N-CGVX](https://perma.cc/F35N-CGVX)
[^7]: Nathan Bronson, Abutalib Aghayev, Aleksey Charapko, and Timothy Zhu. [Metastable Failures in Distributed Systems](https://sigops.org/s/conferences/hotos/2021/papers/hotos21-s11-bronson.pdf). At *Workshop on Hot Topics in Operating Systems* (HotOS), May 2021. [doi:10.1145/3458336.3465286](https://doi.org/10.1145/3458336.3465286)
[^8]: Marc Brooker. [Metastability and Distributed Systems](https://brooker.co.za/blog/2021/05/24/metastable.html). *brooker.co.za*, May 2021. Archived at [perma.cc/7FGJ-7XRK](https://perma.cc/7FGJ-7XRK)
[^9]: Marc Brooker. [Exponential Backoff And Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/). *aws.amazon.com*, March 2015. Archived at [perma.cc/R6MS-AZKH](https://perma.cc/R6MS-AZKH)
[^10]: Marc Brooker. [What is Backoff For?](https://brooker.co.za/blog/2022/08/11/backoff.html) *brooker.co.za*, August 2022. Archived at [perma.cc/PW9N-55Q5](https://perma.cc/PW9N-55Q5)
[^11]: Michael T. Nygard. [*Release It!*](https://learning.oreilly.com/library/view/release-it-2nd/9781680504552/), 2nd Edition. Pragmatic Bookshelf, January 2018. ISBN: 9781680502398
[^12]: Frank Chen. [Slowing Down to Speed Up Circuit Breakers for Slacks CI/CD](https://slack.engineering/circuit-breakers/). *slack.engineering*, August 2022. Archived at [perma.cc/5FGS-ZPH3](https://perma.cc/5FGS-ZPH3)
[^12]: Frank Chen. [Slowing Down to Speed Up Circuit Breakers for Slack's CI/CD](https://slack.engineering/circuit-breakers/). *slack.engineering*, August 2022. Archived at [perma.cc/5FGS-ZPH3](https://perma.cc/5FGS-ZPH3)
[^13]: Marc Brooker. [Fixing retries with token buckets and circuit breakers](https://brooker.co.za/blog/2022/02/28/retries.html). *brooker.co.za*, February 2022. Archived at [perma.cc/MD6N-GW26](https://perma.cc/MD6N-GW26)
[^14]: David Yanacek. [Using load shedding to avoid overload](https://aws.amazon.com/builders-library/using-load-shedding-to-avoid-overload/). Amazon Builders Library, *aws.amazon.com*. Archived at [perma.cc/9SAW-68MP](https://perma.cc/9SAW-68MP)
[^14]: David Yanacek. [Using load shedding to avoid overload](https://aws.amazon.com/builders-library/using-load-shedding-to-avoid-overload/). Amazon Builders' Library, *aws.amazon.com*. Archived at [perma.cc/9SAW-68MP](https://perma.cc/9SAW-68MP)
[^15]: Matthew Sackman. [Pushing Back](https://wellquite.org/posts/lshift/pushing_back/). *wellquite.org*, May 2016. Archived at [perma.cc/3KCZ-RUFY](https://perma.cc/3KCZ-RUFY)
[^16]: Dmitry Kopytkov and Patrick Lee. [Meet Bandaid, the Dropbox service proxy](https://dropbox.tech/infrastructure/meet-bandaid-the-dropbox-service-proxy). *dropbox.tech*, March 2018. Archived at [perma.cc/KUU6-YG4S](https://perma.cc/KUU6-YG4S)
[^17]: Haryadi S. Gunawi, Riza O. Suminto, Russell Sears, Casey Golliher, Swaminathan Sundararaman, Xing Lin, Tim Emami, Weiguang Sheng, Nematollah Bidokhti, Caitie McCaffrey, Gary Grider, Parks M. Fields, Kevin Harms, Robert B. Ross, Andree Jacobson, Robert Ricci, Kirk Webb, Peter Alvaro, H. Birali Runesha, Mingzhe Hao, and Huaicheng Li. [Fail-Slow at Scale: Evidence of Hardware Performance Faults in Large Production Systems](https://www.usenix.org/system/files/conference/fast18/fast18-gunawi.pdf). At *16th USENIX Conference on File and Storage Technologies*, February 2018.
[^18]: Marc Brooker. [Is the Mean Really Useless?](https://brooker.co.za/blog/2017/12/28/mean.html) *brooker.co.za*, December 2017. Archived at [perma.cc/U5AE-CVEM](https://perma.cc/U5AE-CVEM)
[^19]: Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, Gunavardhan Kakulapati, Avinash Lakshman, Alex Pilchin, Swaminathan Sivasubramanian, Peter Vosshall, and Werner Vogels. [Dynamo: Amazons Highly Available Key-Value Store](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf). At *21st ACM Symposium on Operating Systems Principles* (SOSP), October 2007. [doi:10.1145/1294261.1294281](https://doi.org/10.1145/1294261.1294281)
[^19]: Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, Gunavardhan Kakulapati, Avinash Lakshman, Alex Pilchin, Swaminathan Sivasubramanian, Peter Vosshall, and Werner Vogels. [Dynamo: Amazon's Highly Available Key-Value Store](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf). At *21st ACM Symposium on Operating Systems Principles* (SOSP), October 2007. [doi:10.1145/1294261.1294281](https://doi.org/10.1145/1294261.1294281)
[^20]: Kathryn Whitenton. [The Need for Speed, 23 Years Later](https://www.nngroup.com/articles/the-need-for-speed/). *nngroup.com*, May 2020. Archived at [perma.cc/C4ER-LZYA](https://perma.cc/C4ER-LZYA)
[^21]: Greg Linden. [Marissa Mayer at Web 2.0](https://glinden.blogspot.com/2006/11/marissa-mayer-at-web-20.html). *glinden.blogspot.com*, November 2005. Archived at [perma.cc/V7EA-3VXB](https://perma.cc/V7EA-3VXB)
[^22]: Jake Brutlag. [Speed Matters for Google Web Search](https://services.google.com/fh/files/blogs/google_delayexp.pdf). *services.google.com*, June 2009. Archived at [perma.cc/BK7R-X7M2](https://perma.cc/BK7R-X7M2)
@ -424,19 +422,19 @@ Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒使电子
[^24]: Akamai Technologies, Inc. [The State of Online Retail Performance](https://web.archive.org/web/20210729180749/https%3A//www.akamai.com/us/en/multimedia/documents/report/akamai-state-of-online-retail-performance-spring-2017.pdf). *akamai.com*, April 2017. Archived at [perma.cc/UEK2-HYCS](https://perma.cc/UEK2-HYCS)
[^25]: Xiao Bai, Ioannis Arapakis, B. Barla Cambazoglu, and Ana Freire. [Understanding and Leveraging the Impact of Response Latency on User Behaviour in Web Search](https://iarapakis.github.io/papers/TOIS17.pdf). *ACM Transactions on Information Systems*, volume 36, issue 2, article 21, April 2018. [doi:10.1145/3106372](https://doi.org/10.1145/3106372)
[^26]: Jeffrey Dean and Luiz André Barroso. [The Tail at Scale](https://cacm.acm.org/research/the-tail-at-scale/). *Communications of the ACM*, volume 56, issue 2, pages 7480, February 2013. [doi:10.1145/2408776.2408794](https://doi.org/10.1145/2408776.2408794)
[^27]: Alex Hidalgo. [*Implementing Service Level Objectives: A Practical Guide to SLIs, SLOs, and Error Budgets*](https://www.oreilly.com/library/view/implementing-service-level/9781492076803/). OReilly Media, September 2020. ISBN: 1492076813
[^27]: Alex Hidalgo. [*Implementing Service Level Objectives: A Practical Guide to SLIs, SLOs, and Error Budgets*](https://www.oreilly.com/library/view/implementing-service-level/9781492076803/). O'Reilly Media, September 2020. ISBN: 1492076813
[^28]: Jeffrey C. Mogul and John Wilkes. [Nines are Not Enough: Meaningful Metrics for Clouds](https://research.google/pubs/pub48033/). At *17th Workshop on Hot Topics in Operating Systems* (HotOS), May 2019. [doi:10.1145/3317550.3321432](https://doi.org/10.1145/3317550.3321432)
[^29]: Tamás Hauer, Philipp Hoffmann, John Lunney, Dan Ardelean, and Amer Diwan. [Meaningful Availability](https://www.usenix.org/conference/nsdi20/presentation/hauer). At *17th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), February 2020.
[^30]: Ted Dunning. [The t-digest: Efficient estimates of distributions](https://www.sciencedirect.com/science/article/pii/S2665963820300403). *Software Impacts*, volume 7, article 100049, February 2021. [doi:10.1016/j.simpa.2020.100049](https://doi.org/10.1016/j.simpa.2020.100049)
[^31]: David Kohn. [How percentile approximation works (and why its more useful than averages)](https://www.timescale.com/blog/how-percentile-approximation-works-and-why-its-more-useful-than-averages/). *timescale.com*, September 2021. Archived at [perma.cc/3PDP-NR8B](https://perma.cc/3PDP-NR8B)
[^31]: David Kohn. [How percentile approximation works (and why it's more useful than averages)](https://www.timescale.com/blog/how-percentile-approximation-works-and-why-its-more-useful-than-averages/). *timescale.com*, September 2021. Archived at [perma.cc/3PDP-NR8B](https://perma.cc/3PDP-NR8B)
[^32]: Heinrich Hartmann and Theo Schlossnagle. [Circllhist — A Log-Linear Histogram Data Structure for IT Infrastructure Monitoring](https://arxiv.org/pdf/2001.06561.pdf). *arxiv.org*, January 2020.
[^33]: Charles Masson, Jee E. Rim, and Homin K. Lee. [DDSketch: A Fast and Fully-Mergeable Quantile Sketch with Relative-Error Guarantees](https://www.vldb.org/pvldb/vol12/p2195-masson.pdf). *Proceedings of the VLDB Endowment*, volume 12, issue 12, pages 21952205, August 2019. [doi:10.14778/3352063.3352135](https://doi.org/10.14778/3352063.3352135)
[^34]: Baron Schwartz. [Why Percentiles Dont Work the Way You Think](https://orangematter.solarwinds.com/2016/11/18/why-percentiles-dont-work-the-way-you-think/). *solarwinds.com*, November 2016. Archived at [perma.cc/469T-6UGB](https://perma.cc/469T-6UGB)
[^34]: Baron Schwartz. [Why Percentiles Don't Work the Way You Think](https://orangematter.solarwinds.com/2016/11/18/why-percentiles-dont-work-the-way-you-think/). *solarwinds.com*, November 2016. Archived at [perma.cc/469T-6UGB](https://perma.cc/469T-6UGB)
[^35]: Walter L. Heimerdinger and Charles B. Weinstock. [A Conceptual Framework for System Fault Tolerance](https://resources.sei.cmu.edu/asset_files/TechnicalReport/1992_005_001_16112.pdf). Technical Report CMU/SEI-92-TR-033, Software Engineering Institute, Carnegie Mellon University, October 1992. Archived at [perma.cc/GD2V-DMJW](https://perma.cc/GD2V-DMJW)
[^36]: Felix C. Gärtner. [Fundamentals of fault-tolerant distributed computing in asynchronous environments](https://dl.acm.org/doi/pdf/10.1145/311531.311532). *ACM Computing Surveys*, volume 31, issue 1, pages 126, March 1999. [doi:10.1145/311531.311532](https://doi.org/10.1145/311531.311532)
[^37]: Algirdas Avižienis, Jean-Claude Laprie, Brian Randell, and Carl Landwehr. [Basic Concepts and Taxonomy of Dependable and Secure Computing](https://hdl.handle.net/1903/6459). *IEEE Transactions on Dependable and Secure Computing*, volume 1, issue 1, January 2004. [doi:10.1109/TDSC.2004.2](https://doi.org/10.1109/TDSC.2004.2)
[^38]: Ding Yuan, Yu Luo, Xin Zhuang, Guilherme Renna Rodrigues, Xu Zhao, Yongle Zhang, Pranay U. Jain, and Michael Stumm. [Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-Intensive Systems](https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-yuan.pdf). At *11th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), October 2014.
[^39]: Casey Rosenthal and Nora Jones. [*Chaos Engineering*](https://learning.oreilly.com/library/view/chaos-engineering/9781492043850/). OReilly Media, April 2020. ISBN: 9781492043867
[^39]: Casey Rosenthal and Nora Jones. [*Chaos Engineering*](https://learning.oreilly.com/library/view/chaos-engineering/9781492043850/). O'Reilly Media, April 2020. ISBN: 9781492043867
[^40]: Eduardo Pinheiro, Wolf-Dietrich Weber, and Luiz Andre Barroso. [Failure Trends in a Large Disk Drive Population](https://www.usenix.org/legacy/events/fast07/tech/full_papers/pinheiro/pinheiro_old.pdf). At *5th USENIX Conference on File and Storage Technologies* (FAST), February 2007.
[^41]: Bianca Schroeder and Garth A. Gibson. [Disk failures in the real world: What does an MTTF of 1,000,000 hours mean to you?](https://www.usenix.org/legacy/events/fast07/tech/schroeder/schroeder.pdf) At *5th USENIX Conference on File and Storage Technologies* (FAST), February 2007.
[^42]: Andy Klein. [Backblaze Drive Stats for Q2 2021](https://www.backblaze.com/blog/backblaze-drive-stats-for-q2-2021/). *backblaze.com*, August 2021. Archived at [perma.cc/2943-UD5E](https://perma.cc/2943-UD5E)
@ -446,7 +444,7 @@ Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒使电子
[^46]: Jacob Alter, Ji Xue, Alma Dimnaku, and Evgenia Smirni. [SSD failures in the field: symptoms, causes, and prediction models](https://dl.acm.org/doi/pdf/10.1145/3295500.3356172). At *International Conference for High Performance Computing, Networking, Storage and Analysis* (SC), November 2019. [doi:10.1145/3295500.3356172](https://doi.org/10.1145/3295500.3356172)
[^47]: Daniel Ford, François Labelle, Florentina I. Popovici, Murray Stokely, Van-Anh Truong, Luiz Barroso, Carrie Grimes, and Sean Quinlan. [Availability in Globally Distributed Storage Systems](https://www.usenix.org/legacy/event/osdi10/tech/full_papers/Ford.pdf). At *9th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), October 2010.
[^48]: Kashi Venkatesh Vishwanath and Nachiappan Nagappan. [Characterizing Cloud Computing Hardware Reliability](https://www.microsoft.com/en-us/research/wp-content/uploads/2010/06/socc088-vishwanath.pdf). At *1st ACM Symposium on Cloud Computing* (SoCC), June 2010. [doi:10.1145/1807128.1807161](https://doi.org/10.1145/1807128.1807161)
[^49]: Peter H. Hochschild, Paul Turner, Jeffrey C. Mogul, Rama Govindaraju, Parthasarathy Ranganathan, David E. Culler, and Amin Vahdat. [Cores that dont count](https://sigops.org/s/conferences/hotos/2021/papers/hotos21-s01-hochschild.pdf). At *Workshop on Hot Topics in Operating Systems* (HotOS), June 2021. [doi:10.1145/3458336.3465297](https://doi.org/10.1145/3458336.3465297)
[^49]: Peter H. Hochschild, Paul Turner, Jeffrey C. Mogul, Rama Govindaraju, Parthasarathy Ranganathan, David E. Culler, and Amin Vahdat. [Cores that don't count](https://sigops.org/s/conferences/hotos/2021/papers/hotos21-s01-hochschild.pdf). At *Workshop on Hot Topics in Operating Systems* (HotOS), June 2021. [doi:10.1145/3458336.3465297](https://doi.org/10.1145/3458336.3465297)
[^50]: Harish Dattatraya Dixit, Sneha Pendharkar, Matt Beadon, Chris Mason, Tejasvi Chakravarthy, Bharath Muthiah, and Sriram Sankar. [Silent Data Corruptions at Scale](https://arxiv.org/abs/2102.11245). *arXiv:2102.11245*, February 2021.
[^51]: Diogo Behrens, Marco Serafini, Sergei Arnautov, Flavio P. Junqueira, and Christof Fetzer. [Scalable Error Isolation for Distributed Systems](https://www.usenix.org/conference/nsdi15/technical-sessions/presentation/behrens). At *12th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), May 2015.
[^52]: Bianca Schroeder, Eduardo Pinheiro, and Wolf-Dietrich Weber. [DRAM Errors in the Wild: A Large-Scale Field Study](https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/35162.pdf). At *11th International Joint Conference on Measurement and Modeling of Computer Systems* (SIGMETRICS), June 2009. [doi:10.1145/1555349.1555372](https://doi.org/10.1145/1555349.1555372)
@ -463,17 +461,17 @@ Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒使电子
[^63]: Lorin Hochstein. [awesome limits](https://github.com/lorin/awesome-limits). *github.com*, November 2020. Archived at [perma.cc/3R5M-E5Q4](https://perma.cc/3R5M-E5Q4)
[^64]: Caitie McCaffrey. [Clients Are Jerks: AKA How Halo 4 DoSed the Services at Launch & How We Survived](https://www.caitiem.com/2015/06/23/clients-are-jerks-aka-how-halo-4-dosed-the-services-at-launch-how-we-survived/). *caitiem.com*, June 2015. Archived at [perma.cc/MXX4-W373](https://perma.cc/MXX4-W373)
[^65]: Lilia Tang, Chaitanya Bhandari, Yongle Zhang, Anna Karanika, Shuyang Ji, Indranil Gupta, and Tianyin Xu. [Fail through the Cracks: Cross-System Interaction Failures in Modern Cloud Systems](https://tianyin.github.io/pub/csi-failures.pdf). At *18th European Conference on Computer Systems* (EuroSys), May 2023. [doi:10.1145/3552326.3587448](https://doi.org/10.1145/3552326.3587448)
[^66]: Mike Ulrich. [Addressing Cascading Failures](https://sre.google/sre-book/addressing-cascading-failures/). In Betsy Beyer, Jennifer Petoff, Chris Jones, and Niall Richard Murphy (ed). [*Site Reliability Engineering: How Google Runs Production Systems*](https://www.oreilly.com/library/view/site-reliability-engineering/9781491929117/). OReilly Media, 2016. ISBN: 9781491929124
[^66]: Mike Ulrich. [Addressing Cascading Failures](https://sre.google/sre-book/addressing-cascading-failures/). In Betsy Beyer, Jennifer Petoff, Chris Jones, and Niall Richard Murphy (ed). [*Site Reliability Engineering: How Google Runs Production Systems*](https://www.oreilly.com/library/view/site-reliability-engineering/9781491929117/). O'Reilly Media, 2016. ISBN: 9781491929124
[^67]: Harri Faßbender. [Cascading failures in large-scale distributed systems](https://blog.mi.hdm-stuttgart.de/index.php/2022/03/03/cascading-failures-in-large-scale-distributed-systems/). *blog.mi.hdm-stuttgart.de*, March 2022. Archived at [perma.cc/K7VY-YJRX](https://perma.cc/K7VY-YJRX)
[^68]: Richard I. Cook. [How Complex Systems Fail](https://www.adaptivecapacitylabs.com/HowComplexSystemsFail.pdf). Cognitive Technologies Laboratory, April 2000. Archived at [perma.cc/RDS6-2YVA](https://perma.cc/RDS6-2YVA)
[^69]: David D. Woods. [STELLA: Report from the SNAFUcatchers Workshop on Coping With Complexity](https://snafucatchers.github.io/). *snafucatchers.github.io*, March 2017. Archived at [archive.org](https://web.archive.org/web/20230306130131/https%3A//snafucatchers.github.io/)
[^70]: David Oppenheimer, Archana Ganapathi, and David A. Patterson. [Why Do Internet Services Fail, and What Can Be Done About It?](https://static.usenix.org/events/usits03/tech/full_papers/oppenheimer/oppenheimer.pdf) At *4th USENIX Symposium on Internet Technologies and Systems* (USITS), March 2003.
[^71]: Sidney Dekker. [*The Field Guide to Understanding Human Error, 3rd Edition*](https://learning.oreilly.com/library/view/the-field-guide/9781317031833/). CRC Press, November 2017. ISBN: 9781472439055
[^71]: Sidney Dekker. [*The Field Guide to Understanding 'Human Error', 3rd Edition*](https://learning.oreilly.com/library/view/the-field-guide/9781317031833/). CRC Press, November 2017. ISBN: 9781472439055
[^72]: Sidney Dekker. [*Drift into Failure: From Hunting Broken Components to Understanding Complex Systems*](https://www.taylorfrancis.com/books/mono/10.1201/9781315257396/drift-failure-sidney-dekker). CRC Press, 2011. ISBN: 9781315257396
[^73]: John Allspaw. [Blameless PostMortems and a Just Culture](https://www.etsy.com/codeascraft/blameless-postmortems/). *etsy.com*, May 2012. Archived at [perma.cc/YMJ7-NTAP](https://perma.cc/YMJ7-NTAP)
[^74]: Itzy Sabo. [Uptime Guarantees — A Pragmatic Perspective](https://world.hey.com/itzy/uptime-guarantees-a-pragmatic-perspective-736d7ea4). *world.hey.com*, March 2023. Archived at [perma.cc/F7TU-78JB](https://perma.cc/F7TU-78JB)
[^75]: Michael Jurewitz. [The Human Impact of Bugs](http://jury.me/blog/2013/3/14/the-human-impact-of-bugs). *jury.me*, March 2013. Archived at [perma.cc/5KQ4-VDYL](https://perma.cc/5KQ4-VDYL)
[^76]: Mark Halper. [How Software Bugs led to One of the Greatest Miscarriages of Justice in British History](https://cacm.acm.org/news/how-software-bugs-led-to-one-of-the-greatest-miscarriages-of-justice-in-british-history/). *Communications of the ACM*, January 2025. [doi:10.1145/3703779](https://doi.org/10.1145/3703779)
[^76]: Mark Halper. [How Software Bugs led to 'One of the Greatest Miscarriages of Justice' in British History](https://cacm.acm.org/news/how-software-bugs-led-to-one-of-the-greatest-miscarriages-of-justice-in-british-history/). *Communications of the ACM*, January 2025. [doi:10.1145/3703779](https://doi.org/10.1145/3703779)
[^77]: Nicholas Bohm, James Christie, Peter Bernard Ladkin, Bev Littlewood, Paul Marshall, Stephen Mason, Martin Newby, Steven J. Murdoch, Harold Thimbleby, and Martyn Thomas. [The legal rule that computers are presumed to be operating correctly unforeseen and unjust consequences](https://www.benthamsgaze.org/wp-content/uploads/2022/06/briefing-presumption-that-computers-are-reliable.pdf). Briefing note, *benthamsgaze.org*, June 2022. Archived at [perma.cc/WQ6X-TMW4](https://perma.cc/WQ6X-TMW4)
[^78]: Dan McKinley. [Choose Boring Technology](https://mcfunley.com/choose-boring-technology). *mcfunley.com*, March 2015. Archived at [perma.cc/7QW7-J4YP](https://perma.cc/7QW7-J4YP)
[^79]: Andy Warfield. [Building and operating a pretty big storage system called S3](https://www.allthingsdistributed.com/2023/07/building-and-operating-a-pretty-big-storage-system.html). *allthingsdistributed.com*, July 2023. Archived at [perma.cc/7LPK-TP7V](https://perma.cc/7LPK-TP7V)
@ -481,7 +479,7 @@ Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒使电子
[^81]: Ben Stopford. [Shared Nothing vs. Shared Disk Architectures: An Independent View](http://www.benstopford.com/2009/11/24/understanding-the-shared-nothing-architecture/). *benstopford.com*, November 2009. Archived at [perma.cc/7BXH-EDUR](https://perma.cc/7BXH-EDUR)
[^82]: Michael Stonebraker. [The Case for Shared Nothing](https://dsf.berkeley.edu/papers/hpts85-nothing.pdf). *IEEE Database Engineering Bulletin*, volume 9, issue 1, pages 49, March 1986.
[^83]: Panagiotis Antonopoulos, Alex Budovski, Cristian Diaconu, Alejandro Hernandez Saenz, Jack Hu, Hanuma Kodavalla, Donald Kossmann, Sandeep Lingam, Umar Farooq Minhas, Naveen Prakash, Vijendra Purohit, Hugh Qu, Chaitanya Sreenivas Ravella, Krystyna Reisteter, Sheetal Shrotri, Dixin Tang, and Vikram Wakade. [Socrates: The New SQL Server in the Cloud](https://www.microsoft.com/en-us/research/uploads/prod/2019/05/socrates.pdf). At *ACM International Conference on Management of Data* (SIGMOD), pages 17431756, June 2019. [doi:10.1145/3299869.3314047](https://doi.org/10.1145/3299869.3314047)
[^84]: Sam Newman. [*Building Microservices*, second edition](https://www.oreilly.com/library/view/building-microservices-2nd/9781492034018/). OReilly Media, 2021. ISBN: 9781492034025
[^84]: Sam Newman. [*Building Microservices*, second edition](https://www.oreilly.com/library/view/building-microservices-2nd/9781492034018/). O'Reilly Media, 2021. ISBN: 9781492034025
[^85]: Nathan Ensmenger. [When Good Software Goes Bad: The Surprising Durability of an Ephemeral Technology](https://themaintainers.wpengine.com/wp-content/uploads/2021/04/ensmenger-maintainers-v2.pdf). At *The Maintainers Conference*, April 2016. Archived at [perma.cc/ZXT4-HGZB](https://perma.cc/ZXT4-HGZB)
[^86]: Robert L. Glass. [*Facts and Fallacies of Software Engineering*](https://learning.oreilly.com/library/view/facts-and-fallacies/0321117425/). Addison-Wesley Professional, October 2002. ISBN: 9780321117427
[^87]: Marianne Bellotti. [*Kill It with Fire*](https://learning.oreilly.com/library/view/kill-it-with/9781098128883/). No Starch Press, April 2021. ISBN: 9781718501188

View File

@ -6,85 +6,85 @@ breadcrumbs: false
![](/map/ch02.png)
> *语言的边界就是思想的边界。*
> *语言的边界就是世界的边界。*
>
> 路德维希·维特根斯坦《逻辑哲学论》1922
> 路德维希维特根斯坦《逻辑哲学论》1922
数据模型可能是软件开发中最重要的部分了,因为它们有着如此深远的影响:不仅影响着软件的编写方式,而且影响着我们 **思考问题** 的方式。
数据模型或许是开发软件最重要的部分,因为它们有着深远的影响:不仅影响软件的编写方式,还影响我们 *思考问题* 的方式。
大多数应用程序都是通过一层层叠加的数据模型构建的。对于每一层,关键问题是:它如何用下一层的数据模型来 **表示**?例如:
大多数应用程序都是通过层层叠加的数据模型来构建的。对于每一层来说,关键问题是:如何用更低层次的数据模型来 *表示*?例如:
1. 作为应用程序开发人员,你观察现实世界(里面有人员、组织、货物、行为、资金流动、传感器等),并用对象或数据结构以及操作这些数据结构的 API 来进行建模。这些结构通常是特定于你的应用程序的。
2. 当你想要存储这些数据结构时,你用通用的数据模型来表达它们,如 JSON 或 XML 文档、关系数据库中的表,或图中的顶点和边。这些数据模型是本章的主题。
3. 构建数据库软件的工程师决定了如何用内存、磁盘或网络上的字节来表示文档/关系/图数据。这种表示方式可能允许数据以各种方式查询、搜索、操作和处理。我们将在[第 4 章](/ch4#ch_storage)中讨论这些存储引擎的设计。
4. 在更低的层次上,硬件工程师已经想出了如何用电流、光脉冲、磁场等来表示字节。
1. 作为应用程序开发者,你观察现实世界(其中有人员、组织、货物、行为、资金流动、传感器等),并用对象或数据结构以及操作这些数据结构的 API 来建模。这些结构通常是特定于应用程序的。
2. 当你想要存储这些数据结构时,你用通用的数据模型来表达它们,如 JSON 或 XML 文档、关系数据库中的表,或图中的顶点和边。这些数据模型是本章的主题。
3. 构建你的数据库软件的工程师决定了如何用内存、磁盘或网络上的字节来表示文档/关系/图数据。这种表示可能允许以各种方式查询、搜索、操作和处理数据。我们将在 [第 4 章](/ch4#ch_storage) 中讨论这些存储引擎的设计。
4. 在更低的层次上,硬件工程师已经想出了如何用电流、光脉冲、磁场等来表示字节的方法
在复杂的应用程序中可能有更多的中间层,比如基于 API 构建的 API但基本思想仍然相同每一层通过提供一个清晰的数据模型来隐藏下层的复杂性。这些抽象使不同的人群——例如数据库供应商的工程师和使用其数据库的应用程序开发人员——能够有效地协作。
在复杂的应用程序中可能有更多的中间层,例如基于 API 之上的 API但基本思想仍然相同每一层通过提供一个简洁的数据模型来隐藏下层的复杂性。这些抽象允许不同的人群 —— 例如,数据库供应商的工程师和使用他们数据库的应用程序开发者 —— 有效地合作。
实践中广泛使用了几种不同的数据模型,通常用于不同的目的。某些类型的数据和某些查询在一种模型中很容易表达,在另一种模型中却很别扭。在本章中,我们将通过比较关系模型、文档模型、基于图的数据模型、事件溯源和数据框来探讨这些权衡。我们还将简要了解允许你使用这些模型的查询语言。这种比较将帮助你决定何时使用哪种模型。
在实践中广泛使用着几种不同的数据模型,通常用于不同的目的。某些类型的数据和某些查询在一种模型中很容易表达,而在另一种模型中则很困难。在本章中,我们将通过比较关系模型、文档模型、基于图的数据模型、事件溯源和数据框来探讨这些权衡。我们还将简要介绍允许你使用这些模型的查询语言。这种比较将帮助你决定何时使用哪种模型。
--------
> [!TIP] 术语:声明式查询语言
本章中的许多查询语言(如 SQL、Cypher、SPARQL 或 Datalog都是 **声明式** 的,这意味着你指定所需数据的模式——结果必须满足什么条件,以及你希望如何转换数据(例如,排序、分组和聚合)——但不指定 **如何** 实现该目标。数据库系统的查询优化器可以决定使用哪些索引和哪些连接算法,以及以什么顺序执行查询的各个部分。
本章中的许多查询语言(如 SQL、Cypher、SPARQL 或 Datalog都是 *声明式* 的,这意味着你指定所需数据的模式 —— 结果必须满足什么条件,以及你希望如何转换数据(例如,排序、分组和聚合)—— 但不指定 *如何* 实现该目标。数据库系统的查询优化器可以决定使用哪些索引和哪些连接算法,以及以什么顺序执行查询的各个部分。
相比之下,在大多数编程语言中,你必须编写一个 **算法**——即告诉计算机以什么顺序执行哪些操作。声明式查询语言很有吸引力,因为它通常比显式算法更简洁、更容易编写。但更重要的是,它还隐藏了查询引擎的实现细节,这使得数据库系统可以在不需要更改任何查询的情况下引入性能改进 [^1]。
相比之下,使用大多数编程语言,你必须编写一个 *算法* —— 即告诉计算机以什么顺序执行哪些操作。声明式查询语言很有吸引力,因为它通常更简洁,比显式算法更容易编写。但更重要的是,它还隐藏了查询引擎的实现细节,这使得数据库系统可以在不需要更改任何查询的情况下引入性能改进 [^1]。
例如,数据库可能能够跨多个 CPU 核心和机器并行执行声明式查询,而你无需担心如何实现这种并行性 [^2]。在手写算法中,要自己实现这种并行执行将需要大量的工作。
例如,数据库可能能够跨多个 CPU 核心和机器并行执行声明式查询,而你无需担心如何实现该并行性 [^2]。在手写算法中,实现这种并行执行将需要大量的工作。
--------
## 关系模型与文档模型 {#sec_datamodels_history}
今最知的数据模型可能是 SQL它基于 Edgar Codd 于 1970 年提出的关系模型 [^3]:数据被组织成 **关系**SQL 中称为 **表**),其中每个关系是 **元组**SQL 中的 **行**)的无序集合。
广为人知的数据模型可能是 SQL它基于 Edgar Codd 在 1970 年提出的关系模型 [^3]:数据被组织成 *关系*(在 SQL 中称为 *表*),其中每个关系是 *元组*(在 SQL 中称为 *行*)的无序集合。
关系模型最初是一个理论性的提议,当时许多人怀疑它是否能够有效地实现。然而,到 20 世纪 80 年代中期关系数据库管理系统RDBMS和 SQL 已经成为大多数需要存储和查询某种规则结构数据的人的首选工具。几十年后,许多数据管理用例仍然由关系数据主导——例如,商业分析(参见["星型和雪花型:分析模式"](/ch3#sec_datamodels_analytics))。
关系模型最初是一个理论提议,当时许多人怀疑它是否能够高效实现。然而,到 20 世纪 80 年代中期关系数据库管理系统RDBMS和 SQL 已成为大多数需要存储和查询具有某种规则结构的数据的人的首选工具。许多数据管理用例在几十年后仍然由关系数据主导 —— 例如,商业分析(参见 ["星型与雪花型:分析模式"](/ch3#sec_datamodels_analytics))。
多年来,有许多竞争性的数据存储和查询方法。在 20 世纪 70 年代和 80 年代初,**网络模型** 和 **层次模型** 是主要的替代方案,但关系模型最终占据了主导地位。对象数据库在 20 世纪 80 年代末和 90 年代初出现又消失。XML 数据库出现在 2000 年代初,但只在小众领域被采用。关系模型的每个竞争者在其时代都产生了大量的炒作,但从未持续 [^4]。相反SQL 已经发展到在其关系核心之外包含其他数据类型——例如,增加了对 XML、JSON 和图数据的支持 [^5]。
多年来,出现了许多与数据存储和查询相关的竞争方法。在 20 世纪 70 年代和 80 年代初,*网状模型* 和 *层次模型* 是主要的替代方案,但关系模型最终战胜了它们。对象数据库在 20 世纪 80 年代末和 90 年代初出现又消失。XML 数据库在 21 世纪初出现,但只获得了小众的采用。每个关系模型的竞争者在其时代都产生了大量的炒作,但都没有持续下去 [^4]。相反SQL 已经发展到在其关系核心之外纳入其他数据类型 —— 例如,增加了对 XML、JSON 和图数据的支持 [^5]。
在 2010 年代,**NoSQL** 是试图推翻关系数据库主导地位的最新流行。NoSQL 指的不是单一技术,而是围绕新数据模型、模式灵活性、可扩展性以及向开源许可模式转变的一系列松散的想法。一些数据库将自己标榜为 **NewSQL**,因为它们旨在提供 NoSQL 系统的可扩展性以及传统关系数据库的数据模型和事务保证。NoSQL 和 NewSQL 的想法在数据系统设计中非常有影响力,但随着这些原则被广泛采用,这些术语的使用已经淡化
在 2010 年代,*NoSQL* 是试图推翻关系数据库主导地位的最新流行。NoSQL 指的不是单一技术,而是围绕新数据模型、模式灵活性、可扩展性以及向开源许可模式转变的一系列松散的想法。一些数据库将自己标榜为 *NewSQL*,因为它们旨在提供 NoSQL 系统的可扩展性以及传统关系数据库的数据模型和事务保证。NoSQL 和 NewSQL 的想法在数据系统设计中产生了很大的影响,但随着这些原则被广泛采用,这些术语的使用已经减少
NoSQL 运动的一个持久影响是 **文档模型** 的普及,它通常将数据表示为 JSON。这个模型最初由专门的文档数据库如 MongoDB 和 Couchbase推广尽管大多数关系数据库现在也添加了 JSON 支持。与关系表相比(关系表通常被视为具有刚性和不灵活的模式)JSON 文档被认为更加灵活。
NoSQL 运动的一个持久影响是 *文档模型* 的流行,它通常将数据表示为 JSON。这个模型最初由专门的文档数据库如 MongoDB 和 Couchbase推广尽管大多数关系数据库现在也增加了 JSON 支持。与通常被视为具有严格和不灵活模式的关系表相比JSON 文档被认为更加灵活。
文档和关系数据的优缺点已经被广泛讨论;让我们来研究一下这场辩论的一些关键要点。
文档和关系数据的优缺点已经被广泛讨论;让我们来看看该辩论的一些关键点。
### 对象关系不匹配 {#sec_datamodels_document}
今天的大部分应用程序开发都是用面向对象的编程语言完成的,这导致了对 SQL 数据模型的一个常见批评:如果数据存储在关系表中,则需要在应用程序代码中的对象和数据库的表、行、列模型之间建立一个笨拙的转换层。模型之间的这种脱节有时被称为 **阻抗不匹配**。
如今,大部分应用程序开发都是使用面向对象的编程语言完成的,这导致了对 SQL 数据模型的常见批评:如果数据存储在关系表中,则需要在应用程序代码中的对象和数据库的表、行、列模型之间建立一个笨拙的转换层。这种模型之间的脱节有时被称为 *阻抗不匹配*。
--------
> [!NOTE]
> **阻抗不匹配** 这个术语借用自电子学。每个电路的输入和输出都有一定的阻抗(对交流电的阻力)。当你将一个电路的输出连接到另一个电路的输入时,如果两个电路的输出和输入阻抗匹配,则连接的功率传输将最大化。阻抗不匹配可能导致信号反射和其他问题。
> 术语 *阻抗不匹配*自电子学。每个电路的输入和输出都有一定的阻抗(对交流电的阻力)。当你将一个电路的输出连接到另一个电路的输入时,如果两个电路的输出和输入阻抗匹配,则通过连接的功率传输将最大化。阻抗不匹配可能导致信号反射和其他问题。
--------
#### 对象关系映射ORM {#object-relational-mapping-orm}
像 ActiveRecord 和 Hibernate 这样的对象关系映射ORM框架减少了这个转换层所需的样板代码量但它们经常受到批评 [^6]。一些常被提到的问题是
对象关系映射ORM框架如 ActiveRecord 和 Hibernate减少了这个转换层所需的样板代码量但它们经常受到批评 [^6]。一些常见的问题包括
* ORM 很复杂,不能完全隐藏两个模型之间的差异,因此开发人员最终仍然需要考虑数据的关系表示和对象表示。
* ORM 通常只用于 OLTP 应用程序开发(参见["描述事务处理和分析"](/ch1#sec_introduction_oltp));为分析目的提供数据的数据工程师仍然需要使用底层的关系表示,因此在使用 ORM 时关系模式的设计仍然很重要。
* 许多 ORM 适用于关系型 OLTP 数据库。拥有多样化数据系统(如搜索引擎、图数据库和 NoSQL 系统)的组织可能会发现 ORM 支持不足。
* 一些 ORM 会自动生成关系模式,但这些模式对于直接访问关系数据的用户来说可能很别扭,而且在底层数据库上可能效率低下。自定义 ORM 的模式和查询生成可能很复杂,并且会抵消使用 ORM 的好处。
* ORM 使得很容易意外地编写低效的查询,例如 **N+1 查询问题** [^7]。例如,假设你想在页面上显示用户评论列表,所以你执行一个查询返回 *N* 条评论,每条评论包含其作者的 ID。要显示评论作者的姓名你需要在 users 表中查找 ID。在手写 SQL 中,你可能会在查询中执行这个连接,并将作者姓名与每条评论一起返回,但使用 ORM 时,你可能最终会为每条 *N* 条评论在 users 表上进行单独的查询来查找其作者,总共产生 *N*+1 个数据库查询,这比在数据库中执行连接要慢。为了避免这个问题,你可能需要告诉 ORM 在获取评论的同时获取作者信息。
* ORM 很复杂,无法完全隐藏两种模型之间的差异,因此开发人员仍然需要考虑数据的关系和对象表示。
* ORM 通常仅用于 OLTP 应用程序开发(参见 ["表征事务处理和分析"](/ch1#sec_introduction_oltp));为分析目的提供数据的数据工程师仍然需要使用底层的关系表示,因此在使用 ORM 时关系模式的设计仍然很重要。
* 许多 ORM 适用于关系型 OLTP 数据库。拥有多样化数据系统(如搜索引擎、图数据库和 NoSQL 系统)的组织可能会发现 ORM 支持不足。
* 一些 ORM 会自动生成关系模式,但这些模式对于直接访问关系数据的用户来说可能很尴尬,并且在底层数据库上可能效率低下。自定义 ORM 的模式和查询生成可能很复杂,并否定了首先使用 ORM 的好处。
* ORM 使得意外编写低效查询变得容易,例如 *N+1 查询问题* [^7]。例如,假设你想在页面上显示用户评论列表,因此你执行一个返回 *N* 条评论的查询,每条评论都包含其作者的 ID。要显示评论作者的姓名你需要在用户表中查找 ID。在手写 SQL 中,你可能会在查询中执行此连接并返回每个评论的作者姓名,但使用 ORM 时,你可能最终会为 *N* 条评论中的每一条在用户表上进行单独的查询以查找其作者,总共产生 *N*+1 个数据库查询,这比在数据库中执行连接要慢。为了避免这个问题,你可能需要告诉 ORM 在获取评论的同时获取作者信息。
尽管如此ORM 也有优势:
然而ORM 也有优势:
* 对于非常适合关系模型的数据,持久关系表示和内存对象表示之间的某种转换是不可避免的ORM 减少了这种转换所需的样板代码量。复杂的查询可能仍然需要在 ORM 之外处理,但 ORM 可以帮助处理简单和重复的情况。
* 一些 ORM 帮助缓存数据库查询的结果,这可以帮助减少数据库的负载。
* 对于非常适合关系模型的数据持久关系和内存对象表示之间的某种转换是不可避免的ORM 减少了这种转换所需的样板代码量。复杂的查询可能仍然需要在 ORM 之外处理,但 ORM 可以帮助处理简单和重复的情况。
* 一些 ORM 有助于缓存数据库查询的结果,这可以帮助减少数据库的负载。
* ORM 还可以帮助管理模式迁移和其他管理活动。
#### 用于一对多关系的文档数据模型 {#the-document-data-model-for-one-to-many-relationships}
并非所有数据都适合关系表示;让我们看一个例子来探讨关系模型的局限性。[图 3-1](/ch3#fig_obama_relational) 说明了如何在关系模式中表达简历LinkedIn 个人资料)。整个资料可以通过唯一标识符 `user_id` 来识别。像 `first_name``last_name` 这样的字段每个用户只出现一次,因此可以将它们建模为 `users` 表上的列。
并非所有数据都很适合关系表示;让我们通过一个例子来探讨关系模型的局限性。[图 3-1](/ch3#fig_obama_relational) 说明了如何在关系模式中表达简历LinkedIn 个人资料)。整个个人资料可以通过唯一标识符 `user_id` 来识别。像 `first_name``last_name` 这样的字段每个用户只出现一次,因此它们可以建模为 `users` 表上的列。
大多数人在职业生涯中有多份工作(职位),人们可能有不同数量的教育经历和任意数量的联系信息。表示这种 **一对多关系** 的一种方法是将职位、教育和联系信息放在单独的表中,并引用 `users`的外键,如[图 3-1](/ch3#fig_obama_relational) 所示。
大多数人在职业生涯中有多份工作(职位),人们可能有不同数量的教育经历和任意数量的联系信息。表示这种 *一对多关系* 的一种方法是将职位、教育和联系信息放在单独的表中,并使用外键引用 `users` 表,如 [图 3-1](/ch3#fig_obama_relational) 所示。
{{< figure src="/fig/ddia_0301.png" id="fig_obama_relational" caption="图 3-1. 使用关系模式表示 LinkedIn 个人资料。" class="w-full my-4" >}}
表示相同信息的另一种方式(这可能更自然,更贴近应用程序代码中的对象结构)是作为 JSON 文档,如[示例 3-1](/ch3#fig_obama_json) 所示。
另一种表示相同信息的方式,可能更自然并且更接近应用程序代码中的对象结构,是作为 JSON 文档,如 [示例 3-1](/ch3#fig_obama_json) 所示。
{{< figure id="fig_obama_json" title="示例 3-1. 将 LinkedIn 个人资料表示为 JSON 文档" class="w-full my-4" >}}
@ -111,38 +111,38 @@ NoSQL 运动的一个持久影响是 **文档模型** 的普及,它通常将
}
```
一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。然而,正如我们将在[第 5 章](/ch5#ch_encoding)中看到的JSON 作为数据编码格式也存在问题。缺乏模式通常被认为是一个优势;我们将在["文档模型中的模式灵活性"](/ch3#sec_datamodels_schema_flexibility)中讨论这一点
一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。然而,正如我们将在 [第 5 章](/ch5#ch_encoding) 中看到的JSON 作为数据编码格式也存在问题。缺乏模式通常被认为是一个优势;我们将在 ["文档模型中的模式灵活性"](/ch3#sec_datamodels_schema_flexibility) 中讨论这个问题
与[图 3-1](/ch3#fig_obama_relational) 中的多表模式相比JSON 表示具有更好的 **局部性**(参见["读写的数据局部性"](/ch3#sec_datamodels_document_locality))。如果你想在关系示例中获取个人资料,你需要执行多个查询(按 `user_id` 查询每个表)或在 `users` 表及其从属表之间执行混乱的多路连接 [^8]。在 JSON 表示中,所有相关信息都在一个地方,使查询既更快又更简单。
[图 3-1](/ch3#fig_obama_relational) 中的多表模式相比JSON 表示具有更好的 *局部性*(参见 ["读写的数据局部性"](/ch3#sec_datamodels_document_locality))。如果你想在关系示例中获取个人资料,你需要执行多个查询(通过 `user_id` 查询每个表)或在 `users` 表与其从属表之间执行混乱的多路连接 [^8]。在 JSON 表示中,所有相关信息都在一个地方,使查询既更快又更简单。
从用户个人资料到用户职位、教育历史和联系信息的一对多关系意味着数据中存在树形结构JSON 表示使这种树形结构明确(参见[图 3-2](/ch3#fig_json_tree))。
从用户个人资料到用户职位、教育历史和联系信息的一对多关系暗示了数据中的树形结构,而 JSON 表示使这种树形结构变得明确(见 [图 3-2](/ch3#fig_json_tree))。
{{< figure src="/fig/ddia_0302.png" id="fig_json_tree" caption="图 3-2. 形成树形结构的一对多关系。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0302.png" id="fig_json_tree" caption="图 3-2. 一对多关系形成树状结构。" class="w-full my-4" >}}
--------
> [!NOTE]
> 这种关系有时被称为 **一对少** 而不是 **一对多**,因为简历通常只有少量的职位 [^9] [^10]。在可能有大量相关项的情况下——比如名人社交媒体帖子上的评论,可能有数千条——将它们全部嵌入同一文档中可能太笨拙,因此[图 3-1](/ch3#fig_obama_relational) 中的关系方法更可取。
> 这种类型的关系有时被称为 *一对少* 而不是 *一对多*,因为简历通常有少量的职位 [^9] [^10]。在可能存在真正大量相关项目的情况下 —— 比如名人社交媒体帖子上的评论,可能有成千上万条 —— 将它们全部嵌入同一个文档中可能太笨拙了,因此 [图 3-1](/ch3#fig_obama_relational) 中的关系方法更可取。
--------
### 规范化、反规范化与连接 {#sec_datamodels_normalization}
在前一节的[示例 3-1](/ch3#fig_obama_json) 中,`region_id` 被给出为 ID而不是纯文本字符串 `"Washington, DC, United States"`。为什么?
在前一节的 [示例 3-1](/ch3#fig_obama_json) 中,`region_id` 被给出为 ID而不是纯文本字符串 `"Washington, DC, United States"`。为什么?
如果用户界面有一个用于输入地区的自由文本字段,将其存储为纯文本字符串是有意义的。但是,拥有标准化的地理区域列表并让用户从下拉列表或自动完成中选择有优势
如果用户界面有一个用于输入地区的自由文本字段,将其存储为纯文本字符串是有意义的。但是,拥有标准化的地理区域列表并让用户从下拉列表或自动完成中选择有优势:
* 跨个人资料的风格和拼写一致
* 避免歧义(如果有多个同名的地方)(如果字符串只是 "Washington",它是指 DC 还是州?)
* 易于更新——名称只存储在一个地方,因此如果需要更改(例如,由于政治事件导致的城市名称更改),很容易全面更新
* 本地化支持——当网站被翻译成其他语言时,标准化列表可以被本地化,因此可以用查看者的语言显示地区
* 更好的搜索——例如,搜索美国东海岸的人可以匹配这个个人资料,因为区域列表可以编码华盛顿位于东海岸的事实(这从字符串 `"Washington, DC"` 中并不明显)
* 跨个人资料的风格和拼写一致
* 避免歧义,如果有几个同名的地方(如果字符串只是 "Washington",它是指 DC 还是州?)
* 易于更新 —— 名称只存储在一个地方,因此如果需要更改(例如,由于政治事件而更改城市名称),可以轻松地全面更新
* 本地化支持 —— 当网站被翻译成其他语言时,标准化列表可以被本地化,因此区域可以用查看者的语言显示
* 更好的搜索 —— 例如,搜索美国东海岸的人可以匹配此个人资料,因为区域列表可以编码华盛顿位于东海岸的事实(这从字符串 `"Washington, DC"` 中并不明显)
是存储 ID 还是文本字符串是 **规范化** 的问题。当你使用 ID 时,你的数据更加规范化:对人类有意义的信息(如文本 *Washington, DC*)只存储在一个地方,所有引用它的地方都使用 ID它只在数据库有意义)。当你直接存储文本时,你在每个使用它的记录中复制了对人类有意义的信息;这种表示是 **反规范化** 的。
无论你存储 ID 还是文本字符串,这都是 *规范化* 的问题。当你使用 ID 时,你的数据更加规范化:对人类有意义的信息(如文本 *Washington, DC*)只存储在一个地方,所有引用它的地方都使用 ID它只在数据库有意义)。当你直接存储文本时,你在使用它的每条记录中复制了对人类有意义的信息;这种表示是 *反规范化* 的。
使用 ID 的优势在于因为它对人类没有意义所以永远不需要更改即使它标识的信息发生变化ID 也可以保持不变。任何对人类有意义的东西在未来的某个时候可能需要更改——如果该信息被复制,所有冗余副本都需要更新。这需要更多的代码、更多的写操作、更多的磁盘空间,并且存在不一致的风险(某些信息副本被更新但其他副本没有)。
使用 ID 的优势在于因为它对人类没有意义所以永远不需要更改即使它标识的信息发生变化ID 也可以保持不变。任何对人类有意义的东西将来某个时候可能需要更改 —— 如果该信息被复制,所有冗余副本都需要更新。这需要更多的代码、更多的写操作、更多的磁盘空间,并且存在不一致的风险(其中一些信息副本被更新但其他的没有)。
规范化表示的缺点是,每次你想显示包含 ID 的记录时,都必须执行额外的查找以将 ID 解析为人类可读的内容。在关系数据模型中,这是使用 **连接** 完成的,例如:
规范化表示的缺点是,每次要显示包含 ID 的记录时,都必须进行额外的查找以将 ID 解析为人类可读的内容。在关系数据模型中,这是使用 *连接* 完成的,例如:
```sql
SELECT users.*, regions.region_name
@ -151,7 +151,7 @@ SELECT users.*, regions.region_name
WHERE users.id = 251;
```
文档数据库可以存储规范化和反规范化的数据,但它们通常与反规范化相关联——部分是因为 JSON 数据模型使得存储额外的反规范化字段变得容易,部分是因为许多文档数据库对连接的支持较弱,使得规范化不方便。一些文档数据库根本不支持连接,因此你必须在应用程序代码中执行它们——也就是说,你首先获取包含 ID 的文档,然后执行第二个查询将该 ID 解析为另一个文档。在 MongoDB 中,也可以使用聚合管道中的 `$lookup` 操作符执行连接:
文档数据库可以存储规范化和反规范化的数据,但它们通常与反规范化相关联 —— 部分是因为 JSON 数据模型使得存储额外的反规范化字段变得容易,部分是因为许多文档数据库对连接的支持使得规范化不方便。一些文档数据库根本不支持连接,因此你必须在应用程序代码中执行它们 —— 也就是说,你首先获取包含 ID 的文档,然后执行第二个查询将该 ID 解析为另一个文档。在 MongoDB 中,也可以使用聚合管道中的 `$lookup` 运算符执行连接:
```mongodb-json
db.users.aggregate([
@ -167,24 +167,24 @@ db.users.aggregate([
#### 规范化的权衡 {#trade-offs-of-normalization}
在简历示例中,虽然 `region_id` 字段是对标准化区域集的引用,但 `organization`(人工作的公司或政府)的名称`school_name`(他们学习的地方)只是字符串。这种表示是反规范化的:许多人可能在同一家公司工作过,但没有 ID 将他们联系起来。
在简历示例中,虽然 `region_id` 字段是对标准化区域集的引用,但 `organization`(人工作的公司或政府)和 `school_name`(他们学习的地方)的名称只是字符串。这种表示是反规范化的:许多人可能在同一家公司工作过,但没有 ID 将他们联系起来。
也许组织和学校应该是实体,个人资料应该引用它们的 ID 而不是它们的名称?引用区域 ID 的相同论点也适用于此。例如,假设我们想要除了名称之外还包括学校或公司的徽标
也许组织和学校应该是实体,个人资料应该引用它们的 ID 而不是它们的名称?引用区域 ID 的相同论点也适用于此。例如,假设我们想在他们的名字之外包括学校或公司的标志
* 在反规范化表示中,我们会在每个人的个人资料中包含标的图像 URL这使得 JSON 文档自包含,但如果我们需要更改徽标,就会很头疼,因为我们现在需要找到旧 URL 的所有出现并更新它们 [^9]。
* 在规范化表示中,我们将创建一个代表组织或学校的实体,并在该实体上存储其名称、标 URL 以及可能的其他属性(描述、新闻源等)一次。每个提到该组织的简历都只需引用其 ID更新徽标很容易。
* 在反规范化表示中,我们会在每个人的个人资料中包含标的图像 URL这使得 JSON 文档自包含,但如果我们需要更改标志,就会产生麻烦,因为我们现在需要找到旧 URL 的所有出现并更新它们 [^9]。
* 在规范化表示中,我们将创建一个代表组织或学校的实体,并在该实体上存储其名称、标 URL 以及可能的其他属性(描述、新闻提要等)一次。然后,每个提到该组织的简历都会简单地引用其 ID更新标志很容易。
作为一般原则,规范化数据通常写入更快(因为只有一个副本),但查询更慢(因为需要连接);反规范化数据通常读取更快(连接更少),但写入更昂贵(更多副本要更新,使用更多磁盘空间)。你可能会发现将反规范化视为派生数据的一种形式很有帮助(["记录系统和派生数据"](/ch1#sec_introduction_derived)),因为你需要设置一个过程来更新数据的冗余副本。
作为一般原则,规范化数据通常写入更快(因为只有一个副本),但查询更慢(因为需要连接);反规范化数据通常读取更快(连接更少),但写入更昂贵(更多副本要更新,使用更多磁盘空间)。你可能会发现将反规范化视为衍生数据的一种形式很有帮助(["记录系统和衍生数据"](/ch1#sec_introduction_derived)),因为你需要设置一个过程来更新数据的冗余副本。
除了执行所有这些更新的成本之外,你还需要考虑如果进程在进行更新的中崩溃,数据库的一致性。提供原子事务的数据库(参见["原子性"](/ch8#sec_transactions_acid_atomicity))使保持一致性变得更容易,但并非所有数据库都提供跨多个文档的原子性。也可以通过流处理来确保一致性,我们将在 [链接待定] 中讨论。
除了执行所有这些更新的成本之外,如果进程在进行更新的过程中崩溃,你还需要考虑数据库的一致性。提供原子事务的数据库(参见 ["原子性"](/ch8#sec_transactions_acid_atomicity))使保持一致性变得更容易,但并非所有数据库都在多个文档之间提供原子性。通过流处理确保一致性也是可能的,我们将在 [待补充链接] 中讨论。
规范化往往更适合 OLTP 系统,其中读取和更新都需要快速;分析系统通常使用反规范化数据表现更好,因为它们批量执行更新,只读查询的性能是主要关注点。此外,在中小规模的系统中,规范化数据模型通常是最好的,因为你不必担心保持数据的多个副本彼此一致,执行连接的成本是可以接受的。然而,在非常大规模的系统中,连接的成本可能会成为问题。
规范化往往更适合 OLTP 系统,其中读取和更新都需要快速;分析系统通常使用反规范化数据表现更好,因为它们批量执行更新,只读查询的性能是主要关注点。此外,在中小规模的系统中,规范化数据模型通常是最好的,因为你不必担心保持数据的多个副本相互一致,执行连接的成本是可以接受的。然而,在非常大规模的系统中,连接的成本可能会成为问题。
#### 社交网络案例研究中的反规范化 {#denormalization-in-the-social-networking-case-study}
在["案例研究:社交网络主页时间线"](/ch2#sec_introduction_twitter)中,我们比较了规范化表示([图 2-1](/ch2#fig_twitter_relational))和反规范化表示(预计算、物化的时间线):这里,`posts` 和 `follows` 之间的连接太昂贵了,物化时间线是该连接结果的缓存。将新帖子插入关注者时间线的扇出过程是我们保持反规范化表示一致的方式。
["案例研究:社交网络主页时间线"](/ch2#sec_introduction_twitter) 中,我们比较了规范化表示([图 2-1](/ch2#fig_twitter_relational))和反规范化表示(预计算的物化时间线):这里,`posts` 和 `follows` 之间的连接太昂贵了,物化时间线是该连接结果的缓存。将新帖子插入关注者时间线的扇出过程是我们保持反规范化表示一致的方式。
然而X前 Twitter的物化时间线实现实际上并不存储每个帖子的实际文本每个条目实际上只存储帖子 ID、发布它的用户的 ID以及一些额外的信息来识别转发和回复 [^11]。换句话说,它是(大约)以下查询的预计算结果:
然而X前 Twitter的物化时间线实现实际上并不存储每个帖子的实际文本每个条目实际上只存储帖子 ID、发布者的用户 ID以及一些额外的信息来识别转发和回复 [^11]。换句话说,它是(大约)以下查询的预计算结果:
```sql
SELECT posts.id, posts.sender_id
@ -195,23 +195,23 @@ SELECT posts.id, posts.sender_id
LIMIT 1000
```
这意味着每当读取时间线时,服务仍然需要执行两个连接:通过 ID 查找帖子以获取实际的帖子内容(以及喜欢和回复数等统计信息),并通过 ID 查找发送者的个人资料(获取他们的用户名、头像和其他详细信息)。这个通过 ID 查找人类可读信息的过程称为 **水合**hydratingID它本质上是在应用程序代码中执行的连接 [^11]。
这意味着每当读取时间线时,服务仍然需要执行两个连接:通过 ID 查找帖子以获取实际的帖子内容(以及点赞数和回复数等统计信息),并通过 ID 查找发送者的个人资料(以获取他们的用户名、个人资料图片和其他详细信息)。这个通过 ID 查找人类可读信息的过程称为 *hydrating* ID它本质上是在应用程序代码中执行的连接 [^11]。
在预计算时间线中存储 ID 的原因是它们引用的数据变化很快:热门帖子的点赞和回复数可能每秒变化多次,一些用户经常更改他们的用户名或头像。由于时间线在查看时应该显示最新的点赞数和头像,因此将这些信息反规范化到物化时间线中是没有意义的。此外,这种反规范化会显著增加存储成本。
在预计算时间线中存储 ID 的原因是它们引用的数据变化很快:热门帖子的点赞和回复数可能每秒变化多次,一些用户定期更改他们的用户名或个人资料照片。由于时间线在查看时应该显示最新的点赞数和个人资料图片,因此将此信息反规范化到物化时间线中是没有意义的。此外,这种反规范化会显著增加存储成本。
这个例子表明,在读取数据时必须执行连接并不像有时声称的那样,是创建高性能、可扩展服务的障碍。水合帖子 ID 和用户 ID 实际上是一个相当容易扩展的操作,因为它很好地并行化,成本不取决于你关注的账户数或你拥有的关注者数
这个例子表明,在读取数据时必须执行连接并不像有时声称的那样,是创建高性能、可扩展服务的障碍。Hydrating 帖子 ID 和用户 ID 实际上是一个相当容易扩展的操作,因为它可以很好地并行化,并且成本不取决于你关注的帐户数量或你拥有的关注者数量
如果你需要决定是否在应用程序中反规范化某些内容,社交网络案例研究表明选择并不是立即显而易见的:最可扩展的方法可能涉及反规范化一些东西并保持其他东西规范化。你必须仔细考虑信息变化的频率,以及读取和写入的成本(这可能由异常值主导,例如在典型社交网络的情况下具有许多关注/关注者的用户)。规范化和反规范化本质上并不好或坏——它们只是在读取和写入性能以及实现工作量方面的权衡。
如果你需要决定是否在应用程序中反规范化某些内容,社交网络案例研究表明选择并不是立即显而易见的:最可扩展的方法可能涉及反规范化某些内容并保持其他内容规范化。你必须仔细考虑信息更改的频率以及读写成本(这可能由异常值主导,例如在典型社交网络的情况下拥有许多关注/关注者的用户)。规范化和反规范化本质上并不好或坏 —— 它们只是在读写性能以及实施工作量方面的权衡。
### 多对一与多对多关系 {#sec_datamodels_many_to_many}
虽然[图 3-1](/ch3#fig_obama_relational) 中的 `positions``education` 是一对多或一对少关系的例子(一份简历有多个职位,但每个职位只属于一份简历),`region_id` 字段是 **多对一** 关系的例子(许多人住在同一个地区,但我们假设每个人在任何时候只住在一个地区)。
虽然 [图 3-1](/ch3#fig_obama_relational) 中的 `positions``education` 是一对多或一对少关系的例子(一份简历有多个职位,但每个职位只属于一份简历),`region_id` 字段是 *多对一* 关系的例子(许多人住在同一个地区,但我们假设每个人在任何时候只住在一个地区)。
如果我们为组织和学校引入实体,并从简历中通过 ID 引用它们,那么我们也有 **多对多** 关系(一个人为几个组织工作过,一个组织有几个过去或现在的员工)。在关系模型中,这种关系通常表示为 **关联表****连接表**,如[图 3-3](/ch3#fig_datamodels_m2m_rel) 所示:每个职位将一个用户 ID 与一个组织 ID 关联起来。
如果我们为组织和学校引入实体,并通过 ID 从简历中引用它们,那么我们也有 *多对多* 关系(一个人曾为多个组织工作,一个组织有多个过去或现在的员工)。在关系模型中,这种关系通常表示为 *关联表**连接表*,如 [图 3-3](/ch3#fig_datamodels_m2m_rel) 所示:每个职位将一个用户 ID 与一个组织 ID 关联起来。
{{< figure src="/fig/ddia_0303.png" id="fig_datamodels_m2m_rel" caption="图 3-3. 关系模型中的多对多关系。" class="w-full my-4" >}}
多对一和多对多关系不容易适应一个自包含的 JSON 文档;它们更适合规范化表示。在文档模型中,一种可能的表示在[示例 3-2](/ch3#fig_datamodels_m2m_json) 中给出,并在[图 3-4](/ch3#fig_datamodels_many_to_many) 中说明:每个虚线矩形内的数据可以分组到一个文档中,但与组织和学校的链接最好表示为对其他文档的引用。
多对一和多对多关系不容易适应一个自包含的 JSON 文档;它们更适合规范化表示。在文档模型中,一种可能的表示如 [示例 3-2](/ch3#fig_datamodels_m2m_json) 所示,并在 [图 3-4](/ch3#fig_datamodels_many_to_many) 中说明:每个虚线矩形内的数据可以分组到一个文档中,但到组织和学校的链接最好表示为对其他文档的引用。
{{< figure id="fig_datamodels_m2m_json" title="示例 3-2. 通过 ID 引用组织的简历。" class="w-full my-4" >}}
@ -230,68 +230,68 @@ SELECT posts.id, posts.sender_id
{{< figure src="/fig/ddia_0304.png" id="fig_datamodels_many_to_many" caption="图 3-4. 文档模型中的多对多关系:每个虚线框内的数据可以分组到一个文档中。" class="w-full my-4" >}}
多对多关系通常需要在"两个方向"上查询:例如,查找特定人员工作过的所有组织,以及查找在特定组织工作过的所有人员。启用此类查询的一种方法是在两侧存储 ID 引用,即简历包括该人工作过的每个组织的 ID组织文档包括提到该组织的简历的 ID。这种表示是反规范化的因为关系存储在两个地方它们可能彼此不一致。
多对多关系通常需要"双向"查询:例如,找到特定人员工作过的所有组织,以及找到在特定组织工作过的所有人员。启用此类查询的一种方法是在两边都存储 ID 引用,即简历包含该人工作过的每个组织的 ID组织文档包含提到该组织的简历的 ID。这种表示是反规范化的因为关系存储在两个地方可能会相互不一致。
规范化的表示只在一个地方存储关系,并依赖 **二级索引**(我们将在[第 4 章](/ch4#ch_storage)中讨论)来允许在两个方向上有效地查询关系。在[图 3-3](/ch3#fig_datamodels_m2m_rel) 的关系模式中,我们会告诉数据库在 `positions` 表的 `user_id``org_id` 列上创建索引。
规范化表示仅在一个地方存储关系,并依赖 *二级索引*(我们将在 [第 4 章](/ch4#ch_storage) 中讨论)来允许有效地双向查询关系。在 [图 3-3](/ch3#fig_datamodels_m2m_rel) 的关系模式中,我们会告诉数据库在 `positions` 表的 `user_id``org_id` 列上创建索引。
在[示例 3-2](/ch3#fig_datamodels_m2m_json) 的文档模型中,数据库需要索引 `positions` 数组内对象的 `org_id` 字段。许多文档数据库和支持 JSON 的关系数据库能够在文档内的值上创建这样的索引。
[示例 3-2](/ch3#fig_datamodels_m2m_json) 的文档模型中,数据库需要索引 `positions` 数组内对象的 `org_id` 字段。许多文档数据库和具有 JSON 支持的关系数据库能够在文档内的值上创建此类索引。
### 星型与雪花型:分析模式 {#sec_datamodels_analytics}
数据仓库(参见["数据仓库"](/ch1#sec_introduction_dwh))通常是关系型的,数据仓库中表结构有几个广泛使用的约定:**星型模式**、**雪花型模式**、**维度建模** [^12] 和 **一大表**OBT。这些结构针对业务分析师的需求进行了优化。ETL 过程将数据从操作系统转换为此模式。
数据仓库(参见 ["数据仓库"](/ch1#sec_introduction_dwh))通常是关系型的,并且数据仓库中表结构有一些广泛使用的约定:*星型模式*、*雪花模式*、*维度建模* [^12],以及 *一张大表*OBT。这些结构针对业务分析师的需求进行了优化。ETL 过程将来自运营系统的数据转换为此模式。
[图 3-5](/ch3#fig_dwh_schema) 展示了一个可能在杂货零售商数据仓库中找到的星型模式示例。模式的中心是所谓的 **事实表**(在此示例中,它称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买产品)。如果我们分析的是网站流量而不是零售销售,每一行可能代表用户的页面浏览或点击。
[图 3-5](/ch3#fig_dwh_schema) 显示了一个可能在杂货零售商的数据仓库中找到的星型模式示例。模式的中心是所谓的 *事实表*(在此示例中,它称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买产品)。如果我们分析的是网站流量而不是零售销售,每一行可能代表用户的页面查看或点击。
{{< figure src="/fig/ddia_0305.png" id="fig_dwh_schema" caption="图 3-5. 用于数据仓库的星型模式示例。" class="w-full my-4" >}}
通常,事实被捕获为单个事件,因为这允许以后进行最大灵活性的分析。然而,这意味着事实表可能变得非常大。一个大型企业的数据仓库中可能有许多 PB 的交易历史,主要表示为事实表。
通常,事实被捕获为单个事件,因为这允许以后最大的分析灵活性。然而,这意味着事实表可能变得非常大。一个大型企业可能在其数据仓库中有许多 PB 的交易历史,主要表示为事实表。
事实表中的一些列是属性,例如产品的销售价格和从供应商处购买的成本(允许计算利润率)。事实表中的其他列是对其他表的外键引用,称为 **维度表**。由于事实表中的每一行都代表一个事件,维度代表事件的 **谁**、**什么**、**哪里**、**何时**、**如何** 和 **为什么**。
事实表中的一些列是属性,例如产品售出的价格和从供应商那里购买它的成本(允许计算利润率)。事实表中的其他列是对其他表的外键引用,称为 *维度表*。由于事实表中的每一行代表一个事件,维度代表事件的 *谁*、*什么*、*哪里*、*何时*、*如何* 和 *为什么*。
例如,在[图 3-5](/ch3#fig_dwh_schema) 中,其中一个维度是售产品。`dim_product` 表中的每一行代表一种待售产品类型包括其库存单位SKU、描述、品牌名称、类别、脂肪含量、包装大小等。`fact_sales` 表中的每一行使用外键来指示在该特定交易中销售的产品。查询通常涉及对多个维度表的多个连接。
例如,在 [图 3-5](/ch3#fig_dwh_schema) 中,其中一个维度是售出的产品。`dim_product` 表中的每一行代表一种待售产品类型包括其库存单位SKU、描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行使用外键来指示在该特定交易中售出了哪种产品。查询通常涉及对多个维度表的多个连接。
甚至日期和时间通常使用维度表表示,因为这允许编码有关日期的额外信息(例如公共假期),允许查询区分假期和非假期的销售。
即使日期和时间也经常使用维度表表示,因为这允许编码有关日期的附加信息(例如公共假期),允许查询区分假期和非假期的销售。
[图 3-5](/ch3#fig_dwh_schema) 是星型模式的一个例子。该名称来自这样一个事实:当表关系被可视化时,事实表位于中间,被其维度表包围;这些表的连接就像星星的光芒。
[图 3-5](/ch3#fig_dwh_schema) 是星型模式的一个例子。该名称来自这样一个事实:当表关系被可视化时,事实表位于中间,被其维度表包围;这些表的连接就像星星的光芒。
这个模板的一个变体被称为 **雪花型模式**,其中维度进一步分解为子维度。例如,品牌和产品类别可能有单独的表,`dim_product` 表中的每一行可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表中。雪花模式比星型模式更规范化,但星型模式通常更受欢迎,因为它们对分析师来说更简单 [^12]。
这个模板的一个变体被称为 *雪花模式*,其中维度被进一步分解为子维度。例如,品牌和产品类别可能有单独的表,`dim_product` 表中的每一行可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表中。雪花模式比星型模式更规范化,但星型模式通常更受欢迎,因为它们对分析师来说更简单 [^12]。
在典型的数据仓库中,表通常相当宽:事实表通常有 100 多列,有时有几百列。维度表也可能很宽,因为它们包括可能与分析相关的所有元数据——例如,`dim_store` 表可能包括每个商店提供哪些服务的详细信息、是否有店内烘焙店、面积、商店首次开业的日期、最后一次改造的时间、离最近的高速公路有多远等。
在典型的数据仓库中,表通常非常宽:事实表通常有超过 100 列,有时有几百列。维度表也可能很宽,因为它们包括所有可能与分析相关的元数据 —— 例如,`dim_store` 表可能包括每个商店提供哪些服务的详细信息、是否有店内面包房、平方英尺、商店首次开业的日期、最后一次改造的时间、离最近的高速公路有多远等。
星型或雪花模式主要由多对一关系组成(例如,许多销售发生在一个特定产品、一个特定商店),表示为事实表具有指向维度表的外键,或维度指向子维度。原则上,可能存在其他类型的关系,但它们通常被反规范化以简化查询。例如,如果客户一次购买几种不同的产品,则不会明确表示该多项交易;相反,事实表中为每个购买的产品都有一个单独的行,这些事实都恰好具有相同的客户 ID、商店 ID 和时间戳。
星型或雪花模式主要由多对一关系组成(例如,许多销售发生在一个特定产品,在一个特定商店),表示为事实表对维度表的外键,或维度对子维度的外键。原则上,其他类型的关系可能存在,但它们通常被反规范化以简化查询。例如,如果客户一次购买多种不同的产品,则该多项交易不会被明确表示;相反,事实表中为每个购买的产品都有一个单独的行,这些事实都恰好具有相同的客户 ID、商店 ID 和时间戳。
一些数据仓库模式进一步采用反规范化,完全省略维度表,而是将维度中的信息折叠到事实表上的反规范化列中(本质上是预计算事实表和维度表之间的连接)。这种方法被称为 **一大表**OBT虽然它需要更多的存储空间但有时可以实现更快的查询 [^13]。
一些数据仓库模式进一步进行反规范化,完全省略维度表,将维度中的信息折叠到事实表上的反规范化列中(本质上是预计算事实表和维度表之间的连接)。这种方法被称为 *一张大表*OBT虽然它需要更多的存储空间但有时可以实现更快的查询 [^13]。
在分析的上下文中,这种反规范化是没有问题的,因为数据通常代表不会改的历史数据日志除了偶尔纠正错误。OLTP 系统中反规范化出现的数据一致性和写入开销问题在分析中并不那么紧迫。
在分析的背景下,这种反规范化是没有问题的,因为数据通常代表不会改的历史数据日志除了偶尔纠正错误。OLTP 系统中反规范化出现的数据一致性和写入开销问题在分析中并不那么紧迫。
### 何时使用哪种模型 {#sec_datamodels_document_summary}
支持文档数据模型的主要论点是模式灵活性、由于局部性而获得更好的性能,以及对于某些应用程序来说,它更接近应用程序使用的对象模型。关系模型通过提供对连接、多对一和多对多关系的更好支持来反驳。让我们更详细地研究这些论点。
文档数据模型的主要论点是模式灵活性、由于局部性而获得更好的性能,以及对于某些应用程序来说,它更接近应用程序使用的对象模型。关系模型通过为连接、多对一和多对多关系提供更好的支持来反击。让我们更详细地研究这些论点。
如果应用程序中的数据具有类似文档的结构(即一对多关系的树,通常一次加载整个树),那么使用文档模型可能是个好主意。将类似文档的结构 **分解** 为多个表的关系技术(如[图 3-1](/ch3#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能导致繁琐的模式和不必要复杂应用程序代码。
如果你的应用程序中的数据具有类似文档的结构(即一对多关系的树,通常一次加载整个树),那么使用文档模型可能是个好主意。将类似文档的结构 *切碎*shredding为多个表的关系技术如 [图 3-1](/ch3#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能导致繁琐的模式和不必要复杂应用程序代码。
文档模型有局限性:例如,你不能直接引用文档中的嵌套项,而是需要说类似"用户 251 的职位列表中的第二项"。如果你确实需要引用嵌套项,关系方法效果更好,因为你可以通过其 ID 直接引用任何项。
文档模型有局限性:例如,你不能直接引用文档中的嵌套项,而是需要说类似"用户 251 的职位列表中的第二项"之类的话。如果你确实需要引用嵌套项,关系方法效果更好,因为你可以通过其 ID 直接引用任何项。
一些应用程序允许用户选择项目的顺序:例如,想象一个待办事项列表或问题跟踪器,用户可以拖放任务来重新排序它们。文档模型很好地支持这样的应用程序,因为项目(或它们的 ID可以简单地存储在 JSON 数组中以确定它们的顺序。在关系数据库中,没有表示这种可重新排序列表的标准方法,使用了各种技巧:按整数列排序(在中间插入时需要重新编号、ID 的链表或分数索引 [^14] [^15] [^16]。
一些应用程序允许用户选择项目的顺序:例如,想象一个待办事项列表或问题跟踪器,用户可以拖放任务来重新排序它们。文档模型很好地支持此类应用程序,因为项目(或它们的 ID可以简单地存储在 JSON 数组中以确定它们的顺序。在关系数据库中,没有表示此类可重新排序列表的标准方法,并且使用各种技巧:按整数列排序(在插入中间时需要重新编号、ID 的链表或分数索引 [^14] [^15] [^16]。
#### 文档模型中的模式灵活性 {#sec_datamodels_schema_flexibility}
大多数文档数据库和关系数据库中的 JSON 支持不对文档中的数据强制执行任何模式。关系数据库中的 XML 支持通常带有可选的模式验证。没有模式意味着可以向文档添加任意键和值,并且在读取时,客户端不能保证文档可能包含哪些字段。
大多数文档数据库以及关系数据库中的 JSON 支持不会对文档中的数据强制执行任何模式。关系数据库中的 XML 支持通常带有可选的模式验证。没有模式意味着可以将任意键和值添加到文档中,并且在读取时,客户端不能保证文档可能包含哪些字段。
文档数据库有时被称为 **无模式**,但这是误导性的,因为读取数据的代码通常假定某种结构——即存在隐式模式,但数据库不强制执行 [^17]。更准确的术语是 **读时模式**(数据的结构是隐式的,只有在读取数据时才被解释),与 **写时模式**(关系数据库的传统方法,其中模式是显式的,数据库确保在写入数据时所有数据都符合它)形成对比 [^18]。
文档数据库有时被称为 *无模式*,但这是误导性的,因为读取数据的代码通常假设某种结构 —— 即存在隐式模式,但数据库不强制执行 [^17]。更准确的术语是 *读时模式*(数据的结构是隐式的,只有在读取数据时才解释),与 *写时模式*(关系数据库的传统方法,其中模式是显式的,数据库确保所有数据在写入时都符合它)形成对比 [^18]。
读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。正如静态和动态类型检查的倡导者对它们的相对优点有很大的争论 [^19],数据库中模式的执行是一个有争议的话题,通常没有正确或错误的答案。
读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。正如静态和动态类型检查的倡导者对它们的相对优点有很大的争论 [^19],数据库中模式的强制执行是一个有争议的话题,通常没有正确或错误的答案。
当应用程序想要更改其数据格式时,方法之间的差异特别明显。例如,假设你当前在一个字段中存储每个用户的全名,而你希望分别存储名字和姓氏 [^20]。在文档数据库中,你只需开始使用新字段编写新文档,并在应用程序中有代码处理读取旧文档的情况。例如:
当应用程序想要更改其数据格式时,这些方法之间的差异特别明显。例如,假设你当前在一个字段中存储每个用户的全名,而你想要分别存储名字和姓氏 [^20]。在文档数据库中,你只需开始编写具有新字段的新文档,并在应用程序中编写处理读取旧文档时的代码。例如:
```mongodb-json
if (user && user.name && !user.first_name) {
// 2023年12月08日之前编写的文档没有 first_name
// 2023 年 12 月 8 日之前写入的文档没有 first_name
user.first_name = user.name.split(" ")[0];
}
```
这种方法的缺点是,应用程序中从数据库读取的每个部分现在都需要处理可能很久以前写入的旧格式文档。另一方面,在写时模式数据库中,你通常会执行如下的 **迁移**
这种方法的缺点是,从数据库读取的应用程序的每个部分现在都需要处理可能很久以前写入的旧格式文档。另一方面,在写时模式数据库中,你通常会执行 *迁移*,如下所示
```sql
ALTER TABLE users ADD COLUMN first_name text DEFAULT NULL;
@ -301,32 +301,32 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
在大多数关系数据库中,添加具有默认值的列即使在大表上也是快速且无问题的。然而,在大表上运行 `UPDATE` 语句可能会很慢,因为每一行都需要重写,其他模式操作(例如更改列的数据类型)通常也需要复制整个表。
存在各种工具允许在后台执行此类模式更改而停机 [^21] [^22] [^23] [^24],但在大型数据库上执行此类迁移在操作上仍然具有挑战性。通过仅添加具有默认值 `NULL``first_name` 列(这很快)并在读取时填充它,就像你在文档数据库中所做的那样,可以避免复杂的迁移
存在各种工具允许在后台执行此类模式更改而无需停机 [^21] [^22] [^23] [^24],但在大型数据库上执行此类迁移在操作上仍然具有挑战性。通过仅添加默认值 `NULL``first_name` 列(这很快)并在读取时填充它,可以避免复杂的迁移,就像你在文档数据库中所做的那样。
如果集合中的项由于某种原因并非都具有相同的结构(即数据是异构的),读时模式方法是有利的——例如,因为:
如果集合中的项由于某种原因并非都具有相同的结构(即数据是异构的),读时模式方法是有利的 —— 例如,因为:
* 有许多不同类型的对象,将每种类型的对象放在自己的表中是不切实际的。
* 数据的结构由你无法控制且可能随时更改的外部系统定。
* 数据的结构由你无法控制且可能随时更改的外部系统定。
在这样的情况下,模式可能弊大于利,无模式文档可能是更自然的数据模型。但在所有记录都应具有相同结构的情况下,模式是记录和执行该结构的有用机制。我们将在[第 5 章](/ch5#ch_encoding)中更详细地讨论模式和模式演化。
在这样的情况下,模式可能弊大于利,无模式文档可能是更自然的数据模型。但在所有记录都应具有相同结构的情况下,模式是记录和强制该结构的有用机制。我们将在 [第 5 章](/ch5#ch_encoding) 中更详细地讨论模式和模式演化。
#### 读写的数据局部性 {#sec_datamodels_document_locality}
文档通常存储为单个连续字符串,编码为 JSON、XML 或二进制变体(如 MongoDB 的 BSON。如果你的应用程序经常需要访问整个文档例如在网页上呈现它),这种 **存储局部性** 具有性能优势。如果数据分散在多个表中,如[图 3-1](/ch3#fig_obama_relational),则需要多个索引查找来检索所有数据,这可能需要更多的磁盘寻道并花费更多时间。
文档通常存储为单个连续字符串,编码为 JSON、XML 或二进制变体(如 MongoDB 的 BSON。如果你的应用程序经常需要访问整个文档例如在网页上渲染它),则这种 *存储局部性* 具有性能优势。如果数据分布在多个表中,如 [图 3-1](/ch3#fig_obama_relational) 所示,则需要多次索引查找才能检索所有数据,这可能需要更多的磁盘寻道并花费更多时间。
局部性优势仅在你同时需要文档的大部分时才适用。数据库通常需要加载整个文档,如果你只需要访问大文档的一小部分,这可能会浪费。在更新文档时,通常需要重写整个文档。出于这些原因,通常建议你保持文档相当小,并避免对文档进行频繁的小更新。
局部性优势仅在你同时需要文档的大部分时才适用。数据库通常需要加载整个文档,如果你只需要访问大文档的一小部分,这可能会浪费。在文档更新时,通常需要重写整个文档。由于这些原因,通常建议你保持文档相当小,并避免频繁对文档进行小更新。
然而将相关数据存储在一起以获得局部性的想法并不限于文档模型。例如Google 的 Spanner 数据库在关系数据模型中提供相同的局部性属性,允许模式声明表的行应该交错(嵌套)在父表中 [^25]。Oracle 允许相同的功能,使用称为 **多表索引集群表** 的功能 [^26]。由 Google 的 Bigtable 推广并在例如 HBase 和 Accumulo 中使用的 **宽列** 数据模型具有 **列族** 的概念,它具有管理局部性的类似目的 [^27]。
然而将相关数据存储在一起以获得局部性的想法并不限于文档模型。例如Google 的 Spanner 数据库在关系数据模型中提供相同的局部性属性,允许模式声明表的行应该交错(嵌套)在父表中 [^25]。Oracle 允许相同的功能,使用称为 *多表索引集群表* 的功能 [^26]。由 Google 的 Bigtable 推广并在 HBase 和 Accumulo 等中使用的 *宽列* 数据模型具有 *列族* 的概念,其目的类似于管理局部性 [^27]。
#### 文档的查询语言 {#query-languages-for-documents}
关系数据库和文档数据库之间的另一个区别是你用来查询它的语言或 API。大多数关系数据库使用 SQL 查询,但文档数据库更加多样化。一些只允许通过主键进行键值访问,而其他的还提供二级索引来查询文档内的值,有些提供丰富的查询语言。
关系数据库和文档数据库之间的另一个区别是你用来查询它的语言或 API。大多数关系数据库使用 SQL 查询,但文档数据库更加多样化。一些只允许通过主键进行键值访问,而另一些还提供二级索引来查询文档内的值,有些提供丰富的查询语言。
XML 数据库通常使用 XQuery 和 XPath 查询,它们旨在允许复杂查询,包括跨多个文档的连接,并且还将其结果格式化为 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 为 JSON 提供了与 XPath 等效的功能。
XML 数据库通常使用 XQuery 和 XPath 查询,它们旨在允许复杂查询,包括跨多个文档的连接,并将其结果格式化为 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 为 JSON 提供了等效于 XPath 的功能。
MongoDB 的聚合管道(我们在["规范化、反规范化和连接"](/ch3#sec_datamodels_normalization)中看到了其用于连接的 `$lookup` 操作符)是 JSON 文档集合查询语言的一个例子。
MongoDB 的聚合管道,我们在 ["规范化、反规范化与连接"](/ch3#sec_datamodels_normalization) 中看到了其用于连接的 `$lookup` 运算符,是 JSON 文档集合查询语言的一个例子。
让我们看另一个例子来感受这种语言——这次是聚合,这对分析特别需要。想象你是一名海洋生物学家,每次在海洋中看到动物时,你都会向数据库添加观察记录。现在你想生成一份报告,说明你每月看到了多少鲨鱼。在 PostgreSQL 中,你可能会这样表达该查询:
让我们看另一个例子来感受这种语言 —— 这次是聚合,这对分析特别需要。想象你是一名海洋生物学家,每次在海洋中看到动物时,你都会向数据库添加一条观察记录。现在你想生成一份报告,说明你每月看到了多少鲨鱼。在 PostgreSQL 中,你可能会这样表达该查询:
```sql
SELECT date_trunc('month', observation_timestamp) AS observation_month, ❶
@ -338,7 +338,7 @@ GROUP BY observation_month;
❶ : `date_trunc('month', timestamp)` 函数确定包含 `timestamp` 的日历月,并返回表示该月开始的另一个时间戳。换句话说,它将时间戳向下舍入到最近的月份。
此查询首先过滤观察结果以仅显示 `Sharks` 家族中的物种,然后按发生的日历月对观察结果进行分组,最后将该月所有观察中看到的动物数量相加。同样的查询可以使用 MongoDB 的聚合管道表达如下:
此查询首先过滤观察结果以仅显示 `Sharks` 家族中的物种,然后按它们发生的日历月对观察结果进行分组,最后将该月所有观察中看到的动物数量相加。可以使用 MongoDB 的聚合管道表达相同的查询,如下所示
```mongodb-json
db.observations.aggregate([
@ -357,57 +357,58 @@ db.observations.aggregate([
#### 文档和关系数据库的融合 {#convergence-of-document-and-relational-databases}
文档数据库和关系数据库最初是非常不同的数据管理方法,但随着时间的推移,它们变得更加相似 [^31]。关系数据库添加了对 JSON 类型和查询操作符的支持,以及索引文档内属性的能力。一些文档数据库(如 MongoDB、Couchbase 和 RethinkDB加了对连接、二级索引和声明式查询语言的支持。
文档数据库和关系数据库最初是非常不同的数据管理方法,但随着时间的推移,它们变得更加相似 [^31]。关系数据库增加了对 JSON 类型和查询运算符的支持,以及索引文档内属性的能力。一些文档数据库(如 MongoDB、Couchbase 和 RethinkDB加了对连接、二级索引和声明式查询语言的支持。
模型的这种融合对应用程序开发人员来说是个好消息,因为当你可以在同一个数据库中组合两者时,关系模型和文档模型都能发挥最佳效果。许多文档数据库需要对其他文档的关系式引用,许多关系数据库在某些部分中模式灵活性有益的。关系-文档混合是一个强大的组合。
模型的这种融合对应用程序开发人员来说是个好消息,因为当你可以在同一个数据库中组合两者时,关系模型和文档模型效果最好。许多文档数据库需要对其他文档的关系式引用,许多关系数据库在模式灵活性有益的部分。关系-文档混合是一个强大的组合。
--------
> [!NOTE]
> Codd 对关系模型的原始描述 [^3] 实际上允许在关系模式中使用类似 JSON 的东西。他称之为 **非简单域**。这个想法是,行中的值不必只是像数字或字符串这样的原始数据类型,它也可以是嵌套关系(表)——所以你可以将任意嵌套的树结构作为值,就像 30 多年后添加到 SQL 的 JSON 或 XML 支持一样
> Codd 对关系模型的原始描述 [^3] 实际上允许在关系模式中存在类似于 JSON 的东西。他称之为 *非简单域*。这个想法是,行中的值不必只是原始数据类型(如数字或字符串),但它也可以是嵌套关系(表)—— 所以你可以有一个任意嵌套的树结构作为值,很像 30 多年后添加到 SQL 的 JSON 或 XML 支持。
--------
## 图数据模型 {#sec_datamodels_graph}
我们之前看到,关系类型是区分不同数据模型的重要特征。如果你的应用程序主要有一对多关系(树形结构数据)并且记录之间的其他关系很少,文档模型是合适的。
我们之前看到,关系类型是不同数据模型之间的重要区别特征。如果你的应用程序主要有一对多关系(树形结构数据)并且记录之间很少有其他关系,则文档模型是合适的。
但是,如果你的数据中多对多关系非常普遍呢?关系模型可以处理多对多关系的简单情况,但随着数据中的连接变得更加复杂,将数据建模为图变得更加自然。
但是,如果你的数据中多对多关系非常常见呢?关系模型可以处理多对多关系的简单情况,但随着数据内部连接变得更加复杂,开始将数据建模为图变得更加自然。
图由两种对象组成:**顶点**(也称为 **节点****实体**)和 **边**(也称为 **关系****弧**)。许多类型的数据可以建模为图。典型的例子包括:
图由两种对象组成:*顶点*(也称为 *节点**实体*)和 *边*(也称为 *关系**弧*)。许多类型的数据可以建模为图。典型的例子包括:
社交图
: 顶点是人,边表示哪些人彼此认识。
: 顶点是人,边表示哪些人相互认识。
网页图
: 顶点是网页,边表示指向其他页面的 HTML 链接。
道路或铁路网络
: 顶点是交叉路口,边代表它们之间的道路或铁路线。
: 顶点是交叉点,边表示它们之间的道路或铁路线。
众所周知的算法可以在这些图上运行:例如,地图导航应用程序在道路网络中搜索两点之间的最短路径PageRank 可以用于网页图来确定网页的受欢迎程度,从而确定其在搜索结果中的排名 [^32]。
众所周知的算法可以在这些图上运行:例如,地图导航应用程序搜索道路网络中两点之间的最短路径PageRank 可用于网页图以确定网页的受欢迎程度,从而确定其在搜索结果中的排名 [^32]。
图可以用几种不同的方式表示。在 **邻接列表** 模型中,每个顶点存储其一条边之外的邻居顶点的 ID。或者你可以使用 **邻接矩阵**,这是一个二维数组,其中每一行和每一列对应一个顶点,当行顶点和列顶点之间没有边时值为零,如果有边则值为一。邻接表适合图遍历,矩阵适合机器学习(参见["数据框、矩阵和数组"](/ch3#sec_datamodels_dataframes))。
图可以用几种不同的方式表示。在 *邻接表* 模型中,每个顶点存储其相距一条边的邻居顶点的 ID。或者你可以使用 *邻接矩阵*,这是一个二维数组,其中每一行和每一列对应一个顶点,当行顶点和列顶点之间没有边时值为零,如果有边则值为一。邻接表适合图遍历,矩阵适合机器学习(参见 ["数据框、矩阵与数组"](/ch3#sec_datamodels_dataframes))。
在刚才给出的例子中,图中的所有顶点分别代表相同类型的事物(人、网页或道路交叉口)。然而,图不限于这种 **同质** 数据:图的一个同样强大的用途是提供一种一致的方式在单个数据库中存储完全不同类型的对象。例如:
在刚才给出的示例中,图中的所有顶点都表示相同类型的事物(分别是人、网页或道路交叉点)。然而,图不限于这种 *同质* 数据:图的一个同样强大的用途是提供一种一致的方式在单个数据库中存储完全不同类型的对象。例如:
* Facebook 维护一个包含许多不同类型顶点和边的单一图:顶点表人、位置、事件、签到和用户发表的评论;边表示哪些人是彼此的朋友、哪个签到发生在哪个位置、谁评论了哪个帖子、谁参加了哪个事件等 [^33]。
* 知识图被搜索引擎用来记录搜索查询中经常出现的实体的事实,如组织、人员和地点 [^34]。这些信息通过爬取和分析网站上的文本获得;一些网站,如 Wikidata也以结构化形式发布图数据。
* Facebook 维护一个包含许多不同类型顶点和边的单一图:顶点表、位置、事件、签到和用户发表的评论;边表示哪些人彼此是朋友、哪个签到发生在哪个位置、谁评论了哪个帖子、谁参加了哪个事件等 [^33]。
* 知识图被搜索引擎用来记录搜索查询中经常出现的实体(如组织、人员和地点)的事实 [^34]。这些信息通过爬取和分析网站上的文本获得;一些网站(如 Wikidata也以结构化形式发布图数据。
有几种不同但相关的方式来构建和查询图中的数据。在本节中,我们将讨论 **属性图** 模型(由 Neo4j、Memgraph、KùzuDB [^35] 等实现)和 **三元组存储** 模型(由 Datomic、AllegroGraph、Blazegraph 等实现)。这些模型在它们可以表达的内容上相当相似,一些图数据库(如 Amazon Neptune支持两种模型。
在图中构建和查询数据有几种不同但相关的方式。在本节中,我们将讨论 *属性图* 模型(由 Neo4j、Memgraph、KùzuDB [^35] 和其他 [^36] 实现)和 *三元组存储* 模型(由 Datomic、AllegroGraph、Blazegraph 和其他实现)。这些模型在它们可以表达的内容方面相当相似,一些图数据库(如 Amazon Neptune支持两种模型。
我们还将研究四种图查询语言Cypher、SPARQL、Datalog 和 GraphQL以及 SQL 对查询图的支持。还存在其他图查询语言,如 Gremlin [^37],但这些将给我们一个代表性的概述。
我们还将查看图的四种查询语言Cypher、SPARQL、Datalog 和 GraphQL以及用于查询图的 SQL 支持。还存在其他图查询语言,如 Gremlin [^37],但这些将为我们提供代表性的概述。
为了说明这些不同的语言和模型,本节使用[图 3-6](/ch3#fig_datamodels_graph) 中显示的图作为运行示例。它可以取自社交网络或家谱数据库:它显示了两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已婚并住在伦敦。每个人和每个位置都表示为顶点它们之间的关系表示为边。这个例子将帮助演示一些在图数据库中很容易但在其他模型中很困难的查询。
为了说明这些不同的语言和模型,本节使用 [图 3-6](/ch3#fig_datamodels_graph) 中显示的图作为运行示例。它可能取自社交网络或家谱数据库:它显示了两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已婚并住在伦敦。每个人和每个位置都表示为顶点它们之间的关系表示为边。此示例将帮助演示一些在图数据库中很容易但在其他模型中很困难的查询。
{{< figure src="/fig/ddia_0306.png" id="fig_datamodels_graph" caption="图 3-6. 图结构数据示例(框表示顶点,箭头表示边)。" class="w-full my-4" >}}
### 属性图 {#id56}
**属性图**(也称为 **标记属性图**)模型中,每个顶点包含:
*属性图*(也称为 *标记属性图*)模型中,每个顶点包含:
* 唯一标识符
* 标签(字符串)来描述此顶点代表什么类型的对象
* 标签(字符串),描述此顶点表示的对象类型
* 一组出边
* 一组入边
* 属性集合(键值对)
@ -415,12 +416,12 @@ db.observations.aggregate([
每条边包含:
* 唯一标识符
* 边开始的顶点(**尾顶点**
* 边结束的顶点(**头顶点**
* 边开始的顶点(*尾顶点*
* 边结束的顶点(*头顶点*
* 描述两个顶点之间关系类型的标签
* 属性集合(键值对)
你可以将图存储视为由两个关系表组成,一个用于顶点,一个用于边,如[示例 3-3](/ch3#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 数据类型来存储每个顶点或边的属性)。每条边都存储头顶点和尾顶点;如果你想要顶点的入边或出边集,可以分别通过 `head_vertex``tail_vertex` 查询 `edges` 表。
你可以将图存储视为由两个关系表组成,一个用于顶点,一个用于边,如 [示例 3-3](/ch3#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 数据类型来存储每个顶点或边的属性)。每条边都存储头顶点和尾顶点;如果你想要顶点的入边或出边集,可以分别通过 `head_vertex``tail_vertex` 查询 `edges` 表。
{{< figure id="fig_graph_sql_schema" title="示例 3-3. 使用关系模式表示属性图" class="w-full my-4" >}}
@ -443,30 +444,30 @@ CREATE INDEX edges_tails ON edges (tail_vertex);
CREATE INDEX edges_heads ON edges (head_vertex);
```
这个模型的一些重要方面是:
模型的一些重要方面是:
1. 任何顶点都可以有一条边将其与任何其他顶点连接。没有模式限制哪些事物可以或不能关联。
2. 给定任何顶点,你可以有效地找到其入边和出边,从而 **遍历** 图——即通过顶点链跟随路径——向前和向后。(这就是为什么[示例 3-3](/ch3#fig_graph_sql_schema) 在 `tail_vertex``head_vertex` 列上都有索引。)
3. 通过使用不同的标签来表示不同类型的顶点和关系,你可以在单个图中存储几种不同类型的信息,同时仍然保持干净的数据模型。
1. 任何顶点都可以有一条边将其与任何其他顶点连接。没有限制哪些类型的事物可以或不能关联的模式
2. 给定任何顶点,你可以有效地找到其入边和出边,从而 *遍历* 图 —— 即通过顶点链跟随路径 —— 向前和向后。(这就是为什么 [示例 3-3](/ch3#fig_graph_sql_schema) 在 `tail_vertex``head_vertex` 列上都有索引。)
3. 通过对不同类型的顶点和关系使用不同的标签,你可以在单个图中存储几种不同类型的信息,同时仍保持简洁的数据模型。
edges 表就像我们在["多对一和多对多关系"](/ch3#sec_datamodels_many_to_many)中看到的多对多关联表/连接表,推广到允许在同一表中存储许多不同类型的关系。标签和属性上也可能有索引,允许有效地找到具有某些属性的顶点或边。
边表就像我们在 ["多对一与多对多关系"](/ch3#sec_datamodels_many_to_many) 中看到的多对多关联表/连接表,泛化为允许在同一表中存储许多不同类型的关系。标签和属性上也可能有索引,允许有效地找到具有某些属性的顶点或边。
--------
> [!NOTE]
> 图模型的一个局限性是边只能将两个顶点相互关联,而关系连接表可以通过在单行上有多个外键引用来表示三向甚至更高度的关系。这种关系可以通过创建对应于连接表每一行的额外顶点以及与该顶点的边来在图中表示,或者使用 **超图**
> 图模型的一个限制是边只能将两个顶点相互关联,而关系连接表可以通过在单行上具有多个外键引用来表示三元或甚至更高阶的关系。此类关系可以通过为连接表的每一行创建一个额外的顶点,以及到/从该顶点的边,或者使用 *超图* 在图中表示
--------
这些特性为数据建模提供了很大的灵活性,如[图 3-6](/ch3#fig_datamodels_graph) 所示。该图显示了一些在传统关系模式中难以表达的事情,例如不同国家的不同地区结构(法国有 **省****大区**,而美国有 **县****州**)、历史的怪癖如国中之国(暂时忽略主权国家和民族的复杂性),以及数据的不同粒度Lucy 的当前居住地指定为城市,而她的出生地仅在州级别指定)。
这些功能为数据建模提供了极大的灵活性,如 [图 3-6](/ch3#fig_datamodels_graph) 所示。该图显示了一些在传统关系模式中难以表达的内容,例如不同国家的不同区域结构(法国有 *省**大区*,而美国有 *县**州*)、历史的怪癖(如国中之国)(暂时忽略主权国家和民族的复杂性),以及不同粒度的数据Lucy 的当前居住地指定为城市,而她的出生地仅在州级别指定)。
你可以想象扩展图以包括关 Lucy 和 Alain 或其他人的许多其他事实。例如,你可以使用它来表示他们有哪些食物过敏(通过为每种过敏原引入一个顶点,以及人和过敏原之间的边来表示过敏),并将过敏原与一组显示哪些食物含有哪些物质的顶点链接。然后你可以编写查询来找出每个人吃什么是安全的。图有利于可演化性:当你向应用程序添加功能时,图可以轻松扩展以适应应用程序数据结构的变化。
你可以想象扩展图以包括关 Lucy 和 Alain 或其他人的许多其他事实。例如,你可以使用它来指示他们有哪些食物过敏(通过为每个过敏原引入一个顶点,并在人和过敏原之间设置边以指示过敏),并将过敏原与显示哪些食物含有哪些物质的一组顶点链接。然后你可以编写查询来找出每个人可以安全食用的食物。图适合可演化性:随着你向应用程序添加功能,图可以轻松扩展以适应应用程序数据结构的变化。
### Cypher 查询语言 {#id57}
**Cypher** 是属性图的查询语言,最初为 Neo4j 图数据库创建,后来发展为开放标准 **openCypher** [^38]。除了 Neo4jCypher 还得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE在 PostgreSQL 中存储)等的支持。它以电影《黑客帝国》中的角色命名,与密码学中的密码无关 [^39]。
*Cypher* 是用于属性图的查询语言,最初为 Neo4j 图数据库创建,后来作为 *openCypher* 发展为开放标准 [^38]。除了 Neo4jCypher 还得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE在 PostgreSQL 中存储)等的支持。它以电影《黑客帝国》中的角色命名,与密码学中的密码无关 [^39]。
[示例 3-4](/ch3#fig_cypher_create) 显示了将[图 3-6](/ch3#fig_datamodels_graph) 左侧部分插入图数据库的 Cypher 查询。图的其余部分可以类似地添加。每个顶点都被赋予一个`usa``idaho` 这样的符号名称。该名称不存储在数据库中,而仅在查询内部使用以在顶点之间创建边,使用箭头表示法:`(idaho) -[:WITHIN]-> (usa)` 创建一个标记为 `WITHIN` 的边,`idaho` 作为尾节点,`usa` 作为头节点。
[示例 3-4](/ch3#fig_cypher_create) 显示了将 [图 3-6](/ch3#fig_datamodels_graph) 左侧部分插入图数据库的 Cypher 查询。图的其余部分可以类似地添加。每个顶点都被赋予一个符号名称,如 `usa``idaho`。该名称不存储在数据库中,仅在查询内部使用以在顶点之间创建边,使用箭头符号:`(idaho) -[:WITHIN]-> (usa)` 创建一条标记为 `WITHIN` 的边,其中 `idaho` 作为尾节点,`usa` 作为头节点。
{{< figure id="fig_cypher_create" title="示例 3-4. [图 3-6](/ch3#fig_datamodels_graph) 中数据的子集,表示为 Cypher 查询" class="w-full my-4" >}}
@ -480,9 +481,9 @@ CREATE
(lucy) -[:BORN_IN]-> (idaho)
```
当[图 3-6](/ch3#fig_datamodels_graph) 的所有顶点和边都添加到数据库后,我们可以开始提出有趣的问题:例如,**查找从美国移民到欧洲的所有人的姓名**。也就是说,找到所有具有 `BORN_IN` 边指向美国境内某个位置,并且也有 `LIVING_IN` 边指向欧洲境内某个位置的顶点,并返回每个这些顶点的 `name` 属性。
[图 3-6](/ch3#fig_datamodels_graph) 的所有顶点和边都添加到数据库后,我们可以开始提出有趣的问题:例如,*查找所有从美国移民到欧洲的人的姓名*。也就是说,找到所有具有指向美国境内位置的 `BORN_IN` 边,以及指向欧洲境内位置的 `LIVING_IN` 边的顶点,并返回每个顶点的 `name` 属性。
[示例 3-5](/ch3#fig_cypher_query) 显示了如何在 Cypher 中表达该查询。相同的箭头表示法用于 `MATCH` 子句以在图中查找模式:`(person) -[:BORN_IN]-> ()` 匹配由标记为 `BORN_IN` 的边相关的任意两个顶点。该边的尾顶点绑定到变量 `person`,头顶点未命名。
[示例 3-5](/ch3#fig_cypher_query) 显示了如何在 Cypher 中表达该查询。相同的箭头符号用于 `MATCH` 子句中以在图中查找模式:`(person) -[:BORN_IN]-> ()` 匹配由标记为 `BORN_IN` 的边相关的任意两个顶点。该边的尾顶点绑定到变量 `person`,头顶点未命名。
{{< figure id="fig_cypher_query" title="示例 3-5. Cypher 查询查找从美国移民到欧洲的人" class="w-full my-4" >}}
@ -493,32 +494,32 @@ MATCH
RETURN person.name
```
查询可以按如下方式阅读
查询可以这样理解
> 查找满足以下 **两个** 条件的任何顶点(称为 `person`
> 找到满足以下 *两个* 条件的任何顶点(称为 `person`
>
> 1. `person` 有一条出边 `BORN_IN` 指向某个顶点。从该顶点,你可以跟随一系列出边 `WITHIN`,直到最终到达类型为 `Location` 的顶点,其 `name` 属性等于 `"United States"`
> 2. 同一个 `person` 顶点也有一条出边 `LIVES_IN`。跟随该边,然后是一系列出边 `WITHIN`,你最终到达类型为 `Location` 的顶点,其 `name` 属性等于 `"Europe"`
> 1. `person` 有一条出边 `BORN_IN` 指向某个顶点。从那个顶点,你可以跟随一条出边 `WITHIN` 链,直到最终到达一个类型为 `Location` 的顶点,其 `name` 属性等于 `"United States"`
> 2. 同一个 `person` 顶点也有一条出边 `LIVES_IN`。跟随该边,然后是一条出边 `WITHIN` 链,你最终到达一个类型为 `Location` 的顶点,其 `name` 属性等于 `"Europe"`
>
> 对于每个这样的 `person` 顶点,返回 `name` 属性。
有几种可能的执行查询的方。这里给出的描述建议你从扫描数据库中的所有人开始,检查每个人的出生地和居住地,并仅返回满足条件的那些人。
有几种可能的执行查询的方。这里给出的描述建议你从扫描数据库中的所有人开始,检查每个人的出生地和居住地,并仅返回符合条件的人。
同样地,你可以从两个 `Location` 顶点开始并向后工作。如果 `name` 属性上有索引,你可以有效地找到表美国和欧洲的两个顶点。然后你可以通过跟随所有传入的 `WITHIN` 边来分别找到美国和欧洲的所有位置(州、地区、城市等)。最后,你可以寻找可以通过其中一个位置顶点的传入 `BORN_IN``LIVES_IN` 边找到的人。
等效地,你可以从两个 `Location` 顶点开始并向后工作。如果 `name` 属性上有索引,你可以有效地找到表美国和欧洲的两个顶点。然后你可以通过跟随所有传入的 `WITHIN` 边来查找美国和欧洲各自的所有位置(州、地区、城市等)。最后,你可以寻找可以通过位置顶点之一的传入 `BORN_IN``LIVES_IN` 边找到的人。
### SQL 中的图查询 {#id58}
[示例 3-3](/ch3#fig_graph_sql_schema) 建议图数据可以在关系数据库中表示。但是,如果我们将图数据放入关系结构中,我们是否也可以使用 SQL 查询它
[示例 3-3](/ch3#fig_graph_sql_schema) 建议图数据可以在关系数据库中表示。但如果我们将图数据放入关系结构中,我们还能使用 SQL 查询它吗
答案是肯定的,但有一些困难。你在图查询中遍历的每条边实际上都是与 `edges` 表的连接。在关系数据库中,你通常事先知道查询中需要哪些连接。另一方面,在图查询中,你可能需要遍历可变数量的边才能找到你要查找的顶点——也就是说,连接的数量不是事先固定的。
答案是肯定的,但有一些困难。你在图查询中遍历的每条边实际上都是与 `edges` 表的连接。在关系数据库中,你通常事先知道查询中需要哪些连接。另一方面,在图查询中,你可能需要遍历可变数量的边才能找到你要查找的顶点 —— 也就是说,连接的数量不是预先固定的。
在我们的例中,这发生在 Cypher 查询中的 `() -[:WITHIN*0..]-> ()` 模式中。一个人的 `LIVES_IN` 边可能指向任何类型的位置:街道、城市、地区、地区、州等。城市可能 `WITHIN` 一个地区,地区 `WITHIN` 一个州,州 `WITHIN` 一个国家等。`LIVES_IN` 边可能直接指向你要查找的位置顶点,或者它可能在位置层次结构中相距几个级别。
在我们的例中,这发生在 Cypher 查询中的 `() -[:WITHIN*0..]-> ()` 模式中。一个人的 `LIVES_IN` 边可能指向任何类型的位置:街道、城市、地区、地区、州等。一个城市可能在一个地区 `WITHIN`,一个地区在一个州 `WITHIN`,一个州在一个国家 `WITHIN`,等等。`LIVES_IN` 边可能直接指向你要查找的位置顶点,或者它可能在位置层次结构中相距几个级别。
在 Cypher 中,`:WITHIN*0..` 非常简洁地表达了这事实:它意味着"跟随 `WITHIN` 边,零次或多次"。它就像正则表达式中的 `*` 运算符。
在 Cypher 中,`:WITHIN*0..` 非常简洁地表达了这事实:它意味着"跟随 `WITHIN` 边,零次或多次"。它就像正则表达式中的 `*` 运算符。
自 SQL:1999 以来,这种查询中可变长度遍历路径的想法可以使用称为 **递归公用表表达式**`WITH RECURSIVE` 语法)的东西来表达。[示例 3-6](/ch3#fig_graph_sql_query) 显示了相同的查询——查找从美国移民到欧洲的人的姓名——使用此技术用 SQL 表达。然而,与 Cypher 相比,语法非常笨拙。
自 SQL:1999 以来,查询中可变长度遍历路径的想法可以使用称为 *递归公用表表达式*`WITH RECURSIVE` 语法)的东西来表达。[示例 3-6](/ch3#fig_graph_sql_query) 显示了相同的查询 —— 查找从美国移民到欧洲的人的姓名 —— 使用此技术在 SQL 中表达。然而,与 Cypher 相比,语法非常笨拙。
{{< figure id="fig_graph_sql_query" title="示例 3-6. 与[示例 3-5](/ch3#fig_cypher_query) 相同的查询,使用递归公用表表达式用 SQL 编写" class="w-full my-4" >}}
{{< figure id="fig_graph_sql_query" title="示例 3-6. 与 [示例 3-5](/ch3#fig_cypher_query) 相同的查询,使用递归公用表表达式在 SQL 中编写" class="w-full my-4" >}}
```sql
WITH RECURSIVE
@ -543,14 +544,14 @@ WITH RECURSIVE
WHERE edges.label = 'within'
),
-- born_in_usa 是在美国出生的所有人的顶点 ID 集合
-- 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 是所有居住在欧洲的人的顶点 ID 集合
lives_in_europe(vertex_id) AS ( ❺
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
@ -559,46 +560,46 @@ WITH RECURSIVE
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;
```
❶: 首先找到 `name` 属性值为 `"United States"` 的顶点,并使其成为顶点集 `in_usa` 的第一个元素。
❶: 首先找到 `name` 属性值为 `"United States"` 的顶点,并使其成为顶点集 `in_usa` 的第一个元素。
❷: 从集合 `in_usa` 中的顶点跟随所有传入的 `within` 边,并将它们添加到同一集合中,直到访问了所有传入的 `within` 边。
❸: 从 `name` 属性值为 `"Europe"` 的顶点开始执行相同操作,并构建顶点集 `in_europe`
❸: 从 `name` 属性值为 `"Europe"` 的顶点开始执行相同操作,并构建顶点集 `in_europe`
❹: 对于集合 `in_usa` 中的每个顶点,跟随传入的 `born_in` 边以找在美国某个地方出生的人。
❹: 对于集合 `in_usa` 中的每个顶点,跟随传入的 `born_in` 边以找在美国某个地方出生的人。
❺: 类似地,对于集合 `in_europe` 中的每个顶点,跟随传入的 `lives_in` 边以找到住在欧洲的人。
❺: 类似地,对于集合 `in_europe` 中的每个顶点,跟随传入的 `lives_in` 边以查找居住在欧洲的人。
❻: 最后,通过连接它们来交叉在美国出生的人集合和住在欧洲的人集合
❻: 最后,通过连接它们来将在美国出生的人的集合与居住在欧洲的人的集合相交
4 行 Cypher 查询在 SQL 中需要 31 行这一事实表明,正确选择数据模型和查询语言可以产生多大的差异。这只是开始;还有更多细节需要考虑,例如处理循环以及在广度优先或深度优先遍历之间进行选择 [^40]。
4 行 Cypher 查询需要 31 行 SQL 的事实表明,正确选择数据模型和查询语言可以产生多大的差异。这只是开始;还有更多细节需要考虑,例如处理循环以及在广度优先或深度优先遍历之间进行选择 [^40]。
Oracle 对递归查询有不同的 SQL 扩展,它称之为 **分层** [^41]。
Oracle 对递归查询有不同的 SQL 扩展,它称之为 *层次* [^41]。
然而,情况可能正在改善:在撰写本文时,有计划将名为 GQL 的图查询语言添加到 SQL 标准 [^42] [^43],它将提供受 Cypher、GSQL [^44] 和 PGQL [^45] 启发的语法。
然而,情况可能正在改善:在撰写本文时,有计划向 SQL 标准添加一种名为 GQL 的图查询语言 [^42] [^43],它将提供受 Cypher、GSQL [^44] 和 PGQL [^45] 启发的语法。
### 三元组存储与 SPARQL {#id59}
三元组存储模型与属性图模型基本等效,使用不同的词来描述相同的想法。尽管如此,还是值得讨论,因为有各种用于三元组存储的工具和语言,它们可以成为构建应用程序工具箱的宝贵补充。
三元组存储模型大多等同于属性图模型,使用不同的词来描述相同的想法。尽管如此,它仍值得讨论,因为有各种三元组存储的工具和语言,它们可以成为构建应用程序工具箱的宝贵补充。
在三元组存储中,所有信息都以非常简单的三部分语句的形式存储:(**主语**、**谓语****宾语**)。例如,在三元组(*Jim*、*likes*、*bananas*)中,*Jim* 是主语,*likes* 是谓语(动词),*bananas* 是宾语。
在三元组存储中,所有信息都以非常简单的三部分语句的形式存储:(*主语*、*谓语*、*宾语*)。例如,在三元组(*Jim*、*likes*、*bananas*)中,*Jim* 是主语,*likes* 是谓语(动词),*bananas* 是宾语。
三元组的主语相当于图中的顶点。宾语是两种事物之一:
三元组的主语等同于图中的顶点。宾语是两种东西之一:
1. 原始数据类型的值,如字符串或数字。在这种情况下,三元组的谓语和宾语相当于主语顶点上属性的键和值。使用[图 3-6](/ch3#fig_datamodels_graph) 中的例子,(*lucy*、*birthYear*、*1989*)就像具有属性 `{"birthYear": 1989}` 的顶点 `lucy`。
1. 原始数据类型的值,如字符串或数字。在这种情况下,三元组的谓语和宾语等同于主语顶点上属性的键和值。使用 [图 3-6](/ch3#fig_datamodels_graph) 中的示例,(*lucy*、*birthYear*、*1989*)就像一个顶点 `lucy`,其属性为 `{"birthYear": 1989}`。
2. 图中的另一个顶点。在这种情况下,谓语是图中的边,主语是尾顶点,宾语是头顶点。例如,在(*lucy*、*marriedTo*、*alain*)中,主语和宾语 *lucy**alain* 都是顶点,谓语 *marriedTo* 是连接它们的边的标签。
> [!NOTE]
> 准确地说提供类似三元组数据模型的数据库通常需要在每个元组上存储一些额外的元数据。例如AWS Neptune 通过向每个三元组添加图 ID 来使用四元组4元组[^46]Datomic 使用 5 元组,通过事务 ID 和表示删除的布尔值扩展每个三元组 [^47]。由于这些数据库保留了上述解释的基本 **主语-谓语-宾语** 结构,本书仍然称它们为三元组存储。
> 准确地说提供类似三元组数据模型的数据库通常需要在每个元组上存储一些额外的元数据。例如AWS Neptune 使用四元组4-tuples通过向每个三元组添加图 ID [^46]Datomic 使用 5 元组,用事务 ID 和一个表示删除的布尔值扩展每个三元组 [^47]。由于这些数据库保留了上面解释的基本 *主语-谓语-宾语* 结构,本书仍然称它们为三元组存储。
[示例 3-7](/ch3#fig_graph_n3_triples) 显示了与[示例 3-4](/ch3#fig_cypher_create) 相同的数据,以称为 **Turtle** 的格式编写为三元组,它是 **Notation3****N3**)的子集 [^48]。
[示例 3-7](/ch3#fig_graph_n3_triples) 显示了与 [示例 3-4](/ch3#fig_cypher_create) 中相同的数据,以称为 *Turtle* 的格式编写为三元组,它是 *Notation3**N3*)的子集 [^48]。
{{< figure link="#fig_datamodels_graph" id="fig_graph_n3_triples" title="示例 3-7. 图 3-6 中数据的子集,表示为 Turtle 三元组" class="w-full my-4" >}}
{{< figure id="fig_graph_n3_triples" title="示例 3-7. [图 3-6](/ch3#fig_datamodels_graph) 中数据的子集,表示为 Turtle 三元组" class="w-full my-4" >}}
```
@prefix : <urn:example:>.
@ -618,11 +619,11 @@ _:namerica :name "North America".
_:namerica :type "continent".
```
这个例子中,图的顶点写为 `_:someName`。该名称在此文件之外没有任何意义;它的存在只是因为否则我们不知道哪些三元组引用同一个顶点。当谓语表示边时,宾语是顶点,如 `_:idaho :within _:usa`。当谓语是属性时,宾语是字符串字,如 `_:usa :name "United States"`
此示例中,图的顶点写为 `_:someName`。该名称在此文件之外没有任何意义;它的存在只是因为否则我们不知道哪些三元组引用同一个顶点。当谓语表示边时,宾语是顶点,如 `_:idaho :within _:usa`。当谓语是属性时,宾语是字符串字面量,如 `_:usa :name "United States"`
一遍又一遍地重复相同的主语相当重复,但幸运的是,你可以使用分号对同一主语说多件事。这使得 Turtle 格式相当可读:参见[示例 3-8](/ch3#fig_graph_n3_shorthand)。
一遍又一遍地重复相同的主语相当重复,但幸运的是,你可以使用分号来表达关于同一主语的多个内容。这使得 Turtle 格式非常易读:见 [示例 3-8](/ch3#fig_graph_n3_shorthand)。
{{< figure id="fig_graph_n3_shorthand" title="示例 3-8. 编写[示例 3-7](/ch3#fig_graph_n3_triples) 中数据的更简洁方式" class="w-full my-4" >}}
{{< figure id="fig_graph_n3_shorthand" title="示例 3-8. 编写 [示例 3-7](/ch3#fig_graph_n3_triples) 中数据的更简洁方式" class="w-full my-4" >}}
```
@prefix : <urn:example:>.
@ -636,15 +637,15 @@ _:namerica a :Location; :name "North America"; :type "continent".
> [!TIP] 语义网
三元组存储的一些研究和开发工作是由 **语义网** 激发的,这是 2000 年代初的一项努力,通过不仅以人类可读的网页发布数据,而且以标准化、机器可读的格式发布数据,促进互联网范围的数据交换。虽然最初设想的语义网没有成功 [^49] [^50],但语义网项目的遗产在几个特定技术中继续存在:**链接数据** 标准如 JSON-LD [^51]、生物医学科学中使用的 **本体** [^52]、Facebook 的开放图谱协议 [^53](用于链接展开 [^54]、Wikidata 等知识图,以及由 [`schema.org`](https://schema.org/) 维护的结构化数据标准化词汇表。
一些三元组存储的研究和开发工作是由 *语义网* 推动的,这是 2000 年代初的一项努力,旨在通过不仅以人类可读的网页形式发布数据,还以标准化的机器可读格式发布数据来促进互联网范围的数据交换。尽管最初设想的语义网没有成功 [^49] [^50],但语义网项目的遗产在几项特定技术中继续存在:*链接数据* 标准(如 JSON-LD [^51])、生物医学科学中使用的 *本体* [^52]、Facebook 的开放图协议 [^53](用于链接展开 [^54])、知识图(如 Wikidata以及由 [`schema.org`](https://schema.org/) 维护的结构化数据标准化词汇表。
三元组存储是另一种在其原始用例之外找到用途的语义网技术:即使你对语义网没有兴趣,三元组也可以应用程序的良好内部数据模型。
三元组存储是另一种在其原始用例之外找到用途的语义网技术:即使你对语义网没有兴趣,三元组也可以成为应用程序的良好内部数据模型。
--------
#### RDF 数据模型 {#the-rdf-data-model}
我们在[示例 3-8](/ch3#fig_graph_n3_shorthand) 中使用的 Turtle 语言实际上是在 **资源描述框架**RDF[^55] 中编码数据的一种方式这是为语义网设计的数据模型。RDF 数据也可以用其他方式编码,例如(更冗长地)在 XML 中,如[示例 3-9](/ch3#fig_graph_rdf_xml) 所示。像 Apache Jena 这样的工具可以在不同的 RDF 编码之间自动转换。
我们在 [示例 3-8](/ch3#fig_graph_n3_shorthand) 中使用的 Turtle 语言实际上是在 *资源描述框架*RDF[^55] 中编码数据的一种方式这是为语义网设计的数据模型。RDF 数据也可以用其他方式编码,例如(更冗长地)用 XML如 [示例 3-9](/ch3#fig_graph_rdf_xml) 所示。像 Apache Jena 这样的工具可以在不同的 RDF 编码之间自动转换。
{{< figure id="fig_graph_rdf_xml" title="示例 3-9. [示例 3-8](/ch3#fig_graph_n3_shorthand) 的数据,使用 RDF/XML 语法表示" class="w-full my-4" >}}
@ -676,17 +677,17 @@ _:namerica a :Location; :name "North America"; :type "continent".
</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>`
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`。幸运的是,你只需在文件顶部指定一次此前缀,然后就可以忘记它。
URL `<http://my-company.com/namespace>` 不一定需要解析为任何内容 —— 从 RDF 的角度来看,它只是一个命名空间。为了避免与 `http://` URL 的潜在混淆,本节中的示例使用不可解析的 URI`urn:example:within`。幸运的是,你只需在文件顶部指定一次此前缀,然后就可以忘记它。
#### SPARQL 查询语言 {#the-sparql-query-language}
**SPARQL** 是使用 RDF 数据模型的三元组存储的查询语言 [^56]。(它是 **SPARQL 协议和 RDF 查询语言** 的缩写,发音为"sparkle"。)它早于 Cypher由于 Cypher 的模式匹配借鉴自 SPARQL,它们看起来非常相似。
*SPARQL* 是使用 RDF 数据模型的三元组存储的查询语言 [^56]。(它是 *SPARQL Protocol and RDF Query Language* 的首字母缩略词,发音为 "sparkle"。)它早于 Cypher由于 Cypher 的模式匹配是从 SPARQL 借用的,它们看起来非常相似。
以前相同的查询——查找从美国搬到欧洲的人——在 SPARQL 中与在 Cypher 中一样简洁(参见[示例 3-10](/ch3#fig_sparql_query))。
之前相同的查询 —— 查找从美国搬到欧洲的人 —— 在 SPARQL 中与在 Cypher 中一样简洁(见 [示例 3-10](/ch3#fig_sparql_query))。
{{< figure id="fig_sparql_query" title="示例 3-10. 与[示例 3-5](/ch3#fig_cypher_query) 相同的查询,用 SPARQL 表示" class="w-full my-4" >}}
{{< figure id="fig_sparql_query" title="示例 3-10. 与 [示例 3-5](/ch3#fig_cypher_query) 相同的查询,用 SPARQL 表示" class="w-full my-4" >}}
```
PREFIX : <urn:example:>
@ -706,7 +707,7 @@ SELECT ?personName WHERE {
?person :bornIn / :within* ?location. # SPARQL
```
因为 RDF 不区分属性和边,而是对两者都使用谓语,所以你可以使用相同的语法来匹配属性。在以下表达式中,变量 `usa` 绑定到任何具有 `name` 属性且其值为字符串 `"United States"` 的顶点:
因为 RDF 不区分属性和边,而是对两者都使用谓语,所以你可以使用相同的语法来匹配属性。在以下表达式中,变量 `usa` 绑定到任何具有 `name` 属性且其值为字符串 `"United States"` 的顶点:
```
(usa {name:'United States'}) # Cypher
@ -718,13 +719,13 @@ SPARQL 得到 Amazon Neptune、AllegroGraph、Blazegraph、OpenLink Virtuoso、A
### Datalog递归关系查询 {#id62}
Datalog 是一种比 SPARQL 或 Cypher 更古老的语言:它源于 20 世纪 80 年代的学术研究 [^57] [^58] [^59]。它在软件工程师中不太为人所知,在主流数据库中没有得到广泛支持,但它应该更为人所知,因为它是一种非常有表现力的语言,对于复杂查询特别强大。几个小众数据库,包括 Datomic、LogicBlox、CozoDB 和 LinkedIn 的 LIquid [^60] 使用 Datalog 作为们的查询语言。
Datalog 是一种比 SPARQL 或 Cypher 更古老的语言:它源于 20 世纪 80 年代的学术研究 [^57] [^58] [^59]。它在软件工程师中不太为人所知,并且在主流数据库中没有得到广泛支持,但它应该更为人所知,因为它是一种非常有表现力的语言,对于复杂查询特别强大。几个小众数据库,包括 Datomic、LogicBlox、CozoDB 和 LinkedIn 的 LIquid [^60] 使用 Datalog 作为们的查询语言。
Datalog 实际上基于关系数据模型而不是图,但它出现在本书的图数据库部分,因为图上的递归查询是 Datalog 的特殊优势。
Datalog 实际上基于关系数据模型而不是图,但它出现在本书的图数据库部分,因为图上的递归查询是 Datalog 的特殊优势。
Datalog 数据库的内容由 **事实** 组成,每个事实对应于关系表中的一行。例如,假设我们有一个包含位置的表 *location*,它有三列:*ID*、*name* 和 *type*。美国是一个国家的事实可以写 `location(2, "United States", "country")`,其中 `2` 是美国的 ID。一般来说语句 `table(val1, val2, …​)` 意味着 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此类推。
Datalog 数据库的内容由 *事实* 组成,每个事实对应于关系表中的一行。例如,假设我们有一个包含位置的表 *location*,它有三列:*ID*、*name* 和 *type*。美国是一个国家的事实可以写 `location(2, "United States", "country")`,其中 `2` 是美国的 ID。一般来说语句 `table(val1, val2, …​)` 意味着 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此类推。
[示例 3-11](/ch3#fig_datalog_triples) 显示了如何在 Datalog 中编写[图 3-6](/ch3#fig_datamodels_graph) 左侧的数据。图的边(`within`、`born_in` 和 `lives_in`表示为两列连接表。例如Lucy 的 ID 为 100爱达荷州的 ID 为 3因此关系"Lucy 出生在爱达荷州"表示为 `born_in(100, 3)`
[示例 3-11](/ch3#fig_datalog_triples) 显示了如何在 Datalog 中编写 [图 3-6](/ch3#fig_datamodels_graph) 左侧的数据。图的边(`within`、`born_in` 和 `lives_in`表示为两列连接表。例如Lucy 的 ID 是 100爱达荷州的 ID 是 3所以关系"Lucy 出生在爱达荷州"表示为 `born_in(100, 3)`
{{< figure id="fig_datalog_triples" title="示例 3-11. [图 3-6](/ch3#fig_datamodels_graph) 中数据的子集,表示为 Datalog 事实" class="w-full my-4" >}}
@ -740,9 +741,9 @@ person(100, "Lucy").
born_in(100, 3). /* Lucy 出生在爱达荷州 */
```
现在我们已经定义了数据,我们可以编写与以前相同的查询,如[示例 3-12](/ch3#fig_datalog_query) 所示。它看起来与 Cypher 或 SPARQL 中的等效查询有点不同但不要让它吓到你。Datalog 是 Prolog 的子集,如果你学过计算机科学,你可能以前见过这种编程语言
现在我们已经定义了数据,我们可以编写与之前相同的查询,如 [示例 3-12](/ch3#fig_datalog_query) 所示。它看起来与 Cypher 或 SPARQL 中的等效查询有点不同但不要让这吓倒你。Datalog 是 Prolog 的子集,这是一种编程语言,如果你学过计算机科学,你可能见过它
{{< figure id="fig_datalog_query" title="示例 3-12. 与[示例 3-5](/ch3#fig_cypher_query) 相同的查询,用 Datalog 表示" class="w-full my-4" >}}
{{< figure id="fig_datalog_query" title="示例 3-12. 与 [示例 3-5](/ch3#fig_cypher_query) 相同的查询,用 Datalog 表示" class="w-full my-4" >}}
```sql
within_recursive(LocID, PlaceName) :- location(LocID, PlaceName, _). /* 规则 1 */
@ -760,13 +761,13 @@ us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* 规则 4
/* us_to_europe 包含行 "Lucy"。 */
```
Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小步。我们定义从底层事实派生新虚拟表**规则**。这些派生表就像虚拟SQL 视图:它们不存储在数据库中,但你可以像查询包含存储事实的表一样查询它们。
Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小步。我们定义 *规则* 从底层事实派生新虚拟表。这些派生表就像虚拟SQL 视图:它们不存储在数据库中,但你可以像查询包含存储事实的表一样查询它们。
在[示例 3-12](/ch3#fig_datalog_query) 中,我们定义了三个派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虚拟表的名称和列由每个规则的 `:-` 符号之前出现的内容定义。例如,`migrated(PName, BornIn, LivingIn)` 是一个具有三列的虚拟表:一个人的姓名、他们出生地的名称和他们居住地的名称。
[示例 3-12](/ch3#fig_datalog_query) 中,我们定义了三个派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虚拟表的名称和列由每个规则的 `:-` 符号之前出现的内容定义。例如,`migrated(PName, BornIn, LivingIn)` 是一个具有三列的虚拟表:一个人的姓名、他们出生地的名称和他们居住地的名称。
虚拟表的内容由规则的 `:-` 符号之后的部分定义,我们在其中尝试在表中查找与某种模式匹配的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,变量 `PersonID` 绑定到值 `100`,变量 `PName` 绑定到值 `"Lucy"`。如果系统可以为 `:-` 运算符右侧的 **所有** 模式找到匹配,则规则适用。当规则适用时,就好像 `:-` 的左侧被添加到数据库中(变量被它们匹配的值替换)。
虚拟表的内容由规则的 `:-` 符号之后的部分定义,我们在其中尝试查找表中匹配某种模式的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,变量 `PersonID` 绑定到值 `100`,变量 `PName` 绑定到值 `"Lucy"`。如果系统可以为 `:-` 运算符右侧的 *所有* 模式找到匹配项,则规则适用。当规则适用时,就好像 `:-` 的左侧被添加到数据库中(变量被它们匹配的值替换)。
因此,应用规则的一种可能方式是(如[图 3-7](/ch3#fig_datalog_naive) 所示):
因此,应用规则的一种可能方式是(如 [图 3-7](/ch3#fig_datalog_naive) 所示):
1. `location(1, "North America", "continent")` 存在于数据库中,因此规则 1 适用。它生成 `within_recursive(1, "North America")`
2. `within(2, 1)` 存在于数据库中,前一步生成了 `within_recursive(1, "North America")`,因此规则 2 适用。它生成 `within_recursive(2, "North America")`
@ -774,21 +775,21 @@ Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小
通过重复应用规则 1 和 2`within_recursive` 虚拟表可以告诉我们数据库中包含的北美(或任何其他位置)的所有位置。
{{< figure link="#fig_datalog_query" src="/fig/ddia_0307.png" id="fig_datalog_naive" title="图 3-7. 使用[示例 3-12](/ch3#fig_datalog_query) 中的 Datalog 规则确定爱达荷州在北美。" class="w-full my-4" >}}
{{< figure link="#fig_datalog_query" src="/fig/ddia_0307.png" id="fig_datalog_naive" title="图 3-7. 使用示例 3-12 中的 Datalog 规则确定爱达荷州在北美。" class="w-full my-4" >}}
> 图 3-7. 使用[示例 3-12](/ch3#fig_datalog_query) 中的 Datalog 规则确定爱达荷州在北美。
> 图 3-7. 使用 [示例 3-12](/ch3#fig_datalog_query) 中的 Datalog 规则确定爱达荷州在北美。
现在规则 3 可以找到出生在某个位置 `BornIn` 并住在某个位置 `LivingIn` 的人。规则 4 调用规则 3其中 `BornIn = 'United States'``LivingIn = 'Europe'`,并仅返回匹配搜索的人的姓名。通过查询虚拟 `us_to_europe` 表的内容Datalog 系统最终得到与早期 Cypher 和 SPARQL 查询相同的答案。
现在规则 3 可以找到出生在某个位置 `BornIn`住在某个位置 `LivingIn` 的人。规则 4 使用 `BornIn = 'United States'``LivingIn = 'Europe'` 调用规则 3,并仅返回匹配搜索的人的姓名。通过查询虚拟 `us_to_europe` 表的内容Datalog 系统最终得到与早期 Cypher 和 SPARQL 查询相同的答案。
与本章讨论的其他查询语言相比Datalog 方法需要不同的思维方式。它允许逐条规则地构建复杂查询一个规则引用其他规则类似于你将代码分解为相互调用的函数的方式。就像函数可以递归一样Datalog 规则也可以调用自己,就像[示例 3-12](/ch3#fig_datalog_query) 中的规则 2它在 Datalog 查询中启用图遍历
与本章讨论的其他查询语言相比Datalog 方法需要不同类型的思维。它允许逐条规则地构建复杂查询一个规则引用其他规则类似于你将代码分解为相互调用的函数的方式。就像函数可以递归一样Datalog 规则也可以调用自己,如 [示例 3-12](/ch3#fig_datalog_query) 中的规则 2这使得 Datalog 查询中的图遍历成为可能
### GraphQL {#id63}
GraphQL 是一种查询语言,设计上比本章中看到的其他查询语言更具限制性。GraphQL 的目的是允许在用户设备上运行的客户端软件(如移动应用程序或 JavaScript Web 应用程序前端)请求具有特定结构的 JSON 文档,其中包含呈现其用户界面所需的字段。GraphQL 接口允许开发人员快速更改客户端代码中的查询,而无需更改服务器端 API。
GraphQL 是一种查询语言,设计上讲,它我们在本章中看到的其他查询语言限制性更强。GraphQL 的目的是允许在用户设备上运行的客户端软件(如移动应用程序或 JavaScript Web 应用程序前端)请求具有特定结构的 JSON 文档,其中包含渲染其用户界面所需的字段。GraphQL 接口允许开发人员快速更改客户端代码中的查询,而无需更改服务器端 API。
GraphQL 的灵活性是有代价的。采用 GraphQL 的组织通常需要工具将 GraphQL 查询转换为对内部服务的请求,这些服务通常使用 REST 或 gRPC参见[第 5 章](/ch5#ch_encoding))。授权、速率限制和性能挑战是其他问题 [^61]。GraphQL 的查询语言也受到限制,因为 GraphQL 来自不受信任的来源。该语言不允许任何可能执行成本高昂的内容,否则用户可以通过运行大量昂贵的查询对服务器执行拒绝服务攻击。特别是GraphQL 不允许递归查询(与 Cypher、SPARQL、SQL 或 Datalog 不同),并且不允许任意搜索条件,如"查找出生在美国现在住在欧洲的人"(除非服务所有者特别选择提供此类搜索功能)。
GraphQL 的灵活性是有代价的。采用 GraphQL 的组织通常需要工具将 GraphQL 查询转换为对内部服务的请求,这些服务通常使用 REST 或 gRPC参见 [第 5 章](/ch5#ch_encoding))。授权、速率限制和性能挑战是额外的关注点 [^61]。GraphQL 的查询语言也受到限制,因为 GraphQL 来自不受信任的来源。该语言不允许任何可能执行成本高昂的操作,否则用户可能通过运行大量昂贵的查询对服务器执行拒绝服务攻击。特别是GraphQL 不允许递归查询(与 Cypher、SPARQL、SQL 或 Datalog 不同),并且不允许任意搜索条件,如"查找在美国出生并现在住在欧洲的人"(除非服务所有者特别选择提供此类搜索功能)。
尽管如此GraphQL 还是很有用的。[示例 3-13](/ch3#fig_graphql_query) 显示了如何使用 GraphQL 实现群聊应用程序(如 Discord 或 Slack。查询请求用户有权访问的所有频道,包括频道名称和每个频道中最近的 50 条消息。对于每条消息,它请求时间戳、消息内容以及消息发送者的姓名和头像 URL。此外如果消息是对另一条消息的回复查询还请求发送者姓名和它回复的消息内容可能以较小的字体呈现在回复上方以提供一些上下文
尽管如此GraphQL 还是很有用的。[示例 3-13](/ch3#fig_graphql_query) 显示了如何使用 GraphQL 实现 Discord 或 Slack 等群聊应用程序。查询请求用户有权访问的所有频道,包括频道名称和每个频道中的 50 条最新消息。对于每条消息,它请求时间戳、消息内容以及消息发送者的姓名和个人资料图片 URL。此外如果消息是对另一条消息的回复查询还请求发送者姓名和它回复的消息内容(可能以较小的字体呈现在回复上方,以提供一些上下文)。
{{< figure id="fig_graphql_query" title="示例 3-13. 群聊应用程序的示例 GraphQL 查询" class="w-full my-4" >}}
@ -814,9 +815,9 @@ query ChatApp {
}
```
[示例 3-14](/ch3#fig_graphql_response) 显示了对[示例 3-13](/ch3#fig_graphql_query) 中查询的响应可能是什么样子。响应是一个反映查询结构的 JSON 文档:它包含恰好请求的那些属性,不多也不少。这种方法的优点是服务器不需要知道客户端需要哪些属性来呈现用户界面;相反,客户端可以简单地请求它需要的内容。例如,此查询不为 `replyTo` 消息的发送者请求头像 URL但如果用户界面更改为添加该头像客户端很容易将所需的 `imageUrl` 属性添加到查询中,而无需更改服务器。
[示例 3-14](/ch3#fig_graphql_response) 显示了对 [示例 3-13](/ch3#fig_graphql_query) 中查询的响应可能是什么样子。响应是一个反映查询结构的 JSON 文档:它正好包含请求的那些属性,不多也不少。这种方法的优点是服务器不需要知道客户端需要哪些属性来渲染用户界面;相反,客户端可以简单地请求它需要的内容。例如,此查询不会为 `replyTo` 消息的发送者请求个人资料图片 URL但如果用户界面更改为添加该个人资料图片客户端可以很容易地将所需的 `imageUrl` 属性添加到查询中,而无需更改服务器。
{{< figure id="fig_graphql_response" title="示例 3-14. 对[示例 3-13](/ch3#fig_graphql_query) 中查询的可能响应" class="w-full my-4" >}}
{{< figure id="fig_graphql_response" title="示例 3-14. 对 [示例 3-13](/ch3#fig_graphql_query) 中查询的可能响应" class="w-full my-4" >}}
```json
{
@ -843,111 +844,111 @@ query ChatApp {
...
```
在[示例 3-14](/ch3#fig_graphql_response) 中,消息发送者的姓名和图像 URL 直接嵌入在消息对象中。如果同一用户发送多条消息,此信息会在每条消息上重复。原则上,可以减少这种重复,但 GraphQL 做出了设计选择,接受更大的响应大小,以便根据数据更简单地呈现用户界面。
[示例 3-14](/ch3#fig_graphql_response) 中,消息发送者的姓名和图像 URL 直接嵌入在消息对象中。如果同一用户发送多条消息,此信息会在每条消息上重复。原则上,可以减少这种重复,但 GraphQL 做出了接受更大响应大小的设计选择,以便更简单地基于数据渲染用户界面。
`replyTo` 字段类似:在[示例 3-14](/ch3#fig_graphql_response) 中,第二条消息是对第一条消息的回复,内容("Hey!…")和发送者 Aaliyah 在 `replyTo` 下重复。可以改为返回被回复消息的 ID但如果该 ID 不在返回的最近 50 条消息中,客户端将不得不向服务器发出额外的请求。复制内容使处理数据变得更加简单。
`replyTo` 字段类似:在 [示例 3-14](/ch3#fig_graphql_response) 中,第二条消息是对第一条消息的回复,内容("Hey!…")和发送者 Aaliyah 在 `replyTo` 下重复。可以改为返回被回复消息的 ID但如果该 ID 不在返回的 50 条最新消息中,客户端就必须向服务器发出额外的请求。重复内容使得处理数据变得更加简单。
服务器的数据库可以以更规范化的形式存储数据,并执行必要的连接来处理查询。例如,服务器可能存储消息以及发送者的用户 ID 和它回复的消息的 ID当它收到像上面这样的查询时服务器会解析这些 ID 以找到它们引用的记录。但是,客户端只能要求服务器执行 GraphQL 模式中明确提供的连接。
服务器的数据库可以以更规范化的形式存储数据,并执行必要的连接来处理查询。例如,服务器可能存储消息以及发送者的用户 ID 和它所回复的消息的 ID当它收到如上所示的查询时服务器将解析这些 ID 以查找它们引用的记录。但是,客户端只能要求服务器执行 GraphQL 模式中明确提供的连接。
即使对 GraphQL 查询的响应看起来类似于文档数据库的响应,即使它的名称中有"graph"GraphQL 也可以在任何类型的数据库——关系、文档或图——之上实现
即使对 GraphQL 查询的响应看起来类似于文档数据库的响应,即使它的名称中有"graph"GraphQL 也可以在任何类型的数据库之上实现 —— 关系型、文档型或图型
## 事件溯源与 CQRS {#sec_datamodels_events}
在我们迄今为止讨论的所有数据模型中,数据的查询形式与写入形式相同——无论是 JSON 文档、表中的行,还是图中的顶点和边。然而,在复杂的应用中,有时很难找到一种能够满足所有不同查询和展示需求的单一数据表示。在这种情况下,以一种形式写入数据,然后从中派生出针对不同类型读取优化的多种表示,可能会很有益处
在我们迄今为止讨论的所有数据模型中,数据以与写入相同的形式被查询 —— 无论是 JSON 文档、表中的行,还是图中的顶点和边。然而,在复杂的应用程序中,有时很难找到一种能够满足所有不同查询和呈现数据方式的单一数据表示。在这种情况下,以一种形式写入数据,然后从中派生出针对不同类型读取优化的多种表示形式可能是有益的
我们之前在[「记录系统和派生数据」](/ch1#sec_introduction_derived)中看到过这个想法ETL参见[「数据仓库」](/ch1#sec_introduction_dwh))就是这种派生过程的一个例子。现在我们将进一步深入这个想法。如果我们无论如何都要从一种数据表示派生出另一种,我们可以选择分别针对写入和读取优化的不同表示。如果你只想优化写入,而不关心高效查询,你会如何建模你的数据
我们之前在 ["记录系统和衍生数据"](/ch1#sec_introduction_derived) 中看到了这个想法ETL参见 ["数据仓库"](/ch1#sec_introduction_dwh))就是这种派生过程的一个例子。现在我们将进一步深入这个想法。如果我们无论如何都要从一种数据表示派生出另一种,我们可以选择分别针对写入和读取优化的不同表示。如果你只想为写入优化数据建模,而不关心高效查询,你会如何建模
也许写入数据最简单、最快、最具表现力的方式是**事件日志**:每次你想写入一些数据时,你将其编码为自包含的字符串(可能是 JSON含时间戳,然后将其追加到事件序列中。这个日志中的事件是**不可变的**:你永远不会更改或删除它们,你只会向日志追加更多事件(这些事件可能会取代之前的事件)。一个事件可以包含任意属性。
也许写入数据的最简单、最快速和最具表现力的方式是 *事件日志*:每次你想写入一些数据时,你将其编码为自包含的字符串(可能是 JSON括时间戳,然后将其追加到事件序列中。此日志中的事件是 *不可变的*:你永远不会更改或删除它们,你只会向日志追加更多事件(这可能会取代早期事件)。事件可以包含任意属性。
[图 3-8](/ch3#fig_event_sourcing) 显示了一个可能来自会议管理系统的示例。会议可能是一个复杂的业务领域:不仅个人参会者可以注册并通过信用卡支付,公司也可以批量订购座位,通过发票支付,然后稍后将座位分配给个人。一定数量的座位可能会为演讲者、赞助商、志愿者助手等预留。预订也可能被取消,与此同时,会议组织者可能通过将活动移至不同的房间来改变活动的容量。在所有这些情况下,简单地计算可用座位数就成为一个具有挑战性的查询。
[图 3-8](/ch3#fig_event_sourcing) 显示了一个可能来自会议管理系统的示例。会议可能是一个复杂的业务领域:不仅个人参与者可以注册并用信用卡付款,公司也可以批量订购座位,通过发票付款,然后再将座位分配给个人。一些座位可能为演讲者、赞助商、志愿者助手等保留。预订也可能被取消,与此同时,会议组织者可能通过将其移至不同的房间来更改活动的容量。在所有这些情况发生时,简单地计算可用座位数量就成为一个具有挑战性的查询。
{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="图 3-8. 使用不可变事件日志作为事实来源,并从中派生物化视图。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="图 3-8. 使用不可变事件日志作为真相源,并从中派生物化视图。" class="w-full my-4" >}}
在[图 3-8](/ch3#fig_event_sourcing) 中,会议状态的每个变化(例如组织者开放注册,或参会者进行和取消注册)首先被存储为事件。每当一个事件被追加到日志时,几个**物化视图**(也称为**投影**或**读模型**)也会被更新以反映该事件的影响。在会议示例中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为制作参会者徽章的打印机生成文件。
[图 3-8](/ch3#fig_event_sourcing) 中,会议状态的每个变化(例如组织者开放注册,或参与者进行和取消注册)首先被存储为事件。每当事件追加到日志时,几个 *物化视图*(也称为 *投影**读模型*)也会更新以反映该事件的影响。在会议示例中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为打印参与者徽章的打印机生成文件。
使用事件作为事实来源,并将每个状态变化表示为事件的想法被称为**事件溯源** [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则被称为**命令查询责任分离CQRS** [^64]。这些术语起源于领域驱动设计DDD社区尽管类似的想法已经存在很长时间了例如在**状态机复制**中(参见[「使用共享日志」](/ch10#sec_consistency_smr))。
使用事件作为真相源,并将每个状态变化表达为事件的想法被称为 *事件溯源* [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则称为 *命令查询责任分离CQRS* [^64]。这些术语起源于领域驱动设计DDD社区尽管类似的想法已经存在很长时间了例如 *状态机复制*(参见 ["使用共享日志"](/ch10#sec_consistency_smr))。
当用户的请求进来时,它被称为**命令**,首先需要被验证。只有当命令被执行并被确定为有效(例如,有足够的可用座位来满足请求的预订)后,它才成为事实,相应的事件被添加到日志中。因此,事件日志应该只包含有效事件,构建物化视图的事件日志消费者不允许拒绝事件。
当用户的请求进来时,它被称为 *命令*,首先需要验证。只有在命令已执行并确定有效(例如,请求的预订有足够的可用座位)后,它才成为事实,相应的事件被添加到日志中。因此,事件日志应该只包含有效事件,构建物化视图的事件日志消费者不允许拒绝事件。
在以事件溯源风格建模数据时,建议你使用过去时命名事件(例如,"座位已预订"),因为事件是记录过去发生的事实。即使用户后来决定更改或取消,他们曾经持有预订的事实仍然是真实的,更改或取消是稍后添加的单独事件。
在以事件溯源风格建模数据时,建议你使用过去时命名事件(例如,"座位已预订"),因为事件是记录过去发生的事情的记录。即使用户后来决定更改或取消,他们以前持有预订的事实仍然是真实的,更改或取消是稍后添加的单独事件。
事件溯源与[「星型和雪花型:分析模式」](/ch3#sec_datamodels_analytics)中讨论的星型模式事实表之间的相似之处在于,两者都是过去发生的事件集合。然而,事实表中的行都具有相同的列集,而在事件溯源中可能有许多不同的事件类型,每种都有不同的属性。此外,事实表是无序集合,而在事件溯源中事件的顺序很重要:如果先进行预订然后取消,以错误的顺序处理这些事件是没有意义的
事件溯源与星型模式事实表之间的相似之处(如 ["星型与雪花型:分析模式"](/ch3#sec_datamodels_analytics) 中所讨论的)是两者都是过去发生的事件的集合。然而,事实表中的行都具有相同的列集,而在事件溯源中可能有许多不同的事件类型,每种都有不同的属性。此外,事实表是无序集合,而在事件溯源中事件的顺序很重要:如果先进行预订然后取消,以错误的顺序处理这些事件将没有意义
事件溯源和 CQRS 有几个优点:
* 对于开发系统的人来说,事件更好地传达了**为什么**某事发生的意图。例如,理解"预订被取消"这个事件比理解"`bookings` 表第 4001 行的 `active` 列被设置为 `false`,与该预订相关的三行从 `seat_assignments` 表中被删除,代表退款的一行被插入到 `payments` 表中"要容易得多。当物化视图处理取消事件时,这些行修改可能仍然会发生,但当它们由事件驱动时,更新的原因变得更加清晰。
* 事件溯源的一个关键原则是物化视图以可重现的方式从事件日志派生:你应该始终能够删除物化视图并通过使用相同的代码以相同顺序处理相同事件来重新计算它们。如果视图维护代码中有错误,你可以删除视图并使用新代码重新计算它。找到错误也更容易,因为你可以根据需要多次重新运行视图维护代码并检查其行为。
* 你可以有多个针对应用程序所需特定查询优化的物化视图。它们可以存储在与事件相同的数据库中不同的数据库中,具体取决于你的需求。它们可以使用任何数据模型,并且可以为快速读取进行反规范化。你甚至可以只将视图保存在内存中而避免持久化,只要在服务重启时从事件日志重新计算视图是可以接受的
* 如果你决定以新方式呈现现有信息,很容易从现有事件日志构建新的物化视图。你还可以通过添加新类型的事件或向现有事件类型添加新属性(任何旧事件保持不变)来演进系统以支持新功能。你还可以将新行为链接到现有事件(例如,当会议参会者取消时,他们的座位可以提供给等候名单上的下一个人)。
* 如果事件被错误写入,你可以再次删除它,然后可以在没有删除事件的情况下重建视图。另一方面,在直接更新和删除数据的数据库中,已提交的事务通常很难逆转。因此,事件溯源可以减少系统中不可逆操作的数量,使其更容易更改(参见[「可演化性:让变更变得容易」](/ch2#sec_introduction_evolvability))。
* 对于开发系统的人来说,事件更好地传达了 *为什么* 发生某事的意图。例如,理解事件"预订已取消"比理解"`bookings` 表第 4001 行的 `active` 列被设置为 `false`,与该预订相关的三行从 `seat_assignments` 表中删除,并且在 `payments` 表中插入了一行代表退款"更容易。当物化视图处理取消事件时,这些行修改可能发生,但当它们由事件驱动时,更新的原因变得更加清晰。
* 事件溯源的关键原则是物化视图以可重现的方式从事件日志派生:你应该始终能够删除物化视图并通过以相同顺序处理相同事件,使用相同代码来重新计算它们。如果视图维护代码中有错误,你可以删除视图并使用新代码重新计算它。查找错误也更容易,因为你可以随意重新运行视图维护代码并检查其行为。
* 你可以有多个物化视图,针对应用程序所需特定查询进行优化。它们可以存储在与事件相同的数据库中,也可以存储在不同的数据库中,具体取决于你的需求。它们可以使用任何数据模型,并且可以为快速读取而反规范化。你甚至可以只在内存中保留视图并避免持久化它,只要可以在服务重新启动时从事件日志重新计算视图即可
* 如果你决定以新方式呈现现有信息,很容易从现有事件日志构建新的物化视图。你还可以通过添加新类型的事件或向现有事件类型添加新属性(任何旧事件保持未修改)来发展系统以支持新功能。你还可以将新行为链接到现有事件(例如,当会议参与者取消时,他们的座位可以提供给等候名单上的下一个人)。
* 如果事件被错误写入,你可以再次删除它,然后可以在没有删除事件的情况下重建视图。另一方面,在直接更新和删除数据的数据库中,已提交的事务通常很难撤销。因此,事件溯源可以减少系统中不可逆操作的数量,使其更容易更改(参见 ["可演化性:让变更变得容易"](/ch2#sec_introduction_evolvability))。
* 事件日志还可以作为系统中发生的所有事情的审计日志,这在需要此类可审计性的受监管行业中很有价值。
然而,事件溯源和 CQRS 也有缺点:
* 如果涉及外部信息,你需要小心。例如,假设一个事件包含以一种货币给出的价格,对于其中一个视图,它需要转换为另一种货币。由于汇率可能会波动,在处理事件时从外部源获取汇率会有问题,因为如果你在另一个日期重新计算物化视图,你会得到不同的结果。为了使事件处理逻辑具有确定性,你要么需要在事件本身中包含汇率,要么有办法查询事件中指示的时间戳处的历史汇率,确保此查询对于相同的时间戳始终返回相同的结果。
* 事件不可变的要求在事件包含用户个人数据时产生问题,因为用户可能行使他们的权利(例如,根据 GDPR请求删除他们的数据。如果事件日志是基于每个用户的你可以删除该用户的整个日志但如果你的事件日志包含与多个用户相关的事件这就不起作用了。你可以尝试将个人数据存储在实际事件之外使用你以后可以选择删除的密钥对其进行加密,但这也使得在需要时重新计算派生状态变得更加困难
* 如果有外部可见的副作用,重新处理事件需要谨慎——例如,你可能不希望每次重建物化视图时都重新发送确认电子邮件。
* 如果涉及外部信息,你需要小心。例如,假设一个事件包含以一种货币给出的价格,对于其中一个视图,它需要转换为另一种货币。由于汇率可能会波动,在处理事件时从外部源获取汇率会有问题,因为如果你在另一个日期重新计算物化视图,你会得到不同的结果。为了使事件处理逻辑具有确定性,你要么需要在事件本身中包含汇率,要么有一种方法来查询事件中指示的时间戳处的历史汇率,确保此查询始终为相同的时间戳返回相同的结果。
* 事件不可变的要求在事件包含用户个人数据时产生问题,因为用户可能行使他们的权利(例如,根据 GDPR请求删除他们的数据。如果事件日志是基于每个用户的你可以删除该用户的整个日志但如果你的事件日志包含与多个用户相关的事件这就不起作用了。你可以尝试将个人数据存储在实际事件之外者使用密钥对其进行加密,你可以稍后选择删除该密钥,但这也使得在需要时更难重新计算派生状态
* 如果存在外部可见的副作用,重新处理事件需要小心 —— 例如,你可能不希望每次重建物化视图时都重新发送确认电子邮件。
你可以在任何数据库之上实现事件溯源,但也有一些专门设计用于支持此模式的系统,例如 EventStoreDB、MartenDB基于 PostgreSQL和 Axon Framework。你还可以使用消息代理如 Apache Kafka来存储事件日志流处理器可以保持物化视图是最新的;我们将在 [Link to Come] 中回到这些主题。
你可以在任何数据库之上实现事件溯源,但也有一些专门设计来支持这种模式的系统,例如 EventStoreDB、MartenDB基于 PostgreSQL和 Axon Framework。你还可以使用消息代理如 Apache Kafka来存储事件日志流处理器可以使物化视图保持最新;我们将在 [待补充链接] 中返回这些主题。
唯一重要的要求是事件存储系统必须保证所有物化视图以与它们在日志中出现的完全相同的顺序处理事件;正如我们将在[第 10 章](/ch10#ch_consistency)中看到的,这在分布式系统中并不总是容易实现
唯一重要的要求是事件存储系统必须保证所有物化视图以与它们在日志中出现的完全相同的顺序处理事件;正如我们将在 [第 10 章](/ch10#ch_consistency) 中看到的,这在分布式系统中并不总是容易实现。
## 数据框、矩阵与数组 {#sec_datamodels_dataframes}
本章到目前为止我们看到的数据模型通常既用于事务处理也用于分析目的(参见[「分析型与事务型系统」](/ch1#sec_introduction_analytics))。还有一些数据模型你可能会在分析或科学背景下遇到,但很少出现在 OLTP 系统中:数据框和多维数值数组(如矩阵)。
到目前为止,我们在本章中看到的数据模型通常用于事务处理和分析目的(参见 ["分析与运营系统"](/ch1#sec_introduction_analytics))。还有一些数据模型你可能会在分析或科学环境中遇到,但很少出现在 OLTP 系统中:数据框和多维数字数组(如矩阵)。
数据框是 R 语言、Python 的 Pandas 库、Apache Spark、ArcticDB、Dask 和其他系统支持的数据模型。它们是数据科学家准备数据以训练机器学习模型的流行工具,但它们也广泛用于数据探索、统计数据分析、数据可视化和类似目的。
数据框是 R 语言、Python 的 Pandas 库、Apache Spark、ArcticDB、Dask 和其他系统支持的数据模型。它们是数据科学家为训练机器学习模型准备数据的流行工具,但它们也广泛用于数据探索、统计数据分析、数据可视化和类似目的。
乍一看,数据框类似于关系数据库中的表或电子表格。它支持对数据框内容执行批量操作的类关系算子:例如,对所有行应用函数、基于某些条件过滤行、按某些列分组行并聚合其他列,以及基于某个键将一个数据框中的行与另一个数据框连接(关系数据库称为**连接**的操作在数据框中通常称为**合并**)。
乍一看,数据框类似于关系数据库中的表或电子表格。它支持对数据框内容执行批量操作的类关系运算符:例如,将函数应用于所有行、基于某些条件过滤行、按某些列对行进行分组并聚合其他列,以及基于某个键将一个数据框中的行与另一个数据框连接(关系数据库称为 *连接* 的操作在数据框上通常称为 *合并*)。
数据框通常不是通过 SQL 等声明式查询来操作,而是通过一系列修改其结构和内容的命令来操作。这符合数据科学家的典型工作流程,他们逐步"整理"数据,使其成为能够找到他们所提问题答案的形式。这些操作通常在数据科学家的私有数据集副本上进行,通常在他们的本地机器上,尽管最终结果可能与其他用户共享。
数据框通常不是通过声明式查询(如 SQL而是通过一系列修改其结构和内容的命令来操作。这符合数据科学家的典型工作流程,他们逐步"整理"数据,使其成为能够找到他们所提问题答案的形式。这些操作通常在数据科学家的数据集私有副本上进行,通常在他们的本地机器上,尽管最终结果可能与其他用户共享。
数据框 API 还提供了远远超出关系数据库提供的各种操作,数据模型的使用方式通常与典型的关系数据建模截然不同 [^65]。例如,数据框的一个常见用途是将数据从类关系表示转换为矩阵或多维数组表示,这是许多机器学习算法期望的输入形式。
数据框 API 还提供了远远超出关系数据库提供的各种操作,数据模型的使用方式通常与典型的关系数据建模非常不同 [^65]。例如,数据框的常见用途是将数据从类似关系的表示转换为矩阵或多维数组表示,这是许多机器学习算法期望的输入形式。
[图 3-9](/ch3#fig_dataframe_to_matrix) 显示了这种转换的简单示例。左边我们有一个不同用户如何对各种电影进行评分1 到 5 分)的关系表,右边数据已被转换为矩阵,其中每列是一部电影,每行是一个用户(类似于电子表格中的**透视表**)。矩阵是**稀疏的**,这意味着许多用户-电影组合没有数据,但这没关系。这个矩阵可能有数千列,因此不太适合关系数据库,但数据框和提供稀疏数组的库(如 Python 的 NumPy可以轻松处理这样的数据。
[图 3-9](/ch3#fig_dataframe_to_matrix) 显示了这种转换的简单示例。左侧是不同用户如何评价各种电影的关系表(评分为 1 到 5右侧数据已转换为矩阵其中每列是一部电影每行是一个用户类似于电子表格中的 *数据透视表*)。矩阵是 *稀疏*,这意味着许多用户-电影组合没有数据,但这没关系。这个矩阵可能有数千列,因此不太适合关系数据库,但数据框和提供稀疏数组的库(如 Python 的 NumPy可以轻松处理此类数据。
{{< figure src="/fig/ddia_0309.png" id="fig_dataframe_to_matrix" title="图 3-9. 将电影评分的关系数据库转换为矩阵表示。" class="w-full my-4" >}}
矩阵只能包含数字,各种技术用于将非数数据转换为矩阵中的数字。例如:
矩阵只能包含数字,各种技术用于将非数数据转换为矩阵中的数字。例如:
* 日期(在[图 3-9](/ch3#fig_dataframe_to_matrix) 的示例矩阵中省略)可以缩放为某个合适范围内的浮点数。
* 对于只能取一小组固定值之一的列(例如,电影数据库中电影的类型),通常使用**独热编码**:我们为每个可能的值创建一列(一个用于"喜剧",一个用于"剧",一个用于"恐怖"等),对于代表电影的每一行,我们在与该电影类型对应的列中放置 1在所有其他列中放置 0。这种表示也很容易推广到适合多种类型的电影。
* 日期(在 [图 3-9](/ch3#fig_dataframe_to_matrix) 的示例矩阵中省略)可以缩放为某个合适范围内的浮点数。
* 对于只能取一小组固定值之一的列(例如,电影数据库中电影的类型),通常使用 *独热编码*:我们为每个可能的值创建一列(一个用于"喜剧",一个用于"剧",一个用于"恐怖"等),对于代表电影的每一行,我们在对应于该电影类型的列中放置 1在所有其他列中放置 0。这种表示也很容易推广到适合多种类型的电影。
一旦数据以数字矩阵的形式存在,它就适合进行线性代数运算,这构成了许多机器学习算法的基础。例如,[图 3-9](/ch3#fig_dataframe_to_matrix) 中的数据可能是推荐用户可能喜欢的电影系统的一部分。数据框足够灵活,允许数据从关系形式逐渐演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示。
一旦数据以数字矩阵的形式存在,它就适合线性代数运算,这构成了许多机器学习算法的基础。例如,[图 3-9](/ch3#fig_dataframe_to_matrix) 中的数据可能是推荐用户可能喜欢的电影系统的一部分。数据框足够灵活,允许数据从关系形式逐渐演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示。
还有一些数据库(如 TileDB [^66])专门存储大型多维数值数组;它们被称为**数组数据库**,最常用于科学数据集,如地理空间测量(规则间隔网格上的栅格数据)、医学成像或天文望远镜的观测 [^67]。数据框也在金融行业用于表示**时间序列数据**,如资产价格和交易随时间的变化 [^68]。
还有像 TileDB [^66] 这样专门存储大型多维数字数组的数据库;它们被称为 *数组数据库*,最常用于科学数据集,如地理空间测量(规则间隔网格上的栅格数据)、医学成像或天文望远镜的观测 [^67]。数据框在金融行业也用于表示 *时间序列数据*,如资产价格和随时间变化的交易 [^68]。
## 总结 {#summary}
数据模型是一个庞大的主题,在本章中,我们快速浏览了各种不同的模型。我们没有空间详细介绍每个模型的所有细节,但希望这个概览已经足以激发你的兴趣,让你进一步了解最适合你应用需求的模型。
数据模型是一个巨大的主题,在本章中,我们快速浏览了各种不同的模型。我们没有空间深入每个模型的所有细节,但希望这个概述足以激发你的兴趣,找出最适合你的应用需求的模型。
**关系模型**尽管已有半个多世纪的历史,仍然是许多应用的重要数据模型——特别是在数据仓库和商业分析中,关系星型或雪花模式和 SQL 查询无处不在。然而,关系数据的几种替代方案也在其他领域变得流行:
*关系模型* 尽管已有半个多世纪的历史,但对许多应用来说仍然是一个重要的数据模型——特别是在数据仓库和商业分析中,关系星型或雪花模式和 SQL 查询无处不在。然而,关系数据的几种替代方案也在其他领域变得流行:
* **文档模型**针对数据以自包含 JSON 文档形式出现的用例,其中一个文档与另一个文档之间的关系很少。
* **图数据模型**走向相反的方向,针对任何事物都可能与任何事物相关的用例,以及查询可能需要遍历多个跳转以找到感兴趣的数据(可以使用 Cypher、SPARQL 或 Datalog 中的递归查询来表达)。
* **数据框**将关系数据推广到大量列,从而在数据库和构成许多机器学习、统计数据分析和科学计算基础的多维数组之间架起桥梁。
* *文档模型* 针对数据以独立的 JSON 文档形式出现的用例,以及一个文档与另一个文档之间的关系很少的情况
* *图数据模型* 走向相反的方向,针对任何东西都可能与一切相关的用例,以及查询可能需要遍历多个跳跃才能找到感兴趣的数据(可以使用 Cypher、SPARQL 或 Datalog 中的递归查询来表达)。
* *数据框* 将关系数据推广到大量列,从而在数据库和构成大量机器学习、统计数据分析和科学计算基础的多维数组之间提供桥梁。
在某种程度上,一个模型可以用另一个模型来模拟——例如,图数据可以在关系数据库中表示——但结果可能很别扭,正如我们在 SQL 中对递归查询的支持中看到的那样。
在某种程度上,一个模型可以用另一个模型来模拟——例如,图数据可以在关系数据库中表示——但结果可能很别扭,正如我们在 SQL 中对递归查询的支持中看到的那样。
因此,为每个数据模型开发了各种专门的数据库,提供针对特定模型优化的查询语言和存储引擎。然而,数据库也有一种趋势是通过添加对其他数据模型的支持来扩展到相邻领域:例如,关系数据库已经以 JSON 列的形式添加了对文档数据的支持,文档数据库已经添加了类似关系的连接SQL 中对图数据的支持也在逐渐改善
因此,为每个数据模型开发了各种专数据库,提供针对特定模型优化的查询语言和存储引擎。然而,数据库也有通过添加对其他数据模型的支持来扩展到相邻领域的趋势:例如,关系数据库以 JSON 列的形式添加了对文档数据的支持文档数据库添加了类似关系的连接SQL 中对图数据的支持也在逐步改进
我们讨论的另一个模型是**事件溯源**,它将数据表示为不可变事件的仅追加日志,这对于在复杂业务领域中建模活动可能是有利的。仅追加日志很适合写入数据(正如我们将在[第 4 章](/ch4#ch_storage)中看到的);为了支持高效查询,事件日志通过 CQRS 转换为读优化的物化视图。
我们讨论的另一个模型是 *事件溯源*,它将数据表示为不可变事件的仅追加日志,这对于建模复杂业务领域中的活动可能是有利的。仅追加日志有利于写入数据(正如我们将在 [第 4 章](/ch4#ch_storage) 中看到的);为了支持高效查询,事件日志通过 CQRS 转换为读优化的物化视图。
非关系数据模型的一个共同点是,它们通常不会对存储的数据强制执行模式,这可以使应用程序更容易适应不断变化的需求。然而,你的应用程序很可能仍然假设数据具有某种结构;这只是一个问题,即模式是显式的(在写入时强制执行)还是隐式的(在读取时假设)。
非关系数据模型的一个共同点是,它们通常不会对存储的数据强制执行模式,这可以使应用更容易适应不断变化的需求。然而,你的应用很可能仍然假设数据具有某种结构;这只是模式是显式的(在写入时强制执行)还是隐式的(在读取时假设)的问题
尽管我们已经涵盖了很多内容,但仍有一些数据模型未被提及。举几个简短的例子:
尽管我们涵盖了很多内容,但仍有数据模型未被提及。举几个简短的例子:
* 研究基因组数据的研究人员经常需要执行**序列相似性搜索**,这意味着取一个很长的字符串(代表 DNA 分子)并将其与大型字符串数据库进行匹配,这些字符串相似但不相同。这里描述的数据库都不能处理这种用法,这就是为什么研究人员编写了专门的基因组数据库软件,如 GenBank [^69]
* 许多金融系统使用带有复式记账的**账本**作为其数据模型。这种类型的数据可以在关系数据库中表示,但也有像 TigerBeetle 这样专门针对这种数据模型的数据库。加密货币和区块链通常基于分布式账本,它们也将价值转移内置到其数据模型中
* **全文搜索**可以说是一种经常与数据库一起使用的数据模型。信息检索是一个大型的专门主题,我们不会在本书中详细介绍,但我们将在[「全文搜索」](/ch4#sec_storage_full_text)中涉及搜索索引和向量搜索。
* 研究基因组数据的研究人员通常需要执行 *序列相似性搜索*,这意味着获取一个非常长的字符串(代表 DNA 分子)并将其与相似但不相同的大量字符串数据库进行匹配。这里描述的数据库都无法处理这种用法,这就是研究人员编写了像 GenBank [^69] 这样的专门基因组数据库软件的原因
* 许多金融系统使用具有复式记账的 *账本* 作为其数据模型。这种类型的数据可以在关系数据库中表示,但也有像 TigerBeetle 这样专门研究这种数据模型的数据库。加密货币和区块链通常基于分布式账本,它们的数据模型中也内置了价值转移
* *全文搜索* 可以说是一种经常与数据库一起使用的数据模型。信息检索是一个大型的专业主题,我们不会在本书中详细介绍,但我们将在 ["全文搜索"](/ch4#sec_storage_full_text) 中涉及搜索索引和向量搜索。
我们现在必须就此打住。在下一章中,我们将讨论在**实现**本章描述的数据模型时涉及的一些权衡。
我们现在必须到此为止了。在下一章中,我们将讨论在 *实现* 本章中描述的数据模型时出现的一些权衡。
### 参考
### 参考文献
[^1]: Jamie Brandon. [Unexplanations: query optimization works because sql is declarative](https://www.scattered-thoughts.net/writing/unexplanations-sql-declarative/). *scattered-thoughts.net*, February 2024. Archived at [perma.cc/P6W2-WMFZ](https://perma.cc/P6W2-WMFZ)
[^2]: Joseph M. Hellerstein. [The Declarative Imperative: Experiences and Conjectures in Distributed Logic](https://www2.eecs.berkeley.edu/Pubs/TechRpts/2010/EECS-2010-90.pdf). Tech report UCB/EECS-2010-90, Electrical Engineering and Computer Sciences, University of California at Berkeley, June 2010. Archived at [perma.cc/K56R-VVQM](https://perma.cc/K56R-VVQM)

View File

@ -6,20 +6,20 @@ breadcrumbs: false
![](/map/ch03.png)
> *生活中的一大痛苦是,每个人给事物起的名字都有一点点不对。这让世界上的一切都比换个名字后更难理解。计算机主要并不是在算术意义上进行计算。[...] 它们主要是归档系统。*
> *生活的苦恼之一是,每个人对事物的命名都有些偏差。这让我们理解世界变得比本该有的样子困难一些,要是命名方式不同就好了。计算机的主要功能并不是传统意义上的计算,比如算术运算。[……] 它们主要是归档系统。*
>
> [Richard Feynman](https://www.youtube.com/watch?v=EKWGGDXe5MA&t=296s)
> *特立独行的思* 研讨会1985
> [理查德·费曼](https://www.youtube.com/watch?v=EKWGGDXe5MA&t=296s)
> *特立独行的思* 研讨会1985
在最基础的层面上,数据库需要做两件事:当你给它一些数据时,它应该存储这些数据,当你稍后再次询问时,它应该把数据返回给你。
在最基础的层面上,数据库需要做两件事:当你给它一些数据时,它应该存储这些数据;当你之后再询问时,它应该把数据返回给你。
在 [三章](/ch3#ch_datamodels) 中,我们讨论了数据模型和查询语言——即你向数据库提供数据的格式,以及稍后再次请求数据的接口。在本章中,我们从数据库的角度讨论同样的问题:数据库如何存储你提供给它的数据,以及当你请求时如何再次找到数据。
在 [ 3 章](/ch3#ch_datamodels) 中,我们讨论了数据模型和查询语言 —— 即你向数据库提供数据的格式,以及之后再次请求数据的接口。在本章中,我们从数据库的角度讨论同样的问题:数据库如何存储你提供的数据,以及当你请求时如何再次找到这些数据。
作为应用程序开发人员,为什么要关心数据库内部如何处理存储和检索?你可能不会从头开始实现自己的存储引擎,但你 *确实* 需要从众多可用的存储引擎中选择一个适合你应用程序的。为了配置存储引擎在你的工作负载类型上表现良好,你需要对存储引擎在底层做什么有个大致的了解。
作为应用开发者,你为什么要关心数据库内部如何处理存储和检索?你可能不会从头开始实现自己的存储引擎,但你 *确实* 需要从众多可用的存储引擎中选择一个适合你应用的。为了让存储引擎在你的工作负载类型上表现良好,你需要对存储引擎在底层做什么有个大致的了解。
特别是,针对事务性工作负载OLTP优化的存储引擎与针对分析优化的存储引擎之间存在很大差异我们在 ["分析与操作系统"](/ch1#sec_introduction_analytics) 中介绍了这种区别)。本章首先研究两种 OLTP 存储引擎系列:写出不可变数据文件的 *日志结构* 存储引擎,以及像 *B 树* 这样就地更新数据的存储引擎。这些结构既用于键值存储,也用于二级索引。
特别是,针对事务型工作负载OLTP优化的存储引擎和针对分析型工作负载优化的存储引擎之间存在巨大差异我们在 ["分析型与事务型系统"](/ch1#sec_introduction_analytics) 中介绍了这种区别)。本章首先研究两种用于 OLTP 的存储引擎家族:写入不可变数据文件的 *日志结构* 存储引擎,以及像 *B 树* 这样就地更新数据的存储引擎。这些结构既用于键值存储,也用于二级索引。
后在 ["分析型数据存储"](/ch4#sec_storage_analytics) 中,我们将讨论一系列针对分析优化的存储引擎,在 ["多维和全文索引"](/ch4#sec_storage_multidimensional) 中,我们将简要介绍用于更高级查询(如文本检索)的索引。
后在 ["分析型数据存储"](#sec_storage_analytics) 中,我们将讨论一系列针对分析优化的存储引擎;在 ["多维索引与全文索引"](#sec_storage_multidimensional) 中,我们将简要介绍用于更高级查询(如文本检索)的索引。
## OLTP 系统的存储与索引 {#sec_storage_oltp}
@ -37,9 +37,9 @@ db_get () {
}
```
这两个函数实现了一个键值存储。你可以调用 `db_set key value`,它在数据库中存储 `key``value`。键和值可以是(几乎)任何你喜欢的东西——例如,值可以是 JSON 文档。然后你可以调用 `db_get key`,它会查找与该特定键关联的最新值并返回它。
这两个函数实现了一个键值存储。你可以调用 `db_set key value`,它在数据库中存储 `key``value`。键和值可以是(几乎)任何你喜欢的内容 —— 例如,值可以是一个 JSON 文档。然后你可以调用 `db_get key`,它会查找与该特定键关联的最新值并返回它。
它确实有效
它确实能工作
```bash
$ db_set 12 '{"name":"London","attractions":["Big Ben","London Eye"]}'
@ -50,7 +50,7 @@ $ db_get 42
{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
```
存储格式非常简单:一个文本文件,每行包含一个键值对,用逗号分隔(大致类似 CSV 文件,忽略转义问题)。每次调用 `db_set` 都会追加到文件末尾。如果你多次更新一个键,值的旧版本不会被覆盖——你需要查看文件中键的最后一次出现来找到最新值(因此 `db_get` `tail -n 1`
存储格式非常简单:一个文本文件,每行包含一个键值对,用逗号分隔(大致类似 CSV 文件,忽略转义问题)。每次调用 `db_set` 都会追加到文件末尾。如果你多次更新一个键,旧版本的值不会被覆盖 —— 你需要查看文件中键的最后一次出现来找到最新值(因此 `db_get`使用了 `tail -n 1`
```bash
$ db_set 42 '{"name":"San Francisco","attractions":["Exploratorium"]}'
@ -65,303 +65,303 @@ $ cat database
```
对于如此简单的东西,`db_set` 函数实际上具有相当好的性能,因为追加到文件通常非常高效。与 `db_set` 所做的类似,许多数据库内部使用 *日志*,这是一个仅追加的数据文件。真正的数据库需要处理更多问题(如处理并发写入、回收磁盘空间以免日志无限增长,以及在崩溃恢复时处理部分写入的记录),但基本原理是相同的。日志非常有用,我们将在本书中多次遇到它们。
对于如此简单的实现,`db_set` 函数实际上有相当好的性能,因为追加到文件通常非常高效。与 `db_set` 所做的类似,许多数据库内部使用 *日志*,这是一个仅追加的数据文件。真正的数据库有更多问题要处理(如处理并发写入、回收磁盘空间以防日志无限增长,以及从崩溃中恢复时处理部分写入的记录),但基本原理是相同的。日志非常有用,我们将在本书中多次遇到它们。
---------
> [!NOTE]
> *日志* 这个词通常用于指应用程序日志,其中应用程序输出描述正在发生的事情的文本。在本书中,*日志* 用于更一般的意义:磁盘上仅追加的记录序列。它不必是人类可读的;它可能是二进制的,仅供数据库系统内部使用。
> *日志* 这个词通常用于指应用程序日志,应用程序输出描述正在发生什么的文本。在本书中,*日志* 用于更一般的含义:磁盘上仅追加的记录序列。它不一定是人类可读的;它可能是二进制的,仅供数据库系统内部使用。
--------
另一方面,如果你的数据库中有大量记录,`db_get` 函数的性能会很糟糕。每次你想查找一个键时,`db_get` 必须从头到尾扫描整个数据库文件,寻找键的出现。用算法术语来说,查找的成本是 *O*(*n*):如果你数据库中的记录数 *n* 翻倍,查找需要两倍的时间。这不好。
另一方面,如果你的数据库中有大量记录,`db_get` 函数的性能会很糟糕。每次你想查找一个键时,`db_get` 必须从头到尾扫描整个数据库文件,寻找键的出现。用算法术语来说,查找的成本是 *O*(*n*):如果你数据库中的记录数 *n* 翻倍,查找时间也会翻倍。这不好。
为了高效地找到数据库中特定键的值,我们需要一种不同的数据结构:*索引*。在本章中,我们将研究一系列索引结构并比较它们;总体思路是以特定方式(例如,按某个键排序)构建数据,使定位所需数据更快。如果你想以几种不同的方式搜索相同的数据,你可能需要在数据的不同部分上建立几个不同的索引。
为了高效地找到数据库中特定键的值,我们需要一个不同的数据结构:*索引*。在本章中,我们将研究一系列索引结构并了解它们的比较;一般思想是以特定方式(例如,按某个键排序)构建数据,使定位所需数据更快。如果你想以几种不同的方式搜索相同的数据,你可能需要在数据的不同部分上建立几个不同的索引。
索引是从主数据派生*额外* 结构。许多数据库允许你添加和删除索引,这不会影响数据库的内容;它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。对于写入,很难超越简单追加到文件的性能,因为这是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时也需要更新索引。
索引是从主数据衍生出*额外* 结构。许多数据库允许你添加和删除索引,这不会影响数据库的内容;它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。对于写入,很难超越简单追加到文件的性能,因为这是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时也需要更新索引。
这是存储系统中的一个重要权衡:精心选择的索引可以加速读查询,但每个索引都会消耗额外的磁盘空间并减慢写入速度,有时会大幅减慢 [^1]。因此,数据库通常不会默认为所有内容建立索引,而是要你——编写应用程序或管理数据库的人——使用你对应用程序典型查询模式的了解来手动选择索引。然后你可以选择为你的应用程序带来最大收益的索引,而不会在写入时引入超过必要的开销。
这是存储系统中的一个重要权衡:精心选择的索引加快了读查询速度,但每个索引都会消耗额外的磁盘空间并减慢写入速度,有时会大幅减慢 [^1]。因此,数据库通常不会默认为所有内容建立索引,而是要 —— 编写应用程序或管理数据库的人 —— 使用你对应用程序典型查询模式的了解来手动选择索引。然后你可以选择为你的应用程序带来最大收益的索引,而不会引入超过必要的写入开销。
### 日志结构存储 {#sec_storage_log_structured}
首先,让我们假设你想继续将数据存储在 `db_set` 写入的仅追加文件中,只是想加速读取。你可以通过在内存中保留一个哈希映射来做到这一点,其中每个键都映射到文件中可以找到该键最新值的字节偏移量,如 [图 4-1](/ch4#fig_storage_csv_hash_index) 所示。
首先,让我们假设你想继续将数据存储在 `db_set` 写入的仅追加文件中,你只是想加快读取速度。一种方法是在内存中保留一个哈希映射,其中每个键都映射到文件中可以找到该键最新值的字节偏移量,如 [图 4-1](#fig_storage_csv_hash_index) 所示。
{{< figure src="/fig/ddia_0401.png" id="fig_storage_csv_hash_index" caption="图 4-1. 以类似 CSV 格式存储键值对日志,使用内存中的哈希映射进行索引。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0401.png" id="fig_storage_csv_hash_index" caption="图 4-1. 以类似 CSV 格式存储键值对日志,使用内存哈希映射建立索引。" class="w-full my-4" >}}
每当你向文件追加新的键值对时,你也会更新哈希映射以反映刚刚写入数据的偏移量。当你想查找一个值时,你使用哈希映射找到日志文件中的偏移量,寻址到该位置,然后读取值。如果数据文件的该部分已经在文件系统缓存中,读取根本不需要任何磁盘 I/O。
每当你向文件追加新的键值对时,你也会更新哈希映射以反映刚刚写入数据的偏移量。当你想查找一个值时,你使用哈希映射找到日志文件中的偏移量,寻找到该位置,然后读取值。如果数据文件的那部分已经在文件系统缓存中,读取根本不需要任何磁盘 I/O。
这种方法要快得多,但仍然存在几个问题:
这种方法速度更快,但仍然存在几个问题:
* 你永远不会释放被覆盖的旧日志条目占用的磁盘空间;如果你持续写入数据库,可能会耗尽磁盘空间。
* 哈希映射不是持久化的,所以当你重启数据库时必须重建它——例如,通过扫描整个日志文件来找到每个键的最新字节偏移量。如果你有大量数据,这会使重启变慢。
* 哈希表必须适合内存。原则上,你可以在磁盘上维护哈希表,但不幸的是很难使磁盘上的哈希映射表现良好。它需要大量的随机访问 I/O当它变满时扩展成本很高,哈希冲突需要繁琐的逻辑 [^2]。
* 范围查询效率不高。例如,你不能轻松扫描 `10000``19999` 之间的所有键——你必须在哈希映射中单独查找每个键。
* 你永远不会释放被覆盖的旧日志条目占用的磁盘空间;如果你不断写入数据库,可能会耗尽磁盘空间。
* 哈希映射不是持久化的,所以当你重启数据库时必须重建它 —— 例如,通过扫描整个日志文件来找到每个键的最新字节偏移量。如果你有大量数据,这会使重启变慢。
* 哈希表必须适合内存。原则上,你可以在磁盘上维护哈希表,但不幸的是,很难让磁盘上的哈希映射表现良好。它需要大量的随机访问 I/O当它变满时扩展成本高昂,哈希冲突需要复杂的逻辑 [^2]。
* 范围查询效率不高。例如,你不能轻松扫描 `10000``19999` 之间的所有键 —— 你必须在哈希映射中单独查找每个键。
#### SSTable 文件格式 {#the-sstable-file-format}
实际上,哈希表很少用于数据库索引,相反,保持数据 *键排序* 的结构更为常见 [^3]。这种结构的一个例子是 *排序字符串表*,简称 *SSTable*,如 [图 4-2](/ch4#fig_storage_sstable_index) 所示。这种文件格式也存储键值对,但它确保它们按键排序,每个键在文件中只出现一次。
实际上,哈希表很少用于数据库索引,相反,保持数据 *键排序* 的结构更为常见 [^3]。这种结构的一个例子是 *排序字符串表**Sorted String Table*,简称 *SSTable*,如 [图 4-2](#fig_storage_sstable_index) 所示。这种文件格式也存储键值对,但它确保它们按键排序,每个键在文件中只出现一次。
{{< figure src="/fig/ddia_0402.png" id="fig_storage_sstable_index" caption="图 4-2. 带有稀疏索引的 SSTable允许查询跳转到正确的块。" class="w-full my-4" >}}
现在你不需要在内存中保留所有键:你可以将 SSTable 中的键值对分组到几千字节的 *块*,然后在索引中存储每个块的第一个键。这种只存储部分键的索引称为 *稀疏* 索引。该索引存储在 SSTable 的单独部分中,例如使用不可变 B 树、trie 或其他允许查询快速查找特定键的数据结构 [^4]。
现在你不需要在内存中保留所有键:你可以将 SSTable 中的键值对分组为几千字节的 *块*,然后在索引中存储每个块的第一个键。这种只存储部分键的索引称为 *稀疏* 索引。这个索引存储在 SSTable 的单独部分,例如使用不可变 B 树、字典树或其他允许查询快速查找特定键的数据结构 [^4]。
例如,在 [图 4-2](/ch4#fig_storage_sstable_index) 中,一个块的第一个键是 `handbag`,下一个块的第一个键是 `handsome`。现在假设你要查找键 `handiwork`,它出现在稀疏索引中。由于排序,你知道 `handiwork` 必须出现在 `handbag``handsome` 之间。这意味着你可以寻址到 `handbag` 的偏移量,并从那里扫描文件,直到找到 `handiwork`(或没有,如果键不在文件中)。几千字节的块可以非常快速地扫描。
例如,在 [图 4-2](#fig_storage_sstable_index) 中,一个块的第一个键是 `handbag`,下一个块的第一个键是 `handsome`。现在假设你要查找键 `handiwork`,它没有出现在稀疏索引中。由于排序,你知道 `handiwork` 必须出现在 `handbag``handsome` 之间。这意味着你可以寻找到 `handbag` 的偏移量,然后从那里扫描文件,直到找到 `handiwork`(或没有,如果键不在文件中)。几千字节的块可以非常快速地扫描。
此外,每个记录块可以被压缩(如 [图 4-2](/ch4#fig_storage_sstable_index) 中的阴影区域所示)。除了节省磁盘空间外,压缩还减少了 I/O 带宽使用,代价是使用更多的 CPU 时间。
此外,每个记录块都可以压缩(在 [图 4-2](#fig_storage_sstable_index) 中用阴影区域表示)。除了节省磁盘空间外,压缩还减少了 I/O 带宽使用,代价是使用更多一点的 CPU 时间。
#### 构建和合并 SSTable {#constructing-and-merging-sstables}
SSTable 文件格式比仅追加日志更适合读取,但它使写入更加困难。我们不能简单地追加到末尾,因为那样文件将不再排序(除非键恰好按升序写入)。如果每次在中间某处插入键时都必须重写整个 SSTable写入将变得太昂贵。
SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更加困难。我们不能简单地追加到末尾,因为那样文件就不再排序了(除非键恰好按升序写入)。如果我们每次在中间某处插入键时都必须重写整个 SSTable写入将变得太昂贵。
我们可以用 *日志结构* 方法解决这个问题,这是仅追加日志和排序文件之间的混合:
1. 当写入到来时,将其添加到内存中的有序映射数据结构,如红黑树、跳表 [^5] 或 trie [^6]。使用这些数据结构,你可以按任何顺序插入键,高效地查找它们,并按排序顺序读回它们。这个内存中的数据结构称为 *内存表*
2. 当内存表变得大于某个阈值——通常是几兆字节——将其按排序顺序作为 SSTable 文件写入磁盘。我们将这个新的 SSTable 文件称为数据库的最新 *段*,它与旧段一起作为单独的文件存储。每个段都有内容的单独索引。当新段被写入磁盘时,数据库可以继续写入新的内存表实例,当 SSTable 写入完成时,旧内存表的内存被释放。
3. 为了读取某个键的值,首先尝试在内存表和最新的磁盘段中查找键。如果不在那里,查看下一个较旧的段,等等,直到找到键或到达最旧的段。如果键不出现在任何段中,它就不存在于数据库中。
4. 不时地,在后台运行合并和压缩过程来合并段文件并丢弃被覆盖或删除的值。
1. 当写入操作到来时,将其添加到内存中的有序映射数据结构中,例如红黑树、跳表 [^5] 或字典树 [^6]。使用这些数据结构,你可以按任意顺序插入键,高效地查找它们,并按排序顺序读回它们。这个内存数据结构称为 *内存表**memtable*
2. 当内存表变得大于某个阈值(通常是几兆字节)时,将其按排序顺序作为 SSTable 文件写入磁盘。我们将这个新的 SSTable 文件称为数据库的最新 *段*,它与旧段一起作为单独的文件存储。每个段都有自己内容的单独索引。当新段被写入磁盘时,数据库可以继续写入新的内存表实例,当 SSTable 写入完成时,旧内存表的内存被释放。
3. 为了读取某个键的值,首先尝试在内存表和最新的磁盘段中找到该键。如果没有找到,就在下一个较旧的段中查找,依此类推,直到找到键或到达最旧的段。如果键没有出现在任何段中,则它不存在于数据库中。
4. 不时地在后台运行合并和压实过程,以合并段文件并丢弃被覆盖或删除的值。
合并段的工作类似于 *归并排序* 算法 [^5]。该过程如 [图 4-3](/ch4#fig_storage_sstable_merging) 所示:并排开始读取输入文件,查看每个文件中的第一个键,将最低键(根据排序顺序)复制到输出文件,然后重复。如果同一个键出现在多个输入文件中,只保留较新的值。这产生一个新的合并段文件,也按键排序,每个键一个值,并且使用最少的内存,因为我们可以一次迭代一个键的 SSTable。
合并段的工作方式类似于 *归并排序* 算法 [^5]。该过程如 [图 4-3](#fig_storage_sstable_merging) 所示:并排开始读取输入文件,查看每个文件中的第一个键,将最低键(根据排序顺序)复制到输出文件,然后重复。如果同一个键出现在多个输入文件中,只保留较新的值。这产生一个新的合并段文件,也按键排序,每个键只有一个值,并且它使用最少的内存,因为我们可以一次遍历一个键的 SSTable。
{{< figure src="/fig/ddia_0403.png" id="fig_storage_sstable_merging" caption="图 4-3. 合并几个 SSTable 段,只保留每个键的最新值。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0403.png" id="fig_storage_sstable_merging" caption="图 4-3. 合并多个 SSTable 段,仅保留每个键的最新值。" class="w-full my-4" >}}
为了确保内存表中的数据在数据库崩溃时不会丢失,存储引擎在磁盘上保留一个单独的日志,每次写入都立即追加到该日志。此日志不按键排序,但这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每次内存表被写出到 SSTable 后,日志的相应部分可以丢弃。
为了确保数据库崩溃时内存表中的数据不会丢失,存储引擎在磁盘上保留一个单独的日志,每次写入都会立即追加到该日志中。此日志不按键排序,但这无关紧要,因为它的唯一目的是在崩溃后恢复内存表。每次内存表被写出到 SSTable 后,日志的相应部分可以丢弃。
如果你想删除一个键及其关联的值,你必须向数据文件追加一个称为 *墓碑* 的特殊删除记录。当日志段合并时,墓碑告诉合并过程丢弃已删除键的任何先前值。一旦墓碑合并到最旧的段中,它就可以被丢弃。
如果你想删除一个键及其关联的值,你必须向数据文件追加一个称为 *墓碑**tombstone*的特殊删除记录。当日志段合并时,墓碑告诉合并过程丢弃已删除键的任何先前值。一旦墓碑合并到最旧的段中,它就可以被丢弃。
这里描述的算法本上就是 RocksDB [^7]、Cassandra、Scylla 和 HBase [^8] 中使用的算法,所有这些都受到 Google 的 Bigtable 论文 [^9] 的启发(该论文引入了术语 *SSTable**memtable*)。
这里描述的算法本上就是 RocksDB [^7]、Cassandra、Scylla 和 HBase [^8] 中使用的算法,它们都受到 Google 的 Bigtable 论文 [^9] 的启发(该论文引入了 *SSTable**memtable* 这两个术语)。
该算法最初于 1996 年以 *日志结构合并树**LSM 树* 的名称发表 [^10],建立在早期日志结构文件系统工作的基础上 [^11]。因此,基于合并和压缩排序文件原理的存储引擎通常称为 *LSM 存储引擎*
该算法最初于 1996 年以 *日志结构合并树**Log-Structured Merge-Tree*)或 *LSM 树**LSM-Tree*[^10] 的名称发布,建立在早期日志结构文件系统工作的基础上 [^11]。因此,基于合并和压实排序文件原理的存储引擎通常被称为 *LSM 存储引擎*
在 LSM 存储引擎中,段文件是一次写入的(通过写出内存表或通过合并一些现有段),此后它是不可变的。段的合并和压可以在后台线程中完成,当它进行时,我们仍然可以使用旧段文件继续提供读取服务。当合并过程完成时,我们将读取请求切换到使用新的合并段而不是旧段,然后可以删除旧段文件。
在 LSM 存储引擎中,段文件是一次写入的(通过写出内存表或合并一些现有段),此后它是不可变的。段的合并和压可以在后台线程中完成,当它进行时,我们仍然可以使用旧段文件继续提供读取服务。当合并过程完成时,我们将读取请求切换到使用新的合并段而不是旧段,然后可以删除旧段文件。
段文件不一定必须存储在本地磁盘上它们也非常适合写入对象存储。例如SlateDB 和 Delta Lake [^12] 采用了这种方法。
有不可变段文件也简化了崩溃恢复:如果在写出内存表或合并段时发生崩溃,数据库可以删除未完成的 SSTable 并重新开始。持久化写入到内存表的日志如果在写入记录的中发生崩溃,或者磁盘已满,可能包含不完整的记录;这些通常通过在日志中包含校验和来检测,并丢弃损坏或不完整的日志条目。我们将在 [第 8 章](/ch8#ch_transactions) 中更多地讨论持久性和崩溃恢复。
有不可变段文件也简化了崩溃恢复:如果在写出内存表或合并段时发生崩溃,数据库可以删除未完成的 SSTable 并重新开始。将写入持久化到内存表的日志如果在写入记录的过程中发生崩溃,或者磁盘已满,可能包含不完整的记录;这些通常通过在日志中包含校验和来检测,并丢弃损坏或不完整的日志条目。我们将在 [第 8 章](/ch8#ch_transactions) 中更多地讨论持久性和崩溃恢复。
#### 布隆过滤器 {#bloom-filters}
使用 LSM 存储,读取很久以前最后更新或不存在的键可能会很慢,因为存储引擎需要检查几个段文件。为了加速此类读取LSM 存储引擎通常在每个段中包含一个 *布隆过滤器* [^13],它提供了一种快速但近似的方法来检查特定键是否出现在特定 SSTable 中。
使用 LSM 存储,读取很久以前更新的键或不存在的键可能会很慢,因为存储引擎需要检查多个段文件。为了加快此类读取LSM 存储引擎通常在每个段中包含一个 *布隆过滤器**Bloom filter*[^13],它提供了一种快速但近似的方法来检查特定键是否出现在特定 SSTable 中。
[图 4-4](/ch4#fig_storage_bloom) 显示了一个包含两个键和 16 位的布隆过滤器示例(实际上,它会包含更多键和更多位)。对于 SSTable 中的每个键,我们计算一个哈希函数,生一组数字,然后将其解释为位数组的索引 [^14]。我们将对应于这些索引的位设置为 1其余保留为 0。例如`handbag` 哈希到数字2、9、4所以我们将第 2、9 和 4 位设置为 1。然后将位图作为 SSTable 的一部分存储,与键的稀疏索引一起。这需要一些额外的空间,但与 SSTable 的其余部分相比,布隆过滤器通常很小。
[图 4-4](#fig_storage_bloom) 显示了一个包含两个键和 16 位的布隆过滤器示例(实际上,它会包含更多键和更多位)。对于 SSTable 中的每个键,我们计算一个哈希函数,生一组数字,然后将其解释为位数组的索引 [^14]。我们将对应于这些索引的位设置为 1其余保持为 0。例如`handbag` 哈希为数字 (2, 9, 4),所以我们将第 2、9 和 4 位设置为 1。然后将位图与键的稀疏索引一起存储为 SSTable 的一部分。这需要一点额外的空间,但与 SSTable 的其余部分相比,布隆过滤器通常很小。
{{< figure src="/fig/ddia_0404.png" id="fig_storage_bloom" caption="图 4-4. 布隆过滤器提供了一种快速的概率检查,以确定特定键是否存在于特定 SSTable 中。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0404.png" id="fig_storage_bloom" caption="图 4-4. 布隆过滤器提供了一种快速的概率检查,用于判断特定键是否存在于特定 SSTable 中。" class="w-full my-4" >}}
当我们想知道一个键是否出现在 SSTable 中时,我们像以前一样计算该键的相同哈希,并检查这些索引处的位。例如,在 [图 4-4](/ch4#fig_storage_bloom) 中,我们正在查询键 `handheld`它哈希到6、11、2。其中一个位是 1即第 2 位),而另外两个是 0。这些检查可以使用所有 CPU 支持的位运算极其快速地进行。
当我们想知道一个键是否出现在 SSTable 中时,我们像以前一样计算该键的相同哈希,并检查这些索引处的位。例如,在 [图 4-4](#fig_storage_bloom) 中,我们查询键 `handheld`,它哈希为 (6, 11, 2)。其中一个位是 1即第 2 位),而另外两个是 0。这些检查可以使用所有 CPU 都支持的位运算非常快速地进行。
如果至少有一个位是 0我们知道键肯定不出现在 SSTable 中。如果查询中的位都是 1键很可能在 SSTable 中,但也可能巧合地所有这些位都被其他键设置为 1。这种看起来好像键存在即使它不存在的情况称为 *误报*
如果至少有一个位是 0我们知道键肯定不在 SSTable 中。如果查询中的位都是 1那么该键很可能在 SSTable 中,但也有可能是巧合,所有这些位都被其他键设置为 1。这种看起来键存在但实际上不存在的情况称为 *假阳性**false positive*
误报的概率取决于键的数量、每个键设置的位数和布隆过滤器中的总位数。你可以使用在线计算器工具来计算适合你应用程序的正确参数 [^15]。作为经验法则,你需要为 SSTable 中的每个键分配 10 位布隆过滤器空间,以获得 1% 的误报概率,每为每个键分配额外的 5 位,概率降低十倍。
假阳性的概率取决于键的数量、每个键设置的位数和布隆过滤器中的总位数。你可以使用在线计算器工具为你的应用计算出正确的参数 [^15]。作为经验法则,你需要为 SSTable 中的每个键分配 10 位布隆过滤器空间以获得 1% 的假阳性概率,每为每个键分配额外的 5 位,概率就会降低十倍。
在 LSM 存储引擎的上下文中,误报没有问题:
在 LSM 存储引擎的上下文中,假阳性没有问题:
* 如果布隆过滤器说键 *不* 存在,我们可以安全地跳过该 SSTable因为我们可以确定它不包含该键。
* 如果布隆过滤器说键 *存在*,我们必须查阅稀疏索引并解码键值对块以检查键是否真的在那里。如果是误报,我们做了一些不必要的工作,但除此之外没有造成伤害——我们只是继续搜索下一个最旧的段
* 如果布隆过滤器说键 *存在*,我们必须查询稀疏索引并解码键值对块以检查键是否真的在那里。如果是假阳性,我们做了一些不必要的工作,但除此之外没有害处 —— 我们只是继续使用下一个最旧的段进行搜索
#### 压实策略 {#sec_storage_lsm_compaction}
一个重要的细节是 LSM 存储如何选择何时执行压缩,以及在压缩中包含哪些 SSTable。许多基于 LSM 的存储系统允许你配置使用哪种压缩策略,一些常见的选择是 [^16] [^17]
一个重要的细节是 LSM 存储如何选择何时执行压实,以及在压实中包括哪些 SSTable。许多基于 LSM 的存储系统允许你配置使用哪种压实策略,一些常见的选择是 [^16] [^17]
大小分层压实
: 较新和较小的 SSTable 依次合并成较旧和较大的 SSTable。包含较旧数据的 SSTable 可能变得非常大,合并它们需要大量临时磁盘空间。这种策略的优点是它可以处理非常高的写入吞吐量。
分层压实Size-tiered compaction
: 较新和较小的 SSTable 依次合并到较旧和较大的 SSTable 中。包含较旧数据的 SSTable 可能变得非常大,合并它们需要大量临时磁盘空间。这种策略的优点是它可以处理非常高的写入吞吐量。
层压实
: 键范围被分成更小的 SSTable较旧的数据被移动到单独的"级别"中,这允许压缩更渐进地进行,并使用比大小分层策略更少的磁盘空间。这种策略对于读取比大小分层压缩更有效,因为存储引擎需要读取更少的 SSTable 来检查它们是否包含键。
级压实Leveled compaction
: 键范围被分成较小的 SSTable较旧的数据被移动到单独的"级别"中,这允许压实更增量地进行,并且比分层策略使用更少的磁盘空间。这种策略对于读取比分层压实更有效,因为存储引擎需要读取更少的 SSTable 来检查它们是否包含键。
作为经验法则,如果你主要有写入和很少读取,大小分层压缩性能更好,而如果你的工作负载以读取为主,分层压缩性能更好。如果你频繁写入少量键,很少写入大量键,那么分层压缩也可能有优势 [^18]。
作为经验法则,如果你主要有写入而读取很少,分层压实表现更好,而如果你的工作负载以读取为主,分级压实表现更好。如果你频繁写入少量键,而很少写入大量键,那么分级压实也可能有优势 [^18]。
尽管有许多细微之处,LSM 树的基本思想——保持在后台合并的 SSTable 级联——简单而有效。我们在 ["比较 B 树和 LSM 树"](/ch4#sec_storage_btree_lsm_comparison) 中更详细地讨论它们的性能特征。
尽管有许多细微之处,但 LSM 树的基本思想 —— 保持在后台合并的 SSTable 级联 —— 简单而有效。我们将在 ["比较 B 树与 LSM 树"](#sec_storage_btree_lsm_comparison) 中更详细地讨论它们的性能特征。
--------
> [!TIP] 嵌入式存储引擎
>
> 许多数据库作为接受网络查询的服务运行,但也有 *嵌入式* 数据库不公开网络 API。相反它们是在与你的应用程序代码相同的进程中运行的库,通常读取和写入本地磁盘上的文件,你通过正常的函数调用与它们交互。嵌入式存储引擎的例子包括 RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB [^19]。
>
> 嵌入式数据库在移动应用程序中非常常用,用于存储本地用户的数据。在后端,如果数据足够小以适合单台机器,并且没有很多并发事务,它们可能是合适的选择。例如,在多租户系统中,如果每个租户足够小且彼此完全分离(即,你不需要运行组合来自多个租户的数据的查询),你可能可以为每个租户使用单独的嵌入式数据库实例 [^20]。
>
> 我们在本章讨论的存储和检索方法既用于嵌入式数据库,也用于客户端-服务器数据库。在 [第 6 章](/ch6#ch_replication) 和 [第 7 章](/ch7#ch_sharding) 中,我们将讨论跨多台机器扩展数据库的技术。
许多数据库作为接受网络查询的服务运行,但也有 *嵌入式* 数据库不公开网络 API。相反它们是在与应用程序代码相同的进程中运行的库通常读取和写入本地磁盘上的文件你通过正常的函数调用与它们交互。嵌入式存储引擎的例子包括 RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB [^19]。
嵌入式数据库在移动应用中非常常用,用于存储本地用户的数据。在后端,如果数据足够小以适合单台机器,并且没有太多并发事务,它们可能是一个合适的选择。例如,在多租户系统中,如果每个租户足够小且完全与其他租户分离(即,你不需要运行合并多个租户数据的查询),你可能可以为每个租户使用单独的嵌入式数据库实例 [^20]。
我们在本章讨论的存储和检索方法既用于嵌入式数据库,也用于客户端-服务器数据库。在 [第 6 章](/ch6#ch_replication) 和 [第 7 章](/ch7#ch_sharding) 中,我们将讨论跨多台机器扩展数据库的技术。
--------
### B 树 {#sec_storage_b_trees}
日志结构方法很流行,但它不是键值存储的唯一形式。按键读取和写入数据库记录最广泛使用的结构是 *B 树*
日志结构方法很流行,但它不是键值存储的唯一形式。按键读取和写入数据库记录最广泛使用的结构是 *B 树*
B 树于 1970 年引入 [^21],不到 10 年后被称为"无处不在" [^22],它们经受住了时间的考验。它们仍然是几乎所有关系数据库中的标准索引实现,许多非关系数据库也使用它们。
B 树于 1970 年引入 [^21],不到 10 年后被称为"无处不在"[^22],它们经受住了时间的考验。它们仍然是几乎所有关系数据库中的标准索引实现,许多非关系数据库也使用它们。
像 SSTable 一样B 树按键排序键值对这允许高效的键值查找和范围查询。但相似之处到此为止B 树有非常不同的设计理念。
像 SSTable 一样B 树按键保持键值对排序这允许高效的键值查找和范围查询。但相似之处到此为止B 树有非常不同的设计理念。
我们之前看到的日志结构索引将数据库分解为可变大小的 *段*,通常为几兆字节或更大,一次写入然后不可变。相比之下B 树将数据库分解为固定大小的 *块**页*,并可能就地覆盖页。页传统上大小为 4 KiB但 PostgreSQL 现在默认使用 8 KiBMySQL 默认使用 16 KiB。
我们之前看到的日志结构索引将数据库分解为可变大小的 *段*,通常为几兆字节或更大,写入一次后就不可变。相比之下B 树将数据库分解为固定大小的 *块**页*,并可能就地覆盖页。页传统上大小为 4 KiB但 PostgreSQL 现在默认使用 8 KiBMySQL 默认使用 16 KiB。
每个页可以使用页号标识,这允许一个页引用另一个页——类似于指针,但在磁盘上而不是在内存中。如果所有页都存储在同一个文件中,将页号乘以页大小会给我们页所在文件中的字节偏移量。我们可以使用这些页引用来构建页树,如 [图 4-5](/ch4#fig_storage_b_tree) 所示。
每个页可以使用页号标识,这允许一个页引用另一个页 —— 类似于指针,但在磁盘上而不是在内存中。如果所有页都存储在同一个文件中,将页号乘以页大小就给我们文件中页所在位置的字节偏移量。我们可以使用这些页引用来构建页树,如 [图 4-5](#fig_storage_b_tree) 所示。
{{< figure src="/fig/ddia_0405.png" id="fig_storage_b_tree" caption="图 4-5. 使用 B 树索引查找键 251。从根页开始我们首先跟随引用到键 200-300 的页,然后是键 250-270 的页。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0405.png" id="fig_storage_b_tree" caption="图 4-5. 使用 B 树索引查找键 251。从根页开始我们首先跟随引用到键 200300 的页,然后是键 250270 的页。" class="w-full my-4" >}}
一个页被指定为 B 树的 *根*;每当你想在索引中查找一个键时,你从这里开始。该页包含几个键和对子页的引用。每个子负责一个连续的键范围,引用之间的键指示这些范围之间的边界在哪里。(这种结构有时称为 B+ 树,但我们不需要将其与其他 B 树变体区分开来。)
一个页被指定为 B 树的 *根*;每当你想在索引中查找一个键时,你从这里开始。该页包含几个键和对子页的引用。每个子负责一个连续的键范围,引用之间的键指示这些范围之间的边界在哪里。(这种结构有时称为 B+ 树,但我们不需要将其与其他 B 树变体区分开来。)
在 [图 4-5](/ch4#fig_storage_b_tree) 的例中,我们正在查找键 251所以我们知道需要跟随 200 和 300 之间的页引用。这将我们带到一个外观相似的页,进一步将 200-300 范围分解为子范围。最终我们到达包含单个键的页(*叶页*),它要么内联包含每个键的值,要么包含对可以找到值的页的引用。
在 [图 4-5](#fig_storage_b_tree) 的例中,我们正在查找键 251所以我们知道我们需要跟随边界 200 和 300 之间的页引用。这将我们带到一个看起来相似的页,该页进一步将 200300 范围分解为子范围。最终我们到达包含单个键的页(*叶页*),该页要么内联包含每个键的值,要么包含对可以找到值的页的引用。
B 树的一页中对子页的引用数称为 *分支因子*。例如,在 [图 4-5](/ch4#fig_storage_b_tree) 中,分支因子为六。实际上,分支因子取决于存储页引用和范围边界所需的空间量,但通常几百。
B 树的一页中对子页的引用数称为 *分支因子*。例如,在 [图 4-5](#fig_storage_b_tree) 中,分支因子为六。实际上,分支因子取决于存储页引用和范围边界所需的空间量,但通常几百。
如果你想更新 B 树中现有键的值,你搜索包含该键的叶页,并用包含新值的版本覆盖磁盘上的该页。如果你想添加新键,你需要找到其范围包含新键的页并将其添加到该页。如果页中没有足够的空闲空间来容纳新键,则页分成两个半满页,并更新父页以考虑键范围的新细分。
如果你想更新 B 树中现有键的值,你搜索包含该键的叶页,并用包含新值的版本覆盖磁盘上的该页。如果你想添加一个新键,你需要找到其范围包含新键的页并将其添加到该页。如果页中没有足够的空闲空间来容纳新键,则页被分成两个半满的页,并更新父页以说明键范围的新细分。
{{< figure src="/fig/ddia_0406.png" id="fig_storage_b_tree_split" caption="图 4-6. 通过在边界键 337 上分割页来增长 B 树。父页被更新以引用两个子页。" class="w-full my-4" >}}
在 [图 4-6](/ch4#fig_storage_b_tree_split) 的示例中,我们想插入键 334但范围 333-345 的页已满。因此,我们将其分成范围 333-337包括新键的页和 337-344 的页。我们还必须更新父页以引用两个子页,它们之间的边界值为 337。如果父页没有足够的空间用于新引用,它也可能需要分割,分割可以一直持续到树的根。当根分割时,我们在其上方创建一个新根。删除键(可能需要合并节点)更复杂 [^5]。
在 [图 4-6](#fig_storage_b_tree_split) 的例子中,我们想插入键 334但范围 333345 的页已经满了。因此,我们将其分成范围 333337包括新键的页和 337344 的页。我们还必须更新父页以引用两个子页,它们之间的边界值为 337。如果父页没有足够的空间容纳新引用,它也可能需要被分割,分割可以一直持续到树的根。当根被分割时,我们在它上面创建一个新根。删除键(可能需要合并节点)更复杂 [^5]。
算法确保树保持 *平衡*:具有 *n* 个键的 B 树始终具有 *O*(log *n*) 的深度。大多数数据库可以适合三或四层深的 B 树,因此你不需要跟随很多页引用来找到你要查找的页。(具有 500 分支因子的 4 KiB 页的四层树可以存储多达 250 TB。
这个算法确保树保持 *平衡*:具有 *n* 个键的 B 树始终具有 *O*(log *n*) 的深度。大多数数据库可以适合三或四层深的 B 树,所以你不需要跟随许多页引用来找到你要查找的页。(具有 500 分支因子的 4 KiB 页的四层树可以存储多达 250 TB。
#### 使 B 树可靠 {#sec_storage_btree_wal}
B 树的基本底层写操作是用新数据覆盖磁盘上的页。假设覆盖不会改变页的位置;即,当页被覆盖时,对该页的所有引用保持不变。这与日志结构索引(如 LSM 树)形成鲜明对比,后者只追加到文件(并最终删除过时的文件)但从不就地修改文件。
B 树的基本底层写操作是用新数据覆盖磁盘上的页。假设覆盖不会改变页的位置;即,当页被覆盖时,对该页的所有引用保持不变。这与日志结构索引(如 LSM 树)形成鲜明对比,后者只追加到文件(并最终删除过时的文件)但从不就地修改文件。
一次覆盖几个页,如在页分割中,是一个危险的操作:如果数据库在只写入了一些页后崩溃,你最终会得到一个损坏的树(例如,可能有一个 *孤立* 页不是任何父页的子页)。如果硬件不能原子地写入整个页,你也可能最终得到部分写入的页(这称为 *撕裂页* [^23])。
一次覆盖多个页,如在页分割中,是一个危险的操作:如果数据库在只写入了部分页后崩溃,你最终会得到一个损坏的树(例如,可能有一个 *孤立*,它不是任何父页的子页)。如果硬件不能原子地写入整个页,你也可能最终得到部分写入的页(这称为 *撕裂页**torn page*[^23])。
为了使数据库对崩溃具有弹性B 树实现通常包括磁盘上的额外数据结构:*预写日志*WAL。这是一个仅追加文件在将每个 B 树修改应用于树本身的页之前,必须将其写入该文件。当数据库在崩溃后恢复时,此日志用于将 B 树恢复到一致状态 [^2] [^24]。在文件系统中,等效机制称为 *日志记录*
为了使数据库对崩溃具有弹性B 树实现通常包括磁盘上的额外数据结构:*预写日志**write-ahead log*WAL。这是一个仅追加文件每个 B 树修改必须在应用于树本身的页之前写入其中。当数据库在崩溃后恢复时,此日志用于将 B 树恢复到一致状态 [^2] [^24]。在文件系统中,等效机制称为 *日志记录**journaling*
为了提高性能B 树实现通常不会立即将每个修改的页写入磁盘,而是首先在内存中缓冲 B 树页一段时间。预写日志然后还确保在崩溃的情况下数据不会丢失:只要数据已写入 WAL并使用 `fsync()` 系统调用刷新到磁盘,数据是持久的,因为数据库将能够在崩溃后恢复它 [^25]。
为了提高性能B 树实现通常不会立即将每个修改的页写入磁盘,而是首先将 B 树页缓冲在内存中一段时间。预写日志还确保在崩溃的情况下数据不会丢失:只要数据已写入 WAL并使用 `fsync()` 系统调用刷新到磁盘,数据是持久的,因为数据库将能够在崩溃后恢复它 [^25]。
#### B 树变体 {#b-tree-variants}
由于 B 树已经存在了很长时间,多年来已经开发了许多变体。仅举几例:
由于 B 树已经存在了很长时间,多年来已经开发了许多变体。仅举几
* 一些数据库(如 LMDB使用写时复制方案 [^26],而不是覆盖页并维护 WAL 进行崩溃恢复。修改的页被写入不同的位置,并创建树中父页的新版本,指向新位置。这种方法对于并发控制也很有用,正如我们将在 ["快照隔离和可重复读"](/ch8#sec_transactions_snapshot_isolation) 中看到
* 我们可以通过不存储整个键而是缩写它来节省页中的空间。特别是在树内部的页上,键只需要提供足够的信息来充当键范围之间的边界。将更多键打包到页中允许树具有更高的分支因子,从而减少层级
* 为了加按排序顺序扫描键范围,一些 B 树实现尝试布局树,使叶页在磁盘上按顺序出现,减少磁盘寻道次数。然而,随着树的增长,很难维持顺序。
* 已向树添加了额外的指针。例如,每个叶页可能具有对其左右兄弟页的引用,这允许按顺序扫描键而无需跳回父页。
* 一些数据库(如 LMDB使用写时复制方案 [^26],而不是覆盖页并维护 WAL 进行崩溃恢复。修改的页被写入不同的位置,并创建树中父页的新版本,指向新位置。这种方法对于并发控制也很有用,我们将在 ["快照隔离和可重复读"](/ch8#sec_transactions_snapshot_isolation) 中看到。
* 我们可以通过不存储整个键而是缩写它来节省页中的空间。特别是在树内部的页中,键只需要提供足够的信息来充当键范围之间的边界。在页中打包更多键允许树具有更高的分支因子,从而减少层数
* 为了加按排序顺序扫描键范围,一些 B 树实现尝试布局树,使叶页按顺序出现在磁盘上,减少磁盘寻道次数。然而,随着树的增长,很难维持这种顺序。
* 已向树添加了其他指针。例如,每个叶页可能有对其左右兄弟页的引用,这允许按顺序扫描键而无需跳回父页。
### 比较 B 树与 LSM 树 {#sec_storage_btree_lsm_comparison}
作为经验法则LSM 树更适合写入密集型应用程序,而 B 树对读取更快 [^27] [^28]。然而,基准测试通常对工作负载的细节很敏感。你需要使用你的特定工作负载测试系统才能进行有效比较。此外,这不是 LSM 和 B 树之间的严格二选一选择:存储引擎有时融合了两种方法的特征,例如通过拥有多个 B 树并以 LSM 风格合并它们。在本节中,我们将简要讨论在衡量存储引擎性能时值得考虑的几件事。
作为经验法则LSM 树更适合写入密集型应用,而 B 树对读取更快 [^27] [^28]。然而,基准测试通常对工作负载的细节很敏感。你需要使用特定的工作负载测试系统,以便进行有效的比较。此外,这不是 LSM 和 B 树之间的严格二选一选择:存储引擎有时会混合两种方法的特征,例如具有多个 B 树并以 LSM 风格合并它们。在本节中,我们将简要讨论在衡量存储引擎性能时值得考虑的几件事。
#### 读取性能 {#read-performance}
在 B 树中,查找键涉及在 B 树的每个级读取一页。由于级数通常很小,这意味着从 B 树读取通常很快且具有可预测的性能。在 LSM 存储引擎中,读取通常必须检查处于不同压阶段的几个不同 SSTable但布隆过滤器有助于减少所需的实际磁盘 I/O 操作数。两种方法都可以表现良好,哪个更快取决于存储引擎和工作负载的细节
在 B 树中,查找键涉及在 B 树的每个级读取一页。由于级数通常很小,这意味着从 B 树读取通常很快且具有可预测的性能。在 LSM 存储引擎中,读取通常必须检查处于不同压阶段的几个不同 SSTable但布隆过滤器有助于减少所需的实际磁盘 I/O 操作数。两种方法都可以表现良好,哪个更快取决于存储引擎的细节和工作负载。
范围查询在 B 树上简单而快速,因为它们可以使用树的排序结构。在 LSM 存储上,范围查询也可以利用 SSTable 排序,但它们需要并行扫描所有段并组合结果。布隆过滤器对范围查询没有帮助(因为你需要计算范围内每个可能键的哈希,这是不切实际的),使得范围查询在 LSM 方法中比点查询更昂贵 [^29]。
如果内存表填满,高写入吞吐量可能会在日志结构存储引擎中导致延迟峰值。如果数据无法足够快地写入磁盘,可能是因为压缩过程无法跟上传入写入,就会发生这种情况。许多存储引擎,包括 RocksDB在这种情况下执行 *背压*:它们暂停所有读取和写入,直到内存表被写入磁盘 [^30] [^31]。
如果内存表填满,高写入吞吐量可能会导致日志结构存储引擎中的延迟峰值。如果数据无法足够快地写入磁盘,可能是因为压实过程无法跟上传入的写入,就会发生这种情况。许多存储引擎,包括 RocksDB在这种情况下执行 *背压*:它们暂停所有读取和写入,直到内存表被写入磁盘 [^30] [^31]。
关于读取吞吐量,现代 SSD特别是 NVMe可以并行执行许多独立的读请求。LSM 树和 B 树都能够提供高读取吞吐量,但存储引擎需要精心设计以利用这种并行性 [^32]。
关于读取吞吐量,现代 SSD特别是 NVMe可以并行执行许多独立的读请求。LSM 树和 B 树都能够提供高读取吞吐量,但存储引擎需要仔细设计以利用这种并行性 [^32]。
#### 顺序与随机写入 {#sidebar_sequential}
使用 B 树,如果应用程序写入分散在整个键空间中的键,则产生的磁盘操作也是随机分散的,因为存储引擎需要覆盖的页可能位于磁盘的任何位置。另一方面,日志结构存储引擎一次写入整个段文件(要么写出内存表,要么压缩现有段),这比 B 树中的页大得多。
使用 B 树时,如果应用程序写入的键分散在整个键空间中,生成的磁盘操作也会随机分散,因为存储引擎需要覆盖的页可能位于磁盘的任何位置。另一方面,日志结构存储引擎一次写入整个段文件(无论是写出内存表还是压实现有段),这比 B 树中的页大得多。
许多小的、分散的写入模式(如 B 树中发现的)称为 *随机写入*,而较少的大写入模式(如 LSM 树中发现的)称为 *顺序写入*。磁盘通常具有比随机写入吞吐量更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘HDD上特别大在今天大多数数据库使用的固态硬盘SSD差异较小但仍然明显(参见 ["SSD 上的顺序与随机写入"](/ch4#sidebar_sequential))。
许多小的、分散的写入模式(如 B 树中的)称为 *随机写入*,而较少的大写入模式(如 LSM 树中的)称为 *顺序写入*。磁盘通常具有比随机写入更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘HDD上特别大在今天大多数数据库使用的固态硬盘SSD差异较小但仍然明显参见 ["SSD 上的顺序与随机写入"](#sidebar_sequential))。
--------
> [!TIP] SSD 上的顺序与随机写入
>
> 在旋转磁盘硬盘HDD顺序写入比随机写入快得多随机写入必须机械地将磁头移动到新位置并等待盘片的正确部分通过磁头下方,这需要几毫秒——在计算时间尺度上是永恒的。然而SSD固态硬盘包括 NVMe非易失性内存快速即连接到 PCI Express 总线的闪存)现在已经在许多用例中取代了 HDD它们不受此类机械限制。
>
> 尽管如此SSD 的顺序写入吞吐量也高于随机写入。原因是闪存可以一次读取或写入一页(通常为 4 KiB但只能一次擦除一个块通常为 512 KiB。块中的某些页可能包含有效数据而其他页可能包含不再需要的数据。在擦除块之前控制器必须首先将包含有效数据的页移动到其他块中这个过程称为 *垃圾*GC[^33]。
>
> 顺序写入工作负载一次写入更大的数据块,因此整个 512 KiB 块很可能属于单个文件;当该文件稍后再次删除时,可以擦除整个块而无需执行任何 GC。另一方面使用随机写入工作负载,块更可能包含有效和无效数据页的混合,因此 GC 必须在擦除之前执行更多工作 [^34] [^35] [^36]。
>
> GC 消耗的写入带宽随后不可用于应用程序。此外GC 执行的额外写入会导致闪存磨损;因此,随机写入比顺序写入更快地磨损驱动器。
在旋转磁盘硬盘HDD顺序写入比随机写入快得多随机写入必须机械地将磁头移动到新位置并等待盘片的正确部分经过磁头下方,这需要几毫秒 —— 在计算时间尺度上是永恒的。然而SSD固态硬盘包括 NVMe非易失性内存快速即连接到 PCI Express 总线的闪存)现在已经在许多用例中超越了 HDD它们不受这种机械限制。
尽管如此SSD 对顺序写入的吞吐量也高于随机写入。原因是闪存可以一次读取或写入一页(通常为 4 KiB但只能一次擦除一个块通常为 512 KiB。块中的某些页可能包含有效数据而其他页可能包含不再需要的数据。在擦除块之前控制器必须首先将包含有效数据的页移动到其他块中这个过程称为 *垃圾收*GC[^33]。
顺序写入工作负载一次写入更大的数据块,因此整个 512 KiB 块很可能属于单个文件;当该文件稍后再次被删除时,整个块可以被擦除而无需执行任何 GC。另一方面对于随机写入工作负载,块更可能包含有效和无效数据页的混合,因此 GC 必须在块可以擦除之前执行更多工作 [^34] [^35] [^36]。
GC 消耗的写入带宽就不能用于应用程序。此外GC 执行的额外写入会导致闪存磨损;因此,随机写入比顺序写入更快地磨损驱动器。
--------
#### 写放大 {#write-amplification}
使用任何类型的存储引擎,来自应用程序的一个写入请求都会在底层磁盘上变成多个 I/O 操作。使用 LSM 树,首先将值写入日志以获得持久性,然后在内存表写入磁盘时再次写入,并且每次键值对成为压缩的一部分时再次写入。(如果值明显大于键,可以通过将值与键分开存储,并仅对包含键和值引用的 SSTable 执行压缩来减少此开销 [^37]。)
对于任何类型的存储引擎,来自应用程序的一次写请求都会转换为底层磁盘上的多个 I/O 操作。对于 LSM 树,一个值首先被写入日志以保证持久性,然后在内存表写入磁盘时再次写入,并且每次键值对参与压实时再次写入。(如果值明显大于键,可以通过将值与键分开存储,并仅对包含键和值引用的 SSTable 执行压实来减少这种开销 [^37]。)
B 树索引必须至少写入每条数据两次:一次到预写日志,一次到树页本身。此外,它们有时需要写出整个页,即使该页中只有几个字节发生了变化,以确保 B 树在崩溃或断电后可以正确恢复 [^38] [^39]。
B 树索引必须至少写入每条数据两次:一次写入预写日志,一次写入树页本身。此外,它们有时需要写出整个页,即使该页中只有几个字节发生了变化,以确保 B 树在崩溃或断电后可以正确恢复 [^38] [^39]。
如果你将某些工作负载中写入磁盘的总字节数除以你仅写入没有索引的仅追加日志时必须写入的字节数,你就得到 *写放大*。(有时写放大是根据 I/O 操作而不是字节定义的。)在写入密集型应用程序中,瓶颈可能是数据库可以写入磁盘的速率。在这种情况下,写放大越高,它可以在可用磁盘带宽内处理的每秒写入越少。
如果你获取在某个工作负载中写入磁盘的总字节数,然后除以如果你只是写入没有索引的仅追加日志需要写入的字节数,你就得到了 *写放大*。(有时写放大是根据 I/O 操作而不是字节定义的。)在写入密集型应用程序中,瓶颈可能是数据库可以写入磁盘的速率。在这种情况下,写放大越高,它在可用磁盘带宽内可以处理的每秒写入次数就越少。
写放大是 LSM 树和 B 树中的问题。哪个更好取决于各种因素例如键和值的长度以及你覆盖现有键与插入新键的频率。对于典型工作负载LSM 树往往具有较低的写放大,因为它们不必写入整个页,并且可以压缩 SSTable 的块 [^40]。这是使 LSM 存储引擎非常适合写入密集型工作负载的另一个因素。
写放大是 LSM 树和 B 树中的问题。哪个更好取决于各种因素,例如键和值的长度,以及你覆盖现有键与插入新键的频率。对于典型工作负载LSM 树往往具有较低的写放大,因为它们不必写入整个页,并且可以压缩 SSTable 的块 [^40]。这是使 LSM 存储引擎非常适合写入密集型工作负载的另一个因素。
除了影响吞吐量外,写放大还与 SSD 的磨损有关:具有较低写放大的存储引擎将更慢地磨损 SSD。
除了影响吞吐量,写放大也与 SSD 的磨损有关:写放大较低的存储引擎将更慢地磨损 SSD。
在测量存储引擎的写入吞吐量时,重要的是运行实验足够长的时间,以使写放大的影响变得明显。写入空 LSM 树时,还没有压缩在进行,因此所有磁盘带宽都可用于新写入。随着数据库的增长,新写入需要与压缩共享磁盘带宽。
在测量存储引擎的写入吞吐量时,重要的是要运行足够长的实验,以便写放大的影响变得清晰。当写入空的 LSM 树时,还没有进行压实,因此所有磁盘带宽都可用于新写入。随着数据库的增长,新写入需要与压实共享磁盘带宽。
#### 磁盘空间使用 {#disk-space-usage}
B 树会随着时间的推移变得 *碎片化*:例如,如果删除了大量键,数据库文件可能包含许多不再被 B 树使用的页。对 B 树的后续添加可以使用那些空闲页,但它们不能轻易地返回给操作系统,因为它们在文件中间,因此它们仍然占用文件系统上的空间。因此,数据库需要一个后台进程来移动页以更好地放置它们,例如 PostgreSQL 中的 vacuum 进程 [^25]。
B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了大量键,数据库文件可能包含许多 B 树不再使用的页。对 B 树的后续添加可以使用这些空闲页,但它们不能轻易地返回给操作系统,因为它们在文件的中间,所以它们仍然占用文件系统上的空间。因此,数据库需要一个后台过程来移动页以更好地放置它们,例如 PostgreSQL 中的真空过程 [^25]。
碎片化在 LSM 树中不是问题,因为压缩过程无论如何都会定期重写数据文件,并且 SSTable 没有未使用空间的页。此外,键值对块可以在 SSTable 中更好地压缩,因此通常在磁盘上产生比 B 树更小的文件。被覆盖的键和值继续消耗空间,直到它们被压缩删除,但使用分层压缩时此开销相当低 [^40] [^41]。大小分层压缩(参见 ["压缩策略"](/ch4#sec_storage_lsm_compaction))使用更多磁盘空间,特别是在压缩期间暂时使用。
碎片化在 LSM 树中不太成问题,因为压实过程无论如何都会定期重写数据文件,而且 SSTable 没有未使用空间的页。此外SSTable 中的键值对块可以更好地压缩,因此通常比 B 树在磁盘上产生更小的文件。被覆盖的键和值继续消耗空间,直到它们被压实删除,但使用分级压实时,这种开销相当低 [^40] [^41]。分层压实(参见 ["压实策略"](#sec_storage_lsm_compaction))使用更多的磁盘空间,特别是在压实期间临时使用。
在磁盘上有某些数据的多个副本也可能是一个问题,当你需要删除某些数据,并确信它真的已被删除(可能是为了遵守数据保护法规)。例如,在大多数 LSM 存储引擎中,已删除的记录可能仍然存在于较高级别,直到代表删除的墓碑通过所有压缩级别传播,这可能需要很长时间。专门的存储引擎设计可以更快地传播删除 [^42]。
在磁盘上有一些数据的多个副本也可能是一个问题,当你需要删除一些数据,并确信它真的已被删除(也许是为了遵守数据保护法规)。例如,在大多数 LSM 存储引擎中,已删除的记录可能仍然存在于较高级别中,直到代表删除的墓碑通过所有压实级别传播,这可能需要很长时间。专门的存储引擎设计可以更快地传播删除 [^42]。
另一方面SSTable 段文件的不可变性质很有用,如果你想在某个时间点拍摄数据库快照(例如,用于备份或创建用于测试的数据库副本):你可以写出内存表并记录该时间点存在的段文件。只要你不删除快照的一部分文件,就不需要实际复制它们。在页被覆盖的 B 树中,有效地拍摄这样的快照更加困难。
另一方面SSTable 段文件的不可变性质在你想在某个时间点对数据库进行快照时很有用(例如,用于备份或创建数据库副本以进行测试):你可以写出内存表并记录该时间点存在的段文件。只要你不删除快照的一部分的文件,你就不需要实际复制它们。在其页被覆盖的 B 树中,有效地进行这样的快照更困难。
### 多列索引与二级索引 {#sec_storage_index_multicolumn}
到目前为止,我们只讨论了键值索引,它们类似于关系模型中的 *主键* 索引。主键唯一标识关系表中的一行、文档数据库中的一个文档或图数据库中的一个顶点。数据库中的其他记录可以通过其主键(或 ID引用该行/文档/顶点,索引用于解析此类引用。
到目前为止,我们只讨论了键值索引,它们就像关系模型中的 *主键* 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档,或图数据库中的一个顶点。数据库中的其他记录可以通过其主键(或 ID引用该行/文档/顶点,索引用于解析此类引用。
拥有 *二级索引*常见。在关系数据库中,你可以使用 `CREATE INDEX` 命令在同一个表上创建个二级索引,允许你按主键以外的列进行搜索。例如,在 [第 3 章](/ch3#ch_datamodels) 的 [图 3-1](/ch3#fig_obama_relational) 中,你很可能在 `user_id` 列上有一个二级索引,以便你可以在每个表中找到属于同一用户的所有行。
拥有 *二级索引*非常常见。在关系数据库中,你可以使用 `CREATE INDEX` 命令在同一个表上创建个二级索引,允许你按主键以外的列进行搜索。例如,在 [第 3 章](/ch3#ch_datamodels) 的 [图 3-1](/ch3#fig_obama_relational) 中,你很可能在 `user_id` 列上有一个二级索引,以便你可以在每个表中找到属于同一用户的所有行。
二级索引可以很容易地从键值索引构建。主要区别在于,在二级索引中,索引值不一定是唯一的;也就是说,同一索引条目下可能有许多行(文档、顶点)。这可以通过两种方式解决:要么使索引中的每个值成为匹配行标识符列表(如全文索引中的发布列表),要么通过向其附加行标识符使每个条目唯一。具有就地更新的存储引擎(如 B 树)和日志结构存储都可用于实现索引。
二级索引可以很容易地从键值索引构建。主要区别在于,在二级索引中,索引值不一定是唯一的;也就是说,同一索引条目下可能有许多行(文档、顶点)。这可以通过两种方式解决:要么使索引中的每个值成为匹配行标识符的列表(如全文索引中的倒排列表),要么通过向其追加行标识符使每个条目唯一。具有就地更新的存储引擎(如 B 树)和日志结构存储都可用于实现索引。
#### 在索引中存储值 {#sec_storage_index_heap}
索引中的键是查询搜索的内容,但值可以是几种东西之一:
* 如果实际数据(行、文档、顶点)直接存储在索引结构中,则称为 *聚簇索引*。例如,在 MySQL 的 InnoDB 存储引擎中,表的主键始终是聚簇索引,在 SQL Server 中,你可以为每个表指定一个聚簇索引 [^43]。
* 或者值可以是对实际数据的引用要么是相关行的主键InnoDB 对二级索引执行此操作),要么是对磁盘上位置的直接引用。在后一种情况下,存储行的地方称为 *堆文件*它以无特定顺序存储数据它可能是仅追加的或者它可能跟踪已删除的行以便稍后用新数据覆盖它们。例如Postgres 使用堆文件方法 [^44]。
* 两者之间的中间地带*覆盖索引**包含列的索引*除了在堆或主键聚簇索引上存储完整行外,还在索引中存储表的 *某些* 列 [^45]。这允许仅使用索引回答某些查询,而无需解析主键或查看堆文件(在这种情况下,索引称为 *覆盖* 查询)。这可以使某些查询更快,但数据的重复意味着索引使用更多磁盘空间并减慢写入速度。
* 或者值可以是对实际数据的引用要么是相关行的主键InnoDB 对二级索引这样做),要么是对磁盘上位置的直接引用。在后一种情况下,存储行的地方称为 *堆文件*它以无特定顺序存储数据它可能是仅追加的或者它可能跟踪已删除的行以便稍后用新数据覆盖它们。例如Postgres 使用堆文件方法 [^44]。
* 两者之间的中是 *覆盖索引**包含列的索引*它在索引中存储表的 *某些* 列,除了在堆上或主键聚簇索引中存储完整行 [^45]。这允许仅使用索引来回答某些查询,而无需解析主键或查看堆文件(在这种情况下,索引称为 *覆盖* 查询)。这可以使某些查询更快,但数据的重复意味着索引使用更多磁盘空间并减慢写入速度。
到目前为止讨论的索引只将单个键映射到值。如果你需要同时查询表的多个列(或文档中的多个字段),请参阅 ["多维和全文索引"](/ch4#sec_storage_multidimensional)。
到目前为止讨论的索引只将单个键映射到值。如果你需要同时查询表的多个列(或文档中的多个字段),请参见 ["多维索引与全文索引"](#sec_storage_multidimensional)。
在不更改键的情况下更新值时,堆文件方法可以允许记录就地覆盖,前提是新值不大于旧值。如果新值更大,情况会更复杂,因为它可能需要移动到堆中有足够空间的新位置。在这种情况下,要么所有索引都需要更新以指向记录的新堆位置,要么在旧堆位置留下转发指针 [^2]。
当更新值而不更改键时,堆文件方法可以允许记录就地覆盖,前提是新值不大于旧值。如果新值更大,情况会更复杂,因为它可能需要移动到堆中有足够空间的新位置。在这种情况下,要么所有索引都需要更新以指向记录的新堆位置,要么在旧堆位置留下转发指针 [^2]。
### 全内存存储 {#sec_storage_inmemory}
本章到目前为止讨论的数据结构都是对磁盘限制的回答。与主内存相比,磁盘很难处理。使用磁盘和 SSD如果你想在读取和写入方面获得良好的性能需要仔细布局磁盘上的数据。然而,我们容忍这种尴尬,因为磁盘有两个显著的优势:它们是持久的(如果电源关闭,它们的内容不会丢失),并且它们每千兆字节的成本低于 RAM
本章到目前为止讨论的数据结构都是对磁盘限制的回应。与主内存相比,磁盘很难处理。对于磁盘和 SSD如果你想在读取和写入上获得良好的性能磁盘上的数据需要仔细布局。然而,我们容忍这种尴尬,因为磁盘有两个显著的优势:它们是持久的(如果断电,其内容不会丢失),并且它们每千兆字节的成本比 RAM 低
随着 RAM 变得更便宜,每千兆字节成本的论点被削弱了。许多数据集根本不是那么大,因此将它们完全保存在内存中是完全可行的,可能分布在几台机器上。这导致了 *内存数据库* 的发展。
随着 RAM 变得更便宜,每千兆字节成本的论点被侵蚀。许多数据集根本不是那么大,因此将它们完全保留在内存中是完全可行的,可能分布在几台机器上。这导致了 *内存数据库* 的发展。
一些内存键值存储,如 Memcached仅用于缓存使用,如果机器重新启动,数据丢失是可以接受的。但其他内存数据库旨在持久性,这可以通过特殊硬件(如电池供电的 RAM、将更改日志写入磁盘、将定期快照写入磁盘或将内存状态复制到其他机器来实现。
一些内存键值存储,如 Memcached仅用于缓存如果机器重新启动数据丢失是可以接受的。但其他内存数据库旨在实现持久性,这可以通过特殊硬件(如电池供电的 RAM、将更改日志写入磁盘、将定期快照写入磁盘或将内存状态复制到其他机器来实现。
当内存数据库重新启动时,它需要重新加载其状态,要么从磁盘,要么通过网络从副本(除非使用特殊硬件)。尽管写入磁盘,它仍然是内存数据库,因为磁盘仅用作持久性的仅追加日志,读取完全从内存提供服务。写入磁盘还具有操作优势:磁盘上的文件可以轻松备份、检查和由外部实用程序分析。
当内存数据库重新启动时,它需要重新加载其状态,要么从磁盘,要么通过网络从副本(除非使用特殊硬件)。尽管写入磁盘,它仍然是一个内存数据库,因为磁盘仅用作持久性的仅追加日志,读取完全从内存提供。写入磁盘还具有操作优势:磁盘上的文件可以轻松备份、检查和由外部实用程序分析。
VoltDB、SingleStore 和 Oracle TimesTen 等产品是具有关系模型的内存数据库,供应商声称通过消除管理磁盘数据结构相关的所有开销,它们可以提供巨大的性能改进 [^46] [^47]。RAMCloud 是一个开源的、具有持久性的内存键值存储(对内存中的数据以及磁盘上的数据使用日志结构方法)[^48]。
VoltDB、SingleStore 和 Oracle TimesTen 等产品是具有关系模型的内存数据库,供应商声称通过消除管理磁盘数据结构相关的所有开销,它们可以提供巨大的性能改进 [^46] [^47]。RAMCloud 是一个开源的内存键值存储,具有持久性(对内存中的数据以及磁盘上的数据使用日志结构方法)[^48]。
Redis 和 Couchbase 通过异步写入磁盘提供弱持久性。
反直觉的是,内存数据库的性能优势不是因为它们不需要从磁盘读取。即使基于磁盘的存储引擎,如果你有足够的内存,也可能永远不需要从磁盘读取,因为操作系统无论如何都会在内存中缓存最近使用的磁盘块。相反,它们可以更快,因为它们可以避免以可以写入磁盘的形式编码内存数据结构的开销 [^49]。
反直觉的是,内存数据库的性能优势不是因为它们不需要从磁盘读取。即使基于磁盘的存储引擎,如果你有足够的内存,也可能永远不需要从磁盘读取,因为操作系统无论如何都会在内存中缓存最近使用的磁盘块。相反,它们可以更快,因为它们可以避免将内存数据结构编码为可以写入磁盘的形式的开销 [^49]。
除了性能内存数据库的另一个有趣领域是提供难以用基于磁盘的索引实现的数据模型。例如Redis 为各种数据结构(如优先队列和集合)提供类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。
除了性能,内存数据库的另一个有趣领域是提供难以使用基于磁盘的索引实现的数据模型。例如Redis 为各种数据结构(如优先队列和集合)提供类似数据库的接口。因为它将所有数据保留在内存中,其实现相对简单。
## 分析型数据存储 {#sec_storage_analytics}
数据仓库的数据模型最常见的是关系型,因为 SQL 通常非常适合分析查询。有许多图形数据分析工具可以生成 SQL 查询、可视化结果,并允许分析师探索数据(通过 *下钻**切片切块* 等操作)。
数据仓库的数据模型最常见的是关系型,因为 SQL 通常非常适合分析查询。有许多图形数据分析工具可以生成 SQL 查询、可视化结果,并允许分析师探索数据(通过 *下钻**切片切块* 等操作)。
表面上,数据仓库和关系型 OLTP 数据库看起来很相似,因为它们都有 SQL 查询接口。然而,系统的内部可能看起来完全不同,因为它们针对非常不同的查询模式进行了优化。许多数据库供应商现在专注于支持事务处理或分析工作负载,但不是两者都支持
表面上,数据仓库和关系型 OLTP 数据库看起来很相似,因为它们都有 SQL 查询接口。然而,系统的内部可能看起来完全不同,因为它们针对非常不同的查询模式进行了优化。许多数据库供应商现在专注于支持事务处理或分析工作负载,但不是两者兼而有之
一些数据库,如 Microsoft SQL Server、SAP HANA 和 SingleStore在同一产品中支持事务处理和数据仓库。然而这些混合事务和分析处理HTAP数据库在 ["数据仓库"](/ch1#sec_introduction_dwh) 中介绍)越来越多地成两个独立的存储和查询引擎,它们恰好可以通过通用的 SQL 接口访问 [^50] [^51] [^52] [^53]。
一些数据库,如 Microsoft SQL Server、SAP HANA 和 SingleStore在同一产品中支持事务处理和数据仓库。然而这些混合事务和分析处理HTAP数据库在 ["数据仓库"](/ch1#sec_introduction_dwh) 中介绍)越来越多地成两个独立的存储和查询引擎,它们恰好可以通过通用的 SQL 接口访问 [^50] [^51] [^52] [^53]。
### 云数据仓库 {#sec_cloud_data_warehouses}
Teradata、Vertica 和 SAP HANA 等数据仓库供应商既销售商业许可的本地仓库,也销售基于云的解决方案。但随着他们的许多客户迁移到云,新的云数据仓库如 Google Cloud BigQuery、Amazon Redshift 和 Snowflake 也已被广泛采用。与传统数据仓库不同,云数据仓库利用可扩展的云基础设施,如对象存储和无服务器计算平台。
Teradata、Vertica 和 SAP HANA 等数据仓库供应商既销售商业许可的本地仓库,也销售基于云的解决方案。但随着他们的许多客户转向云,新的云数据仓库(如 Google Cloud BigQuery、Amazon Redshift 和 Snowflake也变得广泛采用。与传统数据仓库不同,云数据仓库利用可扩展的云基础设施,如对象存储和无服务器计算平台。
云数据仓库往往与其他云服务集成得更好,并且更具弹性。例如,许多云仓库支持自动日志摄取,并提供与数据处理框架(如 Google Cloud 的 Dataflow 或 Amazon Web Services 的 Kinesis的轻松集成。这些仓库也更具弹性因为它们将查询计算与存储层解耦 [^54]。数据持久在对象存储而不是本地磁盘上,这使得独立调整存储容量和查询计算资源变得容易,正如我们之前在 ["云原生系统架构"](/ch1#sec_introduction_cloud_native) 中看到的。
云数据仓库往往与其他云服务更好地集成,并且更具弹性。例如,许多云仓库支持自动日志摄取,并提供与数据处理框架(如 Google Cloud 的 Dataflow 或 Amazon Web Services 的 Kinesis的轻松集成。这些仓库也更具弹性因为它们将查询计算与存储层解耦 [^54]。数据持久存储在对象存储而不是本地磁盘上,这使得可以独立调整存储容量和查询计算资源,正如我们之前在 ["云原生系统架构"](/ch1#sec_introduction_cloud_native) 中看到的。
开源数据仓库如 Apache Hive、Trino 和 Apache Spark 也随着云而发展。随着分析数据存储转移到对象存储上的数据湖,开源仓库已经开始分解 [^55]。以下组件以前集成在单个系统(如 Apache Hive现在通常作为单独的组件实现
Apache Hive、Trino 和 Apache Spark 等开源数据仓库也随着云的发展而发展。随着分析数据存储转移到对象存储上的数据湖,开源仓库已经开始分解 [^55]。以下组件以前集成在单个系统(如 Apache Hive现在通常作为单独的组件实现
查询引擎
: 查询引擎如 Trino、Apache DataFusion 和 Presto 解析 SQL 查询,将它们优化为执行计划,并针对数据执行它们。执行通常需要并行的分布式数据处理任务。一些查询引擎提供内置任务执行,而其他查询引擎选择使用第三方执行框架,如 Apache Spark 或 Apache Flink。
: Trino、Apache DataFusion 和 Presto 等查询引擎解析 SQL 查询,将其优化为执行计划,并针对数据执行它们。执行通常需要并行、分布式数据处理任务。一些查询引擎提供内置任务执行,而其他选择使用第三方执行框架,如 Apache Spark 或 Apache Flink。
存储格式
: 存储格式决定了表的行如何编码为文件中的字节,然后通常存储在对象存储或分布式文件系统中 [^12]。然后,查询引擎可以访问此数据,但使用数据湖的其他应用程序也可以访问。此类存储格式的示例包括 Parquet、ORC、Lance 或 Nimble我们将在下一节中看到更多关内容。
: 存储格式确定表的行如何编码为文件中的字节,然后通常存储在对象存储或分布式文件系统中 [^12]。然后查询引擎可以访问这些数据,但使用数据湖的其他应用程序也可以访问。此类存储格式的示例包括 Parquet、ORC、Lance 或 Nimble我们将在下一节中看到更多关于它们的内容。
表格式
: 以 Apache Parquet 和类似存储格式编写的文件一旦写通常是不可变的。为了支持行插入和删除,使用表格式,如 Apache Iceberg 或 Databricks 的 Delta 格式。表格式指定一种文件格式,该格式定义哪些文件构成表以及表的模式。此类格式还提供高级功能,如时间旅行(查询表在先前时间点的状态的能力)、垃圾收集,甚至事务。
: 以 Apache Parquet 和类似存储格式编写的文件一旦写通常是不可变的。为了支持行插入和删除,使用 Apache Iceberg 或 Databricks 的 Delta 格式等表格式。表格式指定定义哪些文件构成表以及表模式的文件格式。此类格式还提供高级功能,例如时间旅行(查询表在以前时间点的能力)、垃圾回收,甚至事务。
数据目录
: 就像表格式定义哪些文件成表一样,数据目录定义哪些表组成数据库。目录用于创建、重命名和删除表。与存储和表格式不同,数据目录如 Snowflake 的 Polaris 和 Databricks 的 Unity Catalog 通常作为独立服务运行,可以使用 REST 接口查询。Apache Iceberg 也提供目录,可以在客户端内运行或作为单独的进程运行。查询引擎在读取和写入表时使用目录信息。传统上,目录和查询引擎是集成的,但将它们解耦使数据发现和数据治理系统(在 ["数据系统、法律和社会"](/ch1#sec_introduction_compliance) 中讨论)也能访问目录的元数据。
: 就像表格式定义哪些文件成表一样数据目录定义哪些表组成数据库。目录用于创建、重命名和删除表。与存储和表格式不同Snowflake 的 Polaris 和 Databricks 的 Unity Catalog 等数据目录通常作为可以使用 REST 接口查询的独立服务运行。Apache Iceberg 也提供目录,可以在客户端内运行或作为单独的进程运行。查询引擎在读取和写入表时使用目录信息。传统上,目录和查询引擎已经集成,但将它们解耦使数据发现和数据治理系统(在 ["数据系统、法律和社会"](/ch1#sec_introduction_compliance) 中讨论)也能访问目录的元数据。
### 列式存储 {#sec_storage_column}
如 ["星型和雪花型:分析模式"](/ch3#sec_datamodels_analytics) 中所讨论的,数据仓库按照惯例通常使用关系模式,其中有一个包含对维度表的外键引用的大型事实表。如果你的事实表中有数万亿行和 PB 级数据,有效地存储和查询它们就成为一个具有挑战性的问题。维度表通常要小得多(数百万行),因此在本节中我们将重点关注事实的存储。
如 ["星型和雪花型:分析模式"](/ch3#sec_datamodels_analytics) 中所讨论的,数据仓库按照惯例通常使用带有大型事实表的关系模式,该表包含对维度表的外键引用。如果你的事实表中有数万亿行和数 PB 的数据,有效地存储和查询它们就成为一个具有挑战性的问题。维度表通常要小得多(数百万行),因此在本节中我们将重点关注事实的存储。
尽管事实表通常超过 100 列,但典型的数据仓库查询一次只访问其中的 4 或 5 列(分析很少需要 `"SELECT *"` 查询)[^52]。以 [示例 4-1](/ch4#fig_storage_analytics_query) 中的查询为例它访问大量行2024 日历年每次有人购买水果或糖果的情况),但它只需要访问 `fact_sales` 表的三列:`date_key`、`product_sk` 和 `quantity`。查询忽略所有其他列。
尽管事实表通常超过 100 列,但典型的数据仓库查询一次只访问其中的 4 或 5 列(分析很少需要 `"SELECT *"` 查询)[^52]。以 [示例 4-1](#fig_storage_analytics_query) 中的查询为例它访问大量行2024 日历年期间每次有人购买水果或糖果的情况),但它只需要访问 `fact_sales` 表的三列:`date_key`、`product_sk` 和 `quantity`。查询忽略所有其他列。
{{< figure id="fig_storage_analytics_query" title="示例 4-1. 分析人们是否更倾向于购买新鲜水果或糖果,取决于星期几" class="w-full my-4" >}}
@ -379,40 +379,40 @@ GROUP BY
dim_date.weekday, dim_product.category;
```
我们如何效地执行这个查询?
我们如何效地执行这个查询?
在大多数 OLTP 数据库中,存储*行式* 方式布局:表的一行中的所有值彼此相邻存储。文档数据库类似:整个文档通常作为一个连续的字节序列存储。你可以在 [图 4-1](/ch4#fig_storage_csv_hash_index) 的 CSV 示例中看到这一点。
在大多数 OLTP 数据库中,存储是以 *面向行* 的方式布局的:表中一行的所有值彼此相邻存储。文档数据库类似:整个文档通常作为一个连续的字节序列存储。你可以在 [图 4-1](#fig_storage_csv_hash_index) 的 CSV 示例中看到这一点。
为了处理像 [示例 4-1](/ch4#fig_storage_analytics_query) 这样的查询,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,它们告诉存储引擎在哪里找到特定日期或特定产品的所有销售。但是,行式存储引擎仍然需要将所有这些行(每行由 100 多个属性组成)从磁盘加载到内存中,解析它们,并过滤掉不符合所需条件的行。这可能需要很长时间。
为了处理像 [示例 4-1](#fig_storage_analytics_query) 这样的查询,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,告诉存储引擎在哪里找到特定日期或特定产品的所有销售。但是,面向行的存储引擎仍然需要将所有这些行(每行包含超过 100 个属性)从磁盘加载到内存中,解析它们,并过滤掉不符合所需条件的行。这可能需要很长时间。
*列*(或 *列式*)存储背后的想法很简单:不要将一行中的所有值存储在一起,而是将每 *列* 中的所有值存储在一起 [^56]。如果每列单独存储,查询只需要读取和解析该查询中使用的那些列,这可以节省大量工作。[图 4-7](/ch4#fig_column_store) 使用 [图 3-5](/ch3#fig_dwh_schema) 中事实表的扩展版本示了这一原理。
*面向列*(或 *列式*)存储背后的想法很简单:不要将一行中的所有值存储在一起,而是将每 *列* 中的所有值存储在一起 [^56]。如果每列单独存储,查询只需要读取和解析该查询中使用的那些列,这可以节省大量工作。[图 4-7](#fig_column_store) 使用 [图 3-5](/ch3#fig_dwh_schema) 中事实表的扩展版本示了这一原理。
--------
> [!NOTE]
> 列存储在关系数据模型中最容易理解但它同样适用于非关系数据。例如Parquet [^57] 是一种支持文档数据模型的列式存储格式,基于 Google 的 Dremel [^58],使用一种称为 *切碎**条带化* 的技术 [^59]。
> 列存储在关系数据模型中最容易理解但它同样适用于非关系数据。例如Parquet [^57] 是一种列式存储格式,它支持基于 Google 的 Dremel [^58] 的文档数据模型,使用一种称为 *分解**shredding*)或 *条带化**striping*的技术 [^59]。
--------
{{< figure src="/fig/ddia_0407.png" id="fig_column_store" caption="图 4-7. 按列而不是按行存储关系数据。" class="w-full my-4" >}}
列式存储布局依赖于每列以相同顺序存储行。因此,如果你需要重新组装整行,你可以从每个单独列中取出第 23 个条目,并将它们组合在一起形成表的第 23 行。
面向列的存储布局依赖于每列以相同顺序存储行。因此,如果你需要重新组装整行,你可以从每个单独列中取出第 23 个条目,并将它们组合在一起形成表的第 23 行。
实际上,列式存储引擎实际上并不会一次存储整个列(可能包含数万亿行)。相反,它们将表分成数千或数百万行的块,并在每个块内分别存储每列的值 [^60]。由于许多查询限制在特定日期范围内,因此通常使每个块包含特定时间戳范围的行。然后查询只需要在与所需日期范围重叠的那些块中加载它需要的列。
实际上,列式存储引擎并不真的一次存储整个列(可能包含数万亿行)。相反,它们将表分解为数千或数百万行的块,并且在每个块内,它们分别存储每列的值 [^60]。由于许多查询限制在特定日期范围内,因此通常使每个块包含特定时间戳范围的行。然后查询只需要在与所需日期范围重叠的那些块中加载它需要的列。
列式存储现在几乎用于所有分析数据库 [^60],从大规模云数据仓库(如 Snowflake [^61])到单节点嵌入式数据库(如 DuckDB [^62]),以及产品分析系统(如 Pinot [^63] 和 Druid [^64])。它用于存储格式,如 Parquet、ORC [^65] [^66]、Lance [^67] 和 Nimble [^68],以及内存分析格式,如 Apache Arrow [^65] [^69] 和 Pandas/NumPy [^70]。一些时间序列数据库,如 InfluxDB IOx [^71] 和 TimescaleDB [^72],也基于列式存储。
列式存储如今几乎用于所有分析数据库 [^60],从大规模云数据仓库(如 Snowflake [^61])到单节点嵌入式数据库(如 DuckDB [^62]),以及产品分析系统(如 Pinot [^63] 和 Druid [^64])。它用于存储格式,如 Parquet、ORC [^65] [^66]、Lance [^67] 和 Nimble [^68],以及内存分析格式,如 Apache Arrow [^65] [^69] 和 Pandas/NumPy [^70]。一些时间序列数据库,如 InfluxDB IOx [^71] 和 TimescaleDB [^72],也基于面向列的存储。
#### 列压缩 {#sec_storage_column_compression}
除了只从磁盘加载查询所需的那些列外,我们还可以通过压缩数据进一步减少对磁盘吞吐量和网络带宽的需求。幸运的是,列式存储通常非常适合压缩。
除了只从磁盘加载查询所需的那些列外,我们还可以通过压缩数据进一步减少对磁盘吞吐量和网络带宽的需求。幸运的是,面向列的存储通常非常适合压缩。
看看 [图 4-7](/ch4#fig_column_store) 中每列的值序列:它们通常看起来相当重复,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是 *位图编码*,如 [图 4-8](/ch4#fig_bitmap_index) 所示。
看看 [图 4-7](#fig_column_store) 中每列的值序列:它们看起来经常重复,这是压缩的良好迹象。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是 *位图编码*,如 [图 4-8](#fig_bitmap_index) 所示。
{{< figure src="/fig/ddia_0408.png" id="fig_bitmap_index" caption="图 4-8. 单列的压缩、位图索引存储。" class="w-full my-4" >}}
通常,列中不同值的数量与行数相比很小(例如,零售商可能有数十亿销售交易,但只有 100,000 种不同的产品)。我们现在可以将具有 *n* 个不同值的列转换为 *n* 个单独的位图:每个不同值一个位图,每行一位。如果行具有该值,则位为 1否则为 0。
通常,列中不同值的数量与行数相比很小(例如,零售商可能有数十亿销售交易,但只有 100,000 种不同的产品)。我们现在可以将具有 *n* 个不同值的列转换为 *n* 个单独的位图:每个不同值一个位图,每行一位。如果行具有该值,则位为 1否则为 0。
一种选择是使用每行一位存储这些位图。然而,这些位图通常包含大量零(我们说它们是 *稀疏* 的)。在这种情况下,位图可以外进行游程编码:计算连续零或一的数量并存储该数字,如 [图 4-8](/ch4#fig_bitmap_index) 底部所示。*咆哮位图* 等技术在两种位图表示之间切换,使用更紧凑的表示 [^73]。这可以使列的编码非常高效。
一种选择是使用每行一位存储这些位图。然而,这些位图通常包含大量零(我们说它们是 *稀疏* 的)。在这种情况下,位图可以外进行游程编码:计算连续零或一的数量并存储该数字,如 [图 4-8](#fig_bitmap_index) 底部所示。诸如 *咆哮位图**roaring bitmaps*)之类的技术在两种位图表示之间切换,使用最紧凑的表示 [^73]。这可以使列的编码非常高效。
像这样的位图索引非常适合数据仓库中常见的查询类型。例如:
@ -420,179 +420,178 @@ GROUP BY
: 加载 `product_sk = 31`、`product_sk = 68` 和 `product_sk = 69` 的三个位图,并计算三个位图的按位 *OR*,这可以非常高效地完成。
`WHERE product_sk = 30 AND store_sk = 3:`
: 加载 `product_sk = 30``store_sk = 3` 的位图,并计算按位 *AND*。这有效是因为列以相同顺序包含行,因此一列位图中的第 *k* 位对应于另一列位图中第 *k* 位的同一行。
: 加载 `product_sk = 30``store_sk = 3` 的位图,并计算按位 *AND*。这有效是因为列以相同的顺序包含行,所以一列位图中的第 *k* 位对应于另一列位图中第 *k* 位的同一行。
位图也可用于回答图查询,例如查找社交网络中被用户 *X* 关注且也关注用户 *Y* 的所有用户 [^74]。列式数据库还有各种其他压缩方案,你可以在参考文献中找到 [^75]。
位图也可用于回答图查询,例如查找社交网络中被用户 *X* 关注且也关注用户 *Y* 的所有用户 [^74]。列式数据库还有各种其他压缩方案,你可以在参考文献中找到 [^75]。
--------
> [!NOTE]
> 不要将列式数据库与 *宽列*(也称为 *列族*)数据模型混淆,在该模型中,一行可以有数千列,并且不需要所有行都有相同的列 [^9]。尽管名称相似,宽列数据库是行因为它们将一行中的所有值存储在一起。Google 的 Bigtable、Apache Accumulo 和 HBase 是宽列模型的例。
> 不要将面向列的数据库与 *宽列*(也称为 *列族*)数据模型混淆,在该模型中,一行可以有数千列,并且不需要所有行都有相同的列 [^9]。尽管名称相似,宽列数据库是面向行的因为它们将一行中的所有值存储在一起。Google 的 Bigtable、Apache Accumulo 和 HBase 是宽列模型的例
--------
#### 列存储中的排序顺序 {#sort-order-in-column-storage}
在列存储中,行存储顺序不一定重要。最简单的是按插入顺序存储它们,因为插入新行只意味着追加到每列。然而,我们可以选择施加顺序,就像我们之前对 SSTable 所做的那样,并将其用作索引机制。
在列存储中,行存储顺序不一定重要。最简单的是按插入顺序存储它们,因为这样插入新行只需追加到每列。但是,我们可以选择强制执行顺序,就像我们之前对 SSTable 所做的那样,并将其用作索引机制。
请注意,独立排序每列是没有意义的,因为那样我们就不再知道列中的哪些项属于同一行。我们只能重建一行,因为我们知道一列中的第 *k* 项与另一列中的第 *k* 项属于同一行。
请注意,独立排序每列是没有意义的,因为那样我们就不再知道列中的哪些项属于同一行。我们只能重建一行,因为我们知道一列中的第 *k* 项与另一列中的第 *k* 项属于同一行。
相反,数据需要一次排序整行,即使它是按列存储的。数据库管理员可以使用他们对常见查询的了解来选择表应按哪些列排序。例如,如果查询经常针对日期范围(如上个月),那么`date_key` 作为第一个排序键可能是有意义的。然后查询只扫描上个月的行,这将比扫描所有行快得多。
相反,数据需要一次排序整行,即使它是按列存储的。数据库管理员可以使用他们对常见查询的了解来选择表应按哪些列排序。例如,如果查询经常针对日期范围(例如上个月),则`date_key` 作为第一个排序键可能是有意义的。然后查询可以只扫描上个月的行,这将比扫描所有行快得多。
第二列可以确定在第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是 [图 4-7](/ch4#fig_column_store) 中的第一个排序键,那么 `product_sk` 作为第二个排序键可能是有意义的,这样同一天同一产品的所有销售在存储中组合在一起。这将有助于需要在特定日期范围内按产品分组或过滤销售的查询。
第二列可以确定在第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是 [图 4-7](#fig_column_store) 中的第一个排序键,那么 `product_sk` 作为第二个排序键可能是有意义的,这样同一天同一产品的所有销售都在存储中分组在一起。这将有助于需要在某个日期范围内按产品分组或过滤销售的查询。
排序顺序的另一个优点是它可以帮助列的压缩。如果主排序列没有很多不同的值,那么排序后,它将有很长的序列,其中相同的值连续重复多次。简单的游程编码(就像我们在 [图 4-8](/ch4#fig_bitmap_index) 中用于位图的那样)可以将该列压缩到几千字节——即使表有数十亿行。
排序顺序的另一个优点是它可以帮助压缩列。如果主排序列没有许多不同的值,那么排序后,它将有很长的序列,其中相同的值在一行中重复多次。简单的游程编码,就像我们在 [图 4-8](#fig_bitmap_index) 中用于位图的那样,可以将该列压缩到几千字节 —— 即使表有数十亿行。
该压缩效果在第一个排序键上最强。第二和第三个排序键将更加混乱,因此不会有如此长的重复值运行。排序优先级更低的列基本上以随机顺序出现,因此它们可能不会压缩得那么好。但是,对前几列进行排序总体上仍然是一种胜利。
该压缩效果在第一个排序键上最强。第二和第三个排序键将更加混乱,因此不会有如此长的重复值运行。排序优先级较低的列基本上以随机顺序出现,因此它们可能不会压缩得那么好。但是,让前几列排序仍然是整体上的胜利。
#### 写入列式存储 {#writing-to-column-oriented-storage}
我们在 ["表征事务处理和分析"](/ch1#sec_introduction_oltp) 中看到,数据仓库中的读取往往包括大量行的聚合;列式存储、压缩和排序都有助于使这些读取查询更快。数据仓库中的写入往往是数据的批量导入,通常通过 ETL 过程。
我们在 ["事务处理和分析的特征"](/ch1#sec_introduction_oltp) 中看到,数据仓库中的读取往往包括大量行的聚合;列式存储、压缩和排序都有助于使这些读取查询更快。数据仓库中的写入往往是数据的批量导入,通常通过 ETL 过程。
使用列式存储,在排序表中间某处写入单个行将非常低效,因为你必须从插入位置开始重写所有压缩列。然而,一次批量写入许多行可以分摊重写这些列的成本,使其高效。
使用列式存储,在排序表中间某处写入单个行将非常低效,因为你必须从插入位置开始重写所有压缩列。但是,一次批量写入许多行会分摊重写这些列的成本,使其高效。
通常使用日志结构方法批量执行写入。所有写入首先进入行式、排序的内存存储。当积累了足够的写入时,它们与磁盘上的列编码文件合并,并批量写入新文件。由于旧文件保持不可变,新文件一次写入,对象存储非常适合存储这些文件。
通常使用日志结构方法以批次执行写入。所有写入首先进入面向行的、排序的内存存储。当积累了足够的写入时,它们与磁盘上的列编码文件合并,并批量写入新文件。由于旧文件保持不可变,新文件一次写入,对象存储非常适合存储这些文件。
查询需要检查磁盘上的列数据和内存中的最近写入,并将两者结合起来。查询执行引擎用户隐藏了这种区别。从分析师的角度来看通过插入、更新或删除修改的数据会立即反映在后续查询中。Snowflake、Vertica、Apache Pinot、Apache Druid 和许多其他系统都这样做 [^61] [^63] [^64] [^76]。
查询需要检查磁盘上的列数据和内存中的最近写入,并将两者结合起来。查询执行引擎用户隐藏了这种区别。从分析师的角度来看,通过插入、更新或删除修改的数据会立即反映在后续查询中。Snowflake、Vertica、Apache Pinot、Apache Druid 和许多其他系统都这样做 [^61] [^63] [^64] [^76]。
### 查询执行:编译与向量化 {#sec_storage_vectorized}
复杂的分析 SQL 查询被分解为由多个阶段组成的 *查询计划*,称为 *算子*,这些算子可能分布在多台机器上以进行并行执行。查询规划器可以通过选择使用哪些算子、以什么顺序执行它们以及在哪里运行每个算子来执行大量优化。
用于分析的复杂 SQL 查询被分解为由多个阶段组成的 *查询计划*,称为 *算子*,这些算子可能分布在多台机器上以并行执行。查询规划器可以通过选择使用哪些算子、以何种顺序执行它们以及在哪里运行每个算子来执行大量优化。
在每个算子,查询引擎需要对列中的值执行各种操作,例如查找值在特定值集中的所有行(可能作为连接的一部分),或检查值是否大于 15。它还需要查看同一行的几列例如查找所有产品是香蕉且商店是特定感兴趣商店的销售交易。
在每个算子,查询引擎需要对列中的值执行各种操作,例如查找值在特定值集中的所有行(可能作为连接的一部分),或检查值是否大于 15。它还需要查看同一行的几列例如查找产品是香蕉且商店是特定感兴趣商店的所有销售交易。
对于需要扫描数百万行的数据仓库查询,我们不仅需要担心它们需要从磁盘读取的数据量,还需要担心执行复杂算子所需的 CPU 时间。最简单的算子类型就像编程语言的解释器:在迭代每行时,它检查表示查询的数据结构,以找出需要对哪些列执行哪些比较或计算。不幸的是,这对许多分析目的来说太慢了。出现了两种高效查询执行的替代方法 [^77]
对于需要扫描数百万行的数据仓库查询,我们不仅需要担心它们需要从磁盘读取的数据量,还需要担心执行复杂算子所需的 CPU 时间。最简单的算子类型就像编程语言的解释器:在遍历每一行时,它检查表示查询的数据结构,以找出需要对哪些列执行哪些比较或计算。不幸的是,这对许多分析目的来说太慢了。高效查询执行的两种替代方法已经出现 [^77]
查询编译
: 查询引擎获取 SQL 查询并生成用于执行它的代码。代码逐行迭代,查看感兴趣列中的值,执行所需的任何比较或计算,并在满足所需条件时将必要的值复制到输出缓冲区。查询引擎将生成的代码编译为机器代码(通常使用现有编译器,如 LLVM然后在已加载到内存中的列编码数据上运行它。这种代码生成方法类似于 Java 虚拟机JVM和类似运行时中使用的即时JIT编译方法。
: 查询引擎获取 SQL 查询并生成用于执行它的代码。代码逐行迭代,查看感兴趣列中的值,执行所需的任何比较或计算,如果满足所需条件,则将必要的值复制到输出缓冲区。查询引擎将生成的代码编译为机器代码(通常使用现有编译器,如 LLVM然后在已加载到内存中的列编码数据上运行它。这种代码生成方法类似于 Java 虚拟机JVM和类似运行时中使用的即时JIT编译方法。
向量化处理
: 查询被解释,而不是编译,但通过批量处理列中的许多值而不是逐行迭代来加快速度。一组固定的预定义算子内置于数据库中;我们可以向它们传递参数并获得一批结果 [^50] [^75]。
: 查询被解释,而不是编译,但通过批量处理列中的许多值而不是逐行迭代来提高速度。一组固定的预定义算子内置在数据库中;我们可以向它们传递参数并获得一批结果 [^50] [^75]。
例如,我们可以将 `product_sk` 列和"香蕉"的 ID 传递给相等算子,并获得一个位图(输入列中每个值一位,如果是香蕉则为 1然后我们可以将 `store_sk` 列和感兴趣商店的 ID 传递给同一个相等算子,并获得另一个位图;然后我们可以将两个位图传递给"按位 AND"算子,如 [图 4-9](/ch4#fig_bitmap_and) 所示。结果将是一个位图,其中包含特定商店中所有香蕉销售的 1。
例如,我们可以将 `product_sk` 列和"香蕉"的 ID 传递给相等算子,并获得一个位图(输入列中每个值一位,如果是香蕉则为 1然后我们可以将 `store_sk` 列和感兴趣商店的 ID 传递给相同的相等算子,并获得另一个位图;然后我们可以将两个位图传递给"按位 AND"算子,如 [图 4-9](#fig_bitmap_and) 所示。结果将是一个位图,包含特定商店中所有香蕉销售的 1。
{{< figure src="/fig/ddia_0409.png" id="fig_bitmap_and" caption="图 4-9. 两个位图之间的按位 AND 适合向量化。" class="w-full my-4" >}}
这两种方法在实现方面非常不同,但在实践中都在使用 [^77]。两者都可以通过利用现代 CPU 的特性来实现非常好的性能:
这两种方法在实现方面非常不同,但两者都在实践中使用 [^77]。两者都可以通过利用现代 CPU 的特性来实现非常好的性能:
* 优先顺序内存访问而不是随机访问以减少缓存未命中 [^78]
* 在紧密的内部循环中完成大部分工作(即,使用少量指令且没有函数调用)以保持 CPU 指令处理管道繁忙并避免分支错误预测
* 利用并行性如多线程和单指令多数据SIMD指令 [^79] [^80],以及
* 直接对压缩数据进行操作,而无需将其解码为单独的内存表示,这节省内存分配和复制成本。
* 优先选择顺序内存访问而不是随机访问以减少缓存未命中 [^78]
* 在紧密的内部循环中完成大部分工作(即,具有少量指令且没有函数调用)以保持 CPU 指令处理管道繁忙并避免分支预测错误,
* 利用并行性,如多线程和单指令多数据SIMD指令 [^79] [^80],以及
* 直接对压缩数据进行操作,而无需将其解码为单独的内存表示,这可以节省内存分配和复制成本。
### 物化视图与多维数据集 {#sec_storage_materialized_views}
我们之前在 ["物化和更新时间线"](/ch2#sec_introduction_materializing) 中遇到了 *物化视图*:在关系数据模型中,它们是类似表的对象其内容是某些查询的结果。区别在于物化视图是查询结果的实际副本写入磁盘而虚拟视图只是编写查询的快捷方式。当你从虚拟视图读取时SQL 引擎会即时将其扩展为视图的底层查询,然后处理扩展的查询。
我们之前在 ["物化和更新时间线"](/ch2#sec_introduction_materializing) 中遇到了 *物化视图*:在关系数据模型中,它们是表状对象其内容是某些查询的结果。区别在于物化视图是查询结果的实际副本写入磁盘而虚拟视图只是编写查询的快捷方式。当你从虚拟视图读取时SQL 引擎会即时将其扩展为视图的基础查询,然后处理扩展的查询。
底层数据发生变化时,物化视图需要相应地更新。一些数据库可以自动执行此操作,还有像 Materialize 这样专门从事物化视图维护的系统 [^81]。执行此类更新意味着写入时需要更多工作,但物化视图可以提高重复需要执行相同查询的工作负载中的读取性能。
基础数据更改时,物化视图需要相应更新。一些数据库可以自动执行此操作,还有像 Materialize 这样专门从事物化视图维护的系统 [^81]。执行此类更新意味着写入时需要更多工作,但物化视图可以改善在重复需要执行相同查询的工作负载中的读取性能。
*物化聚合* 是一种可以在数据仓库中有用的物化视图类型。如前所述,数据仓库查询通常涉及聚合函数,如 SQL 中的 `COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX`。如果许多不同的查询使用相同的聚合,每次都处理原始数据可能是浪费的。为什么不缓存查询最常使用的一些计数或总和呢?*数据立方体* 或 *OLAP 立方体* 通过创建按不同维度分组的聚合网格来做到这一点 [^82]。[图 4-10](/ch4#fig_data_cube) 显示了一个示例。
*物化聚合* 是一种可以在数据仓库中有用的物化视图类型。如前所述,数据仓库查询通常涉及聚合函数,如 SQL 中的 `COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX`。如果许多不同的查询使用相同的聚合,每次都处理原始数据可能会很浪费。为什么不缓存查询最常使用的一些计数或总和?*多维数据集* 或 *OLAP 立方体* 通过创建按不同维度分组的聚合网格来做到这一点 [^82]。[图 4-10](#fig_data_cube) 显示了一个示例。
{{< figure src="/fig/ddia_0410.png" id="fig_data_cube" caption="图 4-10. 数据立方体的两个维度,通过求和聚合数据。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0410.png" id="fig_data_cube" caption="图 4-10. 多维数据集的两个维度,通过求和聚合数据。" class="w-full my-4" >}}
现在想象每个事实只有两个维度表的外键——在 [图 4-10](/ch4#fig_data_cube) 中,这些是 `date_key``product_sk`。你现在可以绘制一个二维表,日期沿一个轴,产品沿另一个轴。每个单元格包含具有该日期-产品组合的所有事实的属性(例如`net_price`)的聚合(例如,`SUM`)。然后,你可以沿着每行或列应用相同的聚合,并获得已减少一个维度的摘要(无论日期如何的产品销售,或无论产品如何的日期销售)。
现在假设每个事实只有两个维度表的外键 —— 在 [图 4-10](#fig_data_cube) 中,这些是 `date_key``product_sk`。你现在可以绘制一个二维表,日期沿一个轴,产品沿另一个轴。每个单元格包含具有该日期-产品组合的所有事实的属性(例如 `net_price`)的聚合(例如 `SUM`)。然后,你可以沿着每行或列应用相同的聚合,并获得已减少一个维度的摘要(不管日期的产品销售,或不管产品的日期销售)。
一般来说,事实通常有两个以上的维度。在 [图 3-5](/ch3#fig_dwh_schema) 中有五个维度:日期、产品、商店、促销和客户。很难想象五维超立方体会是什么样子,但原理保持不变:每个单元格包含特定日期-产品-商店-促销-客户组合的销售。然后可以沿着每个维度重复汇总这些值。
物化数据立方体的优点是某些查询变得非常快,因为它们已经有效地被预先计算。例如,如果你想知道昨天每家商店的总销售额,你只需要查看适当维度的总计——无需扫描数百万行。
物化多维数据集的优点是某些查询变得非常快,因为它们已经有效地预先计算了。例如,如果你想知道昨天每个商店的总销售额,你只需要查看适当维度的总计 —— 不需要扫描数百万行。
缺点是数据立方体没有查询原始数据的灵活性。例如,无法计算成本超过 100 美元的商品所占销售额的比例,因为价格不是维度之一。因此,大多数数据仓库尝试保留尽可能多的原始数据,并仅将聚合(如数据立方体)用作某些查询的性能提升。
缺点是多维数据集没有与查询原始数据相同的灵活性。例如,没有办法计算成本超过 100 美元的商品的销售比例,因为价格不是维度之一。因此,大多数数据仓库尽可能多地保留原始数据,并仅将聚合(如多维数据集)用作某些查询的性能提升。
## 多维索引与全文索引 {#sec_storage_multidimensional}
我们在本章前半部分看到的 B 树和 LSM 树允许对单个属性进行范围查询:例如,如果键是用户名,你可以使用它们作为索引来高效地查找所有以 L 开头的名字。但有时,按单个属性搜索是不够的。
我们在本章前半部分看到的 B 树和 LSM 树允许对单个属性进行范围查询:例如,如果键是用户名,你可以使用它们作为索引来高效查找所有以 L 开头的名称。但有时,按单个属性搜索是不够的。
最常见的多列索引类型称为 *联合索引*,它通过将一列追加到另一列来简单地将几个字段组合成一个键(索引定义指定字段连接的顺序)。这就像老式的纸质电话簿,它提供了从(*姓**名*)到电话号码的索引。由于排序顺序,索引可用于查找所有具有特定姓氏的人,或所有具有特定 *姓-名* 组合的人。但是,如果你想查找所有具有特定名字的人,索引就没用了
最常见的多列索引类型称为 *联合索引*,它通过将一列追加到另一列来将几个字段组合成一个键(索引定义指定字段以何种顺序连接)。这就像老式的纸质电话簿,它提供从(*姓氏*、*名字*)到电话号码的索引。由于排序顺序,索引可用于查找具有特定姓氏的所有人,或具有特定 *姓氏-名字* 组合的所有人。但是,如果你想查找具有特定名字的所有人,索引是无用的
另一方面,*多维索引* 允许你一次查询多个列。这在地理空间数据中特别重要。例如,餐厅搜索网站可能有一个包含每家餐厅纬度和经度的数据库。当用户在地图上查看餐厅时,网站需要搜索用户当前查看的矩形地图区域内的所有餐厅。这需要像下这样的二维范围查询:
另一方面,*多维索引* 允许你一次查询多个列。在地理空间数据中这尤其重要。例如,餐厅搜索网站可能有一个包含每个餐厅的纬度和经度的数据库。当用户在地图上查看餐厅时,网站需要搜索用户当前查看的矩形地图区域内的所有餐厅。这需要像下这样的二维范围查询:
```sql
SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
AND longitude > -0.1162 AND longitude < -0.1004;
```
纬度和经度列上的联合索引无法有效地回答这种查询:它可以给你纬度范围内的所有餐厅(但在任何经度),或经度范围内的所有餐厅(但在南北极之间的任何地方),但不能同时满足两者。
纬度和经度列上的联合索引无法有效地回答这种查询:它可以为你提供纬度范围内的所有餐厅(但在任何经度),或经度范围内的所有餐厅(但在北极和南极之间的任何地方),但不能同时提供两者。
一种选择是使用空间填充曲线将二维位置转换为单个数字,然后使用常规 B 树索引 [^83]。更常见的是,使用专门的空间索引,如 R 树或 Bkd 树 [^84];它们划分空间,使附近的数据点往往分组在同一子树中。例如PostGIS 使用 PostgreSQL 的通用搜索树索引设施将地理空间索引实现为 R 树 [^85]。也可以使用规则间隔的三角形、正方形或六边形网格 [^86]。
一种选择是使用空间填充曲线将二维位置转换为单个数字,然后使用常规 B 树索引 [^83]。更常见的是,使用专门的空间索引,如 R 树或 Bkd 树 [^84];它们划分空间,使附近的数据点倾向于分组在同一子树中。例如PostGIS 使用 PostgreSQL 的通用搜索树索引设施将地理空间索引实现为 R 树 [^85]。也可以使用规则间隔的三角形、正方形或六边形网格 [^86]。
多维索引不仅用于地理位置。例如,在电子商务网站上,你可以在*红*、*绿*、*蓝*)维度上使用三维索引来搜索特定颜色范围内的产品,或者在天气观测数据库中,你可以在(*日期*、*温度*)上有一个二维索引,以便有效地搜索 2013 年期间温度在 25 到 30℃ 之间的所有观测。使用一维索引,你必须扫描 2013 年的所有记录(无论温度如何),然后按温度过滤它们,或者反之亦然。二维索引可以同时按时间戳和温度缩小范围 [^87]。
多维索引不仅用于地理位置。例如,在电子商务网站上,你可以在维度(*红色*、*绿色*、*蓝色*)上使用三维索引来搜索某个颜色范围内的产品,或者在天气观测数据库中,你可以在(*日期*、*温度*)上有一个二维索引,以便有效地搜索 2013 年期间温度在 25 到 30°C 之间的所有观测。使用一维索引,你必须扫描 2013 年的所有记录(不管温度),然后按温度过滤它们,反之亦然。二维索引可以同时按时间戳和温度缩小范围 [^87]。
### 全文检索 {#sec_storage_full_text}
全文搜索允许你通过可能出现在文本中任何位置的关键字搜索文本文档集合(网页、产品描述等)[^88]。信息检索是一个大的专业主题,通常涉及特定于语言的处理:例如,几种亚洲语言在单词之间没有空格或标点符号,因此将文本分割成单词需要一个指示哪些字符序列构成单词的模型。全文搜索还经常涉及匹配相似但不相同的单词(如拼写错误或单词的不同语法形式)和同义词。这些问题超出了本书的范围。
全文搜索允许你通过可能出现在文本中任何位置的关键字搜索文本文档集合(网页、产品描述等)[^88]。信息检索是一个大的专业主题,通常涉及特定于语言的处理:例如,几种亚洲语言在单词之间没有空格或标点符号,因此将文本分割成单词需要一个指示哪些字符序列构成单词的模型。全文搜索还经常涉及匹配相似但不相同的单词(如拼写错误或单词的不同语法形式)和同义词。这些问题超出了本书的范围。
然而,在其核心,你可以将全文搜索视为另一种多维查询:在这种情况下,可能出现在文本中的每个单词(*词项*)是一个维度。包含词项 *x* 的文档在维度 *x* 中的值为 1不包含 *x* 的文档的值为 0。搜索提到"红苹果"的文档意味着查询在 *红* 维度中查找 1同时在 *苹果* 维度中查找 1。因此,维度数可能非常大。
然而,在其核心,你可以将全文搜索视为另一种多维查询:在这种情况下,可能出现在文本中的每个单词(*词项*)是一个维度。包含词项 *x* 的文档在维度 *x* 中的值为 1不包含 *x* 的文档的值为 0。搜索提到"红苹果"的文档意味着查询在 *红* 维度中查找 1同时在 *苹果* 维度中查找 1。维度数可能因此非常大。
许多搜索引擎用来回答此类查询的数据结构称为 *倒排索引*。这是一个键值结构,其中键是词项,值是包含该词项的所有文档的 ID 列表(*发布列表*)。如果文档 ID 是连续数字,发布列表也可以表示为稀疏位图,如 [图 4-8](/ch4#fig_bitmap_index) 所示:词项 *x* 的位图中的第 *n* 位是 1如果 ID 为 *n* 的文档包含词项 *x* [^89]。
许多搜索引擎用来回答此类查询的数据结构称为 *倒排索引*。这是一个键值结构,其中键是词项,值是包含该词项的所有文档的 ID 列表(*倒排列表*)。如果文档 ID 是顺序数字,倒排列表也可以表示为稀疏位图,如 [图 4-8](#fig_bitmap_index):词项 *x* 的位图中的第 *n* 位是 1如果 ID 为 *n* 的文档包含词项 *x* [^89]。
现在,查找包含词项 *x**y* 的所有文档类似于搜索匹配两个条件的行的向量化数据仓库查询([图 4-9](/ch4#fig_bitmap_and)):加载词项 *x**y* 的两个位图并计算它们的按位 AND。即使位图是游程编码的这也可以非常高效地完成。
查找包含词项 *x**y* 的所有文档现在类似于搜索匹配两个条件的行的向量化数据仓库查询([图 4-9](#fig_bitmap_and)):加载词项 *x**y* 的两个位图并计算它们的按位 AND。即使位图是游程编码的这也可以非常高效地完成。
例如Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就是这样工作的 [^90]。它将词项到发布列表的映射存储在类似 SSTable 的排序文件中,这些文件使用我们在本章前面看到的相同日志结构方法在后台合并 [^91]。PostgreSQL 的 GIN 索引类型也使用发布列表来支持全文搜索和 JSON 文档内的索引 [^92] [^93]。
例如Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就是这样工作的 [^90]。它将词项到倒排列表的映射存储在类似 SSTable 的排序文件中,这些文件使用我们在本章前面看到的相同日志结构方法在后台合并 [^91]。PostgreSQL 的 GIN 索引类型也使用倒排列表来支持全文搜索和 JSON 文档内的索引 [^92] [^93]。
不是将文本分解为单词,另一种方法是查找长度为 *n* 的所有子字符串,称为 *n*-gram。例如字符串 `"hello"` 的三元组*n* = 3`"hel"`、`"ell"` 和 `"llo"`。如果我们为所有三元组构建倒排索引,我们可以搜索至少三个字符长的任意子字符串的文档。三元组索引甚至允许在搜索查询中使用正则表达式;缺点是它们相当大 [^94]。
除了将文本分解为单词,另一种选择是查找长度为 *n* 的所有子字符串,称为 *n* 元语法。例如,字符串 `"hello"` 的三元语法*n* = 3`"hel"`、`"ell"` 和 `"llo"`。如果我们为所有三元语法构建倒排索引,我们可以搜索至少三个字符长的任意子字符串的文档。三元语法索引甚至允许在搜索查询中使用正则表达式;缺点是它们相当大 [^94]。
为了处理文档或查询中的拼写错误Lucene 能够在一定编辑距离内搜索单词的文本(编辑距离为 1 意味着已添加、删除或替换了一个字母)[^95]。它通过将词项集存储为键上字符的有限状态自动机(类似于 *trie* [^96])来实现这一点,并将其转换为 *Levenshtein 自动机*,该自动机支持在给定编辑距离内高效搜索单词 [^97]。
为了处理文档或查询中的拼写错误Lucene 能够在一定编辑距离内搜索文本中的单词(编辑距离为 1 意味着已添加、删除或替换了一个字母)[^95]。它通过将词项集存储为字符上的有限状态自动机(类似于 *字典树* [^96])并将其转换为 *莱文斯坦自动机* 来实现,该自动机支持在给定编辑距离内高效搜索单词 [^97]。
### 向量嵌入 {#id92}
语义搜索超越了同义词和拼写错误,试图理解文档概念和用户意图。例如,如果你的帮助页面包含标题为"取消订阅"的页面,用户在搜索"如何关闭我的户"或"终止合同"时仍应能够找到该页面,即使它们使用完全不同的词,但在含义上很接近。
语义搜索超越了同义词和拼写错误,试图理解文档概念和用户意图。例如,如果你的帮助页面包含标题为"取消订阅"的页面,用户在搜索"如何关闭我的户"或"终止合同"时仍应能够找到该页面,即使它们使用完全不同的词,但在含义上很接近。
为了理解文档的语义——它的含义——语义搜索索引使用嵌入模型将文档转换为浮点值向量,称为 *向量嵌入*。向量表示多维空间中的一个点,每个浮点值表示文档沿一个维度轴的位置。当嵌入的输入文档在语义上相似时,嵌入模型生成的向量嵌入彼此接近(在这个多维空间中)。
为了理解文档的语义 —— 它的含义 —— 语义搜索索引使用嵌入模型将文档转换为浮点值向量,称为 *向量嵌入*。向量表示多维空间中的一个点,每个浮点值表示文档沿一个维度轴的位置。嵌入模型生成的向量嵌入在这个多维空间中)彼此接近,当嵌入的输入文档在语义上相似时
--------
> [!NOTE]
> 我们在 ["查询执行:编译和向量化"](/ch4#sec_storage_vectorized) 中看到了术语 *向量化处理*。语义搜索中的向量有不同的含义。在向量化处理中,向量指的是可以使用专门优化的代码处理的一批位。在嵌入模型中,向量是表示多维空间中位置的浮点数列表。
> 我们在 ["查询执行:编译与向量化"](#sec_storage_vectorized) 中看到了术语 *向量化处理*。语义搜索中的向量有不同的含义。在向量化处理中,向量指的是可以用特别优化的代码处理的一批位。在嵌入模型中,向量是表示多维空间中位置的浮点数列表。
--------
例如,关于农业的维基百科页面的三维向量嵌入可能是 `[0.1, 0.22, 0.11]`。关于蔬菜的维基百科页面会非常接近,可能嵌入为 `[0.13, 0.19, 0.24]`。关于星型模式的页面可能有 `[0.82, 0.39, -0.74]` 的嵌入,相对较远。我们可以通过观察看出前两个向量比第三个更接近。
嵌入模型使用更大的向量(通常超过 1,000 个数字),但原理是相同的。我们不试图理解各个数字的含义;它们只是嵌入模型指向抽象多维空间中位置的方式。搜索引擎使用距离函数(如余弦相似度或欧几里得距离)来测量向量之间的距离。余弦相似度测量两个向量角度的余弦以确定它们有多接近,而欧几里得距离测量空间中两点之间的直线距离。
嵌入模型使用更大的向量(通常超过 1,000 个数字),但原理是相同的。我们不试图理解各个数字的含义;它们只是嵌入模型指向抽象多维空间中位置的一种方式。搜索引擎使用距离函数(如余弦相似度或欧几里得距离)来测量向量之间的距离。余弦相似度测量两个向量角度的余弦以确定它们的接近程度,而欧几里得距离测量空间中两点之间的直线距离。
许多早期的嵌入模型,如 Word2Vec [^98]、BERT [^99] 和 GPT [^100],处理文本数据。此类模型通常实现为神经网络。研究人员继续为视频、音频和图像创建嵌入模型。最近,模型架构已变得 *多模态*:单个模型可以为多种模态(如文本和图像)生成向量嵌入。
许多早期的嵌入模型,如 Word2Vec [^98]、BERT [^99] 和 GPT [^100] 都处理文本数据。这些模型通常实现为神经网络。研究人员继续为视频、音频和图像创建嵌入模型。最近,模型架构已经变成 *多模态*:单个模型可以为多种模态(如文本和图像)生成向量嵌入。
当用户输入查询时,语义搜索引擎使用嵌入模型生成向量嵌入。用户的查询和相关上下文(如用户的位置)被输入嵌入模型。嵌入模型生成查询的向量嵌入后,搜索引擎必须使用向量索引找到具有相似向量嵌入的文档。
语义搜索引擎在用户输入查询时使用嵌入模型生成向量嵌入。用户的查询和相关上下文(如用户的位置)被输入嵌入模型。嵌入模型生成查询的向量嵌入后,搜索引擎必须使用向量索引找到具有相似向量嵌入的文档。
向量索引存储文档集合的向量嵌入。要查询索引,你传入查询的向量嵌入,索引返回其向量最接近查询向量的文档。由于我们之前看到的 R 树对于具有许多维度的向量效果不佳,因此使用专门的向量索引,例如:
向量索引存储文档集合的向量嵌入。要查询索引,你传入查询的向量嵌入,索引返回其向量最接近查询向量的文档。由于我们之前看到的 R 树不适用于多维向量,因此使用专门的向量索引,例如:
平面索引
: 向量按原样存储在索引中。查询必须读取每个向量并测量其与查询向量的距离。平面索引是准确的,但测量查询每个向量之间的距离很慢。
平面索引Flat indexes
: 向量按原样存储在索引中。查询必须读取每个向量并测量其与查询向量的距离。平面索引是准确的,但测量查询每个向量之间的距离很慢。
倒排文件IVF索引
: 向量空间被聚类向量的分区(称为 *质心*以减少必须比较的向量数量。IVF 索引比平面索引更快,但只能给出近似结果:查询和文档可能落入不同的分区,即使它们彼此接近。IVF 索引的查询首先定义 *探针*,这只是要检查的分区数。使用更多探针的查询将更准确,但会更慢,因为必须比较更多向量。
: 向量空间被聚类向量的分区(称为 *质心*以减少必须比较的向量数量。IVF 索引比平面索引更快,但只能给出近似结果:即使查询和文档彼此接近,它们也可能落入不同的分区。IVF 索引的查询首先定义 *探针*,这只是要检查的分区数。使用更多探针的查询将更准确,但会更慢,因为必须比较更多向量。
层可导航小世界HNSW
: HNSW 索引维护向量空间的多个层,如 [图 4-11](/ch4#fig_vector_hnsw) 所示。每一层都表示为一个图,其中节点表示向量,边表示与附近向量的接近度。查询首先在最顶层定位最近的向量,该层具有少量节点。然后查询移动到下面层的同一节点,并跟随该层中的边,该层连接更密集,寻找更接近查询向量的向量。该过程一直持续到到达最后一层。与 IVF 索引一样HNSW 索引是近似的。
可导航小世界HNSW
: HNSW 索引维护向量空间的多个层,如 [图 4-11](#fig_vector_hnsw) 所示。每一层都表示为一个图,其中节点表示向量,边表示与附近向量的接近度。查询首先在最顶层定位最近的向量,该层具有少量节点。然后查询移动到下面层的同一节点,并跟随该层中的边,该层连接更密集,寻找更接近查询向量的向量。该过程继续直到到达最后一层。与 IVF 索引一样HNSW 索引是近似的。
{{< figure src="/fig/ddia_0411.png" id="fig_vector_hnsw" caption="图 4-11. 在 HNSW 索引中搜索最接近给定查询向量的数据库条目。" class="w-full my-4" >}}
许多流行的向量数据库实现 IVF 和 HNSW 索引。Facebook 的 Faiss 库有每种的许多变体 [^101]PostgreSQL 的 pgvector 也支持两者 [^102]。IVF 和 HNSW 算法的完整细节超出了本书的范围,但它们的论文是很好的资源 [^103] [^104]。
许多流行的向量数据库实现了 IVF 和 HNSW 索引。Facebook 的 Faiss 库有每种的许多变体 [^101]PostgreSQL 的 pgvector 也支持两者 [^102]。IVF 和 HNSW 算法的完整细节超出了本书的范围,但它们的论文是极好的资源 [^103] [^104]。
## 总结 {#summary}
在本章中,我们试图深入了解数据库如何执行存储和检索。当你将数据存储在数据库中时会发生什么,以及当你稍后再次查询数据时数据库会做什么?
在本章中,我们试图深入了解数据库如何执行存储和检索。当你在数据库中存储数据时会发生什么,当你稍后再次查询数据时数据库会做什么?
["分析与操作系统"](/ch1#sec_introduction_analytics) 介绍了事务处理OLTP和分析OLAP之间的区别。在本章中我们看到为 OLTP 优化的存储引擎与为分析优化的存储引擎看起来非常不同:
["分析型与事务型系统"](/ch1#sec_introduction_analytics) 介绍了事务处理OLTP和分析OLAP之间的区别。在本章中我们看到为 OLTP 优化的存储引擎与为分析优化的存储引擎看起来非常不同:
* OLTP 系统针对大量请求进行了优化,每个请求读取和写入少量记录,并且需要快速响应。记录通常通过主键或二级索引访问,这些索引通常是从键到记录的有序映射,也支持范围查询。
* 数据仓库和类似的分析系统针对扫描大量记录的复杂读取查询进行了优化。它们通常使用列式存储布局和压缩,以最大限度地减少此类查询需要从磁盘读取的数据量,并使用查询的即时编译或向量化来最大限度地减少处理数据所花费的 CPU 时间。
* 数据仓库和类似的分析系统针对扫描大量记录的复杂读取查询进行了优化。它们通常使用带有压缩的列式存储布局,以最小化此类查询需要从磁盘读取的数据量,并使用查询的即时编译或向量化来最小化处理数据所花费的 CPU 时间。
在 OLTP 方面,我们看到了两主要思想流派的存储引擎:
在 OLTP 方面,我们看到了两主要思想流派的存储引擎:
* 日志结构方法,只允许追加到文件和删除过时文件但从不更新已写入的文件。SSTable、LSM 树、RocksDB、Cassandra、HBase、Scylla、Lucene 等属于这一组。一般来说,日志结构存储引擎往往提供高写入吞吐量。
* 就地更新方法,将磁盘视为一组可以覆盖的固定大小页。B 树是这种理念的最大例子,用于所有主要的关系 OLTP 数据库以及许多非关系数据库。作为经验法则B 树往往更适合读取,提供比日志结构存储更高的读取吞吐量和更低的响应时间。
* 日志结构方法只允许追加到文件和删除过时文件但从不更新已写入的文件。SSTable、LSM 树、RocksDB、Cassandra、HBase、Scylla、Lucene 等属于这一组。一般来说,日志结构存储引擎往往提供高写入吞吐量。
* 就地更新方法将磁盘视为一组可以覆盖的固定大小页。B 树是这种理念的最大例子,用于所有主要的关系 OLTP 数据库以及许多非关系数据库。作为经验法则B 树往往更适合读取,提供比日志结构存储更高的读取吞吐量和更低的响应时间。
然后,我们研究了可以同时搜索多个条件的索引:多维索引(如 R 树)可以同时按纬度和经度搜索地图上的点,全文搜索索引可以搜索同一文本中出现的多个关键字。最后,向量数据库用于文本文档和其他媒体的语义搜索;它们使用具有更多维度的向量,并通过比较向量相似性来查找相似文档。
然后我们查看了可以同时搜索多个条件的索引:多维索引(如 R 树)可以同时按纬度和经度搜索地图上的点,全文搜索索引可以搜索出现在同一文本中的多个关键字。最后,向量数据库用于文本文档和其他媒体的语义搜索;它们使用具有大量维度的向量,并通过比较向量相似性来查找相似文档。
作为应用程序开发人员,如果你掌握了有关存储引擎内部的这些知识,你就能更好地了解哪种工具最适合你的特定应用程序。如果你需要调整数据库的调优参数,这种理解使你能够想象更高或更低的值可能产生什么效果
作为应用程序开发人员,如果你掌握了有关存储引擎内部的这些知识,你就能更好地知道哪种工具最适合你的特定应用程序。如果你需要调整数据库的调优参数,这种理解使你能够想象更高或更低的值可能产生什么影响
尽管本章不能让你成为调整任何特定存储引擎的专家,但希望它为你提供了足够的词汇和想法,让你能够理解所选数据库的文档。
尽管本章不能让你成为调优任何特定存储引擎的专家,但它希望为你提供了足够的词汇和想法,使你能够理解你选择的数据库的文档。

View File

@ -34,7 +34,7 @@ breadcrumbs: false
向前兼容性的另一个挑战如 [图 5-1](/ch5#fig_encoding_preserve_field) 所示。假设你向记录模式添加了一个字段,新代码创建了包含该新字段的记录并将其存储在数据库中。随后,旧版本的代码(尚不知道新字段)读取记录,更新它,然后写回。在这种情况下,理想的行为通常是旧代码保持新字段不变,即使它无法解释。但是,如果记录被解码为不显式保留未知字段的模型对象,数据可能会丢失,如 [图 5-1](/ch5#fig_encoding_preserve_field) 所示。
{{< figure src="/fig/ddia_0501.png" id="fig_encoding_preserve_field" caption="当旧版本的应用程序更新之前由新版本应用程序写入的数据时,如果不小心,数据可能会丢失。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0501.png" id="fig_encoding_preserve_field" caption="图 5-1. 当旧版本的应用程序更新之前由新版本应用程序写入的数据时,如果不小心,数据可能会丢失。" class="w-full my-4" >}}
在本章中,我们将研究几种编码数据的格式,包括 JSON、XML、Protocol Buffers 和 Avro。特别是我们将研究它们如何处理模式变化以及它们如何支持新旧数据和代码需要共存的系统。然后我们将讨论这些格式如何用于数据存储和通信在数据库、Web 服务、REST API、远程过程调用RPC、工作流引擎以及事件驱动系统如 actor 和消息队列)中。