背景&现象

生产微服务架构环境,kafka消息消费服务架构如下:

kafka 为消息生成唯一ID kafka消息顺序问题_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源码如下:

kafka 为消息生成唯一ID kafka消息顺序问题_消息处理_02

kafka 为消息生成唯一ID kafka消息顺序问题_spring_03

设置客户端消费线程记录的offse位置,此处是在消息处理之前进行更新,待消息处理结束后的回掉进行提交broker。消息处理内部如果发生异常并被捕获的话,此处拿不到回掉,所以只会记录offset变化。

kafka 为消息生成唯一ID kafka消息顺序问题_spring_04

kafka 为消息生成唯一ID kafka消息顺序问题_spring_05

kafka 为消息生成唯一ID kafka消息顺序问题_spring_06

消费工厂初始化消费容器,每个容器的start和stop都是对象锁同步,每个容器绑定一个消费者线程,这里执行一个消息分区的消息拉取,提交等操作。

kafka 为消息生成唯一ID kafka消息顺序问题_kafka 为消息生成唯一ID_07

消息拉取后,提交给业务消费者实现,等待结果回掉,并提交offset.

详细原因思考

当服务C接口出现宕机或者接口超时情况时,kafka消息处理服务业务逻辑内部出现处理异常,虽然未执行手动提交offset的操作,但是内部抛出的异常被catch住然后继续消费产生的新消息,并且消费端继续的offset一只在增加,只是为提交broker。

此时一旦下游服务恢复,kafka消费端会直接把本地存储的最新offset提交到broker,服务端在收到同一分区offset后会把之前的offset重置,并把小于此offset的消息全部状态置为已消费,导致之前处理失败的offset消息丢失的情况。示意图如下:

kafka 为消息生成唯一ID kafka消息顺序问题_kafka_08

解决方案

高清问题原因后,我们可以针对具体原因进行解决,解题思路有2种

死信队列

这样就是把处理异常的消息写入死信队列,待服务恢复后重新消费私信队列中的异常消息,这种方案有2点问题:

1、需要写新的处理逻辑死信队列逻辑代码,并定义新的topic

2、此方案适合无唯一主键的消息处理

异常监听器

解决业务消息处理异常时不要自增本地的offset,这样即使服务恢复后本地客户端仍旧从异常的消息节点开始消费,消费成功时才能提交offset。

弊端是服务在异常时会进入死循环模式,即同一分区一只在消费一条消息;

优点是只需要引入spring-kafka异常监听器对异常消息进行处理即可,不需要引入复杂的代码和更多的消息topic。

我是采用第二种解决方案,经过测试无误后发布到生产环境,代码如下:

kafka 为消息生成唯一ID kafka消息顺序问题_kafka_09

首先增加未知异常抛出逻辑,其次定义异常处理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;
            }
        };
    }





}

定位问题原因* 根据原因思考问题解决方案* 实践验证方案有效性* 提交验证结果