FaceBookHaystack初步探究

/ 架构 / 0 条评论 / 260 浏览

介绍

Haystack是Facebook上分享照片儿设计的对象存储技术。在这个应用场景中,每个数据只会写入一次,读操作频繁,从不修改,很少删除。在Facebook遭遇的负荷下,传统的文件系统性能很差,优化定制Haystack是符合需求的一个理论。

传统的POSIX的文件系统最大的缺点主要是目录和文件的元数据。对于图片这种小文件来说,很多元数据,比如文件权限,文件类型,修改时间,都是无用而且浪费存储容量的。更大的消耗是文件元数据必须从磁盘读取到内存中定位文件。文件存储规模较小的时候,这些消耗不是特别重要,当面对整个磁盘都是这种小文件时,这种消耗,就很致命了。通常读取一个文件时,系统操作分为好几个操作:

简单来说,读取一个文件要三次磁盘定位。

Haystack达到了4个目标:

背景

普通的文件存储

我们先来看一个概览图,它描述了通常的设计方案,web服务器、CDN和存储系统如何交互协作,来实现一个热门站点的图片服务。图1描述了从用户访问包含某个图片的页面开始,直到她最终从磁盘的特定位置下载此图片结束的全过程。访问一个页面时,用户的浏览器首先发送HTTP请求到一个web服务器,它负责生成markup以供浏览器渲染。对每张图片,web服务器为其构造一个URL,引导浏览器在此位置下载图片数据。对于热门站点,这个URL通常指向一个CDN。如果CDN缓存了此图片,那么它会立刻将数据回复给浏览器。否则,CDN检查URL,URL中需要嵌入足够的信息以供CDN从本站点的存储系统中检索图片。拿到图片后,CDN更新它的缓存数据、将图片发送回用户的浏览器.

基于NFS的设计

在我们最初的设计中,我们使用了一个基于NFS的方案。我们吸取的主要教训是,对于一个热门的社交网络站点,只有CDN不足以为图片服务提供一个实用的解决方案。对于热门图片,CDN确实很高效——比如个人信息图片和最近上传的照片——但是一个像Facebook的社交网络站点,会产生大量的对不热门(较老)内容的请求,我们称之为long tail(长尾理论中的名词)。long tail的请求也占据了很大流量,它们都需要访问更下游的图片存储主机,因为这些请求在CDN缓存里基本上都会命中失败。缓存所有的图片是可以解决此问题,但这么做代价太大,需要极大容量的缓存。

基于NFS的设计中,图片文件存储在一组商用NAS设备上,NAS设备的卷被mount到Photo Store Server的NFS上。图2展示了这个架构。Photo Store Server解析URL得出卷和完整的文件路径,在NFS上读取数据,然后返回结果到CDN。

我们最初在NFS卷的每个目录下存储几千个文件,导致读取文件时产生了过多的磁盘操作,哪怕只是读单个图片。由于NAS设备管理目录元数据的机制,放置几千个文件在一个目录是极其低效的,因为目录的blockmap太大不能被设备有效的缓存。因此检索单个图片都可能需要超过10个磁盘操作。在减少到每个目录下几百个图片后,系统仍然大概需要3个磁盘操作来获取一个图片:一个读取目录元数据到内存、第二个装载inode到内存、最后读取文件内容。

为了继续减少磁盘操作,我们让图片存储服务器明确的缓存NAS设备返回的文件“句柄”。第一次读取一个文件时,图片存储服务器正常打开一个文件,将文件名与文件“句柄”的映射缓存到memcache中。同时,我们在os内核中添加了一个通过句柄打开文件的接口,当查询被缓存的文件时,图片存储服务器直接用此接口和“句柄”参数打开文件。遗憾的是,文件“句柄”缓存改进不大,因为越冷门的图片越难被缓存到(没有解决long tail问题)。值得讨论的是可以将所有文件“句柄”缓存到memcache,不过这也需要NAS设备能缓存所有的inode信息,这么做是非常昂贵的。总结一下,我们从NAS方案吸取的主要教训是,仅针对缓存——不管是NAS设备缓存还是额外的像memcache缓存——对减少磁盘操作的改进是有限的。存储系统终究是要处理long tail请求(不热门图片)。

