kafka消息传输:概念、原理与实战

一、概念

1、消息模型

JMS规范:Java消息服务(Java Message Service,JMS)应用程序接口是一个Java平台中关于面向消息中间件(MOM)的API,用于在两个应用程序之间,或分布式系统中发送消息,进行异步通信。

点对点与发布订阅最初是由JMS定义的。这两种模式主要区别或解决的问题就是发送到队列的消息能否重复消费(多订阅)

消息队列-点对点:

消息队列-发布订阅:

 

2、消息:消息是Kafka通信的基本单位,由一个固定长度的消息头和一个可变长度的消息体构成。

3、生产者:负责将消息发送给代理,也就是向Kafka代理发送消息的客户端。

4、消费者和消费分组:同一个主题的一条消息只能被同一个消费组下某一个消费者消费,但不同消费组的消费者可同时消费该消息。

二、原理

1、生产者:

KafkaProducer实现原理:发送消息的方法包括2种,一种是Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback),另外一种是Future<RecordMetadata> send(ProducerRecord<K, V> record)。前一种使用异步方式调用,使用Callback类方法onCompletion(RecordMetadata metadata, Exception exception)获取服务器响应结果,后一种使用同步方式调用,调用Future的get()方法进行阻塞,直到服务器返回响应。使用异步方式时,设计时应该是该回调函数尽快执行完成,如果要处理复杂且耗时的业务逻辑,采用并发编程模型设计。kafka为了保证Callback能够被顺序执行,会将Callback顺序保存到List中,在底层实现时将Callback对象与每次发送消息返回的FutureRecordMetadata对象封装为一个Thunk对象,在RecordBatch中会维护一个Thunk的链表thunks,用于记录同一批RecordBatch下每次发送的Record对应的Callback,这里用到的设计模式是责任链模式。下面对生产者的主流程进行分析:

1.1、实例化过程

实例化用于存储消息的记录累加器RecordAccumulator,使用的数据结构是双端队列。KafkaProducer发送的消息都先被追加到消息累加器的一个双端对列Deque中,在消息累加器内部每一个主题的每一个分区TopicPartition对应一个双端队列,队列中的元素是RecordBatch,而RecordBatch是由同一个主题发往同一个分区的多条消息Record组成,并将结果以每个TopicPartiton作为Key,该TopicPartition所对应的双端队列作为Value保存到一个ConcurrentMap类型的batches中。采用双端队列是为了当消息发送失败需要重试时,将消息优先插入到队列的头部,而最新的消息总是插入到队列尾部,只有需要重试发送时才在队列头部插入,发送消息是从队列头部获取RecordBatch,这样就实现了对发送失败的消息进行重试发送。但是双端队列只是指定了RecordBatch的顺序存储方式,而并没有定义存储空间大小,在消息累加器中有一个BufferPool缓存数据结构,用于存储消息Record。

RecordAccumulator底层实现类图

Record追加操作调用关系的类图

 

1.2、send过程

在KafkaProducer实例化后,调用KafkaProducer.send()方法进行消息发送。send方法会创建一个线程,在该线程内发送消息。

方法一源码

public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {

    ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record);

    return this.doSend(interceptedRecord, callback);

}

方法二源码

public Future<RecordMetadata> send(ProducerRecord<K, V> record) {

    return this.send(record, (Callback)null);

}

this.interceptors.onSend(record)会将record插入列表存放的ProducerInterceptors拦截器。doSend方法内主要的操作包括异常检测、分区设置、序列化、返回结果追加拦截器等。

3.Sender发送消息

KafkaProducer生产消息主体流程

 

2、消费者:

2.1、消费订阅,支持两种消费模式,一种是通过KafkaConsumer.subscribe(),另一种是通过KafkaConsumer.assign()。KafkaConsumer两类订阅方式是互斥的,客户端只能选择其中一种订阅方式。

KafkaConsumer非模式匹配订阅主题的基本流程

 

2.2、消费消息

KafkaConsumer提供了一个poll()方法用于从服务端拉取消息,包括Fetcher拉取消息过程和KafkaConsumer拉取消息。

KafkaConsumer.poll()方法的执行逻辑流程

 

2.3、 消费偏移量提交

Kafka提供了两种提交消费偏移量的方式:KafkaConsumer自动提交和客户端调用KafkaConsumer相应API提交,后者提交偏移量的方式通常也称为手动提交。

2.4、分区数与消费者线程的关系

Kafka提供了配置项partition.assignment.strategy用来设置消费者线程与分区映射关系,Kafka提供了range和round-robin两种分配策略,默认是range分配的策略

2.4.1、round-robin。假设有2个主题,每个主题有3个分区,现在有2个消费者线程订阅了这2个主题,分配结果下图所示。图中,Tn表示主题,Pn表示分区,Cn表示消费者线程。

 

