Q先生的世界

面朝大海,春暖花开

经典系统深度解析|从对象存储研发工程师视角看文件系统:VFS、Page Cache、Journal 与崩溃一致性

很多工程师做对象存储久了,会逐渐形成一种错觉:

  1. 我们做的是 object,不是 file
  2. 底层文件系统只是一个“本地持久化容器”
  3. 真正复杂的是副本、纠删码、数据分片、元数据服务和一致性协议

这几个判断都不能说错,但它们很容易让人忽略一个事实:

对象存储并没有绕开文件系统,它只是把最关键的语义压力,都压到了文件系统上。

你把一个对象切成 chunk 落到磁盘,本质上还是在做:

  1. 路径查找
  2. inode 分配
  3. page cache 写入
  4. extent 映射
  5. journal 或 log 提交
  6. fsync 持久化
  7. rename 原子切换

如果这些环节理解得不够深,系统在实验环境里往往“能跑”,一到生产就会暴露出很多你原本以为属于分布式层的问题:

  1. 小对象写入吞吐为什么上不去
  2. 为什么删除后磁盘空间没有立刻回来
  3. 为什么机器重启后 index file 在,data file 却不在
  4. 为什么 rename 成功了,目录项却没有持久化
  5. 为什么明明是顺序写,尾延迟还是会被脏页回写打爆

所以这篇文章不打算写一本“文件系统概论”,而是换一个更工程化的角度:

如果你是对象存储研发工程师,你到底应该怎样理解文件系统。

我会重点回答这些问题:

  1. VFS 到具体文件系统之间的调用链是什么
  2. inode、dentry、page cache、extent 各自解决什么问题
  3. 一次对象写入在内核里实际经历了哪些路径
  4. fsyncfdatasyncrename 到底保证了什么,不保证什么
  5. ext4 / XFS / CoW 文件系统在对象存储场景下有什么关键差异
  6. 为什么很多对象存储最终都会走向 append-only、log-structured 或 chunk immutable 设计

为了避免文章停留在概念层,我会尽量补两类内容:

  1. Linux 内核源码里的关键结构和关键函数名
  2. 足够接近真实实现的伪代码

不是为了让你背源码,而是为了建立一种“看得见数据流和状态流”的理解方式。


1. 先把问题摆正:对象存储为什么必须认真理解文件系统

很多对象存储系统,对外暴露的是这样的 API:

PUT /bucket/key
GET /bucket/key
DELETE /bucket/key
LIST /bucket/prefix

看起来跟传统文件系统的 open/read/write/close 很不一样。

但在单机持久化层,你最终还是要处理这些问题:

  1. 一个对象的元数据放哪里
  2. 一个对象的数据块放哪里
  3. 对象覆盖时是原地更新还是新写后切换引用
  4. 删除一个对象时,空间什么时候真正回收
  5. 机器掉电后,哪些对象应该可见,哪些不应该可见

只要你开始回答这些问题,你其实就已经在和文件系统语义正面交锋了。

从工程上看,一个对象存储节点往往不是“直接写裸盘”,而是运行在这样的分层之上:

Application (object service)
    -> libc / syscalls
    -> VFS
    -> ext4 / XFS / btrfs / ZFS
    -> block layer
    -> NVMe / SSD / HDD

也就是说,你设计对象写入协议时,真正依赖的是:

  1. VFS 的抽象语义
  2. 具体文件系统的崩溃一致性模型
  3. block 层和设备的 flush / FUA / cache 行为

如果你只理解对象层,不理解这三层,那很多“诡异现象”其实就根本解释不通。


2. 文件系统不是一个模块,而是一组语义合同

初学者常常把文件系统理解成“负责把字节存到磁盘上的程序”。这个说法不算错,但太粗了。

更准确地说,文件系统至少同时做了五件事:

  1. 提供命名空间
  2. 管理元数据
  3. 管理空间分配
  4. 提供缓存与回写机制
  5. 提供崩溃恢复与一致性语义

对象存储工程里,最后两件最容易被低估。

为什么?因为对象存储通常会自己实现副本、校验、重建、垃圾回收,看起来像是“系统级一致性”都被上层接管了。

