背景&现象
生产微服务架构环境,kafka消息消费服务架构如下:
当服务B接口出现宕机或者B接口调用超时,kafka消息消费端服务A出现异常,异常发生后未执行手动提交offset操作。待服务B恢复后,消费端A服务也恢复正常,但之前消费异常的消息在broker自动变为已消费,实际未消费(数据库中无处理消息的业务数据)。
问题原因
问题定位
在灰度环境复刻了一样的操作,100%复现此问题。增加了debug打印日志,确认了业务代码在异常后,仍继续消费服务端产生的消息,只不过会抱异常,具体业务代码如下:
@KafkaListener(containerFactory = "kafkaBatchListener", topics = {CAMERA_DEV_FAULT})
public void alarmListener(List<ConsumerRecord<?, ?>> records, Acknowledgment ack) throws Exception{
try{
//1.处理
for (ConsumerRecord consumerRecord : records) {
Long startTime = (new Date()).getTime();
log.info("recv message offset" + String.valueOf(consumerRecord.offset())+ " | " + consumerRecord.partition() +
" | " + consumerRecord.topic() + " | " + consumerRecord.value());
JSONObject msg = JSONObject.fromObject(consumerRecord.value());
uCameraAlarmProcessor.process(msg);
// List ss = new ArrayList();
// System.out.println("=========================="+ss.get(5).toString());
Long endTime = (new Date()).getTime();
log.info("recv message offset222 = " + consumerRecord.offset() + "| oprTime = " + (endTime-startTime));
}
//2.消费提交偏移量
ack.acknowledge();
log.info("OK records {}",records.size());
} catch (DuplicateKeyException e){
ack.acknowledge();
log.info("Exception 1");
e.printStackTrace();
} catch (Exception e){
log.info("Exception 2"+e.getMessage());
throw e;
}
}
spring-kafka配置信息如下:
private Map<String, Object> consumerConfigs() {
Map<String, Object> props = new HashMap<>(10);
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitInterval);
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, autoCommit);
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset);
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, sessionTimeout);
props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, maxPollInterval);
props.put(ConsumerConfig.MAX_PARTITION_FETCH_BYTES_CONFIG, maxPartitionFetchBytes);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.GROUP_ID_CONFIG,groupId);
props.put("security.protocol", "SASL_PLAINTEXT");
props.put("sasl.mechanism", "SCRAM-SHA-512");
props.put("sasl.jaas.config",
"org.apache.kafka.common.security.scram.ScramLoginModule required username=\""+username+"\" password=\""+password+"\";");
return props;
}
private ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
//批量消费
factory.setBatchListener(batchListener);
//如果消息队列中没有消息,等待timeout毫秒后,调用poll()方法。
// 如果队列中有消息,立即消费消息,每次消费的消息的多少可以通过max.poll.records配置。
//手动提交无需配置
factory.getContainerProperties().setPollTimeout(pollTimeout);
//设置提交偏移量的方式, MANUAL_IMMEDIATE 表示消费一条提交一次;MANUAL表示批量提交一次
// factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
// factory.getContainerProperties().setCommitLogLevel(LogIfLevelEnabled.Level.INFO);
// factory.getContainerProperties().setLogContainerConfig(true);
factory.getContainerProperties().setAckMode(AbstractMessageListenerContainer.AckMode.MANUAL_IMMEDIATE);
return factory;
}
结合的spring-kafka源码如下:
设置客户端消费线程记录的offse位置,此处是在消息处理之前进行更新,待消息处理结束后的回掉进行提交broker。消息处理内部如果发生异常并被捕获的话,此处拿不到回掉,所以只会记录offset变化。
消费工厂初始化消费容器,每个容器的start和stop都是对象锁同步,每个容器绑定一个消费者线程,这里执行一个消息分区的消息拉取,提交等操作。
消息拉取后,提交给业务消费者实现,等待结果回掉,并提交offset.
详细原因思考
当服务C接口出现宕机或者接口超时情况时,kafka消息处理服务业务逻辑内部出现处理异常,虽然未执行手动提交offset的操作,但是内部抛出的异常被catch住然后继续消费产生的新消息,并且消费端继续的offset一只在增加,只是为提交broker。
此时一旦下游服务恢复,kafka消费端会直接把本地存储的最新offset提交到broker,服务端在收到同一分区offset后会把之前的offset重置,并把小于此offset的消息全部状态置为已消费,导致之前处理失败的offset消息丢失的情况。示意图如下:
解决方案
高清问题原因后,我们可以针对具体原因进行解决,解题思路有2种
死信队列
这样就是把处理异常的消息写入死信队列,待服务恢复后重新消费私信队列中的异常消息,这种方案有2点问题:
1、需要写新的处理逻辑死信队列逻辑代码,并定义新的topic
2、此方案适合无唯一主键的消息处理
异常监听器
解决业务消息处理异常时不要自增本地的offset,这样即使服务恢复后本地客户端仍旧从异常的消息节点开始消费,消费成功时才能提交offset。
弊端是服务在异常时会进入死循环模式,即同一分区一只在消费一条消息;
优点是只需要引入spring-kafka异常监听器对异常消息进行处理即可,不需要引入复杂的代码和更多的消息topic。
我是采用第二种解决方案,经过测试无误后发布到生产环境,代码如下:
首先增加未知异常抛出逻辑,其次定义异常处理errorHandler。
异常处理类
@Component
public class ErrorListenner {
private static final Logger log= LoggerFactory.getLogger(ErrorListenner.class);
@Bean
public ConsumerAwareListenerErrorHandler consumerAwareErrorHandler() {
return new ConsumerAwareListenerErrorHandler() {
@Override
public Object handleError(Message<?> message, ListenerExecutionFailedException e, Consumer<?, ?> consumer) {
log.info("consumerAwareErrorHandler receive : "+message.getPayload().toString());
LinkedList<ConsumerRecord> records = (LinkedList)message.getPayload();
ConsumerRecord consumerRecord = records.get(0);
Long offset = consumerRecord.offset();
String topic = consumerRecord.topic();
Integer partition = consumerRecord.partition();
// Map<TopicPartition, OffsetAndMetadata> offsetsToReset = new HashMap<>();
// 设置每个分区的偏移量
TopicPartition topicPartition = new TopicPartition(topic, partition);
// OffsetAndMetadata offsetAndMetadata = new OffsetAndMetadata(offset, "manual_immediate");
// offsetsToReset.put(topicPartition, offsetAndMetadata);
log.info("重置客户端offset位置 topic {} partition {} {}",topic, partition,offset);
// acknowledgment.acknowledge();
consumer.seek(topicPartition,offset);
// consumer.commitSync(offsetsToReset);
return null;
}
};
}
}
定位问题原因* 根据原因思考问题解决方案* 实践验证方案有效性* 提交验证结果