【读书笔记】《数据密集型应用系统设计》第1章 可靠、可扩展与可维护的应用系统
数据密集型应用通常也是基于标准模块构建而成,每个模块负责单一的常用功能。例如,许多应用系统都包含以下模块 :
-
-
数据库:用以存储数据,这样之后应用可以再次面问。
-
高速缓存 :缓存那些复杂或操作代价昂贵的结果,以加快下一次访问。
-
索引 : 用户可以按关键字搜索数据井支持各种过掳 。
-
流式处理:持续发送消息至另一个进程,处理采用异步方式。
-
批处理 : 定期处理大量的累积数据。
-
在本章 ,我们将首先探讨所关注的核心设计目标:可靠 、可扩展与可维护的数据系统。澄清本源,解析处理之道,建立后续章节所需的基本要点。
认识数据系统
我们通常将数据库、队列、高速缓存等视为不同类型的系统 。虽然数据库和消息队列存在某些相似性,例如两者都会保存数据(至少一段时间),但他们却有着截然不同的访问模式,这就意味着不同的性能特征和设计实现。但系统之间的界限正在变得模糊。例如,Redis既可以用于数据存储也适用于消息队列,Apache Kafka作为消息队列也具备了持久化存储保证。
越来越多的应用系统需求广泛,单个组件往往无能满足所有数据处理与存储需求。因而需要将任务分解,每个组件负责高效完成其中一部分,多个组件依靠应用层代码驱动有机衔接起来。
影响数据系统设计的因素有很多,其中包括相关人员技能和经验水平 、遗留系统依赖性、交付周期、对不同风险因素的容忍度、监管合规等。这些因素往往因时因地而异。本书将专注于对大多数软件系统都极为重要的三个问题:
可靠性 (Reliability)
当出现意外情况如硬件、软件故障、人为失误等,系统应可以继续正常运转:虽然性能可能有所降低,但确保功能正确。具体请参阅本章后面的“可靠性”一节。
可扩展性 (Scalability)
随着规模的增长 ,例如数据量 、流量或复杂性,系统应以合理的方式来匹配这种 增长,具体请参阅本章后面的 “可扩展性” 一节。
可维护性 (Maintainability)
随着时间的推移,许多新的人员参与到系统开发和运维,以维护现有功能或适配新场景等,系统都应高效运转。具体请参阅本章后面的“可维护性”一节。
可靠性
对于软件,典型的可靠性包括:
-
-
应用程序执行用户所期望的功能。
-
可以容忍用户出现错误或者不正确的软件使用方怯 。
-
性能可以应对典型场景、合理负载压力和数据量。
-
系统可防止任何未经授权的访问和滥用。
-
总结为:即使发生了某些错误,系统仍可以继续正常工作。
可能出错的事情称为错误( faults )或故障,系统可应对错误则称为容错( fault tolerant )或者弹性( resilient )。容错略显误导,似乎暗示着系统可以容忍各种可能的故障类型,显然实际中这是不可能的。容错总是指特定类型的故障 ,这样的系统才更有实际意义。
故障与失效( failure )不完全一致。故障通常被定义为组件偏离其正常规格,而失效意味系统作为一个整体停止,无法向用户提供所需的服务。然我们通常倾向于容忍故障而不是预防故障, 但是也存在“预防胜于治疗”的情况,安全问题就是一例,例如:如果攻击者破坏了系统井窃取了敏感数据,则该事件造成的影响显然无法被撤销。
硬件故障
有研究证明硬盘的平均无故障时间( MTTF )约为 10 ~ 50年。 因此,在一个包括10000个磁盘的存储集群中,我们应该预期平均每天有一个磁盘发生故障。我们的第一个反应通常是为硬件添加冗余来减少系统故障率。直到最近,采用硬件冗余方案对于大多数应用场景还是足够的,它使得单台机器完全失效的概率降为非常低的水平。只要可以将备份迅速恢复到新机器上,故障的停机时间在大多数应用中并不是灾难性的。
但是,随着数据量和应用计算需求的增加, 更多的应用可以运行在大规模机器之上,随之而来的硬件故障率呈线性增长。例如,对于某些云平台(如Amazon Web Services, AWS),由于系统强调的是总体灵活性与弹性而非单台机器的可靠性,虚拟机实例经常会在事先无告警的情况下出现无法访问问题。
因此,通过软件容错的方式来容忍多机失效成为新的手段,或者至少成为硬件容错的有力补充 。这样的系统更具有操作便利性,例如,当需要重启计算机时为操作系统打安全补丁,可以每次给一个节点打补丁然后重启,而不需要同时下线整个系统(即滚动升级)。
软件错误
另一类故障则是系统内的软件问题。这些故障事先更加难以预料,而且因为节点之间是由软件关联的,因而往往会导致更多的系统故障。
导致软件故障的bug通常会长时间处于引而不发的状态,直到碰到特定的触发条件。 这也意味着系统软件其实对使用环境存在某种假设,而这种假设多数情况都可以满足,但是在特定情况下,假设条件变得不再成立。
软件系统问题有时没有快速解决办法,而只能仔细考虑很多细节,包括认真检查依赖的假设条件与系统之间交互 ,进行全面的测试,进程隔离,允许进程崩愤并自动重启,反复评估,监控并分析生产环节的行为表现等。如果系统提供某些保证,例如,在消息队列中,输出消息的数量应等于输入消息的数量,则可以不断地检查确认 ,如发现差异则立即告警(frog/mafka?)。
人为失误
如果我们假定人是不可靠的,那么该如何保证系统的可靠性呢?可以尝试结合以下多种方法:
-
-
以最小出错的方式来设计系统。例如,精心设计的抽象层、 API以及管理界面(入口收拢?), 使“做正确的事情”很轻松,但搞坏很复杂。但是,如果限制过多,人们就会想法来绕过它,这会抵消其正面作用。因此解决之道在于很好的平衡。
-
想办法分离最容易出错的地方、容易引发故障的接口。特别是,提供一个功能齐全但非生产用的沙箱环境(test环境?),使人们可以放心的尝试、体验,包括导入真实的数据,万一出现问题,不会影响真实用户。
-
充分的测试:从各单元测试到全系统集成测试以及手动测试。自动化测试已被广泛使用,对于覆盖正常操作中很少出现的边界条件等尤为重要。
-
当出现人为失误时,提供快速的恢复机制以尽量减少故障影响 。例如,快速回滚(Plus回滚包?)配置改动,滚动发布新代码(这样任何意外的错误仅会影响一小部分用户)(灰度发布?),并提
供校验数据的工具(防止旧的计算方式不正确)。
-
设置详细而清晰的监控子系统,包括性能指标和错误率(raptor?)。在其他行业称为遥测 (Telemetry ), 一旦火箭离开地面,遥测对于跟踪运行和了解故障至关重要。监控可以向我们发送告警信号,井检查是否存在假设不成立或违反约约束件等。这些检测指标对于诊断问题也非常有用。
-
推行管理流程井加以培训(CR & 上线checklist?)。这非常重要而且比较复杂,具体内容已超出本书范围。
-
可靠性的重要性
可靠性绝不仅仅针对的是核电站和空中交管软件之类的系统,很多应用都需要可靠工作。商业软件中的错误会导致效率下降(如数据报告错误,甚至带来怯律风险),电子商务网站的暂停会对营收和声誉带来巨大损失。
即使在所谓“非关键”应用中,我们也应秉持对用户负责的态度。例如一对父母,将其所有的照片以及他们孩子的视频存放在你的照片应用中。如果不幸发生了数据库损坏,他们的感受可想而知,他们是否知道该如何从备份数据来执行恢复?
当然,也会存在其他一些情况,例如面对不太确定的市场开发原型系统,或者服务的利润微薄,有时也会牺牲一些可靠性来降低开发成本或者运营开销,对此,我们总是建议务必三思后行。
可扩展性
即使系统现在工作可靠 ,并不意味着它将来一定能够可靠运转。发生退化的一个常见原因是负载增加。可扩展性是用来描述系统应对负载增加能力的术语。
描述负载
负载可以用称为负载参数的若干数字来描述。参数的最佳选择取决于系统的体系结构,它可能是Web服务器的每秒请求处理次数,数据库中写入的比例,聊天室的同时活动用户数量,缓存命中率等。有时平均值很重要,有时系统瓶颈来自于少数峰值。
以Twitter为例,Twitter的两个典型业务操作是:
-
发布twitter消息:用户可以快速推送新消息到所有的关注者,平均大约 4.6k request/sec,峰值约12k requests/sec 。
-
主页时间线( Home timeline )浏览:平均300k request/sec 查看关注对象的最新消息。
Twitter扩展性的挑战重点不在于消息大小, 而在于巨大的扇出(fan-out)结构 :每个用户会关注很多人,也会被很多人圈粉。
此时大概有两种处理结构:
1、将发送的新tweet插入到全局的tweet集合中。当用户查看时间线时,首先查找所有的关注对象,列出这些人的所有tweet ,最后以时间为序来排序合井。如果以关系型数据库模型,可以执行下面的查询语句
SELECT tweets.*, users.* FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.foller_id = current_user
2、对每个用户的时间线维护一个缓存 ,如图1-3所示, 类似每个用户一个tweet邮箱。当用户推送新tweet肘,查询其关注者,将tweet插入到每个关注者的时间线缓存中。因为已经预先将结果取出,之后访问时间线性能非常快。
然而,方法2的缺点也很明显,在发布tweet时增加了大量额外的工作。考虑平均75个关注者和每秒4.6k的tweet ,则需要每秒4.6 × 75 = 345k速率写入缓存。但是,75这个平均关注者背后还隐藏其他事实,即关注者其实偏差巨大,例如某些用户拥有超过3000万的追随者。这就意味着峰值情况下一个tweet会导致3000万笔写入!而且要求尽量快,Twitter的设计目标是5s内完成,这成为一个巨大的挑战。
Twitter故事最后的结局是 :方案2已经得到了稳定实现,Twitter正在转向结合两种方注。大多数用户的tweet在发布时继续以一对多写入时间线,但是少数具有超多关注者 (例如那些名人)的用户除外,对这些用户采用类似方案1,其推文被单独提取,在读取时才和用户的时间线主表合井。这种混合方法能够提供始终如一的良好表现。
描述性能
即使是反复发送、处理相同的请求,每次可能都会产生略微不同的响应时间。实际情况往往更复杂,由于系统要处理各种不同的请求,响应时间可能变化很大。因此,最好不要将响应时间视为一个固定的数字,而是可度量的一种数值分布。
如果想知道更典型的响应时间,平均值并不是合适的指标 ,因为它掩盖了一些信息,无能告诉有多少用户实际经历了多少延迟。因此最好使用百分位数( percentiles ) 。如果已经搜集到了响应时间信息,将其从最快到最慢排序,中位数( median )就是列表中间的响应时间。为了弄清楚异常值有多糟糕,需要关注更大的百分位数如常见的第95 、99和99.9(缩写为 p95 、p99和p999)值。作为典型的响应时间阈值,它们分别表示有95% 、 99%或 99.9% 的请求响应时间快于阈值。
排队延迟往往在高百分数响应时间中影响很大。由于服务器井行处理的请求有限(例如,CPU内核数的限制),正在处理的少数请求可能会阻挡后续请求,这种情况有时被称为队头阻塞。
应对负载增加的方法
现在谈论更多的是如何在垂直扩展(即升级到更强大的机器 )和水平扩展(即将负载 分布到多个更小的机器)之间做取舍。在多台机器上分配负载也被称为无共享体系结构。
某些系统具有弹性特征,它可以自动检测负载增加,然后自动添加更多计算资源,而其他系统则是手动扩展。
把无状态服务分布然后扩展至多台机器相对比较容易,而有状态服务从单个节点扩展到分布式多机环境的复杂性会大大增加。出于这个原因,直到最近通常的做法一直是,将数据库运行在一个节点上(采用垂直扩展策略),直到高扩展性或高可用性的要求迫使不得不做水平扩展。
超大规模的系统往往针对特定应用而高度定制,很难有一种通用的架构。背后取舍因素包括数据读取量、写入量、待存储的数据量、数据的复杂程度、 相应时间要求、访问模式等,或者更多的是上述所有因素的叠加,再加上其他更复杂的问题。例如, 即使两个系统的数据吞吐量折算下来是一样的, 但是为每秒处理 100000次请求 (每个大小为 1KB )而设计的系统, 与为每分钟3个请求(每个大小为2GB )设计的系统会大不相同。
对于特定应用来说,扩展能力好的架构通常会做出某些假设,然后有针对性地优化设计,如哪些操作是最频繁的,哪些负载是少数情况。
可维护性
众所周知,软件的大部分成本并不在最初的开发阶段,而是在于整个生命周期内持续的投入,这包括维护与缺陷修复,监控系统来保持正常运行、故障排查、适配新平台、搭配新场景、技术缺陷的完善以及增加新功能等。不幸的是,许多从业人根本不喜欢维护这些所谓的遗留系统。但是,换个角度,我们可以从软件设计时开始考虑,尽可能较少维护期间的麻烦,甚至避免造出容易过期的系统。 为此,我们将特别关注软件系统的三个设计原则:
可运维性
方便运营团队来保持系统平稳运行。
简单性
简化系统复杂性, 使新工程师能够轻松理解系统。 注意这与用户界面的简单性并不一样。
可演化性
后续工程师能够轻松地对系统进行改进,并根据需求变化将其适配到非典型场景,也称为可延伸性、易修改性或可塑性。
可运维性:运维更轻松
运营团队对于保持软件系统顺利运行至关重要。一个优秀的运营团队通常至少负责以下内容:
-
-
监视系统的健康状况, 井在服务出现异常状态时快速恢复服务。
-
追踪问题的原因, 例如系统故障或性能下降。
-
保持软件和平台至最新状态, 例如安全补丁方面。
-
了解不同系统如何相互影响,避免执行带有破坏性的操作 。
-
预测未来可能的问题,并在问题发生之前即使解决(例如容量规划)。
-
建立用于部署、配置管理等良好的实践规范和工具包。
-
执行复杂的维护任务, 例如将应用程序从一个平台迁移到另一个平台。
-
当配置更改时,维护系统的安全稳健。
-
制定流程来规范操作行为,并保持生产环境稳定 。
-
保持相关知识的传承(如对系统理解),例如发生团队人员离职或者新员工加入等。
-
数据系统设计可以贡献良好的可操作性,使运营团队能够专注于高附加值的任务:
-
-
提供对系统运行时行为和内部的可观测性,方便监控。
-
支持自动化,与标准工具集成 。
-
避免绑定特定的机器,这样在整个系统不间断运行的同时,允许机器停机维护。
-
提供良好的文档和易于理解的操作模式,诸如“如果我做了X ,会发生Y”。
-
提供良好的默认配置,且允许管理员在需要时方便地修改默认值。
-
尝试自我修复,在需要时让管理员手动控制系统状态 。
-
行为可预测,减少意外发生。
-
简单性:简化复杂度
小型软件项目通常可以写出简单而漂亮的代码,但随着项目越来越大,就会越来越复杂和难以理解。这种复杂性拖慢了开发效率,增加了维护成本。复杂性有各种各样的表现方式:状态空间的膨胀,模块紧耦合,纠结的相互依赖关系,不一致的命名和术语,为了性能而采取的特殊处理,为解决某特定问题而引入的特殊框架等。
简化系统设计并不意味着减少系统功能,而主要意味着消除意外方面的复杂性,正如Moseley和Marks把复杂性定义为一种“意外”,即它并非软件固有、被用户所见或感知,而是实现本身所衍生出来的问题。
消除意外复杂性最好手段之一是抽象。一个好的设计抽象可以隐藏大量的实现细节,并对外提供干净、易懂的接口。 一个好的设计抽象可用于各种不同的应用程序。这样,复用远比多次重复实现更有效率;另一方面,也带来更高质量的软件,而质量过硬的抽象组件所带来的好处,可以使运行其上的所有应用轻松获益。
可演化性:易于改变
一成不变的系统需求几乎没有,想法和目标经常在不断变化:适配新的外部环境,新的用例,业务优先级的变化,用户要求的新功能,新平台取代旧平台,法律或监管要求的变化,业务增长促使架构的演变等。
在组织、流程方面 ,敏捷开发模式为适应变化提供了很好的参考。敏捷社区还发布了很多技术工具和模式,以帮助在频繁变化的环境中开发软件,例如测试驱动开发 (TDD)和重构。
简单易懂的系统往往比复杂的系统更容易修改。这是一个非常重要的理念,我们将采用另一个不同的词来指代数据系统级的敏捷性 ,即可演化性。
浙公网安备 33010602011771号