Kafka复习计划 - 客户端实践及原理(连接器/TCP的管理/幂等性和事务)
一. Kafka的拦截器
Kafka
拦截器可以再消息处理的前后插入对应的处理逻辑。其分为两种:
- 生产者拦截器:可作用于
发送消息前
和消息提交成功后
两个时间点。 - 消费者拦截器:可作用于
消费消息前
和提交位移后
两个时间点。
倘若需要配置对应的拦截器,需要注意这么几个点:
- 生产者端和消费者端都有一个相同的参数,名字为
interceptor.classes
,它代表一组拦截器列表。 - 生产者端拦截器需要实现
org.apache.kafka.clients.producer.ProducerInterceptor
接口。 - 消费者端拦截器需要实现
org.apache.kafka.clients.consumer.ConsumerInterceptor
接口。 - 其中配置的值是对应实现类的全类名。
例如下面的伪代码:
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);
生产者拦截器中,需要实现两个具体方法:
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.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
来进行通信呢,主要有两个原因(了解即可):
- 社区认为,在开发客户端的时候,可以合理利用
TCP
本身提供的高级特性,例如多路复用请求以及轮询多连接的能力。 - 目前已知的
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的时机从代码上可以推断可能有两处:
new KafkaProducer<>(properties)
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
设计的启动过程是:
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
连接。
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的时机
KafkaConsumer
和KafkaProducer
不同的是,KafkaConsumer
并不会在创建实例的时候创建TCP
连接。而 TCP
连接是在调用 KafkaConsumer.poll()
方法时被创建的,其中可能在以下三个场景创建TCP
:
- 发起
FindCoordinator
请求时。目的是确定协调者和获取集群元数据。 - 连接协调者
Coordinator
时。目的是连接协调者,令其执行组成员管理操作。 - 消费数据时。此时会为每个
要消费的分区
创建与该分区领导者副本所在 Broker
连接的TCP
。目的是执行实际的消息获取。
协调者Coordinator
相关概念:
- 存在于
Broker
端的内存中,负责消费组中的组成员管理
以及各个消费者的位移提交管理
。 - 集群中负载最小的那一台
Broker
将会成为协调者。 - 消费者需要和协调者所在的
Broker
建立TCP
连接,这样才能够进行相关的操作。例如:加入组、等待组分配方案、心跳请求处理、位移获取、位移提交等。
2.2.2 关闭TCP的时机
同样消费者关闭TCP的时机也有两个:
- 主动关闭:KafkaConsumer.close()。或者是用户将该进程停止。
- 自动关闭:connection.max.idle.ms
2.3 题外话 - Java的对象逃逸问题
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
会提供至少一次的可靠性保障,但是这会导致消息重复发送。
那么如何保证消息只有一条并且消息不会丢失也不会被重复处理呢?通过两种机制:
- 幂等性。
- 事务性。
3.1 幂等性
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<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();
}
这里有一个值得注意的点是:
- 仅仅在生产者端开启事务的作用是不大的。
- 哪怕事务写入失败,
kafka
依旧会把这些消息写入到底层的日志中,因此Consumer
端依旧能够消费到这些消息。
因此,在生产者端开启了事务的前提下,消费者端应该做出对应的修改,修改 isolation.level
的值:
read_uncommitted
(默认值):表示Consumer
能够读取到Kafka
写入的任何消息。不论事务型Producer
提交了事务还是终止了事务。read_committed
:表示Consumer
只会读取事务型Producer
成功提交事务写入的消息。(包括非事务写入的消息)