本文翻译自:http://www.aosabook.org/en/index.html (卷2第1章)
中文版参考了这里的翻译:http://www.oschina.net/translate/scalable-web-architecture-and-distributed-systems
开源软件已成为一些超大型网站的基础组件。并且随着那些网站的发展,围绕它们的架构出现了一些最佳实践与指导性原则。本章尝试阐述设计大型网站需要考虑的一些关键问题,以及一些实现这些目标的组件。
本章主要侧重于Web系统,虽然其中一些内容也适用于其它分布式系统。
1.1 Web分布式系统设计原则
构建和运维一个可扩展Web站点或者应用到底意味着什么?说到底这种系统只不过是通过互联网将用户与远程资源相连接—使其可扩展的是分布于多个服务器的资源,或者对这些资源的访问。
类似于生活中的大多数东西,从长远来说,构建一个web服务之前花些时间提前规划是很有帮助的。理解大型网站背后一些需要考虑的因素与权衡取舍,在创建小一些的web站点时能让你作出更明智的决策。以下是影响大规模web系统设计的一些核心原则:
* 可用性: 一个网站的正常运行时间对于许多公司的声誉与运作都是至关重要的。对于一些更大的在线零售站点,几分钟的不可用都会造成数千或数百万美元的营收损失,因此系统设计得能够持续服务,并且能迅速从故障中恢复是技术和业务的最基本要求。分布式系统中的高可用性需要仔细考虑关键部件的冗余,从部分系统故障中迅速恢复,以及问题发生时优雅降级。
* 性能: 对于多数站点而言,网站的性能已成为一个重要的考虑因素。网站的速度影响着使用和用户满意度,以及搜索引擎排名,与营收和是否能留住用户直接相关。因此,创建一个针对快速响应与低延迟进行优化的系统非常重要。
* 可靠性: 系统必须是可靠的,这样相同数据请求才会始终返回相同的数据。数据变换或更新之后,同样的请求则应该返回新的数据。用户应该知道一点:如果东西写入了系统,或者得到存储,那么它会持久化并且肯定保持不变以便将来进行检索。
* 可扩展性: 对于任何大型分布式系统而言,大小(size)只是需要考虑的规模(scale)问题的一个方面。同样重要的是努力去提高处理更大负载的能力,这通常被称为系统的可扩展性。可扩展性以系统的许多不同参数为参考:能够处理多少额外流量?增加存储容量有多容易?能够处理多少更多的事务?
* 可管理性:系统设计得易于运维是另一个重要的考虑因素。系统的可管理性等价于运维(维护和更新)的可扩展性。对于可管理性需要考虑的是:问题发生时易于诊断与理解,便于更新或修改,系统运维起来如何简单(例如:常规运维是否不会引发失败或异常?)
* 成本: 成本是一个重要因素。很明显这包括硬件和软件成本,但也要考虑系统部署和维护这一方面。系统构建所花费的开发者时间,系统运行所需要的运维工作量,以及培训工作都应该考虑进去。成本是拥有系统的总成本。
这些原则中的每一个都为设计分布式web架构提供了决策依据。然而,它们之间也会相互不一致,这样实现一个目标的代价是牺牲另一个目标。一个基本的例子:简单地通过增加更多的服务器(可扩展性)来解决容量问题是以可管理性(你需要运维额外的一台服务器)和成本(服务器的价钱)为代价的。
设计任何一种web应用,考虑这些核心原则都是非常重要的,即使明知某个设计也许会牺牲其中的一个或多个原则。
1.2 基础概念
说到系统架构,需要考虑几个事情:什么是合适的部件,这些部件如何组合在一起,以及什么是正确的权衡取舍。在需要之前扩大投资通常不是一种明智的商业主张。然而,在设计上的一些远见在将来能够节省大量的时间和资源。
本节主要阐述对于几乎所有大型web应用来说都是非常重要的一些核心因素:服务,冗余,分区,以及故障处理。这些因素中的每一个都涉及选择与折中,特别是在上一节所描述的那些原则的上下文中。为了详细地解释这些东西,最好是从一个例子开始。
例子:图片托管应用
可能在以前的某个时候,你在网上张贴过图片。对于托管和提供大量图片的大网站来说,构建一个性价比高、高可用、以及低延迟(快速检索)的架构是存在诸多挑战的。
想象存在这样一个系统,用户可以上传图片到中央服务器,也可以通过web链接或者API请求图片,就像Flickr或Picasa一样。为了简单起见,我们假设这个应用有两个关键部分:上传(写)图片到服务器和查询图片。当然我们希望图片上传很高效,同时我们非常关注当有人请求一张图片时(例如,网页或者其他应用请求图片),系统能够快速地交付。这非常类似于web服务器或内容分发网络(CDN)边缘服务器(CDN将这种服务器用于在多个地方存储内容,这样内容就在地理/物理距离上更接近用户,从而更加快速)提供的功能。
系统的另一些重要方面有:
* 对于将要存储的图片数量没有限制,因此需要考虑存储的可扩展性。
* 图片下载/请求的延迟要低。
* 如果用户上传了某张图片,那么这张图片就得一直存在(图片数据的可靠性)。
* 系统应该易于维护(可管理性)。
* 由于图片托管的利润空间不大,所以系统应有较高的性价比。
图1.1是系统的一张功能简化图。
图1.1:图片托管应用的简化架构图
在这个图片托管例子中,系统必须明显地快速,数据存储可靠,并且所有这些属性高度可扩展。构建该应用的一个小型版本轻而易举,也很容易搭载在单个服务器上;然而,那样本章就没多大意思了。假设我们想构建一个能够发展得和Flickr一样庞大的应用。
服务(Services)
考虑可扩展的系统设计时,对功能进行解耦,然后将系统的每一部分看作能够自己提供服务,并具备明确定义的接口。实践中,人们评价以这种方式设计的系统具备面向服务的架构(SOA)。对这类系统来说,每个服务都有自己截然不同的功能上下文,并通过一个抽象接口与该上下文之外的一切(通常是另一个服务公开的API)进行交互。
将系统解构为一组相互补充的服务也就将不同组件的操作进行解耦。这种抽象有助于在服务、底层环境以及服务的消费者之间建立清晰的关系。这样明确的划分有助于隔离问题,也允许每个组件独立于其他组件进行扩展。这类面向服务的系统设计非常类似于程序设计的面向对象设计。
在我们的例子中,所有上传和检索图片的请求都是在同一个服务器上处理的;然而,将这两个功能分割成两个独立的服务在系统需要扩展时非常有意义。
现在假设该服务被大量使用;这种情况下很容易看到写操作对读取图片所花时间的影响有多大(因为这两个功能将竞争共享资源)。依赖于这种架构,这个影响会很大。即使上传和下载速度相同(多数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的架构,需要更新或搜索每个数据库分片(或者需要创建一个搜索服务来整理元数据—事实上它们也这么做了)。
对于这些系统的讨论并没有正确的答案,但回归到本章开头叙述的原则,确定系统的需求(频繁读或写或两者皆如此,并发级别,查询整个数据集,范围,排序,等等),基准测试不同的方案选择,理解系统如何会失效,以及准备一个可靠的计划以应对故障的发生是很有用的。
冗余(Redundancy)
为了优雅地处理故障,web架构必须具备冗余的服务和数据。例如,若某文件仅有一个拷贝存储在单个服务器上,那么失去该服务器即意味着失去了该文件。丢失数据很少是件好事,处理该问题的常见方法是创建多个或者说冗余的数据拷贝。
同样的原则也可应用于服务。如果应用程序的功能有个核心组件,那么确保同时运行多个拷贝或版本能够使系统免于单点故障。
在系统中创建冗余能够消除可能发生故障的单点,为了灾难恢复提供备份或备用的功能。例如,如果生产中运行着两个相同服务的实例,当其中一个发生故障或功能退化时,系统能够失效转移到健全副本。失效备援可以自动发生或者手动介入。
服务冗余的另一关键部分是创建一个无共享(shared-nothing)的架构。使用这种架构,每个节点的运维工作都能独立于其它节点,也没有中心“大脑”来管理状态或协调节点的行为。这有助于提高可扩展性,因为不需要特殊的条件或了解就能添加新的节点。然而,最重要的是这种系统不会有单点故障,因此对于故障更有弹性。
例如,在我们的图片服务器应用中,所有的图片都在另一处(理想情况是在不同的地理位置,从而能够应对地震或数据中心发生火灾一类的灾难)的硬件上存放着冗余的拷贝,提供图片访问的服务也是冗余的,均潜在地服务于请求(见图1.3)(负载均衡器是使其成为可能的一种绝佳方法,将在下文详述)。
图1.3:具备冗余的图片托管应用
分区(Partitions)
可能会存在非常大的数据集无法存放在单个服务器上。也可能某个操作需要非常多的计算资源,导致性能降低,需要增强计算能力。对于任一情形,你都有两种选择:纵向或横向扩展。
纵向扩展即对单个服务器添加更多的资源。因此对于一个非常庞大的数据集来说,这意味着增加更多(或更大的)硬盘,从而单个服务器能够容纳下整个数据集。对于计算操作而言,这意味着将计算迁移到具备更快速的CPU或更大的内存空间的服务器上。任一情况,都是使得单个服务器的资源能够自己解决对于更多资源的需求问题,实现纵向扩展。
另一方面,横向扩展则是添加更多的节点。针对大数据集的情况,这就是使用第二个服务器来存储数据集的一部分,对于计算资源而言,这意味着将操作或负载分割到额外的节点。为了充分利用横向扩展,应将其作为系统架构的一种本质的设计原则,否则为实现横向扩展而修改系统或分割上下文会相当麻烦。
说到横向扩展,一种更常见的技术是对服务进行分区,或分块。分区可以是分布式的,这样逻辑功能集之间是相互独立的;可以通过地理边界,或其他标准(如非付费用户 VS. 付费用户)来实现。这些方案的优势是可以提供更强的服务或数据存储能力。
在我们的图片服务器例子中,将曾经储存在单一的文件服务器的图片重新保存到多个文件服务器中是可以实现的,每个文件服务器都有自己惟一的图片集(见图1.4)。这种架构允许系统将图片保存到某个文件服务器中,在服务器都即将存满时,像增加硬盘一样增加额外的服务器。这种设计需要一种能够将文件名和存放服务器绑定的命名规则。一个图像的名称可能是映射全部服务器的完整散列方案的形式。或者可选的,每个图像都被分配给一个递增的 ID,当用户请求图像时,图像检索服务只需要保存映射到每个服务器的 ID 范围(类似索引)就可以了。
图1.4:使用冗余和分区实现的图片存储服务
当然,为多个服务器分配数据或功能是充满挑战的。一个关键的问题就是数据局部性;对于分布式系统,计算或操作的数据越相近,系统的性能越佳。因此,一个潜在的问题就是数据的存放遍布多个服务器,当需要一个数据时,它们并不在一起,迫使服务器不得不为从网络中获取数据而付出昂贵的性能代价。
另一个潜在的问题是不一致性。当多个不同的服务读取和写入同一共享资源时,有可能会遭遇竞争状态——某些数据应当被更新,但读取操作恰好发生在更新之前——这种情形下,数据就是不一致的。例如图像托管方案中可能出现的竞争状态,一个客户端发送请求,将其某标题为“狗"的图像改名为”小家伙“。而同时另一个客户端发送读取此图像的请求。第二个客户端中显示的标题是“狗”还是“小家伙”是不能明确的。
当然,对于分区还有一些障碍存在,但分区允许将问题——数据、负载、使用模式等——切割成可以管理的数据块。这将极大的提高可扩展性和可管理性,当然并非没有风险。有很多可以降低风险和处理故障的方法;不过篇幅有限,不再赘述。若有兴趣,可见于此文,获取更多容错和检测的信息。
1.3 构建高效和可伸缩的数据访问模块
在设计分布式系统时一些核心问题已经考虑到,现在让我们来讨论下比较困难的一部分:可伸缩的数据访问。
对于大多数简单的web应用程序,比如LAMP系统,类似于图1.5。
图1.5:简单web应用程序
随着它们的成长,主要发生了两方面的变化:应用服务器和数据库的扩展。在一个高度可伸缩的应用程序中,应用服务器通常最小化并且一般是shared-nothing架构(译注:shared nothing architecture是一 种分布式计算架构,这种架构中不存在集中存储的状态,整个系统中没有资源竞争,这种架构具有非常强的扩展性,在web应用中广泛使用)方式的体现,这使得系统的应用服务器层水平可伸缩。由于这种设计,数据库服务器可以支持更多的负载和服务;在这一层真正的扩展和性能改变开始发挥作用了。
剩下的章节主要集中于通过一些更常用的策略和方法提供快速的数据访问来使这些类型服务变得更加迅捷。
图1.6:最简单的web应用程序
多数系统简化为如图 Figure 1.6所示,这是一个良好的开始。如果你有大量的数据,你想快捷的访问,就像一堆糖果摆放在你办公室抽屉的最上方。虽然过于简化,前面的声明暗示了两个困难的问题:存储的可伸缩性和数据的快速访问。
为了这一节内容,我们假设你有很大的数据存储空间(TB),并且你想让用户随机访问一小部分数据(查看Figure 1.7)。这类似于在图像应用的例子里在文件服务器定位一个图片文件。
图1.7:访问特定数据
这非常具有挑战性,因为它需要把数TB的数据加载到内存中;并且直接转化为磁盘的IO。要知道从磁盘读取比从内存读取慢很多倍-内存的访问速度如同敏捷的查克·诺里斯(译注:空手道冠军),而磁盘的访问速度就像笨重的卡车一样。这个速度差异在大数据集上会增加更多;在实数顺序读取上内存访问速度至少是磁盘的6倍,随机读取速度比磁盘快100,000倍(参考“大数据之殇”http://queue.acm.org/detail.cfm?id=1563874)。另外,即使使用唯一的ID,解决获取少量数据存放位置的问题也是个艰巨的任务。这就如同不用眼睛看,在你的糖果存放点取出最后一块Jolly Rancher口味的糖果一样。
谢天谢地,有很多方式你可以让这样的操作更简单些;其中四个比较重要的是缓存,代理,索引和负载均衡。本章的剩余部分将讨论下如何使用每一个概念来使数据访问加快。
缓存
缓存利用局部访问原则:最近请求的数据可能会再次被请求。它们几乎被用于计算机的每一层:硬件,操作系统,web浏览器,web应用程序等等。缓存就像短期存储的内存:它有空间的限制,但是通常访问速度比源数据源快并且包含了大多数最近访问的条目。缓存可以在架构的各个层级存在,但是常常在前端比较常见,在这里通常需要在没有下游层级的负担下快速返回数据。
在我们的API例子中如何使用缓存来快速访问数据?在这种情况下,有两个地方你可以插入缓存。一个操作是在你的请求层节点添加一个缓存,如图 Figure 1.8。
图1.8:在请求层节点插入一个缓存
直接在一个请求层节点配置一个缓存可以在本地存储相应数据。每次发送一个请求到服务,如果数据存在节点会快速的返回本地缓存的数据。如果数据不在缓存中,请求节点将在磁盘查找数据。请求层节点缓存可以存放在内存和节点本地磁盘中(比网络存储快些)。
图1.9:多个缓存
当你扩展这些节点后会发生什么呢?如图Figure 1.9所示,如果请求层扩展为多个节点,每个主机仍然可能有自己的缓存。然而,如果你的负载均衡器随机分配请求到节点,同样的请求将指向不同的节点,从而增加了缓存的命中缺失率。有两种选择可以解决这个问题:全局缓存和分布式缓存。
全局缓存
全局缓存顾名思义:所有的节点使用同一个缓存空间,这涉及到添加一个服务器,或者某种文件存储系统,速度比访问源存储和通过所有节点访问要快些。每个请求节点以同样的方式查询本地的一个缓存,这种缓存方案可能有点复杂,因为在客户端和请求数量增加时它很容易被压倒,但是在有些架构里它还是很有用的(尤其是那些专门的硬件来使全局缓存变得非常快,或者是固定数据集需要被缓存的)。
在描述图中有两种常见形式的缓存。在图Figure 1.10中,当一个缓存响应没有在缓存中找到时,缓存自身从底层存储中查找出数据。在 Figure 1.11中,当在缓存中招不到数据时,请求节点会向底层去检索数据。
图1.10:全局缓存(缓存自己负责查找数据)
图1.11:全局缓存(请求节点负责查找数据)
大多数使用全局缓存的应用程序趋向于第一类,这类缓存可以管理数据的读取,防止客户端大量的请求同样的数据。然而,一些情况下,第二类实现方式似乎更有意义。比如,如果一个缓存被用于非常大的文件,一个低命中比的缓存将会导致缓冲区来填满未命中的缓存;在这种情况下,将使缓存中有一个大比例的总数据集。另一个例子是架构设计中文件在缓存中存储是静态的并且不会被排除。(这可能是因为应用程序要求周围数据的延迟---某些片段的数据可能需要在大数据集中非常快---在有些地方应用程序逻辑知道,排除策略或者热点会比缓存方案好使些)
分布式缓存
在分布式缓存(图1.12)中,每个节点都会缓存一部分数据。如果把冰箱看作食杂店的缓存的话,那么分布式缓存就象是把你的食物分别放到多个地方 —— 你的冰箱、柜橱以及便当盒 ——放到这些便于随时取用的地方就无需一趟趟跑去食杂店了。缓存一般使用一个具有一致性的哈希函数进行分割,如此便可在某请求节点寻找某数据时,能够迅速知道要到分布式缓存中的哪个地方去找它,以确定改数据是否从缓存中可得。在这种情况下,每个节点都有一个小型缓存,在直接到数据原始存放处找数据之前就可以向别的节点发出寻找数据的请求。由此可得,分布式缓存的一个优势就是,仅仅通过向请求池中添加新的节点便可以拥有更多的缓存空间。
分布式缓存的一个缺点是修复缺失的节点。一些分布式缓存系统通过在不同节点做多个备份绕过了这个问题;然而,你可以想象这个逻辑迅速变复杂了,尤其是当你在请求层添加或者删除节点时。即便是一个节点消失和部分缓存数据丢失了,我们还可以在源数据存储地址获取-因此这不一定是灾难性的!
图1.12:分布式缓存
缓存的伟大之处在于它们使我们的访问速度更快了(当然前提是正确使用),你选择的方法要在更多请求下更快才行。然而,所有这些缓存的代价是必须有额外的存储空间,通常在放在昂贵的内存中;从来没有嗟来之食。缓存让事情处理起来更快,而且在高负载情况下提供系统功能,否则将会使服务器出现降级。
有一个很流行的开源缓存项目Memcached (http://memcached.org/)(它可以当做一个本地缓存,也可以用作分布式缓存);当然,还有一些其他操作的支持(包括语言包和框架的一些特有设置)。
Memcached被用作很多大型的web站点,尽管他很强大,但也只是简单的内存key-value存储方式,它优化了任意数据存储和快速检索(o(1))。
Facebook使用了多种不同的缓存来提高他们站点的性能(查看"Facebook caching and performance")。在语言层面上(使用PHP内置函数调用)他们使用$GLOBALSand APC缓存,这有助于使中间函数调用和结果返回更快(大多数语言都有这样的类库用来提高web页面的性能)。Facebook使用的全局缓存分布在多个服务器上(查看 "Scaling memcached at Facebook"),这样一个访问缓存的函数调用可以使用很多并行的请求在不同的Memcached 服务器上获取存储的数据。这使得他们在为用户分配数据空间时有了更高的性能和吞吐量,同时有一个中央服务器做更新(这非常重要,因为当你运行上千服务器时,缓存失效和一致性将是一个大挑战)。
现在让我们讨论下当数据不在缓存中时该如何处理···
代理
简单来说,代理服务器是一种处于客户端和服务器中间的硬件或软件,它从客户端接收请求,并将它们转交给服务器。代理一般用于过滤请求、记录日志或对请求进行转换(增加/删除头部、加密/解密、压缩,等等)。
图1.13:代理服务器
当需要协调来自多个服务器的请求时,代理服务器也十分有用,它允许我们从整个系统的角度出发、对请求流量执行优化。压缩转发(collapsed forwarding)是利用代理加快访问的其中一种方法,将多个相同或相似的请求压缩在同一个请求中,然后将单个结果发送给各个客户端。
假设,有几个节点都希望请求同一份数据,而且它并不在缓存中。在这些请求经过代理时,代理可以通过压缩转发技术将它们合并成为一个请求,这样一来,数据只需要从磁盘上读取一次即可(见图1.14)。这种技术也有一些缺点,由于每个请求都会有一些时延,有些请求会由于等待与其它请求合并而有所延迟。不管怎么样,这种技术在高负载环境中是可以帮助提升性能的,特别是在同一份数据被反复访问的情况下。压缩转发有点类似缓存技术,只不过它并不对数据进行存储,而是充当客户端的代理人,对它们的请求进行某种程度的优化。
在一个LAN代理服务器中,客户端不需要通过自己的IP连接到Internet,而代理会将请求相同内容的请求合并起来。这里比较容易搞混,因为许多代理同时也充当缓存(这里也确实是一个很适合放缓存的地方),但缓存却不一定能当代理。
图1.14: 使用代理来合并请求
另一个使用代理的方式不只是合并相同数据的请求,同时也可以用来合并靠近存储源(一般是磁盘)的数据请求。采用这种策略可以让请求最大化使用本地数据,这样可以减少请求的数据延迟。比如,一群节点请求B的部分信息:partB1,partB2等,我们可以设置代理来识别各个请求的空间区域,然后把它们合并为一个请求并返回一个bigB,大大减少了读取的数据来源(查看图Figure 1.15)。当你随机访问上TB数据时这个请求时间上的差异就非常明显了!代理在高负载情况下,或者限制使用缓存时特别有用,因为它基本上可以批量的把多个请求合并为一个。
图1.15:使用代理来合并空间紧密的数据请求
值得注意的是,代理和缓存可以放到一起使用,但通常最好把缓存放到代理的前面,放到前面的原因和在参加者众多的马拉松比赛中最好让跑得较快的选手在队首起跑一样。因为缓存从内存中提取数据,速度飞快,它并不介意存在对同一结果的多个请求。但是如果缓存位于代理服务器的另一边,那么在每个请求到达cache之前都会增加一段额外的时延,这就会影响性能。
如果你正想在系统中添加代理,那你可以考虑的选项有很多;Squid和Varnish都经过了实践检验,广泛用于很多实际的web站点中。这些代理解决方案针对大部分client-server通信提供了大量的优化措施。将二者之中的某一个安装为web服务器层的反向代理(reverse proxy,下面负载均衡器一节中解释)可以大大提高web服务器的性能,减少处理来自客户端的请求所需的工作量。
索引
使用索引快速访问数据是个优化数据访问性能公认的策略;可能我们大多数人都是从数据库了解到的索引。索引用增长的存储空间占用和更慢的写(因为你必须写和更新索引)来换取更快的读取。
你可以把这个概念应用到大数据集中就像应用在传统的关系数据存储。索引要关注的技巧是你必须仔细考虑用户会怎样访问你的数据。如果数据集有很多TBs,但是每个数据包(payload)很小(可能只有1KB),这时就必须用索引来优化数据访问。在这么大的数据集找到小的数据包是个很有挑战性的工作,因为你不可能在合理的时间內遍历所有数据。甚至,更有可能的是这么大的数据集分布在几个(甚至很多个)物理设备上-这意味着你要用些方法找到期望数据的正确物理位置。索引是最适合的方法做这种事情。
图1.16:索引
索引可以作为内容的一个表格-表格的每一项指明你的数据存储的位置。例如,如果你正在查找B的第二部分数据-你如何知道去哪里找?如果你有个根据数据类型(数据A,B,C)排序的索引,索引会告诉你数据B的起点位置。然后你就可以跳转(seek)到那个位置,读取你想要的数据B的第二部分。 (参看Figure 1.16)
这些索引常常存储在内存中,或者存储在对于客户端请求来说非常快速的本地位置(somewhere very local)。Berkeley DBs (BDBs)和树状数据结构常常按顺序存储数据,非常理想用来存储索引。
常常索引有很多层,当作数据地图,把你从一个地方指向另外一个地方,一直到你的得到你想要的那块数据。(See Figure 1.17.)
图1.17:多层索引
索引也可以用来创建同样数据的多个不同视图(views)。对于大数据集来说,这是个很棒的方法来定义不同的过滤器(filter)和类别(sort),而不用创建多个额外的数据拷贝。
例如,想象一下,图片存储系统开始实际上存储的是书的每一页的图像,而且服务允许客户查询这些图片中的文字,搜索每个主题的所有书的内容,就像搜索引擎允许你搜索HTML内容一样。在这种情况下,所有的书的图片占用了很多很多服务器存储,查找其中的一页给用户显示有点难度。首先,用来查询任意词或者词数组(tuples)的倒排索引(inverse indexes)需要很容易的访问到;然后,导航到那本书的确切页面和位置并获取准确的图片作为返回结果,也有点挑战性。所以,这种境况下,倒排索引应该映射到每个位置(例如书B),然后B要包含一个索引每个部分所有单词,位置和出现次数的索引。
可以表示上图Index1的一个倒排索引,可能看起来像下面的样子-每个词或者词数组对应一个包含他们的书。
Word(s) | Book(s) |
---|---|
being awesome | Book B, Book C, Book D |
always | Book C, Book F |
believe | Book B |
这是大型系统的关键点,因为即使压缩,这些索引也太大,太昂贵(expensive)而难以存储。在这个系统,如果我们假设我们世界上的很多书--100,000,000(seeInside Google Books blog post)-每个书只有10页(只是为了下面好计算),每页有250个词,那就是2500亿(250 billion)个词。如果我们假设每个词有5个字符,每个字符占用8位(或者1个字节,即使某些字符要用2个字节),所以每个词占用5个字节,那么每个词即使只包含一次,这个索引也要占用超过1000GB存储空间。那么,你可以明白创建包含很多其他信息-词组,数据位置和出现次数-的索引,存储空间增长多快了吧。
创建这些中间索引和用更小分段表示数据,使的大数据问题可以得到解决。数据可以分散到多个服务器,访问仍然很快。索引是信息检索(information retrieval)的奠基石,是现代搜索引擎的基础。当然,我们这段只是浅显的介绍,还有其他很多深入研究没有涉及-例如如何使索引更快,更小,包含更多信息(例如关联(relevancy)),和无缝的更新(在竞争条件下(race conditions),有一些管理性难题;在海量添加或者修改数据的更新中,尤其还涉及到关联(relevancy)和得分(scoring),也有一些难题)。
快速简便的查找到数据是很重要的;索引是可以达到这个目的有效简单工具。
负载均衡器
最后还要讲讲所有分布式系统中另一个比较关键的部分,负载均衡器。负载均衡器是各种体系结构中一个不可或缺的部分,因为它们担负着将负载在处理服务请求的一组节点中进行分配的任务。这样就可以让系统中的多个节点透明地服务于同一个功能(参见图1.18)。它的主要目的就是要处理大量并发的连接并将这些连接分配给某个请求处理节点,从而可使系统具有伸缩性,仅仅通过添加新节点便能处理更多的请求。
图1.18:负载均衡器
用于处理这些请求的算法有很多种,包括随机选取节点、循环式选取,甚至可以按照内存或CPU的利用率等等这样特定的条件进行节点选取。负载均衡器可以用软件或硬件设备来实现。近来得到广泛应用的一个开源的软件负载均衡器叫做HAProxy。
在分布式系统中,负载均衡器往往处于系统的最前端,这样所有发来的请求才能进行相应的分发。在一些比较复杂的分布式系统中,将一个请求分发给多个负载均衡器也是常事,如图1.19所示。
图1.19:多重负载均衡器
负载均衡器面临的一个难题是怎么管理同用户的session相关的数据。在电子商务网站中,如果你只有一个客户端,那么很容易就可以把用户放入购物车里的东西保存起来,等他下次访问访问时购物车里仍能看到那些东西(这很重要,因为当用户回来发现仍然呆在购物车里的产品时很有可能就会买它)。然而,如果在一个session中将用户分发到了某个节点,但该用户下次访问时却分发到了另外一个节点,这里就有可能产生不一致性,因为新的节点可能就没有保留下用户购物车里的东西。(要是你把6盒子子农夫山泉放到购物车里了,可下次回来一看购物车空了,难道你不会发火吗?) 解决该问题的一个方法是使session具有粘性,让同一用户总是分发到同一个节点之上,但这样一来就很难利用类似失效备援(failover)这样的可靠性措施了。如果这样的话,用户的购物车里的东西不会丢,但如果用户保持的那个节点失效,就会出现一种特殊的情况,购物车里的东西不会丢这个假设再也不成立了(虽然但愿不要把这个假设写到程序里)。当然,这个问题还可以用本章中讲到的其它策略和工具来解决,比如服务以及许多并没有讲到的方法(像服务器缓存、cookie以及URL重写)。
如果系统中只有不太多的节点,循环式(round robin)DNS系统这样的方案也许更有意义,因为负载均衡器可能比较贵,而且还额外增加了一层没必要的复杂性。当然,在比较大的系统中会有各种各样的调度以及负载均衡算法,简单点的有随机选取或循环式选取,复杂点的可以考虑上利用率以及处理能力这些因素。所有这些算法都是对浏览和请求进行分发,并能提供很有用的可靠性工具,比如自动failover或者自动提出失效节点(比如节点失去响应)。然而,这些高级特性会让问题诊断难以进行。例如,当系统载荷较大时,负载均衡器可能会移除慢速或者超时的节点(由于节点要处理大量请求),但对其它节点而言,这么做实际上是加剧了情况的恶化程度。在这时进行大量的监测非常重要,因为系统总体流量和吞吐率可能看上去是在下降(因为节点处理的请求变少了),但个别节点却越来越忙得不可开交。
负载均衡器是一种能让你扩展系统能力的简单易行的方式,和本文中所讲的其它技术一样,它在分布式系统架构中起着基础性的作用。负载均衡器还要提供一个比较关键的功能,它必需能够探测出节点的运行状况,比如,如果一个节点失去响应或处于过载状态,负载均衡器可以将其从处理请求的节点池中移除出去,还接着使用系统中冗余的其它不同节点。
队列
目前为止我们已经介绍了许多更快读取数据的方法,但另一个使数据层具伸缩性的重要部分是对写的有效管理。当系统简单的时候,只有最小的处理负载和很小的数据库,写的有多快可以预知;然而,在更复杂的系统,写可能需要几乎无法决定的长久时间。例如,数据可能必须写到不同数据库或索引中的几个地方,或者系统可能正好处于高负载。这些情况下,写或者任何那一类任务,有可能需要很长的时间,追求性能和可用性需要在系统中创建异步;一个通常的做到那一点的办法是通过队列。
图1.20:同步请求
设想一个系统,每个客户端都在发起一个远程服务的任务请求。每一个客户端都向服务器发送它们的请求,服务器尽可能快的完成这些任务,并分别返回结果给各个客户端。在一个小型系统,一个服务器(或逻辑服务)可以给传入的客户端请求提供迅速服务,就像它们来的一样快,这种情形应该工作的很好。然而,当服务器收到了超过它所能处理数量的请求时,每个客户端在产生一个响应前,将被迫等待其他客户端的请求结束。这是一个同步请求的例子,示意在图1.20。
这种同步的行为会严重的降低客户端性能;客户端被迫等待,有效的执行零工作,直到它的请求被应答。添加额外的服务器承担系统负载也不会解决这个问题;即使是有效的负载均衡,为了最大化客户端性能,保证平等的公平的分发工作也是极其困难的。而且,如果服务器处理请求不可及,或者失败了,客户端上行也会失败。有效解决这个问题在于,需要在客户端请求与实际的提供服务的被执行工作之间建立抽象。
图1.21:用队列管理请求
进入队列。一个队列就像它听起来那么简单:一个任务进入,被加入队列然后工人们只要有能力去处理就会拿起下一个任务。(看图1.21)这些任务可能是代表了简单的写数据库,或者一些复杂的事情,像为一个文档生成一个缩略预览图一类的。当一个客户端提交一个任务请求到一个队列,它们再也不会被迫等待结果;它们只需要确认请求被正确的接收了。这个确认之后可能在客户端请求的时候,作为一个工作结果的参考。
队列使客户端能以异步的方式工作,提供了一个客户端请求与其响应的战略抽象。换句话说,在一个同步系统,没有请求与响应的区别,因此它们不能被单独的管理。在一个异步的系统,客户端请求一个任务,服务端响应一个任务已收到的确认,然后客户端可以周期性的检查任务的状态,一旦它结束就请求结果。当客户端等待一个异步的请求完成,它可以自由执行其它工作,甚至异步请求其它的服务。后者是队列与消息在分布式系统如何成为杠杆的例子(译注:用一个或多个、一层或多层的消息队列来撬动海量的并发连接)。
队列也对服务中断和失败提供了防护。例如,创建一个高度强健的队列,这个队列能够重新尝试由于瞬间服务器故障而失败的服务请求,是非常容易的事。相比直接暴露客户端于间歇性服务中断下--这需要复杂的而且经常不一致的客户端错误处理,用一个队列去加强服务质量的担保更为可取。
队列在管理任何大规模分布式系统不同部分之间的分布式通信方面是一个基础,而且实现它们有许多的方法。有不少开源的队列如RabbitMQ,ActiveMQ, BeanstalkD,但是有些也用像Zookeeper的服务,或者甚至像Redis的数据存储。
1.4 结论
设计有效的系统来进行快速的大数据访问是有趣的,同时有大量的好工具来帮助各种各样的应用程序进行设计。 这文章只覆盖了一些例子,仅仅是一些表面的东西,但将会越来越多--同时在这个领域里一定会继续有更多创新东西。