Kafka复习计划 - 客户端实践及原理(连接器/TCP的管理/幂等性和事务)

一. Kafka的拦截器

Kafka拦截器可以再消息处理的前后插入对应的处理逻辑。其分为两种:

  • 生产者拦截器:可作用于 发送消息前消息提交成功后 两个时间点。
  • 消费者拦截器:可作用于 消费消息前提交位移后 两个时间点。

倘若需要配置对应的拦截器,需要注意这么几个点:

  1. 生产者端和消费者端都有一个相同的参数,名字为interceptor.classes,它代表一组拦截器列表
  2. 生产者端拦截器需要实现org.apache.kafka.clients.producer.ProducerInterceptor接口。
  3. 消费者端拦截器需要实现org.apache.kafka.clients.consumer.ConsumerInterceptor接口。
  4. 其中配置的值是对应实现类的全类名

例如下面的伪代码:

Properties props = new Properties();

List<String> interceptors = new ArrayList<>();
interceptors.add("com.test.MyInterceptor"); 
interceptors.add("com.test.MyInterceptor2"); 

props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,interceptors);

生产者拦截器中,需要实现两个具体方法:

  1. onSend()在消息发送之前被调用。一般用于在消息发送之前对消息进行封装。
  2. onAcknowledgement():该方法会在消息成功提交或发送失败之后被调用。值得注意的是:该方法的调用要send(message,callback)函数中callback的调用。

消费者拦截器中,同样需要实现两个方法:

  1. onConsume():该方法在消息返回给 Consumer 程序之前调用。
  2. onCommit()Consumer提交位移之后调用该方法。可以用来记录日志。

接下来就来给个案例,从搭建环境开始到编写自定义的拦截器。

1.1 拦截器 - 环境准备

本次使用Docker来进行环境安装。如果使用安装包安装的,可以参考我这篇文章(附带了安装软件的下载地址)深入理解Kafka系列(一)–初识Kafka

1.下载ZookeeperKafka的镜像:

docker pull wurstmeister/zookeeper
docker pull wurstmeister/kafka

2.在/mydata/zookeeper/conf路径下创建Zookeeper的配置文件(你自己的机器可以自己找一个路径):vi zoo.cfg

# 先提前在安装目录下创建一个文件夹zkData:mkdir zkData,用来保存数据和日志
dataDir=/mydata/zookeeper/data

3.启动Zookeeper,并进行挂载,这样方便修改配置文件:

docker run -d --name zookeeper -p 2181:2181 --restart=always \
-v /mydata/zookeeper/data:/data \
-v /mydata/zookeeper/conf:/conf wurstmeister/zookeeper 

4.校验Zookeeper是否启动成功:docker ps
在这里插入图片描述

5.启动Kafka

docker run -d --name kafka -p 9092:9092 \
-e KAFKA_BROKER_ID=1 \
-e KAFKA_ZOOKEEPER_CONNECT=(服务器外网IP):2181 \
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://(服务器外网IP):9092 \
-e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \
-v /etc/localtime:/etc/localtime -t wurstmeister/kafka

结果如下:
在这里插入图片描述

1.2 创建个主题

1.查看容器docker ps。记住Kafka的容器ID
2.运行命令:

docker exec -it 6cdd634814d5 bash

3.进入对应的目录:/opt/kafka_2.13-2.8.1/bin,你的版本可能不一样,但是目录是/opt下的没问题。可以看到这里有很多脚本:
在这里插入图片描述
4.创建主题:

./bin/kafka-topics.sh --zookeeper (你的Zookeeper地址):2181 \
--partitions 1 --replication-factor 1 --create --topic test

结果如下:
在这里插入图片描述

1.3 准备 - 验证生产者能否发送消息

1.pom依赖:

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>0.11.0.0</version>
</dependency>
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka_2.12</artifactId>
    <version>0.11.0.0</version>
</dependency>

2.生产者程序:

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;

import java.util.Properties;

/**
 * @author Zong0915
 * @date 2022/7/13 下午7:46
 */
public class Test {
    public static void main(String[] args) {
        Properties properties = new Properties();
        properties.put("bootstrap.servers", "你的服务器地址:9092");
        properties.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
        for (int i = 0; i < 3; i++) {
        	// 实际写代码的时候,请使用带回调函数的send重载方法。
            producer.send(new ProducerRecord<String, String>("test", Integer.toString(i), "message" + i));
        }
        producer.close();
    }
}

3.提前在Kafka容器中输入命令,等待消息的接收

./kafka-console-consumer.sh --bootstrap-server [你的服务器地址]:9092 --from-beginning --topic test

4.运行程序后,在容器中观察结果:
在这里插入图片描述

1.4 准备 - 验证消费者能否接收消息

1.消费者端代码:

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;

