kafka单排日志—springboot整合kafka

862 阅读7分钟

前言

kafka已经安装好,原理咱们也做了初步的探究。接下来springboot整合kafka就要开始了。这里我想说的是,整合mq的话绝不能生产者发个消息,消费者消费了然后完事了,这绝对不行。这算什么整合呢?整合至少要关注到以下问题,下面的问题都解决了,整合才算是有那么一点用的。尽可能地咱们要贴近实际的业务场景:

  • 如何确保消息不丢失?
  • 如何保证消息不会重复消费?
  • 如何避免消息的积压,加快消费速度?
  • 如何保证有序的消费?(当然,业务场景是有状态的前提下)
  • ...

依赖、环境准备

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.6.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>20.0</version>
</dependency>
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.16</version>
</dependency>

    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
        <version>2.4.6.RELEASE</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
    <dependency>
        <groupId>com.google.code.gson</groupId>
        <artifactId>gson</artifactId>
        <version>2.8.7</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.58</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

springboot parent的版本选择2.2.6,spring-kakfa的版本选择2.4.6.RELEASE。

yml文件的配置如下:

spring:
  # KAFKA
  kafka:
    # ָkafka服务器地址,可以指定多个
    bootstrap-servers: ****:9092,****:9092, ****:9092
    #=============== producer生产者配置 =======================
    producer:
      retries: 0
      # 每次批量发送消息的数量
      batch-size: 16384
      # 缓存容量
      buffer-memory: 33554432
      # ָ指定消息key和消息体的编解码方式
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      acks: 1
     # properties:
        #partitioner:
          #class: com.cmdc.config.CustomizePartitioner
    #=============== consumer消费者配置  =======================
    consumer:
      #指定默认消费者的group id
      group-id: test-app
      #earliest
      #当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
      #latest
      #当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据
      #none
      #topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
      auto-offset-reset: latest
      enable-auto-commit: false # 消费时不自动提交,改为手动确认
      auto-commit-interval: 100ms
      #指定消费key和消息体的编解码方式
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      max-poll-records: 10    # 每次拉取10条
server:
  port: 8888

这里各项配置的描述已经很清晰了,几个重点配置特别地说下:

producer.acks:

# acks=0 : 生产者在成功写入消息之前不会等待任何来自服务器的响应。
# acks=1 : 只要集群的首领节点收到消息,生产者就会收到一个来自服务器成功响应。
# acks=all或-1 :只有当所有参与复制的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应。

image.png

consumer. enable-auto-commit:

假设你在代码里面配置了消费者的手动提交,那么这里千万不要再设置为true了,否则自相矛盾,会报错IllegalStateException。 image.png

consumer. max-poll-records:

批量拉取的时候最多拉取多少条

image.png

整合生产者

整合生产者的时候,咱们就不啰嗦了,这里直接采用带有回调的发送方式,即完成生产者的消息确认:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Component;
import org.springframework.util.concurrent.ListenableFuture;
import org.springframework.util.concurrent.ListenableFutureCallback;



/**
 * @author : wuwensheng
 * @date : 10:49 2021/12/20
 */
@Component

public class KafkaSender {

    @Autowired
    private KafkaTemplate<String, Object> kafkaTemplate;
    private final Logger logger = LoggerFactory.getLogger(KafkaSender.class);


    public void send(String topic, String taskid, String jsonStr) {


        //发送消息
        ListenableFuture<SendResult<String, Object>> future = kafkaTemplate.send(topic, taskid, jsonStr);
        future.addCallback(new ListenableFutureCallback<SendResult<String, Object>>() {
            @Override
            //推送成功
            public void onSuccess(SendResult<String, Object> result) {
                logger.info(topic + " 生产者 发送消息成功:" + result.toString());
                logger.info("发送消息成功:" + result.getRecordMetadata().topic() + "-"
                        + result.getRecordMetadata().partition() + "-" + result.getRecordMetadata().offset());
            }

            @Override
            //推送失败
            public void onFailure(Throwable ex) {
                logger.info(topic + " 生产者 发送消息失败:" + ex.getMessage());
            }
        });


    }


}