但你只要把数据先落到本地磁盘,本地文件系统就已经替你做了另一套一致性承诺。而问题恰恰在于:

上层假设的承诺,未必等于文件系统实际提供的承诺。

例如:

  1. 你以为 write() 返回成功就等于落盘,实际上通常只是进入 page cache
  2. 你以为 rename() 成功后掉电也一定可见,实际上目录本身可能还没持久化
  3. 你以为删除一个大文件后空间会立刻回来,实际上 block 回收、extent 更新、后台 trim 都可能滞后

对象存储最怕的不是“慢一点”,而是“语义理解错了”。


3. 从源码视角看第一层:VFS 到底抽象了什么

Linux 不会让每个上层程序都直接和 ext4、XFS 打交道。中间有一层非常关键的抽象:VFS,Virtual File System。

VFS 解决的问题是:

给上层提供统一的文件接口,同时把不同文件系统的实现细节藏在各自的 operation table 里。

如果只看最核心的对象,VFS 大致可以理解成下面这组结构:

super_block   # 一个已挂载文件系统实例
inode         # 一个文件的元数据对象
dentry        # 路径名到 inode 的缓存项
file          # 一次打开后的运行时句柄
address_space # 文件页缓存与回写视图

把它翻译成对象存储工程师更熟悉的话:

  1. inode 像“对象实体”本身
  2. dentry 像“名字到实体”的缓存映射
  3. file 像“某次客户端请求持有的会话状态”
  4. address_space 像“这份对象数据当前在内存页缓存里的视图”

一次 open() 在内核里大概发生什么

从源码路径上,现代内核里你常会看到这样的主链路:

do_sys_openat2
  -> path_openat
    -> filename_lookup
    -> link_path_walk
    -> lookup_fast / lookup_slow
    -> do_open
      -> vfs_open

这条链路里最重要的事,不是“打开文件”这四个字本身,而是:

  1. 路径逐级解析
  2. dentry cache 命中或回源
  3. 权限检查
  4. 找到最终 inode
  5. 生成 struct file

伪代码可以写成:

function open(path, flags):
    current = root_or_cwd()
    for component in split(path, "/"):
        dent = dcache_lookup(current, component)
        if dent == nil:
            inode = fs_lookup(current.inode, component)
            dent = dcache_insert(current, component, inode)
        current = dent

    inode = current.inode
    check_permission(inode, flags)
    file = alloc_file(inode, flags)
    return file

对于对象存储,这里有两个直接启发:

  1. 如果你的对象布局把大量对象摊在单目录里,目录查找本身就会变成热点
  2. 如果你的 key 到本地路径映射过深,路径遍历成本和 dentry 行为也会放大

所以很多对象存储会做两件事:

  1. 对 key 做 hash 分桶,避免单目录过热
  2. 用更扁平的 chunk namespace,而不是保留用户原始路径层级

这不是“编码风格问题”,而是对目录索引和 dentry 行为的工程回应。


4. inode 解决的不是“名字”,而是“对象本体”

很多人第一次学文件系统时会记住一句话:文件名不在 inode 里,目录项才维护名字到 inode 的映射。

这句话非常重要,因为它直接决定了几个对象存储里常见但容易误判的行为。

inode 里通常保存的是:

  1. 文件类型
  2. 权限和属主
  3. 文件大小
  4. 时间戳
  5. block 或 extent 映射信息
  6. link count
  7. 回写、锁、页缓存等运行时状态引用

它本质上描述的是:

这个文件是什么,以及它的数据在哪里。

而目录项描述的是:

这个名字指向哪个 inode。

这会带来一个非常关键的事实:

rename() 改的主要是目录关系,不是文件数据本体。

这也是为什么很多对象存储喜欢用“临时文件写入完成后 rename 切换”的原因。

因为如果你采用这样的写入协议:

  1. 先把对象内容写到 tmp/object-id.tmp
  2. fsync(tmp_fd)
  3. rename(tmp, final)
  4. fsync(parent_dir_fd)

那么你实际上是在利用:

  1. 数据块持久化
  2. inode 元数据持久化
  3. 目录项切换的原子性

