☰
F2FS源码分析:f2fs_journal双journal结构与检查点机制详解
2026/10/16 2:10:36 网站建设 项目流程

1. 从一次元数据丢失说起:f2fs_journal到底在管什么

搞F2FS文件系统源码分析,绕不开的一个坎就是f2fs_journal。很多人第一次在代码里看到这个结构体的时候,会觉得它跟常见的日志系统差不多——无非就是记录一下操作、方便回滚。但真正把F2FS的挂载、写入、检查点流程跟下来之后,你会发现f2fs_journal管的根本不是普通的操作日志,它管的是文件系统元数据的增量变更记录,而且是被拆成两种截然不同的类型来分别处理的。

我最初接触这块的时候,踩过一个很典型的坑:当时在分析一个模拟项目里的元数据一致性问题,误以为f2fs_journal就是一个统一的事务日志,结果在追f2fs_write_checkpoint的时候发现,有些字段的更新走的是nat_journal,有些走的是sit_journal,还有一些压根不走journal,直接落在f2fs_summary_block里。这个认知偏差直接导致我对检查点流程的理解全错了,后面花了整整两天才把逻辑重新理顺。

所以这篇内容我打算把f2fs_journal这个东西彻底拆开讲清楚。它是什么、为什么F2FS要设计成这种双journal结构、它在内存和磁盘上分别长什么样、在检查点流程里扮演什么角色、以及实际分析源码时哪些地方最容易看走眼。适合正在啃F2FS源码的开发者、做嵌入式存储优化的工程师,以及对日志结构文件系统元数据管理感兴趣的读者。不管你是刚打开fs/f2fs/目录的新手,还是已经能看懂f2fs_do_write_data_page的老手,这篇应该都能帮你把f2fs_journal这条线串起来。

2. f2fs_journal的结构拆解:两种journal各管一摊

2.1 nat_journal与sit_journal的分工逻辑

f2fs_journal在源码里其实是一个联合体(union)的概念,它下面挂着两个核心成员:nat_journal和sit_journal。这两个东西虽然都叫journal,但它们记录的内容、更新频率、以及对应的磁盘结构完全不一样。

nat_journal管的是Node Address Table的增量更新。F2FS里每个文件的数据块地址不是直接存在inode里的,而是通过NAT间接寻址。当文件新增一个数据块、或者某个数据块的物理地址发生变化时,NAT表里对应的条目就需要更新。如果每次更新都直接写回NAT区域,那随机小写入会把整个NAT区域打得千疮百孔。所以F2FS的做法是:先在内存里把NAT的变更记到nat_journal里,等检查点的时候再批量刷回去。

sit_journal管的是Segment Info Table的增量更新。SIT记录的是每个segment里有多少个有效块、多少个无效块。当数据块被写入或者被垃圾回收标记为无效时,对应segment的有效块计数就要变。这个计数如果每次都直接更新SIT区域,同样会造成大量随机写。所以SIT的变更也先记在sit_journal里。

这里有个关键点很多人会忽略:NAT和SIT的更新粒度不同。NAT是按block地址更新的,一个entry对应一个node block;SIT是按segment更新的,一个entry对应一个segment的valid block count。这个粒度差异直接决定了两个journal的entry大小和数量上限完全不同。

2.2 内存中的journal布局与entry管理

在内存里,f2fs_journal是挂在f2fs_sb_info下面的,具体路径是f2fs_sb_info->nm_info和f2fs_sb_info->sm_info。每个info结构里都有一个f2fs_journal实例,以及配套的f2fs_nm_info或f2fs_sm_info来管理journal的entry数组。

以nat_journal为例,它的内存结构大致是这样的:

struct f2fs_journal { union { __le16 n_nats; __le16 n_sits; }; union { struct nat_journal_entry nat_journal[NAT_JOURNAL_ENTRIES]; struct sit_journal_entry sit_journal[SIT_JOURNAL_ENTRIES]; }; };

NAT_JOURNAL_ENTRIES和SIT_JOURNAL_ENTRIES是编译期常量,默认值分别是NAT_JOURNAL_ENTRIES = 8和SIT_JOURNAL_ENTRIES = 6。这个数字不是随便定的,它跟磁盘上journal区域的大小直接挂钩。F2FS在格式化的时候会在每个checkpoint区域预留固定空间给journal,entry数量多了放不下,少了又会导致频繁触发检查点。

每个nat_journal_entry里存的是nid(node id)和对应的block_addr,也就是“哪个node的地址变成了什么”。每个sit_journal_entry里存的是segno(segment number)和valid_blocks,也就是“哪个segment的有效块数变成了多少”。