import java.util.Collections;
import java.util.Properties;

/**
 * @author Zong0915
 * @date 2022/7/13 下午7:55
 */
public class Consumer {
    public static void main(String[] args) {
        Properties properties = new Properties();
        properties.put("bootstrap.servers", "你的服务器地址:9092");
        properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put("group.id", "test-consumer");
        // 1.创建KafkaConsumer
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
        // 2.订阅主题
        consumer.subscribe(Collections.singletonList("test"));
        // 3.轮询消费
        try {
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(100);
                for (ConsumerRecord<String, String> record : records) {
                    System.out.println("topic = " + record.topic() +
                            ", partition = " + record.partition() +
                            ", offset = " + record.offset() +
                            ", customer = " + record.key() +
                            ", country = " + record.value());
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }
    }
}

2.运行消费者端代码,准备接收消息。

3.运行一遍1.3节中的生产者代码,发送三条消息,观察消费者端窗口输出:
在这里插入图片描述

到这里,程序就串起来啦,接下来开始写自定义的拦截器。

1.5 拦截器案例 - 计算消息的平均处理时长

需求:我们希望一条消息,从被生产出来到最后被消费的平均总时长是多少。

大致思路:在生产端和消费端都添加一个拦截器,用于计算前后的时间差,其中,利用Redis来存储开始的时间。

由于要使用到Redis,添加pom依赖:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.1.1</version>
</dependency>

1.生产者拦截器如下:

import org.apache.kafka.clients.producer.ProducerInterceptor;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import redis.clients.jedis.Jedis;

import java.util.Map;

/**
 * @author Zong0915
 * @date 2022/7/13 下午8:07
 */
public class MyProducerInterceptor implements ProducerInterceptor<String, String> {
    private static Jedis jedis = null;

    static {
        jedis = new Jedis("你的服务器地址", 6379);
        // 如果你设置了密码
        jedis.auth("xxx");
    }

    @Override
    public ProducerRecord<String, String> onSend(ProducerRecord<String, String> producerRecord) {
        // 发送的数据总数+1
        jedis.incr("totalSentMessage");
        return producerRecord;
    }

    @Override
    public void onAcknowledgement(RecordMetadata recordMetadata, Exception e) {

    }

    @Override
    public void close() {

    }

    @Override
    public void configure(Map<String, ?> map) {

    }
}

2.消费者拦截器如下:

import org.apache.kafka.clients.consumer.ConsumerInterceptor;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;
import redis.clients.jedis.Jedis;

import java.util.Map;

/**
 * @author Zong0915
 * @date 2022/7/13 下午8:10
 */
public class MyConsumerInterceptor implements ConsumerInterceptor<String, String> {
    private static Jedis jedis = null;

    static {
        jedis = new Jedis("你的服务器地址", 6379);
        // 如果你设置了密码
        jedis.auth("xxx");
    }

    @Override
    public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> consumerRecords) {
        long count = 0L;
        for (ConsumerRecord<String, String> record : consumerRecords) {
            count += (System.currentTimeMillis() - record.timestamp());
        }
        jedis.incrBy("totalTimeCount", count);
        // 所有消息的总耗时
        long totalTimeCount = Long.parseLong(jedis.get("totalTimeCount"));
        // 消息总数
        long totalSentMessage = Long.parseLong(jedis.get("totalSentMessage"));
        String avgLatency = String.valueOf(totalTimeCount / totalSentMessage);
        jedis.set("avgLatency", avgLatency);
        System.out.println("平均耗时:" + avgLatency + "ms");
        return consumerRecords;
    }

    @Override
    public void onCommit(Map<TopicPartition, OffsetAndMetadata> map) {

    }

    @Override
    public void close() {

    }

    @Override
    public void configure(Map<String, ?> map) {

    }
}

3.先运行消费者,再运行生产者,可见结果如下:
在这里插入图片描述

当然这个数值可能会有出入,不过整个流程和思路已经是跑通了。案例到这里也就结束了。

二. TCP管理

背景:Kafka相关的通信都是基于TCP的,而非HTTP或是其他协议。那么Kafka为何使用TCP来进行通信呢,主要有两个原因(了解即可):

  1. 社区认为,在开发客户端的时候,可以合理利用TCP本身提供的高级特性,例如多路复用请求以及轮询多连接的能力。
  2. 目前已知的 HTTP 库在很多编程语言中都略显简陋。

2.1 生产者管理TCP

首先我们把上文中,一个简单的生产者代码搬出来:

// 创建生产者
KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
// 发送消息
producer.send(new ProducerRecord<String, String>("test", Integer.toString(i), "message" + i));
// 关闭生产者
producer.close();

2.1.1 创建TCP的时机

创建TCP的时机从代码上可以推断可能有两处:

  1. new KafkaProducer<>(properties)
  2. producer.send()

