zeus00456

导航

基于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(及其子类) 中的一部分,以防止子类实现的过程中因为具体实现导致预设的目的(比如子类中重设为支持批量消费),可以通过持有,而非基础这些类,并只提供有限的参数和功能对原始类进行操作。

posted on 2022-07-28 16:40  问仙长何方蓬莱  阅读(394)  评论(0编辑  收藏  举报