《DDIA》读书笔记第一章

之前一直听说Designing Data-Intensive Applications (DDIA) 这本书是神书,也决定读一下,顺便做些笔记,也算巩固一下学到的东西吧。

书的第一部分,主要关注于一些基础的知识,第一章的标题Reliable, Scalable, and Maintainable Applications就讲了三个当前应用的最主要特点:可靠性,可扩展性和可维护性。

可靠性

首先是Reliability也就是可靠性,主要包含下面的几个预期:

  • 应用可以按用户所期待的功能正常运行
  • 可以容忍用户犯的错误或者不正确的使用方式
  • 在对应的系统容量下性能能满足正常的使用要求
  • 能拒绝未授权或者滥用的情况

如果能满足上面的需求,可以说一个软件是可靠的,但是并不是所有的东西都能满足预期,当一些意料之外的东西发生了,称之为faults,系统正确应对这些faults的能力则称作fault-tolerant or resilient(即容错能力或者弹性),虽然容错能力很重要,但也不是意味着可以实现一个能容忍任何错误的系统(比如地球爆炸了)。
需要注意的是,faultfailure是不一样的,前者一般指的是系统某个部分没有按照设计正常工作,而后者一般就意味着整个系统都无法正常提供服务了。当然,我们没有办法去降低fault发生的概率,特别是降低到0,能做的,就是当fault发生时,系统不会因为这些faults变成failure状态,这也是一个容错系统的设计目标。
针对一个系统,我们可以人为的提升faults的发生概率,来验证系统的可靠性,比如kill某个进程,或者断开网络等等。一般情况下,比较严重的bug都是因为对错误的处理不完善导致的。
当然尽管我们可以通过设计容忍很多错误,但是预防错误的发生,远远比发生后再去修复来的好,毕竟很多错误是没办法被修复的,比如数据库被黑客入侵,这个操作无法被修复到原始的样子(数据已经泄漏)。
常见的错误主要有三个:

硬件故障

硬件故障是最容易想到的故障,比如硬盘损坏、机房掉电、网络问题、内存问题等等,对于一个大型的数据中心来说,这些故障几乎是每时每刻都在发生的。
对于磁盘,一般情况下,磁盘的平均无故障时间(MTTF)在10到50年这个级别,也就意味着,如果一个存储集群有超过10000个磁盘,那理论上每一天都会出现磁盘损坏的情况。
而针对一些硬件的冗余措施,可以减少故障发生的概率,比如引入RAID,实现CPU、内存的热切换,多路电源供应,UPS等等。
在一般情况下,一台机器在这些硬件冗余措施的帮助下发生失效的概率很低,即使发生失效,很多也都可以通过备份进行恢复,针对很多较小的应用,问题不是很大,只有在某些特别重要的应用上,多机部署实现高可用才显得必要一点。
并且,随着如AWS等云计算的发展,单个虚拟机的可用性已经到了一个很高的高度。但是设计使用可以容忍一台机器丢失的多机器系统还是有优势的,比如某些机器因为系统升级或者打补丁等需要重启的情况下,这样的系统也是可以保证系统可以一直正常运行。

软件错误

对于硬件故障,一般来说,都是偶发的,比如一台机器的硬盘出现问题基本也不会预示着另外的机器也出问题,当然也有一些情况,比如整个机架的机器因为散热问题都出问题了,但是毕竟这是小概率,而且这种同一时间大量机器硬件出现问题的概率实在很小。
另外的错误集中在系统内部,往往这类错误是跨机器的,很难预测,这些问题一旦出现,一般会比硬件故障产生更严重的后果,包括一下的一些情况:

  • 某个特殊的输入导致的软件bug,比如闰秒触发Linux内核的一个bug导致系统挂起
  • 某个进程失控,导致占用了过多的资源,CPU、内存、磁盘、网络等
  • 系统依赖的某个服务响应变慢、或者失去响应、或者返回了错误的结果
  • 级联故障,一个小组件故障导致了其他组件跟着故障

