04 数据持久化——事务日志与快照

摘要

ZooKeeper 的”内存数据库”设计让它拥有极低的读延迟,但内存是易失的——节点宕机后内存数据全部消失。ZooKeeper 通过**事务日志(Transaction Log,WAL)快照(Snapshot)**两套机制保障数据持久性:事务日志以追加写的方式记录每个操作,保证零数据丢失;快照定期将内存数据全量序列化到磁盘,用于加速重启恢复。

本文深入剖析这两套机制的文件格式、写入时机、磁盘空间管理,以及节点重启时如何正确地”重放事务日志 + 应用快照”来恢复完整内存状态。


第 1 章 ZooKeeper 的内存数据模型:DataTree

1.1 DataTree 是什么

ZooKeeper 服务端在内存中维护一棵名为 DataTree 的数据结构,它就是第 1 篇中介绍的”ZNode 树”的内存表示。

DataTree 的核心数据结构(简化版):

public class DataTree {
    // 核心:ZNode 路径 → ZNode 数据的哈希表
    private final ConcurrentHashMap<String, DataNode> nodes;
    
    // 临时节点索引:Session ID → 该 Session 创建的所有临时节点路径
    private final Map<Long, HashSet<String>> ephemerals;
    
    // Watcher 管理:路径 → 注册了该路径 Watcher 的所有连接
    private final WatchManager dataWatches;
    private final WatchManager childWatches;
}

每个 DataNode 存储:

  • byte[] data:节点的数据内容(最大 1MB);
  • StatPersisted stat:节点元数据(czxid、mzxid、version 等);
  • HashSet<String> children:子节点名称集合(注意存的是名称,不是路径)。

整个 DataTree 在 ZooKeeper 运行期间完全在 JVM Heap 中,这是 ZooKeeper 读操作延迟极低的根本原因——读数据就是 HashMap 查找,不涉及任何磁盘 IO。

代价是:所有节点的数据量之和受限于可用 JVM Heap。这也是为什么 ZooKeeper 不适合存储大量数据,每个 ZNode 的数据量限制在 1MB,且总节点数通常建议不超过几十万个。

1.2 持久化的必要性

DataTree 全在内存中,一旦 ZooKeeper 进程崩溃或节点宕机,内存数据全部消失。重启后需要从头恢复。

如果没有持久化,重启时只能从其他 ZooKeeper 节点同步完整数据(通过 ZAB 崩溃恢复的 SNAP 全量同步),这个过程可能很慢(GB 级数据序列化传输)。

持久化到本地磁盘,让节点可以从本地恢复大部分数据,只需从 Leader 同步少量最新事务,大幅缩短重启恢复时间。


第 2 章 事务日志:WAL 的设计与格式

2.1 事务日志的写入时机

每当 ZooKeeper 处理一个写请求(setDatacreatedelete 等),都会将这个操作序列化为一条事务记录(Transaction Record),追加写入事务日志文件。

写入发生在事务提交之前(WAL 的经典模式):Follower 收到 Leader 的 PROPOSAL 后,先将事务写入本地日志(fsync),然后才返回 ACK。这保证了:即使在 ACK 之后、COMMIT 之前发生宕机,节点重启后可以通过重放日志恢复这条事务记录。

2.2 事务日志的文件格式

ZooKeeper 的事务日志文件以 ZXID 命名:log.{zxid_of_first_entry},例如 log.1log.100000001

文件头(File Header)

magic number: 0x5a4b4c47 (ASCII: "ZKLG")
version: 2
dbid: 数据库 ID(通常为 0)

事务记录(每条记录)

checksum: 校验和(Adler32),用于检测数据损坏
record_length: 记录长度(字节数)
client_id: 客户端 Session ID
cxid: 客户端本地事务序号(同一 Session 内的请求序号)
zxid: 全局事务 ID
time: 事务发生的时间戳(毫秒)
type: 事务类型(create/delete/setData/createSession/closeSession...)
payload: 事务内容(不同 type 有不同的序列化格式)

2.3 预分配磁盘空间

ZooKeeper 的事务日志文件使用**预分配(Pre-allocation)**策略,默认每次预分配 64MB 的磁盘空间。当文件的已用空间超过预分配大小时,再追加分配 64MB。

预分配的目的:避免频繁的文件系统元数据更新(每次 write 扩展文件都需要更新 inode 中的文件大小),改为一次性分配大块空间,后续写入只更新文件内容,减少磁盘寻道和元数据操作开销。

