RustFS 文档 文档

RustFS Chunk 格式规范 — M0(中文版)

RustFS Chunk 格式规范 — M0(中文版)

文档编号:   RUSTFS-SPEC-CHUNK-001
状态:       草案——里程碑 M0 的冻结目标
版本:       format_version = 1
范围:       on-disk shard 文件格式、流式 bitrot、chunk↔shard 映射、chunk-key
            路径方案,以及持久化的写/读协议。这是 chunk-store 与自校验完整性
            模型的地基。
语言:       中文  (English mirror: rustfs-chunk-format-EN.md)
字节序:     除非另行说明,一律小端(little-endian)

1. 术语与存储层级

File(文件)   POSIX 文件 / S3 对象 / 块卷 的逻辑字节。
Chunk(块)    文件的固定逻辑切片:chunk_size 字节(默认 4 MiB)。
              由 (file_id, version, chunk_index) 标识。
Shard(分片)  真正存于一块磁盘上的单元。chunk 按存储策略变换为分片:
                · EC(k,m)     → k 个数据分片 + m 个校验分片
                · Replica(r)  → r 个完全相同的分片(概念上 k=1, m=0)
Shard file    保存一个分片的磁盘文件,采用本规范定义的格式。
Bitrot block  在 shard 文件内部计算一个完整性哈希的单元(默认 16 KiB)。
              shard 文件的数据区是一串 bitrot block;每个在哈希表中有一个
              HighwayHash-256 条目。
EC block      对原始 chunk 字节做纠删编码的单元(默认 1 MiB);每个 EC block
              为每个数据分片产生一个 shard_fragment。
File ──切分──▶ Chunk ──EC 或 Replica──▶ Shard ──存为──▶ Shard 文件
                                                          │
                                     [ Header | HashTable | Data ]
                                     Data = bitrot block,每块带哈希

权威性说明:KV 中的 inode Layout 对放置与解码参数具有权威性。shard 文件 头部(见下)复制了关键参数,使每个分片对校验、巡检、灾难恢复自描述。若头部与 inode 不一致,即为损坏信号——正常路径上头部绝不用于覆盖放置。


2. Shard 文件布局(顶层)

shard 文件由三个连续、4 KiB 对齐的区域组成:

偏移                           区域
─────────────────────────────  ──────────────────────────────────────────────
0                              Header(恰好 4096 字节)
4096                           Hash Table(align_up(block_count*32, 4096) 字节)
4096 + hash_table_size         Data(分片字节;bitrot block)

采用拆分布局(哈希与数据分离,而非交错)的理由:Data 区从 4 KiB 边界开始, 且每个 bitrot block 都是 4 KiB 的倍数,因此读取对 O_DIRECT 友好,且 shard 偏移 O 处的字节总位于文件偏移 data_start + O——随机读的偏移计算很干净。包含 O 的 bitrot block 的哈希位于 4096 + (O / bitrot_block) * 32。


3. Header(4096 字节,定长)

偏移 大小  字段                  说明
───  ────  ────────────────────  ────────────────────────────────────────────
0    8     magic                 ASCII "RFSHARD1"
8    2     format_version (u16)  = 1
10   2     flags (u16)           见 §3.1
12   4     header_crc32c (u32)   对字节 [16, 4096) 的 CRC32C(Castagnoli)
16   8     file_id (u64)
24   8     chunk_index (u64)
32   8     version (u64)         对象/文件版本(无版本时为 1)
40   2     shard_index (u16)     0..k+m-1(EC)或 0..r-1(replica)
42   2     ec_k (u16)            数据分片数(replica 为 1)
44   2     ec_m (u16)            校验分片数(replica 为 0)
46   1     ec_algo (u8)          0=无/replica,1=rs-vandermonde
47   1     csum_algo (u8)        1=HighwayHash256
48   4     bitrot_block (u32)    每个哈希块的字节数;4096 的倍数
52   8     shard_data_len (u64)  本分片数据区的逻辑字节数
60   8     chunk_logical_len(u64) 原始 chunk 的逻辑字节数
68   4     block_count (u32)     = ceil(shard_data_len / bitrot_block)
72   8     created_unix_nanos(u64)
80   8     ec_block_size (u64)   对 chunk 字节的纠删块大小(如 1 MiB)
88   8     placement_seed (u64)  Layout.placement_seed 的回显(交叉校验)
96   8     cluster_map_epoch(u64) 该分片放置时的 epoch
104  8     chunk_size (u64)      文件的 chunk_size(Layout 回显)
112  8     content_len (u64)     chunk 载荷变换前的逻辑长度
                                 (压缩/加密前),用于最后一个 chunk 的短尾
