文章摘要
飞宇 GPT

引言

NameNode 是 HDFS 的大脑,整个命名空间——目录树、文件到 block 的映射、权限、副本数、时间戳——常驻内存。内存意味着快,也意味着一旦进程退出就什么都没了。所以元数据的持久化,是 NameNode 能活下来的命脉。这篇不重复 HA 与 Federation,专门讲元数据本身:FsImage 和 EditLog 怎么存、checkpoint 怎么做、冷启动怎么恢复、坏了怎么修。

我在某集群上运维过约 12 亿 block 规模的 HDFS,NameNode 重启一次要四十多分钟,期间业务全部卡死——这种痛让我对元数据链路格外较真。下面把这条链路从写到读、从正常到异常,完整拆一遍。

两类元数据

NameNode 的持久化数据只有两类文件,都落在 dfs.namenode.name.dir 指向的本地磁盘上。

类型 角色 内容 生成时机
FsImage 全量快照 目录树、文件→block 列表、权限、副本数、时间戳 checkpoint 时生成
EditLog 增量日志 FsImage 之后每一次状态变更(创建/删除/改名/副本变更等) 实时 append

一个简单的等式贯穿始终:

1
内存当前状态 = 最新 FsImage + 回放其后所有 EditLog

FsImage 是个大而全的序列化文件,二进制格式(Hadoop 2.x 起支持 protobuf),写起来慢、读起来也慢,但能完整描述某一个时刻的命名空间。EditLog 是预写日志(WAL),每次只追加一条记录,写快、读慢(要顺序回放)。两者配合,本质是”全量 + 增量”的经典日志结构存储思路,和数据库的 checkpoint + redo log 是一套打法。

1
2
3
4
5
6
7
8
9
10
11
     FsImage (txid=1,000,000)

▼ 其后所有 edits
┌──────────────────────────────┐
│ edits_0000001_0000500 │
│ edits_0000501_0010233 │
│ edits_inprogress_0010234 │ ← 正在写
└──────────────────────────────┘

▼ 顺序回放
内存 = FsImage + edits

文件名里的数字就是 txid 区间,启动时 NameNode 找到最大的 FsImage txid,然后回放所有 起始 txid > 该值 的 edits 文件。

写路径与事务

每一次让 NameNode 状态发生变更的操作——比如 mkdir /data/2026create /log/app.log——都会被分配一个全局自增的事务号 txid,然后写入 EditLog。完整流程大致是:

  1. NameNode 分配下一个自增 txid
  2. EditLog append 一条记录(含操作类型、路径、参数、时间戳、txid)
  3. EditLog 强制刷盘到 dfs.namenode.edits.dir 的所有目录(多目录同步写)
  4. 视操作类型,通知 DataNode 执行块操作 / 等待副本数满足
  5. 更新内存命名空间
  6. 返回 client 成功

关键点:必须先落盘,再返回 client。这是 WAL 的铁律。如果先更新内存再写日志,进程崩了,client 收到了”成功”但元数据丢了,数据就和元数据对不上——HDFS 最深的恐惧就是 block 在 DataNode 上存在但 NameNode 元数据里没有对应文件,等于数据静默丢失。

dfs.namenode.edits.dir 默认等于 dfs.namenode.name.dir,可以单独配。生产上我习惯让 edits 和 fsimage 落同一批磁盘(顺序写、随机读的混合负载),也有人把 edits 单独放到更快的 SSD 上加速启动回放

EditLog 的并发写通过 FSEditLog 的 double-buffer 实现:一个 buffer 给写线程追加,另一个 buffer 异步刷盘,两者交替使用,避免每次 append 都阻塞等 fsync。但每条 edits 是否真正持久化,仍取决于对应的 fsync 完成——这是 dfs.namenode.edits.dir 多目录的意义,多个 fsync 并行,但任一失败要回滚。

Checkpoint 机制

EditLog 会无限增长。如果不合并,启动时回放几亿条 edits 能把 NameNode 启动时间拖到几小时。所以需要周期性把 FsImage 和 EditLog 合并,生成新的 FsImage,把旧的 edits 清掉——这就是 checkpoint。

在非 HA 模式下由 SecondaryNameNode 完成;HA 模式下由 Standby NameNode 完成。两者流程一致:

1
2
3
4
5
6
7
8
9
SecondaryNameNode / Standby NN:
1. 触发 checkpoint(定时 or txid 阈值)
2. 请求 Active/Primary NN 滚动 edits(rollEditLog)
→ Active 关闭当前 edits_inprogress,新开一个
3. 下载 Active 的最新 FsImage
4. 拉取本次 checkpoint 之后的所有 edits
5. 在本地把 FsImage + edits 加载到内存并回放 → 得到新 FsImage
6. 把新 FsImage 上传回 Active
7. Active 用新 FsImage 替换旧的,并再次 roll edits

触发条件由几个参数控制:

1
2
3
4
5
6
7
8
# 定时触发,默认 3600 秒(1 小时)
dfs.namenode.checkpoint.period=3600

# 事务数触发,默认 100 万
dfs.namenode.checkpoint.tx=1000000

# 保留的历史 FsImage 数量,默认 2
dfs.namenode.num.checkpoints.retained=2

前两个条件是”或”的关系,任意满足就触发。生产上我会把 num.checkpoints.retained 调到 4,留点回滚余地。还有个 dfs.namenode.checkpoint.check.period(默认 60 秒)是 SNN/Standby 检查触发条件的轮询间隔。

SaveNamespace 是 Active/Standby 执行 checkpoint 写出 FsImage 时的内部流程,它会拿全局写锁(FSNamesystem.writeLock()):

  1. 加写锁,阻塞所有新的写操作
  2. 滚动 edits(让后续写入落到新 edits 文件)
  3. 把内存命名空间序列化成 FsImage 文件
  4. 写一个 fsimage_0000000123 + 对应的 MD5 校验文件
  5. 持久化新的 seen_txid(记录已 checkpoint 到哪个 txid)
  6. 释放写锁

这个写锁就是 SaveNamespace 卡顿的根源——文件数越多,序列化越久,写锁持锁越长,期间所有写请求排队。某集群 8 亿文件时,一次 SaveNamespace 能锁 90 秒,期间业务写入全部超时、client 重试风暴、甚至触发上层熔断。监控 SaveNamespace 耗时是 NameNode 运维的必备项。

启动恢复

NameNode 冷启动是元数据链路最脆弱的时刻。完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NN 启动

1. 加载最新 FsImage 到内存(读 fsimage_xxx)
│ ← 慢点 1:文件数决定,几亿文件要几分钟

2. 回放 FsImage 之后所有 EditLog(按 txid 顺序)
│ ← 慢点 2:edits 条数决定,所以要勤做 checkpoint

3. 写一个新的"空" edits_inprogress,记录当前最大 txid

4. 进入 Safemode(只读,不接受写/删/改名)

5. 等待 DataNode 上报块报告(block report)
│ NN 根据块报告计算每个 block 的实际副本数
│ 当"已达到最小副本数的 block 占比" >= threshold

6. 自动退出 Safemode,恢复读写

Safemode 的退出由这几个参数控制:

1
2
3
4
5
6
7
8
# 已达到最小副本数的 block 占比阈值,默认 0.999f
dfs.safemode.threshold.pct=0.999f

# 退出前等待的数据节点数量,默认 0(不强制)
dfs.safemode.min.datanodes=0

# 满足条件后还要等多久才退出(毫秒),默认 30000
dfs.safemode.extension=30000

冷启动为何慢?因为时间 = 加载 FsImage + 回放 edits + 等块报告,三者都和文件/block 数线性相关。某集群 12 亿 block,光等块报告就要二十多分钟——DataNode 上报是分批的,每批还要经过 NameNode 处理和校验。这也是为什么 HDFS 要搞 HA:Standby 常驻内存、持续回放,故障切换只要几秒到十几秒,不用冷启动。

safemode.extension 我习惯设大一点(比如 60 秒),让晚到的 DataNode 有机会补报,避免刚一退出 safemode 就因为缺副本触发大量补齐风暴,把网络和磁盘打满。

多目录冗余

dfs.namenode.name.dir 是 NameNode 最关键的冗余配置:

1
2
# 多目录,逗号分隔,建议分布在不同物理磁盘
dfs.namenode.name.dir=/data1/dfs/name,/data2/dfs/name,/data3/dfs/name

