【Claude 4.6 分析源码生成】
本文基于 Ceph 源码(
client/Client.cc、osdc/Striper.cc、osdc/ObjectCacher.h、os/bluestore/BlueStore.cc、include/fs_types.h、mds/mdstypes.h)深入分析 CephFS 的完整文件写流程,回答"一次 write() 系统调用到底经历了哪些层次、数据最终以什么形式存在哪里"这一核心问题。
一、核心概念:file_layout_t ------ 文件的数据分布策略
理解 CephFS 写流程,首先要理解它的文件布局参数。每个文件(inode)都有一个 file_layout_t 结构体,定义数据如何从文件空间映射到 RADOS 对象空间。
源码定义在 include/fs_types.h 第 86 行:
cpp
struct file_layout_t {
uint32_t stripe_unit; ///< stripe unit,每个 stripe 块的大小(字节)
uint32_t stripe_count; ///< stripe 跨越的对象数量
uint32_t object_size; ///< 每个 RADOS 对象的最大大小
int64_t pool_id; ///< 数据存放的 RADOS pool id
string pool_ns; ///< RADOS pool namespace
};
默认值由 get_default() 给出(include/fs_types.h 第 102 行):
cpp
static file_layout_t get_default() {
return file_layout_t(1<<22, 1, 1<<22); // su=4MB, sc=1, os=4MB
}
| 参数 | 默认值 | 含义 |
|---|---|---|
stripe_unit |
4 MB | 每条 stripe 的大小 |
stripe_count |
1 | stripe 跨 1 个对象(即不做跨对象条带化) |
object_size |
4 MB | 单个 RADOS 对象的最大大小 |
默认情况下 stripe_count=1,等效于简单分片:文件按 4MB 为单位切成连续的 RADOS 对象,不做跨对象条带。这也是绝大多数 CephFS 部署的实际行为。
这些布局参数存储在 MDS 管理的 inode 元数据里(mds/mdstypes.h 中 inode_t::layout 字段),通过 MDS journal 持久化到 RADOS metadata pool。客户端 open() 文件时从 MDS 获取 layout 参数,之后完全在本地按算法工作,无需再查 MDS。
二、文件偏移到 RADOS 对象的映射:Striper
2.1 对象名命名规则
CephFS 的数据对象名格式在 osdc/Striper.h 第 52 行定义:
cpp
snprintf(buf, sizeof(buf), "%llx.%%08llx", (long long unsigned)ino);
// 生成格式字符串: "{ino_hex}.%08llx"
// 最终对象名如: 1000000001a.00000000、1000000001a.00000001、...
命名规则:{inode号十六进制}.{对象序号十六进制(8位,补零)}
例如,ino=0x1000000001a 的文件,其第 0、1、2 个数据对象分别命名为:
1000000001a.000000001000000001a.000000011000000001a.00000002
2.2 映射算法
Striper::file_to_extents() 函数(osdc/Striper.cc 第 42 行)将文件偏移+长度转换为 RADOS 对象列表及其内部偏移,核心计算逻辑:
cpp
uint64_t blockno = offset / stripe_unit; // 第几个 stripe 块
uint64_t stripeno = blockno / stripe_count; // 第几条水平 stripe
uint64_t stripepos = blockno % stripe_count; // 在 stripe 中的列位置
uint64_t objectsetno = stripeno / stripes_per_object; // 第几个 object set
uint64_t objectno = objectsetno * stripe_count + stripepos; // 对象序号
// 对象内偏移
uint64_t block_start = (stripeno % stripes_per_object) * stripe_unit;
uint64_t x_offset = block_start + (offset % stripe_unit);
关键特性:这是一个纯算法映射,不需要任何索引表! 只要知道文件的 ino、layout 参数和文件偏移,就能直接计算出 RADOS 对象名和对象内偏移,不需要查 MDS、不需要查任何目录。
2.3 一个直观的例子
以默认 layout(stripe_unit=stripe_count=object_size=4MB)为例,4GB 文件的映射:
文件偏移 [0, 4MB) → 对象 ino.00000000 的 [0, 4MB)
文件偏移 [4MB, 8MB) → 对象 ino.00000001 的 [0, 4MB)
文件偏移 [8MB, 12MB) → 对象 ino.00000002 的 [0, 4MB)
...
文件偏移 [4092MB,4096MB)→ 对象 ino.000003ff 的 [0, 4MB)
共 1024 个 RADOS 对象(4GB ÷ 4MB)。这 1024 个对象按 CRUSH 算法分散到不同 PG 和 OSD,不要求物理连续。
三、客户端写流程:ObjectCacher 缓冲层
3.1 _write() 的入口逻辑
客户端写入的核心函数是 Client::_write()(client/Client.cc 第 9547 行)。其中最重要的决策分支:
cpp
if (cct->_conf->client_oc &&
(have & (CEPH_CAP_FILE_BUFFER | CEPH_CAP_FILE_LAZYIO))) {
// ↓ 默认走这里:异步缓冲写
r = objectcacher->file_write(&in->oset, &in->layout,
in->snaprealm->get_snap_context(),
offset, size, bl, ...);
} else {
// ↓ O_DIRECT 或未获得缓冲 cap 时:同步写
filer->write_trunc(in->ino, &in->layout, ...);
}
client_oc 是 client object cache,默认开启。只要客户端持有 FILE_BUFFER capability(MDS 授权),就走异步缓冲写路径。
3.2 ObjectCacher:客户端的写缓冲池
ObjectCacher::file_write()(osdc/ObjectCacher.h 第 695 行)是整个缓冲层的入口:
cpp
int file_write(ObjectSet *oset, file_layout_t *layout, ...) {
OSDWrite *wr = prepare_write(snapc, bl, mtime, flags, 0);
// 关键:把文件 offset/len 拆解成 RADOS 对象列表
Striper::file_to_extents(cct, oset->ino, layout, offset, len,
oset->truncate_size, wr->extents);
return writex(wr, oset, NULL);
}
writex() 函数(osdc/ObjectCacher.cc 第 1722 行)将数据写入内存中的 BufferHead(按 RADOS 对象组织的缓冲区),并标记为 dirty 状态。多次写入同一个对象范围的 BufferHead 会自动合并(try_merge_bh),避免碎片化。
BufferHead 的状态机:
MISSING → DIRTY → TX(正在下刷)→ CLEAN
↑ ↓
└──────── 再次写入 ──────────┘
3.3 脏数据何时真正下刷到 OSD
脏数据(dirty BufferHead)不会立刻发往 OSD,触发下刷的时机有:
- 脏数据超过阈值 :
objectcacher_target_dirty(默认 100MB),触发flush()将最老的脏数据下刷 - O_SYNC / O_DSYNC 标志 :每次 write 后立即调用
_flush_range() - cap 被撤销 :MDS 要求客户端归还
FILE_BUFFERcap 时,客户端必须先 flush 所有脏数据 - 文件关闭/fsync:显式触发完整 flush
- 内存压力:ObjectCacher 缓存总量达到上限(默认 256MB)时主动 trim
下刷路径:bh_write() → Objecter::write() → RADOS Primary OSD
四、顺序写与随机写的行为分析
4.1 顺序写 40MB(每次 4K)
以默认 layout 写一个 40MB 的文件,每次 write 4KB:
- 前 1024 次写(0 ~ 4MB-1),调用 Striper 后全部映射到对象
ino.00000000的不同内部偏移 - 这些 4K 写都落在同一个
BufferHead(或被合并),在内存中聚合 - 触发下刷时,可能以一次 4MB 的 RADOS write 发往 OSD,而非 1024 次 4K 的 RADOS 写
整个 40MB 顺序写最终产生 10 个 RADOS 对象(40MB ÷ 4MB),每个对象携带连续的 4MB 数据。从 OSD 视角看,写 IO 较大、较规整,对 HDD 非常友好。
4.2 随机写 40MB
随机写的 ObjectCacher 缓冲机制完全相同,区别在于:
- 写请求映射到的 RADOS 对象是散乱的(可能命中 1~10 个不同对象)
- 同一对象内的多次随机写也会在 BufferHead 中聚合,但聚合后仍是对象内的随机分布
- 对三副本 pool:OSD 端直接写对象的局部偏移,BlueStore 支持任意偏移写入,无需 RMW
- 对 EC 纠删码 pool:随机写会触发 OSD 端的 RMW(Read-Modify-Write),这是 CephFS 默认用三副本 pool 而非 EC pool 的重要原因
五、RADOS 对象的存储模型:空间按需分配
5.1 RADOS 对象不是预分配固定大小的文件
一个重要的认知纠正:4MB 的 RADOS 对象并不是创建时就预留 4MB 连续磁盘空间的。
以只写了 4K 的 RADOS 对象为例:
- 客户端发送 RADOS write,目标是对象
ino.00000000的偏移[0, 4K) - OSD 的 BlueStore 收到写请求后,在裸盘上只分配 4K(实际是 min_alloc_size,HDD 默认 64KB,SSD 默认 16KB)的物理空间
- onode 记录
size = 4K,ExtentMap 记录[0, 4K) → disk_offset - 该对象此时在磁盘上只占用一个 min_alloc_size 大小的物理 extent,不存在 4MB 的占位
只有当你继续往这个对象写入数据时,BlueStore 才会增量分配更多磁盘空间。也就是说,RADOS 对象是稀疏的、按需增长的。
5.2 4K 写到"4MB 对象"后后续的空间占用
| 场景 | 实际磁盘占用 |
|---|---|
| 只写了 4K | ≈ 1 × min_alloc_size(HDD: 64KB,SSD: 16KB) |
| 顺序写满 4MB | ≈ 4MB(64 × 64KB 的 extent,HDD 上) |
| 随机写入若干 4K,分布在 0~4MB | 实际写入字节数 × 对齐系数,有空洞不占空间 |
5.3 RADOS 对象支持任意偏移的随机读写
RADOS 对象完全支持随机写,比如只写 [1MB, 2MB) 的范围,这是完全合法的:
RADOS write(object="ino.00000003", offset=1MB, len=1MB, data=...)
BlueStore 会在该偏移范围分配物理空间,其余未写的范围([0, 1MB) 和 [2MB, 4MB))不占磁盘空间,读取时返回全零。
这种稀疏对象特性对支持文件空洞(sparse file)非常关键------用户在文件中 lseek 跳过大段空间再写入,中间的空洞不消耗实际存储。
六、BlueStore 对 RADOS 对象的底层实现
6.1 两层存储分离
BlueStore 对每个 RADOS 对象的存储分为两部分:
BlueStore
├── RocksDB(通过 BlueFS 管理)
│ └── key="O" + {collection} + {oid}
│ value = onode(元数据:size、attrs、ExtentMap)
└── 裸块设备(block)
└── 实际数据字节,按物理 extent 散布
元数据 (RocksDB)与数据(裸盘)完全分离,这是 BlueStore 相比 FileStore 的核心优势------避免了双重写(先写文件系统日志,再写数据文件)。
6.2 onode:每个 RADOS 对象的元数据
每个 RADOS 对象对应一个 bluestore_onode_t(os/bluestore/bluestore_types.h 第 898 行):
cpp
struct bluestore_onode_t {
uint64_t nid; ///< 本地唯一数字 ID
uint64_t size; ///< 对象当前逻辑大小
map<string, buffer::ptr> attrs; ///< 对象的 xattrs(如 CephFS 的 layout、snapshots 信息)
vector<shard_info> extent_map_shards; ///< ExtentMap 分片信息(大对象用)
uint32_t expected_object_size; ///< 期望对象大小(hint)
uint32_t expected_write_size; ///< 期望写大小(hint,影响 blob 分配策略)
uint32_t alloc_hint_flags; ///< 访问模式 hint(顺序/随机/不可压缩等)
uint8_t flags; ///< 标志位(是否有 omap 数据等)
};
6.3 ExtentMap:逻辑偏移到物理 extent 的映射
ExtentMap 是 BlueStore 最核心的数据结构,记录对象内每段逻辑偏移对应的物理磁盘位置(BlueStore.h 第 768 行)。
每一条 Extent 条目:
lextent(逻辑 extent)
logical_offset ─── 对象内的逻辑起始偏移
length ─── 逻辑长度
blob_offset ─── 在 Blob 内的偏移
BlobRef ─── 指向一个 bluestore_blob_t
每个 bluestore_blob_t(os/bluestore/bluestore_types.h 第 431 行):
cpp
struct bluestore_blob_t {
PExtentVector extents; ///< 磁盘上的物理 extent 列表(offset, length)
uint32_t logical_length; ///< 原始数据长度(压缩前)
uint32_t compressed_length; ///< 压缩后长度(如果压缩了)
uint32_t flags; ///< 是否压缩、是否 shared、checksum 类型等
bufferptr csum_data; ///< checksum 数据
};
也就是说:
RADOS 对象
└── ExtentMap
├── [0K, 4K) → Blob#1 → pextent(disk_off=0x1000000, len=4K)
├── [4K, 64K) → Blob#2 → pextent(disk_off=0x5000000, len=60K)
└── [1MB, 2MB) → Blob#3 → pextent(disk_off=0x8000000, len=1MB)
// 中间 [64K, 1MB) 没有映射 → 空洞,读取返回零
6.4 写路径的分支:_do_write_small vs _do_write_big
BlueStore 的写入根据写入大小与 min_alloc_size 的关系分两个路径(BlueStore.cc 第 13291 行):
cpp
void BlueStore::_do_write_data(...) {
if (写入范围在同一个 min_alloc_size 块内 && length != min_alloc_size) {
_do_write_small(...); // 小写:尝试复用现有 blob,或走 deferred write
} else {
// 头尾未对齐部分用 _do_write_small
// 中间对齐部分用 _do_write_big
_do_write_big(...); // 大写:分配新 pextent,直接写裸盘
}
}
_do_write_small(小于 min_alloc_size 的写):优先查找可复用的已有 blob,减少碎片;如果需要覆盖写,走 deferred write(先写 RocksDB WAL,后台异步写磁盘)_do_write_big(对齐的大块写):直接分配新的物理 extent,以 AIO 写裸盘,不经过 WAL
七、完整的读写路径总结
7.1 写路径(完整视图)
用户进程
│ write(fd, buf, size, offset)
↓
Client::_write() [client/Client.cc]
│ 1. 获取 MDS cap(FILE_WR + FILE_BUFFER)
│ 2. 处理 inline data(小文件 ≤4KB 特殊路径)
↓
ObjectCacher::file_write() [osdc/ObjectCacher.h]
│ Striper::file_to_extents() [osdc/Striper.cc]
│ → 把 (ino, offset, len) 映射为 RADOS 对象列表
│ writex() → 写入 BufferHead(dirty) [osdc/ObjectCacher.cc]
│ → 异步缓冲,可多次写合并
↓(触发 flush)
Objecter::write() [osdc/Objecter.cc]
│ → 发送 OSD write op(RADOS 协议)
↓
Primary OSD
│ 三副本同步写(或 EC 编码写)
↓
BlueStore::_do_write() [os/bluestore/BlueStore.cc]
│ _do_write_data() → 分 small/big 两路
│ _do_alloc_write() → 分配 pextent,写裸盘(AIO)
│ 更新 ExtentMap
└→ RocksDB 提交 onode 元数据
7.2 读路径(完整视图)
用户进程
│ read(fd, buf, size, offset)
↓
Client::_read() [client/Client.cc]
↓
ObjectCacher::file_read() [osdc/ObjectCacher.h]
│ Striper::file_to_extents() ← 同写路径,无需查表
│ 检查 BufferHead 缓存
├─ 命中(clean/dirty)→ 直接拷贝,返回用户
└─ 未命中 → Objecter::read()
↓
Primary OSD
↓
BlueStore::read()
│ RocksDB 查 onode → ExtentMap → 找物理 extent
│ AIO 读裸盘 → 校验 checksum
└→ 数据回填 ObjectCacher(clean)→ 返回用户
八、关键问题汇总
Q:4GB 文件分成 1024 个 RADOS 对象,这 1024 个对象的"索引"存在哪里?
不需要索引表。对象名和内部偏移通过纯算法(Striper)从文件偏移实时计算,只需 inode 中的 layout 参数(4 个整数)即可。
Q:顺序写 40MB,每次写 4K,是每 4K 一个 RADOS 对象,还是合并到 4MB 对象?
合并到 4MB 对象。ObjectCacher 在客户端内存中缓冲脏数据,同一 RADOS 对象范围内的多次小写会自动合并,最终下刷时可能是一次大 IO,不是 1024 次 4K 的 RADOS 写。
Q:随机写和顺序写有什么区别?
客户端侧的 ObjectCacher 机制完全相同,都做缓冲聚合。差异在 OSD 侧:顺序写产生连续的大 pextent,随机写产生碎片化的 ExtentMap,HDD 性能差异明显。EC pool 的随机写还有 RMW 开销,这是推荐三副本 pool 的原因。
Q:RADOS 对象是预分配 4MB 空间的吗?
不是。RADOS 对象的磁盘空间完全按需分配,只写了 4K 就只占 1 个 min_alloc_size 大小的 pextent(HDD 上通常是 64KB)。4MB 只是逻辑上的"上限大小",物理空间随写入量增长。
Q:RADOS 对象支持随机写吗?可以只写 [1MB, 2MB) 的范围吗?
完全支持。RADOS 对象是字节寻址的,BlueStore 支持任意偏移的读写。写 [1MB, 2MB) 只分配该范围对应的 pextent,其他范围不占空间(稀疏对象),读取时返回零。这是 CephFS 支持稀疏文件(文件空洞)的底层基础。
Q:RADOS 对象底层是什么实现的?
每个 RADOS 对象底层由 BlueStore 管理,分两部分:
- 元数据(onode + ExtentMap):存入 RocksDB(通过 BlueFS 管理的裸盘上的 SST 文件)
- 实际数据 :按需分配物理 extent,以 AIO 直接写入主数据裸盘(
block设备),不经过文件系统
onode 的 ExtentMap 记录"对象内逻辑偏移 → 物理 extent"的稀疏映射,是 BlueStore 实现稀疏对象、高效随机写、压缩和 checksum 的核心数据结构。
九、一个具体的端到端例子
假设:ino=0x10000000001,默认 layout,写入文件偏移 [4K, 8K) 的 4K 数据。
1. Client::_write(offset=4096, size=4096)
↓ Striper::file_to_extents()
→ objectno = 0(4096 / 4MB = 0)
→ object内偏移 = 4096
→ 对象名 = "10000000001.00000000",写 [4096, 8192)
2. ObjectCacher 写入 BufferHead
→ 对象 "10000000001.00000000" 的 dirty BufferHead [4096, 4096+4096)
3. 下刷时,Objecter 发送 RADOS write op
→ OSD: write("10000000001.00000000", offset=4096, len=4096)
4. BlueStore::_do_write()
→ _do_write_data(): 4096 < min_alloc_size(64KB)
→ _do_write_small(): 在 ExtentMap 中查找可复用 blob,
若无则分配新 blob,申请 1 个 min_alloc_size(64KB) 的 pextent
→ AIO 写裸盘 64KB(其中 4KB 有效数据,前 4K 用零填充保证对齐)
→ RocksDB 更新 onode:
size = 8192
ExtentMap: [4096, 4096) → pextent(disk_off=X, len=64KB)
- 后续如果再写
[0, 4K),_do_write_small 会检测到相邻 blob,
尝试将 [0, 4K) 写入同一个已有的 64KB pextent(blob reuse),
这样两次 4K 写共享同一个物理 pextent,节省空间和碎片。