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,节省空间和碎片。
相关推荐
2301_773643621 天前
ceph镜像
前端·javascript·ceph
2301_773643622 天前
ceph池
开发语言·ceph·python
2301_773643622 天前
ceph实践
ceph
2301_773643626 天前
ceph分布式存储
分布式·ceph
m0_736034858 天前
ceph分布式存储
分布式·ceph
三十..8 天前
Ceph 三大存储接口深度实践与数据保护指南
运维·ceph
AOwhisky8 天前
Ceph系列第六期:Ceph 文件系统(CephFS)精讲
linux·运维·网络·笔记·ceph
潮起鲸落入海8 天前
Ceph 分布式存储 对象存储管理
ceph
qq_356408668 天前
Kubernetes Loki 日志收集系统部署文档 (读写分离模式 + Ceph S3 + Nginx 日志分离)
ceph·nginx·kubernetes