消息中间件系列二:Kafka

1,042 阅读21分钟

一、Kafka概述

继前一篇RabbitMQ之后,这次说说kafka,kafka不仅仅是属于消息中间件。

kafka在设计之初,是想设计一个能够存储数据的系统,有点像常见的非关系型数据库,比如说NoSql等,除此之外还希望kafka能支持持续不断增长变化的数据流可以发布和订阅数据流。也就是说kafka的本质是一个数据存储平台、流平台,只是他在做消息订阅发布的时候,我们可以把他当做消息中间件来用。

Kafka是分布式发布-订阅消息系统,它最初是由LinkedIn公司开发的,之后成为Apache项目的一部分,Kafka是一个分布式,可划分的,冗余备份的持久性的日志服务,它主要用于处理流式数据。kafka是采用分布式架构设计的,基于集群的方式工作,还可以自由伸缩,所以kafka构建集群非常简单,当然kafka必须依赖zookeeper。

二、核心概念说明

  • Broker : 和RabbitMQ的概念一样, 就是消息中间件所在的服务器,一台服务器就称为一个Broker;
  • Topic(主题) : 每条发布到Kafka集群的消息都有一个类别,这个类别被称为Topic;(物理上不同Topic的消息分开存储,逻辑上一个Topic的消息虽然保存于一个或多个Broker上但用户只需指定消息的Topic即可生产或消费数据而不必关心数据存于何处)
  • Partition(分区) : Partition是物理上的概念,体现在磁盘上面,每个Topic包含一个或多个Partition,Partition是实际存储数据的地方,向每个Partition写入数据时,是顺序写入的,这保证了每个Partition内部的数据是有序的;
  • Producer(消息生产者) : 负责发布消息到Kafka的Broker的客户端;
  • Consumer(消息消费者) : 从Kafka的Broker读取消息的客户端;
  • Consumer Group(消费者群组) : 每个Consumer属于一个特定的Consumer Group(可为每个Consumer指定group name,若不指定group name则属于默认的group),一条消息可以被不同的Consumer Group消费,但是一个Consumer Group中只能有一个Consumer能够消费该消息;
  • offset(偏移量): 是kafka用来确定消息是否被消费过的标识,他标记每个Partition保存的数据当前被消费的位置,offset就是一个long型的数字。

kafka的集群架构如上图,其中备份分区仅用作备份用,不做读写,读写都在主分区上进行。如果某个Broker挂掉了,就会选举出其他Broker中的Partition来作为主分区,以此来实现高可用。(比如Broker1挂掉了,此时Partition1没有主分区了,则选举出Broker0下的Partition1来作为主分区)

Partition重点说明

kafka为每个主题维护了分布式的分区(Partition)日志文件,任何发布到此Partition的消息都会被追加到log文件的尾部(Partition持久化到磁盘是IO顺序访问的,并且是先写缓存,隔一段时间或数据量足够大的时候才批量写入磁盘),在分区中的每条消息都会按照时间顺序分配到一个单调递增的顺序编号,也就是我们的offset,我们通过这个offset可以确定一条在该Partition下的唯一消息。在每个Partition下面是保证了有序性,但是在topic下面没有保证有序性。

如上图所示,生产者会决定发送到哪个Partition

  • 如果指定了Partition,则会发送到对应的Partition
  • 如果没有指定Partition,却指定了key,则对key进行hash,然后对分区数取余,保证了同一个key会被路由到同一个分区
  • 如果Partition和key都没有指定,则进行轮询发送到Partition

提示:发送消息topic是必须要指定的

前面提到了Kafka通过多副本(Partition的一个leader和多个follower)机制实现故障自动转移,读写数据都是在leader上进行,follower只做复制备份的作用。那么follower是如何与leader同步数据的呢?

说到副本同步,需要知道以下三个概念

  • AR:所有的副本(replicas)统称为Assigned Replicas;(AR=ISR+OSR)
  • ISR:(In-Sync Replicas)副本同步队列;
  • OSR:(Outof-Sync Replicas)列表。

