DDIA 读书笔记——构建可靠的、可扩展和可维护的应用

17 年听说了这本神书 Designing Data-Intensive Applications,可以说这是一本全面讲解了大数据整个生态的百科全书,之前快速地看过前几章,感觉讲解地很系统而且通俗易懂,但是看到后面开始讲解更多细节性的内容慢慢地有点跟不上节奏了。作为一名数据从业人员感觉很有必要通过这本书把数据系统层面的内容整个做一个了解,所以这次在阅读的过程中会作一些笔记或者说是翻译以加深自己的记忆和理解。目标是一周更新一章,希望用两个月左右的时间读完这本书吧!

现在很多应用都是数据密集型应用,所谓的数据密集型应用,是指数据是其主要挑战,数据量、数据复杂度以及数据变化的速度是要考虑的主要方面。与之对应的是计算密集型,即 CPU 是主要瓶颈。

数据密集型应用实际上是由多个标准化组件组合而成的,这些组件提供相应通用性的功能,例如很多应用都需要:

  • 存储及查询数据(数据库)
  • 记录一个复杂操作的结果,加速读操作(缓存)
  • 允许用户通过关键字查数据,并且可以用多种方式过滤数据(搜索索引)
  • 将消息发送到其他程序,方便异步处理(流处理)
  • 周期性处理大量的累计数据(批处理)

这些功能听起来是不是很常见,而且你可以想到相应的工具去实现,简单点讲数据系统就是将它们整合在一起,但其实并没有那么简单。因为对应到不同的应用有不同的需求,然而有很多种不同的数据库,也有多种不同的缓存方式,不同的建立搜索索引的方法等等,那么我们如何做出选择呢?所以应该是就事论事,具体情况具体分析,我们应该根据我们的需求正确选择出最适合的工具和途径。

通常我们会认为数据库、队列、缓存等是完全不同的工具,即使一个数据库和一个消息队列表面上有一些相同点(能存储数据),但其实他们解决的痛点、内部的实现和性能等都是完全不同的。那么为什么我们要把他们统一放在数据系统这面大旗底下呢?

首先,这些不同类别工具的边界正在逐渐变模糊,他们都可以服务于数据系统。例如近年来出现了很多用于存储数据和处理数据的工具,他们都被优化成可以适用于多种应用场景而不再只是单一的某个方面。例如有些数据存储也可以作为消息队列来使用(如 Redis),有些消息队列也会提供像数据库一样的持久化保证(如 Kafka)。
其次,现在的应用一般都有各种各样的需求,想要用一种工具完美满足所有需求那是不存在的。所以我们一般都会把一个应用拆分成不同的部分,对应每个部分使用专门的工具来高效处理,最后通过应用代码把这些工具整合在一起。

举个栗子,假设现在有一个通过应用管理的缓存层(使用 Memcached 或者其他类似的),或者有一个全文搜索服务器(例如 Elasticsearch 或者 Solr)与主数据库分离,那么就需要使缓存或者索引和数据库保持同步,这就是通过应用代码来实现的,下图是该例子的架构,具体细节后面的章节我们会再讲。

当设计一个数据系统或服务的时候,会遇到很多奇怪的问题:系统内部出错了,如何确保数据的正确性和完整性?系统的某个部分退化,如何为客户提供始终如一的良好性能?负载增加的时候如何扩展?一个好的 API 应该如何实现?有很多因素会影响到数据系统的设计,包括相关人员的能力和经验,对遗留系统的一些依赖,时间成本,监管约束等。因素很大程度上取决于情况。本书我们主要关注对于大多数软件系统来讲都很重要的三点:

可靠性
即使出现了某些问题(软硬件错误或者偶然的人为失误),系统应该保证始终能够正常工作

可扩展性
随着系统的增长(数据量、流量或复杂度),应该有相应的应对措施

可维护性
随着时间的推移,许多不同的人将在系统上工作(工程和操作,既保持当前的行为,又使系统适应新的用例),并且他们都应该能够有效地在系统上工作。

