基于Springboot的kafka单消息体非批量消费的consumer

文章目的和缘由

在实际工作中,笔者使用了kafka,业务场景并不算太复杂,结合网络上一些帖子(绝大部分是互相重复的),简单快速的实现了。然而,在后续的观察中,发现里面有一些不大不小的坑,于是又白嫖了一堆帖子(依旧是各种相互重复)进行了修复,经过一段较长时间的观察和测试,感觉基本上(至少在笔者的场景中,[尴尬])没有问题了。
使用kafka的过程中,仅凭主观体会,kafka是个功能强大但坑比较多、并且使用相对繁琐的东西。作为对自己各种白嫖的忏悔,同时出于为降低帖子重复率出一份力的初衷,整理一个总结性的文章(虽然实在不敢保证里面的内容是最好的甚至是完全正确的,还是厚着脸皮写了)。
此文内容为记录一种消费者的写法(至于生产者,因为一直没有遇到问题,所以不再此文中讨论),包括配置、消费原型、后续使用等内容,并介绍了使用过程中遇到的一些问题、方案取舍时考虑的因素等。其中疏漏部分请带着正确的实现方式来怼[尴尬]。

所谓单消息体非批量消费

首先,比较好理解的是非批量消费,即consumer每次从kafka中只获取一条消息进行消费。与之对应的,一次从kafka获取多条消息进行消费就是批量消费。
然后,需要解释消息中消息体数量的含义。比如xx码(xx码的具体业务含义随便)是我们kafka消息里的内容,消费就是对xx码进行处理(比如通过xx码查询a,进而筛选b,然后经过比较复杂的处理后下方C服务),那么xx码就是消息体。而一个消息中可以仅含有一个xx码,也可以是xx码的数组,后者就是一个消息里具有多个消息体。
这里就会出现一个坑,部分同学在进行批量消费时,确实是一次消费了多条,但是没有保证一条消息里只有一条或固定数量的消息体
这并不是一个推荐的做法,因为通常在消费时,需要考虑consumer获取消息后的处理时间或效率,而上述情况会导致consumer的实际处理信息数=Σ每条消息里包含的消息体数量,这明显是个不太可控的玩意。而只有当consumer的复杂度和总消息体数量n为O(1)时,处理这种总体积不确定的信息量才是比较合适的(不管一次拉过来多少东西,反正处理一次就是这么多时间)。当然并不是说即使复杂度达到了O(n)也是效率低下的,而是这种情况下它和非批量消费并且消息里只有一条消息体的效率差不多,但是,consumer中代码的逻辑复杂度通常会上升一个档次。这在业务没有相关要求(业务要求必须批次处理,因为消息体在业务上有捆绑等场景)的情况下,显得没有必要,甚至得不偿失。
另一个隐患在于若一个消息有多个消息体,其中一部分处理失败,那么已经成功的怎么处理,尤其是其中涉及到了分布式事务的场景,需要额外考虑幂等性。一旦处理不好,容易导致重复消费或消息丢失的情况。当然这个问题并不是不能解决的,要不在处理的过程中兼顾幂等性,要不在中间件阶段处理(比如无论成功多少,都提交offset,不成功的消息体组成新消息发送至topic或者通过死信队列处理等方式)。但对比他们提高的效率和带来的逻辑复杂度总会感觉不是特别具有性价比。
因此,推荐的消息消费方式为,每次单消费,每条消息只有一个消息体。若消息内容和consumer的消费逻辑又保证可以进行每条消息的消息体数量固定的批量消费。

各种类说明和具体实现方式推荐
最简单的consumer

在Springboot 环境下,最简单的consumer实现起来非常简单,简单到如下:

spring:
	kafka:
		consumer:
			group-id: ""
			enable-auto-commit: true
			auto-commit-interval: 100
			key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
			value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
@Component
public class KafkaConsumer {
	 @KafkaListener(topics = {"topic_name"})
	 public void receive(String message){
	 	System.out.println("消费消息:" + message);
	 }
}

为了解决发送消息失败导致的消息丢失,给kafkaTemplate设置发送失败的feedback,下面是一个比较注重仪式感的写法(其实就是写法比较麻烦),从简单考虑可以使用@Bean进行KafkaTemplate的Ioc。这种写法的好处是可以把堆KafkaTemplate的复杂配置都集中到一起,且不太显得杂乱(只是这里没有这么复杂的准备工作,因此显得多此一举),暂时发送失败的处理只是记录下来

