kafka 防止消息丢失方案

40 阅读7分钟

kafka 基本概念

名称解释
Broker消息中间件处理节点,一个Kafka节点就是一个broker,一个或者多个Broker可以组成一个Kafka集群
TopicKafka根据topic对消息进行归类,发布到Kafka集群的每条
Partition物理上的概念,一个topic可以分为多个partition,每个partition内部消息是有序的
Producer消息生产者,向Broker发送消息的客户端
Consumer消息消费者,从Broker读取消息的客户端
ConsumerGroup每个Consumer属于一个特定的Consumer Group,一条消息可以被多个不同的Consumer Group消费,但是一个Consumer Group中只能有一个Consumer能够消费该消息

如何防止消息丢失

生产者端

原因

1.网络抖动

2.生产者服务器异常

解决方案

  1. 设置重试次数、重试时间间隔
  2. 发送异步消息的时候,通过callback 回调通知结果进行重试
  3. 使用消息表,发送消息之前保存消息发送记录,定时回查消息表未发送、发送失败的重新发送

消费端

原因

1.设置了自动提交offset

解决方案

1.设置成手动提交

Broker

原因

1.broker挂了

解决方案

消息是先写到 page cache 在刷新到磁盘上去,page cache 没有刷新到磁盘,broker宕机了,重启可以解决。

但是如果此时操作系统宕机或者物理机宕机了,page cache里的数据还没有持久化到磁盘里,此种情况数据就丢了。

broker多副本机制来解决

  1. 生产端参数 ack 设置为all 代表消息需要写入到“大多数”的副本分区后,leader broker才给生产端应答消息写入成功。(即写入了“大多数”机器的page cache里)
  2. 发出消息持久化机制参数 (1)acks=0: 表示producer不需要等待任何broker确认收到消息的回复,就可以继续发送下一条消息。性能最高,但是最容易丢消息。 (2)acks=1: 至少要等待leader已经成功将数据写入本地log,但是不需要等待所有follower是否成功写入。就可以继续发送下一条消息。这种情况下,如果follower没有成功备份数据,而此时leader 又挂掉,则消息会丢失。 (3)acks=‐1或all: 这意味着leader需要等待所有备份(min.insync.replicas配置的备份个数)都成功写入日志,这种策略会保证只要有 一个备份存活就不会丢失数据。这是最强的数据保证。一般除非是金融级别,或跟钱打交道的场景才会使用这种配置。
  3. broker端 配置 min.insync.replicas参数设置至少为2 此参数代表了 上面的“大多数”副本。为2表示除了写入leader分区外,还需要写入到一个follower 分区副本里,broker端才会应答给生产端消息写入成功。此参数设置需要搭配第一个参数使用。
  4. broker端配置 default.replicator.factor参数至少3 此参数表示:topic每个分区的副本数。如果配置为2,表示每个分区只有2个副本,在加上第二个参数消息写入时至少写入2个分区副本,则整个写入逻辑就表示集群中topic的分区副本不能有一个宕机。如果配置为3,则topic的每个分区副本数为3,再加上第二个参数min.insync.replicas为2,即每次,只需要写入2个分区副本即可,另外一个宕机也不影响,在保证了消息不丢的情况下,也能提高分区的可用性;只是有点费空间,毕竟多保存了一份相同的数据到另外一台机器上

整合java原生使用

<dependency>
 <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>3.0.2</version>
</dependency>
/**
 * 消息生产者
 *
 * @author LGC
 */
public class MsgProducer {

