跳到正文
F3D Life
返回

图解 RustFS 磁盘自描述:从 format.json 到 pool.bin(2026)

RustFS 的 .rustfs.sys/ 目录里,有两个很容易误判的对象:每盘一份的 format.json,以及看起来像系统元数据的 pool.bin/xl.meta

如果只看目录结构,很容易把它们理解成某种中心元数据。但从启动拓扑、磁盘身份、对象 hash 和 xl.meta/part.N 的关系看,RustFS 的恢复模型更像一组分层规则。

本文沿运行顺序拆开这几层:RUSTFS_VOLUMES 先给出 endpoint 拓扑,format.json 再让每块盘证明自己的身份,pool.bin 保存 pool 生命周期状态;对象真正写入时,还要经过 object key hash、set 选择、xl.metapart.1

实验拓扑:RustFS 源码 main@acdf43937162b247619c6a32a5fe079146ca794d,4 个节点,每个节点 2 块盘,一共 8 个 disk endpoint。实验形成 1 个 pool、1 个 set,上传一个 2 MiB 对象后观察磁盘文件和源码路径。

RustFS topology separates startup endpoint input, per-disk identity, pool lifecycle metadata, and PUT object placement into xl.meta and part shards Fig. 这张图要避免一个误读:format.jsonpool.bin 都在磁盘上,但它们不决定同一件事;对象最终落到哪个 set,仍然要走 object key hash 和 xl.meta/part.N

目录

1 问题与环境

证据只取四类:启动日志与 RUSTFS_VOLUMESformat.json 内容、一次 2 MiB PUT 后的 xl.meta/part.1 写入结果、RustFS 源码里的启动/set 选择/pool metadata 入口。rebalance/decommission 的完整状态机不放进本文。

要回答的问题是:

  1. 节点从哪里知道集群有哪些磁盘?
  2. 每块盘怎么证明“我属于这个部署、我是 set 里的第几块盘”?
  3. 一个对象怎么从名字映射到某个 set?
  4. pool.bin 存了什么,它在架构里处在哪一层?
  5. 纠删码里的 6+24+48+4 容忍的是盘、机器,还是别的边界?

实验环境如下:

项目
RustFS 源码main@acdf43937162b247619c6a32a5fe079146ca794d
节点数4
每节点磁盘数2
disk endpoint 总数8
pool / set1 pool / 1 set
上传对象dist-bucket/dist-large-2m.bin
对象逻辑大小2097152 B,也就是 2 MiB

先把两个词钉住。

pool 是一组 disk endpoint 形成的容量集合,也是 RustFS 做容量生命周期管理的单位。 一个集群可以有一个或多个 pool。新增容量、decommission、pool metadata reload 这类动作围绕 pool 展开。pool.bin 记录的就是 pool 列表和这些 pool 的生命周期状态。

set 是 pool 内部的对象放置组,也是纠删码的工作边界。 一个 pool 会被切成一个或多个 set;每个 set 有固定数量的磁盘。对象写入时,RustFS 先用 object key hash 选择 set,再在这个 set 内做纠删码切分,把 xl.metapart.N shard 写到 set 的磁盘上。

在本实验里,8 个 disk endpoint 形成 1 个 pool,并且这个 pool 里只有 1 个 8 盘 set:

pool 0
  set 0
    node1/disk1
    node1/disk2
    node2/disk1
    node2/disk2
    node3/disk1
    node3/disk2
    node4/disk1
    node4/disk2

后面所有现象都围绕这个边界展开:format.json 让每块盘知道自己在 set 里的身份,pool.bin 让运行时知道 pool 的生命周期状态,PUT 对象时再由 object key hash 进入 set。

后面的输出都来自这个环境。为避免发布本机挂载路径,命令输出里的 /data/rustfs-dist/ 前缀会显式省略成 nodeN/diskN/;对象 data dir UUID 在路径里用 <data-dir> 标记。

2 启动入口:RUSTFS_VOLUMES 定义同一张拓扑图

