docs(zh): annotate technical terms on first use (#394)

This commit is contained in:
Feng Ruohang
2026-08-28 15:15:29 +08:00
parent c53791d336
commit 2f7002c065
14 changed files with 427 additions and 427 deletions

View File

@@ -17,15 +17,15 @@ breadcrumbs: false
少量数据可以在单台机器上存储和处理,通常比较容易应付。然而,随着数据量或查询速率增长,数据就需要分布到多台机器上,由此带来许多挑战。应用需求变得更加复杂之后,把所有数据放在一个系统里也不再够用,往往需要组合多个能力各异的存储或处理系统。
如果数据管理是开发应用时面临的主要挑战之一,我们就称这种应用为 *数据密集型* 应用 [^1]。*计算密集型* 系统的难点在于如何将某项极其庞大的计算并行化;而对数据密集型应用来说,我们通常更关心如何存储和处理海量数据、如何管理数据变更、如何在故障和并发面前保证一致性,以及如何维持服务的高可用性。
如果数据管理是开发应用时面临的主要挑战之一,我们就称这种应用为 *数据密集型**data-intensive*)应用 [^1]。*计算密集型**compute-intensive*系统的难点在于如何将某项极其庞大的计算并行化;而对数据密集型应用来说,我们通常更关心如何存储和处理海量数据、如何管理数据变更、如何在故障和并发面前保证一致性,以及如何维持服务的高可用性。
这类应用通常由一些标准构件搭建而成,它们提供各种常用功能。例如,许多应用都需要:
* 存储数据,以便自己或其他应用日后能够再次找到(*数据库*
* 记住开销昂贵的操作结果,加快读取速度(*缓存*
* 允许用户按关键字搜索数据,或以各种方式过滤数据(*搜索索引*
* 在事件和数据变更发生后立即处理(*流处理*
* 定期处理累积的大批量数据(*批处理*
* 存储数据,以便自己或其他应用日后能够再次找到(*数据库**database*
* 记住开销昂贵的操作结果,加快读取速度(*缓存**cache*
* 允许用户按关键字搜索数据,或以各种方式过滤数据(*搜索索引**search index*
* 在事件和数据变更发生后立即处理(*流处理**stream processing*
* 定期处理累积的大批量数据(*批处理**batch processing*
构建应用时,我们通常会选用几个软件系统或服务(例如数据库和 API再用应用代码把它们拼接起来。如果你的用途恰好是这些数据系统原本就为之设计的整个过程可能相当容易。
@@ -33,7 +33,7 @@ breadcrumbs: false
本书将帮助你决定采用哪些技术,以及如何组合这些技术。正如你将看到的,并不存在一种从根本上优于其他方案的做法;每种方案都有利有弊。本书会教你提出恰当的问题来评估和比较数据系统,从而找出最能满足特定应用需求的方案。
我们的旅程从当今组织使用数据的一些典型方式开始。这里的许多思想源自 *企业软件*,也就是大型组织(例如大公司和政府机构)的软件需求与工程实践。因为在过去,只有大型组织才拥有如此庞大的数据量,需要复杂的技术方案;只要数据量足够小,用电子表格保存就行了!不过近年来,小公司和初创企业管理海量数据、构建数据密集型系统,也已变得十分普遍。
我们的旅程从当今组织使用数据的一些典型方式开始。这里的许多思想源自 *企业软件**enterprise software*,也就是大型组织(例如大公司和政府机构)的软件需求与工程实践。因为在过去,只有大型组织才拥有如此庞大的数据量,需要复杂的技术方案;只要数据量足够小,用电子表格保存就行了!不过近年来,小公司和初创企业管理海量数据、构建数据密集型系统,也已变得十分普遍。
数据系统的一项关键挑战是:不同的人需要用数据做截然不同的事情。在一家公司里,你和你的团队有自己的一套优先事项;另一个团队即使在处理同一份数据,也可能有完全不同的目标。而且,这些目标未必得到明确表达,因而很容易造成误解,并引发对正确方案的争论。
@@ -48,34 +48,34 @@ breadcrumbs: false
> [!TIP] 术语:前端与后端
>
> 本书讨论的许多内容都与 *后端开发* 有关。以 Web 应用为例,运行在浏览器中的客户端代码称为 *前端*,处理用户请求的服务器端代码则称为 *后端*。移动应用与前端相似:它们负责提供用户界面,并且常常经由互联网与服务器端后端通信。前端有时也会在用户设备上管理本地数据 [^2],但数据基础设施面临的最大挑战往往在后端:前端只需处理一个用户的数据,而后端要代表 *所有* 用户管理数据。
> 本书讨论的许多内容都与 *后端开发**backend development*有关。以 Web 应用为例,运行在浏览器中的客户端代码称为 *前端**frontend*,处理用户请求的服务器端代码则称为 *后端**backend*。移动应用与前端相似:它们负责提供用户界面,并且常常经由互联网与服务器端后端通信。前端有时也会在用户设备上管理本地数据 [^2],但数据基础设施面临的最大挑战往往在后端:前端只需处理一个用户的数据,而后端要代表 *所有* 用户管理数据。
>
> 后端服务通常可以通过 HTTP有时是 WebSocket访问。它一般由一些应用代码组成这些代码在一个或多个数据库中读写数据有时也与缓存、消息队列等其他数据系统交互这些系统可以统称为 *数据基础设施*。应用代码通常是 *无状态* 的,也就是说,处理完一个 HTTP 请求后,它就会忘掉有关该请求的一切。任何需要在请求之间持久保存的信息,都必须存放在客户端或服务器端的数据基础设施中。
> 后端服务通常可以通过 HTTP有时是 WebSocket访问。它一般由一些应用代码组成这些代码在一个或多个数据库中读写数据有时也与缓存、消息队列等其他数据系统交互这些系统可以统称为 *数据基础设施**data infrastructure*。应用代码通常是 *无状态**stateless*的,也就是说,处理完一个 HTTP 请求后,它就会忘掉有关该请求的一切。任何需要在请求之间持久保存的信息,都必须存放在客户端或服务器端的数据基础设施中。
## 分析型与事务型系统 {#sec_introduction_analytics}
如果你在企业中从事数据系统工作,很可能会遇到几类与数据打交道的人。第一类是 *后端工程师*,负责构建处理数据读取和更新请求的服务。这些服务通常直接面向外部用户,或通过其他服务间接为外部用户提供功能(参见[“微服务与无服务器”](/ch1#sec_introduction_microservices));有时也只供组织内其他部门使用。
如果你在企业中从事数据系统工作,很可能会遇到几类与数据打交道的人。第一类是 *后端工程师**backend engineer*,负责构建处理数据读取和更新请求的服务。这些服务通常直接面向外部用户,或通过其他服务间接为外部用户提供功能(参见[“微服务与无服务器”](/ch1#sec_introduction_microservices));有时也只供组织内其他部门使用。
除了管理后端服务的团队,通常还有两类人需要访问组织的数据:*业务分析师* 根据组织的活动生成报表,帮助管理层做出更好的决策,这就是 *商业智能*BI*数据科学家* 则从数据中寻找新的洞见,或者利用数据分析和机器学习/AI 构建面向用户的产品功能,例如电商网站上“购买了 X 的人也购买了 Y”的推荐、风险评分或垃圾邮件过滤等预测分析以及搜索结果排名。
除了管理后端服务的团队,通常还有两类人需要访问组织的数据:*业务分析师**business analyst*根据组织的活动生成报表,帮助管理层做出更好的决策,这就是 *商业智能*BI*数据科学家**data scientist*则从数据中寻找新的洞见,或者利用数据分析和机器学习/AI 构建面向用户的产品功能,例如电商网站上“购买了 X 的人也购买了 Y”的推荐、风险评分或垃圾邮件过滤等预测分析以及搜索结果排名。
业务分析师和数据科学家使用的工具不同,工作方式也不同,但仍有一些共同之处:两者都要进行 *分析*,也就是查看用户和后端服务产生的数据,但通常不会修改这些数据(纠正错误或许除外)。他们可能会创建衍生数据集,以某种方式处理原始数据。由此形成了两类相互分离的系统——本书将始终沿用这种区分:
业务分析师和数据科学家使用的工具不同,工作方式也不同,但仍有一些共同之处:两者都要进行 *分析**analytics*,也就是查看用户和后端服务产生的数据,但通常不会修改这些数据(纠正错误或许除外)。他们可能会创建衍生数据集,以某种方式处理原始数据。由此形成了两类相互分离的系统——本书将始终沿用这种区分:
* *事务型系统* 由创建数据的后端服务和数据基础设施组成,例如直接为外部用户提供服务。应用代码根据用户执行的操作,读取和修改数据库中的数据。
* *分析型系统* 服务于业务分析师和数据科学家。它们保存事务型系统数据的只读副本,并针对分析所需的数据处理方式进行优化。
* *事务型系统**operational system*由创建数据的后端服务和数据基础设施组成,例如直接为外部用户提供服务。应用代码根据用户执行的操作,读取和修改数据库中的数据。
* *分析型系统**analytical system*服务于业务分析师和数据科学家。它们保存事务型系统数据的只读副本,并针对分析所需的数据处理方式进行优化。
正如下一节将要说明的,事务型系统与分析型系统往往有充分的理由彼此分离。随着这两类系统日趋成熟,又出现了两个专业角色:*数据工程师**分析工程师*。数据工程师懂得如何集成事务型系统和分析型系统,并对组织的数据基础设施承担更广泛的责任 [^3]。分析工程师则对数据进行建模和转换,使其更便于组织内的业务分析师和数据科学家使用 [^4]。
正如下一节将要说明的,事务型系统与分析型系统往往有充分的理由彼此分离。随着这两类系统日趋成熟,又出现了两个专业角色:*数据工程师**data engineer*)与 *分析工程师**analytics engineer*。数据工程师懂得如何集成事务型系统和分析型系统,并对组织的数据基础设施承担更广泛的责任 [^3]。分析工程师则对数据进行建模和转换,使其更便于组织内的业务分析师和数据科学家使用 [^4]。
许多工程师专攻事务型或分析型系统中的一类。不过,本书会同时涵盖两者,因为它们都在组织的数据生命周期中扮演重要角色。我们将深入探讨为内部和外部用户提供服务所需的数据基础设施,帮助你更好地与分界线另一侧的同事合作。
### 事务处理与分析的特征 {#sec_introduction_oltp}
在商业数据处理的早期,每次写入数据库通常都对应一笔 *商业交易*:完成一笔销售、向供应商下订单、发放员工工资,等等。后来,数据库的应用扩展到不涉及金钱往来的领域,*事务* 这个名称却沿用下来,用来指构成一个逻辑单元的一组读写操作。
在商业数据处理的早期,每次写入数据库通常都对应一笔 *商业交易*:完成一笔销售、向供应商下订单、发放员工工资,等等。后来,数据库的应用扩展到不涉及金钱往来的领域,*事务**transaction*这个名称却沿用下来,用来指构成一个逻辑单元的一组读写操作。
> [!NOTE]
> [第 8 章](/ch8#ch_transactions)会详细探讨“事务”的含义。本章则宽泛地用这个词指代低延迟的读写操作。
尽管数据库开始处理形形色色的数据——社交媒体帖子、游戏中的操作、地址簿联系人,等等——基本访问模式仍与处理商业交易相似。事务型系统通常按某个键查找少量记录(称为 *点查询*),再根据用户输入插入、更新或删除记录。由于这些应用具有交互性,这种访问模式称为 *联机事务处理*OLTP
尽管数据库开始处理形形色色的数据——社交媒体帖子、游戏中的操作、地址簿联系人,等等——基本访问模式仍与处理商业交易相似。事务型系统通常按某个键查找少量记录(称为 *点查询**point query*),再根据用户输入插入、更新或删除记录。由于这些应用具有交互性,这种访问模式称为 *联机事务处理*OLTP
与此同时,数据库也越来越多地用于分析,而分析的访问模式与 OLTP 大相径庭。分析查询通常会扫描海量记录,计算计数、总和或平均值等聚合统计量,而不是把一条条记录返回给用户。例如,连锁超市的业务分析师可能想回答下面的问题:
@@ -101,17 +101,17 @@ breadcrumbs: false
事务型系统一般不允许用户自行编写 SQL 查询并提交给数据库执行否则用户可能读取或修改自己无权访问的数据。用户也可能写出执行开销很高的查询影响其他用户使用数据库。因此OLTP 系统主要执行写在应用代码中的一组固定查询,只在维护或排查故障时偶尔运行一次性自定义查询。分析数据库则不同:它通常允许用户自由手写任意 SQL 查询,也可以通过 Tableau、Looker 或 Microsoft Power BI 等数据可视化或仪表盘工具自动生成查询。
还有一类系统专为分析型负载(即聚合大量记录的查询)而设计,却嵌入在面向用户的产品中。这类用途称为 *产品分析**实时分析*,为此设计的系统包括 Pinot、Druid 和 ClickHouse [^6]。
还有一类系统专为分析型负载(即聚合大量记录的查询)而设计,却嵌入在面向用户的产品中。这类用途称为 *产品分析**product analytics*)或 *实时分析**real-time analytics*,为此设计的系统包括 Pinot、Druid 和 ClickHouse [^6]。
### 数据仓库 {#sec_introduction_dwh}
起初同一套数据库既用于事务处理也用于分析查询。事实证明SQL 在这方面非常灵活,两类查询都能胜任。不过到了 20 世纪 80 年代末和 90 年代初,企业开始不再使用 OLTP 系统进行分析,转而在一个独立的数据库系统上运行分析查询。这个独立的数据库称为 *数据仓库*
起初同一套数据库既用于事务处理也用于分析查询。事实证明SQL 在这方面非常灵活,两类查询都能胜任。不过到了 20 世纪 80 年代末和 90 年代初,企业开始不再使用 OLTP 系统进行分析,转而在一个独立的数据库系统上运行分析查询。这个独立的数据库称为 *数据仓库**data warehouse*
一家大型企业可能拥有几十乃至上百个联机事务处理系统:支撑面向客户的网站,控制实体店的销售终端(收银系统),跟踪仓库库存,规划车辆路线,管理供应商和员工,以及执行许多其他任务。每个系统都很复杂,都需要专门的团队维护,因此最终大多彼此独立运行。
通常不宜让业务分析师和数据科学家直接查询这些 OLTP 系统,原因有以下几点:
* 所需数据可能散落在多个事务型系统中,很难通过一条查询组合这些数据集;这个问题称为 *数据孤岛*
* 所需数据可能散落在多个事务型系统中,很难通过一条查询组合这些数据集;这个问题称为 *数据孤岛**data silo*
* 适合 OLTP 的模式和数据布局不太适合分析(参见[“星型与雪花型:分析模式”](/ch3#sec_datamodels_analytics))。
* 分析查询的开销可能很高;在 OLTP 数据库上运行它们,会影响其他用户的性能。
* 出于安全或合规方面的考虑OLTP 系统可能位于一个不允许用户直接访问的独立网络中。
@@ -136,16 +136,16 @@ breadcrumbs: false
数据仓库通常采用 *关系* 数据模型,并通过 SQL 查询(参见[第 3 章](/ch3#ch_datamodels)),有时还会配合专门的商业智能软件。这种模型很适合业务分析师所需的查询,却不太适合数据科学家的需求;他们可能需要完成下面的工作:
* 把数据转换成适合训练机器学习模型的形式。这通常要把数据库表中的行列转换为数值向量或矩阵,其中的数值称为 *特征*。以尽可能提高训练后模型性能的方式完成这种转换,称为 *特征工程*;它通常需要编写难以用 SQL 表达的定制代码。
* 把数据转换成适合训练机器学习模型的形式。这通常要把数据库表中的行列转换为数值向量或矩阵,其中的数值称为 *特征**feature*。以尽可能提高训练后模型性能的方式完成这种转换,称为 *特征工程**feature engineering*;它通常需要编写难以用 SQL 表达的定制代码。
* 对文本数据(例如商品评论)应用自然语言处理技术,尝试从中提取结构化信息(例如作者表达的情绪,或提到了哪些主题)。同样,他们也可能要用计算机视觉技术从照片中提取结构化信息。
尽管人们一直尝试为 SQL 数据模型加入机器学习算子 [^12],也在关系模型的基础上构建高效的机器学习系统 [^13],许多数据科学家仍不愿在数据仓库这样的关系数据库中工作。他们往往更喜欢 pandas、scikit-learn 等 Python 数据分析库R 等统计分析语言,以及 Spark 等分布式分析框架 [^14]。我们将在[“数据框、矩阵与数组”](/ch3#sec_datamodels_dataframes)中进一步讨论这些工具。
因此,组织需要以适合数据科学家使用的形式提供数据。解决方案是 *数据湖*:一个集中的数据存储库,保存一切可能对分析有用的数据副本,这些数据通过 ETL 流程从事务型系统取得。数据湖与数据仓库的区别在于,它只保存文件,并不强制规定文件格式或数据模型。数据湖中的文件可以是一批数据库记录,以 Avro 或 Parquet 等文件格式编码(参见[第 5 章](/ch5#ch_encoding));也完全可以是文本、图像、视频、传感器读数、稀疏矩阵、特征向量、基因组序列,或任何其他类型的数据 [^15]。数据湖不仅更加灵活,而且往往比关系数据存储更便宜,因为它可以使用对象存储等廉价通用的文件存储(参见[“云原生系统架构”](/ch1#sec_introduction_cloud_native))。
因此,组织需要以适合数据科学家使用的形式提供数据。解决方案是 *数据湖**data lake*:一个集中的数据存储库,保存一切可能对分析有用的数据副本,这些数据通过 ETL 流程从事务型系统取得。数据湖与数据仓库的区别在于,它只保存文件,并不强制规定文件格式或数据模型。数据湖中的文件可以是一批数据库记录,以 Avro 或 Parquet 等文件格式编码(参见[第 5 章](/ch5#ch_encoding));也完全可以是文本、图像、视频、传感器读数、稀疏矩阵、特征向量、基因组序列,或任何其他类型的数据 [^15]。数据湖不仅更加灵活,而且往往比关系数据存储更便宜,因为它可以使用对象存储等廉价通用的文件存储(参见[“云原生系统架构”](/ch1#sec_introduction_cloud_native))。
ETL 流程已经泛化为 *数据管道*;在某些情况下,数据湖成为事务型系统通往数据仓库途中的一站。数据湖以事务型系统产生的“原始”形态保存数据,不把它们转换成关系数据仓库的模式。这种做法的好处是,每个数据消费者都可以把原始数据转换成最适合自己需求的形式。它有一个诙谐的名字——*寿司原则*:“原始数据更好”[^16]。
ETL 流程已经泛化为 *数据管道**data pipeline*;在某些情况下,数据湖成为事务型系统通往数据仓库途中的一站。数据湖以事务型系统产生的“原始”形态保存数据,不把它们转换成关系数据仓库的模式。这种做法的好处是,每个数据消费者都可以把原始数据转换成最适合自己需求的形式。它有一个诙谐的名字——*寿司原则**sushi principle*:“原始数据更好”[^16]。
除了把数据从数据湖载入独立的数据仓库也可以直接针对数据湖中的文件运行典型的数据仓库工作负载SQL 查询和业务分析),并与数据科学和机器学习工作负载并存。这种架构称为 *数据湖仓*;它需要在数据湖的文件存储之上增加查询执行引擎和元数据层(例如模式管理)[^17]。
除了把数据从数据湖载入独立的数据仓库也可以直接针对数据湖中的文件运行典型的数据仓库工作负载SQL 查询和业务分析),并与数据科学和机器学习工作负载并存。这种架构称为 *数据湖仓**data lakehouse*;它需要在数据湖的文件存储之上增加查询执行引擎和元数据层(例如模式管理)[^17]。
Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
@@ -155,25 +155,25 @@ Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
此外,分析数据的提供形式越来越多,不仅包括文件和关系表,还包括事件流(参见[第 12 章](/ch12#ch_stream))。采用基于文件的分析时,可以定期(例如每天)重新运行分析,以响应数据的变化;流处理则能让分析系统快得多,通常在几秒内便对事件作出响应。具体是否值得采用流处理,要看应用对时效性的要求。例如,它可以用来识别并阻止潜在的欺诈或滥用活动。
有时,分析系统的输出还会提供给事务型系统,这个过程有时称为 *反向 ETL* [^19]。例如,在分析系统中训练好的机器学习模型可以部署到生产环境,向最终用户生成“购买了 X 的人也购买了 Y”之类的推荐。分析系统中这类投入实际应用的输出也称为 *数据产品* [^20]。机器学习模型可以借助 TFX、Kubeflow 或 MLflow 等专用工具部署到事务型系统。
有时,分析系统的输出还会提供给事务型系统,这个过程有时称为 *反向 ETL**reverse ETL*[^19]。例如,在分析系统中训练好的机器学习模型可以部署到生产环境,向最终用户生成“购买了 X 的人也购买了 Y”之类的推荐。分析系统中这类投入实际应用的输出也称为 *数据产品**data product*[^20]。机器学习模型可以借助 TFX、Kubeflow 或 MLflow 等专用工具部署到事务型系统。
### 权威记录系统与衍生数据 {#sec_introduction_derived}
除了区分事务型系统与分析型系统,本书还区分 *权威记录系统**衍生数据系统*。这组术语很有用,可以帮助你理清数据在系统中的流向:
除了区分事务型系统与分析型系统,本书还区分 *权威记录系统**system of record*)与 *衍生数据系统**derived data system*。这组术语很有用,可以帮助你理清数据在系统中的流向:
权威记录系统
: 权威记录系统也称 *权威数据源*,保存某类数据的权威或 *规范* 版本。新数据到来时,例如用户输入,首先写入这里。每项事实只表示一次通常采用 *规范化* 表示参见[“规范化、反规范化与连接”](/ch3#sec_datamodels_normalization))。如果其他系统与权威记录系统的数据不一致,那么按照定义,应以权威记录系统中的值为准。
: 权威记录系统也称 *权威数据源**source of truth*,保存某类数据的权威或 *规范**canonical*版本。新数据到来时,例如用户输入,首先写入这里。每项事实只表示一次通常采用 *规范化**normalized*表示参见[“规范化、反规范化与连接”](/ch3#sec_datamodels_normalization))。如果其他系统与权威记录系统的数据不一致,那么按照定义,应以权威记录系统中的值为准。
衍生数据系统
: 衍生系统中的数据,是以另一个系统中的现有数据为基础,经过某种转换或处理而得到的结果。衍生数据即使丢失,也可以从原始数据源重新创建。缓存就是一个典型例子:若数据在缓存中,便可直接返回;若缓存中没有所需数据,则可以退回底层数据库读取。反规范化值、索引、物化视图、转换后的数据表示,以及在数据集上训练的模型,也都属于这一类。
从技术上讲,衍生数据是 *冗余* 的,因为它复制了现有信息。但是,要让读查询获得良好性能,这种冗余往往不可或缺。你可以从同一个数据源衍生出多个不同的数据集,从不同“视角”观察数据。
从技术上讲,衍生数据是 *冗余**redundant*的,因为它复制了现有信息。但是,要让读查询获得良好性能,这种冗余往往不可或缺。你可以从同一个数据源衍生出多个不同的数据集,从不同“视角”观察数据。
分析型系统通常属于衍生数据系统,因为它们消费的是在别处创建的数据。事务型服务则可能同时包含权威记录系统和衍生数据系统:权威记录系统是数据首先写入的主数据库,衍生数据系统则是加快常见读取操作的索引和缓存,尤其适用于权威记录系统无法高效回答的查询。
大多数数据库、存储引擎和查询语言,本身并不天然是权威记录系统或衍生系统。数据库只是工具,如何使用由你决定。一个系统究竟属于哪一类,取决于它在应用中的用法,而不是采用了什么工具。明确哪些数据衍生自哪些其他数据,可以让原本令人困惑的系统架构变得清晰。
如果一个系统的数据衍生自另一个系统,那么每当权威记录系统中的原始数据发生变化,就需要有相应流程更新衍生数据。遗憾的是,许多数据库在设计时都假定应用只会使用这一个数据库,因而很难集成多个系统并传播这类更新。我们将在[“数据集成”](/ch13#sec_future_integration)中讨论 *数据集成* 的各种方法;借助这些方法,可以组合多个数据系统,完成单个系统无法独力完成的任务。
如果一个系统的数据衍生自另一个系统,那么每当权威记录系统中的原始数据发生变化,就需要有相应流程更新衍生数据。遗憾的是,许多数据库在设计时都假定应用只会使用这一个数据库,因而很难集成多个系统并传播这类更新。我们将在[“数据集成”](/ch13#sec_future_integration)中讨论 *数据集成**data integration*的各种方法;借助这些方法,可以组合多个数据系统,完成单个系统无法独力完成的任务。
至此,我们对分析与事务处理的比较告一段落。下一节要讨论的另一项权衡,你可能已经见过许多人反复争论。
@@ -188,7 +188,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
{{< fig num="1-2" id="fig_cloud_spectrum" src="/fig/ddia_0102.png" caption="软件类型及其运维方式的连续谱。" class="ddia-figure ddia-figure--panorama" width="1772" height="392" />}}
这条连续谱的中间,是由你 *自托管* 的现成软件(可以是开源软件,也可以是商业软件),也就是由你亲自部署。例如,下载 MySQL 并安装到一台由你掌控的服务器上。这台服务器可以是你自己的硬件——通常称为 *本地部署*,即使它实际位于租用的数据中心机架里,并不真的在你的自有场所——也可以是云中的虚拟机,即 *基础设施即服务*IaaS。这条连续谱上还有更多中间位置例如采用开源软件但运行自己修改过的版本。
这条连续谱的中间,是由你 *自托管**self-hosted*的现成软件(可以是开源软件,也可以是商业软件),也就是由你亲自部署。例如,下载 MySQL 并安装到一台由你掌控的服务器上。这台服务器可以是你自己的硬件——通常称为 *本地部署**on-premises*,即使它实际位于租用的数据中心机架里,并不真的在你的自有场所——也可以是云中的虚拟机,即 *基础设施即服务*IaaS。这条连续谱上还有更多中间位置例如采用开源软件但运行自己修改过的版本。
与这条连续谱相互独立的另一个问题是:无论在云端还是本地,究竟要 *如何* 部署服务,例如是否采用 Kubernetes 之类的编排框架。不过,部署工具的选择不在本书讨论范围内,因为还有其他因素对数据系统架构的影响更大。
@@ -218,7 +218,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
### 云原生系统架构 {#sec_introduction_cloud_native}
云计算不仅采用了不同的经济模式——订阅服务,而不是购买硬件和软件许可证,再自行运行软件——它的兴起也从技术层面深刻影响了数据系统的实现方式。*云原生* 一词用来描述专为利用云服务优势而设计的架构。
云计算不仅采用了不同的经济模式——订阅服务,而不是购买硬件和软件许可证,再自行运行软件——它的兴起也从技术层面深刻影响了数据系统的实现方式。*云原生**cloud-native*一词用来描述专为利用云服务优势而设计的架构。
原则上,几乎任何可以自托管的软件都能以云服务的形式提供;事实上,许多流行的数据系统如今都有相应的托管服务。然而,从一开始就按云原生思路设计的系统已经展现出若干优势:在相同硬件上性能更好,故障恢复更快,能够迅速调整计算资源以匹配负载,并且可以支持更大的数据集 [^25] [^26] [^27]。{{< xref tbl="1-2" page="/ch1" anchor="tab_cloud_native_dbs" >}}表 1-2{{< /xref >}}列出了两类系统的一些例子。
@@ -232,11 +232,11 @@ Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
许多自托管数据系统对运行环境的要求非常简单:在 Linux 或 Windows 等常规操作系统上运行,把数据存成文件系统中的文件,并通过 TCP/IP 等标准网络协议通信。少数系统依赖 GPU用于机器学习或 RDMA 网卡等特殊硬件但总体而言自托管软件使用的都是十分通用的计算资源CPU、内存、文件系统和 IP 网络。
在云中这类软件可以运行在基础设施即服务IaaS环境里使用一台或多台虚拟机也称为 *实例*),每台实例分配一定数量的 CPU、内存、磁盘和网络带宽。与物理机器相比云实例的开通速度更快可选规格也更多但除此之外它们与传统计算机相似你可以随意运行任何软件也要自行负责管理。
在云中这类软件可以运行在基础设施即服务IaaS环境里使用一台或多台虚拟机也称为 *实例**instance*),每台实例分配一定数量的 CPU、内存、磁盘和网络带宽。与物理机器相比云实例的开通速度更快可选规格也更多但除此之外它们与传统计算机相似你可以随意运行任何软件也要自行负责管理。
与之相对,云原生服务的关键思想是:不仅使用操作系统管理的计算资源,还要在低层云服务的基础上构建更高层的服务。例如:
* Amazon S3、Azure Blob Storage 和 Cloudflare R2 等 *对象存储* 服务用于保存大型文件。它们的 API 比普通文件系统更受限,只提供基本的文件读写;但好处是隐藏了底层物理机器。服务会自动把数据分布到许多机器上,你不必担心其中某台机器的磁盘空间耗尽。即使某些机器或其磁盘彻底损坏,数据也不会丢失。
* Amazon S3、Azure Blob Storage 和 Cloudflare R2 等 *对象存储**object storage*服务用于保存大型文件。它们的 API 比普通文件系统更受限,只提供基本的文件读写;但好处是隐藏了底层物理机器。服务会自动把数据分布到许多机器上,你不必担心其中某台机器的磁盘空间耗尽。即使某些机器或其磁盘彻底损坏,数据也不会丢失。
* 许多其他服务又建立在对象存储和其他云服务之上。例如Snowflake 是一种云端分析数据库(数据仓库),依靠 S3 存储数据 [^27];还有一些服务进一步构建在 Snowflake 之上。
计算机领域的抽象一向如此:该选择哪一层,并没有唯一正确的答案。一般来说,层次越高的抽象,往往越面向特定用例。如果你的需求恰好符合某个高层系统的设计场景,那么直接使用现成系统,通常比自己用低层系统搭建省心得多,也足以满足需要。反过来,如果没有任何高层系统符合需求,那就只能用低层组件自行构建。
@@ -247,13 +247,13 @@ Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
云中的计算实例(虚拟机)也可以连接本地磁盘,但云原生系统通常更愿意把它们当作临时缓存,而不是长期存储。原因在于:一旦相应实例发生故障,本地磁盘就无法访问;为了适应负载变化而把实例换成另一台物理机上的更大或更小规格时,本地磁盘同样无法访问。
作为本地磁盘的替代方案,云服务还提供虚拟磁盘存储,可以从一个实例卸载,再挂载到另一个实例上,例如 Amazon EBS、Azure 托管磁盘和 Google Cloud 持久磁盘。这种虚拟磁盘并不是真正的物理磁盘,而是由另一组机器提供的云服务,用来模拟磁盘的行为——也就是 *块设备*,其中每个块通常为 4 KiB。这项技术让传统的磁盘软件可以在云中运行但块设备模拟会引入额外开销如果系统从一开始便针对云设计这些开销本来可以避免 [^25]。它还使应用对网络异常极为敏感,因为虚拟块设备上的每次 I/O 实际上都是一次网络调用 [^28]。
作为本地磁盘的替代方案,云服务还提供虚拟磁盘存储,可以从一个实例卸载,再挂载到另一个实例上,例如 Amazon EBS、Azure 托管磁盘和 Google Cloud 持久磁盘。这种虚拟磁盘并不是真正的物理磁盘,而是由另一组机器提供的云服务,用来模拟磁盘的行为——也就是 *块设备**block device*,其中每个块通常为 4 KiB。这项技术让传统的磁盘软件可以在云中运行但块设备模拟会引入额外开销如果系统从一开始便针对云设计这些开销本来可以避免 [^25]。它还使应用对网络异常极为敏感,因为虚拟块设备上的每次 I/O 实际上都是一次网络调用 [^28]。
为了解决这个问题云原生服务通常避开虚拟磁盘转而建立在针对特定工作负载优化的专用存储服务之上。S3 等对象存储服务适合长期保存较大的文件,大小从数百 KB 到数 GB 不等。数据库中的单行或单个值通常远小于这个范围;因此,云数据库通常在一个独立服务中管理较小的值,并把包含许多个值的较大数据块存入对象存储 [^26] [^29]。我们将在[第 4 章](/ch4#ch_storage)介绍相应的实现方法。
在传统系统架构中同一台计算机同时负责存储磁盘和计算CPU 与内存);而在云原生系统中,这两项职责在一定程度上相互分离,或者说被 *解耦* 了 [^9] [^27] [^30] [^31]。例如S3 只负责保存文件;如果要分析其中的数据,就必须在 S3 之外运行分析代码。这也意味着数据需要通过网络传输,我们将在[“分布式与单节点系统”](/ch1#sec_introduction_distributed)中进一步讨论。
在传统系统架构中同一台计算机同时负责存储磁盘和计算CPU 与内存);而在云原生系统中,这两项职责在一定程度上相互分离,或者说被 *解耦**decoupled*了 [^9] [^27] [^30] [^31]。例如S3 只负责保存文件;如果要分析其中的数据,就必须在 S3 之外运行分析代码。这也意味着数据需要通过网络传输,我们将在[“分布式与单节点系统”](/ch1#sec_introduction_distributed)中进一步讨论。
此外,云原生系统往往采用 *多租户* 模式:它并不为每个客户单独分配一台机器,而是由同一项服务在共享硬件上处理多个客户的数据和计算 [^32]。
此外,云原生系统往往采用 *多租户**multitenant*模式:它并不为每个客户单独分配一台机器,而是由同一项服务在共享硬件上处理多个客户的数据和计算 [^32]。
多租户可以提高硬件利用率,更容易实现可伸缩性,也便于云服务商管理;但要保证一个客户的活动不影响其他客户的性能或安全,就必须经过周密的工程设计 [^33]。
@@ -263,7 +263,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
运维的职责是确保服务可靠地交付给用户,包括配置基础设施和部署应用;同时保障生产环境稳定,包括监控和诊断任何可能影响可靠性的问题。对自托管系统来说,传统运维有大量工作落在单台机器上,例如容量规划(监控可用磁盘空间,并在耗尽前增加磁盘)、开通新机器、在机器之间迁移服务,以及安装操作系统补丁。
许多云服务通过 API 隐藏了实际承载服务的一台台机器。例如,云存储不再提供固定容量的磁盘,而是采用 *按量计费*:你无需提前规划容量,就可以存储数据,然后按实际占用的空间付费。此外,即使个别机器发生故障,许多云服务仍然保持高可用性(参见[“可靠性与容错”](/ch2#sec_introduction_reliability))。
许多云服务通过 API 隐藏了实际承载服务的一台台机器。例如,云存储不再提供固定容量的磁盘,而是采用 *按量计费**metered billing*:你无需提前规划容量,就可以存储数据,然后按实际占用的空间付费。此外,即使个别机器发生故障,许多云服务仍然保持高可用性(参见[“可靠性与容错”](/ch2#sec_introduction_reliability))。
关注点从单台机器转向服务也伴随着运维角色的变化。可靠地提供服务这一高层目标没有改变但流程和工具已经演变。DevOps/SRE 理念更强调:
@@ -277,7 +277,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 都采用了这种方法。
云服务的客户仍然需要运维,只是关注的方面有所不同,例如为一项任务选择最合适的服务、集成不同服务,以及从一项服务迁移到另一项服务。尽管按量计费消除了传统意义上的容量规划,你依然必须清楚哪些资源被用在什么地方,免得为并不需要的云资源白白花钱:容量规划变成了财务规划,性能优化变成了成本优化 [^37]。
而且,云服务仍有资源上限或 *配额*,例如并发运行的进程数上限。你必须提前了解并做好规划,不能等到撞上限额才处理 [^38]。
而且,云服务仍有资源上限或 *配额**quota*,例如并发运行的进程数上限。你必须提前了解并做好规划,不能等到撞上限额才处理 [^38]。
采用云服务或许比自行运行基础设施更加容易、快捷,但学习如何使用仍有成本,有时还得设法绕过它的限制。随着供应商越来越多,面向各种用例的云服务层出不穷,如何集成不同服务成了一项格外棘手的挑战 [^39] [^40]。
@@ -288,7 +288,7 @@ ETL参见[“数据仓库”](/ch1#sec_introduction_dwh))只是其中一部
## 分布式与单节点系统 {#sec_introduction_distributed}
由多台机器通过网络通信而构成的系统,称为 *分布式系统*。参与分布式系统的每个进程称为一个 *节点*。采用分布式系统可能出于以下各种原因:
由多台机器通过网络通信而构成的系统,称为 *分布式系统**distributed system*。参与分布式系统的每个进程称为一个 *节点**node*。采用分布式系统可能出于以下各种原因:
固有的分布式系统
: 如果一项应用涉及两个或更多相互交互的用户,而每个用户都使用自己的设备,那么这个系统不可避免地是分布式的:设备之间只能通过网络通信。
@@ -329,7 +329,7 @@ ETL参见[“数据仓库”](/ch1#sec_introduction_dwh))只是其中一部
节点更多也不一定更快:有些情况下,一台计算机上简单的单线程程序,性能可以显著胜过拥有 100 多个 CPU 核心的集群 [^46]。
分布式系统往往很难排查故障:如果系统响应缓慢,怎样才能找出问题在哪里?用于诊断分布式系统问题的技术统称为 *可观测性* [^47] [^48]。它收集系统的执行数据并允许人们查询这些数据从而既能分析高层指标也能追查单个事件。OpenTelemetry、Zipkin 和 Jaeger 等 *追踪* 工具可以记录哪个客户端为了什么操作调用了哪个服务器,以及每次调用花了多长时间 [^49]。
分布式系统往往很难排查故障:如果系统响应缓慢,怎样才能找出问题在哪里?用于诊断分布式系统问题的技术统称为 *可观测性**observability*[^47] [^48]。它收集系统的执行数据并允许人们查询这些数据从而既能分析高层指标也能追查单个事件。OpenTelemetry、Zipkin 和 Jaeger 等 *追踪**tracing*工具可以记录哪个客户端为了什么操作调用了哪个服务器,以及每次调用花了多长时间 [^49]。
数据库提供了多种保证数据一致性的机制,我们将在[第 6 章](/ch6#ch_replication)和[第 8 章](/ch8#ch_transactions)中看到。但是,当每个服务都有自己的数据库时,如何保持不同服务之间的数据一致,就成了应用自身的问题。分布式事务(参见[第 8 章](/ch8#ch_transactions))是一种可能的手段,但很少用于微服务,因为它与服务彼此独立的目标背道而驰,而且许多数据库根本不支持分布式事务 [^50]。
@@ -339,11 +339,11 @@ ETL参见[“数据仓库”](/ch1#sec_introduction_dwh))只是其中一部
把系统分布到多台机器上,最常见的做法是将它划分为客户端和服务器,由客户端向服务器发送请求。这种通信最常使用 HTTP我们将在[“流经服务的数据流REST 与 RPC”](/ch5#sec_encoding_dataflow_rpc)中进一步讨论。同一个进程既可以是服务器(处理传入的请求),也可以是客户端(向其他服务发出请求)。
这种构建应用的方式传统上称为 *面向服务架构*SOA近年来这一思想又进一步演化为 *微服务* 架构 [^52] [^53]。在这种架构中,每项服务都有明确的用途(例如 S3 的用途就是文件存储);服务通过 API 向客户端开放能力,由客户端经网络调用;每项服务还由一个团队负责维护。这样一来,复杂应用就可以拆分成多项相互交互的服务,分别由不同团队管理。
这种构建应用的方式传统上称为 *面向服务架构*SOA近年来这一思想又进一步演化为 *微服务**microservices*架构 [^52] [^53]。在这种架构中,每项服务都有明确的用途(例如 S3 的用途就是文件存储);服务通过 API 向客户端开放能力,由客户端经网络调用;每项服务还由一个团队负责维护。这样一来,复杂应用就可以拆分成多项相互交互的服务,分别由不同团队管理。
把复杂软件拆成多项服务有几个优点:各项服务可以独立更新,减少团队之间的协调;每项服务可以获得符合自身需要的硬件资源;实现细节隐藏在 API 后面,服务负责人可以自由改变实现,而不影响客户端。在数据存储方面,通常每项服务都有自己的数据库,服务之间不共享数据库。否则,整个数据库结构实际上都会成为服务 API 的一部分,很难再行修改;而且,一项服务发出的查询也可能拖累其他服务的性能。
另一方面服务多了本身也会滋生复杂度每项服务都需要相应基础设施用来部署新版本、根据负载调整硬件资源、收集日志、监控服务健康状况并在出现问题时向值班工程师告警。Kubernetes 等 *编排* 框架为这类基础设施提供了基础能力,因此成了部署服务的常用方式。在开发过程中测试一项服务也可能很麻烦,因为它所依赖的其他服务必须一并运行。
另一方面服务多了本身也会滋生复杂度每项服务都需要相应基础设施用来部署新版本、根据负载调整硬件资源、收集日志、监控服务健康状况并在出现问题时向值班工程师告警。Kubernetes 等 *编排**orchestration*框架为这类基础设施提供了基础能力,因此成了部署服务的常用方式。在开发过程中测试一项服务也可能很麻烦,因为它所依赖的其他服务必须一并运行。
微服务 API 也很难演化。调用 API 的客户端会期待其中存在某些字段;随着业务需求改变,开发者或许想要增删 API 字段,但这可能导致客户端出错。更糟的是,这类问题往往要到开发周期后期,更新后的服务 API 部署到预发布或生产环境时才被发现。OpenAPI 和 gRPC 等 API 描述标准有助于管理客户端 API 与服务器 API 之间的关系,我们将在[第 5 章](/ch5#ch_encoding)中进一步讨论。
@@ -355,7 +355,7 @@ ETL参见[“数据仓库”](/ch1#sec_introduction_dwh))只是其中一部
### 云计算与超级计算 {#id17}
云计算并非构建大规模计算系统的唯一方式,另一条路线是 *高性能计算*HPC也称为 *超级计算*。尽管二者有所重叠但与云计算和企业数据中心系统相比HPC 的侧重点通常不同,采用的技术也不一样。差异包括:
云计算并非构建大规模计算系统的唯一方式,另一条路线是 *高性能计算*HPC也称为 *超级计算**supercomputing*。尽管二者有所重叠但与云计算和企业数据中心系统相比HPC 的侧重点通常不同,采用的技术也不一样。差异包括:
* 超级计算机通常用于计算密集型的科学计算任务,例如天气预报、气候建模、分子动力学(模拟原子和分子的运动)、复杂优化问题,以及求解偏微分方程。云计算则更常用于在线服务、业务数据系统,以及其他需要以高可用性响应用户请求的系统。
* 超级计算机通常运行大型批处理作业,并不时把计算状态作为检查点写入磁盘。如果某个节点发生故障,一种常见做法是直接停止整个集群的工作负载,修复故障节点,再从最近的检查点重新开始计算 [^55] [^56]。云服务通常不能这样停掉整个集群,因为服务必须持续响应用户,尽量减少中断。
@@ -375,7 +375,7 @@ ETL参见[“数据仓库”](/ch1#sec_introduction_dwh))只是其中一部
每一个参与这类系统的人,都有责任考虑它们的伦理影响,并确保系统遵守相关法律。并非人人都要成为法律和伦理专家,但具备基本的法律与伦理常识,与掌握分布式系统的基础知识同样重要。
法律因素正在影响数据系统设计最根本的部分 [^61]。例如GDPR 赋予个人要求删除其数据的权利,有时称为 *被遗忘权*。然而,正如本书将要介绍的,许多数据系统的设计依赖仅追加日志等不可变结构;一个本应不可变的文件,要怎样从中间删除某些数据?如果数据已经纳入衍生数据集(参见[“权威记录系统与衍生数据”](/ch1#sec_introduction_derived)),例如成为机器学习模型的训练数据,又该如何删除?回答这些问题带来了新的工程挑战。
法律因素正在影响数据系统设计最根本的部分 [^61]。例如GDPR 赋予个人要求删除其数据的权利,有时称为 *被遗忘权**right to be forgotten*。然而,正如本书将要介绍的,许多数据系统的设计依赖仅追加日志等不可变结构;一个本应不可变的文件,要怎样从中间删除某些数据?如果数据已经纳入衍生数据集(参见[“权威记录系统与衍生数据”](/ch1#sec_introduction_derived)),例如成为机器学习模型的训练数据,又该如何删除?回答这些问题带来了新的工程挑战。
目前,还没有明确指南说明哪些具体技术或系统架构算是“符合 GDPR”。法规有意不规定特定技术因为技术进步可能很快就会使这些规定过时。法律文本给出的只是有待解释的高层原则。因此如何遵守隐私法规并没有简单答案不过在讨论本书的一些技术时我们会从这个角度加以审视。
@@ -383,7 +383,7 @@ ETL参见[“数据仓库”](/ch1#sec_introduction_dwh))只是其中一部
政府或警方也可能强迫企业交出数据。如果数据可能暴露某些在当地被法律定为犯罪的行为——例如,在一些中东和非洲国家,同性恋会受到刑事处罚;在美国一些州,寻求堕胎也可能受到刑事追究——那么存储这些数据会给用户带来切实的安全风险。例如,位置数据很容易暴露一个人曾前往堕胎诊所;哪怕只是一段用户 IP 地址的历史日志,也可能泄露其大致位置。
把所有风险都考虑在内之后,合理的结论可能是:某些数据根本不值得存储,因此应当删除。*数据最小化* 原则(有时也用德语 *Datensparsamkeit* 表示)与“大数据”理念背道而驰;后者倾向于先存下大量数据,指望它们将来也许会派上用场 [^62]。不过,数据最小化符合 GDPR个人数据只能为具体、明确的目的而收集日后不得用于其他目的也不得在超出原定目的所需期限后继续保留 [^63]。
把所有风险都考虑在内之后,合理的结论可能是:某些数据根本不值得存储,因此应当删除。*数据最小化**data minimization*原则(有时也用德语 *Datensparsamkeit* 表示)与“大数据”理念背道而驰;后者倾向于先存下大量数据,指望它们将来也许会派上用场 [^62]。不过,数据最小化符合 GDPR个人数据只能为具体、明确的目的而收集日后不得用于其他目的也不得在超出原定目的所需期限后继续保留 [^63]。
企业同样开始重视隐私和安全问题。信用卡公司要求支付处理企业遵守严格的支付卡行业PCI标准支付处理商要频繁接受独立审计机构的评估验证是否持续合规。软件供应商面临的审查也日趋严格许多采购方如今要求供应商符合服务组织控制SOC第 2 类标准。与 PCI 合规一样,供应商要通过第三方审计来验证是否符合要求。

View File

@@ -17,21 +17,21 @@ breadcrumbs: false
正如[第 9 章](/ch9#ch_distributed)所述,分布式系统里可能出错的事情很多。要让服务在这些故障发生时仍能正确运行,就必须设法容忍故障。
*复制* 是实现容错最有力的工具之一。然而,正如[第 6 章](/ch6#ch_replication)所示,把同一份数据复制到多个副本,也带来了不一致的风险。读请求可能由尚未追上进度的副本处理,返回陈旧结果;如果多个副本都能接受写入,还必须解决不同副本上并发写入的值之间的冲突。从总体上看,处理这类问题有两种彼此竞争的思路:
*复制**replication*是实现容错最有力的工具之一。然而,正如[第 6 章](/ch6#ch_replication)所示,把同一份数据复制到多个副本,也带来了不一致的风险。读请求可能由尚未追上进度的副本处理,返回陈旧结果;如果多个副本都能接受写入,还必须解决不同副本上并发写入的值之间的冲突。从总体上看,处理这类问题有两种彼此竞争的思路:
最终一致性
最终一致性eventual consistency
: 这种思路把系统采用复制这一事实暴露给应用,由应用开发者处理随之而来的不一致与冲突。采用[“多主复制”](/ch6#sec_replication_multi_leader)和[“无主复制”](/ch6#sec_replication_leaderless)的系统常常使用这种方式。
强一致性
强一致性strong consistency
: 这种思路认为,应用不应操心复制的内部细节,系统应当表现得仿佛只有一个节点。它让应用开发者的工作简单得多,代价则是更强的一致性会损害性能,而且有些故障在最终一致系统中尚可容忍,却会令强一致系统停摆。
一如既往,哪种方式更好取决于具体应用。如果应用允许用户离线修改数据,那么正如[“同步引擎与本地优先软件”](/ch6#sec_replication_offline_clients)所述,最终一致性不可避免。然而,应用要正确处理最终一致性也很困难。如果各副本位于通信快速而可靠的数据中心,强一致性的成本通常可以接受,因而往往更合适。
本章将深入讨论强一致性,重点考察三个方面:
1. “强一致性”这个说法相当含糊,因此我们先给出一个更精确的目标:*线性一致性*。
1. “强一致性”这个说法相当含糊,因此我们先给出一个更精确的目标:*线性一致性**linearizability*
2. 接着讨论 ID 和时间戳的生成。这个问题看似与一致性无关,实际上二者关系密切。
3. 最后探讨分布式系统如何既实现线性一致性,又保持容错能力;答案在于 *共识* 算法。
3. 最后探讨分布式系统如何既实现线性一致性,又保持容错能力;答案在于 *共识**consensus*算法。
在此过程中,我们会看到,分布式系统中什么可以做到、什么无法做到,受到一些根本限制。
@@ -45,9 +45,9 @@ breadcrumbs: false
要让复制数据库尽可能简单易用,最好让它表现得仿佛根本没有复制。这样,用户就不必操心复制延迟、冲突和其他不一致问题:既能获得容错的好处,又不必承担思考多个副本所带来的复杂性。
这就是 *线性一致性* [^1](也称为 *原子一致性* [^2]*强一致性*、*即时一致性* 或 *外部一致性* [^3])背后的思想。线性一致性的精确定义相当微妙,本节余下部分会逐步展开;其基本思想,是让系统看起来仿佛只有一份数据,所有操作都原子地作用于这份数据。有了这种保证,即使实际上存在多个副本,应用也无需关心它们。
这就是 *线性一致性**linearizability*[^1](也称为 *原子一致性**atomic consistency* [^2]*强一致性**strong consistency**即时一致性**immediate consistency*,或 *外部一致性**external consistency* [^3])背后的思想。线性一致性的精确定义相当微妙,本节余下部分会逐步展开;其基本思想,是让系统看起来仿佛只有一份数据,所有操作都原子地作用于这份数据。有了这种保证,即使实际上存在多个副本,应用也无需关心它们。
在线性一致的系统中,只要一个客户端成功完成写入,此后所有客户端从数据库读取时,都必须能看到刚写入的值。要维持“只有一份数据”的假象,就必须保证读到的是最近写入的最新值,而不是来自陈旧缓存或副本的旧值。换句话说,线性一致性是一种 *新鲜度保证*。下面用一个不满足线性一致性的系统来说明这一点。
在线性一致的系统中,只要一个客户端成功完成写入,此后所有客户端从数据库读取时,都必须能看到刚写入的值。要维持“只有一份数据”的假象,就必须保证读到的是最近写入的最新值,而不是来自陈旧缓存或副本的旧值。换句话说,线性一致性是一种 *新鲜度保证**recency guarantee*。下面用一个不满足线性一致性的系统来说明这一点。
{{< fig num="10-1" id="fig_consistency_linearizability_0" src="/fig/ddia_1001.png" caption="如果这个数据库满足线性一致性,那么 Alice 的读取应返回 1 而不是 0或者 Bob 的读取应返回 0 而不是 1。" class="ddia-figure ddia-figure--standard" width="2658" height="1904" />}}
@@ -57,7 +57,7 @@ breadcrumbs: false
### 什么使系统具有线性一致性? {#sec_consistency_lin_definition}
为了更好地理解线性一致性,我们再看几个例子。{{< xref fig="10-2" page="/ch10" anchor="fig_consistency_linearizability_1" >}}图 10-2{{< /xref >}}展示了三个客户端如何并发读写线性一致数据库中的同一个对象 *x*。在分布式系统理论中,*x* 称为 *寄存器*;在实际系统里,它可以是键值存储中的一个键、关系数据库中的一行,或文档数据库中的一个文档。
为了更好地理解线性一致性,我们再看几个例子。{{< xref fig="10-2" page="/ch10" anchor="fig_consistency_linearizability_1" >}}图 10-2{{< /xref >}}展示了三个客户端如何并发读写线性一致数据库中的同一个对象 *x*。在分布式系统理论中,*x* 称为 *寄存器**register*;在实际系统里,它可以是键值存储中的一个键、关系数据库中的一行,或文档数据库中的一个文档。
{{< fig num="10-2" id="fig_consistency_linearizability_1" src="/fig/ddia_1002.png" caption="Alice 观察到 x = 0、y = 1而 Bob 观察到 x = 1、y = 0仿佛两人的计算机对写入发生的顺序各执一词。" class="ddia-figure ddia-figure--panorama" width="2880" height="829" />}}
@@ -88,7 +88,7 @@ breadcrumbs: false
还可以进一步细化时序图,把每个操作视为在某个时刻原子生效 [^5],如{{< xref fig="10-4" page="/ch10" anchor="fig_consistency_linearizability_3" >}}图 10-4{{< /xref >}}这个更复杂的例子所示。除了 *read**write*,图中又加入了第三种操作:
* *cas*(*x*, *v* old, *v* new) ⇒ *r* 表示客户端请求执行原子 *比较并设置* 操作(参见[“条件写入(比较并设置)”](/ch8#sec_transactions_compare_and_set))。如果寄存器 *x* 的当前值等于 *v* old就原子地把它改为 *v* new否则保持不变并返回错误。*r* 是数据库的响应(*ok* 或 *error*)。
* *cas*(*x*, *v* old, *v* new) ⇒ *r* 表示客户端请求执行原子 *比较并设置**compare-and-set*操作(参见[“条件写入(比较并设置)”](/ch8#sec_transactions_compare_and_set))。如果寄存器 *x* 的当前值等于 *v* old就原子地把它改为 *v* new否则保持不变并返回错误。*r* 是数据库的响应(*ok* 或 *error*)。
{{< xref fig="10-4" page="/ch10" anchor="fig_consistency_linearizability_3" >}}图 10-4{{< /xref >}}在每次操作的横条内画了一根竖线,表示我们认为该操作实际生效的时刻。把这些标记依次连起来,必须得到寄存器的一条合法读写序列——每次读取都应返回最近一次写入所设置的值。
@@ -124,7 +124,7 @@ breadcrumbs: false
>
> *顺序一致性* 又是另外一回事 [^8],但我们不会在这里讨论它。)
>
> 数据库可以同时提供可串行化与线性一致性,这种组合称为 *严格可串行化* 或 *强单副本可串行化**strong-1SR*[^11] [^12]。单节点数据库通常满足线性一致性。对于采用[“可串行化快照隔离SSI”](/ch8#sec_transactions_ssi)等乐观方法的分布式数据库情况要复杂一些。例如CockroachDB 提供可串行化以及一定的读取新鲜度保证,却不提供严格可串行化 [^13],因为后者要求事务之间进行代价高昂的协调 [^14]。
> 数据库可以同时提供可串行化与线性一致性,这种组合称为 *严格可串行化**strict serializability*)或 *强单副本可串行化**strong one-copy serializability**strong-1SR*[^11] [^12]。单节点数据库通常满足线性一致性。对于采用[“可串行化快照隔离SSI”](/ch8#sec_transactions_ssi)等乐观方法的分布式数据库情况要复杂一些。例如CockroachDB 提供可串行化以及一定的读取新鲜度保证,却不提供严格可串行化 [^13],因为后者要求事务之间进行代价高昂的协调 [^14]。
>
> 也可以把较弱的隔离级别与线性一致性组合,或把较弱的一致性模型与可串行化组合。事实上,一致性模型与隔离级别在很大程度上可以独立选择 [^15] [^16]。
@@ -228,7 +228,7 @@ Apache ZooKeeper [^18]、etcd 等协调服务经常用于实现分布式租约
{{< fig num="10-7" id="fig_consistency_cap_availability" src="/fig/ddia_1007.png" caption="如果网络分区使客户端无法联系足够多的副本,它们就无法处理写入。" class="ddia-figure ddia-figure--wide" width="2658" height="1194" />}}
考虑两个地区之间网络中断时会发生什么。假设各地区内部的网络仍然正常,客户端也能访问本地区域,但两个地区彼此无法通信。这种情况称为 *网络分区*
考虑两个地区之间网络中断时会发生什么。假设各地区内部的网络仍然正常,客户端也能访问本地区域,但两个地区彼此无法通信。这种情况称为 *网络分区**network partition*
在多主数据库中,每个地区都可以继续正常工作:一地的写入原本就异步复制到另一地,因此网络中断期间只需暂存排队,连接恢复后再相互交换。
@@ -313,13 +313,13 @@ ID 生成器还有几种替代方案:
正如[“用于事件排序的时间戳”](/ch9#sec_distributed_lww)所述,日历时钟时间戳至多只能给出近似顺序:如果较早的写入读取了略快的时钟,较晚的写入读取了略慢的时钟,时间戳顺序就可能与事件实际顺序相反。非单调时钟还可能突然跳变,甚至让同一节点生成的时间戳顺序出错。因此,基于日历时钟的 ID 生成器通常不满足线性一致性。
使用原子钟或 GPS 接收器进行高精度时钟同步,可以减少这种顺序错乱。但如果无需特殊硬件,也能生成唯一且顺序正确的 ID当然更好。这正是 *逻辑时钟* 要解决的问题。
使用原子钟或 GPS 接收器进行高精度时钟同步,可以减少这种顺序错乱。但如果无需特殊硬件,也能生成唯一且顺序正确的 ID当然更好。这正是 *逻辑时钟**logical clock*要解决的问题。
### 逻辑时钟 {#sec_consistency_timestamps}
在[“不可靠的时钟”](/ch9#sec_distributed_clocks)中,我们讨论了日历时钟和单调时钟。二者都属于 *物理时钟*,度量的是经过了多少秒(或毫秒、微秒等)。
分布式系统还经常使用另一类时钟,称为 *逻辑时钟*。物理时钟是计算流逝秒数的硬件设备;逻辑时钟则是一种计算已发生事件数量的算法。因此,逻辑时间戳不能告诉你现在是几点,却 *可以* 相互比较,判断哪个较早、哪个较晚。
分布式系统还经常使用另一类时钟,称为 *逻辑时钟**logical clock*。物理时钟是计算流逝秒数的硬件设备;逻辑时钟则是一种计算已发生事件数量的算法。因此,逻辑时间戳不能告诉你现在是几点,却 *可以* 相互比较,判断哪个较早、哪个较晚。
逻辑时钟的要求通常是:
@@ -351,7 +351,7 @@ Lamport 时间戳很适合表示事件发生顺序,但也有一些局限:
* 它与物理时间没有直接关系,所以无法据此查找某个具体日期发布的所有消息;物理时间必须另行保存。
* 如果两个节点从不通信,一个节点的计数器增长就永远不会反映到另一个节点上。因此,不同节点在大致同一时刻生成的事件,计数器值可能相差悬殊。
*混合逻辑时钟* 兼具物理日历时钟的优点与 Lamport 时钟的顺序保证 [^55]。它像物理时钟一样以秒或微秒计数;又像 Lamport 时钟一样,在看到其他节点更大的时间戳时,把自己的本地值向前推进到对方的时间戳。因此,如果某个节点的时钟偏快,其他节点与它通信后也会相应地把时钟向前推进。
*混合逻辑时钟**hybrid logical clock*HLC兼具物理日历时钟的优点与 Lamport 时钟的顺序保证 [^55]。它像物理时钟一样以秒或微秒计数;又像 Lamport 时钟一样,在看到其他节点更大的时间戳时,把自己的本地值向前推进到对方的时间戳。因此,如果某个节点的时钟偏快,其他节点与它通信后也会相应地把时钟向前推进。
混合逻辑时钟每次生成时间戳时也会递增,从而保证始终单调向前,即使底层物理时钟因 NTP 校正而向后跳变也不例外。因此,混合逻辑时钟可能略微领先于底层物理时钟;算法会尽量把这项误差控制在最小范围内。
@@ -363,7 +363,7 @@ Lamport 时间戳很适合表示事件发生顺序,但也有一些局限:
多个时间戳并发生成时,这些算法会任意规定它们之间的顺序。因此,只看两个时间戳,通常无法判断二者是并发生成,还是一个先于另一个发生。(在{{< xref fig="10-9" page="/ch10" anchor="fig_consistency_lamport_ts" >}}图 10-9{{< /xref >}}中,因为 Aaliyah 与 Caleb 的消息具有相同计数器值,可以断定它们彼此并发;但计数器值不同时,就无法作出这种判断。)
如果需要判断记录是否并发创建,就得采用另一种算法,例如 *向量时钟*。它的缺点是时间戳大得多,可能需要为系统中的每个节点保存一个整数。有关并发检测的更多细节,参见[“检测并发写入”](/ch6#sec_replication_concurrent)。
如果需要判断记录是否并发创建,就得采用另一种算法,例如 *向量时钟**vector clock*。它的缺点是时间戳大得多,可能需要为系统中的每个节点保存一个整数。有关并发检测的更多细节,参见[“检测并发写入”](/ch6#sec_replication_concurrent)。
### 线性一致的 ID 生成器 {#sec_consistency_linearizable_id}
@@ -414,7 +414,7 @@ ID 生成器很难分片:多个分片独立发号后,就无法再保证整
* 单节点上的线性一致 ID 生成器,不过是一个带有原子“获取并增加”指令的计数器;但如果这个节点崩溃了呢?
* 原子比较并设置CAS操作用途广泛例如多个进程争抢锁或租约时决定谁能获得它或者确保给定名称的文件或用户具有唯一性。在单个节点上CAS 可能只需一条 CPU 指令;但怎样才能让它容错?
事实证明,这些都是同一个分布式系统基本问题的不同实例:*共识*。共识是分布式计算中最重要、最基本的问题之一;同时,它也出了名地难以正确实现 [^58] [^59],许多系统都曾在这里栽过跟头。至此,我们已经讨论过复制([第 6 章](/ch6#ch_replication))、事务([第 8 章](/ch8#ch_transactions))、系统模型([第 9 章](/ch9#ch_distributed))以及线性一致性(本章),终于可以正面处理共识问题了。
事实证明,这些都是同一个分布式系统基本问题的不同实例:*共识**consensus*。共识是分布式计算中最重要、最基本的问题之一;同时,它也出了名地难以正确实现 [^58] [^59],许多系统都曾在这里栽过跟头。至此,我们已经讨论过复制([第 6 章](/ch6#ch_replication))、事务([第 8 章](/ch8#ch_transactions))、系统模型([第 9 章](/ch9#ch_distributed))以及线性一致性(本章),终于可以正面处理共识问题了。
最著名的共识算法包括视图戳复制Viewstamped ReplicationVSR[^60] [^61]、Paxos [^58] [^62] [^63] [^64]、Raft [^23] [^65] [^66] 和 Zab [^18] [^22] [^67]。这些算法有不少相似之处,但并不完全相同 [^68] [^69]。它们采用非拜占庭系统模型:网络通信可以被任意延迟或丢弃,节点可以崩溃、重启或断开连接;但除此之外,算法假定节点都会正确遵守协议,不会采取恶意行为。
@@ -432,9 +432,9 @@ ID 生成器很难分片:多个分片独立发号后,就无法再保证整
共识可以用几种不同的方式表达:
* *单值共识* 与原子 *比较并设置* 操作非常相似,可以用来实现锁、租约和唯一性约束。
* 构建 *仅追加日志* 同样需要共识;这个问题通常形式化为 *全序广播*。有了日志,就可以构建 *状态机复制*、基于领导者的复制、事件溯源以及其他许多有用的机制。
* 多数据库或多分片事务的 *原子提交*,要求所有参与者就是否提交或中止事务达成一致。
* *单值共识**single-value consensus*与原子 *比较并设置* 操作非常相似,可以用来实现锁、租约和唯一性约束。
* 构建 *仅追加日志**append-only log*同样需要共识;这个问题通常形式化为 *全序广播**total order broadcast*)。有了日志,就可以构建 *状态机复制**state machine replication*、基于领导者的复制、事件溯源以及其他许多有用的机制。
* 多数据库或多分片事务的 *原子提交**atomic commitment*,要求所有参与者就是否提交或中止事务达成一致。
下面很快就会逐一探讨这些形式。事实上,这几个问题彼此等价:只要有一种算法能解决其中一个问题,就可以把它转换成其他任意一种问题的解法。这是一个相当深刻、甚至有些出人意料的洞见!也正因为如此,尽管它们表面上截然不同,我们仍可以把它们统统归入“共识”这一范畴。
@@ -487,7 +487,7 @@ ID 生成器很难分片:多个分片独立发号后,就无法再保证整
#### 共享日志作为共识 {#sec_consistency_shared_logs}
我们已经见过多种日志,例如复制日志、事务日志和预写日志。日志存储一系列 *日志条目*,任何读取者都会以相同顺序看到相同的条目。有时,只有一个写入者有权向日志追加新条目;而在 *共享日志* 中,多个节点都可以请求追加条目。单主复制就是一个例子:任何客户端都可以请求领导者执行写入,领导者把写入追加到复制日志,随后所有追随者都按照与领导者相同的顺序应用这些写入。
我们已经见过多种日志,例如复制日志、事务日志和预写日志。日志存储一系列 *日志条目**log entry*,任何读取者都会以相同顺序看到相同的条目。有时,只有一个写入者有权向日志追加新条目;而在 *共享日志**shared log*中,多个节点都可以请求追加条目。单主复制就是一个例子:任何客户端都可以请求领导者执行写入,领导者把写入追加到复制日志,随后所有追随者都按照与领导者相同的顺序应用这些写入。
更形式化地说,共享日志支持两种操作:请求把一个值加入日志,以及读取日志条目。它必须满足以下属性:
@@ -508,7 +508,7 @@ ID 生成器很难分片:多个分片独立发号后,就无法再保证整
> [!NOTE]
> 共享日志在形式上称为 *全序广播*、*原子广播* 或 *全序组播* 协议 [^26] [^76] [^77]。这些术语只是用不同说法描述同一件事:请求把一个值加入日志称为“广播”这个值,读取日志条目则称为“交付”这条日志。
> 共享日志在形式上称为 *全序广播**total order broadcast*)、*原子广播**atomic broadcast*)或 *全序组播**total order multicast*协议 [^26] [^76] [^77]。这些术语只是用不同说法描述同一件事:请求把一个值加入日志称为“广播”这个值,读取日志条目则称为“交付”这条日志。
有了共享日志的实现,就很容易解决共识问题:每个想提议某个值的节点都请求把它加入日志,而第一条日志条目中读出的值就是决定值。由于所有节点都按相同顺序读取日志条目,它们必然会就哪个值最先交付达成一致 [^28]。
@@ -532,11 +532,11 @@ ID 生成器很难分片:多个分片独立发号后,就无法再保证整
现在规定,读到零的节点胜出,其提议值成为决定值。这对读到零的节点当然没问题,其他节点却陷入了困境:它们知道自己没有胜出,却不知道其他节点中究竟谁赢了。胜者可以发消息告知其他节点,但如果它还没来得及发送消息就崩溃了呢?其余节点将一直悬而未决,无法决定任何值,共识也就不能终止。它们也不能改选另一个节点,因为读到零的节点日后仍可能恢复,并有充分理由决定自己提议的值。
有一个例外:我们能够确定提议值的节点不超过两个。此时,两个节点可以先互相发送各自的提议值,再分别执行获取并增加。读到零的节点决定自己的值,读到一的节点则决定另一个节点的值。这样就解决了两个节点之间的共识问题,因此我们说获取并增加的 *共识数* 为二 [^28]。相比之下CAS 和共享日志可以在任意数量的节点提议值时解决共识,所以它们的共识数为 ∞(无穷大)。
有一个例外:我们能够确定提议值的节点不超过两个。此时,两个节点可以先互相发送各自的提议值,再分别执行获取并增加。读到零的节点决定自己的值,读到一的节点则决定另一个节点的值。这样就解决了两个节点之间的共识问题,因此我们说获取并增加的 *共识数**consensus number*为二 [^28]。相比之下CAS 和共享日志可以在任意数量的节点提议值时解决共识,所以它们的共识数为 ∞(无穷大)。
#### 原子提交作为共识 {#atomic-commitment-as-consensus}
在 [“分布式事务”](/ch8#sec_transactions_distributed) 中,我们见过 *原子提交* 问题:参与分布式事务的所有数据库或分片,要么全都提交事务,要么全都中止。我们还见过 *两阶段提交* 算法,它依赖一个构成单点故障的协调者。
在 [“分布式事务”](/ch8#sec_transactions_distributed) 中,我们见过 *原子提交**atomic commitment*问题:参与分布式事务的所有数据库或分片,要么全都提交事务,要么全都中止。我们还见过 *两阶段提交**two-phase commit*2PC算法,它依赖一个构成单点故障的协调者。
共识与原子提交是什么关系?乍看之下,两者十分相似——都要求节点达成某种一致。不过,它们之间存在一项重要区别:共识可以决定任意一个被提议的值;原子提交则要求,只要 *任何* 参与者投票中止,算法就 *必须* 中止。更准确地说,原子提交必须满足以下属性 [^78]
@@ -573,7 +573,7 @@ ID 生成器很难分片:多个分片独立发号后,就无法再保证整
#### 使用共享日志 {#sec_consistency_smr}
共享日志与数据库复制十分契合:如果每条日志条目都代表一次数据库写入,而每个副本都以相同顺序、使用确定性逻辑处理相同的写入,那么所有副本最终都会处于一致状态。这个思想称为 *状态机复制* [^80],也是我们在 [“事件溯源与 CQRS”](/ch3#sec_datamodels_events) 中见过的事件溯源原理。共享日志对流处理也很有用,我们将在 [第 12 章](/ch12#ch_stream) 看到这一点。
共享日志与数据库复制十分契合:如果每条日志条目都代表一次数据库写入,而每个副本都以相同顺序、使用确定性逻辑处理相同的写入,那么所有副本最终都会处于一致状态。这个思想称为 *状态机复制**state machine replication*[^80],也是我们在 [“事件溯源与 CQRS”](/ch3#sec_datamodels_events) 中见过的事件溯源原理。共享日志对流处理也很有用,我们将在 [第 12 章](/ch12#ch_stream) 看到这一点。
类似地,共享日志还可以实现可串行化事务。正如 [“实际串行执行”](/ch8#sec_transactions_serial) 所述,如果每条日志条目都代表一个将以存储过程形式执行的确定性事务,而且每个节点都以相同顺序执行这些事务,那么事务就是可串行化的 [^81] [^82]。
@@ -596,7 +596,7 @@ ID 生成器很难分片:多个分片独立发号后,就无法再保证整
然而,这里有一个难题。前面讨论脑裂时说过,所有节点必须就谁是领导者达成一致,否则两个不同节点可能都自认为是领导者,进而作出彼此矛盾的决定。如此看来,选举领导者需要共识,解决共识又需要领导者。怎样才能跳出这个先有鸡还是先有蛋的困局?
事实上,共识算法并不要求任何时刻都只有一个领导者。它们提供的是稍弱一些的保证:协议定义一个 *纪元编号*Paxos 称为 *投票编号*,视图戳复制称为 *视图编号*Raft 称为 *任期编号*),并保证每个纪元内的领导者是唯一的。
事实上,共识算法并不要求任何时刻都只有一个领导者。它们提供的是稍弱一些的保证:协议定义一个 *纪元编号**epoch number*Paxos 称为 *投票编号**ballot number*视图戳复制称为 *视图编号**view number*Raft 称为 *任期编号**term number*),并保证每个纪元内的领导者是唯一的。
如果一个节点在指定的超时时间内始终没有收到现任领导者的消息,因而认为它已经失效,这个节点就可能发起投票,选举新的领导者。这次选举会取得一个大于以往所有纪元的新纪元编号。如果两个不同纪元的领导者发生冲突——也许前任领导者其实并未失效——纪元编号较高的领导者说了算。
@@ -644,7 +644,7 @@ Raft、Multi-Paxos、Zab 和视图戳复制都采用这一基本结构:先由
### 协调服务 {#sec_consistency_coordination}
任何想提供线性一致操作的分布式数据库都能从共识算法中受益许多现代分布式数据库确实也用共识算法做复制。不过有一类系统尤其倚重共识ZooKeeper、etcd、Consul 等 *协调服务*。它们表面上与普通键值存储相似,却不像大多数数据库那样以通用数据存储为目标。
任何想提供线性一致操作的分布式数据库都能从共识算法中受益许多现代分布式数据库确实也用共识算法做复制。不过有一类系统尤其倚重共识ZooKeeper、etcd、Consul 等 *协调服务**coordination service*。它们表面上与普通键值存储相似,却不像大多数数据库那样以通用数据存储为目标。
协调服务的用途是协调另一个分布式系统中的多个节点。例如Kubernetes 依赖 etcdSpark 和 Flink 在高可用模式下,则依赖后台运行的 ZooKeeper。协调服务只保存少量足以全部放入内存的数据同时仍会写入磁盘以保证持久性再用容错共识算法把这些数据复制到多个节点。
@@ -657,7 +657,7 @@ Raft、Multi-Paxos、Zab 和视图戳复制都采用这一基本结构:先由
: 正如 [“分布式锁和租约”](/ch9#sec_distributed_lock_fencing) 所述,以租约保护某项资源时,需要用 *栅栏机制* 防止客户端在进程暂停或网络严重延迟时相互干扰。共识系统可以为每条日志条目分配单调递增的 ID以此生成栅栏令牌ZooKeeper 使用 `zxid``cversion`etcd 使用修订号)。
故障检测
: 客户端与协调服务维持一个长期会话并定期交换心跳确认对方是否仍然存活。即使连接暂时中断或某台服务器失效客户端持有的租约仍然有效但如果心跳中断的时间超过租约超时协调服务就会认定客户端已经失效并释放其租约。ZooKeeper 把这种随会话到期自动消失的条目称为 *临时节点*。)
: 客户端与协调服务维持一个长期会话并定期交换心跳确认对方是否仍然存活。即使连接暂时中断或某台服务器失效客户端持有的租约仍然有效但如果心跳中断的时间超过租约超时协调服务就会认定客户端已经失效并释放其租约。ZooKeeper 把这种随会话到期自动消失的条目称为 *临时节点**ephemeral node*。)
变更通知
: 客户端可以要求协调服务在某些键发生变化时主动发送通知。这样一来,客户端便能得知另一个客户端何时加入集群(根据它写入协调服务的值),或何时失效(它的会话超时,临时节点随之消失),无须再频繁轮询服务来发现变化。
@@ -684,7 +684,7 @@ Raft、Multi-Paxos、Zab 和视图戳复制都采用这一基本结构:先由
#### 服务发现 {#service-discovery}
ZooKeeper、etcd 和 Consul 也经常用于 *服务发现*,即找出应当连接哪个 IP 地址才能访问特定服务(见 [“负载均衡器、服务发现和服务网格”](/ch5#sec_encoding_service_discovery))。云环境中的虚拟机不断创建和销毁,通常无法预先知道服务的 IP 地址。常见做法是让服务在启动时把自己的网络端点注册到服务注册表,供其他服务查找。
ZooKeeper、etcd 和 Consul 也经常用于 *服务发现**service discovery*,即找出应当连接哪个 IP 地址才能访问特定服务(见 [“负载均衡器、服务发现和服务网格”](/ch5#sec_encoding_service_discovery))。云环境中的虚拟机不断创建和销毁,通常无法预先知道服务的 IP 地址。常见做法是让服务在启动时把自己的网络端点注册到服务注册表,供其他服务查找。
用协调服务做服务发现很方便:故障检测与变更通知功能,让客户端很容易跟踪服务实例的增减。如果系统已经用协调服务管理租约、锁或领导者选举,继续用它做服务发现也顺理成章,因为它本来就知道哪个节点应当接收服务请求。

View File

@@ -15,15 +15,15 @@ breadcrumbs: false
>
> 高德纳
到目前为止,本书的大部分内容都在讨论 *请求* *查询*,以及相应的 *响应* *结果*。许多现代数据系统都默认采用这种处理方式:你请求某样东西,或者发出一条指令,系统便尽量快速地给出答案。
到目前为止,本书的大部分内容都在讨论 *请求**request**查询**query*,以及相应的 *响应**response**结果**result*。许多现代数据系统都默认采用这种处理方式:你请求某样东西,或者发出一条指令,系统便尽量快速地给出答案。
浏览器请求网页、服务调用远程 API以及数据库、缓存、搜索索引等许多系统都是这样工作的。我们称它们为 *在线系统*。这类系统通常以响应时间作为主要性能指标,而且往往需要具备容错能力,才能保证高可用性。
浏览器请求网页、服务调用远程 API以及数据库、缓存、搜索索引等许多系统都是这样工作的。我们称它们为 *在线系统**online system*。这类系统通常以响应时间作为主要性能指标,而且往往需要具备容错能力,才能保证高可用性。
然而,有些计算规模太大,或者需要处理的数据太多,无法放在一次交互式请求中完成。例如,你可能需要训练 AI 模型,把大量数据从一种形式转换成另一种形式,或者在非常大的数据集上进行分析。我们把这类任务称为 *批处理* 作业,相应的系统有时也称为 *离线系统*
然而,有些计算规模太大,或者需要处理的数据太多,无法放在一次交互式请求中完成。例如,你可能需要训练 AI 模型,把大量数据从一种形式转换成另一种形式,或者在非常大的数据集上进行分析。我们把这类任务称为 *批处理**batch processing*作业,相应的系统有时也称为 *离线系统**offline system*
批处理作业读取一组输入数据(只读),并产生一组输出数据(每次运行都从头生成)。它通常不会像读写事务那样修改现有数据。因此,输出是由输入 *衍生* 而来的(参见[“权威记录系统与衍生数据”](/ch1#sec_introduction_derived)):如果对输出不满意,只需把它删除,调整作业逻辑,再运行一次。把输入视为不可变数据,并避免产生副作用(例如写入外部数据库),不仅能让批处理作业获得良好的性能,还会带来其他好处:
批处理作业读取一组输入数据(只读),并产生一组输出数据(每次运行都从头生成)。它通常不会像读写事务那样修改现有数据。因此,输出是由输入 *衍生**derived*而来的(参见[“权威记录系统与衍生数据”](/ch1#sec_introduction_derived)):如果对输出不满意,只需把它删除,调整作业逻辑,再运行一次。把输入视为不可变数据,并避免产生副作用(例如写入外部数据库),不仅能让批处理作业获得良好的性能,还会带来其他好处:
- 如果代码中引入了错误,导致输出有误或遭到破坏,只要回滚到先前版本的代码并重新运行作业,输出便能恢复正确。更简单的办法是把旧输出保存在另一个目录中,需要时直接切换回去。大多数对象存储和开放表格式(参见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))都支持这种称为 *时间旅行* 的功能。大多数支持读写事务的数据库却不具备这种性质:如果错误代码把坏数据写进数据库,回滚代码并不能修复已经写入的数据。这种从错误代码中恢复的能力称为 *容忍人为失误* [^1]。
- 如果代码中引入了错误,导致输出有误或遭到破坏,只要回滚到先前版本的代码并重新运行作业,输出便能恢复正确。更简单的办法是把旧输出保存在另一个目录中,需要时直接切换回去。大多数对象存储和开放表格式(参见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))都支持这种称为 *时间旅行**time travel*的功能。大多数支持读写事务的数据库却不具备这种性质:如果错误代码把坏数据写进数据库,回滚代码并不能修复已经写入的数据。这种从错误代码中恢复的能力称为 *容忍人为失误**human fault tolerance*[^1]。
- 由于回滚很容易,功能开发可以比“犯错就会造成不可逆损害”的环境推进得更快。这种 *尽量减少不可逆操作* 的原则有利于敏捷软件开发 [^2]。
@@ -138,7 +138,7 @@ for count, url in top5: #5
Python 脚本在内存中维护一个 URL 散列表,把每个 URL 映射到它出现的次数。Unix 管道没有这样的散列表,而是依靠排序 URL 列表;同一个 URL 出现多次时,它只是在列表中重复多次。
哪种方法更好?这取决于不同 URL 的数量。对于大多数中小型网站,你大概可以把所有不同的 URL 及其计数器放进 1 GB 左右的内存。这个作业的 *工作集*(即作业需要随机访问的内存量)只取决于不同 URL 的数量:即使日志中同一个 URL 出现了一百万次,散列表所需的空间仍然只是一个 URL 加一个计数器。只要工作集足够小,内存散列表就能很好地工作——即便在笔记本电脑上也是如此。
哪种方法更好?这取决于不同 URL 的数量。对于大多数中小型网站,你大概可以把所有不同的 URL 及其计数器放进 1 GB 左右的内存。这个作业的 *工作集**working set*即作业需要随机访问的内存量)只取决于不同 URL 的数量:即使日志中同一个 URL 出现了一百万次,散列表所需的空间仍然只是一个 URL 加一个计数器。只要工作集足够小,内存散列表就能很好地工作——即便在笔记本电脑上也是如此。
另一方面,如果作业的工作集大于可用内存,排序方法就有一个优势:它可以高效利用磁盘。这与[“日志结构存储”](/ch4#sec_storage_log_structured)中讨论的原理相同:先在内存中对数据块排序,并把它们作为段文件写入磁盘,再将多个有序段合并成一个更大的有序文件。归并排序采用顺序访问模式,在磁盘上表现很好(参见[“顺序与随机写入”](/ch4#sidebar_sequential))。
@@ -156,7 +156,7 @@ Unix 工具的局限在于,它们只能在单台机器上运行。如果数据
- 一系列 Unix 程序,其 `stdin` 和 `stdout` 通过管道连接在一起。
分布式数据处理框架中也存在同样的组件。事实上,你可以把分布式处理框架看作一种分布式操作系统:它们拥有文件系统和作业调度器,程序则通过文件系统或其他通信通道相互发送数据。
分布式数据处理框架中也存在同样的组件。事实上,你可以把 *分布式处理框架**distributed processing framework*看作一种分布式操作系统:它们拥有文件系统和作业调度器,程序则通过文件系统或其他通信通道相互发送数据。
### 分布式文件系统 {#sec_batch_dfs}
@@ -170,11 +170,11 @@ Unix 工具的局限在于,它们只能在单台机器上运行。如果数据
- 最后操作系统通过名为虚拟文件系统VFS的统一 API把不同文件系统暴露给应用。无论底层采用哪一种文件系统应用都可以用同样的方式读写数据。
分布式文件系统的工作方式与此非常相似。文件同样被拆分成块只不过这些块分布在许多机器上。分布式文件系统的块通常比本地文件系统大得多HDFSHadoop 分布式文件系统)的默认块大小为 128 MBJuiceFS 和许多对象存储使用 4 MB 的块,而 ext4 的块只有 4,096 字节。块越大,需要跟踪的元数据就越少;对于 PB 级数据集,这种差异十分显著。相对于读取数据块所需的时间,大块也能降低寻道开销所占的比例。
*分布式文件系统**distributed filesystem*的工作方式与此非常相似。文件同样被拆分成块只不过这些块分布在许多机器上。分布式文件系统的块通常比本地文件系统大得多HDFSHadoop 分布式文件系统)的默认块大小为 128 MBJuiceFS 和许多对象存储使用 4 MB 的块,而 ext4 的块只有 4,096 字节。块越大,需要跟踪的元数据就越少;对于 PB 级数据集,这种差异十分显著。相对于读取数据块所需的时间,大块也能降低寻道开销所占的比例。
大多数物理存储设备无法写入不完整的数据块,因此即使数据没有填满一个块,操作系统也必须为写入使用整个块。分布式文件系统的块更大,而且通常构建在操作系统文件系统之上,所以没有这种要求。例如,一个 900 MB 的文件采用 128 MB 的分块时,会由 7 个占用 128 MB 的块和 1 个占用 4 MB 的块组成。
读取分布式文件系统中的块,需要向集群中存放该块的机器发出网络请求。每台机器都运行一个守护进程,并公开一套 API让远程进程能够读写以文件形式存储在其本地文件系统中的数据块。HDFS 把这些守护进程称为 DataNodeGlusterFS 则称为 glusterfsd。本书统一把它们称为 *数据节点*。
读取分布式文件系统中的块,需要向集群中存放该块的机器发出网络请求。每台机器都运行一个守护进程,并公开一套 API让远程进程能够读写以文件形式存储在其本地文件系统中的数据块。HDFS 把这些守护进程称为 DataNodeGlusterFS 则称为 glusterfsd。本书统一把它们称为 *数据节点**data node*
分布式文件系统还实现了分布式版本的页缓存。由于数据块以文件形式存放在数据节点上,读写操作会经过每个数据节点的操作系统,其中就包含内存页缓存。因此,经常读取的数据块会被缓存在数据节点的内存中。有些分布式文件系统还实现了更多缓存层,例如 JuiceFS 提供的客户端缓存和本地磁盘缓存。
@@ -187,11 +187,11 @@ ext4 和 XFS 等文件系统会跟踪空闲空间、文件块位置、目录结
> [!TIP] 分布式文件系统与网络存储
> 分布式文件系统以 *无共享* 原则为基础(参见[“共享内存、共享磁盘与无共享架构”](/ch2#sec_introduction_shared_nothing)不同于网络附加存储NAS和存储区域网络SAN架构采用的 *共享磁盘* 方法。共享磁盘存储由集中式存储设备实现,往往需要定制硬件和光纤通道等专用网络基础设施。相比之下,无共享方法不需要特殊硬件,只需用普通的数据中心网络连接计算机即可。
许多分布式文件系统构建在普通商用硬件上。这种硬件比较便宜,但故障率也高于企业级硬件。为了容忍机器和磁盘故障,文件块会复制到多台机器上。这样也便于调度器更均匀地分配工作负载,因为任务可以在任何存有其输入数据副本的节点上运行。这里的复制可以像[第 6 章](/ch6#ch_replication)所述,在多台机器上保存若干份完整副本;也可以采用 ReedSolomon 码等 *纠删码*,以低于完整复制的存储开销恢复丢失的数据 [^10] [^11] [^12]。这些技术与 RAID 很相似,后者在连接到同一台机器的多个磁盘之间提供冗余;区别在于,分布式文件系统通过普通的数据中心网络访问和复制文件,不需要特殊硬件。
许多分布式文件系统构建在普通商用硬件上。这种硬件比较便宜,但故障率也高于企业级硬件。为了容忍机器和磁盘故障,文件块会复制到多台机器上。这样也便于调度器更均匀地分配工作负载,因为任务可以在任何存有其输入数据副本的节点上运行。这里的复制可以像[第 6 章](/ch6#ch_replication)所述,在多台机器上保存若干份完整副本;也可以采用 ReedSolomon 码等 *纠删码**erasure coding*,以低于完整复制的存储开销恢复丢失的数据 [^10] [^11] [^12]。这些技术与 RAID 很相似,后者在连接到同一台机器的多个磁盘之间提供冗余;区别在于,分布式文件系统通过普通的数据中心网络访问和复制文件,不需要特殊硬件。
### 对象存储 {#id277}
Amazon S3、Google Cloud Storage、Azure Blob Storage 和 OpenStack Swift 等对象存储服务,已经成为批处理作业中分布式文件系统的常用替代方案。事实上,两者的界限有些模糊。正如上一节和[“以对象存储为后端的数据库”](/ch6#sec_replication_object_storage)中所述用户空间文件系统FUSE驱动程序可以让用户把 S3 之类的对象存储当作文件系统使用。JuiceFS 和 Ceph 等分布式文件系统实现也同时提供对象存储与文件系统 API。不过两类系统的 API、性能和一致性保证可能大相径庭。即使某个系统看似实现了所需的 API采用之前也必须仔细确认它的实际行为符合预期。
Amazon S3、Google Cloud Storage、Azure Blob Storage 和 OpenStack Swift 等 *对象存储**object storage*服务,已经成为批处理作业中分布式文件系统的常用替代方案。事实上,两者的界限有些模糊。正如上一节和[“以对象存储为后端的数据库”](/ch6#sec_replication_object_storage)中所述用户空间文件系统FUSE驱动程序可以让用户把 S3 之类的对象存储当作文件系统使用。JuiceFS 和 Ceph 等分布式文件系统实现也同时提供对象存储与文件系统 API。不过两类系统的 API、性能和一致性保证可能大相径庭。即使某个系统看似实现了所需的 API采用之前也必须仔细确认它的实际行为符合预期。
对象存储中的每个对象都有一个 URL例如 `s3://my-photo-bucket/2025/04/01/birthday.png`。URL 的主机部分(`my-photo-bucket`表示存放对象的存储桶bucket后面的部分则是对象的 *键*(本例中为 `/2025/04/01/birthday.png`)。存储桶的名称在全局范围内唯一,而每个对象的键在所属存储桶内必须唯一。
@@ -264,7 +264,7 @@ Kubernetes 和 Hadoop YARNYet Another Resource Negotiator[^13] 等编排
这个例子非常简单,却已经暴露出许多艰难的权衡。以成组调度为例:如果调度器不断预留 CPU 核,直到 100 个核能够同时使用,那么一些节点就会闲置,集群的资源利用率也会下降;如果其他作业也试图预留 CPU 核,甚至还可能发生死锁。
另一方面,如果调度器只是等待 100 个核空闲,其他作业又可能在此期间抢走这些核。集群或许会在很长时间内都凑不出 100 个可用核,从而导致 *饥饿*。调度器还可以 *抢占* 第一项作业的部分任务,终止它们来为第二项作业腾出空间。不过,抢占任务同样会降低集群效率,因为被终止的任务稍后需要重新启动、重新执行。
另一方面,如果调度器只是等待 100 个核空闲,其他作业又可能在此期间抢走这些核。集群或许会在很长时间内都凑不出 100 个可用核,从而导致 *饥饿**starvation*。调度器还可以 *抢占**preempt*第一项作业的部分任务,终止它们来为第二项作业腾出空间。不过,抢占任务同样会降低集群效率,因为被终止的任务稍后需要重新启动、重新执行。
现在再设想一下,调度器必须为数百乃至数百万项这样的作业请求作出分配决策。要找到最优解似乎根本不可行。事实上,这个问题是 *NP-hard* 的;也就是说,除了规模最小的例子之外,计算最优解所需的时间长得令人无法接受 [^14] [^15]。
@@ -272,7 +272,7 @@ Kubernetes 和 Hadoop YARNYet Another Resource Negotiator[^13] 等编排
#### 工作流调度 {#sec_batch_workflows}
本章开头的 Unix 工具示例由若干命令串联而成。分布式批处理也经常采用相同的模式:一项作业的输出需要成为另一项或多项作业的输入,而一项作业又可能有多个输入,分别由其他作业产生。这种作业结构称为 *工作流*,也称为作业的 *有向无环图*DAG
本章开头的 Unix 工具示例由若干命令串联而成。分布式批处理也经常采用相同的模式:一项作业的输出需要成为另一项或多项作业的输入,而一项作业又可能有多个输入,分别由其他作业产生。这种作业结构称为 *工作流**workflow*,也称为作业的 *有向无环图**directed acyclic graph*DAG
> [!NOTE]
> 在[“持久化执行与工作流”](/ch5#sec_encoding_dataflow_workflows)中,我们见过能够持久执行一系列步骤的工作流引擎,这些步骤通常会发出 RPC。在批处理的语境中“工作流”有不同含义它是一系列批处理过程每个过程都接收输入数据、产生输出数据通常不会向外部服务发出 RPC。持久化执行引擎通常会比批处理系统在每个请求中处理更少的数据不过两者之间的界限并不十分清晰。
@@ -346,7 +346,7 @@ Reducer
为了解决 MapReduce 的一些问题,人们开发了几种新的分布式批处理执行引擎,其中最著名的是 Spark [^18] [^21] 和 Flink [^19]。它们的设计方式各有不同,却有一个共同点:把整个工作流作为一项作业处理,而不是拆成彼此独立的子作业。
这些系统显式建模了数据流经多个处理阶段的过程,因此称为 *数据流引擎*。与 MapReduce 一样,它们提供底层 API通过反复调用用户定义的函数每次处理一条记录同时也提供 *连接* 和 *分组* 等高层算子。它们通过对输入分片来并行执行工作,并把一项任务的输出复制到另一项任务,作为后者的输入;如果两项任务运行在不同机器上,复制就通过网络完成。与 MapReduce 不同,算子不必严格交替扮演 map 和 reduce 的角色,而可以以更加灵活的方式组合。
这些系统显式建模了数据流经多个处理阶段的过程,因此称为 *数据流引擎**dataflow engine*。与 MapReduce 一样,它们提供底层 API通过反复调用用户定义的函数每次处理一条记录同时也提供 *连接**join*)和 *分组**grouping*等高层算子。它们通过对输入分片来并行执行工作,并把一项任务的输出复制到另一项任务,作为后者的输入;如果两项任务运行在不同机器上,复制就通过网络完成。与 MapReduce 不同,算子不必严格交替扮演 map 和 reduce 的角色,而可以以更加灵活的方式组合。
这些数据流 API 通常使用关系模型风格的构件来表达计算:按照某个字段的值连接数据集,按键对元组分组,根据某项条件过滤数据,以及通过计数、求和等函数聚合元组。这些操作在内部通过下一节讨论的混洗算法实现。
@@ -395,7 +395,7 @@ MapReduce 在 map 与 reduce 阶段之间执行混洗,而现代数据流引擎
下面来看有序数据如何简化分布式连接与聚合。为便于说明,我们仍以 MapReduce 为例,不过这些概念适用于大多数批处理系统。
{{< xref fig="11-2" page="/ch11" anchor="fig_batch_join_example" >}}图 11-2{{< /xref >}} 展示了批处理作业中一个典型的连接示例。左侧是一份事件日志,记录已登录用户在网站上的操作,这些记录称为 *活动事件*,也叫 *点击流数据*;右侧则是用户数据库。这个例子可以看作星型模式的一部分(参见[“星型与雪花型:分析模式”](/ch3#sec_datamodels_analytics)):事件日志是事实表,用户数据库则是其中一张维度表。
{{< xref fig="11-2" page="/ch11" anchor="fig_batch_join_example" >}}图 11-2{{< /xref >}} 展示了批处理作业中一个典型的连接示例。左侧是一份事件日志,记录已登录用户在网站上的操作,这些记录称为 *活动事件**activity event*),也叫 *点击流数据**clickstream data*;右侧则是用户数据库。这个例子可以看作星型模式的一部分(参见[“星型与雪花型:分析模式”](/ch3#sec_datamodels_analytics)):事件日志是事实表,用户数据库则是其中一张维度表。
{{< fig num="11-2" id="fig_batch_join_example" src="/fig/ddia_1102.png" caption="用户活动日志与用户画像数据库的连接。" class="ddia-figure ddia-figure--wide" width="2880" height="1238" />}}
@@ -405,7 +405,7 @@ MapReduce 在 map 与 reduce 阶段之间执行混洗,而现代数据流引擎
{{< fig num="11-3" id="fig_batch_join_reduce" src="/fig/ddia_1103.png" caption="基于用户 ID 的排序合并连接。若输入数据集由多个文件分片组成,可并行启动多个 mapper 处理。" class="ddia-figure ddia-figure--wide" width="2880" height="1308" />}}
混洗随后确保 reducer 函数能够同时访问某位用户的出生日期以及该用户的所有页面浏览事件。MapReduce 甚至可以安排记录的顺序,让 reducer 总是先看到用户数据库中的记录,接着再按时间戳顺序看到活动事件;这种技术称为 *二次排序* [^25]。
混洗随后确保 reducer 函数能够同时访问某位用户的出生日期以及该用户的所有页面浏览事件。MapReduce 甚至可以安排记录的顺序,让 reducer 总是先看到用户数据库中的记录,接着再按时间戳顺序看到活动事件;这种技术称为 *二次排序**secondary sort*[^25]。
这样reducer 就能轻松完成实际的连接逻辑。第一个值应当是出生日期reducer 先把它保存在局部变量中,再遍历具有相同用户 ID 的活动事件,输出每个浏览过的 URL 以及浏览者的出生日期。reducer 会一次处理某个用户 ID 的全部记录,所以任何时刻只需在内存中保存一条用户记录,也完全不必发出网络请求。这种算法称为 *排序合并连接*sort-merge join因为 mapper 的输出按键排序,而 reducer 随后会合并连接两侧的有序记录列表。
@@ -478,11 +478,11 @@ Daft 等框架甚至同时支持客户端与服务端计算:规模较小的内
### 分析 {#sec_batch_olap}
在[“分析型与事务型系统”](/ch1#sec_introduction_analytics)中我们看到分析查询OLAP经常扫描大量记录并执行分组与聚合。这样的工作负载可以与其他批处理工作负载一起在批处理系统中运行。分析师编写 SQL 查询,由查询引擎执行,并读写分布式文件系统或对象存储。表与文件之间的映射、名称和类型等表元数据,则通过 Apache Iceberg 等表格式和 Unity 等目录服务管理(参见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))。这种架构称为 *数据湖仓* [^39]。
在[“分析型与事务型系统”](/ch1#sec_introduction_analytics)中我们看到分析查询OLAP经常扫描大量记录并执行分组与聚合。这样的工作负载可以与其他批处理工作负载一起在批处理系统中运行。分析师编写 SQL 查询,由查询引擎执行,并读写分布式文件系统或对象存储。表与文件之间的映射、名称和类型等表元数据,则通过 Apache Iceberg 等表格式和 Unity 等目录服务管理(参见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))。这种架构称为 *数据湖仓**data lakehouse*[^39]。
与 ETL 一样SQL 查询接口的改进使许多组织如今也使用 Spark 等批处理框架进行分析。这类查询模式分为两种:
- *预聚合查询*:把数据汇总成 OLAP 多维数据集或数据集市,以加快查询(参见[“物化视图与多维数据集”](/ch4#sec_storage_materialized_views))。预聚合数据可以在数据仓库中查询,也可以推送到 Apache Druid 或 Apache Pinot 等专用的实时 OLAP 系统。预聚合通常按固定周期进行,这类工作负载由[“工作流调度”](/ch11#sec_batch_workflows)中讨论的工作流调度器管理。
- *预聚合查询**pre-aggregated query*:把数据汇总成 OLAP 多维数据集或数据集市,以加快查询(参见[“物化视图与多维数据集”](/ch4#sec_storage_materialized_views))。预聚合数据可以在数据仓库中查询,也可以推送到 Apache Druid 或 Apache Pinot 等专用的实时 OLAP 系统。预聚合通常按固定周期进行,这类工作负载由[“工作流调度”](/ch11#sec_batch_workflows)中讨论的工作流调度器管理。
- *即席查询*ad hoc query用户运行查询来回答特定业务问题、调查用户行为、调试运行故障以及完成其他许多工作。在这种场景中响应时间很重要。分析师会反复运行查询在得到响应、进一步了解正在研究的数据后继续调整查询。能够快速执行查询的批处理框架可以减少分析师的等待时间。
@@ -490,7 +490,7 @@ SQL 支持还让批处理框架能够与电子表格和 Tableau、Power BI、Loo
### 机器学习 {#id290}
机器学习ML经常用到批处理。数据科学家、机器学习工程师和 AI 工程师使用批处理框架来探索数据规律、转换数据并训练机器学习模型。常见用途包括:
*机器学习*ML经常用到批处理。数据科学家、机器学习工程师和 AI 工程师使用批处理框架来探索数据规律、转换数据并训练机器学习模型。常见用途包括:
- *特征工程*:对原始数据进行过滤和转换,使其能够用于训练模型。预测模型通常要求输入数值数据,因此工程师必须把文本或离散值等其他形式的数据转换成所需格式。
- *模型训练*:训练数据是批处理过程的输入,训练所得的模型权重则是输出。
@@ -502,7 +502,7 @@ SQL 支持还让批处理框架能够与电子表格和 Tableau、Power BI、Loo
*批量同步并行*bulk synchronous parallelBSP计算模型 [^40] 已经成为批量处理图数据时的常用模型。Apache Giraph [^20]、Spark 的 GraphX API 和 Flink 的 Gelly API [^41] 等系统都实现了这一模型。它也称为 *Pregel* 模型,因为 Google 的 Pregel 论文推广了这种图处理方法 [^42]。
批处理也是大语言模型LLM数据准备与训练的重要组成部分。网站等原始文本输入通常存放在分布式文件系统或对象存储中必须先经过预处理才能用于训练。适合交给批处理框架完成的预处理步骤包括
批处理也是 *大语言模型*LLM数据准备与训练的重要组成部分。网站等原始文本输入通常存放在分布式文件系统或对象存储中必须先经过预处理才能用于训练。适合交给批处理框架完成的预处理步骤包括
- 从 HTML 中提取纯文本,并修复格式损坏的文本;
- 检测并删除质量低劣、内容无关或重复的文档;

View File

@@ -17,9 +17,9 @@ breadcrumbs: false
在 [第 11 章](/ch11#ch_batch) 中,我们讨论了批处理技术:它以一组文件作为输入,并生成一组新的输出文件。输出是 *衍生数据derived data* 的一种形式;也就是说,如有必要,可以再次运行批处理来重新创建这份数据集。我们看到,这个简单而强大的思想可以用来构建搜索索引、推荐系统和分析系统,等等。
然而,[第 11 章](/ch11#ch_batch) 始终建立在一个重要假设之上:输入是有界的,也就是大小已知且有限,因此批处理知道何时已经读完输入。例如,作为 MapReduce 核心环节的排序操作必须先读完全部输入,才能开始生成输出。因为最后一条输入记录可能恰好拥有最小的键,因而需要成为第一条输出记录,所以不能提前开始输出。
然而,[第 11 章](/ch11#ch_batch) 始终建立在一个重要假设之上:输入是 *有界的**bounded*,也就是大小已知且有限,因此批处理知道何时已经读完输入。例如,作为 MapReduce 核心环节的排序操作必须先读完全部输入,才能开始生成输出。因为最后一条输入记录可能恰好拥有最小的键,因而需要成为第一条输出记录,所以不能提前开始输出。
实际上,许多数据都是 *无界的*,因为它们会随时间推移陆续到达:用户昨天和今天产生了数据,明天还会继续产生更多数据。只要你的企业还在经营,这个过程就不会结束,因此从任何有意义的角度来看,数据集都永远不会“完整”[^1]。所以,批处理程序不得不人为地按固定时长切分数据,例如每天结束时处理当天的数据,或每小时结束时处理这一小时的数据。
实际上,许多数据都是 *无界的**unbounded*,因为它们会随时间推移陆续到达:用户昨天和今天产生了数据,明天还会继续产生更多数据。只要你的企业还在经营,这个过程就不会结束,因此从任何有意义的角度来看,数据集都永远不会“完整”[^1]。所以,批处理程序不得不人为地按固定时长切分数据,例如每天结束时处理当天的数据,或每小时结束时处理这一小时的数据。
每日批处理的问题在于,输入中的变化要到一天后才会反映到输出中,对许多没有耐心的用户来说实在太慢。为了缩短延迟,可以更频繁地运行处理——比如每秒结束时处理这一秒的数据——也可以完全抛开固定的时间切片,改为连续处理,每个事件一发生就立即处理。这就是 *流处理stream processing* 的基本思想。
@@ -216,13 +216,13 @@ Apache Kafka[^20] 和 Amazon Kinesis Streams 都是以这种方式工作的基
前面我们对消息代理与数据库做过一些比较。传统上,它们被视为两类不同的工具,但基于日志的消息代理已经成功地把数据库中的思想用于消息传递。反过来也一样:我们可以把消息传递和流中的思想用于数据库。
一种做法是用 *事件流充当存储数据的权威记录系统*(参阅 [“权威记录系统与衍生数据”](/ch1#sec_introduction_derived))。这正是我们在 [“事件溯源与 CQRS”](/ch3#sec_datamodels_events) 中讨论过的 *事件溯源*:不再用更新和删除来改变数据模型,而是把每次状态变化建模成不可变事件,写入仅追加日志;所有为读取优化的物化视图都从这些事件中衍生出来。基于日志的消息代理采用仅追加存储,还能以低延迟通知消费者有新事件到来,因此很适合事件溯源——只要将其配置为永不删除旧事件。
一种做法是用 *事件流充当存储数据的权威记录系统*(参阅 [“权威记录系统与衍生数据”](/ch1#sec_introduction_derived))。这正是我们在 [“事件溯源与 CQRS”](/ch3#sec_datamodels_events) 中讨论过的 *事件溯源**event sourcing*:不再用更新和删除来改变数据模型,而是把每次状态变化建模成不可变事件,写入仅追加日志;所有为读取优化的物化视图都从这些事件中衍生出来。基于日志的消息代理采用仅追加存储,还能以低延迟通知消费者有新事件到来,因此很适合事件溯源——只要将其配置为永不删除旧事件。
不过,你不必走到采用事件溯源这一步;即便数据模型是可变的,事件流对数据库仍然很有用。事实上,每次数据库写入都是一个可以捕获、存储和处理的事件。数据库与流的联系不只是日志在磁盘上的物理存储形式,而是更为根本。
例如,复制日志(参阅 [“复制日志的实现”](/ch6#sec_replication_implementation))就是数据库写入事件组成的流,由领导者在处理事务时生成。追随者把这股写入流应用到自己的数据库副本上,最终得到同一份数据的准确副本。复制日志中的事件描述的正是已经发生的数据变化。
我们还在 [“使用共享日志”](/ch10#sec_consistency_smr) 中遇到过 *状态机复制* 原理:如果每个事件都代表一次数据库写入,并且每个副本都以相同顺序处理相同事件,那么所有副本最终都会达到相同状态(这里假定事件处理是确定性的操作)。这又是事件流的一个例子!
我们还在 [“使用共享日志”](/ch10#sec_consistency_smr) 中遇到过 *状态机复制**state machine replication*原理:如果每个事件都代表一次数据库写入,并且每个副本都以相同顺序处理相同事件,那么所有副本最终都会达到相同状态(这里假定事件处理是确定性的操作)。这又是事件流的一个例子!
本节先考察异构数据系统中会出现的一个问题,再探讨如何把事件流的思想引入数据库来解决它。
@@ -232,7 +232,7 @@ Apache Kafka[^20] 和 Amazon Kinesis Streams 都是以这种方式工作的基
相同或相关的数据分散在不同位置,就必须彼此保持同步:数据库中的某个项目更新后,缓存、搜索索引和数据仓库也要随之更新。数据仓库通常通过 ETL 流程完成同步(参阅 [“数据仓库”](/ch1#sec_introduction_dwh)):先取得数据库的完整副本,转换数据,再批量加载进数据仓库——换言之,这是一个批处理过程。同样,我们在 [“批处理用例”](/ch11#sec_batch_output) 中看到,搜索索引、推荐系统以及其他衍生数据系统也可以通过批处理来创建。
如果定期转储完整数据库太慢,有时会改用 *双写*:数据发生变化时,应用代码显式写入每个系统,例如先写数据库,再更新搜索索引,最后使相应的缓存项失效(也可能并发执行这些写入)。
如果定期转储完整数据库太慢,有时会改用 *双写**dual write*:数据发生变化时,应用代码显式写入每个系统,例如先写数据库,再更新搜索索引,最后使相应的缓存项失效(也可能并发执行这些写入)。
然而,双写存在一些严重问题,其中之一就是 {{< xref fig="12-4" page="/ch12" anchor="fig_stream_write_order" >}}图 12-4{{< /xref >}} 所示的竞态条件。在这个例子中,两个客户端并发更新项目 X客户端 1 想把值设为 A客户端 2 想把值设为 B。两个客户端都先把新值写入数据库再写入搜索索引。由于时序不巧请求交错执行数据库先收到客户端 1 将值设为 A 的写入,再收到客户端 2 将值设为 B 的写入,因此数据库中的最终值为 B搜索索引却先收到客户端 2 的写入,再收到客户端 1 的写入,因此最终值为 A。虽然没有发生任何错误两个系统却永久地不一致了。
@@ -262,11 +262,11 @@ Apache Kafka[^20] 和 Amazon Kinesis Streams 都是以这种方式工作的基
#### 变更数据捕获的实现 {#id307}
按照 [“权威记录系统与衍生数据”](/ch1#sec_introduction_derived) 中的说法,我们可以把日志消费者称为 *衍生数据系统*:搜索索引和数据仓库中存储的数据,不过是权威记录系统中数据的另一种视图。变更数据捕获机制确保权威记录系统中的所有变化也会反映到衍生数据系统中,使衍生系统持有准确的数据副本。
按照 [“权威记录系统与衍生数据”](/ch1#sec_introduction_derived) 中的说法,我们可以把日志消费者称为 *衍生数据系统**derived data system*:搜索索引和数据仓库中存储的数据,不过是权威记录系统中数据的另一种视图。变更数据捕获机制确保权威记录系统中的所有变化也会反映到衍生数据系统中,使衍生系统持有准确的数据副本。
实质上,变更数据捕获让一个数据库成为领导者(即从中捕获变化的数据库),让其他系统成为追随者。基于日志的消息代理能够保持消息顺序,避免 {{< xref fig="12-2" page="/ch12" anchor="fig_stream_redelivery" >}}图 12-2{{< /xref >}} 中的乱序问题,因此很适合把变更事件从源数据库传送到衍生系统。
逻辑复制日志可以用来实现变更数据捕获(参阅 [“逻辑(基于行)的日志复制”](/ch6#sec_replication_logical)),不过需要应对模式变更、恰当建模更新等挑战。开源项目 Debezium 正是为解决这些问题而生。它为 MySQL、PostgreSQL、Oracle、SQL Server、Db2、Cassandra 以及其他许多数据库提供了 *源连接器*。这些连接器接入数据库复制日志以标准事件模式呈现其中的变化随后便可转换消息并将其写入下游数据库。Kafka Connect 框架也为各种数据库提供了更多 CDC 连接器。Maxwell 通过解析 binlog 为 MySQL 提供类似功能[^29]GoldenGate 为 Oracle 提供类似功能pgcapture 则面向 PostgreSQL。
逻辑复制日志可以用来实现变更数据捕获(参阅 [“逻辑(基于行)的日志复制”](/ch6#sec_replication_logical)),不过需要应对模式变更、恰当建模更新等挑战。开源项目 Debezium 正是为解决这些问题而生。它为 MySQL、PostgreSQL、Oracle、SQL Server、Db2、Cassandra 以及其他许多数据库提供了 *源连接器**source connector*。这些连接器接入数据库复制日志以标准事件模式呈现其中的变化随后便可转换消息并将其写入下游数据库。Kafka Connect 框架也为各种数据库提供了更多 CDC 连接器。Maxwell 通过解析 binlog 为 MySQL 提供类似功能[^29]GoldenGate 为 Oracle 提供类似功能pgcapture 则面向 PostgreSQL。
与消息代理一样,变更数据捕获通常也是异步的:权威记录数据库提交变化之前,并不会等待消费者应用该变化。这种设计在运维上的好处是,增加一个缓慢的消费者不会对权威记录系统造成太大影响;缺点则是复制延迟的所有问题同样存在(参阅 [“复制延迟的问题”](/ch6#sec_replication_lag))。
@@ -286,7 +286,7 @@ Apache Kafka[^20] 和 Amazon Kinesis Streams 都是以这种方式工作的基
{{< fig num="12-6" id="fig_stream_compaction" src="/fig/ddia_1206.png" caption="一个键值对日志:键是猫咪视频的 IDmew、purr、scratch 或 yawn值是播放次数。日志压实只保留每个键的最新值。" class="ddia-figure ddia-figure--wide" width="2880" height="1477" />}}
在日志结构存储引擎中,带有特殊空值的更新(称为 *墓碑*)表示某个键已被删除,并使该键在日志压实时被移除。但只要一个键没有被覆盖或删除,它就会永久留在日志中。压实后的日志所需磁盘空间只取决于数据库当前的内容,而与数据库有史以来发生过多少次写入无关。如果同一个键经常被覆盖,旧值最终会被垃圾回收,只留下最新值。
在日志结构存储引擎中,带有特殊空值的更新(称为 *墓碑**tombstone*)表示某个键已被删除,并使该键在日志压实时被移除。但只要一个键没有被覆盖或删除,它就会永久留在日志中。压实后的日志所需磁盘空间只取决于数据库当前的内容,而与数据库有史以来发生过多少次写入无关。如果同一个键经常被覆盖,旧值最终会被垃圾回收,只留下最新值。
同样的思路也适用于基于日志的消息代理和变更数据捕获。如果 CDC 系统保证每次变化都有主键,而且对某个键的每次更新都会取代该键的旧值,那么只保留这个键最近一次写入就足够了。
@@ -353,7 +353,7 @@ Kafka Connect[^33] 把许多数据库系统的变更数据捕获工具与 Kafka
#### 不可变事件的优点 {#sec_stream_immutability_pros}
数据库中的不变性是一个古老的观念。例如,会计师几个世纪以来一直在财务记账中运用不变性。一笔交易发生后,会被记入仅追加的 *分类账*;分类账本质上就是事件日志,描述货币、商品或服务的转手。损益表、资产负债表等账目,则是汇总分类账中的交易而衍生出来的[^40]。
数据库中的不变性是一个古老的观念。例如,会计师几个世纪以来一直在财务记账中运用不变性。一笔交易发生后,会被记入仅追加的 *分类账**ledger*;分类账本质上就是事件日志,描述货币、商品或服务的转手。损益表、资产负债表等账目,则是汇总分类账中的交易而衍生出来的[^40]。
如果出了差错,会计师不会删除或修改分类账中的错误交易,而是另加一笔交易来抵消错误,例如退还一笔误收的费用。错误交易会永远保留在分类账中,因为它对审计可能十分重要。如果根据错误分类账得出的错误数字已经公布,下一个会计期间的数字就会包含相应的更正。这在会计工作中再正常不过[^41]。
@@ -438,7 +438,7 @@ CQRS 最大的缺点是事件日志的消费者通常以异步方式运行。因
*复合事件处理*complex event processingCEP是一种在 20 世纪 90 年代发展起来的事件流分析方法,特别适合需要搜索特定事件模式的应用[^52]。正如正则表达式可以在字符串中搜索特定的字符模式CEP 允许你指定规则,在流中搜索特定的事件模式。
CEP 系统通常使用 SQL 等高级声明式查询语言或图形用户界面,描述应该检测哪些事件模式。这些查询会提交给处理引擎;引擎消费输入流,并在内部维护一个状态机来执行所需的匹配。一旦找到匹配,引擎便发出一个 *复合事件*(名称由此而来),其中包含检测到的事件模式详情[^53]。
CEP 系统通常使用 SQL 等高级声明式查询语言或图形用户界面,描述应该检测哪些事件模式。这些查询会提交给处理引擎;引擎消费输入流,并在内部维护一个状态机来执行所需的匹配。一旦找到匹配,引擎便发出一个 *复合事件**complex event*名称由此而来),其中包含检测到的事件模式详情[^53]。
这类系统中查询与数据的关系恰好和普通数据库相反。数据库通常持久存储数据把查询视为临时对象查询到来时数据库搜索与之匹配的数据查询完成后便将其忘掉。CEP 引擎却反过来长期存储查询;每个事件到来时,引擎都会检查迄今所见的事件是否形成了与某个常驻查询相匹配的模式[^54]。
@@ -454,7 +454,7 @@ CEP 的实现包括 Esper、Apama 和 TIBCO StreamBase。Flink、Spark Streaming
- 将当前统计值与先前时间段对比(例如检测趋势,或在某项指标与上周同一时间相比异常偏高或偏低时发出警报)。
这类统计值通常在固定时间区间内计算。例如,你可能想知道过去 5 分钟内某项服务平均每秒收到多少次查询,以及这段时间内响应时间的第 99 百分位点。在几分钟内取平均,可以抹平相邻秒之间无关紧要的波动,同时仍能及时反映流量模式的变化。用于聚合的时间区间称为 *窗口*,我们将在 [“时间推理”](/ch12#sec_stream_time) 中详细讨论。
这类统计值通常在固定时间区间内计算。例如,你可能想知道过去 5 分钟内某项服务平均每秒收到多少次查询,以及这段时间内响应时间的第 99 百分位点。在几分钟内取平均,可以抹平相邻秒之间无关紧要的波动,同时仍能及时反映流量模式的变化。用于聚合的时间区间称为 *窗口**window*,我们将在 [“时间推理”](/ch12#sec_stream_time) 中详细讨论。
流分析系统有时会使用概率算法,例如用布隆过滤器(我们在 [“布隆过滤器”](/ch4#sec_storage_bloom_filter) 中见过)判断集合成员关系,用 HyperLogLog[^55] 估计基数,以及用各种算法估计百分位点(参阅 [“计算百分位点”](/ch2#sidebar_percentiles))。概率算法给出近似结果,但与精确算法相比,流处理器所需内存少得多。近似算法的这种用途有时让人误以为流处理系统总是有损而不精确,其实不然:流处理本身并没有任何近似性,使用概率算法只是一项优化[^56]。
@@ -515,7 +515,7 @@ Actor 框架也可以用来处理流。不过,许多这类框架无法保证
一个批处理任务可能几分钟内就读完一整年的历史事件;大多数情况下,我们关心的是这一年的历史时间线,而不是几分钟的处理时间。此外,使用事件中的时间戳还可以让处理具有确定性:针对同一输入重新运行同一个过程,会得到相同的结果。
另一方面,许多流处理框架使用处理机器的本地系统时钟(即 *处理时间*)来划分窗口[^64]。这种方法简单明了;如果事件创建与事件处理之间的延迟短到可以忽略,也很合理。但只要处理延迟比较显著——也就是事件实际发生后过了一段明显可感的时间才得到处理——这种方法就会失效。
另一方面,许多流处理框架使用处理机器的本地系统时钟(即 *处理时间**processing time*)来划分窗口[^64]。这种方法简单明了;如果事件创建与事件处理之间的延迟短到可以忽略,也很合理。但只要处理延迟比较显著——也就是事件实际发生后过了一段明显可感的时间才得到处理——这种方法就会失效。
#### 事件时间与处理时间 {#id322}
@@ -525,7 +525,7 @@ Actor 框架也可以用来处理流。不过,许多这类框架无法保证
不妨拿《星球大战》系列电影来类比:第四部于 1977 年上映,第五部于 1980 年上映,第六部于 1983 年上映;随后依次是 1999、2002 和 2005 年上映的第一、二、三部,以及 2015、2017 和 2019 年上映的第七、八、九部[^65]。如果按上映顺序观看,你处理这些电影的顺序就与故事的叙事顺序不同(集数好比事件时间戳,观看日期则是处理时间)。人类能够应对这种不连续性,但流处理算法必须专门设计,才能处理这类时间与顺序问题。
混淆事件时间与处理时间会产生错误数据。例如,假设有一个流处理器用来测量请求速率(统计每秒请求数)。重新部署流处理器时,它可能停机一分钟,恢复运行后再处理积压的事件。如果按处理时间计算速率,处理积压期间看起来会突然出现异常的请求尖峰,而真实的请求速率其实一直很稳定({{< xref fig="12-8" page="/ch12" anchor="fig_stream_processing_time" >}}图 12-8{{< /xref >}})。
混淆 *事件时间**event time*与处理时间会产生错误数据。例如,假设有一个流处理器用来测量请求速率(统计每秒请求数)。重新部署流处理器时,它可能停机一分钟,恢复运行后再处理积压的事件。如果按处理时间计算速率,处理积压期间看起来会突然出现异常的请求尖峰,而真实的请求速率其实一直很稳定({{< xref fig="12-8" page="/ch12" anchor="fig_stream_processing_time" >}}图 12-8{{< /xref >}})。
{{< fig num="12-8" id="fig_stream_processing_time" src="/fig/ddia_1208.png" caption="按处理时间划分窗口,会因处理速率的变化而产生人为假象。" class="ddia-figure ddia-figure--wide" width="2880" height="1619" />}}
@@ -539,7 +539,7 @@ Actor 框架也可以用来处理流。不过,许多这类框架无法保证
1. 忽略滞留事件,因为正常情况下,它们可能只占所有事件的很小一部分。可以把丢弃事件的数量作为指标跟踪;如果开始丢弃大量数据,就发出警报。
2. 发布 *更正*:为窗口发布一个包含滞留事件的更新值。可能还需要撤回先前的输出。
2. 发布 *更正**correction*:为窗口发布一个包含滞留事件的更新值。可能还需要撤回先前的输出。
有时可以用一条特殊消息表示:“从现在起,不会再有时间戳早于 *t* 的消息。”消费者可以利用它触发窗口[^66]。然而,如果多台机器上的多个生产者都在生成事件,各自拥有不同的最小时间戳阈值,消费者就必须分别跟踪每个生产者。这时,增加或移除生产者会更加棘手。
@@ -587,7 +587,7 @@ Actor 框架也可以用来处理流。不过,许多这类框架无法保证
在 [“JOIN 与 GROUP BY”](/ch11#sec_batch_join) 中,我们讨论过批处理作业如何按键连接数据集,以及这种连接为何是数据流水线的重要组成部分。流处理把数据流水线推广到对无界数据集的增量处理,因此也同样需要对流执行连接。
不过,流中随时可能出现新事件,使流连接比批处理作业中的连接更具挑战。为了看清这个问题,我们把连接分成三类:*流—流连接*、*流—表连接* 和 *表—表连接*。下面各用一个例子来说明。
不过,流中随时可能出现新事件,使流连接比批处理作业中的连接更具挑战。为了看清这个问题,我们把连接分成三类:*流—流连接**stream-stream join*)、*流—表连接**stream-table join*)和 *表—表连接**table-table join*。下面各用一个例子来说明。
#### 流流连接(窗口连接) {#id440}
@@ -597,7 +597,7 @@ Actor 框架也可以用来处理流。不过,许多这类框架无法保证
请注意,把搜索详情嵌入点击事件并不等同于连接两类事件:这样只能了解用户点击搜索结果的情况,却无法了解用户没有点击任何结果的搜索。衡量搜索质量需要准确的点击率,因此搜索事件和点击事件缺一不可。
为了实现这类连接,流处理器需要维护 *状态*,例如按会话 ID 索引过去一小时内的所有事件。每当搜索事件或点击事件到来,就把它加入相应索引,同时检查另一个索引,看看相同会话 ID 的另一事件是否已经到达。找到匹配时,发出一个事件,说明哪项搜索结果被点击;如果搜索事件过期时仍未看到匹配的点击事件,则发出一个事件,说明哪些搜索结果没有被点击。
为了实现这类连接,流处理器需要维护 *状态**state*,例如按会话 ID 索引过去一小时内的所有事件。每当搜索事件或点击事件到来,就把它加入相应索引,同时检查另一个索引,看看相同会话 ID 的另一事件是否已经到达。找到匹配时,发出一个事件,说明哪项搜索结果被点击;如果搜索事件过期时仍未看到匹配的点击事件,则发出一个事件,说明哪些搜索结果没有被点击。
#### 流表连接(流扩充) {#sec_stream_table_joins}
@@ -605,7 +605,7 @@ Actor 框架也可以用来处理流。不过,许多这类框架无法保证
执行这项连接时,流处理器要逐一查看活动事件,在数据库中查找事件里的用户 ID再把档案信息加入活动事件。数据库查找可以通过查询远程数据库来实现不过正如 [“JOIN 与 GROUP BY”](/ch11#sec_batch_join) 中所讨论的,这类远程查询很可能速度缓慢,还可能使数据库过载[^58]。
另一种做法是把数据库副本载入流处理器,在本地查询,免去网络往返。由于数据库的本地副本可能是内存散列表(如果足够小),也可能是本地磁盘上的索引,因此这项技术称为 *散列连接*
另一种做法是把数据库副本载入流处理器,在本地查询,免去网络往返。由于数据库的本地副本可能是内存散列表(如果足够小),也可能是本地磁盘上的索引,因此这项技术称为 *散列连接**hash join*
它与批处理作业的区别在于:批处理作业把数据库某个时间点的快照用作输入;流处理器却长期运行,而数据库内容很可能随时间变化,所以流处理器中的本地副本必须持续更新。变更数据捕获可以解决这个问题:除了活动事件流,流处理器还可以订阅用户档案数据库的变更日志。每当创建或修改档案时,流处理器就更新本地副本。这样,我们实际上得到了两股流之间的连接:活动事件与档案更新。
@@ -684,7 +684,7 @@ Apache Flink 采用一种变体:定期生成滚动的状态检查点,并将
#### 幂等性 {#sec_stream_idempotence}
我们的目标是丢弃所有失败任务的部分输出,以便能安全地重试,而不会生效两次。分布式事务是实现这个目标的一种方法;另一种方法是依靠 *幂等性*,正如 [“持久化执行与工作流”](/ch5#sec_encoding_dataflow_workflows) 中所见[^80]。
我们的目标是丢弃所有失败任务的部分输出,以便能安全地重试,而不会生效两次。分布式事务是实现这个目标的一种方法;另一种方法是依靠 *幂等性**idempotence*,正如 [“持久化执行与工作流”](/ch5#sec_encoding_dataflow_workflows) 中所见[^80]。
幂等操作可以执行多次,效果与只执行一次相同。例如,删除键值存储中的一个键是幂等的(再次删除不会产生进一步影响);递增计数器却不是幂等的(再执行一次递增,值就增加了两次)。

View File

@@ -17,9 +17,9 @@ breadcrumbs: false
>
> 圣托马斯・阿奎那《神学大全》1265—1274
我们在 [第 2 章](/ch2#ch_nonfunctional) 中讨论了构建 *可靠*、*可伸缩*、*可维护* 的应用与系统这一目标。这些主题贯穿全书各章:例如,我们讨论了许多有助于提升可靠性的容错算法、提升可伸缩性的分片,以及提升可维护性的演化与抽象机制。
我们在 [第 2 章](/ch2#ch_nonfunctional) 中讨论了构建 *可靠**reliable*)、*可伸缩**scalable*)、*可维护**maintainable*的应用与系统这一目标。这些主题贯穿全书各章:例如,我们讨论了许多有助于提升可靠性的容错算法、提升可伸缩性的分片,以及提升可维护性的演化与抽象机制。
在本章中,我们将把所有这些想法汇集起来,并特别以 [第 12 章](/ch12#ch_stream) 的流处理与事件驱动架构为基础,形成一套能够实现上述目标的应用开发哲学。与前几章相比,本章的立场更为鲜明:它将深入阐述一种特定的哲学,而不是比较多种不同的方法。
在本章中,我们将把所有这些想法汇集起来,并特别以 [第 12 章](/ch12#ch_stream) 的 *流处理**stream processing*)与 *事件驱动架构**event-driven architecture*为基础,形成一套能够实现上述目标的应用开发哲学。与前几章相比,本章的立场更为鲜明:它将深入阐述一种特定的哲学,而不是比较多种不同的方法。
## 数据集成 {#sec_future_integration}
@@ -37,7 +37,7 @@ breadcrumbs: false
例如,为了支持任意关键词查询,通常需要把 OLTP 数据库与全文检索索引集成起来。尽管某些数据库(例如 PostgreSQL内置的全文索引功能足以满足简单应用的需要 [^1],但更复杂的检索功能仍然需要专业的信息检索工具。反过来,搜索索引通常又不适合作为持久的权威记录系统,因此许多应用都需要组合使用这两种工具,才能满足全部需求。
我们在 [“保持系统同步”](/ch12#sec_stream_sync) 中谈到过数据系统的集成问题。数据表示越多,集成就越困难。除了数据库和搜索索引,你可能还需要在分析系统(数据仓库,或者批处理与流处理系统)中保存数据副本;维护由原始数据衍生而来的缓存或反规范化对象;让数据经过机器学习、分类、排名或推荐系统;或者根据数据变更发送通知。
我们在 [“保持系统同步”](/ch12#sec_stream_sync) 中谈到过数据系统的集成问题。数据表示越多,集成就越困难。除了数据库和搜索索引,你可能还需要在分析系统中保存数据副本,例如数据仓库、*批处理**batch processing*)系统或流处理系统;维护由原始数据衍生而来的缓存或反规范化对象;让数据经过机器学习、分类、排名或推荐系统;或者根据数据变更发送通知。
#### 理解数据流 {#id443}
@@ -49,7 +49,7 @@ breadcrumbs: false
如果能够把所有用户输入都汇入一个系统,由它决定全部写入的顺序,那么只需按相同顺序处理这些写入,就能更容易地衍生出数据的其他表示。这正是我们在 [“共识的实践”](/ch10#sec_consistency_total_order) 中见过的状态机复制方法的一种应用。使用变更数据捕获还是事件溯源日志并不是最重要的,真正重要的是先确定一个全序。
根据事件日志更新衍生数据系统,往往可以做到确定性与幂等性(参见 [“幂等性”](/ch12#sec_stream_idempotence)),因而很容易从故障中恢复。
根据事件日志更新衍生数据系统,往往可以做到 *确定性**determinism*)与 *幂等性**idempotence*参见 [“幂等性”](/ch12#sec_stream_idempotence)),因而很容易从故障中恢复。
#### 衍生数据与分布式事务 {#sec_future_derived_vs_transactions}
@@ -77,7 +77,7 @@ breadcrumbs: false
- 一些应用会在客户端维护状态:用户输入后立即更新,不等待服务器确认,甚至在离线时仍能继续工作。在这样的应用中,客户端与服务器很可能以不同的顺序看到事件。
从形式上说,决定事件全序的问题称为 *全序广播*,它等价于共识(参见 [“共识的多面性”](/ch10#sec_consistency_faces))。大多数共识算法针对的是单个节点吞吐量足以处理整个事件流的情形,并没有提供让多个节点共同分担事件排序工作的机制。
从形式上说,决定事件全序的问题称为 *全序广播**total order broadcast*,它等价于共识(参见 [“共识的多面性”](/ch10#sec_consistency_faces))。大多数共识算法针对的是单个节点吞吐量足以处理整个事件流的情形,并没有提供让多个节点共同分担事件排序工作的机制。
#### 排序事件以捕获因果关系 {#sec_future_capture_causality}
@@ -99,13 +99,13 @@ breadcrumbs: false
### 批处理与流处理 {#sec_future_batch_streaming}
数据集成的目标,是确保数据以正确的形式出现在所有正确的位置。为此,需要消费输入,进行转换、连接、过滤、聚合、模型训练与评估,最终再写入适当的输出。批处理器和流处理器正是实现这一目标的工具。批处理与流处理的输出是衍生数据集,例如搜索索引、物化视图、向用户展示的推荐结果、聚合指标,等等。
*数据集成**data integration*的目标,是确保数据以正确的形式出现在所有正确的位置。为此,需要消费输入,进行转换、连接、过滤、聚合、模型训练与评估,最终再写入适当的输出。批处理器和流处理器正是实现这一目标的工具。批处理与流处理的输出是衍生数据集,例如搜索索引、物化视图、向用户展示的推荐结果、聚合指标,等等。
正如我们在 [第 11 章](/ch11#ch_batch) 和 [第 12 章](/ch12#ch_stream) 中看到的,批处理与流处理有许多共同原则;两者最根本的区别在于,流处理器处理的是无界数据集,而批处理的输入大小已知且有限。
#### 维护衍生状态 {#id446}
批处理带有很强的函数式风格(即使代码并不是用函数式编程语言编写的):它鼓励使用确定性的纯函数,其输出只取决于输入,除了明确的输出之外没有其他副作用;输入被视为不可变,输出则只能追加。流处理与之类似,不过它扩展了算子,使其能够维护受管理且容错的状态。
批处理带有很强的函数式风格(即使代码并不是用函数式编程语言编写的):它鼓励使用确定性的 *纯函数**pure function*),其输出只取决于输入,除了明确的输出之外没有其他 *副作用**side effect*;输入被视为不可变,输出则只能追加。流处理与之类似,不过它扩展了算子,使其能够维护受管理且容错的状态。
输入和输出定义明确的确定性函数,不仅有利于容错,也能简化对组织内部数据流的推理 [^5]。无论衍生数据是搜索索引、统计模型还是缓存,都可以把它看成数据管道:从一项事物衍生出另一项事物,让一个系统的状态变更经过函数式应用代码,再把相应效果施加到衍生系统上。这样思考大有裨益。
@@ -138,7 +138,7 @@ breadcrumbs: false
- 能够让历史事件通过处理近期事件流的同一个处理引擎重放。例如,基于日志的消息代理可以重放消息,一些流处理器也能从分布式文件系统或对象存储中读取输入。
- 为流处理器提供恰好一次语义——也就是说,即使实际发生了故障,也要确保输出与从未发生故障时相同。与批处理一样,这要求丢弃所有失败任务的部分输出。
- 为流处理器提供 *恰好一次语义**exactly-once semantics*——也就是说,即使实际发生了故障,也要确保输出与从未发生故障时相同。与批处理一样,这要求丢弃所有失败任务的部分输出。
- 提供按事件时间而非处理时间划分窗口的工具因为重新处理历史事件时处理时间没有任何意义。例如Apache Beam 提供了表达这类计算的 API之后可以用 Apache Flink 或 Google Cloud Dataflow 来运行。
@@ -191,7 +191,7 @@ Unix 与关系数据库采用截然不同的哲学来处理信息管理问题。
联合数据库:统一读取
: 可以为各种底层存储引擎和处理方法提供统一的查询接口——这种方法称为 *联合数据库* *多模存储* [^17]、[^18]。例如PostgreSQL 的 *外部数据封装器* 功能符合这种模式Trino、Hoptimator 和 Xorq 等联合查询引擎也是如此。需要专用数据模型或查询接口的应用仍可直接访问底层存储引擎;而希望组合不同位置数据的用户,则可以通过联合接口轻松完成。
: 可以为各种底层存储引擎和处理方法提供统一的查询接口——这种方法称为 *联合数据库**federated database**多模存储**polystore*[^17]、[^18]。例如PostgreSQL 的 *外部数据封装器**foreign data wrapper*功能符合这种模式Trino、Hoptimator 和 Xorq 等联合查询引擎也是如此。需要专用数据模型或查询接口的应用仍可直接访问底层存储引擎;而希望组合不同位置数据的用户,则可以通过联合接口轻松完成。
联合查询接口延续了关系模型的传统:提供带有高层查询语言和优雅语义的单一集成系统,但其实现非常复杂。
@@ -209,7 +209,7 @@ Unix 与关系数据库采用截然不同的哲学来处理信息管理问题。
例如,一些流处理器内部使用分布式事务来实现恰好一次语义,而且效果可以很好。然而,如果一项事务需要涉及由不同团队编写的系统(例如从流处理器把数据写入分布式键值存储或搜索索引),缺少标准化事务协议就会使集成难上加难。带有幂等消费者的有序事件日志是一种简单得多的抽象,因此更有可能跨异构系统实现 [^5]。
基于日志的集成有一项巨大优势:各个组件之间 *松散耦合*。这种优势体现在两个方面:
基于日志的集成有一项巨大优势:各个组件之间 *松散耦合**loose coupling*。这种优势体现在两个方面:
1. 在系统层面,异步事件流使整个系统更能抵御个别组件中断或性能下降。如果某个消费者速度很慢或发生故障,事件日志可以缓冲消息,让生产者和其他消费者不受影响地继续运行。故障消费者修复后可以追赶进度,因此不会漏掉任何数据,而故障也被限制在局部。相比之下,分布式事务中的同步交互往往会把局部故障升级为大规模失效。
@@ -264,7 +264,7 @@ Unix 与关系数据库采用截然不同的哲学来处理信息管理问题。
在这种典型的 Web 应用模型中,数据库充当一种可以通过网络同步访问的可变共享变量。应用可以读取和更新这个变量,数据库则负责将它持久保存,并提供一定的并发控制与容错能力。
然而,在大多数编程语言中,你无法订阅可变变量的变化——只能定期读取它。与电子表格不同,变量的值发生变化时,读取者不会收到通知。(你可以在自己的代码中实现这种通知,这称为 *观察者模式*,但大多数语言都没有把这种模式作为内置功能。)
然而,在大多数编程语言中,你无法订阅可变变量的变化——只能定期读取它。与电子表格不同,变量的值发生变化时,读取者不会收到通知。(你可以在自己的代码中实现这种通知,这称为 *观察者模式**observer pattern*但大多数语言都没有把这种模式作为内置功能。)
数据库继承了这种对待可变数据的被动方式:如果想知道数据库内容是否发生变化,你通常只能轮询(即定期重复查询)。订阅变更才刚刚开始成为数据库的一项功能。
@@ -306,11 +306,11 @@ Unix 与关系数据库采用截然不同的哲学来处理信息管理问题。
### 观察衍生状态 {#sec_future_observing}
从抽象层面看,上一节讨论的数据流系统提供了一套创建并持续更新衍生数据集(例如搜索索引、物化视图和预测模型)的过程。我们把这个过程称为 *写路径*:每当有信息写入系统,它可能经过多轮批处理和流处理,最终所有衍生数据集都会更新,纳入这次写入的数据。{{< xref fig="13-1" page="/ch13" anchor="fig_future_write_read_paths" >}}图 13-1{{< /xref >}} 展示了更新搜索索引的例子。
从抽象层面看,上一节讨论的数据流系统提供了一套创建并持续更新衍生数据集(例如搜索索引、物化视图和预测模型)的过程。我们把这个过程称为 *写路径**write path*:每当有信息写入系统,它可能经过多轮批处理和流处理,最终所有衍生数据集都会更新,纳入这次写入的数据。{{< xref fig="13-1" page="/ch13" anchor="fig_future_write_read_paths" >}}图 13-1{{< /xref >}} 展示了更新搜索索引的例子。
{{< fig num="13-1" id="fig_future_write_read_paths" src="/fig/ddia_1301.png" caption="在搜索索引中,写入(文档更新)与读取(查询)相遇。" class="ddia-figure ddia-figure--wide" width="2658" height="1199" />}}
但你最初为什么要创建衍生数据集?很可能是为了日后查询。这就是 *读路径*:处理用户请求时,从衍生数据集中读取数据,也许再对结果做些处理,最后构造返回给用户的响应。
但你最初为什么要创建衍生数据集?很可能是为了日后查询。这就是 *读路径**read path*:处理用户请求时,从衍生数据集中读取数据,也许再对结果做些处理,最后构造返回给用户的响应。
写路径和读路径合在一起,涵盖了数据的完整旅程:从收集数据的地方,直至数据被消费的地方(很可能由另一个人消费)。写路径是预先计算的那一段——也就是数据一到达便立即完成,不管有没有人要求查看。读路径则只在有人请求时才发生。如果你熟悉函数式编程语言,或许会发现写路径类似于立即求值,读路径则类似于惰性求值。
@@ -324,7 +324,7 @@ Unix 与关系数据库采用截然不同的哲学来处理信息管理问题。
反过来,也可以设想预先计算所有可能查询的搜索结果。这样一来,读路径的工作就少了:无需进行布尔逻辑计算,只要找到相应查询的结果并返回即可。然而,写路径会昂贵得多:可能提出的搜索查询集合是无限的(或者至少随语料库中的词项数量呈指数增长),因此不可能预先计算所有搜索结果。
另一种选择是,只为一组固定的最常见查询预先计算搜索结果,使这些查询无需访问索引便能迅速得到响应;不常见的查询仍由索引处理。这通常称为常见查询的 *缓存*,不过也可以称为物化视图:一旦出现应当纳入某项常见查询结果的新文档,它就必须随之更新。
另一种选择是,只为一组固定的最常见查询预先计算搜索结果,使这些查询无需访问索引便能迅速得到响应;不常见的查询仍由索引处理。这通常称为常见查询的 *缓存**cache*,不过也可以称为物化视图:一旦出现应当纳入某项常见查询结果的新文档,它就必须随之更新。
这个例子说明,索引并不是写路径与读路径之间唯一可能的边界。既可以缓存常见搜索结果;文档数量较少时,也可以不用索引,进行类似 `grep` 的扫描。从这个角度看,缓存、索引与物化视图的作用很简单:它们移动了读路径与写路径之间的边界。我们通过预先计算结果,让写路径多做一些工作,从而节省读路径的开销。
@@ -475,7 +475,7 @@ COMMIT;
#### 端到端原则 {#sec_future_e2e_argument}
抑制重复事务的情形,只是一个更普遍原则的例子。这个原则称为 *端到端原则*,由 Saltzer、Reed 和 Clark 于 1984 年提出 [^44]
抑制重复事务的情形,只是一个更普遍原则的例子。这个原则称为 *端到端原则**end-to-end argument*,由 Saltzer、Reed 和 Clark 于 1984 年提出 [^44]
> 只有借助位于通信系统两端的应用所掌握的知识与提供的协助,所讨论的功能才能得到完整、正确的实现。因此,不可能把这一功能作为通信系统本身的一项功能来提供。(有时,通信系统提供的不完整版本可以用来提升性能。)
@@ -567,21 +567,21 @@ COMMIT;
### 及时性与完整性 {#sec_future_integrity}
许多事务系统都有一项便利的性质:一个事务提交后,其写入立刻对其他事务可见。这项性质的形式化名称是 *严格可串行化*(参见 [“线性一致性与可串行化”](/ch10#sidebar_consistency_serializability))。
许多事务系统都有一项便利的性质:一个事务提交后,其写入立刻对其他事务可见。这项性质的形式化名称是 *严格可串行化**strict serializability*参见 [“线性一致性与可串行化”](/ch10#sidebar_consistency_serializability))。
把一项操作分拆为流处理器的多个阶段后,情况却并非如此:日志消费者在设计上就是异步的,所以发送者不会等待消费者处理完自己的消息。不过,客户端仍可以等待某条消息出现在输出流上。例如,{{< xref fig="13-2" page="/ch13" anchor="fig_future_multi_shard" >}}图 13-2{{< /xref >}} 中的用户可以等待出账事件或付款被拒事件,这取决于源账户中是否有足够资金。
在这个例子中,检查源账户余额是否正确,并不取决于发出请求的用户是否等待结果。等待只是为了同步告知用户付款是否成功,这项通知与处理请求产生的效果彼此解耦。
更一般地说,*一致性* 这个术语混合了两种不同的需求,而它们值得分开考虑:
更一般地说,*一致性**consistency*这个术语混合了两种不同的需求,而它们值得分开考虑:
及时性
及时性timeliness
: 及时性是指确保用户观察到系统的最新状态。前面看到,如果用户从陈旧的数据副本中读取,可能观察到不一致的系统状态(参见 [“复制延迟的问题”](/ch6#sec_replication_lag))。不过,这种不一致只是暂时的,只需等待并重试,最终便会消失。
CAP 定理中的一致性指线性一致性,这是实现及时性的一种强保证。*写后读一致性* 等较弱的及时性属性同样有用。
CAP 定理中的一致性指线性一致性,这是实现及时性的一种强保证。*写后读一致性**read-after-write consistency*等较弱的及时性属性同样有用。
完整性
完整性integrity
: 完整性是指没有损坏:既不丢失数据,也没有相互矛盾或虚假的数据。尤其是,如果某个衍生数据集作为底层数据之上的视图来维护,衍生过程必须正确。例如,数据库索引必须准确反映数据库内容——漏掉某些记录的索引没什么用。
@@ -599,7 +599,7 @@ ACID 事务通常同时提供及时性保证(例如线性一致性)和完整
另一方面,本章讨论的基于事件的数据流系统有一项有趣性质:它们把及时性与完整性解耦了。异步处理事件流时,除非明确构建消费者,让它等到消息到达后才返回,否则就没有及时性保证。例如,用户可以请求一笔付款,随后在流处理器执行该请求之前读取自己的账户状态;此时,用户看不到刚刚请求的付款。
然而,完整性事实上是流式系统的核心。*恰好一次* 或 *等效一次* 语义就是维护完整性的一种机制。事件丢失或生效两次,都可能破坏数据系统的完整性。因此,面对故障时,容错消息传递与重复抑制(例如幂等操作)是维护数据系统完整性的关键。
然而,完整性事实上是流式系统的核心。*恰好一次**exactly-once*)或 *等效一次**effectively-once*语义就是维护完整性的一种机制。事件丢失或生效两次,都可能破坏数据系统的完整性。因此,面对故障时,容错消息传递与重复抑制(例如幂等操作)是维护数据系统完整性的关键。
正如上一节所见,可靠的流处理系统无需分布式事务与原子提交协议也能保持完整性。这意味着它们有望实现同等程度的正确性,同时获得好得多的性能与运维稳健性。我们通过组合以下机制实现了这种完整性:
@@ -625,7 +625,7 @@ ACID 事务通常同时提供及时性保证(例如线性一致性)和完整
- 在跨组织集成数据的系统中,不一致不可避免,因此必须有修正机制来处理它们。正如 [“批处理用例”](/ch11#sec_batch_output) 所指出的,银行之间的付款结算就是一个例子。
因此,在许多业务场景中,暂时违反约束、稍后再通过道歉修正,是可以接受的。这种用于纠正错误的变更称为 *补偿性事务* [^48]、[^49]。道歉的代价各不相同金钱或声誉上的代价却往往很低已经发出的电子邮件无法撤回但可以再发一封邮件更正信用卡不慎扣款两次可以退回其中一笔代价只是手续费或许再加上一项顾客投诉。ATM 一旦吐出现金,确实无法直接收回;但原则上,如果账户已经透支而顾客拒绝还款,可以派催收人员追回欠款。
因此,在许多业务场景中,暂时违反约束、稍后再通过道歉修正,是可以接受的。这种用于纠正错误的变更称为 *补偿性事务**compensating transaction*[^48]、[^49]。道歉的代价各不相同金钱或声誉上的代价却往往很低已经发出的电子邮件无法撤回但可以再发一封邮件更正信用卡不慎扣款两次可以退回其中一笔代价只是手续费或许再加上一项顾客投诉。ATM 一旦吐出现金,确实无法直接收回;但原则上,如果账户已经透支而顾客拒绝还款,可以派催收人员追回欠款。
道歉的代价能否接受,是一项业务决策。如果能够接受,那么“写入数据前先检查全部约束”的传统模型就限制过多。完全可以先乐观地执行写入,再事后检查约束。对于那些一旦出错便很难挽回的事情,仍可以确保在执行前完成验证;但这并不意味着,连数据写入之前也必须先做验证。
@@ -639,7 +639,7 @@ ACID 事务通常同时提供及时性保证(例如线性一致性)和完整
2. 尽管严格的唯一性约束需要及时性与协调,许多应用其实可以接受宽松约束:只要完整性始终得到维护,约束可以暂时遭到违反,稍后再修复。
把这两点结合起来就意味着:数据流系统无需协调,便可为许多应用提供数据管理服务,同时仍给出强有力的完整性保证。这种 *避免协调* 的数据系统极具吸引力:与需要同步协调的系统相比,它们能获得更好的性能与容错能力 [^45]。
把这两点结合起来就意味着:数据流系统无需协调,便可为许多应用提供数据管理服务,同时仍给出强有力的完整性保证。这种 *避免协调**coordination-avoiding*的数据系统极具吸引力:与需要同步协调的系统相比,它们能获得更好的性能与容错能力 [^45]。
例如,这类系统可以采用多主配置,分布在多个数据中心,并在区域之间异步复制。任何一个数据中心都能独立于其他数据中心继续运行,因为不需要跨区域同步协调。这样的系统只提供较弱的及时性保证——不引入协调就不可能实现线性一致性——却仍能提供强有力的完整性保证。
@@ -649,7 +649,7 @@ ACID 事务通常同时提供及时性保证(例如线性一致性)和完整
### 信任但验证 {#sec_future_verification}
前面关于正确性、完整性与容错的所有讨论,都建立在一组假设之上:某些事情可能出错,另一些事情不会。我们把这些假设称为 *系统模型*(参见 [“系统模型与现实”](/ch9#sec_distributed_system_model))。例如,我们应当假设进程可能崩溃、机器可能突然断电、网络可能任意延迟或丢弃消息;但也可能假设,写入磁盘的数据经过 `fsync` 后不会丢失、内存中的数据不会损坏、CPU 的乘法指令总能返回正确结果。
前面关于正确性、完整性与容错的所有讨论,都建立在一组假设之上:某些事情可能出错,另一些事情不会。我们把这些假设称为 *系统模型**system model*参见 [“系统模型与现实”](/ch9#sec_distributed_system_model))。例如,我们应当假设进程可能崩溃、机器可能突然断电、网络可能任意延迟或丢弃消息;但也可能假设,写入磁盘的数据经过 `fsync` 后不会丢失、内存中的数据不会损坏、CPU 的乘法指令总能返回正确结果。
这些假设相当合理,因为绝大多数时候它们都成立;如果必须时刻担心计算机会算错,我们将寸步难行。传统系统模型以二元方式看待故障:假设有些事情可能发生,另一些事情绝不可能发生。现实却更像是概率问题:有些事情更常见,有些事情较少见。真正的问题是,违反假设的情况是否频繁到我们会在实践中遇见。
@@ -667,7 +667,7 @@ ACID 意义上的一致性,建立在这样一种想法之上:数据库从一
#### 不要盲信承诺 {#id364}
硬件和软件都不总能达到理想状态,因此数据损坏迟早似乎不可避免。至少,我们应该有办法发现数据已经损坏,从而修复它,并努力追查错误来源。检查数据完整性的过程称为 *审计*
硬件和软件都不总能达到理想状态,因此数据损坏迟早似乎不可避免。至少,我们应该有办法发现数据已经损坏,从而修复它,并努力追查错误来源。检查数据完整性的过程称为 *审计**auditing*
正如 [“不可变事件的优点”](/ch12#sec_stream_immutability_pros) 所述,审计并不只适用于财务应用。不过,可审计性在金融领域格外重要,恰恰因为人人都知道错误难免发生,也都认可能够发现并修复问题的必要性。
@@ -675,7 +675,7 @@ ACID 意义上的一致性,建立在这样一种想法之上:数据库从一
如果想确认数据仍然存在,就必须真正读取并检查。绝大多数时候数据依然完好;但万一不是,你肯定希望越早发现越好。同理,不时尝试从备份恢复也很重要——否则,你可能直到数据已经丢失、为时已晚,才发现备份根本无法使用。不要盲信一切都在正常工作。
HDFS 与 S3 仍然必须假设磁盘在绝大多数时候能够正确工作——这个假设很合理,却不同于假设磁盘 *始终* 正确工作。然而,目前采用这种“信任,但要验证”方式持续自我审计的系统并不多。许多系统假定正确性保证是绝对的,完全没有为罕见的数据损坏预作安排。未来,我们或许会看到更多 *自我验证**自我审计* 系统:它们不断检查自身完整性,而不是依赖盲目信任 [^54]。
HDFS 与 S3 仍然必须假设磁盘在绝大多数时候能够正确工作——这个假设很合理,却不同于假设磁盘 *始终* 正确工作。然而,目前采用这种“信任,但要验证”方式持续自我审计的系统并不多。许多系统假定正确性保证是绝对的,完全没有为罕见的数据损坏预作安排。未来,我们或许会看到更多 *自我验证**self-validating*)或 *自我审计**self-auditing*系统:它们不断检查自身完整性,而不是依赖盲目信任 [^54]。
#### 为可审计性而设计 {#id365}
@@ -683,7 +683,7 @@ HDFS 与 S3 仍然必须假设磁盘在绝大多数时候能够正确工作—
相比之下,基于事件的系统可以提供更好的可审计性。在事件溯源方法中,系统的用户输入被表示为一条不可变事件,由此产生的所有状态更新都衍生自这条事件。衍生过程可以做到确定性与可重复性,因此,用同一版本的衍生代码处理同一份事件日志,便会得到相同的状态更新。
明确表示数据流,能让 *数据溯源* 清晰得多,从而使完整性检查更切实可行。对于事件日志,可以用哈希检查事件存储是否遭到损坏;对于任何衍生状态,可以重新运行当初从事件日志衍生它的批处理器与流处理器,检查是否得到同样结果,甚至还可以并行运行一条冗余的衍生流程。
明确表示数据流,能让 *数据溯源**data provenance*清晰得多,从而使完整性检查更切实可行。对于事件日志,可以用哈希检查事件存储是否遭到损坏;对于任何衍生状态,可以重新运行当初从事件日志衍生它的批处理器与流处理器,检查是否得到同样结果,甚至还可以并行运行一条冗余的衍生流程。
具有确定性且定义明确的数据流,也让系统执行过程更容易调试和追踪,从而查明系统 *为什么* 做了某件事 [^4]、[^55]。如果发生意外,能够重现导致意外事件的确切情境将极有价值——这是一种时间旅行式调试能力。

View File

@@ -27,11 +27,11 @@ breadcrumbs: false
## 预测分析 {#id369}
例如,预测分析正是人们热衷于大数据和 AI 的主要原因之一。用数据分析预测天气或疾病传播是一回事 [^8];预测一名已定罪者是否可能再犯、贷款申请人是否可能违约,或保险客户是否可能提出高额索赔,则是另一回事 [^9]。后一类预测会直接影响个人的生活。
例如,*预测分析**predictive analytics*正是人们热衷于大数据和 AI 的主要原因之一。用数据分析预测天气或疾病传播是一回事 [^8];预测一名已定罪者是否可能再犯、贷款申请人是否可能违约,或保险客户是否可能提出高额索赔,则是另一回事 [^9]。后一类预测会直接影响个人的生活。
支付网络当然希望阻止欺诈交易,银行希望避免不良贷款,航空公司希望避免劫机,企业也希望避免雇到能力不足或不可信赖的人。从它们的角度看,错失商机的代价不大,不良贷款或问题员工造成的损失却高得多,因此组织自然希望谨慎行事。拿不准时,拒绝总比答应稳妥。
然而,随着算法决策越来越普遍,被某种算法标为高风险的人——无论判断正确与否——可能接连遭到这样的拒绝。一个人若被系统性地排除在就业、航空出行、保险、房屋租赁、金融服务以及社会生活的其他关键领域之外,其自由会受到极大限制,以至于这种处境被称为“算法监狱” [^10]。在尊重人权的国家,刑事司法制度坚持无罪推定;自动化系统却可能在没有任何罪证、几乎无从申诉的情况下,系统性地、任意地剥夺一个人参与社会的机会。
然而,随着算法决策越来越普遍,被某种算法标为高风险的人——无论判断正确与否——可能接连遭到这样的拒绝。一个人若被系统性地排除在就业、航空出行、保险、房屋租赁、金融服务以及社会生活的其他关键领域之外,其自由会受到极大限制,以至于这种处境被称为“*算法监狱**algorithmic prison*”[^10]。在尊重人权的国家,刑事司法制度坚持无罪推定;自动化系统却可能在没有任何罪证、几乎无从申诉的情况下,系统性地、任意地剥夺一个人参与社会的机会。
### 偏见与歧视 {#id370}
@@ -39,13 +39,13 @@ breadcrumbs: false
开发预测分析和 AI 系统时,我们不只是用软件规定何时同意、何时拒绝,把人的决策自动化;甚至连规则本身也交给系统从数据中推断。然而,这些系统学到的模式并不透明:即使数据中确实存在某种相关性,我们也未必知道原因。如果算法的输入带有系统性偏见,系统很可能会学到这种偏见,并在输出中将其放大 [^12]。
许多国家的反歧视法禁止根据族裔、年龄、性别、性取向、残障或信仰等受保护特征区别对待他人。个人数据中的其他特征或许可以分析,但如果它们与受保护特征相关,又该怎么办?例如,在种族隔离的社区中,一个人的邮政编码,甚至 IP 地址,都可以有力地预测其种族。如此看来,相信算法能够以带有偏见的数据为输入,却产出公平公正的结果,实在荒谬 [^13]、[^14]。然而,数据驱动决策的支持者似乎经常默认这种信念;有人讽刺这种态度说,“机器学习就像为偏见洗钱” [^15]。
许多国家的 *反歧视法**anti-discrimination law*)禁止根据族裔、年龄、性别、性取向、残障或信仰等 *受保护特征**protected characteristic*区别对待他人。个人数据中的其他特征或许可以分析,但如果它们与受保护特征相关,又该怎么办?例如,在种族隔离的社区中,一个人的邮政编码,甚至 IP 地址,都可以有力地预测其种族。如此看来,相信算法能够以带有偏见的数据为输入,却产出公平公正的结果,实在荒谬 [^13]、[^14]。然而,数据驱动决策的支持者似乎经常默认这种信念;有人讽刺这种态度说,“机器学习就像为偏见洗钱” [^15]。
预测分析系统不过是在外推过去;如果过去充满歧视,它们就会固化并放大这种歧视 [^16]。要让未来比过去更好,需要道德想象力,而这只有人类才能提供 [^17]。数据与模型应该是我们的工具,而不是我们的主人。
### 责任与问责 {#id371}
自动化决策引出了责任与问责问题 [^17]。如果人犯了错,可以追究其责任,受决定影响的人也可以申诉。算法同样会犯错,但出了问题,谁来负责 [^18]?自动驾驶汽车造成事故,责任由谁承担?自动信用评分算法若系统性地歧视某一种族或宗教的人,他们有没有救济途径?如果机器学习系统作出的决定受到司法审查,你能否向法官解释算法是怎样得出这一决定的?任何人都不应把责任推给算法,借此逃避问责。
自动化决策引出了 *责任**responsibility*)与 *问责**accountability*问题 [^17]。如果人犯了错,可以追究其责任,受决定影响的人也可以申诉。算法同样会犯错,但出了问题,谁来负责 [^18]?自动驾驶汽车造成事故,责任由谁承担?自动信用评分算法若系统性地歧视某一种族或宗教的人,他们有没有救济途径?如果机器学习系统作出的决定受到司法审查,你能否向法官解释算法是怎样得出这一决定的?任何人都不应把责任推给算法,借此逃避问责。
信用评级机构是收集个人数据并据此作出决定的一个早期例子。糟糕的信用评分会给生活带来困难,但至少信用分通常基于当事人实际借贷历史中的相关事实,记录有误时也能更正——尽管评级机构一般不会让更正变得容易。相比之下,基于机器学习的评分算法通常使用范围广得多的输入数据,而且更加不透明,因而很难看清某项决定是如何得出的,也很难判断某个人是否受到了不公平或歧视性对待 [^19]。
@@ -61,11 +61,11 @@ breadcrumbs: false
即便是推荐系统这类对人们生活的影响没有那么直接、深远的预测应用,也有一些难题必须正视。当服务越来越善于预测用户想看什么时,最终可能只向人们展示他们已经认同的观点,形成滋生刻板印象、错误信息与社会极化的回音室。我们已经看到了社交媒体回音室对竞选活动的影响。
当预测分析开始左右人们的生活时,自我强化的反馈循环会造成尤其恶劣的问题。例如,假设雇主用信用分评估求职者。你原本工作能力很强,信用记录也很好,却因一场自己无力控制的变故突然陷入财务困境。几次账单逾期之后,信用分随之下降,找到工作的机会也越来越少。失业又把你推向贫困,进一步拉低评分,让工作变得更加难找 [^17]。这是有毒假设造成的恶性循环,却披着数学严谨性与数据客观性的外衣。
当预测分析开始左右人们的生活时,自我强化的 *反馈循环**feedback loop*会造成尤其恶劣的问题。例如,假设雇主用信用分评估求职者。你原本工作能力很强,信用记录也很好,却因一场自己无力控制的变故突然陷入财务困境。几次账单逾期之后,信用分随之下降,找到工作的机会也越来越少。失业又把你推向贫困,进一步拉低评分,让工作变得更加难找 [^17]。这是有毒假设造成的恶性循环,却披着数学严谨性与数据客观性的外衣。
另一个反馈循环的例子是:经济学家发现,德国的加油站引入算法定价后,市场竞争反而减弱,消费者支付的价格随之上涨,因为算法学会了合谋 [^21]。
我们无法总是预见这种反馈循环何时出现。不过,只要思考整个系统——不仅包括计算机化的部分,也包括与之互动的人——许多后果仍然可以预判。这种方法称为 *系统思维* [^22]。我们可以尝试理解,数据分析系统会如何响应不同的行为、结构或特征。它会巩固并放大人与人之间既有的差异,例如让富者愈富、贫者愈贫,还是会努力消除不公?而且,即使怀有最好的初衷,也必须提防意料之外的后果。
我们无法总是预见这种反馈循环何时出现。不过,只要思考整个系统——不仅包括计算机化的部分,也包括与之互动的人——许多后果仍然可以预判。这种方法称为 *系统思维**systems thinking*[^22]。我们可以尝试理解,数据分析系统会如何响应不同的行为、结构或特征。它会巩固并放大人与人之间既有的差异,例如让富者愈富、贫者愈贫,还是会努力消除不公?而且,即使怀有最好的初衷,也必须提防意料之外的后果。
## 隐私与追踪 {#id373}
@@ -77,7 +77,7 @@ breadcrumbs: false
然而,根据公司的商业模式,追踪往往不会止步于此。如果一项服务靠广告维持,广告主才是真正的客户,用户的利益便退居其次。追踪的数据越来越细,分析触及的范围越来越广,数据也会长期保留,以便为营销目的建立每个人的详细画像。
此时,公司与被收集数据的用户之间,便呈现出一种截然不同的关系。用户得到免费服务,又被诱导尽量多地参与其中;追踪用户主要不是为了服务这个人,而是为了满足出资维持服务的广告主的需求。用一个含义更阴暗的词来描述这种关系再合适不过:*监视*。
此时,公司与被收集数据的用户之间,便呈现出一种截然不同的关系。用户得到免费服务,又被诱导尽量多地参与其中;追踪用户主要不是为了服务这个人,而是为了满足出资维持服务的广告主的需求。用一个含义更阴暗的词来描述这种关系再合适不过:*监视**surveillance*
### 监视 {#id374}
@@ -95,7 +95,7 @@ breadcrumbs: false
### 同意与选择自由 {#id375}
我们或许会说,用户自愿选择使用追踪其活动的服务,也接受了服务条款与隐私政策,因此已经同意收集数据。我们甚至可以声称,用户用自己提供的数据换取了有价值的服务,而追踪是提供服务所必需的。毫无疑问,社交网络、搜索引擎以及其他各种免费在线服务的确对用户很有价值——但这种说法存在问题。
我们或许会说,用户自愿选择使用追踪其活动的服务,也接受了服务条款与隐私政策,因此已经 *同意**consent*收集数据。我们甚至可以声称,用户用自己提供的数据换取了有价值的服务,而追踪是提供服务所必需的。毫无疑问,社交网络、搜索引擎以及其他各种免费在线服务的确对用户很有价值——但这种说法存在问题。
首先应该问清楚,追踪究竟在哪种意义上不可或缺。有些追踪的确直接用于改进面向用户的功能:例如,追踪搜索结果的点击率,可以提升搜索引擎的结果排名与相关性;追踪顾客经常一起购买的商品,可以帮助网店推荐相关产品。然而,如果追踪用户交互是为了推荐内容,或是为广告建立用户画像,就很难说这是否真正符合用户的利益——还是因为广告在为服务买单,追踪才变得“必要”?
@@ -103,7 +103,7 @@ breadcrumbs: false
而且,从用户身上抽取数据是一个单向过程,既不是真正互惠的关系,也不是公平的价值交换。双方没有对话,用户也不能就提供多少数据、换取什么服务进行协商:服务与用户之间的关系高度不对称,完全是一边倒的。条件由服务制定,而不是由用户决定 [^30]、[^31]。
欧盟《通用数据保护条例》GDPR要求同意必须是“自愿作出、具体、知情且明确无误的”用户还必须能够“拒绝或撤回同意而不受不利影响”否则就不能算“自愿作出”。任何征求同意的请求都必须“采用易于理解、便于获取的形式并使用清晰明白的语言”。此外“沉默、预先勾选的选框或无行动均不构成同意” [^32]。同意并不是合法处理个人数据的唯一依据;例如,*合法利益* 也允许出于防范欺诈等目的使用某些数据 [^33]。
欧盟《通用数据保护条例》GDPR要求同意必须是“自愿作出、具体、知情且明确无误的”用户还必须能够“拒绝或撤回同意而不受不利影响”否则就不能算“自愿作出”。任何征求同意的请求都必须“采用易于理解、便于获取的形式并使用清晰明白的语言”。此外“沉默、预先勾选的选框或无行动均不构成同意” [^32]。同意并不是合法处理个人数据的唯一依据;例如,*合法利益**legitimate interest*也允许出于防范欺诈等目的使用某些数据 [^33]。
你也许会说,不愿接受监视的用户只要选择不使用这项服务即可。但这种选择同样算不上自由:如果一项服务普及到“被大多数人视为参与基本社会生活所必需” [^30],就不能合理地要求人们退出这项服务——使用它实际上已成为强制要求。例如,在多数西方社会中,随身携带智能手机、通过社交网络与人交往、使用 Google 查找信息,都已经成为常态。尤其是在一项服务具有网络效应时,选择 ** 使用它需要付出社会代价。
@@ -111,7 +111,7 @@ breadcrumbs: false
### 隐私与数据使用 {#id457}
有时人们声称“隐私已死”,理由是一些用户愿意把生活中的种种事情发布到社交媒体上,其中有日常琐事,也有极其私密的内容。然而,这种说法是错误的,源于对 *隐私* 一词的误解。
有时人们声称“隐私已死”,理由是一些用户愿意把生活中的种种事情发布到社交媒体上,其中有日常琐事,也有极其私密的内容。然而,这种说法是错误的,源于对 *隐私**privacy*一词的误解。
拥有隐私并不意味着把一切都藏起来,而是有权自由选择向谁透露什么、公开什么、保密什么。隐私权是一种决定权:在每一种情境中,它都让每个人自行决定,要站在从保密到透明这条光谱的什么位置 [^30]。这是个人自由与自主的重要组成部分。
@@ -163,7 +163,7 @@ breadcrumbs: false
数据保护法或许能够帮助维护个人权利。例如,欧盟 GDPR 规定,个人数据必须“为特定、明确且合法的目的而收集,不得以与这些目的不相容的方式作进一步处理”;而且,数据必须“相对于处理目的而言充分、相关,并仅限于必要范围” [^32]。
然而,*数据最小化* 原则与大数据的哲学针锋相对。大数据追求尽可能多地收集数据将其与其他数据集组合通过实验和探索获得新的洞见。探索意味着把数据用于未曾预料的目的这恰好与收集数据时必须声明的“特定、明确”目的相反。GDPR 虽然给在线广告行业带来了一些影响 [^45],执行力度却一直很弱 [^46],而且似乎没有促使整个科技行业的文化与实践发生多大改变。
然而,*数据最小化**data minimization*原则与大数据的哲学针锋相对。大数据追求尽可能多地收集数据将其与其他数据集组合通过实验和探索获得新的洞见。探索意味着把数据用于未曾预料的目的这恰好与收集数据时必须声明的“特定、明确”目的相反。GDPR 虽然给在线广告行业带来了一些影响 [^45],执行力度却一直很弱 [^46],而且似乎没有促使整个科技行业的文化与实践发生多大改变。
收集大量个人数据的公司反对监管,认为它会增加负担、妨碍创新。这种反对在一定程度上不无道理。例如,共享医疗数据显然会带来隐私风险,却也蕴藏着机会:如果数据分析能帮助我们改进诊断、找到更好的治疗方法,可以挽救多少生命 [^47]?监管过度也许会阻碍这类突破。要在潜在机会与风险之间找到平衡并不容易 [^41]。

View File

@@ -22,9 +22,9 @@ breadcrumbs: false
安全性等许多非功能性需求超出了本书的范围。不过,本章会讨论其中几项,并帮助你准确表述自己的系统需要达到什么要求:
* 如何定义和衡量系统的 *性能*(参见[“描述性能”](/ch2#sec_introduction_percentiles)
* 服务 *可靠* 意味着什么——也就是即使出了问题,仍能继续正确工作(参见[“可靠性与容错”](/ch2#sec_introduction_reliability)
* 随着系统负载增长,能否高效增加计算能力,使系统具备 *可伸缩性*(参见[“可伸缩性”](/ch2#sec_introduction_scalability));以及
* 如何定义和衡量系统的 *性能**performance*参见[“描述性能”](/ch2#sec_introduction_percentiles)
* 服务 *可靠**reliable*意味着什么——也就是即使出了问题,仍能继续正确工作(参见[“可靠性与容错”](/ch2#sec_introduction_reliability)
* 随着系统负载增长,能否高效增加计算能力,使系统具备 *可伸缩性**scalability*参见[“可伸缩性”](/ch2#sec_introduction_scalability));以及
* 如何让系统在长期使用中更易维护(参见[“可维护性”](/ch2#sec_introduction_maintainability))。
后续章节深入讨论数据密集型系统的实现细节时,还会用到本章引入的术语。不过,抽象定义读起来难免枯燥。为了让这些概念更加具体,我们先从一个社交网络服务的实现案例讲起,以此说明性能与可伸缩性在实践中意味着什么。
@@ -82,15 +82,15 @@ SELECT posts.*, users.* FROM posts
讨论软件性能时,通常会考虑两类主要指标:
响应时间
响应时间response time
: 从用户发出请求到收到所需响应所经过的时间。计量单位是秒(或毫秒、微秒)。
吞吐量
: 系统每秒处理的请求数或数据量。对于给定的硬件资源,系统能处理的吞吐量存在上限,也就是 *最大吞吐量*。计量单位通常写成“每秒多少个……”。
吞吐量throughput
: 系统每秒处理的请求数或数据量。对于给定的硬件资源,系统能处理的吞吐量存在上限,也就是 *最大吞吐量**maximum throughput*。计量单位通常写成“每秒多少个……”。
在社交网络案例中,“每秒帖子数”和“每秒时间线写入数”是吞吐量指标;“加载首页时间线所需的时间”和“帖子送达关注者所需的时间”则是响应时间指标。
吞吐量和响应时间之间往往存在联系,{{< xref fig="2-3" page="/ch2" anchor="fig_throughput" >}}图 2-3{{< /xref >}}勾勒了在线服务中二者的一种典型关系。请求吞吐量较低时,服务的响应时间也很短;随着负载增大,响应时间随之上升。这是 *排队* 造成的当请求到达负载很高的系统时CPU 很可能正在处理先前的请求,新来的请求只好等到前一个处理完毕。当吞吐量逐渐逼近硬件的处理极限时,排队延迟会急剧增加。
吞吐量和响应时间之间往往存在联系,{{< xref fig="2-3" page="/ch2" anchor="fig_throughput" >}}图 2-3{{< /xref >}}勾勒了在线服务中二者的一种典型关系。请求吞吐量较低时,服务的响应时间也很短;随着负载增大,响应时间随之上升。这是 *排队**queueing*造成的当请求到达负载很高的系统时CPU 很可能正在处理先前的请求,新来的请求只好等到前一个处理完毕。当吞吐量逐渐逼近硬件的处理极限时,排队延迟会急剧增加。
{{< fig num="2-3" id="fig_throughput" src="/fig/ddia_0203.png" caption="当服务的吞吐量接近其处理能力上限时,排队会使响应时间急剧增加。" class="ddia-figure ddia-figure--wide" width="2953" height="1018" />}}
@@ -101,7 +101,7 @@ SELECT posts.*, users.* FROM posts
>
> 当系统濒临过载、吞吐量已被推到极限附近时,有时会陷入恶性循环:系统效率越来越低,因而变得更加过载。例如,等待处理的请求排起长队,响应时间可能因此增长到客户端超时并重发请求。请求速率随之进一步上升,让问题愈演愈烈——这就是 *重试风暴retry storm*。即使负载随后下降,系统也可能一直停留在过载状态,直到重启或以其他方式重置。这种现象称为 *亚稳态故障metastable failure*,它可能导致生产系统严重中断 [^7] [^8]。
>
> 为了避免重试压垮服务,可以在客户端逐渐延长并随机扰动连续重试之间的等待时间(*指数退避* [^9] [^10]还可以暂时停止向最近曾返回错误或发生超时的服务发送请求(采用 *熔断器* [^11] [^12] 或 *令牌桶* 算法 [^13]。服务器也可以在察觉自己接近过载时主动拒绝请求(*负载卸除* [^14]),并在响应中要求客户端降低发送速率(*背压* [^1] [^15])。排队算法和负载均衡算法的选择同样会产生影响 [^16]。
> 为了避免重试压垮服务,可以在客户端逐渐延长并随机扰动连续重试之间的等待时间(*指数退避**exponential backoff* [^9] [^10]还可以采用 *熔断器**circuit breaker*[^11] [^12] 或 *令牌桶**token bucket*)算法 [^13]暂时停止向最近曾返回错误或发生超时的服务发送请求。服务器也可以在察觉自己接近过载时主动拒绝请求(*负载卸除**load shedding* [^14]),并在响应中要求客户端降低发送速率(*背压**backpressure* [^1] [^15])。排队算法和负载均衡算法的选择同样会产生影响 [^16]。
在各项性能指标中,用户通常最关心响应时间;吞吐量则决定了所需的计算资源(例如服务器数量),从而决定处理特定工作负载的成本。如果吞吐量可能增长到超出当前硬件的处理能力,就需要扩充容量。如果增加计算资源能够显著提高系统的最大吞吐量,我们就称这个系统具有 *可伸缩性scalability*
@@ -111,10 +111,10 @@ SELECT posts.*, users.* FROM posts
“延迟”和“响应时间”有时会被混为一谈,但本书将按下面的特定含义使用这几个术语(如{{< xref fig="2-4" page="/ch2" anchor="fig_response_time" >}}图 2-4{{< /xref >}}所示):
* *响应时间* 是客户端看到的时间,其中包括系统各处产生的全部延误。
* *服务时间* 是服务真正用于处理用户请求的时间。
* *排队延迟* 可能发生在流程中的多个位置。例如,请求到达后,也许必须等到 CPU 空闲才能开始处理;如果同一台机器上的其他任务正在通过出站网络接口发送大量数据,响应数据包也可能先在缓冲区中等待。
* *延迟* 泛指请求没有得到实际处理的时间,也就是请求处于 *潜伏latent* 状态的时间。具体来说,*网络延迟**网络时延* 是请求和响应在网络中传输所花的时间。
* *响应时间**response time*是客户端看到的时间,其中包括系统各处产生的全部延误。
* *服务时间**service time*是服务真正用于处理用户请求的时间。
* *排队延迟**queueing delay*可能发生在流程中的多个位置。例如,请求到达后,也许必须等到 CPU 空闲才能开始处理;如果同一台机器上的其他任务正在通过出站网络接口发送大量数据,响应数据包也可能先在缓冲区中等待。
* *延迟**latency*泛指请求没有得到实际处理的时间,也就是请求处于 *潜伏latent* 状态的时间。具体来说,*网络延迟**network latency*)或 *网络时延**network delay*是请求和响应在网络中传输所花的时间。
{{< fig num="2-4" id="fig_response_time" src="/fig/ddia_0204.png" caption="响应时间、服务时间、网络延迟和排队延迟。" class="ddia-figure ddia-figure--wide" width="2953" height="1018" />}}
@@ -126,13 +126,13 @@ SELECT posts.*, users.* FROM posts
### 平均值、中位数与分位数 {#id24}
由于每次请求的响应时间都不一样,我们不能只把它看成一个数字,而应将其视为一组可测量数值的 *分布distribution*。在{{< xref fig="2-5" page="/ch2" anchor="fig_lognormal" >}}图 2-5{{< /xref >}}中,每根灰色柱条代表一次服务请求,柱条的高度表示这次请求所花的时间。大多数请求都相当快,但偶尔会出现耗时长得多的 *异常值*。网络延迟的变化也称为 *抖动jitter*
由于每次请求的响应时间都不一样,我们不能只把它看成一个数字,而应将其视为一组可测量数值的 *分布distribution*。在{{< xref fig="2-5" page="/ch2" anchor="fig_lognormal" >}}图 2-5{{< /xref >}}中,每根灰色柱条代表一次服务请求,柱条的高度表示这次请求所花的时间。大多数请求都相当快,但偶尔会出现耗时长得多的 *异常值**outlier*。网络延迟的变化也称为 *抖动jitter*
{{< fig num="2-5" id="fig_lognormal" src="/fig/ddia_0205.png" caption="用 100 次服务请求的响应时间样本说明平均值和分位数。" class="ddia-figure ddia-figure--panorama" width="2953" height="902" />}}
服务通常会报告 *平均* 响应时间(严格来说是 *算术平均值*:把所有响应时间相加,再除以请求数。平均响应时间有助于估算吞吐量的上限 [^18]。不过,如果你想知道“典型”的响应时间,平均值就不是很好的指标,因为它没有告诉你究竟有多少用户实际经历了这样的等待。
服务通常会报告 *平均**mean*)响应时间,也就是 *算术平均值**arithmetic mean*:把所有响应时间相加,再除以请求数。平均响应时间有助于估算吞吐量的上限 [^18]。不过,如果你想知道“典型”的响应时间,平均值就不是很好的指标,因为它没有告诉你究竟有多少用户实际经历了这样的等待。
通常,采用 *分位数percentile* 更合适。将响应时间从快到慢排列,*中位数* 就是位于正中间的值。例如,如果响应时间的中位数是 200 毫秒,就意味着一半请求用时不到 200 毫秒,另一半则需要更长时间。因此,如果想知道用户通常要等多久,中位数是个很好的指标。中位数也称为 *第 50 分位数*,有时缩写为 *p50*
通常,采用 *分位数percentile* 更合适。将响应时间从快到慢排列,*中位数**median*就是位于正中间的值。例如,如果响应时间的中位数是 200 毫秒,就意味着一半请求用时不到 200 毫秒,另一半则需要更长时间。因此,如果想知道用户通常要等多久,中位数是个很好的指标。中位数也称为 *第 50 分位数*,有时缩写为 *p50*
为了弄清异常值究竟有多糟,可以观察更高的分位数。常用的有第 *95*、*99* 和 *99.9* 分位数,分别缩写为 *p95*、*p99* 和 *p999*。它们对应这样一个响应时间阈值:分别有 95%、99% 或 99.9% 的请求快于这个阈值。例如,如果第 95 分位数的响应时间是 1.5 秒,就意味着每 100 个请求中,有 95 个用时不到 1.5 秒,另外 5 个则需要 1.5 秒或更久。{{< xref fig="2-5" page="/ch2" anchor="fig_lognormal" >}}图 2-5{{< /xref >}}对此作了说明。
@@ -243,9 +243,9 @@ SELECT posts.*, users.* FROM posts
软件系统由人设计和构建,维持系统运行的运维人员同样也是人。与机器不同,人类不只是照章行事;他们的长处正是能够发挥创造力、随机应变,把工作完成。不过,这一特点也会带来不可预测性:即使出发点再好,人也会犯错,有时还会导致系统失效。例如,一项针对大型互联网服务的研究发现,运维人员修改配置是服务中断的首要原因,而硬件故障(服务器或网络)只在 10%25% 的中断中起了作用 [^70]。
人们很容易把这类问题归结为“人为错误”,并幻想通过更严格的流程和更严密的规则来约束人的行为,从而解决问题。然而,把错误归咎于个人往往适得其反。所谓“人为错误”其实并不是事故的根本原因,而是人与技术共同构成的 *社会技术系统* 出了问题的一种症状;身处其中的人只是在竭尽所能地完成工作 [^71]。复杂系统也常常表现出涌现行为,组件之间出人意料的交互同样可能导致失效 [^72]。
人们很容易把这类问题归结为“人为错误”,并幻想通过更严格的流程和更严密的规则来约束人的行为,从而解决问题。然而,把错误归咎于个人往往适得其反。所谓“人为错误”其实并不是事故的根本原因,而是人与技术共同构成的 *社会技术系统**sociotechnical system*出了问题的一种症状;身处其中的人只是在竭尽所能地完成工作 [^71]。复杂系统也常常表现出涌现行为,组件之间出人意料的交互同样可能导致失效 [^72]。
多种技术手段都能减小人为失误的影响,包括:彻底测试(既包括手写测试,也包括用大量随机输入进行的 *属性测试*[^38];提供回滚机制,以便迅速撤销配置变更;逐步发布新代码;提供详细而清晰的监控,以及用于诊断生产问题的可观测性工具(参见[“分布式系统的问题”](/ch1#sec_introduction_dist_sys_problems));精心设计界面,使“做正确的事”更加容易,“做错误的事”更加困难。
多种技术手段都能减小人为失误的影响,包括:彻底测试(既包括手写测试,也包括用大量随机输入进行的 *属性测试**property-based testing*[^38];提供回滚机制,以便迅速撤销配置变更;逐步发布新代码;提供详细而清晰的监控,以及用于诊断生产问题的可观测性工具(参见[“分布式系统的问题”](/ch1#sec_introduction_dist_sys_problems));精心设计界面,使“做正确的事”更加容易,“做错误的事”更加困难。
不过,这些措施都要投入时间和金钱。在日常经营的现实压力下,组织往往优先考虑能够创造收入的工作,而不是提高自身抵御失误能力的措施。如果必须在开发更多功能和开展更多测试之间选择,许多组织选择功能也不难理解。既然作出了这样的选择,当本可避免的错误不可避免地发生时,再去责怪犯错的人便毫无道理——真正的问题在于组织如何设定优先级。
@@ -295,7 +295,7 @@ SELECT posts.*, users.* FROM posts
通常,我们的目标是在满足 SLA 性能要求(参见[“响应时间指标的应用”](/ch2#sec_introduction_slo_sla))的同时,尽可能降低系统的运行成本。所需的计算资源越多,成本就越高。某些硬件也许比另一些更具性价比,而随着新型硬件出现,这些因素也会随时间变化。
如果资源增加一倍,就能在性能不变的情况下处理两倍负载,我们称系统具备 *线性可伸缩性*,这通常是一件好事。偶尔,由于规模经济或峰值负载分布得更加均匀,不到两倍的资源也能处理两倍的负载 [^79] [^80]。更常见的情况是,成本增长得比线性更快,造成这种低效的原因可能有很多。例如,系统拥有大量数据时,即使写入请求本身大小相同,处理一次写入所需的工作也可能多于数据量较小时。
如果资源增加一倍,就能在性能不变的情况下处理两倍负载,我们称系统具备 *线性可伸缩性**linear scalability*,这通常是一件好事。偶尔,由于规模经济或峰值负载分布得更加均匀,不到两倍的资源也能处理两倍的负载 [^79] [^80]。更常见的情况是,成本增长得比线性更快,造成这种低效的原因可能有很多。例如,系统拥有大量数据时,即使写入请求本身大小相同,处理一次写入所需的工作也可能多于数据量较小时。
### 共享内存、共享磁盘与无共享架构 {#sec_introduction_shared_nothing}
@@ -371,7 +371,7 @@ SELECT posts.*, users.* FROM posts
例如高级编程语言是一种抽象隐藏了机器码、CPU 寄存器和系统调用。SQL 也是一种抽象,隐藏了复杂的磁盘和内存数据结构、其他客户端发出的并发请求,以及崩溃后产生的不一致。当然,使用高级语言编程时,我们仍然用到了机器码;只不过没有 *直接* 使用它,因为编程语言的抽象让我们不必考虑这些细节。
为了降低应用程序代码的复杂度,可以借助 *设计模式* [^95] 和 *领域驱动设计DDD* [^96] 等方法来构建抽象。本书讨论的不是这类应用专用的抽象,而是数据库事务、索引和事件日志等通用抽象;你可以在它们之上构建应用。如果你想采用 DDD 等方法,也可以把它们实现于本书所述的基础之上。
为了降低应用程序代码的复杂度,可以借助 *设计模式**design pattern*[^95] 和 *领域驱动设计DDD* [^96] 等方法来构建抽象。本书讨论的不是这类应用专用的抽象,而是数据库事务、索引和事件日志等通用抽象;你可以在它们之上构建应用。如果你想采用 DDD 等方法,也可以把它们实现于本书所述的基础之上。
### 可演化性:让变化更容易 {#sec_introduction_evolvability}

View File

@@ -26,35 +26,35 @@ breadcrumbs: false
一个复杂的应用程序可能会有更多的中间层次,比如基于 API 的 API不过基本思想仍然是一样的每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。这些抽象允许不同的人群有效地协作例如数据库厂商的工程师和使用数据库的应用程序开发人员。
实践中广泛使用着几种不同的数据模型,通常各有用途。某些类型的数据和查询在一种模型中很容易表达,在另一种模型中却很别扭。本章将比较关系模型、文档模型、图数据模型、事件溯源和数据框,探讨其中的权衡。我们还将简要介绍操作这些模型的查询语言,帮助你判断何时应该使用哪种模型。
实践中广泛使用着几种不同的数据模型,通常各有用途。某些类型的数据和查询在一种模型中很容易表达,在另一种模型中却很别扭。本章将比较 *关系模型**relational model*)、*文档模型**document model*)、*图数据模型**graph data model*)、*事件溯源**event sourcing*)和 *数据框**dataframe*,探讨其中的权衡。我们还将简要介绍操作这些模型的查询语言,帮助你判断何时应该使用哪种模型。
> [!TIP] 术语:声明式查询语言
>
> 本章中的许多查询语言(如 SQL、Cypher、SPARQL 或 Datalog都是 *声明式* 的。在声明式查询语言中,你只需指定所需数据的模式——结果必须符合哪些条件,以及数据应如何转换(例如排序、分组和聚合)——而不必说明 *如何* 实现这一目标。数据库系统的查询优化器决定使用哪些索引和连接算法,以及以何种顺序执行查询的各个部分。
> 本章中的许多查询语言(如 SQL、Cypher、SPARQL 或 Datalog都是 *声明式**declarative*的。在声明式查询语言中,你只需指定所需数据的模式——结果必须符合哪些条件,以及数据应如何转换(例如排序、分组和聚合)——而不必说明 *如何* 实现这一目标。数据库系统的查询优化器决定使用哪些索引和连接算法,以及以何种顺序执行查询的各个部分。
>
> 相比之下,使用大多数编程语言时,你必须写出一套 *算法*,告诉计算机以特定顺序执行哪些操作。声明式查询语言通常比显式算法更加简洁,也更容易编写;但更重要的是,它隐藏了查询引擎的实现细节,使数据库系统可以在无须对查询做任何修改的情况下提升性能 [^1]。
> 相比之下,使用大多数编程语言时,你必须写出一套 *算法**algorithm*,告诉计算机以特定顺序执行哪些操作。声明式查询语言通常比显式算法更加简洁,也更容易编写;但更重要的是,它隐藏了查询引擎的实现细节,使数据库系统可以在无须对查询做任何修改的情况下提升性能 [^1]。
>
> 例如,数据库或许能跨多个 CPU 核心和多台机器并行执行一条声明式查询,而你无须操心如何实现这种并行 [^2]。若是手写算法,自行实现这种并行执行将费不少功夫。
## 关系模型与文档模型 {#sec_datamodels_history}
如今最广为人知的数据模型或许是 SQL 所采用的关系模型,它由 Edgar Codd 于 1970 年提出 [^3]:数据被组织成 *关系*SQL 称之为 **),每个关系都是由 *元组*SQL 称之为 **)构成的无序集合。
如今最广为人知的数据模型或许是 SQL 所采用的关系模型,它由 Edgar Codd 于 1970 年提出 [^3]:数据被组织成 *关系**relation*SQL 称之为 ***table*),每个关系都是由 *元组**tuple*SQL 称之为 ***row*)构成的无序集合。
关系模型最初只是一项理论提议,当时许多人怀疑它能否得到高效实现。然而到了 20 世纪 80 年代中期对于大多数需要存储和查询具有某种规则结构的数据的人来说关系数据库管理系统RDBMS和 SQL 已成为首选工具。几十年过去,关系数据仍主导着许多数据管理场景,例如商业分析(参见 [“星型与雪花型:分析模式”](/ch3#sec_datamodels_analytics))。
多年来数据存储和查询领域涌现过许多彼此竞争的方法。20 世纪 70 年代至 80 年代初,*网状模型**层次模型* 是关系模型的主要对手,但最终都败下阵来。对象数据库在 20 世纪 80 年代末至 90 年代初兴起后又销声匿迹XML 数据库于 21 世纪初出现,却始终只在少数场景中得到采用。关系模型的每个竞争者都曾盛极一时,但无一长久 [^4]。反倒是 SQL 在关系模型这个核心之上不断吸收其他数据类型,例如增加了对 XML、JSON 和图数据的支持 [^5]。
多年来数据存储和查询领域涌现过许多彼此竞争的方法。20 世纪 70 年代至 80 年代初,*网状模型**network model*)和 *层次模型**hierarchical model*是关系模型的主要对手,但最终都败下阵来。对象数据库在 20 世纪 80 年代末至 90 年代初兴起后又销声匿迹XML 数据库于 21 世纪初出现,却始终只在少数场景中得到采用。关系模型的每个竞争者都曾盛极一时,但无一长久 [^4]。反倒是 SQL 在关系模型这个核心之上不断吸收其他数据类型,例如增加了对 XML、JSON 和图数据的支持 [^5]。
到了 2010 年代,*NoSQL* 成了试图撼动关系数据库统治地位的最新流行语。NoSQL 并非某项特定技术,而是围绕新数据模型、模式灵活性、可伸缩性和开源许可模式形成的一组宽泛理念。另一些数据库则以 *NewSQL* 自居,力图在保留传统关系数据库的数据模型和事务保证的同时,提供 NoSQL 系统的可伸缩性。NoSQL 和 NewSQL 的理念深刻影响了数据系统的设计;不过,随着这些原则被广泛吸收,两个术语本身已渐渐淡出。
NoSQL 运动留下的一项持久影响,是通常以 JSON 表示数据的 *文档模型* 广受欢迎。这个模型最初由 MongoDB、Couchbase 等专用文档数据库推广,如今大多数关系数据库也已加入 JSON 支持。关系表的模式常被视为严格而僵化相比之下JSON 文档被认为更加灵活。
NoSQL 运动留下的一项持久影响,是通常以 JSON 表示数据的 *文档模型**document model*广受欢迎。这个模型最初由 MongoDB、Couchbase 等专用文档数据库推广,如今大多数关系数据库也已加入 JSON 支持。关系表的模式常被视为严格而僵化相比之下JSON 文档被认为更加灵活。
文档数据与关系数据孰优孰劣,已经引发过大量争论。下面来看看其中几个关键问题。
### 对象关系不匹配 {#sec_datamodels_document}
如今,大量应用开发使用面向对象的编程语言,这也引出了针对 SQL 数据模型的一项常见批评:数据若存储在关系表中,就需要一个笨拙的转换层,在应用代码中的对象与数据库的表、行、列模型之间来回转换。两种模型之间的这种脱节,有时称为 *阻抗不匹配*
如今,大量应用开发使用面向对象的编程语言,这也引出了针对 SQL 数据模型的一项常见批评:数据若存储在关系表中,就需要一个笨拙的转换层,在应用代码中的对象与数据库的表、行、列模型之间来回转换。两种模型之间的这种脱节,有时称为 *阻抗不匹配**impedance mismatch*
> [!NOTE]
@@ -69,7 +69,7 @@ ActiveRecord、Hibernate 等对象关系映射ORM框架减少了转换层
* ORM 一般只用于开发 OLTP 应用(参见 [“事务处理与分析的特征”](/ch1#sec_introduction_oltp))。为了让数据可供分析,数据工程师仍须面对底层的关系表示,因此采用 ORM 并不意味着关系模式的设计不再重要。
* 许多 ORM 只面向关系型 OLTP 数据库。若组织还使用搜索引擎、图数据库、NoSQL 系统等多种数据系统ORM 提供的支持可能远远不够。
* 有些 ORM 会自动生成关系模式,但生成的模式对直接访问关系数据的用户未必友好,在底层数据库上也可能效率不佳。要定制 ORM 生成模式与查询的方式,往往相当复杂,甚至会抵消采用 ORM 原本想获得的好处。
* 使用 ORM 很容易在无意中写出低效查询,例如触发 *N+1 查询问题* [^7]。假设你要在页面上显示用户评论列表:先用一条查询取回 *N* 条评论,每条都含有作者 ID为了显示作者姓名还要用这个 ID 查询用户表。手写 SQL 时,你大概会直接在查询中连接用户表,让每条评论连同作者姓名一起返回;使用 ORM 时,却可能对 *N* 条评论逐条查询用户表,最终一共执行 *N*+1 条数据库查询。这比在数据库内完成连接要慢得多。为避免这个问题,你可能必须明确要求 ORM 在获取评论的同时一并取回作者信息。
* 使用 ORM 很容易在无意中写出低效查询,例如触发 *N+1 查询问题**N+1 query problem*[^7]。假设你要在页面上显示用户评论列表:先用一条查询取回 *N* 条评论,每条都含有作者 ID为了显示作者姓名还要用这个 ID 查询用户表。手写 SQL 时,你大概会直接在查询中连接用户表,让每条评论连同作者姓名一起返回;使用 ORM 时,却可能对 *N* 条评论逐条查询用户表,最终一共执行 *N*+1 条数据库查询。这比在数据库内完成连接要慢得多。为避免这个问题,你可能必须明确要求 ORM 在获取评论的同时一并取回作者信息。
不过ORM 也自有其优势:
@@ -81,7 +81,7 @@ ActiveRecord、Hibernate 等对象关系映射ORM框架减少了转换层
并非所有数据都适合用关系形式表示。下面用一个例子看看关系模型的局限。{{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 展示了如何用关系模式表示一份简历LinkedIn 个人资料)。整份资料由唯一标识符 `user_id` 标识;`first_name``last_name` 等字段对每位用户只出现一次,因此可以建模为 `users` 表中的列。
大多数人的职业生涯中都不止有一份工作(即多个职位),每个人的教育经历数量也不相同,联系方式更可能有任意多项。这些 *一对多关系* 可以这样表示:把职位、教育经历和联系信息分别放在单独的表中,再通过外键引用 `users` 表,如 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 所示。
大多数人的职业生涯中都不止有一份工作(即多个职位),每个人的教育经历数量也不相同,联系方式更可能有任意多项。这些 *一对多关系**one-to-many relationship*可以这样表示:把职位、教育经历和联系信息分别放在单独的表中,再通过外键引用 `users` 表,如 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 所示。
{{< fig num="3-1" id="fig_obama_relational" src="/fig/ddia_0301.png" caption="使用关系模式表示 LinkedIn 个人资料。" class="ddia-figure ddia-figure--standard" width="1772" height="1414" />}}
@@ -122,7 +122,7 @@ ActiveRecord、Hibernate 等对象关系映射ORM框架减少了转换层
> [!NOTE]
> 这种关系有时称为 *一对少*,而非 *一对多*,因为一份简历通常只有少数几个职位 [^9] [^10]。如果相关项目确实可能多到惊人——例如名人的社交媒体帖子可能收到成千上万条评论——把它们全部嵌进同一个文档就太过笨重,此时更适合采用 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 所示的关系方法。
> 这种关系有时称为 *一对少**one-to-few*,而非 *一对多*,因为一份简历通常只有少数几个职位 [^9] [^10]。如果相关项目确实可能多到惊人——例如名人的社交媒体帖子可能收到成千上万条评论——把它们全部嵌进同一个文档就太过笨重,此时更适合采用 {{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 所示的关系方法。
### 规范化、反规范化与连接 {#sec_datamodels_normalization}
@@ -137,11 +137,11 @@ ActiveRecord、Hibernate 等对象关系映射ORM框架减少了转换层
* 支持本地化——网站翻译成其他语言时,可以本地化这份标准列表,让地区名称以浏览者使用的语言显示
* 改善搜索——例如,地区列表可以记录华盛顿位于美国东海岸这一事实(单看 `"Washington, DC"` 字符串无法得知),于是搜索美国东海岸的人时也能匹配这份资料
选择存储 ID 还是文本字符串,实质上是在决定是否 *规范化*。使用 ID 时,数据更加规范化:对人有意义的信息(如 *Washington, DC* 这段文字)只存储一份,其他地方都用仅在数据库内有意义的 ID 来引用它。若直接存储文本,这段有意义的信息就会复制到每条使用它的记录中;这样的表示便是 *反规范化* 的。
选择存储 ID 还是文本字符串,实质上是在决定是否 *规范化**normalization*。使用 ID 时,数据更加规范化:对人有意义的信息(如 *Washington, DC* 这段文字)只存储一份,其他地方都用仅在数据库内有意义的 ID 来引用它。若直接存储文本,这段有意义的信息就会复制到每条使用它的记录中;这样的表示便是 *反规范化**denormalized*的。
ID 的好处在于,它本身对人没有意义,因而永远不必改变:即使 ID 所标识的信息发生了变化ID 仍可保持不变。凡是对人有意义的信息,将来都有可能需要修改;一旦这类信息被复制,所有冗余副本就都得随之更新。这不仅需要更多代码、写入操作和磁盘空间,还会带来不一致的风险——有些副本已经更新,另一些却没有。
规范化表示也有代价:每次显示含有 ID 的记录时,都要多做一次查找,把 ID 解析成人能读懂的信息。在关系数据模型中,这项工作通过 *连接* 完成,例如:
规范化表示也有代价:每次显示含有 ID 的记录时,都要多做一次查找,把 ID 解析成人能读懂的信息。在关系数据模型中,这项工作通过 *连接**join*完成,例如:
```sql
SELECT users.*, regions.region_name
@@ -204,9 +204,9 @@ SELECT posts.id, posts.sender_id
### 多对一与多对多关系 {#sec_datamodels_many_to_many}
{{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 中的 `positions` 和 `education` 是一对多(或一对少)关系:一份简历有多个职位,但每个职位只属于一份简历。相比之下,`region_id` 字段表示 *多对一* 关系:许多人住在同一个地区,而我们假设任一时刻每个人只住在一个地区。
{{< xref fig="3-1" page="/ch3" anchor="fig_obama_relational" >}}图 3-1{{< /xref >}} 中的 `positions` 和 `education` 是一对多(或一对少)关系:一份简历有多个职位,但每个职位只属于一份简历。相比之下,`region_id` 字段表示 *多对一**many-to-one*关系:许多人住在同一个地区,而我们假设任一时刻每个人只住在一个地区。
如果进一步把组织和学校建模为实体,让简历通过 ID 引用它们,就会出现 *多对多* 关系:一个人曾在多个组织任职,一个组织也有多名现任或前任员工。在关系模型中,这类关系通常用 *关联表*也称 *连接表*)表示,如 {{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}图 3-3{{< /xref >}} 所示:每个职位把一个用户 ID 与一个组织 ID 关联起来。
如果进一步把组织和学校建模为实体,让简历通过 ID 引用它们,就会出现 *多对多**many-to-many*关系:一个人曾在多个组织任职,一个组织也有多名现任或前任员工。在关系模型中,这类关系通常用 *关联表**associative table*,也称 *连接表**join table*)表示,如 {{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}图 3-3{{< /xref >}} 所示:每个职位把一个用户 ID 与一个组织 ID 关联起来。
{{< fig num="3-3" id="fig_datamodels_m2m_rel" src="/fig/ddia_0303.png" caption="关系模型中的多对多关系。" class="ddia-figure ddia-figure--wide" width="1772" height="745" />}}
@@ -231,21 +231,21 @@ SELECT posts.id, posts.sender_id
多对多关系通常需要从“两个方向”查询:既要找出某人任职过的所有组织,也要找出曾在某组织任职的所有人。一种做法是在关系两端都保存 ID 引用:简历列出此人任职过的各个组织 ID组织文档也列出提及该组织的简历 ID。由于同一关系存储了两份这是一种反规范化表示两边可能彼此不一致。
规范化表示只在一处存储关系,再依靠 *二级索引*(将在 [第 4 章](/ch4#ch_storage) 讨论)从两个方向高效查询。{{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}图 3-3{{< /xref >}} 的关系模式中,可以让数据库分别为 `positions` 表的 `user_id` 列和 `org_id` 列建立索引。
规范化表示只在一处存储关系,再依靠 *二级索引**secondary index*将在 [第 4 章](/ch4#ch_storage) 讨论)从两个方向高效查询。{{< xref fig="3-3" page="/ch3" anchor="fig_datamodels_m2m_rel" >}}图 3-3{{< /xref >}} 的关系模式中,可以让数据库分别为 `positions` 表的 `user_id` 列和 `org_id` 列建立索引。
在 {{< xref eg="3-2" page="/ch3" anchor="fig_datamodels_m2m_json" >}}示例 3-2{{< /xref >}} 的文档模型中,数据库则需要索引 `positions` 数组内各对象的 `org_id` 字段。许多文档数据库以及支持 JSON 的关系数据库,都能为文档内部的值建立这种索引。
### 星型与雪花型:分析模式 {#sec_datamodels_analytics}
数据仓库(参见 [“数据仓库”](/ch1#sec_introduction_dwh))通常采用关系模型,其表结构有几种广泛使用的惯例:*星型模式*、*雪花模式*、*维度建模* [^12],以及 *一张大表*OBT。这些结构针对业务分析师的需求进行了优化ETL 过程则负责把事务型系统中的数据转换成这种模式。
数据仓库(参见 [“数据仓库”](/ch1#sec_introduction_dwh))通常采用关系模型,其表结构有几种广泛使用的惯例:*星型模式**star schema*)、*雪花模式**snowflake schema*)、*维度建模**dimensional modeling*[^12],以及 *一张大表*OBT。这些结构针对业务分析师的需求进行了优化ETL 过程则负责把事务型系统中的数据转换成这种模式。
{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}图 3-5{{< /xref >}} 中的示例模式,可能出现在一家食品零售商的数据仓库中。模式的中心是所谓的 *事实表*(本例中名为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件;在这里,每行代表客户购买了一件产品。如果分析的是网站流量而不是零售量,那么每行可能代表一次页面浏览或一次用户点击。
{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}图 3-5{{< /xref >}} 中的示例模式,可能出现在一家食品零售商的数据仓库中。模式的中心是所谓的 *事实表**fact table*本例中名为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件;在这里,每行代表客户购买了一件产品。如果分析的是网站流量而不是零售量,那么每行可能代表一次页面浏览或一次用户点击。
{{< fig num="3-5" id="fig_dwh_schema" src="/fig/ddia_0305.png" caption="用于数据仓库的星型模式示例。" class="ddia-figure ddia-figure--standard" width="2658" height="2223" />}}
通常会把每项事实记录为独立事件,因为这样能为日后的分析保留最大的灵活性。不过,这也意味着事实表可能变得极其庞大。大型企业的数据仓库可能保存着许多 PB 的交易历史,其中大部分都以事实表表示。
事实表中的一些列是属性,例如产品的售价和从供应商处购入的成本(据此可以计算利润率)。另一些列是指向其他表的外键引用,这些表称为 *维度表*。由于事实表的每一行表示一个事件,各个维度便代表事件发生的对象、内容、地点、时间、方式和原因。
事实表中的一些列是属性,例如产品的售价和从供应商处购入的成本(据此可以计算利润率)。另一些列是指向其他表的外键引用,这些表称为 *维度表**dimension table*。由于事实表的每一行表示一个事件,各个维度便代表事件发生的对象、内容、地点、时间、方式和原因。
例如,{{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}图 3-5{{< /xref >}} 中的一个维度是售出的产品。`dim_product` 表中的每一行代表一种待售产品包括库存单位SKU、产品描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表的每一行都用外键表明该笔交易售出了哪种产品。查询往往要连接多个维度表。
@@ -315,7 +315,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
局部性优势只适用于同时需要文档绝大部分内容的情况。即使只访问大型文档的一小部分,数据库通常也要加载整个文档,这会造成浪费;更新时一般还要重写整个文档。因此,通常建议让文档保持较小,并避免频繁地对文档做小幅更新。
不过为了局部性而把相关数据存储在一起并非文档模型的专利。例如Google 的 Spanner 数据库在关系模型中也提供同样的局部性属性,允许模式声明某张表的行应交错(嵌套)在父表之中 [^25]。Oracle 也用名为 *多表索引集群表* 的功能提供类似能力 [^26]。由 Google Bigtable 推广、并被 HBase 和 Accumulo 等系统采用的 *宽列* 数据模型,则以 *列族* 概念达到相似的局部性管理目的 [^27]。
不过为了局部性而把相关数据存储在一起并非文档模型的专利。例如Google 的 Spanner 数据库在关系模型中也提供同样的局部性属性,允许模式声明某张表的行应交错(嵌套)在父表之中 [^25]。Oracle 也用名为 *多表索引集群表**multi-table index cluster table*的功能提供类似能力 [^26]。由 Google Bigtable 推广、并被 HBase 和 Accumulo 等系统采用的 *宽列**wide-column*)数据模型,则以 *列族**column family*概念达到相似的局部性管理目的 [^27]。
#### 文档的查询语言 {#query-languages-for-documents}
@@ -362,7 +362,7 @@ db.observations.aggregate([
> [!NOTE]
> Codd 对关系模型的原始描述 [^3] 实际上允许关系模式中出现类似 JSON 的结构,他称之为 *非简单域*。其思想是,一行中的值不一定只是数字或字符串之类的原始数据类型,也可以是嵌套的关系(表),因此可以把任意嵌套的树结构作为一个值。这与三十多年后加入 SQL 的 JSON 或 XML 支持非常相似。
> Codd 对关系模型的原始描述 [^3] 实际上允许关系模式中出现类似 JSON 的结构,他称之为 *非简单域**nonsimple domain*。其思想是,一行中的值不一定只是数字或字符串之类的原始数据类型,也可以是嵌套的关系(表),因此可以把任意嵌套的树结构作为一个值。这与三十多年后加入 SQL 的 JSON 或 XML 支持非常相似。
@@ -385,14 +385,14 @@ db.observations.aggregate([
可以把许多众所周知的算法运用到这些图上。例如地图导航应用会搜索道路网络中两点之间的最短路径PageRank 可以用在网页图上,判断网页的流行程度,进而决定它在搜索结果中的排名 [^32]。
图可以用几种不同的方式表示。在 *邻接表* 模型中,每个顶点都保存与它相隔一条边的相邻顶点 ID。另一种方式是 *邻接矩阵*:这是一个二维数组,每行、每列各对应一个顶点;行顶点与列顶点之间没有边时,值为 0有边时则为 1。邻接表适合图遍历邻接矩阵则适合机器学习参见 [“数据框、矩阵与数组”](/ch3#sec_datamodels_dataframes))。
图可以用几种不同的方式表示。在 *邻接表**adjacency list*模型中,每个顶点都保存与它相隔一条边的相邻顶点 ID。另一种方式是 *邻接矩阵**adjacency matrix*:这是一个二维数组,每行、每列各对应一个顶点;行顶点与列顶点之间没有边时,值为 0有边时则为 1。邻接表适合图遍历邻接矩阵则适合机器学习参见 [“数据框、矩阵与数组”](/ch3#sec_datamodels_dataframes))。
在刚才给出的例子中,图里的所有顶点都表示同一种事物,分别是人、网页或道路交叉口。不过,图并不局限于这种 *同质* 数据:图还有一项同样强大的用途,就是以一致的方式在单个数据库中存储截然不同的对象。例如:
在刚才给出的例子中,图里的所有顶点都表示同一种事物,分别是人、网页或道路交叉口。不过,图并不局限于这种 *同质**homogeneous*数据:图还有一项同样强大的用途,就是以一致的方式在单个数据库中存储截然不同的对象。例如:
* Facebook 维护着一个包含许多不同类型顶点和边的图:顶点表示人、地点、事件、签到和用户评论;边表示哪些人是朋友、某次签到发生在哪里、谁评论了哪篇帖子、谁参加了哪场活动,等等 [^33]。
* 搜索引擎用知识图谱来记录查询中经常出现的组织、人物、地点等实体的事实 [^34]。这些信息来自对网站的抓取与文本分析Wikidata 等网站也会以结构化形式发布图数据。
有几种不同但彼此相关的方式,可以用来组织和查询图中的数据。本节将讨论 *属性图* 模型(由 Neo4j、Memgraph、KùzuDB [^35] 等系统实现 [^36])和 *三元组存储* 模型(由 Datomic、AllegroGraph、Blazegraph 等系统实现。两种模型的表达能力相当接近Amazon Neptune 等图数据库还同时支持二者。
有几种不同但彼此相关的方式,可以用来组织和查询图中的数据。本节将讨论 *属性图**property graph*模型(由 Neo4j、Memgraph、KùzuDB [^35] 等系统实现 [^36])和 *三元组存储**triple store*模型(由 Datomic、AllegroGraph、Blazegraph 等系统实现。两种模型的表达能力相当接近Amazon Neptune 等图数据库还同时支持二者。
我们还将介绍四种图查询语言Cypher、SPARQL、Datalog 和 GraphQL以及 SQL 对图查询的支持。其他图查询语言还有 Gremlin 等 [^37],不过这里选取的几种已足以给出一幅有代表性的全景。
@@ -402,7 +402,7 @@ db.observations.aggregate([
### 属性图 {#id56}
在 *属性图*(也称 *带标签属性图*)模型中,每个顶点包括:
在 *属性图*(也称 *带标签属性图**labeled property graph*)模型中,每个顶点包括:
* 唯一标识符
* 一个标签(字符串),描述该顶点所表示的对象类型
@@ -512,7 +512,7 @@ RETURN person.name
Cypher 用 `:WITHIN*0..` 非常简洁地表达了这一点:“沿着 `WITHIN` 边走零次或多次”。它类似于正则表达式中的 `*` 运算符。
从 SQL:1999 开始,可以用所谓的 *递归公用表表达式*`WITH RECURSIVE` 语法)在查询中表示长度可变的遍历路径。{{< xref eg="3-6" page="/ch3" anchor="fig_graph_sql_query" >}}示例 3-6{{< /xref >}} 用这种技术在 SQL 中写出了同一个查询——查找从美国移居欧洲者的姓名。只不过,与 Cypher 相比,它的语法十分笨拙。
从 SQL:1999 开始,可以用所谓的 *递归公用表表达式**recursive common table expression*`WITH RECURSIVE` 语法)在查询中表示长度可变的遍历路径。{{< xref eg="3-6" page="/ch3" anchor="fig_graph_sql_query" >}}示例 3-6{{< /xref >}} 用这种技术在 SQL 中写出了同一个查询——查找从美国移居欧洲者的姓名。只不过,与 Cypher 相比,它的语法十分笨拙。
{{< eg num="3-6" id="fig_graph_sql_query" caption="使用递归公用表表达式,以 SQL 写出与示例 3-5 相同的查询" >}}
```sql
@@ -574,7 +574,7 @@ WITH RECURSIVE
同一个查询用 Cypher 只需 4 行,用 SQL 却要写 31 行,这恰恰说明选对数据模型和查询语言会带来多大差别。而这还只是开始;还有更多细节需要考虑,例如如何处理环,以及选择广度优先还是深度优先遍历 [^40]。
Oracle 为递归查询提供了另一套 SQL 扩展,称为 *层次查询* [^41]。
Oracle 为递归查询提供了另一套 SQL 扩展,称为 *层次查询**hierarchical query*[^41]。
不过,情况可能正在改善:在本书写作时,已有计划把一种名为 GQL 的图查询语言加入 SQL 标准 [^42] [^43],其语法借鉴了 Cypher、GSQL [^44] 和 PGQL [^45]。
@@ -636,7 +636,7 @@ _:namerica a :Location; :name "North America"; :type "continent".
#### RDF 数据模型 {#the-rdf-data-model}
{{< xref eg="3-8" page="/ch3" anchor="fig_graph_n3_shorthand" >}}示例 3-8{{< /xref >}} 使用的 Turtle 语言,实际上是对 *资源描述框架*RDF数据进行编码的一种方式 [^55]RDF 是专为语义网设计的数据模型。RDF 数据也可以采用其他编码,例如用更为冗长的 XML 表示,如 {{< xref eg="3-9" page="/ch3" anchor="fig_graph_rdf_xml" >}}示例 3-9{{< /xref >}} 所示。Apache Jena 等工具可以在不同 RDF 编码之间自动转换。
{{< xref eg="3-8" page="/ch3" anchor="fig_graph_n3_shorthand" >}}示例 3-8{{< /xref >}} 使用的 Turtle 语言,实际上是对 *资源描述框架*RDF*Resource Description Framework*)数据进行编码的一种方式 [^55]RDF 是专为 *语义网**Semantic Web*设计的数据模型。RDF 数据也可以采用其他编码,例如用更为冗长的 XML 表示,如 {{< xref eg="3-9" page="/ch3" anchor="fig_graph_rdf_xml" >}}示例 3-9{{< /xref >}} 所示。Apache Jena 等工具可以在不同 RDF 编码之间自动转换。
{{< eg num="3-9" id="fig_graph_rdf_xml" caption="使用 RDF/XML 语法表示示例 3-8 中的数据" >}}
```xml
@@ -850,17 +850,17 @@ query ChatApp {
我们在 [“权威记录系统与衍生数据”](/ch1#sec_introduction_derived) 中已经见过这种思路ETL参见 [“数据仓库”](/ch1#sec_introduction_dwh))就是一种派生过程。现在让我们再往前走一步。既然无论如何都要由一种数据表示派生出另一种,那就可以分别选用针对写入和读取优化的表示。如果只需优化数据写入,丝毫不必考虑查询效率,你会如何对数据建模?
也许,写入数据最简单、最快且表意最清楚的方式,就是写入 *事件日志*:每次写入数据时,都将它编码成一个自包含的字符串(也许是 JSON其中带有时间戳再追加到事件序列中。日志中的事件是 *不可变的*:你永远不会修改或删除它们,只会向日志追加更多事件(后来的事件可以取代早先事件的效力)。事件可以包含任意属性。
也许,写入数据最简单、最快且表意最清楚的方式,就是写入 *事件日志**event log*:每次写入数据时,都将它编码成一个自包含的字符串(也许是 JSON其中带有时间戳再追加到事件序列中。日志中的事件是 *不可变的**immutable*:你永远不会修改或删除它们,只会向日志追加更多事件(后来的事件可以取代早先事件的效力)。事件可以包含任意属性。
{{< xref fig="3-8" page="/ch3" anchor="fig_event_sourcing" >}}图 3-8{{< /xref >}} 给出了一个可能来自会议管理系统的例子。会议管理是一个复杂的业务领域:不仅个人参会者可以报名并用信用卡付款,企业也可以批量预订座位,以发票结算,再把座位分配给个人。演讲者、赞助商和志愿者等人可能要占用一些预留座位。预订还可能取消;与此同时,会议组织者又可能因为更换场地,而改变活动的容量。这些事情叠加在一起,哪怕只是计算还有多少空余座位,也会变成一项颇具挑战的查询。
{{< fig num="3-8" id="fig_event_sourcing" src="/fig/ddia_0308.png" caption="以不可变事件日志作为权威数据源,并从中派生物化视图。" class="ddia-figure ddia-figure--standard" width="1772" height="1321" />}}
在 {{< xref fig="3-8" page="/ch3" anchor="fig_event_sourcing" >}}图 3-8{{< /xref >}} 中,会议状态的每次变化(例如组织者开放报名,或参会者报名和取消报名),首先都会被存储为事件。每当日志追加一个事件,几个 *物化视图*也称为 *投影* 或 *读模型*)也会随之更新,以反映该事件带来的影响。在这个会议示例中,可以有一个物化视图汇总每笔预订状态的所有相关信息,另一个计算会议组织者仪表盘所需的图表,第三个则为制作参会者胸牌的打印机生成文件。
在 {{< xref fig="3-8" page="/ch3" anchor="fig_event_sourcing" >}}图 3-8{{< /xref >}} 中,会议状态的每次变化(例如组织者开放报名,或参会者报名和取消报名),首先都会被存储为事件。每当日志追加一个事件,几个 *物化视图**materialized view*,也称为 *投影**projection*,或 *读模型**read model*)也会随之更新,以反映该事件带来的影响。在这个会议示例中,可以有一个物化视图汇总每笔预订状态的所有相关信息,另一个计算会议组织者仪表盘所需的图表,第三个则为制作参会者胸牌的打印机生成文件。
以事件作为权威数据源,并把每次状态变化都表达为事件,这种思路称为 *事件溯源* [^62] [^63]。维护独立的读取优化表示,并从写入优化的表示中派生它们,这种原则称为 *命令查询责任分离CQRS* [^64]。这些术语源自领域驱动设计DDD社区不过类似的思路由来已久例如 *状态机复制*(参见 [“使用共享日志”](/ch10#sec_consistency_smr))。
以事件作为权威数据源,并把每次状态变化都表达为事件,这种思路称为 *事件溯源**event sourcing*[^62] [^63]。维护独立的读取优化表示,并从写入优化的表示中派生它们,这种原则称为 *命令查询责任分离**Command Query Responsibility Segregation*CQRS[^64]。这些术语源自领域驱动设计DDD社区不过类似的思路由来已久例如 *状态机复制**state machine replication*参见 [“使用共享日志”](/ch10#sec_consistency_smr))。
当来自用户的请求刚到达时,它还是一个 *命令*,首先需要验证。只有在命令已经执行且确认有效之后(例如,请求的预订有足够的空余座位),它才会成为事实,相应的事件也才会追加到日志中。因此,事件日志中应当只有有效事件;消费事件日志来构建物化视图的组件,不允许拒绝事件。
当来自用户的请求刚到达时,它还是一个 *命令**command*,首先需要验证。只有在命令已经执行且确认有效之后(例如,请求的预订有足够的空余座位),它才会成为事实,相应的事件也才会追加到日志中。因此,事件日志中应当只有有效事件;消费事件日志来构建物化视图的组件,不允许拒绝事件。
以事件溯源的方式对数据建模时,建议用过去时来命名事件(例如“座位已预订”),因为事件记录的是已经发生的事实。即使用户后来更改或取消预订,他们曾经预订过的事实依然成立;更改或取消是之后另行追加的事件。
@@ -888,28 +888,28 @@ query ChatApp {
## 数据框、矩阵与数组 {#sec_datamodels_dataframes}
本章迄今介绍的数据模型,通常既用于事务处理,也用于分析(参见 [“分析型与事务型系统”](/ch1#sec_introduction_analytics))。还有一些数据模型常见于分析或科学场景,却很少出现在 OLTP 系统中:数据框,以及矩阵等多维数值数组。
本章迄今介绍的数据模型,通常既用于事务处理,也用于分析(参见 [“分析型与事务型系统”](/ch1#sec_introduction_analytics))。还有一些数据模型常见于分析或科学场景,却很少出现在 OLTP 系统中:*数据框**dataframe*,以及矩阵等多维数值数组。
R 语言、Python 的 pandas 库、Apache Spark、ArcticDB 和 Dask 等系统,都支持数据框这种数据模型。数据科学家经常用它为训练机器学习模型准备数据;它也广泛用于数据探索、统计分析和数据可视化等场景。
乍看之下,数据框很像关系数据库中的表,也像电子表格。它支持一系列类似关系运算符的批量操作:例如,对所有行应用某个函数,按条件筛选行,按某些列分组并聚合其他列,以及按某个键连接两个数据框中的行(关系数据库中的 *连接*,在数据框中通常称为 *合并*)。
乍看之下,数据框很像关系数据库中的表,也像电子表格。它支持一系列类似关系运算符的批量操作:例如,对所有行应用某个函数,按条件筛选行,按某些列分组并聚合其他列,以及按某个键连接两个数据框中的行(关系数据库中的 *连接*,在数据框中通常称为 *合并**merge*)。
数据框通常不是通过 SQL 之类的声明式查询来操作,而是通过一系列命令逐步修改其结构和内容。这恰好符合数据科学家的典型工作流程:一点点地“整理”数据,直至它变成一种适合回答当前问题的形式。这些操作通常在数据科学家私有的数据集副本上进行,而且往往就在本机上;不过最终结果也可能会分享给其他用户。
数据框 API 提供的许多操作远远超出关系数据库的能力,其使用方式也往往与典型的关系数据建模大不相同 [^65]。例如,数据框的一种常见用途,是把数据从类似关系模型的表示转换为矩阵或多维数组,而许多机器学习算法期望的输入正是这种形式。
{{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}图 3-9{{< /xref >}} 展示了一个简单的转换示例。左侧是一张关系表记录不同用户给各种电影打出的分数1 到 5 分);右侧则把这些数据转换成了矩阵,每一列代表一部电影,每一行代表一位用户(类似电子表格中的 *数据透视表*)。这个矩阵是 *稀疏* 的,也就是说,很多用户与电影的组合都没有数据,但这并不碍事。矩阵可能有成千上万列,不太适合放在关系数据库中;数据框以及 Python 的 NumPy 等支持稀疏数组的库,却能轻松处理这类数据。
{{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}图 3-9{{< /xref >}} 展示了一个简单的转换示例。左侧是一张关系表记录不同用户给各种电影打出的分数1 到 5 分);右侧则把这些数据转换成了矩阵,每一列代表一部电影,每一行代表一位用户(类似电子表格中的 *数据透视表**pivot table*)。这个矩阵是 *稀疏**sparse*的,也就是说,很多用户与电影的组合都没有数据,但这并不碍事。矩阵可能有成千上万列,不太适合放在关系数据库中;数据框以及 Python 的 NumPy 等支持稀疏数组的库,却能轻松处理这类数据。
{{< fig num="3-9" id="fig_dataframe_to_matrix" src="/fig/ddia_0309.png" caption="将电影评分的关系数据库转换为矩阵表示。" class="ddia-figure ddia-figure--wide" width="1772" height="690" />}}
矩阵只能包含数字,因此需要用各种技术把非数值数据转换为矩阵中的数字。例如:
* 日期({{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}图 3-9{{< /xref >}} 的示例矩阵中省略了日期)可以按比例缩放为某个合适范围内的浮点数。
* 对于只能从一小组固定值中取值的列(例如电影数据库中的电影类型),通常采用 *独热编码*:为每个可能的值建立一列(“喜剧”一列、“剧情”一列、“恐怖”一列,依此类推);对于代表某部电影的每一行,在对应其类型的列中填 1其余列填 0。这种表示也很容易推广到同时属于多种类型的电影。
* 对于只能从一小组固定值中取值的列(例如电影数据库中的电影类型),通常采用 *独热编码**one-hot encoding*:为每个可能的值建立一列(“喜剧”一列、“剧情”一列、“恐怖”一列,依此类推);对于代表某部电影的每一行,在对应其类型的列中填 1其余列填 0。这种表示也很容易推广到同时属于多种类型的电影。
数据一旦变成数值矩阵,就适合进行线性代数运算,而线性代数正是许多机器学习算法的基础。例如,{{< xref fig="3-9" page="/ch3" anchor="fig_dataframe_to_matrix" >}}图 3-9{{< /xref >}} 中的数据可以用在向用户推荐其可能喜欢的电影的系统中。数据框十分灵活,可以让数据从关系形式逐步演变为矩阵表示,同时让数据科学家自行掌控哪种表示最适合达成数据分析或模型训练的目标。
还有一些数据库专门存储大型多维数值数组,例如 TileDB [^66]。这类系统称为 *数组数据库*,最常用于科学数据集,例如地理空间测量数据(规则间隔网格上的栅格数据)、医学影像或天文望远镜的观测结果 [^67]。金融行业也用数据框表示 *时间序列数据*,例如资产价格以及按时间记录的交易 [^68]。
还有一些数据库专门存储大型多维数值数组,例如 TileDB [^66]。这类系统称为 *数组数据库**array database*,最常用于科学数据集,例如地理空间测量数据(规则间隔网格上的栅格数据)、医学影像或天文望远镜的观测结果 [^67]。金融行业也用数据框表示 *时间序列数据**time-series data*,例如资产价格以及按时间记录的交易 [^68]。
## 总结 {#summary}

View File

@@ -20,9 +20,9 @@ breadcrumbs: false
在 [第 3 章](/ch3#ch_datamodels) 中,我们讨论了数据模型和查询语言,即你将数据交给数据库时采用的格式,以及日后向数据库取回数据时使用的接口。在本章中,我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
作为应用开发者,为什么要关心数据库内部存储与检索的机理?你可能不会从头开始实现自己的存储引擎,但是你 *确实* 需要从许多可用的存储引擎中选择一个适合应用的。为了让存储引擎能在你的工作负载上运行良好,你也需要大致了解它在底层究竟做了什么。
作为应用开发者,为什么要关心数据库内部存储与检索的机理?你可能不会从头开始实现自己的 *存储引擎**storage engine*,但是你 *确实* 需要从许多可用的存储引擎中选择一个适合应用的。为了让存储引擎能在你的工作负载上运行良好,你也需要大致了解它在底层究竟做了什么。
尤其需要注意针对事务型工作负载OLTP优化的存储引擎与针对分析型工作负载优化的存储引擎之间存在巨大差异这种区别已在 [“分析型与事务型系统”](/ch1#sec_introduction_analytics) 中介绍)。本章首先考察 OLTP 存储引擎的两大类:写出不可变数据文件的 *日志结构* 存储引擎,以及像 *B 树* 这样就地更新数据的存储引擎。键值存储和二级索引都可以采用这两类结构。
尤其需要注意针对事务型工作负载OLTP优化的存储引擎与针对分析型工作负载优化的存储引擎之间存在巨大差异这种区别已在 [“分析型与事务型系统”](/ch1#sec_introduction_analytics) 中介绍)。本章首先考察 OLTP 存储引擎的两大类:写出不可变数据文件的 *日志结构**log-structured*存储引擎,以及像 *B 树* 这样就地更新数据的存储引擎。*键值存储**key-value store*)和 *二级索引**secondary index*都可以采用这两类结构。
稍后在 [“分析型数据存储”](/ch4#sec_storage_analytics) 中,我们会讨论一类针对分析优化的存储引擎;在 [“多维索引与全文索引”](/ch4#sec_storage_multidimensional) 中,还会简要介绍用于文本检索等复杂查询的索引。
@@ -70,7 +70,7 @@ $ cat database
```
`db_set` 函数对于如此简单的实现其实有着相当不错的性能,因为在文件末尾追加写入通常非常高效。与 `db_set` 所做的事情类似,许多数据库在内部使用 *日志*,也就是仅追加的数据文件。真正的数据库还要处理更多问题(例如并发写入、回收磁盘空间以免日志无限增长,以及崩溃恢复时处理只写了一部分的记录),但基本原理是一样的。日志极其有用,我们还会在本书中多次遇到它。
`db_set` 函数对于如此简单的实现其实有着相当不错的性能,因为在文件末尾追加写入通常非常高效。与 `db_set` 所做的事情类似,许多数据库在内部使用 *日志**log*,也就是仅追加的数据文件。真正的数据库还要处理更多问题(例如并发写入、回收磁盘空间以免日志无限增长,以及崩溃恢复时处理只写了一部分的记录),但基本原理是一样的。日志极其有用,我们还会在本书中多次遇到它。
> [!NOTE]
@@ -80,7 +80,7 @@ $ cat database
另一方面,如果数据库中有大量记录,`db_get` 函数的性能就会非常糟糕。每次查找一个键,`db_get` 都必须从头到尾扫描整个数据库文件,寻找这个键。用算法的语言来说,查找开销是 *O*(*n*):如果数据库中的记录数 *n* 翻了一倍,查找时间也要翻一倍。这就不好了。
为了高效查找数据库中特定键的值,我们需要一种数据结构:*索引*。本章将介绍一系列索引结构,并比较它们之间的差异。索引背后的大致思想,是以某种特定方式组织数据(例如按某个键排序),从而更快地定位想要的数据。如果想以几种不同的方式搜索同一份数据,那么也许需要在数据的不同部分建立多个索引。
为了高效查找数据库中特定键的值,我们需要一种数据结构:*索引**index*。本章将介绍一系列索引结构,并比较它们之间的差异。索引背后的大致思想,是以某种特定方式组织数据(例如按某个键排序),从而更快地定位想要的数据。如果想以几种不同的方式搜索同一份数据,那么也许需要在数据的不同部分建立多个索引。
索引是从主数据衍生出的 *额外* 结构。许多数据库允许添加和删除索引,这不会影响数据库的内容,只会影响查询性能。维护额外结构会产生开销,特别是在写入时。写入性能很难超过简单地向文件末尾追加,因为追加是最简单的写入操作。任何类型的索引通常都会拖慢写入速度,因为每次写入数据时还必须更新索引。
@@ -107,7 +107,7 @@ $ cat database
{{< fig num="4-2" id="fig_storage_sstable_index" src="/fig/ddia_0402.png" caption="带有稀疏索引的 SSTable查询可以直接跳到正确的数据块。" class="ddia-figure ddia-figure--wide" width="2658" height="1229" />}}
这样便不必在内存中保留所有键。可以把 SSTable 中的键值对分成若干个几千字节大小的 **,索引只存储每个块的第一个键。这种只收录部分键的索引称为 *稀疏索引*。索引保存在 SSTable 的一个独立区域中,可以采用不可变 B 树、字典树或其他能快速查找特定键的数据结构 [^4]。
这样便不必在内存中保留所有键。可以把 SSTable 中的键值对分成若干个几千字节大小的 ***block*,索引只存储每个块的第一个键。这种只收录部分键的索引称为 *稀疏索引**sparse index*。索引保存在 SSTable 的一个独立区域中,可以采用不可变 B 树、字典树或其他能快速查找特定键的数据结构 [^4]。
以 {{< xref fig="4-2" page="/ch4" anchor="fig_storage_sstable_index" >}}图 4-2{{< /xref >}} 为例,一个块的第一个键是 `handbag`,下一个块的第一个键是 `handsome`。假设要查找没有出现在稀疏索引中的 `handiwork`。根据排序关系可知,`handiwork` 必定在 `handbag``handsome` 之间。因此,可以寻道至 `handbag` 的偏移量,再从那里开始扫描文件,直到找到 `handiwork`;如果一直扫到下一个块仍未找到,就说明文件中没有这个键。几千字节的数据块很快就能扫描完。
@@ -120,7 +120,7 @@ SSTable 文件格式比仅追加日志更利于读取,却让写入变得困难
解决办法是采用 *日志结构* 方法,将仅追加日志与排序文件结合起来:
1. 收到写入时,将其加入内存中的有序映射数据结构,例如红黑树、跳表 [^5] 或字典树 [^6]。这类数据结构可以按任意顺序插入键、高效查找键,并按排序顺序读出键。这个内存数据结构称为 *内存表**memtable*)。
2. 当内存表超过某个阈值(通常为几兆字节)时,按排序顺序将它写成磁盘上的 SSTable 文件。这个新的 SSTable 文件称为数据库最新的 **它与较旧的段分别存放在独立文件中每个段都有自己的索引。向磁盘写出新段期间数据库可以继续向新的内存表实例写入SSTable 写完后,旧内存表占用的内存即可释放。
2. 当内存表超过某个阈值(通常为几兆字节)时,按排序顺序将它写成磁盘上的 SSTable 文件。这个新的 SSTable 文件称为数据库最新的 ***segment*它与较旧的段分别存放在独立文件中每个段都有自己的索引。向磁盘写出新段期间数据库可以继续向新的内存表实例写入SSTable 写完后,旧内存表占用的内存即可释放。
3. 读取某个键的值时,先在内存表和磁盘上最新的段中查找。如果没有找到,就依次查看更旧的段,直到找到这个键或查完最旧的段。如果任何段中都没有这个键,它就不存在于数据库中。
4. 后台不时运行合并与压实过程,将段文件合并起来,并丢弃已经覆盖或删除的值。
@@ -182,7 +182,7 @@ LSM 存储的一项重要设计细节,是何时执行压实,以及每次压
> [!TIP] 嵌入式存储引擎
>
> 许多数据库以服务形式运行,通过网络接收查询;但也有一些 *嵌入式* 数据库并不提供网络 API。它们是与应用代码运行在同一进程中的库通常读写本地磁盘上的文件应用则通过普通函数调用与之交互。RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB 都是嵌入式存储引擎 [^19]。
> 许多数据库以服务形式运行,通过网络接收查询;但也有一些 *嵌入式**embedded*数据库并不提供网络 API。它们是与应用代码运行在同一进程中的库通常读写本地磁盘上的文件应用则通过普通函数调用与之交互。RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB 都是嵌入式存储引擎 [^19]。
>
> 嵌入式数据库在移动应用中十分常见,可用于保存本地用户的数据。在后端,如果数据小到单机足以容纳,并发事务又不多,嵌入式数据库也可能是合适的选择。例如在多租户系统中,如果每个租户的数据量都很小,且彼此完全隔离(即不需要查询多个租户的合并数据),就可以考虑为每个租户分别运行一个嵌入式数据库实例 [^20]。
>
@@ -196,17 +196,17 @@ B 树自 1970 年问世 [^21],不到 10 年就被称为“无处不在”[^22]
与 SSTable 一样B 树按键保存有序的键值对因而能够高效地查找键值和执行范围查询。但相似之处也到此为止B 树有着截然不同的设计理念。
前面看到的日志结构索引把数据库分成大小可变的 **通常每段为几兆字节或更大段只写入一次此后便不可变。相比之下B 树把数据库分成大小固定的 ****,并允许就地覆盖页。传统的页大小是 4 KiB不过 PostgreSQL 目前默认使用 8 KiBMySQL 默认使用 16 KiB。
前面看到的日志结构索引把数据库分成大小可变的 ***segment*通常每段为几兆字节或更大段只写入一次此后便不可变。相比之下B 树把数据库分成大小固定的 ****,并允许就地覆盖页。传统的页大小是 4 KiB不过 PostgreSQL 目前默认使用 8 KiBMySQL 默认使用 16 KiB。
每一页都有页号作为标识,因此一页可以引用另一页——类似于指针,只不过位于磁盘而非内存。如果所有页都保存在同一个文件中,页号乘以页大小,就是该页在文件中的字节偏移量。利用这些页引用可以构造一棵页组成的树,如 {{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}图 4-5{{< /xref >}} 所示。
{{< fig num="4-5" id="fig_storage_b_tree" src="/fig/ddia_0405.png" caption="使用 B 树索引查找键 251。先从根页沿引用进入键 200300 所在的页,再进入键 250270 所在的页。" class="ddia-figure ddia-figure--wide" width="2658" height="1472" />}}
其中一页被指定为 B 树的 **;在索引中查找键时,总是从这里开始。根页包含若干个键和对子页的引用。每个子页负责一段连续的键范围,引用之间的键标示出相邻范围的边界。(这种结构有时称为 B+ 树,不过这里不必把它与其他 B 树变体区分开来。)
其中一页被指定为 B 树的 ***root*;在索引中查找键时,总是从这里开始。根页包含若干个键和对子页的引用。每个子页负责一段连续的键范围,引用之间的键标示出相邻范围的边界。(这种结构有时称为 B+ 树,不过这里不必把它与其他 B 树变体区分开来。)
在 {{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}图 4-5{{< /xref >}} 的例子中,我们要寻找键 251因此沿着边界 200 与 300 之间的页引用向下走。接下来的一页结构相似,只是把 200300 进一步划分成更小的子范围。最终会到达包含各个键的 *叶页*;叶页或者直接保存每个键的值,或者保存指向值所在页的引用。
在 {{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}图 4-5{{< /xref >}} 的例子中,我们要寻找键 251因此沿着边界 200 与 300 之间的页引用向下走。接下来的一页结构相似,只是把 200300 进一步划分成更小的子范围。最终会到达包含各个键的 *叶页**leaf page*;叶页或者直接保存每个键的值,或者保存指向值所在页的引用。
B 树一页中对子页的引用数称为 *分支因子*。例如,{{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}图 4-5{{< /xref >}} 中的分支因子为 6。实践中的分支因子取决于页引用和范围边界所需的空间不过通常可达几百。
B 树一页中对子页的引用数称为 *分支因子**branching factor*。例如,{{< xref fig="4-5" page="/ch4" anchor="fig_storage_b_tree" >}}图 4-5{{< /xref >}} 中的分支因子为 6。实践中的分支因子取决于页引用和范围边界所需的空间不过通常可达几百。
如果要更新 B 树中已有键的值,就先找到包含该键的叶页,再用含有新值的版本覆盖磁盘上的这一页。如果要添加新键,则找到范围涵盖该键的页,并把键加入其中。如果页内没有足够的空闲空间容纳新键,就把它拆成两个半满的页,并更新父页,以反映键范围的新划分。
@@ -214,13 +214,13 @@ B 树一页中对子页的引用数称为 *分支因子*。例如,{{< xref fig
在 {{< xref fig="4-6" page="/ch4" anchor="fig_storage_b_tree_split" >}}图 4-6{{< /xref >}} 中,我们想插入键 334但负责 333345 范围的页已经装满。于是把它拆成两页:一页负责 333337并包含新键另一页负责 337344。父页也必须更新增加对两个子页的引用并以 337 作为二者的边界。如果父页容不下新的引用,它也要拆分;这种拆分可能一路向上传播到树根。根页拆分时,则在其上方创建一个新根。删除键还可能需要合并节点,处理起来更加复杂 [^5]。
这个算法可以确保树始终 *平衡*:包含 *n* 个键的 B 树深度总是 *O*(log *n*)。大多数数据库只需要三四层深的 B 树,因此不必沿着很多页引用就能找到目标页。(一棵四层深、页大小为 4 KiB、分支因子为 500 的树,最多可以存储 250 TB 数据。)
这个算法可以确保树始终 *平衡**balanced*:包含 *n* 个键的 B 树深度总是 *O*(log *n*)。大多数数据库只需要三四层深的 B 树,因此不必沿着很多页引用就能找到目标页。(一棵四层深、页大小为 4 KiB、分支因子为 500 的树,最多可以存储 250 TB 数据。)
#### 使 B 树可靠 {#sec_storage_btree_wal}
B 树最基本的底层写操作,是用新数据覆写磁盘上的页,并假定覆写不会改变页的位置:也就是说,页被覆写后,所有指向它的引用仍然有效。这与 LSM 树一类日志结构索引形成鲜明对比;后者只向文件追加写入(并最终删除过时文件),从不就地修改文件。
一次覆写多个页——例如拆分页时——是很危险的操作。如果数据库只写完其中一部分就崩溃,最终会留下损坏的树(例如出现不属于任何父页的 *孤儿页*)。如果硬件不能原子地写入整页,还可能留下只写了一部分的页,这称为 *页撕裂**torn page*[^23]。
一次覆写多个页——例如拆分页时——是很危险的操作。如果数据库只写完其中一部分就崩溃,最终会留下损坏的树(例如出现不属于任何父页的 *孤儿页**orphan page*)。如果硬件不能原子地写入整页,还可能留下只写了一部分的页,这称为 *页撕裂**torn page*[^23]。
为了让数据库能够从崩溃中恢复B 树实现通常会在磁盘上维护一个额外的数据结构:*预写日志**write-ahead log*WAL。这是一个仅追加文件对 B 树的每项修改,都必须先写入 WAL才能应用到树本身的页上。数据库在崩溃后重新启动时会用这个日志把 B 树恢复到一致状态 [^2] [^24]。文件系统中的对应机制称为 *日志机制**journaling*)。
@@ -245,7 +245,7 @@ B 树最基本的底层写操作,是用新数据覆写磁盘上的页,并假
B 树本身有序因此范围查询简单而快速。LSM 存储也能利用 SSTable 的排序,但必须并行扫描所有段,再把结果合并起来。布隆过滤器对范围查询无能为力,因为不可能计算范围内每个潜在键的哈希;所以在 LSM 存储中,范围查询的成本高于点查询 [^29]。
在日志结构存储引擎中,高写入吞吐量可能在内存表填满时引发延迟尖峰。如果数据来不及写入磁盘——或许因为压实速度赶不上新增写入——就会出现这种情况。包括 RocksDB 在内的许多存储引擎会在此时施加 *背压*:暂停所有读写,直到内存表写入磁盘 [^30] [^31]。
在日志结构存储引擎中,高写入吞吐量可能在内存表填满时引发延迟尖峰。如果数据来不及写入磁盘——或许因为压实速度赶不上新增写入——就会出现这种情况。包括 RocksDB 在内的许多存储引擎会在此时施加 *背压**backpressure*:暂停所有读写,直到内存表写入磁盘 [^30] [^31]。
至于读取吞吐量,现代 SSD尤其是 NVMe可以并行处理许多相互独立的读取请求。LSM 树和 B 树都能提供很高的读取吞吐量,但存储引擎必须经过精心设计,才能利用这种并行能力 [^32]。
@@ -253,7 +253,7 @@ B 树本身有序因此范围查询简单而快速。LSM 存储也能利用 S
使用 B 树时,如果应用写入的键散布在整个键空间,产生的磁盘操作也会四处分散,因为存储引擎要覆写的页可能位于磁盘任何位置。日志结构存储引擎则一次写出整个段文件——无论是把内存表写出,还是压实现有段——其大小远远超过 B 树中的一页。
这种数量多、规模小而位置分散的写入模式(如 B 树)称为 *随机写入*;数量少、规模较大的写入模式(如 LSM 树)则称为 *顺序写入*。磁盘的顺序写入吞吐量通常高于随机写入,因此在相同硬件上,日志结构存储引擎一般能承受比 B 树更高的写入吞吐量。这种差异在机械硬盘HDD上尤其显著如今多数数据库使用固态硬盘SSD差距有所缩小但依然不可忽略参见 [“SSD 上的顺序与随机写入”](/ch4#sidebar_sequential))。
这种数量多、规模小而位置分散的写入模式(如 B 树)称为 *随机写入**random write*;数量少、规模较大的写入模式(如 LSM 树)则称为 *顺序写入**sequential write*。磁盘的顺序写入吞吐量通常高于随机写入,因此在相同硬件上,日志结构存储引擎一般能承受比 B 树更高的写入吞吐量。这种差异在机械硬盘HDD上尤其显著如今多数数据库使用固态硬盘SSD差距有所缩小但依然不可忽略参见 [“SSD 上的顺序与随机写入”](/ch4#sidebar_sequential))。
> [!TIP] SSD 上的顺序与随机写入
>
@@ -271,7 +271,7 @@ B 树本身有序因此范围查询简单而快速。LSM 存储也能利用 S
B 树索引也必须把每份数据至少写两次:一次写入预写日志,一次写入树页本身。为了确保 B 树能在崩溃或断电后正确恢复,有时即使页内只有几个字节发生变化,也必须写出整页 [^38] [^39]。
把某个工作负载实际写入磁盘的总字节数,除以不带索引、只写仅追加日志时所需的字节数,得到的比值就是 *写放大*。(写放大有时也按 I/O 操作次数而非字节数定义。)在写入密集型应用中,瓶颈可能是数据库向磁盘写入的速度。此时写放大越高,在有限磁盘带宽内每秒能处理的写入就越少。
把某个工作负载实际写入磁盘的总字节数,除以不带索引、只写仅追加日志时所需的字节数,得到的比值就是 *写放大**write amplification*。(写放大有时也按 I/O 操作次数而非字节数定义。)在写入密集型应用中,瓶颈可能是数据库向磁盘写入的速度。此时写放大越高,在有限磁盘带宽内每秒能处理的写入就越少。
LSM 树和 B 树都有写放大问题。孰优孰劣取决于许多因素例如键和值的长度以及覆盖现有键与插入新键各有多频繁。对典型工作负载而言LSM 树往往具有更低的写放大,因为它不必写出整页,还可以压缩 SSTable 中的数据块 [^40]。这也是 LSM 存储引擎适合写入密集型工作负载的原因之一。
@@ -303,8 +303,8 @@ B 树可能随着时间推移逐渐 *碎片化*。例如,删除大量键之后
索引中的键是查询要搜索的内容,而值可以采用以下几种形式:
* 如果实际数据(行、文档或顶点)直接存储在索引结构中,这就是 *聚簇索引*。例如MySQL 的 InnoDB 存储引擎总是把表的主键作为聚簇索引SQL Server 则允许每张表指定一个聚簇索引 [^43]。
* 另一种选择是让值引用实际数据它可以是相应行的主键InnoDB 的二级索引便是如此),也可以直接引用磁盘上的位置。后一种情况下,存放行的地方称为 *堆文件*堆文件中的数据没有特定顺序可以是仅追加的也可以记录已删除的行以便日后用新数据覆写。例如Postgres 就使用堆文件 [^44]。
* 两者之间的折中称为 *覆盖索引**包含列的索引*:完整的行仍保存在堆文件或主键聚簇索引中,但索引也会保存表的 *部分* 列 [^45]。这样,一些查询只用索引就能得到答案,不必再解析主键或访问堆文件;此时称该索引 *覆盖* 了查询。覆盖索引可以加快某些查询,但重复数据会占用更多磁盘空间,也会拖慢写入。
* 另一种选择是让值引用实际数据它可以是相应行的主键InnoDB 的二级索引便是如此),也可以直接引用磁盘上的位置。后一种情况下,存放行的地方称为 *堆文件**heap file*堆文件中的数据没有特定顺序可以是仅追加的也可以记录已删除的行以便日后用新数据覆写。例如Postgres 就使用堆文件 [^44]。
* 两者之间的折中称为 *覆盖索引**covering index*)或 *包含列的索引**index with included columns*:完整的行仍保存在堆文件或主键聚簇索引中,但索引也会保存表的 *部分* 列 [^45]。这样,一些查询只用索引就能得到答案,不必再解析主键或访问堆文件;此时称该索引 *覆盖* 了查询。覆盖索引可以加快某些查询,但重复数据会占用更多磁盘空间,也会拖慢写入。
到目前为止讨论的索引,都只是把单个键映射到值。如果需要同时查询表中的多个列(或文档中的多个字段),请参见 [“多维索引与全文索引”](/ch4#sec_storage_multidimensional)。
@@ -381,11 +381,11 @@ GROUP BY
怎样才能高效执行这个查询?
大多数 OLTP 数据库都以 *面向行* 的方式布置存储:表中同一行的所有值相邻存放。文档数据库也很相似,通常把整个文档存成一段连续的字节序列。{{< xref fig="4-1" page="/ch4" anchor="fig_storage_csv_hash_index" >}}图 4-1{{< /xref >}} 的 CSV 示例就是如此。
大多数 OLTP 数据库都以 *面向行**row-oriented*的方式布置存储:表中同一行的所有值相邻存放。文档数据库也很相似,通常把整个文档存成一段连续的字节序列。{{< xref fig="4-1" page="/ch4" anchor="fig_storage_csv_hash_index" >}}图 4-1{{< /xref >}} 的 CSV 示例就是如此。
为了处理 {{< xref eg="4-1" page="/ch4" anchor="fig_storage_analytics_query" >}}示例 4-1{{< /xref >}} 这样的查询,可以在 `fact_sales.date_key` 和(或)`fact_sales.product_sk` 上建立索引,告诉存储引擎去哪里寻找某一天或某种产品的所有销售记录。但面向行的存储引擎仍要把这些完整的行(每行有 100 多个属性)从磁盘载入内存,逐一解析,再过滤掉不满足条件的行。这个过程可能十分耗时。
*面向列*(或 *列式*)存储背后的想法很简单:不要把同一行中的所有值放在一起,而要把同一 ** 中的所有值放在一起 [^56]。每列分别存储后,查询只需读取和解析自己用到的列,能省下大量工作。{{< xref fig="4-7" page="/ch4" anchor="fig_column_store" >}}图 4-7{{< /xref >}} 用 {{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}图 3-5{{< /xref >}} 中事实表的扩展版本展示了这一原理。
*面向列**column-oriented**列式**columnar*)存储背后的想法很简单:不要把同一行中的所有值放在一起,而要把同一 ** 中的所有值放在一起 [^56]。每列分别存储后,查询只需读取和解析自己用到的列,能省下大量工作。{{< xref fig="4-7" page="/ch4" anchor="fig_column_store" >}}图 4-7{{< /xref >}} 用 {{< xref fig="3-5" page="/ch3" anchor="fig_dwh_schema" >}}图 3-5{{< /xref >}} 中事实表的扩展版本展示了这一原理。
> [!NOTE]
@@ -424,7 +424,7 @@ GROUP BY
> [!NOTE]
> 不要把列式数据库与 *宽列*又称 *列族*)数据模型混为一谈。宽列模型的一行可以有数千列,各行也不必拥有相同的列 [^9]。尽管名字相似宽列数据库其实是面向行的因为它会把同一行的所有值存放在一起。Google Bigtable、Apache Accumulo 和 HBase 都属于宽列模型。
> 不要把列式数据库与 *宽列**wide-column*,又称 *列族**column-family*)数据模型混为一谈。宽列模型的一行可以有数千列,各行也不必拥有相同的列 [^9]。尽管名字相似宽列数据库其实是面向行的因为它会把同一行的所有值存放在一起。Google Bigtable、Apache Accumulo 和 HBase 都属于宽列模型。
#### 列存储中的排序顺序 {#sort-order-in-column-storage}
@@ -454,16 +454,16 @@ GROUP BY
### 查询执行:编译与向量化 {#sec_storage_vectorized}
复杂的分析型 SQL 查询会被分解成一个 *查询计划*,其中包含多个称为 *算子* 的执行阶段;这些算子可能分布到多台机器上并行执行。查询规划器可以决定选用哪些算子、以什么顺序执行,以及每个算子在哪里运行,从而完成大量优化。
复杂的分析型 SQL 查询会被分解成一个 *查询计划**query plan*,其中包含多个称为 *算子**operator*的执行阶段;这些算子可能分布到多台机器上并行执行。查询规划器可以决定选用哪些算子、以什么顺序执行,以及每个算子在哪里运行,从而完成大量优化。
在每个算子内部,查询引擎都要对列中的值执行各种操作,例如找出值属于某个集合的所有行(或许是连接的一部分),或者判断值是否大于 15。查询引擎还要同时查看同一行的多个列例如找出产品是香蕉、门店又恰好是目标门店的所有销售记录。
数据仓库查询需要扫描数百万行,因此不仅要关注从磁盘读取的数据量,还要关注执行复杂算子所需的 CPU 时间。最简单的算子就像编程语言解释器:遍历每一行时,查看表示查询的数据结构,弄清要对哪些列做什么比较或计算。遗憾的是,这种方式对许多分析场景来说太慢。实践中出现了两种高效执行查询的方法 [^77]
查询编译
查询编译query compilation
: 查询引擎根据 SQL 查询生成执行代码。代码逐行迭代,读取相关列中的值,完成所需的比较或计算;如果条件满足,就把必要的值复制到输出缓冲区。随后,查询引擎把生成的代码编译成机器码(往往借助 LLVM 等现有编译器),再对已经载入内存的列编码数据运行。这种代码生成方式类似 Java 虚拟机JVM等运行时采用的即时JIT编译。
向量化处理
向量化处理vectorized processing
: 查询仍然采用解释执行,而非编译执行;但它不再逐行迭代,而是成批处理一列中的许多值,从而提高速度。数据库内置一组固定的预定义算子,向算子传入参数,就会得到一批结果 [^50] [^75]。
例如,把 `product_sk` 列和“香蕉”的 ID 传给相等比较算子,会得到一张位图:输入列的每个值对应一位,是香蕉则为 1。再把 `store_sk` 列和目标门店的 ID 传给同一个算子,得到另一张位图。最后把两张位图传给“按位与”算子,如 {{< xref fig="4-9" page="/ch4" anchor="fig_bitmap_and" >}}图 4-9{{< /xref >}} 所示。结果位图中,特定门店售出的每一笔香蕉都对应一个 1。
@@ -479,11 +479,11 @@ GROUP BY
### 物化视图与多维数据集 {#sec_storage_materialized_views}
我们曾在 [“时间线的物化与更新”](/ch2#sec_introduction_materializing) 中遇到 *物化视图*。在关系数据模型中它是一种类似表的对象内容是某个查询的结果。物化视图是实际写入磁盘的查询结果副本而虚拟视图只是编写查询的快捷方式。从虚拟视图读取时SQL 引擎会即时把它展开成底层查询,再处理展开后的查询。
我们曾在 [“时间线的物化与更新”](/ch2#sec_introduction_materializing) 中遇到 *物化视图**materialized view*。在关系数据模型中它是一种类似表的对象内容是某个查询的结果。物化视图是实际写入磁盘的查询结果副本而虚拟视图只是编写查询的快捷方式。从虚拟视图读取时SQL 引擎会即时把它展开成底层查询,再处理展开后的查询。
底层数据变化时物化视图也必须随之更新。有些数据库可以自动完成这项工作Materialize 等系统则专门负责维护物化视图 [^81]。更新视图会增加写入工作量,但如果工作负载反复执行相同查询,物化视图可以改善读取性能。
*物化聚合* 是一类对数据仓库很有用的物化视图。如前所述,数据仓库查询经常使用 SQL 中的 `COUNT``SUM``AVG``MIN``MAX` 等聚合函数。如果许多查询都使用相同的聚合,每次重新处理原始数据就太浪费了,何不把最常用的计数或总和缓存起来?*多维数据集*(或 *OLAP 多维数据集*)会创建一个按不同维度分组的聚合网格,正是为了实现这种缓存 [^82]。{{< xref fig="4-10" page="/ch4" anchor="fig_data_cube" >}}图 4-10{{< /xref >}} 展示了一个例子。
*物化聚合**materialized aggregate*是一类对数据仓库很有用的物化视图。如前所述,数据仓库查询经常使用 SQL 中的 `COUNT``SUM``AVG``MIN``MAX` 等聚合函数。如果许多查询都使用相同的聚合,每次重新处理原始数据就太浪费了,何不把最常用的计数或总和缓存起来?*多维数据集**data cube**OLAP 多维数据集**OLAP cube*)会创建一个按不同维度分组的聚合网格,正是为了实现这种缓存 [^82]。{{< xref fig="4-10" page="/ch4" anchor="fig_data_cube" >}}图 4-10{{< /xref >}} 展示了一个例子。
{{< fig num="4-10" id="fig_data_cube" src="/fig/ddia_0410.png" caption="多维数据集的两个维度,通过求和聚合数据。" class="ddia-figure ddia-figure--wide" width="2880" height="1484" />}}
@@ -500,9 +500,9 @@ GROUP BY
本章前半部分介绍的 B 树和 LSM 树,可以对单个属性执行范围查询。例如,如果键是用户名,就能用它们建立索引,高效找出所有以 L 开头的名字。但有时,只按一个属性搜索并不够用。
最常见的多列索引称为 *联合索引*。它把一列接在另一列之后,将多个字段组合成一个键;字段的连接顺序由索引定义指定。这就像老式纸质电话簿提供的索引:从(*姓*、*名*)映射到电话号码。由于索引按这个顺序排列,可以找出某个姓氏对应的所有人,也可以找出特定 *姓—名* 组合对应的所有人。但如果只想查找某个名字对应的所有人,这个索引就毫无用处。
最常见的多列索引称为 *联合索引**concatenated index*。它把一列接在另一列之后,将多个字段组合成一个键;字段的连接顺序由索引定义指定。这就像老式纸质电话簿提供的索引:从(*姓*、*名*)映射到电话号码。由于索引按这个顺序排列,可以找出某个姓氏对应的所有人,也可以找出特定 *姓—名* 组合对应的所有人。但如果只想查找某个名字对应的所有人,这个索引就毫无用处。
*多维索引* 则允许同时查询多个列,这对地理空间数据尤其重要。例如,餐厅搜索网站的数据库可能保存了每家餐厅的经纬度。用户查看地图时,网站需要找出当前矩形地图区域内的所有餐厅。这就需要下面这样的二维范围查询:
*多维索引**multidimensional index*则允许同时查询多个列,这对地理空间数据尤其重要。例如,餐厅搜索网站的数据库可能保存了每家餐厅的经纬度。用户查看地图时,网站需要找出当前矩形地图区域内的所有餐厅。这就需要下面这样的二维范围查询:
```sql
SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
@@ -517,11 +517,11 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
### 全文检索 {#sec_storage_full_text}
全文检索允许按关键词搜索一组文本文档(网页、产品描述等),关键词可以出现在文本中的任意位置 [^88]。信息检索是一门庞大而专门的学科,往往还要针对具体语言进行处理。例如,一些亚洲语言书写时不会在词与词之间添加空格或标点,因此把文本切分成词需要借助模型,判断哪些字符序列构成一个词。全文检索还经常需要匹配相似但不完全相同的词(例如拼写错误或同一个词的不同语法形式),以及同义词。这些问题都超出了本书的范围。
*全文检索**full-text search*允许按关键词搜索一组文本文档(网页、产品描述等),关键词可以出现在文本中的任意位置 [^88]。信息检索是一门庞大而专门的学科,往往还要针对具体语言进行处理。例如,一些亚洲语言书写时不会在词与词之间添加空格或标点,因此把文本切分成词需要借助模型,判断哪些字符序列构成一个词。全文检索还经常需要匹配相似但不完全相同的词(例如拼写错误或同一个词的不同语法形式),以及同义词。这些问题都超出了本书的范围。
不过从核心原理看,全文检索也可以视为一种多维查询:文本中可能出现的每个词(即一个 *词项*)都是一个维度。包含词项 *x* 的文档在维度 *x* 上取值为 1不包含 *x* 则取值为 0。搜索提到“红苹果”的文档就是同时寻找 ** 维度和 *苹果* 维度都为 1 的文档。这样一来,维度数可能非常庞大。
不过从核心原理看,全文检索也可以视为一种多维查询:文本中可能出现的每个词(即一个 *词项**term*)都是一个维度。包含词项 *x* 的文档在维度 *x* 上取值为 1不包含 *x* 则取值为 0。搜索提到“红苹果”的文档就是同时寻找 ** 维度和 *苹果* 维度都为 1 的文档。这样一来,维度数可能非常庞大。
许多搜索引擎使用 *倒排索引* 回答这类查询。它是一种键值结构:键是词项,值是所有包含该词项的文档 ID 列表,即 *倒排列表*。如果文档 ID 是连续数字,倒排列表也可以表示成 {{< xref fig="4-8" page="/ch4" anchor="fig_bitmap_index" >}}图 4-8{{< /xref >}} 那样的稀疏位图:如果 ID 为 *n* 的文档包含词项 *x*,那么词项 *x* 的位图中第 *n* 位就是 1 [^89]。
许多搜索引擎使用 *倒排索引**inverted index*回答这类查询。它是一种键值结构:键是词项,值是所有包含该词项的文档 ID 列表,即 *倒排列表**postings list*。如果文档 ID 是连续数字,倒排列表也可以表示成 {{< xref fig="4-8" page="/ch4" anchor="fig_bitmap_index" >}}图 4-8{{< /xref >}} 那样的稀疏位图:如果 ID 为 *n* 的文档包含词项 *x*,那么词项 *x* 的位图中第 *n* 位就是 1 [^89]。
现在,查找同时包含词项 *x**y* 的所有文档,就类似于用向量化数据仓库查询寻找满足两个条件的行({{< xref fig="4-9" page="/ch4" anchor="fig_bitmap_and" >}}图 4-9{{< /xref >}}):载入 *x**y* 对应的两张位图,再计算按位与。即使位图经过游程编码,这个操作也可以高效完成。
@@ -529,14 +529,14 @@ Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就采用这种方法
除了把文本切分成词,还可以找出所有长度为 *n* 的子串,称为 *n*-gram。例如字符串 `"hello"` 的 3-gram*n* = 3`"hel"``"ell"``"llo"`。如果为所有 3-gram 建立倒排索引,就能搜索任意长度至少为三个字符的子串。这样的索引甚至支持在搜索查询中使用正则表达式,缺点是体积相当大 [^94]。
为了应对文档或查询中的拼写错误Lucene 可以搜索与目标词相差一定编辑距离的词(编辑距离为 1表示增加、删除或替换了一个字母[^95]。它把词项集合存储成一个以键中字符为边的有限状态自动机,结构类似 *字典树* [^96];再将其转换成 *莱文斯坦自动机*,从而高效搜索给定编辑距离以内的词 [^97]。
为了应对文档或查询中的拼写错误Lucene 可以搜索与目标词相差一定编辑距离的词(编辑距离为 1表示增加、删除或替换了一个字母[^95]。它把词项集合存储成一个以键中字符为边的有限状态自动机,结构类似 *字典树**trie*[^96];再将其转换成 *莱文斯坦自动机**Levenshtein automaton*,从而高效搜索给定编辑距离以内的词 [^97]。
### 向量嵌入 {#id92}
语义搜索不只处理同义词和拼写错误,还试图理解文档表达的概念和用户的意图。例如,帮助中心有一页标题是“取消订阅”,那么用户搜索“如何关闭账户”或“终止合同”时也应当找到它:这些说法用词完全不同,意思却十分接近。
为了理解文档的语义,也就是它表达的含义,语义搜索索引会使用嵌入模型,把文档转换成由浮点数组成的向量,称为 *向量嵌入*。这个向量表示多维空间中的一个点,每个浮点数表示文档在某一维坐标轴上的位置。如果输入文档的语义相似,嵌入模型就会生成在多维空间中彼此接近的向量。
为了理解文档的语义,也就是它表达的含义,语义搜索索引会使用嵌入模型,把文档转换成由浮点数组成的向量,称为 *向量嵌入**vector embedding*。这个向量表示多维空间中的一个点,每个浮点数表示文档在某一维坐标轴上的位置。如果输入文档的语义相似,嵌入模型就会生成在多维空间中彼此接近的向量。
> [!NOTE]
@@ -547,7 +547,7 @@ Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就采用这种方法
实际的嵌入模型使用大得多的向量,往往包含 1,000 多个数字,但原理相同。我们不会试图理解每个数字各自代表什么;它们只是嵌入模型用来指向抽象多维空间中某个位置的方式。搜索引擎通过余弦相似度、欧几里得距离等距离函数衡量向量间的距离。余弦相似度计算两个向量夹角的余弦,判断它们有多接近;欧几里得距离则计算空间中两点间的直线距离。
Word2Vec [^98]、BERT [^99] 和 GPT [^100] 等许多早期嵌入模型都处理文本数据,通常以神经网络实现。后来,研究者又为视频、音频和图像创建了嵌入模型。近来,模型架构进一步走向 *多模态*:同一个模型可以为文本、图像等多种模态生成向量嵌入。
Word2Vec [^98]、BERT [^99] 和 GPT [^100] 等许多早期嵌入模型都处理文本数据,通常以神经网络实现。后来,研究者又为视频、音频和图像创建了嵌入模型。近来,模型架构进一步走向 *多模态**multimodal*:同一个模型可以为文本、图像等多种模态生成向量嵌入。
用户输入查询时,语义搜索引擎会把查询及其相关上下文(例如用户位置)交给嵌入模型,生成查询的向量嵌入。随后,搜索引擎还必须通过向量索引,找出向量嵌入与查询相似的文档。
@@ -557,7 +557,7 @@ Word2Vec [^98]、BERT [^99] 和 GPT [^100] 等许多早期嵌入模型都处理
: 向量原样保存在索引中。查询必须读取每个向量,并测量它与查询向量的距离。平面索引结果精确,但逐一计算查询与每个向量的距离很慢。
倒排文件IVF索引
: 把向量空间聚类成若干向量分区,以减少必须比较的向量数;这些分区称为 *质心*。IVF 索引比平面索引更快,却只能给出近似结果:查询向量和某个文档向量可能十分接近,却恰好落入不同分区。查询 IVF 索引时,首先要指定 *探测数*probes也就是需要检查多少个分区。探测数越大查询越准确但也越慢因为必须比较更多向量。
: 把向量空间聚类成若干向量分区,以减少必须比较的向量数;这些分区称为 *质心**centroid*。IVF 索引比平面索引更快,却只能给出近似结果:查询向量和某个文档向量可能十分接近,却恰好落入不同分区。查询 IVF 索引时,首先要指定 *探测数*probes也就是需要检查多少个分区。探测数越大查询越准确但也越慢因为必须比较更多向量。
分层可导航小世界HNSW
: HNSW 索引维护向量空间的多个层级,如 {{< xref fig="4-11" page="/ch4" anchor="fig_vector_hnsw" >}}图 4-11{{< /xref >}} 所示。每层都表示成一张图:节点代表向量,边表示向量彼此接近。查询先在节点很少的最顶层找到最近向量,再进入下一层的同一节点;下一层连接更密集,查询沿边寻找更接近查询向量的向量。这个过程不断重复,直至最底层。与 IVF 一样HNSW 也是近似索引。

View File

@@ -16,7 +16,7 @@ breadcrumbs: false
>
> 以弗所的赫拉克利特,引自柏拉图《克拉提鲁斯》(公元前 360 年)
应用程序不可避免地会随时间而变化。随着新产品推出、对用户需求的理解日益深入,或者商业环境发生变化,应用程序总要增添或修改功能。在 [第 2 章](/ch2#ch_nonfunctional) 中,我们介绍了 *可演化性* 的概念:应该尽力构建能够灵活适应变化的系统(参见[“可演化性:让变化更容易”](/ch2#sec_introduction_evolvability))。
应用程序不可避免地会随时间而变化。随着新产品推出、对用户需求的理解日益深入,或者商业环境发生变化,应用程序总要增添或修改功能。在 [第 2 章](/ch2#ch_nonfunctional) 中,我们介绍了 *可演化性**evolvability*的概念:应该尽力构建能够灵活适应变化的系统(参见[“可演化性:让变化更容易”](/ch2#sec_introduction_evolvability))。
在大多数情况下,修改应用程序的功能也意味着需要更改其存储的数据:可能需要记录新的字段或记录类型,也可能需要以新的方式呈现现有数据。
@@ -24,15 +24,15 @@ breadcrumbs: false
当数据格式或模式发生变化时,通常也需要相应地修改应用程序代码(例如,为记录添加新字段,然后让应用程序开始读写该字段)。但在大型应用程序中,代码变更往往无法瞬间完成:
* 对于服务端应用程序,可能需要执行 *滚动升级*也称为 *逐步发布*):每次只把新版本部署到少数几个节点,确认运行正常后,再逐步部署到所有节点。这样无需中断服务即可上线新版本,有利于更频繁地发布,也让系统更容易演化。
* 对于服务端应用程序,可能需要执行 *滚动升级**rolling upgrade*,也称为 *逐步发布**staged rollout*):每次只把新版本部署到少数几个节点,确认运行正常后,再逐步部署到所有节点。这样无需中断服务即可上线新版本,有利于更频繁地发布,也让系统更容易演化。
* 对于客户端应用程序,是否升级只能任由用户决定,而用户可能很长时间都不安装更新。
这意味着,新旧版本的代码以及新旧数据格式,可能同时存在于系统中。系统要继续顺利运行,就需要保持双向兼容:
向后兼容
向后兼容backward compatibility
: 较新的代码可以读取由较旧代码写入的数据。
向前兼容
向前兼容forward compatibility
: 较旧的代码可以读取由较新代码写入的数据。
向后兼容通常不难实现:新代码的作者知道旧代码写入的数据格式,因此可以显式地处理它(必要时,只要保留读取旧数据的旧代码即可)。向前兼容则可能棘手得多,因为旧代码必须忽略新版本代码新增的部分。
@@ -50,7 +50,7 @@ breadcrumbs: false
1. 在内存中,数据保存在对象、结构体、列表、数组、哈希表、树等数据结构中。这些结构通常使用指针,针对 CPU 的高效访问与操作进行了优化。
2. 如果要将数据写入文件或通过网络发送,就必须将其编码成某种自包含的字节序列(例如 JSON 文档)。由于指针对其他进程没有意义,这种字节序列表示通常与内存中的数据结构大不相同。
因此,需要在两种表示之间进行转换。从内存表示转换为字节序列,称为 *编码*(也称为 *序列化**编组*);反过来则称为 *解码*(也称为 *解析*、*反序列化* 或 *解组*)。
因此,需要在两种表示之间进行转换。从内存表示转换为字节序列,称为 *编码**encoding*也称为 *序列化**serialization*,或 *编组**marshalling*);反过来则称为 *解码**decoding*也称为 *解析**parsing**反序列化**deserialization*,或 *解组**unmarshalling*)。
> [!TIP] 术语冲突
@@ -58,7 +58,7 @@ breadcrumbs: false
> 遗憾的是,*序列化* 一词也用于事务的语境,而且含义完全不同(参见 [第 8 章](/ch8#ch_transactions))。虽然“序列化”可能更常用,但为了避免一词多义,本书在这里始终使用 *编码*。
也有一些情况不需要编码和解码。例如,[“查询执行:编译与向量化”](/ch4#sec_storage_vectorized) 中介绍过,数据库可以直接操作从磁盘加载的压缩数据。还有一些 *零拷贝* 数据格式,例如 Capn Proto 和 FlatBuffers它们既可用于运行时也可直接用于磁盘或网络上的数据无需显式的转换步骤。
也有一些情况不需要编码和解码。例如,[“查询执行:编译与向量化”](/ch4#sec_storage_vectorized) 中介绍过,数据库可以直接操作从磁盘加载的压缩数据。还有一些 *零拷贝**zero-copy*数据格式,例如 Capn Proto 和 FlatBuffers它们既可用于运行时也可直接用于磁盘或网络上的数据无需显式的转换步骤。
不过,大多数系统仍需在内存对象与扁平的字节序列之间转换。这是个极其常见的问题,因而有数不清的库和编码格式可供选择。下面先来简要概览一下。
@@ -173,7 +173,7 @@ Protocol Buffers 自带代码生成工具。它接收上述模式定义,生成
与 {{< xref fig="5-2" page="/ch5" anchor="fig_encoding_messagepack" >}}图 5-2{{< /xref >}} 类似每个字段都有类型注解用来说明它是字符串、整数还是其他类型必要时还会给出长度例如字符串长度。数据中的字符串“Martin”“daydreaming”“hacking”也和之前一样编码为 ASCII——准确地说是 UTF-8。
与 {{< xref fig="5-2" page="/ch5" anchor="fig_encoding_messagepack" >}}图 5-2{{< /xref >}} 相比,最大的区别在于这里没有字段名(`userName``favoriteNumber``interests`)。编码数据包含的是数字形式的 *字段标签*`1``2``3`),也就是模式定义中的那些数字。字段标签好比字段的别名:无需写出字段名,就能以紧凑的方式指出所说的是哪个字段。
与 {{< xref fig="5-2" page="/ch5" anchor="fig_encoding_messagepack" >}}图 5-2{{< /xref >}} 相比,最大的区别在于这里没有字段名(`userName``favoriteNumber``interests`)。编码数据包含的是数字形式的 *字段标签**field tag*`1``2``3`),也就是模式定义中的那些数字。字段标签好比字段的别名:无需写出字段名,就能以紧凑的方式指出所说的是哪个字段。
Protocol Buffers 把字段类型和标签号塞进同一个字节,进一步节省了空间。它还使用变长整数:数字 1337 编码成两个字节,每个字节的最高位表示后面是否还有更多字节。这样,-64 到 63 之间的数字用一个字节编码,-8192 到 8191 之间的数字用两个字节编码,依此类推;数字越大,占用的字节就越多。
@@ -181,7 +181,7 @@ Protocol Buffers 没有显式的列表或数组数据类型。`interests` 字段
#### 字段标签与模式演化 {#field-tags-and-schema-evolution}
前面说过,模式不可避免地会随时间改变,这称为 *模式演化*。Protocol Buffers 如何在保持向后和向前兼容的同时处理模式变更?
前面说过,模式不可避免地会随时间改变,这称为 *模式演化**schema evolution*。Protocol Buffers 如何在保持向后和向前兼容的同时处理模式变更?
从示例可以看出,一条编码后的记录,就是各个已编码字段的拼接。每个字段由标签号(示例模式中的 `1``2``3`)标识,并带有数据类型注解(例如字符串或整数)。如果某个字段没有值,就直接从编码记录中省略。由此可见,字段标签对编码数据的含义至关重要。模式中的字段名可以修改,因为编码数据从不引用字段名;但字段标签不能修改,否则现有的所有编码数据都会失效。
@@ -236,9 +236,9 @@ record Person {
#### 写入者模式与读取者模式 {#the-writers-schema-and-the-readers-schema}
当应用程序要编码数据——例如写入文件或数据库,或者通过网络发送——它会使用自己所知版本的模式;这个模式可能已经编译进应用程序。这称为 *写入者模式*
当应用程序要编码数据——例如写入文件或数据库,或者通过网络发送——它会使用自己所知版本的模式;这个模式可能已经编译进应用程序。这称为 *写入者模式**writers schema*
当应用程序要解码数据——例如从文件或数据库读取,或者从网络接收——它会使用两个模式:一个是与编码时完全相同的写入者模式,另一个是可能有所不同的 *读取者模式*,如 {{< xref fig="5-5" page="/ch5" anchor="fig_encoding_avro_schemas" >}}图 5-5{{< /xref >}} 所示。读取者模式定义了应用程序代码期望每条记录包含哪些字段,以及这些字段的类型。
当应用程序要解码数据——例如从文件或数据库读取,或者从网络接收——它会使用两个模式:一个是与编码时完全相同的写入者模式,另一个是可能有所不同的 *读取者模式**readers schema*,如 {{< xref fig="5-5" page="/ch5" anchor="fig_encoding_avro_schemas" >}}图 5-5{{< /xref >}} 所示。读取者模式定义了应用程序代码期望每条记录包含哪些字段,以及这些字段的类型。
{{< fig num="5-5" id="fig_encoding_avro_schemas" src="/fig/ddia_0505.png" caption="Protocol Buffers 的编码与解码可以使用不同版本的模式。Avro 解码时使用两个模式:写入者模式必须与编码时所用模式完全相同,读取者模式则可以是较旧或较新的版本。" class="ddia-figure ddia-figure--wide" width="2658" height="1269" />}}
@@ -256,7 +256,7 @@ record Person {
如果新增的字段没有默认值,新读取者就无法读取旧写入者产生的数据,因而破坏向后兼容。如果删除的字段没有默认值,旧读取者就无法读取新写入者产生的数据,因而破坏向前兼容。
在某些编程语言中,任何变量都可以默认取 `null`Avro 却并非如此:如果希望字段允许为 `null`,就必须使用 *联合类型*。例如,`union { null, long, string } field;` 表示 `field` 可以是数字、字符串或 `null`。而且,只有当 `null` 是联合类型的第一个分支时,才能把它用作默认值。这种写法比默认所有内容都可为 `null` 略显冗长,却明确说明了什么可以、什么不可以为 `null`,从而有助于避免错误 [^18]。
在某些编程语言中,任何变量都可以默认取 `null`Avro 却并非如此:如果希望字段允许为 `null`,就必须使用 *联合类型**union type*。例如,`union { null, long, string } field;` 表示 `field` 可以是数字、字符串或 `null`。而且,只有当 `null` 是联合类型的第一个分支时,才能把它用作默认值。这种写法比默认所有内容都可为 `null` 略显冗长,却明确说明了什么可以、什么不可以为 `null`,从而有助于避免错误 [^18]。
只要 Avro 能完成相应的类型转换,就可以更改字段的数据类型。字段名也能更改,不过稍微麻烦一些:读取者模式可以为字段名声明别名,从而让旧写入者模式中的字段名与别名匹配。因此,更改字段名向后兼容,却不向前兼容。同样,给联合类型增加一个分支向后兼容,却不向前兼容。
@@ -351,7 +351,7 @@ record Person {
### 流经服务的数据流REST 与 RPC {#sec_encoding_dataflow_rpc}
当多个进程需要通过网络通信时,可以采用几种不同的组织方式。最常见的方式包含两个角色:*客户端* *服务器*。服务器通过网络公开 API客户端连接服务器并向 API 发出请求。服务器公开的这个 API 称为 *服务*
当多个进程需要通过网络通信时,可以采用几种不同的组织方式。最常见的方式包含两个角色:*客户端**client**服务器**server*。服务器通过网络公开 API客户端连接服务器并向 API 发出请求。服务器公开的这个 API 称为 *服务**service*
Web 正是这样工作的客户端Web 浏览器)向 Web 服务器发出请求,用 `GET` 请求下载 HTML、CSS、JavaScript、图片等内容`POST` 请求向服务器提交数据。这个 API 由一套标准化的协议和数据格式组成,包括 HTTP、URL、SSL/TLS、HTML 等。因为 Web 浏览器、Web 服务器和网站作者基本都遵循这些标准,所以理论上可以用任何 Web 浏览器访问任何网站。
@@ -363,7 +363,7 @@ Web 浏览器并不是唯一的客户端。例如,运行在移动设备或桌
#### Web 服务 {#sec_web_services}
如果以 HTTP 作为与服务通信的底层协议,就称为 *Web 服务*。Web 服务常用于构建面向服务或微服务架构(前文[“微服务与无服务器”](/ch1#sec_introduction_microservices)已经讨论过。不过“Web 服务”这个名字并不十分贴切,因为它不只用于 Web还出现在其他几种场景中。例如
如果以 HTTP 作为与服务通信的底层协议,就称为 *Web 服务**Web service*。Web 服务常用于构建面向服务或微服务架构(前文[“微服务与无服务器”](/ch1#sec_introduction_microservices)已经讨论过。不过“Web 服务”这个名字并不十分贴切,因为它不只用于 Web还出现在其他几种场景中。例如
1. 运行在用户设备上的客户端应用程序(例如移动设备上的原生应用,或者浏览器中的 JavaScript Web 应用)通过 HTTP 请求服务。这些请求通常经由公共互联网传输。
2. 作为面向服务或微服务架构的一部分,一项服务请求同一组织拥有的另一项服务;两者通常位于同一个数据中心。
@@ -426,11 +426,11 @@ async def ping():
Web 服务只是通过网络发起 API 请求这一系列技术的最新化身。此前许多技术都曾被大肆炒作却存在严重问题Enterprise JavaBeansEJB和 Java 的远程方法调用RMI局限于 Java分布式组件对象模型DCOM局限于 Microsoft 平台公共对象请求代理架构CORBA过度复杂又不支持向后或向前兼容 [^33]。SOAP 和 WS-\* Web 服务框架试图实现跨厂商互操作,却同样饱受复杂性和兼容性问题困扰 [^34] [^35] [^36]。
所有这些技术都建立在 *远程过程调用*RPC的思想之上而 RPC 早在 20 世纪 70 年代便已出现 [^37]。RPC 模型试图让远程网络服务请求,看起来就像在同一进程内调用编程语言中的函数或方法一样(这种抽象称为 *位置透明性*。RPC 乍看十分方便,这种思路却有根本性的缺陷 [^38] [^39]。网络请求与本地函数调用大不相同:
所有这些技术都建立在 *远程过程调用**remote procedure call*RPC的思想之上而 RPC 早在 20 世纪 70 年代便已出现 [^37]。RPC 模型试图让远程网络服务请求,看起来就像在同一进程内调用编程语言中的函数或方法一样(这种抽象称为 *位置透明性**location transparency*。RPC 乍看十分方便,这种思路却有根本性的缺陷 [^38] [^39]。网络请求与本地函数调用大不相同:
* 本地函数调用是可预测的,成功还是失败只取决于你能控制的参数。网络请求却不可预测:请求或响应可能因网络问题而丢失,远端机器也可能很慢或不可用,这些情况都完全不受你控制。网络问题很常见,必须预先做好准备,例如重试失败的请求。
* 本地函数调用要么返回结果,要么抛出异常,要么永远不返回(因为陷入死循环或进程崩溃)。网络请求还有另一种结果:它可能因 *超时* 而返回,却没有结果。此时你根本不知道发生了什么;如果远程服务没有响应,就无法判断请求究竟有没有送达([第 9 章](/ch9#ch_distributed) 会更详细地讨论这个问题)。
* 重试失败的网络请求时,原请求可能其实已经成功,只是响应丢失了。此时重试会让同一操作执行多次,除非协议内置了去重机制,也就是 *幂等性* [^40]。本地函数调用没有这个问题(参见[“幂等性”](/ch12#sec_stream_idempotence))。
* 本地函数调用要么返回结果,要么抛出异常,要么永远不返回(因为陷入死循环或进程崩溃)。网络请求还有另一种结果:它可能因 *超时**timeout*而返回,却没有结果。此时你根本不知道发生了什么;如果远程服务没有响应,就无法判断请求究竟有没有送达([第 9 章](/ch9#ch_distributed) 会更详细地讨论这个问题)。
* 重试失败的网络请求时,原请求可能其实已经成功,只是响应丢失了。此时重试会让同一操作执行多次,除非协议内置了去重机制,也就是 *幂等性**idempotence*[^40]。本地函数调用没有这个问题(参见[“幂等性”](/ch12#sec_stream_idempotence))。
* 本地函数每次调用通常耗时相近。网络请求不仅比函数调用慢得多,延迟还会剧烈波动:顺利时可能不到一毫秒就完成;网络拥塞或远程服务过载时,同一个操作却可能花上好几秒。
* 调用本地函数时,可以高效传递指向本地内存对象的引用(指针)。发起网络请求时,所有参数都必须编码成可以通过网络发送的字节序列。对于数字、短字符串等不可变基本值,这不成问题;但数据量一大,或者涉及可变对象,麻烦很快就会出现。
* 客户端和服务可能由不同的编程语言实现,因此 RPC 框架必须在语言之间转换数据类型。各语言的类型并不完全相同转换结果可能十分难看——例如前面提到过JavaScript 无法准确表示大于 2⁵³ 的整数(参见[“JSON、XML 及其二进制变体”](/ch5#sec_encoding_json))。如果单个进程只使用一种语言,就没有这个问题。
@@ -439,17 +439,17 @@ Web 服务只是通过网络发起 API 请求这一系列技术的最新化身
#### 负载均衡器、服务发现和服务网格 {#sec_encoding_service_discovery}
所有服务都通过网络通信,因此客户端必须知道目标服务的地址,这个问题称为 *服务发现*。最简单的做法,是把运行服务的 IP 地址和端口配置到客户端中。这样确实能工作,但服务器一旦离线、迁移到另一台机器或负载过高,就必须手工重新配置客户端。
所有服务都通过网络通信,因此客户端必须知道目标服务的地址,这个问题称为 *服务发现**service discovery*。最简单的做法,是把运行服务的 IP 地址和端口配置到客户端中。这样确实能工作,但服务器一旦离线、迁移到另一台机器或负载过高,就必须手工重新配置客户端。
为了提高可用性和可伸缩性,一项服务通常会在不同机器上运行多个实例,任一实例都能处理传入的请求。把请求分摊到这些实例上的过程称为 *负载均衡* [^41]。负载均衡和服务发现有许多实现方案:
为了提高可用性和可伸缩性,一项服务通常会在不同机器上运行多个实例,任一实例都能处理传入的请求。把请求分摊到这些实例上的过程称为 *负载均衡**load balancing*[^41]。负载均衡和服务发现有许多实现方案:
* *硬件负载均衡器* 是安装在数据中心的专用设备。客户端只连接一个主机和端口,设备再把传入连接路由到运行该服务的某台服务器。此类负载均衡器会在连接下游服务器时检测网络故障,并将流量转移到其他服务器。
* *软件负载均衡器* 的行为与硬件负载均衡器大体相同只是不需要专用设备。Nginx 和 HAProxy 等软件负载均衡器就是可以安装在普通机器上的应用程序。
* *硬件负载均衡器**hardware load balancer*是安装在数据中心的专用设备。客户端只连接一个主机和端口,设备再把传入连接路由到运行该服务的某台服务器。此类负载均衡器会在连接下游服务器时检测网络故障,并将流量转移到其他服务器。
* *软件负载均衡器**software load balancer*的行为与硬件负载均衡器大体相同只是不需要专用设备。Nginx 和 HAProxy 等软件负载均衡器就是可以安装在普通机器上的应用程序。
* *域名系统DNS* 用于在互联网上解析域名,例如打开网页时就会用到。它允许一个域名关联多个 IP 地址,从而实现负载均衡。客户端可以配置为按域名而非 IP 地址连接服务,再由客户端的网络层在建立连接时选择某个 IP 地址。这种方法的缺点是DNS 原本就允许变更经过较长时间才完全传播,而且会缓存 DNS 条目。如果服务器频繁启动、停止或迁移,客户端可能拿到过期的 IP 地址,而该地址上已经没有服务器运行。
* *服务发现系统* 不使用 DNS而是通过集中式注册表跟踪哪些服务端点可用。新服务实例启动时会向发现系统注册自己声明正在监听的主机和端口以及分片归属信息参见 [第 7 章](/ch7#ch_sharding))、数据中心位置等相关元数据。随后,服务定期向发现系统发送心跳,表示自己仍然可用。
* *服务发现系统**service discovery system*不使用 DNS而是通过集中式注册表跟踪哪些服务端点可用。新服务实例启动时会向发现系统注册自己声明正在监听的主机和端口以及分片归属信息参见 [第 7 章](/ch7#ch_sharding))、数据中心位置等相关元数据。随后,服务定期向发现系统发送心跳,表示自己仍然可用。
客户端要连接服务时,先向发现系统查询可用端点列表,再直接连接某个端点。与 DNS 相比,服务发现更适合实例频繁变化的动态环境。发现系统还会向客户端提供更多服务元数据,使客户端能做出更明智的负载均衡决策。
* *服务网格* 是一种更复杂的负载均衡方案,把软件负载均衡器与服务发现结合起来。传统软件负载均衡器运行在独立机器上,服务网格的负载均衡器则通常部署为进程内客户端库,或者部署为伴随客户端和服务器的进程或“边车”容器。客户端应用程序连接本机的服务负载均衡器,后者再连接服务器一侧的负载均衡器,最终把连接路由到本机的服务器进程。
* *服务网格**service mesh*是一种更复杂的负载均衡方案,把软件负载均衡器与服务发现结合起来。传统软件负载均衡器运行在独立机器上,服务网格的负载均衡器则通常部署为进程内客户端库,或者部署为伴随客户端和服务器的进程或“边车”容器。客户端应用程序连接本机的服务负载均衡器,后者再连接服务器一侧的负载均衡器,最终把连接路由到本机的服务器进程。
这种拓扑虽然复杂,却有不少优点。客户端和服务器应用程序都只需建立本地连接,因此连接加密可以完全由负载均衡器处理,让应用程序不必面对 SSL 证书和 TLS 的复杂性。服务网格还提供了强大的可观测性,能够实时跟踪服务间的调用关系、检测故障、监测流量负载等。
@@ -472,28 +472,28 @@ RPC 经常用于跨组织边界通信,这让服务兼容性变得更加困难
按照定义,基于服务的架构由多项服务组成,每项服务负责应用程序的一部分。以支付处理应用为例,它要从信用卡扣款,再把资金存入银行账户。系统很可能分别用不同服务负责欺诈检测、信用卡集成、银行系统集成等工作。
在这个例子中,处理一笔付款需要多次服务调用。支付处理服务可能先调用欺诈检测服务检查风险,再调用信用卡服务扣款,最后调用银行服务把扣下的款项存入账户,如 {{< xref fig="5-7" page="/ch5" anchor="fig_encoding_workflow" >}}图 5-7{{< /xref >}} 所示。这一系列步骤称为 *工作流*,其中每一步称为 *任务*。工作流通常定义成一张任务图其定义可以使用通用编程语言、领域特定语言DSL也可以使用业务流程执行语言BPEL之类的标记语言 [^44]。
在这个例子中,处理一笔付款需要多次服务调用。支付处理服务可能先调用欺诈检测服务检查风险,再调用信用卡服务扣款,最后调用银行服务把扣下的款项存入账户,如 {{< xref fig="5-7" page="/ch5" anchor="fig_encoding_workflow" >}}图 5-7{{< /xref >}} 所示。这一系列步骤称为 *工作流**workflow*,其中每一步称为 *任务**task*。工作流通常定义成一张任务图其定义可以使用通用编程语言、领域特定语言DSL也可以使用业务流程执行语言BPEL之类的标记语言 [^44]。
> [!TIP] 任务、活动与函数
>
> 不同的工作流引擎对任务有不同称呼。例如Temporal 使用 *活动* 一词,另一些引擎则称之为 *持久函数*。名称虽异,概念相同。
> 不同的工作流引擎对任务有不同称呼。例如Temporal 使用 *活动**activity*一词,另一些引擎则称之为 *持久函数**durable function*。名称虽异,概念相同。
{{< fig num="5-7" id="fig_encoding_workflow" src="/fig/ddia_0507.png" caption="使用图形化的业务流程模型与标记法BPMN表示工作流的示例。" class="ddia-figure ddia-figure--panorama" width="2658" height="720" />}}
工作流由 *工作流引擎* 运行或执行。引擎决定每项任务何时运行、在哪台机器上运行、任务失败时该怎么办(例如执行任务的机器崩溃),以及允许多少任务并行执行等。
工作流由 *工作流引擎**workflow engine*运行或执行。引擎决定每项任务何时运行、在哪台机器上运行、任务失败时该怎么办(例如执行任务的机器崩溃),以及允许多少任务并行执行等。
工作流引擎通常由编排器和执行器组成编排器负责调度执行器负责真正运行任务。工作流被触发后执行便开始。如果用户定义了按时间运行的计划例如每小时执行一次编排器可以自行触发工作流Web 服务等外部来源,甚至人,也可以触发工作流。一旦触发,执行器就会受命运行任务。
工作流引擎种类繁多面向的使用场景也各不相同。Airflow、Dagster 和 Prefect 等引擎与数据系统集成,用于编排 ETL 任务。Camunda 和 Orkes 等引擎提供图形化工作流表示,例如 {{< xref fig="5-7" page="/ch5" anchor="fig_encoding_workflow" >}}图 5-7{{< /xref >}} 中的 BPMN让非工程师也能更方便地定义和执行工作流。Temporal 和 Restate 等引擎则提供 *持久化执行*
工作流引擎种类繁多面向的使用场景也各不相同。Airflow、Dagster 和 Prefect 等引擎与数据系统集成,用于编排 ETL 任务。Camunda 和 Orkes 等引擎提供图形化工作流表示,例如 {{< xref fig="5-7" page="/ch5" anchor="fig_encoding_workflow" >}}图 5-7{{< /xref >}} 中的 BPMN让非工程师也能更方便地定义和执行工作流。Temporal 和 Restate 等引擎则提供 *持久化执行**durable execution*
#### 持久化执行 {#durable-execution}
对于需要事务语义的服务架构,持久化执行框架已经成为一种流行的构建方式。在支付示例中,我们希望每笔付款都恰好处理一次。但工作流执行期间一旦发生故障,就可能出现信用卡已经扣款,银行账户却没有收到相应款项的情况。在基于服务的架构中,无法简单地把这两项任务包进一个数据库事务;况且,系统可能还要与我们无法充分控制的第三方支付网关交互。
持久化执行框架可以为工作流提供 *恰好一次语义*。任务失败后,框架会重新执行它,但会跳过失败前已经成功完成的 RPC 调用或状态变更:框架表面上再次发起调用,实际上却直接返回上一次调用的结果。这之所以可行,是因为框架把所有 RPC 和状态变更都记录在预写日志WAL之类的持久存储中 [^45] [^46]。{{< xref eg="5-5" page="/ch5" anchor="fig_temporal_workflow" >}}示例 5-5{{< /xref >}} 展示了用 Temporal 定义支持持久化执行的工作流。
持久化执行框架可以为工作流提供 *恰好一次语义**exactly-once semantics*。任务失败后,框架会重新执行它,但会跳过失败前已经成功完成的 RPC 调用或状态变更:框架表面上再次发起调用,实际上却直接返回上一次调用的结果。这之所以可行,是因为框架把所有 RPC 和状态变更都记录在预写日志WAL之类的持久存储中 [^45] [^46]。{{< xref eg="5-5" page="/ch5" anchor="fig_temporal_workflow" >}}示例 5-5{{< /xref >}} 展示了用 Temporal 定义支持持久化执行的工作流。
{{< eg num="5-5" id="fig_temporal_workflow" caption="用于图 5-7 所示支付工作流的 Temporal 工作流定义片段" >}}
```python
@@ -528,7 +528,7 @@ Temporal 之类的框架并非没有难题。外部服务——例如示例中
### 事件驱动的架构 {#sec_encoding_dataflow_msg}
最后,我们来简要介绍 *事件驱动架构*,这是编码数据在进程间流动的另一种方式。请求在这里称为 *事件* *消息*;与 RPC 不同,发送者通常不会等待接收者处理事件。事件一般也不会通过直接网络连接发给接收者,而是先经过一个临时存储消息的中介,称为 *消息代理*,也叫 *事件代理*、*消息队列* 或 *面向消息的中间件* [^50]。
最后,我们来简要介绍 *事件驱动架构**event-driven architecture*,这是编码数据在进程间流动的另一种方式。请求在这里称为 *事件**event**消息**message*;与 RPC 不同,发送者通常不会等待接收者处理事件。事件一般也不会通过直接网络连接发给接收者,而是先经过一个临时存储消息的中介,称为 *消息代理**message broker*),也叫 *事件代理**event broker*)、*消息队列**message queue*)或 *面向消息的中间件**message-oriented middleware* [^50]。
与直接使用 RPC 相比,消息代理有几个优点:
@@ -538,7 +538,7 @@ Temporal 之类的框架并非没有难题。外部服务——例如示例中
* 它可以把同一条消息发送给多个接收者。
* 它从逻辑上解耦发送者与接收者:发送者只管发布消息,不必关心谁来消费。
通过消息代理进行的通信是 *异步的*:发送者不等待消息送达,只管发出消息,然后就将其忘掉。不过,也可以让发送者在另一条通道上等待响应,从而实现类似同步 RPC 的模型。
通过消息代理进行的通信是 *异步的**asynchronous*:发送者不等待消息送达,只管发出消息,然后就将其忘掉。不过,也可以让发送者在另一条通道上等待响应,从而实现类似同步 RPC 的模型。
#### 消息代理 {#message-brokers}
@@ -546,8 +546,8 @@ Temporal 之类的框架并非没有难题。外部服务——例如示例中
具体的传递语义因实现和配置而异,但最常见的是以下两种消息分发模式:
* 一个进程把消息加入某个命名 *队列*,代理再把消息交给该队列的一个 *消费者*。如果有多个消费者,其中只有一个会收到这条消息。
* 一个进程把消息发布到某个命名 *主题*,代理再把消息交给该主题的所有 *订阅者*。如果有多个订阅者,每个都会收到这条消息。
* 一个进程把消息加入某个命名 *队列**queue*,代理再把消息交给该队列的一个 *消费者**consumer*。如果有多个消费者,其中只有一个会收到这条消息。
* 一个进程把消息发布到某个命名 *主题**topic*,代理再把消息交给该主题的所有 *订阅者**subscriber*。如果有多个订阅者,每个都会收到这条消息。
消息代理通常不强制使用特定数据模型:消息只是附带少量元数据的字节序列,因此可以采用任何编码格式。常见做法是使用 Protocol Buffers、Avro 或 JSON并在消息代理旁部署模式注册表用来保存所有有效的模式版本并检查兼容性 [^19] [^21]。也可以使用 AsyncAPI——面向消息传递、与 OpenAPI 对应的规范——来规定消息模式。
@@ -557,9 +557,9 @@ Temporal 之类的框架并非没有难题。外部服务——例如示例中
#### 分布式 actor 框架 {#distributed-actor-frameworks}
*Actor 模型* 是一种用于单进程并发的编程模型。它不直接处理线程以及随之而来的竞态条件、锁和死锁,而是把逻辑封装在 *actor* 中。每个 actor 通常代表一个客户端或实体,可以拥有不与其他 actor 共享的本地状态,并通过收发异步消息与其他 actor 通信。消息传递并无保证:在某些错误场景下,消息会丢失。由于每个 actor 一次只处理一条消息,所以无需操心线程问题,而框架可以独立调度每个 actor。
*Actor 模型**actor model*是一种用于单进程并发的编程模型。它不直接处理线程以及随之而来的竞态条件、锁和死锁,而是把逻辑封装在 *actor* 中。每个 actor 通常代表一个客户端或实体,可以拥有不与其他 actor 共享的本地状态,并通过收发异步消息与其他 actor 通信。消息传递并无保证:在某些错误场景下,消息会丢失。由于每个 actor 一次只处理一条消息,所以无需操心线程问题,而框架可以独立调度每个 actor。
Akka、Orleans [^51] 和 Erlang/OTP 等 *分布式 actor 框架*,用这种编程模型把应用程序扩展到多个节点。无论发送者和接收者位于同一节点还是不同节点,都使用同一种消息传递机制。如果双方位于不同节点,消息会被透明地编码成字节序列,通过网络发送,再由另一端解码。
Akka、Orleans [^51] 和 Erlang/OTP 等 *分布式 actor 框架**distributed actor framework*,用这种编程模型把应用程序扩展到多个节点。无论发送者和接收者位于同一节点还是不同节点,都使用同一种消息传递机制。如果双方位于不同节点,消息会被透明地编码成字节序列,通过网络发送,再由另一端解码。
位置透明性在 actor 模型中比在 RPC 中效果更好,因为 actor 模型本来就假设消息可能丢失,即使消息只在单个进程内传递也一样。网络延迟固然可能高于进程内延迟,但在 actor 模型中,本地通信与远程通信之间的根本差异要小得多。

View File

@@ -15,19 +15,19 @@ breadcrumbs: false
>
> —— 道格拉斯・亚当斯《基本无害》1992
*复制* 意味着在通过网络连接的多台机器上保留相同数据的副本。正如 [“分布式与单节点系统”](/ch1#sec_introduction_distributed) 中所讨论的,我们希望复制数据,可能出于以下原因:
*复制**replication*意味着在通过网络连接的多台机器上保留相同数据的副本。正如 [“分布式与单节点系统”](/ch1#sec_introduction_distributed) 中所讨论的,我们希望复制数据,可能出于以下原因:
* 使数据在地理上更接近用户(从而降低访问延迟)
* 即使系统的一部分发生故障,系统仍能继续工作(从而提高可用性)
* 增加能够处理读查询的机器数量(从而提高读取吞吐量)
本章假设数据集足够小,每台机器都能保存整个数据集的副本。在 [第 7 章](/ch7#ch_sharding) 中,我们将放宽这一假设,讨论单台机器无法容纳的大型数据集如何进行 *分片**分区*)。再往后的章节会讨论复制数据系统中可能出现的各种故障,以及应对这些故障的方法。
本章假设数据集足够小,每台机器都能保存整个数据集的副本。在 [第 7 章](/ch7#ch_sharding) 中,我们将放宽这一假设,讨论单台机器无法容纳的大型数据集如何进行 *分片**sharding*,也称 *分区**partitioning*)。再往后的章节会讨论复制数据系统中可能出现的各种故障,以及应对这些故障的方法。
如果要复制的数据不随时间变化,复制就很简单:只需把数据复制到每个节点一次,便大功告成。复制的全部难点都在于处理被复制数据的 *变更*,这正是本章的主题。我们将讨论三类在节点之间复制变更的算法:*单主复制*、*多主复制* 和 *无主复制*。几乎所有分布式数据库都采用其中一种。三者各有利弊,本章将逐一详述。
如果要复制的数据不随时间变化,复制就很简单:只需把数据复制到每个节点一次,便大功告成。复制的全部难点都在于处理被复制数据的 *变更*,这正是本章的主题。我们将讨论三类在节点之间复制变更的算法:*单主复制**single-leader replication*)、*多主复制**multi-leader replication*)和 *无主复制**leaderless replication*。几乎所有分布式数据库都采用其中一种。三者各有利弊,本章将逐一详述。
复制需要考虑许多权衡,例如采用同步复制还是异步复制,以及如何处理失效的副本。这些往往都是数据库的配置选项;具体细节因数据库而异,但不同实现背后的基本原理大体相通。本章将讨论这些选择带来的后果。
复制需要考虑许多权衡,例如采用 *同步复制**synchronous replication*)还是 *异步复制**asynchronous replication*,以及如何处理失效的副本。这些往往都是数据库的配置选项;具体细节因数据库而异,但不同实现背后的基本原理大体相通。本章将讨论这些选择带来的后果。
数据库复制算得上是老生常谈:自 20 世纪 70 年代有人开始研究以来,其基本原理并没有太大变化 [^1],因为网络的根本约束也一直未变。即便如此,*最终一致性* 等概念仍常常引起困惑。在 [“复制延迟的问题”](/ch6#sec_replication_lag) 中,我们会更精确地说明最终一致性,并讨论 *读己之写*、*单调读* 等保证。
数据库复制算得上是老生常谈:自 20 世纪 70 年代有人开始研究以来,其基本原理并没有太大变化 [^1],因为网络的根本约束也一直未变。即便如此,*最终一致性**eventual consistency*等概念仍常常引起困惑。在 [“复制延迟的问题”](/ch6#sec_replication_lag) 中,我们会更精确地说明最终一致性,并讨论 *读己之写**read-your-writes*)、*单调读**monotonic reads*等保证。
> [!TIP] 备份与复制
@@ -41,12 +41,12 @@ breadcrumbs: false
## 单主复制 {#sec_replication_leader}
每个保存数据库拷贝的节点都称为一个 *副本*。存在多个副本时,一个问题不可避免:如何确保所有数据最终都出现在所有副本上?
每个保存数据库拷贝的节点都称为一个 *副本**replica*。存在多个副本时,一个问题不可避免:如何确保所有数据最终都出现在所有副本上?
数据库的每次写入都必须由每个副本处理,否则各副本就会包含不同的数据。最常见的解决方案称为 *基于领导者的复制*,也称 *主备复制**主动/被动复制*。其工作原理如下(见 {{< xref fig="6-1" page="/ch6" anchor="fig_replication_leader_follower" >}}图 6-1{{< /xref >}}
数据库的每次写入都必须由每个副本处理,否则各副本就会包含不同的数据。最常见的解决方案称为 *基于领导者的复制**leader-based replication*),也称 *主备复制**primary-backup*)或 *主动/被动复制**active/passive*。其工作原理如下(见 {{< xref fig="6-1" page="/ch6" anchor="fig_replication_leader_follower" >}}图 6-1{{< /xref >}}
1. 其中一个副本被指定为 *领导者*(也称 *主库* ** [^2])。客户端要写入数据库时,必须把请求发给领导者;领导者首先将新数据写入本地存储。
2. 其他副本称为 *追随者*(也称 *只读副本*、*备库* 或 *热备*)。领导者每次把新数据写入本地存储,也会把数据变更作为 *复制日志**变更流* 发送给所有追随者。每个追随者取得领导者的日志,按照领导者处理写入的相同顺序应用所有写入,从而更新本地的数据库副本。
1. 其中一个副本被指定为 *领导者**leader*也称 *主库**primary****source* [^2])。客户端要写入数据库时,必须把请求发给领导者;领导者首先将新数据写入本地存储。
2. 其他副本称为 *追随者**follower*也称 *只读副本**read replica**备库**secondary*,或 *热备**hot standby*)。领导者每次把新数据写入本地存储,也会把数据变更作为 *复制日志**replication log*)或 *变更流**change stream*发送给所有追随者。每个追随者取得领导者的日志,按照领导者处理写入的相同顺序应用所有写入,从而更新本地的数据库副本。
3. 客户端读取数据库时,可以查询领导者,也可以查询任意追随者;但只有领导者接受写入(从客户端的角度看,追随者是只读的)。
{{< fig num="6-1" id="fig_replication_leader_follower" src="/fig/ddia_0601.png" caption="单主复制把所有写入都发往指定的领导者,再由领导者将变更流发送给各追随者副本。" class="ddia-figure ddia-figure--panorama" width="2880" height="925" />}}
@@ -62,7 +62,7 @@ breadcrumbs: false
### 同步复制与异步复制 {#sec_replication_sync_async}
复制系统的一个重要细节是,复制究竟 *同步* 进行还是 *异步* 进行。(在关系数据库中,这通常是一个配置项;其他系统往往固定采用其中一种。)
复制系统的一个重要细节是,复制究竟 *同步**synchronous*)进行还是 *异步**asynchronous*进行。(在关系数据库中,这通常是一个配置项;其他系统往往固定采用其中一种。)
设想 {{< xref fig="6-1" page="/ch6" anchor="fig_replication_leader_follower" >}}图 6-1{{< /xref >}} 中的情形:某网站用户更新个人头像。客户端在某个时刻向领导者发出更新请求,不久后领导者收到请求;领导者随后在某个时刻把数据变更转发给追随者,并最终通知客户端更新成功。{{< xref fig="6-2" page="/ch6" anchor="fig_replication_sync_replication" >}}图 6-2{{< /xref >}} 展示了其中一种可能的时序。
@@ -74,7 +74,7 @@ breadcrumbs: false
同步复制的优点是,追随者保证拥有与领导者一致的最新数据副本。领导者突然失效时,可以确信数据仍可从追随者取得。缺点是,如果同步追随者没有响应——无论因为崩溃、网络故障还是其他原因——写入就无法继续。领导者必须阻塞所有写入,直至同步副本重新可用。
因此,把所有追随者都设为同步并不现实:任意一个节点停机都会拖垮整个系统。实践中,数据库所谓的同步复制,通常是指 *一个* 追随者同步,其余追随者异步。如果同步追随者不可用或过慢,就把某个异步追随者切换为同步。这样可以保证至少有两个节点持有最新数据:领导者和一个同步追随者。这种配置有时也称为 *半同步*
因此,把所有追随者都设为同步并不现实:任意一个节点停机都会拖垮整个系统。实践中,数据库所谓的同步复制,通常是指 *一个* 追随者同步,其余追随者异步。如果同步追随者不可用或过慢,就把某个异步追随者切换为同步。这样可以保证至少有两个节点持有最新数据:领导者和一个同步追随者。这种配置有时也称为 *半同步**semi-synchronous*
有些系统会同步更新 *多数* 副本(例如含领导者在内的 5 个副本中更新 3 个),其余少数副本异步更新。这就是 *法定人数* 的一个例子,我们会在 [“读写仲裁”](/ch6#sec_replication_quorum_condition) 中进一步讨论。采用共识协议自动选举领导者的系统经常使用多数法定人数,[第 10 章](/ch10#ch_consistency) 将再次谈到这个问题。
@@ -92,7 +92,7 @@ breadcrumbs: false
1. 取得领导者数据库在某个时刻的一致快照;如果可能,不要锁住整个数据库。大多数数据库都提供这一功能,因为备份同样需要它。有些情况下需要借助第三方工具,例如 MySQL 的 Percona XtraBackup。
2. 把快照复制到新的追随者节点。
3. 追随者连接领导者请求从快照生成之后发生的所有数据变更。这要求快照与领导者复制日志中的准确位置相关联。不同系统对这个位置有不同称呼PostgreSQL 称之为 *日志序列号*MySQL 则有 *binlog 位点**全局事务标识符*GTID两套机制。
3. 追随者连接领导者请求从快照生成之后发生的所有数据变更。这要求快照与领导者复制日志中的准确位置相关联。不同系统对这个位置有不同称呼PostgreSQL 称之为 *日志序列号**log sequence number*LSNMySQL 则有 *binlog 位点**binlog coordinates*)和 *全局事务标识符**global transaction identifiers*GTIDs)两套机制。
4. 追随者处理完快照之后积压的数据变更时,就称它已经 *赶上进度*。此后,它可以继续随时处理领导者产生的数据变更。
设置追随者的实际步骤因数据库而异。有些系统完全自动完成这一过程,另一些系统则需要管理员手工执行一套颇为晦涩的多步骤流程。
@@ -134,7 +134,7 @@ breadcrumbs: false
#### 领导者失效:故障切换 {#leader-failure-failover}
领导者失效处理起来更加棘手:必须把一个追随者提升为新领导者,重新配置客户端以便把写入发给新领导者,并让其他追随者开始消费新领导者的数据变更。这个过程称为 *故障切换*
领导者失效处理起来更加棘手:必须把一个追随者提升为新领导者,重新配置客户端以便把写入发给新领导者,并让其他追随者开始消费新领导者的数据变更。这个过程称为 *故障切换**failover*
故障切换可以手工完成——通知管理员领导者已经失效,再由管理员执行必要步骤选出新领导者;也可以自动完成。自动故障切换通常包括以下步骤:
@@ -146,7 +146,7 @@ breadcrumbs: false
* 如果使用异步复制,新领导者可能没有收到旧领导者失效前的全部写入。选出新领导者后,原领导者若重新加入集群,那些未复制的写入该怎么办?与此同时,新领导者可能已经收到与之冲突的写入。最常见的办法是直接丢弃旧领导者尚未复制的写入,这意味着你原以为已经提交的写入其实并未持久保存。
* 如果数据库内容还需要与数据库之外的存储系统协调丢弃写入尤其危险。例如GitHub 曾发生过一起事故 [^14]:一个数据过时的 MySQL 追随者被提升为领导者。数据库用自增计数器为新行分配主键;新领导者的计数器落后于旧领导者,因而重复使用了旧领导者已经分配过的一些主键。这些主键同时用于 Redis 存储,主键重用造成 MySQL 与 Redis 数据不一致,最终使一些私有数据泄露给了错误的用户。
* 在某些故障场景下(见 [第 9 章](/ch9#ch_distributed)),可能有两个节点都认为自己是领导者。这种情况称为 *脑裂*,非常危险:如果两个领导者都接受写入,而系统又没有冲突解决流程(参见 [“多主复制”](/ch6#sec_replication_multi_leader)),数据很可能丢失或损坏。有些系统设有保险机制,一旦发现两个领导者便关闭其中一个节点;但机制设计不当时,也可能把两个节点都关闭 [^15]。而且,等系统发现脑裂并关闭旧节点时,可能已经为时过晚,数据早已损坏。
* 在某些故障场景下(见 [第 9 章](/ch9#ch_distributed)),可能有两个节点都认为自己是领导者。这种情况称为 *脑裂**split brain*,非常危险:如果两个领导者都接受写入,而系统又没有冲突解决流程(参见 [“多主复制”](/ch6#sec_replication_multi_leader)),数据很可能丢失或损坏。有些系统设有保险机制,一旦发现两个领导者便关闭其中一个节点;但机制设计不当时,也可能把两个节点都关闭 [^15]。而且,等系统发现脑裂并关闭旧节点时,可能已经为时过晚,数据早已损坏。
* 宣布领导者失效前,超时应该设为多长?超时越长,领导者确实失效时恢复所需的时间就越长;超时太短,又容易触发不必要的故障切换。例如,短暂的负载尖峰可能使节点响应时间超过超时值,网络抖动也可能延迟数据包。如果系统已经饱受高负载或网络问题困扰,不必要的故障切换只会让情况更糟。
@@ -174,7 +174,7 @@ breadcrumbs: false
* 如果语句使用自增列,或依赖数据库中的现有数据(例如 `UPDATE …​ WHERE <some condition>`),就必须在每个副本上按完全相同的顺序执行,否则可能产生不同结果。有多个事务并发执行时,这会成为限制。
* 带有副作用的语句(例如触发器、存储过程、用户定义函数),可能在各副本上产生不同的副作用,除非这些副作用完全确定。
这些问题可以绕开。例如,领导者在记录语句时,可以用固定的返回值替换非确定性函数调用,从而让所有追随者得到相同的值。按固定顺序执行确定性语句,与 [“事件溯源与 CQRS”](/ch3#sec_datamodels_events) 中介绍的事件溯源模型很相似。这种方法也称为 *状态机复制*[“使用共享日志”](/ch10#sec_consistency_smr) 将讨论其理论基础。
这些问题可以绕开。例如,领导者在记录语句时,可以用固定的返回值替换非确定性函数调用,从而让所有追随者得到相同的值。按固定顺序执行确定性语句,与 [“事件溯源与 CQRS”](/ch3#sec_datamodels_events) 中介绍的事件溯源模型很相似。这种方法也称为 *状态机复制**state machine replication*[“使用共享日志”](/ch10#sec_consistency_smr) 将讨论其理论基础。
MySQL 5.1 以前使用基于语句的复制。由于日志相当紧凑如今有时仍会采用这种方式不过默认情况下只要语句中存在任何非确定性MySQL 就会切换到稍后介绍的基于行的复制。VoltDB 也采用基于语句的复制,并要求事务必须是确定性的,以保证安全 [^16]。然而,实践中很难确保确定性,因此许多数据库更倾向于其他复制方式。
@@ -190,7 +190,7 @@ PostgreSQL、Oracle 等数据库采用这种复制方式 [^17] [^18]。其主要
#### 逻辑(基于行)日志复制 {#logical-row-based-log-replication}
另一种方法是让复制和存储引擎使用不同的日志格式,从而使复制日志与存储引擎的内部实现解耦。这种复制日志称为 *逻辑日志*,以区别于存储引擎的(*物理*)数据表示。
另一种方法是让复制和存储引擎使用不同的日志格式,从而使复制日志与存储引擎的内部实现解耦。这种复制日志称为 *逻辑日志**logical log*,以区别于存储引擎的(*物理**physical*)数据表示。
关系数据库的逻辑日志通常由一系列记录组成,以行的粒度描述对数据库表的写入:
@@ -202,7 +202,7 @@ PostgreSQL、Oracle 等数据库采用这种复制方式 [^17] [^18]。其主要
由于逻辑日志与存储引擎的内部实现解耦,更容易保持向后兼容,因而领导者与追随者可以运行不同版本的数据库软件,也就能以极少的停机时间升级到新版本 [^20]。
逻辑日志格式也更容易由外部应用程序解析。如果要把数据库内容发送到外部系统,例如送入数据仓库做离线分析,或构建自定义索引和缓存 [^21],这一点会很有用。这种技术称为 *变更数据捕获*,我们会在 [“变更数据捕获”](/ch12#sec_stream_cdc) 中再次谈到它。
逻辑日志格式也更容易由外部应用程序解析。如果要把数据库内容发送到外部系统,例如送入数据仓库做离线分析,或构建自定义索引和缓存 [^21],这一点会很有用。这种技术称为 *变更数据捕获**change data capture*CDC,我们会在 [“变更数据捕获”](/ch12#sec_stream_cdc) 中再次谈到它。
## 复制延迟的问题 {#sec_replication_lag}
@@ -212,7 +212,7 @@ PostgreSQL、Oracle 等数据库采用这种复制方式 [^17] [^18]。其主要
在这种 *读扩展* 架构中,只需增加追随者,便可提高只读请求的处理能力。不过,这种办法实际上只适用于异步复制。如果尝试同步复制到所有追随者,任意一个节点失效或网络中断都会让整个系统无法写入。节点越多,越可能有某个节点停机,因此完全同步的配置会极不可靠。
不幸的是,应用程序从 *异步* 追随者读取时,如果追随者落后,就可能看到过时的信息。数据库于是显得不一致:同时在领导者和追随者上执行同一查询,结果可能不同,因为追随者尚未反映所有写入。这种不一致只是暂时的——如果停止写入并等待一段时间,追随者最终会赶上领导者,恢复一致。因此,这种现象称为 *最终一致性* [^22]。
不幸的是,应用程序从 *异步**asynchronous*追随者读取时,如果追随者落后,就可能看到过时的信息。数据库于是显得不一致:同时在领导者和追随者上执行同一查询,结果可能不同,因为追随者尚未反映所有写入。这种不一致只是暂时的——如果停止写入并等待一段时间,追随者最终会赶上领导者,恢复一致。因此,这种现象称为 *最终一致性**eventual consistency*[^22]。
> [!NOTE]
@@ -231,7 +231,7 @@ PostgreSQL、Oracle 等数据库采用这种复制方式 [^17] [^18]。其主要
{{< fig num="6-3" id="fig_replication_read_your_writes" src="/fig/ddia_0603.png" caption="用户写入后,又从陈旧副本读取。要防止这种异常,需要写后读一致性。" class="ddia-figure ddia-figure--wide" width="2880" height="1128" />}}
这种情况下需要 *写后读一致性*,也称 *读己之写一致性* [^23]。它保证用户重新加载页面时,总能看到自己提交的更新。至于其他用户则不作保证:他们的更新可能过一段时间才会出现。但至少用户可以确信,自己的输入已经正确保存。
这种情况下需要 *写后读一致性**read-after-write consistency*),也称 *读己之写一致性**read-your-writes consistency*[^23]。它保证用户重新加载页面时,总能看到自己提交的更新。至于其他用户则不作保证:他们的更新可能过一段时间才会出现。但至少用户可以确信,自己的输入已经正确保存。
如何在基于领导者的复制系统中实现写后读一致性?有多种办法,例如:
@@ -291,7 +291,7 @@ Poons 先生
{{< fig num="6-5" id="fig_replication_consistent_prefix" src="/fig/ddia_0605.png" caption="如果某些分片的复制速度慢于其他分片,观察者可能先看到答案,后看到问题。" class="ddia-figure ddia-figure--standard" width="2880" height="1843" />}}
防止这种异常需要另一种保证:*一致前缀读* [^22]。它保证,如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看到它们以相同顺序出现。
防止这种异常需要另一种保证:*一致前缀读**consistent prefix reads*[^22]。它保证,如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看到它们以相同顺序出现。
这在分片(分区)数据库中尤其容易成为问题,我们将在 [第 7 章](/ch7#ch_sharding) 讨论这类数据库。如果数据库始终按相同顺序应用写入,读取就总会看到一致前缀,这种异常也不会发生。然而,在许多分布式数据库中,不同分片彼此独立运行,没有全局写入顺序。用户读取数据库时,可能看到一部分处于较旧状态,另一部分却处于较新状态。
@@ -316,7 +316,7 @@ Poons 先生
单主复制有一个主要缺点:所有写入都必须经过唯一的领导者。无论出于什么原因,只要连接不上领导者——例如客户端与领导者之间的网络中断——就无法写入数据库。
单主复制模型可以自然地扩展为允许多个节点接受写入。复制仍以同样方式进行:每个处理写入的节点都必须把数据变更转发给其他所有节点。我们把这种配置称为 *多主复制*,也称 *主动/主动复制**双向复制*。在这种配置中,每个领导者同时也是其他领导者的追随者。
单主复制模型可以自然地扩展为允许多个节点接受写入。复制仍以同样方式进行:每个处理写入的节点都必须把数据变更转发给其他所有节点。我们把这种配置称为 *多主复制**multi-leader replication*),也称 *主动/主动复制**active/active*)或 *双向复制**bidirectional replication*。在这种配置中,每个领导者同时也是其他领导者的追随者。
与单主复制一样,多主复制也可以选择同步或异步。假设有两个领导者 *A**B*,现在要向 *A* 写入。如果写入必须从 *A* 同步复制到 *B*,那么两者之间的网络一旦中断,在网络恢复前就无法向 *A* 写入。这种同步多主复制提供的模型与单主复制极为相似:也就是说,这等同于把 *B* 设为领导者,由 *A* 把所有写请求转发给 *B* 执行。
@@ -326,7 +326,7 @@ Poons 先生
在单个地区内使用多主配置通常没有多少意义,因为所得好处很少能抵消额外的复杂性。不过,在某些场景中,这种配置确实合理。
设想一个数据库在多个地区都有副本,也许是为了在整个地区失效时仍能运行,也许是为了在地理上更接近用户。这种部署称为 *地理分布式*、*跨地域分布式* 或 *跨地域复制*。采用单主复制时,领导者必须位于其中 *一个* 地区,所有写入都要经过该地区。
设想一个数据库在多个地区都有副本,也许是为了在整个地区失效时仍能运行,也许是为了在地理上更接近用户。这种部署称为 *地理分布式**geographically distributed*)、*跨地域分布式**geo-distributed*)或 *跨地域复制**geo-replicated*。采用单主复制时,领导者必须位于其中 *一个* 地区,所有写入都要经过该地区。
在多主配置中,*每个* 地区都可以有一个领导者。{{< xref fig="6-6" page="/ch6" anchor="fig_replication_multi_dc" >}}图 6-6{{< /xref >}} 展示了这种架构:每个地区内部使用常规的领导者—追随者复制(追随者可以位于与领导者不同的可用区);地区之间,则由各地区的领导者把变更复制给其他地区的领导者。
@@ -356,11 +356,11 @@ Poons 先生
#### 多主复制拓扑 {#sec_replication_topologies}
*复制拓扑* 描述写入从一个节点传播到另一个节点时所经过的通信路径。如果只有两个领导者,如 {{< xref fig="6-9" page="/ch6" anchor="fig_replication_write_conflict" >}}图 6-9{{< /xref >}} 所示,那么只有一种合理的拓扑:领导者 1 必须把所有写入发送给领导者 2反之亦然。领导者超过两个时则有多种拓扑可供选择。{{< xref fig="6-7" page="/ch6" anchor="fig_replication_topologies" >}}图 6-7{{< /xref >}} 给出了三个例子。
*复制拓扑**replication topology*描述写入从一个节点传播到另一个节点时所经过的通信路径。如果只有两个领导者,如 {{< xref fig="6-9" page="/ch6" anchor="fig_replication_write_conflict" >}}图 6-9{{< /xref >}} 所示,那么只有一种合理的拓扑:领导者 1 必须把所有写入发送给领导者 2反之亦然。领导者超过两个时则有多种拓扑可供选择。{{< xref fig="6-7" page="/ch6" anchor="fig_replication_topologies" >}}图 6-7{{< /xref >}} 给出了三个例子。
{{< fig num="6-7" id="fig_replication_topologies" src="/fig/ddia_0607.png" caption="多主复制可以采用的三种拓扑示例。" class="ddia-figure ddia-figure--panorama" width="2880" height="800" />}}
最通用的是 {{< xref fig="6-7" page="/ch6" anchor="fig_replication_topologies" >}}图 6-7{{< /xref >}}(c) 所示的 *全对全* 拓扑,每个领导者都会把写入发送给其他所有领导者。不过,实际系统也会采用限制更多的拓扑。例如在 *环形拓扑* 中,每个节点从一个节点接收写入,再把这些写入连同自己的写入转发给另一个节点。另一种常见拓扑呈 *星形*:指定一个根节点,由它把写入转发给所有其他节点。星形拓扑还可以推广成树形。
最通用的是 {{< xref fig="6-7" page="/ch6" anchor="fig_replication_topologies" >}}图 6-7{{< /xref >}}(c) 所示的 *全对全**all-to-all*拓扑,每个领导者都会把写入发送给其他所有领导者。不过,实际系统也会采用限制更多的拓扑。例如在 *环形拓扑**circular topology*中,每个节点从一个节点接收写入,再把这些写入连同自己的写入转发给另一个节点。另一种常见拓扑呈 *星形**star topology*:指定一个根节点,由它把写入转发给所有其他节点。星形拓扑还可以推广成树形。
> [!NOTE]
@@ -381,7 +381,7 @@ Poons 先生
这也是一个因果关系问题,与 [“一致前缀读”](/ch6#sec_replication_consistent_prefix) 中看到的情况相似。更新依赖先前的插入,因此必须保证所有节点先处理插入,再处理更新。仅仅给每次写入附加时间戳并不够,因为不能相信各节点的时钟同步得足以让领导者 2 正确排列这些事件(见 [第 9 章](/ch9#ch_distributed))。
要正确排列这些事件,可以采用本章稍后介绍的 *版本向量*(见 [“检测并发写入”](/ch6#sec_replication_concurrent))。不过,许多多主复制系统并未使用可靠的更新排序技术,因而容易遇到 {{< xref fig="6-8" page="/ch6" anchor="fig_replication_causality" >}}图 6-8{{< /xref >}} 所示的问题。如果使用多主复制,应当了解这些风险,仔细阅读文档,并充分测试数据库,确认它确实提供了你以为它会提供的保证。
要正确排列这些事件,可以采用本章稍后介绍的 *版本向量**version vector*见 [“检测并发写入”](/ch6#sec_replication_concurrent))。不过,许多多主复制系统并未使用可靠的更新排序技术,因而容易遇到 {{< xref fig="6-8" page="/ch6" anchor="fig_replication_causality" >}}图 6-8{{< /xref >}} 所示的问题。如果使用多主复制,应当了解这些风险,仔细阅读文档,并充分测试数据库,确认它确实提供了你以为它会提供的保证。
### 同步引擎与本地优先软件 {#sec_replication_offline_clients}
@@ -401,7 +401,7 @@ Poons 先生
离线编辑与实时协作需要相似的复制基础设施:应用程序必须捕获用户对文件作出的所有变更,在线时立即发给协作者,离线时则先保存在本地,稍后再发送。同时,应用程序还要接收协作者的变更,将其合并到用户的本地文件副本,并更新界面以显示最新版本。多个用户并发修改文件时,还可能需要用冲突解决逻辑合并这些变更。
支持这一过程的软件库称为 *同步引擎*。这个想法由来已久,但“同步引擎”一词近来才受到关注 [^35] [^36] [^37]。允许用户离线时继续编辑文件的应用程序称为 *离线优先* 应用 [^38],它可以用同步引擎来实现。*本地优先软件* 则不仅要支持离线优先,还要保证即使软件开发者关闭所有在线服务,协作应用仍能继续工作 [^39]。一种实现方式是采用开放标准的同步协议,并让多个服务提供商都能支持这一协议 [^40]。例如Git 就是本地优先的协作系统(虽然它不支持实时协作),因为可以通过 GitHub、GitLab 或其他任意代码仓库托管服务进行同步。
支持这一过程的软件库称为 *同步引擎**sync engine*。这个想法由来已久,但“同步引擎”一词近来才受到关注 [^35] [^36] [^37]。允许用户离线时继续编辑文件的应用程序称为 *离线优先**offline-first*应用 [^38],它可以用同步引擎来实现。*本地优先软件**local-first software*则不仅要支持离线优先,还要保证即使软件开发者关闭所有在线服务,协作应用仍能继续工作 [^39]。一种实现方式是采用开放标准的同步协议,并让多个服务提供商都能支持这一协议 [^40]。例如Git 就是本地优先的协作系统(虽然它不支持实时协作),因为可以通过 GitHub、GitLab 或其他任意代码仓库托管服务进行同步。
#### 同步引擎的利弊 {#pros-and-cons-of-sync-engines}
@@ -410,7 +410,7 @@ Poons 先生
* 数据在本地,用户界面的响应速度可以远快于等待服务调用返回数据。有些应用追求在图形系统的 *下一帧* 响应用户输入:对于刷新率为 60 Hz 的显示器,这意味着要在 16 毫秒内完成渲染。
* 允许用户离线工作很有价值,尤其是在连接时断时续的移动设备上。使用同步引擎后,应用程序无需另设离线模式:离线不过是网络延迟变得非常大。
* 与在应用代码中显式调用服务相比,同步引擎简化了前端应用的编程模型。正如 [“远程过程调用RPC的问题”](/ch5#sec_problems_with_rpc) 中所述,每次服务调用都要处理错误。例如,更新服务器数据的请求失败后,用户界面必须以某种方式反映错误。同步引擎让应用直接读写几乎不会失败的本地数据,从而形成更具声明性的编程风格 [^41]。
* 要实时显示其他用户所作的编辑,需要接收变更通知,并据此高效更新用户界面。同步引擎与 *响应式编程* 模型结合,是实现这一功能的好办法 [^42]。
* 要实时显示其他用户所作的编辑,需要接收变更通知,并据此高效更新用户界面。同步引擎与 *响应式编程**reactive programming*模型结合,是实现这一功能的好办法 [^42]。
如果能事先下载用户可能需要的全部数据,并持久保存在客户端,同步引擎的效果最好。这样一来,需要时就能离线访问;但也意味着,如果用户可以访问的数据量非常大,同步引擎便不适用。例如,下载用户自己创建的全部文件通常没问题(单个用户一般不会产生那么多数据),下载一个电子商务网站的全部商品目录则多半不合理。
@@ -459,7 +459,7 @@ LWW 还有一个问题:如果写入时间戳来自实时时钟(例如 Unix
如果不愿随机丢弃某些写入,下一个选择是手工解决冲突。你可能熟悉 Git 等版本控制系统中的做法:两个分支上的提交修改了同一文件的同一行,合并分支时就会产生合并冲突,必须先解决冲突才能完成合并。
在数据库中,让一次冲突阻塞整个复制过程,直到有人解决,显然并不现实。数据库通常会保存一条记录的所有并发写入值——例如 {{< xref fig="6-9" page="/ch6" anchor="fig_replication_write_conflict" >}}图 6-9{{< /xref >}} 中的 B 和 C。这些值有时称为 *兄弟值*。下次查询该记录时,数据库返回 *全部* 值,而不只是最新的一个。随后可以任意选择解决办法:在应用代码中自动处理(例如把 B 与 C 拼成“B/C”或询问用户最后再向数据库写回一个新值消解冲突。
在数据库中,让一次冲突阻塞整个复制过程,直到有人解决,显然并不现实。数据库通常会保存一条记录的所有并发写入值——例如 {{< xref fig="6-9" page="/ch6" anchor="fig_replication_write_conflict" >}}图 6-9{{< /xref >}} 中的 B 和 C。这些值有时称为 *兄弟值**sibling*。下次查询该记录时,数据库返回 *全部* 值,而不只是最新的一个。随后可以任意选择解决办法:在应用代码中自动处理(例如把 B 与 C 拼成“B/C”或询问用户最后再向数据库写回一个新值消解冲突。
CouchDB 等系统采用这种冲突解决方式,但它也有不少问题:
@@ -519,7 +519,7 @@ OT 最常用于文本的实时协作编辑,例如 Google Docs [^32]CRDT 则
本章此前讨论的单主复制与多主复制,都基于同一个思路:客户端把写请求发给某个节点(领导者),再由数据库系统负责把写入复制到其他副本。领导者决定处理写入的顺序,追随者则按相同顺序应用领导者的写入。
另一些数据存储系统采取了不同办法:放弃领导者概念,允许任何副本直接接受客户端写入。最早的一些复制数据系统采用的就是无主模型 [^1] [^50]但在关系数据库占据主导地位的年代这个思路几乎被遗忘。2007 年,亚马逊把它用于内部的 *Dynamo* 系统 [^45]无主架构由此再度流行。Riak、Cassandra 和 ScyllaDB 都是受 Dynamo 启发、采用无主复制模型的开源数据存储,因此这类数据库也称为 *Dynamo 风格* 数据库。
另一些数据存储系统采取了不同办法:放弃领导者概念,允许任何副本直接接受客户端写入。最早的一些复制数据系统采用的就是无主模型 [^1] [^50]但在关系数据库占据主导地位的年代这个思路几乎被遗忘。2007 年,亚马逊把它用于内部的 *Dynamo* 系统 [^45]无主架构由此再度流行。Riak、Cassandra 和 ScyllaDB 都是受 Dynamo 启发、采用无主复制模型的开源数据存储,因此这类数据库也称为 *Dynamo 风格**Dynamo-style*数据库。
> [!NOTE]
@@ -547,14 +547,14 @@ OT 最常用于文本的实时协作编辑,例如 Google Docs [^32]CRDT 则
复制系统应当保证所有数据最终都会复制到每个副本。不可用节点恢复上线后怎样补上停机期间错过的写入Dynamo 风格的数据存储会使用以下几种机制:
读修复
读修复read repair
: 客户端并行读取多个节点时,可以发现陈旧响应。例如在 {{< xref fig="6-12" page="/ch6" anchor="fig_replication_quorum_node_outage" >}}图 6-12{{< /xref >}} 中,用户 2345 从副本 3 得到版本 6 的值,从副本 1 和副本 2 得到版本 7 的值。客户端发现副本 3 的值已经过时,于是把较新的值写回这个副本。对于经常读取的值,这种方法很有效。
提示移交
提示移交hinted handoff
: 某个副本不可用时,另一个副本可以替它保存写入,并把这些写入记录为 *提示*。原本应接收这些写入的副本恢复后,保存提示的副本会将它们发送过去,然后删除提示。即使某些值从未被读取、无法通过读修复更新,这个 *移交* 过程也能让副本赶上进度。
反熵
: 此外,还有一个后台进程定期查找副本之间的数据差异,把缺失的数据从一个副本复制到另一个。与基于领导者的复制日志不同,这个 *反熵过程* 并不按特定顺序复制写入,数据得到复制之前可能有很长延迟。
反熵anti-entropy
: 此外,还有一个后台进程定期查找副本之间的数据差异,把缺失的数据从一个副本复制到另一个。与基于领导者的复制日志不同,这个 *反熵过程**anti-entropy process*并不按特定顺序复制写入,数据得到复制之前可能有很长延迟。
#### 读写仲裁 {#sec_replication_quorum_condition}
@@ -562,7 +562,7 @@ OT 最常用于文本的实时协作编辑,例如 Google Docs [^32]CRDT 则
如果能保证每次成功写入至少保存在三个副本中的两个上,那么最多只有一个副本是陈旧的。因此,只要读取至少两个副本,就可以确信其中至少一个是最新的。即使第三个副本停机或响应缓慢,读取仍能返回最新值。
更一般地说,假设有 *n* 个副本,每次写入必须得到 *w* 个节点确认才算成功,每次读取则至少查询 *r* 个节点。(上述例子中,*n* = 3、*w* = 2、*r* = 2。只要 *w* + *r* > *n*,读取时就有望得到最新值,因为所查询的 *r* 个节点中,至少有一个必然是最新的。遵守这些 *r*、*w* 取值的操作称为 *仲裁读**仲裁写* [^50]。可以把 *r**w* 看作一次读或写要成立所需的最低票数。
更一般地说,假设有 *n* 个副本,每次写入必须得到 *w* 个节点确认才算成功,每次读取则至少查询 *r* 个节点。(上述例子中,*n* = 3、*w* = 2、*r* = 2。只要 *w* + *r* > *n*,读取时就有望得到最新值,因为所查询的 *r* 个节点中,至少有一个必然是最新的。遵守这些 *r*、*w* 取值的操作称为 *仲裁读**quorum read*)和 *仲裁写**quorum write*[^50]。可以把 *r**w* 看作一次读或写要成立所需的最低票数。
在 Dynamo 风格的数据库中,参数 *n*、*w*、*r* 通常都可以配置。常见做法是让 *n* 取奇数(通常为 3 或 5并令 *w* = *r* = (*n* + 1) / 2向上取整不过也可以根据需要调整。例如写少读多的工作负载可能适合设为 *w* = *n*、*r* = 1。这样读取更快缺点是只要一个节点失效所有数据库写入都会失败。
@@ -625,15 +625,15 @@ OT 最常用于文本的实时协作编辑,例如 Google Docs [^32]CRDT 则
* 领导者失效后,必须等系统检测到故障并完成故障切换,才能继续处理请求。即使故障切换很快,响应时间的短暂上升也会被用户察觉;如果耗时很长,系统就会在此期间不可用。
* 系统对领导者的性能问题极其敏感。如果领导者因过载或资源争用而响应缓慢,用户的响应时间也会立即增加。
无主架构的一大优点,是面对这些问题时韧性更强。系统无需故障切换,而且请求原本就会并行发往多个副本,因此一个副本变慢或不可用,对响应时间的影响很小:客户端只需采用响应较快的其他副本所返回的结果。采用最快响应的做法称为 *请求对冲*,可以显著降低尾延迟 [^55]。
无主架构的一大优点,是面对这些问题时韧性更强。系统无需故障切换,而且请求原本就会并行发往多个副本,因此一个副本变慢或不可用,对响应时间的影响很小:客户端只需采用响应较快的其他副本所返回的结果。采用最快响应的做法称为 *请求对冲**request hedging*,可以显著降低尾延迟 [^55]。
无主系统之所以有这种韧性,根本原因是它不区分正常情况和故障情况。这对于处理所谓的 *灰色失效* 尤其有利:节点并未彻底停机,却处于降级状态,处理请求异常缓慢 [^56];节点单纯过载时也是如此(例如节点离线一段时间后,靠提示移交恢复可能产生大量额外负载)。基于领导者的系统必须判断情况是否严重到需要故障切换,而故障切换本身又可能带来进一步中断;无主系统根本不需要作出这项判断。
无主系统之所以有这种韧性,根本原因是它不区分正常情况和故障情况。这对于处理所谓的 *灰色失效**gray failure*尤其有利:节点并未彻底停机,却处于降级状态,处理请求异常缓慢 [^56];节点单纯过载时也是如此(例如节点离线一段时间后,靠提示移交恢复可能产生大量额外负载)。基于领导者的系统必须判断情况是否严重到需要故障切换,而故障切换本身又可能带来进一步中断;无主系统根本不需要作出这项判断。
当然,无主系统也可能遇到性能问题:
* 即使不需要执行故障切换,也必须由一个副本发现另一个副本不可用,才能替它保存错过写入的提示。不可用副本恢复后,移交过程还要把这些提示发给它。在系统本已承压时,这会给副本增加额外负载 [^54]。
* 副本越多,法定人数越大,请求完成前必须等待的响应也越多。即使只等待最快的 *r**w* 个副本,即使所有请求并行发出,更大的 *r**w* 仍会提高遇到慢副本的概率,从而增加总体响应时间(参见 [“响应时间指标的应用”](/ch2#sec_introduction_slo_sla))。
* 大范围网络中断使客户端与大量副本断开时可能根本无法组成法定人数。有些无主数据库允许任何可达副本接受写入即使它不属于该键通常所在的副本集合Riak 和 Dynamo 称之为 *宽松仲裁* [^45]Cassandra 和 ScyllaDB 称之为 *一致性级别 ANY*)。后续读取不保证能看到这次写入,但对某些应用而言,这仍好过写入直接失败。
* 大范围网络中断使客户端与大量副本断开时可能根本无法组成法定人数。有些无主数据库允许任何可达副本接受写入即使它不属于该键通常所在的副本集合Riak 和 Dynamo 称之为 *宽松仲裁**sloppy quorum* [^45]Cassandra 和 ScyllaDB 称之为 *一致性级别 ANY*)。后续读取不保证能看到这次写入,但对某些应用而言,这仍好过写入直接失败。
多主复制抵御网络中断的能力甚至可能强于无主复制,因为读写只需与一个领导者通信,而领导者可以与客户端位于同一地区。不过,一个领导者上的写入会异步传播给其他领导者,读取结果因而可能任意陈旧。仲裁读写提供了一种折中:既有良好的容错能力,也有很高概率读到最新数据。
@@ -672,7 +672,7 @@ Riak 则把客户端与数据库节点之间的所有通信限制在本地地区
* 在 {{< xref fig="6-8" page="/ch6" anchor="fig_replication_causality" >}}图 6-8{{< /xref >}} 中两次写入并不并发A 的插入 *先发生于* B 的递增,因为 B 所递增的值正是 A 插入的值。换句话说B 的操作建立在 A 的操作之上,所以 B 必然发生得更晚。也可以说B *因果依赖* 于 A。
* {{< xref fig="6-14" page="/ch6" anchor="fig_replication_concurrency" >}}图 6-14{{< /xref >}} 中的两次写入则是并发的:每个客户端开始操作时,都不知道另一个客户端也在操作同一个键。因此,两次操作之间没有因果依赖。
如果操作 B 知道 A、依赖 A或以某种方式建立在 A 之上,就称操作 A *先发生于* 操作 B。一项操作是否先发生于另一项操作是定义并发的关键。事实上只要两个操作谁也不先发生于另一个——也就是说谁都不知道对方——就可以称它们 *并发* [^57]。
如果操作 B 知道 A、依赖 A或以某种方式建立在 A 之上,就称操作 A *先发生于**happens before*操作 B。一项操作是否先发生于另一项操作是定义并发的关键。事实上只要两个操作谁也不先发生于另一个——也就是说谁都不知道对方——就可以称它们 *并发**concurrent*[^57]。
因此,对于任意两个操作 A 与 B只有三种可能A 先发生于 BB 先发生于 A或者 A 与 B 并发。我们需要一种算法判断两次操作是否并发。如果一项操作先发生于另一项,后发生的操作就应覆盖先前操作;如果两者并发,则出现了需要解决的冲突。
@@ -721,16 +721,16 @@ Riak 则把客户端与数据库节点之间的所有通信限制在本地地区
{{< xref fig="6-15" page="/ch6" anchor="fig_replication_causality_single" >}}图 6-15{{< /xref >}} 用一个版本号捕获操作之间的依赖关系,但多个副本并发接受写入时,一个版本号就不够了。此时必须针对每个键,给 *每个副本* 分别维护版本号。副本处理写入时递增自己的版本号,同时记录自己见过的其他副本版本号。这些信息表明哪些值应当覆盖,哪些值应作为兄弟值保留。
所有副本的版本号集合称为 *版本向量* [^58]。这种思路有若干变体,其中最值得关注的也许是 *点化版本向量* [^59] [^60]Riak 2.0 采用了这种变体 [^61] [^62]。这里不展开细节;它的工作方式与购物车例子非常相似。
所有副本的版本号集合称为 *版本向量* [^58]。这种思路有若干变体,其中最值得关注的也许是 *点化版本向量**dotted version vector*[^59] [^60]Riak 2.0 采用了这种变体 [^61] [^62]。这里不展开细节;它的工作方式与购物车例子非常相似。
与 {{< xref fig="6-15" page="/ch6" anchor="fig_replication_causality_single" >}}图 6-15{{< /xref >}} 中的版本号一样读取时数据库副本会把版本向量发给客户端随后写入时客户端必须再把它带回数据库。Riak 把版本向量编码成一个字符串,称为 *因果上下文*。)版本向量让数据库能够区分覆盖写入和并发写入。
与 {{< xref fig="6-15" page="/ch6" anchor="fig_replication_causality_single" >}}图 6-15{{< /xref >}} 中的版本号一样读取时数据库副本会把版本向量发给客户端随后写入时客户端必须再把它带回数据库。Riak 把版本向量编码成一个字符串,称为 *因果上下文**causal context*。)版本向量让数据库能够区分覆盖写入和并发写入。
版本向量还保证:先从一个副本读取,再把写入发给另一个副本,是安全的。这样做可能产生兄弟值,但只要正确合并兄弟值,就不会丢失数据。
> [!TIP] 版本向量与向量时钟
>
> *版本向量* 有时也称为 *向量时钟*,尽管两者并不完全相同。区别十分微妙,细节请参阅相关文献 [^60] [^63] [^64]。简而言之,比较副本状态时,应当使用版本向量。
> *版本向量* 有时也称为 *向量时钟**vector clock*,尽管两者并不完全相同。区别十分微妙,细节请参阅相关文献 [^60] [^63] [^64]。简而言之,比较副本状态时,应当使用版本向量。
## 总结 {#summary}

View File

@@ -17,7 +17,7 @@ breadcrumbs: false
分布式数据库通常通过两种方式在节点间分布数据:
1. 在多个节点上保存相同数据的副本:这就是 *复制*,我们已在 [第 6 章](/ch6#ch_replication) 中讨论过。
1. 在多个节点上保存相同数据的副本:这就是 *复制**replication*,我们已在 [第 6 章](/ch6#ch_replication) 中讨论过。
2. 如果不想让每个节点都存储全部数据,可以将大规模数据集拆成更小的 *分片shard**分区partition*,再把不同分片存放到不同节点上。本章讨论的就是分片。
通常情况下,每条数据(每条记录、每行或每个文档)属于且仅属于一个分片。实现这一点有多种方法,本章将深入讨论其中几种。实际上,每个分片都是自己的小型数据库,尽管有些数据库支持同时涉及多个分片的操作。
@@ -42,23 +42,23 @@ breadcrumbs: false
## 分片的利与弊 {#sec_sharding_reasons}
对数据库进行分片,主要是为了获得 *可伸缩性*:当数据量或写入吞吐量大到单个节点无法承受时,分片可以把数据和写入分散到多个节点上。(如果瓶颈是读取吞吐量,则未必需要分片,可以采用 [第 6 章](/ch6#ch_replication) 介绍的 *读扩展*。)
对数据库进行分片,主要是为了获得 *可伸缩性**scalability*:当数据量或写入吞吐量大到单个节点无法承受时,分片可以把数据和写入分散到多个节点上。(如果瓶颈是读取吞吐量,则未必需要分片,可以采用 [第 6 章](/ch6#ch_replication) 介绍的 *读扩展**read scaling*。)
事实上,分片是实现 *水平扩展**横向扩展* 架构)的主要手段之一,正如 [“共享内存、共享磁盘与无共享架构”](/ch2#sec_introduction_shared_nothing) 所述:系统不必换用更大的机器,而是通过增加更多(较小的)机器来扩充容量。如果能合理划分工作负载,让每个分片承担大致相等的份额,就可以把这些分片分配给不同机器,并行处理其中的数据和查询。
事实上,分片是实现 *水平扩展**horizontal scaling*,也称 *横向扩展**scale-out* 架构)的主要手段之一,正如 [“共享内存、共享磁盘与无共享架构”](/ch2#sec_introduction_shared_nothing) 所述:系统不必换用更大的机器,而是通过增加更多(较小的)机器来扩充容量。如果能合理划分工作负载,让每个分片承担大致相等的份额,就可以把这些分片分配给不同机器,并行处理其中的数据和查询。
复制可以提供容错和离线运行能力,因而无论规模大小都有用;分片却是一种重量级方案,主要适用于大规模场景。如果数据量和写入吞吐量仍可由单台机器处理(如今单机的能力可不容小觑!),通常最好避免分片,坚持使用单分片数据库。
之所以这样建议,是因为分片往往会增加复杂性。通常需要选择一个 *分区键*,据此决定每条记录应放入哪个分片;分区键相同的记录都会进入同一分片 [^4]。这个选择十分重要:如果知道记录在哪个分片,访问就很快;如果不知道,就只能低效地搜索所有分片,而且日后很难更改分片方案。
之所以这样建议,是因为分片往往会增加复杂性。通常需要选择一个 *分区键**partition key*,据此决定每条记录应放入哪个分片;分区键相同的记录都会进入同一分片 [^4]。这个选择十分重要:如果知道记录在哪个分片,访问就很快;如果不知道,就只能低效地搜索所有分片,而且日后很难更改分片方案。
因此,分片通常很适合键值数据,因为可以直接按键分片;关系数据则比较棘手,因为你可能需要通过二级索引搜索,或连接散落在不同分片中的记录。我们将在 [“分片与二级索引”](/ch7#sec_sharding_secondary_indexes) 中进一步讨论这个问题。
分片还有一个问题:一次写入可能需要更新多个不同分片中的相关记录。单节点事务相当普遍(参见 [第 8 章](/ch8#ch_transactions)),但要保证多个分片之间的一致性,就需要 *分布式事务*。正如 [第 8 章](/ch8#ch_transactions) 将要说明的,有些数据库支持分布式事务,但这类事务通常比单节点事务慢得多,可能成为整个系统的瓶颈;还有些系统根本不支持分布式事务。
分片还有一个问题:一次写入可能需要更新多个不同分片中的相关记录。单节点事务相当普遍(参见 [第 8 章](/ch8#ch_transactions)),但要保证多个分片之间的一致性,就需要 *分布式事务**distributed transaction*。正如 [第 8 章](/ch8#ch_transactions) 将要说明的,有些数据库支持分布式事务,但这类事务通常比单节点事务慢得多,可能成为整个系统的瓶颈;还有些系统根本不支持分布式事务。
有些系统甚至会在单台机器上使用分片,通常是在每个 CPU 核心上运行一个单线程进程,以利用 CPU 的并行能力;或者利用 *非一致性内存访问*NUMA架构因为其中某些内存区域离特定 CPU 比其他 CPU 更近 [^5]。例如Redis、VoltDB 和 FoundationDB 都采用每个核心一个进程的方式,并依靠分片把负载分摊到同一台机器的各个 CPU 核心上 [^6]。
### 面向多租户的分片 {#sec_sharding_multitenancy}
软件即服务SaaS产品和云服务通常采用 *多租户* 模式,每个租户对应一个客户。同一租户可以有多个用户账号,但每个租户拥有一份自成一体、与其他租户隔离的数据集。例如在电子邮件营销服务中,每家注册企业通常都是一个独立租户,因为各家企业的简报订阅信息、投递数据等彼此无关。
软件即服务SaaS产品和云服务通常采用 *多租户**multitenant*模式,每个租户对应一个客户。同一租户可以有多个用户账号,但每个租户拥有一份自成一体、与其他租户隔离的数据集。例如在电子邮件营销服务中,每家注册企业通常都是一个独立租户,因为各家企业的简报订阅信息、投递数据等彼此无关。
多租户系统有时通过分片来实现:可以为每个租户分配一个独立分片,也可以把多个小租户归入一个较大的分片。这些分片可以是物理上相互独立的数据库(我们曾在 [“嵌入式存储引擎”](/ch4#sidebar_embedded) 中提到),也可以是一个更大逻辑数据库中能够单独管理的组成部分 [^7]。用分片实现多租户有以下优点:
@@ -69,7 +69,7 @@ breadcrumbs: false
: 如果访问控制逻辑存在漏洞,只要各租户的数据集在物理上彼此隔离,意外让一个租户访问另一租户数据的可能性就会降低。
单元化架构
: 分片不仅可以用在数据存储层,也可以用来划分运行应用代码的服务。在 *单元化架构* 中,为一组特定租户服务的应用与存储会组成一个自包含的 *单元*,不同单元大体可以彼此独立地运行。这种方法能够实现 *故障隔离*:一个单元里的故障只影响该单元,不会殃及其他单元中的租户 [^8]。
: 分片不仅可以用在数据存储层,也可以用来划分运行应用代码的服务。在 *单元化架构**cell-based architecture*中,为一组特定租户服务的应用与存储会组成一个自包含的 *单元**cell*,不同单元大体可以彼此独立地运行。这种方法能够实现 *故障隔离**fault isolation*:一个单元里的故障只影响该单元,不会殃及其他单元中的租户 [^8]。
按租户备份和恢复
: 分别备份每个租户的分片,就能从备份中恢复某个租户的状态,而不影响其他租户。租户意外删除或覆盖重要数据时,这一能力很有用 [^9]。
@@ -95,9 +95,9 @@ breadcrumbs: false
假设你有大量数据并且想要分片,如何决定在哪些节点上存储哪些记录呢?
分片的目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分担数据和负载那么理论上10 个节点应该能够处理单个节点 10 倍的数据量和 10 倍的读写吞吐量(暂时忽略复制)。此外,在添加或移除节点时,我们希望能够 *再平衡* 负载,使它均匀分布在增加后的 11 个节点上,或移除节点后剩余的 9 个节点上。
分片的目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分担数据和负载那么理论上10 个节点应该能够处理单个节点 10 倍的数据量和 10 倍的读写吞吐量(暂时忽略复制)。此外,在添加或移除节点时,我们希望能够 *再平衡**rebalance*负载,使它均匀分布在增加后的 11 个节点上,或移除节点后剩余的 9 个节点上。
如果分片不公平,某些分片承载的数据或查询比其他分片更多,我们就称其为 *倾斜*。倾斜会大幅降低分片的效果。在极端情况下全部负载都可能集中到一个分片上10 个节点中有 9 个闲置,瓶颈却卡在唯一繁忙的节点上。负载高得不成比例的分片称为 *热分片**热点*;如果某个键的负载特别高(例如社交网络中的名人账号),则称为 *热键*
如果分片不公平,某些分片承载的数据或查询比其他分片更多,我们就称其为 *倾斜**skew*。倾斜会大幅降低分片的效果。在极端情况下全部负载都可能集中到一个分片上10 个节点中有 9 个闲置,瓶颈却卡在唯一繁忙的节点上。负载高得不成比例的分片称为 *热分片**hot shard*)或 *热点**hot spot*;如果某个键的负载特别高(例如社交网络中的名人账号),则称为 *热键**hot key*
因此,我们需要一种算法,以记录的分区键为输入,指出这条记录属于哪个分片。在键值存储中,分区键通常就是键或键的第一部分;在关系模型中,它可以是表中的某一列,不一定非得是主键。为了缓解热点,这种算法还必须便于再平衡。
@@ -193,14 +193,14 @@ YugabyteDB 和 DynamoDB 采用哈希范围分片 [^17]MongoDB 也把它作为
#### 一致性哈希 {#sec_sharding_consistent_hashing}
*一致性哈希* 算法是一种哈希函数,它把键映射到指定数量的分片,并满足两个性质:
*一致性哈希**consistent hashing*算法是一种哈希函数,它把键映射到指定数量的分片,并满足两个性质:
1. 映射到各个分片的键数大致相等;
2. 分片数量改变时,尽可能少地在分片之间迁移键。
注意,这里的 *一致性* 与副本一致性(参见 [第 6 章](/ch6#ch_replication))或 ACID 一致性(参见 [第 8 章](/ch8#ch_transactions))毫无关系;它描述的是让一个键尽量留在原分片中的倾向。
Cassandra 和 ScyllaDB 的分片算法与一致性哈希的原始定义相似 [^20],此外还有人提出了多种其他一致性哈希算法 [^21],例如 *最高随机权重*也称 *约会哈希*[^22] 和 *跳跃一致性哈希* [^23]。采用 Cassandra 的算法时,加入一个节点会把少量现有分片拆成若干子范围;采用约会哈希或跳跃一致性哈希时,新节点得到的则是此前散布在所有其他节点上的一个个键。哪种方式更合适,取决于具体应用。
Cassandra 和 ScyllaDB 的分片算法与一致性哈希的原始定义相似 [^20],此外还有人提出了多种其他一致性哈希算法 [^21],例如 *最高随机权重**highest random weight*,也称 *约会哈希**rendezvous hashing*[^22] 和 *跳跃一致性哈希**jump consistent hash*[^23]。采用 Cassandra 的算法时,加入一个节点会把少量现有分片拆成若干子范围;采用约会哈希或跳跃一致性哈希时,新节点得到的则是此前散布在所有其他节点上的一个个键。哪种方式更合适,取决于具体应用。
### 倾斜的工作负载与缓解热点 {#sec_sharding_skew}
@@ -216,7 +216,7 @@ Cassandra 和 ScyllaDB 的分片算法与一致性哈希的原始定义相似 [^
负载还会随时间变化,使问题更加复杂。例如,某条突然爆火的社交媒体帖子可能连续几天承受很高负载,之后又很快归于平静。此外,有些键是写入热点,有些则是读取热点,二者需要采用不同的处理策略。
一些系统尤其是面向大规模场景设计的云服务能够自动处理热分片例如Amazon 把相关机制称为 *热度管理* [^28] 或 *自适应容量* [^17]。这些系统的具体工作方式超出了本书的讨论范围。
一些系统尤其是面向大规模场景设计的云服务能够自动处理热分片例如Amazon 把相关机制称为 *热度管理**heat management*[^28] 或 *自适应容量**adaptive capacity*[^17]。这些系统的具体工作方式超出了本书的讨论范围。
### 运维:自动/手动再平衡 {#sec_sharding_operations}
@@ -238,7 +238,7 @@ Cassandra 和 ScyllaDB 的分片算法与一致性哈希的原始定义相似 [^
我们已经讨论了如何把数据集分片到多个节点,以及如何在增删节点时再平衡这些分片。现在来看下一个问题:如果想读写某个特定的键,怎样知道应该连接哪个节点——也就是哪个 IP 地址和端口?
这个问题称为 *请求路由*,与前文 [“负载均衡器、服务发现和服务网格”](/ch5#sec_encoding_service_discovery) 讨论的 *服务发现* 十分相似。二者最大的区别在于:运行应用代码的服务实例通常是无状态的,负载均衡器可以把请求发给任意实例;而在分片数据库中,某个键的请求只能交给持有该键所在分片副本的节点处理。
这个问题称为 *请求路由**request routing*,与前文 [“负载均衡器、服务发现和服务网格”](/ch5#sec_encoding_service_discovery) 讨论的 *服务发现**service discovery*十分相似。二者最大的区别在于:运行应用代码的服务实例通常是无状态的,负载均衡器可以把请求发给任意实例;而在分片数据库中,某个键的请求只能交给持有该键所在分片副本的节点处理。
因此,请求路由必须了解键到分片、以及分片到节点的映射。概括来说,有以下几种办法(如 {{< xref fig="7-7" page="/ch7" anchor="fig_sharding_routing" >}}图 7-7{{< /xref >}} 所示):
@@ -258,9 +258,9 @@ Cassandra 和 ScyllaDB 的分片算法与一致性哈希的原始定义相似 [^
{{< fig num="7-8" id="fig_sharding_zookeeper" src="/fig/ddia_0708.png" caption="使用 ZooKeeper 跟踪分片到节点的分配。" class="ddia-figure ddia-figure--wide" width="2658" height="1163" />}}
例如HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配Kubernetes 使用 etcd 记录每个服务实例的运行位置。MongoDB 的架构与之相似,不过它依靠自有的 *配置服务器* 实现,并以 *mongos* 守护进程作为路由层。Kafka、YugabyteDB 和 TiDB 则使用内置的 Raft 共识协议实现这项协调功能。
例如HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配Kubernetes 使用 etcd 记录每个服务实例的运行位置。MongoDB 的架构与之相似,不过它依靠自有的 *配置服务器**config server*实现,并以 *mongos* 守护进程作为路由层。Kafka、YugabyteDB 和 TiDB 则使用内置的 Raft 共识协议实现这项协调功能。
Cassandra、ScyllaDB 和 Riak 采用另一种办法:节点之间通过 *流言协议* 传播集群状态的变化。它提供的一致性比共识协议弱得多,因而可能出现脑裂,使集群的不同部分对同一个分片持有不同的节点分配。无主数据库可以容忍这种情况,因为它们本就只提供较弱的一致性保证(参见 [“仲裁一致性的局限”](/ch6#sec_replication_quorum_limitations))。
Cassandra、ScyllaDB 和 Riak 采用另一种办法:节点之间通过 *流言协议**gossip protocol*传播集群状态的变化。它提供的一致性比共识协议弱得多,因而可能出现脑裂,使集群的不同部分对同一个分片持有不同的节点分配。无主数据库可以容忍这种情况,因为它们本就只提供较弱的一致性保证(参见 [“仲裁一致性的局限”](/ch6#sec_replication_quorum_limitations))。
无论使用路由层还是把请求发送给随机节点,客户端仍然要先找到可供连接的 IP 地址。IP 地址的变化没有分片到节点的分配那么频繁,因此通常用 DNS 就足够了。
@@ -278,7 +278,7 @@ Cassandra、ScyllaDB 和 Riak 采用另一种办法:节点之间通过 *流言
假设你正在运营一个二手车交易网站(如 {{< xref fig="7-9" page="/ch7" anchor="fig_sharding_local_secondary" >}}图 7-9{{< /xref >}} 所示)。每条车辆信息都有唯一 ID并以该 ID 作为分区键进行分片例如ID 0 到 499 归分片 0ID 500 到 999 归分片 1依此类推
如果要让用户搜索车辆,并按颜色与品牌筛选,就需要在 `color``make` 上建立二级索引(在文档数据库中它们是字段,在关系数据库中则是列)。声明索引后,数据库会自动维护它。例如,每增加一辆红色汽车,所在分片就会自动把它的 ID 加入索引条目 `color:red` 对应的 ID 列表。正如 [第 4 章](/ch4#ch_storage) 所述,这种 ID 列表也称为 *倒排列表*
如果要让用户搜索车辆,并按颜色与品牌筛选,就需要在 `color``make` 上建立二级索引(在文档数据库中它们是字段,在关系数据库中则是列)。声明索引后,数据库会自动维护它。例如,每增加一辆红色汽车,所在分片就会自动把它的 ID 加入索引条目 `color:red` 对应的 ID 列表。正如 [第 4 章](/ch4#ch_storage) 所述,这种 ID 列表也称为 *倒排列表**postings list*
{{< fig num="7-9" id="fig_sharding_local_secondary" src="/fig/ddia_0709.png" caption="本地二级索引:每个分片只索引其自己分片内的记录。" class="ddia-figure ddia-figure--wide" width="2658" height="1260" />}}
@@ -286,7 +286,7 @@ Cassandra、ScyllaDB 和 Riak 采用另一种办法:节点之间通过 *流言
>
> 如果数据库只支持键值模型,你也许会想在应用代码中建立值到 ID 的映射,自行实现二级索引。如果选择这条路,务必万分小心,确保索引与底层数据始终一致。竞态条件和间歇性写入失败(有些变更保存成功,另一些却没有)很容易让两者失去同步——参见 [“多对象事务的需求”](/ch8#sec_transactions_need)。
在这种索引方式中,每个分片都完全独立:各自维护自己的二级索引,只覆盖本分片中的记录,而不关心其他分片保存了什么数据。每次写入数据库——添加、删除或更新记录——只需处理包含该记录的分片。因此,这种二级索引称为 *本地索引*;在信息检索领域,它也称为 *按文档分区的索引* [^30]。
在这种索引方式中,每个分片都完全独立:各自维护自己的二级索引,只覆盖本分片中的记录,而不关心其他分片保存了什么数据。每次写入数据库——添加、删除或更新记录——只需处理包含该记录的分片。因此,这种二级索引称为 *本地索引**local index*;在信息检索领域,它也称为 *按文档分区的索引**document-partitioned index*[^30]。
读取本地二级索引时,如果已经知道目标记录的分区键,就只需在对应分片上搜索。如果只想获得 *部分* 结果而不要求全部,也可以把请求发给任意分片。
@@ -298,13 +298,13 @@ Cassandra、ScyllaDB 和 Riak 采用另一种办法:节点之间通过 *流言
### 全局二级索引 {#id167}
除了让每个分片各自维护本地二级索引,也可以构建一个覆盖所有分片数据的 *全局索引*。不过,不能只把这个索引存放在单个节点上,否则它很可能成为瓶颈,使分片失去意义。因此全局索引本身也必须分片,但可以采用与主键索引不同的分片方式。
除了让每个分片各自维护本地二级索引,也可以构建一个覆盖所有分片数据的 *全局索引**global index*。不过,不能只把这个索引存放在单个节点上,否则它很可能成为瓶颈,使分片失去意义。因此全局索引本身也必须分片,但可以采用与主键索引不同的分片方式。
{{< xref fig="7-10" page="/ch7" anchor="fig_sharding_global_secondary" >}}图 7-10{{< /xref >}} 展示了它可能采用的形式:来自所有分片的红色汽车 ID 都列在索引的 `color:red` 条目下;索引本身则经过分片,以字母 *a**r* 开头的颜色归分片 0*s**z* 开头的颜色归分片 1。汽车品牌索引也以类似方式分片边界位于 *f**h* 之间。
{{< fig num="7-10" id="fig_sharding_global_secondary" src="/fig/ddia_0710.png" caption="全局二级索引反映来自所有分片的数据,并且本身按索引值进行分片。" class="ddia-figure ddia-figure--wide" width="2658" height="1129" />}}
这种索引也称为 *按词项分区* [^30]。回顾 [“全文检索”](/ch4#sec_storage_full_text):在全文检索中,*词项* 是文本中可供搜索的关键字;这里我们把它推广为二级索引中任何可供搜索的值。
这种索引也称为 *按词项分区**term-partitioned*[^30]。回顾 [“全文检索”](/ch4#sec_storage_full_text):在全文检索中,*词项**term*是文本中可供搜索的关键字;这里我们把它推广为二级索引中任何可供搜索的值。
全局索引以词项作为分区键,因此查找某个词项或值时,可以直接确定需要查询哪个分片。和前面一样,每个分片可以包含一段连续的词项范围(如 {{< xref fig="7-10" page="/ch7" anchor="fig_sharding_global_secondary" >}}图 7-10{{< /xref >}} 所示),也可以根据词项的哈希值把词项分配到各个分片。

View File

@@ -27,17 +27,17 @@ breadcrumbs: false
要做到可靠,系统就必须处理这些故障,确保它们不会导致整个系统灾难性地失效。然而,实现容错机制的工作量很大:既要审慎考虑所有可能出错的情况,又要经过大量测试,才能确保解决方案真正有效。
数十年来,*事务* 一直是简化这些问题的首选机制。应用程序通过事务将多个读写操作组合成一个逻辑单元。从概念上讲,事务中的所有读写被当作一次操作执行:整个事务要么成功(*提交*),要么失败(*中止*、*回滚*)。如果失败,应用程序可以安全重试。有了事务,应用程序的错误处理就简单多了,因为它不必担心部分失效——即无论出于何种原因,有些操作成功、有些操作失败。
数十年来,*事务**transaction*一直是简化这些问题的首选机制。应用程序通过事务将多个读写操作组合成一个逻辑单元。从概念上讲,事务中的所有读写被当作一次操作执行:整个事务要么成功(*提交**commit*),要么失败(*中止**abort**回滚**rollback*)。如果失败,应用程序可以安全重试。有了事务,应用程序的错误处理就简单多了,因为它不必担心部分失效——即无论出于何种原因,有些操作成功、有些操作失败。
如果你与事务打了多年交道,可能会觉得这一切理所当然,但事务并非自然法则。人们创造事务自有其目的:*简化* 访问数据库的 *应用编程模型*。有了事务,应用程序便可以不去考虑某些潜在的错误场景和并发问题,因为数据库会代为处理这些问题(我们称之为 *安全保证*)。
如果你与事务打了多年交道,可能会觉得这一切理所当然,但事务并非自然法则。人们创造事务自有其目的:*简化* 访问数据库的 *应用编程模型**programming model*。有了事务,应用程序便可以不去考虑某些潜在的错误场景和并发问题,因为数据库会代为处理这些问题(我们称之为 *安全保证**safety guarantee*)。
并非所有应用程序都需要事务;有时弱化事务保证,甚至彻底放弃事务也有好处(例如为了提高性能或可用性)。有些安全属性也可以在没有事务的情况下实现。另一方面,事务能够避免许多麻烦:例如,邮局 Horizon 丑闻(参见[“可靠性有多重要?”](/ch2#sidebar_reliability_importance))背后的技术原因,很可能就是底层会计系统缺少 ACID 事务[^1]。
怎样判断自己是否需要事务?要回答这个问题,首先必须确切理解事务能提供哪些安全保证,以及需要为此付出什么代价。事务乍看简单,其中却有许多微妙而重要的细节。
本章将考察许多可能出错的案例,并探讨数据库用来防范这些问题的算法。我们尤其会深入 *并发控制* 领域,讨论可能出现的各种竞态条件,以及数据库如何实现 *读已提交*、*快照隔离* 和 *可串行化* 等隔离级别。
本章将考察许多可能出错的案例,并探讨数据库用来防范这些问题的算法。我们尤其会深入 *并发控制**concurrency control*领域,讨论可能出现的各种竞态条件,以及数据库如何实现 *读已提交**read committed*)、*快照隔离**snapshot isolation*)和 *可串行化**serializable*等隔离级别。
无论对单节点数据库还是分布式数据库,并发控制都很重要。在本章稍后的[“分布式事务”](/ch8#sec_transactions_distributed)一节中,我们将考察 *两阶段提交* 协议,以及在分布式事务中实现原子性所面临的挑战。
无论对单节点数据库还是分布式数据库,并发控制都很重要。在本章稍后的[“分布式事务”](/ch8#sec_transactions_distributed)一节中,我们将考察 *两阶段提交**two-phase commit*2PC协议,以及在分布式事务中实现原子性所面临的挑战。
## 事务到底是什么? {#sec_transactions_overview}
@@ -69,7 +69,7 @@ ACID 的原子性描述的是另一类情况:客户端想执行多次写入,
如果没有原子性,在多项变更执行到一半时发生错误,应用程序很难知道哪些变更已经生效、哪些尚未生效。它可以再试一次,却可能把同一项变更执行两遍,造成重复或错误的数据。原子性简化了这个问题:如果事务已经中止,应用程序便能确定它没有改变任何内容,因此可以安全重试。
遇到错误时中止事务,并丢弃该事务的所有写入,正是 ACID 原子性的定义性特征。或许称为 *可中止性* *原子性* 更贴切,但既然“原子性”是惯用说法,本书仍沿用这一术语。
遇到错误时中止事务,并丢弃该事务的所有写入,正是 ACID 原子性的定义性特征。或许称为 *可中止性**abortability**原子性* 更贴切,但既然“原子性”是惯用说法,本书仍沿用这一术语。
#### 一致性 {#sec_transactions_acid_consistency}
@@ -83,9 +83,9 @@ ACID 的原子性描述的是另一类情况:客户端想执行多次写入,
遗憾的是,同一个词竟有至少五种不同的含义。
ACID 一致性的基本思想是:关于数据的某些陈述(即 *不变式*)必须始终成立。例如,在会计系统中,所有账户的贷记与借记必须始终相抵。如果事务开始时数据库满足这些不变式,事务中的所有写入又能维持其有效性,就可以确信不变式始终成立。(不变式在事务执行期间可以暂时被打破,但到事务提交时必须重新得到满足。)
ACID 一致性的基本思想是:关于数据的某些陈述(即 *不变式**invariant*)必须始终成立。例如,在会计系统中,所有账户的贷记与借记必须始终相抵。如果事务开始时数据库满足这些不变式,事务中的所有写入又能维持其有效性,就可以确信不变式始终成立。(不变式在事务执行期间可以暂时被打破,但到事务提交时必须重新得到满足。)
如果希望由数据库强制执行不变式,就需要把它们声明为模式中的 *约束*。外键约束、唯一性约束或检查约束(限制单行中可以出现的值)常用来表达特定类型的不变式。更复杂的一致性要求有时也可以用触发器或物化视图来表达[^12]。
如果希望由数据库强制执行不变式,就需要把它们声明为模式中的 *约束**constraint*。外键约束、唯一性约束或检查约束(限制单行中可以出现的值)常用来表达特定类型的不变式。更复杂的一致性要求有时也可以用触发器或物化视图来表达[^12]。
不过数据库通常提供的约束可能很难、甚至根本无法表达复杂的不变式。此时应用程序就有责任正确定义事务使其维持一致性。如果应用写入了违反不变式的错误数据却没有事先声明这些不变式数据库也无从阻止。因此ACID 中的 C 往往取决于应用程序如何使用数据库,并非数据库自身的属性。
@@ -176,7 +176,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
> [!NOTE]
> 严格来说,*原子递增* 中的“原子”采用的是多线程编程中的含义。在 ACID 的语境下,它其实应称为 *隔离递增* 或 *可串行化递增*,但通常并不这样叫。
> 严格来说,*原子递增**atomic increment*中的“原子”采用的是多线程编程中的含义。在 ACID 的语境下,它其实应称为 *隔离递增**isolated increment*)或 *可串行化递增**serializable increment*,但通常并不这样叫。
这些单对象操作很有用,因为它们能避免多个客户端同时写入同一对象时发生丢失更新(参见[“防止丢失更新”](/ch8#sec_transactions_lost_update)。但它们并不是通常意义上的事务。例如Cassandra 和 ScyllaDB 的“轻量级事务”功能,以及 Aerospike 的“强一致性”模式,都能在单个对象上提供线性一致的读取和条件写入(参见[“线性一致性”](/ch10#sec_consistency_linearizability)),却不为多个对象之间的操作提供保证。
@@ -217,7 +217,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
并发缺陷很难通过测试发现,因为它们只有在时序碰巧不利时才会触发。这种时序问题可能极少发生,通常也难以重现。并发行为本身同样难以推理,尤其是在大型应用中,你未必知道还有哪些代码正在访问数据库。即使每次只有一个用户,应用开发也已经不容易;面对许多并发用户则更为困难,因为任何数据都可能随时发生意料之外的变化。
正因如此,数据库长期以来一直试图通过 *事务隔离* 向应用开发者隐藏并发问题。理论上,隔离让你可以假装并发根本不存在:*可串行化* 隔离意味着,数据库保证事务产生的效果与 *串行* 运行(即逐个运行、毫无并发)相同。
正因如此,数据库长期以来一直试图通过 *事务隔离**transaction isolation*向应用开发者隐藏并发问题。理论上,隔离让你可以假装并发根本不存在:*可串行化* 隔离意味着,数据库保证事务产生的效果与 *串行* 运行(即逐个运行、毫无并发)相同。
遗憾的是,实践中的隔离并没有这么简单。可串行化隔离有性能代价,许多数据库不愿为此买单[^10]。因此,系统普遍采用较弱的隔离级别,只防范 *一部分* 而非全部并发问题。这些隔离级别更难理解,也可能引发微妙的错误,却仍在实践中广泛使用[^29]。
@@ -234,10 +234,10 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
### 读已提交 {#sec_transactions_read_committed}
最基本的事务隔离级别是 *读已提交*,它提供两项保证:
最基本的事务隔离级别是 *读已提交**read committed*,它提供两项保证:
1. 从数据库读取时,只能看到已经提交的数据(没有 *脏读*)。
2. 向数据库写入时,只能覆盖已经提交的数据(没有 *脏写*)。
1. 从数据库读取时,只能看到已经提交的数据(没有 *脏读**dirty read*)。
2. 向数据库写入时,只能覆盖已经提交的数据(没有 *脏写**dirty write*)。
有些数据库还支持更弱的 *读未提交* 隔离级别。它能防止脏写,却不能防止脏读。下面详细讨论这两项保证。
@@ -293,7 +293,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
假设 Aaliyah 在银行有 1,000 美元存款,分别存在两个账户中,每个账户 500 美元。现在有一笔事务从其中一个账户向另一个账户转账 100 美元。如果她偏偏在转账事务处理的同时查看账户余额可能会先看到一个账户尚未收到转入款项时的余额500 美元再看到另一个账户已经转出款项后的余额400 美元)。在 Aaliyah 看来,两个账户总共只剩 900 美元——仿佛有 100 美元凭空消失了。
这种异常称为 *读偏差*,是 *不可重复读* 的一种:如果 Aaliyah 在转账事务结束后再次读取账户 1 的余额,会得到 600 美元,与上一次查询看到的值不同。读已提交隔离允许读偏差,因为 Aaliyah 读到各账户余额的那一刻,它们确实都已经提交。
这种异常称为 *读偏差**read skew*,是 *不可重复读**nonrepeatable read*的一种:如果 Aaliyah 在转账事务结束后再次读取账户 1 的余额,会得到 600 美元,与上一次查询看到的值不同。读已提交隔离允许读偏差,因为 Aaliyah 读到各账户余额的那一刻,它们确实都已经提交。
> [!NOTE]
@@ -318,7 +318,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
和读已提交一样,快照隔离通常用写锁来防止脏写(参见[“实现读已提交”](/ch8#sec_transactions_read_committed_impl))。因此,一个写事务可以阻塞另一个写入同一行的事务。不过,读取无须取得任何锁。从性能角度看,快照隔离的一项关键原则是:*读不阻塞写,写也不阻塞读*。这样,数据库可以一边在一致快照上执行长期读查询,一边正常处理写入,二者不会争用锁。
为实现快照隔离,数据库把{{< xref fig="8-4" page="/ch8" anchor="fig_transactions_read_committed" >}}图 8-4{{< /xref >}}中的防脏读机制推广开来。数据库不再只为每行保留两个版本(已提交版本,以及覆盖它但尚未提交的新版本),而是可能需要保留多个不同的已提交版本,因为正在运行的不同事务可能要观察数据库在不同时刻的状态。由于同一行的多个版本并存,这项技术称为 *多版本并发控制*MVCC
为实现快照隔离,数据库把{{< xref fig="8-4" page="/ch8" anchor="fig_transactions_read_committed" >}}图 8-4{{< /xref >}}中的防脏读机制推广开来。数据库不再只为每行保留两个版本(已提交版本,以及覆盖它但尚未提交的新版本),而是可能需要保留多个不同的已提交版本,因为正在运行的不同事务可能要观察数据库在不同时刻的状态。由于同一行的多个版本并存,这项技术称为 *多版本并发控制**multi-version concurrency control*MVCC
{{< xref fig="8-7" page="/ch8" anchor="fig_transactions_mvcc" >}}图 8-7{{< /xref >}}展示了 PostgreSQL 如何基于 MVCC 实现快照隔离[^40] [^42] [^43](其他实现与之类似)。事务启动时会获得一个唯一且单调递增的事务 ID`txid`)。事务写入数据库的任何数据,都会以写入者的事务 ID 标记。严格来说PostgreSQL 的事务 ID 是 32 位整数,大约经过 40 亿个事务就会溢出;`vacuum` 进程负责清理,确保溢出不会影响数据。)
@@ -363,7 +363,7 @@ CouchDB、Datomic 和 LMDB 采用另一种方法。它们同样使用 B 树(
#### 快照隔离、可重复读和命名混淆 {#snapshot-isolation-repeatable-read-and-naming-confusion}
MVCC 是数据库常用的实现技术,也经常用来实现快照隔离。不过,不同数据库有时会用不同术语指代同一件事:例如,快照隔离在 PostgreSQL 中称为“可重复读”,在 Oracle 中称为“可串行化”[^29]。反过来不同系统也会用同一个术语表示不同事物PostgreSQL 的“可重复读”指快照隔离MySQL 的“可重复读”却是一个一致性弱于快照隔离的 MVCC 实现[^41]。
MVCC 是数据库常用的实现技术,也经常用来实现快照隔离。不过,不同数据库有时会用不同术语指代同一件事:例如,快照隔离在 PostgreSQL 中称为“可重复读repeatable read”,在 Oracle 中称为“可串行化”[^29]。反过来不同系统也会用同一个术语表示不同事物PostgreSQL 的“可重复读”指快照隔离MySQL 的“可重复读”却是一个一致性弱于快照隔离的 MVCC 实现[^41]。
之所以会出现这种命名混乱,是因为 SQL 标准没有快照隔离的概念。该标准建立在 System R 于 1975 年定义的隔离级别之上[^3]而那时快照隔离尚未问世。标准定义的是表面上与快照隔离相似的“可重复读”。PostgreSQL 的快照隔离符合这项标准要求,于是将它称为“可重复读”,从而可以宣称符合标准。
@@ -375,9 +375,9 @@ MVCC 是数据库常用的实现技术,也经常用来实现快照隔离。不
到目前为止,读已提交和快照隔离主要保证的是:存在并发写入时,只读事务能够看到什么。我们基本没有讨论两个事务并发写入的问题,只讲过一种特定的写—写冲突——脏写(参见[“没有脏写”](/ch8#sec_transactions_dirty_write))。
并发写事务之间还可能出现其他几类值得关注的冲突,其中最著名的便是 *丢失更新*。{{< xref fig="8-1" page="/ch8" anchor="fig_transactions_increment" >}}图 8-1{{< /xref >}}以两个并发递增计数器的事务为例,展示了这个问题。
并发写事务之间还可能出现其他几类值得关注的冲突,其中最著名的便是 *丢失更新**lost update*。{{< xref fig="8-1" page="/ch8" anchor="fig_transactions_increment" >}}图 8-1{{< /xref >}}以两个并发递增计数器的事务为例,展示了这个问题。
应用程序从数据库读取一个值,修改后再写回去,这个过程称为 *读取—修改—写入循环*,可能发生丢失更新。如果两个事务并发执行这样的循环,其中一项修改可能丢失,因为后一次写入没有包含前一个事务的修改。(有时也说后一次写入 *抹掉* 了前一次写入。)这种模式会出现在许多场景中:
应用程序从数据库读取一个值,修改后再写回去,这个过程称为 *读取—修改—写入循环**read-modify-write cycle*,可能发生丢失更新。如果两个事务并发执行这样的循环,其中一项修改可能丢失,因为后一次写入没有包含前一个事务的修改。(有时也说后一次写入 *抹掉* 了前一次写入。)这种模式会出现在许多场景中:
* 递增计数器或更新账户余额(读取当前值、计算新值,再把新值写回);
* 局部修改复杂值,例如向 JSON 文档内的列表添加元素(解析文档、作出修改,再把修改后的文档写回);
@@ -387,7 +387,7 @@ MVCC 是数据库常用的实现技术,也经常用来实现快照隔离。不
#### 原子写操作 {#atomic-write-operations}
许多数据库提供原子更新操作,使应用程序不必自行实现读取—修改—写入循环。如果业务逻辑可以用这些操作表达,它们通常是最佳方案。例如,下面这条语句在大多数关系型数据库中都能安全地并发执行:
许多数据库提供 *原子写操作**atomic write operation*,使应用程序不必自行实现读取—修改—写入循环。如果业务逻辑可以用这些操作表达,它们通常是最佳方案。例如,下面这条语句在大多数关系型数据库中都能安全地并发执行:
```sql
UPDATE counters SET value = value + 1 WHERE key = 'foo';
@@ -395,13 +395,13 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
类似地MongoDB 等文档数据库提供原子操作,供应用局部修改 JSON 文档Redis 则提供修改优先队列等数据结构的原子操作。并非所有写入都容易表示成原子操作——例如,更新 wiki 页面涉及任意文本编辑,可以用[“CRDT 与操作变换”](/ch6#sec_replication_crdts)介绍的算法处理——但只要能够使用,原子操作通常就是最佳选择。
原子操作通常这样实现:读取对象时取得排他锁,使其他事务在更新完成之前无法读取该对象。另一种办法则是强制所有原子操作都在单个线程上执行。
原子操作通常这样实现:读取对象时取得 *排他锁**exclusive lock*,使其他事务在更新完成之前无法读取该对象。另一种办法则是强制所有原子操作都在单个线程上执行。
遗憾的是对象关系映射ORM框架很容易让人无意中写出不安全的读取—修改—写入循环而没有使用数据库提供的原子操作[^49] [^50] [^51]。由此产生的缺陷往往十分隐蔽,很难通过测试发现。
#### 显式锁定 {#explicit-locking}
如果数据库内置的原子操作无法满足需求,还可以由应用程序显式锁定即将更新的对象,再执行读取—修改—写入循环。其他事务若试图并发更新或锁定同一对象,就必须等前一个循环完成。
如果数据库内置的原子操作无法满足需求,还可以由应用程序 *显式锁定**explicit locking*即将更新的对象,再执行读取—修改—写入循环。其他事务若试图并发更新或锁定同一对象,就必须等前一个循环完成。
以多人游戏为例,几个玩家都可以移动同一个棋子。这时原子操作可能还不够,因为应用还要确保走法符合游戏规则,而其中一些逻辑很难合理地写成数据库查询。此时可以加锁,防止两个玩家同时移动同一个棋子,如{{< xref eg="8-1" page="/ch8" anchor="fig_transactions_select_for_update" >}}示例 8-1{{< /xref >}}所示。
@@ -437,7 +437,7 @@ COMMIT;
#### 条件写入(比较并设置) {#sec_transactions_compare_and_set}
不提供事务的数据库有时会提供 *条件写入* 操作(前文[“单对象写入”](/ch8#sec_transactions_single_object)已经提到):只有当值自上次读取后未发生变化,才允许更新,从而防止丢失更新。如果当前值与先前读到的值不符,更新便不生效,应用必须重试读取—修改—写入循环。它相当于数据库版本的原子 *比较并设置**比较并交换*CAS指令许多 CPU 都支持此类指令。
不提供事务的数据库有时会提供 *条件写入* 操作(前文[“单对象写入”](/ch8#sec_transactions_single_object)已经提到):只有当值自上次读取后未发生变化,才允许更新,从而防止丢失更新。如果当前值与先前读到的值不符,更新便不生效,应用必须重试读取—修改—写入循环。它相当于数据库版本的原子 *比较并设置**compare-and-set*)或 *比较并交换**compare-and-swap*CAS指令许多 CPU 都支持此类指令。
例如,为防止两个用户同时更新同一个 wiki 页面,可以尝试如下写法:只有当页面内容在用户开始编辑后没有变化时,更新才会发生。
@@ -447,7 +447,7 @@ UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';
```
如果内容已经变化,不再与 `'old content'` 匹配,更新就不会生效;因此需要检查更新结果,必要时重试。也可以不比较完整内容,而是使用一个每次更新都递增的版本号列,只在当前版本号未变化时应用更新。这种方法有时称为 *乐观锁定*[^52]。
如果内容已经变化,不再与 `'old content'` 匹配,更新就不会生效;因此需要检查更新结果,必要时重试。也可以不比较完整内容,而是使用一个每次更新都递增的版本号列,只在当前版本号未变化时应用更新。这种方法有时称为 *乐观锁定**optimistic locking*[^52]。
如果另一个事务并发修改了 `content`,按照 MVCC 可见性规则,新内容可能并不可见(参见[“观察一致快照的可见性规则”](/ch8#sec_transactions_mvcc_visibility))。许多 MVCC 实现会针对这种场景作出例外:其他事务写入的值即便在快照中不可见,计算 `UPDATE``DELETE` 查询的 `WHERE` 子句时仍然可见。
@@ -457,7 +457,7 @@ UPDATE wiki_pages SET content = 'new content'
锁和条件写入操作都假定存在唯一的最新数据副本。但采用多主复制或无主复制的数据库,通常允许多项写入并发发生,再异步复制到其他节点,因而无法保证存在唯一的最新副本。所以,基于锁或条件写入的技术并不适用于这种场景。([“线性一致性”](/ch10#sec_consistency_linearizability)将更深入地讨论这个问题。)
这类复制数据库通常采用另一种办法,正如[“处理写入冲突”](/ch6#sec_replication_write_conflicts)所述:允许并发写入产生同一个值的多个冲突版本(也称为 *兄弟值*),再由应用代码或特殊数据结构事后解决冲突并合并版本。
这类复制数据库通常采用另一种办法,正如[“处理写入冲突”](/ch6#sec_replication_write_conflicts)所述:允许并发写入产生同一个值的多个冲突版本(也称为 *兄弟值**sibling*),再由应用代码或特殊数据结构事后解决冲突并合并版本。
如果更新是可交换的——即在不同副本上按不同顺序应用,仍会得到相同结果——那么合并冲突值便可防止丢失更新。递增计数器、向集合添加元素都是可交换操作。这正是[“CRDT 与操作变换”](/ch6#sec_replication_crdts)介绍的 CRDT 背后的思想。不过,条件写入等操作无法变成可交换操作。
@@ -480,7 +480,7 @@ UPDATE wiki_pages SET content = 'new content'
#### 写偏差的特征 {#characterizing-write-skew}
这种异常称为 *写偏差*[^36]。它既不是脏写,也不是丢失更新,因为两个事务更新的是不同对象——分别是 Aaliyah 和 Bryce 的值班记录。这里的冲突不那么明显,却确实是一种竞态条件:如果两个事务依次运行,第二位医生就无法退出值班。只有并发执行事务,才会出现这种异常行为。
这种异常称为 *写偏差**write skew*[^36]。它既不是脏写,也不是丢失更新,因为两个事务更新的是不同对象——分别是 Aaliyah 和 Bryce 的值班记录。这里的冲突不那么明显,却确实是一种竞态条件:如果两个事务依次运行,第二位医生就无法退出值班。只有并发执行事务,才会出现这种异常行为。
可以把写偏差看作丢失更新的推广:两个事务读取相同的一组对象,随后各自更新其中一些对象(不同事务可以更新不同对象),就可能发生写偏差。若不同事务碰巧更新同一个对象,则会表现为脏写或丢失更新,具体取决于时序。
@@ -557,7 +557,7 @@ UPDATE wiki_pages SET content = 'new content'
在医生值班的例子中,步骤 3 修改的是步骤 1 返回的某一行,因此可以在步骤 1 中锁定这些行(`SELECT FOR UPDATE`),使事务安全并避免写偏差。但其余四个例子不同:它们检查的是 *不存在* 符合某项条件的行,随后的写入则 *添加* 了一行,使其符合相同条件。如果步骤 1 的查询没有返回任何行,`SELECT FOR UPDATE` 便无处加锁[^56]。
一个事务的写入改变了另一个事务中搜索查询的结果,这种现象称为 *幻读*[^4]。快照隔离可以避免只读查询中的幻读但在上述读写事务中幻读可能引发格外棘手的写偏差。ORM 生成的 SQL 也很容易遇到写偏差[^50] [^51]。
一个事务的写入改变了另一个事务中搜索查询的结果,这种现象称为 *幻读**phantom*[^4]。快照隔离可以避免只读查询中的幻读但在上述读写事务中幻读可能引发格外棘手的写偏差。ORM 生成的 SQL 也很容易遇到写偏差[^50] [^51]。
#### 物化冲突 {#materializing-conflicts}
@@ -567,7 +567,7 @@ UPDATE wiki_pages SET content = 'new content'
现在,创建预订的事务可以锁定(`SELECT FOR UPDATE`)表中与目标房间和时段对应的行。取得锁后,再照常检查重叠预订并插入新预订。请注意,这张附加表并不存储预订信息——它纯粹是一组锁,用来防止同一房间、同一时间范围内的预订被并发修改。
这种方法称为 *物化冲突*:它把幻读转化为数据库中一组具体行上的锁冲突[^14]。遗憾的是,怎样物化冲突既难设计又容易出错,而且让并发控制机制渗入应用数据模型也很不美观。因此,物化冲突只能在别无选择时作为最后手段;大多数情况下,可串行化隔离要好得多。
这种方法称为 *物化冲突**materializing conflicts*:它把幻读转化为数据库中一组具体行上的锁冲突[^14]。遗憾的是,怎样物化冲突既难设计又容易出错,而且让并发控制机制渗入应用数据模型也很不美观。因此,物化冲突只能在别无选择时作为最后手段;大多数情况下,可串行化隔离要好得多。
@@ -612,7 +612,7 @@ VoltDB/H-Store、Redis 和 Datomic 等系统采用了串行执行事务的方式
这种交互式事务把大量时间耗在应用与数据库之间的网络通信上。如果数据库禁止并发、每次只处理一个事务,吞吐量会低得可怕:数据库大部分时间都在等待应用发出当前事务的下一条查询。采用这种模式的数据库必须并发处理多个事务,才能获得合理性能。
因此,单线程串行处理事务的系统不允许交互式多语句事务。应用要么只使用单语句事务,要么提前把整个事务的代码作为 *存储过程* 提交给数据库[^61]。
因此,单线程串行处理事务的系统不允许交互式多语句事务。应用要么只使用单语句事务,要么提前把整个事务的代码作为 *存储过程**stored procedure*提交给数据库[^61]。
交互式事务与存储过程的差别如{{< xref fig="8-9" page="/ch8" anchor="fig_transactions_stored_proc" >}}图 8-9{{< /xref >}}所示。只要事务所需的全部数据都在内存中,存储过程就能快速执行,无须等待任何网络或磁盘 I/O。
@@ -633,7 +633,7 @@ VoltDB/H-Store、Redis 和 Datomic 等系统采用了串行执行事务的方式
存储过程配合内存数据,使所有事务都在单线程上执行成为可行方案。只要存储过程无须等待 I/O又能避开其他并发控制机制的开销单线程也可以达到相当可观的吞吐量。
VoltDB 还利用存储过程进行复制它不把事务写入从一个节点复制到另一个节点而是在每个副本上执行同一个存储过程。因此VoltDB 要求存储过程必须是 *确定性的*,即在不同节点运行时产生相同结果。例如,事务若要使用当前日期和时间,就必须通过特殊的确定性 API 获取(关于确定性操作的更多细节,参见[“持久化执行与工作流”](/ch5#sec_encoding_dataflow_workflows))。这种方法称为 *状态机复制*,我们将在[第 10 章](/ch10#ch_consistency)再次讨论它。
VoltDB 还利用存储过程进行复制它不把事务写入从一个节点复制到另一个节点而是在每个副本上执行同一个存储过程。因此VoltDB 要求存储过程必须是 *确定性的**deterministic*,即在不同节点运行时产生相同结果。例如,事务若要使用当前日期和时间,就必须通过特殊的确定性 API 获取(关于确定性操作的更多细节,参见[“持久化执行与工作流”](/ch5#sec_encoding_dataflow_workflows))。这种方法称为 *状态机复制**state machine replication*,我们将在[第 10 章](/ch10#ch_consistency)再次讨论它。
#### 分片 {#sharding}
@@ -658,7 +658,7 @@ VoltDB 还利用存储过程进行复制:它不把事务写入从一个节点
### 两阶段锁定2PL {#sec_transactions_2pl}
大约 30 年间,数据库领域只有一种得到广泛应用的可串行化算法:*两阶段锁定*2PL。它有时也称为 *强严格两阶段锁定*SS2PL以区别于 2PL 的其他变体。
大约 30 年间,数据库领域只有一种得到广泛应用的可串行化算法:*两阶段锁定**two-phase locking*2PL。它有时也称为 *强严格两阶段锁定**strong strict two-phase locking*SS2PL以区别于 2PL 的其他变体。
> [!TIP] 2PL 不是 2PC
@@ -678,14 +678,14 @@ VoltDB 还利用存储过程进行复制:它不把事务写入从一个节点
MySQLInnoDB和 SQL Server 的可串行化隔离级别,以及 Db2 的可重复读隔离级别,都采用 2PL[^29]。
数据库通过为每个对象设置一把锁来阻塞读写操作。锁可以处于 *共享模式* 或 *独占模式*,这也称为 *多读者单写者* 锁。具体规则如下:
数据库通过为每个对象设置一把锁来阻塞读写操作。锁可以处于 *共享模式**shared mode*)或 *独占模式**exclusive mode*),这也称为 *多读者单写者**multi-reader single-writer*锁。具体规则如下:
* 事务要读取对象,必须先以共享模式取得锁。多个事务可以同时持有共享锁;但如果另一个事务已持有该对象的独占锁,读事务就必须等待。
* 事务要写入对象,必须先以独占模式取得锁。此时不允许其他事务以任何模式持锁;只要对象上已有锁,写事务就必须等待。
* 事务先读后写时,可以把共享锁升级为独占锁。升级规则与直接取得独占锁相同。
* 事务取得锁后,必须一直持有到事务结束(提交或中止)。这正是“两阶段”名称的由来:第一阶段在事务执行期间获取锁,第二阶段在事务结束时一次释放所有锁。
由于大量使用锁,很容易出现事务 A 等待事务 B 释放锁、事务 B 又反过来等待事务 A 的情况,这称为 *死锁*。数据库会自动检测事务之间的死锁并中止其中一个,让其他事务能够继续推进;被中止的事务则由应用程序重试。
由于大量使用锁,很容易出现事务 A 等待事务 B 释放锁、事务 B 又反过来等待事务 A 的情况,这称为 *死锁**deadlock*。数据库会自动检测事务之间的死锁并中止其中一个,让其他事务能够继续推进;被中止的事务则由应用程序重试。
#### 两阶段锁定的性能 {#performance-of-two-phase-locking}
@@ -705,7 +705,7 @@ MySQLInnoDB和 SQL Server 的可串行化隔离级别,以及 Db2 的可
在会议室预订的例子中,如果一个事务已经搜索某个房间在特定时段内的现有预订(参见{{< xref eg="8-2" page="/ch8" anchor="fig_transactions_meeting_rooms" >}}示例 8-2{{< /xref >}}),另一个事务就不能并发插入或更新同一房间、同一时段的预订。(并发预订其他房间,或者预订同一房间但互不影响的其他时段,则没有问题。)
怎样实现这一点?从概念上讲,需要使用 *谓词锁*[^4]。它的工作方式类似前面介绍的共享锁和独占锁,却不属于某个特定对象(例如表中的一行),而是属于所有符合某项搜索条件的对象,例如:
怎样实现这一点?从概念上讲,需要使用 *谓词锁**predicate lock*[^4]。它的工作方式类似前面介绍的共享锁和独占锁,却不属于某个特定对象(例如表中的一行),而是属于所有符合某项搜索条件的对象,例如:
```
SELECT * FROM bookings
@@ -723,7 +723,7 @@ SELECT * FROM bookings
#### 索引范围锁 {#sec_transactions_2pl_range}
遗憾的是,谓词锁性能不佳:活跃事务持有大量锁时,检查是否有匹配的锁会非常耗时。因此,大多数采用 2PL 的数据库实际实现的是 *索引范围锁*(也称为 *next-key locking*),即谓词锁的一种简化近似[^54] [^64]。
遗憾的是,谓词锁性能不佳:活跃事务持有大量锁时,检查是否有匹配的锁会非常耗时。因此,大多数采用 2PL 的数据库实际实现的是 *索引范围锁**index-range lock*也称为 *next-key locking*),即谓词锁的一种简化近似[^54] [^64]。
把谓词放宽到匹配更大的对象集合,是一种安全的简化。例如,要锁定 123 号房间从中午 12 点到下午 1 点的预订,可以近似为锁定 123 号房间全天所有预订;也可以近似为锁定中午 12 点到下午 1 点所有房间的预订。这样做是安全的,因为任何符合原始谓词的写入,必然也符合近似后的条件。
@@ -742,17 +742,17 @@ SELECT * FROM bookings
到目前为止,数据库并发控制的前景显得颇为黯淡:一边是性能不佳的两阶段锁定,以及伸缩性不佳的串行执行;另一边是性能虽好,却容易出现丢失更新、写偏差、幻读等竞态条件的弱隔离。可串行化隔离与良好性能从根本上就无法兼得吗?
看来并非如此。一种名为 *可串行化快照隔离*SSI的算法能够提供完整的可串行化与快照隔离相比只付出很小的性能代价。SSI 相对较新,最早于 2008 年提出[^53] [^65]。
看来并非如此。一种名为 *可串行化快照隔离**serializable snapshot isolation*SSI的算法能够提供完整的可串行化与快照隔离相比只付出很小的性能代价。SSI 相对较新,最早于 2008 年提出[^53] [^65]。
如今SSI 及类似算法已经用于单节点数据库PostgreSQL 的可串行化隔离级别[^54]、SQL Server 的内存 OLTP/Hekaton[^66] 和 HyPer[^67]、分布式数据库CockroachDB[^5] 和 FoundationDB[^8]),以及 BadgerDB 等嵌入式存储引擎。
#### 悲观并发控制与乐观并发控制 {#pessimistic-versus-optimistic-concurrency-control}
两阶段锁定属于所谓的 *悲观* 并发控制机制。它遵循这样的原则:只要有任何出错的可能(例如另一个事务持有锁),就先等待,直到局面重新安全后再行动。这类似于多线程编程中用来保护数据结构的 *互斥*。
两阶段锁定属于所谓的 *悲观**pessimistic*并发控制机制。它遵循这样的原则:只要有任何出错的可能(例如另一个事务持有锁),就先等待,直到局面重新安全后再行动。这类似于多线程编程中用来保护数据结构的 *互斥**mutual exclusion*
从某种意义上说,串行执行把悲观推到了极致:本质上,每个事务在执行期间都像是对整个数据库(或一个数据库分片)持有独占锁。作为补偿,每个事务都要尽快执行,使这把“锁”只持有很短时间。
相比之下,可串行化快照隔离是一种 *乐观* 并发控制技术。这里的“乐观”是指:即使出现潜在危险,也不阻塞事务,而是让它继续运行,寄望最终不会出问题。等事务准备提交时,数据库再检查是否发生了坏事(即隔离是否遭到破坏);若有,就中止并重试事务。只有执行结果可串行化的事务才能提交。
相比之下,可串行化快照隔离是一种 *乐观**optimistic*并发控制技术。这里的“乐观”是指:即使出现潜在危险,也不阻塞事务,而是让它继续运行,寄望最终不会出问题。等事务准备提交时,数据库再检查是否发生了坏事(即隔离是否遭到破坏);若有,就中止并重试事务。只有执行结果可串行化的事务才能提交。
乐观并发控制是个古老的思想[^68],其利弊也已争论多年[^69]。存在严重争用(许多事务访问相同对象)时,它表现很差,因为很大比例的事务都不得不中止。若系统已经接近最大吞吐量,重试事务带来的额外负载还会进一步拖累性能。
@@ -838,7 +838,7 @@ SELECT * FROM bookings
如果有些节点提交、有些节点中止,节点之间便会出现不一致。而事务一旦在某个节点上提交,即便后来发现它在另一个节点上中止,也不能再撤回。因为数据提交后,在 *读已提交* 或更强隔离下就会对其他事务可见。例如,在{{< xref fig="8-12" page="/ch8" anchor="fig_transactions_non_atomic" >}}图 8-12{{< /xref >}}中,当用户 1 发现数据库 1 提交失败时,用户 2 已经在数据库 2 读到了同一事务写入的数据。如果事后再中止用户 1 的事务,就连用户 2 的事务也必须撤销,因为它所依据的数据被追溯宣布为从未存在。
更好的办法是确保参与事务的节点要么全部提交,要么全部中止,绝不允许两种结果混杂。这就是所谓的 *原子提交* 问题。
更好的办法是确保参与事务的节点要么全部提交,要么全部中止,绝不允许两种结果混杂。这就是所谓的 *原子提交**atomic commitment*问题。
### 两阶段提交2PC {#sec_transactions_2pc}
@@ -849,9 +849,9 @@ SELECT * FROM bookings
{{< fig num="8-13" id="fig_transactions_two_phase_commit" src="/fig/ddia_0813.png" caption="两阶段提交2PC的成功执行。" class="ddia-figure ddia-figure--wide" width="2880" height="969" />}}
2PC 引入了一个单节点事务中通常没有的组件:*协调者*也称为 *事务管理器*)。协调者通常实现为一个库,与发起事务的应用运行在同一进程中(例如嵌入 Java EE 容器也可以作为独立进程或服务运行。Narayana、JOTM、BTM 和 MSDTC 都属于此类协调者。
2PC 引入了一个单节点事务中通常没有的组件:*协调者**coordinator*,也称为 *事务管理器**transaction manager*)。协调者通常实现为一个库,与发起事务的应用运行在同一进程中(例如嵌入 Java EE 容器也可以作为独立进程或服务运行。Narayana、JOTM、BTM 和 MSDTC 都属于此类协调者。
使用 2PC 时,分布式事务照常开始:应用在多个数据库节点上读写数据,这些节点称为事务的 *参与者*。应用准备提交时,协调者进入第一阶段,向每个参与者发送 *准备* 请求,询问它是否能够提交,并收集各参与者的响应:
使用 2PC 时,分布式事务照常开始:应用在多个数据库节点上读写数据,这些节点称为事务的 *参与者**participant*。应用准备提交时,协调者进入第一阶段,向每个参与者发送 *准备**prepare*请求,询问它是否能够提交,并收集各参与者的响应:
* 如果所有参与者都回答“是”,表示已准备好提交,协调者就在第二阶段发出 *提交* 请求,真正执行提交;
* 如果任何参与者回答“否”,协调者就在第二阶段向所有节点发送 *中止* 请求。
@@ -868,7 +868,7 @@ SELECT * FROM bookings
2. 应用在每个参与者上启动一个单节点事务,并为其附上这个全局事务 ID。所有读写都在这些单节点事务中完成。如果此阶段出现任何问题例如节点崩溃或请求超时协调者或任何参与者都可以中止事务。
3. 应用准备提交时,协调者向所有参与者发送带全局事务 ID 的准备请求。任何一个请求失败或超时,协调者都会向所有参与者发送该事务 ID 的中止请求。
4. 参与者收到准备请求后,必须确保自己在任何情况下都一定能提交事务。这既包括把全部事务数据写入磁盘(崩溃、断电或磁盘空间耗尽都不能成为以后拒绝提交的理由),也包括检查冲突和约束违规。节点向协调者回答“是”,就等于承诺:只要收到要求,事务一定能够无误提交。换句话说,参与者尚未真正提交,却已经放弃了自行中止事务的权利。
5. 协调者收到所有准备请求的响应后,便对提交还是中止作出最终决定(只有所有参与者都投“是”才会提交)。协调者必须把决定写入磁盘上的事务日志,这样即使随后崩溃,恢复后也知道自己作过什么决定。这一时刻称为 *提交点*。
5. 协调者收到所有准备请求的响应后,便对提交还是中止作出最终决定(只有所有参与者都投“是”才会提交)。协调者必须把决定写入磁盘上的事务日志,这样即使随后崩溃,恢复后也知道自己作过什么决定。这一时刻称为 *提交点**commit point*
6. 协调者的决定一旦落盘,就向所有参与者发送提交或中止请求。请求若失败或超时,协调者必须不断重试,直到成功为止。此时再无回头路:如果决定提交,就必须执行到底,无论需要重试多少次。参与者若在此期间崩溃,恢复后也必须提交事务——既然它已经投了“是”,就不能反悔。
因此,协议中有两个关键的“不归点”:参与者投“是”时,承诺自己日后一定能够提交(尽管协调者仍可决定中止);协调者一旦作出决定,该决定便不可撤销。正是这套承诺保证了 2PC 的原子性。(单节点原子提交把这两件事合并成一步:将提交记录写入事务日志。)
@@ -879,7 +879,7 @@ SELECT * FROM bookings
我们已经讨论过 2PC 期间参与者或网络发生故障时会怎样:任何准备请求失败或超时,协调者都会中止事务;任何提交或中止请求失败,协调者都会无限重试。但协调者自身崩溃时会发生什么,就没那么清楚了。
如果协调者在发送准备请求前失效,参与者可以安全中止事务。但参与者一旦收到准备请求并投出“是”,便不能再单方面中止,必须等待协调者告知事务究竟提交还是中止。如果协调者此时崩溃或网络发生故障,参与者只能等待。这种状态下的事务称为 *存疑* 或 *不确定* 事务。
如果协调者在发送准备请求前失效,参与者可以安全中止事务。但参与者一旦收到准备请求并投出“是”,便不能再单方面中止,必须等待协调者告知事务究竟提交还是中止。如果协调者此时崩溃或网络发生故障,参与者只能等待。这种状态下的事务称为 *存疑**in doubt*)或 *不确定**uncertain*事务。
{{< xref fig="8-14" page="/ch8" anchor="fig_transactions_2pc_crash" >}}图 8-14{{< /xref >}}展示了这种情况。在图中的例子里,协调者实际决定提交,数据库 2 也收到了提交请求;但协调者还没来得及把提交请求发给数据库 1 就崩溃了,所以数据库 1 不知道该提交还是中止。超时对此也无济于事:数据库 1 若在超时后自行中止,就会与已经提交的数据库 2 不一致;自行提交同样不安全,因为另一个参与者可能已经中止。
@@ -892,9 +892,9 @@ SELECT * FROM bookings
#### 三阶段提交 {#three-phase-commit}
由于 2PC 可能卡住并一直等待协调者恢复,两阶段提交也称为 *阻塞式* 原子提交协议。理论上可以设计 *非阻塞式* 原子提交协议,使它在节点发生故障时不会卡住;但要在实践中做到这一点并不容易。
由于 2PC 可能卡住并一直等待协调者恢复,两阶段提交也称为 *阻塞式**blocking*原子提交协议。理论上可以设计 *非阻塞式**nonblocking*原子提交协议,使它在节点发生故障时不会卡住;但要在实践中做到这一点并不容易。
有人提出 *三阶段提交*3PC作为 2PC 的替代方案[^13] [^77]。然而3PC 假设网络延迟有上界、节点响应时间也有上界;大多数现实系统都存在无界网络延迟和进程暂停(参见[第 9 章](/ch9#ch_distributed)3PC 在其中无法保证原子性。
有人提出 *三阶段提交**three-phase commit*3PC作为 2PC 的替代方案[^13] [^77]。然而3PC 假设网络延迟有上界、节点响应时间也有上界;大多数现实系统都存在无界网络延迟和进程暂停(参见[第 9 章](/ch9#ch_distributed)3PC 在其中无法保证原子性。
实践中更好的办法,是用容错共识协议替代单节点协调者。[第 10 章](/ch10#ch_consistency)将介绍如何做到这一点。
@@ -918,7 +918,7 @@ SELECT * FROM bookings
异构分布式事务可以用强有力的方式集成不同系统。例如,当且仅当处理消息的数据库事务成功提交,才确认消息队列中的那条消息已经处理。实现方法是在同一个事务中原子提交 *消息确认* 和 *数据库写入*。有了分布式事务,即使消息代理与数据库是运行在不同机器上的两种互不相关的技术,也能做到这一点。
如果消息传递或数据库事务任一失败,两者都会中止,消息代理稍后便可安全地重新投递。通过原子提交消息及其处理副作用,可以确保消息实际上 *恰好处理一次*,即使成功之前重试了好几次。每次中止都会丢弃未完成事务产生的所有副作用。这就是 *恰好一次语义*。
如果消息传递或数据库事务任一失败,两者都会中止,消息代理稍后便可安全地重新投递。通过原子提交消息及其处理副作用,可以确保消息实际上 *恰好处理一次*,即使成功之前重试了好几次。每次中止都会丢弃未完成事务产生的所有副作用。这就是 *恰好一次语义**exactly-once semantics*
不过,只有事务影响的所有系统都能采用同一种原子提交协议,这类分布式事务才有可能实现。例如,假设处理消息的副作用是发送邮件,而邮件服务器不支持两阶段提交;一旦消息处理失败并重试,邮件就可能发送两次甚至更多次。反之,如果事务中止时,消息处理产生的所有副作用都能回滚,那么处理过程便可安全重试,就像什么也没有发生一样。
@@ -954,7 +954,7 @@ XA 假定应用通过网络驱动或客户端库,与参与者数据库或消
唯一的出路,是由管理员手动决定提交还是回滚。管理员必须检查每个存疑事务的所有参与者,确定是否已有参与者提交或中止,再把同样的结果应用到其余参与者。这项工作可能耗费大量人力,而且往往要在严重生产中断期间,承受巨大的精神与时间压力来完成——否则协调者也不会陷入如此糟糕的状态。
许多 XA 实现都留有一个称为 *启发式决策* 的紧急出口:即使协调者没有给出明确决定,也允许参与者单方面中止或提交存疑事务[^73]。需要直说的是,这里的“*启发式*”只是“*很可能破坏原子性*”的委婉说法,因为它违背了两阶段提交的承诺体系。因此,启发式决策只用于摆脱灾难性局面,绝不能作为常规手段。
许多 XA 实现都留有一个称为 *启发式决策**heuristic decision*的紧急出口:即使协调者没有给出明确决定,也允许参与者单方面中止或提交存疑事务[^73]。需要直说的是,这里的“*启发式*”只是“*很可能破坏原子性*”的委婉说法,因为它违背了两阶段提交的承诺体系。因此,启发式决策只用于摆脱灾难性局面,绝不能作为常规手段。
#### XA 事务的问题 {#problems-with-xa-transactions}
@@ -998,7 +998,7 @@ XA 最严重的几个问题可以这样解决:
如果消息处理器在提交数据库事务前崩溃,事务会中止,消息代理随后重试。如果它在提交后、向代理确认前崩溃,代理同样会重试;但重试时能在数据库中看到消息 ID于是直接丢弃该消息。如果它在确认后、从数据库删除消息 ID 前崩溃,数据库只会残留一条旧消息 ID除了占用少量空间不会造成其他危害。重试也可能发生在原数据库事务中止之前——例如消息处理器与数据库之间的通信中断——此时消息 ID 表上的唯一性约束应能防止两个并发事务插入相同 ID。
因此,实现恰好一次处理只需要数据库内部的事务;这个用例并不要求数据库与消息代理之间具备原子性。把消息 ID 记入数据库,可以让消息处理具备 *幂等性*因而能够安全重试而不重复产生副作用。Kafka Streams 等流处理框架也用类似办法实现恰好一次语义,详见[“容错”](/ch12#sec_stream_fault_tolerance)。
因此,实现恰好一次处理只需要数据库内部的事务;这个用例并不要求数据库与消息代理之间具备原子性。把消息 ID 记入数据库,可以让消息处理具备 *幂等性**idempotence*因而能够安全重试而不重复产生副作用。Kafka Streams 等流处理框架也用类似办法实现恰好一次语义,详见[“容错”](/ch12#sec_stream_fault_tolerance)。
不过,数据库内部的分布式事务仍有助于扩展这类模式。例如,消息 ID 可以存放在一个分片上,消息处理所更新的业务数据放在其他分片上,再由内部事务保证跨分片提交的原子性。

View File

@@ -19,7 +19,7 @@ breadcrumbs: false
如果希望系统在发生故障时依然可靠,就必须从根本上转变思维方式,把注意力放在各种可能出错的地方,即使出错的概率很低。某件事只有百万分之一的概率出错并不意味着可以不管:系统足够大时,百万分之一的事件每天都会发生。经验丰富的系统运维人员会告诉你,任何 *可能* 出错的事情,*终究都会* 出错。
而且,使用分布式系统与在单台计算机上编写软件有着根本区别——最主要的区别,就是事情有了许多新奇而刺激的出错方式 [^1] [^2]。本章将带你领略实践中会遇到的问题,并帮助你理解哪些东西可以依赖,哪些不可以。
而且,使用 *分布式系统**distributed system*与在单台计算机上编写软件有着根本区别——最主要的区别,就是事情有了许多新奇而刺激的出错方式 [^1] [^2]。本章将带你领略实践中会遇到的问题,并帮助你理解哪些东西可以依赖,哪些不可以。
为了理解我们面对的挑战,接下来让我们把悲观主义发挥到极致,考察分布式系统里可能出错的种种事情。我们将讨论网络问题([“不可靠的网络”](/ch9#sec_distributed_networks)),以及时钟和时序问题([“不可靠的时钟”](/ch9#sec_distributed_clocks))。这些问题造成的后果往往令人迷失方向,因此我们还要探讨如何认识分布式系统的状态,以及如何推断已经发生过的事情([“知识、真相和谎言”](/ch9#sec_distributed_truth))。随后在 [第 10 章](/ch10#ch_consistency) 中,我们将通过一些例子看看,面对这些故障时如何实现容错。
@@ -27,7 +27,7 @@ breadcrumbs: false
当你在一台计算机上编写程序时,它通常会以相当可预测的方式运行:要么正常工作,要么彻底罢工。有缺陷的软件可能会让人觉得计算机偶尔也会“状态不好”(而重启往往能解决问题),但这通常只是软件写得糟糕所造成的表象。
从根本上说,单台计算机上的软件不应该时灵时不灵:只要硬件正常,同样的操作总会产生同样的结果(也就是 *确定性的*)。如果硬件出了问题(例如内存损坏或连接器松动),后果通常是整个系统失效(例如内核恐慌、“蓝屏死机”或无法启动)。一台运行着良好软件的计算机,通常要么功能完好,要么完全失效,而不会停留在两者之间。
从根本上说,单台计算机上的软件不应该时灵时不灵:只要硬件正常,同样的操作总会产生同样的结果(也就是 *确定性的**deterministic*)。如果硬件出了问题(例如内存损坏或连接器松动),后果通常是整个系统失效(例如内核恐慌、“蓝屏死机”或无法启动)。一台运行着良好软件的计算机,通常要么功能完好,要么完全失效,而不会停留在两者之间。
这是计算机设计中的有意选择发生内部故障时我们宁愿让计算机彻底崩溃也不愿让它返回错误结果因为后者既难处理又容易造成混乱。于是计算机把其底层模糊而混乱的物理现实隐藏起来呈现出一个以数学般的完美方式运行的理想化系统模型。CPU 指令每次都会做同样的事情;写入内存或磁盘的数据会原样保留,不会随机损坏。正如 [“硬件与软件故障”](/ch2#sec_introduction_hardware_faults) 中所讨论的事实并非真的如此——现实中数据的确可能在没有任何警告的情况下损坏CPU 偶尔也会悄无声息地给出错误结果——只不过这些情况足够罕见,通常可以忽略。
@@ -37,7 +37,7 @@ breadcrumbs: false
>
> —— 柯达黑尔
在分布式系统中,即使其他部分工作正常,系统的某些部分也很可能以不可预知的方式发生故障。这叫作 *部分失效*。棘手之处在于,部分失效是 *非确定性的*:任何涉及多个节点及其网络的操作,有时能够成功,有时却会莫名其妙地失败。正如我们稍后会看到的,你甚至可能根本 *不知道* 某件事究竟成功了没有!
在分布式系统中,即使其他部分工作正常,系统的某些部分也很可能以不可预知的方式发生故障。这叫作 *部分失效**partial failure*。棘手之处在于,部分失效是 *非确定性的**nondeterministic*:任何涉及多个节点及其网络的操作,有时能够成功,有时却会莫名其妙地失败。正如我们稍后会看到的,你甚至可能根本 *不知道* 某件事究竟成功了没有!
正是这种非确定性和部分失效的可能性,让分布式系统如此难以驾驭 [^4]。不过,如果分布式系统能够容忍部分失效,也会由此获得强大的能力。例如,你可以执行滚动升级:每次重启一个节点来安装软件更新,同时让整个系统始终不间断地工作。因此,借助容错,我们可以用不可靠的组件构建出比单节点系统更可靠的分布式系统。
@@ -47,7 +47,7 @@ breadcrumbs: false
正如 [“共享内存、共享磁盘与无共享架构”](/ch2#sec_introduction_shared_nothing) 中所讨论的,本书关注的分布式系统大多是 *无共享系统*,也就是一组通过网络连接的机器。网络是这些机器彼此通信的唯一途径——我们假定每台机器都有自己的内存和磁盘,一台机器无法直接访问另一台机器的内存或磁盘,只能通过网络向服务发送请求。即使存储本身是共享的(例如 Amazon S3机器也仍然要通过网络与共享存储服务通信。
互联网和数据中心里的大多数内部网络(通常是以太网)都是 *异步分组网络*。在这种网络中,一个节点可以向另一个节点发送消息(即数据包),但网络既不保证消息何时到达,也不保证它一定能够到达。如果你发出请求并等待响应,可能会发生许多问题(其中一些如 {{< xref fig="9-1" page="/ch9" anchor="fig_distributed_network" >}}图 9-1{{< /xref >}} 所示):
互联网和数据中心里的大多数内部网络(通常是以太网)都是 *异步分组网络**asynchronous packet network*。在这种网络中,一个节点可以向另一个节点发送消息(即数据包),但网络既不保证消息何时到达,也不保证它一定能够到达。如果你发出请求并等待响应,可能会发生许多问题(其中一些如 {{< xref fig="9-1" page="/ch9" anchor="fig_distributed_network" >}}图 9-1{{< /xref >}} 所示):
1. 请求可能已经丢失(或许有人拔掉了网线)。
2. 请求可能还在队列中等待,稍后才会送达(或许网络或接收方过载了)。
@@ -61,18 +61,18 @@ breadcrumbs: false
发送方甚至无法判断数据包是否送达:唯一的办法是由接收方发回响应消息,而这个响应同样可能丢失或延迟。在异步网络中,这些情形无法区分:你掌握的唯一信息只是“尚未收到响应”。向另一个节点发送请求却没有收到响应时,*不可能* 判断原因究竟是什么。
处理这个问题的惯常办法是设置 *超时*:等待一段时间后便放弃,并假定响应不会再来。然而,即使发生超时,你仍然不知道远程节点是否收到了请求(如果请求仍在某处排队,那么即便发送方已经放弃,它仍可能在稍后送达接收方)。
处理这个问题的惯常办法是设置 *超时**timeout*:等待一段时间后便放弃,并假定响应不会再来。然而,即使发生超时,你仍然不知道远程节点是否收到了请求(如果请求仍在某处排队,那么即便发送方已经放弃,它仍可能在稍后送达接收方)。
### TCP 的局限性 {#sec_distributed_tcp}
网络数据包有大小上限(通常只有几千字节),但许多应用程序需要发送无法装进单个数据包的消息,例如请求和响应。这类应用程序通常使用 TCP传输控制协议建立一条 *连接*,把较大的数据流拆成一个个数据包,再在接收端重新组装起来。
网络数据包有大小上限(通常只有几千字节),但许多应用程序需要发送无法装进单个数据包的消息,例如请求和响应。这类应用程序通常使用 TCP传输控制协议建立一条 *连接**connection*,把较大的数据流拆成一个个数据包,再在接收端重新组装起来。
> [!NOTE]
> 下面关于 TCP 的大部分讨论,也适用于较新的替代方案 QUIC、WebRTC 使用的流控制传输协议SCTP、BitTorrent 的 uTP 协议,以及其他传输协议。关于它与 UDP 的比较,参见 [“TCP 与 UDP”](/ch9#sidebar_distributed_tcp_udp)。
TCP 常被说成能提供“可靠”的传输这里的“可靠”是指它能检测并重传丢失的数据包发现顺序错乱的数据包并将其恢复为正确顺序还能用简单的校验和检测数据包损坏。TCP 也会判断应当以多快的速度发送数据,既尽可能快速传输,又不至于压垮网络或接收节点;这叫作 *拥塞控制*、*流量控制* 或 *背压* [^5]。
TCP 常被说成能提供“可靠”的传输这里的“可靠”是指它能检测并重传丢失的数据包发现顺序错乱的数据包并将其恢复为正确顺序还能用简单的校验和检测数据包损坏。TCP 也会判断应当以多快的速度发送数据,既尽可能快速传输,又不至于压垮网络或接收节点;这叫作 *拥塞控制**congestion control*)、*流量控制**flow control*)或 *背压**backpressure*[^5]。
当你把数据写入套接字来“发送”时,数据其实不会立即发出,只会先进入操作系统管理的缓冲区。拥塞控制算法判断目前有能力发送数据包后,才会从缓冲区取出一个数据包大小的数据,交给网络接口。数据包会经过若干交换机和路由器,最终由接收节点的操作系统把数据放进接收缓冲区,并向发送方发回确认包。直到这时,接收端操作系统才会通知应用程序又有数据到达 [^6]。
@@ -96,14 +96,14 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
> [!TIP] 网络分区
>
> 当网络的一部分因网络故障而与其余部分隔绝时,这种情况有时称为 *网络分区* 或 *网络分裂*,但它与其他类型的网络中断并没有本质区别。网络分区与存储系统的分片无关,后者有时也称为 *分区*(参见 [第 7 章](/ch7#ch_sharding))。
> 当网络的一部分因网络故障而与其余部分隔绝时,这种情况有时称为 *网络分区**network partition*)或 *网络分裂**netsplit*,但它与其他类型的网络中断并没有本质区别。网络分区与存储系统的分片无关,后者有时也称为 *分区*(参见 [第 7 章](/ch7#ch_sharding))。
即使你的环境很少遇到网络故障,故障 *有可能* 发生这一事实也意味着软件必须能够处理它。只要通过网络通信,就有可能失败——这一点无可回避。
如果没有明确定义并测试网络故障的处理方式,后果可能糟到没有下限。例如,即使网络已经恢复,集群仍可能陷入死锁,从此无法再处理请求 [^24];甚至还可能删掉你的全部数据 [^25]。一旦软件落入设计者未曾预料的情形,它就可能做出任何出人意料的事情。
处理网络故障不一定意味着要 *容忍* 它。如果网络通常相当可靠,那么在发生网络问题时直接向用户显示错误消息,也可以是一种合理策略。不过,你必须知道软件会如何应对网络问题,并确保系统能够从中恢复。可以考虑有意触发网络问题,测试系统会如何反应(这叫作 *故障注入*;参见 [“故障注入”](/ch9#sec_fault_injection))。
处理网络故障不一定意味着要 *容忍* 它。如果网络通常相当可靠,那么在发生网络问题时直接向用户显示错误消息,也可以是一种合理策略。不过,你必须知道软件会如何应对网络问题,并确保系统能够从中恢复。可以考虑有意触发网络问题,测试系统会如何反应(这叫作 *故障注入**fault injection*;参见 [“故障注入”](/ch9#sec_fault_injection))。
### 故障检测 {#id307}
@@ -133,7 +133,7 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
设想一个虚构的系统,其网络保证数据包的最大延迟:每个数据包要么在时间 *d* 以内送达,要么丢失,绝不会在超过 *d* 之后才送达。再假定我们能够保证,任何尚未失效的节点总会在时间 *r* 以内处理完请求。在这种情况下,每个成功的请求都能保证在 2 *d* + *r* 以内收到响应;如果过了这么久仍未收到响应,就可以断定网络或远程节点没有正常工作。假如这些保证真的成立,那么 2 *d* + *r* 就是合理的超时时间。
遗憾的是,我们使用的大多数系统都不具备上述任何一项保证:异步网络具有 *无界延迟*(也就是说,它会尽快尝试送达数据包,但数据包所需的传输时间没有上限),大多数服务器实现也不能保证一定在某个最长时间内处理完请求(参见 [“响应时间保证”](/ch9#sec_distributed_clocks_realtime))。对故障检测而言,系统仅仅在大多数时候运行得很快还不够:如果超时时间设得很短,一次短暂的往返时间尖峰就足以打乱整个系统。
遗憾的是,我们使用的大多数系统都不具备上述任何一项保证:异步网络具有 *无界延迟**unbounded delay*也就是说,它会尽快尝试送达数据包,但数据包所需的传输时间没有上限),大多数服务器实现也不能保证一定在某个最长时间内处理完请求(参见 [“响应时间保证”](/ch9#sec_distributed_clocks_realtime))。对故障检测而言,系统仅仅在大多数时候运行得很快还不够:如果超时时间设得很短,一次短暂的往返时间尖峰就足以打乱整个系统。
<a id="sec_distributed_congestion"></a>
@@ -141,7 +141,7 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
开车出行时,交通拥堵往往是造成行程时间波动的最大因素。同样,在计算机网络上,数据包延迟的变化通常也是排队造成的 [^27]
* 如果多个节点同时向同一个目的地发送数据包,网络交换机就必须让这些数据包排队,再逐一送入通往目的地的网络链路(如 {{< xref fig="9-2" page="/ch9" anchor="fig_distributed_switch_queueing" >}}图 9-2{{< /xref >}} 所示)。网络链路繁忙时,数据包可能需要等待一段时间才能获得发送机会,这叫作 *网络拥塞*。如果流入的数据太多,交换机队列被塞满,数据包就会被丢弃,因而必须重发——即使网络本身仍在正常工作。
* 如果多个节点同时向同一个目的地发送数据包,网络交换机就必须让这些数据包排队,再逐一送入通往目的地的网络链路(如 {{< xref fig="9-2" page="/ch9" anchor="fig_distributed_switch_queueing" >}}图 9-2{{< /xref >}} 所示)。网络链路繁忙时,数据包可能需要等待一段时间才能获得发送机会,这叫作 *网络拥塞**network congestion*。如果流入的数据太多,交换机队列被塞满,数据包就会被丢弃,因而必须重发——即使网络本身仍在正常工作。
* 数据包抵达目标机器时,如果所有 CPU 核心都在忙,操作系统会把来自网络的请求放进队列,直到应用程序有能力处理为止。等待时间取决于机器的负载,可能任意之长 [^28]。
* 在虚拟化环境中,当另一台虚拟机占用某个 CPU 核心时,正在运行的操作系统常常会暂停数十毫秒。在此期间,虚拟机无法消费任何网络数据,因此虚拟机监控器会将流入的数据排队(缓冲)[^29],进一步增大网络延迟的波动。
* 如前所述为避免网络过载TCP 会限制数据的发送速率。这意味着数据甚至还没进入网络,就已经在发送方额外排了一次队。
@@ -162,11 +162,11 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
上述因素都会造成网络延迟的波动。当系统接近最大容量时,排队延迟的变化范围尤其大:拥有充足余量的系统可以轻松排空队列,而利用率很高的系统则可能迅速积起长队。
在公共云和多租户数据中心中,许多客户共享同一批资源:网络链路和交换机是共享的,甚至每台机器的网络接口和 CPU使用虚拟机时也是共享的。处理大量数据时可能耗尽网络链路的全部容量使其达到 *饱和*。你既无法控制,也无法了解其他客户如何使用共享资源;如果附近某个 *吵闹的邻居* 正在大量消耗资源,网络延迟就可能剧烈波动 [^30] [^31]。
在公共云和多租户数据中心中,许多客户共享同一批资源:网络链路和交换机是共享的,甚至每台机器的网络接口和 CPU使用虚拟机时也是共享的。处理大量数据时可能耗尽网络链路的全部容量使其达到 *饱和**saturation*。你既无法控制,也无法了解其他客户如何使用共享资源;如果附近某个 *吵闹的邻居**noisy neighbor*正在大量消耗资源,网络延迟就可能剧烈波动 [^30] [^31]。
在这样的环境中,只能通过实验来选择超时时间:长期测量大量机器之间网络往返时间的分布,确定延迟通常会有多大波动。然后结合应用程序自身的特点,在故障检测的延迟与过早超时的风险之间做出适当权衡。
更好的办法是不用固定配置的超时时间,而让系统持续测量响应时间及其波动(*抖动*再根据观测到的响应时间分布自动调整超时。Phi 累积故障检测器 [^32] 就采用了这种方法Akka 和 Cassandra 等系统都在使用它 [^33]。TCP 的重传超时也以类似方式工作 [^5]。
更好的办法是不用固定配置的超时时间,而让系统持续测量响应时间及其波动(*抖动**jitter*再根据观测到的响应时间分布自动调整超时。Phi 累积故障检测器 [^32] 就采用了这种方法Akka 和 Cassandra 等系统都在使用它 [^33]。TCP 的重传超时也以类似方式工作 [^5]。
### 同步网络与异步网络 {#sec_distributed_sync_networks}
@@ -174,9 +174,9 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
要回答这个问题,不妨把数据中心网络与传统的固定电话网络(非蜂窝网络,也非 VoIP比较一下。传统电话网络极其可靠音频帧延迟和通话中断都很罕见。电话通话需要持续保持较低的端到端延迟并提供足够带宽来传输语音采样。如果计算机网络也能具备类似的可靠性与可预测性岂不是很好
通过电话网络拨号时,网络会建立一条 *电路*:从一名通话者到另一名通话者的整条路径上,都会为这次通话分配固定且有保证的带宽。这条电路会一直保留到通话结束 [^34]。例如ISDN 网络以每秒 4,000 帧的固定速率运行。建立通话时,每一帧都会在两个方向上各分配 16 位空间。因此在整个通话期间,双方都能保证每 250 微秒恰好发送 16 位音频数据 [^35]。
通过电话网络拨号时,网络会建立一条 *电路**circuit*:从一名通话者到另一名通话者的整条路径上,都会为这次通话分配固定且有保证的带宽。这条电路会一直保留到通话结束 [^34]。例如ISDN 网络以每秒 4,000 帧的固定速率运行。建立通话时,每一帧都会在两个方向上各分配 16 位空间。因此在整个通话期间,双方都能保证每 250 微秒恰好发送 16 位音频数据 [^35]。
这类网络是 *同步的*:即使数据要经过多台路由器,也不会排队,因为每一跳都已经为这次通话预留了 16 位的空间。由于无需排队,网络的最大端到端延迟是固定的。我们称之为 *有界延迟*
这类网络是 *同步的**synchronous*:即使数据要经过多台路由器,也不会排队,因为每一跳都已经为这次通话预留了 16 位的空间。由于无需排队,网络的最大端到端延迟是固定的。我们称之为 *有界延迟**bounded delay*
#### 我们不能简单地让网络延迟可预测吗? {#can-we-not-simply-make-network-delays-predictable}
@@ -184,11 +184,11 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
如果数据中心网络和互联网采用电路交换,那么建立电路时就可以同时确定有保证的最大往返时间。但它们并非如此:以太网和 IP 都是分组交换协议,数据包会排队,因而网络延迟没有上界。这些协议中根本没有“电路”的概念。
为什么数据中心网络和互联网要使用分组交换?因为它们针对 *突发流量* 做了优化。音频或视频通话在整个通话期间每秒传输的位数相当稳定,很适合使用电路。相比之下,请求网页、发送电子邮件或传输文件并没有特定的带宽要求——我们只希望它们尽快完成。
为什么数据中心网络和互联网要使用分组交换?因为它们针对 *突发流量**bursty traffic*做了优化。音频或视频通话在整个通话期间每秒传输的位数相当稳定,很适合使用电路。相比之下,请求网页、发送电子邮件或传输文件并没有特定的带宽要求——我们只希望它们尽快完成。
如果要通过电路传输文件就必须先猜测应当分配多少带宽。猜得太低传输速度会慢得毫无必要同时还有网络容量闲置猜得太高电路又无法建立因为网络不能为无法保证带宽分配的电路放行。因此用电路传输突发数据既浪费网络容量又使传输无谓地变慢。TCP 则不同,它会根据可用的网络容量动态调整数据传输速率。
人们也曾尝试构建兼具电路交换与分组交换的混合网络。*异步传输模式*ATM在 20 世纪 80 年代曾与以太网竞争但除了电话网络的核心交换机外并未得到广泛采用。InfiniBand 与之有些相似 [^36]:它在链路层实现端到端流量控制,减少了网络排队的必要,不过链路拥塞仍可能带来延迟 [^37]。如果谨慎运用 *服务质量*QoS即数据包的优先级与调度*准入控制*(对发送方限速),就可以在分组网络上模拟电路交换,或提供统计意义上的有界延迟 [^27] [^34]。低延迟、低损耗和可伸缩吞吐量L4S等新型网络算法试图从客户端和路由器两端缓解部分排队与拥塞控制问题。Linux 的流量控制器TC也允许应用程序为实现 QoS 而重新安排数据包的优先级。
人们也曾尝试构建兼具电路交换与分组交换的混合网络。*异步传输模式**Asynchronous Transfer Mode*ATM在 20 世纪 80 年代曾与以太网竞争但除了电话网络的核心交换机外并未得到广泛采用。InfiniBand 与之有些相似 [^36]:它在链路层实现端到端流量控制,减少了网络排队的必要,不过链路拥塞仍可能带来延迟 [^37]。如果谨慎运用 *服务质量**quality of service*QoS即数据包的优先级与调度*准入控制**admission control*,即对发送方限速),就可以在分组网络上模拟电路交换,或提供统计意义上的有界延迟 [^27] [^34]。低延迟、低损耗和可伸缩吞吐量L4S等新型网络算法试图从客户端和路由器两端缓解部分排队与拥塞控制问题。Linux 的流量控制器TC也允许应用程序为实现 QoS 而重新安排数据包的优先级。
<a id="sidebar_distributed_latency_utilization"></a>
@@ -227,7 +227,7 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
7. 这个缓存条目什么时候过期?
8. 日志文件里这条错误消息的时间戳是什么?
问题 14 测量的是 *持续时间*(例如从发送请求到收到响应之间的时间间隔),问题 58 描述的则是 *时间点*(在某个具体日期和时刻发生的事件)。
问题 14 测量的是 *持续时间**duration*例如从发送请求到收到响应之间的时间间隔),问题 58 描述的则是 *时间点**point in time*在某个具体日期和时刻发生的事件)。
在分布式系统中,时间是个棘手的问题,因为通信并非瞬间完成:消息从一台机器经网络传到另一台机器需要时间。收到消息的时刻必然晚于发出消息的时刻,但由于网络延迟会发生变化,我们不知道究竟晚了多少。涉及多台机器时,这一事实有时会让事件的先后顺序难以确定。
@@ -235,11 +235,11 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
### 单调时钟与日历时钟 {#sec_distributed_monotonic_timeofday}
现代计算机至少配有两种不同的时钟:*日历时钟**单调时钟*。它们虽然都用来度量时间,却服务于不同目的,因此必须加以区分。
现代计算机至少配有两种不同的时钟:*日历时钟**time-of-day clock*)和 *单调时钟**monotonic clock*。它们虽然都用来度量时间,却服务于不同目的,因此必须加以区分。
#### 日历时钟 {#time-of-day-clocks}
日历时钟所做的,正是人们直觉上认为时钟应该做的事:按照某种历法返回当前日期和时间(也称为 *墙上时钟时间*。例如Linux 的 `clock_gettime(CLOCK_REALTIME)` 和 Java 的 `System.currentTimeMillis()` 返回从 *纪元* 至今经过的秒数(或毫秒数):这里的纪元是格里高利历中的 1970 年 1 月 1 日 UTC 零时且不计闰秒。有些系统使用其他日期作为参考点。Linux 虽然把这个时钟称为 *实时* 时钟,但它与实时操作系统毫无关系,参见 [“响应时间保证”](/ch9#sec_distributed_clocks_realtime)。)
日历时钟所做的,正是人们直觉上认为时钟应该做的事:按照某种历法返回当前日期和时间(也称为 *墙上时钟时间**wall-clock time*。例如Linux 的 `clock_gettime(CLOCK_REALTIME)` 和 Java 的 `System.currentTimeMillis()` 返回从 *纪元**epoch*至今经过的秒数(或毫秒数):这里的纪元是格里高利历中的 1970 年 1 月 1 日 UTC 零时且不计闰秒。有些系统使用其他日期作为参考点。Linux 虽然把这个时钟称为 *实时* 时钟,但它与实时操作系统毫无关系,参见 [“响应时间保证”](/ch9#sec_distributed_clocks_realtime)。)
日历时钟通常会与 NTP 同步,因此理想情况下,一台机器上的某个时间戳与另一台机器上的同一时间戳表示同一时刻。不过,日历时钟也有各种怪异之处,下一节将进一步说明。尤其是当本地时钟比 NTP 服务器快得太多时,它可能会被强制重置,看起来就像突然跳回了过去。这样的跳变,以及闰秒造成的类似跳变,使日历时钟不适合测量已经过去了多长时间 [^40]。
@@ -253,7 +253,7 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
在有多个 CPU 插槽的服务器上,每颗 CPU 都可能有自己的计时器,而且未必与其他 CPU 同步 [^43]。操作系统会补偿其中的差异,尽力让应用程序线程看到单调递增的时钟,即使线程在不同 CPU 之间调度也是如此。不过,对这样的单调性保证最好还是有所保留 [^44]。
如果 NTP 发现计算机的本地石英钟比 NTP 服务器走得更快或更慢,可以调节单调时钟向前推进的速率,这叫作对时钟进行 *渐进校准*。默认情况下NTP 最多可以把时钟速率加快或减慢 0.05%,但不能让单调时钟突然向前或向后跳变。单调时钟通常有很好的分辨率:在大多数系统上,它能测量微秒级甚至更短的时间间隔。
如果 NTP 发现计算机的本地石英钟比 NTP 服务器走得更快或更慢,可以调节单调时钟向前推进的速率,这叫作对时钟进行 *渐进校准**slewing*。默认情况下NTP 最多可以把时钟速率加快或减慢 0.05%,但不能让单调时钟突然向前或向后跳变。单调时钟通常有很好的分辨率:在大多数系统上,它能测量微秒级甚至更短的时间间隔。
在分布式系统中,用单调时钟测量经过的时间(例如超时)通常没有问题,因为这不要求不同节点的时钟彼此同步,而且测量中的轻微误差也不会造成太大影响。
@@ -266,7 +266,7 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
* 如果防火墙意外阻断了节点与 NTP 服务器的通信,这项错误配置可能很长时间都无人察觉;在此期间,漂移不断累积,不同节点的时钟最终可能相差甚远。轶事证据表明,实践中确实发生过这种情况。
* NTP 同步的准确性不可能优于网络延迟。因此,在数据包延迟波动不定的拥塞网络上,它的准确性必然有限。一项实验表明,经由互联网同步所能达到的最小误差为 35 毫秒 [^46],而网络延迟偶尔出现尖峰时,误差会达到一秒左右。具体取决于配置,过大的网络延迟甚至可能让 NTP 客户端彻底放弃同步。
* 有些 NTP 服务器本身不正确或配置有误,报告的时间能相差数小时 [^47] [^48]。NTP 客户端会查询多台服务器并忽略离群值,以减轻这类错误的影响。即便如此,把系统的正确性押在某个互联网陌生人报给你的时间上,多少还是令人不安。
* 闰秒会让一分钟变成 59 秒或 61 秒,从而打乱那些设计时未考虑闰秒的系统对时序所作的假设 [^49]。闰秒已经导致许多大型系统崩溃 [^40] [^50],足见关于时钟的错误假设有多么容易悄然混入系统。处理闰秒的最佳办法,或许是让 NTP 服务器“撒谎”:把闰秒调整分摊到一整天内逐渐完成,这称为 *平滑处理* [^51] [^52];不过实践中各 NTP 服务器的实际行为并不一致 [^53]。好在从 2035 年起将不再使用闰秒,这个问题也会随之消失。
* 闰秒会让一分钟变成 59 秒或 61 秒,从而打乱那些设计时未考虑闰秒的系统对时序所作的假设 [^49]。闰秒已经导致许多大型系统崩溃 [^40] [^50],足见关于时钟的错误假设有多么容易悄然混入系统。处理闰秒的最佳办法,或许是让 NTP 服务器“撒谎”:把闰秒调整分摊到一整天内逐渐完成,这称为 *平滑处理**smearing*[^51] [^52];不过实践中各 NTP 服务器的实际行为并不一致 [^53]。好在从 2035 年起将不再使用闰秒,这个问题也会随之消失。
* 在虚拟机中,硬件时钟也是虚拟化的,这给需要精确计时的应用程序带来了额外挑战 [^54]。多个虚拟机共享 CPU 核心时,一台虚拟机运行,其他虚拟机就可能暂停数十毫秒。从应用程序的视角看,这种暂停表现为时钟突然向前跳跃 [^29]。如果虚拟机暂停了几秒,其时钟随后可能比实际时间慢几秒,但 NTP 仍可能报告时钟几乎完全同步 [^55]。
* 如果软件运行在你无法完全控制的设备上(例如移动设备或嵌入式设备),那么设备的硬件时钟可能根本不值得信任。有些用户会故意把硬件时钟设置成错误的日期和时间,例如借此在游戏中作弊 [^56]。因此,时钟可能被设到离谱的过去或未来。
@@ -309,7 +309,7 @@ TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:
能否把 NTP 同步做得足够精确从而彻底避免这类错误排序恐怕不能。除了石英钟漂移等其他误差源以外NTP 的同步精度本身就受网络往返时间限制。若要保证顺序正确,时钟误差必须显著小于网络延迟,而这是不可能做到的。
所谓的 *逻辑时钟* [^66] 以递增计数器为基础,而不是以振荡的石英晶体为基础,因此是为事件排序时更安全的选择(参见 [“检测并发写入”](/ch6#sec_replication_concurrent))。逻辑时钟既不度量一天中的时刻,也不度量经过了多少秒,只记录事件之间的相对顺序(一个事件发生在另一个事件之前还是之后)。与之相对,日历时钟和单调时钟度量真实流逝的时间,因此也称为 *物理时钟*。我们将在 [“ID 生成器和逻辑时钟”](/ch10#sec_consistency_logical) 中更详细地讨论逻辑时钟。
所谓的 *逻辑时钟**logical clock*[^66] 以递增计数器为基础,而不是以振荡的石英晶体为基础,因此是为事件排序时更安全的选择(参见 [“检测并发写入”](/ch6#sec_replication_concurrent))。逻辑时钟既不度量一天中的时刻,也不度量经过了多少秒,只记录事件之间的相对顺序(一个事件发生在另一个事件之前还是之后)。与之相对,日历时钟和单调时钟度量真实流逝的时间,因此也称为 *物理时钟**physical clock*。我们将在 [“ID 生成器和逻辑时钟”](/ch10#sec_consistency_logical) 中更详细地讨论逻辑时钟。
#### 带置信区间的时钟读数 {#clock-readings-with-a-confidence-interval}
@@ -343,7 +343,7 @@ Spanner 正是以这种方式实现跨数据中心的快照隔离 [^68] [^69]。
再来看一个在分布式系统中危险使用时钟的例子。假设某个数据库的每个分片都只有一个领导者,而且只有领导者可以接受写入。一个节点怎么知道自己仍是领导者(没有被其他节点宣告死亡),因而可以安全地接受写入呢?
一种办法是由领导者向其他节点取得一份 *租约*,它类似于带有超时的锁 [^73]。任何时刻只能有一个节点持有租约。因此,节点拿到租约后,就知道自己在租约到期以前的一段时间内仍是领导者。为保持领导者身份,节点必须在租约到期前定期续租。如果节点失效,就会停止续租;租约到期后,另一个节点便可接管。
一种办法是由领导者向其他节点取得一份 *租约**lease*,它类似于带有超时的锁 [^73]。任何时刻只能有一个节点持有租约。因此,节点拿到租约后,就知道自己在租约到期以前的一段时间内仍是领导者。为保持领导者身份,节点必须在租约到期前定期续租。如果节点失效,就会停止续租;租约到期后,另一个节点便可接管。
可以想象,请求处理循环大致如下:
@@ -374,9 +374,9 @@ while (true) {
* 许多编程语言运行时(例如 Java 虚拟机)都带有 *垃圾回收器*GC偶尔需要停止所有正在运行的线程。过去这类 *STW GC 暂停* 有时会让程序停上几分钟 [^75]!现代 GC 算法已经大大缓解了这个问题,但 GC 暂停仍可能相当明显(参见 [“限制垃圾回收的影响”](/ch9#sec_distributed_gc_impact))。
* 在虚拟化环境中,虚拟机可以被 *挂起*(暂停所有进程并把内存内容保存到磁盘),随后再 *恢复*(还原内存内容并从原处继续执行)。这种暂停可能发生在进程执行的任何时刻,持续时间也没有上限。这个功能有时用于把虚拟机从一台宿主机 *实时迁移* 到另一台宿主机而无需重启;在这种情况下,暂停多久取决于进程写入内存的速率 [^76]。
* 在笔记本电脑和手机等终端用户设备上,执行也可能随时挂起并恢复,例如用户合上笔记本电脑屏幕时。
* 当操作系统切换到另一个线程,或者虚拟机监控器切换到另一台虚拟机时,当前线程可能停在代码中的任意位置。对虚拟机而言,被其他虚拟机占用的 CPU 时间称为 *窃取时间*。如果机器负载很高——也就是有很长的线程队列在等待运行——暂停的线程可能要过一阵子才能再次获得运行机会。
* 当操作系统切换到另一个线程,或者虚拟机监控器切换到另一台虚拟机时,当前线程可能停在代码中的任意位置。对虚拟机而言,被其他虚拟机占用的 CPU 时间称为 *窃取时间**steal time*。如果机器负载很高——也就是有很长的线程队列在等待运行——暂停的线程可能要过一阵子才能再次获得运行机会。
* 如果应用程序执行同步磁盘访问,线程可能暂停下来,等待缓慢的磁盘 I/O 操作完成 [^77]。在许多语言中即使代码没有明确读写文件磁盘访问也可能出人意料地发生。例如Java 类加载器会在类第一次使用时才加载类文件而这可能出现在程序执行的任何时刻。I/O 暂停与 GC 暂停甚至可能相互叠加 [^78]。如果所谓的磁盘其实是网络文件系统或网络块设备(例如 Amazon EBSI/O 延迟还会受到网络延迟波动的影响 [^31]。
* 如果操作系统允许 *换页到磁盘**分页*),一次简单的内存访问也可能触发缺页错误,必须从磁盘把某个页面载入内存。在这项缓慢的 I/O 操作完成以前,线程会一直暂停。如果内存压力很大,还可能需要先把另一个页面换出到磁盘。极端情况下,操作系统会把大部分时间耗在内存页面的换入换出上,几乎不做实际工作,这叫作 *抖动*。为了避免这种情况,服务器通常会禁用分页——与其冒着发生抖动的风险,不如终止一个进程来释放内存。
* 如果操作系统允许 *换页到磁盘**分页*),一次简单的内存访问也可能触发缺页错误,必须从磁盘把某个页面载入内存。在这项缓慢的 I/O 操作完成以前,线程会一直暂停。如果内存压力很大,还可能需要先把另一个页面换出到磁盘。极端情况下,操作系统会把大部分时间耗在内存页面的换入换出上,几乎不做实际工作,这叫作 *抖动**thrashing*。为了避免这种情况,服务器通常会禁用分页——与其冒着发生抖动的风险,不如终止一个进程来释放内存。
* 向 Unix 进程发送 `SIGSTOP` 信号也会令其暂停,例如在 shell 中按 Ctrl-Z。这个信号会立即停止给进程分配 CPU 周期,直到 `SIGCONT` 令它恢复;随后,它会从先前停下的位置继续运行。即使你的环境通常不用 `SIGSTOP`,运维人员也可能不小心发出这个信号。
上述任何事件都可能在任意位置 *抢占* 正在运行的线程,过一段时间再让它恢复,而线程对此毫无察觉。这个问题类似于保证单机多线程代码的线程安全:不能对时序作任何假定,因为上下文切换与并行执行随时都可能发生。
@@ -389,7 +389,7 @@ while (true) {
如上所述,在许多编程语言与操作系统中,线程和进程都可能暂停任意长的时间。不过,只要投入足够努力,这些暂停的原因 *确实可以* 消除。
有些软件运行在这样的环境中:如果不能在规定时间内响应,就可能造成严重损害。控制飞机、火箭、机器人、汽车及其他实体设备的计算机,必须快速而且可预测地响应传感器输入。在这些系统中,软件必须赶在明确规定的 *截止时间* 之前响应;错过截止时间,就可能导致整个系统失效。这样的系统称为 *硬实时* 系统。
有些软件运行在这样的环境中:如果不能在规定时间内响应,就可能造成严重损害。控制飞机、火箭、机器人、汽车及其他实体设备的计算机,必须快速而且可预测地响应传感器输入。在这些系统中,软件必须赶在明确规定的 *截止时间**deadline*之前响应;错过截止时间,就可能导致整个系统失效。这样的系统称为 *硬实时**hard real-time*系统。
> [!NOTE]
@@ -426,7 +426,7 @@ while (true) {
关于这类系统的讨论已经近乎哲学:在系统中,我们究竟知道什么为真、什么为假?如果感知和测量的机制都不可靠,我们又能在多大程度上确信自己的认知 [^83]?软件系统是否应当服从我们认为物理世界必然遵循的法则,例如因果律?
好在我们不必一路追问到生命的意义。在分布式系统中,可以明确写出对系统行为所作的假设(即 *系统模型*),再把实际系统设计成符合这些假设。我们还可以证明某个算法在特定系统模型中能够正确运行。这意味着,即使底层系统模型只提供极少保证,也仍然可以实现可靠的行为。
好在我们不必一路追问到生命的意义。在分布式系统中,可以明确写出对系统行为所作的假设(即 *系统模型**system model*),再把实际系统设计成符合这些假设。我们还可以证明某个算法在特定系统模型中能够正确运行。这意味着,即使底层系统模型只提供极少保证,也仍然可以实现可靠的行为。
不过,要让软件在不可靠的系统模型中表现良好,绝非轻而易举。本章余下部分将进一步探讨分布式系统中的知识与真相,帮助我们思考可以作出哪些假设,以及希望提供哪些保证。在 [第 10 章](/ch10#ch_consistency) 中,我们将继续考察一些分布式算法:它们在特定假设下提供特定保证。
@@ -438,7 +438,7 @@ while (true) {
第三种场景是,假设某个节点暂停执行一分钟。在此期间,它既不处理请求,也不发送响应。其他节点一边等待、一边重试,终于失去耐心,宣告它死亡并把它抬上灵车。最终暂停结束,节点的线程若无其事地继续执行。其他节点惊讶地看到,那个本应死去的节点突然精神抖擞地从棺材里探出头来,兴高采烈地与旁人聊天。刚恢复时,这个节点甚至不知道整整一分钟已经过去,也不知道自己已经被宣告死亡——在它看来,距离上次和其他节点交谈仿佛只过了一瞬间。
这些故事告诉我们,节点未必能相信自己对处境的判断。分布式系统不能只依赖某一个节点,因为节点随时可能失效,使系统陷入僵局而无法恢复。因此,许多分布式算法依赖 *法定人数*,也就是让多个节点投票(参见 [“读写仲裁”](/ch6#sec_replication_quorum_condition)):一项决策必须获得若干节点的最低票数,借此减少对任何单个节点的依赖。
这些故事告诉我们,节点未必能相信自己对处境的判断。分布式系统不能只依赖某一个节点,因为节点随时可能失效,使系统陷入僵局而无法恢复。因此,许多分布式算法依赖 *法定人数**quorum*,也就是让多个节点投票(参见 [“读写仲裁”](/ch6#sec_replication_quorum_condition)):一项决策必须获得若干节点的最低票数,借此减少对任何单个节点的依赖。
宣告节点死亡的决定也是如此。如果达到法定人数的节点宣告另一个节点已经死亡,那么即使它觉得自己还活得好好的,也必须被视为死亡。单个节点必须服从法定人数作出的决定并下台。
@@ -470,7 +470,7 @@ while (true) {
#### 用栅栏机制隔离僵尸与延迟请求 {#sec_distributed_fencing_tokens}
*僵尸* 一词有时用来形容这样的原租约持有者:它还不知道自己已经失去租约,仍把自己当作当前持有者行事。既然无法彻底杜绝僵尸,就必须确保它们不能以脑裂的形式造成任何破坏。这称为用 *栅栏机制* 隔离僵尸。
*僵尸**zombie*一词有时用来形容这样的原租约持有者:它还不知道自己已经失去租约,仍把自己当作当前持有者行事。既然无法彻底杜绝僵尸,就必须确保它们不能以脑裂的形式造成任何破坏。这称为用 *栅栏机制**fencing*隔离僵尸。
有些系统试图通过关停僵尸来隔离它,例如断开它的网络连接 [^9]、通过云服务商的管理界面关闭虚拟机,甚至直接切断机器电源 [^87]。这种做法称为 STONITH即“击毙另一个节点”。遗憾的是它有几个问题无法防范 {{< xref fig="9-5" page="/ch9" anchor="fig_distributed_lease_delay" >}}图 9-5{{< /xref >}} 所示的超长网络延迟;所有节点可能彼此关停 [^19];而等到僵尸被发现并关闭时,也许早已为时过晚,数据已经损坏。
@@ -479,18 +479,18 @@ while (true) {
{{< fig num="9-6" id="fig_distributed_fencing" src="/fig/ddia_0906.png" caption="只允许写入按照递增的栅栏令牌顺序执行,从而保证存储访问安全。" class="ddia-figure ddia-figure--wide" width="2658" height="975" />}}
假设锁服务每次授予锁或租约时,还会返回一个 *栅栏令牌*。这是一个每次授予锁都会增大的数字,例如由锁服务负责递增。随后可以要求客户端每次向存储服务发送写请求时,都必须带上自己当前的栅栏令牌。
假设锁服务每次授予锁或租约时,还会返回一个 *栅栏令牌**fencing token*。这是一个每次授予锁都会增大的数字,例如由锁服务负责递增。随后可以要求客户端每次向存储服务发送写请求时,都必须带上自己当前的栅栏令牌。
> [!NOTE]
> 栅栏令牌还有几种别称。在 Google 的锁服务 Chubby 中,它叫作 *序列器* [^88];在 Kafka 中则叫作 *纪元编号*。在我们将在 [第 10 章](/ch10#ch_consistency) 讨论的共识算法中Paxos 的 *投票编号* 或 Raft 的 *任期编号* 也发挥着类似作用。
> 栅栏令牌还有几种别称。在 Google 的锁服务 Chubby 中,它叫作 *序列器**sequencer*[^88];在 Kafka 中则叫作 *纪元编号**epoch number*。在我们将在 [第 10 章](/ch10#ch_consistency) 讨论的共识算法中Paxos 的 *投票编号**ballot number*)或 Raft 的 *任期编号**term number*也发挥着类似作用。
在 {{< xref fig="9-6" page="/ch9" anchor="fig_distributed_fencing" >}}图 9-6{{< /xref >}} 中,客户端 1 取得租约及令牌 33随后却长时间暂停导致租约过期。客户端 2 接着取得租约及令牌 34令牌值始终递增并向存储服务发送带令牌 34 的写请求。稍后,客户端 1 恢复执行,也向存储服务发送写请求,其中带着自己的令牌 33。然而存储服务记得自己已经处理过令牌值更高34的写入因此会拒绝令牌 33 的请求。刚取得租约的客户端必须立刻向存储服务执行一次写入;一旦这次写入完成,所有僵尸都会被隔离在外。
如果使用 ZooKeeper 作为锁服务,可以把事务 ID `zxid` 或节点版本 `cversion` 用作栅栏令牌 [^85]。在 etcd 中,修订号与租约 ID 共同发挥类似作用 [^89]。Hazelcast 的 FencedLock API 则会显式生成栅栏令牌 [^90]。
这种机制要求存储服务能够检查写入所携带的令牌是否已经过时。另一种办法是让服务支持类似原子比较并设置CAS的写入只有从当前客户端上次读取对象以后没有其他客户端写过该对象写入才会成功。对象存储服务就支持这类检查Amazon S3 称之为 *条件写入*Azure Blob Storage 称之为 *条件标头*Google Cloud Storage 则称之为 *请求前置条件*
这种机制要求存储服务能够检查写入所携带的令牌是否已经过时。另一种办法是让服务支持类似原子比较并设置CAS的写入只有从当前客户端上次读取对象以后没有其他客户端写过该对象写入才会成功。对象存储服务就支持这类检查Amazon S3 称之为 *条件写入**conditional write*Azure Blob Storage 称之为 *条件标头**conditional header*Google Cloud Storage 则称之为 *请求前置条件**request precondition*
#### 多副本隔离 {#fencing-with-multiple-replicas}
@@ -513,18 +513,18 @@ while (true) {
本书假定节点虽然不可靠,却是诚实的:它们可能因为故障而响应缓慢或永不响应,也可能因为 GC 暂停或网络延迟而持有过时状态;但我们假定,只要节点 *确实* 作出响应,它说的就是“真话”——至少据它自己所知,它是在遵守协议规则。
如果节点有可能“撒谎”,也就是发出任意错误或损坏的响应,分布式系统问题就会困难得多。例如,一个节点可能在同一轮选举中投出多张互相矛盾的票。这种行为叫作 *拜占庭故障*,而在这种互不信任的环境中达成共识的问题,则称为 *拜占庭将军问题* [^94]。
如果节点有可能“撒谎”,也就是发出任意错误或损坏的响应,分布式系统问题就会困难得多。例如,一个节点可能在同一轮选举中投出多张互相矛盾的票。这种行为叫作 *拜占庭故障**Byzantine fault*,而在这种互不信任的环境中达成共识的问题,则称为 *拜占庭将军问题**Byzantine Generals Problem*[^94]。
> [!TIP] 拜占庭将军问题
>
> 拜占庭将军问题是所谓 *两将军问题* [^95] 的推广。两将军问题设想,两名军队将领必须就作战计划达成一致,但他们驻扎在不同地点,只能派信使互通消息,而信使有时会迟到或失踪(就像网络数据包一样)。我们将在 [第 10 章](/ch10#ch_consistency) 中讨论这个 *共识* 问题。
> 拜占庭将军问题是所谓 *两将军问题**Two Generals Problem*[^95] 的推广。两将军问题设想,两名军队将领必须就作战计划达成一致,但他们驻扎在不同地点,只能派信使互通消息,而信使有时会迟到或失踪(就像网络数据包一样)。我们将在 [第 10 章](/ch10#ch_consistency) 中讨论这个 *共识**consensus*问题。
>
> 在拜占庭版本的问题中,需要达成一致的将军有 *n* 名,但其中混入了若干叛徒。大多数将军都忠诚可靠,会发送真实消息;叛徒却可能发送虚假消息,企图欺骗并迷惑其他人。而谁是叛徒,事先无从得知。
>
> 拜占庭原是古希腊的一座城市,后来成为君士坦丁堡,所在地就是今天土耳其的伊斯坦布尔。没有任何历史证据表明,拜占庭的将军比其他地方的将军更爱阴谋诡计。这个名称其实来自 *拜占庭式* 一词在政治语境中的含义:*过度复杂、官僚而诡诈*;这种用法远在计算机出现以前便已存在 [^96]。Lamport 想选择一个不会冒犯读者的国籍,而别人劝他最好不要把问题叫作 *阿尔巴尼亚将军问题* [^97]。
如果某些节点发生故障、不遵守协议,或有恶意攻击者干扰网络时,系统仍能继续正确运行,就称这个系统具有 *拜占庭容错* 能力。这类问题在某些特定情形下确实很重要。例如:
如果某些节点发生故障、不遵守协议,或有恶意攻击者干扰网络时,系统仍能继续正确运行,就称这个系统具有 *拜占庭容错**Byzantine fault tolerance*BFT能力。这类问题在某些特定情形下确实很重要。例如:
* 在航空航天环境中,辐射可能破坏计算机内存或 CPU 寄存器里的数据,使节点以任意且不可预测的方式回应其他节点。系统失效的代价极其高昂——例如飞机坠毁导致机上人员全部遇难,或火箭撞上国际空间站——因此飞行控制系统必须能够容忍拜占庭故障 [^98] [^99]。
* 在有多方参与的系统中,部分参与者可能企图欺骗或诈骗其他人。此时,节点不能轻信另一个节点发来的消息,因为消息可能带有恶意。比特币等加密货币及其他区块链,就可以看作一种无需依赖中央权威,让彼此不信任的各方就某笔交易是否发生达成一致的方式 [^100]。
@@ -551,29 +551,29 @@ Web 应用程序的确必须预料到Web 浏览器等由最终用户控制的
人们已经设计了许多算法来解决分布式系统问题。例如,我们将在 [第 10 章](/ch10#ch_consistency) 中考察共识问题的解决方案。要真正有用,这些算法必须能够容忍本章讨论的分布式系统中的各种故障。
算法的编写方式不应过度依赖其运行环境中具体的硬件与软件配置。这又要求我们以某种方式,把系统中预期会发生的故障类型形式化。为此,我们定义 *系统模型*:它是一种抽象,用来说明算法可以作出哪些假设。
算法的编写方式不应过度依赖其运行环境中具体的硬件与软件配置。这又要求我们以某种方式,把系统中预期会发生的故障类型形式化。为此,我们定义 *系统模型**system model*:它是一种抽象,用来说明算法可以作出哪些假设。
关于时序假设,通常使用以下三种系统模型:
同步模型
同步模型synchronous model
: 同步模型假定网络延迟、进程暂停和时钟误差都有上界。这并不表示时钟完全同步,也不表示网络延迟为零;它只是意味着,我们知道网络延迟、暂停和时钟漂移永远不会超过某个固定上限 [^108]。对大多数实际系统而言,同步模型并不现实,因为正如本章所讨论的,无界延迟和暂停确实可能发生。
部分同步模型
部分同步模型partially synchronous model
: 部分同步是指系统在 *大多数时候* 都像同步系统一样运行,但偶尔会突破网络延迟、进程暂停和时钟漂移的界限 [^108]。对许多系统来说,这是一个现实的模型:绝大多数时候,网络与进程表现得相当规矩,否则我们什么事情也做不成;但我们也必须正视这样一个事实——任何时序假设都有可能偶尔失效。发生这种情况时,网络延迟、进程暂停和时钟误差都可能变得任意之大。
异步模型
异步模型asynchronous model
: 在这种模型中,算法不能作出任何时序假设——事实上,它甚至没有时钟可用(因而也不能使用超时)。有些算法可以针对异步模型来设计,但这种模型限制极大。
除了时序问题,还必须考虑节点失效。常见的节点系统模型包括:
崩溃停止故障
: 在 *崩溃停止*(或 *故障停止*)模型中,算法可以假定节点只有一种失效方式:崩溃 [^109]。节点可能在任意时刻突然停止响应,此后便永远消失,再也不会回来。
崩溃停止故障crash-stop fault
: 在 *崩溃停止**crash-stop**故障停止**fail-stop*)模型中,算法可以假定节点只有一种失效方式:崩溃 [^109]。节点可能在任意时刻突然停止响应,此后便永远消失,再也不会回来。
崩溃恢复故障
崩溃恢复故障crash-recovery fault
: 我们假定节点可能在任意时刻崩溃,也可能在一段未知时间后重新开始响应。在崩溃恢复模型中,节点拥有能在崩溃后保留数据的稳定存储(即非易失性磁盘存储),但内存中的状态会丢失。
性能下降和功能不全
: 除了崩溃与重启,节点还可能变慢:它们也许仍能响应健康检查,却慢得无法完成任何实际工作。例如,千兆网络接口可能因为驱动程序缺陷,吞吐量突然跌到 1 Kb/s [^110];面临内存压力的进程可能把大部分时间花在垃圾回收上 [^111];磨损的 SSD 可能表现得极不稳定;高温、连接器松动、机械振动、电源问题、固件缺陷等也会影响硬件 [^112]。这类情形称为 *跛行节点*、*灰色失效* 或 *慢失效* [^113],甚至可能比彻底失效的节点更难处理。还有一种相关问题:进程不再执行原本应做的某些工作,其他功能却仍在继续,例如后台线程崩溃或死锁时 [^114]。
: 除了崩溃与重启,节点还可能变慢:它们也许仍能响应健康检查,却慢得无法完成任何实际工作。例如,千兆网络接口可能因为驱动程序缺陷,吞吐量突然跌到 1 Kb/s [^110];面临内存压力的进程可能把大部分时间花在垃圾回收上 [^111];磨损的 SSD 可能表现得极不稳定;高温、连接器松动、机械振动、电源问题、固件缺陷等也会影响硬件 [^112]。这类情形称为 *跛行节点**limping node*)、*灰色失效**gray failure*)或 *慢失效**fail-slow*[^113],甚至可能比彻底失效的节点更难处理。还有一种相关问题:进程不再执行原本应做的某些工作,其他功能却仍在继续,例如后台线程崩溃或死锁时 [^114]。
拜占庭(任意)故障
: 节点可能做出任何行为,包括像上一节所述那样欺骗其他节点。
@@ -599,7 +599,7 @@ Web 应用程序的确必须预料到Web 浏览器等由最终用户控制的
#### 安全性与活性 {#sec_distributed_safety_liveness}
为了说清这个问题,有必要区分两类不同的属性:*安全性* *活性*。在刚才的例子中,*唯一性* 和 *单调序列* 属于安全属性,*可用性* 则属于活性属性。
为了说清这个问题,有必要区分两类不同的属性:*安全性**safety**活性**liveness*。在刚才的例子中,*唯一性* 和 *单调序列* 属于安全属性,*可用性* 则属于活性属性。
两者究竟有何区别?一个明显线索是,活性属性的定义中往往含有“最终”二字。(没错,你已经猜到了:*最终一致性* 就是一项活性属性 [^115]。)
@@ -630,11 +630,11 @@ Web 应用程序的确必须预料到Web 浏览器等由最终用户控制的
一种办法是对算法进行形式化验证:用数学语言描述算法,再运用证明技术,证明它在系统模型允许的所有情形下都满足所需属性。证明算法正确,并不表示它在真实系统中的 *实现* 必然始终行为正确。不过,这是非常好的一步,因为理论分析能够发现算法中的隐患;这些问题在真实系统中可能潜伏很久,直到某些异常情况打破了你的假设(例如时序假设)才突然发作。
把理论分析与经验性测试结合起来,验证实现的行为是否符合预期,是一种稳妥做法。基于属性的测试、模糊测试和确定性模拟测试(DST等技术都利用随机化在各种不同情形下测试系统。Amazon Web Services 等公司已经成功地把这些技术组合运用于许多产品 [^120] [^121]。
把理论分析与经验性测试结合起来,验证实现的行为是否符合预期,是一种稳妥做法。基于属性的测试*property-based testing*)、模糊测试(*fuzz testing*)和确定性模拟测试(*deterministic simulation testing*DST等技术都利用随机化在各种不同情形下测试系统。Amazon Web Services 等公司已经成功地把这些技术组合运用于许多产品 [^120] [^121]。
#### 模型检查与规范语言 {#model-checking-and-specification-languages}
*模型检查器* 是帮助验证算法或系统行为是否符合预期的工具。算法规范要用 TLA+、Gallina 或 FizzBee 等专门设计的语言来编写。借助这类语言,我们可以专注于算法行为,不必纠缠于代码实现细节。模型检查器随后会系统地尝试各种可能发生的情况,利用模型来验证不变量是否在算法的所有状态中都成立。
*模型检查器**model checker*是帮助验证算法或系统行为是否符合预期的工具。算法规范要用 TLA+、Gallina 或 FizzBee 等专门设计的语言来编写。借助这类语言,我们可以专注于算法行为,不必纠缠于代码实现细节。模型检查器随后会系统地尝试各种可能发生的情况,利用模型来验证不变量是否在算法的所有状态中都成立。
严格来说,模型检查无法证明算法的不变量在每一种可能状态下都成立,因为大多数现实算法的状态空间是无限的。要真正验证所有状态,需要给出形式化证明;这虽然可行,但通常比运行模型检查器困难得多。因此,使用模型检查器时,通常要把算法模型缩减成一个可以完全验证的近似版本,或是给执行设置某种上限(例如限制最多可以发送多少条消息)。这样一来,只会在更长执行过程中出现的缺陷就无法被发现。
@@ -644,9 +644,9 @@ Web 应用程序的确必须预料到Web 浏览器等由最终用户控制的
#### 故障注入 {#sec_fault_injection}
许多缺陷只有在机器或网络发生失效时才会触发。故障注入是一种有效(有时也相当吓人)的技术,用来验证系统实现遇到问题时是否仍会按预期工作。思路很简单:向正在运行的系统环境注入故障,再观察系统如何反应。注入的故障可以是网络失效、机器崩溃、磁盘损坏、进程暂停——凡是你能想到的计算机出错方式都可以尝试。
许多缺陷只有在机器或网络发生失效时才会触发。*故障注入**fault injection*是一种有效(有时也相当吓人)的技术,用来验证系统实现遇到问题时是否仍会按预期工作。思路很简单:向正在运行的系统环境注入故障,再观察系统如何反应。注入的故障可以是网络失效、机器崩溃、磁盘损坏、进程暂停——凡是你能想到的计算机出错方式都可以尝试。
故障注入测试通常在与系统实际生产环境十分相似的环境中运行有些团队甚至直接在生产环境中注入故障。Netflix 通过 Chaos Monkey 工具推广了这种做法 [^128]。在生产环境中注入故障通常称为 *混沌工程*,我们在 [“可靠性与容错”](/ch2#sec_introduction_reliability) 中已经讨论过。
故障注入测试通常在与系统实际生产环境十分相似的环境中运行有些团队甚至直接在生产环境中注入故障。Netflix 通过 Chaos Monkey 工具推广了这种做法 [^128]。在生产环境中注入故障通常称为 *混沌工程**chaos engineering*,我们在 [“可靠性与容错”](/ch2#sec_introduction_reliability) 中已经讨论过。
运行故障注入测试时首先要部署被测系统以及故障注入协调者和脚本。协调者负责决定注入哪些故障、何时注入本地或远程脚本则负责让单个节点或进程发生失效。注入脚本会利用许多不同工具来触发故障Linux 进程可以用 `kill` 命令暂停或终止,磁盘可以用 `umount` 卸载,网络连接可以通过防火墙规则中断。检查故障注入期间以及之后的系统行为,就能确认系统是否一如预期。