经典系统深度解析|从对象存储研发工程师视角看文件系统:VFS、Page Cache、Journal 与崩溃一致性
很多工程师做对象存储久了,会逐渐形成一种错觉:
- 我们做的是 object,不是 file
- 底层文件系统只是一个“本地持久化容器”
- 真正复杂的是副本、纠删码、数据分片、元数据服务和一致性协议
这几个判断都不能说错,但它们很容易让人忽略一个事实:
对象存储并没有绕开文件系统,它只是把最关键的语义压力,都压到了文件系统上。
你把一个对象切成 chunk 落到磁盘,本质上还是在做:
- 路径查找
- inode 分配
- page cache 写入
- extent 映射
- journal 或 log 提交
fsync持久化rename原子切换
如果这些环节理解得不够深,系统在实验环境里往往“能跑”,一到生产就会暴露出很多你原本以为属于分布式层的问题:
- 小对象写入吞吐为什么上不去
- 为什么删除后磁盘空间没有立刻回来
- 为什么机器重启后 index file 在,data file 却不在
- 为什么
rename成功了,目录项却没有持久化 - 为什么明明是顺序写,尾延迟还是会被脏页回写打爆
所以这篇文章不打算写一本“文件系统概论”,而是换一个更工程化的角度:
如果你是对象存储研发工程师,你到底应该怎样理解文件系统。
我会重点回答这些问题:
- VFS 到具体文件系统之间的调用链是什么
- inode、dentry、page cache、extent 各自解决什么问题
- 一次对象写入在内核里实际经历了哪些路径
fsync、fdatasync、rename到底保证了什么,不保证什么- ext4 / XFS / CoW 文件系统在对象存储场景下有什么关键差异
- 为什么很多对象存储最终都会走向 append-only、log-structured 或 chunk immutable 设计
为了避免文章停留在概念层,我会尽量补两类内容:
- Linux 内核源码里的关键结构和关键函数名
- 足够接近真实实现的伪代码
不是为了让你背源码,而是为了建立一种“看得见数据流和状态流”的理解方式。
1. 先把问题摆正:对象存储为什么必须认真理解文件系统
很多对象存储系统,对外暴露的是这样的 API:
PUT /bucket/key
GET /bucket/key
DELETE /bucket/key
LIST /bucket/prefix
看起来跟传统文件系统的 open/read/write/close 很不一样。
但在单机持久化层,你最终还是要处理这些问题:
- 一个对象的元数据放哪里
- 一个对象的数据块放哪里
- 对象覆盖时是原地更新还是新写后切换引用
- 删除一个对象时,空间什么时候真正回收
- 机器掉电后,哪些对象应该可见,哪些不应该可见
只要你开始回答这些问题,你其实就已经在和文件系统语义正面交锋了。
从工程上看,一个对象存储节点往往不是“直接写裸盘”,而是运行在这样的分层之上:
Application (object service)
-> libc / syscalls
-> VFS
-> ext4 / XFS / btrfs / ZFS
-> block layer
-> NVMe / SSD / HDD
也就是说,你设计对象写入协议时,真正依赖的是:
- VFS 的抽象语义
- 具体文件系统的崩溃一致性模型
- block 层和设备的 flush / FUA / cache 行为
如果你只理解对象层,不理解这三层,那很多“诡异现象”其实就根本解释不通。
2. 文件系统不是一个模块,而是一组语义合同
初学者常常把文件系统理解成“负责把字节存到磁盘上的程序”。这个说法不算错,但太粗了。
更准确地说,文件系统至少同时做了五件事:
- 提供命名空间
- 管理元数据
- 管理空间分配
- 提供缓存与回写机制
- 提供崩溃恢复与一致性语义
对象存储工程里,最后两件最容易被低估。
为什么?因为对象存储通常会自己实现副本、校验、重建、垃圾回收,看起来像是“系统级一致性”都被上层接管了。
但你只要把数据先落到本地磁盘,本地文件系统就已经替你做了另一套一致性承诺。而问题恰恰在于:
上层假设的承诺,未必等于文件系统实际提供的承诺。
例如:
- 你以为
write()返回成功就等于落盘,实际上通常只是进入 page cache - 你以为
rename()成功后掉电也一定可见,实际上目录本身可能还没持久化 - 你以为删除一个大文件后空间会立刻回来,实际上 block 回收、extent 更新、后台 trim 都可能滞后
对象存储最怕的不是“慢一点”,而是“语义理解错了”。
3. 从源码视角看第一层:VFS 到底抽象了什么
Linux 不会让每个上层程序都直接和 ext4、XFS 打交道。中间有一层非常关键的抽象:VFS,Virtual File System。
VFS 解决的问题是:
给上层提供统一的文件接口,同时把不同文件系统的实现细节藏在各自的 operation table 里。
如果只看最核心的对象,VFS 大致可以理解成下面这组结构:
super_block # 一个已挂载文件系统实例
inode # 一个文件的元数据对象
dentry # 路径名到 inode 的缓存项
file # 一次打开后的运行时句柄
address_space # 文件页缓存与回写视图
把它翻译成对象存储工程师更熟悉的话:
inode像“对象实体”本身dentry像“名字到实体”的缓存映射file像“某次客户端请求持有的会话状态”address_space像“这份对象数据当前在内存页缓存里的视图”
一次 open() 在内核里大概发生什么
从源码路径上,现代内核里你常会看到这样的主链路:
do_sys_openat2
-> path_openat
-> filename_lookup
-> link_path_walk
-> lookup_fast / lookup_slow
-> do_open
-> vfs_open
这条链路里最重要的事,不是“打开文件”这四个字本身,而是:
- 路径逐级解析
- dentry cache 命中或回源
- 权限检查
- 找到最终 inode
- 生成
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
对于对象存储,这里有两个直接启发:
- 如果你的对象布局把大量对象摊在单目录里,目录查找本身就会变成热点
- 如果你的 key 到本地路径映射过深,路径遍历成本和 dentry 行为也会放大
所以很多对象存储会做两件事:
- 对 key 做 hash 分桶,避免单目录过热
- 用更扁平的 chunk namespace,而不是保留用户原始路径层级
这不是“编码风格问题”,而是对目录索引和 dentry 行为的工程回应。
4. inode 解决的不是“名字”,而是“对象本体”
很多人第一次学文件系统时会记住一句话:文件名不在 inode 里,目录项才维护名字到 inode 的映射。
这句话非常重要,因为它直接决定了几个对象存储里常见但容易误判的行为。
inode 里通常保存的是:
- 文件类型
- 权限和属主
- 文件大小
- 时间戳
- block 或 extent 映射信息
- link count
- 回写、锁、页缓存等运行时状态引用
它本质上描述的是:
这个文件是什么,以及它的数据在哪里。
而目录项描述的是:
这个名字指向哪个 inode。
这会带来一个非常关键的事实:
rename() 改的主要是目录关系,不是文件数据本体。
这也是为什么很多对象存储喜欢用“临时文件写入完成后 rename 切换”的原因。
因为如果你采用这样的写入协议:
- 先把对象内容写到
tmp/object-id.tmp fsync(tmp_fd)rename(tmp, final)fsync(parent_dir_fd)
那么你实际上是在利用:
- 数据块持久化
- inode 元数据持久化
- 目录项切换的原子性
这是一种非常经典的 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() 成功,通常只说明:
- 数据已经进入内核内存
- 对应页被标记为 dirty
- 后续会由后台 flusher 或主动
fsync去落盘
为什么它对对象存储很重要
因为对象存储的写入模式经常会碰到 page cache 的两面性:
- 小对象写入能被 page cache 吞进去,看起来吞吐很高
- 真正回写时,脏页积压会在某个时刻集中爆发,尾延迟陡增
- 大对象顺序写会把 cache 顶满,污染读热点
- 多副本同步写时,多个文件描述符共享回写压力,会导致写放大和调度抖动
所以你在对象存储里做性能优化,不能只看接口层 QPS,还要问:
- 脏页比例是多少
- 回写带宽什么时候被打满
balance_dirty_pages()什么时候开始反压前台写线程- 你的 workload 适不适合
O_DIRECT
这才是很多“压测很快,生产尾延迟很差”的根因。
6. extent 解决的是空间映射,不是简单的 block list
早期理解文件系统时,很多人会把文件看成“若干 block 的列表”。
这个模型能帮助入门,但对现代文件系统已经不够了。
现代 ext4、XFS 这类文件系统,更常见的是 extent-based allocation。一个 extent 可以理解成:
(logical_offset, physical_start_block, length)
也就是:
文件逻辑上的一段连续区域,映射到磁盘上一段连续物理块。
为什么这很重要?
因为对象存储最喜欢干的事情之一,就是写大文件、大 chunk、append log,而这些场景都非常依赖 extent 带来的好处:
- 元数据更紧凑
- 连续空间更容易顺序读写
- 大块 I/O 更容易形成设备友好的访问模式
但它同时也带来另一个工程现实:
空间分配不是立即且完全确定的。
比如 ext4 里有 delayed allocation,XFS 里也有自己的延迟分配与 unwritten extent 处理逻辑。这样做可以提高聚合写效果,但也意味着:
- 逻辑写入完成时,物理块可能还没最终定型
writeback和fsync路径会触发更重的 block mapping 工作- 掉电一致性必须靠日志或事务机制兜住
从对象存储角度看,这解释了两个常见现象:
- 顺序写对象时吞吐很好,因为分配器能较好地拿到大 extent
- 小对象随机覆盖时性能很差,因为 extent 分裂、元数据更新和 journal 压力都上来了
7. 真正难的不是写入,而是崩溃一致性
如果只考虑正常运行,文件系统并不神秘:
- 找到 inode
- 分配空间
- 修改页缓存
- 后台落盘
真正把复杂度拉上去的,是中途可能随时掉电。
对象存储工程里,一旦你要承诺“PUT 成功后对象不会丢”,你就不能只理解正常路径,必须理解 crash consistency。
为什么崩溃一致性这么难
因为一次“看起来简单的对象写入”,底层其实涉及多类状态:
- 数据块内容
- inode 大小变化
- extent 映射变化
- 目录项变化
- 空闲空间位图或 B-tree 更新
- 日志本身的提交状态
如果这些状态不是以某种原子方式对外可见,那么掉电后系统就可能处于“半更新”状态。
这也是 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 都写完再说”,而是更像:
- 先把日志里需要的描述写进去
- 标记事务 commit
- 崩溃恢复时按日志重放或确认哪些修改有效
这就是为什么 journal 的核心价值不是“提高吞吐”,而是:
让系统在掉电后还能知道哪些修改算数。
数据 journal、ordered、writeback 有什么差别
ext4 常被提到的三种模式,本质是数据和元数据持久化关系的不同:
data=journal:数据和元数据都进 journal,最重但语义强data=ordered:元数据 journal,且在提交相关元数据前尽量保证数据块先落盘data=writeback:元数据 journal,但不严格约束数据块先于元数据
对象存储里最常见的是 ordered 或 XFS 这类 metadata journaling 模式下的类似语义。你真正要记住的是:
元数据可恢复,不等于你写入的数据一定已经按你想象的顺序稳定落盘。
这也是为什么很多 crash-safe 写入协议不能只依赖“文件系统有 journal”。
9. fsync 到底在保证什么
这是对象存储里最容易被写错的一段语义。
很多工程师会把 fsync(fd) 理解成“把这个文件完整保存到磁盘”。这句话方向没错,但不够精确。
更准确地说,fsync(fd) 试图保证的是:
- 这个文件相关的脏数据页被提交
- 这个文件相关的必要元数据被提交
- 设备层需要的 flush 被发出,使得写入跨过 volatile cache 边界
但它不是在说:
- 父目录项一定已经持久化
- 同目录下别的文件也一起安全了
- 整个应用的一组写操作自动构成事务
为什么 rename 后还要 fsync(parent_dir)
这是非常经典也非常容易漏掉的一步。
假设你这样写对象:
write tmp
fsync(tmp)
rename(tmp, final)
很多人会以为这就足够了。其实还差一步:
fsync(parent_dir)
原因是 rename() 修改的核心是目录项。目录本身也是需要持久化的元数据对象。
也就是说,掉电时可能出现这样的状态:
- tmp 文件数据是安全的
- rename 在内存视图里已经成功
- 但父目录的变更尚未稳定落盘
- 重启后 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,也常常要再做一层:
- 先稳定 data file
- 再稳定 metadata file
- 再切换 manifest 引用
- 最后持久化目录
这本质上是在用户态手工拼一个小事务。
10. 从对象存储写路径看一遍:为什么最终常走向“新写后切换引用”
现在假设我们实现一个很朴素的单机对象写入。
方案 A:原地覆盖
open(existing_object)
write(new_bytes)
fsync(fd)
这个方案的问题是:
- 崩溃时旧数据和新数据可能混杂
- 部分页已经回写、部分页未回写时,恢复语义复杂
- 对象元数据校验值、长度、索引项也得同步更新
- 回滚很麻烦
方案 B:写新对象,最后切换引用
write new chunk file
fsync(chunk)
write new manifest
fsync(manifest)
rename(manifest.tmp, manifest)
fsync(parent_dir)
这就是很多对象存储更偏爱的模型,因为它充分利用了文件系统已经很擅长的两件事:
- 追加或顺序写新文件
- 原子地切换名字或引用
如果再抽象一步,你会发现这跟 LSM、WAL、immutable SSTable、append-only segment 的思想非常接近。
不是巧合,而是因为:
只要底层存储对“原地小更新”不友好,系统设计就会自然向“写新版本,再切换可见性”演化。
文件系统如此,对象存储也是如此。
11. 为什么对象存储常常讨厌小文件
表面上看,一个 4KB 文件和一个 4MB 文件,API 都只是“存个对象”。但对文件系统来说,它们的成本结构完全不同。
小文件的问题通常不是数据量本身,而是固定成本太高:
- 路径查找
- inode 分配
- dentry 更新
- journal 事务
fsyncflush- 后续删除与空间回收
很多时候,你写 1KB 数据,真正付出的元数据与同步成本比数据本体大得多。
所以对象存储系统里常见的优化,几乎都能追溯到这个事实:
- 小对象打包进 larger segment
- metadata 和 data 分离存储
- append-only log + 索引映射
- chunk 复用而非 file-per-object
这并不是因为“文件系统差”,而是因为文件系统天然要维护一整套通用语义,而对象存储往往可以用更窄的约束换更高的吞吐。
12. ext4 和 XFS,在对象存储场景下该怎么理解
这类问题很容易被讨论成“谁更快”。其实更好的问法是:
谁的分配、日志、扩展和运维特性,更贴近你的 workload。
这里只给一个工程视角的粗线条,不展开成 benchmark 文。
ext4 的直觉画像
- 通用、成熟、默认值友好
- 小到中等规模 workload 很常见
- jbd2 语义清晰,工具链成熟
- extent + delayed alloc 足以覆盖大量常规场景
XFS 的直觉画像
- 对大文件、并发分配、可扩展元数据管理通常更积极
- allocation group 设计让并发扩展能力比较强
- 大吞吐写入场景下常见于对象、日志、数据湖类系统
- 运维时也需要理解它自己的修复、空间管理和延迟回收特性
从对象存储视角,我更建议这样理解它们:
- 如果你的节点主要写较大的 immutable chunk,XFS 往往会是很自然的候选
- 如果你的节点兼顾大量通用 Linux 服务、生态和默认运维路径,ext4 仍然非常稳妥
- 真正决定效果的,通常不只是文件系统名字,还包括挂载参数、写入模式、对象大小分布、是否
O_DIRECT、是否频繁fsync
所以别把问题简化成“ext4 vs XFS 谁赢”,那通常不是最关键的层。
13. CoW 文件系统为什么看起来很美,但对象存储不一定总喜欢
像 btrfs、ZFS 这类 CoW 文件系统有很多迷人的特性:
- 快照
- checksum
- copy-on-write 更新
- 更强的端到端数据校验能力
这些特性对存储系统工程师天然很有吸引力。
但对象存储场景下也要看到另一面。
如果你的上层本来就在做:
- 多副本或 EC
- 自己的 checksum
- immutable chunk
- manifest 级版本切换
那么底层再叠一层 CoW,可能会出现这些问题:
- 写放大进一步升高
- 碎片化更明显
- 空间回收与后台整理更复杂
- latency predictability 变差
这不是说 CoW 文件系统不能做对象存储,而是说:
当上层已经是 log-structured/immutable 语义时,底层再做一次 CoW,收益和成本需要重新核算。
这也是为什么很多大规模对象或日志系统,最终仍然偏好更“朴素但可预测”的底层文件系统,然后把复杂语义放在上层自己控制。
14. 删除为什么经常不等于“空间立刻回来”
这是对象存储运维里极常见的错觉。
用户删了 10TB 对象,为什么磁盘使用率没有马上掉?
原因通常不是一个,而是一串:
- 目录项删除了,但文件可能仍被某个进程打开
- extent 释放是元数据更新,不代表设备层空间视图立刻变化
- 延迟回收、后台 trim、discard 行为可能滞后
- CoW / snapshot / reflink 结构下,块可能仍被别的引用持有
- 对象存储自己还有 tombstone、GC、segment compaction
从 VFS 语义上讲,一个文件真正释放空间,往往要同时满足:
- link count 归零
- 没有打开引用继续持有
struct file - 具体文件系统完成对应 block 或 extent 回收
这就是为什么很多对象存储内部会把“逻辑删除”和“物理回收”明确拆成两个阶段。
文件系统本来就是这么干的,上层系统只是在重复这个规律。
15. O_DIRECT 是不是银弹
对象存储工程里很容易出现一个阶段:
- 先被 page cache 尾延迟折磨
- 然后开始尝试
O_DIRECT - 最后发现世界并没有突然变简单
因为 O_DIRECT 解决的是一部分问题,不是全部问题。
它通常能带来的好处是:
- 减少 page cache 污染
- 减少双重缓存
- 让应用更直接掌控 I/O 提交节奏
但它也会引入新的工程要求:
- 对齐要求更严格
- 小 I/O 合并要自己做
- 应用层 buffering、prefetch、write batching 都要自己补
- 你仍然需要理解
fsync、目录持久化、元数据事务这些问题
所以正确的问题不是“要不要一律上 O_DIRECT”,而是:
- 你的对象大小分布是什么
- 热点读是否适合 page cache
- 你愿不愿意把 cache 管理复杂度搬到用户态
- 你的存储引擎是不是已经足够 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)
这个协议的核心思想是:
- 数据文件尽量 immutable
- 可见性切换用小元数据完成
- 每个结构性变化后把目录也持久化
- 删除和版本回收交给后台 GC
它未必是性能最极致的,但在“语义先正确”这件事上非常稳。
如果你后面再引入:
- 批量 manifest
- append-only segment
- 多对象共享 pack file
- 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
你会在这里看到:
- 路径解析
- dentry/inode/file 的衔接
- buffered write 如何进入 page cache
fsync如何下钻到具体文件系统
ext4 方向
重点函数名:
ext4_file_write_iter
ext4_da_write_begin
ext4_da_write_end
ext4_map_blocks
ext4_sync_file
ext4_rename
这里值得观察的是:
- delayed allocation 何时转成真实 block mapping
- inode / extent / journal 更新如何串起来
rename和fsync怎样影响崩溃一致性
XFS 方向
重点函数名:
xfs_file_write_iter
xfs_buffered_write_iomap_begin
xfs_file_fsync
xfs_rename
这里更值得关注的是:
- iomap 路径如何组织映射与写入
- 并发分配与日志提交如何配合
- 大文件写入时元数据路径是否更符合你的 workload
阅读方法不要一上来追所有分支。更高效的方式通常是:
- 先定一个系统调用,比如
write()或rename() - 只跟一条最常见的成功路径
- 先看对象和状态如何流转
- 最后再补错误路径和 corner case
这样不会迷路。
18. 对象存储工程师最该建立的三个文件系统直觉
讲到这里,最后收束成三条我认为最重要的直觉。
直觉一:文件系统首先是语义系统,其次才是 I/O 系统
很多性能问题最后都能追到语义要求本身。
你要求:
- 覆盖写立即可见
- 崩溃后不丢
- 小对象很多
- 删除即时回收
这些要求叠在一起,本来就贵。
不要把所有成本都归咎于“磁盘慢”或“文件系统不行”。
直觉二:原地更新天然困难,新写后切换引用天然友好
这不是对象存储特例,而是从文件系统到数据库到 LSM 几乎共同的规律。
只要系统允许你把“更新”改写为“发布新版本”,很多复杂问题都会立刻变得更可控:
- 崩溃恢复更简单
- 校验更简单
- 回滚更简单
- 批处理更简单
直觉三:性能问题常常不是出在 write(),而是出在写后的世界
也就是:
- 脏页回写
- 日志提交
- extent 分配
fsyncflush- 空间回收
所以看对象存储节点,不能只盯前台请求耗时,还要盯内核后台状态。
19. 总结:对象存储不是绕过文件系统,而是在重新组织文件系统的成本
如果把整篇文章压缩成一句话,那就是:
对象存储表面上卖的是 object 语义,底层真正打交道的仍然是文件系统,只不过它会通过 immutable chunk、manifest、rename、GC、segment compaction 等设计,重新组织文件系统的成本与风险。
所以,一个对象存储工程师理解文件系统,真正该追求的不是“我能背出 inode 有哪些字段”,而是下面这些判断力:
- 哪些操作只是进了 page cache,哪些才真正稳定落盘
- 哪些语义可以交给文件系统,哪些必须由上层协议自己兜住
- 为什么
fsync(file)不等于整个发布事务完成 - 为什么原地覆盖常常是错的默认选项
- 为什么很多高性能对象存储最终看起来都越来越 log-structured
当你把这些问题想清楚,再去看本地盘布局、chunk engine、WAL、manifest、GC、compaction、rebuild,就会发现它们不再是零散技巧,而是一条很统一的工程逻辑。
下一次如果你在生产里看到下面这些症状:
- PUT 延迟周期性抖动
- 删除不回空间
- 掉电后对象可见性异常
- 同样的 workload 换 ext4/XFS 表现差很多
不要先从分布式协议怀疑人生。
先问一句:
这到底是不是文件系统语义、page cache、journal、fsync 或目录持久化在说话。
很多时候,答案就是。