YoloKokura

本文翻译自Kate Matsudaira的Scalable Web Architecture and Distributed Systems,这是The Architecture of Open Source Applications中的一章内容。

开源软件已成为一些大型网站的基石。随着这类网站的增长,有关网站架构的最佳实践和指导原则开始浮出水面。本章将介绍一些设计大型网站时需要考虑的关键因素,以及用于实现这些目标的组件。

本章主要针对web系统,但部分内容也适用于其他类型的分布式系统。

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

构建和维护一个可拓展的网站或应用到底意味着什么?简单地说,我们只是通过因特网把用户和远程资源连接起来。这些资源,或者说对资源的访问,分布在多个服务器上,使其支持拓展。

正如生活中的大部分事情一样,提前花时间设计web服务能够起到事半功倍的效果。理解大型网站背后的考量和权衡能帮助我们在构建更小的网站时做出更明智的决定。下面是一些影响大规模web系统设计的关键原则:

  • 可用性:网站正常运行时间对许多公司的商誉和业务至关重要。对一些大型在线购物网站来说,即使是几分钟的崩溃也会导致数千或数百万美元的利润损失,因此,将系统设计的持续可用且能抵御故障,既是基本的业务要求,也是技术要求。分布式系统中的高可用性意味着要审慎考虑关键组件的冗余、分区故障后的快速恢复,以及问题出现后的优雅降级。
  • 性能:网站性能对绝大多数站点来说都是重要的考量因素。网页速度影响了用户的使用体验,也影响了搜索引擎排名,而这直接关系到网站的收入和保留率。因此,创建一个高响应、低延时的系统是关键。
  • 可靠性:系统可靠性指的是对数据的请求总会返回相同的数据。当数据变动或者更新时,相同的请求应返回新的数据。当有东西被写入系统或者存储时,用户需要能感知到这部分数据可以持久化,未来也可以检索到。
  • 可拓展性:对于任何大型分布式系统来说,规模只是可拓展性中需要考虑的一个方面。与之同等重要的是,为增加负载量所需要付出的代价,这通常被称为系统的可拓展性。可拓展性可以体现在系统的许多参数中:系统能承载多少额外流量、增加更多的存储容量有多容易,或者系统能额外处理多少事务。
  • 可管理性:设计容易维护的系统是另一项重要的考虑因素。系统的可管理性等同于运维的可拓展性:维护和更新。在这方面要考虑的事情包括问题诊断的难度、升级或修改系统的难度以及系统运维的难度。(也就是说,系统平时能够无措运行吗?)
  • 成本:成本是很重要的因素。这当然包括软硬件成本,但把其他方面的成本考虑进来也很重要,比如部署和保持系统运行的花销。构建系统所需的开发时间、运行系统要付出的运维代价,甚至人员培训,这些都必须被纳入考虑。成本就是要拥有这个系统的总开销。

上述原则中的每一项都在设计分布式web架构中占有一席之地。然而,他们也会相互冲突,以至于要实现一项原则,就必须牺牲另一项。一个基本的例子是:选择通过增加服务器的方式来增加容量(可拓展性)可能导致管理难度上升(你还需要负责新服务器的运维)和成本增加(服务器不是免费的)。

不管设计哪种web应用,遵循这些原则都很重要,即使一种设计方案可能会牺牲一项或多项原则。

1.2 基础

在系统架构方面有这样一些事情需要我们考虑:哪些组件是合适的?这些组件怎么组合在一起?哪些妥协是值得的?在真正需要之前就在可拓展性上投入过多通常不是一个明智的业务决策。然而,某些预先设计却能在未来节省大量的时间和资源。

这一节主要关注几乎所有大型web应用中的一些核心要素:服务、冗余、分区和容错。我们要遵循,就不得不对这些因素做出选择和妥协。为了详细解释这些因素,我们最好从一个例子开始。

事例:图片存储应用

你应该在网上上传过图片。对于保存和分发海量图片的大型站点来说,构建一个高性价比、高可用性和低延时(快速检索)的架构很有挑战。