@Component
@Order(5)
public class KafkaTemplateInitor implements CommandLineRunner{
    private static final Logger logger= LoggerFactory.getLogger(KafkaTemplateInitor.class);

    @Resource
    private KafkaTemplate kafkaTemplate;

    @Override
    public void run(String... args) throws Exception {
        logger.info("<<<KafkaTemplateInitor start>>>");
        this.kafkaTemplate.setProducerListener(getKafkaCallback());
        logger.info("<<<KafkaTemplateInitor done>>>");
    }
    public ProducerListener getKafkaCallback(){
        return new ProducerListener(){
            @Override
            public void onError(ProducerRecord producerRecord, Exception exception) {
                logger.error("Message send fail : {}" ,producerRecord.value(),exception);
            }
        };
    }
}
分析和目的

理论上最简单的消费只需要上面的代码。但是,这个用法在实际使用中并不太好使。自动维护事务提交,但失败时也不触发重试,也不进入死信,偶尔还有重复消费的问题。我强烈怀疑是不是我配错了而不是这个简单的用法有问题,但没找到原因。因此进行了一轮重配,期望的结果为:

  • 消费时手动提交offset
  • 消费时可以由consumer判断是否消费成功
  • 消费成功可以提交offset
  • 消费失败可以触发重试
  • 重试具有最大重试次数,并在达到最大重试次数后进入死信队列
实现和说明

对kafkaListener的配置最终会反馈到对ConcurrentKafkaListenerContainerFactory的配置上
在这里插入图片描述
为了灵活的调整配置,先提供对应的prototype类作为原型。

原本类名的含义(对于kafka而言)是监听容器工厂,但使用过程中对于我们(的listener)而言是一个消费工厂,因此类名使用方便理解的叫法。

在这里插入图片描述

//这是一个抽象的Prototype原型类,以便于进一步配置
//这里指按照初衷指定其为非批量消费、手动提交的相关配置
public abstract class KafkaConsumerFactoryPrototype extends ConcurrentKafkaListenerContainerFactory {
    public void init(){//初始化方法
	    /**
	     * consumerFactory的具体获取方式通过抽取为 protected 方法允许扩展
	     * 为了进一步的灵活性提供默认实现,
	     * 内部更具体的参数按照同样的道理也抽取为 protected 方法,方便子类实现
	     * /
        this.setConsumerFactory(consumerFactory());
        this.setConcurrency(concurrency());
        this.setBatchListener(false);//设置为批量消费,每个批次数量在Kafka配置参数中设置ConsumerConfig.MAX_POLL_RECORDS_CONFIG
        this.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);//设置提交偏移量的方式
    }

    protected abstract Map<String,Object> consumerFactoryProp();
    protected ConsumerFactory consumerFactory(){return new DefaultKafkaConsumerFactory<>(consumerFactoryProp());}
	protected int concurrency(){return 1;}
}

进一步,提供一个通用的消费工厂

@Component("generalKafkaConsumerFactory")
public class GeneralKafkaConsumerFactory extends KafkaConsumerFactoryPrototype {

    @Resource(name = "generalKafkaConsumerConfig")
    private GeneralKafkaConsumerConfig generalKafkaConsumerConfig;
    @Resource
    private KafkaTemplate kafkaTemplate;

    @PostConstruct
    @Override
    /**
     * 在进行完父类的初始的条件下,增加一个消费失败的handler
     * 此handler的行为由 DeadLetterPublishingRecoverer 提供
     * 作用是重试2次(这个2其实可以放到上面的 GeneralKafkaConsumerConfig 中)之后
     * 开辟死信队列,相关代码在下方截图,死信的topic是 原topic.DLT
     */
    public void init() {
        super.init();
        SeekToCurrentErrorHandler errorHandler = 
        	new SeekToCurrentErrorHandler(new DeadLetterPublishingRecoverer(kafkaTemplate), 2);
        errorHandler.setCommitRecovered(true);
        this.setErrorHandler(errorHandler);
    }

	//具体配置由一个专门的config类提供,专类专用,config类算是springboot的推荐套路
	//自定义config类的目的是可以最大限度的灵活配置参数,甚至是spring-kafka定义范围外的内容
    @Override
    protected Map<String, Object> consumerFactoryProp() {
        return generalKafkaConsumerConfig.getConfig();
    }
}

在这里插入图片描述
config 类的实现套路类似,实现提供一个单纯的config类专门用于接收配置中的参数

@Component("kafkaConfig")
@ConfigurationProperties("kafka.config")
public class KafkaConfig {
    private String bootstrapServers;
    private Boolean enableAutoCommit;//是否自动提交
    private Integer autoCommitIntervalMs;//自动提交间隔
    private Integer sessionTimeoutMs;//连接检查时长
    private String keyDeserializer;//key 反序列化器
    private String valueDeserializer;//value 反序列化器
    private Integer maxPollRecords;//单次最大拉取数量
    private String autoOffsetReset;//无offset时消费策略,latest,earliest,none