ISR正常情况下由leader维护,follower从leader同步数据有一些延迟(包括延迟时间replica.lag.time.max.ms和延迟条数replica.lag.max.messages两个维度)任意一个超过阈值都会把follower剔除出ISR,并存入OSR,新加入的follower也会先存放在OSR中(follower被放入OSR,他同样会向leader同步数据,只是不作为判断producer发数据确认接收的follower)。Leader有单独的线程定期检测ISR中follower是否脱离ISR,以及OSR中是否有满足加入ISR的follower。ISR的管理最终都会反馈到zookeeper节点上,具体位置(zktools工具查看zookeeper):

ISR还有另外一个地方来维护,就是Controller,在Kafka集群中会选举一个Broker作为Controller,它主要负责Partition管理和副本状态管理,也会执行类似于重分配partition之类的管理任务。

由此可见,Kafka的复制机制既不是完全的同步复制,也不是单纯的异步复制。完全同步复制要求All Alive Follower都复制完,这条消息才会被认为commit,这种复制方式极大的影响了吞吐率。而异步复制方式下,Follower异步的从Leader复制数据,数据只要被Leader写入log就被认为已经commit,这种情况下,如果leader挂掉,会丢失数据,kafka使用ISR的方式很好的均衡了确保数据不丢失以及吞吐率。Follower可以批量的从Leader复制数据,而且Leader充分利用磁盘顺序读以及sendfile(零拷贝)机制,这样极大的提高复制性能,内部批量写磁盘,大幅减少了Follower与Leader的消息量差。

这里再说一点:如果leader挂掉了,但此时ISR为空结果会怎样呢?

kafka在Broker端提供了一个配置参数:unclean.leader.election,这个参数有两个值:

  • true(默认):允许不同步副本成为leader,由于不同步副本的消息较为滞后,此时成为leader,可能会出现消息不一致的情况;
  • false:不允许不同步副本成为leader,此时如果发生ISR列表为空,会一直等待旧leader恢复,降低了可用性。

producer

kafka发送消息有两种方式:同步(sync)和异步(async),下面会有代码演示同步和异步的发送方式。kafka通过配置acks来确认消息的发送,有三个值:

  • 1(默认):数据发送到Kafka后,经过leader成功接收消息的确认,就算是发送成功了;(在这种情况下,如果leader宕机了,则会丢失数据)
  • 0:生产者将数据发送出去就不管了,不去等待任何返回;(这种情况下数据传输效率最高,但是数据可靠性确是最低的)
  • -1:需要等待ISR中的所有follower都确认接收到数据后才算一次发送完成,可靠性最高。当ISR中所有副本都向leader发送ACK时,leader才commit

offset和offset提交

偏移量(offset)是kafka特别重要的一个概念,上面简单说了偏移量的作用,这个offset会保存在kafka的Broker当中,而且消费者本地也会存储一份,因为每次消费一条消息都要更新一下偏移量的话,难免会影响整个Broker的吞吐量,所以这个offset在每次发生改动时,先由消费者本地改动,默认情况下消费者五秒钟会提交一次改动的offset。(kafka设计之初可是用来处理日志收集操作的,数据流非常大,所以采取默认5秒钟提交一次offset)

提示:每个分区的offset是由consumer-group(消费者组)来区分的,对于指定的分区,同消费者组里的消费者offset是一样的

这样做虽然说吞吐量上来了,但是可能会出现重复消费的问题。因为可能在下一次提交偏移量之前,消费者本地消费了一些消息,然后发生了分区再均衡(分区再均衡在下面有讲)那么就会出现一个问题。假设上次提交的偏移量是 2000 在下一次提交之前,其实消费者又消费了500条数据,也就是说当前的偏移量应该是2500 但是这个2500只在消费者本地,也就是说,假设其他消费者去消费这个分区的时候拿到的偏移量是2000那么又会从2000开始消费消息,2000到2500之间的消息又会被消费一遍,这就是重复消费的问题。

