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 处理一个写请求(setData、create、delete 等),都会将这个操作序列化为一条事务记录(Transaction Record),追加写入事务日志文件。
写入发生在事务提交之前(WAL 的经典模式):Follower 收到 Leader 的 PROPOSAL 后,先将事务写入本地日志(fsync),然后才返回 ACK。这保证了:即使在 ACK 之后、COMMIT 之前发生宕机,节点重启后可以通过重放日志恢复这条事务记录。
2.2 事务日志的文件格式
ZooKeeper 的事务日志文件以 ZXID 命名:log.{zxid_of_first_entry},例如 log.1、log.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 在每个事务写入后默认执行 fsync(forceSync=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 的完整状态序列化到磁盘。重启时:
- 加载最新快照(恢复截至快照时刻的全量数据);
- 只重放快照之后的事务日志(通常只有几千条);
- 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 校验失败(文件损坏),会自动尝试次新快照,直到找到一个完整的快照。
极端情况:所有快照都损坏了怎么办?
这时只能从最早的事务日志开始重放(从头恢复),时间可能很长。更好的预防措施是:
- 定期验证快照文件完整性(可以用 ZooKeeper 提供的
SnapshotFormatter工具); - 将快照文件备份到外部存储(如 S3),防止本地磁盘故障导致所有快照丢失。
第 5 章 磁盘空间管理
5.1 日志与快照文件的积累
ZooKeeper 默认不自动清理旧的事务日志和快照文件。如果不配置清理策略,这些文件会持续积累,最终耗尽磁盘空间——这是 ZooKeeper 运维中最常见的故障原因之一。
典型场景:集群运行 3 个月后,dataLogDir 目录积累了数百个 64MB 的日志文件和数十个快照文件,总占用数十 GB,触发磁盘满,ZooKeeper 无法写入日志,停止服务。
5.2 自动清理配置
ZooKeeper 3.4+ 提供了内置的自动清理机制:
zoo.cfg 配置:
# 保留最近 N 个快照(及对应的事务日志)
autopurge.snapRetainCount=5
# 自动清理的触发间隔(小时)
autopurge.purgeInterval=24autopurge.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
# 正常响应: imokmntr 命令输出的关键字段:
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 的部署架构、监控告警体系与常见故障处理。
思考题
- ZooKeeper 实现分布式锁:创建临时顺序节点(
/lock/seq-0001)→ 获取所有子节点并排序 → 如果自己是最小的则获取锁 → 否则 Watch 前一个节点。这种’有序节点 + Watch 前一个’的设计避免了惊群效应(只唤醒下一个等待者)。与 Redis 的 SETNX 锁相比,ZooKeeper 锁在什么方面更可靠(如自动释放、有序公平)?- ZooKeeper 的配置管理:将配置存储在 ZNode 中,应用通过 Watch 监听变化并动态更新。但 ZNode 的最大数据量为 1MB——对于大型配置文件(如几百 KB 的 YAML)是否足够?与专业的配置中心(如 Nacos、Apollo)相比,ZooKeeper 的配置管理能力有什么局限?
- Curator 的 LeaderLatch 和 LeaderSelector 实现了 Leader 选举——多个候选者竞争一个’Leader’角色。LeaderLatch 在获取领导权后持续持有直到主动放弃或 Session 断开;LeaderSelector 在’任务完成’后自动释放领导权并重新竞选。在什么场景下你会选择 LeaderLatch(如长期运行的 Master 进程)还是 LeaderSelector(如周期性的批处理任务)?