系统学习 演练学习路径:35 个 lab 的难度排序、4 周基础计划与复盘方法

版本信息最后核对:2026-10-25

Lab 状态:实跑通过2026-10-25 · Docker Compose / Kafka 3.7.0

迁移、broker 停机、写入拒绝、追平与回滚全部实测通过

在四个本地 KRaft broker 中演练副本迁移、broker 退役与替换,并验证 ISR 与 min.insync.replicas 边界

演练目标

安全边界:本 Lab 只允许在本地 Docker Compose 的一次性集群中进行。不要对生产 Kafka 执行 kafka-reassign-partitions --execute、修改 min.insync.replicas 或停止 broker。真实替换涉及 controller quorum、机架分布、客户端 bootstrap、Connect 和 MirrorMaker,必须走变更流程并准备限速与回滚计划。

环境准备

准备 Docker 与 Compose v2,预留约 2 GB 内存。四个 broker 使用同一 CLUSTER_ID、独立日志卷和容器网络 lab-kafka-replace-net,不映射端口到宿主机。broker-1 至 broker-3 同时承担 controller,broker-4 只承担 broker,便于后续退役 controller 时仍保留 2/3 多数派。四个节点必须使用同一 cluster ID 且 node ID 唯一;broker-4 不参与投票,停掉它不会影响 controller quorum。实验开始前先明确两点:副本因子决定每份数据有几份,min.insync.replicas 只约束写入确认,不会自动补副本;任何停止 broker 的操作都要先确认 ISR 不会低于该值:

timeout 10 mkdir -p /tmp/lab-kafka-replace
timeout 30 docker version --format '{{.Server.Version}}'
timeout 30 docker compose version

Compose 用 YAML 锚点复用公共环境变量。KAFKA_HEAP_OPTS 压到 256 MB 只是为了让本地实验跑得动,生产容量必须按吞吐和页缓存重新评估:

cat > /tmp/lab-kafka-replace/docker-compose.yml <<'EOF'
name: lab-kafka-replace
x-kafka-env: &kafka-env
  CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
  KAFKA_PROCESS_ROLES: broker,controller
  KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
  KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
  KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
  KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker-1:9093,2@broker-2:9093,3@broker-3:9093
  KAFKA_LOG_DIRS: /var/lib/kafka/data
  KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3
  KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 3
  KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2
  KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
  KAFKA_HEAP_OPTS: -Xmx256m -Xms256m
services:
  broker-1:
    image: apache/kafka:3.7.0
    container_name: lab-kafka-1
    environment:
      <<: *kafka-env
      KAFKA_NODE_ID: 1
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker-1:9092
    volumes: ["broker1:/var/lib/kafka/data"]
  broker-2:
    image: apache/kafka:3.7.0
    container_name: lab-kafka-2
    environment:
      <<: *kafka-env
      KAFKA_NODE_ID: 2
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker-2:9092
    volumes: ["broker2:/var/lib/kafka/data"]
  broker-3:
    image: apache/kafka:3.7.0
    container_name: lab-kafka-3
    environment:
      <<: *kafka-env
      KAFKA_NODE_ID: 3
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker-3:9092
    volumes: ["broker3:/var/lib/kafka/data"]
  broker-4:
    image: apache/kafka:3.7.0
    container_name: lab-kafka-4
    environment:
      <<: *kafka-env
      KAFKA_NODE_ID: 4
      KAFKA_PROCESS_ROLES: broker
      # broker-only 节点不能监听 CONTROLLER 端口,否则 Kafka 3.7 拒绝启动
      KAFKA_LISTENERS: PLAINTEXT://:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker-4:9092
    volumes: ["broker4:/var/lib/kafka/data"]
volumes:
  broker1: { name: lab-kafka-replace-broker1 }
  broker2: { name: lab-kafka-replace-broker2 }
  broker3: { name: lab-kafka-replace-broker3 }
  broker4: { name: lab-kafka-replace-broker4 }
networks:
  default:
    name: lab-kafka-replace-net
EOF
timeout 300 docker compose -f /tmp/lab-kafka-replace/docker-compose.yml up -d
timeout 240 bash -c 'until docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 --list >/dev/null 2>&1; do sleep 3; done'

固定基线并创建 topic。手动指定副本分配,确保初始副本只落在 broker-1/2/3,broker-4 作为待接替的空节点:

timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server broker-1:9092
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-metadata-quorum.sh --bootstrap-server broker-1:9092 describe --status
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --create --topic lab-orders --replica-assignment 1:2:3,2:3:1,3:1:2 \
  --config min.insync.replicas=2 --config retention.ms=3600000
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --topic lab-orders | tee /tmp/lab-kafka-replace/topics-before.txt
timeout 120 bash -c 'until [ "$(docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 --describe --topic lab-orders | grep -Ec "Isr: [123],[123],[123]")" -ge 3 ]; do sleep 2; done'
printf 'order-1:{"amount":100}\norder-2:{"amount":200}\norder-3:{"amount":300}\n' | \
  timeout 30 docker exec -i lab-kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server broker-1:9092 --topic lab-orders \
  --property parse.key=true --property key.separator=: --producer-property acks=all
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server broker-1:9092 --topic lab-orders | tee /tmp/lab-kafka-replace/offsets-before.txt

若当前版本的 kafka-metadata-quorum.sh 不接受 --bootstrap-server,改用 --bootstrap-controller broker-1:9093,不要跳过 quorum 检查。

演练步骤

1. 迁移副本到替换节点

把每个分区的一份副本从 broker-3 移到 broker-4,保留另外两份不动,避免同一分区多个副本同时搬迁。先保存回滚用的原始分配,再执行迁移并限速:

cat > /tmp/lab-kafka-replace/reassign-to-4.json <<'EOF'
{"version":1,"partitions":[
 {"topic":"lab-orders","partition":0,"replicas":[1,2,4]},
 {"topic":"lab-orders","partition":1,"replicas":[2,4,1]},
 {"topic":"lab-orders","partition":2,"replicas":[4,1,2]}
]}
EOF
timeout 30 docker cp /tmp/lab-kafka-replace/reassign-to-4.json lab-kafka-1:/tmp/reassign-to-4.json
timeout 120 docker exec lab-kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
  --bootstrap-server broker-1:9092 --reassignment-json-file /tmp/reassign-to-4.json \
  --execute --throttle 10485760
timeout 120 docker exec lab-kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
  --bootstrap-server broker-1:9092 --reassignment-json-file /tmp/reassign-to-4.json --verify

--verify 要对三个分区都显示 is completed 才能继续;若长时间 in progress,先查限速、磁盘和网络,不要删除 broker-3 的数据目录。迁移完成后检查 ISR 和 leader:

timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --topic lab-orders
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --under-replicated-partitions

2. 退役 broker-3

确认 lab-orders 的 Replicas 已不含 broker-3、ISR 等于 3、没有离线分区后,停止 broker-3。它同时是 controller,停掉后 quorum 只剩 broker-1/2,仍满足 2/3 多数派,但已不能再损失第二台 controller:

timeout 60 docker compose -f /tmp/lab-kafka-replace/docker-compose.yml stop broker-3
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-metadata-quorum.sh --bootstrap-server broker-1:9092 describe --status
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --topic lab-orders
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --under-replicated-partitions

3. 收缩 ISR 并验证 min.insync.replicas

broker-4 只承担 broker,停掉它不会影响 controller quorum。此时每个分区的 ISR 应从 3 降到 2,仍等于 min.insync.replicas=2,因此 acks=all 写入应当成功:

timeout 60 docker compose -f /tmp/lab-kafka-replace/docker-compose.yml stop broker-4
timeout 120 bash -c 'until [ "$(docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 --describe --topic lab-orders | grep -Ec "Isr: [12],[12]$")" -ge 3 ]; do sleep 3; done'
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --topic lab-orders
printf 'order-4:{"amount":400}\n' | timeout 30 docker exec -i lab-kafka-1 \
  /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server broker-1:9092 \
  --topic lab-orders --property parse.key=true --property key.separator=: \
  --producer-property acks=all --producer-property retries=0

再把 topic 的 min.insync.replicas 临时改成 3。ISR 只有 2,broker 会拒绝 acks=all 写入并返回 NOT_ENOUGH_REPLICAS;这不是集群坏了,而是确认条件在起作用。控制台生产者的退出码不一定是唯一判据,要同时看错误文本:

timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-configs.sh --bootstrap-server broker-1:9092 \
  --alter --entity-type topics --entity-name lab-orders \
  --add-config min.insync.replicas=3
