ZooKeeper 事务日志损坏与恢复
本文分析一个真实的 ZooKeeper 事务日志损坏案例,日志来源是 K8s 环境中 Kafka 集群的 ZK 节点(zookeeper-kafka-1)启动时产生。
背景
| 项 | 值 |
|---|---|
| ZooKeeper 版本 | 3.6.3 |
| 运行环境 | Kubernetes StatefulSet,namespace kafka |
| 节点角色 | myid=2,FOLLOWER |
| 集群规模 | 3 节点 ZK 集群 |
| 部署方式 | Headless Service + StatefulSet |
故障现象
ZK 节点启动时出现大量 ERROR 日志:
2026-06-02 10:41:57,412 [myid:2] - ERROR [main:Util@166] - Last transaction was partial.
2026-06-02 10:41:58,712 [myid:2] - ERROR [main:Util@166] - Last transaction was partial.
2026-06-02 10:42:00,510 [myid:2] - ERROR [main:FileTxnSnapLog@336] - 133144055776(highestZxid) > 133144055743(next log) for type -10
2026-06-02 10:42:00,511 [myid:2] - ERROR [main:FileTxnSnapLog@336] - 133144055776(highestZxid) > 133144055744(next log) for type -11
...- "Last transaction was partial":最后一条事务日志不完整(写入到一半时进程终止)
- "highestZxid > next log":快照期望的事务 ID 大于实际日志文件中的下一条事务 ID,说明快照和事务日志之间存在断层
- type -10 / -11:这些是 Apache BookKeeper 的操作类型,表明上层使用了 BookKeeper 做分布式日志存储
根因分析
事务日志写入机制
ZooKeeper 每次状态变更都采用先写事务日志(Write-Ahead Log)再更新内存的顺序:
Client Request
│
▼
Leader 接收写请求 → 生成 Proposal → 广播给所有 Follower
│
├── Follower 收到 Proposal → 写入 txn log → 返回 ACK
│
└── 超半数 ACK → Leader commit → 更新内存 DataTree
│
└── 定期 snapshot(快照序列化 DataTree 到磁盘)损坏原因
正常流程:
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ 写入 txn log │ ──► │ fsync 到磁盘 │ ──► │ 更新 zxid │
└─────────────┘ └──────────────┘ └──────────────┘
异常终止:
┌─────────────┐ ┌──────────────┐ X 进程被 kill
│ 写入 txn log │ ──► │ 部分 fsync │ ──► │ zxid 未更新 │
└─────────────┘ └──────────────┘
│
▼
磁盘上有半条事务记录 → "Last transaction was partial"K8s 环境中常见触发场景:
| 原因 | 说明 |
|---|---|
| Pod OOMKilled | 内存超限被 Kubelet 杀死 |
| Node 驱逐 | 节点资源不足,Pod 被驱逐 |
| 强制删除 | kubectl delete pod --force --grace-period=0 |
| 节点故障 | 物理机宕机,无优雅关闭 |
| PreStop hook 缺失 | StatefulSet 未配置 lifecycle.preStop,终止信号后直接 SIGTERM |
zxid 不匹配
Snapshot 中最高 zxid: 0x1e000004af (快照时刻的事务 ID)
快照后产生的事务: log.5900002c8a (日志文件名编码了起始 zxid)
gap = 0x5900002c8a - 0x1e000004af = 大量的后续事务快照是定期生成的(默认 snapCount=100000,即每 10 万条事务做一次快照),两次快照之间的事务日志文件是增量。如果最近的事务日志损坏,快照仍然是可用的。
恢复过程
ZooKeeper 的恢复过程分三个阶段:
1. 加载本地快照(Snapshot)
2026-06-02 10:41:40,808 [myid:2] - INFO [main:FileSnap@85] - Reading snapshot /data/version-2/snapshot.1e000004af
2026-06-02 10:42:03,108 [myid:2] - INFO [main:FileTxnSnapLog@363] - 86470 txns loaded in 20601 ms
2026-06-02 10:42:03,109 [myid:2] - INFO [main:ZKDatabase@289] - Snapshot loaded in 22898 ms, highest zxid is 0x5900002c89- 从最新有效快照恢复 DataTree
- 重放快照之后的有效事务日志(86470 条),耗时约 20s
- 快照最高 zxid 为
0x5900002c89,已恢复到快照关闭前的状态
损坏的事务日志 ERROR 只是警告信息,ZK 会跳过这些损坏的日志记录。日志行 112-143 显示大约 32 条损坏的事务被跳过(
highestZxid > next log),这些事务原本应该存在于快照关闭之后,但由于日志损坏无法恢复。
2. Leader 选举
2026-06-02 10:42:08,109 [myid:2] - INFO - LOOKING
2026-06-02 10:42:08,210 [myid:2] - INFO - New election. My id = 2, proposed zxid=0x5900002c89
2026-06-02 10:42:10,112 [myid:2] - INFO - FOLLOWING
2026-06-02 10:42:11,011 [myid:2] - INFO - LEADER ELECTION TOOK - 2801 MS- 节点进入 LOOKING 状态,发起选举
- 收到 myid=3 (LEADING, zxid=0x5800000005) 和 myid=1 (FOLLOWING, leader=3) 的通知
- 本节点 zxid 更高但 Leader 已经确认,转为 FOLLOWING
- 选举耗时 2.8 秒
3. Leader 同步(Diff 同步)
2026-06-02 10:42:11,508 [myid:2] - INFO [Learner@551] - Getting a diff from the leader 0x5900002d3f
2026-06-02 10:42:13,510 [myid:2] - INFO [Learner@701] - Learner received UPTODATE message- 从 Leader 拉取缺失的事务(zxid
0x5900002c8a到0x5900002d3f,约 182 条) - 通过 diff 方式同步(只同步差异,而非全量 SNAP 同步),说明大部分数据一致
- 收到 UPTODATE 后进入 BROADCAST 状态,正式对外服务
2026-06-02 10:42:13,809 [myid:2] - INFO [QuorumPeer@864] - Peer state changed: following - broadcast恢复时间线
10:41:25 读取配置
10:41:40 开始加载快照
10:42:03 快照加载完成(22.9s),zxid=0x5900002c89
↓ Leader 选举
10:42:11 完成选举,转为 FOLLOWING(2.8s)
↓ Leader 同步
10:42:13 同步完成(2.0s)
10:42:13 进入 BROADCAST,对外服务
───────────────────────────
总恢复时间: ~48 秒总结
| 要点 | 说明 |
|---|---|
| 故障本质 | Pod 异常终止导致事务日志写入不完整 |
| 恢复手段 | 快照恢复 + 跳过损坏日志 + Leader diff 同步 |
| 数据安全 | 3 节点多数派 + 快照机制保证了数据完整性,未丢失已提交数据 |
| 恢复时间 | ~48s(快照加载 23s + 选举 3s + 同步 2s) |
预防措施
| 措施 | 说明 |
|---|---|
| 配置 PreStop hook | StatefulSet 中设置 lifecycle.preStop,给 ZK 足够时间优雅关闭 |
| 增加资源 limit | 合理设置 JVM heap 和 memory limit,避免 OOMKilled |
| 使用 PodDisruptionBudget | 限制同时不可用的 ZK Pod 数量 |
| 监控 ZK 指标 | 关注 fsync 耗时、pending syncs、outstanding requests |
| KRaft 迁移 | 长期方案:迁移到 KRaft 模式,消除 ZK 依赖 |