The Google File System中文版
摘要
分类和主题描述
常用术语
关键词
1. 简介
2.设计概述
2.1设计预期
在设计满足我们需求的文件系统时候,我们的设计目标既有机会、又有挑战。之前我们已经提到了一些需要关注的关键点,这里我们将设计的预期目标的细节展开讨论。
- 系统由许多廉价的普通组件组成,组件失效是一种常态。系统必须持续监控自身的状态,它必须将组件失效作为一种常态,能够迅速地侦测、冗余并恢复失效的组件。
- 系统存储一定数量的大文件。我们预期会有几百万文件,文件的大小通常在100MB或者以上。数个GB大小的文件也是普遍存在,并且要能够被有效的管理。系统也必须支持小文件,但是不需要针对小文件做专门的优化。
- 系统的工作负载主要由两种读操作组成:大规模的流式读取和小规模的随机读取。大规模的流式读取通常一次读取数百KB的数据,更常见的是一次读取1MB甚至更多的数据。来自同一个客户机的连续操作通常是读取同一个文件中连续的一个区域。小规模的随机读取通常是在文件某个随机的位置读取几个KB数据。如果应用程序对性能非常关注,通常的做法是把小规模的随机读取操作合并并排序,之后按顺序批量读取,这样就避免了在文件中前后来回的移动读取位置。
- 系统的工作负载还包括许多大规模的、顺序的、数据追加方式的写操作。一般情况下,每次写入的数据的大小和大规模读类似。数据一旦被写入后,文件就很少会被修改了。系统支持小规模的随机位置写入操作,但是可能效率不彰。
- 系统必须高效的、行为定义明确的(alex注:well-defined)实现多客户端并行追加数据到同一个文件里的语意。我们的文件通常被用于”生产者-消费者“队列,或者其它多路文件合并操作。通常会有数百个生产者,每个生产者进程运行在一台机器上,同时对一个文件进行追加操作。使用最小的同步开销来实现的原子的多路追加数据操作是必不可少的。文件可以在稍后读取,或者是消费者在追加的操作的同时读取文件。
- 高性能的稳定网络带宽远比低延迟重要。我们的目标程序绝大部分要求能够高速率的、大批量的处理数据,极少有程序对单一的读写操作有严格的响应时间要求。
2.2 接口
2.3 架构

2.4 单一Master节点
2.5 Chunk尺寸
2.6 元数据
2.6.1 内存中的数据结构
2.6.2 Chunk位置信息
Master服务器并不保存持久化保存哪个Chunk服务器存有指定Chunk的副本的信息。Master服务器只是在启动的时候轮询Chunk服务器以获取这些信息。Master服务器能够保证它持有的信息始终是最新的,因为它控制了所有的Chunk位置的分配,而且通过周期性的心跳信息监控Chunk服务器的状态。
最初设计时,我们试图把Chunk的位置信息持久的保存在Master服务器上,但是后来我们发现在启动的时候轮询Chunk服务器,之后定期轮询更新的方式更简单。这种设计简化了在有Chunk服务器加入集群、离开集群、更名、失效、以及重启的时候,Master服务器和Chunk服务器数据同步的问题。在一个拥有数百台服务器的集群中,这类事件会频繁的发生。
可以从另外一个角度去理解这个设计决策:只有Chunk服务器才能最终确定一个Chunk是否在它的硬盘上。我们从没有考虑过在Master服务器上维护一个这些信息的全局视图,因为Chunk服务器的错误可能会导致Chunk自动消失(比如,硬盘损坏了或者无法访问了),亦或者操作人员可能会重命名一个Chunk服务器。
2.6.3 操作日志
操作日志包含了关键的元数据变更历史记录。这对GFS非常重要。这不仅仅是因为操作日志是元数据唯一的持久化存储记录,它也作为判断同步操作顺序的逻辑时间基线(alex注:也就是通过逻辑日志的序号作为操作发生的逻辑时间,类似于事务系统中的LSN)。文件和Chunk,连同它们的版本(参考4.5节),都由它们创建的逻辑时间唯一的、永久的标识。
操作日志非常重要,我们必须确保日志文件的完整,确保只有在元数据的变化被持久化后,日志才对客户端是可见的。否则,即使Chunk本身没有出现任何问题,我们仍有可能丢失整个文件系统,或者丢失客户端最近的操作。所以,我们会把日志复制到多台远程机器,并且只有把相应的日志记录写入到本地以及远程机器的硬盘后,才会响应客户端的操作请求。Master服务器会收集多个日志记录后批量处理,以减少写入磁盘和复制对系统整体性能的影响。
由于创建一个Checkpoint文件需要一定的时间,所以Master服务器的内部状态被组织为一种格式,这种格式要确保在Checkpoint过程中不会阻塞正在进行的修改操作。Master服务器使用独立的线程切换到新的日志文件和创建新的Checkpoint文件。新的Checkpoint文件包括切换前所有的修改。对于一个包含数百万个文件的集群,创建一个Checkpoint文件需要1分钟左右的时间。创建完成后,Checkpoint文件会被写入在本地和远程的硬盘里。
Master服务器恢复只需要最新的Checkpoint文件和后续的日志文件。旧的Checkpoint文件和日志文件可以被删除,但是为了应对灾难性的故障(alex注:catastrophes,数据备份相关文档中经常会遇到这个词,表示一种超出预期范围的灾难性事件),我们通常会多保存一些历史文件。Checkpoint失败不会对正确性产生任何影响,因为恢复功能的代码可以检测并跳过没有完成的Checkpoint文件。
2.7 一致性模型
GFS支持一个宽松的一致性模型,这个模型能够很好的支撑我们的高度分布的应用,同时还保持了相对简单且容易实现的优点。本节我们讨论GFS的一致性的保障机制,以及对应用程序的意义。我们也着重描述了GFS如何管理这些一致性保障机制,但是实现的细节将在本论文的其它部分讨论。
2.7.1 GFS一致性保障机制
文件命名空间的修改(例如,文件创建)是原子性的。它们仅由Master节点的控制:命名空间锁提供了原子性和正确性(4.1章)的保障;Master节点的操作日志定义了这些操作在全局的顺序(2.6.3章)。

数据修改后文件region(alex注:region这个词用中文非常难以表达,我认为应该是修改操作所涉及的文件中的某个范围)的状态取决于操作的类型、成功与否、以及是否同步修改。表1总结了各种操作的结果。如果所有客户端,无论从哪个副本读取,读到的数据都一样,那么我们认为文件region是“一致的”;如果对文件的数据修改之后,region是一致的,并且客户端能够看到写入操作全部的内容,那么这个region是“已定义的”。当一个数据修改操作成功执行,并且没有受到同时执行的其它写入操作的干扰,那么影响的region就是已定义的(隐含了一致性):所有的客户端都可以看到写入的内容。并行修改操作成功完成之后,region处于一致的、未定义的状态:所有的客户端看到同样的数据,但是无法读到任何一次写入操作写入的数据。通常情况下,文件region内包含了来自多个修改操作的、混杂的数据片段。失败的修改操作导致一个region处于不一致状态(同时也是未定义的):不同的客户在不同的时间会看到不同的数据。后面我们将描述应用如何区分已定义和未定义的region。应用程序没有必要再去细分未定义region的不同类型。
经过了一系列的成功的修改操作之后,GFS确保被修改的文件region是已定义的,并且包含最后一次修改操作写入的数据。GFS通过以下措施确保上述行为:(a) 对Chunk的所有副本的修改操作顺序一致(3.1章),(b)使用Chunk的版本号来检测副本是否因为它所在的Chunk服务器宕机(4.5章)而错过了修改操作而导致其失效。失效的副本不会再进行任何修改操作,Master服务器也不再返回这个Chunk副本的位置信息给客户端。它们会被垃圾收集系统尽快回收。
2.7.2 程序的实现
3. 系统交互
3.1 租约(lease)和变更顺序
(alex注:lease是数据库中的一个术语)
变更是一个会改变Chunk内容或者元数据的操作,比如写入操作或者记录追加操作。变更操作会在Chunk的所有副本上执行。我们使用租约(lease)机制来保持多个副本间变更顺序的一致性。Master节点为Chunk的一个副本建立一个租约,我们把这个副本叫做主Chunk。主Chunk对Chunk的所有更改操作进行序列化。所有的副本都遵从这个序列进行修改操作。因此,修改操作全局的顺序首先由Master节点选择的租约的顺序决定,然后由租约中主Chunk分配的序列号决定。
在图2中,我们依据步骤编号,展现写入操作的控制流程。