很多时候这些问题不会被触发,但是一旦触发了,影响会是比较大的,而且,这些问题不容易被很快的修复,所以在开发过程中,注意更多的边界条件,引入更多的测试,在运行中不断监控自身状态并在异常时发出告警是有必要的。

人为故障

人是系统的设计和建造者,同样也是系统的运维者,即使给予最大限度的注意,也不能避免人是不可靠的这一事实。在一个大型互联网服务的调查中显示,在大的故障中真正由硬件问题导致的仅占10-25%左右。
所以对于人来说,需要达到的目标:

  • 在设计时最大限度减少错误机会,比如精心设计系统抽象,API和界面更容易让人作出“正确的事”并且阻止”错误的事“,但是接口限制太多,反而会让人们要想办法绕过限制去做事,这里也需要找到一个平衡点。
  • 将人们最容易犯错的地方和实际位置分开,比如使用全功能的沙箱测试环境,可以让人安全地做测试和探索,使用真实数据,这样不会影响线上系统。
  • 所有级别的测试,从单元测试到全系统集成测试,以及手动测试。自动化测试在触发很多细小边界条件测试上是很有价值的。
  • 允许从人为错误中快速轻松地恢复,以最大限度地减少人为错误导致的影响。例如,快速回滚配置更改,上线时逐步灰度上线等。
  • 详细和明确的监控,例如性能指标和错误率。在其他工程学科中,这被称为遥测。 (一旦火箭已经离开了地面,遥测对追踪发生的事情至关重要,并能更好的追踪失败的原因。)监测可以向我们展示早期的问题预警,发生问题时,监控数据对于诊断问题是非常有用的。
  • 实施良好的管理实践和培训。

可靠性有多重要?

可靠性不是在核电站或者控制交管系统中才有用的,对于日常使用的所有系统,都应该可以按预计正常的工作。在商业软件里,一个错误可能导致商业利益受损,即使在一些非关键性的应用中,可靠性也是对用户的责任之一,比如用户将照片存放在系统中,如果丢失,他们会知道怎么去做恢复么?
当然在某些情况下,也是会一定程度牺牲可靠性来减少开发成本和运营成本的,比如在原型系统中,或者利润率很低。但是如果这么做了,一定是非常明确为什么要这么做的前提下。

可扩展性

即使一个系统在目前工作的很好,不代表他在未来也能如此。一个最常见的导致降级的原因就是负载,比如系统并发用户从10000上涨到了100000,或者从100万到1000万,或者处理的数据量有了非常大的增长。
可扩展性就是用来描述系统应对负载增加能力的指标。但是他不是一个标签,比如X是可扩展的或者Y是不可扩展的,这类的描述是没有意义的。更多的,我们应该考虑以下的一些问题:如果系统以特定方式增长,我们应对增长的方式有哪些?或者说,我们应该如何增加计算资源来应对这些额外的负载?

描述负载

首先需要描述一下什么是负载,这样才能讨论关于负载增长相关的话题,负载可以从几个参数上描述,根据系统的不同而不同,比如,对于web server来说是QPS,对于数据库来说是读写比,对于一个聊天室来说是同时在线的人数,或者其他比如缓存命中率等等。可能系统是受一些很常见的因素影响,也可能是一些很小的极端情况导致了系统的瓶颈。
以Twitter 2012年的数据为例:Twitter主要有两个操作:

发推:用户给关注者发推(平均4.6k请求/s,峰值12k请求/s)
主信息流:用户刷新自己的时间线,查看别人发的推(300k请求/s)

