开源应用程序架构(第 2 卷)
可扩展的 Web 架构和分布式系统

凯特·马津代拉

开源软件已成为一些大型网站的基本构建块。随着这些网站的不断发展,围绕其架构的最佳实践和指导原则也应运而生。本章旨在探讨设计大型网站时需要考虑的一些关键问题,以及实现这些目标的一些构建块。

本章主要关注 Web 系统,尽管其中一些内容也适用于其他分布式系统。

1.1. Web 分布式系统设计的原则

构建和运行可扩展的网站或应用程序究竟意味着什么?在原始级别,它只是通过互联网将用户与远程资源连接起来——使它具有可扩展性的部分是这些资源或访问这些资源的方式分布在多台服务器上。

就像生活中的大多数事情一样,在构建 Web 服务时提前规划可以帮助您长期受益;了解大型网站背后的一些考虑因素和权衡可以使您在创建小型网站时做出更明智的决策。以下是影响大型 Web 系统设计的一些关键原则

这些原则中的每一个都为设计分布式 Web 架构的决策提供了依据。但是,它们也可能相互矛盾,因此实现一个目标的代价是牺牲另一个目标。一个基本的例子:选择通过简单地添加更多服务器来解决容量问题(可扩展性)可能会以可管理性(您必须操作其他服务器)和成本(服务器的价格)为代价。

在设计任何类型的 Web 应用程序时,务必考虑这些关键原则,即使是为了承认设计可能会牺牲其中一个或多个原则。

1.2. 基础知识

在谈到系统架构时,需要考虑一些事情:哪些是正确的组件,这些组件如何组合在一起,以及哪些是正确的权衡。在不需要之前投资扩展通常不是明智的商业主张;但是,对设计进行一些预先思考可以在将来节省大量时间和资源。

本节重点介绍一些对几乎所有大型 Web 应用程序都至关重要的核心因素:服务冗余分区故障处理。每个因素都涉及选择和折衷,尤其是在上一节中描述的原则的背景下。为了详细解释这些,最好从一个例子开始。

示例:图片托管应用程序

在某些时候,您可能已将图片发布到网上。对于托管和交付大量图片的大型网站来说,构建一个具有成本效益、高可用性和低延迟(快速检索)的架构存在挑战。

想象一个系统,用户能够将他们的图片上传到中央服务器,并且可以通过 Web 链接或 API 请求这些图片,就像 Flickr 或 Picasa 一样。为简单起见,让我们假设此应用程序有两个关键部分:能够将图片上传(写入)到服务器,以及能够查询图片。虽然我们当然希望上传高效,但我们最关心的是当有人请求图片时能够非常快速地交付图片(例如,可以为网页或其他应用程序请求图片)。这与 Web 服务器或内容交付网络 (CDN) 边缘服务器(CDN 用于在许多位置存储内容的服务器,以便内容在地理/物理上更靠近用户,从而提高性能)可能提供的功能非常相似。

系统的其他重要方面是

图 1.1 是功能的简化图。

图 1.1:图片托管应用程序的简化架构图

在此图片托管示例中,系统必须能够快速感知,其数据必须可靠地存储,并且所有这些属性都必须具有高度的可扩展性。构建此应用程序的小版本将非常简单,并且可以轻松地托管在单个服务器上;但是,这对于本章来说并不有趣。让我们假设我们想要构建一些可以像 Flickr 一样发展壮大的东西。

服务

在考虑可扩展的系统设计时,有助于将功能解耦,并将系统的每个部分视为具有明确定义的接口的独立服务。在实践中,以这种方式设计的系统被称为具有面向服务的架构 (SOA)。对于这些类型的系统,每个服务都有其自己的独特功能上下文,并且与该上下文外部的任何内容的交互都通过抽象接口进行,通常是另一个服务的公共 API。

将系统分解成一组互补的服务可以将这些部分的操作彼此解耦。这种抽象有助于建立服务、其底层环境和该服务的使用者之间的清晰关系。创建这些清晰的界限可以帮助隔离问题,还可以使每个部分能够独立于彼此进行扩展。这种面向服务的系统设计与面向对象的编程设计非常相似。

在我们的示例中,所有上传和检索图片的请求都由同一台服务器处理;但是,随着系统需要扩展,将这两个功能分解成各自的服务是有意义的。