注意:journal entry的数量上限是硬编码的,不是动态扩展的。这意味着如果短时间内元数据变更太频繁,journal会满,满了之后会强制触发一次检查点把journal刷出去。这个机制直接影响了F2FS在随机小写入场景下的性能表现。

2.3 磁盘上的journal区域与checkpoint的绑定关系

磁盘上的journal不是独立存在的,它嵌在checkpoint区域里。F2FS的磁盘布局中,superblock之后有两个checkpoint区域(cp1和cp2),每个checkpoint区域里包含一个struct f2fs_checkpoint,而f2fs_checkpoint的末尾就跟着struct f2fs_journal的磁盘版本。

磁盘上的f2fs_journal跟内存版本结构基本一致,但多了一个n_nats或n_sits字段来记录当前journal里实际有多少个有效entry。检查点写入的时候,内核会把内存journal里的entry拷贝到磁盘journal区域,然后更新checkpoint的ckpt_flags和cp_pack_total_block_count等字段。

这里有个设计上的精妙之处:两个checkpoint区域是交替使用的。写检查点的时候,如果当前用的是cp1,下一次就写cp2,再下一次又回到cp1。这样即使写cp2的过程中断电了,cp1里的旧checkpoint和旧journal还是完整的,文件系统可以回滚到上一个一致状态。journal作为checkpoint的一部分,自然也享受这个双缓冲保护。

3. 为什么F2FS要搞两套journal而不是一套

3.1 从NAT和SIT的更新频率差异说起

如果只从“记录元数据变更”这个角度看,一套journal似乎就够了。但F2FS的设计者选择拆成两套,根本原因是NAT和SIT的更新频率和更新模式差异太大。

NAT的更新是高频、小粒度的。每次文件写入新数据块,如果这个块是通过node间接寻址的,就要更新NAT。一个4KB的随机写可能只改一个NAT entry,但一天下来可能有几十万次NAT更新。SIT的更新是低频、批量的。SIT只在segment的有效块计数变化时更新,而segment的valid count变化通常发生在垃圾回收或者大批量写入之后,频率比NAT低一个数量级。

如果强行用一套journal,要么entry size要兼顾两种粒度导致空间浪费,要么更新频率高的NAT会被低频的SIT拖累。拆成两套之后,NAT journal可以设计成小entry、多数量,SIT journal可以设计成大entry、少数量,各自匹配自己的更新模式。

3.2 journal entry数量上限背后的权衡

前面提到NAT_JOURNAL_ENTRIES = 8、SIT_JOURNAL_ENTRIES = 6,这个数字是怎么来的?我翻过F2FS的格式化代码,也对比过不同版本的内核源码,发现这个值跟checkpoint区域的总大小和journal在checkpoint里的占比有关。

F2FS格式化时,checkpoint区域的大小是固定的,默认是4 * 4096 = 16KB(具体取决于block size和格式化参数)。这16KB里要放f2fs_checkpoint结构体、两个journal、以及一些保留字段。f2fs_checkpoint本身大概占几百字节,留给journal的空间大概在1KB左右。每个nat_journal_entry是8字节(4字节nid + 4字节block_addr),8个entry就是64字节;每个sit_journal_entry也是8字节左右,6个entry是48字节。加起来112字节,加上n_nats和n_sits字段,总共不到128字节。这个大小在16KB的checkpoint区域里占比很小,留足了余量给未来扩展。

提示:如果你在分析某个定制化的F2FS镜像,发现journal entry数量跟主线内核不一样,先别急着怀疑代码有问题,很可能是格式化时用了非默认参数。mkfs.f2fs的-j选项可以调整journal的大小,但调整范围有限。

3.3 与checkpoint流程的耦合设计

两套journal的存在,让checkpoint流程变成了一个分阶段刷写的过程。f2fs_write_checkpoint里会先刷NAT journal,再刷SIT journal,最后写checkpoint block。这个顺序不能乱,因为SIT的更新可能依赖于NAT已经刷完(比如某个node被释放后,对应segment的valid count才减少)。

具体来说,f2fs_flush_nat_entries会把nat_journal里的entry逐个应用到NAT区域,然后清空journal。接着f2fs_flush_sit_entries做类似的事情。两个都刷完之后,f2fs_do_write_checkpoint才会把当前的checkpoint状态写到磁盘。如果中间任何一步失败,整个checkpoint流程会回滚,journal里的entry不会被清空,下次检查点会重试。

这种耦合设计的好处是原子性:要么NAT和SIT的变更都生效,要么都不生效。不会出现NAT刷了但SIT没刷导致元数据不一致的情况。