    public static void main(String[] args) throws Exception {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.1.87:9092");
        /*
          发出消息持久化机制参数
         (1)acks=0: 表示producer不需要等待任何broker确认收到消息的回复,就可以继续发送下一条消息。性能最高,但是最容易丢消息。
         (2)acks=1: 至少要等待leader已经成功将数据写入本地log,但是不需要等待所有follower是否成功写入。就可以继续发送下一条消息。
             这种情况下,如果follower没有成功备份数据,而此时leader 又挂掉,则消息会丢失。
         (3)acks=‐1或all: 这意味着leader需要等待所有备份(min.insync.replicas配置的备份个数)都成功写入日志,这种策略会保证只要有 一个备份存活就不会丢失数据。
             这是最强的数据保证。一般除非是金融级别,或跟钱打交道的场景才会使用这种配置。
        */
        props.put(ProducerConfig.ACKS_CONFIG, "1");
        // 发送失败会重试,默认重试间隔100ms,重试能保证消息发送的可靠性,但是也可能造成消息重复发送,比如网络抖动,所以需要在接收者那 边做好消息接收的幂等性处理
        props.put(ProducerConfig.RETRIES_CONFIG, 3);
        // 重试间隔设置
        props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 300);
        // 设置发送消息的本地缓冲区,如果设置了该缓冲区,消息会先发送到本地缓冲区,可以提高消息发送性能,默认值是33554432,即32MB
        props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432);
        // kafka本地线程会从缓冲区取数据,批量发送到broker,
        // 设置批量发送消息的大小,默认值是16384,即16kb,就是说一个batch满了16kb就发送出去
        props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384);
        // 默认值是0,意思就是消息必须立即被发送,但这样会影响性能
        // 一般设置100毫秒左右,就是说这个消息发送完后会进入本地的一个batch,如果100毫秒内,这个batch满了16kb就会随batch一起被发送出去

        // 如果100毫秒内,batch没满,那么也必须把消息发送出去,不能让消息的发送延迟时间太长
        props.put(ProducerConfig.LINGER_MS_CONFIG, 100);
        // 把发送的key从字符串序列化为字节数组
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        // 把发送消息value从字符串序列化为字节数组
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        Producer<String, String> producer = new KafkaProducer<>(props);
        int msgNum = 10;
        CountDownLatch countDownLatch = new CountDownLatch(msgNum);
        for (int i = 1; i <= msgNum; i++) {
            OrderDTO order = new OrderDTO(i, 100 + i, 1, 1000);
            // 指定发送分区
//            ProducerRecord<String, String> producerRecord = new ProducerRecord<>("test", 0, order.getOrderId().toString(), JSON.toJSONString(dto));

            // 未指定发送分区,具体发送的分区计算公式:hash(key)%partitionNum
            ProducerRecord<String, String> producerRecord = new ProducerRecord<>("test", order.getOrderId().toString(), JSON.toJSONString(order));

            // 等待消息发送成功的同步阻塞方法
//             RecordMetadata metadata = producer.send(producerRecord).get();
//             System.out.println("同步方式发送消息结果:" + "topic‐" + metadata.topic() + "|partition‐" + metadata.partition() + "|offset‐" + metadata.offset());

            //异步方式发送消息
            producer.send(producerRecord, new Callback() {
                @Override
                public void onCompletion(RecordMetadata metadata, Exception exception) {
                    if (exception != null) {
                        System.err.println("发送消息失败:" + exception.getStackTrace());
                    }
                    if (metadata != null) {
                        System.out.println("异步方式发送消息结果:" + "topic‐" + metadata.topic() + "|partition‐" + metadata.partition() + "|offset‐" + metadata.offset());
                    }
                    countDownLatch.countDown();
                }
            });
        }
        //送积分TODO
        countDownLatch.await(5, TimeUnit.SECONDS);
        producer.close();
    }
}

/**
 * 消息消费者
 *
 * @author LGC
 */
public class MsgConsumer {
    public static void main(String[] args) {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.1.87:9092");
        // 消费分组名
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "group");
        // 是否自动提交offset
        /*
          props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
          // 自动提交offset的间隔时间
          props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG , "1000");
        */
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");

        /*
          心跳时间,服务端broker通过心跳确认consumer是否故障,如果发现故障,就会通过心跳下发
          rebalance的指令给其他的consumer通知他们进行rebalance操作,这个时间可以稍微短一点
        */
        props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 1000);

        // 服务端broker多久感知不到一个consumer心跳就认为他故障了,默认是10秒
        props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 10 * 1000);
        /*
          如果两次poll操作间隔超过了这个时间,broker就会认为这个consumer处理能力太弱,
          会将其踢出消费组,将分区分配给别的consumer消费
        */
        props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 30 * 1000);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        // 消费主题
        String topicName = "test";
        consumer.subscribe(Collections.singletonList(topicName));
        // 消费指定分区
        // consumer.assign(Arrays.asList(new TopicPartition(topicName, 0)));
        // 消息回溯消费
        // consumer.assign(Arrays.asList(new TopicPartition(topicName, 0)));
        // consumer.seekToBeginning(Arrays.asList(new TopicPartition(topicName, 0)));
        // 指定offset消费
        // consumer.seek(new TopicPartition(topicName, 0), 10);
        while (true) {
            /*
              poll() API 是拉取消息的长轮询,主要是判断consumer是否还活着,只要我们持续调用poll(),
              消费者就会存活在自己所在的group中,并且持续的消费指定partition的消息。
              底层是这么做的:消费者向server持续发送心跳,如果一个时间段(session.timeout.ms)consumer挂掉或是不能发送心跳,
              这个消费者会被认为是挂掉了,这个Partition也会被重新分配给其他consumer
             */
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Integer.MAX_VALUE));
            for (ConsumerRecord<String, String> record : records) {
                System.out.printf("收到消息:offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
            }
            if (records.count() > 0) {
                // 提交offset
                consumer.commitSync();
            }
        }
    }
}