2
0
Fork 0
mirror of https://github.com/Vonng/ddia.git synced 2026-06-21 00:47:05 +08:00

align zh translation format

This commit is contained in:
Feng Ruohang 2026-02-15 13:46:26 +08:00
parent 52f5581c80
commit d11a2936ae
14 changed files with 433 additions and 435 deletions

View file

@ -42,10 +42,10 @@ breadcrumbs: false
为了帮助你了解可以做出哪些选择,本章比较了几个对比概念,并探讨了它们的权衡:
* 事务型系统和分析型系统之间的区别(["分析型与事务型系统"](/ch1#sec_introduction_analytics)
* 云服务和自托管系统的利弊(["云服务与自托管"](/ch1#sec_introduction_cloud)
* 何时从单节点系统转向分布式系统(["分布式与单节点系统"](/ch1#sec_introduction_distributed));以及
* 平衡业务需求和用户权利(["数据系统、法律与社会"](/ch1#sec_introduction_compliance))。
* 事务型系统和分析型系统之间的区别(["分析型与事务型系统"](#sec_introduction_analytics)
* 云服务和自托管系统的利弊(["云服务与自托管"](#sec_introduction_cloud)
* 何时从单节点系统转向分布式系统(["分布式与单节点系统"](#sec_introduction_distributed));以及
* 平衡业务需求和用户权利(["数据系统、法律与社会"](#sec_introduction_compliance))。
此外,本章还会引入贯穿全书的关键术语。
@ -58,7 +58,7 @@ breadcrumbs: false
## 分析型与事务型系统 {#sec_introduction_analytics}
如果你在企业中从事数据系统工作,往往会遇到几类不同的数据使用者。第一类是 **后端工程师**,他们构建服务来处理读取与更新数据的请求;这些服务通常直接面向外部用户,或通过其他服务间接提供能力(参见["微服务与无服务器"](/ch1#sec_introduction_microservices))。有时服务也只供组织内部使用。
如果你在企业中从事数据系统工作,往往会遇到几类不同的数据使用者。第一类是 **后端工程师**,他们构建服务来处理读取与更新数据的请求;这些服务通常直接面向外部用户,或通过其他服务间接提供能力(参见["微服务与无服务器"](#sec_introduction_microservices))。有时服务也只供组织内部使用。
除了管理后端服务的团队外,通常还有两类人需要访问组织的数据:**业务分析师**,他们生成关于组织活动的报告,以帮助管理层做出更好的决策(**商业智能** 或 **BI**);以及 **数据科学家**他们在数据中寻找新的见解或创建由数据分析和机器学习AI支持的面向用户的产品功能例如电子商务网站上的“购买了 X 的人也购买了 Y”推荐、风险评分或垃圾邮件过滤等预测分析以及搜索结果排名
@ -86,7 +86,7 @@ breadcrumbs: false
* 在我们最近的促销期间,我们比平时多卖出了多少香蕉?
* 哪个品牌的婴儿食品最常与 X 品牌尿布一起购买?
这些类型的查询产生的报告对商业智能很重要,可以帮助管理层决定下一步做什么。为了将这种使用数据库的模式与事务处理区分开来,它被称为 **联机分析处理**OLAP[^5]。OLTP 和分析之间的区别并不总是很明确,但[表 1-1](/ch1#tab_oltp_vs_olap) 列出了一些典型特征。
这些类型的查询产生的报告对商业智能很重要,可以帮助管理层决定下一步做什么。为了将这种使用数据库的模式与事务处理区分开来,它被称为 **联机分析处理**OLAP[^5]。OLTP 和分析之间的区别并不总是很明确,但[表 1-1](#tab_oltp_vs_olap) 列出了一些典型特征。
{{< figure id="tab_oltp_vs_olap" title="表 1-1. 事务型系统和分析型系统特征比较" class="w-full my-4" >}}
@ -122,7 +122,7 @@ breadcrumbs: false
相比之下,**数据仓库** 是一个单独的数据库,分析师可以随心所欲地查询,而不会影响 OLTP 操作 [^7]。正如我们将在[第 4 章](/ch4#ch_storage)中看到的,数据仓库通常以与 OLTP 数据库非常不同的方式存储数据,以优化分析中常见的查询类型。
数据仓库包含公司中所有各种 OLTP 系统中数据的只读副本。数据从 OLTP 数据库中提取(使用定期数据转储或连续更新流),转换为分析友好的模式,进行清理,然后加载到数据仓库中。这种将数据导入数据仓库的过程称为 **提取-转换-加载**ETL如[图 1-1](/ch1#fig_dwh_etl) 所示。有时 **转换****加载** 步骤的顺序会互换(即,先加载,再在数据仓库中进行转换),从而产生 **ELT**
数据仓库包含公司中所有各种 OLTP 系统中数据的只读副本。数据从 OLTP 数据库中提取(使用定期数据转储或连续更新流),转换为分析友好的模式,进行清理,然后加载到数据仓库中。这种将数据导入数据仓库的过程称为 **提取-转换-加载**ETL如[图 1-1](#fig_dwh_etl) 所示。有时 **转换****加载** 步骤的顺序会互换(即,先加载,再在数据仓库中进行转换),从而产生 **ELT**
{{< figure src="/fig/ddia_0101.png" id="fig_dwh_etl" caption="图 1-1. ETL 到数据仓库的简化概述。" class="w-full my-4" >}}
@ -130,7 +130,7 @@ breadcrumbs: false
一些数据库系统提供 **混合事务/分析处理**HTAP目标是在单个系统中同时支持 OLTP 和分析,而无需从一个系统 ETL 到另一个系统 [^8] [^9]。然而,许多 HTAP 系统内部由一个 OLTP 系统与一个单独的分析系统耦合组成,隐藏在公共接口后面——因此两者之间的区别对于理解这些系统如何工作仍然很重要。
此外,尽管 HTAP 已出现,但由于目标和约束不同,事务型系统与分析型系统分离仍很常见。尤其是,让每个事务型系统拥有自己的数据库通常被视为良好实践(参见["微服务与无服务器"](/ch1#sec_introduction_microservices)),这会形成数百个相互独立的事务型数据库;与之对应,企业往往只有一个统一的数据仓库,以便分析师能在单个查询里组合多个事务型系统的数据。
此外,尽管 HTAP 已出现,但由于目标和约束不同,事务型系统与分析型系统分离仍很常见。尤其是,让每个事务型系统拥有自己的数据库通常被视为良好实践(参见["微服务与无服务器"](#sec_introduction_microservices)),这会形成数百个相互独立的事务型数据库;与之对应,企业往往只有一个统一的数据仓库,以便分析师能在单个查询里组合多个事务型系统的数据。
因此HTAP 不会取代数据仓库。相反,它在同一应用程序既需要执行扫描大量行的分析查询,又需要以低延迟读取和更新单个记录的场景中很有用。例如,欺诈检测可能涉及此类工作负载 [^10]。
@ -145,7 +145,7 @@ breadcrumbs: false
尽管已经有人在努力将机器学习算子添加到 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]。除了更灵活之外,这通常也比关系数据存储更便宜,因为数据湖可以使用商品化的文件存储,如对象存储(参见["云原生系统架构"](#sec_introduction_cloud_native))。
ETL 过程已经泛化为 **数据管道**,在某些情况下,数据湖已成为从事务型系统到数据仓库路径上的中间站。数据湖包含事务型系统产生的“原始”形式的数据,没有转换为关系数据仓库模式。这种方法的优势在于,每个数据消费者都可以将原始数据转换为最适合其需求的形式。它被称为 **寿司原则**:“原始数据更好”[^16]。
@ -155,7 +155,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。
#### 超越数据湖 {#beyond-the-data-lake}
随着分析实践的成熟,组织越来越重视分析系统与数据管道的管理和运维,这一点在 DataOps 宣言中已有体现 [^18]。其中一部分是治理、隐私以及对 GDPR、CCPA 等法规的遵从;我们会在["数据系统、法律与社会"](/ch1#sec_introduction_compliance)和["立法与行业自律"](/ch14#sec_future_legislation)中讨论。
随着分析实践的成熟,组织越来越重视分析系统与数据管道的管理和运维,这一点在 DataOps 宣言中已有体现 [^18]。其中一部分是治理、隐私以及对 GDPR、CCPA 等法规的遵从;我们会在["数据系统、法律与社会"](#sec_introduction_compliance)和["立法与行业自律"](/ch14#sec_future_legislation)中讨论。
此外,分析数据的提供形式也越来越多样:不仅有文件和关系表,也有事件流(见[第 12 章](/ch12#ch_stream))。基于文件的分析通常通过周期性重跑(例如每天一次)来响应数据变化,而流处理能够让分析系统在秒级响应事件。对于时效性要求高的场景,这种方式很有价值,例如识别并阻断潜在的欺诈或滥用行为。
@ -189,7 +189,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。
归根结底,这是一个关于业务优先级的问题。公认的管理智慧是,作为组织核心竞争力或竞争优势的事物应该在内部完成,而非核心、例行或常见的事物应该留给供应商 [^21]。
举一个极端的例子,大多数公司不会自己发电(除非他们是能源公司,而且不考虑紧急备用电源),因为从电网购买电力更便宜。
对于软件,需要做出的两个重要决定是谁构建软件和谁部署它。有一系列可能性,每个决定都在不同程度上外包,如[图 1-2](/ch1#fig_cloud_spectrum) 所示。
对于软件,需要做出的两个重要决定是谁构建软件和谁部署它。有一系列可能性,每个决定都在不同程度上外包,如[图 1-2](#fig_cloud_spectrum) 所示。
一个极端是你自己编写并在内部运行的定制软件另一个极端是广泛使用的云服务或软件即服务SaaS产品由外部供应商实施和运营你只能通过 Web 界面或 API 访问。
{{< figure src="/fig/ddia_0102.png" id="fig_cloud_spectrum" caption="图 1-2. 软件类型及其运维的范围。" class="w-full my-4" >}}
@ -212,7 +212,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。
另一方面,如果你需要一个你还不知道如何部署和运维的系统,那么采用云服务通常比学习自己管理系统更容易、更快。
如果你必须专门雇用和培训员工来维护和运营系统,那可能会变得非常昂贵。
使用云时你仍然需要一个运维团队(参见["云时代的运维"](/ch1#sec_introduction_operations)),但外包基本的系统管理可以让你的团队专注于更高层次的问题。
使用云时你仍然需要一个运维团队(参见["云时代的运维"](#sec_introduction_operations)),但外包基本的系统管理可以让你的团队专注于更高层次的问题。
当你将系统的运维外包给专门运维该服务的公司时,可能会带来更好的服务,因为供应商在向许多客户提供服务中获得了专业运维知识。
另一方面,如果你自己运维服务,你可以配置和调整它,以专门针对你特定的工作负载进行优化,而云服务不太可能愿意替你进行此类定制。
@ -241,7 +241,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。
术语 **云原生** 用于描述旨在利用云服务的架构。
原则上,几乎任何可自托管的软件都可以做成云服务;事实上,许多主流数据系统都已有托管版本。
不过,从零设计为云原生的系统已经展示出若干优势:同等硬件下性能更好、故障恢复更快、能更快按负载扩缩计算资源,并支持更大数据集 [^25] [^26] [^27]。[表 1-2](/ch1#tab_cloud_native_dbs) 给出两类系统的一些示例。
不过,从零设计为云原生的系统已经展示出若干优势:同等硬件下性能更好、故障恢复更快、能更快按负载扩缩计算资源,并支持更大数据集 [^25] [^26] [^27]。[表 1-2](#tab_cloud_native_dbs) 给出两类系统的一些示例。
{{< figure id="tab_cloud_native_dbs" title="表 1-2. 自托管与云原生数据库系统示例" class="w-full my-4" >}}
@ -275,7 +275,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。
为了解决这个问题,云原生服务通常避免使用虚拟磁盘,而是建立在针对特定工作负载优化的专用存储服务之上。对象存储服务(如 S3设计用于长期存储相当大的文件大小从数百 KB 到几 GB 不等。数据库中存储的单个行或值通常比这小得多;因此,云数据库通常在单独的服务中管理较小的值,并将较大的数据块(包含许多单个值)存储在对象存储中 [^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 之外的某个地方运行分析代码。这意味着通过网络传输数据,我们将在["分布式与单节点系统"](#sec_introduction_distributed)中进一步讨论。
此外,云原生系统通常是 **多租户** 的,这意味着不是每个客户都有一台单独的机器,而是来自几个不同客户的数据和计算由同一服务在同一共享硬件上处理 [^32]。
@ -305,7 +305,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。
采用云服务可能比运行自己的基础设施更容易、更快,尽管学习如何使用它也有成本,也许还要解决其限制。随着越来越多的供应商提供针对不同用例的更广泛的云服务,不同服务之间的集成成为一个特别的挑战 [^39] [^40]。
ETL参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部分;面向事务处理的云服务之间也需要相互集成。目前,缺乏能促进这类集成的标准,因此往往仍要投入大量手工工作。
ETL参见["数据仓库"](#sec_introduction_dwh))只是故事的一部分;面向事务处理的云服务之间也需要相互集成。目前,缺乏能促进这类集成的标准,因此往往仍要投入大量手工工作。
无法完全外包给云服务的其他运维方面包括维护应用程序及其使用的库的安全性、管理你自己的服务之间的交互、监控服务的负载,以及追踪问题的原因,例如性能下降或中断。虽然云正在改变运维的角色,但对运维的需求比以往任何时候都大。
@ -399,7 +399,7 @@ ETL参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部
每个从事此类系统工作的人都有责任考虑道德影响并确保他们遵守相关法律。没有必要让每个人都成为法律和道德专家,但对法律和道德原则的基本认识与分布式系统中的一些基础知识同样重要。
法律考虑正在影响数据系统设计的基础 [^61]。例如GDPR 授予个人在请求时删除其数据的权利(有时称为 **被遗忘权**)。然而,正如我们将在本书中看到的,许多数据系统依赖不可变构造(如仅追加日志)作为其设计的一部分;我们如何确保删除应该不可变的文件中间的某些数据?我们如何处理已被纳入派生数据集(参见["记录系统与派生数据"](/ch1#sec_introduction_derived))的数据删除,例如机器学习模型的训练数据?回答这些问题会带来新的工程挑战。
法律考虑正在影响数据系统设计的基础 [^61]。例如GDPR 授予个人在请求时删除其数据的权利(有时称为 **被遗忘权**)。然而,正如我们将在本书中看到的,许多数据系统依赖不可变构造(如仅追加日志)作为其设计的一部分;我们如何确保删除应该不可变的文件中间的某些数据?我们如何处理已被纳入派生数据集(参见["记录系统与派生数据"](#sec_introduction_derived))的数据删除,例如机器学习模型的训练数据?回答这些问题会带来新的工程挑战。
目前,我们对于哪些特定技术或系统架构应被视为“符合 GDPR”没有明确的指导方针。法规故意不强制要求特定技术因为随着技术的进步这些技术可能会迅速变化。相反法律文本规定了需要解释的高层级原则。这意味着如何遵守隐私法规的问题没有简单的答案但我们将通过这个视角来看待本书中的一些技术。

View file

@ -48,25 +48,25 @@ breadcrumbs: false
{{< figure src="/fig/ddia_1001.png" id="fig_consistency_linearizability_0" caption="图 10-1. 如果这个数据库是线性一致的,那么 Alice 的读取要么返回 1 而不是 0要么 Bob 的读取返回 0 而不是 1。" class="w-full my-4" >}}
[图 10-1](/ch10#fig_consistency_linearizability_0) 显示了一个非线性一致的体育网站示例 [^4]。Aaliyah 和 Bryce 坐在同一个房间里都在查看手机想要了解他们最喜欢的球队比赛的结果。就在最终比分宣布后Aaliyah 刷新了页面,看到了获胜者的公告,并兴奋地告诉了 Bryce。Bryce 怀疑地在自己的手机上点击了 *刷新*,但他的请求发送到了一个滞后的数据库副本,因此他的手机显示比赛仍在进行中。
[图 10-1](#fig_consistency_linearizability_0) 显示了一个非线性一致的体育网站示例 [^4]。Aaliyah 和 Bryce 坐在同一个房间里都在查看手机想要了解他们最喜欢的球队比赛的结果。就在最终比分宣布后Aaliyah 刷新了页面,看到了获胜者的公告,并兴奋地告诉了 Bryce。Bryce 怀疑地在自己的手机上点击了 *刷新*,但他的请求发送到了一个滞后的数据库副本,因此他的手机显示比赛仍在进行中。
如果 Aaliyah 和 Bryce 同时点击刷新他们得到两个不同的查询结果就不会那么令人惊讶了因为他们不知道他们各自的请求在服务器上被处理的确切时间。然而Bryce 知道他是在听到 Aaliyah 宣布最终比分 *之后* 点击刷新按钮(发起查询)的,因此他期望他的查询结果至少与 Aaliyah 的一样新。他的查询返回过时结果这一事实违反了线性一致性。
### 什么使系统具有线性一致性? {#sec_consistency_lin_definition}
为了更好地理解线性一致性,让我们看一些更多的例子。[图 10-2](/ch10#fig_consistency_linearizability_1) 显示了三个客户端在线性一致数据库中并发读取和写入同一个对象 *x*。在分布式系统理论中,*x* 被称为 *寄存器*——在实践中,它可能是键值存储中的一个键,关系数据库中的一行,或者文档数据库中的一个文档,例如。
为了更好地理解线性一致性,让我们看一些更多的例子。[图 10-2](#fig_consistency_linearizability_1) 显示了三个客户端在线性一致数据库中并发读取和写入同一个对象 *x*。在分布式系统理论中,*x* 被称为 *寄存器*——在实践中,它可能是键值存储中的一个键,关系数据库中的一行,或者文档数据库中的一个文档,例如。
{{< figure src="/fig/ddia_1002.png" id="fig_consistency_linearizability_1" caption="图 10-2. Alice 观察到 x = 0 且 y = 1而 Bob 观察到 x = 1 且 y = 0。就好像 Alice 和 Bob 的计算机对写入发生的顺序意见不一。" class="w-full my-4" >}}
为简单起见,[图 10-2](/ch10#fig_consistency_linearizability_1) 仅显示了从客户端角度看的请求,而不是数据库的内部。每个条形代表客户端发出的请求,条形的开始是发送请求的时间,条形的结束是客户端收到响应的时间。由于网络延迟可变,客户端不知道数据库确切何时处理了它的请求——它只知道必须在客户端发送请求和接收响应之间的某个时间发生。
为简单起见,[图 10-2](#fig_consistency_linearizability_1) 仅显示了从客户端角度看的请求,而不是数据库的内部。每个条形代表客户端发出的请求,条形的开始是发送请求的时间,条形的结束是客户端收到响应的时间。由于网络延迟可变,客户端不知道数据库确切何时处理了它的请求——它只知道必须在客户端发送请求和接收响应之间的某个时间发生。
在这个例子中,寄存器有两种类型的操作:
* *read*(*x*) ⇒ *v* 表示客户端请求读取寄存器 *x* 的值,数据库返回值 *v*
* *write*(*x*, *v*) ⇒ *r* 表示客户端请求将寄存器 *x* 设置为值 *v*,数据库返回响应 *r*(可能是 *ok**error*)。
在 [图 10-2](/ch10#fig_consistency_linearizability_1) 中,*x* 的值最初为 0客户端 C 执行写入请求将其设置为 1。在此期间客户端 A 和 B 反复轮询数据库以读取最新值。A 和 B 的读取请求可能得到什么响应?
在 [图 10-2](#fig_consistency_linearizability_1) 中,*x* 的值最初为 0客户端 C 执行写入请求将其设置为 1。在此期间客户端 A 和 B 反复轮询数据库以读取最新值。A 和 B 的读取请求可能得到什么响应?
* 客户端 A 的第一个读取操作在写入开始之前完成,因此它必须明确返回旧值 0。
* 客户端 A 的最后一次读取在写入完成后开始,因此如果数据库是线性一致的,它必须明确返回新值 1因为读取必须在写入之后被处理。
@ -74,32 +74,32 @@ breadcrumbs: false
然而,这还不足以完全描述线性一致性:如果与写入并发的读取可以返回旧值或新值,那么读者可能会在写入进行时多次看到值在旧值和新值之间来回翻转。这不是我们对模拟"单一数据副本"的系统所期望的。
为了使系统线性一致,我们需要添加另一个约束,如 [图 10-3](/ch10#fig_consistency_linearizability_2) 所示。
为了使系统线性一致,我们需要添加另一个约束,如 [图 10-3](#fig_consistency_linearizability_2) 所示。
{{< figure src="/fig/ddia_1003.png" id="fig_consistency_linearizability_2" caption="图 10-3. 如果 Alice 和 Bob 有完美的时钟,线性一致性将要求返回 x = 1因为 x 的读取在写入 x = 1 完成后开始。" class="w-full my-4" >}}
在线性一致系统中,我们想象必须有某个时间点(在写入操作的开始和结束之间),*x* 的值从 0 原子地翻转到 1。因此如果一个客户端的读取返回新值 1所有后续读取也必须返回新值即使写入操作尚未完成。
这种时序依赖关系在 [图 10-3](/ch10#fig_consistency_linearizability_2) 中用箭头表示。客户端 A 是第一个读取新值 1 的。就在 A 的读取返回后B 开始新的读取。由于 B 的读取严格发生在 A 的读取之后,它也必须返回 1即使 C 的写入仍在进行中。(这与 [图 10-1](/ch10#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况相同:在 Aaliyah 读取新值后Bryce 也期望读取新值。)
这种时序依赖关系在 [图 10-3](#fig_consistency_linearizability_2) 中用箭头表示。客户端 A 是第一个读取新值 1 的。就在 A 的读取返回后B 开始新的读取。由于 B 的读取严格发生在 A 的读取之后,它也必须返回 1即使 C 的写入仍在进行中。(这与 [图 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况相同:在 Aaliyah 读取新值后Bryce 也期望读取新值。)
我们可以进一步细化这个时序图,以可视化每个操作在某个时间点原子地生效 [^5],就像 [图 10-4](/ch10#fig_consistency_linearizability_3) 中显示的更复杂的例子。在这个例子中,除了 *read**write* 之外,我们添加了第三种操作类型:
我们可以进一步细化这个时序图,以可视化每个操作在某个时间点原子地生效 [^5],就像 [图 10-4](#fig_consistency_linearizability_3) 中显示的更复杂的例子。在这个例子中,除了 *read**write* 之外,我们添加了第三种操作类型:
* *cas*(*x*, *v*old, *v*new) ⇒ *r* 表示客户端请求一个原子 *比较并设置* 操作(见 ["条件写入(比较并设置)"](/ch8#sec_transactions_compare_and_set))。如果寄存器 *x* 的当前值等于 *v*old它应该原子地设置为 *v*new。如果 *x* 的值与 *v*old 不同,则操作应该保持寄存器不变并返回错误。*r* 是数据库的响应(*ok* 或 *error*)。
[图 10-4](/ch10#fig_consistency_linearizability_3) 中的每个操作都用一条垂直线(在每个操作的条形内)标记,表示我们认为操作执行的时间。这些标记按顺序连接起来,结果必须是寄存器的有效读写序列(每次读取必须返回最近写入设置的值)。
[图 10-4](#fig_consistency_linearizability_3) 中的每个操作都用一条垂直线(在每个操作的条形内)标记,表示我们认为操作执行的时间。这些标记按顺序连接起来,结果必须是寄存器的有效读写序列(每次读取必须返回最近写入设置的值)。
线性一致性的要求是连接操作标记的线始终向前移动(从左到右),永不后退。这个要求确保了我们之前讨论的新鲜度保证:一旦写入或读取了新值,所有后续读取都会看到写入的值,直到它再次被覆盖。
{{< figure src="/fig/ddia_1004.png" id="fig_consistency_linearizability_3" caption="图 10-4. x 的读取与写入 x = 1 并发。由于我们不知道操作的确切时序,读取可以返回 0 或 1。" class="w-full my-4" >}}
[图 10-4](/ch10#fig_consistency_linearizability_3) 中有一些有趣的细节需要指出:
[图 10-4](#fig_consistency_linearizability_3) 中有一些有趣的细节需要指出:
* 首先客户端 B 发送了读取 *x* 的请求,然后客户端 D 发送了将 *x* 设置为 0 的请求,然后客户端 A 发送了将 *x* 设置为 1 的请求。然而,返回给 B 的读取值是 1A 写入的值)。这是可以的:这意味着数据库首先处理了 D 的写入,然后是 A 的写入,最后是 B 的读取。虽然这不是发送请求的顺序,但这是一个可接受的顺序,因为这三个请求是并发的。也许 B 的读取请求在网络中稍有延迟,因此它在两次写入之后才到达数据库。
* 客户端 B 的读取在客户端 A 收到数据库的响应之前返回了 1表示值 1 的写入成功。这也是可以的:这只是意味着从数据库到客户端 A 的 *ok* 响应在网络中稍有延迟。
* 这个模型不假设任何事务隔离另一个客户端可以随时更改值。例如C 首先读取 1然后读取 2因为该值在两次读取之间被 B 更改了。原子比较并设置(*cas*操作可用于检查值是否未被另一个客户端并发更改B 和 C 的 *cas* 请求成功,但 D 的 *cas* 请求失败(到数据库处理它时,*x* 的值不再是 0
* 客户端 B 的最后一次读取(在阴影条中)不是线性一致的。该操作与 C 的 *cas* 写入并发,后者将 *x* 从 2 更新到 4。在没有其他请求的情况下B 的读取返回 2 是可以的。然而,客户端 A 在 B 的读取开始之前已经读取了新值 4因此 B 不允许读取比 A 更旧的值。同样,这与 [图 10-1](/ch10#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况相同。
* 客户端 B 的最后一次读取(在阴影条中)不是线性一致的。该操作与 C 的 *cas* 写入并发,后者将 *x* 从 2 更新到 4。在没有其他请求的情况下B 的读取返回 2 是可以的。然而,客户端 A 在 B 的读取开始之前已经读取了新值 4因此 B 不允许读取比 A 更旧的值。同样,这与 [图 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况相同。
这就是线性一致性背后的直觉;形式化定义 [^1] 更精确地描述了它。可以(尽管计算成本高昂)通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序序列来测试系统的行为是否线性一致 [^6] [^7]。
@ -160,18 +160,18 @@ breadcrumbs: false
#### 跨通道时序依赖 {#cross-channel-timing-dependencies}
注意 [图 10-1](/ch10#fig_consistency_linearizability_0) 中的一个细节:如果 Aaliyah 没有大声说出比分Bryce 就不会知道他的查询结果是过时的。他只会在几秒钟后再次刷新页面最终看到最终比分。线性一致性违规之所以被注意到只是因为系统中有一个额外的通信通道Aaliyah 的声音到 Bryce 的耳朵)。
注意 [图 10-1](#fig_consistency_linearizability_0) 中的一个细节:如果 Aaliyah 没有大声说出比分Bryce 就不会知道他的查询结果是过时的。他只会在几秒钟后再次刷新页面最终看到最终比分。线性一致性违规之所以被注意到只是因为系统中有一个额外的通信通道Aaliyah 的声音到 Bryce 的耳朵)。
类似的情况可能出现在计算机系统中。例如,假设你有一个网站,用户可以上传视频,后台进程将视频转码为较低质量,以便在慢速互联网连接上流式传输。该系统的架构和数据流如 [图 10-5](/ch10#fig_consistency_transcoder) 所示。
类似的情况可能出现在计算机系统中。例如,假设你有一个网站,用户可以上传视频,后台进程将视频转码为较低质量,以便在慢速互联网连接上流式传输。该系统的架构和数据流如 [图 10-5](#fig_consistency_transcoder) 所示。
视频转码器需要明确指示执行转码作业,此指令通过消息队列从 Web 服务器发送到转码器(见 ["消息传递系统"](/ch12#sec_stream_messaging)。Web 服务器不会将整个视频放在队列中,因为大多数消息代理都是为小消息设计的,而视频可能有许多兆字节大小。相反,视频首先写入文件存储服务,写入完成后,转码指令被放入队列。
{{< figure src="/fig/ddia_1005.png" id="fig_consistency_transcoder" caption="图 10-5. 一个非线性一致的系统Alice 和 Bob 在不同时间看到上传的图像,因此 Bob 的请求基于过时的数据。" class="w-full my-4" >}}
如果文件存储服务是线性一致的,那么这个系统应该工作正常。如果它不是线性一致的,就存在竞态条件的风险:消息队列([图 10-5](/ch10#fig_consistency_transcoder) 中的步骤 3 和 4可能比存储服务内部的复制更快。在这种情况下当转码器获取原始视频步骤 5它可能会看到文件的旧版本或者根本看不到任何内容。如果它处理视频的旧版本文件存储中的原始视频和转码视频将永久不一致。
如果文件存储服务是线性一致的,那么这个系统应该工作正常。如果它不是线性一致的,就存在竞态条件的风险:消息队列([图 10-5](#fig_consistency_transcoder) 中的步骤 3 和 4可能比存储服务内部的复制更快。在这种情况下当转码器获取原始视频步骤 5它可能会看到文件的旧版本或者根本看不到任何内容。如果它处理视频的旧版本文件存储中的原始视频和转码视频将永久不一致。
这个问题的出现是因为 Web 服务器和转码器之间有两个不同的通信通道:文件存储和消息队列。如果没有线性一致性的新鲜度保证,这两个通道之间可能存在竞态条件。这种情况类似于 [图 10-1](/ch10#fig_consistency_linearizability_0),其中也存在两个通信通道之间的竞态条件:数据库复制和 Aaliyah 嘴巴到 Bryce 耳朵之间的现实音频通道。
这个问题的出现是因为 Web 服务器和转码器之间有两个不同的通信通道:文件存储和消息队列。如果没有线性一致性的新鲜度保证,这两个通道之间可能存在竞态条件。这种情况类似于 [图 10-1](#fig_consistency_linearizability_0),其中也存在两个通信通道之间的竞态条件:数据库复制和 Aaliyah 嘴巴到 Bryce 耳朵之间的现实音频通道。
如果你有一个可以接收推送通知的移动应用程序,并且应用程序在收到推送通知时从服务器获取一些数据,就会发生类似的竞态条件。如果数据获取可能发送到滞后的副本,可能会发生推送通知快速通过,但后续获取没有看到推送通知所涉及的数据。
@ -204,14 +204,14 @@ breadcrumbs: false
#### 线性一致性与仲裁 {#sec_consistency_quorum_linearizable}
直观地说,在 Dynamo 风格的模型中,仲裁读写似乎应该是线性一致的。然而,当我们有可变的网络延迟时,可能会出现竞态条件,如 [图 10-6](/ch10#fig_consistency_leaderless) 所示。
直观地说,在 Dynamo 风格的模型中,仲裁读写似乎应该是线性一致的。然而,当我们有可变的网络延迟时,可能会出现竞态条件,如 [图 10-6](#fig_consistency_leaderless) 所示。
{{< figure src="/fig/ddia_1006.png" id="fig_consistency_leaderless" caption="图 10-6. 如果网络延迟是可变的,仲裁不足以确保线性一致性。" class="w-full my-4" >}}
在 [图 10-6](/ch10#fig_consistency_leaderless) 中,*x* 的初始值为 0写入客户端通过向所有三个副本发送写入*n* = 3*w* = 3*x* 更新为 1。同时客户端 A 从两个节点的仲裁(*r* = 2读取并在其中一个节点上看到新值 1。同时与写入并发客户端 B 从不同的两个节点仲裁读取,并从两者获得旧值 0。
在 [图 10-6](#fig_consistency_leaderless) 中,*x* 的初始值为 0写入客户端通过向所有三个副本发送写入*n* = 3*w* = 3*x* 更新为 1。同时客户端 A 从两个节点的仲裁(*r* = 2读取并在其中一个节点上看到新值 1。同时与写入并发客户端 B 从不同的两个节点仲裁读取,并从两者获得旧值 0。
仲裁条件得到满足(*w* + *r* > *n*但这种执行仍然不是线性一致的B 的请求在 A 的请求完成后开始,但 B 返回旧值而 A 返回新值。(这又是 [图 10-1](/ch10#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况。)
仲裁条件得到满足(*w* + *r* > *n*但这种执行仍然不是线性一致的B 的请求在 A 的请求完成后开始,但 B 返回旧值而 A 返回新值。(这又是 [图 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况。)
可以使 Dynamo 风格的仲裁线性一致,但代价是降低性能:读者必须同步执行读修复(见 ["追赶错过的写入"](/ch6#sec_replication_read_repair)),然后才能将结果返回给应用程序 [^24]。此外,在写入之前,写入者必须读取节点仲裁的最新状态以获取任何先前写入的最新时间戳,并确保新写入具有更大的时间戳 [^25] [^26]。然而Riak 由于性能损失而不执行同步读修复。Cassandra 确实等待仲裁读取时的读修复完成 [^27],但由于它使用日历时钟作为时间戳而失去了线性一致性。
@ -223,7 +223,7 @@ breadcrumbs: false
由于某些复制方法可以提供线性一致性而其他方法不能,因此更深入地探讨线性一致性的利弊是很有趣的。
我们已经在 [第六章](/ch6) 中讨论了不同复制方法的一些用例;例如,我们看到多主复制通常是多区域复制的良好选择(见 ["地理分布式操作"](/ch6#sec_replication_multi_dc))。[图 10-7](/ch10#fig_consistency_cap_availability) 展示了这种部署的示例。
我们已经在 [第六章](/ch6) 中讨论了不同复制方法的一些用例;例如,我们看到多主复制通常是多区域复制的良好选择(见 ["地理分布式操作"](/ch6#sec_replication_multi_dc))。[图 10-7](#fig_consistency_cap_availability) 展示了这种部署的示例。
{{< figure src="/fig/ddia_1007.png" id="fig_consistency_cap_availability" caption="图 10-7. 如果客户端由于网络分区而无法联系足够的副本,它们就无法处理写入。" class="w-full my-4" >}}
@ -282,12 +282,12 @@ CP/AP 分类方案还有几个进一步的缺陷 [^4]。*一致性* 被形式化
在许多应用程序中,你需要在创建数据库记录时为它们分配某种唯一的 ID这给了你一个可以引用这些记录的主键。在单节点数据库中通常使用自增整数它的优点是只需要 64 位(如果你确定永远不会有超过 40 亿条记录,甚至可以使用 32 位,但这是有风险的)来存储。
这种自增 ID 的另一个优点是ID 的顺序告诉你记录创建的顺序。例如,[图 10-8](/ch10#fig_consistency_id_generator) 显示了一个聊天应用程序,它在发布聊天消息时为其分配自增 ID。然后你可以按 ID 递增的顺序显示消息生成的聊天线程将有意义Aaliyah 发布了一个被分配 ID 1 的问题,而 Bryce 对该问题的回答被分配了一个更大的 ID即 3。
这种自增 ID 的另一个优点是ID 的顺序告诉你记录创建的顺序。例如,[图 10-8](#fig_consistency_id_generator) 显示了一个聊天应用程序,它在发布聊天消息时为其分配自增 ID。然后你可以按 ID 递增的顺序显示消息生成的聊天线程将有意义Aaliyah 发布了一个被分配 ID 1 的问题,而 Bryce 对该问题的回答被分配了一个更大的 ID即 3。
{{< figure src="/fig/ddia_1008.png" id="fig_consistency_id_generator" caption="图 10-8. 两个不同的节点可能生成冲突的 ID。" class="w-full my-4" >}}
这个单节点 ID 生成器是线性一致系统的另一个例子。每个获取 ID 的请求都是一个原子地递增计数器并返回旧计数器值的操作(*获取并增加* 操作);线性一致性确保如果 Aaliyah 的消息发布在 Bryce 的发布开始之前完成,那么 Bryce 的 ID 必须大于 Aaliyah 的。[图 10-8](/ch10#fig_consistency_id_generator) 中 Aaliyah 和 Caleb 的消息是并发的,因此线性一致性不指定它们的 ID 必须如何排序,只要它们是唯一的。
这个单节点 ID 生成器是线性一致系统的另一个例子。每个获取 ID 的请求都是一个原子地递增计数器并返回旧计数器值的操作(*获取并增加* 操作);线性一致性确保如果 Aaliyah 的消息发布在 Bryce 的发布开始之前完成,那么 Bryce 的 ID 必须大于 Aaliyah 的。[图 10-8](#fig_consistency_id_generator) 中 Aaliyah 和 Caleb 的消息是并发的,因此线性一致性不指定它们的 ID 必须如何排序,只要它们是唯一的。
内存中的单节点 ID 生成器很容易实现:你可以使用 CPU 提供的原子递增指令,它允许多个线程安全地递增同一个计数器。使计数器持久化需要更多的努力,这样节点就可以崩溃并重新启动而不重置计数器值,这将导致重复的 ID。但真正的问题是
@ -333,14 +333,14 @@ CP/AP 分类方案还有几个进一步的缺陷 [^4]。*一致性* 被形式化
幸运的是,有一种生成逻辑时间戳的简单方法,它与因果关系 *一致*,你可以将其用作分布式 ID 生成器。它被称为 *Lamport 时钟*,由 Leslie Lamport 在 1978 年提出 [^54],现在是分布式系统领域被引用最多的论文之一。
[图 10-9](/ch10#fig_consistency_lamport_ts) 显示了 Lamport 时钟如何在 [图 10-8](/ch10#fig_consistency_id_generator) 的聊天示例中工作。每个节点都有一个唯一标识符,在 [图 10-9](/ch10#fig_consistency_lamport_ts) 中是名称"Aaliyah"、"Bryce"或"Caleb",但在实践中可能是随机 UUID 或类似的东西。此外每个节点都保留它已处理的操作数的计数器。Lamport 时间戳就是一对(*计数器**节点 ID*)。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点 ID每个时间戳都是唯一的。
[图 10-9](#fig_consistency_lamport_ts) 显示了 Lamport 时钟如何在 [图 10-8](#fig_consistency_id_generator) 的聊天示例中工作。每个节点都有一个唯一标识符,在 [图 10-9](#fig_consistency_lamport_ts) 中是名称"Aaliyah"、"Bryce"或"Caleb",但在实践中可能是随机 UUID 或类似的东西。此外每个节点都保留它已处理的操作数的计数器。Lamport 时间戳就是一对(*计数器**节点 ID*)。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点 ID每个时间戳都是唯一的。
{{< figure src="/fig/ddia_1009.png" id="fig_consistency_lamport_ts" caption="图 10-9. Lamport 时间戳提供与因果关系一致的全序。" class="w-full my-4" >}}
每次节点生成时间戳时,它都会递增其计数器值并使用新值。此外,每次节点看到来自另一个节点的时间戳时,如果该时间戳中的计数器值大于其本地计数器值,它会将其本地计数器增加到与时间戳中的值匹配。
在 [图 10-9](/ch10#fig_consistency_lamport_ts) 中Aaliyah 在发布自己的消息时还没有看到 Caleb 的消息,反之亦然。假设两个用户都以初始计数器值 0 开始,因此都递增其本地计数器并将新计数器值 1 附加到其消息。当 Bryce 收到这些消息时,他将本地计数器值增加到 1。最后Bryce 向 Aaliyah 的消息发送回复,为此他递增本地计数器并将新值 2 附加到消息。
在 [图 10-9](#fig_consistency_lamport_ts) 中Aaliyah 在发布自己的消息时还没有看到 Caleb 的消息,反之亦然。假设两个用户都以初始计数器值 0 开始,因此都递增其本地计数器并将新计数器值 1 附加到其消息。当 Bryce 收到这些消息时,他将本地计数器值增加到 1。最后Bryce 向 Aaliyah 的消息发送回复,为此他递增本地计数器并将新值 2 附加到消息。
要比较两个 Lamport 时间戳,我们首先比较它们的计数器值:例如,(2, "Bryce") 大于 (1, "Aaliyah"),也大于 (1, "Caleb")。如果两个时间戳具有相同的计数器,我们改为比较它们的节点 ID使用通常的字典序字符串比较。因此此示例中的时间戳顺序是 (1, "Aaliyah") < (1, "Caleb") < (2, "Bryce")。
@ -361,7 +361,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制:
在 ["多版本并发控制MVCC"](/ch8#sec_transactions_snapshot_impl) 中,我们讨论了快照隔离通常是如何实现的:本质上,通过给每个事务一个事务 ID并允许每个事务看到由 ID 较低的事务进行的写入,但使 ID 较高的事务的写入不可见。Lamport 时钟和混合逻辑时钟是生成这些事务 ID 的好方法,因为它们确保快照与因果关系一致 [^56]。
当并发生成多个时间戳时,这些算法会任意排序它们。这意味着当你查看两个时间戳时,你通常无法判断它们是并发生成的还是一个发生在另一个之前。(在 [图 10-9](/ch10#fig_consistency_lamport_ts) 的示例中,你实际上可以判断 Aaliyah 和 Caleb 的消息必须是并发的,因为它们具有相同的计数器值,但当计数器值不同时,你无法判断它们是否并发。)
当并发生成多个时间戳时,这些算法会任意排序它们。这意味着当你查看两个时间戳时,你通常无法判断它们是并发生成的还是一个发生在另一个之前。(在 [图 10-9](#fig_consistency_lamport_ts) 的示例中,你实际上可以判断 Aaliyah 和 Caleb 的消息必须是并发的,因为它们具有相同的计数器值,但当计数器值不同时,你无法判断它们是否并发。)
如果你想能够确定记录何时并发创建,你需要不同的算法,例如 *向量时钟*。缺点是向量时钟的时间戳要大得多——可能是系统中每个节点一个整数。有关检测并发的更多详细信息,请参见 ["检测并发写入"](/ch6#sec_replication_concurrent)。
@ -369,7 +369,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制:
尽管 Lamport 时钟和混合逻辑时钟提供了有用的排序保证,但该排序仍然弱于我们之前讨论的线性一致单节点 ID 生成器。回想一下,线性一致性要求如果请求 A 在请求 B 开始之前完成,那么 B 必须具有更高的 ID即使 A 和 B 从未相互通信。另一方面Lamport 时钟只能确保节点生成的时间戳大于该节点看到的任何其他时间戳,但它不能对它没有看到的时间戳说任何话。
[图 10-10](/ch10#fig_consistency_permissions) 显示了非线性一致 ID 生成器如何导致问题。想象一个社交媒体网站,用户 A 想要与朋友私下分享一张尴尬的照片。A 的账户最初是公开的但使用他们的笔记本电脑A 首先将他们的账户设置更改为私密。然后 A 使用他们的手机上传照片。由于 A 按顺序执行了这些更新,他们可能合理地期望照片上传受到新的、受限的账户权限的约束。
[图 10-10](#fig_consistency_permissions) 显示了非线性一致 ID 生成器如何导致问题。想象一个社交媒体网站,用户 A 想要与朋友私下分享一张尴尬的照片。A 的账户最初是公开的但使用他们的笔记本电脑A 首先将他们的账户设置更改为私密。然后 A 使用他们的手机上传照片。由于 A 按顺序执行了这些更新,他们可能合理地期望照片上传受到新的、受限的账户权限的约束。
{{< figure src="/fig/ddia_1010.png" id="fig_consistency_permissions" caption="图 10-10. 使用 Lamport 时间戳的权限系统示例。" class="w-full my-4" >}}
@ -396,7 +396,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制:
#### 使用逻辑时钟强制约束 {#enforcing-constraints-using-logical-clocks}
在 ["约束与唯一性保证"](/ch10#sec_consistency_uniqueness) 中,我们看到线性一致的比较并设置操作可用于在分布式系统中实现锁、唯一性约束和类似构造。这提出了一个问题:逻辑时钟或线性一致的 ID 生成器是否也足以实现这些东西?
在 ["约束与唯一性保证"](#sec_consistency_uniqueness) 中,我们看到线性一致的比较并设置操作可用于在分布式系统中实现锁、唯一性约束和类似构造。这提出了一个问题:逻辑时钟或线性一致的 ID 生成器是否也足以实现这些东西?
答案是:不完全。当你有几个节点都试图获取同一个锁或注册同一个用户名时,你可以使用逻辑时钟为这些请求分配时间戳,并选择具有最低时间戳的请求作为获胜者。如果时钟是线性一致的,你知道任何未来的请求都将始终生成更大的时间戳,因此你可以确定没有未来的请求会收到比获胜者更低的时间戳。
@ -487,7 +487,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制:
这表明 CAS 和共识彼此等价 [^28] [^73]。同样,两者在单个节点上都很简单,但要使其容错则具有挑战性。作为分布式环境中 CAS 的示例,我们在 ["由对象存储支持的数据库"](/ch6#sec_replication_object_storage) 中看到了对象存储的条件写入操作,它允许写入仅在自当前客户端上次读取以来具有相同名称的对象未被另一个客户端创建或修改时发生。
然而线性一致的读写寄存器不足以解决共识。FLP 结果告诉我们,共识不能由异步崩溃停止模型中的确定性算法解决 [^72],但我们在 ["线性一致性与仲裁"](/ch10#sec_consistency_quorum_linearizable) 中看到,线性一致的寄存器可以使用此模型中的仲裁读/写来实现 [^24] [^25] [^26]。由此可见,线性一致的寄存器无法解决共识。
然而线性一致的读写寄存器不足以解决共识。FLP 结果告诉我们,共识不能由异步崩溃停止模型中的确定性算法解决 [^72],但我们在 ["线性一致性与仲裁"](#sec_consistency_quorum_linearizable) 中看到,线性一致的寄存器可以使用此模型中的仲裁读/写来实现 [^24] [^25] [^26]。由此可见,线性一致的寄存器无法解决共识。
#### 共享日志作为共识 {#sec_consistency_shared_logs}
@ -530,7 +530,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制:
#### 获取并增加作为共识 {#fetch-and-add-as-consensus}
我们在 ["线性一致的 ID 生成器"](/ch10#sec_consistency_linearizable_id) 中看到的线性一致 ID 生成器接近解决共识,但略有不足。我们可以使用获取并增加操作实现这样的 ID 生成器,该操作原子地递增计数器并返回旧的计数器值。
我们在 ["线性一致的 ID 生成器"](#sec_consistency_linearizable_id) 中看到的线性一致 ID 生成器接近解决共识,但略有不足。我们可以使用获取并增加操作实现这样的 ID 生成器,该操作原子地递增计数器并返回旧的计数器值。
如果你有 CAS 操作,很容易实现获取并增加:首先读取计数器值,然后执行 CAS其中期望值是你读取的值新值是该值加一。如果 CAS 失败,你将重试整个过程,直到 CAS 成功。当存在争用时,这比本机获取并增加操作效率低,但在功能上是等效的。由于你可以使用共识实现 CAS你也可以使用共识实现获取并增加。
@ -625,7 +625,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制:
> [!TIP] 主节点选举中的一致性与可用性
如果你希望共识算法严格保证 ["共享日志作为共识"](/ch10#sec_consistency_shared_logs) 中列出的属性,那么新主节点在处理任何写入或线性一致读取之前必须了解任何已确认的日志条目,这一点至关重要。如果具有过时数据的节点成为新主节点,它可能会将新值写入已经由旧主节点写入的日志条目,从而违反共享日志的仅追加属性。
如果你希望共识算法严格保证 ["共享日志作为共识"](#sec_consistency_shared_logs) 中列出的属性,那么新主节点在处理任何写入或线性一致读取之前必须了解任何已确认的日志条目,这一点至关重要。如果具有过时数据的节点成为新主节点,它可能会将新值写入已经由旧主节点写入的日志条目,从而违反共享日志的仅追加属性。
在某些情况下你可能选择削弱共识属性以便更快地从主节点故障中恢复。例如Kafka 提供了启用 *不干净的主节点选举* 的选项,它允许任何副本成为主节点,即使它不是最新的。此外,在采用异步复制的数据库中,当主节点失败时,你无法保证任何备库是最新的。

View file

@ -29,7 +29,7 @@ breadcrumbs: false
- 批处理框架能更高效地利用计算资源。虽然也可以用 OLTP 数据库和应用服务器等在线系统做批处理,但资源成本通常显著更高。
批处理也有挑战。多数框架中,作业只有在整体完成后,其输出才能被下游进一步处理。批处理也可能低效:输入哪怕只变动一个字节,也可能需要重算整个输入数据集。尽管如此,批处理在大量场景中依然非常有用,我们会在[“批处理用例”](/ch11#sec_batch_output)中回到这个话题。
批处理也有挑战。多数框架中,作业只有在整体完成后,其输出才能被下游进一步处理。批处理也可能低效:输入哪怕只变动一个字节,也可能需要重算整个输入数据集。尽管如此,批处理在大量场景中依然非常有用,我们会在[“批处理用例”](#sec_batch_output)中回到这个话题。
批处理作业可能运行很久:几分钟、几小时甚至几天。很多作业是周期调度的(例如每天一次)。它的核心性能指标通常是吞吐量:单位时间能处理多少数据。有些批处理系统通过“中止并整体重启”应对故障,也有些具备更细粒度容错能力,可以在部分节点崩溃时仍让作业完成。
@ -278,24 +278,24 @@ YARN ResourceManager 或 Spark 内置调度器主要做“作业内调度”,
当一个任务输出成为另一任务输入即在工作流内传递容错更复杂。MapReduce 的做法是:中间数据总是写回 DFS且只有写入任务成功后才允许下游读取。这个方案在频繁抢占环境中也能工作但会带来大量 DFS 写入,效率不高。
Spark 更倾向把中间数据放内存或溢写本地磁盘,只把最终结果写 DFS它还记录中间数据的计算血缘丢失时可重算 [^18]。Flink 则采用定期检查点快照机制 [^19]。我们会在[“数据流引擎”](/ch11#sec_batch_dataflow)继续讨论。
Spark 更倾向把中间数据放内存或溢写本地磁盘,只把最终结果写 DFS它还记录中间数据的计算血缘丢失时可重算 [^18]。Flink 则采用定期检查点快照机制 [^19]。我们会在[“数据流引擎”](#sec_batch_dataflow)继续讨论。
## 批处理模型 {#id431}
前面我们讨论了分布式环境中批作业如何调度。现在转向“批处理框架如何处理数据”。最常见的两类模型是 MapReduce 与数据流引擎。尽管实践中数据流引擎已大面积替代 MapReduce但理解 MapReduce 仍然重要,因为它深刻影响了现代批处理框架。
MapReduce 与数据流引擎都发展出多种编程接口:低层 API、关系查询语言、DataFrame API。它们让应用工程师、数据分析工程师、业务分析师乃至非技术人员都能参与数据处理。我们将在[“批处理用例”](/ch11#sec_batch_output)中讨论这些用途。
MapReduce 与数据流引擎都发展出多种编程接口:低层 API、关系查询语言、DataFrame API。它们让应用工程师、数据分析工程师、业务分析师乃至非技术人员都能参与数据处理。我们将在[“批处理用例”](#sec_batch_output)中讨论这些用途。
### MapReduce {#sec_batch_mapreduce}
MapReduce 的处理模式与[“简单日志分析”](/ch11#sec_batch_log_analysis)几乎同构:
MapReduce 的处理模式与[“简单日志分析”](#sec_batch_log_analysis)几乎同构:
1. 读取输入文件并切分为 *记录records*。在日志例子里,每条记录就是一行(`\n` 为记录分隔符)。在 Hadoop MapReduce 中,输入通常存放在 HDFS 或 S3 等对象存储,文件格式可能是 Parquet列式见[“面向列存储”](/ch4#sec_storage_column))或 Avro行式见[“Avro”](/ch5#sec_encoding_avro))。
2. 调用 mapper从每条输入记录中提取键和值。Unix 示例中 mapper 相当于 `awk '{print $7}'`URL`$7`)是键,值可留空。
3. 按键排序所有键值对。日志示例中这一步对应第一次 `sort`
4. 调用 reducer 遍历排序后的键值对。同键记录会相邻因此可以在很小内存状态下合并。Unix 示例中 reducer 等价于 `uniq -c`,统计相邻同键记录数。
这四步就是一个 MapReduce 作业。第 2 步map与第 4 步reduce是你写业务逻辑的地方第 1 步(文件切记录)由输入格式解析器完成;第 3 步排序在 MapReduce 中是隐式内置的,你无需手写。这一步是批处理的基础算法,我们会在[“混洗数据”](/ch11#sec_shuffle)再讨论。
这四步就是一个 MapReduce 作业。第 2 步map与第 4 步reduce是你写业务逻辑的地方第 1 步(文件切记录)由输入格式解析器完成;第 3 步排序在 MapReduce 中是隐式内置的,你无需手写。这一步是批处理的基础算法,我们会在[“混洗数据”](#sec_shuffle)再讨论。
要创建 MapReduce 作业你需实现两个回调mapper 与 reducer其行为如下。
@ -342,19 +342,19 @@ Reducer
混洗是批处理系统的基础算法连接与聚合都依赖它。MapReduce、Spark、Flink、Daft、Dataflow、BigQuery [^24] 都实现了高可伸缩且高性能的混洗机制以处理大数据集。这里用 Hadoop MapReduce 的混洗实现做说明 [^25],但核心思想在其他系统同样适用。
[图 11-1](/ch11#fig_batch_mapreduce) 展示了一个 MapReduce 作业的数据流。假设输入已分片,标记为 *m1*、*m2*、*m3*。例如每个分片可以是 HDFS 中一个文件,或对象存储中的一个对象;同一数据集的所有分片可以放在同一 HDFS 目录,或使用同一对象前缀。
[图 11-1](#fig_batch_mapreduce) 展示了一个 MapReduce 作业的数据流。假设输入已分片,标记为 *m1*、*m2*、*m3*。例如每个分片可以是 HDFS 中一个文件,或对象存储中的一个对象;同一数据集的所有分片可以放在同一 HDFS 目录,或使用同一对象前缀。
{{< figure src="/fig/ddia_1101.png" id="fig_batch_mapreduce" caption="图 11-1. 一个包含三个 mapper 和三个 reducer 的 MapReduce 作业。" class="w-full my-4" >}}
框架会为每个输入分片启动一个 map 任务。任务读取分配到的文件,并逐条记录调用 mapper 回调。reduce 侧也会分片。map 任务数由输入分片数决定reduce 任务数由作业作者配置(可与 map 数不同)。
mapper 输出是键值对。框架需要保证:若不同 mapper 输出了同一个键,这些键值对最终必须由同一个 reducer 处理。为此,每个 mapper 会在本地磁盘为每个 reducer 维护一个输出文件(例如[图 11-1](/ch11#fig_batch_mapreduce)中的 *m1,r2*:由 mapper1 生成,目标是 reducer2。mapper 每输出一条键值对,通常会按键的哈希决定写入哪个 reducer 文件(类似[“按键哈希分片”](/ch7#sec_sharding_hash))。
mapper 输出是键值对。框架需要保证:若不同 mapper 输出了同一个键,这些键值对最终必须由同一个 reducer 处理。为此,每个 mapper 会在本地磁盘为每个 reducer 维护一个输出文件(例如[图 11-1](#fig_batch_mapreduce)中的 *m1,r2*:由 mapper1 生成,目标是 reducer2。mapper 每输出一条键值对,通常会按键的哈希决定写入哪个 reducer 文件(类似[“按键哈希分片”](/ch7#sec_sharding_hash))。
mapper 写这些文件的同时,也会在每个文件内部按键排序。可用的正是[“日志结构存储”](/ch4#sec_storage_log_structured)中的技术:先在内存有序结构里积累一批键值对,写成有序段文件,再把小段逐步合并成大段。
每个 mapper 完成后reducer 会连接到 mapper把属于自己的有序文件拷贝到本地磁盘。reducer 拿到所有 mapper 的对应分片后,再用归并排序方式合并它们并保持有序。同键记录即便来自不同 mapper也会在合并后相邻。随后 reducer 以“每个键一次调用”的方式执行,每次拿到一个可迭代器,遍历该键所有值。
reducer 输出记录会顺序写入文件,每个 reduce 任务一个文件。[图 11-1](/ch11#fig_batch_mapreduce)中的 *r1*、*r2*、*r3* 就是输出数据集的分片,最终写回 DFS 或对象存储。
reducer 输出记录会顺序写入文件,每个 reduce 任务一个文件。[图 11-1](#fig_batch_mapreduce)中的 *r1*、*r2*、*r3* 就是输出数据集的分片,最终写回 DFS 或对象存储。
MapReduce 在 map 与 reduce 之间执行混洗现代数据流引擎和云数据仓库则更复杂。BigQuery 等系统已优化混洗,使数据尽量留在内存,并写入外部排序服务 [^24],以提升速度并通过复制增强韧性。
@ -362,13 +362,13 @@ MapReduce 在 map 与 reduce 之间执行混洗;现代数据流引擎和云数
下面看“有序数据”如何简化分布式连接与聚合。为便于说明仍以 MapReduce 为例,但概念适用于大多数批处理系统。
批处理里常见连接场景见[图 11-2](/ch11#fig_batch_join_example)。左边是用户活动日志(*activity events* 或 *clickstream data*),右边是用户数据库。它可以看作星型模型的一部分(见[“星型与雪花型:分析模式”](/ch3#sec_datamodels_analytics)):活动日志是事实表,用户库是维度表之一。
批处理里常见连接场景见[图 11-2](#fig_batch_join_example)。左边是用户活动日志(*activity events* 或 *clickstream data*),右边是用户数据库。它可以看作星型模型的一部分(见[“星型与雪花型:分析模式”](/ch3#sec_datamodels_analytics)):活动日志是事实表,用户库是维度表之一。
{{< figure src="/fig/ddia_1102.png" id="fig_batch_join_example" caption="图 11-2. 用户活动日志与用户画像数据库的连接。" class="w-full my-4" >}}
如果你要做“结合用户库信息的活动分析”(例如利用用户出生日期字段,判断哪些页面更受年轻或年长用户欢迎),就需要连接这两张表。若两边都大到必须分片,怎么做?
可利用 MapReduce 的关键特性:混洗会把同键键值对汇聚到同一个 reducer无论它们最初在哪个分片。这里用户 ID 就可以作为键。因此可写一个 mapper 扫活动日志,输出“按用户 ID 键控的页面访问 URL”见[图 11-3](/ch11#fig_batch_join_reduce));再写一个 mapper 按行扫描用户表,提取“用户 ID 作为键、出生日期作为值”。
可利用 MapReduce 的关键特性:混洗会把同键键值对汇聚到同一个 reducer无论它们最初在哪个分片。这里用户 ID 就可以作为键。因此可写一个 mapper 扫活动日志,输出“按用户 ID 键控的页面访问 URL”见[图 11-3](#fig_batch_join_reduce));再写一个 mapper 按行扫描用户表,提取“用户 ID 作为键、出生日期作为值”。
{{< figure src="/fig/ddia_1103.png" id="fig_batch_join_reduce" caption="图 11-3. 基于用户 ID 的排序合并连接。若输入数据集由多个文件分片组成,可并行启动多个 mapper 处理。" class="w-full my-4" >}}
@ -390,7 +390,7 @@ MapReduce、数据流引擎、云数据仓库都把 SQL 作为批处理“通用
SQL 是最流行的通用批处理语言但在一些细分场景中仍有其他语言。Apache Pig 提供了基于关系算子的逐步式数据流水线描述方式,而非“一个超大 SQL 查询”。DataFrame下一节有相似特征Morel 则是受 Pig 影响的更现代语言。还有用户采用 jq、JMESPath、JsonPath 等 JSON 查询语言。
在[“图状数据模型”](/ch3#sec_datamodels_graph)中,我们讨论了图建模与图查询语言如何遍历边和顶点。许多图处理框架也支持通过查询语言做批计算,例如 Apache TinkerPop 的 Gremlin。我们会在[“批处理用例”](/ch11#sec_batch_output)继续看图处理场景。
在[“图状数据模型”](/ch3#sec_datamodels_graph)中,我们讨论了图建模与图查询语言如何遍历边和顶点。许多图处理框架也支持通过查询语言做批计算,例如 Apache TinkerPop 的 Gremlin。我们会在[“批处理用例”](#sec_batch_output)继续看图处理场景。
> [!TIP] 批处理与云数据仓库正在收敛
> 历史上,数据仓库运行在专用硬件设备上,主要提供关系数据的 SQL 分析查询;而 MapReduce 等批处理框架强调更高可伸缩性与更高灵活性,允许使用通用编程语言写处理逻辑,并读写任意数据格式。
@ -446,7 +446,7 @@ Daft 等框架甚至同时支持客户端与服务端计算:小规模内存操
与 ETL 类似SQL 接口改进让很多组织用 Spark 等批框架直接承载分析。常见模式有两类:
- 预聚合查询:先把数据滚动聚合为 OLAP 立方体或数据集市,以提升查询速度(见[“物化视图与数据立方”](/ch4#sec_storage_materialized_views))。预聚合结果可在仓库查询,或推送到 Apache Druid、Apache Pinot 这类实时 OLAP 系统。预聚合通常按固定周期运行,通常由[“工作流调度”](/ch11#sec_batch_workflows)中提到的调度器管理。
- 预聚合查询:先把数据滚动聚合为 OLAP 立方体或数据集市,以提升查询速度(见[“物化视图与数据立方”](/ch4#sec_storage_materialized_views))。预聚合结果可在仓库查询,或推送到 Apache Druid、Apache Pinot 这类实时 OLAP 系统。预聚合通常按固定周期运行,通常由[“工作流调度”](#sec_batch_workflows)中提到的调度器管理。
- 临时查询ad hoc用户为回答具体业务问题、分析用户行为、排查运行问题等随时发起。该场景非常看重响应时间分析师通常会根据每次结果继续迭代提问。执行快的批处理查询引擎可显著缩短等待。
SQL 支持还让批处理系统更易接入电子表格与可视化工具,如 Tableau、Power BI、Looker、Apache Superset。比如 Tableau 有 SparkSQL、Presto 连接器Superset 支持 Trino、Hive、Spark SQL、Presto 等大量最终会触发批任务的数据系统。

View file

@ -24,7 +24,7 @@ breadcrumbs: false
一般来说,“流” 是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方Unix 的 stdin 和 stdout、编程语言惰性列表[^2]、文件系统 API如 Java 的 `FileInputStream`、TCP 连接、通过互联网传送音频和视频等等。
在本章中,我们将把 **事件流event stream** 视为一种数据管理机制:无界限,增量处理,与上一章中的批量数据相对应。我们将首先讨论怎样表示、存储、通过网络传输流。在 “[数据库与流](/ch12#sec_stream_databases)” 中,我们将研究流和数据库之间的关系。最后在 “[流处理](/ch12#sec_stream_processing)” 中,我们将研究连续处理这些流的方法和工具,以及它们用于应用构建的方式。
在本章中,我们将把 **事件流event stream** 视为一种数据管理机制:无界限,增量处理,与上一章中的批量数据相对应。我们将首先讨论怎样表示、存储、通过网络传输流。在 “[数据库与流](#sec_stream_databases)” 中,我们将研究流和数据库之间的关系。最后在 “[流处理](#sec_stream_processing)” 中,我们将研究连续处理这些流的方法和工具,以及它们用于应用构建的方式。
## 传递事件流 {#sec_stream_transmit}
@ -98,7 +98,7 @@ breadcrumbs: false
#### 多个消费者 {#id298}
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 12-1](/fig/ddia_1201.png) 所示:
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 12-1](#fig_stream_broker_patterns) 所示:
负载均衡load balancing
: 每条消息都被传递给消费者 **之一**,所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂,希望能并行处理消息时,此模式非常有用(在 AMQP 中,可以通过让多个客户端从同一个队列中消费来实现负载均衡,而在 JMS 中则称之为 **共享订阅**,即 shared subscription
@ -106,9 +106,7 @@ breadcrumbs: false
扇出fan-out
: 每条消息都被传递给 **所有** 消费者。扇出允许几个独立的消费者各自 “收听” 相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 JMS 中的主题订阅与 AMQP 中的交叉绑定提供了这一功能)。
![](/fig/ddia_1201.png)
**图 12-1 a负载平衡在消费者间共享消费主题b扇出将每条消息传递给多个消费者。**
{{< figure src="/fig/ddia_1201.png" id="fig_stream_broker_patterns" caption="图 12-1. a负载均衡在消费者间共享消费主题b扇出将每条消息传递给多个消费者。" class="w-full my-4" >}}
两种模式可以组合使用:例如,两个独立的消费者组可以每组各订阅同一个主题,每一组都共同收到所有消息,但在每一组内部,每条消息仅由单个节点处理。
@ -118,17 +116,15 @@ breadcrumbs: false
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。(请注意可能发生这样的情况,消息 **实际上是** 处理完毕的,但 **确认** 在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在 “[实践中的分布式事务](/ch8#sec_transactions_xa)” 中所讨论的那样)
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图 12-2](/fig/ddia_1202.png) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1结果消费者 1 按照 m4m3m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在 [图 12-2](#fig_stream_redelivery_reordering) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1结果消费者 1 按照 m4m3m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
![](/fig/ddia_1202.png)
**图 12-2 在处理 m3 时消费者 2 崩溃,因此稍后重传至消费者 1**
{{< figure src="/fig/ddia_1202.png" id="fig_stream_redelivery_reordering" caption="图 12-2. 在处理 m3 时消费者 2 崩溃,因此稍后重传至消费者 1。" class="w-full my-4" >}}
即使消息代理试图保留消息的顺序(如 JMS 和 AMQP 标准所要求的),负载均衡与重传的组合也不可避免地导致消息被重新排序。为避免此问题,你可以让每个消费者使用单独的队列(即不使用负载均衡功能)。如果消息是完全独立的,则消息顺序重排并不是一个问题。但正如我们将在本章后续部分所述,如果消息之间存在因果依赖关系,这就是一个很重要的问题。
重传还可能导致资源浪费、资源饥饿,甚至使流永久阻塞。一个常见场景是生产者错误地序列化消息,例如 JSON 对象缺少必填键。任何读取到该消息的消费者都会因为缺键而失败,无法发送确认,于是代理会不断重传,导致其他消费者也不断失败。如果代理强顺序保证,后续消息可能被彻底卡住;即便允许重排,也会持续浪费资源在永远无法确认的坏消息上。
这类问题通常通过 **死信队列dead letter queue, DLQ** 处理:不再无限重试,而是把问题消息移到另一条队列中,从而解堵主消费链路[^17][^18]。运维通常会对死信队列设置告警 —— 只要有消息进入,就代表出现了错误。收到告警后,操作员可以决定永久丢弃该消息、人工修复后重新投递,或修复消费者代码以正确处理该消息。除了传统队列系统,基于日志的消息系统和流处理系统也开始支持 DLQ[^19]。
这类问题通常通过 **死信队列dead letter queue, DLQ** 处理:不再无限重试,而是把问题消息移到另一条队列中,从而解堵主消费链路[^17] [^18]。运维通常会对死信队列设置告警 —— 只要有消息进入,就代表出现了错误。收到告警后,操作员可以决定永久丢弃该消息、人工修复后重新投递,或修复消费者代码以正确处理该消息。除了传统队列系统,基于日志的消息系统和流处理系统也开始支持 DLQ[^19]。
### 基于日志的消息代理 {#sec_stream_log}
@ -148,15 +144,13 @@ breadcrumbs: false
同样的结构可以用于实现消息代理生产者通过将消息追加到日志末尾来发送消息而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾则会等待新消息追加的通知。Unix 工具 `tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第七章](/ch7) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 12-3](/fig/ddia_1203.png) 所示。
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第七章](/ch7) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 12-3](#fig_stream_log_partitions) 所示。
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**offset在 [图 12-3](/fig/ddia_1203.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**offset在 [图 12-3](#fig_stream_log_partitions) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
![](/fig/ddia_1203.png)
{{< figure src="/fig/ddia_1203.png" id="fig_stream_log_partitions" caption="图 12-3. 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件。" class="w-full my-4" >}}
**图 12-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件**
Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基于日志的消息代理。Google Cloud Pub/Sub 在架构上类似,但对外暴露的是 JMS 风格的 API而不是日志抽象[^15]。尽管这些消息代理将所有消息写入磁盘,但通过跨多台机器分区,依然能够达到每秒数百万条消息的吞吐量,并通过复制消息实现容错[^21][^22]。
Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基于日志的消息代理。Google Cloud Pub/Sub 在架构上类似,但对外暴露的是 JMS 风格的 API而不是日志抽象[^15]。尽管这些消息代理将所有消息写入磁盘,但通过跨多台机器分区,依然能够达到每秒数百万条消息的吞吐量,并通过复制消息实现容错[^21] [^22]。
#### 日志与传统的消息传递相比 {#sec_stream_logs_vs_messaging}
@ -167,7 +161,7 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基
* 共享消费主题工作的节点数,最多为该主题中的日志分区数,因为同一个分区内的所有消息被递送到同一个节点。
* 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种头部阻塞的形式;请参阅 “[描述性能](/ch2#sec_introduction_percentiles)”)。
因此在消息处理代价高昂希望逐条并行处理以及消息的顺序并没有那么重要的情况下JMS/AMQP 风格的消息代理是可取的。另一方面,在消息吞吐量很高,处理迅速,顺序很重要的情况下,基于日志的方法表现得非常好[^23][^24]。不过,基于日志与传统消息系统的边界并不绝对:例如,一个主题分区通常一次只分配给一个消费者[^25][^26]。
因此在消息处理代价高昂希望逐条并行处理以及消息的顺序并没有那么重要的情况下JMS/AMQP 风格的消息代理是可取的。另一方面,在消息吞吐量很高,处理迅速,顺序很重要的情况下,基于日志的方法表现得非常好[^23] [^24]。不过,基于日志与传统消息系统的边界并不绝对:例如,一个主题分区通常一次只分配给一个消费者[^25] [^26]。
#### 消费者偏移量 {#sec_stream_log_offsets}
@ -226,17 +220,15 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基
如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是 **双写dual write**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 12-4](/fig/ddia_1204.png) 所示。在这个例子中,两个客户端同时想要更新一个项目 X客户端 1 想要将值设置为 A客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库然后将其写入到搜索索引。因为运气不好这些请求的时序是交错的数据库首先看到来自客户端 1 的写入将值设置为 A然后来自客户端 2 的写入将值设置为 B因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误这两个系统现在也永久地不一致了。
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 12-4](#fig_stream_dual_write_race) 所示。在这个例子中,两个客户端同时想要更新一个项目 X客户端 1 想要将值设置为 A客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库然后将其写入到搜索索引。因为运气不好这些请求的时序是交错的数据库首先看到来自客户端 1 的写入将值设置为 A然后来自客户端 2 的写入将值设置为 B因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误这两个系统现在也永久地不一致了。
![](/fig/ddia_1204.png)
**图 12-4 在数据库中 X 首先被设置为 A然后被设置为 B而在搜索索引处写入以相反的顺序到达**
{{< figure src="/fig/ddia_1204.png" id="fig_stream_dual_write_race" caption="图 12-4. 在数据库中 X 首先被设置为 A然后被设置为 B而在搜索索引处写入以相反的顺序到达。" class="w-full my-4" >}}
除非有一些额外的并发检测机制,例如我们在 “[检测并发写入](/ch6#sec_replication_concurrent)” 中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅 “[原子提交与两阶段提交](/ch8#sec_transactions_2pc)”)。
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 12-4](/fig/ddia_1204.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](/ch6#sec_replication_multi_leader)”)。
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 12-4](#fig_stream_dual_write_race) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](/ch6#sec_replication_multi_leader)”)。
如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗?
@ -248,17 +240,15 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基
最近,人们对 **数据变更捕获change data capture, CDC** 越来越感兴趣这是一种观察写入数据库的所有数据变更并将其提取并转换为可以复制到其他系统中的形式的过程。CDC 是非常有意思的,尤其是当变更能在被写入后立刻用于流时[^28]。
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他派生数据系统只是变更流的消费者,如 [图 12-5](/fig/ddia_1205.png) 所示。
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他派生数据系统只是变更流的消费者,如 [图 12-5](#fig_stream_cdc_flow) 所示。
![](/fig/ddia_1205.png)
**图 12-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
{{< figure src="/fig/ddia_1205.png" id="fig_stream_cdc_flow" caption="图 12-5. 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统。" class="w-full my-4" >}}
#### 数据变更捕获的实现 {#id307}
我们可以将日志消费者叫做 **派生数据系统**,正如在 [第一章](/ch1#sec_introduction_derived) 讨论“记录系统与派生数据”时所述:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。数据变更捕获是一种机制,可确保对记录系统所做的所有更改都反映在派生数据系统中,以便派生系统具有数据的准确副本。
从本质上说,数据变更捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 12-2](/fig/ddia_1202.png) 的重新排序问题)。
从本质上说,数据变更捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 12-2](#fig_stream_redelivery_reordering) 的重新排序问题)。
数据库触发器可用来实现数据变更捕获(请参阅 “[基于触发器的复制](/ch6#sec_replication_logical)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。
@ -272,13 +262,13 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基
例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近变更的日志是不够的,因为这样会丢失最近未曾更新的项目。因此,如果你没有完整的历史日志,则需要从一个一致的快照开始,如先前的 “[设置新从库](/ch6#sec_replication_new_replica)” 中所述。
数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪里开始应用变更。一些 CDC 工具集成了这种快照功能而其他工具则把它留给你手动执行。Debezium 使用 Netflix 的 DBLog 水位线算法提供增量快照能力[^30][^31]。
数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪里开始应用变更。一些 CDC 工具集成了这种快照功能而其他工具则把它留给你手动执行。Debezium 使用 Netflix 的 DBLog 水位线算法提供增量快照能力[^30] [^31]。
#### 日志压缩 {#sec_stream_log_compaction}
如果你只能保留有限的历史日志,则每次要添加新的派生数据系统时,都需要做一次快照。但 **日志压缩log compaction** 提供了一个很好的备选方案。
我们之前在 “[日志结构存储](/ch4#sec_storage_log_structured)” 的上下文中讨论过日志压缩(可参阅 [图 4-3](/fig/ddia_0403.png) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
我们之前在 “[日志结构存储](/ch4#sec_storage_log_structured)” 的上下文中讨论过日志压缩(可参阅 [图 4-3](/ch4#fig_storage_sstable_merging) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
在日志结构存储引擎中,具有特殊值 NULL**墓碑**,即 tombstone的更新表示该键被删除并会在日志压缩过程中被移除。但只要键不被覆盖或删除它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入则先前的值将最终将被垃圾回收只有最新的值会保留下来。
@ -300,7 +290,7 @@ Kafka Connect[^33]提供了大量数据库系统与 Kafka 的 CDC 集成能力
数据变更捕获与事件溯源都把状态变化表示成事件日志,但二者抽象层级不同:
* 在数据变更捕获中,应用仍以可变方式使用数据库,任意更新/删除记录;变更日志从数据库底层抽取(如复制日志),因此能保证抽取顺序与真实写入顺序一致,避免 [图 12-4](/fig/ddia_1204.png) 这类竞态问题。
* 在数据变更捕获中,应用仍以可变方式使用数据库,任意更新/删除记录;变更日志从数据库底层抽取(如复制日志),因此能保证抽取顺序与真实写入顺序一致,避免 [图 12-4](#fig_stream_dual_write_race) 这类竞态问题。
* 在事件溯源中,应用逻辑从一开始就构建在不可变事件之上,事件存储通常是仅追加写入,更新和删除被限制或禁止。事件语义是应用层行为,而非底层状态差异。
二者孰优取决于场景。对未采用事件溯源的系统而言,引入它通常是一次较大架构变更;而数据变更捕获通常可在现有数据库上以较小改动接入,应用层甚至可以感知不到 CDC 的存在。
@ -312,7 +302,7 @@ Kafka Connect[^33]提供了大量数据库系统与 Kafka 的 CDC 集成能力
>
> 但 CDC 往往直接复用上游数据库模式做复制,这会把原本“内部模式”变成“外部契约”。删除某个列可能会直接破坏下游消费者[^34]。
>
> 一种常见解法是 **Outbox 模式**:专门维护对外发布的 outbox 表,让 CDC 读取 outbox而不是直接读取内部领域模型表。这样可以在尽量不影响外部消费者的前提下演化内部模式[^35][^36]。它看起来像双写,实际上也是双写;但它把两次写入留在同一个数据库系统内,因此可放进同一事务,规避跨系统双写的一致性问题。
> 一种常见解法是 **Outbox 模式**:专门维护对外发布的 outbox 表,让 CDC 读取 outbox而不是直接读取内部领域模型表。这样可以在尽量不影响外部消费者的前提下演化内部模式[^35] [^36]。它看起来像双写,实际上也是双写;但它把两次写入留在同一个数据库系统内,因此可放进同一事务,规避跨系统双写的一致性问题。
和数据变更捕获一样,重放事件日志也能重建当前状态,但日志压缩策略不同:
@ -331,7 +321,7 @@ Kafka Connect[^33]提供了大量数据库系统与 Kafka 的 CDC 集成能力
无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志 —— **变化日志changelog**,表示了随时间演变的状态。
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 12-7](/fig/ddia_1207.png) 所示[^37][^38]。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 12-7](#fig_stream_state_derivative) 所示[^37] [^38]。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。
$$
\begin{aligned}
@ -340,9 +330,7 @@ stream(t) &= \frac{d\,state(t)}{dt}
\end{aligned}
$$
![](/fig/ddia_1207.png)
**图 12-7 应用当前状态与事件流之间的关系**
{{< figure src="/fig/ddia_1207.png" id="fig_stream_state_derivative" caption="图 12-7. 应用当前状态与事件流之间的关系。" class="w-full my-4" >}}
如果你持久存储了变更日志,那么重现状态就非常简单。如果你将事件日志视为记录系统,而把可变状态视为其派生结果,那么系统中的数据流就更容易推理。正如 Jim Gray 和 Andreas Reuter 在 1992 年所说[^39]
@ -362,9 +350,9 @@ $$
#### 从同一事件日志中派生多个视图 {#sec_stream_deriving_views}
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中派生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 12-5](/fig/ddia_1205.png)例如Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引[^33]。这对于许多其他存储和索引系统(如搜索服务器)来说也是有意义的,当系统要从分布式日志中获取输入时尤其如此(请参阅 “[保持系统同步](#sec_stream_sync)”)。
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中派生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 12-5](#fig_stream_cdc_flow)例如Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引[^33]。这对于许多其他存储和索引系统(如搜索服务器)来说也是有意义的,当系统要从分布式日志中获取输入时尤其如此(请参阅 “[保持系统同步](#sec_stream_sync)”)。
添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的、针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源[^42][^43]。
添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的、针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源[^42] [^43]。
如果你不需要担心如何查询与访问数据,那么存储数据通常是非常简单的。模式设计、索引和存储引擎的许多复杂性,都是希望支持某些特定查询和访问模式的结果(请参阅 [第三章](/ch3))。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许几个不同的读取视图,你能获得很大的灵活性。这个想法有时被称为 **命令查询责任分离command query responsibility segregation, CQRS**[^44]。
@ -386,7 +374,7 @@ $$
许多不使用事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(请参阅 “[索引和快照隔离](/ch8#sec_transactions_snapshot_indexes)” 。Git、Mercurial 和 Fossil 等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。
永远保持所有变更的不变历史,在多大程度上是可行的?答案取决于数据集的流失率。一些工作负载主要是添加数据,很少更新或删除;它们很容易保持不变。其他工作负载在相对较小的数据集上有较高的更新 / 删除率;在这些情况下,不可变的历史可能增至难以接受的巨大,碎片化可能成为一个问题,压缩与垃圾收集的表现对于运维的稳健性变得至关重要[^45][^46]。
永远保持所有变更的不变历史,在多大程度上是可行的?答案取决于数据集的流失率。一些工作负载主要是添加数据,很少更新或删除;它们很容易保持不变。其他工作负载在相对较小的数据集上有较高的更新 / 删除率;在这些情况下,不可变的历史可能增至难以接受的巨大,碎片化可能成为一个问题,压缩与垃圾收集的表现对于运维的稳健性变得至关重要[^45] [^46]。
除了性能方面的原因外,也可能有出于管理方面的原因需要删除数据的情况,尽管这些数据都是不可变的。例如,隐私条例可能要求在用户关闭帐户后删除他们的个人信息,数据保护立法可能要求删除错误的信息,或者可能需要阻止敏感信息的意外泄露。
@ -407,7 +395,7 @@ $$
剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项:
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 12-5](/fig/ddia_1205.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](/ch11#sec_batch_output)” 中所讨论的,它是写入存储系统的流等价物。
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 12-5](#fig_stream_cdc_flow) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](/ch11#sec_batch_output)” 中所讨论的,它是写入存储系统的流等价物。
2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。
3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项 1 或 2
@ -465,7 +453,7 @@ CEP 的实现包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
>
> 但很多数据库刷新物化视图仍依赖批处理或按需触发(例如 PostgreSQL 的 `REFRESH MATERIALIZED VIEW`),而不是在源数据变化时做增量维护。这会带来两个问题:
>
> 1. 效率低:每次刷新都重算全量数据,而不是只处理变化部分[^38][^59][^60]。
> 1. 效率低:每次刷新都重算全量数据,而不是只处理变化部分[^38] [^59] [^60]。
> 2. 不够实时:刷新间隔内的变化不会立刻反映在视图里。
>
> Materialize、RisingWave、ClickHouse、Feldera 等系统都在探索更实时的增量维护路径[^61]。
@ -486,7 +474,7 @@ CEP 的实现包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
* Actor 之间的交流往往是短暂的、一对一的;而事件日志则是持久的、多订阅者的。
* Actor 可以以任意方式进行通信(包括循环的请求 / 响应模式),但流处理通常配置在无环流水线中,其中每个流都是一个特定作业的输出,由良好定义的输入流中派生而来。
也就是说RPC 类系统与流处理之间有一些交叉领域。例如Apache Storm 有一个称为 **分布式 RPC** 的功能,它允许将用户查询分散到一系列也处理事件流的节点上;然后这些查询与来自输入流的事件交织,而结果可以被汇总并发回给用户(另请参阅 “[多分区数据处理](/ch13#多分区数据处理)”)。
也就是说RPC 类系统与流处理之间有一些交叉领域。例如Apache Storm 有一个称为 **分布式 RPC** 的功能,它允许将用户查询分散到一系列也处理事件流的节点上;然后这些查询与来自输入流的事件交织,而结果可以被汇总并发回给用户(另请参阅 “[多分区数据处理](/ch13#sec_future_unbundled_multi_shard)”)。
也可以使用 Actor 框架来处理流。但是,很多这样的框架在崩溃时不能保证消息的传递,除非你实现了额外的重试逻辑,否则这种处理不是容错的。
@ -508,11 +496,9 @@ CEP 的实现包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
有一个类比也许能帮助理解,“星球大战” 电影:第四集于 1977 年发行,第五集于 1980 年,第六集于 1983 年,紧随其后的是 1999 年的第一集、2002 年的第二集、2005 年的第三集,以及 2015 年、2017 年和 2019 年的第七至第九集[^65]。如果你按照它们上映的顺序观看电影,你处理电影的顺序与它们叙事的顺序就是不一致的。(集数编号就像事件时间戳,而你观看电影的日期就是处理时间)作为人类,我们能够应对这种不连续性,但是流处理算法需要专门编写,以适应这种时序与顺序的问题。
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 12-8](/fig/ddia_1208.png))。
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 12-8](#fig_stream_processing_time_skew))。
![](/fig/ddia_1208.png)
**图 12-8 按处理时间分窗,会因为处理速率的变动引入人为因素**
{{< figure src="/fig/ddia_1208.png" id="fig_stream_processing_time_skew" caption="图 12-8. 按处理时间分窗,会因为处理速率的变动引入人为因素。" class="w-full my-4" >}}
#### 处理滞留事件 {#id323}
@ -545,7 +531,7 @@ CEP 的实现包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
#### 窗口的类型 {#id324}
当你知道如何确定一个事件的时间戳后,下一步就是如何定义时间段的窗口。然后窗口就可以用于聚合,例如事件计数,或计算窗口内值的平均值。有几种窗口很常用[^64][^68]
当你知道如何确定一个事件的时间戳后,下一步就是如何定义时间段的窗口。然后窗口就可以用于聚合,例如事件计数,或计算窗口内值的平均值。有几种窗口很常用[^64] [^68]
滚动窗口Tumbling Window
: 滚动窗口有着固定的长度,每个事件都仅能属于一个窗口。例如,假设你有一个 1 分钟的滚动窗口,则所有时间戳在 `10:03:00``10:03:59` 之间的事件会被分组到一个窗口中,`10:04:00` 和 `10:04:59` 之间的事件被分组到下一个窗口,依此类推。通过将每个事件时间戳四舍五入至最近的分钟来确定它所属的窗口,可以实现 1 分钟的滚动窗口。
@ -577,7 +563,7 @@ CEP 的实现包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
#### 流表连接(流扩充) {#sec_stream_table_joins}
在 “[示例:用户活动事件分析](/ch11#sec_batch_join)”([图 11-2](/fig/ddia_1102.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充enriching** 活动事件。
在 “[示例:用户活动事件分析](/ch11#sec_batch_join)”([图 11-2](/ch11#fig_batch_join_example))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充enriching** 活动事件。
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户 ID并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在 “[示例:用户活动事件分析](/ch11#sec_batch_join)” 一节中讨论的,此类远程查询可能会很慢,并且有可能导致数据库过载[^58]。
@ -613,7 +599,7 @@ GROUP BY follows.follower_id
流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当底层的表发生变化时都会更新。
> [!NOTE]
> 如果你将流视作表的导数(如 [图 12-7](/fig/ddia_1207.png) 所示),并把连接看作两个表 *u·v* 的乘积,那么会出现一个有趣现象:物化连接的变化流遵循乘积法则 \( (u \cdot v)' = u'v + uv' \)。换句话说,任何推文变化都要和当前关注关系连接,任何关注关系变化都要和当前推文连接[^37]。
> 如果你将流视作表的导数(如 [图 12-7](#fig_stream_state_derivative) 所示),并把连接看作两个表 *u·v* 的乘积,那么会出现一个有趣现象:物化连接的变化流遵循乘积法则 \( (u \cdot v)' = u'v + uv' \)。换句话说,任何推文变化都要和当前关注关系连接,任何关注关系变化都要和当前推文连接[^37]。
#### 连接的时间依赖性 {#sec_stream_join_time}
@ -627,7 +613,7 @@ GROUP BY follows.follower_id
如果跨越流的事件顺序是未定的,则连接会变为不确定性的[^70],这意味着你在同样输入上重跑相同的作业未必会得到相同的结果:当你重跑任务时,输入流上的事件可能会以不同的方式交织。
在数据仓库中,这个问题被称为 **缓慢变化的维度slowly changing dimension, SCD**,通常通过对特定版本的记录使用唯一的标识符来解决:例如,每当税率改变时都会获得一个新的标识符,而发票在销售时会带有税率的标识符[^71][^72]。这种变化使连接变为确定性的,但也会导致日志压缩无法进行:表中所有的记录版本都需要保留。
在数据仓库中,这个问题被称为 **缓慢变化的维度slowly changing dimension, SCD**,通常通过对特定版本的记录使用唯一的标识符来解决:例如,每当税率改变时都会获得一个新的标识符,而发票在销售时会带有税率的标识符[^71] [^72]。这种变化使连接变为确定性的,但也会导致日志压缩无法进行:表中所有的记录版本都需要保留。
### 容错 {#sec_stream_fault_tolerance}
@ -643,7 +629,7 @@ GROUP BY follows.follower_id
微批次也隐式提供了一个与批次大小相等的滚动窗口(按处理时间而不是事件时间戳分窗)。任何需要更大窗口的作业都需要显式地将状态从一个微批次转移到下一个微批次。
Apache Flink 则使用不同的方法,它会定期生成状态的滚动存档点并将其写入持久存储[^75][^76]。如果流算子崩溃,它可以从最近的存档点重启,并丢弃从最近检查点到崩溃之间的所有输出。存档点会由消息流中的 **壁障barrier** 触发,类似于微批次之间的边界,但不会强制一个特定的窗口大小。
Apache Flink 则使用不同的方法,它会定期生成状态的滚动存档点并将其写入持久存储[^75] [^76]。如果流算子崩溃,它可以从最近的存档点重启,并丢弃从最近检查点到崩溃之间的所有输出。存档点会由消息流中的 **壁障barrier** 触发,类似于微批次之间的边界,但不会强制一个特定的窗口大小。
在流处理框架的范围内,微批次与存档点方法提供了与批处理一样的 **恰好一次语义**。但是,只要输出离开流处理器(例如,写入数据库,向外部消息代理发送消息,或发送电子邮件),框架就无法抛弃失败批次的输出了。在这种情况下,重启失败任务会导致外部副作用发生两次,只有微批次或存档点不足以阻止这一问题。
@ -653,7 +639,7 @@ Apache Flink 则使用不同的方法,它会定期生成状态的滚动存档
这些事情要么都原子地发生,要么都不发生,但是它们不应当失去同步。如果这种方法听起来很熟悉,那是因为我们在分布式事务和两阶段提交的上下文中讨论过它(请参阅 “[恰好一次的消息处理](/ch8#sec_transactions_exactly_once)”)。
在 [第十章](/ch10) 中,我们讨论了分布式事务传统实现中的问题(如 XA。然而在限制更为严苛的环境中也是有可能高效实现这种原子提交机制的。Google Cloud Dataflow[^66][^75]、VoltDB[^77] 和 Apache Kafka[^78][^79] 中都使用了这种方法。与 XA 不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中同时管理状态变更与消息传递来内化事务。事务协议的开销可以通过在单个事务中处理多个输入消息来分摊。
在 [第十章](/ch10) 中,我们讨论了分布式事务传统实现中的问题(如 XA。然而在限制更为严苛的环境中也是有可能高效实现这种原子提交机制的。Google Cloud Dataflow[^66] [^75]、VoltDB[^77] 和 Apache Kafka[^78] [^79] 中都使用了这种方法。与 XA 不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中同时管理状态变更与消息传递来内化事务。事务协议的开销可以通过在单个事务中处理多个输入消息来分摊。
#### 幂等性 {#sec_stream_idempotence}
@ -663,7 +649,7 @@ Apache Flink 则使用不同的方法,它会定期生成状态的滚动存档
即使一个操作不是天生幂等的,往往可以通过一些额外的元数据做成幂等的。例如,在使用来自 Kafka 的消息时,每条消息都有一个持久的、单调递增的偏移量。将值写入外部数据库时可以将这个偏移量带上,这样你就可以判断一条更新是不是已经执行过了,因而避免重复执行。
Storm 的 Trident 基于类似的想法来处理状态。依赖幂等性意味着隐含了一些假设:重启一个失败的任务必须以相同的顺序重播相同的消息(基于日志的消息代理能做这些事),处理必须是确定性的,没有其他节点能同时更新相同的值[^81][^82]。
Storm 的 Trident 基于类似的想法来处理状态。依赖幂等性意味着隐含了一些假设:重启一个失败的任务必须以相同的顺序重播相同的消息(基于日志的消息代理能做这些事),处理必须是确定性的,没有其他节点能同时更新相同的值[^81] [^82]。
当从一个处理节点故障切换到另一个节点时,可能需要进行 **防护**fencing请参阅 “[领导者和锁](/ch9#sec_distributed_lock_fencing)”),以防止被假死节点干扰。尽管有这么多注意事项,幂等操作是一种实现 **恰好一次语义** 的有效方式,仅需很小的额外开销。
@ -673,7 +659,7 @@ Storm 的 Trident 基于类似的想法来处理状态。依赖幂等性意味
一种选择是将状态保存在远程数据存储中,并进行复制,然而正如在 “[流表连接(流扩充)](#sec_stream_table_joins)” 中所述,每个消息都要查询远程数据库可能会很慢。另一种方法是在流处理器本地保存状态,并定期复制。然后当流处理器从故障中恢复时,新任务可以读取状态副本,恢复处理而不丢失数据。
例如Flink 定期捕获算子状态的快照,并将它们写入 HDFS 等持久存储中[^75][^76]。Kafka Streams 通过将状态变更发送到具有日志压缩功能的专用 Kafka 主题来复制状态变更,这与数据变更捕获类似[^83]。VoltDB 通过在多个节点上对每个输入消息进行冗余处理来复制状态(请参阅 “[真的串行执行](/ch8#sec_transactions_serial)”)。
例如Flink 定期捕获算子状态的快照,并将它们写入 HDFS 等持久存储中[^75] [^76]。Kafka Streams 通过将状态变更发送到具有日志压缩功能的专用 Kafka 主题来复制状态变更,这与数据变更捕获类似[^83]。VoltDB 通过在多个节点上对每个输入消息进行冗余处理来复制状态(请参阅 “[真的串行执行](/ch8#sec_transactions_serial)”)。
在某些情况下,甚至可能都不需要复制状态,因为它可以从输入流重建。例如,如果状态是从相当短的窗口中聚合而成,则简单地重播该窗口中的输入事件可能是足够快的。如果状态是通过数据变更捕获来维护的数据库的本地副本,那么也可以从日志压缩的变更流中重建数据库(请参阅 “[日志压缩](#sec_stream_log_compaction)”)。

View file

@ -42,7 +42,7 @@ breadcrumbs: false
例如,你可能会首先将数据写入 **记录系统** 数据库,捕获对该数据库所做的变更(请参阅 “[变更数据捕获](/ch12#sec_stream_cdc)”然后将变更以相同的顺序应用于搜索索引。如果变更数据捕获CDC是更新索引的唯一方式则可以确定该索引完全派生自记录系统因此与其保持一致除软件错误外。写入数据库是向该系统提供新输入的唯一方式。
允许应用程序直接写入搜索索引和数据库引入了如 [图 12-4](/fig/ddia_1204.png) 所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。
允许应用程序直接写入搜索索引和数据库引入了如 [图 12-4](/ch12#fig_stream_dual_write_race) 所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。
如果你可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地派生出其他数据表示。这是状态机复制方法的一个应用,我们在 “[全序广播](/ch10#sec_consistency_total_order)” 中看到。无论你使用变更数据捕获还是事件溯源日志,都不如简单的基于全序的决策原则更重要。
@ -81,10 +81,10 @@ breadcrumbs: false
但是如果好友关系状态与消息存储在不同的地方,在这样一个系统中,可能会出现 **解除好友** 事件与 **发送消息** 事件之间的因果依赖丢失的情况。如果因果依赖关系没有被捕捉到,则发送有关新消息的通知的服务可能会在 **解除好友** 事件之前处理 **发送消息** 事件,从而错误地向前任发送通知。
在本例中,通知实际上是消息和好友列表之间的连接,使得它与我们先前讨论的连接的时序问题有关(请参阅 “[连接的时间依赖性](/ch12#sec_stream_join_time)”)。不幸的是,这个问题似乎并没有一个简单的答案[^2][^3]。起点包括:
在本例中,通知实际上是消息和好友列表之间的连接,使得它与我们先前讨论的连接的时序问题有关(请参阅 “[连接的时间依赖性](/ch12#sec_stream_join_time)”)。不幸的是,这个问题似乎并没有一个简单的答案[^2] [^3]。起点包括:
* 逻辑时间戳可以提供无需协调的全局顺序(请参阅 “[序列号顺序](/ch10#sec_consistency_logical)”),因此它们可能有助于全序广播不可行的情况。但是,他们仍然要求收件人处理不按顺序发送的事件,并且需要传递其他元数据。
* 如果你可以记录一个事件来记录用户在做出决定之前所看到的系统状态,并给该事件一个唯一的标识符,那么后面的任何事件都可以引用该事件标识符来记录因果关系[^4]。我们将在 “[读也是事件](#读也是事件)” 中回到这个想法。
* 如果你可以记录一个事件来记录用户在做出决定之前所看到的系统状态,并给该事件一个唯一的标识符,那么后面的任何事件都可以引用该事件标识符来记录因果关系[^4]。我们将在 “[读也是事件](#sec_future_read_events)” 中回到这个想法。
* 冲突解决算法(请参阅 “[自动冲突解决](/ch6#automatic-conflict-resolution)”)有助于处理以意外顺序传递的事件。它们对于维护状态很有用,但如果行为有外部副作用(例如,给用户发送通知),就没什么帮助了。
也许,随着时间的推移,应用开发模式将出现,使得能够有效地捕获因果依赖关系,并且保持正确的派生状态,而不会迫使所有事件经历全序广播的瓶颈)。
@ -105,7 +105,7 @@ breadcrumbs: false
原则上,派生数据系统可以同步地维护,就像关系数据库在与索引表写入操作相同的事务中同步更新次级索引一样。然而,异步是使基于事件日志的系统稳健的原因:它允许系统的一部分故障被抑制在本地。而如果任何一个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障(请参阅 “[分布式事务的限制](/ch8#sec_transactions_xa)”)。
我们在 “[分区与次级索引](/ch7#sec_sharding_secondary_indexes)” 中看到,次级索引经常跨越分区边界。具有次级索引的分区系统需要将写入发送到多个分区(如果索引按关键词分区的话)或将读取发送到所有分区(如果索引是按文档分区的话)。如果索引是异步维护的,这种跨分区通信也是最可靠和最可伸缩的[^8](另请参阅 “[多分区数据处理](#多分区数据处理)”)。
我们在 “[分区与次级索引](/ch7#sec_sharding_secondary_indexes)” 中看到,次级索引经常跨越分区边界。具有次级索引的分区系统需要将写入发送到多个分区(如果索引按关键词分区的话)或将读取发送到所有分区(如果索引是按文档分区的话)。如果索引是异步维护的,这种跨分区通信也是最可靠和最可伸缩的[^8](另请参阅 “[多分区数据处理](#sec_future_unbundled_multi_shard)”)。
#### 应用演化后重新处理数据 {#sec_future_reprocessing}
@ -169,7 +169,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
此过程非常类似于设置新的从库副本(请参阅 “[设置新从库](/ch6#sec_replication_new_replica)”),也非常类似于流处理系统中的 **引导bootstrap** 变更数据捕获(请参阅 “[初始快照](/ch12#sec_stream_cdc_snapshot)”)。
无论何时运行 `CREATE INDEX`,数据库都会重新处理现有数据集(如 “[应用演化后重新处理数据](#应用演化后重新处理数据)” 中所述),并将该索引作为新视图导出到现有数据上。现有数据可能是状态的快照,而不是所有发生变化的日志,但两者密切相关(请参阅 “[状态、流和不变性](/ch12#sec_stream_immutability)”)。
无论何时运行 `CREATE INDEX`,数据库都会重新处理现有数据集(如 “[应用演化后重新处理数据](#sec_future_reprocessing)” 中所述),并将该索引作为新视图导出到现有数据上。现有数据可能是状态的快照,而不是所有发生变化的日志,但两者密切相关(请参阅 “[状态、流和不变性](/ch12#sec_stream_immutability)”)。
#### 一切的元数据库 {#id341}
@ -181,13 +181,13 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
**联合数据库:统一读取**
可以为各种各样的底层存储引擎和处理方法提供一个统一的查询接口 —— 一种称为 **联合数据库federated database****多态存储polystore** 的方法[^18][^19]。例如PostgreSQL 的 **外部数据包装器foreign data wrapper** 功能符合这种模式[^20]。需要专用数据模型或查询接口的应用程序仍然可以直接访问底层存储引擎,而想要组合来自不同位置的数据的用户可以通过联合接口轻松完成操作。
可以为各种各样的底层存储引擎和处理方法提供一个统一的查询接口 —— 一种称为 **联合数据库federated database****多态存储polystore** 的方法[^18] [^19]。例如PostgreSQL 的 **外部数据包装器foreign data wrapper** 功能符合这种模式[^20]。需要专用数据模型或查询接口的应用程序仍然可以直接访问底层存储引擎,而想要组合来自不同位置的数据的用户可以通过联合接口轻松完成操作。
联合查询接口遵循着单一集成系统的关系型传统,带有高级查询语言和优雅的语义,但实现起来非常复杂。
**分拆数据库:统一写入**
虽然联合能解决跨多个不同系统的只读查询问题,但它并没有很好的解决跨系统 **同步** 写入的问题。我们说过,在单个数据库中,创建一致的索引是一项内置功能。当我们构建多个存储系统时,我们同样需要确保所有数据变更都会在所有正确的位置结束,即使在出现故障时也是如此。想要更容易地将存储系统可靠地插接在一起(例如,通过变更数据捕获和事件日志),就像将数据库的索引维护功能以可以跨不同技术同步写入的方式分开[^7][^21]。
虽然联合能解决跨多个不同系统的只读查询问题,但它并没有很好的解决跨系统 **同步** 写入的问题。我们说过,在单个数据库中,创建一致的索引是一项内置功能。当我们构建多个存储系统时,我们同样需要确保所有数据变更都会在所有正确的位置结束,即使在出现故障时也是如此。想要更容易地将存储系统可靠地插接在一起(例如,通过变更数据捕获和事件日志),就像将数据库的索引维护功能以可以跨不同技术同步写入的方式分开[^7] [^21]。
分拆方法遵循 Unix 传统的小型工具,它可以很好地完成一件事[^22],通过统一的低层级 API管道进行通信并且可以使用更高层级的语言进行组合shell[^16] 。
@ -195,7 +195,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
联合和分拆是一个硬币的两面:用不同的组件构成可靠、 可伸缩和可维护的系统。联合只读查询需要将一个数据模型映射到另一个数据模型,这需要一些思考,但最终还是一个可解决的问题。而我认为同步写入到几个存储系统是更困难的工程问题,所以我将重点关注它。
传统的同步写入方法需要跨异构存储系统的分布式事务[^18],我认为这是错误的解决方案(请参阅 “[派生数据与分布式事务](#派生数据与分布式事务)”)。单个存储或流处理系统内的事务是可行的,但是当数据跨越不同技术之间的边界时,我认为具有幂等写入的异步事件日志是一种更加健壮和实用的方法。
传统的同步写入方法需要跨异构存储系统的分布式事务[^18],我认为这是错误的解决方案(请参阅 “[派生数据与分布式事务](#sec_future_derived_vs_transactions)”)。单个存储或流处理系统内的事务是可行的,但是当数据跨越不同技术之间的边界时,我认为具有幂等写入的异步事件日志是一种更加健壮和实用的方法。
例如,分布式事务在某些流处理组件内部使用,以匹配 **恰好一次exactly-once** 语义(请参阅 “[原子提交再现](/ch12#sec_stream_atomic_commit)”),这可以很好地工作。然而,当事务需要涉及由不同人群编写的系统时(例如,当数据从流处理组件写入分布式键值存储或搜索索引时),缺乏标准化的事务协议会使集成更难。有幂等消费者的有序事件日志(请参阅 “[幂等性](/ch12#sec_stream_idempotence)”)是一种更简单的抽象,因此在异构系统中实现更加可行[^7]。
@ -253,7 +253,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
从数据流的角度思考应用程序,意味着重新协调应用代码和状态管理之间的关系。我们不再将数据库视作被应用操纵的被动变量,取而代之的是更多地考虑状态,状态变更和处理它们的代码之间的相互作用与协同关系。应用代码通过在另一个地方触发状态变更来响应状态变更。
我们在 “[数据库与流](/ch12#sec_stream_databases)” 中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。诸如 Actor 的消息传递系统(请参阅 “[消息传递中的数据流](/ch5#sec_encoding_dataflow_msg)”)也具有响应事件的概念。早在 20 世纪 80 年代,**元组空间tuple space** 模型就已经探索了表达分布式计算的方式:观察状态变更并作出反应的过程[^38][^39]。
我们在 “[数据库与流](/ch12#sec_stream_databases)” 中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。诸如 Actor 的消息传递系统(请参阅 “[消息传递中的数据流](/ch5#sec_encoding_dataflow_msg)”)也具有响应事件的概念。早在 20 世纪 80 年代,**元组空间tuple space** 模型就已经探索了表达分布式计算的方式:观察状态变更并作出反应的过程[^38] [^39]。
如前所述,当触发器由于数据变更而被触发时,或次级索引更新以反映索引表中的变更时,数据库内部也发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外,用于创建派生数据集:缓存、全文检索索引、机器学习或分析系统。我们可以为此使用流处理和消息传递系统。
@ -272,7 +272,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
在数据流中组装流算子与微服务方法有很多相似之处[^40]。但底层通信机制是有很大区别:数据流采用单向异步消息流,而不是同步的请求 / 响应式交互。
除了在 “[消息传递中的数据流](/ch5#sec_encoding_dataflow_msg)” 中列出的优点(如更好的容错性),数据流系统还能实现更好的性能。例如,假设客户正在购买以一种货币定价,但以另一种货币支付的商品。为了执行货币换算,你需要知道当前的汇率。这个操作可以通过两种方式实现[^40][^41]
除了在 “[消息传递中的数据流](/ch5#sec_encoding_dataflow_msg)” 中列出的优点(如更好的容错性),数据流系统还能实现更好的性能。例如,假设客户正在购买以一种货币定价,但以另一种货币支付的商品。为了执行货币换算,你需要知道当前的汇率。这个操作可以通过两种方式实现[^40] [^41]
1. 在微服务方法中,处理购买的代码可能会查询汇率服务或数据库,以获取特定货币的当前汇率。
2. 在数据流方法中,处理订单的代码会提前订阅汇率变更流,并在汇率发生变动时将当前汇率存储在本地数据库中。处理订单时只需查询本地数据库即可。
@ -285,7 +285,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
### 观察派生数据状态 {#sec_future_observing}
在抽象层面,上一节讨论的数据流系统给出了创建并维护派生数据集(如搜索索引、物化视图、预测模型)的过程。我们把这称为 **写路径write path**:当信息写入系统后,它可能经过多个批处理与流处理阶段,最终所有相关派生数据集都会被更新。[图 13-1](/ch13#fig_future_write_read_paths) 展示了搜索索引更新的例子。
在抽象层面,上一节讨论的数据流系统给出了创建并维护派生数据集(如搜索索引、物化视图、预测模型)的过程。我们把这称为 **写路径write path**:当信息写入系统后,它可能经过多个批处理与流处理阶段,最终所有相关派生数据集都会被更新。[图 13-1](#fig_future_write_read_paths) 展示了搜索索引更新的例子。
{{< figure src="/fig/ddia_1301.png" id="fig_future_write_read_paths" caption="图 13-1 在搜索索引中,写入(文档更新)与读取(查询)相遇。" class="w-full my-4" >}}
@ -293,7 +293,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。
如 [图 13-1](/ch13#fig_future_write_read_paths) 所示,派生数据集是写路径和读路径相遇的地方。它代表了写入时工作量与读取时工作量之间的权衡。
如 [图 13-1](#fig_future_write_read_paths) 所示,派生数据集是写路径和读路径相遇的地方。它代表了写入时工作量与读取时工作量之间的权衡。
#### 物化视图和缓存 {#id451}
@ -357,7 +357,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
记录读取事件的日志可能对于追踪整个系统中的因果关系与数据来源也有好处:它可以让你重现出当用户做出特定决策之前看见了什么。例如在网商中,向客户显示的预测送达日期与库存状态,可能会影响他们是否选择购买一件商品[^4]。要分析这种联系,则需要记录用户查询运输与库存状态的结果。
将读取事件写入持久存储可以更好地跟踪因果关系(请参阅 “[排序事件以捕获因果关系](#排序事件以捕获因果关系)”),但会产生额外的存储与 I/O 成本。优化这些系统以减少开销仍然是一个开放的研究问题[^2]。但如果你已经出于运维目的留下了读取请求日志,将其作为请求处理的副作用,那么将这份日志作为请求事件源并不是什么特别大的变更。
将读取事件写入持久存储可以更好地跟踪因果关系(请参阅 “[排序事件以捕获因果关系](#sec_future_capture_causality)”),但会产生额外的存储与 I/O 成本。优化这些系统以减少开销仍然是一个开放的研究问题[^2]。但如果你已经出于运维目的留下了读取请求日志,将其作为请求处理的副作用,那么将这份日志作为请求事件源并不是什么特别大的变更。
#### 多分区数据处理 {#sec_future_unbundled_multi_shard}
@ -378,7 +378,7 @@ MPP 数据库的内部查询执行图有着类似的特征(请参阅 “[Hadoo
事务在某些领域被完全抛弃,并被提供更好性能与可伸缩性的模型取代,但后者有更复杂的语义(例如,请参阅 “[无主复制](/ch6#sec_replication_leaderless)”)。**一致性Consistency** 经常被谈起,但其定义并不明确(请参阅 “[一致性](/ch8#sec_transactions_acid_consistency)” 和 [第十章](/ch10))。有些人断言我们应当为了高可用而 “拥抱弱一致性”,但却对这些概念实际上意味着什么缺乏清晰的认识。
对于如此重要的话题,我们的理解,以及我们的工程方法却是惊人地薄弱。例如,确定在特定事务隔离等级或复制配置下运行特定应用是否安全是非常困难的[^51][^52]。通常简单的解决方案似乎在低并发性的情况下工作正常,并且没有错误,但在要求更高的情况下却会出现许多微妙的错误。
对于如此重要的话题,我们的理解,以及我们的工程方法却是惊人地薄弱。例如,确定在特定事务隔离等级或复制配置下运行特定应用是否安全是非常困难的[^51] [^52]。通常简单的解决方案似乎在低并发性的情况下工作正常,并且没有错误,但在要求更高的情况下却会出现许多微妙的错误。
例如Kyle Kingsbury 的 Jepsen 实验[^53]标出了一些产品声称的安全保证与其在网络问题与崩溃时的实际行为之间的明显差异。即使像数据库这样的基础设施产品没有问题,应用代码仍然需要正确使用它们提供的功能才行,如果配置很难理解,这是很容易出错的(在这种情况下指的是弱隔离级别,法定人数配置等)。
@ -481,7 +481,7 @@ COMMIT;
### 强制约束 {#sec_future_constraints}
让我们思考一下在 [分拆数据库](#分拆数据库) 上下文中的 **正确性correctness**。我们看到端到端的除重可以通过从客户端一路透传到数据库的请求 ID 实现。那么其他类型的约束呢?
让我们思考一下在 [分拆数据库](#sec_future_unbundling) 上下文中的 **正确性correctness**。我们看到端到端的除重可以通过从客户端一路透传到数据库的请求 ID 实现。那么其他类型的约束呢?
我们先来特别关注一下 **唯一性约束** —— 例如我们在 [例 13-2](#fig_future_request_id) 中所依赖的约束。在 “[约束和唯一性保证](/ch10#sec_consistency_uniqueness)” 中,我们看到了几个其他需要强制实施唯一性的应用功能例子:用户名或电子邮件地址必须唯一标识用户,文件存储服务不能包含多个重名文件,两个人不能在航班或剧院预订同一个座位。
@ -537,7 +537,7 @@ COMMIT;
事务的一个便利属性是,它们通常是线性一致的(请参阅 “[线性一致性](/ch10#sec_consistency_linearizability)”),也就是说,写入者会等到事务提交,而之后其写入立刻对所有读取者可见。
当我们把一个操作拆分为跨越多个阶段的流处理器时,却并非如此:日志的消费者在设计上就是异步的,因此发送者不会等其消息被消费者处理完。但是,客户端等待输出流中的特定消息是可能的。这正是我们在 “[基于日志消息传递中的唯一性](#基于日志消息传递中的唯一性)” 一节中检查唯一性约束时所做的事情。
当我们把一个操作拆分为跨越多个阶段的流处理器时,却并非如此:日志的消费者在设计上就是异步的,因此发送者不会等其消息被消费者处理完。但是,客户端等待输出流中的特定消息是可能的。这正是我们在 “[基于日志消息传递中的唯一性](#sec_future_uniqueness_log)” 一节中检查唯一性约束时所做的事情。
在这个例子中,唯一性检查的正确性不取决于消息发送者是否等待结果。等待的目的仅仅是同步通知发送者唯一性检查是否成功。但该通知可以与消息处理的结果相解耦。
@ -573,7 +573,7 @@ ACID 事务通常既提供及时性(例如线性一致性)也提供完整性
正如我们在上一节看到的那样,可靠的流处理系统可以在无需分布式事务与原子提交协议的情况下保持完整性,这意味着它们有潜力达到与后者相当的正确性,同时还具备好得多的性能与运维稳健性。为了达成这种正确性,我们组合使用了多种机制:
* 将写入操作的内容表示为单条消息,从而可以轻松地被原子写入 —— 与事件溯源搭配效果拔群(请参阅 “[事件溯源](/ch12#sec_stream_event_sourcing)”)。
* 使用与存储过程类似的确定性派生函数,从这一消息中派生出所有其他的状态变更(请参阅 “[真的串行执行](/ch8#sec_transactions_serial)” 和 “[应用代码作为派生函数](/ch13#sec_future_dataflow_derivation)”)
* 使用与存储过程类似的确定性派生函数,从这一消息中派生出所有其他的状态变更(请参阅 “[真的串行执行](/ch8#sec_transactions_serial)” 和 “[应用代码作为派生函数](#sec_future_dataflow_derivation)”)
* 将客户端生成的请求 ID 传递通过所有的处理层次,从而允许端到端的除重,带来幂等性。
* 使消息不可变,并允许派生数据能随时被重新处理,这使从错误中恢复更加容易(请参阅 “[不可变事件的优点](/ch12#sec_stream_immutability_pros)”)
@ -585,7 +585,7 @@ ACID 事务通常既提供及时性(例如线性一致性)也提供完整性
然而另一个需要了解的事实是,许多真实世界的应用实际上可以摆脱这种形式,接受弱得多的唯一性:
* 如果两个人同时注册了相同的用户名或预订了相同的座位,你可以给其中一个人发消息道歉,并要求他们换一个不同的用户名或座位。这种纠正错误的变化被称为 **补偿性事务compensating transaction**[^59][^60]。
* 如果两个人同时注册了相同的用户名或预订了相同的座位,你可以给其中一个人发消息道歉,并要求他们换一个不同的用户名或座位。这种纠正错误的变化被称为 **补偿性事务compensating transaction**[^59] [^60]。
* 如果客户订购的物品多于仓库中的物品,你可以下单补仓,并为延误向客户道歉,向他们提供折扣。实际上,这么说吧,如果叉车在仓库中轧过了你的货物,剩下的货物比你想象的要少,那么你也是得这么做[^61]。因此,既然道歉工作流无论如何已经成为你商业过程中的一部分了,那么对库存物品数目添加线性一致的约束可能就没必要了。
* 与之类似,许多航空公司都会超卖机票,打着一些旅客可能会错过航班的算盘;许多旅馆也会超卖客房,抱着部分客人可能会取消预订的期望。在这些情况下,出于商业原因而故意违反了 “一人一座” 的约束;当需求超过供给的情况出现时,就会进入补偿流程(退款、升级舱位 / 房型、提供隔壁酒店的免费的房间)。即使没有超卖,为了应对由恶劣天气或员工罢工导致的航班取消,你还是需要道歉与补偿流程 —— 从这些问题中恢复仅仅是商业活动的正常组成部分。
* 如果有人从账户超额取款,银行可以向他们收取透支费用,并要求他们偿还欠款。通过限制每天的提款总额,银行的风险是有限的。
@ -647,13 +647,13 @@ ACID 意义下的一致性(请参阅 “[一致性](/ch8#sec_transactions_acid
显式处理数据流(请参阅 “[批处理输出的哲学](/ch11#sec_batch_output)”)可以使数据的 **来龙去脉provenance** 更加清晰,从而使完整性检查更具可行性。对于事件日志,我们可以使用散列来检查事件存储没有被破坏。对于任何派生状态,我们可以重新运行从事件日志中派生它的批处理器与流处理器,以检查是否获得相同的结果,或者,甚至并行运行冗余的派生流程。
具有确定性且定义良好的数据流,也使调试与跟踪系统的执行变得容易,以便确定它 **为什么** 做了某些事情[^4][^69]。如果出现意想之外的事情,那么重现导致意外事件的确切事故现场的诊断能力 —— 一种时间旅行调试功能是非常有价值的。
具有确定性且定义良好的数据流,也使调试与跟踪系统的执行变得容易,以便确定它 **为什么** 做了某些事情[^4] [^69]。如果出现意想之外的事情,那么重现导致意外事件的确切事故现场的诊断能力 —— 一种时间旅行调试功能是非常有价值的。
#### 端到端原则重现 {#id456}
如果我们不能完全相信系统的每个组件都不会损坏 —— 每一个硬件都没缺陷,每一个软件都没有 Bug —— 那我们至少必须定期检查数据的完整性。如果我们不检查,我们就不能发现损坏,直到无可挽回地导致对下游的破坏时,那时候再去追踪问题就要难得多,且代价也要高的多。
检查数据系统的完整性,最好是以端到端的方式进行(请参阅 “[数据库的端到端原则](#数据库的端到端原则)”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个派生数据管道端到端的正确性,那么沿着这一路径的任何磁盘、网络、服务以及算法的正确性检查都隐含在其中了。
检查数据系统的完整性,最好是以端到端的方式进行(请参阅 “[数据库的端到端原则](#sec_future_end_to_end)”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个派生数据管道端到端的正确性,那么沿着这一路径的任何磁盘、网络、服务以及算法的正确性检查都隐含在其中了。
持续的端到端完整性检查可以不断提高你对系统正确性的信心,从而使你能更快地进步[^70]。与自动化测试一样,审计提高了快速发现错误的可能性,从而降低了系统变更或新存储技术可能导致损失的风险。如果你不害怕进行变更,就可以更好地充分演化一个应用,使其满足不断变化的需求。
@ -661,9 +661,9 @@ ACID 意义下的一致性(请参阅 “[一致性](/ch8#sec_transactions_acid
目前,把可审计性作为一级目标的数据系统还不多。一些应用会实现自己的审计机制(例如把变更写入独立审计表),但要同时保证审计日志与主数据库状态都不可篡改仍然很难。
像 Bitcoin、Ethereum 这样的区块链,本质上是带密码学一致性校验的共享仅追加日志;交易可视作事件,智能合约可视作流处理器。它们通过共识协议让所有节点同意同一事件序列。与本书 [第十章](/ch10) 的共识协议相比,区块链的一个差异是强调拜占庭容错:参与节点会持续相互校验完整性[^71][^72][^73]。
像 Bitcoin、Ethereum 这样的区块链,本质上是带密码学一致性校验的共享仅追加日志;交易可视作事件,智能合约可视作流处理器。它们通过共识协议让所有节点同意同一事件序列。与本书 [第十章](/ch10) 的共识协议相比,区块链的一个差异是强调拜占庭容错:参与节点会持续相互校验完整性[^71] [^72] [^73]。
对多数应用而言,区块链整体开销仍偏高;但其中一些密码学工具可在更轻量的场景复用。比如 **默克尔树Merkle tree**[^74]可高效证明某条记录属于某数据集。**证书透明性certificate transparency** 使用可验证的仅追加日志与默克尔树来校验 TLS/SSL 证书有效性[^75][^76]。
对多数应用而言,区块链整体开销仍偏高;但其中一些密码学工具可在更轻量的场景复用。比如 **默克尔树Merkle tree**[^74]可高效证明某条记录属于某数据集。**证书透明性certificate transparency** 使用可验证的仅追加日志与默克尔树来校验 TLS/SSL 证书有效性[^75] [^76]。
未来,这类完整性校验与审计算法可能会在通用数据系统中更广泛应用。要把它们做到与无密码学审计系统同等级别的可伸缩性,同时把性能开销压到足够低,仍需要工程改进,但方向值得重视。

View file

@ -6,7 +6,7 @@ breadcrumbs: false
<a id="ch_right_thing"></a>
![](/map/ch13.png)
![](/map/ch12.png)
> *将世界的美好、丑陋与残酷一起喂给 AI却期待它只反映美好的一面这是一种幻想。*
>

View file

@ -19,10 +19,10 @@ breadcrumbs: false
许多非功能性需求(比如安全)超出了本书范围。但本章会讨论其中几项核心要求,并帮助你用更清晰的方式描述自己的系统:
* 如何定义并衡量系统的 **性能**(参见 ["描述性能"](/ch2#sec_introduction_percentiles)
* 服务 **可靠** 到底意味着什么:也就是在出错时仍能持续正确工作(参见 ["可靠性与容错"](/ch2#sec_introduction_reliability)
* 如何通过高效增加计算资源,让系统在负载增长时保持 **可伸缩性**(参见 ["可伸缩性"](/ch2#sec_introduction_scalability));以及
* 如何让系统在长期演进中保持 **可维护性**(参见 ["可维护性"](/ch2#sec_introduction_maintainability))。
* 如何定义并衡量系统的 **性能**(参见 ["描述性能"](#sec_introduction_percentiles)
* 服务 **可靠** 到底意味着什么:也就是在出错时仍能持续正确工作(参见 ["可靠性与容错"](#sec_introduction_reliability)
* 如何通过高效增加计算资源,让系统在负载增长时保持 **可伸缩性**(参见 ["可伸缩性"](#sec_introduction_scalability));以及
* 如何让系统在长期演进中保持 **可维护性**(参见 ["可维护性"](#sec_introduction_maintainability))。
本章引入的术语,在后续章节深入实现细节时也会反复用到。不过纯定义往往比较抽象。为了把概念落到实处,本章先从一个案例研究开始:看看社交网络服务可能如何实现,并借此讨论性能与可伸缩性问题。
@ -35,7 +35,7 @@ breadcrumbs: false
### 表示用户、帖子与关注关系 {#id20}
假设我们将所有数据保存在关系数据库中,如 [图 2-1](/ch2#fig_twitter_relational) 所示。我们有一个用户表、一个帖子表和一个关注关系表。
假设我们将所有数据保存在关系数据库中,如 [图 2-1](#fig_twitter_relational) 所示。我们有一个用户表、一个帖子表和一个关注关系表。
{{< figure src="/fig/ddia_0201.png" id="fig_twitter_relational" caption="图 2-1. 社交网络的简单关系模式,用户可以相互关注。" class="w-full my-4" >}}
@ -62,7 +62,7 @@ SELECT posts.*, users.* FROM posts
设想我们为每个用户维护一个数据结构,保存其首页时间线,也就是其所追随者的近期帖子。每当用户发帖,我们就找出其所有追随者,把这条帖子插入每个追随者的首页时间线中,就像往邮箱里投递信件。这样用户登录时,可以直接读取预先算好的时间线。若要接收新帖提醒,客户端只需订阅“写入该时间线”的帖子流即可。
这种方法的缺点是:每次发帖时都要做更多工作,因为首页时间线属于需要持续更新的派生数据。这个过程见 [图 2-2](/ch2#fig_twitter_timelines)。当一个初始请求触发多个下游请求时,我们用 *扇出* 描述请求数量被放大的倍数。
这种方法的缺点是:每次发帖时都要做更多工作,因为首页时间线属于需要持续更新的派生数据。这个过程见 [图 2-2](#fig_twitter_timelines)。当一个初始请求触发多个下游请求时,我们用 *扇出* 描述请求数量被放大的倍数。
{{< figure src="/fig/ddia_0202.png" id="fig_twitter_timelines" caption="图 2-2. 扇出:将新帖子传递给发布帖子的用户的每个追随者。" class="w-full my-4" >}}
@ -87,12 +87,14 @@ SELECT posts.*, users.* FROM posts
在社交网络案例中,“每秒帖子数”和“每秒时间线写入数”属于吞吐量指标;“加载首页时间线所需时间”或“帖子送达追随者所需时间”属于响应时间指标。
吞吐量和响应时间之间通常相关。在线服务的典型关系如 [图 2-3](/ch2#fig_throughput):低吞吐量时响应时间较低,负载升高后响应时间上升。原因是 *排队*。请求到达高负载系统时CPU 往往已在处理前一个请求,新请求只能等待;当吞吐量逼近硬件上限,排队延迟会急剧上升。
吞吐量和响应时间之间通常相关。在线服务的典型关系如 [图 2-3](#fig_throughput):低吞吐量时响应时间较低,负载升高后响应时间上升。原因是 *排队*。请求到达高负载系统时CPU 往往已在处理前一个请求,新请求只能等待;当吞吐量逼近硬件上限,排队延迟会急剧上升。
{{< figure src="/fig/ddia_0203.png" id="fig_throughput" caption="图 2-3. 随着服务的吞吐量接近其容量,由于排队,响应时间急剧增加。" class="w-full my-4" >}}
--------
<a id="sidebar_metastable"></a>
> [!TIP] 当过载系统无法恢复时
如果系统已接近过载、吞吐量逼近极限,有时会进入恶性循环:效率下降,进而更加过载。例如,请求队列很长时,响应时间可能高到让客户端超时并重发请求,导致请求速率进一步上升,问题持续恶化,形成 *重试风暴*。即使负载后来回落,系统也可能仍卡在过载状态,直到重启或重置。这种现象叫 *亚稳态故障*Metastable Failure可能引发严重生产故障 [^7] [^8]。
@ -103,11 +105,11 @@ SELECT posts.*, users.* FROM posts
从性能指标角度看,用户通常最关心响应时间;而吞吐量决定了所需计算资源(例如服务器数量),从而决定承载特定工作负载的成本。如果吞吐量增长可能超过当前硬件上限,就必须扩容;若系统可通过增加计算资源显著提升最大吞吐量,就称其 *可伸缩*
本节主要讨论响应时间;吞吐量与可伸缩性会在 ["可伸缩性"](/ch2#sec_introduction_scalability) 一节再展开。
本节主要讨论响应时间;吞吐量与可伸缩性会在 ["可伸缩性"](#sec_introduction_scalability) 一节再展开。
### 延迟与响应时间 {#id23}
“延迟”和“响应时间”有时会混用,但本书对它们有明确区分(见 [图 2-4](/ch2#fig_response_time)
“延迟”和“响应时间”有时会混用,但本书对它们有明确区分(见 [图 2-4](#fig_response_time)
* *响应时间* 是客户端看到的总时间,包含链路上各处产生的全部延迟。
* *服务时间* 是服务主动处理该请求的时间。
@ -116,7 +118,7 @@ SELECT posts.*, users.* FROM posts
{{< 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](#fig_response_time) 中,时间从左向右流动。每个通信节点画成一条水平线,请求/响应消息画成节点间的粗斜箭头。本书后文会频繁使用这种图示风格。
即便反复发送同一个请求,响应时间也可能显著波动。许多因素都会引入随机延迟:例如切换到后台进程、网络丢包与 TCP 重传、垃圾回收暂停、缺页导致的磁盘读取、服务器机架机械振动 [^17] 等。我们会在 ["超时与无界延迟"](/ch9#sec_distributed_queueing) 进一步讨论这个问题。
@ -124,7 +126,7 @@ SELECT posts.*, users.* FROM posts
### 平均值、中位数与百分位点 {#id24}
由于响应时间会随请求变化,我们应将其看作一个可测量的 *分布*,而非单一数字。在 [图 2-5](/ch2#fig_lognormal) 中,每个灰色柱表示一次请求,柱高是该请求耗时。大多数请求较快,但会有少量更慢的 *异常值*。网络时延波动也常称为 *抖动*
由于响应时间会随请求变化,我们应将其看作一个可测量的 *分布*,而非单一数字。在 [图 2-5](#fig_lognormal) 中,每个灰色柱表示一次请求,柱高是该请求耗时。大多数请求较快,但会有少量更慢的 *异常值*。网络时延波动也常称为 *抖动*
{{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="图 2-5. 说明平均值和百分位点100 个服务请求的响应时间样本。" class="w-full my-4" >}}
@ -132,7 +134,7 @@ SELECT posts.*, users.* FROM posts
通常,*百分位点* 更有意义。把响应时间从快到慢排序,*中位数* 位于中间。例如中位响应时间为 200 毫秒,表示一半请求在 200 毫秒内返回,另一半更慢。因此中位数适合衡量用户“通常要等多久”。中位数也称 *第 50 百分位*,常记为 *p50*
为了看清异常值有多糟,需要观察更高百分位点:常见的是 *p95*、*p99*、*p999*。它们表示 95%、99%、99.9% 的请求都快于该阈值。例如 p95 为 1.5 秒,表示 100 个请求里有 95 个小于 1.5 秒,另外 5 个不小于 1.5 秒。[图 2-5](/ch2#fig_lognormal) 展示了这一点。
为了看清异常值有多糟,需要观察更高百分位点:常见的是 *p95*、*p99*、*p999*。它们表示 95%、99%、99.9% 的请求都快于该阈值。例如 p95 为 1.5 秒,表示 100 个请求里有 95 个小于 1.5 秒,另外 5 个不小于 1.5 秒。[图 2-5](#fig_lognormal) 展示了这一点。
响应时间的高百分位点(也叫 *尾部延迟*)非常重要,因为它直接影响用户体验。例如亚马逊内部服务常以第 99.9 百分位设定响应要求,尽管它只影响 1/1000 的请求。原因是最慢请求往往来自“账户数据最多”的客户,他们通常也是最有价值客户 [^19]。让这批用户也能获得快速响应,对业务很关键。
@ -154,7 +156,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加
### 响应时间指标的应用 {#sec_introduction_slo_sla}
对于“一个终端请求会触发多次后端调用”的服务,高百分位点尤其关键。即使并行调用,终端请求仍要等待最慢的那个返回。正如 [图 2-6](/ch2#fig_tail_amplification) 所示,只要一个调用慢,就能拖慢整个终端请求。即便慢调用比例很小,只要后端调用次数变多,撞上慢调用的概率就会上升,于是更大比例的终端请求会变慢(称为 *尾部延迟放大* [^26])。
对于“一个终端请求会触发多次后端调用”的服务,高百分位点尤其关键。即使并行调用,终端请求仍要等待最慢的那个返回。正如 [图 2-6](#fig_tail_amplification) 所示,只要一个调用慢,就能拖慢整个终端请求。即便慢调用比例很小,只要后端调用次数变多,撞上慢调用的概率就会上升,于是更大比例的终端请求会变慢(称为 *尾部延迟放大* [^26])。
{{< figure src="/fig/ddia_0206.png" id="fig_tail_amplification" caption="图 2-6. 当需要几个后端调用来服务请求时,只需要一个慢的后端请求就可以减慢整个最终用户请求。" class="w-full my-4" >}}
@ -240,7 +242,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加
导致这类软件故障的 bug 往往潜伏很久,直到一组不寻常条件把它触发出来。这时才暴露出:软件其实对运行环境做了某些假设,平时大多成立,但终有一天会因某种原因失效 [^68] [^69]。
软件系统性故障没有“速效药”。但许多小措施都有效:认真审视系统假设与交互、充分测试、进程隔离、允许进程崩溃并重启、避免反馈环路(如重试风暴,参见 ["当过载系统无法恢复时"](/ch2#sidebar_metastable)),以及在生产环境持续度量、监控和分析系统行为。
软件系统性故障没有“速效药”。但许多小措施都有效:认真审视系统假设与交互、充分测试、进程隔离、允许进程崩溃并重启、避免反馈环路(如重试风暴,参见 ["当过载系统无法恢复时"](#sidebar_metastable)),以及在生产环境持续度量、监控和分析系统行为。
### 人类与可靠性 {#id31}
@ -258,6 +260,8 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加
--------
<a id="sidebar_reliability_importance"></a>
> [!TIP] 可靠性有多重要?
可靠性不只适用于核电站或空管系统,普通应用同样需要可靠。企业软件中的 bug 会造成生产力损失(若报表错误还会带来法律风险);电商网站故障则会带来直接收入损失和品牌伤害。
@ -288,7 +292,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加
### 描述负载 {#id33}
首先要简明描述系统当前负载之后才能讨论“增长会怎样”例如负载翻倍会发生什么。最常见的是吞吐量指标每秒请求数、每天新增数据量GB、每小时购物车结账次数等。有时你关心的是峰值变量比如 ["案例研究:社交网络首页时间线"](/ch2#sec_introduction_twitter) 里的“同时在线用户数”。
首先要简明描述系统当前负载之后才能讨论“增长会怎样”例如负载翻倍会发生什么。最常见的是吞吐量指标每秒请求数、每天新增数据量GB、每小时购物车结账次数等。有时你关心的是峰值变量比如 ["案例研究:社交网络首页时间线"](#sec_introduction_twitter) 里的“同时在线用户数”。
此外还可能有其他统计特征会影响访问模式,进而影响可伸缩性要求。例如数据库读写比、缓存命中率、每用户数据项数量(如社交网络里的追随者数)。有时平均情况最关键,有时瓶颈由少数极端情况主导,具体取决于你的应用细节。
@ -297,7 +301,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加
* 以某种方式增大负载、但保持资源CPU、内存、网络带宽等不变时性能如何变化
* 若负载按某种方式增长、但你希望性能不变,需要增加多少资源?
通常目标是:在尽量降低运行成本的同时,让性能维持在 SLA 要求内(参见 ["响应时间指标的应用"](/ch2#sec_introduction_slo_sla))。所需计算资源越多,成本越高。不同硬件的性价比不同,而且会随着新硬件出现而变化。
通常目标是:在尽量降低运行成本的同时,让性能维持在 SLA 要求内(参见 ["响应时间指标的应用"](#sec_introduction_slo_sla))。所需计算资源越多,成本越高。不同硬件的性价比不同,而且会随着新硬件出现而变化。
如果资源翻倍后能承载两倍负载且性能不变,这称为 *线性可伸缩性*,通常是理想状态。偶尔,借助规模效应或峰值负载更均匀分布,甚至可用不足两倍资源处理两倍负载 [^79] [^80]。但更常见的是成本增长快于线性,低效原因也很多。比如数据量增大后,即使请求大小相同,处理一次写请求也可能比数据量小时更耗资源。

View file

@ -4,6 +4,8 @@ weight: 103
breadcrumbs: false
---
<a id="ch_datamodels"></a>
![](/map/ch02.png)
> *语言的边界就是世界的边界。*
@ -47,7 +49,7 @@ breadcrumbs: false
关系模型最初是一个理论提议,当时许多人怀疑它是否能够高效实现。
然而,到 20 世纪 80 年代中期关系数据库管理系统RDBMS和 SQL 已成为大多数需要存储和查询具有某种规则结构的数据的人的首选工具。
许多数据管理用例在几十年后仍然由关系数据主导 —— 例如,商业分析(参见 ["星型与雪花型:分析模式"](/ch3#sec_datamodels_analytics))。
许多数据管理用例在几十年后仍然由关系数据主导 —— 例如,商业分析(参见 ["星型与雪花型:分析模式"](#sec_datamodels_analytics))。
多年来,出现了许多与数据存储和查询相关的竞争方法。在 20 世纪 70 年代和 80 年代初,**网状模型** 和 **层次模型** 是主要的替代方案,但关系模型最终战胜了它们。
对象数据库在 20 世纪 80 年代末和 90 年代初出现又消失。XML 数据库在 21 世纪初出现,但只获得了小众的采用。
@ -94,13 +96,13 @@ NoSQL 运动的一个持久影响是 **文档模型** 的流行,它通常将
#### 用于一对多关系的文档数据模型 {#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](#fig_obama_relational) 说明了如何在关系模式中表达简历LinkedIn 个人资料)。整个个人资料可以通过唯一标识符 `user_id` 来识别。像 `first_name``last_name` 这样的字段每个用户只出现一次,因此它们可以建模为 `users` 表上的列。
大多数人在职业生涯中有多份工作(职位),人们可能有不同数量的教育经历和任意数量的联系信息。表示这种 *一对多关系* 的一种方法是将职位、教育和联系信息放在单独的表中,并使用外键引用 `users` 表,如 [图 3-1](/ch3#fig_obama_relational) 所示。
大多数人在职业生涯中有多份工作(职位),人们可能有不同数量的教育经历和任意数量的联系信息。表示这种 *一对多关系* 的一种方法是将职位、教育和联系信息放在单独的表中,并使用外键引用 `users` 表,如 [图 3-1](#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](#fig_obama_json) 所示。
{{< figure id="fig_obama_json" title="示例 3-1. 将 LinkedIn 个人资料表示为 JSON 文档" class="w-full my-4" >}}
@ -127,24 +129,24 @@ NoSQL 运动的一个持久影响是 **文档模型** 的流行,它通常将
}
```
一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。然而,正如我们将在 [第 5 章](/ch5#ch_encoding) 中看到的JSON 作为数据编码格式也存在问题。缺乏模式通常被认为是一个优势;我们将在 ["文档模型中的模式灵活性"](/ch3#sec_datamodels_schema_flexibility) 中讨论这个问题。
一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。然而,正如我们将在 [第 5 章](/ch5#ch_encoding) 中看到的JSON 作为数据编码格式也存在问题。缺乏模式通常被认为是一个优势;我们将在 ["文档模型中的模式灵活性"](#sec_datamodels_schema_flexibility) 中讨论这个问题。
与 [图 3-1](/ch3#fig_obama_relational) 中的多表模式相比JSON 表示具有更好的 *局部性*(参见 ["读写的数据局部性"](/ch3#sec_datamodels_document_locality))。如果你想在关系示例中获取个人资料,你需要执行多个查询(通过 `user_id` 查询每个表)或在 `users` 表与其从属表之间执行复杂的多表连接 [^8]。在 JSON 表示中,所有相关信息都在一个地方,使查询既更快又更简单。
与 [图 3-1](#fig_obama_relational) 中的多表模式相比JSON 表示具有更好的 *局部性*(参见 ["读写的数据局部性"](#sec_datamodels_document_locality))。如果你想在关系示例中获取个人资料,你需要执行多个查询(通过 `user_id` 查询每个表)或在 `users` 表与其从属表之间执行复杂的多表连接 [^8]。在 JSON 表示中,所有相关信息都在一个地方,使查询既更快又更简单。
从用户个人资料到用户职位、教育历史和联系信息的一对多关系暗示了数据中的树形结构,而 JSON 表示使这种树形结构变得明确(见 [图 3-2](/ch3#fig_json_tree))。
从用户个人资料到用户职位、教育历史和联系信息的一对多关系暗示了数据中的树形结构,而 JSON 表示使这种树形结构变得明确(见 [图 3-2](#fig_json_tree))。
{{< 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](#fig_obama_relational) 中的关系方法更可取。
--------
### 规范化、反规范化与连接 {#sec_datamodels_normalization}
在前一节的 [示例 3-1](/ch3#fig_obama_json) 中,`region_id` 被给出为 ID而不是纯文本字符串 `"Washington, DC, United States"`。为什么?
在前一节的 [示例 3-1](#fig_obama_json) 中,`region_id` 被给出为 ID而不是纯文本字符串 `"Washington, DC, United States"`。为什么?
如果用户界面有一个用于输入地区的自由文本字段,将其存储为纯文本字符串是有意义的。但是,拥有标准化的地理区域列表并让用户从下拉列表或自动补全中选择也有其优势:
@ -221,13 +223,13 @@ SELECT posts.id, posts.sender_id
### 多对一与多对多关系 {#sec_datamodels_many_to_many}
虽然 [图 3-1](/ch3#fig_obama_relational) 中的 `positions``education` 是一对多或一对少关系的例子(一份简历有多个职位,但每个职位只属于一份简历),但 `region_id` 字段是 *多对一* 关系的例子(许多人住在同一个地区,但我们假设每个人在任何时候只住在一个地区)。
虽然 [图 3-1](#fig_obama_relational) 中的 `positions``education` 是一对多或一对少关系的例子(一份简历有多个职位,但每个职位只属于一份简历),但 `region_id` 字段是 *多对一* 关系的例子(许多人住在同一个地区,但我们假设每个人在任何时候只住在一个地区)。
如果我们为组织和学校引入实体,并通过 ID 从简历中引用它们,那么我们也有 *多对多* 关系(一个人曾为多个组织工作,一个组织有多个过去或现在的员工)。在关系模型中,这种关系通常表示为 *关联表**连接表*,如 [图 3-3](/ch3#fig_datamodels_m2m_rel) 所示:每个职位将一个用户 ID 与一个组织 ID 关联起来。
如果我们为组织和学校引入实体,并通过 ID 从简历中引用它们,那么我们也有 *多对多* 关系(一个人曾为多个组织工作,一个组织有多个过去或现在的员工)。在关系模型中,这种关系通常表示为 *关联表**连接表*,如 [图 3-3](#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](#fig_datamodels_m2m_json) 所示,并在 [图 3-4](#fig_datamodels_many_to_many) 中说明:每个虚线矩形内的数据可以分组到一个文档中,但到组织和学校的链接最好表示为对其他文档的引用。
{{< figure id="fig_datamodels_m2m_json" title="示例 3-2. 通过 ID 引用组织的简历。" class="w-full my-4" >}}
@ -248,15 +250,15 @@ SELECT posts.id, posts.sender_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](#fig_datamodels_m2m_rel) 的关系模式中,我们会告诉数据库在 `positions` 表的 `user_id``org_id` 列上创建索引。
在 [示例 3-2](/ch3#fig_datamodels_m2m_json) 的文档模型中,数据库需要索引 `positions` 数组内对象的 `org_id` 字段。许多文档数据库和具有 JSON 支持的关系数据库能够在文档内的值上创建此类索引。
在 [示例 3-2](#fig_datamodels_m2m_json) 的文档模型中,数据库需要索引 `positions` 数组内对象的 `org_id` 字段。许多文档数据库和具有 JSON 支持的关系数据库能够在文档内的值上创建此类索引。
### 星型与雪花型:分析模式 {#sec_datamodels_analytics}
数据仓库(参见 ["数据仓库"](/ch1#sec_introduction_dwh))通常是关系型的,并且数据仓库中表结构有一些广泛使用的约定:*星型模式*、*雪花模式*、*维度建模* [^12],以及 *一张大表*OBT。这些结构针对业务分析师的需求进行了优化。ETL 过程将来自运营系统的数据转换为此模式。
[图 3-5](/ch3#fig_dwh_schema) 显示了一个可能在杂货零售商的数据仓库中找到的星型模式示例。模式的中心是所谓的 *事实表*(在此示例中,它称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买产品)。如果我们分析的是网站流量而不是零售销售,每一行可能代表用户的页面查看或点击。
[图 3-5](#fig_dwh_schema) 显示了一个可能在杂货零售商的数据仓库中找到的星型模式示例。模式的中心是所谓的 *事实表*(在此示例中,它称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买产品)。如果我们分析的是网站流量而不是零售销售,每一行可能代表用户的页面查看或点击。
{{< figure src="/fig/ddia_0305.png" id="fig_dwh_schema" caption="图 3-5. 用于数据仓库的星型模式示例。" class="w-full my-4" >}}
@ -264,11 +266,11 @@ SELECT posts.id, posts.sender_id
事实表中的一些列是属性,例如产品售出的价格和从供应商那里购买它的成本(允许计算利润率)。事实表中的其他列是对其他表的外键引用,称为 *维度表*。由于事实表中的每一行代表一个事件,维度代表事件的 *谁*、*什么*、*哪里*、*何时*、*如何* 和 *为什么*
例如,在 [图 3-5](/ch3#fig_dwh_schema) 中,其中一个维度是售出的产品。`dim_product` 表中的每一行代表一种待售产品类型包括其库存单位SKU、描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行使用外键来指示在该特定交易中售出了哪种产品。查询通常涉及对多个维度表的多个连接。
例如,在 [图 3-5](#fig_dwh_schema) 中,其中一个维度是售出的产品。`dim_product` 表中的每一行代表一种待售产品类型包括其库存单位SKU、描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行使用外键来指示在该特定交易中售出了哪种产品。查询通常涉及对多个维度表的多个连接。
即使日期和时间也经常使用维度表表示,因为这允许编码有关日期的附加信息(例如公共假期),允许查询区分假期和非假期的销售。
[图 3-5](/ch3#fig_dwh_schema) 是星型模式的一个例子。该名称来自这样一个事实:当表关系被可视化时,事实表位于中间,被其维度表包围;到这些表的连接就像星星的光芒。
[图 3-5](#fig_dwh_schema) 是星型模式的一个例子。该名称来自这样一个事实:当表关系被可视化时,事实表位于中间,被其维度表包围;到这些表的连接就像星星的光芒。
这个模板的一个变体被称为 *雪花模式*,其中维度被进一步分解为子维度。例如,品牌和产品类别可能有单独的表,`dim_product` 表中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表中。雪花模式比星型模式更规范化,但星型模式通常更受欢迎,因为它们对分析师来说更简单 [^12]。
@ -284,7 +286,7 @@ SELECT posts.id, posts.sender_id
文档数据模型的主要论点是模式灵活性、由于局部性而获得更好的性能,以及对于某些应用程序来说,它更接近应用程序使用的对象模型。关系模型通过为连接、多对一和多对多关系提供更好的支持来反击。让我们更详细地研究这些论点。
如果你的应用程序中的数据具有类似文档的结构(即一对多关系的树,通常一次加载整个树),那么使用文档模型可能是个好主意。将类似文档的结构 *切碎*shredding为多个表的关系技术如 [图 3-1](/ch3#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能导致繁琐的模式和不必要复杂的应用程序代码。
如果你的应用程序中的数据具有类似文档的结构(即一对多关系的树,通常一次加载整个树),那么使用文档模型可能是个好主意。将类似文档的结构 *切碎*shredding为多个表的关系技术如 [图 3-1](#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能导致繁琐的模式和不必要复杂的应用程序代码。
文档模型有局限性:例如,你不能直接引用文档中的嵌套项,而是需要说类似"用户 251 的职位列表中的第二项"之类的话。如果你确实需要引用嵌套项,关系方法效果更好,因为你可以通过其 ID 直接引用任何项。
@ -328,7 +330,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
#### 读写的数据局部性 {#sec_datamodels_document_locality}
文档通常存储为单个连续字符串,编码为 JSON、XML 或二进制变体(如 MongoDB 的 BSON。如果你的应用程序经常需要访问整个文档例如在网页上渲染它则这种 *存储局部性* 具有性能优势。如果数据分布在多个表中,如 [图 3-1](/ch3#fig_obama_relational) 所示,则需要多次索引查找才能检索所有数据,这可能需要更多的磁盘寻道并花费更多时间。
文档通常存储为单个连续字符串,编码为 JSON、XML 或二进制变体(如 MongoDB 的 BSON。如果你的应用程序经常需要访问整个文档例如在网页上渲染它则这种 *存储局部性* 具有性能优势。如果数据分布在多个表中,如 [图 3-1](#fig_obama_relational) 所示,则需要多次索引查找才能检索所有数据,这可能需要更多的磁盘寻道并花费更多时间。
局部性优势仅在你同时需要文档的大部分时才适用。数据库通常需要加载整个文档,如果你只需要访问大文档的一小部分,这可能会浪费。在文档更新时,通常需要重写整个文档。由于这些原因,通常建议你保持文档相当小,并避免频繁对文档进行小的更新。
@ -340,7 +342,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
XML 数据库通常使用 XQuery 和 XPath 查询,它们旨在允许复杂的查询,包括跨多个文档的连接,并将其结果格式化为 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 为 JSON 提供了等效于 XPath 的功能。
MongoDB 的聚合管道,我们在 ["规范化、反规范化与连接"](/ch3#sec_datamodels_normalization) 中看到了其用于连接的 `$lookup` 算子,是 JSON 文档集合查询语言的一个例子。
MongoDB 的聚合管道,我们在 ["规范化、反规范化与连接"](#sec_datamodels_normalization) 中看到了其用于连接的 `$lookup` 算子,是 JSON 文档集合查询语言的一个例子。
让我们看另一个例子来感受这种语言 —— 这次是聚合,这对分析特别需要。想象你是一名海洋生物学家,每次你在海洋中看到动物时,你都会向数据库添加一条观察记录。现在你想生成一份报告,说明你每个月看到了多少条鲨鱼。在 PostgreSQL 中,你可能会这样表达该查询:
@ -404,7 +406,7 @@ db.observations.aggregate([
众所周知的算法可以在这些图上运行例如地图导航应用程序搜索道路网络中两点之间的最短路径PageRank 可用于网页图以确定网页的受欢迎程度,从而确定其在搜索结果中的排名 [^32]。
图可以用几种不同的方式表示。在 *邻接表* 模型中,每个顶点存储其相距一条边的邻居顶点的 ID。或者你可以使用 *邻接矩阵*,这是一个二维数组,其中每一行和每一列对应一个顶点,当行顶点和列顶点之间没有边时值为零,如果有边则值为一。邻接表适合图遍历,矩阵适合机器学习(参见 ["数据框、矩阵与数组"](/ch3#sec_datamodels_dataframes))。
图可以用几种不同的方式表示。在 *邻接表* 模型中,每个顶点存储其相距一条边的邻居顶点的 ID。或者你可以使用 *邻接矩阵*,这是一个二维数组,其中每一行和每一列对应一个顶点,当行顶点和列顶点之间没有边时值为零,如果有边则值为一。邻接表适合图遍历,矩阵适合机器学习(参见 ["数据框、矩阵与数组"](#sec_datamodels_dataframes))。
在刚才给出的示例中,图中的所有顶点都表示相同类型的事物(分别是人、网页或道路交叉点)。然而,图不限于这种 *同质* 数据:图的一个同样强大的用途是提供一种一致的方式在单个数据库中存储完全不同类型的对象。例如:
@ -415,7 +417,7 @@ db.observations.aggregate([
我们还将查看图的四种查询语言Cypher、SPARQL、Datalog 和 GraphQL以及用于查询图的 SQL 支持。还存在其他图查询语言,如 Gremlin [^37],但这些将为我们提供代表性的概述。
为了说明这些不同的语言和模型,本节使用 [图 3-6](/ch3#fig_datamodels_graph) 中显示的图作为运行示例。它可能取自社交网络或家谱数据库:它显示了两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已婚并住在伦敦。每个人和每个位置都表示为顶点它们之间的关系表示为边。此示例将帮助演示一些在图数据库中很容易但在其他模型中很困难的查询。
为了说明这些不同的语言和模型,本节使用 [图 3-6](#fig_datamodels_graph) 中显示的图作为运行示例。它可能取自社交网络或家谱数据库:它显示了两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已婚并住在伦敦。每个人和每个位置都表示为顶点它们之间的关系表示为边。此示例将帮助演示一些在图数据库中很容易但在其他模型中很困难的查询。
{{< figure src="/fig/ddia_0306.png" id="fig_datamodels_graph" caption="图 3-6. 图结构数据示例(框表示顶点,箭头表示边)。" class="w-full my-4" >}}
@ -437,7 +439,7 @@ db.observations.aggregate([
* 描述两个顶点之间关系类型的标签
* 属性集合(键值对)
你可以将图存储视为由两个关系表组成,一个用于顶点,一个用于边,如 [示例 3-3](/ch3#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 数据类型来存储每个顶点或边的属性)。每条边都存储头顶点和尾顶点;如果你想要顶点的入边或出边集,可以分别通过 `head_vertex``tail_vertex` 查询 `edges` 表。
你可以将图存储视为由两个关系表组成,一个用于顶点,一个用于边,如 [示例 3-3](#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" >}}
@ -463,10 +465,10 @@ CREATE INDEX edges_heads ON edges (head_vertex);
此模型的一些重要方面是:
1. 任何顶点都可以有一条边将其与任何其他顶点连接。没有限制哪些类型的事物可以或不能关联的模式。
2. 给定任何顶点,你可以有效地找到其入边和出边,从而 *遍历* 图 —— 即通过顶点链跟随路径 —— 向前和向后。(这就是为什么 [示例 3-3](/ch3#fig_graph_sql_schema) 在 `tail_vertex``head_vertex` 列上都有索引。)
2. 给定任何顶点,你可以有效地找到其入边和出边,从而 *遍历* 图 —— 即通过顶点链跟随路径 —— 向前和向后。(这就是为什么 [示例 3-3](#fig_graph_sql_schema) 在 `tail_vertex``head_vertex` 列上都有索引。)
3. 通过对不同类型的顶点和关系使用不同的标签,你可以在单个图中存储几种不同类型的信息,同时仍保持简洁的数据模型。
边表就像我们在 ["多对一与多对多关系"](/ch3#sec_datamodels_many_to_many) 中看到的多对多关联表/连接表,泛化为允许在同一表中存储许多不同类型的关系。标签和属性上也可能有索引,允许有效地找到具有某些属性的顶点或边。
边表就像我们在 ["多对一与多对多关系"](#sec_datamodels_many_to_many) 中看到的多对多关联表/连接表,泛化为允许在同一表中存储许多不同类型的关系。标签和属性上也可能有索引,允许有效地找到具有某些属性的顶点或边。
--------
@ -475,7 +477,7 @@ CREATE INDEX edges_heads ON edges (head_vertex);
--------
这些功能为数据建模提供了极大的灵活性,如 [图 3-6](/ch3#fig_datamodels_graph) 所示。该图显示了一些在传统关系模式中难以表达的内容,例如不同国家的不同区域结构(法国有 *省**大区*,而美国有 *县**州*、历史的怪癖如国中之国暂时忽略主权国家和民族的复杂性以及不同粒度的数据Lucy 的当前居住地指定为城市,而她的出生地仅在州级别指定)。
这些功能为数据建模提供了极大的灵活性,如 [图 3-6](#fig_datamodels_graph) 所示。该图显示了一些在传统关系模式中难以表达的内容,例如不同国家的不同区域结构(法国有 *省**大区*,而美国有 *县**州*、历史的怪癖如国中之国暂时忽略主权国家和民族的复杂性以及不同粒度的数据Lucy 的当前居住地指定为城市,而她的出生地仅在州级别指定)。
你可以想象扩展图以包括有关 Lucy 和 Alain 或其他人的许多其他事实。例如,你可以使用它来指示他们有哪些食物过敏(通过为每个过敏原引入一个顶点,并在人和过敏原之间设置边以指示过敏),并将过敏原与显示哪些食物含有哪些物质的一组顶点链接。然后你可以编写查询来找出每个人可以安全食用的食物。图适合可演化性:随着你向应用程序添加功能,图可以轻松扩展以适应应用程序数据结构的变化。
@ -483,7 +485,7 @@ CREATE INDEX edges_heads ON edges (head_vertex);
*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](#fig_cypher_create) 显示了将 [图 3-6](#fig_datamodels_graph) 的左侧部分插入图数据库的 Cypher 查询。图的其余部分可以类似地添加。每个顶点都被赋予一个符号名称,如 `usa``idaho`。该名称不存储在数据库中,仅在查询内部使用以在顶点之间创建边,使用箭头符号:`(idaho) -[:WITHIN]-> (usa)` 创建一条标记为 `WITHIN` 的边,其中 `idaho` 作为尾节点,`usa` 作为头节点。
{{< figure link="#fig_datamodels_graph" id="fig_cypher_create" title="示例 3-4. 图 3-6 中数据的子集,表示为 Cypher 查询" class="w-full my-4" >}}
@ -497,9 +499,9 @@ CREATE
(lucy) -[:BORN_IN]-> (idaho)
```
当 [图 3-6](/ch3#fig_datamodels_graph) 的所有顶点和边都添加到数据库后,我们可以开始提出有趣的问题:例如,*查找所有从美国移民到欧洲的人的姓名*。也就是说,找到所有具有指向美国境内位置的 `BORN_IN` 边,以及指向欧洲境内位置的 `LIVING_IN` 边的顶点,并返回每个顶点的 `name` 属性。
当 [图 3-6](#fig_datamodels_graph) 的所有顶点和边都添加到数据库后,我们可以开始提出有趣的问题:例如,*查找所有从美国移民到欧洲的人的姓名*。也就是说,找到所有具有指向美国境内位置的 `BORN_IN` 边,以及指向欧洲境内位置的 `LIVING_IN` 边的顶点,并返回每个顶点的 `name` 属性。
[示例 3-5](/ch3#fig_cypher_query) 显示了如何在 Cypher 中表达该查询。相同的箭头符号用于 `MATCH` 子句中以在图中查找模式:`(person) -[:BORN_IN]-> ()` 匹配由标记为 `BORN_IN` 的边相关的任意两个顶点。该边的尾顶点绑定到变量 `person`,头顶点未命名。
[示例 3-5](#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" >}}
@ -525,7 +527,7 @@ RETURN person.name
### SQL 中的图查询 {#id58}
[示例 3-3](/ch3#fig_graph_sql_schema) 建议图数据可以在关系数据库中表示。但如果我们将图数据放入关系结构中,我们还能使用 SQL 查询它吗?
[示例 3-3](#fig_graph_sql_schema) 建议图数据可以在关系数据库中表示。但如果我们将图数据放入关系结构中,我们还能使用 SQL 查询它吗?
答案是肯定的,但有一些困难。你在图查询中遍历的每条边实际上都是与 `edges` 表的连接。在关系数据库中,你通常事先知道查询中需要哪些连接。另一方面,在图查询中,你可能需要遍历可变数量的边才能找到你要查找的顶点 —— 也就是说,连接的数量不是预先固定的。
@ -533,7 +535,7 @@ RETURN person.name
在 Cypher 中,`:WITHIN*0..` 非常简洁地表达了这个事实:它意味着"跟随 `WITHIN` 边,零次或多次"。它就像正则表达式中的 `*` 算子。
自 SQL:1999 以来,查询中可变长度遍历路径的想法可以使用称为 *递归公用表表达式*`WITH RECURSIVE` 语法)的东西来表达。[示例 3-6](/ch3#fig_graph_sql_query) 显示了相同的查询 —— 查找从美国移民到欧洲的人的姓名 —— 使用此技术在 SQL 中表达。然而,与 Cypher 相比,语法非常笨拙。
自 SQL:1999 以来,查询中可变长度遍历路径的想法可以使用称为 *递归公用表表达式*`WITH RECURSIVE` 语法)的东西来表达。[示例 3-6](#fig_graph_sql_query) 显示了相同的查询 —— 查找从美国移民到欧洲的人的姓名 —— 使用此技术在 SQL 中表达。然而,与 Cypher 相比,语法非常笨拙。
{{< figure link="#fig_cypher_query" id="fig_graph_sql_query" title="示例 3-6. 与 示例 3-5 相同的查询,使用递归公用表表达式在 SQL 中编写" class="w-full my-4" >}}
@ -607,13 +609,13 @@ Oracle 对递归查询有不同的 SQL 扩展,它称之为 *层次* [^41]。
三元组的主语等同于图中的顶点。宾语是两种东西之一:
1. 原始数据类型的值,如字符串或数字。在这种情况下,三元组的谓语和宾语等同于主语顶点上属性的键和值。使用 [图 3-6](/ch3#fig_datamodels_graph) 中的示例,(*lucy*、*birthYear*、*1989*)就像一个顶点 `lucy`,其属性为 `{"birthYear": 1989}`
1. 原始数据类型的值,如字符串或数字。在这种情况下,三元组的谓语和宾语等同于主语顶点上属性的键和值。使用 [图 3-6](#fig_datamodels_graph) 中的示例,(*lucy*、*birthYear*、*1989*)就像一个顶点 `lucy`,其属性为 `{"birthYear": 1989}`
2. 图中的另一个顶点。在这种情况下,谓语是图中的边,主语是尾顶点,宾语是头顶点。例如,在(*lucy*、*marriedTo*、*alain*)中,主语和宾语 *lucy**alain* 都是顶点,谓语 *marriedTo* 是连接它们的边的标签。
> [!NOTE]
> 准确地说提供类似三元组数据模型的数据库通常需要在每个元组上存储一些额外的元数据。例如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](#fig_graph_n3_triples) 显示了与 [示例 3-4](#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" >}}
@ -637,7 +639,7 @@ _:namerica :type "continent".
在此示例中,图的顶点写为 `_:someName`。该名称在此文件之外没有任何意义;它的存在只是因为否则我们不知道哪些三元组引用同一个顶点。当谓语表示边时,宾语是顶点,如 `_:idaho :within _:usa`。当谓语是属性时,宾语是字符串字面量,如 `_:usa :name "United States"`
一遍又一遍地重复相同的主语相当重复,但幸运的是,你可以使用分号来表达关于同一主语的多个内容。这使得 Turtle 格式非常易读:见 [示例 3-8](/ch3#fig_graph_n3_shorthand)。
一遍又一遍地重复相同的主语相当重复,但幸运的是,你可以使用分号来表达关于同一主语的多个内容。这使得 Turtle 格式非常易读:见 [示例 3-8](#fig_graph_n3_shorthand)。
{{< figure link="#fig_graph_n3_triples" id="fig_graph_n3_shorthand" title="示例 3-8. 编写 示例 3-7 中数据的更简洁方式" class="w-full my-4" >}}
@ -661,7 +663,7 @@ _:namerica a :Location; :name "North America"; :type "continent".
#### 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](#fig_graph_n3_shorthand) 中使用的 Turtle 语言实际上是在 *资源描述框架*RDF[^55] 中编码数据的一种方式这是为语义网设计的数据模型。RDF 数据也可以用其他方式编码,例如(更冗长地)用 XML如 [示例 3-9](#fig_graph_rdf_xml) 所示。像 Apache Jena 这样的工具可以在不同的 RDF 编码之间自动转换。
{{< figure link="#fig_graph_n3_shorthand" id="fig_graph_rdf_xml" title="示例 3-9. 示例 3-8 的数据,使用 RDF/XML 语法表示" class="w-full my-4" >}}
@ -701,9 +703,9 @@ URL `<http://my-company.com/namespace>` 不一定需要解析为任何内容 —
*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](#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](#fig_cypher_query) 相同的查询,用 SPARQL 表示" class="w-full my-4" >}}
```
PREFIX : <urn:example:>
@ -741,9 +743,9 @@ Datalog 实际上基于关系数据模型,而不是图,但它出现在本书
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](#fig_datalog_triples) 显示了如何在 Datalog 中编写 [图 3-6](#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" >}}
{{< figure id="fig_datalog_triples" title="示例 3-11. [图 3-6](#fig_datamodels_graph) 中数据的子集,表示为 Datalog 事实" class="w-full my-4" >}}
```
location(1, "North America", "continent").
@ -757,9 +759,9 @@ person(100, "Lucy").
born_in(100, 3). /* Lucy 出生在爱达荷州 */
```
现在我们已经定义了数据,我们可以编写与之前相同的查询,如 [示例 3-12](/ch3#fig_datalog_query) 所示。它看起来与 Cypher 或 SPARQL 中的等效查询有点不同但不要让这吓倒你。Datalog 是 Prolog 的子集,这是一种编程语言,如果你学过计算机科学,你可能见过它。
现在我们已经定义了数据,我们可以编写与之前相同的查询,如 [示例 3-12](#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](#fig_cypher_query) 相同的查询,用 Datalog 表示" class="w-full my-4" >}}
```sql
within_recursive(LocID, PlaceName) :- location(LocID, PlaceName, _). /* 规则 1 */
@ -779,11 +781,11 @@ us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* 规则 4
Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小步。我们定义 *规则* 从底层事实派生新的虚拟表。这些派生表就像虚拟SQL 视图:它们不存储在数据库中,但你可以像查询包含存储事实的表一样查询它们。
在 [示例 3-12](/ch3#fig_datalog_query) 中,我们定义了三个派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虚拟表的名称和列由每个规则的 `:-` 符号之前出现的内容定义。例如,`migrated(PName, BornIn, LivingIn)` 是一个具有三列的虚拟表:一个人的姓名、他们出生地的名称和他们居住地的名称。
在 [示例 3-12](#fig_datalog_query) 中,我们定义了三个派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虚拟表的名称和列由每个规则的 `:-` 符号之前出现的内容定义。例如,`migrated(PName, BornIn, LivingIn)` 是一个具有三列的虚拟表:一个人的姓名、他们出生地的名称和他们居住地的名称。
虚拟表的内容由规则的 `:-` 符号之后的部分定义,我们在其中尝试查找表中匹配某种模式的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,变量 `PersonID` 绑定到值 `100`,变量 `PName` 绑定到值 `"Lucy"`。如果系统可以为 `:-` 算子右侧的 *所有* 模式找到匹配项,则规则适用。当规则适用时,就好像 `:-` 的左侧被添加到数据库中(变量被它们匹配的值替换)。
因此,应用规则的一种可能方式是(如 [图 3-7](/ch3#fig_datalog_naive) 所示):
因此,应用规则的一种可能方式是(如 [图 3-7](#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")`
@ -793,11 +795,11 @@ Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小
{{< 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](#fig_datalog_query) 中的 Datalog 规则确定爱达荷州在北美。
现在规则 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](#fig_datalog_query) 中的规则 2这使得 Datalog 查询中的图遍历成为可能。
### GraphQL {#id63}
@ -805,7 +807,7 @@ GraphQL 是一种查询语言,从设计上讲,它比我们在本章中看到
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](#fig_graphql_query) 显示了如何使用 GraphQL 实现 Discord 或 Slack 等群聊应用程序。查询请求用户有权访问的所有频道,包括频道名称和每个频道中的 50 条最新消息。对于每条消息,它请求时间戳、消息内容以及消息发送者的姓名和个人资料图片 URL。此外如果消息是对另一条消息的回复查询还会请求发送者姓名和它所回复的消息内容可能以较小的字体呈现在回复上方以提供一些上下文
{{< figure id="fig_graphql_query" title="示例 3-13. 群聊应用程序的示例 GraphQL 查询" class="w-full my-4" >}}
@ -831,7 +833,7 @@ query ChatApp {
}
```
[示例 3-14](/ch3#fig_graphql_response) 显示了对 [示例 3-13](/ch3#fig_graphql_query) 中查询的响应可能是什么样子。响应是一个反映查询结构的 JSON 文档:它正好包含请求的那些属性,不多也不少。这种方法的优点是服务器不需要知道客户端需要哪些属性来渲染用户界面;相反,客户端可以简单地请求它需要的内容。例如,此查询不会为 `replyTo` 消息的发送者请求个人资料图片 URL但如果用户界面更改为添加该个人资料图片客户端可以很容易地将所需的 `imageUrl` 属性添加到查询中,而无需更改服务器。
[示例 3-14](#fig_graphql_response) 显示了对 [示例 3-13](#fig_graphql_query) 中查询的响应可能是什么样子。响应是一个反映查询结构的 JSON 文档:它正好包含请求的那些属性,不多也不少。这种方法的优点是服务器不需要知道客户端需要哪些属性来渲染用户界面;相反,客户端可以简单地请求它需要的内容。例如,此查询不会为 `replyTo` 消息的发送者请求个人资料图片 URL但如果用户界面更改为添加该个人资料图片客户端可以很容易地将所需的 `imageUrl` 属性添加到查询中,而无需更改服务器。
{{< figure link="#fig_graphql_query" id="fig_graphql_response" title="示例 3-14. 对 示例 3-13 中查询的可能响应" class="w-full my-4" >}}
@ -860,9 +862,9 @@ query ChatApp {
...
```
在 [示例 3-14](/ch3#fig_graphql_response) 中,消息发送者的姓名和图像 URL 直接嵌入在消息对象中。如果同一用户发送多条消息,此信息会在每条消息上重复。原则上,可以减少这种重复,但 GraphQL 做出了接受更大响应大小的设计选择,以便更简单地基于数据渲染用户界面。
在 [示例 3-14](#fig_graphql_response) 中,消息发送者的姓名和图像 URL 直接嵌入在消息对象中。如果同一用户发送多条消息,此信息会在每条消息上重复。原则上,可以减少这种重复,但 GraphQL 做出了接受更大响应大小的设计选择,以便更简单地基于数据渲染用户界面。
`replyTo` 字段类似:在 [示例 3-14](/ch3#fig_graphql_response) 中,第二条消息是对第一条消息的回复,内容("Hey!…")和发送者 Aaliyah 在 `replyTo` 下重复。可以改为返回被回复消息的 ID但如果该 ID 不在返回的 50 条最新消息中,客户端就必须向服务器发出额外的请求。重复内容使得处理数据变得更加简单。
`replyTo` 字段类似:在 [示例 3-14](#fig_graphql_response) 中,第二条消息是对第一条消息的回复,内容("Hey!…")和发送者 Aaliyah 在 `replyTo` 下重复。可以改为返回被回复消息的 ID但如果该 ID 不在返回的 50 条最新消息中,客户端就必须向服务器发出额外的请求。重复内容使得处理数据变得更加简单。
服务器的数据库可以以更规范化的形式存储数据,并执行必要的连接来处理查询。例如,服务器可能存储消息以及发送者的用户 ID 和它所回复的消息的 ID当它收到如上所示的查询时服务器将解析这些 ID 以查找它们引用的记录。但是,客户端只能要求服务器执行 GraphQL 模式中明确提供的连接。
@ -877,11 +879,11 @@ query ChatApp {
也许写入数据的最简单、最快速和最具表现力的方式是 *事件日志*:每次你想写入一些数据时,你将其编码为自包含的字符串(可能是 JSON包括时间戳然后将其追加到事件序列中。此日志中的事件是 *不可变的*:你永远不会更改或删除它们,你只会向日志追加更多事件(这可能会取代早期事件)。事件可以包含任意属性。
[图 3-8](/ch3#fig_event_sourcing) 显示了一个可能来自会议管理系统的示例。会议可能是一个复杂的业务领域:不仅个人参与者可以注册并用信用卡付款,公司也可以批量订购座位,通过发票付款,然后再将座位分配给个人。一些座位可能为演讲者、赞助商、志愿者助手等保留。预订也可能被取消,与此同时,会议组织者可能通过将其移至不同的房间来更改活动的容量。在所有这些情况发生时,简单地计算可用座位数量就成为一个具有挑战性的查询。
[图 3-8](#fig_event_sourcing) 显示了一个可能来自会议管理系统的示例。会议可能是一个复杂的业务领域:不仅个人参与者可以注册并用信用卡付款,公司也可以批量订购座位,通过发票付款,然后再将座位分配给个人。一些座位可能为演讲者、赞助商、志愿者助手等保留。预订也可能被取消,与此同时,会议组织者可能通过将其移至不同的房间来更改活动的容量。在所有这些情况发生时,简单地计算可用座位数量就成为一个具有挑战性的查询。
{{< 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](#fig_event_sourcing) 中,会议状态的每个变化(例如组织者开放注册,或参与者进行和取消注册)首先被存储为事件。每当事件追加到日志时,几个 *物化视图*(也称为 *投影**读模型*)也会更新以反映该事件的影响。在会议示例中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为打印参与者徽章的打印机生成文件。
使用事件作为真相来源(权威数据源),并将每个状态变化表达为事件的想法被称为 *事件溯源* [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则称为 *命令查询责任分离CQRS* [^64]。这些术语起源于领域驱动设计DDD社区尽管类似的想法已经存在很长时间了例如 *状态机复制*(参见 ["使用共享日志"](/ch10#sec_consistency_smr))。
@ -889,7 +891,7 @@ query ChatApp {
在以事件溯源风格建模数据时,建议你使用过去时态命名事件(例如,"座位已预订"),因为事件是记录过去发生的事情的记录。即使用户后来决定更改或取消,他们以前持有预订的事实仍然是真实的,更改或取消是稍后添加的单独事件。
事件溯源与星型模式事实表之间的相似之处(如 ["星型与雪花型:分析模式"](/ch3#sec_datamodels_analytics) 中所讨论的)是两者都是过去发生的事件的集合。然而,事实表中的行都具有相同的列集,而在事件溯源中可能有许多不同的事件类型,每种都有不同的属性。此外,事实表是无序集合,而在事件溯源中事件的顺序很重要:如果先进行预订然后取消,以错误的顺序处理这些事件将没有意义。
事件溯源与星型模式事实表之间的相似之处(如 ["星型与雪花型:分析模式"](#sec_datamodels_analytics) 中所讨论的)是两者都是过去发生的事件的集合。然而,事实表中的行都具有相同的列集,而在事件溯源中可能有许多不同的事件类型,每种都有不同的属性。此外,事实表是无序集合,而在事件溯源中事件的顺序很重要:如果先进行预订然后取消,以错误的顺序处理这些事件将没有意义。
事件溯源和 CQRS 有几个优点:
@ -923,16 +925,16 @@ query ChatApp {
数据框 API 还提供了远远超出关系数据库提供的各种操作,数据模型的使用方式通常与典型的关系数据建模非常不同 [^65]。例如,数据框的常见用途是将数据从类似关系的表示转换为矩阵或多维数组表示,这是许多机器学习算法期望的输入形式。
[图 3-9](/ch3#fig_dataframe_to_matrix) 显示了这种转换的简单示例。左侧是不同用户如何评价各种电影的关系表(评分为 1 到 5右侧数据已转换为矩阵其中每列是一部电影每行是一个用户类似于电子表格中的 *数据透视表*)。矩阵是 *稀疏* 的,这意味着许多用户-电影组合没有数据,但这没关系。这个矩阵可能有数千列,因此不太适合关系数据库,但数据框和提供稀疏数组的库(如 Python 的 NumPy可以轻松处理此类数据。
[图 3-9](#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) 的示例矩阵中省略了)可以缩放为某个合适范围内的浮点数。
* 日期(在 [图 3-9](#fig_dataframe_to_matrix) 的示例矩阵中省略了)可以缩放为某个合适范围内的浮点数。
* 对于只能取一小组固定值之一的列(例如,电影数据库中电影的类型),通常使用 *独热编码*:我们为每个可能的值创建一列(一个用于"喜剧",一个用于"剧情",一个用于"恐怖"等),对于代表电影的每一行,我们在对应于该电影类型的列中放置 1在所有其他列中放置 0。这种表示也很容易推广到适合多种类型的电影。
一旦数据以数字矩阵的形式存在,它就适合线性代数运算,这构成了许多机器学习算法的基础。例如,[图 3-9](/ch3#fig_dataframe_to_matrix) 中的数据可能是推荐用户可能喜欢的电影系统的一部分。数据框足够灵活,允许数据从关系形式逐渐演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示。
一旦数据以数字矩阵的形式存在,它就适合线性代数运算,这构成了许多机器学习算法的基础。例如,[图 3-9](#fig_dataframe_to_matrix) 中的数据可能是推荐用户可能喜欢的电影系统的一部分。数据框足够灵活,允许数据从关系形式逐渐演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示。
还有像 TileDB [^66] 这样专门存储大型多维数字数组的数据库;它们被称为 *数组数据库*,最常用于科学数据集,如地理空间测量(规则间隔网格上的栅格数据)、医学成像或天文望远镜的观测 [^67]。数据框在金融行业也用于表示 *时间序列数据*,如资产价格和随时间变化的交易 [^68]。

View file

@ -21,7 +21,7 @@ breadcrumbs: false
特别是针对事务型工作负载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}
@ -87,7 +87,7 @@ $ cat database
### 日志结构存储 {#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" >}}
@ -102,15 +102,15 @@ $ cat database
#### SSTable 文件格式 {#the-sstable-file-format}
实际上,哈希表很少用于数据库索引,相反,保持数据 *按键排序* 的结构更为常见 [^3]。这种结构的一个例子是 *排序字符串表**Sorted String Table*),简称 *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 树、字典树或其他允许查询快速查找特定键的数据结构 [^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}
@ -123,7 +123,7 @@ 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" >}}
@ -147,11 +147,11 @@ 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" >}}
当我们想知道一个键是否出现在 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。这种看起来键存在但实际上不存在的情况称为 *假阳性**false positive*)。
@ -174,7 +174,7 @@ SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更
作为经验法则,如果你主要有写入而读取很少,分层压实表现更好,而如果你的工作负载以读取为主,分级压实表现更好。如果你频繁写入少量键,而很少写入大量键,那么分级压实也可能有优势 [^18]。
尽管有许多细微之处,但 LSM 树的基本思想 —— 保持在后台合并的 SSTable 级联 —— 简单而有效。我们将在 ["比较 B 树与 LSM 树"](/ch4#sec_storage_btree_lsm_comparison) 中更详细地讨论它们的性能特征。
尽管有许多细微之处,但 LSM 树的基本思想 —— 保持在后台合并的 SSTable 级联 —— 简单而有效。我们将在 ["比较 B 树与 LSM 树"](#sec_storage_btree_lsm_comparison) 中更详细地讨论它们的性能特征。
--------
@ -200,21 +200,21 @@ B 树于 1970 年引入 [^21],不到 10 年后就被称为"无处不在"[^22]
我们之前看到的日志结构索引将数据库分解为可变大小的 *段*通常为几兆字节或更大写入一次后就不可变。相比之下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。从根页开始我们首先跟随引用到键 200300 的页,然后是键 250270 的页。" class="w-full my-4" >}}
一个页被指定为 B 树的 *根*;每当你想在索引中查找一个键时,你就从这里开始。该页包含几个键和对子页的引用。每个子页负责一个连续的键范围,引用之间的键指示这些范围之间的边界在哪里。(这种结构有时称为 B+ 树,但我们不需要将其与其他 B 树变体区分开来。)
在 [图 4-5](/ch4#fig_storage_b_tree) 的例子中,我们正在查找键 251所以我们知道我们需要跟随边界 200 和 300 之间的页引用。这将我们带到一个看起来相似的页,该页进一步将 200300 范围分解为子范围。最终我们到达包含单个键的页(*叶页*),该页要么内联包含每个键的值,要么包含对可以找到值的页的引用。
在 [图 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 树中现有键的值,你搜索包含该键的叶页,并用包含新值的版本覆盖磁盘上的该页。如果你想添加一个新键,你需要找到其范围包含新键的页并将其添加到该页。如果页中没有足够的空闲空间来容纳新键,则页被分成两个半满的页,并更新父页以说明键范围的新细分。
{{< 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但范围 333345 的页已经满了。因此,我们将其分成范围 333337包括新键的页和 337344 的页。我们还必须更新父页以引用两个子页,它们之间的边界值为 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。
@ -255,7 +255,7 @@ B 树的基本底层写操作是用新数据覆盖磁盘上的页。假设覆盖
使用 B 树时,如果应用程序写入的键分散在整个键空间中,生成的磁盘操作也会随机分散,因为存储引擎需要覆盖的页可能位于磁盘的任何位置。另一方面,日志结构存储引擎一次写入整个段文件(无论是写出内存表还是压实现有段),这比 B 树中的页大得多。
许多小的、分散的写入模式(如 B 树中的)称为 *随机写入*,而较少的大写入模式(如 LSM 树中的)称为 *顺序写入*。磁盘通常具有比随机写入更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘HDD上特别大在今天大多数数据库使用的固态硬盘SSD差异较小但仍然明显参见 ["SSD 上的顺序与随机写入"](/ch4#sidebar_sequential))。
许多小的、分散的写入模式(如 B 树中的)称为 *随机写入*,而较少的大写入模式(如 LSM 树中的)称为 *顺序写入*。磁盘通常具有比随机写入更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘HDD上特别大在今天大多数数据库使用的固态硬盘SSD差异较小但仍然明显参见 ["SSD 上的顺序与随机写入"](#sidebar_sequential))。
--------
@ -289,7 +289,7 @@ B 树索引必须至少写入每条数据两次:一次写入预写日志,一
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]。
@ -312,7 +312,7 @@ B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了
* 或者值可以是对实际数据的引用要么是相关行的主键InnoDB 对二级索引这样做),要么是对磁盘上位置的直接引用。在后一种情况下,存储行的地方称为 *堆文件*它以无特定顺序存储数据它可能是仅追加的或者它可能跟踪已删除的行以便稍后用新数据覆盖它们。例如Postgres 使用堆文件方法 [^44]。
* 两者之间的折中是 *覆盖索引**包含列的索引*,它在索引中存储表的 *某些* 列,除了在堆上或主键聚簇索引中存储完整行 [^45]。这允许仅使用索引来回答某些查询,而无需解析主键或查看堆文件(在这种情况下,索引被称为 *覆盖* 查询)。这可以使某些查询更快,但数据的重复意味着索引使用更多的磁盘空间并减慢写入速度。
到目前为止讨论的索引只将单个键映射到值。如果你需要同时查询表的多个列(或文档中的多个字段),请参见 ["多维索引与全文索引"](/ch4#sec_storage_multidimensional)。
到目前为止讨论的索引只将单个键映射到值。如果你需要同时查询表的多个列(或文档中的多个字段),请参见 ["多维索引与全文索引"](#sec_storage_multidimensional)。
当更新值而不更改键时,堆文件方法可以允许记录就地覆盖,前提是新值不大于旧值。如果新值更大,情况会更复杂,因为它可能需要移动到堆中有足够空间的新位置。在这种情况下,要么所有索引都需要更新以指向记录的新堆位置,要么在旧堆位置留下转发指针 [^2]。
@ -367,7 +367,7 @@ Apache Hive、Trino 和 Apache Spark 等开源数据仓库也随着云的发展
如 ["星型和雪花型:分析模式"](/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" >}}
@ -387,11 +387,11 @@ GROUP BY
我们如何高效地执行这个查询?
在大多数 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) 中事实表的扩展版本展示了这一原理。
--------
@ -412,13 +412,13 @@ GROUP BY
除了只从磁盘加载查询所需的那些列之外,我们还可以通过压缩数据进一步减少对磁盘吞吐量和网络带宽的需求。幸运的是,面向列的存储通常非常适合压缩。
看看 [图 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。
一种选择是使用每行一位来存储这些位图。然而,这些位图通常包含大量零(我们说它们是 *稀疏* 的)。在这种情况下,位图可以另外进行游程编码:计算连续零或一的数量并存储该数字,如 [图 4-8](/ch4#fig_bitmap_index) 底部所示。诸如 *咆哮位图**roaring bitmaps*)之类的技术在两种位图表示之间切换,使用最紧凑的表示 [^73]。这可以使列的编码非常高效。
一种选择是使用每行一位来存储这些位图。然而,这些位图通常包含大量零(我们说它们是 *稀疏* 的)。在这种情况下,位图可以另外进行游程编码:计算连续零或一的数量并存储该数字,如 [图 4-8](#fig_bitmap_index) 底部所示。诸如 *咆哮位图**roaring bitmaps*)之类的技术在两种位图表示之间切换,使用最紧凑的表示 [^73]。这可以使列的编码非常高效。
像这样的位图索引非常适合数据仓库中常见的查询类型。例如:
@ -445,9 +445,9 @@ GROUP BY
相反,数据需要一次排序整行,即使它是按列存储的。数据库管理员可以使用他们对常见查询的了解来选择表应按哪些列排序。例如,如果查询经常针对日期范围(例如上个月),则将 `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) 中用于位图的那样,可以将该列压缩到几千字节 —— 即使表有数十亿行。
该压缩效果在第一个排序键上最强。第二和第三个排序键将更加混乱,因此不会有如此长的重复值运行。排序优先级较低的列基本上以随机顺序出现,因此它们可能不会压缩得那么好。但是,让前几列排序仍然是整体上的胜利。
@ -476,7 +476,7 @@ GROUP BY
向量化处理
: 查询被解释,而不是编译,但通过批量处理列中的许多值而不是逐行迭代来提高速度。一组固定的预定义算子内置在数据库中;我们可以向它们传递参数并获得一批结果 [^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" >}}
@ -493,11 +493,11 @@ GROUP BY
当基础数据更改时,物化视图需要相应更新。一些数据库可以自动执行此操作,还有像 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" >}}
现在假设每个事实只有两个维度表的外键 —— 在 [图 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) 中有五个维度:日期、产品、商店、促销和客户。很难想象五维超立方体会是什么样子,但原理保持不变:每个单元格包含特定日期-产品-商店-促销-客户组合的销售。然后可以沿着每个维度重复汇总这些值。
@ -531,9 +531,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
然而,在其核心,你可以将全文检索视为另一种多维查询:在这种情况下,可能出现在文本中的每个单词(*词项*)是一个维度。包含词项 *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]。
@ -551,7 +551,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
--------
> [!NOTE]
> 我们在 ["查询执行:编译与向量化"](/ch4#sec_storage_vectorized) 中看到了术语 *向量化处理*。语义搜索中的向量有不同的含义。在向量化处理中,向量指的是可以用特别优化的代码处理的一批位。在嵌入模型中,向量是表示多维空间中位置的浮点数列表。
> 我们在 ["查询执行:编译与向量化"](#sec_storage_vectorized) 中看到了术语 *向量化处理*。语义搜索中的向量有不同的含义。在向量化处理中,向量指的是可以用特别优化的代码处理的一批位。在嵌入模型中,向量是表示多维空间中位置的浮点数列表。
--------
@ -572,7 +572,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
: 向量空间被聚类为向量的分区(称为 *质心*以减少必须比较的向量数量。IVF 索引比平面索引更快,但只能给出近似结果:即使查询和文档彼此接近,它们也可能落入不同的分区。对 IVF 索引的查询首先定义 *探针*,这只是要检查的分区数。使用更多探针的查询将更准确,但会更慢,因为必须比较更多向量。
分层可导航小世界HNSW
: HNSW 索引维护向量空间的多个层,如 [图 4-11](/ch4#fig_vector_hnsw) 所示。每一层都表示为一个图,其中节点表示向量,边表示与附近向量的接近度。查询首先在最顶层定位最近的向量,该层具有少量节点。然后查询移动到下面一层的同一节点,并跟随该层中的边,该层连接更密集,寻找更接近查询向量的向量。该过程继续直到到达最后一层。与 IVF 索引一样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" >}}

View file

@ -34,7 +34,7 @@ breadcrumbs: false
向后兼容性通常不难实现:作为新代码的作者,你知道旧代码写入的数据格式,因此可以显式地处理它(如有必要,只需保留旧代码来读取旧数据)。向前兼容性可能更棘手,因为它需要旧代码忽略新版本代码添加的部分。
向前兼容性的另一个挑战如 [图 5-1](/ch5#fig_encoding_preserve_field) 所示。假设你向记录模式添加了一个字段,新代码创建了包含该新字段的记录并将其存储在数据库中。随后,旧版本的代码(尚不知道新字段)读取记录,更新它,然后写回。在这种情况下,理想的行为通常是旧代码保持新字段不变,即使它无法解释。但是,如果记录被解码为不显式保留未知字段的模型对象,数据可能会丢失,如 [图 5-1](/ch5#fig_encoding_preserve_field) 所示。
向前兼容性的另一个挑战如 [图 5-1](#fig_encoding_preserve_field) 所示。假设你向记录模式添加了一个字段,新代码创建了包含该新字段的记录并将其存储在数据库中。随后,旧版本的代码(尚不知道新字段)读取记录,更新它,然后写回。在这种情况下,理想的行为通常是旧代码保持新字段不变,即使它无法解释。但是,如果记录被解码为不显式保留未知字段的模型对象,数据可能会丢失,如 [图 5-1](#fig_encoding_preserve_field) 所示。
{{< figure src="/fig/ddia_0501.png" id="fig_encoding_preserve_field" caption="图 5-1. 当旧版本的应用程序更新之前由新版本应用程序写入的数据时,如果不小心,数据可能会丢失。" class="w-full my-4" >}}
@ -91,13 +91,13 @@ JSON、XML 和 CSV 是文本格式,因此在某种程度上是人类可读的
#### JSON 模式 {#json-schema}
JSON 模式已被广泛采用,作为系统间交换或写入存储时对数据建模的一种方式。你会在 Web 服务中找到 JSON 模式(参见 ["Web 服务"](/ch5#sec_web_services))作为 OpenAPI Web 服务规范的一部分,在模式注册表中如 Confluent 的 Schema Registry 和 Red Hat 的 Apicurio Registry以及在数据库中如 PostgreSQL 的 pg_jsonschema 验证器扩展和 MongoDB 的 `$jsonSchema` 验证器语法。
JSON 模式已被广泛采用,作为系统间交换或写入存储时对数据建模的一种方式。你会在 Web 服务中找到 JSON 模式(参见 ["Web 服务"](#sec_web_services))作为 OpenAPI Web 服务规范的一部分,在模式注册表中如 Confluent 的 Schema Registry 和 Red Hat 的 Apicurio Registry以及在数据库中如 PostgreSQL 的 pg_jsonschema 验证器扩展和 MongoDB 的 `$jsonSchema` 验证器语法。
JSON 模式规范提供了许多功能。模式包括标准原始类型,包括字符串、数字、整数、对象、数组、布尔值或空值。但 JSON 模式还提供了一个单独的验证规范,允许开发人员在字段上叠加约束。例如,`port` 字段可能具有最小值 1 和最大值 65535。
JSON 模式可以具有开放或封闭的内容模型。开放内容模型允许模式中未定义的任何字段以任何数据类型存在而封闭内容模型只允许显式定义的字段。JSON 模式中的开放内容模型在 `additionalProperties` 设置为 `true` 时启用这是默认值。因此JSON 模式通常是对 *不允许* 内容的定义(即,任何已定义字段上的无效值),而不是对模式中 *允许* 内容的定义。
开放内容模型功能强大,但可能很复杂。例如,假设你想定义一个从整数(如 ID到字符串的映射。JSON 没有映射或字典类型,只有一个可以包含字符串键和任何类型值的"对象"类型。然后,你可以使用 JSON 模式约束此类型,使键只能包含数字,值只能是字符串,使用 `patternProperties``additionalProperties`,如 [示例 5-1](/ch5#fig_encoding_json_schema) 所示。
开放内容模型功能强大,但可能很复杂。例如,假设你想定义一个从整数(如 ID到字符串的映射。JSON 没有映射或字典类型,只有一个可以包含字符串键和任何类型值的"对象"类型。然后,你可以使用 JSON 模式约束此类型,使键只能包含数字,值只能是字符串,使用 `patternProperties``additionalProperties`,如 [示例 5-1](#fig_encoding_json_schema) 所示。
{{< figure id="fig_encoding_json_schema" title="示例 5-1. 具有整数键和字符串值的示例 JSON 模式。整数键表示为仅包含整数的字符串,因为 JSON 模式要求所有键都是字符串。" class="w-full my-4" >}}
@ -121,7 +121,7 @@ JSON 模式可以具有开放或封闭的内容模型。开放内容模型允许
JSON 比 XML 更简洁,但与二进制格式相比,两者仍然使用大量空间。这一观察导致了大量 JSON 二进制编码MessagePack、CBOR、BSON、BJSON、UBJSON、BISON、Hessian 和 Smile 等等)和 XML 二进制编码(例如 WBXML 和 Fast Infoset的发展。这些格式已在各种利基市场中被采用因为它们更紧凑有时解析速度更快但它们都没有像 JSON 和 XML 的文本版本那样被广泛采用 [^12]。
其中一些格式扩展了数据类型集(例如,区分整数和浮点数,或添加对二进制字符串的支持),但除此之外,它们保持 JSON/XML 数据模型不变。特别是,由于它们不规定模式,因此需要在编码数据中包含所有对象字段名称。也就是说,在 [示例 5-2](/ch5#fig_encoding_json) 中的 JSON 文档的二进制编码中,它们需要在某处包含字符串 `userName`、`favoriteNumber` 和 `interests`
其中一些格式扩展了数据类型集(例如,区分整数和浮点数,或添加对二进制字符串的支持),但除此之外,它们保持 JSON/XML 数据模型不变。特别是,由于它们不规定模式,因此需要在编码数据中包含所有对象字段名称。也就是说,在 [示例 5-2](#fig_encoding_json) 中的 JSON 文档的二进制编码中,它们需要在某处包含字符串 `userName`、`favoriteNumber` 和 `interests`
{{< figure id="fig_encoding_json" title="示例 5-2. 本章中我们将以几种二进制格式编码的示例记录" class="w-full my-4" >}}
@ -133,7 +133,7 @@ JSON 比 XML 更简洁,但与二进制格式相比,两者仍然使用大量
}
```
让我们看一个 MessagePack 的例子,它是 JSON 的二进制编码。[图 5-2](/ch5#fig_encoding_messagepack) 显示了如果你使用 MessagePack 编码 [示例 5-2](/ch5#fig_encoding_json) 中的 JSON 文档所得到的字节序列。前几个字节如下:
让我们看一个 MessagePack 的例子,它是 JSON 的二进制编码。[图 5-2](#fig_encoding_messagepack) 显示了如果你使用 MessagePack 编码 [示例 5-2](#fig_encoding_json) 中的 JSON 文档所得到的字节序列。前几个字节如下:
1. 第一个字节 `0x83` 表示接下来是一个对象(前四位 = `0x80`),有三个字段(后四位 = `0x03`)。(如果你想知道如果对象有超过 15 个字段会发生什么,以至于字段数无法装入四位,那么它会获得不同的类型指示符,字段数会以两个或四个字节编码。)
2. 第二个字节 `0xa8` 表示接下来是一个字符串(前四位 = `0xa0`),长度为八个字节(后四位 = `0x08`)。
@ -151,7 +151,7 @@ JSON 比 XML 更简洁,但与二进制格式相比,两者仍然使用大量
Protocol Buffers (protobuf) 是 Google 开发的二进制编码库。它类似于 Apache Thrift后者最初由 Facebook 开发 [^13];本节关于 Protocol Buffers 的大部分内容也适用于 Thrift。
Protocol Buffers 需要为任何编码的数据提供模式。要在 Protocol Buffers 中编码 [示例 5-2](/ch5#fig_encoding_json) 中的数据,你需要像这样在 Protocol Buffers 接口定义语言IDL中描述模式
Protocol Buffers 需要为任何编码的数据提供模式。要在 Protocol Buffers 中编码 [示例 5-2](#fig_encoding_json) 中的数据,你需要像这样在 Protocol Buffers 接口定义语言IDL中描述模式
```protobuf
syntax = "proto3";
@ -163,14 +163,14 @@ message Person {
}
```
Protocol Buffers 附带了一个代码生成工具,它接受像这里显示的模式定义,并生成以各种编程语言实现该模式的类。你的应用程序代码可以调用此生成的代码来编码或解码模式的记录。使用 Protocol Buffers 编码器编码 [示例 5-2](/ch5#fig_encoding_json) 需要 33 字节,如 [图 5-3](/ch5#fig_encoding_protobuf) 所示 [^14]。
Protocol Buffers 附带了一个代码生成工具,它接受像这里显示的模式定义,并生成以各种编程语言实现该模式的类。你的应用程序代码可以调用此生成的代码来编码或解码模式的记录。使用 Protocol Buffers 编码器编码 [示例 5-2](#fig_encoding_json) 需要 33 字节,如 [图 5-3](#fig_encoding_protobuf) 所示 [^14]。
{{< figure src="/fig/ddia_0503.png" id="fig_encoding_protobuf" caption="图 5-3. 使用 Protocol Buffers 编码的示例记录。" class="w-full my-4" >}}
与 [图 5-2](/ch5#fig_encoding_messagepack) 类似,每个字段都有一个类型注释(指示它是字符串、整数等)以及必要时的长度指示(例如字符串的长度)。数据中出现的字符串("Martin"、"daydreaming"、"hacking")也编码为 ASCII准确地说是 UTF-8与之前类似。
与 [图 5-2](#fig_encoding_messagepack) 类似,每个字段都有一个类型注释(指示它是字符串、整数等)以及必要时的长度指示(例如字符串的长度)。数据中出现的字符串("Martin"、"daydreaming"、"hacking")也编码为 ASCII准确地说是 UTF-8与之前类似。
与 [图 5-2](/ch5#fig_encoding_messagepack) 相比的最大区别是没有字段名(`userName`、`favoriteNumber`、`interests`)。相反,编码数据包含 *字段标签*,即数字(`1`、`2` 和 `3`)。这些是模式定义中出现的数字。字段标签就像字段的别名——它们是说明我们正在谈论哪个字段的紧凑方式,而无需拼写字段名。
与 [图 5-2](#fig_encoding_messagepack) 相比的最大区别是没有字段名(`userName`、`favoriteNumber`、`interests`)。相反,编码数据包含 *字段标签*,即数字(`1`、`2` 和 `3`)。这些是模式定义中出现的数字。字段标签就像字段的别名——它们是说明我们正在谈论哪个字段的紧凑方式,而无需拼写字段名。
如你所见Protocol Buffers 通过将字段类型和标签号打包到单个字节中来节省更多空间。它使用可变长度整数:数字 1337 编码为两个字节,每个字节的最高位用于指示是否还有更多字节要来。这意味着 -64 到 63 之间的数字以一个字节编码,-8192 到 8191 之间的数字以两个字节编码,等等。更大的数字使用更多字节。
@ -182,7 +182,7 @@ Protocol Buffers 没有显式的列表或数组数据类型。相反,`interest
从示例中可以看出,编码记录只是其编码字段的串联。每个字段由其标签号(示例模式中的数字 `1`、`2`、`3`)标识,并带有数据类型注释(例如字符串或整数)。如果未设置字段值,则它会从编码记录中省略。由此可以看出,字段标签对编码数据的含义至关重要。你可以更改模式中字段的名称,因为编码数据从不引用字段名,但你不能更改字段的标签,因为这会使所有现有的编码数据无效。
你可以向模式添加新字段,前提是你为每个字段提供新的标签号。如果旧代码(不知道你添加的新标签号)尝试读取由新代码写入的数据(包括具有它不识别的标签号的新字段),它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过多少字节,并保留未知字段以避免 [图 5-1](/ch5#fig_encoding_preserve_field) 中的问题。这保持了向前兼容性:旧代码可以读取由新代码编写的记录。
你可以向模式添加新字段,前提是你为每个字段提供新的标签号。如果旧代码(不知道你添加的新标签号)尝试读取由新代码写入的数据(包括具有它不识别的标签号的新字段),它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过多少字节,并保留未知字段以避免 [图 5-1](#fig_encoding_preserve_field) 中的问题。这保持了向前兼容性:旧代码可以读取由新代码编写的记录。
向后兼容性呢?只要每个字段都有唯一的标签号,新代码总是可以读取旧数据,因为标签号仍然具有相同的含义。如果在新模式中添加了字段,而你读取尚未包含该字段的旧数据,则它将填充默认值(例如,如果字段类型为字符串,则为空字符串;如果是数字,则为零)。
@ -220,7 +220,7 @@ record Person {
}
```
首先,请注意模式中没有标签号。如果我们使用此模式编码示例记录([示例 5-2](/ch5#fig_encoding_json)Avro 二进制编码只有 32 字节长——是我们看到的所有编码中最紧凑的。编码字节序列的分解如 [图 5-4](/ch5#fig_encoding_avro) 所示。
首先,请注意模式中没有标签号。如果我们使用此模式编码示例记录([示例 5-2](#fig_encoding_json)Avro 二进制编码只有 32 字节长——是我们看到的所有编码中最紧凑的。编码字节序列的分解如 [图 5-4](#fig_encoding_avro) 所示。
如果你检查字节序列,你会发现没有任何东西来标识字段或其数据类型。编码只是由串联在一起的值组成。字符串只是一个长度前缀,后跟 UTF-8 字节,但编码数据中没有任何内容告诉你它是字符串。它也可能是整数,或完全是其他东西。整数使用可变长度编码进行编码。
@ -235,11 +235,11 @@ record Person {
当应用程序想要编码一些数据(将其写入文件或数据库,通过网络发送等)时,它使用它知道的任何版本的模式对数据进行编码——例如,该模式可能被编译到应用程序中。这被称为 *写入者模式*
当应用程序想要解码一些数据(从文件或数据库读取,从网络接收等)时,它使用两个模式:与用于编码相同的写入者模式,以及 *读取者模式*,后者可能不同。这在 [图 5-5](/ch5#fig_encoding_avro_schemas) 中说明。读取者模式定义了应用程序代码期望的每条记录的字段及其类型。
当应用程序想要解码一些数据(从文件或数据库读取,从网络接收等)时,它使用两个模式:与用于编码相同的写入者模式,以及 *读取者模式*,后者可能不同。这在 [图 5-5](#fig_encoding_avro_schemas) 中说明。读取者模式定义了应用程序代码期望的每条记录的字段及其类型。
{{< figure src="/fig/ddia_0505.png" id="fig_encoding_avro_schemas" caption="图 5-5. 在 Protocol Buffers 中,编码和解码可以使用不同版本的模式。在 Avro 中,解码使用两个模式:写入者模式必须与用于编码的模式相同,但读取者模式可以是较旧或较新的版本。" class="w-full my-4" >}}
如果读取者模式和写入者模式相同解码很容易。如果它们不同Avro 通过并排查看写入者模式和读取者模式并将数据从写入者模式转换为读取者模式来解决差异。Avro 规范 [^16] [^17] 准确定义了此解析的工作方式,并在 [图 5-6](/ch5#fig_encoding_avro_resolution) 中进行了说明。
如果读取者模式和写入者模式相同解码很容易。如果它们不同Avro 通过并排查看写入者模式和读取者模式并将数据从写入者模式转换为读取者模式来解决差异。Avro 规范 [^16] [^17] 准确定义了此解析的工作方式,并在 [图 5-6](#fig_encoding_avro_resolution) 中进行了说明。
例如,如果写入者模式和读取者模式的字段顺序不同,这没有问题,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在写入者模式中但不在读取者模式中的字段,它将被忽略。如果读取数据的代码期望某个字段,但写入者模式不包含该名称的字段,则使用读取者模式中声明的默认值填充它。
@ -272,7 +272,7 @@ record Person {
例如Apache Kafka 的 Confluent 模式注册表 [^19] 和 LinkedIn 的 Espresso [^20] 就是这样工作的。
通过网络连接发送记录
: 当两个进程通过双向网络连接进行通信时它们可以在连接设置时协商模式版本然后在连接的生命周期内使用该模式。Avro RPC 协议(参见 ["流经服务的数据流REST 与 RPC"](/ch5#sec_encoding_dataflow_rpc))就是这样工作的。
: 当两个进程通过双向网络连接进行通信时它们可以在连接设置时协商模式版本然后在连接的生命周期内使用该模式。Avro RPC 协议(参见 ["流经服务的数据流REST 与 RPC"](#sec_encoding_dataflow_rpc))就是这样工作的。
无论如何,模式版本数据库都是有用的,因为它充当文档并让你有机会检查模式兼容性 [^21]。作为版本号,你可以使用简单的递增整数,或者可以使用模式的哈希值。
@ -311,10 +311,10 @@ record Person {
这是一个相当抽象的想法——数据可以通过许多方式从一个进程流向另一个进程。谁编码数据,谁解码数据?在本章的其余部分,我们将探讨数据在进程之间流动的一些最常见方式:
* 通过数据库(参见 ["流经数据库的数据流"](/ch5#sec_encoding_dataflow_db)
* 通过服务调用(参见 ["流经服务的数据流REST 与 RPC"](/ch5#sec_encoding_dataflow_rpc)
* 通过工作流引擎(参见 ["持久化执行与工作流"](/ch5#sec_encoding_dataflow_workflows)
* 通过异步消息(参见 ["事件驱动的架构"](/ch5#sec_encoding_dataflow_msg)
* 通过数据库(参见 ["流经数据库的数据流"](#sec_encoding_dataflow_db)
* 通过服务调用(参见 ["流经服务的数据流REST 与 RPC"](#sec_encoding_dataflow_rpc)
* 通过工作流引擎(参见 ["持久化执行与工作流"](#sec_encoding_dataflow_workflows)
* 通过异步消息(参见 ["事件驱动的架构"](#sec_encoding_dataflow_msg)
### 流经数据库的数据流 {#sec_encoding_dataflow_db}
@ -368,7 +368,7 @@ Web 浏览器不是唯一类型的客户端。例如,在移动设备和桌面
需要调用 Web 服务 API 的代码必须知道要查询哪个 HTTP 端点,以及发送什么数据格式以及预期的响应。即使服务采用 RESTful 设计原则客户端也需要以某种方式找出这些详细信息。服务开发人员通常使用接口定义语言IDL来定义和记录其服务的 API 端点和数据模型,并随着时间的推移演化它们。然后,其他开发人员可以使用服务定义来确定如何查询服务。两种最流行的服务 IDL 是 OpenAPI也称为 Swagger [^32])和 gRPC。OpenAPI 用于发送和接收 JSON 数据的 Web 服务,而 gRPC 服务发送和接收 Protocol Buffers。
开发人员通常用 JSON 或 YAML 编写 OpenAPI 服务定义;参见 [示例 5-3](/ch5#fig_open_api_def)。服务定义允许开发人员定义服务端点、文档、版本、数据模型等。gRPC 定义看起来类似,但使用 Protocol Buffers 服务定义进行定义。
开发人员通常用 JSON 或 YAML 编写 OpenAPI 服务定义;参见 [示例 5-3](#fig_open_api_def)。服务定义允许开发人员定义服务端点、文档、版本、数据模型等。gRPC 定义看起来类似,但使用 Protocol Buffers 服务定义进行定义。
{{< figure id="fig_open_api_def" title="示例 5-3. YAML 中的示例 OpenAPI 服务定义" class="w-full my-4" >}}
@ -396,9 +396,9 @@ paths:
example: Pong!
```
即使采用了设计理念和 IDL开发人员仍必须编写实现其服务 API 调用的代码。通常采用服务框架来简化这项工作。Spring Boot、FastAPI 和 gRPC 等服务框架允许开发人员为每个 API 端点编写业务逻辑,而框架代码处理路由、指标、缓存、身份验证等。[示例 5-4](/ch5#fig_fastapi_def) 显示了 [示例 5-3](/ch5#fig_open_api_def) 中定义的服务的示例 Python 实现。
即使采用了设计理念和 IDL开发人员仍必须编写实现其服务 API 调用的代码。通常采用服务框架来简化这项工作。Spring Boot、FastAPI 和 gRPC 等服务框架允许开发人员为每个 API 端点编写业务逻辑,而框架代码处理路由、指标、缓存、身份验证等。[示例 5-4](#fig_fastapi_def) 显示了 [示例 5-3](#fig_open_api_def) 中定义的服务的示例 Python 实现。
{{< figure id="fig_fastapi_def" title="示例 5-4. 实现 [示例 5-3](/ch5#fig_open_api_def) 中定义的示例 FastAPI 服务" class="w-full my-4" >}}
{{< figure id="fig_fastapi_def" title="示例 5-4. 实现 [示例 5-3](#fig_open_api_def) 中定义的示例 FastAPI 服务" class="w-full my-4" >}}
```python
from fastapi import FastAPI
@ -428,7 +428,7 @@ Web 服务只是通过网络进行 API 请求的一长串技术的最新化身
* 如果你重试失败的网络请求,可能会发生前一个请求实际上已经成功,只是响应丢失了。在这种情况下,重试将导致操作执行多次,除非你在协议中构建去重机制(*幂等性*[^40]。本地函数调用没有这个问题。(我们在 [“幂等性”](/ch12#sec_stream_idempotence) 中更详细地讨论幂等性。)
* 每次调用本地函数时,通常需要大约相同的时间来执行。网络请求比函数调用慢得多,其延迟也变化很大:在良好的时候,它可能在不到一毫秒内完成,但当网络拥塞或远程服务过载时,执行完全相同的操作可能需要许多秒。
* 当你调用本地函数时,你可以有效地将引用(指针)传递给本地内存中的对象。当你发出网络请求时,所有这些参数都需要编码为可以通过网络发送的字节序列。如果参数是不可变的原语,如数字或短字符串,那没问题,但对于更大量的数据和可变对象,它很快就会出现问题。
* 客户端和服务可能以不同的编程语言实现,因此 RPC 框架必须将数据类型从一种语言转换为另一种语言。这可能会变得很丑陋,因为并非所有语言都具有相同的类型——例如,回想一下 JavaScript 处理大于 2⁵³ 的数字的问题(参见 ["JSON、XML 及其二进制变体"](/ch5#sec_encoding_json))。单一语言编写的单个进程中不存在此问题。
* 客户端和服务可能以不同的编程语言实现,因此 RPC 框架必须将数据类型从一种语言转换为另一种语言。这可能会变得很丑陋,因为并非所有语言都具有相同的类型——例如,回想一下 JavaScript 处理大于 2⁵³ 的数字的问题(参见 ["JSON、XML 及其二进制变体"](#sec_encoding_json))。单一语言编写的单个进程中不存在此问题。
所有这些因素意味着试图让远程服务看起来太像编程语言中的本地对象是没有意义的因为它是根本不同的东西。REST 的部分吸引力在于它将网络上的状态传输视为与函数调用不同的过程。
@ -467,7 +467,7 @@ RPC 方案的向后和向前兼容性属性继承自它使用的任何编码:
根据定义,基于服务的架构具有多个服务,这些服务都负责应用程序的不同部分。考虑一个处理信用卡并将资金存入银行账户的支付处理应用程序。该系统可能有不同的服务负责欺诈检测、信用卡集成、银行集成等。
在我们的示例中,处理单个付款需要许多服务调用。支付处理器服务可能会调用欺诈检测服务以检查欺诈,调用信用卡服务以扣除信用卡费用,并调用银行服务以存入扣除的资金,如 [图 5-7](/ch5#fig_encoding_workflow) 所示。我们将这一系列步骤称为 *工作流*,每个步骤称为 *任务*。工作流通常定义为任务图。工作流定义可以用通用编程语言、领域特定语言 (DSL) 或标记语言(如业务流程执行语言 (BPEL)[^44] 编写。
在我们的示例中,处理单个付款需要许多服务调用。支付处理器服务可能会调用欺诈检测服务以检查欺诈,调用信用卡服务以扣除信用卡费用,并调用银行服务以存入扣除的资金,如 [图 5-7](#fig_encoding_workflow) 所示。我们将这一系列步骤称为 *工作流*,每个步骤称为 *任务*。工作流通常定义为任务图。工作流定义可以用通用编程语言、领域特定语言 (DSL) 或标记语言(如业务流程执行语言 (BPEL)[^44] 编写。
--------
@ -484,15 +484,15 @@ RPC 方案的向后和向前兼容性属性继承自它使用的任何编码:
工作流引擎通常由编排器和执行器组成。编排器负责调度要执行的任务,执行器负责执行任务。当工作流被触发时,执行开始。如果用户定义了基于时间的调度(例如每小时执行),则编排器会自行触发工作流。外部源(如 Web 服务)甚至人类也可以触发工作流执行。一旦触发,就会调用执行器来运行任务。
有许多类型的工作流引擎可以满足各种各样的用例。有些,如 Airflow、Dagster 和 Prefect与数据系统集成并编排 ETL 任务。其他的,如 Camunda 和 Orkes为工作流提供图形标记法如 [图 5-7](/ch5#fig_encoding_workflow) 中使用的 BPMN以便非工程师可以更轻松地定义和执行工作流。还有一些如 Temporal 和 Restate提供 *持久化执行*
有许多类型的工作流引擎可以满足各种各样的用例。有些,如 Airflow、Dagster 和 Prefect与数据系统集成并编排 ETL 任务。其他的,如 Camunda 和 Orkes为工作流提供图形标记法如 [图 5-7](#fig_encoding_workflow) 中使用的 BPMN以便非工程师可以更轻松地定义和执行工作流。还有一些如 Temporal 和 Restate提供 *持久化执行*
#### 持久化执行 {#durable-execution}
持久化执行框架已成为构建需要事务性的基于服务的架构的流行方式。在我们的支付示例中,我们希望每笔付款都恰好处理一次。工作流执行期间的故障可能导致信用卡扣费,但没有相应的银行账户存款。在基于服务的架构中,我们不能简单地将两个任务包装在数据库事务中。此外,我们可能正在与我们控制有限的第三方支付网关进行交互。
持久化执行框架是为工作流提供 *恰好一次语义* 的一种方式。如果任务失败,框架将重新执行该任务,但会跳过任务在失败之前成功完成的任何 RPC 调用或状态更改。相反,框架将假装进行调用,但实际上将返回先前调用的结果。这是可能的,因为持久化执行框架将所有 RPC 和状态更改记录到持久存储(如预写日志)[^45] [^46]。[示例 5-5](/ch5#fig_temporal_workflow) 显示了使用 Temporal 支持持久化执行的工作流定义示例。
持久化执行框架是为工作流提供 *恰好一次语义* 的一种方式。如果任务失败,框架将重新执行该任务,但会跳过任务在失败之前成功完成的任何 RPC 调用或状态更改。相反,框架将假装进行调用,但实际上将返回先前调用的结果。这是可能的,因为持久化执行框架将所有 RPC 和状态更改记录到持久存储(如预写日志)[^45] [^46]。[示例 5-5](#fig_temporal_workflow) 显示了使用 Temporal 支持持久化执行的工作流定义示例。
{{< figure id="fig_temporal_workflow" title="示例 5-5. [图 5-7](/ch5#fig_encoding_workflow) 中支付工作流的 Temporal 工作流定义片段。" class="w-full my-4" >}}
{{< figure id="fig_temporal_workflow" title="示例 5-5. [图 5-7](#fig_encoding_workflow) 中支付工作流的 Temporal 工作流定义片段。" class="w-full my-4" >}}
```python
@workflow.defn
@ -552,7 +552,7 @@ class PaymentWorkflow:
消息代理在消息的持久性方面有所不同。许多将消息写入磁盘,以便在消息代理崩溃或需要重新启动时不会丢失。与数据库不同,许多消息代理在消息被消费后会自动再次删除消息。某些代理可以配置为无限期地存储消息,如果你想使用事件溯源,这是必需的(参见 ["事件溯源与 CQRS"](/ch3#sec_datamodels_events))。
如果消费者将消息重新发布到另一个主题,你可能需要小心保留未知字段,以防止前面在数据库上下文中描述的问题([图 5-1](/ch5#fig_encoding_preserve_field))。
如果消费者将消息重新发布到另一个主题,你可能需要小心保留未知字段,以防止前面在数据库上下文中描述的问题([图 5-1](#fig_encoding_preserve_field))。
#### 分布式 actor 框架 {#distributed-actor-frameworks}

View file

@ -4,6 +4,8 @@ weight: 206
breadcrumbs: false
---
<a id="ch_replication"></a>
![](/map/ch05.png)
> *可能出错的东西和“不可能”出错的东西之间,最大的区别在于:后者一旦出错,往往几乎无从下手,也难以修复。*
@ -22,7 +24,7 @@ breadcrumbs: false
复制需要考虑许多权衡:例如,是使用同步还是异步复制,以及如何处理失败的副本。这些通常是数据库中的配置选项,尽管不同数据库的细节有所不同,但许多不同实现的通用原则是相似的。我们将在本章中讨论这些选择的后果。
数据库复制是一个古老的话题——自 20 世纪 70 年代研究以来,原理并没有太大变化 [^1],因为网络的基本约束保持不变。尽管如此古老,像 **最终一致性** 这样的概念仍然会引起困惑。在 ["复制延迟的问题"](/ch6#sec_replication_lag) 中,我们将更准确地了解最终一致性,并讨论诸如 **读己之写****单调读** 等保证。
数据库复制是一个古老的话题——自 20 世纪 70 年代研究以来,原理并没有太大变化 [^1],因为网络的基本约束保持不变。尽管如此古老,像 **最终一致性** 这样的概念仍然会引起困惑。在 ["复制延迟的问题"](#sec_replication_lag) 中,我们将更准确地了解最终一致性,并讨论诸如 **读己之写****单调读** 等保证。
--------
@ -30,7 +32,7 @@ breadcrumbs: false
>
> 你可能会想,如果有了复制,是否还需要备份。答案是肯定的,因为它们有不同的目的:副本会快速将一个节点的写入反映到其他节点上,但备份存储数据的旧快照,以便你可以回到过去的时间点。如果你不小心删除了一些数据,复制并不能帮助你,因为删除操作也会传播到副本,所以如果你想恢复被删除的数据,就需要备份。
>
> 事实上,复制和备份通常是相互补充的。备份有时是设置复制过程的一部分,正如我们将在 ["设置新的副本"](/ch6#sec_replication_new_replica) 中看到的。反过来,归档复制日志可以成为备份过程的一部分。
> 事实上,复制和备份通常是相互补充的。备份有时是设置复制过程的一部分,正如我们将在 ["设置新的副本"](#sec_replication_new_replica) 中看到的。反过来,归档复制日志可以成为备份过程的一部分。
>
> 一些数据库在内部维护过去状态的不可变快照,作为一种内部备份。然而,这意味着在与当前状态相同的存储介质上保留数据的旧版本。如果你有大量数据,将旧数据的备份保存在针对不常访问数据优化的对象存储中可能会更便宜,而只在主存储中存储数据库的当前状态。
@ -40,7 +42,7 @@ breadcrumbs: false
存储数据库副本的每个节点称为 **副本**。有了多个副本,不可避免地会出现一个问题:我们如何确保所有数据最终都出现在所有副本上?
每次写入数据库都需要由每个副本处理;否则,副本将不再包含相同的数据。最常见的解决方案称为 **基于领导者的复制**、**主备复制** 或 **主动/被动复制**。它的工作原理如下(见 [图 6-1](/ch6#fig_replication_leader_follower)
每次写入数据库都需要由每个副本处理;否则,副本将不再包含相同的数据。最常见的解决方案称为 **基于领导者的复制**、**主备复制** 或 **主动/被动复制**。它的工作原理如下(见 [图 6-1](#fig_replication_leader_follower)
1. 其中一个副本被指定为 **领导者**(也称为 **主库****源** [^2])。当客户端想要写入数据库时,他们必须将请求发送给领导者,领导者首先将新数据写入其本地存储。
2. 其他副本称为 **追随者****只读副本**、**从库** 或 **热备**)。每当领导者将新数据写入其本地存储时,它也会将数据变更作为 **复制日志****变更流** 的一部分发送给所有追随者。每个追随者从领导者获取日志,并通过按照与领导者处理相同的顺序应用所有写入来相应地更新其本地数据库副本。
@ -48,7 +50,7 @@ breadcrumbs: false
{{< figure src="/fig/ddia_0601.png" id="fig_replication_leader_follower" caption="图 6-1. 单主复制将所有写入定向到指定的领导者,该领导者向追随者发送变更流。" class="w-full my-4" >}}
如果数据库是分片的(见 [第 7 章](/ch7#ch_sharding)),每个分片都有一个领导者。不同的分片可能在不同的节点上有其领导者,但每个分片仍必须有一个领导者。在 ["多主复制"](/ch6#sec_replication_multi_leader) 中,我们将讨论一种替代模型,其中系统可能同时为同一分片拥有多个领导者。
如果数据库是分片的(见 [第 7 章](/ch7#ch_sharding)),每个分片都有一个领导者。不同的分片可能在不同的节点上有其领导者,但每个分片仍必须有一个领导者。在 ["多主复制"](#sec_replication_multi_leader) 中,我们将讨论一种替代模型,其中系统可能同时为同一分片拥有多个领导者。
单主复制被广泛使用。它是许多关系数据库的内置功能,如 PostgreSQL、MySQL、Oracle Data Guard [^3] 和 SQL Server 的 Always On 可用性组 [^4]。它也用于一些文档数据库,如 MongoDB 和 DynamoDB [^5],消息代理如 Kafka复制块设备如 DRBD以及一些网络文件系统。许多共识算法如 Raft也基于单个领导者用于 CockroachDB [^6]、TiDB [^7]、etcd 和 RabbitMQ 仲裁队列(以及其他)中的复制,并在旧领导者失败时自动选举新领导者(我们将在 [第 10 章](/ch10#ch_consistency) 中更详细地讨论共识)。
@ -63,11 +65,11 @@ breadcrumbs: false
复制系统的一个重要细节是复制是 **同步** 发生还是 **异步** 发生。(在关系数据库中,这通常是一个可配置选项;其他系统通常硬编码为其中之一。)
想想 [图 6-1](/ch6#fig_replication_leader_follower) 中发生的情况,一个网站用户更新他们的个人资料图片。在某个时间点,客户端向领导者发送更新请求;不久之后,领导者收到了它。在某个时间点,领导者将数据变更转发给追随者。最终,领导者通知客户端更新成功。[图 6-2](/ch6#fig_replication_sync_replication) 显示了时序可能的工作方式。
想想 [图 6-1](#fig_replication_leader_follower) 中发生的情况,一个网站用户更新他们的个人资料图片。在某个时间点,客户端向领导者发送更新请求;不久之后,领导者收到了它。在某个时间点,领导者将数据变更转发给追随者。最终,领导者通知客户端更新成功。[图 6-2](#fig_replication_sync_replication) 显示了时序可能的工作方式。
{{< figure src="/fig/ddia_0602.png" id="fig_replication_sync_replication" caption="图 6-2. 基于领导者的复制,带有一个同步和一个异步追随者。" class="w-full my-4" >}}
在 [图 6-2](/ch6#fig_replication_sync_replication) 的示例中,对追随者 1 的复制是 **同步的**:领导者等待追随者 1 确认它已收到写入,然后才向用户报告成功,并使写入对其他客户端可见。对追随者 2 的复制是 **异步的**:领导者发送消息,但不等待追随者的响应。
在 [图 6-2](#fig_replication_sync_replication) 的示例中,对追随者 1 的复制是 **同步的**:领导者等待追随者 1 确认它已收到写入,然后才向用户报告成功,并使写入对其他客户端可见。对追随者 2 的复制是 **异步的**:领导者发送消息,但不等待追随者的响应。
图中显示,追随者 2 处理消息之前有相当大的延迟。通常,复制相当快:大多数数据库系统在不到一秒的时间内将变更应用到追随者。然而,不能保证需要多长时间。在某些情况下,追随者可能落后领导者几分钟或更长时间;例如,如果追随者正在从故障中恢复,如果系统正在接近最大容量运行,或者如果节点之间存在网络问题。
@ -75,11 +77,11 @@ breadcrumbs: false
因此,将所有追随者都设为同步是不切实际的:任何一个节点的中断都会导致整个系统停止。实际上,如果数据库提供同步复制,通常意味着 **一个** 追随者是同步的,其他的是异步的。如果同步追随者变得不可用或缓慢,异步追随者之一将变为同步。这保证了你至少在两个节点上拥有最新的数据副本:领导者和一个同步追随者。这种配置有时也称为 **半同步**
在某些系统中,**多数**(例如,包括领导者在内的 5 个副本中的 3 个)副本被同步更新,其余少数是异步的。这是 **仲裁** 的一个例子,我们将在 ["读写仲裁"](/ch6#sec_replication_quorum_condition) 中进一步讨论。多数仲裁通常用于使用共识协议进行自动领导者选举的系统中,我们将在 [第 10 章](/ch10#ch_consistency) 中回到这个话题。
在某些系统中,**多数**(例如,包括领导者在内的 5 个副本中的 3 个)副本被同步更新,其余少数是异步的。这是 **仲裁** 的一个例子,我们将在 ["读写仲裁"](#sec_replication_quorum_condition) 中进一步讨论。多数仲裁通常用于使用共识协议进行自动领导者选举的系统中,我们将在 [第 10 章](/ch10#ch_consistency) 中回到这个话题。
有时,基于领导者的复制被配置为完全异步。在这种情况下,如果领导者失败且无法恢复,任何尚未复制到追随者的写入都会丢失。这意味着即使已向客户端确认,写入也不能保证持久。然而,完全异步配置的优点是领导者可以继续处理写入,即使所有追随者都已落后。
弱化持久性可能听起来像是一个糟糕的权衡,但异步复制仍然被广泛使用,特别是如果有许多追随者或者它们在地理上分布广泛 [^9]。我们将在 ["复制延迟的问题"](/ch6#sec_replication_lag) 中回到这个问题。
弱化持久性可能听起来像是一个糟糕的权衡,但异步复制仍然被广泛使用,特别是如果有许多追随者或者它们在地理上分布广泛 [^9]。我们将在 ["复制延迟的问题"](#sec_replication_lag) 中回到这个问题。
### 设置新的副本 {#sec_replication_new_replica}
@ -100,6 +102,8 @@ breadcrumbs: false
--------
<a id="sec_replication_object_storage"></a>
> [!TIP] 由对象存储支持的数据库
>
> 对象存储可用于存档数据之外的更多用途。许多数据库开始使用对象存储(如 Amazon Web Services S3、Google Cloud Storage 和 Azure Blob Storage来为实时查询提供数据。在对象存储中存储数据库数据有许多好处
@ -145,7 +149,7 @@ breadcrumbs: false
* 如果使用异步复制,新的领导者可能在失败之前没有收到来自旧领导者的所有写入。如果前领导者在选择了新领导者后重新加入集群,那些写入应该怎么办?新的领导者可能同时收到了冲突的写入。最常见的解决方案是简单地丢弃旧领导者未复制的写入,这意味着你认为已提交的写入实际上并不持久。
* 如果数据库之外的其他存储系统需要与数据库内容协调,丢弃写入尤其危险。例如,在 GitHub 的一次事故中 [^14],一个过时的 MySQL 追随者被提升为领导者。数据库使用自增计数器为新行分配主键,但由于新领导者的计数器落后于旧领导者,它重用了旧领导者先前分配的一些主键。这些主键也在 Redis 存储中使用,因此主键的重用导致 MySQL 和 Redis 之间的不一致,这导致一些私人数据被错误地披露给错误的用户。
* 在某些故障场景中(见 [第 9 章](/ch9#ch_distributed)),可能会发生两个节点都认为自己是领导者的情况。这种情况称为 **脑裂**,这是危险的:如果两个领导者都接受写入,并且没有解决冲突的过程(见 ["多主复制"](/ch6#sec_replication_multi_leader)),数据很可能会丢失或损坏。作为安全措施,一些系统在检测到两个领导者时有一种机制来关闭一个节点。然而,如果这种机制设计不当,你最终可能会关闭两个节点 [^15]。此外,当检测到脑裂并关闭旧节点时,可能为时已晚,数据已经损坏。
* 在某些故障场景中(见 [第 9 章](/ch9#ch_distributed)),可能会发生两个节点都认为自己是领导者的情况。这种情况称为 **脑裂**,这是危险的:如果两个领导者都接受写入,并且没有解决冲突的过程(见 ["多主复制"](#sec_replication_multi_leader)),数据很可能会丢失或损坏。作为安全措施,一些系统在检测到两个领导者时有一种机制来关闭一个节点。然而,如果这种机制设计不当,你最终可能会关闭两个节点 [^15]。此外,当检测到脑裂并关闭旧节点时,可能为时已晚,数据已经损坏。
* 在宣布领导者死亡之前,正确的超时是什么?更长的超时意味着在领导者失败的情况下恢复时间更长。然而,如果超时太短,可能会有不必要的故障转移。例如,临时负载峰值可能导致节点的响应时间增加到超时以上,或者网络故障可能导致数据包延迟。如果系统已经在高负载或网络问题上挣扎,不必要的故障转移可能会使情况变得更糟,而不是更好。
--------
@ -187,6 +191,8 @@ breadcrumbs: false
这可能看起来像是一个小的实现细节,但它可能会产生很大的操作影响。如果复制协议允许追随者使用比领导者更新的软件版本,你可以通过首先升级追随者然后执行故障转移以使其中一个升级的节点成为新的领导者来执行数据库软件的零停机升级。如果复制协议不允许此版本不匹配(如 WAL 传输的情况),此类升级需要停机。
<a id="sec_replication_logical"></a>
#### 逻辑(基于行)日志复制 {#logical-row-based-log-replication}
另一种选择是为复制和存储引擎使用不同的日志格式,这允许复制日志与存储引擎内部解耦。这种复制日志称为 **逻辑日志**,以区别于存储引擎的(**物理**)数据表示。
@ -229,7 +235,7 @@ breadcrumbs: false
许多应用程序让用户提交一些数据,然后查看他们提交的内容。这可能是客户数据库中的记录,或讨论线程上的评论,或其他类似的东西。提交新数据时,必须将其发送到领导者,但当用户查看数据时,可以从追随者读取。如果数据经常被查看但只是偶尔被写入,这尤其合适。
使用异步复制,存在一个问题,如 [图 6-3](/ch6#fig_replication_read_your_writes) 所示:如果用户在写入后不久查看数据,新数据可能尚未到达副本。对用户来说,看起来他们提交的数据丢失了,所以他们自然会不高兴。
使用异步复制,存在一个问题,如 [图 6-3](#fig_replication_read_your_writes) 所示:如果用户在写入后不久查看数据,新数据可能尚未到达副本。对用户来说,看起来他们提交的数据丢失了,所以他们自然会不高兴。
{{< figure src="/fig/ddia_0603.png" id="fig_replication_read_your_writes" caption="图 6-3. 用户进行写入,然后从陈旧副本读取。为了防止这种异常,我们需要写后读一致性。" class="w-full my-4" >}}
@ -255,7 +261,7 @@ breadcrumbs: false
>
> 我们用 **地区**region来指代一个地理位置中的一组数据中心。云服务提供商通常会在同一地区部署多个数据中心每个数据中心称为 **可用区**availability zone简称 AZ。因此一个地区由多个可用区组成每个可用区都是独立的物理设施具有自己的供电、制冷等基础设施。
>
> 同一地区内各可用区通常通过高速网络互联,延迟足够低,因此大多数分布式系统可以把同一地区内的多个可用区近似看作一个机房。多可用区部署可以抵御单个可用区故障,但无法抵御整个地区不可用。要应对地区级中断,系统必须跨多个地区部署,这通常会带来更高延迟、更低吞吐和更高的云网络费用。我们将在 ["多主复制拓扑"](/ch6#sec_replication_topologies) 中进一步讨论这些权衡。这里你只需记住:本书所说的“地区”,是同一地理位置内多个可用区(数据中心)的集合。
> 同一地区内各可用区通常通过高速网络互联,延迟足够低,因此大多数分布式系统可以把同一地区内的多个可用区近似看作一个机房。多可用区部署可以抵御单个可用区故障,但无法抵御整个地区不可用。要应对地区级中断,系统必须跨多个地区部署,这通常会带来更高延迟、更低吞吐和更高的云网络费用。我们将在 ["多主复制拓扑"](#sec_replication_topologies) 中进一步讨论这些权衡。这里你只需记住:本书所说的“地区”,是同一地理位置内多个可用区(数据中心)的集合。
--------
@ -263,7 +269,7 @@ breadcrumbs: false
从异步追随者读取时可能发生的第二个异常示例是,用户可能会看到事物 **在时间上倒退**
如果用户从不同的副本进行多次读取,就可能发生这种情况。例如,[图 6-4](/ch6#fig_replication_monotonic_reads) 显示用户 2345 进行相同的查询两次,首先到延迟很小的追随者,然后到延迟更大的追随者。(如果用户刷新网页,并且每个请求都路由到随机服务器,这种情况很可能发生。)第一个查询返回用户 1234 最近添加的评论,但第二个查询没有返回任何内容,因为滞后的追随者尚未获取该写入。实际上,第二个查询观察到的系统状态比第一个查询更早的时间点。如果第一个查询没有返回任何内容,这不会那么糟糕,因为用户 2345 可能不知道用户 1234 最近添加了评论。然而,如果用户 2345 首先看到用户 1234 的评论出现,然后又看到它消失,这对用户 2345 来说非常令人困惑。
如果用户从不同的副本进行多次读取,就可能发生这种情况。例如,[图 6-4](#fig_replication_monotonic_reads) 显示用户 2345 进行相同的查询两次,首先到延迟很小的追随者,然后到延迟更大的追随者。(如果用户刷新网页,并且每个请求都路由到随机服务器,这种情况很可能发生。)第一个查询返回用户 1234 最近添加的评论,但第二个查询没有返回任何内容,因为滞后的追随者尚未获取该写入。实际上,第二个查询观察到的系统状态比第一个查询更早的时间点。如果第一个查询没有返回任何内容,这不会那么糟糕,因为用户 2345 可能不知道用户 1234 最近添加了评论。然而,如果用户 2345 首先看到用户 1234 的评论出现,然后又看到它消失,这对用户 2345 来说非常令人困惑。
{{< figure src="/fig/ddia_0604.png" id="fig_replication_monotonic_reads" caption="图 6-4. 用户首先从新鲜副本读取,然后从陈旧副本读取。时间似乎倒退了。为了防止这种异常,我们需要单调读。" class="w-full my-4" >}}
@ -283,7 +289,7 @@ Cake 夫人
这两个句子之间存在因果依赖关系Cake 夫人听到了 Poons 先生的问题并回答了它。
现在想象第三个人通过追随者听这个对话。Cake 夫人说的话通过延迟很小的追随者,但 Poons 先生说的话有更长的复制延迟(见 [图 6-5](/ch6#fig_replication_consistent_prefix))。这个观察者会听到以下内容:
现在想象第三个人通过追随者听这个对话。Cake 夫人说的话通过延迟很小的追随者,但 Poons 先生说的话有更长的复制延迟(见 [图 6-5](#fig_replication_consistent_prefix))。这个观察者会听到以下内容:
Cake 夫人
: 通常大约十秒钟Poons 先生。
@ -299,7 +305,7 @@ Poons 先生
这是分片(分区)数据库中的一个特殊问题,我们将在 [第 7 章](/ch7#ch_sharding) 中讨论。如果数据库始终以相同的顺序应用写入,读取始终会看到一致的前缀,因此这种异常不会发生。然而,在许多分布式数据库中,不同的分片独立运行,因此没有全局的写入顺序:当用户从数据库读取时,他们可能会看到数据库的某些部分处于较旧状态,而某些部分处于较新状态。
一种解决方案是确保任何因果相关的写入都写入同一分片——但在某些应用程序中,这无法有效完成。还有一些算法明确跟踪因果依赖关系,这是我们将在 ["先发生关系与并发"](/ch6#sec_replication_happens_before) 中回到的主题。
一种解决方案是确保任何因果相关的写入都写入同一分片——但在某些应用程序中,这无法有效完成。还有一些算法明确跟踪因果依赖关系,这是我们将在 ["先发生关系与并发"](#sec_replication_happens_before) 中回到的主题。
### 复制延迟的解决方案 {#id131}
@ -333,7 +339,7 @@ Poons 先生
想象你有一个数据库,在几个不同的地区有副本(也许是为了能够容忍整个地区的故障,或者是为了更接近你的用户)。这被称为 **地理分布式**、**地域分布式** 或 **地域复制** 设置。使用单主复制,领导者必须在 **一个** 地区,所有写入都必须通过该地区。
在多主配置中,你可以在 **每个** 地区都部署一个领导者。[图 6-6](/ch6#fig_replication_multi_dc) 展示了这种架构:在每个地区内使用常规单主复制(追随者可能位于与领导者不同的可用区);在地区之间,每个地区的领导者把变更复制给其他地区的领导者。
在多主配置中,你可以在 **每个** 地区都部署一个领导者。[图 6-6](#fig_replication_multi_dc) 展示了这种架构:在每个地区内使用常规单主复制(追随者可能位于与领导者不同的可用区);在地区之间,每个地区的领导者把变更复制给其他地区的领导者。
{{< figure src="/fig/ddia_0606.png" id="fig_replication_multi_dc" caption="图 6-6. 跨多个地区的多主复制。" class="w-full my-4" >}}
@ -353,7 +359,7 @@ Poons 先生
一致性
: 单主系统可以提供强一致性保证,例如可串行化事务,我们将在 [第 8 章](/ch8#ch_transactions) 中讨论。多主系统的最大缺点是它们能够实现的一致性要弱得多。例如,你不能保证银行账户不会变成负数或用户名是唯一的:不同的领导者总是可能处理单独没问题的写入(从账户中支付一些钱,注册特定用户名),但当与另一个领导者上的另一个写入结合时违反了约束。
这只是分布式系统的基本限制 [^28]。如果你必须强制执行这类约束,通常应选择单主系统。不过,正如我们将在 ["处理写入冲突"](/ch6#sec_replication_write_conflicts) 中看到的,多主系统在不需要这类约束的广泛应用里,仍然可以提供有用的一致性属性。
这只是分布式系统的基本限制 [^28]。如果你必须强制执行这类约束,通常应选择单主系统。不过,正如我们将在 ["处理写入冲突"](#sec_replication_write_conflicts) 中看到的,多主系统在不需要这类约束的广泛应用里,仍然可以提供有用的一致性属性。
多主复制不如单主复制常见,但许多数据库仍然支持它,包括 MySQL、Oracle、SQL Server 和 YugabyteDB。在某些情况下它是一个外部附加功能例如在 Redis Enterprise、EDB Postgres Distributed 和 pglogical 中 [^29]。
@ -361,11 +367,11 @@ Poons 先生
#### 多主复制拓扑 {#sec_replication_topologies}
**复制拓扑** 描述了写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如 [图 6-9](/ch6#fig_replication_write_conflict) 中,只有一种合理的拓扑:领导者 1 必须将其所有写入发送到领导者 2反之亦然。有了两个以上的领导者各种不同的拓扑是可能的。[图 6-7](/ch6#fig_replication_topologies) 中说明了一些示例。
**复制拓扑** 描述了写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如 [图 6-9](#fig_replication_write_conflict) 中,只有一种合理的拓扑:领导者 1 必须将其所有写入发送到领导者 2反之亦然。有了两个以上的领导者各种不同的拓扑是可能的。[图 6-7](#fig_replication_topologies) 中说明了一些示例。
{{< figure src="/fig/ddia_0607.png" id="fig_replication_topologies" caption="图 6-7. 可以设置多主复制的三个示例拓扑。" class="w-full my-4" >}}
最通用的拓扑是 **全对全**,如 [图 6-7](/ch6#fig_replication_topologies)(c) 所示,其中每个领导者将其写入发送到每个其他领导者。然而,也使用更受限制的拓扑:例如 **环形拓扑**,其中每个节点从一个节点接收写入并将这些写入(加上其自己的任何写入)转发到另一个节点。另一种流行的拓扑具有 **星形** 形状:一个指定的根节点将写入转发到所有其他节点。星形拓扑可以推广到树形。
最通用的拓扑是 **全对全**,如 [图 6-7](#fig_replication_topologies)(c) 所示,其中每个领导者将其写入发送到每个其他领导者。然而,也使用更受限制的拓扑:例如 **环形拓扑**,其中每个节点从一个节点接收写入并将这些写入(加上其自己的任何写入)转发到另一个节点。另一种流行的拓扑具有 **星形** 形状:一个指定的根节点将写入转发到所有其他节点。星形拓扑可以推广到树形。
--------
@ -380,15 +386,15 @@ Poons 先生
环形和星形拓扑的一个问题是,如果只有一个节点发生故障,它可能会中断其他节点之间的复制消息流,使它们无法通信,直到节点被修复。可以重新配置拓扑以绕过故障节点,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑(如全对全)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。
另一方面,全对全拓扑也可能有问题。特别是,一些网络链路可能比其他链路更快(例如,由于网络拥塞),结果是一些复制消息可能会"超越"其他消息,如 [图 6-8](/ch6#fig_replication_causality) 所示。
另一方面,全对全拓扑也可能有问题。特别是,一些网络链路可能比其他链路更快(例如,由于网络拥塞),结果是一些复制消息可能会"超越"其他消息,如 [图 6-8](#fig_replication_causality) 所示。
{{< figure src="/fig/ddia_0608.png" id="fig_replication_causality" caption="图 6-8. 使用多主复制,写入可能以错误的顺序到达某些副本。" class="w-full my-4" >}}
在 [图 6-8](/ch6#fig_replication_causality) 中,客户端 A 在领导者 1 上向表中插入一行,客户端 B 在领导者 3 上更新该行。然而,领导者 2 可能以不同的顺序接收写入:它可能首先接收更新(从其角度来看,这是对数据库中不存在的行的更新),然后才接收相应的插入(应该在更新之前)。
在 [图 6-8](#fig_replication_causality) 中,客户端 A 在领导者 1 上向表中插入一行,客户端 B 在领导者 3 上更新该行。然而,领导者 2 可能以不同的顺序接收写入:它可能首先接收更新(从其角度来看,这是对数据库中不存在的行的更新),然后才接收相应的插入(应该在更新之前)。
这是一个因果关系问题,类似于我们在 ["一致前缀读"](/ch6#sec_replication_consistent_prefix) 中看到的问题:更新依赖于先前的插入,因此我们需要确保所有节点首先处理插入,然后处理更新。简单地为每个写入附加时间戳是不够的,因为时钟不能被信任足够同步以在领导者 2 上正确排序这些事件(见 [第 9 章](/ch9#ch_distributed))。
这是一个因果关系问题,类似于我们在 ["一致前缀读"](#sec_replication_consistent_prefix) 中看到的问题:更新依赖于先前的插入,因此我们需要确保所有节点首先处理插入,然后处理更新。简单地为每个写入附加时间戳是不够的,因为时钟不能被信任足够同步以在领导者 2 上正确排序这些事件(见 [第 9 章](/ch9#ch_distributed))。
为了正确排序这些事件,可以使用一种称为 **版本向量** 的技术,我们将在本章后面讨论(见 ["检测并发写入"](/ch6#sec_replication_concurrent))。然而,许多多主复制系统不使用良好的技术来排序更新,使它们容易受到像 [图 6-8](/ch6#fig_replication_causality) 中的问题的影响。如果你使用多主复制,值得了解这些问题,仔细阅读文档,并彻底测试你的数据库,以确保它真正提供你认为它具有的保证。
为了正确排序这些事件,可以使用一种称为 **版本向量** 的技术,我们将在本章后面讨论(见 ["检测并发写入"](#sec_replication_concurrent))。然而,许多多主复制系统不使用良好的技术来排序更新,使它们容易受到像 [图 6-8](#fig_replication_causality) 中的问题的影响。如果你使用多主复制,值得了解这些问题,仔细阅读文档,并彻底测试你的数据库,以确保它真正提供你认为它具有的保证。
### 同步引擎与本地优先软件 {#sec_replication_offline_clients}
@ -430,14 +436,14 @@ Poons 先生
多主复制的最大问题——无论是在地域分布式服务器端数据库中还是在终端用户设备上的本地优先同步引擎中——是不同领导者上的并发写入可能导致需要解决的冲突。
例如,考虑一个维基页面同时被两个用户编辑,如 [图 6-9](/ch6#fig_replication_write_conflict) 所示。用户 1 将页面标题从 A 更改为 B用户 2 独立地将标题从 A 更改为 C。每个用户的更改成功应用于其本地领导者。然而当更改异步复制时检测到冲突。这个问题在单主数据库中不会发生。
例如,考虑一个维基页面同时被两个用户编辑,如 [图 6-9](#fig_replication_write_conflict) 所示。用户 1 将页面标题从 A 更改为 B用户 2 独立地将标题从 A 更改为 C。每个用户的更改成功应用于其本地领导者。然而当更改异步复制时检测到冲突。这个问题在单主数据库中不会发生。
{{< figure src="/fig/ddia_0609.png" id="fig_replication_write_conflict" caption="图 6-9. 两个领导者并发更新同一记录导致的写入冲突。" class="w-full my-4" >}}
> [!NOTE]
> 我们说 [图 6-9](/ch6#fig_replication_write_conflict) 中的两个写入是 **并发的**,因为在最初进行写入时,两者都不“知道”对方。写入是否真的在同一时刻发生并不重要;实际上,如果写入发生在离线状态,它们在物理时间上可能相隔很久。关键在于:一个写入是否发生在另一个写入已经生效的状态之上。
> 我们说 [图 6-9](#fig_replication_write_conflict) 中的两个写入是 **并发的**,因为在最初进行写入时,两者都不“知道”对方。写入是否真的在同一时刻发生并不重要;实际上,如果写入发生在离线状态,它们在物理时间上可能相隔很久。关键在于:一个写入是否发生在另一个写入已经生效的状态之上。
在 ["检测并发写入"](/ch6#sec_replication_concurrent) 中,我们将解决数据库如何确定两个写入是否并发的问题。现在我们假设我们可以检测冲突,并且我们想找出解决它们的最佳方法。
在 ["检测并发写入"](#sec_replication_concurrent) 中,我们将解决数据库如何确定两个写入是否并发的问题。现在我们假设我们可以检测冲突,并且我们想找出解决它们的最佳方法。
#### 冲突避免 {#conflict-avoidance}
@ -452,9 +458,9 @@ Poons 先生
#### 最后写入胜利(丢弃并发写入) {#sec_replication_lww}
如果无法避免冲突,解决它们的最简单方法是为每个写入附加时间戳,并始终使用具有最大时间戳的值。例如,在 [图 6-9](/ch6#fig_replication_write_conflict) 中,假设用户 1 的写入时间戳大于用户 2 的写入时间戳。在这种情况下,两个领导者都将确定页面的新标题应该是 B并丢弃将其设置为 C 的写入。如果写入巧合地具有相同的时间戳,可以通过比较值来选择获胜者(例如,在字符串的情况下,取字母表中较早的那个)。
如果无法避免冲突,解决它们的最简单方法是为每个写入附加时间戳,并始终使用具有最大时间戳的值。例如,在 [图 6-9](#fig_replication_write_conflict) 中,假设用户 1 的写入时间戳大于用户 2 的写入时间戳。在这种情况下,两个领导者都将确定页面的新标题应该是 B并丢弃将其设置为 C 的写入。如果写入巧合地具有相同的时间戳,可以通过比较值来选择获胜者(例如,在字符串的情况下,取字母表中较早的那个)。
这种方法称为 **最后写入胜利**LWW因为具有最大时间戳的写入可以被认为是"最后"的。然而,这个术语是误导性的,因为当两个写入像 [图 6-9](/ch6#fig_replication_write_conflict) 中那样并发时,哪个更旧,哪个更新是未定义的,因此并发写入的时间戳顺序本质上是随机的。
这种方法称为 **最后写入胜利**LWW因为具有最大时间戳的写入可以被认为是"最后"的。然而,这个术语是误导性的,因为当两个写入像 [图 6-9](#fig_replication_write_conflict) 中那样并发时,哪个更旧,哪个更新是未定义的,因此并发写入的时间戳顺序本质上是随机的。
因此LWW 的真正含义是:当同一记录在不同的领导者上并发写入时,其中一个写入被随机选择为获胜者,其他写入被静默丢弃,即使它们在各自的领导者上成功处理。这实现了最终所有副本都处于一致状态的目标,但代价是数据丢失。
@ -466,13 +472,13 @@ LWW 的另一个问题是,如果使用实时时钟(例如 Unix 时间戳)
如果随机丢弃你的一些写入是不可取的,下一个选择是手动解决冲突。你可能熟悉 Git 和其他版本控制系统中的手动冲突解决:如果两个不同分支上的提交编辑同一文件的相同行,并且你尝试合并这些分支,你将得到一个需要在合并完成之前解决的合并冲突。
在数据库里,让冲突阻塞整个复制流程、直到人工处理,通常并不现实。更常见的是,数据库会保留某条记录的所有并发写入值——例如 [图 6-9](/ch6#fig_replication_write_conflict) 中的 B 和 C。这些值有时称为 **兄弟**。下次查询该记录时,数据库会返回 **所有** 这些值,而不只是最新值。随后你可以按需要解决这些值:要么在应用代码里自动处理(例如把 B 和 C 合并成 "B/C"),要么让用户参与处理;最后再把新值写回数据库以消解冲突。
在数据库里,让冲突阻塞整个复制流程、直到人工处理,通常并不现实。更常见的是,数据库会保留某条记录的所有并发写入值——例如 [图 6-9](#fig_replication_write_conflict) 中的 B 和 C。这些值有时称为 **兄弟**。下次查询该记录时,数据库会返回 **所有** 这些值,而不只是最新值。随后你可以按需要解决这些值:要么在应用代码里自动处理(例如把 B 和 C 合并成 "B/C"),要么让用户参与处理;最后再把新值写回数据库以消解冲突。
这种冲突解决方法在某些系统中使用,例如 CouchDB。然而它也存在许多问题
* 数据库的 API 发生变化:例如,以前维基页面的标题只是一个字符串,现在它变成了一组字符串,通常包含一个元素,但如果有冲突,有时可能包含多个元素。这可能使应用程序代码中的数据难以处理。
* 要求用户手动合并兄弟,会带来很大负担:开发者需要构建冲突解决界面,用户也可能不明白自己为何要做这件事。在很多场景下,自动合并比打扰用户更合适。
* 如果不够谨慎,自动合并兄弟也可能产生反直觉行为。例如,亚马逊购物车曾允许并发更新,并用“并集”策略合并(保留出现在任一兄弟中的所有商品)。这意味着:若用户在一个兄弟里删除了某商品,但另一个兄弟仍保留它,该商品会“复活”回购物车 [^45]。[图 6-10](/ch6#fig_replication_amazon_anomaly) 就是一个例子:设备 1 删除 Book设备 2 并发删除 DVD冲突合并后两个商品都回来了。
* 如果不够谨慎,自动合并兄弟也可能产生反直觉行为。例如,亚马逊购物车曾允许并发更新,并用“并集”策略合并(保留出现在任一兄弟中的所有商品)。这意味着:若用户在一个兄弟里删除了某商品,但另一个兄弟仍保留它,该商品会“复活”回购物车 [^45]。[图 6-10](#fig_replication_amazon_anomaly) 就是一个例子:设备 1 删除 Book设备 2 并发删除 DVD冲突合并后两个商品都回来了。
* 如果多个节点观察到冲突并并发解决它,冲突解决过程本身可能会引入新的冲突。这些解决方案甚至可能不一致:例如,如果你不小心一致地排序它们,一个节点可能将 B 和 C 合并为"B/C",另一个可能将它们合并为"C/B"。当"B/C"和"C/B"之间的冲突被合并时,它可能导致"B/C/C/B"或类似令人惊讶的东西。
{{< figure src="/fig/ddia_0610.png" id="fig_replication_amazon_anomaly" caption="图 6-10. 亚马逊购物车异常的示例:如果购物车上的冲突通过取并集合并,删除的项目可能会重新出现。" class="w-full my-4" >}}
@ -485,7 +491,7 @@ LWW 的另一个问题是,如果使用实时时钟(例如 Unix 时间戳)
LWW 是冲突解决算法的一个简单示例。已经为不同类型的数据开发了更复杂的合并算法,目标是尽可能保留所有更新的预期效果,从而避免数据丢失:
* 如果数据是文本(例如维基页面标题或正文),我们可以检测每次版本演进中的字符插入和删除。合并结果会保留任一兄弟中的所有插入和删除。如果多个用户并发在同一位置插入文本,还可以用确定性顺序来排序,以确保所有节点得到同样的合并结果。
* 如果数据是项目集合(像待办事项列表那样有序,或像购物车那样无序),我们可以通过跟踪插入和删除类似于文本来合并它。为了避免 [图 6-10](/ch6#fig_replication_amazon_anomaly) 中的购物车问题,算法跟踪 Book 和 DVD 被删除的事实,因此合并的结果是 Cart = {Soap}。
* 如果数据是项目集合(像待办事项列表那样有序,或像购物车那样无序),我们可以通过跟踪插入和删除类似于文本来合并它。为了避免 [图 6-10](#fig_replication_amazon_anomaly) 中的购物车问题,算法跟踪 Book 和 DVD 被删除的事实,因此合并的结果是 Cart = {Soap}。
* 如果数据是可增可减的整数计数器(例如社交媒体帖子的点赞数),合并算法可以统计每个兄弟上的递增和递减次数,并正确求和,既不重复计数,也不丢更新。
* 如果数据是键值映射,我们可以通过将其他冲突解决算法之一应用于该键下的值来合并对同一键的更新。对不同键的更新可以相互独立处理。
@ -495,7 +501,7 @@ LWW 是冲突解决算法的一个简单示例。已经为不同类型的数据
两个算法族通常用于实现自动冲突解决:**无冲突复制数据类型**CRDT[^46] 和 **操作变换**OT[^47]。它们具有不同的设计理念和性能特征,但都能够为前面提到的所有类型的数据执行自动合并。
[图 6-11](/ch6#fig_replication_ot_crdt) 显示了 OT 和 CRDT 如何合并对文本的并发更新的示例。假设你有两个副本,都从文本"ice"开始。一个副本在前面添加字母"n"以制作"nice",而另一个副本并发地附加感叹号以制作"ice!"。
[图 6-11](#fig_replication_ot_crdt) 显示了 OT 和 CRDT 如何合并对文本的并发更新的示例。假设你有两个副本,都从文本"ice"开始。一个副本在前面添加字母"n"以制作"nice",而另一个副本并发地附加感叹号以制作"ice!"。
{{< figure src="/fig/ddia_0611.png" id="fig_replication_ot_crdt" caption="图 6-11. OT 和 CRDT 如何分别合并对字符串的两个并发插入。" class="w-full my-4" >}}
@ -505,7 +511,7 @@ OT
: 我们记录插入或删除字符的索引:"n"插入在索引 0"!"插入在索引 3。接下来副本交换它们的操作。在 0 处插入"n"可以按原样应用,但如果在 3 处插入"!"应用于状态"nice",我们将得到"nic!e",这是不正确的。因此,我们需要转换每个操作的索引以考虑已经应用的并发操作;在这种情况下,"!"的插入被转换为索引 4 以考虑在较早索引处插入"n"。
CRDT
: 大多数 CRDT 为每个字符提供唯一的、不可变的 ID并使用这些 ID 来确定插入/删除的位置,而不是索引。例如,在 [图 6-11](/ch6#fig_replication_ot_crdt) 中,我们将 ID 1A 分配给"i"ID 2A 分配给"c"等。插入感叹号时,我们生成一个包含新字符的 ID4B和我们想要在其后插入的现有字符的 ID3A的操作。要在字符串的开头插入我们将"nil"作为前面的字符 ID。在同一位置的并发插入按字符的 ID 排序。这确保副本收敛而不执行任何转换。
: 大多数 CRDT 为每个字符提供唯一的、不可变的 ID并使用这些 ID 来确定插入/删除的位置,而不是索引。例如,在 [图 6-11](#fig_replication_ot_crdt) 中,我们将 ID 1A 分配给"i"ID 2A 分配给"c"等。插入感叹号时,我们生成一个包含新字符的 ID4B和我们想要在其后插入的现有字符的 ID3A的操作。要在字符串的开头插入我们将"nil"作为前面的字符 ID。在同一位置的并发插入按字符的 ID 排序。这确保副本收敛而不执行任何转换。
有许多基于这些想法变体的算法。列表/数组可以类似地支持使用列表元素而不是字符其他数据类型如键值映射可以很容易地添加。OT 和 CRDT 之间存在一些性能和功能权衡,但可以在一个算法中结合 CRDT 和 OT 的优点 [^48]。
@ -513,7 +519,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
#### 什么是冲突? {#what-is-a-conflict}
某些类型的冲突是显而易见的。在 [图 6-9](/ch6#fig_replication_write_conflict) 的示例中,两个写入并发修改了同一记录中的同一字段,将其设置为两个不同的值。毫无疑问,这是一个冲突。
某些类型的冲突是显而易见的。在 [图 6-9](#fig_replication_write_conflict) 的示例中,两个写入并发修改了同一记录中的同一字段,将其设置为两个不同的值。毫无疑问,这是一个冲突。
其他类型的冲突可能更难以检测。例如,考虑一个会议室预订系统:它跟踪哪个房间由哪组人在什么时间预订。此应用程序需要确保每个房间在任何时间只由一组人预订(即,同一房间不得有任何重叠的预订)。在这种情况下,如果为同一房间同时创建两个不同的预订,可能会出现冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两个预订是在两个不同的领导者上进行的,也可能会发生冲突。
@ -537,9 +543,9 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
### 当节点故障时写入数据库 {#id287}
想象你有一个具有三个副本的数据库,其中一个副本当前不可用——也许它正在重新启动以安装系统更新。在单主配置中,如果你想继续处理写入,你可能需要执行故障转移(见 ["处理节点故障"](/ch6#sec_replication_failover))。
想象你有一个具有三个副本的数据库,其中一个副本当前不可用——也许它正在重新启动以安装系统更新。在单主配置中,如果你想继续处理写入,你可能需要执行故障转移(见 ["处理节点故障"](#sec_replication_failover))。
另一方面,在无主配置中,故障转移不存在。[图 6-12](/ch6#fig_replication_quorum_node_outage) 显示了发生的情况:客户端(用户 1234将写入并行发送到所有三个副本两个可用副本接受写入但不可用副本错过了它。假设三个副本中有两个确认写入就足够了在用户 1234 收到两个 **ok** 响应后,我们认为写入成功。客户端只是忽略了其中一个副本错过写入的事实。
另一方面,在无主配置中,故障转移不存在。[图 6-12](#fig_replication_quorum_node_outage) 显示了发生的情况:客户端(用户 1234将写入并行发送到所有三个副本两个可用副本接受写入但不可用副本错过了它。假设三个副本中有两个确认写入就足够了在用户 1234 收到两个 **ok** 响应后,我们认为写入成功。客户端只是忽略了其中一个副本错过写入的事实。
{{< figure src="/fig/ddia_0612.png" id="fig_replication_quorum_node_outage" caption="图 6-12. 节点中断后的仲裁写入、仲裁读取和读修复。" class="w-full my-4" >}}
@ -548,14 +554,14 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
为了解决这个问题,当客户端从数据库读取时,它不只是将其请求发送到一个副本:**读取请求也并行发送到多个节点**。客户端可能会从不同的节点获得不同的响应;例如,从一个节点获得最新值,从另一个节点获得陈旧值。
为了区分哪些响应是最新的,哪些是过时的,写入的每个值都需要用版本号或时间戳标记,类似于我们在 ["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww) 中看到的。当客户端收到对读取的多个值响应时,它使用具有最大时间戳的值(即使该值仅由一个副本返回,而其他几个副本返回较旧的值)。有关更多详细信息,请参见 ["检测并发写入"](/ch6#sec_replication_concurrent)。
为了区分哪些响应是最新的,哪些是过时的,写入的每个值都需要用版本号或时间戳标记,类似于我们在 ["最后写入胜利(丢弃并发写入)"](#sec_replication_lww) 中看到的。当客户端收到对读取的多个值响应时,它使用具有最大时间戳的值(即使该值仅由一个副本返回,而其他几个副本返回较旧的值)。有关更多详细信息,请参见 ["检测并发写入"](#sec_replication_concurrent)。
#### 追赶错过的写入 {#sec_replication_read_repair}
复制系统应确保最终所有数据都复制到每个副本。在不可用节点恢复上线后,它如何赶上它错过的写入?在 Dynamo 风格的数据存储中使用了几种机制:
读修复
: 当客户端并行从多个节点读取时,它可以检测任何陈旧响应。例如,在 [图 6-12](/ch6#fig_replication_quorum_node_outage) 中,用户 2345 从副本 3 获得版本 6 的值,从副本 1 和 2 获得版本 7 的值。客户端发现副本 3 陈旧后,会把较新的值写回该副本。这种方法适用于经常被读取的值。
: 当客户端并行从多个节点读取时,它可以检测任何陈旧响应。例如,在 [图 6-12](#fig_replication_quorum_node_outage) 中,用户 2345 从副本 3 获得版本 6 的值,从副本 1 和 2 获得版本 7 的值。客户端发现副本 3 陈旧后,会把较新的值写回该副本。这种方法适用于经常被读取的值。
提示移交
: 如果一个副本不可用,另一个副本可能会以 **提示** 的形式代表其存储写入。当应该接收这些写入的副本恢复时,存储提示的副本将它们发送到恢复的副本,然后删除提示。这个 **移交** 过程有助于使副本保持最新,即使对于从未读取的值也是如此,因此不由读修复处理。
@ -565,7 +571,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
#### 读写仲裁 {#sec_replication_quorum_condition}
在 [图 6-12](/ch6#fig_replication_quorum_node_outage) 的例子中,即使写入仅在三个副本中的两个上处理,我们也认为写入成功。如果三个副本中只有一个接受了写入呢?我们能推多远?
在 [图 6-12](#fig_replication_quorum_node_outage) 的例子中,即使写入仅在三个副本中的两个上处理,我们也认为写入成功。如果三个副本中只有一个接受了写入呢?我们能推多远?
如果我们知道每次成功的写入都保证至少存在于三个副本中的两个上,这意味着最多一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确信两个中至少有一个是最新的。如果第三个副本宕机或响应缓慢,读取仍然可以继续返回最新值。
@ -584,8 +590,8 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
* 如果 *w* < *n*,如果节点不可用,我们仍然可以处理写入。
* 如果 *r* < *n*,如果节点不可用,我们仍然可以处理读取。
* 使用 *n* = 3*w* = 2*r* = 2我们可以容忍一个不可用节点如 [图 6-12](/ch6#fig_replication_quorum_node_outage) 中所示。
* 使用 *n* = 5*w* = 3*r* = 3我们可以容忍两个不可用节点。这种情况在 [图 6-13](/ch6#fig_replication_quorum_overlap) 中说明。
* 使用 *n* = 3*w* = 2*r* = 2我们可以容忍一个不可用节点如 [图 6-12](#fig_replication_quorum_node_outage) 中所示。
* 使用 *n* = 5*w* = 3*r* = 3我们可以容忍两个不可用节点。这种情况在 [图 6-13](#fig_replication_quorum_overlap) 中说明。
通常,读取和写入总是并行发送到所有 *n* 个副本。参数 *w**r* 确定我们等待多少个节点——即,在我们认为读取或写入成功之前,*n* 个节点中有多少个需要报告成功。
@ -596,7 +602,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
### 仲裁一致性的局限 {#sec_replication_quorum_limitations}
如果你有 *n* 个副本,并且你选择 *w**r* 使得 *w* + *r* > *n*,你通常可以期望每次读取都返回为键写入的最新值。这是因为你写入的节点集和你读取的节点集必须重叠。也就是说,在你读取的节点中,必须至少有一个具有最新值的节点(如 [图 6-13](/ch6#fig_replication_quorum_overlap) 所示)。
如果你有 *n* 个副本,并且你选择 *w**r* 使得 *w* + *r* > *n*,你通常可以期望每次读取都返回为键写入的最新值。这是因为你写入的节点集和你读取的节点集必须重叠。也就是说,在你读取的节点中,必须至少有一个具有最新值的节点(如 [图 6-13](#fig_replication_quorum_overlap) 所示)。
通常,*r* 和 *w* 被选择为多数(超过 *n*/2节点因为这确保了 *w* + *r* > *n*,同时仍然容忍最多 *n*/2向下舍入个节点故障。但仲裁不一定是多数——重要的是读取和写入操作使用的节点集至少在一个节点中重叠。其他仲裁分配是可能的这允许分布式算法设计中的一些灵活性 [^51]。
@ -610,8 +616,8 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
* 在重新平衡正在进行时,其中一些数据从一个节点移动到另一个节点(见 [第 7 章](/ch7#ch_sharding)),节点可能对哪些节点应该持有特定值的 *n* 个副本有不一致的视图。这可能导致读取和写入仲裁不再重叠。
* 如果读取与写入操作并发,读取可能会或可能不会看到并发写入的值。特别是,一次读取可能看到新值,而后续读取看到旧值,正如我们将在 ["线性一致性与仲裁"](/ch10#sec_consistency_quorum_linearizable) 中看到的。
* 如果写入在某些副本上成功但在其他副本上失败(例如,因为某些节点上的磁盘已满),并且总体上在少于 *w* 个副本上成功,它不会在成功的副本上回滚。这意味着如果写入被报告为失败,后续读取可能会或可能不会返回该写入的值 [^52]。
* 如果数据库使用实时时钟的时间戳来确定哪个写入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一个具有更快时钟的节点已写入同一键,写入可能会被静默丢弃——我们之前在 ["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww) 中看到的问题。我们将在 ["依赖同步时钟"](/ch9#sec_distributed_clocks_relying) 中更详细地讨论这一点。
* 如果两个写入并发发生,其中一个可能首先在一个副本上处理,另一个可能首先在另一个副本上处理。这导致冲突,类似于我们在多主复制中看到的(见 ["处理写入冲突"](/ch6#sec_replication_write_conflicts))。我们将在 ["检测并发写入"](/ch6#sec_replication_concurrent) 中回到这个主题。
* 如果数据库使用实时时钟的时间戳来确定哪个写入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一个具有更快时钟的节点已写入同一键,写入可能会被静默丢弃——我们之前在 ["最后写入胜利(丢弃并发写入)"](#sec_replication_lww) 中看到的问题。我们将在 ["依赖同步时钟"](/ch9#sec_distributed_clocks_relying) 中更详细地讨论这一点。
* 如果两个写入并发发生,其中一个可能首先在一个副本上处理,另一个可能首先在另一个副本上处理。这导致冲突,类似于我们在多主复制中看到的(见 ["处理写入冲突"](#sec_replication_write_conflicts))。我们将在 ["检测并发写入"](#sec_replication_concurrent) 中回到这个主题。
因此尽管仲裁似乎保证读取返回最新写入的值但实际上并不那么简单。Dynamo 风格的数据库通常针对可以容忍最终一致性的用例进行了优化。参数 *w**r* 允许你调整读取陈旧值的概率 [^53],但明智的做法是不要将它们视为绝对保证。
@ -626,7 +632,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
### 单主与无主复制的性能 {#sec_replication_leaderless_perf}
基于单个领导者的复制系统可以提供在无主系统中难以或不可能实现的强一致性保证。然而,正如我们在 ["复制延迟的问题"](/ch6#sec_replication_lag) 中看到的,如果你在异步更新的追随者上进行读取,基于领导者的复制系统中的读取也可能返回陈旧值。
基于单个领导者的复制系统可以提供在无主系统中难以或不可能实现的强一致性保证。然而,正如我们在 ["复制延迟的问题"](#sec_replication_lag) 中看到的,如果你在异步更新的追随者上进行读取,基于领导者的复制系统中的读取也可能返回陈旧值。
从领导者读取确保最新响应,但它存在性能问题:
@ -648,7 +654,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32]
#### 多地区操作 {#multi-region-operation}
我们之前讨论了跨地区复制作为多主复制的用例(见 ["多主复制"](/ch6#sec_replication_multi_leader))。无主复制也适合多地区操作,因为它被设计为容忍冲突的并发写入、网络中断和延迟峰值。
我们之前讨论了跨地区复制作为多主复制的用例(见 ["多主复制"](#sec_replication_multi_leader))。无主复制也适合多地区操作,因为它被设计为容忍冲突的并发写入、网络中断和延迟峰值。
Cassandra 和 ScyllaDB 在正常的无主模型中实现了它们的多地区支持:客户端直接将其写入发送到所有地区的副本,你可以从各种一致性级别中进行选择,这些级别确定请求成功所需的响应数。例如,你可以请求所有地区中副本的仲裁、每个地区中的单独仲裁,或仅客户端本地地区的仲裁。本地仲裁避免了必须等待到其他地区的缓慢请求,但它也更可能返回陈旧结果。
@ -659,7 +665,7 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本
与多主复制一样,无主数据库允许对同一键进行并发写入,导致需要解决的冲突。此类冲突可能在写入发生时发生,但并非总是如此:它们也可能在读修复、提示移交或反熵期间稍后检测到。
问题在于,由于可变的网络延迟和部分故障,事件可能以不同的顺序到达不同的节点。例如,[图 6-14](/ch6#fig_replication_concurrency) 显示了两个客户端 A 和 B 同时写入三节点数据存储中的键 *X*
问题在于,由于可变的网络延迟和部分故障,事件可能以不同的顺序到达不同的节点。例如,[图 6-14](#fig_replication_concurrency) 显示了两个客户端 A 和 B 同时写入三节点数据存储中的键 *X*
* 节点 1 接收来自 A 的写入,但由于瞬时中断从未接收来自 B 的写入。
* 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
@ -667,9 +673,9 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本
{{< figure src="/fig/ddia_0614.png" id="fig_replication_concurrency" caption="图 6-14. Dynamo 风格数据存储中的并发写入:没有明确定义的顺序。" class="w-full my-4" >}}
如果每个节点在接收到来自客户端的写入请求时只是覆盖键的值,节点将变得永久不一致,如 [图 6-14](/ch6#fig_replication_concurrency) 中的最终 *get* 请求所示:节点 2 认为 *X* 的最终值是 B而其他节点认为值是 A。
如果每个节点在接收到来自客户端的写入请求时只是覆盖键的值,节点将变得永久不一致,如 [图 6-14](#fig_replication_concurrency) 中的最终 *get* 请求所示:节点 2 认为 *X* 的最终值是 B而其他节点认为值是 A。
为了最终保持一致,副本应该收敛到相同的值。为此,我们可以使用我们之前在 ["处理写入冲突"](/ch6#sec_replication_write_conflicts) 中讨论的任何冲突解决机制,例如最后写入胜利(由 Cassandra 和 ScyllaDB 使用)、手动解决或 CRDT在 ["CRDT 与操作变换"](/ch6#sec_replication_crdts) 中描述,并由 Riak 使用)。
为了最终保持一致,副本应该收敛到相同的值。为此,我们可以使用我们之前在 ["处理写入冲突"](#sec_replication_write_conflicts) 中讨论的任何冲突解决机制,例如最后写入胜利(由 Cassandra 和 ScyllaDB 使用)、手动解决或 CRDT在 ["CRDT 与操作变换"](#sec_replication_crdts) 中描述,并由 Riak 使用)。
最后写入胜利很容易实现:每个写入都标有时间戳,具有更高时间戳的值总是覆盖具有较低时间戳的值。然而,时间戳不会告诉你两个值是否实际上冲突(即,它们是并发写入的)或不冲突(它们是一个接一个写入的)。如果你想显式解决冲突,系统需要更加小心地检测并发写入。
@ -677,8 +683,8 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本
我们如何决定两个操作是否并发?为了培养直觉,让我们看一些例子:
* 在 [图 6-8](/ch6#fig_replication_causality) 中两个写入不是并发的A 的插入 **先发生于** B 的递增,因为 B 递增的值是 A 插入的值。换句话说B 的操作建立在 A 的操作之上,所以 B 的操作必须稍后发生。我们也说 B **因果依赖** 于 A。
* 另一方面,[图 6-14](/ch6#fig_replication_concurrency) 中的两个写入是并发的:当每个客户端开始操作时,它不知道另一个客户端也在对同一键执行操作。因此,操作之间没有因果依赖关系。
* 在 [图 6-8](#fig_replication_causality) 中两个写入不是并发的A 的插入 **先发生于** B 的递增,因为 B 递增的值是 A 插入的值。换句话说B 的操作建立在 A 的操作之上,所以 B 的操作必须稍后发生。我们也说 B **因果依赖** 于 A。
* 另一方面,[图 6-14](#fig_replication_concurrency) 中的两个写入是并发的:当每个客户端开始操作时,它不知道另一个客户端也在对同一键执行操作。因此,操作之间没有因果依赖关系。
如果操作 B 知道 A或依赖于 A或以某种方式建立在 A 之上,则操作 A **先发生于** 另一个操作 B。一个操作是否先发生于另一个操作是定义并发含义的关键。事实上我们可以简单地说如果两个操作都不先发生于另一个两者都不知道另一个则它们是 **并发的** [^57]。
@ -700,7 +706,7 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本
让我们看一个确定两个操作是否并发或一个先发生于另一个的算法。为了简单起见,让我们从只有一个副本的数据库开始。一旦我们弄清楚如何在单个副本上执行此操作,我们就可以将该方法推广到具有多个副本的无主数据库。
[图 6-15](/ch6#fig_replication_causality_single) 显示了两个客户端并发地向同一购物车添加项目。(如果这个例子让你觉得太无聊,想象一下两个空中交通管制员并发地向他们正在跟踪的扇区添加飞机。)最初,购物车是空的。两个客户端总共向数据库发起了五次写入:
[图 6-15](#fig_replication_causality_single) 显示了两个客户端并发地向同一购物车添加项目。(如果这个例子让你觉得太无聊,想象一下两个空中交通管制员并发地向他们正在跟踪的扇区添加飞机。)最初,购物车是空的。两个客户端总共向数据库发起了五次写入:
1. 客户端 1 将 `milk` 添加到购物车。这是对该键的第一次写入,因此服务器成功存储它并为其分配版本 1。服务器还将值连同版本号一起回显给客户端。
2. 客户端 2 将 `eggs` 添加到购物车,不知道客户端 1 并发地添加了 `milk`(客户端 2 认为它的 `eggs` 是购物车中的唯一项目)。服务器为此写入分配版本 2并将 `eggs``milk` 存储为两个单独的值(兄弟)。然后,它将 **两个** 值连同版本号 2 一起返回给客户端。
@ -711,7 +717,7 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本
{{< figure src="/fig/ddia_0615.png" id="fig_replication_causality_single" caption="图 6-15. 捕获两个客户端并发编辑购物车之间的因果依赖关系。" class="w-full my-4" >}}
[图 6-15](/ch6#fig_replication_causality_single) 中操作之间的数据流在 [图 6-16](/ch6#fig_replication_causal_dependencies) 中以图形方式说明。箭头指示哪个操作 **先发生于** 哪个其他操作,即后面的操作 **知道****依赖于** 前面的操作。在这个例子中,客户端从未完全了解服务器上的数据,因为总是有另一个并发进行的操作。但是值的旧版本最终会被覆盖,并且不会丢失任何写入。
[图 6-15](#fig_replication_causality_single) 中操作之间的数据流在 [图 6-16](#fig_replication_causal_dependencies) 中以图形方式说明。箭头指示哪个操作 **先发生于** 哪个其他操作,即后面的操作 **知道****依赖于** 前面的操作。在这个例子中,客户端从未完全了解服务器上的数据,因为总是有另一个并发进行的操作。但是值的旧版本最终会被覆盖,并且不会丢失任何写入。
{{< figure link="#fig_replication_causality_single" src="/fig/ddia_0616.png" id="fig_replication_causal_dependencies" caption="图 6-16. 图 6-15 中因果依赖关系的图。" class="w-full my-4" >}}
@ -727,13 +733,13 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本
#### 版本向量 {#version-vectors}
[图 6-15](/ch6#fig_replication_causality_single) 中的示例只使用了单个副本。当存在多个副本、且没有领导者时,算法如何变化?
[图 6-15](#fig_replication_causality_single) 中的示例只使用了单个副本。当存在多个副本、且没有领导者时,算法如何变化?
[图 6-15](/ch6#fig_replication_causality_single) 使用单个版本号来捕获操作间依赖关系,但当多个副本并发接受写入时,这还不够。我们需要为 **每个副本**、每个键分别维护版本号。每个副本在处理写入时递增自己的版本号,并追踪从其他副本看到的版本号。这些信息决定了哪些值该被覆盖,哪些值要作为兄弟保留。
[图 6-15](#fig_replication_causality_single) 使用单个版本号来捕获操作间依赖关系,但当多个副本并发接受写入时,这还不够。我们需要为 **每个副本**、每个键分别维护版本号。每个副本在处理写入时递增自己的版本号,并追踪从其他副本看到的版本号。这些信息决定了哪些值该被覆盖,哪些值要作为兄弟保留。
来自所有副本的版本号集合称为 **版本向量** [^58]。这一思想有若干变体,其中较有代表性的是 **点版本向量** [^59] [^60]Riak 2.0 使用了它 [^61] [^62]。这里不展开细节,它的工作方式与前面的购物车示例非常相似。
和 [图 6-15](/ch6#fig_replication_causality_single) 里的版本号一样版本向量会在读取时由数据库副本返回给客户端并在后续写入时再由客户端带回数据库。Riak 把版本向量编码成一个字符串,称为 **因果上下文**。)版本向量让数据库能够区分“覆盖写入”和“并发写入”。
和 [图 6-15](#fig_replication_causality_single) 里的版本号一样版本向量会在读取时由数据库副本返回给客户端并在后续写入时再由客户端带回数据库。Riak 把版本向量编码成一个字符串,称为 **因果上下文**。)版本向量让数据库能够区分“覆盖写入”和“并发写入”。
版本向量还保证了“从一个副本读取,再写回另一个副本”是安全的。这样做可能会产生兄弟,但只要正确合并兄弟,就不会丢失数据。

View file

@ -21,7 +21,7 @@ breadcrumbs: false
分片通常与复制结合使用,以便每个分片的副本存储在多个节点上。这意味着,即使每条记录属于恰好一个分片,它仍然可以存储在多个不同的节点上以提供容错能力。
一个节点可能存储多个分片。例如,如果使用单领导者复制模型,分片与复制的组合可能如 [图 7-1](/ch7#fig_sharding_replicas) 所示。每个分片的领导者被分配到一个节点,追随者被分配到其他节点。每个节点可能是某些分片的领导者,同时又是其他分片的追随者,但每个分片仍然只有一个领导者。
一个节点可能存储多个分片。例如,如果使用单领导者复制模型,分片与复制的组合可能如 [图 7-1](#fig_sharding_replicas) 所示。每个分片的领导者被分配到一个节点,追随者被分配到其他节点。每个节点可能是某些分片的领导者,同时又是其他分片的追随者,但每个分片仍然只有一个领导者。
{{< figure src="/fig/ddia_0701.png" id="fig_sharding_replicas" caption="图 7-1. 复制与分片结合使用:每个节点对某些分片充当领导者,对另一些分片充当追随者。" class="w-full my-4" >}}
@ -51,7 +51,7 @@ breadcrumbs: false
推荐这样做的原因是分片通常会增加复杂性:你通常必须通过选择 *分区键* 来决定将哪些记录放在哪个分片中;具有相同分区键的所有记录都放在同一个分片中 [^4]。这个选择很重要,因为如果你知道记录在哪个分片中,访问记录会很快,但如果你不知道分片,你必须在所有分片中进行低效的搜索,而且分片方案很难更改。
因此,分片通常适用于键值数据,你可以轻松地按键进行分片,但对于关系数据则较难,因为你可能想要通过二级索引搜索,或连接可能分布在不同分片中的记录。我们将在 ["分片与二级索引"](/ch7#sec_sharding_secondary_indexes) 中进一步讨论这个问题。
因此,分片通常适用于键值数据,你可以轻松地按键进行分片,但对于关系数据则较难,因为你可能想要通过二级索引搜索,或连接可能分布在不同分片中的记录。我们将在 ["分片与二级索引"](#sec_sharding_secondary_indexes) 中进一步讨论这个问题。
分片的另一个问题是写入可能需要更新多个不同分片中的相关记录。虽然单节点上的事务相当常见(见 [第 8 章](/ch8#ch_transactions)),但确保跨多个分片的一致性需要 *分布式事务*。正如我们将在 [第 8 章](/ch8#ch_transactions) 中看到的,分布式事务在某些数据库中可用,但它们通常比单节点事务慢得多,可能成为整个系统的瓶颈,有些系统根本不支持它们。
@ -105,11 +105,11 @@ breadcrumbs: false
### 按键的范围分片 {#sec_sharding_key_range}
一种分片方法是为每个分片分配一个连续的分区键范围(从某个最小值到某个最大值),就像纸质百科全书的卷一样,如 [图 7-2](/ch7#fig_sharding_encyclopedia) 所示。在这个例子中,条目的分区键是其标题。如果你想查找特定标题的条目,你可以通过找到键范围包含你要查找标题的卷来轻松确定哪个分片包含该条目,从而从书架上挑选正确的书。
一种分片方法是为每个分片分配一个连续的分区键范围(从某个最小值到某个最大值),就像纸质百科全书的卷一样,如 [图 7-2](#fig_sharding_encyclopedia) 所示。在这个例子中,条目的分区键是其标题。如果你想查找特定标题的条目,你可以通过找到键范围包含你要查找标题的卷来轻松确定哪个分片包含该条目,从而从书架上挑选正确的书。
{{< figure src="/fig/ddia_0702.png" id="fig_sharding_encyclopedia" caption="图 7-2. 印刷版百科全书按键范围分片。" class="w-full my-4" >}}
键的范围不一定是均匀分布的,因为你的数据可能不是均匀分布的。例如,在 [图 7-2](/ch7#fig_sharding_encyclopedia) 中,第 1 卷包含以 A 和 B 开头的单词,但第 12 卷包含以 T、U、V、W、X、Y 和 Z 开头的单词。简单地为字母表的每两个字母分配一卷会导致某些卷比其他卷大得多。为了均匀分布数据,分片边界需要适应数据。
键的范围不一定是均匀分布的,因为你的数据可能不是均匀分布的。例如,在 [图 7-2](#fig_sharding_encyclopedia) 中,第 1 卷包含以 A 和 B 开头的单词,但第 12 卷包含以 T、U、V、W、X、Y 和 Z 开头的单词。简单地为字母表的每两个字母分配一卷会导致某些卷比其他卷大得多。为了均匀分布数据,分片边界需要适应数据。
分片边界可能由管理员手动选择,或者数据库可以自动选择它们。手动键范围分片例如被 VitessMySQL 的分片层)使用;自动变体被 Bigtable、其开源等价物 HBase、MongoDB 中基于范围的分片选项、CockroachDB、RethinkDB 和 FoundationDB 使用 [^6]。YugabyteDB 提供手动和自动表块分割两种选项。
@ -146,7 +146,7 @@ breadcrumbs: false
一旦你对键进行了哈希,如何选择将其存储在哪个分片中?也许你的第一个想法是取哈希值 *模* 系统中的节点数(在许多编程语言中使用 `%` 运算符)。例如,*hash*(*key*) % 10 将返回 0 到 9 之间的数字如果我们将哈希写为十进制数hash % 10 将是最后一位数字)。如果我们有 10 个节点,编号从 0 到 9这似乎是将每个键分配给节点的简单方法。
*mod N* 方法的问题是,如果节点数 *N* 发生变化,大多数键必须从一个节点移动到另一个节点。[图 7-3](/ch7#fig_sharding_hash_mod_n) 显示了当你有三个节点并添加第四个节点时会发生什么。在再平衡之前,节点 0 存储哈希值为 0、3、6、9 等的键。添加第四个节点后,哈希值为 3 的键已移动到节点 3哈希值为 6 的键已移动到节点 2哈希值为 9 的键已移动到节点 1依此类推。
*mod N* 方法的问题是,如果节点数 *N* 发生变化,大多数键必须从一个节点移动到另一个节点。[图 7-3](#fig_sharding_hash_mod_n) 显示了当你有三个节点并添加第四个节点时会发生什么。在再平衡之前,节点 0 存储哈希值为 0、3、6、9 等的键。添加第四个节点后,哈希值为 3 的键已移动到节点 3哈希值为 6 的键已移动到节点 2哈希值为 9 的键已移动到节点 1依此类推。
{{< figure src="/fig/ddia_0703.png" id="fig_sharding_hash_mod_n" caption="图 7-3. 通过对键进行哈希并取模节点数来将键分配给节点。更改节点数会导致许多键从一个节点移动到另一个节点。" class="w-full my-4" >}}
@ -156,7 +156,7 @@ breadcrumbs: false
一个简单但广泛使用的解决方案是创建比节点多得多的分片,并为每个节点分配多个分片。例如,在 10 个节点的集群上运行的数据库可能从一开始就被分成 1,000 个分片,以便每个节点分配 100 个分片。然后将键存储在分片号 *hash*(*key*) % 1,000 中,系统单独跟踪哪个分片存储在哪个节点上。
现在,如果向集群添加一个节点,系统可以从现有节点重新分配一些分片到新节点,直到它们再次公平分布。这个过程在 [图 7-4](/ch7#fig_sharding_rebalance_fixed) 中说明。如果从集群中删除节点,则反向发生相同的事情。
现在,如果向集群添加一个节点,系统可以从现有节点重新分配一些分片到新节点,直到它们再次公平分布。这个过程在 [图 7-4](#fig_sharding_rebalance_fixed) 中说明。如果从集群中删除节点,则反向发生相同的事情。
{{< figure src="/fig/ddia_0704.png" id="fig_sharding_rebalance_fixed" caption="图 7-4. 向每个节点有多个分片的数据库集群添加新节点。" class="w-full my-4" >}}
@ -174,7 +174,7 @@ breadcrumbs: false
如果无法提前预测所需的分片数量,最好使用一种方案,其中分片数量可以轻松适应工作负载。前面提到的键范围分片方案具有这个属性,但当有大量对相邻键的写入时,它有热点的风险。一种解决方案是将键范围分片与哈希函数结合,使每个分片包含 *哈希值* 的范围而不是 *键* 的范围。
[图 7-5](/ch7#fig_sharding_hash_range) 显示了使用 16 位哈希函数的示例,该函数返回 0 到 65,535 = 2¹⁶ 1 之间的数字(实际上,哈希通常是 32 位或更多)。即使输入键非常相似(例如,连续的时间戳),它们的哈希值也会在该范围内均匀分布。然后我们可以为每个分片分配一个哈希值范围:例如,值 0 到 16,383 分配给分片 0值 16,384 到 32,767 分配给分片 1依此类推。
[图 7-5](#fig_sharding_hash_range) 显示了使用 16 位哈希函数的示例,该函数返回 0 到 65,535 = 2¹⁶ 1 之间的数字(实际上,哈希通常是 32 位或更多)。即使输入键非常相似(例如,连续的时间戳),它们的哈希值也会在该范围内均匀分布。然后我们可以为每个分片分配一个哈希值范围:例如,值 0 到 16,383 分配给分片 0值 16,384 到 32,767 分配给分片 1依此类推。
{{< figure src="/fig/ddia_0705.png" id="fig_sharding_hash_range" caption="图 7-5. 为每个分片分配连续的哈希值范围。" class="w-full my-4" >}}
@ -190,11 +190,11 @@ breadcrumbs: false
--------
哈希范围分片被 YugabyteDB 和 DynamoDB 使用 [^17],并且是 MongoDB 中的一个选项。Cassandra 和 ScyllaDB 使用这种方法的一个变体,如 [图 7-6](/ch7#fig_sharding_cassandra) 所示:哈希值空间被分割成与节点数成比例的范围数([图 7-6](/ch7#fig_sharding_cassandra) 中每个节点 3 个范围,但实际数字在 Cassandra 中默认为每个节点 8 个,在 ScyllaDB 中为每个节点 256 个),这些范围之间有随机边界。这意味着某些范围比其他范围大,但通过每个节点有多个范围,这些不平衡倾向于平均化 [^15] [^18]。
哈希范围分片被 YugabyteDB 和 DynamoDB 使用 [^17],并且是 MongoDB 中的一个选项。Cassandra 和 ScyllaDB 使用这种方法的一个变体,如 [图 7-6](#fig_sharding_cassandra) 所示:哈希值空间被分割成与节点数成比例的范围数([图 7-6](#fig_sharding_cassandra) 中每个节点 3 个范围,但实际数字在 Cassandra 中默认为每个节点 8 个,在 ScyllaDB 中为每个节点 256 个),这些范围之间有随机边界。这意味着某些范围比其他范围大,但通过每个节点有多个范围,这些不平衡倾向于平均化 [^15] [^18]。
{{< figure src="/fig/ddia_0706.png" id="fig_sharding_cassandra" caption="图 7-6. Cassandra 和 ScyllaDB 将可能的哈希值范围(这里是 0-1023分割成具有随机边界的连续范围并为每个节点分配多个范围。" class="w-full my-4" >}}
当添加或删除节点时,会添加和删除范围边界,并相应地分割或合并分片 [^19]。在 [图 7-6](/ch7#fig_sharding_cassandra) 的示例中,当添加节点 3 时,节点 1 将其两个范围的部分转移到节点 3节点 2 将其一个范围的部分转移到节点 3。这样做的效果是给新节点一个大致公平的数据集份额而不会在节点之间传输超过必要的数据。
当添加或删除节点时,会添加和删除范围边界,并相应地分割或合并分片 [^19]。在 [图 7-6](#fig_sharding_cassandra) 的示例中,当添加节点 3 时,节点 1 将其两个范围的部分转移到节点 3节点 2 将其一个范围的部分转移到节点 3。这样做的效果是给新节点一个大致公平的数据集份额而不会在节点之间传输超过必要的数据。
#### 一致性哈希 {#sec_sharding_consistent_hashing}
@ -245,7 +245,7 @@ Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定
我们称这个问题为 *请求路由*,它与 *服务发现* 非常相似,我们之前在 ["负载均衡器、服务发现和服务网格"](/ch5#sec_encoding_service_discovery) 中讨论过。两者之间最大的区别是,对于运行应用程序代码的服务,每个实例通常是无状态的,负载均衡器可以将请求发送到任何实例。对于分片数据库,对键的请求只能由包含该键的分片的副本节点处理。
这意味着请求路由必须知道键到分片的分配,以及分片到节点的分配。在高层次上,这个问题有几种不同的方法(在 [图 7-7](/ch7#fig_sharding_routing) 中说明):
这意味着请求路由必须知道键到分片的分配,以及分片到节点的分配。在高层次上,这个问题有几种不同的方法(在 [图 7-7](#fig_sharding_routing) 中说明):
1. 允许客户端连接任何节点(例如,通过循环负载均衡器)。如果该节点恰好拥有请求适用的分片,它可以直接处理请求;否则,它将请求转发到适当的节点,接收回复,并将回复传递给客户端。
2. 首先将客户端的所有请求发送到路由层,该层确定应该处理每个请求的节点并相应地转发它。这个路由层本身不处理任何请求;它只充当分片感知的负载均衡器。
@ -259,7 +259,7 @@ Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定
* 执行路由的组件(可能是节点之一、路由层或客户端)如何了解分片到节点分配的变化?
* 当分片从一个节点移动到另一个节点时,有一个切换期,在此期间新节点已接管,但对旧节点的请求可能仍在传输中。如何处理这些?
许多分布式数据系统依赖于单独的协调服务(如 ZooKeeper 或 etcd来跟踪分片分配如 [图 7-8](/ch7#fig_sharding_zookeeper) 所示。它们使用共识算法(见 [第 10 章](/ch10#ch_consistency))来提供容错和防止脑裂。每个节点在 ZooKeeper 中注册自己ZooKeeper 维护分片到节点的权威映射。其他参与者,如路由层或分片感知客户端,可以在 ZooKeeper 中订阅此信息。每当分片所有权发生变化或者添加或删除节点时ZooKeeper 都会通知路由层,以便它可以保持其路由信息最新。
许多分布式数据系统依赖于单独的协调服务(如 ZooKeeper 或 etcd来跟踪分片分配如 [图 7-8](#fig_sharding_zookeeper) 所示。它们使用共识算法(见 [第 10 章](/ch10#ch_consistency))来提供容错和防止脑裂。每个节点在 ZooKeeper 中注册自己ZooKeeper 维护分片到节点的权威映射。其他参与者,如路由层或分片感知客户端,可以在 ZooKeeper 中订阅此信息。每当分片所有权发生变化或者添加或删除节点时ZooKeeper 都会通知路由层,以便它可以保持其路由信息最新。
{{< figure src="/fig/ddia_0708.png" id="fig_sharding_zookeeper" caption="图 7-8. 使用 ZooKeeper 跟踪分片到节点的分配。" class="w-full my-4" >}}
@ -281,7 +281,7 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使
### 本地二级索引 {#id166}
例如,假设你正在运营一个出售二手车的网站(如 [图 7-9](/ch7#fig_sharding_local_secondary) 所示)。每个列表都有一个唯一的 ID——称之为文档 ID——你使用该 ID 作为分区键对数据库进行分片例如ID 0 到 499 在分片 0 中ID 500 到 999 在分片 1 中,等等)。
例如,假设你正在运营一个出售二手车的网站(如 [图 7-9](#fig_sharding_local_secondary) 所示)。每个列表都有一个唯一的 ID——称之为文档 ID——你使用该 ID 作为分区键对数据库进行分片例如ID 0 到 499 在分片 0 中ID 500 到 999 在分片 1 中,等等)。
如果你想让用户搜索汽车,允许他们按颜色和制造商过滤,你需要在 `color``make` 上建立二级索引(在文档数据库中这些是字段;在关系数据库中这些是列)。如果你已声明索引,数据库就可以自动维护索引。例如,每当一辆红色汽车被写入数据库,所在分片会自动将其 ID 加入索引条目 `color:red` 对应的文档 ID 列表。正如 [第 4 章](/ch4#ch_storage) 所述,这个 ID 列表也称为 *倒排列表*
@ -297,7 +297,7 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使
当从本地二级索引读取时,如果你已经知道你正在查找的记录的分区键,你可以只在适当的分片上执行搜索。此外,如果你只想要 *一些* 结果,而不需要全部,你可以将请求发送到任何分片。
但是,如果你想要所有结果并且事先不知道它们的分区键,你需要将查询发送到所有分片,并组合你收到的结果,因为匹配的记录可能分散在所有分片中。在 [图 7-9](/ch7#fig_sharding_local_secondary) 中,红色汽车出现在分片 0 和分片 1 中。
但是,如果你想要所有结果并且事先不知道它们的分区键,你需要将查询发送到所有分片,并组合你收到的结果,因为匹配的记录可能分散在所有分片中。在 [图 7-9](#fig_sharding_local_secondary) 中,红色汽车出现在分片 0 和分片 1 中。
这种查询分片数据库的方法有时称为 *分散/收集*scatter/gather它可能使二级索引读取变得相当昂贵。即使并行查询各分片分散/收集也容易导致尾部延迟放大(见 ["响应时间指标的使用"](/ch2#sec_introduction_slo_sla))。它还会限制应用的可伸缩性:增加分片可以提升可存储数据量,但若每个查询仍需所有分片参与,查询吞吐量并不会随分片数增加而提升。
@ -307,13 +307,13 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使
我们可以构建一个覆盖所有分片数据的 *全局索引*,而不是每个分片有自己的本地二级索引。但是,我们不能只将该索引存储在一个节点上,因为它可能会成为瓶颈并违背分片的目的。全局索引也必须进行分片,但它可以以不同于主键索引的方式进行分片。
[图 7-10](/ch7#fig_sharding_global_secondary) 说明了这可能是什么样子:来自所有分片的红色汽车的 ID 出现在索引的 `color:red` 下,但索引是分片的,以便以字母 *a**r* 开头的颜色出现在分片 0 中,以 *s**z* 开头的颜色出现在分片 1 中。汽车制造商的索引也类似地分区(分片边界在 *f**h* 之间)。
[图 7-10](#fig_sharding_global_secondary) 说明了这可能是什么样子:来自所有分片的红色汽车的 ID 出现在索引的 `color:red` 下,但索引是分片的,以便以字母 *a**r* 开头的颜色出现在分片 0 中,以 *s**z* 开头的颜色出现在分片 1 中。汽车制造商的索引也类似地分区(分片边界在 *f**h* 之间)。
{{< figure src="/fig/ddia_0710.png" id="fig_sharding_global_secondary" caption="图 7-10. 全局二级索引反映来自所有分片的数据,并且本身按索引值进行分片。" class="w-full my-4" >}}
这种索引也称为 *基于词项分区* [^30]:回忆一下 ["全文检索"](/ch4#sec_storage_full_text),在全文检索中,*词项* 是你可以搜索的文本中的关键字。这里我们将其推广为指二级索引中你可以搜索的任何值。
全局索引使用词项作为分区键,因此当你查找特定词项或值时,你可以找出需要查询哪个分片。和以前一样,分片可以包含连续的词项范围(如 [图 7-10](/ch7#fig_sharding_global_secondary)),或者你可以基于词项的哈希将词项分配给分片。
全局索引使用词项作为分区键,因此当你查找特定词项或值时,你可以找出需要查询哪个分片。和以前一样,分片可以包含连续的词项范围(如 [图 7-10](#fig_sharding_global_secondary)),或者你可以基于词项的哈希将词项分配给分片。
全局索引的优点是,只有一个查询条件时(如 *color = red*),只需从一个分片读取即可获得倒排列表。但如果你不仅要 ID还要取回完整记录仍然必须去负责这些 ID 的各个分片读取。

View file

@ -34,7 +34,7 @@ breadcrumbs: false
在本章中,我们将研究许多可能出错的案例,并探索数据库用于防范这些问题的算法。我们将特别深入并发控制领域,讨论可能发生的各种竞态条件,以及数据库如何实现*读已提交*、*快照隔离*和*可串行化*等隔离级别。
并发控制对单节点和分布式数据库都很重要。在本章后面的["分布式事务"](/ch8#sec_transactions_distributed)部分,我们将研究*两阶段提交*协议和在分布式事务中实现原子性的挑战。
并发控制对单节点和分布式数据库都很重要。在本章后面的["分布式事务"](#sec_transactions_distributed)部分,我们将研究*两阶段提交*协议和在分布式事务中实现原子性的挑战。
## 事务到底是什么? {#sec_transactions_overview}
@ -60,7 +60,7 @@ breadcrumbs: false
一般来说,*原子*是指不能分解成更小部分的东西。这个词在计算机的不同分支中意味着相似但又微妙不同的东西。例如,在多线程编程中,如果一个线程执行原子操作,这意味着另一个线程无法看到该操作的半完成结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间。
相比之下,在 ACID 的上下文中,原子性*不是*关于并发的。它不描述如果几个进程试图同时访问相同的数据会发生什么,因为这包含在字母 *I**隔离性*)中(参见["隔离性"](/ch8#sec_transactions_acid_isolation))。
相比之下,在 ACID 的上下文中,原子性*不是*关于并发的。它不描述如果几个进程试图同时访问相同的数据会发生什么,因为这包含在字母 *I**隔离性*)中(参见["隔离性"](#sec_transactions_acid_isolation))。
相反ACID 原子性描述了当客户端想要进行多次写入,但在某些写入被处理后发生故障时会发生什么——例如,进程崩溃、网络连接中断、磁盘变满或违反了某些完整性约束。如果这些写入被分组到一个原子事务中,并且由于故障无法完成(*提交*)事务,则事务被*中止*,数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。
@ -90,14 +90,14 @@ ACID 一致性的思想是,你对数据有某些陈述(*不变式*)必须
大多数数据库都会同时被多个客户端访问。如果它们读写数据库的不同部分,这没有问题,但如果它们访问相同的数据库记录,你可能会遇到并发问题(竞态条件)。
[图 8-1](/ch8#fig_transactions_increment) 是这种问题的一个简单例子。假设你有两个客户端同时递增存储在数据库中的计数器。每个客户端需要读取当前值,加 1然后写回新值假设数据库中没有内置的递增操作。在[图 8-1](/ch8#fig_transactions_increment) 中,计数器应该从 42 增加到 44因为发生了两次递增但实际上由于竞态条件只增加到 43。
[图 8-1](#fig_transactions_increment) 是这种问题的一个简单例子。假设你有两个客户端同时递增存储在数据库中的计数器。每个客户端需要读取当前值,加 1然后写回新值假设数据库中没有内置的递增操作。在[图 8-1](#fig_transactions_increment) 中,计数器应该从 42 增加到 44因为发生了两次递增但实际上由于竞态条件只增加到 43。
{{< figure src="/fig/ddia_0801.png" id="fig_transactions_increment" caption="图 8-1. 两个客户端并发递增计数器之间的竞态条件。" class="w-full my-4" >}}
ACID 意义上的*隔离性*意味着同时执行的事务彼此隔离:它们不能相互干扰。经典的数据库教科书将隔离性形式化为*可串行化*,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们*串行*运行(一个接一个)相同,即使实际上它们可能是并发运行的[^13]。
然而,可串行化有性能成本。在实践中,许多数据库使用比可串行化更弱的隔离形式:也就是说,它们允许并发事务以有限的方式相互干扰。一些流行的数据库,如 Oracle甚至没有实现它Oracle 有一个称为"可串行化"的隔离级别,但它实际上实现了*快照隔离*,这是比可串行化更弱的保证[^10] [^14])。这意味着某些类型的竞态条件仍然可能发生。我们将在["弱隔离级别"](/ch8#sec_transactions_isolation_levels)中探讨快照隔离和其他形式的隔离。
然而,可串行化有性能成本。在实践中,许多数据库使用比可串行化更弱的隔离形式:也就是说,它们允许并发事务以有限的方式相互干扰。一些流行的数据库,如 Oracle甚至没有实现它Oracle 有一个称为"可串行化"的隔离级别,但它实际上实现了*快照隔离*,这是比可串行化更弱的保证[^10] [^14])。这意味着某些类型的竞态条件仍然可能发生。我们将在["弱隔离级别"](#sec_transactions_isolation_levels)中探讨快照隔离和其他形式的隔离。
#### 持久性 {#durability}
@ -140,7 +140,7 @@ ACID 意义上的*隔离性*意味着同时执行的事务彼此隔离:它们
隔离性
: 并发运行的事务不应该相互干扰。例如,如果一个事务进行多次写入,那么另一个事务应该看到所有或不看到这些写入,但不是某些子集。
这些定义假设你想要同时修改多个对象(行、文档、记录)。这种*多对象事务*通常需要保持多块数据同步。[图 8-2](/ch8#fig_transactions_read_uncommitted) 显示了一个来自电子邮件应用程序的示例。要显示用户的未读消息数,你可以查询类似这样的内容:
这些定义假设你想要同时修改多个对象(行、文档、记录)。这种*多对象事务*通常需要保持多块数据同步。[图 8-2](#fig_transactions_read_uncommitted) 显示了一个来自电子邮件应用程序的示例。要显示用户的未读消息数,你可以查询类似这样的内容:
```
SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
@ -151,9 +151,9 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
然而,如果有很多电子邮件,你可能会发现这个查询太慢,并决定将未读消息的数量存储在一个单独的字段中(一种反规范化,我们在["规范化、反规范化和连接"](/ch3#sec_datamodels_normalization)中讨论)。现在,每当有新消息进来时,你必须增加未读计数器,每当消息被标记为已读时,你也必须减少未读计数器。
在[图 8-2](/ch8#fig_transactions_read_uncommitted) 中,用户 2 遇到了异常:邮箱列表显示有未读消息,但计数器显示零未读消息,因为计数器增量尚未发生。(如果电子邮件应用程序中的错误计数器看起来太微不足道,请考虑客户账户余额而不是未读计数器,以及支付事务而不是电子邮件。)隔离本可以通过确保用户 2 看到插入的电子邮件和更新的计数器,或者两者都不看到,但不是不一致的中间点,来防止这个问题。
在[图 8-2](#fig_transactions_read_uncommitted) 中,用户 2 遇到了异常:邮箱列表显示有未读消息,但计数器显示零未读消息,因为计数器增量尚未发生。(如果电子邮件应用程序中的错误计数器看起来太微不足道,请考虑客户账户余额而不是未读计数器,以及支付事务而不是电子邮件。)隔离本可以通过确保用户 2 看到插入的电子邮件和更新的计数器,或者两者都不看到,但不是不一致的中间点,来防止这个问题。
[图 8-3](/ch8#fig_transactions_atomicity) 说明了对原子性的需求:如果在事务过程中某处发生错误,邮箱的内容和未读计数器可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,插入的电子邮件将被回滚。
[图 8-3](#fig_transactions_atomicity) 说明了对原子性的需求:如果在事务过程中某处发生错误,邮箱的内容和未读计数器可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,插入的电子邮件将被回滚。
{{< figure src="/fig/ddia_0803.png" id="fig_transactions_atomicity" caption="图 8-3. 原子性确保如果发生错误,该事务的任何先前写入都会被撤消,以避免不一致的状态。" class="w-full my-4" >}}
@ -172,7 +172,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
这些问题会令人非常困惑,因此存储引擎几乎普遍的目标是在一个节点上的单个对象(如键值对)上提供原子性和隔离性。原子性可以使用日志实现崩溃恢复(参见["使 B 树可靠"](/ch4#sec_storage_btree_wal)),隔离性可以使用每个对象上的锁来实现(一次只允许一个线程访问对象)。
某些数据库还提供更复杂的原子操作,例如递增操作,它消除了像[图 8-1](/ch8#fig_transactions_increment) 中那样的读-修改-写循环的需求。类似流行的是*条件写入*操作,它允许仅在值未被其他人并发更改时才进行写入(参见["条件写入(比较并设置)"](/ch8#sec_transactions_compare_and_set)类似于共享内存并发中的比较并设置或比较并交换CAS操作。
某些数据库还提供更复杂的原子操作,例如递增操作,它消除了像[图 8-1](#fig_transactions_increment) 中那样的读-修改-写循环的需求。类似流行的是*条件写入*操作,它允许仅在值未被其他人并发更改时才进行写入(参见["条件写入(比较并设置)"](#sec_transactions_compare_and_set)类似于共享内存并发中的比较并设置或比较并交换CAS操作。
--------
@ -181,7 +181,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
--------
这些单对象操作很有用,因为它们可以防止多个客户端尝试同时写入同一对象时的丢失更新(参见["防止丢失更新"](/ch8#sec_transactions_lost_update)。然而它们不是通常意义上的事务。例如Cassandra 和 ScyllaDB 的"轻量级事务"功能以及 Aerospike 的"强一致性"模式在单个对象上提供线性一致(参见["线性一致性"](/ch10#sec_consistency_linearizability))读取和条件写入,但不保证跨多个对象。
这些单对象操作很有用,因为它们可以防止多个客户端尝试同时写入同一对象时的丢失更新(参见["防止丢失更新"](#sec_transactions_lost_update)。然而它们不是通常意义上的事务。例如Cassandra 和 ScyllaDB 的"轻量级事务"功能以及 Aerospike 的"强一致性"模式在单个对象上提供线性一致(参见["线性一致性"](/ch10#sec_consistency_linearizability))读取和条件写入,但不保证跨多个对象。
#### 多对象事务的需求 {#sec_transactions_need}
@ -190,10 +190,10 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
在某些用例中,单对象插入、更新和删除就足够了。然而,在许多其他情况下,需要协调对多个不同对象的写入:
* 在关系数据模型中,一个表中的行通常具有对另一个表中行的外键引用。类似地,在类似图的数据模型中,顶点具有指向其他顶点的边。多对象事务允许你确保这些引用保持有效:插入引用彼此的多个记录时,外键必须正确且最新,否则数据变得毫无意义。
* 在文档数据模型中,需要一起更新的字段通常在同一文档内,它被视为单个对象——更新单个文档时不需要多对象事务。然而,缺乏连接功能的文档数据库也鼓励反规范化(参见["何时使用哪种模型"](/ch3#sec_datamodels_document_summary))。当需要更新反规范化信息时,如[图 8-2](/ch8#fig_transactions_read_uncommitted) 的示例,你需要一次更新多个文档。事务在这种情况下非常有用,可以防止反规范化数据失去同步。
* 在文档数据模型中,需要一起更新的字段通常在同一文档内,它被视为单个对象——更新单个文档时不需要多对象事务。然而,缺乏连接功能的文档数据库也鼓励反规范化(参见["何时使用哪种模型"](/ch3#sec_datamodels_document_summary))。当需要更新反规范化信息时,如[图 8-2](#fig_transactions_read_uncommitted) 的示例,你需要一次更新多个文档。事务在这种情况下非常有用,可以防止反规范化数据失去同步。
* 在具有二级索引的数据库中(几乎除了纯键值存储之外的所有数据库),每次更改值时都需要更新索引。从事务的角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离,记录可能出现在一个索引中但不在另一个索引中,因为对第二个索引的更新尚未发生(参见["分片和二级索引"](/ch7#sec_sharding_secondary_indexes))。
这些应用程序仍然可以在没有事务的情况下实现。然而,没有原子性的错误处理变得更加复杂,缺乏隔离性可能导致并发问题。我们将在["弱隔离级别"](/ch8#sec_transactions_isolation_levels)中讨论这些问题,并在["派生数据与分布式事务"](/ch13#sec_future_derived_vs_transactions)中探索替代方法。
这些应用程序仍然可以在没有事务的情况下实现。然而,没有原子性的错误处理变得更加复杂,缺乏隔离性可能导致并发问题。我们将在["弱隔离级别"](#sec_transactions_isolation_levels)中讨论这些问题,并在["派生数据与分布式事务"](/ch13#sec_future_derived_vs_transactions)中探索替代方法。
#### 处理错误和中止 {#handling-errors-and-aborts}
@ -208,7 +208,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
* 如果事务实际上成功了,但在服务器尝试向客户端确认成功提交时网络中断(因此从客户端的角度来看超时),那么重试事务会导致它被执行两次——除非你有额外的应用程序级去重机制。
* 如果错误是由于过载或并发事务之间的高争用,重试事务会使问题变得更糟,而不是更好。为了避免这种反馈循环,你可以限制重试次数,使用指数退避,并以不同的方式处理与过载相关的错误与其他错误(参见["当过载系统无法恢复时"](/ch2#sidebar_metastable))。
* 仅在瞬态错误后重试才值得(例如,由于死锁、隔离违规、临时网络中断和故障转移);在永久错误后(例如,约束违规)重试将毫无意义。
* 如果事务在数据库之外也有副作用,即使事务被中止,这些副作用也可能发生。例如,如果你正在发送电子邮件,你不会希望每次重试事务时都再次发送电子邮件。如果你想确保几个不同的系统一起提交或中止,两阶段提交可以提供帮助(我们将在["两阶段提交2PC"](/ch8#sec_transactions_2pc)中讨论这个问题)。
* 如果事务在数据库之外也有副作用,即使事务被中止,这些副作用也可能发生。例如,如果你正在发送电子邮件,你不会希望每次重试事务时都再次发送电子邮件。如果你想确保几个不同的系统一起提交或中止,两阶段提交可以提供帮助(我们将在["两阶段提交2PC"](#sec_transactions_2pc)中讨论这个问题)。
* 如果客户端进程在重试时崩溃,它试图写入数据库的任何数据都会丢失。
@ -234,7 +234,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
这些例子还强调了一个重要观点:即使并发问题在正常操作中很少见,你也必须考虑攻击者故意向你的 API 发送大量高度并发请求以故意利用并发错误的可能性[^30]。因此,为了构建可靠和安全的应用程序,你必须确保系统地防止此类错误。
在本节中,我们将研究实践中使用的几种弱(非可串行化)隔离级别,并详细讨论哪些竞态条件可以发生和不能发生,以便你可以决定哪个级别适合你的应用程序。完成后,我们将详细讨论可串行化(参见["可串行化"](/ch8#sec_transactions_serializability))。我们对隔离级别的讨论将是非正式的,使用示例。如果你想要严格的定义和对其属性的分析,你可以在学术文献中找到它们[^36] [^37] [^38] [^39]。
在本节中,我们将研究实践中使用的几种弱(非可串行化)隔离级别,并详细讨论哪些竞态条件可以发生和不能发生,以便你可以决定哪个级别适合你的应用程序。完成后,我们将详细讨论可串行化(参见["可串行化"](#sec_transactions_serializability))。我们对隔离级别的讨论将是非正式的,使用示例。如果你想要严格的定义和对其属性的分析,你可以在学术文献中找到它们[^36] [^37] [^38] [^39]。
### 读已提交 {#sec_transactions_read_committed}
@ -249,14 +249,14 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
想象一个事务已经向数据库写入了一些数据,但事务尚未提交或中止。另一个事务能看到那个未提交的数据吗?如果能,这称为*脏读*[^3]。
在读已提交隔离级别下运行的事务必须防止脏读。这意味着事务的任何写入只有在该事务提交时才对其他人可见(然后它的所有写入立即变得可见)。这在[图 8-4](/ch8#fig_transactions_read_committed) 中说明,其中用户 1 已设置 *x* = 3但用户 2 的 *get x* 仍返回旧值 2因为用户 1 尚未提交。
在读已提交隔离级别下运行的事务必须防止脏读。这意味着事务的任何写入只有在该事务提交时才对其他人可见(然后它的所有写入立即变得可见)。这在[图 8-4](#fig_transactions_read_committed) 中说明,其中用户 1 已设置 *x* = 3但用户 2 的 *get x* 仍返回旧值 2因为用户 1 尚未提交。
{{< figure src="/fig/ddia_0804.png" id="fig_transactions_read_committed" caption="图 8-4. 没有脏读:用户 2 只有在用户 1 的事务提交后才能看到 x 的新值。" class="w-full my-4" >}}
有几个原因说明为什么防止脏读是有用的:
* 如果事务需要更新多行,脏读意味着另一个事务可能看到某些更新但不是其他更新。例如,在[图 8-2](/ch8#fig_transactions_read_uncommitted) 中,用户看到新的未读电子邮件但没有看到更新的计数器。这是电子邮件的脏读。看到数据库处于部分更新状态会让用户感到困惑,并可能导致其他事务做出错误的决定。
* 如果事务中止,它所做的任何写入都需要回滚(如[图 8-3](/ch8#fig_transactions_atomicity))。如果数据库允许脏读,这意味着事务可能看到后来被回滚的数据——即从未实际提交到数据库的数据。任何读取未提交数据的事务也需要被中止,导致称为*级联中止*的问题。
* 如果事务需要更新多行,脏读意味着另一个事务可能看到某些更新但不是其他更新。例如,在[图 8-2](#fig_transactions_read_uncommitted) 中,用户看到新的未读电子邮件但没有看到更新的计数器。这是电子邮件的脏读。看到数据库处于部分更新状态会让用户感到困惑,并可能导致其他事务做出错误的决定。
* 如果事务中止,它所做的任何写入都需要回滚(如[图 8-3](#fig_transactions_atomicity))。如果数据库允许脏读,这意味着事务可能看到后来被回滚的数据——即从未实际提交到数据库的数据。任何读取未提交数据的事务也需要被中止,导致称为*级联中止*的问题。
#### 没有脏写 {#sec_transactions_dirty_write}
@ -266,8 +266,8 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
通过防止脏写,这个隔离级别避免了某些类型的并发问题:
* 如果事务更新多行,脏写可能导致糟糕的结果。例如,考虑[图 8-5](/ch8#fig_transactions_dirty_writes),它说明了一个二手车销售网站,两个人 Aaliyah 和 Bryce 同时尝试购买同一辆车。购买汽车需要两次数据库写入:网站上的列表需要更新以反映买家,销售发票需要发送给买家。在[图 8-5](/ch8#fig_transactions_dirty_writes) 的情况下,销售被授予 Bryce因为他对 `listings` 表执行了获胜的更新),但发票被发送给 Aaliyah因为她对 `invoices` 表执行了获胜的更新)。读已提交防止了这种事故。
* 然而,读已提交*不*防止[图 8-1](/ch8#fig_transactions_increment) 中两个计数器递增之间的竞态条件。在这种情况下,第二个写入发生在第一个事务提交之后,所以它不是脏写。它仍然是不正确的,但原因不同——在["防止丢失更新"](/ch8#sec_transactions_lost_update)中,我们将讨论如何使此类计数器递增安全。
* 如果事务更新多行,脏写可能导致糟糕的结果。例如,考虑[图 8-5](#fig_transactions_dirty_writes),它说明了一个二手车销售网站,两个人 Aaliyah 和 Bryce 同时尝试购买同一辆车。购买汽车需要两次数据库写入:网站上的列表需要更新以反映买家,销售发票需要发送给买家。在[图 8-5](#fig_transactions_dirty_writes) 的情况下,销售被授予 Bryce因为他对 `listings` 表执行了获胜的更新),但发票被发送给 Aaliyah因为她对 `invoices` 表执行了获胜的更新)。读已提交防止了这种事故。
* 然而,读已提交*不*防止[图 8-1](#fig_transactions_increment) 中两个计数器递增之间的竞态条件。在这种情况下,第二个写入发生在第一个事务提交之后,所以它不是脏写。它仍然是不正确的,但原因不同——在["防止丢失更新"](#sec_transactions_lost_update)中,我们将讨论如何使此类计数器递增安全。
{{< figure src="/fig/ddia_0805.png" id="fig_transactions_dirty_writes" caption="图 8-5. 有了脏写,来自不同事务的冲突写入可能会混在一起。" class="w-full my-4" >}}
@ -284,13 +284,13 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
尽管如此,在某些数据库中使用锁来防止脏读,例如 IBM Db2 和 Microsoft SQL Server 在 `read_committed_snapshot=off` 设置中[^29]。
防止脏读的更常用方法是[图 8-4](/ch8#fig_transactions_read_committed) 中说明的方法:对于每个被写入的行,数据库记住旧的已提交值和当前持有写锁的事务设置的新值。当事务正在进行时,任何其他读取该行的事务都只是被给予旧值。只有当新值被提交时,事务才会切换到读取新值(有关更多详细信息,请参见["多版本并发控制MVCC"](/ch8#sec_transactions_snapshot_impl))。
防止脏读的更常用方法是[图 8-4](#fig_transactions_read_committed) 中说明的方法:对于每个被写入的行,数据库记住旧的已提交值和当前持有写锁的事务设置的新值。当事务正在进行时,任何其他读取该行的事务都只是被给予旧值。只有当新值被提交时,事务才会切换到读取新值(有关更多详细信息,请参见["多版本并发控制MVCC"](#sec_transactions_snapshot_impl))。
### 快照隔离与可重复读 {#sec_transactions_snapshot_isolation}
如果你肤浅地看待读已提交隔离,你可能会被原谅认为它做了事务需要做的一切:它允许中止(原子性所需),它防止读取事务的不完整结果,并且它防止并发写入混淆。确实,这些是有用的功能,比没有事务的系统能获得的保证要强得多。
然而,使用这个隔离级别时,仍然有很多方式可能出现并发错误。例如,[图 8-6](/ch8#fig_transactions_item_many_preceders) 说明了读已提交可能发生的问题。
然而,使用这个隔离级别时,仍然有很多方式可能出现并发错误。例如,[图 8-6](#fig_transactions_item_many_preceders) 说明了读已提交可能发生的问题。
{{< figure src="/fig/ddia_0806.png" id="fig_transactions_item_many_preceders" caption="图 8-6. 读取偏差Aaliyah 观察到数据库处于不一致状态。" class="w-full my-4" >}}
@ -322,18 +322,18 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
#### 多版本并发控制MVCC {#sec_transactions_snapshot_impl}
与读已提交隔离一样,快照隔离的实现通常使用写锁来防止脏写(参见["实现读已提交"](/ch8#sec_transactions_read_committed_impl)),这意味着进行写入的事务可以阻止写入同一行的另一个事务的进度。但是,读取不需要任何锁。从性能的角度来看,快照隔离的一个关键原则是*读者永远不会阻塞写者,写者永远不会阻塞读者*。这允许数据库在一致快照上处理长时间运行的读查询,同时正常处理写入,两者之间没有任何锁争用。
与读已提交隔离一样,快照隔离的实现通常使用写锁来防止脏写(参见["实现读已提交"](#sec_transactions_read_committed_impl)),这意味着进行写入的事务可以阻止写入同一行的另一个事务的进度。但是,读取不需要任何锁。从性能的角度来看,快照隔离的一个关键原则是*读者永远不会阻塞写者,写者永远不会阻塞读者*。这允许数据库在一致快照上处理长时间运行的读查询,同时正常处理写入,两者之间没有任何锁争用。
为了实现快照隔离,数据库使用了我们在[图 8-4](/ch8#fig_transactions_read_committed) 中看到的防止脏读机制的泛化。数据库必须潜在地保留每行的几个不同的已提交版本,而不是每行的两个版本(已提交版本和被覆盖但尚未提交的版本),因为各种正在进行的事务可能需要在不同时间点看到数据库的状态。因为它并排维护一行的多个版本,所以这种技术被称为*多版本并发控制*MVCC
为了实现快照隔离,数据库使用了我们在[图 8-4](#fig_transactions_read_committed) 中看到的防止脏读机制的泛化。数据库必须潜在地保留每行的几个不同的已提交版本,而不是每行的两个版本(已提交版本和被覆盖但尚未提交的版本),因为各种正在进行的事务可能需要在不同时间点看到数据库的状态。因为它并排维护一行的多个版本,所以这种技术被称为*多版本并发控制*MVCC
[图 8-7](/ch8#fig_transactions_mvcc) 说明了 PostgreSQL 中如何实现基于 MVCC 的快照隔离[^40] [^42] [^43](其他实现类似)。当事务启动时,它被赋予一个唯一的、始终递增的事务 ID`txid`)。每当事务向数据库写入任何内容时,它写入的数据都用写入者的事务 ID 标记。准确地说PostgreSQL 中的事务 ID 是 32 位整数,因此它们在大约 40 亿个事务后溢出。清理过程执行清理以确保溢出不会影响数据。)
[图 8-7](#fig_transactions_mvcc) 说明了 PostgreSQL 中如何实现基于 MVCC 的快照隔离[^40] [^42] [^43](其他实现类似)。当事务启动时,它被赋予一个唯一的、始终递增的事务 ID`txid`)。每当事务向数据库写入任何内容时,它写入的数据都用写入者的事务 ID 标记。准确地说PostgreSQL 中的事务 ID 是 32 位整数,因此它们在大约 40 亿个事务后溢出。清理过程执行清理以确保溢出不会影响数据。)
{{< figure src="/fig/ddia_0807.png" id="fig_transactions_mvcc" caption="图 8-7. 使用多版本并发控制实现快照隔离。" class="w-full my-4" >}}
表中的每一行都有一个 `inserted_by` 字段,包含将此行插入表中的事务的 ID。此外每行都有一个 `deleted_by` 字段,最初为空。如果事务删除一行,该行实际上不会从数据库中删除,而是通过将 `deleted_by` 字段设置为请求删除的事务的 ID 来标记为删除。在稍后的某个时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会删除任何标记为删除的行并释放它们的空间。
更新在内部被转换为删除和插入[^44]。例如,在[图 8-7](/ch8#fig_transactions_mvcc) 中,事务 13 从账户 2 中扣除 100 美元,将余额从 500 美元更改为 400 美元。`accounts` 表现在实际上包含账户 2 的两行:余额为 500 美元的行被事务 13 标记为已删除,余额为 400 美元的行由事务 13 插入。
更新在内部被转换为删除和插入[^44]。例如,在[图 8-7](#fig_transactions_mvcc) 中,事务 13 从账户 2 中扣除 100 美元,将余额从 500 美元更改为 400 美元。`accounts` 表现在实际上包含账户 2 的两行:余额为 500 美元的行被事务 13 标记为已删除,余额为 400 美元的行由事务 13 插入。
行的所有版本都存储在同一个数据库堆中(参见["在索引中存储值"](/ch4#sec_storage_index_heap)),无论写入它们的事务是否已提交。同一行的版本形成一个链表,从最新版本到最旧版本或相反,以便查询可以在内部迭代行的所有版本[^45] [^46]。
@ -346,7 +346,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
3. 中止事务所做的任何写入都被忽略,无论该中止何时发生。这样做的好处是,当事务中止时,我们不需要立即从存储中删除它写入的行,因为可见性规则会将它们过滤掉。垃圾收集过程可以稍后删除它们。
4. 所有其他写入对应用程序的查询可见。
这些规则适用于行的插入和删除。在[图 8-7](/ch8#fig_transactions_mvcc) 中,当事务 12 从账户 2 读取时,它看到 500 美元的余额,因为 500 美元余额的删除是由事务 13 进行的(根据规则 2事务 12 无法看到事务 13 进行的删除),而 400 美元余额的插入尚不可见(根据相同的规则)。
这些规则适用于行的插入和删除。在[图 8-7](#fig_transactions_mvcc) 中,当事务 12 从账户 2 读取时,它看到 500 美元的余额,因为 500 美元余额的删除是由事务 13 进行的(根据规则 2事务 12 无法看到事务 13 进行的删除),而 400 美元余额的插入尚不可见(根据相同的规则)。
换句话说,如果以下两个条件都为真,则行是可见的:
@ -379,9 +379,9 @@ MVCC 是数据库常用的实现技术,通常用于实现快照隔离。然而
### 防止丢失更新 {#sec_transactions_lost_update}
到目前为止,我们讨论的读已提交和快照隔离级别主要是关于只读事务在并发写入存在的情况下可以看到什么的保证。我们大多忽略了两个事务并发写入的问题——我们只讨论了脏写(参见["没有脏写"](/ch8#sec_transactions_dirty_write)),这是可能发生的一种特定类型的写-写冲突。
到目前为止,我们讨论的读已提交和快照隔离级别主要是关于只读事务在并发写入存在的情况下可以看到什么的保证。我们大多忽略了两个事务并发写入的问题——我们只讨论了脏写(参见["没有脏写"](#sec_transactions_dirty_write)),这是可能发生的一种特定类型的写-写冲突。
并发写入事务之间还可能发生其他几种有趣的冲突。其中最著名的是*丢失更新*问题,在[图 8-1](/ch8#fig_transactions_increment) 中以两个并发计数器递增的例子说明。
并发写入事务之间还可能发生其他几种有趣的冲突。其中最著名的是*丢失更新*问题,在[图 8-1](#fig_transactions_increment) 中以两个并发计数器递增的例子说明。
如果应用程序从数据库读取某个值,修改它,然后写回修改后的值(*读-修改-写循环*),就会出现丢失更新问题。如果两个事务并发执行此操作,其中一个修改可能会丢失,因为第二个写入不包括第一个修改。(我们有时说后面的写入*覆盖*了前面的写入。)这种模式出现在各种不同的场景中:
@ -409,7 +409,7 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
如果数据库的内置原子操作不提供必要的功能,另一个防止丢失更新的选项是应用程序显式锁定要更新的对象。然后应用程序可以执行读-修改-写循环,如果任何其他事务尝试并发更新或锁定同一对象,它将被迫等到第一个读-修改-写循环完成。
例如,考虑一个多人游戏,其中几个玩家可以同时移动同一个棋子。在这种情况下,原子操作可能不够,因为应用程序还需要确保玩家的移动遵守游戏规则,这涉及一些你无法合理地作为数据库查询实现的逻辑。相反,你可以使用锁来防止两个玩家同时移动同一个棋子,如[例 8-1](/ch8#fig_transactions_select_for_update) 所示。
例如,考虑一个多人游戏,其中几个玩家可以同时移动同一个棋子。在这种情况下,原子操作可能不够,因为应用程序还需要确保玩家的移动遵守游戏规则,这涉及一些你无法合理地作为数据库查询实现的逻辑。相反,你可以使用锁来防止两个玩家同时移动同一个棋子,如[例 8-1](#fig_transactions_select_for_update) 所示。
{{< figure id="fig_transactions_select_for_update" title="例 8-1. 显式锁定行以防止丢失更新" class="w-full my-4" >}}
@ -443,7 +443,7 @@ COMMIT;
#### 条件写入(比较并设置) {#sec_transactions_compare_and_set}
在不提供事务的数据库中,你有时会发现一个*条件写入*操作,它可以通过仅在值自你上次读取以来未更改时才允许更新来防止丢失的更新(之前在["单对象写入"](/ch8#sec_transactions_single_object)中提到)。如果当前值与你之前读取的不匹配,则更新无效,必须重试读-修改-写循环。它是许多 CPU 支持的原子*比较并设置*或*比较并交换*CAS指令的数据库等价物。
在不提供事务的数据库中,你有时会发现一个*条件写入*操作,它可以通过仅在值自你上次读取以来未更改时才允许更新来防止丢失的更新(之前在["单对象写入"](#sec_transactions_single_object)中提到)。如果当前值与你之前读取的不匹配,则更新无效,必须重试读-修改-写循环。它是许多 CPU 支持的原子*比较并设置*或*比较并交换*CAS指令的数据库等价物。
例如,为了防止两个用户同时更新同一个 wiki 页面,你可以尝试类似这样的操作,期望仅当页面内容自用户开始编辑以来没有更改时才进行更新:
@ -455,7 +455,7 @@ UPDATE wiki_pages SET content = 'new content'
如果内容已更改并且不再匹配 `'old content'`,则此更新将无效,因此你需要检查更新是否生效并在必要时重试。你也可以使用在每次更新时递增的版本号列,并且仅在当前版本号未更改时才应用更新,而不是比较完整内容。这种方法有时称为*乐观锁定*[^52]。
请注意,如果另一个事务并发修改了 `content`,则根据 MVCC 可见性规则,新内容可能不可见(参见["观察一致快照的可见性规则"](/ch8#sec_transactions_mvcc_visibility)。MVCC 的许多实现对此场景有可见性规则的例外,其中其他事务写入的值对 `UPDATE``DELETE` 查询的 `WHERE` 子句的评估可见,即使这些写入在快照中不可见。
请注意,如果另一个事务并发修改了 `content`,则根据 MVCC 可见性规则,新内容可能不可见(参见["观察一致快照的可见性规则"](#sec_transactions_mvcc_visibility)。MVCC 的许多实现对此场景有可见性规则的例外,其中其他事务写入的值对 `UPDATE``DELETE` 查询的 `WHERE` 子句的评估可见,即使这些写入在快照中不可见。
#### 冲突解决与复制 {#conflict-resolution-and-replication}
@ -477,7 +477,7 @@ UPDATE wiki_pages SET content = 'new content'
首先,想象这个例子:你正在为医生编写一个应用程序来管理他们在医院的值班班次。医院通常试图在任何时候都有几位医生值班,但绝对必须至少有一位医生值班。医生可以放弃他们的班次(例如,如果他们自己生病了),前提是该班次中至少有一位同事留在值班[^53] [^54]。
现在想象 Aaliyah 和 Bryce 是特定班次的两位值班医生。两人都感觉不舒服,所以他们都决定请假。不幸的是,他们碰巧大约在同一时间点击了下班的按钮。接下来发生的事情如[图 8-8](/ch8#fig_transactions_write_skew) 所示。
现在想象 Aaliyah 和 Bryce 是特定班次的两位值班医生。两人都感觉不舒服,所以他们都决定请假。不幸的是,他们碰巧大约在同一时间点击了下班的按钮。接下来发生的事情如[图 8-8](#fig_transactions_write_skew) 所示。
{{< figure src="/fig/ddia_0808.png" id="fig_transactions_write_skew" caption="图 8-8. 写偏差导致应用程序错误的示例。" class="w-full my-4" >}}
@ -493,24 +493,24 @@ UPDATE wiki_pages SET content = 'new content'
我们看到有各种不同的方法可以防止丢失的更新。对于写偏差,我们的选择更受限制:
* 原子单对象操作没有帮助,因为涉及多个对象。
* 不幸的是,你在某些快照隔离实现中发现的丢失更新的自动检测也没有帮助:写偏差在 PostgreSQL 的可重复读、MySQL/InnoDB 的可重复读、Oracle 的可串行化或 SQL Server 的快照隔离级别中不会自动检测到[^29]。自动防止写偏差需要真正的可串行化隔离(参见["可串行化"](/ch8#sec_transactions_serializability))。
* 某些数据库允许你配置约束,然后由数据库强制执行(例如,唯一性、外键约束或对特定值的限制)。但是,为了指定至少有一个医生必须值班,你需要一个涉及多个对象的约束。大多数数据库没有对此类约束的内置支持,但你可能能够使用触发器或物化视图实现它们,如["一致性"](/ch8#sec_transactions_acid_consistency)中所讨论的[^12]。
* 不幸的是,你在某些快照隔离实现中发现的丢失更新的自动检测也没有帮助:写偏差在 PostgreSQL 的可重复读、MySQL/InnoDB 的可重复读、Oracle 的可串行化或 SQL Server 的快照隔离级别中不会自动检测到[^29]。自动防止写偏差需要真正的可串行化隔离(参见["可串行化"](#sec_transactions_serializability))。
* 某些数据库允许你配置约束,然后由数据库强制执行(例如,唯一性、外键约束或对特定值的限制)。但是,为了指定至少有一个医生必须值班,你需要一个涉及多个对象的约束。大多数数据库没有对此类约束的内置支持,但你可能能够使用触发器或物化视图实现它们,如["一致性"](#sec_transactions_acid_consistency)中所讨论的[^12]。
* 如果你不能使用可串行化隔离级别,在这种情况下,第二好的选择可能是显式锁定事务所依赖的行。在医生示例中,你可以编写如下内容:
```sql
BEGIN TRANSACTION;
```sql
BEGIN TRANSACTION;
SELECT * FROM doctors
WHERE on_call = true
AND shift_id = 1234 FOR UPDATE; ❶
SELECT * FROM doctors
WHERE on_call = true
AND shift_id = 1234 FOR UPDATE; ❶
UPDATE doctors
SET on_call = false
WHERE name = 'Aaliyah'
AND shift_id = 1234;
UPDATE doctors
SET on_call = false
WHERE name = 'Aaliyah'
AND shift_id = 1234;
COMMIT;
```
COMMIT;
```
❶:和以前一样,`FOR UPDATE` 告诉数据库锁定此查询返回的所有行。
@ -519,7 +519,7 @@ UPDATE wiki_pages SET content = 'new content'
写偏差起初可能看起来是一个深奥的问题,但一旦你意识到它,你可能会注意到更多可能发生的情况。以下是更多示例:
会议室预订系统
: 假设你想强制同一会议室在同一时间不能有两个预订[^55]。当有人想要预订时,你首先检查是否有任何冲突的预订(即,具有重叠时间范围的同一房间的预订),如果没有找到,你就创建会议(参见[例 8-2](/ch8#fig_transactions_meeting_rooms))。
: 假设你想强制同一会议室在同一时间不能有两个预订[^55]。当有人想要预订时,你首先检查是否有任何冲突的预订(即,具有重叠时间范围的同一房间的预订),如果没有找到,你就创建会议(参见[例 8-2](#fig_transactions_meeting_rooms))。
{{< figure id="fig_transactions_meeting_rooms" title="例 8-2. 会议室预订系统试图避免重复预订(在快照隔离下不安全)" class="w-full my-4" >}}
@ -541,7 +541,7 @@ UPDATE wiki_pages SET content = 'new content'
不幸的是,快照隔离不会阻止另一个用户并发插入冲突的会议。为了保证你不会出现调度冲突,你再次需要可串行化隔离。
多人游戏
: 在[例 8-1](/ch8#fig_transactions_select_for_update) 中,我们使用锁来防止丢失的更新(即,确保两个玩家不能同时移动同一个棋子)。但是,锁不会阻止玩家将两个不同的棋子移动到棋盘上的同一位置,或者可能做出违反游戏规则的其他移动。根据你要执行的规则类型,你可能能够使用唯一约束,但否则你很容易受到写偏差的影响。
: 在[例 8-1](#fig_transactions_select_for_update) 中,我们使用锁来防止丢失的更新(即,确保两个玩家不能同时移动同一个棋子)。但是,锁不会阻止玩家将两个不同的棋子移动到棋盘上的同一位置,或者可能做出违反游戏规则的其他移动。根据你要执行的规则类型,你可能能够使用唯一约束,但否则你很容易受到写偏差的影响。
声明用户名
: 在每个用户都有唯一用户名的网站上,两个用户可能同时尝试使用相同的用户名创建账户。你可以使用事务来检查名称是否被占用,如果没有,使用该名称创建账户。但是,就像前面的例子一样,这在快照隔离下是不安全的。幸运的是,唯一约束在这里是一个简单的解决方案(尝试注册用户名的第二个事务将由于违反约束而被中止)。
@ -591,9 +591,9 @@ UPDATE wiki_pages SET content = 'new content'
但如果可串行化隔离比弱隔离级别的混乱要好得多,那为什么不是每个人都在使用它?要回答这个问题,我们需要查看实现可串行化的选项,以及它们的性能如何。今天提供可串行化的大多数数据库使用以下三种技术之一,我们将在本章的其余部分探讨:
* 字面上串行执行事务(参见["实际串行执行"](/ch8#sec_transactions_serial)
* 两阶段锁定(参见["两阶段锁定2PL"](/ch8#sec_transactions_2pl)),几十年来这是唯一可行的选择
* 乐观并发控制技术,如可串行化快照隔离(参见["可串行化快照隔离SSI"](/ch8#sec_transactions_ssi)
* 字面上串行执行事务(参见["实际串行执行"](#sec_transactions_serial)
* 两阶段锁定(参见["两阶段锁定2PL"](#sec_transactions_2pl)),几十年来这是唯一可行的选择
* 乐观并发控制技术,如可串行化快照隔离(参见["可串行化快照隔离SSI"](#sec_transactions_ssi)
### 实际串行执行 {#sec_transactions_serial}
@ -620,9 +620,9 @@ UPDATE wiki_pages SET content = 'new content'
因此,具有单线程串行事务处理的系统不允许交互式多语句事务。相反,应用程序必须将自己限制为包含单个语句的事务,或者提前将整个事务代码作为*存储过程*提交给数据库[^61]。
交互式事务和存储过程之间的差异如[图 8-9](/ch8#fig_transactions_stored_proc) 所示。前提是事务所需的所有数据都在内存中,存储过程可以非常快速地执行,而无需等待任何网络或磁盘 I/O。
交互式事务和存储过程之间的差异如[图 8-9](#fig_transactions_stored_proc) 所示。前提是事务所需的所有数据都在内存中,存储过程可以非常快速地执行,而无需等待任何网络或磁盘 I/O。
{{< figure src="/fig/ddia_0809.png" id="fig_transactions_stored_proc" caption="图 8-9. 交互式事务和存储过程之间的差异(使用[图 8-8](/ch8#fig_transactions_write_skew)的示例事务)。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0809.png" id="fig_transactions_stored_proc" caption="图 8-9. 交互式事务和存储过程之间的差异(使用[图 8-8](#fig_transactions_write_skew)的示例事务)。" class="w-full my-4" >}}
#### 存储过程的利弊 {#sec_transactions_stored_proc_tradeoffs}
@ -671,18 +671,18 @@ VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个
> [!TIP] 2PL 不是 2PC
两阶段*锁定*2PL和两阶段*提交*2PC是两个非常不同的东西。2PL 提供可串行化隔离,而 2PC 在分布式数据库中提供原子提交(参见["两阶段提交2PC"](/ch8#sec_transactions_2pc))。为避免混淆,最好将它们视为完全独立的概念,并忽略名称中不幸的相似性。
两阶段*锁定*2PL和两阶段*提交*2PC是两个非常不同的东西。2PL 提供可串行化隔离,而 2PC 在分布式数据库中提供原子提交(参见["两阶段提交2PC"](#sec_transactions_2pc))。为避免混淆,最好将它们视为完全独立的概念,并忽略名称中不幸的相似性。
--------
我们之前看到锁通常用于防止脏写(参见["没有脏写"](/ch8#sec_transactions_dirty_write)):如果两个事务并发尝试写入同一对象,锁确保第二个写入者必须等到第一个完成其事务(中止或提交)后才能继续。
我们之前看到锁通常用于防止脏写(参见["没有脏写"](#sec_transactions_dirty_write)):如果两个事务并发尝试写入同一对象,锁确保第二个写入者必须等到第一个完成其事务(中止或提交)后才能继续。
两阶段锁定类似,但使锁要求更强。只要没有人写入,多个事务就可以并发读取同一对象。但是一旦有人想要写入(修改或删除)对象,就需要独占访问:
* 如果事务 A 已读取对象而事务 B 想要写入该对象B 必须等到 A 提交或中止后才能继续。(这确保 B 不能在 A 背后意外地更改对象。)
* 如果事务 A 已写入对象而事务 B 想要读取该对象B 必须等到 A 提交或中止后才能继续。(像[图 8-4](/ch8#fig_transactions_read_committed) 中那样读取对象的旧版本在 2PL 下是不可接受的。)
* 如果事务 A 已写入对象而事务 B 想要读取该对象B 必须等到 A 提交或中止后才能继续。(像[图 8-4](#fig_transactions_read_committed) 中那样读取对象的旧版本在 2PL 下是不可接受的。)
在 2PL 中,写入者不仅阻塞其他写入者;它们还阻塞读者,反之亦然。快照隔离有这样的口号:*读者永远不会阻塞写者,写者永远不会阻塞读者*(参见["多版本并发控制MVCC"](/ch8#sec_transactions_snapshot_impl)),这捕捉了快照隔离和两阶段锁定之间的关键区别。另一方面,因为 2PL 提供可串行化,它可以防止早期讨论的所有竞态条件,包括丢失的更新和写偏差。
在 2PL 中,写入者不仅阻塞其他写入者;它们还阻塞读者,反之亦然。快照隔离有这样的口号:*读者永远不会阻塞写者,写者永远不会阻塞读者*(参见["多版本并发控制MVCC"](#sec_transactions_snapshot_impl)),这捕捉了快照隔离和两阶段锁定之间的关键区别。另一方面,因为 2PL 提供可串行化,它可以防止早期讨论的所有竞态条件,包括丢失的更新和写偏差。
#### 两阶段锁定的实现 {#implementation-of-two-phase-locking}
@ -703,7 +703,7 @@ VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个
这部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性降低。按设计,如果两个并发事务尝试执行任何可能以任何方式导致竞态条件的操作,其中一个必须等待另一个完成。
例如,如果你有一个需要读取整个表的事务(例如,备份、分析查询或完整性检查,如["快照隔离与可重复读"](/ch8#sec_transactions_snapshot_isolation)中所讨论的),该事务必须对整个表进行共享锁。因此,读取事务首先必须等到所有正在写入该表的进行中事务完成;然后,在读取整个表时(对于大表可能需要很长时间),所有想要写入该表的其他事务都被阻塞,直到大型只读事务提交。实际上,数据库在很长一段时间内无法进行写入。
例如,如果你有一个需要读取整个表的事务(例如,备份、分析查询或完整性检查,如["快照隔离与可重复读"](#sec_transactions_snapshot_isolation)中所讨论的),该事务必须对整个表进行共享锁。因此,读取事务首先必须等到所有正在写入该表的进行中事务完成;然后,在读取整个表时(对于大表可能需要很长时间),所有想要写入该表的其他事务都被阻塞,直到大型只读事务提交。实际上,数据库在很长一段时间内无法进行写入。
因此,运行 2PL 的数据库可能具有相当不稳定的延迟,如果工作负载中存在争用,它们在高百分位数可能非常慢(参见["描述性能"](/ch2#sec_introduction_percentiles))。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就会导致系统的其余部分停滞不前。
@ -711,9 +711,9 @@ VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个
#### 谓词锁 {#predicate-locks}
在前面的锁描述中,我们掩盖了一个微妙但重要的细节。在["导致写偏差的幻读"](/ch8#sec_transactions_phantom)中,我们讨论了*幻读*的问题——即一个事务改变另一个事务的搜索查询结果。具有可串行化隔离的数据库必须防止幻读。
在前面的锁描述中,我们掩盖了一个微妙但重要的细节。在["导致写偏差的幻读"](#sec_transactions_phantom)中,我们讨论了*幻读*的问题——即一个事务改变另一个事务的搜索查询结果。具有可串行化隔离的数据库必须防止幻读。
在会议室预订示例中,这意味着如果一个事务已经搜索了某个时间窗口内某个房间的现有预订(参见[例 8-2](/ch8#fig_transactions_meeting_rooms)),另一个事务不允许并发插入或更新同一房间和时间范围的另一个预订。(并发插入其他房间的预订,或同一房间不影响拟议预订的不同时间的预订是可以的。)
在会议室预订示例中,这意味着如果一个事务已经搜索了某个时间窗口内某个房间的现有预订(参见[例 8-2](#fig_transactions_meeting_rooms)),另一个事务不允许并发插入或更新同一房间和时间范围的另一个预订。(并发插入其他房间的预订,或同一房间不影响拟议预订的不同时间的预订是可以的。)
我们如何实现这一点?从概念上讲,我们需要一个*谓词锁*[^4]。它的工作方式类似于前面描述的共享/独占锁,但它不属于特定对象(例如,表中的一行),而是属于匹配某些搜索条件的所有对象,例如:
@ -768,11 +768,11 @@ SELECT * FROM bookings
但是,如果有足够的备用容量,并且事务之间的争用不太高,乐观并发控制技术往往比悲观技术性能更好。可交换原子操作可以减少争用:例如,如果几个事务并发想要递增计数器,应用递增的顺序无关紧要(只要计数器在同一事务中没有被读取),因此并发递增都可以应用而不会发生冲突。
顾名思义SSI 基于快照隔离——也就是说,事务中的所有读取都从数据库的一致快照进行(参见["快照隔离与可重复读"](/ch8#sec_transactions_snapshot_isolation)。在快照隔离的基础上SSI 添加了一种算法来检测读写之间的串行化冲突,并确定要中止哪些事务。
顾名思义SSI 基于快照隔离——也就是说,事务中的所有读取都从数据库的一致快照进行(参见["快照隔离与可重复读"](#sec_transactions_snapshot_isolation)。在快照隔离的基础上SSI 添加了一种算法来检测读写之间的串行化冲突,并确定要中止哪些事务。
#### 基于过时前提的决策 {#decisions-based-on-an-outdated-premise}
当我们之前讨论快照隔离中的写偏差时(参见["写偏差与幻读"](/ch8#sec_transactions_write_skew)),我们观察到一个反复出现的模式:事务从数据库读取一些数据,检查查询结果,并根据它看到的结果决定采取某些行动(写入数据库)。但是,在快照隔离下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在此期间被修改。
当我们之前讨论快照隔离中的写偏差时(参见["写偏差与幻读"](#sec_transactions_write_skew)),我们观察到一个反复出现的模式:事务从数据库读取一些数据,检查查询结果,并根据它看到的结果决定采取某些行动(写入数据库)。但是,在快照隔离下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在此期间被修改。
换句话说,事务基于*前提*(事务开始时为真的事实,例如,"当前有两名医生值班")采取行动。后来,当事务想要提交时,原始数据可能已更改——前提可能不再为真。
@ -785,9 +785,9 @@ SELECT * FROM bookings
#### 检测陈旧的 MVCC 读取 {#detecting-stale-mvcc-reads}
回想一下快照隔离通常由多版本并发控制MVCC参见["多版本并发控制MVCC"](/ch8#sec_transactions_snapshot_impl))实现。当事务从 MVCC 数据库中的一致快照读取时,它会忽略在拍摄快照时尚未提交的任何其他事务所做的写入。
回想一下快照隔离通常由多版本并发控制MVCC参见["多版本并发控制MVCC"](#sec_transactions_snapshot_impl))实现。当事务从 MVCC 数据库中的一致快照读取时,它会忽略在拍摄快照时尚未提交的任何其他事务所做的写入。
在[图 8-10](/ch8#fig_transactions_detect_mvcc) 中,事务 43 看到 Aaliyah 的 `on_call = true`,因为事务 42修改了 Aaliyah 的值班状态)未提交。但是,当事务 43 想要提交时,事务 42 已经提交。这意味着从一致快照读取时被忽略的写入现在已生效,事务 43 的前提不再为真。当写入者插入以前不存在的数据时,事情变得更加复杂(参见["导致写偏差的幻读"](/ch8#sec_transactions_phantom))。我们将在["检测影响先前读取的写入"](/ch8#sec_detecting_writes_affect_reads)中讨论为 SSI 检测幻写。
在[图 8-10](#fig_transactions_detect_mvcc) 中,事务 43 看到 Aaliyah 的 `on_call = true`,因为事务 42修改了 Aaliyah 的值班状态)未提交。但是,当事务 43 想要提交时,事务 42 已经提交。这意味着从一致快照读取时被忽略的写入现在已生效,事务 43 的前提不再为真。当写入者插入以前不存在的数据时,事情变得更加复杂(参见["导致写偏差的幻读"](#sec_transactions_phantom))。我们将在["检测影响先前读取的写入"](#sec_detecting_writes_affect_reads)中讨论为 SSI 检测幻写。
{{< figure src="/fig/ddia_0810.png" id="fig_transactions_detect_mvcc" caption="图 8-10. 检测事务何时从 MVCC 快照读取过时值。" class="w-full my-4" >}}
@ -798,18 +798,18 @@ SELECT * FROM bookings
#### 检测影响先前读取的写入 {#sec_detecting_writes_affect_reads}
要考虑的第二种情况是另一个事务在数据被读取后修改数据。这种情况如[图 8-11](/ch8#fig_transactions_detect_index_range) 所示。
要考虑的第二种情况是另一个事务在数据被读取后修改数据。这种情况如[图 8-11](#fig_transactions_detect_index_range) 所示。
{{< figure src="/fig/ddia_0811.png" id="fig_transactions_detect_index_range" caption="图 8-11. 在可串行化快照隔离中,检测一个事务何时修改另一个事务的读取。" class="w-full my-4" >}}
在两阶段锁定的上下文中,我们讨论了索引范围锁(参见["索引范围锁"](/ch8#sec_transactions_2pl_range)),它允许数据库锁定对匹配某些搜索查询的所有行的访问,例如 `WHERE shift_id = 1234`。我们可以在这里使用类似的技术,除了 SSI 锁不会阻塞其他事务。
在两阶段锁定的上下文中,我们讨论了索引范围锁(参见["索引范围锁"](#sec_transactions_2pl_range)),它允许数据库锁定对匹配某些搜索查询的所有行的访问,例如 `WHERE shift_id = 1234`。我们可以在这里使用类似的技术,除了 SSI 锁不会阻塞其他事务。
在[图 8-11](/ch8#fig_transactions_detect_index_range) 中,事务 42 和 43 都在班次 `1234` 期间搜索值班医生。如果 `shift_id` 上有索引,数据库可以使用索引条目 1234 来记录事务 42 和 43 读取此数据的事实。(如果没有索引,可以在表级别跟踪此信息。)此信息只需要保留一段时间:在事务完成(提交或中止)并且所有并发事务完成后,数据库可以忘记它读取的数据。
在[图 8-11](#fig_transactions_detect_index_range) 中,事务 42 和 43 都在班次 `1234` 期间搜索值班医生。如果 `shift_id` 上有索引,数据库可以使用索引条目 1234 来记录事务 42 和 43 读取此数据的事实。(如果没有索引,可以在表级别跟踪此信息。)此信息只需要保留一段时间:在事务完成(提交或中止)并且所有并发事务完成后,数据库可以忘记它读取的数据。
当事务写入数据库时,它必须在索引中查找最近读取受影响数据的任何其他事务。此过程类似于获取受影响键范围的写锁,但它不是阻塞直到读者提交,而是充当绊线:它只是通知事务它们读取的数据可能不再是最新的。
在[图 8-11](/ch8#fig_transactions_detect_index_range) 中,事务 43 通知事务 42 其先前的读取已过时,反之亦然。事务 42 首先提交,并且成功:尽管事务 43 的写入影响了 42但 43 尚未提交,因此写入尚未生效。但是,当事务 43 想要提交时,来自 42 的冲突写入已经提交,因此 43 必须中止。
在[图 8-11](#fig_transactions_detect_index_range) 中,事务 43 通知事务 42 其先前的读取已过时,反之亦然。事务 42 首先提交,并且成功:尽管事务 43 的写入影响了 42但 43 尚未提交,因此写入尚未生效。但是,当事务 43 想要提交时,来自 42 的冲突写入已经提交,因此 43 必须中止。
#### 可串行化快照隔离的性能 {#performance-of-serializable-snapshot-isolation}
@ -837,7 +837,7 @@ SELECT * FROM bookings
但是,如果多个节点参与事务会怎样?例如,也许你在分片数据库中有多对象事务,或者有全局二级索引(其中索引条目可能与主数据在不同的节点上;参见["分片和二级索引"](/ch7#sec_sharding_secondary_indexes))。大多数"NoSQL"分布式数据存储不支持此类分布式事务,但各种分布式关系数据库支持。
在这些情况下,仅向所有节点发送提交请求并在每个节点上独立提交事务是不够的。如[图 8-12](/ch8#fig_transactions_non_atomic) 所示,提交可能在某些节点上成功,在其他节点上失败:
在这些情况下,仅向所有节点发送提交请求并在每个节点上独立提交事务是不够的。如[图 8-12](#fig_transactions_non_atomic) 所示,提交可能在某些节点上成功,在其他节点上失败:
* 某些节点可能检测到约束违规或冲突,需要中止,而其他节点能够成功提交。
* 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求通过。
@ -846,7 +846,7 @@ SELECT * FROM bookings
{{< figure src="/fig/ddia_0812.png" id="fig_transactions_non_atomic" caption="图 8-12. 当事务涉及多个数据库节点时,它可能在某些节点上提交,在其他节点上失败。" class="w-full my-4" >}}
如果某些节点提交事务而其他节点中止它,节点之间就会变得不一致。一旦事务在一个节点上提交,如果后来发现它在另一个节点上被中止,就不能撤回了。这是因为一旦数据被提交,它在*读已提交*或更强的隔离下对其他事务可见。例如,在[图 8-12](/ch8#fig_transactions_non_atomic) 中,当用户 1 注意到其在数据库 1 上的提交失败时,用户 2 已经从数据库 2 上的同一事务读取了数据。如果用户 1 的事务后来被中止,用户 2 的事务也必须被还原,因为它基于被追溯声明不存在的数据。
如果某些节点提交事务而其他节点中止它,节点之间就会变得不一致。一旦事务在一个节点上提交,如果后来发现它在另一个节点上被中止,就不能撤回了。这是因为一旦数据被提交,它在*读已提交*或更强的隔离下对其他事务可见。例如,在[图 8-12](#fig_transactions_non_atomic) 中,当用户 1 注意到其在数据库 1 上的提交失败时,用户 2 已经从数据库 2 上的同一事务读取了数据。如果用户 1 的事务后来被中止,用户 2 的事务也必须被还原,因为它基于被追溯声明不存在的数据。
更好的方法是确保参与事务的节点要么全部提交,要么全部中止,并防止两者的混合。确保这一点被称为*原子提交*问题。
@ -854,7 +854,7 @@ SELECT * FROM bookings
两阶段提交是一种跨多个节点实现原子事务提交的算法。它是分布式数据库中的经典算法[^13] [^71] [^72]。2PC 在某些数据库内部使用,也以 *XA 事务*[^73] 的形式提供给应用程序例如Java 事务 API 支持),或通过 WS-AtomicTransaction 用于 SOAP Web 服务[^74] [^75]。
2PC 的基本流程如[图 8-13](/ch8#fig_transactions_two_phase_commit) 所示。与单节点事务的单个提交请求不同2PC 中的提交/中止过程分为两个阶段(因此得名)。
2PC 的基本流程如[图 8-13](#fig_transactions_two_phase_commit) 所示。与单节点事务的单个提交请求不同2PC 中的提交/中止过程分为两个阶段(因此得名)。
{{< figure src="/fig/ddia_0813.png" id="fig_transactions_two_phase_commit" title="图 8-13. 两阶段提交2PC的成功执行。" class="w-full my-4" >}}
@ -893,7 +893,7 @@ SELECT * FROM bookings
如果协调器在发送准备请求之前失败,参与者可以安全地中止事务。但是一旦参与者收到准备请求并投票"是",它就不能再单方面中止——它必须等待协调器回复事务是提交还是中止。如果协调器此时崩溃或网络失败,参与者除了等待别无他法。参与者在此状态下的事务称为*存疑*或*不确定*。
这种情况如[图 8-14](/ch8#fig_transactions_2pc_crash) 所示。在这个特定的例子中,协调器实际上决定提交,数据库 2 收到了提交请求。但是,协调器在向数据库 1 发送提交请求之前崩溃了,因此数据库 1 不知道是提交还是中止。即使超时在这里也没有帮助:如果数据库 1 在超时后单方面中止,它将与已提交的数据库 2 不一致。同样,单方面提交也不安全,因为另一个参与者可能已中止。
这种情况如[图 8-14](#fig_transactions_2pc_crash) 所示。在这个特定的例子中,协调器实际上决定提交,数据库 2 收到了提交请求。但是,协调器在向数据库 1 发送提交请求之前崩溃了,因此数据库 1 不知道是提交还是中止。即使超时在这里也没有帮助:如果数据库 1 在超时后单方面中止,它将与已提交的数据库 2 不一致。同样,单方面提交也不安全,因为另一个参与者可能已中止。
{{< figure src="/fig/ddia_0814.png" id="fig_transactions_2pc_crash" title="图 8-14. 协调器在参与者投票“是”后崩溃。数据库 1 不知道是提交还是中止。" class="w-full my-4" >}}
@ -952,9 +952,9 @@ XA 假设你的应用程序使用网络驱动程序或客户端库与参与者
为什么我们如此关心事务陷入存疑?系统的其余部分不能继续工作,忽略最终会被清理的存疑事务吗?
问题在于*锁定*。如["读已提交"](/ch8#sec_transactions_read_committed)中所讨论的,数据库事务通常对它们修改的任何行进行行级独占锁,以防止脏写。此外,如果你想要可串行化隔离,使用两阶段锁定的数据库还必须对事务*读取*的任何行进行共享锁。
问题在于*锁定*。如["读已提交"](#sec_transactions_read_committed)中所讨论的,数据库事务通常对它们修改的任何行进行行级独占锁,以防止脏写。此外,如果你想要可串行化隔离,使用两阶段锁定的数据库还必须对事务*读取*的任何行进行共享锁。
数据库在事务提交或中止之前不能释放这些锁(如[图 8-13](/ch8#fig_transactions_two_phase_commit) 中的阴影区域所示)。因此,使用两阶段提交时,事务必须在存疑期间保持锁。如果协调器崩溃并需要 20 分钟才能重新启动,这些锁将保持 20 分钟。如果协调器的日志由于某种原因完全丢失,这些锁将永远保持——或者至少直到管理员手动解决情况。
数据库在事务提交或中止之前不能释放这些锁(如[图 8-13](#fig_transactions_two_phase_commit) 中的阴影区域所示)。因此,使用两阶段提交时,事务必须在存疑期间保持锁。如果协调器崩溃并需要 20 分钟才能重新启动,这些锁将保持 20 分钟。如果协调器的日志由于某种原因完全丢失,这些锁将永远保持——或者至少直到管理员手动解决情况。
当这些锁被持有时,没有其他事务可以修改这些行。根据隔离级别,其他事务甚至可能被阻止读取这些行。因此,其他事务不能简单地继续他们的业务——如果他们想要访问相同的数据,他们将被阻塞。这可能导致你的应用程序的大部分变得不可用,直到存疑事务得到解决。
@ -976,7 +976,7 @@ XA 假设你的应用程序使用网络驱动程序或客户端库与参与者
即使协调器被复制,应用程序代码也将是单点故障。解决这个问题需要完全重新设计应用程序代码的运行方式,使其复制或可重启,这可能看起来类似于持久执行(参见["持久执行和工作流"](/ch5#sec_encoding_dataflow_workflows))。但是,实践中似乎没有任何工具实际采用这种方法。
另一个问题是,由于 XA 需要与各种数据系统兼容,它必然是最低公分母。例如,它无法检测跨不同系统的死锁(因为这需要系统交换有关每个事务正在等待的锁的信息的标准化协议),并且它不适用于 SSI参见["可串行化快照隔离SSI"](/ch8#sec_transactions_ssi)),因为这需要跨不同系统识别冲突的协议。
另一个问题是,由于 XA 需要与各种数据系统兼容,它必然是最低公分母。例如,它无法检测跨不同系统的死锁(因为这需要系统交换有关每个事务正在等待的锁的信息的标准化协议),并且它不适用于 SSI参见["可串行化快照隔离SSI"](#sec_transactions_ssi)),因为这需要跨不同系统识别冲突的协议。
这些问题在某种程度上是跨异构技术执行事务所固有的。但是,保持几个异构数据系统彼此一致仍然是一个真实而重要的问题,因此我们需要为其找到不同的解决方案。这可以做到,我们将在下一节和["派生数据与分布式事务"](/ch13#sec_future_derived_vs_transactions)中看到。
@ -999,7 +999,7 @@ XA 的最大问题可以通过以下方式解决:
#### 再谈恰好一次消息处理 {#exactly-once-message-processing-revisited}
我们在["恰好一次消息处理"](/ch8#sec_transactions_exactly_once)中看到,分布式事务的一个重要用例是确保某些操作恰好生效一次,即使在处理过程中发生崩溃并且需要重试处理。如果你可以跨消息代理和数据库原子地提交事务,则当且仅当成功处理消息并且从处理过程产生的数据库写入被提交时,你可以向代理确认消息。
我们在["恰好一次消息处理"](#sec_transactions_exactly_once)中看到,分布式事务的一个重要用例是确保某些操作恰好生效一次,即使在处理过程中发生崩溃并且需要重试处理。如果你可以跨消息代理和数据库原子地提交事务,则当且仅当成功处理消息并且从处理过程产生的数据库写入被提交时,你可以向代理确认消息。
但是,你实际上不需要这样的分布式事务来实现恰好一次语义。另一种方法如下,它只需要数据库中的事务:
@ -1024,9 +1024,9 @@ XA 的最大问题可以通过以下方式解决:
没有事务,各种错误场景(进程崩溃、网络中断、停电、磁盘已满、意外并发等)意味着数据可能以各种方式变得不一致。例如,反规范化数据很容易与源数据失去同步。没有事务,很难推理复杂的交互访问对数据库可能产生的影响。
在本章中,我们特别深入地探讨了并发控制的主题。我们讨论了几种广泛使用的隔离级别,特别是*读已提交*、*快照隔离*(有时称为*可重复读*)和*可串行化*。我们通过讨论各种竞态条件的示例来描述这些隔离级别,总结在[表 8-1](/ch8#ch_transactions_isolation_levels) 中:
在本章中,我们特别深入地探讨了并发控制的主题。我们讨论了几种广泛使用的隔离级别,特别是*读已提交*、*快照隔离*(有时称为*可重复读*)和*可串行化*。我们通过讨论各种竞态条件的示例来描述这些隔离级别,总结在 [表 8-1](#tab_transactions_isolation_levels) 中:
表 8-1. 各种隔离级别可能发生的异常总结
{{< figure id="tab_transactions_isolation_levels" title="表 8-1. 各种隔离级别可能发生的异常总结" class="w-full my-4" >}}
| 隔离级别 | 脏读 | 读取偏差 | 幻读 | 丢失更新 | 写偏差 |
|------|------|------|------|-------|------|
@ -1066,7 +1066,7 @@ XA 的最大问题可以通过以下方式解决:
最后,我们研究了当事务分布在多个节点上时如何实现原子性,使用两阶段提交。如果这些节点都运行相同的数据库软件,分布式事务可以很好地工作,但跨不同存储技术(使用 XA 事务2PC 是有问题的:它对协调器和驱动事务的应用程序代码中的故障非常敏感,并且与并发控制机制的交互很差。幸运的是,幂等性可以确保恰好一次语义,而无需跨不同存储技术的原子提交,我们将在后面的章节中看到更多相关内容。
本章中的示例使用了关系数据模型。但是,如["多对象事务的需求"](/ch8#sec_transactions_need)中所讨论的,无论使用哪种数据模型,事务都是有价值的数据库功能。
本章中的示例使用了关系数据模型。但是,如["多对象事务的需求"](#sec_transactions_need)中所讨论的,无论使用哪种数据模型,事务都是有价值的数据库功能。

View file

@ -18,7 +18,7 @@ breadcrumbs: false
此外,使用分布式系统与在单台计算机上编写软件有着根本的不同 —— 主要区别在于有许多新的、令人兴奋的出错方式 [^1] [^2]。在本章中,你将体验实践中出现的问题,并理解你可以依赖和不能依赖的事物。
为了理解我们面临的挑战,我们现在将把悲观情绪发挥到极致,探索分布式系统中可能出错的事情。我们将研究网络问题(["不可靠的网络"](/ch9#sec_distributed_networks))以及时钟和时序问题(["不可靠的时钟"](/ch9#sec_distributed_clocks))。所有这些问题的后果令人迷惑,因此我们将探索如何思考分布式系统的状态以及如何推理已经发生的事情(["知识、真相与谎言"](/ch9#sec_distributed_truth))。稍后,在 [第 10 章](/ch10#ch_consistency) 中,我们将看一些面对这些故障时如何实现容错的例子。
为了理解我们面临的挑战,我们现在将把悲观情绪发挥到极致,探索分布式系统中可能出错的事情。我们将研究网络问题(["不可靠的网络"](#sec_distributed_networks))以及时钟和时序问题(["不可靠的时钟"](#sec_distributed_clocks))。所有这些问题的后果令人迷惑,因此我们将探索如何思考分布式系统的状态以及如何推理已经发生的事情(["知识、真相与谎言"](#sec_distributed_truth))。稍后,在 [第 10 章](/ch10#ch_consistency) 中,我们将看一些面对这些故障时如何实现容错的例子。
## 故障与部分失效 {#sec_distributed_partial_failure}
@ -44,12 +44,12 @@ breadcrumbs: false
正如 ["共享内存、共享磁盘和无共享架构"](/ch2#sec_introduction_shared_nothing) 中所讨论的,我们在本书中关注的分布式系统主要是 *无共享系统*:即通过网络连接的一组机器。网络是这些机器进行通信的唯一方式 —— 我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除非通过网络向服务发出请求)。即使存储是共享的,例如亚马逊的 S3机器也是通过网络与共享存储服务通信。
互联网和数据中心中的大多数内部网络(通常是以太网)都是 *异步分组网络*。在这种网络中,一个节点可以向另一个节点发送消息(数据包),但网络不保证它何时到达,或者是否会到达。如果你发送请求并期望响应,许多事情可能会出错(其中一些如 [图 9-1](/ch9#fig_distributed_network) 所示):
互联网和数据中心中的大多数内部网络(通常是以太网)都是 *异步分组网络*。在这种网络中,一个节点可以向另一个节点发送消息(数据包),但网络不保证它何时到达,或者是否会到达。如果你发送请求并期望响应,许多事情可能会出错(其中一些如 [图 9-1](#fig_distributed_network) 所示):
1. 你的请求可能已经丢失(也许有人拔掉了网线)。
2. 你的请求可能在队列中等待,稍后将被交付(也许网络或接收方过载)。
3. 远程节点可能已经失效(也许它崩溃了或被关闭了)。
4. 远程节点可能暂时停止响应(也许它正在经历长时间的垃圾回收暂停;见 ["进程暂停"](/ch9#sec_distributed_clocks_pauses)),但稍后会再次开始响应。
4. 远程节点可能暂时停止响应(也许它正在经历长时间的垃圾回收暂停;见 ["进程暂停"](#sec_distributed_clocks_pauses)),但稍后会再次开始响应。
5. 远程节点可能已经处理了你的请求,但响应在网络上丢失了(也许网络交换机配置错误)。
6. 远程节点可能已经处理了你的请求,但响应被延迟了,稍后将被交付(也许网络或你自己的机器过载)。
@ -67,7 +67,7 @@ breadcrumbs: false
--------
> [!NOTE]
> 我们关于 TCP 的大部分内容也适用于其更新的替代方案 QUIC以及 WebRTC 中使用的流控制传输协议SCTP、BitTorrent uTP 协议和其他传输协议。有关与 UDP 的比较,请参见 ["TCP 与 UDP"](/ch9#sidebar_distributed_tcp_udp)。
> 我们关于 TCP 的大部分内容也适用于其更新的替代方案 QUIC以及 WebRTC 中使用的流控制传输协议SCTP、BitTorrent uTP 协议和其他传输协议。有关与 UDP 的比较,请参见 ["TCP 与 UDP"](#sidebar_distributed_tcp_udp)。
--------
@ -104,7 +104,7 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检
如果网络故障的错误处理没有定义和测试,可能会发生任意糟糕的事情:例如,集群可能会陷入死锁并永久无法提供请求,即使网络恢复 [^24],或者它甚至可能删除你的所有数据 [^25]。如果软件处于意料之外的情况,它可能会做任意意外的事情。
处理网络故障不一定意味着 *容忍* 它们:如果你的网络通常相当可靠,一个有效的方法可能是在网络出现问题时简单地向用户显示错误消息。但是,你确实需要知道你的软件如何对网络问题做出反应,并确保系统可以从中恢复。故意触发网络问题并测试系统的响应可能是有意义的(这被称为 *故障注入*;见 ["故障注入"](/ch9#sec_fault_injection))。
处理网络故障不一定意味着 *容忍* 它们:如果你的网络通常相当可靠,一个有效的方法可能是在网络出现问题时简单地向用户显示错误消息。但是,你确实需要知道你的软件如何对网络问题做出反应,并确保系统可以从中恢复。故意触发网络问题并测试系统的响应可能是有意义的(这被称为 *故障注入*;见 ["故障注入"](#sec_fault_injection))。
### 检测故障 {#id307}
@ -128,13 +128,13 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检
长超时意味着在节点被宣布死亡之前需要长时间等待(在此期间,用户可能不得不等待或看到错误消息)。短超时可以更快地检测故障,但当节点实际上只是遭受暂时的减速(例如,由于节点或网络上的负载峰值)时,错误地宣布节点死亡的风险更高。
过早地宣布节点死亡是有问题的:如果节点实际上是活着的并且正在执行某些操作(例如,发送电子邮件),而另一个节点接管,该操作可能最终被执行两次。我们将在 ["知识、真相与谎言"](/ch9#sec_distributed_truth) 以及第 10 章和后续章节中更详细地讨论这个问题。
过早地宣布节点死亡是有问题的:如果节点实际上是活着的并且正在执行某些操作(例如,发送电子邮件),而另一个节点接管,该操作可能最终被执行两次。我们将在 ["知识、真相与谎言"](#sec_distributed_truth) 以及第 10 章和后续章节中更详细地讨论这个问题。
当节点被宣布死亡时,其职责需要转移到其他节点,这会给其他节点和网络带来额外的负载。如果系统已经在高负载下挣扎,过早地宣布节点死亡可能会使问题变得更糟。特别是,可能发生的情况是,节点实际上并没有死亡,只是由于过载而响应缓慢;将其负载转移到其他节点可能会导致级联故障(在极端情况下,所有节点互相宣布对方死亡,一切都停止工作 —— 见 ["当过载系统无法恢复时"](/ch2#sidebar_metastable))。
想象一个虚构的系统,其网络保证数据包的最大延迟 —— 每个数据包要么在某个时间 *d* 内交付,要么丢失,但交付从不会超过 *d*。此外,假设你可以保证未失效的节点总是在某个时间 *r* 内处理请求。在这种情况下,你可以保证每个成功的请求在时间 2*d* + *r* 内收到响应 —— 如果你在该时间内没有收到响应你就知道网络或远程节点不工作。如果这是真的2*d* + *r* 将是一个合理的超时时间。
不幸的是,我们使用的大多数系统都没有这些保证:异步网络具有 *无界延迟*(即,它们尝试尽快交付数据包,但数据包到达所需的时间没有上限),大多数服务器实现无法保证它们可以在某个最大时间内处理请求(见 ["响应时间保证"](/ch9#sec_distributed_clocks_realtime))。对于故障检测,系统大部分时间快速运行是不够的:如果你的超时很低,往返时间的瞬时峰值就足以使系统失去平衡。
不幸的是,我们使用的大多数系统都没有这些保证:异步网络具有 *无界延迟*(即,它们尝试尽快交付数据包,但数据包到达所需的时间没有上限),大多数服务器实现无法保证它们可以在某个最大时间内处理请求(见 ["响应时间保证"](#sec_distributed_clocks_realtime))。对于故障检测,系统大部分时间快速运行是不够的:如果你的超时很低,往返时间的瞬时峰值就足以使系统失去平衡。
<a id="sec_distributed_congestion"></a>
@ -142,7 +142,7 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检
开车时,道路网络上的行驶时间通常因交通拥堵而变化最大。同样,计算机网络上数据包延迟的可变性最常是由于排队 [^27]
* 如果几个不同的节点同时尝试向同一目的地发送数据包,网络交换机必须将它们排队并逐个送入目标网络链路(如 [图 9-2](/ch9#fig_distributed_switch_queueing) 所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为 *网络拥塞*)。如果有太多的传入数据以至于交换机队列满了,数据包将被丢弃,因此需要重新发送 —— 即使网络运行正常。
* 如果几个不同的节点同时尝试向同一目的地发送数据包,网络交换机必须将它们排队并逐个送入目标网络链路(如 [图 9-2](#fig_distributed_switch_queueing) 所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为 *网络拥塞*)。如果有太多的传入数据以至于交换机队列满了,数据包将被丢弃,因此需要重新发送 —— 即使网络运行正常。
* 当数据包到达目标机器时,如果所有 CPU 核心当前都很忙,来自网络的传入请求会被操作系统排队,直到应用程序准备处理它。根据机器上的负载,这可能需要任意长的时间 [^28]。
* 在虚拟化环境中,正在运行的操作系统经常会暂停几十毫秒,而另一个虚拟机使用 CPU 核心。在此期间VM 无法消耗来自网络的任何数据,因此传入数据由虚拟机监视器排队(缓冲)[^29],进一步增加了网络延迟的可变性。
* 如前所述为了避免网络过载TCP 限制发送数据的速率。这意味着在数据甚至进入网络之前,发送方就有额外的排队。
@ -205,7 +205,7 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检
>
> 相比之下,互联网 *动态* 共享网络带宽。发送者互相推挤,尽可能快地通过线路发送数据包,网络交换机决定在每个时刻发送哪个数据包(即带宽分配)。这种方法的缺点是排队,但优点是它最大化了线路的利用率。线路有固定成本,所以如果你更好地利用它,你通过线路发送的每个字节都更便宜。
>
> CPU 也会出现类似的情况:如果你在几个线程之间动态共享每个 CPU 核心,一个线程有时必须在操作系统的运行队列中等待,而另一个线程正在运行,因此线程可能会暂停不同的时间长度 [^38]。然而,这比为每个线程分配静态数量的 CPU 周期更好地利用硬件(见 ["响应时间保证"](/ch9#sec_distributed_clocks_realtime))。更好的硬件利用率也是云平台在同一物理机器上运行来自不同客户的多个虚拟机的原因。
> CPU 也会出现类似的情况:如果你在几个线程之间动态共享每个 CPU 核心,一个线程有时必须在操作系统的运行队列中等待,而另一个线程正在运行,因此线程可能会暂停不同的时间长度 [^38]。然而,这比为每个线程分配静态数量的 CPU 周期更好地利用硬件(见 ["响应时间保证"](#sec_distributed_clocks_realtime))。更好的硬件利用率也是云平台在同一物理机器上运行来自不同客户的多个虚拟机的原因。
>
> 如果资源是静态分区的(例如,专用硬件和独占带宽分配),则在某些环境中可以实现延迟保证。然而,这是以降低利用率为代价的 —— 换句话说,它更昂贵。另一方面,具有动态资源分区的多租户提供了更好的利用率,因此更便宜,但它有可变延迟的缺点。
>
@ -244,7 +244,7 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检
#### 日历时钟 {#time-of-day-clocks}
日历时钟做你直观期望时钟做的事情:它根据某个日历返回当前日期和时间(也称为 *墙上时钟时间*。例如Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 *纪元* 以来的秒数或毫秒数根据格里高利历1970 年 1 月 1 日午夜 UTC不计算闰秒。一些系统使用其他日期作为参考点。尽管 Linux 时钟被称为 *实时*,但它与实时操作系统无关,如 ["响应时间保证"](/ch9#sec_distributed_clocks_realtime) 中所讨论的。)
日历时钟做你直观期望时钟做的事情:它根据某个日历返回当前日期和时间(也称为 *墙上时钟时间*。例如Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 *纪元* 以来的秒数或毫秒数根据格里高利历1970 年 1 月 1 日午夜 UTC不计算闰秒。一些系统使用其他日期作为参考点。尽管 Linux 时钟被称为 *实时*,但它与实时操作系统无关,如 ["响应时间保证"](#sec_distributed_clocks_realtime) 中所讨论的。)
日历时钟通常与 NTP 同步,这意味着来自一台机器的时间戳(理想情况下)与另一台机器上的时间戳意思相同。然而,日历时钟也有各种奇怪之处,如下一节所述。特别是,如果本地时钟远远超前于 NTP 服务器,它可能会被强制重置并显示跳回到以前的时间点。这些跳跃,以及闰秒引起的类似跳跃,使日历时钟不适合测量经过的时间 [^40]。
@ -293,21 +293,21 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检
让我们考虑一个特定的情况,其中依赖时钟是诱人但危险的:跨多个节点的事件排序 [^64]。例如,如果两个客户端写入分布式数据库,谁先到达?哪个写入是更新的?
[图 9-3](/ch9#fig_distributed_timestamps) 说明了在具有多主复制的数据库中日历时钟的危险使用(该示例类似于 [图 6-8](/ch6#fig_replication_causality))。客户端 A 在节点 1 上写入 *x* = 1写入被复制到节点 3客户端 B 在节点 3 上递增 *x*(我们现在有 *x* = 2最后两个写入都被复制到节点 2。
[图 9-3](#fig_distributed_timestamps) 说明了在具有多主复制的数据库中日历时钟的危险使用(该示例类似于 [图 6-8](/ch6#fig_replication_causality))。客户端 A 在节点 1 上写入 *x* = 1写入被复制到节点 3客户端 B 在节点 3 上递增 *x*(我们现在有 *x* = 2最后两个写入都被复制到节点 2。
{{< figure src="/fig/ddia_0903.png" id="fig_distributed_timestamps" caption="图 9-3. 客户端 B 的写入在因果关系上晚于客户端 A 的写入,但 B 的写入具有更早的时间戳。" class="w-full my-4" >}}
在 [图 9-3](/ch9#fig_distributed_timestamps) 中,当写入被复制到其他节点时,它会根据写入起源节点上的日历时钟标记时间戳。此示例中的时钟同步非常好:节点 1 和节点 3 之间的偏差小于 3 毫秒,这可能比你在实践中可以期望的要好。
在 [图 9-3](#fig_distributed_timestamps) 中,当写入被复制到其他节点时,它会根据写入起源节点上的日历时钟标记时间戳。此示例中的时钟同步非常好:节点 1 和节点 3 之间的偏差小于 3 毫秒,这可能比你在实践中可以期望的要好。
由于递增建立在 *x* = 1 的早期写入之上,我们可能期望 *x* = 2 的写入应该具有两者中更大的时间戳。不幸的是,[图 9-3](/ch9#fig_distributed_timestamps) 中发生的并非如此:写入 *x* = 1 的时间戳为 42.004 秒,但写入 *x* = 2 的时间戳为 42.003 秒。
由于递增建立在 *x* = 1 的早期写入之上,我们可能期望 *x* = 2 的写入应该具有两者中更大的时间戳。不幸的是,[图 9-3](#fig_distributed_timestamps) 中发生的并非如此:写入 *x* = 1 的时间戳为 42.004 秒,但写入 *x* = 2 的时间戳为 42.003 秒。
如 ["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww) 中所讨论的,解决不同节点上并发写入值之间冲突的一种方法是 *最后写入胜利*LWW这意味着保留给定键的具有最大时间戳的写入并丢弃所有具有较旧时间戳的写入。在 [图 9-3](/ch9#fig_distributed_timestamps) 的示例中,当节点 2 接收这两个事件时,它将错误地得出结论,认为 *x* = 1 是更新的值并丢弃写入 *x* = 2因此递增丢失了。
如 ["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww) 中所讨论的,解决不同节点上并发写入值之间冲突的一种方法是 *最后写入胜利*LWW这意味着保留给定键的具有最大时间戳的写入并丢弃所有具有较旧时间戳的写入。在 [图 9-3](#fig_distributed_timestamps) 的示例中,当节点 2 接收这两个事件时,它将错误地得出结论,认为 *x* = 1 是更新的值并丢弃写入 *x* = 2因此递增丢失了。
可以通过确保当值被覆盖时,新值总是具有比被覆盖值更高的时间戳来防止这个问题,即使该时间戳超前于写入者的本地时钟。然而,这会产生额外的读取成本来查找最大的现有时间戳。一些系统,包括 Cassandra 和 ScyllaDB希望在单次往返中写入所有副本因此它们只是使用客户端时钟的时间戳以及最后写入胜利策略 [^62]。这种方法有一些严重的问题:
* 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖先前由具有快速时钟的节点写入的值,直到节点之间的时钟偏差时间过去 [^63] [^65]。这种情况可能导致任意数量的数据被静默丢弃,而不会向应用程序报告任何错误。
* LWW 无法区分快速连续发生的顺序写入(在 [图 9-3](/ch9#fig_distributed_timestamps) 中,客户端 B 的递增肯定发生在客户端 A 的写入 *之后*)和真正并发的写入(两个写入者都不知道对方)。需要额外的因果关系跟踪机制,如版本向量,以防止违反因果关系(见 ["检测并发写入"](/ch6#sec_replication_concurrent))。
* LWW 无法区分快速连续发生的顺序写入(在 [图 9-3](#fig_distributed_timestamps) 中,客户端 B 的递增肯定发生在客户端 A 的写入 *之后*)和真正并发的写入(两个写入者都不知道对方)。需要额外的因果关系跟踪机制,如版本向量,以防止违反因果关系(见 ["检测并发写入"](/ch6#sec_replication_concurrent))。
* 两个节点可能独立生成具有相同时间戳的写入,特别是当时钟只有毫秒分辨率时。需要额外的决胜值(可以简单地是一个大的随机数)来解决此类冲突,但这种方法也可能导致违反因果关系 [^62]。
因此,即使通过保留最 "新" 的值并丢弃其他值来解决冲突很诱人,但重要的是要意识到 "新" 的定义取决于本地日历时钟,它很可能是不正确的。即使使用紧密 NTP 同步的时钟,你也可能在时间戳 100 毫秒(根据发送者的时钟)发送数据包,并让它在时间戳 99 毫秒(根据接收者的时钟)到达 —— 因此看起来数据包在发送之前就到达了,这是不可能的。
@ -376,7 +376,7 @@ while (true) {
假设线程可能暂停这么长时间是合理的吗?不幸的是,是的。有各种原因可能导致这种情况发生:
* 线程访问共享资源(如锁或队列)时的争用可能导致线程花费大量时间等待。转移到具有更多 CPU 核心的机器可能会使此类问题变得更糟,并且争用问题可能难以诊断 [^74]。
* 许多编程语言运行时(如 Java 虚拟机)有 *垃圾回收器*GC偶尔需要停止所有正在运行的线程。过去这种 *"全局暂停" GC 暂停* 有时会持续几分钟 [^75]!使用现代 GC 算法,这不再是一个大问题,但 GC 暂停仍然可能很明显(见 ["限制垃圾回收的影响"](/ch9#sec_distributed_gc_impact))。
* 许多编程语言运行时(如 Java 虚拟机)有 *垃圾回收器*GC偶尔需要停止所有正在运行的线程。过去这种 *"全局暂停" GC 暂停* 有时会持续几分钟 [^75]!使用现代 GC 算法,这不再是一个大问题,但 GC 暂停仍然可能很明显(见 ["限制垃圾回收的影响"](#sec_distributed_gc_impact))。
* 在虚拟化环境中,虚拟机可以被 *挂起*(暂停所有进程的执行并将内存内容保存到磁盘)和 *恢复*(恢复内存内容并继续执行)。这种暂停可能发生在进程执行的任何时间,并且可能持续任意长的时间。这个功能有时用于虚拟机从一台主机到另一台主机的 *实时迁移*,无需重启,在这种情况下,暂停的长度取决于进程写入内存的速率 [^76]。
* 在笔记本电脑和手机等终端用户设备上,执行也可能被任意挂起和恢复,例如,当用户合上笔记本电脑盖时。
* 当操作系统上下文切换到另一个线程时,或者当虚拟机管理程序切换到不同的虚拟机时(在虚拟机中运行时),当前运行的线程可能在代码的任何任意点暂停。在虚拟机的情况下,在其他虚拟机中花费的 CPU 时间称为 *窃取时间*。如果机器负载很重 —— 即,如果有长队列的线程等待运行 —— 暂停的线程可能需要一些时间才能再次运行。
@ -407,7 +407,7 @@ while (true) {
在系统中提供实时保证需要软件栈所有级别的支持:需要 *实时操作系统*RTOS它允许进程在指定的时间间隔内以有保证的 CPU 时间分配进行调度;库函数必须记录其最坏情况执行时间;动态内存分配可能受到限制或完全禁止(实时垃圾回收器存在,但应用程序仍必须确保它不会给 GC 太多工作);必须进行大量的测试和测量以确保满足保证。
所有这些都需要大量的额外工作,并严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,它们最常用于安全关键的嵌入式设备。此外,"实时" 不同于 "高性能" —— 事实上,实时系统可能具有较低的吞吐量,因为它们必须优先考虑及时响应高于一切(另见 ["延迟和资源利用率"](/ch9#sidebar_distributed_latency_utilization))。
所有这些都需要大量的额外工作,并严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,它们最常用于安全关键的嵌入式设备。此外,"实时" 不同于 "高性能" —— 事实上,实时系统可能具有较低的吞吐量,因为它们必须优先考虑及时响应高于一切(另见 ["延迟和资源利用率"](#sidebar_distributed_latency_utilization))。
对于大多数服务器端数据处理系统,实时保证根本不经济或不合适。因此,这些系统必须承受在非实时环境中运行带来的暂停和时钟不稳定性。
@ -455,7 +455,7 @@ while (true) {
分布式应用程序中的锁和租约容易被误用,并且是错误的常见来源 [^84]。让我们看看它们如何出错的一个特定案例。
在 ["进程暂停"](/ch9#sec_distributed_clocks_pauses) 中,我们看到租约是一种超时的锁,如果旧所有者停止响应(可能是因为它崩溃了、暂停太久或与网络断开连接),可以分配给新所有者。你可以在系统需要只有一个某种东西的情况下使用租约。例如:
在 ["进程暂停"](#sec_distributed_clocks_pauses) 中,我们看到租约是一种超时的锁,如果旧所有者停止响应(可能是因为它崩溃了、暂停太久或与网络断开连接),可以分配给新所有者。你可以在系统需要只有一个某种东西的情况下使用租约。例如:
* 只允许一个节点成为数据库分片的主节点,以避免脑裂(见 ["处理节点中断"](/ch6#sec_replication_failover))。
* 只允许一个事务或客户端更新特定资源或对象,以防止并发写入损坏它。
@ -463,14 +463,14 @@ while (true) {
值得仔细思考如果几个节点同时认为它们持有租约会发生什么,可能是由于进程暂停。在第三个例子中,后果只是一些浪费的计算资源,这不是什么大问题。但在前两种情况下,后果可能是数据丢失或损坏,这要严重得多。
例如,[图 9-4](/ch9#fig_distributed_lease_pause) 显示了由于锁的错误实现导致的数据损坏错误。该错误不是理论上的HBase 曾经有这个问题 [^85] [^86]。)假设你想确保存储服务中的文件一次只能由一个客户端访问,因为如果多个客户端试图写入它,文件将被损坏。你尝试通过要求客户端在访问文件之前从锁服务获取租约来实现这一点。这种锁服务通常使用共识算法实现;我们将在 [第 10 章](/ch10#ch_consistency) 中进一步讨论这一点。
例如,[图 9-4](#fig_distributed_lease_pause) 显示了由于锁的错误实现导致的数据损坏错误。该错误不是理论上的HBase 曾经有这个问题 [^85] [^86]。)假设你想确保存储服务中的文件一次只能由一个客户端访问,因为如果多个客户端试图写入它,文件将被损坏。你尝试通过要求客户端在访问文件之前从锁服务获取租约来实现这一点。这种锁服务通常使用共识算法实现;我们将在 [第 10 章](/ch10#ch_consistency) 中进一步讨论这一点。
{{< figure src="/fig/ddia_0904.png" id="fig_distributed_lease_pause" caption="图 9-4. 分布式锁的错误实现:客户端 1 认为它仍然有有效的租约,即使它已经过期,因此损坏了存储中的文件。" class="w-full my-4" >}}
问题是我们在 ["进程暂停"](/ch9#sec_distributed_clocks_pauses) 中讨论的一个例子:如果持有租约的客户端暂停太久,其租约就会过期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它(错误地)认为它仍然有有效的租约,并继续写入文件。我们现在有了脑裂情况:客户端的写入冲突并损坏了文件。
问题是我们在 ["进程暂停"](#sec_distributed_clocks_pauses) 中讨论的一个例子:如果持有租约的客户端暂停太久,其租约就会过期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它(错误地)认为它仍然有有效的租约,并继续写入文件。我们现在有了脑裂情况:客户端的写入冲突并损坏了文件。
[图 9-5](/ch9#fig_distributed_lease_delay) 显示了具有类似后果的另一个问题。在这个例子中没有进程暂停,只有客户端 1 的崩溃。就在客户端 1 崩溃之前,它向存储服务发送了一个写请求,但这个请求在网络中被延迟了很长时间。(请记住 ["实践中的网络故障"](/ch9#sec_distributed_network_faults),数据包有时可能会延迟一分钟或更长时间。)当写请求到达存储服务时,租约已经超时,允许客户端 2 获取它并发出自己的写入。结果是类似于 [图 9-4](/ch9#fig_distributed_lease_pause) 的损坏。
[图 9-5](#fig_distributed_lease_delay) 显示了具有类似后果的另一个问题。在这个例子中没有进程暂停,只有客户端 1 的崩溃。就在客户端 1 崩溃之前,它向存储服务发送了一个写请求,但这个请求在网络中被延迟了很长时间。(请记住 ["实践中的网络故障"](#sec_distributed_network_faults),数据包有时可能会延迟一分钟或更长时间。)当写请求到达存储服务时,租约已经超时,允许客户端 2 获取它并发出自己的写入。结果是类似于 [图 9-4](#fig_distributed_lease_pause) 的损坏。
{{< figure src="/fig/ddia_0905.png" id="fig_distributed_lease_delay" caption="图 9-5. 来自前租约持有者的消息可能会延迟很长时间,并在另一个节点接管租约后到达。" class="w-full my-4" >}}
@ -479,9 +479,9 @@ while (true) {
术语 *僵尸* 有时用于描述尚未发现失去租约的前租约持有者,并且仍在充当当前租约持有者。由于我们不能完全排除僵尸,我们必须确保它们不能以脑裂的形式造成任何损害。这被称为 *隔离* 僵尸。
一些系统试图通过关闭僵尸来隔离它们,例如通过断开它们与网络的连接 [^9]、通过云提供商的管理界面关闭 VM甚至物理关闭机器 [^87]。这种方法被称为 *对端节点爆头*STONITH。不幸的是它存在一些问题它不能防范像 [图 9-5](/ch9#fig_distributed_lease_delay) 中那样的大网络延迟;可能会发生所有节点相互关闭的情况 [^19];到检测到僵尸并关闭它时,可能已经太晚了,数据可能已经被损坏。
一些系统试图通过关闭僵尸来隔离它们,例如通过断开它们与网络的连接 [^9]、通过云提供商的管理界面关闭 VM甚至物理关闭机器 [^87]。这种方法被称为 *对端节点爆头*STONITH。不幸的是它存在一些问题它不能防范像 [图 9-5](#fig_distributed_lease_delay) 中那样的大网络延迟;可能会发生所有节点相互关闭的情况 [^19];到检测到僵尸并关闭它时,可能已经太晚了,数据可能已经被损坏。
一个更强大的隔离解决方案,可以防范僵尸和延迟请求,如 [图 9-6](/ch9#fig_distributed_fencing) 所示。
一个更强大的隔离解决方案,可以防范僵尸和延迟请求,如 [图 9-6](#fig_distributed_fencing) 所示。
{{< figure src="/fig/ddia_0906.png" id="fig_distributed_fencing" caption="图 9-6. 通过只允许按递增隔离令牌顺序写入来使存储访问安全。" class="w-full my-4" >}}
@ -495,7 +495,7 @@ while (true) {
--------
在 [图 9-6](/ch9#fig_distributed_fencing) 中,客户端 1 获得带有令牌 33 的租约,但随后进入长时间暂停,租约过期。客户端 2 获得带有令牌 34 的租约(数字总是增加),然后将其写请求发送到存储服务,包括令牌 34。稍后客户端 1 恢复执行并将其写入发送到存储服务,包括其令牌值 33。然而存储服务记得它已经处理了具有更高令牌编号34的写入因此它拒绝带有令牌 33 的请求。刚刚获得租约的客户端必须立即向存储服务进行写入,一旦该写入完成,任何僵尸都被隔离了。
在 [图 9-6](#fig_distributed_fencing) 中,客户端 1 获得带有令牌 33 的租约,但随后进入长时间暂停,租约过期。客户端 2 获得带有令牌 34 的租约(数字总是增加),然后将其写请求发送到存储服务,包括令牌 34。稍后客户端 1 恢复执行并将其写入发送到存储服务,包括其令牌值 33。然而存储服务记得它已经处理了具有更高令牌编号34的写入因此它拒绝带有令牌 33 的请求。刚刚获得租约的客户端必须立即向存储服务进行写入,一旦该写入完成,任何僵尸都被隔离了。
如果 ZooKeeper 是你的锁服务,你可以使用事务 ID `zxid` 或节点版本 `cversion` 作为隔离令牌 [^85]。使用 etcd修订号与租约 ID 一起起着类似的作用 [^89]。Hazelcast 中的 FencedLock API 明确生成隔离令牌 [^90]。
@ -507,12 +507,12 @@ while (true) {
例如,想象存储服务是一个具有最后写入胜利冲突解决的无主复制键值存储(见 ["无主复制"](/ch6#sec_replication_leaderless))。在这样的系统中,客户端直接向每个副本发送写入,每个副本根据客户端分配的时间戳独立决定是否接受写入。
如 [图 9-7](/ch9#fig_distributed_fencing_leaderless) 所示,你可以将写入者的隔离令牌放在时间戳的最高有效位或数字中。然后你可以确保新租约持有者生成的任何时间戳都将大于旧租约持有者的任何时间戳,即使旧租约持有者的写入发生得更晚。
如 [图 9-7](#fig_distributed_fencing_leaderless) 所示,你可以将写入者的隔离令牌放在时间戳的最高有效位或数字中。然后你可以确保新租约持有者生成的任何时间戳都将大于旧租约持有者的任何时间戳,即使旧租约持有者的写入发生得更晚。
{{< figure src="/fig/ddia_0907.png" id="fig_distributed_fencing_leaderless" caption="图 9-7. 使用隔离令牌保护对无主复制数据库的写入。" class="w-full my-4" >}}
在 [图 9-7](/ch9#fig_distributed_fencing_leaderless) 中,客户端 2 有隔离令牌 34因此它所有以 34… 开头的时间戳都大于客户端 1 生成的任何以 33… 开头的时间戳。客户端 2 写入副本的仲裁,但它无法到达副本 3。这意味着当僵尸客户端 1 稍后尝试写入时,它的写入可能在副本 3 上成功,即使它被副本 1 和 2 忽略。这不是问题,因为后续的仲裁读取将更喜欢具有更大时间戳的客户端 2 的写入,读修复或反熵最终将覆盖客户端 1 写入的值。
在 [图 9-7](#fig_distributed_fencing_leaderless) 中,客户端 2 有隔离令牌 34因此它所有以 34… 开头的时间戳都大于客户端 1 生成的任何以 33… 开头的时间戳。客户端 2 写入副本的仲裁,但它无法到达副本 3。这意味着当僵尸客户端 1 稍后尝试写入时,它的写入可能在副本 3 上成功,即使它被副本 1 和 2 忽略。这不是问题,因为后续的仲裁读取将更喜欢具有更大时间戳的客户端 2 的写入,读修复或反熵最终将覆盖客户端 1 写入的值。
从这些例子可以看出,假设任何时候只有一个节点持有租约是不安全的。幸运的是,通过一点小心,你可以使用隔离令牌来防止僵尸和延迟请求造成任何损害。
@ -594,7 +594,7 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和
为了定义算法 *正确* 的含义,我们可以描述它的 *属性*。例如,排序算法的输出具有这样的属性:对于输出列表的任何两个不同元素,左边的元素小于右边的元素。这只是定义列表排序含义的正式方式。
同样,我们可以写下我们希望分布式算法具有的属性,以定义正确的含义。例如,如果我们为锁生成隔离令牌(见 ["隔离僵尸进程和延迟请求"](/ch9#sec_distributed_fencing_tokens)),我们可能要求算法具有以下属性:
同样,我们可以写下我们希望分布式算法具有的属性,以定义正确的含义。例如,如果我们为锁生成隔离令牌(见 ["隔离僵尸进程和延迟请求"](#sec_distributed_fencing_tokens)),我们可能要求算法具有以下属性:
唯一性
: 没有两个隔离令牌请求返回相同的值。