为什么要学习使用kafka?kafka到底能够为我们的系统来带什么?
带着问题,我们才能去思考,去尝试寻找解决的方案。 在解决问题的过程中才更加有兴趣去学习和使用,并深入了解其原理,从宏观的的设计上学会架构自己的业务系统。
有了学习目标,我们就可以针对自己的专业方向进行深入的了解,不管是从学习搭建及管理 Kafka 线上环境,还是在业务系统中学习如何使用kafka进行有效隔离上下游业务(业务解耦,形成微服务),对上游突然增加的流量进行有序的处理(异步消息处理,防止冲击)。
Apache Kafka是一个分布式消息发布订阅系统。它最初由LinkedIn公司基于独特的设计实现为一个分布式的提交日志系统( a distributed commit log),之后成为Apache项目的一部分。Kafka系统快速、可扩展并且可持久化。它的分区特性,可复制和可容错都是其不错的特性。
官网解释说:Kafka是一个分布式系统,由服务器和客户端组成,通过高性能的TCP网络协议进行通信。它可以部署在裸金属硬件、虚拟机和内部环境以及云环境中的容器上。
提供能力:
1、发布(写)和订阅(读)事件流,包括从其他系统持续导入/导出数据。
2、可以持久可靠地存储事件流,只要您想要。
3、处理:在事件发生时或回顾时处理事件流
了解了kafka的整个组件的提供能力,我们需要对能力进行解析和深入,方便日后在不同的业务中使用和配置其最优的性能,选择最理想的实现方式。
kafka提供2种基本数据传输模型
生产者发送消息后,只能由其中一个消费者进行消费,消费后消息便不在存储Queue中。
生产者发送到主题topic的消息,只有订阅了此主题topic的消费者才会收到消息,同一个消息可以被多个消费者进行消费。
Kafka 的消息生产者就是Producer,上游消费者进程添加 Kafka Client 创建 Kafka Producer,向 Broker 发送消息。
Kafka 的消息消费者就是Consumer,在下游消费者进程引入 Kafka Consumer API 持续消费队列中消息。
消费者通过向群组协调器所在的 broker 发送心跳来维持它们和群组的从属关系以及它们对分区的所有权
Broker 是集群部署在远程服务器上的 Kafka Server 进程。
kafka组件订阅broker在zookeeper上的注册路径,当有broker进入或退出集群时,这些组件就可以获得通知。
主题是一个逻辑上的概念,它还可以细分为多个分区,一个分区只属于单个主题,很多时候也会把分区称为主题分区(Topic-Partition)。同一主题下的不同分区包含的消息是不同的,分区在存储层面可以看作一个可追加的日志(Log)文件,消息在被追加到分区日志文件的时候都会分配一个特定的偏移量(offset)。
offset 是消息在分区中的唯一标识,是一个单调递增且不变的值。Kafka 通过它来保证消息在分区内的顺序性,不过 offset 并不跨越分区,也就是说,Kafka 保证的是分区有序而不是主题有序。
kafka中的分区机制指的是每个主题划分为多个分区(Partition),每个分区是一组有序的消息日志。生产者生产的每条消息只会被发送到一个分区中。
kafka的三层消息架构:
第一层为主题层,每个主题可以配置M个分区,而每个分区又可以配置N个副本
第二层为分区层,每个分区的N个副本只能有一个充当领导者角色对外服务;其余的N-1个副本是追随者副本,只是提供备份。
第三层为消息层,分区中包含若干条消息,每条消息的位移从0开始,依次递增。
负责定义 Kafka 分区状态、合法的状态转换,以及管理状态之间的转换。
Kafka 的每一条消息都有一个偏移量属性,记录了其在分区中的位置,偏移量是一个单调递增的整数
在kafka中,为每个分区可引入副本(Repica)机制,通过增加副本数量就可以提升系统的容灾能力。简而言之,就是同一份数据拷贝到多台机器上。
同一个分区的不同副本保存的消息是相同的(但在同一时刻未必相同),副本之间的管理方式为“一主多从”,“主”被称为leader,“从”被称为follower。
follower位于多个broker中,当leader出现故障的时候,会从follower中重新选举新的leader。kafka通过这样的机制实现了容灾处理。
负责定义 Kafka 副本状态、合法的状态转换,以及管理状态之间的转换。
一个Kalfa集群内有多个borker,类比于服务器进程,负责处理读写请求,通过zookeeper选举出一个主broker作为controller。
一个broker进程内有自己的数据,这些数据存储在不同的partition分区内,由进程自己选出一个主分区,数据的读写都针对这个分区进行,其他的复制分区只在数据恢复时使用。
zookeeper除了用来选举主broker,还存储每个broker的主分区位置,用于读写。
1、每个服务器节点发起一个投票,广播一个(myId,votes),对i号节点就是(i,0)。
2、每个服务器节点检查接受到的投票的有效性。
3、处理投票。对自己发出的投票和收到的投票进行比较,更新自己的投票,然后把更新后的投票再广播出去。
4、优先选择votes多的节点作为leader,当votes相同,优先选择myId大的节点作为leader
计算模型:
如:
存在ZK1,它的投票是(1, 0),接收ZK2的投票为(2, 0),首先会比较两者的ZXID,均为0,再比较myid,此时ZK2的myid最大,于是ZK2胜出。ZK1更新自己的投票为(2, 0),并将投票重新发送给ZK2。
通过多次统计投票,每次投票后,服务器都会统计投票信息,判断是否已经有过半机器接受到相同的投票信息,对于ZK1、ZK2而言,都统计出集群中已经有两台机器接受了(2, 0)的投票信息,此时便认为已经选出ZK2作为Leader。
最后确定了Leader,每个服务器就会更新自己的状态,如果是Follower,那么就变更为FOLLOWING,如果是Leader,就变更为LEADING。当新的Zookeeper节点ZK3启动时,发现已经有Leader了,不再选举,直接将直接的状态从LOOKING改为FOLLOWING。
如果服务运行期间发现选举的Leader服务挂了,剩余服务会将状态更新为LOOKING后重新进行选举Leader。
当存在多个消费者时,需要多个partitions,消费者的数量应当小于等于partitions的数量,防止剩余消费者浪费,同一个Topic写入不同的partitions中时是有序的,所以在不同的消费者组中,每个partition只能被每一个group中的一个消费者消费,保证了消费时也是有序的。
当一个消息被多个集群消费者不同的分组监听消费时,被称为广播消费。
如果GroupId是动态生成时,当消费服务重启,会重新更新为新的分组,需要正确配置 auto.offset.reset ,进行防止重复消费。
如果GrouopId是静态变量时,配置latest可能会存在丢失、漏掉消费,所以需要在不同的业务中实现消费监听的幂等和再消费保证业务的正常实现。
由于kafka的高吞吐量和消费处理效率性能的考虑,kafka没有使用第三方网络架构,直接使用java.nio的封装。
负责与客户端建立连接链路,然后把Socket轮转交给Process Thread
负责接收请求和响应数据,Process Thread每次基于Selector事件循环,首先从Response Queue读取响应数据,向客户端回复响应,然后接收到客户端请求后,读取数据放入Request Queue
M个请求处理线程,包含在线程池—KafkaRequestHandlerPool内部,从RequestChannel的全局请求队列—requestQueue中获取请求数据并交给KafkaApis处理,M的大小由 “num.io.threads” 决定
其为Kafka服务端的请求通道,该数据结构中包含了一个全局的请求队列 requestQueue和多个与Processor处理器相对应的响应队列responseQueue,提供给Processor与请求处理线程KafkaRequestHandler和KafkaApis交换数据的地方
其底层是对 Java NIO 进行相应的封装,位于Kafka的网络接口层。Kafka消息生产者对象—KafkaProducer的send方法主要调用NetworkClient完成消息发送
其是一个NIO的服务,它同时启动一个Acceptor接收线程和多个Processor处理器线程。提供了一种典型的Reactor多线程模式,将接收客户端请求和处理请求相分离
代表了一个Kafka Broker的实例;其startup方法为实例启动的入口
Kafka的业务逻辑处理Api,负责处理不同类型的请求;比如 “发送消息”、 “获取消息偏移量—offset” 和 “处理心跳请求” 等
1、支持多个消费者监听主题,并且在不同的分区中消费
2、采用的NIO传输模型,底层异步多路管道复用,减少对象创建、销毁、连接的损耗
3、由消费顺序写入磁盘文件,不需要内存级拷贝,从消费者顺序读取消费
4、多个主题分区操作互不影响
5、支持批量发送消息、批量拉取消费消息
6、支持消息压缩,减少传输IO
# kafka 服务器集群地址
metadata.broker.list=kafka0:9092,kafka1:9092,kafka2:9092
# kafka序列化,实现String、Object、arrays等实现
serializer.class=kafka.serializer.DefaultEncoder
# 消息确认机制
# 默认0,ack = 1 无论是否写入成功。ack = 2 写入leader成功。 ack = 3 写入leader和所有副本成功
compressed.topics.ack=0
# 异步发送,提供吞吐量
producer.type=sync
# 批量发送,可以积累达到后一次发送
producer.batch-size=1
# 重试次数,当主节点失败时,不会重复produce,由新的leader来重发,消息不会丢失
producer.retries=0
kafka:
# kafka 服务群群地址
bootstrap-servers:
- kafka0:9092
- kafka1:9092
- kafka0:9092
# 生产者配置
producer:
# 消息确认机制
acks: 0
# value序列化
value-serializer: kafka.serializer.DefaultEncoder
# 开启压缩
compression-type: gizp
# 重试次数
retries : 1
@KafkaListener
id 默认是consumer-id#{线程计数}
Topics = #{Topics} 监听消息主题,#{Topics} 可以是多个。
GroupId = #{GroupId} 对消息进行分组,默认是等同与id
partitionOffsets = @partitionOffsets(partation = 1,initialOffset =1)
指定消费分区,偏移量,可以指定从第N个分区中开始对第N个消息消费
如果在一个服务中创建多个相同的监听器监听同一个Topic(扩展消费水平),即使GroupId不同,还需要设置id不能重复。
JDK8后支持多个绑定@KafkaListener
package mo.aomi.retail.kafka.goods.consumer;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
/**
* 测试消费者
*
* @author macheng
*/
@Component
public class TestConsumer {
private final String MY_TEST_TOPIC_ONE = "MY_TEST_TOPIC_ONE";
private final String MY_TEST_TOPIC_GROUP_ONE = "MY_TEST_TOPIC_GROUP_ONE";
private final String MY_TEST_TOPIC_TWO = "MY_TEST_TOPIC_TWO";
private final String MY_TEST_TOPIC_GROUP_TWO = "MY_TEST_TOPIC_GROUP_TWO";
@KafkaListener(topics = MY_TEST_TOPIC_TWO, groupId = MY_TEST_TOPIC_GROUP_TWO)
@KafkaListener(topics = MY_TEST_TOPIC_ONE, groupId = MY_TEST_TOPIC_GROUP_ONE)
public void testMoreConsumer(String menuId) {
topicOneUpdateMenuSold(menuId);
topicTwoUpdateMenuStock(menuId);
}
private void topicOneUpdateMenuSold(String menuId){
// todo 更新菜品销量
}
private void topicTwoUpdateMenuStock(String menuId){
// todo 更新菜品库存
}
}
package mo.aomi.retail.kafka.goods.consumer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.PartitionOffset;
import org.springframework.kafka.annotation.TopicPartition;
import org.springframework.stereotype.Component;
/**
* 测试消费者
*
* @author macheng
*/
@Component
public class TestConsumer {
private final String MY_TEST_TOPIC_ONE = "MY_TEST_TOPIC_ONE";
@KafkaListener(topicPartitions = {@TopicPartition(topic = MY_TEST_TOPIC_ONE, partitionOffsets = @PartitionOffset(partition = "1", initialOffset = "10"))})
public void testOffSetConsumer(String menuId) {
// 消费第1个分区,处理第10条开始的消息
makeUpForMenuUpdate(menuId);
}
private void makeUpForMenuUpdate(String menuId){
// todo 补偿未消费的消息(非死信队列场景,由kafka宕机或者消费者宕机时)
}
}
package mo.aomi.retail.kafka.goods.consumer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.PartitionOffset;
import org.springframework.kafka.annotation.TopicPartition;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
/**
* 测试消费者
*
* @author macheng
*/
@Component
public class TestConsumer {
private final String MY_TEST_TOPIC_ONE = "MY_TEST_TOPIC_ONE";
@KafkaListener(topics = MY_TEST_TOPIC_ONE)
public void testConsumer(String menuId, Acknowledgment ack) {
try {
updateMenuStock(menuId);
} catch (Exception e) {
addUpdateMenuStockErrorConsumer(menuId);
} finally {
ack.acknowledge();
}
}
private void updateMenuStock(String menuId) {
// todo 更新菜品库存(必须成功,失败进入自定义死信队列再记录排查处理)
}
private void addUpdateMenuStockErrorConsumer(String menuId) {
// todo 失败异常记录
}
}
package mo.aomi.retail.kafka.goods.consumer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.PartitionOffset;
import org.springframework.kafka.annotation.TopicPartition;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
/**
* 测试消费者
*
* @author macheng
*/
@Component
public class TestConsumer {
private final String MY_TEST_TOPIC_ONE = "MY_TEST_TOPIC_ONE";
private final String MY_TEST_TOPIC_ONE_DTL = "MY_TEST_TOPIC_ONE.DTL";
@KafkaListener(topics = MY_TEST_TOPIC_ONE)
public void testConsumer(String menuId) {
updateMenuStock(menuId);
}
private void updateMenuStock(String menuId) {
// todo 更新菜品库存(必须成功,尝试retries仍旧失败,进入自定义死信队列)
}
@KafkaListener(topics = MY_TEST_TOPIC_ONE_DTL)
public void testConsumer(String menuId) {
// 死信队列
}
}
package mo.aomi.retail.kafka.goods.consumer;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 批量消费
*
* @author macheng
*/
@Component
public class TestBatchConsumer {
private final String MY_TEST_BATCH_TOPIC = "MY_TEST_BATCH_TOPIC";
@KafkaListener(topics = MY_TEST_BATCH_TOPIC)
public void batchConsumer(List> consumerRecordList, Acknowledgment ack) {
consumerRecordList.forEach(record ->{
updateMenuStock(record.value());
});
ack.acknowledge();
}
private void updateMenuStock(String menuId) {
// todo 更新菜品库存
}
}
1、对参数进行处理幂等控制重复处理(如:参数时间戳间隔)
2、同一个topic时不同消费者校验offset(如:offset交叉奇偶处理)
3、多个业务记录时先校验业务状态,再处理业务(如:唯一状态、记录更新时间)
...更多场景待补充