实际工作中大家可能忽略了这三个准则的重要性,那么下面让我们来真正理解可靠性、可扩展性和可维护性意味着什么。

可靠性

对软件而言,可靠性意味着:

  • 应用执行用户期望的函数
  • 允许用户犯错或者错误地使用该软件
  • 在预期的负载和数据量下,对不同的需求都能提供良好的性能
  • 系统防止任何未经授权的访问和滥用

可靠性必须保证出了问题也能正常使用,系统出了问题称为 错误(faults),如果系统可以预料并且可以做出合适的应对我们称之为 容错性(fault-tolerant) 或者 韧性(resilient)。注意错误(faluts)和故障(failure)不同,错误(faluts)通常定义为系统的某个组件偏离其规范操作,然而故障(failure)是指整个系统停止向用户提供服务,错误太多可能会导致系统故障,但是想要完全没有错误又是不可能的,因此最好的方法就是设计良好的容错机制。

硬件错误

当发生了系统故障的时候,首先想到的就是一些硬件错误:硬盘坏了、RAM 出现故障、电网停电、太平洋底的鲨鱼咬断了光纤等等。硬盘平均经过 10-50 年就会出故障,因此在一个有一万个硬盘的存储集群上,平均每天都会有一个硬盘挂掉。

对于硬盘故障,第一个常用的方式是增加备份。磁盘可以设置成 RAID(独立硬盘冗余阵列),服务器设置成双电源和热交换 CPU,数据中心配备电池和柴油发电机作为备用电源。当某一个组件发生故障,冗余备份可以代替它,当然在恢复的时间內系统是挂掉的,但是对大部分应用来说这点宕机时间应该并不是灾难性的,然而却可以成功避免系统完全崩溃。

随着数据量和应用的计算需求的提高,更多应用需要使用大量的机器,这就增大了硬件故障发生的几率。而且许多云平台如 AWS 的虚拟机实例经常没有任何警告就不可用了,那是因为与可靠性相比,云平台优先考虑机器的灵活性和弹性。因此现在越来越趋向于设计能够允许整个机器出现故障的应用系统,通过软件层面的容错性技术来代替硬件备份,这种系统具有操作上的优势:普通的单机系统宕机或者更新(例如需要发布一个新的安全补丁)的时候,有一段时间是不可用的,然而一个可以允许机器故障的系统某个时间只更新某个节点,不需要整个系统都停掉。

软件错误

另一种类型的错误是系统内部的错误,也就是软件错误。这种错误很难被预测到,并且由于软件可能运行在多个机器节点中,所以软件错误更容易导致整个系统挂掉。例如:

  • 因为不正常的输入导致应用服务器的所有机器全部挂掉的 bug
  • 一个死循环耗尽了所有的共享资源如 CPU、内存、磁盘空间和网络带宽
  • 系统依赖的某个服务变慢、无响应或者返回异常的结果
  • 连环故障,即组件中的一个小错误可能会导致另一个组件出错,最后触发了更多的错误

导致这些软件错误的 bug 通常都潜伏很久,只有在某些非常奇怪的情况下才会被触发。对于软件错误没有什么系统快速的解决方式,只能通过各种细节来避免:仔细思考系统可能遇到的异常情况;全面的测试;模块分离;允许程序崩溃并且能够恢复重启;测量、监控和分析生产环境下系统的指标。

人为错误

