Kafka 机制和 Kafka-Stream
Kafka 优势
- 异步和解耦:生产者和消费者间,没有直接关系,也不需要等待对方回应
- 压力自己控制:由于消费者使用 pull 消费数据,不会出现消费能力不足而丢数据的问题
- 高可靠:多节点,分布式,一致性,数据多个备份
- 安全:支持 TLS/SSL 认证
- 高吞吐量
- 可重复消费
是一个非常好用的高吞吐量的分布式发布订阅消息系统
高吞吐量原因
- 每个 topic 由多个 partition 组成,可以并行处理
- 顺序读写,Kafka 的消息是不断添加到文件中的,利用磁盘的顺序读写性能,比随机读写快很多
- 批量发送
- 数据压缩,减少网络传输数据量
- 零拷贝
可以看到为了高吞吐量 Kafka 采用了很多办法
零拷贝
重点讲下零拷贝,可参考 https://baijiahao.baidu.com/s?id=1769186849807925293&wfr=spider&for=pc
传统模式:
- 用户做 read 系统调用
- 通过 DMA Copy 把数据从磁盘拷贝到内核缓冲区
- 通过 CPU Copy 把数据从内核缓冲区拷贝到用户缓冲区
- 返回用户进程
- 用户再做 write 系统调用
- 通过 CPU Copy 把数据从用户缓冲区拷贝到内核缓冲区
- 通过 DMA Copy 把数据从内核缓冲区拷贝到网卡
- 返回用户进程
这里有 4 次 Copy 和两次系统调用 (4 次上下文切换)
零拷贝模式
- 用户做 sendfile 系统调用
- 通过 DMA Copy 把数据从磁盘拷贝到内核缓冲区
- 通过 DMA Copy 把数据从内核缓冲区拷贝到网卡
- 返回用户进程
这里只有两次 Copy 和一次系统调用 (2 次上下文切换)
反压
短时间内数据量太大,可能导致系统接收数据的速率远高于它的处理能力
通常有三种选择
- 丢弃消息
- 缓冲消息
- 反压
丢弃数据影响业务
缓冲对应用有压力
反压就是告知上游,自己处理能力不足,减少发送过来的数据,缓解系统压力
由于 Kafka 是采用 pull 方式消费数据的,所以不需要采取反压措施,也不会导致数据丢失
消息文件
每个 partition 是一个文件夹,假设 topic 是 my.test.topic,有 2 个 partition,那么就有两个目录
my.test.topic-0
my.test.topic-1
每个 partition 目录下由多组 segment 文件组成,每组 segment 有 3 个文件
00000000000000000000.index
00000000000000000000.log
00000000000000000000.timeindex
00000000000003456789.index
00000000000003456789.log
00000000000003456789.timeindex
00000000000003456789 代表这个 segment 存储的第一个数据的 offset 是 3456789
.log 文件存储的是数据
.index 存储的是 offset 和 position 的关系
比如记录 offset 3 的数据在 .log 文件中的 position 也就是偏移是 300
不会每个数据都存位置索引,所以是用于缩小范围,而不是精确查找
.timeindex 存储的是 timestamp 和 offset 的关系
比如记录 timestamp 1702621123000 的数据的 offset 是 15
同样不会每个数据都存时间索引
所以检索数据, 主要靠稀疏索引 + 二分法 + 顺序查找
topic 的 delete 和 compact 机制
创建 topic 的时候可以指定 cleanup.policy 参数
- delete:发消息时不需要指定 key,数据文件过期或是太大时,会全部删除
- compact:发消息时必须指定 key,数据文件过期或是太大时,不会全部删除,会保留每个 key 的最新数据
默认是 delete 配置
数据清除策略
server.properties 部分全局配置 (https://kafka.apache.org/30/documentation.html#brokerconfigs)
log.roll.hours=168 ## 超过 7 天就要生成新的 segment 文件
log.segment.bytes=1073741824 ## 超过 1G 就要生成新的 segment 文件
log.flush.interval.messages=10000 ## 缓存消息量最大数
log.flush.interval.ms=null ## 缓存消息最长时间
log.retention.hours=168 ## 数据文件保留时间 (7 天)
log.retention.bytes=-1 ## 总数据太大时删除旧的 segment 文件 (默认不限制)
log.segment.delete.delay.ms=60000 ## 数据删除后,文件还在,只是无法读取,这是保留多长时间后真正删除
topic 级别部分配置 (https://kafka.apache.org/30/documentation.html#topicconfigs)
delete.retention.ms=86400000 ## time to retain delete tombstone markers for log compacted topics.
retention.ms=604800000 ## 数据文件保留时间 (7 天)
retention.bytes=-1 ## 总数据太大时删除旧的 segment 文件 (默认不限制)
segment.ms=604800000 ## 超过 7 天就要生成新的 segment 文件
segment.bytes=1073741824 ## 超过 1G 就要生成新的 segment 文件
可以让 topic 的配置和全局配置不一样
消息内容
Consumer 获取 Kafka 消息 ConsumerRecord 后,能拿到的内容有
- topic
- partition
- offset
- timestamp
- header
- key
- value
timestamp 是发送时的时间
header 不是系统加的,而是发送程序自己自定义加的
Partition、Key、有序数据
不同 partition 之间的数据是无法保证有序的
所以如果有业务逻辑关系,需要保证有序处理的数据,要放到同一个 partition
如果没有指定消息的 key 和 partition,那 Kafka 会以 round-robin 的方式把消息均匀地写到每个 partition
如果有指定消息的 key,那 Kafka 会对 key 做 hash 然后跟 partition 个数取模,最终消息进入到特定 partition
Group
多个 Consumer 可以使用同一个 group.id 组成一个 Group
一个消息,只会被 Group 中的一个 Consumer 消费
这是通过把 Consumer 和 Partition 一一绑定实现的
也就是说 Topic 的每个 Partition 只会被 Group 中的一个 Consumer 消费
如果 Group 中的 Consumer 数量少于 Partition 数量,有些 Consumer 就要消费多个 Partition
如果 Group 中的 Consumer 数量多于 Partition 数量,有些 Consumer 就会处于空闲状态
最好是 Consumer 和 Partition 数量一样
如果只有一个 Consumer,那就是轮询每个 Partition 的数据
ZooKeeper 作用和不足
ZooKeeper 主要用于分布式应用的协调
在 Kafka 中的作用包括
- 存储 broker 信息
每个 broker 都会创建节点 /brokers/ids/{id},存 ADVERTISED_LISTENERS 等信息
ADVERTISED_LISTENERS 作用
连上 broker 后,会把 ZooKeeper 记录的 ADVERTISED_LISTENERS 信息回给 client
后面 client 会用这个 ADVERTISED_LISTENERS 信息进行连接
所以要保证 ADVERTISED_LISTENERS 配置的 IP 是 client 可以连通的 - Controller 选举
每个 Broker 都会创建 /controller 临时节点
只有一个 Broker 成功并成为 controller,并把自己的 broker id 设置到 /controller
其他的 Broker 失败后改成对 /controller 设置 Watcher
这样当 controller 出问题时,就能收到通知,然后重新选举
controller 主要作用包括
Broker 状态管理、partition 状态管理、Leader 选举、副本状态管理、分区重平衡、管理集群元数据 - Leader 选举
Kafka 的 leader 和 follower 是按 topic 和 partition 来的
即不同 topic 的不同 partition 的 Leader 节点是不一样的
Leader 是由 Controller 按一定策略选出来的 (均匀分布、优先选择 in-sync 状态的 follower)
然后会把 Leader/Follower 等信息存在 ZooKeeper 比如 /brokers/topics/my.topic/partitions/0/state
内容如下,Leader 是 broker-1,而 isr 表示共 3 个副本,那么 Follower 是 broker-2 和 broker-3
- Consumer 信息
早期版本的 consumer group 和 offset 等信息存在 ZooKeeper 的 /consumers 下面
现在改成存在内部 topic __consumer_offsets-xx
使用 ZooKeeper 的缺点
- 运维复杂,有两套系统要部署维护
- Kafka 的可靠性和可用性不仅取决于自己,还取决于外部组件
- ZooKeeper 是 CP,就是保证分布式的强一致性,在可用性方面做出了牺牲
- Controller 故障重新选举,耗时间,这个过程 Kafka 暂时不能工作
- topic 和 partition 数量太大会造成瓶颈
新版本的 Kafka (2.8.0 后) 可以不再使用 ZooKeeper
Quorum Controller
用 Quorum Controller 代替之前的 Controller,每个节点都会保存所有元数据
KRaft
去中心化的协议,最重要的特点是 Leader 选举、日志复制,能保证数据一致性
升级后可以支持百万级分区
Kafka Stream 优势和不足
优势
- 轻量,容易部署,只需要依赖一个简单轻量的 jar 包
- 基于 Kafka 的 Partition 和 Rebalance 机制,实现水平扩展和动态在线调整并行度
- 支持基于事件时间的窗口操作
- 支持状态存储 State Store
- 支持 exactly-once
- 支持记录级处理
- 提供高级别 (DSL,比如 filter、map、count、join、agg 等)、低级别 (Processor 比如 transform、process、stateStore 等) 两套 API
不足
- 数据源和数据输出单一,只支持 Kafka,不像 Spark、Flink 等支持多种数据
- 没有资源管理和任务调度
- 不支持 SQL
(和其他通用流式计算引擎比有不足)
Kafka Stream 配置 group id 和线程数量
Kafka Stream 的 application.id 用于设置 Kafka groupd-id
Kafka Stream 的 num.stream.threads 用于设置线程数量,也就是 Group 的 Consumer 数量
在 application.yaml 中的配置
kafka.streams:
applicationId: ${KAFKA_APPLICATION_ID:app.idea}
numStreamThreads: ${KAFKA_NUMBER_OF_THREADS:8}
numStreamThreads 的默认值是 1
Kafka Stream 的 Task 和 Partition
Kafka Stream 的最小单位是 Task,每个 Partition 只会分配给一个 Task
每个 Task 只在一个 Thread 中运行,每个 Thread 运行一个或多个 Task
(所以依然是每个 Partition 只会被一个 Consumer 消费)
如果有多个 app 且 applicationId 一样,那就能实现并行处理,Partition 会在这些 app 的所有 Thread 均匀分配
基于 Rebalance 机制的扩容
基于 Rebalance 机制,Kafka Stream 是可扩展收缩的
Partition 在某些情况下可能被重新分配,比如
1. 新的 Consumer 加入
2. 现有 Consumer 离开
3. Broker 认为 Consumer 故障了
4. Consumer Group 订阅的 topic 的 partition 数量出现变化
5. Consumer unsubscribe topic
这样只要增加或减少 app 实例,就能实现扩展或收缩
Kafka Stream 基于消息时间的 Window 操作
Kafka stream 有的操作依赖于消息的时间戳,比如窗口操作、延迟处理、乱序处理
默认是使用消息产生的时间
ConsumerRecord<Object, Object> record;
record.timestamp();
也可以自定义函数
import org.apache.kafka.streams.processor.TimestampExtractor;
public class MyTimestampExtractor implements TimestampExtractor {
@Override
public long extract(final ConsumerRecord<Object, Object> record, final long partitionTime) {
}
}
基于时间戳使用窗口函数
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "windowing-example");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "your-kafka-bootstrap-servers");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.Integer().getClass());
StreamsBuilder builder = new StreamsBuilder();
builder.stream("input-topic",
Consumed.with(Serdes.String(), Serdes.Integer(), new MyTimestampExtractor(), null))
.groupByKey()
.windowedBy(TimeWindows.of(Duration.ofMinutes(5))) // 5-minute windows
.reduce((value1, value2) -> value1 + value2)
.toStream()
.to("output-topic");
KafkaStreams streams = new KafkaStreams(builder.build(), props);
streams.start();
如果没自定义提取时间戳函数,就用消息的 timestamp
Kafka Stream 的 State Store 状态存储
Kafka Stream 支持状态存储
比如用于 aggregate
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "example");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "your-kafka-bootstrap-servers");
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();
KTable<String, Long> wordCounts = builder
.stream("input-topic", Consumed.with(Serdes.String(), Serdes.String()))
.flatMapValues(value -> Arrays.asList(value.toLowerCase().split("\\s+")))
.groupByKey()
.aggregate(
// 初始化值
() -> 0L,
// 每次收到新值时的聚合逻辑
(key, newValue, aggValue) -> aggValue + 1,
// 为状态存储定义名称
Materialized.as("word-counts")
);
wordCounts.toStream().to("output-topic", Produced.with(Serdes.String(), Serdes.Long()));
KafkaStreams streams = new KafkaStreams(builder.build(), config);
streams.start();
也可以自定义 processor 处理
{
// InMemoryKeyValueStore 内存存储
// 适用于需要快速读写, 数据量小的场景
StoreBuilder<KeyValueStore<String, Long>> storeTimestamp = Stores.keyValueStoreBuilder(
Stores.inMemoryKeyValueStore("my-store-timestamp"),
Serdes.String(),
Serdes.Long()
);
kStreamBuilder.addStateStore(storeTimestamp);
// persistentKeyValueStore 持久化存储, 使用 RocksDB
// 适用于对读写速度不高, 数据量大, 需要持久化存储的场景
StoreBuilder<KeyValueStore<String, Integer>> storeCounter = Stores.keyValueStoreBuilder(
Stores.persistentKeyValueStore("my-store-counter"),
Serdes.String(),
Serdes.Integer()
);
kStreamBuilder.addStateStore(storeCounter);
kStreamBuilder.stream("input-topic", Consumed.with(Serdes.String(), Serdes.String()))
.filter((key, value) -> nonNull(value))
.transform(new MyTestSupplier(), Named.as("my-processor"),
"my-store-timestamp", "my-store-counter")
.filter(((key, value) -> nonNull(value)))
.to("output-topic", Produced.with(Serdes.String(), Serdes.Long()));
}
public class MyTestSupplier implements TransformerSupplier<String, String, KeyValue<String, Long>> {
@Override
public Transformer<String, String, KeyValue<String, Long>> get() {
return new MyTestProcessor();
}
}
public class MyTestProcessor implements Transformer<String, String, KeyValue<String, Long>> {
private KeyValueStore<String, Integer> storeCounter;
private KeyValueStore<String, Long> storeTimestamp;
@Override
public void init(ProcessorContext context) {
this.storeTimestamp = (KeyValueStore<String, Long>) context.getStateStore("my-store-timestamp");
this.storeCounter = (KeyValueStore<String, Integer>) context.getStateStore("my-store-counter");
}
@Override
public KeyValue<String, Long> transform(String key, String value) {
Integer counter = this.storeCounter.get(key);
if (counter == null) {
counter = 0;
this.storeCounter.put(key, counter);
this.storeTimestamp.put(key, System.currentTimeMillis());
}
if (value.equals("error")) {
counter += 1;
if (counter == 5) {
counter = 0;
Long duration = System.currentTimeMillis() - this.storeTimestamp.get(key);
this.storeCounter.put(key, counter);
this.storeTimestamp.put(key, System.currentTimeMillis());
return KeyValue.pair(key, duration);
// 也可以用 context.forward(key, duration);
// 然后在外面统一返回 KeyValue.pair(null, null)
// context.forward 的优势是可以产生多个输出 (比如对一个 value 做多次 context.forward)
// return 就只能产生一个输出
// context.forward 的值不会立刻传给下一级 (这个例子里下一级是 filter), 而是等到函数执行完后
} else {
this.storeCounter.put(key, counter);
}
}
return KeyValue.pair(null, null);
}
@Override
public void close() {
}
}
对于每个 state store 存储,Kafka stream 都会建一个 topic,用于记录 state store 每次 update 的数据,相当于 change log
这个 topic 的 cleanup.policy 设置为 compact,这样超时或数据量太大要删除时,会保留 key 的最后一个数据
topic 的名字是 <application-id>-<store-name>-changelog
比如 applicationId 是 app.idea,然后 store name 是 my.store
这样 topic 的名字就是 app.idea-my.store-changelog
state store 数据的读写并不是在这个 topic,而是在内存或 RocksDB 等地方
这个 topic 只作为 changelog 使用,用于出问题的时候可以恢复 state store 数据
state store 还支持 standby replica 机制,通过配置 num.standby.replicas 参数 (默认值是 0),可以在其他实例上定期同步快照 (如果有多个 Kafka stream app 的话),当某个 kafka stream 出问题的时候,它负责的 partition 可能会被优先调度到包含 standby replica 的 Kafka stream
state store 没有 replica 也没关系,可以从 changelog topic 恢复
在 K8S 通过命令把 deployment 的 pod 由 2 个变成 1 个
sudo kubectl scale deployment my-app --replicas=1
通过 debug 代码可以看到被 terminated 的那个 app 所维护的 store 的内容,在仍然 running 的 app 中被恢复了
Kafka Stream 的 Join 操作
两个 Stream 基于 Window 做 join
KStream<String, String> stream1 =
kStreamBuilder.stream("input-topic-1", Consumed.with(Serdes.String(), Serdes.String()));
KStream<String, String> stream2 =
kStreamBuilder.stream("input-topic-2", Consumed.with(Serdes.String(), Serdes.String()));
KStream<String, String> joinedStream = stream1.join(
stream2,
(value1, value2) -> value1 + ", " + value2,
JoinWindows.of(Duration.ofMinutes(5))
);
joinedStream.to("output-topic");
KStream 和 KTable 做 left join
KStream<String, String> stream =
kStreamBuilder.stream("input-topic-1", Consumed.with(Serdes.String(), Serdes.String()));
KTable<String, String> table =
kStreamBuilder.table("input-topic-2", Consumed.with(Serdes.String(), Serdes.String()));
KStream<String, String> joinedStream = stream.leftJoin(
table,
(value1, value2) -> value1 + ", " + value2
);
joinedStream.to("output-topic");
还支持 full join 等操作
同步和异步
Producer 发送消息时默认是异步的
// acks - 对 broker 的 ack 的要求
//
// 0 : 发送后不需要等待 ack
// 1 : 成功发送到 leader 节点就返回 ack
// -1/all : 不仅发送到 leader 节点, 还发送副本到 follower 节点, 才返回 ack
//
// 默认是 1, 注意这不代表是同步的, 异步也有 ack 的, 只是由回调函数处理
//
// 注意这里的 leader 和 follower 是按 topic 和 partition 来的,
// 就是有可能
// topic-a 的 partition-0 的 leader 在 broker-1,
// topic-a 的 partition-1 的 leader 在 broker-2,
// 然后
// topic-b 的 partition-0 的 leader 在 broker-2,
// topic-b 的 partition-1 的 leader 在 broker-1
//
// batch.size - 缓存的异步消息的总大小, 超过就发送, 默认 16K
//
// linger.ms - 如果没达到 batch.size, 最多等多久, 默认是 0, 即不等待, 等于没有用 batch
//
// retries - 失败重试次数, 默认没限制
//
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.ACKS_CONFIG, "1");
props.put(ProducerConfig.RETRIES_CONFIG, 2);
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384);
props.put(ProducerConfig.LINGER_MS_CONFIG, 100);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
Producer<String, String> producer = new KafkaProducer<>(props);
// 不用回调函数, 即不处理返回结果
producer.send(new ProducerRecord<>("my-topic", "key", "value"));
producer.close();
// 用回调函数
producer.send(new ProducerRecord<>("my-topic", "key", "value"), (metadata, exception) -> {
if (exception == null) {
System.out.println("topic : " + metadata.topic());
System.out.println("partition : " + metadata.partition());
System.out.println("offset : " + metadata.offset());
}
});
producer.close();
// 异步有可能丢数据, 可以通过一些配置尽可能减少
//
// 加大阻塞时间
// max.block.ms : 当缓存满了之后, 异步 send 函数也会阻塞, 阻塞一定时间后会抛异常, 可以设置大点
// 加大缓存区
// send.buffer.bytes
// buffer.memory
// 考虑增加超时
// request.timeout.ms
// delivery.timeout.ms
// 设置 acks 为 "-1"
同步发送, 在异步的基础上用 get() 阻塞直到返回结果
// 同步不存在 batch, 所以不需要 batch.size 和 linger.ms
//
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.ACKS_CONFIG, "1");
props.put(ProducerConfig.RETRIES_CONFIG, 2);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
Producer<String, String> producer = new KafkaProducer<>(props);
// 不处理返回结果
producer.send(new ProducerRecord<>("my-topic", "key", "value")).get();
producer.close();
// 处理返回结果
RecordMetadata metadata = producer.send(new ProducerRecord<>("my-topic", "key", "value")).get();
System.out.println("topic : " + metadata.topic());
System.out.println("partition : " + metadata.partition());
System.out.println("offset : " + metadata.offset());
producer.close();
// 同步没有缓存问题, 要进一步增加可靠性, 主要是设置 acks 为 "-1", 以及考虑增加超时时间
// acks
// request.timeout.ms
// delivery.timeout.ms
Consumer 端防止丢数据,可以设置为 auto commit
命令行工具
Kafka 提供一些命令行工具
创建 topic
kafka-topics.sh --create --zookeeper zookeeper:2181 \
--replication-factor 1 --partitions 1 \
--topic my-test-1 \
--config cleanup.policy=compact \
--config delete.retention.ms=300000 \
--config max.compaction.lag.ms=60000 \
--config retention.ms=120000 \
--config segment.ms=300000
kafka-topics.sh --create --zookeeper zookeeper:2181 \
--replication-factor 1 --partitions 1 \
--topic my-test-2 \
--config cleanup.policy=delete \
--config retention.ms=300000
发送消息
kafka-console-producer.sh --broker-list kafka:9092 \
--topic my-test-1 \
--property "parse.key=true" \
--property "key.separator=:"
消费消息
kafka-console-consumer.sh --bootstrap-server kafka:9092 \
--property print.key=true \
--topic my-test-1 \
--from-beginning
查看、描述、删除 topic
kafka-topics.sh --zookeeper zookeeper:2181 --list
kafka-topics.sh --zookeeper zookeeper:2181 --topic my-test-1 --describe
kafka-topics.sh --bootstrap-server localhost:9092 --delete --topic my-test-1