楔子

可靠性(Reliability)、可扩展性(Scalability)、可维护性(Maintainability)一直是分布式系统所绕不开的几个话题,当然它们的含义也很好理解,简单来说的话就是:

  • 可靠性:系统在困境(adversity)中仍然可以正常工作,这里困境指的是诸如「硬件故障」、「软件故障|、「人为错误」等一系列错误,正常工作则是系统能正确完成功能,并达到期望的性能水准
  • 可扩展性:能够通过线性增加机器来对系统的性能进行水平扩展,从而应对系统在数据量、流量、复杂性方面的增长
  • 可维护性:不同的人(开发、运维)在不同的生命周期,都能高效地在系统上工作(系统保持现有行为,并适应新的应用场景)

基本上很多大数据库框架都具有可靠性、可扩展性、可维护性,业内的人员也经常追求这些词汇,但其实很多人并没有清楚地理解它们到底意味着什么。而本文,就来深入地讨论可靠性、可扩展性、可维护性的含义。

可靠性

人们对于一个东西是否可靠,都有一个直观的想法,人们对可靠软件的典型期望包括:

  • 应用程序表现出用户所期望的功能
  • 允许用户犯错,允许用户以出乎意料的方式使用软件
  • 在预期的负载和数据量下,性能满足要求
  • 系统能防止未经授权的访问和滥用

如果这些叠在一起就意味着「正确工作」,那么可以把可靠性粗略理解为「即使出现问题,也能继续正确工作」。

造成错误的原因叫做「故障(fault)」,能预料并应对故障的系统特性可称为「容错(fault-tolerant)」或「韧性(resilient)」。这里的容错一词可能会产生误导,因为它暗示着系统可以容忍所有可能的错误,但在实际中这是不可能的,因为任何性质都是有极限的。举个栗子,如果整个地球都毁灭了,我们的系统还能保证高可靠吗?如果想保证的话,那么只能将服务器部署在其它星球上了。所以在讨论容错时,只有谈论特定类型的错误才有意义。

然后还要注意的是「故障」,我们常常会说系统故障了、程序出故障了之类的,它是造成错误的原因,但是「故障(fault)」不等同于「失效(failure)」。故障通常定义为系统的一部分状态偏离其标准,而失效则是系统作为一个整体停止向用户提供服务,所以当系统出现故障时,如果系统不具备容错或者出故障时不进行修复,那么就可能造成系统失效。所以一个好的分布式系统需要设计出优秀的容错机制来防止系统因「故障」而「失效」,可能有人觉得如果能确保系统不出故障的话,是不是就不容设计容错机制了?理论上是这样的,但现实是不允许的,一个分布式系统出故障的概率不可能降低到零。因为在生产环境中各种情况都有可能出现,比如某个节点的磁盘坏掉了,网络连接出问题了,安装新服务器的时候哪个小伙伴不小心把网线踢掉了等等,这些都属于不在控制范围之内的故障,尽管发生的可能性很小,但确实有可能会发生。而一旦发生,就意味着整个分布式系统出故障了,但是出现故障并不代表这个分布式系统设计的不好,好的分布式系统指的是在出现故障(预期之内)时能够很好地容错,并仍然能够正常工作。

不过反直觉的是,在这类容错系统中,通过故意触发来提高故障率也是有意义的,比如在没有告警的情况下随机地杀死进程。因为许多高危漏洞实际上是由糟糕的错误处理导致的,因为我们可以通过故意引发故障来确保容错机制不断运行并接收考验,从而提高故障自然发生时系统能正确处理的信心。

虽然故障无法百分百避免,或者说系统不可能百分百不出错,因此相比「阻止错误(prevent error)」,我们更倾向于「容忍错误」。不过也有「预防胜于治疗」的情况,如果一个错误我们百分百无法容忍的话,那么只能事先尽最大努力确保它不发生,比如安全问题。如果某个攻击者破坏了系统,并获取了敏感数据,这种事情显然是撤销不了的,无法做容错。

下面我们来讨论一下,当出现时可以恢复的故障种类。

硬件故障

