ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Zookeeper FileTxnLog 事务日志持久化机制与调优实战

Zookeeper FileTxnLog 事务日志持久化机制与调优实战

1. 从一次磁盘写满的线上告警说起

Zookeeper 集群半夜告警,磁盘使用率飙到 98%,节点频繁进入只读模式。排查下来不是数据量暴涨,而是事务日志目录里堆了几万个log.xxxxx文件,单个文件不大,但数量惊人。当时第一反应是"日志没清理干净",可翻配置发现autopurge是开着的。真正的问题出在对FileTxnLog这个类的理解上——它负责本地事务日志的持久化,但它的文件滚动、预分配、刷盘策略,和很多人想当然的"写一条日志追加一行"完全不是一回事。

这篇就围绕FileTxnLog这个 Zookeeper 事务日志持久化的核心类,把它的文件结构、写入流程、预分配机制、刷盘时机、滚动策略、以及和FileTxnSnapLog、SyncRequestProcessor的协作关系拆开讲。适合已经用过 Zookeeper、想深入源码层理解持久化机制的同学,也适合正在做分布式一致性组件、需要参考 WAL(Write-Ahead Log)设计的开发者。读完你应该能回答几个问题:为什么事务日志要预分配、txnlog文件里到底存了什么、forceSync什么时候触发、zxid是怎么编码进文件名的、以及为什么删日志不能简单rm。

先把结论摆前面:FileTxnLog的本质是一个带校验、带预分配、按 zxid 滚动、可批量刷盘的追加型日志文件管理器。它不负责日志内容怎么序列化(那是SerializeUtils和TxnHeader的事),也不负责什么时候该写(那是SyncRequestProcessor的事),它只专注一件事——把一条条已经序列化好的Txn记录,安全、有序、高效地落到磁盘上,并且保证重启后能按顺序读回来。

2. FileTxnLog 在 Zookeeper 持久化链路里的位置

2.1 一次写请求的完整落盘路径

要理解FileTxnLog,得先看它在整条链路里站在哪。客户端发起一次写请求(比如create),请求先到 Leader,Leader 把请求包装成Proposal,通过 ZAB 协议广播给 Follower。在真正提交之前,Leader 和 Follower 都会把这条事务写到本地事务日志——注意,是先写日志再改内存,这就是典型的 WAL 思想。

具体到代码路径,请求进入SyncRequestProcessor的队列,这个 Processor 单独起一个线程,从队列里批量取请求,调用ZooKeeperServer.getTxnLogFactory().append(),最终落到FileTxnLog.append()。写完一批之后调用commit(),commit()里根据配置决定是否forceSync。只有日志落盘成功,请求才会继续往FinalRequestProcessor走,去修改DataTree内存结构。

所以FileTxnLog是持久化的最后一道关口,它写失败,整个写请求就失败。这也是为什么它的异常处理特别保守——一旦append或commit抛异常,Zookeeper 会直接把自己关掉(System.exit),因为此时内存状态和磁盘状态可能已经不一致,继续跑下去会出大问题。

2.2 它和 FileTxnSnapLog、Snapshot 的分工

很多人会把事务日志和快照搞混。简单说:

  • 事务日志(TxnLog):记录每一条写操作,追加写,文件小但数量多,用于崩溃恢复时重放。
  • 快照(Snapshot):某一时刻全量内存数据的序列化,文件大但数量少,用于加速恢复。

FileTxnSnapLog是个门面类,同时持有TxnLog和SnapShot两个接口的实现,对外提供save、restore、getLastLoggedZxid等方法。FileTxnLog是TxnLog接口的默认实现。恢复时,先加载最新快照,再用快照的 zxid 之后的事务日志做增量重放,两者配合才能把状态恢复到最新。

这里有个容易忽略的点:事务日志和快照的目录是分开配置的,dataLogDir和dataDir。生产环境强烈建议把dataLogDir放到独立磁盘,因为事务日志是顺序追加写、对延迟极敏感,和快照的随机读写混在一起会互相拖累。我见过不少集群图省事只配dataDir,结果高峰期写延迟抖动明显。

3. 事务日志文件的物理结构:一个文件里到底装了什么

3.1 文件命名与 zxid 的编码关系

事务日志文件名格式是log.<16进制zxid>,比如log.0000000001。这个 zxid 是该文件第一条事务记录的 zxid,不是文件名随便起的。为什么要用 16 进制?因为 zxid 是 64 位 long,用 16 进制表示长度固定、排序方便,FileTxnLog.getLogFiles()直接按文件名排序就能得到时间顺序,不需要读文件内容。

