读Designing Data-Intensive Applications Chapter 1

许多应用程序是数据密集型的,而不是计算密集型的。对它们来说,CPU能力很少是限制因素,更大的问题是数据的数量、数据的复杂性以及数据变化的速度
许多应用程序需要:
- 将数据存储,以便它们或其他应用程序稍后能够再次找到它(数据库)
- 记住昂贵操作的结果,加快读取速度(缓存)
- 允许用户通过关键字搜索数据或以各种方式过滤数据(搜索索引)
- 向另一个进程发送消息,以便异步处理(流处理)
- 定期处理大量累积数据(批处理)
已出现了许多新的数据存储和处理工具。它们针对各种不同的用例进行优化,不再整齐地适合传统类别,类别间的界限变得模糊
- 有些数据存储也被用作消息队列(Redis)
- 有些消息队列具有类似数据库的持久性保证(Apache Kafka)
越来越多的应用程序有苛刻或广泛的要求,以至于单一工具无法满足其所有数据处理和存储需求。相反,工作被分解成可以在单个工具上高效执行的任务
设计一个数据系统或服务会出现许多棘手的问题
- 当内部出现问题时,如何确保数据保持正确和完整?
- 系统的某些部分降级时,如何向客户提供持续良好的性能?
- 负载增加时,如何扩展来处理?
- 一个好的服务API应该是什么样的?
Three Important concerns:
- Reliability
- Scalability
- Maintainability
Reliability
可靠性为“即使出现问题,也能继续正确工作”
故障不等于失败
- 故障: 系统一个组成部分偏离其规范
- 失败: 指系统整体停止向用户提供所需服务
不可能将故障概率降低到0;因此设计防止故障导致失败的容错机制是最佳选择
违反直觉的是在这种容错系统中,有意识地增加故障率是有意义的
- 通过随机无预警地终止单个进程: 许多关键错误是由于错误处理不当造成的;通过故意引发故障,确保容错机制不断被执行和测试,确保当自然发生故障时能够正确处理
- 例子:Netflix的“混沌猴”(Chaos Monkey)
硬件错误
硬盘平均故障时间(MTTF)约为10到50年。因此一个拥有10,000个硬盘的存储集群中,应该预期平均每天有一个硬盘死亡
为单个硬件组件增加冗余,降低系统故障率。硬盘可能被设置在RAID中,服务器可能有双电源和热插拔CPU,数据中心可能有电池和柴油发电机作为备用电源。当一个组件死亡时,冗余组件可在更换损坏组件时代替它的位置
对于大多数应用程序来说硬件组件的冗余足够了,因为它使单个机器的完全故障相当罕见。只要能在新机器上相当快地恢复备份,故障情况下的停机时间在大多数应用中并不灾难性。只有少数需要高可用性的应用程序才需要多机器冗余
然而随着数据量和应用程序的计算需求增加,越来越多的应用程序开始使用更多的机器,这与硬件故障的比率成正比增加。一些云平台如AWS上,虚拟机实例变得不可用而没有预警是相当常见的,因为这些平台设计时优先考虑了灵活性和弹性,而不是单机器的可靠性
有一种趋势是使用软件容错技术(优先或除了硬件冗余之外)来容忍整个机器的丢失。这样的系统在操作上也有优势:如果需要重启机器(如应用操作系统安全补丁),单服务器系统需要计划停机,而能容忍机器故障的系统可以一次一个节点地打补丁,而不需要整个系统停机
很难理解,感觉软件容错和硬件容错差不太多,可能还是需要具体的实例来理解
软件错误
通常认为硬件故障是随机且彼此独立的:一台机器的硬盘故障并不意味着另一台机器的硬盘会故障。可能存在弱相关性(如服务器架内的温度),但同时发生大量硬件组件故障的可能性不大
另一类故障是系统内部的系统性错误。这类故障更难预测,由于它们在节点间相关,因此比不相关的硬件故障导致的系统故障更多。如:
- 一个软件错误导致在收到特定错误输入时,应用服务器的每个实例都崩溃。如2012年6月30日的闰秒事件,由于Linux内核中的一个错误,导致许多应用程序同时挂起
- 使用某些共享资源的失控进程——CPU时间、内存、磁盘空间或网络带宽
- 系统依赖的某个服务变慢、无响应或开始返回损坏的响应
级联故障,即一个组件中的小故障触发另一个组件中的故障,进而触发更多故障
系统性软件故障问题没有快速解决方案。许多小事情可以帮助:仔细思考系统中的假设和交互;彻底测试;进程隔离;允许进程崩溃和重启;测量、监控和分析生产中的系统行为。如果系统预期提供某种保证(例如,在消息队列中,传入消息的数量等于传出消息的数量),它可以在运行时不断自我检查,并在发现差异时发出警报
human error
一项对大型互联网服务的研究发现,操作人员的配置错误是导致停机的主要原因,而硬件故障(服务器或网络)在停机中只起了10-25%的作用
- 以减少犯错机会的方式设计系统。例如,设计良好的抽象、API和管理界面可以轻松地做到“正确的事情”,并阻止“错误的事情”。然而,如果界面过于限制性,人们将绕过它们,从而抵消它们的好处,因此这是一个难以把握的平衡。
- 将人们最容易犯错误的地方与他们可能导致失败的地方解耦。特别是,提供功能齐全的非生产沙箱环境,让人们可以安全地探索和实验,使用真实数据,而不影响真实用户。
- 从单元测试到整个系统集成测试和手动测试,进行彻底测试【3】。自动化测试被广泛使用,理解良好,对于覆盖在正常运行中很少出现的边缘情况尤其有价值。
- 允许从人为错误中快速轻松恢复,以尽量减少故障的影响。例如,快速回滚配置更改,逐步推出新代码(以便任何意外错误只影响少部分用户),并提供重新计算数据的工具(以防旧计算出现错误)。
- 设置详细和清晰的监控,例如性能指标和错误率。在其他工程学科中,这被称为遥测。(一旦火箭离开地面,遥测对于追踪正在发生的事情和了解故障至关重要【14】。)监控可以向我们展示早期警告信号,并允许我们检查是否有任何假设或约束被违反。当出现问题时,指标在诊断问题上是无价的。
Scalability
我们需要简洁地描述系统当前的负载;只有这样,我们才能讨论增长问题(如果我们的负载翻倍会发生什么?)。负载可以用我们称之为负载参数的几个数字来描述。参数的最佳选择取决于您的系统架构:它可能是对Web服务器的每秒请求数,数据库中读写的比例,聊天室中同时活跃的用户数量,缓存的命中率,或者其他什么。也许对您而言,平均情况是重要的,或者您的瓶颈由少数极端情况主导。
以Twitter为例,使用2012年11月发布的数据【16】。Twitter的两个主要操作是:
- 发布推文:用户可以向其关注者发布新消息(平均每秒4.6k个请求,高峰时超过12k个请求/秒)。
- 首页时间线:用户可以查看他们关注的人发布的推文(每秒300k个请求)。
单纯处理每秒12,000次写入(发布推文的高峰速率)相对容易。然而,Twitter的扩展挑战并不主要是由于推文量,而是由于“扇出”——每个用户关注许多人,而且每个用户被许多人关注
为每个用户的首页时间线维护一个缓存——就像每个接收用户的推文邮箱(见图1-3)。当用户发布推文时,查找关注该用户的所有人,并将新推文插入他们每个人的首页时间线缓存中。读取首页时间线的请求变得便宜,因为其结果已经提前计算好了。
缺点是,现在发布推文需要更多额外的工作。平均而言,每条推文被传递给大约75个关注者,因此每秒4.6k条推文变成了向首页时间线缓存的每秒345k次写入。但这个平均值掩盖了一个事实,即每个用户的关注者数量差异很大,有些用户有超过3000万的关注者。这意味着一条推文可能导致超过3000万次对首页时间线的写入!在及时的方式下完成这一任务——Twitter试图在五秒内将推文传递给关注者——是一个重大的挑战。
以Twitter为例,用户每位关注者的分布(可能按照这些用户发推的频率加权)是讨论可扩展性的关键负载参数,因为它决定了扇出负载。您的应用程序可能有非常不同的特点,但您可以应用类似的原则来推理其负载。
Twitter轶事的最后一点:现在方法2已经稳健实施,Twitter正在向两种方法的混合迁移。大多数用户的推文在发布时继续被扇出到首页时间线,但有一小部分拥有非常多关注者的用户(即名人)被排除在扇出之外。任何用户可能关注的名人的推文被单独获取,并在读取时与该用户的首页时间线合并,就像方法1一样。这种混合方法能够提供一致的良好性能
在像Hadoop这样的批处理系统中,我们通常关心的是吞吐量——我们每秒可以处理的记录数,或者在特定大小的数据集上运行作业所需的总时间。在在线系统中,通常更重要的是服务的响应时间——即客户端发送请求和接收响应之间的时间。延迟和响应时间
延迟和响应时间经常被混用,但它们并不相同。响应时间是客户端看到的:除了实际处理请求的时间(服务时间)外,它还包括网络延迟和排队延迟。延迟是请求等待处理的持续时间——在此期间,它处于潜伏状态,等待服务。
即使您一次又一次地只发送相同的请求,每次尝试的响应时间也会略有不同。实际上,在处理各种请求的系统中,响应时间可能会有很大的变化。因此,我们需要将响应时间视为一组可以测量的分布值,而不是单一数字。
中位数成为一个好的度量标准,如果你想知道用户通常需要等待多久:用户请求的一半在中位响应时间内得到服务,另一半则需要更长时间。中位数也被称为第50百分位数,有时缩写为p50。请注意,中位数指的是单个请求;如果用户发出多个请求(在一次会话中或因为单个页面包含多个资源),那么至少有一个请求慢于中位数的概率远大于50%。
为了弄清楚你的异常值有多糟糕,你可以查看更高的百分位数:常见的是第95、99和99.9百分位数(分别缩写为p95、p99和p999)。它们是响应时间阈值,表示95%、99%或99.9%的请求比该特定阈值快。例如,如果第95百分位响应时间是1.5秒,这意味着100个请求中有95个在1.5秒内完成,5个请求需要1.5秒或更长时间
响应时间的高百分位数,也称为尾部延迟,很重要,因为它们直接影响用户对服务的体验。例如,亚马逊描述其内部服务的响应时间要求是根据第99.9百分位数,尽管它只影响1000个请求中的1个。这是因为请求最慢的客户往往是那些在其账户上有最多数据的客户,因为他们进行了很多购买——也就是说,他们是最有价值的客户。重要的是通过确保网站对他们来说很快来保持这些客户的满意度:亚马逊还观察到,响应时间增加100毫秒会导致销售额下降1%,其他报告称,1秒的延迟会导致客户满意度指标下降16%。
另一方面,对亚马逊来说,优化第99.99百分位数(最慢的1万个请求中的1个)被认为代价太高,收益不足。在非常高的百分位数降低响应时间是困难的,因为它们容易受到你无法控制的随机事件的影响,而且收益递减。
例如,百分位数经常用于服务级目标(SLO)和服务级协议(SLA),这些是定义服务预期性能和可用性的合同。SLA可能会声明,如果服务的中位响应时间小于200毫秒,并且第99百分位数小于1秒(如果响应时间更长,它可能同样被认为是停机),则认为服务是正常的,且该服务至少需要在99.9%的时间内正常运行。这些指标为服务的客户端设置了预期,并允许客户在SLA未达到时要求退款。
在高百分位数时,队列延迟通常占响应时间的很大一部分。由于服务器只能并行处理少量事务(例如,受其CPU核心数的限制),只需少量慢请求就足以阻碍后续请求的处理——这种效应有时被称为排头阻塞。即使这些后续请求在服务器上处理得很快,由于等待前一个请求完成的时间,客户端会看到整体响应时间变慢。因此,在客户端测量响应时间很重要。
为了测试系统的可扩展性而人为产生负载时,负载生成客户端需要独立于响应时间持续发送请求。如果客户端等待前一个请求完成后再发送下一个请求,这种行为会造成在测试中人为地保持队列比现实中短,从而扭曲了测量结果。
在后端服务中,高百分位数尤其重要,这些服务在处理单个最终用户请求时会被多次调用。即使你并行进行调用,最终用户请求仍然需要等待最慢的并行调用完成。只需一个慢调用就会使整个最终用户请求变慢,如图1-5所示。
即使只有少量的后端调用很慢,如果最终用户请求需要多个后端调用,获取慢调用的机会就会增加,因此最终用户请求的比例变慢的机会更大(这种效应被称为尾部延迟放大)。
如果你想将响应时间百分位数添加到你的服务的监控仪表板中,你需要持续有效地计算它们。例如,你可能想保持最近10分钟内请求的响应时间的滚动窗口。每分钟,你可以计算该窗口中的中位数和各种百分位数,并将这些指标绘制在图表上。
简单的实现是保留时间窗口内所有请求的响应时间列表,并每分钟对该列表进行排序。如果这对你来说效率太低,有一些算法可以以最小的CPU和内存成本计算百分位数的良好近似值,例如前向衰减、t-digest或HdrHistogram。请注意,对百分位数进行平均,例如为了降低时间分辨率或将几台机器的数据结合起来,在数学上是没有意义的——聚合响应时间数据的正确方法是添加直方图。
适用于某一负载水平的架构不太可能应对该负载的10倍。因此,如果你正在快速增长的服务上工作,很可能你需要在每个数量级的负载增加时——或许甚至更频繁地——重新思考你的架构。
人们经常谈论纵向扩展(垂直扩展,迁移到更强大的机器)和横向扩展(水平扩展,将负载分散到多个较小的机器上)之间的二分法。在多台机器上分散负载也被称为无共享架构。在单台机器上运行的系统通常更简单,但高端机器可能非常昂贵,因此非常密集的工作负载通常无法避免横向扩展。实际上,好的架构通常涉及方法的实用混合:例如,使用几台相当强大的机器仍然比大量小型虚拟机更简单、更便宜。
一些系统是弹性的,意味着它们可以在检测到负载增加时自动添加计算资源,而其他系统则是手动扩展的(人类分析容量并决定向系统中添加更多机器)。如果负载高度不可预测,弹性系统可能很有用,但手动扩展的系统更简单,可能有更少的操作意外(见“重新平衡分区”)。
尽管在多台机器上分发无状态服务相对简单,但将有状态数据系统从单节点扩展到分布式设置可能会引入大量额外的复杂性。因此,直到最近的普遍智慧都是在单个节点上保持你的数据库(纵向扩展),直到扩展成本或高可用性要求迫使你使其分布式。
随着分布式系统的工具和抽象变得更好,这种普遍智慧可能会改变,至少对于某些类型的应用程序而言。可以想象,即使对于不处理大量数据或流量的用例,分布式数据系统也将成为未来的默认选择
在早期阶段的初创企业或未经验证的产品中,能够快速迭代产品功能通常比扩展到某个假设的未来负载更重要。
Maintainability
众所周知,软件的大部分成本不在于其最初的开发,而在于其持续的维护——修复错误、保持其系统运行、调查故障、适应新平台、修改新用例、偿还技术债务和添加新功能。
软件系统的三个设计原则:
- Operablity 可操作性
- Simplicity 简单性
- Evolvability 可演变性
运维团队对于保持软件系统平稳运行至关重要。一个好的运维团队通常负责以下工作,甚至更多[29]:
- 监控系统健康状况,并在系统状态不佳时迅速恢复服务
- 追踪问题的根源,如系统故障或性能下降
- 更新软件和平台,包括安全补丁
- 监控不同系统之间的相互影响,以便在问题变化造成损害之前避免它
- 预测未来的问题,并在它们发生之前解决(例如,容量规划)
- 建立部署、配置管理等方面的良好实践和工具
- 执行复杂的维护任务,如将应用程序从一个平台迁移到另一个平台
- 在进行配置更改时保持系统的安全
- 定义流程,使运维可预测并帮助保持生产环境稳定
- 即使个人来来去去,也保留组织对系统的知识
数据系统可以做很多事情来简化常规任务,包括:
- 通过良好的监控提供系统运行时行为和内部情况的可见性
- 为自动化提供良好的支持,并与标准工具集成
- 避免对单个机器的依赖(允许在系统整体持续运行的同时进行机器维护)
- 提供良好的文档和易于理解的操作模型(“如果我做X,Y会发生”)
- 提供良好的默认行为,但在需要时也给管理员自由覆盖默认设置的权力
- 在适当时自愈,但在需要时也给管理员对系统状态的手动控制权
- 表现出可预测的行为,尽量减少意外
复杂性的各种可能症状包括:状态空间的爆炸、模块之间的紧密耦合、纠缠的依赖关系、不一致的命名和术语、旨在解决性能问题的黑客攻击、为了解决其他地方的问题而进行特殊处理等等
我们去除偶然复杂性的最好工具之一是抽象。一个好的抽象可以在干净、易于理解的外观后面隐藏大量的实现细节。一个好的抽象也可以用于广泛的不同应用。这种重用不仅比多次重新实现类似的东西更有效,而且还会导致更高质量的软件,因为对抽象组件的质量改进将惠及所有使用它的应用。
敏捷工作模式提供了适应变化的框架。敏捷社区还开发了在频繁变化的环境中开发软件时有用的技术工具和模式,例如测试驱动开发(TDD)和重构。