当想到系统失效的原因时,硬件故障(hardware faults)总会第一个进入脑海,例如硬盘崩溃、内存出错、机房断电、有人拔错网线等等。任何与大型数据中心打过交道的人都会告诉你:一旦你拥有很多机 器,这些事情总会发生。据报道称,硬盘的平均无故障时间(MTTF,mean time to failure)约为 10 到 50 年,因此从数学期望上讲,在拥有 10000 个磁盘的存储集群上,平均每天会有 1 个磁盘出故障。

为了减少系统的故障率,第一反应通常都是增加单个硬件的冗余度,例如磁盘可以组建 RAID,服务器可能有双路电源和热插拔 CPU,数据中心可能有电池和柴油发电机作为后备电源,某个组件挂掉时冗余组件可以立刻接管。这种方法虽然不能完全防止由硬件问题导致的系统失效,但它简单易懂,通常也足以让机器不间断运行很多年。

直到最近,硬件冗余对于大多数应用来说已经足够了,它使单台机器完全失效变得相当罕见。只要你能快速地把备份恢复到新机器上,故障停机时间对大多数应用而言都算不上灾难性的,但实际上只有少量高可用性至关重要的应用才会要求有多套硬件冗余。不过随着数据量和应用计算需求的增加,越来越多的应用开始大量使用机器,这会相应地增加硬件故障率。此外在一些云平台,如亚马逊网络服务(AWS,Amazon Web Services)中,虚拟机实例不可用却没有任何警告也是很常见的,因为云平台的设计就是优先考虑灵活性(flexibility)和弹性(elasticity),而不是单机可靠性。

如果在硬件冗余的基础上进一步引入软件容错机制,那么系统在容忍整个(单台)机器故障的道路上就更进一步了。这样的系统也有运维上的便利,例如:如果需要重启机器(例如应用操作系统安全补丁),单服务器系统就需要计划停机,而允许机器失效的系统则可以一次修复一个节点,无需整个系统停机。

软件错误

我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器的磁盘也会失效。大量硬件组件不可能同时发生故障,除非它们存在比较弱的相关性(同样的原因导致关联性错误,例如服务器机架的温度,机架温度过高可能导致一批节点不可用)。

另一类错误是内部的系统性错误(systematic error),这类错误难以预料,而且因为是跨节点相关的,所以比起不相关的硬件故障往往可能造成更多的系统失效,举个栗子:

  • 接受特定的错误输入,便导致所有应用服务器实例崩溃的 BUG,例如 2012 年 6 月 30 日的闰秒,由于 Linux 内核中的一个错误,许多应用同时挂掉了
  • 失控进程会占用一些共享资源,包括 CPU 时间片、内存、磁盘空间或网络带宽
  • 系统依赖的服务变慢,没有响应,或者开始返回错误的响应
  • 级联故障,一个组件中的小故障触发另一个组件中的故障,进而触发更多的故障

导致这类软件故障的 BUG 通常会潜伏很长时间,直到被异常情况触发为止。这种情况意味着软件对其环境做出了某种假设:虽然这种假设通常来说是正确的,但由于某种原因最后不再成立了。

虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量相等),那么系统就可以在运行时不断自检,并在出现差异(discrepancy)时报警。

人为错误

设计并构建了软件系统的工程师是人类,维持系统运行的运维也是人类,即使他们怀有最大的善意,人类也是不可靠的。举个栗子,一项关于大型互联网服务的研究发现,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了10-25% 的服务中断。尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法:

  • 以最小化犯错机会的方式设计系统。例如,精心设计的抽象、API 和管理后台使做对事情更容易,搞砸事情更困难。但如果接口限制太多,人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡
  • 将人们最容易犯错的地方与可能导致失效的地方解耦(decouple)。特别是提供一个功能齐全的非生产环境沙箱(sandbox),使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验
  • 在各个层次进行彻底的测试,从单元测试、全系统集成测试到手动测试。自动化测试易于理解,已经被广泛使用,特别适合用来覆盖正常情况中少见的边缘场景(corner case)
  • 允许从人为错误中简单快速地恢复,以最大限度地减少失效情况带来的影响。例如,快速回滚配置变更,分批发布新代码(以便任何意外错误只影响一小部分用户),并提供数据重算工具(以备旧的计算出错)
  • 配置详细和明确的监控,比如性能指标和错误率。在其他工程学科中这指的是遥测(telemetry),比如火箭离开了地面,遥测技术对于跟踪发生的事情和理解失败是至关重要。监控可以向我们发出预警信号,并允许我们检查是否有任何地方违反了假设和约束,当出现问题时,指标数据对于问题诊断是非常宝贵的
  • 良好的管理实践与充分的培训,不过这个话题就比较远了