软件系统是人设计和构建的,也是人使用的,人也恰恰是最能犯错的,据调查操作者错误配置是服务异常的主要原因,而硬件错误仅占 10-25%。那么面对人类不可靠这个不可避免的事实,如何使我们的系统更可靠呢?可以从如下几个方面入手:

  • 尽可能设计不易出错的系统。例如良好设计的抽象、API、管理接口就可以很大层度上避免错误,但是如果接口过分严格,限制了对其工作人员的很多操作,那么这个系统就会显得很难用。所以这需要很好的平衡。
  • 将人最容易犯错的地方和容易导致系统故障的地方分离开,并且提供非生产环境进行充分探索和实验。
  • 进行从单元测试到集成测试和手动测试的全面全方位测试。自动化测试也应当被广泛使用,它可以检测到某些正常操作覆盖不到的细枝末节。
  • 能够方便快捷恢复到系统之前地样子。例如,能够快速回滚配置文件,逐步应用新代码(保证即使有问题也只影响一小撮用户),提前准备能够重新计算数据的工具(当原有数据有误时)
  • 建立清晰明确的监控,例如性能指标和错误率。监控能够提前向我们发出警报,检验是否与我们预期结果相符。当出现问题时,这些监控指标对于排查问题更是意义重大。
  • 对人员进行良好地管理和培训

可扩展性

一个系统现在可靠不代表将来也可靠,这是因为随着负载的增加系统会发生退化。例如系统的并发用户从一百万增加到了一千万,或者处理的数据量加倍。所以除了关注系统的可靠性外,我们还应当提前考虑系统的扩展。

如何衡量负载

正如上面所说,负载增加是一个很宽泛的概念,不同的系统对应不同的情况,我们可以通过一些称为 负载参数 的数字来描述负载。不同的系统这个参数是不同的,例如网站我们会使用每秒请求数(RPS)来描述负载,数据库我们使用读写率,聊天软件我们使用同时活跃用户,在缓存中我们会使用命中率等等。不同的系统应该选择合适的负载参数。

让我们使用 Twitter 的例子讲解一下,Twitter 上的两个主要操作是:

  • 发推:用户可以向其粉丝发布推文。(平均每秒 4.6k 条请求,峰值时每秒会发送 12k 条请求)
  • 刷推文:用户可以刷新主页查看他们关注人发布的推文。(每秒 300k 条请求)

仅仅是每秒写入 12000 条记录其实很简单,Twitter 扩展的挑战并不主要是推文的量,而是在于如何让所有人流畅地看到这些推文,因为每个用户可以关注很多人,同时每个用户也可以被很多人关注。大体上有两种方式可以实现这两个操作:

方法 1:发推就是将新的推文插入到一个包含全部推文的表中,当一个用户请求刷新它的推特主页时,首先查找他关注的所有用户,找到这些用户的所有推文并且用时间排序。它们在数据库中的表关系如下图,可以用下面的 SQL 来查询

SELECT tweets.*, users.*
FROM tweets
JOIN users ON tweets.send_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user

方法 2:给每个用户维持一个包含要查看推文的缓存,就像用户的邮箱一样。当某个用户发推以后,首先会找到关注该用户的所有人,然后把这条推文插入到每个人自己的推文缓存中,每个用户需要刷推特主页的时候会直接查询对应的缓存,由于是提前计算好的所以速度会很快。

第一种方法需要承受大量用户刷新推特主页带来的负载,所以 Twitter 公司开始使用第二种方法,这个方法比较好的原因是从上图我们可以看到用户发布推文的请求数比用户刷推文的请求数几乎低两个数量级,所以最好是在写入时多做点事情,写入到缓存中,读取的时候就会承受比较小的负载。但是方法 2 的问题是发布一条推文现在就需要做更多的额外工作了,平均每个用户会被 75 个人关注,所以每秒平均 4.6k 的推文发布量就意味着每秒要向用户主页缓存中写入 75 * 4.6k = 345k 的记录。但要明白这里说的是平均值,每个用户的粉丝数差别是很大的,有些用户可能会有三千万的粉丝,这就意味着发布一条推文会写入三千万条数据,而推特尽量保证消息 5 秒內能发送给其关注者,这个挑战就相当大了。

在上面 Twitter 的例子中,每个用户的关注者数目的分布(可能同时考虑用户发推的频率)是讨论可扩展性的主要负载参数,我们自己的应用肯定是与之不同的,我们应当根据自己系统的业务逻辑和技术架构综合考虑系统的负载。

