我一直在思考分布式系统工程师在工作中学到的教训。我们大部分的教导都来自于在生产环境中犯过的错误留下的伤痕。这些伤痕固然是有用的提醒,但让更多的工程师能够完整地保留手指会更好。

新的系统工程师在自我学习中会遇到分布式计算的谬论和CAP定理。但这些都是抽象的概念,缺乏针对经验不足的工程师直接可行的建议。让人惊讶的是,新工程师在开始工作时所了解到的背景信息是如此之少。

下面是我作为一名分布式系统工程师学到的一些经验教训,值得告诉新工程师。其中一些经验是微妙的,一些是令人惊讶的,但没有一条是有争议的。这个列表是为了引导新的分布式系统工程师思考他们所从事领域的问题,虽然不是全面的,但是是一个很好的开始。

这个列表最糟糕的特点是它主要关注技术问题,很少讨论工程师可能遇到的社交问题。由于分布式系统需要更多的机器和资本,它们的工程师往往需要与更多的团队和更大的组织合作。社交问题通常是任何软件开发者工作中最困难的部分,也许对于分布式系统的开发来说尤其如此。

我们的背景、教育和经验使我们倾向于采用技术解决方案,即使社交解决方案可能更高效、更令人满意。让我们试着纠正这一点。与计算机相比,人们并不那么挑剔,即使他们的接口没有那么标准化。

好了,我们开始吧。

分布式系统是不同的,因为它们经常失败。 当被问及是什么将分布式系统与软件工程的其他领域区分开来时,这位新工程师经常引用延迟,认为这是使分布式计算变得困难的原因。

但他们错了。分布式系统工程的区别在于失败的概率,更糟糕的是,部分失败的概率。如果格式良好的互斥锁解锁失败并出现错误,我们可以假设该过程不稳定并使其崩溃。但是,分布式互斥锁解锁的失败必须内置到锁定协议中。

没有从事过分布式计算的系统工程师会想出一些想法,比如“好吧,它只是将写入发送到两台机器”或“它会不断重试写入,直到它成功”。这些工程师还没有完全接受(尽管他们通常在理智上认识到)网络系统比只存在于一台机器上的系统更容易失败,而且故障往往是部分的而不是全部的。

其中一个写入可能会成功,而另一个写入失败,那么现在我们如何获得一致的数据视图呢?这些部分故障更难推理。

交换机故障、垃圾回收暂停导致领导者“消失”、套接字写操作似乎成功但实际上在另一台机器上失败、一台机器上的慢速磁盘驱动引起整个集群中的通信协议变慢等等。从本地内存读取比通过几个交换机读取更稳定。。

为失败而设计!

编写健壮的分布式系统比编写健壮的单机系统成本更高。 与单机解决方案相比,创建强大的分布式解决方案需要更多的资金,因为只有许多计算机才会发生故障。虚拟机和云技术使分布式系统工程更便宜,但不像能够在您已经拥有的计算机上进行设计、实施和测试那样便宜。并且存在难以在单台机器上复制的故障条件。

无论是因为它们只发生在比共享机器上可以容纳的数据集大小大得多的数据集上,还是在数据中心的网络条件下,分布式系统往往需要实际的(而不是模拟的)分发来清除它们的错误。当然,模拟非常有用。

健壮的开源分布式系统远不如健壮的单机系统常见。长时间运行多台机器的成本是开源社区的负担。业余爱好者和业余爱好者是开源软件的引擎,他们没有可用的财务资源来探索或解决分布式系统将遇到的许多问题。业余爱好者在空闲时间使用他们已经拥有的机器编写开源代码以取乐。

要找到愿意启动、维护和支付一堆机器的开源开发人员要困难得多。

为公司实体工作的工程师已经填补了部分空缺。但是,其组织的优先级可能与组织的优先级不一致。

虽然开源社区中的一些人已经意识到了这个问题,但它还没有得到解决。这很难。

协调非常困难。 尽可能避免协调机器。这通常被描述为“水平可伸缩性”。水平可扩展性的真正诀窍是独立性——能够将数据传送到机器上,从而将这些机器之间的通信和共识保持在最低限度。每当两台机器必须就某件事达成一致时,服务就会变得更难实现。

信息的传播速度是有上限的,网络通信比你想象的要脆弱,你对什么是共识的想法可能是错误的。在这里,了解 Two Generals拜占庭将军 的问题很有用。(哦,Paxos真的很难实现;这不是脾气暴躁的老工程师认为他们比你更了解。)