这是一种非常经典的 crash-safe publish 模式。


5. page cache 是吞吐来源,也是尾延迟来源

对象存储工程里,很多人会很早就接触 page cache,因为它几乎总会影响 benchmark。

但 page cache 真正值得理解的地方,不是“它能加速读写”,而是:

它把 write() 的完成语义和“真正写到设备”这件事拆开了。

一次 buffered write 的主路径

如果不是 O_DIRECT,常见写路径大致会走到:

vfs_write
  -> new_sync_write
    -> ext4_file_write_iter / xfs_file_write_iter
      -> generic_perform_write
        -> write_begin
        -> copy user data to page cache
        -> write_end

这里最关键的一步是:

用户态 buffer 先被拷进 page cache 对应的 page,而不是直接写进磁盘。

可以把它抽象成:

function buffered_write(file, user_buf, offset):
    while user_buf not empty:
        page = grab_cache_page(file.mapping, offset)
        lock(page)
        copy_from_user(page, user_buf, offset)
        mark_page_dirty(page)
        unlock(page)
        offset += copied
    return success

这意味着 write() 成功,通常只说明:

  1. 数据已经进入内核内存
  2. 对应页被标记为 dirty
  3. 后续会由后台 flusher 或主动 fsync 去落盘

为什么它对对象存储很重要

因为对象存储的写入模式经常会碰到 page cache 的两面性:

  1. 小对象写入能被 page cache 吞进去,看起来吞吐很高
  2. 真正回写时,脏页积压会在某个时刻集中爆发,尾延迟陡增
  3. 大对象顺序写会把 cache 顶满,污染读热点
  4. 多副本同步写时,多个文件描述符共享回写压力,会导致写放大和调度抖动

所以你在对象存储里做性能优化,不能只看接口层 QPS,还要问:

  1. 脏页比例是多少
  2. 回写带宽什么时候被打满
  3. balance_dirty_pages() 什么时候开始反压前台写线程
  4. 你的 workload 适不适合 O_DIRECT

这才是很多“压测很快,生产尾延迟很差”的根因。


6. extent 解决的是空间映射,不是简单的 block list

早期理解文件系统时,很多人会把文件看成“若干 block 的列表”。

这个模型能帮助入门,但对现代文件系统已经不够了。

现代 ext4、XFS 这类文件系统,更常见的是 extent-based allocation。一个 extent 可以理解成:

(logical_offset, physical_start_block, length)

也就是:

文件逻辑上的一段连续区域,映射到磁盘上一段连续物理块。

为什么这很重要?

因为对象存储最喜欢干的事情之一,就是写大文件、大 chunk、append log,而这些场景都非常依赖 extent 带来的好处:

  1. 元数据更紧凑
  2. 连续空间更容易顺序读写
  3. 大块 I/O 更容易形成设备友好的访问模式

但它同时也带来另一个工程现实:

空间分配不是立即且完全确定的。

比如 ext4 里有 delayed allocation,XFS 里也有自己的延迟分配与 unwritten extent 处理逻辑。这样做可以提高聚合写效果,但也意味着:

  1. 逻辑写入完成时,物理块可能还没最终定型
  2. writebackfsync 路径会触发更重的 block mapping 工作
  3. 掉电一致性必须靠日志或事务机制兜住

从对象存储角度看,这解释了两个常见现象:

  1. 顺序写对象时吞吐很好,因为分配器能较好地拿到大 extent
  2. 小对象随机覆盖时性能很差,因为 extent 分裂、元数据更新和 journal 压力都上来了

7. 真正难的不是写入,而是崩溃一致性

如果只考虑正常运行,文件系统并不神秘:

  1. 找到 inode
  2. 分配空间
  3. 修改页缓存
  4. 后台落盘

真正把复杂度拉上去的,是中途可能随时掉电。

对象存储工程里,一旦你要承诺“PUT 成功后对象不会丢”,你就不能只理解正常路径,必须理解 crash consistency。

为什么崩溃一致性这么难

因为一次“看起来简单的对象写入”,底层其实涉及多类状态:

  1. 数据块内容
  2. inode 大小变化
  3. extent 映射变化
  4. 目录项变化
  5. 空闲空间位图或 B-tree 更新
  6. 日志本身的提交状态