快进并假设服务正在大量使用;在这种情况下,很容易看出较长的写入时间将如何影响读取图片所需的时间(因为这两个功能将争夺共享资源)。根据架构,这种影响可能是巨大的。即使上传和下载速度相同(大多数 IP 网络并非如此,因为大多数 IP 网络的设计至少为 3:1 的下载速度:上传速度比率),读取文件通常会从缓存中读取,而写入最终必须写入磁盘(并且可能在最终一致的情况下写入多次)。即使所有内容都存储在内存中或从磁盘读取(如 SSD),数据库写入几乎总是比读取慢。(Pole Position 是一款用于数据库基准测试的开源工具,http://polepos.org/ 和结果 http://polepos.sourceforge.net/results/PolePositionClientServer.pdf)。

此设计的另一个潜在问题是,像 Apache 或 lighttpd 这样的 Web 服务器通常对其可以维护的同时连接数有一个上限(默认值约为 500,但可以更高),在高流量的情况下,写入可以快速消耗所有这些连接。由于读取可以是异步的,或者利用其他性能优化(如 gzip 压缩或分块传输编码),Web 服务器可以更快地切换服务读取并在客户端之间快速切换,从而每秒服务比最大连接数多得多请求(使用 Apache 和最大连接数设置为 500,每秒服务数千个读取请求并不少见)。另一方面,写入倾向于在上传期间保持打开连接,因此上传 1MB 文件在大多数家庭网络上可能需要超过 1 秒的时间,因此该 Web 服务器只能处理 500 个此类同时写入。

图 1.2:分离读取和写入

计划这种类型的瓶颈为将图片的读取和写入分离成各自的服务提供了充分的理由,如 图 1.2 所示。这使我们能够独立地扩展它们(因为我们很有可能总是进行更多读取而不是写入),但也帮助阐明了每个点正在发生的事情。最后,这分离了未来的问题,这将使解决和扩展诸如读取速度慢等问题变得更容易。

这种方法的优势在于,我们能够独立地解决问题——我们不必担心在同一个上下文中写入和检索新的图像。这两项服务仍然利用全局图像语料库,但它们可以自由地使用适合服务的方法来优化自己的性能(例如,将请求排队或缓存热门图像——稍后将详细介绍)。从维护和成本的角度来看,每项服务都可以根据需要独立扩展,这很好,因为如果它们被组合和混合在一起,一个服务可能会无意中影响另一个服务的性能,就像上面讨论的场景一样。

当然,当您有两个不同的端点时,上述示例可以很好地工作(事实上,这与几个云存储提供商的实现和内容交付网络非常相似)。有很多方法可以解决这些类型的瓶颈,并且每种方法都有不同的权衡。

例如,Flickr 通过将用户分布到不同的分片来解决这个读/写问题,这样每个分片只能处理一定数量的用户,并且随着用户数量的增加,会向集群中添加更多分片(请参阅有关 Flickr 扩展的演示文稿,http://mysqldba.blogspot.com/2008/04/mysql-uc-2007-presentation-file.html)。在第一个示例中,更容易根据实际使用情况(整个系统的读取和写入次数)扩展硬件,而 Flickr 则根据其用户群进行扩展(但强制假设用户之间的使用情况相等,因此可能存在额外的容量)。在前面一种情况下,其中一项服务的故障或问题会导致整个系统的功能下降(例如,没有人可以写入文件),而在 Flickr 的一个分片出现故障只会影响那些用户。在第一个示例中,更容易在整个数据集上执行操作——例如,更新写入服务以包含新的元数据或搜索所有图像元数据——而在 Flickr 架构中,需要更新或搜索每个分片(或者需要创建搜索服务来整理这些元数据——这实际上就是他们所做的)。

对于这些系统,没有正确的答案,但可以回顾本章开头的原则,确定系统需求(大量读取或写入或两者兼而有之,并发级别,跨数据集的查询,范围,排序等),对不同的替代方案进行基准测试,了解系统将如何发生故障,并为发生故障时制定一个可靠的计划。

冗余

为了优雅地处理故障,Web 架构必须具有其服务和数据的冗余性。例如,如果只有一个文件副本存储在单个服务器上,那么丢失该服务器意味着丢失该文件。丢失数据很少是好事,并且处理它的常用方法是创建多个或冗余的副本。

同样的原则也适用于服务。如果应用程序有一个核心功能部件,确保同时运行多个副本或版本可以防止单个节点发生故障。

在系统中创建冗余可以消除单点故障,并在危机情况下提供备用或备用功能。例如,如果生产环境中运行着同一服务的两个实例,并且一个实例发生故障或性能下降,则系统可以故障转移到正常的副本。故障转移可以自动发生,也可以需要人工干预。

服务冗余的另一个关键部分是创建无共享架构。使用这种架构,每个节点都能够独立于其他节点运行,并且没有中央“大脑”来管理状态或协调其他节点的活动。这对于可扩展性非常有帮助,因为可以在没有特殊条件或知识的情况下添加新节点。但是,最重要的是,这些系统中没有单点故障,因此它们对故障的恢复能力更强。

例如,在我们的图像服务器应用程序中,所有图像都将在其他硬件上的某个位置具有冗余副本(理想情况下,在发生诸如数据中心地震或火灾等灾难时,位于不同的地理位置),并且访问图像的服务也将是冗余的,所有这些都可能服务于请求。(参见图 1.3。)(负载均衡器是实现此目的的好方法,但稍后将详细介绍)。

图 1.3:具有冗余的图像托管应用程序

分区

可能存在非常大的数据集,无法容纳在单个服务器上。也可能是某个操作需要过多的计算资源,从而降低性能,并需要增加容量。在任何一种情况下,您都有两种选择:垂直扩展或水平扩展。

垂直扩展意味着向单个服务器添加更多资源。因此,对于非常大的数据集,这可能意味着添加更多(或更大的)硬盘驱动器,以便单个服务器可以包含整个数据集。在计算操作的情况下,这可能意味着将计算转移到具有更快的 CPU 或更多内存的更大服务器上。在每种情况下,垂直扩展都是通过使单个资源能够独立处理更多内容来实现的。

另一方面,水平扩展是指添加更多节点。对于大型数据集,这可能意味着第二台服务器来存储数据集的一部分,对于计算资源,这将意味着在一些额外的节点上拆分操作或负载。为了充分利用水平扩展,应将其作为系统架构的内在设计原则,否则修改和分离上下文以使其成为可能可能会非常麻烦。

在水平扩展方面,一种更常见的技术是将您的服务分解成分区或分片。可以将分区分布开来,使得每个逻辑功能集都是独立的;这可以通过地理边界或其他标准(例如非付费用户与付费用户)来完成。这些方案的优点是它们为服务或数据存储提供了额外的容量。

在我们的图像服务器示例中,可以使用多个文件服务器替换用于存储图像的单个文件服务器,每个文件服务器包含自己唯一的一组图像。(参见图 1.4。)这种架构将允许系统用图像填充每个文件服务器,并在磁盘填满时添加额外的服务器。该设计需要一个命名方案,将图像的文件名与其包含它的服务器绑定。可以根据跨服务器映射的一致哈希方案来形成图像的名称。或者,可以为每个图像分配一个增量 ID,以便当客户端请求图像时,图像检索服务只需要维护映射到每个服务器的 ID 范围(如索引)。

图 1.4:具有冗余和分区的图像托管应用程序

当然,将数据或功能分布到多台服务器上也存在挑战。其中一个关键问题是数据局部性;在分布式系统中,数据越靠近操作或计算点,系统的性能就越好。因此,将数据分散到多台服务器上可能会存在问题,因为任何时候需要数据时,它可能都不在本地,迫使服务器通过网络执行代价高昂的信息提取操作。

另一个潜在问题来自不一致性。当不同的服务从共享资源(可能是另一个服务或数据存储)读取和写入时,就有可能发生竞争条件——某些数据应该被更新,但读取发生在更新之前——在这种情况下,数据是不一致的。例如,在图像托管场景中,如果一个客户端发送请求以使用新标题更新狗的图像,将其从“狗”更改为“Gizmo”,但同时另一个客户端正在读取图像,则可能会发生竞争条件。在这种情况下,不清楚第二个客户端接收到的标题是“狗”还是“Gizmo”。

分区数据肯定存在一些障碍,但分区允许将每个问题(按数据、负载、使用模式等)拆分为可管理的块。这有助于提高可扩展性和可管理性,但并非没有风险。有很多方法可以降低风险和处理故障;但是,为了简洁起见,本章不介绍这些内容。如果您有兴趣了解更多信息,可以查看我关于容错和监控的博客文章

1.3. 快速可扩展数据访问的基础构建块

在介绍了设计分布式系统的一些核心考虑因素之后,现在让我们讨论一下难题:扩展对数据的访问。

例如,大多数简单的 Web 应用程序(例如 LAMP 堆栈应用程序)看起来类似于图 1.5

图 1.5:简单的 Web 应用程序

随着应用程序的增长,主要有两个挑战:扩展对应用程序服务器和数据库的访问。在高度可扩展的应用程序设计中,应用程序(或 Web)服务器通常被最小化,并且通常体现了无共享架构。这使得系统的应用程序服务器层能够水平扩展。由于这种设计,繁重的工作被推送到数据库服务器和支持服务堆栈中;在这一层,出现了真正的扩展和性能挑战。

本章的其余部分将介绍一些更常见的策略和方法,通过提供对数据的快速访问,使这些类型的服务快速且可扩展。

图 1.6:简化的 Web 应用程序

大多数系统可以简化为图 1.6。这是一个很好的起点。如果您有很多数据,您希望快速轻松地访问它们,就像将一堆糖果放在办公桌抽屉的最上面一样。尽管过于简单,但前面的陈述暗示了两个难题:存储的可扩展性和数据的快速访问。

在本节中,让我们假设您有许多 TB 的数据,并且您希望允许用户随机访问这些数据的小部分。(参见图 1.7。)这类似于在图像应用程序示例中查找文件服务器上的某个图像文件。

图 1.7:访问特定数据

这尤其具有挑战性,因为将 TB 级数据加载到内存中可能非常昂贵;这直接转化为磁盘 I/O。从磁盘读取的速度比从内存读取慢得多——内存访问的速度与 Chuck Norris 一样快,而磁盘访问的速度比 DMV 的排队速度慢。对于大型数据集,这种速度差异确实会累积;用实际数字来说,内存访问速度对于顺序读取最多快 6 倍,对于随机读取快 100,000 倍,比从磁盘读取快(参见“大数据的病理”,https://queue.org.cn/detail.cfm?id=1563874)。此外,即使使用唯一的 ID,解决如何找到那一点数据的问题也可能是一项艰巨的任务。这就像试图在不看的情况下从糖果藏匿处取出最后一块 Jolly Rancher 一样。

幸运的是,有很多方法可以让你更容易地实现这一点;其中四个比较重要的分别是缓存、代理、索引和负载均衡器。本节的其余部分将讨论如何使用这些概念来加快数据访问速度。

缓存

缓存利用了局部性原理:最近请求过的数据很可能再次被请求。它们几乎被应用于计算的每一层:硬件、操作系统、Web 浏览器、Web 应用程序等等。缓存就像短期内存:它具有有限的空间,但通常比原始数据源更快,并且包含最近访问的项目。缓存可以在架构的各个级别存在,但通常位于最靠近前端的级别,在那里它们被用来快速返回数据,而不会给下游级别带来负担。

在我们的 API 示例中,如何使用缓存来加快数据访问速度?在这种情况下,您可以在几个地方插入缓存。一个选项是在您的请求层节点上插入一个缓存,如图 1.8所示。

图 1.8:在请求层节点上插入缓存

将缓存直接放置在请求层节点上可以实现响应数据的本地存储。每次向服务发出请求时,节点都会快速返回本地缓存的数据(如果存在)。如果缓存中不存在,请求节点将从磁盘查询数据。一个请求层节点上的缓存也可以同时位于内存中(非常快)和节点的本地磁盘上(比访问网络存储快)。

图 1.9:多个缓存

当您将其扩展到多个节点时会发生什么?正如您在图 1.9中看到的,如果请求层扩展到多个节点,每个节点仍然可以拥有自己的缓存。但是,如果您的负载均衡器随机地在节点之间分配请求,则相同的请求将发送到不同的节点,从而增加缓存未命中率。克服这一障碍的两个选择是全局缓存和分布式缓存。

全局缓存

全局缓存顾名思义:所有节点都使用同一个缓存空间。这涉及添加一个服务器或某种文件存储,其速度比您的原始存储快,并且所有请求层节点都可以访问。每个请求节点都以与本地缓存相同的方式查询缓存。这种缓存方案可能会变得有点复杂,因为随着客户端和请求数量的增加,很容易压垮单个缓存,但在某些架构中非常有效(特别是那些具有专门硬件使全局缓存非常快,或者具有需要缓存的固定数据集的架构)。

图中描绘了两种常见的全局缓存形式。在图 1.10中,当在缓存中找不到缓存的响应时,缓存本身负责从底层存储中检索丢失的数据。在图 1.11中,请求节点负责检索缓存中找不到的任何数据。

图 1.10:缓存负责检索的全局缓存
图 1.11:请求节点负责检索的全局缓存

大多数利用全局缓存的应用程序倾向于使用第一种类型,其中缓存本身管理逐出和获取数据以防止来自客户端的大量相同数据的请求。但是,在某些情况下,第二个实现更有意义。例如,如果缓存用于非常大的文件,则低缓存命中率会导致缓存缓冲区被缓存未命中所淹没;在这种情况下,在缓存中拥有大部分总数据集(或热门数据集)会有所帮助。另一个示例是文件存储在缓存中并且不应该被逐出的架构。(这可能是由于围绕该数据延迟的应用程序要求——某些数据对于大型数据集可能需要非常快——应用程序逻辑比缓存更好地理解逐出策略或热点。)

分布式缓存

在分布式缓存(图 1.12)中,每个节点都拥有缓存数据的一部分,因此,如果冰箱充当杂货店的缓存,则分布式缓存就像将您的食物放在多个位置——您的冰箱、橱柜和午餐盒——方便取零食的地方,而无需去商店。通常使用一致的哈希函数将缓存划分,这样如果请求节点正在查找某个特定数据,它可以快速知道在分布式缓存中在哪里查找以确定该数据是否可用。在这种情况下,每个节点都有一小部分缓存,然后将向另一个节点发送请求以获取数据,然后再到源。因此,分布式缓存的优点之一是,只需向请求池中添加节点即可增加缓存空间。

分布式缓存的一个缺点是补救丢失的节点。一些分布式缓存通过在不同节点上存储数据的多个副本来解决这个问题;但是,您可以想象这种逻辑很快就会变得复杂,尤其是在您向请求层添加或删除节点时。尽管即使一个节点消失并且一部分缓存丢失,请求也会从源拉取——因此它不一定是灾难性的!

图 1.12:分布式缓存

缓存的好处在于它们通常会使事情变得更快(当然,如果实现正确!)。您选择的方法只是允许您为更多请求使其更快。但是,所有这些缓存都需要付出维护额外存储空间的代价,通常以昂贵的内存形式存在;没有免费的午餐。缓存对于使事物普遍更快非常有用,而且在高负载条件下提供系统功能,否则会出现完全的服务降级。

一个流行的开源缓存示例是 Memcached(https://memcached.org.cn/)(它既可以作为本地缓存,也可以作为分布式缓存);但是,还有许多其他选项(包括许多特定于语言或框架的选项)。

Memcached 用于许多大型网站,即使它非常强大,它也只是一个内存中的键值存储,针对任意数据存储和快速查找(O(1))进行了优化。

Facebook 使用几种不同类型的缓存来获得其网站性能(参见“Facebook 缓存和性能”)。他们在语言级别使用$GLOBALS和 APC 缓存(在 PHP 中以函数调用的代价提供),这有助于使中间函数调用和结果更快。(大多数语言都有这些类型的库来提高网页性能,并且几乎应该始终使用它们。)然后 Facebook 使用一个分布在许多服务器上的全局缓存(参见“在 Facebook 上扩展 Memcached”),这样,一个访问缓存的函数调用可以并行发出许多请求以获取存储在不同 Memcached 服务器上的数据。这使他们能够为其用户资料数据获得更高的性能和吞吐量,并拥有一个更新数据的地方(这很重要,因为当您运行数千台服务器时,缓存失效和维护一致性可能具有挑战性)。

现在让我们讨论当数据不在缓存中时该怎么办……

代理

在基本层面上,代理服务器是接收来自客户端的请求并将其转发到后端源服务器的中间硬件/软件。通常,代理用于过滤请求、记录请求或有时转换请求(通过添加/删除标头、加密/解密或压缩)。

图 1.13:代理服务器

代理在协调来自多个服务器的请求时也非常有帮助,提供了从系统范围的角度优化请求流量的机会。使用代理来加速数据访问的一种方法是将相同(或类似)的请求合并到一个请求中,然后将单个结果返回给请求客户端。这称为合并转发。

假设在几个节点上对相同数据(我们称之为 littleB)有请求,并且该数据不在缓存中。如果该请求通过代理路由,则所有这些请求都可以合并为一个,这意味着我们只需要从磁盘读取一次 littleB。(参见图 1.14。)此设计有一些成本,因为每个请求的延迟可能会稍微高一些,并且某些请求可能会稍微延迟以与类似的请求分组。但在高负载情况下,它会提高性能,尤其是在反复请求相同数据时。这类似于缓存,但它不是像缓存一样存储数据/文档,而是优化这些文档的请求或调用并充当这些客户端的代理。

例如,在 LAN 代理中,客户端不需要自己的 IP 来连接到 Internet,并且 LAN 将合并来自客户端对相同内容的调用。但是,这里很容易混淆,因为许多代理也是缓存(因为它是一个放置缓存的非常合乎逻辑的地方),但并非所有缓存都充当代理。

图 1.14:使用代理服务器合并请求

使用代理的另一种好方法不仅是合并对相同数据的请求,还可以合并对源存储中空间上接近的数据的请求(磁盘上连续)。采用这种策略可以最大程度地提高请求的数据局部性,从而导致请求延迟降低。例如,假设一堆节点请求 B 的一部分:partB1、partB2 等。我们可以设置我们的代理以识别各个请求的空间局部性,将其合并为单个请求并仅返回 bigB,从而最大程度地减少来自数据源的读取。(参见图 1.15。)当您随机访问 TB 级数据时,这会对请求时间产生很大的影响!代理在高负载情况下或缓存有限时特别有用,因为它们基本上可以将多个请求批处理到一个请求中。

图 1.15:使用代理合并对空间上接近的数据的请求

值得注意的是,您可以将代理和缓存一起使用,但通常最好将缓存放在代理前面,因为在拥挤的马拉松比赛中让速度快的跑步者先开始是最好的。这是因为缓存正在从内存中提供数据,它非常快,并且不介意对相同结果的多个请求。但是,如果缓存位于代理服务器的另一侧,则在缓存之前的每个请求都会有额外的延迟,这可能会阻碍性能。

如果您正在考虑向系统中添加代理,则需要考虑许多选项;SquidVarnish 都经过了道路测试,并且被广泛用于许多生产网站。这些代理解决方案提供了许多优化,以充分利用客户端-服务器通信。在 Web 服务器层安装其中一个作为反向代理(在下面的负载均衡器部分中解释)可以显着提高 Web 服务器性能,减少处理传入客户端请求所需的工作量。

索引

使用索引快速访问数据是优化数据访问性能的众所周知的方法;在数据库方面可能最为人知。索引以增加存储开销和写入速度变慢(因为您必须写入数据并更新索引)为代价,换取读取速度的提高。

就像传统的关系型数据库一样,您也可以将此概念应用于更大的数据集。索引的技巧在于您必须仔细考虑用户将如何访问您的数据。对于大小为许多 TB 但有效负载非常小(例如,1 KB)的数据集,索引对于优化数据访问是必需的。在如此庞大的数据集中查找一个小有效负载可能是一个真正的挑战,因为您不可能在任何合理的时间内迭代如此多的数据。此外,如此庞大的数据集很可能分布在多个(或许多!)物理设备上——这意味着您需要某种方法来查找所需数据的正确物理位置。索引是执行此操作的最佳方法。

图 1.16:索引

索引可以像目录一样,引导您到数据所在的位置。例如,假设您正在查找一段数据,B 的第 2 部分——您如何知道在哪里找到它?如果您有一个按数据类型排序的索引——例如数据 A、B、C——它会告诉您数据 B 在原点的位置。然后,您只需跳转到该位置并读取您想要的 B 的部分。(参见图 1.16。)

这些索引通常存储在内存中,或者存储在靠近传入客户端请求的某个位置。Berkeley DB(BDB)和树状数据结构通常用于以有序列表存储数据,非常适合使用索引访问。

通常有多层索引充当映射,将您从一个位置移动到下一个位置,依此类推,直到您获得所需的特定数据片段。(参见图 1.17。)

图 1.17:多层索引

索引还可以用于创建同一数据的多个不同视图。对于大型数据集,这是一种定义不同过滤器和排序而无需创建许多额外数据副本的好方法。

例如,假设前面提到的图像托管系统实际上正在托管书籍页面的图像,并且该服务允许客户端跨这些图像中的文本查询,搜索所有关于某个主题的书籍内容,就像搜索引擎允许您搜索 HTML 内容一样。在这种情况下,所有这些书籍图像都需要许多服务器来存储文件,并且查找要呈现给用户的单个页面可能会有些复杂。首先,需要轻松访问用于查询任意单词和单词元组的反向索引;然后是导航到书籍中确切的页面和位置以及检索结果的正确图像的挑战。因此,在这种情况下,倒排索引将映射到一个位置(例如书籍 B),然后 B 可能包含一个包含所有单词、位置和每个部分中出现次数的索引。

倒排索引(可以在上图中表示为索引 1)可能如下所示——每个单词或单词元组都提供包含它们的书籍索引。

单词(s)书籍(s)
很棒书籍 B、书籍 C、书籍 D
总是书籍 C、书籍 F
相信书籍 B

中间索引看起来类似,但仅包含书籍 B 的单词、位置和信息。这种嵌套索引架构允许这些索引中的每一个都占用比所有这些信息都必须存储在一个大型倒排索引中的空间更少。

这在大型系统中至关重要,因为即使经过压缩,这些索引也可能变得非常大且存储成本很高。如果假设我们拥有世界上许多书籍——1 亿本(参见Google 图书内部博客文章)——并且每本书只有 10 页长(为了简化计算),每页 250 个单词,这意味着有 2500 亿个单词。如果假设每个单词平均 5 个字符,每个字符占用 8 位(或 1 字节,即使某些字符为 2 字节),因此每个单词 5 字节,那么仅包含每个单词一次的索引超过 1 TB 的存储空间。因此,您可以看到创建包含许多其他信息的索引,例如单词元组、数据位置和出现次数,可以非常迅速地累加。

创建这些中间索引并将数据表示为更小的部分使大数据问题变得易于处理。数据可以分布在许多服务器上,并且仍然可以快速访问。索引是信息检索的基石,也是当今现代搜索引擎的基础。当然,本节只是触及了表面,并且正在进行大量研究,以了解如何使索引更小、更快、包含更多信息(如相关性)以及无缝更新。(在竞争条件以及添加新数据或更改现有数据所需的大量更新方面存在一些可管理性挑战,尤其是在涉及相关性或评分的情况下)。

能够快速轻松地找到您的数据非常重要;索引是一种实现此目标的有效且简单的工具。

负载均衡器

最后,任何分布式系统的另一个关键部分是负载均衡器。负载均衡器是任何架构的主要组成部分,因为它们的作用是在负责服务请求的一组节点之间分配负载。这允许多个节点透明地为系统中的相同功能提供服务。(参见图 1.18。)它们的主要目的是处理大量同时连接并将这些连接路由到其中一个请求节点,从而允许系统通过简单地添加节点来扩展以服务更多请求。

图 1.18:负载均衡器

可以使用许多不同的算法来服务请求,包括选择随机节点、循环轮询,甚至根据某些标准(例如内存或 CPU 利用率)选择节点。负载均衡器可以实现为软件或硬件设备。一个获得广泛采用的开源软件负载均衡器是HAProxy)。

在分布式系统中,负载均衡器通常位于系统的最前端,以便所有传入请求都相应地路由。在复杂的分布式系统中,请求被路由到多个负载均衡器的情况并不少见,如图 1.19所示。

图 1.19:多个负载均衡器

与代理一样,某些负载均衡器还可以根据请求的类型以不同的方式路由请求。(从技术上讲,这些也称为反向代理。)

负载均衡器面临的挑战之一是管理特定于用户会话的数据。在电子商务网站上,当您只有一个客户端时,允许用户将商品放入购物车并在访问之间保留这些内容非常容易(这很重要,因为如果用户返回时商品仍然在购物车中,则更有可能出售该商品)。但是,如果用户在一个会话中被路由到一个节点,然后在下次访问时被路由到另一个节点,则可能会出现不一致,因为新节点可能缺少该用户的购物车内容。(如果您将 6 包 Mountain Dew 放入购物车然后回来发现它空了,您不会感到沮丧吗?)解决此问题的一种方法是使会话粘滞,以便用户始终被路由到同一节点,但这使得很难利用某些可靠性功能,例如自动故障转移。在这种情况下,用户的购物车将始终包含内容,但如果其粘滞节点不可用,则需要一个特殊情况,并且内容存在的假设将不再有效(尽管希望此假设不会内置到应用程序中)。当然,可以使用本章中的其他策略和工具(如服务)以及许多未涵盖的工具(如浏览器缓存、cookie 和 URL 重写)来解决此问题。

如果系统只有几个节点,则像循环 DNS 这样的系统可能更有意义,因为负载均衡器可能很昂贵并且会增加不必要的复杂性。当然,在更大的系统中,存在各种不同的调度和负载平衡算法,包括简单的算法(如随机选择或循环轮询),以及更复杂的机制,这些机制会考虑利用率和容量等因素。所有这些算法都允许流量和请求进行分配,并可以提供有用的可靠性工具,例如自动故障转移或自动删除不良节点(例如,当它变得无响应时)。但是,这些高级功能可能会使问题诊断变得麻烦。例如,在高负载情况下,负载均衡器会删除速度缓慢或超时(由于请求过多)的节点,但这只会加剧其他节点的状况。在这种情况下,广泛的监控非常重要,因为整个系统的流量和吞吐量看起来可能正在下降(因为节点服务的请求更少),但各个节点却达到最大限度。

负载均衡器是扩展系统容量的简便方法,并且与本文中的其他技术一样,在分布式系统架构中发挥着至关重要的作用。负载均衡器还提供了能够测试节点健康状况的关键功能,以便如果节点无响应或过载,则可以将其从处理请求的池中删除,从而利用系统中不同节点的冗余性。

队列

到目前为止,我们已经介绍了许多快速读取数据的方法,但扩展数据层的另一个重要部分是有效地管理写入。当系统简单、处理负载最小且数据库较小时,写入速度可以预测地很快;但是,在更复杂的系统中,写入可能需要几乎不确定的时间。例如,数据可能必须写入不同服务器或索引上的多个位置,或者系统可能只是负载过高。在写入或任何任务可能需要很长时间的情况下,要实现性能和可用性,需要在系统中构建异步性;一种常见的方法是使用队列。

图 1.20:同步请求

想象一个系统,其中每个客户端都请求远程服务任务。每个客户端都将其请求发送到服务器,服务器尽快完成任务并将结果返回到各自的客户端。在小型系统中,如果一个服务器(或逻辑服务)可以像客户端一样快地为传入客户端提供服务,则这种情况应该可以正常工作。但是,当服务器接收到的请求超过其能够处理的请求时,则每个客户端都必须等待其他客户端的请求完成才能生成响应。这是一个同步请求的示例,如图 1.20所示。

这种同步行为会严重降低客户端性能;客户端被迫等待,实际上执行零工作,直到可以回答其请求。添加其他服务器来解决系统负载也不会解决问题;即使使用有效的负载均衡,也极难确保实现最大化客户端性能所需的均匀和公平的工作分配。此外,如果处理请求的服务器不可用或发生故障,则上游的客户端也将发生故障。有效地解决此问题需要在客户端的请求与其为服务而执行的实际工作之间进行抽象。

图 1.21:使用队列管理请求

进入队列。队列就像它的字面意思一样简单:一个任务进来,被添加到队列中,然后工作器在有能力处理时拾取下一个任务。(参见图 1.21。)这些任务可以表示对数据库的简单写入,也可以表示像为文档生成缩略图预览图像这样复杂的事情。当客户端向队列提交任务请求时,它们不再被迫等待结果;相反,它们只需要确认请求已正确接收。此确认稍后可作为工作结果的参考,供客户端在需要时使用。

队列使客户端能够以异步方式工作,为客户端的请求及其响应提供了一个策略性的抽象。另一方面,在同步系统中,请求和回复之间没有区别,因此无法分别管理它们。在异步系统中,客户端请求一个任务,服务响应一条确认已接收任务的消息,然后客户端可以定期检查任务的状态,只有在任务完成后才请求结果。在等待异步请求完成的同时,客户端可以自由地执行其他工作,甚至向其他服务发出异步请求。后者是队列和消息如何在分布式系统中得到利用的一个例子。

队列还提供了一些保护,以防止服务中断和故障。例如,创建高度健壮的队列非常容易,该队列可以重试由于瞬态服务器故障而失败的服务请求。最好使用队列来强制执行服务质量保证,而不是将客户端直接暴露于间歇性服务中断,这需要复杂且经常不一致的客户端错误处理。

队列是管理任何大型分布式系统不同部分之间分布式通信的基础,并且有很多方法可以实现它们。有相当多的开源队列,例如 RabbitMQActiveMQBeanstalkD,但也有一些使用 Zookeeper 等服务,甚至像 Redis 这样的数据存储。

1.4. 结论

设计能够快速访问大量数据的有效系统是一件令人兴奋的事情,并且有很多很棒的工具可以实现各种新的应用程序。本章只介绍了几个例子,仅仅触及了表面,但还有很多其他的例子——而且这个领域只会继续出现更多的创新。