    private String groupId;
}

然后是一层ConfigPrototype和一层通用config,代码放在一起了

public abstract class KafkaConsummerConfigPrototype {
    protected Map<String,Object> config;

    /**
     * 这个init里内容这么多时历史遗留问题
     * 实际上可以只配置少量和yml配置文件里配置的值无关的信息
     * 比如 KEY_DESERIALIZER_CLASS_CONFIG
     * 无论yml配置了什么这里口可以配置为StringDeserializer.class,并且正常情况下不需要变动
     */
    public void init(){
        this.config = new HashMap<String, Object>();
        config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, getBootstrapServers());
        config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
//        config.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "100");
        config.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "15000");
        config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        config.put(ConsumerConfig.GROUP_ID_CONFIG, getGroupId());
        config.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG,1);//每一批数量
        config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, getAutoOffsetReset());
    }
}
@Component("generalKafkaConsumerConfig")
public class GeneralKafkaConsumerConfig extends KafkaConsummerConfigPrototype {
    @Resource(name = "kafkaConfig")
    private KafkaConfig kafkaConfig;

	//真正进行参数配置的地方
    @PostConstruct
    @Override
    public void init() {
        if(MapUtils.isEmpty(this.config))
            this.config = new HashMap<String, Object>();

        config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaConfig.getBootstrapServers());
        config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, kafkaConfig.getEnableAutoCommit());
        if(null != kafkaConfig.getAutoCommitIntervalMs())
            config.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, kafkaConfig.getAutoCommitIntervalMs());
        config.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, kafkaConfig.getSessionTimeoutMs());
        config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, kafkaConfig.getKeyDeserializer());
        config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, kafkaConfig.getValueDeserializer());
        config.put(ConsumerConfig.GROUP_ID_CONFIG, kafkaConfig.getGroupId());
        config.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG,1);
        config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, kafkaConfig.getAutoOffsetReset());
    }
}

消费时,因为我们要考虑offset的提交,消息的处理是否成功等问题,因此把消费时的逻辑分为两个部分。一个部分是控制上述问题的语句,剩下的是用于处理业务的部分。很明显,前一个部分基本是固定的,后面是根据消息和消费场所的不同而变化的,因此又是一个prototype,KafkaLisenterPrototype

//泛型T是读取的消息经过反序列化后的类型,通常都是String
//因为一般反序列化器使用默认的字符串反序列化器,没什么特殊的原因——稳定,坑少
public abstract class KafkaLisenterPrototype<T> {
    protected static Logger logger= LoggerFactory.getLogger(KafkaLisenterPrototype.class);

    public void listen(ConsumerRecord<String, T> record, Acknowledgment ack) {
        logger.info("<<<kafkaLisenter>>> load msg:");
        boolean processed = false;
        try {
            logger.info("<<<kafkaLisenter>>> {} loaded",record.topic());
            T message = record.value();
            processed = onMessages(message);
            logger.info("<<<kafkaLisenter>>> processed [{}]",processed);
            if(processed){
                ack.acknowledge();//手动提交偏移量
                logger.info("<<<kafkaLisenter>>> offset commited:\n{}",message);
            }else{
                logger.info("<<<kafkaLisenter>>> offset not commited:\n{}",message);
                //为了不抛异常时的处理失败也触发重试
                //本着“不能完全信任子类开发者”的原则,虽然有点怪,但没有找到更好的写法
                throw new RuntimeException("for retry");
            }
        } catch (Exception e ) {
            logger.error("<<<kafkaLisenter>>> exception:",e);
            if(e instanceof RuntimeException) throw e;
        }finally{
            logger.info("<<<kafkaLisenter>>> done");
        }
    }
    public abstract boolean onMessages(T message);
}