本实验的“4 节点 8 磁盘”不是 4 台真实服务器,而是在一台机器上用 4 个 RustFS 进程模拟 4 个节点。每个节点挂两块目录盘:

node1: /data1, /data2  -> <mount>/rustfs-dist/node1/disk1, disk2
node2: /data1, /data2  -> <mount>/rustfs-dist/node2/disk1, disk2
node3: /data1, /data2  -> <mount>/rustfs-dist/node3/disk1, disk2
node4: /data1, /data2  -> <mount>/rustfs-dist/node4/disk1, disk2

关键配置是一模一样的 RUSTFS_VOLUMES。在这个实验里,它的值是 8 个 endpoint:

export RUSTFS_VOLUMES="http://node1:9000/data1 http://node1:9000/data2 http://node2:9000/data1 http://node2:9000/data2 http://node3:9000/data1 http://node3:9000/data2 http://node4:9000/data1 http://node4:9000/data2"

这行的含义很直接:

http://node1:9000/data1  node1 的第 1 块盘
http://node1:9000/data2  node1 的第 2 块盘
...
http://node4:9000/data2  node4 的第 2 块盘

如果用容器在单机上复现实验,启动形态可以写成下面这样。这里的 <image> 是本地构建或拉取的 RustFS 镜像;<mount> 是发布文章时脱敏后的宿主机挂载点。

mkdir -p <mount>/rustfs-dist/node{1,2,3,4}/{disk1,disk2,logs}

docker network create rustfs-dist

for n in 1 2 3 4; do
  docker run -d --name "node${n}" --hostname "node${n}" \
    --network rustfs-dist \
    -p "$((8999 + n)):9000" \
    -e RUSTFS_ADDRESS=":9000" \
    -e RUSTFS_CONSOLE_ENABLE="false" \
    -e RUSTFS_VOLUMES="$RUSTFS_VOLUMES" \
    -v "<mount>/rustfs-dist/node${n}/disk1:/data1" \
    -v "<mount>/rustfs-dist/node${n}/disk2:/data2" \
    -v "<mount>/rustfs-dist/node${n}/logs:/logs" \
    <image>
done

这不是唯一部署方式。真实多机时,node1node4 会换成机器 DNS/IP,本地挂载路径会换成各机器自己的磁盘路径;关键不变:所有节点拿到同一份 RUSTFS_VOLUMES endpoint 列表。

node1 日志里已经能看到这个拓扑被识别出来;/data1/data2 是 node1 本地盘,http://node2:9000/data1 这类是远端盘:

{"timestamp":"2026-06-29T01:38:39.237414309Z","level":"INFO","fields":{"message":"Disk \"/data1\" is online"},"target":"rustfs_ecstore::set_disk::lock","filename":"crates/ecstore/src/set_disk/lock.rs","line_number":262,"threadName":"rustfs-worker","threadId":"ThreadId(29)"}
{"timestamp":"2026-06-29T01:38:39.237443614Z","level":"INFO","fields":{"message":"Disk \"/data2\" is online"},"target":"rustfs_ecstore::set_disk::lock","filename":"crates/ecstore/src/set_disk/lock.rs","line_number":262,"threadName":"rustfs-worker","threadId":"ThreadId(29)"}
{"timestamp":"2026-06-29T01:38:39.237477888Z","level":"INFO","fields":{"message":"Disk \"http://node2:9000/data1\" is online"},"target":"rustfs_ecstore::set_disk::lock","filename":"crates/ecstore/src/set_disk/lock.rs","line_number":262,"threadName":"rustfs-worker","threadId":"ThreadId(29)"}
{"timestamp":"2026-06-29T01:38:39.237496253Z","level":"INFO","fields":{"message":"Disk \"http://node2:9000/data2\" is online"},"target":"rustfs_ecstore::set_disk::lock","filename":"crates/ecstore/src/set_disk/lock.rs","line_number":262,"threadName":"rustfs-worker","threadId":"ThreadId(29)"}

这个日志证明了一件事:从 node1 的视角,它既能看到本地磁盘,也能通过 endpoint 看到其他节点的磁盘。