可靠性有多重要?

可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险),而电商网站的中断则可能会导致收入和声誉的巨大损失,因此即使在「非关键」应用中,我们也对用户负有责任。试想一位家长把所有的照片和孩子的视频储存在你的照片应用里,可如果数据库突然损坏,他们会感觉如何?他们可能会知道如何从备份恢复吗?

在某些情况下,我们可能会选择牺牲可靠性来降低开发成本(例如为未经证实的市场开发产品原型)或运营成本(例如利润率极低的服务),但我们偷工减料时,应该清楚意识到自己在做什么。

可扩展性

系统今天能可靠运行,并不意味未来也能可靠运行,服务降级(degradation)的一个常见原因是负载增加。例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万,也许现在处理的数据量级要比过去大得多。

可扩展性(Scalability)是用来描述系统应对负载增长能力的术语,但是请注意,这不是贴在系统上的一维标签:说「X 可扩展」或「Y 不可扩展」是没有任何意义的。相反,讨论可扩展性意味着考虑诸如「如果系统以特定方式增长,有什么选项可以应对增长?」和「如何增加计算资源来处理额外的负载?」等问题。

描述负载

在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为负载参数(load parameters)的数字来描述,参数的最佳选择取决于系统架构,它可能是每秒向 Web 服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。

为了使这个概念更加具体,我们以推特在 2012 年 11 月发布的数据为例。推特的两个主要业务是:

  • 发布推文:用户可以向其粉丝发布新消息(平均 4.6k 请求/秒,峰值超过 12k 请求/秒)。
  • 主页时间线:用户可以查阅他们关注的⼈发布的推文(300k请求/秒)。

处理每秒 12,000 次写入(发推文的速率峰值)还是很简单的,然而推特的扩展性挑战并不是主要来自推特量,而是来自扇出(fan-out)——每个用户关注了很多人,也被很多人关注。大体上讲,这一对操作有两种实现方式。

1)发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。比如在关系型数据库中,我们可以编写这样的查询:

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;

光看 SQL 语句的话估计会不好理解,我们画一张图:

可扩展性架构设计 可扩展性强_可扩展性架构设计

follows 表描述的是关注者的 id(follower_id)和被关注者的 id(followee_id)之间的对应关系,当前登录用户的 id 为 17055506,它关注的人的 id 是 12。然后根据 12 从 tweets 表(记录每个用户发的推文)中取出该用户发的推文,以及 users 表中用户的信息,然后将数据组装之后返回。

2)为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱,当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。

可扩展性架构设计 可扩展性强_可扩展性架构设计_02

推特的第一个版本使用方法 1,但实际工作中系统很难跟上主页时间线查询的负载,从相应的 SQL 语句我们就能看出来,每一次查询都涉及三张表的 JOIN 操作。所以推特转向了方法 2,方法 2 的效果更好,因为发推频率比查询主页时间线的频率低了两个数量级,所以在这种情况下,最好在写入的时候做更多的工作,而在读取的时候做更少的工作。

但是方法 2 也有缺点,因为发推需要大量的额外工作,平均来说一条推文会发给 75 个关注者,所以每秒 4.6k 的发推写入,对主页时间线缓存变成了每秒 345k 的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过 3000 万粉丝(许多大明星、当红歌手的粉丝更多),这意味着一条推文就可能导致主页时间线缓存的 3000 万次写入。更何况还有懂王「推特治国」,每天频繁发推这种,会不断地导致主页时间线缓存的至少 8000 万次写入(懂王大概 8000 多万粉丝),显然这个压力是难以承受的,以及想要完成这种操作也是一个巨大的挑战(推特尝试在 5 秒内将推文写入时间线缓存)。

在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可扩展性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。而推特最终的做法是将两者结合起来了,大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(顶流)会被排除在外,当用户读取主页时间线时,会临时获取该用户所关注的每位顶流的推文,再与用户的主页时间线缓存合并,这种方法算是吸取了两种方法的优点,能够始终如一地提供良好性能。

描述性能