最终使用时的listener的写法

    @KafkaListener(topics = { "topic_name" }, containerFactory = "generalKafkaConsumerFactory")
    @Override
    public void listen(ConsumerRecord<String, String> consumerRecord, Acknowledgment ack) {
        super.listen(consumerRecord, ack);
    }
	@Override
    public boolean onMessages(String message) {//do what you want}
变体和说明

首先需要明确的是,这是一种代码量比较多的实现方式,尤其是两个prototype(消费工厂的和配置的),以及没有使用springboot默认的配置(默认配置是 spring.kafka.producer/consumer 开头的),导致出现了好几层config。
需要首先解释的是,这种实现方式的初衷是最大限度的保证整套内容的可扩展性并在此基础上使使用者用起来最大程度的方便
保留可扩展性可以预防随业务场景扩展,导致基于最初实现的扩展不能满足要求,而需要平行开发一套。这意味着扩展功能不是通过添加子类而是重写一个兄弟类(因为通常一个类的子类是对父类接口的具体化、细化、深化,当遇到扩展其能力广度的要求时往往是无力的),而兄弟类的出现可能导致新老代码不一致,并且想把老的实现方式向新的兄弟类的实现方法上同步时因工作量较大而基本没有可行性(这种情况很常见)。更糟的情况是后面相关人员在开发过程中随机(从事实看,确实是随机)使用新老实现,只要两种实现都能满足需求。

文中的实现保留扩展性的手段主要通过prototype实现,每级prototype原则上都需要允许子类重写,并只实现“只能在这里实现的部分”和“此时已经可以决定的部分”。
比如假设setBatchListener(false)信息只能在prototype阶段进行设置,因为为了防止子类进行修改导致最终实现不是一个非批量消费的消费工厂而在prototype阶段私有化了此参数的配置途径。
又比如假设项目约定触发特殊情况,真个项目消息的生产者消费者的序列化反序列器一律使用字符串的,这个信息也应该在prototype阶段进行配置,因为此时这个配置已经可以决定了。

使用方便的好理解,好用意味着只需要处理业务相关的逻辑,节省开发时间;同时非业务相关的逻辑因为是写好了不用管的,因此这部分代码更安全,毕竟不能保证任何人动这一部分代码都不会造成什么不可预知的问题。因此这种写法不是实现功能而是帮助别人实现功能的,研发团队中只需要一小部分面对上面一大坨内容,其他的同学只需要:

  • consumer类继承KafkaLisenterPrototype
  • listen方法上添加@KafkaListener注解,填上对应topic的名字
  • 实现onMessages方法

为什么使用自定义配置,并在前文强调保留配置的灵活性。举例说明,假设公司不满足于标准的字符串序列化,而是基于各种目的实现了一坨专用序列化器,比如加密的、自定义二进制序列化的、特殊报文格式的等等。并且这些序列化器由另一个团队开发并提供依赖,要求通过对应的名字进行声明,此时原配置方式失效,因为默认的配置方式的处理器对象不能识别这种配置。这种比较神经病的情况当然很难见到,但是对于实现一次长期稳定使用的东西来说,这点额外的代码成本不是不能考虑。

简化

去掉消费工厂和配置的prototype,使用默认配置即可

变化

若只是在非批量消费这个大前提下和通用实现有区别,可以仅仅扩展一个prototype的子类,比如变更了某个具体配置的值。或者消费时不需要死信、只需要将失败的信息推送至特定的服务等(虽然看起来这些场景应该不会出现,但具体业务被定义成什么都是可能的并且有道理的)。
但如果要求一个批量消费者,那只能扩展prototype的兄弟类(因为这个实现一开始就定义成非批量消费了,前文提到的保留扩展性也是在这个前提下保留扩展性)。
若希望具体实现仅可以操作 ConcurrentKafkaListenerContainerFactory(及其子类) 中的一部分,以防止子类实现的过程中因为具体实现导致预设的目的(比如子类中重设为支持批量消费),可以通过持有,而非基础这些类,并只提供有限的参数和功能对原始类进行操作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Spring Boot Kafka 批量消费是指通过 Spring Boot 框架集成 Kafka,实现一次性消费多条消息的功能。在 Kafka 中,批量消费可以提高消费效率,减少网络开销,提高系统的吞吐量。Spring Boot Kafka 批量消费可以通过配置 Kafka 消费者的批量拉取大小和批量处理大小来实现。同时,还可以使用 Kafka批量消费器来实现批量消费。 ### 回答2: Spring Boot是一款非常流行的Java框架,其中集成了Kafka,支持快速搭建Kafka生产者和消费者应用。而在Kafka消费者应用中,有时会需要批量消费消息,以提高性能。 批量消费是指一次性从Kafka服务器获取多个消息,然后一次性处理它们,而不是逐个处理。这种方式可以减少网络传输和处理的时间,提高处理效率,特别是在大数据量的场景下非常有用。 Spring Boot提供了多种方式来实现Kafka批量消费。其中一种方式是通过@EnableKafka注解来启用Kafka消费者,然后手动创建一个ConcurrentKafkaListenerContainerFactory,通过该工厂类来设置属性,如批量消费配置。 例如: ``` @Configuration @EnableKafka public class KafkaConfig { @Bean public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() { ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(consumerFactory()); //设置批量消费 factory.setBatchListener(true); return factory; } @Bean public ConsumerFactory<String, String> consumerFactory() { return new DefaultKafkaConsumerFactory<>(consumerConfigs()); } @Bean public Map<String, Object> consumerConfigs() { Map<String, Object> propsMap = new HashMap<>(); propsMap.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); propsMap.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); propsMap.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "100"); propsMap.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "15000"); propsMap.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); propsMap.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); propsMap.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "10"); //设置每次批量获取的消息数量 return propsMap; } @Bean public Listener listener() { return new Listener(); } } ``` 以上配置已经开启了批量消费模式。在Listener类中,只需要添加@KafkaListener注解即可实现批量消费: ``` @Component public class Listener { @KafkaListener(topics = "test", containerFactory="kafkaListenerContainerFactory") public void batchListener(List<String> data) { for(String d : data) { System.out.println(d); } } } ``` 上述batchListener方法的参数列表类型为List<String>,因此Spring Boot自动将多条消息打包成list传递到batchListener方法中,实现了批量消费。 除了通过ConcurrentKafkaListenerContainerFactory手动设置批量消费,还可以通过直接定义@KafkaListener相关参数来实现: ``` @KafkaListener( topics = "test", groupId = "foo", containerFactory = "kafkaListenerContainerFactory", concurrency = "3", //设置并发处理的线程数 autoStartup = "false") public void batchListener(List<String> data) { for(String d : data) { System.out.println(d); } } ``` 总结一下,Spring Boot集成Kafka批量消费主要有两种实现方式:手动配置ConcurrentKafkaListenerContainerFactory或直接在@KafkaListener注解中设置参数。通过这种方式,能够提高消费者处理效率,适用于大数据量的场景。 ### 回答3: Spring Boot是一个轻量级的Java框架,它提供了丰富的功能和易于使用的编程模型,使得开发者可以快速构建、部署和运行应用程序。Kafka则是一个开源的分布式消息系统,它提供了高效、可靠和可扩展的消息传递机制,可以帮助开发者构建大规模的实时数据处理和消息系统。 在使用Spring Boot和Kafka进行消息处理时,很多时候需要处理大量的批量数据,例如从数据库中读取数据并批量写入到Kafka中。这时候,如何进行批量消费就成为了一个非常重要的问题。 针对这个问题,Spring Boot和Kafka提供了多种解决方案,主要包括以下几种: 1. 手动提交offset:通过手动控制offset的提交,可以实现批量消费。当处理完一批消息后,手动将offset提交到Kafka中,下次再从提交的offset开始继续消费下一批消息即可。这种方式可以提高消费的效率和吞吐量。需要注意的是,如果在消费过程中出现异常或者程序挂掉,需要通过重新启动程序并从上次提交的offset开始重新消费消息。 2. 使用BatchListener:BatchListener是Spring Kafka提供的一个可以实现批量消费的特性。通过在注解中设置batchSize参数,即可指定每一批次需要处理的消息数量。当消息数量达到batchSize时,Spring Kafka会自动调用一次BatchListener进行批量消费。需要注意的是,如果在生产环境中,需要适当地调整batchSize的大小,避免因批量消息过大导致程序内存溢出等问题。 3. 使用Kafka Consumer API:如果需要对批量消费的逻辑和流程进行更加灵活的控制,可以直接使用Kafka Consumer API。通过在Kafka Consumer API中使用poll()方法,可以实现按照批量方式获取消息。当消息数量达到一定阈值时,就可以进行批量处理。需要注意的是,使用Kafka Consumer API需要自己控制offset的提交和消费异常的处理等问题,相对比较复杂。 综上所述,Spring Boot和Kafka提供了多种实现批量消费的解决方案,选择合适的方式可以提高消息处理的效率和稳定性。需要根据实际情况进行选择和调整。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值