再回到源码入口。RustFS server 的 volumes 参数来自命令行或环境变量 RUSTFS_VOLUMES。源码里 ServerOpts.volumes 标了 env = "RUSTFS_VOLUMES",并用空格拆分 endpoint 列表:

#[arg(
    required = true,
    env = "RUSTFS_VOLUMES",
    value_delimiter = ' ',
    value_parser = NonEmptyStringValueParser::new()
)]
pub volumes: Vec<String>,

源码锚点: rustfs/src/config/cli.rs#L172-L182 说明 ServerOpts.volumes 可以从 RUSTFS_VOLUMES 读取,并按空格切分成 endpoint 列表。

多机部署不是靠节点随便广播“我要加入哪个集群”。更准确的模型是:

all nodes receive the same RUSTFS_VOLUMES
  -> parse endpoint list
  -> decide local endpoints vs remote endpoints
  -> read or create per-disk format.json
  -> build pool / set / disk mapping

RUSTFS_VOLUMES 给出预期拓扑,但它本身还不足以说明“这块盘是谁”。磁盘身份落在 .rustfs.sys/format.json

这个顺序很重要:节点不是先扫描目录再猜集群有哪些盘,而是先拿到同一份 endpoint 拓扑,再用本地/远端磁盘上的 format.json 校验这张拓扑。

3 format.json:为什么节点能恢复 pool/set/disk 视图

format.json 是每块盘的身份文件。真实 JSON 里 id 在同一部署内相同,xl.sets 记录 set 矩阵,而每块盘的 xl.this 不同。

# cwd: <mount>/rustfs-dist
jq . node1/disk1/.rustfs.sys/format.json
for f in node*/disk*/.rustfs.sys/format.json; do
  jq -r .xl.this "$f"
done | sort

RustFS format.json 真实 JSON 结构显示顶层 id、xl.this、xl.sets 和 distributionAlgo,并列出 8 个磁盘的 this UUID Fig. 这里最值得看的是 xl.this:每块盘看到的 sets 相同,但 this 指向自己那一格;这就是磁盘身份和全局布局同时存在于同一个文件里的地方。

抽取其中一块盘的 format.json,真实形状是:

{
  "version": "1",
  "format": "xl",
  "id": "13c779ee-1cf5-4b6d-bbfd-8c5298e04e2e",
  "xl": {
    "version": "3",
    "this": "63124b32-a325-4072-9b45-e56a8eb6ae75",
    "sets": [
      [
        "63124b32-a325-4072-9b45-e56a8eb6ae75",
        "7cee30d3-b5bb-4591-8931-f5998f8eb455",
        "... 6 more disk UUIDs omitted ..."
      ]
    ],
    "distributionAlgo": "SIPMOD+PARITY"
  }
}

上面的 "... 6 more disk UUIDs omitted ..." 是明确省略,真实文件里这里还有 6 个 disk UUID。这里直接读真实字段:id 是部署 ID,xl.this 是当前磁盘 UUID,xl.sets 是 set 矩阵,xl.distributionAlgo 是分布算法。

8 块盘的 this 正好是 8 个不同 UUID:

0c57ef04-0734-4851-b96f-ca32a2194512
1106aec2-0326-4292-a1d2-75869e183d28
54462c11-0012-4430-a764-087666e6c87a
6050e0a2-7639-4e2f-8cf3-802e7062959c
63124b32-a325-4072-9b45-e56a8eb6ae75
7cee30d3-b5bb-4591-8931-f5998f8eb455
d30eade3-aee9-4bea-832e-337e187d6422
f089621e-7aa2-4772-959f-42c2aae42f30

源码里的创建逻辑也能对上这个现象:init_format_erasure 先创建一个包含 setsFormatV3,然后按 (set_idx, disk_idx) 给每块盘写入不同的 erasure.this,最后保存到所有磁盘。

for i in 0..set_count {
    for j in 0..set_drive_count {
        let idx = i * set_drive_count + j;
        let mut newfm = fm.clone();
        newfm.erasure.this = fm.erasure.sets[i][j];
        fms[idx] = Some(newfm);
    }
}