讨论

我们很难提出一个指导方针关于何时应该构建一个自定义的存储系统。下面是我们在最终决定搭建Haystack之前的一些思考,希望能给大家提供参考。

面对基于NFS设计的瓶颈,我们探讨了是否可以构建一个类似GFS的系统。而我们大部分用户数据都存储在Mysql数据库,文件存储主要用于开发工作、日志数据以及图片。NAS设备其实为这些场景提供了性价比很好的方案。此外,我们补充了hadoop以供海量日志数据处理。面对图片服务的long tail问题,Mysql、NAS、Hadoop都不太合适。

我们面临的困境可简称为“已存在存储系统缺乏合适的RAM-to-disk比率”。然而,没有什么比率是绝对正确的。系统需要足够的内存才能缓存所有的文件系统元数据。在我们基于NAS的方案中,一个图片对应到一个文件,每个文件需要至少一个inode,这已经占了几百byte。提供足够的内存太昂贵。所以我们决定构建一个定制存储系统,减少每个图片的元数据总量,以便能有足够的内存。相对购买更多的NAS设备,这是更加可行的、性价比更好的方案。

设计和实现

Facebook使用CDN来支撑热门图片查询,结合Haystack则解决了它的long tail问题。如果web站点在查询静态内容时遇到I/O瓶颈,传统方案就是使用CDN,它为下游的存储系统挡住了绝大部分的查询请求。在Facebook,为了传统的、廉价的的底层存储不受I/O摆布,CDN往往需要缓存难以置信的海量静态内容。

上面已经论述过,在不久的将来,CDN也不能完全的解决我们的问题,所以我们设计了Haystack来解决这个严重瓶颈:磁盘操作。我们接受long tail请求必然导致磁盘操作的现实,但是会尽量减少除了访问真实图片数据之外的其他操作。Haystack有效的减少了文件系统元数据的空间,并在内存中保存所有元数据。

每个图片存储为一个文件将会导致元数据太多,难以被全部缓存。Haystack的对策是:将多个图片存储在单个文件中,控制文件个数,维护大型文件,我们将论述此方案是非常有效的。另外,我们强调了它设计的简洁性,以促进快速的实现和部署。我们将以此核心技术展开,结合它周边的所有架构组件,描述Haystack是如何实现了一个高可靠、高可用的存储系统。在下面对Haystack的介绍中,需要区分两种元数据,不要混淆。一种是应用元数据,它是用来为浏览器构造检索图片所需的URL;另一种是文件系统元数据,用于在磁盘上检索文件。

概览

Haystack架构包含3个核心组件:Haytack Store、Haystack Directory和Haystack Cache(简单起见我们下面就不带Haystack前缀了)。Store是持久化存储系统,并负责管理图片的文件系统元数据。Store将数据存储在物理的卷上。比如,在一台机器上提供100个物理卷,每个提供100GB的存储容量,整台机器则可以支撑10TB的存储。更进一步,不同机器上的多个物理卷将对应一个逻辑卷。Haystack将一个图片存储到一个逻辑卷时,图片被写入到所有对应的物理卷。这个冗余可避免由于硬盘故障,磁盘控制器bug等导致的数据丢失。Directory维护了逻辑到物理卷的映射以及其他应用元数据,比如某个图片寄存在哪个逻辑卷、某个逻辑卷的空闲空间等。Cache的功能类似我们系统内部的CDN,它帮Store挡住热门图片的请求(可以缓存的就绝不交给下游的持久化存储)。在独立设计Haystack时,我们要设想它处于一个没有CDN的大环境中,即使有CDN也要预防其节点故障导致大量请求直接进入存储系统,所以Cache是十分必要的。