printf 'order-5:{"amount":500}\n' | timeout 30 docker exec -i lab-kafka-1 \
  /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server broker-1:9092 \
  --topic lab-orders --property parse.key=true --property key.separator=: \
  --producer-property acks=all --producer-property retries=0 2>&1 | tee /tmp/lab-kafka-replace/produce-fail.txt
grep -E 'NOT_ENOUGH_REPLICAS|TimeoutException|Delivery failed' /tmp/lab-kafka-replace/produce-fail.txt
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-configs.sh --bootstrap-server broker-1:9092 \
  --alter --entity-type topics --entity-name lab-orders \
  --add-config min.insync.replicas=2

4. 恢复替换节点并验证

重启 broker-4,等它重新加入并追平。随后再次以 acks=all 写入,确认 ISR 恢复到 3、写入恢复、offset 继续增长:

timeout 120 docker compose -f /tmp/lab-kafka-replace/docker-compose.yml start broker-4
timeout 240 bash -c 'until [ "$(docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 --describe --topic lab-orders | grep -Ec "Replicas: .*4.*Isr: [0-9]+,[0-9]+,[0-9]+")" -ge 3 ]; do sleep 5; done'
printf 'order-6:{"amount":600}\n' | timeout 30 docker exec -i lab-kafka-1 \
  /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server broker-1:9092 \
  --topic lab-orders --property parse.key=true --property key.separator=: \
  --producer-property acks=all
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server broker-1:9092 --topic lab-orders | tee /tmp/lab-kafka-replace/offsets-after.txt

观察现象

迁移期间,--verify 从 in progress 变为 completed,分区的 Replicas 中出现 broker-4,ISR 在追平前可能短暂为 2,追平后回到 3。broker-3 停止后,lab-orders 不受影响;kafka-metadata-quorum... describe --status 的 CurrentVoters 仍为 [1,2,3],但存活 voter 只剩 broker-1/2,仍满足 2/3 多数派,并显示一个 active controller。

重分配不会改变消息内容和分区内顺序,但会占用磁盘和网络;限速过高时 ISR 可能短暂下降,应暂停或降速,而不是降低副本因子。broker-4 停止后,每个分区的 ISR 变为 2,UnderReplicatedPartitions 增加;因为仍满足 min.insync.replicas=2,acks=all 写入成功。把 min ISR 改成 3 后,生产请求返回 NOT_ENOUGH_REPLICAS,消息没有写入 leader;恢复配置并重启 broker-4 后,ISR 回到 3,写入恢复,offset 单调增长。成功的判据是:quorum 稳定、无离线分区、目标 topic 的 ISR 等于 3、迁移前后 offset 连续、acks=all 在恢复后成功。失败判据是:controller 失去多数派、分区 leader 为 -1、ISR 长期低于 2、迁移卡住、消息键顺序或 offset 异常。

判断与定位

  1. 先看 kafka-topics --describe 的 Leader、Replicas、Isr 三列。Leader 为 -1 表示没有可用 leader;Isr 缺员表示副本没追上,不是单纯的“节点在线”。
  2. 用 --under-replicated-partitions 和 --under-min-isr-partitions 分别定位“副本不足”和“低于最小同步副本”的分区,两者含义不同。
  3. kafka-metadata-quorum.sh describe --status 看 active controller、voter 数量和 leader;KRaft 失去多数派时不能再做元数据变更,更不能强行制造第二个 controller。
  4. 重分配要看 --verify 的逐分区结果。限速过低会拖长窗口,过高会打满磁盘和网络;出现 ISR 下降时先降速或暂停,不要降低副本因子。
  5. 容易误判:broker 容器 Running 不代表副本已追平;ISR 等于 2 且 min ISR 为 2 时写入仍可成功,只有低于 min ISR 才拒绝;acks=all 失败也可能是超时或 leader 不可达,需要看 broker 错误码。
  6. 退役 controller 和退役普通 broker 风险不同。三 controller 集群停一台仍有 2/3 多数派,但此时再停一台就会失去 quorum;替换 controller 必须逐台加入、追平、再移除。
  7. offset 是分区内位置,不是全局时间。迁移前后要按分区比较 end offset,确认没有回退或跳变;消费者位点、保留策略和日志清理也可能让旧消息消失,不能用“能消费到一条”代替完整核对。

恢复与回滚

如果迁移后尚未发生大量新写入,回滚相对简单:先启动 broker-3,确认它仍持有旧数据目录;再把副本分配改回 [1,2,3],验证 ISR 恢复后再停止 broker-4。不要为了回滚删除 broker-4 的数据目录,也不要让两个节点使用同一 node ID。