save_format_file_all(disks, &fms).await?;

源码锚点: crates/ecstore/src/store_init.rs#L129-L150 说明初始化时会按 set/disk 下标给每块盘写入不同的 erasure.this,然后保存 format.json

RustFS 的“磁盘自描述”不是说每块盘保存了所有动态状态,而是说每块盘保存了足够稳定的身份和布局信息:

deployment id: 我属于哪个部署
this: 我是哪块盘
sets: 这个 pool 的 set 矩阵长什么样
distributionAlgo: 对象分布算法是什么

启动时,RUSTFS_VOLUMES 是拓扑输入,format.json 是磁盘身份账本。两者对得上,节点才能恢复出同一套 pool/set/disk 视图。

4 pool.bin:pool 生命周期账本,不是对象索引

pool.bin 是 RustFS 持久化 pool 状态的内部 config 对象。

它和 format.json 分工不同:format.json 记录磁盘身份和 set 布局,pool.bin 记录 pool 列表和 pool 生命周期状态。

它不是业务对象索引,不记录 bucket/object 落在哪个 pool 或 set,也不替代 format.jsonpool.bin 只回答另一类问题:当前有哪些 pool,这些 pool 是否处在 decommission 这类生命周期流程中。

这也回答一个容易混淆的问题:bucket 创建之后,并不是 bucket 固定绑定到某个 pool。 pool 是对象写入时选择出来的,不是 bucket 创建时写死的。

get_pool_idx 的路径可以拆成四步:

  1. 如果集群只有一个 pool,PUT 直接进入 pools[0]
  2. 如果有多个 pool,PUT 调用 get_pool_idx(bucket, object, size)
  3. get_pool_idx 先跨所有 pool 查这个对象是否已经存在;如果对象已经存在,就返回已有对象所在的 pool。这个逻辑能让覆盖写、已有版本和迁移场景尽量保持在原来的 pool。
  4. 如果所有 pool 都查不到这个对象,说明这是一个新对象;这时 RustFS 会计算各 pool 的可用空间,并按可用空间加权选出一个 pool。选中 pool 之后,才在该 pool 内用 object key hash 选择 set。

更准确的说法是:bucket 不绑定 pool;对象写入会先找已有对象位置,找不到再按容量选择 pool。

源码锚点: crates/ecstore/src/store/object.rs#L624-L656 说明 PUT 会在多 pool 场景下调用 get_pool_idx 选择目标 pool; crates/ecstore/src/store/rebalance.rs#L341-L370 说明 get_pool_idx 先查已有对象位置,找不到时再走可用空间选择; crates/ecstore/src/store/rebalance.rs#L405-L457 说明已有对象查找会查询每个 pool 的 object info,并返回命中的 pool; crates/ecstore/src/store/rebalance.rs#L248-L273 说明新对象的 pool 选择会基于各 pool 可用空间做加权选择。

磁盘上看到的是这个路径:

.rustfs.sys/pool.bin/xl.meta

这只是它的磁盘外壳。对象层看,它是 .rustfs.sys bucket 下名为 pool.bin 的内部对象,内容是 RustFS 自己编码的 PoolMeta

用 RustFS 自带的 dump_fileinfo 解码,这个样本里的 pool.bin 是一个 inline 的 xl.meta 对象:

cargo run -p rustfs-filemeta --example dump_fileinfo -- \
  node1/disk1/.rustfs.sys/pool.bin/xl.meta

RustFS dump_fileinfo 解码 pool.bin/xl.meta 和用户对象 xl.meta,pool.bin 显示 inline data 标记,用户对象显示逻辑大小 2097152 字节 Fig. 左右两边都叫 xl.meta,但语义完全不同:pool.bin/xl.meta 里是内部 config 对象,用户对象的 xl.meta 才记录 2 MiB 对象的 size、part 和 erasure 信息。

关键行是:

path: node1/disk1/.rustfs.sys/pool.bin/xl.meta
size: 234
etag: Some("951a7de075b3ec05f9e09e74bea75345")
parts: 1
part#0: number=1 size=234 actual_size=234 etag=951a7de075b3ec05f9e09e74bea75345
part#0.index: none
metadata entries: 3
meta[etag]=951a7de075b3ec05f9e09e74bea75345
meta[x-minio-internal-inline-data]=true
meta[x-rustfs-internal-inline-data]=true

外壳只说明“它怎么存在磁盘上”。真正重要的是里面的 PoolMetapool.bin 这个名字来自 POOL_META_NAME

pub const POOL_META_NAME: &str = "pool.bin";

源码锚点: crates/ecstore/src/pools.rs#L84-L86 定义 pool.bin 这个内部对象名。

读写它时,RustFS 走 read_config / save_config,并固定使用内部命名空间 .rustfs.sys。源码里有两个锚点: crates/ecstore/src/config/com.rs#L185-L241 负责配置对象的读写路径; crates/ecstore/src/disk/mod.rs#L26 定义内部命名空间常量 .rustfs.sys。两者连起来说明 pool.bin 写在 RustFS 内部对象路径下,而不是外部数据库。

这时 pool.bin 的定位已经足够明确:

bucket: .rustfs.sys
object: pool.bin
disk representation: .rustfs.sys/pool.bin/xl.meta
write path: save_config(pool, "pool.bin", bytes)
read path: read_config(pool, "pool.bin")

保存时,PoolMeta::save 会把当前 PoolMeta 保存到所有 pool。编码细节对理解架构不是重点;这里需要抓住的是:pool.binPoolMeta 的持久化形态。

源码锚点: crates/ecstore/src/pools.rs#L844-L857 说明保存 pool.bin 时会序列化 PersistedPoolMeta,并保存到所有 pool。

PoolMeta::load 则反过来读取 pool.bin,校验格式和版本后恢复 PersistedPoolMeta

源码锚点: crates/ecstore/src/pools.rs#L807-L841 说明加载 pool.bin 时会校验格式号、版本号,再解码出 PersistedPoolMeta

真正持久化的结构不是 format.json 里的 set 拓扑,而是 pool 状态数组:

struct PersistedPoolMeta {
    pub version: u16,
    pub pools: Vec<PersistedPoolStatus>,
}

struct PersistedPoolStatus {
    #[serde(rename = "id")]
    pub id: usize,
    #[serde(rename = "cmdline")]
    pub cmd_line: String,
    #[serde(rename = "lastUpdate", with = "time::serde::rfc3339")]
    pub last_update: OffsetDateTime,
    #[serde(rename = "decommissionInfo")]
    pub decommission: Option<PersistedPoolDecommissionInfo>,
}

源码锚点: crates/ecstore/src/pools.rs#L590-L606 给出 PersistedPoolMetaPersistedPoolStatus 的持久化结构。

如果 pool 正在 decommission,decommissionInfo 里还会持久化开始时间、容量进度、完成/失败/取消状态、待处理 bucket、已处理 bucket、当前 bucket/prefix/object,以及对象数和字节数统计。 源码锚点: crates/ecstore/src/pools.rs#L608-L642 给出 PersistedPoolDecommissionInfo 的真实字段。

pool.bin 的内容可以按下面的结构理解:

PoolMeta
  version
  pools[]
    id                 pool 下标
    cmdline            这个 pool 对应的启动 endpoint 表达
    lastUpdate         最近更新时间
    decommissionInfo?  decommission 进度和状态

这不是从 pool.bin/xl.meta 里直接打印出来的 JSON。已验证的是:磁盘外壳是 xl.meta,源码写入和读取的是 PersistedPoolMeta。本文不展开它的二进制编码细节。

它在架构中的位置也因此清楚了:format.json 解决“这块盘是谁、属于哪个 set”;pool.bin 解决“当前有哪些 pool,以及这些 pool 的生命周期状态如何”。

