MIT 6.824 Distributed System 学习笔记 - GFS

859 阅读26分钟

MIT 6.824 Distributed System 学习笔记 - GFS

分布式存储系统

为什么难

  • performance -> sharding -> faults

    人们设计大型分布式系统或大型存储系统出发点通常是,他们想获取巨大的性能加成,进而利用数百台计算机的资源来同时完成大量工作。因此,性能问题就成为了最初的诉求。

    之后,很自然的想法就是将数据分割放到大量的服务器上,这样就可以并行的从多台服务器读取数据。我们将这种方式称之为分片(Sharding)。

    如果你有数千台服务器,那么经常会有一台服务器宕机,每天甚至每个小时都可能会发生错误(faults)。

  • faults -> fault tolerance -> replication -> inconsistency

    如果你有数千台服务器,那么经常会有一台服务器宕机,每天甚至每个小时都可能会发生错误。所以,我们需要一个自动的容错系统,这就引出了容错这个话题(fault tolerance)。

    实现容错最有用的一种方法是使用复制(replication),只需要维护2-3个数据的副本,当其中一个故障了,你就可以使用另一个。所以,如果想要容错能力,就得有复制(replication)。

    如果有复制,那就有了两个数据副本,你可以任意使用其中一个副本来容错。但是如果你不够小心,两个数据的副本就不是完全一致,严格来说,它们就不再互为副本了。而你获取到的数据内容也将取决于你向哪个副本请求数据。这对于应用程序来说就有些麻烦了。所以,如果我们有了复制,我们就有不一致的问题(inconsistency)。

  • inconsistency -> consistency -> low perforamce

    通过聪明的设计,你可以避免不一致的问题,并且让数据看起来也表现的符合预期。但是为了达到这样的效果,你总是需要额外的工作,需要不同服务器之间通过网络额外的交互,而这样的交互会降低性能(low performace)。所以,如果想要一致性,代价就是低性能。

总结:performance -> sharding -> faults -> fault tolerance -> replication -> inconsistency -> consistency -> low perforamce

你可以构建性能很高的系统,但是不可避免的,都会陷入到这里的循环来。现实中,如果你想要好的一致性,你就要付出相应的代价。如果你不想付出代价,那就要忍受一些不确定的行为。

我们之后会在很多系统中看到这里介绍的循环。通常,人们很少会乐意为好的一致性付出相应的性能代价(也就是会忍受一些不一致的行为)。

经典错误设计

比如有两台服务器,每个服务器都有数据的一份完整拷贝。它们在磁盘上都存储了一个key-value表单。直观上我们希望这两个表单是完全一致的,这样,一台服务器故障了,我们可以切换到另一台服务器去做读写。

两个表单完全一致意味着,每一个写请求都必须在两台服务器上执行(因为我们想要更新两台服务器上的数据),而读请求只需要在一台服务器上执行,否则就没有容错性了(因为如果读请求也需要从两台服务器读数据,那么一台服务器故障我们就没法提供服务了)。

现在问题来了,假设客户端C1和C2都想执行写请求,其中一个要写X为1,另一个写X为2。C1会将写X为1的请求发送个两个服务器,C2也会将写X为2的请求发送给两个服务器。

这里会出现什么错误呢?我们没有做任何事情来保障两台服务器以相同的顺序处理这2个请求:如果服务器1(S1)先处理C1的请求,那么在它的表单里面,X先是1,之后S1看到了来自C2的请求,会将自己表单中的X覆盖成2;但是,如果S2恰好以不同的顺序收到客户端请求,那么S2会先执行C2的请求,将X设置为2,之后收到C1的请求,将X设置为1。

image.png

之后,如果另外一些客户端,假设C3从S1读数据,C4从S2读数据,我们就会面临一个可怕的场景:这两个客户端读取的数据不一样。

这里的问题可以以另一种方式暴露出来。假设我们尝试修复上面的问题,我们让客户端在S1还在线的时候,只从S1读取数据,S1不在线了再从S2读取数据。这样最开始所有的客户端读X都能得到2。但是突然,如果S1故障了,尽管没有写请求将X改成1,客户端读X得到的数据将会从2变成1。因为S1故障之后,所有的客户端都会切换到S2去读数据。

