GFS
论文首先分析了作为一个大型的分布式存储系统的特点和难题:
- 故障:所有分布式系统的共同特点是总有机器会发生故障,系统需要能够应对这种情况。
- 文件体积巨大:文件往往以 GB 的级别进行传输,因此文件系统的块大小必须重新设计。
- 追加写入为主:许多文件只进行追加写,而不是覆盖现有的数据。同时,读取以顺序访问为主。
由于以上特点,GFS 在设计时提出了以下假设:
- 系统需具备自主监控能力,能够实时检测并处理运行过程中出现的异常。
- 系统设计主要针对大文件存储场景进行优化(存储对象以少量大文件为主),同时保留对小文件的基本支持(但不作为优化目标)。
- 系统核心优化方向为高吞吐量的流式读取操作。对于客户端发起的随机读取请求,应由客户端自行实现访问优化策略。
- 系统架构优先支持大规模顺序写入场景的优化。虽然提供小规模写入功能接口,但不对其性能作特别优化。
- 系统必须明确定义多客户端并发追加写入同一文件时的操作语义,并在保证语义正确性的前提下,最大限度提升并发写入效率。
- 系统性能优化以高持续带宽为首要目标(强调吞吐量最大化),而非追求低延迟特性(对随机访问的响应时间不作严格约束)。
为了方便使用 GFS,除了常规的创建、删除、打开、关闭、读取和写入文件,GFS 额外支持快照(snapshot)和记录追加(record append)操作。快照以低成本创建文件或目录树的副本。记录追加允许多个客户端并发向同一文件追加数据,同时保证每个客户端的追加操作具有原子性。
架构
GFS 集群由一个主节点(master)、多个 chunkserver 和多个客户端组成。同时,只要机器资源允许,并且可以接受因运行可能不稳定的程序代码而导致的较低可靠性,就可以在同一台机器上同时运行 chunkserver 和客户端。
文件被划分为固定大小的块(chunk)。每个块由主节点在创建时分配的一个不可变且全局唯一的 64 位块句柄(chunk handle)标识。Chunkserver 将块存储为本地磁盘上的 Linux 文件,并根据块句柄和字节范围读取或写入块数据。为了可靠性,每个块会在多个 chunkserver 上复制。
主节点维护所有文件系统元数据。这包括命名空间、访问控制信息、文件到块的映射以及块的当前位置。它还控制全局活动,如块租约管理、孤儿块的垃圾回收以及块在 chunkserver 之间的迁移。主节点定期通过心跳消息与每个 chunkserver 通信,以发送指令并收集其状态。
链接到每个应用程序中的 GFS 客户端代码实现了文件系统 API,并与主节点和 chunkserver 通信,代表应用程序读取或写入数据。客户端在元数据操作时与主节点交互,但所有承载数据的通信都直接发送给 chunkserver。
客户端和 chunkserver 均不缓存文件数据。客户端缓存带来的好处很小,因为大多数应用程序会流式处理大文件,或者工作集太大无法缓存。不缓存简化了客户端和整个系统,消除了缓存一致性问题。(不过客户端会缓存元数据。)Chunkserver 不需要缓存文件数据,因为块作为本地文件存储,Linux 的缓冲区缓存已经将频繁访问的数据保留在内存中。
单一主节点
主节点利用全局的信息进行统筹和决策,但尽量避免参与文件的直接读写操作。客户端向主节点获取 chunkserver 信息后直接与 chunkserver 进行后续通信。
一次简单的交互:
首先,客户端使用固定的块大小,将应用程序指定的文件名和字节偏移转换为文件内的块索引。然后,客户端向主节点发送包含文件名和块索引的请求。主节点回复对应的块句柄以及副本的位置。客户端使用文件名和块索引作为键缓存这些信息。
接着,客户端向其中一个副本(最可能是最近的那个)发送请求。该请求指定块句柄和该块内的字节范围。对同一块的进一步读取直到缓存信息过期或文件重新打开前不再需要与主节点交互。事实上,客户端通常在一次请求中要求多个块,主节点也可以包含紧随请求块之后的块信息。这种额外信息几乎无需额外成本即可避免多次未来的客户端-主节点交互。
块大小
GFS 采用 64MB 作为标准块大小,并将每个块以普通 Linux 文件的形式存储在 chunkserver 上,后续可按需动态扩容。采用如此大的块大小具有以下优势:
-
减少客户端与主节点的交互开销:
- 客户端在获取块的位置信息后,可直接与 chunkserver 通信,而无需频繁与主节点交互。
- 大块使得元数据索引更加高效,相同内存容量可管理更大的存储空间。
- 客户端可轻松缓存所有必要的块信息,减少与主节点的通信频率。
- 主节点同样受益,能够缓存更大规模的元数据信息,提升整体管理效率。
-
降低 TCP 连接开销:
- 客户端与 chunkserver 的通信基于长连接,每个块只需维护单个 TCP 连接。
- 大块减少了访问相同数据所需的 TCP 连接数,从而降低网络握手和连接管理的开销。
元数据
主服务器维护三类核心元数据:
- 文件与块的命名空间结构
- 文件到块的映射关系
- 各块副本的存储位置信息
所有元数据均驻留于主服务器内存中。对于前两类元数据(命名空间及文件-块映射),系统通过以下机制确保其持久性:
- 将变更操作记录到主服务器本地磁盘的操作日志
- 同时将该日志复制到远程机器
这种基于日志的机制具有以下优势:
- 提供简单可靠的主服务器状态更新方式
- 确保主服务器崩溃时不会引发数据不一致问题
值得注意的是,系统采用不同的策略处理块位置信息:
- 不进行持久化存储
- 主服务器会主动向各块服务器查询其持有的块信息:
- 主服务器启动时
- 新块服务器加入集群时
主服务器会在后台定期扫描块的状态,以便实现垃圾回收、块的复制、迁移块以实现均衡负载。
Q&A:
Q:所有元数据缓存在内存中会限制系统的最大容量吗?
A:Google 认为不会,一方面每个块的元数据的很小,同时进一步使用前缀压缩以紧凑的存储文件名。最后,为了系统的速度,必要时扩展主节点内存容量也是可以接受的。(尽管最后似乎还是发现内存不够用)
块位置
主节点不持久化块的位置信息,而是按计划轮询 chunkserver 以获取块的位置。这种设计出于以下考虑:
- chunkserver 的频繁故障,使得主节点本身就需要定期更新块信息。
- chunkserver 才是块信息的决定者,主节点上持久化这些信息没有意义。
操作日志
GFS 的操作日志系统具有三个核心功能:
- 它是元数据的唯一持久存储位置
- 它建立了逻辑时间线,用于唯一标识文件、数据块及其版本
- 它确定了并发操作的执行顺序
与数据库的预写日志类似,GFS 的日志系统是其崩溃恢复的基础。因此,GFS 只有在主节点和备份节点都将日志写入磁盘后,才会向客户端发送操作完成确认。
为提高恢复效率,GFS 也采用了类似数据库日志系统的检查点(checkpoint)机制。创建检查点可能耗时较长,因此主节点的内部状态被专门设计为:
- 允许在不阻塞新操作的情况下创建检查点
- 通过切换到新日志文件并在独立线程中创建检查点
- 新检查点包含切换前的所有操作变更,完成后会同时保存到本地和远程磁盘。
恢复时只需使用最新的完整检查点及其后的日志文件。系统可以安全删除旧的检查点和日志文件(但通常会保留几个作为备份)。即使检查点创建失败也不会影响系统可靠性,因为恢复程序能够自动识别并跳过不完整的检查点。
一致性模型
GFS 将块文件分类为以下几个状态:
- 一致(consistent):所有客户端无论从哪个副本读取,都会看到相同的数据内容。
- 已定义(defined):不仅一致,而且客户端能完整看到某个写操作所写入的数据内容。
- 已异变 / 未定义(undefined):虽然可能一致,但数据内容不一定反映任何一个写操作的完整写入结果,可能是多个写操作混合的片段。
注意:“一致”不等于“已定义”,“一致”只是数据在多个副本中相同,而“已定义”还要求数据必须完整反映某次写入。
为了保证一系列成功的变异(多个并发写均成功)修改块是已定义的。GFS 采取了以下措施:
- 在所有副本上进行相同顺序的变异。
- 使用块版本号检查块副本是否过期,过期的副本直接丢弃等待垃圾回收。
客户端缓存:
客户端会缓存近期访问过的块位置信息,因此可能会访问失效的块。为此,首先需要明白客户端的缓存失效受两个机制影响:
- 超时过期,自动失效。
- 重新打开文件,客户端主动重新查询。
我们知道采用强一致性模型往往产生很严重的性能问题,为此 GFS 采用了宽松的一致性模型:
- 命名空间操作的原子性:文件创建、删除等命名空间操作由主节点独占执行,通过命名空间锁和日志实现原子性和全局顺序。
- 并发写的宽松处理:允许多个客户端并发追加数据(如日志文件),但可能产生未定义区域(数据混合),牺牲了强一致性以支持高吞吐。
- 弱化读取一致性:客户端可能读取到过期的副本数据(因副本间同步延迟),但通过校验和和版本号机制最终修复。
- 追加操作的非原子性:追加(append)操作可能部分成功(如某些副本失败),需客户端重试,而非保证原子性写入。当客户端重试后,可能在之前成功追加的 chunkserver 上产生重复数据,客户端需要自行处理这部分。同时,也不保证 GFS 中数据的顺序和写入顺序一致,客户端需要自行重组数据顺序。
写入数据
1. 客户端初始化追加请求:
- 步骤 1:客户端向 Master 节点查询目标文件的最后一个 Chunk 的 ID 及其副本位置(Primary 和 Secondaries)。
- 步骤 2:Master 返回 Chunk 的元数据,包括 Primary Chunkserver 的地址和副本列表。
2. Primary Chunkserver 的生成与作用:
- Primary 的产生:Master 在 Chunk 租约期内(60 秒)指定一个副本为 Primary,负责协调写操作的顺序。
- Primary 的职责:
- 接收客户端的追加请求,确定数据在 Chunk 中的偏移量(由 GFS 自动选择,保证原子性)。
- 确保所有副本以相同顺序执行追加操作。
3. 数据推送与临时存储:
- 步骤 3:客户端将待追加的数据**推送到所有副本(Primary 和 Secondaries)**的临时缓冲区(非直接写入文件)。
- 步骤 4:每个副本(包括 Primary)收到数据后,暂存数据并返回确认(ACK)给客户端。
4. Primary 协调正式追加:
- 步骤 5:客户端收到所有副本的 ACK 后,向 Primary 发送正式追加请求,要求将数据写入文件末尾。
- 步骤 6:Primary 执行以下操作:
- 检查空间:若当前 Chunk 剩余空间不足,填充至最大尺寸(64MB),通知客户端重试到下一个 Chunk。
- 确定偏移量:若空间足够,Primary 将数据追加到本地 Chunk 末尾,并通知所有 Secondaries 在相同偏移量处写入。
- 序列化并发请求:Primary 按接收顺序串行处理多个客户端的并发追加请求,避免竞争。
5. 副本一致性处理:
- 成功场景:所有副本成功写入后,Primary 向客户端返回成功响应,并返回数据写入的偏移量。
- 失败场景:
- 若任一 Secondary 写入失败(如磁盘满、网络超时),Primary 向客户端返回失败响应。
- 客户端需重试整个流程(重新查询 Master 并推送数据)。
- 副本不一致:部分副本可能包含重复或缺失数据,但 GFS 仅保证数据至少被原子写入一次。
6. Primary 失效处理:
- 租约机制:Master 通过租约(lease)管理 Primary 的有效期。若 Primary 失效:
- Master 检测到租约超时后,重新选举新的 Primary。
- 客户端从 Master 获取新 Primary 地址并重试。
- 数据一致性:旧 Primary 未完成的写操作可能被丢弃,客户端需重试以确保最终一致性。
校验和
除了版本号机制检查 chunk 是否过期,GFS 还引入校验和机制保证数据的正确性。具体来说:
- 客户端在发送数据时一并发送 chunk 的校验和。
- chunkserver 接受数据和校验和后,通过 chunkserver 自己的日志保存校验和。
- 校验发生在读取时,当 chunkserver 返回数据(客户端或其他 chunkserver)之前验证校验和,校验失败会导致直接终止传输并返回错误,以阻止坏数据传播。
- 主节点会尝试从其他副本恢复数据。
Q&A
Q:客户端发送和接收数据是流式还是按块处理?
A:按块处理。但写入和读取有细微的差别,写入时按块链式顺序写入,读取时并发获取chunk 链的数据并在本地重组。
Q:GFS 如何处理大文件或跨越块边界的写入?
A:这里分为客户端部分和 GFS 系统部分,客户端会根据写入偏移量和待写数据大小预判分片方式,然后顺序发送写入请求。GFS 会负责实际分片判断,如果 GFS 发现 chunk 实际空间不足,会通知客户端将剩余数据写入下一个 chunk,客户端以此重新计划 chunk 分片。
Q:客户端是否会读取到过期数据?
A:在某些情况下,确实会读取到过期数据。GFS 使用一个递增的版本号来标识 chunk 是否过期,如果客户端首次向主节点请求数据,主节点会通过自身的日志系统判断最新的版本号,然后返回大于等于该版本号的 chunkserver 地址,此时不会读取到过期数据。但客户端自身会缓存 chunkserver 信息,这时如果该 chunkserver 过期了,客户端将会读取到过期的 chunk。GFS 的主节点在返回读取请求时,也会将过期 chunk 进行标记并安排垃圾回收。随着系统运行,过期 chunk 最终会被清除。
Q:为什么 Primary chunkserver 采用定期超时机制(租约(lease)),而不是主节点撤销?
A:
- 简化主节点管理开销:主节点无需持续追踪每个租约的状态并主动撤销,而是依赖时间机制自动处理。这减少了主节点与 chunkserver 之间的通信频率和复杂性。
- 网络不可靠性:如果主节点试图主动撤销租约,但 Primary 副本未能正确接收撤销指令(例如网络中断),则可能出现脑裂(split-brain),即两个副本都认为自己是 Primary,造成数据不一致。
- 安全性与最终一致性:主节点只需等待租约自然到期,就能确保旧的 Primary 不再响应写请求,从而安全地将租约授予另一个副本。
Q:GFS 的系统设计和两阶段提交有什么区别?
A:GFS 并不是一个标准的两阶段提交系统,尽管它的写操作流程在某些方面与 2PC 相似。在 GFS 中,客户端的写操作的流程如下:
- 客户端将数据推送到一组 chunkserver 的链式结构中;
- 当数据到达所有副本后,客户端通知 primary chunkserver;
- primary chunkserver 为操作分配一个序列号并决定写入顺序;
- primary 将写入请求广播给所有 secondary chunkserver;
- 所有副本写入成功后,primary 通知客户端操作成功。
这个过程看起来像 2PC(协调者协调多个副本达成一致),但缺乏 2PC 的原子性保证:GFS 允许部分副本失败,只要 primary 和至少一个 secondary 写入成功即可。
Q:写请求肯定需要经过 Primary Chunkserver,但读请求都通过 Primary Chunkserver 吗?
A:不对,GFS 的读取是并发的。客户端向 Master 获取了 chunkserver 地址之后读取操作将直接通过该 chunkserver 进行,而写入操作会通过“广播”向所有 chunkserver 推送。综上,读取操作是分布在不同机器上执行的。