想想这样一个系统,用户能向中心服务器上传图片,可以通过网页链接或者API请求图片,就像Flickr或者Picasa一样。为了简化问题,我们假设这个应用有两个关键部分:将图片上传至(写入)服务器、请求图片。虽然我们希望上传越快越好,但我们更关心用户请求时图片能否很快被分发出去(举个例子,网站或者其他应用经常要请求图片资源)。这和web服务器或者内容分发网络(Content Delivery Network,CDN)边缘服务器(CDN用这类服务器在多个地点存储内容,因此内容和用户在地理/物理上更近,从而有更快的性能)可能要提供的功能基本类似。

系统的其他一些重要方面包括:

  • 要保存的图片数量没有限制,因此需要考虑存储可拓展性,即图片总量。
  • 图片下载和请求的时延要低。
  • 如果用户上传一张图片,这张图片就必须一直被存储(图片的数据可靠性)。
  • 系统维护难度应该不大(可管理性)。
  • 由于有图片存储没有很高的毛利,系统需要有较高性价比。

图 1.1是系统功能的简单图表。

图 1.1

在这个图片存储示例中,系统必须速度快,数据存储可靠,所有这些属性都具备高可拓展性。构建这样的一个小型应用很容易,能够在单服务器上运行,但这不是本章要考虑别的。我们假设我们想要构建的系统有朝一日能增长到Flickr那样的规模。

服务

设计可拓展的系统时,解耦功能模块,并把系统的每个部分都想象成具有明确定义接口的服务很有帮助。在实践中,这种设计思路成为面向服务的架构(Service-Oriented Architecture,SOA)。对于这类系统,每项服务都具备独特的功能上下文,其与上下文之外的任何交互都是通过抽象接口(通常是另一项服务的公开API)实现的。

将系统分解成一组互补的服务能将这些部分的操作解耦。这种抽象有助于建立服务、依赖环境和服务的消费者之间的清洗关系。构建这类清晰的界限既能帮助隔离问题,也使各部分能够独立扩展。这种面向服务的系统设计和编程语言中面向对象的设计很相近。

在我们的例子里,单一服务器执行了所有上传或检索图片的请求。然而,由于系统需要扩展,有必要把这两个功能分解到各自的服务里。