当然,这里的问题是可以修复的,修复需要服务器之间更多的通信,并且复杂度也会提升。 由于获取强一致会带来不可避免的复杂性的提升,有大量的方法可以在好的一致性和一些小瑕疵行为之间追求一个平衡。

GFS设计目标

Google有大量的数据,需要大量的磁盘来存储这些数据,并且需要能借助MapReduce这样的工具来快速处理这些数据。所以,Google需要能够快速的并行访问这些海量数据。

big, fast, global

Google的目标是构建一个大型的,快速的文件系统,并且这个文件系统是全局有效的,这样各种不同的应用程序都可以从中读取数据。

sharding

为了获得大容量和高速的特性,每个包含了数据的文件会被GFS自动的分片(sharding)并存放在多个服务器之上,这样读写操作自然就会变得很快。因为可以从多个服务器上同时读取同一个文件,进而获得更高的聚合吞吐量。将文件分片存储还可以在存储系统中保存比单个磁盘还要大的文件。

automatic recovery

因为我们现在在数百台服务器之上构建存储系统,我们希望有自动的故障修复。 我们不希望每次服务器出了故障,派人到机房去修复服务器或者迁移数据。我们希望系统能自动修复自己。

GFS其他特征(并非设计目标)

single data center

GFS被设计成只在一个数据中心运行,并没有将副本保存在世界各地,单个GFS只存在于单个数据中心的单个机房里。理论上来说,数据的多个副本应该彼此之间隔的远一些,但是实现起来挺难的,所以GFS局限在一个数据中心内。

internal use

GFS并不面向普通的用户,这是一个Google内部使用的系统,供Google的工程师写应用程序使用。所以Google并没有售卖GFS,它或许售卖了基于GFS的服务,但是GFS并不直接面向普通用户。

big sequential access

GFS在各个方面对大型顺序文件读写做了定制。

  • 在存储系统中有一个完全不同的领域,这个领域只对小份数据进行优化。例如一个银行账户系统就需要一个能够读写100字节的数据库,因为100字节就可以表示人们的银行账户。但是GFS不是这样的系统,GFS是为TB级别的文件而生。

  • 并且GFS只会顺序处理,不支持随机访问。某种程度上来说,它有点像批处理的风格。GFS并没有花费过多的精力来降低延迟,它的关注点在于巨大的吞吐量上,所以单次操作都涉及到MB级别的数据。

GFS论文特点

它描述了一个真正运行在成百上千台计算机上的系统,这个规模远远超过了学术界建立的系统。并且由于GFS被用于工业界,它反映了现实世界的经验,例如对于一个系统来说,怎样才能正常工作,怎样才能节省成本,这些内容也极其有价值。

论文也提出了一个当时非常异类的观点:存储系统具有弱一致性也是可以的。当时,学术界的观念认为,存储系统就应该有良好的行为,如果构建了一个会返回错误数据的系统,那还有什么意义?为什么不直接构建一个能返回正确数据的系统?GFS并不保证返回正确的数据,借助于这一点,GFS的目标是提供更好的性能

在一些学术论文中,你或许可以看到一些容错的,多副本,自动修复的多个Master节点共同分担工作,但是GFS却宣称使用单个Master节点并能够很好的工作。

GFS 大致架构

单个Master节点,多个chunkservers,多个clients

Master节点用来管理文件和Chunk的信息,而Chunk服务器用来存储实际的数据。这是GFS设计中比较好的一面,它将这两类数据的管理问题几乎完全隔离开了,这两个问题可以使用独立设计来解决。

chunk

  • 文件被分割成固定大小的chunks,每个是64MB
  • 每个chunk有一个全局唯一且不变的64bit的chunk handle,这个chunk handle是在这个chunk被创建的时候,由master分配的

chunkserver

  • chunkservers以文件的形式在本地磁盘上存储chunk
  • 会根据chunk handle和byte range来读写chunk数据
  • 每个chunk在多个chunksevers上复制,默认有3个replicas

master

  • 存储所有的file system metadata,包括namespace,access control information,mapping from files to chunks,current location of chunks等等。
  • 控制着system-wide activities,比如chunk lease management,gc,和chunkservers间的chunk migration。
  • 周期性地用HeartBeat message和每个chunkserver进行交互,用来给chunkserver指令并收集chunkserver的状态。

