本文是一篇Kafka学习的综述。
Kafka是一个分布式流平台,主要特性:
Kafka通常用于两大类应用:
可参考KafkaProducer
应用程序通过Producer API来发布流式数据。
可以使用$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
可参考
KafkaConsumer
Kafka系列1----Rebalance过程
Kafka新版消费者API示例
Kafka Consumer多线程实例
Kafka源码分析 KafkaConsumer
应用程序通过Consumer API来订阅和处理流式数据。
在Kafka0.8.2及以前版本是旧API,分为高阶低阶。Kafka0.9开始统一了高阶低阶consumer API,maven依赖也减负不少:
<dependency>
<groupId>org.apache.kafkagroupId>
<artifactId>kafka-clientsartifactId>
dependency>
关于position(offset)有两个概念:
position
Consumer每次调用poll
方法,都会增加该值,表示下一个需要被获取的值的offset。
committed position
表示最后一个安全存储的offset。当前线程挂掉重启或首次启动后,使用committed position
来进行恢复读取。该值可自动周期性提交或通过commitSync-异步提交到kafka / commitAsync手动提交。
TopicPartition
一个topic名称及partition号
ConsumerRecords
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
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存在且合法时就使用它,否则调节:
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时,返回的最大数据条数。
他和以前老的api差不多,也是有group概念,group之间相互隔离。可以订阅多个topic,partitions在同个group内的consumer之间均衡消费。当有新topic被regex匹配到、新partition加入、consumer增减时触发rebalance.
可以使用ConsumerRebalanceListener来获取rebalance事件,进行应用级别的处理,比如状态清理、手动提交offset等操作。
使用assign(Collection)手动分配特定partition(类似于旧版的SimpleConsumer)。 在这种情况下,将禁用动态分区分配和Group协调机制。
public ConsumerRecords<K,V> poll(long timeout)
当订阅了topics和partitions后,第一次调用poll(long)
方法时,当前consumer会自动加入消费者group。如果没调用订阅api就poll,会报错。
此后需要循环调用该方法,每次调用时consumer会尝试使用最后提交的offset(也可以使用seek(TopicPartition, long)手动指定)来作为新的消费offset,并顺序拉取数据。
session.timeout.ms
),则该consumer被认为挂掉,分配给他消费的partition会重分配给同group的其他成员。max.poll.interval.ms
来限制poll的最大时间间隔,一旦超过,Consumer会主动退出group,partition被其他consumer成员接管。max.poll.records
可限制每次poll拉取的数据条数。同步方式提交最后一次通过poll获取到的 topic和partition的offset到kafka。
异步方式提交最后一次通过poll获取到的 topic和partition的offset到kafka。
多次调用该方法时,Kafka可保证按顺序发送offset,如果有callback也会按发送顺序依次调用callback。
还需要注意的是,如果调用该方法后又调用了commitSync方法,该方法提交的offset可保证在commitSync
方法返回前完成。
enable.auto.commit
为true,只要poll调用了就自动提交offset。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());
}
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();
}
}
使用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。
大多数场景,consumer从头到尾消费,周期性提交position。但也可以手动管理position到指定位置:
有三个重要方法:
实例可参考文章:
指定多个partition时,默认同优先级消费。
可使用pause暂停消费指定分区,resume(Collection)恢复
Kafka0.11.x后支持原子性写入多个topic和partition,consumer读取这些数据时,应该把Consumer客户端的事务隔离界别设为只读取已提交的数据:isolation.level=read_committed
。
在该隔离级别,可正常读非事务数据,但只能读到已提交的事务数据,且最大能读到的offset终点是一个open状态的事务的首条数据offset(LSO
)。
还需要注意,此时Consumer没有客户端buffer。
因为事务消息会在partition中存放标记位,所以读取事务消息时可能看到offset断层。
更多事务内容可以点这里
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();
}
}
可以使用$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
Consumer Rebalance由分区Leader和Consumer上的Coordinator Leader协调进行。每个KafkaConsumer上会有一个ConsumerCoordinator,用来协调消费组。其中会有一个Leader,她会从所有组成员中收集元数据(比如感兴趣的Topic等),然后发布最新的状态给他们。每个ConsumerCoordinator成员收到ConsumerCoordinator Leader发来的状态委派请求后就开始处理。
当消费者订阅的Topic的分区发生变化的时候会发生Rebalance,具体触发条件如下:
Rebalance算法请参考这里
应用程序通过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., 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 更多信息请点击这里
概述
应用程序通过Connector API来创建和运行可重用的生产者或消费者,他们可以连接kafka topic和应用程序或数据系统,相对于Consumer和Producer,KafkaConnect省掉了更多的开发工作,尤其是编码部分,这使得应用开发人员更容易上手使用。 这里先提一些重要概念:
KafkaSourceConnector
将外部数据导入Kafka。
例如,可以构建一个connector,来捕获RDBMS的所有改动写入kafka。
Kafka SinkConnector
可以把数据从Kafka导出到外部存储系统。
但task失败不会触发rebalance,需要通过REST API重启该失败的task。
- Worker
实际运行connector和task的进程.
Kafka Connect功能特性如下:
两种运行模式
plugin
在配置文件中通过plugin.path
来指定connector, converter, transformation插件所在的一个大Jar包或是包含jar包和第三方依赖的目录,可以有多个,以逗号分隔。
当前成熟的Connector如下:
ConnectorName | Owner | Status |
---|---|---|
HDFS | [email protected] | Confluent supported |
JDBC | [email protected] | Confluent supported |
MongoDB Source | [email protected] | |
[email protected] | In progress | |
MQTT Source | [email protected] | Community project |
MySQL Binlog Source | [email protected] | In progress |
Elastic Search Sink | [email protected] | In progress |
Elastic Search Sink | [email protected] | Community project |
Elastic Search Sink | [email protected] | |
[email protected] | In progress |
关于ConnectorAPI更多信息可以点击:
单机模式下,所有的工作运行在同一进程中。
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"
处修改。
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过来的数据。
status.storage.topic
,config.storage.topic
,offset.storage.topic
应该按推荐的属性手动创建,否则会用默认配置自动创建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,
默认的REST Server端口是8083,使用HTTP协议。也可以如下方式配置:
listeners=http://localhost:8080,https://localhost:8443
常用REST API如下:
name
字段和Object类型的包含该Connector配置信息的config
字段。获取Connector所有插件信息的 REST API:
GET /Connector-plugins
返回已在 Kafka Connect 集群安装的 Connector plugin 列表。
请注意,API 仅验证处理此次请求的那个worker所拥有的的Connector ,这意味着你可能看到不一致的结果,特别是在滚动升级的时候(添加新的 Connector jar包时)
PUT /Connector-plugins/{Connector-type}/config/validate
对指定的配置定义进行验证,执行对每个配置验证,返回建议值和错误信息
外部数据源读写的格式不需要与 Kafka 消息的序列化格式一样,因为KafkaConnect会负责和外部数据员交互,并使用Converter和Kafka交互。
常用Converter:
io.confluent.connect.avro.AvroConverter
建议使用,适用于Java,注意需要与Schema Registry
配合使用(如"value.converter.schema.registry.url": "http://schema-registry:8081"
)。Avro 是 Confluent 平台的一等公民,拥有来自 Confluent Schema 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
可以通过配置transformations 进行轻量级的消息即时修改, 它们可以方便数据传送和事件路由。具体来说,就是输入一条数据,经Transformation后变为一条处理转换后的数据。
Transformation可形成链条,上一个的输出作为下一个的输入,并可与Source Connector或Sink Connector配合使用。
可按Kafka Connect定义的规则实现自定义Transformation,也可以用Kafka自带的Transformation:
还可参考:
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
请参考
使用不匹配的Converter读写时报错速查表如下:
更多关于问题处理的内容请参考 深入理解Kafka Connect:转换器和序列化的常见错误章节
就是一个Kafka节点,接受数据读写请求,持久化数据,数据副本复制,leader选举等。Kafka集群由多个Broker节点组成。
数据的主题,类似于数据库中的表,数据跨多个节点。可通过Topic实现生产者消费(订阅)者。
一个Topic数据往往分为多个Partition,散布在多个节点上。
每个Partition都是一个有序、不可变的,拥有一系列记录(record),持续追加到结构化的若干文件。
Java Producer的默认分区策略:
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
一个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
分区副本分配目标:
当没有机架信息时,我们遵循以下原则来分配
无机架信息时例子如下:
对于有机架信息时,先创建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。
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]] = {
详细源码解析可参考
具体在5.2章节中讲解,这里可以理解为可以当老大的节点集合,是和主副本处于同步的所有副本,由Leader动态维护。
下面是一个KafkaManager中的Topic关于Partition分布的真实例子:
每条记录由[key, value, timestamp]组成。Record拥有Partition内唯一且有序的ID(Offset)
详见这里
在磁盘上存储的是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]
}
一个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
Offset是Partition内唯一且有序的ID,可以表示Record在partition内的偏移量
生产者,需要抉择哪一个record应该写到该Topic的哪一个partition内,可通过遍历或key 对 partition取模的方式等实现均衡。
消费者从指定topic中消费数据。
消费者组,一条数据只能同一个Consumer Group内的一个Consumer消费到。
可参考:
每个Broker启动时都会创建KafkaController对象,但是集群中只能有一个KafkaController leader对外提供服务,原理如下:
/controller
)创建临时节点,第一个成功创建ZK节点的KafkaController成为leader。Kafka controller部分主要做下面这些事情:
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
KafkaController对比ReplicaManager
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状态的监听,被处罚后调用对应的业务逻辑
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 |
此外,如果持久性比可用性重要(也就是说不允许丢数据),那么可以做以下两个操作:
Avaliable
和Consistency
。设置越大一致性越强,但可能因为某些ISR节点挂掉甚至少于最小ISR集合大小导致partition无法写入。另外一个很重要的持久性辅助保证设置就是retires
:
max.in.flight.requests.per.connection
设为1,可能导致乱序,比如发送两批数据到一个partition,第一批失败被重试,第二批直接成功,则第二条会排在第一条前拥有更小的offset。max.in.flight.requests.per.connection
具体可以查看这里在0.11.0之前,producer未收到response只能retry即重发,也就是at least once
。0.11.0开始,kafka实现了消息发送幂等,具体做法是这样:
消费者的语义可见这里
Kafka的每个broker节点是某些partition的leader,又是另一些的follower。master负责处理所有读写请求,follower从leader复制数据。这样就实现了读写处理的均衡。
生产者来抉择哪一个record应该写到该Topic的哪一个partition内,可通过遍历或key 对 partition取模的方式等实现均衡。
Consumer Group内有多个consumer实例时,数据会均衡地分发给这些consumer处理。
具体来说,在Kafka中实现消费的方式是通过在消费者实例上划分日志中的partition,以便每个Consumer在任何时间点都是分配的“公平份额”的独占。Kafka协议动态处理、维护Consumer Group中成员资格。
如果新consumer实例加入该Group,将从该组的其他consumer接管一些partition;如果consumer挂掉退出group,其partition将分发给其余组内成员。
Kafka仅保证partition内有序,而非topic有序。实现原理大致是每个生产者客户端与每个broker只有一个连接,而且一个连接只允许存在一个in_flight_request。具体可以查看这里中关于此问题的讨论。
如果必须要topic有序就只能使用一个partition,也就意味着只能有一个consumer消费,并发能力低下。
寻找leader
客户端决定了发送消息的partition。生产者在发送数据前,可以询问kafka集群中任一节点目标topic partition的leader在哪个节点上,然后直接将数据发送给该partition leader所在broker。
批处理
而且,Kafka生产者也可在内存中尝试累积数据并在单个请求中以批的形式发送数据。这样的好处是以少量延迟换取高吞吐。这一且都是可配的。
以下生产流程为ack=-1/all时
high watermark
,最后 commit 的 offset) 并向 producer 发送 ACKKafka消费者采用拉的方式从partition的leader节点上消费数据。
切记,Consumer不会从partition的follower副本拉取数据!
在每次消费请求中,会附带上offset,消费时就会从该位置开始接受一块儿数据。所以消费者可以根据业务需要手动指定offset,以实现重复消费或跳过一段消费。
创建副本的单位是topic的分区,每个分区都有一个leader和0或多个followers。所有的读写操作都由leader处理,一般分区的数量都比broker的数量多的多,各分区的leader均匀的分布在brokers中。所有的followers都像普通的consumer那样从leader消费并保存在自己的日志文件中,达到数据同步的目的。数据中的消息、顺序都和leader中相同。
Kafka的副本功能不是必须的,你可以配置只有一个副本,这样其实就相当于只有一份数据。
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创建逻辑。
简单来说我们发起的命令行主要做两件事情:
Kafka controller部分主要做下面这些事情:
更多详细内容请点击这里
写入Kafka的数据将写入磁盘并进行复制以实现容错。
关于水位好文Kafka水位
本节大量内容转自此文。
水位或水印(watermark)一词,也可称为高水位(high watermark,简称HW),通常被用在流式处理领域(如Flink,Spark),以表征元素或事件在基于时间层面上的进度。一个比较经典的表述为:流式系统保证在水位t
时刻,创建时间(event time) = t'
且t'
≤ t
的所有事件都已经到达或被观测到。
在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(有两套)的更新是有区别的。
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尝试修改分区HW时,会选择那些处于ISR中或是不处于ISR但副本LEO落后Leader LEO不超过replica.lag.time.max.ms
的副本(拥有进入ISR资格但还未进入ISR),将他们的LEO和自己的LEO进行比较,最终选择最小LEO作为分区HW。
Kafka有分区副本概念用于实现冗余,副本根据角色的不同可分为3类:
Kafka的leader动态维护了一个动态同步状态的副本的集合(a set of in-sync replicas),简称ISR,每个Partition都会有一个ISR,在这个集合中的节点都是和leader保持高度一致的。任何一条消息必须被这个集合中的每个节点读取并追加到日志中了,才会通知外部这个消息已经被提交了。因此只有ISR集合中的节点才可能被选为leader。
如果一个follower比leader落后太多,或者超过一定时间未发起数据复制请求,则leader将其从ISR集合中移除 。
ISR通过ZooKeeper持久化。
当某个topic有f+1个
副本时,就可以允许在f
个副本写入失败的情况下不会丢失消息。这一点就是Kafka可用性(Avaliable)的体现。
ISR的成员是动态维护的。如果一个节点因为某种原因被淘汰了,当它重启后必须同步数据,重新达到“同步中”的状态后可以重新加入ISR。这种leader的选择方式是非常快速的,适合kafka的应用场景。
如果所有节点都down掉了怎么办?Kafka对于数据不会丢失的保证,是基于至少一个节点是存活的,一旦所有节点都down了,这个就不能保证了。
实际应用中,当所有的副本都down掉时,必须及时作出反应。可以有以下两种选择:
这是一个在可用性和连续性之间的权衡:
如果等待ISR中的节点恢复,一旦ISR中的节点起不起来或者数据都是了,那集群就永远恢复不了了;
如果等待ISR以外的节点恢复,这个节点的数据就会被作为线上数据,有可能和真实的数据有所出入,因为有些数据它可能还没同步到。
Kafka 0.11.0
之前选择了第二种策略,之后的版本选择第一种策略永远等待。可以根据场景灵活的。具体配置是unclean.leader.election.enable
,设为true表示允许不是ISR中的节点被选为leader。
总的来说,ISR的职责就是两个:
可以通过quotas
(配额)来控制客户端使用的broker资源
这里讲的不是前面提到的分区有序,而是Kafka可以保证同一个生产者对一个指定topic partition发送的记录是有序的,也就是说同一个生产者先发送的记录总是能比后发送的记录有更小的offset,更早的对consumer可见。
这是由前面提到过的机制保证:服务器可以保证在单个TCP连接中,会按发送顺序来处理和响应请求。broker仅允许每个连接存在一个in-flight请求,来保证排序
。
max.in.flight.requests.per.connection
此项为Producer配置,默认为5。表示客户端在一个独立连接中需要阻塞时的最大发送请求数。
注意,如果此项被设置为大于1,可能会在开启了retries
时导致发送数据和Kafka存储顺序不一致!具体请参考这里
ISR章节已经说过,当某个topic有f+1个
副本时,就可以允许在f
个副本写入失败的情况下也不会丢失消息。
Kafka采用Producer push,Consumer pull的方式。
push的缺点是消费者无法控制消费速率,可能造成消费速度跟不上导致出错,但是实时性强。
pull的好处是消费者自己根据情况控制消费速率,但实时性较push模式差
Kafka采用了pull模式,除了上述优点还有一个原因是消费者可以对数据进行批处理。而且Kafka允许pull 无数据时 consumer阻塞而不是持续轮询。
可参考Message Delivery Semantics
在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
。
消费者角度语义:
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与实际存储:
Kafka Connector
,它填充HDFS中的数据以及它读取的数据的offset,以确保数据和偏移都被更新或两者都不更新。如果外部系统支持主键的话就直接覆盖,更简便。KafkaConnector将数据和offset一起原子性写入HDFS。Kafka Stream利用此以及事务性生产者/消费者在Kafka topic之间传输和处理数据时实现了exactly once。
将数据映射为唯一key,每次消费前先查询该key是否已经存在。效率低
利用幂等性
一些系统支持id幂等,如hbase、ES、传统数据库主键约束等
利用版本号实现幂等
对于不支持幂等系统,人为给数据增加版本号属性,每次更数据前,比较当前数据的版本号是否和消息中的版本号一致,如果不一致就拒绝更新数据,更新数据的同时将版本号 +1,一样可以实现幂等。
数据生产的时候就加入一列version ,消费的时候需要将这条数据的version和本地version对比:
详细实现例子可参考:
可参考Notes on Exactly Once Semantics
kafka 0.11.0开始支持producer的幂等和事务:
这两个特性共同提供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消费者将无法读取数据高水位线。
Kafka官方文档介绍说,合理使用、设计磁盘存储结构通常和网络一样快。顺序读取磁盘因为其可预测性,所以操作系统针对此进行了大量优化。而且现代操作系统使用了预读(read-ahead,在实际请求前预先读文件的几个相邻的数据块)和后写(write-behind,将一些较小的逻辑写入操作合并起来组成比较大的物理写入操作)。在ACM Queue article这篇文章中甚至论证结果为:在某些场景下磁盘顺序访问速度可以比内存随机访问还快!
另一方面,现代操作系统往往将大量内存用于磁盘缓存,这种策略在回收内存时几乎没有性能损失。此时,非直接IO操作都会经过此统一缓存,十分高效。
Kafka设计是以PageCache为中心设计的存储。Kafka的做法并不是将数据保留在内存中,并在数据快填满缓存时刷盘,而是直接将数据持久化到文件系统(其实是内核中的PageCache)。这么做的原因如下:
此外Kafka没有采用传统消息系统使用的BTree,因为看起来时间复杂度是O(log N) 但因为磁盘寻道时间10ms而且每个磁盘不可能并行寻道,再加上缓存和磁盘混用,最终会导致时间复杂度超线性。
Kafka针对此问题的方法是在简单读取上构建队列并追加到文件,使得时间复杂度为O(1)且不阻塞读写。
除了磁盘问题,影响Kafka性能表现的还有两个关键问题:
一般小IO发生在C/S之间或Server自己的持久化操作中。
为了避免这个问题,Kafka设计了一个消息集
抽象,他自然地将消息组合。这样就能让数据传输时将多条数据组合,而不是一条一条单独传输。这种批处理的好处是将数据转为大型网络数据包、大型顺序磁盘操作、连续的内存块。
在高负载工况下过多的字节复制对系统影响大。
Kafka采用了标准化的二进制消息个事,Producer Consumer Broker共享,数据块可以在传输过程中不修改。
Kafka使用了linux的sendfile
系统调用来优化将数据从pagecache
传输到socket
的过程。
一般来说这个过程是这样的:
可以看到上述过程很繁琐,有多达4份数据副本(1内核PageCache -> 2用户Buffer -> 3内核Socket Buffer -> 4网络NIC Buffer)和多次系统调用。
而Kafka-零拷贝SendFile(2.4+版本)过程如下:
可以看到SendFile省去了多余的数据副本和系统调用,可以让OS直接将数据从PageCache发送到网络,只有两份数据副本。
我们可以举个例子:
多个消费者订阅一个Topic。使用零拷贝技术,数据只会被拷贝到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);
}
该方法会在客户端向服务端拉取数据时调用,读取到相关文件消息后零拷贝发送到网络通道传回客户端。
网络带宽也会成为瓶颈,特别是需要通过广域网在数据中心之间发送消息的数据管道而言。如果用户直接用每个消息单独压缩-解压缩方式,那么其实压缩比并不高,因为kafka中冗余的往往是如字段名称这类的公共数据。
Kafka支持Producer高效的批量消息压缩然后发往服务器,并以压缩形式吸入日志,最后由消费者解压缩。
Kafka有GZIP,Snappy,LZ4等压缩协议
更多Kafka压缩请点击Kafka-Compression
每当有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。
Kafka中的leader选举主要包括Controller Leader选举和 Partition Leader选举。
如果controller 挂掉了,活着的节点中的一个会被切换为新的controller。
/controller
)创建临时节点,第一个成功创建ZK节点的KafkaController成为leader。/controller
节点已存在而创建失败作为follower,并为该znode设置watcher以在leader controller出问题时及时得到通知。/controller
下创建节点从而选举新的leader。/controller_epoch
)的条件递增获得一个新的更大的controller epoch。这样,可使得其他节点在收到较旧的epoch信息时及时忽略,还能避免脑裂!先调用kafka.controller.KafkaController#elect
,然后在内部调用kafka.zk.KafkaZkClient#registerControllerAndIncrementControllerEpoch
。
elect
方法有两个调用时机:
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.KafkaController#processReelect
(kafka 2.7,老版本也是在StateChangeHandler里面)部分核心源码如下:
// 先尝试获取当前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()
}
}
/controller
节点/controller_epoch
,得到当前controller epochauto.leader.rebalance.enable
,所以还需要开启一个间隔为leader.imbalance.check.interval.seconds
(默认300秒)的定时任务,用来做所有partition的Leader再平衡工作为/controller
设置watcher,感知Leader Controller掉线。
当之前是Leader,现在不是Leader时,需要清空本节点作为Leader时的一系列内容:
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()
}
}
/controller_epoch
获取当前controller epoch,如果不存在就创建一个带有初始值(从0开始)的持久化Znode/controller_epoch
。val (curEpoch, curEpochZkVersion) = getControllerEpoch
.map(e => (e._1, e._2.getVersion))
.getOrElse(maybeCreateControllerEpochZNode())
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)
}
}
一个Kafka将会管理成千上万的topic分区。Kafka尽量地使所有分区均匀的分布到集群所有的节点(Broker)上而不是集中在某些节点上。此外,主从关系也尽量均衡分布。也就是说,每个Broker节点都会是其上所有的partition中一定比例的partition的leader角色。
优化leader的选择过程也是很重要的,它决定了系统发生故障时的空窗期有多久:
controller
控制器,当发现有Broker节点挂掉的时候,负责为每个受影响的partition在ISR的分区的所有Broker节点中选择新的leader。这使得Kafka可以批量、高效地管理partition leader变更,特别是在partition数量巨大时好处明显。代码在kafka.controller.PartitionStateMachine.PartitionLeaderElectionAlgorithms
和kafka.controller.PartitionStateMachine.PartitionLeaderElectionStrategy
首先,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
状态转移自此状态。
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))
}
}
doElectLeaderForPartitions
方法分别又会调用leaderForOffline
、leaderForReassign
、leaderForControlledShutdown
、leaderForPreferredReplica
来处理所有分区,分别调用对应PartitionLeaderElectionStrategy
,这就是个典型策略模式。
注意,我们要明确一点,根据前面6.3.2.2 PartitionStateMachine
我们可知,只有从NewPartition
和OfflinePartition
转移到 OnlinePartition
的时候才需要选举分区Leader!
调用时机有
KafkaController#onNewPartitionCreation
NewPartition
,然后再转为OnlinePartition
,此时会使用本策略PartitionStateMachine#triggerOnlinePartitionStateChange
KafkaController#onBrokerStartup
KafkaController#onReplicasBecomeOffline
PartitionStateMachine#startup
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。
随后要做的就是:
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)
调用时机有
KafkaController#moveReassignedPartitionLeaderIfRequired
/admin/reassign_partitions
,然后触发Controller的Watcher,开始重分配流程。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))
}
调用时机有
KafkaController#onPreferredReplicaElection
KafkaController#onControllerFailover
checkAndTriggerAutoLeaderRebalance
检测到Leader imbalanceRatio超过阈值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))
}
调用时机有
KafkaController#doControlledShutdown
KafkaController#onControllerFailover
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))
}
分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
关于.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
追加的方式写入文件,当写入达到配置的大小时就会转为写的新文件。还可以配置Kafka按时间或文件大小阈值来强制OS将文件flush到磁盘,这样可以确定在异常发生时最多丢的文件数量。
读取时需要的参数是消息offset和每次读取的chunk
大小,每次返回一个chunk大小的消息buffer,可迭代。当buffer.size 小于一条消息时,该buffer会每次动态扩容到之前的两倍来减少为了读取该条数据而读取的次数。
读取流程如下:
log segment
文件以上搜索过程是是在内存中完成。
可使用log.cleanup.policy
配置,可选delete
(默认)和compact
。
每次删除数据的时候是以log segment
为单位。
删除策略可配。默认的是删除那些最后修改时间超过N天的segment。还有个策略是最近的N GB大小的数据。
Kafka采用了copy-on-write
的思想为待删除的log segment构建了不可变的静态快照视图供搜索使用,避免了删除时对数据读取的阻塞。
Kafka Log 合并策略可以保证总是至少能保证,对于单个topic parition的log中的数据内的每个消息key,都会保留最后那个已知的value。这个机制着重解决应用重启或系统崩溃时的数据恢复问题。
一个关于可变数据的例子:
一个邮件系统,key为用户id,value为邮件地址。修改时示例如下:
123 => [email protected]
123 => [email protected]
123 => [email protected]
Kafka Log 合并策略可以从更细的存储粒度来保存id为123的用户的最新的修改,即[email protected]
。这样的机制使得下游的消费者可以不用存储整个变化log就能恢复最新状态。
这种存储策略可以在topic级别设置,也就是说在同一个集群中有的topic可以是按size
或time
保留,有的是按上面提到的机制保留最新的value。
下面是一张Kafka log的逻辑视图,数字代表消息的offset,他是稠密、有序的。
注意上图中的Log tail是 Compacted tail
。36,37,38 位置因为已经被合并,所以都是等价的位置,读时都从38开始。
当之前的日志被清理掉时,会将该位置标记为Delete Retention Point
。
Log合并是通过后台周期性的复制Segment来完成的。清理不会阻塞读,并可配IO吞吐量来限制使用避免影响生产和消费。
段合并的过程如下图:
可以看到段合并后保留了段合并前,每个key的最大offset的value。
delete.retention.ms
,则可能会错过删除标记,导致数据遗漏。Log合并是由Log清理程序触发,他拥有一个后台线程池来重复拷贝Log Segment段文件,这些线程主要工作如下:
Segment
为单位来复制log的方式,将复制出来的段做合并处理后替换回Log中。也就是说不需要复制整个Log。此时,需要提送一条该key为键,null为值的数据到kafka。
当Log合并线程发现该数据、合并后会只保留
$KAFKA_HOME/conf/server.properties
内的log.dirs
项配置,可以配置以逗号,
分隔的多个路径,默认/tmp/kafka-logs
。客户端启动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或现有协议。对此的答案因协议而异,但一般来说问题是协议确实确定了实现的大部分内容,如果我们无法控制协议,我们就无法做我们正在做的事情。
sendfiles是在MessageSet
接口内定义的writeTo()
方法实现,这样能使得消息集使用高效的transferTo
而不是进程内缓存来写入。
Kafka服务端采用多线程 Selector,Acceptor是单线程。对于读取操作的线程池中的线程都会在 Selector 注册 OP_READ
事件,负责服务端读取请求的逻辑。
成功读取后,将请求放入 Message Queue共享队列中。然后在写线程池中,取出这个请求,对其进行逻辑处理。
这样,即使某个请求线程阻塞了,还有后续的线程从消息队列中获取请求并进行处理,在写线程中处理完逻辑处理,由于注册了 OP_WIRTE 事件,所以还需要对其发送响应。
Kafka服务端网络层采用JavaNIO-Server实现,采用Reactor模式。Kafka服务端有一个接受请求的Acceptor
线程,以及若干个能处理固定数量连接的Processor
线程以及若干处理业务逻辑的Handler线程。这就是个十分简单的模型,但效率很高。
注意:上图中应该是Processor线程数组而不是线程池。
对于broker来说,客户端连接数量有限,不会频繁新建大量连接。因此一个Acceptor thread线程处理新建连接绰绰有余。Acceptor线程主要执行步骤如下:
Kafka高吐吞量,则要求broker接收和发送数据必须快速,因此用proccssor thread线程数组处理,并把读取客户端数据转交给缓冲区,不会导致客户端请求大量堆积。
Kafka磁盘操作比较频繁会且有IO阻塞或等待,KafkaRequestHandler IO Thread线程数量一般设置为processor thread num两倍,可以根据运行环境需要进行调节。
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)
}
}
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
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()
}
}
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
}
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)
}
}
}
// 这里就是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)))
}
下面说下消息相关抽象概念,具体格式请参考官网文档
log文件由一系列的LogEntry
(4字节存放数字N,用来表示消息长度;随后存放着N字节的消息)组成。
Message
就是我们在3.1.6章节提到过的Record
。
Message
由变长的header
,变长的、不透明的分别表示key
和value
的字节数组组成。 header的格式将在下一节中介绍。 保留密钥和值不透明是正确的决定:目前在序列化库上取得了很大进展,任何特定的选择都不太适合所有用途Record Batch
进行写入,后文会提到。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之间传递直接使用无需再次转换。
一个带有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[]
注意,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数之后进行序列化。
/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":.
/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/[group_id]/owners/[topic]/[partition_id] --> consumer_node_id (ephemeral node)
消费者启动后的注册流程如下:
Watcher
,监听consumer加入和离开。有变化时可触发对应consumer group内的均衡过程Watcher
,监听broker的加入和离开。有变化时会触发所有consumer group组内重新均衡过程Rebalance触发时机可见这里
消费者均衡算法主要是重新分配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
。
在重新均衡期间,我们尝试以这样一种方式为消费者分配分区,以减少每个消费者必须连接的broker节点的数量,以下便是均衡期间每个consumer所做的:
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个分区,达成了均衡目的。
轮询消费者进行分配。
比如有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个分区,达成了均衡目的。
本节转自 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个分区。最终的分配结果如下:
这样初看上去似乎与采用RoundRobinAssignor策略所分配的结果相同,但事实是否真的如此呢?再假设此时消费者C1脱离了消费组,那么消费组就会执行Rebalance操作,进而消费分区会重新分配。
如果采用RoundRobinAssignor策略,那么此时的分配结果如下:
如分配结果所示,RoundRobinAssignor策略会按照消费者C0和C2进行重新轮询分配。
而如果此时使用的是StickyAssignor策略,那么分配结果为:
可以看到分配结果中保留了上一次分配中对于消费者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策略比另外两者分配策略而言显得更加的优异,这个策略的代码实现也是异常复杂,如果读者没有接触过这种分配策略,不妨使用一下来尝尝鲜。
.log
文件速度很快,.log
文件中位置。还可以阅读这篇文章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会抛出异常。比如可以配置如下
此时就可以在写入时大多数副本成功响应时成功,大多数失败时抛异常
f+1个
副本时,就可以允许在f
个副本写入失败的情况下不会丢失消息。这一点就是Kafka可用性(Avaliable)的体现。Producer
在0.11.0之前,producer未收到response只能重发,也就是at least once
。0.11.0开始,kafka实现了消息发送幂等,具体做法是给每个producer分配一个ID,发送每条消息时带上
,使用该ID进行消息去重
Consumer
消费者的语义可见这里
还可以引入外部缓存系统,比如Redis,每次消费前先去查询是否消费过,不过这种增加了系统延时和不稳定性。
KSQL是一个用于Apache kafka的流式SQL引擎,KSQL降低了进入流处理的门槛,提供了一个简单的、完全交互式的SQL接口,用于处理Kafka的数据,可以让我们在流数据上持续执行 SQL 查询,KSQL支持广泛的强大的流处理操作,可以直接流式ETL,支持多种操作包括聚合、连接、窗口、会话等等。
KSQL 是分布式、可扩展、可靠的和实时的,支持多种流式操作,包括aggregate
、join
、window、session
等等。
KSQL好文推荐
部分资料整理自互联网,如有侵权请联系我标明出处或删除。
Apache-Kafka
Kafka如何创建topic?
跟我学Kafka之NIO通信机制
Apache Kafka源码分析-客户端请求响应模型
Kafka的架构原理,你真的理解吗?
Kafka 设计详解之网络通信
Kafka水位(high watermark)与leader epoch的讨论
Kafka HA Kafka一致性重要机制之ISR(kafka replica)
6个步骤,全方位掌握 Kafka
KafkaController介绍
Kafka Connect简介
Apache Kafka系列(五) Kafka Connect及FileConnector示例
一文看懂大数据集结神器Kafka Connect
kafka connect,将数据批量写到hdfs完整过程
Kafka Connect的安装和配置
天天在用消息队列,却不知道为啥要用MQ,这就尴尬了
Kafka – 生产者消息分区机制