它主要在三类时刻起作用:

  1. 启动时加载:RustFS 读取 pool.bin,把 PoolMeta 放进运行时的 ECStore.pool_meta,并校验当前 pool 列表。
  2. 生命周期变化时保存:例如 decommission 启动或状态变化时,RustFS 更新 pool_meta,再保存回 pool.bin
  3. 节点间同步时 reload:一个节点更新后,会通知其他节点重新加载 pool.bin,替换内存里的 pool_meta

源码锚点: crates/ecstore/src/store/init.rs#L362-L374 说明启动时会加载并校验 PoolMetacrates/ecstore/src/pools.rs#L2574-L2590 说明 decommission 路径会保存 pool_meta 并通知 reload; crates/ecstore/src/store/rebalance.rs#L625-L635 说明 peer reload 会重新加载 PoolMeta 并替换内存状态。

把这些证据合起来,pool.bin 的语义栈是:

结论
位置.rustfs.sys/pool.bin/xl.meta 是磁盘上的对象元数据外壳
外层格式xl.meta;这个小对象样本里内容 inline 在元数据里
对象路径.rustfs.sys bucket 下的 pool.bin config 对象
内容PersistedPoolMeta:pool 列表、更新时间、decommission 状态
写入者初始化校验需要更新时、decommission 状态变化时调用 PoolMeta::save
读取者启动和 peer reload 时调用 PoolMeta::load
边界不记录每个业务对象在哪里,也不替代 format.json 的磁盘身份和 set 布局

因此可以这样区分:

format.json
  local disk identity file
  plain JSON
  every disk has one

pool.bin
  internal RustFS object
  stored under .rustfs.sys
  represented on disk by xl.meta and possibly inline data
  content = PoolMeta state: pool list and lifecycle state
  loaded into ECStore.pool_meta

format.json 更像每块盘的身份证;pool.bin 更像 RustFS 自己存在对象层里的 pool 生命周期账本。 它不是对象索引,也不决定某个业务对象进入哪个 set。

启动阶段的三个概念可以串起来:

RUSTFS_VOLUMES  给出预期 endpoint 拓扑
format.json     让每块盘证明自己的 deployment / set / disk 身份
pool.bin        让运行时知道 pool 列表和 pool 生命周期状态

对象写入不直接从目录结构里推导位置,它仍然要靠 deployment id、object key hash 和 set 布局。

5 PUT 2 MiB:对象如何进入 set 并落盘

有了 pool/set/disk 视图之后,对象写入还需要解决另一个问题:这个 object key 进入哪个 set?

RustFS 在 Sets 里有一个很直接的路径。两个相关函数如下;源码中间隔着 storage info 相关函数,这里只保留 set 选择路径:

pub fn get_disks_by_key(&self, key: &str) -> Arc<SetDisks> {
    self.get_disks(self.get_hashed_set_index(key))
}

fn get_hashed_set_index(&self, input: &str) -> usize {
    match self.distribution_algo {
        DistributionAlgoVersion::V1 => crc_hash(input, self.disk_set.len()),
        DistributionAlgoVersion::V2 | DistributionAlgoVersion::V3 => {
            sip_hash(input, self.disk_set.len(), self.id.as_bytes())
        }
    }
}

源码锚点: crates/ecstore/src/sets.rs#L287-L340 说明对象名会先映射到 set index,再拿到该 set 对应的磁盘集合。

set 选择不是扫描目录得出的,而是由 object key 确定性哈希得出的。本实验里的 distributionAlgoSIPMOD+PARITY,对应 V2/V3 的 sip hash 路径;hash key 用的是 deployment id。

本实验只有一个 set,所有对象都会落到 set 0。换成多个 set 的 pool,路径会变成:

object name
  -> sip_hash(object_name, set_count, deployment_id)
  -> set index
  -> set disks
  -> xl.meta + part.N shards

这也是为什么“只看目录”会误导:目录是写入后的形态,不是对象定位的规则。对象先通过 hash 进入某个 set,再在 set 内按纠删码布局写出 xl.metapart.1