如果仅仅是简单的处理12k/s的写请求(发推峰值),其实是比较简单的。但是Twitter面临的最大问题不是用户发推的容量,而是fan-out(电子行业的扇出问题),每个人都被很多人关注,每个人又关注很多人。所以大致有两种方式来处理上面说的两种基本操作:

  1. 在发推时,直接发到一个公共的数据库里,然后用户在刷新自己的信息流时,查询一下自己关注了哪些人,然后去这些人的推文列表里查找最近发的推文,最后合并一下进行显示。最后的SQL语句与下面的类似:
    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
  2. 为每个人的主信息流维护一个缓存,用户发推时,先查找所有关注自己的人,然后在往所有关注自己人的缓存中都写一条当前的推文,这样用户在刷新自己的主信息流时会快很多,因为结果已经在之前都计算好了。

最初的Twitter使用的是第一种实现,但是系统在用户量增加时发现主信息流查询的负载跟不上了,于是又切换到了第二种实现,相比之前要好一些,因为发推相比刷推来说频率要低一点,所以在发推的时候多做点事减少刷推时的成本是没错的。
但是呢,第二种方法在发推时需要做的事要多很多。平均来看,每条推文要发送给75个关注者,也就是说4.6k/s的推文会产生345k/s的缓存写,这仅仅是平均情况,实际上每个人的关注人数差别很大,有些用户的关注者超过了3000万,这意味着发一条简单的推文需要超过3000万次写缓存操作!在一定时间内做到这些(Twitter尝试在5s内做完)是一个非常大的挑战。
在Twitter的例子里,每个用户关注者的分布是作为可扩展性的一个关键指标来讨论的,因为这个直接影响到了fan-out的负载。
而最终的Twitter实现了一个混合型的架构,大多数的用户使用的是类似方案2的方式,但是对于一些关注者特别多的用户,使用的是类似方案1的模式,这套方案可以算达到了比较好的性能。

描述性能

一旦描述好了系统的负载,就可以继续看当负载提升时系统的变化了,主要有两个方向:

  • 当提升负载并且保持系统资源(CPU,内存,网络等)不变时,系统的性能会有什么影响?
  • 当负载提升了,需要增加多少的系统资源才能保持性能和之前一样?

这两个问题都需要使用到性能数据,所以还得先看看如何去描述一个系统的性能。
在类似Hadoop这种的批处理系统中,我们一般关系的是吞吐量,即每秒时间能够处理的数据量,或者处理一个固定数据集所需要花费的时间。而针对一个在线系统,通常最重要的是服务的响应时间,也就是客户端发送请求到接收到请求返回的时间。

延迟(latency)和响应时间(response time)通常被放在一起讨论,但其实两者还是有一些区别的,响应时间是客户端所见的,包括处理时间,网络延迟以及排队时间。而延迟是一个请求等待被处理的时间。

即使不停的发起完全相同的一个请求,响应时间还是会有轻微的不同的,所以在实践中,考虑的不仅仅是某个请求的响应时间,而应该考虑很多请求的响应时间的分布情况。在正常情况下,大多数请求都很快,但是还是会有一些比较慢。也许这些慢请求是处理了更多的数据,更随机的情况,比如遇到了进程的上下文切换,或者TCP丢包重传了,遇到了GC的停顿,或者内存缺页需要从磁盘读取数据,甚至是机架震动了等等各种问题。
一般情况下很容易计算一个服务的平均响应时间,但是平均响应时间典型性不强,因为它并没有告诉你到底有多少用户真正经历了延迟。
所以更好的办法,是看百分比,将所有的请求时间进行从快到慢排序,然后中位数就是最中间的响应时间,假如中位响应时间是200ms,也就意味着一半的请求是超过200ms的,另一半则是低于200ms,这可以作为一个不错的考察典型用户等待时间的数据,即一半用户的请求小于中位响应时间,另一半是大于的。这个中位数也被叫做50%响应时间,或者p50,需要注意的是,这个数据仅仅针对的是一个请求,如果用户有多个请求,比如一个页面里有多个请求,那么大概率整个页面请求时间是会超过p50的。