120  4904  reserved              写入时必须为零;读取时忽略

3.1 flags(u16,位域)

bit 0   mode_ec          1 = EC(k,m) 数据/校验分片;0 = replica 副本
bit 1   compressed       哈希前载荷已压缩(算法在 Layout 中)
bit 2   encrypted        哈希前载荷已加密(密钥引用在 Layout 中)
bit 3   last_block_short 最后一个 bitrot block 不足 bitrot_block 字节
bit 4   parity_shard     本分片为校验分片(shard_index >= ec_k)
bit 5..15  reserved(0)

变换顺序(置位时):存储载荷 = encrypt(compress(raw))。 bitrot 哈希在存储载荷上计算(变换后),因此完整性恰好保护落盘字节。


4. Hash Table(区域 2)

block_count 个条目,每个 32 字节(HighwayHash-256 摘要),按块顺序排列。
条目 i 覆盖 Data 字节 [i*bitrot_block, min((i+1)*bitrot_block, shard_data_len))。
区域以零填充至下一个 4096 边界。

4.1 HighwayHash-256 密钥(位置绑定)

每个块的哈希使用由集群 bitrot 密钥 K = (K0,K1,K2,K3)(4×u64,按集群配置)与 块位置 XOR 混合派生的 256 位密钥,使得正确的块放在错误位置时校验失败:

key0 = K0 ^ file_id
key1 = K1 ^ chunk_index
key2 = K2 ^ ((shard_index as u64) << 32 | block_index as u64)
key3 = K3 ^ version
digest_i = HighwayHash256(key=(key0,key1,key2,key3), data = data_block_i)

这把每个块绑定到 (file, chunk, shard, version, 块索引),在位腐之外还能捕获 错位的读/写。


5. Data 区(区域 3)

shard_data_len 字节,概念上是 block_count 个 bitrot block 连续排布。除最后一个
可能不足(flags.last_block_short)外,所有块均为 bitrot_block 字节。块与块之间
不填充;仅整个文件可在 EOF 处为 O_DIRECT 写入补齐到 4 KiB(超过 shard_data_len
的尾部填充字节被忽略)。

6. Chunk ↔ Shard 字节映射(EC 模式)

对 EC(k,m) 与 ec_block_size(EB),chunk 的原始字节按 EB 大小的纠删块处理; 每个 EB 被切为 k 个等长 shard_fragment:

shard_fragment = ceil(EB / k)         (最后一个数据分片在需要时零填充)

定位 chunk 偏移 C(chunk 内 0 基)处的字节:
  eb_index        = C / EB                       # 哪个纠删块
  off_in_eb       = C % EB
  data_shard      = off_in_eb / shard_fragment   # 哪个数据分片(0..k-1)
  off_in_fragment = off_in_eb % shard_fragment
  shard_offset    = eb_index * shard_fragment + off_in_fragment

因此连续的 chunk 范围最多触及 k 个数据分片;一个小范围(< shard_fragment、
位于一个 EB 内)恰好触及**一个**数据分片 → 1x 读放大(随机读快路径;不碰校验、
除非所需数据分片不健康才重建)。

Replica 模式下,chunk 字节原样存于每个分片(shard_offset = chunk_offset); 读取选任一健康副本。


7. Chunk-Key 与 on-disk 路径方案

逻辑 chunk-key:  (file_id, version, chunk_index)
分片标识:        (file_id, version, chunk_index, shard_index)

on-disk 路径:
  {disk_root}/.chunks/{p0}/{p1}/{file_id:016x}.v{version}/c{chunk_index}/{shard_index}.shard

其中:
  p0 = h = xxh3_64(file_id) 的十六进制第 0 字节   # 00..ff
  p1 = h 的十六进制第 1 字节                       # 00..ff
  → 两级扇出(≤ 65536 个叶组),限制每目录条目数。

写入期间临时文件:
  {...}/{shard_index}.shard.tmp.{random_u64:016x}

目录扇出仅以 file_id 为键(不含 chunk_index),使一个文件的所有 chunk 在磁盘上 聚集——有利于顺序巡检/heal 的局部性。