最后解密一下 Twitter 最终的解决方案:综合使用方法 1 和 2,对于普通用户仍然使用方法 2,对于名人网红使用方法 1。所以没有哪种方法和技术是最好的或者没有哪种方法或者技术可以全面解决所有问题,最好的办法就是根据实际情况选择合适技术,甚至综合使用多种技术。

如何描述性能

当我们清楚系统的负载是什么以后,就能够研究当负载增加的时候会发生什么,你可以关注两个方面:

  • 当增大负载参数并保持系统资源(CPU、内存、网络带宽等)不变,系统的性能是否会受到影响?
  • 当增大负载参数后,需要相应增加多少系统资源才能使性能保持不变?

所有问题都需要有一个性能指标,让我们简单看一下如何描述一个系统的性能。

在一个批处理系统中如 Hadoop,我们通常关心的是吞吐量——每秒能够处理的记录数或者处理特定大小的数据所花费的时间。而在一个在线系统中,更重要的是服务的相应时间,也就是客户端发送请求到收到响应的时间。
即使多次发送同样的请求,每次的相应时间也是不同的。一个系统会收到大量的请求,其响应时间也会不同,因此我们不能拿一个单独的数值来考虑响应时间,而是应该取一个范围来衡量。

在下图中,每个灰色条代表对服务的一条请求,它的高度显示了请求花费的时间。大部分请求还是很快的,但偶尔有一些会花比较久的时间。或许由于有些请求处理的数据量比较大,所以需要更长的时间,但即使相同一条请求,请求多次的返回时间也会有差异。这是因为在服务器正式处理请求前可能会有额外的延迟,例如网络数据包丢失或 TCP 重传,垃圾处理造成的暂停,强制从磁盘读取的网页错误,服务器机架的机械震动等其他很多原因。

经常可以看到用平均响应时间来描述一个服务的性能,但实际上如果你想知道一个服务的响应时间的真实情况,平均值并不是一个很好的指标,因为你并不能知道有多少用户的请求是有延迟的。

所以一个更好描述响应时间的指标是百分率。如果把一组响应时间按照最快到最慢排序,那么位于中间的点称为 中位数,如果中位数是 200 ms,那就意味着有一半请求响应时间低于 200 ms,还有一半请求的响应时间高于 200 ms。中位数就是一个很好的可以知道用户需要等待多久的指标:一半的请求快于中位数,一半的请求慢于中位数。中位数也缩写成 p50,代表 50% 的情况,如果你想了解比较慢的响应时间是多少,也可以用 95%,99% 或者 99.9% 的阈值,它们的缩写分别是 p95,p99 和 p999。它们的值意味着有 95%,99% 或者 99.9% 的请求响应时间比这个阈值要短。高百分率的响应时间,也称为尾延迟,是非常重要的因为他们直接影响用户体验。

队列延迟往往是高百分比响应时间的主要原因。因为服务器只能并行处理一小部分的事情,可能很少一部分的慢请求就会延迟所有请求的响应时间,即使队列中剩下的请求很快就可以执行完毕,因为必须等在它们之前的慢请求被处理完,这种现象也叫做 “行头阻塞”。在考虑系统的负载的时候我们一定要把这种并发的情况考虑在內,而不能只是单独看每条请求的响应时间,否则测试和生产环境就会有很大的偏差。

如下图,还有一点应当注意的是在实际生产环境中,一个终端用户的请求往往不只请求一个服务,而是同时向服务器请求多个服务。即使这些请求都是同时发送的,最终的返回还是需要等待最慢的那个请求,这就又会增加终端客户请求时响应延迟的可能性。如果你想要将百分率这个指标加入到服务监控的图表中,最直接的想法就是可以每分钟把所有的请求按响应时间排序,当然还有一些算法可以帮助我们提高效率,这里不再多讲。

处理负载的方式

