消息中间件的背景分析
场景分析
前面跟着我看过 zk 的源码,学过并发编程的同学应该知道,我们可以使用阻塞队列+线程池来实现生产者消费者模式.比如说在一个应用中,A 方法调用 B 方法去执行一些任务处理.我们可以同步调用.但是如果这个时候请求比较多的情况下,同步调用比较耗时会导致请求阻塞.我们会使用阻塞队列加线程池来实现异步任务的处理
那么,问题来了,如果是在分布式系统中,两个服务之间需要通过这种异步队列的方式来处理任务,那单进程级别的队列就无法解决这个问题了
因此,引入了消息中间件,也就是把消息处理交给第三方的服务,这个服务能够实现数据的存储以及传输,使得在分布式架构下实现跨进程的远程消息通信
所以,简单来说: 消息中间件是指利用高效可靠的消息传输机制进行平台无关的数据交流,并且基于数据通信来进行分布式系统的集成
思考一下消息中间件的设计
可以先从基本的需求开始思考
- 最基本的是要能支持消息的发送和接收,需要涉及到网络通信就一定会涉及到 NIO
- 消息中心的消息存储(持久化/非持久化)
- 消息的序列化和反序列化
- 是否跨语言
- 消息的确认机制,如何避免消息重发
高级功能
- 消息的有序性
- 是否支持事务消息
- 消息收发的性能,对高并发大数据量的支持
- 是否支持集群
- 消息的可靠性存储
- 是否支持多协议
这个思考的过程其实就是做需求的整理,然后在使用已有的技术体系进行技术的实现.而我们所目前阶段所去了解的,无非就是别人根据实际需求进行实现之后,我们如何使用他们提供的 api 进行应用而已.但是有了这样一个全局的思考,那么对于后续学习这个技术本身而言,也显得很容易了
发展过程
实际上消息中间件的发展也是挺有意思的,我们知道任何一个技术的出现都是为了解决实际问题,这个问题是 通过一种通用的软件“总线”也就是一种通信系统,解决应用程序之间繁重的信息通信工作.最早的小白鼠就是金融交易领域,因为在当时这个领域中,交易员需要通过不同的终端完成交易,每台终端显示不同的信息.如果接入消息总线,那么交易员只需要在一台终端上操作,然后订阅其他终端感兴趣的消息.于是就诞生了发布订阅模型(pubsub),同时诞生了世界上第一个现代消息队列软件(TIB)The information Bus,TIB 允许开发者建立一系列规则去描述消息内容,只要消息按照这些规则发布出去,任何消费者应用都能订阅感兴趣的消息.随着 TIB 带来的甜头被广泛应用在各大领域,IBM 也开始研究开发自己的消息中间件,3 年后 IBM 的消息队列 IBM MQ 产品系列发布,之后的一段时间 MQ 系列进化成了 WebSphere MQ 统治商业消息队列平台市场
包括后期微软也研发了自己的消息队列(MSMQ)
各大厂商纷纷研究自己的 MQ,但是他们是以商业化模式运营自己的 MQ 软件,商业 MQ 想要解决的是应用互通的问题,而不是创建标准接口来允许不同 MQ 产品互通.所以有些大型的金融公司可能会使用来自多个供应商的 MQ 产品,来服务企业内部不同的应用.那么问题来了,如果应用已经订阅了 TIB MQ 的消息然后突然需要消费 IBM MQ 的消息,那么整个实现过程会很麻烦.为了解决这个问题,在 2001 年诞生了 Java Message Service(JMS),JMS 通过提供公共的 Java API 方式,隐藏单独 MQ 产品供应商的实现接口,从而跨越了不同 MQ 消费和解决互通问题.从技术层面来说,Java 应用程序只需要针对 JMS API 编程,选择合适的 MQ 驱动即可.JMS 会处理其他部分.这种方案实际上是通过单独标准化接口来整合很多不同的接口,效果还是不错的,但是碰到了互用性的问题.两套使用两种不同编程语言的程序如何通过它们的异步消息传递机制相互通信呢.这个时候就需要定义一个异步消息传递的通用标准
所以 AMQP(Advanced Message Queuing Protocol)高级消息队列协议产生了,它使用了一套标准的底层协议,加入了许多其他特征来支持互用性,为现代应用丰富了消息传递需求,针对标准编码的任何人都可以和任意 AMQP 供应商提供的 MQ 服务器进行交互
除了 JMS 和 AMQP 规范以外,还有一种 MQTT(Message Queueing Telemetry[特莱米缺] Transport),它是专门为小设备设计的.因为计算性能不高的设备不能适应 AMQP 上的复杂操作,它们需要一种简单而且可互用的方式进行通信.这是 MQTT 的基本要求,而如今,MQTT 是物联网(IOT)生态系统中主要成分之一
今天要讲解的 Kafka,它并没有遵循上面所说的协议规范,注重吞吐量,类似 udp 和 tcp
kafka 的介绍
本期讲解的 kafka 是基于 2.0 来讲解,所以有些内容会和之前课程讲的不太一样
什么是 Kafka
Kafka 是一款分布式消息发布和订阅系统,它的特点是高性能,高吞吐量
最早设计的目的是作为 LinkedIn 的活动流和运营数据的处理管道.这些数据主要是用来对用户做用户画像分析以及服务器性能数据的一些监控
所以 kafka 一开始设计的目标就是作为一个分布式,高吞吐量的消息系统,所以适合运用在大数据传输场景.所以 kafka 在我们大数据的课程里面也有讲解,但是在 Java 的课程中,我们仍然主要是讲解 kafka 作为分布式消息中间件来讲解.不会去讲解到数据流的处理这块
Kafka 的应用场景
由于 kafka 具有更好的吞吐量,内置分区,冗余及容错性的优点(kafka 每秒可以处理几十万消息),让 kafka 成为了一个很好的大规模消息处理应用的解决方案.所以在企业级应用长,主要会应用于如下几个方面
行为跟踪
kafka 可以用于跟踪用户浏览页面,搜索及其他行为.通过发布-订阅模式实时记录到对应的 topic 中,通过后端大数据平台接入处理分析,并做更进一步的实时处理和监控
日志收集
日志收集方面,有很多比较优秀的产品,比如 Apache Flume,很多公司使用 kafka 代理日志聚合.日志聚合表示从服务器上收集日志文件,然后放到一个集中的平台(文件服务器)进行处理.在实际应用开发中,我们应用程序的 log 都会输出到本地的磁盘上,排查问题的话通过 linux 命令来搞定,如果应用程序组成了负载均衡集群,并且集群的机器有几十台以上,那么想通过日志快速定位到问题,就是很麻烦的事情了.所以一般都会做一个日志统一收集平台管理 log 日志用来快速查询重要应用的问题.所以很多公司的套路都是把应用日志集中到 kafka 上,然后分别导入到 es 和 hdfs 上,用来做实时检索分析和离线统计数据备份等.而另一方面,kafka 本身又提供了很好的 api 来集成日志并且做日志收集
Kafka 本身的架构
一个典型的 kafka 集群包含若干 Producer(可以是应用节点产生的消息,也可以是通过 Flume 收集日志产生的事件),若干个 Broker(kafka 支持水平扩展),若干个 Consumer Group,以及一个 zookeeper 集群.kafka 通过 zookeeper 管理集群配置及服务协同.Producer 使用 push 模式将消息发布到 broker,consumer 通过监听使用 pull 模式从 broker 订阅并消费消息
多个 broker 协同工作,producer 和 consumer 部署在各个业务逻辑中.三者通过 zookeeper 管理协调请求和转发.这样就组成了一个高性能的分布式消息发布和订阅系统图上有一个细节是和其他 mq 中间件不同的点,producer 发送消息到 broker 的过程是 push,而 consumer 从 broker 消费消息的过程是 pull,主动去拉数据.而不是 broker 把数据主动发送给 consumer
名词解释
Broker
Kafka 集群包含一个或多个服务器,这种服务器被称为 broker.broker 端不维护数据的消费状态,提升了性能.直接使用磁盘进行存储,线性读写,速度快:避免了数据在 JVM 内存和系统内存之间的复制,减少耗性能的创建对象和垃圾回收
Producer
负责发布消息到 Kafka broker
Consumer
消息消费者,向 Kafka broker 读取消息的客户端,consumer 从 broker 拉取(pull)数据并进行处理
Topic
每条发布到 Kafka 集群的消息都有一个类别,这个类别被称为 Topic.(物理上不同 Topic 的消息分开存储,逻辑上一个 Topic 的消息虽然保存于一个或多个 broker 上但用户只需指定消息的 Topic 即可生产或消费数据而不必关心数据存于何处)
Partition
Parition 是物理上的概念,每个 Topic 包含一个或多个 Partition
Consumer Group
每个 Consumer 属于一个特定的 Consumer Group(可为每个 Consumer 指定 group name,若不指定 group name 则属于默认的 group)
Topic & Partition
Topic 在逻辑上可以被认为是一个 queue,每条消费都必须指定它的 Topic,可以简单理解为必须指明把这条消息放进哪个 queue 里.为了使得 Kafka 的吞吐率可以线性提高,物理上把 Topic 分成一个或多个 Partition,每个 Partition 在物理上对应一个文件夹,该文件夹下存储这个 Partition 的所有消息和索引文件.若创建 topic1 和 topic2 两个 topic,且分别有 13 个和 19 个分区,则整个集群上会相应会生成共 32 个文件夹(本文所用集群共 8 个节点,此处 topic1 和 topic2 replication-factor 均为 1)
Kafka 的安装部署
下载 kafka
https://archive.apache.org/dist/kafka/2.0.0/kafka_2.11-2.0.0.tgz
安装过程
安装过程非常简单,只需要解压就行,因为这个是编译好之后的可执行程序
tar -zxvf kafka_2.11-2.0.0.tgz 解压
配置 zookeeper
因为 kafka 依赖于 zookeeper 来做 master 选举一起其他数据的维护,所以需要先启动 zookeeper 节点
kafka 内置了 zookeeper 的服务,所以在 bin 目录下提供了这些脚本
zookeeper-server-start.sh
zookeeper-server-stop.sh
在 config 目录下,存在一些配置文件
zookeeper.properties server.properties
所以我们可以通过下面的脚本来启动 zk 服务,当然,也可以自己搭建 zk 的集群来实现
sh zookeeper-server-start.sh -daemon ../config/zookeeper.properties
启动和停止 kafka
修改 server.properties,增加 zookeeper 的配置
zookeeper.connect=localhost:2181
启动 kafka
sh kafka-server-start.sh -damoen config/server.properties
停止 kafka
sh kafka-server-stop.sh -daemon config/server.properties
kafka 的基本操作
创建 topic
sh kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 -- partitions 1 --topic test
Replication-factor 表示该 topic 需要在不同的 broker 中保存几份,这里设置成 1,表示在两个 broker 中保存两份 Partitions 分区数
查看 topic
sh kafka-topics.sh --list --zookeeper localhost:2181
查看 topic 属性
sh kafka-topics.sh --describe --zookeeper localhost:2181 --topic first_topic
消费消息
sh kafka-console-consumer.sh --bootstrap-server 192.168.13.106:9092 --topic test --from-beginning
发送消息
sh kafka-console-producer.sh --broker-list 192.168.244.128:9092 --topic first_topic
集群环境安装
环境准备
- 准备三台虚拟机
- 分别把 kafka 的安装包部署在三台机器上
修改配置
以下配置修改均为 server.properties
- 分别修改三台机器的 server.properties 配置,同一个集群中的每个机器的 id 必须唯一
broker.id=0
broker.id=1
broker.id=2
- 修改 zookeeper 的连接配置
zookeeper.connect=192.168.13.106:2181
- 修改 listeners 配置
如果配置了 listeners,那么消息生产者和消费者会使用 listeners 的配置来进行消息的收发,否则,会使用 localhost
PLAINTEXT 表示协议,默认是明文,可以选择其他加密协议
listeners=PLAINTEXT://192.168.13.102:9092
- 分别启动三台服务器
sh kafka-server-start.sh -daemon ../config/server.properties
消息中间件能做什么
消息中间件主要解决的就是分布式系统之间消息传递的问题,它能够屏蔽各种平台以及协议之间的特性,实现应用程序之间的协同.举个非常简单的例子,就拿一个电商平台的注册功能来简单分析下,用户注册这一个服务,不单单只是 insert 一条数据到数据库里面就完事了,还需要发送激活邮件,发送新人红包或者积分,发送营销短信等一系列操作.假如说这里面的每一个操作,都需要消耗 1s,那么整个注册过程就需要耗时 4s 才能响应给用户
但是我们从注册这个服务可以看到,每一个子操作都是相对独立的,同时,基于领域划分以后,发送激活邮件,发送营销短信,赠送积分及红包都属于不同的子域.所以我们可以对这些子操作进行来实现异步化执行,类似于多线程并行处理的概念
如何实现异步化呢?用多线程能实现吗?多线程当然可以实现,只是,消息的持久化,消息的重发这些条件,多线程并不能满足.所以需要借助一些开源中间件来解决.而分布式消息队列就是一个非常好的解决办法,引入分布式消息队列以后,架构图就变成这样了(下图是异步消息队列的场景).通过引入分布式队列,就能够大大提升程序的处理效率,并且还解决了各个模块之间的耦合问题
-
这个是分布式消息队列的第一个解决场景【异步处理】
我们再来展开一种场景,通过分布式消息队列来实现流量整形,比如在电商平台的秒杀场景下,流量会
非常大.通过消息队列的方式可以很好的缓解高流量的问题
- 用户提交过来的请求,先写入到消息队列.消息队列是有长度的,如果消息队列长度超过指定长度,直接抛弃
- 秒杀的具体核心处理业务,接收消息队列中消息进行处理,这里的消息处理能力取决于消费端本身的吞吐量
当然,消息中间件还有更多应用场景,比如在弱一致性事务模型中,可以采用分布式消息队列的实现最大能力通知方式来实现数据的最终一致性等等
Java 中使用 kafka 进行通信
依赖
org.apache.kafka
kafka-clients
2.0.0
发送端代码
public class Producer extends Thread{
private final KafkaProducer producer;
private final String topic;
public Producer(String topic){
Properties properties = new Properties();
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"192.168.13.102:9092,192 .168.13.103:9092,192.168.13.104:9092");
properties.put(ProducerConfig.CLIENT_ID_CONFIG,"practice-producer");
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, IntegerSerializer.class.getName());
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
producer = new KafkaProducer(properties);
this.topic = topic;
}
@Override
public void run(){
int num=0;
while(num < 50){
String msg = "pratice test message:" + num;
try {
producer.send(new ProducerRecord (topic,msg)).get();
TimeUnit.SECONDS.sleep(2);
num++;
} catch (InterruptedException e){
e.printStackTrace();
} catch (ExecutionException e){
e.printStackTrace();
}
}
}
public static void main(String[] args){
new Producer("test").start();
}
}
消费端代码
public class Consumer extends Thread{
private final KafkaConsumer consumer;
private final String topic;
public Consumer(String topic){
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"192.168.13.102:9092,192 .168.13.103:9092,192.168.13.104:9092");
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "practice-consumer");
//设置 offset 自动提交
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
// 自动提交间隔时间
properties.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
properties.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.IntegerDeserializer");
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
//对于 当前 groupid 来说,消息的 offset 从最早的消息开始消费
properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,"earliest");
consumer = new KafkaConsumer<>(properties);
this.topic=topic;
}
@Override
public void run(){
while(true){
consumer.subscribe(Collections.singleton(this.topic));
ConsumerRecords records = consumer.poll(Duration.ofSeconds(1));
records.forEach(record -> {
System.out.println(record.key()+ " " + record.value()+ " -> offset:" + record.offset());
});
}
}
public static void main(String[] args){
new Consumer("test").start();
}
}
异步发送
kafka 对于消息的发送,可以支持同步和异步,前面演示的案例中,我们是基于同步发送消息.同步会需要阻塞,而异步不需要等待阻塞的过程
从本质上来说,kafka 都是采用异步的方式来发送消息到 broker,但是 kafka 并不是每次发送消息都会直接发送到 broker 上,而是把消息放到了一个发送队列中,然后通过一个后台线程不断从队列取出消息进行发送,发送成功后会触发 callback.kafka 客户端会积累一定量的消息统一组装成一个批量消息发送出去,触发条件是前面提到的 batch.size 和 linger.ms
而同步发送的方法,无非就是通过 future.get()来等待消息的发送返回结果,但是这种方法会严重影响消息发送的性能
batch.size
public void run(){
int num = 0;
while(num < 50){
String msg = "pratice test message:" + num;
try {
producer.send(new ProducerRecord<>(topic, msg), new Callback(){
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e){
System.out.println("callback: " + recordMetadata.offset()+ "->" + recordMetadata.partition());
}
});
TimeUnit.SECONDS.sleep(2);
num++;
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
生产者发送多个消息到 broker 上的同一个分区时,为了减少网络请求带来的性能开销,通过批量的方式来提交消息,可以通过这个参数来控制批量提交的字节数大小,默认大小是 16384byte,也就是 16kb,意味着当一批消息大小达到指定的 batch.size 的时候会统一发送
linger.ms
Producer 默认会把两次发送时间间隔内收集到的所有 Requests 进行一次聚合然后再发送,以此提高吞吐量,而 linger.ms 就是为每次发送到 broker 的请求增加一些 delay,以此来聚合更多的 Message 请求.这个有点想 TCP 里面的 Nagle 算法,在 TCP 协议的传输中,为了减少大量小数据包的发送,采用了 Nagle 算法,也就是基于小包的等-停协议
batch.size 和 linger.ms 这两个参数是 kafka 性能优化的关键参数,很多同学会发现 batch.size 和 linger.ms 这两者的作用是一样的,如果两个都配置了,那么怎么工作的呢?实际上,当二者都配置的时候,只要满足其中一个要求,就会发送请求到 broker 上
一些基础配置分析
group.id
consumer group 是 kafka 提供的可扩展且具有容错性的消费者机制.既然是一个组,那么组内必然可以有多个消费者或消费者实例(consumer instance),它们共享一个公共的 ID,即 group ID.组内的所有消费者协调在一起来消费订阅主题(subscribed topics)的所有分区(partition).当然,每个分区只能由同一个消费组内的一个 consumer 来消费.如下图所示,分别有三个消费者,属于两个不同的 group,那么对于 firstTopic 这个 topic 来说,这两个组的消费者都能同时消费这个 topic 中的消息,对于此事的架构来说,这个 firstTopic 就类似于 ActiveMQ 中的 topic 概念.如右图所示,如果 3 个消费者都属于同一个 group,那么此事 firstTopic 就是一个 Queue 的概念
enable.auto.commit
消费者消费消息以后自动提交,只有当消息提交以后,该消息才不会被再次接收到,还可以配合 auto.commit.interval.ms
控制自动提交的频率
当然,我们也可以通过 consumer.commitSync()的方式实现手动提交
auto.offset.reset
这个参数是针对新的 groupid 中的消费者而言的,当有新 groupid 的消费者来消费指定的 topic 时,对于该参数的配置,会有不同的语义
auto.offset.reset=latest 情况下,新的消费者将会从其他消费者最后消费的 offset 处开始消费 Topic 下的消息
auto.offset.reset= earliest 情况下,新的消费者会从该 topic 最早的消息开始消费
auto.offset.reset=none 情况下,新的消费者加入以后,由于之前不存在 offset,则会直接抛出异常
max.poll.records
此设置限制每次调用 poll 返回的消息数,这样可以更容易的预测每次 poll 间隔要处理的最大值.通过调整此值,可以减少 poll 间隔
Springboot + kafka
springboot 的版本和 kafka 的版本,有一个对照表格,如果没有按照正确的版本来引入,那么会存在版本问题导致 ClassNotFound 的问题,具体请参考
https://spring.io/projects/spring-kafka
jar 包依赖
org.springframework.kafka
spring-kafka
2.2.0.RELEASE
KafkaProducer
@Component
public class KafkaProducer {
@Autowired
private KafkaTemplate kafkaTemplate;
public void send(){
kafkaTemplate.send("test","msgKey","msgData");
}
}
KafkaConsumer
@Component
public class KafkaConsumer {
@KafkaListener(topics = {"test"})
public void listener(ConsumerRecord record){
Optional msg = Optional.ofNullable(record.value());
if (msg.isPresent()){
System.out.println(msg.get());
}
}
}
application 配置
spring.kafka.bootstrap-servers=192.168.13.102:9092,192.168.13.103:9092,192.168.13.104:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.consumer.group-id=test-consumer-group
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.enable-auto-commit=true
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer
测试
public static void main(String[] args){
ConfigurableApplicationContext context = SpringApplication.run(KafkaDemoApplication.class, args);
KafkaProducer kafkaProducer = context.getBean(KafkaProducer.class);
for (int i = 0; i < 3; i++){
kafkaProducer.send();
try {
Thread.sleep(3000);
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
原理分析
从前面的整个演示过程来看,只要不是超大规模的使用 kafka,那么基本上没什么大问题,否则,对于 kafka 本身的运维的挑战会很大,同时,针对每一个参数的调优也显得很重要
据我了解,快手在使用 kafka 集群规模是挺大的,他们在 19 年的开发者大会上有提到,总机器数大概 2000 台;30 多个集群;topic 12000 个;一共大概 20 万 TP(topic partition);每天总处理的消息 数超过 4 万亿条;峰值超过 1 亿条
技术的使用是最简单的,要想掌握核心价值,就势必要了解一些原理,在设计这个课程的时候,我想了很久应该从哪个地方着手,最后还是选择从最基础的消息通讯的原理着手
关于 Topic 和 Partition
Topic
在 kafka 中,topic 是一个存储消息的逻辑概念,可以认为是一个消息集合.每条消息发送到 kafka 集群的消息都有一个类别.物理上来说,不同的 topic 的消息是分开存储的,每个 topic 可以有多个生产者向它发送消息,也可以有多个消费者去消费其中的消息
Partition
每个 topic 可以划分多个分区(每个 Topic 至少有一个分区),同一 topic 下的不同分区包含的消息是不同的.每个消息在被添加到分区时,都会被分配一个 offset(称之为偏移量),它是消息在此分区中的唯一编号,kafka 通过 offset 保证消息在分区内的顺序,offset 的顺序不跨分区,即 kafka 只保证在同一个分区内的消息是有序的
下图中,对于名字为 test 的 topic,做了 3 个分区,分别是 p0,p1,p2
-
每一条消息发送到 broker 时,会根据 partition 的规则选择存储到哪一个 partition.如果 partition 规则设置合理,那么所有的消息会均匀的分布在不同的 partition 中,这样就有点类似数据库的分库分表的概念,把数据做了分片处理
Topic&Partition 的存储
Partition 是以文件的形式存储在文件系统中,比如创建一个名为 firstTopic 的 topic,其中有 3 个 partition,那么在 kafka 的数据目录(/tmp/kafka-log)中就有 3 个目录,firstTopic-0~3,命名规则是
sh kafka-topics.sh --create --zookeeper 192.168.11.156:2181 --replication-factor 1 --partitions 3 --topic firstTopic
关于消息分发
kafka 消息分发策略
消息是 kafka 中最基本的数据单元,在 kafka 中,一条消息由 key,value 两部分构成,在发送一条消息时,我们可以指定这个 key,那么 producer 会根据 key 和 partition 机制来判断当前这条消息应该发送并存储到哪个 partition 中.我们可以根据需要进行扩展 producer 的 partition 机制
代码演示
自定义 Partitioner
public class MyPartitioner implements Partitioner {
private Random random = new Random();
@Override
public int partition(String s, Object o, byte[] bytes, Object o1, byte[] bytes1, Cluster cluster){
//获取集群中指定 topic 的所有分区信息
List partitionInfos = cluster.partitionsForTopic(s);
int numOfPartition = partitionInfos.size();
int partitionNum = 0;
if (o == null){
//key 没有设置
partitionNum = random.nextInt(numOfPartition);
//随机指定分区
} else {
partitionNum = Math.abs((o1.hashCode()))% numOfPartition;
}
System.out.println("key->" + o + ",value->" + o1 + "->send to partition:" + partitionNum);
return partitionNum;
}
}
发送端代码添加自定义分区
public KafkaProducerDemo(String topic,boolean isAysnc){
Properties properties = new Properties();
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.13.102:9092,192.168.13.103:9092,192.168.13.104:9092");
properties.put(ProducerConfig.CLIENT_ID_CONFIG,"KafkaProducerDemo");
properties.put(ProducerConfig.ACKS_CONFIG,"-1");
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.IntegerSerializer");
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,"com.gupaoedu.kafka.MyPa rtitioner");
producer = new KafkaProducer(properties);
this.topic=topic;
this.isAysnc=isAysnc;
}
消息默认的分发机制
默认情况下,kafka 采用的是 hash 取模的分区算法.如果 Key 为 null,则会随机分配一个分区.这个随机是在这个参数”metadata.max.age.ms”的时间范围内随机选择一个.对于这个时间段内,如果 key 为 null,则只会发送到唯一的分区.这个值值哦默认情况下是 10 分钟更新一次
关于 Metadata,这个之前没讲过,简单理解就是 Topic/Partition 和 broker 的映射关系,每一个 topic 的每一个 partition,需要知道对应的 broker 列表是什么,leader 是谁,follower 是谁.这些信息都是存储在 Metadata 这个类里面
消费端如何消费指定的分区
通过下面的代码,就可以消费指定该 topic 下的 0 号分区.其他分区的数据就无法接收
//消费指定分区的时候,不需要再订阅
//kafkaConsumer.subscribe(Collections.singletonList(topic));
//消费指定的分区
TopicPartition topicPartition = new TopicPartition(topic,0);
kafkaConsumer.assign(Arrays.asList(topicPartition));
消息的消费原理
kafka 消息消费原理演示
在实际生产过程中,每个 topic 都会有多个 partitions,多个 partitions 的好处在于,一方面能够对 broker 上的数据进行分片有效减少了消息的容量从而提升 io 性能.另外一方面,为了提高消费端的消费能力,一般会通过多个 consumer 去消费同一个 topic ,也就是消费端的负载均衡机制,也就是我们接下来要了解的,在多个 partition 以及多个 consumer 的情况下,消费者是如何消费消息的
同时,在上一节课,我们讲了,kafka 存在 consumer group 的概念,也就是 group.id 一样的 consumer,这些 consumer 属于一个 consumer group,组内的所有消费者协调在一起来消费订阅主题的所有分区.当然每一个分区只能由同一个消费组内的 consumer 来消费,那么同一个 consumer group 里面的 consumer 是怎么去分配该消费哪个分区里的数据的呢?如下图所示,3 个分区,3 个消费者,那么哪个消费者消分哪个分区?
对于上面这个图来说,这 3 个消费者会分别消费 test 这个 topic 的 3 个分区,也就是每个 consumer 消费一个 partition
代码演示(3 个 partiton 对应 3 个 consumer)
- 创建一个带 3 个分区的 topic
- 启动 3 个消费者消费同一个 topic,并且这 3 个 consumer 属于同一个组
- 启动发送者进行消息发送
演示结果:consumer1 会消费 partition0 分区,consumer2 会消费 partition1 分区,consumer3 会消费 partition2 分区
如果是 2 个 consumer 消费 3 个 partition 呢?会是怎么样的结果?
代码演示(3 个 partiton 对应 2 个 consumer)
- 基于上面演示的案例的 topic 不变
- 启动 2 个消费这消费该 topic
- 启动发送者进行消息发送
演示结果:consumer1 会消费 partition0/partition1 分区,consumer2 会消费 partition2 分区
代码演示(3 个 partition 对应 4 个或以上 consumer)
演示结果:仍然只有 3 个 consumer 对应 3 个 partition,其他的 consumer 无法消费消息
通过这个演示的过程,我希望引出接下来需要了解的 kafka 的分区分配策略(Partition Assignment Strategy)
consumer 和 partition 的数量建议
- 如果 consumer 比 partition 多,是浪费,因为 kafka 的设计是在一个 partition 上是不允许并发的,所以 consumer 数不要大于 partition 数
- 如果 consumer 比 partition 少,一个 consumer 会对应于多个 partitions,这里主要合理分配 consumer 数和 partition 数,否则会导致 partition 里面的数据被取的不均匀.最好 partiton 数目是 consumer 数目的整数倍,所以 partition 数目很重要,比如取 24,就很容易设定 consumer 数目
- 如果 consumer 从多个 partition 读到数据,不保证数据间的顺序性,kafka 只保证在一个 partition 上数据是有序的,但多个 partition,根据你读的顺序会有不同
- 增减 consumer,broker,partition 会导致 rebalance,所以 rebalance 后 consumer 对应的 partition 会发生变化
什么时候会触发这个策略呢?
当出现以下几种情况时,kafka 会进行一次分区分配操作,也就是 kafka consumer 的 rebalance
- 同一个 consumer group 内新增了消费者
- 消费者离开当前所属的 consumer group,比如主动停机或者宕机
- topic 新增了分区(也就是分区数量发生了变化)
kafka consuemr 的 rebalance 机制规定了一个 consumer group 下的所有 consumer 如何达成一致来分配订阅 topic 的每个分区.而具体如何执行分区策略,就是前面提到过的两种内置的分区策略.而 kafka 对于分配策略这块,提供了可插拔的实现方式,也就是说,除了这两种之外,我们还可以创建自己的分配机制
什么是分区分配策略
通过前面的案例演示,我们应该能猜到,同一个 group 中的消费者对于一个 topic 中的多个 partition,存在一定的分区分配策略
在 kafka 中,存在三种分区分配策略,一种是 Range(默认),另一种是 RoundRobin(轮询),StickyAssignor(粘性).在消费端中的 ConsumerConfig 中,通过这个属性来指定分区分配策略
public static final String PARTITION_ASSIGNMENT_STRATEGY_CONFIG = "partition.assignment.strategy";
RangeAssignor(范围分区)
Range 策略是对每个主题而言的,首先对同一个主题里面的分区按照序号进行排序,并对消费者按照字母顺序进行排序
假设 n = 分区数/消费者数量
m= 分区数%消费者数量
那么前 m 个消费者每个分配 n+l 个分区,后面的(消费者数量-m)个消费者每个分配 n 个分区
假设我们有 10 个分区,3 个消费者,排完序的分区将会是 0,1,2,3,4,5,6,7,8,9;消费者线程排完序将会是 C1-0,C2-0,C3-0.然后将 partitions 的个数除于消费者线程的总数来决定每个消费者线程消费几个分区.如果除不尽,那么前面几个消费者线程将会多消费一个分区.在我们的例子里面,我们有 10 个分区,3 个消费者线程,10 / 3 = 3,而且除不尽,那么消费者线程 C1-0 将会多消费一个分区的结果看起来是这样的:
C1-0 将消费 0,1,2,3 分区
C2-0 将消费 4,5,6 分区
C3-0 将消费 7,8,9 分区
假如我们有11个分区,那么最后分区分配的结果看起来是这样的:
C1-0 将消费 0,1,2,3 分区
C2-0 将消费 4,5,6,7 分区
C3-0 将消费 8,9,10 分区
假如我们有 2 个主题(T1 和 T2),分别有 10 个分区,那么最后分区分配的结果看起来是这样的:
C1-0 将消费 T1 主题的 0,1,2,3 分区以及 T2 主题的 0,1,2,3 分区
C2-0 将消费 T1 主题的 4,5,6 分区以及 T2 主题的 4,5,6 分区
C3-0 将消费 T1 主题的 7,8,9 分区以及 T2 主题的 7,8,9 分区
可以看出,C1-0 消费者线程比其他消费者线程多消费了 2 个分区,这就是 Range strategy 的一个很明显的弊端
RoundRobinAssignor(轮询分区)
轮询分区策略是把所有 partition 和所有 consumer 线程都列出来,然后按照 hashcode 进行排序.最后通过轮询算法分配 partition 给消费线程.如果所有 consumer 实例的订阅是相同的,那么 partition 会均匀分布
在我们的例子里面,假如按照 hashCode 排序完的 topic-partitions 组依次为 T1-5,T1-3,T1-0,T1-8,T1-2,T1-1,T1-4,T1-7,T1-6,T1-9,我们的消费者线程排序为 C1-0,C1-1,C2-0,C2-1,最后分区分配的结果为:
C1-0 将消费 T1-5,T1-2,T1-6 分区;
C1-1 将消费 T1-3,T1-1,T1-9 分区;
C2-0 将消费 T1-0,T1-4 分区;
C2-1 将消费 T1-8,T1-7 分区;
使用轮询分区策略必须满足两个条件
- 每个主题的消费者实例具有相同数量的流
- 每个消费者订阅的主题必须是相同的
StrickyAssignor 分配策略
kafka 在 0.11.x 版本支持了 StrickyAssignor,翻译过来叫粘滞策略,它主要有两个目的
- 分区的分配尽可能的均匀
- 分区的分配尽可能和上次分配保持相同
当两者发生冲突时,第一个目标优先于第二个目标.鉴于这两个目标,StickyAssignor 分配策略的具体实现要比 RangeAssignor 和 RoundRobinAssi gn or 这两种分配策略要复杂得多,假设我们有这样一个场景
假设消费组有 3 个消费者:C0,C1,C2,它们分别订阅了 4 个 Topic(t0,t1,t2,t3),并且每个主题有两个分区(p0,p1),也就是说,整个消费组订阅了 8 个分区:tOpO ,tOpl ,tlpO ,tlpl ,t2p0 ,t2pl ,t3p0 ,t3pl
那么最终的分配场景结果为
CO: tOpO,tlpl ,t3p0
Cl: tOpl,t2p0 ,t3pl
C2: tlpO,t2pl
这种分配方式有点类似于轮询策略,但实际上并不是,因为假设这个时候,C1 这个消费者挂了,就势必会造成重新分区(reblance),如果是轮询,那么结果应该是
CO: tOpO,tlpO,t2p0,t3p0
C2: tOpl,tlpl,t2pl,t3pl
然后,strickyAssignor 它是一种粘滞策略,所以它会满足分区的分配尽可能和上次分配保持相同
,所以分配结果应该是
消费者 CO: tOpO,tlpl ,t3p0,t2p0
消费者 C2: tlpO,t2pl,tOpl,t3pl
也就是说,C0 和 C2 保留了上一次是的分配结果,并且把原来 C1 的分区分配给了 C0 和 C2.这种策略的好处是 使得分区发生变化时,由于分区的“粘性,减少了不必要的分区移动
谁来执行 Rebalance 以及管理 consumer 的 group 呢?
Kafka 提供了一个角色:coordinator 来执行对于 consumer group 的管理,Kafka 提供了一个角色:coordinator 来执行对于 consumer group 的管理,当 consumer group 的第一个 consumer 启动的时候,它会去和 kafka server 确定谁是它们组的 coordinator.之后该 group 内的所有成员都会和该 coordinator 进行协调通信
如何确定 coordinator
consumer group 如何确定自己的 coordinator 是谁呢,消费者向 kafka 集群中的任意一个 broker 发送一个 GroupCoordinatorRequest 请求,服务端会返回一个负载最小的 broker 节点的 id,并将该 broker 设置为 coordinator
JoinGroup 的过程
在 rebalance 之前,需要保证 coordinator 是已经确定好了的,整个 rebalance 的过程分为两个步骤,Join 和 Sync
-
join: 表示加入到 consumer group 中,在这一步中,所有的成员都会向 coordinator 发送 joinGroup 的请求.一旦所有成员都发送了 joinGroup 请求,那么 coordinator 会选择一个 consumer 担任 leader 角色,并把组成员信息和订阅信息发送消费者
leader 选举算法比较简单,如果消费组内没有 leader,那么第一个加入消费组的消费者就是消费者 leader,如果这个时候 leader 消费者退出了消费组,那么重新选举一个 leader,这个选举很随意,类似于随机算法
protocol_metadata: 序列化后的消费者的订阅信息
leader_id: 消费组中的消费者,coordinator 会选择一个座位 leader,对应的就是 member_id
member_metadata 对应消费者的订阅信息
members:consumer group 中全部的消费者的订阅信息
generation_id: 年代信息,类似于之前讲解 zookeeper 的时候的 epoch 是一样的,对于每一轮 rebalance,generation_id 都会递增.主要用来保护 consumer group.隔离无效的 offset 提交.也就是上一轮的 consumer 成员无法提交 offset 到新的 consumer group 中
每个消费者都可以设置自己的分区分配策略,对于消费组而言,会从各个消费者上报过来的分区分配策略中选举一个彼此都赞同的策略来实现整体的分区分配,这个"赞同"的规则是,消费组内的各个消费者会通过投票来决定
- 在 joingroup 阶段,每个 consumer 都会把自己支持的分区分配策略发送到 coordinator
- coordinator 手机到所有消费者的分配策略,组成一个候选集
- 每个消费者需要从候选集里找出一个自己支持的策略,并且为这个策略投票
- 最终计算候选集中各个策略的选票数,票数最多的就是当前消费组的分配策略
Synchronizing Group State 阶段
完成分区分配之后,就进入了 Synchronizing Group State 阶段,主要逻辑是向 GroupCoordinator 发送 SyncGroupRequest 请求,并且处理 SyncGroupResponse 响应,简单来说,就是 leader 将消费者对应的 partition 分配方案同步给 consumer group 中的所有 consumer
每个消费者都会向 coordinator 发送 syncgroup 请求,不过只有 leader 节点会发送分配方案,其他消费者只是打打酱油而已.当 leader 把方案发给 coordinator 以后,coordinator 会把结果设置到 SyncGroupResponse 中.这样所有成员都知道自己应该消费哪个分区
consumer group 的分区分配方案是在客户端执行的!Kafka 将这个权利下放给客户端主要是因为这样做可以有更好的灵活性
总结
我们再来总结一下 consumer group rebalance 的过程
- 对于每个 consumer group 子集,都会在服务端对应一个 GroupCoordinator 进行管理,GroupCoordinator 会在 zookeeper 上添加 watcher,当消费者加入或者退出 consumer group 时,会修改 zookeeper 上保存的数据,从而触发 GroupCoordinator 开始 Rebalance 操作
- 当消费者准备加入某个 Consumer group 或者 GroupCoordinator 发生故障转移时,消费者并不知道 GroupCoordinator 的在网络中的位置,这个时候就需要确定 GroupCoordinator,消费者会向集群中的任意一个 Broker 节点发送 ConsumerMetadataRequest 请求,收到请求的 broker 会返回一个 response 作为响应,其中包含管理当前 ConsumerGroup 的 GroupCoordinator
- 消费者会根据 broker 的返回信息,连接到 groupCoordinator,并且发送 HeartbeatRequest,发送心跳的目的是要要奥噶苏 GroupCoordinator 这个消费者是正常在线的.当消费者在指定时间内没有发送心跳请求,则 GroupCoordinator 会触发 Rebalance 操作
- 发起 join group 请求,两种情况
- 如果 GroupCoordinator 返回的心跳包数据包含异常,说明 GroupCoordinator 因为前面说的几种情况导致了 Rebalance 操作,那这个时候,consumer 会发起 join group 请求
- 新加入到 consumer group 的 consumer 确定好了 GroupCoordinator 以后消费者会向 GroupCoordinator 发起 join group 请求,GroupCoordinator 会收集全部消费者信息之后,来确认可用的消费者,并从中选取一个消费者成为 group_leader.并把相应的信息(分区分配策略,leader_id,…)封装成 response 返回给所有消费者,但是只有 group leader 会收到当前 consumer group 中的所有消费者信息.当消费者确定自己是 group leader 以后,会根据消费者的信息以及选定分区分配策略进行分区分配
- 接着进入 Synchronizing Group State 阶段,每个消费者会发送 SyncGroupRequest 请求到 GroupCoordinator,但是只有 Group Leader 的请求会存在分区分配结果,GroupCoordinator 会根据 Group Leader 的分区分配结果形成 SyncGroupResponse 返回给所有的 Consumer
- consumer 根据分配结果,执行相应的操作
到这里为止,我们已经知道了消息的发送分区策略,以及消费者的分区消费策略和 rebalance.对于应用层面来说,还有一个最重要的东西没有讲解,就是 offset,他类似一个游标,表示当前消费的消息的位置
如何保存消费端的消费位置
什么是 offset
前面在讲解 partition 的时候,提到过 offset,每个 topic 可以划分多个分区(每个 Topic 至少有一个分区),同一 topic 下的不同分区包含的消息是不同的.每个消息在被添加到分区时,都会被分配一个 offset(称之为偏移量),它是消息在此分区中的唯一编号,kafka 通过 offset 保证消息在分区内的顺序,offset 的顺序不跨分区,即 kafka 只保证在同一个分区内的消息是有序的; 对于应用层的消费来说,每次消费一个消息并且提交以后,会保存当前消费到的最近的一个 offset.那么 offset 保存在哪里?
offset 在哪里维护?
在 kafka 中,提供了一个 consumer_offsets_* 的一个 topic,把 offset 信息写入到这个 topic 中
consumer_offsets——按保存了每个 consumer group 某一时刻提交的 offset 信息.__consumer_offsets 默认有 50 个分区
根据前面我们演示的案例,我们设置了一个 KafkaConsumerDemo 的 groupid.首先我们需要找到这个 consumer_group 保存在哪个分区中
properties.put(ConsumerConfig.GROUP_ID_CONFIG,"KafkaConsumerDemo");
计算公式
- Math.abs(“groupid”.hashCode())%groupMetadataTopicPartitionCount ; 由于默认情况下 groupMetadataTopicPartitionCount 有 50 个分区,计算得到的结果为:35,意味着当前的 consumer_group 的位移信息保存在__consumer_offsets 的第 35 个分区
- 执行如下命令,可以查看当前 consumer_goup 中的 offset 位移提交的信息
kafka-console-consumer.sh --topic __consumer_offsets --partition 15 -- bootstrap-server 192.168.13.102:9092,192.168.13.103:9092,192.168.13.104:9092 --formatter 'kafka.coordinator.group.GroupMetadataManager$OffsetsMessageFormatter'
从输出结果中,我们就可以看到 test 这个 topic 的 offset 的位移日志
分区的副本机制
我们已经知道 Kafka 的每个 topic 都可以分为多个 Partition,并且多个 partition 会均匀分布在集群的各个节点下.虽然这种方式能够有效的对数据进行分片,但是对于每个 partition 来说,都是单点的,当其中一个 partition 不可用的时候,那么这部分消息就没办法消费.所以 kafka 为了提高 partition 的可靠性而提供了副本的概念(Replica),通过副本机制来实现冗余备份
每个分区可以有多个副本,并且在副本集合中会存在一个 leader 的副本,所有的读写请求都是由 leader 副本来进行处理.剩余的其他副本都做为 follower 副本,follower 副本会从 leader 副本同步消息日志.这个有点类似 zookeeper 中 leader 和 follower 的概念,但是具体的时间方式还是有比较大的差异.所以我们可以认为,副本集会存在一主多从的关系
一般情况下,同一个分区的多个副本会被均匀分配到集群中的不同 broker 上,当 leader 副本所在的 broker 出现故障后,可以重新选举新的 leader 副本继续对外提供服务.通过这样的副本机制来提高 kafka 集群的可用性
创建一个带副本机制的 topic
通过下面的命令去创建带 2 个副本的 topic
sh kafka-topics.sh --create --zookeeper 192.168.11.156:2181 --replication-factor 3 --partitions 3 --topic secondTopic
然后我们可以在/tmp/kafka-log 路径下看到对应 topic 的副本信息了.我们通过一个图形的方式来表达
针对 secondTopic 这个 topic 的 3 个分区对应的 3 个副本
如何知道那个各个分区中对应的 leader 是谁呢?
在 zookeeper 服务器上,通过如下命令去获取对应分区的信息,比如下面这个是获取 secondTopic 第 1 个分区的状态信息
get /brokers/topics/secondTopic/partitions/1/state
{"controller_epoch":12,"leader":0,"version":1,"leader_epoch":0,"isr":[0,1]}
或通过这个命令 sh kafka-topics.sh --zookeeper 192.168.13.106:2181 --describe --topic test_partition
leader 表示当前分区的 leader 是那个 broker-id.下图中.绿色线条的表示该分区中的 leader 节点.其他节点就为 follower
需要注意的是,kafka 集群中的一个 broker 中最多只能有一个副本,leader 副本所在的 broker 节点的分区叫 leader 节点,follower 副本所在的 broker 节点的分区叫 follower 节点
副本的 leader 选举
Kafka 提供了数据复制算法保证,如果 leader 副本所在的 broker 节点宕机或者出现故障,或者分区的 leader 节点发生故障,这个时候怎么处理呢?
那么,kafka 必须要保证从 follower 副本中选择一个新的 leader 副本.那么 kafka 是如何实现选举的呢?要了解 leader 选举,我们需要了解几个概念
Kafka 分区下有可能有很多个副本(replica)用于实现冗余,从而进一步实现高可用.副本根据角色的不同可分为 3 类:
- leader 副本:响应 clients 端读写请求的副本
- follower 副本:被动地备份 leader 副本中的数据,不能响应 clients 端读写请求.
- ISR 副本:包含了 leader 副本和所有与 leader 副本保持同步的 follower 副本——如何判定是否与 leader 同步后面会提到每个 Kafka 副本对象都有两个重要的属性:LEO 和 HW.注意是所有的副本,而不只是 leader 副本
- LEO:即日志末端位移(log end offset),记录了该副本底层日志(log)中下一条消息的位移值.注意是下一条消息!也就是说,如果 LEO=10,那么表示该副本保存了 10 条消息,位移值范围是[0,9].另外,leader LEO 和 follower LEO 的更新是有区别的.我们后面会详细说
- HW:即上面提到的水位值.对于同一个副本对象而言,其 HW 值不会大于 LEO 值.小于等于 HW 值的所有消息都被认为是“已备份”的(replicated).同理,leader 副本和 follower 副本的 HW 更新是有区别的
从生产者发出的 一 条消息首先会被写入分区的 leader 副本,不过还需要等待 ISR 集合中的所有 follower 副本都同步完之后才能被认为已经提交,之后才会更新分区的 HW,进而消费者可以消费到这条消息
副本协同机制
刚刚提到了,消息的读写操作都只会由 leader 节点来接收和处理.follower 副本只负责同步数据以及当 leader 副本所在的 broker 挂了以后,会从 follower 副本中选取新的 leader
写请求首先由 Leader 副本处理,之后 follower 副本会从 leader 上拉取写入的消息,这个过程会有一定的延迟,导致 follower 副本中保存的消息略少于 leader 副本,但是只要没有超出阈值都可以容忍.但是如果一个 follower 副本出现异常,比如宕机,网络断开等原因长时间没有同步到消息,那这个时候,leader 就会把它踢出去.kafka 通过 ISR 集合来维护一个分区副本信息
一个新 leader 被选举并被接受客户端的消息成功写入.Kafka 确保从同步副本列表中选举一个副本为 leader;leader 负责维护和跟踪 ISR(in-Sync replicas ,副本同步队列)中所有 follower 滞后的状态.当 producer 发送一条消息到 broker 后,leader 写入消息并复制到所有 follower.消息提交之后才被成功复制到所有的同步副本
ISR
ISR 表示目前“可用且消息量与 leader 相差不多的副本集合,这是整个副本集合的一个子集”.怎么去理解可用和相差不多这两个词呢?具体来说,ISR 集合中的副本必须满足两个条件
- 副本所在节点必须维持着与 zookeeper 的连接
- 副本最后一条消息的 offset 与 leader 副本的最后一条消息的 offset 之间的差值不能超过指定的阈值(replica.lag.time.max.ms)replica.lag.time.max.ms:如果该 follower 在此时间间隔内一直没有追上过 leader 的所有消息,则该 follower 就会被剔除 isr 列表
- ISR 数据保存在 Zookeeper 的 /brokers/topics/
/partitions/ /state 节点中
follower 副本把 leader 副本 LEO 之前的日志全部同步完成时,则认为 follower 副本已经追赶上了 leader 副本,这个时候会更新这个副本的 lastCaughtUpTimeMs 标识,kafk 副本管理器会启动一个副本过期检查的定时任务,这个任务会定期检查当前时间与副本的 lastCaughtUpTimeMs 的差值是否大于参数 replica.lag.time.max.ms 的值,如果大于,则会把这个副本踢出 ISR 集合
如何处理所有的 Replica 不工作的情况
在 ISR 中至少有一个 follower 时,Kafka 可以确保已经 commit 的数据不丢失,但如果某个 Partition 的所有 Replica 都宕机了,就无法保证数据不丢失了
- 等待 ISR 中的任一个 Replica“活”过来,并且选它作为 Leader
- 选择第一个“活”过来的 Replica(不一定是 ISR 中的)作为 Leader
这就需要在可用性和一致性当中作出一个简单的折衷
如果一定要等待 ISR 中的 Replica“活”过来,那不可用的时间就可能会相对较长.而且如果 ISR 中的所有 Replica 都无法“活”过来了,或者数据都丢失了,这个 Partition 将永远不可用
选择第一个“活”过来的 Replica 作为 Leader,而这个 Replica 不是 ISR 中的 Replica,那即使它并不保证已经包含了所有已 commit 的消息,它也会成为 Leader 而作为 consumer 的数据源(前文有说明,所有读写都由 Leader 完成).在我们课堂讲的版本中,使用的是第一种策略
副本数据同步原理
了解了副本的协同过程以后,还有一个最重要的机制,就是数据的同步过程.它需要解决
- 怎么传播消息
- 在向消息发送端返回 ack 之前需要保证多少个 Replica 已经接收到这个消息
数据的处理过程是下图中,深红色部分表示 test_replica 分区的 leader 副本,另外两个节点上浅色部分表示 follower 副本
Producer 在发布消息到某个 Partition 时
- 先通过 ZooKeeper 找到该 Partition 的 Leader get /brokers/topics/
/partitions/2/state ,然后无论该 Topic 的 Replication Factor 为多少(也即该 Partition 有多少个 Replica),Producer 只将该消息发送到该 Partition 的 Leader. - Leader 会将该消息写入其本地 Log.每个 Follower 都从 Leader pull 数据.这种方式上,Follower 存储的数据顺序与 Leader 保持一致
- Follower 在收到该消息并写入其 Log 后,向 Leader 发送 ACK
- 一旦 Leader 收到了 ISR 中的所有 Replica 的 ACK,该消息就被认为已经 commit 了,Leader 将增加 HW(HighWatermark)并且向 Producer 发送 ACK
LEO
即日志末端位移(log end offset),记录了该副本底层日志(log)中下一条消息的位移值.注意是下一条消息!也就是说,如果 LEO=10,那么表示该副本保存了 10 条消息,位移值范围是[0,9].另外,leader LEO 和 follower LEO 的更新是有区别的.我们后面会详细说
HW
即上面提到的水位值(Hight Water).对于同一个副本对象而言,其 HW 值不会大于 LEO 值.小于等于 HW 值的所有消息都被认为是“已备份”的(replicated).同理,leader 副本和 follower 副本的 HW 更新是有区别的
通过下面这幅图来表达 LEO,HW 的含义,随着 follower 副本不断和 leader 副本进行数据同步,follower 副本的 LEO 会主键后移并且追赶到 leader 副本,这个追赶上的判断标准是当前副本的 LEO 是否大于或者等于 leader 副本的 HW,这个追赶上也会使得被踢出的 follower 副本重新加入到 ISR 集合中
另外,假如说下图中的最右侧的 follower 副本被踢出 ISR 集合,也会导致这个分区的 HW 发生变化,变成了 3
初始状态
初始状态下,leader 和 follower 的 HW 和 LEO 都是 0,leader 副本会保存 remote LEO,表示所有 follower LEO,也会被初始化为 0,这个时候,producer 没有发送消息.follower 会不断地个 leader 发送 FETCH 请求,但是因为没有数据,这个请求会被 leader 寄存,当在指定的时间之后会强制完成请求,这个时间配置是(replica.fetch.wait.max.ms),如果在指定时间内 producer 有消息发送过来,那么 kafka 会唤醒 fetch 请求,让 leader 继续处理
数据的同步处理会分两种情况,这两种情况下处理方式是不一样的
- 第一种是 leader 处理完 producer 请求之后,follower 发送一个 fetch 请求过来
- 第二种是 follower 阻塞在 leader 指定时间之内,leader 副本收到 producer 的请求
第一种情况
生产者发送一条消息
leader 处理完 producer 请求之后,follower 发送一个 fetch 请求过来 .状态图如下
leader 副本收到请求以后,会做几件事情
- 把消息追加到 log 文件,同时更新 leader 副本的 LEO
- 尝试更新 leader HW 值.这个时候由于 follower 副本还没有发送 fetch 请求,那么 leader 的 remote LEO 仍然是 0.leader 会比较自己的 LEO 以及 remote LEO 的值发现最小值是 0,与 HW 的值相同,所以不会更新 HW
follower fetch 消息
follower 发送 fetch 请求,leader 副本的处理逻辑是:
- 读取 log 数据,更新 remote LEO=0(follower 还没有写入这条消息,这个值是根据 follower 的 fetch 请求中的 offset 来确定的)
- 尝试更新 HW,因为这个时候 LEO 和 remoteLEO 还是不一致,所以仍然是 HW=0
- 把消息内容和当前分区的 HW 值发送给 follower 副本
follower 副本收到 response 以后
- 将消息写入到本地 log,同时更新 follower 的 LEO
- 更新 follower HW,本地的 LEO 和 leader 返回的 HW 进行比较取小的值,所以仍然是 0
第一次交互结束以后,HW 仍然还是 0,这个值会在下一次 follower 发起 fetch 请求时被更新
follower 发第二次 fetch 请求,leader 收到请求以后
- 读取 log 数据
- 更新 remote LEO=1,因为这次 fetch 携带的 offset 是 1.
- 更新当前分区的 HW,这个时候 leader LEO 和 remote LEO 都是 1,所以 HW 的值也更新为 1 4.把数据和当前分区的 HW 值返回给 follower 副本,这个时候如果没有数据,则返回为空
follower 副本收到 response 以后
- 如果有数据则写本地日志,并且更新 LEO
- 更新 follower 的 HW 值
到目前为止,数据的同步就完成了,意味着消费端能够消费 offset=1 这条消息
第二种情况
前面说过,由于 leader 副本暂时没有数据过来,所以 follower 的 fetch 会被阻塞,直到等待超时或者 leader 接收到新的数据.当 leader 收到请求以后会唤醒处于阻塞的 fetch 请求.处理过程基本上和前面说的一致
- leader 将消息写入本地日志,更新 Leader 的 LEO
- 唤醒 follower 的 fetch 请求
- 更新 HW
kafka 使用 HW 和 LEO 的方式来实现副本数据的同步,本身是一个好的设计,但是在这个地方会存在一个数据丢失的问题,当然这个丢失只出现在特定的背景下.我们回想一下,HW 的值是在新的一轮 FETCH 中才会被更新.我们分析下这个过程为什么会出现数据丢失
数据丢失的问题
前提
min.insync.replicas=1 //设定 ISR 中的最小副本数是多少,默认值为 1(在 server.properties 中配
置),并且 acks 参数设置为-1(表示需要所有副本确认)时,此参数才生效
表达的含义是,至少需要多少个副本同步才能表示消息是提交的,所以,当 min.insync.replicas=1 的时候,一旦消息被写入 leader 端 log 即被认为是“已提交”,而延迟一轮 FETCH RPC 更新 HW 值的设计使得 follower HW 值是异步延迟更新的,倘若在这个过程中 leader 发生变更,那么成为新 leader 的 follower 的 HW 值就有可能是过期的,使得 clients 端认为是成功提交的消息被删除
producer 的 ack
acks 配置表示 producer 发送消息到 broker 上以后的确认值.有三个可选项
- 0:表示 producer 不需要等待 broker 的消息确认.这个选项时延最小但同时风险最大(因为当 server 宕机时,数据将会丢失)
- 1:表示 producer 只需要获得 kafka 集群中的 leader 节点确认即可,这个选择时延较小同时确保了 leader 节点确认接收成功
- all(-1):需要 ISR 中所有的 Replica 给予接收确认,速度最慢,安全性最高,但是由于 ISR 可能会缩小到仅包含一个 Replica,所以设置参数为 all 并不能一定避免数据丢失
数据丢失的解决方案
在 kafka0.11.0.0 版本之后,引入了一个 leader epoch 来解决这个问题,所谓的 leader epoch 实际上是一对值(epoch,offset),epoch 代表 leader 的版本号,从 0 开始递增,当 leader 发生过变更,epoch 就+1,而 offset 则是对应这个 epoch 版本的 leader 写入第一条消息的 offset,比如 (0,0),(1,50),表示第一个 leader 从 offset=0 开始写消息,一共写了 50 条.第二个 leader 版本号是 1,从 offset=50 开始写,这个信息会持久化在对应的分区的本地磁盘上,文件名是 /tmp/kafka- log/topic/leader-epoch-checkpoint
leader broker 中会保存这样一个缓存,并且定期写入到 checkpoint 文件中
当 leader 写 log 时它会尝试更新整个缓存: 如果这个 leader 首次写消息,则会在缓存中增加一个条目;否则就不做更新.而每次副本重新成为 leader 时会查询这部分缓存,获取出对应 leader 版本的 offset
我们基于同样的情况来分析,follower 宕机并且恢复之后,有两种情况,如果这个时候 leader 副本没有挂,也就是意味着没有发生 leader 选举,那么 follower 恢复之后并不会去截断自己的日志,而是先发送一个 OffsetsForLeaderEpochRequest 请求给到 leader 副本,leader 副本收到请求之后返回当前的 LEO
如果 follower 副本的 leaderEpoch 和 leader 副本的 epoch 相同,leader 的 leo 只可能大于或者等于 follower 副本的 leo 值,所以这个时候不会发生截断
如果 follower 副本和 leader 副本的 epoch 值不同,那么 leader 副本会查找 follower 副本传过来的 epoch+1 在本地文件中存储的 StartOffset 返回给 follower 副本,也就是新 leader 副本的 LEO.这样也避免了数据丢失的问题
如果 leader 副本宕机了重新选举新的 leader,那么原本的 follower 副本就会变成 leader,意味着 epoch 从 0 变成 1,使得原本 follower 副本中 LEO 的值的到了保留
Leader 副本的选举过程
- KafkaController 会监听 ZooKeeper 的/brokers/ids 节点路径,一旦发现有 broker 挂了,执行下面的逻辑.这里暂时先不考虑 KafkaController 所在 broker 挂了的情况,KafkaController 挂了,各个 broker 会重新 leader 选举出新的 KafkaController
- leader 副本在该 broker 上的分区就要重新进行 leader 选举,目前的选举策略是
- 优先从 isr 列表中选出第一个作为 leader 副本,这个叫优先副本,理想情况下有限副本就是该分区的 leader 副本
- 如果 isr 列表为空,则查看该 topic 的 unclean.leader.election.enable 配置.unclean.leader.election.enable:为 true 则代表允许选用非 isr 列表的副本作为 leader,那么此时就意味着数据可能丢失,为 false 的话,则表示不允许,直接抛出 NoReplicaOnlineException 异常,造成 leader 副本选举失败
- 如果上述配置为 true,则从其他副本中选出一个作为 leader 副本,并且 isr 列表只包含该 leader 副本.一旦选举成功,则将选举后的 leader 和 isr 和其他副本信息写入到该分区的对应的 zk 路径上
消息的存储
消息发送端发送消息到 broker 上以后,消息是如何持久化的呢?那么接下来去分析下消息的存储
首先我们需要了解的是,kafka 是使用日志文件的方式来保存生产者和发送者的消息,每条消息都有一个 offset 值来表示它在分区中的偏移量.Kafka 中存储的一般都是海量的消息数据,为了避免日志文件过大,Log 并不是直接对应在一个磁盘上的日志文件,而是对应磁盘上的一个目录,这个目录的命名规则是
消息的文件存储机制
一个 topic 的多个 partition 在物理磁盘上的保存路径,路径保存在 /tmp/kafka-logs/topic_partition,包含日志文件,索引文件和时间索引文件
kafka 是通过分段的方式将 Log 分为多个 LogSegment,LogSegment 是一个逻辑上的概念,一个 LogSegment 对应磁盘上的一个日志文件和一个索引文件,其中日志文件是用来记录消息的.索引文件是用来保存消息的索引.那么这个 LogSegment 是什么呢?
LogSegment
假设 kafka 以 partition 为最小存储单位,那么我们可以想象当 kafka producer 不断发送消息,必然会引起 partition 文件的无线扩张,这样对于消息文件的维护以及被消费的消息的清理带来非常大的挑战,所以 kafka 以 segment 为单位又把 partition 进行细分.每个 partition 相当于一个巨型文件被平均分配到多个大小相等的 segment 数据文件中(每个 segment 文件中的消息不一定相等),这种特性方便已经被消费的消息的清理,提高磁盘的利用率
- log.segment.bytes=107370 (设置分段大小),默认是 1gb,我们把这个值调小以后,可以看到日志分段的效果
-
抽取其中3个分段来进行分析
segment file 由 2 大部分组成,分别为 index file 和 data file,此 2 个文件一一对应,成对出现,后缀".index"和“.log”分别表示为 segment 索引文件,数据文件
segment 文件命名规则:partion 全局的第一个 segment 从 0 开始,后续每个 segment 文件名为上一个 segment 文件最后一条消息的 offset 值进行递增.数值最大为 64 位 long 大小,20 位数字字符长度,没有数字用 0 填充
查看 segment 文件命名规则
通过下面这条命令可以看到 kafka 消息日志的内容
sh kafka-run-class.sh kafka.tools.DumpLogSegments --files /tmp/kafka-logs/test- 0/00000000000000000000.log --print-data-log
假如第一个 log 文件的最后一个 offset 为:5376,所以下一个 segment 的文件命名为:00000000000000005376.log.对应的 index 为 00000000000000005376.index
segment 中 index 和 log 的对应关系
从所有分段中,找一个分段进行分析
为了提高查找消息的性能,为每一个日志文件添加 2 个索引索引文件:OffsetIndex 和 TimeIndex,分别对应.index 以及.timeindex,TimeIndex 索引文件格式:它是映射时间戳和相对 offset
查看索引内容:
sh kafka-run-class.sh kafka.tools.DumpLogSegments --files /tmp/kafka-logs/test- 0/00000000000000000000.index --print-data-log
如图所示,index 中存储了索引以及物理偏移量.log 存储了消息的内容.索引文件的元数据执行对应数据文件中 message 的物理偏移地址.举个简单的案例来说,以[4053,80899]为例,在 log 文件中,对应的是第 4053 条记录,物理偏移量(position)为 80899.position 是 ByteBuffer 的指针位置
在 partition 中如何通过 offset 查找 message
查找的算法是
- 根据 offset 的值,查找 segment 段中的 index 索引文件.由于索引文件命名是以上一个文件的最后一个 offset 进行命名的,所以,使用二分查找算法能够根据 offset 快速定位到指定的索引文件.
- 找到索引文件后,根据 offset 进行定位,找到索引文件中的符合范围的索引.(kafka 采用稀疏索引的方式来提高查找性能)
- 得到 position 以后,再到对应的 log 文件中,从 position 出开始查找 offset 对应的消息,将每条消息的 offset 与目标 offset 进行比较,直到找到消息
比如说,我们要查找 offset=2490 这条消息,那么先找到 00000000000000000000.index,然后找到[2487,49111]这个索引,再到 log 文件中,根据 49111 这个 position 开始查找,比较每条消息的 offset 是否大于等于 2490.最后查找到对应的消息以后返回
Log 文件的消息内容分析
前面我们通过 kafka 提供的命令,可以查看二进制的日志文件信息,一条消息,会包含很多的字段
offset: 5371 position: 102124 CreateTime: 1531477349286
isvalid: true keysize: -1 valuesize: 12 magic: 2 compresscodec: NONE
producerId: -1 producerEpoch: -1 sequence: -1 isTransactional: false headerKeys: [] payload: message_5371
offset 和 position 这两个前面已经讲过了,createTime 表示创建时间,keysize 和 valuesize 表示 key 和 value 的大小,compresscodec 表示压缩编码,payload:表示消息的具体内容
日志的清除策略以及压缩策略
日志清除策略
前面提到过,日志的分段存储,一方面能够减少单个文件内容的大小,另一方面,方便 kafka 进行日志清理.日志的清理策略有两个
- 根据消息的保留时间,当消息在 kafka 中保存的时间超过了指定的时间,就会触发清理过程
- 根据 topic 存储的数据大小,当 topic 所占的日志文件大小大于一定的阀值,则可以开始删除最旧的消息.kafka 会启动一个后台线程,定期检查是否存在可以删除的消息
通过 log.retention.bytes 和 log.retention.hours 这两个参数来设置,当其中任意一个达到要求,都会执行删除
默认的保留时间是:7天
日志压缩策略
Kafka 还提供了“日志压缩(Log Compaction)”功能,通过这个功能可以有效的减少日志文件的大小,缓解磁盘紧张的情况,在很多实际场景中,消息的 key 和 value 的值之间的对应关系是不断变化的,就像数据库中的数据会不断被修改一样,消费者只关心 key 对应的最新的 value.因此,我们可以开启 kafka 的日志压缩功能,服务端会在后台启动启动 Cleaner 线程池,定期将相同的 key 进行合并,只保留最新的 value 值.日志的压缩原理是
磁盘存储的性能问题
磁盘存储的性能优化
我们现在大部分企业仍然用的是机械结构的磁盘,如果把消息以随机的方式写入到磁盘,那么磁盘首先要做的就是寻址,也就是定位到数据所在的物理地址,在磁盘上就要找到对应的柱面,磁头以及对应的扇区;这个过程相对内存来说会消耗大量时间,为了规避随机读写带来的时间消耗,kafka 采用顺序写的方式存储数据.即使是这样,但是频繁的 I/O 操作仍然会造成磁盘的性能瓶颈
零拷贝
消息从发送到落地保存,broker 维护的消息日志本身就是文件目录,每个文件都是二进制保存,生产者和消费者使用相同的格式来处理.在消费者获取消息时,服务器先从硬盘读取数据到内存,然后把内存中的数据原封不动的通过 socket 发送给消费者.虽然这个操作描述起来很简单,但实际上经历了很多步骤
操作系统将数据从磁盘读入到内核空间的页缓存
- 应用程序将数据从内核空间读入到用户空间缓存中
- 应用程序将数据写回到内核空间到 socket 缓存中
- 操作系统将数据从 socket 缓冲区复制到网卡缓冲区,以便将数据经网络发出
通过“零拷贝”技术,可以去掉这些没必要的数据复制操作,同时也会减少上下文切换次数.现代的 unix 操作系统提供一个优化的代码路径,用于将数据从页缓存传输到 socket;在 Linux 中,是通过 sendfile 系统调用来完成的.Java 提供了访问这个系统调用的方法:FileChannel.transferTo API 使用 sendfile,只需要一次拷贝就行,允许操作系统将数据直接从页缓存发送到网络上.所以在这个优化的路径中,只有最后一步将数据拷贝到网卡缓存中是需要的
页缓存
页缓存是操作系统实现的一种主要的磁盘缓存,但凡设计到缓存的,基本都是为了提升 i/o 性能,所以页缓存是用来减少磁盘 I/O 操作的
磁盘高速缓存有两个重要因素:
- 访问磁盘的速度要远低于访问内存的速度,若从处理器 L1 和 L2 高速缓存访问则速度更快.
- 数据一旦被访问,就很有可能短时间内再次访问.正是由于基于访问内存比磁盘快的多,所以磁盘的内存缓存将给系统存储性能带来质的飞越
当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页(page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物理磁盘的 I/0 操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程.同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页.被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性
Kafka 中大量使用了页缓存,这是 Kafka 实现高吞吐的重要因素之一.虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的,但在 Kafka 中同样提供了同步刷盘及间断性强制刷盘(fsync),可以通过 log.flush.interval.messages 和 log.flush.interval.ms 参数来控制
同步刷盘能够保证消息的可靠性,避免因为宕机导致页缓存数据还未完成同步时造成的数据丢失.但是实际使用上,我们没必要去考虑这样的因素以及这种问题带来的损失,消息可靠性可以由多副本来解决,同步刷盘会带来性能的影响.刷盘的操作由操作系统去完成即可
Kafka 消息的可靠性
没有一个中间件能够做到百分之百的完全可靠,可靠性更多的还是基于几个 9 的衡量指标,比如 4 个 9,5 个 9.软件系统的可靠性只能够无限去接近 100%,但不可能达到 100%.所以 kafka 如何是实现最大可能的可靠性呢?
- 分区副本,你可以创建更多的分区来提升可靠性,但是分区数过多也会带来性能上的开销,一般来说,3个副本就能满足对大部分场景的可靠性要求
- acks,生产者发送消息的可靠性,也就是我要保证我这个消息一定是到了 broker 并且完成了多副本的持久化,但这种要求也同样会带来性能上的开销.它有几个可选项
- 1 ,生产者把消息发送到 leader 副本,leader 副本在成功写入到本地日志之后就告诉生产者消息提交成功,但是如果 isr 集合中的 follower 副本还没来得及同步 leader 副本的消息,leader 挂了,就会造成消息丢失
- -1 ,消息不仅仅写入到 leader 副本,并且被 ISR 集合中所有副本同步完成之后才告诉生产者已经提交成功,这个时候即使 leader 副本挂了也不会造成数据丢失
- 0:表示 producer 不需要等待 broker 的消息确认.这个选项时延最小但同时风险最大(因为当 server 宕机时,数据将会丢失)
- 保障消息到了 broker 之后,消费者也需要有一定的保证,因为消费者也可能出现某些问题导致消息没有消费到
- enable.auto.commit 默认为 true,也就是自动提交 offset,自动提交是批量执行的,有一个时间窗口,这种方式会带来重复提交或者消息丢失的问题,所以对于高可靠性要求的程序,要使用手动提交.对于高可靠要求的应用来说,宁愿重复消费也不应该因为消费异常而导致消息丢失