互联网做得很好,大多数人把它看作像太平洋这样的自然资源,而不是人为的东西。 这样规模的技术没出幺蛾子,上一次是什么时候了?
——阿兰·凯在接受Dobb博士杂志采访时(2012年)
今天的许多应用程序都是数据密集型的,而不是计算密集型的。对于这些应用程序来说,CPU的能力很少成为一个限制因素 - 更大的问题通常是数据量,数据的复杂性以及数据更改的速度。
数据密集型应用程序通常由提供常用功能的标准构建模块构建而成。例如,许多应用程序需要:
-
存储数据,以便他们或其他应用程序可以稍后再次找到它(数据库)
-
记住昂贵操作的结果,加快读取(缓存)
-
允许用户按关键字搜索数据或以各种方式对其进行过滤(搜索索引)
-
将消息发送到另一个进程,进行异步处理(流处理)
-
定期收集大量的累积数据(批处理)
如果这听起来很痛苦,那只是因为这些数据系统是如此成功的抽象:我们一直都在使用它们,而没有想太多。在构建应用程序时,大多数工程师不会想从头开始编写新的数据存储引擎,因为数据库是很好的工具。
但事实并非如此简单。有许多不同的数据库系统具有不同的特征,因为不同的应用程序有不同的要求。有各种各样的缓存方法,建立搜索索引的几种方法,等等。在构建应用程序时,我们仍然需要弄清楚哪些工具和哪些方法最适合手头的任务。当你需要做一些单一工具不能单独完成的工作时,很难将工具组合起来。 本书是通过数据系统的原理和实用性的一次旅程,以及如何使用它们来构建数据密集型应用程序。我们将探索不同的工具有哪些共同之处,区别它们的方法以及它们如何实现其特征。 在本章中,我们将从探索我们所要实现的基本原则开始:可靠的,可扩展的和可维护的数据系统。我们将澄清这些东西的含义,概述一些思考它们的方法,并回顾后面章节需要的基础知识。在下面的章节中,我们将一层一层地继续研究,看看在处理数据密集型应用程序时需要考虑的不同设计决策。
我们通常认为数据库,队列,缓存等是非常不同的工具类别。虽然数据库和消息队列有一些表面的相似性 - 都存储数据一段时间 - 他们有非常不同的访问模式,这意味着不同的性能特点,因此非常不同的实现。 那么为什么我们要把它们全部放在一起,像数据系统这样的总称呢? 近年来出现了许多新的数据存储和处理工具。它们针对各种不同的用例进行了优化,不再适合传统的类别[1]。例如,有一些数据存储也被用作消息队列(Redis),并且有类似数据库的持久保证(Apache Kafka)的消息队列。类别之间的界限变得模糊。 其次,现在越来越多的应用程序有这样苛刻或广泛的要求,即单个工具不能再满足其所有的数据处理和存储需求。而是将工作分解成单个工具可以高效执行的任务,并使用应用程序代码将这些不同的工具拼接在一起。 例如,如果您有一个应用程序管理的缓存层(使用Memcached或类似的)或与主数据库分离的全文搜索服务器(如Elasticsearch或Solr),通常应用程序代码负责保留这些缓存并与主数据库同步索引。图1-1给出了这可能是什么样的一瞥(我们将在后面的章节中详细介绍)。
当您将多个工具组合在一起以提供服务时,服务的接口或应用程序编程接口(API)通常会隐藏来自客户端的这些实现细节。现在,您基本上已经从较小的通用组件创建了一个新的专用数据系统。您的复合数据系统可能会提供某些保证:例如,缓存将在写入时正确无效或更新,以便外部客户端看到一致的结果。您现在不仅是应用程序开发人员,还是数据系统设计人员。 如果你正在设计一个数据系统或服务,会出现很多棘手的问题。即使内部出现问题,您如何确保数据保持正确和完整?即使部分系统退化,您如何为客户提供始终如一的良好性能?你如何扩大规模来处理负荷的增加?什么是一个良好的服务API的样子? 影响数据系统设计的因素很多,包括参与人员的技能和经验,遗留系统依赖性,交付时间,贵组织对各种风险的容忍度,监管约束等。因素很大程度上取决于情况。
在本书中,我们着重讨论三个在大多数软件系统中很重要的问题:
-
可靠性
即使面临逆境(硬件或软件故障,甚至人为错误),系统仍应继续正常工作(在所需的性能水平上执行正确的功能)。请参阅第6页上的“可靠性”。
-
可扩展性
随着系统的增长(数据量,流量或复杂性),应该有合理的方法来处理这种增长。请参阅第10页上的“可伸缩性”。
-
可维护性
随着时间的推移,许多不同的人将在系统上工作(工程和操作,既维持当前的行为,又使系统适应新的使用案例),他们都应该能够高效地工作。请参阅第18页上的“维护”。
这些话经常被丢弃,而没有清楚地理解它们的意思。为了进行深思熟虑的工程,我们将在本章的其余部分中探索关于可靠性,可伸缩性和可维护性的思考方法。然后,在下面的章节中,我们将看看为了实现这些目标而使用的各种技术,体系结构和算法。
每个人都有一个直观的想法,意味着什么是可靠或不可靠的。对于软件,典型的期望包括:
- 应用程序执行用户所期望的功能。
- 它可以容忍用户犯错或以意想不到的方式使用软件。
- 在预期的负载和数据量下,其性能足够满足所需的用例。
- 系统防止未经授权的访问和滥用。
如果所有这些东西在一起意味着“正确地工作”,那么我们可以把可靠性理解为粗略的意思,即“即使出现问题,也能继续正确地工作”。
可以出错的东西叫做故障(fault),预知故障并能应付的系统称之为容错(fault-tolerant)或韧性(resilient)。前一句话有点误导:它表明我们可以使系统容忍各种可能的错误,这在实际中是不可行的。如果整个地球(及其上的所有服务器)都被黑洞吞噬,容忍这个错误将需要网络托管到太空。祝你好运得到预算项目的批准。所以说说容忍某些类型的故障是有道理的。
请注意,故障不同于故障[2]。故障通常被定义为系统偏离其规格的一个组成部分,而失败则是系统作为一个整体停止向用户提供所需的服务。不可能将故障概率降低到零;因此通常最好设计容错机制,以防止故障导致故障。在本书中,我们将介绍几种从不可靠部件构建可靠系统的技术。
直觉上,在这样的容错系统中,通过故意触发来提高故障率是有意义的,例如,在没有警告的情况下随机地杀死单个进程。许多重要的错误实际上是由于糟糕的错误处理[3];通过故意引发故障,确保容错机器不断运行和测试,从而提高了自然发生故障时的正确性。 Netflix Chaos Monkey [4]就是这种方法的一个例子。
尽管我们通常宁愿容忍过错来防止错误,但也有预防胜于治疗的情况(例如,因为不存在治愈)。安全问题就是这种情况,例如:如果攻击者损害了系统并获得了对敏感数据的访问权限,则该事件不能被撤消。但是,本书主要讨论可以治愈的故障类型,如下面的部分所述。
当我们想到系统故障的原因时,很快就会想到硬件故障。硬盘崩溃,内存变成故障,电网中断,有人拔掉错误的网线。任何与大型数据中心合作的人都可以告诉你,当你拥有大量的机器时,这些事情总是会发生的。
据报道,硬盘的平均无故障时间(MTTF)约为10到50年[5,6]。因此,在一个有10,000个磁盘的存储集群上,我们应该平均每天有一个磁盘死亡。
我们的第一个反应是为了减少系统的故障率,通常是为了增加单个硬件组件的冗余度。磁盘可能设置为RAID配置,服务器可能具有双电源和热插拔CPU,数据中心可能有备用电池的电池和柴油发电机。当一个组件死亡时,冗余组件可以在更换损坏的组件的情况下取代它。这种方法不能完全防止硬件问题导致故障,但它是很好理解,并且可以使机器不间断运行多年。
直到最近,硬件组件的冗余对于大多数应用来说已经足够了,因为它使单台机器的完全失效相当罕见。只要您可以快速地将备份恢复到新机器上,出现故障的停机时间在大多数应用程序中并不是灾难性的。因此,多机冗余只需要少量的高可用性绝对必要的应用程序。
但是,随着数据量和应用程序的计算需求的增加,越来越多的应用程序开始使用大量的机器,这会相应地增加硬件故障率。此外,在一些云平台(如亚马逊网络服务(AWS))中,虚拟机实例在没有警告的情况下变得不可用[7],这是因为平台旨在优先考虑单机可靠性的灵活性和弹性。
因此,通过优先使用软件容错技术或除了硬件冗余之外,还有一种趋向于可以容忍整个机器损失的系统。这样的系统还具有操作优势:如果需要重启机器(例如应用操作系统安全补丁),则单服务器系统需要计划的停机时间,而可以容忍机器故障的系统可以一次修补一个节点没有整个系统的停机时间(滚动升级;参见第4章)。
我们通常认为硬件故障是随机的,相互独立的:一台机器的磁盘失败并不意味着另一台机器的磁盘将会失败。可能存在较弱的相关性(例如,由于常见原因,例如服务器机架中的温度),否则大量硬件组件不可能同时发生故障。 另一类错误是系统内部的系统错误[8]。这样的错误很难预测,而且由于它们在节点间相互关联,所以往往比不相关的硬件故障造成更多的系统故障[5]。例子包括:
- 给定特定的错误输入时,导致应用程序服务器的每个实例崩溃的软件错误。例如,考虑到2012年6月30日的闰秒,由于Linux内核中的一个错误,导致许多应用程序同时挂起。
- 失控进程会占用一些共享资源 - CPU时间,内存,磁盘空间或网络带宽。
- 系统依赖的服务变慢,变为无响应,或者开始返回损坏的响应。
- 级联故障,其中一个组件中的小故障触发另一个组件中的故障,进而触发进一步的故障[10]。
导致这类软件故障的错误通常会长时间处于休眠状态,直到被不寻常的情况触发为止。在这种情况下,显示出软件正在对其环境做出某种假设 - 虽然这种假设通常是正确的,但由于某种原因它最终会被否定[11]。
软件中的系统故障问题没有快速的解决方案。许多小事情可以帮助:仔细考虑系统中的假设和交互;彻底的测试;过程隔离;允许进程崩溃并重新启动;测量,监控和分析生产中的系统行为。如果一个系统需要提供一些保证(例如在一个消息队列中,输入消息的数量等于输出消息的数量),它可以在运行时不断检查自己,并在出现差异时发出警报被发现[12]。
人类设计和建造软件系统,保持系统运行的操作人员也是人。即使他们有最好的意图,人类也是不可靠的。例如,一项关于大型互联网服务的研究发现,运营商的配置错误是导致中断的主要原因,而硬件故障(服务器或网络)仅在10-25%的中断中发挥作用[13]。
尽管人类不可靠,我们如何使我们的系统可靠?最好的系统结合了几种方法:
- 以最大限度地减少错误机会的方式设计系统。例如,精心设计的抽象,API和管理界面使得“做正确的事情”变得容易,并且阻止“错误的事情”。然而,如果接口过于严格,人们会绕过它们,否定它们的好处。这是一个棘手的平衡得到正确的。
- 将人们犯最多错误的地方与可能导致失败的地方分开。特别是提供功能齐全的非生产性沙箱环境,使用户可以在不影响真实用户的情况下使用真实数据安全地探索和实验。
- 从单元测试到全系统集成测试和手动测试,在各个层面进行彻底测试[3]。自动化测试广泛使用,易于理解,特别适用于覆盖在正常运行中很少出现的角落案例。
- 允许从人为错误中快速轻松地恢复,以最大限度地减少故障情况下的影响。 例如,快速回滚配置更改,逐渐推出新代码(以便任何意外的错误只影响一小部分用户),并提供重新计算数据的工具(万一事实证明旧计算 是不正确的)。
- 设置详细和明确的监控,如性能指标和错误率。 在其他工程学科中,这被称为遥测。 (一旦火箭离开了地面,遥测技术对于追踪发生的事情和理解失败是必不可少的。)监测可以向我们展示预警信号,并让我们检查是否有任何假设或限制是违反的。迟来。 发生问题时,度量标准对于诊断问题是非常有价值的。
- 实施良好的管理实践和培训 - 一个复杂而重要的方面,超出了本书的范围。
可靠性不仅仅针对核电站和空中交通管制软件,更多的普通应用也预计可靠运行。商业应用程序中的错误会导致生产力的损失(如果数据报告不完整,则会面临法律风险),而且电子商务网站的中断可能会导致收入损失和声誉受损。
即使在“非关键”应用中,我们也对用户负有责任。考虑一个父母,他们的所有照片和他们的孩子的视频存储在您的照片应用程序[15]。如果数据库突然被破坏,他们会感觉如何?他们会知道如何从备份恢复它?
在某些情况下,我们可能选择牺牲可靠性来降低开发成本(例如,为未经证实的市场开发原型产品)或运营成本(例如,利润率极低的服务),但是我们应该非常清楚我们什么时候偷工减料。
即使系统今天运行稳定,但这并不意味着未来一定能够可靠运行。降级的一个常见原因是负载增加:或许系统已经从10,000个并发用户增加到100000个并发用户,或者从100万增加到1000万。也许正在处理的数据量比以前大得多。
可伸缩性(Scalability)是我们用来描述系统应对负载增加的能力的术语。但是请注意,这不是我们可以附加到系统上的一维标签:说“X可伸缩”或“Y不能缩放”是没有意义的。相反,讨论可扩展性意味着考虑如“如果系统以特定方式增长,我们有什么选择来应对增长?”和“我们如何增加计算资源来处理额外的负载?”等问题。
首先,我们需要简洁地描述系统的当前负载。只有这样我们才能讨论增长问题(如果我们的负荷加倍,会发生什么?)。负载可以用一些我们称之为负载参数的数字来描述。参数的最佳选择取决于系统的体系结构:它可能是每秒向Web服务器发出的请求,数据库中的读写比率,聊天室中同时活动的用户数量,缓存或其他东西。也许平均情况对你来说很重要,或者你的瓶颈主要是少数极端情况。
为了使这个想法更加具体,我们以2012年11月发布的数据[16]为例,以Twitter为例。 Twitter的两个主要业务是:
-
发布推文:用户可以向其追随者发布新消息(平均每秒4.6k个请求/秒,峰值超过12k个请求/秒)。
-
主页时间线:用户可以查看他们关注的人发布的推文(300k请求/秒)。
简单地处理每秒12,000次写入(发布推文的最高速率)将是相当容易的。然而,Twitter的扩张挑战并不是主要由于推特量,而是由扇出(fan-out:从电子工程中借用的一个术语,它描述了连接到另一个门输出的逻辑门输入的数量。 输出需要提供足够的电流来驱动所有连接的输入。 在事务处理系统中,我们使用它来描述为了服务一个传入请求而需要做的其他服务的请求数量。), 每个用户跟随很多人,每个用户跟着很多人。大致有两种方法来实现这两个操作:
- 发布推文只需将新推文插入推文的全局集合即可。当用户请求他们的主页时间线时,查找他们关注的所有人,为每个用户查找所有推文,并合并(按时间排序)。在如图1-2所示的关系数据库中,可以编写如下查询:
SELECT tweets.*, users.*
FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user
为每个用户的主页时间线维护一个缓存,例如每个收件人用户的推文信箱(见图1-3)。 当用户发布tweet时,查找所有关注该用户的人,并将新的tweet插入到每个主页时间线缓存中。 阅读主页时间表的请求便宜,因为其结果是提前计算的。
Twitter的第一个版本使用了方法1,但系统努力跟上主页时间线查询的负载,所以公司转向了方法2.这更好地发挥作用,因为发布的推文的平均比率几乎比主页时间线查询频率低了两个数量级,所以在这种情况下,最好在写入时间做更多的工作,而在读取时间做更少的工作。
然而,方法2的缺点是发布推文现在需要大量的额外工作。平均来说,一条推特被发送到约75个追随者,所以每秒4.6k的推文变成主页时间线缓存每秒345k的写入。但是这个平均值隐藏了每个用户的关注者数量与一些用户差异很大的事实
有超过三千万的追随者。这意味着一个推特可能会导致超过3000万的写入时间表!及时做到这一点 - Twitter试图在5秒内向粉丝发送推文 - 是一个重大的挑战。 在Twitter的例子中,每个用户的关注者分布(可能是这些用户发微博的频率)是讨论可伸缩性的关键负载参数,因为它决定了扇出负载。您的应用程序可能具有非常不同的特征,但您可以应用相似的原则来推理其负载。
推特轶事的最后一个转折:现在,方法2 健壮的实施了,Twitter正在转向两种方法的混合。大多数用户的推文在发布的时候仍然是在主页时间线上,但是很少有粉丝(即名人)的用户被排除在外。用户可能关注的任何名人的推文都会单独提取,并在阅读时与用户的家庭时间线合并,如方法1所示。这种混合方法能够持续提供良好的性能。我们将在第12章重新讨论这个例子,因为我们已经覆盖了更多的技术层面。
一旦描述了系统的负载,就可以调查负载增加时发生的情况。你可以用两种方法来看它:
- 增加负载参数并保持系统资源(CPU,内存,网络带宽等)不变时,系统性能如何受影响?
- 当您增加一个负载参数时,如果要保持性能不变,您需要增加多少资源?
这两个问题都需要性能数字,所以让我们简单地看一下系统的性能。
在像Hadoop这样的批处理系统中,我们通常关心吞吐量 - 每秒处理的记录数量,或者在一定数量的数据集上运行作业的总时间.(理想情况下,批量作业的运行时间是数据集的大小除以吞吐量。 在实践中,由于歪斜(数据不是均匀分布在工作进程中),而且需要等待最慢的任务完成,所以运行时间往往更长)在在线系统中,通常是什么更重要的是服务的响应时间 - 也就是客户端发送请求和接收响应之间的时间。
延迟(Latency)和响应时间(Response Time)通常用作同义词,但它们并不相同。响应时间是客户看到的:除了处理请求的实际时间(服务时间)之外,还包括网络延迟和排队延迟。延迟是一个请求等待处理的时间 - 在这个时间内,它是潜在的,等待服务[17]。
即使你只是一次又一次地提出相同的请求,每次尝试都会得到一个稍微不同的响应时间。实际上,在处理各种请求的系统中,响应时间可能会有很大差异。因此,我们需要将响应时间视为一个单一的数字,而不是一个可以衡量的价值分布。
在图1-4中,每个灰色条表示对服务的请求,其高度表示请求花了多长时间。大多数请求是相当快的,但是偶尔出现的异常值需要更长的时间。也许缓慢的请求本质上更昂贵,例如,因为它们处理更多的数据。但是即使在你认为所有的请求都要花费同样的时间的情况下,你也会得到一些变化:上下文切换到后台进程可能引入随机的附加延迟,网络数据包丢失和TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架[18]中的机械振动或许多其他原因。
通常看到报告的服务的平均响应时间。 (严格地说,“平均”一词并不是指任何特定的公式,但实际上它通常被理解为算术平均值:给定n 个值,加起来所有的值,除以n)。然而,平均值如果你想知道你的“典型”响应时间,那么它不是一个很好的指标,因为它不能告诉你有多少用户实际上经历了这个延迟。
通常使用百分比更好。如果将响应时间列表从最快到最慢排序,那么中值是中间值:例如,如果您的中值响应时间是200毫秒,这意味着一半请求的返回时间少于200毫秒,一半你的要求比这个要长。
如果您想知道用户通常需要等待多长时间,那么这使中间值成为一个好的度量标准:用户请求的一半服务时间少于中间响应时间,另一半服务时间比中间值长。中位数也被称为第50百分位,有时缩写为p50。请注意,中位数是指单个请求;如果用户提出了几个请求(在一个会话过程中,或者由于多个资源被包含在一个页面中),至少其中一个请求比中间值慢的可能性远远大于50%。
为了弄清楚你的异常值有多糟糕,你可以看看更高的百分位数:第95,99和99.9百分位数是常见的(缩写p95,p99和p999)。它们是95%,99%或99.9%的请求比特定阈值更快的响应时间阈值。例如,如果第95百分位响应时间是1.5秒,则意味着100个请求中的95个占用少于1.5秒,并且100个请求中的5个占用1.5秒或更多。如图1-4所示。
响应时间的高百分比(也称为尾部延迟 Tail Percentil)非常重要,因为它们直接影响用户的服务体验。例如,亚马逊描述了内部服务的响应时间要求,以百分之九十九为单位,即使只影响一千个请求中的一个。这是因为要求最慢的客户往往是那些账户数据最多的客户,因为他们进行了大量的采购 - 也就是说,他们是最有价值的客户[19]。通过确保网站快速发展,让客户满意是非常重要的:亚马逊还观察到,响应时间增加了100毫秒,销售量减少了1%[20],而另一些人则报告说,1秒钟的减速会减少客户 - 收敛度为16%[21,22]。
另一方面,优化第99.99个百分点(10000个请求中最慢的1个)被认为太昂贵,并且不能为亚马逊的目的带来足够的好处。以非常高的百分比来减少响应时间是困难的,因为它们很容易受到您控制之外的随机事件的影响,并且好处正在减少。
例如,百分比通常用于服务级别目标(SLO)和服务级别协议(SLA),即定义服务的预期性能和可用性的合同。 SLA可能会声明,如果服务的响应时间中位数小于200毫秒,并且在1秒内响应时间较长(如果响应时间较长,则可能会下降),则认为该服务已启动。可能需要至少99.9%的时间。这些指标为服务客户设定了期望值,并允许客户在SLA未达到的情况下要求退款。
排队延迟通常占高百分比响应时间的很大一部分。由于服务器只能并行处理少量的事务(例如,受其CPU核数量的限制),所以只需要少量缓慢的请求来阻止后续请求的处理,这种效果有时被称为头部阻塞。即使这些后续请求在服务器上快速处理,由于等待事先请求完成的时间,客户端将看到总体响应时间缓慢。由于这种影响,测量客户端的响应时间非常重要。
当为了测试系统的可扩展性而人为地产生负载时,产生负载的客户端需要不受响应时间的影响而不断发送请求。如果客户端在发送下一个请求之前等待先前的请求完成,那么这种行为会在测试中人为地保持队列的长度,而不是在实际中保持队列的长度,这会影响测量结果[23]。
# 实践中的百分位点
在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行呼叫完成。如图1-5所示,只需要一个缓慢的呼叫就可以使整个最终用户请求变慢。即使只有一小部分后端呼叫速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大[24])。
如果您想将响应时间百分点添加到您的服务的监视仪表板,则需要持续有效地计算它们。例如,您可能希望在最近10分钟内保持请求响应时间的滚动窗口。每一分钟,您都会计算出该窗口中的中值和各种百分数,并将这些度量值绘制在图上。
简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的CPU和内存成本(如正向衰减[25],t-digest [26]或HdrHistogram [27])来计算百分位数的近似值。请注意,平均百分比(例如,减少时间分辨率或合并来自多台机器的数据)在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图[28]。
现在我们已经讨论了用于描述测量性能的负载和度量的参数,我们可以开始认真讨论可伸缩性:即使当我们的负载参数增加一些时,我们如何保持良好的性能呢?
适合于一个级别的负载的体系结构不太可能应付10倍的负载。如果您正在开发一个快速增长的服务,那么您可能需要重新考虑每个数量级负载增长的架构 - 或者甚至更多。
人们经常谈到 scale-up(垂直扩展,转向更强大的机器)和scale-out(水平扩展,将负载分配到多个小型机器)之间的矛盾。在多台机器上分配负载也称为“无共享(shared-nothing)”体系结构。可以在一台机器上运行的系统通常更简单,但是高端机器可能变得非常昂贵,所以非常密集的工作量通常无法避免向外扩展。实际上,优秀的体系结构通常包含一些实用的方法:例如,使用几个功能相当强大的机器可能比大量的小型虚拟机更简单,更便宜。
有些系统是弹性的,这意味着他们可以在检测到负载增加时自动添加计算资源,而其他系统则是手动扩展(人工分析容量并决定向系统添加更多计算机)。如果负载高度不可预测,则弹性系统可能非常有用,但手动缩放系统更简单,并且可能具有更少的操作意外(请参阅“重新平衡分区”第195页)。
在多台机器上分发无状态服务非常简单,从单一节点到分布式设置的状态数据系统可能会带来很多额外的复杂性。出于这个原因,直到最近,人们普遍认为将数据库保持在单个节点上(扩展),直到缩放成本或高可用性要求迫使您将其改为分布式的。
随着分布式系统的工具和抽象变得越来越好,这种常识可能会改变,至少对于某些类型的应用来说。可以想象,分布式数据系统将成为未来的默认设置,即使对于不处理大量数据或流量的用例也是如此。在本书的其余部分中,我们将介绍多种分布式数据系统,并讨论它们不仅在可伸缩性方面的表现,还包括易用性和可维护性。 大规模运行的系统体系结构通常对应用程序具有高度的特定性 - 没有像通用的,一刀切的可扩展体系结构(非正式地称为魔力缩放magic scaling sauce )这样的事物。问题可能是读取量,写入量,要存储的数据量,数据的复杂程度,响应时间要求,访问模式,或者(通常)所有这些的混合物以及更多的问题。
例如,设计用于处理每秒100,000个请求(每个大小为1 kB)的系统与为每分钟3个请求(每个大小为2 GB)设计的系统看起来非常不同,即使两个系统的大小相同数据吞吐量。
一个适合特定应用的体系结构是围绕着哪些操作是常见的,哪些是负载参数是罕见的。如果这些假设结果是错误的,那么缩放的工程努力至多是浪费的,最糟糕的是适得其反。在早期阶段的初创公司或非正式的产品中,能够快速迭代产品特征比扩展到假设的未来负载更重要。
尽管它们是特定于特定应用程序的,但可扩展架构通常是从通用构建模块构建而成,并以熟悉的模式排列。在本书中,我们将讨论这些构件和模式。
众所周知,软件的大部分成本并没不在最初的开发阶段,而是在于持续的维护修复漏洞:保持系统正常运行,调查故障,适应新的平台,修改新的用例,偿还技术债务,增加新的功能。
然而不幸的是,许多从事软件系统工作的人不喜欢维护所谓的遗留系统 - 也许涉及修复其他人的错误或处理已经过时的平台,或者被迫做从未有意为之的系统。每一个遗留系统都是以自己的方式让人不爽,所以很难给出一个一般的建议来处理它们。
但是,我们可以也应该设计软件,以便在维护期间尽可能减少痛苦,从而避免自己创建传统软件。为此,我们将特别关注软件系统的三个设计原则:
-
可操作性
方便运营团队保持系统平稳运行。
-
简单
通过从系统中消除尽可能多的复杂性,使新工程师能够轻松理解系统。 (注意这与用户界面的简单性不一样。)
-
可进化
使工程师能够轻松地对将来的系统进行更改,并根据需求变化将其适用于意外的用例。也被称为可扩展性,可修改性或可塑性。
正如以前的可靠性和可扩展性一样,实现这些目标也没有简单的解决方案。相反,我们会考虑可操作性,简单性和可演化性的系统。
有人认为,“良好的运维经常可以解决不好的(或不完整的)软件的局限性,再好的系统也架不住垃圾运维。尽管运维的某些方面可以而且应该是自动化的,但首先要确保自动化的正确性,然后由人来完成。
运维团队对于保持软件系统顺利运行至关重要。一个优秀的运维团队通常负责以下内容,以及更多[29]:
- 监控系统的运行状况,并在服务状况不佳时快速恢复服务
- 追踪问题的原因,例如系统故障或性能下降
- 保持软件和平台保持最新状态,包括安全补丁
- 了解不同的系统如何相互影响,以便在造成损害之前避免有问题的更改
- 预测未来的问题并在问题出现之前加以解决(例如扩容计划)
- 建立部署,配置管理等方面的良好实践和工具
- 执行复杂的维护任务,例如将应用程序从一个平台移动到另一个平台
- 随着配置更改,维护系统的安全性
- 定义使操作可预测的流程,并帮助保持生产环境稳定
- 保持组织对系统的了解,即使是个人来来去去
良好的可操作性意味着使日常工作变得简单,使运营团队能够专注于高价值的活动。数据系统可以做各种事情,使日常任务变得简单,包括:
- 提供对系统的运行时行为和内部的可视性,并具有良好的监控能力
- 为自动化和与标准工具的集成提供良好的支持
- 避免依赖个别机器(在整个系统继续不间断运行的情况下允许机器停机维护)
- 提供良好的文档和易于理解的操作模型(“如果我做X,Y会发生”)
- 提供良好的默认行为,还可以让管理员在需要时自由覆盖默认值
- 在适当的情况下进行自我修复,并在需要时让管理员手动控制系统状态
- 展现可预见的行为,最大限度地减少惊喜
小型软件项目可以有简单而富有表现力的代码,但随着项目越来越大,它们往往变得非常复杂,难以理解。这种复杂性拖慢了每个需要在系统上工作的人员,进一步增加了维护成本。一个陷入复杂的软件项目有时被描述为一个大泥潭[30]。
复杂性有各种可能的症状:状态空间的爆炸,模块的紧密耦合,纠结的依赖关系,不一致的命名和术语,旨在解决性能问题的黑客攻击,解决其他问题的特殊框架等等。已经有很多关于这个话题的说法[31,32,33]。
当复杂性使维护困难时,预算和时间安排通常会超支。在复杂的软件中,当发生变化时,引入错误的风险也更大:系统开发人员难以理解和推理时,隐藏的假设,意想不到的后果和意外的交互更容易被忽略。相反,降低复杂性大大提高了软件的可维护性,因此简单性应该是我们构建系统的关键目标。
简化系统并不一定意味着减少其功能;它也意味着消除意外的复杂性。 Moseley和Marks [32]把复杂性定义为偶然的,如果软件解决的问题不是固有的(用户看到的),而只是由实现产生的。
我们用来消除意外复杂性的最好工具之一是抽象。一个好的抽象可以隐藏大量的实现细节在一个干净,简单易懂的外观背后。一个好的抽象也可以用于各种不同的应用程序。这不仅是重复使用效率比多次重复实现类似的东西更高效,而且还会导致更高质量的软件,因为抽象组件中的质量改进将有利于所有使用它的应用程序。
例如,高级编程语言是隐藏机器代码,CPU寄存器和系统调用的抽象。 SQL是隐藏复杂的磁盘和内存数据结构,
来自其他客户端的并发请求以及崩溃之后的不一致的抽象。当然,在用高级语言编程时,我们仍然使用机器码;我们只是不直接使用它,因为编程语言抽象使我们不必考虑它。
但是,找到好的抽象是非常困难的。在分布式系统领域,虽然有许多好的算法,但是我们应该如何将它们打包成抽象,这样就不那么清楚了,这些抽象可以帮助我们将系统的复杂性保持在可管理的水平。
在整本书中,我们将继续睁大眼睛来看好抽象,从而使我们能够将大型系统的一部分抽象成定义明确,可重用的组件。
你的系统的需求不会永远保持不变。他们更有可能处于不断变化的状态:您学习新的事实,之前出现意想不到的用例,业务优先级发生变化,用户请求新功能,新平台取代旧平台,法律或监管要求发生变化,系统增长强迫架构发生变化等
在组织流程方面,敏捷工作模式为适应变化提供了一个框架。敏捷社区还开发了技术工具和模式,这些工具和模式在频繁变化的环境中开发软件时很有帮助,如测试驱动开发(TDD)和重构。
这些敏捷技术的大部分讨论都集中在相当小的本地规模(同一个应用程序中的源代码文件)。在本书中,我们将探索在更大的数据系统层面上提高敏捷性的方法,可能由几个不同特性的应用程序或服务组成。例如,您将如何“重构”Twitter的架构来将Home Time从方法1重构为方法2?
您可以轻松修改数据系统并使其适应不断变化的需求,这与其简单性和抽象性密切相关:简单易懂的系统通常比复杂系统更容易修改。但是由于这是一个非常重要的想法,我们将用一个不同的词来指代数据系统层面的敏捷性:可进化性[34]。
在本章中,我们探讨了一些关于数据密集型应用程序的基本思路。这些原则将指导我们阅读本书的其余部分,在这里我们深入技术细节。
一个应用程序必须满足各种要求才能有用。有一些功能需求(它应该做什么,比如允许以各种方式存储,检索,搜索和处理数据)以及一些非功能性需求(一般属性如安全性,可靠性,合规性,可伸缩性,兼容性和可维护性)。在本章中,我们详细讨论了可靠性,可扩展性和可维护性。
可靠性意味着即使发生故障,也能使系统正常工作。故障可以是硬件(通常是随机的和不相关的),软件(缺陷通常是系统的,难以处理的),以及人类(不可避免地会不时出错)。容错技术可以隐藏最终用户的某些类型的故障。
可扩展性意味着即使在负载增加的情况下也有保持性能的策略。为了讨论可扩展性,我们首先需要定量描述负载和性能的方法。我们简单地将Twitter的家庭时间表作为描述负载的一个例子,并将响应时间百分比作为衡量每个时间段的一种方式。在可扩展的系统中,您可以添加处理能力以在高负载下保持可靠。
可维护性有许多方面,但实质上是为需要使用该系统的工程和运营团队提供更好的生活。良好的抽象可以帮助降低复杂性,并使系统更易于修改和适应新的用例。良好的可操作性意味着对系统的健康具有良好的可见性,并具有有效的管理方式。
不幸的是,为了使应用程序可靠,可扩展或可持续,并不容易。但是,某些模式和技术会不断出现在不同的应用程序中。在接下来的几章中,我们将看看数据系统的一些例子,并分析它们如何实现这些目标。 在本书后面的第三部分中,我们将看看由几个组件组成的系统的模式,比如图1-1中的组件。