前面我们讨论了描述负载的参数和度量性能的指标,现在我们可以认真地讨论扩展性的问题了:当负载参数增加的时候如何保持高性能?

一个适用于一倍负载的架构不太可能支撑地起十倍的负载,如果你负责的是一个高速发展的系统,负载每提升一个数量级你就需要重新考虑系统架构了,甚至比这频繁都不为过。

扩展通常有两种方式,垂直扩展(使用更强大的机器)和水平扩展(将负载分布到多台较小的机器上,也称为无共享架构)。将系统运行在单一机器上会变得简单些,但是高端的机器都非常昂贵,所以还是免不了水平扩展。实践中好的架构通常是这两种方式的结合体,例如使用少数几台高性能机器可能仍然比使用大量小型虚拟机更加简单和便宜。

有些系统是弹性的,意味着可以在检测到负载增加的时候自动增加系统计算资源,其他的系统就需要人工扩展。如果负载的增加很难预测,那么弹性系统就相当有用,但是人工扩展的系统会更简单并且操作可以控制不会产生意想不到的后果。

将无状态服务分布到多台机器是很简单粗暴的,但是将有状态的数据系统从单节点构造成分布式就会复杂很多,所以目前的共识是将数据库设立在单节点上直到垂直扩展的成本过高或者具有数据高可用的要求时才会考虑分布式存储。随着分布式系统的工具和理论基础越来越完善,这个共识也会发生改变,至少对某些应用是这样。毋庸置疑在未来分布式系统将会成为标配,即使使用场景并不需要处理大数据或者大流量。

一个超大规模系统的架构一般都是对应具体应用的,并不存在一个通用万能的可扩展架构。因为可能有不同的需求,比如读取量,写入量,需要存储的数据量,数据复杂度,响应时间的,访问模式等等都可能成为系统的负载,也可能综合所有这些问题。例如,一个被设计成每秒处理 10 万条请求,每条请求 1 KB 数据量的系统,和一个每分钟处理 3 条请求,每条请求 2 GB 的系统是完全不一样的,即使这两个系统数据吞吐量是相同的。

可维护性

众所周知,软件的主要成本并不在最开始的部署,而是之后持续的维护上。例如解 bug,保持系统正常运行,故障的研究,迁移到新平台,为了新的用户案例做更改,偿还技术债务,增加新功能等。然而很不幸,很多人不喜欢维护这些所谓的 “遗留” 系统,因为可能需要解决其他人的错误或者使用过时的平台。

因此,为了避免我们自己构造一个 “遗留” 软件,或者维护起来很蛋疼的系统,在设计软件的时候我们应该考虑地全面一点,在这里我们主要关注三个原则:

  • 可操作性:运维团队维护系统正常运行时应当相对简单。
  • 简单性:尽量多的移除系统的复杂度,使新的工程师可以更快更容易地了解系统。(注意这个简单和用户层面的简单是不同的)
  • 可进化性:使工程师在未来可以更方便地对系统进行改变,适应因需求改变带来的不可预期用例。也称为延展性、可变性和适应性。

可操作性:使系统更容易操作

有人说差的软件在好的运维下也可以运行,但是差的运维却会使优秀的软件也变得不可靠。即使某些运维可以并且也应该实现自动化,但最开始还是得依靠人类实现自动化并且确保正常运行。

想要使系统平稳运行,运维团队至关重要。一个好的运维团队需要负责下面这些事情,甚至更多:

  • 监控系统是否正常,一旦出现异常能够快速恢复服务
  • 跟踪问题原因,如系统故障或性能下降
  • 保持软件和平台的更新,包括安全补丁
  • 密切关注不同系统之间如何交互,这样就可以在造成损害之前避免可能有问题的更改
  • 预测潜在的问题并且在发生之前解决掉(例如容量预测)
  • 为部署、配置管理等建立良好的实践和工具
  • 执行复杂的维护任务,例如把应用从一个平台迁移到另一个平台
  • 当配置发生改变的时候维护系统的安全性
  • 定义使操作可预测并有助于生产环境稳定的流程
  • 储备系统的相关知识,有利于运维人员的快速学习