8. 持久化写协议

8.1 整分片写(新 chunk;数据集写路径)

1. 为所有块计算 bitrot 哈希(在变换后载荷上)。
2. 将 Header、Hash Table、Data 写入 {path}.tmp.{rand}。
3. fsync(tmp)。
4. rename(tmp → final)            # 同一文件系统上原子
5. fsync(父目录)                 # 持久化 rename

8.2 部分写(块卷 / 随机原地写)

写入必须以 bitrot_block 粒度处理(读-改-写):

对每个与写范围重叠的 bitrot block B:
  1. 读 B 的数据 + 其哈希条目;校验(若 B 被完全覆写则跳过校验)。
  2. 将新字节应用到 B 的缓冲。
  3. 为 B 重算摘要。
  4. 写数据块(O_DIRECT 对齐);写 32 字节哈希条目。
  5. fdatasync(或 O_DSYNC)。

数据块与其哈希条目之间的撕裂写窗口可容忍:后续读时不匹配会触发重建(EC)或 另一副本读 + 修复。块卷应使用 Replica 模式(见 RUSTFS-MD-DESIGN-005), 正是为了让部分写避免 EC 读-改-写放大。


9. 读协议(分片的校验字节范围读)

输入:分片相对范围 [O, O+L)
  first = O / bitrot_block ;  last = (O + L - 1) / bitrot_block
  data_start = 4096 + align_up(block_count*32, 4096)
  for i in first..=last:
      blk = read(data_start + i*bitrot_block, this_block_len(i))   # 可 O_DIRECT
      h   = read_hash_entry(i)                                     # 32 字节
      if HighwayHash256(key_i, blk) != h:  return BitrotError(i)
  return concat(blocks)[ O - first*bitrot_block .. + L ]

发生 BitrotError 时,chunk-store 不让客户端读失败:EC 模式读其他分片重建 受影响的纠删块;Replica 模式读另一副本。坏分片入队后台 heal。


10. 默认值与可调项

参数             默认值       说明
───────────────  ───────────  ──────────────────────────────────────────────
chunk_size       4 MiB        逐文件(Layout)
ec_block_size    1 MiB        EC 编码单元;chunk_size 为其倍数
bitrot_block     16 KiB       4×4 KiB;点读密集文件取**更小**(更少校验放大),
                              顺序取**更大**(更少哈希表开销)。
                              必须是 4096 的倍数。
csum_algo        HighwayHash256
ec_algo          rs-vandermonde
目录扇出         2 级         xxh3_64(file_id) 第 0、1 字节

默认下的开销:哈希表 = 每 16 KiB 用 32 B ≈ 分片大小的 0.195%。


11. 容器与 Slice 布局(小文件打包)

小文件(AI 样本/token;node_modules、pip/conda、构建临时——动辄数十亿个)不 作为各自的分片存储。它们以 needle 的形式打包进 容器(container),文件的 inode 持有一个 Slice 指针(见 RUSTFS-MD-DESIGN-005 §3.5)。本节定义 on-disk needle 布局、Slice 指针字段,以及一次 slice 读如何映射到 §6 的 chunk↔shard 映射与 §9 的校验读——从而让打包原样复用既有 chunk 格式、EC 与 bitrot。

11.1 容器(Container)

容器是一个大的**内部**文件(默认容量 256 MiB),位于保留命名空间。它**完全**像
一个普通大文件那样存储:
  · 自己的 file_id(= container_id),
  · 自己的 Layout(公式放置 + EC,或 Replica)——DESIGN-005 §3.2,
  · 它的字节就是 §2 分片格式的 chunk,照常 EC / bitrot。
容器在达到容量前为追加写,封存(SEAL)后不可变(适合 EC)。容器存储策略在**容器
层级**遵循 大小×访问模式 规则(一次写入数据集打包 → EC;高 churn 临时打包 →
Replica)。这对下面的 needle 层透明。

11.2 Needle(容器内的一个小文件)

容器的逻辑字节流是一串 needle:

Needle = [ NeedleHeader (32 B) | Data (data_len 字节) | 补齐到 needle_align ]

NeedleHeader(小端,32 字节):
  偏移 大小 字段         说明
  0   4    magic        "RFND" (0x52464E44)
  4   4    flags        bit0 已删除, bit1 已压缩, bit2 已加密
  8   8    file_id      所属 inode(支持 KV-轻量压实与巡检)
  16  4    data_len     Data 的逻辑字节数
  20  4    cookie       随机;必须与 Slice 指针匹配(防陈旧/防损坏)
  24  4    data_crc32c  对 Data 的 CRC32C(needle 级快速校验)
  28  4    reserved     0