上图说明了Store、Directory、Cache是如何协作的,以及如何与外部的浏览器、web服务器、CDN和存储系统交互。在Haystack架构中,浏览器会被引导至CDN或者Cache上。需要注意的是Cache本质上也是一个CDN,为了避免困惑,我们使用“CDN”表示外部的系统、使用“Cache”表示我们内部的系统。有一个内部的缓存设施能减少对外部CDN的依赖。

当用户访问一个页面,web服务器使用Directory为每个图片来构建一个URL(Directory中有足够的应用元数据来构造URL)。URL包含几块信息,每一块内容可以对应到从浏览器访问CDN(或者Cache)直至最终在一台Store机器上检索到图片的各个步骤。一个典型的URL如下:

http://///<Logical volume, Photo>

第一个部分指明了从哪个CDN查询此图片。到CDN后它使用最后部分的URL(逻辑卷和图片ID)即可查找缓存的图片。如果CDN未命中缓存,它从URL中删除相关信息,然后访问Cache。Cache的查找过程与之类似,如果还没命中,则去掉相关信息,请求被发至指定的Store机器()。如果请求不经过CDN直接发至Cache,其过程与上述类似,只是少了CDN这个环节。

上图说明了在Haystack中的上传流程。用户上传一个图片时,她首先发送数据到web服务器。web服务器随后从Directory中请求一个可写逻辑卷。最后,web服务器为图片分配一个唯一的ID,然后将其上传至逻辑卷对应的每个物理卷。

Haystack Directory

Directory提供4个主要功能。首先,它提供一个从逻辑卷到物理卷的映射。web服务器上传图片和构建图片URL时都需要使用这个映射。第二,Directory在分配写请求到逻辑卷、分配读请求到物理卷时需保证负载均衡。第三,Directory决定一个图片请求应该被发至CDN还是Cache,这个功能可以让我们动态调整是否依赖CDN。第四,Directory指明那些逻辑卷是只读的(只读限制可能是源自运维原因、或者达到存储容量上限;为了运维方便,我们以机器粒度来标记卷的只读)。

当我们增加新机器以增大Store的容量时,那些新机器是可写的;仅仅可写的机器会收到upload请求。随时间流逝这些机器的可用容量会不断减少。当一个机器达到容量上限,我们标记它为只读,在下一个子章节我们将讨论如何这个特性如何影响Cache和Store。

Directory将应用元数据存储在一个冗余复制的数据库,通过一个PHP接口访问,也可以换成memcache以减少延迟。当一个Store机器故障、数据丢失时,Directory在应用元数据中删除对应的项,新Store机器上线后则接替此项。

【译者YY】3.2章节是整篇文章中唯一一处译者认为没有解释清楚的环节。结合3.1章节中的URL结构解析部分,读者可以发现Directory需要拿到图片的“原始URL”(页面html中link的URL),再结合应用元数据,就可以构造出“引导URL”以供下游使用。从3.2中我们知道Directory必然保存了逻辑卷到物理卷的映射,仅用此映射+原始URL足够发掘其他应用元数据吗?原始URL中到底包含了什么信息(论文中没看到介绍)?我们可以做个假设,假如原始URL中仅仅包含图片ID,那Directory如何得知它对应哪个逻辑卷(必须先完成这一步映射,才能继续挖掘更多应用元数据)?Directory是否在upload阶段将图片ID与逻辑卷的映射也保存了?如果是,那这个映射的数据量不能忽略不计,论文也不该一笔带过。

从原文一些细枝末节的描述中,译者认为Directory确实保存了很多与图片ID相关的元数据(存储在哪个逻辑卷、cookie等)。但整篇论文译者也没找到对策,总感觉这样性价比太低,不符合Haystack的作风。对于这个疑惑,文章末尾扩展阅读部分将尝试解答。读者先认为其具备此能力吧。