为了真正了解系统的情况,更多的时候是看一个更高的百分比数据如95%,99%,99.9%(p95,p99,p999)。
高百分比的响应时间,也被称作长尾延迟,这些长尾很重要,因为这些是实实在在影响用户体验的。举个Amazon的例子,对于p999,1000个请求中仅仅只有1个请求比较慢,但是这个慢的请求很有可能就是一个数据量大的用户,因为他经常进行购物。也就是最有价值的用户会受到影响。Amazon表示每100ms的延迟增长都会导致约1%的销售下降,而1s的下降降低了高达16%的客户满意度。
另一方面,尝试去优化%99.99请求时间(10000个请求中最慢的1个)对于Amazon来说又太昂贵了,并且没办法带来更多的好处。针对非常高百分比请求的优化是非常困难的,因为这些请求非常随机,而且超出控制,并且收益也相对较小。
百分比数据通常用于定义服务级别目标(SLO)服务级别协议(SLA)
排队延长通常是慢请求的主要原因,毕竟服务器的并行处理能力是有限的,如果有一些慢请求堵塞了所有的执行器,就会造成所有后续的请求都会变慢,这就是所说的队头阻塞问题,所以以客户端角度去计算相应时间是非常重要的。
针对这一点,在测试的时候,生产请求的时候所有的请求都应该独立,而不是当一个请求结束后再发起另一个请求,因为这样会认为的缩短服务端的请求队列,这个和实际情况也是不相符的。
高百分比的时间对于需要多次调用的后端服务来说很重要,因为即使是异步地发起这些请求,最终也需要等待最慢的请求返回后客户端才能继续完成任务,这样哪怕很小部分的请求变慢都会影响很多的用户请求。

应对负载的方法

一般来说在当前的业务环境下比较适合的架构不太能适应10倍的负载增长,对于快速增长的服务,基本需要在每个数量级都需要思考架构的变化。

人们经常将扩展分成两种:scaling up(垂直扩展,升级到更好的服务器)和scaling out(水平扩展,将负载分配到更多的小服务器上),将负载均衡到很多太机器上又叫做shared-nothing架构,一般来说在一台机器上运行的系统会比较简单,但是高端的机器又很贵,所以业务量很大的业务通常都避免不了水平扩展,在现实世界里,好的架构是务实的,有时候用少量强大的机器也比很多小机器要便宜和简单。

有些系统是弹性的,可以在资源不足时自动发现并添加资源,而其他的系统就需要手动计算需要的容量手动扩容。弹性的系统对于负载不确定的业务很有用,但是手动操作通常更简单,而且遇到意外的情况更少。
无状态服务扩展到多台机器很简单,但是有状态的数据服务想要这么做就比较困难了,所以对于类似的情况,优先考虑垂直扩展,等到单机成本太高,或者对可用性有要求的时候,再迁移到分布式的系统。

随着分布式系统相关的工具和抽象方法越来越完善,优先考虑垂直扩展的思路可能会转变,至少对于某些类型的应用程序而言,分布式系统将成为未来的标配。
很多大型应用的场景非常的具体,也就是说,没有一种通用的,通吃的可扩展体系架构,系统里遇到的问题,可能是读容量,也可能是写容量,或者是存储的数据量,数据的复杂度,响应时间要求,访问模式等等一种或者混合在一起。
例如一个每秒处理100000个1KB请求的系统和一个每分钟处理3个2GB请求的系统,虽然从吞吐量上看是一致的,但是显然设计的重点完全不同。
针对一个特定的应用,实现好的扩展性是基于一个简单的假设,即哪些操作是常见的,哪些操作是不常见的。如果这个假设都是错误的话,那在这个基础上所做的这些设计都是无用功。所以在很多初创公司或者不太成熟的公司里,能够快速迭代产品比应用能够快速扩展要更重要一些。

可维护性

包含三点:

  • 可操作
    让运维人员更容易保证整个系统正确稳定运行
  • 简单
    让新人能更快的了解整个系统,尽可能的移除系统的复杂特性
  • 可进化
    让工程师可以更方便的对整个应用增加新的功能和特性