zxid 本身的结构也值得说:高 32 位是 epoch(Leader 任期),低 32 位是计数器。所以同一个 Leader 任期内,zxid 单调递增;换 Leader 后 epoch 变大,zxid 整体跳变。这个设计保证了全局唯一且有序,事务日志按 zxid 排序就等于按时间排序。

3.2 文件头:魔数与版本

每个log.xxx文件开头有 16 字节的文件头,结构是:

字段长度说明
magic4 字节固定值ZKLG,用于识别这是事务日志文件
version4 字节版本号,当前为 2
dbid8 字节数据库 id,通常为 0

读日志时先校验 magic,不匹配直接报错。这个设计是为了防止把快照文件或其他文件误当成事务日志读。version 字段则为将来格式升级留了口子——不同版本可以有不同的解析逻辑。

3.3 单条事务记录的组成

文件头之后,就是一条条事务记录。每条记录的结构是:

[checksum: 8字节][len: 4字节][txn bytes: len字节]
  • checksum:对len + txn bytes计算的校验和,用的是 Adler32 算法。读的时候会重新计算比对,防止磁盘位翻转导致读到脏数据。
  • len:后面 txn 字节数组的长度。
  • txn bytes:序列化后的事务内容,包含TxnHeader(记录 zxid、时间、类型等)和Record(具体操作数据)。

这里有个细节:checksum 是在写入时算好一起写进去的,不是单独一个文件。所以每条记录自带校验,读取时可以逐条验证。如果某条记录校验失败,说明文件损坏,Zookeeper 会从这条记录开始截断——这也是为什么事务日志损坏通常只影响最后几条,而不是整个文件报废。

注意:checksum 用的是 Adler32 而不是 CRC32,Adler32 计算更快但碰撞概率略高。对于日志场景,速度优先,且配合 len 字段基本够用。

4. 预分配机制:为什么文件一创建就是 64MB

4.1 预分配解决的是什么问题

FileTxnLog创建新文件时,不是从 0 字节开始慢慢写,而是一次性把文件扩展到预分配大小(默认 64MB,由preAllocSize控制)。这个操作在createTxnLog里通过RandomAccessFile.setLength()完成。

为什么要这么做?核心是减少文件系统元数据更新和磁盘碎片。如果文件从 0 开始追加,每写一段就要更新文件大小、分配新块,元数据操作频繁。预分配之后,文件大小固定,写入只是在已分配的块上覆盖,元数据几乎不动。对于顺序追加写的 WAL 场景,这个优化对吞吐提升很明显。

另一个原因是避免写入过程中磁盘满。预分配时如果磁盘空间不够,会立刻失败,而不是写到一半才发现没空间。这种"提前失败"对分布式系统很重要,能让节点尽早进入只读或下线,而不是产生半截日志。

4.2 预分配的边界与滚动触发

预分配不是无脑扩到 64MB 就完事。FileTxnLog内部维护一个currentSize,每次append后累加。当currentSize + 本次写入长度 > preAllocSize时,触发滚动——关闭当前文件,创建新文件,新文件名用下一条事务的 zxid。

这里有个容易踩的坑:预分配大小和实际文件大小是两回事。ls -l看到文件是 64MB,但实际有效数据可能只有几 KB。判断日志是否写满不能看文件大小,要看currentSize这个逻辑值。我见过有人写监控脚本按文件大小告警,结果全是误报。

预分配大小也不是越大越好。设太大,磁盘空间浪费严重,尤其日志滚动频繁时;设太小,滚动太频繁,每次滚动都要创建文件、写文件头、刷盘,开销上升。默认 64MB 是个经验值,如果单条事务很大(比如大批量create),可以适当调大;如果事务都很小且写入频繁,可以调小到 16MB 或 32MB。

4.3 预分配与磁盘空间的真实关系

假设preAllocSize=64MB,集群每秒产生 1000 条事务,每条平均 200 字节,那么每秒写入约 200KB,写满一个文件需要约 320 秒。如果保留 3 天的日志,大约需要3*24*3600/320 * 64MB ≈ 51GB。这个估算方法比拍脑袋靠谱。

但要注意,预分配的空间在文件删除前一直占着。所以清理策略必须跟上,否则就是开头那个磁盘写满的场景。autopurge.purgeInterval和autopurge.snapRetainCount要配合设置,前者是清理间隔(小时),后者是保留的文件数。生产环境建议purgeInterval=1,snapRetainCount=3到5。