kafka对于这种问题也提供了解决方案:手动提交。你可以关闭默认的自动提交enable.auto.commit=false 然后使用kafka提供的API来进行偏移量提交: kafka提供了两种方式提交你的偏移量:同步(commitSync())和异步(commitAsync())

他们之间的区别在于:

  • 同步提交偏移量会等待服务器应答,并且遇到错误会尝试重试,但是会一定程度上影响性能不过能确保偏移量到底提交成功与否;
  • 而异步提交的对于性能肯定是有提升的,但是弊端也就像我们刚刚所提到,遇到错误没办法重试,因为可能在收到你这个结果的时候又提交过偏移量了,如果这时候重试,又会导致消息重复的问题了。

其实我们可以采用同步+异步的方式来保证提交的正确性以及服务器的性能。因为异步提交的话,如果出现问题但是不是致命问题的话,可能下一次提交就不会出现这个问题了,所以有些异常是不需要解决的(可能单纯的就是网络抽风了呢?),所以我们平时可以采用异步提交的方式,等到消费者中断了(遇到了致命问题,或是强制中断消费者)的时候再使用同步提交(因为这次如果失败了就没有下次了,所以要让他重试) 。下面代码演示的时候,我会在代码注释中标明。

consumer消费

kafka消费者,就是读取Partition里面的数据,并更新offset 消费者在读的时候也很有讲究:正常的读磁盘数据是需要将内核态数据拷贝到用户态的,而Kafka 通过调用sendfile(socket, file, len);零拷贝方式直接从内核空间(DMA)到内核空间(Socket)

运行流程如下:

  1. sendfile系统调用,文件数据被copy至内核缓冲区
  2. 再从内核缓冲区copy至内核中socket相关的缓冲区
  3. 最后再socket相关的缓冲区copy到协议引擎

Rebalance分区再均衡

这也是kafka里面非常重要的一个概念!首先 Rebalance 是一个操作,在以下情况下会触发Rebalance 操作:

  • 组成员发生变更(新consumer加入组、已有consumer主动离开组或已有consumer崩溃了)
  • 订阅主题数发生变更,如果你使用了正则表达式的方式进行订阅,那么新建匹配正则表达式的topic就会触发Rebalance
  • 订阅主题的分区数发生变更

当触发Rebalance,kafka会重新分配分区所有权,什么叫分区所有权?我们前面提到过,消费者有一个消费者组概念,而且一个消费者组在消费一个主题时有以下规则:

  • 一个消费者可以消费多个分区,但是一个分区只能被一个消费者消费。比如现在我有一个主题,里面有0、1、2三个分区(Partition),有两个消费者A、B,那么kafka可能会让消费者A消费0、1这两个分区,B消费2这一个分区,这时候我们就会说消费者A拥有分区0、1的所有权;消费者B拥有分区2的所有权。

我们根据上面这个比方,当消费者B离开了,此时就会触发Rebalance,kafka就会重新分配一下所有权,这个时候消费者组里只有一个消费者A,因此0、1、2三个分区所有权都属于A;同理,如果这时候消费者C加入到这个消费者组,那么kafka会确保每一个消费者都能消费一个分区(有一种情况,就是消费者数量大于分区数时,多余消费者就轮空休息)。

当触发Rebalance时,由于kafka正在分配所有权,会导致消费者不能消费,而且还会引发一个重复消费的问题,当消费者还没来得及提交偏移量时,分区所有权遭到了重新分配,那么这时候就会导致一个消息被多个消费者重复消费。

解决方案就是在消费者订阅时,添加一个再均衡监听器,也就是当kafka在做Rebalance操作前后均会调用再均衡监听器,那么这时候我们可以在Rebalance之前提交我们消费者最后处理的消息来解决这个问题。

代码实现