Haystack Cache

Cache会从CDN或者直接从用户浏览器接收到图片查询请求。Cache的实现可理解为一个分布式Hash Table,使用图片ID作为key来定位缓存的数据。如果Cache未命中,Cache则根据URL从指定Store机器上获取图片,视情况回复给CDN或者用户浏览器。

我们现在强调一下Cache的一个重要行为概念。只有当符合两种条件之一时它才会缓存图片:(a)请求直接来自用户浏览器而不是CDN;(b)图片获取自一个可写的Store机器。第一个条件的理由是一个请求如果在CDN中没命中(非热门图片),那在我们内部缓存也不太需要命中(即使此图片开始逐渐活跃,那也能在CDN中命中缓存,这里无需多此一举;直接的浏览器请求说明是不经过CDN的,那就需要Cache代为CDN,为其缓存)。第二个条件的理由是间接的,有点经验论,主要是为了保护可写Store机器;原因挺有意思,大部分图片在上传之后很快会被频繁访问(比如某个美女新上传了一张自拍),而且文件系统在只有读或者只有写的情况下执行的更好,不太喜欢同时并发读写(章节4.1)。如果没有Cache,可写Store机器往往会遭遇频繁的读请求。因此,我们甚至会主动的推送最近上传的图片到Cache。

Haystack Store

Store机器的接口设计的很简约。读操作只需提供一些很明确的元数据信息,包括图片ID、哪个逻辑卷、哪台物理Store机器等。机器如果找到图片则返回其真实数据,否则返回错误信息。

每个Store机器管理多个物理卷。每个物理卷存有百万张图片。读者可以将一个物理卷想象为一个非常大的文件(100GB),保存为’/hay/haystack’。Store机器仅需要逻辑卷ID和文件offset就能非常快的访问一个图片。这是Haystack设计的主旨:不需要磁盘操作就可以检索文件名、偏移量、文件大小等元数据。Store机器会将其下所有物理卷的文件描述符(open的文件“句柄”,卷的数量不多,数据量不大)缓存在内存中。同时,图片ID到文件系统元数据(文件、偏移量、大小等)的映射(后文简称为“内存中映射”)是检索图片的重要条件,也会全部缓存在内存中。

现在我们描述一下物理卷和内存中映射的结构。一个物理卷可以理解为一个大型文件,其中包含一系列的needle。每个needle就是一张图片。图5说明了卷文件和每个needle的格式。Table1描述了needle中的字段。

为了快速的检索needle,Store机器需要为每个卷维护一个内存中的key-value映射。映射的Key就是(needle.key+needle.alternate_key)的组合,映射的Value就是needle的flag、size、卷offset(都以byte为单位)。如果Store机器崩溃、重启,它可以直接分析卷文件来重新构建这个映射(构建完成之前不处理请求)。下面我们介绍Store机器如何响应读写和删除请求(Store仅支持这些操作)。

【译者注】从Table1我们看到needle.key就是图片ID,为何仅用图片ID做内存中映射的Key还不够,还需要一个alternate_key?这是因为一张照片会有4份副本,它们的图片ID相同,只是类型不同(比如大图、小图、缩略图等),于是将图片ID作为needle.key,将类型作为needle.alternate_key。根据译者的理解,内存中映射不是一个简单的HashMap结构,而是类似一个两层的嵌套HashMap,Map<long/needle.key/,Map<int/alternate_key/,Object>>。这样做可以让4个副本共用同一个needle.key,避免为重复的内容浪费内存空间。

读取图片

Cache机器向Store机器请求一个图片时,它需要提供逻辑卷id、key、alternate key,和cookie。cookie是个数字,嵌在URL中。当一张新图片被上传,Directory为其随机分配一个cookie值,并作为应用元数据之一存储在Directory。它就相当于一张图片的“私人密码”,此密码可以保证所有发往Cache或CDN的请求都是经过Directory“批准”的(Cache和Store都持有图片的cookie,若用户自己在浏览器中伪造、猜测URL或发起攻击,则会因为cookie不匹配而失败,从而保证Cache、Store能放心处理合法的图片请求)。