若 min.insync.replicas 改错导致写入失败,立即改回 2 并确认 ISR 至少为 2;不要通过关闭 acks=all 或提高副本因子来掩盖问题。若 controller quorum 异常,停止后续变更,优先恢复网络和故障 controller,禁止用强制手段制造第二个 active controller。

回滚重分配同样要走 --execute 和 --verify,并保留原始 topic 描述和 offset 基线。回滚前还要确认 broker-3 的数据目录与元数据仍匹配原 cluster ID,且没有第二个进程使用同一 node ID;如果新写入已经覆盖旧副本,回滚应按一次新的迁移重新评估,而不是直接切回。只有在新拓扑通过验证、旧目录过了回滚窗口后,才按流程销毁旧数据:

cat > /tmp/lab-kafka-replace/rollback.json <<'EOF'
{"version":1,"partitions":[
 {"topic":"lab-orders","partition":0,"replicas":[1,2,3]},
 {"topic":"lab-orders","partition":1,"replicas":[2,3,1]},
 {"topic":"lab-orders","partition":2,"replicas":[3,1,2]}
]}
EOF
timeout 60 docker compose -f /tmp/lab-kafka-replace/docker-compose.yml start broker-3
timeout 120 docker cp /tmp/lab-kafka-replace/rollback.json lab-kafka-1:/tmp/rollback.json
# 仅在确认回滚时执行;执行前先核对 broker-3 已追平且没有未完成的重分配
timeout 120 docker exec lab-kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
  --bootstrap-server broker-1:9092 --reassignment-json-file /tmp/rollback.json \
  --execute --throttle 10485760
timeout 120 docker exec lab-kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
  --bootstrap-server broker-1:9092 --reassignment-json-file /tmp/rollback.json --verify
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server broker-1:9092 --describe --topic lab-orders
# 确认三个分区的 Replicas 回到 1/2/3 且 ISR 恢复为 3 后,再停止 broker-4
timeout 60 docker compose -f /tmp/lab-kafka-replace/docker-compose.yml stop broker-4

验证

timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-metadata-quorum.sh --bootstrap-server broker-1:9092 describe --status
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --topic lab-orders
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --under-replicated-partitions
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --under-min-isr-partitions
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server broker-1:9092 --topic lab-orders
timeout 30 docker exec lab-kafka-1 /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server broker-1:9092 --topic lab-orders --partition 0 \
  --offset earliest --max-messages 1 --timeout-ms 15000 \
  --property print.key=true --property print.timestamp=true

清理

全部验证通过后再清理本项目,不要对生产 Compose 项目执行 down -v。清理前确认回滚 JSON、topic 描述和 offset 记录已经复制到宿主机:

timeout 120 docker compose -f /tmp/lab-kafka-replace/docker-compose.yml down -v --remove-orphans
timeout 30 docker volume ls --filter name=lab-kafka-replace
rm -rf /tmp/lab-kafka-replace

复盘

相关命令

参考链接

读完自测

  1. 替换 broker 前,为什么要同时看副本因子、当前 ISR 和 min.insync.replicas?
  2. ISR 降到 2 时 acks=all 仍成功,把 min ISR 改成 3 后为什么立刻失败?消息真的写入了吗?
  3. 为什么重分配完成、broker 停止后仍不能马上删除旧数据目录?
答案
  1. 副本因子决定每个分区有几份数据,ISR 决定当前有几份可用;如果 ISR 已经等于 min ISR,再停一台 broker 就会让分区无法满足写入确认条件。必须先补齐副本或调整迁移顺序,不能靠降低 min ISR 或关闭 acks=all 绕过。
  2. 写入确认要求 ISR 数量不少于 min ISR。ISR 为 2、min ISR 为 2 时满足条件;改成 3 后条件不成立,broker 返回 NOT_ENOUGH_REPLICAS,消息没有成功追加到 leader,生产者应重试或失败。恢复 min ISR 后写入才会重新成功。
  3. 重分配完成只代表副本已经复制到新位置,客户端、备份、监控和回滚材料可能尚未收敛;旧目录是回滚和取证的依据。要等新拓扑通过 ISR、offset、消费和回滚窗口验证后,才能按流程销毁,且不能删除唯一可用的数据副本。
微信小程序码:扫码打开命令速查小程序

小程序版

微信扫码,随时查命令