老规矩还是先来一把代码实现以及和springboot整合的代码,最后再说说可靠性、不重复消费以及顺序消费

/**
 * 生产者
 */
public class TestProducer {
    public static void main(String[] args) throws  Exception{
        Properties properties = new Properties();
        //指定kafka服务器地址,如果是集群可以指定多个,但是就算只指定一个他也会去集群环境下寻找其他的节点地址
        properties.setProperty("bootstrap.servers","127.0.0.1:9092");
        //key序列化器
        properties.setProperty("key.serializer", StringSerializer.class.getName());
        //value序列化器
        properties.setProperty("value.serializer",StringSerializer.class.getName());
        //设置acks,-1表示需要ISR中所有副本成功
        properties.setProperty(ProducerConfig.ACKS_CONFIG, "-1");
        KafkaProducer<String,String> kafkaProducer = new KafkaProducer<String, String>(properties);

        // 同步发送消息
        ProducerRecord<String, String> producerRecord1 = new ProducerRecord<String, String>(
            "my-topic",0,"testKey","hello sync message");
        Future<RecordMetadata> send = kafkaProducer.send(producerRecord1);
        RecordMetadata recordMetadata = send.get();
        // send.get(500, TimeUnit.MILLISECONDS); 同步发送可以指定超时时间,如果超时还未返回,则会抛出异常
        System.out.println("同步发送的主题:"+recordMetadata.topic()+" --- 分区:"+recordMetadata.partition());

        // 异步发送消息
        ProducerRecord<String, String> producerRecord2 = new ProducerRecord<String, String>(
            "my-topic",1,"testKey","hello async message");
        kafkaProducer.send(producerRecord2, new Callback() {
            @Override
            public void onCompletion(RecordMetadata metadata, Exception exception) {
                if (exception != null) {
                    System.out.println("异步发送失败");
                    return;
                }
                System.out.println("异步发送的主题:"+metadata.topic()+" --- 分区:"+metadata.partition());
            }
        });
    }
}

/**
 * 消费者
 */
public class TestConsumer {
    public static void main(String[] args) {
        Properties properties = new Properties();
        properties.setProperty("bootstrap.servers","127.0.0.1:9092");
        properties.setProperty("key.deserializer", StringDeserializer.class.getName());
        properties.setProperty("value.deserializer",StringDeserializer.class.getName());
        properties.setProperty("group.id","consumer-group1");   // 设置消费者组
        properties.setProperty("enable.auto.commit", "false");  // 关闭自动提交
        properties.put("max.poll.records", 100); // 一次最大拉取条数
        KafkaConsumer<String,String> consumer = new KafkaConsumer<String, String>(properties);
        consumer.subscribe(Collections.singletonList("my-topic"));  // 订阅主题

        try{
            int index = 0;
            while (true){
                // 消费者1秒拉取一次数据,如果没有数据,poll为空
                ConsumerRecords<String, String> poll = consumer.poll(Duration.ofSeconds(1));
                if (poll.isEmpty()) continue;
                for (ConsumerRecord<String, String> context : poll) {
                    System.out.println("消息所在分区: " + context.partition()
                        + " --- 消息的偏移量: " + context.offset()
                        + " --- key: " + context.key()
                        + " --- value: " + context.value());
                }
                // 获取了3次数据才提交一次,正常情况下异步提交到offset到Broker
                if (++index == 3) {
                    index = 0;
                    consumer.commitAsync();
                }
            }
        } catch (Exception e) {
            System.out.println(e.getMessage());
        } finally {
            // 当程序中断时同步提交
            consumer.commitSync();
        }
    }
}

上面的代码消费者采用手动提交offset,而且同时使用了异步提交和同步提交。

上面代码是消费者订阅主题模式,对于订阅主题的方式,哪些分区的消息属于你来消费是由kafka分配的。有时你的消费者不想订阅某个主题,也不想加入上面消费者组,只想订阅某个或多个主题下的某个或多个分区,这时可以采用分配方式,分配方式的消费者代码如下:

KafkaConsumer<String,String> consumer = new KafkaConsumer<String, String>(properties);
List<TopicPartition> list = new ArrayList<>();
//new出一个分区对象 声明这个分区是哪个topic下面的哪个分区
list.add(new TopicPartition("my-topic", 0));
list.add(new TopicPartition("test-topic", 1));
//分配这个消费者所需要消费的分区, 传入一个分区对象集合
consumer.assign(list);

和SpringBoot整合

  1. 添加kafka依赖
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
  1. application.yml配置kafka
spring:
  kafka:
    bootstrap-servers: 127.0.0.1:9092
    producer:
      # 生产者发送消息重试次数,默认值3
      retries: 3
      # 生产者批次发送消息大小,默认值16kB
      batch-size: 16384
      # Producer缓存区整个可用的内存,默认32MB
      buffer-memory: 33554432
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
    consumer:
      group-id: consumer-group1
      # 手动提交
      enable-auto-commit: false
      # 当消费者本地没有对应分区的offset时,有三个earliest、latest、none
      # earliest: 当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
      # latest: 当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从最后开始消费
      # none: topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
      auto-offset-reset: earliest
      # 一次最多接收多少消息,默认就是500
      max-poll-records: 500
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      properties:
        # 消费者多久没有发送心跳给服务器服务器则认为消费者死亡/退出消费者组 默认值:10000ms
        session.timeout.ms: 10000
        # 消费者往kafka服务器发送心跳的间隔 一般设置为session.timeout.ms的三分之一 默认值:3000ms
        heartbeat.interval.ms: 3000
    listener:
      # 开启批量消费
      type: batch
      # 同时消费的监听器,建议与分区数量一致
      concurrency: 3
      # 手动提交
      ack-mode: manual_immediate
  1. 消息发送者和消息消费者代码
@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class ApplicationTests {
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

   /**
     * 测试同步发送消息
     */
    @Test
    public void testSendSync() throws ExecutionException, InterruptedException {
        for (int i=0; i<20; i++) {
            ListenableFuture<SendResult<String, String>> future = kafkaTemplate.send("my-topic", 0, "key1", "hello,kafka sync...");
            RecordMetadata recordMetadata = future.get().getRecordMetadata();
            log.info("同步发送的主题:{} --- 分区: {}", recordMetadata.topic(), recordMetadata.partition());
        }
    }

    /**
     * 测试异步发送消息
     */
    @Test
    public void testSendAsync() throws ExecutionException, InterruptedException {
        for (int i=0; i<20; i++) {
            kafkaTemplate.send("my-topic","hello,kafka async...")
                .addCallback(new ListenableFutureCallback<SendResult<String, String>>() {
                @Override
                public void onSuccess(SendResult<String, String> objectObjectSendResult) {
                    RecordMetadata recordMetadata = objectObjectSendResult.getRecordMetadata();
                    log.info("异步发送的主题:{} --- 分区: {}", recordMetadata.topic(), recordMetadata.partition());
                }
                @Override
                public void onFailure(Throwable throwable) {
                    log.error(throwable.getMessage());
                }
            });
        }
    }
}

/**
 * 消息消费者
 */
@Component
@Slf4j
public class MessageHandler {
    @KafkaListener(topics = "my-topic")
    public void handleMessage(List<ConsumerRecord> recordList, Acknowledgment acknowledgment) {
        log.info("接收到消息数量:{}", recordList.size());
        for (ConsumerRecord record : recordList) {
            log.info("消息所在分区: {} --- 消息的偏移量: {} --- key: {} --- value: {}",
                record.partition(), record.offset(), record.key(), record.value());
        }
        // 手动提交 offset
        acknowledgment.acknowledge();
    }
}

上面例子采用纯yml配置kafka方式,我们也可以java bean方式来配置,如下面代码:

@Configuration
@EnableConfigurationProperties({KafkaProperties.class})
@EnableKafka
public class KafkaConfig {
    private final KafkaProperties kafkaProperties;

    public KafkaConfig(KafkaProperties kafkaProperties) {
        this.kafkaProperties = kafkaProperties;
    }

    @Bean
    public KafkaTemplate<String, String> kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }

    @Bean
    public ProducerFactory<String, String> producerFactory() {
        return new DefaultKafkaProducerFactory<>(kafkaProperties.buildProducerProperties());
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
        ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory());
        factory.setConcurrency(2);
        factory.setBatchListener(true);
        factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
        return factory;
    }

    @Bean
    public ConsumerFactory<String, String> consumerFactory() {
        return new DefaultKafkaConsumerFactory<>(kafkaProperties.buildConsumerProperties());
    }
}

事务消息

yml添加配置spring.kafka.producer.transaction-id-prefix=kafka_tx激活事务特性。事务激活后,所有的消息发送只能在发生事务的方法内执行,不然会抛出没有事务的异常。

当发送消息有事务要求时,所有消息发送成功才算成功,比如:假设第一条消息发送后,在发二条消息前出现了异常,则第一条已经发送的消息也会回滚。而且一个事务内的消息需要全部发送完后,消费端才会拉取的到消息,事务代码有下面两种写法:

@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class SpringBootDemoMqKafkaApplicationTests {
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Test
    public void testTransactionSend1() {
        kafkaTemplate.executeInTransaction(t -> {
            t.send("my-topic", "hello,transaction message1");
            // xxx 一系列处理
            t.send("my-topic", "hello,transaction message2");
            return true;
        });
    }

    @Test
    @Transactional(rollbackFor = RuntimeException.class)
    public void testTransactionSend2() {
        kafkaTemplate.executeInTransaction(t -> {
            t.send("my-topic", "hello,annotation transaction message1");
            if (1==1) {
                throw new RuntimeException("error");
            }
            t.send("my-topic", "hello,annotation transaction message2");
            return true;
        });
    }
}

调用testTransactionSend1()的测试,消费端会接收到两条消息,而下面的一条消息也不会接收到。

SendTo消息转发

@SendTo注解可以带一个参数,指定转发的Topic队列,常见的场景,一个消息需要做多重加工,不同的加工耗费的cup等资源不一致,就可以通过跨不同的Topic和部署在不同主机上consumer来解决。

    @KafkaListener(topics = "test-topic")
    @SendTo("my-topic")
    public String listen(List<ConsumerRecord> recordList, Acknowledgment acknowledgment) {
        log.info("test-topic消息数量:{}", recordList.size());
        for (ConsumerRecord record : recordList) {
            log.info("消息所在分区: {} --- 消息的偏移量: {} --- key: {} --- value: {}",
                record.partition(), record.offset(), record.key(), record.value());
        }
        // 手动提交 offset
        acknowledgment.acknowledge();
        return "test sendTo my: " + recordList.get(0).value();
    }

提示:@KafkaListener注解的方法,第一个参数时List,是因为yml配置了spring.kafka.listener.type=batch(或java bean配置ConcurrentKafkaListenerContainerFactory<String, String>时,factory.setBatchListener(true);是一样的),默认不配是single的,当为single时,方法的第一个参数就不能是List了

死信队列

在将RabbitMQ的时候也说过死信队列了,当消费者消费数据出现异常,重试这个消息,直到到达预设的重试次数后,消息就会进入死信队列。代码演示如下:

@Component
@Slf4j
public class MessageHandler {
    @Autowired
    private KafkaTemplate kafkaTemplate;

    @Autowired
    private ConsumerFactory consumerFactory;

    @Bean
    public ConcurrentKafkaListenerContainerFactory retryContainerFactory() {
        ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory();
        factory.setConsumerFactory(consumerFactory);
        // 最大重试次数3次
        factory.setErrorHandler(new SeekToCurrentErrorHandler(new DeadLetterPublishingRecoverer(kafkaTemplate), 3));
        // 手动提交
        factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
        return factory;
    }
    
