前阵子帮某项目做技术升级,下单链路里有几步同步调用,高峰期每次请求都可能被下游拖到超时,线程池很快就堵满了。大家聊到解决方案时,几乎一致同意引入消息队列,而选型清单里排在最前面的就是Apache Kafka。Spring整合Kafka这个工程,从我接触过的团队看,基本是微服务拆分绕不开的一环。它做的事情并不复杂:把Spring Boot应用与Kafka集群连接起来,让生产者稳定地发消息、消费者可靠地收消息。它的价值在于解耦、削峰和异步化。这篇文章适合有Spring Boot基础、刚把Kafka装起来或者正在准备上生产环境的Java后端开发者,不管你是想用消息队列解决服务间同步调用的问题,还是已经被消费组、offset、重平衡这些概念折腾过,下面这些内容应该都能帮上忙。
1. 整体设计思路与方案选型
1.1 为什么在技术栈里选Kafka
面对消息队列选型,大家通常会在RabbitMQ、RocketMQ和Kafka之间纠结。我的习惯是先看场景:如果业务偏传统企业应用,消息量不大、要求路由灵活,RabbitMQ更顺手;如果团队在大规模数据管道、日志采集、事件驱动架构里面,Kafka的优势非常明显——高吞吐、数据持久化、可重放。有一个比喻可能不太严谨但很贴近:RabbitMQ像邮局,每封信按路由规则送出去,送完就没影了;Kafka像一卷带刻度的磁带,消息按offset连续写下去,谁都可以从某个刻度重新听一遍。对于需要回溯问题的团队,这个“可重放”能力极其宝贵。Kafka单分区顺序写,多分区并行,整体吞吐量可以支撑很高的业务峰值,这是它能在Spring生态里流行起来最根本的原因。
从成本角度想,磁盘顺序写在Kafka里被用到了极致,它的写入路径对机械键盘和SSD都很友好,不需要像传统数据库那样频繁随机寻道。所以即便一个Broker所在节点磁盘不算特别好,写消息的吞吐也能做到很高。做技术选型时,我会重点确认团队是否已经有Kafka运维经验。如果只是为了一两个简单业务引入一套独立的消息系统,则要考虑额外维护成本,Kafka虽然强大,它需要关注的节点状态、副本同步、分区平衡这些事并不少。我见过不少小团队因为RabbitMQ上量后扛不住才换成Kafka,也见过用了Kafka但业务量极低,最后发现维护成本反而比收益高。结论还是那句话:先想清楚场景,再选中间件。
1.2 Spring Kafka包装了什么
如果直接用Kafka原生客户端,你要自己管理Producer、Consumer,要处理连接池、回调、线程模型,大量代码和业务逻辑缠在一起。Spring Kafka做了一套声明式封装:生产者端提供KafkaTemplate,消费者端提供@KafkaListener注解,配合Spring Boot的自动配置,很多底层参数只要写进application.yml就能生效。你可以把这个封装理解成Spring对JDBC做的事情:原生JDBC能跑,但没人愿意天天手工签Connection、处理异常、释放资源。Spring Kafka到了spring-kafka版本3.x之后,和Spring Boot 3.x搭配,整体API更干净,类型化监听、死信主题、事务支持这些能力都已经比较成熟。
新项目我建议直接引入spring-boot-starter依赖,而不是单独引spring-kafka再手工配置一堆Bean,后者容易漏配默认行为。用starter的好处是版本号由Spring Boot父工程统一管理,你不会碰到Kafka客户端版本和Spring Kafka内部依赖互相打架的怪问题。如果你负责的是老项目,要注意Spring Boot和Spring Kafka的版本对应关系。我整理过一张常用对应表,Spring Boot 2.6.x对应Spring Kafka 2.7.x或2.8.x;Spring Boot 2.7.x对应Spring Kafka 2.8.x;Spring Boot 3.0.x对应Spring Kafka 3.0.x;Spring Boot 3.1.x对应Spring Kafka 3.1.x。实际以官方依赖管理为准,不要自己指定一个很新的Kafka客户端版本然后去配很老的Spring Kafka,那样很容易踩到API移除的坑。
1.3 Topic与分区策略是整合前最该想清楚的事
很多同学把Spring整合Kafka当成单纯的接口调用,装好集群就开始撸代码,结果上线后发现主题设计乱成一团。我建议动手前先定一套约定:topic一律采用“领域.事件”的命名格式,比如order.event.created、inventory.event.changed。命名的好处在于,后期运维和排查问题时能一眼看出这条消息属于哪个业务域,而不会出现orders、order_create、orderevent这种乱七八糟的版本。其次是分区数的评估,不要一上来就设64个分区,分区数决定消费并行度上限,但分区太多也会增加Broker上的文件句柄和副本同步压力。合理做法是根据预期的峰值流量和单分区吞吐来估算,再留30%左右的余量。
分区数的另一个问题是后期调整成本。Kafka支持增加分区,但是增加分区后,原先同一个key到分区的映射会被打乱,如果你依赖相同key保证消息顺序,这时候顺序就会出问题。所以早期设计时要认真评估,不要有“先跑起来后面再扩容分区”的心态。副本数方面,本地开发可以设为1,生产环境至少3。Kafka从3.0版本开始支持KRaft模式,不再需要独立的Zookeeper集群,本地单机开发比过去轻量很多,这个在下一节展开讲。总之,主题设计和分区规划属于前置设计,不花点时间想清楚,后面在Spring代码里怎么调都没法弥补。
2. 环境准备与依赖引入
2.1 本地环境搭建:从Zookeeper到KRaft模式
如果你照着老教程操作,第一件事是先启动Zookeeper,再启动Kafka。但在新版本里,Kafka已经支持KRaft模式,把元数据直接放在Kafka内部,单机开发省掉了一道工序。我习惯用KRaft模式做本地环境,步骤大致是:下载二进制包并解压,用内置脚本生成一个UUID作为集群ID,执行格式化命令,再启动服务端。以较新的3.x版本为例,命令如下:
export KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)" bin/kafka-storage.sh format -t $KAFKA_CLUSTER_ID -c config/kraft/server.properties bin/kafka-server-start.sh config/kraft/server.properties这里有一个新人容易忽略的点:如果之前已经格式化过,再次format会提示数据目录非空,需要换一个新的数据目录或者清掉日志目录。生成集群ID的random-uuid命令每次执行都会产生新值,不要格式化后把变量丢了,否则后续启动会因为元数据标识不匹配而报错。启动完成后,创建主题的命令也很简单:
bin/kafka-topics.sh --create --topic order.event.created \ --partitions 3 --replication-factor 1 \ --bootstrap-server localhost:9092本地开发环境replication-factor设为1没问题,因为单节点Broker没法满足3副本要求。生产环境建议分区数根据业务评估,副本数设3。启动之后,可以用describe命令查看分区和副本分布,也能确认集群是否正常。这一步属于基本功,建议所有准备在Spring里写Kafka代码的同学先把命令行跑熟。
2.2 在Spring Boot工程里加入依赖
Spring Boot工程加依赖很简单,如果你用初始化工具建项目,直接勾选Spring for Apache Kafka即可。手工添加时,pom.xml里放上这段就行:
<dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId> </dependency>如果不加版本号,就由Spring Boot父工程统一管理。我做老项目升级时吃过一个亏:以为Kafka客户端版本越高越好,手动把spring-kafka换成新的,结果和项目里Spring Boot 2.7的自动配置对不上,启动后KafkaTemplate的Bean匹配总是失败。后来把版本交给父工程管理,问题就消失了。如果是Spring Boot版本很老的项目,建议先评估升级成本,而不是单独升级Spring Kafka模块。相关的日志依赖不需要额外引入,spring-kafka会传递依赖kafka-clients,但如果你需要依赖kafka-clients中的某些类做手工补偿,也可以显式声明。
2.3 最值得写进application.yml的配置项
在Spring Boot里,大部分Kafka配置都可以直接写在application.yml里,下面是我常用的基础配置:
spring: kafka: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer acks: all retries: 3 linger-ms: 5 consumer: group-id: order-service-group auto-offset-reset: earliest enable-auto-commit: false key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer几个配置项需要多说几句。acks=all表示消息要写入leader分区并等待ISR列表中的副本都确认后才算成功,能避免leader写完后立刻宕机导致数据丢失。retries=3是允许发送端的瞬时网络异常重试,但要注意它和幂等生产者是两回事,下面生产者章节会再展开。linger-ms=5表示消息在发送缓冲区攒5毫秒再发出,用来提高吞吐,代价是增加少量延迟。auto-offset-reset=earliest是让新消费组从最早可用offset开始消费,如果设为latest,新上线的消费者可能丢掉历史消息。enable-auto-commit=false配合手动ack,是避免消息实际上还没处理完就把offset提交掉的关键配置。另外生产中经常需要调consumer properties下的max-poll-records、max-poll-interval-ms这类参数,后面排查章节会说。
3. 核心环节实现:生产者端
3.1 KafkaTemplate的装配与关键参数
Spring Boot自动配置会帮你创建KafkaTemplate,但如果对默认参数不满意,建议显式定义ProducerFactory。比如你要发业务对象,而不是纯String,就需要把value-serializer换成JsonSerializer。下面是我在实际项目里常用的一段配置:
@Configuration public class KafkaProducerConfig { @Bean public ProducerFactory<String, Object> producerFactory() { Map<String, Object> props = new HashMap<>(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); props.put(ProducerConfig.ACKS_CONFIG, "all"); props.put(ProducerConfig.RETRIES_CONFIG, 3); props.put(ProducerConfig.LINGER_MS_CONFIG, 5); props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); return new DefaultKafkaProducerFactory<>(props); } @Bean public KafkaTemplate<String, Object> kafkaTemplate() { return new KafkaTemplate<>(producerFactory()); } }value用Object类型是为了让KafkaTemplate可以兼容不同业务事件。JsonSerializer会把对象序列化成JSON字节,在消息header里写入类型信息,消费者端用JsonDeserializer可以还原。生产环境我建议发业务对象时不要手工把对象转成JSON字符串再拼给Kafka,少一道手工转换就少一处踩坑。多语言环境或者跨团队对接时,需要考虑更严格的序列化方案,比如Avro加Schema注册中心,这点后面还会提。EnableIdempotence=true很重要,它让producer为每条消息生成序列号,即便发送过程中出现网络重试,Broker也可以去重,避免因重试而引发的重复消息。
3.2 发送消息的几种写法要分清
KafkaTemplate提供的发送方法主要分几种:直接send到topic、带key发送、带分区号发送、发送Message对象、事务内发送。代码上看就是:
ListenableFuture<SendResult<String, Object>> f1 = kafkaTemplate.send("order.event.created", orderEvent); ListenableFuture<SendResult<String, Object>> f2 = kafkaTemplate.send("order.event.created", orderEvent.getOrderId(), orderEvent); ListenableFuture<SendResult<String, Object>> f3 = kafkaTemplate.send("order.event.created", 0, orderEvent.getOrderId(), orderEvent); kafkaTemplate.executeInTransaction(status -> kafkaTemplate.send("order.event.created", orderEvent));第一种不带key,消息会按照Kafka分区器的粘性分区策略落到某个分区,整体分布比较均匀,但同一个业务主体的消息可能跑到不同分区,顺序性无法保证。第二种带key,相同key的消息经过哈希后一定进入同一个分区,这是保证同一订单事件顺序的前提。第三种指定分区号,除非你对分区归属有强约束,否则不建议,因为人为指定容易让分区数据倾斜。executeInTransaction会把发送放进Kafka事务,适合和业务处理一起构成原子操作。我的实际建议是:大部分业务场景用第二种,key取业务唯一标识,比如订单ID、用户ID,既能保证单个主体的顺序,又能兼顾消费者端的并行扩展。
3.3 发送失败不能只靠等,必须走回调
KafkaTemplate.send返回的是ListenableFuture,你可以挂回调来处理结果。很多同学写完send后就不管了,这样消息丢了都不知道。我的代码里通常这样处理:
ListenableFuture<SendResult<String, Object>> future = kafkaTemplate.send("order.event.created", orderEvent.getOrderId(), orderEvent); future.addCallback(result -> { RecordMetadata meta = result.getRecordMetadata(); log.info("消息发送成功,topic={},partition={},offset={}", meta.topic(), meta.partition(), meta.offset()); }, ex -> { log.error("消息发送失败,orderId={}", orderEvent.getOrderId(), ex); retryOrderEventMapper.insert(toRetryRecord(orderEvent)); });这里要澄清一个关键点:KafkaProducer的retries参数只针对可重试的瞬时错误,比如网络抖动、leader切换。它不会在业务回调失败后帮你重发。所以真正的兜底方案是:发送失败后把消息落库,由定时任务或者后续补偿流程重新投递。我看到过一段生产事故,某团队把发送日志打在了回调的ERROR分支,但没做落库,Kafka集群短暂抖动期间所有消息都丢了,日志文件只能证明“确实失败过”,却无法恢复数据。所以强调一句:生产者的失败处理一定要有可持久化的兜底机制,否则别上生产。
4. 核心环节实现:消费者端
4.1 用@KafkaListener写一个可靠消费者
消费者端最核心的注解是@KafkaListener。你只需要在方法上指定topic和groupId,Spring Kafka会为它创建监听容器。一个最简单但相对稳妥的写法如下:
@Component public class OrderEventListener { private static final Logger log = LoggerFactory.getLogger(OrderEventListener.class); @KafkaListener(topics = "order.event.created", groupId = "order-service-group") public void onOrderCreated(OrderCreatedEvent event, Acknowledgment acknowledgment) { log.info("收到订单消息,orderId={}", event.getOrderId()); try { orderApplicationService.handle(event); acknowledgment.acknowledge(); } catch (Exception e) { log.error("处理订单消息失败,orderId={}", event.getOrderId(), e); throw e; } } }注意方法的执行顺序:业务处理成功后再ack,顺序一定不能反。如果先ack再处理业务,处理抛异常时offset已经提交,这条消息就相当于“永久丢失”了。如果不ack并抛出异常,容器会在后续poll时把这条消息再次投递给监听器,形成重试。Spring Kafka内部对异常消息的处理策略比较灵活,但基础前提是理解offset提交和业务操作的先后关系。这个方法会默认放在ConcurrentMessageListenerContainer管理的线程里执行,容器线程数和Kafka分区数的关系,下一小节细说。
4.2 消费者并发数由分区数决定
@KafkaListener可以通过concurrency属性控制并发消费线程数。比如:
@KafkaListener(topics = "order.event.created", groupId = "order-service-group", concurrency = "3")这里要强调一个容易踩坑的点:concurrency并不是越大越好。topic的分区数是消费并行度的上限,如果topic只有3个分区,你设置concurrency=5,最终也只有3个线程真正在消费,另外两个线程闲置。如果设置concurrency=1,那么无论你有几个分区,同一时刻只有一个消费者在处理消息。原因非常简单:Kafka里同一个分区同一时刻只允许被同一消费组内的一个消费者线程持有。如果你把一个消费组部署多个实例,分区会分摊到不同实例上,还是由分区总数决定并行上限。
所以在设计阶段,分区数要提前匹配你的处理能力。假设单个订单消息处理耗时50毫秒,单分区每秒最多只能处理20条,如果一个topic高峰期每秒有100条订单事件,你至少需要5个消费者线程,对应至少5个分区。同样的逻辑也解释了为什么不建议把分区数设太多:分区数上升,消费组重平衡和Broker文件句柄都会增加,得不偿失。项目里最合理的做法是先压测单分区消费能力,再反推分区数。
4.3 手动提交Offset的正确姿势
前面提到enable-auto-commit=false和Acknowledgment手动确认。手动提交的真正好处是把“消息处理成功”和“提交offset”绑定到同一个节点。你可以在方法签名里加Acknowledgment参数,也可以直接用ConsumerRecord拿到offset自己提交。我推荐用Acknowledgment,因为Spring Kafka会替你把分区事务细节处理好。
@KafkaListener(topics = "order.event.created", groupId = "order-service-group") public void handle(String message, Acknowledgment ack) { try { process(message); ack.acknowledge(); } catch (Exception e) { log.error("handle failed", e); // 抛出异常让容器触发重试或死信策略 throw e; } }从Spring Kafka 2.7开始,Acknowledgment接口还提供了nack方法,可以指定等待时长后将该消息重新投递。这在某些场景比直接抛异常更灵活。手动ack有一个附带注意点:如果业务处理完成后、ack执行前JVM崩溃,offset没来得及提交,消费者重启后会从旧offset重新读取消息,这可能引发重复消费。这是“至少一次”语义的天然代价,不能完全避免。所以消费端一定要为重复消费做好幂等设计,这个主题后面再展开。
4.4 JsonDeserializer的类型信息与容器工厂配置
生产端用JsonSerializer,消费端就需要用JsonDeserializer来还原对象。配置文件里的value-deserializer要改成JsonDeserializer:
spring: kafka: consumer: value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializerSpring Kafka的JsonDeserializer默认会从消息header里读取类型信息,把它转换成@KafkaListener方法参数上声明的类型。如果你在多个包都有同名类,或者消息发送端和消费端类路径不一致,就可能会报找不到类或者类型不匹配。这种情况下建议在yml里显式指定trusted packages:
spring: kafka: consumer: properties: spring.json.trusted.packages: "com.example.project"如果不配trusted packages,默认只信任某些基础包,自定义业务包会被拒绝。另一个常见问题是:跨服务共用topic时,如果生产端发送的是字符串,而消费端期望JsonDeserializer,就会反序列化失败。所以团队里要统一序列化协议,要么全部String,要么全部Json,最好不要混用。要是跨语言或者跨大团队,建议上Avro加Schema注册中心,一致性和兼容性会更可控。
5. 可靠性设计:重试、死信与事务
5.1 默认的错误重试未必符合业务预期
@KafkaListener方法抛异常后,如果不做任何配置,Spring Kafka会把当前poll批次的消息不断重新投递,默认情况下几乎是无限重试的。这在业务上可能是灾难:一条坏消息占住线程,后面排队的正常消息全被卡住,日志里刷满堆栈。所以我建议所有项目都显式配置DefaultErrorHandler:
@Bean public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate<String, Object> kafkaTemplate) { DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate); return new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3)); }FixedBackOff的参数含义是:每次重试间隔1秒,最多重试3次。超过3次后,交给DeadLetterPublishingRecoverer处理,它会把失败消息投递到原topic加.DLT后缀的死信主题,也就是order.event.created.DLT。这样既不会无限阻塞消费者,也能把问题消息留给人去对比和补偿。如果不想用死信主题,也可以在构造DefaultErrorHandler时传一个自定义BiConsumer做其他恢复动作,比如记录异常表然后发送告警。核心思路是:重试次数要有上限,最终兜底路径必须存在。
5.2 死信主题的消费与人工补偿通道
死信主题里不是只有原始消息,Spring Kafka还会把相关异常信息写进头部的EXCEPTION_STACKTRACE和EXCEPTION_MESSAGE字段。你可以单独写一个监听器来处理死信消息:
@KafkaListener(topics = "order.event.created.DLT", groupId = "order-dlt-group") public void onDlt(OrderCreatedEvent event, ConsumerRecordMetadata meta) { log.warn("收到死信消息,topic={},partition={},offset={},error={}", meta.topic(), meta.partition(), meta.offset(), ...); // 转储到专门的补偿表,或者告警人工介入 }死信主题的设计不是简单“收起来不管”。我见过一个团队因为死信消费监听器里又抛异常,结果死信主题的消息继续进入死信循环,最终把磁盘空间吃满。正确做法是把死信消息转存到业务补偿表,持久化关键信息,然后触发告警。如果需要重放,再通过运维工具从死信主题重新生产一个副本到目标topic。换句话说,死信环节要设计成“可观测、可人工干预”,而不是让它一直在Kafka里转圈。
5.3 事务性消息:让发送和本地业务尽量一致
Spring Kafka支持Kafka事务,可以和业务方法一起开启事务。基本写法是:
@Transactional public void createOrderAndSendEvent(Order order) { orderRepository.insert(order); kafkaTemplate.send("order.event.created", order.getId().toString(), toEvent(order)); }要让这个写法真正生效,需要为ProducerFactory配置transactionIdPrefix,并声明KafkaTransactionManager:
@Bean public ProducerFactory<String, Object> producerFactory() { DefaultKafkaProducerFactory<String, Object> factory = new DefaultKafkaProducerFactory<>(props()); factory.setTransactionIdPrefix("order-tx-"); return factory; } @Bean public KafkaTransactionManager<String, Object> kafkaTransactionManager( ProducerFactory<String, Object> producerFactory) { return new KafkaTransactionManager<>(producerFactory); }消费者端要读到事务内提交的消息,还需要把消费端的隔离级别设为read_committed:
spring: kafka: consumer: properties: isolation.level: read_committed这里必须泼一盆冷水:Kafka事务解决的是“多个Kafka消息之间的原子性”,或者配合@Transactional让本地数据库事务与Kafka发送一起回滚,但这不等于分布式事务。数据库提交成功但Kafka发送超时,或者反过来的情况在复杂网络环境下仍有可能出现。我曾经在某个项目里因为迷信这个方案,把数据库事务和Kafka事务绑定在一起,结果业务方法内部还调了外部接口,外部接口超时导致整个事务回滚,本地数据也回滚了,造成大量重复调用。后来改为本地消息表加定时任务补偿,反而更稳定。所以,能用简单补偿机制解决的场景,不建议强行用事务套住全部流程。
5.4 幂等消费才是真正的兜底
无论Kafka提供的可靠性有多强,消费端最终要面对的是“至少一次”的投递语义。重复消费几乎是不可避免的,比如消费者处理完消息后没来得及提交offset就宕机,重启后同一条消息会再次投递。你要做的不是强行干掉重复消息,而是在业务侧设计幂等。常见的做法是给每条业务消息一个全局唯一ID,处理时先查redis或者数据库,如果已经处理过就跳过。
@Transactional public void process(OrderCreatedEvent event) { int rows = jdbcTemplate.update( "insert ignore into consumed_event(event_id, handle_status) values (?, ?)", event.getEventId(), "DONE"); if (rows == 0) { return; } // 真正的业务处理 }这里只是示意,实际项目里可以用唯一索引加insert冲突判断。幂等设计是整个消息链路里我最看重的一点,它能让很多极端情况变得可恢复。如果连幂等都没有,就算Kafka配置再完美,赶上一次重平衡或者手动提交offset的抖动,你依然会发现数据库里出现了重复数据。
6. 常见问题与排查技巧实录
6.1 消息“丢了”的排查顺序
消息丢失是使用Kafka最常见的痛点,按我的经验,排查顺序应该从生产端开始,逐段往消费端走。可以套用下面这张表:
| 现象 | 检查点 | 常见原因 |
|---|---|---|
| 生产端没收到发送成功回调 | producer配置和日志 | acks=0或未处理发送异常 |
| 消费者没收到消息 | bootstrap-servers、网络 | 集群地址配置错误 |
| 消费者日志里没有处理记录 | 消费组首次启动时的offset | auto-offset-reset设为latest且丢历史消息 |
| 消费端好像处理了但没效果 | 监听器异常被吞掉 | catch里只打印日志不抛出异常 |
最常见的一个坑是catch住异常后没有重新抛出,也没有调用ack,结果消息处理失败但业务层还以为成功了。我用一个简单原则:消费端的异常要么抛出交给容器重试,要么走死信流程,绝不要在业务方法里只打印一行error日志然后继续执行。否则这条消息从Kafka视角看已经被消费了,但你的业务数据是错的。
6.2 重复消费可能不是“Kafka的问题”
想排查重复消费,先看消费组offset提交情况。命令是:
bin/kafka-consumer-groups.sh --describe \ --group order-service-group \ --bootstrap-server localhost:9092输出里会看到每个分区的CURRENT-OFFSET、LOG-END-OFFSET和LAG。如果LAG不为0,说明消费者还有消息没消费完。当消费组发生重平衡或者消费者重启时,未提交的offset会让部分分区消息重新投递。这不一定代表Kafka异常,更可能是业务处理时间超过了预期,导致poll和offset提交节奏没跟上。
针对重复消费,除了幂等设计外,我还会建议把所有消费逻辑尽量做得轻快,不要把大批量任务放在监听器线程里同步跑。耗时操作可以考虑异步化,或者把消息转存到本地任务表再走任务线程。实际调参上,如果单条消息处理耗时偶尔会超过30秒,可以适当调大max.poll.interval.ms,避免消费组因为“长时间没有poll”判定消费者失联并触发重平衡。
6.3 消费组频繁Rebalance的处理思路
消费者频繁掉线、加入,组内分区不停重新分配,是很多团队会遇到的性能杀手。原因通常有两个方向:一个是session.timeout.ms设得太小,消费者短暂卡顿就被认为是心跳超时;另一个是max.poll.interval.ms设得太小,消费者处理批量消息花费时间太长,还没来得及poll就被判定为“还活着但不拉取”。我常用的调整思路是:
spring: kafka: consumer: properties: session.timeout.ms: 45000 max.poll.interval.ms: 300000 max.poll.records: 200把max.poll.records从默认500降到200,可以让单次处理时间更紧凑,避免单批消息过多导致处理时长超过max.poll.interval。同时把session.timeout.ms和max.poll.interval.ms都调大,给消费者更多的缓冲时间。但这个调参不是越大越好,主动扩缩容时的重平衡响应速度会变慢。如果你有“快速扩容消费者实例”的需求,需要在重平衡灵敏度和稳定性之间找平衡。
6.4 序列化和类型匹配的踩坑记录
说一个我实际遇到过的场景:A服务用StringSerializer发送了一条JSON字符串,B服务用JsonDeserializer去消费,结果启动时不报错,但一处理消息就抛ClassCastException。原因在于JsonDeserializer想把JSON反序列化成方法参数声明的类型,但消息里没有类型头,只能猜测。另一种情况是StringProducer加StringConsumer,发的时候没问题,但消费端看到的中文变成乱码,这是字符编码或序列化器配置不一致造成的。
跨团队协作时,序列化协议一定要在接口文档里写死。最简单的约定是生产端和消费端全部使用StringSerializer和StringDeserializer,消息体统一JSON字符串,代码里再做一次手工反序列化。这种方式最直观、最容易排查。如果系统规模上来,再考虑Avro或者Protobuf,配合统一Schema管理。不要在一个topic上混用多种序列化方式,那会变成排查成本无限放大的温床。
经验心得:把兜底手段做在代码层面
Spring整合Kafka,代码层面真不难,难的是把异常路径考虑全。我自己的习惯是:所有业务消息的发送入口都统一封装成一个Service,topic名称用常量维护,发送日志带上traceId,失败消息落库。这样线上一旦出问题,按traceId能直接翻出这条消息从生产到消费的所有轨迹。另一个小技巧是开发环境把每个项目的groupId都加上本机标识,例如order-dev-x,避免本地消费者把测试环境共享的offset推到奇怪的位置。调参可以慢慢磨,但这些兜底手段一开始就做好,能帮你省掉无数个熬夜排查的晚上。