对于T1而言,P0分配给C1,P1分配给C2,P2分配给C1。

对于T2而言,P0分配给C2,P1分配给C1,P2分配给C2.

2.4.2、range分配策略

range策略即按照线程总数与分区总数进行整除运算计算一个跨度。该策略具体实现逻辑如下:

  • 对线程集合按照字典顺序进行排序

  • 每个线程平均分配的分区数(numPartitionsPerConsumer)=分区总数 / 消费者线程总数

  • 多余的分区数(consumersWithExtraPartition)=分区总数 % 消费者线程总数

  • 遍历线程集合为每个线程分配分区,从起始分区开始分配,依次为每个线程分配numPartitionsPerConsumer个分区,如果consumersWithExtraPartition不为0,那么在一轮分配结束后,再从第一个被分配线程开始分配分区,即部分线程的分区数为numPartitionsPerConsumer+1个。

示例:假设一个主题有10个分区,消费者线程总数为4个。根据range分配策略每个消费者线程分配的分区下图所示。图中,分区以Pn表示,消费者线程以Cn表示,n为从0开始依次递增的整数。

 

消费者消费消息时需要注意:

● 若Pnt> Cnt,则有部分消费者线程会分配到多个分区,从而部分消费者线程会收到多个分区消息。这种情况下若对消息顺序有要求的场景,则要实现相应机制来保证消息的顺序。

● 若Pnt= Cnt,则每个消费者线程分配到一个分区,每个消费者收到固定分区的消息。

● 若Pnt< Cnt,则有部分消费者线程分配不到分区,这导致分配不到分区的消费者线程将收不到任何消息。

当增加分区或者消费者线程数发生变化时就会引起平衡操作,线程与分区分配关系就会进行重新分配,这在消息恰好消费一次的场景需要特别关注。

2.4.3、消费者平衡过程

Kafka消费者平衡是指消费者重新加入消费组,并重新分配分区给消费者的过程,主要包括:

● 新的消费者加入消费组。

● 当前消费者从消费组退出。这里的退出包括异常退出和消费者正常关闭。

● 消费者取消对某个主题的订阅。

● 订阅主题的分区增加。

● 代理(broker)宕机,新的协调器当选。

● 当消费者在${session.timeout.ms}毫秒内还没发送心跳请求,组协调器认为消费者已退出。

三、实战

3.1、生产者

配置

描述

enable.idempotence

是否启用幂等(默认情况下为false)。 如果禁用,生产者将不会在生成请求中设置PID字段,并且当前的生产者传递语义将生效。 请注意,必须启用幂等才能使用事务。当启用幂等时,我们强制执行acks = all,retries> 1和max.inflight.requests.per.connection = 1。 没有这些配置的这些值,我们不能保证幂等。 如果这些设置未被应用程序显式覆盖,则在启用幂等时,生产者将设置acks = all,retries = Integer.MAX_VALUE和max.inflight.requests.per.connection = 1。

transaction.timeout.ms

在主动中止正在进行的事务之前,事务协调器将等待生产者的事务状态更新的最长时间(以ms为单位)。

transactional.id

用于事务传递的TransactionalId。 这使得可以跨越多个生产者会话的可靠性语义,因为它允许客户端保证在开始任何新事务之前使用相同的TransactionalId的事务已经完成。 如果没有提供TransactionalId,则生产者被限制为幂等传递。请注意,如果配置了TransactionalId,则必须启用enable.idempotence。默认值为空,这意味着无法使用事务。

acks

acks这个参数用来指定分区中必须要有多少个副本收到这条消息,之后生产者才会认为这条消息是成功写入的

(1)当acks=0时,生产者不用等待代理返回确认信息,而连续发送消息。容易丢失消息,但是吞吐量高。

(2)当acsk=1时,生产者需要等待Leader副本已成功将消息写入日志文件中。只要集群的Leader副本收到消息,生产者就会收到服务器的响应。

(3)当acks=-1时,Leader副本和所有ISR列表中的副本都完成数据存储时才会向生产者发送确认信息,这种策略保证只要Leader副本和Follower副本中至少有一个节点存活,数据就不会丢失。只有参与复制的所有节点都收到消息,生产者才会收到服务器的响应,延迟高。

环境准备:

启动Zookeeper

 

启动kafka broker1

 

启动kafka broker2

 

启动kafka broker3

 