NameNode 对每个目录同步写一份完整的 FsImage 和 EditLog。任何一个目录损坏,NameNode 仍能从其他目录读出完整元数据启动。生产铁律:

  • 至少 2 个目录,跨物理磁盘(不是同盘不同分区,那是骗自己)
  • 有条件跨 RAID 卡或跨 JBOD,但 RAID 不是必需,NameNode 自己做了多副本
  • 不要配太多,4 个足够,每多一个就多一份 fsync 开销,影响写吞吐
  • 目录所在磁盘要监控 SMART 错误和坏道,预防性更换

我踩过一个坑:name.dir 配了 3 个目录,运维挂载磁盘时手滑把 /data2 挂成了 /data1 的拷贝,结果三个”目录”其实是同一个物理盘的两份拷贝——盘坏的时候两份一起没。挂载后务必 ls -li 看 inode、df 看设备号,确认是不同物理设备。

元数据损坏与修复实战

这是这篇最值钱的部分。元数据损坏分两种:EditLog 损坏和 FsImage 损坏,严重程度天差地别。

EditLog 损坏

表现:NameNode 启动时报 Corrupted EditLogtxid 断裂、回放到某条 edits 抛 IOExceptionEOFException

典型原因:机器掉电、磁盘满、内核 panic 导致 edits 写到一半就被截断。EditLog 是 append-only,损坏通常发生在最后一条未完整刷盘的记录。

修复手段按严重程度递进:

  1. 自动 recover:NameNode 启动时如果检测到 edits 不完整,会尝试跳过最后一条坏记录继续回放。多数轻微损坏这样就能过。
  2. hdfs namenode -recover:交互式恢复模式,会提示 “roll edits? (Y/N)” 之类。本质是回滚到最后一个完整 checkpoint,丢弃其后的所有 edits——意味着上次 checkpoint 之后的写操作全部丢失。所以要勤做 checkpoint。
  3. 手动删坏 edits:极端情况下,定位到 edits_inprogress_xxxxx 文件,备份后删除,让 NN 从上一个 FsImage 启动。丢的数据更多,但能起来。

-recover 的内部逻辑是:先尝试最近的 FsImage,再回放 edits;遇到坏 edits 就停在那条之前。所以最近一次 checkpoint 越新,丢的越少。这也是为什么 checkpoint 周期不能拉太长。

FsImage 损坏

表现:启动时加载 FsImage 报 MD5 mismatch 或反序列化失败、InvalidProtocolBufferException

FsImage 损坏比 EditLog 损坏严重得多,因为 FsImage 是全量基线,丢了它等于丢了整个命名空间的起点。修复路径:

  1. 从其他 name.dir 恢复:多目录冗余的意义就在这里,某个目录的 fsimage 坏了,从另一个目录拷过来覆盖。
  2. 从 Standby/Secondary 拷贝:HA 集群里 Standby 时刻有较新的 FsImage,直接拷过来。
  3. 从异地备份恢复:定期把 FsImage 异地备份(见下文)。

最惨的情况:单目录、没 Standby、没备份,FsImage 损坏——基本就是数据全没。只能从 DataNode 的 block 反向重建目录树(用 hdfs debug + 人工拼凑),工程量极大且不保证完整。所以多目录 + 定期备份是底线,不是可选项。

离线分析:oiv 与 oev

hdfs oiv(Offline Image Viewer)和 hdfs oev(Offline Edits Viewer)是排查元数据问题的两把利器,不需要启动 NameNode 就能看 FsImage 和 EditLog 的内容。下面是一组实战命令示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 1. 把 FsImage 转成可读的 XML(小集群适用,大集群 XML 会很大)
hdfs oiv -i /data1/dfs/name/current/fsimage_0000000123 \
-o /tmp/fsimage.xml \
-p XML

# 2. 大集群用 Delimited 格式更省内存,自定义分隔符
hdfs oiv -i /data1/dfs/name/current/fsimage_0000000123 \
-o /tmp/fsimage.txt \
-p Delimited \
-delimiter '|'

# 3. 统计文件大小分布(揪小文件利器)
hdfs oiv -i /data1/dfs/name/current/fsimage_0000000123 \
-o /tmp/filedist.csv \
-p FileDistribution \
-maxSize 1073741824 \
-step 10485760