一旦系统的负载被描述好,就可以研究当负载增加时会发生什么?我们可以从两种角度来看:

  • 增加负载参数并保持系统资源(CPU、内存、网络带宽)不变时,系统性能将受到什么影响?
  • 增加负载并希望保持性能不变时,需要增加多少系统资源?

这两个问题都需要性能数据,所以让我们简单地看一下如何描述性能。对于 Hadoop 这样的批处理系统,通常关心的是吞吐量(throughout),即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间。对于在线系统,通常更重要的是响应时间(response time),即客户端发送请求到接收响应所经历的时间。

这里有必要解释一下延迟(latency)和响应时间(response time),很多人容易混淆两个概念,其实它们是不一样的。响应时间是客户端锁观察到的,除了实际处理请求的时间(服务时间,service time)之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的「持续时长」,在此期间它处于休眠(latent)状态,并等待服务。

即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同,因为现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值分布(distribution),而不是单个数值。

可扩展性架构设计 可扩展性强_可扩展性_03

上图中的每个灰条代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现异常值(时间过大),这可能是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如上下文切换到后台进程、网络数据包丢失与 TCP 重传、垃圾回收导致 STW、强制从磁盘读取的页面错误,以及服务器机架中的震动等等,原因有很多。

另外报表通常都会展示服务的平均响应时间(严格来讲,平均一词并不指代任何特定公式,实际上通常被理解为算术平均值,arithmetic mean),然而如果你想知道「典型(typical)」响应时间,那么平均值并不是一个非常好的指标,因为它不能告诉你有多少用户实际上经历了这个延迟。

这个时候使用百分位点(percentiles)会更好,如果将响应时间列表按照最快到最慢排序,那么中位数(median)就在正中间。举个栗子,如果你响应时间中位数是 200 毫秒,这意味着一半请求的返回时间少于 200 秒,另一半比这个要长。因此想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准,一半用户请求的响应时间少于响应时间的中位数,另一半则多于中位数。中位数也被称为第 50 百分点,有时缩写为 p50。注意:中位数是关于单个请求的,如果用户发出多个请求(在一个会话过程中,或者由于一个页面中包含了多个资源),则至少有一个请求的响应时间高于中位数的概率远大于百分之 50。

为了弄清异常值有多糟糕,我们还可以看看更高的百分位点,例如第 95、99和 99.9 百分位点(缩写为 p95、p99 和 p999),它们意味着 95%、99%、99.9% 的请求的响应时间要比该阈值快。例如 p95 的响应时间是 1.5 秒,意味着 100 个请求中有 95 个,其响应时间小于 1.5 秒(更快);而 100 个请求中有 5 个,其响应时间超过 1.5 秒。

对于那些响应时间的高百分位点,我们称之为「尾部延迟(tail latencies)」。因为响应时间排好序后,会发现大部分都很快,就是尾部的那些请求比较慢,或者称之为「长尾效应」。而尾部延迟也非常重要,因为它们直接影响用户的服务体验,例如亚马逊在描述内部服务的响应时间要求 p999,也就是 1000 个用户只能影响 1 个。这是因为请求响应最慢的客户往往也是数据量最多的客户,也可能是最有价值的客户(因为掏钱了)。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加 100 毫秒,销售量就减少 1%;另一些报告说,慢 1 秒钟会让客户端满意度指标减少 16%。

另一方面,优化第 99.99 百分位点(一万个请求中最慢的一个)被认为太昂贵了,不能为亚马逊的目标带来足够好处。减少高百分点处的响应时间相当困难,因为它很容易受到随机时间的影响,这超出了控制范围,而且效益也很小。

百分位点通常用于「服务级别目标(SLO,service level objectives)」和「服务级别协议(SLA,service level agreements)」,即定义服务预期性能和可用性的合同。SLA 可能会声明,如果服务响应时间的中位数小于 200 毫秒,且 99.9 百分位点小于 1 秒,则认为服务工作正常(如果响应时间更长,则认为服务不达标)。这些指标为客户设定了期望值,并允许客户在 SLA 未达标的情况下要求退款。

排队延迟(queueing delay)通常占了高百分位点处响应时间的很大一部分,由于服务器只能并行处理少量的事务(如受其 CPU 核数的限制),所以只要有少量缓慢的请求就能阻碍后续请求的处理,这种效应有时被称为「头部阻塞(head-of-line blocking)」。即使后续请求在服务器上处理的非常迅速,但由于需要等待先前请求完成,所以客户端最终看到的是缓慢的总体响应时间。正因为存在这种效应,测量客户端的响应时间非常重要。