needle_align : 默认 8 字节。needle 起点在容器逻辑流中是 needle_align 的倍数。
               每个 needle 浪费 ≤ needle_align-1 字节。

对齐分层(重要):needle_align 仅用于**打包密度**。
  设备 / O_DIRECT / EC-fragment 对齐完全由 chunk 层(§2/§5/§6)在读取容器字节时
  处理。needle **不**需要设备对齐——因此微小文件以 8 B 粒度紧密打包,而读取在
  下层 chunk 层依然对 O_DIRECT 友好。

11.3 Slice 指针(存于小文件的 inode)

inode.body = Slice {
    container_id : u64,   // 容器的 file_id
    offset       : u64,   // NeedleHeader 在容器流中的字节偏移
    length       : u32,   // = data_len(无需解析头部即可确定读取大小)
    cookie       : u32,   // 必须等于 NeedleHeader.cookie
}

这是唯一的逐小文件数据结构,且就放在 inode 行(KV)里。没有单独的小文件索引, KV 中也没有数据本体。

11.4 Slice 读 → EC 部分读(偏移映射)

小文件读完全复用 §6 + §9;数据路径上无任何新东西:

给定 Slice{container_id, offset, length} 与已缓存的容器 Layout:
  1. 要取的容器字节范围:[offset, offset + 32 + length)
     (头部 + 数据;读头部以校验 magic/file_id/cookie)。
  2. 把容器偏移 Oc → 容器 chunk 与 EC 分片(这就是 §6):
        chunk_index  = Oc / container.chunk_size
        off_in_chunk = Oc % container.chunk_size
        (eb_index, data_shard, shard_offset) = §6( off_in_chunk, EB, k )
  3. 对容器分片发起 §9 校验字节范围读:
        bitrot 校验;EC 部分读(只触及覆盖该范围的数据分片;除非所需数据分片不健康,
        否则不重建)。
  4. 解析 NeedleHeader;校验 magic == "RFND"、file_id == inode、cookie ==
     Slice.cookie;可选校验 data_crc32c。
  5. 返回 Data[0 .. length]。

放大:小于 shard_fragment(= ceil(EB/k))的 needle,常见情形下落在**一个** EC 数据
分片 fragment 内 → 1× 读(一个数据分片)。若跨 fragment 边界则触及 2 个数据分片——
仍无校验块、无重建。于是小文件读继承 LLD-002 的随机读快路径。MDS **不**在此路径上
(客户端缓存容器 Layout,且容器少而热)。

11.5 追加、封存、删除、压实

追加  : 在容器当前追加偏移预留一个 needle 槽(每次写一次原子自增;每写入者/会话
        一个**打开**容器以避免跨写入者争用)。把 needle 写入打开容器的 chunk;
        然后提交 inode.body = Slice{...}。数据在 inode 提交**之前**已持久(容器
        chunk 写,§8)(先数据;崩溃 GC 见 DESIGN-005 §3.4)。
封存  : used ≥ 容量时标记 CMTA.sealed;容器变为不可变,作为普通一次写入 EC 文件定稿。
删除  : 仅逻辑删除——丢弃小文件 inode 并增加 CMTA.dead_bytes。封存容器**不**原地修改
        (不可变 / EC)。
压实  : 当 CMTA.dead_bytes/容量 ≥ 阈值(Q6),压实器扫描容器 needle;对每个 file_id
        为 F、偏移为 O 的 needle 检查 inode F:若 F.body == Slice{本容器, O, …} 则
        needle **存活** → 复制进新的打开容器并事务性更新 F 的 Slice;否则**死亡** →
        跳过。完成后释放旧容器。(needle 的 file_id 使这种存活判定无需反向索引。)

11.6 默认值

container_capacity  256 MiB   (Q6 可调;越小压实周转越快)
container_policy    一次写入打包用 EC(k,m);高 churn 打包用 Replica
needle_align        8 字节
NeedleHeader        32 字节(定长)
压实触发            dead_bytes/容量 ≥ ~0.4(Q6 可调),相对 I/O 限速

12. 格式版本与兼容性