我们快进到服务被大量使用的场景中。在这种情况下,可以很容易地看出写入对图片读出时间的影响,因为这两个功能会竞争共享资源。考虑架构的不同,这种影响可能很大。即使上传和下载速度相同(对于大多数IP网络这不可能出现,因为他们的下载/上传比例被设计为3 : 1以上),文件读取通常在缓存中进行,而写入则最终需要落盘(在最终一致性的情况下,可能需要多次写入)。即使所有数据都在内存中,或者从磁盘读取(比如SSD),数据库的写入速率通常比读出速率低。(Pole Position是一个开源的数据库评测工具,http://polepos.org/,其结果见http://polepos.sourceforge.net/results/PolePositionClientServer.pdf

这种设计的另一个潜在问题在于,Apache或者lighttpd这样的web服务器通常限制了并发连接数的上限(默认一般在500左右,但可以设置的很大),而在大流量场景下,写入能够很快达到这个上限。由于读出可以采用异步设计,或者使用其它性能优化技术(如gzip压缩和分块传输编码),web服务器能够更快地切换读取请求,快速切换用户,达到比最大连接数更高的每秒请求数(最大连接设置到500的Apache通常能实现几千的每秒请求)。另一方面,写入则需要在上传时维持连接,因此,上传1MB的文件在大多数家庭网络中用时可能超过一秒,所以web服务器只能承受500个并发写入请求。

图 1.2所示,将图片的读出和写入封装入各自的服务是针对这种瓶颈的较佳方案。我们能够分别拓展每项服务(因为我们很可能面临读多写少的场景),同时也更清楚每个阶段发生了什么。这种方案也分解了未来维护时的内容,从而更容易定位慢查询这样的问题。

这种方案的优势在于我们可以分别独立的解决每个问题,无需担心同一环境下对新图片的写入和检索。两种服务仍然共享全局图片库,但能够根据各自情况采取不同的性能优化手段(比如,服务限流或热点缓存,后面会详细介绍)。而从维护和成本的角度考虑,每项服务能够独立按需扩展,如果它们混在一起的话,两者的性能就会相互影响,就像前文讨论的那样。

当然,上述示例在你有两个不同的端点时能顺利运行(实际上,这和CDN及一些云存储服务供应商的实现很接近)。但也有很多别的方式来解决这类瓶颈,每一种都存在不同的妥协。

例如,Flickr为了解决读写问题,将用户分布到不同的分片中,每个分片只负责一定数目的用户,当用户增长时,可以将更多分片加入集群(详见Flickr扩展的pre,http://mysqldba.blogspot.com/2008/04/mysql-uc-2007-presentation-file.html)。在第一种示例中,很容易基于实际使用率(整个系统的读写量)拓展硬件,但Flickr基于其用户量进行拓展(但需要假设每个用户使用的使用量相同,因此硬件容量将会过剩)。在后一种示例中,一项服务崩溃将导致整个系统的功能崩溃(比如没人可以写入文件),而Flickr中一个分片崩溃将只会影响到对应的用户。第一种实例中,对整个数据集进行操作会很容易,比如为了容纳新的元数据升级写入服务,或者搜索所有的图片元数据,而在Flickr的架构里,每一个分片都需要被升级或检索到(又或者需要创建一个搜索服务来整理元数据,他们实际上也是这么做的)。

这些系统并没有唯一正确的答案,但时刻回到本章最开始提到的原则总是有帮助的,确定系统需求(读多、写多或者都有,并发量、数据集上的请求、范围、排序等等),评估不同方案,理解系统的崩溃原因,确定系统崩溃的预案。

冗余

为了容错,web架构必须设置冗余服务和数据。例如,如果只在一台服务器上存储一份数据,那么服务器崩溃了数据也就丢失了。数据丢失一般都不是好事,而解决这个问题的常见方法就是创建多个数据备份。

同样的原则也适用于服务。如果一个应用存在功能核心组件,确保同时有多个副本或者版本运行能够应对单节点崩溃的情况。

系统中的冗余策略能消除单点故障,在出现问题时提供备份或功能。比如,如果生产环境中同时运行相同服务的两个实例,其中一个故障或降级,系统可以迁移到正常的副本上去。迁移可以自动进行或通过人工干预的方式进行。

另一个服务冗余的关键点是创建无共享架构。在这种架构下,每个节点都能独立运行,不存在管理状态或协调其他节点活动的中心管理者。这对扩展性很有帮助,因为无需额外配置就能加入新节点。但最重要的是,这些系统中不存在单点故障,因此容错能力更强。

例如,在我们的图片服务应用中,所有的图片可以在别的硬件中保有备份(为了容灾,备份最好在另一个地理位置),访问图片的服务也要冗余,所有服务都能处理请求(见图 1.3)(负载均衡器可以很好地实现这个功能,后面会详细介绍)。

分区

可能存在大量的数据集,以至于无法存储在单一服务器上。也可能存在需要大量运算资源的操作,影响性能,需要增加容量。不管是哪种情况,你都有两种选择:纵向扩容或横向扩容。

纵向扩容意味着对单一服务器增加更多资源。对数据量极大的情况,这代表增加更多(或更大)的硬盘,让单一服务器也能容纳全部数据集。在计算操作的情况下,这可能意味着把计算过程转移到有着更快CPU或更多内存的大型服务器上。在每一种情况下,纵向扩容都是通过增强单一资源的能力来实现的。

而要横向扩容,就要增加更多的节点。在数据量大的情况下,可能需要增加第二台服务器来存储部分数据集。而对于计算资源,这就代表要把操作分解,或者用多个节点共同作业。为了最大程度利用横向扩容的优势,这种思路必须被系统架构内化,否则,为实现横向扩容而修改或拆解环境会很麻烦。

横向扩容的一种常见技术是把服务分解成分区或分片。分区可以是分布式的,每个功能的逻辑集合都相互隔离,这种隔离可以通过地理边界或者其他标准决定,如用户是否付费。这种做法的好处是能够提供更大容量的服务或数据存储。

在我们的图片服务器中,用多个文件服务器取代单一服务器来存储图片是可行的,每个服务器可以存储一部分图片。(见图 1.4)。这种架构使得系统能够在一台服务器满载后增加更多的服务器资源。该设计需要一种命名方案,将图片的文件名和存储图片的服务器联系起来。图片名称可以通过跨服务器的一致性哈希方案形成。或者,可以给每张图片分配递增ID,当用户发起图片请求时,图片检索服务只需要维护映射到每个服务器上的ID范围即可(比如索引)。

当然,将数据或功能分布到多台服务器上也有挑战。其中一个关键问题是数据局部性,在分布式系统中,数据和操作或计算点更近,系统性能就更好。因此,将数据散布在多台服务器上可能存在这样的问题,当需要数据时,它却不在本地,不得不通过网络拉取需要的信息,这样的开销很高。

另一个潜在问题是不一致性。当别的服务对共享资源(可能是另一个服务或者数据存储)进行读写时,有可能出现竟态条件,某些数据本应被更新,但读取发生在更新之前,这种情况下数据就会不一致。比如,在图片存储的场景下,如果一个用户发送更新狗狗图片标题的请求,把“Dong”修改成“Gizmo”,但同时另一个用户正在读取这张图片,就会触发竟态条件。这种情况下,第二个用户读取到的图片标题到底是“Dog”还是“Gizmo”是不确定的。

拆分数据无疑存在一些困难,但分区使得每个问题都被拆解(通过数据、负载量和使用场景等)到可供管理的块中。这对可扩展性和客观理性很有用,但并非没有风险。有很多方法可以减少风险处理错误,但由于篇幅原因,本章不再介绍。如果你想知道更多,可以阅读我这篇关于容错和监控的blog post

1.3 快速且可拓展数据访问的构建模块

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

很多简单的web应用,如LAMP栈应用,和图 1.5很类似。

这些应用增长时会面临两个主要问题:对应用服务器的访问扩展和对数据库的访问扩展。在可拓展性高的应用设计中,应用(或web)服务器经常被最小化,且经常采用了无共享架构。这使系统的应用服务器层支持横向扩容。由于这种设计,繁重的工作被下推到数据库服务器和支撑服务中。正是这一层面临着真正的扩展和性能挑战。

本章的剩余部分会讨论一些更常见的策略方法,通过提供对数据的快速访问,让这类服务快速且可扩展。

大多数系统可以被简化到图 1.6。这是一个很好的起点。如果你有很多数据,你会需要快速便捷地访问数据,就像从桌子最上面的抽屉里拿一罐糖果一样。尽管过分简化了,前面的论述暗示了两个难题:存储的可拓展性和数据的快速访问。

在这节中,我们假设有很多TB的数据,用户需要随机访问数据的一小部分。(见图 1.7)。这和图片应用示例中把图片文件定位在某个文件服务器上类似。

实现这个功能很有难度,因为把TB级别的数据装载到内存中的成本很大,成本这直接会转移到磁盘IO上。从磁盘中读取数据要比从内存中读取数据慢数倍。访问内存的速度就像Chuck Norris一样快,而访问磁盘的速度却比DMV的线路还慢。这种速度差异会在大型数据集上累积。内存访问的速度比磁盘顺序读取的速度快6倍,比磁盘随机读取的速度快100000倍(详见The Pathologies of Big Data,http://queue.acm.org/detail.cfm?id=1563874)。另外,即使有唯一ID,找到数据位置仍然很难。就像看都不看就从糖果罐里拿到最后一块Jolly Rancher糖一样难。

好在有很多选项来减轻难度,其中最重要的四种分别是缓存、代理、索引和负载均衡器。本届的剩余篇幅会讨论怎么使用这些手段加速数据访问。

缓存

缓存用到了引用局部性原理:最近被请求的数据很可能会被再次请求。缓存被用在了计算的几乎每一层中:硬件、操作系统、web浏览器、web应用等等。缓存就像是短期内存,其容量有限,但比原始数据源快得多,包含最近被访问的条目。缓存可以在架构的所有层级中出现,但经常在最接近前端的地方实现,以便快速返回数据,而不用将请求下传。

在我们的API示例里,要怎么利用缓存加速数据访问呢?在这个例子里,我们可以在很多地方插入缓存。其中一个选择是将缓存插入到请求层节点中,如图 1.8所示。

直接在请求层节点中插入缓存使响应数据可以被存放在本地。每当服务收到请求,节点将能快速返回本地存在的缓存数据。如果缓存未击中,节点会从磁盘中检索数据。请求层节点中的缓存可以既在内存中(很快),又在节点的本地磁盘中(比通过网络请求要快)。

要是把这种策略扩展到多个节点上呢?如图 1.9所示,如果请求层拓展到多个节点,每个节点也可以维护其本地缓存。然而,如果负载均衡器将请求随机分配给这些节点,导致相同的请求走向了不同节点,就会造成缓存未击中。全局缓存和分布式缓存都能解决这个问题。

全局缓存

顾名思义,所有节点都使用同一个缓存空间。这通过引入某种比原有存储更快,且能被所有请求层节点访问到的服务器或者文件存储来实现。每个请求层节点以和访问本地缓存相同的方式来访问全局缓存。这种缓存方案可能略显复杂,因为随着用户量和请求量的增加,缓存空间很容易满溢,但在某些架构中这种思路很有用(尤其是拥有全局缓存访问速度快的特定硬件,或者需要缓存的数据集大小固定的情况)。

存在两种全局缓存的形式。在图 1.10中,当某个请求未击中,缓存会自行从底层存储中拉取数据。在图 1.11中,请求节点会检索未命中的数据。

大多数采用全局缓存的应用倾向使用第一种方案,由缓存来管理数据淘汰和数据拉取,以避免缓存击穿。然而,在一些情况下第二种方案会更合理。例如,如果缓存用于很大的文件,低缓存命中率会导致缓存的缓冲区由于缓存未命中而溢出。在这种情况下,始终将全部数据集(或热点数据集)存在缓存里会很有用。另一种情况是存在文件被静态存放在缓存中,且不应该被删除的架构(这可能是由于应用对数据时延的要求,大型数据集的某一部分可能需要被快速检索,而应用逻辑比缓存更能理解淘汰策略或热点数据)。

分布式缓存

在分布式缓存中(图 1.12),每个节点拥有部分缓存数据。如果把冰箱比杂货店的缓存,那分布式缓存就像是把食物存放在若干地点——冰箱、杯垫、午餐盒——无需到店就可以轻松拿到零食。缓存一般使用一致性哈希函数来拆分,如果一个请求节点在寻找特定数据,他就能很快知道数据应该在分布式缓存的哪个位置,就能查清数据是否可用。在这个例子里,每个节点都持有一小部分缓存,在向数据源请求数据前会给其他节点发送请求。因此,分布式缓存的优势之一就是可以通过给请求池增加节点的方式来增加缓存空间。

分布式缓存的一个缺点是对缺失节点的补救。一些分布式缓存方案通过在不同节点上存放多个数据副本来绕过该问题,然而,你可以想象这样做的逻辑会很快变得复杂,尤其是当你从请求层增加或删除结点的时候。尽管节点消失导致部分缓存丢失,请求还是会从数据源拉取数据,因此这也不一定是灾难性的。

缓存的优点是能让事情变快(当然,需要正确的实现)。你选择的方法能让你更快地响应甚至更多的请求。然而,缓存以额外的存储空间作为代价,尤其是昂贵的内存,天下没有免费的午餐。缓存能让事情变得更快,还能让系统在高负载情况下运行,否则整个服务可能需要降级。

一个流行的开源缓存例子是Memcached(http://memcached.org/)(它既能用作本地缓存,也能用做分布式缓存)。然而,还有很多不同的选择,包括很多编程语言或框架特定的选择。

Memcached被用于很多大型网站,尽管它很强大,但却是一个简单的内存键值存储,在任意类型数据存储和快速查询上(O(1))进行了优化。

Facebook使用很多不同类型的缓存技术来确保他们站点的性能(见Facebook caching and performance)。他们在语言层面使用$GLOBAL和APC缓存(PHP提供,代价是一次函数调用),这使函数调用和结果返回更快(很多语言都有这类改善网页性能的库,应该经常使用)。Facebook还使用分布在多个服务器上的全局缓存(见Scaling memcached at Facebook),使得访问缓存的一次函数调用能并行请求不同Memcached服务器上的数据。这让他们实现了很高的性能和用户资料数据的吞吐量,且在一个中心更新数据(这很重要,因为运行上千服务器时,缓存失效和一致性保证会很难)。

接下来我们来聊聊如果数据不在缓存中时能做些什么...

代理

基本上讲,代理服务器是软硬件的中间部分,它接受用户请求并中继到后端服务器上。代理通常被用于过滤请求、记录请求日志,或者在某些时候改变请求(增加/删除请求头、加解密或者压缩)。

在协调多个服务器的请求时,代理也很有用,提供了从系统层面优化请求流量的机会。使用代理加速数据访问的一种方式是将相同或相似的请求合并成一个请求,并给请求的用户返回单一结果。这也被称为折叠转发。

想象一个跨节点的请求指向同样的数据(我们就叫他littleB),而这个数据项不在缓存里。如果这个请求通过代理转发,所有的这类请求可以被合并成一个,这就意味着我们只需要从磁盘中读取littleB一次(见图 1.14)。这种设计也有一些代价,因为每个请求可能面临稍微更高的延迟,一些请求可能会被推迟,以便和相似的请求合并。但是在高负载场景下,这样做能提高性能,特别是相同数据被反复请求的情况下。这和缓存很相似,但代理优化了对数据或者文档的请求,而不是将数据/文档保存下来。

比如,在LAN代理中用户不必知道他们自己的公网IP,LAN会把用户对相同内容的请求合并。这里很容易搞糊涂,因为很多代理也是缓存(这里放缓存也很合理),但不是所有缓存都能扮演代理的角色。

另一个使用代理的方式是不光合并针对相同数据的请求,还要把针对存储服务器上空间临近的数据的请求合并起来(在磁盘上连续分布)。使用这种策略能最大化请求的数据局部性,减少请求时延。比如,我们假设有一些节点请求B的一部分:B1、B2,诸如此类。我们可以设置我们的代理,使之能够识别这些请求的空间局部性,将其合并成一个请求,只返回bigB,这就很大程度上减少了对数据源的读取。(见图 1.15)当需要随机访问TB级别的数据时,这能减少很多的请求时间!代理在高负载,或者缓存有限的情况下尤其有用,因为能将许多请求合并。

你可以将代理和缓存放在一起使用,但通常来说,最好把缓存置于代理之前,这和在马拉松时让最快的运动员先起跑是一个道理。因为缓存从内存中返回数据,很快,也不用在意针对同一结果的大量请求。但如果缓存被放在代理服务器的另一侧,所有缓存之前的请求都将面临额外时延,这会影响到性能。

如果你在给自己的系统寻找代理,有很多选项值得考虑。SquidVanish都经过了实际测试,并广泛应用于许多生产网站。这些代理解决方案提供了许多针对用户-服务器通信的优化。使用其中之一在web服务器层作为反向代理(在后面负载均衡器一节会解释)能够极大改善web服务器性能,减少处理用户请求所需的工作量。

索引

使用索引来快速检索数据是优化数据访问性能的众所周知的手段,在数据库层面尤其知名。为了达到更快的读取,索引牺牲了存储空间和写入速度(因为数据和索引都需要更新)。

就像在传统关系型数据存储中一样,索引的概念也适用于大型数据集。索引的技巧在于,你必须谨慎考虑用户访问数据的方式。对于TB级别但有效负载很小(比如1KB)的数据集,索引对优化数据访问是必须的。由于不可能在有限时间内遍历这么大的数据量,在大型数据集中寻找很小的负载很困难。另外,这样规模的数据集很可能被分散在一些(或很多!)物理设备上,这代表你得用某种方式来寻找数据的正确物理位置。索引就是实现这一点的最佳手段。

索引的使用方式就像目录,能够让你找到数据的位置。比如,假设你在寻找一部分数据,B的2部分,你要怎么知道数据位置呢?如果你有按照数据类型排列的索引,比如A,B,C,你就能知道数据B的位置。你只需要找到那个位置开始读取B的部分即可。(见图 1.16

这些索引经常存在内存里,或者和用户请求很接近的位置。Berkeley DB(BDBs)和树形树结构通常被用于在顺序列表中存储数据,是使用索引访问的理想选择。

通常索引有很多用作map的层,使你来回跳转,直到找到你需要的数据。(见图 1.17

索引也用于给相同的数据创建不同的视图。对大型数据集来说,这是一种定义不同过滤器和排序的好方法,无需创建额外的数据副本。

例如,想象之前的图片存储系统实际上存储的是书页图片,服务允许用户请求图片中的文字,在所有的书本内容中搜索某个话题,就像搜索引擎搜索HTML内容一样。在这个例子里,需要很多很多服务器来存储书页图片,找到一页渲染给读者会很复杂。首先,要能容易地访问用于请求任意词汇或者词组的倒排索引。其次,导航到指定页面和书本位置,检索到正确的结果图片很难。因此,在这个例子里,倒排索引将映射到一个位置(比如book B),而B可能维护一个索引,存储所有的词汇、位置和每个部分的词汇出现数量。

上图中Index1这样的倒排索引和下面的表格很像,每个词汇或词组都提供了所属书籍的索引。

词汇书籍
being awesomeB, C, D
alwaysC, F
believeB

中间索引也很类似,但只包括词汇、位置和B的信息。这种嵌套索引结构使每个索引都只占据更小的空间,而不是像用一个大型倒排索引存放所有信息那样。

这在大规模系统中很关键,因为即使被压缩,这些索引也可能很大,存储成本很高。在这个系统中,如果我们假设有很多书,比如100000000(见Inside Google Books),每本书都只有10页(简化考虑),每一页有250个词,那意味着一共有2500亿词。如果我们假设每个词平均有5个字符,每个字符都占8 bit(或者1 byte,尽管一些字符是2 bytes),那么每个词都有5 bytes,每个词都只收录一次的索引将会占据超过1 TB的空间。因此,创建包含大量其他信息,如词组、数据位置、出现次数的索引,空间占用的增长会很快。

创建这类索引,在更小的单元里表示数据使大数据问题变得易于处理。数据可以分布在很多服务器上,却仍然能被快速访问。索引是信息检索的基石,也是当今现代搜索引擎的基础。当然,这一节只是表面,有很多关于如何让索引更小更快,包含更多信息(比如相关性)和无缝更新(由于竟态条件和增加或更新数据所需的更新量,尤其是在设计相关度或者评分的情况下,保证可管理性是一个挑战)的研究。

能够容易、快速找到数据很重要,而检索就是实现这一点的简单高效的工具。

负载均衡器

最后,任何分布式系统都有的另一个重要组件是负载均衡器。负载均衡器是任何架构的首要部分,因为它负责将负载分布到一系列复杂服务请求的节点上。这使很多节点能在一个系统中透明服务相同功能。(见图 1.18)其主要目的是处理大量并发连接,将其转发到一个请求节点上,使系统能够以增加节点的方式扩展,以便为更多请求提供服务。

有许多可用于服务请求的算法,包括随机选择、轮询,或者根据特定标准,如内存或CPU使用率来选择节点。负载均衡器可通过软件或硬件应用实现。一个被广泛使用的负载均衡软件是HAProxy

在分布式系统中,复杂均衡器经常在系统最前端,因此所有请求都能被转发。在复杂的分布式系统里,一个请求经常被转发给多个负载均衡器,如图 1.19所示。

像代理一样,一些负载均衡器也可以根据请求类型使用不同的方式转发请求。(技术上这也被称为反向代理)

负载均衡器的挑战之一是管理和用户session相关的数据。在电子商务网站中,如果只有一个用户,那么让用户加购,并在多次访问之间保存购物车信息(这很重要,毕竟用户回来时更容易购买还在购物车里的商品)很容易。然而,如果一个用户在其session内被路由到一个节点,下次访问时到了另一个节点,由于新节点可能没有用户的购物车信息,这就可能导致不一致性。(当你把6箱Mountain Dew放到购物车,回来一看不见了,你不会伤心吗?)一种方式是固定session,用户每次都能被路由到同一个节点,但是这样很难利用到故障自动迁移这样的可靠性功能。这种情况下,虽然用户购物车数据不会丢失,但如果固定的节点不可用,就需要考虑用户购物车数据失效的情况(尽管这种假设不会出现在真实应用里)。当然,使用本章中介绍的其他的策略或工具能解决这个问题,如服务,还有一些没被介绍(如浏览器缓存、cookies和URL重写)。

如果一个系统只有一部分节点,像轮询DNS这样的系统可能更合理,毕竟负载均衡器的代价可能很高,也引入了不必要的一层复杂性。当然,在大型系统里存在多种不同的调度和负载均衡算法,既包括简单的随机选择和轮询,也包括考虑使用率和容量的复杂技术。所有这些算法都将流量和请求分布开,且能够提供像崩溃自动迁移或坏节点(比如节点失去响应)自动删除这样有用的可靠性工具。然而,这些高级功能会使问题诊断更麻烦。例如,在高负载情境下,负载均衡器会移除响应慢或者超时(由于大量请求)的节点,但这样却恶化了其他节点的情景。在这些场景下,扩展监控很重要,因为整个系统的流量和吞吐量看上去在下降(因为节点服务的请求变少了),但单独的节点却达到了极限。

负载均衡器是扩展服务容量的简单方式,就像这篇文章中的其他技术一样,在分布式系统架构中扮演着重要角色。负载均衡器页提供了节点健康检测的重要功能,一旦节点失去响应或者过载,它就会从请求池中被移除,这也利用到了系统中的节点冗余策略。

队列

目前为止我们已经介绍了很多快速读取数据的方法,但另外一个扩展数据层的部分是对写入的有效管理。在系统简单地使用很少的负载处理和小型数据库时,写入可以预见会很快。但是,在更复杂的系统中,写入可能需要无法预期的很长时间。比如,数据可能需要在不同服务器或者索引上的一些地方写入,或者系统的负载很高。在写入或者任何其它重要的任务可能耗时的情况下,实现高性能和高可用性需要在系统中引入异步,通常使用队列实现这一点。

想象这样一个系统,每个用户都请求一个远程服务的任务。每个用户都向服务器发送请求,服务器尽快完成任务,给响应用户返回请求。在一台服务器(或者逻辑服务)就能尽快服务用户的小型系统中,这种场景没什么问题。然而,当服务器收到的请求高于其处理能力时,每个用户都必须等待其他用户的请求处理完毕。这就是图 1.20描述的同步请求例子。

这种同步行为会严重拉低用户性能。用户不得不等待,在请求被响应前无法完成任何工作。增加额外的服务器以增强系统负载也不能解决这个问题。甚至使用了有效的负载均衡后,也很难保证平均、公平地分配工作量以最大化用户性能。另外,如果处理请求的服务器不可用,或者崩溃,那么上游用户也会崩溃。要有效解决这个问题,就需要在用户请求和为服务请求而实际执行的任务之间进行抽象。

进入队列。队列顾名思义,一项任务进来,被加入到队列,worker在有能力处理任务时会从队列中领取任务。(见图 1.21)这些任务可以表示对数据库的简单写入或者更复杂的任务,如为一个文档生成预览图。当用户提交任务请求到队列,它们不再需要等待执行结果,相反,他们只需要知道(ACK)请求被收到即可。这份ACK后面被用作工作结果的参考。

队列使用户能以异步方式工作,在策略上提供了用户请求和响应的抽象。另外,在同步系统中,响应和恢复之间没有区别,因此不能被分开管理。在异步系统中用户请求任务,服务会返回确认任务被收到的消息,用户可以周期性地检查任务状态,只有在任务完成时才会请求结果。在用户等待异步请求完成期间,它能够自由执行其他工作,甚至给其它服务发送异步请求。后者即为分布式系统中队列和消息的用例。

队列也为服务中断和崩溃提供了保护。例如,要创建一个高鲁棒性,能够重试因为短暂的服务器崩溃而失败的请求的队列是很容易的。我们更倾向于是用队列来增强服务质量保障,而不是将用户直接暴露给内部服务终端,后者要求复杂且经常不一致的用户端错误处理。

在任何大规模分布式系统中,队列都是管理不同部分分布式通信的基础,实现队列的方式也有很多。有不少开源队列,如RabbitMQActiveMQBeanstalkD,但也有系统使用像Zookeeper这样的服务,或者甚至是Redis这样的数据存储。

1.4 结论

设计能快速访问大量数据的高效系统很让人激动,而且已经存在大量支持新型应用的优秀工具了。本章概括了一小部分用例,只是浅尝辄止,但还有很多需要探索的。在这个领域只会持续不断地涌现出更多的创新。

Tags: