当今许多新型应用都属于数据密集型(data-intensive),而不是计算密集型(compute-intensive)。对于这些类型应用,CPU的处理能力往往不是第一限制性因素,关键在于数据量、数据的复杂度及数据的快速多变性。
数据密集型应用通常也是基于标准模块构建而成,每个模块负责单一的常用功能。例如,许多应用系统都包含以下模块:
- 数据库:用以存储数据,这样之后应用可以再次访问。
- 缓存:缓存那些复杂或操作代价昂贵的结果,以加快下一次访问。
- 索引:用户可以按关键字搜索数据并支持各种过滤。
- 流式处理:持续发送消息至另一个进程,处理采用异步方式。
- 批处理:定期处理大量的累积数据。
数据系统
我们通常将数据库、队列、缓存等视为不同类型的系统。虽然数据库和消息队列存在某些相似性,例如两者都会保存数据(至少一段时间),但他们却有着截然不同的访问模式,这就意味着不同的性能特征和设计实现。
假定某个应用包含缓存层(例如Memcached)与全文索引服务器(如Elasticsearch或Solr),二者与主数据库保持关联,通常由应用代码负责缓存、索引与主数据库之间的同步,如图所示。
在上面例子中,组合使用了多个组件来提供服务,而对外提供服务的界面或者API会隐藏很多内部实现细节。这样基本上我们基于一个个较小的、通用的组件,构建而成一个全新的、专用的数据系统。这样的集成数据系统会提供某些技术保证,例如,缓存要正确刷新以保证外部客户端看到一致的结果。
设计数据系统时,会碰到很多棘手的问题。这里将专注与对大多数软件系统都极为重要的三个问题:
- 可靠性(Reliability):当出现意外情况如硬件、软件故障、人为失误等,系统应可以继续正常运转:虽然性能可能有所降低,但确保功能正确。
- 可扩展性(Scalability):随着规模的增长,例如数据量、流量或复杂性,系统应以合理的方式来匹配这种增长。
- 可维护性(Maintainability):随着时间的推移,许多新的人员参与到系统开发和运维,以维护现有功能或适配新场景等,系统都应高效运转。
可靠性
对于软件,典型的期望包括:
- 应用程序执行用户所期望的功能。
- 可以容忍用户出现错误或者不正确的软件使用方法。
- 性能可以应对典型场景、合理负载压力和数据量。
- 系统可防止任何未经授权的访问和滥用。
如果所有上述目标都要支持才算“正常工作”,那么我们可以认为可靠性大致意味着:即使发生了某些错误,系统仍可以继续正常工作。
硬件故障
当我们考虑系统故障时,对于硬件故障总是很容易想到:硬盘崩溃,内存故障,电网停电,甚至有人误拔掉了网线。当有很多机器时,这类事情迟早会发生。
我们的第一个反应通常是为硬件添加冗余来减少系统故障率。例如对磁盘配置RAID,服务器配备双电源,甚至热插拔CPU,数据中心添加备用电源、发电机等。当一个组件发生故障,冗余组件可以快速接管,之后再更换失效的组件。这种方法能并不能完全防止硬件故障所引发的失效,但还是被普遍采用,且在实际中也确实以让系统不间断运行长达数年。
直到最近,采用硬件冗余方案对于大多数应用场景还是足够的,它使得单台机器完全失效的概率降为非常低的水平。只要可以将备份迅速恢复到新机器上,故障的停机时间在大多数应用中并不是灾难性的。而多机冗余则只对少量的关键应用更有意义,对于这些应用,高可用性是绝对必要的。
通过软件容错的方式来容忍多机失效也逐渐成为新的手段,或者至少成为硬件容错的有力补充。这样的系统更具有操作便利性,例如当需要重启计算机时为操作系统打安全补丁,可以每次给一个节点打补丁然后重启,而不需要同时下线整个系统。
软件错误
导致软件故障的bug通常会长时间处于引而不发的状态,直到碰到特定的触发条件。这也意味着系统软件其实对使用环境存在某种假设,而这种假设多数情况都可以满足,但是在特定情况下,假设条件变得不再成立。
软件系统问题有时没有快速解决办法,而只能仔细考虑很多细节,包括认真检查依赖的假设条件与系统之间交互,进行全面的测试,进程隔离,允许进程崩溃并自动重启,反复评估,监控并分析生产环节的行为表现等。如果系统提供某些保证,例如,在消息队列中,输出消息的数量应等于输入消息的数量,则可以不断地检查确认,发现差异则立即告警。
人为失误
如果我们假定人是不可靠的,那么该如何保证系统的可靠性呢?可以尝试结合以下多种方法:
- 以最小出错的方式来设计系统。例如,精心设计的抽象层、API以及管理界面,使“做正确的事情”很轻松,但搞坏很复杂。但是,如果限制过多,人们就会想法来绕过它,这会抵消其正面作用。因此解决之道在于很好的平衡。
- 想办法分离最容易出错的地方、容易引发故障的接口。特别是,提供一个功能齐全但非生产用的沙箱环境,使人们可以放心的尝试、体验,包括导入真实的数据,万一出现问题,不会影响真实用户。
- 充分的测试:从各单元测试到全系统集成测试以及手动测试。自动化测试已被广泛使用,对于覆盖正常操作中很少出现的边界条件等尤为重要。
- 当出现人为失误时,提供快速的恢复机制以尽量减少故障影响。例如,快速回滚配置改动,滚动发布新代码(这样任何意外的错误仅会影响一小部分用户),并提供校验数据的工具(防止旧的计算方式不正确)。
- 设置详细而清晰的监控子系统,包括性能指标和错误率。监控可以向我们发送告警信号,并检查是否存在假设不成立或违反约束条件等。这些检测指标对于诊断问题也非常有用。
- 推行管理流程并加以培训。
可扩展性
可扩展性是用来描述系统应对负载增加能力的术语。但是请注意,它并不是衡量一个系统的一维指标,谈论“X是可扩展”或“Y不扩展”没有太大意义。相反,讨论可扩展性通常要考虑这类问题:“如果系统以某种方式增长,我们应对增长的措施有哪些”,“我们该如何添加计算资源来处理额外的负载”。
描述负载
首先,我们需要简洁地描述系统当前的负载,只有这样才能更好地讨论后续增长问题(例如负载加倍会意味着什么)。负载可以用称为负载参数的若干数字来描述。参数的最佳选择取决于系统的体系结构,它可能是Web服务器的每秒请求处理次数,数据库中写入的比例,聊天室的同时活动用户数量,缓存命中率等。有时平均值很重要,有时系统瓶颈来自于少数峰值。
以Twitter为例,Twitter的两个典型业务操作是:
- 发布tweet消息:用户可以快速推送新消息到所有的关注者,平均大约4.6k request/sec,峰值约12k requests/sec。
- 主页时间线(Home timeline)浏览:平均300k request/sec 查看关注对象的最新消息。
仅仅处理峰值约12k的消息发送听起来并不难,但是,Twitter扩展性的挑战重点不于消息大小,而在于巨大的扇出 (fan-out)结构:每个用户会关注很多人,也会被很多人圈粉。此时大概有两种处理方案:
1.将发送的新tweet插入到全局的tweet集合中。当用户查看时间线时,首先查找所
有的关注对象,列出这些人的所有tweet,最后以时间为序来排序合并。如果以下图的关系型数据模型,可以执行下述的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.对每个用户的时间线维护一个缓存,如图所示,类似每个用户一个tweet邮
箱。当用户推送新tweet时,查询其关注者,将tweet插入到每个关注者的时间线缓存中。因为已经预先将结果取出,之后访问时间线性能非常快。
Twitter在其第一个版本使用了方法1,但发现主页时间线的读负载压力与日俱增,系统优化颇费周折,因此转而采用第二种方法。实践发现这样更好,因为时间线浏览tweet的压力几乎比发布tweet要高出两个数量级,基于此,在发布时多完成一些事情可以加速读性能。
然而,方法2的缺点也很明显,在发布tweet时增加了大量额外的工作。考虑平均75个关注者和每秒4.6k的tweet,则需要每秒4.6 × 75 = 345k速率写入缓存。但是,75这个平均关注者背后还隐藏其他事实,即关注者其实偏差巨大,例如某些用户拥有超过3000万的追随者。这就意味着峰值情况下一个tweet会导致3000万笔写入!而且要求尽量快,Twitter的设计目标是5s内完成,这成为一个巨大的挑战。
在Twitter的例子中,每个用户关注者的分布情况(还可以结合用户使用Twitter频率情况进行加权)是该案例可扩展的关键负载参数,因为它决定了扇出数。其他应用可能具有不同的特性,但可以采用类似的原则来研究具体负载。
Twitter故事最后的结局是:方法2已经得到了稳定实现,Twitter正在转向结合两种方法。大多数用户的tweet在发布时继续以一对多写入时间线,但是少数具有超多关注者(例如那些名人)的用户除外,对这些用户采用类似方案1,其推文被单独提取,在读取时才和用户的时间线主表合并。这种混合方法能够提供始终如一的良好表现。
描述性能
描述系统负载之后,接下来设想如果负载增加将会发生什么。有两种考虑方式:
- 负载增加,但系统资源(如CPU、内存、网络带宽等)保持不变,系统性能会发生什么变化?
- 负载增加,如果要保持性能不变,需要增加多少资源?
这两个问题都会关注性能指标,所以我们先简要介绍一下如何描述系统性能。
在批处理系统如Hadoop中,我们通常关心吞吐量(throughput),即每秒可处理的记录条数,或者在某指定数据集上运行作业所需的总时间;而在线系统通常更看重服务的响应时间(response time),即客户端从发送请求到接收响应之间的间隔。
我们经常考察的是服务请求的平均响应时间。然而,如果想知道更典型的响应时间,平均值并不是合适的指标,因为它掩盖了一些信息,无法告诉有多少用户实际经历了多少延迟。
因此最好使用百分位数(percentiles)。如果已经搜集到了响应时间信息,将其从最快到最慢排序,中位数(median)就是列表中间的响应时间。例如,如果中位数响应时间为200 ms,那意味着有一半的请求响应不到200 ms,而另一半请求则需要更长的时间。
为了弄清楚异常值有多糟糕,需要关注更大的百分位数如常见的第95、99和99.9(缩写为p95、p99和p999)值。作为典型的响应时间阈值,它们分别表示有95%、99%或99.9%的请求响应时间快于阈值。例如,如果95百分位数响应时间为1.5s,这意味着100个请求中的95个请求快于1.5s,而5个请求则需要1.5s或更长时间。
采用较高的响应时间百分位数(tail latencies,尾部延迟或长尾效应)很重要,因为它们直接影响用户的总体服务体验。例如,亚马逊采用99.9百分位数来定义其内部服务的响应时间标准,或许它仅影响1000个请求中的1个。但是考虑到请求最慢的客户往往是购买了更多的商品,因此数据量更大换言之,他们是最有价值的客户。让这些客户始终保持愉悦的购物体验显然非常重要:亚马逊还注意到,响应时间每增加100ms,销售额就会下降了约1%,其他研究则表明,1s的延迟增加等价于客户满意度下降16%。
排队延迟往往在高百分数响应时间中影响很大。由于服务器并行处理的请求有限(例如,CPU内核数的限制),正在处理的少数请求可能会阻挡后续请求,这种情况有时被称为队头阻塞。即使后续请求可能处理很简单,但它阻塞在等待先前请求的完成,客户端将会观察到极慢的响应时间。因此,很重要的一点是要在客户端来测量响应时间。
因此,当测试系统可扩展性而人为产生负载时,负载生成端要独立于响应时间来持续发送请求。如果客户端在发送请求之前总是等待先前请求的完成,就会在测试中人为地缩短了服务器端的累计队列深度,这就带来了测试偏差。
应对负载增加的方法
现在谈论更多的是如何在垂直扩展(即升级到更强大的机器)和水平扩展(即将负载分布到多个更小的机器)之间做取舍。在多台机器上分配负载也被称为无共享体系结构。在单台机器上运行的系统通常更简单,然而高端机器可能非常昂贵,且扩展水平有限,最终往往还是无法避免需要水平扩展。实际上,好的架构通常要做些实际取舍,例如,使用几个强悍的服务器仍可以比大量的小型虚拟机来得更简单、便宜。
某些系统具有弹性特征,它可以自动检测负载增加,然后自动添加更多计算资源,而其他系统则是手动扩展(人工分析性能表现,之后决定添加更多计算)。如果负载高度不可预测,则自动弹性系统会更加高效,但或许手动方式可以减少执行期间的意外情况。
把无状态服务分布然后扩展至多台机器相对比较容易,而有状态服务从单个节点扩展到分布式多机环境的复杂性会大大增加。出于这个原因,直到最近通常的做法一直是,将数据库运行在一个节点上(采用垂直扩展策略),直到高扩展性或高可用性的要求迫使不得不做水平扩展。
然而,随着相关分布式系统专门组件和编程接口越来越好,至少对于某些应用类型来讲,上述通常做法或许会发生改变。可以乐观设想,即使应用可能并不会处理大量数据或流量,但未来分布式数据系统将成为标配。
对于特定应用来说,扩展能力好的架构通常会做出某些假设,然后有针对性地优化设计,如哪些操作是最频繁的,哪些负载是少数情况。如果这些假设最终发现是错误的,那么可扩展性的努力就白费了,甚至会出现与设计预期完全相反的情况。对于早期的初创公司或者尚未定型的产品,快速迭代推出产品功能往往比投入精力来应对不可知的扩展性更为重要。
可维护性
从软件设计时就应该开始考虑,尽可能较少维护期间的麻烦,至避免造出容易过期的系统。为此,我们将特别关注软件系统的三个设计原则:
- 可运维性:方便运营团队来保持系统平稳运行。
- 简单性:简化系统复杂性,使新工程师能够轻松理解系统。注意这与用户界面的简单性并不一样。
- 可演化性:后续工程师能够轻松地对系统进行改进,并根据需求变化将其适配到非典型场景,也称为可延伸性、易修改性或可塑性。
可运维性
良好的可运维性意味着使日常工作变得简单,数据系统设计可以在这方面贡献很多,包括:
- 提供对系统运行时行为和内部的可观测性,方便监控。
- 支持自动化,与标准工具集成。
- 避免绑定特定的机器,这样在整个系统不间断运行的同时,允许机器停机维护。
- 提供良好的文档和易于理解的操作模式,诸如“如果我做了X,会发生Y”。
- 提供良好的默认配置,且允许管理员在需要时方便地修改默认值。
- 尝试自我修复,在需要时让管理员手动控制系统状态。
- 行为可预测,减少意外发生。
简单性
小型软件项目通常可以写出简单而漂亮的代码,但随着项目越来越大,就会越来越复杂和难以理解。这种复杂性拖慢了开发效率,增加了维护成本。
复杂性有各种各样的表现方式:状态空间的膨胀,模块紧耦合,令人纠结的相互依赖关系,不一致的命名和术语,为了性能而采取的特殊处理,为解决某特定问题而引入的特殊框架等。
消除意外复杂性最好手段之一是抽象。一个好的设计抽象可以隐藏大量的实现细节,并对外提供干净、易懂的接口。一个好的设计抽象可用于各种不同的应用程序。这样,复用远比多次重复实现更有效率;另一方面,也带来更高质量的软件,而质量过硬的抽象组件所带来的好处,可以使运行其上的所有应用轻松获益。
可演化性
一成不变的系统需求几乎没有,想法和目标经常在不断变化:适配新的外部环境,新的用例,业务优先级的变化,用户要求的新功能,新平台取代旧平台,法律或监管要求的变化,业务增长促使架构的演变等。
在组织流程方面,敏捷开发模式为适应变化提供了很好的参考。敏捷社区还发布了很多技术工具和模式,以帮助在频繁变化的环境中开发软件,例如测试驱动开发(TDD)和重构。
我们的目标是可以轻松地修改数据系统,使其适应不断变化的需求,这和简单性与抽象性密切相关:简单易懂的系统往往比复杂的系统更容易修改。这是一个非常重要的理念,我们将采用另一个不同的词来指代数据系统级的敏捷性,即可演化性。