副作用:未写满的日志文件末尾会有大量填充零(padding),文件大小通常是 64MB 的整数倍,而非实际事务数据的大小。

配置项snapCount(默认 100,000)——当事务日志记录数达到 snapCount / 2 + random(snapCount / 2) 时(引入随机性是为了防止集群内所有节点同时触发快照),触发新的快照,并创建新的日志文件。

2.4 fsync 策略与写入性能

ZooKeeper 在每个事务写入后默认执行 fsyncforceSync=yes),确保数据落盘。

每次 fsync 的延迟取决于存储设备:

  • HDD(机械硬盘):5~15ms/次(受磁头寻道和旋转延迟影响);
  • SSD(固态硬盘):0.1~1ms/次;
  • NVMe SSD:< 0.1ms/次。

由于 ZAB 协议要求 Follower 在 fsync 后才返回 ACK,ZooKeeper 的写入延迟直接受限于事务日志的 fsync 延迟。在使用 HDD 的集群中,写入延迟通常在 10~20ms,而使用 NVMe SSD 可以降低到 1ms 以内。

生产避坑

将 ZooKeeper 的事务日志目录(dataLogDir)单独挂载到专用 SSD 磁盘,与快照目录(dataDir)、OS 系统盘分离。这是 ZooKeeper 性能调优中收益最高的单项操作。原因:事务日志是顺序写 + 高频 fsync,对磁盘延迟极度敏感;快照是低频的大块写,对延迟不那么敏感。混用磁盘时,快照写入会干扰事务日志的 fsync 延迟,造成写入抖动。


第 3 章 快照:内存状态的全量序列化

3.1 快照的作用

如果只有事务日志,重启时需要从第一条日志开始逐条重放,才能恢复当前状态。当集群运行了很长时间(数百万条事务),重放所有日志可能需要数分钟,这是不可接受的。

**快照(Snapshot)**解决了这个问题:周期性地将当前内存 DataTree 的完整状态序列化到磁盘。重启时:

  1. 加载最新快照(恢复截至快照时刻的全量数据);
  2. 只重放快照之后的事务日志(通常只有几千条);
  3. DataTree 恢复完成,ZooKeeper 正常启动。

3.2 快照的触发时机

快照在后台异步线程中执行,不会阻塞正常的事务处理。触发条件:

当事务日志的记录数达到 snapCount / 2 + random(snapCount / 2) 时(snapCount 默认 100,000),Leader 或 Follower 各自独立地触发快照(每个节点独立决策,不需要 Leader 协调)。

引入随机偏移(random(snapCount / 2))是为了错开集群内各节点的快照时机,避免所有节点同时进行快照写入,导致磁盘 IO 峰值。

3.3 模糊快照:与事务并发的快照

ZooKeeper 的快照是在正常运行期间拍摄的,快照进行期间新的事务仍在不断提交。这意味着快照可能不是某个精确时刻的一致性镜像——快照可能包含”已执行部分事务的状态”。

这被称为模糊快照(Fuzzy Snapshot)

乍看之下,模糊快照似乎有问题——如果快照反映的是中间状态,重放日志时会不会产生错误?

ZooKeeper 的聪明之处在于:每个事务都是幂等的(Idempotent)。对于 setData(/config, v2, version=3) 这样的操作,无论执行多少次,结果都是一样的(设置为 v2)。因此,即使快照中已经包含了某些事务的效果,重放日志时再次执行这些事务(已经包含在快照中的事务)也不会产生错误——多次执行同一 setData 的结果与执行一次完全相同。

这是一个精妙的设计:通过保证事务幂等性,消除了对精确一致性快照的需求,使得快照可以在不暂停事务处理的情况下异步拍摄,大幅降低了快照对服务的影响。

3.4 快照的文件格式

快照文件命名为 snapshot.{last_committed_zxid},记录快照时最后一条已提交事务的 ZXID。

文件内容

magic number: 0x5a4b4c4e (ASCII: "ZKLN")
version: 2
dbid: 数据库 ID

Session 数据:
  session_count: Session 总数
  [session_id, timeout] × session_count  ← 所有活跃 Session 及其超时配置

DataTree 数据:
  [path, data_bytes, stat, acl_id] × node_count  ← 每个 ZNode 的完整信息
  
结束标记:
  "/" (根节点路径)作为 DataTree 序列化结束标记

Checksum: Adler32 校验和(验证文件完整性)

快照文件通常很大(MB 到 GB 级别),取决于 ZNode 总数和数据量。快照写入使用 Java 的 OutputArchive(ZooKeeper 自定义的序列化框架 Jute)进行序列化。