调用send()方法的时候需要传递topic、taskid以及数据,taskid也可以不传递,看情况进行选择就好。

另外kafkaTemplate可以自行定制,不一定要用来自springboot enableAutoConfigure自动配置的这个。

我们现在写一个接口,模仿瞎=下kafak生产者发送数据;

import com.alibaba.fastjson.JSONObject;
import com.cmdc.producer.KafkaSender;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
 * @author : wuwensheng
 * @date : 10:51 2021/12/20
 */
@RestController
public class KafkaController {

    @Autowired
    private KafkaSender kafkaSender;

    @GetMapping("/sendMessageToKafka")
    public String sendMessageToKafka() {
        Map<String, String> messageMap = new HashMap();
        messageMap.put("message", "我是一条消息");
        String taskid = "123456";
        String jsonStr = JSONObject.toJSONString(messageMap);
        //kakfa的推送消息方法有多种,可以采取带有任务key的,也可以采取不带有的(不带时默认为null)
        kafkaSender.send("testTopic", taskid, jsonStr);
        return "hi guy!";
    }
}

注意这个testTopic并没有提前创建,在发送的时候将自动创建,不过分区数量是1,副本数也是1而已。启动项目,咱们试一下:

image.png 发送成功,那么接下来搞个生产者将这个消息消费下,看能不能成功。

整合简单消费者

import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;


import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;


import java.util.List;


/**
 * Hello!
 * Created By  JCccc on 2018/11/24
 * 13:13
 */
@Component
public class KafkaConsumer {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 下面的主题是一个数组,可以同时订阅多主题,只需按数组格式即可,也就是用“,”隔开
     *
     * @param record
     */
    @KafkaListener(topics = {"testTopic"})
    public void receive(ConsumerRecord<?, ?> record) {

        logger.info("消费得到的消息---key: " + record.key());
        logger.info("消费得到的消息---value: " + record.value().toString());
    }
}

在发送一条消息,看看能否接收到:

image.png

ok,已经发送成功。

整合消费者(批量消费,带消息确认等)

提前创建topic,规划分区和副本

import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.consumer.Consumer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.config.KafkaListenerContainerFactory;
import org.springframework.kafka.config.TopicBuilder;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.listener.ContainerProperties;
import org.springframework.kafka.listener.KafkaListenerErrorHandler;
import org.springframework.kafka.listener.ListenerExecutionFailedException;
import org.springframework.messaging.Message;

import javax.validation.constraints.NotNull;



/**
 * @author : wuwensheng
 * @date : 10:49 2021/12/20
 */
@Configuration
@Slf4j
public class KafkaInitialConfiguration {
    private static final String TOPIC_FIRST = "testtopic1";
    private static final String TOPIC_SECOND = "singleTopic";

    /**
     * 创建一个名为testtopic的Topic并设置分区数为8,分区副本数为2
     * <p>
     * 如果要修改分区数,只需要修改配置重启即可,修改分区数不会导致数据的丢失,但是分区数只能增大不能减少
     *
     * @return 创建的新topic
     */
    @Bean
    public NewTopic initialTopic() {
        log.info("create new topic now");
        return TopicBuilder.name(TOPIC_FIRST).partitions(8).replicas(2).build();
    }
}

自行定制KafkaListenerContainerFactory

KafkaListenerContainerFactory可以帮助来配置消费者进行消费时的几乎所有细节,因为咱们想在消费的时候能完成消息确认,批量消费等,所以咱们自己来定制,如下:

@Bean("batch")
@NotNull
public KafkaListenerContainerFactory<?> batchFactory(ConsumerFactory<Integer, String> consumerFactory) {
    // 这里的consumerFactory可以设置各种参数的
    log.info("batch receive consumerFactory,is:{}", consumerFactory);
    ConcurrentKafkaListenerContainerFactory<Integer, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory);
    // 批量拉取数据
    factory.setBatchListener(true);
    // 设置每个@KafkaListener的线程数
    factory.setConcurrency(3);
    // 设置手动提交ack,对于要求较为严格的业务较为合适
    factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
    return factory;
}

