一、offset提交三方案------自动、同步、异步的取舍边界
auto.commit是出事的根源。它的机制是消费者每隔auto.commit.interval.ms自动提交当前poll的最大offset,但提交的是"已拉取"的offset而非"已处理"的offset。消费者poll下来500条,处理到第200条时触发自动提交,提交了第500条的offset。此时如果崩溃,第201到500条直接丢失。反过来,如果处理完了但还没到提交间隔就rebalance,新消费者从上次提交点重新消费,201到500条重复处理。
commitSync是同步提交,调用consumer.commitSync()阻塞当前线程直到Broker返回成功。精确控制提交时机,处理完一批再提交,不丢消息。但阻塞消费线程,如果处理耗时100ms,每批次提交额外100ms开销。更要命的是max.poll.interval.ms超时风险------如果处理加提交总耗时超过这个值,消费者被踢出组触发rebalance,恶性循环。
commitAsync是异步提交,consumer.commitAsync(callback)发出提交请求后不阻塞,通过回调拿结果。吞吐量高,但异步提交可能乱序------第N批提交还没返回,第N+1批又提交了。如果第N批失败而第N+1批成功,offset跳过第N批,消息丢失。
生产推荐的混合提交模式,commitAsync跑常态提交,消费结束前用commitSync做一次兜底:
try {
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
processRecords(records);
consumer.commitAsync((offsets, exception) -> {
if (exception != null) {
log.warn("异步提交失败 offsets={} err={}", offsets, exception.getMessage());
}
});
}
} finally {
try {
consumer.commitSync();
} finally {
consumer.close();
}
}
commitAsync失败不重试,下一轮poll会再提交。finally里的commitSync确保关闭前最后一批offset落盘。这个模式我们用了两年,再没出过offset丢失的事故。
二、消费并行度------分区数×消费者数×消费线程的配比公式
积压的本质是消费速度跟不上生产速度。并行度公式很直接:消费组内消费者数不能超过分区数,超出的消费者空闲。单消费者内消费线程数,每个分区同一时刻只能被一个线程消费,所以有效消费线程数等于min(线程数, 分配到该消费者的分区数)。理论最大并行度就是分区数本身。实例:topic 16分区,部署4个消费者实例,每实例4线程等于16线程刚好打满16分区。
max.poll.records和max.poll.interval.ms是联动参数。max.poll.records是单次poll拉取的最大记录数,max.poll.interval.ms是两次poll之间最大间隔。联动逻辑:如果单条处理耗时50ms,max.poll.records=500,一批处理需25秒,远低于5分钟,安全。但如果max.poll.records调到5000,一批处理需250秒,逼近5分钟上限,有rebalance风险。
调优公式:max.poll.records × 单条处理耗时 < max.poll.interval.ms × 0.8,留20%安全余量。16分区场景下的推荐配置:
max.poll.records=1000 max.poll.interval.ms=600000 session.timeout.ms=30000 heartbeat.interval.ms=10000
session.timeout.ms是心跳超时,不要太短否则网络抖动误判。heartbeat.interval.ms是心跳间隔,设session.timeout的三分之一。扩分区用kafka-topics.sh --alter --bootstrap-server xxx:9092 --topic order-events --partitions 32,注意扩分区后key的分区路由会变化,有序性要求的场景需要评估影响。
三、消费幂等------重复消费的最后一道防线
无论offset怎么提交,rebalance场景下的重复消费无法100%避免。三种幂等方案按场景选型。
MySQL去重表方案,消费前往去重表插入消息ID,表上建唯一索引,INSERT IGNORE命中重复则跳过。配合业务表写入放同一事务,要么都成功要么都回滚。实测单机MySQL约3000到5000 TPS,适合金融、订单等强一致场景。
Redis SETNX方案,SET messageId 1 NX EX 86400返回OK表示首次消费,返回nil表示重复。纯内存操作单Redis实例约5到8万OPS,但TTL到期后有重复消费窗口,Redis宕机时SETNX丢失,不是强一致。适合日志、统计、监控等容忍少量重复的场景。
业务版本号乐观锁方案,消息携带版本号,消费时UPDATE table SET value=?, version=version+1 WHERE id=? AND version=?,返回影响行数0则跳过。无需额外去重表,但要求上游消息必须携带版本号,适用面窄,适合状态机更新如订单状态流转。
MySQL去重表方案代码,消费加去重加业务写入放同一事务:
@Transactional
public void consume(ConsumerRecord<String, String> record) {
String messageId = record.key();
int inserted = dedupMapper.insertIgnore(messageId);
if (inserted == 0) {
log.info("重复消息跳过 messageId={}", messageId);
return;
}
businessMapper.insert(parseRecord(record));
}
四、rebalance时的offset保全
rebalance是消费组最脆弱的时刻。消费者加入退出崩溃都会触发rebalance,期间分区被重新分配,offset没保存好要么重复消费要么跳过消息。
实现ConsumerRebalanceListener,在onPartitionsRevoked回调中提交当前处理完的offset,确保放弃分区前offset落盘:
consumer.subscribe(Collections.singletonList("order-events"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
consumer.commitSync();
log.info("分区收回前offset已提交 partitions={}", partitions);
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
log.info("分区已分配 partitions={}", partitions);
}
});
配合consumer.seek(topicPartition, offset)可实现从外部存储恢复offset,而非依赖Kafka内部__consumer_offsets topic。这是手动offset管理模式,适合对offset精确控制要求极高的场景。我们的报表消费组在加上rebalance监听器后,rebalance导致的重复消费从每次几千条降到0。
五、积压处理的6个策略清单
-
offset提交:commitAsync常态跑加commitSync关闭兜底,别用auto.commit
-
并行度公式:有效消费线程等于分区数,消费者数不超过分区数
-
参数联动:max.poll.records乘以单条耗时小于max.poll.interval.ms乘以0.8
-
幂等防线:金融用MySQL去重表,日志用Redis SETNX,状态机用乐观锁
-
rebalance保全:onPartitionsRevoked回调里commitSync,分区收回前落盘offset
-
极端积压应急:临时增加分区数加临时消费组从最新offset开始消费,放弃历史数据单独补