3 系统交互
我们在设计这个系统时,一个重要的原则是最小化所有操作和 Master 节点的交互。
带着这样的设计理念,我们现在描述一下客户机、Master 服务器和 Chunk 服务器如何进行交互,以实现
数据修改操作、原子的记录追加操作以及快照功能。
3.1 租约(lease)和变更顺序
变更是一个会改变 Chunk 内容或者元数据的操作,比如写入操作或者记录追加操作。变更操作会在 Chunk的所有副本上执行。我们使用租约(lease)机制来保持多个副本间变更顺序的一致性。Master 节点为 Chunk的一个副本建立一个租约,我们把这个副本叫做主 Chunk。主 Chunk 对 Chunk 的所有更改操作进行序列化。
所有的副本都遵从这个序列进行修改操作。因此,修改操作全局的顺序首先由 Master 节点选择的租约的顺序
决定,然后由租约中主 Chunk 分配的序列号决定。
设计租约机制的目的是为了最小化 Master 节点的管理负担。租约的初始超时设置为 60 秒。不过,只要Chunk 被修改了,主 Chunk 就可以申请更长的租期,通常会得到 Master 节点的确认并收到租约延长的时间。
这些租约延长请求和批准的信息通常都是附加在 Master 节点和 Chunk 服务器之间的心跳消息中来传递。有时Master 节点会试图提前取消租约(例如,Master 节点想取消在一个已经被改名的文件上的修改操作)。即使Master节点和主Chunk失去联系,它仍然可以安全地在旧的租约到期后和另外一个Chunk副本签订新的租约。
文件写入
客户端尝试将数据写入到某个 Chunk 的指定位置的过程大致如下:
- 客户端向 Master 询问目前哪个 Chunk Server 持有该 Chunk 的 Lease
- Master 向客户端返回 Primary 和其他 Replica 的位置
- 客户端将数据推送到所有的 Replica 上。Chunk Server 会把这些数据保存在缓冲区中,等待使用
- 待所有 Replica 都接收到数据后,客户端发送写请求给 Primary。Primary 为来自各个客户端的修改操作安排连续的执行序列号,并按顺序地应用于其本地存储的数据
- Primary 将写请求转发给其他 Secondary Replica,Replica 们按照相同的顺序应用这些修改
- Secondary Replica 响应 Primary,示意自己已经完成操作
- Primary 响应客户端,并返回该过程中发生的错误(若有)
如果该过程有发生错误,可以认为修改已在 Primary 和部分 Secondary 上成功执行(如果在 Primary 上就出错了,那么写请求不会被转发出去)。此时可以认为此次修改操作没有成功,因为数据会处于不一致的状态。实际上,GFS 所使用的客户端 lib 在此时会重新尝试执行此次操作。
值得注意的是,这个流程特意将数据流与控制流分开:客户端先向 Chunk Server 提交数据,再将写请求发往 Primary。这么做的好处在于 GFS 能够更好地利用网络带宽资源。
从上述步骤可见,控制流借由写请求从客户端流向 Primary,再流向其他 Secondary Replica。实际上,数据流以一条线性数据管道进行传递的:客户端会把数据上传到离自己最近的 Replica,该 Replica 在接收到数据后再转发给离自己最近的另一个 Replica,如此递归直到所有 Replica 都能接收到数据,如此一来便能够利用上每台机器的所有出口带宽。
如果应用程序一次写入的数据量很大,或者数据跨越了多个 Chunk,GFS 客户机代码会把它们分成多个写操作。这些操作都遵循前面描述的控制流程,但是可能会被其它客户机上同时进行的操作打断或者覆盖。
因此,共享的文件 region 的尾部可能包含来自不同客户机的数据片段,尽管如此,由于这些分解后的写入操作在所有的副本上都以相同的顺序执行完成,Chunk 的所有副本都是一致的。这使文件 region 处于 2.7 节描述的一致的、但是未定义的状态。
3.3 原子的记录追加
文件追加操作的过程和写入的过程有几分相似:
- 客户端将数据推送到每个 Replica,然后将请求发往 Primary
- Primary 首先判断将数据追加到该块后是否会令块的大小超过上限:如果是,那么 Primary 会为该块写入填充至其大小达到上限,并通知其他 Replica 执行相同的操作,再响应客户端,通知其应在下一个块上重试该操作
- 如果数据能够被放入到当前块中,那么 Primary 会把数据追加到自己的 Replica 中,拿到追加成功返回的偏移值,然后通知其他 Replica 将数据写入到该偏移位置中
- 最后 Primary 再响应客户端
当追加操作在部分 Replica 上执行失败时,Primary 会响应客户端,通知它此次操作已失败,客户端便会重试该操作。和写入操作的情形相同,此时已有部分 Replica 顺利写入这些数据,重新进行数据追加便会导致这一部分的 Replica 上出现重复数据,不过 GFS 的一致性模型也确实并未保证每个 Replica 都会是完全一致的。
GFS 只确保数据会以一个原子的整体被追加到文件中至少一次。由此我们可以得出,当追加操作成功时,数据必然已被写入到所有 Replica 的相同偏移位置上,且每个 Replica 的长度都至少超出此次追加的记录的尾部,下一次的追加操作必然会被分配一个比该值更大的偏移值,或是被分配到另一个新的块上。
3.4 文件快照
GFS 还提供了文件快照操作,可为指定的文件或目录创建一个副本。
快照操作的实现采用了写时复制(Copy on Write)的思想:
- 在 Master 接收到快照请求后,它首先会撤回这些 Chunk 的 Lease,以让接下来其他客户端对这些 Chunk 进行写入时都会需要请求 Master 获知 Primary 的位置,Master 便可利用这个机会创建新的 Chunk
- 当 Chunk Lease 撤回或失效后,Master 会先写入日志,然后对自己管理的命名空间进行复制操作,复制产生的新记录指向原本的 Chunk
- 当有客户端尝试对这些 Chunk 进行写入时,Master 会注意到这个 Chunk 的引用计数大于 1。此时,Master 会为即将产生的新 Chunk 生成一个 Handle,然后通知所有持有这些 Chunk 的 Chunk Server 在本地复制出一个新的 Chunk,应用上新的 Handle,然后再返回给客户端
4.1 Master Namespace 管理和锁
在前面我们已经了解到,Namespace 作为 GFS 元信息的一部分会被维持在 Master 的内存中,由 Master 负责管理。在逻辑上,GFS Master 并不会根据文件与目录的关系以分层的结构来管理这部分数据,而是单纯地将其表示为从完整路径名到对应文件元数据的映射表,并在路径名上应用前缀压缩以减少内存占用。
为了管理来自不同客户端的并发请求对 Namespace 的修改,Master 会为 Namespace 中的每个文件和目录都分配一个读写锁(Read-Write Lock)。由此,对不同 Namespace 区域的并发请求便可以同时进行。
所有 Master 操作在执行前都会需要先获取一系列的锁:通常,当操作涉及某个路径 /d1/d2/.../dn/leaf 时,Master 会需要先获取从 /d1、/d1/d2 到 /d1/d2/.../dn 的读锁,然后再根据操作的类型获取 /d1/d2/.../dn/leaf 的读锁或写锁 —— 获取父目录的读锁是为了避免父目录在此次操作执行的过程中被重命名或删除。
由于大量的读写锁可能会造成较高的内存占用,这些锁会在实际需要时才进行创建,并在不再需要时被销毁。除外,所有的锁获取操作也会按照一个相同的顺序进行,以避免发生死锁:锁首先按 Namespace 树的层级排列,同一层级内则以路径名字典序排列。
4.2 副本的位置
为了进一步优化 GFS 集群的效率,Master 在 Replica 的位置选取上会采取一定的策略。
Master 的 Replica 编排策略主要为了实现两个目标:最大化数据的可用性,以及最大化网络带宽的利用率。为此,Replica 不仅需要被保存在不同的机器上,还会需要被保存在不同的机架上,这样如果整个机架不可用了,数据仍然得以存活。如此一来,不同客户端对同一个 Chunk 进行读取时便可以利用上不同机架的出口带宽,但坏处就是进行写入时数据也会需要在不同机架间流转,不过在 GFS 的设计者看来这是个合理的 trade-off。
4.3 创建,重新复制,重新负载均衡
Replica 的生命周期转换操作实际只有两个:创建和删除。首先,Replica 的创建可能源于以下三种事件:创建 Chunk、为 Chunk 重备份、以及 Replica 均衡。
在 Master 创建一个新的 Chunk 时,首先它会需要考虑在哪放置新的 Replica。Master 会考虑如下几个因素:
Master 会倾向于把新的 Replica 放在磁盘使用率较低的 Chunk Server 上
Master 会倾向于确保每个 Chunk Server 上“较新”的 Replica 不会太多,因为新 Chunk 的创建意味着接下来会有大量的写入,如果某些 Chunk Server 上有太多的新 Chunk Replica,那么写操作压力就会集中在这些 Chunk Server 上
如上文所述,Master 会倾向于把 Replica 放在不同的机架上
当某个 Chunk 的 Replica 数量低于用户指定的阈值时,Master 就会对该 Chunk 进行重备份。这可能是由 Chunk Server 失效、Chunk Server 回报 Replica 数据损坏或是用户提高了 Replica 数量阈值所触发。
首先,Master 会按照以下因素为每个需要重备份的 Chunk 安排优先级:
该 Chunk 的 Replica 数距离用户指定的 Replica 数量阈值的差距有多大
优先为未删除的文件(见下文)的 Chunk 进行重备份
除外,Master 还会提高任何正在阻塞用户操作的 Chunk 的优先级
有了 Chunk 的优先级后,Master 会选取当前拥有最高优先级的 Chunk,并指定若干 Chunk Server 直接从现在已有的 Replica 上复制数据。Master 具体会指定哪些 Chunk Server 进行复制操作同样会考虑上面提到的几个因素。除外,为了减少重备份对用户使用的影响,Master 会限制当前整个集群正在进行的复制操作的数量,同时 Chunk Server 也会限制复制操作所使用的带宽。
最后,Master 会周期地检查每个 Chunk 当前在集群内的分布情况,并在必要时迁移部分 Replica 以更好地均衡各节点的磁盘利用率和负载。新 Replica 的位置选取策略和上面提到的大体相同,除此以外 Master 还会需要选择要移除哪个已有的 Replica:简单概括的话,Master 会倾向于移除磁盘占用较高的 Chunk Server 上的 Replica,以均衡磁盘使用率。
4.4 垃圾回收
GFS 在文件删除后不会立刻回收可用的物理空间。GFS 空间回收采用惰性的策略,只在文件和 Chunk 级的常规垃圾收集时进行。我们发现这个方法使系统更简单、更可靠。
4.4.1 机制
当一个文件被应用程序删除时,Master 节点象对待其它修改操作一样,立刻把删除操作以日志的方式记录下来。但是,Master 节点并不马上回收资源,而是把文件名改为一个包含删除时间戳的、隐藏的名字。当Master 节点对文件系统命名空间做常规扫描的时候,它会删除所有三天前的隐藏文件(这个时间间隔是可以
设置的)。直到文件被真正删除,它们仍旧可以用新的特殊的名字读取,也可以通过把隐藏文件改名为正常显示的文件名的方式“反删除”。当隐藏文件被从名称空间中删除,Master 服务器内存中保存的这个文件的相关元数据才会被删除。这也有效的切断了文件和它包含的所有 Chunk 的连接。
在对 Chunk 名字空间做类似的常规扫描时,Master 节点找到孤儿 Chunk(不被任何文件包含的 Chunk)并删除它们的元数据。Chunk 服务器在和 Master 节点交互的心跳信息中,报告它拥有的 Chunk 子集的信息,Master 节点回复 Chunk 服务器哪些 Chunk 在 Master 节点保存的元数据中已经不存在了。Chunk 服务器可以任意删除这些 Chunk 的副本。
采用这种删除机制主要有如下三点好处:
对于大规模的分布式系统来说,这样的机制更为可靠:在 Chunk 创建时,创建操作可能在某些 Chunk Server 上成功了,在其他 Chunk Server 上失败了,这导致某些 Chunk Server 上可能存在 Master 不知道的 Replica。除此以外,删除 Replica 的请求可能会发送失败,Master 会需要记得尝试重发。相比之下,由 Chunk Server 主动地删除 Replica 能够以一种更为统一的方式解决以上的问题
这样的删除机制将存储回收过程与 Master 日常的周期扫描过程合并在了一起,这就使得这些操作可以以批的形式进行处理,以减少资源损耗;除外,这样也得以让 Master 选择在相对空闲的时候进行这些操作
用户发送删除请求和数据被实际删除之间的延迟也有效避免了用户误操作的问题
不过,如果在存储资源较为稀缺的情况下,用户对存储空间使用的调优可能就会受到该机制的阻碍。为此,GFS 允许客户端再次指定删除该文件,以确实地从 Namespace 层移除该文件。除外,GFS 还可以让用户为 Namespace 中不同的区域指定不同的备份和删除策略,如限制 GFS 不对某个目录下的文件进行 Chunk 备份等。
4.5 过期失效的副本检测
当 Chunk 服务器失效时,Chunk 的副本有可能因错失了一些修改操作而过期失效。Master 节点保存了每个 Chunk 的版本号,用来区分当前的副本和过期副本。
无论何时,只要 Master 节点和 Chunk 签订一个新的租约,它就增加 Chunk 的版本号,然后通知最新的副本。Master 节点和这些副本都把新的版本号记录在它们持久化存储的状态信息中。这个动作发生在任何客户机得到通知以前,因此也是对这个 Chunk 开始写之前。如果某个副本所在的 Chunk 服务器正好处于失效状态,那么副本的版本号就不会被增加。Master 节点在这个 Chunk 服务器重新启动,并且向 Master 节点报告它拥有的 Chunk 的集合以及相应的版本号的时候,就会检测出它包含过期的 Chunk。如果 Master 节点看到一个比它记录的版本号更高的版本号,Master 节点会认为它和 Chunk 服务器签订租约的操作失败了,因此会选择更高的版本号作为当前的版本号。
Master 节点在例行的垃圾回收过程中移除所有的过期失效副本。在此之前Master 节点在回复客户机的Chunk 信息请求的时候,简单的认为那些过期的块根本就不存在。另外一重保障措施是,Master 节点在通知客户机哪个 Chunk 服务器持有租约、或者指示 Chunk 服务器从哪个 Chunk 服务器进行克隆时,消息中都附带
了 Chunk 的版本号。客户机或者 Chunk 服务器在执行操作时都会验证版本号以确保总是访问当前版本的数据
5 容错和诊断
CHUNK 复制
每个 Chunk 都被复制到不同机架上的不同的 Chunk 服务器上。用户可以为文件命名空间的不同部分设定不同的复制级别。缺省是 3。当有 Chunk 服务器离线了,或者通过 Chksum 校验(参考 5.2节)发现了已经损坏的数据,Master 节点通过克隆已有的副本保证每个 Chunk 都被完整复制。虽然 Chunk复制策略对我们非常有效,但是我们也在寻找其它形式的跨服务器的冗余解决方案,比如使用奇偶校验、或者 Erasure codes来解决我们日益增长的只读存储需求。我们的系统主要的工作负载是追加方式的写入和读取操作,很少有随机的写入操作,因此,我们认为在我们这个高度解耦合的系统架构下实现这些复杂的冗余方案很有挑战性,但并非不可实现。
MASTER 复制
前面我们提到,Master 会以先写日志(Operation Log)的形式对集群元数据进行持久化:日志在被确实写出前,Master 不会对客户端的请求进行响应,后续的变更便不会继续执行;除外,日志还会被备份到其他的多个机器上,日志只有在写入到本地以及远端备份的持久化存储中才被视为完成写出。
在重新启动时,Master 会通过重放已保存的操作记录来恢复自身的状态。为了保证 Master 能够快速地完成恢复,Master 会在日志达到一定大小后为自身的当前状态创建 Checkpoint(检查点),并删除 Checkpoing 创建以前的日志,重启时便从最近一次创建的 Checkpoint 开始恢复。Checkpoint 文件的内容会以 B 树的形式进行组织,且在被映射到内存后便能够在不做其他额外的解析操作的情况下检索其所存储的 Namespace,这便进一步减少了 Master 恢复所需的时间。
为了简化设计,同一时间只会有一个 Master 起作用。当 Master 失效时,外部的监控系统会侦测到这一事件,并在其他地方重新启动新的 Master 进程。
除外,集群中还会有其他提供只读功能的 Shadow Master:它们会同步 Master 的状态变更,但有可能延迟若干秒,其主要用于为 Master 分担读操作的压力。Shadow Master 会通过读取 Master 操作日志的某个备份来让自己的状态与 Master 同步;它也会像 Master 那样,在启动时轮询各个 Chunk Server,获知它们所持有的 Chunk Replica 信息,并持续监控它们的状态。实际上,在 Master 失效后,Shadow Master 仍能为整个 GFS 集群提供只读功能,而 Shadow Master 对 Master 的依赖只限于 Replica 位置的更新事件。
5.2 数据完整性
如前面所述,每个 Chunk 都会以 Replica 的形式被备份在不同的 Chunk Server 中,而且用户可以为 Namespace 的不同部分赋予不同的备份策略。
为了保证数据完整,每个 Chunk Server 都会以校验和的形式来检测自己保存的数据是否有损坏;在侦测到损坏数据后,Chunk Server 也可以利用其它 Replica 来恢复数据。
首先,Chunk Server 会把每个 Chunk Replica 切分为若干个 64KB 大小的块,并为每个块计算 32 位校验和。和 Master 的元数据一样,这些校验和会被保存在 Chunk Server 的内存中,每次修改前都会用先写日志的形式来保证可用。当 Chunk Server 接收到读请求时,Chunk Server 首先会利用校验和检查所需读取的数据是否有发生损坏,如此一来 Chunk Server 便不会把损坏的数据传递给其他请求发送者,无论它是客户端还是另一个 Chunk Server。发现损坏后,Chunk Server 会为请求发送者发送一个错误,并向 Master 告知数据损坏事件。接收到错误后,请求发送者会选择另一个 Chunk Server 重新发起请求,而 Master 则会利用另一个 Replica 为该 Chunk 进行重备份。当新的 Replica 创建完成后,Master 便会通知该 Chunk Server 删除这个损坏的 Replica。
当进行数据追加操作时,Chunk Server 可以为位于 Chunk 尾部的校验和块的校验和进行增量式的更新,或是在产生了新的校验和块时为其计算新的校验和。即使是被追加的校验和块在之前已经发生了数据损坏,增量更新后的校验和依然会无法与实际的数据相匹配,在下一次读取时依然能够检测到数据的损坏。在进行数据写入操作时,Chunk Server 必须读取并校验包含写入范围起始点和结束点的校验和块,然后进行写入,最后再重新计算校验和。
除外,在空闲的时候,Chunk Server 也会周期地扫描并校验不活跃的 Chunk Replica 的数据,以确保某些 Chunk Replica 即使在不怎么被读取的情况下,其数据的损坏依然能被检测到,同时也确保了这些已损坏的 Chunk Replica 不至于让 Master 认为该 Chunk 已有足够数量的 Replica。
FAQ
Q:为什么原子记录追加操作是至少一次(At Least Once),而不是确定一次(Exactly Once)?
要让追加操作做到确定一次是不容易的,因为如此一来 Primary 会需要保存一些状态信息以检测重复的数据,而这些信息也需要复制到其他服务器上,以确保 Primary 失效时这些信息不会丢失。在 Lab 3 中你会实现确定一次的行为,但用的是比 GFS 更复杂的协议(Raft)。
Q:应用怎么知道 Chunk 中哪些是填充数据或者重复数据?
要想检测填充数据,应用可以在每个有效记录之前加上一个魔数(Magic Number)进行标记,或者用校验和保证数据的有效性。应用可通过在记录中添加唯一 ID 来检测重复数据,这样应用在读入数据时就可以利用已经读入的 ID 来排除重复的数据了。GFS 本身提供了 library 来支撑这些典型的用例。
Q:考虑到原子记录追加操作会把数据写入到文件的一个不可预知的偏移值中,客户端该怎么找到它们的数据?
追加操作(以及 GFS 本身)主要是面向那些会完整读取文件的应用的。这些应用会读取所有的记录,所以它们并不需要提前知道记录的位置。例如,一个文件中可能包含若干个并行的网络爬虫获取的所有链接 URL。这些 URL 在文件中的偏移值是不重要的,应用只会想要完整读取所有 URL。
Q:如果一个应用使用了标准的 POSIX 文件 API,为了使用 GFS 它会需要做出修改吗?
答案是需要的,不过 GFS 并不是设计给已有的应用的,它主要面向的是新开发的应用,如 MapReduce 程序。
Q:GFS 是怎么确定最近的 Replica 的位置的?
论文中有提到 GFS 是基于保存 Replica 的服务器的 IP 地址来判断距离的。在 2003 年的时候,Google 分配 IP 地址的方式应该确保了如果两个服务器的 IP 地址在 IP 地址空间中较为接近,那么它们在机房中的位置也会较为接近。
Q:Google 现在还在使用 GFS 吗?
Google 仍然在使用 GFS,而且是作为其他如 BigTable 等存储系统的后端。由于工作负载的扩大以及技术的革新,GFS 的设计在这些年里无疑已经经过大量调整了,但我并不了解其细节。HDFS 是公众可用的对 GFS 的设计的一种效仿,很多公司都在使用它。
Q:Master 不会成为性能瓶颈吗?
确实有这个可能,GFS 的设计者也花了很多心思来避免这个问题。例如,Master 会把它的状态保存在内存中以快速地进行响应。从实验数据来看,对于大文件读取(GFS 主要针对的负载类型),Master 不是瓶颈所在;对于小文件操作以及目录操作,Master 的性能也还跟得上(见 6.2.4 节)。
Q:GFS 为了性能和简洁而牺牲了正确性,这样的选择有多合理呢?
这是分布式系统领域的老问题了。保证强一致性通常需要更加复杂且需要机器间进行更多通信的协议(正如我们会在接下来几门课中看到的那样)。通过利用某些类型的应用可以容忍较为松懈的一致性的事实,人们就能够设计出拥有良好性能以及足够的一致性的系统。例如,GFS 对 MapReduce 应用做出了特殊优化,这些应用需要的是对大文件的高读取效率,还能够容忍文件中存在数据空洞、重复记录或是不一致的读取结果;另一方面,GFS 则不适用于存储银行账号的存款信息。
Q:如果 Master 失效了会怎样?
GFS 集群中会有持有 Master 状态完整备份的 Replica Master;通过论文中没有提到的某个机制,GFS 会在 Master 失效时切换到其中一个 Replica(见 5.1.3 节)。有可能这会需要一个人类管理者的介入来指定一个新的 Master。无论如何,我们都可以确定集群中潜伏着一个故障单点,理论上能够让集群无法从 Master 失效中进行自动恢复。我们会在后面的课程中学习如何使用 Raft 协议实现可容错的 Master。
回答开头的那个问题
由失效后重启的 Chunk Server + 客户端缓存的 Chunk 位置数据导致客户端读取到过时的文件内容
和由于 Shadow Master 读取到的过时文件元信息
以上是保证所有写入操作都成功时客户端可能读取到过时数据的两种情况 —— 如果有写入操作失败,数据会进入不确定的状态,自然客户端也有可能读取到过时或是无效的数据。
我的收获与启发
- GFS的论文的信息量真的十分庞大,我精读了3遍,也未能很好的消化。
- 为了简化设计,GFS引入了单MASTER的架构。然后就是也防止单点负载过高的问题。GFS做了如下努力,只把关键信息放在MASTER内存里,其余的让MASTER在多个REPLICA里选出PRIMARY去和CLIENT交互。
- 容错依赖于很多,最重要的是复制,也就是冗余。还有CHECKSUM机制。以及MASTER的定期发送心跳验活同时在心跳里检查副本是否被污染,或数据不平衡。
- MASTER则自身建立了常规DB的操作,LOG + CHECK POINT来保证可以最小化MASTER的信息丢失。 引入了SHADOW MASTER 来临时担负起读的工作。
- 这篇文章里用到了租约,其目的是为了只有一个CHUNK可以作为主CHUNK。
- 主CHUNK的作用是为了统一写入顺序还有分担MASTER的压力。
- 删除用的是LAZY 删除机制,不是立即删除,而是做个标记,最后交给垃圾收集器去做。这样做的好处有防止用户误操作。 在分布式环境下这种机制更可靠。 还有可以在MASTER相对空闲的时候,用批处理的形式更高效的删除。
- 文件的快照操作,利用写时复制的思想。