diff --git a/content/zh/ch1.md b/content/zh/ch1.md index 54fd40b..a4dd296 100644 --- a/content/zh/ch1.md +++ b/content/zh/ch1.md @@ -4,23 +4,25 @@ weight: 101 breadcrumbs: false --- + + > *没有完美的解决方案,只有权衡取舍。[…] 你能做的就是努力获得最佳的权衡,这就是你所能期望的一切。* > > [Thomas Sowell](https://www.youtube.com/watch?v=2YUtKr8-_Fg),接受 Fred Barnes 采访(2005) > [!TIP] 早期读者注意事项 -> 通过早期发布电子书,您可以在书籍最早期的形式中获得内容——作者在撰写时的原始和未经编辑的内容——以便您可以在这些技术正式发布之前就充分利用它们。 +> 通过 Early Release 电子书,你可以在最早阶段读到作者写作中的原始、未编辑内容,从而在正式版发布前尽早使用这些技术。 > > 这将是最终书籍的第 1 章。本书的 GitHub 仓库是 https://github.com/ept/ddia2-feedback。 -> 如果您想积极参与审阅和评论本草稿,请在 GitHub 上联系我们。 +> 如果你希望积极参与本草稿的审阅与评论,请在 GitHub 上联系。 -数据是当今许多应用程序开发的核心。随着 Web 和移动应用、软件即服务(SaaS)以及云服务的兴起,将来自不同用户的数据存储在共享的基于服务器的数据基础设施中已成为常态。来自用户活动、业务交易、设备和传感器的数据需要被存储并可供分析使用。当用户与应用程序交互时,他们既读取已存储的数据,也生成更多的数据。 +数据是当今应用开发的核心。随着 Web 与移动应用、软件即服务(SaaS)和云服务普及,把许多不同用户的数据存放在共享的服务器端数据基础设施中,已经成为常态。来自用户行为、业务交易、设备与传感器的数据,需要被存储并可用于分析。用户每次与应用交互,既会读取已有数据,也会产生新数据。 -少量的数据可以存储和处理在单台机器上,通常相当容易处理。然而,随着数据量或查询速率的增长,数据需要分布在多台机器上,这带来了许多挑战。随着应用程序需求变得更加复杂,将所有内容存储在一个系统中已经不够,可能需要组合多个提供不同能力的存储或处理系统。 +当数据量较小、可在单机存储和处理时,问题往往并不复杂。但随着数据规模或查询速率增长,数据必须分布到多台机器上,挑战随之而来。随着需求变得更复杂,仅靠单一系统通常已不足够,你可能需要组合多个具备不同能力的存储与处理系统。 -如果数据管理是开发应用程序的主要挑战之一,我们就称应用程序为 **数据密集型(data-intensive)** 的 [^1]。虽然在 **计算密集型(compute-intensive)** 系统中,挑战是并行化某些非常大规模的计算,但在数据密集型应用中,我们通常更关心诸如存储和处理大量数据、管理数据变更、在面对故障和并发时确保一致性,以及确保服务高可用等问题。 +如果“管理数据”是开发过程中的主要挑战之一,我们称这样的应用为 **数据密集型(data-intensive)** 应用 [^1]。与之对照,在 **计算密集型(compute-intensive)** 系统中,难点是并行化超大规模计算;而在数据密集型应用中,我们更常关心的是:如何存储与处理海量数据、如何管理数据变化、如何在故障与并发下保持一致性,以及如何让服务保持高可用。 -这些应用程序通常由提供常用功能的标准构建块构建而成。例如,许多应用程序需要: +这类应用通常由若干标准构件搭建而成,每个构件负责一种常见能力。例如,很多应用都需要: * 存储数据,以便它们或其他应用程序以后能再次找到(**数据库**) * 记住昂贵操作的结果,以加快读取速度(**缓存**) @@ -28,15 +30,15 @@ breadcrumbs: false * 一旦事件和数据变更发生就立即处理(**流处理**) * 定期处理累积的大量数据(**批处理**) -在构建应用程序时,我们通常会采用几个软件系统或服务,例如数据库或 API,并用一些应用程序代码将它们粘合在一起。如果你正在做数据系统设计的工作,那么这个过程可能会相当容易。 +在构建应用时,我们通常会选择若干软件系统或服务(例如数据库或 API),再用应用代码把它们拼接起来。如果你的需求恰好落在这些系统的设计边界内,这并不困难。 -然而,随着你的应用程序变得更加雄心勃勃,挑战就会出现。有许多具有不同特性的数据库系统,适合不同的目的——你如何选择使用哪一个?有各种缓存方法、构建搜索索引的几种方式等等——你如何在它们之间进行权衡?你需要找出哪些工具和哪些方法最适合手头的任务,当你需要做单个工具无法单独完成的事情时,组合工具可能会很困难。 +但当应用目标更有野心时,问题就会出现。数据库有很多种,各自特性不同、适用场景也不同,如何选型?缓存有多种做法,搜索索引也有多种构建方式,如何权衡?当单个工具无法独立完成目标时,如何把多个工具可靠地组合起来?这些都并不简单。 -本书是一个指南,帮助你决定使用哪些技术以及如何组合它们。正如你将看到的,没有一种方法从根本上优于其他方法;一切都有利弊。通过本书,你将学会提出正确的问题来评估和比较数据系统,以便你能找出哪种方法最能满足你特定应用程序的需求。 +本书正是用来帮助你做这类决策:该用什么技术、怎样组合技术。你会看到,没有哪种方案在根本上永远优于另一种;每种方案都有得失。通过本书,你将学会提出正确问题来评估和比较数据系统,从而为你的具体应用找到更合适的方案。 -我们将通过观察当今组织中数据的一些典型使用方式来开始我们的旅程。这里的许多想法起源于 **企业软件**(即大型组织的软件需求和工程实践,大型组织包括大公司和政府等),因为历史上只有大型组织拥有需要复杂技术解决方案的大数据量。如果你的数据量足够小,你可以简单地将其保存在电子表格中!然而,最近小公司和初创公司管理大数据量并构建数据密集型系统也变得很常见。 +我们将从今天组织内数据的典型使用方式开始。这些思想很多源自 **企业软件**(即大型组织的软件需求与工程实践,例如大公司和政府机构),因为在历史上,只有这类组织才有足够大的数据规模,值得投入复杂技术方案。如果你的数据足够小,电子表格都可能够用;但近些年,小公司和初创团队构建数据密集型系统也越来越常见。 -数据系统的关键挑战之一是不同的人需要用数据做非常不同的事情。如果你在一家公司工作,你和你的团队将有一套优先事项,而另一个团队可能有完全不同的目标,即使你们可能在处理相同的数据集!此外,这些目标可能没有被明确阐述,这可能导致对正确方法的误解和分歧。 +数据系统的核心难点之一在于:不同的人需要用同一份数据做完全不同的事。在公司里,你和你的团队有自己的优先级,另一个团队即使使用同一数据集,目标也可能完全不同。更麻烦的是,这些目标往往并未被明确表达,容易引发误解和分歧。 为了帮助你了解可以做出哪些选择,本章比较了几个对比概念,并探讨了它们的权衡: @@ -45,18 +47,18 @@ breadcrumbs: false * 何时从单节点系统转向分布式系统(["分布式与单节点系统"](/ch1#sec_introduction_distributed));以及 * 平衡业务需求和用户权利(["数据系统、法律与社会"](/ch1#sec_introduction_compliance))。 -此外,本章将为你提供本书其余部分所需的术语。 +此外,本章还会引入贯穿全书的关键术语。 > [!TIP] 术语:前端和后端 -本书中我们将讨论的大部分内容都与 **后端开发** 有关。为了解释这个术语:对于 Web 应用程序,在 Web 浏览器中运行的客户端代码称为 **前端**,处理用户请求的服务器端代码称为 **后端**。移动应用类似于前端,它们提供用户界面,通常通过互联网与服务器端后端通信。前端有时在用户设备上本地管理数据 [^2],但最大的数据基础设施挑战通常在于后端:前端只需要处理一个用户的数据,而后端为 **所有** 用户管理数据。 +本书讨论的大部分内容都与 **后端开发** 相关。对 Web 应用而言,运行在浏览器中的客户端代码称为 **前端**,处理用户请求的服务器端代码称为 **后端**。移动应用也类似前端:它们提供用户界面,通常经由互联网与服务器端后端通信。前端有时会在设备本地管理数据 [^2],但更棘手的数据基础设施问题通常发生在后端:前端只处理单个用户的数据,而后端需要代表 **所有** 用户管理数据。 -后端服务通常可通过 HTTP(有时是 WebSocket)访问;它通常由一些应用程序代码组成,这些代码在一个或多个数据库中读取和写入数据,有时还与其他数据系统(如缓存或消息队列)交互(我们可能将其统称为 **数据基础设施**)。应用程序代码通常是 **无状态的**(即,当它完成处理一个 HTTP 请求时,它会忘记关于该请求的所有内容),任何需要从一个请求持续到另一个请求的信息都需要存储在客户端或服务器端的数据基础设施中。 +后端服务通常通过 HTTP(有时是 WebSocket)提供访问。其核心是应用代码:在一个或多个数据库中读写数据,并按需接入缓存、消息队列等其他系统(可统称为 **数据基础设施**)。应用代码往往是 **无状态** 的:处理完一个 HTTP 请求后,不保留该请求上下文。因此,凡是需要跨请求持久化的信息,都必须写在客户端,或写入服务器端数据基础设施。 ## 分析型与事务型系统 {#sec_introduction_analytics} -如果你在企业中从事数据系统工作,你可能会遇到几种不同类型的数据工作者。第一类是 **后端工程师**,他们构建服务来处理读取和更新数据的请求;这些服务通常直接或间接地通过其他服务为外部用户提供服务(参见["微服务与 Serverless"](/ch1#sec_introduction_microservices))。有时服务是供组织其他部门内部使用的。 +如果你在企业中从事数据系统工作,往往会遇到几类不同的数据使用者。第一类是 **后端工程师**,他们构建服务来处理读取与更新数据的请求;这些服务通常直接面向外部用户,或通过其他服务间接提供能力(参见["微服务与无服务器"](/ch1#sec_introduction_microservices))。有时服务也只供组织内部使用。 除了管理后端服务的团队外,通常还有两类人需要访问组织的数据:**业务分析师**,他们生成关于组织活动的报告,以帮助管理层做出更好的决策(**商业智能** 或 **BI**);以及 **数据科学家**,他们在数据中寻找新的见解,或创建由数据分析和机器学习(AI)支持的面向用户的产品功能(例如,电子商务网站上的“购买了 X 的人也购买了 Y”推荐、风险评分或垃圾邮件过滤等预测分析,以及搜索结果排名)。 @@ -67,7 +69,7 @@ breadcrumbs: false 正如我们将在下一节中看到的,事务型系统和分析型系统通常出于充分的理由而保持分离。随着这些系统的成熟,出现了两个新的专业角色:**数据工程师** 和 **分析工程师**。数据工程师是知道如何集成事务型系统和分析型系统的人,并更广泛地负责组织的数据基础设施 [^3]。分析工程师对数据进行建模和转换,使其对组织中的业务分析师和数据科学家更有用 [^4]。 -许多工程师专注于事务型系统和分析型系统中的一个。然而,本书涵盖了事务型和分析型数据系统,因为两者在组织内数据的生命周期中都扮演着重要角色。我们将深入探讨用于向内部和外部用户提供服务的数据基础设施,以便你能更好地与分界线另一边的同事合作。 +许多工程师只专注于事务型或分析型其中一侧。然而,本书会同时覆盖这两类数据系统,因为它们都在组织内的数据生命周期中扮演关键角色。我们将深入讨论向内外部用户提供服务所需的数据基础设施,帮助你更好地与“另一侧”的同事协作。 ### 事务处理与分析的特征 {#sec_introduction_oltp} @@ -128,7 +130,7 @@ breadcrumbs: false 一些数据库系统提供 **混合事务/分析处理**(HTAP),目标是在单个系统中同时支持 OLTP 和分析,而无需从一个系统 ETL 到另一个系统 [^8] [^9]。然而,许多 HTAP 系统内部由一个 OLTP 系统与一个单独的分析系统耦合组成,隐藏在公共接口后面——因此两者之间的区别对于理解这些系统如何工作仍然很重要。 -此外,尽管 HTAP 存在,但由于目标和要求不同,事务型系统和分析型系统之间的分离是常见的。特别是,让每个事务型系统拥有自己的数据库被认为是良好的做法(参见["微服务与 Serverless"](/ch1#sec_introduction_microservices)),这将导致数百个单独的事务型数据库;另一方面,企业通常有一个单一的数据仓库,以便业务分析师可以在单个查询中组合来自多个事务型系统的数据。 +此外,尽管 HTAP 已出现,但由于目标和约束不同,事务型系统与分析型系统分离仍很常见。尤其是,让每个事务型系统拥有自己的数据库通常被视为良好实践(参见["微服务与无服务器"](/ch1#sec_introduction_microservices)),这会形成数百个相互独立的事务型数据库;与之对应,企业往往只有一个统一的数据仓库,以便分析师能在单个查询里组合多个事务型系统的数据。 因此,HTAP 不会取代数据仓库。相反,它在同一应用程序既需要执行扫描大量行的分析查询,又需要以低延迟读取和更新单个记录的场景中很有用。例如,欺诈检测可能涉及此类工作负载 [^10]。 @@ -153,31 +155,31 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。 #### 超越数据湖 {#beyond-the-data-lake} -随着分析实践的成熟,组织越来越关注分析系统和数据管道的管理和运维,如在 DataOps 宣言中所描述的那样 [^18]。其中一部分是治理、隐私和遵守 GDPR 和 CCPA 等法规的问题,我们将在["数据系统、法律与社会"](/ch1#sec_introduction_compliance)和[待补充链接]中讨论。 +随着分析实践的成熟,组织越来越重视分析系统与数据管道的管理和运维,这一点在 DataOps 宣言中已有体现 [^18]。其中一部分是治理、隐私以及对 GDPR、CCPA 等法规的遵从;我们会在["数据系统、法律与社会"](/ch1#sec_introduction_compliance)和["立法与行业自律"](/ch14#sec_future_legislation)中讨论。 -此外,分析数据越来越多地不仅作为文件和关系表提供,还作为事件流(参见[待补充链接])。使用基于文件的数据分析,你可以定期(例如,每天)重新运行分析以响应数据的变化,但流处理允许分析系统以秒级的速度响应事件。根据应用程序及其时间敏感性,流处理方法可能很有价值,例如识别和阻止潜在的欺诈或滥用活动。 +此外,分析数据的提供形式也越来越多样:不仅有文件和关系表,也有事件流(见[第 12 章](/ch12#ch_stream))。基于文件的分析通常通过周期性重跑(例如每天一次)来响应数据变化,而流处理能够让分析系统在秒级响应事件。对于时效性要求高的场景,这种方式很有价值,例如识别并阻断潜在的欺诈或滥用行为。 -在某些情况下,分析系统的输出被提供给事务型系统(这个过程有时被称为 **反向 ETL** [^19])。例如,在分析系统中训练的机器学习模型可能会部署到生产环境中,以便为最终用户生成推荐,例如“购买了 X 的人也购买了 Y”。这种分析系统的部署输出也被称为 **数据产品** [^20]。机器学习模型可以使用 TFX、Kubeflow 或 MLflow 等专门工具部署到事务型系统。 +在某些场景中,分析系统的输出还会回流到事务型系统(这一过程有时称为 **反向 ETL** [^19])。例如,在分析系统里训练出的机器学习模型会部署到生产环境,为终端用户生成“买了 X 的人也买了 Y”这类推荐。此类分析系统的投产结果也称为 **数据产品** [^20]。机器学习模型可借助 TFX、Kubeflow、MLflow 等专用工具部署到事务型系统。 -### 权威数据源与派生数据 {#sec_introduction_derived} +### 记录系统与派生数据 {#sec_introduction_derived} -与事务型系统和分析型系统之间的区别相关,本书还区分了 **权威记录系统** 和 **派生数据系统**。这些术语很有用,因为它们可以帮助你澄清数据在系统中的流动: +与事务型系统和分析型系统的区分相关,本书还区分 **记录系统** 与 **派生数据系统**。这组术语有助于你理清数据在系统中的流向: 权威记录系统 -: 权威记录系统,也称为 **权威数据源**,保存某些数据的权威或 **规范** 版本。当新数据进入时,例如作为用户输入,它首先写入这里。每个事实只表示一次(表示通常是 **规范化** 的;参见["规范化、反规范化和连接"](/ch3#sec_datamodels_normalization))。如果另一个系统与权威记录系统之间存在任何差异,那么权威记录系统中的值(根据定义)是正确的。 +: 记录系统,也称 **真相来源(权威数据源)**,保存某类数据的权威(canonical)版本。新数据进入系统时(例如用户输入)首先写入这里。每个事实只表示一次(这种表示通常是 **规范化** 的;见["规范化、反规范化与连接"](/ch3#sec_datamodels_normalization))。如果其他系统与记录系统不一致,则按定义以记录系统为准。 派生数据系统 -: 派生系统中的数据是从另一个系统获取一些现有数据并以某种方式转换或处理它的结果。如果你丢失了派生数据,你可以从原始源重新创建它。一个经典的例子是缓存:如果存在,可以从缓存提供数据,但如果缓存不包含你需要的内容,你可以回退到底层数据库。反规范化值、索引、物化视图、转换的数据表示和在数据集上训练的模型也属于这一类别。 +: 派生系统中的数据,是对其他系统中已有数据进行转换或处理后的结果。如果派生数据丢失,可以从原始数据源重新构建。经典例子是缓存:命中时由缓存返回,未命中时回退到底层数据库。反规范化值、索引、物化视图、变换后的数据表示,以及在数据集上训练出的模型,都属于这一类。 -从技术上讲,派生数据是 **冗余** 的,因为它复制了现有信息。然而,它通常对于在读取查询上获得良好性能至关重要。你可以从单个源派生几个不同的数据集,使你能够从不同的"视角"查看数据。 +从技术上说,派生数据是 **冗余** 的,因为它复制了已有信息。但它往往是读查询高性能的关键。你可以从同一个源数据派生出多个数据集,以不同“视角”观察同一份事实。 -分析系统通常是派生数据系统,因为它们是在其他地方创建的数据的消费者。事务型服务可能包含权威记录系统和派生数据系统的混合。权威记录系统是数据首先被写入的主数据库,而派生数据系统是加速常见读取操作的索引和缓存,特别是对于权威记录系统无法有效回答的查询。 +分析系统通常属于派生数据系统,因为它消费的是别处产生的数据。事务型服务往往同时包含记录系统和派生数据系统:前者是数据首先写入的主数据库,后者则是用于加速常见读取操作的索引与缓存,尤其针对记录系统难以高效回答的查询。 -大多数数据库、存储引擎和查询语言并非从本质上就是权威记录系统或派生数据系统。数据库只是一个工具:如何使用它取决于你。权威记录系统和派生数据系统之间的区别不取决于工具,而取决于你如何在应用程序中使用它。通过明确哪些数据是从哪些其他数据派生的,你可以为让原本混乱的系统架构变得清晰。 +大多数数据库、存储引擎和查询语言本身并不天然属于“记录系统”或“派生系统”。数据库只是工具,关键在于你如何使用它。两者的区别不在工具本身,而在应用中的职责划分。只要明确“哪些数据由哪些数据派生而来”,原本混乱的系统架构就会清晰很多。 -当一个系统中的数据源自另一个系统中的数据时,你需要一个过程来在权威记录系统中的原始数据发生变化时更新派生数据。不幸的是,许多数据库的设计基于这样的假设:你的应用程序只需要使用那一个数据库,它们不易于集成多个系统以传播此类更新。在[待补充链接]中,我们将讨论 **数据集成** 的方法,这允许我们组合多个数据系统来实现单个系统无法做到的事情。 +当一个系统的数据由另一个系统的数据派生而来时,你需要在记录系统原始数据变化时同步更新派生数据。不幸的是,很多数据库默认假设应用只依赖单一数据库,并不擅长在多系统之间传播这类更新。在["数据集成"](/ch13#sec_future_integration)中,我们会讨论如何组合多个数据系统,实现单一系统难以独立完成的能力。 -这就结束了我们对分析和事务处理的比较。在下一节中,我们将研究另一个你可能已经看到多次争论的权衡。 +至此,我们结束了对分析与事务处理的比较。下一节将讨论另一组常被反复争论的权衡。 ## 云服务与自托管 {#sec_introduction_cloud} @@ -238,11 +240,10 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。 除了具有不同的经济模型(订阅服务而不是购买硬件和许可软件在其上运行)之外,云的兴起也对数据系统在技术层面的实现产生了深远的影响。 术语 **云原生** 用于描述旨在利用云服务的架构。 -原则上,几乎任何你可以自托管的软件也可以作为云服务提供,实际上,许多流行的数据系统现在都有托管服务。 -然而,从头开始设计为云原生的系统已被证明具有几个优势:在相同硬件上具有更好的性能、从故障中更快恢复、 -能够快速扩展计算资源以匹配负载,以及支持更大的数据集 [^25] [^26] [^27]。[表 1-2](/ch1#tab_cloud_native_dbs) 列出了两种类型系统的一些示例。 +原则上,几乎任何可自托管的软件都可以做成云服务;事实上,许多主流数据系统都已有托管版本。 +不过,从零设计为云原生的系统已经展示出若干优势:同等硬件下性能更好、故障恢复更快、能更快按负载扩缩计算资源,并支持更大数据集 [^25] [^26] [^27]。[表 1-2](/ch1#tab_cloud_native_dbs) 给出两类系统的一些示例。 -{{< figure id="#tab_cloud_native_dbs" title="表 1-2. 自托管和云原生数据库系统示例" class="w-full my-4" >}} +{{< figure id="tab_cloud_native_dbs" title="表 1-2. 自托管与云原生数据库系统示例" class="w-full my-4" >}} | 类别 | 自托管系统 | 云原生系统 | |------------------|----------------------------|----------------------------------------------------------------------| @@ -304,7 +305,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是这种方法的例子。 采用云服务可能比运行自己的基础设施更容易、更快,尽管学习如何使用它也有成本,也许还要解决其限制。随着越来越多的供应商提供针对不同用例的更广泛的云服务,不同服务之间的集成成为一个特别的挑战 [^39] [^40]。 -ETL(参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部分;事务型云服务也需要相互集成。目前,缺乏促进这种集成的标准,因此它通常涉及大量的手动工作。 +ETL(参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部分;面向事务处理的云服务之间也需要相互集成。目前,缺乏能促进这类集成的标准,因此往往仍要投入大量手工工作。 无法完全外包给云服务的其他运维方面包括维护应用程序及其使用的库的安全性、管理你自己的服务之间的交互、监控服务的负载,以及追踪问题的原因,例如性能下降或中断。虽然云正在改变运维的角色,但对运维的需求比以往任何时候都大。 @@ -358,11 +359,11 @@ ETL(参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部 出于所有这些原因,如果你可以在单台机器上做某件事情,与搭建分布式系统相比通常要简单得多,成本也更低 [^23] [^46] [^51]。CPU、内存和磁盘已经变得更大、更快、更可靠。当与 DuckDB、SQLite 和 KùzuDB 等单节点数据库结合使用时,许多工作负载现在可以在单个节点上运行。我们将在[第 4 章](/ch4#ch_storage)中进一步探讨这个主题。 -### 微服务与 Serverless {#sec_introduction_microservices} +### 微服务与无服务器 {#sec_introduction_microservices} 在多台机器上分布系统的最常见方式是将它们分为客户端和服务器,并让客户端向服务器发出请求。最常见的是使用 HTTP 进行此通信,正如我们将在["流经服务的数据流:REST 和 RPC"](/ch5#sec_encoding_dataflow_rpc)中讨论的。同一进程可能既是服务器(处理传入请求)又是客户端(向其他服务发出出站请求)。 -这种构建应用程序的方式传统上被称为 **面向服务架构**(SOA);最近,这个想法已经被细化为 **微服务** 架构 [^52] [^53]。在这种架构中,服务有一个明确定义的目的(例如,对于 S3 来说,这个目的是文件存储);每个服务公开一个可以由客户端通过网络调用的 API,每个服务有一个负责其维护的团队。因此,复杂的应用程序可以分解为多个交互服务,每个服务由单独的团队管理。 +这种构建应用程序的方式传统上被称为 **面向服务的体系结构**(SOA);最近,这个想法已经被细化为 **微服务** 架构 [^52] [^53]。在这种架构中,服务有一个明确定义的目的(例如,对于 S3 来说,这个目的是文件存储);每个服务公开一个可以由客户端通过网络调用的 API,每个服务有一个负责其维护的团队。因此,复杂的应用程序可以分解为多个交互服务,每个服务由单独的团队管理。 将复杂的软件分解为多个服务有几个优点:每个服务可以独立更新,减少团队之间的协调工作;每个服务可以分配它需要的硬件资源;通过将实现细节隐藏在 API 后面,服务所有者可以自由地更改实现而不影响客户端。在数据存储方面,每个服务通常有自己的数据库,而不在服务之间共享数据库:共享数据库实际上会使整个数据库结构成为服务 API 的一部分,然后该结构将很难更改。共享数据库还可能导致一个服务的查询对其他服务的性能产生负面影响。 @@ -372,9 +373,9 @@ ETL(参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部 微服务主要是人员问题的技术解决方案:允许不同的团队独立取得进展,而无需相互协调。这在大公司中很有价值,但在没有很多团队的小公司中,使用微服务可能是不必要的开销,最好以最简单的方式实现应用程序 [^52]。 -**Serverless** 或 **函数即服务**(FaaS)是部署服务的另一种方法,其中基础设施的管理外包给云供应商 [^33]。使用虚拟机时,你必须明确选择何时启动或关闭实例;相比之下,使用 serverless 模型,云提供商根据对你服务的传入请求自动分配和释放硬件资源 [^54]。Serverless 部署将更多的运营负担转移到云提供商,并通过使用量而不是机器实例实现灵活的计费。为了提供这些好处,许多 serverless 基础设施提供商对函数执行施加时间限制,限制运行时环境,并且在首次调用函数时可能会遭受缓慢的启动时间。术语“serverless”也可能具有误导性:每个 serverless 函数执行仍然在服务器上运行,但后续执行可能在不同的服务器上运行。此外,BigQuery 和各种 Kafka 产品等基础设施已经采用“serverless”术语来表示他们的服务自动扩展,并且他们按使用量而不是机器实例计费。 +**无服务器(Serverless)**,或 **函数即服务**(FaaS),是另一种部署方式:基础设施管理进一步外包给云厂商 [^33]。使用虚拟机时,你需要显式决定何时启动、何时关闭实例;而在无服务器模型中,云厂商会根据进入服务的请求自动分配和回收计算资源 [^54]。这种部署方式把更多运维负担转移给云厂商,并支持按使用量计费,而不是按实例计费。为实现这些优势,许多无服务器平台会限制函数执行时长、限制运行时环境,并在函数首次调用时出现较慢冷启动。术语“无服务器”本身也容易误导:每次函数执行依然运行在某台服务器上,只是后续执行未必在同一台机器上。此外,BigQuery 及多种 Kafka 产品也采用“Serverless”术语,强调其服务可自动扩缩容且按使用量计费。 -就像云存储用计量计费模型取代了容量规划(提前决定购买多少磁盘)一样,serverless 方法正在为代码执行带来计量计费:你只为应用程序代码实际运行的时间付费,而不必提前配置资源。 +就像云存储以计量计费取代了传统容量规划(预先决定买多少磁盘)一样,无服务器模式把同样的计费逻辑带到了代码执行层:你只为代码实际运行的时间付费,而不必预先准备固定资源。 ### 云计算与超级计算 {#id17} @@ -398,7 +399,7 @@ ETL(参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部 每个从事此类系统工作的人都有责任考虑道德影响并确保他们遵守相关法律。没有必要让每个人都成为法律和道德专家,但对法律和道德原则的基本认识与分布式系统中的一些基础知识同样重要。 -法律考虑正在影响数据系统设计的基础 [^61]。例如,GDPR 授予个人在请求时删除其数据的权利(有时称为 **被遗忘权**)。然而,正如我们将在本书中看到的,许多数据系统依赖不可变构造(如仅追加日志)作为其设计的一部分;我们如何确保删除应该不可变的文件中间的某些数据?我们如何处理已被纳入派生数据集(参见["权威数据源与派生数据"](/ch1#sec_introduction_derived))的数据删除,例如机器学习模型的训练数据?回答这些问题会带来新的工程挑战。 +法律考虑正在影响数据系统设计的基础 [^61]。例如,GDPR 授予个人在请求时删除其数据的权利(有时称为 **被遗忘权**)。然而,正如我们将在本书中看到的,许多数据系统依赖不可变构造(如仅追加日志)作为其设计的一部分;我们如何确保删除应该不可变的文件中间的某些数据?我们如何处理已被纳入派生数据集(参见["记录系统与派生数据"](/ch1#sec_introduction_derived))的数据删除,例如机器学习模型的训练数据?回答这些问题会带来新的工程挑战。 目前,我们对于哪些特定技术或系统架构应被视为“符合 GDPR”没有明确的指导方针。法规故意不强制要求特定技术,因为随着技术的进步,这些技术可能会迅速变化。相反,法律文本规定了需要解释的高层级原则。这意味着如何遵守隐私法规的问题没有简单的答案,但我们将通过这个视角来看待本书中的一些技术。 @@ -410,22 +411,22 @@ ETL(参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部 企业也注意到了隐私和安全问题。信用卡公司要求处理支付的企业遵守严格的支付卡行业(PCI)标准。处理商需要经常接受独立审计师的评估,以验证持续的合规性。软件供应商也受到了更多的审查。现在许多买家要求他们的供应商遵守服务组织控制(SOC)类型 2 标准。与 PCI 合规性一样,供应商需要接受第三方审计以验证遵守情况。 -一般来说,重要的是平衡你的业务需求与你收集和处理其数据的人的需求。这个话题还有很多内容;在[待补充链接]中,我们将更深入地探讨道德和法律合规性的主题,包括偏见和歧视的问题。 +总的来说,关键在于平衡业务目标与被收集、被处理数据的人们的权益。这个主题还有很多内容;在[第 14 章](/ch14#ch_right_thing)中,我们会进一步讨论伦理与法律合规,以及偏见与歧视等问题。 ## 总结 {#summary} -本章的主题是理解权衡:也就是说要认识到对于许多问题并没有一个正确的答案,而是有几种不同的方法,每种方法都有各种利弊。我们探讨了影响数据系统架构的一些最重要的选择,并介绍了本书其余部分所需的术语。 +本章的主线是理解“权衡”。对许多问题而言,并不存在唯一正确答案,而是有多种路径,各有利弊。我们讨论了影响数据系统架构的几个关键选择,并引入了后续章节会反复使用的术语。 -我们首先区分了事务型(事务处理,OLTP)和分析型(OLAP)系统,并看到了它们的不同特征:不仅通过不同的访问模式管理不同类型的数据,而且服务于不同的受众。我们遇到了数据仓库和数据湖的概念,它们通过 ETL 从事务型系统接收数据。在[第 4 章](/ch4#ch_storage)中,我们将看到事务型和分析型系统通常使用非常不同的内部数据布局,因为它们需要服务的查询类型不同。 +我们首先区分了事务型(事务处理,OLTP)和分析型(OLAP)系统。它们不仅面对不同访问模式与数据类型,也服务于不同人群。我们还看到数据仓库与数据湖这两类体系,它们通过 ETL 接收来自事务型系统的数据。在[第 4 章](/ch4#ch_storage)中,我们会看到由于查询类型不同,事务型与分析型系统常常采用截然不同的内部数据布局。 -然后,我们将云服务(一个相对较新的发展)与传统的自托管软件范式进行了比较,后者以前主导了数据系统架构。这些方法中哪一种更具成本效益在很大程度上取决于你的特定情况,但不可否认的是,云原生方法正在为数据系统的架构带来重大变化,例如它们分离存储和计算的方式。 +随后,我们把相对较新的云服务模式与长期主导数据系统架构的自托管范式做了比较。哪种方式更具成本效益高度依赖具体情境,但不可否认,云原生架构正在深刻改变数据系统的构建方式,例如存储与计算的分离。 -云系统本质上是分布式的,我们简要地研究了分布式系统与使用单台机器相比的一些权衡。有些情况下你无法避免分布式,但如果可能在单台机器上运行系统,建议不要急于使系统分布式化。在[第 9 章](/ch9#ch_distributed)中,我们将更详细地介绍分布式系统的挑战。 +云系统天然是分布式系统,我们也简要讨论了它与单机方案之间的权衡。有些场景无法避免分布式,但如果单机可行,不必急于把系统分布式化。在[第 9 章](/ch9#ch_distributed)中,我们会更深入地讨论分布式系统的挑战。 -最后,我们看到数据系统架构不仅由部署系统的企业的需求决定,还由保护其数据被处理的人的权利的隐私法规决定——这是许多工程师容易忽视的一个方面。我们如何将法律要求转化为技术实现还没有非常清晰的答案,但在我们阅读本书的其余部分时,记住这个问题很重要。 +最后,数据系统架构不仅由企业自身需求决定,也受保护数据主体权利的隐私法规所塑造,而这一点常被工程实践忽略。如何把法律要求转化为技术实现,目前仍无标准答案;但在阅读本书后续内容时,始终带着这个问题会很重要。 -### 参考 +### 参考文献 [^1]: Richard T. Kouzes, Gordon A. Anderson, Stephen T. Elbert, Ian Gorton, and Deborah K. Gracio. [The Changing Paradigm of Data-Intensive Computing](http://www2.ic.uff.br/~boeres/slides_AP/papers/TheChanginParadigmDataIntensiveComputing_2009.pdf). *IEEE Computer*, volume 42, issue 1, January 2009. [doi:10.1109/MC.2009.26](https://doi.org/10.1109/MC.2009.26) [^2]: Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. [Local-first software: you own your data, in spite of the cloud](https://www.inkandswitch.com/local-first/). At *2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software* (Onward!), October 2019. [doi:10.1145/3359591.3359737](https://doi.org/10.1145/3359591.3359737) @@ -490,4 +491,3 @@ ETL(参见["数据仓库"](/ch1#sec_introduction_dwh))只是故事的一部 [^61]: Supreeth Shastri, Vinay Banakar, Melissa Wasserman, Arun Kumar, and Vijay Chidambaram. [Understanding and Benchmarking the Impact of GDPR on Database Systems](https://www.vldb.org/pvldb/vol13/p1064-shastri.pdf). *Proceedings of the VLDB Endowment*, volume 13, issue 7, pages 1064–1077, March 2020. [doi:10.14778/3384345.3384354](https://doi.org/10.14778/3384345.3384354) [^62]: Martin Fowler. [Datensparsamkeit](https://www.martinfowler.com/bliki/Datensparsamkeit.html). *martinfowler.com*, December 2013. Archived at [perma.cc/R9QX-CME6](https://perma.cc/R9QX-CME6) [^63]: [Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 (General Data Protection Regulation)](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679&from=EN). *Official Journal of the European Union* L 119/1, May 2016. - diff --git a/content/zh/ch10.md b/content/zh/ch10.md index 462a566..5efa89e 100644 --- a/content/zh/ch10.md +++ b/content/zh/ch10.md @@ -4,6 +4,8 @@ weight: 210 breadcrumbs: false --- + + ![](/map/ch09.png) > *一句古老的格言告诫说:"千万不要带着两块计时器出海;要么带一块,要么带三块。"* @@ -42,7 +44,7 @@ breadcrumbs: false 这就是 *线性一致性* [^1] 背后的想法(也称为 *原子一致性* [^2]、*强一致性*、*即时一致性* 或 *外部一致性* [^3])。线性一致性的确切定义相当微妙,我们将在本节的其余部分探讨它。但基本思想是让系统看起来好像只有一份数据副本,并且对它的所有操作都是原子的。有了这个保证,即使实际上可能有多个副本,应用程序也不需要担心它们。 -在线性一致系统中,一旦一个客户端成功完成写入,所有从数据库读取的客户端都必须能够看到刚刚写入的值。维护单一数据副本的假象意味着保证读取的值是最新的、最新的值,而不是来自过时的缓存或副本。换句话说,线性一致性是一个 *新鲜度保证*。为了阐明这个想法,让我们看一个非线性一致系统的例子。 +在线性一致系统中,一旦一个客户端成功完成写入,所有从数据库读取的客户端都必须能够看到刚刚写入的值。维护单一数据副本的假象,意味着要保证读取到的是最新值,而不是来自过时的缓存或副本。换句话说,线性一致性是一种 *新鲜度保证*。为了阐明这个想法,让我们看一个非线性一致系统的例子。 {{< figure src="/fig/ddia_1001.png" id="fig_consistency_linearizability_0" caption="图 10-1. 如果这个数据库是线性一致的,那么 Alice 的读取要么返回 1 而不是 0,要么 Bob 的读取返回 0 而不是 1。" class="w-full my-4" >}} @@ -152,7 +154,7 @@ breadcrumbs: false 如果你想确保银行账户余额永远不会变为负数,或者你不会销售超过仓库库存的物品,或者两个人不会同时预订同一航班或剧院的同一座位,也会出现类似的问题。这些约束都要求有一个所有节点都同意的单一最新值(账户余额、库存水平、座位占用情况)。 -在实际应用中,有时可以接受宽松地对待这些约束(例如,如果航班超售,你可以将客户转移到其他航班,并为不便提供补偿)。在这种情况下,可能不需要线性一致性,我们将在 [Link to Come] 中讨论这种宽松解释的约束。 +在实际应用中,有时可以接受宽松地对待这些约束(例如,如果航班超售,你可以将客户转移到其他航班,并为不便提供补偿)。在这种情况下,可能不需要线性一致性,我们将在 ["时效性与完整性"](/ch13#sec_future_integrity) 中讨论这种宽松解释的约束。 然而,硬唯一性约束,例如你通常在关系数据库中找到的约束,需要线性一致性。其他类型的约束,例如外键或属性约束,可以在没有线性一致性的情况下实现 [^20]。 @@ -162,7 +164,7 @@ breadcrumbs: false 类似的情况可能出现在计算机系统中。例如,假设你有一个网站,用户可以上传视频,后台进程将视频转码为较低质量,以便在慢速互联网连接上流式传输。该系统的架构和数据流如 [图 10-5](/ch10#fig_consistency_transcoder) 所示。 -视频转码器需要明确指示执行转码作业,此指令通过消息队列从 Web 服务器发送到转码器(见 [Link to Come])。Web 服务器不会将整个视频放在队列中,因为大多数消息代理都是为小消息设计的,而视频可能有许多兆字节大小。相反,视频首先写入文件存储服务,写入完成后,转码指令被放入队列。 +视频转码器需要明确指示执行转码作业,此指令通过消息队列从 Web 服务器发送到转码器(见 ["消息传递系统"](/ch12#sec_stream_messaging))。Web 服务器不会将整个视频放在队列中,因为大多数消息代理都是为小消息设计的,而视频可能有许多兆字节大小。相反,视频首先写入文件存储服务,写入完成后,转码指令被放入队列。 {{< figure src="/fig/ddia_1005.png" id="fig_consistency_transcoder" caption="图 10-5. 一个非线性一致的系统:Alice 和 Bob 在不同时间看到上传的图像,因此 Bob 的请求基于过时的数据。" class="w-full my-4" >}} @@ -185,7 +187,7 @@ breadcrumbs: false 让我们重新审视 [第六章](/ch6) 中的复制方法,并比较它们是否可以实现线性一致: 单主复制(可能线性一致) -: 在单主复制系统中,主节点拥有用于写入的数据主副本,从节点在其他节点上维护数据的备份副本。只要你在主节点上执行所有读写操作,它们很可能是线性一致的。然而,这假设你确定知道谁是主节点。如 ["分布式锁和租约"](/ch9#sec_distributed_lock_fencing) 中所讨论的,一个节点很可能认为自己是主节点,而实际上并不是——如果这个妄想的主节点继续服务请求,很可能会违反线性一致性 [^21]。使用异步复制,故障切换甚至可能丢失已提交的写入,这违反了持久性和线性一致性。 +: 在单主复制系统中,主节点拥有用于写入的数据主副本,备库在其他节点上维护数据副本。只要你在主节点上执行所有读写操作,它们很可能是线性一致的。然而,这假设你确定知道谁是主节点。如 ["分布式锁和租约"](/ch9#sec_distributed_lock_fencing) 中所讨论的,一个节点很可能认为自己是主节点,而实际上并不是。如果这个“妄想中的主节点”继续处理请求,很可能会违反线性一致性 [^21]。使用异步复制时,故障切换甚至可能丢失已提交的写入,这违反了持久性和线性一致性。 对单主数据库进行分片,每个分片有一个单独的主节点,不会影响线性一致性,因为它只是单对象保证。跨分片事务是另一回事(见 ["分布式事务"](/ch8#sec_transactions_distributed))。 @@ -230,11 +232,11 @@ breadcrumbs: false 使用多主数据库,每个区域可以继续正常运行:由于来自一个区域的写入被异步复制到另一个区域,写入只是排队并在网络连接恢复时交换。 -另一方面,如果使用单主复制,那么主节点必须在其中一个区域。任何写入和任何线性一致的读取都必须发送到主节点——因此,对于连接到从节点区域的任何客户端,这些读写请求必须通过网络同步发送到主节点区域。 +另一方面,如果使用单主复制,那么主节点必须在其中一个区域。任何写入和任何线性一致的读取都必须发送到主节点。因此,对于连接到备库所在区域的任何客户端,这些读写请求都必须通过网络同步发送到主节点区域。 -如果在单主设置中区域之间的网络中断,连接到从节点区域的客户端无法联系主节点,因此它们既不能对数据库进行任何写入,也不能进行任何线性一致的读取。它们仍然可以从从节点读取,但它们可能是过时的(非线性一致)。如果应用程序需要线性一致的读写,网络中断会导致应用程序在无法联系主节点的区域中变得不可用。 +如果在单主设置中区域之间的网络中断,连接到备库区域的客户端无法联系主节点,因此它们既不能对数据库进行任何写入,也不能进行任何线性一致的读取。它们仍然可以从备库读取,但这些读取可能是过时的(非线性一致)。如果应用程序需要线性一致的读写,网络中断会导致应用程序在无法联系主节点的区域中变得不可用。 -如果客户端可以直接连接到主节点区域,这不是问题,因为应用程序在那里继续正常工作。但只能访问从节点区域的客户端将在网络链接修复之前遇到中断。 +如果客户端可以直接连接到主节点区域,这不是问题,因为应用程序在那里继续正常工作。但只能访问备库区域的客户端将在网络链路修复之前遇到中断。 #### CAP 定理 {#the-cap-theorem} @@ -273,7 +275,7 @@ CP/AP 分类方案还有几个进一步的缺陷 [^4]。*一致性* 被形式化 许多选择不提供线性一致保证的分布式数据库也是如此:它们这样做主要是为了提高性能,而不是为了容错 [^42]。线性一致性很慢——这在任何时候都是真的,不仅在网络故障期间。 -我们能否找到更高效的线性一致存储实现?答案似乎是否定的:Attiya 和 Welch [^49] 证明,如果你想要线性一致性,读写请求的响应时间至少与网络中延迟的不确定性成正比。在具有高度可变延迟的网络中,例如大多数计算机网络(见 ["超时和无界延迟"](/ch9#sec_distributed_queueing)),线性一致读写的响应时间不可避免地会很高。更快的线性一致性算法不存在,但较弱的一致性模型可能会快得多,因此这种权衡对于延迟敏感的系统很重要。在 [Link to Come] 中,我们将讨论一些在不牺牲正确性的情况下避免线性一致性的方法。 +我们能否找到更高效的线性一致存储实现?答案似乎是否定的:Attiya 和 Welch [^49] 证明,如果你想要线性一致性,读写请求的响应时间至少与网络中延迟的不确定性成正比。在具有高度可变延迟的网络中,例如大多数计算机网络(见 ["超时和无界延迟"](/ch9#sec_distributed_queueing)),线性一致读写的响应时间不可避免地会很高。更快的线性一致性算法不存在,但较弱的一致性模型可能会快得多,因此这种权衡对于延迟敏感的系统很重要。在 ["时效性与完整性"](/ch13#sec_future_integrity) 中,我们将讨论一些在不牺牲正确性的情况下避免线性一致性的方法。 ## ID 生成器和逻辑时钟 {#sec_consistency_logical} @@ -323,7 +325,7 @@ CP/AP 分类方案还有几个进一步的缺陷 [^4]。*一致性* 被形式化 * 其时间戳紧凑(大小为几个字节)且唯一; * 你可以比较任意两个时间戳(即它们是 *全序* 的);并且 -* 时间戳的顺序与因果关系 *一致*:如果操作 A 发生在 B 之前,那么 A 的时间戳小于 B 的时间戳。(我们之前在 [""先发生"关系和并发"](/ch6#sec_replication_happens_before) 中讨论了因果关系。) +* 时间戳的顺序与因果关系 *一致*:如果操作 A 发生在 B 之前,那么 A 的时间戳小于 B 的时间戳。(我们之前在 ["“先发生”关系与并发"](/ch6#sec_replication_happens_before) 中讨论了因果关系。) 单节点 ID 生成器满足这些要求,但我们刚刚讨论的分布式 ID 生成器不满足因果排序要求。 @@ -384,7 +386,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 确保 ID 分配线性一致的最简单方法实际上是为此目的使用单个节点。该节点只需要原子地递增计数器并在请求时返回其值,持久化计数器值(以便在节点崩溃并重新启动时不会生成重复的 ID),并使用单主复制进行容错复制。这种方法在实践中使用:例如,TiDB/TiKV 称之为 *时间戳预言机*,受 Google 的 Percolator [^57] 启发。 -作为优化,你可以避免在每个请求上执行磁盘写入和复制。相反,ID 生成器可以写入描述一批 ID 的记录;一旦该记录被持久化和复制,节点就可以开始按顺序向客户端分发这些 ID。在它用完该批次中的 ID 之前,它可以为下一批持久化和复制记录。这样,如果节点崩溃并重新启动或你故障转移到从节点,某些 ID 将被跳过,但你不会发出任何重复或乱序的 ID。 +作为优化,你可以避免在每个请求上执行磁盘写入和复制。相反,ID 生成器可以写入描述一批 ID 的记录;一旦该记录被持久化并完成复制,节点就可以开始按顺序向客户端分发这些 ID。在它用完该批次中的 ID 之前,它可以为下一批持久化并复制记录。这样,如果节点崩溃并重启,或故障切换到备库,某些 ID 会被跳过,但不会发出任何重复或乱序的 ID。 你不能轻易地对 ID 生成器进行分片,因为如果你有多个分片独立分发 ID,你就无法再保证它们的顺序是线性一致的。你也不能轻易地将 ID 生成器分布在多个区域;因此,在地理分布式数据库中,所有 ID 请求都必须转到单个区域的节点。从好的方面来说,ID 生成器的工作非常简单,因此单个节点可以处理大量请求吞吐量。 @@ -489,7 +491,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: #### 共享日志作为共识 {#sec_consistency_shared_logs} -我们已经看到了几个日志的例子,例如复制日志、事务日志和预写日志。日志存储一系列 *日志条目*,任何读取它的人都会看到相同顺序的相同条目。有时日志有一个允许追加新条目的单个写入者,但 *共享日志* 是多个节点可以请求追加条目的日志。单主复制的一个例子:任何客户端都可以要求主节点进行写入,主节点将其追加到复制日志,然后所有从节点按照与主节点相同的顺序应用写入。 +我们已经看到了几个日志的例子,例如复制日志、事务日志和预写日志。日志存储一系列 *日志条目*,任何读取它的人都会看到相同顺序的相同条目。有时日志有一个允许追加新条目的单个写入者,但 *共享日志* 是多个节点可以请求追加条目的日志。单主复制就是一个例子:任何客户端都可以要求主节点进行写入,主节点将其追加到复制日志,然后所有备库按照与主节点相同的顺序应用写入。 更正式地说,共享日志支持两种操作:你可以请求将值添加到日志中,并且可以读取日志中的条目。它必须满足以下属性: @@ -577,7 +579,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: #### 使用共享日志 {#sec_consistency_smr} -共享日志非常适合数据库复制:如果每个日志条目代表对数据库的写入,并且每个副本使用确定性逻辑以相同的顺序处理相同的写入,那么副本将全部处于一致状态。这个想法被称为 *状态机复制* [^80],它是事件溯源背后的原则,我们在 ["事件溯源和 CQRS"](/ch3#sec_datamodels_events) 中看到了。共享日志对于流处理也很有用,我们将在 [Link to Come] 中看到。 +共享日志非常适合数据库复制:如果每个日志条目代表对数据库的写入,并且每个副本使用确定性逻辑以相同的顺序处理相同的写入,那么副本将全部处于一致状态。这个想法被称为 *状态机复制* [^80],它是事件溯源背后的原则,我们在 ["事件溯源和 CQRS"](/ch3#sec_datamodels_events) 中看到了。共享日志对于流处理也很有用,我们将在 [第十二章](/ch12#ch_stream) 中看到。 同样,共享日志可用于实现可串行化事务:如 ["实际串行执行"](/ch8#sec_transactions_serial) 中所讨论的,如果每个日志条目代表要作为存储过程执行的确定性事务,并且如果每个节点以相同的顺序执行这些事务,那么事务将是可串行化的 [^81] [^82]。 @@ -606,7 +608,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 当节点因为在某个超时时间内没有收到主节点的消息而认为当前主节点已死时,它可能会开始投票选举新的主节点。这次选举被赋予一个大于任何先前纪元的新纪元编号。如果两个不同纪元中的两个不同主节点之间存在冲突(也许是因为先前的主节点实际上并没有死),那么具有更高纪元编号的主节点获胜。 -在主节点被允许将下一个条目追加到共享日志之前,它必须首先检查是否有其他具有更高纪元编号的主节点可能追加不同的条目。它可以通过从节点仲裁收集投票来做到这一点——通常但不总是大多数节点 [^85]。只有在节点不知道任何其他具有更高纪元的主节点时,节点才会投赞成票。 +在主节点被允许将下一个条目追加到共享日志之前,它必须首先检查是否有其他具有更高纪元编号的主节点可能追加不同的条目。它可以通过从一个节点仲裁收集投票来做到这一点,通常(但并非总是)是多数节点 [^85]。只有在节点不知道任何其他具有更高纪元的主节点时,节点才会投赞成票。 因此,我们有两轮投票:一次选择主节点,第二次对主节点提议的下一个要追加到日志的条目进行投票。这两次投票的仲裁必须重叠:如果对提议的投票成功,投票支持它的节点中至少有一个也必须参与了最近成功的主节点选举 [^85]。因此,如果对提议的投票通过而没有透露任何更高编号的纪元,当前主节点可以得出结论,没有选出具有更高纪元编号的主节点,因此它可以安全地将提议的条目追加到日志中 [^26] [^86]。 @@ -625,7 +627,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 如果你希望共识算法严格保证 ["共享日志作为共识"](/ch10#sec_consistency_shared_logs) 中列出的属性,那么新主节点在处理任何写入或线性一致读取之前必须了解任何已确认的日志条目,这一点至关重要。如果具有过时数据的节点成为新主节点,它可能会将新值写入已经由旧主节点写入的日志条目,从而违反共享日志的仅追加属性。 -在某些情况下,你可能选择削弱共识属性,以便更快地从主节点故障中恢复。例如,Kafka 提供了启用 *不干净的主节点选举* 的选项,它允许任何副本成为主节点,即使它不是最新的。此外,在具有异步复制的数据库中,当主节点失败时,你无法保证任何从节点是最新的。 +在某些情况下,你可能选择削弱共识属性,以便更快地从主节点故障中恢复。例如,Kafka 提供了启用 *不干净的主节点选举* 的选项,它允许任何副本成为主节点,即使它不是最新的。此外,在采用异步复制的数据库中,当主节点失败时,你无法保证任何备库是最新的。 如果你放弃新主节点必须是最新的要求,你可能会提高性能和可用性,但你是在薄冰上,因为共识理论不再适用。虽然只要没有故障,事情就会正常工作,但 [第九章](/ch9) 中讨论的问题很容易导致大量数据丢失或损坏。 @@ -649,7 +651,62 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 有时,共识算法对网络问题特别敏感。例如,Raft 已被证明具有不愉快的边缘情况 [^88] [^89]:如果除了一个始终不可靠的特定网络链接之外,整个网络都正常工作,Raft 可能会进入主节点身份在两个节点之间不断跳跃的情况,或者当前主节点不断被迫辞职,因此系统实际上从未取得进展。设计对不可靠网络更稳健的算法仍然是一个开放的研究问题。 -对于想要高可用但不想接受共识成本的系统,唯一真正的选择是使用较弱的一致性模型,例如 [第六章](/ch6) 中讨论的无主或多主复制提供的模型。这些方法通常不提供线性一致性,但对于不需要它的应用程序来说这很好。 +对于想要高可用但不想接受共识成本的系统,唯一真正的选择是使用较弱的一致性模型,例如 [第六章](/ch6) 中讨论的无主或多主复制提供的模型。这些方法通常不提供线性一致性,但对于不需要它的应用程序来说已经足够。 + + +### 协调服务 {#sec_consistency_coordination} + +共识算法对于任何希望提供线性一致操作的分布式数据库都很有价值,许多现代分布式数据库也都用共识来做复制。但有一类系统是共识算法的重度用户:*协调服务*,例如 ZooKeeper、etcd 和 Consul。虽然它们表面上看起来像普通键值存储,但它们并不是为通用数据存储而设计的。 + +相反,它们的目标是协调另一个分布式系统中的多个节点。例如,Kubernetes 依赖 etcd;Spark 和 Flink 在高可用模式下会在后台依赖 ZooKeeper。协调服务通常只存储小规模数据,这些数据可以完全放入内存(同时仍会写盘以保证持久性),并通过容错共识算法在多个节点间复制。 + +协调服务的设计思路来自 Google 的 Chubby 锁服务 [^17] [^58]。它把共识算法与一些在分布式系统里尤其有用的能力结合在一起: + +锁与租约 +: 我们前面看到,共识系统可以实现具备容错能力的原子比较并设置(CAS)操作。协调服务正是基于这一点来实现锁和租约:若多个节点并发尝试获取同一个租约,最终只会有一个成功。 + +支持栅栏 +: 如 ["分布式锁和租约"](/ch9#sec_distributed_lock_fencing) 所述,当某个资源受租约保护时,需要 *栅栏* 机制来防止进程暂停或网络大延迟时的相互干扰。共识系统可通过为每个日志条目分配单调递增 ID 来生成栅栏令牌(ZooKeeper 中的 `zxid` 和 `cversion`,etcd 中的 revision)。 + +故障检测 +: 客户端会在协调服务上维持长连接会话,并通过周期性心跳检查对端是否存活。即使连接临时中断或某台服务端故障,客户端持有的租约仍可保持有效;但如果超过租约超时时间仍未收到心跳,协调服务就会认为客户端已失效并释放租约(ZooKeeper 将其称为 *临时节点*)。 + +变更通知 +: 客户端可以请求:当某些键发生变化时由协调服务主动通知。这样客户端就能知道另一个节点何时加入集群(基于其写入的值),或者何时失效(会话超时、临时节点消失)。这类通知避免了客户端频繁轮询。 + +故障检测和变更通知本身不需要共识,但与需要共识的原子操作、栅栏机制结合后,它们对分布式协调非常有用。 + +-------- + +> [!TIP] 用协调服务管理配置 + +应用与基础设施通常都有配置参数,例如超时时间、线程池大小等。有时会把这类配置数据以键值对形式存放在协调服务中。进程启动时加载最新配置,并订阅后续变更通知。配置更新后,进程可以立即应用新值,或重启后生效。 + +配置管理本身不需要协调服务里的共识能力;但如果系统本来就已经运行了协调服务,那么直接复用它的通知机制会很方便。另一种做法是进程周期性地从文件或 URL 拉取配置更新,以避免依赖专门的协调服务。 + +-------- + +#### 将工作分配给节点 {#allocating-work-to-nodes} + +当你有某个进程或服务的多个实例,且其中一个需要被选为主节点时,协调服务很有用。如果主节点失效,其他节点之一应当接管。这不仅适用于单主数据库,也适用于作业调度器等有状态系统。 + +另一个场景是:你有某种分片资源(数据库、消息流、文件存储、分布式 Actor 系统等),需要决定每个分片由哪个节点负责。随着新节点加入集群,需要把部分分片从旧节点迁移到新节点以实现再平衡;当节点被移除或失效时,其他节点需要接手其工作。 + +这类任务可以通过协调服务中的原子操作、临时节点和通知机制配合完成。若实现得当,应用可以在无人值守的情况下自动从故障中恢复。即使有 Apache Curator 这类在 ZooKeeper 客户端 API 上封装的高级库,这件事仍不容易;但它仍远好于从零实现共识算法,后者极易引入缺陷。 + +专用协调服务还有一个优势:无论被协调系统有多少节点,协调服务本身通常都只需运行在一组固定节点上(常见是 3 个或 5 个)。例如,一个拥有数千分片的存储系统若在数千节点上直接跑共识会非常低效;把共识“外包”给少量协调服务节点通常更合理。 + +通常,协调服务管理的数据变化频率不高:例如“IP 为 10.1.1.23 的节点当前是分片 7 的主节点”这类信息,更新周期往往是分钟级或小时级。协调服务不适合存储每秒变化数千次的数据。对于高频变化数据,应该使用常规数据库;或者使用 Apache BookKeeper [^90] [^91] 这类工具复制服务内部的快速变化状态。 + +#### 服务发现 {#service-discovery} + +ZooKeeper、etcd 和 Consul 也常用于 *服务发现*:即确定连接某个服务所需的 IP 地址(见 ["负载均衡、服务发现和服务网格"](/ch5#sec_encoding_service_discovery))。在云环境下,虚拟机常常频繁上下线,因此你通常无法预先知道服务地址。常见做法是让服务启动时把自身网络端点注册到服务注册表,再供其他服务查询。 + +用协调服务做服务发现很方便,因为它的故障检测和变更通知能让客户端及时跟踪服务实例的增减。而且如果你本来就用协调服务做租约、锁或主节点选举,那么继续复用它做服务发现通常也很自然,因为它已经知道哪个节点应该接收请求。 + +不过,对服务发现使用共识往往有些“杀鸡用牛刀”:这个场景通常不要求线性一致性,更重要的是高可用和低延迟,因为没有服务发现,整个系统都会停滞。因此通常更倾向于缓存服务发现结果,并接受其可能略有陈旧。比如基于 DNS 的服务发现,就是通过多层缓存来获得良好的性能与可用性。 + +为支持这类需求,ZooKeeper 提供了 *observer*(观察者)节点:它接收日志并维护一份 ZooKeeper 数据副本,但不参与共识投票。来自 observer 的读取不具备线性一致性(可能陈旧),但即使网络中断仍然可用,并且能通过缓存提高系统可支持的读吞吐量。 ## 总结 {#summary} @@ -676,20 +733,20 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 原子事务提交 : 参与分布式事务的数据库节点必须都以相同的方式 **决定** 是提交还是中止事务。 -线性一致的 fetch-and-add 操作 -: 这个操作可以用来实现 ID 生成器。多个节点可以并发地调用该操作,它 **决定** 它们递增计数器的顺序。这种情况实际上只解决了两个节点之间的共识,而其他的适用于任意数量的节点。 +线性一致的获取并增加操作 +: 这个操作可以用来实现 ID 生成器。多个节点可以并发调用该操作,它 **决定** 它们递增计数器的顺序。这种情况实际上只解决了两个节点之间的共识,而其他情况适用于任意数量的节点。 -如果你只有一个节点,或者如果你愿意将决策能力分配给单个节点,所有这些都是简单的。这就是单领导者数据库中发生的事情:所有的决策权都授予了领导者,这就是为什么这样的数据库能够提供线性一致的操作、唯一性约束、复制日志等等。 +如果你只有一个节点,或者愿意把决策能力交给单个节点,所有这些都很简单。这就是单主数据库中发生的事情:所有决策权都授予主节点,这也是这类数据库能够提供线性一致操作、唯一性约束和复制日志等能力的原因。 -然而,如果那个单一的领导者失败,或者如果网络中断使领导者无法访问,这样的系统就无法取得任何进展,直到人工执行手动故障转移。广泛使用的共识算法如 Raft 和 Paxos 本质上是带有内置自动领导者选举和故障转移的单领导者复制(如果当前领导者失败)。 +然而,如果这个单一主节点失效,或者网络中断使其不可达,这样的系统就无法继续推进,直到人工完成手动故障切换。Raft 和 Paxos 等广泛使用的共识算法,本质上就是内置自动主节点选举与故障切换的“单主复制”。 共识算法经过精心设计,以确保在故障转移期间不会丢失任何已提交的写入,并且系统不会进入脑裂状态(多个节点接受写入)。这要求每个写入和每个线性一致的读取都由节点的仲裁(通常是多数)确认。这可能是昂贵的,特别是跨地理区域,但如果你想要共识提供的强一致性和容错性,这是不可避免的。 像 ZooKeeper 和 etcd 这样的协调服务也是建立在共识算法之上的。它们提供锁、租约、故障检测和变更通知功能,这些功能对于管理分布式应用程序的状态很有用。如果你发现自己想要做那些可以归约为共识的事情之一,并且你希望它是容错的,建议使用协调服务。它不会保证你做对,但它可能会有所帮助。 -共识算法是复杂而微妙的,但它们得到了自 1980 年代以来发展起来的丰富理论体系的支持。这个理论使得构建能够容忍我们在[第 9 章](/ch9#ch_distributed)中讨论的所有故障的系统成为可能,同时仍然确保你的数据不会损坏。这是一个了不起的成就,本章末尾的参考文献展示了这项工作的一些亮点。 +共识算法复杂而微妙,但其背后有自 1980 年代以来形成的丰富理论体系支持。正是这些理论,使我们能够构建出能够容忍 [第九章](/ch9#ch_distributed) 所述故障、同时仍保证数据不被破坏的系统。这是分布式系统工程中的重要成就,本章末尾参考文献展示了其中一些关键工作。 -然而,共识并不总是正确的工具:在某些系统中,不需要它提供的强一致性属性,使用较弱的一致性以获得更高的可用性和更好的性能会更好。在这些情况下,通常使用无领导者或多领导者复制,这是我们之前在[第 6 章](/ch6#ch_replication)中讨论过的。我们在本章中讨论的逻辑时钟在那种情况下是有帮助的。 +然而,共识并不总是正确的工具:在某些系统中,不需要它提供的强一致性属性,使用较弱一致性来换取更高可用性和更好性能反而更合适。在这些场景下,通常会使用无主或多主复制,这也是我们之前在 [第六章](/ch6#ch_replication) 讨论过的内容。我们在本章讨论的逻辑时钟在那类场景中也很有帮助。 ### 参考文献 diff --git a/content/zh/ch11.md b/content/zh/ch11.md index 5fb9d73..ec6725d 100644 --- a/content/zh/ch11.md +++ b/content/zh/ch11.md @@ -19,7 +19,7 @@ breadcrumbs: false 但有时候,你需要执行的计算比一次交互式请求大得多,或者要处理的数据量远超单次请求能承载的范围。例如训练 AI 模型、把海量数据从一种形式转换成另一种形式、或者在超大数据集上做分析计算。我们把这类任务称为 *批处理(batch processing)* 作业,有时也称为 *离线系统(offline systems)*。 -批处理作业读取一批输入数据(只读),并生成一批输出数据(每次运行都从头生成)。它通常不会像读写事务那样原地修改数据。因此,输出是由输入推导出的 *衍生数据(derived data)*(见[“记录系统与衍生数据”](/ch1#sec_introduction_derived)):如果不满意输出,你可以直接删除它,修改作业逻辑,再跑一遍即可。把输入视为不可变并尽量避免副作用(例如直接写外部数据库),不仅有助于性能,也带来其他好处: +批处理作业读取一批输入数据(只读),并生成一批输出数据(每次运行都从头生成)。它通常不会像读写事务那样原地修改数据。因此,输出是由输入推导出的 *派生数据(derived data)*(见[“记录系统与派生数据”](/ch1#sec_introduction_derived)):如果不满意输出,你可以直接删除它,修改作业逻辑,再跑一遍即可。把输入视为不可变并尽量避免副作用(例如直接写外部数据库),不仅有助于性能,也带来其他好处: - 如果你在代码中引入了 bug 导致输出错误或损坏,可以直接回滚代码并重跑作业,输出就会恢复正确。更简单的做法是把旧输出保留在另一个目录,直接切回旧版本。多数对象存储与开放表格式(见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))都支持这种能力,通常称为 *时间旅行(time travel)*。大多数支持读写事务的数据库不具备这种特性:如果错误代码把坏数据写进数据库,仅回滚代码并不能修复已写入的数据。能够从错误代码中恢复的能力被称为 *容忍人为失误* [^1]。 @@ -85,11 +85,13 @@ cat /var/log/nginx/access.log | #1 输出大致如下: +``` 4189 /favicon.ico 3631 /2016/02/08/how-to-do-distributed-locking.html 2124 /2020/11/18/distributed-systems-and-elliptic-curves.html 1369 / 915 /css/typography.css +``` 如果你不熟悉 Unix 工具,这条命令看起来可能有点晦涩,但它威力很强。它能在几秒内处理 GB 级日志,而且修改分析逻辑也非常方便:例如要排除 CSS 文件,可把 `awk` 参数改成 `'$7 !~ /\.css$/ {print $7}'`;若要统计访问最多的客户端 IP,把 `awk` 参数改成 `'{print $1}'` 即可。 @@ -473,9 +475,9 @@ Kubeflow、Flyte、Ray 等框架就专为这类负载构建。以 OpenAI 为例 最后,数据科学家常在 Jupyter、Hex 等交互式 Notebook 中实验数据。Notebook 由多个 *cell* 组成,每个 cell 是一小段 Markdown、Python 或 SQL;按顺序执行可得到表格、图表或数据结果。很多 Notebook 背后通过 DataFrame API 或 SQL 调用批处理系统。 -### 对外提供衍生数据 {#sec_batch_serving_derived} +### 对外提供派生数据 {#sec_batch_serving_derived} -批处理常用于构建预计算/衍生数据集,如商品推荐、面向用户的报表、机器学习特征等。这些数据通常由生产数据库、键值存储或搜索引擎对外服务。不论目标系统是什么,都需要把批处理环境中的 DFS/对象存储输出,回灌到线上服务数据库。 +批处理常用于构建预计算/派生数据集,如商品推荐、面向用户的报表、机器学习特征等。这些数据通常由生产数据库、键值存储或搜索引擎对外服务。不论目标系统是什么,都需要把批处理环境中的 DFS/对象存储输出,回灌到线上服务数据库。 最直观的做法是:在批作业里直接使用数据库客户端库,一条条写生产数据库(假设防火墙允许)。这虽然能工作,但通常不是好主意,原因有三: @@ -483,7 +485,7 @@ Kubeflow、Flyte、Ray 等框架就专为这类负载构建。以 OpenAI 为例 - 批处理框架常并行跑很多任务。若所有任务同时以批处理速率写同一数据库,很容易把数据库压垮,进而影响其在线查询性能,引发系统其他部分故障 [^44]。 - 批作业通常提供清晰的“全有或全无”输出语义:作业成功时,结果等价于每个任务恰好执行一次;作业失败时,无有效输出。但如果在作业内直接写外部系统,就产生了外部可见副作用,难以隐藏:部分完成结果可能被其他系统看到,任务失败重启还可能造成重复写。 -更好的方案是把预计算结果先推送到 Kafka 这类流系统(我们会在[第十二章](/ch12#ch_stream)深入讨论)。Elasticsearch、Apache Pinot、Apache Druid、Venice 这类衍生数据存储 [^45],以及 ClickHouse 等云数仓,都支持从 Kafka 摄入数据。通过流系统过渡可以改善前述问题: +更好的方案是把预计算结果先推送到 Kafka 这类流系统(我们会在[第十二章](/ch12#ch_stream)深入讨论)。Elasticsearch、Apache Pinot、Apache Druid、Venice 这类派生数据存储 [^45],以及 ClickHouse 等云数仓,都支持从 Kafka 摄入数据。通过流系统过渡可以改善前述问题: - 流系统针对顺序写优化,更适合批作业的大吞吐写入模式; - 流系统可在批作业与生产库间充当缓冲层,下游可按自身能力限速读取,避免影响线上流量; @@ -513,7 +515,7 @@ Kubeflow、Flyte、Ray 等框架就专为这类负载构建。以 OpenAI 为例 - ETL 流水线:通过定时工作流在不同系统间提取、转换、加载数据; - 分析:既支持预聚合报表,也支持临时探索查询; - 机器学习:用于准备与处理大规模训练数据; -- 把批处理输出灌入面向生产流量的系统:常通过流系统或批量导入工具,把衍生数据提供给用户。 +- 把批处理输出灌入面向生产流量的系统:常通过流系统或批量导入工具,把派生数据提供给用户。 下一章我们将转向流处理。与批处理不同,流处理输入是 *无界(unbounded)* 的:作业仍在,但输入是持续不断的数据流,因此作业不会“完成”。我们会看到,流处理与批处理在一些方面很相似,但“输入无界”这一前提也会显著改变系统设计。 diff --git a/content/zh/ch12.md b/content/zh/ch12.md index e690caf..7ede91d 100644 --- a/content/zh/ch12.md +++ b/content/zh/ch12.md @@ -14,7 +14,7 @@ breadcrumbs: false > > —— 约翰・加尔,Systemantics(1975) -在 [第十一章](/ch11) 中,我们讨论了批处理技术,它读取一组文件作为输入,并生成一组新的文件作为输出。输出是 **衍生数据(derived data)** 的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引、推荐系统、做分析等等。 +在 [第十一章](/ch11) 中,我们讨论了批处理技术,它读取一组文件作为输入,并生成一组新的文件作为输出。输出是 **派生数据(derived data)** 的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引、推荐系统、做分析等等。 然而,在 [第十一章](/ch11) 中仍然有一个很大的假设:即输入是有界的,即已知和有限的大小,所以批处理知道它何时完成输入的读取。例如,MapReduce 核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。 @@ -136,7 +136,7 @@ breadcrumbs: false 数据库和文件系统采用截然相反的方法论:至少在某人显式删除前,通常写入数据库或文件的所有内容都要被永久记录下来。 -这种思维方式上的差异对创建衍生数据的方式有巨大影响。如 [第十一章](/ch11) 所述,批处理过程的一个关键特性是,你可以反复运行它们,试验处理步骤,不用担心损坏输入(因为输入是只读的)。而 AMQP/JMS 风格的消息传递并非如此:收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此你不能期望再次运行同一个消费者能得到相同的结果。 +这种思维方式上的差异对创建派生数据的方式有巨大影响。如 [第十一章](/ch11) 所述,批处理过程的一个关键特性是,你可以反复运行它们,试验处理步骤,不用担心损坏输入(因为输入是只读的)。而 AMQP/JMS 风格的消息传递并非如此:收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此你不能期望再次运行同一个消费者能得到相同的结果。 如果你将新的消费者添加到消息传递系统,通常只能接收到消费者注册之后开始发送的消息。先前的任何消息都随风而逝,一去不复返。作为对比,你可以随时为文件和数据库添加新的客户端,且能读取任意久远的数据(只要应用没有显式覆盖或删除这些数据)。 @@ -203,7 +203,7 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基 除了消费者的任何输出之外,处理的唯一副作用是消费者偏移量的前进。但偏移量是在消费者的控制之下的,所以如果需要的话可以很容易地操纵:例如你可以用昨天的偏移量跑一个消费者副本,并将输出写到不同的位置,以便重新处理最近一天的消息。你可以使用各种不同的处理代码重复任意次。 -这一方面使得基于日志的消息传递更像上一章的批处理,其中衍生数据通过可重复的转换过程与输入数据显式分离。它允许进行更多的实验,更容易从错误和漏洞中恢复,使其成为在组织内集成数据流的良好工具[^27]。 +这一方面使得基于日志的消息传递更像上一章的批处理,其中派生数据通过可重复的转换过程与输入数据显式分离。它允许进行更多的实验,更容易从错误和漏洞中恢复,使其成为在组织内集成数据流的良好工具[^27]。 ## 数据库与流 {#sec_stream_databases} @@ -222,7 +222,7 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基 正如我们在本书中所看到的,没有一个系统能够满足所有的数据存储、查询和处理需求。在实践中,大多数重要应用都需要组合使用几种不同的技术来满足所有的需求:例如,使用 OLTP 数据库来为用户请求提供服务,使用缓存来加速常见请求,使用全文索引来处理搜索查询,使用数据仓库用于分析。每一种技术都有自己的数据副本,并根据自己的目的进行存储方式的优化。 -由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存、搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由 ETL 进程执行(请参阅 “[数据仓库](/ch1#sec_introduction_dwh)”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在 “[批处理工作流的输出](/ch11#sec_batch_output)” 中同样看到了如何使用批处理创建搜索索引、推荐系统和其他衍生数据系统。 +由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存、搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由 ETL 进程执行(请参阅 “[数据仓库](/ch1#sec_introduction_dwh)”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在 “[批处理工作流的输出](/ch11#sec_batch_output)” 中同样看到了如何使用批处理创建搜索索引、推荐系统和其他派生数据系统。 如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是 **双写(dual write)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。 @@ -248,7 +248,7 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基 最近,人们对 **数据变更捕获(change data capture, CDC)** 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。CDC 是非常有意思的,尤其是当变更能在被写入后立刻用于流时[^28]。 -例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如 [图 12-5](/fig/ddia_1205.png) 所示。 +例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他派生数据系统只是变更流的消费者,如 [图 12-5](/fig/ddia_1205.png) 所示。 ![](/fig/ddia_1205.png) @@ -256,7 +256,7 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基 #### 数据变更捕获的实现 {#id307} -我们可以将日志消费者叫做 **衍生数据系统**,正如在 [第一章](/ch1#sec_introduction_derived) 讨论“记录系统与衍生数据”时所述:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。数据变更捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。 +我们可以将日志消费者叫做 **派生数据系统**,正如在 [第一章](/ch1#sec_introduction_derived) 讨论“记录系统与派生数据”时所述:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。数据变更捕获是一种机制,可确保对记录系统所做的所有更改都反映在派生数据系统中,以便派生系统具有数据的准确副本。 从本质上说,数据变更捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 12-2](/fig/ddia_1202.png) 的重新排序问题)。 @@ -276,7 +276,7 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基 #### 日志压缩 {#sec_stream_log_compaction} -如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但 **日志压缩(log compaction)** 提供了一个很好的备选方案。 +如果你只能保留有限的历史日志,则每次要添加新的派生数据系统时,都需要做一次快照。但 **日志压缩(log compaction)** 提供了一个很好的备选方案。 我们之前在 “[日志结构存储](/ch4#sec_storage_log_structured)” 的上下文中讨论过日志压缩(可参阅 [图 4-3](/fig/ddia_0403.png) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 @@ -284,7 +284,7 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基 在基于日志的消息代理与数据变更捕获的上下文中也适用相同的想法。如果 CDC 系统被配置为,每个变更都包含一个主键,且每个键的更新都替换了该键以前的值,那么只需要保留对键的最新写入就足够了。 -现在,无论何时需要重建衍生数据系统(如搜索索引),你可以从压缩日志主题的零偏移量处启动新的消费者,然后依次扫描日志中的所有消息。日志能保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,你可以使用它来获取数据库内容的完整副本,而无需从 CDC 源数据库取一个快照。 +现在,无论何时需要重建派生数据系统(如搜索索引),你可以从压缩日志主题的零偏移量处启动新的消费者,然后依次扫描日志中的所有消息。日志能保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,你可以使用它来获取数据库内容的完整副本,而无需从 CDC 源数据库取一个快照。 Apache Kafka 支持这种日志压缩功能。正如我们将在本章后面看到的,它允许消息代理被当成持久性存储使用,而不仅仅是用于临时消息。 @@ -294,7 +294,7 @@ Apache Kafka 支持这种日志压缩功能。正如我们将在本章后面看 即使是 Cassandra 这类最终一致、基于法定票数的数据库,也开始支持数据变更捕获。正如我们在第十章关于线性一致与法定票数中看到的,写入是否“可见”取决于读写一致性设置,这使得其 CDC 的统一抽象更困难。Cassandra 的做法通常是公开各节点原始日志段,而不是提供单一统一的变更流;消费方需要自己读取并合并各节点日志,生成业务可用的单一事件流[^32]。 -Kafka Connect[^33]提供了大量数据库系统与 Kafka 的 CDC 集成能力。变更事件一旦进入 Kafka,就可以用于更新搜索索引等衍生系统,也可以继续送入后续流处理链路。 +Kafka Connect[^33]提供了大量数据库系统与 Kafka 的 CDC 集成能力。变更事件一旦进入 Kafka,就可以用于更新搜索索引等派生系统,也可以继续送入后续流处理链路。 #### 数据变更捕获与事件溯源 {#sec_stream_event_sourcing} @@ -344,7 +344,7 @@ $$ **图 12-7 应用当前状态与事件流之间的关系** -如果你持久存储了变更日志,那么重现状态就非常简单。如果你将事件日志视为记录系统,而把可变状态视为其衍生结果,那么系统中的数据流就更容易推理。正如 Jim Gray 和 Andreas Reuter 在 1992 年所说[^39]: +如果你持久存储了变更日志,那么重现状态就非常简单。如果你将事件日志视为记录系统,而把可变状态视为其派生结果,那么系统中的数据流就更容易推理。正如 Jim Gray 和 Andreas Reuter 在 1992 年所说[^39]: > 从原理上讲,数据库并非必需;日志已经包含了全部信息。之所以要保留数据库(即日志末端的当前状态),只是为了提高读取性能。 @@ -360,7 +360,7 @@ $$ 不可变的事件也包含了比当前状态更多的信息。例如在购物网站上,顾客可以将物品添加到他们的购物车,然后再将其移除。虽然从履行订单的角度,第二个事件取消了第一个事件,但对分析目的而言,知道客户考虑过某个特定项而之后又反悔,可能是很有用的。也许他们会选择在未来购买,或者他们已经找到了替代品。这个信息被记录在事件日志中,但对于移出购物车就删除记录的数据库而言,这个信息在移出购物车时可能就丢失了。 -#### 从同一事件日志中衍生多个视图 {#sec_stream_deriving_views} +#### 从同一事件日志中派生多个视图 {#sec_stream_deriving_views} 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中派生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 12-5](/fig/ddia_1205.png)):例如,Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引[^33]。这对于许多其他存储和索引系统(如搜索服务器)来说也是有意义的,当系统要从分布式日志中获取输入时尤其如此(请参阅 “[保持系统同步](#sec_stream_sync)”)。 @@ -454,7 +454,7 @@ CEP 的实现包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str #### 维护物化视图 {#sec_stream_mat_view} -我们在 “[数据库与流](#sec_stream_databases)” 中看到,数据库的变更流可以用于维护衍生数据系统(如缓存、搜索索引和数据仓库),并使其与源数据库保持最新。我们可以将这些示例视作维护 **物化视图(materialized view)** 的一种具体场景:在某个数据集上派生出一个替代视图以便高效查询,并在底层数据变更时更新视图[^37]。 +我们在 “[数据库与流](#sec_stream_databases)” 中看到,数据库的变更流可以用于维护派生数据系统(如缓存、搜索索引和数据仓库),并使其与源数据库保持最新。我们可以将这些示例视作维护 **物化视图(materialized view)** 的一种具体场景:在某个数据集上派生出一个替代视图以便高效查询,并在底层数据变更时更新视图[^37]。 同样,在事件溯源中,应用程序的状态是通过应用事件日志来维护的;这里的应用程序状态也是一种物化视图。与流分析场景不同的是,仅考虑某个时间窗口内的事件通常是不够的:构建物化视图可能需要任意时间段内的 **所有** 事件,除了那些可能由日志压缩丢弃的过时事件(请参阅 “[日志压缩](#sec_stream_log_compaction)”)。实际上,你需要一个可以一直延伸到时间开端的窗口。 @@ -692,13 +692,13 @@ AMQP/JMS 风格的消息代理 基于日志的消息代理 : 代理将一个分区中的所有消息分配给同一个消费者节点,并始终以相同的顺序传递消息。并行是通过分区实现的,消费者通过存档最近处理消息的偏移量来跟踪工作进度。消息代理将消息保留在磁盘上,因此如有必要的话,可以回跳并重新读取旧消息。 -基于日志的方法与数据库中的复制日志(请参阅 [第六章](/ch6))和日志结构存储引擎(请参阅 [第四章](/ch4))有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。 +基于日志的方法与数据库中的复制日志(请参阅 [第六章](/ch6))和日志结构存储引擎(请参阅 [第四章](/ch4))有相似之处。我们看到,这种方法对于消费输入流,并产生派生状态或派生输出数据流的系统而言特别适用。 就流的来源而言,我们讨论了几种可能性:用户活动事件,定期读数的传感器,和 Feed 数据(例如,金融中的市场数据)能够自然地表示为流。我们发现将数据库写入视作流也是很有用的:我们可以捕获变更日志 —— 即对数据库所做的所有变更的历史记录 —— 隐式地通过数据变更捕获,或显式地通过事件溯源。日志压缩允许流也能保有数据库内容的完整副本。 -将数据库表示为流为系统集成带来了很多强大机遇。通过消费变更日志并将其应用至衍生系统,你能使诸如搜索索引、缓存以及分析系统这类衍生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。 +将数据库表示为流为系统集成带来了很多强大机遇。通过消费变更日志并将其应用至派生系统,你能使诸如搜索索引、缓存以及分析系统这类派生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。 -像流一样维护状态以及消息重播的基础设施,是在各种流处理框架中实现流连接和容错的基础。我们讨论了流处理的几种目的,包括搜索事件模式(复杂事件处理),计算分窗聚合(流分析),以及保证衍生数据系统处于最新状态(物化视图)。 +像流一样维护状态以及消息重播的基础设施,是在各种流处理框架中实现流连接和容错的基础。我们讨论了流处理的几种目的,包括搜索事件模式(复杂事件处理),计算分窗聚合(流分析),以及保证派生数据系统处于最新状态(物化视图)。 然后我们讨论了在流处理中对时间进行推理的困难,包括处理时间与事件时间戳之间的区别,以及当你认为窗口已经完事之后,如何处理到达的掉队事件的问题。 diff --git a/content/zh/ch13.md b/content/zh/ch13.md index 94253ec..d35f52c 100644 --- a/content/zh/ch13.md +++ b/content/zh/ch13.md @@ -30,37 +30,37 @@ breadcrumbs: false 但是,即使你已经完全理解各种工具与其适用环境间的关系,还有一个挑战:在复杂的应用中,数据的用法通常花样百出。不太可能存在适用于 **所有** 不同数据应用场景的软件,因此你不可避免地需要拼凑几个不同的软件来以提供应用所需的功能。 -### 组合使用衍生数据的工具 {#id442} +### 组合使用派生数据的工具 {#id442} 例如,为了处理任意关键词的搜索查询,将 OLTP 数据库与全文检索索引集成在一起是很常见的需求。尽管一些数据库(例如 PostgreSQL)包含了全文索引功能,对于简单的应用完全够了[^1],但更复杂的搜索能力就需要专业的信息检索工具了。相反的是,搜索索引通常不适合作为持久的记录系统,因此许多应用需要组合这两种不同的工具以满足所有需求。 -我们在 “[保持系统同步](/ch12#sec_stream_sync)” 中接触过集成数据系统的问题。随着数据不同表示形式的增加,集成问题变得越来越困难。除了数据库和搜索索引之外,也许你需要在分析系统(数据仓库,或批处理和流处理系统)中维护数据副本;维护从原始数据中衍生的缓存,或反规范化的数据版本;将数据灌入机器学习、分类、排名或推荐系统中;或者基于数据变更发送通知。 +我们在 “[保持系统同步](/ch12#sec_stream_sync)” 中接触过集成数据系统的问题。随着数据不同表示形式的增加,集成问题变得越来越困难。除了数据库和搜索索引之外,也许你需要在分析系统(数据仓库,或批处理和流处理系统)中维护数据副本;维护从原始数据中派生的缓存,或反规范化的数据版本;将数据灌入机器学习、分类、排名或推荐系统中;或者基于数据变更发送通知。 #### 理解数据流 {#id443} -当需要在多个存储系统中维护相同数据的副本以满足不同的访问模式时,你要对输入和输出了如指掌:哪些数据先写入,哪些数据表示衍生自哪些来源?如何以正确的格式,将所有数据导入正确的地方? +当需要在多个存储系统中维护相同数据的副本以满足不同的访问模式时,你要对输入和输出了如指掌:哪些数据先写入,哪些数据表示派生自哪些来源?如何以正确的格式,将所有数据导入正确的地方? -例如,你可能会首先将数据写入 **记录系统** 数据库,捕获对该数据库所做的变更(请参阅 “[变更数据捕获](/ch12#sec_stream_cdc)”),然后将变更以相同的顺序应用于搜索索引。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全衍生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。 +例如,你可能会首先将数据写入 **记录系统** 数据库,捕获对该数据库所做的变更(请参阅 “[变更数据捕获](/ch12#sec_stream_cdc)”),然后将变更以相同的顺序应用于搜索索引。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。 允许应用程序直接写入搜索索引和数据库引入了如 [图 12-4](/fig/ddia_1204.png) 所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 -如果你可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。这是状态机复制方法的一个应用,我们在 “[全序广播](/ch10#sec_consistency_total_order)” 中看到。无论你使用变更数据捕获还是事件溯源日志,都不如简单的基于全序的决策原则更重要。 +如果你可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地派生出其他数据表示。这是状态机复制方法的一个应用,我们在 “[全序广播](/ch10#sec_consistency_total_order)” 中看到。无论你使用变更数据捕获还是事件溯源日志,都不如简单的基于全序的决策原则更重要。 -基于事件日志来更新衍生数据的系统,通常可以做到 **确定性** 与 **幂等性**(请参阅 “[幂等性](/ch12#sec_stream_idempotence)”),使得从故障中恢复相当容易。 +基于事件日志来更新派生数据的系统,通常可以做到 **确定性** 与 **幂等性**(请参阅 “[幂等性](/ch12#sec_stream_idempotence)”),使得从故障中恢复相当容易。 -#### 衍生数据与分布式事务 {#sec_future_derived_vs_transactions} +#### 派生数据与分布式事务 {#sec_future_derived_vs_transactions} -保持不同数据系统彼此一致的经典方法涉及分布式事务,如 “[原子提交与两阶段提交](/ch8#sec_transactions_2pc)” 中所述。与分布式事务相比,使用衍生数据系统的方法如何? +保持不同数据系统彼此一致的经典方法涉及分布式事务,如 “[原子提交与两阶段提交](/ch8#sec_transactions_2pc)” 中所述。与分布式事务相比,使用派生数据系统的方法如何? 在抽象层面,它们通过不同的方式达到类似的目标。分布式事务通过 **锁** 进行互斥来决定写入的顺序(请参阅 “[两阶段锁定](/ch8#sec_transactions_2pl)”),而 CDC 和事件溯源使用日志进行排序。分布式事务使用原子提交来确保变更只生效一次,而基于日志的系统通常基于 **确定性重试** 和 **幂等性**。 -最大的不同之处在于事务系统通常提供 [线性一致性](/ch10#sec_consistency_linearizability),这包含着有用的保证,例如 [读己之写](/ch6#sec_replication_ryw)。另一方面,衍生数据系统通常是异步更新的,因此它们默认不会提供相同的时序保证。 +最大的不同之处在于事务系统通常提供 [线性一致性](/ch10#sec_consistency_linearizability),这包含着有用的保证,例如 [读己之写](/ch6#sec_replication_ryw)。另一方面,派生数据系统通常是异步更新的,因此它们默认不会提供相同的时序保证。 在愿意为分布式事务付出代价的有限场景中,它们已被成功应用。但是,我认为 XA 的容错能力和性能很差劲(请参阅 “[实践中的分布式事务](/ch8#sec_transactions_xa)”),这严重限制了它的实用性。我相信为分布式事务设计一种更好的协议是可行的。但使这样一种协议被现有工具广泛接受是很有挑战的,且不是立竿见影的事。 -在没有广泛支持的良好分布式事务协议的情况下,我认为基于日志的衍生数据是集成不同数据系统的最有前途的方法。然而,诸如读己之写的保证是有用的,我认为告诉所有人 “最终一致性是不可避免的 —— 忍一忍并学会和它打交道” 是没有什么建设性的(至少在缺乏 **如何** 应对的良好指导时)。 +在没有广泛支持的良好分布式事务协议的情况下,我认为基于日志的派生数据是集成不同数据系统的最有前途的方法。然而,诸如读己之写的保证是有用的,我认为告诉所有人 “最终一致性是不可避免的 —— 忍一忍并学会和它打交道” 是没有什么建设性的(至少在缺乏 **如何** 应对的良好指导时)。 -在本章后文中,我们将讨论一些在异步衍生系统之上实现更强保障的方法,并迈向分布式事务和基于日志的异步系统之间的中间地带。 +在本章后文中,我们将讨论一些在异步派生系统之上实现更强保障的方法,并迈向分布式事务和基于日志的异步系统之间的中间地带。 #### 全序的限制 {#id335} @@ -87,29 +87,29 @@ breadcrumbs: false * 如果你可以记录一个事件来记录用户在做出决定之前所看到的系统状态,并给该事件一个唯一的标识符,那么后面的任何事件都可以引用该事件标识符来记录因果关系[^4]。我们将在 “[读也是事件](#读也是事件)” 中回到这个想法。 * 冲突解决算法(请参阅 “[自动冲突解决](/ch6#automatic-conflict-resolution)”)有助于处理以意外顺序传递的事件。它们对于维护状态很有用,但如果行为有外部副作用(例如,给用户发送通知),就没什么帮助了。 -也许,随着时间的推移,应用开发模式将出现,使得能够有效地捕获因果依赖关系,并且保持正确的衍生状态,而不会迫使所有事件经历全序广播的瓶颈)。 +也许,随着时间的推移,应用开发模式将出现,使得能够有效地捕获因果依赖关系,并且保持正确的派生状态,而不会迫使所有事件经历全序广播的瓶颈)。 ### 批处理与流处理 {#sec_future_batch_streaming} 我会说数据集成的目标是,确保数据最终能在所有正确的地方表现出正确的形式。这样做需要消费输入、转换、连接、过滤、聚合、训练模型、评估、以及最终写出适当的输出。批处理和流处理是实现这一目标的工具。 -批处理和流处理的输出是衍生数据集,例如搜索索引、物化视图、向用户显示的建议、聚合指标等(请参阅 “[批处理工作流的输出](/ch11#sec_batch_output)” 和 “[流处理的应用](/ch12#sec_stream_uses)”)。 +批处理和流处理的输出是派生数据集,例如搜索索引、物化视图、向用户显示的建议、聚合指标等(请参阅 “[批处理工作流的输出](/ch11#sec_batch_output)” 和 “[流处理的应用](/ch12#sec_stream_uses)”)。 正如我们在 [第十一章](/ch11) 和 [第十二章](/ch12) 中看到的,批处理和流处理有许多共同的原则,主要的根本区别在于流处理器在无限数据集上运行,而批处理输入是已知的有限大小。 -#### 维护衍生状态 {#id446} +#### 维护派生状态 {#id446} 批处理有着很强的函数式风格(即使其代码不是用函数式语言编写的):它鼓励确定性的纯函数,其输出仅依赖于输入,除了显式输出外没有副作用,将输入视作不可变的,且输出是仅追加的。流处理与之类似,但它扩展了算子以允许受管理的、容错的状态(请参阅 “[失败后重建状态](/ch12#sec_stream_state_fault_tolerance)”)。 -具有良好定义的输入和输出的确定性函数的原理不仅有利于容错(请参阅 “[幂等性](/ch12#sec_stream_idempotence)”),也简化了有关组织中数据流的推理[^7]。无论衍生数据是搜索索引、统计模型还是缓存,采用这种观点思考都是很有帮助的:将其视为从一个东西衍生出另一个的数据管道,通过函数式应用代码推送一个系统的状态变更,并将其效果应用至衍生系统中。 +具有良好定义的输入和输出的确定性函数的原理不仅有利于容错(请参阅 “[幂等性](/ch12#sec_stream_idempotence)”),也简化了有关组织中数据流的推理[^7]。无论派生数据是搜索索引、统计模型还是缓存,采用这种观点思考都是很有帮助的:将其视为从一个东西派生出另一个的数据管道,通过函数式应用代码推送一个系统的状态变更,并将其效果应用至派生系统中。 -原则上,衍生数据系统可以同步地维护,就像关系数据库在与索引表写入操作相同的事务中同步更新次级索引一样。然而,异步是使基于事件日志的系统稳健的原因:它允许系统的一部分故障被抑制在本地。而如果任何一个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障(请参阅 “[分布式事务的限制](/ch8#sec_transactions_xa)”)。 +原则上,派生数据系统可以同步地维护,就像关系数据库在与索引表写入操作相同的事务中同步更新次级索引一样。然而,异步是使基于事件日志的系统稳健的原因:它允许系统的一部分故障被抑制在本地。而如果任何一个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障(请参阅 “[分布式事务的限制](/ch8#sec_transactions_xa)”)。 我们在 “[分区与次级索引](/ch7#sec_sharding_secondary_indexes)” 中看到,次级索引经常跨越分区边界。具有次级索引的分区系统需要将写入发送到多个分区(如果索引按关键词分区的话)或将读取发送到所有分区(如果索引是按文档分区的话)。如果索引是异步维护的,这种跨分区通信也是最可靠和最可伸缩的[^8](另请参阅 “[多分区数据处理](#多分区数据处理)”)。 #### 应用演化后重新处理数据 {#sec_future_reprocessing} -在维护衍生数据时,批处理和流处理都是有用的。流处理允许将输入中的变化以低延迟反映在衍生视图中,而批处理允许重新处理大量累积的历史数据以便将新视图导出到现有数据集上。 +在维护派生数据时,批处理和流处理都是有用的。流处理允许将输入中的变化以低延迟反映在派生视图中,而批处理允许重新处理大量累积的历史数据以便将新视图导出到现有数据集上。 特别是,重新处理现有数据为维护系统、演化并支持新功能和需求变更提供了一个良好的机制(请参阅 [第四章](/ch4))。没有重新进行处理,模式演化将仅限于简单的变化,例如向记录中添加新的可选字段或添加新类型的记录。无论是在写时模式还是在读时模式中都是如此(请参阅 “[文档模型中的模式灵活性](/ch3#sec_datamodels_schema_flexibility)”)。另一方面,通过重新处理,可以将数据集重组为一个完全不同的模型,以便更好地满足新的要求。 @@ -121,7 +121,7 @@ breadcrumbs: false > > 以这种方式 “再加工” 现有的轨道,让新旧版本并存,可以在几年的时间内逐渐改变轨距。然而,这是一项昂贵的事业,这就是今天非标准轨距仍然存在的原因。例如,旧金山湾区的 BART 系统使用了与美国大部分地区不同的轨距。 -衍生视图允许 **渐进演化(gradual evolution)**。如果你想重新构建数据集,不需要执行突然切换式的迁移。取而代之的是,你可以将旧架构和新架构并排维护为相同基础数据上的两个独立衍生视图。然后可以开始将少量用户转移到新视图,以测试其性能并发现任何错误,而大多数用户仍然会被路由到旧视图。你可以逐渐地增加访问新视图的用户比例,最终可以删除旧视图[^10]。 +派生视图允许 **渐进演化(gradual evolution)**。如果你想重新构建数据集,不需要执行突然切换式的迁移。取而代之的是,你可以将旧架构和新架构并排维护为相同基础数据上的两个独立派生视图。然后可以开始将少量用户转移到新视图,以测试其性能并发现任何错误,而大多数用户仍然会被路由到旧视图。你可以逐渐地增加访问新视图的用户比例,最终可以删除旧视图[^10]。 这种逐渐迁移的美妙之处在于,如果出现问题,每个阶段的过程都很容易逆转:你始终有一个可以回滚的可用系统。通过降低不可逆损害的风险,你能对继续前进更有信心,从而更快地改善系统[^11]。 @@ -159,9 +159,9 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni * 复制日志,保持其他节点上数据的副本最新(请参阅 “[复制日志的实现](/ch6#sec_replication_implementation)”) * 全文检索索引,允许在文本中进行关键字搜索(请参阅 “[全文检索与模糊索引](/ch4#sec_storage_full_text)”),也内置于某些关系数据库[^1] -在 [第十一章](/ch11) 和 [第十二章](/ch12) 中,出现了类似的主题。我们讨论了如何构建全文检索索引(请参阅 “[批处理工作流的输出](/ch11#sec_batch_output)”),了解了如何维护物化视图(请参阅 “[维护物化视图](/ch12#sec_stream_mat_view)”)以及如何将变更从数据库复制到衍生数据系统(请参阅 “[变更数据捕获](/ch12#sec_stream_cdc)”)。 +在 [第十一章](/ch11) 和 [第十二章](/ch12) 中,出现了类似的主题。我们讨论了如何构建全文检索索引(请参阅 “[批处理工作流的输出](/ch11#sec_batch_output)”),了解了如何维护物化视图(请参阅 “[维护物化视图](/ch12#sec_stream_mat_view)”)以及如何将变更从数据库复制到派生数据系统(请参阅 “[变更数据捕获](/ch12#sec_stream_cdc)”)。 -数据库中内置的功能与人们用批处理和流处理器构建的衍生数据系统似乎有相似之处。 +数据库中内置的功能与人们用批处理和流处理器构建的派生数据系统似乎有相似之处。 #### 创建索引 {#id340} @@ -175,7 +175,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni 有鉴于此,我认为整个组织的数据流开始像一个巨大的数据库[^7]。每当批处理、流处理或 ETL 过程将数据从一个地方传输并转换到另一个地方时,它都像数据库子系统在维护索引或物化视图。 -从这种角度来看,批处理和流处理器就像精心实现的触发器、存储过程和物化视图维护例程。它们维护的衍生数据系统就像不同的索引类型。例如,关系数据库可能支持 B 树索引、散列索引、空间索引(请参阅 “[多列索引](/ch4#sec_storage_index_multicolumn)”)以及其他类型的索引。在新兴的衍生数据系统架构中,不是将这些设施作为单个集成数据库产品的功能实现,而是由各种不同的软件提供,运行在不同的机器上,由不同的团队管理。 +从这种角度来看,批处理和流处理器就像精心实现的触发器、存储过程和物化视图维护例程。它们维护的派生数据系统就像不同的索引类型。例如,关系数据库可能支持 B 树索引、散列索引、空间索引(请参阅 “[多列索引](/ch4#sec_storage_index_multicolumn)”)以及其他类型的索引。在新兴的派生数据系统架构中,不是将这些设施作为单个集成数据库产品的功能实现,而是由各种不同的软件提供,运行在不同的机器上,由不同的团队管理。 这些发展在未来将会把我们带到哪里?如果我们从没有适合所有访问模式的单一数据模型或存储格式的前提出发,我推测有两种途径可以将不同的存储和处理工具组合成一个有凝聚力的系统: @@ -195,7 +195,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni 联合和分拆是一个硬币的两面:用不同的组件构成可靠、 可伸缩和可维护的系统。联合只读查询需要将一个数据模型映射到另一个数据模型,这需要一些思考,但最终还是一个可解决的问题。而我认为同步写入到几个存储系统是更困难的工程问题,所以我将重点关注它。 -传统的同步写入方法需要跨异构存储系统的分布式事务[^18],我认为这是错误的解决方案(请参阅 “[衍生数据与分布式事务](#衍生数据与分布式事务)”)。单个存储或流处理系统内的事务是可行的,但是当数据跨越不同技术之间的边界时,我认为具有幂等写入的异步事件日志是一种更加健壮和实用的方法。 +传统的同步写入方法需要跨异构存储系统的分布式事务[^18],我认为这是错误的解决方案(请参阅 “[派生数据与分布式事务](#派生数据与分布式事务)”)。单个存储或流处理系统内的事务是可行的,但是当数据跨越不同技术之间的边界时,我认为具有幂等写入的异步事件日志是一种更加健壮和实用的方法。 例如,分布式事务在某些流处理组件内部使用,以匹配 **恰好一次(exactly-once)** 语义(请参阅 “[原子提交再现](/ch12#sec_stream_atomic_commit)”),这可以很好地工作。然而,当事务需要涉及由不同人群编写的系统时(例如,当数据从流处理组件写入分布式键值存储或搜索索引时),缺乏标准化的事务协议会使集成更难。有幂等消费者的有序事件日志(请参阅 “[幂等性](/ch12#sec_stream_idempotence)”)是一种更简单的抽象,因此在异构系统中实现更加可行[^7]。 @@ -216,22 +216,22 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni ### 围绕数据流设计应用 {#sec_future_dataflow} -当底层数据发生变化时去更新衍生数据,这个思路并不新鲜。比如电子表格就有很强的数据流编程能力[^33]:你可以在一个单元格写公式(例如对另一列求和),只要输入变化,结果就会自动重算。这正是我们希望数据系统具备的能力:数据库记录一旦变化,相关索引、缓存视图和聚合结果都应自动刷新,而不需要应用开发者关心刷新细节。 +当底层数据发生变化时去更新派生数据,这个思路并不新鲜。比如电子表格就有很强的数据流编程能力[^33]:你可以在一个单元格写公式(例如对另一列求和),只要输入变化,结果就会自动重算。这正是我们希望数据系统具备的能力:数据库记录一旦变化,相关索引、缓存视图和聚合结果都应自动刷新,而不需要应用开发者关心刷新细节。 从这个意义上说,今天很多数据系统仍可以向 VisiCalc 在 1979 年就具备的特性学习[^34]。与电子表格不同的是,现代数据系统还必须同时满足容错、可伸缩、持久化存储、跨团队异构技术集成等要求,也必须能够复用已有库与服务。指望所有软件都在一种语言、框架或工具上统一实现并不现实。 -#### 应用代码作为衍生函数 {#sec_future_dataflow_derivation} +#### 应用代码作为派生函数 {#sec_future_dataflow_derivation} -当一个数据集衍生自另一个数据集时,它会经历某种转换函数。例如: +当一个数据集派生自另一个数据集时,它会经历某种转换函数。例如: -* 次级索引是由一种直白的转换函数生成的衍生数据集:对于基础表中的每行或每个文档,它挑选被索引的列或字段中的值,并按这些值排序(假设使用 B 树或 SSTable 索引,按键排序,如 [第四章](/ch4) 所述)。 +* 次级索引是由一种直白的转换函数生成的派生数据集:对于基础表中的每行或每个文档,它挑选被索引的列或字段中的值,并按这些值排序(假设使用 B 树或 SSTable 索引,按键排序,如 [第四章](/ch4) 所述)。 * 全文检索索引是通过应用各种自然语言处理函数而创建的,诸如语言检测、分词、词干或词汇化、拼写纠正和同义词识别,然后构建用于高效查找的数据结构(例如倒排索引)。 -* 在机器学习系统中,我们可以将模型视作从训练数据通过应用各种特征提取、统计分析函数衍生的数据,当模型应用于新的输入数据时,模型的输出是从输入和模型(因此间接地从训练数据)中衍生的。 +* 在机器学习系统中,我们可以将模型视作从训练数据通过应用各种特征提取、统计分析函数派生的数据,当模型应用于新的输入数据时,模型的输出是从输入和模型(因此间接地从训练数据)中派生的。 * 缓存通常包含将以用户界面(UI)显示的形式的数据聚合。因此填充缓存需要知道 UI 中引用的字段;UI 中的变更可能需要更新缓存填充方式的定义,并重建缓存。 -用于次级索引的衍生函数是如此常用的需求,以致于它作为核心功能被内建至许多数据库中,你可以简单地通过 `CREATE INDEX` 来调用它。对于全文索引,常见语言的基本语言特征可能内置到数据库中,但更复杂的特征通常需要领域特定的调整。在机器学习中,特征工程是众所周知的特定于应用的特征,通常需要包含很多关于用户交互与应用部署的详细知识[^35]。 +用于次级索引的派生函数是如此常用的需求,以致于它作为核心功能被内建至许多数据库中,你可以简单地通过 `CREATE INDEX` 来调用它。对于全文索引,常见语言的基本语言特征可能内置到数据库中,但更复杂的特征通常需要领域特定的调整。在机器学习中,特征工程是众所周知的特定于应用的特征,通常需要包含很多关于用户交互与应用部署的详细知识[^35]。 -当创建衍生数据集的函数不是像创建次级索引那样的标准搬砖函数时,需要自定义代码来处理特定于应用的东西。而这个自定义代码是让许多数据库挣扎的地方,虽然关系数据库通常支持触发器、存储过程和用户定义的函数,可以用它们来在数据库中执行应用代码,但它们有点像数据库设计里的事后反思。(请参阅 “[传递事件流](/ch12#sec_stream_transmit)”)。 +当创建派生数据集的函数不是像创建次级索引那样的标准搬砖函数时,需要自定义代码来处理特定于应用的东西。而这个自定义代码是让许多数据库挣扎的地方,虽然关系数据库通常支持触发器、存储过程和用户定义的函数,可以用它们来在数据库中执行应用代码,但它们有点像数据库设计里的事后反思。(请参阅 “[传递事件流](/ch12#sec_stream_transmit)”)。 #### 应用代码和状态的分离 {#id344} @@ -255,16 +255,16 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni 我们在 “[数据库与流](/ch12#sec_stream_databases)” 中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。诸如 Actor 的消息传递系统(请参阅 “[消息传递中的数据流](/ch5#sec_encoding_dataflow_msg)”)也具有响应事件的概念。早在 20 世纪 80 年代,**元组空间(tuple space)** 模型就已经探索了表达分布式计算的方式:观察状态变更并作出反应的过程[^38][^39]。 -如前所述,当触发器由于数据变更而被触发时,或次级索引更新以反映索引表中的变更时,数据库内部也发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外,用于创建衍生数据集:缓存、全文检索索引、机器学习或分析系统。我们可以为此使用流处理和消息传递系统。 +如前所述,当触发器由于数据变更而被触发时,或次级索引更新以反映索引表中的变更时,数据库内部也发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外,用于创建派生数据集:缓存、全文检索索引、机器学习或分析系统。我们可以为此使用流处理和消息传递系统。 -需要记住的重要一点是,维护衍生数据不同于执行异步任务。传统的消息传递系统通常是为执行异步任务设计的(请参阅 “[日志与传统的消息传递相比](/ch12#sec_stream_logs_vs_messaging)”): +需要记住的重要一点是,维护派生数据不同于执行异步任务。传统的消息传递系统通常是为执行异步任务设计的(请参阅 “[日志与传统的消息传递相比](/ch12#sec_stream_logs_vs_messaging)”): -* 在维护衍生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志衍生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如 “[确认与重新传递](/ch12#sec_stream_reordering)” 中所述,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外(请参阅 “[保持系统同步](/ch12#sec_stream_sync)”)。 -* 容错是衍生数据的关键:仅仅丢失单个消息就会导致衍生数据集永远与其数据源失去同步。消息传递和衍生状态更新都必须可靠。例如,许多 Actor 系统默认在内存中维护 Actor 的状态和消息,所以如果运行 Actor 的机器崩溃,状态和消息就会丢失。 +* 在维护派生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志派生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如 “[确认与重新传递](/ch12#sec_stream_reordering)” 中所述,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外(请参阅 “[保持系统同步](/ch12#sec_stream_sync)”)。 +* 容错是派生数据的关键:仅仅丢失单个消息就会导致派生数据集永远与其数据源失去同步。消息传递和派生状态更新都必须可靠。例如,许多 Actor 系统默认在内存中维护 Actor 的状态和消息,所以如果运行 Actor 的机器崩溃,状态和消息就会丢失。 稳定的消息排序和容错消息处理是相当严格的要求,但与分布式事务相比,它们开销更小,运行更稳定。现代流处理组件可以提供这些排序和可靠性保证,并允许应用代码以流算子的形式运行。 -这些应用代码可以执行任意处理,包括数据库内置衍生函数通常不提供的功能。就像通过管道链接的 Unix 工具一样,流算子可以围绕着数据流构建大型系统。每个算子接受状态变更的流作为输入,并产生其他状态变化的流作为输出。 +这些应用代码可以执行任意处理,包括数据库内置派生函数通常不提供的功能。就像通过管道链接的 Unix 工具一样,流算子可以围绕着数据流构建大型系统。每个算子接受状态变更的流作为输入,并产生其他状态变化的流作为输出。 #### 流处理器和服务 {#id345} @@ -281,19 +281,19 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni 连接是时间相关的:如果购买事件在稍后的时间点被重新处理,汇率可能已经改变。如果要重建原始输出,则需要获取原始购买时的历史汇率。无论是查询服务还是订阅汇率更新流,你都需要处理这种时间相关性(请参阅 “[连接的时间依赖性](/ch12#sec_stream_join_time)”)。 -订阅变更流,而不是在需要时查询当前状态,使我们更接近类似电子表格的计算模型:当某些数据发生变更时,依赖于此的所有衍生数据都可以快速更新。还有很多未解决的问题,例如关于时间相关连接等问题,但我认为围绕数据流构建应用的想法是一个非常有希望的方向。 +订阅变更流,而不是在需要时查询当前状态,使我们更接近类似电子表格的计算模型:当某些数据发生变更时,依赖于此的所有派生数据都可以快速更新。还有很多未解决的问题,例如关于时间相关连接等问题,但我认为围绕数据流构建应用的想法是一个非常有希望的方向。 -### 观察衍生数据状态 {#sec_future_observing} +### 观察派生数据状态 {#sec_future_observing} -在抽象层面,上一节讨论的数据流系统给出了创建并维护衍生数据集(如搜索索引、物化视图、预测模型)的过程。我们把这称为 **写路径(write path)**:当信息写入系统后,它可能经过多个批处理与流处理阶段,最终所有相关衍生数据集都会被更新。[图 13-1](/ch13#fig_future_write_read_paths) 展示了搜索索引更新的例子。 +在抽象层面,上一节讨论的数据流系统给出了创建并维护派生数据集(如搜索索引、物化视图、预测模型)的过程。我们把这称为 **写路径(write path)**:当信息写入系统后,它可能经过多个批处理与流处理阶段,最终所有相关派生数据集都会被更新。[图 13-1](/ch13#fig_future_write_read_paths) 展示了搜索索引更新的例子。 {{< figure src="/fig/ddia_1301.png" id="fig_future_write_read_paths" caption="图 13-1 在搜索索引中,写入(文档更新)与读取(查询)相遇。" class="w-full my-4" >}} -但你为什么一开始就要创建衍生数据集?很可能是因为你想在以后再次查询它。这就是 **读路径(read path)**:当服务用户请求时,你需要从衍生数据集中读取,也许还要对结果进行一些额外处理,然后构建给用户的响应。 +但你为什么一开始就要创建派生数据集?很可能是因为你想在以后再次查询它。这就是 **读路径(read path)**:当服务用户请求时,你需要从派生数据集中读取,也许还要对结果进行一些额外处理,然后构建给用户的响应。 总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。 -如 [图 13-1](/ch13#fig_future_write_read_paths) 所示,衍生数据集是写路径和读路径相遇的地方。它代表了写入时工作量与读取时工作量之间的权衡。 +如 [图 13-1](/ch13#fig_future_write_read_paths) 所示,派生数据集是写路径和读路径相遇的地方。它代表了写入时工作量与读取时工作量之间的权衡。 #### 物化视图和缓存 {#id451} @@ -335,7 +335,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni 最近用于开发有状态的客户端与用户界面的工具,例如如 Elm 语言[^30]和 Facebook 的 React、Flux 和 Redux 工具链,已经通过订阅表示用户输入或服务器响应的事件流来管理客户端的内部状态,其结构与事件溯源相似(请参阅 “[事件溯源](/ch12#sec_stream_event_sourcing)”)。 -将这种编程模型扩展为:允许服务器将状态变更事件推送到客户端的事件管道中,是非常自然的。因此,状态变化可以通过 **端到端(end-to-end)** 的写路径流动:从一个设备上的交互触发状态变更开始,经由事件日志,并穿过几个衍生数据系统与流处理器,一直到另一台设备上的用户界面,而有人正在观察用户界面上的状态变化。这些状态变化能以相当低的延迟传播 —— 比如说,在一秒内从一端到另一端。 +将这种编程模型扩展为:允许服务器将状态变更事件推送到客户端的事件管道中,是非常自然的。因此,状态变化可以通过 **端到端(end-to-end)** 的写路径流动:从一个设备上的交互触发状态变更开始,经由事件日志,并穿过几个派生数据系统与流处理器,一直到另一台设备上的用户界面,而有人正在观察用户界面上的状态变化。这些状态变化能以相当低的延迟传播 —— 比如说,在一秒内从一端到另一端。 一些应用(如即时消息传递与在线游戏)已经具有这种 “实时” 架构(在低延迟交互的意义上,不是在 “[响应时间保证](/ch9#sec_distributed_clocks_realtime)” 中的意义上)。但我们为什么不用这种方式构建所有的应用? @@ -345,7 +345,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni #### 读也是事件 {#sec_future_read_events} -我们讨论过,当流处理器将衍生数据写入存储(数据库,缓存或索引)时,以及当用户请求查询该存储时,存储将充当写路径和读路径之间的边界。该存储应当允许对数据进行随机访问的读取查询,否则这些查询将需要扫描整个事件日志。 +我们讨论过,当流处理器将派生数据写入存储(数据库,缓存或索引)时,以及当用户请求查询该存储时,存储将充当写路径和读路径之间的边界。该存储应当允许对数据进行随机访问的读取查询,否则这些查询将需要扫描整个事件日志。 在很多情况下,数据存储与流处理系统是分开的。但回想一下,流处理器还是需要维护状态以执行聚合和连接的(请参阅 “[流连接](/ch12#sec_stream_joins)”)。这种状态通常隐藏在流处理器内部,但一些框架也允许这些状态被外部客户端查询[^45],将流处理器本身变成一种简单的数据库。 @@ -450,7 +450,7 @@ COMMIT; [例 13-2](#fig_future_request_id) 依赖于 `request_id` 列上的唯一约束。如果事务尝试插入已存在的 ID,`INSERT` 会失败并中止事务,从而避免重复生效。即使在较弱隔离级别下,关系数据库通常也能正确维护唯一性约束(而应用层的 “先检查再插入” 在不可串行化隔离下可能失败,见 “[写入偏差与幻读](/ch8#sec_transactions_write_skew)”)。 -除了抑制重复请求,[例 13-2](#fig_future_request_id) 中的 `requests` 表本身也像一份事件日志,可用于事件溯源或变更数据捕获。账户余额更新并不一定要与事件插入放在同一事务中,因为余额是可由下游消费者从请求事件衍生出的冗余状态;只要请求事件被恰好处理一次(同样可通过请求 ID 保证),即可保持正确性。 +除了抑制重复请求,[例 13-2](#fig_future_request_id) 中的 `requests` 表本身也像一份事件日志,可用于事件溯源或变更数据捕获。账户余额更新并不一定要与事件插入放在同一事务中,因为余额是可由下游消费者从请求事件派生出的冗余状态;只要请求事件被恰好处理一次(同样可通过请求 ID 保证),即可保持正确性。 #### 端到端原则 {#sec_future_e2e_argument} @@ -551,7 +551,7 @@ COMMIT; * 完整性(Integrity) - 完整性意味着没有损坏;即没有数据丢失,并且没有矛盾或错误的数据。尤其是如果某些衍生数据集是作为底层数据之上的视图而维护的(请参阅 “[从事件日志中衍生出当前状态](/ch12#sec_stream_deriving_views)”),这种衍生必须是正确的。例如,数据库索引必须正确地反映数据库的内容 —— 缺失某些记录的索引并不是很有用。 + 完整性意味着没有损坏;即没有数据丢失,并且没有矛盾或错误的数据。尤其是如果某些派生数据集是作为底层数据之上的视图而维护的(请参阅 “[从事件日志中派生出当前状态](/ch12#sec_stream_deriving_views)”),这种派生必须是正确的。例如,数据库索引必须正确地反映数据库的内容 —— 缺失某些记录的索引并不是很有用。 如果完整性被违背,这种不一致是永久的:在大多数情况下,等待与重试并不能修复数据库损坏。相反的是,需要显式地检查与修复。在 ACID 事务的上下文中(请参阅 “[ACID 的含义](/ch8#sec_transactions_acid)”),一致性通常被理解为某种特定于应用的完整性概念。原子性和持久性是保持完整性的重要工具。 @@ -573,9 +573,9 @@ ACID 事务通常既提供及时性(例如线性一致性)也提供完整性 正如我们在上一节看到的那样,可靠的流处理系统可以在无需分布式事务与原子提交协议的情况下保持完整性,这意味着它们有潜力达到与后者相当的正确性,同时还具备好得多的性能与运维稳健性。为了达成这种正确性,我们组合使用了多种机制: * 将写入操作的内容表示为单条消息,从而可以轻松地被原子写入 —— 与事件溯源搭配效果拔群(请参阅 “[事件溯源](/ch12#sec_stream_event_sourcing)”)。 -* 使用与存储过程类似的确定性衍生函数,从这一消息中衍生出所有其他的状态变更(请参阅 “[真的串行执行](/ch8#sec_transactions_serial)” 和 “[应用代码作为衍生函数](/ch13#sec_future_dataflow_derivation)”) +* 使用与存储过程类似的确定性派生函数,从这一消息中派生出所有其他的状态变更(请参阅 “[真的串行执行](/ch8#sec_transactions_serial)” 和 “[应用代码作为派生函数](/ch13#sec_future_dataflow_derivation)”) * 将客户端生成的请求 ID 传递通过所有的处理层次,从而允许端到端的除重,带来幂等性。 -* 使消息不可变,并允许衍生数据能随时被重新处理,这使从错误中恢复更加容易(请参阅 “[不可变事件的优点](/ch12#sec_stream_immutability_pros)”) +* 使消息不可变,并允许派生数据能随时被重新处理,这使从错误中恢复更加容易(请参阅 “[不可变事件的优点](/ch12#sec_stream_immutability_pros)”) 这种机制组合在我看来,是未来构建容错应用的一个非常有前景的方向。 @@ -600,14 +600,14 @@ ACID 事务通常既提供及时性(例如线性一致性)也提供完整性 我们现在已经做了两个有趣的观察: -1. 数据流系统可以维持衍生数据的完整性保证,而无需原子提交、线性一致性或者同步的跨分区协调。 +1. 数据流系统可以维持派生数据的完整性保证,而无需原子提交、线性一致性或者同步的跨分区协调。 2. 虽然严格的唯一性约束要求及时性和协调,但许多应用实际上可以接受宽松的约束:只要整个过程保持完整性,这些约束可能会被临时违反并在稍后被修复。 总之这些观察意味着,数据流系统可以为许多应用提供无需协调的数据管理服务,且仍能给出很强的完整性保证。这种 **无协调(coordination-avoiding)** 的数据系统有着很大的吸引力:比起需要执行同步协调的系统,它们能达到更好的性能与更强的容错能力[^56]。 例如,这种系统可以使用多领导者配置运维,跨越多个数据中心,在区域间异步复制。任何一个数据中心都可以持续独立运行,因为不需要同步的跨区域协调。这样的系统的及时性保证会很弱 —— 如果不引入协调它是不可能是线性一致的 —— 但它仍然可以提供有力的完整性保证。 -在这种情况下,可串行化事务作为维护衍生状态的一部分仍然是有用的,但它们只能在小范围内运行,在那里它们工作得很好[^8]。异构分布式事务(如 XA 事务,请参阅 “[实践中的分布式事务](/ch8#sec_transactions_xa)”)不是必需的。同步协调仍然可以在需要的地方引入(例如在无法恢复的操作之前强制执行严格的约束),但是如果只是应用的一小部分地方需要它,没必要让所有操作都付出协调的代价。[^43]。 +在这种情况下,可串行化事务作为维护派生状态的一部分仍然是有用的,但它们只能在小范围内运行,在那里它们工作得很好[^8]。异构分布式事务(如 XA 事务,请参阅 “[实践中的分布式事务](/ch8#sec_transactions_xa)”)不是必需的。同步协调仍然可以在需要的地方引入(例如在无法恢复的操作之前强制执行严格的约束),但是如果只是应用的一小部分地方需要它,没必要让所有操作都付出协调的代价。[^43]。 另一种审视协调与约束的角度是:它们减少了由于不一致而必须做出的道歉数量,但也可能会降低系统的性能和可用性,从而可能增加由于宕机中断而需要做出的道歉数量。你不可能将道歉数量减少到零,但可以根据自己的需求寻找最佳平衡点 —— 既不存在太多不一致性,又不存在太多可用性问题。 @@ -643,9 +643,9 @@ ACID 意义下的一致性(请参阅 “[一致性](/ch8#sec_transactions_acid 如果一个事务在一个数据库中改变了多个对象,在这一事实发生后,很难说清这个事务到底意味着什么。即使你捕获了事务日志(请参阅 “[变更数据捕获](/ch12#sec_stream_cdc)”),各种表中的插入、更新和删除操作并不一定能清楚地表明 **为什么** 要执行这些变更。决定这些变更的是应用逻辑中的调用,而这一应用逻辑稍纵即逝,无法重现。 -相比之下,基于事件的系统可以提供更好的可审计性。在事件溯源方法中,系统的用户输入被表示为一个单一不可变事件,而任何其导致的状态变更都衍生自该事件。衍生可以实现为具有确定性与可重复性,因而相同的事件日志通过相同版本的衍生代码时,会导致相同的状态变更。 +相比之下,基于事件的系统可以提供更好的可审计性。在事件溯源方法中,系统的用户输入被表示为一个单一不可变事件,而任何其导致的状态变更都派生自该事件。派生可以实现为具有确定性与可重复性,因而相同的事件日志通过相同版本的派生代码时,会导致相同的状态变更。 -显式处理数据流(请参阅 “[批处理输出的哲学](/ch11#sec_batch_output)”)可以使数据的 **来龙去脉(provenance)** 更加清晰,从而使完整性检查更具可行性。对于事件日志,我们可以使用散列来检查事件存储没有被破坏。对于任何衍生状态,我们可以重新运行从事件日志中衍生它的批处理器与流处理器,以检查是否获得相同的结果,或者,甚至并行运行冗余的衍生流程。 +显式处理数据流(请参阅 “[批处理输出的哲学](/ch11#sec_batch_output)”)可以使数据的 **来龙去脉(provenance)** 更加清晰,从而使完整性检查更具可行性。对于事件日志,我们可以使用散列来检查事件存储没有被破坏。对于任何派生状态,我们可以重新运行从事件日志中派生它的批处理器与流处理器,以检查是否获得相同的结果,或者,甚至并行运行冗余的派生流程。 具有确定性且定义良好的数据流,也使调试与跟踪系统的执行变得容易,以便确定它 **为什么** 做了某些事情[^4][^69]。如果出现意想之外的事情,那么重现导致意外事件的确切事故现场的诊断能力 —— 一种时间旅行调试功能是非常有价值的。 @@ -653,7 +653,7 @@ ACID 意义下的一致性(请参阅 “[一致性](/ch8#sec_transactions_acid 如果我们不能完全相信系统的每个组件都不会损坏 —— 每一个硬件都没缺陷,每一个软件都没有 Bug —— 那我们至少必须定期检查数据的完整性。如果我们不检查,我们就不能发现损坏,直到无可挽回地导致对下游的破坏时,那时候再去追踪问题就要难得多,且代价也要高的多。 -检查数据系统的完整性,最好是以端到端的方式进行(请参阅 “[数据库的端到端原则](#数据库的端到端原则)”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个衍生数据管道端到端的正确性,那么沿着这一路径的任何磁盘、网络、服务以及算法的正确性检查都隐含在其中了。 +检查数据系统的完整性,最好是以端到端的方式进行(请参阅 “[数据库的端到端原则](#数据库的端到端原则)”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个派生数据管道端到端的正确性,那么沿着这一路径的任何磁盘、网络、服务以及算法的正确性检查都隐含在其中了。 持续的端到端完整性检查可以不断提高你对系统正确性的信心,从而使你能更快地进步[^70]。与自动化测试一样,审计提高了快速发现错误的可能性,从而降低了系统变更或新存储技术可能导致损失的风险。如果你不害怕进行变更,就可以更好地充分演化一个应用,使其满足不断变化的需求。 @@ -672,13 +672,13 @@ ACID 意义下的一致性(请参阅 “[一致性](/ch8#sec_transactions_acid 在本章中,我们讨论了设计数据系统的新方式,而且也包括了我的个人观点,以及对未来的猜测。我们从这样一种观察开始:没有单种工具能高效服务所有可能的用例,因此应用必须组合使用几种不同的软件才能实现其目标。我们讨论了如何使用批处理与事件流来解决这一 **数据集成(data integration)** 问题,以便让数据变更在不同系统之间流动。 -在这种方法中,某些系统被指定为记录系统,而其他数据则通过转换衍生自记录系统。通过这种方式,我们可以维护索引、物化视图、机器学习模型、统计摘要等等。通过使这些衍生和转换操作异步且松散耦合,能够防止一个区域中的问题扩散到系统中不相关部分,从而增加整个系统的稳健性与容错性。 +在这种方法中,某些系统被指定为记录系统,而其他数据则通过转换派生自记录系统。通过这种方式,我们可以维护索引、物化视图、机器学习模型、统计摘要等等。通过使这些派生和转换操作异步且松散耦合,能够防止一个区域中的问题扩散到系统中不相关部分,从而增加整个系统的稳健性与容错性。 -将数据流表示为从一个数据集到另一个数据集的转换也有助于演化应用程序:如果你想变更其中一个处理步骤,例如变更索引或缓存的结构,则可以在整个输入数据集上重新运行新的转换代码,以便重新衍生输出。同样,出现问题时,你也可以修复代码并重新处理数据以便恢复。 +将数据流表示为从一个数据集到另一个数据集的转换也有助于演化应用程序:如果你想变更其中一个处理步骤,例如变更索引或缓存的结构,则可以在整个输入数据集上重新运行新的转换代码,以便重新派生输出。同样,出现问题时,你也可以修复代码并重新处理数据以便恢复。 这些过程与数据库内部已经完成的过程非常类似,因此我们将数据流应用的概念重新改写为,**分拆(unbundling)** 数据库组件,并通过组合这些松散耦合的组件来构建应用程序。 -衍生状态可以通过观察底层数据的变更来更新。此外,衍生状态本身可以进一步被下游消费者观察。我们甚至可以将这种数据流一路传送至显示数据的终端用户设备,从而构建可动态更新以反映数据变更,并在离线时能继续工作的用户界面。 +派生状态可以通过观察底层数据的变更来更新。此外,派生状态本身可以进一步被下游消费者观察。我们甚至可以将这种数据流一路传送至显示数据的终端用户设备,从而构建可动态更新以反映数据变更,并在离线时能继续工作的用户界面。 接下来,我们讨论了如何确保所有这些处理在出现故障时保持正确。我们看到可伸缩的强完整性保证可以通过异步事件处理来实现,通过使用端到端操作标识符使操作幂等,以及通过异步检查约束。客户端可以等到检查通过,或者不等待继续前进,但是可能会冒有违反约束需要道歉的风险。这种方法比使用分布式事务的传统方法更具可伸缩性与可靠性,并且在实践中适用于很多业务流程。 diff --git a/content/zh/ch14.md b/content/zh/ch14.md index 77c8467..862e543 100644 --- a/content/zh/ch14.md +++ b/content/zh/ch14.md @@ -98,7 +98,7 @@ breadcrumbs: false 首先,我们应当问:追踪在哪种意义上是“必要的”?有些追踪形式确实直接用于改进用户功能:例如,追踪搜索结果点击率可提升搜索排序与相关性;追踪客户常一起购买哪些商品,可帮助网店推荐关联商品。然而,当追踪用户交互是为了内容推荐,或为了广告构建用户画像时,这是否真正在用户利益之中就不那么清楚了——还是说,它“必要”仅仅因为广告在为服务买单? -其次,用户对自己向我们的数据库“喂入”了哪些数据、这些数据如何被保留与处理,几乎没有认知——而多数隐私政策更多是在遮蔽而非阐明。用户若不了解其数据会发生什么,就无法给出有意义的同意。并且,某个用户的数据往往也会揭示并非该服务用户、也未同意任何条款的其他人。我们在本书这部分讨论过的那些衍生数据集——其中可能把全体用户数据与行为追踪及外部数据源结合——正是用户不可能形成有意义理解的数据类型。 +其次,用户对自己向我们的数据库“喂入”了哪些数据、这些数据如何被保留与处理,几乎没有认知——而多数隐私政策更多是在遮蔽而非阐明。用户若不了解其数据会发生什么,就无法给出有意义的同意。并且,某个用户的数据往往也会揭示并非该服务用户、也未同意任何条款的其他人。我们在本书这部分讨论过的那些派生数据集——其中可能把全体用户数据与行为追踪及外部数据源结合——正是用户不可能形成有意义理解的数据类型。 此外,数据从用户身上被抽取是单向过程,不是具有真实互惠的关系,也不是公平的价值交换。这里没有对话,没有让用户协商“提供多少数据、换取什么服务”的空间:服务与用户之间的关系高度不对称、单向度。规则由服务制定,而非用户 [^30], [^31]。 diff --git a/content/zh/ch2.md b/content/zh/ch2.md index 670aa02..f30314a 100644 --- a/content/zh/ch2.md +++ b/content/zh/ch2.md @@ -4,32 +4,34 @@ weight: 102 breadcrumbs: false --- + + ![](/map/ch01.png) -> *互联网做得如此之好,以至于大多数人都把它想象成像太平洋一样的自然资源,而不是人造的东西。上一次出现这种规模且无差错的技术是什么时候?* +> *互联网做得太好了,以至于大多数人把它看成像太平洋那样的自然资源,而不是人造产物。上一次出现这种规模且几乎无差错的技术是什么时候?* > > [艾伦・凯](https://www.drdobbs.com/architecture-and-design/interview-with-alan-kay/240003442), > 在接受 *Dr Dobb's Journal* 采访时(2012 年) -如果你正在构建一个应用程序,你将会被一系列需求所驱动。在你的需求列表中,最重要的可能是应用程序必须提供的功能:需要哪些界面和按钮,以及每个操作应该做什么,以实现软件的目的。这些是你的 ***功能性需求***。 +构建一个应用时,你通常会从一张需求清单开始。清单最上面的,往往是应用必须提供的功能:需要哪些页面和按钮,每个操作应该完成什么行为,才能实现软件的目标。这些就是 ***功能性需求***。 -此外,你可能还有一些 ***非功能性需求***:例如,应用程序应该快速、可靠、安全、合规,并且易于维护。这些需求可能没有明确写下来,因为它们看起来有些显而易见,但它们与应用程序的功能同样重要:一个慢得让人无法忍受或不可靠的应用程序还不如不存在。 +此外,你通常还会有一些 ***非功能性需求***:例如,应用应当足够快、足够可靠、足够安全、符合法规,而且易于维护。这些需求可能并没有明确写下来,因为它们看起来像是“常识”,但它们与功能需求同样重要。一个慢得无法忍受、或频繁出错的应用,几乎等于不存在。 -许多非功能性需求,比如安全性,超出了本书的范围。但我们将考虑一些非功能性需求,本章将帮助你为自己的系统阐明它们: +许多非功能性需求(比如安全)超出了本书范围。但本章会讨论其中几项核心要求,并帮助你用更清晰的方式描述自己的系统: -* 如何定义和衡量系统的 **性能**(参见 ["描述性能"](/ch2#sec_introduction_percentiles)); -* 服务 **可靠** 意味着什么——即即使出现问题也能继续正确工作(参见 ["可靠性与容错"](/ch2#sec_introduction_reliability)); -* 通过在系统负载增长时添加计算能力的有效方法,使系统具有 **可伸缩性**(参见 ["可伸缩性"](/ch2#sec_introduction_scalability));以及 -* 使系统长期更 **易于维护**(参见 ["可维护性"](/ch2#sec_introduction_maintainability))。 +* 如何定义并衡量系统的 **性能**(参见 ["描述性能"](/ch2#sec_introduction_percentiles)); +* 服务 **可靠** 到底意味着什么:也就是在出错时仍能持续正确工作(参见 ["可靠性与容错"](/ch2#sec_introduction_reliability)); +* 如何通过高效增加计算资源,让系统在负载增长时保持 **可伸缩性**(参见 ["可伸缩性"](/ch2#sec_introduction_scalability));以及 +* 如何让系统在长期演进中保持 **可维护性**(参见 ["可维护性"](/ch2#sec_introduction_maintainability))。 -本章介绍的术语在后续章节中也很有用,当我们深入研究数据密集型系统的实现细节时。然而,抽象定义可能相当枯燥;为了使这些想法更具体,我们将从一个案例研究开始本章,研究社交网络服务可能如何工作,这将提供性能和可伸缩性的实际案例。 +本章引入的术语,在后续章节深入实现细节时也会反复用到。不过纯定义往往比较抽象。为了把概念落到实处,本章先从一个案例研究开始:看看社交网络服务可能如何实现,并借此讨论性能与可伸缩性问题。 ## 案例研究:社交网络首页时间线 {#sec_introduction_twitter} -想象一下,你被赋予了实现一个类似 X(前身为 Twitter)风格的社交网络的任务,用户可以发布消息并关注其他用户。这将是对这种服务实际工作方式的巨大简化 [^1] [^2] [^3],但它将有助于说明大规模系统中出现的一些问题。 +假设你要实现一个类似 X(原 Twitter)的社交网络:用户可以发帖,并追随其他用户。这会极大简化真实系统的实现方式 [^1] [^2] [^3],但足以说明大规模系统会遇到的一些关键问题。 -假设用户每天发布 5 亿条帖子,或平均每秒 5,700 条帖子。偶尔,速率可能飙升至每秒 150,000 条帖子 [^4]。我们还假设平均每个用户关注 200 人并有 200 个粉丝(尽管实际的范围要大得多:大多数人只有少数粉丝,而少数名人如巴拉克・奥巴马有超过 1 亿粉丝)。 +我们假设:用户每天发帖 5 亿条,平均每秒约 5,700 条;在特殊事件期间,峰值可能冲到每秒 150,000 条 [^4]。再假设平均每位用户追随 200 人,并有 200 名追随者(实际分布非常不均匀:大多数人只有少量追随者,少数名人如巴拉克・奥巴马则有上亿追随者)。 ### 表示用户、帖子与关注关系 {#id20} @@ -37,7 +39,7 @@ breadcrumbs: false {{< figure src="/fig/ddia_0201.png" id="fig_twitter_relational" caption="图 2-1. 社交网络的简单关系模式,用户可以相互关注。" class="w-full my-4" >}} -假设我们的社交网络必须支持的主要读取操作是 *首页时间线*,它显示你关注的人最近发布的帖子(为简单起见,我们将忽略广告、来自你未关注的人的推荐帖子和其他扩展)。我们可以编写以下 SQL 查询来获取特定用户的首页时间线: +假设该社交网络最重要的读操作是 *首页时间线*:展示你所追随的人最近发布的帖子(为简化起见,我们忽略广告、未追随用户的推荐帖,以及其他扩展功能)。获取某个用户首页时间线的 SQL 可能如下: ```sql SELECT posts.*, users.* FROM posts @@ -50,42 +52,42 @@ SELECT posts.*, users.* FROM posts 要执行此查询,数据库将使用 `follows` 表找到 `current_user` 关注的所有人,查找这些用户最近的帖子,并按时间戳排序以获取被关注用户的最新 1,000 条帖子。 -帖子应该是及时的,所以假设在某人发布帖子后,我们希望他们的粉丝能够在 5 秒内看到它。一种方法是让用户的客户端每 5 秒重复上述查询(这称为 *轮询*)。如果我们假设有 1000 万用户同时在线登录,这意味着每秒运行 200 万次查询。即使增加轮询间隔,这也是很大的负载。 +帖子具有时效性。我们假设:某人发帖后,追随者应在 5 秒内看到。一个做法是客户端每 5 秒重复执行一次上述查询(即 *轮询*)。如果同时在线登录用户有 1000 万,就意味着每秒要执行 200 万次查询。即使把轮询间隔调大,这个量也很可观。 -此外,上述查询相当昂贵:如果你关注 200 人,它需要获取这 200 人中每个人的最近帖子列表,并合并这些列表。每秒 200 万次时间线查询意味着数据库需要每秒查找某个发送者的最近帖子 4 亿次——这是一个巨大的数字。这是平均情况。一些用户关注数万个账户;对他们来说,这个查询执行起来非常昂贵,而且很难快速完成。 +此外,这个查询本身也很昂贵。若你追随 200 人,系统就要分别抓取这 200 人的近期帖子列表,再把它们归并。每秒 200 万次时间线查询,等价于数据库每秒要执行约 4 亿次“按发送者查最近帖子”。这还只是平均情况。少数用户会追随数万账户,这个查询对他们尤其昂贵,也更难做快。 ### 时间线的物化与更新 {#sec_introduction_materializing} -我们如何做得更好?首先,与其轮询,不如服务器主动向当前在线的任何粉丝推送新帖子。其次,我们应该预先计算上述查询的结果,以便可以从缓存中提供用户的首页时间线请求。 +要如何优化?第一,与其轮询,不如由服务器主动向在线追随者推送新帖。第二,我们应该预先计算上述查询结果,让首页时间线请求可以直接从缓存返回。 -想象一下,我们为每个用户存储一个包含其首页时间线的数据结构,即他们关注的人的最近帖子。每次用户发布帖子时,我们查找他们的所有粉丝,并将该帖子插入到每个粉丝的首页时间线中——就像向邮箱投递消息一样。现在当用户登录时,我们可以简单地给他们这个预先计算的首页时间线。此外,要接收时间线上任何新帖子的通知,用户的客户端只需订阅添加到其首页时间线的帖子流。 +设想我们为每个用户维护一个数据结构,保存其首页时间线,也就是其所追随者的近期帖子。每当用户发帖,我们就找出其所有追随者,把这条帖子插入每个追随者的首页时间线中,就像往邮箱里投递信件。这样用户登录时,可以直接读取预先算好的时间线。若要接收新帖提醒,客户端只需订阅“写入该时间线”的帖子流即可。 -这种方法的缺点是,现在每次用户发布帖子时我们需要做更多的工作,因为首页时间线是需要更新的派生数据。该过程如 [图 2-2](/ch2#fig_twitter_timelines) 所示。当一个初始请求导致几个下游请求被执行时,我们使用术语 *扇出* 来描述请求数量增加的因子。 +这种方法的缺点是:每次发帖时都要做更多工作,因为首页时间线属于需要持续更新的派生数据。这个过程见 [图 2-2](/ch2#fig_twitter_timelines)。当一个初始请求触发多个下游请求时,我们用 *扇出* 描述请求数量被放大的倍数。 -{{< figure src="/fig/ddia_0202.png" id="fig_twitter_timelines" caption="图 2-2. 扇出:将新帖子传递给发布帖子的用户的每个粉丝。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0202.png" id="fig_twitter_timelines" caption="图 2-2. 扇出:将新帖子传递给发布帖子的用户的每个追随者。" class="w-full my-4" >}} -以每秒 5,700 条帖子的速率,如果平均帖子到达 200 个粉丝(即扇出因子为 200),我们将需要每秒执行超过 100 万次首页时间线写入。这很多,但与我们本来需要的每秒 4 亿次每个发送者的帖子查找相比,这仍然是一个显著的节省。 +按每秒 5,700 条帖子计算,若平均每条帖到达 200 名追随者(扇出因子 200),则每秒需要略高于 100 万次首页时间线写入。这已经很多,但相比原先每秒 4 亿次“按发送者查帖”,仍是显著优化。 -如果由于某些特殊事件导致帖子速率激增,我们不必立即进行时间线交付——我们可以将它们排队,并接受帖子在粉丝的时间线中显示会暂时花费更长时间。即使在这种负载峰值期间,时间线仍然可以快速加载,因为我们只是从缓存中提供它们。 +如果遇到特殊事件导致发帖速率激增,我们不必立刻完成时间线投递。可以先入队,接受“帖子出现在追随者时间线中”会暂时变慢。即便在这种峰值期,时间线加载仍然很快,因为读取仍来自缓存。 -这种预先计算和更新查询结果的过程称为 *物化*,时间线缓存是 *物化视图* 的一个例子(我们将在 [待补充链接] 中进一步讨论这个概念)。物化视图加速了读取,但作为交换,我们必须在写入时做更多的工作。对于大多数用户来说,写入成本是适中的,但社交网络还必须考虑一些极端情况: +这种预先计算并持续更新查询结果的过程称为 *物化*。时间线缓存就是一种 *物化视图*(这个概念见 [“维护物化视图”](/ch12#sec_stream_mat_view))。物化视图能加速读取,但代价是写入侧工作量增加。对大多数用户而言,这个写入成本仍可接受,但社交网络还要处理一些极端情况: -* 如果用户关注非常多的账户,并且这些账户发布很多内容,该用户的物化时间线将有很高的写入率。然而,在这种情况下,用户实际上不太可能阅读其时间线中的所有帖子,因此可以简单地丢弃其时间线的一些写入,只向用户显示他们关注的账户的帖子样本 [^5]。 -* 当拥有大量粉丝的名人账户发布帖子时,我们必须做大量工作将该帖子插入到他们数百万粉丝的每个首页时间线中。在这种情况下,丢弃一些写入是不可接受的。解决这个问题的一种方法是将名人帖子与其他人的帖子分开处理:我们可以通过将名人帖子单独存储并在读取时与物化时间线合并,来节省将它们添加到数百万时间线的工作。尽管有这些优化,处理社交网络上的名人仍然需要大量基础设施 [^6]。 +* 如果某用户追随了大量账户,且这些账户发帖频繁,那么该用户的物化时间线写入率会很高。但在这种场景下,用户通常也看不完全部帖子,因此可以丢弃部分时间线写入,只展示其追随账户帖子的一部分样本 [^5]。 +* 如果一个拥有海量追随者的名人账号发帖,我们需要把这条帖子写入其数百万追随者的首页时间线,工作量极大。此时不能随意丢写。常见做法是把名人帖子与普通帖子分开处理:名人帖单独存储,读取时间线时再与物化时间线合并,从而省去写入数百万条时间线的成本。即便如此,服务名人账号仍需大量基础设施 [^6]。 ## 描述性能 {#sec_introduction_percentiles} -大多数关于软件性能的讨论都考虑两种主要的度量类型: +软件性能通常围绕两类指标展开: 响应时间 -: 从用户发出请求到收到请求应答所经过的时间。测量单位是秒(或毫秒,或微秒)。 +: 从用户发出请求到收到响应所经历的时间。单位是秒(或毫秒、微秒)。 吞吐量 -: 系统正在处理的每秒请求数,或每秒数据量。对于给定的硬件资源分配,存在可以处理的 *最大吞吐量*。测量单位是"每秒某物"。 +: 系统每秒可处理的请求数或数据量。对于给定硬件资源,系统存在一个可处理的 *最大吞吐量*。单位是“每秒某种工作量”。 -在社交网络案例研究中,"每秒帖子数"和"每秒时间线写入数"是吞吐量指标,而"加载首页时间线所需的时间"或"帖子传递给粉丝的时间"是响应时间指标。 +在社交网络案例中,“每秒帖子数”和“每秒时间线写入数”属于吞吐量指标;“加载首页时间线所需时间”或“帖子送达追随者所需时间”属于响应时间指标。 -吞吐量和响应时间之间通常存在联系;在线服务的这种关系示例如 [图 2-3](/ch2#fig_throughput) 所示。当请求吞吐量较低时,服务具有较低的响应时间,但随着负载增加,响应时间也会增加。这是因为 *排队*:当请求到达高负载系统时,CPU 很可能已经在处理先前的请求,因此传入请求需要等待先前请求完成。随着吞吐量接近硬件可以处理的最大值,排队延迟急剧增加。 +吞吐量和响应时间之间通常相关。在线服务的典型关系如 [图 2-3](/ch2#fig_throughput):低吞吐量时响应时间较低,负载升高后响应时间上升。原因是 *排队*。请求到达高负载系统时,CPU 往往已在处理前一个请求,新请求只能等待;当吞吐量逼近硬件上限,排队延迟会急剧上升。 {{< figure src="/fig/ddia_0203.png" id="fig_throughput" caption="图 2-3. 随着服务的吞吐量接近其容量,由于排队,响应时间急剧增加。" class="w-full my-4" >}} @@ -93,126 +95,126 @@ SELECT posts.*, users.* FROM posts > [!TIP] 当过载系统无法恢复时 -如果系统接近过载,吞吐量被推到极限附近,它有时会进入恶性循环,变得效率更低,从而更加过载。例如,如果有很长的请求队列等待处理,响应时间可能会增加到客户端超时并重新发送请求的程度。这导致请求率进一步增加,使问题变得更糟——*重试风暴*。即使负载再次降低,这样的系统也可能保持过载状态,直到重新启动或以其他方式重置。这种现象称为 *亚稳态故障(Metastable Failure)*,它可能导致生产系统的严重中断 [^7] [^8]。 +如果系统已接近过载、吞吐量逼近极限,有时会进入恶性循环:效率下降,进而更加过载。例如,请求队列很长时,响应时间可能高到让客户端超时并重发请求,导致请求速率进一步上升,问题持续恶化,形成 *重试风暴*。即使负载后来回落,系统也可能仍卡在过载状态,直到重启或重置。这种现象叫 *亚稳态故障*(Metastable Failure),可能引发严重生产故障 [^7] [^8]。 -为了避免重试使服务过载,你可以在客户端增加并随机化连续重试之间的时间(*指数退避* [^9] [^10]),并暂时停止向最近返回错误或超时的服务发送请求(使用 *熔断器* [^11] [^12] 或 *令牌桶* 算法 [^13])。服务器还可以检测何时接近过载并开始主动拒绝请求(*负载卸除* [^14]),并发送响应要求客户端减速(*背压* [^1] [^15])。排队和负载均衡算法的选择也可能产生影响 [^16]。 +为了避免重试把服务拖垮,可以在客户端拉大并随机化重试间隔(*指数退避* [^9] [^10]),并临时停止向近期报错或超时的服务发请求(例如 *熔断器* [^11] [^12] 或 *令牌桶* [^13])。服务端也可在接近过载时主动拒绝请求(*负载卸除* [^14]),并通过响应要求客户端降速(*背压* [^1] [^15])。此外,排队与负载均衡算法的选择也会影响结果 [^16]。 -------- -就性能指标而言,响应时间通常是用户最关心的,而吞吐量决定了所需的计算资源(例如,你需要多少服务器),因此决定了服务特定工作负载的成本。如果吞吐量可能会增长超出当前硬件可以处理的范围,则需要扩展容量;如果系统的最大吞吐量可以通过添加计算资源显著增加,则称系统为 *可伸缩的*。 +从性能指标角度看,用户通常最关心响应时间;而吞吐量决定了所需计算资源(例如服务器数量),从而决定承载特定工作负载的成本。如果吞吐量增长可能超过当前硬件上限,就必须扩容;若系统可通过增加计算资源显著提升最大吞吐量,就称其 *可伸缩*。 -在本节中,我们将主要关注响应时间,我们将在 ["可伸缩性"](/ch2#sec_introduction_scalability) 中回到吞吐量和可伸缩性。 +本节主要讨论响应时间;吞吐量与可伸缩性会在 ["可伸缩性"](/ch2#sec_introduction_scalability) 一节再展开。 ### 延迟与响应时间 {#id23} -"延迟"和"响应时间"有时可互换使用,但在本书中我们将以特定方式使用这些术语(如 [图 2-4](/ch2#fig_response_time) 所示): +“延迟”和“响应时间”有时会混用,但本书对它们有明确区分(见 [图 2-4](/ch2#fig_response_time)): -* *响应时间* 是客户端看到的;它包括系统中任何地方产生的所有延迟。 -* *服务时间* 是服务主动处理用户请求的持续时间。 -* *排队延迟* 可能发生在流程中的几个点:例如,在收到请求后,它可能需要等待直到 CPU 可用才能被处理;如果同一台机器上的其他任务通过出站网络接口发送大量数据,响应数据包可能需要在发送之前进行缓冲。 -* *延迟* 是一个涵盖请求未被主动处理时间的总称,即在此期间它是 *潜在的*。特别是,*网络延迟* 或 *网络延迟* 指的是请求和响应在网络中传输所花费的时间。 +* *响应时间* 是客户端看到的总时间,包含链路上各处产生的全部延迟。 +* *服务时间* 是服务主动处理该请求的时间。 +* *排队延迟* 可发生在流程中的多个位置。例如请求到达后,可能要等 CPU 空出来才能处理;同机其他任务若占满出站网卡,响应包也可能先在缓冲区等待发送。 +* *延迟* 是对“请求未被主动处理这段时间”的统称,也就是请求处于 *潜伏(latent)* 状态的时间。尤其是 *网络延迟*(或网络时延)指请求与响应在网络中传播所花的时间。 {{< figure src="/fig/ddia_0204.png" id="fig_response_time" caption="图 2-4. 响应时间、服务时间、网络延迟和排队延迟。" class="w-full my-4" >}} -在 [图 2-4](/ch2#fig_response_time) 中,时间从左到右流动,每个通信节点显示为水平线,请求或响应消息显示为从一个节点到另一个节点的粗对角箭头。你将在本书中经常遇到这种风格的图表。 +在 [图 2-4](/ch2#fig_response_time) 中,时间从左向右流动。每个通信节点画成一条水平线,请求/响应消息画成节点间的粗斜箭头。本书后文会频繁使用这种图示风格。 -响应时间可能会因请求而异,即使你一遍又一遍地发出相同的请求。许多因素可能会增加随机延迟:例如,上下文切换到后台进程、网络数据包丢失和 TCP 重传、垃圾回收暂停、强制从磁盘读取的缺页错误、服务器机架中的机械振动 [^17],或许多其他原因。我们将在 ["超时与无界延迟"](/ch9#sec_distributed_queueing) 中更详细地讨论这个主题。 +即便反复发送同一个请求,响应时间也可能显著波动。许多因素都会引入随机延迟:例如切换到后台进程、网络丢包与 TCP 重传、垃圾回收暂停、缺页导致的磁盘读取、服务器机架机械振动 [^17] 等。我们会在 ["超时与无界延迟"](/ch9#sec_distributed_queueing) 进一步讨论这个问题。 -排队延迟通常占响应时间变化的很大一部分。由于服务器只能并行处理少量事务(例如,受其 CPU 核心数的限制),只需要少量慢请求就可以阻塞后续请求的处理——这种效应称为 *队头阻塞*。即使那些后续请求的服务时间很快,由于等待先前请求完成的时间,客户端仍会看到缓慢的整体响应时间。排队延迟不是服务时间的一部分,因此在客户端测量响应时间很重要。 +排队延迟常常是响应时间波动的主要来源。服务器并行处理能力有限(例如受 CPU 核数约束),少量慢请求就可能堵住后续请求,这就是 *头部阻塞*。即便后续请求本身服务时间很短,客户端仍会因为等待前序请求而看到较慢的总体响应。排队延迟不属于服务时间,因此必须在客户端侧测量响应时间。 -### 平均值、中位数与百分位数 {#id24} +### 平均值、中位数与百分位点 {#id24} -因为响应时间因请求而异,我们需要将其视为值的 *分布*,而不是单个数字。在 [图 2-5](/ch2#fig_lognormal) 中,每个灰色条表示对服务的请求,其高度显示该请求花费的时间。大多数请求相当快,但偶尔会有 *异常值* 需要更长时间。网络延迟的变化也称为 *抖动*。 +由于响应时间会随请求变化,我们应将其看作一个可测量的 *分布*,而非单一数字。在 [图 2-5](/ch2#fig_lognormal) 中,每个灰色柱表示一次请求,柱高是该请求耗时。大多数请求较快,但会有少量更慢的 *异常值*。网络时延波动也常称为 *抖动*。 -{{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="图 2-5. 说明平均值和百分位数:100 个服务请求的响应时间样本。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="图 2-5. 说明平均值和百分位点:100 个服务请求的响应时间样本。" class="w-full my-4" >}} -报告服务的 *平均* 响应时间是常见的(技术上是 *算术平均值*:即,将所有响应时间相加,然后除以请求数)。平均响应时间对于估计吞吐量限制很有用 [^18]。然而,如果你想知道你的"典型"响应时间,平均值不是一个很好的指标,因为它不能告诉你有多少用户实际经历了那种延迟。 +报告服务 *平均* 响应时间很常见(严格说是 *算术平均值*:总响应时间除以请求数)。平均值对估算吞吐量上限有帮助 [^18]。但若你想知道“典型”响应时间,平均值并不理想,因为它不能反映到底有多少用户经历了这种延迟。 -通常使用 *百分位数* 更好。如果你将响应时间列表从最快到最慢排序,那么 *中位数* 就在中间:例如,如果你的中位响应时间是 200 毫秒,这意味着一半的请求在不到 200 毫秒内返回,一半的请求花费的时间更长。这使得中位数成为了解用户通常需要等待多长时间的良好指标。中位数也称为 *第 50 百分位*,有时缩写为 *p50*。 +通常,*百分位点* 更有意义。把响应时间从快到慢排序,*中位数* 位于中间。例如中位响应时间为 200 毫秒,表示一半请求在 200 毫秒内返回,另一半更慢。因此中位数适合衡量用户“通常要等多久”。中位数也称 *第 50 百分位*,常记为 *p50*。 -为了弄清异常值有多糟糕,你可以查看更高的百分位数:*第 95*、*99* 和 *99.9* 百分位数很常见(缩写为 *p95*、*p99* 和 *p999*)。它们是 95%、99% 或 99.9% 的请求比该特定阈值快的响应时间阈值。例如,如果第 95 百分位响应时间是 1.5 秒,这意味着 100 个请求中的 95 个花费不到 1.5 秒,100 个请求中的 5 个花费 1.5 秒或更长时间。这在 [图 2-5](/ch2#fig_lognormal) 中有所说明。 +为了看清异常值有多糟,需要观察更高百分位点:常见的是 *p95*、*p99*、*p999*。它们表示 95%、99%、99.9% 的请求都快于该阈值。例如 p95 为 1.5 秒,表示 100 个请求里有 95 个小于 1.5 秒,另外 5 个不小于 1.5 秒。[图 2-5](/ch2#fig_lognormal) 展示了这一点。 -响应时间的高百分位数,也称为 *尾部延迟*,很重要,因为它们直接影响用户的服务体验。例如,亚马逊在描述内部服务的响应时间要求时使用第 99.9 百分位,即使它只影响 1,000 个请求中的 1 个。这是因为请求最慢的客户通常是那些账户上数据最多的客户,因为他们进行了许多购买——也就是说,他们是最有价值的客户 [^19]。确保网站对他们来说速度快对于保持这些客户的满意度很重要。 +响应时间的高百分位点(也叫 *尾部延迟*)非常重要,因为它直接影响用户体验。例如亚马逊内部服务常以第 99.9 百分位设定响应要求,尽管它只影响 1/1000 的请求。原因是最慢请求往往来自“账户数据最多”的客户,他们通常也是最有价值客户 [^19]。让这批用户也能获得快速响应,对业务很关键。 -另一方面,优化第 99.99 百分位(10,000 个请求中最慢的 1 个)被认为太昂贵,对亚马逊的目的没有足够的好处。在非常高的百分位数上减少响应时间很困难,因为它们很容易受到你无法控制的随机事件的影响,而且收益递减。 +另一方面,继续优化到第 99.99 百分位(最慢的万分之一请求)通常成本过高、收益有限。越到高百分位,越容易受不可控随机因素影响,也更符合边际收益递减规律。 -------- > [!TIP] 响应时间对用户的影响 -直觉上似乎很明显,快速服务比慢速服务对用户更好 [^20]。然而,要获得可靠的数据来量化延迟对用户行为的影响是令人惊讶地困难的。 +直觉上,快服务当然比慢服务更好 [^20]。但真正要拿到“延迟如何影响用户行为”的可靠量化数据,其实非常困难。 -一些经常被引用的统计数据是不可靠的。2006 年,谷歌报告说,搜索结果从 400 毫秒减慢到 900 毫秒与流量和收入下降 20% 相关 [^21]。然而,2009 年谷歌的另一项研究报告说,延迟增加 400 毫秒导致每天搜索减少仅 0.6% [^22],同年必应发现加载时间增加两秒将广告收入减少 4.3% [^23]。这些公司的较新数据似乎没有公开。 +一些被频繁引用的统计并不可靠。2006 年,Google 曾报告:搜索结果从 400 毫秒变慢到 900 毫秒,与流量和收入下降 20% 相关 [^21]。但 2009 年 Google 另一项研究又称,延迟增加 400 毫秒仅导致日搜索量下降 0.6% [^22];同年 Bing 发现,加载时间增加 2 秒会让广告收入下降 4.3% [^23]。这些公司的更新数据似乎并未公开。 -Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒将电子商务网站的转化率降低多达 7%;然而,仔细检查后,同一研究显示,非常 *快* 的页面加载时间也与较低的转化率相关!这个看似矛盾的结果是因为加载最快的页面通常是那些没有有用内容的页面(例如,404 错误页面)。然而,由于该研究没有努力将页面内容的影响与加载时间的影响分开,其结果可能没有意义。 +Akamai 的一项较新研究 [^24] 声称:响应时间增加 100 毫秒会让电商网站转化率最多下降 7%。但细看可知,同一研究也显示“加载极快”的页面同样和较低转化率相关。这个看似矛盾的结果,很可能是因为加载最快的页面往往是“无有效内容”的页面(如 404)。而该研究并未把“页面内容影响”和“加载时间影响”区分开,因此结论可能并不可靠。 -雅虎的一项研究 [^25] 比较了快速加载与慢速加载搜索结果的点击率,控制了搜索结果的质量。它发现当快速和慢速响应之间的差异为 1.25 秒或更多时,快速搜索的点击次数增加 20-30%。 +Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加载对点击率的影响。结果显示:当快慢响应差异达到 1.25 秒或以上时,快速搜索的点击量会高出 20%–30%。 -------- ### 响应时间指标的应用 {#sec_introduction_slo_sla} -高百分位数在被多次调用作为服务单个最终用户请求的一部分的后端服务中尤其重要。即使你并行进行调用,最终用户请求仍然需要等待最慢的并行调用完成。只需要一个慢调用就可以使整个最终用户请求变慢,如 [图 2-6](/ch2#fig_tail_amplification) 所示。即使只有一小部分后端调用很慢,如果最终用户请求需要多个后端调用,获得慢调用的机会就会增加,因此更高比例的最终用户请求最终会变慢(这种效应称为 *尾部延迟放大* [^26])。 +对于“一个终端请求会触发多次后端调用”的服务,高百分位点尤其关键。即使并行调用,终端请求仍要等待最慢的那个返回。正如 [图 2-6](/ch2#fig_tail_amplification) 所示,只要一个调用慢,就能拖慢整个终端请求。即便慢调用比例很小,只要后端调用次数变多,撞上慢调用的概率就会上升,于是更大比例的终端请求会变慢(称为 *尾部延迟放大* [^26])。 {{< figure src="/fig/ddia_0206.png" id="fig_tail_amplification" caption="图 2-6. 当需要几个后端调用来服务请求时,只需要一个慢的后端请求就可以减慢整个最终用户请求。" class="w-full my-4" >}} -百分位数通常用于 *服务级别目标*(SLO)和 *服务级别协议*(SLA),作为定义服务预期性能和可用性的方式 [^27]。例如,SLO 可能设定服务的中位响应时间小于 200 毫秒且第 99 百分位低于 1 秒的目标,以及至少 99.9% 的有效请求产生非错误响应的目标。SLA 是一份合同,规定如果不满足 SLO 会发生什么(例如,客户可能有权获得退款)。这至少是基本想法;实际上,为 SLO 和 SLA 定义良好的可用性指标并不简单 [^28] [^29]。 +百分位点也常用于定义 *服务级别目标*(SLO)和 *服务级别协议*(SLA)[^27]。例如,一个 SLO 可能要求:中位响应时间低于 200 毫秒、p99 低于 1 秒,并且至少 99.9% 的有效请求返回非错误响应。SLA 则是“未达成 SLO 时如何处理”的合同条款(例如客户可获赔偿)。这是基本思路;但在实践中,为 SLO/SLA 设计合理可用性指标并不容易 [^28] [^29]。 -------- -> [!TIP] 计算百分位数 +> [!TIP] 计算百分位点 -如果你想将响应时间百分位数添加到服务的监控仪表板中,你需要持续有效地计算它们。例如,你可能希望保留过去 10 分钟内请求的响应时间的滚动窗口。每分钟,你计算该窗口中值的中位数和各种百分位数,并在图表上绘制这些指标。 +如果你想在监控面板中展示响应时间百分位点,就需要持续且高效地计算它们。例如,维护“最近 10 分钟请求响应时间”的滚动窗口,每分钟计算一次该窗口内的中位数与各百分位点,并绘图展示。 -最简单的实现是在时间窗口内保留所有请求的响应时间列表,并每分钟对该列表进行排序。如果这对你来说效率太低,有一些算法可以以最小的 CPU 和内存成本计算百分位数的良好近似值。开源百分位数估计库包括 HdrHistogram、t-digest [^30] [^31]、OpenHistogram [^32] 和 DDSketch [^33]。 +最简单的实现是保存窗口内全部请求的响应时间,并每分钟排序一次。若效率不够,可以用一些低 CPU/内存开销的算法来近似计算百分位点。常见开源库包括 HdrHistogram、t-digest [^30] [^31]、OpenHistogram [^32] 和 DDSketch [^33]。 -请注意,平均百分位数,例如,减少时间分辨率或组合来自多台机器的数据,在数学上是没有意义的——聚合响应时间数据的正确方法是添加直方图 [^34]。 +要注意,“对百分位点再取平均”(例如降低时间分辨率,或合并多机器数据)在数学上没有意义。聚合响应时间数据的正确方式是聚合直方图 [^34]。 -------- ## 可靠性与容错 {#sec_introduction_reliability} -每个人都对某物是否可靠或不可靠有直观的想法。对于软件,典型的期望包括: +每个人对“可靠”与“不可靠”都有直觉。对软件而言,典型期望包括: -* 应用程序执行用户期望的功能。 -* 它可以容忍用户犯错误或以意想不到的方式使用软件。 -* 在预期的负载和数据量下,其性能足以满足所需的用例。 -* 系统防止任何未经授权的访问和滥用。 +* 应用能完成用户预期的功能。 +* 能容忍用户犯错,或以意料之外的方式使用软件。 +* 在预期负载与数据规模下,性能足以支撑目标用例。 +* 能防止未授权访问与滥用。 -如果所有这些加在一起意味着"正确工作",那么我们可以将 *可靠性* 大致理解为"即使出现问题也能继续正确工作"。为了更准确地说明出现问题,我们将区分 *故障* 和 *失效* [^35] [^36] [^37]: +如果把这些合起来称为“正确工作”,那么 *可靠性* 可以粗略理解为:即使出现问题,系统仍能持续正确工作。为了更精确地描述“出问题”,我们区分 *故障* 与 *失效* [^35] [^36] [^37]: 故障 -: 故障是指系统的某个特定 *部分* 停止正确工作:例如,如果单个硬盘驱动器发生故障,或单台机器崩溃,或外部服务(系统所依赖的)发生中断。 +: 指系统某个 *局部组件* 停止正常工作:例如单个硬盘损坏、单台机器宕机,或系统依赖的外部服务中断。 失效 -: 失效是指 *整个* 系统停止向用户提供所需的服务;换句话说,当它不满足服务级别目标(SLO)时。 +: 指 *整个系统* 无法继续向用户提供所需服务;换言之,系统未满足服务级别目标(SLO)。 -故障和失效之间的区别可能会令人困惑,因为它们在不同层面上是同一件事。例如,如果硬盘驱动器停止工作,我们说硬盘驱动器已失效:如果系统仅由该一个硬盘驱动器组成,它已停止提供所需的服务。然而,如果你正在谈论的系统包含许多硬盘驱动器,那么从更大系统的角度来看,单个硬盘驱动器的失效只是一个故障,并且更大的系统可能能够通过在另一个硬盘驱动器上拥有数据副本来容忍该故障。 +“故障”与“失效”的区别容易混淆,因为它们本质上是同一件事在不同层级上的表述。比如一个硬盘坏了,对“硬盘这个系统”来说是失效;但对“由许多硬盘组成的更大系统”来说,它只是一个故障。更大系统若在其他硬盘上有副本,就可能容忍该故障。 ### 容错 {#id27} 如果系统在发生某些故障时仍继续向用户提供所需的服务,我们称系统为 *容错的*。如果系统不能容忍某个部分变得有故障,我们称该部分为 *单点故障*(SPOF),因为该部分的故障会升级导致整个系统的失效。 -例如,在社交网络案例研究中,可能发生的故障是在扇出过程中,参与更新物化时间线的机器崩溃或变得不可用。为了使这个过程容错,我们需要确保另一台机器可以接管这项任务,而不会错过任何应该交付的帖子,也不会复制任何帖子。(这个想法被称为 *精确一次语义*,我们将在 [待补充链接] 中详细研究它。) +例如在社交网络案例中,扇出流程里可能有机器崩溃或不可用,导致物化时间线更新中断。若要让该流程具备容错性,就必须保证有其他机器可接管任务,同时既不漏投帖子,也不重复投递。(这个思想称为 *恰好一次语义*,我们会在 [“数据库的端到端论证”](/ch13#sec_future_end_to_end) 中详细讨论。) -容错总是限于某些类型的某些数量的故障。例如,系统可能能够容忍最多两个硬盘驱动器同时故障,或最多三个节点中的一个崩溃。如果所有节点都崩溃,没有什么可以做的,这没有意义容忍任何数量的故障。如果整个地球(及其上的所有服务器)被黑洞吞噬,容忍该故障将需要在太空中进行网络托管——祝你获得批准该预算项目的好运。 +容错能力总是“有边界”的:它只针对某些类型、某个数量以内的故障。例如系统可能最多容忍 2 块硬盘同时故障,或 3 个节点里坏 1 个。若全部节点都崩溃,就无计可施,因此“容忍任意数量故障”并无意义。要是地球和上面的服务器都被黑洞吞噬,那就只能去太空托管了,预算审批祝你好运。 -反直觉地是,在这种容错系统中,通过故意触发故障来 *增加* 故障率是有意义的——例如,在没有警告的情况下随机杀死单个进程。这称为 *故障注入*。许多关键错误实际上是由于错误处理不当造成的 [^38];通过故意引发故障,你确保容错机制不断得到锻炼和测试,这可以增加你对故障自然发生时将被正确处理的信心。*混沌工程* 是一门旨在通过故意注入故障等实验来提高对容错机制的信心的学科 [^39]。 +反直觉的是,在这类系统里,故意 *提高* 故障发生率反而有意义,例如无预警随机杀死某个进程。这叫 *故障注入*。许多关键故障本质上是错误处理做得不够好 [^38]。通过主动注入故障,可以持续演练并验证容错机制,提升对“真实故障发生时系统仍能正确处理”的信心。*混沌工程* 就是围绕这类实验建立起来的方法论 [^39]。 -尽管我们通常更喜欢容忍故障而不是预防故障,但在预防比治疗更好的情况下(例如,因为不存在治疗方法)。安全问题就是这种情况:如果攻击者已经破坏了系统并获得了对敏感数据的访问,该事件无法撤消。然而,本书主要涉及可以恢复的故障类型,如以下部分所述。 +尽管我们通常更倾向于“容忍故障”,而非“阻止故障”,但也有“预防优于补救”的场景(例如根本无法补救)。安全问题就是如此:若攻击者已攻破系统并获取敏感数据,事件本身无法撤销。不过,本书主要讨论的是可恢复的故障类型。 ### 硬件与软件故障 {#sec_introduction_hardware_faults} 当我们想到系统失效的原因时,硬件故障很快就会浮现在脑海中: -* 大约 2-5% 的硬磁盘驱动器每年发生故障 [^40] [^41];在拥有 10,000 个磁盘的存储集群中,我们因此应该认为平均每天有一个磁盘故障。最近的数据表明磁盘变得更可靠,但故障率仍然很显著 [^42]。 -* 大约 0.5-1% 的固态硬盘(SSD)每年发生故障 [^43]。少量位错误会自动纠正 [^44],但不可纠正的错误大约每年每个驱动器发生一次,即使在相当新的驱动器中(即,经历很少磨损);这个错误率高于硬磁盘驱动器 [^45]、[^46]。 +* 机械硬盘每年故障率约为 2%–5% [^40] [^41];在 10,000 盘位的存储集群中,平均每天约有 1 块盘故障。近期数据表明磁盘可靠性在提升,但故障率仍不可忽视 [^42]。 +* SSD 每年故障率约为 0.5%–1% [^43]。少量比特错误可自动纠正 [^44],但不可纠正错误大约每盘每年一次,即使是磨损较轻的新盘也会出现;该错误率高于机械硬盘 [^45]、[^46]。 * 其他硬件组件,如电源、RAID 控制器和内存模块也会发生故障,尽管频率低于硬盘驱动器 [^47] [^48]。 -* 大约千分之一的机器有一个 CPU 核心偶尔计算错误的结果,可能是制造缺陷 [^49] [^50] [^51]造成的。在某些情况下,错误的计算会导致崩溃,但在其他情况下,它会导致程序简单地返回错误的结果。 -* RAM 中的数据也可能被损坏,要么是由于宇宙射线等随机事件,要么是由于永久性物理缺陷。即使使用纠错码(ECC)的内存,也有超过 1% 的机器在给定年限内遇到不可纠正的错误,这通常会导致机器崩溃和受影响的内存模块需要更换 [^52]。此外,某些病态的内存访问模式可能会以很高的概率翻转比特位 [^53]。 -* 整个数据中心可能变得不可用(例如,由于停电或网络配置错误)甚至被永久摧毁(例如,由于火灾、洪水或地震 [^54])。太阳风暴,当太阳喷射大量带电粒子时,会在长距离电线中感应出大电流,可能会损坏电网和海底网络电缆 [^55]。尽管这种大规模故障很少见,但如果服务不能容忍数据中心的丢失,它们的影响可能是灾难性的 [^56]。 +* 大约每 1000 台机器里就有 1 台存在“偶发算错结果”的 CPU 核心,可能由制造缺陷导致 [^49] [^50] [^51]。有时错误计算会直接导致崩溃;有时则只是悄悄返回错误结果。 +* RAM 数据也可能损坏:要么来自宇宙射线等随机事件,要么来自永久性物理缺陷。即便使用 ECC 内存,任意一年内仍有超过 1% 的机器会遇到不可纠正错误,通常表现为机器崩溃并需要更换受影响内存条 [^52]。此外,某些病态访问模式还可能以较高概率触发比特翻转 [^53]。 +* 整个数据中心也可能不可用(如停电、网络配置错误),甚至被永久摧毁(如火灾、洪水、地震 [^54])。太阳风暴会在长距离导线中感应大电流,可能损坏电网和海底通信电缆 [^55]。这类大规模故障虽罕见,但若服务无法容忍数据中心丢失,后果将极其严重 [^56]。 -这些事件足够罕见,你在处理小型系统时通常不需要担心它们,只要你能够轻松地更换出现故障的硬件。然而,在大规模系统中,硬件故障发生得足够频繁,以至于它们成为正常系统运行的一部分。 +这类事件在小系统里足够罕见,通常不必过度担心,只要能方便地更换故障硬件即可。但在大规模系统里,硬件故障足够频繁,已经是“正常运行”的一部分。 #### 通过冗余容忍硬件故障 {#tolerating-hardware-faults-through-redundancy} @@ -220,7 +222,7 @@ Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒将电子 当组件故障独立时,冗余最有效,即一个故障的发生不会改变另一个故障发生的可能性。然而,经验表明,组件故障之间通常存在显著的相关性 [^41] [^57] [^58];整个服务器机架或整个数据中心的不可用仍然比我们预期的更频繁地发生。 -硬件冗余增加了单台机器的正常运行时间;然而,如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 中所讨论的,使用分布式系统有一些优势,例如能够容忍一个数据中心的完全中断。出于这个原因,云系统倾向于较少关注单个机器的可靠性,而是旨在通过在软件级别容忍故障节点来使服务高度可用。云提供商使用 *可用区* 来识别哪些资源在物理上位于同一位置;同一地方的资源比地理上分离的资源更可能同时发生故障。 +硬件冗余确实能提升单机可用时间;但正如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 所述,分布式系统还具备额外优势,例如可容忍整个数据中心中断。因此云系统通常不再过分追求“单机极致可靠”,而是通过软件层容忍节点故障来实现高可用。云厂商使用 *可用区* 标识资源是否物理共址;同一可用区内资源比跨地域资源更容易同时失效。 我们在本书中讨论的容错技术旨在容忍整个机器、机架或可用区的丢失。它们通常通过允许一个数据中心的机器在另一个数据中心的机器发生故障或变得不可达时接管来工作。我们将在 [第 6 章](/ch6)、[第 10 章](/ch10) 以及本书的其他各个地方讨论这种容错技术。 @@ -228,198 +230,174 @@ Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒将电子 #### 软件故障 {#software-faults} -尽管硬件故障可能是弱相关的,但它们大多仍然是独立的:例如,如果一个磁盘发生故障,同一台机器中的其他磁盘很可能在一段时间内还能正常工作。另一方面,软件故障通常高度相关,因为许多节点运行相同的软件并因此具有相同的错误是常见的 [^59] [^60]。这种故障比不相关的硬件故障更难预料,并且它们往往导致比硬件故障更多的系统失效 [^47]。例如: +尽管硬件故障可能存在弱相关,但整体上仍相对独立:例如一块盘坏了,同机其他盘往往还能再正常工作一段时间。相比之下,软件故障常常高度相关,因为许多节点运行同一套软件,也就共享同一批 bug [^59] [^60]。这类故障更难预判,也往往比“相互独立的硬件故障”造成更多系统失效 [^47]。例如: * 在特定情况下导致每个节点同时失效的软件错误。例如,2012 年 6 月 30 日,闰秒导致许多 Java 应用程序由于 Linux 内核中的错误而同时挂起 [^61]。由于固件错误,某些型号的所有 SSD 在精确运行 32,768 小时(不到 4 年)后突然失效,使其上的数据无法恢复 [^62]。 * 使用某些共享、有限资源(如 CPU 时间、内存、磁盘空间、网络带宽或线程)的失控进程 [^63]。例如,处理大请求时消耗过多内存的进程可能会被操作系统杀死。客户端库中的错误可能导致比预期更高的请求量 [^64]。 * 系统所依赖的服务变慢、无响应或开始返回损坏的响应。 -* 不同系统之间的交互导致在隔离测试每个系统时不会发生的紧急行为 [^65]。 +* 不同系统交互后出现“单系统隔离测试中看不到”的涌现行为 [^65]。 * 级联故障,其中一个组件中的问题导致另一个组件过载和减速,这反过来又导致另一个组件崩溃 [^66] [^67]。 -导致这些类型软件故障的错误通常会潜伏很长时间,直到它们被一组不寻常的环境触发。在这些情况下,软件对其环境做出了某种假设——虽然该假设通常是正确的,但它最终由于某种原因不再成立 [^68] [^69]。 +导致这类软件故障的 bug 往往潜伏很久,直到一组不寻常条件把它触发出来。这时才暴露出:软件其实对运行环境做了某些假设,平时大多成立,但终有一天会因某种原因失效 [^68] [^69]。 -软件中的系统故障没有快速解决方案。许多小事情可以帮助:仔细考虑系统中的假设和交互;彻底测试;进程隔离;允许进程崩溃和重新启动;避免反馈循环,如重试风暴(参见 ["当过载系统无法恢复时"](/ch2#sidebar_metastable));测量、监控和分析生产中的系统行为。 +软件系统性故障没有“速效药”。但许多小措施都有效:认真审视系统假设与交互、充分测试、进程隔离、允许进程崩溃并重启、避免反馈环路(如重试风暴,参见 ["当过载系统无法恢复时"](/ch2#sidebar_metastable)),以及在生产环境持续度量、监控和分析系统行为。 ### 人类与可靠性 {#id31} -人类设计和构建软件系统,保持系统运行的操作员也是人类。与机器不同,人类不只是遵循规则;他们的优势是创造性和适应性地完成工作。然而,这一特征也导致了不可预测性,有时会导致失效的错误,即使本意是好的。例如,一项对大型互联网服务的研究发现,操作员的配置更改是中断的主要原因,而硬件故障(服务器或网络)仅在 10-25% 的中断中发挥作用 [^70]。 +软件系统由人设计、构建和运维。与机器不同,人不会只按规则执行;人的优势在于创造性和适应性。但这也带来不可预测性,即使本意是好的,也会犯导致失效的错误。例如,一项针对大型互联网服务的研究发现:运维配置变更是中断首因,而硬件故障(服务器或网络)仅占 10%–25% [^70]。 -人们很自然地倾向于将这类问题归咎于“人为错误”,并希望通过更严格的程序和规则遵守来更好地控制人为行为从而解决问题。然而,将错误归咎于人是适得其反的。我们所说的“人为错误”并非事件的真实原因,而是人们尽力工作时,社会技术系统中存在问题的征兆 [^71]。通常,复杂系统具有紧急行为,组件之间的意外交互也可能导致故障 [^72]。 +遇到这类问题,人们很容易归咎于“人为错误”,并试图通过更严格流程和更强规则约束来控制人。但“责怪个人”通常适得其反。所谓“人为错误”往往不是事故根因,而是社会技术系统本身存在问题的征兆 [^71]。复杂系统里,组件意外交互产生的涌现行为也常导致故障 [^72]。 -各种技术措施可以帮助最小化人为错误的影响,包括彻底测试(手写测试和对大量随机输入的 *属性测试*)[^38]、快速回滚配置更改的回滚机制、新代码的渐进部署、详细和清晰的监控、用于诊断生产问题的可观测性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems)),以及鼓励"正确的事情"并阻止"错误的事情"的精心设计的界面。 +有多种技术手段可降低人为失误的影响:充分测试(含手写测试与大量随机输入的 *属性测试*)[^38]、可快速回滚配置变更的机制、新代码渐进发布、清晰细致的监控、用于排查生产问题的可观测性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems)),以及鼓励“正确操作”并抑制“错误操作”的良好界面设计。 -然而,这些事情需要时间和金钱的投资,在日常业务的务实现实中,组织通常优先考虑创收活动而不是增加其抵御错误的韧性措施。如果要在更多功能和更多测试之间做出选择,许多组织会很自然地选择功能。鉴于这种选择,当可预防的错误不可避免地发生时,责怪犯错误的人是没有意义的——问题在于组织的事项优先级。 +但这些措施都需要时间和预算。在日常业务压力下,组织往往优先投入“直接创收”活动,而非提升抗错韧性的建设。若在“更多功能”和“更多测试”之间二选一,很多组织会自然选择前者。既然如此,当可预防错误最终发生时,责怪个人并无意义,问题本质在于组织的优先级选择。 -越来越多的组织正在采用 *无责备事后分析* 的文化:事件发生后,鼓励相关人员充分分享发生的事情的细节,而不用担心惩罚,因为这允许组织中的其他人学习如何在未来防止类似的问题 [^73]发生。这个过程可能会发现需要改变业务优先级、需要投资于被忽视的领域、需要改变相关人员的激励措施,或者需要引起管理层注意的其他一些系统性问题。 +越来越多组织在实践 *无责备事后分析*:事故发生后,鼓励参与者在不担心惩罚的前提下完整复盘细节,让组织其他人也能学习如何避免类似问题 [^73]。这个过程常会揭示出:业务优先级需要调整、某些长期被忽视的领域需要补投入、相关激励机制需要改,或其他应由管理层关注的系统性问题。 -作为一般原则,在调查事件时,你应该对简单化的答案持怀疑态度。"鲍勃在部署该更改时应该更加小心"是没有意义的,"我们必须用 Haskell 重写后端"也一样。相反,管理层应该借此机会从每天与之合作的人的角度了解社会技术系统如何工作的细节,并根据这些反馈采取措施改进它 [^71]。 +一般来说,调查事故时应警惕“过于简单”的答案。“鲍勃部署时应更小心”没有建设性,“我们必须用 Haskell 重写后端”同样不是。更可行的做法是:管理层借机从一线人员视角理解社会技术系统的真实运行方式,并据此推动改进 [^71]。 -------- > [!TIP] 可靠性有多重要? -可靠性不仅仅适用于核电站和空中交通管制——更普通的应用程序也应该可靠地工作。业务应用程序中的错误会导致生产力损失(如果数据被不正确地报告,还会有法律风险),而电子商务网站的中断会因收入损失和声誉受损而产生巨大的成本。 +可靠性不只适用于核电站或空管系统,普通应用同样需要可靠。企业软件中的 bug 会造成生产力损失(若报表错误还会带来法律风险);电商网站故障则会带来直接收入损失和品牌伤害。 -在许多应用程序中,几分钟甚至几小时的临时中断是可以容忍的 [^74],但永久数据丢失或损坏将是灾难性的。考虑一位家长在你的照片应用程序中存储他们孩子的所有照片和视频 [^75]。如果该数据库突然损坏,他们会有什么感觉?他们会知道如何从备份中恢复吗? +在许多应用里,几分钟乃至几小时的短暂中断尚可容忍 [^74];但永久性数据丢失或损坏往往是灾难性的。想象一位家长把孩子的全部照片和视频都存在你的相册应用里 [^75]。若数据库突然损坏,他们会怎样?又是否知道如何从备份恢复? -作为不可靠软件如何伤害人们的另一个例子,可以参考邮局“Horizon”丑闻。在 1999 年至 2019 年期间,管理英国邮局分支机构的数百人因会计软件显示其账户财务漏洞而被判盗窃或欺诈罪。最终真相水落石出,许多这些财务漏洞是由于软件中的错误,许多定罪已被推翻 [^76]。导致这一可能是英国历史上最大的司法不公的是,英国法律假设计算机正确运行(因此,计算机产生的证据是可靠的),除非有相反的证据 [^77]。软件工程师可能会嘲笑软件可能无错误的想法,但这对那些因不可靠的计算机系统而被错误监禁、宣布破产甚至自杀的人来说,这几乎没什么安慰。 +另一个“软件不可靠伤害现实人群”的例子,是英国邮局 Horizon 丑闻。1999 到 2019 年间,数百名邮局网点负责人因会计系统显示“账目短缺”被判盗窃或欺诈。后来事实证明,许多“短缺”来自软件缺陷,且大量判决已被推翻 [^76]。造成这场可能是英国史上最大司法不公的一个关键前提,是英国法律默认计算机正常运行(因此其证据可靠),除非有相反证据 [^77]。软件工程师或许会觉得“软件无 bug”很荒谬,但这对那些因此被错判入狱、破产乃至自杀的人来说毫无安慰。 -在某些情况下,我们可能选择牺牲可靠性以降低开发成本(例如,在为未经证实的市场开发原型产品时)——但我们应该非常清楚何时走捷径并牢记潜在的后果。 +在某些场景下,我们也许会有意牺牲部分可靠性来降低开发成本(例如做未验证市场的原型产品)。但应明确知道自己在何处“走捷径”,并充分评估其后果。 -------- ## 可伸缩性 {#sec_introduction_scalability} -即使系统今天可靠地工作,这并不意味着它将来必然会可靠地工作。降级的一个常见原因是负载增加:也许系统已经从 10,000 个并发用户增长到 100,000 个并发用户,或者从 100 万增长到 1000 万。也许它正在处理比以前大得多的数据量。 +即便系统今天运行可靠,也不代表将来一定如此。性能退化的常见原因之一是负载增长:比如并发用户从 1 万涨到 10 万,或从 100 万涨到 1000 万;也可能是处理的数据规模远大于从前。 -*可伸缩性* 是我们用来描述系统应对负载增加能力的术语。有时,在讨论可伸缩性时,人们会发表评论,如"你不是谷歌或亚马逊。停止担心规模,只使用关系数据库。"这个格言是否适用于你取决于你正在构建的应用程序类型。 +*可伸缩性* 用来描述系统应对负载增长的能力。讨论这个话题时,常有人说:“你又不是 Google/Amazon,别担心规模,直接上关系数据库。”这句话是否成立,取决于你在做什么类型的应用。 -如果你正在构建一个目前只有少数用户的新产品,也许是在初创公司,首要的工程目标通常是保持系统尽可能简单和灵活,以便你可以在了解更多关于客户需求时轻松修改和调整产品的功能 [^78]。在这种环境中,担心未来可能需要的假设规模是适得其反的:在最好的情况下,对可伸缩性的投资是浪费的努力和过早的优化;在最坏的情况下,它们会将你锁定在不灵活的设计中,并使你的应用程序更难发展。 +如果你在做一个目前用户很少的新产品(例如创业早期),首要工程目标通常是“尽可能简单、尽可能灵活”,以便随着对用户需求理解加深而快速调整产品功能 [^78]。在这种环境下,过早担心“未来也许会有”的规模往往适得其反:最好情况是白费功夫、过早优化;最坏情况是把自己锁进僵化设计,反而阻碍演进。 -原因是可伸缩性不是一维标签:说"X 是可伸缩的"或"Y 不伸缩"是没有意义的。相反,讨论可伸缩性意味着考虑诸如以下问题: +原因在于,可伸缩性不是一维标签;“X 可伸缩”或“Y 不可伸缩”这种说法本身意义不大。更有意义的问题是: -* "如果系统以特定方式增长,我们有什么选择来应对增长?" -* "我们如何增加计算资源来处理额外的负载?" -* "基于当前的增长预测,我们何时会达到当前架构的极限?" +* “如果系统按某种方式增长,我们有哪些应对选项?” +* “我们如何增加计算资源来承载额外负载?” +* “按当前增长趋势,现有架构何时会触顶?” -如果你成功地使你的应用程序受欢迎,因此处理越来越多的负载,你将了解你的性能瓶颈在哪里,因此你将知道需要沿着哪些维度进行伸缩。那时是开始担心可伸缩性技术的时候。 +当你的产品真的做起来、负载持续上升时,你自然会看到瓶颈在哪里,也就知道该沿哪些维度扩展。那时再系统性投入可伸缩性技术,通常更合适。 ### 描述负载 {#id33} -首先,我们需要简洁地描述系统上的当前负载;只有这样我们才能讨论增长问题(如果我们的负载翻倍会发生什么?)。通常这将是吞吐量的度量:例如,对服务的每秒请求数、每天到达多少千兆字节的新数据,或每小时购物车结账的数量。有时你关心某个变量数值的峰值,例如 ["案例研究:社交网络首页时间线"](/ch2#sec_introduction_twitter) 中同时在线用户的数量。 +首先要简明描述系统当前负载,之后才能讨论“增长会怎样”(例如负载翻倍会发生什么)。最常见的是吞吐量指标:每秒请求数、每天新增数据量(GB)、每小时购物车结账次数等。有时你关心的是峰值变量,比如 ["案例研究:社交网络首页时间线"](/ch2#sec_introduction_twitter) 里的“同时在线用户数”。 -通常还有其他负载统计特征,会影响访问模式,从而影响可扩展性要求。例如,你可能需要知道数据库中的读写比率、缓存的命中率或每个用户的数据项数量(例如,社交网络案例研究中的粉丝数量)。也许平均情况对你很重要,或者也许你的瓶颈由少数极端情况主导。这一切都取决于你特定应用程序的细节。 +此外还可能有其他统计特征会影响访问模式,进而影响可伸缩性要求。例如数据库读写比、缓存命中率、每用户数据项数量(如社交网络里的追随者数)。有时平均情况最关键,有时瓶颈由少数极端情况主导,具体取决于你的应用细节。 -一旦你描述了系统上的负载,你就可以调查当负载增加时会发生什么。你可以从两个方面来看待它: +当负载被清楚描述后,就可以分析“负载增加时系统会怎样”。可从两个角度看: -* 当你以某种方式增加负载并保持系统资源(CPU、内存、网络带宽等)不变时,系统的性能如何受到影响? -* 当你以某种方式增加负载时,如果你想保持性能不变,你需要增加多少资源? +* 以某种方式增大负载、但保持资源(CPU、内存、网络带宽等)不变时,性能如何变化? +* 若负载按某种方式增长、但你希望性能不变,需要增加多少资源? -通常我们的目标是在最小化运行系统成本的同时保持系统性能在 SLA 的要求范围内(参见 ["响应时间指标的应用"](/ch2#sec_introduction_slo_sla))。所需的计算资源越多,成本就越高。可能某些类型的硬件比其他类型更具成本效益,这些因素可能会随着新类型硬件的出现而随时间变化。 +通常目标是:在尽量降低运行成本的同时,让性能维持在 SLA 要求内(参见 ["响应时间指标的应用"](/ch2#sec_introduction_slo_sla))。所需计算资源越多,成本越高。不同硬件的性价比不同,而且会随着新硬件出现而变化。 -如果你可以将资源翻倍以处理两倍的负载,同时保持性能不变,我们说你有 *线性可伸缩性*,这被认为是好事。偶尔,由于规模经济或峰值负载的更好分布,可以用不到两倍的资源处理两倍的负载 [^79] [^80]。更可能的是,成本增长速度快于线性,并且效率低下可能有许多原因。例如,如果你有大量数据,那么处理单个写请求可能涉及比你有少量数据时更多的工作,即使请求的大小相同。 +如果资源翻倍后能承载两倍负载且性能不变,这称为 *线性可伸缩性*,通常是理想状态。偶尔,借助规模效应或峰值负载更均匀分布,甚至可用不足两倍资源处理两倍负载 [^79] [^80]。但更常见的是成本增长快于线性,低效原因也很多。比如数据量增大后,即使请求大小相同,处理一次写请求也可能比数据量小时更耗资源。 ### 共享内存、共享磁盘与无共享架构 {#sec_introduction_shared_nothing} -增加服务硬件资源的最简单方法是将其移动到更强大的机器。单个 CPU 核心不再变得显著更快,但你可以购买一台机器(或租用云实例)具有更多 CPU 核心、更多 RAM 和更多磁盘空间。这种方法称为 *纵向伸缩* 或 *向上扩展*。 +增加服务硬件资源的最简单方式,是迁移到更强的机器。虽然单核 CPU 不再明显提速,但你仍可购买(或租用)拥有更多 CPU 核心、更多 RAM、更多磁盘的实例。这叫 *纵向伸缩*(scaling up)。 -你可以通过使用多个进程或线程在单台机器上获得并行性。属于同一进程的所有线程都可以访问相同的 RAM,因此这种方法也称为 *共享内存架构*。共享内存方法的问题是成本增长速度快于线性:具有两倍硬件资源的高端机器通常成本远远超过两倍。由于瓶颈,两倍大小的机器通常只能处理不到两倍的负载。 +在单机上,你可以通过多进程/多线程获得并行性。同一进程内线程共享同一块 RAM,因此这也叫 *共享内存架构*。问题是它的成本常常“超线性增长”:硬件资源翻倍的高端机器,价格往往远超两倍;且受限于瓶颈,性能提升通常又达不到两倍。 -另一种方法是 *共享磁盘架构*,它使用几台具有独立 CPU 和 RAM 的机器,但将数据存储在机器之间共享的磁盘阵列上,这些机器通过快速网络连接:*网络附加存储*(NAS)或 *存储区域网络*(SAN)。这种架构传统上用于本地数据仓库工作负载,但争用和锁定的开销限制了共享磁盘方法的可伸缩性 [^81]。 +另一种方案是 *共享磁盘架构*:多台机器各有独立 CPU 和 RAM,但共享同一组磁盘阵列,通过高速网络连接(NAS 或 SAN)。该架构传统上用于本地数据仓库场景,但争用与锁开销限制了其可伸缩性 [^81]。 -相比之下,*无共享架构* [^82](也称为 *横向伸缩* 或 *向外扩展*)已经获得了很大的流行。在这种方法中,我们使用具有多个节点的分布式系统,每个节点都有自己的 CPU、RAM 和磁盘。节点之间的任何协调都在软件级别通过传统网络完成。 +相比之下,*无共享架构* [^82](即 *横向伸缩*、scaling out)已广泛流行。这种方案使用多节点分布式系统,每个节点拥有自己的 CPU、RAM 和磁盘;节点间协作通过常规网络在软件层完成。 -无共享的优点是它有线性伸缩的潜力,它可以使用提供最佳性价比的任何硬件(特别是在云中),它可以随着负载的增加或减少更容易地调整其硬件资源,并且它可以通过在多个数据中心和地区分布系统来实现更大的容错。缺点是它需要显式分片(参见 [第 7 章](/ch7)),并且它会产生分布式系统的所有复杂性([第 9 章](/ch9))。 +无共享的优势在于:具备线性伸缩潜力、可灵活选用高性价比硬件(尤其在云上)、更容易随负载增减调整资源,并可通过跨多个数据中心/地域部署提升容错。代价是:需要显式分片(见 [第 7 章](/ch7)),并承担分布式系统的全部复杂性(见 [第 9 章](/ch9))。 -一些云原生数据库系统为存储和事务执行使用单独的服务(参见 ["存储与计算分离"](/ch1#sec_introduction_storage_compute)),多个计算节点共享对同一存储服务的访问。这个模型与共享磁盘架构有一些相似之处,但它避免了旧系统的可伸缩性问题:它不是提供文件系统(NAS)或块设备(SAN)抽象,而是存储服务提供专门为数据库特定需求设计的 API [^83]。 +一些云原生数据库把“存储”和“事务执行”拆成独立服务(参见 ["存储与计算分离"](/ch1#sec_introduction_storage_compute)),由多个计算节点共享同一存储服务。这种模式与共享磁盘有相似性,但规避了老系统的可伸缩瓶颈:它不暴露 NAS/SAN 那种文件系统或块设备抽象,而是提供面向数据库场景定制的存储 API [^83]。 ### 可伸缩性原则 {#id35} -在大规模运行的系统架构通常对应用程序高度特定——没有通用的、一刀切的可伸缩架构(非正式地称为 *万金油*)。例如,设计用于处理每秒 100,000 个请求(每个 1 kB 大小)的系统与设计用于每分钟 3 个请求(每个 2 GB 大小)的系统看起来非常不同——即使两个系统具有相同的数据吞吐量(100 MB/秒)。 +能够大规模运行的系统架构,通常高度依赖具体应用,不存在通用“一招鲜”的可伸缩架构(俗称 *万金油*)。例如:面向“每秒 10 万次请求、每次 1 kB”的系统,与面向“每分钟 3 次请求、每次 2 GB”的系统,形态会完全不同,尽管二者数据吞吐量都约为 100 MB/s。 -此外,适合一个负载级别的架构不太可能应对 10 倍的负载。如果你正在开发快速增长的服务,因此很可能你需要在每个数量级的负载增加时重新考虑你的架构。由于应用程序的需求可能会演变,通常不值得提前规划超过一个数量级的未来伸缩需求。 +此外,适合某一级负载的架构,通常难以直接承受 10 倍负载。若你在做高速增长服务,几乎每跨一个数量级都要重新审视架构。考虑到业务需求本身也会变化,提前规划超过一个数量级的未来伸缩需求,往往不划算。 -可伸缩性的一个良好通用原则是将系统分解为可以在很大程度上相互独立运行的较小组件。这是微服务背后的基本原则(参见 ["微服务与无服务器"](/ch1#sec_introduction_microservices))、分片([第 7 章](/ch7))、流处理([待补充链接])和无共享架构。然而,挑战在于知道在哪里划分应该在一起的事物和应该分开的事物之间的界限。微服务的设计指南可以在其他书籍中找到 [^84],我们在 [第 7 章](/ch7) 中讨论无共享系统的分片。 +可伸缩性的一个通用原则,是把系统拆分成尽量可独立运行的小组件。这也是微服务(参见 ["微服务与无服务器"](/ch1#sec_introduction_microservices))、分片([第 7 章](/ch7))、流处理([第 12 章](/ch12#ch_stream))和无共享架构的共同基础。难点在于:哪里该拆,哪里该合。微服务设计可参考其他书籍 [^84];无共享系统的分片问题我们会在 [第 7 章](/ch7) 讨论。 -另一个好原则是不要让事情变得比必要的更复杂。如果单机数据库可以完成工作,它可能比复杂的分布式设置更可取。自动伸缩系统(根据需求自动添加或删除资源)很酷,但如果你的负载相当可预测,手动伸缩的系统可能会有更少的操作意外(参见 ["操作:自动或手动再平衡"](/ch7#sec_sharding_operations))。具有五个服务的系统比具有五十个服务的系统更简单。良好的架构通常涉及多种方案的务实混合。 +另一个好原则是:不要把系统做得比必要更复杂。若单机数据库足够,就往往优于复杂分布式方案。自动伸缩(按需求自动加减资源)很吸引人,但若负载相对可预测,手动伸缩可能带来更少运维意外(参见 ["操作:自动或手动再平衡"](/ch7#sec_sharding_operations))。5 个服务的系统通常比 50 个服务更简单。好架构往往是多种方案的务实组合。 ## 可维护性 {#sec_introduction_maintainability} -软件不会磨损或遭受材料老化,因此它不会像机械物体那样损坏。但应用程序的需求经常变化,软件运行在变化的环境中(例如其依赖项和底层平台),并且它有需要修复的错误。 +软件不会像机械设备那样磨损或材料疲劳,但应用需求会变化,软件所处环境(依赖项、底层平台)也会变化,代码中还会持续暴露需要修复的缺陷。 -人们普遍认为,软件的大部分成本不在其初始开发中,而在其持续维护中——修复错误、保持其系统运行、调查故障、将其适应新平台、为新用例修改它、偿还技术债务和添加新功能 [^85] [^86]。 +业界普遍认同:软件成本的大头不在初始开发,而在后续维护,包括修 bug、保障系统稳定运行、排查故障、适配新平台、支持新场景、偿还技术债,以及持续交付新功能 [^85] [^86]。 -然而,维护也很困难。如果系统已成功运行很长时间,它可能使用如今很少有工程师理解的过时技术(如大型机和 COBOL 代码);随着人员的离开,关于系统如何以及为何以某种特定方式设计的制度性知识可能已经丢失了;可能需要修复其他人的错误。此外,计算机系统通常与它支持的人类组织交织在一起,这意味着此类 *遗留* 系统的维护既是人的问题,也是技术问题 [^87]。 +然而维护并不容易。一个长期运行成功的系统,可能仍依赖今天少有人熟悉的旧技术(如大型机和 COBOL);随着人员流动,系统为何如此设计的组织记忆也可能丢失;维护者往往还要修复前人留下的问题。更重要的是,计算机系统通常与其支撑的组织流程深度耦合,这使得 *遗留* 系统维护既是技术问题,也是人员与组织问题 [^87]。 -如果我们今天创建的每个系统都足够有价值以长期生存,它有一天将成为遗留系统。为了最小化需要维护我们软件的未来几代人的痛苦,我们应该在设计时考虑维护问题。尽管我们不能总是预测哪些决定可能会在未来造成维护难题,但在本书中,我们将注意几个广泛适用的原则: +如果今天构建的系统足够有价值并长期存活,它终有一天会变成遗留系统。为减少后继维护者的痛苦,我们应在设计阶段就考虑维护性。虽然难以准确预判哪些决策会在未来埋雷,但本书会强调几条广泛适用的原则: 可运维性(Operability) -: 使组织容易保持系统平稳运行。 +: 让组织能够更容易地保持系统平稳运行。 简单性(Simplicity) -: 通过使用易于理解、一致的模式和结构来实施它,并避免不必要的复杂性,使新工程师容易理解系统。 +: 采用易理解且一致的模式与结构,避免不必要复杂性,让新工程师也能快速理解系统。 可演化性(Evolvability) -: 使工程师将来容易对系统进行更改,随着需求变化而适应和扩展它以用于未预料的使用场景。 +: 让工程师在未来能更容易修改系统,使其随着需求变化而持续适配并扩展到未预料场景。 ### 可运维性:让运维更轻松 {#id37} -我们之前在 ["云时代的运维"](/ch1#sec_introduction_operations) 中讨论了运维的角色,我们看到人类流程对于可靠运维至少与软件工具一样重要。 -事实上,有人提出 “良好的运维通常可以解决糟糕(或不完整)软件的局限性,但再好的软件碰上糟糕的运维也难以可靠地运行” [^60]。 +我们在 ["云时代的运维"](/ch1#sec_introduction_operations) 已讨论过运维角色:可靠运行不仅依赖工具,人类流程同样关键。甚至有人指出:“好的运维常能绕过糟糕(或不完整)软件的局限;但再好的软件,碰上糟糕运维也难以可靠运行” [^60]。 -在由数千台机器组成的大规模系统中,手动维护将是不合理地昂贵的,自动化是必不可少的。然而,自动化可能是一把双刃剑: -总会有边际场景(如罕见的故障场景)需要运维团队的手动干预。由于无法自动处理的情况是最复杂的问题,更大的自动化需要一个 **更** 熟练的运维团队来解决这些问题 [^88]。 +在由成千上万台机器组成的大规模系统中,纯手工维护成本不可接受,自动化必不可少。但自动化也是双刃剑:总会有边缘场景(如罕见故障)需要运维团队人工介入。并且“自动化处理不了”的往往恰恰最复杂,因此自动化越深,越需要 **更** 高水平的运维团队来兜底 [^88]。 -此外,如果自动化系统出错,通常比依赖操作员手动执行某些操作的系统更难排除故障。出于这个原因,更多的自动化并不总是对可操作性更好。 -然而,一定程度的自动化很重要,最佳点将取决于你特定应用程序和组织的细节。 +另外,一旦自动化系统本身出错,往往比“部分依赖人工操作”的系统更难排查。因此自动化并非越多越好。合理自动化程度取决于你所在应用与组织的具体条件。 -良好的可操作性意味着使常规任务变得容易,使运维团队能够将精力集中在高价值活动上。 -数据系统可以做各种事情来使常规任务变得容易,包括 [^89]: +良好的可运维性意味着把日常任务做简单,让运维团队把精力投入到高价值工作。数据系统可以通过多种方式达成这一点 [^89]: -* 允许监控工具检查系统的关键指标,并支持可观测性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems))以深入了解系统的运行时行为。各种商业和开源工具可以在这方面提供帮助 [^90]。 -* 避免对单个机器的依赖(允许在系统整体持续不间断运行的同时关闭机器进行维护) -* 提供良好的文档和易于理解的操作模型("如果我做 X,Y 将会发生") -* 提供良好的默认行为,但也给管理员在需要时覆盖默认值的自由 -* 在适当的地方自我修复,但也在需要时给管理员手动控制系统状态 -* 表现出可预测的行为,最小化意外 +* 让监控工具能获取关键指标,并支持可观测性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems))以洞察运行时行为。相关商业/开源工具都很多 [^90]。 +* 避免依赖单机(系统整体不停机的前提下允许下线机器维护)。 +* 提供完善文档和易理解的操作模型(“我做 X,会发生 Y”)。 +* 提供良好默认值,同时允许管理员在需要时覆盖默认行为。 +* 适当支持自愈,同时在必要时保留管理员对系统状态的手动控制权。 +* 行为可预测,尽量减少“惊喜”。 ### 简单性:管理复杂度 {#id38} -小型软件项目可以有令人愉悦的、简单而富有表现力的代码,但随着项目变大,它们通常变得非常复杂且难以理解。 -这种复杂性减慢了需要在系统上工作的每个人的效率,进一步增加了维护成本。陷入复杂性的软件项目有时被描述为 *大泥团* [^91]。 +小型项目往往能保持简洁、优雅、富有表达力;但项目变大后,代码常会迅速变复杂且难理解。这种复杂性会拖慢所有参与者效率,进一步抬高维护成本。陷入这种状态的软件项目常被称为 *大泥团* [^91]。 -当复杂性使维护困难时,预算和时间表经常超支。在复杂软件中,进行更改时引入错误的风险也更大: -当系统对开发人员来说更难理解和推理时,隐藏的假设、意外的后果和意外的交互更容易被忽视 [^69]。 -相反,降低复杂性极大地提高了软件的可维护性,因此简单性应该是我们构建的系统的关键目标。 +当复杂性让维护变难时,预算和进度常常失控。在复杂软件里,变更时引入缺陷的风险也更高:系统越难理解和推理,隐藏假设、非预期后果和意外交互就越容易被忽略 [^69]。反过来,降低复杂性能显著提升可维护性,因此“追求简单”应是系统设计核心目标之一。 -简单系统更容易理解,因此我们应该尝试以尽可能简单的方式解决给定问题。不幸的是,这说起来容易做起来难。 -某物是否简单通常是主观的品味问题,因为没有客观的简单性标准 [^92]。例如,一个系统可能在简单界面后面隐藏复杂的实现, -而另一个系统可能有一个向用户公开更多内部细节的简单实现——哪一个更简单? +简单系统更容易理解,因此我们应尽可能用最简单方式解决问题。但“简单”知易行难。什么叫简单,往往带有主观判断,因为不存在绝对客观的简单性标准 [^92]。例如,一个系统可能“接口简单但实现复杂”,另一个可能“实现简单但暴露更多内部细节”,到底谁更简单,并不总有标准答案。 -推理复杂性的一种尝试是将其分为两类,**本质复杂性** 和 **偶然复杂性** [^93]。 -这个想法是,本质复杂性是应用程序问题域中固有的,而偶然复杂性仅由于我们工具的限制而产生。 -不幸的是,这种区别也有缺陷,因为本质和偶然之间的边界随着我们工具的发展而变化 [^94]。 +一种常见分析方法是把复杂性分成两类:**本质复杂性** 与 **偶然复杂性** [^93]。前者源于业务问题本身,后者源于工具与实现限制。但这种划分也并不完美,因为随着工具演进,“本质”和“偶然”的边界会移动 [^94]。 -我们管理复杂性的最佳工具之一是 **抽象**。良好的抽象可以在干净、易于理解的外观后面隐藏大量实现细节。良好的抽象也可以用于各种不同的应用程序。 -这种重用不仅比多次重新实现类似的东西更有效,而且还能提高软件质量,因为抽象组件中的质量改进使所有使用它的应用程序受益。 +管理复杂度最重要的工具之一是 **抽象**。好的抽象能在清晰外观后隐藏大量实现细节,也能被多种场景复用。这种复用不仅比反复重写更高效,也能提升质量,因为抽象组件一旦改进,所有依赖它的应用都会受益。 -例如,高级编程语言是隐藏机器码、CPU 寄存器和系统调用的抽象。SQL 是一种隐藏磁盘和内存中的复杂数据结构、来自其他客户端的并发请求以及崩溃后的不一致性的抽象。 -当然,在用高级语言编程时,我们仍在使用机器码;我们只是不 *直接* 使用它,因为编程语言抽象使我们不必考虑它。 +例如,高级语言是对机器码、CPU 寄存器和系统调用的抽象。SQL 则抽象了磁盘/内存中的复杂数据结构、来自其他客户端的并发请求,以及崩溃后的不一致状态。用高级语言编程时,我们仍然在“使用机器码”,但不再 *直接* 面对它,因为语言抽象替我们屏蔽了细节。 -应用程序代码的抽象,旨在降低其复杂性,可以使用诸如 *设计模式* [^95] 和 *领域驱动设计*(DDD)[^96] 等方法创建。 -本书不是关于此类特定于应用程序的抽象,而是关于你可以在其上构建应用程序的通用抽象,例如数据库事务、索引和事件日志。如果你想使用像 DDD 这样的技术,你可以在本书中描述的基础之上实现它们。 +应用代码层面的抽象,常借助 *设计模式* [^95]、*领域驱动设计*(DDD)[^96] 等方法来构建。本书重点不在这类应用专用抽象,而在你可以拿来构建应用的通用抽象,例如数据库事务、索引、事件日志等。若你想采用 DDD 等方法,也可以建立在本书介绍的基础能力之上。 ### 可演化性:让变化更容易 {#sec_introduction_evolvability} -你的系统需求将保持不变的可能性极小。它们更可能处于不断变化中: -你学习新事实、以前未预料的用例出现、业务优先级发生变化、用户请求新功能、 -新平台取代旧平台、法律或监管要求发生变化、系统增长迫使架构变化等。 +系统需求永远不变的概率极低。更常见的是持续变化:你会发现新事实,出现此前未预期用例,业务优先级会调整,用户会提出新功能,新平台会替换旧平台,法律与监管会变化,系统增长也会倒逼架构调整。 -在组织流程方面,*敏捷* 工作模式为适应变化提供了框架。敏捷社区还开发了在频繁变化的环境中开发软件时有用的技术工具和流程, -例如测试驱动开发(TDD)和重构。在本书中,我们探寻在由具有不同特征的几个不同应用程序或服务组成的系统级别增加敏捷性的方法。 +在组织层面,*敏捷* 方法为适应变化提供了框架;敏捷社区也发展出多种适用于高变化环境的技术与流程,如测试驱动开发(TDD)和重构。本书关注的是:如何在“由多个不同应用/服务组成的系统层级”提升这种敏捷能力。 -你可以修改数据系统并使其适应不断变化的需求的容易程度与其简单性及其抽象密切相关:松耦合、简单的系统通常比紧耦合、复杂的系统更容易修改。 -由于这是一个如此重要的概念,我们将使用一个不同的词来指代数据系统级别的敏捷性:*可演化性* [^97]。 +数据系统对变化的适应难易度,与其简单性和抽象质量高度相关:松耦合、简单系统通常比紧耦合、复杂系统更容易修改。由于这一点极其重要,我们把“数据系统层面的敏捷性”单独称为 *可演化性* [^97]。 -使大型系统中的变化困难的一个主要因素是某些操作不可逆,因此需要非常谨慎地采取该操作 [^98]。 -例如,假设你正在从一个数据库迁移到另一个数据库:如果在新数据库出现问题时无法切换回旧系统,风险就会高得多,而如果你可以轻松返回。最小化不可逆性提高了灵活性。 +大型系统中让变更困难的一个关键因素,是某些操作不可逆,因此执行时必须极其谨慎 [^98]。例如从一个数据库迁移到另一个:若新库出问题后无法回切,风险就远高于可随时回退。尽量减少不可逆操作,能显著提升系统灵活性。 ## 总结 {#summary} -在本章中,我们研究了几个非功能性需求的例子:性能、可靠性、可伸缩性和可维护性。 -通过这些主题,我们还遇到了我们在本书其余部分需要的原则和术语。我们从社交网络中首页时间线如何实现的案例研究开始,这说明了在规模扩大时出现的一些挑战。 +本章讨论了几类核心非功能性需求:性能、可靠性、可伸缩性与可维护性。围绕这些主题,我们也建立了贯穿全书的一组概念与术语。章节从“社交网络首页时间线”案例切入,直观展示了系统在规模增长时会遇到的现实挑战。 -我们讨论了如何衡量性能(例如,使用响应时间百分位数)、系统上的负载(例如,使用吞吐量指标),以及它们如何在 SLA 中使用。 -可伸缩性是一个密切相关的概念:即,在负载增长时确保性能保持不变。我们看到了可伸缩性的一些通用性原则,例如将任务分解为可以独立运行的较小部分,我们将在以下章节中深入研究可伸缩性技术的技术细节。 +我们讨论了如何衡量性能(例如响应时间百分位点)、如何描述系统负载(例如吞吐量指标),以及这些指标如何进入 SLA。与之紧密相关的是可伸缩性:当负载增长时,如何保持性能不退化。我们也给出了若干通用原则,例如将任务拆解为可独立运行的小组件。后续章节会深入展开相关技术细节。 -为了实现可靠性,你可以使用容错技术,即使某个组件(例如,磁盘、机器或其他服务)出现故障,系统也可以继续提供其服务。 -我们看到了可能发生的硬件故障的例子,并将它们与软件故障区分开来,软件故障可能更难处理,因为它们通常是强相关的。 -实现可靠性的另一个方面是建立对人类犯错误的韧性,我们看到无责备事后分析作为从事件中学习的技术。 +为实现可靠性,可以使用容错机制,使系统在部分组件(如磁盘、机器或外部服务)故障时仍能持续提供服务。我们区分了硬件故障与软件故障,并指出软件故障常更难处理,因为它们往往高度相关。可靠性的另一面是“对人为失误的韧性”,其中 *无责备事后分析* 是重要学习机制。 -最后,我们研究了可维护性的几个方面,包括支持运维团队的工作、管理复杂性以及随着时间的推移使应用程序功能易于演化。 -实现这些目标没有简单的答案,但有一件事可以帮助,那就是使用提供有用抽象的易于理解的构建块来构建应用程序。本书的其余部分将涵盖一系列在实践中被证明有价值的构建块。 +最后,我们讨论了可维护性的多个维度:支持运维工作、管理复杂度、提升系统可演化性。实现这些目标没有银弹,但一个普遍有效的做法是:用清晰、可理解、具备良好抽象的构件来搭建系统。接下来全书会介绍一系列在实践中证明有效的构件。 -### 参考 {#参考} +### 参考文献 [^1]: Mike Cvet. [How We Learned to Stop Worrying and Love Fan-In at Twitter](https://www.youtube.com/watch?v=WEgCjwyXvwc). At *QCon San Francisco*, December 2016. [^2]: Raffi Krikorian. [Timelines at Scale](https://www.infoq.com/presentations/Twitter-Timeline-Scalability/). At *QCon San Francisco*, November 2012. Archived at [perma.cc/V9G5-KLYK](https://perma.cc/V9G5-KLYK) @@ -518,4 +496,4 @@ Akamai 最近的一项研究 [^24] 声称响应时间增加 100 毫秒将电子 [^95]: Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. [*Design Patterns: Elements of Reusable Object-Oriented Software*](https://learning.oreilly.com/library/view/design-patterns-elements/0201633612/). Addison-Wesley Professional, October 1994. ISBN: 9780201633610 [^96]: Eric Evans. [*Domain-Driven Design: Tackling Complexity in the Heart of Software*](https://learning.oreilly.com/library/view/domain-driven-design-tackling/0321125215/). Addison-Wesley Professional, August 2003. ISBN: 9780321125217 [^97]: Hongyu Pei Breivold, Ivica Crnkovic, and Peter J. Eriksson. [Analyzing Software Evolvability](https://www.es.mdh.se/pdf_publications/1251.pdf). at *32nd Annual IEEE International Computer Software and Applications Conference* (COMPSAC), July 2008. [doi:10.1109/COMPSAC.2008.50](https://doi.org/10.1109/COMPSAC.2008.50) -[^98]: Enrico Zaninotto. [From X programming to the X organisation](https://martinfowler.com/articles/zaninotto.pdf). At *XP Conference*, May 2002. Archived at [perma.cc/R9AR-QCKZ](https://perma.cc/R9AR-QCKZ) \ No newline at end of file +[^98]: Enrico Zaninotto. [From X programming to the X organisation](https://martinfowler.com/articles/zaninotto.pdf). At *XP Conference*, May 2002. Archived at [perma.cc/R9AR-QCKZ](https://perma.cc/R9AR-QCKZ) diff --git a/content/zh/ch3.md b/content/zh/ch3.md index d41ed20..5bc1b0a 100644 --- a/content/zh/ch3.md +++ b/content/zh/ch3.md @@ -167,7 +167,7 @@ SELECT users.*, regions.region_name WHERE users.id = 251; ``` -文档数据库可以存储规范化和反规范化的数据,但它们通常与反规范化相关联 —— 部分是因为 JSON 数据模型使得存储额外的反规范化字段变得容易,部分是因为许多文档数据库中对连接的弱支持使得规范化不方便。一些文档数据库根本不支持连接,因此你必须在应用程序代码中执行它们 —— 也就是说,你首先获取包含 ID 的文档,然后执行第二个查询将该 ID 解析为另一个文档。在 MongoDB 中,也可以使用聚合管道中的 `$lookup` 运算符执行连接: +文档数据库可以存储规范化和反规范化的数据,但它们通常与反规范化相关联 —— 部分是因为 JSON 数据模型使得存储额外的反规范化字段变得容易,部分是因为许多文档数据库中对连接的弱支持使得规范化不方便。一些文档数据库根本不支持连接,因此你必须在应用程序代码中执行它们 —— 也就是说,你首先获取包含 ID 的文档,然后执行第二个查询将该 ID 解析为另一个文档。在 MongoDB 中,也可以使用聚合管道中的 `$lookup` 算子执行连接: ```mongodb-json db.users.aggregate([ @@ -190,9 +190,9 @@ db.users.aggregate([ * 在反规范化表示中,我们会在每个人的个人资料中包含标志的图像 URL;这使得 JSON 文档自包含,但如果我们需要更改标志,就会产生麻烦,因为我们现在需要找到旧 URL 的所有出现并更新它们 [^9]。 * 在规范化表示中,我们将创建一个代表组织或学校的实体,并在该实体上存储其名称、标志 URL 以及可能的其他属性(描述、新闻提要等)一次。然后,每个提到该组织的简历都会简单地引用其 ID,更新标志很容易。 -作为一般原则,规范化数据通常写入更快(因为只有一个副本),但查询更慢(因为它需要连接);反规范化数据通常读取更快(连接更少),但写入更昂贵(更多副本要更新,使用更多磁盘空间)。你可能会发现将反规范化视为派生数据的一种形式很有帮助(["记录系统和派生数据"](/ch1#sec_introduction_derived)),因为你需要设置一个过程来更新数据的冗余副本。 +作为一般原则,规范化数据通常写入更快(因为只有一个副本),但查询更慢(因为它需要连接);反规范化数据通常读取更快(连接更少),但写入更昂贵(更多副本要更新,使用更多磁盘空间)。你可能会发现将反规范化视为派生数据的一种形式很有帮助(["记录系统与派生数据"](/ch1#sec_introduction_derived)),因为你需要设置一个过程来更新数据的冗余副本。 -除了执行所有这些更新的成本之外,如果进程在进行更新的过程中崩溃,你还需要考虑数据库的一致性。提供原子事务的数据库(参见 ["原子性"](/ch8#sec_transactions_acid_atomicity))使保持一致性变得更容易,但并非所有数据库都在多个文档之间提供原子性。通过流处理确保一致性也是可能的,我们将在 [待补充链接] 中讨论。 +除了执行所有这些更新的成本之外,如果进程在进行更新的过程中崩溃,你还需要考虑数据库的一致性。提供原子事务的数据库(参见 ["原子性"](/ch8#sec_transactions_acid_atomicity))使保持一致性变得更容易,但并非所有数据库都在多个文档之间提供原子性。通过流处理确保一致性也是可能的,我们将在 ["保持系统同步"](/ch12#sec_stream_sync) 中讨论。 规范化往往更适合 OLTP 系统,其中读取和更新都需要快速;分析系统通常使用反规范化数据表现更好,因为它们批量执行更新,只读查询的性能是主要关注点。此外,在中小规模的系统中,规范化数据模型通常是最好的,因为你不必担心保持数据的多个副本相互一致,执行连接的成本是可以接受的。然而,在非常大规模的系统中,连接的成本可能会成为问题。 @@ -200,7 +200,7 @@ db.users.aggregate([ 在 ["案例研究:社交网络首页时间线"](/ch2#sec_introduction_twitter) 中,我们比较了规范化表示([图 2-1](/ch2#fig_twitter_relational))和反规范化表示(预计算的物化时间线):这里,`posts` 和 `follows` 之间的连接太昂贵了,物化时间线是该连接结果的缓存。将新帖子插入关注者时间线的扇出过程是我们保持反规范化表示一致的方式。 -然而,X(前 Twitter)的物化时间线实现实际上并不存储每个帖子的实际文本:每个条目实际上只存储帖子 ID、发布者的用户 ID,以及一些额外的信息来识别转发和回复 [^11]。换句话说,它是(大约)以下查询的预计算结果: +然而,X(前 Twitter)的物化时间线实现实际上并不存储每个帖子的实际文本:每个条目实际上只存储帖子 ID、发布者的用户 ID,以及一些额外的信息来识别转发和回复 [^11]。换句话说,它大致是以下查询的预计算结果: ```sql SELECT posts.id, posts.sender_id @@ -211,11 +211,11 @@ SELECT posts.id, posts.sender_id LIMIT 1000 ``` -这意味着每当读取时间线时,服务仍然需要执行两个连接:通过 ID 查找帖子以获取实际的帖子内容(以及点赞数和回复数等统计信息),并通过 ID 查找发送者的个人资料(以获取他们的用户名、个人资料图片和其他详细信息)。这个通过 ID 查找人类可读信息的过程称为 *hydrating* ID,它本质上是在应用程序代码中执行的连接 [^11]。 +这意味着每当读取时间线时,服务仍然需要执行两个连接:通过 ID 查找帖子以获取实际的帖子内容(以及点赞数和回复数等统计信息),并通过 ID 查找发送者的个人资料(以获取他们的用户名、个人资料图片和其他详细信息)。这个将 ID 补全为人类可读信息的过程称为 *hydrating* ID,本质上是在应用程序代码中执行的连接 [^11]。 在预计算时间线中仅存储 ID 的原因是它们引用的数据变化很快:热门帖子的点赞数和回复数可能每秒变化多次,一些用户定期更改他们的用户名或个人资料照片。由于时间线在查看时应该显示最新的点赞数和个人资料图片,因此将此信息反规范化到物化时间线中是没有意义的。此外,这种反规范化会显著增加存储成本。 -这个例子表明,在读取数据时必须执行连接并不像有时声称的那样,是创建高性能、可扩展服务的障碍。Hydrating 帖子 ID 和用户 ID 实际上是一个相当容易扩展的操作,因为它可以很好地并行化,并且成本不取决于你关注的帐户数量或你拥有的关注者数量。 +这个例子表明,在读取数据时必须执行连接并不像有时声称的那样,是创建高性能、可扩展服务的障碍。`hydrating` 帖子 ID 和用户 ID 实际上是一个相当容易扩展的操作,因为它可以很好地并行化,并且成本不取决于你关注的账户数量或你拥有的关注者数量。 如果你需要决定是否在应用程序中反规范化某些内容,社交网络案例研究表明选择并不是立即显而易见的:最可扩展的方法可能涉及反规范化某些内容并保持其他内容规范化。你必须仔细考虑信息更改的频率以及读写成本(这可能由异常值主导,例如在典型社交网络的情况下拥有许多关注/关注者的用户)。规范化和反规范化本质上并不好或坏 —— 它们只是在读写性能以及实施工作量方面的权衡。 @@ -340,7 +340,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL XML 数据库通常使用 XQuery 和 XPath 查询,它们旨在允许复杂的查询,包括跨多个文档的连接,并将其结果格式化为 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 为 JSON 提供了等效于 XPath 的功能。 -MongoDB 的聚合管道,我们在 ["规范化、反规范化与连接"](/ch3#sec_datamodels_normalization) 中看到了其用于连接的 `$lookup` 运算符,是 JSON 文档集合查询语言的一个例子。 +MongoDB 的聚合管道,我们在 ["规范化、反规范化与连接"](/ch3#sec_datamodels_normalization) 中看到了其用于连接的 `$lookup` 算子,是 JSON 文档集合查询语言的一个例子。 让我们看另一个例子来感受这种语言 —— 这次是聚合,这对分析特别需要。想象你是一名海洋生物学家,每次你在海洋中看到动物时,你都会向数据库添加一条观察记录。现在你想生成一份报告,说明你每个月看到了多少条鲨鱼。在 PostgreSQL 中,你可能会这样表达该查询: @@ -373,9 +373,9 @@ db.observations.aggregate([ #### 文档和关系数据库的融合 {#convergence-of-document-and-relational-databases} -文档数据库和关系数据库最初是非常不同的数据管理方法,但随着时间的推移,它们变得更加相似 [^31]。关系数据库增加了对 JSON 类型和查询运算符的支持,以及索引文档内属性的能力。一些文档数据库(如 MongoDB、Couchbase 和 RethinkDB)增加了对连接、二级索引和声明式查询语言的支持。 +文档数据库和关系数据库最初是非常不同的数据管理方法,但随着时间的推移,它们变得更加相似 [^31]。关系数据库增加了对 JSON 类型和查询算子的支持,以及索引文档内属性的能力。一些文档数据库(如 MongoDB、Couchbase 和 RethinkDB)增加了对连接、二级索引和声明式查询语言的支持。 -模型的这种融合对应用程序开发人员来说是个好消息,因为当你可以在同一个数据库中组合两者时,关系模型和文档模型效果最好。许多文档数据库需要对其他文档的关系式引用,许多关系数据库在模式灵活性有益的部分。关系-文档混合是一个强大的组合。 +模型的这种融合对应用程序开发人员来说是个好消息,因为当你可以在同一个数据库中组合两者时,关系模型和文档模型效果最好。许多文档数据库需要对其他文档进行关系式引用,许多关系数据库也有一些场景更适合模式灵活性。关系-文档混合是一个强大的组合。 -------- @@ -531,7 +531,7 @@ RETURN person.name 在我们的示例中,这发生在 Cypher 查询中的 `() -[:WITHIN*0..]-> ()` 模式中。一个人的 `LIVES_IN` 边可能指向任何类型的位置:街道、城市、区(district)、地区(region)、州等。一个城市可能在(`WITHIN`)某个地区,该地区在(`WITHIN`)某个州,该州在(`WITHIN`)某个国家,等等。`LIVES_IN` 边可能直接指向你要查找的位置顶点,或者它可能在位置层次结构中相距几个级别。 -在 Cypher 中,`:WITHIN*0..` 非常简洁地表达了这个事实:它意味着"跟随 `WITHIN` 边,零次或多次"。它就像正则表达式中的 `*` 运算符。 +在 Cypher 中,`:WITHIN*0..` 非常简洁地表达了这个事实:它意味着"跟随 `WITHIN` 边,零次或多次"。它就像正则表达式中的 `*` 算子。 自 SQL:1999 以来,查询中可变长度遍历路径的想法可以使用称为 *递归公用表表达式*(`WITH RECURSIVE` 语法)的东西来表达。[示例 3-6](/ch3#fig_graph_sql_query) 显示了相同的查询 —— 查找从美国移民到欧洲的人的姓名 —— 使用此技术在 SQL 中表达。然而,与 Cypher 相比,语法非常笨拙。 @@ -781,7 +781,7 @@ Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小 在 [示例 3-12](/ch3#fig_datalog_query) 中,我们定义了三个派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虚拟表的名称和列由每个规则的 `:-` 符号之前出现的内容定义。例如,`migrated(PName, BornIn, LivingIn)` 是一个具有三列的虚拟表:一个人的姓名、他们出生地的名称和他们居住地的名称。 -虚拟表的内容由规则的 `:-` 符号之后的部分定义,我们在其中尝试查找表中匹配某种模式的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,变量 `PersonID` 绑定到值 `100`,变量 `PName` 绑定到值 `"Lucy"`。如果系统可以为 `:-` 运算符右侧的 *所有* 模式找到匹配项,则规则适用。当规则适用时,就好像 `:-` 的左侧被添加到数据库中(变量被它们匹配的值替换)。 +虚拟表的内容由规则的 `:-` 符号之后的部分定义,我们在其中尝试查找表中匹配某种模式的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,变量 `PersonID` 绑定到值 `100`,变量 `PName` 绑定到值 `"Lucy"`。如果系统可以为 `:-` 算子右侧的 *所有* 模式找到匹配项,则规则适用。当规则适用时,就好像 `:-` 的左侧被添加到数据库中(变量被它们匹配的值替换)。 因此,应用规则的一种可能方式是(如 [图 3-7](/ch3#fig_datalog_naive) 所示): @@ -803,7 +803,7 @@ Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小 GraphQL 是一种查询语言,从设计上讲,它比我们在本章中看到的其他查询语言限制性更强。GraphQL 的目的是允许在用户设备上运行的客户端软件(如移动应用程序或 JavaScript Web 应用程序前端)请求具有特定结构的 JSON 文档,其中包含渲染其用户界面所需的字段。GraphQL 接口允许开发人员快速更改客户端代码中的查询,而无需更改服务器端 API。 -GraphQL 的灵活性是有代价的。采用 GraphQL 的组织通常需要工具将 GraphQL 查询转换为对内部服务的请求,这些服务通常使用 REST 或 gRPC(参见 [第 5 章](/ch5#ch_encoding))。授权、速率限制和性能挑战是额外的关注点 [^61]。GraphQL 的查询语言也受到限制,因为 GraphQL 来自不受信任的来源。该语言不允许任何可能执行成本高昂的操作,否则用户可能通过运行大量昂贵的查询对服务器执行拒绝服务攻击。特别是,GraphQL 不允许递归查询(与 Cypher、SPARQL、SQL 或 Datalog 不同),并且不允许任意搜索条件,如"查找在美国出生并现在居住在欧洲的人"(除非服务所有者特别选择提供此类搜索功能)。 +GraphQL 的灵活性是有代价的。采用 GraphQL 的组织通常需要工具将 GraphQL 查询转换为对内部服务的请求,这些服务通常使用 REST 或 gRPC(参见 [第 5 章](/ch5#ch_encoding))。授权、速率限制和性能挑战是额外的关注点 [^61]。GraphQL 的查询语言也受到限制,因为 GraphQL 查询来自不受信任的来源。该语言不允许任何可能执行成本高昂的操作,否则用户可能通过运行大量昂贵的查询对服务器执行拒绝服务攻击。特别是,GraphQL 不允许递归查询(与 Cypher、SPARQL、SQL 或 Datalog 不同),并且不允许任意搜索条件,如"查找在美国出生并现在居住在欧洲的人"(除非服务所有者特别选择提供此类搜索功能)。 尽管如此,GraphQL 还是很有用的。[示例 3-13](/ch3#fig_graphql_query) 显示了如何使用 GraphQL 实现 Discord 或 Slack 等群聊应用程序。查询请求用户有权访问的所有频道,包括频道名称和每个频道中的 50 条最新消息。对于每条消息,它请求时间戳、消息内容以及消息发送者的姓名和个人资料图片 URL。此外,如果消息是对另一条消息的回复,查询还会请求发送者姓名和它所回复的消息内容(可能以较小的字体呈现在回复上方,以提供一些上下文)。 @@ -873,17 +873,17 @@ query ChatApp { 在我们迄今为止讨论的所有数据模型中,数据以与写入相同的形式被查询 —— 无论是 JSON 文档、表中的行,还是图中的顶点和边。然而,在复杂的应用程序中,有时很难找到一种能够满足所有不同查询和呈现数据方式的单一数据表示。在这种情况下,以一种形式写入数据,然后从中派生出针对不同类型读取优化的多种表示形式可能是有益的。 -我们之前在 ["记录系统和派生数据"](/ch1#sec_introduction_derived) 中看到了这个想法,ETL(参见 ["数据仓库"](/ch1#sec_introduction_dwh))就是这种派生过程的一个例子。现在我们将进一步深入这个想法。如果我们无论如何都要从一种数据表示派生出另一种,我们可以选择分别针对写入和读取优化的不同表示。如果你只想为写入优化数据建模,而不关心高效查询,你会如何建模? +我们之前在 ["记录系统与派生数据"](/ch1#sec_introduction_derived) 中看到了这个想法,ETL(参见 ["数据仓库"](/ch1#sec_introduction_dwh))就是这种派生过程的一个例子。现在我们将进一步深入这个想法。如果我们无论如何都要从一种数据表示派生出另一种,我们可以选择分别针对写入和读取优化的不同表示。如果你只想为写入优化数据建模,而不关心高效查询,你会如何建模? 也许写入数据的最简单、最快速和最具表现力的方式是 *事件日志*:每次你想写入一些数据时,你将其编码为自包含的字符串(可能是 JSON),包括时间戳,然后将其追加到事件序列中。此日志中的事件是 *不可变的*:你永远不会更改或删除它们,你只会向日志追加更多事件(这可能会取代早期事件)。事件可以包含任意属性。 [图 3-8](/ch3#fig_event_sourcing) 显示了一个可能来自会议管理系统的示例。会议可能是一个复杂的业务领域:不仅个人参与者可以注册并用信用卡付款,公司也可以批量订购座位,通过发票付款,然后再将座位分配给个人。一些座位可能为演讲者、赞助商、志愿者助手等保留。预订也可能被取消,与此同时,会议组织者可能通过将其移至不同的房间来更改活动的容量。在所有这些情况发生时,简单地计算可用座位数量就成为一个具有挑战性的查询。 -{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="图 3-8. 使用不可变事件日志作为真相源,并从中派生物化视图。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="图 3-8. 使用不可变事件日志作为真相来源(权威数据源),并从中派生物化视图。" class="w-full my-4" >}} 在 [图 3-8](/ch3#fig_event_sourcing) 中,会议状态的每个变化(例如组织者开放注册,或参与者进行和取消注册)首先被存储为事件。每当事件追加到日志时,几个 *物化视图*(也称为 *投影* 或 *读模型*)也会更新以反映该事件的影响。在会议示例中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为打印参与者徽章的打印机生成文件。 -使用事件作为真相源,并将每个状态变化表达为事件的想法被称为 *事件溯源* [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则称为 *命令查询责任分离(CQRS)* [^64]。这些术语起源于领域驱动设计(DDD)社区,尽管类似的想法已经存在很长时间了,例如 *状态机复制*(参见 ["使用共享日志"](/ch10#sec_consistency_smr))。 +使用事件作为真相来源(权威数据源),并将每个状态变化表达为事件的想法被称为 *事件溯源* [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则称为 *命令查询责任分离(CQRS)* [^64]。这些术语起源于领域驱动设计(DDD)社区,尽管类似的想法已经存在很长时间了,例如 *状态机复制*(参见 ["使用共享日志"](/ch10#sec_consistency_smr))。 当用户的请求进来时,它被称为 *命令*,首先需要验证。只有在命令已执行并确定有效(例如,请求的预订有足够的可用座位)后,它才成为事实,相应的事件被添加到日志中。因此,事件日志应该只包含有效事件,构建物化视图的事件日志消费者不允许拒绝事件。 @@ -906,7 +906,7 @@ query ChatApp { * 事件不可变的要求会在事件包含用户的个人数据时产生问题,因为用户可能行使他们的权利(例如,根据 GDPR)请求删除他们的数据。如果事件日志是基于每个用户的,你可以删除该用户的整个日志,但如果你的事件日志包含与多个用户相关的事件,这就不起作用了。你可以尝试将个人数据存储在实际事件之外,或者使用密钥对其进行加密,你可以稍后选择删除该密钥,但这也使得在需要时更难重新计算派生状态。 * 如果存在外部可见的副作用,重新处理事件需要小心 —— 例如,你可能不希望每次重建物化视图时都重新发送确认电子邮件。 -你可以在任何数据库之上实现事件溯源,但也有一些专门设计来支持这种模式的系统,例如 EventStoreDB、MartenDB(基于 PostgreSQL)和 Axon Framework。你还可以使用消息代理(如 Apache Kafka)来存储事件日志,流处理器可以使物化视图保持最新;我们将在 [待补充链接] 中返回这些主题。 +你可以在任何数据库之上实现事件溯源,但也有一些专门设计来支持这种模式的系统,例如 EventStoreDB、MartenDB(基于 PostgreSQL)和 Axon Framework。你还可以使用消息代理(如 Apache Kafka)来存储事件日志,流处理器可以使物化视图保持最新;我们将在 ["数据变更捕获与事件溯源"](/ch12#sec_stream_event_sourcing) 中回到这些主题。 唯一重要的要求是事件存储系统必须保证所有物化视图以与它们在日志中出现的完全相同的顺序处理事件;正如我们将在 [第 10 章](/ch10#ch_consistency) 中看到的,这在分布式系统中并不总是容易实现。 @@ -917,7 +917,7 @@ query ChatApp { 数据框是 R 语言、Python 的 Pandas 库、Apache Spark、ArcticDB、Dask 和其他系统支持的数据模型。它们是数据科学家为训练机器学习模型准备数据的流行工具,但它们也广泛用于数据探索、统计数据分析、数据可视化和类似目的。 -乍一看,数据框类似于关系数据库中的表或电子表格。它支持对数据框内容执行批量操作的类关系运算符:例如,将函数应用于所有行、基于某些条件过滤行、按某些列对行进行分组并聚合其他列,以及基于某个键将一个数据框中的行与另一个数据框连接(关系数据库称为 *连接* 的操作在数据框上通常称为 *合并*)。 +乍一看,数据框类似于关系数据库中的表或电子表格。它支持对数据框内容执行批量操作的类关系算子:例如,将函数应用于所有行、基于某些条件过滤行、按某些列对行进行分组并聚合其他列,以及基于某个键将一个数据框中的行与另一个数据框连接(关系数据库称为 *连接* 的操作在数据框上通常称为 *合并*)。 数据框通常不是通过声明式查询(如 SQL)而是通过一系列修改其结构和内容的命令来操作的。这符合数据科学家的典型工作流程,他们逐步"整理"数据,使其成为能够找到他们所提问题答案的形式。这些操作通常在数据科学家的数据集私有副本上进行,通常在他们的本地机器上,尽管最终结果可能与其他用户共享。 diff --git a/content/zh/ch4.md b/content/zh/ch4.md index eaf4cdb..f3a14f0 100644 --- a/content/zh/ch4.md +++ b/content/zh/ch4.md @@ -4,6 +4,8 @@ weight: 104 breadcrumbs: false --- + + ![](/map/ch03.png) > *生活的苦恼之一是,每个人对事物的命名都有些偏差。这让我们理解世界变得比本该有的样子困难一些,要是命名方式不同就好了。计算机的主要功能并不是传统意义上的计算,比如算术运算。[……] 它们主要是归档系统。* @@ -19,7 +21,7 @@ breadcrumbs: false 特别是,针对事务型工作负载(OLTP)优化的存储引擎和针对分析型工作负载优化的存储引擎之间存在巨大差异(我们在 ["分析型与事务型系统"](/ch1#sec_introduction_analytics) 中介绍了这种区别)。本章首先研究两种用于 OLTP 的存储引擎家族:写入不可变数据文件的 *日志结构* 存储引擎,以及像 *B 树* 这样就地更新数据的存储引擎。这些结构既用于键值存储,也用于二级索引。 -随后在 ["分析型数据存储"](#sec_storage_analytics) 中,我们将讨论一系列针对分析优化的存储引擎;在 ["多维索引与全文索引"](#sec_storage_multidimensional) 中,我们将简要介绍用于更高级查询(如文本检索)的索引。 +随后在 ["分析型数据存储"](/ch4#sec_storage_analytics) 中,我们将讨论一系列针对分析优化的存储引擎;在 ["多维索引与全文索引"](/ch4#sec_storage_multidimensional) 中,我们将简要介绍用于更高级查询(如文本检索)的索引。 ## OLTP 系统的存储与索引 {#sec_storage_oltp} @@ -39,7 +41,7 @@ db_get () { 这两个函数实现了一个键值存储。你可以调用 `db_set key value`,它将在数据库中存储 `key` 和 `value`。键和值可以是(几乎)任何你喜欢的内容 —— 例如,值可以是一个 JSON 文档。然后你可以调用 `db_get key`,它会查找与该特定键关联的最新值并返回它。 -它确实能工作: +麻雀虽小,五脏俱全: ```bash $ db_set 12 '{"name":"London","attractions":["Big Ben","London Eye"]}' @@ -85,7 +87,7 @@ $ cat database ### 日志结构存储 {#sec_storage_log_structured} -首先,让我们假设你想继续将数据存储在 `db_set` 写入的仅追加文件中,你只是想加快读取速度。一种方法是在内存中保留一个哈希映射,其中每个键都映射到文件中可以找到该键最新值的字节偏移量,如 [图 4-1](#fig_storage_csv_hash_index) 所示。 +首先,让我们假设你想继续将数据存储在 `db_set` 写入的仅追加文件中,你只是想加快读取速度。一种方法是在内存中保留一个哈希映射,其中每个键都映射到文件中可以找到该键最新值的字节偏移量,如 [图 4-1](/ch4#fig_storage_csv_hash_index) 所示。 {{< figure src="/fig/ddia_0401.png" id="fig_storage_csv_hash_index" caption="图 4-1. 以类似 CSV 格式存储键值对日志,使用内存哈希映射建立索引。" class="w-full my-4" >}} @@ -100,15 +102,15 @@ $ cat database #### SSTable 文件格式 {#the-sstable-file-format} -实际上,哈希表很少用于数据库索引,相反,保持数据 *按键排序* 的结构更为常见 [^3]。这种结构的一个例子是 *排序字符串表*(*Sorted String Table*),简称 *SSTable*,如 [图 4-2](#fig_storage_sstable_index) 所示。这种文件格式也存储键值对,但它确保它们按键排序,每个键在文件中只出现一次。 +实际上,哈希表很少用于数据库索引,相反,保持数据 *按键排序* 的结构更为常见 [^3]。这种结构的一个例子是 *排序字符串表*(*Sorted String Table*),简称 *SSTable*,如 [图 4-2](/ch4#fig_storage_sstable_index) 所示。这种文件格式也存储键值对,但它确保它们按键排序,每个键在文件中只出现一次。 {{< figure src="/fig/ddia_0402.png" id="fig_storage_sstable_index" caption="图 4-2. 带有稀疏索引的 SSTable,允许查询跳转到正确的块。" class="w-full my-4" >}} 现在你不需要在内存中保留所有键:你可以将 SSTable 中的键值对分组为几千字节的 *块*,然后在索引中存储每个块的第一个键。这种只存储部分键的索引称为 *稀疏* 索引。这个索引存储在 SSTable 的单独部分,例如使用不可变 B 树、字典树或其他允许查询快速查找特定键的数据结构 [^4]。 -例如,在 [图 4-2](#fig_storage_sstable_index) 中,一个块的第一个键是 `handbag`,下一个块的第一个键是 `handsome`。现在假设你要查找键 `handiwork`,它没有出现在稀疏索引中。由于排序,你知道 `handiwork` 必须出现在 `handbag` 和 `handsome` 之间。这意味着你可以寻找到 `handbag` 的偏移量,然后从那里扫描文件,直到找到 `handiwork`(或没有,如果该键不在文件中)。几千字节的块可以非常快速地扫描。 +例如,在 [图 4-2](/ch4#fig_storage_sstable_index) 中,一个块的第一个键是 `handbag`,下一个块的第一个键是 `handsome`。现在假设你要查找键 `handiwork`,它没有出现在稀疏索引中。由于排序,你知道 `handiwork` 必须出现在 `handbag` 和 `handsome` 之间。这意味着你可以寻找到 `handbag` 的偏移量,然后从那里扫描文件,直到找到 `handiwork`(或没有,如果该键不在文件中)。几千字节的块可以非常快速地扫描。 -此外,每个记录块都可以压缩(在 [图 4-2](#fig_storage_sstable_index) 中用阴影区域表示)。除了节省磁盘空间外,压缩还减少了 I/O 带宽使用,代价是使用更多一点的 CPU 时间。 +此外,每个记录块都可以压缩(在 [图 4-2](/ch4#fig_storage_sstable_index) 中用阴影区域表示)。除了节省磁盘空间外,压缩还减少了 I/O 带宽使用,代价是使用更多一点的 CPU 时间。 #### 构建和合并 SSTable {#constructing-and-merging-sstables} @@ -121,7 +123,7 @@ SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更 3. 为了读取某个键的值,首先尝试在内存表和最新的磁盘段中找到该键。如果没有找到,就在下一个较旧的段中查找,依此类推,直到找到键或到达最旧的段。如果键没有出现在任何段中,则它不存在于数据库中。 4. 不时地在后台运行合并和压实过程,以合并段文件并丢弃被覆盖或删除的值。 -合并段的工作方式类似于 *归并排序* 算法 [^5]。该过程如 [图 4-3](#fig_storage_sstable_merging) 所示:并排开始读取输入文件,查看每个文件中的第一个键,将最低的键(根据排序顺序)复制到输出文件,然后重复。如果同一个键出现在多个输入文件中,只保留较新的值。这会产生一个新的合并段文件,也按键排序,每个键只有一个值,并且它使用最少的内存,因为我们可以一次遍历一个键的 SSTable。 +合并段的工作方式类似于 *归并排序* 算法 [^5]。该过程如 [图 4-3](/ch4#fig_storage_sstable_merging) 所示:并排开始读取输入文件,查看每个文件中的第一个键,将最低的键(根据排序顺序)复制到输出文件,然后重复。如果同一个键出现在多个输入文件中,只保留较新的值。这会产生一个新的合并段文件,也按键排序,每个键只有一个值,并且它使用最少的内存,因为我们可以一次遍历一个键的 SSTable。 {{< figure src="/fig/ddia_0403.png" id="fig_storage_sstable_merging" caption="图 4-3. 合并多个 SSTable 段,仅保留每个键的最新值。" class="w-full my-4" >}} @@ -139,15 +141,17 @@ SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更 具有不可变段文件也简化了崩溃恢复:如果在写出内存表或合并段时发生崩溃,数据库可以删除未完成的 SSTable 并重新开始。将写入持久化到内存表的日志如果在写入记录的过程中发生崩溃,或者磁盘已满,可能包含不完整的记录;这些通常通过在日志中包含校验和来检测,并丢弃损坏或不完整的日志条目。我们将在 [第 8 章](/ch8#ch_transactions) 中更多地讨论持久性和崩溃恢复。 + + #### 布隆过滤器 {#bloom-filters} 使用 LSM 存储,读取很久以前更新的键或不存在的键可能会很慢,因为存储引擎需要检查多个段文件。为了加快此类读取,LSM 存储引擎通常在每个段中包含一个 *布隆过滤器*(*Bloom filter*)[^13],它提供了一种快速但近似的方法来检查特定键是否出现在特定 SSTable 中。 -[图 4-4](#fig_storage_bloom) 显示了一个包含两个键和 16 位的布隆过滤器示例(实际上,它会包含更多的键和更多的位)。对于 SSTable 中的每个键,我们计算一个哈希函数,产生一组数字,然后将其解释为位数组的索引 [^14]。我们将对应于这些索引的位设置为 1,其余保持为 0。例如,键 `handbag` 哈希为数字 (2, 9, 4),所以我们将第 2、9 和 4 位设置为 1。然后将位图与键的稀疏索引一起存储为 SSTable 的一部分。这需要一点额外的空间,但与 SSTable 的其余部分相比,布隆过滤器通常很小。 +[图 4-4](/ch4#fig_storage_bloom) 显示了一个包含两个键和 16 位的布隆过滤器示例(实际上,它会包含更多的键和更多的位)。对于 SSTable 中的每个键,我们计算一个哈希函数,产生一组数字,然后将其解释为位数组的索引 [^14]。我们将对应于这些索引的位设置为 1,其余保持为 0。例如,键 `handbag` 哈希为数字 (2, 9, 4),所以我们将第 2、9 和 4 位设置为 1。然后将位图与键的稀疏索引一起存储为 SSTable 的一部分。这需要一点额外的空间,但与 SSTable 的其余部分相比,布隆过滤器通常很小。 {{< figure src="/fig/ddia_0404.png" id="fig_storage_bloom" caption="图 4-4. 布隆过滤器提供了一种快速的概率检查,用于判断特定键是否存在于特定 SSTable 中。" class="w-full my-4" >}} -当我们想知道一个键是否出现在 SSTable 中时,我们像以前一样计算该键的相同哈希,并检查这些索引处的位。例如,在 [图 4-4](#fig_storage_bloom) 中,我们查询键 `handheld`,它哈希为 (6, 11, 2)。其中一个位是 1(即第 2 位),而另外两个是 0。这些检查可以使用所有 CPU 都支持的位运算非常快速地进行。 +当我们想知道一个键是否出现在 SSTable 中时,我们像以前一样计算该键的相同哈希,并检查这些索引处的位。例如,在 [图 4-4](/ch4#fig_storage_bloom) 中,我们查询键 `handheld`,它哈希为 (6, 11, 2)。其中一个位是 1(即第 2 位),而另外两个是 0。这些检查可以使用所有 CPU 都支持的位运算非常快速地进行。 如果至少有一个位是 0,我们知道该键肯定不在 SSTable 中。如果查询中的位都是 1,那么该键很可能在 SSTable 中,但也有可能是巧合,所有这些位都被其他键设置为 1。这种看起来键存在但实际上不存在的情况称为 *假阳性*(*false positive*)。 @@ -170,10 +174,12 @@ SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更 作为经验法则,如果你主要有写入而读取很少,分层压实表现更好,而如果你的工作负载以读取为主,分级压实表现更好。如果你频繁写入少量键,而很少写入大量键,那么分级压实也可能有优势 [^18]。 -尽管有许多细微之处,但 LSM 树的基本思想 —— 保持在后台合并的 SSTable 级联 —— 简单而有效。我们将在 ["比较 B 树与 LSM 树"](#sec_storage_btree_lsm_comparison) 中更详细地讨论它们的性能特征。 +尽管有许多细微之处,但 LSM 树的基本思想 —— 保持在后台合并的 SSTable 级联 —— 简单而有效。我们将在 ["比较 B 树与 LSM 树"](/ch4#sec_storage_btree_lsm_comparison) 中更详细地讨论它们的性能特征。 -------- + + > [!TIP] 嵌入式存储引擎 许多数据库作为接受网络查询的服务运行,但也有 *嵌入式* 数据库不公开网络 API。相反,它们是在与应用程序代码相同的进程中运行的库,通常读取和写入本地磁盘上的文件,你通过正常的函数调用与它们交互。嵌入式存储引擎的例子包括 RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB [^19]。 @@ -194,21 +200,21 @@ B 树于 1970 年引入 [^21],不到 10 年后就被称为"无处不在"[^22] 我们之前看到的日志结构索引将数据库分解为可变大小的 *段*,通常为几兆字节或更大,写入一次后就不可变。相比之下,B 树将数据库分解为固定大小的 *块* 或 *页*,并可能就地覆盖页。页传统上大小为 4 KiB,但 PostgreSQL 现在默认使用 8 KiB,MySQL 默认使用 16 KiB。 -每个页都可以使用页号来标识,这允许一个页引用另一个页 —— 类似于指针,但在磁盘上而不是在内存中。如果所有页都存储在同一个文件中,将页号乘以页大小就给我们文件中页所在位置的字节偏移量。我们可以使用这些页引用来构建页树,如 [图 4-5](#fig_storage_b_tree) 所示。 +每个页都可以使用页号来标识,这允许一个页引用另一个页 —— 类似于指针,但在磁盘上而不是在内存中。如果所有页都存储在同一个文件中,将页号乘以页大小就给我们文件中页所在位置的字节偏移量。我们可以使用这些页引用来构建页树,如 [图 4-5](/ch4#fig_storage_b_tree) 所示。 {{< figure src="/fig/ddia_0405.png" id="fig_storage_b_tree" caption="图 4-5. 使用 B 树索引查找键 251。从根页开始,我们首先跟随引用到键 200–300 的页,然后是键 250–270 的页。" class="w-full my-4" >}} 一个页被指定为 B 树的 *根*;每当你想在索引中查找一个键时,你就从这里开始。该页包含几个键和对子页的引用。每个子页负责一个连续的键范围,引用之间的键指示这些范围之间的边界在哪里。(这种结构有时称为 B+ 树,但我们不需要将其与其他 B 树变体区分开来。) -在 [图 4-5](#fig_storage_b_tree) 的例子中,我们正在查找键 251,所以我们知道我们需要跟随边界 200 和 300 之间的页引用。这将我们带到一个看起来相似的页,该页进一步将 200–300 范围分解为子范围。最终我们到达包含单个键的页(*叶页*),该页要么内联包含每个键的值,要么包含对可以找到值的页的引用。 +在 [图 4-5](/ch4#fig_storage_b_tree) 的例子中,我们正在查找键 251,所以我们知道我们需要跟随边界 200 和 300 之间的页引用。这将我们带到一个看起来相似的页,该页进一步将 200–300 范围分解为子范围。最终我们到达包含单个键的页(*叶页*),该页要么内联包含每个键的值,要么包含对可以找到值的页的引用。 -B 树的一个页中对子页的引用数称为 *分支因子*。例如,在 [图 4-5](#fig_storage_b_tree) 中,分支因子为六。实际上,分支因子取决于存储页引用和范围边界所需的空间量,但通常为几百。 +B 树的一个页中对子页的引用数称为 *分支因子*。例如,在 [图 4-5](/ch4#fig_storage_b_tree) 中,分支因子为六。实际上,分支因子取决于存储页引用和范围边界所需的空间量,但通常为几百。 如果你想更新 B 树中现有键的值,你搜索包含该键的叶页,并用包含新值的版本覆盖磁盘上的该页。如果你想添加一个新键,你需要找到其范围包含新键的页并将其添加到该页。如果页中没有足够的空闲空间来容纳新键,则页被分成两个半满的页,并更新父页以说明键范围的新细分。 {{< figure src="/fig/ddia_0406.png" id="fig_storage_b_tree_split" caption="图 4-6. 通过在边界键 337 上分割页来增长 B 树。父页被更新以引用两个子页。" class="w-full my-4" >}} -在 [图 4-6](#fig_storage_b_tree_split) 的例子中,我们想插入键 334,但范围 333–345 的页已经满了。因此,我们将其分成范围 333–337(包括新键)的页和 337–344 的页。我们还必须更新父页以引用两个子页,它们之间的边界值为 337。如果父页没有足够的空间容纳新引用,它也可能需要被分割,分割可以一直持续到树的根。当根被分割时,我们在它上面创建一个新根。删除键(可能需要合并节点)更复杂 [^5]。 +在 [图 4-6](/ch4#fig_storage_b_tree_split) 的例子中,我们想插入键 334,但范围 333–345 的页已经满了。因此,我们将其分成范围 333–337(包括新键)的页和 337–344 的页。我们还必须更新父页以引用两个子页,它们之间的边界值为 337。如果父页没有足够的空间容纳新引用,它也可能需要被分割,分割可以一直持续到树的根。当根被分割时,我们在它上面创建一个新根。删除键(可能需要合并节点)更复杂 [^5]。 这个算法确保树保持 *平衡*:具有 *n* 个键的 B 树始终具有 *O*(log *n*) 的深度。大多数数据库可以适合三或四层深的 B 树,所以你不需要跟随许多页引用来找到你要查找的页。(具有 500 分支因子的 4 KiB 页的四层树可以存储多达 250 TB。) @@ -249,13 +255,13 @@ B 树的基本底层写操作是用新数据覆盖磁盘上的页。假设覆盖 使用 B 树时,如果应用程序写入的键分散在整个键空间中,生成的磁盘操作也会随机分散,因为存储引擎需要覆盖的页可能位于磁盘的任何位置。另一方面,日志结构存储引擎一次写入整个段文件(无论是写出内存表还是压实现有段),这比 B 树中的页大得多。 -许多小的、分散的写入模式(如 B 树中的)称为 *随机写入*,而较少的大写入模式(如 LSM 树中的)称为 *顺序写入*。磁盘通常具有比随机写入更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘(HDD)上特别大;在今天大多数数据库使用的固态硬盘(SSD)上,差异较小,但仍然明显(参见 ["SSD 上的顺序与随机写入"](#sidebar_sequential))。 +许多小的、分散的写入模式(如 B 树中的)称为 *随机写入*,而较少的大写入模式(如 LSM 树中的)称为 *顺序写入*。磁盘通常具有比随机写入更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘(HDD)上特别大;在今天大多数数据库使用的固态硬盘(SSD)上,差异较小,但仍然明显(参见 ["SSD 上的顺序与随机写入"](/ch4#sidebar_sequential))。 -------- > [!TIP] SSD 上的顺序与随机写入 -在旋转磁盘硬盘(HDD)上,顺序写入比随机写入快得多:随机写入必须机械地将磁头移动到新位置,并等待盘片的正确部分经过磁头下方,这需要几毫秒 —— 在计算时间尺度上是永恒的。然而,SSD(固态硬盘)包括 NVMe(非易失性内存快速,即连接到 PCI Express 总线的闪存)现在已经在许多用例中超越了 HDD,它们不受这种机械限制。 +在旋转磁盘硬盘(HDD)上,顺序写入比随机写入快得多:随机写入必须机械地将磁头移动到新位置,并等待盘片的正确部分经过磁头下方,这需要几毫秒 —— 在计算时间尺度上是永恒的。然而,SSD(固态硬盘)包括 NVMe(Non-Volatile Memory Express,即连接到 PCI Express 总线的闪存)现在已经在许多场景中超越了 HDD,它们不受这种机械限制。 尽管如此,SSD 对顺序写入的吞吐量也高于随机写入。原因是闪存可以一次读取或写入一页(通常为 4 KiB),但只能一次擦除一个块(通常为 512 KiB)。块中的某些页可能包含有效数据,而其他页可能包含不再需要的数据。在擦除块之前,控制器必须首先将包含有效数据的页移动到其他块中;这个过程称为 *垃圾回收*(GC)[^33]。 @@ -283,7 +289,7 @@ B 树索引必须至少写入每条数据两次:一次写入预写日志,一 B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了大量键,数据库文件可能包含许多 B 树不再使用的页。对 B 树的后续添加可以使用这些空闲页,但它们不能轻易地返回给操作系统,因为它们在文件的中间,所以它们仍然占用文件系统上的空间。因此,数据库需要一个后台过程来移动页以更好地放置它们,例如 PostgreSQL 中的真空过程 [^25]。 -碎片化在 LSM 树中不太成问题,因为压实过程无论如何都会定期重写数据文件,而且 SSTable 没有未使用空间的页。此外,SSTable 中的键值对块可以更好地压缩,因此通常比 B 树在磁盘上产生更小的文件。被覆盖的键和值继续消耗空间,直到它们被压实删除,但使用分级压实时,这种开销相当低 [^40] [^41]。分层压实(参见 ["压实策略"](#sec_storage_lsm_compaction))使用更多的磁盘空间,特别是在压实期间临时使用。 +碎片化在 LSM 树中不太成问题,因为压实过程无论如何都会定期重写数据文件,而且 SSTable 没有未使用空间的页。此外,SSTable 中的键值对块可以更好地压缩,因此通常比 B 树在磁盘上产生更小的文件。被覆盖的键和值继续消耗空间,直到它们被压实删除,但使用分级压实时,这种开销相当低 [^40] [^41]。分层压实(参见 ["压实策略"](/ch4#sec_storage_lsm_compaction))使用更多的磁盘空间,特别是在压实期间临时使用。 在磁盘上有一些数据的多个副本也可能是一个问题,当你需要删除一些数据,并确信它真的已被删除(也许是为了遵守数据保护法规)。例如,在大多数 LSM 存储引擎中,已删除的记录可能仍然存在于较高级别中,直到代表删除的墓碑通过所有压实级别传播,这可能需要很长时间。专门的存储引擎设计可以更快地传播删除 [^42]。 @@ -306,7 +312,7 @@ B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了 * 或者,值可以是对实际数据的引用:要么是相关行的主键(InnoDB 对二级索引这样做),要么是对磁盘上位置的直接引用。在后一种情况下,存储行的地方称为 *堆文件*,它以无特定顺序存储数据(它可能是仅追加的,或者它可能跟踪已删除的行以便稍后用新数据覆盖它们)。例如,Postgres 使用堆文件方法 [^44]。 * 两者之间的折中是 *覆盖索引* 或 *包含列的索引*,它在索引中存储表的 *某些* 列,除了在堆上或主键聚簇索引中存储完整行 [^45]。这允许仅使用索引来回答某些查询,而无需解析主键或查看堆文件(在这种情况下,索引被称为 *覆盖* 查询)。这可以使某些查询更快,但数据的重复意味着索引使用更多的磁盘空间并减慢写入速度。 -到目前为止讨论的索引只将单个键映射到值。如果你需要同时查询表的多个列(或文档中的多个字段),请参见 ["多维索引与全文索引"](#sec_storage_multidimensional)。 +到目前为止讨论的索引只将单个键映射到值。如果你需要同时查询表的多个列(或文档中的多个字段),请参见 ["多维索引与全文索引"](/ch4#sec_storage_multidimensional)。 当更新值而不更改键时,堆文件方法可以允许记录就地覆盖,前提是新值不大于旧值。如果新值更大,情况会更复杂,因为它可能需要移动到堆中有足够空间的新位置。在这种情况下,要么所有索引都需要更新以指向记录的新堆位置,要么在旧堆位置留下转发指针 [^2]。 @@ -314,7 +320,7 @@ B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了 本章到目前为止讨论的数据结构都是对磁盘限制的回应。与主内存相比,磁盘很难处理。对于磁盘和 SSD,如果你想在读取和写入上获得良好的性能,磁盘上的数据需要仔细布局。然而,我们容忍这种尴尬,因为磁盘有两个显著的优势:它们是持久的(如果断电,其内容不会丢失),并且它们每千兆字节的成本比 RAM 低。 -随着 RAM 变得更便宜,每千兆字节成本的论点被侵蚀。许多数据集根本不是那么大,因此将它们完全保留在内存中是完全可行的,可能分布在几台机器上。这导致了 *内存数据库* 的发展。 +随着 RAM 变得更便宜,按每 GB 计价的成本优势正在减弱。许多数据集根本没有那么大,因此将它们完全保留在内存中是完全可行的,甚至可以分布在几台机器上。这导致了 *内存数据库* 的发展。 一些内存键值存储,例如 Memcached,仅用于缓存,如果机器重新启动,数据丢失是可以接受的。但其他内存数据库旨在实现持久性,这可以通过特殊硬件(例如电池供电的 RAM)、将更改日志写入磁盘、将定期快照写入磁盘或将内存状态复制到其他机器来实现。 @@ -331,7 +337,7 @@ Redis 和 Couchbase 通过异步写入磁盘提供弱持久性。 ## 分析型数据存储 {#sec_storage_analytics} -数据仓库的数据模型最常见的是关系型,因为 SQL 通常非常适合分析查询。有许多图形数据分析工具可以生成 SQL 查询、可视化结果,并允许分析师探索数据(通过 *下钻* 和 *切片切块* 等操作)。 +数据仓库的数据模型最常见的是关系型,因为 SQL 通常非常适合分析查询。有许多图形化数据分析工具可以生成 SQL 查询、可视化结果,并允许分析师探索数据(通过 *下钻* 和 *切片切块* 等操作)。 表面上,数据仓库和关系型 OLTP 数据库看起来很相似,因为它们都有 SQL 查询接口。然而,系统的内部可能看起来完全不同,因为它们针对非常不同的查询模式进行了优化。许多数据库供应商现在专注于支持事务处理或分析工作负载,但不是两者兼而有之。 @@ -343,16 +349,16 @@ Teradata、Vertica 和 SAP HANA 等数据仓库供应商既销售商业许可下 云数据仓库往往与其他云服务更好地集成,并且更具弹性。例如,许多云仓库支持自动日志摄取,并提供与数据处理框架(如 Google Cloud 的 Dataflow 或 Amazon Web Services 的 Kinesis)的轻松集成。这些仓库也更具弹性,因为它们将查询计算与存储层解耦 [^54]。数据持久存储在对象存储而不是本地磁盘上,这使得可以独立调整存储容量和查询的计算资源,正如我们之前在 ["云原生系统架构"](/ch1#sec_introduction_cloud_native) 中看到的。 -Apache Hive、Trino 和 Apache Spark 等开源数据仓库也随着云的发展而发展。随着分析数据存储转移到对象存储上的数据湖,开源仓库已经开始分解 [^55]。以下组件以前集成在单个系统(如 Apache Hive)中,现在通常作为单独的组件实现: +Apache Hive、Trino 和 Apache Spark 等开源数据仓库也随着云的发展而发展。随着分析数据存储转移到对象存储上的数据湖,开源仓库也开始解耦拆分 [^55]。以下组件以前集成在单个系统(如 Apache Hive)中,现在通常作为单独的组件实现: 查询引擎 -: Trino、Apache DataFusion 和 Presto 等查询引擎解析 SQL 查询,将其优化为执行计划,并针对数据执行它们。执行通常需要并行、分布式数据处理任务。一些查询引擎提供内置任务执行,而其他选择使用第三方执行框架,如 Apache Spark 或 Apache Flink。 +: Trino、Apache DataFusion 和 Presto 等查询引擎解析 SQL 查询,将其优化为执行计划,并在数据上执行这些计划。执行通常需要并行、分布式的数据处理任务。一些查询引擎提供内置任务执行,而有些则选择使用第三方执行框架,如 Apache Spark 或 Apache Flink。 存储格式 : 存储格式确定表的行如何编码为文件中的字节,然后通常存储在对象存储或分布式文件系统中 [^12]。然后查询引擎可以访问这些数据,但使用数据湖的其他应用程序也可以访问。此类存储格式的示例包括 Parquet、ORC、Lance 或 Nimble,我们将在下一节中看到更多关于它们的内容。 表格式 -: 以 Apache Parquet 和类似存储格式编写的文件一旦编写通常是不可变的。为了支持行插入和删除,使用 Apache Iceberg 或 Databricks 的 Delta 格式等表格式。表格式指定定义哪些文件构成表以及表模式的文件格式。此类格式还提供高级功能,例如时间旅行(查询表在以前时间点的能力)、垃圾回收,甚至事务。 +: 以 Apache Parquet 和类似存储格式编写的文件一旦写入通常就是不可变的。为了支持行插入和删除,通常会使用 Apache Iceberg 或 Databricks Delta 等表格式。表格式规定了哪些文件构成一张表,以及表模式的定义格式。此类格式还提供高级功能,例如时间旅行(查询表在过去某个时间点状态的能力)、垃圾回收,甚至事务。 数据目录 : 就像表格式定义哪些文件构成表一样,数据目录定义哪些表组成数据库。目录用于创建、重命名和删除表。与存储和表格式不同,Snowflake 的 Polaris 和 Databricks 的 Unity Catalog 等数据目录通常作为可以使用 REST 接口查询的独立服务运行。Apache Iceberg 也提供目录,可以在客户端内运行或作为单独的进程运行。查询引擎在读取和写入表时使用目录信息。传统上,目录和查询引擎已经集成,但将它们解耦使数据发现和数据治理系统(在 ["数据系统、法律和社会"](/ch1#sec_introduction_compliance) 中讨论)也能够访问目录的元数据。 @@ -361,7 +367,7 @@ Apache Hive、Trino 和 Apache Spark 等开源数据仓库也随着云的发展 如 ["星型和雪花型:分析模式"](/ch3#sec_datamodels_analytics) 中所讨论的,数据仓库按照惯例通常使用带有大型事实表的关系模式,该表包含对维度表的外键引用。如果你的事实表中有数万亿行和数 PB 的数据,有效地存储和查询它们就成为一个具有挑战性的问题。维度表通常要小得多(数百万行),因此在本节中我们将重点关注事实的存储。 -尽管事实表通常有超过 100 列,但典型的数据仓库查询一次只访问其中的 4 或 5 列(分析很少需要 `"SELECT *"` 查询)[^52]。以 [示例 4-1](#fig_storage_analytics_query) 中的查询为例:它访问大量行(2024 日历年期间每次有人购买水果或糖果的情况),但它只需要访问 `fact_sales` 表的三列:`date_key`、`product_sk` 和 `quantity`。查询忽略所有其他列。 +尽管事实表通常有超过 100 列,但典型的数据仓库查询一次只访问其中的 4 或 5 列(分析很少需要 `"SELECT *"` 查询)[^52]。以 [示例 4-1](/ch4#fig_storage_analytics_query) 中的查询为例:它访问大量行(2024 日历年期间每次有人购买水果或糖果的情况),但它只需要访问 `fact_sales` 表的三列:`date_key`、`product_sk` 和 `quantity`。查询忽略所有其他列。 {{< figure id="fig_storage_analytics_query" title="示例 4-1. 分析人们是否更倾向于购买新鲜水果或糖果,取决于星期几" class="w-full my-4" >}} @@ -381,11 +387,11 @@ GROUP BY 我们如何高效地执行这个查询? -在大多数 OLTP 数据库中,存储是以 *面向行* 的方式布局的:表中一行的所有值彼此相邻存储。文档数据库类似:整个文档通常作为一个连续的字节序列存储。你可以在 [图 4-1](#fig_storage_csv_hash_index) 的 CSV 示例中看到这一点。 +在大多数 OLTP 数据库中,存储是以 *面向行* 的方式布局的:表中一行的所有值彼此相邻存储。文档数据库类似:整个文档通常作为一个连续的字节序列存储。你可以在 [图 4-1](/ch4#fig_storage_csv_hash_index) 的 CSV 示例中看到这一点。 -为了处理像 [示例 4-1](#fig_storage_analytics_query) 这样的查询,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,告诉存储引擎在哪里找到特定日期或特定产品的所有销售。但是,面向行的存储引擎仍然需要将所有这些行(每行包含超过 100 个属性)从磁盘加载到内存中,解析它们,并过滤掉不符合所需条件的行。这可能需要很长时间。 +为了处理像 [示例 4-1](/ch4#fig_storage_analytics_query) 这样的查询,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,告诉存储引擎在哪里找到特定日期或特定产品的所有销售。但是,面向行的存储引擎仍然需要将所有这些行(每行包含超过 100 个属性)从磁盘加载到内存中,解析它们,并过滤掉不符合所需条件的行。这可能需要很长时间。 -*面向列*(或 *列式*)存储背后的想法很简单:不要将一行中的所有值存储在一起,而是将每 *列* 中的所有值存储在一起 [^56]。如果每列单独存储,查询只需要读取和解析该查询中使用的那些列,这可以节省大量工作。[图 4-7](#fig_column_store) 使用 [图 3-5](/ch3#fig_dwh_schema) 中事实表的扩展版本展示了这一原理。 +*面向列*(或 *列式*)存储背后的想法很简单:不要将一行中的所有值存储在一起,而是将每 *列* 中的所有值存储在一起 [^56]。如果每列单独存储,查询只需要读取和解析该查询中使用的那些列,这可以节省大量工作。[图 4-7](/ch4#fig_column_store) 使用 [图 3-5](/ch3#fig_dwh_schema) 中事实表的扩展版本展示了这一原理。 -------- @@ -406,13 +412,13 @@ GROUP BY 除了只从磁盘加载查询所需的那些列之外,我们还可以通过压缩数据进一步减少对磁盘吞吐量和网络带宽的需求。幸运的是,面向列的存储通常非常适合压缩。 -看看 [图 4-7](#fig_column_store) 中每列的值序列:它们看起来经常重复,这是压缩的良好迹象。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是 *位图编码*,如 [图 4-8](#fig_bitmap_index) 所示。 +看看 [图 4-7](/ch4#fig_column_store) 中每列的值序列:它们看起来经常重复,这是压缩的良好迹象。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是 *位图编码*,如 [图 4-8](/ch4#fig_bitmap_index) 所示。 {{< figure src="/fig/ddia_0408.png" id="fig_bitmap_index" caption="图 4-8. 单列的压缩、位图索引存储。" class="w-full my-4" >}} 通常,列中不同值的数量与行数相比很小(例如,零售商可能有数十亿条销售交易,但只有 100,000 种不同的产品)。我们现在可以将具有 *n* 个不同值的列转换为 *n* 个单独的位图:每个不同值一个位图,每行一位。如果该行具有该值,则该位为 1,否则为 0。 -一种选择是使用每行一位来存储这些位图。然而,这些位图通常包含大量零(我们说它们是 *稀疏* 的)。在这种情况下,位图可以另外进行游程编码:计算连续零或一的数量并存储该数字,如 [图 4-8](#fig_bitmap_index) 底部所示。诸如 *咆哮位图*(*roaring bitmaps*)之类的技术在两种位图表示之间切换,使用最紧凑的表示 [^73]。这可以使列的编码非常高效。 +一种选择是使用每行一位来存储这些位图。然而,这些位图通常包含大量零(我们说它们是 *稀疏* 的)。在这种情况下,位图可以另外进行游程编码:计算连续零或一的数量并存储该数字,如 [图 4-8](/ch4#fig_bitmap_index) 底部所示。诸如 *咆哮位图*(*roaring bitmaps*)之类的技术在两种位图表示之间切换,使用最紧凑的表示 [^73]。这可以使列的编码非常高效。 像这样的位图索引非常适合数据仓库中常见的查询类型。例如: @@ -439,9 +445,9 @@ GROUP BY 相反,数据需要一次排序整行,即使它是按列存储的。数据库管理员可以使用他们对常见查询的了解来选择表应按哪些列排序。例如,如果查询经常针对日期范围(例如上个月),则将 `date_key` 作为第一个排序键可能是有意义的。然后查询可以只扫描上个月的行,这将比扫描所有行快得多。 -第二列可以确定在第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是 [图 4-7](#fig_column_store) 中的第一个排序键,那么 `product_sk` 作为第二个排序键可能是有意义的,这样同一天同一产品的所有销售都在存储中分组在一起。这将有助于需要在某个日期范围内按产品分组或过滤销售的查询。 +第二列可以确定在第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是 [图 4-7](/ch4#fig_column_store) 中的第一个排序键,那么 `product_sk` 作为第二个排序键可能是有意义的,这样同一天同一产品的所有销售都在存储中分组在一起。这将有助于需要在某个日期范围内按产品分组或过滤销售的查询。 -排序顺序的另一个优点是它可以帮助压缩列。如果主排序列没有许多不同的值,那么排序后,它将有很长的序列,其中相同的值在一行中重复多次。简单的游程编码,就像我们在 [图 4-8](#fig_bitmap_index) 中用于位图的那样,可以将该列压缩到几千字节 —— 即使表有数十亿行。 +排序顺序的另一个优点是它可以帮助压缩列。如果主排序列没有许多不同的值,那么排序后,它将有很长的序列,其中相同的值在一行中重复多次。简单的游程编码,就像我们在 [图 4-8](/ch4#fig_bitmap_index) 中用于位图的那样,可以将该列压缩到几千字节 —— 即使表有数十亿行。 该压缩效果在第一个排序键上最强。第二和第三个排序键将更加混乱,因此不会有如此长的重复值运行。排序优先级较低的列基本上以随机顺序出现,因此它们可能不会压缩得那么好。但是,让前几列排序仍然是整体上的胜利。 @@ -460,7 +466,7 @@ GROUP BY 用于分析的复杂 SQL 查询被分解为由多个阶段组成的 *查询计划*,称为 *算子*,这些算子可能分布在多台机器上以并行执行。查询规划器可以通过选择使用哪些算子、以何种顺序执行它们以及在哪里运行每个算子来执行大量优化。 -在每个算子内,查询引擎需要对列中的值执行各种操作,例如查找值在特定值集中的所有行(可能作为连接的一部分),或检查值是否大于 15。它还需要查看同一行的几列,例如查找产品是香蕉且商店是特定感兴趣商店的所有销售交易。 +在每个算子内,查询引擎需要对列中的值执行各种操作,例如查找值在特定值集中的所有行(可能作为连接的一部分),或检查值是否大于 15。它还需要查看同一行的几列,例如查找产品是香蕉且门店是某个特定目标门店的所有销售交易。 对于需要扫描数百万行的数据仓库查询,我们不仅需要担心它们需要从磁盘读取的数据量,还需要担心执行复杂算子所需的 CPU 时间。最简单的算子类型就像编程语言的解释器:在遍历每一行时,它检查表示查询的数据结构,以找出需要对哪些列执行哪些比较或计算。不幸的是,这对许多分析目的来说太慢了。高效查询执行的两种替代方法已经出现 [^77]: @@ -470,7 +476,7 @@ GROUP BY 向量化处理 : 查询被解释,而不是编译,但通过批量处理列中的许多值而不是逐行迭代来提高速度。一组固定的预定义算子内置在数据库中;我们可以向它们传递参数并获得一批结果 [^50] [^75]。 -例如,我们可以将 `product_sk` 列和"香蕉"的 ID 传递给相等算子,并获得一个位图(输入列中每个值一位,如果是香蕉则为 1);然后我们可以将 `store_sk` 列和感兴趣商店的 ID 传递给相同的相等算子,并获得另一个位图;然后我们可以将两个位图传递给"按位 AND"算子,如 [图 4-9](#fig_bitmap_and) 所示。结果将是一个位图,包含特定商店中所有香蕉销售的 1。 +例如,我们可以将 `product_sk` 列和"香蕉"的 ID 传递给相等算子,并获得一个位图(输入列中每个值一位,如果是香蕉则为 1);然后我们可以将 `store_sk` 列和感兴趣商店的 ID 传递给相同的相等算子,并获得另一个位图;然后我们可以将两个位图传递给"按位 AND"算子,如 [图 4-9](/ch4#fig_bitmap_and) 所示。结果将是一个位图,包含特定商店中所有香蕉销售的 1。 {{< figure src="/fig/ddia_0409.png" id="fig_bitmap_and" caption="图 4-9. 两个位图之间的按位 AND 适合向量化。" class="w-full my-4" >}} @@ -481,23 +487,23 @@ GROUP BY * 利用并行性,例如多线程和单指令多数据(SIMD)指令 [^79] [^80],以及 * 直接对压缩数据进行操作,而无需将其解码为单独的内存表示,这可以节省内存分配和复制成本。 -### 物化视图与多维数据集 {#sec_storage_materialized_views} +### 物化视图与数据立方体 {#sec_storage_materialized_views} 我们之前在 ["物化和更新时间线"](/ch2#sec_introduction_materializing) 中遇到了 *物化视图*:在关系数据模型中,它们是表状对象,其内容是某些查询的结果。区别在于物化视图是查询结果的实际副本,写入磁盘,而虚拟视图只是编写查询的快捷方式。当你从虚拟视图读取时,SQL 引擎会即时将其扩展为视图的基础查询,然后处理扩展的查询。 当基础数据更改时,物化视图需要相应更新。一些数据库可以自动执行此操作,还有像 Materialize 这样专门从事物化视图维护的系统 [^81]。执行此类更新意味着写入时需要更多工作,但物化视图可以改善在重复需要执行相同查询的工作负载中的读取性能。 -*物化聚合* 是一种可以在数据仓库中有用的物化视图类型。如前所述,数据仓库查询通常涉及聚合函数,例如 SQL 中的 `COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX`。如果许多不同的查询使用相同的聚合,每次都处理原始数据可能会很浪费。为什么不缓存查询最常使用的一些计数或总和?*多维数据集* 或 *OLAP 立方体* 通过创建按不同维度分组的聚合网格来做到这一点 [^82]。[图 4-10](#fig_data_cube) 显示了一个示例。 +*物化聚合* 是一种可以在数据仓库中有用的物化视图类型。如前所述,数据仓库查询通常涉及聚合函数,例如 SQL 中的 `COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX`。如果许多不同的查询使用相同的聚合,每次都处理原始数据可能会很浪费。为什么不缓存查询最常使用的一些计数或总和?*数据立方体*(*OLAP 立方体*)通过创建按不同维度分组的聚合网格来做到这一点 [^82]。[图 4-10](/ch4#fig_data_cube) 显示了一个示例。 -{{< figure src="/fig/ddia_0410.png" id="fig_data_cube" caption="图 4-10. 多维数据集的两个维度,通过求和聚合数据。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0410.png" id="fig_data_cube" caption="图 4-10. 数据立方体的两个维度,通过求和聚合数据。" class="w-full my-4" >}} -现在假设每个事实只有两个维度表的外键 —— 在 [图 4-10](#fig_data_cube) 中,这些是 `date_key` 和 `product_sk`。你现在可以绘制一个二维表,日期沿着一个轴,产品沿着另一个轴。每个单元格包含具有该日期-产品组合的所有事实的属性(例如 `net_price`)的聚合(例如 `SUM`)。然后,你可以沿着每行或列应用相同的聚合,并获得已减少一个维度的摘要(不管日期的产品销售,或不管产品的日期销售)。 +现在假设每个事实只有两个维度表的外键 —— 在 [图 4-10](/ch4#fig_data_cube) 中,这些是 `date_key` 和 `product_sk`。你现在可以绘制一个二维表,日期沿着一个轴,产品沿着另一个轴。每个单元格包含具有该日期-产品组合的所有事实的属性(例如 `net_price`)的聚合(例如 `SUM`)。然后,你可以沿着每行或列应用相同的聚合,并获得已减少一个维度的摘要(不管日期的产品销售,或不管产品的日期销售)。 一般来说,事实通常有两个以上的维度。在 [图 3-5](/ch3#fig_dwh_schema) 中有五个维度:日期、产品、商店、促销和客户。很难想象五维超立方体会是什么样子,但原理保持不变:每个单元格包含特定日期-产品-商店-促销-客户组合的销售。然后可以沿着每个维度重复汇总这些值。 -物化多维数据集的优点是某些查询变得非常快,因为它们已经有效地预先计算了。例如,如果你想知道昨天每个商店的总销售额,你只需要查看适当维度的总计 —— 不需要扫描数百万行。 +物化数据立方体的优点是某些查询会变得非常快,因为结果已经被预先计算好了。例如,如果你想知道昨天每个商店的总销售额,你只需要查看相应维度上的汇总值 —— 不需要扫描数百万行。 -缺点是多维数据集没有与查询原始数据相同的灵活性。例如,没有办法计算成本超过 100 美元的商品的销售比例,因为价格不是维度之一。因此,大多数数据仓库尽可能多地保留原始数据,并仅将聚合(如多维数据集)用作某些查询的性能提升。 +缺点是数据立方体不像直接查询原始数据那样灵活。例如,没有办法计算售价超过 100 美元的商品销售占比,因为价格并不是其中一个维度。因此,大多数数据仓库都会尽可能保留原始数据,只把这类聚合(如数据立方体)当作特定查询的性能加速手段。 ## 多维索引与全文索引 {#sec_storage_multidimensional} @@ -523,29 +529,29 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 全文检索允许你通过可能出现在文本中任何位置的关键字搜索文本文档集合(网页、产品描述等)[^88]。信息检索是一个大的专业主题,通常涉及特定于语言的处理:例如,几种亚洲语言在单词之间没有空格或标点符号,因此将文本分割成单词需要一个指示哪些字符序列构成单词的模型。全文检索还经常涉及匹配相似但不相同的单词(例如拼写错误或单词的不同语法形式)和同义词。这些问题超出了本书的范围。 -然而,在其核心,你可以将全文检索视为另一种多维查询:在这种情况下,可能出现在文本中的每个单词(*词项*)是一个维度。包含词项 *x* 的文档在维度 *x* 中的值为 1,不包含 *x* 的文档的值为 0。搜索提到"红苹果"的文档意味着查询在 *红* 维度中查找 1,同时在 *苹果* 维度中查找 1。维度数量可能因此非常大。 +然而,在其核心,你可以将全文检索视为另一种多维查询:在这种情况下,可能出现在文本中的每个单词(*词项*)是一个维度。包含词项 *x* 的文档在维度 *x* 中的值为 1,不包含 *x* 的文档的值为 0。搜索提到“红苹果”的文档意味着查询在 *红* 维度中查找 1,同时在 *苹果* 维度中查找 1。维度数量可能因此非常大。 -许多搜索引擎用来回答此类查询的数据结构称为 *倒排索引*。这是一个键值结构,其中键是词项,值是包含该词项的所有文档的 ID 列表(*倒排列表*)。如果文档 ID 是顺序数字,倒排列表也可以表示为稀疏位图,如 [图 4-8](#fig_bitmap_index):词项 *x* 的位图中的第 *n* 位是 1,如果 ID 为 *n* 的文档包含词项 *x* [^89]。 +许多搜索引擎用来回答此类查询的数据结构称为 *倒排索引*。这是一个键值结构,其中键是词项,值是包含该词项的所有文档的 ID 列表(*倒排列表*)。如果文档 ID 是顺序数字,倒排列表也可以表示为稀疏位图,如 [图 4-8](/ch4#fig_bitmap_index):词项 *x* 的位图中的第 *n* 位是 1,如果 ID 为 *n* 的文档包含词项 *x* [^89]。 -查找包含词项 *x* 和 *y* 的所有文档现在类似于搜索匹配两个条件的行的向量化数据仓库查询([图 4-9](#fig_bitmap_and)):加载词项 *x* 和 *y* 的两个位图并计算它们的按位 AND。即使位图是游程编码的,这也可以非常高效地完成。 +查找包含词项 *x* 和 *y* 的所有文档现在类似于搜索匹配两个条件的行的向量化数据仓库查询([图 4-9](/ch4#fig_bitmap_and)):加载词项 *x* 和 *y* 的两个位图并计算它们的按位 AND。即使位图是游程编码的,这也可以非常高效地完成。 例如,Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就是这样工作的 [^90]。它将词项到倒排列表的映射存储在类似 SSTable 的排序文件中,这些文件使用我们在本章前面看到的相同日志结构方法在后台合并 [^91]。PostgreSQL 的 GIN 索引类型也使用倒排列表来支持全文检索和 JSON 文档内的索引 [^92] [^93]。 -除了将文本分解为单词,另一种选择是查找长度为 *n* 的所有子字符串,称为 *n* 元语法。例如,字符串 `"hello"` 的三元语法(*n* = 3)是 `"hel"`、`"ell"` 和 `"llo"`。如果我们为所有三元语法构建倒排索引,我们可以搜索至少三个字符长的任意子字符串的文档。三元语法索引甚至允许在搜索查询中使用正则表达式;缺点是它们相当大 [^94]。 +除了将文本分解为单词,另一种选择是查找长度为 *n* 的所有子字符串,称为 *n-gram*(*n 元语法*)。例如,字符串 `"hello"` 的三元语法(*n* = 3)是 `"hel"`、`"ell"` 和 `"llo"`。如果我们为所有三元语法构建倒排索引,我们就可以搜索任意至少三个字符长的子字符串。三元语法索引甚至允许在搜索查询中使用正则表达式;缺点是它们相当大 [^94]。 为了处理文档或查询中的拼写错误,Lucene 能够在一定编辑距离内搜索文本中的单词(编辑距离为 1 意味着已添加、删除或替换了一个字母)[^95]。它通过将词项集存储为字符上的有限状态自动机(类似于 *字典树* [^96])并将其转换为 *莱文斯坦自动机* 来实现,该自动机支持在给定编辑距离内高效搜索单词 [^97]。 ### 向量嵌入 {#id92} -语义搜索超越了同义词和拼写错误,试图理解文档概念和用户意图。例如,如果你的帮助页面包含标题为"取消订阅"的页面,用户在搜索"如何关闭我的帐户"或"终止合同"时仍应能够找到该页面,即使它们使用完全不同的单词,但在含义上很接近。 +语义搜索超越了同义词和拼写错误,试图理解文档概念和用户意图。例如,如果你的帮助页面中有一个标题为“取消订阅”的页面,用户在搜索“如何关闭我的账户”或“终止合同”时,仍应能找到这个页面,即使查询词完全不同,但语义非常接近。 为了理解文档的语义 —— 它的含义 —— 语义搜索索引使用嵌入模型将文档转换为浮点值向量,称为 *向量嵌入*。向量表示多维空间中的一个点,每个浮点值表示文档沿着一个维度轴的位置。嵌入模型生成的向量嵌入在(这个多维空间中)彼此接近,当嵌入的输入文档在语义上相似时。 -------- > [!NOTE] -> 我们在 ["查询执行:编译与向量化"](#sec_storage_vectorized) 中看到了术语 *向量化处理*。语义搜索中的向量有不同的含义。在向量化处理中,向量指的是可以用特别优化的代码处理的一批位。在嵌入模型中,向量是表示多维空间中位置的浮点数列表。 +> 我们在 ["查询执行:编译与向量化"](/ch4#sec_storage_vectorized) 中看到了术语 *向量化处理*。语义搜索中的向量有不同的含义。在向量化处理中,向量指的是可以用特别优化的代码处理的一批位。在嵌入模型中,向量是表示多维空间中位置的浮点数列表。 -------- @@ -566,7 +572,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 : 向量空间被聚类为向量的分区(称为 *质心*),以减少必须比较的向量数量。IVF 索引比平面索引更快,但只能给出近似结果:即使查询和文档彼此接近,它们也可能落入不同的分区。对 IVF 索引的查询首先定义 *探针*,这只是要检查的分区数。使用更多探针的查询将更准确,但会更慢,因为必须比较更多向量。 分层可导航小世界(HNSW) -: HNSW 索引维护向量空间的多个层,如 [图 4-11](#fig_vector_hnsw) 所示。每一层都表示为一个图,其中节点表示向量,边表示与附近向量的接近度。查询首先在最顶层定位最近的向量,该层具有少量节点。然后查询移动到下面一层的同一节点,并跟随该层中的边,该层连接更密集,寻找更接近查询向量的向量。该过程继续直到到达最后一层。与 IVF 索引一样,HNSW 索引是近似的。 +: HNSW 索引维护向量空间的多个层,如 [图 4-11](/ch4#fig_vector_hnsw) 所示。每一层都表示为一个图,其中节点表示向量,边表示与附近向量的接近度。查询首先在最顶层定位最近的向量,该层具有少量节点。然后查询移动到下面一层的同一节点,并跟随该层中的边,该层连接更密集,寻找更接近查询向量的向量。该过程继续直到到达最后一层。与 IVF 索引一样,HNSW 索引是近似的。 {{< figure src="/fig/ddia_0411.png" id="fig_vector_hnsw" caption="图 4-11. 在 HNSW 索引中搜索最接近给定查询向量的数据库条目。" class="w-full my-4" >}} @@ -589,9 +595,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 然后我们查看了可以同时搜索多个条件的索引:多维索引(如 R 树)可以同时按纬度和经度搜索地图上的点,全文检索索引可以搜索出现在同一文本中的多个关键字。最后,向量数据库用于文本文档和其他媒体的语义搜索;它们使用具有大量维度的向量,并通过比较向量相似性来查找相似文档。 -作为应用程序开发人员,如果你掌握了有关存储引擎内部的这些知识,你就能更好地知道哪种工具最适合你的特定应用程序。如果你需要调整数据库的调优参数,这种理解使你能够想象更高或更低的值可能产生什么影响。 +作为应用开发者,如果你掌握了这些关于存储引擎内部机制的知识,就能更好地判断哪种工具最适合你的具体应用。如果你需要调整数据库的调优参数,这种理解也能帮助你预判参数调高或调低可能带来的影响。 -尽管本章不能让你成为调优任何特定存储引擎的专家,但它希望为你提供了足够的词汇和想法,使你能够理解你选择的数据库的文档。 +尽管本章不能让你成为调优某个特定存储引擎的专家,但它希望已经为你提供了足够的术语和思路,使你能够读懂所选数据库的文档。 @@ -701,4 +707,4 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 [^101]: Matthijs Douze, Maria Lomeli, and Lucas Hosseini. [Faiss indexes](https://github.com/facebookresearch/faiss/wiki/Faiss-indexes). *github.com*, August 2024. Archived at [perma.cc/2EWG-FPBS](https://perma.cc/2EWG-FPBS) [^102]: Varik Matevosyan. [Understanding pgvector’s HNSW Index Storage in Postgres](https://lantern.dev/blog/pgvector-storage). *lantern.dev*, August 2024. Archived at [perma.cc/B2YB-JB59](https://perma.cc/B2YB-JB59) [^103]: Dmitry Baranchuk, Artem Babenko, and Yury Malkov. [Revisiting the Inverted Indices for Billion-Scale Approximate Nearest Neighbors](https://arxiv.org/pdf/1802.02422). At *European Conference on Computer Vision* (ECCV), pages 202–216, September 2018. [doi:10.1007/978-3-030-01258-8\_13](https://doi.org/10.1007/978-3-030-01258-8_13) -[^104]: Yury A. Malkov and Dmitry A. Yashunin. [Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs](https://arxiv.org/pdf/1603.09320). *IEEE Transactions on Pattern Analysis and Machine Intelligence*, volume 42, issue 4, pages 824–836, April 2020. [doi:10.1109/TPAMI.2018.2889473](https://doi.org/10.1109/TPAMI.2018.2889473) \ No newline at end of file +[^104]: Yury A. Malkov and Dmitry A. Yashunin. [Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs](https://arxiv.org/pdf/1603.09320). *IEEE Transactions on Pattern Analysis and Machine Intelligence*, volume 42, issue 4, pages 824–836, April 2020. [doi:10.1109/TPAMI.2018.2889473](https://doi.org/10.1109/TPAMI.2018.2889473) diff --git a/content/zh/ch5.md b/content/zh/ch5.md index 7b23817..90d2c54 100644 --- a/content/zh/ch5.md +++ b/content/zh/ch5.md @@ -5,6 +5,8 @@ math: true breadcrumbs: false --- + + ![](/map/ch04.png) > *万物流转,无物常驻。* @@ -65,7 +67,7 @@ breadcrumbs: false 这些编码库非常方便,因为它们允许用最少的额外代码保存和恢复内存对象。然而,它们也有许多深层次的问题: -* 编码通常与特定的编程语言绑定,用另一种语言读取数据非常困难。如果你以这种编码存储或传输数据,你就将自己承诺于当前的编程语言,可能很长时间,并且排除了与其他组织(可能使用不同语言)的系统集成。 +* 编码通常与特定编程语言绑定,在另一种语言中读取会非常困难。如果你以这种编码存储或传输数据,就等于在相当长时间内把自己绑定在当前编程语言上,也排除了与其他组织(可能使用不同语言)的系统集成。 * 为了以相同的对象类型恢复数据,解码过程需要能够实例化任意类。这经常是安全问题的来源 [^1]:如果攻击者可以让你的应用程序解码任意字节序列,他们可以实例化任意类,这反过来通常允许他们做可怕的事情,例如远程执行任意代码 [^2] [^3]。 * 在这些库中,数据版本控制通常是事后考虑的:由于它们旨在快速轻松地编码数据,因此它们经常忽略向前和向后兼容性的不便问题 [^4]。 * 效率(编码或解码所需的 CPU 时间以及编码结构的大小)通常也是事后考虑的。例如,Java 的内置序列化因其糟糕的性能和臃肿的编码而臭名昭著 [^5]。 @@ -262,7 +264,7 @@ record Person { 答案取决于 Avro 的使用环境。举几个例子: 包含大量记录的大文件 -: Avro 的一个常见用途是存储包含数百万条记录的大文件,所有记录都使用相同的模式编码。(我们将在 [Link to Come] 中讨论这种情况。)在这种情况下,该文件的写入者可以在文件开头只包含一次写入者模式。Avro 指定了一种文件格式(对象容器文件)来执行此操作。 +: Avro 的一个常见用途是存储包含数百万条记录的大文件,所有记录都使用相同的模式编码。(我们将在 [第 11 章](/ch11#ch_batch) 讨论这种情况。)在这种情况下,该文件的写入者可以在文件开头只包含一次写入者模式。Avro 指定了一种文件格式(对象容器文件)来执行此操作。 具有单独写入记录的数据库 : 在数据库中,不同的记录可能在不同的时间点使用不同的写入者模式编写——你不能假定所有记录都具有相同的模式。最简单的解决方案是在每个编码记录的开头包含一个版本号,并在数据库中保留模式版本列表。读取者可以获取记录,提取版本号,然后从数据库中获取该版本号的写入者模式。使用该写入者模式,它可以解码记录的其余部分。 @@ -286,7 +288,7 @@ record Person { ### 模式的优点 {#sec_encoding_schemas} -正如我们所见,Protocol Buffers 和 Avro 都使用模式来描述二进制编码格式。它们的模式语言比 XML 模式或 JSON 模式简单得多,后者支持更详细的验证规则(例如,"此字段的字符串值必须与此正则表达式匹配"或"此字段的整数值必须在 0 到 100 之间")。由于 Protocol Buffers 和 Avro 更简单实现和使用,它们已经发展到支持相当广泛的编程语言。 +正如我们所见,Protocol Buffers 和 Avro 都使用模式来描述二进制编码格式。它们的模式语言比 XML 模式或 JSON 模式简单得多,后者支持更详细的验证规则(例如,"此字段的字符串值必须与此正则表达式匹配"或"此字段的整数值必须在 0 到 100 之间")。由于 Protocol Buffers 和 Avro 在实现和使用上都更简单,它们已经发展到支持相当广泛的编程语言。 这些编码所基于的想法绝不是新的。例如,它们与 ASN.1 有很多共同之处,ASN.1 是 1984 年首次标准化的模式定义语言 [^23] [^24]。它用于定义各种网络协议,其二进制编码(DER)仍用于编码 SSL 证书(X.509),例如 [^25]。ASN.1 支持使用标签号的模式演化,类似于 Protocol Buffers [^26]。然而,它也非常复杂且文档记录不佳,因此 ASN.1 可能不是新应用程序的好选择。 @@ -340,7 +342,7 @@ record Person { 由于数据转储是一次性写入的,此后是不可变的,因此像 Avro 对象容器文件这样的格式非常适合。这也是将数据编码为分析友好的列式格式(如 Parquet)的好机会(参见 ["列压缩"](/ch4#sec_storage_column_compression))。 -在 [Link to Come] 中,我们将更多地讨论如何使用归档存储中的数据。 +在 [第 11 章](/ch11#ch_batch) 中,我们将更多地讨论如何使用归档存储中的数据。 ### 流经服务的数据流:REST 与 RPC {#sec_encoding_dataflow_rpc} @@ -423,7 +425,7 @@ Web 服务只是通过网络进行 API 请求的一长串技术的最新化身 * 本地函数调用是可预测的,要么成功要么失败,仅取决于你控制的参数。网络请求是不可预测的:由于网络问题,请求或响应可能会丢失,或者远程机器可能速度慢或不可用,而这些问题完全超出了你的控制。网络问题很常见,因此你必须预料到它们,例如通过重试失败的请求。 * 本地函数调用要么返回结果,要么抛出异常,要么永不返回(因为它进入无限循环或进程崩溃)。网络请求有另一种可能的结果:它可能由于 *超时* 而没有返回结果。在这种情况下,你根本不知道发生了什么:如果你没有从远程服务获得响应,你无法知道请求是否通过。(我们在 [第 9 章](/ch9#ch_distributed) 中更详细地讨论了这个问题。) -* 如果你重试失败的网络请求,可能会发生前一个请求实际上已经通过,只是响应丢失了。在这种情况下,重试将导致操作执行多次,除非你在协议中构建去重机制(*幂等性*)[^40]。本地函数调用没有这个问题。(我们在 [Link to Come] 中更详细地讨论了幂等性。) +* 如果你重试失败的网络请求,可能会发生前一个请求实际上已经成功,只是响应丢失了。在这种情况下,重试将导致操作执行多次,除非你在协议中构建去重机制(*幂等性*)[^40]。本地函数调用没有这个问题。(我们在 [“幂等性”](/ch12#sec_stream_idempotence) 中更详细地讨论幂等性。) * 每次调用本地函数时,通常需要大约相同的时间来执行。网络请求比函数调用慢得多,其延迟也变化很大:在良好的时候,它可能在不到一毫秒内完成,但当网络拥塞或远程服务过载时,执行完全相同的操作可能需要许多秒。 * 当你调用本地函数时,你可以有效地将引用(指针)传递给本地内存中的对象。当你发出网络请求时,所有这些参数都需要编码为可以通过网络发送的字节序列。如果参数是不可变的原语,如数字或短字符串,那没问题,但对于更大量的数据和可变对象,它很快就会出现问题。 * 客户端和服务可能以不同的编程语言实现,因此 RPC 框架必须将数据类型从一种语言转换为另一种语言。这可能会变得很丑陋,因为并非所有语言都具有相同的类型——例如,回想一下 JavaScript 处理大于 2⁵³ 的数字的问题(参见 ["JSON、XML 及其二进制变体"](/ch5#sec_encoding_json))。单一语言编写的单个进程中不存在此问题。 @@ -488,7 +490,7 @@ RPC 方案的向后和向前兼容性属性继承自它使用的任何编码: 持久化执行框架已成为构建需要事务性的基于服务的架构的流行方式。在我们的支付示例中,我们希望每笔付款都恰好处理一次。工作流执行期间的故障可能导致信用卡扣费,但没有相应的银行账户存款。在基于服务的架构中,我们不能简单地将两个任务包装在数据库事务中。此外,我们可能正在与我们控制有限的第三方支付网关进行交互。 -持久化执行框架是为工作流提供 *精确一次语义* 的一种方式。如果任务失败,框架将重新执行该任务,但会跳过任务在失败之前成功完成的任何 RPC 调用或状态更改。相反,框架将假装进行调用,但实际上将返回先前调用的结果。这是可能的,因为持久化执行框架将所有 RPC 和状态更改记录到持久存储(如预写日志)[^45] [^46]。[示例 5-5](/ch5#fig_temporal_workflow) 显示了使用 Temporal 支持持久化执行的工作流定义示例。 +持久化执行框架是为工作流提供 *恰好一次语义* 的一种方式。如果任务失败,框架将重新执行该任务,但会跳过任务在失败之前成功完成的任何 RPC 调用或状态更改。相反,框架将假装进行调用,但实际上将返回先前调用的结果。这是可能的,因为持久化执行框架将所有 RPC 和状态更改记录到持久存储(如预写日志)[^45] [^46]。[示例 5-5](/ch5#fig_temporal_workflow) 显示了使用 Temporal 支持持久化执行的工作流定义示例。 {{< figure id="fig_temporal_workflow" title="示例 5-5. [图 5-7](/ch5#fig_encoding_workflow) 中支付工作流的 Temporal 工作流定义片段。" class="w-full my-4" >}} @@ -514,7 +516,7 @@ class PaymentWorkflow: 像 Temporal 这样的框架并非没有挑战。外部服务(例如我们示例中的第三方支付网关)仍必须提供幂等 API。开发人员必须记住为这些 API 使用唯一 ID 以防止重复执行 [^47]。由于持久化执行框架按顺序记录每个 RPC 调用,因此它期望后续执行以相同的顺序进行相同的 RPC 调用。这使得代码更改变得脆弱:你可能仅通过重新排序函数调用就引入未定义的行为 [^48]。与其修改现有工作流的代码,不如单独部署新版本的代码更安全,以便现有工作流调用的重新执行继续使用旧版本,只有新调用使用新代码 [^49]。 -同样,由于持久化执行框架期望以确定性方式重放所有代码(相同的输入产生相同的输出),因此随机数生成器或系统时钟等非确定性代码会产生问题 [^48]。框架通常提供此类库函数的自己的确定性实现,但你必须记住使用它们。在某些情况下,例如 Temporal 的 workflowcheck 工具,框架提供静态分析工具来确定是否引入了非确定性行为。 +同样,由于持久化执行框架期望以确定性方式重放所有代码(相同的输入产生相同的输出),因此随机数生成器或系统时钟等非确定性代码会产生问题 [^48]。框架通常会为这类库函数提供自己的确定性实现,但你必须记得使用它们。在某些情况下,例如 Temporal 的 workflowcheck 工具,框架还会提供静态分析工具来判断是否引入了非确定性行为。 -------- @@ -539,7 +541,7 @@ class PaymentWorkflow: #### 消息代理 {#message-brokers} -过去,消息代理的格局由 TIBCO、IBM WebSphere 和 webMethods 等公司的商业企业软件主导,然后开源实现(如 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka)变得流行。最近,云服务(如 Amazon Kinesis、Azure Service Bus 和 Google Cloud Pub/Sub)也获得了采用。我们将在 [Link to Come] 中更详细地比较它们。 +过去,消息代理的格局由 TIBCO、IBM WebSphere 和 webMethods 等公司的商业企业软件主导,然后开源实现(如 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka)变得流行。最近,云服务(如 Amazon Kinesis、Azure Service Bus 和 Google Cloud Pub/Sub)也获得了采用。我们将在 [“消息系统”](/ch12#sec_stream_messaging) 中更详细地比较它们。 详细的传递语义因实现和配置而异,但通常,最常使用两种消息分发模式: @@ -640,4 +642,4 @@ class PaymentWorkflow: [^48]: [What is a Temporal Workflow?](https://docs.temporal.io/workflows) *docs.temporal.io*, 2024. Archived at [perma.cc/B5C5-Y396](https://perma.cc/B5C5-Y396) [^49]: Jack Kleeman. [Solving durable execution’s immutability problem](https://restate.dev/blog/solving-durable-executions-immutability-problem/). *restate.dev*, February 2024. Archived at [perma.cc/G55L-EYH5](https://perma.cc/G55L-EYH5) [^50]: Srinath Perera. [Exploring Event-Driven Architecture: A Beginner’s Guide for Cloud Native Developers](https://wso2.com/blogs/thesource/exploring-event-driven-architecture-a-beginners-guide-for-cloud-native-developers/). *wso2.com*, August 2023. Archived at [archive.org](https://web.archive.org/web/20240716204613/https%3A//wso2.com/blogs/thesource/exploring-event-driven-architecture-a-beginners-guide-for-cloud-native-developers/) -[^51]: Philip A. Bernstein, Sergey Bykov, Alan Geller, Gabriel Kliot, and Jorgen Thelin. [Orleans: Distributed Virtual Actors for Programmability and Scalability](https://www.microsoft.com/en-us/research/publication/orleans-distributed-virtual-actors-for-programmability-and-scalability/). Microsoft Research Technical Report MSR-TR-2014-41, March 2014. Archived at [perma.cc/PD3U-WDMF](https://perma.cc/PD3U-WDMF) \ No newline at end of file +[^51]: Philip A. Bernstein, Sergey Bykov, Alan Geller, Gabriel Kliot, and Jorgen Thelin. [Orleans: Distributed Virtual Actors for Programmability and Scalability](https://www.microsoft.com/en-us/research/publication/orleans-distributed-virtual-actors-for-programmability-and-scalability/). Microsoft Research Technical Report MSR-TR-2014-41, March 2014. Archived at [perma.cc/PD3U-WDMF](https://perma.cc/PD3U-WDMF) diff --git a/content/zh/ch6.md b/content/zh/ch6.md index ff78e48..228adcd 100644 --- a/content/zh/ch6.md +++ b/content/zh/ch6.md @@ -6,11 +6,11 @@ breadcrumbs: false ![](/map/ch05.png) -> *出错的事物与不可能出错的事物之间的主要区别在于,当不可能出错的事物出错时,通常会发现它几乎不可能查找或修复。* +> *可能出错的东西和“不可能”出错的东西之间,最大的区别在于:后者一旦出错,往往几乎无从下手,也难以修复。* > -> Douglas Adams,《基本无害》(1992) +> 道格拉斯·亚当斯,《基本无害》(1992) -**复制** 指的是通过网络连接的多台机器上保存相同数据的副本。如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 中所讨论的,你可能出于以下几个原因想要复制数据: +**复制** 指的是在通过网络连接的多台机器上保留相同数据的副本。如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 中所讨论的,你可能出于以下几个原因希望复制数据: * 使数据在地理上更接近用户(从而减少访问延迟) * 即使系统的部分组件出现故障,也能让系统继续工作(从而提高可用性) @@ -18,7 +18,7 @@ breadcrumbs: false 本章假设你的数据集足够小,每台机器都可以保存整个数据集的副本。在 [第 7 章](/ch7#ch_sharding) 中,我们将放宽这一假设,讨论单台机器无法容纳的、过大数据集的 **分片**(**分区**)。在后续章节中,我们将讨论复制数据系统中可能发生的各种故障,以及如何处理它们。 -如果需要复制的数据不会随时间变化,那么复制就很简单:只需要将数据复制到每个节点一次就大功告成。处理复制的所有困难都在于处理复制数据的 **变更**,这也是本章的主题。我们将讨论三种复制节点间变更的算法族:**单主**、**多主** 和 **无主** 复制。几乎所有分布式数据库都使用这三种方法之一。它们各有利弊,我们将详细研究。 +如果需要复制的数据不会随时间变化,那么复制就很简单:只需要将数据复制到每个节点一次就大功告成。处理复制的所有困难都在于处理复制数据的 **变更**,这也是本章的主题。我们将讨论三类在节点之间复制变更的算法:**单主**、**多主** 和 **无主** 复制。几乎所有分布式数据库都使用这三种方法之一。它们各有利弊,我们将详细研究。 复制需要考虑许多权衡:例如,是使用同步还是异步复制,以及如何处理失败的副本。这些通常是数据库中的配置选项,尽管不同数据库的细节有所不同,但许多不同实现的通用原则是相似的。我们将在本章中讨论这些选择的后果。 @@ -40,22 +40,22 @@ breadcrumbs: false 存储数据库副本的每个节点称为 **副本**。有了多个副本,不可避免地会出现一个问题:我们如何确保所有数据最终都出现在所有副本上? -每次写入数据库都需要由每个副本处理;否则,副本将不再包含相同的数据。最常见的解决方案称为 **基于主节点的复制**、**主备复制** 或 **主动/被动复制**。它的工作原理如下(见 [图 6-1](/ch6#fig_replication_leader_follower)): +每次写入数据库都需要由每个副本处理;否则,副本将不再包含相同的数据。最常见的解决方案称为 **基于领导者的复制**、**主备复制** 或 **主动/被动复制**。它的工作原理如下(见 [图 6-1](/ch6#fig_replication_leader_follower)): -1. 其中一个副本被指定为 **主节点**(也称为 **主库** 或 **源** [^2])。当客户端想要写入数据库时,他们必须将请求发送给主节点,主节点首先将新数据写入其本地存储。 -2. 其他副本称为 **从节点**(**只读副本**、**从库** 或 **热备**)。每当主节点将新数据写入其本地存储时,它也会将数据变更作为 **复制日志** 或 **变更流** 的一部分发送给所有从节点。每个从节点从主节点获取日志,并通过按照与主节点处理相同的顺序应用所有写入来相应地更新其本地数据库副本。 -3. 当客户端想要从数据库读取时,它可以查询主节点或任何从节点。然而,只有主节点接受写入(从客户端的角度来看,从节点是只读的)。 +1. 其中一个副本被指定为 **领导者**(也称为 **主库** 或 **源** [^2])。当客户端想要写入数据库时,他们必须将请求发送给领导者,领导者首先将新数据写入其本地存储。 +2. 其他副本称为 **追随者**(**只读副本**、**从库** 或 **热备**)。每当领导者将新数据写入其本地存储时,它也会将数据变更作为 **复制日志** 或 **变更流** 的一部分发送给所有追随者。每个追随者从领导者获取日志,并通过按照与领导者处理相同的顺序应用所有写入来相应地更新其本地数据库副本。 +3. 当客户端想要从数据库读取时,它可以查询领导者或任何追随者。然而,只有领导者接受写入(从客户端的角度来看,追随者是只读的)。 -{{< figure src="/fig/ddia_0601.png" id="fig_replication_leader_follower" caption="图 6-1. 单主复制将所有写入定向到指定的主节点,该主节点向从副本发送变更流。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0601.png" id="fig_replication_leader_follower" caption="图 6-1. 单主复制将所有写入定向到指定的领导者,该领导者向追随者发送变更流。" class="w-full my-4" >}} -如果数据库是分片的(见 [第 7 章](/ch7#ch_sharding)),每个分片都有一个主节点。不同的分片可能在不同的节点上有其主节点,但每个分片仍必须有一个主节点。在 ["多主复制"](/ch6#sec_replication_multi_leader) 中,我们将讨论一种替代模型,其中系统可能同时为同一分片拥有多个主节点。 +如果数据库是分片的(见 [第 7 章](/ch7#ch_sharding)),每个分片都有一个领导者。不同的分片可能在不同的节点上有其领导者,但每个分片仍必须有一个领导者。在 ["多主复制"](/ch6#sec_replication_multi_leader) 中,我们将讨论一种替代模型,其中系统可能同时为同一分片拥有多个领导者。 -单主复制被广泛使用。它是许多关系数据库的内置功能,如 PostgreSQL、MySQL、Oracle Data Guard [^3] 和 SQL Server 的 Always On 可用性组 [^4]。它也用于一些文档数据库,如 MongoDB 和 DynamoDB [^5],消息代理如 Kafka,复制块设备如 DRBD,以及一些网络文件系统。许多共识算法(如 Raft)也基于单个主节点,用于 CockroachDB [^6]、TiDB [^7]、etcd 和 RabbitMQ 仲裁队列(以及其他)中的复制,并在旧主节点失败时自动选举新主节点(我们将在 [第 10 章](/ch10#ch_consistency) 中更详细地讨论共识)。 +单主复制被广泛使用。它是许多关系数据库的内置功能,如 PostgreSQL、MySQL、Oracle Data Guard [^3] 和 SQL Server 的 Always On 可用性组 [^4]。它也用于一些文档数据库,如 MongoDB 和 DynamoDB [^5],消息代理如 Kafka,复制块设备如 DRBD,以及一些网络文件系统。许多共识算法(如 Raft)也基于单个领导者,用于 CockroachDB [^6]、TiDB [^7]、etcd 和 RabbitMQ 仲裁队列(以及其他)中的复制,并在旧领导者失败时自动选举新领导者(我们将在 [第 10 章](/ch10#ch_consistency) 中更详细地讨论共识)。 -------- > [!NOTE] -> 在较旧的文档中,你可能会看到术语 **主从复制**。它与基于主节点的复制含义相同,但应该避免使用该术语,因为它被广泛认为是冒犯性的 [^8]。 +> 在较旧的文档中,你可能会看到术语 **主从复制**。它与基于领导者的复制含义相同,但应该避免使用该术语,因为它被广泛认为是冒犯性的 [^8]。 -------- @@ -63,40 +63,40 @@ breadcrumbs: false 复制系统的一个重要细节是复制是 **同步** 发生还是 **异步** 发生。(在关系数据库中,这通常是一个可配置选项;其他系统通常硬编码为其中之一。) -想想 [图 6-1](/ch6#fig_replication_leader_follower) 中发生的情况,一个网站用户更新他们的个人资料图片。在某个时间点,客户端向主节点发送更新请求;不久之后,主节点收到了它。在某个时间点,主节点将数据变更转发给从节点。最终,主节点通知客户端更新成功。[图 6-2](/ch6#fig_replication_sync_replication) 显示了时序可能的工作方式。 +想想 [图 6-1](/ch6#fig_replication_leader_follower) 中发生的情况,一个网站用户更新他们的个人资料图片。在某个时间点,客户端向领导者发送更新请求;不久之后,领导者收到了它。在某个时间点,领导者将数据变更转发给追随者。最终,领导者通知客户端更新成功。[图 6-2](/ch6#fig_replication_sync_replication) 显示了时序可能的工作方式。 -{{< figure src="/fig/ddia_0602.png" id="fig_replication_sync_replication" caption="图 6-2. 基于主节点的复制,带有一个同步和一个异步从节点。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0602.png" id="fig_replication_sync_replication" caption="图 6-2. 基于领导者的复制,带有一个同步和一个异步追随者。" class="w-full my-4" >}} -在 [图 6-2](/ch6#fig_replication_sync_replication) 的示例中,对从节点 1 的复制是 **同步的**:主节点等待从节点 1 确认它已收到写入,然后才向用户报告成功,并使写入对其他客户端可见。对从节点 2 的复制是 **异步的**:主节点发送消息,但不等待从节点的响应。 +在 [图 6-2](/ch6#fig_replication_sync_replication) 的示例中,对追随者 1 的复制是 **同步的**:领导者等待追随者 1 确认它已收到写入,然后才向用户报告成功,并使写入对其他客户端可见。对追随者 2 的复制是 **异步的**:领导者发送消息,但不等待追随者的响应。 -图中显示,从节点 2 处理消息之前有相当大的延迟。通常,复制相当快:大多数数据库系统在不到一秒的时间内将变更应用到从节点。然而,不能保证需要多长时间。在某些情况下,从节点可能落后主节点几分钟或更长时间;例如,如果从节点正在从故障中恢复,如果系统正在接近最大容量运行,或者如果节点之间存在网络问题。 +图中显示,追随者 2 处理消息之前有相当大的延迟。通常,复制相当快:大多数数据库系统在不到一秒的时间内将变更应用到追随者。然而,不能保证需要多长时间。在某些情况下,追随者可能落后领导者几分钟或更长时间;例如,如果追随者正在从故障中恢复,如果系统正在接近最大容量运行,或者如果节点之间存在网络问题。 -同步复制的优点是从节点保证拥有与主节点一致的最新数据副本。如果主节点突然失败,我们可以确信数据仍然在从节点上可用。缺点是,如果同步从节点没有响应(因为它已崩溃,或存在网络故障,或任何其他原因),写入就无法处理。主节点必须阻塞所有写入并等待同步副本再次可用。 +同步复制的优点是追随者保证拥有与领导者一致的最新数据副本。如果领导者突然失败,我们可以确信数据仍然在追随者上可用。缺点是,如果同步追随者没有响应(因为它已崩溃,或存在网络故障,或任何其他原因),写入就无法处理。领导者必须阻塞所有写入并等待同步副本再次可用。 -因此,将所有从节点都设为同步是不切实际的:任何一个节点的中断都会导致整个系统停止。实际上,如果数据库提供同步复制,通常意味着 **一个** 从节点是同步的,其他的是异步的。如果同步从节点变得不可用或缓慢,异步从节点之一将变为同步。这保证了你至少在两个节点上拥有最新的数据副本:主节点和一个同步从节点。这种配置有时也称为 **半同步**。 +因此,将所有追随者都设为同步是不切实际的:任何一个节点的中断都会导致整个系统停止。实际上,如果数据库提供同步复制,通常意味着 **一个** 追随者是同步的,其他的是异步的。如果同步追随者变得不可用或缓慢,异步追随者之一将变为同步。这保证了你至少在两个节点上拥有最新的数据副本:领导者和一个同步追随者。这种配置有时也称为 **半同步**。 -在某些系统中,**多数**(例如,包括主节点在内的 5 个副本中的 3 个)副本被同步更新,其余少数是异步的。这是 **仲裁** 的一个例子,我们将在 ["读写仲裁"](/ch6#sec_replication_quorum_condition) 中进一步讨论。多数仲裁通常用于使用共识协议进行自动主节点选举的系统中,我们将在 [第 10 章](/ch10#ch_consistency) 中回到这个话题。 +在某些系统中,**多数**(例如,包括领导者在内的 5 个副本中的 3 个)副本被同步更新,其余少数是异步的。这是 **仲裁** 的一个例子,我们将在 ["读写仲裁"](/ch6#sec_replication_quorum_condition) 中进一步讨论。多数仲裁通常用于使用共识协议进行自动领导者选举的系统中,我们将在 [第 10 章](/ch10#ch_consistency) 中回到这个话题。 -有时,基于主节点的复制被配置为完全异步。在这种情况下,如果主节点失败且无法恢复,任何尚未复制到从节点的写入都会丢失。这意味着即使已向客户端确认,写入也不能保证持久。然而,完全异步配置的优点是主节点可以继续处理写入,即使所有从节点都已落后。 +有时,基于领导者的复制被配置为完全异步。在这种情况下,如果领导者失败且无法恢复,任何尚未复制到追随者的写入都会丢失。这意味着即使已向客户端确认,写入也不能保证持久。然而,完全异步配置的优点是领导者可以继续处理写入,即使所有追随者都已落后。 -弱化持久性可能听起来像是一个糟糕的权衡,但异步复制仍然被广泛使用,特别是如果有许多从节点或者它们在地理上分布广泛 [^9]。我们将在 ["复制延迟的问题"](/ch6#sec_replication_lag) 中回到这个问题。 +弱化持久性可能听起来像是一个糟糕的权衡,但异步复制仍然被广泛使用,特别是如果有许多追随者或者它们在地理上分布广泛 [^9]。我们将在 ["复制延迟的问题"](/ch6#sec_replication_lag) 中回到这个问题。 ### 设置新的副本 {#sec_replication_new_replica} -不时地,你需要设置新的从节点——也许是为了增加副本的数量,或者替换失败的节点。如何确保新的从节点拥有主节点数据的准确副本? +不时地,你需要设置新的追随者——也许是为了增加副本的数量,或者替换失败的节点。如何确保新的追随者拥有领导者数据的准确副本? 简单地将数据文件从一个节点复制到另一个节点通常是不够的:客户端不断向数据库写入,数据总是在变化,所以标准文件复制会在不同的时间点看到数据库的不同部分。结果可能没有任何意义。 -你可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但这将违背我们的高可用性目标。幸运的是,设置从节点通常可以在不停机的情况下完成。从概念上讲,过程如下所示: +你可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但这将违背我们的高可用性目标。幸运的是,设置追随者通常可以在不停机的情况下完成。从概念上讲,过程如下所示: -1. 在某个时间点获取主节点数据库的一致快照——如果可能,不锁定整个数据库。大多数数据库都有此功能,因为备份也需要它。在某些情况下,需要第三方工具,例如用于 MySQL 的 Percona XtraBackup。 -2. 将快照复制到新的从节点。 -3. 从节点连接到主节点并请求自快照拍摄以来发生的所有数据变更。这要求快照与主节点复制日志中的确切位置相关联。该位置有各种名称:例如,PostgreSQL 称之为 **日志序列号**;MySQL 有两种机制,**binlog 位点** 和 **全局事务标识符**(GTID)。 -4. 当从节点处理了自快照以来的数据变更积压后,我们说它已经 **追上进度**。它现在可以继续处理主节点发生的数据变更。 +1. 在某个时间点获取领导者数据库的一致快照——如果可能,不锁定整个数据库。大多数数据库都有此功能,因为备份也需要它。在某些情况下,需要第三方工具,例如用于 MySQL 的 Percona XtraBackup。 +2. 将快照复制到新的追随者。 +3. 追随者连接到领导者并请求自快照拍摄以来发生的所有数据变更。这要求快照与领导者复制日志中的确切位置相关联。该位置有各种名称:例如,PostgreSQL 称之为 **日志序列号**;MySQL 有两种机制,**binlog 位点** 和 **全局事务标识符**(GTID)。 +4. 当追随者处理了自快照以来的数据变更积压后,我们说它已经 **追上进度**。它现在可以继续处理领导者发生的数据变更。 -设置从节点的实际步骤因数据库而异。在某些系统中,该过程是完全自动化的,而在其他系统中,它可能是需要管理员手动执行的有些神秘的多步骤工作流程。 +设置追随者的实际步骤因数据库而异。在某些系统中,该过程是完全自动化的,而在其他系统中,它可能是需要管理员手动执行的有些神秘的多步骤工作流程。 -你也可以将复制日志归档到对象存储;连同对象存储中整个数据库的定期快照,这是实现数据库备份和灾难恢复的好方法。你还可以通过从对象存储下载这些文件来执行设置新从节点的步骤 1 和 2。例如,WAL-G 为 PostgreSQL、MySQL 和 SQL Server 执行此操作,Litestream 为 SQLite 执行等效操作。 +你也可以将复制日志归档到对象存储;连同对象存储中整个数据库的定期快照,这是实现数据库备份和灾难恢复的好方法。你还可以通过从对象存储下载这些文件来执行设置新追随者的步骤 1 和 2。例如,WAL-G 为 PostgreSQL、MySQL 和 SQL Server 执行此操作,Litestream 为 SQLite 执行等效操作。 -------- @@ -121,53 +121,53 @@ breadcrumbs: false 系统中的任何节点都可能发生故障,可能是由于故障意外发生,但同样可能是由于计划维护(例如,重新启动机器以安装内核安全补丁)。能够在不停机的情况下重新启动单个节点对于操作和维护来说是一个很大的优势。因此,我们的目标是尽管单个节点发生故障,但保持整个系统运行,并尽可能减小节点中断的影响。 -如何通过基于主节点的复制实现高可用性? +如何通过基于领导者的复制实现高可用性? -#### 从节点故障:追赶恢复 {#follower-failure-catch-up-recovery} +#### 追随者故障:追赶恢复 {#follower-failure-catch-up-recovery} -在其本地磁盘上,每个从节点保留从主节点接收的数据变更日志。如果从节点崩溃并重新启动,或者如果主节点和从节点之间的网络暂时中断,从节点可以很容易地恢复:从其日志中,它知道在故障发生之前处理的最后一个事务。因此,从节点可以连接到主节点并请求在从节点断开连接期间发生的所有数据变更。当它应用了这些变更后,它就赶上了主节点,可以像以前一样继续接收数据变更流。 +在其本地磁盘上,每个追随者保留从领导者接收的数据变更日志。如果追随者崩溃并重新启动,或者如果领导者和追随者之间的网络暂时中断,追随者可以很容易地恢复:从其日志中,它知道在故障发生之前处理的最后一个事务。因此,追随者可以连接到领导者并请求在追随者断开连接期间发生的所有数据变更。当它应用了这些变更后,它就赶上了领导者,可以像以前一样继续接收数据变更流。 -尽管从节点恢复在概念上很简单,但在性能方面可能具有挑战性:如果数据库具有高写入吞吐量,或者如果从节点已离线很长时间,可能有很多写入需要赶上。在进行这种追赶时,恢复的从节点和主节点(需要将写入积压发送到从节点)都会有高负载。 +尽管追随者恢复在概念上很简单,但在性能方面可能具有挑战性:如果数据库具有高写入吞吐量,或者如果追随者已离线很长时间,可能有很多写入需要赶上。在进行这种追赶时,恢复的追随者和领导者(需要将写入积压发送到追随者)都会有高负载。 -一旦所有从节点都确认已处理了日志,主节点就可以删除其写入日志,但如果从节点长时间不可用,主节点面临选择:要么保留日志直到从节点恢复并赶上(冒着主节点磁盘空间耗尽的风险),要么删除不可用从节点尚未确认的日志(在这种情况下,从节点无法从日志中恢复,并且在它回来时必须从备份中恢复)。 +一旦所有追随者都确认已处理了日志,领导者就可以删除其写入日志,但如果追随者长时间不可用,领导者面临选择:要么保留日志直到追随者恢复并赶上(冒着领导者磁盘空间耗尽的风险),要么删除不可用追随者尚未确认的日志(在这种情况下,追随者无法从日志中恢复,并且在它回来时必须从备份中恢复)。 #### 领导者故障:故障转移 {#leader-failure-failover} -处理主节点故障更加棘手:其中一个从节点需要被提升为新的主节点,客户端需要重新配置以将其写入发送到新的主节点,其他从节点需要开始从新的主节点消费数据变更。这个过程称为 **故障转移**。 +处理领导者故障更加棘手:其中一个追随者需要被提升为新的领导者,客户端需要重新配置以将其写入发送到新的领导者,其他追随者需要开始从新的领导者消费数据变更。这个过程称为 **故障转移**。 -故障转移可以手动发生(管理员收到主节点失败的通知并采取必要步骤来创建新的主节点)或自动发生。自动故障转移过程通常包括以下步骤: +故障转移可以手动发生(管理员收到领导者失败的通知并采取必要步骤来创建新的领导者)或自动发生。自动故障转移过程通常包括以下步骤: -1. **确定主节点已失败。** 可能会出现许多问题:崩溃、停电、网络问题等。没有万无一失的方法来检测出了什么问题,所以大多数系统只是使用超时:节点经常相互反弹消息,如果节点在一段时间内没有响应——比如 30 秒——它被认为已死。(如果主节点被故意关闭以进行计划维护,这不适用。) -2. **选择新的主节点。** 这可以通过选举过程完成(其中主节点由剩余副本的多数选择),或者新的主节点可以由先前建立的 **控制器节点** 任命 [^13]。领导的最佳候选者通常是具有来自旧主节点的最新数据变更的副本(以最小化任何数据丢失)。让所有节点就新主节点达成一致是一个共识问题,在 [第 10 章](/ch10#ch_consistency) 中详细讨论。 -3. **重新配置系统以使用新的主节点。** 客户端现在需要将其写入请求发送到新的主节点(我们在 ["请求路由"](/ch7#sec_sharding_routing) 中讨论这个问题)。如果旧的主节点恢复,它可能仍然认为自己是主节点,没有意识到其他副本已经迫使它下台。系统需要确保旧的主节点成为从节点并识别新的主节点。 +1. **确定领导者已失效。** 可能会出现许多问题:崩溃、停电、网络故障等。没有万无一失的方法能准确判断发生了什么,所以大多数系统只是依赖超时:节点之间会频繁来回发送消息,如果某个节点在一段时间内(例如 30 秒)没有响应,就认为它已经失效。(如果是计划维护而主动下线领导者,则不适用。) +2. **选择新的领导者。** 这可以通过选举过程完成(由剩余副本中的多数选出领导者),也可以由预先设定的 **控制器节点** 任命 [^13]。最适合担任领导者的通常是那个拥有旧领导者最新数据变更的副本(以尽量减少数据丢失)。让所有节点就新领导者达成一致是一个共识问题,我们会在 [第 10 章](/ch10#ch_consistency) 详细讨论。 +3. **将系统重新配置为使用新的领导者。** 客户端现在需要把写请求发送到新领导者(我们在 ["请求路由"](/ch7#sec_sharding_routing) 中讨论这个问题)。如果旧领导者恢复,它可能仍然以为自己是领导者,并不知道其他副本已经让它下台。系统需要确保旧领导者降级为追随者,并识别新的领导者。 故障转移充满了可能出错的事情: -* 如果使用异步复制,新的主节点可能在失败之前没有收到来自旧主节点的所有写入。如果前主节点在选择了新主节点后重新加入集群,那些写入应该怎么办?新的主节点可能同时收到了冲突的写入。最常见的解决方案是简单地丢弃旧主节点未复制的写入,这意味着你认为已提交的写入实际上并不持久。 -* 如果数据库之外的其他存储系统需要与数据库内容协调,丢弃写入尤其危险。例如,在 GitHub 的一次事故中 [^14],一个过时的 MySQL 从节点被提升为主节点。数据库使用自增计数器为新行分配主键,但由于新主节点的计数器落后于旧主节点,它重用了旧主节点先前分配的一些主键。这些主键也在 Redis 存储中使用,因此主键的重用导致 MySQL 和 Redis 之间的不一致,这导致一些私人数据被错误地披露给错误的用户。 -* 在某些故障场景中(见 [第 9 章](/ch9#ch_distributed)),可能会发生两个节点都认为自己是主节点的情况。这种情况称为 **脑裂**,这是危险的:如果两个主节点都接受写入,并且没有解决冲突的过程(见 ["多主复制"](/ch6#sec_replication_multi_leader)),数据很可能会丢失或损坏。作为安全措施,一些系统在检测到两个主节点时有一种机制来关闭一个节点。然而,如果这种机制设计不当,你最终可能会关闭两个节点 [^15]。此外,当检测到脑裂并关闭旧节点时,可能为时已晚,数据已经损坏。 -* 在宣布主节点死亡之前,正确的超时是什么?更长的超时意味着在主节点失败的情况下恢复时间更长。然而,如果超时太短,可能会有不必要的故障转移。例如,临时负载峰值可能导致节点的响应时间增加到超时以上,或者网络故障可能导致数据包延迟。如果系统已经在高负载或网络问题上挣扎,不必要的故障转移可能会使情况变得更糟,而不是更好。 +* 如果使用异步复制,新的领导者可能在失败之前没有收到来自旧领导者的所有写入。如果前领导者在选择了新领导者后重新加入集群,那些写入应该怎么办?新的领导者可能同时收到了冲突的写入。最常见的解决方案是简单地丢弃旧领导者未复制的写入,这意味着你认为已提交的写入实际上并不持久。 +* 如果数据库之外的其他存储系统需要与数据库内容协调,丢弃写入尤其危险。例如,在 GitHub 的一次事故中 [^14],一个过时的 MySQL 追随者被提升为领导者。数据库使用自增计数器为新行分配主键,但由于新领导者的计数器落后于旧领导者,它重用了旧领导者先前分配的一些主键。这些主键也在 Redis 存储中使用,因此主键的重用导致 MySQL 和 Redis 之间的不一致,这导致一些私人数据被错误地披露给错误的用户。 +* 在某些故障场景中(见 [第 9 章](/ch9#ch_distributed)),可能会发生两个节点都认为自己是领导者的情况。这种情况称为 **脑裂**,这是危险的:如果两个领导者都接受写入,并且没有解决冲突的过程(见 ["多主复制"](/ch6#sec_replication_multi_leader)),数据很可能会丢失或损坏。作为安全措施,一些系统在检测到两个领导者时有一种机制来关闭一个节点。然而,如果这种机制设计不当,你最终可能会关闭两个节点 [^15]。此外,当检测到脑裂并关闭旧节点时,可能为时已晚,数据已经损坏。 +* 在宣布领导者死亡之前,正确的超时是什么?更长的超时意味着在领导者失败的情况下恢复时间更长。然而,如果超时太短,可能会有不必要的故障转移。例如,临时负载峰值可能导致节点的响应时间增加到超时以上,或者网络故障可能导致数据包延迟。如果系统已经在高负载或网络问题上挣扎,不必要的故障转移可能会使情况变得更糟,而不是更好。 -------- > [!NOTE] -> 通过限制或关闭旧主节点来防止脑裂被称为 **栅栏机制**,或者更强调地说,**向头部开枪**(STONITH)。我们将在 ["分布式锁和租约"](/ch9#sec_distributed_lock_fencing) 中更详细地讨论栅栏机制。 +> 通过限制或关闭旧领导者来防止脑裂,被称为 **栅栏机制**(fencing),或者更直白地说,**爆彼之头**(STONITH)。我们将在 ["分布式锁和租约"](/ch9#sec_distributed_lock_fencing) 中更详细地讨论栅栏机制。 -------- 这些问题没有简单的解决方案。因此,一些运维团队更喜欢手动执行故障转移,即使软件支持自动故障转移。 -故障转移最重要的是选择一个最新的从节点作为新的主节点——如果使用同步或半同步复制,这将是旧主节点在确认写入之前等待的从节点。使用异步复制,你可以选择具有最大日志序列号的从节点。这最小化了故障转移期间丢失的数据量:丢失几分之一秒的写入可能是可以容忍的,但选择落后几天的从节点可能是灾难性的。 +故障转移最重要的是选择一个最新的追随者作为新的领导者——如果使用同步或半同步复制,这将是旧领导者在确认写入之前等待的追随者。使用异步复制,你可以选择具有最大日志序列号的追随者。这最小化了故障转移期间丢失的数据量:丢失几分之一秒的写入可能是可以容忍的,但选择落后几天的追随者可能是灾难性的。 这些问题——节点故障;不可靠的网络;以及围绕副本一致性、持久性、可用性和延迟的权衡——实际上是分布式系统中的基本问题。在 [第 9 章](/ch9#ch_distributed) 和 [第 10 章](/ch10#ch_consistency) 中,我们将更深入地讨论它们。 ### 复制日志的实现 {#sec_replication_implementation} -基于主节点的复制在底层是如何工作的?让我们简要地看看实践中使用的几种不同的复制方法。 +基于领导者的复制在底层是如何工作的?让我们简要地看看实践中使用的几种不同的复制方法。 #### 基于语句的复制 {#statement-based-replication} -在最简单的情况下,主节点记录它执行的每个写入请求(**语句**)并将该语句日志发送给其从节点。对于关系数据库,这意味着每个 `INSERT`、`UPDATE` 或 `DELETE` 语句都被转发到从节点,每个从节点解析并执行该 SQL 语句,就像它是从客户端接收的一样。 +在最简单的情况下,领导者记录它执行的每个写入请求(**语句**)并将该语句日志发送给其追随者。对于关系数据库,这意味着每个 `INSERT`、`UPDATE` 或 `DELETE` 语句都被转发到追随者,每个追随者解析并执行该 SQL 语句,就像它是从客户端接收的一样。 虽然这听起来合理,但这种复制方法可能会出现各种问题: @@ -175,17 +175,17 @@ breadcrumbs: false * 如果语句使用自增列,或者如果它们依赖于数据库中的现有数据(例如,`UPDATE … WHERE <某条件>`),它们必须在每个副本上以完全相同的顺序执行,否则它们可能会产生不同的效果。当有多个并发执行的事务时,这可能会受到限制。 * 具有副作用的语句(例如,触发器、存储过程、用户定义的函数)可能会导致每个副本上发生不同的副作用,除非副作用是绝对确定的。 -可以解决这些问题——例如,主节点可以在记录语句时用固定的返回值替换任何非确定性函数调用,以便从节点都获得相同的值。以固定顺序执行确定性语句的想法类似于我们之前在 ["事件溯源与 CQRS"](/ch3#sec_datamodels_events) 中讨论的事件溯源模型。这种方法也称为 **状态机复制**,我们将在 ["使用共享日志"](/ch10#sec_consistency_smr) 中讨论其背后的理论。 +可以解决这些问题——例如,领导者可以在记录语句时用固定的返回值替换任何非确定性函数调用,以便追随者都获得相同的值。以固定顺序执行确定性语句的想法类似于我们之前在 ["事件溯源与 CQRS"](/ch3#sec_datamodels_events) 中讨论的事件溯源模型。这种方法也称为 **状态机复制**,我们将在 ["使用共享日志"](/ch10#sec_consistency_smr) 中讨论其背后的理论。 基于语句的复制在 MySQL 5.1 版本之前使用。它今天有时仍在使用,因为它相当紧凑,但默认情况下,如果语句中有任何非确定性,MySQL 现在会切换到基于行的复制(稍后讨论)。VoltDB 使用基于语句的复制,并通过要求事务是确定性的来使其安全 [^16]。然而,确定性在实践中很难保证,因此许多数据库更喜欢其他复制方法。 #### 预写日志(WAL)传输 {#write-ahead-log-wal-shipping} -在 [第 4 章](/ch4#ch_storage) 中,我们看到预写日志是使 B 树存储引擎健壮所必需的:每个修改首先写入 WAL,以便在崩溃后可以将树恢复到一致状态。由于 WAL 包含将索引和堆恢复到一致状态所需的所有信息,我们可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘外,主节点还通过网络将其发送给其从节点。当从节点处理此日志时,它构建了与主节点上找到的完全相同的文件副本。 +在 [第 4 章](/ch4#ch_storage) 中,我们看到预写日志是使 B 树存储引擎健壮所必需的:每个修改首先写入 WAL,以便在崩溃后可以将树恢复到一致状态。由于 WAL 包含将索引和堆恢复到一致状态所需的所有信息,我们可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘外,领导者还通过网络将其发送给其追随者。当追随者处理此日志时,它构建了与领导者上找到的完全相同的文件副本。 -此复制方法在 PostgreSQL 和 Oracle 等中使用 [^17] [^18]。主要缺点是日志在非常低的级别描述数据:WAL 包含哪些字节在哪些磁盘块中被更改的详细信息。这使得复制与存储引擎紧密耦合。如果数据库从一个版本更改其存储格式到另一个版本,通常不可能在主节点和从节点上运行不同版本的数据库软件。 +此复制方法在 PostgreSQL 和 Oracle 等中使用 [^17] [^18]。主要缺点是日志在非常低的级别描述数据:WAL 包含哪些字节在哪些磁盘块中被更改的详细信息。这使得复制与存储引擎紧密耦合。如果数据库从一个版本更改其存储格式到另一个版本,通常不可能在领导者和追随者上运行不同版本的数据库软件。 -这可能看起来像是一个小的实现细节,但它可能会产生很大的操作影响。如果复制协议允许从节点使用比主节点更新的软件版本,你可以通过首先升级从节点然后执行故障转移以使其中一个升级的节点成为新的主节点来执行数据库软件的零停机升级。如果复制协议不允许此版本不匹配(如 WAL 传输的情况),此类升级需要停机。 +这可能看起来像是一个小的实现细节,但它可能会产生很大的操作影响。如果复制协议允许追随者使用比领导者更新的软件版本,你可以通过首先升级追随者然后执行故障转移以使其中一个升级的节点成为新的领导者来执行数据库软件的零停机升级。如果复制协议不允许此版本不匹配(如 WAL 传输的情况),此类升级需要停机。 #### 逻辑(基于行)日志复制 {#logical-row-based-log-replication} @@ -199,35 +199,35 @@ breadcrumbs: false 修改多行的事务会生成多个这样的日志记录,后跟指示事务已提交的记录。MySQL 除了 WAL 之外还保留一个单独的逻辑复制日志,称为 **binlog**(当配置为使用基于行的复制时)。PostgreSQL 通过将物理 WAL 解码为行插入/更新/删除事件来实现逻辑复制 [^19]。 -由于逻辑日志与存储引擎内部解耦,因此可以更容易地保持向后兼容,允许主节点和从节点运行不同版本的数据库软件。这反过来又可以以最少的停机时间升级到新版本 [^20]。 +由于逻辑日志与存储引擎内部解耦,因此可以更容易地保持向后兼容,允许领导者和追随者运行不同版本的数据库软件。这反过来又可以以最少的停机时间升级到新版本 [^20]。 -逻辑日志格式也更容易供外部应用程序解析。如果你想将数据库的内容发送到外部系统(例如用于离线分析的数据仓库),或者构建自定义索引和缓存 [^21],这方面很有用。这种技术称为 **变更数据捕获**,我们将在 [Link to Come] 中回到它。 +逻辑日志格式也更容易被外部应用解析。如果你想把数据库内容发送到外部系统(例如用于离线分析的数据仓库),或者构建自定义索引和缓存 [^21],这一点会很有用。这种技术称为 **数据变更捕获**,我们将在 ["数据变更捕获"](/ch12#sec_stream_cdc) 一节再回到它。 ## 复制延迟的问题 {#sec_replication_lag} 能够容忍节点故障只是想要复制的一个原因。如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 中所述,其他原因是可伸缩性(处理比单台机器能够处理的更多请求)和延迟(将副本在地理上放置得更接近用户)。 -基于主节点的复制要求所有写入都通过单个节点,但只读查询可以转到任何副本。对于主要由读取和只有少量写入组成的工作负载(这通常是在线服务的情况),有一个有吸引力的选择:创建许多从节点,并将读取请求分布在这些从节点上。这减轻了主节点的负载,并允许附近的副本提供读取请求。 +基于领导者的复制要求所有写入都通过单个节点,但只读查询可以转到任何副本。对于主要由读取和只有少量写入组成的工作负载(这通常是在线服务的情况),有一个有吸引力的选择:创建许多追随者,并将读取请求分布在这些追随者上。这减轻了领导者的负载,并允许附近的副本提供读取请求。 -在这种 **读扩展** 架构中,你可以通过添加更多从节点来简单地增加服务只读请求的容量。然而,这种方法只有在使用异步复制时才现实可行——如果你试图同步复制到所有从节点,单个节点故障或网络中断将使整个系统无法写入。而且你拥有的节点越多,其中一个节点宕机的可能性就越大,因此完全同步的配置将非常不可靠。 +在这种 **读扩展** 架构中,你可以通过添加更多追随者来简单地增加服务只读请求的容量。然而,这种方法只有在使用异步复制时才现实可行——如果你试图同步复制到所有追随者,单个节点故障或网络中断将使整个系统无法写入。而且你拥有的节点越多,其中一个节点宕机的可能性就越大,因此完全同步的配置将非常不可靠。 -不幸的是,如果应用程序从 **异步** 从节点读取,如果从节点已落后,它可能会看到过时的信息。这导致数据库中出现明显的不一致:如果你同时在主节点和从节点上运行相同的查询,你可能会得到不同的结果,因为并非所有写入都已反映在从节点中。这种不一致只是一种临时状态——如果你停止向数据库写入并等待一段时间,从节点最终将赶上并与主节点保持一致。因此,这种效果被称为 **最终一致性** [^22]。 +不幸的是,如果应用程序从 **异步** 追随者读取,如果追随者已落后,它可能会看到过时的信息。这导致数据库中出现明显的不一致:如果你同时在领导者和追随者上运行相同的查询,你可能会得到不同的结果,因为并非所有写入都已反映在追随者中。这种不一致只是一种临时状态——如果你停止向数据库写入并等待一段时间,追随者最终将赶上并与领导者保持一致。因此,这种效果被称为 **最终一致性** [^22]。 -------- > [!NOTE] -> 术语 **最终一致性** 由 Douglas Terry 等人创造 [^23],由 Werner Vogels 推广 [^24],并成为许多 NoSQL 项目的战斗口号。然而,不仅 NoSQL 数据库是最终一致的:异步复制的关系数据库中的从节点具有相同的特征。 +> 术语 **最终一致性** 由 Douglas Terry 等人创造 [^23],由 Werner Vogels 推广 [^24],并成为许多 NoSQL 项目的战斗口号。然而,不仅 NoSQL 数据库是最终一致的:异步复制的关系数据库中的追随者具有相同的特征。 -------- -术语"最终"是故意模糊的:一般来说,副本可以落后多远没有限制。在正常操作中,写入发生在主节点上并反映在从节点上之间的延迟——**复制延迟**——可能只是几分之一秒,在实践中不会被注意到。然而,如果系统在接近容量运行或网络中存在问题,延迟可以轻易增加到几秒甚至几分钟。 +术语"最终"是故意模糊的:一般来说,副本可以落后多远没有限制。在正常操作中,写入发生在领导者上并反映在追随者上之间的延迟——**复制延迟**——可能只是几分之一秒,在实践中不会被注意到。然而,如果系统在接近容量运行或网络中存在问题,延迟可以轻易增加到几秒甚至几分钟。 当延迟如此之大时,它引入的不一致不仅仅是一个理论问题,而是应用程序的真正问题。在本节中,我们将重点介绍复制延迟时可能发生的三个问题示例。我们还将概述解决它们的一些方法。 ### 读己之写 {#sec_replication_ryw} -许多应用程序让用户提交一些数据,然后查看他们提交的内容。这可能是客户数据库中的记录,或讨论线程上的评论,或其他类似的东西。提交新数据时,必须将其发送到主节点,但当用户查看数据时,可以从从节点读取。如果数据经常被查看但只是偶尔被写入,这尤其合适。 +许多应用程序让用户提交一些数据,然后查看他们提交的内容。这可能是客户数据库中的记录,或讨论线程上的评论,或其他类似的东西。提交新数据时,必须将其发送到领导者,但当用户查看数据时,可以从追随者读取。如果数据经常被查看但只是偶尔被写入,这尤其合适。 使用异步复制,存在一个问题,如 [图 6-3](/ch6#fig_replication_read_your_writes) 所示:如果用户在写入后不久查看数据,新数据可能尚未到达副本。对用户来说,看起来他们提交的数据丢失了,所以他们自然会不高兴。 @@ -235,39 +235,39 @@ breadcrumbs: false 在这种情况下,我们需要 **写后读一致性**,也称为 **读己之写一致性** [^23]。这是一种保证,如果用户重新加载页面,他们将始终看到他们自己提交的任何更新。它不对其他用户做出承诺:其他用户的更新可能直到稍后才可见。然而,它向用户保证他们自己的输入已正确保存。 -我们如何在基于主节点的复制系统中实现写后读一致性?有各种可能的技术。提及其中几个: +我们如何在基于领导者的复制系统中实现写后读一致性?有各种可能的技术。下面举几个例子: -* 当读取用户可能已修改的内容时,从主节点或同步更新的从节点读取;否则,从异步更新的从节点读取。这要求你有某种方法知道某物是否可能已被修改,而无需实际查询它。例如,社交网络上的用户个人资料信息通常只能由个人资料的所有者编辑,而不能由其他任何人编辑。因此,一个简单的规则是:始终从主节点读取用户自己的个人资料,从从节点读取任何其他用户的个人资料。 -* 如果应用程序中的大多数东西都可能被用户编辑,那种方法将不会有效,因为大多数东西都必须从主节点读取(否定了读扩展的好处)。在这种情况下,可以使用其他标准来决定是否从主节点读取。例如,你可以跟踪上次更新的时间,并在上次更新后的一分钟内,使所有读取都来自主节点 [^25]。你还可以监控从节点上的复制延迟,并防止在落后主节点超过一分钟的任何从节点上进行查询。 +* 当读取用户可能已修改的内容时,从领导者或同步更新的追随者读取;否则,从异步更新的追随者读取。这要求你有某种方法知道某物是否可能已被修改,而无需实际查询它。例如,社交网络上的用户个人资料信息通常只能由个人资料的所有者编辑,而不能由其他任何人编辑。因此,一个简单的规则是:始终从领导者读取用户自己的个人资料,从追随者读取任何其他用户的个人资料。 +* 如果应用程序中的大多数东西都可能被用户编辑,那种方法将不会有效,因为大多数东西都必须从领导者读取(否定了读扩展的好处)。在这种情况下,可以使用其他标准来决定是否从领导者读取。例如,你可以跟踪上次更新的时间,并在上次更新后的一分钟内,使所有读取都来自领导者 [^25]。你还可以监控追随者上的复制延迟,并防止在落后领导者超过一分钟的任何追随者上进行查询。 * 客户端可以记住其最近写入的时间戳——然后系统可以确保为该用户提供任何读取的副本至少反映该时间戳之前的更新。如果副本不够最新,则可以由另一个副本处理读取,或者查询可以等待直到副本赶上 [^26]。时间戳可以是 **逻辑时间戳**(指示写入顺序的东西,例如日志序列号)或实际系统时钟(在这种情况下,时钟同步变得至关重要;见 ["不可靠的时钟"](/ch9#sec_distributed_clocks))。 -* 如果你的副本分布在各个地区(为了地理上接近用户或为了可用性),还有额外的复杂性。任何需要由主节点提供的请求都必须路由到包含主节点的地区。 +* 如果你的副本分布在各个地区(为了地理上接近用户或为了可用性),还有额外的复杂性。任何需要由领导者提供的请求都必须路由到包含领导者的地区。 当同一用户从多个设备访问你的服务时,会出现另一个复杂情况,例如桌面网络浏览器和移动应用程序。在这种情况下,你可能希望提供 **跨设备** 写后读一致性:如果用户在一个设备上输入一些信息,然后在另一个设备上查看它,他们应该看到他们刚刚输入的信息。 在这种情况下,需要考虑一些额外的问题: * 需要记住用户上次更新的时间戳的方法变得更加困难,因为在一个设备上运行的代码不知道在另一个设备上发生了什么更新。此元数据将需要集中化。 -* 如果你的副本分布在不同的地区,则无法保证来自不同设备的连接将路由到同一地区。(例如,如果用户的台式计算机使用家庭宽带连接,而他们的移动设备使用蜂窝数据网络,则设备的网络路由可能完全不同。)如果你的方法需要从主节点读取,你可能首先需要将来自用户所有设备的请求路由到同一地区。 +* 如果你的副本分布在不同的地区,则无法保证来自不同设备的连接将路由到同一地区。(例如,如果用户的台式计算机使用家庭宽带连接,而他们的移动设备使用蜂窝数据网络,则设备的网络路由可能完全不同。)如果你的方法需要从领导者读取,你可能首先需要将来自用户所有设备的请求路由到同一地区。 -------- -> ![TIP] 地区和可用区 +> [!TIP] 地区和可用区 > -> 我们使用术语 **地区** 来指代单个地理位置中的一个或多个数据中心。云提供商在同一地理区域中定位多个数据中心。每个数据中心被称为 **可用区** 或简称 **区域**。因此,单个云区域由多个区域组成。每个区域是位于独立物理设施中的独立数据中心,具有自己的电源、冷却等。 +> 我们用 **地区**(region)来指代一个地理位置中的一组数据中心。云服务提供商通常会在同一地区部署多个数据中心,每个数据中心称为 **可用区**(availability zone,简称 AZ)。因此,一个地区由多个可用区组成;每个可用区都是独立的物理设施,具有自己的供电、制冷等基础设施。 > -> 同一地区的区域通过非常高速的网络连接连接。延迟足够低,以至于大多数分布式系统可以在同一地区的多个区域中运行节点,就好像它们在单个区域中一样。多区域配置允许分布式系统在一个区域离线的区域中断中幸存,但它们不能防止所有区域不可用的区域中断。为了在区域中断中幸存,分布式系统必须部署在多个地区,这可能导致更高的延迟、更低的吞吐量和增加的云网络账单。我们将在 ["多主复制拓扑"](/ch6#sec_replication_topologies) 中更多地讨论这些权衡。现在,只要知道当我们说地区时,我们指的是单个地理位置中的区域/数据中心集合。 +> 同一地区内各可用区通常通过高速网络互联,延迟足够低,因此大多数分布式系统可以把同一地区内的多个可用区近似看作一个机房。多可用区部署可以抵御单个可用区故障,但无法抵御整个地区不可用。要应对地区级中断,系统必须跨多个地区部署,这通常会带来更高延迟、更低吞吐和更高的云网络费用。我们将在 ["多主复制拓扑"](/ch6#sec_replication_topologies) 中进一步讨论这些权衡。这里你只需记住:本书所说的“地区”,是同一地理位置内多个可用区(数据中心)的集合。 -------- ### 单调读 {#sec_replication_monotonic_reads} -从异步从节点读取时可能发生的第二个异常示例是,用户可能会看到事物 **在时间上倒退**。 +从异步追随者读取时可能发生的第二个异常示例是,用户可能会看到事物 **在时间上倒退**。 -如果用户从不同的副本进行多次读取,就可能发生这种情况。例如,[图 6-4](/ch6#fig_replication_monotonic_reads) 显示用户 2345 进行相同的查询两次,首先到延迟很小的从节点,然后到延迟更大的从节点。(如果用户刷新网页,并且每个请求都路由到随机服务器,这种情况很可能发生。)第一个查询返回用户 1234 最近添加的评论,但第二个查询没有返回任何内容,因为滞后的从节点尚未获取该写入。实际上,第二个查询观察到的系统状态比第一个查询更早的时间点。如果第一个查询没有返回任何内容,这不会那么糟糕,因为用户 2345 可能不知道用户 1234 最近添加了评论。然而,如果用户 2345 首先看到用户 1234 的评论出现,然后又看到它消失,这对用户 2345 来说非常令人困惑。 +如果用户从不同的副本进行多次读取,就可能发生这种情况。例如,[图 6-4](/ch6#fig_replication_monotonic_reads) 显示用户 2345 进行相同的查询两次,首先到延迟很小的追随者,然后到延迟更大的追随者。(如果用户刷新网页,并且每个请求都路由到随机服务器,这种情况很可能发生。)第一个查询返回用户 1234 最近添加的评论,但第二个查询没有返回任何内容,因为滞后的追随者尚未获取该写入。实际上,第二个查询观察到的系统状态比第一个查询更早的时间点。如果第一个查询没有返回任何内容,这不会那么糟糕,因为用户 2345 可能不知道用户 1234 最近添加了评论。然而,如果用户 2345 首先看到用户 1234 的评论出现,然后又看到它消失,这对用户 2345 来说非常令人困惑。 {{< figure src="/fig/ddia_0604.png" id="fig_replication_monotonic_reads" caption="图 6-4. 用户首先从新鲜副本读取,然后从陈旧副本读取。时间似乎倒退了。为了防止这种异常,我们需要单调读。" class="w-full my-4" >}} -**单调读** [^22] 是一种保证这种异常不会发生的保证。它是比强一致性更弱的保证,但比最终一致性更强的保证。当你读取数据时,你可能会看到一个旧值;单调读只意味着如果一个用户按顺序进行多次读取,他们不会看到时间倒退——即,在之前读取较新数据后,他们不会读取较旧的数据。 +**单调读** [^22] 是一种保证这类异常不会发生的会话保证。它比强一致性弱,但比最终一致性强。当你读取数据时,仍可能看到旧值;单调读只保证同一用户按顺序进行多次读取时,不会出现“时间倒退”——也就是先读到新值,后又读到更旧的值。 实现单调读的一种方法是确保每个用户始终从同一副本进行读取(不同的用户可以从不同的副本读取)。例如,可以基于用户 ID 的哈希选择副本,而不是随机选择。然而,如果该副本失败,用户的查询将需要重新路由到另一个副本。 @@ -283,7 +283,7 @@ Cake 夫人 这两个句子之间存在因果依赖关系:Cake 夫人听到了 Poons 先生的问题并回答了它。 -现在,想象第三个人通过从节点听这个对话。Cake 夫人说的话通过延迟很小的从节点,但 Poons 先生说的话有更长的复制延迟(见 [图 6-5](/ch6#fig_replication_consistent_prefix))。这个观察者会听到以下内容: +现在,想象第三个人通过追随者听这个对话。Cake 夫人说的话通过延迟很小的追随者,但 Poons 先生说的话有更长的复制延迟(见 [图 6-5](/ch6#fig_replication_consistent_prefix))。这个观察者会听到以下内容: Cake 夫人 : 通常大约十秒钟,Poons 先生。 @@ -299,13 +299,13 @@ Poons 先生 这是分片(分区)数据库中的一个特殊问题,我们将在 [第 7 章](/ch7#ch_sharding) 中讨论。如果数据库始终以相同的顺序应用写入,读取始终会看到一致的前缀,因此这种异常不会发生。然而,在许多分布式数据库中,不同的分片独立运行,因此没有全局的写入顺序:当用户从数据库读取时,他们可能会看到数据库的某些部分处于较旧状态,而某些部分处于较新状态。 -一种解决方案是确保任何因果相关的写入都写入同一分片——但在某些应用程序中,这无法有效完成。还有一些算法明确跟踪因果依赖关系,这是我们将在 [""先发生"关系与并发"](/ch6#sec_replication_happens_before) 中回到的主题。 +一种解决方案是确保任何因果相关的写入都写入同一分片——但在某些应用程序中,这无法有效完成。还有一些算法明确跟踪因果依赖关系,这是我们将在 ["先发生关系与并发"](/ch6#sec_replication_happens_before) 中回到的主题。 ### 复制延迟的解决方案 {#id131} -在使用最终一致系统时,值得思考如果复制延迟增加到几分钟甚至几小时,应用程序的行为如何。如果答案是"没问题",那很好。然而,如果结果对用户来说是糟糕的体验,那么设计系统以提供更强的保证(如写后读)很重要。明明是异步复制,却假装它是同步的,这为日后出问题埋下了隐患。 +在使用最终一致系统时,值得思考:如果复制延迟上升到几分钟甚至几小时,应用程序会如何表现。如果答案是“没问题”,那很好;但如果这会造成糟糕的用户体验,就应当设计系统提供更强的保证(如写后读一致性)。把异步复制当作同步复制来假设,往往会在系统承压时暴露问题。 -如前所述,应用程序可以提供比底层数据库更强的保证——例如,通过在主节点或同步更新的从节点上执行某些类型的读取。然而,在应用程序代码中处理这些问题很复杂且容易出错。 +如前所述,应用程序可以提供比底层数据库更强的保证——例如,通过在领导者或同步更新的追随者上执行某些类型的读取。然而,在应用程序代码中处理这些问题很复杂且容易出错。 对于应用程序开发人员来说,最简单的编程模型是选择一个为副本提供强一致性保证的数据库,例如线性一致性(见 [第 10 章](/ch10#ch_consistency))和 ACID 事务(见 [第 8 章](/ch8#ch_transactions))。这允许你大部分忽略复制带来的挑战,并将数据库视为只有一个节点。在 2010 年代初期,**NoSQL** 运动推广了这样的观点,即这些功能限制了可伸缩性,大规模系统必须接受最终一致性。 @@ -317,43 +317,43 @@ Poons 先生 ## 多主复制 {#sec_replication_multi_leader} -到目前为止,本章中我们只考虑了使用单个主节点的复制架构。尽管这是一种常见的方法,但还有一些有趣的替代方案。 +到目前为止,本章中我们只考虑了使用单个领导者的复制架构。尽管这是一种常见的方法,但还有一些有趣的替代方案。 -单主复制有一个主要缺点:所有写入都必须通过一个主节点。如果由于任何原因无法连接到主节点,例如你和主节点之间的网络中断,你就无法写入数据库。 +单主复制有一个主要缺点:所有写入都必须通过一个领导者。如果由于任何原因无法连接到领导者,例如你和领导者之间的网络中断,你就无法写入数据库。 -单主复制模型的自然扩展是允许多个节点接受写入。复制仍然以相同的方式进行:每个处理写入的节点必须将该数据变更转发给所有其他节点。我们称之为 **多主** 配置(也称为 **主动/主动** 或 **双向** 复制)。在这种设置中,每个主节点同时充当其他主节点的从节点。 +单主复制模型的自然扩展是允许多个节点接受写入。复制仍然以相同的方式进行:每个处理写入的节点必须将该数据变更转发给所有其他节点。我们称之为 **多主** 配置(也称为 **主动/主动** 或 **双向** 复制)。在这种设置中,每个领导者同时充当其他领导者的追随者。 -与单主复制一样,可以选择使其同步或异步。假设你有两个主节点,*A* 和 *B*,你正在尝试写入 *A*。如果写入从 *A* 同步复制到 *B*,并且两个节点之间的网络中断,你就无法写入 *A* 直到网络恢复。同步多主复制因此给你一个非常类似于单主复制的模型,即如果你让 *B* 成为主节点,*A* 只是将任何写入请求转发给 *B* 执行。 +与单主复制一样,可以选择使其同步或异步。假设你有两个领导者,*A* 和 *B*,你正在尝试写入 *A*。如果写入从 *A* 同步复制到 *B*,并且两个节点之间的网络中断,你就无法写入 *A* 直到网络恢复。同步多主复制因此给你一个非常类似于单主复制的模型,即如果你让 *B* 成为领导者,*A* 只是将任何写入请求转发给 *B* 执行。 -因此,我们不会进一步讨论同步多主复制,而只是将其视为等同于单主复制。本节的其余部分专注于异步多主复制,其中任何主节点都可以处理写入,即使其与其他主节点的连接中断。 +因此,我们不会进一步讨论同步多主复制,而只是将其视为等同于单主复制。本节的其余部分专注于异步多主复制,其中任何领导者都可以处理写入,即使其与其他领导者的连接中断。 ### 跨地域运行 {#sec_replication_multi_dc} 在单个地区内使用多主设置很少有意义,因为好处很少超过增加的复杂性。然而,在某些情况下,这种配置是合理的。 -想象你有一个数据库,在几个不同的地区有副本(也许是为了能够容忍整个地区的故障,或者是为了更接近你的用户)。这被称为 **地理分布式**、**地域分布式** 或 **地域复制** 设置。使用单主复制,主节点必须在 **一个** 地区,所有写入都必须通过该地区。 +想象你有一个数据库,在几个不同的地区有副本(也许是为了能够容忍整个地区的故障,或者是为了更接近你的用户)。这被称为 **地理分布式**、**地域分布式** 或 **地域复制** 设置。使用单主复制,领导者必须在 **一个** 地区,所有写入都必须通过该地区。 -在多主配置中,你可以在 **每个** 地区都有一个主节点。[图 6-6](/ch6#fig_replication_multi_dc) 显示了这种架构可能的样子。在每个地区内,使用常规的主从复制(从节点可能在与主节点不同的可用区中);在地区之间,每个地区的主节点将其变更复制到其他地区的主节点。 +在多主配置中,你可以在 **每个** 地区都部署一个领导者。[图 6-6](/ch6#fig_replication_multi_dc) 展示了这种架构:在每个地区内使用常规单主复制(追随者可能位于与领导者不同的可用区);在地区之间,每个地区的领导者把变更复制给其他地区的领导者。 {{< figure src="/fig/ddia_0606.png" id="fig_replication_multi_dc" caption="图 6-6. 跨多个地区的多主复制。" class="w-full my-4" >}} 让我们比较单主和多主配置在多地区部署中的表现: 性能 -: 在单主配置中,每次写入都必须通过互联网到拥有主节点的地区。这可能会给写入增加显著的延迟,并可能违背首先拥有多个地区的目的。在多主配置中,每次写入都可以在本地地区处理,并异步复制到其他地区。因此,跨地区网络延迟对用户是隐藏的,这意味着感知性能可能更好。 +: 在单主配置中,每次写入都必须通过互联网到拥有领导者的地区。这可能会给写入增加显著的延迟,并可能违背首先拥有多个地区的目的。在多主配置中,每次写入都可以在本地地区处理,并异步复制到其他地区。因此,跨地区网络延迟对用户是隐藏的,这意味着感知性能可能更好。 地区故障容忍 -: 在单主配置中,如果拥有主节点的地区变得不可用,故障转移可以将另一个地区的从节点提升为主节点。在多主配置中,每个地区可以独立于其他地区继续运行,并在离线地区恢复上线时赶上复制。 +: 在单主配置中,如果拥有领导者的地区变得不可用,故障转移可以将另一个地区的追随者提升为领导者。在多主配置中,每个地区可以独立于其他地区继续运行,并在离线地区恢复上线时赶上复制。 网络问题容忍 -: 即使有专用连接,地区之间的流量也可能比同一地区内或单个区域内的流量更不可靠。单主配置对这种跨地区链路中的问题非常敏感,因为当一个地区的客户端想要写入另一个地区的主节点时,它必须通过该链路发送其请求并等待响应才能完成。 +: 即使有专用连接,地区之间的流量也可能比同一地区内或单个区域内的流量更不可靠。单主配置对这种跨地区链路中的问题非常敏感,因为当一个地区的客户端想要写入另一个地区的领导者时,它必须通过该链路发送其请求并等待响应才能完成。 - 具有异步复制的多主配置可以更好地容忍网络问题:在临时网络中断期间,每个地区的主节点可以继续独立处理写入。 + 具有异步复制的多主配置可以更好地容忍网络问题:在临时网络中断期间,每个地区的领导者可以继续独立处理写入。 一致性 -: 单主系统可以提供强一致性保证,例如可串行化事务,我们将在 [第 8 章](/ch8#ch_transactions) 中讨论。多主系统的最大缺点是它们能够实现的一致性要弱得多。例如,你不能保证银行账户不会变成负数或用户名是唯一的:不同的主节点总是可能处理单独没问题的写入(从账户中支付一些钱,注册特定用户名),但当与另一个主节点上的另一个写入结合时违反了约束。 +: 单主系统可以提供强一致性保证,例如可串行化事务,我们将在 [第 8 章](/ch8#ch_transactions) 中讨论。多主系统的最大缺点是它们能够实现的一致性要弱得多。例如,你不能保证银行账户不会变成负数或用户名是唯一的:不同的领导者总是可能处理单独没问题的写入(从账户中支付一些钱,注册特定用户名),但当与另一个领导者上的另一个写入结合时违反了约束。 - 这只是分布式系统的基本限制 [^28]。如果你需要强制执行此类约束,因此你最好使用单主系统。然而,正如我们将在 ["处理写入冲突"](/ch6#sec_replication_write_conflicts) 中看到的,多主系统仍然可以实现在不需要此类约束的广泛应用程序中有用的一致性属性。 + 这只是分布式系统的基本限制 [^28]。如果你必须强制执行这类约束,通常应选择单主系统。不过,正如我们将在 ["处理写入冲突"](/ch6#sec_replication_write_conflicts) 中看到的,多主系统在不需要这类约束的广泛应用里,仍然可以提供有用的一致性属性。 多主复制不如单主复制常见,但许多数据库仍然支持它,包括 MySQL、Oracle、SQL Server 和 YugabyteDB。在某些情况下,它是一个外部附加功能,例如在 Redis Enterprise、EDB Postgres Distributed 和 pglogical 中 [^29]。 @@ -361,11 +361,11 @@ Poons 先生 #### 多主复制拓扑 {#sec_replication_topologies} -**复制拓扑** 描述了写入从一个节点传播到另一个节点的通信路径。如果你有两个主节点,如 [图 6-9](/ch6#fig_replication_write_conflict) 中,只有一种合理的拓扑:主节点 1 必须将其所有写入发送到主节点 2,反之亦然。有了两个以上的主节点,各种不同的拓扑是可能的。[图 6-7](/ch6#fig_replication_topologies) 中说明了一些示例。 +**复制拓扑** 描述了写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如 [图 6-9](/ch6#fig_replication_write_conflict) 中,只有一种合理的拓扑:领导者 1 必须将其所有写入发送到领导者 2,反之亦然。有了两个以上的领导者,各种不同的拓扑是可能的。[图 6-7](/ch6#fig_replication_topologies) 中说明了一些示例。 {{< figure src="/fig/ddia_0607.png" id="fig_replication_topologies" caption="图 6-7. 可以设置多主复制的三个示例拓扑。" class="w-full my-4" >}} -最通用的拓扑是 **全对全**,如 [图 6-7](/ch6#fig_replication_topologies)(c) 所示,其中每个主节点将其写入发送到每个其他主节点。然而,也使用更受限制的拓扑:例如 **环形拓扑**,其中每个节点从一个节点接收写入并将这些写入(加上其自己的任何写入)转发到另一个节点。另一种流行的拓扑具有 **星形** 形状:一个指定的根节点将写入转发到所有其他节点。星形拓扑可以推广到树形。 +最通用的拓扑是 **全对全**,如 [图 6-7](/ch6#fig_replication_topologies)(c) 所示,其中每个领导者将其写入发送到每个其他领导者。然而,也使用更受限制的拓扑:例如 **环形拓扑**,其中每个节点从一个节点接收写入并将这些写入(加上其自己的任何写入)转发到另一个节点。另一种流行的拓扑具有 **星形** 形状:一个指定的根节点将写入转发到所有其他节点。星形拓扑可以推广到树形。 -------- @@ -384,9 +384,9 @@ Poons 先生 {{< figure src="/fig/ddia_0608.png" id="fig_replication_causality" caption="图 6-8. 使用多主复制,写入可能以错误的顺序到达某些副本。" class="w-full my-4" >}} -在 [图 6-8](/ch6#fig_replication_causality) 中,客户端 A 在主节点 1 上向表中插入一行,客户端 B 在主节点 3 上更新该行。然而,主节点 2 可能以不同的顺序接收写入:它可能首先接收更新(从其角度来看,这是对数据库中不存在的行的更新),然后才接收相应的插入(应该在更新之前)。 +在 [图 6-8](/ch6#fig_replication_causality) 中,客户端 A 在领导者 1 上向表中插入一行,客户端 B 在领导者 3 上更新该行。然而,领导者 2 可能以不同的顺序接收写入:它可能首先接收更新(从其角度来看,这是对数据库中不存在的行的更新),然后才接收相应的插入(应该在更新之前)。 -这是一个因果关系问题,类似于我们在 ["一致前缀读"](/ch6#sec_replication_consistent_prefix) 中看到的问题:更新依赖于先前的插入,因此我们需要确保所有节点首先处理插入,然后处理更新。简单地为每个写入附加时间戳是不够的,因为时钟不能被信任足够同步以在主节点 2 上正确排序这些事件(见 [第 9 章](/ch9#ch_distributed))。 +这是一个因果关系问题,类似于我们在 ["一致前缀读"](/ch6#sec_replication_consistent_prefix) 中看到的问题:更新依赖于先前的插入,因此我们需要确保所有节点首先处理插入,然后处理更新。简单地为每个写入附加时间戳是不够的,因为时钟不能被信任足够同步以在领导者 2 上正确排序这些事件(见 [第 9 章](/ch9#ch_distributed))。 为了正确排序这些事件,可以使用一种称为 **版本向量** 的技术,我们将在本章后面讨论(见 ["检测并发写入"](/ch6#sec_replication_concurrent))。然而,许多多主复制系统不使用良好的技术来排序更新,使它们容易受到像 [图 6-8](/ch6#fig_replication_causality) 中的问题的影响。如果你使用多主复制,值得了解这些问题,仔细阅读文档,并彻底测试你的数据库,以确保它真正提供你认为它具有的保证。 @@ -396,7 +396,7 @@ Poons 先生 例如,考虑你的手机、笔记本电脑和其他设备上的日历应用程序。你需要能够随时查看你的会议(进行读取请求)并输入新会议(进行写入请求),无论你的设备当前是否有互联网连接。如果你在离线时进行任何更改,它们需要在设备下次上线时与服务器和你的其他设备同步。 -在这种情况下,每个设备都有一个充当主节点的本地数据库副本(它接受写入请求),并且在你所有设备上的日历副本之间有一个异步多主复制过程(同步)。复制延迟可能是几小时甚至几天,具体取决于你何时有可用的互联网访问。 +在这种情况下,每个设备都拥有一个充当领导者的本地数据库副本(可接受写入),并在你所有设备上的日历副本之间运行异步多主复制流程(即同步过程)。复制延迟可能是几小时甚至几天,具体取决于你何时能连上互联网。 从架构的角度来看,这种设置与地区之间的多主复制非常相似,达到了极端:每个设备是一个"地区",它们之间的网络连接极其不可靠。 @@ -428,37 +428,37 @@ Poons 先生 ### 处理写入冲突 {#sec_replication_write_conflicts} -多主复制的最大问题——无论是在地域分布式服务器端数据库中还是在终端用户设备上的本地优先同步引擎中——是不同主节点上的并发写入可能导致需要解决的冲突。 +多主复制的最大问题——无论是在地域分布式服务器端数据库中还是在终端用户设备上的本地优先同步引擎中——是不同领导者上的并发写入可能导致需要解决的冲突。 -例如,考虑一个维基页面同时被两个用户编辑,如 [图 6-9](/ch6#fig_replication_write_conflict) 所示。用户 1 将页面标题从 A 更改为 B,用户 2 独立地将标题从 A 更改为 C。每个用户的更改成功应用于其本地主节点。然而,当更改异步复制时,检测到冲突。这个问题在单主数据库中不会发生。 +例如,考虑一个维基页面同时被两个用户编辑,如 [图 6-9](/ch6#fig_replication_write_conflict) 所示。用户 1 将页面标题从 A 更改为 B,用户 2 独立地将标题从 A 更改为 C。每个用户的更改成功应用于其本地领导者。然而,当更改异步复制时,检测到冲突。这个问题在单主数据库中不会发生。 -{{< figure src="/fig/ddia_0609.png" id="fig_replication_write_conflict" caption="图 6-9. 两个主节点并发更新同一记录导致的写入冲突。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0609.png" id="fig_replication_write_conflict" caption="图 6-9. 两个领导者并发更新同一记录导致的写入冲突。" class="w-full my-4" >}} > [!NOTE] -> 我们说 [图 6-9](/ch6#fig_replication_write_conflict) 中的两个写入是 **并发的**,因为在最初进行写入时,两者都不"知道"另一个。写入是否真的在同一时间发生并不重要;实际上,如果写入是在离线时进行的,它们实际上可能相隔一段时间。重要的是一个写入是否发生在另一个写入已经生效的状态下。 +> 我们说 [图 6-9](/ch6#fig_replication_write_conflict) 中的两个写入是 **并发的**,因为在最初进行写入时,两者都不“知道”对方。写入是否真的在同一时刻发生并不重要;实际上,如果写入发生在离线状态,它们在物理时间上可能相隔很久。关键在于:一个写入是否发生在另一个写入已经生效的状态之上。 在 ["检测并发写入"](/ch6#sec_replication_concurrent) 中,我们将解决数据库如何确定两个写入是否并发的问题。现在我们假设我们可以检测冲突,并且我们想找出解决它们的最佳方法。 #### 冲突避免 {#conflict-avoidance} -冲突的一种策略是首先避免它们发生。例如,如果应用程序可以确保特定记录的所有写入都通过同一主节点,那么即使整个数据库是多主的,也不会发生冲突。这种方法在同步引擎客户端离线更新的情况下是不可能的,但在地域复制的服务器系统中有时是可能的 [^30]。 +冲突的一种策略是首先避免它们发生。例如,如果应用程序可以确保特定记录的所有写入都通过同一领导者,那么即使整个数据库是多主的,也不会发生冲突。这种方法在同步引擎客户端离线更新的情况下是不可能的,但在地域复制的服务器系统中有时是可能的 [^30]。 -例如,在一个用户只能编辑自己数据的应用程序中,你可以确保来自特定用户的请求始终路由到同一地区,并使用该地区的主节点进行读写。不同的用户可能有不同的"主"地区(可能基于与用户的地理接近程度选择),但从任何一个用户的角度来看,配置本质上是单主的。 +例如,在一个用户只能编辑自己数据的应用程序中,你可以确保来自特定用户的请求始终路由到同一地区,并使用该地区的领导者进行读写。不同的用户可能有不同的"主"地区(可能基于与用户的地理接近程度选择),但从任何一个用户的角度来看,配置本质上是单主的。 -然而,有时你可能想要更改记录的指定主节点——也许是因为一个地区不可用,你需要将流量重新路由到另一个地区,或者也许是因为用户已经移动到不同的位置,现在更接近不同的地区。现在存在风险,即用户在指定主节点更改正在进行时执行写入,导致必须使用下面的方法之一解决的冲突。因此,如果你允许更改主节点,冲突避免就会失效。 +然而,有时你可能想要更改记录的指定领导者——也许是因为一个地区不可用,你需要将流量重新路由到另一个地区,或者也许是因为用户已经移动到不同的位置,现在更接近不同的地区。现在存在风险,即用户在指定领导者更改正在进行时执行写入,导致必须使用下面的方法之一解决的冲突。因此,如果你允许更改领导者,冲突避免就会失效。 -冲突避免的另一个例子:想象你想要插入新记录并基于自增计数器为它们生成唯一 ID。如果你有两个主节点,你可以设置它们,使得一个主节点只生成奇数,另一个只生成偶数。这样你可以确保两个主节点不会同时为不同的记录分配相同的 ID。我们将在 ["ID 生成器和逻辑时钟"](/ch10#sec_consistency_logical) 中讨论其他 ID 分配方案。 +冲突避免的另一个例子:想象你想要插入新记录并基于自增计数器为它们生成唯一 ID。如果你有两个领导者,你可以设置它们,使得一个领导者只生成奇数,另一个只生成偶数。这样你可以确保两个领导者不会同时为不同的记录分配相同的 ID。我们将在 ["ID 生成器和逻辑时钟"](/ch10#sec_consistency_logical) 中讨论其他 ID 分配方案。 -#### 最后写入者胜(丢弃并发写入) {#sec_replication_lww} +#### 最后写入胜利(丢弃并发写入) {#sec_replication_lww} -如果无法避免冲突,解决它们的最简单方法是为每个写入附加时间戳,并始终使用具有最大时间戳的值。例如,在 [图 6-9](/ch6#fig_replication_write_conflict) 中,假设用户 1 的写入时间戳大于用户 2 的写入时间戳。在这种情况下,两个主节点都将确定页面的新标题应该是 B,并丢弃将其设置为 C 的写入。如果写入巧合地具有相同的时间戳,可以通过比较值来选择获胜者(例如,在字符串的情况下,取字母表中较早的那个)。 +如果无法避免冲突,解决它们的最简单方法是为每个写入附加时间戳,并始终使用具有最大时间戳的值。例如,在 [图 6-9](/ch6#fig_replication_write_conflict) 中,假设用户 1 的写入时间戳大于用户 2 的写入时间戳。在这种情况下,两个领导者都将确定页面的新标题应该是 B,并丢弃将其设置为 C 的写入。如果写入巧合地具有相同的时间戳,可以通过比较值来选择获胜者(例如,在字符串的情况下,取字母表中较早的那个)。 -这种方法称为 **最后写入者胜**(LWW),因为具有最大时间戳的写入可以被认为是"最后"的。然而,这个术语是误导性的,因为当两个写入像 [图 6-9](/ch6#fig_replication_write_conflict) 中那样并发时,哪个更旧,哪个更新是未定义的,因此并发写入的时间戳顺序本质上是随机的。 +这种方法称为 **最后写入胜利**(LWW),因为具有最大时间戳的写入可以被认为是"最后"的。然而,这个术语是误导性的,因为当两个写入像 [图 6-9](/ch6#fig_replication_write_conflict) 中那样并发时,哪个更旧,哪个更新是未定义的,因此并发写入的时间戳顺序本质上是随机的。 -因此,LWW 的真正含义是:当同一记录在不同的主节点上并发写入时,其中一个写入被随机选择为获胜者,其他写入被静默丢弃,即使它们在各自的主节点上成功处理。这实现了最终所有副本都处于一致状态的目标,但代价是数据丢失。 +因此,LWW 的真正含义是:当同一记录在不同的领导者上并发写入时,其中一个写入被随机选择为获胜者,其他写入被静默丢弃,即使它们在各自的领导者上成功处理。这实现了最终所有副本都处于一致状态的目标,但代价是数据丢失。 -如果你可以避免冲突——例如,通过只插入具有唯一键(如 UUID)的记录,而从不更新它们——那么 LWW 没有问题。但是,如果你更新现有记录,或者如果不同的主节点可能插入具有相同键的记录,那么你必须决定丢失的更新对你的应用程序是否是个问题。如果丢失的更新是不可接受的,你需要使用下面描述的冲突解决方法之一。 +如果你可以避免冲突——例如,通过只插入具有唯一键(如 UUID)的记录,而从不更新它们——那么 LWW 没有问题。但是,如果你更新现有记录,或者如果不同的领导者可能插入具有相同键的记录,那么你必须决定丢失的更新对你的应用程序是否是个问题。如果丢失的更新是不可接受的,你需要使用下面描述的冲突解决方法之一。 LWW 的另一个问题是,如果使用实时时钟(例如 Unix 时间戳)作为写入的时间戳,系统对时钟同步变得非常敏感。如果一个节点的时钟领先于其他节点,并且你尝试覆盖该节点写入的值,你的写入可能会被忽略,因为它可能具有较低的时间戳,即使它明显发生得更晚。这个问题可以通过使用 **逻辑时钟** 来解决,我们将在 ["ID 生成器和逻辑时钟"](/ch10#sec_consistency_logical) 中讨论。 @@ -466,13 +466,13 @@ LWW 的另一个问题是,如果使用实时时钟(例如 Unix 时间戳) 如果随机丢弃你的一些写入是不可取的,下一个选择是手动解决冲突。你可能熟悉 Git 和其他版本控制系统中的手动冲突解决:如果两个不同分支上的提交编辑同一文件的相同行,并且你尝试合并这些分支,你将得到一个需要在合并完成之前解决的合并冲突。 -在数据库中,冲突停止整个复制过程直到人类解决它是不切实际的。相反,数据库通常存储给定记录的所有并发写入值——例如,[图 6-9](/ch6#fig_replication_write_conflict) 中的 B 和 C。这些值有时称为 **兄弟节点**。下次查询该记录时,数据库返回 **所有** 这些值,而不仅仅是最新的值。然后,你可以以任何你想要的方式解决这些值,无论是在应用程序代码中自动(例如,你可以将 B 和 C 连接成"B/C"),还是通过询问用户。然后,你将新值写回数据库以解决冲突。 +在数据库里,让冲突阻塞整个复制流程、直到人工处理,通常并不现实。更常见的是,数据库会保留某条记录的所有并发写入值——例如 [图 6-9](/ch6#fig_replication_write_conflict) 中的 B 和 C。这些值有时称为 **兄弟**。下次查询该记录时,数据库会返回 **所有** 这些值,而不只是最新值。随后你可以按需要解决这些值:要么在应用代码里自动处理(例如把 B 和 C 合并成 "B/C"),要么让用户参与处理;最后再把新值写回数据库以消解冲突。 这种冲突解决方法在某些系统中使用,例如 CouchDB。然而,它也存在许多问题: * 数据库的 API 发生变化:例如,以前维基页面的标题只是一个字符串,现在它变成了一组字符串,通常包含一个元素,但如果有冲突,有时可能包含多个元素。这可能使应用程序代码中的数据难以处理。 -* 要求用户手动合并兄弟节点是很多工作,无论是对应用程序开发人员(需要构建冲突解决的用户界面)还是对用户(可能对他们被要求做什么以及为什么感到困惑)。在许多情况下,自动合并比打扰用户更好。 -* 如果不仔细进行,自动合并兄弟节点可能会导致令人惊讶的行为。例如,亚马逊的购物车曾经允许并发更新,然后通过保留出现在任何兄弟节点中的所有购物车项目(即,取购物车的集合并集)来合并。这意味着如果客户在一个兄弟节点中从购物车中删除了一个项目,但另一个兄弟节点仍然包含该旧项目,删除的项目会意外地重新出现在客户的购物车中 [^45]。[图 6-10](/ch6#fig_replication_amazon_anomaly) 显示了一个示例,其中设备 1 从购物车中删除 Book,并发地设备 2 删除 DVD,但合并冲突后两个项目都重新出现。 +* 要求用户手动合并兄弟,会带来很大负担:开发者需要构建冲突解决界面,用户也可能不明白自己为何要做这件事。在很多场景下,自动合并比打扰用户更合适。 +* 如果不够谨慎,自动合并兄弟也可能产生反直觉行为。例如,亚马逊购物车曾允许并发更新,并用“并集”策略合并(保留出现在任一兄弟中的所有商品)。这意味着:若用户在一个兄弟里删除了某商品,但另一个兄弟仍保留它,该商品会“复活”回购物车 [^45]。[图 6-10](/ch6#fig_replication_amazon_anomaly) 就是一个例子:设备 1 删除 Book,设备 2 并发删除 DVD,冲突合并后两个商品都回来了。 * 如果多个节点观察到冲突并并发解决它,冲突解决过程本身可能会引入新的冲突。这些解决方案甚至可能不一致:例如,如果你不小心一致地排序它们,一个节点可能将 B 和 C 合并为"B/C",另一个可能将它们合并为"C/B"。当"B/C"和"C/B"之间的冲突被合并时,它可能导致"B/C/C/B"或类似令人惊讶的东西。 {{< figure src="/fig/ddia_0610.png" id="fig_replication_amazon_anomaly" caption="图 6-10. 亚马逊购物车异常的示例:如果购物车上的冲突通过取并集合并,删除的项目可能会重新出现。" class="w-full my-4" >}} @@ -484,9 +484,9 @@ LWW 的另一个问题是,如果使用实时时钟(例如 Unix 时间戳) LWW 是冲突解决算法的一个简单示例。已经为不同类型的数据开发了更复杂的合并算法,目标是尽可能保留所有更新的预期效果,从而避免数据丢失: -* 如果数据是文本(例如,维基页面的标题或正文),我们可以检测从一个版本到下一个版本插入或删除了哪些字符。合并的结果然后保留在任何兄弟节点中进行的所有插入和删除。如果用户并发地在同一位置插入文本,可以确定性地排序,以便所有节点获得相同的合并结果。 +* 如果数据是文本(例如维基页面标题或正文),我们可以检测每次版本演进中的字符插入和删除。合并结果会保留任一兄弟中的所有插入和删除。如果多个用户并发在同一位置插入文本,还可以用确定性顺序来排序,以确保所有节点得到同样的合并结果。 * 如果数据是项目集合(像待办事项列表那样有序,或像购物车那样无序),我们可以通过跟踪插入和删除类似于文本来合并它。为了避免 [图 6-10](/ch6#fig_replication_amazon_anomaly) 中的购物车问题,算法跟踪 Book 和 DVD 被删除的事实,因此合并的结果是 Cart = {Soap}。 -* 如果数据是表示可以递增或递减的计数器的整数(例如,社交媒体帖子上的点赞数),合并算法可以告诉每个兄弟节点上发生了多少次递增和递减,并正确地将它们相加,以便结果不会重复计数也不会丢弃更新。 +* 如果数据是可增可减的整数计数器(例如社交媒体帖子的点赞数),合并算法可以统计每个兄弟上的递增和递减次数,并正确求和,既不重复计数,也不丢更新。 * 如果数据是键值映射,我们可以通过将其他冲突解决算法之一应用于该键下的值来合并对同一键的更新。对不同键的更新可以相互独立处理。 冲突解决的可能性是有限的。例如,如果你想强制一个列表不包含超过五个项目,并且多个用户并发地向列表添加项目,使得总共有五个以上,你唯一的选择是丢弃一些项目。尽管如此,自动冲突解决足以构建许多有用的应用程序。如果你从想要构建协作离线优先或本地优先应用程序的要求开始,那么冲突解决是不可避免的,自动化它通常是最好的方法。 @@ -515,16 +515,16 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], 某些类型的冲突是显而易见的。在 [图 6-9](/ch6#fig_replication_write_conflict) 的示例中,两个写入并发修改了同一记录中的同一字段,将其设置为两个不同的值。毫无疑问,这是一个冲突。 -其他类型的冲突可能更难以检测。例如,考虑一个会议室预订系统:它跟踪哪个房间由哪组人在什么时间预订。此应用程序需要确保每个房间在任何时间只由一组人预订(即,同一房间不得有任何重叠的预订)。在这种情况下,如果为同一房间同时创建两个不同的预订,可能会出现冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两个预订是在两个不同的主节点上进行的,也可能会发生冲突。 +其他类型的冲突可能更难以检测。例如,考虑一个会议室预订系统:它跟踪哪个房间由哪组人在什么时间预订。此应用程序需要确保每个房间在任何时间只由一组人预订(即,同一房间不得有任何重叠的预订)。在这种情况下,如果为同一房间同时创建两个不同的预订,可能会出现冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两个预订是在两个不同的领导者上进行的,也可能会发生冲突。 -没有快速现成的答案,但在以下章节中,我们将追踪通向对这个问题的良好理解的路径。我们将在 [第 8 章](/ch8#ch_transactions) 中看到更多冲突的例子,并在 [Link to Come] 中讨论在复制系统中检测和解决冲突的可伸缩方法。 +没有现成的快速答案,不过在后续章节中,我们会逐步建立对这个问题的理解。我们将在 [第 8 章](/ch8#ch_transactions) 看到更多冲突案例,并在 ["通过事件顺序捕获因果关系"](/ch13#sec_future_capture_causality) 中讨论在复制系统里可伸缩地检测和解决冲突的方法。 ## 无主复制 {#sec_replication_leaderless} -到目前为止,我们在本章中讨论的复制方法——单主和多主复制——都基于这样的想法:客户端向一个节点(主节点)发送写入请求,数据库系统负责将该写入复制到其他副本。主节点确定写入应该处理的顺序,从节点以相同的顺序应用主节点的写入。 +到目前为止,我们在本章中讨论的复制方法——单主和多主复制——都基于这样的想法:客户端向一个节点(领导者)发送写入请求,数据库系统负责将该写入复制到其他副本。领导者确定写入应该处理的顺序,追随者以相同的顺序应用领导者的写入。 -一些数据存储系统采用不同的方法,放弃主节点的概念,并允许任何副本直接接受来自客户端的写入。一些最早的复制数据系统是无主的 [^1] [^50],但在关系数据库主导的时代,这个想法基本上被遗忘了。在亚马逊于 2007 年将其用于其内部 **Dynamo** 系统后,它再次成为数据库的时尚架构 [^45]。Riak、Cassandra 和 ScyllaDB 是受 Dynamo 启发的具有无主复制模型的开源数据存储,因此这种数据库也被称为 **Dynamo 风格**。 +一些数据存储系统采用不同的方法,放弃领导者的概念,并允许任何副本直接接受来自客户端的写入。一些最早的复制数据系统是无主的 [^1] [^50],但在关系数据库主导的时代,这个想法基本上被遗忘了。在亚马逊于 2007 年将其用于其内部 **Dynamo** 系统后,它再次成为数据库的时尚架构 [^45]。Riak、Cassandra 和 ScyllaDB 是受 Dynamo 启发的具有无主复制模型的开源数据存储,因此这种数据库也被称为 **Dynamo 风格**。 -------- @@ -533,7 +533,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], -------- -在某些无主实现中,客户端直接将其写入发送到多个副本,而在其他实现中,协调器节点代表客户端执行此操作。然而,与主节点数据库不同,该协调器不强制执行特定的写入顺序。正如我们将看到的,这种设计差异对数据库的使用方式产生了深远的影响。 +在某些无主实现中,客户端直接将其写入发送到多个副本,而在其他实现中,协调器节点代表客户端执行此操作。然而,与领导者数据库不同,该协调器不强制执行特定的写入顺序。正如我们将看到的,这种设计差异对数据库的使用方式产生了深远的影响。 ### 当节点故障时写入数据库 {#id287} @@ -548,20 +548,20 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], 为了解决这个问题,当客户端从数据库读取时,它不只是将其请求发送到一个副本:**读取请求也并行发送到多个节点**。客户端可能会从不同的节点获得不同的响应;例如,从一个节点获得最新值,从另一个节点获得陈旧值。 -为了区分哪些响应是最新的,哪些是过时的,写入的每个值都需要用版本号或时间戳标记,类似于我们在 ["最后写入者胜(丢弃并发写入)"](/ch6#sec_replication_lww) 中看到的。当客户端收到对读取的多个值响应时,它使用具有最大时间戳的值(即使该值仅由一个副本返回,而其他几个副本返回较旧的值)。有关更多详细信息,请参见 ["检测并发写入"](/ch6#sec_replication_concurrent)。 +为了区分哪些响应是最新的,哪些是过时的,写入的每个值都需要用版本号或时间戳标记,类似于我们在 ["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww) 中看到的。当客户端收到对读取的多个值响应时,它使用具有最大时间戳的值(即使该值仅由一个副本返回,而其他几个副本返回较旧的值)。有关更多详细信息,请参见 ["检测并发写入"](/ch6#sec_replication_concurrent)。 #### 追赶错过的写入 {#sec_replication_read_repair} 复制系统应确保最终所有数据都复制到每个副本。在不可用节点恢复上线后,它如何赶上它错过的写入?在 Dynamo 风格的数据存储中使用了几种机制: 读修复 -: 当客户端并行从多个节点进行读取时,它可以检测任何陈旧响应。例如,在 [图 6-12](/ch6#fig_replication_quorum_node_outage) 中,用户 2345 从副本 3 获得版本 6 值,从副本 1 和 2 获得版本 7 值。客户端看到副本 3 有陈旧值,并将较新的值写回该副本。这种方法适用于经常读取的值。 +: 当客户端并行从多个节点读取时,它可以检测任何陈旧响应。例如,在 [图 6-12](/ch6#fig_replication_quorum_node_outage) 中,用户 2345 从副本 3 获得版本 6 的值,从副本 1 和 2 获得版本 7 的值。客户端发现副本 3 陈旧后,会把较新的值写回该副本。这种方法适用于经常被读取的值。 提示移交 : 如果一个副本不可用,另一个副本可能会以 **提示** 的形式代表其存储写入。当应该接收这些写入的副本恢复时,存储提示的副本将它们发送到恢复的副本,然后删除提示。这个 **移交** 过程有助于使副本保持最新,即使对于从未读取的值也是如此,因此不由读修复处理。 反熵 -: 此外,还有一个后台进程定期查找副本之间数据的差异,并将任何缺失的数据从一个副本复制到另一个。与基于主节点的复制中的复制日志不同,这个 **反熵进程** 不以任何特定顺序复制写入,并且在复制数据之前可能会有显著的延迟。 +: 此外,还有一个后台进程定期查找副本之间数据的差异,并将任何缺失的数据从一个副本复制到另一个。与基于领导者的复制中的复制日志不同,这个 **反熵进程** 不以任何特定顺序复制写入,并且在复制数据之前可能会有显著的延迟。 #### 读写仲裁 {#sec_replication_quorum_condition} @@ -610,7 +610,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], * 在重新平衡正在进行时,其中一些数据从一个节点移动到另一个节点(见 [第 7 章](/ch7#ch_sharding)),节点可能对哪些节点应该持有特定值的 *n* 个副本有不一致的视图。这可能导致读取和写入仲裁不再重叠。 * 如果读取与写入操作并发,读取可能会或可能不会看到并发写入的值。特别是,一次读取可能看到新值,而后续读取看到旧值,正如我们将在 ["线性一致性与仲裁"](/ch10#sec_consistency_quorum_linearizable) 中看到的。 * 如果写入在某些副本上成功但在其他副本上失败(例如,因为某些节点上的磁盘已满),并且总体上在少于 *w* 个副本上成功,它不会在成功的副本上回滚。这意味着如果写入被报告为失败,后续读取可能会或可能不会返回该写入的值 [^52]。 -* 如果数据库使用实时时钟的时间戳来确定哪个写入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一个具有更快时钟的节点已写入同一键,写入可能会被静默丢弃——我们之前在 ["最后写入者胜(丢弃并发写入)"](/ch6#sec_replication_lww) 中看到的问题。我们将在 ["依赖同步时钟"](/ch9#sec_distributed_clocks_relying) 中更详细地讨论这一点。 +* 如果数据库使用实时时钟的时间戳来确定哪个写入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一个具有更快时钟的节点已写入同一键,写入可能会被静默丢弃——我们之前在 ["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww) 中看到的问题。我们将在 ["依赖同步时钟"](/ch9#sec_distributed_clocks_relying) 中更详细地讨论这一点。 * 如果两个写入并发发生,其中一个可能首先在一个副本上处理,另一个可能首先在另一个副本上处理。这导致冲突,类似于我们在多主复制中看到的(见 ["处理写入冲突"](/ch6#sec_replication_write_conflicts))。我们将在 ["检测并发写入"](/ch6#sec_replication_concurrent) 中回到这个主题。 因此,尽管仲裁似乎保证读取返回最新写入的值,但实际上并不那么简单。Dynamo 风格的数据库通常针对可以容忍最终一致性的用例进行了优化。参数 *w* 和 *r* 允许你调整读取陈旧值的概率 [^53],但明智的做法是不要将它们视为绝对保证。 @@ -619,24 +619,24 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], 从操作角度来看,监控你的数据库是否返回最新结果很重要。即使你的应用程序可以容忍陈旧读取,你也需要了解复制的健康状况。如果它明显落后,它应该提醒你,以便你可以调查原因(例如,网络中的问题或过载的节点)。 -对于基于主节点的复制,数据库通常公开复制延迟的指标,你可以将其输入到监控系统。这是可能的,因为写入以相同的顺序应用于主节点和从节点,每个节点在复制日志中都有一个位置(它在本地应用的写入数)。通过从主节点的当前位置减去从节点的当前位置,你可以测量复制延迟的量。 +对于基于领导者的复制,数据库通常公开复制延迟的指标,你可以将其输入到监控系统。这是可能的,因为写入以相同的顺序应用于领导者和追随者,每个节点在复制日志中都有一个位置(它在本地应用的写入数)。通过从领导者的当前位置减去追随者的当前位置,你可以测量复制延迟的量。 然而,在具有无主复制的系统中,没有固定的写入应用顺序,这使得监控更加困难。副本为移交存储的提示数量可以是系统健康的一个度量,但很难有用地解释 [^54]。最终一致性是一个故意模糊的保证,但为了可操作性,能够量化"最终"很重要。 ### 单主与无主复制的性能 {#sec_replication_leaderless_perf} -基于单个主节点的复制系统可以提供在无主系统中难以或不可能实现的强一致性保证。然而,正如我们在 ["复制延迟的问题"](/ch6#sec_replication_lag) 中看到的,如果你在异步更新的从节点上进行读取,基于主节点的复制系统中的读取也可能返回陈旧值。 +基于单个领导者的复制系统可以提供在无主系统中难以或不可能实现的强一致性保证。然而,正如我们在 ["复制延迟的问题"](/ch6#sec_replication_lag) 中看到的,如果你在异步更新的追随者上进行读取,基于领导者的复制系统中的读取也可能返回陈旧值。 -从主节点读取确保最新响应,但它存在性能问题: +从领导者读取确保最新响应,但它存在性能问题: -* 读取吞吐量受主节点处理请求能力的限制(与读扩展相反,读扩展将读取分布在可能返回陈旧值的异步更新副本上)。 -* 如果主节点失败,你必须等待检测到故障,并在继续处理请求之前完成故障转移。即使故障转移过程非常快,用户也会因为临时增加的响应时间而注意到它;如果故障转移需要很长时间,系统在其持续时间内不可用。 -* 系统对主节点上的性能问题非常敏感:如果主节点响应缓慢,例如由于过载或某些资源争用,增加的响应时间也会立即影响用户。 +* 读取吞吐量受领导者处理请求能力的限制(与读扩展相反,读扩展将读取分布在可能返回陈旧值的异步更新副本上)。 +* 如果领导者失败,你必须等待检测到故障,并在继续处理请求之前完成故障转移。即使故障转移过程非常快,用户也会因为临时增加的响应时间而注意到它;如果故障转移需要很长时间,系统在其持续时间内不可用。 +* 系统对领导者上的性能问题非常敏感:如果领导者响应缓慢,例如由于过载或某些资源争用,增加的响应时间也会立即影响用户。 -无主架构的一大优势是它对此类问题更有弹性。因为没有故障转移,并且请求无论如何都并行发送到多个副本,一个副本变慢或不可用对响应时间的影响很小:客户端只是使用响应更快的其他副本的响应。使用最快的响应称为 **请求对冲**,它可以显著减少尾部延迟 [^55])。 +无主架构的一大优势是它对此类问题更有弹性。因为没有故障转移,而且请求本来就是并行发往多个副本,所以某个副本变慢或不可用对响应时间影响较小:客户端只需采用更快副本的响应即可。利用最快响应的做法称为 **请求对冲**,它可以显著降低尾部延迟 [^55]。 -从根本上说,无主系统的弹性来自于它不区分正常情况和故障情况的事实。这在处理所谓的 **灰色故障** 时特别有用,其中节点没有完全宕机,但以降级状态运行,处理请求异常缓慢 [^56],或者当节点只是过载时(例如,如果节点已离线一段时间,通过提示移交恢复可能会导致大量额外负载)。基于主节点的系统必须决定情况是否足够糟糕以保证故障转移(这本身可能会导致进一步的中断),而在无主系统中,这个问题甚至不会出现。 +从根本上说,无主系统的弹性来自于它不区分正常情况和故障情况的事实。这在处理所谓的 **灰色故障** 时特别有用,其中节点没有完全宕机,但以降级状态运行,处理请求异常缓慢 [^56],或者当节点只是过载时(例如,如果节点已离线一段时间,通过提示移交恢复可能会导致大量额外负载)。基于领导者的系统必须决定情况是否足够糟糕以保证故障转移(这本身可能会导致进一步的中断),而在无主系统中,这个问题甚至不会出现。 也就是说,无主系统也可能有性能问题: @@ -644,7 +644,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], * 你拥有的副本越多,你的仲裁就越大,在请求完成之前你必须等待的响应就越多。即使你只等待最快的 *r* 或 *w* 个副本响应,即使你并行发出请求,更大的 *r* 或 *w* 增加了你遇到慢副本的机会,增加了总体响应时间(见 ["响应时间指标的应用"](/ch2#sec_introduction_slo_sla))。 * 大规模网络中断使客户端与大量副本断开连接,可能使形成仲裁变得不可能。一些无主数据库提供了一个配置选项,允许任何可访问的副本接受写入,即使它不是该键的通常副本之一(Riak 和 Dynamo 称之为 **宽松仲裁** [^45];Cassandra 和 ScyllaDB 称之为 **一致性级别 ANY**)。不能保证后续读取会看到写入的值,但根据应用程序,它可能仍然比写入失败更好。 -多主复制可以提供比无主复制更大的网络中断弹性,因为读取和写入只需要与一个主节点通信,该主节点可以与客户端位于同一位置。然而,由于一个主节点上的写入异步传播到其他主节点,读取可能任意过时。仲裁读取和写入提供了一种折衷:良好的容错性,同时也有很高的可能性读取最新数据。 +多主复制可以提供比无主复制更大的网络中断弹性,因为读取和写入只需要与一个领导者通信,该领导者可以与客户端位于同一位置。然而,由于一个领导者上的写入异步传播到其他领导者,读取可能任意过时。仲裁读取和写入提供了一种折衷:良好的容错性,同时也有很高的可能性读取最新数据。 #### 多地区操作 {#multi-region-operation} @@ -669,9 +669,9 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本 如果每个节点在接收到来自客户端的写入请求时只是覆盖键的值,节点将变得永久不一致,如 [图 6-14](/ch6#fig_replication_concurrency) 中的最终 *get* 请求所示:节点 2 认为 *X* 的最终值是 B,而其他节点认为值是 A。 -为了最终保持一致,副本应该收敛到相同的值。为此,我们可以使用我们之前在 ["处理写入冲突"](/ch6#sec_replication_write_conflicts) 中讨论的任何冲突解决机制,例如最后写入者胜(由 Cassandra 和 ScyllaDB 使用)、手动解决或 CRDT(在 ["CRDT 与操作变换"](/ch6#sec_replication_crdts) 中描述,并由 Riak 使用)。 +为了最终保持一致,副本应该收敛到相同的值。为此,我们可以使用我们之前在 ["处理写入冲突"](/ch6#sec_replication_write_conflicts) 中讨论的任何冲突解决机制,例如最后写入胜利(由 Cassandra 和 ScyllaDB 使用)、手动解决或 CRDT(在 ["CRDT 与操作变换"](/ch6#sec_replication_crdts) 中描述,并由 Riak 使用)。 -最后写入者胜很容易实现:每个写入都标有时间戳,具有更高时间戳的值总是覆盖具有较低时间戳的值。然而,时间戳不会告诉你两个值是否实际上冲突(即,它们是并发写入的)或不冲突(它们是一个接一个写入的)。如果你想显式解决冲突,系统需要更加小心地检测并发写入。 +最后写入胜利很容易实现:每个写入都标有时间戳,具有更高时间戳的值总是覆盖具有较低时间戳的值。然而,时间戳不会告诉你两个值是否实际上冲突(即,它们是并发写入的)或不冲突(它们是一个接一个写入的)。如果你想显式解决冲突,系统需要更加小心地检测并发写入。 #### "先发生"关系与并发 {#sec_replication_happens_before} @@ -686,7 +686,7 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本 -------- -> ![TIP] 并发、时间和相对论 +> [!TIP] 并发、时间和相对论 > > 似乎两个操作如果"同时"发生,应该称为并发——但实际上,它们是否真的在时间上重叠并不重要。由于分布式系统中的时钟问题,实际上很难判断两件事是否恰好在同一时间发生——我们将在 [第 9 章](/ch9#ch_distributed) 中更详细地讨论这个问题。 > @@ -700,10 +700,10 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本 让我们看一个确定两个操作是否并发或一个先发生于另一个的算法。为了简单起见,让我们从只有一个副本的数据库开始。一旦我们弄清楚如何在单个副本上执行此操作,我们就可以将该方法推广到具有多个副本的无主数据库。 -[图 6-15](/ch6#fig_replication_causality_single) 显示了两个客户端并发地向同一购物车添加项目。(如果这个例子让你觉得太无聊,想象一下两个空中交通管制员并发地向他们正在跟踪的扇区添加飞机。)最初,购物车是空的。客户端之间向数据库进行了五次写入: +[图 6-15](/ch6#fig_replication_causality_single) 显示了两个客户端并发地向同一购物车添加项目。(如果这个例子让你觉得太无聊,想象一下两个空中交通管制员并发地向他们正在跟踪的扇区添加飞机。)最初,购物车是空的。两个客户端总共向数据库发起了五次写入: 1. 客户端 1 将 `milk` 添加到购物车。这是对该键的第一次写入,因此服务器成功存储它并为其分配版本 1。服务器还将值连同版本号一起回显给客户端。 -2. 客户端 2 将 `eggs` 添加到购物车,不知道客户端 1 并发地添加了 `milk`(客户端 2 认为它的 `eggs` 是购物车中的唯一项目)。服务器为此写入分配版本 2,并将 `eggs` 和 `milk` 存储为两个单独的值(兄弟节点)。然后,它将 **两个** 值连同版本号 2 一起返回给客户端。 +2. 客户端 2 将 `eggs` 添加到购物车,不知道客户端 1 并发地添加了 `milk`(客户端 2 认为它的 `eggs` 是购物车中的唯一项目)。服务器为此写入分配版本 2,并将 `eggs` 和 `milk` 存储为两个单独的值(兄弟)。然后,它将 **两个** 值连同版本号 2 一起返回给客户端。 3. 客户端 1,不知道客户端 2 的写入,想要将 `flour` 添加到购物车,因此它认为当前购物车内容应该是 `[milk, flour]`。它将此值连同服务器之前给客户端 1 的版本号 1 一起发送到服务器。服务器可以从版本号判断 `[milk, flour]` 的写入取代了 `[milk]` 的先前值,但它与 `[eggs]` 并发。因此,服务器将版本 3 分配给 `[milk, flour]`,覆盖版本 1 值 `[milk]`,但保留版本 2 值 `[eggs]` 并将两个剩余值返回给客户端。 4. 同时,客户端 2 想要将 `ham` 添加到购物车,不知道客户端 1 刚刚添加了 `flour`。客户端 2 在上次响应中从服务器接收了两个值 `[milk]` 和 `[eggs]`,因此客户端现在合并这些值并添加 `ham` 以形成新值 `[eggs, milk, ham]`。它将该值连同先前的版本号 2 一起发送到服务器。服务器检测到版本 2 覆盖 `[eggs]` 但与 `[milk, flour]` 并发,因此两个剩余值是版本 3 的 `[milk, flour]` 和版本 4 的 `[eggs, milk, ham]`。 5. 最后,客户端 1 想要添加 `bacon`。它之前从服务器接收了版本 3 的 `[milk, flour]` 和 `[eggs]`,因此它合并这些,添加 `bacon`,并将最终值 `[milk, flour, eggs, bacon]` 连同版本号 3 一起发送到服务器。这覆盖了 `[milk, flour]`(注意 `[eggs]` 已经在上一步中被覆盖)但与 `[eggs, milk, ham]` 并发,因此服务器保留这两个并发值。 @@ -719,23 +719,23 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本 请注意,服务器可以通过查看版本号来确定两个操作是否并发——它不需要解释值本身(因此值可以是任何数据结构)。算法的工作原理如下: * 服务器为每个键维护一个版本号,每次写入该键时递增版本号,并将新版本号与写入的值一起存储。 -* 当客户端读取键时,服务器返回所有兄弟节点,即所有未被覆盖的值,以及最新的版本号。客户端必须在写入之前读取键。 -* 当客户端写入键时,它必须包含来自先前读取的版本号,并且必须合并它在先前读取中收到的所有值,例如使用 CRDT 或通过询问用户。写入请求的响应就像读取一样,返回所有兄弟节点,这允许我们像购物车示例中那样链接多个写入。 +* 当客户端读取键时,服务器返回所有兄弟,即所有未被覆盖的值,以及最新的版本号。客户端必须在写入之前读取键。 +* 当客户端写入键时,它必须包含来自先前读取的版本号,并且必须合并它在先前读取中收到的所有值,例如使用 CRDT 或通过询问用户。写入请求的响应就像读取一样,返回所有兄弟,这允许我们像购物车示例中那样链接多个写入。 * 当服务器接收到具有特定版本号的写入时,它可以覆盖具有该版本号或更低版本号的所有值(因为它知道它们已合并到新值中),但它必须保留具有更高版本号的所有值(因为这些值与传入写入并发)。 当写入包含来自先前读取的版本号时,这告诉我们写入基于哪个先前状态。如果你在不包含版本号的情况下进行写入,它与所有其他写入并发,因此它不会覆盖任何内容——它只会作为后续读取的值之一返回。 #### 版本向量 {#version-vectors} -[图 6-15](/ch6#fig_replication_causality_single) 中的示例仅使用了单个副本。当有多个副本但没有主节点时,算法如何变化? +[图 6-15](/ch6#fig_replication_causality_single) 中的示例只使用了单个副本。当存在多个副本、且没有领导者时,算法如何变化? -[图 6-15](/ch6#fig_replication_causality_single) 使用单个版本号来捕获操作之间的依赖关系,但当有多个副本并发接受写入时,这是不够的。相反,我们需要使用 **每个副本** 以及每个键的版本号。每个副本在处理写入时递增其自己的版本号,并且还跟踪它从其他每个副本看到的版本号。此信息指示要覆盖哪些值以及保留哪些值作为兄弟节点。 +[图 6-15](/ch6#fig_replication_causality_single) 使用单个版本号来捕获操作间依赖关系,但当多个副本并发接受写入时,这还不够。我们需要为 **每个副本**、每个键分别维护版本号。每个副本在处理写入时递增自己的版本号,并追踪从其他副本看到的版本号。这些信息决定了哪些值该被覆盖,哪些值要作为兄弟保留。 -来自所有副本的版本号集合称为 **版本向量** [^58]。正在使用此想法的几个变体,但最有趣的可能是 **点化版本向量** [^59] [^60],它在 Riak 2.0 中使用 [^61] [^62]。我们不会详细介绍,但它的工作方式与我们在购物车示例中看到的非常相似。 +来自所有副本的版本号集合称为 **版本向量** [^58]。这一思想有若干变体,其中较有代表性的是 **点版本向量** [^59] [^60],Riak 2.0 使用了它 [^61] [^62]。这里不展开细节,它的工作方式与前面的购物车示例非常相似。 -像 [图 6-15](/ch6#fig_replication_causality_single) 中的版本号一样,版本向量在读取值时从数据库副本发送到客户端,并且在随后写入值时需要发送回数据库。(Riak 将版本向量编码为它称为 **因果上下文** 的字符串。)版本向量允许数据库区分覆盖和并发写入。 +和 [图 6-15](/ch6#fig_replication_causality_single) 里的版本号一样,版本向量会在读取时由数据库副本返回给客户端,并在后续写入时再由客户端带回数据库。(Riak 把版本向量编码成一个字符串,称为 **因果上下文**。)版本向量让数据库能够区分“覆盖写入”和“并发写入”。 -版本向量还确保从一个副本读取然后写回另一个副本是安全的。这样做可能会导致创建兄弟节点,但只要正确合并兄弟节点,就不会丢失数据。 +版本向量还保证了“从一个副本读取,再写回另一个副本”是安全的。这样做可能会产生兄弟,但只要正确合并兄弟,就不会丢失数据。 -------- @@ -766,17 +766,17 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本 我们讨论了三种主要的复制方法: **单主复制** -: 客户端将所有写入发送到单个节点(主节点),该节点将数据变更事件流发送到其他副本(从节点)。读取可以在任何副本上执行,但从从节点读取可能是陈旧的。 +: 客户端将所有写入发送到单个节点(领导者),该节点将数据变更事件流发送到其他副本(追随者)。读取可以在任何副本上执行,但从追随者读取可能是陈旧的。 **多主复制** -: 客户端将每个写入发送到几个主节点之一,任何主节点都可以接受写入。主节点相互发送数据变更事件流,并发送到任何从节点。 +: 客户端将每个写入发送到几个领导者之一,任何领导者都可以接受写入。领导者相互发送数据变更事件流,并发送到任何追随者。 **无主复制** : 客户端将每个写入发送到多个节点,并行从多个节点读取,以检测和纠正具有陈旧数据的节点。 每种方法都有优缺点。单主复制很受欢迎,因为它相当容易理解,并且提供强一致性。多主和无主复制在存在故障节点、网络中断和延迟峰值时可以更加健壮——代价是需要冲突解决并提供较弱的一致性保证。 -复制可以是同步的或异步的,这对系统在出现故障时的行为有深远的影响。尽管异步复制在系统平稳运行时可能很快,但重要的是要弄清楚当复制延迟增加和服务器失败时会发生什么。如果主节点失败并且你将异步更新的从节点提升为新的主节点,最近提交的数据可能会丢失。 +复制可以是同步的或异步的,这对系统在出现故障时的行为有深远的影响。尽管异步复制在系统平稳运行时可能很快,但重要的是要弄清楚当复制延迟增加和服务器失败时会发生什么。如果领导者失败并且你将异步更新的追随者提升为新的领导者,最近提交的数据可能会丢失。 我们研究了复制延迟可能导致的一些奇怪效果,并讨论了一些有助于决定应用程序在复制延迟下应如何表现的一致性模型: @@ -789,7 +789,7 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本 **一致前缀读** : 用户应该看到处于因果意义状态的数据:例如,按正确顺序看到问题及其回复。 -最后,我们讨论了多主和无主复制如何确保所有副本最终收敛到一致状态:通过使用版本向量或类似算法来检测哪些写入是并发的,并通过使用冲突解决算法(如 CRDT)来合并并发写入的值。最后写入者胜和手动冲突解决也是可能的。 +最后,我们讨论了多主和无主复制如何确保所有副本最终收敛到一致状态:通过使用版本向量或类似算法来检测哪些写入是并发的,并通过使用冲突解决算法(如 CRDT)来合并并发写入的值。最后写入胜利和手动冲突解决也是可能的。 本章假设每个副本都存储整个数据库的完整副本,这对于大型数据集是不现实的。在下一章中,我们将研究 **分片**,它允许每台机器只存储数据的子集。 @@ -859,4 +859,4 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本 [^61]: Sean Cribbs. [A Brief History of Time in Riak](https://speakerdeck.com/seancribbs/a-brief-history-of-time-in-riak). At *RICON*, October 2014. Archived at [perma.cc/7U9P-6JFX](https://perma.cc/7U9P-6JFX) [^62]: Russell Brown. [Vector Clocks Revisited Part 2: Dotted Version Vectors](https://riak.com/posts/technical/vector-clocks-revisited-part-2-dotted-version-vectors/). *riak.com*, November 2015. Archived at [perma.cc/96QP-W98R](https://perma.cc/96QP-W98R) [^63]: Carlos Baquero. [Version Vectors Are Not Vector Clocks](https://haslab.wordpress.com/2011/07/08/version-vectors-are-not-vector-clocks/). *haslab.wordpress.com*, July 2011. Archived at [perma.cc/7PNU-4AMG](https://perma.cc/7PNU-4AMG) -[^64]: Reinhard Schwarz and Friedemann Mattern. [Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](https://disco.ethz.ch/courses/hs08/seminar/papers/mattern4.pdf). *Distributed Computing*, volume 7, issue 3, pages 149–174, March 1994. [doi:10.1007/BF02277859](https://doi.org/10.1007/BF02277859) \ No newline at end of file +[^64]: Reinhard Schwarz and Friedemann Mattern. [Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](https://disco.ethz.ch/courses/hs08/seminar/papers/mattern4.pdf). *Distributed Computing*, volume 7, issue 3, pages 149–174, March 1994. [doi:10.1007/BF02277859](https://doi.org/10.1007/BF02277859) diff --git a/content/zh/ch7.md b/content/zh/ch7.md index c6f41c6..745cb31 100644 --- a/content/zh/ch7.md +++ b/content/zh/ch7.md @@ -4,6 +4,8 @@ weight: 207 breadcrumbs: false --- + + ![](/map/ch06.png) > *显然,我们必须跳出顺序计算机指令的窠臼。我们必须叙述定义、提供优先级和数据描述。我们必须叙述关系,而不是过程。* @@ -19,9 +21,9 @@ breadcrumbs: false 分片通常与复制结合使用,以便每个分片的副本存储在多个节点上。这意味着,即使每条记录属于恰好一个分片,它仍然可以存储在多个不同的节点上以提供容错能力。 -一个节点可能存储多个分片。如果使用单主复制模型,分片和复制的组合可能看起来像 [图 7-1](/ch7#fig_sharding_replicas),例如。每个分片的主节点被分配给一个节点,其从节点被分配给其他节点。每个节点可能是某些分片的主节点,同时是其他分片的从节点。 +一个节点可能存储多个分片。例如,如果使用单领导者复制模型,分片与复制的组合可能如 [图 7-1](/ch7#fig_sharding_replicas) 所示。每个分片的领导者被分配到一个节点,追随者被分配到其他节点。每个节点可能是某些分片的领导者,同时又是其他分片的追随者,但每个分片仍然只有一个领导者。 -{{< figure src="/fig/ddia_0701.png" id="fig_sharding_replicas" caption="图 7-1. 结合复制和分片:每个节点充当某些分片的主节点,同时充当其他分片的从节点。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0701.png" id="fig_sharding_replicas" caption="图 7-1. 复制与分片结合使用:每个节点对某些分片充当领导者,对另一些分片充当追随者。" class="w-full my-4" >}} 我们在 [第 6 章](/ch6#ch_replication) 中讨论的关于数据库复制的所有内容同样适用于分片的复制。由于分片方案的选择大部分独立于复制方案的选择,为了简单起见,我们将在本章中忽略复制。 @@ -53,7 +55,7 @@ breadcrumbs: false 分片的另一个问题是写入可能需要更新多个不同分片中的相关记录。虽然单节点上的事务相当常见(见 [第 8 章](/ch8#ch_transactions)),但确保跨多个分片的一致性需要 *分布式事务*。正如我们将在 [第 8 章](/ch8#ch_transactions) 中看到的,分布式事务在某些数据库中可用,但它们通常比单节点事务慢得多,可能成为整个系统的瓶颈,有些系统根本不支持它们。 -一些系统即使在单台机器上也使用分片,通常每个 CPU 核心运行一个单线程进程以利用 CPU 中的并行性,或者利用 *非一致性内存访问*(NUMA)架构,其中某些内存库比其他内存库更接近某个 CPU [^5]。例如,Redis、VoltDB 和 FoundationDB 每个核心使用一个进程,并依靠分片在同一台机器的 CPU 核心之间分散负载 [^6]。 +一些系统即使在单台机器上也使用分片,通常每个 CPU 核心运行一个单线程进程,以利用 CPU 的并行性,或者利用 *非统一内存访问*(NUMA)架构:某些内存分区比其他分区更靠近某个 CPU [^5]。例如,Redis、VoltDB 和 FoundationDB 每个核心使用一个进程,并依靠分片在同一台机器的 CPU 核心之间分散负载 [^6]。 ### 面向多租户的分片 {#sec_sharding_multitenancy} @@ -62,10 +64,10 @@ breadcrumbs: false 有时分片用于实现多租户系统:要么每个租户被分配一个单独的分片,要么多个小租户可能被分组到一个更大的分片中。这些分片可能是物理上分离的数据库(我们之前在 ["嵌入式存储引擎"](/ch4#sidebar_embedded) 中提到过),或者是更大逻辑数据库的可单独管理部分 [^7]。使用分片实现多租户有几个优点: 资源隔离 -: 如果一个租户执行计算密集型操作,如果它们在不同的分片上运行,其他租户的性能受影响的可能性较小。 +: 如果某个租户执行计算密集型操作,而它与其他租户运行在不同分片上,那么其他租户性能受影响的可能性更小。 权限隔离 -: 如果你的访问控制逻辑中存在错误,如果这些租户的数据集彼此物理分离存储,你意外地给一个租户访问另一个租户数据的可能性较小。 +: 如果访问控制逻辑有漏洞,而租户数据集又是彼此物理隔离存储的,那么误将一个租户的数据暴露给另一个租户的概率会更低。 基于单元的架构 : 你不仅可以在数据存储级别应用分片,还可以为运行应用程序代码的服务应用分片。在 *基于单元的架构* 中,特定租户集的服务和存储被分组到一个自包含的 *单元* 中,不同的单元被设置为可以在很大程度上彼此独立运行。这种方法提供了 *故障隔离*:即,一个单元中的故障仅限于该单元,其他单元中的租户不受影响 [^8]。 @@ -86,7 +88,7 @@ breadcrumbs: false * 它假设每个单独的租户都足够小,可以适应单个节点。如果情况并非如此,并且你有一个对于一台机器来说太大的租户,你将需要在单个租户内额外执行分片,这将我们带回到为可伸缩性进行分片的主题 [^12]。 * 如果你有许多小租户,那么为每个租户创建单独的分片可能会产生太多开销。你可以将几个小租户组合到一个更大的分片中,但随后你会遇到如何在租户增长时将其从一个分片移动到另一个分片的问题。 -* 如果你需要支持跨多个租户连接数据的功能,如果你需要跨多个分片连接数据,这些功能将变得更难实现。 +* 如果你需要支持跨多个租户关联数据的功能,那么在必须跨多个分片做连接时,实现难度会显著增加。 @@ -96,7 +98,7 @@ breadcrumbs: false 我们进行分片的目标是将数据和查询负载均匀地分布在各节点上。如果每个节点承担公平的份额,那么理论上——10 个节点应该能够处理 10 倍的数据量和 10 倍单个节点的读写吞吐量(忽略复制)。此外,如果我们添加或删除节点,我们希望能够 *再平衡* 负载,使其在添加时均匀分布在 11 个节点上(或删除时在剩余的 9 个节点上)。 -如果分片不公平,使得某些分片比其他分片有更多的数据或查询,我们称之为 *倾斜*。倾斜的存在使分片的效果大打折扣。在极端情况下,所有负载可能最终集中在一个分片上,因此 10 个节点中有 9 个处于空闲状态,你的瓶颈是单个繁忙的节点。具有不成比例高负载的分片称为 *热分片* 或 *热点*。如果有一个键具有特别高的负载(例如,社交网络中的名人),我们称之为 *热键*。 +如果分片不公平,使得某些分片比其他分片承载更多数据或查询,我们称之为 *偏斜*。偏斜会显著削弱分片效果。在极端情况下,所有负载都可能集中在一个分片上,导致 10 个节点中有 9 个处于空闲状态,而瓶颈落在那一个繁忙节点上。负载明显高于其他分片的分片称为 *热分片* 或 *热点*。如果某个键的负载特别高(例如社交网络中的名人),我们称之为 *热键*。 因此,我们需要一种算法,它以记录的分区键作为输入,并告诉我们该记录在哪个分片中。在键值存储中,分区键通常是键,或键的第一部分。在关系模型中,分区键可能是表的某一列(不一定是其主键)。该算法需要能够进行再平衡以缓解热点。 @@ -136,7 +138,7 @@ breadcrumbs: false 键范围分片在你希望具有相邻(但不同)分区键的记录被分组到同一个分片中时很有用;例如,如果是时间戳,这可能就是这种情况。如果你不关心分区键是否彼此接近(例如,如果它们是多租户应用程序中的租户 ID),一种常见方法是先对分区键进行哈希,然后将其映射到分片。 -一个好的哈希函数接受倾斜的数据并使其均匀分布。假设你有一个 32 位哈希函数,它接受一个字符串。每当你给它一个新字符串时,它返回一个介于 0 和 2³² − 1 之间的看似随机的数字。即使输入字符串非常相似,它们的哈希值也会均匀分布在该数字范围内(但相同的输入总是产生相同的输出)。 +一个好的哈希函数可以把偏斜的数据变得更均匀。假设你有一个 32 位哈希函数,输入是字符串。每当给它一个新字符串,它都会返回一个看似随机、介于 0 和 2³² − 1 之间的数字。即使输入字符串非常相似,它们的哈希值也会在这个范围内均匀分布(但相同输入总是产生相同输出)。 出于分片目的,哈希函数不需要是密码学强度的:例如,MongoDB 使用 MD5,而 Cassandra 和 ScyllaDB 使用 Murmur3。许多编程语言都内置了简单的哈希函数(因为它们用于哈希表),但它们可能不适合分片:例如,在 Java 的 `Object.hashCode()` 和 Ruby 的 `Object#hash` 中,相同的键在不同的进程中可能有不同的哈希值,使它们不适合分片 [^16]。 @@ -166,7 +168,7 @@ breadcrumbs: false 如果你发现最初配置的分片数量是错误的——例如,如果你已经达到需要比分片更多节点的规模——那么需要进行昂贵的重新分片操作。它需要分割每个分片并将其写入新文件,在此过程中使用大量额外的磁盘空间。一些系统不允许在并发写入数据库时进行重新分片,这使得在没有停机时间的情况下更改分片数量变得困难。 -如果数据集的总大小高度可变(例如,如果它开始很小但可能随时间增长得更大),选择正确的分片数量是困难的。由于每个分片包含总数据的固定部分,每个分片的大小与集群中的总数据量成比例增长。如果分片非常大,再平衡和从节点故障恢复会变得昂贵。但如果分片太小,它们会产生太多开销。当分片大小"恰到好处"时可以实现最佳性能,既不太大也不太小,如果分片数量固定但数据集大小变化,这可能很难实现。 +如果数据集总大小高度可变(例如起初很小,但会随时间显著增长),选择合适的分片数量就很困难。由于每个分片包含总数据中的固定比例,每个分片的大小会随集群总数据量按比例增长。如果分片很大,再平衡和节点故障恢复都会很昂贵;但如果分片太小,又会产生过多管理开销。最佳性能通常出现在分片大小“恰到好处”时,但在分片数量固定、数据规模又持续变化的情况下,这很难做到。 #### 按哈希范围分片 {#sharding-by-hash-range} @@ -205,15 +207,15 @@ breadcrumbs: false Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定义 [^20],但也提出了其他几种一致性哈希算法 [^21],如 *最高随机权重*,也称为 *会合哈希* [^22],以及 *跳跃一致性哈希* [^23]。使用 Cassandra 的算法,如果添加一个节点,少量现有分片会被分割成子范围;另一方面,使用会合和跳跃一致性哈希,新节点被分配之前分散在所有其他节点中的单个键。哪种更可取取决于应用程序。 -### 倾斜的工作负载与缓解热点 {#sec_sharding_skew} +### 偏斜的工作负载与缓解热点 {#sec_sharding_skew} -一致性哈希确保键在节点间均匀分布,但这并不意味着实际负载是均匀分布的。如果工作负载高度倾斜——即某些分区键下的数据量远大于其他键,或者对某些键的请求率远高于其他键——你仍然可能最终导致某些服务器过载,而其他服务器几乎处于空闲状态。 +一致性哈希保证键在节点间大致均匀分布,但这并不等于实际负载也均匀分布。如果工作负载高度偏斜,即某些分区键下的数据量远大于其他键,或某些键的请求速率远高于其他键,那么你仍可能出现部分服务器过载、其他服务器几乎空闲的情况。 例如,在社交媒体网站上,拥有数百万粉丝的名人用户在做某事时可能会引起活动风暴 [^24]。这个事件可能导致对同一个键的大量读写(其中分区键可能是名人的用户 ID,或者人们正在评论的动作的 ID)。 在这种情况下,需要更灵活的分片策略 [^25] [^26]。基于键范围(或哈希范围)定义分片的系统使得可以将单个热键放在自己的分片中,甚至可能为其分配专用机器 [^27]。 -也可以在应用程序级别补偿倾斜。例如,如果已知一个键非常热,一个简单的技术是在键的开头或结尾添加一个随机数。仅仅一个两位数的十进制随机数就会将对该键的写入均匀分布在 100 个不同的键上,允许这些键分布到不同的分片。 +也可以在应用层补偿偏斜。例如,如果已知某个键非常热,一个简单方法是在键的前后附加随机数。仅用两位十进制随机数,就可以把对该键的写入均匀打散到 100 个不同键上,从而将它们分布到不同分片。 然而,将写入分散到不同的键之后,任何读取现在都必须做额外的工作,因为它们必须从所有 100 个键读取数据并将其组合。对热键每个分片的读取量没有减少;只有写入负载被分割。这种技术还需要额外的记账:只对少数热键附加随机数是有意义的;对于写入吞吐量低的绝大多数键,这将是不必要的开销。因此,你还需要某种方法来跟踪哪些键正在被分割,以及将常规键转换为特殊管理的热键的过程。 @@ -221,7 +223,7 @@ Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定 一些系统(特别是为大规模设计的云服务)有自动处理热分片的方法;例如,Amazon 称之为 *热管理* [^28] 或 *自适应容量* [^17]。这些系统如何工作的细节超出了本书的范围。 -### 运维:自动/手动再均衡 {#sec_sharding_operations} +### 运维:自动/手动再平衡 {#sec_sharding_operations} 关于再平衡有一个我们已经忽略的重要问题:分片的分割和再平衡是自动发生还是手动发生? @@ -267,7 +269,7 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 当使用路由层或向随机节点发送请求时,客户端仍然需要找到要连接的 IP 地址。这些不像分片到节点的分配那样快速变化,因此通常使用 DNS 就足够了。 -这个关于请求路由的讨论集中在查找单个键的分片,这对于分片 OLTP 数据库最相关。分析数据库通常也使用分片,但它们通常有非常不同类型的查询执行:查询通常需要并行聚合和连接来自许多不同分片的数据,而不是在单个分片中执行。我们将在 [链接待定] 中讨论这种并行查询执行的技术。 +上面对请求路由的讨论,主要关注如何为单个键找到对应分片,这对分片 OLTP 数据库最相关。分析型数据库通常也使用分片,但其查询执行模型很不一样:查询往往需要并行聚合并连接来自多个分片的数据,而不是在单个分片内执行。我们将在 ["JOIN 和 GROUP BY"](/ch11#sec_batch_join) 中讨论这类并行查询执行技术。 ## 分片与二级索引 {#sec_sharding_secondary_indexes} @@ -275,17 +277,17 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 如果涉及二级索引,情况会变得更加复杂(另见 ["多列和二级索引"](/ch4#sec_storage_index_multicolumn))。二级索引通常不唯一地标识记录,而是一种搜索特定值出现的方法:查找用户 `123` 的所有操作、查找包含单词 `hogwash` 的所有文章、查找颜色为 `red` 的所有汽车等。 -键值存储通常没有二级索引,但它们是关系数据库的基础,在文档数据库中也很常见,它们是 Solr 和 Elasticsearch 等搜索引擎的 *存在理由*。二级索引的问题是它们不能整齐地映射到分片。有两种主要方法来使用二级索引对数据库进行分片:本地索引和全局索引。 +键值存储通常没有二级索引;但在关系数据库中,二级索引是基础能力,在文档数据库中也很常见,而且它们正是 Solr、Elasticsearch 等全文检索引擎的 *立身之本*。二级索引的难点在于,它们不能整齐地映射到分片。带二级索引的分片数据库主要有两种做法:本地索引与全局索引。 ### 本地二级索引 {#id166} 例如,假设你正在运营一个出售二手车的网站(如 [图 7-9](/ch7#fig_sharding_local_secondary) 所示)。每个列表都有一个唯一的 ID——称之为文档 ID——你使用该 ID 作为分区键对数据库进行分片(例如,ID 0 到 499 在分片 0 中,ID 500 到 999 在分片 1 中,等等)。 -如果你想让用户搜索汽车,允许他们按颜色和制造商过滤,你需要在 `color` 和 `make` 上建立二级索引(在文档数据库中这些是字段;在关系数据库中这些是列)。如果你已声明索引,数据库可以自动执行索引。例如,每当将红色汽车添加到数据库时,数据库分片会自动将其 ID 添加到索引条目 `color:red` 的文档 ID 列表中。如 [第 4 章](/ch4#ch_storage) 中所讨论的,该 ID 列表也称为 *发布列表*。 +如果你想让用户搜索汽车,允许他们按颜色和制造商过滤,你需要在 `color` 和 `make` 上建立二级索引(在文档数据库中这些是字段;在关系数据库中这些是列)。如果你已声明索引,数据库就可以自动维护索引。例如,每当一辆红色汽车被写入数据库,所在分片会自动将其 ID 加入索引条目 `color:red` 对应的文档 ID 列表。正如 [第 4 章](/ch4#ch_storage) 所述,这个 ID 列表也称为 *倒排列表*。 {{< figure src="/fig/ddia_0709.png" id="fig_sharding_local_secondary" caption="图 7-9. 本地二级索引:每个分片只索引其自己分片内的记录。" class="w-full my-4" >}} -> [!WARN] 警告 +> [!WARNING] 警告 如果你的数据库只支持键值模型,你可能会尝试通过在应用程序代码中创建从值到文档 ID 的映射来自己实现二级索引。如果你走这条路,你需要格外小心,确保你的索引与底层数据保持一致。竞态条件和间歇性写入失败(其中某些更改已保存但其他更改未保存)很容易导致数据不同步——见 ["多对象事务的需求"](/ch8#sec_transactions_need)。 @@ -297,7 +299,7 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 但是,如果你想要所有结果并且事先不知道它们的分区键,你需要将查询发送到所有分片,并组合你收到的结果,因为匹配的记录可能分散在所有分片中。在 [图 7-9](/ch7#fig_sharding_local_secondary) 中,红色汽车出现在分片 0 和分片 1 中。 -这种查询分片数据库的方法有时称为 *分散/聚集*,它可能使二级索引上的读取查询相当昂贵。即使并行查询分片,分散/聚集也容易导致尾部延迟放大(见 ["响应时间指标的使用"](/ch2#sec_introduction_slo_sla))。它还限制了应用程序的可伸缩性:添加更多分片让你存储更多数据,但如果每个分片无论如何都必须处理每个查询,它不会增加你的查询吞吐量。 +这种查询分片数据库的方法有时称为 *分散/收集*(scatter/gather),它可能使二级索引读取变得相当昂贵。即使并行查询各分片,分散/收集也容易导致尾部延迟放大(见 ["响应时间指标的使用"](/ch2#sec_introduction_slo_sla))。它还会限制应用的可伸缩性:增加分片可以提升可存储数据量,但若每个查询仍需所有分片参与,查询吞吐量并不会随分片数增加而提升。 尽管如此,本地二级索引被广泛使用 [^31]:例如,MongoDB、Riak、Cassandra [^32]、Elasticsearch [^33]、SolrCloud 和 VoltDB [^34] 都使用本地二级索引。 @@ -313,13 +315,13 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 全局索引使用词项作为分区键,因此当你查找特定词项或值时,你可以找出需要查询哪个分片。和以前一样,分片可以包含连续的词项范围(如 [图 7-10](/ch7#fig_sharding_global_secondary)),或者你可以基于词项的哈希将词项分配给分片。 -全局索引的优点是具有单个条件的查询(如 *color = red*)只需要从单个分片读取以获取发布列表。但是,如果你想获取记录而不仅仅是 ID,你仍然必须从负责这些 ID 的所有分片中读取。 +全局索引的优点是,只有一个查询条件时(如 *color = red*),只需从一个分片读取即可获得倒排列表。但如果你不仅要 ID,还要取回完整记录,仍然必须去负责这些 ID 的各个分片读取。 -如果你有多个搜索条件或词项(例如,搜索某种颜色和某种制造商的汽车,或搜索同一文本中出现的多个单词),很可能这些词项将被分配给不同的分片。要计算两个条件的逻辑 AND,系统需要找到两个发布列表中都出现的所有 ID。如果发布列表很短,这没问题,但如果它们很长,通过网络发送它们来计算它们的交集可能会很慢 [^30]。 +如果你有多个搜索条件或词项(例如搜索某种颜色且某个制造商的汽车,或搜索同一文本中出现的多个单词),这些词项很可能会落在不同分片。要计算两个条件的逻辑 AND,系统需要找出同时出现在两个倒排列表中的 ID。若倒排列表较短,这没问题;但若很长,把它们通过网络发送后再算交集就可能很慢 [^30]。 全局二级索引的另一个挑战是写入比本地索引更复杂,因为写入单个记录可能会影响索引的多个分片(文档中的每个词项可能在不同的分片或不同的节点上)。这使得二级索引与底层数据保持同步更加困难。一种选择是使用分布式事务来原子地更新存储主记录的分片及其二级索引(见 [第 8 章](/ch8#ch_transactions))。 -全局二级索引被 CockroachDB、TiDB 和 YugabyteDB 使用;DynamoDB 支持本地和全局二级索引。在 DynamoDB 的情况下,写入异步反映在全局索引中,因此从全局索引读取可能是陈旧的(类似于复制延迟,如 ["复制延迟的问题"](/ch6#sec_replication_lag))。尽管如此,如果读取吞吐量高于写入吞吐量,并且发布列表不太长,全局索引是有用的。 +全局二级索引被 CockroachDB、TiDB 和 YugabyteDB 使用;DynamoDB 同时支持本地与全局二级索引。在 DynamoDB 中,写入会异步反映到全局索引,因此从全局索引读取到的结果可能是陈旧的(类似复制延迟,见 ["复制延迟的问题"](/ch6#sec_replication_lag))。尽管如此,在读吞吐量高于写吞吐量且倒排列表不太长的场景下,全局索引仍然很有价值。 ## 总结 {#summary} @@ -330,10 +332,13 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 我们讨论了两种主要的分片方法: -* *键范围分片*,其中键是有序的,分片拥有从某个最小值到某个最大值的所有键。排序的优点是可以进行高效的范围查询,但如果应用程序经常访问排序顺序中彼此接近的键,则存在热点风险。 +**键范围分片** +: 其中键是有序的,分片拥有从某个最小值到某个最大值的所有键。排序的优点是可以进行高效的范围查询,但如果应用程序经常访问排序顺序中彼此接近的键,则存在热点风险。 在这种方法中,当分片变得太大时,通常通过将范围分成两个子范围来动态重新平衡分片。 -* *哈希分片*,其中对每个键应用哈希函数,分片拥有一个哈希值范围(或者可以使用另一种一致性哈希算法将哈希映射到分片)。这种方法破坏了键的顺序,使范围查询效率低下,但可能更均匀地分布负载。 + +**哈希分片** +: 其中对每个键应用哈希函数,分片拥有一个哈希值范围(或者可以使用另一种一致性哈希算法将哈希映射到分片)。这种方法破坏了键的顺序,使范围查询效率低下,但可能更均匀地分布负载。 当按哈希分片时,通常预先创建固定数量的分片,为每个节点分配多个分片,并在添加或删除节点时将整个分片从一个节点移动到另一个节点。像键范围一样分割分片也是可能的。 @@ -341,17 +346,20 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 我们还讨论了分片和二级索引之间的交互。二级索引也需要进行分片,有两种方法: -* *本地二级索引*,其中二级索引与主键和值存储在同一个分片中。这意味着写入时只需要更新一个分片,但二级索引的查找需要从所有分片读取。 -* *全局二级索引*,它们基于索引值单独分片。二级索引中的条目可能引用来自主键所有分片的记录。写入记录时,可能需要更新多个二级索引分片;但是,可以从单个分片提供发布列表的读取(获取实际记录仍需要从多个分片读取)。 +**本地二级索引** +: 其中二级索引与主键和值存储在同一个分片中。这意味着写入时只需要更新一个分片,但二级索引的查找需要从所有分片读取。 -最后,我们讨论了将查询路由到适当分片的技术,以及协调服务通常用于跟踪分片到节点的分配的方式。 +**全局二级索引** +: 它们基于索引值单独分片。二级索引中的条目可能引用来自主键所有分片的记录。写入记录时,可能需要更新多个二级索引分片;但读取倒排列表时,可以由单个分片提供(获取实际记录仍需从多个分片读取)。 -按设计,每个分片主要独立运行——这就是允许分片数据库扩展到多台机器的原因。但是,需要写入多个分片的操作可能会有问题:例如,如果对一个分片的写入成功,但对另一个分片的写入失败,会发生什么?我们将在以下章节中解决该问题。 +最后,我们讨论了将查询路由到正确分片的技术,以及如何借助协调服务维护分片到节点的分配信息。 + +按设计,每个分片大体独立运行,这正是分片数据库能够扩展到多台机器的原因。然而,凡是需要同时写多个分片的操作都会变得棘手:例如,一个分片写入成功、另一个分片写入失败时会发生什么?这个问题将在后续章节中讨论。 -### References +### 参考 [^1]: Claire Giordano. [Understanding partitioning and sharding in Postgres and Citus](https://www.citusdata.com/blog/2023/08/04/understanding-partitioning-and-sharding-in-postgres-and-citus/). *citusdata.com*, August 2023. Archived at [perma.cc/8BTK-8959](https://perma.cc/8BTK-8959) [^2]: Brandur Leach. [Partitioning in Postgres, 2022 edition](https://brandur.org/fragments/postgres-partitioning-2022). *brandur.org*, October 2022. Archived at [perma.cc/Z5LE-6AKX](https://perma.cc/Z5LE-6AKX) @@ -386,4 +394,4 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 [^31]: Michael Busch, Krishna Gade, Brian Larson, Patrick Lok, Samuel Luckenbill, and Jimmy Lin. [Earlybird: Real-Time Search at Twitter](https://cs.uwaterloo.ca/~jimmylin/publications/Busch_etal_ICDE2012.pdf). At *28th IEEE International Conference on Data Engineering* (ICDE), April 2012. [doi:10.1109/ICDE.2012.149](https://doi.org/10.1109/ICDE.2012.149) [^32]: Nadav Har’El. [Indexing in Cassandra 3](https://github.com/scylladb/scylladb/wiki/Indexing-in-Cassandra-3). *github.com*, April 2017. Archived at [perma.cc/3ENV-8T9P](https://perma.cc/3ENV-8T9P) [^33]: Zachary Tong. [Customizing Your Document Routing](https://www.elastic.co/blog/customizing-your-document-routing/). *elastic.co*, June 2013. Archived at [perma.cc/97VM-MREN](https://perma.cc/97VM-MREN) -[^34]: Andrew Pavlo. [H-Store Frequently Asked Questions](https://hstore.cs.brown.edu/documentation/faq/). *hstore.cs.brown.edu*, October 2013. Archived at [perma.cc/X3ZA-DW6Z](https://perma.cc/X3ZA-DW6Z) \ No newline at end of file +[^34]: Andrew Pavlo. [H-Store Frequently Asked Questions](https://hstore.cs.brown.edu/documentation/faq/). *hstore.cs.brown.edu*, October 2013. Archived at [perma.cc/X3ZA-DW6Z](https://perma.cc/X3ZA-DW6Z) diff --git a/content/zh/ch8.md b/content/zh/ch8.md index 61f6a8c..a43dc6c 100644 --- a/content/zh/ch8.md +++ b/content/zh/ch8.md @@ -5,6 +5,8 @@ math: true breadcrumbs: false --- + + ![](/map/ch07.png) > *有些作者声称,支持通用的两阶段提交代价太大,会带来性能与可用性的问题。我们认为,让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多。* @@ -40,7 +42,7 @@ breadcrumbs: false 在 2000 年代后期,非关系(NoSQL)数据库开始流行起来。它们旨在通过提供新的数据模型选择(参见[第 3 章](/ch3#ch_datamodels)),以及默认包含复制([第 6 章](/ch6#ch_replication))和分片([第 7 章](/ch7#ch_sharding))来改进关系型数据库的现状。事务是这一运动的主要牺牲品:许多这一代数据库完全放弃了事务,或者重新定义了这个词,用来描述比以前理解的更弱的保证集。 -围绕 NoSQL 分布式数据库的炒作导致了一种流行的信念,即事务从根本上是不可扩展的,任何大规模系统都必须放弃事务以保持良好的性能和高可用性。最近,这种信念被证明是错误的。所谓的"NewSQL"数据库,如 CockroachDB[^5]、TiDB[^6]、Spanner[^7]、FoundationDB[^8] 和 Yugabyte 已经证明,事务系统可以扩展到大数据量和高吞吐量。这些系统将分片与共识协议([第 10 章](/ch10#ch_consistency))相结合,以大规模提供强 ACID 保证。 +围绕 NoSQL 分布式数据库的炒作导致了一种流行的信念,即事务从根本上不可伸缩,任何大规模系统都必须放弃事务以保持良好的性能和高可用性。最近,这种信念被证明是错误的。所谓 "NewSQL" 数据库,如 CockroachDB[^5]、TiDB[^6]、Spanner[^7]、FoundationDB[^8] 和 YugabyteDB 已经证明,事务系统同样可以具备很强的可伸缩性,并支持大数据量与高吞吐量。这些系统将分片与共识协议([第 10 章](/ch10#ch_consistency))结合,在大规模下提供强 ACID 保证。 然而,这并不意味着每个系统都必须是事务型的:与任何其他技术设计选择一样,事务有优点也有局限性。为了理解这些权衡,让我们深入了解事务可以提供的保证的细节——无论是在正常操作中还是在各种极端(但现实)的情况下。 @@ -71,9 +73,9 @@ breadcrumbs: false *一致性*这个词被严重滥用: * 在[第 6 章](/ch6#ch_replication)中,我们讨论了*副本一致性*和异步复制系统中出现的*最终一致性*问题(参见["复制延迟的问题"](/ch6#sec_replication_lag))。 -* 数据库的*一致快照*(例如,用于备份)是整个数据库在某一时刻存在的快照。更准确地说,它与先发生关系(happens-before relation)一致(参见[""先发生"关系和并发"](/ch6#sec_replication_happens_before)):也就是说,如果快照包含在特定时间写入的值,那么它也反映了在该值写入之前发生的所有写入。 +* 数据库的*一致快照*(例如,用于备份)是整个数据库在某一时刻存在的快照。更准确地说,它与先发生关系(happens-before relation)一致(参见["“先发生”关系和并发"](/ch6#sec_replication_happens_before)):也就是说,如果快照包含在特定时间写入的值,那么它也反映了在该值写入之前发生的所有写入。 * *一致性哈希*是某些系统用于再平衡的分片方法(参见["一致性哈希"](/ch7#sec_sharding_consistent_hashing))。 -* 在 CAP 定理中(参见[第 10 章](/ch10#ch_consistency)),*一致性*一词用于表示*线性一致性*(参见["线性一致性"](/ch10#sec_consistency_linearizability))。 +* 在 CAP定理中(参见[第 10 章](/ch10#ch_consistency)),*一致性*一词用于表示*线性一致性*(参见["线性一致性"](/ch10#sec_consistency_linearizability))。 * 在 ACID 的上下文中,*一致性*是指应用程序特定的数据库处于"良好状态"的概念。 不幸的是,同一个词至少有五种不同的含义。 @@ -107,6 +109,8 @@ ACID 意义上的*隔离性*意味着同时执行的事务彼此隔离:它们 -------- + + > [!TIP] 复制与持久性 历史上,持久性意味着写入归档磁带。然后它被理解为写入磁盘或 SSD。最近,它已经适应为意味着复制。哪种实现更好? @@ -189,13 +193,13 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true * 在文档数据模型中,需要一起更新的字段通常在同一文档内,它被视为单个对象——更新单个文档时不需要多对象事务。然而,缺乏连接功能的文档数据库也鼓励反规范化(参见["何时使用哪种模型"](/ch3#sec_datamodels_document_summary))。当需要更新反规范化信息时,如[图 8-2](/ch8#fig_transactions_read_uncommitted) 的示例,你需要一次更新多个文档。事务在这种情况下非常有用,可以防止反规范化数据失去同步。 * 在具有二级索引的数据库中(几乎除了纯键值存储之外的所有数据库),每次更改值时都需要更新索引。从事务的角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离,记录可能出现在一个索引中但不在另一个索引中,因为对第二个索引的更新尚未发生(参见["分片和二级索引"](/ch7#sec_sharding_secondary_indexes))。 -这些应用程序仍然可以在没有事务的情况下实现。然而,没有原子性的错误处理变得更加复杂,缺乏隔离性可能导致并发问题。我们将在["弱隔离级别"](/ch8#sec_transactions_isolation_levels)中讨论这些问题,并在[待补充链接]中探索替代方法。 +这些应用程序仍然可以在没有事务的情况下实现。然而,没有原子性的错误处理变得更加复杂,缺乏隔离性可能导致并发问题。我们将在["弱隔离级别"](/ch8#sec_transactions_isolation_levels)中讨论这些问题,并在["派生数据与分布式事务"](/ch13#sec_future_derived_vs_transactions)中探索替代方法。 #### 处理错误和中止 {#handling-errors-and-aborts} 事务的一个关键特性是,如果发生错误,它可以被中止并安全地重试。ACID 数据库基于这样的哲学:如果数据库有违反其原子性、隔离性或持久性保证的危险,它宁愿完全放弃事务,也不允许它保持半完成状态。 -然而,并非所有系统都遵循这种哲学。特别是,具有无领导者复制的数据存储(参见["无领导者复制"](/ch6#sec_replication_leaderless))更多地基于"尽力而为"的基础工作,可以总结为"数据库将尽其所能,如果遇到错误,它不会撤消已经完成的操作"——因此,从错误中恢复是应用程序的责任。 +然而,并非所有系统都遵循这种哲学。特别是,具有无主(无领导者)复制的数据存储(参见["无主(无领导者)复制"](/ch6#sec_replication_leaderless))更多地基于"尽力而为"的基础工作,可以总结为"数据库将尽其所能,如果遇到错误,它不会撤消已经完成的操作"——因此,从错误中恢复是应用程序的责任。 错误不可避免地会发生,但许多软件开发人员更愿意只考虑快乐路径,而不是错误处理的复杂性。例如,流行的对象关系映射(ORM)框架,如 Rails 的 ActiveRecord 和 Django,不会重试中止的事务——错误通常导致异常冒泡到堆栈中,因此任何用户输入都被丢弃,用户收到错误消息。这是一种遗憾,因为中止的全部意义是启用安全重试。 @@ -288,12 +292,12 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 然而,使用这个隔离级别时,仍然有很多方式可能出现并发错误。例如,[图 8-6](/ch8#fig_transactions_item_many_preceders) 说明了读已提交可能发生的问题。 -{{< figure src="/fig/ddia_0806.png" id="fig_transactions_item_many_preceders" caption="图 8-6. 读偏斜:Aaliyah 观察到数据库处于不一致状态。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0806.png" id="fig_transactions_item_many_preceders" caption="图 8-6. 读取偏差:Aaliyah 观察到数据库处于不一致状态。" class="w-full my-4" >}} 假设 Aaliyah 在银行有 1,000 美元的储蓄,分成两个账户,每个 500 美元。现在一笔事务从她的一个账户转账 100 美元到另一个账户。如果她不幸在该事务处理的同时查看她的账户余额列表,她可能会看到一个账户余额在收款到达之前(余额为 500 美元),另一个账户在转出之后(新余额为 400 美元)。对 Aaliyah 来说,现在她的账户总共只有 900 美元——似乎 100 美元凭空消失了。 -这种异常称为*读偏斜*,它是*不可重复读*的一个例子:如果 Aaliyah 在事务结束时再次读取账户 1 的余额,她会看到与之前查询中看到的不同的值(600 美元)。读偏斜在读已提交隔离下被认为是可接受的:Aaliyah 看到的账户余额确实是在她读取它们时已提交的。 +这种异常称为*读取偏差*,它是*不可重复读*的一个例子:如果 Aaliyah 在事务结束时再次读取账户 1 的余额,她会看到与之前查询中看到的不同的值(600 美元)。读取偏差在读已提交隔离下被认为是可接受的:Aaliyah 看到的账户余额确实是在她读取它们时已提交的。 -------- @@ -351,6 +355,8 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 长时间运行的事务可能会长时间继续使用快照,继续读取(从其他事务的角度来看)早已被覆盖或删除的值。通过永远不更新原地的值,而是在每次更改值时插入新版本,数据库可以提供一致的快照,同时只产生很小的开销。 + + #### 索引与快照隔离 {#indexes-and-snapshot-isolation} 索引如何在多版本数据库中工作?最常见的方法是每个索引条目指向与该条目匹配的行的一个版本(最旧或最新版本)。每个行版本可能包含对下一个最旧或下一个最新版本的引用。使用索引的查询必须迭代行以找到可见的行,并且值与查询要查找的内容匹配。当垃圾收集删除不再对任何事务可见的旧行版本时,相应的索引条目也可以被删除。 @@ -455,15 +461,15 @@ UPDATE wiki_pages SET content = 'new content' 在复制数据库中(参见[第 6 章](/ch6#ch_replication)),防止丢失的更新具有另一个维度:由于它们在多个节点上有数据副本,并且数据可能在不同节点上并发修改,因此需要采取一些额外的步骤来防止丢失的更新。 -锁和条件写入操作假设有一个最新的数据副本。然而,具有多领导者或无领导者复制的数据库通常允许多个写入并发发生并异步复制它们,因此它们不能保证有一个最新的数据副本。因此,基于锁或条件写入的技术在此上下文中不适用。(我们将在["线性一致性"](/ch10#sec_consistency_linearizability)中更详细地重新讨论这个问题。) +锁和条件写入操作假设有一个最新的数据副本。然而,具有多领导者或无主(无领导者)复制的数据库通常允许多个写入并发发生并异步复制它们,因此它们不能保证有一个最新的数据副本。因此,基于锁或条件写入的技术在此上下文中不适用。(我们将在["线性一致性"](/ch10#sec_consistency_linearizability)中更详细地重新讨论这个问题。) 相反,如["处理冲突写入"](/ch6#sec_replication_write_conflicts)中所讨论的,此类复制数据库中的常见方法是允许并发写入创建值的多个冲突版本(也称为*兄弟节点*),并使用应用程序代码或特殊数据结构在事后解决和合并这些版本。 如果更新是可交换的(即,你可以在不同副本上以不同顺序应用它们,仍然得到相同的结果),合并冲突值可以防止丢失的更新。例如,递增计数器或向集合添加元素是可交换操作。这就是 CRDT 背后的想法,我们在["CRDT 和操作转换"](/ch6#sec_replication_crdts)中遇到过。然而,某些操作(如条件写入)不能成为可交换的。 -另一方面,*最后写入获胜*(LWW)冲突解决方法容易丢失更新,如["最后写入获胜(丢弃并发写入)"](/ch6#sec_replication_lww)中所讨论的。不幸的是,LWW 是许多复制数据库中的默认值。 +另一方面,*最后写入胜利*(LWW)冲突解决方法容易丢失更新,如["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww)中所讨论的。不幸的是,LWW 是许多复制数据库中的默认值。 -### 写偏斜与幻读 {#sec_transactions_write_skew} +### 写偏差与幻读 {#sec_transactions_write_skew} 在前面的部分中,我们看到了*脏写*和*丢失更新*,这是当不同事务并发尝试写入相同对象时可能发生的两种竞态条件。为了避免数据损坏,需要防止这些竞态条件——要么由数据库自动防止,要么通过使用锁或原子写操作等手动保护措施。 @@ -473,21 +479,21 @@ UPDATE wiki_pages SET content = 'new content' 现在想象 Aaliyah 和 Bryce 是特定班次的两位值班医生。两人都感觉不舒服,所以他们都决定请假。不幸的是,他们碰巧大约在同一时间点击了下班的按钮。接下来发生的事情如[图 8-8](/ch8#fig_transactions_write_skew) 所示。 -{{< figure src="/fig/ddia_0808.png" id="fig_transactions_write_skew" caption="图 8-8. 写偏斜导致应用程序错误的示例。" class="w-full my-4" >}} +{{< figure src="/fig/ddia_0808.png" id="fig_transactions_write_skew" caption="图 8-8. 写偏差导致应用程序错误的示例。" class="w-full my-4" >}} 在每个事务中,你的应用程序首先检查当前是否有两个或更多医生在值班;如果是,它假设一个医生下班是安全的。由于数据库使用快照隔离,两个检查都返回 `2`,因此两个事务都继续到下一阶段。Aaliyah 更新她自己的记录让自己下班,Bryce 同样更新他自己的记录。两个事务都提交,现在没有医生值班。你至少有一个医生值班的要求被违反了。 -#### 描述写偏斜 {#characterizing-write-skew} +#### 写偏差的特征 {#characterizing-write-skew} -这种异常称为*写偏斜*[^36]。它既不是脏写也不是丢失的更新,因为两个事务正在更新两个不同的对象(分别是 Aaliyah 和 Bryce 的值班记录)。这里发生冲突不太明显,但这绝对是一个竞态条件:如果两个事务一个接一个地运行,第二个医生将被阻止下班。异常行为只有在事务并发运行时才可能。 +这种异常称为*写偏差*[^36]。它既不是脏写也不是丢失的更新,因为两个事务正在更新两个不同的对象(分别是 Aaliyah 和 Bryce 的值班记录)。这里发生冲突不太明显,但这绝对是一个竞态条件:如果两个事务一个接一个地运行,第二个医生将被阻止下班。异常行为只有在事务并发运行时才可能。 -你可以将写偏斜视为丢失更新问题的概括。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),就会发生写偏斜。在不同事务更新同一对象的特殊情况下,你会得到脏写或丢失更新异常(取决于时机)。 +你可以将写偏差视为丢失更新问题的概括。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),就会发生写偏差。在不同事务更新同一对象的特殊情况下,你会得到脏写或丢失更新异常(取决于时机)。 -我们看到有各种不同的方法可以防止丢失的更新。对于写偏斜,我们的选择更受限制: +我们看到有各种不同的方法可以防止丢失的更新。对于写偏差,我们的选择更受限制: * 原子单对象操作没有帮助,因为涉及多个对象。 -* 不幸的是,你在某些快照隔离实现中发现的丢失更新的自动检测也没有帮助:写偏斜在 PostgreSQL 的可重复读、MySQL/InnoDB 的可重复读、Oracle 的可串行化或 SQL Server 的快照隔离级别中不会自动检测到[^29]。自动防止写偏斜需要真正的可串行化隔离(参见["可串行化"](/ch8#sec_transactions_serializability))。 +* 不幸的是,你在某些快照隔离实现中发现的丢失更新的自动检测也没有帮助:写偏差在 PostgreSQL 的可重复读、MySQL/InnoDB 的可重复读、Oracle 的可串行化或 SQL Server 的快照隔离级别中不会自动检测到[^29]。自动防止写偏差需要真正的可串行化隔离(参见["可串行化"](/ch8#sec_transactions_serializability))。 * 某些数据库允许你配置约束,然后由数据库强制执行(例如,唯一性、外键约束或对特定值的限制)。但是,为了指定至少有一个医生必须值班,你需要一个涉及多个对象的约束。大多数数据库没有对此类约束的内置支持,但你可能能够使用触发器或物化视图实现它们,如["一致性"](/ch8#sec_transactions_acid_consistency)中所讨论的[^12]。 * 如果你不能使用可串行化隔离级别,在这种情况下,第二好的选择可能是显式锁定事务所依赖的行。在医生示例中,你可以编写如下内容: @@ -508,9 +514,9 @@ UPDATE wiki_pages SET content = 'new content' ❶:和以前一样,`FOR UPDATE` 告诉数据库锁定此查询返回的所有行。 -#### 更多写偏斜的例子 {#more-examples-of-write-skew} +#### 写偏差的更多例子 {#more-examples-of-write-skew} -写偏斜起初可能看起来是一个深奥的问题,但一旦你意识到它,你可能会注意到更多可能发生的情况。以下是更多示例: +写偏差起初可能看起来是一个深奥的问题,但一旦你意识到它,你可能会注意到更多可能发生的情况。以下是更多示例: 会议室预订系统 : 假设你想强制同一会议室在同一时间不能有两个预订[^55]。当有人想要预订时,你首先检查是否有任何冲突的预订(即,具有重叠时间范围的同一房间的预订),如果没有找到,你就创建会议(参见[例 8-2](/ch8#fig_transactions_meeting_rooms))。 @@ -535,15 +541,15 @@ UPDATE wiki_pages SET content = 'new content' 不幸的是,快照隔离不会阻止另一个用户并发插入冲突的会议。为了保证你不会出现调度冲突,你再次需要可串行化隔离。 多人游戏 -: 在[例 8-1](/ch8#fig_transactions_select_for_update) 中,我们使用锁来防止丢失的更新(即,确保两个玩家不能同时移动同一个棋子)。但是,锁不会阻止玩家将两个不同的棋子移动到棋盘上的同一位置,或者可能做出违反游戏规则的其他移动。根据你要执行的规则类型,你可能能够使用唯一约束,但否则你很容易受到写偏斜的影响。 +: 在[例 8-1](/ch8#fig_transactions_select_for_update) 中,我们使用锁来防止丢失的更新(即,确保两个玩家不能同时移动同一个棋子)。但是,锁不会阻止玩家将两个不同的棋子移动到棋盘上的同一位置,或者可能做出违反游戏规则的其他移动。根据你要执行的规则类型,你可能能够使用唯一约束,但否则你很容易受到写偏差的影响。 声明用户名 : 在每个用户都有唯一用户名的网站上,两个用户可能同时尝试使用相同的用户名创建账户。你可以使用事务来检查名称是否被占用,如果没有,使用该名称创建账户。但是,就像前面的例子一样,这在快照隔离下是不安全的。幸运的是,唯一约束在这里是一个简单的解决方案(尝试注册用户名的第二个事务将由于违反约束而被中止)。 防止重复消费 -: 允许用户花钱或积分的服务需要检查用户不会花费超过他们拥有的。你可以通过在用户账户中插入暂定支出项目,列出账户中的所有项目,并检查总和是否为正来实现这一点。有了写偏斜,可能会发生两个支出项目并发插入,它们一起导致余额变为负数,但没有任何事务注意到另一个。 +: 允许用户花钱或积分的服务需要检查用户不会花费超过他们拥有的。你可以通过在用户账户中插入暂定支出项目,列出账户中的所有项目,并检查总和是否为正来实现这一点。有了写偏差,可能会发生两个支出项目并发插入,它们一起导致余额变为负数,但没有任何事务注意到另一个。 -#### 导致写偏斜的幻读 {#sec_transactions_phantom} +#### 导致写偏差的幻读 {#sec_transactions_phantom} 所有这些例子都遵循类似的模式: @@ -555,9 +561,9 @@ UPDATE wiki_pages SET content = 'new content' 步骤可能以不同的顺序发生。例如,你可以先进行写入,然后进行 `SELECT` 查询,最后根据查询结果决定是中止还是提交。 -在医生值班示例的情况下,步骤 3 中被修改的行是步骤 1 中返回的行之一,因此我们可以通过锁定步骤 1 中的行(`SELECT FOR UPDATE`)来使事务安全并避免写偏斜。但是,其他四个示例是不同的:它们检查*不存在*匹配某些搜索条件的行,而写入*添加*了匹配相同条件的行。如果步骤 1 中的查询不返回任何行,`SELECT FOR UPDATE` 就无法附加锁[^56]。 +在医生值班示例的情况下,步骤 3 中被修改的行是步骤 1 中返回的行之一,因此我们可以通过锁定步骤 1 中的行(`SELECT FOR UPDATE`)来使事务安全并避免写偏差。但是,其他四个示例是不同的:它们检查*不存在*匹配某些搜索条件的行,而写入*添加*了匹配相同条件的行。如果步骤 1 中的查询不返回任何行,`SELECT FOR UPDATE` 就无法附加锁[^56]。 -这种效果,其中一个事务中的写入改变另一个事务中搜索查询的结果,称为*幻读*[^4]。快照隔离避免了只读查询中的幻读,但在我们讨论的读写事务中,幻读可能导致特别棘手的写偏斜情况。ORM 生成的 SQL 也容易出现写偏斜[^50] [^51]。 +这种效果,其中一个事务中的写入改变另一个事务中搜索查询的结果,称为*幻读*[^4]。快照隔离避免了只读查询中的幻读,但在我们讨论的读写事务中,幻读可能导致特别棘手的写偏差情况。ORM 生成的 SQL 也容易出现写偏差[^50] [^51]。 #### 物化冲突 {#materializing-conflicts} @@ -573,7 +579,7 @@ UPDATE wiki_pages SET content = 'new content' ## 可串行化 {#sec_transactions_serializability} -在本章中,我们已经看到了几个容易出现竞态条件的事务示例。某些竞态条件被读已提交和快照隔离级别所防止,但其他的则没有。我们遇到了一些特别棘手的写偏斜和幻读示例。这是一个令人沮丧的情况: +在本章中,我们已经看到了几个容易出现竞态条件的事务示例。某些竞态条件被读已提交和快照隔离级别所防止,但其他的则没有。我们遇到了一些特别棘手的写偏差和幻读示例。这是一个令人沮丧的情况: * 隔离级别很难理解,并且在不同数据库中的实现不一致(例如,"可重复读"的含义差异很大)。 * 如果你查看你的应用程序代码,很难判断在特定隔离级别下运行是否安全——特别是在大型应用程序中,你可能不知道所有可能并发发生的事情。 @@ -676,7 +682,7 @@ VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个 * 如果事务 A 已读取对象而事务 B 想要写入该对象,B 必须等到 A 提交或中止后才能继续。(这确保 B 不能在 A 背后意外地更改对象。) * 如果事务 A 已写入对象而事务 B 想要读取该对象,B 必须等到 A 提交或中止后才能继续。(像[图 8-4](/ch8#fig_transactions_read_committed) 中那样读取对象的旧版本在 2PL 下是不可接受的。) -在 2PL 中,写入者不仅阻塞其他写入者;它们还阻塞读者,反之亦然。快照隔离有这样的口号:*读者永远不会阻塞写者,写者永远不会阻塞读者*(参见["多版本并发控制(MVCC)"](/ch8#sec_transactions_snapshot_impl)),这捕捉了快照隔离和两阶段锁定之间的关键区别。另一方面,因为 2PL 提供可串行化,它可以防止早期讨论的所有竞态条件,包括丢失的更新和写偏斜。 +在 2PL 中,写入者不仅阻塞其他写入者;它们还阻塞读者,反之亦然。快照隔离有这样的口号:*读者永远不会阻塞写者,写者永远不会阻塞读者*(参见["多版本并发控制(MVCC)"](/ch8#sec_transactions_snapshot_impl)),这捕捉了快照隔离和两阶段锁定之间的关键区别。另一方面,因为 2PL 提供可串行化,它可以防止早期讨论的所有竞态条件,包括丢失的更新和写偏差。 #### 两阶段锁定的实现 {#implementation-of-two-phase-locking} @@ -705,7 +711,7 @@ VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个 #### 谓词锁 {#predicate-locks} -在前面的锁描述中,我们掩盖了一个微妙但重要的细节。在["导致写偏斜的幻读"](/ch8#sec_transactions_phantom)中,我们讨论了*幻读*的问题——即一个事务改变另一个事务的搜索查询结果。具有可串行化隔离的数据库必须防止幻读。 +在前面的锁描述中,我们掩盖了一个微妙但重要的细节。在["导致写偏差的幻读"](/ch8#sec_transactions_phantom)中,我们讨论了*幻读*的问题——即一个事务改变另一个事务的搜索查询结果。具有可串行化隔离的数据库必须防止幻读。 在会议室预订示例中,这意味着如果一个事务已经搜索了某个时间窗口内某个房间的现有预订(参见[例 8-2](/ch8#fig_transactions_meeting_rooms)),另一个事务不允许并发插入或更新同一房间和时间范围的另一个预订。(并发插入其他房间的预订,或同一房间不影响拟议预订的不同时间的预订是可以的。) @@ -723,7 +729,7 @@ SELECT * FROM bookings * 如果事务 A 想要读取匹配某些条件的对象,就像在该 `SELECT` 查询中一样,它必须在查询条件上获取共享模式谓词锁。如果另一个事务 B 当前对匹配这些条件的任何对象具有独占锁,A 必须等到 B 释放其锁后才允许进行查询。 * 如果事务 A 想要插入、更新或删除任何对象,它必须首先检查旧值或新值是否匹配任何现有的谓词锁。如果存在事务 B 持有的匹配谓词锁,则 A 必须等到 B 提交或中止后才能继续。 -这里的关键思想是,谓词锁甚至适用于数据库中尚不存在但将来可能添加的对象(幻读)。如果两阶段锁定包括谓词锁,数据库将防止所有形式的写偏斜和其他竞态条件,因此其隔离变为可串行化。 +这里的关键思想是,谓词锁甚至适用于数据库中尚不存在但将来可能添加的对象(幻读)。如果两阶段锁定包括谓词锁,数据库将防止所有形式的写偏差和其他竞态条件,因此其隔离变为可串行化。 #### 索引范围锁 {#sec_transactions_2pl_range} @@ -738,13 +744,13 @@ SELECT * FROM bookings 无论哪种方式,搜索条件的近似都附加到其中一个索引。现在,如果另一个事务想要插入、更新或删除同一房间和/或重叠时间段的预订,它将必须更新索引的相同部分。在这样做的过程中,它将遇到共享锁,并被迫等到锁被释放。 -这提供了对幻读和写偏斜的有效保护。索引范围锁不如谓词锁精确(它们可能锁定比严格维护可串行化所需的更大范围的对象),但由于它们的开销要低得多,它们是一个很好的折衷。 +这提供了对幻读和写偏差的有效保护。索引范围锁不如谓词锁精确(它们可能锁定比严格维护可串行化所需的更大范围的对象),但由于它们的开销要低得多,它们是一个很好的折衷。 如果没有合适的索引可以附加范围锁,数据库可以退回到整个表的共享锁。这对性能不利,因为它将阻止所有其他事务写入表,但这是一个安全的后备位置。 ### 可串行化快照隔离(SSI) {#sec_transactions_ssi} -本章描绘了数据库并发控制的黯淡画面。一方面,我们有性能不佳(两阶段锁定)或扩展性不佳(串行执行)的可串行化实现。另一方面,我们有性能良好但容易出现各种竞态条件(丢失的更新、写偏斜、幻读等)的弱隔离级别。可串行化隔离和良好性能从根本上是对立的吗? +本章描绘了数据库并发控制的黯淡画面。一方面,我们有性能不佳(两阶段锁定)或可伸缩性不佳(串行执行)的可串行化实现。另一方面,我们有性能良好但容易出现各种竞态条件(丢失的更新、写偏差、幻读等)的弱隔离级别。可串行化隔离和良好性能从根本上是对立的吗? 似乎不是:一种称为*可串行化快照隔离*(SSI)的算法提供完全可串行化,与快照隔离相比只有很小的性能损失。SSI 相对较新:它于 2008 年首次描述[^53] [^65]。 @@ -766,7 +772,7 @@ SELECT * FROM bookings #### 基于过时前提的决策 {#decisions-based-on-an-outdated-premise} -当我们之前讨论快照隔离中的写偏斜时(参见["写偏斜与幻读"](/ch8#sec_transactions_write_skew)),我们观察到一个反复出现的模式:事务从数据库读取一些数据,检查查询结果,并根据它看到的结果决定采取某些行动(写入数据库)。但是,在快照隔离下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在此期间被修改。 +当我们之前讨论快照隔离中的写偏差时(参见["写偏差与幻读"](/ch8#sec_transactions_write_skew)),我们观察到一个反复出现的模式:事务从数据库读取一些数据,检查查询结果,并根据它看到的结果决定采取某些行动(写入数据库)。但是,在快照隔离下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在此期间被修改。 换句话说,事务基于*前提*(事务开始时为真的事实,例如,"当前有两名医生值班")采取行动。后来,当事务想要提交时,原始数据可能已更改——前提可能不再为真。 @@ -781,14 +787,14 @@ SELECT * FROM bookings 回想一下,快照隔离通常由多版本并发控制(MVCC;参见["多版本并发控制(MVCC)"](/ch8#sec_transactions_snapshot_impl))实现。当事务从 MVCC 数据库中的一致快照读取时,它会忽略在拍摄快照时尚未提交的任何其他事务所做的写入。 -在[图 8-10](/ch8#fig_transactions_detect_mvcc) 中,事务 43 看到 Aaliyah 的 `on_call = true`,因为事务 42(修改了 Aaliyah 的值班状态)未提交。但是,当事务 43 想要提交时,事务 42 已经提交。这意味着从一致快照读取时被忽略的写入现在已生效,事务 43 的前提不再为真。当写入者插入以前不存在的数据时,事情变得更加复杂(参见["导致写偏斜的幻读"](/ch8#sec_transactions_phantom))。我们将在["检测影响先前读取的写入"](/ch8#sec_detecting_writes_affect_reads)中讨论为 SSI 检测幻写。 +在[图 8-10](/ch8#fig_transactions_detect_mvcc) 中,事务 43 看到 Aaliyah 的 `on_call = true`,因为事务 42(修改了 Aaliyah 的值班状态)未提交。但是,当事务 43 想要提交时,事务 42 已经提交。这意味着从一致快照读取时被忽略的写入现在已生效,事务 43 的前提不再为真。当写入者插入以前不存在的数据时,事情变得更加复杂(参见["导致写偏差的幻读"](/ch8#sec_transactions_phantom))。我们将在["检测影响先前读取的写入"](/ch8#sec_detecting_writes_affect_reads)中讨论为 SSI 检测幻写。 {{< figure src="/fig/ddia_0810.png" id="fig_transactions_detect_mvcc" caption="图 8-10. 检测事务何时从 MVCC 快照读取过时值。" class="w-full my-4" >}} 为了防止这种异常,数据库需要跟踪事务由于 MVCC 可见性规则而忽略另一个事务的写入的时间。当事务想要提交时,数据库会检查是否有任何被忽略的写入现在已经提交。如果是,事务必须被中止。 -为什么要等到提交?为什么不在检测到陈旧读取时立即中止事务 43?好吧,如果事务 43 是只读事务,它就不需要被中止,因为没有写偏斜的风险。在事务 43 进行读取时,数据库还不知道该事务是否稍后会执行写入。此外,事务 42 可能还会中止,或者在事务 43 提交时可能仍未提交,因此读取可能最终不是陈旧的。通过避免不必要的中止,SSI 保留了快照隔离对从一致快照进行长时间运行读取的支持。 +为什么要等到提交?为什么不在检测到陈旧读取时立即中止事务 43?好吧,如果事务 43 是只读事务,它就不需要被中止,因为没有写偏差的风险。在事务 43 进行读取时,数据库还不知道该事务是否稍后会执行写入。此外,事务 42 可能还会中止,或者在事务 43 提交时可能仍未提交,因此读取可能最终不是陈旧的。通过避免不必要的中止,SSI 保留了快照隔离对从一致快照进行长时间运行读取的支持。 #### 检测影响先前读取的写入 {#sec_detecting_writes_affect_reads} @@ -920,15 +926,15 @@ SELECT * FROM bookings 数据库内部事务不必与任何其他系统兼容,因此它们可以使用任何协议并应用特定于该特定技术的优化。因此,数据库内部分布式事务通常可以很好地工作。另一方面,跨异构技术的事务更具挑战性。 -#### 精确一次消息处理 {#sec_transactions_exactly_once} +#### 恰好一次消息处理 {#sec_transactions_exactly_once} 异构分布式事务允许以强大的方式集成各种系统。例如,当且仅当处理消息的数据库事务成功提交时,来自消息队列的消息才能被确认为已处理。这是通过在单个事务中原子地提交消息确认和数据库写入来实现的。有了分布式事务支持,即使消息代理和数据库是在不同机器上运行的两种不相关的技术,这也是可能的。 -如果消息传递或数据库事务失败,两者都会中止,因此消息代理可以稍后安全地重新传递消息。因此,通过原子地提交消息及其处理的副作用,我们可以确保消息被*有效地*精确处理一次,即使在成功之前需要几次重试。中止会丢弃部分完成事务的任何副作用。这被称为*精确一次语义*。 +如果消息传递或数据库事务失败,两者都会中止,因此消息代理可以稍后安全地重新传递消息。因此,通过原子地提交消息及其处理的副作用,我们可以确保消息在效果上*恰好*处理一次,即使在成功之前需要几次重试。中止会丢弃部分完成事务的任何副作用。这被称为*恰好一次语义*。 但是,只有当受事务影响的所有系统都能够使用相同的原子提交协议时,这种分布式事务才有可能。例如,假设处理消息的副作用是发送电子邮件,而电子邮件服务器不支持两阶段提交:如果消息处理失败并重试,可能会发生电子邮件被发送两次或更多次。但是,如果处理消息的所有副作用在事务中止时都会回滚,那么处理步骤可以安全地重试,就好像什么都没有发生一样。 -我们将在本章后面回到精确一次语义的主题。让我们首先看看允许此类异构分布式事务的原子提交协议。 +我们将在本章后面回到恰好一次语义的主题。让我们首先看看允许此类异构分布式事务的原子提交协议。 #### XA 事务 {#xa-transactions} @@ -972,7 +978,7 @@ XA 假设你的应用程序使用网络驱动程序或客户端库与参与者 另一个问题是,由于 XA 需要与各种数据系统兼容,它必然是最低公分母。例如,它无法检测跨不同系统的死锁(因为这需要系统交换有关每个事务正在等待的锁的信息的标准化协议),并且它不适用于 SSI(参见["可串行化快照隔离(SSI)"](/ch8#sec_transactions_ssi)),因为这需要跨不同系统识别冲突的协议。 -这些问题在某种程度上是跨异构技术执行事务所固有的。但是,保持几个异构数据系统彼此一致仍然是一个真实而重要的问题,因此我们需要为其找到不同的解决方案。这可以做到,我们将在下一节和[待补充链接]中看到。 +这些问题在某种程度上是跨异构技术执行事务所固有的。但是,保持几个异构数据系统彼此一致仍然是一个真实而重要的问题,因此我们需要为其找到不同的解决方案。这可以做到,我们将在下一节和["派生数据与分布式事务"](/ch13#sec_future_derived_vs_transactions)中看到。 ### 数据库内部的分布式事务 {#sec_transactions_internal} @@ -991,11 +997,11 @@ XA 的最大问题可以通过以下方式解决: 为分布式事务提供的隔离级别取决于系统,但跨分片的快照隔离和可串行化快照隔离都是可能的。有关其工作原理的详细信息,请参见本章末尾引用的论文。 -#### 再谈精确一次消息处理 {#exactly-once-message-processing-revisited} +#### 再谈恰好一次消息处理 {#exactly-once-message-processing-revisited} -我们在["精确一次消息处理"](/ch8#sec_transactions_exactly_once)中看到,分布式事务的一个重要用例是确保某些操作精确生效一次,即使在处理过程中发生崩溃并且需要重试处理。如果你可以跨消息代理和数据库原子地提交事务,则当且仅当成功处理消息并且从处理过程产生的数据库写入被提交时,你可以向代理确认消息。 +我们在["恰好一次消息处理"](/ch8#sec_transactions_exactly_once)中看到,分布式事务的一个重要用例是确保某些操作恰好生效一次,即使在处理过程中发生崩溃并且需要重试处理。如果你可以跨消息代理和数据库原子地提交事务,则当且仅当成功处理消息并且从处理过程产生的数据库写入被提交时,你可以向代理确认消息。 -但是,你实际上不需要这样的分布式事务来实现精确一次语义。另一种方法如下,它只需要数据库中的事务: +但是,你实际上不需要这样的分布式事务来实现恰好一次语义。另一种方法如下,它只需要数据库中的事务: 1. 假设每条消息都有唯一的 ID,并且在数据库中有一个已处理消息 ID 的表。当你开始从代理处理消息时,你在数据库上开始一个新事务,并检查消息 ID。如果数据库中已经存在相同的消息 ID,你知道它已经被处理,因此你可以向代理确认消息并丢弃它。 2. 如果消息 ID 尚未在数据库中,你将其添加到表中。然后你处理消息,这可能会导致在同一事务中对数据库进行额外的写入。完成处理消息后,你提交数据库上的事务。 @@ -1004,7 +1010,7 @@ XA 的最大问题可以通过以下方式解决: 如果消息处理器在提交数据库事务之前崩溃,事务将被中止,消息代理将重试处理。如果它在提交后但在向代理确认消息之前崩溃,它也将重试处理,但重试将在数据库中看到消息 ID 并丢弃它。如果它在确认消息后但在从数据库中删除消息 ID 之前崩溃,你将有一个旧的消息 ID 留下,除了占用一点存储空间外不会造成任何伤害。如果在数据库事务中止之前发生重试(如果消息处理器和数据库之间的通信中断,这可能会发生),消息 ID 表上的唯一性约束应该防止两个并发事务插入相同的消息 ID。 -因此,实现精确一次处理只需要数据库中的事务——跨数据库和消息代理的原子性对于此用例不是必需的。在数据库中记录消息 ID 使消息处理*幂等*,因此可以安全地重试消息处理而不会重复其副作用。流处理框架(如 Kafka Streams)中使用类似的方法来实现精确一次语义,我们将在[待补充链接]中看到。 +因此,实现恰好一次处理只需要数据库中的事务——跨数据库和消息代理的原子性对于此用例不是必需的。在数据库中记录消息 ID 使消息处理具备*幂等性*,因此可以安全地重试消息处理而不会重复其副作用。流处理框架(如 Kafka Streams)中使用类似的方法来实现恰好一次语义,我们将在["容错"](/ch12#sec_stream_fault_tolerance)中看到。 但是,数据库内的内部分布式事务对于此类模式的可伸缩性仍然有用:例如,它们将允许消息 ID 存储在一个分片上,而消息处理更新的主数据存储在其他分片上,并确保跨这些分片的事务提交的原子性。 @@ -1022,7 +1028,7 @@ XA 的最大问题可以通过以下方式解决: 表 8-1. 各种隔离级别可能发生的异常总结 -| 隔离级别 | 脏读 | 读偏斜 | 幻读 | 丢失更新 | 写偏斜 | +| 隔离级别 | 脏读 | 读取偏差 | 幻读 | 丢失更新 | 写偏差 | |------|------|------|------|-------|------| | 读未提交 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 | | 读已提交 | ✓ 防止 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 | @@ -1035,17 +1041,17 @@ XA 的最大问题可以通过以下方式解决: 脏写 : 一个客户端覆盖另一个客户端已写入但尚未提交的数据。几乎所有事务实现都防止脏写。 -读偏斜 -: 客户端在不同时间点看到数据库的不同部分。某些读偏斜的情况也称为*不可重复读*。这个问题最常通过快照隔离来防止,它允许事务从对应于特定时间点的一致快照读取。它通常使用*多版本并发控制*(MVCC)实现。 +读取偏差 +: 客户端在不同时间点看到数据库的不同部分。某些读取偏差的情况也称为*不可重复读*。这个问题最常通过快照隔离来防止,它允许事务从对应于特定时间点的一致快照读取。它通常使用*多版本并发控制*(MVCC)实现。 丢失更新 : 两个客户端并发执行读-修改-写循环。一个覆盖另一个的写入而不合并其更改,因此数据丢失。某些快照隔离的实现会自动防止此异常,而其他实现需要手动锁(`SELECT FOR UPDATE`)。 -写偏斜 +写偏差 : 事务读取某些内容,根据它看到的值做出决定,并将决定写入数据库。但是,在进行写入时,决策的前提不再为真。只有可串行化隔离才能防止此异常。 幻读 -: 事务读取匹配某些搜索条件的对象。另一个客户端进行影响该搜索结果的写入。快照隔离防止直接的幻读,但写偏斜上下文中的幻读需要特殊处理,例如索引范围锁。 +: 事务读取匹配某些搜索条件的对象。另一个客户端进行影响该搜索结果的写入。快照隔离防止直接的幻读,但写偏差上下文中的幻读需要特殊处理,例如索引范围锁。 弱隔离级别可以防止某些异常,但让你(应用程序开发人员)手动处理其他异常(例如,使用显式锁定)。只有可串行化隔离可以防止所有这些问题。我们讨论了实现可串行化事务的三种不同方法: @@ -1058,13 +1064,13 @@ XA 的最大问题可以通过以下方式解决: 可串行化快照隔离(SSI) : 一种相对较新的算法,避免了前面方法的大部分缺点。它使用乐观方法,允许事务在不阻塞的情况下进行。当事务想要提交时,它会被检查,如果执行不可串行化,它将被中止。 -最后,我们研究了当事务分布在多个节点上时如何实现原子性,使用两阶段提交。如果这些节点都运行相同的数据库软件,分布式事务可以很好地工作,但跨不同存储技术(使用 XA 事务),2PC 是有问题的:它对协调器和驱动事务的应用程序代码中的故障非常敏感,并且与并发控制机制的交互很差。幸运的是,幂等性可以确保精确一次语义,而无需跨不同存储技术的原子提交,我们将在后面的章节中看到更多相关内容。 +最后,我们研究了当事务分布在多个节点上时如何实现原子性,使用两阶段提交。如果这些节点都运行相同的数据库软件,分布式事务可以很好地工作,但跨不同存储技术(使用 XA 事务),2PC 是有问题的:它对协调器和驱动事务的应用程序代码中的故障非常敏感,并且与并发控制机制的交互很差。幸运的是,幂等性可以确保恰好一次语义,而无需跨不同存储技术的原子提交,我们将在后面的章节中看到更多相关内容。 本章中的示例使用了关系数据模型。但是,如["多对象事务的需求"](/ch8#sec_transactions_need)中所讨论的,无论使用哪种数据模型,事务都是有价值的数据库功能。 -## 参考 +### 参考 [^1]: Steven J. Murdoch. [What went wrong with Horizon: learning from the Post Office Trial](https://www.benthamsgaze.org/2021/07/15/what-went-wrong-with-horizon-learning-from-the-post-office-trial/). *benthamsgaze.org*, July 2021. Archived at [perma.cc/CNM4-553F](https://perma.cc/CNM4-553F) diff --git a/content/zh/ch9.md b/content/zh/ch9.md index 7f4e153..acbf80f 100644 --- a/content/zh/ch9.md +++ b/content/zh/ch9.md @@ -4,9 +4,11 @@ weight: 209 breadcrumbs: false --- + + ![](/map/ch08.png) -> *它们是有趣的东西,意外。在你遇到它们之前,你永远不会遇到它们。* +> *意外这东西挺有意思:你没碰上之前,它就从来不会发生。* > > A.A. 米尔恩,《小熊维尼和老灰驴的家》(1928) @@ -75,11 +77,11 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 那么,如果 TCP 提供 "可靠性",这是否意味着我们不再需要担心网络不可靠?不幸的是不是。如果在某个超时时间内没有收到确认,它会认为数据包一定已经丢失,但 TCP 也无法判断是出站数据包还是确认丢失了。尽管 TCP 可以重新发送数据包,但它不能保证新数据包也会通过。如果网线被拔掉,TCP 不能为你重新插上它。最终,在可配置的超时后,TCP 放弃并向应用程序发出错误信号。 -如果 TCP 连接因错误而关闭 —— 也许是因为远程节点崩溃了,或者是因为网络被中断了 —— 你不幸地无法知道远程节点实际处理了多少数据 [^6]。即使 TCP 确认数据包已交付,这仅意味着远程节点上的操作系统内核收到了它,但应用程序可能在处理该数据之前就崩溃了。如果你想确保请求成功,你需要来自应用程序本身的积极响应 [^7]。 +如果 TCP 连接因错误而关闭 —— 也许是因为远程节点崩溃了,或者是因为网络被中断了 —— 你不幸地无法知道远程节点实际处理了多少数据 [^6]。即使 TCP 确认数据包已交付,这也仅意味着远程节点上的操作系统内核收到了它,但应用程序可能在处理该数据之前就崩溃了。如果你想确保请求成功,你需要应用层返回明确的成功响应 [^7]。 尽管如此,TCP 非常有用,因为它提供了一种方便的方式来发送和接收太大而无法装入一个数据包的消息。一旦建立了 TCP 连接,你还可以使用它来发送多个请求和响应。这通常是通过首先发送一个标头来完成的,该标头以字节为单位指示后续消息的长度,然后是实际消息。HTTP 和许多 RPC 协议(见 ["通过服务的数据流:REST 和 RPC"](/ch5#sec_encoding_dataflow_rpc))就是这样工作的。 -### 网络故障的实践 {#sec_distributed_network_faults} +### 实践中的网络故障 {#sec_distributed_network_faults} 我们已经建立计算机网络几十年了 —— 人们可能希望到现在我们已经弄清楚如何使它们可靠。不幸的是,我们还没有成功。有一些系统研究和大量轶事证据表明,网络问题可能出人意料地常见,即使在由一家公司运营的受控环境(如数据中心)中也是如此 [^8]: @@ -108,7 +110,7 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 许多系统需要自动检测故障节点。例如: -* 负载均衡器需要停止向已死亡的节点发送请求(即,将其 *移出轮转*)。 +* 负载均衡器需要停止向已死亡的节点发送请求(即,将其 *从轮询池中摘除*)。 * 在具有单主复制的分布式数据库中,如果主节点失效,其中一个从节点需要被提升为新的主节点(见 ["处理节点中断"](/ch6#sec_replication_failover))。 不幸的是,网络的不确定性使得很难判断节点是否正常工作。在某些特定情况下,你可能会得到一些明确告诉你某事不工作的反馈: @@ -134,6 +136,8 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 不幸的是,我们使用的大多数系统都没有这些保证:异步网络具有 *无界延迟*(即,它们尝试尽快交付数据包,但数据包到达所需的时间没有上限),大多数服务器实现无法保证它们可以在某个最大时间内处理请求(见 ["响应时间保证"](/ch9#sec_distributed_clocks_realtime))。对于故障检测,系统大部分时间快速运行是不够的:如果你的超时很低,往返时间的瞬时峰值就足以使系统失去平衡。 + + #### 网络拥塞和排队 {#network-congestion-and-queueing} 开车时,道路网络上的行驶时间通常因交通拥堵而变化最大。同样,计算机网络上数据包延迟的可变性最常是由于排队 [^27]: @@ -149,6 +153,8 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 -------- + + > [!TIP] TCP 与 UDP > > 一些对延迟敏感的应用程序,如视频会议和 IP 语音(VoIP),使用 UDP 而不是 TCP。这是可靠性和延迟可变性之间的权衡:由于 UDP 不执行流量控制并且不重传丢失的数据包,它避免了网络延迟可变的一些原因(尽管它仍然容易受到交换机队列和调度延迟的影响)。 @@ -189,6 +195,8 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 -------- + + > [!TIP] 延迟和资源利用率 > > 更一般地说,你可以将可变延迟视为动态资源分区的结果。 @@ -417,7 +425,7 @@ while (true) { -## 知识、真相和谎言 {#sec_distributed_truth} +## 知识、真相与谎言 {#sec_distributed_truth} 到目前为止,在本章中,我们已经探讨了分布式系统与在单台计算机上运行的程序的不同之处:没有共享内存,只有通过不可靠的网络进行消息传递,具有可变延迟,系统可能会遭受部分失效、不可靠的时钟和处理暂停。 @@ -471,7 +479,7 @@ while (true) { 术语 *僵尸* 有时用于描述尚未发现失去租约的前租约持有者,并且仍在充当当前租约持有者。由于我们不能完全排除僵尸,我们必须确保它们不能以脑裂的形式造成任何损害。这被称为 *隔离* 僵尸。 -一些系统试图通过关闭僵尸来隔离它们,例如通过断开它们与网络的连接 [^9]、通过云提供商的管理界面关闭 VM,甚至物理关闭机器 [^87]。这种方法被称为 *向对方节点头部开枪* 或 STONITH。不幸的是,它存在一些问题:它不能防范像 [图 9-5](/ch9#fig_distributed_lease_delay) 中那样的大网络延迟;可能会发生所有节点相互关闭的情况 [^19];到检测到僵尸并关闭它时,可能已经太晚了,数据可能已经被损坏。 +一些系统试图通过关闭僵尸来隔离它们,例如通过断开它们与网络的连接 [^9]、通过云提供商的管理界面关闭 VM,甚至物理关闭机器 [^87]。这种方法被称为 *对端节点爆头*(STONITH)。不幸的是,它存在一些问题:它不能防范像 [图 9-5](/ch9#fig_distributed_lease_delay) 中那样的大网络延迟;可能会发生所有节点相互关闭的情况 [^19];到检测到僵尸并关闭它时,可能已经太晚了,数据可能已经被损坏。 一个更强大的隔离解决方案,可以防范僵尸和延迟请求,如 [图 9-6](/ch9#fig_distributed_fencing) 所示。 @@ -487,7 +495,7 @@ while (true) { -------- -在 [图 9-6](/ch9#fig_distributed_fencing) 中,客户端 1 获得带有令牌 33 的租约,但随后进入长时间暂停,租约过期。客户端 2 获得带有令牌 34 的租约(数字总是增加),然后将其写请求发送到存储服务,包括令牌 34。稍后,客户端 1 恢复生机并将其写入发送到存储服务,包括其令牌值 33。然而,存储服务记得它已经处理了具有更高令牌编号(34)的写入,因此它拒绝带有令牌 33 的请求。刚刚获得租约的客户端必须立即向存储服务进行写入,一旦该写入完成,任何僵尸都被隔离了。 +在 [图 9-6](/ch9#fig_distributed_fencing) 中,客户端 1 获得带有令牌 33 的租约,但随后进入长时间暂停,租约过期。客户端 2 获得带有令牌 34 的租约(数字总是增加),然后将其写请求发送到存储服务,包括令牌 34。稍后,客户端 1 恢复执行并将其写入发送到存储服务,包括其令牌值 33。然而,存储服务记得它已经处理了具有更高令牌编号(34)的写入,因此它拒绝带有令牌 33 的请求。刚刚获得租约的客户端必须立即向存储服务进行写入,一旦该写入完成,任何僵尸都被隔离了。 如果 ZooKeeper 是你的锁服务,你可以使用事务 ID `zxid` 或节点版本 `cversion` 作为隔离令牌 [^85]。使用 etcd,修订号与租约 ID 一起起着类似的作用 [^89]。Hazelcast 中的 FencedLock API 明确生成隔离令牌 [^90]。 @@ -539,6 +547,8 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 同样,如果协议可以保护我们免受漏洞、安全妥协和恶意攻击,那将是很有吸引力的。不幸的是,这也不现实:在大多数系统中,如果攻击者可以破坏一个节点,他们可能可以破坏所有节点,因为它们可能运行相同的软件。因此,传统机制(身份验证、访问控制、加密、防火墙等)仍然是防范攻击者的主要保护。 + + #### 弱形式的谎言 {#weak-forms-of-lying} 尽管我们假设节点通常是诚实的,但向软件添加防范弱形式 "谎言" 的机制可能是值得的 —— 例如,由于硬件问题、软件错误和配置错误导致的无效消息。这种保护机制不是完全的拜占庭容错,因为它们无法抵御坚定的对手,但它们仍然是朝着更好可靠性迈出的简单而务实的步骤。例如: @@ -671,7 +681,7 @@ DST 要求模拟器能够控制所有非确定性来源,例如网络延迟。 DST 提供了超越可重放性的几个优势。Antithesis 等工具试图通过在发现不太常见的行为时将测试执行分支为多个子执行来探索应用程序代码中的许多不同代码路径。由于确定性测试通常使用模拟时钟和网络调用,因此此类测试可以比挂钟时间运行得更快。例如,TigerBeetle 的时间抽象允许模拟模拟网络延迟和超时,而实际上不需要触发超时的全部时间长度。这些技术允许模拟器更快地探索更多代码路径。 -# 确定性的力量 +#### 确定性的力量 {#sidebar_distributed_determinism} 非确定性是我们在本章中讨论的所有分布式系统挑战的核心:并发性、网络延迟、进程暂停、时钟跳跃和崩溃都以不可预测的方式发生,从系统的一次运行到下一次运行都不同。相反,如果你能使系统确定性,那可以极大地简化事情。 @@ -691,7 +701,7 @@ DST 提供了超越可重放性的几个优势。Antithesis 等工具试图通 * 节点的时钟可能与其他节点严重不同步(尽管你尽最大努力设置了 NTP),它可能会突然向前或向后跳跃,而依赖它是危险的,因为你很可能没有一个好的时钟置信区间度量。 * 进程可能在其执行的任何时刻暂停相当长的时间,被其他节点宣告死亡,然后再次恢复活动而没有意识到它曾暂停。 -这种 *部分失败* 可能发生的事实是分布式系统的决定性特征。每当软件尝试做任何涉及其他节点的事情时,都有可能偶尔失败、随机变慢或根本没有响应(并最终超时)。在分布式系统中,我们尝试将对部分失败的容忍构建到软件中,这样即使某些组成部分出现故障,整个系统也可以继续运行。 +这种 *部分失效* 可能发生的事实是分布式系统的决定性特征。每当软件尝试做任何涉及其他节点的事情时,都有可能偶尔失败、随机变慢或根本没有响应(并最终超时)。在分布式系统中,我们尝试将对部分失效的容忍构建到软件中,这样即使某些组成部分出现故障,整个系统也可以继续运行。 要容忍故障,第一步是 *检测* 它们,但即使这样也很困难。大多数系统没有准确的机制来检测节点是否已失败,因此大多数分布式算法依赖超时来确定远程节点是否仍然可用。然而,超时无法区分网络和节点故障,可变的网络延迟有时会导致节点被错误地怀疑崩溃。处理跛行节点(limping nodes)更加困难,这些节点正在响应但速度太慢而无法做任何有用的事情。 @@ -842,4 +852,4 @@ DST 提供了超越可重放性的几个优势。Antithesis 等工具试图通 [^131]: Rupak Majumdar and Filip Niksic. [Why is random testing effective for partition tolerance bugs?](https://dl.acm.org/doi/pdf/10.1145/3158134) *Proceedings of the ACM on Programming Languages* (PACMPL), volume 2, issue POPL, article no. 46, December 2017. [doi:10.1145/3158134](https://doi.org/10.1145/3158134) [^132]: FoundationDB project authors. [Simulation and Testing](https://apple.github.io/foundationdb/testing.html). *apple.github.io*. Archived at [perma.cc/NQ3L-PM4C](https://perma.cc/NQ3L-PM4C) [^133]: Alex Kladov. [Simulation Testing For Liveness](https://tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness/). *tigerbeetle.com*, July 2023. Archived at [perma.cc/RKD4-HGCR](https://perma.cc/RKD4-HGCR) -[^134]: Alfonso Subiotto Marqués. [(Mostly) Deterministic Simulation Testing in Go](https://www.polarsignals.com/blog/posts/2024/05/28/mostly-dst-in-go). *polarsignals.com*, May 2024. Archived at [perma.cc/ULD6-TSA4](https://perma.cc/ULD6-TSA4) \ No newline at end of file +[^134]: Alfonso Subiotto Marqués. [(Mostly) Deterministic Simulation Testing in Go](https://www.polarsignals.com/blog/posts/2024/05/28/mostly-dst-in-go). *polarsignals.com*, May 2024. Archived at [perma.cc/ULD6-TSA4](https://perma.cc/ULD6-TSA4)