Kafka 可靠性实验(U57 / #94):真实 KRaft 集群上的确认、事务与恢复
仓库根目录的公开入口:
python3 labs/kafka-reliability/run.py需要 Python 3.8+、JDK 21 和 Maven 3.9.x(JAVA_HOME/MAVEN_BIN 可覆盖;macOS 未设置 JAVA_HOME 时通过 /usr/libexec/java_home -v 21 选择)。入口以 -o(离线)方式运行 Maven,要求依赖已在本机 Maven 仓库缓存;首次准备可在本目录执行一次 mvn -B -ntp test-compile 在线解析(不属于公开入口的成功条件)。入口自己不启动/停止任何外部服务、不连接本机或远端已有 Kafka、不使用 Docker。
教材入口:MQ 生产存储消费可靠性、Kafka 消费位点与重平衡、重试、死信与积压恢复。本实验不重写三篇 3.7.1 机制教材:固定源码结论仍以正文登记的 3.7.1 摘录为准,本实验在 Kafka 4.1.2 上验证客户端与 broker 的实际行为。
组件与证明边界
| 组件 | 实际实现 | 不能证明 |
|---|---|---|
| Kafka 集群 | KafkaClusterTestKit(org.apache.kafka:kafka-test-common-runtime:4.1.2)在同一测试 JVM 内启动 3 broker + 3 controller 的真实 KRaft 集群(setCombined(false)),真实 broker 代码、随机 loopback 端口、每节点独立目录 | 多机进程隔离、网络分区、broker 磁盘故障、跨机房复制、断电恢复 |
| 生产者/消费者 | 真实 KafkaProducer/KafkaConsumer 4.1.2,acks=all、enable.idempotence=true、事务 producer 与 sendOffsetsToTransaction | 生产 TLS/SASL 与配额、跨版本客户端兼容矩阵 |
| Broker 故障 | 通过 BrokerServer.shutdown()/startup() 关闭/恢复单个 broker,controller 节点不受影响(combined 模式会连带丢失 controller 仲裁,因此本夹具用独立 controller) | OS 进程强杀、磁盘损坏、leader 选举期间的写中断时长 |
| 观察 | 用 AdminClient.describeTopics 读 ISR/leader,用 listConsumerGroupOffsets 读已提交位点,用消费者实际 poll 结果判断可见性 | Kafka 内部指标/日志语义的完整因果 |
用例与观察行
测试名称固定在 runner 的 EXPECTED_TESTS,观察行固定在 MARKERS;两者都与 Surefire/进程输出核对,缺失、重复或版本不符即失败。
| 测试 | 输入与操作 | 观察行 |
|---|---|---|
minIsrRejectsBeforeAppendAndRecoversAfterBrokersReturn | 1 分区 3 副本 min.insync.replicas=2;依次停 1 个、2 个 follower,acks=all 写入第三条 | U57 minIsr=PASS(ISR=1 时被拒;恢复后只读到 v1、v2) |
abortedTransactionIsHiddenAndOpenTransactionBlocksLaterRecords | 事务中止一条、未决事务一条 + 普通一条,分别用 read_committed/read_uncommitted 读 | U57 transactionVisibility=PASS(中止记录不可见;未决事务挡住其后的普通记录) |
sendOffsetsToTransactionMakesOffsetsAndOutputAtomic | 输入两条,先尝试"输出 + 位点"事务后中止,再提交一次 | U57 eosOffsets=PASS(中止:位点 0、无输出;提交:位点 2、输出 1 条) |
restartFromCommittedOffsetReplaysUncommittedWork | 业务处理 2 条但只提交第 1 条位点后关闭消费者 | U57 offsetReplay=PASS(重启从 e2 重放;无业务幂等则效果重复) |
cooperativeRebalanceMovesPartitionsToSurvivor | 2 分区、2 个 CooperativeStickyAssignor 成员,关掉一个 | U57 cooperativeRebalance=PASS(1+1 → 幸存者拿到 0、1) |
retentionDeletesRecordsBeforeSlowConsumerCatchesUp | 1 分区 retention.ms=4000、segment.ms=1000;慢消费者位点停在 1 | U57 retention=PASS(日志起点推进到 3;auto.offset.reset=none 抛 OffsetOutOfRange,earliest 静默跳到 3) |
transactionalIdFencesOldProducerInsteadOfDuplicating | 同一 transactional.id 先后 init 两个 producer,旧实例继续提交 | U57 producerFencing=PASS(旧实例 InvalidProducerEpochException;只有新实例的写入可见) |
conflictingIdempotenceConfigurationIsRejectedByClient | 显式 enable.idempotence=true 且 acks=1、retries=0、max.in.flight=6;以及未显式开启时只给 acks=1 | U57 idempotenceConfig=PASS(三个显式冲突都在构造时 ConfigException;未显式开启时 acks=1 只是静默关闭幂等) |
版本
固定组合:Boot 4.0.9 BOM、spring-kafka/spring-kafka-test 4.0.8、Kafka 客户端与测试工具 4.1.2、JUnit Jupiter 6.0.3。runner 解析 dependency:list 并核对上表版本,不符即失败。教材正文的机制源码仍固定 Kafka 3.7.1(课程基线);本实验的运行版本是 4.1.2,二者分别登记,不拼成一次运行。
证据与清理
证据固定写入 evidence/u57-2026-10-27.json:开始/结束时间、输入、每条命令与退出码、观察行、实际依赖版本、Surefire 名称/计数、源文件 SHA-256、日志路径。只有 status=passed 且 exitCode=0 的完整记录表示该次验证通过;README 与预期观察本身不代表已实跑。每次执行先把证据标为 running,完成时原子替换为 passed/failed;失败覆盖上一轮成功。运行期间源文件哈希变化、测试数减少、测试跳过、观察行缺失/重复、版本不符、命令超时或进程组清理失败都不会产生成功状态。
原始日志与依赖清单在仓库外:
~/.cache/software-engineering-study/java-depth-impl/u57/run-<随机目录>/runner 对自己创建的进程组先 TERM 后有界 KILL;fixture 的 close() 关闭集群与 Admin 客户端,临时目录由 setDeleteOnClose(true) 与有界兜底删除。runner 不按端口或进程名清理本机其他服务。
未运行范围
- RabbitMQ(U58)、RocketMQ 事务回查(U59)、多地域复制与 MirrorMaker。
- 多机部署、网络分区/丢包注入、broker 磁盘故障与断电、controller 仲裁丢失(combined 模式)。
- 生产 TLS/SASL、配额、配额与请求限流、容量压测、消费者组规模与吞吐。
- 与数据库/外部支付的跨资源恰好一次(U61/U80 负责);本实验只覆盖 Kafka 原子域内的位点与输出。