client

  • 和master交互,用来做metadata operations
  • 和chunkserver交互,用来做data-bearing communication

image.png

Master节点中存储的metadata

  1. file 和 chunk 的namespaces

  2. file name -> array of chunk handles 的映射关系

  3. chunk handle -> chunk相关数据的映射关系

    chunk相关数据包括:

    • 这个chunk存储在哪些服务器上,即chunksevers的列表(之所以是列表是因为每个chunk有多个relicas)
    • 这个chunk当前的版本号
    • 所有对于Chunk的写操作都必须在主Chunk(Primary Chunk)上顺序处理,主Chunk是Chunk的多个副本之一。所以,Master节点必须记住哪个Chunk服务器持有主Chunk
    • 主Chunk只能在特定的租约时间内担任主Chunk,所以,Master节点要记住主Chunk的租约过期时间

都存储在master的内存中。

有些数据除了存储在master的内存中,还需要存在磁盘上,而有些不用。它们分别是:

  • 前两项都要保存在磁盘上
  • Chunk服务器列表不用保存到磁盘上。因为Master节点重启之后可以与所有的Chunk服务器通信,并查询每个Chunk服务器存储了哪些Chunk,所以我认为它不用写入磁盘。
  • 版本号要不要写入磁盘取决于GFS是如何工作的,我认为它需要写入磁盘。
  • 主Chunk的ID,几乎可以确定不用写入磁盘,因为Master节点重启之后会忘记谁是主Chunk,它只需要等待60秒租约到期,那么它知道对于这个Chunk来说没有主Chunk,这个时候,Master节点可以安全指定一个新的主Chunk。
  • 类似的,租约过期时间也不用写入磁盘.
  • 磁盘上会保存operation log。Master节点读数据只会从内存读,但是写数据的时候,至少有一部分数据会接入到磁盘中。更具体来说,Master会在磁盘上存储log,每次有数据变更时,Master会在磁盘的operation log中追加一条记录,并生成CheckPoint(类似于备份点)
    • 比如,任何时候如果文件扩展到达了一个新的64MB,需要新增一个Chunk或者由于指定了新的主Chunk而导致版本号更新了,Master节点需要向磁盘中的Log追加一条记录说,我刚刚向这个文件添加了一个新的Chunk或者我刚刚修改了Chunk的版本号。所以每次有这样的更新,都需要写磁盘。
    • 因为写磁盘的速度是有限的,写磁盘会导致Master节点的更新速度也是有限的,所以要尽可能少的写入数据到磁盘。
    • 在磁盘中维护log而不是数据库的原因是,数据库本质上来说是某种B树(b-tree)或者hash table,相比之下,追加log会非常的高效,因为你可以将最近的多个log记录一次性的写入磁盘。因为这些数据都是向同一个地址追加,这样只需要等待磁盘的磁碟旋转一次。而对于B树来说,每一份数据都需要在磁盘中随机找个位置写入。所以使用Log可以使得磁盘写入更快一些。
    • 当Master节点故障重启,并重建它的状态,你不会想要从log的最开始重建状态,因为log的最开始可能是几年之前,所以Master节点会在磁盘中创建一些checkpoint点,这样Master节点重启时,会从log中的最近一个checkpoint开始恢复,再逐条执行从Checkpoint开始的log,最后恢复自己的状态。

GFS读数据过程

image.png

  1. 由于chunk size是一个固定的64MB,GFS client可以根据应用提供的要读的file name和byte range(file内的byte range),翻译出一个该文件内的chunk index。

  2. GFS client向master发出请求,请求中指定file name和chunk index。(file name, chunk index)

  3. master会响应对应的chunk handle和其所有replicas的位置。(chunk handle, chunk locations)

  4. client收到响应后,用file name和chunk index为key,把这些信息缓存下来

  5. client向其中一个replica(很有可能是最近的那个chunksever)发送请求,请求中指定chunk handle和byte range(chunk内的byte range)。(chunk handle,byte range)

    Google的数据中心中,IP地址是连续的,所以可以从IP地址的差异判断网络位置的远近
    
  6. chunkserver返回它请求的数据

由于第3步中,client收到master的响应后,会用file name和chunk index为key,把master给它的对应chunk的(chunk handle, chunk locations),所以之后对于相同file name和chunk index的读取就不需要再有clinet-master的交互了。(除非缓存信息过期了,或者文件被reopened了)