查看leader副本与分区副本
.\bin\windows\kafka-topics.bat --describe --zookeeper localhost:2181 --topic my-replicated-topic
Topic: my-replicated-topic      PartitionCount: 3       ReplicationFactor: 3    Configs:
Topic: my-replicated-topic      Partition: 0    Leader: 1       Replicas: 1,0,2 Isr: 1,0,2
Topic: my-replicated-topic      Partition: 1    Leader: 2       Replicas: 2,1,0 Isr: 2,1,0
Topic: my-replicated-topic      Partition: 2    Leader: 0       Replicas: 0,2,1 Isr: 0,2,1
查看消息消费情况
#LAG不为0,说明消费者消费能力落后于生产者生产速度
.\bin\windows\kafka-consumer-groups.bat --describe --bootstrap-server localhost:9094,localhost:9093,localhost:9092 --group Kafka
GROUP           TOPIC                  PARTITION    CURRENT-OFFSET  LOG-END-OFFSET  LAG             CONSUMER-ID                                                                   HOST                CLIENT-ID
Kafka           my-replicated-topic             0          5151709                        5194586                42877           consumer-1-196d204e-d88f-4b33-ab51-803d82cf24e9  /192.254.159.191  consumer-1
Kafka           my-replicated-topic             1          673586                          716532                  42946           consumer-1-196d204e-d88f-4b33-ab51-803d82cf24e9  /192.254.159.191  consumer-1
Kafka           my-replicated-topic             2          673655                          716532                  42877           consumer-1-196d204e-d88f-4b33-ab51-803d82cf24e9  /192.254.159.191  consumer-1
查看broker leader
#broker 0为leader
[zk: localhost:2181(CONNECTED) 2] get /controller
{"version":1,"brokerid":0,"timestamp":"1607235000521"}
#leader切换发生了23次
[zk: localhost:2181(CONNECTED) 3] get /controller_epoch
23
 
示例代码:
String topic = "my-replicated-topic";
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092,localhost:9093,localhost:9094");
props.put("acks", "-1");
props.put("retries", 1);
//props.put("enable.idempotence",true);
props.put("max.in.flight.requests.per.connection", 1);
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
Producer<String, String> producer = new KafkaProducer<String, String>(props);
 
int messageNo = 1;
boolean stop = false;
boolean isAsync = true;
 
String messageStr = "Message_" + messageNo;
//System.out.println("message:" + messageStr);
long startTime = System.currentTimeMillis();
if (isAsync) { 
    producer.send(new ProducerRecord<>(topic,
            messageStr), new DemoCallBack(startTime, messageNo, messageStr));
} else { 
    try {
        Future<RecordMetadata> future = producer.send(new ProducerRecord<>(topic, messageStr));
        RecordMetadata metadata = future.get();
        long elapsedTime = System.currentTimeMillis() - startTime;
        System.err.println("block mode: message(" + messageStr +
                ") sent to partition(" + metadata.partition() +
                "), offset(" + metadata.offset() +
                ") in " + elapsedTime +
                " ms");
    } catch (InterruptedException | ExecutionException e) {
    }
}
++messageNo;
 
异步 至多发送一次,acks=0,retries=0,发送出去即完成,不管成功以否,可以做到很高的吞吐量。
async mode: message(3, Message_3) sent to partition(1), offset(-1) in 15 ms
async mode: message(6, Message_6) sent to partition(1), offset(-1) in 15 ms
async mode: message(9, Message_9) sent to partition(1), offset(-1) in 14 ms
async mode: message(2, Message_2) sent to partition(0), offset(-1) in 26 ms
async mode: message(5, Message_5) sent to partition(0), offset(-1) in 25 ms
async mode: message(8, Message_8) sent to partition(0), offset(-1) in 25 ms
async mode: message(1, Message_1) sent to partition(2), offset(-1) in 353 ms
async mode: message(4, Message_4) sent to partition(2), offset(-1) in 25 ms
async mode: message(7, Message_7) sent to partition(2), offset(-1) in 25 ms
async mode: message(10, Message_10) sent to partition(2), offset(-1) in 23 ms
 
