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.meta 和 part.1。
实验拓扑:RustFS 源码 main@acdf43937162b247619c6a32a5fe079146ca794d,4 个节点,每个节点 2 块盘,一共 8 个 disk endpoint。实验形成 1 个 pool、1 个 set,上传一个 2 MiB 对象后观察磁盘文件和源码路径。
Fig. 这张图要避免一个误读:
format.json 和 pool.bin 都在磁盘上,但它们不决定同一件事;对象最终落到哪个 set,仍然要走 object key hash 和 xl.meta/part.N。
目录
- 1 问题与环境
- 2 启动入口:RUSTFS_VOLUMES 定义同一张拓扑图
- 3 format.json:为什么节点能恢复 pool/set/disk 视图
- 4 pool.bin:pool 生命周期账本,不是对象索引
- 5 PUT 2 MiB:对象如何进入 set 并落盘
- 6 纠删码边界:4+4、6+2、8+4 到底差在哪
- 7 pool、set 与扩容/坏槽位
- 8 小结
- 参考资料
1 问题与环境
证据只取四类:启动日志与 RUSTFS_VOLUMES、format.json 内容、一次 2 MiB PUT 后的 xl.meta/part.1 写入结果、RustFS 源码里的启动/set 选择/pool metadata 入口。rebalance/decommission 的完整状态机不放进本文。
要回答的问题是:
- 节点从哪里知道集群有哪些磁盘?
- 每块盘怎么证明“我属于这个部署、我是 set 里的第几块盘”?
- 一个对象怎么从名字映射到某个 set?
pool.bin存了什么,它在架构里处在哪一层?- 纠删码里的
6+2、4+4、8+4容忍的是盘、机器,还是别的边界?
实验环境如下:
| 项目 | 值 |
|---|---|
| RustFS 源码 | main@acdf43937162b247619c6a32a5fe079146ca794d |
| 节点数 | 4 |
| 每节点磁盘数 | 2 |
| disk endpoint 总数 | 8 |
| pool / set | 1 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.meta 和 part.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
这不是唯一部署方式。真实多机时,node1 到 node4 会换成机器 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
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 先创建一个包含 sets 的 FormatV3,然后按 (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.json。pool.bin 只回答另一类问题:当前有哪些 pool,这些 pool 是否处在 decommission 这类生命周期流程中。
这也回答一个容易混淆的问题:bucket 创建之后,并不是 bucket 固定绑定到某个 pool。 pool 是对象写入时选择出来的,不是 bucket 创建时写死的。
get_pool_idx 的路径可以拆成四步:
- 如果集群只有一个 pool,PUT 直接进入
pools[0]。 - 如果有多个 pool,PUT 调用
get_pool_idx(bucket, object, size)。 get_pool_idx先跨所有 pool 查这个对象是否已经存在;如果对象已经存在,就返回已有对象所在的 pool。这个逻辑能让覆盖写、已有版本和迁移场景尽量保持在原来的 pool。- 如果所有 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
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
外壳只说明“它怎么存在磁盘上”。真正重要的是里面的 PoolMeta。pool.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.bin 是 PoolMeta 的持久化形态。
源码锚点:
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
给出 PersistedPoolMeta 和 PersistedPoolStatus 的持久化结构。
如果 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 的生命周期状态如何”。
它主要在三类时刻起作用:
- 启动时加载:RustFS 读取
pool.bin,把PoolMeta放进运行时的ECStore.pool_meta,并校验当前 pool 列表。 - 生命周期变化时保存:例如 decommission 启动或状态变化时,RustFS 更新
pool_meta,再保存回pool.bin。 - 节点间同步时 reload:一个节点更新后,会通知其他节点重新加载
pool.bin,替换内存里的pool_meta。
源码锚点:
crates/ecstore/src/store/init.rs#L362-L374
说明启动时会加载并校验 PoolMeta;
crates/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 确定性哈希得出的。本实验里的 distributionAlgo 是 SIPMOD+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.meta 和 part.1。
沿着 PUT 路径走到落盘阶段,再检查写入结果。8 块盘上都有用户对象 xl.meta,同一个对象在 8 块盘上各有一个 part.1 分片;截图里同时出现的 format.json 和 pool.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' {} \;
Fig. 这一屏把三类文件放到一起看:format.json 是每盘身份,pool.bin/xl.meta 是 pool 生命周期对象,用户对象的 xl.meta 和 part.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 的空间直觉。
Fig. 图里的红色不是“坏了一台机器”这么简单,而是同一个 set 里少了 2 个 shard;换成 6+2 时,这个差别会直接影响能不能恢复。
纠删码利用率可以先用一个简单公式理解:
usable = data_shards / (data_shards + parity_shards)
| 组合 | set 宽度 | 利用率 | 单个 set 内最多容忍缺失 shard |
|---|---|---|---|
| 4+4 | 8 | 50.0% | 4 |
| 4+2 | 6 | 66.7% | 2 |
| 6+2 | 8 | 75.0% | 2 |
| 8+4 | 12 | 66.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%,但它们也不等价:
- 4+2 的 set 更小,读写扇出更少,恢复影响面也更小;
- 8+4 的 set 更大,最多容忍 4 个 shard 缺失,但每个对象牵涉更多盘,慢盘和恢复的影响面更大。
选纠删码配置时,先问两个问题:这个 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 分组。
这带来几个实践判断:
- 如果一个硬盘槽位坏了,且不能在同一位置恢复可用 endpoint,原 set 会持续带着这个风险。
- 新增 pool 可以接收新写入或迁移后的数据,但它不会神奇修好旧 set 的故障域。
- 把 pool 粒度切得很小,比如“一个 set 一个 pool”,可以让隔离更细,但也会增加容量均衡、迁移和运维观察成本。
- 更关键的不是 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 source
acdf43937162b247619c6a32a5fe079146ca794d:rustfs/src/config/cli.rs - RustFS source
acdf43937162b247619c6a32a5fe079146ca794d:crates/ecstore/src/store_init.rs - RustFS source
acdf43937162b247619c6a32a5fe079146ca794d:crates/ecstore/src/store/init.rs - RustFS source
acdf43937162b247619c6a32a5fe079146ca794d:crates/ecstore/src/pools.rs - RustFS source
acdf43937162b247619c6a32a5fe079146ca794d:crates/ecstore/src/config/com.rs - RustFS source
acdf43937162b247619c6a32a5fe079146ca794d:crates/ecstore/src/store/rebalance.rs - RustFS source
acdf43937162b247619c6a32a5fe079146ca794d:crates/ecstore/src/sets.rs - RustFS source
acdf43937162b247619c6a32a5fe079146ca794d:crates/ecstore/src/disks_layout.rs - RustFS source
acdf43937162b247619c6a32a5fe079146ca794d:crates/filemeta/src/filemeta.rs - 实验命令:
cargo run -p rustfs-filemeta --example dump_fileinfo -- <xl.meta>