首先对于第一点,关于KafkaProducer实例的创建,其会在后台创建一个名为Sender的线程,并将其启动,他会首先与Broker建立连接,以下是KafkaProducer构造函数中的部分代码(会在后续的章节去专门学习Kafka的源码):

KafkaProducer(ProducerConfig config,
                  Serializer<K> keySerializer,
                  Serializer<V> valueSerializer,
                  ProducerMetadata metadata,
                  KafkaClient kafkaClient,
                  ProducerInterceptors<K, V> interceptors,
                  Time time) {
   try {
    	this.sender = newSender(logContext, kafkaClient, this.metadata);
        String ioThreadName = NETWORK_THREAD_PREFIX + " | " + clientId;
        this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
        this.ioThread.start();
        config.logUnused();
        AppInfoParser.registerAppInfo(JMX_PREFIX, clientId, metrics, time.milliseconds());
        log.debug("Kafka producer started");
    } catch (Throwable t) {
        // ....
    }
}

目前KafkaProducer设计的启动过程是:

  1. bootstrap.servers参数指定了Broker的地址。
  2. KafkaProducer启动的时候会发起与这些Broker的连接。

注意:

  • bootstrap.servers中,没必要把整个Kafka集群的IP地址全部写上去,倘若有1000个,那么KafkaProducer启动的时候就会和1000个Broker发起TCP连接。
  • 同时, Producer 一旦连接到集群中的任一台Broker,就能拿到整个集群的 Broker 信息。 因此,通常只需要配置 3 - 4 台即可。

我们目前可以得知:创建KafkaProducer实例的时候,就会创建TCP连接。那么除此之外还可能发生在两个地方:

  1. 更新元数据。可能有两个场景:1.当 Producer 尝试给一个不存在的主题发送消息时。2.Producer 通过 metadata.max.age.ms 参数定期地去更新元数据信息。
  2. 消息发送时。

上述两种情况并不是必然发生的,在更新元数据或者发送消息的时候,当生产者发现某些Broker当前没有TCP连接,此时才会创建。

2.1.2 关闭TCP的时机

关闭TCP的时机有两种:

  1. 用户主动关闭,例如producer.close() 方法。
  2. Kafka自动关闭。

Producer端有一个参数:connections.max.idle.ms,默认情况下改参数的值为9分钟,意思是:在 9 分钟内没有任何请求流过某个TCP 连接,那么 Kafka 会主动帮你把该 TCP 连接关闭。不然,也可以将这个值设置为 -1 ,那么此时TCP连接将成为永久的长连接。

总结下就是:

  1. 生产者端:创建KafkaProducer实例的时候,会启动Sender线程,会和bootstrap.servers配置的所有Broker进行TCP连接。
  2. KafkaProducer实例首次更新元数据信息之后,会再次与所有的Broker进行TCP连接。
  3. Producer发送消息的时候,发现与某台Broker没有建立TCP连接,那么创建。
  4. Producerconnections.max.idle.ms 参数大于0,那么在指定的时间内,某个TCP没有流过任何的请求,此时关闭该TCP连接。

2.2 消费者管理TCP

同样地,这里把消费者的程序段搬过来先:

// 1.创建KafkaConsumer
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
// 2.订阅主题
consumer.subscribe(Collections.singletonList("test"));
// 3.轮询消费
try {
    while (true) {
    	// 取一个批次的消息
        ConsumerRecords<String, String> records = consumer.poll(100);
        for (ConsumerRecord<String, String> record : records) {
            // doSomething()
        }
    }
} catch (Exception e) {
    e.printStackTrace();
} finally {
    consumer.close();
}

2.2.1 创建TCP的时机

KafkaConsumerKafkaProducer不同的是,KafkaConsumer并不会在创建实例的时候创建TCP连接。而 TCP 连接是在调用 KafkaConsumer.poll() 方法时被创建的,其中可能在以下三个场景创建TCP

  • 发起FindCoordinator请求时。目的是确定协调者和获取集群元数据。
  • 连接协调者Coordinator时。目的是连接协调者,令其执行组成员管理操作。
  • 消费数据时。此时会为每个要消费的分区创建与该分区领导者副本所在 Broker 连接的 TCP。目的是执行实际的消息获取。

协调者Coordinator相关概念:

  1. 存在于Broker端的内存中,负责消费组中的组成员管理以及各个消费者的位移提交管理
  2. 集群中负载最小的那一台Broker将会成为协调者。
  3. 消费者需要和协调者所在的Broker建立TCP连接,这样才能够进行相关的操作。例如:加入组、等待组分配方案、心跳请求处理、位移获取、位移提交等。

2.2.2 关闭TCP的时机

