CephFS 文件写流程深度解析:从 write() 系统调用到 BlueStore 落盘

【Claude 4.6 分析源码生成】

本文基于 Ceph 源码(client/Client.ccosdc/Striper.ccosdc/ObjectCacher.hos/bluestore/BlueStore.ccinclude/fs_types.hmds/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.hinode_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.00000000
  • 1000000001a.00000001
  • 1000000001a.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);

关键特性:这是一个纯算法映射,不需要任何索引表! 只要知道文件的 inolayout 参数和文件偏移,就能直接计算出 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,触发下刷的时机有:

  1. 脏数据超过阈值objectcacher_target_dirty(默认 100MB),触发 flush() 将最老的脏数据下刷
  2. O_SYNC / O_DSYNC 标志 :每次 write 后立即调用 _flush_range()
  3. cap 被撤销 :MDS 要求客户端归还 FILE_BUFFER cap 时,客户端必须先 flush 所有脏数据
  4. 文件关闭/fsync:显式触发完整 flush
  5. 内存压力:ObjectCacher 缓存总量达到上限(默认 256MB)时主动 trim

下刷路径:bh_write()Objecter::write() → RADOS Primary OSD


四、顺序写与随机写的行为分析

4.1 顺序写 40MB(每次 4K)

以默认 layout 写一个 40MB 的文件,每次 write 4KB:

  1. 前 1024 次写(0 ~ 4MB-1),调用 Striper 后全部映射到对象 ino.00000000 的不同内部偏移
  2. 这些 4K 写都落在同一个 BufferHead(或被合并),在内存中聚合
  3. 触发下刷时,可能以一次 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 对象为例:

  1. 客户端发送 RADOS write,目标是对象 ino.00000000 的偏移 [0, 4K)
  2. OSD 的 BlueStore 收到写请求后,在裸盘上只分配 4K(实际是 min_alloc_size,HDD 默认 64KB,SSD 默认 16KB)的物理空间
  3. onode 记录 size = 4K,ExtentMap 记录 [0, 4K) → disk_offset
  4. 该对象此时在磁盘上只占用一个 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_tos/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_tos/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)
  1. 后续如果再写 [0, 4K),_do_write_small 会检测到相邻 blob,
    尝试将 [0, 4K) 写入同一个已有的 64KB pextent(blob reuse),
    这样两次 4K 写共享同一个物理 pextent,节省空间和碎片。
相关推荐
yyyyy_abc1 天前
ceph学习笔记
笔记·ceph·学习
自由且自律3 天前
ceph实战,基于docker部署
运维·ceph·docker·容器·云计算
老wang你好5 天前
Ceph存储全攻略:RBD、CephFS与RGW详解
ceph
珂玥c7 天前
Ceph集群新增osd
ceph
老wang你好8 天前
Ceph分布式存储系统全解析
ceph
一个行走的民21 天前
分布式系统中 Map 增量(Delta)是否需要持久化
ceph
一个行走的民23 天前
BlueStore 核心原理与关键机制
ceph
奋斗的小青年I25 天前
Proxmox VE Ceph 超融合集群落地实战
windows·ceph·vmware·pve·超融合·proxmox
一个行走的民25 天前
深度剖析 Ceph PG 分裂机制:原理、底层、实操、影响、线上避坑(最全完整版)
ceph·算法