- 客户机向Master节点询问哪一个Chunk服务器持有当前的租约,以及其它副本的位置。如果没有一个Chunk持有租约,Master节点就选择其中一个副本建立一个租约(这个步骤在图上没有显示)。
- Master节点将主Chunk的标识符以及其它副本(又称为secondary副本、二级副本)的位置返回给客户机。客户机缓存这些数据以便后续的操作。只有在主Chunk不可用,或者主Chunk回复信息表明它已不再持有租约的时候,客户机才需要重新跟Master节点联系。
- 客户机把数据推送到所有的副本上。客户机可以以任意的顺序推送数据。Chunk服务器接收到数据并保存在它的内部LRU缓存中,一直到数据被使用或者过期交换出去。由于数据流的网络传输负载非常高,通过分离数据流和控制流,我们可以基于网络拓扑情况对数据流进行规划,提高系统性能,而不用去理会哪个Chunk服务器保存了主Chunk。3.2章节会进一步讨论这点。
- 当所有的副本都确认接收到了数据,客户机发送写请求到主Chunk服务器。这个请求标识了早前推送到所有副本的数据。主Chunk为接收到的所有操作分配连续的序列号,这些操作可能来自不同的客户机,序列号保证了操作顺序执行。它以序列号的顺序把操作应用到它自己的本地状态中(alex注:也就是在本地执行这些操作,这句话按字面翻译有点费解,也许应该翻译为“它顺序执行这些操作,并更新自己的状态”)。
- 主Chunk把写请求传递到所有的二级副本。每个二级副本依照主Chunk分配的序列号以相同的顺序执行这些操作。
- 所有的二级副本回复主Chunk,它们已经完成了操作。
- 主Chunk服务器(alex注:即主Chunk所在的Chunk服务器)回复客户机。任何副本产生的任何错误都会返回给客户机。在出现错误的情况下,写入操作可能在主Chunk和一些二级副本执行成功。(如果操作在主Chunk上失败了,操作就不会被分配序列号,也不会被传递。)客户端的请求被确认为失败,被修改的region处于不一致的状态。我们的客户机代码通过重复执行失败的操作来处理这样的错误。在从头开始重复执行之前,客户机会先从步骤(3)到步骤(7)做几次尝试。
3.2 数据流
3.3 原子的记录追加
3.4 快照
4. Master节点的操作
4.1 名称空间管理和锁
因为名称空间可能有很多节点,读写锁采用惰性分配策略,在不再使用的时候立刻被删除。同样,锁的获取也要依据一个全局一致的顺序来避免死锁:首先按名称空间的层次排序,在同一个层次内按字典顺序排序。
4.2 副本的位置
4.3 创建,重新复制,重新负载均衡
4.4 垃圾回收
4.4.1 机制
4.4.2 讨论
4.5 过期失效的副本检测
5. 容错和诊断
5.1 高可用性
5.1.1 快速恢复
5.1.2 Chunk复制
正如之前讨论的,每个Chunk都被复制到不同机架上的不同的Chunk服务器上。用户可以为文件命名空间的不同部分设定不同的复制级别。缺省是3。当有Chunk服务器离线了,或者通过Chksum校验(参考5.2节)发现了已经损坏的数据,Master节点通过克隆已有的副本保证每个Chunk都被完整复制(alex注:即每个Chunk都有复制因子制定的个数个副本,缺省是3)。虽然Chunk复制策略对我们非常有效,但是我们也在寻找其它形式的跨服务器的冗余解决方案,比如使用奇偶校验、或者Erasure codes(alex注:Erasure codes用来解决链接层中不相关的错误,以及网络拥塞和buffer限制造成的丢包错误)来解决我们日益增长的只读存储需求。我们的系统主要的工作负载是追加方式的写入和读取操作,很少有随机的写入操作,因此,我们认为在我们这个高度解耦合的系统架构下实现这些复杂的冗余方案很有挑战性,但并非不可实现。
5.1.3 Master服务器的复制
此外,GFS中还有些“影子”Master服务器,这些“影子”服务器在“主”Master服务器宕机的时候提供文件系统的只读访问。它们是影子,而不是镜像,所以它们的数据可能比“主”Master服务器更新要慢,通常是不到1秒。对于那些不经常改变的文件、或者那些允许获取的数据有少量过期的应用程序,“影子”Master服务器能够提高读取的效率。事实上,因为文件内容是从Chunk服务器上读取的,因此,应用程序不会发现过期的文件内容。在这个短暂的时间窗内,过期的可能是文件的元数据,比如目录的内容或者访问控制信息。
“影子”Master服务器为了保持自身状态是最新的,它会读取一份当前正在进行的操作的日志副本,并且依照和主Master服务器完全相同的顺序来更改内部的数据结构。和主Master服务器一样,“影子”Master服务器在启动的时候也会从Chunk服务器轮询数据(之后定期拉数据),数据中包括了Chunk副本的位置信息;“影子”Master服务器也会定期和Chunk服务器“握手”来确定它们的状态。在主Master服务器因创建和删除副本导致副本位置信息更新时,“影子”Master服务器才和主Master服务器通信来更新自身状态。
5.2 数据完整性
每个Chunk服务器都使用Checksum来检查保存的数据是否损坏。考虑到一个GFS集群通常都有好几百台机器、几千块硬盘,磁盘损坏导致数据在读写过程中损坏或者丢失是非常常见的(第7节讲了一个原因)。我们可以通过别的Chunk副本来解决数据损坏问题,但是跨越Chunk服务器比较副本来检查数据是否损坏很不实际。另外,GFS允许有歧义的副本存在:GFS修改操作的语义,特别是早先讨论过的原子纪录追加的操作,并不保证副本完全相同(alex注:副本不是byte-wise完全一致的)。因此,每个Chunk服务器必须独立维护Checksum来校验自己的副本的完整性。
我们把每个Chunk都分成64KB大小的块。每个块都对应一个32位的Checksum。和其它元数据一样,Checksum与其它的用户数据是分开的,并且保存在内存和硬盘上,同时也记录操作日志。
对于读操作来说,在把数据返回给客户端或者其它的Chunk服务器之前,Chunk服务器会校验读取操作涉及的范围内的块的Checksum。因此Chunk服务器不会把错误数据传递到其它的机器上。如果发生某个块的Checksum不正确,Chunk服务器返回给请求者一个错误信息,并且通知Master服务器这个错误。作为回应,请求者应当从其它副本读取数据,Master服务器也会从其它副本克隆数据进行恢复。当一个新的副本就绪后,Master服务器通知副本错误的Chunk服务器删掉错误的副本。
Checksum对读操作的性能影响很小,可以基于几个原因来分析一下。因为大部分的读操作都至少要读取几个块,而我们只需要读取一小部分额外的相关数据进行校验。GFS客户端代码通过每次把读取操作都对齐在Checksum block的边界上,进一步减少了这些额外的读取操作的负面影响。另外,在Chunk服务器上,Chunksum的查找和比较不需要I/O操作,Checksum的计算可以和I/O操作同时进行。
Checksum的计算针对在Chunk尾部的追加写入操作作了高度优化(与之对应的是覆盖现有数据的写入操作),因为这类操作在我们的工作中占了很大比例。我们只增量更新最后一个不完整的块的Checksum,并且用所有的追加来的新Checksum块来计算新的Checksum。即使是最后一个不完整的Checksum块已经损坏了,而且我们不能够马上检查出来,由于新的Checksum和已有数据不吻合,在下次对这个块进行读取操作的时候,会检查出数据已经损坏了。
相比之下,如果写操作覆盖已经存在的一个范围内的Chunk,我们必须读取和校验被覆盖的第一个和最后一个块,然后再执行写操作;操作完成之后再重新计算和写入新的Checksum。如果我们不校验第一个和最后一个被写的块,那么新的Checksum可能会隐藏没有被覆盖区域内的数据错误。
在Chunk服务器空闲的时候,它会扫描和校验每个不活动的Chunk的内容。这使得我们能够发现很少被读取的Chunk是否完整。一旦发现有Chunk的数据损坏,Master可以创建一个新的、正确的副本,然后把损坏的副本删除掉。这个机制也避免了非活动的、已损坏的Chunk欺骗Master节点,使Master节点认为它们已经有了足够多的副本了。
5.3 诊断工具
详尽的、深入细节的诊断日志,在问题隔离、调试、以及性能分析等方面给我们带来无法估量的帮助,同时也只需要很小的开销。没有日志的帮助,我们很难理解短暂的、不重复的机器之间的消息交互。GFS的服务器会产生大量的日志,记录了大量关键的事件(比如,Chunk服务器启动和关闭)以及所有的RPC的请求和回复。这些诊断日志可以随意删除,对系统的正确运行不造成任何影响。然而,我们在存储空间允许的情况下会尽量的保存这些日志。
RPC日志包含了网络上发生的所有请求和响应的详细记录,但是不包括读写的文件数据。通过匹配请求与回应,以及收集不同机器上的RPC日志记录,我们可以重演所有的消息交互来诊断问题。日志还用来跟踪负载测试和性能分析。
日志对性能的影响很小(远小于它带来的好处),因为这些日志的写入方式是顺序的、异步的。最近发生的事件日志保存在内存中,可用于持续不断的在线监控。
6. 度量
6.1 小规模基准测试
6.1.1 读取

6.1.2 写入
6.1.3 记录追加
图3(c)显示了记录追加操作的性能。N个客户机同时追加数据到一个文件。记录追加操作的性能受限于保存文件最后一个Chunk的Chunk服务器的带宽,而与客户机的数量无关。记录追加的速度由一个客户机的6.0MB/s开始,下降到16个客户机的4.8MB/s为止,速度的下降主要是由于不同客户端的网络拥塞以及网络传输速度的不同而导致的。
我们的程序倾向于同时处理多个这样的文件。换句话说,即N个客户机同时追加数据到M个共享文件中,这里N和M都是数十或者数百以上。所以,在我们的实际应用中,Chunk服务器的网络拥塞并没有成为一个严重问题,如果Chunk服务器的某个文件正在写入,客户机会去写另外一个文件。
6.2 实际应用中的集群

6.2.1 存储
如上表前五行所描述的,两个集群都由上百台Chunk服务器组成,支持数TB的硬盘空间;两个集群虽然都存储了大量的数据,但是还有剩余的空间。“已用空间”包含了所有的Chunk副本。实际上所有的文件都复制了三份。因此,集群实际上各存储了18TB和52TB的文件数据。
两个集群存储的文件数量都差不多,但是集群B上有大量的死文件。所谓“死文件”是指文件被删除了或者是被新版本的文件替换了,但是存储空间还没有来得及被回收。由于集群B存储的文件较大,因此它的Chunk数量也比较多。
6.2.2 元数据
Chunk服务器总共保存了十几GB的元数据,大多数是来自用户数据的、64KB大小的块的Checksum。保存在Chunk服务器上其它的元数据是Chunk的版本号信息,我们在4.5节描述过。
在Master服务器上保存的元数据就小的多了,大约只有数十MB,或者说平均每个文件100字节的元数据。这和我们设想的是一样的,Master服务器的内存大小在实际应用中并不会成为GFS系统容量的瓶颈。大多数文件的元数据都是以前缀压缩模式存放的文件名。Master服务器上存放的其它元数据包括了文件的所有者和权限、文件到Chunk的映射关系,以及每一个Chunk的当前版本号。此外,针对每一个Chunk,我们都保存了当前的副本位置以及对它的引用计数,这个引用计数用于实现写时拷贝(alex注:即COW,copy-on-write)。
对于每一个单独的服务器,无论是Chunk服务器还是Master服务器,都只保存了50MB到100MB的元数据。因此,恢复服务器是非常快速的:在服务器响应客户请求之前,只需要花几秒钟时间从磁盘上读取这些数据就可以了。不过,Master服务器会持续颠簸一段时间–通常是30到60秒–直到它完成轮询所有的Chunk服务器,并获取到所有Chunk的位置信息。
6.2.3 读写速率

6.2.4 Master服务器的负载
6.2.5 恢复时间
6.3 工作负荷分析(Workload Breakdown)
6.3.1 方法论和注意事项
6.3.2 Chunk服务器工作负荷

表4显示了操作按涉及的数据量大小的分布情况。读取操作按操作涉及的数据量大小呈现了双峰分布。小的读取操作(小于64KB)一般是由查找操作的客户端发起的,目的在于从巨大的文件中查找小块的数据。大的读取操作(大于512KB)一般是从头到尾顺序的读取整个文件。
在集群Y上,有相当数量的读操作没有返回任何的数据。在我们的应用中,尤其是在生产系统中,经常使用文件作为生产者-消费者队列。生产者并行的向文件中追加数据,同时,消费者从文件的尾部读取数据。某些情况下,消费者读取的速度超过了生产者写入的速度,这就会导致没有读到任何数据的情况。集群X通常用于短暂的数据分析任务,而不是长时间运行的分布式应用,因此,集群X很少出现这种情况。
写操作按数据量大小也同样呈现为双峰分布。大的写操作(超过256KB)通常是由于Writer使用了缓存机制导致的。Writer缓存较小的数据,通过频繁的Checkpoint或者同步操作,或者只是简单的统计小的写入(小于64KB)的数据量(alex注:即汇集多次小的写入操作,当数据量达到一个阈值,一次写入),之后批量写入。
再来观察一下记录追加操作。我们可以看到集群Y中大的记录追加操作所占比例比集群X多的多,这是因为集群Y用于我们的生产系统,针对GFS做了更全面的调优。

6.3.3 记录追加 vs. 写操作
如同我们所预期的,我们的数据修改操作主要是记录追加操作而不是覆盖方式的写操作。我们测量了第一个副本的数据覆盖写的情况。这近似于一个客户机故意覆盖刚刚写入的数据,而不是增加新的数据。对于集群X,覆盖写操作在写操作所占据字节上的比例小于0.0001%,在所占据操作数量上的比例小于0.0003%。对于集群Y,这两个比率都是0.05%。虽然这只是某一片断的情况,但是仍然高于我们的预期。这是由于这些覆盖写的操作,大部分是由于客户端在发生错误或者超时以后重试的情况。这在本质上应该不算作工作负荷的一部分,而是重试机制产生的结果。
6.3.4 Master的工作负荷

7. 经验
8. 相关工作
9. 结束语
致谢
Kramer worked on performance enhancements. Fay Chang, Urs Hoelzle, Max Ibel, Sharon Perl, Rob Pike, and Debby Wallach commented on earlier drafts of the paper. Many of our colleagues at Google bravely trusted their data to a new file system and gave us useful feedback. Yoshka helped with early testing.
参考
[4] Garth A. Gibson, David F. Nagle, Khalil Amiri, Jeff Butler, Fay W. Chang, Howard Gobioff, Charles Hardin, ErikR iedel, David Rochberg, and Jim Zelenka. A cost-effective, high-bandwidth storage architecture. In Proceedings of the 8th Architectural Support for Programming Languages and Operating Systems, pages 92–103, San Jose, California, October 1998.
Google MapReduce中文版
摘要
1、介绍
2、编程模型
2.1、例子
map(String key, String value):
// key: document name
// value: document contents
for each word w in value:
EmitIntermediate(w, “1″);
reduce(String key, Iterator values):
// key: a word
// values: a list of counts
int result = 0;
for each v in values:
result += ParseInt(v);
Emit(AsString(result));
2.2、类型
map(k1,v1) ->list(k2,v2)
reduce(k2,list(v2)) ->list(v2)
比如,输入的key和value值与输出的key和value值在类型上推导的域不同。此外,中间key和value值与输出key和value值在类型上推导的域相同。
(alex注:原文中这个domain的含义不是很清楚,我参考Hadoop、KFS等实现,map和reduce都使用了泛型,因此,我把domain翻译成类型推导的域)。
我们的C++中使用字符串类型作为用户自定义函数的输入输出,用户在自己的代码中对字符串进行适当的类型转换。
2.3、更多的例子
- 分布式的Grep:Map函数输出匹配某个模式的一行,Reduce函数是一个恒等函数,即把中间数据复制到输出。
- 计算URL访问频率:Map函数处理日志中web页面请求的记录,然后输出(URL,1)。Reduce函数把相同URL的value值都累加起来,产生(URL,记录总数)结果。
- 倒转网络链接图:Map函数在源页面(source)中搜索所有的链接目标(target)并输出为(target,source)。Reduce函数把给定链接目标(target)的链接组合成一个列表,输出(target,list(source))。
- 每个主机的检索词向量:检索词向量用一个(词,频率)列表来概述出现在文档或文档集中的最重要的一些词。Map函数为每一个输入文档输出(主机名,检索词向量),其中主机名来自文档的URL。Reduce函数接收给定主机的所有文档的检索词向量,并把这些检索词向量加在一起,丢弃掉低频的检索词,输出一个最终的(主机名,检索词向量)。
- 倒排索引:Map函数分析每个文档输出一个(词,文档号)的列表,Reduce函数的输入是一个给定词的所有(词,文档号),排序所有的文档号,输出(词,list(文档号))。所有的输出集合形成一个简单的倒排索引,它以一种简单的算法跟踪词在文档中的位置。
- 分布式排序:Map函数从每个记录提取key,输出(key,record)。Reduce函数不改变任何的值。这个运算依赖分区机制(在4.1描述)和排序属性(在4.2描述)。
3、实现
1.x86架构、运行Linux操作系统、双处理器、2-4GB内存的机器。
2.普通的网络硬件设备,每个机器的带宽为百兆或者千兆,但是远小于网络的平均带宽的一半。 (alex注:这里需要网络专家解释一下了)
3.集群中包含成百上千的机器,因此,机器故障是常态。
4.存储为廉价的内置IDE硬盘。一个内部分布式文件系统用来管理存储在这些磁盘上的数据。文件系统通过数据复制来在不可靠的硬件上保证数据的可靠性和有效性。
5.用户提交工作(job)给调度系统。每个工作(job)都包含一系列的任务(task),调度系统将这些任务调度到集群中多台可用的机器上。
3.1、执行概括