同样消费者关闭TCP的时机也有两个:

  1. 主动关闭:KafkaConsumer.close()。或者是用户将该进程停止。
  2. 自动关闭:connection.max.idle.ms

2.3 题外话 - Java的对象逃逸问题

Java里面,对于对象的溢出有两种常见的情况:

  1. 在构造函数中注册事件的监听。
  2. 在构造函数中启动新线程。

当然,上面的两种我个人觉得它只是一个场景,而只有满足了指定的动作才会发生溢出的现象,总额的来说就是:

  1. 在执行构造函数的时候,外部类对象的创建过程可能还没有结束(这里指的是构造函数)。
  2. 这个时候如果内部类(这里指的是监听器或者内部启动的新线程)访问了外部类中的数据。
  3. 此时可能得到的数据并没有被正确地初始化。

我们以第二个例子,启动新线程为例,来看下这个案例:

public class EscapeDemo {
    private int count = 0;

    public EscapeDemo() {
        count = 1;
        new Thread(() -> {
            System.out.println(EscapeDemo.this.count);
        }).start();
        for (int i = 0; i < 1000; i++) {
            count++;
        }
    }

    public static void main(String[] args) {
        new EscapeDemo();
    }
}

想想看,按照代码的逻辑顺序,我们在构造函数内部新启动了一个线程,这里我们理想的值打印出来应该是1,那么实际效果会是啥呢?请看结果:
在这里插入图片描述

这就是一个典型的对象逃逸问题。而根据2.1小节的内容来看,Kafka的生产者KafkaProducer在创建实例的时候,就会启动一个Sender线程,那么这个场景对应着Java的对象逃逸情形里的其中一种。就不合适了。

三. Kafka的幂等性和事务介绍

我们知道,只有Broker成功提交消息并且Producer端接收到Broker的应答才会认定为消息发送成功。倘若消息在成功发送到Broker的前提下,但是Broker的应答却因网络波动等因素没有返回,那么此时Producer端无法确定消息是否真的提交成功,只能通过重试来再次发送消息。 因此Kafka提供至少一次的可靠性保障,但是这会导致消息重复发送。

那么如何保证消息只有一条并且消息不会丢失也不会被重复处理呢?通过两种机制:

  • 幂等性。
  • 事务性。

3.1 幂等性

Producer默认不具有幂等性,但是我们可以通过开启配置来创建一个幂等性的Producer。开启幂等性非常简单,如下:

// 方式一
properties.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
// 方式二
properties.put("enable.idempotence", true);

开启幂等性之后,Kafka就会自动做消息的去重。底层逻辑大概如下:

  1. Broker端会对每个消息多保存一些字段。
  2. Producer发送了一些具有相同字段值的消息后。此时Broker就能够识别哪些消息是重复的。此时就将这些重复的消息给排除掉。

注意点:

  1. 该特性只能够保证单分区上的幂等性:保证某个主题的某一个分区上不存在重复消息但是无法保证多分区的幂等性。
  2. 只能够实现单会话(Producer进程的一次运行)上的幂等性,不能实现跨会话的幂等性。

因此,倘若想实现多分区以及多会话上的消息无重复,仅仅靠幂等性是不行的,此时应该依赖事务的运用。

3.2 事务性

事务型的Producer能够保证将消息原子性地写入到多个分区。要么这批消息全部写入成功,要么全部失败。

开启条件:

  1. enable.idempotence =true
  2. 设置transctional. id的值。

如何使用:

public static void main(String[] args) {
    Properties properties = new Properties();
    properties.put("bootstrap.servers", "你的服务器地址:9092");
    properties.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

    properties.put("enable.idempotence", true);
    properties.put("transactional.id", "testTransaction");
    KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
    // 1.初始化事务
    producer.initTransactions();
    try {
        // 2.事务的开启
        producer.beginTransaction();
        for (int i = 0; i < 3; i++) {
            // 实际写代码的时候,请使用带回调函数的send重载方法。
            producer.send(new ProducerRecord<String, String>("test", Integer.toString(i), "message" + i));
        }
        // 3.事务提交
        producer.commitTransaction();
    }catch (Exception e){
        // 4.事务终止
        producer.abortTransaction();
    }
    producer.close();
}

这里有一个值得注意的点是:

  1. 仅仅在生产者端开启事务的作用是不大的。
  2. 哪怕事务写入失败,kafka依旧会把这些消息写入到底层的日志中,因此Consumer端依旧能够消费到这些消息。

因此,在生产者端开启了事务的前提下,消费者端应该做出对应的修改,修改 isolation.level的值:

  • read_uncommitted(默认值):表示Consumer能够读取到Kafka写入的任何消息。不论事务型Producer提交了事务还是终止了事务。
  • read_committed:表示Consumer只会读取事务型Producer成功提交事务写入的消息。(包括非事务写入的消息)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zong_0915

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值