Kafka学习
1 概述
本文是一篇Kafka学习的综述。
Kafka是一个分布式流平台,主要特性:
- 发布和订阅流式数据,类似消息队列或是企业级消息系统
- 通过一种错误容忍的持久方式处理流式数据
- 持续高吞吐处理产生海量的事件流数据,如实时日志流聚合
Kafka通常用于两大类应用:
- 构建实时流数据管道,可在系统或应用程序之间可靠地获取数据
- 构建实时流应用程序,可转换或响应数据流
2 Kafka API
Kafka-0.11.0拥有四个核心API:
2.1 Producer API
2.1.1 概述
可参考
- KafkaProducer
- Kafka生产者编程(1)——KafkaProducer详解
应用程序通过Producer API来发布流式数据。 - Kafka的有且仅有一次语义与事务消息
- kafka幂等生产者及事务
- 阿里云Kafka幂等生产者与事务生产者
- kafka send方法详解 (同步异步)
- kafka之Producer同步与异步消息发送及事务幂等性案例应用实战
- 详解Kafka Producer
包括流程、同步和异步发送、压缩、分区策略等详细内容
2.1.2 kafka-console-producer.sh
可以使用$KAFKA_HOME/bin/kafka-console-producer.sh
启动Kafka命令行生产者。直接敲此命令可以看所有选项。
一个将tom_student.log
的本地数据读取并写入,最后写入topic tom_student
的例子如下:
bin/kafka-console-producer.sh --topic tom_student --broker-list 192.168.1.1:9092 < tom_student.log
2.2 Consumer API
可参考
2.2.1 概述
应用程序通过Consumer API来订阅和处理流式数据。
在Kafka0.8.2及以前版本是旧API,分为高阶低阶。Kafka0.9开始统一了高阶低阶consumer API,maven依赖也减负不少:
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
</dependency>
关于position(offset)有两个概念:
-
position
Consumer每次调用poll
方法,都会增加该值,表示下一个需要被获取的值的offset。 -
committed position
表示最后一个安全存储的offset。当前线程挂掉重启或首次启动后,使用committed position
来进行恢复读取。该值可自动周期性提交或通过commitSync-异步提交到kafka / commitAsync手动提交。 -
TopicPartition
一个topic名称及partition号 -
public class ConsumerRecords<K,V> extends java.lang.Object implements java.lang.Iterable<ConsumerRecord<K,V>>
一个持有特定topic的某个partition的ConsumerRecord list。
-
ConsumerRecord
poll方法从kafka接收的一个key-value对,还包括topic, partition号,partition offset, ProducerRecord指定的timestamp
2.2.2 重要配置
-
bootstrap.servers
kafka broker地址,不一定全部,但最好多设几台防止挂掉 -
group.id
-
heartbeat.interval.ms
默认值3000,必须小于session.timeout.ms
,通常低于1/3,可以继续调低避免非必要的rebalance。含义是Consumer发送给Kafka Consumer 协调者的心跳间隔时间,在使用Kafka consumer group时有效,可被用来触发自动rebalance。 -
session.timeout.ms
默认值10000。Kafka Broker使用该值来判断发送心跳的Consumer间隔时间是否超过,如果超过就认为该Consumer失败,将其从group移除并触发rebalance。注意该值必须处于Broker的group.min.session.timeout.ms
和group.max.session.timeout.ms
之间。 -
auto.offset.reset
当前offset存在且合法时就使用它,否则调节:- earliest
重设为当最早的offset - latest
重设为当最近的offset
- earliest
-
enable.auto.commit
true代表Consumer周期性提交offset -
key.deserializer
-
value.deserializer
-
fetch.min.bytes
默认值1。每次获取数据时应该返回的最小数据byte数,小于就等待,直到达到才返回。调大该值可以增加吞吐量,但可能略微增加延时。 -
fetch.max.bytes
默认值52428800。每次Consumer获取数据时应该返回的最大数据byte数。(需要注意,Consumer并行执行多个数据获取任务)需要注意的是,Broker的
message.max.bytes
控制每个batch的最大大小,也可以调整Topic Config中的max.message.bytes
。 -
max.partition.fetch.bytes
默认值1048576。Consumer以batch消费时,服务器为每个分区返回的数据最大byte数。也就是说,KafkaConsumer.poll() 方法从每个分区里返回的记录最多不超过max.partition.fetch.bytes
指定的字节。需要注意的是,Broker的
message.max.bytes
控制每个batch的最大大小,也可以调整Topic Config中的max.message.bytes
。max.partition.fetch.bytes
必须大于这些值,否则可能无法处理。在设置该属性时,另一个需要考虑的因素是消费者处理数据的时间。消费者需要频繁调用 poll() 方法来避免会话过期和发生分区再均衡,如果单次调用 poll() 返回的数据太多,消费者需要更多的时间来处理,可能无法及时进行下一个轮询来避免会话过期。如果出现这种情况,可以把
max.partition.fetch.bytes
值改小,或者延长会话过期时间session.timeout.ms
。 -
max.poll.records
默认值500.每次调用poll时,返回的最大数据条数。
2.2.3 重要方法
2.2.3.1 subscribe
他和以前老的api差不多,也是有group概念,group之间相互隔离。可以订阅多个topic,partitions在同个group内的consumer之间均衡消费。当有新topic被regex匹配到、新partition加入、consumer增减时触发rebalance.
可以使用ConsumerRebalanceListener来获取rebalance事件,进行应用级别的处理,比如状态清理、手动提交offset等操作。
2.2.3.2 assign
使用assign(Collection)手动分配特定partition(类似于旧版的SimpleConsumer)。 在这种情况下,将禁用动态分区分配和Group协调机制。
2.2.3.3 poll
public ConsumerRecords<K,V> poll(long timeout)
当订阅了topics和partitions后,第一次调用poll(long)
方法时,当前consumer会自动加入消费者group。如果没调用订阅api就poll,会报错。
此后需要循环调用该方法,每次调用时consumer会尝试使用最后提交的offset(也可以使用seek(TopicPartition, long)手动指定)来作为新的消费offset,并顺序拉取数据。
- Consumer心跳
同时consumer会周期性发送心跳到Broker,一旦consumer挂掉或网络异常(超过session.timeout.ms
),则该consumer被认为挂掉,分配给他消费的partition会重分配给同group的其他成员。 - 活锁保证
为了避免Consumer能发送心跳,但消费数据进程卡了,可以通过max.poll.interval.ms
来限制poll的最大时间间隔,一旦超过,Consumer会主动退出group,partition被其他consumer成员接管。 - 拉取数限制
使用max.poll.records
可限制每次poll拉取的数据条数。
2.2.3.4 commitSync-异步提交到kafka
同步方式提交最后一次通过poll获取到的 topic和partition的offset到kafka。
2.2.3.5 commitAsync
异步方式提交最后一次通过poll获取到的 topic和partition的offset到kafka。
多次调用该方法时,Kafka可保证按顺序发送offset,如果有callback也会按发送顺序依次调用callback。
还需要注意的是,如果调用该方法后又调用了commitSync方法,该方法提交的offset可保证在commitSync
方法返回前完成。
2.2.4 例子
2.2.4.1 自动提交offset
enable.auto.commit
为true,只要poll调用了就自动提交offset。- at most once
Properties props = new Properties();
// 部分机器列表,可以自动找到其他机器
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "test");
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "1000");
// 二进制数据反序列化为对象的解析类
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
// 订阅两个topic:foor,bar
consumer.subscribe(Arrays.asList("foo", "bar"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
}
2.2.4.2 手动提交offset
- at-least-once
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "test");
props.put("enable.auto.commit", "false");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("foo", "bar"));
final int minBatchSize = 200;
List<ConsumerRecord<String, String>> buffer = new ArrayList<>();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
buffer.add(record);
}
if (buffer.size() >= minBatchSize) {
insertIntoDb(buffer);
consumer.commitSync();
buffer.clear();
}
}
2.2.4.3 指定topic和partition消费
使用assign API,注意不能同时和subscribe混用。
String topic = "foo";
TopicPartition partition0 = new TopicPartition(topic, 0);
TopicPartition partition1 = new TopicPartition(topic, 1);
consumer.assign(Arrays.asList(partition0, partition1));
注意此时已经没有自动balance。
2.2.4.4 保存offset到Kafka以外之处
2.2.4.5 控制Consumer position
大多数场景,consumer从头到尾消费,周期性提交position。但也可以手动管理position到指定位置:
- 跳过大量数据,到较新的数据offset
- 已经消费并存储的数据出问题了,需要回溯过去的数据
有三个重要方法:
实例可参考文章:
2.2.4.6 控制Consume优先级
指定多个partition时,默认同优先级消费。
可使用pause暂停消费指定分区,resume(Collection)恢复
2.2.4.7 读取事务数据
Kafka0.11.x后支持原子性写入多个topic和partition,consumer读取这些数据时,应该把Consumer客户端的事务隔离界别设为只读取已提交的数据:isolation.level=read_committed
。
在该隔离级别,可正常读非事务数据,但只能读到已提交的事务数据,且最大能读到的offset终点是一个open状态的事务的首条数据offset(LSO
)。
还需要注意,此时Consumer没有客户端buffer。
因为事务消息会在partition中存放标记位,所以读取事务消息时可能看到offset断层。
更多事务内容可以点这里
2.2.4.8 多线程处理
KafkaConsumer并非线程安全,未同步的操作会导致抛出ConcurrentModificationException
。
但有wakeup方法可以使得其他线程interrupt当前长时间阻塞在poll方法上的consumer线程,例子如下:
public class KafkaConsumerRunner implements Runnable {
private final AtomicBoolean closed = new AtomicBoolean(false);
private final KafkaConsumer consumer;
public void run() {
try {
consumer.subscribe(Arrays.asList("topic"));
while (!closed.get()) {
ConsumerRecords records = consumer.poll(10000);
// Handle new records
}
} catch (WakeupException e) {
// Ignore exception if closing
if (!closed.get()) throw e;
} finally {
consumer.close();
}
}
// Shutdown hook which can be called from a separate thread
public void shutdown() {
closed.set(true);
consumer.wakeup();
}
}
- 每个线程一个Consumer
- 优点
- 实现简单
- 单线程,没有协作,速度快
- 它使每个分区的顺序处理非常容易实现(每个线程只按接收顺序处理消息)。
- 缺点
- 每个消费者就需要创建一个到Kafka集群的TCP连接,虽然Kafka线程模型高效
- 线程更多了,发送的请求更多了,但每个批的数据减少,可能造成整体IO吞吐下降
- 因为消费和处理绑定在一个线程内,所以总的线程数受限于partition数量,如果数据处理需要大量消耗,则会有性能瓶颈。
- 优点
- 解耦消费和处理线程
若干Consumer线程消费数据后放入线程安全的阻塞队列,然后由若干消费者线程负责处理数据- 优点
- 解耦消费和处理,处理线程数不受partition数限制
- 缺点
- 难以在多线程处理场景保证处理顺序
- 难以手动维护position,因为很难协作知道其他线程处理partition的offset。
- 优化
比如为每个处理线程设置独有的queue,然后Consumer线程将接受到的数据按TopicPartition分别hash到这些queue中,可以保证partition内有序处理和方便提交offset。
- 优点
2.2.5 kafka-console-consumer.sh
可以使用$KAFKA_HOME/bin/kafka-console-consumer.sh
启动Kafka命令行消费者。直接敲此命令可以看所有选项。
一个将user_info
的数据消费出来并按关键字tom
进行过滤,最后写入tom_student.log
的例子如下:
bin/kafka-console-consumer.sh --topic user_info --from-beginning --bootstrap-server 192.168.1.1:9092 | grep 'tom' > tom_student.log
2.2.6 Consumer Rebalance
Consumer Rebalance由分区Leader和Consumer上的Coordinator Leader协调进行。每个KafkaConsumer上会有一个ConsumerCoordinator,用来协调消费组。其中会有一个Leader,她会从所有组成员中收集元数据(比如感兴趣的Topic等),然后发布最新的状态给他们。每个ConsumerCoordinator成员收到ConsumerCoordinator Leader发来的状态委派请求后就开始处理。
当消费者订阅的Topic的分区发生变化的时候会发生Rebalance,具体触发条件如下:
- 消费者订阅的topic中的分区数量发生变化
- Topic被创建或者被删除
- 消费者所在ConsumerGroup中有Consumer成员挂掉
- 有新的Consumer加入了ConsumerGroup
Rebalance算法请参考这里
2.3 Streams API
应用程序通过Streams API来充当流处理器,从N个topic中消费数据,然后生产一个输出流到M个topic,高效地将输入流转换为输出流。
流API构建在Kafka提供的核心原语上:它使用生产者和消费者API进行输入,使用Kafka进行有状态存储,并在流处理器实例之间使用相同的组机制来实现容错。
以下是一个官方小例子,他的功能是从Kafka TextLinesTopic 中消费每一行数据,然后按单词进行统计个数,最后写入WordsWithCountsTopic:
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.common.utils.Bytes;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.kstream.KTable;
import org.apache.kafka.streams.kstream.Materialized;
import org.apache.kafka.streams.kstream.Produced;
import org.apache.kafka.streams.state.KeyValueStore;
import java.util.Arrays;
import java.util.Properties;
public class WordCountApplication {
public static void main(final String[] args) throws Exception {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "wordcount-application");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-broker1:9092");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> textLines = builder.stream("TextLinesTopic");
KTable<String, Long> wordCounts = textLines
.flatMapValues(textLine -> Arrays.asList(textLine.toLowerCase().split("\\W+")))
.groupBy((key, word) -> word)
.count(Materialized.<String, Long, KeyValueStore<Bytes, byte[]>>as("counts-store"));
wordCounts.toStream().to("WordsWithCountsTopic", Produced.with(Serdes.String(), Serdes.Long()));
KafkaStreams streams = new KafkaStreams(builder.build(), props);
streams.start();
}
}
关于Kafka Streams API 更多信息请点击这里
2.4 Connector API
2.4.1 概述
-
概述
应用程序通过Connector API来创建和运行可重用的生产者或消费者,他们可以连接kafka topic和应用程序或数据系统,相对于Consumer和Producer,KafkaConnect省掉了更多的开发工作,尤其是编码部分,这使得应用开发人员更容易上手使用。 这里先提一些重要概念:- Converter(转换器)
数据复制过程中进行序列化和反序列化转码的实现类。Kafka数据其实就是键值对的结合,在Kafka中Key与Value都是字节形式。 - Transformation(变换器)
可以通过配置transformations 进行轻量级的消息即时修改, 它们可以方便数据传送和事件路由 - Connector(连接器)
Kafka Connect与用户交互的核心概念是Connector,他是一个逻辑概念,负责管理Kafka和外部系统之间的数据导入/导出,每个Connector可以实例化一组实际复制数据的task,分配到各个worker执行。 Kafka Connector有两种:-
KafkaSourceConnector
将外部数据导入Kafka。例如,可以构建一个connector,来捕获RDBMS的所有改动写入kafka。
-
Kafka SinkConnector
可以把数据从Kafka导出到外部存储系统。
-
- Task
在外部数据源和Kafka之间进行数据复制的具体实现,每个Connector实例有若干task,单个作业会分解为多个task。
当connect首次提交到Kafka Connect集群时或task数量增减时,集群中的connector和task进行rebalance分配给各worker。
但task失败不会触发rebalance,需要通过REST API重启该失败的task。
- Worker
实际运行connector和task的进程. - Converter(转换器)
-
Kafka Connect功能特性如下:
- REST接口
可通过RestAPI提交管理Connector - Offset自动管理
- 方便扩展Worker
- 流/批量集成
是桥接流式系统和批处理系统的理想方案
- REST接口
-
两种运行模式
- Standalone
对于适合单个代理的环境(例如从web服务器向Kafka发送日志),standalone模式非常适合。 - Distributed
在单个source或sink可能需要大量数据的用例中(例如,将数据从Kafka发送到HDFS),分布式模式在可伸缩性方面更加灵活,并提供了高可用性服务,从而最小化停机时间。
- Standalone
-
plugin
在配置文件中通过plugin.path
来指定connector, converter, transformation插件所在的一个大Jar包或是包含jar包和第三方依赖的目录,可以有多个,以逗号分隔。 -
当前成熟的Connector如下:
ConnectorName Owner Status HDFS confluent-platform@googlegroups.com Confluent supported JDBC confluent-platform@googlegroups.com Confluent supported MongoDB Source a.patelli@reply.de a.topchyan@reply.de In progress MQTT Source tomasz.pietrzak@evok.ly Community project MySQL Binlog Source wushujames@gmail.com In progress Elastic Search Sink hannes.stockner@gmail.com In progress Elastic Search Sink ksenji@gmail.com Community project Elastic Search Sink a.patelli@reply.de a.topchyan@reply.de In progress
关于ConnectorAPI更多信息可以点击:
- 官方Connector例子
- 自定义Kafka Connector开发指南
- Confluent Platform
- kafka connect,将数据批量写到hdfs完整过程
- 深入理解Kafka Connect:转换器和序列化
- FlinkKafkaConnector
- KafkaConnect实现Mysql增量同步
- Kafka Connect JDBC
- https://www.jianshu.com/p/9b1dd28e92f0
2.4.2 单机模式
2.4.2.1 概述
单机模式下,所有的工作运行在同一进程中。
Worker公共配置文件一般命名为connect-standalone.properties
,启动命令为:
bin/connect-standalone.sh
config/connect-standalone.properties
config/connect-file-source.properties
config/connect-file-sink.properties
上述启动命令的第一个参数是启动模式,有单机模式和分布式模式。
要改JVM参数,可以在bin/connect-standalone.sh
中的export KAFKA_HEAP_OPTS="-Xms256M -Xmx2G"
处修改。
2.4.2.2 File-Connect例子
connect-standalone.properties
示例配置如下:
# Kafka Broker
bootstrap.servers=localhost:9092
# 指定了数据中key转换的格式,控制source-connector写入Kafka的数据格式
# 或sink connector从Kafka读取的数据格式
# 常用的有JsonConverter,AvroConverter,需和Producer指定一致
key.converter=org.apache.kafka.connect.json.JsonConverter
# 指定了数据中value转换的格式,需和Producer指定一致
value.converter=org.apache.kafka.connect.json.JsonConverter
#
key.converter.schemas.enable=true
value.converter.schemas.enable=true
# 不推荐使用; 将在即将推出的版本中删除
internal.key.converter=org.apache.kafka.connect.json.JsonConverter
internal.value.converter=org.apache.kafka.connect.json.JsonConverter
internal.key.converter.schemas.enable=false
internal.value.converter.schemas.enable=false
# standalone模式中重要的配置文件,指定了offset数据存储的文件
offset.storage.file.filename=/tmp/connect.offsets
# 提交offset的时间间隔
offset.flush.interval.ms=60000
# connectors, converters, transformations 插件目录
plugin.path=/usr/local/share/java,/usr/local/share/kafka/plugins,/opt/connectors,
connect-file-source.properties
示例配置如下:
# connector名字
name=local-file-source
# connector source实现类,需要将自己按connect接口规范编写的代码打包后放在kafka/libs目录下,
# 再根据项目结构引用对应connector
connector.class=FileStreamSource
# 指定1个task线程来运行导入
tasks.max=1
# 输入源文件
file=/home/kafka/kafka_2.11-1.1.1/bin/test.txt
# kafka-connect使用的topic名称
topic=connect-test
connect-file-sink.properties
FileStreamSink
示例配置如下:
# connector名字
name=local-file-sink
# connector sink实现类
connector.class=FileStreamSink
# 指定1个task线程来运行导出
tasks.max=1
# 输出目标文件
file=/home/kafka/kafka_2.11-1.1.1/bin/test.sink.txt
# kafka-connect使用的topic名称
topics=connect-test
用以下命令启动该file-connect例子:
./bin/connect-standalone.sh config/connect-standalone.properties config/connect-file-source.properties config/connect-file-sink.properties &
如果遇到了以下报错:
Exception in thread "main" java.lang.NoSuchMethodError: com.google.common.collect.Sets$SetView.iterator()Lcom/google/common/collect/UnmodifiableIterator;
请参考Kafka-常见问题中的2.1 kafka-connect
部分。
随后可启动一个consumer来看我们数据写入connect-test
topic的情况:
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic connect-test
最后可以使用echo
命令来往test.txt
推数据,可以在consumer的窗口观察到数据流入,也可以在file-sink目的地test.sink.txt
看到connect过来的数据。
2.4.3 分布式模式
2.4.3.1 概述
- 分布式模式下,可以自动均衡同组的多个Worker来执行connector和tasks,动态扩缩容,具备了容错性。
- 分布式模式中,offset/config/task状态都存储在Kafka topic中,
- 需要注意的是,
status.storage.topic
,config.storage.topic
,offset.storage.topic
应该按推荐的属性手动创建,否则会用默认配置自动创建 - 需要牢记,分布式模式下只能使用REST API来增删改Connectors.
2.4.3.2 示例
- Worker公共配置文件一般命名为
connect-distributed.properties
,启动命令为bin/connect-distributed.sh config/connect-distributed.properties
。connect-distributed.properties
示例如下:
bootstrap.servers=localhost:9092
# Kafka Connect集群的唯一Group名称,不能和ConsumerGroup冲突
group.id=connect-cluster
key.converter=org.apache.kafka.connect.json.JsonConverter
value.converter=org.apache.kafka.connect.json.JsonConverter
key.converter.schemas.enable=true
value.converter.schemas.enable=true
internal.key.converter=org.apache.kafka.connect.json.JsonConverter
internal.value.converter=org.apache.kafka.connect.json.JsonConverter
internal.key.converter.schemas.enable=false
internal.value.converter.schemas.enable=false
# 存储offset的topic,推荐配置很多分区,多副本,使用压缩
offset.storage.topic=connect-offsets
offset.storage.replication.factor=3
offset.storage.partitions=24
# 存储config的topic,推荐配置单分区,多副本,使用压缩
config.storage.topic=connect-configs
config.storage.replication.factor=3
# 存储任务状态的topic,推荐配置多个分区,多副本,使用压缩
status.storage.topic=connect-status
status.storage.replication.factor=3
#status.storage.partitions=12
# 提交offset的时间间隔
offset.flush.interval.ms=60000
# connectors, converters, transformations 插件目录
plugin.path=/usr/local/share/java,/usr/local/share/kafka/plugins,/opt/connectors,
2.4.4 REST API
默认的REST Server端口是8083,使用HTTP协议。也可以如下方式配置:
listeners=http://localhost:8080,https://localhost:8443
常用REST API如下:
- GET /Connectors
返回所有活跃的 Connector 列表 - POST /Connectors
输入参数为JSON格式。创建一个新的 Connector。request body应该是一个包含String类型的name
字段和Object类型的包含该Connector配置信息的config
字段。 - GET /Connectors/{name}
获取指定name的 Connector 的信息 - GET /Connectors/{name}/config
获取指定name的 Connector 的配置信息 - PUT /Connectors/{name}/config
更新指定name的 Connector 的配置 - GET /Connectors/{name}/status
获取指定name的 Connector 的运行状态,包括它运行/失败/暂停,分配的worker信息,所任task信息,错误日志等。 - GET /Connectors/{name}/tasks
获取指定name的 Connector正在运行的所有任务列表。 - GET /Connectors/{name}/tasks/{taskid}/status
获取指定name的 Connector的指定taskid任务的状态 - PUT /Connectors/{name}/pause:
暂停指定name的 Connector和它的任务,停止消息处理直到恢复。 - PUT /Connectors/{name}/resume:
恢复已暂停指定name的 Connector,若没有暂停则忽略 - POST /Connectors/{name}/restart:
重启 Connector(Connector 已故障) - POST /Connectors/{name}/tasks/{taskId}/restart:
重启指定name的 Connector的指定taskid任务 (比如该任务失败了) - DELETE /Connectors/{name}:
删除指定name的 Connector, 停止所有的任务并删除其配置
获取Connector所有插件信息的 REST API:
-
GET /Connector-plugins
返回已在 Kafka Connect 集群安装的 Connector plugin 列表。请注意,API 仅验证处理此次请求的那个worker所拥有的的Connector ,这意味着你可能看到不一致的结果,特别是在滚动升级的时候(添加新的 Connector jar包时)
-
PUT /Connector-plugins/{Connector-type}/config/validate
对指定的配置定义进行验证,执行对每个配置验证,返回建议值和错误信息
2.4.5 Converter
外部数据源读写的格式不需要与 Kafka 消息的序列化格式一样,因为KafkaConnect会负责和外部数据员交互,并使用Converter和Kafka交互。
常用Converter:
-
io.confluent.connect.avro.AvroConverter
建议使用,适用于Java,注意需要与Schema Registry
配合使用(如"value.converter.schema.registry.url": "http://schema-registry:8081"
)。Avro 是 Confluent 平台的一等公民,拥有来自 ConfluentSchema Registry
、Kafka Connect
、KSQL
的原生支持。Avro是二进制格式,且Avro中Schema单独存储,消息中只有数据主题playload部分且进行了压缩,所以序列化后的数据所占空间更小。
-
org.apache.kafka.connect.json.JsonConverter
适用于结构化数据,是纯文本的,并且依赖于 Kafka 本身的压缩机制。需要指定schema是否嵌入到JSON,如
key.converter.schemas.enable
。使用JsonConverter且需要写入 Kafka 消息包含
schema
的实例:key.converter=org.apache.kafka.connect.json.JsonConverter key.converter.schemas.enable=true
此时consumer可以消费到的带Schema的Kafka数据类似如下,消息中的
playload
和schema
属于冗余重复,占用空间较多:{"schema":{"type":"string","optional":false},"payload":"{\"name\":\"tony\",\"age\":18,\"gender\":\"M\"}"}
调整
schemas.enable=false
后,无Schema,consumer消费数据如下:"{\"name\":\"fisker\",\"age\":20,\"gender\":\"F\"}"
-
org.apache.kafka.connect.storage.StringConverter
字符串格式 -
org.apache.kafka.connect.converters.ByteArrayConverter
提供不进行转换的“传递”选项 -
com.blueapron.connect.protobuf.ProtobufConverter
2.4.6 Transformation
可以通过配置transformations 进行轻量级的消息即时修改, 它们可以方便数据传送和事件路由。具体来说,就是输入一条数据,经Transformation后变为一条处理转换后的数据。
Transformation可形成链条,上一个的输出作为下一个的输入,并可与Source Connector或Sink Connector配合使用。
可按Kafka Connect定义的规则实现自定义Transformation,也可以用Kafka自带的Transformation:
- InsertField - Add a field using either static data or record metadata
-ReplaceField - Filter or rename fields - MaskField - Replace field with valid null value for the type (0, empty string, etc)
- ValueToKey
- HoistField - Wrap the entire event as a single field inside a Struct or a Map
- ExtractField - Extract a specific field from Struct and Map and include only this field in results
- SetSchemaMetadata - modify the schema name or version
- TimestampRouter - Modify the topic of a record based on original topic and timestamp. Useful when using a - sink that needs to write to different tables or indexes based on timestamps
- RegexRouter - modify the topic of a record based on original topic, replacement string and a regular expression
还可参考:
2.4.7 HdfsSink-Connectork例子
HdfsSinkConnectork
示例配置如下:
# connector名字
name="test"
# 将自己按connect接口规范编写的代码打包后放在kafka/libs目录下,再根据项目结构引用对应connector
connector.class=hdfs.HdfsSinkConnector
# 指定1个task线程来运行导入/导出。由于HDFS一个文件每次只能由一个文件操作,所以这里为1
tasks.max=1
# 指定从哪个topic读取数据,这些其实是用来在connector或者task的代码中读取的。
topics=test
# key转换方式,需和Producer指定一致
key.converter=org.apache.kafka.connect.converters.ByteArrayConverter
# value转换方式,需和Producer指定一致
value.converter=org.apache.kafka.connect.json.JsonConverter
# 输出文件所在hdfs的url
hdfs.url=hdfs://127.0.0.1:9000
# 输出文件所在hdfs文件路径
hdfs.path=/test/file
# 可以true也可以false,影响传输格式
key.converter.schemas.enable=true
# 可以true也可以false
value.converter.schemas.enable=true
2.4.8 JDBC-Connectork例子
请参考
- Confluent-Connect-JDBC
- Kafka Connect JDBC简介
- Kafka Connect JDBC Connector概念
- Kafka Connect 实现MySQL增量同步
- Kafka Connect 数据管道教程-JDBC->File
- Kafka Connect 数据管道教程-JDBC->Elasticsearch
- 阿里云-Kafka Connect如何实现同步RDS binlog数据
2.4.9 常见问题
使用不匹配的Converter读写时报错速查表如下:
更多关于问题处理的内容请参考 深入理解Kafka Connect:转换器和序列化的常见错误章节
3 Kafka架构
3.1 基本概念
3.1.1 Broker
就是一个Kafka节点,接受数据读写请求,持久化数据,数据副本复制,leader选举等。Kafka集群由多个Broker节点组成。
3.1.2 Topic
数据的主题,类似于数据库中的表,数据跨多个节点。可通过Topic实现生产者消费(订阅)者。
3.1.3 Partition
一个Topic数据往往分为多个Partition,散布在多个节点上。
每个Partition都是一个有序、不可变的,拥有一系列记录(record),持续追加到结构化的若干文件。
Java Producer的默认分区策略:
- 如果Record指定了Key,采用按key保序策略
- 如果Record没有指定Key,采用轮询策略
轮询策略是Kafka Java生产者的默认分区策略,且该策略负载均衡表现非常优秀,总能保证消息最大限度地被平均分配到所有分区上,默认情况下它是最合理的分区策略。
kafka.cluster.Partition
源码如下:
class Partition(val topicPartition: TopicPartition, // 当前Partition的topic和partition序号
// 标识是否已离线
val isOffline: Boolean,
// 如果某个follower持续没有从leader拉取数据,或追上LEO,时间超过该值则会被leader移出ISR
private val replicaLagTimeMaxMs: Long,
private val interBrokerProtocolVersion: ApiVersion,
// broker.id
private val localBrokerId: Int,
private val time: Time,
// 副本管理器实例
private val replicaManager: ReplicaManager,
// LogManager是kafka log管理系统的入口点,负责log 创建、搜索、清理(后台线程周期删除过期log)
// LogManager将Log放在若干目录中,新的Log会放在拥有最少log的那个目录
// 所有log读写请求都委派给单独的log实例进行
private val logManager: LogManager,
// 基于kafka.zookeeper.ZooKeeperClient实现高级操作
// 为很多组件服务,如Controller, Configs, Old Consumer
private val zkClient: KafkaZkClient) extends Logging with KafkaMetricsGrou
3.1.4 副本-AR(Assigned Replica)
3.1.4.1 概述
一个Partition可以有多个副本,每个副本由Follower和其他普通Consumer一样从主节点拉取数据进行复制。
AR就是所有分区的所有副本。
Partition有一个master和若干follower。master负责处理所有读写请求,follower从leader复制数据。而且master是只能在ISR节点中选举产生。
kafka.cluster.Replica
源码如下:
class Replica(val brokerId: Int, // broker.id
val topicPartition: TopicPartition, // 当前Partition的topic和partition序号
time: Time = Time.SYSTEM,
initialHighWatermarkValue: Long = 0L, // 初始水位线
// 和该副本关联的本地log文件
@volatile var log: Option[Log] = None) extends Logging
3.1.4.2 分区副本分配原理
分区副本分配目标:
- 将副本均匀得分布到Broker集群
- 每个分区的各个副本分散到不同节点上
- 如果Broker有机架信息,则将分区尽可能分布到不同机架
当没有机架信息时,我们遵循以下原则来分配
- 随机位置选择一个Broker开始,先将每个分区的首个副本以轮询方式分配
- 然后分配分区余下副本
无机架信息时例子如下:
对于有机架信息时,先创建BrokerId到Rack的映射:
0 -> "rack1", 1 -> "rack3", 2 -> "rack3", 3 -> "rack2", 4 -> "rack2", 5 -> "rack1"
根据rackID从大到小,则对应BrokerId信息为:
0, 3, 1, 5, 4, 2
随后采用轮询方式分配,比如六个分区,副本数为3,每个分区分配到BrokerId情况为:
注意首列用的我们刚才算出的rackID从大到小对应BrokerId信息看后两列在调整。
而如果此时又有需要分配的分区,则移动除首副本以后的副本分配,变为(0, 4, 2)。
总之,考虑机架时总是先将分区首个副本按轮询机架-broker方式分配,剩余副本偏向于没有分配副本的机架,直到每个机架都有副本,然后开始轮询Broker列表方式分配。
效果就是当副本数大于等于机架数,则每个机架至少一个副本;否则当副本数小于机架数,则每个机架最多只能拿到一个副本。完美场景就是副本数和机架数相等,每个机架都有相同Broker节点,这就保证了副本均匀分布到整个机架和所有Broker。
更多关于partition副本内容可参考
3.1.4.2 分区副本分配源码
AdminZKClient#addPartitions
和AdminZKClient#createTopic
会调用 AdminUtils#assignReplicasToBrokers
方法:
def assignReplicasToBrokers(brokerMetadatas: Seq[BrokerMetadata],
nPartitions: Int,
replicationFactor: Int,
fixedStartIndex: Int = -1,
startPartitionId: Int = -1): Map[Int, Seq[Int]] = {
详细源码解析可参考
3.1.5 ISR(In-Sync Replica)
具体在5.2章节中讲解,这里可以理解为可以当老大的节点集合,是和主副本处于同步的所有副本,由Leader动态维护。
下面是一个KafkaManager中的Topic关于Partition分布的真实例子:
3.1.6 Record
每条记录由[key, value, timestamp]组成。Record拥有Partition内唯一且有序的ID(Offset)
3.1.7 Log
详见这里
在磁盘上存储的是Log,Log又分为多个LogSegment段。同一分区目录下的所有LogSegment同属一个分区。
顺序访问时可直接访问.log
文件速度很快,随机访问可通过索引文件来确定offset对应记录的大致范围再快速查找指定记录在.log
文件中位置。
kafka.log.Log
部分源码如下:
@threadsafe
class Log(@volatile var dir: File, //目录
@volatile var config: LogConfig,
// 允许暴露给客户端的最早offset
// 更新时机:1.用户删除Record 2。Broker的retention
// 作用:1.当LogSegment的nextOffset小于等于logStartOffset时,可以删除该LogSegment(可能在active segment删除时引起log滚动)
// 2.保证logStartOffset <= log HW
@volatile var logStartOffset: Long,
// 指向首个未被flush到disk的记录的offset
@volatile var recoveryPoint: Long,
// 后台操作所用的线程池调度器
scheduler: Scheduler,
brokerTopicStats: BrokerTopicStats,
val time: Time,
val maxProducerIdExpirationMs: Int,
val producerIdExpirationCheckIntervalMs: Int,
// 当前Log的topic和partition序号
val topicPartition: TopicPartition,
val producerStateManager: ProducerStateManager,
logDirFailureChannel: LogDirFailureChannel) extends Logging with KafkaMetricsGroup{
// 该log包含的多个LogSegment
// 注意这里用的是个跳表
private val segments: ConcurrentNavigableMap[java.lang.Long, LogSegment] = new ConcurrentSkipListMap[java.lang.Long, LogSegment]
}
3.1.8 LogSegment
一个Log又有若干LogSegment组成,每个LogSegment包含.log
、.index
、.timeindex
等文件,其中.log
是FileRecords
真正存放log entries
数据,而.index
用来映射逻辑offset到磁盘上文件的偏移值。每个LogSegment都有一个base offset,是该段的起始最小offset,大于前面的段的所有消息的offset。
kafka.log.LogSegment
源码:
class LogSegment private[log] (val log: FileRecords,
// 使用offset做索引
val lazyOffsetIndex: LazyIndex[OffsetIndex],
// 使用time做索引
val lazyTimeIndex: LazyIndex[TimeIndex],
val txnIndex: TransactionIndex,
// 该LogSegment的最小起始offset
val baseOffset: Long,
// 每两个logEntry之间的字节数预估值
val indexIntervalBytes: Int,
val rollJitterMs: Long,
val time: Time) extends Logging
3.1.9 Offset
Offset是Partition内唯一且有序的ID,可以表示Record在partition内的偏移量
3.1.10 Producer
生产者,需要抉择哪一个record应该写到该Topic的哪一个partition内,可通过遍历或key 对 partition取模的方式等实现均衡。
3.1.11 Consumer
消费者从指定topic中消费数据。
3.1.12 Consumer Group
消费者组,一条数据只能同一个Consumer Group内的一个Consumer消费到。
3.1.13 Controller
可参考:
3.1.13.1 概述
每个Broker启动时都会创建KafkaController对象,但是集群中只能有一个KafkaController leader对外提供服务,原理如下:
- 每个节点上的KafkaController会在指定的ZK路径(
/controller
)创建临时节点,第一个成功创建ZK节点的KafkaController成为leader。 - 其余的会因节点已存在而创建失败作为follower,并为该znode设置watcher以在leader controller出问题时及时得到通知。
- 当leader故障后,所有的follower会收到通知,再次竞争在该路径下创建节点从而选举新的leader。
- 每个新担任Leader的Controller会通过ZK的条件递增获得一个新的更大的controller epoch。这样,可使得其他节点在收到较旧的epoch信息时及时忽略,还能避免脑裂!
Kafka controller部分主要做下面这些事情:
- 创建分区;
- 创建副本
- 为每个分区选举leader、ISR 可见这里;
- 更新各种缓存
- Broker节点新增或退出时维护副本leader
- 定时检查Topic的Partition Leader分布到Broker是否均匀,不均匀则触发PartitionLeaderRebanlence。代码在
kafka.controllerKafkaController#checkAndTriggerAutoLeaderRebalance
具体来说,Kafka集群中多个broker,只有一个会被选举为Controller leader,负责管理整个Kafka集群中分区和副本的状态:
-
Leader Controller会为每个partition leader broker设置watcher,当发现某个partition的leader副本故障,就会由Controller负责为该partition重新选举新的leader副本(一般来说就是分区副本里的下一个);随后,Controller将新leader信息广播给所有节点,这样新partition leader就开始处理读写请求、Follower开始从新leader拉取同步数据。
-
当检测到ISR列表发生变化,由Controller通知集群中所有broker更新其MetadataCache信息;
-
增加某个topic分区的时候也会由Controller重新分配工作
-
关于ZK上的partition的watcher
- 老版本kafka的所有Broker节点都会去ZK注册partition的watcher,一旦有Broker挂掉会造成很多Watcher触发并会有很多调整,还需要重新注册watcher,给ZK造成很大负担,可能出现脑裂等意外情况。
- 新版本Kafka只会有KafkaController Leader会向ZK注册Watcher。
-
KafkaController对比ReplicaManager
- KafkaController
负责管理整个集群中分区和副本的状态 - ReplicaManager
负责管理当前broker所有分区和副本的信息,会处理KafkaController发起的一些请求,如副本状态的切换,添加/读取消息等
- KafkaController
3.1.13.2 核心组件
-
ZookeeperLeaderElector: 主要用于KafkaController Leader选举
-
ControllerContext: 维护了controller需要用到的上下文,同时也缓存一些zookeeper数据,包括可用的broker,全部的topic,分区和副本信息
-
ControllerChannelManager: 维护Controller Leader与集群中其他broker之间连接,是管理这个集群的基础
-
TopicDeletionManager: 用于删除指定的topic
-
PartitionStateMachine: 用于管理集群所有partition状态的状态机
-
ReplicaStateMachine: 用于管理集群中所有副本状态的状态机
-
ControllerBrokerRequestBatch: 实现了向broker批量发送请求的功能
-
PartitionLeaderSelector:选举leader副本的选举策略
-
IzkChildListener:是zookeeper上的监听器,实现了对zookeeper上某些节点数据,子节点或者session状态的监听,被处罚后调用对应的业务逻辑
3.2 可用性和持久性保证
3.2.1 acks
Kafka生产者中有一个很重要的配置项:
request.required.acks
不同的值决定生产者发送消息后不同的等待行为,这就影响着写入kafka的数据可靠性和持久性保证,具体来说如下:
request.required.acks(高版本为acks ) | 含义 | 使用场景 | 可靠性 |
---|---|---|---|
0 | 生产者不等broker确认,而是直接放入本机Socket Buffer 准备发送就不管了。此时retries 参数不会生效,因为客户端不知道是否发送异常。此时调用producer.send 方法返回的RecordMetadata 的offset始终为-1 | 延迟要求高但允许丢数据 | at most once |
1 | 默认值。生产者会等待该partition的leader副本确认收到此条数据,而不等待已发送给其他follower副本 | 适中的时效性和可靠性,在leader还没有同步给follower副本时可能丢数据 | 可能多也可能少 |
-1(0.9以后为all) | 生产者会等待该partition的所有位于ISR集中的节点上的副本都向leader确认收到此条数据 | 时效性低,可靠性高 | at least once |
3.2.2 持久性比可用性重要时的设置
此外,如果持久性比可用性重要(也就是说不允许丢数据),那么可以做以下两个操作:
- 禁止在ISR节点挂掉后,非ISR节点被选为leader。因为这些节点数据可能不完整。
- ack设为-1/all,即ISR-Ack
- 指定最小的ISR集合大小。这个设定用来均衡
Avaliable
和Consistency
。设置越大一致性越强,但可能因为某些ISR节点挂掉甚至少于最小ISR集合大小导致partition无法写入。
3.2.3 retires
另外一个很重要的持久性辅助保证设置就是retires
:
- 默认值为0,即producer.send发生失败时不重试,而是跳过
- 此值设置正整数,且未把
max.in.flight.requests.per.connection
设为1,可能导致乱序,比如发送两批数据到一个partition,第一批失败被重试,第二批直接成功,则第二条会排在第一条前拥有更小的offset。 max.in.flight.requests.per.connection
具体可以查看这里
3.2.4 消息发送幂等
在0.11.0之前,producer未收到response只能retry即重发,也就是at least once
。
0.11.0开始,kafka实现了消息发送幂等,具体做法是这样:
- 给每个producer分配一个ID
每个新的Producer在初始化的时候会被分配一个唯一的PID,这个PID对用户是不可见的。 - producer发送每条消息时带上一个
Sequence Number
序号
对于每个PID,该Producer发送数据的每个<Topic, Partition>
都对应一个从0开始单调递增的Sequence Number - Broker使用上述序号进行消息去重
Broker缓存这些Sequence Number
,对于接收到的每条消息如果其序号比缓存中序号大则接受,否则丢弃。
需要注意的是:
- 当前的幂等性只能保证单分区
即一个幂等性 Producer 能够保证某个Topic的一个Partition上不出现重复消息,但它无法实现多个Partition的幂等性。 - 当前只能实现单Session上的幂等性,不能实现跨Session的幂等性。
这里的Session可以理解为 Producer 进程的一次运行,即当重启 Producer 进程之后幂等性保证就丧失了。
消费者的语义可见这里
3.3 均衡
3.3.1 分区Leader均匀分布
Kafka的每个broker节点是某些partition的leader,又是另一些的follower。master负责处理所有读写请求,follower从leader复制数据。这样就实现了读写处理的均衡。
3.3.2 生产时
生产者来抉择哪一个record应该写到该Topic的哪一个partition内,可通过遍历或key 对 partition取模的方式等实现均衡。
3.3.3 消费时
Consumer Group内有多个consumer实例时,数据会均衡地分发给这些consumer处理。
具体来说,在Kafka中实现消费的方式是通过在消费者实例上划分日志中的partition,以便每个Consumer在任何时间点都是分配的“公平份额”的独占。Kafka协议动态处理、维护Consumer Group中成员资格。
如果新consumer实例加入该Group,将从该组的其他consumer接管一些partition;如果consumer挂掉退出group,其partition将分发给其余组内成员。
3.4 分区有序
Kafka仅保证partition内有序,而非topic有序。实现原理大致是每个生产者客户端与每个broker只有一个连接,而且一个连接只允许存在一个in_flight_request。具体可以查看这里中关于此问题的讨论。
如果必须要topic有序就只能使用一个partition,也就意味着只能有一个consumer消费,并发能力低下。
4 数据流
4.1 Producer-数据生产流程
-
寻找leader
客户端决定了发送消息的partition。生产者在发送数据前,可以询问kafka集群中任一节点目标topic partition的leader在哪个节点上,然后直接将数据发送给该partition leader所在broker。 -
批处理
而且,Kafka生产者也可在内存中尝试累积数据并在单个请求中以批的形式发送数据。这样的好处是以少量延迟换取高吞吐。这一且都是可配的。 -
以下生产流程为ack=-1/all时
- producer 先从 zookeeper 的 “/brokers/…/state” 节点找到该 partition 的 leader
- producer 将消息发送给该 Leader
- Leader 将消息写入本地 log,并增加Leader LEO
- Follower们附带着自己的LEO来从 Leader pull 消息,
- Leader从底层log读取数据,并增加Leader管理的Follower的LEO,在返回数据给Follower的同时附带Leader的HW,
- Follower收到数据后,先写入本地 log ,然后增加Follower管理的LEO,并比较收到的Leader HW和自己的LEO,取较小的那个作为自己的HW(Follower HW永不会超过 Leader HW)。最后Follower向 Leader 发送 ACK。
- Leader 收到所有 ISR 中的 replica 的 ACK 后,增加Leader分区HW(
high watermark
,最后 commit 的 offset,取所有Follower返回所有LEO和自己的LEO中最小值) 并向 producer 发送 ACK
4.2 Consumer-数据读取
Kafka消费者采用拉的方式从partition的leader节点上消费数据。
切记,Consumer不会从partition的follower副本拉取数据!
在每次消费请求中,会附带上offset,消费时就会从该位置开始接受一块儿数据。所以消费者可以根据业务需要手动指定offset,以实现重复消费或跳过一段消费。
4.3 数据同步
创建副本的单位是topic的分区,每个分区都有一个leader和0或多个followers。所有的读写操作都由leader处理,一般分区的数量都比broker的数量多的多,各分区的leader均匀的分布在brokers中。所有的followers都像普通的consumer那样从leader消费并保存在自己的日志文件中,达到数据同步的目的。数据中的消息、顺序都和leader中相同。
Kafka的副本功能不是必须的,你可以配置只有一个副本,这样其实就相当于只有一份数据。
4.4 Topic创建
Kafka创建topic命令很简单,要创建一个名为test的topic、3个分区、每个分区3个副本,命令如下:
bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 3 --partitions 3 --topic test
topic创建主要分为两个部分:命令行+后台逻辑,如下图所示。
如上图,后台逻辑会监听zookeeper下对应的目录节点,一旦从命令行发起topic创建命令,就会创建新的数据节点从而触发后台的topic创建逻辑。
简单来说我们发起的命令行主要做两件事情:
- 确定分区副本的分配方案(就是每个分区的副本都分配到哪些broker上);
- 创建zookeeper节点,把这个方案写入/brokers/topics/节点下
Kafka controller部分主要做下面这些事情:
- 创建分区;
- 创建副本
- 为每个分区选举leader、ISR;
- 更新各种缓存
更多详细内容请点击这里
4.5 数据持久化
写入Kafka的数据将写入磁盘并进行复制以实现容错。
5 重要概念
5.1 high watermark高水位(HW)
关于水位好文Kafka水位
本节大量内容转自此文。
5.1.1 水位是什么
水位或水印(watermark)一词,也可称为高水位(high watermark,简称HW),通常被用在流式处理领域(如Flink,Spark),以表征元素或事件在基于时间层面上的进度。一个比较经典的表述为:流式系统保证在水位t
时刻,创建时间(event time) = t'
且t'
≤ t
的所有事件都已经到达或被观测到。
5.1.2 Kafka中的水位
在Kafka中,称为high watermark (HW),与时间无关,而是与位置信息相关。严格来说,它表示的就是位置信息,具体来说表示Kafka中partition的各个副本之间同步且一致的offset位置,即所有副本已经commit的位置。每个Broker缓存中维护此信息,并不断更新。
Consumer从Leader消费时,只有被commit过的消息(broker维护的消息offset <= HW的消息)才对Consumer可见。
Kafka 0.11之前副本备份机制主要依赖水位(或水印)的概念,而0.11采用了leader epoch
来标识副本的备份进度。
每个Kafka副本对象都有两个重要的属性:LEO和HW。注意是所有的副本,而不只是leader副本。
从上图可以看出:
-
HW值是7,表示位移为[0,7]的所有消息都已经处于“已备份状态”(committed)
-
而LEO值是15,那么
8~14
的消息就是尚未完全备份(fully replicated)——为什么没有15?因为刚才说过了,LEO指向的是下一条消息到来时的位移,故上图使用虚线框表示。 -
所谓Consumer无法消费Uncommitted的消息,即Consumer无法消费分区下的leader副本中那些
offset
值大于分区HW的消息。这里需要特别注意所谓分区HW就是leader副本的HW值。
-
LEO
即日志末端位移(log end offset),记录了该副本底层日志(log)中下一条消息的位移值。注意是下一条消息!也就是说,如果LEO=10,那么表示该副本保存了10条消息,位移值范围是[0, 9]。另外,leader LEO和follower LEO(有两套)的更新是有区别的。-
Leader副本的LEO
leader接受到新数据时写入后就会更新自己的LEO值 -
Follower自己管理的副本LEO
follower从leader同步过来一条新消息时,其自身管理的LEO值+1 -
Leader管理的Follower副本LEO
Follower不断向Leader拉取同步数据的同时会附带上自己的LEO。当leader返回数据给follower时,leader更新其管理的该follower的LEO值+1。随后会根据Follower LEO情况动态更新ISR列表!还会使用ISR中LEO最小值来更新Leader管理的HW。返回给的Follower的数据会带上Leader HW。
-
-
HW
即上面提到的水位值。对于同一个副本对象而言,其HW值不会大于LEO值。小于等于HW值的所有消息都被认为是“已备份”的(replicated)。同理,leader副本和follower副本的HW更新是有区别的:-
Follower副本HW
在从Leader拉取数据的请求响应中会有个Leader的HW值,Follower需要比较该值和Follower的当前LEO值,取较小的那个作为自己的HW。(Follower HW永不会超过 Leader HW) -
Leader副本HW
他就代表了该partition的HW,这个值会被影响数据对Consumer的可见性,所以十分重要。以下情况会尝试更新HW:- 副本成为leader副本
当某个副本成为了分区的leader副本,Kafka会尝试去更新分区HW。 - Broker出现崩溃导致副本被踢出ISR
若有broker崩溃则必须查看下是否会波及此partition,因此检查分区HW值是否需要更新是有必要的。 - Producer向leader副本写入消息
此时会更新leader的LEO,故有必要检查HW值是否也需要修改 - Leader处理Follower的pull请求
此时会首先从底层的log读取数据,之后会尝试更新分区HW值
当Leader尝试修改分区HW时,会选择那些处于ISR中或是不处于ISR但副本LEO落后Leader LEO不超过
replica.lag.time.max.ms
的副本(拥有进入ISR资格但还未进入ISR),将他们的LEO和自己的LEO进行比较,最终选择最小LEO作为分区HW。 - 副本成为leader副本
-
5.2 ISR
5.2.1 ISR是什么
Kafka有分区副本概念用于实现冗余,副本根据角色的不同可分为3类:
- Leader副本:响应clients端读写请求的副本
- Follower副本:以pull方式备份leader副本中的数据,不能响应clients端读写请求。
- ISR副本:包含了leader副本和所有与leader副本保持同步的follower副本
Kafka的leader动态维护了一个动态同步状态的副本的集合(a set of in-sync replicas),简称ISR,每个Partition都会有一个ISR,在这个集合中的节点都是和leader保持高度一致的。任何一条消息必须被这个集合中的每个节点读取并追加到日志中了,才会通知外部这个消息已经被提交了。因此只有ISR集合中的节点才可能被选为leader。
如果一个follower比leader落后太多,或者超过一定时间未发起数据复制请求,则leader将其从ISR集合中移除 。
-
既然所有Replica都向Leader发送ACK时,leader才commit,那么follower怎么会leader落后太多?
producer往kafka中发送数据,不仅可以一次发送一条数据,还可以发送message的数组;同步的时候批量发送,异步的时候本身就是就是批量;底层会有队列缓存起来,批量发送,对应broker而言,就会收到很多数据(假设1000),这时候leader发现自己有1000条数据,follower只有500条数据,落后了500条数据,就把它从ISR中移除出去,这时候发现其他的follower与他的差距都很小,就等待;如果因为内存等原因,差距很大,就把它从ISR中移除出去。所以在0.9以后移除了
replica.lag.max.messages
(允许 follower 副本落后 leader 副本的消息数量,超过这个数量后,follower 会被踢出 ISR)现在只有
replica.lag.time.max.ms
,默认10000毫秒。
5.2.2 ISR原理
ISR通过ZooKeeper持久化。
当某个topic有f+1个
副本时,就可以允许在f
个副本写入失败的情况下不会丢失消息。这一点就是Kafka可用性(Avaliable)的体现。
ISR的成员是动态维护的。如果一个节点因为某种原因被淘汰了,当它重启后必须同步数据,重新达到“同步中”的状态后可以重新加入ISR。这种leader的选择方式是非常快速的,适合kafka的应用场景。
如果所有节点都down掉了怎么办?Kafka对于数据不会丢失的保证,是基于至少一个节点是存活的,一旦所有节点都down了,这个就不能保证了。
实际应用中,当所有的副本都down掉时,必须及时作出反应。可以有以下两种选择:
- 等待ISR中的任何一个节点恢复并担任leader
- 选择所有节点中(不只是ISR)第一个恢复的节点作为leader
这是一个在可用性和连续性之间的权衡:
-
如果等待ISR中的节点恢复,一旦ISR中的节点起不起来或者数据都是了,那集群就永远恢复不了了;
-
如果等待ISR以外的节点恢复,这个节点的数据就会被作为线上数据,有可能和真实的数据有所出入,因为有些数据它可能还没同步到。
Kafka 0.11.0
之前选择了第二种策略,之后的版本选择第一种策略永远等待。可以根据场景灵活的。具体配置是unclean.leader.election.enable
,设为true表示允许不是ISR中的节点被选为leader。
这里就能看出CAP理论下,Kafka 可在选择了基础上,还能自主选择 C 或 A。
5.2.3 如果ISR中有Follower副本挂掉
分区中ISR中的某follower副本挂掉后,leader会将该follower副本剔除出ISR。
待该follower恢复后,会去读取本地磁盘记录的最后一次HW,并将log中大于HW的部分截取掉,并从HW位置开始请求向leader同步。当follower的LEO达到该分区partition的HW时候(即该follower赶上了leader),就可以重新加入到ISR中。
5.2.3 小结
总的来说,ISR的职责就是两个:
- 当leader
- 同步数据必达之一、应答producer
5.3 Consumer和水位
- Leo
表示producer提交的message set的最后一个offset的下一个位置。保存在recovery-point-offset-checkpoint
文件内 - HW
表示最大可以供Consumer消费的message set的offset。保存在recovery-point-offset-checkpoint
文件内 - Current Position
表示某个Consumer Group真实消费到的offset,由Consumer自己维护。 - Last Committed Offset
代表了某个Consumer Group 已经Commit到Kafka Server的offset。
5.4 Heartbeat
由于Consumer定时向GroupCoordinator心跳超时会导致Session连接超时,被判断为已经断开,所以会造成Rebalance。
而一旦GroupCoordinator返回了某个特定的error code:ILLEGAL_GENERATION
就说明之前的group无效了(解散了),要重新进行JoinGroup + SyncGroup操作。
可见AbstractCoordinator#HeartbeatResponseHandler
else if (error == Errors.ILLEGAL_GENERATION) {
log.info("Attempt to heartbeat failed since generation {} is not current", generation.generationId);
resetGeneration();
future.raise(Errors.ILLEGAL_GENERATION);
}
/**
* Reset the generation and memberId because we have fallen out of the group.
*/
protected synchronized void resetGeneration() {
this.generation = Generation.NO_GENERATION;
this.rejoinNeeded = true;
this.state = MemberState.UNJOINED;
}
心跳任务是执行在Consumer的一个线程:AbstractCoordinator.HeartbeatThread
,内有死循环。
有一些响应错误,需要进行Rejoin Group、重置 generation等处理
5.4 有序性
这里讲的不是前面提到的分区有序,而是Kafka可以保证同一个生产者对一个指定topic partition发送的记录是有序的,也就是说同一个生产者先发送的记录总是能比后发送的记录有更小的offset,更早的对consumer可见。
这是由前面提到过的机制保证:服务器可以保证在单个TCP连接中,会按发送顺序来处理和响应请求。broker仅允许每个连接存在一个in-flight请求,来保证排序
。
-
max.in.flight.requests.per.connection
此项为Producer配置,默认为5。表示客户端在一个独立连接中需要阻塞时的最大发送请求数。注意,如果此项被设置为大于1,可能会在开启了
retries
时导致发送数据和Kafka存储顺序不一致!具体请参考这里
5.5 容错性
ISR章节已经说过,当某个topic有f+1个
副本时,就可以允许在f
个副本写入失败的情况下也不会丢失消息。
5.6 Push vs. Pull
Kafka采用Producer push,Consumer pull的方式。
push的缺点是消费者无法控制消费速率,可能造成消费速度跟不上导致出错,但是实时性强。
pull的好处是消费者自己根据情况控制消费速率,但实时性较push模式差
Kafka采用了pull模式,除了上述优点还有一个原因是消费者可以对数据进行批处理。而且Kafka允许pull 无数据时 consumer阻塞而不是持续轮询。
5.7 消息传递语义
5.7.1 生产者语义
在3.2可用性和持久性保证中在生产者角度谈了一些重要配置,现在总结下生产者语义
-
at most once
acks
设为0,即Producer发送消息不等待leader任何反馈。此时数据最多只会被发送一次。 -
at least once
acks
设为-1/all,即Producer发送消息等待leader及ISR确认。此时数据至少会被发送一次(除非leader和ISR再返回确认后一起全挂才会丢失,几乎不可能)。 -
exactly once
Kafka0.11之前如果Producer未能收到消息确认ack,则只能重发,但其实此时无法确认错误是在提交消息之前还是之后发生。如果设置了retires就会重发,此时是at least once
。从0.11开始,Producer支持幂等生产,具体来说Broker为每个Producer分配唯一ID,且Producer发送每条消息时还会附带一个顺序号,Broker据此进行消息去重。这就实现了Produer
exactly once
。
5.7.2 消费者语义
消费者角度语义:
-
at most once
读取数据后先提交offset后处理数据。这种情况下可能发生已经提交offset但处理数据失败。 -
at least once
读取数据后先处理消息再保存offset。这种情况下可能发生已经处理的数据却没有提交offset。只不过在大多数情况下可以用主键来保证幂等。 -
exactly once
如果具有唯一主键,则重复数据仅会覆盖,以下讨论没有唯一主键的情况。当从一个KafkaTopic消费并且生产到另外的topic时,kafka 0.11.0之后可以利用生产者事务。Consumer的offset作为topic中的消息存储,因此我们可以在与接收处理数据的输出topic相同的事务中将offset写入Kafka。如果事务中止,则Consumer的offset将恢复为旧值,并且输出topic上生成的数据将不会被其他消费者看到,具体取决于其“隔离级别”。在默认的
read_uncommitted
隔离级别中,消费者可以看到所有消息,即使它们是中止事务的一部分,但在read_committed
中,消费者只会从已提交的事务中返回消息。而在写入外部系统时,需要协调消费者的offset与实际存储:
- 两阶段提交
实现这一目标的经典方法是在offset和外部存储之间引入两阶段提交,但许多外部系统不支持两阶段提交。 - 原子提交offset和数据到外部系统
更简单的,可以通过让消费者将其offset存储在与其外部输出存储相同的位置来处理。例如,引入一个Kafka Connector
,它填充HDFS中的数据以及它读取的数据的offset,以确保数据和偏移都被更新或两者都不更新。如果外部系统支持主键的话就直接覆盖,更简便。
KafkaConnector将数据和offset一起原子性写入HDFS。Kafka Stream利用此以及事务性生产者/消费者在Kafka topic之间传输和处理数据时实现了exactly once。
-
将数据映射为唯一key,每次消费前先查询该key是否已经存在。效率低
-
利用幂等性
一些系统支持id幂等,如hbase、ES、传统数据库主键约束等 -
利用版本号实现幂等
对于不支持幂等系统,人为给数据增加版本号属性,每次更数据前,比较当前数据的版本号是否和消息中的版本号一致,如果不一致就拒绝更新数据,更新数据的同时将版本号 +1,一样可以实现幂等。数据生产的时候就加入一列version ,消费的时候需要将这条数据的version和本地version对比:
- 如果更小就说明已经消费过不再消费,直接跳过;
- 否则说明未消费过,将数据写入目的地并将内存内的version+1
- 两阶段提交
详细实现例子可参考:
5.8 生产者幂等和生产者事务
可参考Notes on Exactly Once Semantics
kafka 0.11.0开始支持producer的幂等和事务:
- 幂等
幂等保证同一条数据只被一个生产者实例发送到指定topic的指定partition精确一次 - 事务
事务允许生产者发送数据给多个partition,要么全部成功,要么就都失败。
这两个特性共同提供EOS特性。0.11.0版本后的java produce consumer new API可以使用EOS。
事务状态存在一个特殊的topic __transaction_state
中,当第一次使用事务请求API时会自动创建。
事务隔离机制可以通过consumer的isolation.level
配置,他能控制消费者读取通过事务写入的数据的方式:
事务隔离 | 是否默认 | consumer.poll()结果 |
---|---|---|
read_committed | 默认 | 只返回事务性中已提交的数据。 |
read_uncommitted | 返回所有数据,包括丢弃的事务性数据 | |
非事务性数据 | 无论如何都会返回 |
注意,消息将始终以offset顺序返回。 因此,在read_committed
模式下,consumer.poll()将仅返回最后一个稳定偏移量(LSO)的消息,该offset小于第一个打开的事务offset。 特别是在相关事务完成之前,将保留在属于正在进行的事务的消息之后出现的任何消息。 因此,当有正在进行的事务时,read_committed消费者将无法读取数据高水位线。
5.9 关于使用磁盘
Kafka官方文档介绍说,合理使用、设计磁盘存储结构通常和网络一样快。顺序读取磁盘因为其可预测性,所以操作系统针对此进行了大量优化。而且现代操作系统使用了预读(read-ahead,在实际请求前预先读文件的几个相邻的数据块)和后写(write-behind,将一些较小的逻辑写入操作合并起来组成比较大的物理写入操作)。在ACM Queue article这篇文章中甚至论证结果为:在某些场景下磁盘顺序访问速度可以比内存随机访问还快!
另一方面,现代操作系统往往将大量内存用于磁盘缓存,这种策略在回收内存时几乎没有性能损失。此时,非直接IO操作都会经过此统一缓存,十分高效。
Kafka设计是以PageCache为中心设计的存储。Kafka的做法并不是将数据保留在内存中,并在数据快填满缓存时刷盘,而是直接将数据持久化到文件系统(其实是内核中的PageCache)。这么做的原因如下:
-
使用JVM的程序会随着数据量增加,GC越来越频繁和缓慢,Kafka作者认为使用磁盘文件系统并依赖PageCache优于维护内存缓存或是其他结构。
当上层有写操作时,操作系统先将数据写入PageCache并将相关Page标记为Dirty。
当读操作发生时,优先从PageCache中查找,如果发生缺页才进行磁盘调度即从磁盘读取数据,最终返回需要的数据。
实际上PageCache思想是把尽可能多的空闲内存都当做了磁盘缓存来使用。同时如果有其他进程需要申请内存,回收PageCache的代价又很小(将PageCache脏页刷入磁盘),所以现代操作系统都支持PageCache。
-
通过这个设计,可以在32GB内存机器上产生高达28-30GB的缓存而且没有GC开销,并且服务器重启该缓存仍然不会全部失效。与之对比,进程内的缓存需要随重启而重建,带来大量开销。
-
代码设计工作被大大简化,因为维护高速缓存和文件系统的一致性工作现在由操作系统来完成了。
-
大量的顺序读可以充分利用磁盘预读技术
此外Kafka没有采用传统消息系统使用的BTree,因为看起来时间复杂度是O(log N) 但因为磁盘寻道时间10ms而且每个磁盘不可能并行寻道,再加上缓存和磁盘混用,最终会导致时间复杂度超线性。
Kafka针对此问题的方法是在简单读取上构建队列并追加到文件,使得时间复杂度为O(1)且不阻塞读写。
5.10 批量IO和零拷贝
除了磁盘问题,影响Kafka性能表现的还有两个关键问题:
5.10.1 过多的小IO操作
一般小IO发生在C/S之间或Server自己的持久化操作中。
为了避免这个问题,Kafka设计了一个消息集
抽象,他自然地将消息组合。这样就能让数据传输时将多条数据组合,而不是一条一条单独传输。这种批处理的好处是将数据转为大型网络数据包、大型顺序磁盘操作、连续的内存块。
5.10.2 过多的字节复制
在高负载工况下过多的字节复制对系统影响大。
Kafka采用了标准化的二进制消息个事,Producer Consumer Broker共享,数据块可以在传输过程中不修改。
Kafka使用了linux的sendfile
系统调用来优化将数据从pagecache
传输到socket
的过程。
一般来说这个过程是这样的:
- OS读取磁盘中的数据
- OS将读到的数据写入Kernel的PageCache
- Application从Kernel中PageCache读取数据
- Application将读到的数据放入UserSpace Buffer
- Application将数据写回Kernel中的Socket Buffer
- OS从Socket Buffer复制数据
- OS将复制的数据放入网卡NIC Buffer
- OS通过网络发送数据
可以看到上述过程很繁琐,有多达4份数据副本(1内核PageCache -> 2用户Buffer -> 3内核Socket Buffer -> 4网络NIC Buffer)和多次系统调用以及用户态和内核态空间状态切换。
5.10.3 零拷贝(Zero-Copy)
而Kafka-零拷贝SendFile(2.4+版本)过程如下:
可以看到SendFile省去了多余的数据副本和系统调用,可以让OS直接将数据从PageCache发送到网络,只有两份数据副本。
我们可以举个例子:
多个消费者订阅一个Topic。使用零拷贝技术,数据只会被拷贝到PageCache中一次。当多个消费者消费时,可以直接重用PageCache中的数据。而不是将数据存储到内存,让每次消费时去内存读取然后复制到自己的用户空间。
这样的好处很明显:
- 省去了反复的数据复制时间和空间消耗
- 性能大大提升,甚至接近带宽限制
- 直接从PageCache读而不是磁盘,速度提升
关于更多关于零拷贝sendFile
的例子可以点击sendfile:Linux中的"零拷贝"
在Kafka中使用零拷贝将内容传输到网络SocketChannel的org.apache.kafka.common.network.PlaintextTransportLayer
代码如下:
@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
该方法会在客户端向服务端拉取数据时调用,读取到相关文件消息后零拷贝发送到网络通道传回客户端。
5.11 数据压缩
网络带宽也会成为瓶颈,特别是需要通过广域网在数据中心之间发送消息的数据管道而言。如果用户直接用每个消息单独压缩-解压缩方式,那么其实压缩比并不高,因为kafka中冗余的往往是如字段名称这类的公共数据。
Kafka支持Producer高效的批量消息压缩然后发往服务器,并以压缩形式吸入日志,最后由消费者解压缩。
Kafka有GZIP,Snappy,LZ4等压缩协议
更多Kafka压缩请点击Kafka-Compression
5.12 preferred replicas - 优先副本
每当有Broker节点停止或崩溃时,其上的分区就会转移到其他副本。 这意味着默认情况下,重新启动Broker时,它将仅是其所有分区的Follower,这意味着它将不能用于客户端进行数据读写。
为了避免这种不平衡,Kafka提出了preferred replicas
的概念,其实每次Partition Leader再平衡时选择Broker Id较小的来当Leader。
比如,某个分区的副本列表为1,5,9
,则首选节点1
作为节点5
或9
的Leader,因为它在副本列表中Broker Id较小。
也可以让Kafka集群尝试通过运行以下命令来恢复对已还原的副本的领导权:
bin/kafka-preferred-replica-election.sh --zookeeper zk_host:port/chroot
还有个配置auto.leader.rebalance.enable
,默认为true,即有个后台线程定时检查(leader.imbalance.check.interval.seconds
,默认300秒)Partition Leader是否需要Rebalance。
5.13 Quotas配额-多租户(Multi-tenancy)
可以通过quotas
(配额)来控制客户端使用的broker资源
6 leader选举
6.1 概述
Kafka中的leader选举主要包括Controller Leader选举和 Partition Leader选举。
6.2 Kafka Controller Leader选举
6.2.1 概述
如果controller 挂掉了,活着的节点中的一个会被切换为新的controller。
- 所有Broker节点都会启动一个Controller,启动后都会尝试在指定的ZK路径(
/controller
)创建临时节点,第一个成功创建ZK节点的KafkaController成为leader。 - 其余的节点会因
/controller
节点已存在而创建失败作为follower,并为该znode设置watcher以在leader controller出问题时及时得到通知。 - 当leader controller故障后,所有的follower会收到通知,再次竞争在该路径
/controller
下创建节点从而选举新的leader。 - 每个新担任Leader的Controller会通过ZK(
/controller_epoch
)的条件递增获得一个新的更大的controller epoch。这样,可使得其他节点在收到较旧的epoch信息时及时忽略,还能避免脑裂!
6.2.2 源码
6.2.2.1 概述
先调用kafka.controller.KafkaController#elect
,然后在内部调用kafka.zk.KafkaZkClient#registerControllerAndIncrementControllerEpoch
。
elect
方法有两个调用时机:
- Kafka Server启动时
具体代码在kafka.controller.KafkaController#startup
:
def startup() = {
zkClient.registerStateChangeHandler(new StateChangeHandler {
override val name: String = StateChangeHandlers.ControllerHandler
override def afterInitializingSession(): Unit = {
// ZK回话过期,重新建立后会调这个
// 先注册Broker信息到ZK,然后执行maybeResign和elect
eventManager.put(RegisterBrokerAndReelect)
}
override def beforeInitializingSession(): Unit = {
val expireEvent = new Expire
eventManager.clearAndPut(expireEvent)
// Block initialization of the new session until the expiration event is being handled,
// which ensures that all pending events have been processed before creating the new session
expireEvent.waitUntilProcessingStarted()
}
})
// 其实就是将Startup事件放入queue
eventManager.put(Startup)
// 启动ControllerEventThread线程,不断从queue中获取ControllerEvent处理
eventManager.start()
}
- 发现Kafka Controller Leader不在了需要重新选举时
具体代码在kafka.controller.KafkaController#processReelect
(kafka 2.7,老版本也是在StateChangeHandler里面)
6.2.2.2 KafkaController#elect
部分核心源码如下:
// 先尝试获取当前ctrollerId,因为可能已经有Controller被选为Leader
activeControllerId = zkClient.getControllerId.getOrElse(-1)
// 如果 已经有Controller被选为Leader,就无需继续选举
if (activeControllerId != -1) {
debug(s"Broker $activeControllerId has been elected as the controller, so stopping the election process.")
return
}
// 否则说明需要选举
try {
// 尝试以自己的BrokerId来注册为Leader以及更新/controller_epoch
// 得到当前controller epoch
val (epoch, epochZkVersion) = zkClient.registerControllerAndIncrementControllerEpoch(config.brokerId)
// 走到这里,说明本节点申请Controller Leader成功
controllerContext.epoch = epoch
controllerContext.epochZkVersion = epochZkVersion
// 将新的
activeControllerId = config.brokerId
info(s"${config.brokerId} successfully elected as the controller. Epoch incremented to ${controllerContext.epoch} " +
s"and epoch zk version is now ${controllerContext.epochZkVersion}")
// 本节点已成为新的Controller,需要做很多Controller初始化工作,详见代码后的总结
onControllerFailover()
} catch {
// Controller Leader是其他节点
case e: ControllerMovedException =>
// 又需要处理下
maybeResign()
if (activeControllerId != -1)
debug(s"Broker $activeControllerId was elected as controller instead of broker ${config.brokerId}", e)
else
warn("A controller has been elected but just resigned, this will result in another round of election", e)
case t: Throwable =>
error(s"Error while electing or becoming controller on broker ${config.brokerId}. " +
s"Trigger controller movement immediately", t)
triggerControllerMove()
}
}
- 获取当前controllerId,如果获取到了就返回什么都不用做了
- 否则尝试创建
/controller
节点
尝试以自己的BrokerId来注册为Leader以及更新/controller_epoch
,得到当前controller epoch - 如果失败,会抛ControllerMovedException,调用maybeResign处理
- 否则成功,则,本节点已成为新的Controller,需要做以下事情
- 注册各种监听器
- 初始化controllerContext,他持有当前所有topic、存活的broker节点、partition的leader信息等
- 开启channel manager
- 开启replica状态机
- 开启partition状态机
- 开启kafkaScheduler线程池,做各项异步工作
- 默认开启了
auto.leader.rebalance.enable
,所以还需要开启一个间隔为leader.imbalance.check.interval.seconds
(默认300秒)的定时任务,用来做所有partition的Leader再平衡工作
brokerChangeHandler, topicChangeHandler, topicDeletionHandler, logDirEventNotificationHandler,
isrChangeNotificationHandler
6.2.2.3 KafkaController#maybeResign
为/controller
设置watcher,感知Leader Controller掉线。
当之前是Leader,现在不是Leader时,需要清空本节点作为Leader时的一系列内容:
- 删除对/isr_change_notification、/reassign_partitions、/preferred_replica_election、/log_dir_event_notification的Znode变动监听
private def maybeResign(): Unit = {
// 如果activeControllerId == config.brokerId则为true
val wasActiveBeforeChange = isActive
// 在/controller设置watcher,感知Leader Controller掉线
zkClient.registerZNodeChangeHandlerAndCheckExistence(controllerChangeHandler)
// 获取最新的Controller Leader Id
activeControllerId = zkClient.getControllerId.getOrElse(-1)
if (wasActiveBeforeChange && !isActive) {
// 当之前是Leader,现在不是Leader时,需要清空本节点作为Leader时的一系列内容
onControllerResignation()
}
}
6.2.2.4 KafkaZkClient#registerControllerAndIncrementControllerEpoch
- 读取持久节点
/controller_epoch
获取当前controller epoch,如果不存在就创建一个带有初始值(从0开始)的持久化Znode/controller_epoch
。val (curEpoch, curEpochZkVersion) = getControllerEpoch .map(e => (e._1, e._2.getVersion)) .getOrElse(maybeCreateControllerEpochZNode())
- maybeCreateControllerEpochZNode结果就会有创建Znode成功和已存在两种可能:
private def maybeCreateControllerEpochZNode(): (Int, Int) = { createControllerEpochRaw(KafkaController.InitialControllerEpoch).resultCode match { // 创建成功,返回初始controller_leader_epoch和初始epochZkVersion(表示此znode的数据修改次数) case Code.OK => info(s"Successfully created ${ControllerEpochZNode.path} with initial epoch ${KafkaController.InitialControllerEpoch}") (KafkaController.InitialControllerEpoch, KafkaController.InitialControllerEpochZkVersion) // 节点已存在,那就获取当前znode带有的epoch信息和epochZkVersion case Code.NODEEXISTS => val (epoch, stat) = getControllerEpoch.getOrElse(throw new IllegalStateException(s"${ControllerEpochZNode.path} existed before but goes away while trying to read it")) (epoch, stat.getVersion) case code => throw KeeperException.create(code) } }
- 开始创建
/controller
和更新/controller_epoch
(增加1),创建成功就返回更新后的zkVersion;否则判断epoch是否已变更,变更就说明ControllerLeader变为其他节点了,此时需要抛出ControllerMovedException
异常,停止本节点的Controller Leader申请流程。
def tryCreateControllerZNodeAndIncrementEpoch(): (Int, Int) = {
// 创建临时节点 /controller,并更新/controller_epoch为新的epoch值
// 注意这里使用了Zkversion,即是该version才设置数据
val response = retryRequestUntilConnected(
MultiRequest(Seq(
CreateOp(ControllerZNode.path, ControllerZNode.encode(controllerId, timestamp), defaultAcls(ControllerZNode.path), CreateMode.EPHEMERAL),
SetDataOp(ControllerEpochZNode.path, ControllerEpochZNode.encode(newControllerEpoch), expectedControllerEpochZkVersion)))
)
response.resultCode match {
// 节点已存在或者节点的ZkVersion和期望不符合
// 就检查最新的,如果epoch和当前节点的epoch相同,就更新zkVersion
// 否则抛出ControllerMovedException,表示ControllerLeader变为其他节点了
case Code.NODEEXISTS | Code.BADVERSION => checkControllerAndEpoch()
// 成功时,也返回新的zkVersion用于更新本地缓存的那一份
case Code.OK =>
val setDataResult = response.zkOpResults(1).rawOpResult.asInstanceOf[SetDataResult]
(newControllerEpoch, setDataResult.getStat.getVersion)
case code => throw KeeperException.create(code)
}
}
6.2.2.4 小结
6.3 Kafka Partition Leader选举
6.3.1 概述
一个Kafka将会管理成千上万的topic分区。Kafka尽量地使所有分区均匀的分布到集群所有的节点(Broker)上而不是集中在某些节点上。此外,主从关系也尽量均衡分布。也就是说,每个Broker节点都会是其上所有的partition中一定比例的partition的leader角色。
优化leader的选择过程也是很重要的,它决定了系统发生故障时的空窗期有多久:
-
一个愚蠢幼稚的想法是,当某个Broker节点发生故障时,leader选举会为失败节点拥有的每个分区进行新的leader选举。
-
但kafka的做法是,选择其中一个Broker节点作为
controller
控制器,当发现有Broker节点挂掉的时候,负责为每个受影响的partition在ISR的分区的所有Broker节点中选择新的leader。这使得Kafka可以批量、高效地管理partition leader变更,特别是在partition数量巨大时好处明显。Leader Controller会为每个partition leader broker设置watcher,当发现某个partition的leader副本故障,就会由Controller负责为该partition重新选举新的leader副本(一般来说就是分区副本里的下一个);随后,Controller将新leader信息广播给所有节点,这样新partition leader就开始处理读写请求、Follower开始从新leader拉取同步数据。
-
如果controller 挂掉了,活着的节点中的一个会被切换为新的controller。
6.3.2 源码
6.3.2.1 概述
代码在kafka.controller.PartitionStateMachine.PartitionLeaderElectionAlgorithms
和kafka.controller.PartitionStateMachine.PartitionLeaderElectionStrategy
6.3.2.2 PartitionStateMachine
首先,Kafka Controller Leader维护PartitionStateMachine
即Partition状态机,状态如下:
-
NonExistentPartition
表示该Partition还没有创建或曾经被创建但已经被删除了。只能由
OfflinePartition
状态转移自此状态。 -
NewPartition
在一个分区创建后就是NewPartition
状态。此时已经分配了副本集,但尚没有isr也没有选举出分区 leader。只能由
NonExistentPartition
状态转移自此状态。 -
OnlinePartition
一旦某个分区的Leader被选举出来,就进入OnlinePartition
状态。只能由
NewPartition
和OfflinePartition
状态转移自此状态。 -
OfflinePartition
在某Partition Leader选举成功后,该Leader挂了,那么该Partition状态就变为OfflinePartition
只能由NewPartition
和OnlinePartition
状态转移自此状态。
6.3.2.3 doElectLeaderForPartitions
PartitionStateMachine#doElectLeaderForPartitions
方法可以为多个TopicPartition选择Leader,其中有一个重要代码:
private def doElectLeaderForPartitions(partitions: Seq[TopicPartition], partitionLeaderElectionStrategy: PartitionLeaderElectionStrategy):
(Seq[TopicPartition], Seq[TopicPartition], Map[TopicPartition, Exception]) = {
val (invalidPartitionsForElection, validPartitionsForElection) = leaderIsrAndControllerEpochPerPartition.partition { case (_, leaderIsrAndControllerEpoch) =>
leaderIsrAndControllerEpoch.controllerEpoch > controllerContext.epoch
}
// partitionLeaderElectionStrategy是当前选举策略,不同策略有不同逻辑
// 最后得到的结果如果没有选出Leader就放到partitionsWithoutLeaders,
// 成功选出Leader的放到partitionsWithLeaders
val (partitionsWithoutLeaders, partitionsWithLeaders) = partitionLeaderElectionStrategy match {
case OfflinePartitionLeaderElectionStrategy =>
leaderForOffline(validPartitionsForElection).partition { case (_, newLeaderAndIsrOpt, _) => newLeaderAndIsrOpt.isEmpty }
case ReassignPartitionLeaderElectionStrategy =>
leaderForReassign(validPartitionsForElection).partition { case (_, newLeaderAndIsrOpt, _) => newLeaderAndIsrOpt.isEmpty }
case PreferredReplicaPartitionLeaderElectionStrategy =>
leaderForPreferredReplica(validPartitionsForElection).partition { case (_, newLeaderAndIsrOpt, _) => newLeaderAndIsrOpt.isEmpty }
case ControlledShutdownPartitionLeaderElectionStrategy =>
leaderForControlledShutdown(validPartitionsForElection, shuttingDownBrokers).partition { case (_, newLeaderAndIsrOpt, _) => newLeaderAndIsrOpt.isEmpty }
}
partitionsWithoutLeaders.foreach { case (partition, _, _) =>
val failMsg = s"Failed to elect leader for partition $partition under strategy $partitionLeaderElectionStrategy"
failedElections.put(partition, new StateChangeFailedException(failMsg))
}
}
6.3.2.4 PartitionLeaderElectionStrategy
6.3.2.4.1 概述
doElectLeaderForPartitions
方法分别又会调用leaderForOffline
、leaderForReassign
、leaderForControlledShutdown
、leaderForPreferredReplica
来处理所有分区,分别调用对应PartitionLeaderElectionStrategy
,这就是个典型策略模式。
注意,我们要明确一点,根据前面6.3.2.2 PartitionStateMachine
我们可知,只有从NewPartition
和OfflinePartition
转移到 OnlinePartition
的时候才需要选举分区Leader!
6.3.2.4.2 OfflinePartitionLeaderElectionStrategy
调用时机有
KafkaController#onNewPartitionCreation
先直接将状态转为NewPartition
,然后再转为OnlinePartition
,此时会使用本策略PartitionStateMachine#triggerOnlinePartitionStateChange
KafkaController#onBrokerStartup
当发现有新的Controller启动,就尝试将NewPartition和OfflinePartition状态分区用该策略进行Leader选举KafkaController#onReplicasBecomeOffline
当因为Broker离线等原因造成副本离线时PartitionStateMachine#startup
当前节点竞争Controller Leader成功后会启动PartitionStateMachine
// online -> offline时选举算法调用该策略
PartitionLeaderElectionAlgorithms.offlinePartitionLeaderElection(assignment, isr, liveReplicas.toSet, uncleanLeaderElectionEnabled, controllerContext)
// assignment为分配的副本
// isr
// liveReplicas存活的副本
// uncleanLeaderElectionEnabled 即unclean.leader.election.enable,默认false,
// 标记是否将不在ISR集中的副本作为Leader作为最后的选择,这样做可能会导致数据丢失。
// controllerContext controller维护的整个集群信息,如所有topic、存活broker节点、partition的leader信息等
def offlinePartitionLeaderElection(assignment: Seq[Int], isr: Seq[Int], liveReplicas: Set[Int], uncleanLeaderElectionEnabled: Boolean, controllerContext: ControllerContext): Option[Int] = {
// 找出第一个在存活副本中又在isr中的BrokerId
assignment.find(id => liveReplicas.contains(id) && isr.contains(id)).orElse {
// 如果没有一个满足的,则判断是否开启将不在ISR集中的副本作为Leader作为最后的选择
if (uncleanLeaderElectionEnabled) {
// 如果开启,则找出所有副本中第一个找到的存活的副本
val leaderOpt = assignment.find(liveReplicas.contains)
if (!leaderOpt.isEmpty)
controllerContext.stats.uncleanLeaderElectionRate.mark()
// 返回待选副本
leaderOpt
} else {
None
}
}
}
也就是说,优先从isr的存活副本取第一个节点做Leader,如果且开启unclean.leader.election.enable
(默认false)就找出所有副本中第一个找到的存活的副本作为Leader。
随后要做的就是:
- 如果找到的目标leader属于isr,则将isr过滤掉离线的节点
- 如果不属于isr,则直接将leader封装为list
- 随后创建新的LeaderAndIsr,并将leaderEpoch + 1
- 最后返回该分区选举结果相关信息和存活副本信息
val newLeaderAndIsrOpt = leaderOpt.map { leader =>
val newIsr = if (isr.contains(leader)) isr.filter(replica => controllerContext.isReplicaOnline(replica, partition))
else List(leader)
leaderIsrAndControllerEpoch.leaderAndIsr.newLeaderAndIsr(leader, newIsr)
}
(partition, newLeaderAndIsrOpt, liveReplicas)
6.3.2.4.3 ReassignPartitionLeaderElectionStrategy
调用时机有
KafkaController#moveReassignedPartitionLeaderIfRequired
副本重分配时。- 用户手动分配
当用户调用分区副本重分配指令时会在Zk创建/admin/reassign_partitions
,然后触发Controller的Watcher,开始重分配流程。 - 系统启动时,检查到有副本被分配到了其他Broker
- ISR变动
- 用户手动分配
leaderForReassign:
private def leaderForReassign(leaderIsrAndControllerEpochs: Seq[(TopicPartition, LeaderIsrAndControllerEpoch)]):
Seq[(TopicPartition, Option[LeaderAndIsr], Seq[Int])] = {
// 遍历
leaderIsrAndControllerEpochs.map { case (partition, leaderIsrAndControllerEpoch) =>
// 该分区的重分配副本
val reassignment = controllerContext.partitionsBeingReassigned(partition).newReplicas
// 过使用滤出存活副本
val liveReplicas = reassignment.filter(replica => controllerContext.isReplicaOnline(replica, partition))
val isr = leaderIsrAndControllerEpoch.leaderAndIsr.isr
// 使用对应策略选举分区leader
val leaderOpt = PartitionLeaderElectionAlgorithms.reassignPartitionLeaderElection(reassignment, isr, liveReplicas.toSet)
val newLeaderAndIsrOpt = leaderOpt.map(leader => leaderIsrAndControllerEpoch.leaderAndIsr.newLeader(leader))
(partition, newLeaderAndIsrOpt, reassignment)
}
}
PartitionLeaderElectionAlgorithms.reassignPartitionLeaderElection:
def reassignPartitionLeaderElection(reassignment: Seq[Int], isr: Seq[Int], liveReplicas: Set[Int]): Option[Int] = {
// 很简单,找重分配副本中的第一个即存活又在isr中的即可
reassignment.find(id => liveReplicas.contains(id) && isr.contains(id))
}
6.3.2.4.4 PreferredReplicaPartitionLeaderElectionStrategy
调用时机有
KafkaController#onPreferredReplicaElection
KafkaController#onControllerFailover
当前节点竞争Controller Leader成功时- 定时任务
checkAndTriggerAutoLeaderRebalance
检测到Leader imbalanceRatio超过阈值 - PreferredReplicaLeaderElection
leaderForPreferredReplica:
private def leaderForPreferredReplica(leaderIsrAndControllerEpochs: Seq[(TopicPartition, LeaderIsrAndControllerEpoch)]):
Seq[(TopicPartition, Option[LeaderAndIsr], Seq[Int])] = {
// 遍历
leaderIsrAndControllerEpochs.map { case (partition, leaderIsrAndControllerEpoch) =>
val assignment = controllerContext.partitionReplicaAssignment(partition)
val liveReplicas = assignment.filter(replica => controllerContext.isReplicaOnline(replica, partition))
val isr = leaderIsrAndControllerEpoch.leaderAndIsr.isr
// 这里是将
val leaderOpt = PartitionLeaderElectionAlgorithms.preferredReplicaPartitionLeaderElection(assignment, isr, liveReplicas.toSet)
val newLeaderAndIsrOpt = leaderOpt.map(leader => leaderIsrAndControllerEpoch.leaderAndIsr.newLeader(leader))
(partition, newLeaderAndIsrOpt, assignment)
}
}
def preferredReplicaPartitionLeaderElection(assignment: Seq[Int], isr: Seq[Int], liveReplicas: Set[Int]): Option[Int] = {
// 将分配副本的第一个拿出,且他如果存活又在isr中就返回,否则返回null
assignment.headOption.filter(id => liveReplicas.contains(id) && isr.contains(id))
}
6.3.2.4.5 ControlledShutdownPartitionLeaderElectionStrategy
调用时机有
KafkaController#doControlledShutdown
KafkaController#onControllerFailover
Controller发现某个Broker挂了,就将该Broker lead的partition全部转移到别的ISR中的节点上。
private def leaderForControlledShutdown(leaderIsrAndControllerEpochs: Seq[(TopicPartition, LeaderIsrAndControllerEpoch)], shuttingDownBrokers: Set[Int]):
Seq[(TopicPartition, Option[LeaderAndIsr], Seq[Int])] = {
leaderIsrAndControllerEpochs.map { case (partition, leaderIsrAndControllerEpoch) =>
// 新分配的副本
val assignment = controllerContext.partitionReplicaAssignment(partition)
// 选出存活的和shutdown机器上的副本
val liveOrShuttingDownReplicas = assignment.filter(replica => controllerContext.isReplicaOnline(replica, partition, includeShuttingDownBrokers = true))
val isr = leaderIsrAndControllerEpoch.leaderAndIsr.isr
// 第一个存活会在shutdown机器上的、在lsr中且不在已挂掉的brokerSet中的节点做为leader
val leaderOpt = PartitionLeaderElectionAlgorithms.controlledShutdownPartitionLeaderElection(assignment, isr, liveOrShuttingDownReplicas.toSet, shuttingDownBrokers)
// 更新isr,去除在挂掉机器上的副本
val newIsr = isr.filter(replica => !controllerContext.shuttingDownBrokerIds.contains(replica))
val newLeaderAndIsrOpt = leaderOpt.map(leader => leaderIsrAndControllerEpoch.leaderAndIsr.newLeaderAndIsr(leader, newIsr))
(partition, newLeaderAndIsrOpt, liveOrShuttingDownReplicas)
}
}
def controlledShutdownPartitionLeaderElection(assignment: Seq[Int], isr: Seq[Int], liveReplicas: Set[Int], shuttingDownBrokers: Set[Int]): Option[Int] = {
// 第一个存活会在shutdown机器上的、在lsr中且不在已挂掉的brokerSet中的节点做为leader
assignment.find(id => liveReplicas.contains(id) && isr.contains(id) && !shuttingDownBrokers.contains(id))
}
7 Log
7.1 Log的组成
-
分partition目录存储
一个topic分为多个partition即分区,每个分区都是不同目录,目录下放着log文件和索引文件。比如一个拥有两个分区,
my_topic
为topicName的log,物理上由my_topic_0
和my_topic_1
两个目录组成。这两个目录里存放的文件包含了关于该topic
的数据,包括消息日志文件(包括log主文件start_offset.log、offset索引文件start_offset.index、时间戳索引文件start_offset.timeindex等类型) -
分段存储
一个分区又由多个存放在该分区目录的连续的Segment(段)组成,每个段文件内部存放了一定offset范围的消息。上图是官网图片有一点小错误,其实右下方的段文件topic/82796232652.kafka
其实消息范围应该是82797232652-83869974631。默认每个Segment包含1GB或者1周数据,以较小为准,往该分区写入数据时如果该LogSegment文件大小超过阈值则就滚动写新文件。
当前正在写的LogSegment就成为
Active Segment
段会进行合并后,合并保留了段合并前每个key的最大offset的value即最新值,同时会删除过期数据。
-
关于
.index
文件
index文件也是该segment的日志起始offset,和.log
文件名字相同都是offset开头。用于通过offset查找某条log时,可通过offset先找到index文件,再通过index来快速确定log在分段文件中的所在范围,可以很快找到指定offset数据而不需遍历整个文件来查找,所以在非顺序读取的场景下可以提高随机访问的效率。上图的index文件中,逗号左边表示该segment的log文件中KafkaRecord的offset,右边表示Record所在文件中的偏移值。
还要注意index是稀疏的,并不是为每条数据创建索引,而是按一定间隔来创建,大大减少索引数据量,可放入内存使用避免磁盘IO。
但有个问题就是必须保证index和log文件都同时正确写入。
更多关于索引内容可参考Kafka系列4----LogSegment分析 -
关于
.timeindex
文件 -
基本单位为LogEntry
这些log 文件由一系列的LogEntry
(4字节存放数字N,用来表示消息长度;随后存放着N字节的消息)组成。 -
Message
每条Message有一个唯一的64位int值表示的offfset,该offset是该条数据在发送给该partition的所有数据中的起始字节偏移值。关于Record的具体内容,请点击这里
-
消息的offset
每条消息都有一个唯一的64bit的整数,即offset。这个offset表示,该topic的这个分区上,所有发送过来的消息流中,该条消息起始位置所在的位置。 -
LogSegment文件名
在磁盘上,每个log文件的名字是包含的第一条消息的偏移量,比如00000000000.kafka
7.2 Log的写入
追加的方式写入文件,当写入达到配置的大小时就会转为写的新文件。还可以配置Kafka按时间或文件大小阈值来强制OS将文件flush到磁盘,这样可以确定在异常发生时最多丢的文件数量。
- 关于写入数据验证
使用crc32验证
7.3 Log的读取
7.3.1 chunk批
读取时需要的参数是消息offset和每次读取的chunk
大小,每次返回一个chunk大小的消息buffer,可迭代。当buffer.size 小于一条消息时,该buffer会每次动态扩容到之前的两倍来减少为了读取该条数据而读取的次数。
7.3.2 读取流程
读取流程如下:
- 定位需要读取的消息数据所在的
log segment
文件 - 通过全局offset来计算所在文件实际偏移量
- 从文件实际偏移量开始读取数据
以上搜索过程是是在内存中完成。
7.4 Log清理
可使用log.cleanup.policy
配置,可选delete
(默认)和compact
。
7.4.1 Log Delete
-
每次删除数据的时候是以
log segment
为单位。 -
删除策略可配。默认的是删除那些最后修改时间超过N天的segment。还有个策略是最近的N GB大小的数据。
-
Kafka采用了
copy-on-write
的思想为待删除的log segment构建了不可变的静态快照视图供搜索使用,避免了删除时对数据读取的阻塞。
7.4.2 Log 结构和Compact
7.4.2.1 Log合并的感性认识
Kafka Log 合并策略可以保证总是至少能保证,对于单个topic parition的log中的数据内的每个消息key,都会保留最后那个已知的value。这个机制着重解决应用重启或系统崩溃时的数据恢复问题。
一个关于可变数据的例子:
一个邮件系统,key为用户id,value为邮件地址。修改时示例如下:
123 => bill@microsoft.com
123 => bill@gatesfoundation.org
123 => bill@gmail.com
Kafka Log 合并策略可以从更细的存储粒度来保存id为123的用户的最新的修改,即bill@gmail.com
。这样的机制使得下游的消费者可以不用存储整个变化log就能恢复最新状态。
这种存储策略可以在topic级别设置,也就是说在同一个集群中有的topic可以是按size
或time
保留,有的是按上面提到的机制保留最新的value。
7.4.2.2 Log 结构和合并原理
下面是一张Kafka log的逻辑视图,数字代表消息的offset,他是稠密、有序的。
注意上图中的Log tail是 Compacted tail
。36,37,38 位置因为已经被合并,所以都是等价的位置,读时都从38开始。
当之前的日志被清理掉时,会将该位置标记为Delete Retention Point
。
Log合并是通过后台周期性的复制Segment来完成的。清理不会阻塞读,并可配IO吞吐量来限制使用避免影响生产和消费。
段合并的过程如下图:
可以看到段合并后保留了段合并前,每个key的最大offset的value。
7.4.2.3 Log合并的保证
- 每个追着log头的消费者都能看见每条消息,也就是说这些数据拥有连续的offset。可以配置数据写入后,被合并的最小时间间隔。
- 合并不会影响消息顺序,合并前后消息的顺序相对顺序不会变
- 一条消息的offset永不改变,且在该log中是唯一标识
- 在达到head前,消费者至少能按写入顺序看到所有消息的最终状态。由于删除操作与读取同时发生,因此如果消费者滞后的时间超过
delete.retention.ms
,则可能会错过删除标记,导致数据遗漏。
7.4.2.4 深入Log合并
Log合并是由Log清理程序触发,他拥有一个后台线程池来重复拷贝Log Segment段文件,这些线程主要工作如下:
- 在Log头部为每个key创建最近的一个offset的value
- 通过以
Segment
为单位来复制log的方式,将复制出来的段做合并处理后替换回Log中。也就是说不需要复制整个Log。
7.4.2.5 删除某个key
此时,需要提送一条该key为键,null为值的数据到kafka。
当Log合并线程发现该数据、合并后会只保留<key,null>,这被称为墓碑消息。会保留一段可配置的时间,以便消费者及时发现该key被移除了做出相应处理。
7.5 Log存储配置
- 目录
可以在$KAFKA_HOME/conf/server.properties
内的log.dirs
项配置,可以配置以逗号,
分隔的多个路径,默认/tmp/kafka-logs
。 - log.retention.hours=168
日志默认保存时间 - log.retention.bytes=1073741824
可参考: - kafka log.retention.bytes
- Kafka日志清理之Log Deletion
8 网络通信
8.1 通信协议
- Kafka协议基于TCP协议
Kafka中,客户端和服务端的通信是通过一个简单高效的、语言无关的TCP协议,所以Kafka支持很多语言。
具体来说,Kafka使用TCP之上的二进制协议。该协议将所有API定义为请求[响应/消息]对。所有消息都以大小分隔,并由以下基元类型组成。
客户端启动Socket连接,然后写入一系列请求消息,并读取相应的响应消息。连接或断开时无需握手。如果你直接保持用于请求的持久连接,分摊TCP握手产生的开销,TCP喜闻乐见。
-
分区有序性的原理
因为数据分散在多个parition,所以客户端可能维护与多个broker的连接通信。但是,通常不需要单个客户端实例维护与单个broker的多个连接(即连接池)。
服务器可以保证在单个TCP连接中,会按发送顺序来处理和响应请求。broker仅允许每个连接存在一个in-flight
请求,来保证排序。 -
非阻塞IO请求
客户端可以使用非阻塞IO来实现请求流水线操作,提高吞吐量。也就是说,即使之前的请求还未获取到,客户端也可以继续发送请求,因为未完成的请求将被缓冲在底层OS Socket buffer中。
注意,服务器对请求大小具有可配置的最大阈值,任何超过的请求都将导致Socket断开连接。 -
为什么Kafka不采用HTTP或其他通信协议
Kafka不使用HTTP有很多原因,最大的原因的是用户使用客户端可以利用一些高级的TCP特性,比如多路复用请求。另一个问题是为什么Kafka不采用XMPP,STOMP,AMQP或现有协议。对此的答案因协议而异,但一般来说问题是协议确实确定了实现的大部分内容,如果我们无法控制协议,我们就无法做我们正在做的事情。
8.2 网络层实现
8.2.1 sendfile零拷贝实现
sendfiles是在MessageSet
接口内定义的writeTo()
方法实现,这样能使得消息集使用高效的transferTo
而不是进程内缓存来写入。
8.3 线程模型
-
Kafka的Producer和Consumer客户端都采用单线程Selector,十分简单,但不适合服务端。
-
Kafka服务端采用多线程 Selector,Acceptor是单线程。对于读取操作的线程池中的线程都会在 Selector 注册
OP_READ
事件,负责服务端读取请求的逻辑。
成功读取后,将请求放入 Message Queue共享队列中。然后在写线程池中,取出这个请求,对其进行逻辑处理。
这样,即使某个请求线程阻塞了,还有后续的线程从消息队列中获取请求并进行处理,在写线程中处理完逻辑处理,由于注册了 OP_WIRTE 事件,所以还需要对其发送响应。
8.3.1 Kafka线程模型详述
Kafka服务端网络层采用JavaNIO-Server实现,采用Reactor模式。Kafka服务端有一个接受请求的Acceptor
线程,以及若干个能处理固定数量连接的Processor
线程以及若干处理业务逻辑的Handler线程。这就是个十分简单的模型,但效率很高。
注意:上图中应该是Processor线程数组而不是线程池。
对于broker来说,客户端连接数量有限,不会频繁新建大量连接。因此一个Acceptor thread线程处理新建连接绰绰有余。Acceptor线程主要执行步骤如下:
- 创建ServerSocketChannel
- 向selector注册OP_ACCEPT事件
- 循环,直到有accept事件发生时交给Processor线程处理
Kafka高吐吞量,则要求broker接收和发送数据必须快速,因此用proccssor thread线程数组处理,并把读取客户端数据转交给缓冲区,不会导致客户端请求大量堆积。
Kafka磁盘操作比较频繁会且有IO阻塞或等待,KafkaRequestHandler IO Thread线程数量一般设置为processor thread num两倍,可以根据运行环境需要进行调节。
8.3.2 Kafka线程源码分析
8.3.2.1 Acceptor
- Acceptor部分代码
private[kafka] class Acceptor(val host: String,
val port: Int,
// Processor线程数组
private val processors: Array[Processor],
val sendBufferSize: Int,
val recvBufferSize: Int,
// 连接配额,管理每个地址的连接数上线
connectionQuotas: ConnectionQuotas) extends AbstractServerThread(connectionQuotas)
// 创建一个非阻塞的ServerSocketChannel
val serverChannel = openServerSocket(host, port)
// Acceptor线程run方法
def run() {
// 注册到selector,只关注socket-accept事件
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while(isRunning) {
val ready = selector.select(500)
if(ready > 0) {
val keys = selector.selectedKeys()
val iter = keys.iterator()
while(iter.hasNext && isRunning) {
var key: SelectionKey = null
try {
key = iter.next
iter.remove()
// Acceptable的就交给轮询到的processor[index]线程去处理该SelectionKey
if(key.isAcceptable)
accept(key, processors(currentProcessor))
else
throw new IllegalStateException("Unrecognized key state for acceptor thread.")
// 以轮询的方式每次调用一个process线程
currentProcessor = (currentProcessor + 1) % processors.length
} catch {
case e: Throwable => error("Error while accepting connection", e)
}
}
}
}
}
def accept(key: SelectionKey, processor: Processor) {
val serverSocketChannel = key.channel().asInstanceOf[ServerSocketChannel]
val socketChannel = serverSocketChannel.accept()
try {
// 记录该地址连接数
connectionQuotas.inc(socketChannel.socket().getInetAddress)
socketChannel.configureBlocking(false)
// 禁用Nagle算法,数据立刻发送不累积到缓冲
socketChannel.socket().setTcpNoDelay(true)
socketChannel.socket().setSendBufferSize(sendBufferSize)
// 交给process线程处理这个socketChannel
processor.accept(socketChannel)
} catch {
case e: TooManyConnectionsException =>
info("Rejected connection from %s, address already has the configured maximum of %d connections.".format(e.ip, e.count))
close(socketChannel)
}
}
8.3.2.2 Processor
- Processor定义
private[kafka] class Processor(val id: Int,
val time: Time,
val maxRequestSize: Int,
val aggregateIdleMeter: Meter,
val idleMeter: Meter,
val totalProcessorThreads: Int,
val requestChannel: RequestChannel,
connectionQuotas: ConnectionQuotas,
val connectionsMaxIdleMs: Long) extends AbstractServerThread(connectionQuotas) {
// 线程安全的链表队列,存放SocketChannel
private val newConnections = new ConcurrentLinkedQueue[SocketChannel]()
private val connectionsMaxIdleNanos = connectionsMaxIdleMs * 1000 * 1000
// 当前纳秒时间
private var currentTimeNanos = SystemTime.nanoseconds
private val lruConnections = new util.LinkedHashMap[SelectionKey, Long]
private var nextIdleCloseCheckTime = currentTimeNanos + connectionsMaxIdleNanos
- Processor主要方法:
def accept(socketChannel: SocketChannel) {
// 将该SocketChannel入队
newConnections.add(socketChannel)
// 唤醒阻塞在select上线程
wakeup()
}
def wakeup() = selector.wakeup()
// 所有新的连接全部以OP_READ注册到selector
private def configureNewConnections() {
while(newConnections.size() > 0) {
val channel = newConnections.poll()
channel.register(selector, SelectionKey.OP_READ)
}
}
// 处理响应的方法
private def processNewResponses() {
// 获取requestChannel里的ProcessorID对应的response队列
var curr = requestChannel.receiveResponse(id)
while(curr != null) {
val key = curr.request.requestKey.asInstanceOf[SelectionKey]
try {
curr.responseAction match {
case RequestChannel.NoOpAction => {
// There is no response to send to the client, we need to read more pipelined requests
// that are sitting in the server's socket buffer
curr.request.updateRequestMetrics
key.interestOps(SelectionKey.OP_READ)
key.attach(null)
}
case RequestChannel.SendAction => {
key.interestOps(SelectionKey.OP_WRITE)
// 通过附加到selectionKey的方式发送该响应
key.attach(curr)
}
case RequestChannel.CloseConnectionAction => {
curr.request.updateRequestMetrics
close(key)
}
case responseCode => throw new KafkaException("No mapping found for response code " + responseCode)
}
} catch {
case e: CancelledKeyException => {
debug("Ignoring response for closed socket.")
close(key)
}
} finally {
curr = requestChannel.receiveResponse(id)
}
}
}
// Processor线程run方法
override def run() {
startupComplete()
while(isRunning) {
configureNewConnections()
// register any new responses for writing
processNewResponses()
val startSelectTime = SystemTime.nanoseconds
val ready = selector.select(300)
currentTimeNanos = SystemTime.nanoseconds
val idleTime = currentTimeNanos - startSelectTime
idleMeter.mark(idleTime)
aggregateIdleMeter.mark(idleTime / totalProcessorThreads)
if(ready > 0) {
val keys = selector.selectedKeys()
val iter = keys.iterator()
while(iter.hasNext && isRunning) {
var key: SelectionKey = null
try {
key = iter.next
iter.remove()
if(key.isReadable)
// 处理读
read(key)
else if(key.isWritable)
// 处理写
write(key)
else if(!key.isValid)
close(key)
else
throw new IllegalStateException("Unrecognized key state for processor thread.")
} catch {
}
}
}
maybeCloseOldestConnection
}
closeAll()
swallowError(selector.close())
shutdownComplete()
}
// Process处理读
def read(key: SelectionKey) {
lruConnections.put(key, currentTimeNanos)
// 得到key关联的socketChannel
val socketChannel = channelFor(key)
var receive = key.attachment.asInstanceOf[Receive]
if(key.attachment == null) {
receive = new BoundedByteBufferReceive(maxRequestSize)
key.attach(receive)
}
// 从channel中读取数据
val read = receive.readFrom(socketChannel)
val address = socketChannel.socket.getRemoteSocketAddress();
if(read < 0) {
close(key)
} else if(receive.complete) {
// 读取完了数据就构造一个请求
val req = RequestChannel.Request(processor = id, requestKey = key, buffer = receive.buffer, startTimeMs = time.milliseconds, remoteAddress = address)
// 向requestChannel发送该请求
requestChannel.sendRequest(req)
key.attach(null)
// 不再关注OP_READ,因为已经数据读取完了
key.interestOps(key.interestOps & (~SelectionKey.OP_READ))
} else {
// 还有数据需要读取
key.interestOps(SelectionKey.OP_READ)
wakeup()
}
}
// Process处理写
def write(key: SelectionKey) {
val socketChannel = channelFor(key)
val response = key.attachment().asInstanceOf[RequestChannel.Response]
val responseSend = response.responseSend
if(responseSend == null)
throw new IllegalStateException("Registered for write interest but no response attached to key.")
// 将需要返回的数据写到socketChannel
val written = responseSend.writeTo(socketChannel)
if(responseSend.complete) {
// 写入完成
response.request.updateRequestMetrics()
key.attach(null)
key.interestOps(SelectionKey.OP_READ)
} else {
// 写入未完成
key.interestOps(SelectionKey.OP_WRITE)
wakeup()
}
}
8.3.2.3 RequestChannel
- RequestChannel定义
class RequestChannel(val numProcessors: Int, val queueSize: Int) extends KafkaMetricsGroup {
private var responseListeners: List[(Int) => Unit] = Nil
// 请求队列
private val requestQueue = new ArrayBlockingQueue[RequestChannel.Request](queueSize)
// 响应队列
private val responseQueues = new Array[BlockingQueue[RequestChannel.Response]](numProcessors)
for(i <- 0 until numProcessors)
responseQueues(i) = new LinkedBlockingQueue[RequestChannel.Response]()
- 主要方法
// 发送请求,就是放入requestQueue队列
def sendRequest(request: RequestChannel.Request) {
requestQueue.put(request)
}
// 从请求队列中拉取请求对象
def receiveRequest(timeout: Long): RequestChannel.Request =
requestQueue.poll(timeout, TimeUnit.MILLISECONDS)
// 将要发送的相应放入processor线程对应的responseQueues
def sendResponse(response: RequestChannel.Response) {
responseQueues(response.processor).put(response)
for(onResponse <- responseListeners)
onResponse(response.processor)
}
// 返回processor对应的response
def receiveResponse(processor: Int): RequestChannel.Response = {
val response = responseQueues(processor).poll()
if (response != null)
response.request.responseDequeueTimeMs = SystemTime.milliseconds
response
}
8.3.2.4 KafkaRequestHandler
KafkaRequestHandler
实际处理request请求,根据具体请求类型调用kafka.server.KafkaApis
中不同方法。Kafka中有一个KafkaRequestHandler线程池。
def run() {
while(true) {
try {
var req : RequestChannel.Request = null
while (req == null) {
val startSelectTime = SystemTime.nanoseconds
// 调用RequestChannel中定义的receiveRequest方法,从requestQueue中拉取请求对象
req = requestChannel.receiveRequest(300)
val idleTime = SystemTime.nanoseconds - startSelectTime
aggregateIdleMeter.mark(idleTime / totalHandlerThreads)
}
if(req eq RequestChannel.AllDone) {
// 该请求已经处理完毕就直接返回了
return
}
req.requestDequeueTimeMs = SystemTime.milliseconds
// 否则调用KafkaApi处理
apis.handle(req)
} catch {
case e: Throwable => error("Exception when handling request", e)
}
}
}
8.3.2.5 KafkaApis
// 这里就是KafkaRequestHandler 处理请求时调用的方法
def handle(request: RequestChannel.Request) {
try{
trace("Handling request: " + request.requestObj + " from client: " + request.remoteAddress)
request.requestId match {
case RequestKeys.ProduceKey => handleProducerOrOffsetCommitRequest(request)
case RequestKeys.FetchKey => handleFetchRequest(request)
case RequestKeys.OffsetsKey => handleOffsetRequest(request)
case RequestKeys.MetadataKey => handleTopicMetadataRequest(request)
case RequestKeys.LeaderAndIsrKey => handleLeaderAndIsrRequest(request)
case RequestKeys.StopReplicaKey => handleStopReplicaRequest(request)
case RequestKeys.UpdateMetadataKey => handleUpdateMetadataRequest(request)
case RequestKeys.ControlledShutdownKey => handleControlledShutdownRequest(request)
case RequestKeys.OffsetCommitKey => handleOffsetCommitRequest(request)
case RequestKeys.OffsetFetchKey => handleOffsetFetchRequest(request)
case RequestKeys.ConsumerMetadataKey => handleConsumerMetadataRequest(request)
case requestId => throw new KafkaException("Unknown api code " + requestId)
}
}
}
// 我们找一个handleTopicMetadataRequest方法看看请求、处理、响应的处理流程
// 这个是获取Topic的元数据的请求
def handleTopicMetadataRequest(request: RequestChannel.Request) {
val metadataRequest = request.requestObj.asInstanceOf[TopicMetadataRequest]
val topicMetadata = getTopicMetadata(metadataRequest.topics.toSet)
val brokers = metadataCache.getAliveBrokers
// 构建一个响应
val response = new TopicMetadataResponse(brokers, topicMetadata, metadataRequest.correlationId)
// 调用requestChannel 的 sendResponse方法 发送响应
requestChannel.sendResponse(new RequestChannel.Response(request, new BoundedByteBufferSend(response)))
}
8.4 资源抽象
下面说下消息相关抽象概念,具体格式请参考官网文档
8.4.1 LogEntry
log文件由一系列的LogEntry
(4字节存放数字N,用来表示消息长度;随后存放着N字节的消息)组成。
8.4.2 Message
Message
就是我们在3.1.6章节提到过的Record
。
- 每条数据的二进制格式是版本化和标准化的,所以RecordBatch可在Producer、Broker、Client之间进行无修改、零拷贝传输。
- 逻辑上每条记录由[key, value, timestamp]组成。
- 物理上,
Message
由变长的header
,变长的、不透明的分别表示key
和value
的字节数组组成。 header的格式将在下一节中介绍。 保留密钥和值不透明是正确的决定:目前在序列化库上取得了很大进展,任何特定的选择都不太适合所有用途 - Message总是组成一批即
Record Batch
进行写入,后文会提到。
8.4.3 MessageSet
0.11版本之前的Message
格式如下:
message length : 4 bytes (value: 1+4+n) //表示该条消息长度
"magic" value : 1 byte //魔数
crc : 4 bytes // 用来验证数据
payload : n bytes //数据主体
MessageSet
是Kafka 0.11版本之前的消息集合
MessageSet
是由Message
和对应的offset
值抽象出来的新对象MessageAndOffset
的集合。
消息格式是一个标准化接口,所以MessageSet可以在生产者、消费者、Broker之间传递直接使用无需再次转换。
8.4.4 Record
一个带有Header的Record如下:
length: varint
attributes: int8
bit 0~7: unused
timestampDelta: varint
offsetDelta: varint
keyLength: varint
key: byte[]
valueLen: varint
value: byte[]
Headers => [Header]
Record Header如下:
headerKeyLength: varint
headerKey: String
headerValueLength: varint
Value: byte[]
8.4.5 RecordBatch
-
注意,
RecordBatch
是Kafka 0.11版本开始有的。 -
RecordBatch
是Kafka-Client
包内的。他表示一批Record
,正在或准备被发送。 -
RecordBatch是一个接口,是
Message
的迭代器,具有用于批量读取和写入NIO Channel
的专用方法。 -
每个RecordBach和Record都有自己的Header,RecordBach的格式如下:
baseOffset: int64 batchLength: int32 partitionLeaderEpoch: int32 magic: int8 (current magic value is 2) crc: int32 attributes: int16 bit 0~2: 0: no compression 1: gzip 2: snappy 3: lz4 4: zstd bit 3: timestampType bit 4: isTransactional (0 means not transactional) bit 5: isControlBatch (0 means not a control batch) bit 6~15: unused lastOffsetDelta: int32 firstTimestamp: int64 maxTimestamp: int64 producerId: int64 producerEpoch: int16 baseSequence: int32 records: [Record]
-
请注意,启用压缩后,压缩了的Record数据将直接在Record数之后进行序列化。
9 关于Zookeeper
9.1 各服务注册列表
- Broker
Broker启动时会在ZK下的/brokers/ids
以逻辑ID号为名创建带有自身信息的临时znode,示例如下:
/brokers/ids/[0...N] --> {"jmx_port":...,"timestamp":...,"endpoints":[...],"host":...,"version":...,"port":...} (ephemeral node)
-
Topic
Broker会向ZK注册自己持有的Topic和Partition相关信息,示例如下:/brokers/topics/[topic]/partitions/[0...N]/state --> {"controller_epoch":...,"leader":...,"version":...,"leader_epoch":...,"isr":[...]} (ephemeral node)
-
/brokers/topics/[topic]/partitions
记录了topic所有分区分配信息以及AR集合 -
/brokers/topics/[topic]/partitions/[partition_id]/state
记录了某partition的leader副本所在brokerId,leader_epoch, ISR集合,zk 版本信息
-
-
Consumer
Consumer也会将自己的ConsumerGroup等信息存入ZK临时节点。
/consumers/[group_id]/ids/[consumer_id] --> {"version":...,"subscription":{...:...},"pattern":.
-
Consumer Offset
Kafka高级Consumer API自动提交offset,低级API手动管理,一般来说是提交到Zookeeper。/consumers/[group_id]/offsets/[topic]/[partition_id] --> offset_counter_value (persistent node)
在未来版本中,Kafka会放弃依赖ZK。Kafka提供了一个
Offset Manager
,来存储高级API提交的offset。主要做法是将提交过来的Offset 请求到名为__consumer_offsets
的特殊topic,当所有副本都成功响应后才会返回。Offset Manager为了提高速度还会定期压缩该topic以及将offset都缓存到内存表中。这些操作都对高级API透明。
__consumers_offsets 配置了compact策略,使得它总是能够保存最新的位移信息,既控制了该topic总体的日志容量,也能体现最新offset。ConsumerGroup Offset具体存的分区,使用
GroupMetadataManager#partitionFor
方法计算def partitionFor(groupId: String): Int = Utils.abs(groupId.hashCode) % groupMetadataTopicPartitionCount
groupMetadataTopicPartitionCount
是__consumer_offsets
的分区数,默认是50个 -
Partition Owner
在消费开始前,每个Consumer将自己占有消费的partition放入ZK:
/consumers/[group_id]/owners/[topic]/[partition_id] --> consumer_node_id (ephemeral node)
- Controller
- /controller_epoch
持久节点,不会在创建该节点的客户端离线时被删除。记录了当前Controller Leader的年代信息 - /controller
临时节点,会在创建该节点的客户端离线时被删除。记录了当前Controller Leader的id,也用于Controller Leader的选择
- /controller_epoch
- Admin
- /admin/reassign_partitions
记录了需要进行副本重新分配的分区 - /admin/preferred_replica_election
记录了需要进行"优先副本"选举的分区,优先副本在创建分区的时候第一个副本 - /admin/delete_topics
记录删除的topic
- /admin/reassign_partitions
- ISR
- /isr_change_notification
记录一段时间内ISR列表变化的分区信息
- /isr_change_notification
- config
- /config
记录的一些配置信息
9.2 Consumer ZK注册流程
消费者启动后的注册流程如下:
- 将consumer_id注册到consumer_group以下
- 对consumer_ids注册一个
Watcher
,监听consumer加入和离开。有变化时可触发对应consumer group内的均衡过程 - 对broker_ids注册一个
Watcher
,监听broker的加入和离开。有变化时会触发所有consumer group组内重新均衡过程 - 如果consumer消费多个Topic,还会注册一个Topic Watcher。变化时会触发对应consumer group组内重新均衡过程
- 触发一次所在consumer group的均衡过程
9.3 ConsumerRebalance-均衡算法
9.3.1 概述
9.3.1.1 概述
在Kafka中,当有新消费者加入或者订阅的Topic数发生变化时,会触发Rebalance即再均衡,是指在同一个消费者组当中,分区的所有权从一个消费者转移到另外一个消费者的机制。
9.3.1.2 负责人
-
GroupCoordinator
负责 consumer group 成员的Rebalance和offset 管理。处理的请求类型主要有以下几种:- ApiKeys.OFFSET_COMMIT;
- ApiKeys.OFFSET_FETCH;
- ApiKeys.JOIN_GROUP;
- ApiKeys.LEAVE_GROUP;
- ApiKeys.SYNC_GROUP;
- ApiKeys.DESCRIBE_GROUPS;
- ApiKeys.LIST_GROUPS;
- ApiKeys.HEARTBEAT;
注意,Kafka Server 端要处理的请求总共有21 种,其中有 8 种是由 GroupCoordinator 来完成的。
-
Consumer Rebalance由分区Leader和Consumer上的Coordinator Leader协调进行。
-
每个KafkaConsumer上会有一个ConsumerCoordinator,用来协调消费组。
-
所有ConsumerCoordinator中只有一个ConsumerCoordinator Leader,她会从所有组成员ConsumerCoordinator中收集元数据(比如感兴趣的Topic等),然后发布最新的状态给他们。
-
每个ConsumerCoordinator成员收到ConsumerCoordinator Leader发来的状态委派请求后就开始处理。
9.3.1.3 触发时机
当消费者订阅的Topic的分区发生变化的时候会发生Rebalance,具体触发条件如下:
- 消费者订阅的topic中的分区数量发生变化
- Topic被创建或者被删除
- 消费组有成员加入或离开
- 消费者所在ConsumerGroup中有Consumer成员挂掉
- 有新的Consumer加入了ConsumerGroup
9.3.1.4 执行原理
9.3.1.4.1 0.9之前基于ZK。
-
流程
- 每个consumer在创建的时会在在Zookeeper上的路径为
/consumers/[consumer group]/ids/[consumer id]
下将自己的id注册到该消费组下; - 然后本ConsumerCoordinator在
/consumers/[consumer group]/ids 和/brokers/ids
下注册Watcher; - 最后强制自己在消费组启动Rebalance。
- 每个consumer在创建的时会在在Zookeeper上的路径为
-
问题:
这种做法很容易带来ZK的羊群效应,因为任何Broker或者Consumer的增减都会触发所有的Consumer的Rebalance,造成集群内大量的调整;同时由于每个consumer单独通过zookeeper判断Broker和consumer宕机,由于zk并非强一致性,同一时刻不同consumer通过zk看到的表现可能是不一样,这就可能会造成很多不正确的rebalance尝试;
除此之外,由于consumer彼此独立,每个consumer都不知道其他consumer是否rebalance成功,可能会导致consumer group消费不正确,出现意料之外后果。
9.3.1.4.2 0.9之后
-
GroupCoordinator和WorkerCoordinator
为了解决基于ZK存在的问题,0.9之后将Consumer失败探测和Rebalance的逻辑放到了一个高可用的中心GroupCoordinator,降低ZK负载,他就是真正的协调者。GroupCoordinator又是由WorkerCoordinator管理分配的。每个KafkaServer都在启动时启动一个GroupCoordinator实例,每个实例管理若干集群中的ConsumerGroup及其Offset,他就会主要负责:
- 监控新加入消费者
- 检测消费者存活性,不存活就清理
-
ConsumerCoordinator
还有一个Coordinator角色是每个Consumer拥有的ConsumerCoordinator,负责和GroupCoordinator通信。
-
所有消费成员都向Coordinator发送请求,请求加入Consumer Group。
-
一旦所有消费成员都发送了请求,Coordinator会从中选择一个Consumer担任Leader的角色,并把组成员信息以及订阅信息发给Leader。
-
Leader开始分配消费方案,指明具体哪个Consumer负责消费哪些Topic的哪些Partition,最后将这个方案发给Coordinator。Coordinator接收到分配方案之后会把方案发给各个Consumer,这样组内的所有成员就都知道自己应该消费哪些分区了。
所以对于Rebalance来说,Coordinator起着至关重要的作用
具体Rebalance过程,就是当HeartbeatResponseHandler.handle
遇到一些GroupCoordinator返回的异常(如REBALANCE_IN_PROGRESS
、ILLEGAL_GENERATION
、UNKNOWN_MEMBER_ID
等)时将将将rejoinNeeded
设为true,从而重新触发JoinGroup和SyncGroup过程,即所谓Rebalance。
9.3.1.4.3 Rebalance场景剖析
- 新成员加入组(member join)
- 组成员崩溃(member failure)
- 组成员主动离组(member leave group)
- 提交位移(member commit offset)
9.3.2 均衡算法
9.3.2.1 概述
消费者均衡算法主要是重新分配consumer group中的所有消费者锁占有消耗的partition达成共识。
对于给定topic和给定的consumer group,broker partition在组内的consumer之间平均分配,每个partition只由一个consumer占有消费,避免了多消费者同时竞争(意味着会使用锁)分区资源的情况。 如果一个组内的consumer多于partition,则某些consumer不会消费。
Kafka2.3中 consumer partitioner策略在PartitionAssignor
中定义,可通过partition.assignment.strategy
配置,默认class org.apache.kafka.clients.consumer.RangeAssignor
。
9.3.2.2 RangeAssignor
在重新均衡期间,我们尝试以这样一种方式为消费者分配分区,以减少每个消费者必须连接的broker节点的数量,以下便是均衡期间每个consumer所做的:
- 对于每个订阅了Topic
T
的消费者者Ci
;Pt
视为T
的所有分区集合;Cg
视为消费者Ci
所在的消费者组 - 对
Pt
进行排序,使得同一个broker上的partition按集群聚合到一起 - 对
Cg
排序 - 设
i
为Ci
在Cg
中的所在位置 - 将
N
设为size(Pt)
/size(Cg)
- 将
i*N
到(i+1)*N-1
分配给Ci
- 将
Ci
原来拥有的分区注册的znode数据移除 - 注册新拥有的分区
比如有12个分区,3个消费者,开始均衡,我们看看会发生什么:
C0.index = 0, C1.index=1, C2.index=2
N = 12/3 = 4
那么
C0分配到[0, 1, 2, 3]
C1分配到[4, 5, 6, 7]
C2分配到[8, 9, 10, 11]
一共12个分区,每个消费者被分配了4个分区,达成了均衡目的。
9.3.2.3 RoundRobinAssignor
轮询消费者进行分配。
比如有12个分区,3个消费者,开始均衡,我们看看会发生什么:
C0.index = 0, C1.index=1, C2.index=2
那么
C0分配到[0, 3, 6, 9]
C1分配到[1, 4, 7, 10]
C2分配到[2, 5, 8, 11]
一共12个分区,每个消费者被分配了4个分区,达成了均衡目的。
9.3.2.4 StickyAssignor
本节转自 Kafka分区分配策略(2)——RoundRobinAssignor和StickyAssignor
“sticky”这个单词可以翻译为“粘性的”,Kafka从0.11.x版本开始引入这种分配策略,它主要有两个目的:
- 分区的分配要尽可能的均匀;
- 分区的分配尽可能的与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标,StickyAssignor策略的具体实现要比RangeAssignor和RoundRobinAssignor这两种分配策略要复杂很多。我们举例来看一下StickyAssignor策略的实际效果。
假设消费组内有3个消费者:C0、C1和C2,它们都订阅了4个主题:t0、t1、t2、t3,并且每个主题有2个分区,也就是说整个消费组订阅了t0p0、t0p1、t1p0、t1p1、t2p0、t2p1、t3p0、t3p1这8个分区。最终的分配结果如下:
- 消费者C0:t0p0、t1p1、t3p0
- 消费者C1:t0p1、t2p0、t3p1
- 消费者C2:t1p0、t2p1
这样初看上去似乎与采用RoundRobinAssignor策略所分配的结果相同,但事实是否真的如此呢?再假设此时消费者C1脱离了消费组,那么消费组就会执行Rebalance操作,进而消费分区会重新分配。
-
如果采用RoundRobinAssignor策略,那么此时的分配结果如下:
- 消费者C0:t0p0、t1p0、t2p0、t3p0
- 消费者C2:t0p1、t1p1、t2p1、t3p1
如分配结果所示,RoundRobinAssignor策略会按照消费者C0和C2进行重新轮询分配。
-
而如果此时使用的是StickyAssignor策略,那么分配结果为:
- 消费者C0:t0p0、t1p1、t3p0、t2p0
- 消费者C2:t1p0、t2p1、t0p1、t3p1
可以看到分配结果中保留了上一次分配中对于消费者C0和C2的所有分配结果,并将原来消费者C1的“负担”分配给了剩余的两个消费者C0和C2,最终C0和C2的分配还保持了均衡。
如果发生分区重分配,那么对于同一个分区而言有可能之前的消费者和新指派的消费者不是同一个,对于之前消费者进行到一半的处理还要在新指派的消费者中再次复现一遍,这显然很浪费系统资源。StickyAssignor策略如同其名称中的“sticky”一样,让分配策略具备一定的“粘性”,尽可能地让前后两次分配相同,进而减少系统资源的损耗以及其它异常情况的发生。
到目前为止所分析的都是消费者的订阅信息都是相同的情况,我们来看一下订阅信息不同的情况下的处理。
举例,同样消费组内有3个消费者:C0、C1和C2,集群中有3个主题:t0、t1和t2,集群中有t0p0、t1p0、t1p1、t2p0、t2p1、t2p2这6个分区。消费者C0订阅了主题t0,消费者C1订阅了主题t0和t1,消费者C2订阅了主题t0、t1和t2。
如果此时采用RoundRobinAssignor策略,那么最终的分配结果如下所示(和讲述RoundRobinAssignor策略时的一样,这样不妨赘述一下):
【分配结果集1】
消费者C0:t0p0
消费者C1:t1p0
消费者C2:t1p1、t2p0、t2p1、t2p2
如果此时采用的是StickyAssignor策略,那么最终的分配结果为:
【分配结果集2】
消费者C0:t0p0
消费者C1:t1p0、t1p1
消费者C2:t2p0、t2p1、t2p2
可以看到这是一个最优解(消费者C0没有订阅主题t1和t2,所以不能分配主题t1和t2中的任何分区给它,对于消费者C1也可同理推断)。
假如此时消费者C0脱离了消费组,那么RoundRobinAssignor策略的分配结果为:
-
消费者C1:t0p0、t1p1
-
消费者C2:t1p0、t2p0、t2p1、t2p2
可以看到RoundRobinAssignor策略保留了消费者C1和C2中原有的3个分区的分配:t2p0、t2p1和t2p2(针对结果集1)。
而如果采用的是StickyAssignor策略,那么分配结果为:
-
消费者C1:t1p0、t1p1、t0p0
-
消费者C2:t2p0、t2p1、t2p2
可以看到StickyAssignor策略保留了消费者C1和C2中原有的5个分区的分配:t1p0、t1p1、t2p0、t2p1、t2p2。
从结果上看StickyAssignor策略比另外两者分配策略而言显得更加的优异,这个策略的代码实现也是异常复杂,如果读者没有接触过这种分配策略,不妨使用一下来尝尝鲜。
9.3.3 Partition分配过程
下图中:
- 左侧外层椭圆为Kafka集群
- 左侧内层圆圈为某个Broker,其内部含有一个GroupCoordinator
- 右侧 c0…c2 为某个ConsumerGroup的Consumer
首先要知道,每个Consumer都有一个ConsumerCoordinator
用来处理ConsumerGroup相关同步消息。
-
Kafka会为每个ConsumerGroup选择一个Coordinator负责。初始时,
ConsumerNetworkClient
发送一个GroupCoordinatorRequest
(GCR
,新版本改为了FindCoordinatorRequest
)给任意一个Broker来寻找目标GroupCoordinatorServer收到后,通过ConsumerGroup 的group.id 进行 hash 并计算得到其具对应的 partition 值,该 partition的分区leader 所在 Broker 即为该 Group 所对应的 GroupCoordinator,GroupCoordinator 会存储与该 ConsumerGroup 相关的所有的元数据信息。这也是Kafka负载均衡思想一部分。
-
JoinGroup
找到负责的GroupCoordinator后发送JoinGroupRequest
(JGR
)请求给该GroupCoordinator(源码在AbstractCoordinator#sendJoinGroupRequest
),加入Group。
- 所有请求响应被包装为
JoinGroupResponse
,通过他可判断当前Consumer被GroupCoordinator分配为Leader与否 - 响应回调处理在
JoinGroupResponseHandler
这里各个Consumer被划分为一个Leader和若干Follower
- 所有请求响应被包装为
-
SyncGroup
JoinGroupRequest返回之后,Leader根据JoinGroupRequest得到的当前Group的消费者及订阅的TopicPartition信息,通过PartitionAssignor#assign
分配分区(所以Client可以自己指定PartitionAssignor),然后发送SyncGroupRequest
给GroupCoordinator;Follower 直接发送空的SyncGroupRequest
给GroupCoordinator,以得到自己所分配到的partition
- Leader-源码在
AbstractCoordinator#onJoinLeader
方法
将执行消费组的分配。监控Group关注的所有Topic以备在发现元数据变动, 还会利用得到的元数据信息来调用PartitionAssignor#assign
即具体的分区-Consumer算法来分配,并将分配状态信息推送到组中的所有成员(例如,在新Consumer加入时下推送分区分配),但需要注意这里不是直接推送给其他Follower Consumer,而是最后将分配结果包装为SyncGroupRequest
发回给GroupCoordinator,GroupCoordinator回复是null,因为Leader已经得知分配情况。 - Follower-源码在
AbstractCoordinator#onJoinFollower
方法
发送具有空分配SyncGroupRequest
到GroupCoordinator,GroupCoordinator回复是partition分配的结果。
- Leader-源码在
9.3.4 Rebalance Generation
表示Rebalance之后的新一届Consumer,主要是用于保护consumer group,以隔离无效的offset提交。
具体来说,上一届的consumer是无法提交offset到新一届的consumer group中的,因为generationId小于当前generationId。
每次group进行rebalance之后,generation号都会加1,表示group进入到了一个新的版本,如下图:
- Generation 1中group有3个成员
- 随后成员2退出组,GroupCoordinator监听到后触发Rebalance,Consumer Group进入Generation 2
- 成员4加入,再次触发Rebalance,Consumer Group进入Generation 3
9.3.5 Consumer Group状态机
代码在kafka.coordinator.group.GroupState
-
PreparingRebalance
ConsumerGroup准备开启新的rebalance,等待成员加入 -
CompletingRebalance
ConsumerGroup正在等待Consumer Leader 将分配方案发送给各个成员 -
Stable
各个Consumer已经接收到由GroupCoordinator发来的Consumer Leader构建的分配方案,此时表示Rebalance完成,可以开始正常消费了 -
Dead
组内已经没有任何成员的最终状态,组的元数据也已经被GroupCoordinator移除了。这种状态响应各种请求都是一个response:
UNKNOWN_MEMBER_ID
-
Empty
组内没有任何成员,但是要等到所有offset信息都因Kafka过期机制而消除。此状态还可表示那些仅利用Kafka而没有成员的ConsumerGroup.
具体各状态下的Action可以参考源码。
9.3.4 Rebalance的影响
-
可能重复消费
旧Consumer被踢出消费组时可能还没来得及提交offset,Rebalance时会将Partition Offset重新分配其它Consumer,此时可能造成重复消费发送给下游。虽可采用幂等操作,但必须下游支持幂等。 -
集群不稳定
Rebalance扩散到整个ConsumerGroup的所有消费者,因为一个消费者的退出,导致整个ConsumerGroup进行了Rebalance,并在一个比较慢的时间内达到稳定状态,会造成一定影响 -
影响消费速度
频繁的Rebalance反而降低了消息的消费速度,大部分时间都在重复消费和Rebalance,整体处理效率大大降低
9.3.5 避免Rebalance
9.3.5.1 提前规划分区数量
对数据量充分预估,提前规划分区数量
9.3.5.2 合理设置Consumer参数
-
Consumer未能及时发送心跳而Rebalance
- session.timeout.ms
一次session的连接超时时间 - heartbeat.interval.ms
发送心跳给ConsumerCoordinator的间隔时间,一般为超时时间的1/3,即Consumer在被判定为死亡之前,能够发送至少 3 轮的心跳请求
- session.timeout.ms
-
Consumer消费超时而Rebalance
-
max.poll.interval.ms
每隔多长时间去拉取消息。合理设置预期值,尽量但间隔时间消费者处理完业务逻辑,否则就会被coordinator判定为死亡,踢出Consumer Group,进行Rebalance -
max.poll.records
每次拉取的数据条数。根据消费业务处理耗费时长合理设置,如果每次max.poll.interval.ms
设置的时间较短,可以max.poll.records
设置小点儿,少拉取些,这样不会超时。总之,尽可能在
max.poll.interval.ms
时间间隔内处理完max.poll.records条消息,让Coordinator认为消费Consumer还活着
-
10 调优
可参考
10.1 不均衡
- 推送的分区大小不均衡,造成消费也不均衡
可以通过参数Partitioner.class自定义分区函数
10.2 Broker
10.3 磁盘IO成为瓶颈时调优
转自 Linux Page Cache调优在Kafka中的应用,作者Yang Yijun,出处 vivo互联网技术
11 FAQ
11.1 为什么Kafka这么快
还可参考kafka 高吞吐量性能揭秘
- 批量IO
Kafka设计了一个消息集抽象将消息组合,批量传输数据。这种批处理的好处是将数据转为大型网络数据包、大型顺序磁盘操作、连续的内存块。 - 零拷贝技术
Kafka使用了linux的sendfile系统调用来优化将数据从PageCache传输到网络的过程。数据从磁盘只会拷贝到Kernel一次,然后就发送到网络中的NIC,不会拷贝到用户空间。 - 底层存储
- 采用PageCache为中心的设计
不用再为GC发愁;现在操作系统对PageCache大量优化 - 磁盘追加方式顺序写,顺序读,某些场景下甚至高于内存
- 采用PageCache为中心的设计
- 因为文件名跳表设计,可以通过文件名快速找到要读取的文件(空间换时间),且不同访问方式有不同处理方法
- 顺序访问时可直接访问
.log
文件速度很快, - 随机访问可通过索引文件来确定offset对应记录的大致范围再快速查找指定记录在
.log
文件中位置。
- 顺序访问时可直接访问
- 压缩
- 高效的端对端压缩
- 架构
- 生产消费自动均衡
- 分partition,分担读写压力
- 每个broker都是topic的某些partition的leader,也为其他partition的follower。
leader负责处理所有读写请求,follower从leader复制数据,这样就实现了读写处理的均衡。
- 合并时CopyOnWrite思想不阻塞读
Kafka采用了copy-on-write的思想为待删除的log segment构建了不可变的静态快照视图供搜索使用,避免了删除时对数据读取的阻塞。 - NIO线程模式
客户端服务端都采用NIO通信。
还可以阅读这篇文章Kafka如何实现每秒上百万的超高并发写入?
11.2 Kafka怎么保证消息不丢失
-
生产者
写入时数据先到Leader副本,然后其他Follower副本对该数据从Leader进行pull,成功响应后发回ack。ISR内的Follower都响应后,Leader才认为该条消息成功并返回客户端。当然这是可配的。具体来说,还是用了水位HW机制。
-
消费者
需要使用手动提交offset,先处理数据后提交offset(at least once) -
Topic
default.replication.factor
至少设为2,即副本数至少为2min.insync.replicas
至少设为2,即当Producer ack设为all
时,指定至少多少个副本响应才能视为写入成功,否则Producer会抛出异常。
比如可以配置如下
- Producer
- ack = al
- Broker
- default.replication.factor = 3
- min.insync.replicas = 2
此时就可以在写入时大多数副本成功响应时成功,大多数失败时抛异常
11.3 Kafka怎么保证HA
- 多副本(Leader负责读写,Follower pull方式从Leader获取数据)
当某个topic有f+1个
副本时,就可以允许在f
个副本写入失败的情况下不会丢失消息。这一点就是Kafka可用性(Avaliable)的体现。 - ISR
- KafkaController
选举副本Leader,ISR变化时通知其他Broker更新MetaCache,增加某个topic分区的时候也会由Controller重新分配
11.4 Kafka怎么保证数据不重复
-
Producer
在0.11.0之前,producer未收到response只能重发,也就是at least once
。0.11.0开始,kafka实现了消息发送幂等,具体做法是给每个producer分配一个ID,发送每条消息时带上,Broker保存并使用该ID进行消息去重。 -
Consumer
消费者的语义可见这里还可以引入外部缓存系统,比如Redis,每次消费前先去查询是否消费过,不过这种增加了系统延时和不稳定性。
11.5 Kafka怎么保证顺序性
- 分区内有序原理
服务器可以保证在单个TCP连接中,会按发送顺序来处理和响应请求。broker仅允许每个连接存在一个in-flight请求,来保证排序。 - 单分区做法
默认是partition内有序,也就是说采用一个partition一个Consumer消费肯定可以保证有序。 - 多分区做法
如果想增加吞吐量,可以多个partition,但引入自定义Partitioner,比如按用户ID,相同的肯定到一个Partition。
11.6 Kafka如何实现DelayQueue延迟队列
https://www.infoq.cn/article/2YMOLi5o2ooj1vW3R3q7
12 KSQL
KSQL是一个用于Apache kafka的流式SQL引擎,KSQL降低了进入流处理的门槛,提供了一个简单的、完全交互式的SQL接口,用于处理Kafka的数据,可以让我们在流数据上持续执行 SQL 查询,KSQL支持广泛的强大的流处理操作,可以直接流式ETL,支持多种操作包括聚合、连接、窗口、会话等等。
KSQL 是分布式、可扩展、可靠的和实时的,支持多种流式操作,包括aggregate
、join
、window、session
等等。
0xFF 参考文档
部分资料整理自互联网,如有侵权请联系我标明出处或删除。