好的运维意味着使常规任务变得更简单,这样就可以集中注意力于其他高价值的事情上。数据团队能做很多事情来确保常规任务的简易性,包括:

  • 充分使用监控提供对系统内部和运行时状态的可见性
  • 提供自动化支持并且集成标准工具
  • 避免依赖于单独机器(允许运维时机器可以停止并且系统不会中断运行)
  • 提供好的文档和一个易于理解的操作模型(如果我做了 X,那么 Y 会发生)
  • 提供良好的默认行为,同时给予管理员在必要时覆盖默认配置的自由
  • 在适当的时候进行自修复,但是必要时仍然提供管理员对系统的手动控制
  • 预览可预测行为,尽量减少意外

简单性:管理复杂度

小规模的软件项目一般会很简单并且代码直观,但是随着项目变得越来越大,它们往往会变得非常复杂并且难以理解。这种复杂性会降低系统中工作的每一个人的效率,并且增加未来维护的成本,一个深陷复杂度泥潭的软件项目有时候也称为 “大坑”。

复杂度有多种可能的特征:混乱的状态、系统模块强耦合、复杂依赖、不一致的命名和术语、旨在解决性能问题或其他问题的特殊代码,等等。

复杂度使系统难以维护的同时,预算和计划也会超出预期。复杂系统做出更改有很大风险会引入 bug,当开发者对系统很难理解时,隐藏的问题、意想不到的结果和交互就很容易出现。相反得,降低复杂度可以极大地提升软件的可维护性,所以简单性应该是我们建立系统的关键点。

使一个系统变得简单并不是意味着减少它的功能,而是移除非必须的复杂度,非必须的复杂度是指并不是因为软件需要解决的问题本身而是实现的方式带来的复杂度。

移除非必须复杂度最好的工具之一就是 抽象,优秀的抽象会隐藏很多的实现细节而只是显示干净、易于理解的外观,优秀的抽象也可以用于多种不同的应用。重用明显比多次造轮子效率高,而且可以产生高质量的软件,因为抽象组件的质量提升对所有依赖的应用都有利。

例如,高级变成语言是隐藏了系统码、CPU 寄存器和系统调用等的抽象。SQL 是隐藏了复杂的磁盘和内存数据结构、来自客户端的并发请求和崩溃后的不一致的抽象。当然,即使使用高级语言变成,我们还是会使用到机器码,只不过我们不是直接使用它,因为变成语言的抽象帮助我们不需要考虑它。

可进化性:使改变更简单

一个系统的需求不可能永远保持不变。一个系统越简单和具有良好的抽象,越是容易做出改变。

总结

一个应用需要满足多种需求,有功能性需求(必须实现的功能,例如存储、检索、查询数据)和非功能性需求(通用性性能例如安全性、可靠性、可扩展性、兼容性和可维护性)。本章我们主要讲了可靠性、可扩展性和可维护性。

可靠性意味着使系统正常工作,即使发生了错误。错误可能是硬件错误(一般是随机且无关的),软件错误(bug 一般是系统级别的,很难处理)和人为错误(不可避免总会犯错)。容错机制可以对用户端隐藏某种类型的错误。

可扩展性意味着始终保持良好的性能,即使负载增加。为了讨论可扩展性,我们首先需要可以用数值量的方式来描述负载和性能。在一个可伸缩系统中,你需要增加容量来保证高负载下的可靠性。

可维护性具有很多方面,但是从本质上讲它就是为了更方便于系统的工程师和运维人员。良好的抽象可以降低复杂度和易于系统改变并且适用于新的场景,良好的运维意味着很容易观察系统的安全状况并且高效地进行管理。

猜你喜欢

转载自blog.csdn.net/Trigl/article/details/80686989