    // 监听test-topic,并设置containerFactory为上面的retryContainerFactory(主要是设置重试次数和手动提交)
    @KafkaListener(groupId = "retry-group", topics = "test-topic", containerFactory = "retryContainerFactory")
    public void retryListen(ConsumerRecord record) {
        log.info("retry...消息所在分区: {} --- 消息的偏移量: {} --- key: {} --- value: {}",
            record.partition(), record.offset(), record.key(), record.value());

        throw new RuntimeException("dlt");
    }

    // 监听test-topic对应的死信队列
    @KafkaListener(topics = "test-topic.DLT")
    public void dltListen(List<ConsumerRecord> recordList, Acknowledgment acknowledgment) {
        log.info("DLT消息数量:{}", recordList.size());
        for (ConsumerRecord record : recordList) {
            log.info("消息所在分区: {} --- 消息的偏移量: {} --- key: {} --- value: {}",
                record.partition(), record.offset(), record.key(), record.value());
        }
        acknowledgment.acknowledge();
    }
}

// 测试发送到test-topic
// 提示:测试的时候,把yml配置的激活事务给注释掉
@Test
public void testSend() {
    kafkaTemplate.send(test-topic, "hello, test ...");
}

上面的代码,发送到test-topic后,retry...消息内容会打印四次,第一次为正常接收,剩余三次为重试次数,当到达我们设置的最大重试次数3后,消息就会被丢到死信队列。死信队列的Topic规则是,业务topic的名字(test-topic) + ".DLT" ,上面就是"test-topic.DLT" 。之后在死信队列监控里会打印出消息。

可靠性保证

同样我们分为三部分考虑,生产者发送到kafka、kafka自身的存储、kafka到消费者

生产者发送到kafka

这个上面也提到了,我们设置producer的acks为-1,kafka需要等待ISR中的所有follower都确认接收到数据后才算一次发送完成,可靠性最高。当然性能可能就没其他两种高了,我们得根据实际的应用场景,对数据的可靠性要求和合理配置此参数。

kafka自身的存储

kafka天生就是分布式集群方式运行,数据存储在Partition,我们可以设置每个Partition副本数,默认kafka配置文件中是1,我们可以设置多个比如3(Partition副本数最大为10,且不能大于Broker个数),也可以在代码中创建专题的时候指定分区数。如果producer的acks不是-1,则某个Broker挂掉的时候,follower还没完全复制改Broker下leader分区的数据,则可能会存在丢失一部分数据。

kafka到消费者

kafka存储的数据默认是保存7天,并不会想RabbitMQ那样,消费完了就会删除,而且这个保存时间是可以设置的,所以我们并不太需要考虑保存到kafka上的数据会丢失。我们只是需要关心消费者消费到哪里了,上面代码也说了,改为手动提交offset;还有一个配置spring.kafka.consumer.auto-offset-reset=latest有三个值可选,earliest、latest、none上面在yml里也说明的很清楚了,你得根据你实际的应用来做出正确的选择。

不重复消息

kafka保存的消息完全不关系你消费多少次,消费次数由消费者自己控制,就是提交offset,所以为了保证不重复消费消息的情况,你只能自己做幂等。跟上篇文章一样,给每个消息加业务上的唯一id来判断。

顺序消费

顺序消费也很简单,kafka中的每个Partition中的消息在写入时都是有序的,所以我们只需要保证consumer每次都从这一个分区中一个一个的消费数据就可以了,具体操作就是:

  • 设置topic的Partition为1;
  • 设置consumer只能单个消费(默认就是single单个的);
  • 设置consumer手动提交offset,业务逻辑处理完一个,手动ack,然后再消费下一个。

创作不易,如果觉得总结的好请来个三连击。

上篇文章消息中间件系列一:RabbitMQ链接