如果你能把你的问题放在内存中,那可能是微不足道的。 对于分布式系统工程师来说,一台机器的本地问题很容易解决。当数据距离几个开关而不是几个指针取消引用时,弄清楚如何快速处理数据会更难。在分布式系统中,自计算机科学开始以来就记录的陈旧效率技巧不再适用。

对于在单台机器上运行的算法,有大量的文献和实现,因为大部分计算都是在单一的、不协调的机器上完成的。对于分布式系统来说,存在的数量要少得多。

“很慢”是你调试过的最难的问题。 “速度慢”可能意味着执行用户请求所涉及的一个或多个系统速度较慢。这可能意味着跨多台计算机的转换管道的一个或多个部分速度较慢。“它很慢”很难,部分原因是问题陈述没有提供很多关于缺陷位置的线索。部分故障,即那些没有出现在你通常查找的图表上的故障,潜伏在一个黑暗的角落里。

而且,在退化变得非常明显之前,您将无法获得那么多的资源(时间、金钱和工具)来解决它。Dapper 和 Zipkin 的出现是有原因的。

**在整个系统中实现反压机制。**反压是服务系统向请求系统发出故障信号,并由请求系统处理这些故障以防止自身和服务系统过载。设计反压意味着在负载过重和系统故障时限制资源使用。这是创建健壮的分布式系统的基本构建块之一。

实现反压通常涉及以下两种方式之一:要么将新消息丢弃,要么在资源受限或发生故障时将错误返回给用户(并在两种情况下增加指标)。对于与其他系统的连接和请求,超时和指数退避也是至关重要的。

如果没有反压机制,可能会发生级联故障或意外消息丢失。当一个系统无法处理另一个系统的故障时,它倾向于将故障传播给依赖它的另一个系统。

寻找实现部分可用性的方法。 部分可用性是指即使系统的某些部分发生故障,仍能返回一些结果。

搜索是一个理想的案例来探讨这个问题。搜索系统在结果质量和用户等待时间之间进行权衡。一个典型的搜索系统会设置一个时间限制,如果在搜索所有文档之前超过了时间限制,它会返回已经收集到的结果。这使得搜索在面对间歇性减速和错误时更容易扩展,因为这些故障被视为无法搜索所有文档的情况。系统允许返回部分结果给用户,并增加了其弹性。

再以Web应用程序中的私密消息功能为例。无论你做什么,私密消息的存储机器都可能同时宕机,用户会注意到这一点。那么在这个系统中,我们希望出现什么样的部分故障呢?

这需要一些思考。一般来说,人们对于无法使用私密消息功能(或许是其他一些用户也无法使用)会更容忍,而对于所有用户中有一些消息丢失则更为不满意。如果服务过载或其中一台机器故障,只让一小部分用户无法使用比让更大比例的用户丢失数据更可取。除此之外,我们可能不希望一个无关的功能(比如公共图片上传)受到影响,只因为私密消息功能出现问题。我们愿意付出多少努力来保持这些故障域的独立?

能够在部分可用性中识别这些权衡是很有帮助的。

指标是完成工作的唯一途径。 公开指标(如延迟百分比、特定操作的计数器增加、变化速率等)是弥合您对系统在生产环境中所做的假设与实际情况之间差距的唯一途径。了解系统在第20天的行为与第15天的行为有何不同,是成功工程和失败巫术之间的区别。当然,指标是了解问题和行为的必要手段,但并不足以知道接下来该做什么。

稍微提一下日志记录。日志文件是很有用的,但它们往往会欺骗人。例如,很常见的情况是几个错误类别的日志记录占据了日志文件的很大比例,但实际上在请求中的比例非常低。因为在大多数情况下记录成功是多余的(并且在大多数情况下会耗尽磁盘空间),而且工程师经常错误地猜测哪些错误类别是有用的,所以日志文件中充斥着各种奇怪的信息。最好以一种假设有人会阅读日志但没有看过代码的方式进行日志记录。

我见过很多次由于另一位工程师(或者我自己)过于强调日志中的一些奇怪现象而导致故障延长,而没有先将其与指标进行对比。我还见过另一位工程师(或者我自己)从少数几行日志中推断出整套失败行为的情况。但请注意:a) 我们之所以记住这些成功案例,是因为它们非常罕见;b) 除非指标或实验证实了故事,否则你并不是福尔摩斯(Sherlock)。

