目前许多的新型应用都属于「数据密集型」(data-intensive),而不是计算密集型(compute-intensive),对于这些应用,CPU 的处理能力并不是第一限制性因素,关键在于数据量、数据的复杂度及数据的快速多变性。
近年来出现了许多用于数据存储和处理的新工具,它们可以针对各种不同的应用场景进行优化,不适合再归为数据库、队列、高速缓存等不同类型,因此我们将其统称为「数据系统」(data system)。此外,越来越多的应用系统需求广泛,单个组件无法满足所有数据处理与存储需求。因此需要将任务分解,每个组件负责高效完成其中一部分,多个组件依靠应用代码有机衔接起来。例如,许多应用系统包括以下模块:
下图给出了一个包含多个不同组件的数据系统架构的示例。其组合使用了多个组件来提供服务,而对外提供服务的界面或 API 会隐藏很多内部实现细节。这样基本上我们基于一个个较小的、通用的组件,构建了一个全新的、专用的数据系统。
影响数据系统设计的因素有很多,本章将专注于对大多数系统都极为重要的三个问题:
系统的可靠性意味着即使发生了某些错误,系统仍可以继续正常工作。此处定义可能出错的事情为「故障」(faults),而系统可应对的错误称为「容错」(fault-tolerant),注意系统不可能容忍所有的故障,因此容错总是指容忍「特定类型」的故障。
我们还需要注意区分「故障」与「失效」(failure)这两个概念。故障通常定义为组件偏离其正常规格,而失效则意味着系统作为一个整体停止,无法向用户提供所需的服务。失效要比故障更加严重,我们希望通过设计容错机制来避免从故障引发系统失效。下面将介绍三种主要的故障类型:硬件故障、软件错误与人为失误。这些故障的共同点是其产生的影响可以被消除(因此我们倾向于容忍而不是预防,与安全问题不同)。
常见的硬件故障包括硬盘崩溃、内存故障、电网停电等。通常减少这种故障的方法是为硬件添加「冗余」,例如对磁盘配置 RAID、服务器配置双电源等。当一个组件发生故障,冗余组件可以快速接管,之后再更换失效的组件。
硬件冗余方案可以使得单台机器完全失效的概率降为非常低的水平,而近年来随着数据量和应用计算需求的增加,更多的应用可以运行在大规模机器之上,随之而来的硬件故障率呈线性增长。此时系统强调的是总体的灵活性与弹性而非单台机器的可靠性,因此通过「软件容错」的方式来容忍多机失效逐渐成为新的手段。
硬件故障大多是相互独立的,而发生在系统内的软件错误往往是相互关联的,例如系统内核 bug、应用进程失控、级联故障等。软件错误通常会长时间处于引而不发的状态,直到碰到特定的触发条件。这意味着软件其实对使用环境存在某种假设,这种假设通常为真,由于某些原因可能会不再成立。
软件的系统问题通常没有快速的解决办法,只能仔细考虑更多细节,包括认真检查系统中的假设与交互、进行全面的测试、进程隔离、允许进程崩溃并自动重启、评估监测并分析生产环节的行为表现等。
除了上述两种错误,人为的失误(包括软件开发、运维等环节)也常常会引发系统故障,下面列举一些可以提升系统可靠性,减少人为失误影响的方法:
可扩展性是用来描述系统应对负载增加能力的术语。我们首先会介绍定量描述负载与性能的方法,然后给出应对负载增加的可能方法。
负载可以用称为「负载参数」(load parmeters)的若干数字来描述。参数的最佳选择取决于系统的体系结构,常见的选择有:
有时平均值很重要,而有时少数的峰值更加重要。原文这里给出了一个 Twitter 的例子来说明负载,Twitter 的两个典型业务操作是:
上述操作的难点在于巨大的「扇出」(fan-out)结构,即每个用户会关注很多人,也会被很多人圈粉。Twitter 给出了如下图所示的两种处理方案:
方法 1 是将发送的新推特插入到全局的推特集合中,当用户查看时间线时,首先查找其所有的关注对象,列出这些人的所有推特,最后以时间为序进行合并。在关系型数据库中,可以通过如下查询语句实现:
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 在最初的版本中使用了方法 1,但随着主页时间线的读负载压力的与日俱增,开始切换为方法 2,因为时间线浏览的压力要比发布推特高的多,所以在发布时多完成一些事情可以加速读性能。然而,方法 2 也存在着一定缺陷,如图中所示,假定每名用户平均 75 个关注者,则需要每秒约 345k 次写入缓存,而由于关注者数量的偏差,对于某些超级用户来说,其发布一条推特可能会导致数千万次的写入发生,这对写性能的要求极大。目前 Twitter 正在考虑将两种方法结合起来,大部分用户发布推特时采用方法 2,以一对多写入时间线,而部分超级用户则才用类似方法 1 的方法,其推特被单独提取,当读取时才和用户的时间线合并。
在本例中,每个用户关注者的分布情况时可扩展性的关键负载参数,其决定了扇出数,在不同的应用中存在着类似的关键负载参数,需要根据具体情况进行判断。
描述负载之后,我们需要进一步了解负载增加系统将会发生什么变化,此时我们就需要对系统性能进行定量描述。与负载的描述类似,在不同的系统中关心的性能指标有所不同:
对于响应时间来说,其包括处理请求时间、以及各种延迟(网络延迟、排队延迟)。即使反复发送、处理相同的请求,每次都可能会产生略微不同的响应时间。因此我们最好将响应时间视为一种可度量的数据分布。
如下图所示,每个灰色条代表一个服务请求,高度表示该请求的响应时间。我们可以考察服务请求的「平均响应时间」(一般为算术平均值),但其有时会掩盖一些信息;因此更好的选择是使用「百分位数」,包括中位数、95百分位数、99百分位数等。百分位数常用于描述、定义服务质量指标和服务质量协议,较高的响应时间百分位数会直接影响用户的总体服务体验(请求最慢的客户往往购买了更多的商品)。
在描述完负载与性能的相关参数后,我们关心的问题是:当负载参数增加时,应如何保持良好性能?这里原书给出了一系列的 tips,现归纳如下:
软件的大部分成本在于开发完成后的持续维护,包括 bug 修复、保证系统正常运行、故障排查、新平台适配、增加新功能等。本节将关注软件系统中保持高可维护性的三个设计原则:
良好的操作性可以使得日常的工作变得简单,使运维团队能够专注于高附加值的任务。数据系统设计可以在这方面做很多事情,例如:
系统的复杂性有各种各样的表现方式:状态空间的膨胀、模块的紧耦合、不一致的命名和术语、特殊处理等。复杂性会使得维护变得越来越困难,降低复杂性可以大大提高软件的可维护性。
简化系统设计并不意味着减少系统功能,而主要意味着消除「意外」(accidental)方面的复杂性,其并非软件固有,而是实现本身所衍生出来的问题。
消除意外复杂性的最好方法之一就是「抽象」(abstraction)。一个好的设计抽象可以隐藏大量的实现细节,并对外提供干净、易懂的接口;一个好的抽象也可以用于不同的应用程序,提升开发效率与软件质量。
一成不变的系统需求几乎不存在,我们使用可演化性来指代「数据系统层面的敏捷性」。可演化性的目标在于可以轻松地修改数据系统,使其适应不断变化的需求。这一目标与简单性密切相关:简单易懂的系统通常比复杂的系统更容易修改。
在组织流程方面,「敏捷」(Agile)开发模式为适应变化提供了很好的参考。不过当前大部分的敏捷开发技术还只是针对小规模、本地模式环境。在更大的数据系统层面上提高敏捷性的方法还有待探索。