三种「不一致」,处理方式完全不同
「副本数据不一致」这句话太粗,落到取证上至少要分成三种:
| 形态 | 含义 | 会不会自愈 |
|---|---|---|
| 落后(lagging) | follower 的 LEO 小于 leader | 会,只要 follower 活着并在拉取 |
| 分歧(diverging) | 同一 offset 上两个副本的内容不同 | 不会,必须截断后重建 |
| 损坏(corrupt) | 段文件字节坏了,CRC 对不上 | 取决于恢复流程能否发现 |
日常监控里的 UnderReplicatedPartitions 只能说明第一种。第二种和第三种在 Kafka 的常规指标里几乎没有直接信号,因为复制是「追加字节」而不是「比对内容」:leader 不会主动校验 follower 上已有的数据,follower 也不会回头检查自己写过的字节。
这就是为什么需要专门的取证工具。这一节按「先粗后细」的顺序把三层工具跑一遍,再亲手制造一次损坏。
第一层:replica verification 的粗筛
先建一个干净的样本:3 分区 3 副本,写入 2000 条 512 字节的消息。
docker exec mq-e1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server mq-e1:9092,mq-e2:9092,mq-e3:9092 \
--create --topic e1.verify.demo --partitions 3 --replication-factor 3
docker exec mq-e1 /opt/kafka/bin/kafka-producer-perf-test.sh \
--topic e1.verify.demo --num-records 2000 --record-size 512 --throughput -1 \
--producer-props acks=all bootstrap.servers=mq-e1:9092,mq-e2:9092,mq-e3:9092
三个分区的末端位移是 698 / 682 / 620,合计 2000。注意它并不均匀:默认的粘性分区器按批分配,2000 条不会均分成三份。
运行校验工具:
docker exec mq-e1 timeout 25 /opt/kafka/bin/kafka-replica-verification.sh \
--broker-list mq-e1:9092,mq-e2:9092,mq-e3:9092 \
--topics-include 'e1.verify.demo' \
--report-interval-ms 5000
本机实测(Kafka 4.3.0 KRaft 三节点 / 2026-10-29):
[2026-10-29 04:50:44,886] WARN This tool is deprecated and may be removed in a future major release. (org.apache.kafka.tools.ReplicaVerificationTool)
2026-10-29 04:50:45,752: verification process is started
2026-10-29 04:50:50,800: max lag is 0 for partition e1.verify.demo-1 at offset 682 among 3 partitions
2026-10-29 04:50:55,826: max lag is 0 for partition e1.verify.demo-1 at offset 682 among 3 partitions
2026-10-29 04:51:00,847: max lag is 0 for partition e1.verify.demo-1 at offset 682 among 3 partitions
2026-10-29 04:51:05,862: max lag is 0 for partition e1.verify.demo-1 at offset 682 among 3 partitions
三件事要读出来:
- 工具在 4.3.0 已经标记 deprecated(第一行 WARN)。它还能用,但不要把它写进长期依赖的自动化。
- 输出的是「每个分区的最大 lag」,
max lag is 0表示三个副本在同一 offset 上读到相同内容。这里的 lag 不是消费组的 LAG,是副本之间按 offset 对齐后的差异数。 - 它要跑一段时间,用
--report-interval-ms控制输出频率。工具本身不会自己退出,需要外部timeout(macOS 没有timeout,本机是在容器里用 BusyBox 的)。
这个工具的定位是粗筛:它告诉你「有没有差异」,不告诉你在哪个字节。100 GB 的 topic 上跑一次代价不小,生产高峰别用。
第二层:offsetLag 的局限
kafka-log-dirs.sh 给出每个 broker 每个分区的物理信息:
docker exec mq-e1 /opt/kafka/bin/kafka-log-dirs.sh \
--bootstrap-server mq-e1:9092,mq-e2:9092,mq-e3:9092 \
--describe --topic-list e1.verify.demo
输出是一段 JSON,把关键字段挑出来:
broker 1 [('e1.verify.demo-1', 356664, 0), ('e1.verify.demo-0', 365061, 0), ('e1.verify.demo-2', 324240, 0)]
broker 2 [('e1.verify.demo-1', 356664, 0), ('e1.verify.demo-0', 365061, 0), ('e1.verify.demo-2', 324240, 0)]
broker 3 [('e1.verify.demo-1', 356664, 0), ('e1.verify.demo-0', 365061, 0), ('e1.verify.demo-2', 324240, 0)]
每项是 (分区, size, offsetLag)。健康状态下三份完全一样:字节数相同、offsetLag 为 0。
offsetLag 的定义是「该副本的 LEO 与 leader LEO 的差」,它回答的是第一种形态(落后)。它不能发现分歧:两个副本都有 offset 682 条消息、内容不同,offsetLag 仍然是 0。
所以这一层的价值是快速确认「没有落后」,不能用来证明「一致」。
第三层:同 offset 的 CRC 比对
要到字节级,只能直接读段文件。Kafka 的每条消息批次头里带 CRC32,kafka-dump-log.sh 会把它解出来。
先确认分区 1 的 leader 与副本分布:
Topic: e1.verify.demo Partition: 1 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1
leader 是 broker 2,follower 是 3 和 1。分别 dump 两个副本的同一个段:
docker exec mq-e2 /opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kafka-logs/e1.verify.demo-1/00000000000000000000.log --print-data-log
docker exec mq-e3 /opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kafka-logs/e1.verify.demo-1/00000000000000000000.log --print-data-log
两个副本的第一批完全相同:
baseOffset: 0 lastOffset: 30 count: 31 baseSequence: 0 lastSequence: 30
producerId: 2002 producerEpoch: 0 partitionLeaderEpoch: 0
position: 0 size: 16212 magic: 2 compresscodec: none crc: 3497634919 isvalid: true
把整个段里所有批次的 CRC 去重后算摘要,两边一致:
ec1f564a31f145ad6ff91f283d3ce08f -
ec1f564a31f145ad6ff91f283d3ce08f -
这就是字节级的一致:同一个 offset 区间,两边的批次边界、长度、CRC 都一样。
取证时要注意 kafka-dump-log.sh 的一个行为:它逐条输出两行,批次头一行、记录明细一行,两行都含 offset: 字样。统计条数要用 grep -c '^| offset:',不能用 grep -c 'offset:'。这个坑在 4.1 节已经踩过一次。
leader epoch:决定「截到哪」的依据
副本分歧之后,follower 该退到哪个 offset 重新拉?答案不是「退到 leader 的 LEO」,因为 leader 的日志也可能在选举后变短。Kafka 用的是 leader epoch。
每个批次头里有 partitionLeaderEpoch。follower 发现自己的 LEO 超过 leader 时,会带着自己的 epoch 历史去问 leader 的 OffsetsForLeaderEpoch,拿到「在这个 epoch 下,leader 日志的末端 offset」,然后把本地日志截断到那里。这样截断的依据是「同一任 leader 下写入过什么」,而不是「现在 leader 有多长」。
每个分区目录里有一个 leader-epoch-checkpoint 文件,本机这个分区在恢复后是 8 字节:
-rw-r--r-- 1 appuser appuser 8 Oct 29 04:52 leader-epoch-checkpoint
文件很小,但它决定了截断点。生产环境里如果看到副本之间反复截断、重建,先怀疑这个文件与日志不一致,而不是网络。
要强调的是:截断只发生在 follower 追上之后发现分歧的场景。下一节的实验里 follower 是落后的(LEO 651 < leader 682),所以走的是「继续拉取」,不是 epoch 截断。两者不要混。
破坏实验:截断 follower 的段文件
操作对象只有本机自己起的 mq-e3,动手前先复制一份原文件。步骤:
- 停掉
mq-e3; - 把
e1.verify.demo-1的.log复制出来; - 去掉最后 500 字节;
- 清空
.index与.timeindex,强制恢复时重建; - 放回容器,重启,观察日志。
docker stop mq-e3
docker cp mq-e3:/tmp/kafka-logs/e1.verify.demo-1/00000000000000000000.log p1.log.bak
python3 - <<'PY'
data = open('/tmp/mq14/p1.log.bak', 'rb').read()
open('/tmp/mq14/p1.log.cut', 'wb').write(data[:-500])
print('原大小', len(data), '截断后', len(data) - 500)
PY
: > empty.idx
chmod 666 p1.log.cut empty.idx
docker cp p1.log.cut mq-e3:/tmp/kafka-logs/e1.verify.demo-1/00000000000000000000.log
docker cp empty.idx mq-e3:/tmp/kafka-logs/e1.verify.demo-1/00000000000000000000.index
docker cp empty.idx mq-e3:/tmp/kafka-logs/e1.verify.demo-1/00000000000000000000.timeindex
docker start mq-e3
原文件 356664 字节,截断后 356164 字节。被切掉的是最后一个批次(offset 651 到 681,31 条,16212 字节)的尾部。
chmod 666 不是可有可无的:docker cp 会把宿主机文件的属主带进容器,而 Kafka 以 appuser(uid 1000)运行。不放宽权限,broker 能读不能写,重启用例会以另一种方式失败,干扰对恢复流程的观察。
这类操作只能在一次性实验环境做。 改动的是 broker 的持久化数据,放回生产等同于制造一次数据损坏。
恢复过程:LEO 从 682 退回 651
本机实测(Kafka 4.3.0 KRaft 三节点 / 2026-10-29):重启后,server.log 里这个分区的加载记录是:
[LogLoader partition=e1.verify.demo-1] Loading producer state till offset 651
[LogLoader partition=e1.verify.demo-1] Reloading from producer snapshot and rebuilding producer state from offset 651
Deleted producer state snapshot /tmp/kafka-logs/e1.verify.demo-1/00000000000000000682.snapshot
Completed load of Log(... topic=e1.verify.demo, partition=1, highWatermark=0, ...,
logEndOffset=651) with 1 segments, local-log-start-offset 0 and log-end-offset 651
三行读出一条完整链路:
- 恢复扫描到最后一个不完整的批次,把 LEO 定在它的起始 offset 651;
- 682 处的 producer 快照失效,被删除,状态从 651 重建;
- 日志以
logEndOffset=651完成加载。
随后 fetcher 从 651 开始向 leader 拉取:
Added fetcher to broker 2 for partitions HashMap(
e1.verify.demo-1 -> InitialFetchState(..., BrokerEndPoint[id=2, host=mq-e2, port=9092], 0, 651),
...)
这里的 651 是拉取起点。follower 没有走 epoch 截断,因为它本来就比 leader 短,只需要补。
几秒后再查:
Topic: e1.verify.demo Partition: 1 Leader: 2 Replicas: 2,3,1 Isr: 1,2,3
ISR 回到三个副本,offsetLag 全部归零,再跑一次校验仍然是:
2026-10-29 04:52:39,699: max lag is 0 for partition e1.verify.demo-1 at offset 682 among 3 partitions
从「被切掉一个批次」到「完全一致」,整个过程没有人工干预数据,靠的是副本重建。这正是 13.3 讲的机制在故障场景下的应用。
反直觉的结果:offsetLag=0,字节数却不同
恢复完成后,kafka-log-dirs.sh 的三行不再一样:
broker 1 [('e1.verify.demo-1', 356664, 0), ...]
broker 2 [('e1.verify.demo-1', 356664, 0), ...]
broker 3 [('e1.verify.demo-1', 372376, 0), ...]
broker 3 是那台被截断过的节点。它的段文件比另外两台大 15712 字节,但 offsetLag 是 0,replica verification 也报 max lag is 0。
差额不是巧合:16212 - 500 = 15712。恢复只把逻辑末端退回 651,文件里那 15712 字节的残批还在;重新拉取时,651 到 681 这一批被完整追加到文件末尾(356164 + 16212 = 372376),于是同一段 offset 在磁盘上出现了两份字节:一份是残缺的旧批次,一份是完整的新批次。
用 dump 验证。broker 3 上最后一个能被解析的批次是:
baseOffset: 651 lastOffset: 681 count: 31 ... position: 340452 size: 16212
crc: 2736696394 isvalid: false
同一个批次在 leader 上是 isvalid: true。而且 dump 到这一条就停了,后面 16212 字节的有效批次没有被列出来——扫描器遇到 isvalid: false 的批次就不再往后读。
这个结果给出两条实操结论:
- 不要用文件大小判断副本是否一致。 字节数受截断、残批、段滚动影响,和 offset 语义不是一一对应。
- 不要用 dump 的行数估算副本的记录数。 遇到损坏批次会提前停止,数出来的偏小。
判断一致性只能回到 offset 与 CRC:offsetLag 看是否落后,replica verification 看内容是否一致,dump 看具体批次的 crc 与 isvalid。
取证顺序
把三层工具串起来,日常排查按这个顺序:
kafka-topics.sh --describe看 ISR 与 leader,确认有没有副本掉队;kafka-log-dirs.sh --describe看offsetLag与size,区分「落后」与「只是字节布局不同」;kafka-replica-verification.sh粗筛内容,确认是否有分歧;kafka-dump-log.sh对具体分区、具体 offset 比crc与isvalid;- 只有确认分歧且无法自愈时,才考虑停副本、删目录、让它重建。
第 5 步是最后手段。多数「不一致」其实是落后,等一会儿就追上了;把落后当成分歧去删数据,是把小故障放大成数据丢失。
未在本机执行的两项
段中间的字节翻转(bit rot)。本次只截断了尾部,没有改动中间字节。预期形态是:恢复扫描到 CRC 不匹配的批次,从该批次起截断,然后重新拉取;server.log 会出现该批次的校验失败记录。命令与截断实验相同,只是把「去掉尾部」换成「改写中间若干字节」。没有执行,是因为它需要精确命中批次边界之外的 payload 区,重复试验成本高于本节要说明的结论。
follower 领先于 leader 的分歧。这种形态需要不干净选举:新 leader 的日志比某个旧副本短,旧副本重启后 LEO 更大,才会触发 OffsetsForLeaderEpoch 截断。三节点 KRaft 停两个就失去 quorum,本机做不出来。预期输出:
# 未在本机执行
Truncating partition T-0 with TruncationState(offset=<epoch 末端>, completed=true)
要把这项做成可复现实验,需要 5 节点或 dedicated controller 拓扑。
小结
这一节的主线是「从粗到细」:
kafka-replica-verification.sh给的是布尔结论(有没有差异),4.3.0 已标记 deprecated;kafka-log-dirs.sh的offsetLag只能发现落后,发现不了分歧;kafka-dump-log.sh的crc与isvalid才能定位到批次;- leader epoch 决定截断点,落后与分歧走的是两条不同路径;
- 截断一个 follower 的段文件后,它从 LEO 682 回退到 651,重建后
offsetLag归零; offsetLag=0不代表字节一致:本机恢复后的副本段文件比 leader 大 15712 字节,但 offset 语义完全一致。
下一章转向监控:这些故障在指标上分别长什么样,哪些指标值得告警,阈值怎么定。14.1 的对照表会变成一张带采集方式的清单。
阅读导航:上一节 14.2 积压的分层定位 | 下一节 15.1 监控指标与告警
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。