# 4. 离线看 EditLog 内容,定位某个 txid 范围的操作
hdfs oev -i /data1/dfs/name/current/edits_0000000100_0000000200 \
-o /tmp/edits.xml \
-p XML

# 5. 统计 edits 里每种操作的频次(排查批量灌数据)
hdfs oev -i /data1/dfs/name/current/edits_inprogress_0000000234 \
-o /tmp/edits_stats.xml \
-p XML \
-statistics

我用 oiv 做过的事:统计全集群小文件数量(FileDistribution 看小于 1MB 的 block 占比,一度占到 60%)、审计某目录下文件数(Delimited + grep + wc)、确认 checkpoint 是否成功(对比新旧 fsimage 的 txid 是否推进)。oev 则用来排查”为什么这次启动回放特别慢”——看 edits 里是不是被某个离线任务的批量 rename 灌了几百万条记录。

备份纪律

元数据备份是运维的底线:

  • 定期把最新 FsImage + MD5 异地备份(重要集群每小时一次,普通集群每天一次)
  • 备份到对象存储或异地 NAS,不要和 NameNode 同机房、同机架
  • 备份脚本要校验 MD5,损坏的备份等于没备份
  • 定期做”恢复演练”——拿备份的 FsImage 在测试集群起一遍,确认能加载、能回放

我见过最痛的事故:某集群两年没备份过 FsImage,主磁盘连环故障,最后从 DataNode block 反推目录树,用了三周才恢复 80% 的元数据,剩下 20% 永久丢失,业务层大量依赖路径的 ETL 任务重写。

HA 下的元数据同步

HA 模式下 Active/Standby 通过 QJM(Quorum Journal Manager)同步 edits:

  • Active NN 写 edits 时,同时写本地和一组 JournalNode(通常 3 或 5 个,多数派成功才算提交)
  • Standby NN 持续从 JournalNode 拉取 edits 并回放,保持内存状态紧随 Active
  • 故障切换时,新 Active 先确保把 JournalNode 上所有已提交的 edits 回放完,再对外提供服务

这样冷启动的痛点被化解了——Standby 随时在回放,切换是热的,不需要加载 FsImage。QJM 的多数派写也取代了早期依赖共享存储(NFS/BookKeeper)的 edits 共享方案,避免了单点。细节属于 HA 篇,这里不展开,只强调一点:JournalNode 的可用性直接决定 edits 不丢,JN 至少 3 节点跨机架部署,且独立于 NameNode 所在机器。

性能与运维

元数据链路的常见性能问题:

  1. SaveNamespace 卡顿:文件数多时序列化慢,写锁持留长。缓解:勤做 checkpoint 让单次 fsimage 不要太大;升级到 Hadoop 3.x 用原生 protobuf 序列化;监控 SaveNamespace 耗时并告警,超过阈值要查。
  2. EditLog 膨胀:checkpoint 不及时,edits 文件堆积,启动回放慢。检查 dfs.namenode.checkpoint.tx 是否设得太高;SNN/Standby 是否健康在跑 checkpoint(看 JMX 的 LastCheckpointTime)。
  3. dfs.namenode.max.objects 上限:默认 0(无限制),但文件/目录/block 总数受 JVM 堆内存约束。经验值:64GB 堆大约撑 4 亿文件。接近上限时要横向扩容(Federation)或治理小文件。
  4. 启动超时:冷启动慢的本质是文件/block 多。除了做 checkpoint,还可以调大 dfs.namenode.handler.count 让块报告处理并发更高,缩短 safemode 等待;dfs.namenode.blockreport.maxSize 调大单次块报告体量。

小结

NameNode 元数据持久化就两件东西:FsImage 存全量快照,EditLog 存增量变更,内存状态等于两者之和。checkpoint 把它们周期性合并,防止 edits 无限膨胀;冷启动时先加载 FsImage 再回放 edits,进 safemode 等块报告达到阈值再退出。多目录冗余保命,定期异地备份保底,oiv/oev 是离线排查的瑞士军刀,-recover 是 EditLog 损坏的最后一道防线。HA 用 QJM 让 Standby 热备、持续回放,绕开了冷启动的痛。把这条链路理解透了,NameNode 才不会在你最不想它崩的时候崩。