如果这些状态不是以某种原子方式对外可见,那么掉电后系统就可能处于“半更新”状态。

这也是 journal / log 的存在理由。


8. 从源码思想理解 journal:它不是为了加速,而是为了把更新变成事务

以 ext4 为例,很多关键元数据更新都依赖 jbd2 journal 子系统。

你在源码里常会看到类似这样的函数名:

jbd2__journal_start
jbd2_journal_stop
ext4_mark_inode_dirty
ext4_dirty_inode
ext4_sync_file

虽然具体细节很多,但思想可以抽象成一句话:

把一组必须共同成立的元数据修改,放进一个事务里。

伪代码可以这么理解:

function ext4_like_update(file, op):
    txn = journal_start()

    old_inode = load_inode(file)
    new_mapping = allocate_or_update_extents(file, op)
    new_inode = update_inode_size_mtime(old_inode, op)
    mark_bitmap_changes(txn, new_mapping)
    log_inode_changes(txn, new_inode)
    log_extent_changes(txn, new_mapping)

    journal_stop(txn)

真正落盘时,通常不是“先把所有 home location 都写完再说”,而是更像:

  1. 先把日志里需要的描述写进去
  2. 标记事务 commit
  3. 崩溃恢复时按日志重放或确认哪些修改有效

这就是为什么 journal 的核心价值不是“提高吞吐”,而是:

让系统在掉电后还能知道哪些修改算数。

数据 journal、ordered、writeback 有什么差别

ext4 常被提到的三种模式,本质是数据和元数据持久化关系的不同:

  1. data=journal:数据和元数据都进 journal,最重但语义强
  2. data=ordered:元数据 journal,且在提交相关元数据前尽量保证数据块先落盘
  3. data=writeback:元数据 journal,但不严格约束数据块先于元数据

对象存储里最常见的是 ordered 或 XFS 这类 metadata journaling 模式下的类似语义。你真正要记住的是:

元数据可恢复,不等于你写入的数据一定已经按你想象的顺序稳定落盘。

这也是为什么很多 crash-safe 写入协议不能只依赖“文件系统有 journal”。


9. fsync 到底在保证什么

这是对象存储里最容易被写错的一段语义。

很多工程师会把 fsync(fd) 理解成“把这个文件完整保存到磁盘”。这句话方向没错,但不够精确。

更准确地说,fsync(fd) 试图保证的是:

  1. 这个文件相关的脏数据页被提交
  2. 这个文件相关的必要元数据被提交
  3. 设备层需要的 flush 被发出,使得写入跨过 volatile cache 边界

但它不是在说:

  1. 父目录项一定已经持久化
  2. 同目录下别的文件也一起安全了
  3. 整个应用的一组写操作自动构成事务

为什么 rename 后还要 fsync(parent_dir)

这是非常经典也非常容易漏掉的一步。

假设你这样写对象:

write tmp
fsync(tmp)
rename(tmp, final)

很多人会以为这就足够了。其实还差一步:

fsync(parent_dir)

原因是 rename() 修改的核心是目录项。目录本身也是需要持久化的元数据对象。

也就是说,掉电时可能出现这样的状态:

  1. tmp 文件数据是安全的
  2. rename 在内存视图里已经成功
  3. 但父目录的变更尚未稳定落盘
  4. 重启后 final 名字不一定存在,或者目录项状态不符合你的预期

最小 crash-safe publish 伪代码通常应该是:

function publish_object(tmp_path, final_path, data):
    fd = open(tmp_path, O_CREAT|O_TRUNC|O_WRONLY)
    write_all(fd, data)
    fsync(fd)
    close(fd)

    rename(tmp_path, final_path)

    dirfd = open(parent(final_path), O_RDONLY)
    fsync(dirfd)
    close(dirfd)

如果你还有一个独立的元数据文件,比如 manifest,也常常要再做一层:

  1. 先稳定 data file
  2. 再稳定 metadata file
  3. 再切换 manifest 引用
  4. 最后持久化目录

这本质上是在用户态手工拼一个小事务。


