在单节点 KRaft Kafka 中杀死消费者组成员,观察会话超时、Rebalance 与分区重新分配

演练目标

环境准备

隔离边界:只使用本机 Docker Compose 的 lab-kafka 项目和 127.0.0.1:9092,不要连接或修改任何生产 Kafka 集群。需要 Docker Compose v2,预留约 1 GB 内存。

docker version --format '{{.ServerVersion}}' && docker compose version
mkdir -p /tmp/lab-kafka
cat > /tmp/lab-kafka/docker-compose.yml <<'EOF'
name: lab-kafka
services:
  kafka:
    image: apache/kafka:3.7.0
    container_name: lab-kafka
    ports: ["127.0.0.1:9092:9092"]
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://127.0.0.1:9092
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
EOF
docker compose -f /tmp/lab-kafka/docker-compose.yml up -d
for i in $(seq 1 60); do
  docker exec lab-kafka /opt/kafka/bin/kafka-topics.sh \
    --bootstrap-server localhost:9092 --list >/dev/null 2>&1 && break
  sleep 2
done

第一次启动 KRaft 需要初始化存储并拉取镜像,等待循环最多约 2 分钟。

注入故障

先建 3 分区 topic,再启动两个同组消费者;c1 会话超时 30 秒,c2 为 10 秒。

docker exec lab-kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 \
  --create --if-not-exists --topic lab-rebalance --partitions 3 --replication-factor 1

docker exec -d lab-kafka sh -c 'echo $$ > /tmp/c1.pid; exec /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic lab-rebalance --group lab-group --consumer-property session.timeout.ms=30000 --consumer-property max.poll.interval.ms=30000 > /tmp/c1.log 2>&1'
docker exec -d lab-kafka sh -c 'echo $$ > /tmp/c2.pid; exec /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic lab-rebalance --group lab-group --consumer-property session.timeout.ms=10000 --consumer-property max.poll.interval.ms=30000 > /tmp/c2.log 2>&1'
sleep 8
docker exec lab-kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group lab-group --members --verbose

确认两个成员分别拿到分区后,直接杀死 c2 进程,模拟没有 LeaveGroup 的崩溃:

docker exec lab-kafka sh -c 'kill -9 $(cat /tmp/c2.pid)'
sleep 3
docker exec lab-kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group lab-group --members --verbose
sleep 12
docker exec lab-kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group lab-group --members --verbose

观察现象

docker exec lab-kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group lab-group --state
docker exec lab-kafka sh -c 'tail -20 /tmp/c1.log'

预期初始两个成员分别持有 2 个和 1 个分区。c2 被杀后 3 秒,group 仍可能显示 Stable 且 c2 仍挂着分区,这是 broker 还没等到会话超时,属于“看起来没变化”的窗口。约 10 秒后 group 经历 PreparingRebalance/CompletingRebalance,最终 c1 接管全部 3 个分区;c1 日志出现再次入组和 assignment 变化的记录。只杀进程不走 LeaveGroup 时,分区不会立即释放。

判断与定位

  1. 先写假设:新成员加入、成员主动离开、进程崩溃后会话超时、max.poll.interval.ms 超限,还是消费停滞但成员仍存活。不同原因对应不同的 group 状态和成员列表。
  2. 用 --describe --members --verbose 看成员数、每个成员的分配和 group 状态;用 --state 确认是否处于 PreparingRebalance/CompletingRebalance。
  3. 看消费者日志中的 JoinGroup、LeaveGroup、RejoinGroup、Heartbeat。kill -9 不会发送 LeaveGroup,必须等 session.timeout.ms 到期;正常关闭会更快触发 rebalance。
  4. 如果成员还在但分区不消费,检查 max.poll.interval.ms 和单次 poll 的处理耗时。超过上限时客户端会离开组并重新加入,日志常见 MAX_POLL_EXCEEDED 或 rebalance 记录。
  5. 容易误判:看到 group Stable 就认为消费者健康。成员可能因心跳线程还在而保持存活,但业务线程已经卡住;反过来,Rebalance 期间短暂 PreparingRebalance 并不一定是故障。另一个误判是认为分区数固定就一定能均匀分配,成员数多于分区数时必然有人分不到。
  6. 只有单个消费者时,所有分区都在它手上;成员增加后原消费者会让出一部分分区,这是正常再平衡,不是故障。

修复

重启被杀的消费者,让它重新加入组:

docker exec -d lab-kafka sh -c 'echo $$ > /tmp/c2.pid; exec /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic lab-rebalance --group lab-group --consumer-property session.timeout.ms=10000 --consumer-property max.poll.interval.ms=30000 > /tmp/c2.log 2>&1'
sleep 10
docker exec lab-kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group lab-group --members --verbose

预期成员数恢复为 2,分区重新分配为 2/1。生产中要保证消费者优雅停机(先停止 poll、提交 offset、发送 LeaveGroup),并根据处理耗时设置 max.poll.interval.ms,避免频繁 rebalance。

清理与回滚

docker exec lab-kafka sh -c 'kill -9 $(cat /tmp/c1.pid) $(cat /tmp/c2.pid) 2>/dev/null || true'
docker compose -f /tmp/lab-kafka/docker-compose.yml down -v --remove-orphans
rm -rf /tmp/lab-kafka

只删除本实验的 Compose 项目和本地数据卷;不要对生产 Kafka 执行 kill 或删除 topic。

复盘

延伸阅读

读完自测

  1. 消费者被 kill -9 后,为什么 group 不会立即 rebalance?
  2. session.timeout.ms 与 max.poll.interval.ms 分别约束什么?
  3. 成员数多于分区数时,多出来的消费者会怎样?
答案
  1. 进程来不及发送 LeaveGroup,broker 要等该成员的会话超时后才把它移出组并触发 rebalance。
  2. 前者约束心跳/会话存活时间,后者约束两次 poll 之间的最大处理时间;超限都会导致成员离开组。
  3. 它们分不到分区,处于空闲状态,但仍然是 group 成员并参与后续 rebalance。