1.用户程序首先调用的MapReduce库将输入文件分成M个数据片度,每个数据片段的大小一般从 16MB到64MB(可以通过可选的参数来控制每个数据片段的大小)。然后用户程序在机群中创建大量的程序副本。 (alex:copies of the program还真难翻译)
2.这些程序副本中的有一个特殊的程序–master。副本中其它的程序都是worker程序,由master分配任务。有M个Map任务和R个Reduce任务将被分配,master将一个Map任务或Reduce任务分配给一个空闲的worker。
3.被分配了map任务的worker程序读取相关的输入数据片段,从输入的数据片段中解析出key/value pair,然后把key/value pair传递给用户自定义的Map函数,由Map函数生成并输出的中间key/value pair,并缓存在内存中。
4.缓存中的key/value pair通过分区函数分成R个区域,之后周期性的写入到本地磁盘上。缓存的key/value pair在本地磁盘上的存储位置将被回传给master,由master负责把这些存储位置再传送给Reduce worker。
5.当Reduce worker程序接收到master程序发来的数据存储位置信息后,使用RPC从Map worker所在主机的磁盘上读取这些缓存数据。当Reduce worker读取了所有的中间数据后,通过对key进行排序后使得具有相同key值的数据聚合在一起。由于许多不同的key值会映射到相同的Reduce任务上,因此必须进行排序。如果中间数据太大无法在内存中完成排序,那么就要在外部进行排序。
6.Reduce worker程序遍历排序后的中间数据,对于每一个唯一的中间key值,Reduce worker程序将这个key值和它相关的中间value值的集合传递给用户自定义的Reduce函数。Reduce函数的输出被追加到所属分区的输出文件。
7.当所有的Map和Reduce任务都完成之后,master唤醒用户程序。在这个时候,在用户程序里的对MapReduce调用才返回。
在成功完成任务之后,MapReduce的输出存放在R个输出文件中(对应每个Reduce任务产生一个输出文件,文件名由用户指定)。一般情况下,用户不需要将这R个输出文件合并成一个文件–他们经常把这些文件作为另外一个MapReduce的输入,或者在另外一个可以处理多个分割文件的分布式应用中使用。
3.2、Master数据结构
3.3、容错
master周期性的ping每个worker。如果在一个约定的时间范围内没有收到worker返回的信息,master将把这个worker标记为失效。所有由这个失效的worker完成的Map任务被重设为初始的空闲状态,之后这些任务就可以被安排给其他的worker。同样的,worker失效时正在运行的Map或Reduce任务也将被重新置为空闲状态,等待重新调度。
当worker故障时,由于已经完成的Map任务的输出存储在这台机器上,Map任务的输出已不可访问了,因此必须重新执行。而已经完成的Reduce任务的输出存储在全局文件系统上,因此不需要再次执行。
一个简单的解决办法是让master周期性的将上面描述的数据结构 (alex注:指3.2节)的写入磁盘,即检查点(checkpoint)。如果这个master任务失效了,可以从最后一个检查点(checkpoint)开始启动另一个master进程。然而,由于只有一个master进程,master失效后再恢复是比较麻烦的,因此我们现在的实现是如果master失效,就中止MapReduce运算。客户可以检查到这个状态,并且可以根据需要重新执行MapReduce操作。
(alex注:原文为”semantics in the presence of failures”)
当用户提供的Map和Reduce操作是输入确定性函数(即相同的输入产生相同的输出)时,我们的分布式实现在任何情况下的输出都和所有程序没有出现任何错误、顺序的执行产生的输出是一样的。
使用MapReduce模型的程序员可以很容易的理解他们程序的行为,因为我们绝大多数的Map和Reduce操作是确定性的,而且存在这样的一个事实:我们的失效处理机制等价于一个顺序的执行的操作。当Map或/和Reduce操作是不确定性的时候,我们提供虽然较弱但是依然合理的处理机制。当使用非确定操作的时候,一个Reduce任务R1的输出等价于一个非确定性程序顺序执行产生时的输出。但是,另一个Reduce任务R2的输出也许符合一个不同的非确定顺序程序执行产生的R2的输出。
3.4、存储位置
3.5、任务粒度
但是实际上,在我们的具体实现中对M和R的取值都有一定的客观限制,因为master必须执行O(M+R)次调度,并且在内存中保存O(M*R)个状态(对影响内存使用的因素还是比较小的:O(M*R)块状态,大概每对Map任务/Reduce任务1个字节就可以了)。
3.6、备用任务
4、技巧
虽然简单的Map和Reduce函数提供的基本功能已经能够满足大部分的计算需要,我们还是发掘出了一些有价值的扩展功能。本节将描述这些扩展功能。
4.1、分区函数
4.2、顺序保证
4.3、Combiner函数
部分的合并中间结果可以显著的提高一些MapReduce操作的速度。附录A包含一个使用combiner函数的例子。
4.4、输入和输出的类型
Reader并非一定要从文件中读取数据,比如,我们可以很容易的实现一个从数据库里读记录的Reader,或者从内存中的数据结构读取数据的Reader。
类似的,我们提供了一些预定义的输出数据的类型,通过这些预定义类型能够产生不同格式的数据。用户采用类似添加新的输入数据类型的方式增加新的输出类型。
4.5、副作用
如果一个任务产生了多个输出文件,我们没有提供类似两阶段提交的原子操作支持这种情况。因此,对于会产生多个输出文件、并且对于跨文件有一致性要求的任务,都必须是确定性的任务。但是在实际应用过程中,这个限制还没有给我们带来过麻烦。
4.6、跳过损坏的记录
每个worker进程都设置了信号处理函数捕获内存段异常(segmentation violation)和总线错误(bus error)。在执行Map或者Reduce操作之前,MapReduce库通过全局变量保存记录序号。如果用户程序触发了一个系统信号,消息处理函数将用“最后一口气”通过UDP包向master发送处理的最后一条记录的序号。当master看到在处理某条特定记录不止失败一次时,master就标志着条记录需要被跳过,并且在下次重新执行相关的Map或者Reduce任务的时候跳过这条记录。
4.7、本地执行
4.8、状态信息
另外,处于最顶层的状态页面显示了哪些worker失效了,以及他们失效的时候正在运行的Map和Reduce任务。这些信息对于调试用户代码中的bug很有帮助。
4.9、计数器
Counter* uppercase;
uppercase = GetCounter(“uppercase”);
map(String name, String contents):
for each word w in contents:
if (IsCapitalized(w)):
uppercase->Increment();
EmitIntermediate(w, “1″);
计数器机制对于MapReduce操作的完整性检查非常有用。比如,在某些MapReduce操作中,用户需要确保输出的key value pair精确的等于输入的key value pair,或者处理的German文档数量在处理的整个文档数量中属于合理范围。
5、性能
5.1、集群配置
在4GB内存里,大概有1-1.5G用于运行在集群上的其他任务。测试程序在周末下午开始执行,这时主机的CPU、磁盘和网络基本上处于空闲状态。
5.2、GREP

图2显示了这个运算随时间的处理过程。其中Y轴表示输入数据的处理速度。处理速度随着参与MapReduce计算的机器数量的增加而增加,当1764台worker参与计算的时,处理速度达到了30GB/s。当Map任务结束的时候,即在计算开始后80秒,输入的处理速度降到0。整个计算过程从开始到结束一共花了大概150秒。这包括了大约一分钟的初始启动阶段。初始启动阶段消耗的时间包括了是把这个程序传送到各个worker机器上的时间、等待GFS文件系统打开1000个输入文件集合的时间、获取相关的文件本地位置优化信息的时间。
5.3、排序
在这个benchmark测试中,我们使用的分区函数知道key的分区情况。通常对于排序程序来说,我们会增加一个预处理的MapReduce操作用于采样key值的分布情况,通过采样的数据来计算对最终排序处理的分区点。
还有一些值得注意的现象:输入数据的读取速度比排序速度和输出数据写入磁盘速度要高不少,这是因为我们的输入数据本地化优化策略起了作用 — 绝大部分数据都是从本地硬盘读取的,从而节省了网络带宽。排序速度比输出数据写入到磁盘的速度快,这是因为输出数据写了两份(我们使用了2路的GFS文件系统,写入复制节点的原因是为了保证数据可靠性和可用性)。我们把输出数据写入到两个复制节点的原因是因为这是底层文件系统的保证数据可靠性和可用性的实现机制。如果底层文件系统使用类似容错编码[14](erasure coding)的方式而不是复制的方式保证数据的可靠性和可用性,那么在输出数据写入磁盘的时候,就可以降低网络带宽的使用。
5.4、高效的backup任务
5.5、失效的机器
6、经验
- 大规模机器学习问题
- Google News和Froogle产品的集群问题
- 从公众查询产品(比如Google的Zeitgeist)的报告中抽取数据。
- 从大量的新应用和新产品的网页中提取有用信息(比如,从大量的位置搜索网页中抽取地理位置信息)。
- 大规模的图形计算。


