供新人们阅读的关于分布式系统的注意事项
刚入行的系统工程师将 分布式计算的谬论
和 CAP定理
作为他们自我教育的一部分。但是这些都是抽象的内容,缺少对零经验的工程师开始行动所需要的直接且可执行的建议。很令人惊讶新人们在起步时所能获取到的背景信息是这么少。
下面是我作为一个分布式系统工程师所学到的一些教训,值得每个新工程师一听。其中有些很微妙,有些令人惊讶,但没有一个是有争议的。这个列表供所有入门分布式系统工程师阅读,以指导他们对所从事领域的思考。虽然它并不全面,但它是一个好的开始。
该列表它最糟糕的特点是它只关注技术问题,很少讨论工程师可能遇到的社交问题。由于分布式系统需要更多的机器和更多的资本,因此他们的工程师倾向于与更多的团队和更大的组织协作。社交问题通常是每个软件工程师工作中的难点,也许在分布式系统开发中甚是如此。
我们的背景,教育和经验使我们偏向技术解决方案,即使是从社会角度解决会更高效,更愉悦。那么让我们尝试纠正它。人不会像计算机那么挑剔,即使人的“接口”的标准化更低。
好了,那我们开始吧。
-
分布式系统有所不同是因为它们经常发生故障。
当被问及是什么将分布式系统与软件工程的其他领域区分开时,新人通常会提到延迟,认为正是延迟使得分布式计算变得困难。
但他们错了。使分布式系统工程与众不同的是失败的概率,更糟糕的是部分失败的概率。如果一个格式良好的互斥锁解锁失败并出现错误,我们可以假设是进程不稳定并使其崩溃。但是分布式互斥锁解锁的失败必须被内置到锁协议中。
缺少分布式计算经验的系统工程师会提出这样的想法:“好吧,它只会把写操作发送到两台机器上”或者是“它会一直重复写操作直至成功”。这些工程师还没有完全接受(尽管他们通常在智力上认识到)网络系统的故障要比仅存在于单个计算机上的系统更多,并且故障往往是部分故障而不是全部故障。一个写操作可能成功,而另一个失败,那么现在我们如何获得一致的数据视图呢?这些部分的失败更加难以解释。
交换机故障,垃圾回收暂停使得领导者“消失”,套接字写操作似乎成功了但实际上在另一台机器上失败了,计算机上的慢速磁盘使得整个集群中的通信协议缓慢,等等。从本地内存中读取比跨越多个交换机读取更稳定。
因此我们需要容错设计。
- 编写健壮的分布式系统比编写健壮的单机系统花费更多。
打造一个健壮的分布式解决方案比一个单机方案需要更多资金,因为存在仅会在多台机器时才出现的故障。虚拟机跟云技术使得分布式系统工程更便宜,但仍不如在已有的计算机上进行设计、实现和测试便宜。以及有些故障情况很难在单机上复现。无论是因为它们只出现在比共享机器所能容纳的大得多的数据集上,还是在数据中心的网络条件下,分布式系统往往需要实际的而不是模拟的分布来消除它们的bug。当然,模拟有时是很有用的。
- 健壮的开源的分布式系统比健壮的单机系统少得多。
长时间运行多台机器的花费是开源社区的一大负担。业余和兴趣爱好者是开源软件的源动力,但是他们没有足够的财力资源来探索或者解决分布式系统的许多问题。业余和兴趣爱好者在空闲时间在已有的机器上编写开源软件,以此作为一种乐趣。但是若要找到愿意启动,维护和购买一堆机器的开源软件开发者,那将很难。
为企业实体工作的工程师弥补了其中的一些不足,但是他们组织的优先级可能跟你的组织优先级不同。
虽然开源社区中的一些人已经意识到这个问题,但仍未解决。这是件棘手的事。
-
协调是非常困难的
。
尽可能避免协调机器。这通常被称为“水平可伸缩性”。水平可伸缩性的真正诀窍是独立性,能够将数据传输到多台机器,使这些机器之间的通信和共识保持在最低限度。每当两台机器必须在某些事情上达成一致时,服务如何实现就会变得更加困难。信息的传播速度是有上限的,网络通信比你所想象的要脆弱,你对共识的理解也可能是错误的。了解 两将军问题
和 拜占庭将军问题
在此会很有用。( Paxos真的很难实现
,这不是某个脾气暴躁的老工程师在此危言耸听。)
- 如果你能在内存中解决你的问题,那么它可能是微不足道的。
对一个分布式系统工程师而言,本地单机问题很容易。当数据在几个交换机之外而不是在几个指针引用以外时,计算出如何快速传输数据会更困难。在分布式系统中,从计算机科学开始就记录在案的陈旧的效率技巧不再适用。由于大多数计算都是在单台不协调的机器上完成,因此有大量的文献和实现可用于在单机运行的算法上。对于分布式系统来说,这样的系统明显较少。
- “系统很慢”将会是你要调试的最困难的问题。
“很慢”可能是指执行用户请求涉及的一个或多个系统速度很慢,也可能是跨多节点的数据传输流水线中的一个或多个部分慢。“很慢”的确很难解释,部分原因是问题陈述没有提供很多有关缺陷问题的线索。部分失败,指那些通常不会出现在你常查看的图表上的那些,它们正潜伏在某个黑暗的角落里。而且,在问题变得非常明显之前,你不会获得足够多的资源(指时间,金钱和工具)来解决它。 Dapper
和 Zipkin
就是为此诞生的。
- 在整个系统中实施背压。
背压是从服务系统到请求系统的故障信号,以及请求系统如何处理这些故障以防止自身和服务系统过载。设计被压意味着在过载和系统故障时限制资源的使用。这是创建稳健的分布式系统的基本构件之一。
背压的实现通常涉及当资源变得有限或故障发生时丢弃新报文,或者是直接向用户报错(在这两种情况下增加度量指标)。超时和对其他系统的连接和请求的指数回退也是必要的。
如果缺少适当的背压机制,则很有可能会发生级联故障或意外消息丢失。当一个系统无法处理另一个系统的故障时,它往往会往另一个依赖它的系统发出故障。
- 寻找部分可用的方法。
部分可用性是指即使系统的某些部分故障,也能够返回一些结果。
搜索引擎是一个很好的案例。搜索系统需要权衡搜索结果的好坏和用户等待的时长。普遍的搜索系统对搜索文档的时间设定了一个期限,如果时间期限在搜集完所有文档之前过期,那么它将返回其收集到的任何结果。这使得搜索更容易在时断时续的慢速情况下扩展,因为这些故障被视为无法搜索所有文档。该系统允许返回部分结果给用户,这提高了系统的弹性。
思考下web应用中的私信功能。在某种程度上,无论你做什么,足够多的用于存储私信的机器在宕机时,你的用户将同时注意到这一点。那么在这样的系统中我们需要怎样的部分失效呢?
这需要好好思考下。通常人们更愿意接受私信功能暂不可用,而不是所有用户会丢失部分信息。如果服务过载或其中一台机器宕机,仅退出一小部分用户群比丢失更大一部分用户群的数据更可取。除此之外,我们可能不希望一个无关的特性,比如公共图片上传,仅仅因为私信功能有问题就受到影响。我们愿意做多少工作来保持这些失败域的隔离呢?
能够在部分可用性中识别这些类型的权衡是很有必要的。
- 度量指标是完成工作的唯一方法。
暴露指标(例如时延百分位数,增加某些操作的计数器,变更速率)是弥合你认为系统在生产中和实际运行中所产生的差距的唯一方法。了解系统在第20天的行为与第15天的行为有何不同是成功的工程与失败的萨满教之间的区别。当然,度量指标对于理解问题和行为是必须的,但是它们不足以知道下一步该做什么。
接着讲到日志。日志文件很好,但它们往往会说谎。例如,记录几个错误类通常会占用日志文件中很大一部分空间,但实际上,请求的比例非常低。由于日志记录成功在大多数情况下是多余的(并且在大多数情况下会导致磁盘崩溃),并且由于工程师经常错误地判断哪些类型的错误类是有用的,所以日志文件经常被各种奇怪的记录所填满。更喜欢日志记录就好像没看到代码的人在阅读日志一样。
我已经看过相当多的由其他工程师(或是我自己)扩展的中断,他们过分强调我们在日志中看到的一些奇怪的东西,而没有首先检查它们是否符合度量标准。我也同样看过其他人(或是我自己)像福尔摩斯般从少数几行日志中发现了一整套的失败行为。但是请记住,一是我们记住这些成功是因为它们非常罕见,二是除非有证据或试验支持这个故事,否则你也不是福尔摩斯。
- 使用百分位数,而不是平均值。
百分位数(第50、99、99.9、99.99)比绝大多数分布式系统中的平均值更准确,更有价值。使用平均值假设所评估的指标遵循钟形曲线,但实际上,这描述的是工程师很少关心的指标。“平均延迟”是一个经常报告的指标,但是我从未见过分布式系统的延迟遵循钟形曲线。如果度量标准不遵循钟形曲线,则平均值就毫无意义,且会导致错误的决策和理解。使用百分位数来避免这个陷阱。默认为百分位,你将能更好地了解用户如何真正看到你的系统。
- 学会评估自己的能力。
你会因此学习到更多。知道需要多少台机器来执行一项任务是一个长期系统与一个需要在工作3个月后更换或是更糟糕的在你完成生产上线之前需要换掉的系统之间的区别。
以推文为例子,在一台普通的机器上,你可以在内存中放入多少个推文ID?在2012年底一台典型的机器有24GB的内存,你将需要4-5GB的操作系统开销。还有至少另外两点要考虑到的,一是处理请求,二是推文ID是8个字节的。你将发现你自己在做的是一种猜量推算。谷歌大牛Jeff Dean的“ 每个程序员都该知道的数字
”该幻灯片是一个很好的预期设定者。
- 特性标志是基础设施迭代的方式。
“特性标志”是产品工程师在一个系统中推出新特性的一种常用方式。特性标志通常与前端A/B测试相关联,用于向用户群中的一部分展示新的设计或功能。但它们也是替换基础设施的一种强有力的方式。
太多的项目失败了,因为它们进行了大的切换或是一系列的大的切换,然后由于太迟发现的bug又强制回滚。通过使用特性标志,你将可以重拾项目信心,减少故障的代价。
假设你从单数据库转换到一个隐藏所有细节的新的存储方案的服务,使用特性标志,你可以在对旧数据库写入的同时缓慢地增加对新服务的写入,以确保其写路径正确且足够快。等到写入路径达到100%并且回填到服务的数据存储完成之后,你可以使用一个单独的特性标志来开始从服务中读取数据,而无需使用用户响应的数据来检查性能问题。另一个特性标志可用于在从旧系统和新系统读取数据时执行对比检查。最后有一个最终标志用于缓慢增加新系统的实际读取。
通过将部署分解成多个步骤,并使用特性标志为自己提供快速和部分的响应,你能更轻松地在发布过程中发现错误和性能问题,而不是在发布产生大爆炸时才察觉。如果发生问题,你可以立刻将功能标志降低到更低的设置(也许是零)。调整速率可以让你在不同流量下进行调试和试验,在这种情况下你遇到的问题都不会是一个彻彻底底的灾难。使用特性标志,你还可以选择其他一些迁移策略,例如基于每个用户转移请求,来更好地体验新系统。而且,当你仍在对新服务进行原型设计时,你可以使用标志一个更低的设置来让你的新系统消耗更少的资源。
现在,对于受过传统培训的开发人员和受过良好培训的新工程师来讲,特性标志听起来像是一堆乱七八糟的条件组合。使用特性标志意味着接受拥有多个版本的基础设施和数据是一种常态,而非罕见情况。这是一个深刻的教训。面对分布式问题,对单机系统有效的方法有时会失败。
特性标志最好被理解为一种折衷,用局部复杂性(在代码中或是一个系统中)换取全局简单性和弹性。
- 明智地选择ID空间。
你为系统选择的ID空间将影响到你的系统。
获取一段数据所需的ID越多,对数据进行分区的选项就越多。获取一条数据所需的ID越少,就越容易消耗系统的输出。
想想第一版本的推特API。所有关于获取,创建和删除推文的操作都是针对每条推文的单个数字ID完成的。推文ID是一个简单的64位数字,未关联到任何其他数据。随着推文数字的增加,很明显,如果同一用户存储的所有推文都存储在同一台计算机上,则可以有效地构建创建用户推文时间轴和其他用户订阅的时间轴。
但公共API要求每个推文都只能通过推文ID进行寻址。如要按用户划分推文,必须构建查找服务,用来查询哪个用户拥有哪个推特ID。如果有必要,该方法是可行的,但要付出不少。
另一种API可能需要在任何推文查询中使用用户ID,并且在用户分区存储上线之前,初期仅简单地使用推文ID进行存储。再另一种做法是将用户ID包含在推文ID内,但是代价就是推文ID不再是K型可排序的和数字化的。
注意你在ID中显式或隐式编码的信息。客户端可能使用你的ID结构来匿名化私有数据,以意想不到的方式来爬取你的系统(自增ID是一个典型的痛点),或进行其他一系列攻击。
- 利用数据本地化。
数据的处理和缓存越接近其持久化存储,传输就越高效,保持缓存的一致性和速度就越容易。与取消引用和释放指针相比,网络存在更多的故障和延迟。
当然,数据本地化意味着空间上的邻近,但也意味着时间上的邻近。如果多个用户几乎在同时执行一个昂贵的请求,或许他们的请求可以合并到一个。如果对同一类型的数据的请求实例彼此接近,则可以将它们连接成一个更大的请求。这样做的好处可以降低通信窃听和更容易进行错误管理。
- 不要将缓存数据写回到持久化存储。
这种情况发生在你难以想象的更多的系统中。尤其是那些最初由缺少分布式系统经验的人设计的产品。很多你继承的系统都会有这种问题。如果实现者讨论“俄罗斯娃娃缓存”,你会有很大可能遇到非常明显的bug。这个条目本可以从列表中移除,但我对此深感讨厌。该缺陷的一个常见表现是用户信息(例如账户名、邮件和哈希密码)难以理解地恢复到之前的值。
- 计算机可以做的事超出你的想象。
在当今的领域中,对于没有经验的从业者,有关机器的功能存在很多误导。
在2012年末,一个轻型的web服务器有6个或以上的CPU,24GB内存和超出可用空间的磁盘空间。在单台机器上,一个使用现代语言运行时的相对复杂的 CRUD
应用程序可以在几百毫秒内每秒处理数千个请求。这是一个很深的下限。就操作能力而言,在大多数情况下,每台机器每秒处理数百个请求并不是什么值得夸耀的事情。
如果你愿意分析你的应用程序并根据度量引入效率的话,将很容易获得更高的性能。
-
使用CAP定理来批判系统。
CAP定理不是你可以构建系统的东西。它不是一个你可以把它作为第一原则并从中推导出一个工作系统的定理。它的作用范围太笼统,可能的解决方案范畴也太广。
然而,它非常适合于批判分布式系统设计,了解需要进行哪些权衡。进行系统设计并迭代CAP在其子系统上施加的约束,最终将为你提供更好的设计。此处是家庭作业,请将CAP定理的约束条件应用于真实的俄罗斯娃娃缓存实现。
最后一点:一致性C,可用性A,分区容忍性P, 你不能牺牲掉P
。
- 提取服务。
“服务”一词在这里指“一个分布式系统,包含了比存储系统更高级的逻辑,并且通常还有一个请求响应风格的API”。如果代码存在于单独的服务中而不是在系统中,请随时注意代码变更。
提取的服务提供了通常与创建库关联的封装的好处。但是,提取的服务比通过升级客户端系统中的库可以更快,更轻松地部署更改,从而改善了创建库的过程。(当然,如果提取的服务很难部署,那么客户端系统则是变得容易部署的那个。)这种简化归功于较小的提取的服务中较少的代码和操作依赖,以及它所创建的严格边界,这使得简化库所允许的操作变得更加困难。这种捷径几乎总是使得更难将内部或是客户端系统迁移到新版本。
有多个客户端系统时,使用一个服务的协调成本也比共享库低得多。即使不需要更改API,升级库也需要协调每个客户端系统的部署。如果部署不按顺序进行,当数据发生损坏时,这将变得更难(难以预料是否会发生此种情况)。如果客户端系统有不同的维护者,那么更新一个库和部署一个系统相比存在更高的社会协调成本。让其他人意识到并且愿意更新是出人意料的难,因为他们的优先级跟你有所不同。
规范的服务用例是隐藏将要进行更改的存储层。提取的服务具有更方便的API,并且与它所面向的存储层相比,减少了表面积。通过提取服务,客户端系统不需要了解迁移到新存储系统或格式的缓慢过程的复杂性,只需要评估新服务的bug,这些bug肯定会在新存储布局中发现。
在这样做时需要考虑大量的可操作性和社会问题。在此没有足够的空间再继续写下,将不得不再写另一篇文章。
最后感谢我的审稿人Bill dehóra,Coda Hale,JD Maturen,Micaela McDonald和Ted Nyman,你们的见解和关心非常宝贵。