目录
- 1 Kafka
- 1.1 定义
- 1.1.1 Kafka名词
- 1.1.2 Kafka核心API
- 1.2 相关组件介绍
- 1.2.1 Topic
- 1.2.2 Partitions分区
- 1.2.3 Topics主题 和 partitions分区
- 1.2.4 Distribution分配
- 1.2.5 Producers生产者 和 Consumers消费者
- 1.2.5.1 Producers生产者
- 1.2.5.2 Consumers
- 1.2.5.3 Consumers kafka确保
- 1.2.6 架构和zookeeper关系
- 1.3 kafka是如何保证消息的有序性
- 1.4 消息重复
- 1.4.1 引言
- 1.4.2 解决重复消息
- 1.4.2.1 Kafka 幂等性 Producer
- 1.4.2.2 Kafka 事务
- 1.4.2.3 消费端幂等
1 Kafka
1.1 定义
Kafka
是一种高吞吐量、分布式、基于发布/订阅
的消息系统,最初由 LinkedIn
公司开发,使用Scala
语言编写,目前是Apache
的开源项目。
流媒体平台有三个关键功能:
- 发布和订阅记录流,类似于消息队列或企业消息传递系统。
- 以容错的持久方式存储记录流。
- 记录发生时处理流。
Kafka
通常用于两大类应用:
- 构建可在系统或应用程序之间可靠获取数据的实时流数据管道
- 构建转换或响应数据流的实时流应用程序
1.1.1 Kafka名词
下面是Kafka
中涉及到的相关概念:
-
broker
:Kafka
服务器,负责消息存储和转发 -
topic
:消息类别,Kafka
按照topic
来分类消息(即使如此,kafka仍然有点对点和广播发布类型) -
partition
:topic
的分区,一个topic
可以包含多个partition
,topic
消息保存在各个partition
上 -
offset
:消息在日志中的位置,可以理解是消息在partition
上的偏移量,也是代表该消息的唯一序号 -
Producer
:消息生产者 -
Consumer
:消息消费者 -
Consumer Group
:消费者分组,每个Consumer
必须属于一个group
-
Zookeeper
:保存着集群broker、topic、partition
等meta
数据;另外,还负责broker
故障发现,partition leader
选举,负载均衡等功能
1.1.2 Kafka核心API
Kafka
有四个核心API:
Producer API
(生产者API)允许应用程序发布记录流至一个或多个kafka
的topics
(主题)Consumer API
(消费者API)允许应用程序订阅一个或多个topics
(主题),并处理所产生的对他们记录的数据流。Streams API
(流API)允许应用程序充当流处理器,从一个或多个topics
(主题)消耗的输入流,并产生一个输出流至一个或多个输出的topics
(主题),有效地变换所述输入流,以输出流。Connector API
(连接器API)允许构建和运行kafka topics
(主题)连接到现有的应用程序或数据系统中重用生产者或消费者。例如,关系数据库的连接器可能捕获对表的每个更改。
在Kafka
中,客户端和服务器之间的通信是通过简单,高性能,语言无关的TCP
协议完成的。此协议已版本化并保持与旧版本的向后兼容性。Kafka
提供Java客户端,但客户端有多种语言版本。
1.2 相关组件介绍
1.2.1 Topic
Topic
是生产者发送消息的目标地址,是消费者的监听目标
一个服务可以监听、发送多个 Topics
Kafka 中有一个consumer-group(消费者组)
的概念。
这是一组服务,扮演一个消费者
如果是消费者组接收消息,Kafka
会把一条消息路由到组中的某一个服务
这样有助于消息的负载均衡,也方便扩展消费者。
Topic
扮演一个消息的队列。
首先,一条消息发送了
然后,这条消息被记录和存储在这个队列中,不允许被修改
接下来,消息会被发送给此 Topic
的消费者。
但是,这条消息并不会被删除,会继续保留在队列中
像之前一样,这条消息会发送给消费者、不允许被改动、一直呆在队列中。
(消息在队列中能呆多久,可以修改 Kafka 的配置)
1.2.2 Partitions分区
上面 Topic
的描述中,把 Topic
看做了一个队列,实际上,一个 Topic
是由多个队列组成的,被称为Partition(分区)
。
这样可以便于 Topic
的扩展
生产者发送消息的时候,这条消息会被路由到此 Topic
中的某一个 Partition
消费者监听的是所有分区
生产者发送消息时,默认是面向 Topic
的,由 Topic
决定放在哪个 Partition
,默认使用轮询策略
也可以配置 Topic
,让同类型的消息都在同一个 Partition
。
例如,处理用户消息,可以让某一个用户所有消息都在一个 Partition
。
例如,用户1发送了3条消息:A、B、C,默认情况下,这3条消息是在不同的 Partition 中(如 P1、P2、P3)。
在配置之后,可以确保用户1的所有消息都发到同一个分区中(如 P1)
这个功能有什么用呢?
这是为了提供消息的【有序性】。
消息在不同的 Partition 是不能保证有序的,只有一个 Partition 内的消息是有序的
1.2.3 Topics主题 和 partitions分区
一个Topic
可以认为是一类消息,每个topic
将被分成多个partition
(区),每个partition
在存储层面是append log
文件
主题是发布记录的类别或订阅源名称。Kafka
的主题总是多用户; 也就是说,一个主题可以有零个,一个或多个消费者订阅写入它的数据。
对于每个主题,Kafka
集群都维护一个如下所示的分区日志:
每个分区都是一个有序的,不可变的记录序列,不断附加到结构化的提交日志中。分区中的记录每个都分配了一个称为偏移的顺序ID
号,它唯一地标识分区中的每个记录。
Kafka
集群持久保存所有已发布的记录 - 无论是否已使用 - 使用可配置的保留期。例如,如果保留策略设置为两天,则在发布记录后的两天内,它可供使用,之后将被丢弃以释放空间。Kafka
的性能在数据大小方面实际上是恒定的,因此长时间存储数据不是问题。
实际上,基于每个消费者保留的唯一元数据是该消费者在日志中的偏移或位置。这种偏移由消费者控制:通常消费者在读取记录时会线性地提高其偏移量,但事实上,由于该位置由消费者控制,因此它可以按照自己喜欢的任何顺序消费记录。例如,消费者可以重置为较旧的偏移量来重新处理过去的数据,或者跳到最近的记录并从“现在”开始消费。
这些功能组合意味着Kafka
消费者consumers
非常cheap
- 他们可以来来往往对集群或其他消费者没有太大影响。例如,可以使用我们的命令行工具“tail”任何主题的内容,而无需更改任何现有使用者所消耗的内容。
日志中的分区有多种用途。首先,它们允许日志扩展到超出适合单个服务器的大小。每个单独的分区必须适合托管它的服务器,但主题可能有许多分区,因此它可以处理任意数量的数据。其次,它们充当了并行性的单位 - 更多的是它
1.2.4 Distribution分配
一个Topic
的多个partitions
,被分布在kafka
集群中的多个server
上;每个server
(kafka实例)负责partitions
中消息的读写操作;此外kafka
还可以配置partitions
需要备份的个数(replicas
),每个partition
将会被备份到多台机器上,以提高可用性.
基于replicated
方案,那么就意味着需要对多个备份进行调度;每个partition
都有一个server
为leader
;leader
负责所有的读写操作,如果leader
失效,那么将会有其他follower
来接管(成为新的leader);follower
只是单调的和leader
跟进,同步消息即可..由此可见作为leader
的server
承载了全部的请求压力,因此从集群的整体考虑,有多少个partitions就意味着有多少个"leader",kafka会将"leader"均衡的分散在每个实例上,来确保整体的性能稳定
1.2.5 Producers生产者 和 Consumers消费者
1.2.5.1 Producers生产者
Producers
将数据发布到指定的topics
主题。同时Producer
也能决定将此消息归属于哪个partition
;比如基于round-robin
方式或者通过其他的一些算法等。
1.2.5.2 Consumers
本质上kafka
只支持Topic
.每个consumer
属于一个consumer group
;反过来说,每个group
中可以有多个consumer
.发送到Topic
的消息,只会被订阅此Topic
的每个group
中的一个consumer
消费。
如果所有使用者实例具有相同的使用者组,则记录将有效地在使用者实例上进行负载平衡。
如果所有消费者实例具有不同的消费者组,则每个记录将广播到所有消费者进程。
分析:两个服务器Kafka群集,托管四个分区(P0-P3),包含两个使用者组。消费者组A有两个消费者实例,B组有四个消费者实例。
在Kafka
中实现消费consumption
的方式是通过在消费者实例上划分日志中的分区,以便每个实例在任何时间点都是分配的“公平份额”的独占消费者。维护组中成员资格的过程由Kafka协议动态处理。如果新实例加入该组,他们将从该组的其他成员接管一些分区; 如果实例死亡,其分区将分发给其余实例。
Kafka
仅提供分区内记录的总订单,而不是主题中不同分区之间的记录。对于大多数应用程序而言,按分区排序与按键分区数据的能力相结合就足够了。但是,如果您需要对记录进行总订单,则可以使用仅包含一个分区的主题来实现,但这将意味着每个使用者组只有一个使用者进程。
1.2.5.3 Consumers kafka确保
发送到partitions
中的消息将会按照它接收的顺序追加到日志中。也就是说,如果记录M1
由与记录M2
相同的生成者发送,并且首先发送M1
,则M1
将具有比M2
更低的偏移并且在日志中更早出现。
消费者实例按照它们存储在日志中的顺序查看记录。对于消费者而言,它们消费消息的顺序和日志中消息顺序一致。
如果Topic
的replicationfactor
为N,那么允许N-1个kafka实例失效,我们将容忍最多N-1个服务器故障,而不会丢失任何提交到日志的记录。
1.2.6 架构和zookeeper关系
Kafka
是集群架构的,ZooKeeper
是重要组件。
ZooKeeper
管理者所有的 Topic
和 Partition
。
Topic
和 Partition
存储在 Node
物理节点中,ZooKeeper
负责维护这些 Node
有2个 Topic,各自有2个 Partition
这是逻辑上的形式,但在 Kafka 集群中的实际存储可能是这样的
Topic A
的 Partition #1
有3份,分布在各个 Node
上。
这样可以增加 Kafka
的可靠性和系统弹性。
3个 Partition #1
中,ZooKeeper
会指定一个 Leader
,负责接收生产者发来的消息
其他2个 Partition #1 会作为 Follower,Leader 接收到的消息会复制给 Follower
这样,每个 Partition 都含有了全量消息数据。
即使某个 Node 节点出现了故障,也不用担心消息的损坏。
Topic A 和 Topic B 的所有 Partition 分布可能就是这样的
1.3 kafka是如何保证消息的有序性
kafka
这样保证消息有序性的:
一个 topic
,一个 partition
,一个 consumer
,内部单线程消费,单线程吞吐量太低,一般不会用这个。(全局有序性)
写 N
个内存 queue
,具有相同 key
的数据都到同一个内存 queue
;然后对于 N
个线程,每个线程分别消费一个内存 queue
即可,这样就能保证顺序性。
大家可以看下消息队列的有序性是怎么推导的:
消息的有序性,就是指可以按照消息的发送顺序来消费。有些业务对消息的顺序是有要求的,比如先下单再付款,最后再完成订单,这样等。假设生产者先后产生了两条消息,分别是下单消息(M1),付款消息(M2),M1比M2先产生,如何保证M1比M2先被消费呢。
为了保证消息的顺序性,可以将将M1、M2发送到同一个Server上,当M1发送完收到ack后,M2再发送。如图:
这样还是可能会有问题,因为从MQ服务器到服务端,可能存在网络延迟,虽然M1先发送,但是它比M2晚到。
那还能怎么办才能保证消息的顺序性呢?将M1和M2发往同一个消费者,且发送M1后,等到消费端ACK成功后,才发送M2就得了。
消息队列保证顺序性整体思路就是这样啦。比如Kafka
的全局有序消息,就是这种思想的体现: 就是生产者发消息时,1个Topic
只能对应1个Partition
,一个 Consumer
,内部单线程消费。
但是这样吞吐量太低,一般保证消息局部有序即可。在发消息的时候指定Partition Key
,Kafka
对其进行Hash
计算,根据计算结果决定放入哪个Partition
。这样Partition Key
相同的消息会放在同一个Partition
。然后多消费者单线程消费指定的Partition
1.4 消息重复
1.4.1 引言
数据重复这个问题其实也是挺正常,全链路都有可能会导致数据重复
通常,消息消费时候都会设置一定重试次数来避免网络波动造成的影响,同时带来副作用是可能出现消息重复。
整理下消息重复的几个场景:
生产端
: 遇到异常,基本解决措施都是重试
- 场景一:
leader
分区不可用了,抛LeaderNotAvailableException
异常,等待选出新leader
分区。 - 场景二:
Controller
所在Broker
挂了,抛NotControllerException
异常,等待Controller
重新选举。 - 场景三:网络异常、断网、网络分区、丢包等,抛
NetworkException
异常,等待网络恢复。
-
消费端
:poll
一批数据,处理完毕还没提交offset
,机子宕机重启了,又会poll
上批数据,再度消费就造成了消息重复。
1.4.2 解决重复消息
了解下消息的三种投递语义:
- 最多一次(
at most once
): 消息只发一次,消息可能会丢失,但绝不会被重复发送。例如:mqtt 中 QoS = 0。 - 至少一次(
at least once
): 消息至少发一次,消息不会丢失,但有可能被重复发送。例如:mqtt 中 QoS = 1 - 精确一次(
exactly once
): 消息精确发一次,消息不会丢失,也不会被重复发送。例如:mqtt 中 QoS = 2。
了解了这三种语义,再来看如何解决消息重复,即如何实现精准一次,可分为三种方法:
-
Kafka
幂等性Producer
: 保证生产端发送消息幂等。局限性,是只能保证单分区且单会话(重启后就算新会话) -
Kafka
事务: 保证生产端发送消息幂等。解决幂等Producer
的局限性。 - 消费端幂等: 保证消费端接收消息幂等
1.4.2.1 Kafka 幂等性 Producer
幂等性指:无论执行多少次同样的运算,结果都是相同的。即一条命令,任意多次执行所产生的影响均与一次执行的影响相同
幂等性使用示例:在生产端添加对应配置即可
- 设置幂等,启动幂等。
- 配置
acks
,注意
:一定要设置 acks=all,否则会抛异常。 - 配置 max.in.flight.requests.per.connection 需要 <= 5,否则会抛异常
OutOfOrderSequenceException
。
- 0.11 >= Kafka < 1.1, max.in.flight.request.per.connection = 1
- Kafka >= 1.1, max.in.flight.request.per.connection <= 5
Properties props = new Properties();
props.put("enable.idempotence", ture); // 1. 设置幂等
props.put("acks", "all"); // 2. 当 enable.idempotence 为 true,这里默认为 all
props.put("max.in.flight.requests.per.connection", 5); // 3. 注意
为了更好理解,需要了解下 Kafka
幂等机制:
-
Producer
每次启动后,会向Broker
申请一个全局唯一的pid
。(重启后 pid 会变化,这也是弊端之一) -
Sequence Numbe
:针对每个<Topic, Partition>
都对应一个从0开始单调递增的Sequence
,同时Broker
端会缓存这个seq num
- 判断是否重复: 拿
<pid, seq num>
去Broker
里对应的队列ProducerStateEntry.Queue
(默认队列长度为 5)查询是否存在
如果nextSeq == lastSeq + 1
,即服务端seq + 1 == 生产传入seq
,则接收。
如果nextSeq == 0 && lastSeq == Int.MaxValue
,即刚初始化,也接收。
反之,要么重复,要么丢消息,均拒绝。
这种设计针对解决了两个问题:
-
消息重复
: 场景Broker
保存消息后还没发送ack
就宕机了,这时候Producer
就会重试,这就造成消息重复。 -
消息乱序
: 避免场景,前一条消息发送失败而其后一条发送成功,前一条消息重试后成功,造成的消息乱序。
那什么时候该使用幂等:
- 如果已经使用
acks=all
,使用幂等也可以。 - 如果已经使用
acks=0 或者 acks=1
,说明系统追求高性能,对数据一致性要求不高。不要使用幂等
使用例子
启动消息者:可以用 Kafka
提供的脚本
# 举个栗子:topic 需要自己去修改
$ cd ./kafka-2.7.1-src/bin
$ ./kafka-console-producer.sh --broker-list localhost:9092 --topic test_topic
创建 topic : 1副本,2 分区
$ ./kafka-topics.sh --bootstrap-server localhost:9092 --topic myTopic --create --replication-factor 1 --partitions 2
# 查看
$ ./kafka-topics.sh --bootstrap-server broker:9092 --topic myTopic --describe
生产者代码
public class KafkaProducerApplication {
private final Producer<String, String> producer;
final String outTopic;
public KafkaProducerApplication(final Producer<String, String> producer,
final String topic) {
this.producer = producer;
outTopic = topic;
}
public void produce(final String message) {
final String[] parts = message.split("-");
final String key, value;
if (parts.length > 1) {
key = parts[0];
value = parts[1];
} else {
key = null;
value = parts[0];
}
final ProducerRecord<String, String> producerRecord
= new ProducerRecord<>(outTopic, key, value);
producer.send(producerRecord,
(recordMetadata, e) -> {
if(e != null) {
e.printStackTrace();
} else {
System.out.println("key/value " + key + "/" + value + "\twritten to topic[partition] " + recordMetadata.topic() + "[" + recordMetadata.partition() + "] at offset " + recordMetadata.offset());
}
}
);
}
public void shutdown() {
producer.close();
}
public static void main(String[] args) {
final Properties props = new Properties();
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.CLIENT_ID_CONFIG, "myApp");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
final String topic = "myTopic";
final Producer<String, String> producer = new KafkaProducer<>(props);
final KafkaProducerApplication producerApp = new KafkaProducerApplication(producer, topic);
String filePath = "/home/donald/Documents/Code/Source/kafka-2.7.1-src/examples/src/main/java/kafka/examples/input.txt";
try {
List<String> linesToProduce = Files.readAllLines(Paths.get(filePath));
linesToProduce.stream().filter(l -> !l.trim().isEmpty())
.forEach(producerApp::produce);
System.out.println("Offsets and timestamps committed in batch from " + filePath);
} catch (IOException e) {
System.err.printf("Error reading file %s due to %s %n", filePath, e);
} finally {
producerApp.shutdown();
}
}
}
启动消费者:
$ ./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic myTopic
1.4.2.2 Kafka 事务
使用 Kafka
事务解决幂等的弊端:单会话且单分区幂等。
事务使用示例:分为生产端 和 消费端
Properties props = new Properties();
props.put("enable.idempotence", ture); // 1. 设置幂等
props.put("acks", "all"); // 2. 当 enable.idempotence 为 true,这里默认为 all
props.put("max.in.flight.requests.per.connection", 5); // 3. 最大等待数
props.put("transactional.id", "my-transactional-id"); // 4. 设定事务 id
Producer<String, String> producer = new KafkaProducer<String, String>(props);
// 初始化事务
producer.initTransactions();
try{
// 开始事务
producer.beginTransaction();
// 发送数据
producer.send(new ProducerRecord<String, String>("Topic", "Key", "Value"));
// 数据发送及 Offset 发送均成功的情况下,提交事务
producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
// 数据发送或者 Offset 发送出现异常时,终止事务
producer.abortTransaction();
} finally {
// 关闭 Producer 和 Consumer
producer.close();
consumer.close();
}
这里消费端 Consumer
需要设置下配置:isolation.level
参数
-
read_uncommitted
: 这是默认值,表明Consumer
能够读取到Kafka
写入的任何消息,不论事务型Producer
提交事务还是终止事务,其写入的消息都可以读取。如果用了事务型Producer
,那么对应的Consumer
就不要使用这个值。 -
read_committed
: 表明Consumer
只会读取事务型Producer
成功提交事务写入的消息。当然了,它也能看到非事务型Producer
写入的所有消息。
1.4.2.3 消费端幂等
只要消费端具备了幂等性,那么重复消费消息的问题也就解决了。
典型的方案是使用:消息表,来去重:
上述demo中,消费端拉取到一条消息后,开启事务,将消息Id 新增到本地消息表中,同时更新订单信息。
如果消息重复,则新增操作 insert
会异常,同时触发事务回滚。