Kafka消息传递
本文将研究 Kafka 从生产、存储到消费消息的详细过程。
一、Producer
1. 消息发送
所有的 Kafka 服务器节点任何时间都能响应是否可用、是否 topic 中的 partition leader,这样生产者就能发送它的请求到服务器上。
producer 只会将数据 push 给 partition 中的 leader,而 follower 需要自己去 leader 那里 pull 消息。
那么 producer 以什么形式发送数据,发送了一条/批消息之后,需要什么条件或者需要等待多久才能发送下一条消息呢,发送失败会重试吗?......
Kafka 3.3 Documentation 中 Producer Configs 里有相关配置说明:
compression.type
生产者生成的数据的压缩类型。通过使用压缩,可以节省网络带宽和 Kafka 存储成本。
type: string
default: none
valid values: [none, gzip, snappy, lz4, zstd]
importance: high
retries
生产者发送消息失败或出现潜在暂时性错误时,会进行的重试次数。
type: int
default: 2147483647
valid values: [0, ..., 2147483647]
importance: high
batch.size
当多条消息发送到一个分区时,producer 批量发送消息大小的上限 (以字节为单位)。即使没有达到这个大小,生产者也会定时发送消息,避免消息延迟过大。默认 16K,值越小延迟越低,吞吐量和性能也会降低。
type: int
default: 16384
valid values: [0, ...]
importance: medium
acks
producer 在确认一个请求发送完成之前需要收到的反馈信息的数量。这个参数是为了保证发送请求的可靠性。
acks = 0:producer 把消息发送到 broker 即视为成功,不等待 broker 反馈。该情况吞吐量最高,消息最易丢失
acks = 1:producer 等待 leader 将记录写入本地日志后,在所有 follower 节点反馈之前就先确认成功。若 leader 在接收记录后,follower 复制数据完成前产生错误,则记录可能丢失
acks = all:leader 节点会等待所有同步中的副本确认之后,producer 才能再确认成功。只要至少有一个同步副本存在,记录就不会丢失。这种方式是对请求传递的最有效保证。acks = -1与acks = all等效
type: string
default: all
valid values: [all, -1, 0, 1]
importance: low
Java 实现 Kafka 消息发送分为直接、同步、异步发送。其中直接发送无回调,同步发送有阻塞,故生产环境多用异步发送。
Properties properties = new Properties();
// 建立与 Kafka 群集的初始连接的主机 / 端口对的列表 多个以逗号隔开
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka1:9092, kafka2:9092, kafka3:9092");
// 消息不成功重试次数
properties.put(ProducerConfig.RETRIES_CONFIG, 0);
// 请求的最大大小 以字节为单位
properties.put(ProducerConfig.MAX_REQUEST_SIZE_CONFIG, 2147483640);
// 超时限制 ms
properties.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 60000);
// 缓冲区大小
properties.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432);
// key、value的序列化类
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
Producer<String,String> producer = new KafkaProducer<>(properties);
ProducerRecord<String, String> record = new ProducerRecord<>("Topic", "Key", "Value");
try {
// 直接发送
producer.send(record);
// 同步
RecordMetadata recordMetadata = producer.send(record).get();
System.out.println("part: " + recordMetadata.partition() + " " + "topic: " + recordMetadata.topic()+ " " + "offset: " + recordMetadata.offset());
// 异步
producer.send(record, (metadata, exception) -> {
if (exception == null){
System.out.println("part: " + metadata.partition() + " " + "topic: " + metadata.topic()+ " " + "offset: " + metadata.offset());
}else {
exception.printStackTrace();
}
});
} catch (Exception e) {
e.printStackTrace();
}
producer.close();
2. partition 中文件存储
partition 中存储了若干 segment,segment 包含 .index (存储元数据)、 .log 文件 (存储消息) 和 .timeindex 文件 (记录时间信息) 等。
message 物理结构包括 offset, message size 等。偏移 (offset) 是每条消息的有序编号,它可以确定每条消息在 partition 内的唯一位置。
如上图所示, .index 文件中的 N 为索引,position 为元数据物理位置。 .log 文件中的 345678 + N 为 offset,position 为物理偏移地址。 .index 文件元数据物理位置指向 .log 文件中 message 的物理偏移地址。
.index 文件采用稀疏索引存储方式,只为每个存储块建立索引项,而非稠密索引的每个单元都建立。存储块意味着块内连续存储单元。稀疏索引比稠密索引节省了存储空间,但查找起来需要消耗更多时间。稠密索引与稀疏索引_Jeaforea的博客-CSDN博客_稠密索引和稀疏索引
注:稀疏索引不宜太过稀疏或密集,以免增大查找成本或导致存储块太小。
二、Consumer
1. 消息查找
consumer 通过向 broker 发出一个 “fetch” 请求来获取它想要消费的 partition。consumer 的每个请求都在 log 中指定了对应的 offset,并接收从该位置开始的一块数据。
若现在 consumer 想查找 offset 为 345682 的数据,整个查询过程基于二分法,顺序为:
(1) 利用二分法找到小于 345682 且离其最近的 segment 2 文件
(2) 345682 = 345678 + 4,故在对应 .index 文件中利用二分法找到小于 4 且离其最近的3索引
(3) 因为 .index 文件元数据物理位置指向 .log 文件中 message 的物理偏移地址。找到 3 索引 578 元数据物理位置指向 .log 文件中 578 位置的 Message3456781
(4) 从 Message3456781 开始扫描,直到找到 offset 为 345682 的数据
2. 接收消息
Kafka consumer 从 broker 中 pull 数据。具体代码实现调用 poll() 方法。
// Duration 为 poll() 调用间隔时间
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
push 和 pull 比较
两者区别是,push 是发送方定义发送速率而不管接收方接收速率,而 pull 是接收方在能承受的范围内自己定义接收速率。
push 容易造成 consumer 超载、无法进行消费、吞吐量很低、延迟高等问题。而 pull 出现这些问题的概率会小很多,即使 message 很多,也能根据自身服务器的速率消化数据,一直能进行消费。pull 更支持批处理,吞吐量高且有效利用了缓冲区。
知道填鸭吧,push 就是喂吐了还要喂,pull 就是自己吃,可惜没人听小鸭子说话,小鸭子也说不了话罢了。
但 pull 也存在缺陷:如果 broker 中没有数据,consumer 可能会 busy-waiting 直到数据到来 (busy-waiting 会一直循环检测是否有数据,占用线程和 CPU)。为了避免 busy-waiting,Kafka 在pull 请求中加入参数,使得 consumer 在一个 “long pull” 中阻塞等待,直到数据到来 (还可以选择等待给定字节长度的数据来确保传输长度)。
connections.max.idle.ms
在此配置指定的毫秒数后关闭空闲连接。
type: long
default: 540000 (9 min)
valid values: [0, ...]
importance: medium
fetch.min.bytes
服务器读取请求返回的最小数据量。如果可用的数据不足,请求将等待累积指定数据,然后再响应请求。可以用一些额外的延迟为代价来提高服务器吞吐量。
type: int
default: 1
valid values: [0, ...]
importance: high
3. 消费
Kafka 是多订阅模式,一个 topic 可以有一个或者多个消费者来订阅它的数据。
Kafka 的 topic 被分割成了一组完全有序的 partition,其中每一个 partition 在任意给定的时间内只能被每个订阅了这个 topic 的 consumer group 中的一个 consumer 消费。
但问题是消息传向消费者消费的过程中,可能会丢失、重复消费或者一直无响应。如何让 broker 和 consumer 被消费的数据保持一致性?
Kafka 提供了精简的 consumer 的消费确认机制来解决这些问题:若当前消息已被正确消费,则 consumer 存储下一条要消费的消息的 offset。
该机制的优点
(1) 占用存储少
(2) 可以进行周期性的检查
(3) consumer 可以回退到之前的offset来再次消费数据
auto.offset.reset
当Kafka中没有初始偏移量或当前偏移量在服务器上不再存在时 (如该数据已被删除) 的策略:
earliest: 自动将偏移量重置为最早偏移量
latest: 自动将偏移量重置为最新偏移量
none: 如果找不到消费者组的先前偏移量,则向消费者抛出异常
其他: 向消费者抛出异常
type: string
default: latest
valid values: [latest, earliest, none]
importance: medium
enable.auto.commit
如果为true,则将在后台定期提交offset。
频率可用 [auto.commit.interval.ms] 进行设置,ms。
type: boolean
default: true
valid values:
importance: medium
示例代码:
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka1:9092, kafka2:9092, kafka3:9092");
// 所属消费者组的id
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "group1");
// 消费者的偏移量提交频率 ms
properties.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
// 客户端超时时间限制
properties.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
// key、value的反序列化类
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringSerializer.class);
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringSerializer.class);
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
// 订阅 topic
consumer.subscribe(Collections.singletonList("Topic"));
try {
// noinspection InfiniteLoopStatement
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records)
System.out.println("offset: "+ record.offset() + " " + "key: " + record.key() + "" + "value: " + record.value());
}
}catch (Exception e){
e.printStackTrace();
}
consumer.close();
至此可得一个较为完整的Kafka结构
路漫漫其修远兮,吾将上下而求索。上索下索都是索,修修修修修修修。
附 producer 另一种实现方式:
@Slf4j
public class KafkaTemplateProducer {
public void sendTemplate(String topic, Object data){
Map<String, Object> properties = new HashMap<>();
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka1:9092");
properties.put(ProducerConfig.RETRIES_CONFIG, 0);
properties.put(ProducerConfig.MAX_REQUEST_SIZE_CONFIG, 2147483640);
properties.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 60000);
properties.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432);
// key、value的序列化类
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
DefaultKafkaProducerFactory<String, Object> defaultKafkaProducerFactory = new DefaultKafkaProducerFactory<>(properties);
KafkaTemplate<String, Object> kafkaTemplate = new KafkaTemplate<>(defaultKafkaProducerFactory);
ListenableFuture<SendResult<String, Object>> future = kafkaTemplate.send(topic, data);
future.addCallback(new ListenableFutureCallback<SendResult<String, Object>>() {
@Override
public void onSuccess(SendResult<String, Object> sendResult) {
log.debug("topic: " + topic + " " + "value: " + data + " " + "success result: " + sendResult.toString());
}
@Override
public void onFailure(Throwable throwable) {
log.error("topic: " + topic + " " + "value: " + data + " " + "failure result:" + throwable.getMessage());
}
});
}
}
参考:
学习 Kafka 入门知识看这一篇就够了!(万字长文) - 腾讯云开发者社区-腾讯云 (tencent.com)
【Kafka】消息的同步发送和异步发送_云川之下的博客-CSDN博客_kafkatemplate异步发送
Kafka API 异步发送_Coffee-Code的博客-CSDN博客
Kafka文件存储机制详解_透过水花看花儿的博客-CSDN博客_kafka文件存储机制
开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 6 天,点击查看活动详情