GFS写数据过程

/posts/paper-reading/gfs-sosp2003/figure-2.png

  1. client向master询问哪个chunkserver持有指定chunk的租约及该chunk的所有副本的位置。

  2. master回复primary副本的标识符和其他副本(也称secondary)的位置。

    client为后续的变更缓存这些信息。client只有当primary不可访问或primary向client回复其不再持有租约时才需要再次与master通信。

    注:如果没有chunkserver持有租约(即如果发现这个chunk的primary不存在),那么master会选择一个副本对其授权(这一步在图中没有展示)。

    授权方式:

    第一步,找出所有存有chunk最新副本的chunkservers。(如果你的一个系统已经运行了很长时间,那么有可能某一个Chunk服务器保存的Chunk副本是旧的,比如说还是昨天或者上周的。导致这个现象的原因可能是服务器因为宕机而没有收到任何的更新。所以,master节点需要能够在chunk的多个副本中识别出,哪些副本是新的,哪些是旧的。)

    最新的副本是指,副本中保存的版本号与Master中记录的Chunk的版本号一致。
    Chunk副本中的版本号是由Master节点下发的,所以Master节点知道,对于一个特定的Chunk,哪个版本号是最新的。
    (这就是为什么Chunk的版本号在Master节点上需要保存在磁盘这种非易失的存储中)
    

    第二步,挑选一个作为Primary,其他的作为Secondary

    第三步,master会增加版本号,并将版本号写入磁盘,这样就算故障了也不会丢失这个数据。

    第四步,Master节点会向Primary和Secondary副本对应的服务器发送消息并告诉它们,谁是这个chunk的Primary,谁是Secondary,chunk的新版本是什么。Primary和Secondary服务器都会将版本号存储在本地的磁盘中。

  3. client将数据推送到所有副本。client可以按任意顺序推送。每个chunkserver都会将数据在内部的LRU中缓存(一个临时位置),直到数据被使用或缓存老化失效(age out)。

    为了高效地利用网络,我们对数据流与控制流进行了解耦。在控制流从client向primary再向所有secondary推送的同时,数据流沿着一条精心挑选的chunkserver链以流水线的方式线性推送。我们的目标是充分利用每台机器的网络带宽,避免网络瓶颈和高延迟的链路,并最小化推送完所有数据的时延。

    为了充分利用机器的网络带宽,数据会沿着chunkserver链线性地推送,而不是通过其他拓扑结构(如树等)分配发送。因此,每台机器全部的出口带宽都被用来尽可能快地传输数据,而不是非给多个接受者。

    每台机器会将数据传递给在网络拓扑中最近的的且还没有收到数据的机器。由于我们的网络拓扑非常简单,所以可以通过IP地址来准确地估算出网络拓扑中的“距离”。

    我们通过流水线的方式通过TCP连接传输数据,以最小化时延。当chunkserver收到一部分数据时,它会立刻开始将数据传递给其他chunkserver。因为我们使用全双工的交换网络,所以流水线可以大幅减少时延,发送数据不会减少接受数据的速度。如果没有网络拥塞,理论上将B个字节传输给RR副本所需的时间为B/T+RL,其中T是网络的吞吐量,L是两台机器间的传输时延。通常,我们的网络连接吐吞量TTT为100Mbps,传输时延L远小于1ms。所以理想状态下,1MB的数据可以在80ms左右被distributed。

  4. 一旦所有副本都确认收到了数据,client会向primary发送一个write请求。这个请求标识了之前推送到所有副本的数据。

    primary会为其收到的所有的变更(可能来自多个client)分配连续的编号,这一步提供了重要的顺序。primary对在本地按照该顺序应用变更。

  5. primary将write请求继续传递给其他secondary副本。每个secondary副本都按照primary分配的顺序来应用变更。

  6. 所有的secondary副本完成变更操作后,通知primary其完成了变更操作。

  7. primary回复client。

    任意副本遇到的任何错误都会被报告给client。即使错误发生,write操作可能已经在primary或secondary的任意子集中被成功执行。(如果错误在primary中发生,那么操作将不会被分配顺序,也不会被继续下发到其他副本。)只要错误发生,该请求都会被认为是失败的,且被修改的区域的状态为inconsistent。client中的代码会通过重试失败的变更来处理这种错误。首先它会重试几次步骤(3)到步骤(7),如果还没有成功,再从write请求的初始操作开始重试。