@Bean("single")
@NotNull
public KafkaListenerContainerFactory<?> singleFactory(ConsumerFactory<Integer, String> consumerFactory) {
    log.info("single factory receive consumerFactory::{}", consumerFactory);
    ConcurrentKafkaListenerContainerFactory<Integer, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory);
    // 批量拉取数据
    factory.setBatchListener(false);
    // 设置每个@KafkaListener的线程数
    factory.setConcurrency(5);
    // 设置手动提交ack,对于要求较为严格的业务较为合适
    factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
    return factory;
}

batch用于批量监听的消费者,single用于单条消费的消费者。我们对是否批量拉取数据以及手动ack确认消息的消费都做出设置了。详细的可以看看代码怎么做的。

定制异常处理器

@Bean
public KafkaListenerErrorHandler kafkaListenerErrorHandler() {
    return new KafkaListenerErrorHandler() {
        @Override
        public Object handleError(Message<?> message, ListenerExecutionFailedException exception) {
            return null;
        }

        @Override
        public Object handleError(Message<?> message, ListenerExecutionFailedException exception, Consumer<?, ?> consumer) {
            //do something
            return null;
        }
    };
}

在消费者消费消息出现异常的时候可以通过异常处理器做出处理,但是由于咱们是手动ack的,异常可以自行处理,手动ack的时候这个异常处理器没什么作用了就。

批量消息消费者代码

/**
 * @param recordList
 * @param acknowledgment KafkaListener id :消费者线程的命名规则.
 *                       groupId:指定该消费组的消费组名
 *                       topics:指定要监听哪些topic 这个是从所有的分区取数据吗?
 */
@KafkaListener(id = "consumer2", groupId = "felix-group", topics = "testtopic1", containerFactory = "batch",
        properties = {
                ConsumerConfig.MAX_POLL_RECORDS_CONFIG + "=10",// 每次最多取10个进行消费
                ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG + "=50000" // 如果超过5分钟pull不到数据,那么尝试自行拉取
        }
)
public void onMessage3(List<ConsumerRecord<?, ?>> recordList, Acknowledgment acknowledgment) {
    logger.info(">>>批量消费一次,records.size()=" + recordList.size());
    logger.info("===================================================start");
    try {
        for (ConsumerRecord<?, ?> record : recordList) {
            logger.info("get message from record:{}", record.value());

        }
        acknowledgment.acknowledge();
        logger.info("====================================================end");
    } catch (Exception e) {
        logger.info("exception occur when consume message:{}", e.getMessage());
        acknowledgment.acknowledge();
    }

}

消费者所属的group是felix-group,每次拉取10个消息,使用的containerFactory是"batch"这个实例,无论是否抛出异常都进行了ack。我这里是简单的模拟,你完全可以在抛出异常的时候执行更加可靠和复杂的操作。

那么咱们玩一下,先把消费者注释掉。然后重启下项目:

image.png 紧接着往testTopic1这个队列发送一些消息。

@Test
public void test1() {
    for (int i = 1; i < 101; i++) {
        String replace = UUID.randomUUID().toString().replace("-", "");
        kafkaSender.send("testtopic1", String.valueOf(i) + "|||" + replace, String.valueOf(i) + "|||" + replace);
    }
}

image.png

发送完成之后把消费者的注释去掉,然后再重启下项目:

image.png

没问题的,可以看到消息消费的时候没有顺序哈,这很正常,因为存入的时候就是无序的,8个分区,消息去往哪个分区都有可能。消费的时候也是无序的,消费者线程不止一个,无法保证顺序。关于这个问题,后面咱们专门再讨论。

总结

对kafka的简单整合到此,后面咱们慢慢讨论复杂情形。