在每个任务结束的时候,MapReduce库统计计算资源的使用状况。在表1,我们列出了2004年8月份MapReduce运行的任务所占用的相关资源。
6.1、大规模索引
- 实现索引部分的代码简单、小巧、容易理解,因为对于容错、分布式以及并行计算的处理都是MapReduce库提供的。比如,使用MapReduce库,计算的代码行数从原来的3800行C++代码减少到大概700行代码。
- MapReduce库的性能已经足够好了,因此我们可以把在概念上不相关的计算步骤分开处理,而不是混在一起以期减少数据传递的额外消耗。概念上不相关的计算步骤的隔离也使得我们可以很容易改变索引处理方式。比如,对之前的索引系统的一个小更改可能要耗费好几个月的时间,但是在使用MapReduce的新系统上,这样的更改只需要花几天时间就可以了。
- 索引系统的操作管理更容易了。因为由机器失效、机器处理速度缓慢、以及网络的瞬间阻塞等引起的绝大部分问题都已经由MapReduce库解决了,不再需要操作人员的介入了。另外,我们可以通过在索引系统集群中增加机器的简单方法提高整体处理性能。
7、相关工作
TACC[7]是一个用于简化构造高可用性网络服务的系统。和MapReduce一样,它也依靠重新执行机制来实现的容错处理。
8、结束语
我们也从MapReduce开发过程中学到了不少东西。首先,约束编程模式使得并行和分布式计算非常容易,也易于构造容错的计算环境;其次,网络带宽是稀有资源。大量的系统优化是针对减少网络传输量为目的的:本地优化策略使大量的数据从本地磁盘读取,中间文件写入本地磁盘、并且只写一份中间文件也节约了网络带宽;第三,多次执行相同的任务可以减少性能缓慢的机器带来的负面影响(alex注:即硬件配置的不平衡),同时解决了由于机器失效导致的数据丢失问题。
9、感谢
10、参考资料
[2] Remzi H. Arpaci-Dusseau, Eric Anderson, NoahTreuhaft, David E. Culler, Joseph M. Hellerstein, David Patterson, and Kathy Yelick. Cluster I/O with River:Making the fast case common. In Proceedings of the Sixth Workshop on Input/Output in Parallel and Distributed Systems (IOPADS ’99), pages 10.22, Atlanta, Georgia, May 1999.
[3] Arash Baratloo, Mehmet Karaul, Zvi Kedem, and Peter Wyckoff. Charlotte: Metacomputing on the web. In Proceedings of the 9th International Conference on Parallel and Distributed Computing Systems, 1996. [4] Luiz A. Barroso, Jeffrey Dean, and Urs H¨olzle. Web search for a planet: The Google cluster architecture. IEEE Micro, 23(2):22.28, April 2003.
[5] John Bent, Douglas Thain, Andrea C.Arpaci-Dusseau, Remzi H. Arpaci-Dusseau, and Miron Livny. Explicit control in a batch-aware distributed file system. In Proceedings of the 1st USENIX Symposium on Networked Systems Design and Implementation NSDI, March 2004.
[6] Guy E. Blelloch. Scans as primitive parallel operations.IEEE Transactions on Computers, C-38(11), November 1989.
[7] Armando Fox, Steven D. Gribble, Yatin Chawathe, Eric A. Brewer, and Paul Gauthier. Cluster-based scalable network services. In Proceedings of the 16th ACM Symposium on Operating System Principles, pages 78. 91, Saint-Malo, France, 1997.
[8] Sanjay Ghemawat, Howard Gobioff, and Shun-Tak Leung. The Google file system. In 19th Symposium on Operating Systems Principles, pages 29.43, Lake George, New York, 2003. To appear in OSDI 2004 12
[9] S. Gorlatch. Systematic efficient parallelization of scan and other list homomorphisms. In L. Bouge, P. Fraigniaud, A. Mignotte, and Y. Robert, editors, Euro-Par’96. Parallel Processing, Lecture Notes in Computer Science 1124, pages 401.408. Springer-Verlag, 1996.
[10] Jim Gray. Sort benchmark home page. http://research.microsoft.com/barc/SortBenchmark/.
[11] William Gropp, Ewing Lusk, and Anthony Skjellum. Using MPI: Portable Parallel Programming with the Message-Passing Interface. MIT Press, Cambridge, MA, 1999.
[12] L. Huston, R. Sukthankar, R.Wickremesinghe, M. Satyanarayanan, G. R. Ganger, E. Riedel, and A. Ailamaki. Diamond: A storage architecture for early discard in interactive search. In Proceedings of the 2004 USENIX File and Storage Technologies FAST Conference, April 2004.
[13] Richard E. Ladner and Michael J. Fischer. Parallel prefix computation. Journal of the ACM, 27(4):831.838, 1980.
[14] Michael O. Rabin. Efficient dispersal of information for security, load balancing and fault tolerance. Journal of the ACM, 36(2):335.348, 1989.
[15] Erik Riedel, Christos Faloutsos, Garth A. Gibson, and David Nagle. Active disks for large-scale data processing. IEEE Computer, pages 68.74, June 2001.
[16] Douglas Thain, Todd Tannenbaum, and Miron Livny. Distributed computing in practice: The Condor experience. Concurrency and Computation: Practice and Experience, 2004.
[17] L. G. Valiant. A bridging model for parallel computation. Communications of the ACM, 33(8):103.111, 1997.
[18] Jim Wyllie. Spsort: How to sort a terabyte quickly. http://alme1.almaden.ibm.com/cs/spsort.pdf.
附录A、单词频率统计
#include “mapreduce/mapreduce.h”
// User’s map function
class WordCounter : public Mapper {
public:
virtual void Map(const MapInput& input) {
const string& text = input.value();
const int n = text.size();
for (int i = 0; i < n; ) {
// Skip past leading whitespace
while ((i < n) && isspace(text[i]))
i++;
// Find word end
int start = i;
while ((i < n) && !isspace(text[i]))
i++;
if (start < i)
Emit(text.substr(start,i-start),”1″);
}
}
};
REGISTER_MAPPER(WordCounter);
// User’s reduce function
class Adder : public Reducer {
virtual void Reduce(ReduceInput* input) {
// Iterate over all entries with the
// same key and add the values
int64 value = 0;
while (!input->done()) {
value += StringToInt(input->value());
input->NextValue();
}
// Emit sum for input->key()
Emit(IntToString(value));
}
};
REGISTER_REDUCER(Adder);
int main(int argc, char** argv) {
ParseCommandLineFlags(argc, argv);
MapReduceSpecification spec;
// Store list of input files into “spec”
for (int i = 1; i < argc; i++) {
MapReduceInput* input = spec.add_input();
input->set_format(“text”);
input->set_filepattern(argv[i]);
input->set_mapper_class(“WordCounter”);
}
// Specify the output files:
// /gfs/test/freq-00000-of-00100
// /gfs/test/freq-00001-of-00100
// …
MapReduceOutput* out = spec.output();
out->set_filebase(“/gfs/test/freq”);
out->set_num_tasks(100);
out->set_format(“text”);
out->set_reducer_class(“Adder”);
// Optional: do partial sums within map
// tasks to save network bandwidth
out->set_combiner_class(“Adder”);
// Tuning parameters: use at most 2000
// machines and 100 MB of memory per task
spec.set_machines(2000);
spec.set_map_megabytes(100);
spec.set_reduce_megabytes(100);
// Now run it
MapReduceResult result;
if (!MapReduce(spec, &result)) abort();
// Done: ‘result’ structure contains info
// about counters, time taken, number of
// machines used, etc.
return 0;
}
摘要
Bigtable是一个分布式的结构化数据存储系统,它被设计用来处理海量数据:通常是分布在数千台普通服务器上的PB级的数据。Google的很多项目使用Bigtable存储数据,包括Web索引、Google Earth、Google Finance。这些应用对Bigtable提出的要求差异非常大,无论是在数据量上(从URL到网页到卫星图像)还是在响应速度上(从后端的批量处理到实时数据服务)。尽管应用需求差异很大,但是,针对Google的这些产品,Bigtable还是成功的提供了一个灵活的、高性能的解决方案。本论文描述了Bigtable提供的简单的数据模型,利用这个模型,用户可以动态的控制数据的分布和格式;我们还将描述Bigtable的设计和实现。
1 介绍
在过去两年半时间里,我们设计、实现并部署了一个分布式的结构化数据存储系统 — 在Google,我们称之为Bigtable。Bigtable的设计目的是可靠的处理PB级别的数据,并且能够部署到上千台机器上。Bigtable已经实现了下面的几个目标:适用性广泛、可扩展、高性能和高可用性。Bigtable已经在超过60个Google的产品和项目上得到了应用,包括Google Analytics、Google Finance、Orkut、PersonalizedSearch、Writely和Google Earth。这些产品对Bigtable提出了迥异的需求,有的需要高吞吐量的批处理,有的则需要及时响应,快速返回数据给最终用户。它们使用的Bigtable集群的配置也有很大的差异,有的集群只有几台服务器,而有的则需要上千台服务器、存储几百TB的数据。
在很多方面,Bigtable和数据库很类似:它使用了很多数据库的实现策略。并行数据库【14】和内存数据库【13】已经具备可扩展性和高性能,但是Bigtable提供了一个和这些系统完全不同的接口。Bigtable不支持完整的关系数据模型;与之相反,Bigtable为客户提供了简单的数据模型,利用这个模型,客户可以动态控制数据的分布和格式(alex注:也就是对BigTable而言,数据是没有格式的,用数据库领域的术语说,就是数据没有Schema,用户自己去定义Schema),用户也可以自己推测(alex注:reasonabout)底层存储数据的位置相关性(alex注:位置相关性可以这样理解,比如树状结构,具有相同前缀的数据的存放位置接近。在读取的时候,可以把这些数据一次读取出来)。数据的下标是行和列的名字,名字可以是任意的字符串。Bigtable将存储的数据都视为字符串,但是Bigtable本身不去解析这些字符串,客户程序通常会在把各种结构化或者半结构化的数据串行化到这些字符串里。通过仔细选择数据的模式,客户可以控制数据的位置相关性。最后,可以通过BigTable的模式参数来控制数据是存放在内存中、还是硬盘上。
第二节描述关于数据模型更多细节方面的东西;第三节概要介绍了客户端API;第四节简要介绍了BigTable底层使用的Google的基础框架;第五节描述了BigTable实现的关键部分;第6节描述了我们为了提高BigTable的性能采用的一些精细的调优方法;第7节提供了BigTable的性能数据;第8节讲述了几个Google内部使用BigTable的例子;第9节是我们在设计和后期支持过程中得到一些经验和教训;最后,在第10节列出我们的相关研究工作,第11节是我们的结论。
2 数据模型
Bigtable是一个稀疏的、分布式的、持久化存储的多维度排序Map(alex注:对于程序员来说,Map应该不用翻译了吧。Map由key和value组成,后面我们直接使用key和value,不再另外翻译了)。Map的索引是行关键字、列关键字以及时间戳;Map中的每个value都是一个未经解析的byte数组。
(row:string,column:string,time:int64)->string
我们在仔细分析了一个类似Bigtable的系统的种种潜在用途之后,决定使用这个数据模型。我们先举个具体的例子,这个例子促使我们做了很多设计决策;假设我们想要存储海量的网页及相关信息,这些数据可以用于很多不同的项目,我们姑且称这个特殊的表为Webtable。在Webtable里,我们使用URL作为行关键字,使用网页的某些属性作为列名,网页的内容存在“contents:”列中,并用获取该网页的时间戳作为标识(alex注:即按照获取时间不同,存储了多个版本的网页数据),如图一所示。
图一:一个存储Web网页的例子的表的片断。行名是一个反向URL。contents列族存放的是网页的内容,anchor列族存放引用该网页的锚链接文本(alex注:如果不知道HTML的Anchor,请Google一把)。CNN的主页被Sports Illustrater和MY-look的主页引用,因此该行包含了名为“anchor:cnnsi.com”和 “anchhor:my.look.ca”的列。每个锚链接只有一个版本(alex注:注意时间戳标识了列的版本,t9和t8分别标识了两个锚链接的版本);而contents列则有三个版本,分别由时间戳t3,t5,和t6标识。
行
表中的行关键字可以是任意的字符串(目前支持最大64KB的字符串,但是对大多数用户,10-100个字节就足够了)。对同一个行关键字的读或者写操作都是原子的(不管读或者写这一行里多少个不同列),这个设计决策能够使用户很容易的理解程序在对同一个行进行并发更新操作时的行为。
Bigtable通过行关键字的字典顺序来组织数据。表中的每个行都可以动态分区。每个分区叫做一个”Tablet”,Tablet是数据分布和负载均衡调整的最小单位。这样做的结果是,当操作只读取行中很少几列的数据时效率很高,通常只需要很少几次机器间的通信即可完成。用户可以通过选择合适的行关键字,在数据访问时有效利用数据的位置相关性,从而更好的利用这个特性。举例来说,在Webtable里,通过反转URL中主机名的方式,可以把同一个域名下的网页聚集起来组织成连续的行。具体来说,我们可以把maps.google.com/index.html的数据存放在关键字com.google.maps/index.html下。把相同的域中的网页存储在连续的区域可以让基于主机和域名的分析更加有效。
列族
列关键字组成的集合叫做“列族“,列族是访问控制的基本单位。存放在同一列族下的所有数据通常都属于同一个类型(我们可以把同一个列族下的数据压缩在一起)。列族在使用之前必须先创建,然后才能在列族中任何的列关键字下存放数据;列族创建后,其中的任何一个列关键字下都可以存放数据。根据我们的设计意图,一张表中的列族不能太多(最多几百个),并且列族在运行期间很少改变。与之相对应的,一张表可以有无限多个列。
列关键字的命名语法如下:列族:限定词。列族的名字必须是可打印的字符串,而限定词的名字可以是任意的字符串。比如,Webtable有个列族language,language列族用来存放撰写网页的语言。我们在language列族中只使用一个列关键字,用来存放每个网页的语言标识ID。Webtable中另一个有用的列族是anchor;这个列族的每一个列关键字代表一个锚链接,如图一所示。Anchor列族的限定词是引用该网页的站点名;Anchor列族每列的数据项存放的是链接文本。
访问控制、磁盘和内存的使用统计都是在列族层面进行的。在我们的Webtable的例子中,上述的控制权限能帮助我们管理不同类型的应用:我们允许一些应用可以添加新的基本数据、一些应用可以读取基本数据并创建继承的列族、一些应用则只允许浏览数据(甚至可能因为隐私的原因不能浏览所有数据)。
时间戳
在Bigtable中,表的每一个数据项都可以包含同一份数据的不同版本;不同版本的数据通过时间戳来索引。Bigtable时间戳的类型是64位整型。Bigtable可以给时间戳赋值,用来表示精确到毫秒的“实时”时间;用户程序也可以给时间戳赋值。如果应用程序需要避免数据版本冲突,那么它必须自己生成具有唯一性的时间戳。数据项中,不同版本的数据按照时间戳倒序排序,即最新的数据排在最前面。
为了减轻多个版本数据的管理负担,我们对每一个列族配有两个设置参数,Bigtable通过这两个参数可以对废弃版本的数据自动进行垃圾收集。用户可以指定只保存最后n个版本的数据,或者只保存“足够新”的版本的数据(比如,只保存最近7天的内容写入的数据)。
在Webtable的举例里,contents:列存储的时间戳信息是网络爬虫抓取一个页面的时间。上面提及的垃圾收集机制可以让我们只保留最近三个版本的网页数据。
3 API
Bigtable提供了建立和删除表以及列族的API函数。Bigtable还提供了修改集群、表和列族的元数据的API,比如修改访问权限。
// Open the table Table *T = OpenOrDie(“/bigtable/web/webtable”); // Write a new anchor and delete an old anchor RowMutation r1(T, “com.cnn.www”); r1.Set(“anchor:www.c-span.org”, “CNN”); r1.Delete(“anchor:www.abc.com”); Operation op; Apply(&op, &r1) |
Figure 2: Writing to Bigtable.
客户程序可以对Bigtable进行如下的操作:写入或者删除Bigtable中的值、从每个行中查找值、或者遍历表中的一个数据子集。图2中的C++代码使用RowMutation抽象对象进行了一系列的更新操作。(为了保持示例代码的简洁,我们忽略了一些细节相关代码)。调用Apply函数对Webtable进行了一个原子修改操作:它为www.cnn.com增加了一个锚点,同时删除了另外一个锚点。
Scanner scanner(T); ScanStream *stream; stream = scanner.FetchColumnFamily(“anchor”); stream->SetReturnAllVersions(); scanner.Lookup(“com.cnn.www”); for (; !stream->Done(); stream->Next()) { printf(“%s %s %lld %s\n”, scanner.RowName(), stream->ColumnName(), stream->MicroTimestamp(), stream->Value()); } |
Figure3: Reading from Bigtable.
图3中的C++代码使用Scanner抽象对象遍历一个行内的所有锚点。客户程序可以遍历多个列族,有几种方法可以对扫描输出的行、列和时间戳进行限制。例如,我们可以限制上面的扫描,让它只输出那些匹配正则表达式*.cnn.com的锚点,或者那些时间戳在当前时间前10天的锚点。
Bigtable还支持一些其它的特性,利用这些特性,用户可以对数据进行更复杂的处理。首先,Bigtable支持单行上的事务处理,利用这个功能,用户可以对存储在一个行关键字下的数据进行原子性的读-更新-写操作。虽然Bigtable提供了一个允许用户跨行批量写入数据的接口,但是,Bigtable目前还不支持通用的跨行事务处理。其次,Bigtable允许把数据项用做整数计数器。最后,Bigtable允许用户在服务器的地址空间内执行脚本程序。脚本程序使用Google开发的Sawzall【28】数据处理语言。虽然目前我们基于的Sawzall语言的API函数还不允许客户的脚本程序写入数据到Bigtable,但是它允许多种形式的数据转换、基于任意表达式的数据过滤、以及使用多种操作符的进行数据汇总。
Bigtable可以和MapReduce【12】一起使用,MapReduce是Google开发的大规模并行计算框架。我们已经开发了一些Wrapper类,通过使用这些Wrapper类,Bigtable可以作为MapReduce框架的输入和输出。
4 BigTable构件
Bigtable是建立在其它的几个Google基础构件上的。BigTable使用Google的分布式文件系统(GFS)【17】存储日志文件和数据文件。BigTable集群通常运行在一个共享的机器池中,池中的机器还会运行其它的各种各样的分布式应用程序,BigTable的进程经常要和其它应用的进程共享机器。BigTable依赖集群管理系统来调度任务、管理共享的机器上的资源、处理机器的故障、以及监视机器的状态。
BigTable内部存储数据的文件是GoogleSSTable格式的。SSTable是一个持久化的、排序的、不可更改的Map结构,而Map是一个key-value映射的数据结构,key和value的值都是任意的Byte串。可以对SSTable进行如下的操作:查询与一个key值相关的value,或者遍历某个key值范围内的所有的key-value对。从内部看,SSTable是一系列的数据块(通常每个块的大小是64KB,这个大小是可以配置的)。SSTable使用块索引(通常存储在SSTable的最后)来定位数据块;在打开SSTable的时候,索引被加载到内存。每次查找都可以通过一次磁盘搜索完成:首先使用二分查找法在内存中的索引里找到数据块的位置,然后再从硬盘读取相应的数据块。也可以选择把整个SSTable都放在内存中,这样就不必访问硬盘了。
BigTable还依赖一个高可用的、序列化的分布式锁服务组件,叫做Chubby【8】。一个Chubby服务包括了5个活动的副本,其中的一个副本被选为Master,并且处理请求。只有在大多数副本都是正常运行的,并且彼此之间能够互相通信的情况下,Chubby服务才是可用的。当有副本失效的时候,Chubby使用Paxos算法【9,23】来保证副本的一致性。Chubby提供了一个名字空间,里面包括了目录和小文件。每个目录或者文件可以当成一个锁,读写文件的操作都是原子的。Chubby客户程序库提供对Chubby文件的一致性缓存。每个Chubby客户程序都维护一个与Chubby服务的会话。如果客户程序不能在租约到期的时间内重新签订会话的租约,这个会话就过期失效了(alex注:又用到了lease。原文是:Aclient’s session expires if it is unable to renew its session lease within the leaseexpiration time.)。当一个会话失效时,它拥有的锁和打开的文件句柄都失效了。Chubby客户程序可以在文件和目录上注册回调函数,当文件或目录改变、或者会话过期时,回调函数会通知客户程序。
Bigtable使用Chubby完成以下的几个任务:确保在任何给定的时间内最多只有一个活动的Master副本;存储BigTable数据的自引导指令的位置(参考5.1节);查找Tablet服务器,以及在Tablet服务器失效时进行善后(5.2节);存储BigTable的模式信息(每张表的列族信息);以及存储访问控制列表。如果Chubby长时间无法访问,BigTable就会失效。最近我们在使用11个Chubby服务实例的14个BigTable集群上测量了这个影响。由于Chubby不可用而导致BigTable中的部分数据不能访问的平均比率是0.0047%(Chubby不能访问的原因可能是Chubby本身失效或者网络问题)。单个集群里,受Chubby失效影响最大的百分比是0.0326%(alex注:有点莫名其妙,原文是: The percentage for the single cluster that was most affected byChubby unavailability was 0.0326%.)。
5 介绍
Bigtable包括了三个主要的组件:链接到客户程序中的库、一个Master服务器和多个Tablet服务器。针对系统工作负载的变化情况,BigTable可以动态的向集群中添加(或者删除)Tablet服务器。
Master服务器主要负责以下工作:为Tablet服务器分配Tablets、检测新加入的或者过期失效的Table服务器、对Tablet服务器进行负载均衡、以及对保存在GFS上的文件进行垃圾收集。除此之外,它还处理对模式的相关修改操作,例如建立表和列族。
每个Tablet服务器都管理一个Tablet的集合(通常每个服务器有大约数十个至上千个Tablet)。每个Tablet服务器负责处理它所加载的Tablet的读写操作,以及在Tablets过大时,对其进行分割。
和很多Single-Master类型的分布式存储系统【17.21】类似,客户端读取的数据都不经过Master服务器:客户程序直接和Tablet服务器通信进行读写操作。由于BigTable的客户程序不必通过Master服务器来获取Tablet的位置信息,因此,大多数客户程序甚至完全不需要和Master服务器通信。在实际应用中,Master服务器的负载是很轻的。
一个BigTable集群存储了很多表,每个表包含了一个Tablet的集合,而每个Tablet包含了某个范围内的行的所有相关数据。初始状态下,一个表只有一个Tablet。随着表中数据的增长,它被自动分割成多个Tablet,缺省情况下,每个Tablet的尺寸大约是100MB到200MB。
5.1Tablet的位置
我们使用一个三层的、类似B+树[10]的结构存储Tablet的位置信息(如图4)。
第一层是一个存储在Chubby中的文件,它包含了Root Tablet的位置信息。Root Tablet包含了一个特殊的METADATA表里所有的Tablet的位置信息。METADATA表的每个Tablet包含了一个用户Tablet的集合。RootTablet实际上是METADATA表的第一个Tablet,只不过对它的处理比较特殊 — Root Tablet永远不会被分割 — 这就保证了Tablet的位置信息存储结构不会超过三层。
在METADATA表里面,每个Tablet的位置信息都存放在一个行关键字下面,而这个行关键字是由Tablet所在的表的标识符和Tablet的最后一行编码而成的。METADATA的每一行都存储了大约1KB的内存数据。在一个大小适中的、容量限制为128MB的METADATA Tablet中,采用这种三层结构的存储模式,可以标识2^34个Tablet的地址(如果每个Tablet存储128MB数据,那么一共可以存储2^61字节数据)。
客户程序使用的库会缓存Tablet的位置信息。如果客户程序没有缓存某个Tablet的地址信息,或者发现它缓存的地址信息不正确,客户程序就在树状的存储结构中递归的查询Tablet位置信息;如果客户端缓存是空的,那么寻址算法需要通过三次网络来回通信寻址,这其中包括了一次Chubby读操作;如果客户端缓存的地址信息过期了,那么寻址算法可能需要最多6次网络来回通信才能更新数据,因为只有在缓存中没有查到数据的时候才能发现数据过期(alex注:其中的三次通信发现缓存过期,另外三次更新缓存数据)(假设METADATA的Tablet没有被频繁的移动)。尽管Tablet的地址信息是存放在内存里的,对它的操作不必访问GFS文件系统,但是,通常我们会通过预取Tablet地址来进一步的减少访问的开销:每次需要从METADATA表中读取一个Tablet的元数据的时候,它都会多读取几个Tablet的元数据。
在METADATA表中还存储了次级信息(alex注:secondary information),包括每个Tablet的事件日志(例如,什么时候一个服务器开始为该Tablet提供服务)。这些信息有助于排查错误和性能分析。
5.2Tablet分配
在任何一个时刻,一个Tablet只能分配给一个Tablet服务器。Master服务器记录了当前有哪些活跃的Tablet服务器、哪些Tablet分配给了哪些Tablet服务器、哪些Tablet还没有被分配。当一个Tablet还没有被分配、并且刚好有一个Tablet服务器有足够的空闲空间装载该Tablet时,Master服务器会给这个Tablet服务器发送一个装载请求,把Tablet分配给这个服务器。
BigTable使用Chubby跟踪记录Tablet服务器的状态。当一个Tablet服务器启动时,它在Chubby的一个指定目录下建立一个有唯一性名字的文件,并且获取该文件的独占锁。Master服务器实时监控着这个目录(服务器目录),因此Master服务器能够知道有新的Tablet服务器加入了。如果Tablet服务器丢失了Chubby上的独占锁 — 比如由于网络断开导致Tablet服务器和Chubby的会话丢失 — 它就停止对Tablet提供服务。(Chubby提供了一种高效的机制,利用这种机制,Tablet服务器能够在不增加网络负担的情况下知道它是否还持有锁)。只要文件还存在,Tablet服务器就会试图重新获得对该文件的独占锁;如果文件不存在了,那么Tablet服务器就不能再提供服务了,它会自行退出(alex注:so it killsitself)。当Tablet服务器终止时(比如,集群的管理系统将运行该Tablet服务器的主机从集群中移除),它会尝试释放它持有的文件锁,这样一来,Master服务器就能尽快把Tablet分配到其它的Tablet服务器。
Master服务器负责检查一个Tablet服务器是否已经不再为它的Tablet提供服务了,并且要尽快重新分配它加载的Tablet。Master服务器通过轮询Tablet服务器文件锁的状态来检测何时Tablet服务器不再为Tablet提供服务。如果一个Tablet服务器报告它丢失了文件锁,或者Master服务器最近几次尝试和它通信都没有得到响应,Master服务器就会尝试获取该Tablet服务器文件的独占锁;如果Master服务器成功获取了独占锁,那么就说明Chubby是正常运行的,而Tablet服务器要么是宕机了、要么是不能和Chubby通信了,因此,Master服务器就删除该Tablet服务器在Chubby上的服务器文件以确保它不再给Tablet提供服务。一旦Tablet服务器在Chubby上的服务器文件被删除了,Master服务器就把之前分配给它的所有的Tablet放入未分配的Tablet集合中。为了确保Bigtable集群在Master服务器和Chubby之间网络出现故障的时候仍然可以使用,Master服务器在它的Chubby会话过期后主动退出。但是不管怎样,如同我们前面所描述的,Master服务器的故障不会改变现有Tablet在Tablet服务器上的分配状态。
当集群管理系统启动了一个Master服务器之后,Master服务器首先要了解当前Tablet的分配状态,之后才能够修改分配状态。Master服务器在启动的时候执行以下步骤:(1)Master服务器从Chubby获取一个唯一的Master锁,用来阻止创建其它的Master服务器实例;(2)Master服务器扫描Chubby的服务器文件锁存储目录,获取当前正在运行的服务器列表;(3)Master服务器和所有的正在运行的Tablet表服务器通信,获取每个Tablet服务器上Tablet的分配信息;(4)Master服务器扫描METADATA表获取所有的Tablet的集合。在扫描的过程中,当Master服务器发现了一个还没有分配的Tablet,Master服务器就将这个Tablet加入未分配的Tablet集合等待合适的时机分配。
可能会遇到一种复杂的情况:在METADATA表的Tablet还没有被分配之前是不能够扫描它的。因此,在开始扫描之前(步骤4),如果在第三步的扫描过程中发现Root Tablet还没有分配,Master服务器就把Root Tablet加入到未分配的Tablet集合。这个附加操作确保了Root Tablet会被分配。由于Root Tablet包括了所有METADATA的Tablet的名字,因此Master服务器扫描完Root Tablet以后,就得到了所有的METADATA表的Tablet的名字了。
保存现有Tablet的集合只有在以下事件发生时才会改变:建立了一个新表或者删除了一个旧表、两个Tablet被合并了、或者一个Tablet被分割成两个小的Tablet。Master服务器可以跟踪记录所有这些事件,因为除了最后一个事件外的两个事件都是由它启动的。Tablet分割事件需要特殊处理,因为它是由Tablet服务器启动。在分割操作完成之后,Tablet服务器通过在METADATA表中记录新的Tablet的信息来提交这个操作;当分割操作提交之后,Tablet服务器会通知Master服务器。如果分割操作已提交的信息没有通知到Master服务器(可能两个服务器中有一个宕机了),Master服务器在要求Tablet服务器装载已经被分割的子表的时候会发现一个新的Tablet。通过对比METADATA表中Tablet的信息,Tablet服务器会发现Master服务器要求其装载的Tablet并不完整,因此,Tablet服务器会重新向Master服务器发送通知信息。
5.3Tablet服务
如图5所示,Tablet的持久化状态信息保存在GFS上。更新操作提交到REDO日志中(alex注:Updates are committed to a commit log that stores redo records)。在这些更新操作中,最近提交的那些存放在一个排序的缓存中,我们称这个缓存为memtable;较早的更新存放在一系列SSTable中。为了恢复一个Tablet,Tablet服务器首先从METADATA表中读取它的元数据。Tablet的元数据包含了组成这个Tablet的SSTable的列表,以及一系列的Redo Point(alex注:a set of redo points),这些Redo Point指向可能含有该Tablet数据的已提交的日志记录。Tablet服务器把SSTable的索引读进内存,之后通过重复Redo Point之后提交的更新来重建memtable。
当对Tablet服务器进行写操作时,Tablet服务器首先要检查这个操作格式是否正确、操作发起者是否有执行这个操作的权限。权限验证的方法是通过从一个Chubby文件里读取出来的具有写权限的操作者列表来进行验证(这个文件几乎一定会存放在Chubby客户缓存里)。成功的修改操作会记录在提交日志里。可以采用批量提交方式(alex注:group commit)来提高包含大量小的修改操作的应用程序的吞吐量【13,16】。当一个写操作提交后,写的内容插入到memtable里面。
当对Tablet服务器进行读操作时,Tablet服务器会作类似的完整性和权限检查。一个有效的读操作在一个由一系列SSTable和memtable合并的视图里执行。由于SSTable和memtable是按字典排序的数据结构,因此可以高效生成合并视图。
当进行Tablet的合并和分割时,正在进行的读写操作能够继续进行。
5.4Compactions
(alex注:这个词挺简单,但是在这节里面挺难翻译的。应该是空间缩减的意思,但是似乎又不能完全概括它在上下文中的意思,干脆,不翻译了)
随着写操作的执行,memtable的大小不断增加。当memtable的尺寸到达一个门限值的时候,这个memtable就会被冻结,然后创建一个新的memtable;被冻结住memtable会被转换成SSTable,然后写入GFS(alex注:我们称这种Compaction行为为Minor Compaction)。MinorCompaction过程有两个目的:shrink(alex注:shrink是数据库用语,表示空间收缩)Tablet服务器使用的内存,以及在服务器灾难恢复过程中,减少必须从提交日志里读取的数据量。在Compaction过程中,正在进行的读写操作仍能继续。
每一次Minor Compaction都会创建一个新的SSTable。如果Minor Compaction过程不停滞的持续进行下去,读操作可能需要合并来自多个SSTable的更新;否则,我们通过定期在后台执行Merging Compaction过程合并文件,限制这类文件的数量。Merging Compaction过程读取一些SSTable和memtable的内容,合并成一个新的SSTable。只要Merging Compaction过程完成了,输入的这些SSTable和memtable就可以删除了。
合并所有的SSTable并生成一个新的SSTable的Merging Compaction过程叫作Major Compaction。由非Major Compaction产生的SSTable可能含有特殊的删除条目,这些删除条目能够隐藏在旧的、但是依然有效的SSTable中已经删除的数据(alex注:令人费解啊,原文是SSTables produced by non-major compactions can contain specialdeletion entries that suppress deleted data in older SSTables that are stilllive)。而MajorCompaction过程生成的SSTable不包含已经删除的信息或数据。Bigtable循环扫描它所有的Tablet,并且定期对它们执行Major Compaction。Major Compaction机制允许Bigtable回收已经删除的数据占有的资源,并且确保BigTable能及时清除已经删除的数据(alex注:实际是回收资源。数据删除后,它占有的空间并不能马上重复利用;只有空间回收后才能重复使用),这对存放敏感数据的服务是非常重要。
6 优化
上一章我们描述了Bigtable的实现,我们还需要很多优化工作才能使Bigtable到达用户要求的高性能、高可用性和高可靠性。本章描述了Bigtable实现的其它部分,为了更好的强调这些优化工作,我们将深入细节。
局部性群组
客户程序可以将多个列族组合成一个局部性群族。对Tablet中的每个局部性群组都会生成一个单独的SSTable。将通常不会一起访问的列族分割成不同的局部性群组可以提高读取操作的效率。例如,在Webtable表中,网页的元数据(比如语言和Checksum)可以在一个局部性群组中,网页的内容可以在另外一个群组:当一个应用程序要读取网页的元数据的时候,它没有必要去读取所有的页面内容。
此外,可以以局部性群组为单位设定一些有用的调试参数。比如,可以把一个局部性群组设定为全部存储在内存中。Tablet服务器依照惰性加载的策略将设定为放入内存的局部性群组的SSTable装载进内存。加载完成之后,访问属于该局部性群组的列族的时候就不必读取硬盘了。这个特性对于需要频繁访问的小块数据特别有用:在Bigtable内部,我们利用这个特性提高METADATA表中具有位置相关性的列族的访问速度。
压缩
客户程序可以控制一个局部性群组的SSTable是否需要压缩;如果需要压缩,那么以什么格式来压缩。每个SSTable的块(块的大小由局部性群组的优化参数指定)都使用用户指定的压缩格式来压缩。虽然分块压缩浪费了少量空间(alex注:相比于对整个SSTable进行压缩,分块压缩压缩率较低),但是,我们在只读取SSTable的一小部分数据的时候就不必解压整个文件了。很多客户程序使用了“两遍”的、可定制的压缩方式。第一遍采用Bentleyand McIlroy’s方式[6],这种方式在一个很大的扫描窗口里对常见的长字符串进行压缩;第二遍是采用快速压缩算法,即在一个16KB的小扫描窗口中寻找重复数据。两个压缩的算法都很快,在现在的机器上,压缩的速率达到100-200MB/s,解压的速率达到400-1000MB/s。
虽然我们在选择压缩算法的时候重点考虑的是速度而不是压缩的空间,但是这种两遍的压缩方式在空间压缩率上的表现也是令人惊叹。比如,在Webtable的例子里,我们使用这种压缩方式来存储网页内容。在一次测试中,我们在一个压缩的局部性群组中存储了大量的网页。针对实验的目的,我们没有存储每个文档所有版本的数据,我们仅仅存储了一个版本的数据。该模式的空间压缩比达到了10:1。这比传统的Gzip在压缩HTML页面时3:1或者4:1的空间压缩比好的多;“两遍”的压缩模式如此高效的原因是由于Webtable的行的存放方式:从同一个主机获取的页面都存在临近的地方。利用这个特性,Bentley-McIlroy算法可以从来自同一个主机的页面里找到大量的重复内容。不仅仅是Webtable,其它的很多应用程序也通过选择合适的行名来将相似的数据聚簇在一起,以获取较高的压缩率。当我们在Bigtable中存储同一份数据的多个版本的时候,压缩效率会更高。
通过缓存提高读操作的性能
为了提高读操作的性能,Tablet服务器使用二级缓存的策略。扫描缓存是第一级缓存,主要缓存Tablet服务器通过SSTable接口获取的Key-Value对;Block缓存是二级缓存,缓存的是从GFS读取的SSTable的Block。对于经常要重复读取相同数据的应用程序来说,扫描缓存非常有效;对于经常要读取刚刚读过的数据附近的数据的应用程序来说,Block缓存更有用(例如,顺序读,或者在一个热点的行的局部性群组中随机读取不同的列)。
Bloom过滤器
(alex注:Bloom,又叫布隆过滤器,什么意思?请参考Google黑板报http://googlechinablog.com/2007/07/bloom-filter.html请务必先认真阅读)
如5.3节所述,一个读操作必须读取构成Tablet状态的所有SSTable的数据。如果这些SSTable不在内存中,那么就需要多次访问硬盘。我们通过允许客户程序对特定局部性群组的SSTable指定Bloom过滤器【7】,来减少硬盘访问的次数。我们可以使用Bloom过滤器查询一个SSTable是否包含了特定行和列的数据。对于某些特定应用程序,我们只付出了少量的、用于存储Bloom过滤器的内存的代价,就换来了读操作显著减少的磁盘访问的次数。使用Bloom过滤器也隐式的达到了当应用程序访问不存在的行或列时,大多数时候我们都不需要访问硬盘的目的。
Commit日志的实现
如果我们把对每个Tablet的操作的Commit日志都存在一个单独的文件的话,那么就会产生大量的文件,并且这些文件会并行的写入GFS。根据GFS服务器底层文件系统实现的方案,要把这些文件写入不同的磁盘日志文件时(alex注:differentphysicallog files),会有大量的磁盘Seek操作。另外,由于批量提交(alex注:groupcommit)中操作的数目一般比较少,因此,对每个Tablet设置单独的日志文件也会给批量提交本应具有的优化效果带来很大的负面影响。为了避免这些问题,我们设置每个Tablet服务器一个Commit日志文件,把修改操作的日志以追加方式写入同一个日志文件,因此一个实际的日志文件中混合了对多个Tablet修改的日志记录。
使用单个日志显著提高了普通操作的性能,但是将恢复的工作复杂化了。当一个Tablet服务器宕机时,它加载的Tablet将会被移到很多其它的Tablet服务器上:每个Tablet服务器都装载很少的几个原来的服务器的Tablet。当恢复一个Tablet的状态的时候,新的Tablet服务器要从原来的Tablet服务器写的日志中提取修改操作的信息,并重新执行。然而,这些Tablet修改操作的日志记录都混合在同一个日志文件中的。一种方法新的Tablet服务器读取完整的Commit日志文件,然后只重复执行它需要恢复的Tablet的相关修改操作。使用这种方法,假如有100台Tablet服务器,每台都加载了失效的Tablet服务器上的一个Tablet,那么,这个日志文件就要被读取100次(每个服务器读取一次)。
为了避免多次读取日志文件,我们首先把日志按照关键字(table,row name,log sequence number)排序。排序之后,对同一个Tablet的修改操作的日志记录就连续存放在了一起,因此,我们只要一次磁盘Seek操作、之后顺序读取就可以了。为了并行排序,我们先将日志分割成64MB的段,之后在不同的Tablet服务器对段进行并行排序。这个排序工作由Master服务器来协同处理,并且在一个Tablet服务器表明自己需要从Commit日志文件恢复Tablet时开始执行。
在向GFS中写Commit日志的时候可能会引起系统颠簸,原因是多种多样的(比如,写操作正在进行的时候,一个GFS服务器宕机了;或者连接三个GFS副本所在的服务器的网络拥塞或者过载了)。为了确保在GFS负载高峰时修改操作还能顺利进行,每个Tablet服务器实际上有两个日志写入线程,每个线程都写自己的日志文件,并且在任何时刻,只有一个线程是工作的。如果一个线程的在写入的时候效率很低,Tablet服务器就切换到另外一个线程,修改操作的日志记录就写入到这个线程对应的日志文件中。每个日志记录都有一个序列号,因此,在恢复的时候,Tablet服务器能够检测出并忽略掉那些由于线程切换而导致的重复的记录。
Tablet恢复提速
当Master服务器将一个Tablet从一个Tablet服务器移到另外一个Tablet服务器时,源Tablet服务器会对这个Tablet做一次Minor Compaction。这个Compaction操作减少了Tablet服务器的日志文件中没有归并的记录,从而减少了恢复的时间。Compaction完成之后,该服务器就停止为该Tablet提供服务。在卸载Tablet之前,源Tablet服务器还会再做一次(通常会很快)Minor Compaction,以消除前面在一次压缩过程中又产生的未归并的记录。第二次Minor Compaction完成以后,Tablet就可以被装载到新的Tablet服务器上了,并且不需要从日志中进行恢复。
利用不变性
我们在使用Bigtable时,除了SSTable缓存之外的其它部分产生的SSTable都是不变的,我们可以利用这一点对系统进行简化。例如,当从SSTable读取数据的时候,我们不必对文件系统访问操作进行同步。这样一来,就可以非常高效的实现对行的并行操作。memtable是唯一一个能被读和写操作同时访问的可变数据结构。为了减少在读操作时的竞争,我们对内存表采用COW(Copy-on-write)机制,这样就允许读写操作并行执行。
因为SSTable是不变的,因此,我们可以把永久删除被标记为“删除”的数据的问题,转换成对废弃的SSTable进行垃圾收集的问题了。每个Tablet的SSTable都在METADATA表中注册了。Master服务器采用“标记-删除”的垃圾回收方式删除SSTable集合中废弃的SSTable【25】,METADATA表则保存了Root SSTable的集合。
最后,SSTable的不变性使得分割Tablet的操作非常快捷。我们不必为每个分割出来的Tablet建立新的SSTable集合,而是共享原来的Tablet的SSTable集合。
7 性能评估
为了测试Bigtable的性能和可扩展性,我们建立了一个包括N台Tablet服务器的Bigtable集群,这里N是可变的。每台Tablet服务器配置了1GB的内存,数据写入到一个包括1786台机器、每台机器有2个IDE硬盘的GFS集群上。我们使用N台客户机生成工作负载测试Bigtable。(我们使用和Tablet服务器相同数目的客户机以确保客户机不会成为瓶颈。)每台客户机配置2GZ双核Opteron处理器,配置了足以容纳所有进程工作数据集的物理内存,以及一张Gigabit的以太网卡。这些机器都连入一个两层的、树状的交换网络里,在根节点上的带宽加起来有大约100-200Gbps。所有的机器采用相同的设备,因此,任何两台机器间网络来回一次的时间都小于1ms。
Tablet服务器、Master服务器、测试机、以及GFS服务器都运行在同一组机器上。每台机器都运行一个GFS的服务器。其它的机器要么运行Tablet服务器、要么运行客户程序、要么运行在测试过程中,使用这组机器的其它的任务启动的进程。
R是测试过程中,Bigtable包含的不同的列关键字的数量。我们精心选择R的值,保证每次基准测试对每台Tablet服务器读/写的数据量都在1GB左右。
在序列写的基准测试中,我们使用的列关键字的范围是0到R-1。这个范围又被划分为10N个大小相同的区间。核心调度程序把这些区间分配给N个客户端,分配方式是:只要客户程序处理完上一个区间的数据,调度程序就把后续的、尚未处理的区间分配给它。这种动态分配的方式有助于减少客户机上同时运行的其它进程对性能的影响。我们在每个列关键字下写入一个单独的字符串。每个字符串都是随机生成的、因此也没有被压缩(alex注:参考第6节的压缩小节)。另外,不同列关键字下的字符串也是不同的,因此也就不存在跨行的压缩。随机写入基准测试采用类似的方法,除了行关键字在写入前先做Hash,Hash采用按R取模的方式,这样就保证了在整个基准测试持续的时间内,写入的工作负载均匀的分布在列存储空间内。
序列读的基准测试生成列关键字的方式与序列写相同,不同于序列写在列关键字下写入字符串的是,序列读是读取列关键字下的字符串(这些字符串由之前序列写基准测试程序写入)。同样的,随机读的基准测试和随机写是类似的。
扫描基准测试和序列读类似,但是使用的是BigTable提供的、从一个列范围内扫描所有的value值的API。由于一次RPC调用就从一个Tablet服务器取回了大量的Value值,因此,使用扫描方式的基准测试程序可以减少RPC调用的次数。
随机读(内存)基准测试和随机读类似,除了包含基准测试数据的局部性群组被设置为“in-memory”,因此,读操作直接从Tablet服务器的内存中读取数据,不需要从GFS读取数据。针对这个测试,我们把每台Tablet服务器存储的数据从1GB减少到100MB,这样就可以把数据全部加载到Tablet服务器的内存中了。
图6中有两个视图,显示了我们的基准测试的性能;图中的数据和曲线是读/写 1000-byte value值时取得的。图中的表格显示了每个Tablet服务器每秒钟进行的操作的次数;图中的曲线显示了每秒种所有的Tablet服务器上操作次数的总和。
单个Tablet服务器的性能
我们首先分析下单个Tablet服务器的性能。随机读的性能比其它操作慢一个数量级或以上(alex注:by the order of magnitude or more) 。 每个随机读操作都要通过网络从GFS传输64KB的SSTable到Tablet服务器,而我们只使用其中大小是1000 byte的一个value值。Tablet服务器每秒大约执行1200次读操作,也就是每秒大约从GFS读取75MB的数据。这个传输带宽足以占满Tablet服务器的CPU时间,因为其中包括了网络协议栈的消耗、SSTable解析、以及BigTable代码执行;这个带宽也足以占满我们系统中网络的链接带宽。大多数采用这种访问模式BigTable应用程序会减小Block的大小,通常会减到8KB。
内存中的随机读操作速度快很多,原因是,所有1000-byte的读操作都是从Tablet服务器的本地内存中读取数据,不需要从GFS读取64KB的Block。
随机和序列写操作的性能比随机读要好些,原因是每个Tablet服务器直接把写入操作的内容追加到一个Commit日志文件的尾部,并且采用批量提交的方式,通过把数据以流的方式写入到GFS来提高性能。随机写和序列写在性能上没有太大的差异,这两种方式的写操作实际上都是把操作内容记录到同一个Tablet服务器的Commit日志文件中。
序列读的性能好于随机读,因为每取出64KB的SSTable的Block后,这些数据会缓存到Block缓存中,后续的64次读操作直接从缓存读取数据。
扫描的性能更高,这是由于客户程序每一次RPC调用都会返回大量的value的数据,所以,RPC调用的消耗基本抵消了。
性能提升
随着我们将系统中的Tablet服务器从1台增加到500台,系统的整体吞吐量有了梦幻般的增长,增长的倍率超过了100。比如,随着Tablet服务器的数量增加了500倍,内存中的随机读操作的性能增加了300倍。之所以会有这样的性能提升,主要是因为这个基准测试的瓶颈是单台Tablet服务器的CPU。
尽管如此,性能的提升还不是线性的。在大多数的基准测试中我们看到,当Tablet服务器的数量从1台增加到50台时,每台服务器的吞吐量会有一个明显的下降。这是由于多台服务器间的负载不均衡造成的,大多数情况下是由于其它的程序抢占了CPU。我们负载均衡的算法会尽量避免这种不均衡,但是基于两个主要原因,这个算法并不能完美的工作:一个是尽量减少Tablet的移动导致重新负载均衡能力受限(如果Tablet被移动了,那么在短时间内 — 一般是1秒内 — 这个Tablet是不可用的),另一个是我们的基准测试程序产生的负载会有波动(alex注:the load generated by our benchmarks shifts around as the benchmarkprogresses)。
随机读基准测试的测试结果显示,随机读的性能随Tablet服务器数量增加的提升幅度最小(整体吞吐量只提升了100倍,而服务器的数量却增加了500倍)。这是因为每个1000-byte的读操作都会导致一个64KB大的Block在网络上传输。这样的网络传输量消耗了我们网络中各种共享的1GB的链路,结果导致随着我们增加服务器的数量,每台服务器上的吞吐量急剧下降。
8 实际应用
截止到2006年8月,Google内部一共有388个非测试用的Bigtable集群运行在各种各样的服务器集群上,合计大约有24500个Tablet服务器。表1显示了每个集群上Tablet服务器的大致分布情况。这些集群中,许多用于开发目的,因此会有一段时期比较空闲。通过观察一个由14个集群、8069个Tablet服务器组成的集群组,我们看到整体的吞吐量超过了每秒1200000次请求,发送到系统的RPC请求导致的网络负载达到了741MB/s,系统发出的RPC请求网络负载大约是16GB/s。
表2提供了一些目前正在使用的表的相关数据。一些表存储的是用户相关的数据,另外一些存储的则是用于批处理的数据;这些表在总的大小、每个数据项的平均大小、从内存中读取的数据的比例、表的Schema的复杂程度上都有很大的差别。本节的其余部分,我们将主要描述三个产品研发团队如何使用Bigtable的。
8.1Google Analytics
Google Analytics是用来帮助Web站点的管理员分析他们网站的流量模式的服务。它提供了整体状况的统计数据,比如每天的独立访问的用户数量、每天每个URL的浏览次数;它还提供了用户使用网站的行为报告,比如根据用户之前访问的某些页面,统计出几成的用户购买了商品。
为了使用这个服务,Web站点的管理员只需要在他们的Web页面中嵌入一小段JavaScript脚本就可以了。这个Javascript程序在页面被访问的时候调用。它记录了各种Google Analytics需要使用的信息,比如用户的标识、获取的网页的相关信息。Google Analytics汇总这些数据,之后提供给Web站点的管理员。
我们粗略的描述一下Google Analytics使用的两个表。Row Click表(大约有200TB数据)的每一行存放了一个最终用户的会话。行的名字是一个包含Web站点名字以及用户会话创建时间的元组。这种模式保证了对同一个Web站点的访问会话是顺序的,会话按时间顺序存储。这个表可以压缩到原来尺寸的14%。
Summary表(大约有20TB的数据)包含了关于每个Web站点的、各种类型的预定义汇总信息。一个周期性运行的MapReduce任务根据Raw Click表的数据生成Summary表的数据。每个MapReduce工作进程都从Raw Click表中提取最新的会话数据。系统的整体吞吐量受限于GFS的吞吐量。这个表的能够压缩到原有尺寸的29%。
8.2Google Earth
Google通过一组服务为用户提供了高分辨率的地球表面卫星图像,访问的方式可以使通过基于Web的Google Maps访问接口(maps.google.com),也可以通过Google Earth定制的客户端软件访问。这些软件产品允许用户浏览地球表面的图像:用户可以在不同的分辨率下平移、查看和注释这些卫星图像。这个系统使用一个表存储预处理数据,使用另外一组表存储用户数据。
数据预处理流水线使用一个表存储原始图像。在预处理过程中,图像被清除,图像数据合并到最终的服务数据中。这个表包含了大约70TB的数据,所以需要从磁盘读取数据。图像已经被高效压缩过了,因此存储在Bigtable后不需要再压缩了。
Imagery表的每一行都代表了一个单独的地理区域。行都有名称,以确保毗邻的区域存储在了一起。Imagery表中有一个列族用来记录每个区域的数据源。这个列族包含了大量的列:基本上市每个列对应一个原始图片的数据。由于每个地理区域都是由很少的几张图片构成的,因此这个列族是非常稀疏的。
数据预处理流水线高度依赖运行在Bigtable上的MapReduce任务传输数据。在运行某些MapReduce任务的时候,整个系统中每台Tablet服务器的数据处理速度是1MB/s。
这个服务系统使用一个表来索引GFS中的数据。这个表相对较小(大约是500GB),但是这个表必须在保证较低的响应延时的前提下,针对每个数据中心,每秒处理几万个查询请求。因此,这个表必须在上百个Tablet服务器上存储数据,并且使用in-memory的列族。
8.3 个性化查询
个性化查询(www.google.com/psearch)是一个双向服务;这个服务记录用户的查询和点击,涉及到各种Google的服务,比如Web查询、图像和新闻。用户可以浏览他们查询的历史,重复他们之前的查询和点击;用户也可以定制基于Google历史使用习惯模式的个性化查询结果。
个性化查询使用Bigtable存储每个用户的数据。每个用户都有一个唯一的用户id,每个用户id和一个列名绑定。一个单独的列族被用来存储各种类型的行为(比如,有个列族可能是用来存储所有的Web查询的)。每个数据项都被用作Bigtable的时间戳,记录了相应的用户行为发生的时间。个性化查询使用以Bigtable为存储的MapReduce任务生成用户的数据图表。这些用户数据图表用来个性化当前的查询结果。
个性化查询的数据会复制到几个Bigtable的集群上,这样就增强了数据可用性,同时减少了由客户端和Bigtable集群间的“距离”造成的延时。个性化查询的开发团队最初建立了一个基于Bigtable的、“客户侧”的复制机制为所有的复制节点提供一致性保障。现在的系统则使用了内建的复制子系统。
个性化查询存储系统的设计允许其它的团队在它们自己的列中加入新的用户数据,因此,很多Google服务使用个性化查询存储系统保存用户级的配置参数和设置。在多个团队之间分享数据的结果是产生了大量的列族。为了更好的支持数据共享,我们加入了一个简单的配额机制(alex注:quota,参考AIX的配额机制)限制用户在共享表中使用的空间;配额也为使用个性化查询系统存储用户级信息的产品团体提供了隔离机制。
9 经验教训
在设计、实现、维护和支持Bigtable的过程中,我们得到了很多有用的经验和一些有趣的教训。
一个教训是,我们发现,很多类型的错误都会导致大型分布式系统受损,这些错误不仅仅是通常的网络中断、或者很多分布式协议中设想的fail-stop类型的错误(alex注:fail-stop failture,指一旦系统fail就stop,不输出任何数据;fail-fastfailture,指fail不马上stop,在短时间内return错误信息,然后再stop)。比如,我们遇到过下面这些类型的错误导致的问题:内存数据损坏、网络中断、时钟偏差、机器挂起、扩展的和非对称的网络分区(alex注:extended and asymmetric network partitions,不明白什么意思。partition也有中断的意思,但是我不知道如何用在这里)、我们使用的其它系统的Bug(比如Chubby)、GFS配额溢出、计划内和计划外的硬件维护。我们在解决这些问题的过程中学到了很多经验,我们通过修改协议来解决这些问题。比如,我们在我们的RPC机制中加入了Checksum。我们在设计系统的部分功能时,不对其它部分功能做任何的假设,这样的做法解决了其它的一些问题。比如,我们不再假设一个特定的Chubby操作只返回错误码集合中的一个值。
另外一个教训是,我们明白了在彻底了解一个新特性会被如何使用之后,再决定是否添加这个新特性是非常重要的。比如,我们开始计划在我们的API中支持通常方式的事务处理。但是由于我们还不会马上用到这个功能,因此,我们并没有去实现它。现在,Bigtable上已经有了很多的实际应用,我们可以检查它们真实的需求;我们发现,大多是应用程序都只是需要单个行上的事务功能。有些应用需要分布式的事务功能,分布式事务大多数情况下用于维护二级索引,因此我们增加了一个特殊的机制去满足这个需求。新的机制在通用性上比分布式事务差很多,但是它更有效(特别是在更新操作的涉及上百行数据的时候),而且非常符合我们的“跨数据中心”复制方案的优化策略。
还有一个具有实践意义的经验:我们发现系统级的监控对Bigtable非常重要(比如,监控Bigtable自身以及使用Bigtable的客户程序)。比如,我们扩展了我们的RPC系统,因此对于一个RPC调用的例子,它可以详细记录代表了RPC调用的很多重要操作。这个特性允许我们检测和修正很多的问题,比如Tablet数据结构上的锁的内容、在修改操作提交时对GFS的写入非常慢的问题、以及在METADATA表的Tablet不可用时,对METADATA表的访问挂起的问题。关于监控的用途的另外一个例子是,每个Bigtable集群都在Chubby中注册了。这可以帮助我们跟踪所有的集群状态、监控它们的大小、检查集群运行的我们软件的版本、监控集群流入数据的流量,以及检查是否有引发集群高延时的潜在因素。
对我们来说,最宝贵的经验是简单设计的价值。考虑到我们系统的代码量(大约100000行生产代码(alex注:non-test code)),以及随着时间的推移,新的代码以各种难以预料的方式加入系统,我们发现简洁的设计和编码给维护和调试带来的巨大好处。这方面的一个例子是我们的Tablet服务器成员协议。我们第一版的协议很简单:Master服务器周期性的和Tablet服务器签订租约,Tablet服务器在租约过期的时候Kill掉自己的进程。不幸的是,这个协议在遇到网络问题时会大大降低系统的可用性,也会大大增加Master服务器恢复的时间。我们多次重新设计这个协议,直到它能够很好的处理上述问题。但是,更不幸的是,最终的协议过于复杂了,并且依赖一些Chubby很少被用到的特性。我们发现我们浪费了大量的时间在调试一些古怪的问题(alex注:obscure corner cases),有些是Bigtable代码的问题,有些事Chubby代码的问题。最后,我们只好废弃了这个协议,重新制订了一个新的、更简单、只使用Chubby最广泛使用的特性的协议。
10 相关工作
Boxwood【24】项目的有些组件在某些方面和Chubby、GFS以及Bigtable类似,因为它也提供了诸如分布式协议、锁、分布式Chunk存储以及分布式B-tree存储。Boxwood与Google的某些组件尽管功能类似,但是Boxwood的组件提供更底层的服务。Boxwood项目的目的是提供创建类似文件系统、数据库等高级服务的基础构件,而Bigtable的目的是直接为客户程序的数据存储需求提供支持。
现在有不少项目已经攻克了很多难题,实现了在广域网上的分布式数据存储或者高级服务,通常是“Internet规模”的。这其中包括了分布式的Hash表,这项工作由一些类似CAN【29】、Chord【32】、Tapestry【37】和Pastry【30】的项目率先发起。这些系统的主要关注点和Bigtable不同,比如应对各种不同的传输带宽、不可信的协作者、频繁的更改配置等;另外,去中心化和Byzantine灾难冗余(alex注:Byzantine,即拜占庭式的风格,也就是一种复杂诡秘的风格。Byzantine Fault表示:对于处理来说,当发错误时处理器并不停止接收输出,也不停止输出,错就错了,只管算,对于这种错误来说,这样可真是够麻烦了,因为用户根本不知道错误发生了,也就根本谈不上处理错误了。在多处理器的情况下,这种错误可能导致运算正确结果的处理器也产生错误的结果,这样事情就更麻烦了,所以一定要避免处理器产生这种错误。)也不是Bigtable的目的。
就提供给应用程序开发者的分布式数据存储模型而言,我们相信,分布式B-Tree或者分布式Hash表提供的Key-value pair方式的模型有很大的局限性。Key-value pair模型是很有用的组件,但是它们不应该是提供给开发者唯一的组件。我们选择的模型提供的组件比简单的Key-value pair丰富的多,它支持稀疏的、半结构化的数据。另外,它也足够简单,能够高效的处理平面文件;它也是透明的(通过局部性群组),允许我们的使用者对系统的重要行为进行调整。
有些数据库厂商已经开发出了并行的数据库系统,能够存储海量的数据。Oracle的RAC【27】使用共享磁盘存储数据(Bigtable使用GFS),并且有一个分布式的锁管理系统(Bigtable使用Chubby)。IBM并行版本的DB2【4】基于一种类似于Bigtable的、不共享任何东西的架构(a shared-nothing architecture)【33】。每个DB2的服务器都负责处理存储在一个关系型数据库中的表中的行的一个子集。这些产品都提供了一个带有事务功能的完整的关系模型。
Bigtable的局部性群组提供了类似于基于列的存储方案在压缩和磁盘读取方面具有的性能;这些以列而不是行的方式组织数据的方案包括C-Store【1,34】、商业产品Sybase IQ【15,36】、SenSage【31】、KDB+【22】,以及MonetDB/X100【38】的ColumnDM存储层。另外一种在平面文件中提供垂直和水平数据分区、并且提供很好的数据压缩率的系统是AT&T的Daytona数据库【19】。局部性群组不支持Ailamaki系统中描述的CPU缓存级别的优化【2】。
Bigtable采用memtable和SSTable存储对表的更新的方法与Log-StructuredMerge Tree【26】存储索引数据更新的方法类似。这两个系统中,排序的数据在写入到磁盘前都先存放在内存中,读取操作必须从内存和磁盘中合并数据产生最终的结果集。
C-Store和Bigtable有很多相似点:两个系统都采用Shared-nothing架构,都有两种不同的数据结构,一种用于当前的写操作,另外一种存放“长时间使用”的数据,并且提供一种机制在两个存储结构间搬运数据。两个系统在API接口函数上有很大的不同:C-Store操作更像关系型数据库,而Bigtable提供了低层次的读写操作接口,并且设计的目标是能够支持每台服务器每秒数千次操作。C-Store同时也是个“读性能优化的关系型数据库”,而Bigtable对读和写密集型应用都提供了很好的性能。
Bigtable也必须解决所有的Shared-nothing数据库需要面对的、类型相似的一些负载和内存均衡方面的难题(比如,【11,35】)。我们的问题在某种程度上简单一些:(1)我们不需要考虑同一份数据可能有多个拷贝的问题,同一份数据可能由于视图或索引的原因以不同的形式表现出来;(2)我们让用户决定哪些数据应该放在内存里、哪些放在磁盘上,而不是由系统动态的判断;(3)我们的系统中没有复杂的查询执行或优化工作。
11 结论
我们已经讲述完了Bigtable,Google的一个分布式的结构化数据存储系统。Bigtable的集群从2005年4月开始已经投入使用了,在此之前,我们花了大约7人年设计和实现这个系统。截止到2006年4月,已经有超过60个项目使用Bigtable了。我们的用户对Bigtable提供的高性能和高可用性很满意,随着时间的推移,他们可以根据自己的系统对资源的需求增加情况,通过简单的增加机器,扩展系统的承载能力。
由于Bigtable提供的编程接口并不常见,一个有趣的问题是:我们的用户适应新的接口有多难?新的使用者有时不太确定使用Bigtable接口的最佳方法,特别是在他们已经习惯于使用支持通用事务的关系型数据库的接口的情况下。但是,Google内部很多产品都成功的使用了Bigtable的事实证明了,我们的设计在实践中行之有效。
我们现在正在对Bigtable加入一些新的特性,比如支持二级索引,以及支持多Master节点的、跨数据中心复制的Bigtable的基础构件。我们现在已经开始将Bigtable部署为服务供其它的产品团队使用,这样不同的产品团队就不需要维护他们自己的Bigtable集群了。随着服务集群的扩展,我们需要在Bigtable系统内部处理更多的关于资源共享的问题了【3,5】。
最后,我们发现,建设Google自己的存储解决方案带来了很多优势。通过为Bigtable设计我们自己的数据模型,是我们的系统极具灵活性。另外,由于我们全面控制着Bigtable的实现过程,以及Bigtable使用到的其它的Google的基础构件,这就意味着我们在系统出现瓶颈或效率低下的情况时,能够快速的解决这些问题。