3.5 快照压缩

ZooKeeper 3.6+ 支持快照压缩(snapshot.compression.method = gz),使用 gzip 压缩快照文件,可以将文件大小减少 50%~80%(ZNode 数据通常是文本或 JSON,压缩率很高),但压缩/解压需要额外的 CPU 时间。

对于磁盘空间紧张而 CPU 充足的场景,开启快照压缩是一个有效的权衡。


第 4 章 重启恢复流程

4.1 完整的重启恢复过程

ZooKeeper 节点重启时,FileTxnSnapLog 类负责协调恢复流程:

第 1 步:扫描快照目录,找到所有快照文件,按 ZXID 降序排列
         [snapshot.10000, snapshot.8000, snapshot.6000, ...]

第 2 步:加载最新快照(snapshot.10000)
         - 读取文件,反序列化 DataTree(恢复所有 ZNode)和 Session 数据
         - 验证 Checksum,如果损坏则尝试次新快照(snapshot.8000)

第 3 步:扫描事务日志目录,找到 ZXID >= snapshot.zxid 的所有日志文件
         例如:snapshot.zxid = 10000,找到 log.9001, log.10001

第 4 步:重放事务日志
         - 从 log.9001 开始,跳过 ZXID < 10000 的记录
         - 对 ZXID >= 10000 的记录,逐条重放事务(调用 processTransaction)
         - 继续处理 log.10001 中的记录,直到日志末尾

第 5 步:恢复完成,DataTree 状态 = 快照状态 + 后续事务的应用
         通过 ZAB 与 Leader 同步,补充本地恢复后仍缺失的最新事务

为什么从 log.9001 而不是 log.10001 开始重放?

因为是模糊快照——snapshot.10000 可能包含了 ZXID 9001~10000 这段时间内部分事务的效果(不是全部)。从 ZXID 9001 开始重放,确保这个区间内所有事务都被应用,由于事务幂等性,重复应用已在快照中的事务不会产生错误。

4.2 快照损坏时的降级恢复

ZooKeeper 的恢复代码有容错机制:如果最新快照文件的 Checksum 校验失败(文件损坏),会自动尝试次新快照,直到找到一个完整的快照。

极端情况:所有快照都损坏了怎么办?

这时只能从最早的事务日志开始重放(从头恢复),时间可能很长。更好的预防措施是:

  1. 定期验证快照文件完整性(可以用 ZooKeeper 提供的 SnapshotFormatter 工具);
  2. 将快照文件备份到外部存储(如 S3),防止本地磁盘故障导致所有快照丢失。

第 5 章 磁盘空间管理

5.1 日志与快照文件的积累

ZooKeeper 默认不自动清理旧的事务日志和快照文件。如果不配置清理策略,这些文件会持续积累,最终耗尽磁盘空间——这是 ZooKeeper 运维中最常见的故障原因之一。

典型场景:集群运行 3 个月后,dataLogDir 目录积累了数百个 64MB 的日志文件和数十个快照文件,总占用数十 GB,触发磁盘满,ZooKeeper 无法写入日志,停止服务。

5.2 自动清理配置

ZooKeeper 3.4+ 提供了内置的自动清理机制:

zoo.cfg 配置:

# 保留最近 N 个快照(及对应的事务日志)
autopurge.snapRetainCount=5
 
# 自动清理的触发间隔(小时)
autopurge.purgeInterval=24

autopurge.snapRetainCount=5 + autopurge.purgeInterval=24 的含义:每 24 小时清理一次,保留最近 5 个快照文件及其对应的事务日志(比最旧保留快照还要旧的日志也会被删除)。

推荐配置:保留 3~5 个快照(应对快照损坏的降级需求),每天清理一次。不要将 snapRetainCount 设置为 1——如果当天唯一的快照损坏,就无法正常恢复了。

5.3 手动清理

如果需要立即释放磁盘空间(紧急情况),可以使用 ZooKeeper 提供的 PurgeTxnLog 工具:

java -cp zookeeper-*.jar:lib/* org.apache.zookeeper.server.PurgeTxnLog \
  <dataDir> <dataLogDir> -n 3

-n 3 表示保留最近 3 个快照。该工具会找到第 3 个最新快照对应的 ZXID,删除所有比这个 ZXID 更早的日志文件和快照文件。

生产避坑

永远不要手动删除最新的快照或日志文件。这会导致重启恢复时找不到有效的数据,节点无法启动(或丢失数据)。应始终通过 PurgeTxnLog 工具或 autopurge 机制来清理,确保保留足够的历史文件用于恢复。


第 6 章 事务日志与快照的监控

6.1 关键监控指标

磁盘空间使用率(最重要):

# 监控 dataDir 和 dataLogDir 的磁盘使用率
df -h /data/zookeeper/data
df -h /data/zookeeper/logs

建议在磁盘使用率超过 70% 时告警,给自动清理(或手动清理)留足空间。

快照文件数量和日志文件数量

ls /data/zookeeper/data/version-2/snapshot.* | wc -l
ls /data/zookeeper/logs/version-2/log.* | wc -l

文件数量异常增多(如 autopurge 失效),会在磁盘满之前给出预警。

ZooKeeper 4 字命令(四字命令,Four Letter Words):

ZooKeeper 提供了一批简单的调试命令,通过 TCP 发送 4 个字符即可获取响应:

# 检查节点状态(是 Leader/Follower/Observer)
echo stat | nc localhost 2181
 
# 检查连接数、请求处理统计
echo mntr | nc localhost 2181
 
# 检查 Watcher 统计
echo wchs | nc localhost 2181
 
# 确认节点正常响应(最简单的健康检查)
echo ruok | nc localhost 2181
# 正常响应: imok

mntr 命令输出的关键字段:

zk_avg_latency          0          ← 平均请求延迟(ms)
zk_max_latency          10         ← 最大请求延迟(ms)
zk_outstanding_requests 0          ← 待处理请求队列长度
zk_znode_count          1234       ← ZNode 总数
zk_watch_count          567        ← 注册的 Watcher 总数
zk_ephemerals_count     89         ← 临时节点总数
zk_approximate_data_size 1048576   ← 所有 ZNode 数据的近似总大小(字节)
zk_open_file_descriptor_count 300  ← 打开的文件描述符数量

设计哲学

zk_outstanding_requests 是 ZooKeeper 负载的重要指标。正常运行时,这个值应该为 0 或极小(< 10)。如果这个值持续增大,说明 ZooKeeper 处理请求的速度跟不上请求到来的速度,是过载的早期信号,需要立即排查(通常是磁盘 IO 瓶颈导致的事务日志写入慢,或 Leader 节点 GC 导致的处理暂停)。


小结

本文深入解析了 ZooKeeper 的数据持久化机制:

  • DataTree 是 ZooKeeper 的内存数据结构,所有读操作直接在 Heap 中完成,速度极快但受限于内存容量;
  • **事务日志(WAL)**在每次事务写入时追加记录,预分配 64MB 磁盘块提高写入效率,高频 fsync 保证持久性——事务日志的磁盘性能直接决定 ZooKeeper 的写延迟;
  • 快照周期性地全量序列化 DataTree,通过模糊快照 + 事务幂等性的设计,实现了不暂停服务的异步快照拍摄;
  • 重启恢复:加载最新快照 + 重放后续日志,通常在秒级完成;
  • 磁盘空间管理是日常运维必须关注的点,autopurge 配置是防止磁盘耗尽的标配。

下一篇文章将从运维视角,梳理 ZooKeeper 的部署架构、监控告警体系与常见故障处理。


思考题

  1. ZooKeeper 实现分布式锁:创建临时顺序节点(/lock/seq-0001)→ 获取所有子节点并排序 → 如果自己是最小的则获取锁 → 否则 Watch 前一个节点。这种’有序节点 + Watch 前一个’的设计避免了惊群效应(只唤醒下一个等待者)。与 Redis 的 SETNX 锁相比,ZooKeeper 锁在什么方面更可靠(如自动释放、有序公平)?
  2. ZooKeeper 的配置管理:将配置存储在 ZNode 中,应用通过 Watch 监听变化并动态更新。但 ZNode 的最大数据量为 1MB——对于大型配置文件(如几百 KB 的 YAML)是否足够?与专业的配置中心(如 Nacos、Apollo)相比,ZooKeeper 的配置管理能力有什么局限?
  3. Curator 的 LeaderLatch 和 LeaderSelector 实现了 Leader 选举——多个候选者竞争一个’Leader’角色。LeaderLatch 在获取领导权后持续持有直到主动放弃或 Session 断开;LeaderSelector 在’任务完成’后自动释放领导权并重新竞选。在什么场景下你会选择 LeaderLatch(如长期运行的 Master 进程)还是 LeaderSelector(如周期性的批处理任务)?