当Store机器接收到Cache机器发来的图片查询请求,它会利用内存中映射快速的查找相关的元数据。如果图片没有被删除,Store则在卷文件中seek到相应的offset,从磁盘上读取整个needle(needle的size可以提前计算出来),然后检查cookie和数据完整性,若全部合法则将图片数据返回到Cache机器。

写入图片

上传一个图片到Haystack时,web服务器向Directory咨询得到一个可写逻辑卷及其对应的多台Store机器,随后直接访问这些Store机器,向其提供逻辑卷id、key、alternate key、cookie和真实数据。每个Store机器为图片创建一个新needle,append到相应的物理卷文件,更新内存中映射。过程很简单,但是append-only策略不能很好的支持修改性的操作,比如旋转(图片顺时针旋转90度之类的)。Haystack并不允许覆盖needle,所以图片的修改只能通过添加一个新needle,其拥有相同的key和alternate key。如果新needle被写入到与老needle不同的逻辑卷,则只需要Directory更新它的应用元数据,未来的请求都路由到新逻辑卷,不会获取老版本的数据。如果新needle写入到相同的逻辑卷,Store机器也只是将其append到相同的物理卷中。Haystack利用一个十分简单的手段来区分重复的needle,那就是判断它们的offset(新版本的needle肯定是offset最高的那个),在构造或更新内存中映射时如果遇到相同的needle,则用offset高的覆盖低的。

图片删除

在删除图片时,Store机器将内存中映射和卷文件中相应的flag同步的设置为已删除(软删除机制,此刻不会删除needle的磁盘数据)。当接收到已删除图片的查询请求,Store会检查内存中flag并返回错误信息。值得注意的是,已删除needle依然占用的空间是个问题,我们稍后将讨论如何通过压缩技术来回收已删除needle的空间。

索引文件

Store机器使用一个重要的优化——索引文件——来帮助重启初始化。尽管理论上一个机器能通过读取所有的物理卷来重新构建它的内存中映射,但大量数据(TB级别)需要从磁盘读取,非常耗时。索引文件允许Store机器快速的构建内存中映射,减少重启时间。

Store机器为每个卷维护一个索引文件。索引文件可以想象为内存中映射的一个“存档”。索引文件的布局和卷文件类似,一个超级块包含了一系列索引记录,每个记录对应到各个needle。索引文件中的记录与卷文件中对应的needle必须保证相同的存储顺序。图6描述了索引文件的布局,Table2解释了记录中的不同的字段。

使用索引帮助重启稍微增加了系统复杂度,因为索引文件都是异步更新的,这意味着当前索引文件中的“存档”可能不是最新的。当我们写入一个新图片时,Store机器同步append一个needle到卷文件末尾,并异步append一个记录到索引文件。当我们删除图片时,Store机器在对应needle上同步设置flag,而不会更新索引文件。这些设计决策是为了让写和删除操作更快返回,避免附加的同步磁盘写。但是也导致了两方面的影响:一个needle可能没有对应的索引记录、索引记录中无法得知图片已删除。

我们将对应不到任何索引记录的needle称为“孤儿”。在重启时,Store机器顺序的检查每个孤儿,重新创建匹配的索引记录,append到索引文件。我们能快速的识别孤儿是因为索引文件中最后的记录能对应到卷文件中最后的非孤儿needle。处理完孤儿问题,Store机器则开始使用索引文件初始化它的内存中映射。

由于索引记录中无法得知图片已删除,Store机器可能去检索一个实际上已经被删除的图片。为了解决这个问题,可以在Store机器读取整个needle后检查其flag,若标记为已删除,则更新内存中映射的flag,并回复Cache此对象未找到。

