开源软件近年来已变为构建一些大型网站的基础组件。并且伴随着网站的成长,围绕着它们架构的最佳实践和指导准则已经显露。这篇文章旨在涉及一些在设计大型网站时需要考虑的关键问题和一些为达到这些目标所使用的组件。上篇文章介绍了Web分布式系统设计准则和基本原理,本文介绍构建快速、可伸缩数据访问的组件。
(上文)谈及了在设计分布式系统中需要考虑的一些核心问题,现在让我们来聊聊(比较)困难的部分:访问数据的可伸缩性。 大多数简单的web应用,例如LAMP栈应用,看上去如图1.5
图1.5:简单的web应用
随着它们的成长,会有两个主要的挑战:访问应用服务器和数据库的可伸缩性。在一个高可伸缩的应用设计中,应用(或者web)服务器通常会最小化(minimized)并通常表现为一个非共享(无状态)架构。这样使得系统的应用服务层能够很好地进行伸缩。这样数据的结果是,压力被向下推到了数据库服务器和相关(底层)支持服务;真正的伸缩和性能挑战就在这一层起到作用。 本章余下部分致力于(介绍)一些更加通用的策略和方法,通过更快的数据访问使得这些类型的服务更加快速和可伸缩。
图1.6:极简的web应用
大多数系统可以极度简化为像图1.6这样的。这是一个很好的开始。如果你有大量的数据且希望快速、简单地访问,就像你把糖果藏在你桌子第一个抽屉里。虽然被极度简化,前面观点仍暗示着两个难题:存储的可伸缩性和数据的快速访问。
为了本节,我们假设你有数以TB计的数据并且希望能让用户随机访问这些数据的一小部分。(见图1.7)这就类似于在图片应用例子里定位文件服务器上一个图片文件的位置。
图1.7:访问特定的数据
由于很难将TB级的数据加载到内存,所以这会使得事情变得非常有挑战性;这(种访问)将直接变为磁盘IO操作。从磁盘读取会比从内存要慢得多——访问内存就像Chuck Norris一样快,然而访问磁盘比DMV线还要慢。这样的速度差异对于大数据来说比较客观(This speed difference really adds up for large data sets);顺序读方面访问内存的速度是访问磁盘的6倍,而在随机读方面,前者是后者的十万倍(参见”The Pathologies of Big Data”, http://queue.acm.org/detail.cfm?id=1563874)。而且,即使有唯一ID,从哪里能够找到这样一小块数据仍然是一项艰巨的任务。这就好比从你藏糖果的地方不看一眼地想拿到最后一块Jolly Rancher。
幸运的是,你有很多能把事情变得更加容易的选择;其中重要的有如下4个:缓存、代理、索引、负载均衡。本节剩余部分将会讨论每个用于加速数据访问的概念。
缓存
缓存利用了本地引用原则的好处:最近访问的数据可能被再次访问。缓存几乎被用在计算机运行的各层:硬件,操作系统,web浏览器,web应用等等。缓存就像短期的内存:有着限定大小的空间,但通常比访问原始数据源更快,并且包含有最近最多被访问过的(数据)项。缓存可以存在于架构的各个层次,但会发现到经常更靠近前端(非web前端界面,架构上层),这样就可尽快返回数据而不用经过繁重的下层(处理)了。
在我们的API例子中,如何使用一个缓存来加速你的数据访问速度呢?在这个场景下,你可以在很多地方插入一个缓存。选择之一是在你的请求层节点中插入一个缓存,如图1.8.
图1.8:在你的请求层节点中插入缓存
将缓存直接放置在请求层节点中让本地存储响应数据变为可能。每次对于一个服务的请求,节点将立即返回存在的本地、缓存的数据。如果(对应的)缓存不存在,请求节点将会从磁盘中查询数据。请求层节点的缓存既可以放置在内存(更快)也可以在节点本地磁盘(比通过网络快)上。
图1.9:多个缓存
当你扩展到多个节点时,会发生什么呢?正如你看到的图1.9,如果请求曾扩展到多个节点,那么每个节点都可以拥有它自身的缓存。但是,如果你的负载均衡器将请求随机分发到这些节点上,同样的请求会到达不同的节点,就会提高缓存miss率。两种克服这种困难的方法是:全局缓存和分布式缓存。
全局缓存
正如听起来的一样,全局缓存是指:所有节点使用同一缓存空间。这包括增加一台服务器或是某种类型的文件存储,比从你原始存储地方(访问)更快,并且所有请求层的节点均可以访问(全局缓存)。所有请求节点统一像访问其本地缓存般访问(全局)缓存。这种类型的缓存机制可能会变得比较复杂,因为随着客户端和请求数量的增加,单个缓存(服务器)很容易被压垮,但是在一些架构中非常有效(特别是有专门定制的硬件使得访问全局缓存非常快速,或者需要缓存的数据集是固定的)。
通常有两种形式的全局缓存,如下图。图1.10中,如果缓存中找不到对应的响应,那缓存自身会去从下层存储中获取丢失的数据。在图1.11中,当缓存中找不到相应数据时,需要请求节点自己去获取数据。
图1.10:全局缓存自身负责存取
图1.11 全局缓存,请求节点负责存取
【译者注】第一种方式相当于是全局缓存将查询缓存、底层获取数据、填充缓存这些操作一并做掉,理想情况下对于上层应用应该只需要提供一个获取数据的API,上层应用无需关心所请求的数据是已存在于缓存中的还是从底层存储中获取的,能够更专注于上层业务逻辑,但这就可能需要这种全局缓存设计成能够根据传入API接口的参数去获取底层存储的数据,译者认为接口签名可以简化为Object getData(String uniqueId, DataRetrieveCallback callback),第一个参数代表与缓存约定的唯一标示一个数据的ID,第二个是一个获取数据回调接口,具体实现由调用该接口的业务端来实现,即当全局缓存中未找到uniqueId对应的缓存数据时,那就会以该callback去获取数据,并以uniqueId为key、callback获取数据为value放入全局缓存中。第二种方式相对来说自由一些。请求节点自行根据业务场景需求来决定查询数据的方式,以及查数据后的处理(比如缓存回收策略),全局缓存只作为一个基础组件让请求节点能够在其中存取数据。
大多数应用倾向于通过第一种方式使用全局缓存,由缓存自身来管理回收、获取数据,来应对从客户端发起的对同一数据的众多请求。但是,对于一些场景来说,第二种实现就比较有意义。比如,如果是用来缓存大型文件,那缓存低命中率将会导致缓存缓冲区被缓存miss给压垮;在这种情况下,缓存中缓存大部分数据集(或热门数据)将会有助解决这个问题。另一个例子是,一个架构中缓存的文件是静态、不应回收的。(这可能跟应用对于数据延迟的需求有关——对于大数据集来说,某些数据段需要被快速访问——这时应用的业务逻辑会比缓存更懂得回收策略或热点处理。)
分布式缓存
在一个分布式缓存中(如图1.12),没个节点拥有部分缓存的数据,如果将杂货店里的冰箱比作一个缓存,那么一个分布式缓存好比是将你的食物放在几个不同的地方——你的冰箱、食物柜、午餐饭盒里——非常便于取到快餐的地方而无需跑一趟商店。通常这类缓存使用一致性Hash算法进行切分,这样一个请求节点在查询指定数据时,可以很快知道去哪里查询,并通过分布式缓存来判断数据可用性。这种场景下,每个节点都会拥有一部分缓存,并且会将请求传递到其他节点来获取数据,最后才到原始地方查询数据。因此,分布式缓存的一个优势就是通过往请求池里增加节点来扩大缓存空间。
分布式缓存的一个缺点在于节点丢失纠正问题。一些分布式缓存通过将复制数据多份存放在不同的节点来解决这个问题;但是,你可以想象到这样做会让逻辑迅速变得复杂,特别是当你向请求层增加或减少节点的时候。虽然一个节点丢失并且缓存失效,但请求仍然可以从源头来获取(数据)——所以这不一定是最悲剧的。
图1.12 分布式缓存
缓存的伟大之处在于它们让事情进行的更快(当然需要执行正确)。你所选择的方法只是让你能够更快处理更多的请求。但是,这些缓存是以需要维护更多存储空间为代价的,特别是昂贵的内存方式;天下没有免费的午餐。缓存让事情变得更快,同时还保证了高负载条件下系统的功能,否则(系统)服务可能早已降级。
一个非常受欢迎的开源缓存叫做Memcached(http://memcached.org/)(既可以是本地又可以是分布式缓存);但是,还有很多其他选择(包括许多语言/框架特定选择)。Memcached被应用于许多大型web网站,纵然它功能强大,但它简单来说就是一个内存key-value存储,对任意数据存储和快速查找做了优化(时间复杂度O(1))。
Facebook使用了若干种不同类型的缓存以达到他们网站的性能(要求,参加see “Facebook caching and performance“)。他们在语言层面使用$GLOBALS和APC缓存(在PHP中提供的函数调用)使得中间功能调用和(得到)结果更加快速。(大多数语言都有这种类型的类库来提高web性能,应该经常去使用。)Facebook使用一种全局缓存,分布在多台服务器上(参见”Scaling memcached at Facebook“),这样一个访问缓存的函数调用就会产生很多并行请求来从Memcached服务器(集群)获取数据。这使得他们能够在用户概况数据上获得更高的性能和吞吐量,并且有一个集中的地方去更新数据(当你运行着数以千计的服务器时,缓存失效、管理一致性都将变得很有挑战,所以这是很重要的)。
现在让我们来聊聊当数据不存在于缓存的时候应该做什么。
代理
从基本层面来看,代理服务器是硬件/软件的一个中间层,用于接收从客户端发起的请求并传递到后端服务器。通常来说,代理是用来过滤请求、记录请求日志或者有时对请求进行转换(增加/去除头文件,加密/解密或者进行压缩)。
图1.13:代理服务器
代理同样能够极大帮助协调多个服务器的请求,有机会从系统的角度来优化请求流量。使用代理来加快数据访问速度的方式之一是将多个同种请求集中放到一个请求中,然后将单个结果返回到请求客户端。这就叫做压缩转发(原文叫做collapsed forwarding)。
假设在几个节点上存在对同样数据的请求(我们叫它littleB),并且这份数据不在缓存里。如果请求通过代理路由,那么这些请求可以被压缩为一个,就意味着我们只需要从磁盘读取一次littleB即可。(见图1.14)这种设计是会带来一定的开销,因为每个请求都会产生更高的延迟(跟不用代理相比),并且一些请求会因为要与相同请求合并而产生一些延迟。但这种做法在高负载的情况下提高系统性能,特别是当相同的数据重复被请求。这很像缓存,但不用像缓存那样存储数据/文件,而是优化了对那些文件的请求或调用,并且充当那些客户端的代理。
例如,在局域网(LAN)代理中,客户端不需有自己的IP来连接互联网,而局域网会将对同样内容的客户端请求进行压缩。这里可能很容易产生困惑,因为许多代理同样也是缓存(因为在这里放一个缓存很合理),但不是所有缓存都能充当代理。
图1.14:使用一个代理服务器来压缩请求
另一个使用代理的好方法是,不单把代理用来压缩对同样数据的请求,还可以用来压缩对那些在原始存储中空间上紧密联系的数据(磁盘连续块)的请求。使用这一策略最大化(利用)所请求数据的本地性,可以减少请求延迟。例如,我们假设一群节点请求B的部分(数据):B1, B2,等。我们可以对代理进行设置使其能够识别出不同请求的空间局部性,将它们压缩为单个请求并且只返回bigB,最小化对原始数据的读取操作。(见图1.15)当你随机访问TB级的数据时,这样会大幅改变(降低)请求时间。在高负载情况下或者当你只有有限的缓存,代理是非常有帮助的,因为代理可以从根本上将若干个请求合并为一个。
图1.15:使用代理压缩空间上邻近的数据请求
你完全可以一并使用代理和缓存,但通常最好将缓存放在代理之前使用,正如在马拉松赛跑中最好让跑得快的选手跑在前面。这是因为缓存通过内存来提供数据非常快速,并且它也不关心多个对同样结果的请求。但如果缓存被放在代理服务器的另一边(后面),那在每个请求访问缓存前就会有额外的延迟,这会阻碍系统性能。
如果你在寻找一款代理想要加入到你的系统中,那有很多选择可供考虑;Squid和Varnish都是经过路演并广泛应用于很多网站的生产环境中。这些代理方案做了很多优化来充分使用客户端与服务端的通信。安装其中之一并在web服务器层将其作为一个反向代理(将在下面的负载均衡小节解释)可以提高web服务器相当大的性能,降低处理来自客户端的请求所消耗的工作量。
索引
使用索引来加快访问数据已经是优化数据访问性能众所周知的策略;可能更多来自数据库。索引是以增加存储开销和减慢写入速度(因为你必须同时写入数据并更新索引)的代价来得到更快读取的好处。
就像对于传统的关系数据库,你同样可以将这种概念应用到大数据集上。索引的诀窍在于你必须仔细考虑你的用户会如何使用你的数据。对于TB级但单项数据比较小(比如1KB,原文这里写的是small payload)的数据集,索引是优化数据访问非常必要的方式。在一个大数据集中寻找一个小单元是非常困难的,因为你不可能在一个可接受的时间里遍历这么大的数据。并且,像这么一个大数据集很有可能是分布在几个(或更多)物理设备上——这就意味着你需要有方法能够找到所要数据正确的物理位置。索引是达到这个的最好方法。
图1.16:索引
索引可以像一张可以引导你至所要数据位置的表格来使用。例如,我们假设你在寻找B的part2数据——你将如何知道到哪去找到它?如果你有一个按照数据类型(如A,B,C)排序好的索引,它会告诉你数据B在哪里。然后你查找到位置,然后读取你所要的部分。(见图1.16) 这些索引通常存放在内存中,或者在更靠近客户端请求的地方。伯克利数据库(BDBs)和树形数据结构经常用来有序地存储数据,非常适合通过索引来访问。
索引经常会有很多层,类似一个map,将你从一个地方引导至另一个,以此类推,直到你获取到你所要的那份数据。(见图1.17)
图1.17:多层索引
索引也可以用来对同样的数据创建出一些不同的视图。对于大数据集来说,通过定义不同的过滤器和排序是一个很好的方式,而不需要创建很多额外数据拷贝。
例如,假设之前的图片托管系统就是在管理书页上的图片,并且服务能够允许客户端查询图片中的文字,按照标题搜索整本书的内容,就像搜索引擎允许你搜索HTML内容一样。这种场景下,所有书中的图片需要很多很多的服务器去存储文件,查找到其中一页渲染给用户将会是比较复杂的。首先,对需要易于查询的任意单词、词组进行倒排索引;然后挑战在于导航至那本书具体的页面、位置并获取到正确的图片。所以,在这一场景,倒排索引将会映射到一个位置(比如B书),然后B可能会包含每个部分的所有单词、位置、出现次数的索引。 倒排索引可能如同下图——每个单词或词组会提供一个哪些书包含它的索引。
这种中间索引看上去都类似,仅会包含单词、位置和B的一些信息。这种嵌套索引的架构允许每个索引占用更少的空间而非将所有的信息存放在一个巨大的倒排索引中。
在大型可伸缩的系统中,即使索引已被压缩但仍会变得很大,不易存储。在这个系统里,我们假设世界上有很多书——100,000,000本——并且每本书仅有10页(为了便于计算),每页有250个单词,这就意味着一共有2500亿个单词。如果我们假设平均每个单词有5个字符,每个字符占用8个比特,每个单词5个字节,那么对于仅包含每个单词的索引的大小就达到TB级。所以你会发现创建像一些如词组、数据位置、出现次数之类的其他信息的索引将会增长得更快。
创建这些中间索引并且以更小的方式表达数据,将大数据的问题变得易于处理。数据可以分布在多台服务器但仍可以快速访问。索引是信息获取的基石,也是当今现代搜索引擎的基础。当然,这一小节仅仅是揭开表面,为了把索引变得更小、更快、包含更多信息(比如关联)、无缝更新,还有大量的研究工作要做。(还有一些可管理性方面的挑战,比如竞争条件、增加或修改数据所带来的更新操作,特别是再加上关联、scoring)
能够快速、简单地找到你的数据非常重要;索引是达到这一目标非常有效、简单的工具。
负载均衡
另一个任何分布式系统的关键组件是负载均衡器。负载均衡器是任何架构的关键部分,用于将负载分摊在一些列负责服务请求的节点上。这使得一个系统的多个节点能够为相同功能提供服务。(见图1.18)它们主要目的是处理许多同时进行的连接并将这些连接路由到其中的一个请求节点上,使得系统能够可伸缩地通过增加节点来服务更多请求。
图1.18 负载均衡器
有很多不同的用于服务请求的算法,包括随机挑选一个节点、循环(round robin)或给予某些标准如内存/CPU使用率选取节点。一个广泛使用的开源软件级负载均衡器是HAProxy。
在一个分布式系统中,负责均衡器通常是放置在系统很前端的地方,这样就能路由所有进入(系统)的请求。在一个复杂的分布式系统中,一个请求被多个负载均衡器路由也不是不可能。(见图1.19)
图1.19:多重负责均衡器
如同代理一般,一些负载均衡器也能根据不同类型的请求进行路由。(从技术上来说,就是所谓的反向代理。)
负载均衡器的挑战之一在于(如何)管理用户session数据。在一个电子商务网站,当你只有一个客户端时很容易让用户把东西放到他们的购物车并且在不同的访问间保存(这是很重要的,因为当用户回来时很有可能买放在购物车里的产品)。但是,如果一个用户先被路由到一个session节点,然后在他们下次访问时路由到另一个不同的节点,那将会因为新节点可能丢失用户购物车里的东西而产生不一致。(如果你精心挑选了6包Mountain Dew放到购物车,但当你回来的时候发现购物车清空了,你会不会很沮丧?)解决办法之一通过粘性session机制总是将用户路由到同一节点,但这样既很难享受到一些像自动failover的可靠机制了。在这一场景下,用户的购物车总是会有东西的,如果他们所对应的粘性节点不可用了,那么就会是一个特殊情况对于(保存)在那里的东西的假设就无效了(当然我们希望这种假设不会出现在应用里)。当然,这个问题可以通过本章中的一些其他策略或者工具来解决,比如服务,还有一些没有提到的(如浏览器缓存、cookie、URL地址重写)。
【译者注】上段中提到的用户session问题,实际上在很多大型网站如淘宝、支付宝,都是通过一个分布式session的中间件来解决的。原理其实很简单,比如用户登录了支付宝,那么系统会给当前用户分配一个全局唯一的sessionId并写入到浏览器的cookie中,在后台服务端也会有专门的一个分布式存储以sessionId为key开辟一个空间存放该用户session数据。虽然应用都是集群部署方式,但每个无状态应用节点都会统一连接到该分布式存储。由于用户session数据是统一保存在分布式存储上,即对session数据的存取都是发生在同一个地方,而非各个节点内部,所以不会因为不同的请求路由到不同的应用节点上导致session数据不一致的情况。同时,这一方法不会像sticky session机制那样限制了系统的可伸缩性。如果出现session存取的性能问题,那只需通过扩展后端分布式存储即可解决。 如果系统只是由少数节点构成的,那么像Round Robin DNS那样的系统就更加明智,因为负责均衡器很贵而且增加了一层不必要的复杂度。当然在大型系统里有各种各样的调度和负载均衡算法,包括简单的像随机选择或循环方式,还有更加复杂的机制如考虑(系统)使用率和容量的。所有这些算法都分布化了流量和请求,并且提供像自动failover或者自动去除坏节点(当该节点失去响应后)这类对可靠性非常有帮助的工具。但是,这些先进特性也会使得问题诊断变得复杂化。比如,在一个高负载情况下,负载均衡器会去除掉那些变慢或者超时(由于请求过多)的节点,但这样反而加重了其他节点的(恶劣)处境。在这些情况下,全面监控变得很重要,因为从全局来看系统的流量和吞吐量正在下降(由于各节点服务请求越来越少),但从节点个体来看正在达到极限。
负载均衡器是一个非常简单能让你提高系统容量的方法,并且像本文其他的技术一样,在分布式系统架构中扮演者重要角色。负载均衡器还能用来判断一个节点的健康度,这样当一个节点失去响应或者过载时,得益于系统不同节点的冗余性,可以将其从请求处理池中去除。 队列
至此,我们已经覆盖了很多用于加快数据读取的方法,另一个扩展数据层的重要部分是有效管理写入操作。当系统比较简单,系统处理负载很低,数据库也很小,可以预见写入操作是很快的;但是,在更加复杂的系统中,写入操作的时间可能无法确定。例如,数据需要被写入到不同服务器或索引的多个地方,或者系统负载很高。这些情况下,由于上面的原因,写操作或者任何任务都会花费很长的时间,这时需要异步化系统才能提高系统的性能和可靠性;通常的方法之一是使用队列。
图1.20:同步化请求
假设在一个系统中,每个客户端在请求远程服务来处理任务。每个客户端将其请求送至服务器,服务器尽可能快地完成这些任务并返回结果给相应的客户端。在小型系统中,当一台服务器(或者逻辑上的一个服务)可以尽快地服务到来的客户端(请求),这种情况下(系统)工作会比较好。但是,当服务器接收到超过其处理能力的请求时,那每个客户端都只能被迫等待其他客户端请求完成才能得到响应。图1.20描绘的就是一个同步请求的例子。
这种同步的方式将会严重降低客户端性能;客户端被强制等待,在请求被响应前什么都做不了。增加额外的服务器并不能解决这个问题;即使通过有效的负载均衡,依然难以保证最大化客户端性能所需做的公平分配的工作。更进一步来说,当处理请求的服务器不可用或挂掉了,那么上游的客户端同样也会失败。有效解决这个问题需要抽象化客户端的请求和真正服务它所做的工作。
图1.21:使用队列来管理请求
现在进入队列环节。一个队列,正如听上去的,简单来说就是当一个任务过来时,会被加入到队列中,然后会有当前有能力处理(任务)的worker去取下一个任务来做。(见图1.21。)这些任务可以是对数据库的写入操作,或是复杂一些的如生成文件的小型预览图。当一个客户端将任务的请求提交到队列后,它们不再需要被迫等待结果;取而代之的是,它们只需要确认请求被得到正确接收。当客户端需要的时候,这个确认此后可以当做是任务结果的引用。
队列使得客户端能够以异步的方式进行工作,至关重要地抽象了一个客户端请求及其响应。另一方面,一个同步化系统不会区分请求和响应,因此就无法分开管理。在一个异步化系统里,客户端提交任务请求,后端服务反馈一个收到任务的确认信息,并且客户端可以定期地查看任务的状态,一旦完成即可取得任务结果。在客户端等待一个异步请求完成时,它可以自由地处理其他的工作,即使是发起对其他服务的异步请求。上面第二个就是分布式系统中采用队列和消息的例子。
队列还能提供对服务断供/失败的保护措施。比如,很容易创建一个健壮的队列来重试那些由于服务器短暂失败的服务请求。更好的是通过使用队列来确保服务品质,而非将客户端直接面对断断续续的服务,因为那样会需要客户端复杂且经常不一致的错误处理。
队列是管理大型可伸缩分布式应用不同部分间通信的基础,可以通过很多方式来实现。有一些开源的队列如RabbitMQ, ActiveMQ, BeanstalkD,也有一些使用像Zookeeper的服务,还有像Redis那样的数据存储。
【译者注】队列是分布式系统异步化的一个关键基础组件。在淘宝、支付宝这类大型分布式网站中应用广泛。正如大家所知的双十一、双十二,这两天用户的请求可谓超级海量。拿支付宝来说,核心系统如支付、账务,即使使用了很多技术方案来确保高性能、高可用,但面对数倍、数十倍于平时的请求量,依然捉急。在开发了一套分布式队列基础中间件后,网站的吞吐量、可用性得到了很大的提高。同时,对于队列来说,除了将客户端请求与服务端处理分离外,通过对队列加上额外的一些特性,能够起到非常大的作用。比如,在队列上加入限流特性,当请求量大大超过后端服务处理能力时,可以采取丢弃请求的方式来保证系统、队列不至于被海量请求压垮;当请求量回到一定水平,再将限流放开。这种做法,正好满足了系统对可用性、性能、可伸缩性、可管理性的要求。
总结
设计出能够快速访问大量数据的高效系统(的方法)是存在的,并且又很多非常棒的工具来帮助各种各样的新应用来达到这一点。本章只覆盖了少量例子,仅仅是掀开了面纱,但其实还有更多,并将继续保持创新。