- 一. Kafka的拦截器
- 1.1 拦截器 - 环境准备
- 1.2 创建个主题
- 1.3 准备 - 验证生产者能否发送消息
- 1.4 准备 - 验证消费者能否接收消息
- 1.5 拦截器案例 - 计算消息的平均处理时长
- 二. TCP管理
- 2.1 生产者管理TCP
- 2.1.1 创建TCP的时机
- 2.1.2 关闭TCP的时机
- 2.2 消费者管理TCP
- 2.2.1 创建TCP的时机
- 2.2.2 关闭TCP的时机
- 2.3 题外话 - Java的对象逃逸问题
- 三. Kafka的幂等性和事务介绍
- 3.1 幂等性
- 3.2 事务性
Kafka拦截器可以再消息处理的前后插入对应的处理逻辑。其分为两种:
- 生产者拦截器:可作用于 发送消息前 和 消息提交成功后 两个时间点。
- 消费者拦截器:可作用于 消费消息前 和 提交位移后 两个时间点。
倘若需要配置对应的拦截器,需要注意这么几个点:
- 生产者端和消费者端都有一个相同的参数,名字为interceptor.classes,它代表一组拦截器列表。
- 生产者端拦截器需要实现org.apache.kafka.clients.producer.ProducerInterceptor接口。
- 消费者端拦截器需要实现org.apache.kafka.clients.consumer.ConsumerInterceptor接口。
- 其中配置的值是对应实现类的全类名。
例如下面的伪代码:
Properties props = new Properties(); Listinterceptors = new ArrayList<>(); interceptors.add("com.test.MyInterceptor"); interceptors.add("com.test.MyInterceptor2"); props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,interceptors);
生产者拦截器中,需要实现两个具体方法:
- onSend():在消息发送之前被调用。一般用于在消息发送之前对消息进行封装。
- onAcknowledgement():该方法会在消息成功提交或发送失败之后被调用。值得注意的是:该方法的调用要早于send(message,callback)函数中callback的调用。
消费者拦截器中,同样需要实现两个方法:
- onConsume():该方法在消息返回给 Consumer 程序之前调用。
- onCommit():Consumer 在提交位移之后调用该方法。可以用来记录日志。
接下来就来给个案例,从搭建环境开始到编写自定义的拦截器。
1.1 拦截器 - 环境准备本次使用Docker来进行环境安装。如果使用安装包安装的,可以参考我这篇文章(附带了安装软件的下载地址)深入理解Kafka系列(一)–初识Kafka
1.下载Zookeeper和Kafka的镜像:
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.查看容器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.pom依赖:
org.apache.kafka kafka-clients0.11.0.0 org.apache.kafka kafka_2.120.11.0.0
2.生产者程序:
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import java.util.Properties;
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 producer = new KafkaProducer<>(properties);
for (int i = 0; i < 3; i++) {
// 实际写代码的时候,请使用带回调函数的send重载方法。
producer.send(new ProducerRecord("test", Integer.toString(i), "message" + i));
}
producer.close();
}
}
3.提前在Kafka容器中输入命令,等待消息的接收
./kafka-console-consumer.sh --bootstrap-server [你的服务器地址]:9092 --from-beginning --topic test
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;
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 consumer = new KafkaConsumer<>(properties);
// 2.订阅主题
consumer.subscribe(Collections.singletonList("test"));
// 3.轮询消费
try {
while (true) {
ConsumerRecords records = consumer.poll(100);
for (ConsumerRecord 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依赖:
redis.clients jedis 4.1.1
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; public class MyProducerInterceptor implements ProducerInterceptor{ private static Jedis jedis = null; static { jedis = new Jedis("你的服务器地址", 6379); // 如果你设置了密码 jedis.auth("xxx"); } @Override public ProducerRecord onSend(ProducerRecord producerRecord) { // 发送的数据总数+1 jedis.incr("totalSentMessage"); return producerRecord; } @Override public void onAcknowledgement(RecordMetadata recordMetadata, Exception e) { } @Override public void close() { } @Override public void configure(Map 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; public class MyConsumerInterceptor implements ConsumerInterceptor{ private static Jedis jedis = null; static { jedis = new Jedis("你的服务器地址", 6379); // 如果你设置了密码 jedis.auth("xxx"); } @Override public ConsumerRecords onConsume(ConsumerRecords consumerRecords) { long count = 0L; for (ConsumerRecord 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 map) { } @Override public void close() { } @Override public void configure(Map map) { } }
3.先运行消费者,再运行生产者,可见结果如下:
当然这个数值可能会有出入,不过整个流程和思路已经是跑通了。案例到这里也就结束了。
二. TCP管理背景:Kafka相关的通信都是基于TCP的,而非HTTP或是其他协议。那么Kafka为何使用TCP来进行通信呢,主要有两个原因(了解即可):
- 社区认为,在开发客户端的时候,可以合理利用TCP本身提供的高级特性,例如多路复用请求以及轮询多连接的能力。
- 目前已知的 HTTP 库在很多编程语言中都略显简陋。
首先我们把上文中,一个简单的生产者代码搬出来:
// 创建生产者 KafkaProducer2.1.1 创建TCP的时机producer = new KafkaProducer<>(properties); // 发送消息 producer.send(new ProducerRecord ("test", Integer.toString(i), "message" + i)); // 关闭生产者 producer.close();
创建TCP的时机从代码上可以推断可能有两处:
- new KafkaProducer<>(properties)
- producer.send()
首先对于第一点,关于KafkaProducer实例的创建,其会在后台创建一个名为Sender的线程,并将其启动,他会首先与Broker建立连接,以下是KafkaProducer构造函数中的部分代码(会在后续的章节去专门学习Kafka的源码):
KafkaProducer(ProducerConfig config,
Serializer keySerializer,
Serializer valueSerializer,
ProducerMetadata metadata,
KafkaClient kafkaClient,
ProducerInterceptors 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设计的启动过程是:
- bootstrap.servers参数指定了Broker的地址。
- KafkaProducer启动的时候会发起与这些Broker的连接。
注意:
- bootstrap.servers中,没必要把整个Kafka集群的IP地址全部写上去,倘若有1000个,那么KafkaProducer启动的时候就会和1000个Broker发起TCP连接。
- 同时, Producer 一旦连接到集群中的任一台Broker,就能拿到整个集群的 Broker 信息。 因此,通常只需要配置 3 - 4 台即可。
我们目前可以得知:创建KafkaProducer实例的时候,就会创建TCP连接。那么除此之外还可能发生在两个地方:
- 更新元数据。可能有两个场景:1.当 Producer 尝试给一个不存在的主题发送消息时。2.Producer 通过 metadata.max.age.ms 参数定期地去更新元数据信息。
- 消息发送时。
上述两种情况并不是必然发生的,在更新元数据或者发送消息的时候,当生产者发现某些Broker当前没有TCP连接,此时才会创建。
2.1.2 关闭TCP的时机关闭TCP的时机有两种:
- 用户主动关闭,例如producer.close() 方法。
- Kafka自动关闭。
Producer端有一个参数:connections.max.idle.ms,默认情况下改参数的值为9分钟,意思是:在 9 分钟内没有任何请求流过某个TCP 连接,那么 Kafka 会主动帮你把该 TCP 连接关闭。不然,也可以将这个值设置为 -1 ,那么此时TCP连接将成为永久的长连接。
总结下就是:
- 生产者端:创建KafkaProducer实例的时候,会启动Sender线程,会和bootstrap.servers配置的所有Broker进行TCP连接。
- KafkaProducer实例首次更新元数据信息之后,会再次与所有的Broker进行TCP连接。
- 若Producer发送消息的时候,发现与某台Broker没有建立TCP连接,那么创建。
- 若Producer端connections.max.idle.ms 参数大于0,那么在指定的时间内,某个TCP没有流过任何的请求,此时关闭该TCP连接。
同样地,这里把消费者的程序段搬过来先:
// 1.创建KafkaConsumer KafkaConsumer2.2.1 创建TCP的时机consumer = new KafkaConsumer<>(properties); // 2.订阅主题 consumer.subscribe(Collections.singletonList("test")); // 3.轮询消费 try { while (true) { // 取一个批次的消息 ConsumerRecords records = consumer.poll(100); for (ConsumerRecord record : records) { // doSomething() } } } catch (Exception e) { e.printStackTrace(); } finally { consumer.close(); }
KafkaConsumer和KafkaProducer不同的是,KafkaConsumer并不会在创建实例的时候创建TCP连接。而 TCP 连接是在调用 KafkaConsumer.poll() 方法时被创建的,其中可能在以下三个场景创建TCP:
- 发起FindCoordinator请求时。目的是确定协调者和获取集群元数据。
- 连接协调者Coordinator时。目的是连接协调者,令其执行组成员管理操作。
- 消费数据时。此时会为每个要消费的分区创建与该分区领导者副本所在 Broker 连接的 TCP。目的是执行实际的消息获取。
协调者Coordinator相关概念:
- 存在于Broker端的内存中,负责消费组中的组成员管理以及各个消费者的位移提交管理。
- 集群中负载最小的那一台Broker将会成为协调者。
- 消费者需要和协调者所在的Broker建立TCP连接,这样才能够进行相关的操作。例如:加入组、等待组分配方案、心跳请求处理、位移获取、位移提交等。
同样消费者关闭TCP的时机也有两个:
- 主动关闭:KafkaConsumer.close()。或者是用户将该进程停止。
- 自动关闭:connection.max.idle.ms
Java里面,对于对象的溢出有两种常见的情况:
- 在构造函数中注册事件的监听。
- 在构造函数中启动新线程。
当然,上面的两种我个人觉得它只是一个场景,而只有满足了指定的动作才会发生溢出的现象,总额的来说就是:
- 在执行构造函数的时候,外部类对象的创建过程可能还没有结束(这里指的是构造函数)。
- 这个时候如果内部类(这里指的是监听器或者内部启动的新线程)访问了外部类中的数据。
- 此时可能得到的数据并没有被正确地初始化。
我们以第二个例子,启动新线程为例,来看下这个案例:
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会提供至少一次的可靠性保障,但是这会导致消息重复发送。
那么如何保证消息只有一条并且消息不会丢失也不会被重复处理呢?通过两种机制:
- 幂等性。
- 事务性。
Producer默认不具有幂等性,但是我们可以通过开启配置来创建一个幂等性的Producer。开启幂等性非常简单,如下:
// 方式一
properties.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
// 方式二
properties.put("enable.idempotence", true);
开启幂等性之后,Kafka就会自动做消息的去重。底层逻辑大概如下:
- Broker端会对每个消息多保存一些字段。
- Producer发送了一些具有相同字段值的消息后。此时Broker就能够识别哪些消息是重复的。此时就将这些重复的消息给排除掉。
注意点:
- 该特性只能够保证单分区上的幂等性:保证某个主题的某一个分区上不存在重复消息。但是无法保证多分区的幂等性。
- 只能够实现单会话(Producer进程的一次运行)上的幂等性,不能实现跨会话的幂等性。
因此,倘若想实现多分区以及多会话上的消息无重复,仅仅靠幂等性是不行的,此时应该依赖事务的运用。
3.2 事务性事务型的Producer能够保证将消息原子性地写入到多个分区。要么这批消息全部写入成功,要么全部失败。
开启条件:
- enable.idempotence =true。
- 设置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 producer = new KafkaProducer<>(properties);
// 1.初始化事务
producer.initTransactions();
try {
// 2.事务的开启
producer.beginTransaction();
for (int i = 0; i < 3; i++) {
// 实际写代码的时候,请使用带回调函数的send重载方法。
producer.send(new ProducerRecord("test", Integer.toString(i), "message" + i));
}
// 3.事务提交
producer.commitTransaction();
}catch (Exception e){
// 4.事务终止
producer.abortTransaction();
}
producer.close();
}
这里有一个值得注意的点是:
- 仅仅在生产者端开启事务的作用是不大的。
- 哪怕事务写入失败,kafka依旧会把这些消息写入到底层的日志中,因此Consumer端依旧能够消费到这些消息。
因此,在生产者端开启了事务的前提下,消费者端应该做出对应的修改,修改 isolation.level的值:
- read_uncommitted(默认值):表示Consumer能够读取到Kafka写入的任何消息。不论事务型Producer提交了事务还是终止了事务。
- read_committed:表示Consumer只会读取事务型Producer成功提交事务写入的消息。(包括非事务写入的消息)