10. 从对象存储写路径看一遍:为什么最终常走向“新写后切换引用”

现在假设我们实现一个很朴素的单机对象写入。

方案 A:原地覆盖

open(existing_object)
write(new_bytes)
fsync(fd)

这个方案的问题是:

  1. 崩溃时旧数据和新数据可能混杂
  2. 部分页已经回写、部分页未回写时,恢复语义复杂
  3. 对象元数据校验值、长度、索引项也得同步更新
  4. 回滚很麻烦

方案 B:写新对象,最后切换引用

write new chunk file
fsync(chunk)
write new manifest
fsync(manifest)
rename(manifest.tmp, manifest)
fsync(parent_dir)

这就是很多对象存储更偏爱的模型,因为它充分利用了文件系统已经很擅长的两件事:

  1. 追加或顺序写新文件
  2. 原子地切换名字或引用

如果再抽象一步,你会发现这跟 LSM、WAL、immutable SSTable、append-only segment 的思想非常接近。

不是巧合,而是因为:

只要底层存储对“原地小更新”不友好,系统设计就会自然向“写新版本,再切换可见性”演化。

文件系统如此,对象存储也是如此。


11. 为什么对象存储常常讨厌小文件

表面上看,一个 4KB 文件和一个 4MB 文件,API 都只是“存个对象”。但对文件系统来说,它们的成本结构完全不同。

小文件的问题通常不是数据量本身,而是固定成本太高:

  1. 路径查找
  2. inode 分配
  3. dentry 更新
  4. journal 事务
  5. fsync flush
  6. 后续删除与空间回收

很多时候,你写 1KB 数据,真正付出的元数据与同步成本比数据本体大得多。

所以对象存储系统里常见的优化,几乎都能追溯到这个事实:

  1. 小对象打包进 larger segment
  2. metadata 和 data 分离存储
  3. append-only log + 索引映射
  4. chunk 复用而非 file-per-object

这并不是因为“文件系统差”,而是因为文件系统天然要维护一整套通用语义,而对象存储往往可以用更窄的约束换更高的吞吐。


12. ext4 和 XFS,在对象存储场景下该怎么理解

这类问题很容易被讨论成“谁更快”。其实更好的问法是:

谁的分配、日志、扩展和运维特性,更贴近你的 workload。

这里只给一个工程视角的粗线条,不展开成 benchmark 文。

ext4 的直觉画像

  1. 通用、成熟、默认值友好
  2. 小到中等规模 workload 很常见
  3. jbd2 语义清晰,工具链成熟
  4. extent + delayed alloc 足以覆盖大量常规场景

XFS 的直觉画像

  1. 对大文件、并发分配、可扩展元数据管理通常更积极
  2. allocation group 设计让并发扩展能力比较强
  3. 大吞吐写入场景下常见于对象、日志、数据湖类系统
  4. 运维时也需要理解它自己的修复、空间管理和延迟回收特性

从对象存储视角,我更建议这样理解它们:

  1. 如果你的节点主要写较大的 immutable chunk,XFS 往往会是很自然的候选
  2. 如果你的节点兼顾大量通用 Linux 服务、生态和默认运维路径,ext4 仍然非常稳妥
  3. 真正决定效果的,通常不只是文件系统名字,还包括挂载参数、写入模式、对象大小分布、是否 O_DIRECT、是否频繁 fsync

所以别把问题简化成“ext4 vs XFS 谁赢”,那通常不是最关键的层。


13. CoW 文件系统为什么看起来很美,但对象存储不一定总喜欢

像 btrfs、ZFS 这类 CoW 文件系统有很多迷人的特性:

  1. 快照
  2. checksum
  3. copy-on-write 更新
  4. 更强的端到端数据校验能力

这些特性对存储系统工程师天然很有吸引力。

但对象存储场景下也要看到另一面。

如果你的上层本来就在做:

  1. 多副本或 EC
  2. 自己的 checksum
  3. immutable chunk
  4. manifest 级版本切换

那么底层再叠一层 CoW,可能会出现这些问题:

  1. 写放大进一步升高
  2. 碎片化更明显
  3. 空间回收与后台整理更复杂
  4. latency predictability 变差