为测试系统的可扩展性而人为产生负载时,产生负载的客户端要独立于响应时间不断发送请求。如果客户端在发送下一个请求之前需要等待先前的请求完成,那么这种行为会产生人为排队的结果,使得测试时的队列比现实情况更短,进而造成测量结果出现偏差。

实践中的百分位点

在多重调用的后端服务里,高百分位数变得特别重要,即使并行调用,最终用户请求仍然需要等待最慢的调用完成。

可扩展性架构设计 可扩展性强_响应时间_04

如上图所示,只需要一个慢调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,但如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(该表现被称为「尾部延迟放大」)。

如果你想将响应时间百分点添加到你的服务监视仪盘表,则需要持续有效地计算它们。例如你可能希望在最近 10 分钟内保持请求响应时间的滚动窗口,那么每一分钟你都要计算出该窗口中的中值和各种百分位数,并将这些度量值绘制在图上。简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序,如果对你来说效率太低,那么有一些算法能够以最小的 CPU 和内存成本(比如向前衰减、tdigest、HdrHistogram)来计算百分位数的近似值。但需要注意的是,平均百分比(例如减少时间分辨率或合并来自多台机器的数据)在数学上没有意义,聚合响应时间的正确方法是添加直方图。

应对负载的方法

现在我们已经讨论了用于描述负载的参数和用于衡量性能的指标,下面可以认真讨论可扩展性了:当负载参数增加时,如何保持良好的性能?

适应某个级别负载的架构不太可能应付 10 倍于此的负载,如果你正在开发一个快速增长的服务,那么每次负载发生数量级的增长时,你可能都需要重新考虑架构(或者更频繁)。遇到这种情况就需要对系统进行扩展,而扩展分为「纵向扩展(scaling up)」和「横向扩展(scaling out)」,纵向扩展也被称为「垂直扩展(vertical scaling)」,即使用性能更强大的机器;横向扩展也被称为「水平扩展(horizontal scaling)」,即增加机器的数量,将负载分摊到更多的机器上。

可以在单台机器上运行的系统通常更加简单,但当负载增加时需要升级机器的性能,而高端机器会非常贵,因此纵向扩展的限制非常大。更何况再好的机器,其性能也是有上限的。所以我们通常会采用纵向扩展,用多台廉价的机器来均摊负载,毕竟找一个能搬 200 斤货的人远不如找两个能搬 100 斤货的人来的简单。比如 Hadoop 框架,它就满足可扩展,能够利用多台廉价的 PC 机来解决任务。

此外有些系统是弹性(elastic)的,这意味着可以在检测到负载增加时自动增加计算资源,而其它系统则是手动扩展(人工分析容量并决定向系统添加更多的机器)。如果负载极难预测(highly unpredictable),则弹性系统可能会很有用,不过手动扩展系统更简单,并且意外操作也更少。

跨多个节点分配负载叫「无共享(share-nothing)」架构,因此意味着部署「无状态服务(stateless services)」非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向扩展),直到扩展成本或可用性需求迫使其改为分布式(横向扩展)。因为单台节点的性能有上限,所以业务流量变大时改成分布式是不可避免的,也正因如此,分布式系统的工具和抽象越来越好,至少对于某些类型的应用而言,这种常识可能会改变。可以预见分布式数据系统会成为未来的默认设置,即使对不处理大量数据或流量的场景也是如此,至于分布式数据系统目前有哪些,以及它们在可扩展性(还有其它方面)上的表现如何,我们以后会单独聊。

这里需要强调的是,大规模的系统架构通常是应用特定的,没有一招鲜吃遍天的通用可扩展架构,或者说「万金油(magic scaling sauce)」。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度、响应时间要求、访问模式或者所有问题的大杂烩。举个栗子,用于处理每秒十万个请求(每个大小为 1KB)的系统和用于处理每分钟 3 个请求(每个大小 2GB)的系统看上去会非常不一样,尽管两个系统有同样的数据吞吐量。因此,如果一个架构师跟你说,他的架构适合所有的应用场景,那么这个架构师不是傻缺就是傻X。