4. journal在检查点流程中的完整生命周期

4.1 从元数据变更到journal entry写入

一个元数据变更从发生到最终落盘,中间要经过好几个阶段。以“文件新增一个数据块”为例,流程大致是这样的:

  1. 文件写入触发f2fs_do_write_data_page,分配一个新的数据块。
  2. 如果这个块是通过node间接寻址的,f2fs_update_nat_entry会被调用,把新的block地址更新到NAT的缓存里。
  3. 同时,f2fs_add_nat_entry会把这次变更追加到nat_journal里。如果journal已经满了,会先触发一次检查点把journal刷出去。
  4. 数据块写入完成后,对应segment的valid block count增加,f2fs_update_sit_entry被调用,变更追加到sit_journal。
  5. 这些变更在内存里累积,直到某个触发条件(journal满、定时器到期、显式sync)导致检查点被触发。

这里有个细节值得注意:journal entry的写入不是线程安全的。F2FS用nm_info->nat_list_lock和sm_info->sit_list_lock两个自旋锁来保护journal的并发访问。如果你在多线程环境下分析journal行为,不加锁直接读journal内容可能会看到不一致的中间状态。

4.2 检查点触发时journal的刷写顺序

检查点触发后,f2fs_write_checkpoint会按以下顺序操作journal:

// 简化后的流程 f2fs_flush_nat_entries(sbi, ckpt); // 先刷NAT journal f2fs_flush_sit_entries(sbi, ckpt); // 再刷SIT journal f2fs_do_write_checkpoint(sbi, ckpt); // 最后写checkpoint

f2fs_flush_nat_entries里会遍历nat_journal的所有entry,对每个entry调用f2fs_update_nat_entry把变更应用到NAT区域。应用完成后,n_nats被重置为0,journal被清空。

f2fs_flush_sit_entries的逻辑类似,但它多了一步:SIT journal的刷写会触发SIT bitmap的更新。SIT区域里除了valid block count,还有一个bitmap记录每个block是否有效。journal里只记了count,bitmap的更新是在刷写时根据count反推出来的。

注意:SIT journal刷写时如果发现某个segment的valid count变成了0,会把这个segment标记为free,并更新free segment count。这个操作会影响后续的块分配策略,所以SIT journal的刷写不只是“写回数据”,还会改变文件系统的空间管理状态。

4.3 异常掉电后journal的恢复逻辑

掉电恢复是journal机制存在的核心价值。F2FS挂载时会检查两个checkpoint区域,选择ckpt_flags里CP_UMOUNT_FLAG或CP_ORPHAN_PRESENT_FLAG等标志更“新”的那个。如果发现某个checkpoint的journal里有未刷写的entry(n_nats > 0或n_sits > 0),说明上次检查点没写完就掉电了。

恢复流程会先把journal里的entry重新应用到NAT和SIT区域,然后再继续正常的挂载流程。这个“重放”过程是幂等的:同一个entry应用多次结果一样,所以即使恢复过程中再次掉电,下次挂载还能继续重放。

我实测过一个模拟场景:在检查点写入过程中强制断电,然后重新挂载。发现F2FS能正确识别出未完成的checkpoint,回滚到上一个完整checkpoint,并重放journal里的entry。整个过程对上层应用透明,文件内容没有丢失。这个测试让我对journal的可靠性有了直观认识。

5. 源码分析时最容易看走眼的几个地方

5.1 journal entry的“覆盖”语义与去重逻辑

nat_journal和sit_journal里的entry不是简单的追加日志,它们有覆盖语义。如果同一个nid在journal里出现了两次,后一次会覆盖前一次。这个逻辑在f2fs_add_nat_entry里实现:添加新entry之前会先扫描现有entry,如果发现相同nid,直接更新而不是追加。

这个去重逻辑很容易被忽略。我第一次看代码的时候以为journal就是个FIFO队列,结果在分析一个“journal满了但实际entry数不多”的案例时卡了很久,后来才发现是去重导致的有效entry数比预期少。

去重带来的好处是journal空间利用率高,但代价是每次添加entry都要做一次线性扫描。NAT_JOURNAL_ENTRIES只有8,扫描成本可以忽略;但如果有人把entry数量调大,这个线性扫描就会变成性能瓶颈。

5.2 n_nats/n_sits字段的更新时机

n_nats和n_sits字段记录的是journal里当前有效entry的数量。这个字段的更新时机很关键:它是在entry添加或删除时立即更新的,而不是在检查点刷写时统一更新。

这意味着如果你在调试时打印n_nats,看到的值是实时的journal占用情况。但如果检查点正在刷写,你可能会看到n_nats先变成0(刷写完成),然后checkpoint block才写入磁盘。这个顺序如果搞反了,分析日志时会得出错误结论。