5. append 与 commit:写入流程的两次关键动作

5.1 append 做了什么,没做什么

append(TxnHeader hdr, Record txn)的核心逻辑:

  1. 检查当前文件是否为空,为空则先写文件头。
  2. 用SerializeUtils.serialize把hdr和txn序列化成字节数组。
  3. 计算 checksum,组装成[checksum][len][data]格式。
  4. 写入BufferedOutputStream。
  5. 更新currentSize,判断是否需要滚动。

注意,append 只是写进了缓冲区,没有落盘。BufferedOutputStream默认 64KB 缓冲,数据先到内存,攒够或 flush 时才真正 write 到文件。所以 append 返回成功不代表数据安全,这点非常关键。

5.2 commit 与 forceSync 的刷盘策略

commit()才是真正决定数据是否落盘的地方。它调用stream.flush()把缓冲区刷到文件,然后根据forceSync参数决定是否调用fileChannel.force(false)。

forceSync从哪来?来自SyncRequestProcessor的构造参数,最终由配置zookeeper.forceSync控制,默认是yes。如果设为no,commit 只 flush 不 force,数据到了操作系统页缓存就返回,性能高但掉电会丢数据。

这里要区分两个层次:

  • flush:把 Java 缓冲区刷到 OS 页缓存。
  • force(fsync):把 OS 页缓存刷到磁盘物理介质。

只有 force 之后,掉电才不丢。forceSync=yes时每次 commit 都 fsync,延迟高但安全;forceSync=no时依赖 OS 定期回写,吞吐高但有丢失窗口。生产环境除非能接受数据丢失,否则别关。

实操心得:如果写入压力大且对延迟敏感,可以考虑批量提交——SyncRequestProcessor本身就是批量取请求的,一次 commit 覆盖多条 append,fsync 次数大幅减少。这也是为什么它要单独起线程攒批,而不是来一条写一条。

5.3 滚动时的收尾动作

文件滚动时,FileTxnLog会做几件事:关闭当前BufferedOutputStream和FileChannel,把当前文件加入txnLogStreams列表(用于后续读取),然后用新 zxid 创建下一个文件。滚动过程中如果抛异常,会尝试关闭资源并抛出,由上层决定是否终止进程。

滚动还有个细节:新文件的 zxid 用的是触发滚动的那条事务的 zxid,而不是当前文件最后一条的 zxid+1。这样保证新文件名精确对应它包含的第一条记录,读取时不会错位。

6. 读取与恢复:getLogFiles 和 read 的配合

6.1 如何找到需要重放的日志

恢复时,FileTxnLog.read(zxid)的逻辑是:先getLogFiles()列出所有log.*文件并按文件名排序,找到最后一个文件名 zxid 小于等于目标 zxid 的文件作为起点,然后从这个文件开始逐条读,跳过 zxid 小于目标值的记录,返回第一条大于等于目标的记录。

getLogFiles的实现有个优化:它用FilenameFilter过滤出log.开头的文件,解析出 zxid 存进TreeMap,天然有序。如果目录里文件很多(几万个),这个操作会遍历整个目录,有一定开销。所以日志清理要及时,别让目录膨胀。

6.2 读取时的校验与截断

读每条记录时,先读 8 字节 checksum,再读 4 字节 len,然后读 len 字节数据,重新计算 checksum 比对。不匹配就抛IOException,上层FileTxnSnapLog.restore会捕获并停止重放——从损坏点之后的数据全部丢弃。

这个行为要理解清楚:Zookeeper 认为损坏点之后的数据不可信,宁可少恢复也不恢复错。所以如果事务日志损坏,可能丢失最后若干条已提交的事务。这也是为什么生产环境要用 RAID 或副本盘,降低单盘损坏概率。

另外,读到文件末尾时,如果剩余字节不足以构成一条完整记录(比如预分配的空间还没写满),会正常结束,不算损坏。判断依据是len读出来是否合理——如果 len 是负数或超大,说明读到的是预分配的空白区,停止即可。

6.3 恢复顺序:先快照后日志

完整恢复流程是:FileTxnSnapLog.restore先找最新快照加载,得到快照的 zxid,然后调用FileTxnLog.read(snapshotZxid+1)开始重放日志,逐条 apply 到DataTree,直到日志读完。重放过程中会更新lastProcessedZxid,最终得到集群的最新状态。