使用百分位数而不是平均值。 在绝大多数分布式系统中,百分位数(50th、99th、99.9th、99.99th)比平均值更准确、更有信息量。使用平均值假设正在评估的指标遵循正态分布曲线,但在实践中,这只适用于少数工程师关心的指标。 “平均延迟” 是一个常见的报告指标,但我从未见过一个延迟遵循正态分布曲线的分布式系统。如果指标不遵循正态分布曲线,平均值就没有意义,会导致错误的决策和理解。通过使用百分位数来避免这个陷阱。默认使用百分位数,你将更好地了解用户真正看待你的系统的方式。

学会估算你的容量。 因此,你将会知道一天有多少秒。知道你需要多少台机器来执行一个任务是一个持久系统和一个在工作开始3个月后需要被替换的系统之间的区别。或者更糟糕的是,在你完成将其投入生产之前就需要被替换。

以推文为例。在一台普通的机器上,你可以将多少个推文ID存放在内存中? 嗯,到2012年底,一台典型的机器有24 GB的内存,你需要4-5 GB的开销来运行操作系统,另外还需要至少几个GB来处理请求,而一个推文ID占用8个字节。这是你可能会进行的粗略计算。Jeff Dean的《每个人都应该知道的数字》幻灯片是一个很好的期望设定工具。

特性标志(Feature flags)是基础设施推出的方式。 特性标志是产品工程师在系统中推出新功能的常用方式。特性标志通常与前端A/B测试相关联,用于向部分用户展示新的设计或功能。但它们也是替换基础设施的强大方式。

很多项目因为选择了“大切换”或一系列“大切换”,然后由于发现了太晚的错误而被迫回滚,从而导致失败。通过使用特性标志,你将增强对项目的信心并减轻失败的成本。

假设你要从单一数据库迁移到一个隐藏了新存储解决方案细节的服务。使用特性标志,你可以逐步将写操作转移到新服务,与对旧数据库的写操作并行进行,以确保其写路径的正确性和速度足够快。在写路径达到100%并将数据回填到服务的数据存储完成后,你可以使用单独的特性标志开始从该服务读取,而不在用户响应中使用该数据,以检查性能问题。另一个特性标志可以用于比较从旧系统和新系统读取的数据。最后一个标志可以用于逐步增加从新系统进行“真实”读取操作。

通过将部署拆分为多个步骤,并通过特性标志提供快速和部分反应,你可以更容易地在扩展过程中发现错误和性能问题,而不是在“一次性发布”时发现。如果出现问题,你只需立即将特性标志设置降低到较低(可能是零)的设置。通过调整速率,你可以在不同的流量量级下进行调试和实验,知道任何问题都不会造成灾难。使用特性标志,你还可以选择其他迁移策略,例如基于每个用户的方式将请求转移到新系统,以提供对新系统的更好洞察。当你的新服务仍在原型阶段时,你可以将特性标志设置为较低,以减少新系统的资源消耗。

现在,特性标志对于经典训练的开发人员或新工程师来说可能听起来像是一堆条件语句的可怕混乱。而使用特性标志意味着接受多个基础设施和数据版本是一种常态,而不是罕见情况。这是一个深刻的教训。在单机系统中有效的方法在面对分布式问题时有时会失败。

特性标志最好被理解为一种权衡,以在代码和一个系统中交换局部复杂性,以获得全局的简单性和弹性。

**明智地选择ID空间。 **你为系统选择的ID空间将塑造你的系统。

要获取数据所需的ID数量越多,就越有选择将数据进行分区的选项。要获取数据所需的ID数量越少,消费你的系统输出就越容易。

以Twitter API的第一个版本为例。所有获取、创建和删除推文的操作都是基于每个推文的单个数字ID进行的。推文ID是一个简单的64位数字,不与任何其他数据相关联。随着推文数量的增加,人们意识到,如果将同一用户的所有推文存储在同一台机器上,可以有效地构建用户的推文时间线和其他用户订阅的时间线。

但公共API要求每个推文只能通过推文ID进行访问。要按用户对推文进行分区,需要构建一个查找服务,它知道哪个用户拥有哪个推文ID。如果必要,这是可行的,但成本不可忽视。

另一种选择是在任何推文查找时要求用户ID,并且最初只是使用推文ID进行存储,直到用户分区存储上线。另一种选择是在推文ID本身中包含用户ID,这样做的代价是推文ID不再具有k-sortable和数字的特性。