这不是说 CoW 文件系统不能做对象存储,而是说:

当上层已经是 log-structured/immutable 语义时,底层再做一次 CoW,收益和成本需要重新核算。

这也是为什么很多大规模对象或日志系统,最终仍然偏好更“朴素但可预测”的底层文件系统,然后把复杂语义放在上层自己控制。


14. 删除为什么经常不等于“空间立刻回来”

这是对象存储运维里极常见的错觉。

用户删了 10TB 对象,为什么磁盘使用率没有马上掉?

原因通常不是一个,而是一串:

  1. 目录项删除了,但文件可能仍被某个进程打开
  2. extent 释放是元数据更新,不代表设备层空间视图立刻变化
  3. 延迟回收、后台 trim、discard 行为可能滞后
  4. CoW / snapshot / reflink 结构下,块可能仍被别的引用持有
  5. 对象存储自己还有 tombstone、GC、segment compaction

从 VFS 语义上讲,一个文件真正释放空间,往往要同时满足:

  1. link count 归零
  2. 没有打开引用继续持有 struct file
  3. 具体文件系统完成对应 block 或 extent 回收

这就是为什么很多对象存储内部会把“逻辑删除”和“物理回收”明确拆成两个阶段。

文件系统本来就是这么干的,上层系统只是在重复这个规律。


15. O_DIRECT 是不是银弹

对象存储工程里很容易出现一个阶段:

  1. 先被 page cache 尾延迟折磨
  2. 然后开始尝试 O_DIRECT
  3. 最后发现世界并没有突然变简单

因为 O_DIRECT 解决的是一部分问题,不是全部问题。

它通常能带来的好处是:

  1. 减少 page cache 污染
  2. 减少双重缓存
  3. 让应用更直接掌控 I/O 提交节奏

但它也会引入新的工程要求:

  1. 对齐要求更严格
  2. 小 I/O 合并要自己做
  3. 应用层 buffering、prefetch、write batching 都要自己补
  4. 你仍然需要理解 fsync、目录持久化、元数据事务这些问题

所以正确的问题不是“要不要一律上 O_DIRECT”,而是:

  1. 你的对象大小分布是什么
  2. 热点读是否适合 page cache
  3. 你愿不愿意把 cache 管理复杂度搬到用户态
  4. 你的存储引擎是不是已经足够 log-structured,适合 direct I/O

很多成熟系统最后会形成混合策略,而不是极端站队。


16. 一个对象存储友好的最小本地写入协议

如果让我给一个“尽量不踩坑”的本地持久化最小协议,我会推荐接近下面这种结构:

data file immutable
manifest file small and replaceable
visibility switch via rename
directory fsync after structural changes
background GC for old chunks

更具体一点,可以写成:

function put_object(key, payload):
    chunk_id = allocate_chunk_id()
    tmp_chunk = chunk_tmp_path(chunk_id)
    final_chunk = chunk_final_path(chunk_id)

    fd = open(tmp_chunk, O_CREAT|O_EXCL|O_WRONLY)
    write_all(fd, payload)
    fsync(fd)
    close(fd)

    rename(tmp_chunk, final_chunk)
    fsync(parent_dir(final_chunk))

    manifest_tmp = manifest_tmp_path(key)
    write_manifest(manifest_tmp, {
        key: key,
        chunk_id: chunk_id,
        size: len(payload),
        checksum: crc32(payload),
    })
    fsync(manifest_tmp)

    rename(manifest_tmp, manifest_final_path(key))
    fsync(parent_dir(manifest_final_path(key)))

    enqueue_old_versions_for_gc(key)

这个协议的核心思想是:

  1. 数据文件尽量 immutable
  2. 可见性切换用小元数据完成
  3. 每个结构性变化后把目录也持久化
  4. 删除和版本回收交给后台 GC

它未必是性能最极致的,但在“语义先正确”这件事上非常稳。

如果你后面再引入:

  1. 批量 manifest
  2. append-only segment
  3. 多对象共享 pack file
  4. WAL + checkpoint

也基本是在这个骨架上演进。


17. 再往下一层:源码里真正值得盯住哪些函数

