可扩展的 Web 架构与分布式系统
译者:尹星
本文介绍了分布式架构是如何解决系统扩展性问题的粗略方法,适合刚刚入门分布式系统的同学,我把整篇文章翻译如下,希望给你一些启发。
备注:[idea]标注的内容为译者的理解和注释,非原作内容。
开源软件已经成为了一些大型网站的基本组成部分。随着这些网站的发展,围绕其架构的最佳实践和指导原则已经诞生了。本章试图覆盖一些设计大型网站时需要考虑的关键问题和实现这些目标所需要使用的构件。
本章主要侧重于 web 系统,其中一些材料也适用于其他的分布式系统。
1.1 Web 分布式系统设计原理
构建和运行可伸缩的网站或应用程序到底意味着什么?起始阶段,它只是通过互联网将用户与远程资源连接起来。而使其具有可伸缩性的部分,则是资源或对这些资源的访问分布在多个服务器上。
[idea] 这就是可伸缩的分布式区别于单机的一个明显特征。
就像生活中的大多数事情,在构建一个 web 服务时,花时间提前进行规划从长远来看非常有益;理解大型网站背后的一些考量和权衡可以在创建更小的网站时做出更明智的决定。
以下是影响大型 web 系统设计的一些关键原则:
-
可用性:网站的正常运行时间对许多公司的声誉和功能都至关重要。对于一些较大的在线零售网站来说,即使几分钟的不可用,也会导致数千甚至数百万美元的收入损失,因此,他们的系统设计成能够持续可用并具有抗故障能力,既是一项基本业务,也是一项技术要求。分布式系统中的高可用性要求仔细考虑关键组件的冗余,从而在发生部分系统故障时能快速恢复,以及在出现问题时优雅的降级。
-
性能:网站性能已经成为大多数网站的重要考虑因素。网站的速度会影响使用和用户满意度,以及搜索引擎排名——这是一个和收入、留存率直接相关的因素。因此,创建一个系统并对其快速的响应速度和低延迟进行优化是关键。
[idea]常见的高性能指标有:响应时间(RT)、吞吐量、P90 等。
-
可靠性:系统需要可靠,这样的话请求将始终返回相同的数据。如果数据发生变更或更新,则同一个请求应应该返回新数据。用户需要知道,如果向系统写入或存储了某些内容,那么它将被持久化,并且可以依赖它(持久化数据)在未来的检索中就位。
-
可扩展性:对于任何大型分布式系统,大小只是规模需要考虑的一个方面。同样重要的,是增加容量来处理更大的负载所需的努力,这通常被称为系统的可伸缩性。可伸缩性可以参考系统许多不同的参数:它可以处理多少额外的流量,增加更多的存储容量是否容易,甚至是否可以处理更多的事务等等。
-
可管理性:设计一个易于操作的系统是另一个重要的考虑因素。系统的可管理性等同于操作的可伸缩性:维护和更新。可管理性需要考虑的点有:当问题发生时诊断和理解的容易程度,进行更新或修改的容易程度,以及系统操作的简单程度。(即它是否没有故障或异常的状态下正常运行?)
-
成本:成本是一个重要因素。包括硬件和软件成本,但考虑部署和维护系统所需的其他因素也很重要。开发人员构建系统所需的时间、运行系统所需的操作量、甚至所需的培训数量都应考虑在内。成本是拥有它的总成本。
这些原则中的每一条都为设计分布式 web 架构提供了决策依据。然而,它们也可能相互矛盾,以至于实现一个目标的同时要付出牺牲另一个目标的代价。
举个例子:选择通过简单添加更多服务器(可伸缩性)来解决容量问题,其可管理性(你必须运行额外的服务器)和成本(服务器的价格)代价变大。
在设计任何类型的 web 应用程序时,考虑这些关键原则是很重要的,即使要承认一个设计可能会牺牲其中的一个或多个原则。
[idea]系统设计没有绝对的好与坏,很多时候是根据具体的场景进行取舍,即 trade-off。
1.2 基础知识
当谈到系统架构时,需要考虑一些事情:哪些是正确的,这些部分是如何组合在一起的,以及哪些是正确的权衡。在真正需要分布式系统之前投资可伸缩的分布式系统通常不是一个明智的业务决定。但是,对设计进行一些预先的考虑可以在将来节省大量的时间和资源。
[idea]分布式不是银弹,在不需要分布式的场景下尽量不要分布式,这也符合软件设计原则:keep it simple。否则,可能就会跳到焦油坑,甚至损害业务。
本节重点介绍大多数大型 web 应用的核心要素:服务,冗余,分区和处理故障。 这些要素中每一个都涉及到选择和妥协,尤其是在上一节所描述的原则的背景下。为了详细解释这些概念,我们最好从一个例子开始。
示例:图片托管应用程序
在某个点,你可能已经在网上发布了一张图片。对于托管和传输大量图像的大型站点,构建一个经济高效、高可用性和低延迟(快速检索)的架构是一个挑战。
设想一个系统,用户可以将他们的图片上传到中央服务器,并且可以通过一个 web 链接或者 API 请求图像,就像 Flickr(公司名) 或 Picasa一样。为了简单起见,我们假设这个应用程序有两个关键部分:向服务器上传(写入)和查询图像的能力。虽然我们希望上传高效,但我们最关心的是当有人请求一个图像(例如为网页或其他应用程序请求图像)时传输非常快速。
这与 web 服务器或 Content Delivery Network(CDN)边缘服务器(服务器 CDN 用于在许多地点存储内容,以便内容在地理/物理上更接近用户,从而获得更快的性能)提供的功能非常相似。
这个系统其他重要方面还包括:
- 存储的图像数量没有限制,因此需要考虑在图像数量方面的存储可伸缩性。
- 图像下载/请求需要低延迟。
- 如果用户上传了一个图像,那么该图像应该一直存在(图像数据的可靠性)。
- 系统应该易于维护(可管理性)。
- 由于图像托管的利润率不高,所以系统需要成本效益比较高。
图1.1是功能的简化图,如下图所示:
图1.1:镜像托管应用程序的简化架构图
在这个图像托管的例子中,系统必须让人感觉很快、数据存储可靠,所有这些属性都是可伸缩的。为这个应用程序构建一个小版本非常简单,并且很容易托管在一台服务器上;但是,对于本章来说,这种方式并不怎么有趣。让我们假设我们想要建立一个能成长到 Flickr(公司产品) 一样大的系统。
服务
在考虑可伸缩的系统设计时,把系统每个具有明确定义接口的部分看成是服务将有助于解耦功能。实际上,用这种方式设计的系统被称为Service-Oriented Architecture(SOA)。对于这种类型的系统,每个服务都有自己的区分清晰的功能环境,与环境之外的内容交互都是通过一个抽象接口完成的,通常是另一个服务面向公共的 API 。
将一个系统分解成一组相辅相成的服务,可以使这些部分的操作彼此解耦。这种抽象有助于在服务、其底层环境和该服务的使用者之间建立清晰的关系。创建这些清晰的描述有助于隔离问题,但也允许每个部分独立地扩展。这种面向服务的系统设计非常类似于面向对象的编程设计。
在上面的示例中,上传和检索图像的所有请求都由同一个服务器处理;但是,由于系统需要进行扩展,因此将这两个功能分解为独立的服务很有意义。
让我们快进一步,假设该服务正在被大量使用;这样的场景使我们很容易看到更长的写入时间会对读取图像的耗时产生多大的影响(因为这两个功能将会竞争共享资源)。根据构架的不同,这种效果可能是影响非常明显。即使上传和下载的速度相同(这不是大多数 IP 网络的真实情况,因为大多数IP网络的设计速度至少为 3:1 下载速度:上传速度比),读取文件通常会从缓存中读取,写操作最终还是要到磁盘上(在最终一致的情况下可能要写几次),即使所有内容都在内存中或从磁盘读取(如SSD),数据库写入几乎总是比读取慢。(PolePosition,一个用于数据库基准测试的开源工具, http://polepos.org/,结果参见 http://polepos.sourceforge.net/results/PolePositionClientServer.pdf 。)。
[idea]可以好好看看不同场景的性能情况,有助于形成更直观的认知。
这种设计另一个潜在问题是,像 Apache 或 lighttpd 这样的 web 服务器通常维护的并发连接数有一个上限(默认值约为500个,但可能会更高),在高流量下,写请求很快会消耗掉所有的连接。因为读取可以异步进行,或者利用其他性能优化方案,如 gzip 压缩或分块传输编码,web服务器可以更快地服务读操作,并在客户机之间快速切换,每秒处理比最大连接数更多的请求(apache 和 max connections 设置为500,在每秒服务数千个读请求的情况下并不少见)。另一方面,写操作倾向于在上传期间维护一个开放的连接(长连接),因此大多数家庭网络上传一个 1MB 的文件可能需要超过1秒,因此 web 服务器只能同时处理 500 个这样的写操作。
图1.2:拆分读写
通过将图像的读写操作分开到各自的服务中,可以很好地对这种瓶颈进行处理,如中所示图1.2。这使我们能够独立地测量每一个问题(因为我们可能总是读多于写),但也有助于澄清每个点上发生的事情。最后,这种分离未来关注点的方法,使得排除故障和扩展类似慢速读取的问题变得更加容易。
[idea]这种处理高并发场景的利器之一就是我们平时常见的“读写分离”。将问题进行拆分隔离,单一变量法更好地定位并解决问题。
这种方法的优点是,我们能够独立地解决问题——我们不必担心在相同的环境中读写新图像。这两种服务仍然利用全局图像库,但它们可以使用合适的服务方法(例如,排队请求或者缓存热点图片)来优化自己的性能。从维护和成本的角度来看,每个服务都可以根据需要进行独立扩展,这非常棒!因为如果将它们组合并混在一起,就会像上面讨论的场景一样,对另一个服务的性能产生负面的影响。
当然,当有两个不同的端点时,上面的示例可以很好地工作(事实上,这与一些云存储提供商的实现和 Content Delivery Networks 非常相似)。但是,有很多方法可以解决这些类型的瓶颈,而且每种方法都有不同的权衡。
例如,Flickr 通过给用户分发共享不同的碎片来解决这个读/写问题。这样每个碎片只能处理一定数量的用户,并且随着用户数量的增加,往集群中添加更多的碎片(关于Flickr 扩展的演示文稿,参见http://mysqldba.blogspot.com/2008/04/mysql-uc-2007-presentation-file.html)。
第一个例子中,根据实际使用情况(如整个系统的读写次数)来扩展硬件更容易,而 Flicker 会根据用户位基准进行扩展(但会强制假设用户之间的值相等,因此可以有额外的容量)。前一个服务的中断或出现问题会导致整个系统的功能下降(例如,没有人可以写文件),而 Flickr 的一个碎片导致的中断只会影响到这部分用户。
第一个示例中,跨整个数据集执行操作更容易,例如,更新写服务从而支持包含新的元数据或搜索所有的图像元数据,而使用 Flicker 架构,每个碎片都需要更新或搜索(或者需要创建一个搜索服务来整理这些元数据——元数据也就是做了什么操作)。
[idea] 单点操作只需集中维护一个数据集,分布式由于多个节点,想要维护全局数据就需要进行元数据的管理,从而保障数据的一致性。
当涉及到分布式系统时,没有正确的答案,但它有助于回到本章开头的原则:确定系统需求(大量的读或写,或者两者兼而有之,并发级别,跨数据集的查询,范围,排序等),基准不同的替代方案,理解系统将如何失败,对失败的发生有一个牢靠的计划。
冗余
为了优雅地处理故障,web 架构必须具备服务和数据的冗余。例如,如果一个文件只有一个副本存储在一台服务器上,那么丢失该服务器就意味着丢失该文件。丢失数据一般不是件好事,处理丢失数据的一种常见方法是创建多个或冗余的副本。
同样的原则也适用于服务。如果应用程序有一个核心功能,那么确保多个副本或版本同时运行可以预防单点故障。
在系统中创建冗余可以消除单点故障,并在遇到危机时提供备份或备用功能。例如,如果同一服务的两个实例在生产中运行,并且其中一个出现故障或降级,则系统可以将故障转移给健康的副本。故障转移可以自动完成,也可能需要手动干预。
服务冗余的另一个关键部分是创建无共享架构
。 在这种架构下,每个节点都能够独立运行,并且没有中央“大脑”来管理其他节点的状态或协调活动。这对可伸缩性有很大帮助,因为可以在没有特殊条件或知识的情况下添加新节点。然而,最重要的是,这些系统中没有单点故障,因此它们对故障的恢复能力更强。
[idea]如分布式账单(如区块链)去中心化就是一种类似的架构。
例如,在我们的图片服务器应用程序中,所有图片都在另一个硬件上有冗余的副本(最好是在发生地震或数据中心火灾等灾难时位于不同的地理位置),访问这些图像的服务是冗余的,所有这些都是潜在的服务请求。(参见图1.3(负载均衡器是实现这点的一个很好的方法,但是下面还有更多的例子)。
图1.3:具有冗余的图片托管应用程序
分区
可能有非常大的数据集不适合在一台服务器上。也可能是一个操作需要太多的计算资源,从而导致性能降低并需要增加容量。无论哪种情况,都有两种选择:垂直扩展或水平扩展。
-
垂直扩展意味着给单个服务器添加更多资源。因此,对于一个非常大的数据集,这可能意味着添加更多(或更大)硬盘驱动器,以便单个服务器可以容纳整个数据集。在计算操作的情况下,这可能意味着将计算转移到更大的服务器上,该服务器具有更快的CPU或更多的内存。在每种情况下,垂直扩展都是通过增加单台机器的资源从而处理更多数据来实现的。
-
另一方面,水平扩展就是添加更多的节点。在大数据集的情况下,可能是第二个服务器存储数据集的部分数据,对于计算资源,这意味着将操作或负载分散到一些额外的节点上。为了充分利用水平伸缩性,它应该作为系统架构的一个固有设计原则,否则修改和分离上下文可能会非常复杂且低效。
谈到水平扩展时,一种更常见的技术是将服务拆分为多个分区或碎片。分区可以分散部署,这样每个逻辑功能集都是独立的;这可以根据地理边界或其他标准(如非付费用户和付费用户)来确定。这个方案的优点是它们提供了一个具有额外容量的服务或数据存储。
在我们的图像服务器示例中,有可能将用于存储图像的单个文件服务器替换为多个文件服务器,每个文件服务器都包含自己唯一的一组图像。(参见图1.4)这种无架构将允许系统用映像填充每个文件服务器,并在磁盘满时添加额外的服务器。设计需要一个命名方案,将图像的文件名绑定到包含它的服务器上。映像的名称可以通过映射到服务器上的一致哈希方案形成。或者,可以为每个图像分配一个增量ID,这样当客户端请求图像时,图像检索服务只需要维护映射到每个服务器的ID范围(如索引)。
图1.4:具有冗余和分区的映像托管应用程序
当然,跨多个服务器分发数据或功能是有挑战的。关键问题之一是数据在分布式系统不同位置,数据越接近操作或计算点,系统的性能越好。因此,在多个服务器上部署数据是有潜在问题的,因为任何时候都可能不需要数据,迫使服务器通过网络执行代价高昂的获取所需信息的操作。
[idea]最好是尽可能将数据靠近计算点,虽然存储与计算分离是当下的一种趋势,但存储靠近计算仍然是非常有意义的。
另一个潜在的问题是数据不一致。 在某些情况下,服务可能会在读取数据前发生不一致的情况,或者说,在这些情况下,服务可能会在读取和写入数据之前发生不一致的情况。
例如,在图片托管场景中,如果一个客户机发送了一个请求,更新狗的图片,将其从“Dog”更改为“Gizmo”,但同时另一个客户机正在读取该图像,则可能会发生竞争条件。这种情况下,不清楚第二个客户会接收到哪个标题,是“Dog”还是“Gizmo”。
当然,与数据分区相关还有其他一些困难,但是分区允许按数据、负载、使用模式等方式将每个问题拆分为可管理的块。这有利于提高可伸缩性和可管理性,但这并非没有风险。有很多方法可以降低风险并处理故障;但是,为了简洁起见,本章不讨论这些方法。
如果你有兴趣了解更多,你可以看看我的容错与监控的博客文章。
1.3 快速和可扩展数据访问的构建块
在讨论了设计分布式系统时的一些需要考虑的核心点后,现在我们讨论一下困难的部分:扩展对数据的访问。
大多数简单的web应用程序,例如 LAMP 栈的应用程序,看起来像图1.5所示。
图1.5:简单的web应用程序
随着它们的增长,将面临两个主要的挑战:扩展对应用服务器和数据库的访问。在一个高度可伸缩的应用程序设计中,应用程序(或web)服务器通常是最小化的,并常常包含一个不共享的架构。这使得系统的应用服务器层具有水平可伸缩性。这种设计的结果是,数据库服务器和支撑的服务承担了繁重的工作;在这一层,真正的扩展和性能挑战将开始展现威力。
本章的其余部分将介绍一些更常见的策略和方法,通过提供对数据的快速访问,使这些类型的服务更加快速并且可扩展。
图1.6:高度简化的web应用程序
大多数系统可以被高度简化为图1.6所示。这是一个很好的起点。如果你有大量的数据,同时你想要快速方便的访问,就像把糖果藏在办公桌最上面的抽屉里。虽然高度简化,但前面的描述暗示了两个难题:存储的可伸缩性和数据的快速访问。
在本节中,假设你有许多 TB 的数据,并且希望允许用户随机访问这些数据中的一小部分。(参见图1.7)这类似于在图片应用示例中查找文件服务器上某个地方的图片文件。
图1.7:访问特定的数据
这一点尤其具有挑战性,因为将 TBs 级别的数据加载到内存中可能非常耗时;这直接通过磁盘IO 进行传输。从磁盘读取比从内存访问慢很多倍——内存访问的速度与Chuck Norris一样快,而磁盘访问比 DMV 的线路慢。这种速度的差异对于大型数据集来说确实是累加;按实数计算,顺序读取的内存访问速度为从磁盘读取的6倍,或比随机读取的速度快 10 万倍(参见“反常的大数据”,http://queue.acm.org/detail.cfm?id=1563874)。此外,即使使用唯一 id,解决知道在哪里找到这些少量数据的问题也是一项艰巨的任务。就好像是看都不看就想把最后一个 Jolly Rancher 从你的糖果堆里拿出来一样。
谢天谢地,你有很多选择来简化这一过程;其中四个最重要的选项分别是缓存、代理、索引和负载均衡器。本节的其余部分将讨论如何使用这些概念从而使数据访问更快。
缓存
缓存利用了引用原则的局部性:最近请求的数据很可能会被请求。它们几乎应用在计算的每一层:硬件、操作系统、web浏览器、web应用程序等等。缓存就像短期内存:它的空间有限,但通常比原始数据源快,并且包含最近访问过的项。缓存可以存在于架构中的所有层级中,但通常位于最靠近前端的层级,在那里,缓存的实现可以快速返回数据,且不会对下游层级产生负担。
在我们的 API 示例中,如何使用缓存使数据访问更快?
在这种情况下,有几个地方可以插入缓存。其中一个选项就是在请求层节点上插入缓存,如图1.8 。
图1.8:在请求层节点上插入缓存
[idea] 不只是分布式系统,比如在 mybatis 的设计中的多级缓存,也是这种思想的体现。
直接在请求层节点上放置缓存可以在本地存储响应数据。每次请求服务时,节点将快速返回本地缓存的数据(如果存在)。如果不在缓存中,请求节点将从磁盘查询数据。一个请求层节点上的缓存可以同时位于内存(非常快)和节点的本地磁盘上(比从网络存储中获取更快)。
图1.9:多个缓存
当你将其扩展到多个节点时会发生什么情况?如你在图1.9所见,如果请求层扩展到多个节点,可以让每个节点托管自己的缓存。但是,如果你的负载均衡器在节点间随机分发请求,那么相同的请求将被路由到不同的节点,从而增加缓存不命中概率。克服这个障碍的两个选择方案是全局缓存和分布式缓存。
全局缓存
全局缓存就像听起来一样:所有节点都使用同一个缓存空间。这涉及到添加一个服务器或某种类型的文件存储,其速度比原始存储更快,并且可由所有requestlayer节点访问。每一个请求节点都会在同一个路径中查询缓存,它将是一个本地缓存。这种缓存方案可能会有点复杂,因为随着客户端和请求数量的增加,很容易覆盖单个缓存,但在某些架构中非常有效(特别是那些具有专用硬件的架构,这些架构使全局缓存非常快,或者有一个需要缓存的固定数据集)。
图中描述了两种常见的全局缓存形式。在图1.10,当在缓存中找不到缓存响应时,缓存本身负责从底层存储中检索丢失的数据块。在图1.11请求节点负责检索缓存中未找到的任何数据。
图1.10 负责检索的全局缓存
图1.11 请求节点负责检索的全局缓存
利用全局缓存的大多数应用程序倾向于使用第一种类型,即缓存本身管理收回和获取数据,以避免客户端大量请求相同的数据。
但是,在某些情况下,第二个实现更有意义。
例如,如果缓存用于非常大的文件,则低缓存命中百分比将导致缓存缓冲区因缓存未命中而变得不堪重负;在这种情况下,在缓存中保留总数据集(或热点数据集)的很大比例很有帮助。
另一个例子是一个架构,其中存储在缓存中的文件是静态的,不应该被驱逐(这可能是因为应用程序对数据延迟的要求,对于大型数据集,某些数据可能需要非常快,此时应用程序逻辑比缓存更了解驱逐策略或热点数据。)
分布式缓存
在分布式缓存中(图1.12),每个节点都拥有缓存数据的一部分,因此,如果冰箱充当杂货店的缓存,则分布式缓存就像将食物放在冰箱、橱柜等多个位置,和午餐盒方便取零食的位置,而无需去商店。
通常,缓存是使用一致哈希函数进行划分的。通过这种方式,如果请求节点正在查找某个数据块,它就可以快速判断分布式缓存中查找的位置,以确定该数据是否可用。在这种情况下,每个节点都持有一小块缓存,在到达源节点之前向另一个节点发送一个请求以获取数据。因此,分布式缓存的一个优点是增加了缓存空间,只需将节点添加到请求池中即可。
分布式缓存的一个缺点是修复丢失的节点。一些分布式缓存通过在不同的节点上存储多个数据副本来解决这个问题。但是,你可以想象这个逻辑很快就变得复杂,特别是当你从请求层添加或删除节点时。虽然即使一个节点消失并导致部分缓存丢失,请求也只是从源文件中拉出来——所以这不一定是灾难性的!
图1.12:分布式缓存
缓存最棒的地方在于它们通常能让系统变得更快(当然前提是实现正确!)。你选择的方法允许它更快地处理更多请求。然而,所有这些缓存都是以维护额外的存储空间为代价的,通常是以昂贵的内存形式实现;天下没有免费的午餐。缓存对于提高速度非常有用,而且在高负载条件下提供系统功能,否则会出现完全的服务降级。
[idea]缓存本质是空间换时间。空间与时间的权衡,如压缩存储是时间(cpu)换空间(磁盘存储)。
一个流行开源缓存的例子是memcached(http://memcached.org/)(既可以用作本地缓存,也可以用作分布式缓存);但是,还有许多其他选项(包括许多特定语言或框架的选项)。
Memcached 在许多大型网站中被广泛使用,尽管它功能强大,但它只是内存中简单的键值存储,可用于任意数据存储和快速查找优化(O(1) ).
Facebook 使用几种不同类型的缓存来提高其站点性能(参见Facebook缓存和性能”)。他们在语言级别使用GLOBALS
和APC缓存(在PHP中以函数调用的代价实现),这有助于更快地进行中间函数调用和获取结果。(大多数语言都有这些类型的函数库来提高网页性能,而且应该一直使用它们。)Facebook使用一个分布在多个服务器上的全局缓存(参见在Facebook上扩展memcached”),因此访问缓存的一个函数调用可以对存储在不同Memcached服务器上的数据发出并行的多个请求。这使得他们能够获取更高的性能和增大用户配置文件数据的吞吐量,并且有一个中心地方来更新数据(这点很重要,因为当你运行数千台服务器时,缓存失效和保持数据一致性会成为一个难题)。
现在让我们讨论一下当数据不在缓存中时该怎么做。
代理
在底层,代理服务器是一个硬件/软件的中间部分,它接收来自客户端的请求并将其转发到后端源服务器。通常,代理用于过滤请求、日志请求,有时还用于转换请求(通过添加/删除 header、加密/解密或压缩)。
图1.13:代理服务器
代理在协调来自多个服务器的请求时也非常有用,提供了从系统范围的角度优化请求流量的机会。使用代理加速数据访问的一种方法是将相同(或相似)的请求合并为一个请求,然后将单个结果返回给请求客户端。这称为折叠转发。
假设在多个节点上请求一个相同数据(我们称之为littleB),而且该数据不在缓存中。如果该请求被路由到代理,那么这些请求都可以折叠成一个,这意味着我们只需要从磁盘读取一次 littleB。(参见图1.14)这种设计有一些成本,因为每个请求的延迟可能会稍高一些,有些请求可能会稍微延迟,以便与相似的请求进行分组。但它在高负载情况下提高性能,特别是反复请求相同数据时。这与缓存类似,但它不像缓存那样存储数据/文档,而是优化对这些文档的请求或调用,并充当客户端的代理。
[idea]代理并不存储数据,只是对请求进行优化(如折叠转发相同请求)来提高性能。
例如,在局域网代理中,客户机不需要自己的IP来连接到 Internet,LAN将对来自客户端请求的相同内容进行压缩。不过,这里很容易混淆,因为许多代理也是缓存(因为在代理中放置缓存的逻辑非常合理),但并非所有缓存都充当代理。
图1.14:使用代理服务器来折叠请求
使用代理的另一个很好的方法是不仅折叠对相同数据的请求,还可以折叠对源存储中空间上靠近的数据的请求(在磁盘上连续)。采用这样的策略可以最大限度地提高请求的数据局部性,从而减少请求延迟。
例如,假设有一组节点请求B的部分数据:partB1、partB2等。我们可以设置代理来识别单个请求的空间位置,将它们压缩为单个请求并只返回 bigB,从而极大地减少了从数据源读取的数据。(参见图1.15)当你随机访问 TBs 级别的数据时,这在请求时间上将大为不同!在高负载情况下,或者当缓存有限时,代理尤其有用,因为它们基本上可以将多个请求批处理为一个请求。
[idea]这种情况下代理相当于将多次请求使用批处理(多次随机读写合并为一次)为一次请求,进而提升性能。
图1.15:使用代理来折叠对空间上邻近的数据的请求
值得注意的是,你可以同时使用代理和缓存,但通常最好将缓存放在代理前面,因为同样的原因,在长距离马拉松比赛中,最好让跑得更快的人先开始。这是因为缓存提供来自内存的数据,它非常快,并且不介意对同一结果的多个请求。但是,如果缓存位于代理服务器的另一端,那么每次请求在缓存之前都会有额外的延迟,这可能会影响性能。
如果你正在考虑往你的系统里添加一个代理,有许多选项可以考虑: Squid and Varnish 已经过了负载测试,并广泛应用于许多生产级的网站。这些代理解决方案提供了许多优化,以充分利用客户机-服务器通信。在web服务器层安装其中一个作为反向代理(将在下文的负载均衡器部分中解释)可以显著提高web服务器性能,从而减少处理到来的客户端请求所需的工作量。
索引
使用索引快速访问数据是优化数据访问性能的一种众所周知的策略;在数据库方面,这可能是最广为人知的策略。索引权衡了增长的存储开销和较慢的写入速度(因为你必须同时写入数据和更新索引),以获得更快的读取速度。
就像传统关系数据型存储一样,你也可以将此概念应用到更大的数据集。索引的诀窍在于,你必须仔细考虑用户将如何访问你的数据。对于大小为许多 TB 但有效负载非常小(例如1kb)的数据集,索引是优化数据访问的必要条件。在如此大的数据集中找到一个小的有效负载挑战很大,因为你无法在合理的时间内迭代这么多的数据。此外,如此大的数据集很可能分布在几个(或很多)物理设备上——这意味着你需要一些方法来找到所需数据准确的物理位置。索引是最好的方法。
图1.16:索引
索引可以像目录一样使用,指引你定位到数据所在的位置。
例如,假设你正在查找B的第2部分中的每个数据,你如何知道在哪里可以找到它?如果你有一个按数据类型排序的索引——比如说数据A,B,C——它会告诉你数据B所在的原始位置。然后你只需找到那个位置,读出B中你想要的部分。(参见图1.16 。)
这些索引通常存储在内存中,或者存储在距离到来的客户端请求本地的位置。Berkeley DBs(BDBs)和树状数据结构通常用于按顺序列表存储数据,非常适合使用索引进行访问。
通常有许多层的索引充当 map,将你从一个位置移动到另一个位置,以此类推,直到你获得所需的特定数据块。(参见图1.17 。)
图1.17:多层次索引
索引还可以用于创建相同数据的多个不同视图。对于大型数据集,这是一种很好的方法,可以定义不同的过滤器和排序,而不必依赖于创建许多额外的数据副本。
[idea]如 mysql 中的覆盖索引,就是一个排序好的方式存储的,需要有序读取时就无需再使用类似于临时表的方式再做排序。
例如,想象一下早期的图片托管系统实际上托管了图书页面的图像,该服务允许客户端查询这些图像中的文本,搜索关于某个主题的所有图书内容,就像搜索引擎允许你搜索 HTML 内容一样。这种情况下,所有这些图书图像需要很多很多的服务器来存储文件,而要找到一个页面呈现给用户可能会有点麻烦。首先,查询任意单词和单词元组的倒排索引需要容易访问;然后是导航到书中正确的页面和位置,并为结果检索正确图像,这些都很有挑战性。因此,在这种情况下,倒排索引将映射到一个位置(如book B),然后B可能包含一个索引,其中包含每个部分中的所有单词、位置和出现次数。
[idea]倒排索引是搜索中非常重要的概念,有兴趣的读者可以自行了解。
一个倒排索引,可以表示上图中的 Index1,可能看起来像下面的每个单词或单词的元组,提供书籍包含的索引。
Word(s) | Book(s) |
---|---|
being awesome | Book B, Book C, Book D |
always | Book C, Book F |
believe | Book B |
中间索引看起来很相似,但只包含book B的单词、位置和信息。这种嵌套索引架构允许每个索引占用的空间比所有这些信息存储到一个大的倒排索引中所占用的空间小。
这在大型系统中非常关键,因为即使是压缩过的,这些索引也会变得非常大,存储起来代价也很高。在这个系统中,如果我们假设世界上有很多书——100000000(参见谷歌图书内部博客文章)——每本书只有10页(为了便于计算),每页250字,也就是说有2500亿字。如果我们平均每个字使用5个字符,而每个字符占用8位(或1个字节,尽管有些字符是2个字节),因此每个字5字节,那么只包含每个字一次的索引就超过了 1TB 的存储。因此,你可以看到创建包含大量其他信息(如单词元组、数据位置和出现次数)的索引可以非常迅速地累计到很大的数量。
创建这些中间索引并在较小的部分中表示数据使得处理大数据问题变得更容易。数据可以分布在多个服务器上,并且仍然可以快速访问。索引是信息检索领域的基石,也是现代搜索引擎的基础。当然,这一节只涉及了表层知识,关于如何使索引更小、更快、包含更多信息(如相关性)以及无缝更新,还有很多研究正在进行中。(在竞争条件下,以及添加新数据或更改现有数据所需的更新数量太多,尤其是在涉及相关性或评分的情况下,存在一些可管理性的挑战)。
能够快速、轻松地找到数据非常重要;索引是实现这点的一个有效而简单的工具。
负载均衡器
最后,任何分布式系统的另一个关键部分是负载均衡器。负载均衡器是任何架构的主要部分,因为它们的角色是在负责服务请求的一组节点之间分配负载。这允许多个节点透明地为系统中的同一功能提供服务。(参见图1.18)它们的主要目的是处理大量同时发起的连接,并将这些连接路由到一个请求节点,从而允许系统通过添加节点来扩展从而服务更多的请求。
图1.18:负载均衡器
有许多不同的算法可用于服务请求,包括随机选择一个节点、轮询,甚至根据某些标准(如内存或CPU利用率)选择节点。负载均衡器可以用软件或硬件设备实现。一个得到广泛采用的开源软件负载均衡器是 HAProxy ).
在分布式系统中,负载均衡器通常位于系统的最前端,这样所有传入的请求都会被一致地路由。在复杂的分布式系统中,请求路由到多个负载均衡器并不少见,如中所示图1.19 。
[idea]在一些大型互联网公司(如电商)存在级联部署的关系,如 LVS 下级联多个 Nginx,Nginx 后级联 Nginx。
图1.19:多个负载均衡器
与代理一样,一些负载均衡器还可以根据请求的类型用不同的方式路由请求。(从技术上讲,这些也称为反向代理。)
负载均衡器的挑战之一是管理特定的用户会话的数据。
在一个电子商务网站中,当你只有一个客户时,很容易允许用户把东西放进他们的购物车里,并在两次访问之间保存这些内容(这一点很重要,因为如果用户返回时产品仍在购物车中,你就更有可能卖出产品)。但是,如果用户被路由到一个节点进行会话,然后在下次访问另一个节点,则可能会出现不一致,因为新节点可能缺少该用户的购物车内容。(如果你把6包山泉水放在你的购物车里,然后回来的时候购物车是空的,你不会不高兴吗?)
解决这一问题的一种方法是会话粘连,以便用户始终被路由到同一个节点,但是很难利用诸如自动故障转移之类的可靠特性。这种情况下,用户的购物车将始终包含内容,但如果他们的粘连的节点不可用,则需要有一个特殊情况,并且内容存在的假设将不再有效(尽管希望此假设不会内置到应用程序中)。当然,这个问题可以通过使用本章中的其他策略和工具来解决,比如服务,还有很多没有涉及的内容(比如浏览器缓存、cookies 和URL重写)。
[idea]这种情况也可以使用分布式缓存保存用户会话,如 redis 保存分布式会话。
如果一个系统只有几个节点,那么像轮询 DNS 这样的系统可能更有意义,因为负载均衡器可能很昂贵,并且会增加不必要的复杂层。当然,在更大的系统中有各种不同的调度和负载均衡算法,包括简单的随机选择或轮询调度算法,以及更复杂的机制,它们考虑了利用率和容量等因素。所有这些算法都允许分发流量和请求,并可以提供有用的可靠性工具,如自动故障转移,或自动删除故障节点(例如当它变得无响应时)。
然而,这些特征会造成一些问题。例如,当遇到高负载情况时,负载均衡器将删除可能运行缓慢(因为请求太多)的节点,但这只会加剧其他节点的情况。在这些情况下,广泛的监控是很重要的,因为整个系统的流量和吞吐量可能看起来在减少(因为节点提供的请求更少),但是单个节点变得最大化。
负载均衡器是允许你扩展系统容量的一种简单方法,并且像本文中的其他技术一样,在分布式系统架构中扮演着重要的角色。负载均衡器还提供了一个关键功能,即能够测试节点的运行状况。
例如,如果某个节点没有响应或负载过重,则可以利用系统中不同节点的冗余将其从处理请求的池中移除。
队列
到目前为止,我们已经讨论了很多快速读取数据的方法,但是扩展数据层的另一个重要部分是有效的写操作管理。
当系统很简单,处理负载最小且数据库较小时,写入速度可以预见的快;但是,在更复杂的系统中,写操作几乎需要花费非确定性的很长时间才能完成。例如,数据可能必须写入不同服务器或索引的多个位置,或者系统可能只是处于高负载下。在写操作或任何与此相关的任务可能需要很长时间的情况下,实现性能和可用性需要在系统中构建异步操作;通常的方法是使用队列。
图1.20:同步请求
设想一个系统,其中每个客户机都请求远程服务一个任务。每个客户机都向服务器发送请求,服务器尽快完成任务并将结果返回给各自的客户机。在小型系统中,一个服务器(或逻辑服务)可以尽可能快地为请求的客户机提供服务,这种情况可以正常工作。但是,当服务器接收到的请求超过它所能处理的数量时,每个客户机都必须等待其他客户机的请求完成,然后才能生成响应。这是同步请求的一个示例,如图20.1 所示。
这种同步行为会严重降低客户机性能;客户机被迫等待,实际上执行无效工作,直到其请求得到响应。添加额外的服务器也不能解决系统负载问题;即使进行有效的负载平衡,也很难保证为了最大限度地提高客户机性能所需的均衡和公平分配工作。此外,如果处理请求的服务器不可用或失败,则上游客户端也将失败。有效地解决客户请求和服务之间的问题需要进行抽象。
[idea]这就好像去饭店吃饭,每个顾客点好菜之后都守在厨房,只有等他菜上齐了才走,那后面顾客的这个感觉可想而知。
图1.21:使用队列管理请求
开始队列之旅。队列听上去很简单:一个任务进来,添加到队列中,然后 workers 在有能力处理下一个任务时取走它。(参见图1.21)这些任务可以表示对数据库的简单写入,或者类似于为文档生成缩略图预览页面这样复杂的任务。当客户机向队列提交任务请求时,它们不再被迫等待结果;相反,它们只需要确认请求已被正确接收即可。当客户端需要时,该确认可以作为工作结果的参考。
[idea]一个简单的队列实现如 jdk 中线程池,推荐感兴趣的读者可以自行研究一下 jdk 的线程池源码实现。
队列使客户机能够以异步方式工作,提供了客户机请求及其响应的整体抽象。另一方面,在同步系统中,请求和响应之间没有区别,因此无法单独管理它们。在异步系统中,客户机请求一个任务,服务用一条确认消息来响应,表示任务已收到,然后客户机可以定期检查任务的状态,只需要在任务完成后请求结果。当客户机等待异步请求完成时,它可以自由地执行其他工作,甚至可以对其他服务发出异步请求。后者是分布式系统中如何利用队列和消息的一个例子。
[idea]这个思想可以类比多路复用、异步等 linux 线程模型去理解。
队列还提供了一些防止服务中断和失败的保护。
例如,很容易创建一个高度健壮的队列来重试那些由于短暂的服务器故障而失败的服务请求。使用队列来执行服务质量保证相比于将客户机直接暴露于交互服务中断(需要复杂且常常不一致的客户端错误处理)更可取。
队列是管理任何大型分布式系统不同部分之间的分布式通信的基础,有很多方法可以实现它们。有很多开源队列像RabbitMQ, ActiveMQ, BeanstalkD,但也有人使用像 Zookeeper,甚至数据存储 Redis。
1.4 结论
设计能够快速访问大量数据的高性能系统是件令人兴奋的事情,而且有许多很棒的工具可以支持各种新的应用程序。本文只介绍了几个例子,仅仅停留在表面,但是还有更多的例子——而且这个领域里将会持续出现更多的创新。
为了方便你查看,我做了一张思维导图,你可以对照下图回顾一下本文的主要内容。