如果应用程序发出的一次write请求过大或跨多个chunk,GFS的client代码会将其拆分成多个write操作。拆分后的write请求都按照上文中的控制流执行,但是可能存在与其他client的并发的请求交叉或被其他client的并发请求覆盖的情况。

因此,共享的文件区域最终可能包含来自不同client的片段。但共享的文件区域中的内容最终是相同的,因为每个操作在所有副本上都会以相同的顺序被成功执行。这会使文件区域变为consistent but undefined状态。

master操作

master执行所有命名空间(namesapces)操作。

master还管理整个系统中chunk的副本:做chunk分配(placement)决策、创建新chunk与副本、协调各种系统范围的活动以保持chunk副本fully replicated、平衡所有chunkserver的负载并回收未使用的存储。

命名空间管理与锁

master的很多操作可能消耗很长时间,例如:快照操作必须收回其涉及到的chunk所在的chunkserver的租约。当这些操作执行时,我们不希望推迟master的其他操作。因此,我们允许同时存在多个运行中的操作,并对命名空间的区域使用锁机制来保证操作正确地串行执行。

GFS在逻辑上用一个完整路径名到元数据的查找表来表示命名空间。通过前缀压缩技术,这个查找表可在内存中高效地表示。在命名空间树上的每个节点(既可能是一个文件的绝对路径名,也可能是一个目录的绝对路径名)都有一个与之关联的读写锁(read-write lock)。

master的每个操作执行前都会请求一系列的锁。通常,如果master的操作包含命名空间/d1/d2/…/dn/leaf,master会在目录/d1,/d1/d2,...,/d1/d2/…/dn上请求读取锁,并在完整路径名/d1/d2/…/dn/leaf上请求读取锁或写入锁。其中leaf可能是文件或者目录,这取决于执行的操作。

举例:说明锁机制如何在/home/user正在被快照到/save/user时,防止/home/user/foo被创建。

快照操作会在/home和/save上请求读取锁、在/home/user和/save/user上请求写入锁。

文件创建操作需要在/home和/home/user上请求读取锁,在/home/user/foo上请求写入锁。

由于它们试图在/home/user上获取锁时发生冲突,因此这两个操作可以正确地串行执行。因为GFS中没有目录数据结果或像inode一样的数据结构,所以无需在修改时对其进行保护,因此在文件创建操作时不需要获取其父目录的写入锁。其父目录上的读取锁已经足够保护其父目录不会被删除。

这种锁机制提供了一个非常好的性质:允许在同一目录下并发地执行变更。例如,在同一目录下的多个文件创建操作可以并发执行:每个文件创建操作都获取其父目录的读取锁与被创建的文件的写入锁。目录名上的读取锁足够防止其被删除、重命名或快照。文件名上的写入锁可以防止相同同名文件被创建两次。

因为命名空间可能含有很多的结点,所以读写锁对象会在使用时被懒式创建,并一旦其不再被使用就会被删除。此外,为了防止死锁,锁的获取顺序总是一致的:首先按照命名空间树中的层级排序,在同一层级内按照字典顺序排序。

副本分配(Replica Placement)

GFS集群在多个层级上都高度分布。GFS通常有数百个跨多个机架的chunkserver。这些chunkserver可能会被来自相同或不同机架上的数百个client访问。在不同机架上的两台机器的通信可能会跨一个或多个交换机。另外,一个机架的出入带宽可能小于这个机架上所有机器的出入带宽之和。多层级的分布为数据的可伸缩性、可靠性和可用性带来了特有的挑战。

chunk副本分配策略有两个目标:最大化数据可靠性和可用性、最大化网络带宽的利用。对于这两个目标,仅将副本分散在所有机器上是不够的,这样做只保证了容忍磁盘或机器故障且只充分利用了每台机器的网络带宽。我们必须在机架间分散chunk的副本。这样可以保证在一整个机架都被损坏或离线时(例如,由交换机、电源电路等共享资源问题引起的故障),chunk的一些副本仍存在并保持可用状态。除此之外,这样还使对chunk的流量(特别是读流量)能够充分利用多个机架的总带宽。而另一方面,写流量必须流经多个机架,这是我们资源做出的权衡。