这里有个性能点:快照越新,需要重放的日志越少,恢复越快。所以snapCount配置(默认 10 万)决定多久做一次快照。设太小,快照频繁影响性能;设太大,恢复慢。一般保持默认或根据写入量调整。

7. 那些文档不会告诉你的实操坑

7.1 删日志不能直接 rm

有人图省事直接rm log.*,这是大忌。原因:FileTxnLog内部维护了文件句柄和currentSize状态,直接删文件会导致句柄失效、状态错乱,轻则写入异常,重则数据不一致。正确做法是用autopurge自动清理,或者停掉节点后用zkCleanup工具清理。清理时也要保留最近的日志,否则重启无法恢复。

7.2 preAllocSize 设太大导致磁盘告警

前面提过,预分配的空间立即占用。如果preAllocSize设成 256MB 而写入量很小,一个文件可能几天才写满,但 256MB 一直占着。多个这样的文件累积,磁盘很快满。建议根据实际写入速率估算,一般 64MB 够用,写入量特别小的场景可以降到 16MB。

7.3 forceSync=no 的隐性风险

有些团队为了压测性能把forceSync关了,测完忘了改回来,上线后遇到机房断电,丢了一批已确认的写请求。Zookeeper 的写请求是多数派确认后才返回客户端成功的,但如果日志没 fsync,掉电后这些"已确认"的请求可能丢失,导致客户端看到的数据和实际不一致。这个坑很隐蔽,因为平时不出问题,一出就是数据事故。

7.4 日志目录和快照目录混用的性能问题

dataDir同时放快照和日志时,快照的大文件写入会和日志的追加写抢 IO。快照是随机写大文件,日志是顺序追加,两者 IO 模式冲突。分开到不同磁盘后,写延迟通常能降一个档次。如果只能用一块盘,至少保证日志目录所在分区有足够空间和 IO 余量。

7.5 文件句柄泄漏

FileTxnLog在滚动时会关闭旧文件的流,但如果异常路径没走到关闭逻辑,可能泄漏句柄。长时间运行、频繁滚动的集群要监控进程的打开文件数。ulimit -n要设够,否则会出现 "Too many open files" 导致写入失败。

8. 从 FileTxnLog 看 WAL 设计的通用套路

把FileTxnLog抽象出来看,它其实是一套通用 WAL 设计的教科书案例,几个关键决策值得借鉴:

第一,追加写 + 预分配。顺序写是磁盘最擅长的模式,预分配把元数据开销摊薄。任何需要高吞吐持久化的场景都可以参考。

第二,按逻辑 id 滚动文件。用 zxid 而不是时间戳命名,保证排序即时间序,且和业务逻辑对齐。滚动阈值用逻辑大小而非物理大小,避免预分配干扰。

第三,每条记录自带校验。checksum 让损坏可检测、可截断,而不是整个文件报废。这是容错的基本功。

第四,flush 和 fsync 分离。给上层选择权,在性能和安全之间权衡。默认安全,允许显式关闭。

第五,批量提交。单条 fsync 太贵,攒批后一次 fsync 覆盖多条,是吞吐和延迟的平衡点。

如果你在自研分布式组件,需要设计 WAL,这五点基本可以直接抄。区别只在于序列化格式、滚动策略和清理策略的细节。

9. 调优参数速查与验证方法

最后给一份FileTxnLog相关的参数速查,以及怎么验证调优效果:

参数默认值作用调优建议
preAllocSize64MB日志文件预分配大小写入量大调大,量小调小
snapCount100000多少事务后做快照写入频繁可适当调大
forceSyncyescommit 时是否 fsync除非能接受丢数据,否则保持 yes
autopurge.purgeInterval0(关闭)清理间隔小时生产设 1
autopurge.snapRetainCount3保留文件数设 3 到 5
dataLogDir同 dataDir日志目录独立磁盘

验证方法:用zkServer.sh起单机,写一批数据,观察dataLogDir下文件生成和滚动;用jstack看SyncRequestProcessor线程状态;用iostat看日志盘的写延迟和 IOPS。调forceSync前后对比写入吞吐,能直观看到 fsync 的代价。

我个人在实际操作中的体会是,FileTxnLog这类组件的调优,先搞清楚瓶颈在 fsync 还是磁盘带宽,再动参数。盲目调preAllocSize或关forceSync,往往解决不了根本问题,反而引入新风险。先把监控做起来,看清楚每次 commit 的耗时分布,再决定往哪个方向优化,比拍脑袋靠谱得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进