一个良好适配应用的可扩展架构,是围绕着「假设(assumption)」建立的,比如哪些操作是常见的?哪些操作是罕见的?这就是所谓的负载参数。如果假设最终是错误的,那么为扩展所做的工程就白费了,最糟糕的是适得其反。在早期创业公司或非正式产品中,通常支持产品快速迭代的能力,要比可扩展至未来的假想负载重要的多。不过尽管这些架构是应用程序特定的,但可扩展的架构通常也是从通用的积木块搭建而成的,并以常见的模式排列,关于这些构建和模式我们以后也单独聊。

可维护性

众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债、添加新的功能等等。但不不幸的是,许多从事软件系统行业的⼈不喜欢维护所谓的「遗留(legacy)」系统,也许因为涉及修复其他人的错误、和过时的平台打交道,或者系统被迫使用于一些份外工作。每一个遗留系统都以自己的方式让⼈不爽,所以很难给出一个通用的建议来和它们打交道。

但是我们可以,也应该以这样一种方式来设计软件:在设计之初就尽量考虑尽可能减少维护期间的痛苦,从而避免自己的软件系统变成遗留系统。为此我们将特别关注软件系统的三个设计原则:

  • 可操作性(Operability):便于运维团队保持系统平稳运行。
  • 简单性(Simplicity):从系统中消除尽可能多的复杂度(complexity),使新工程师也能轻松理解系统,注意这和用户接口的简单性不一样。
  • 可演化性(Evolability):使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配,也称为可扩展性(extensibility)、可修改性(modifiability)或者可塑性(plasticity)。

和之前提到的可靠性、可扩展性一样,实现这些目标也没有简单的解决方案,不过我们会试着想象具有可操作性,简单性和可演化性的系统会是什么样子。

可操作性:人生苦短,关爱运维

有⼈认为,良好的运维经常可以绕开垃圾(或不完整)软件的局限性,而再好的软件摊上垃圾运维也没法可靠运行。尽管运维的某些方面可以,而且应该是自动化的,但在最初建立正确运作的自动化机制仍然取决于人。

运维团队对于保持软件系统顺利运行至关重要,一个优秀运维团队的典型职责如下(或者更多):

  • 监控系统的运行状况,并在服务状态不佳时快速恢复服务
  • 跟踪问题的原因,例如系统故障或性能下降
  • 及时更新软件和平台,⽐如安全补丁
  • 了解系统间的相互作用,以便在异常变更造成损失前进行规避
  • 预测未来的问题,并在问题出现之前加以解决(例如,容量规划)
  • 建立部署,配置、管理方面的良好实践,编写相应工具
  • 执行复杂的维护任务,例如将应用程序从一个平台迁移到另一个平台
  • 当配置变更时,维持系统的安全性
  • 定义工作流程,使运维操作可预测,并保持生产环境稳定
  • 铁打的营盘流水的兵,维持组织对系统的了解

良好的可操作性意味着更轻松的⽇常工作,进而运维团队能专注于高价值的事情,数据系统可以通过各种方式使日常任务更轻松:

  • 通过良好的监控,提供对系统内部状态和运行时行为的可见性(visibility)
  • 为自动化提供良好支持,将系统与标准化工具相集成
  • 避免依赖单台机器(在整个系统继续不间断运行的情况下允许机器停机维护)
  • 提供良好的文档和易于理解的操作模型(例如如果做 X,会发生生Y)
  • 提供良好的默认行为,但需要时也允许管理员自由覆盖默认值
  • 有条件时进行自我修复,但需要时也允许管理员⼿动控制系统状态
  • 行为可预测,最⼤限度减少意外

简单性:管理复杂度

小型软件项目可以使用简单讨喜的、富表现力的代码,但随着项目越来越大,代码就往往会变得非常复杂,难以理解。这种复杂度拖慢了所有系统相关人员,进一步增加了维护成本。而一个陷入复杂泥潭的软件项目有时被描述为「烂泥潭(a big ball of mud)」。

「复杂度(complexity)」有各种可能的症状,例如:状态空间激增、模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的 Hack、需要绕开的特例等等,现在已经有很多关于这个话题的讨论。因为复杂度导致维护困难时,预算和时间安排通常会超支,在复杂的软件中进行变更,引入错误的风险也更大。比如当开发人员难以理解系统时,隐藏的假设、无意的后果和意外的交互就更容易被忽略;相反,降低复杂度能极大地提高软件的可维护性,因此简单性应该是构建系统的一个关键目标。