chunk创建、重做副本、重均衡(Creation, Re-replication, Rebalancing)

chunk副本(chunk relpicas)的创建可能由三个原因引起:chunk创建(creation)、重做副本(re-replication)和重均衡(rebalance)。

Creation

当master创建一个chunk的时候,它会选择初始化空副本的位置。位置的选择会参考很多因素:(1)我们希望在磁盘利用率低于平均值的chunkserver上放置副本。随着时间推移,这样将平衡chunkserver间的磁盘利用率(2)我们希望限制每台chunkserver上最近创建的chunk的数量。尽管创建chunk本身开销很小,但是由于chunk时写入时创建的,且在我们的一次追加多次读取(append-once-read-many)的负载下chunk在写入完成后经常是只读的,所以master还要会可靠的预测即将到来的大量的写入流量。(3)对于以上讨论的因素,我们希望将chunk的副本跨机架分散。

Re-replication

当chunk可用的副本数少于用户设定的目标值时,master会重做副本。chunk副本数减少可能有很多种原因,比如:chunkserver可能变得不可用、chunkserver报告其副本被损坏、chunkserver的磁盘因为错误变得不可用、或者目标副本数增加。

每个需要重做副本的chunk会参考一些因素按照优先级排序。这些因素之一是当前chunk副本数与目标副本数之差。例如,我们给失去两个副本的chunk比仅失去一个副本的chunk更高的优先级。另外,我们更倾向于优先为还存在的文件的chunk重做副本,而不是优先为最近被删除的文件重做。最后,为了最小化故障对正在运行的应用程序的影响,我们提高了所有正在阻塞client进程的chunk的优先级。

master选取优先级最高的chunk,并通过命令若干chunkserver直接从一个存在且合法的副本拷贝的方式来克隆这个chunk。新副本位置的选取与创建新chunk时位置选取的目标类似:均衡磁盘空间利用率、限制在单个chunkserver上活动的克隆操作数、在机架间分散副本。为了防止克隆操作的流量远高于client流量的情况发生,master需要对整个集群中活动的克隆操作数和每个chunkserver上活动的克隆操作数进行限制。除此之外,在克隆操作中,每个chunkserver还会限制对源chunkserver的读请求,以限制每个克隆操作占用的总带宽。

Rebalancing

每隔一段时间master会对副本进行重均衡:master会检测当前的副本分布并移动副本位置,使磁盘空间和负载更加均衡。同样,在这个过程中,master会逐渐填充一个新的chunkserver,而不会立刻让来自新chunk的高负荷的写入流量压垮新的chunkserver。新副本放置位置的选择方法与我们上文中讨论过的类似。此外,master必须删除一个已有副本。通常,master会选择删除空闲磁盘空间低于平均的chunkserver上的副本,以均衡磁盘空间的使用。

垃圾回收

在文件被删除后,GFS不会立刻回收可用的物理存储空间。master仅在周期性执行懒式垃圾回收时回收物理存储空间,其中垃圾回收分为文件级垃圾回收和chunk级垃圾回收。我们发现这种方法可以让系统更为简单可靠。

当一个文件被应用程序删除时,master会像执行其他操作时一样立刻将删除操作写入日志。但是master不会立刻对资源进行回收,而是将待删除的文件重命名为一个带有删除时间戳的隐藏文件名。当master周期性地扫描文件系统命名空间时,它会删除已经存在超过三天(用户可以配置这个间隔时间)的这种隐藏文件。在文件被彻底删除之前,仍可通过该文件被重命名后的特殊的新文件名对其进行访问,也可以通过将其重命名为正常文件的方式撤销删除。当隐藏文件被从命名空间中移除时,其在内存中的元数据也会被删除。这种方式可以有效地切断文件和其对应的chunk的链接。

和上文介绍的文件级垃圾回收类似,在进行chunk级垃圾回收时,master会周期性扫描chunk命名空间,并找出孤儿chunk(orphaned chunk)(例如哪些无法被任何文件访问到的chunk)并删除这些chunk的元数据。在chunkserver周期性地与master进行心跳消息交换时,chunkserver会报告其拥有的chunk的子集,而master会回复这些chunk中元数据已经不存在的chunk的标识。chunkserver可以自由地删除这些元数据已经不存在的chunk的副本。