要注意在ID中明确和隐含地编码了哪种类型的信息。客户端可能利用ID的结构来去匿名化私人数据,以意想不到的方式爬取你的系统(自增ID通常是一个痛点),或进行其他一系列攻击。

利用数据局部性。 将数据的处理和缓存与其持久存储保持靠近,处理效率更高,同时保持缓存一致性和快速性更容易。与指针解引用和fread(3)相比,网络故障和延迟更多。

当然,数据局部性意味着在空间上靠近,但也意味着在时间上靠近。如果多个用户几乎同时进行相同的昂贵请求,也许可以将它们的请求合并为一个请求。如果在相近的时间内发出了多个相同类型的数据请求,可以将它们合并为一个更大的请求。这样做通常可以降低通信开销并更容易进行故障管理。

**将缓存数据写回持久存储是不好的。 **这种情况在比想象中更多的系统中发生。尤其是那些最初由缺乏分布式系统经验的人设计的系统。你将继承许多具有此缺陷的系统。如果实施者谈到“俄罗斯套娃缓存”,你很有可能遇到非常明显的错误。这个条目本可以从列表中省略,但我对此特别痛恨。这种缺陷的常见表现是用户信息(例如屏幕名称、电子邮件和哈希密码)神秘地恢复到先前的值。

计算机的能力超乎你的想象。 现在的现场存在很多关于机器能力的错误信息,这些信息来自于没有太多经验的从业者。

在2012年底,轻型Web服务器拥有6个或更多处理器,24GB内存和比你能使用的更多磁盘空间。在现代语言运行时环境中,一个相对复杂的CRUD应用程序在单个机器上可以在几百毫秒内轻松处理数千个请求每秒。这还只是下限。在大多数情况下,每台机器每秒处理数百个请求并不值得夸耀。

获得更高的性能并不难,尤其是如果你愿意对应用程序进行性能分析,并根据测量结果引入效率。

**利用CAP定理对系统进行批判。 **CAP定理不能用作构建系统的基础。它不是一个可以作为第一原则并从中推导出一个可行系统的定理。它的适用范围过于广泛,可能的解决方案空间也过于宽泛。

然而,CAP定理非常适合用于对分布式系统设计进行批判,并理解需要做出的权衡。通过对系统设计进行迭代,考虑CAP对其子系统施加的约束,最终可以得到更好的设计。作业中,将CAP定理的约束应用于俄罗斯套娃缓存的实际实现。

最后需要注意的是:在一致性(C)、可用性(A)和分区容忍性(P)中,不能选择CA

**提取服务。 **这里的"服务"指的是"一个包含高级逻辑的分布式系统,通常具有请求-响应式的API"。要留意那些如果存在于一个单独的服务中而不是你的系统中,将更容易进行的代码更改。

提取出一个服务提供了封装的好处,通常与创建库相提并论。然而,提取出一个服务改进了创建库的方式,因为它允许更快、更容易地部署变更,而不像升级客户系统中的库那样麻烦。(当然,如果提取出的服务难以部署,那么客户系统将变得更容易部署。)这种便利是由于较小、提取出的服务中的代码和操作依赖较少,并且其创建的严格边界使得很难"走捷径",而库则允许这种走捷径。这些走捷径通常会使迁移内部或客户系统到新版本变得更加困难。

当存在多个客户系统时,使用服务的协调成本也比使用共享库要低得多。即使不需要进行API更改,升级库也需要协调每个客户系统的部署。如果部署次序颠倒,可能会导致数据损坏(而且很难预测这种情况),这使得升级库变得更加困难。如果客户系统由不同的维护者负责,升级库的社交协调成本也比部署服务更高。让其他人意识到并愿意升级是非常困难的,因为他们的优先事项可能与你的不一致。

典型的服务使用案例是隐藏一个将要进行变更的存储层。提取出的服务具有更方便且表面积更小的API,与其前端的存储层相比。通过提取服务,客户系统无需了解迁移到新的存储系统或格式的复杂性,只需要评估新服务中肯定会发现的与新存储布局相关的错误。

在执行此操作时,需要考虑许多操作和社交问题。在这里无法对它们进行充分阐述。需要撰写另一篇文章对此进行详细说明。

我对我的审稿人Bill de hÓra、Coda Hale、JD Maturen、Micaela McDonald和Ted Nyman表示衷心感谢。你们的见解和关心是无价的。

更新(2016-08-15):我为每个部分添加了永久链接,并对协调、数据本地性、功能标志和背压等部分的文本进行了一些清理。

原文链接:Notes on Distributed Systems for Young Bloods