分区是并行的最小单位
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 随手执行。 它影响的是整个组而不是单个消费者,误操作会导致全量重放或跳过一段数据,执行前先导出当前位点。
评论