沿着 PUT 路径走到落盘阶段,再检查写入结果。8 块盘上都有用户对象 xl.meta,同一个对象在 8 块盘上各有一个 part.1 分片;截图里同时出现的 format.jsonpool.bin/xl.meta 是前面两节讲过的系统元数据。

# cwd: <mount>/rustfs-dist
stat -c '%n %s bytes' node*/disk*/.rustfs.sys/format.json \
  node*/disk*/.rustfs.sys/pool.bin/xl.meta \
  node*/disk*/dist-bucket/dist-large-2m.bin/xl.meta
find node*/disk*/dist-bucket/dist-large-2m.bin -name part.1 \
  -exec stat -c '%n %s bytes' {} \;

RustFS 4 节点 8 磁盘实验里,每块盘都有 format.json、pool.bin/xl.meta、对象 xl.meta,且每块盘的 part.1 分片大小都是 524352 字节 Fig. 这一屏把三类文件放到一起看:format.json 是每盘身份,pool.bin/xl.meta 是 pool 生命周期对象,用户对象的 xl.metapart.1 才是这次 PUT 的落盘结果。

node1/disk1/.rustfs.sys/format.json 498 bytes
node1/disk1/.rustfs.sys/pool.bin/xl.meta 537 bytes
node1/disk1/dist-bucket/dist-large-2m.bin/xl.meta 413 bytes
... 12 metadata lines omitted ...
node4/disk2/.rustfs.sys/format.json 498 bytes
node4/disk2/.rustfs.sys/pool.bin/xl.meta 537 bytes
node4/disk2/dist-bucket/dist-large-2m.bin/xl.meta 413 bytes

对象的数据分片完整列表如下:

node1/disk1/dist-bucket/dist-large-2m.bin/<data-dir>/part.1 524352 bytes
node1/disk2/dist-bucket/dist-large-2m.bin/<data-dir>/part.1 524352 bytes
node2/disk1/dist-bucket/dist-large-2m.bin/<data-dir>/part.1 524352 bytes
node2/disk2/dist-bucket/dist-large-2m.bin/<data-dir>/part.1 524352 bytes
node3/disk1/dist-bucket/dist-large-2m.bin/<data-dir>/part.1 524352 bytes
node3/disk2/dist-bucket/dist-large-2m.bin/<data-dir>/part.1 524352 bytes
node4/disk1/dist-bucket/dist-large-2m.bin/<data-dir>/part.1 524352 bytes
node4/disk2/dist-bucket/dist-large-2m.bin/<data-dir>/part.1 524352 bytes

这组数字给出对象路径的结果:

2 MiB 逻辑对象
  -> hash 到 pool 0 / set 0
  -> 8 个 part.1
  -> 每个 part.1 524352 B
  -> 合计约 4 MiB

这不是 8 份完整复制,而是 4+4 纠删码:4 个 data shard,4 个 parity shard。对象恢复依赖 set 内的 shard 和 xl.meta,不是依赖目录里某一份完整文件。

6 纠删码边界:4+4、6+2、8+4 到底差在哪

实验里的 set 宽度是 8,对象被写成 8 个 part.1,每个 524352 B。逻辑对象是 2 MiB,8 个分片合计约 4 MiB,符合 4+4 的空间直觉。

RustFS 4 plus 4 erasure set shows node1 failure losing two shards in the same set while other nodes keep data and parity shards Fig. 图里的红色不是“坏了一台机器”这么简单,而是同一个 set 里少了 2 个 shard;换成 6+2 时,这个差别会直接影响能不能恢复。

纠删码利用率可以先用一个简单公式理解:

usable = data_shards / (data_shards + parity_shards)
组合set 宽度利用率单个 set 内最多容忍缺失 shard
4+4850.0%4
4+2666.7%2
6+2875.0%2
8+41266.7%4

这里最容易踩的坑是把 6+2 解释成“最多坏两台机器”。严格说,它最多容忍的是同一个 set 内缺失 2 个 shard。

如果一个 set 横跨 8 台机器,每台机器只放这个 set 的 1 块盘,那么坏 2 台机器确实等价于丢 2 个 shard。