· 头部的 format_version 决定一切解析。读取器**必须**拒绝不理解的版本。
· 保留头部字节写入时必须为零、读取时忽略;**仅当**字段缺省(零)是合法默认时,
  才允许在未来小修订中无需提升版本地增加字段。
· 对区域布局、哈希、映射的任何改动都**要求**提升 format_version 并提供新版读取器。
· 不存在从旧格式的迁移:这是全新构建系统(RUSTFS-MD-DESIGN-005)。
  format_version 1 是 GA 时的第一个也是唯一格式。

13. 黄金测试向量(互通契约)

两个独立实现必须逐字节一致。参考实现生成并提交黄金向量于 testdata/chunk/, 每个向量 = { params.json, input.bin, expected.shard }。

一个向量至少必须覆盖:

G1  Replica,单个完整 bitrot block(data_len = bitrot_block)。
G2  Replica,短尾末块(data_len = 1.5 × bitrot_block)。
G3  EC(4,2) 数据分片 0,多 EC-block 的 chunk(chunk = 4 MiB,EB = 1 MiB)。
G4  EC(4,2) 校验分片(shard_index = 4),与 G3 同一 chunk。
G5  压缩 + 加密载荷(flags 位 1、2),验证 bitrot 在变换后字节上计算。
G6  位置绑定:把 G3 的一个有效块放到错误的 block_index,HighwayHash 校验**必须**
    失败(负向向量)。
G7  打包(Replica 容器):向一个容器写入 3 个 needle;各自经 Slice 逐字节读回;
    校验 NeedleHeader 的 magic/file_id/cookie;cookie 错误的 Slice **必须**被拒
    (负向向量)。
G8  打包(EC 容器):完全落在一个 shard_fragment 内的 needle 只从**一个**数据分片
    读取(EC 部分读,无重建);跨 fragment 边界的 needle 恰从**两个**数据分片读取。

13.1 结构化示例(G2,为说明用极小尺寸)

用示意性的 bitrot_block = 16 字节(真实默认为 16 KiB),Replica 模式, shard_data_len = 24(一个完整块 + 一个 8 字节短块):

block_count = ceil(24/16) = 2
Header  : 字节 [0,4096)    magic="RFSHARD1", format_version=1, flags=0x0008
                           (last_block_short), bitrot_block=16, shard_data_len=24,
                           block_count=2, ec_k=1, ec_m=0, ec_algo=0, csum_algo=1, ...
HashTable: 字节 [4096,8192)(align_up(2*32=64, 4096)=4096)
            [4096,4128)  = HighwayHash256(key_0, data[0..16])   # 32 B
            [4128,4160)  = HighwayHash256(key_1, data[16..24])  # 32 B
            [4160,8192)  = 0x00 填充
Data    : 字节 [8192, 8192+24)  = 24 个载荷字节
            文件为 O_DIRECT 补齐到 8192+4096(下一个 4 KiB);填充被忽略。

(真实的 32 字节摘要由参考 HighwayHash256 按 §4.1 密钥生成并提交于 expected.shard,此处无法手算。上述结构偏移是规范契约。)


14. 合规检查清单(M0 退出条件)

□ Header 编解码全部字段;读取时校验 header_crc32c。
□ 拆分布局偏移完全如 §2;Data 从 4 KiB 对齐处开始。
□ 实现 §4.1 的 HighwayHash256 密钥(位置绑定)。
□ Chunk↔shard 映射(§6)与 rustfs-placement 的预期一致。
□ 实现路径方案(§7)与原子写(§8.1)。
□ Replica 模式的部分 RMW(§8.2);撕裂写经修复容忍。
□ 校验读(§9)在不匹配时返回 BitrotError;绝不静默。
□ NeedleHeader(§11.2)编解码;校验 magic + file_id + cookie + data_crc32c;
  Slice 读(§11.4)映射到 §6/§9(容器范围 → EC 部分读)。
□ 小于 shard_fragment 的 needle 触及 ≤ 2 个数据分片,健康数据下无重建;压实存活
  判定经 needle.file_id ↔ inode.Slice;封存容器绝不原地修改。
□ 两个独立解码器均通过全部黄金向量 G1–G8。
□ format_version 闸门拒绝未知版本。

英文镜像:rustfs-chunk-format-EN.md。本规范是 RUSTFS-PLAN-001 的 M0 冻结产物; 它支撑 chunk-store(Track B)与 rustfs-placement。