异步 至少发送一次,acks=1,retries=1,只要一个Leader副本接收到数据即算发送成功,数据完整性和吞吐量的权衡。
async mode: message(1, Message_1) sent to partition(0), offset(11122392) in 343 ms
async mode: message(4, Message_4) sent to partition(0), offset(11122393) in 21 ms
async mode: message(7, Message_7) sent to partition(0), offset(11122394) in 22 ms
async mode: message(10, Message_10) sent to partition(0), offset(11122395) in 22 ms
async mode: message(3, Message_3) sent to partition(2), offset(6644417) in 23 ms
async mode: message(6, Message_6) sent to partition(2), offset(6644418) in 22 ms
async mode: message(9, Message_9) sent to partition(2), offset(6644419) in 22 ms
async mode: message(2, Message_2) sent to partition(1), offset(6644462) in 32 ms
async mode: message(5, Message_5) sent to partition(1), offset(6644463) in 28 ms
async mode: message(8, Message_8) sent to partition(1), offset(6644464) in 28 ms
 
 
异步 恰好发送一次,acks=-1,retries=1。所有的Leader副本和follower副本都接收到数据才算发送成功。也可以设置props.put("enable.idempotence",true),启用生产消息幂等。
async mode: message(1, Message_1) sent to partition(1), offset(6644465) in 362 ms
async mode: message(4, Message_4) sent to partition(1), offset(6644466) in 38 ms
async mode: message(7, Message_7) sent to partition(1), offset(6644467) in 36 ms
async mode: message(10, Message_10) sent to partition(1), offset(6644468) in 36 ms
async mode: message(3, Message_3) sent to partition(0), offset(11122396) in 38 ms
async mode: message(6, Message_6) sent to partition(0), offset(11122397) in 36 ms
async mode: message(9, Message_9) sent to partition(0), offset(11122398) in 36 ms
async mode: message(2, Message_2) sent to partition(2), offset(6644420) in 47 ms
async mode: message(5, Message_5) sent to partition(2), offset(6644421) in 38 ms
async mode: message(8, Message_8) sent to partition(2), offset(6644422) in 37 ms
以上,可以看到在同一个分区,消息是有序的。
3.2、消费者
两种消费提交方式
(1)自动提交。在创建一个消费者时,默认是自动提交偏移量,当然我们也可以显示设置为自动。
(2)手动提交。在有些场景我们可能对消费偏移量有更精确的管理,以保证消息不被重复消费以及消息不被丢失。假设我们对拉取到的消息需要进行写入数据库处理,或者用于其他网络访问请求等等复杂的业务处理,在这种场景下,所有的业务处理完成后才认为消息被成功消费,这种场景下,我们必须手动控制偏移量的提交。
配置
描述
isolation.level
以下是可能的值(默认为read_uncommitted):read_uncommitted:在偏移顺序中消费已提交和未提交的消息; read_committed:仅以偏移顺序消耗非事务性消息或已提交事务消息。 为了保持偏移顺序,该设置意味着我们必须缓冲消费者中的消息,直到我们看到给定事务中的所有消息。
代码示例;
String topic = "my-replicated-topic";
String group = "Kafka";
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092,localhost:9093,localhost:9094");
props.put("group.id", group);   //required
props.put("enable.auto.commit", "false"); // 关闭自动提交
props.put("auto.commit.interval.ms", "1000");
props.put("auto.offset.reset", "latest");     //从最早的消息开始读取
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");  //required
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); //required
 
Consumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));       //订阅topic
 
至少消费一次,先处理业务再提交位移,即 consumer先读取消息,再处理消息,最后确认position。
ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
// 处理业务之后提交位移
执行结果示例:
at least once mode partition([my-replicated-topic-1]), offset(11122426)
at least once mode partition([my-replicated-topic-1]), offset(6644498)
at least once mode partition([my-replicated-topic-1]), offset(6644450)
at least once mode partition([my-replicated-topic-0]), offset(11122430)
at least once mode partition([my-replicated-topic-0]), offset(6644498)
at least once mode partition([my-replicated-topic-0]), offset(6644450)
at least once mode partition([my-replicated-topic-2]), offset(11122430)
at least once mode partition([my-replicated-topic-2]), offset(6644498)
at least once mode partition([my-replicated-topic-2]), offset(6644453)
 
至多消费一次,先提交位移再处理业务。即 consumer先读取消息,再确认position,最后处理消息。
ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
// 处理业务之前就提交位移
执行结果示例:
at most once,partition(0), offset (11122422)
at most once,partition(0), offset (11122423)
at most once,partition(0), offset (11122424)
at most once,partition(0), offset (11122425)
at most once,partition(1), offset (6644492)
at most once,partition(1), offset (6644493)
at most once,partition(1), offset (6644494)
at most once,partition(2), offset (6644447)
at most once,partition(2), offset (6644448)
at most once,partition(2), offset (6644449)
 
恰好消费一次,这种场景可以借助数据库的事务机制来记录消费位移。
说明:
a、如果消息处理后的输出端(如db)能保证消息更新幂等性,则多次消费也能保证exactly once语义。
b、如果输出端能支持两阶段提交协议(2PC),则能保证确认position和处理输出消息同时成功或者同时失败。
c、在消息处理的输出端存储更新后的position,保证了确认position和处理输出消息的原子性(简单、通用)。
 
参考:
《深入理解Kafka:核心设计与实践原理》,朱忠华,2019-01
《Kafka入门与实践》,牟大恩,2017-11
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

zxhyxwwu

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

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

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

打赏作者

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

抵扣说明:

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

余额充值