文件系统

Haystack可以理解为基于通用的类Unix文件系统搭建的对象存储,但是某些特殊文件系统能更好的适应Haystack。比如,Store机器的文件系统应该不需要太多内存就能够在一个大型文件上快速的执行随机seek。当前我们所有的Store机器都在使用的文件系统是XFS,一个基于“范围(extent)”的文件系统。XFS有两个优势:首先,XFS中邻近的大型文件的”blockmap”很小,可放心使用内存存储;第二,XFS提供高效文件预分配,减轻磁盘碎片等问题。

使用XFS,Haystack可以在读取一张图片时完全避免检索文件系统元数据导致的磁盘操作。但是这并不意味着Haystack能保证读取单张图片绝对只需要一个磁盘操作。在一些极端情况下会发生额外的磁盘操作,比如当图片数据跨越XFS的“范围(extent)”或者RAID边界时。不过Haystack会预分配1GB的“范围(extent)”、设置RAID stripe大小为256KB,所以实际上我们很少遭遇这些极端场景。

故障恢复

对于运行在普通硬件上的大规模系统,容忍各种类型的故障是必须的,包括硬盘驱动故障、RAID控制器错误、主板错误等,Haystack也不例外。我们的对策由两个部分组成——一个为侦测、一个为修复。

为了主动找到有问题的Store机器,我们维护了一个后台任务,称之为pitchfork,它周期性的检查每个Store机器的健康度。pitchfork远程的测试到每台Store机器的连接,检查其每个卷文件的可用性,并尝试读取数据。如果pitchfork确定某台Store机器没通过这些健康检查,它会自动标记此台机器涉及的所有逻辑卷为只读。我们的工程师将在线下人工的检查根本故障原因。

一旦确诊,我们就能快速的解决问题。不过在少数情况下,需要执行一个更加严厉的bulk同步操作,此操作需要使用复制品中的卷文件重置某个Store机器的所有数据。Bulk同步发生的几率很小(每个月几次),而且过程比较简单,只是执行很慢。主要的瓶颈在于bulk同步的数据量经常会远远超过单台Store机器NIC速度,导致好几个小时才能恢复。我们正积极解决这个问题。

优化

Haystack的成功还归功于几个非常重要的细节优化。

压缩

压缩操作是直接在线执行的,它能回收已删除的、重复的needle所占据的空间。Store机器压缩卷文件的方式是,逐个复制needle到一个新的卷文件,并跳过任何重复项、已删除项。在压缩时如果接收到删除操作,两个卷文件都需处理。一旦复制过程执行到卷文件末尾,所有对此卷的修改操作将被阻塞,新卷文件和新内存中映射将对前任执行原子替换,随后恢复正常工作。

节省更多内存

上面描述过,Store机器会在内存中映射中维护一个flag,但是目前它只会用来标记一个needle是否已删除,有点浪费。所以我们通过设置偏移量为0来表示图片已删除,物理上消除了这个flag。另外,映射Value中不包含cookie,当needle从磁盘读出之后Store才会进行cookie检查。通过这两个技术减少了20%的内存占用。

当前,Haystack平均为每个图片使用10byte的内存。每个上传的图片对应4张副本,它们共用同一个key(占64bits),alternate keys不同(占32bits),size不同(占16bits),目前占用(64+(32+16)*4)/8=32个bytes。另外,对于每个副本,Haystack在用hash table等结构时需要消耗额外的2个bytes,最终总量为一张图片的4份副本共占用40bytes。作为对比,一个xfs_inode_t结构在Linux中需占用536bytes。

批量上传

磁盘在执行大型的、连续的写时性能要优于大量小型的随机写,所以我们尽量将相关写操作捆绑批量执行。幸运的是,很多用户都会上传整个相册到Facebook,而不是频繁上传单个图片。因此只需做一些巧妙的安排就可以捆绑批量upload,实现大型、连续的写操作。。