第一章:可靠性、可伸缩性和可维护性
第一章:可靠性、可伸缩性和可维护性

互联网做得太棒了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术, 你还记得是什么时候吗?
—— 艾伦・凯 在接受
Dobb 博士杂志采访时说(2012 年)
现今很多应用程序都是
数据密集型应用通常由标准组件构建而成,标准组件提供了很多通用的功能;例如,许多应用程序都需要:
- 存储数据,以便自己或其他应用程序之后能再次找到 (数据库,即
databases ) - 记住开销昂贵操作的结果,加快读取速度(缓存,即
caches ) - 允许用户按关键字搜索数据,或以各种方式对数据进行过滤(搜索索引,即
search indexes ) - 向其他进程发送消息,进行异步处理(流处理,即
stream processing ) - 定期处理累积的大批量数据(批处理,即
batch processing )
如果这些功能听上去平淡无奇,那是因为这些 数据系统(data system) 是非常成功的抽象:我们一直不假思索地使用它们并习以为常。绝大多数工程师不会幻想从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。
但现实没有这么简单。不同的应用有着不同的需求,因而数据库系统也是百花齐放,有着各式各样的特性。实现缓存有很多种手段,创建搜索索引也有好几种方法,诸如此类。因此在开发应用前,我们依然有必要先弄清楚最适合手头工作的工具和方法。而且当单个工具解决不了你的问题时,组合使用这些工具可能还是有些难度的。
本书将是一趟关于数据系统原理、实践与应用的旅程,并讲述了设计数据密集型应用的方法。我们将探索不同工具之间的共性与特性,以及各自的实现原理。
本章将从我们所要实现的基础目标开始:可靠、可伸缩、可维护的数据系统。我们将澄清这些词语的含义,概述考量这些目标的方法。并回顾一些后续章节所需的基础知识。在接下来的章节中我们将抽丝剥茧,研究设计数据密集型应用时可能遇到的设计决策。
关于数据系统的思考
我们通常认为,数据库、消息队列、缓存等工具分属于几个差异显著的类别。虽然数据库和消息队列表面上有一些相似性 —— 它们都会存储一段时间的数据 —— 但它们有迥然不同的访问模式,这意味着迥异的性能特征和实现手段。
那我们为什么要把这些东西放在 数据系统(data system) 的总称之下混为一谈呢?
近些年来,出现了许多新的数据存储工具与数据处理工具。它们针对不同应用场景进行优化,因此不再适合生硬地归入传统类别【1
其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。
例如,如果将缓存(应用管理的缓存层,

图
当你将多个工具组合在一起提供服务时,服务的接口或 应用程序编程接口(API, Application Programming Interface) 通常向客户端隐藏这些实现细节。现在,你基本上已经使用较小的通用组件创建了一个全新的、专用的数据系统。这个新的复合数据系统可能会提供特定的保证,例如:缓存在写入时会作废或更新,以便外部客户端获取一致的结果。现在你不仅是应用程序开发人员,还是数据系统设计人员了。
设计数据系统或服务时可能会遇到很多棘手的问题,例如:当系统出问题时,如何确保数据的正确性和完整性?当部分系统退化降级时,如何为客户提供始终如一的良好性能?当负载增加时,如何扩容应对?什么样的
影响数据系统设计的因素很多,包括参与人员的技能和经验、历史遗留问题、系统路径依赖、交付时限、公司的风险容忍度、监管约束等,这些因素都需要具体问题具体分析。
本书着重讨论三个在大多数软件系统中都很重要的问题:
-
可靠性(Reliability)
系统在 困境(adversity,比如硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准
) 。请参阅 “可靠性”。 -
可伸缩性(Scalability)
有合理的办法应对系统的增长(数据量、流量、复杂性
) 。请参阅 “可伸缩性”。 -
可维护性(Maintainability)
许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景
) 。请参阅 “可维护性”。
人们经常追求这些词汇,却没有清楚理解它们到底意味着什么。为了工程的严谨性,本章的剩余部分将探讨可靠性、可伸缩性和可维护性的含义。为实现这些目标而使用的各种技术,架构和算法将在后续的章节中研究。
可靠性
人们对于一个东西是否可靠,都有一个直观的想法。人们对可靠软件的典型期望包括:
- 应用程序表现出用户所期望的功能。
- 允许用户犯错,允许用户以出乎意料的方式使用软件。
- 在预期的负载和数据量下,性能满足要求。
- 系统能防止未经授权的访问和滥用。
如果所有这些在一起意味着 “正确工作”,那么可以把可靠性粗略理解为 “即使出现问题,也能继续正确工作”。
造成错误的原因叫做 故障(fault
注意 故障(fault) 不同于 失效(failure
反直觉的是,在这类容错系统中,通过故意触发来 提高 故障率是有意义的,例如:在没有警告的情况下随机地杀死单个进程。许多高危漏洞实际上是由糟糕的错误处理导致的【3
尽管比起 阻止错误(prevent error
硬件故障
当想到系统失效的原因时,硬件故障(hardware faults) 总会第一个进入脑海。硬盘崩溃、内存出错、机房断电、有人拔错网线…… 任何与大型数据中心打过交道的人都会告诉你:一旦你拥有很多机器,这些事情 总 会发生!
据报道称,硬盘的 平均无故障时间(MTTF, mean time to failure) 约为
为了减少系统的故障率,第一反应通常都是增加单个硬件的冗余度,例如:磁盘可以组建
直到最近,硬件冗余对于大多数应用来说已经足够了,它使单台机器完全失效变得相当罕见。只要你能快速地把备份恢复到新机器上,故障停机时间对大多数应用而言都算不上灾难性的。只有少量高可用性至关重要的应用才会要求有多套硬件冗余。
但是随着数据量和应用计算需求的增加,越来越多的应用开始大量使用机器,这会相应地增加硬件故障率。此外,在类似亚马逊
如果在硬件冗余的基础上进一步引入软件容错机制,那么系统在容忍整个(单台)机器故障的道路上就更进一步了。这样的系统也有运维上的便利,例如:如果需要重启机器(例如应用操作系统安全补丁
软件错误
我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器的磁盘也会失效。虽然大量硬件组件之间可能存在微弱的相关性(例如服务器机架的温度等共同的原因
另一类错误是内部的 系统性错误(systematic error
- 接受特定的错误输入,便导致所有应用服务器实例崩溃的
BUG 。例如2012 年6 月30 日的闰秒,由于Linux 内核中的一个错误【9】 ,许多应用同时挂掉了。 - 失控进程会用尽一些共享资源,包括
CPU 时间、内存、磁盘空间或网络带宽。 - 系统依赖的服务变慢,没有响应,或者开始返回错误的响应。
- 级联故障,一个组件中的小故障触发另一个组件中的故障,进而触发更多的故障【10
】 。
导致这类软件故障的
虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量相等
人为错误
设计并构建了软件系统的工程师是人类,维持系统运行的运维也是人类。即使他们怀有最大的善意,人类也是不可靠的。举个例子,一项关于大型互联网服务的研究发现,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了
尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法:
- 以最小化犯错机会的方式设计系统。例如,精心设计的抽象、
API 和管理后台使做对事情更容易,搞砸事情更困难。但如果接口限制太多,人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。 - 将人们最容易犯错的地方与可能导致失效的地方 解耦(decouple
) 。特别是提供一个功能齐全的非生产环境 沙箱(sandbox) ,使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验。 - 在各个层次进行彻底的测试【3
】 ,从单元测试、全系统集成测试到手动测试。自动化测试易于理解,已经被广泛使用,特别适合用来覆盖正常情况中少见的 边缘场景(corner case) 。 - 允许从人为错误中简单快速地恢复,以最大限度地减少失效情况带来的影响。例如,快速回滚配置变更,分批发布新代码(以便任何意外错误只影响一小部分用户
) ,并提供数据重算工具(以备旧的计算出错) 。 - 配置详细和明确的监控,比如性能指标和错误率。在其他工程学科中这指的是 遥测(telemetry
) (一旦火箭离开了地面,遥测技术对于跟踪发生的事情和理解失败是至关重要的) 。监控可以向我们发出预警信号,并允许我们检查是否有任何地方违反了假设和约束。当出现问题时,指标数据对于问题诊断是非常宝贵的。 - 良好的管理实践与充分的培训 —— 一个复杂而重要的方面,但超出了本书的范围。
可靠性有多重要?
可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险
即使在 “非关键” 应用中,我们也对用户负有责任。试想一位家长把所有的照片和孩子的视频储存在你的照片应用里【15
在某些情况下,我们可能会选择牺牲可靠性来降低开发成本(例如为未经证实的市场开发产品原型)或运营成本(例如利润率极低的服务
可伸缩性
系统今天能可靠运行,并不意味未来也能可靠运行。服务 降级(degradation) 的一个常见原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万。也许现在处理的数据量级要比过去大得多。
可伸缩性(Scalability) 是用来描述系统应对负载增长能力的术语。但是请注意,这不是贴在系统上的一维标签:说 “
描述负载
在讨论增长问题(如果负载加倍会发生什么
为了使这个概念更加具体,我们以推特在
-
发布推文
用户可以向其粉丝发布新消息(平均
4.6k 请求/ 秒,峰值超过12k 请求/ 秒) 。 -
主页时间线
用户可以查阅他们关注的人发布的推文(
300k 请求/ 秒) 。
处理每秒
大体上讲,这一对操作有两种实现方式。
-
发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如 图
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-2 推特主页时间线的关系型模式简单实现 -
为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱(图
1-3 ) 。当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。图
1-3 用于分发推特至关注者的数据流水线,2012 年11 月的负载参数【16】
推特的第一个版本使用了方法
然而方法
在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可伸缩性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。
推特轶事的最终转折:现在已经稳健地实现了方法
描述性能
一旦系统的负载被描述好,就可以研究当负载增加会发生什么。我们可以从两种角度来看:
- 增加负载参数并保持系统资源(CPU、内存、网络带宽等)不变时,系统性能将受到什么影响?
- 增加负载参数并希望保持性能不变时,需要增加多少系统资源?
这两个问题都需要性能数据,所以让我们简单地看一下如何描述系统性能。
对于
延迟和响应时间
延迟(latency) 和 响应时间(response time) 经常用作同义词,但实际上它们并不一样。响应时间是客户所看到的,除了实际处理请求的时间( 服务时间(service time) )之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的 持续时长,在此期间它处于 休眠(latent) 状态,并等待服务【17
】 。
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值 分布(distribution
在 图

图
通常报表都会展示服务的平均响应时间
通常使用 百分位点(percentiles) 会更好。如果将响应时间列表按最快到最慢排序,那么 中位数(median) 就在正中间:举个例子,如果你的响应时间中位数是
如果想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准:一半用户请求的响应时间少于响应时间的中位数,另一半服务时间比中位数长。中位数也被称为第
为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第
响应时间的高百分位点(也称为 尾部延迟,即
另一方面,优化第
百分位点通常用于 服务级别目标(SLO, service level objectives) 和 服务级别协议(SLA, service level agreements
排队延迟(queueing delay) 通常占了高百分位点处响应时间的很大一部分。由于服务器只能并行处理少量的事务(如受其
为测试系统的可伸缩性而人为产生负载时,产生负载的客户端要独立于响应时间不断发送请求。如果客户端在发送下一个请求之前等待先前的请求完成,这种行为会产生人为排队的效果,使得测试时的队列比现实情况更短,使测量结果产生偏差【23
实践中的百分位点
在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如 图
1-5 所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】 ) 。如果你想将响应时间百分点添加到你的服务的监视仪表板,则需要持续有效地计算它们。例如,你可能希望在最近
10 分钟内保持请求响应时间的滚动窗口。每一分钟,你都会计算出该窗口中的中值和各种百分数,并将这些度量值绘制在图上。简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的
CPU 和内存成本(如前向衰减【25】 、t-digest【26】或HdrHistogram 【27】 )来计算百分位数的近似值。请注意,平均百分比(例如,减少时间分辨率或合并来自多台机器的数据)在数学上没有意义- 聚合响应时间数据的正确方法是添加直方图【28】 。

图
应对负载的方法
现在我们已经讨论了用于描述负载的参数和用于衡量性能的指标。可以开始认真讨论可伸缩性了:当负载参数增加时,如何保持良好的性能?
适应某个级别负载的架构不太可能应付
人们经常讨论 纵向伸缩(scaling up,也称为垂直伸缩,即
有些系统是 弹性(elastic) 的,这意味着可以在检测到负载增加时自动增加计算资源,而其他系统则是手动伸缩(人工分析容量并决定向系统添加更多的机器
跨多台机器部署 无状态服务(stateless services) 非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向伸缩
随着分布式系统的工具和抽象越来越好,至少对于某些类型的应用而言,这种常识可能会改变。可以预见分布式数据系统将成为未来的默认设置,即使对不处理大量数据或流量的场景也如此。本书的其余部分将介绍多种分布式数据系统,不仅讨论它们在可伸缩性方面的表现,还包括易用性和可维护性。
大规模的系统架构通常是应用特定的 —— 没有一招鲜吃遍天的通用可伸缩架构(不正式的叫法:万金油(magic scaling sauce)
举个例子,用于处理每秒十万个请求(每个大小为
一个良好适配应用的可伸缩架构,是围绕着 假设(assumption) 建立的:哪些操作是常见的?哪些操作是罕见的?这就是所谓负载参数。如果假设最终是错误的,那么为伸缩所做的工程投入就白费了,最糟糕的是适得其反。在早期创业公司或非正式产品中,通常支持产品快速迭代的能力,要比可伸缩至未来的假想负载要重要的多。
尽管这些架构是应用程序特定的,但可伸缩的架构通常也是从通用的积木块搭建而成的,并以常见的模式排列。在本书中,我们将讨论这些构件和模式。
可维护性
众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债、添加新的功能等等。
不幸的是,许多从事软件系统行业的人不喜欢维护所谓的 遗留(legacy) 系统,—— 也许因为涉及修复其他人的错误、和过时的平台打交道,或者系统被迫使用于一些份外工作。每一个遗留系统都以自己的方式让人不爽,所以很难给出一个通用的建议来和它们打交道。
但是我们可以,也应该以这样一种方式来设计软件:在设计之初就尽量考虑尽可能减少维护期间的痛苦,从而避免自己的软件系统变成遗留系统。为此,我们将特别关注软件系统的三个设计原则:
-
可操作性(Operability)
便于运维团队保持系统平稳运行。
-
简单性(Simplicity)
从系统中消除尽可能多的 复杂度(complexity
) ,使新工程师也能轻松理解系统(注意这和用户接口的简单性不一样) 。 -
可演化性(evolvability)
使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为 可扩展性(extensibility
) 、可修改性(modifiability) 或 可塑性(plasticity) 。
和之前提到的可靠性、可伸缩性一样,实现这些目标也没有简单的解决方案。不过我们会试着想象具有可操作性,简单性和可演化性的系统会是什么样子。
可操作性:人生苦短,关爱运维
有人认为
运维团队对于保持软件系统顺利运行至关重要。一个优秀运维团队的典型职责如下(或者更多
- 监控系统的运行状况,并在服务状态不佳时快速恢复服务。
- 跟踪问题的原因,例如系统故障或性能下降。
- 及时更新软件和平台,比如安全补丁。
- 了解系统间的相互作用,以便在异常变更造成损失前进行规避。
- 预测未来的问题,并在问题出现之前加以解决(例如,容量规划
) 。 - 建立部署、配置、管理方面的良好实践,编写相应工具。
- 执行复杂的维护任务,例如将应用程序从一个平台迁移到另一个平台。
- 当配置变更时,维持系统的安全性。
- 定义工作流程,使运维操作可预测,并保持生产环境稳定。
- 铁打的营盘流水的兵,维持组织对系统的了解。
良好的可操作性意味着更轻松的日常工作,进而运维团队能专注于高价值的事情。数据系统可以通过各种方式使日常任务更轻松:
- 通过良好的监控,提供对系统内部状态和运行时行为的 可见性(visibility
) 。 - 为自动化提供良好支持,将系统与标准化工具相集成。
- 避免依赖单台机器(在整个系统继续不间断运行的情况下允许机器停机维护
) 。 - 提供良好的文档和易于理解的操作模型(“如果做
X ,会发生Y ”) 。 - 提供良好的默认行为,但需要时也允许管理员自由覆盖默认值。
- 有条件时进行自我修复,但需要时也允许管理员手动控制系统状态。
- 行为可预测,最大限度减少意外。
简单性:管理复杂度
小型软件项目可以使用简单讨喜的、富表现力的代码,但随着项目越来越大,代码往往变得非常复杂,难以理解。这种复杂度拖慢了所有系统相关人员,进一步增加了维护成本。一个陷入复杂泥潭的软件项目有时被描述为 烂泥潭(a big ball of mud) 【30
复杂度(complexity) 有各种可能的症状,例如:状态空间激增、模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的
因为复杂度导致维护困难时,预算和时间安排通常会超支。在复杂的软件中进行变更,引入错误的风险也更大:当开发人员难以理解系统时,隐藏的假设、无意的后果和意外的交互就更容易被忽略。相反,降低复杂度能极大地提高软件的可维护性,因此简单性应该是构建系统的一个关键目标。
简化系统并不一定意味着减少功能;它也可以意味着消除 额外的(accidental) 的复杂度。
用于消除 额外复杂度 的最好工具之一是 抽象(abstraction
例如,高级编程语言是一种抽象,隐藏了机器码、
抽象可以帮助我们将系统的复杂度控制在可管理的水平,不过,找到好的抽象是非常困难的。在分布式系统领域虽然有许多好的算法,但我们并不清楚它们应该打包成什么样抽象。
本书将紧盯那些允许我们将大型系统的部分提取为定义明确的、可重用的组件的优秀抽象。
可演化性:拥抱变化
系统的需求永远不变,基本是不可能的。更可能的情况是,它们处于常态的变化中,例如:你了解了新的事实、出现意想不到的应用场景、业务优先级发生变化、用户要求新功能、新平台取代旧平台、法律或监管要求发生变化、系统增长迫使架构变化等。
在组织流程方面, 敏捷(agile) 工作模式为适应变化提供了一个框架。敏捷社区还开发了对在频繁变化的环境中开发软件很有帮助的技术工具和模式,如 测试驱动开发(TDD, test-driven development) 和 重构(refactoring) 。
这些敏捷技术的大部分讨论都集中在相当小的规模(同一个应用中的几个代码文件
修改数据系统并使其适应不断变化需求的容易程度,是与 简单性 和 抽象性 密切相关的:简单易懂的系统通常比复杂系统更容易修改。但由于这是一个非常重要的概念,我们将用一个不同的词来指代数据系统层面的敏捷性: 可演化性(evolvability) 【34
本章小结
本章探讨了一些关于数据密集型应用的基本思考方式。这些原则将指导我们阅读本书的其余部分,那里将会深入技术细节。
一个应用必须满足各种需求才称得上有用。有一些 功能需求(functional requirements,即它应该做什么,比如允许以各种方式存储,检索,搜索和处理数据)以及一些 非功能性需求(nonfunctional,即通用属性,例如安全性、可靠性、合规性、可伸缩性、兼容性和可维护性
可靠性(Reliability) 意味着即使发生故障,系统也能正常工作。故障可能发生在硬件(通常是随机的和不相关的
可伸缩性(Scalability) 意味着即使在负载增加的情况下也有保持性能的策略。为了讨论可伸缩性,我们首先需要定量描述负载和性能的方法。我们简要了解了推特主页时间线的例子,介绍描述负载的方法,并将响应时间百分位点作为衡量性能的一种方式。在可伸缩的系统中可以添加 处理容量(processing capacity) 以在高负载下保持可靠。
可维护性(Maintainability) 有许多方面,但实质上是关于工程师和运维团队的生活质量的。良好的抽象可以帮助降低复杂度,并使系统易于修改和适应新的应用场景。良好的可操作性意味着对系统的健康状态具有良好的可见性,并拥有有效的管理手段。
不幸的是,使应用可靠、可伸缩或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。