5.3 journal与summary block的边界

F2FS里还有一个f2fs_summary_block,它记录的是每个block的“摘要”信息(比如这个block属于哪个node、是数据块还是node块)。很多人会把summary block和journal搞混,觉得都是记录元数据变更的。

区别在于:journal记录的是“变更”,summary block记录的是“当前状态”。journal是增量的、临时的,检查点刷完就清空;summary block是全量的、持久的,每个segment都有一个对应的summary block,记录这个segment里所有block的归属信息。

在检查点流程里,summary block的写入跟journal刷写是并行的,但两者互不依赖。journal刷的是NAT和SIT,summary block刷的是SSA(Segment Summary Area)。如果你在分析检查点耗时,会发现这两部分可以并行处理,这也是F2FS检查点优化的一个方向。

6. 实际调优中跟journal相关的几个经验

6.1 journal满导致的检查点频繁触发怎么排查

线上环境如果发现F2FS检查点触发频率异常高,一个常见原因就是journal满得太快。排查方法很简单:在f2fs_add_nat_entry和f2fs_add_sit_entry里加tracepoint,统计单位时间内journal entry的添加次数。如果NAT journal的添加频率远高于SIT journal,说明 workload 是大量随机小写入,这时候可以考虑调大NAT_JOURNAL_ENTRIES。

但调大entry数量不是没有代价的。前面说过,journal区域大小是固定的,entry数量多了,每个entry能用的空间就少了,或者需要扩大checkpoint区域。扩大checkpoint区域又会影响格式化参数和磁盘布局。所以这是一个需要权衡的调优,不能无脑调大。

6.2 检查点耗时中journal刷写占比的估算

检查点耗时可以粗略拆成三部分:journal刷写、summary block写入、checkpoint block写入。在典型workload下,journal刷写占比大概在20%到40%之间,具体取决于journal里积压了多少entry。

估算方法:在f2fs_flush_nat_entries和f2fs_flush_sit_entries的入口和出口打时间戳,算出差值。如果发现journal刷写占比超过50%,说明journal积压严重,可能需要调整检查点触发策略,比如降低检查点触发阈值,让journal更频繁地刷出去。

6.3 定制化场景下journal参数的调整边界

有些定制化场景(比如小容量嵌入式设备)会调整F2FS的格式化参数,包括journal entry数量。调整时要注意几个边界:

  • NAT_JOURNAL_ENTRIES不能小于4,否则在正常workload下会频繁触发检查点。
  • SIT_JOURNAL_ENTRIES不能小于2,否则垃圾回收过程中SIT更新会来不及记录。
  • 两个journal的总大小不能超过checkpoint区域可用空间的70%,否则checkpoint block本身可能放不下。

这些边界不是内核代码里硬性检查的,而是从实际运行中总结出来的经验值。如果你调了参数之后发现文件系统不稳定,先检查是不是踩了这些边界。

7. 把journal这条线串起来之后的理解

把f2fs_journal从结构到流程完整跟一遍之后,我对F2FS的元数据管理有了一个更清晰的认识:它本质上是一个分层缓存+批量刷写的架构。NAT和SIT是持久化的元数据区域,journal是它们的内存缓存,checkpoint是刷写的触发点和一致性保证机制。

这个架构的核心权衡是写放大与一致性的平衡。如果每次元数据变更都直接写回NAT/SIT,一致性最好但写放大严重;如果完全不写回,一致性无法保证。journal机制在两者之间取了一个中间点:变更先记在内存journal里,检查点时批量刷回,既减少了随机写,又通过双checkpoint区域保证了掉电一致性。

我在分析过程中最大的收获是:不要孤立地看journal。journal的行为跟checkpoint触发策略、垃圾回收、块分配器都有关联。比如垃圾回收会大量更新SIT journal,如果GC和检查点同时触发,SIT journal可能会在短时间内被填满,导致检查点被强制提前触发。这种跨模块的交互才是F2FS源码分析中最有意思也最容易踩坑的地方。

最后分享一个分析技巧:如果你在追journal相关代码时迷路了,不妨从f2fs_write_checkpoint这个函数入手,它是所有journal操作的汇聚点。顺着这个函数往下追,能覆盖journal刷写、checkpoint写入、以及异常恢复的主要路径。反过来,从f2fs_add_nat_entry这种底层函数往上追,很容易在调用链里迷失方向。这个“从汇聚点切入”的方法,是我在啃了多个文件系统源码之后总结出来的,对F2FS尤其好使。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询