但在这个实验拓扑里,4 台机器 × 每台 2 块盘,且这 8 块盘都在同一个 set 中。一台机器掉线就可能丢 2 个 shard;两台机器掉线就可能丢 4 个 shard。对于 6+2,这已经超过 parity 数。

机器级容灾不是只看 +2+4,还要看 set 内 shard 如何跨机器摆放。

4+2 和 8+4 的利用率都约等于 66.7%,但它们也不等价:

选纠删码配置时,先问两个问题:这个 set 横跨多少台机器?一次机器级故障会让这个 set 丢几个 shard?

7 pool、set 与扩容/坏槽位

有了前面的定义,扩容和坏槽位问题就能落到两个边界上:pool 决定容量生命周期,set 决定对象放置和故障容忍。

已有 pool 的 set 几何形状不是随手改变的。比如一个 pool 已经由若干个 8 盘 set 组成,它不是简单加一块盘就把旧 set 变成 9 盘 set。新增容量通常体现为新增 pool,再通过迁移、rebalance 或 decommission 类流程改变数据分布。

源码里 endpoint 展开和 set 分组也体现了“先根据 volumes 形成 endpoint list,再按 set size 切成 set”的模型:

pub fn from_volumes<T: AsRef<str>>(args: &[T], set_drive_count: usize) -> Result<Self> {
    let mut arg_patterns = Vec::with_capacity(args.len());
    for arg in args {
        arg_patterns.push(find_ellipses_patterns(arg.as_ref())?);
    }

    let total_sizes = get_total_sizes(&arg_patterns);
    let set_indexes = get_set_indexes(args, &total_sizes, set_drive_count, &arg_patterns)?;

    let mut endpoints = Vec::new();
    for ap in arg_patterns.iter() {
        let aps = ap.expand();
        for bs in aps {
            endpoints.push(bs.join(""));
        }
    }

    Ok(EndpointSet {
        set_indexes,
        _arg_patterns: arg_patterns,
        endpoints,
    })
}

源码锚点: crates/ecstore/src/disks_layout.rs#L262-L283 说明 endpoint 会从 volumes 展开,再按 set drive count 形成 set 分组。

这带来几个实践判断:

  1. 如果一个硬盘槽位坏了,且不能在同一位置恢复可用 endpoint,原 set 会持续带着这个风险。
  2. 新增 pool 可以接收新写入或迁移后的数据,但它不会神奇修好旧 set 的故障域。
  3. 把 pool 粒度切得很小,比如“一个 set 一个 pool”,可以让隔离更细,但也会增加容量均衡、迁移和运维观察成本。
  4. 更关键的不是 pool 多小,而是每个 set 的 shard 是否按期望跨机器、跨机架或跨故障域分布。

更稳定的工作模型是:pool 是“容量生命周期单位”,set 是“对象放置和故障容忍单位”。这两个边界不要混着看。

8 小结

把启动路径、写入路径和元数据持久化放在一起,可以得到这个工作模型:

startup path:
  RUSTFS_VOLUMES
    -> endpoint list
    -> local/remote disk mapping
    -> read or create format.json
    -> pool / set / disk view
    -> load pool.bin into ECStore.pool_meta

metadata persistence:
  format.json = per-disk identity and set layout
  pool.bin = persisted PoolMeta: pool list and lifecycle state

object PUT path:
  object name
    -> hash to set
    -> erasure encode inside the set
    -> write xl.meta and part.N shards

实验输出和源码路径共同指向同一个结论:RustFS 的“自描述”不是没有集群拓扑,而是把拓扑分散到几个层次里。 RUSTFS_VOLUMES 给出预期 endpoint 列表;format.json 固化每块盘的 deployment、set 和 disk 身份;pool.bin 持久化 pool 生命周期状态;对象写入时再用 object key hash、set 布局和 xl.meta/part.N 完成数据放置。

参考资料


分享这篇文章:

上一篇
RustFS 扩容、Rebalance 与换盘回填实践笔记(2026)
下一篇
Rust 学习笔记 02:用链表理解 Box、Option 和智能指针