ElIauk

Kafka 分区与消费组

分区是并行的最小单位 Kafka 的一个 topic 由若干分区组成,分区才是有序的日志,topic 级别没有全局顺序。消息按 key 哈希到分区,同一个 key 永远进同一个分区,所以“同一订单的消息有序”是可以保证的,“所有消息全局有序”则要付出单分区的代价,吞吐直接砍到单线程水平。 分区数决定

评论
Kafka 分区与消费组

分区是并行的最小单位

Kafka 的一个 topic 由若干分区组成,分区才是有序的日志,topic 级别没有全局顺序。消息按 key 哈希到分区,同一个 key 永远进同一个分区,所以“同一订单的消息有序”是可以保证的,“所有消息全局有序”则要付出单分区的代价,吞吐直接砍到单线程水平。

分区数决定了两件事:并行度上限和顺序边界。消费者数量超过分区数时,多出来的消费者会一直空闲——这是排查“消费能力上不去”时最先要看的数字。

另一个常被忽略的后果是:增加分区会改变 key 到分区的映射。扩容后同一个 key 可能落到别的分区,如果此时还有旧消息在途,顺序性就被破坏了。所以分区数要在建 topic 时按未来一到两年的量规划,宁可一开始多给一些。

消费组与 rebalance

同一消费组内,一个分区只会被一个消费者消费;不同消费组各自维护位点,互不影响。这就是“一条消息被多个业务同时消费”的实现方式——不是复制消息,而是各自消费。

rebalance 是消费者成员变化时重新分配分区的过程。触发条件包括:消费者加入或退出、订阅的 topic 分区数变化、心跳超时、以及处理时间超过 max.poll.interval.ms 被踢出组。

rebalance 期间整个组会短暂停止消费,日志里能看到明显的消费延迟尖刺。两个优化手段:

  • 使用 CooperativeStickyAssignor,把“全部撤销再全部分配”改成增量协作,减少无谓的重复消费;
  • 配置 group.instance.id(静态成员),滚动重启时在会话超时内保留成员身份,避免每次发版都触发一轮 rebalance。

位点提交与至少一次

关掉自动提交,改用处理成功后手动提交,才能做到“至少一次”。自动提交的语义是“按时间间隔提交当前拉取位置”,消息还没处理完位点就可能已经提交,进程崩溃时那批消息就丢了。

手动提交意味着重复是常态,消费端必须幂等。最可靠的做法是唯一索引加捕获重复键异常,而不是“先查再插”——那在并发下同样有竞态。

@KafkaListener(topics = "order-created", concurrency = "6")
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
    try {
        settleService.handle(record.key(), record.value());
        ack.acknowledge();
    } catch (DuplicateKeyException e) {
        // 幂等表拦截,说明这条消息之前已经处理过,直接确认
        log.warn("重复消息已跳过, key={}, offset={}", record.key(), record.offset());
        ack.acknowledge();
    } catch (Exception e) {
        log.error("结算处理失败, key={}, offset={}", record.key(), record.offset(), e);
        // 抛出去交给容器的错误处理器重试,重试耗尽后进入死信
        throw e;
    }
}

不要在这里 catch 之后只打日志不抛,那等于把消息吞掉,位点照样提交,消息永久丢失。

一份消费者配置

spring:
  kafka:
    bootstrap-servers: kafka-0.kafka:9092,kafka-1.kafka:9092,kafka-2.kafka:9092
    consumer:
      group-id: order-settle
      enable-auto-commit: false
      auto-offset-reset: earliest
      max-poll-records: 200
      properties:
        max.poll.interval.ms: 300000
        session.timeout.ms: 45000
        heartbeat.interval.ms: 3000
        partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
        group.instance.id: order-settle-${HOSTNAME}
    listener:
      ack-mode: manual_immediate
      concurrency: 6

这里的关系要算清楚:max-poll-records × 单条处理耗时 必须明显小于 max.poll.interval.ms。200 条、每条 1 秒,就是 200 秒,已经逼近 300 秒的上限,一次 GC 或下游抖动就会把消费者踢出组,引发 rebalance 风暴,然后消息被重复消费,形成恶性循环。

concurrency: 6 要和分区数匹配,6 个消费者线程在 3 个分区的 topic 上有一半是闲着的。

排查步骤

消费堆积时,按“是不是消费不动”和“是不是分区不够”两条线查:

# 1. 看每个分区的堆积量与消费者分配情况
kafka-consumer-groups.sh --bootstrap-server kafka-0.kafka:9092 \
  --describe --group order-settle

# 2. 看 topic 的分区数、副本与 ISR
kafka-topics.sh --bootstrap-server kafka-0.kafka:9092 \
  --describe --topic order-created

# 3. 确认没有消费者卡在 rebalance(ASSIGNING 状态反复出现)
kubectl logs -n shop-prod deploy/order-settle --since=10m | grep -i rebalance

# 4. 紧急跳过坏消息(影响整个组,务必先确认再执行)
kafka-consumer-groups.sh --bootstrap-server kafka-0.kafka:9092 \
  --group order-settle --reset-offsets --shift-by 1 \
  --topic order-created --execute

LAG 持续增长但消费者进程 CPU 不高,通常是下游变慢;LAG 忽高忽低且日志里反复 rebalance,通常是 max.poll.interval.ms 太小;只有个别分区堆积,八成是 key 设计导致数据倾斜。

常见坑

消费者数超过分区数。 多出来的实例白占资源,扩容完全无效。

key 为 null。 空 key 会轮询分区,同一订单的消息散落在多个分区,顺序性彻底消失。

自动提交加异步处理。 位点先提交、处理后失败,消息就丢了。要么同步处理再提交,要么用本地消息表兜底。

auto-offset-reset 用 latest。 新消费组上线时,部署到开始消费之间的消息全部跳过,看起来像“消息丢了”。

多个环境的消费组同名。 测试环境的消费者会和生产共享位点,互相抢分区,这在共用集群时特别常见,组名一定要带环境前缀。

--reset-offsets 随手执行。 它影响的是整个组而不是单个消费者,误操作会导致全量重放或跳过一段数据,执行前先导出当前位点。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者