《Java 消息中间件·Kafka 内核与客户端》14.3 数据不一致的取证

副本落后与副本分歧是两件事:落后会自己追上,分歧不会。本节实跑 kafka-replica-verification.sh 得到 max lag 0,用 offsetLag 与 dump-log 的 crc 逐层取证,再手动截断 follower 的段文件,观察 LEO 从 682 回退到 651 并重建,并给出反直觉结论:offsetLag=0 时段文件字节数可以不同。

三种「不一致」,处理方式完全不同

「副本数据不一致」这句话太粗,落到取证上至少要分成三种:

形态含义会不会自愈
落后(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

三件事要读出来:

  1. 工具在 4.3.0 已经标记 deprecated(第一行 WARN)。它还能用,但不要把它写进长期依赖的自动化。
  2. 输出的是「每个分区的最大 lag」,max lag is 0 表示三个副本在同一 offset 上读到相同内容。这里的 lag 不是消费组的 LAG,是副本之间按 offset 对齐后的差异数。
  3. 它要跑一段时间,用 --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,动手前先复制一份原文件。步骤:

  1. 停掉 mq-e3;
  2. 把 e1.verify.demo-1 的 .log 复制出来;
  3. 去掉最后 500 字节;
  4. 清空 .index 与 .timeindex,强制恢复时重建;
  5. 放回容器,重启,观察日志。
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

三行读出一条完整链路:

  1. 恢复扫描到最后一个不完整的批次,把 LEO 定在它的起始 offset 651;
  2. 682 处的 producer 快照失效,被删除,状态从 651 重建;
  3. 日志以 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 的批次就不再往后读。

这个结果给出两条实操结论:

  1. 不要用文件大小判断副本是否一致。 字节数受截断、残批、段滚动影响,和 offset 语义不是一一对应。
  2. 不要用 dump 的行数估算副本的记录数。 遇到损坏批次会提前停止,数出来的偏小。

判断一致性只能回到 offset 与 CRC:offsetLag 看是否落后,replica verification 看内容是否一致,dump 看具体批次的 crc 与 isvalid。

取证顺序

把三层工具串起来,日常排查按这个顺序:

  1. kafka-topics.sh --describe 看 ISR 与 leader,确认有没有副本掉队;
  2. kafka-log-dirs.sh --describe 看 offsetLag 与 size,区分「落后」与「只是字节布局不同」;
  3. kafka-replica-verification.sh 粗筛内容,确认是否有分歧;
  4. kafka-dump-log.sh 对具体分区、具体 offset 比 crc 与 isvalid;
  5. 只有确认分歧且无法自愈时,才考虑停副本、删目录、让它重建。

第 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 拓扑。

小结

这一节的主线是「从粗到细」:

  1. kafka-replica-verification.sh 给的是布尔结论(有没有差异),4.3.0 已标记 deprecated;
  2. kafka-log-dirs.sh 的 offsetLag 只能发现落后,发现不了分歧;
  3. kafka-dump-log.sh 的 crc 与 isvalid 才能定位到批次;
  4. leader epoch 决定截断点,落后与分歧走的是两条不同路径;
  5. 截断一个 follower 的段文件后,它从 LEO 682 回退到 651,重建后 offsetLag 归零;
  6. offsetLag=0 不代表字节一致:本机恢复后的副本段文件比 leader 大 15712 字节,但 offset 语义完全一致。

下一章转向监控:这些故障在指标上分别长什么样,哪些指标值得告警,阈值怎么定。14.1 的对照表会变成一张带采集方式的清单。

阅读导航:上一节 14.2 积压的分层定位 | 下一节 15.1 监控指标与告警

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收