但简化系统并不一定意味着减少功能,它也可以意味着消除「额外的(accidental)」的复杂度。 Moseley 和 Marks 把额外复杂度定义为:由具体实现中涌现,而非(从用户视⻆看,系统所解决的)问题本身固有的复杂度。

用于消除额外复杂度的最好工具之一是「抽象(abstraction)」,一个好的抽象可以将大量实现细节隐藏在一个干净、简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用,比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。抽象组件的质量改进将使所有使用它的应用受益。

例如高级编程语言是一种抽象,隐藏了机器码、CPU 寄存器和系统调用。 SQL也是一种抽象,隐藏了复杂的磁盘/内存数据结构、来自其他客户端的并发请求、崩溃后的不一致性。当然在用高级语⾔编程时,我们仍然用到了机器码,只不过没有「直接(directly)」使用罢了,正是因为编程语言的抽象,我们才不必去考虑这些实现细节。

抽象可以帮助我们将系统的复杂度控制在可管理的水平,但想找到好的抽象也是非常困难的。在分布式系统领域虽然有许多好的算法,但我们并不清楚它们应该打包成什么样的抽象,因此我们应该更加关注那些允许我们将大型系统的部分提取为定义明确的、可重用的组件的优秀抽象。

可演化性:拥抱变化

系统的需求永远不变基本上是不可能的,而且更可能的情况是,它们处于常态的变化中,例如:你了解了新的事实、出现意想不到的应用场景、业务优先级发生变化、用户要求新功能、新平台取代旧平台、法律或监管要求发生变化、系统增长迫使架构变化等。

在组织流程方面,「敏捷(agile)」工作模式为适应变化提供了一个框架。敏捷社区还开发了对在频繁变化的环境中开发软件很有帮助的技术工具和模式,如「测试驱动开发(TDD, test-driven development)」和「重构(refactoring)」。

这些敏捷技术的大部分讨论都集中在相当小的规模(同一个应用中的几个代码文件),当然我们也可以探索在更⼤数据系统层⾯上提高敏捷性的方法,可能由几个不同的应用或服务组成,比如为了将装配主页时间线的方法从方法 1 变为方法 2,你会如何「重构」推特的架构?

修改数据系统并使其适应不断变化需求的容易程度,是与简单性和抽象性密切相关的:简单易懂的系统通常⽐复杂系统更容易修改。但由于这是一个非常重要的概念,我们用了一个不同的词来指代数据系统层面的敏捷性:可演化性(evolvability)。

小结

一个应用必须满足各种需求才称得上有用,需求有「功能需求(functional requirements)」和「非功能需求(nonfunctional requirements)」。功能需求指的是它应该做什么,比如允许以各种方式存储,检索,搜索和处理理数据;非功能需求指的是通用属性,比如安全性,可靠性,合规性,可扩展性,兼容性和可维护性,本文详细讨论了可靠性、可扩展性和可维护性。

  • 可靠性(Reliability)意味着即使发生故障,系统也能正常工作。故障可能发生生在硬件(通常是随机的和不相关的)、软件(通常是系统性的 Bug,很难处理),和人类(不可避免地时不时出错),而容错技术可以对终端用户隐藏某些类型的故障。
  • 可扩展性(Scalability)意味着即使在负载增加的情况下也有保持性能的策略。为了讨论可扩展性,我们首先需要定量描述负载和性能的方法。我们简要了解了推特主页时间线的例子,介绍描述负载的方法,并将响应时间百分位点作为衡量性能的一种方式。在可扩展的系统中可以添加处理容量(processing capacity)以在高负载下保持可靠。
  • 可维护性(Maintainability)意味着有许多方面实质上是关于工程师和运维团队的质量的。良好的抽象可以帮助降低复杂度,并使系统易于修改和适应新的应用场景。良好的可操作性意味着对系统的健康状态具有良好的可见性,并拥有有效的管理手段。

不幸的是,使应用可靠、可扩展、可维护并不容易。因此会不断地出现一些模式和技术,使得这一过程变得简单,我们有机会再聊吧。