如果你打算继续往 Linux 源码里深挖,我建议优先盯这些路径,而不是一开始就陷进所有细节里。

VFS 与通用写路径

重点函数名:

do_sys_openat2
path_openat
vfs_open
vfs_write
new_sync_write
generic_perform_write
filemap_write_and_wait_range
vfs_fsync_range

你会在这里看到:

  1. 路径解析
  2. dentry/inode/file 的衔接
  3. buffered write 如何进入 page cache
  4. fsync 如何下钻到具体文件系统

ext4 方向

重点函数名:

ext4_file_write_iter
ext4_da_write_begin
ext4_da_write_end
ext4_map_blocks
ext4_sync_file
ext4_rename

这里值得观察的是:

  1. delayed allocation 何时转成真实 block mapping
  2. inode / extent / journal 更新如何串起来
  3. renamefsync 怎样影响崩溃一致性

XFS 方向

重点函数名:

xfs_file_write_iter
xfs_buffered_write_iomap_begin
xfs_file_fsync
xfs_rename

这里更值得关注的是:

  1. iomap 路径如何组织映射与写入
  2. 并发分配与日志提交如何配合
  3. 大文件写入时元数据路径是否更符合你的 workload

阅读方法不要一上来追所有分支。更高效的方式通常是:

  1. 先定一个系统调用,比如 write()rename()
  2. 只跟一条最常见的成功路径
  3. 先看对象和状态如何流转
  4. 最后再补错误路径和 corner case

这样不会迷路。


18. 对象存储工程师最该建立的三个文件系统直觉

讲到这里,最后收束成三条我认为最重要的直觉。

直觉一:文件系统首先是语义系统,其次才是 I/O 系统

很多性能问题最后都能追到语义要求本身。

你要求:

  1. 覆盖写立即可见
  2. 崩溃后不丢
  3. 小对象很多
  4. 删除即时回收

这些要求叠在一起,本来就贵。

不要把所有成本都归咎于“磁盘慢”或“文件系统不行”。

直觉二:原地更新天然困难,新写后切换引用天然友好

这不是对象存储特例,而是从文件系统到数据库到 LSM 几乎共同的规律。

只要系统允许你把“更新”改写为“发布新版本”,很多复杂问题都会立刻变得更可控:

  1. 崩溃恢复更简单
  2. 校验更简单
  3. 回滚更简单
  4. 批处理更简单

直觉三:性能问题常常不是出在 write(),而是出在写后的世界

也就是:

  1. 脏页回写
  2. 日志提交
  3. extent 分配
  4. fsync flush
  5. 空间回收

所以看对象存储节点,不能只盯前台请求耗时,还要盯内核后台状态。


19. 总结:对象存储不是绕过文件系统,而是在重新组织文件系统的成本

如果把整篇文章压缩成一句话,那就是:

对象存储表面上卖的是 object 语义,底层真正打交道的仍然是文件系统,只不过它会通过 immutable chunk、manifest、rename、GC、segment compaction 等设计,重新组织文件系统的成本与风险。

所以,一个对象存储工程师理解文件系统,真正该追求的不是“我能背出 inode 有哪些字段”,而是下面这些判断力:

  1. 哪些操作只是进了 page cache,哪些才真正稳定落盘
  2. 哪些语义可以交给文件系统,哪些必须由上层协议自己兜住
  3. 为什么 fsync(file) 不等于整个发布事务完成
  4. 为什么原地覆盖常常是错的默认选项
  5. 为什么很多高性能对象存储最终看起来都越来越 log-structured

当你把这些问题想清楚,再去看本地盘布局、chunk engine、WAL、manifest、GC、compaction、rebuild,就会发现它们不再是零散技巧,而是一条很统一的工程逻辑。

下一次如果你在生产里看到下面这些症状:

  1. PUT 延迟周期性抖动
  2. 删除不回空间
  3. 掉电后对象可见性异常
  4. 同样的 workload 换 ext4/XFS 表现差很多

不要先从分布式协议怀疑人生。

先问一句:

这到底是不是文件系统语义、page cache、journal、fsync 或目录持久化在说话。

很多时候,答案就是。