可能需要的linux命令:
可以通过free -h命令查看内存大小
sudo netstat -ap | grep 2181 查看端口是否被占用
下载tgz包,解压后进入目录,启动ZooKeeper(以下简称Z)服务器:
bin/zookeeper-server-start.sh config/zookeeper.properties
此时会输出日志,表示绑定端口2181:
[2018-08-08 07:43:32,714] INFO binding to port 0.0.0.0/0.0.0.0:2181 (org.apache.zookeeper.server.NIOServerCnxnFactory)
启动kafka,默认的端口是9092
bin/kafka-server-start.sh config/server.properties
注意的是,如果使用的是虚拟机,可能JVM分配不了足够的内存,这时候可以修改脚本kafka-server-start.sh,将export KAFKA_HEAP_OPTS=”-Xmx1G -Xms1G”改为export KAFKA_HEAP_OPTS=”-Xmx256M -Xms128M”
创建topic test:
bin/kafka-topics.sh –create –zookeeper localhost:2181 –topic test –partitions 1 –replication-factor 1
查看topic的状态:
bin/kafka-topics.sh –describe –zookeeper localhost:2181 –topic test
Topic:test PartitionCount:1 ReplicationFactor:1 Configs:
Topic: test Partition: 0 Leader: 0 Replicas: 0 Isr: 0
实时发送消息:
bin/kafka-console-producer.sh –broker-list localhost:9092 –topic test
然后输入消息,按回车发送,ctrl+c结束
实时查看消息:
bin/kafka-console-consumer.sh –bootstrap-server localhost:9092 –topicst –from-beginning
消息引擎系统也就是消息队列或者说消息中间件。生产者会将消息发送到消息引擎系统,有消费者去消费,设计消息引擎系统需要考虑的两个重要因素:
消息设计:消息引擎系统在设计消息时一定要考虑语义的清晰和格式上的通用性,消息通常都采用结构化的方式进行设计,比如XML和JSON。Kafka采用的是二进制方式来保存的
传输协议设计:目前的主流协议包括AMQP、WebService+SOAP以及微软的MSMQ。kafka自己设计了一套二进制的消息传输协议
最常用的两种消息引擎范型是消息队列模型和发布/订阅模型
消息队列模型:是基于队列提供消息传输服务的,多用于进程间通信以及线程间通信,该模型定义了消息队列、发送者和接收者。提供了一种点对点的消息传递方式,一旦消息被消费,就会从队列中移除该消息
发布/订阅模型:有topic概念,一个topic可以被理解为逻辑语义相近的消息的容器,消息一旦生产,所有订阅了该topic的订阅者都可以接收到该消息
吞吐量和延时是AK的两个重要指标,AK是通过下面四点实现特点达到了高吞吐量、低延时的设计目标的:
- 大量使用操作系统页缓存,内存操作速度快且命中率高
- AK不直接参与物理I/O操作,而是交由最擅长此事的操作系统来完成
- 采用追加写入方式,摒弃了缓存的磁盘随机读写操作
- 使用sendfiles为代表的零拷贝技术加强网络间的数据传输效率
AK的消息持久化就是把消息写到磁盘上:
- 解耦消息发送与消息消费:生产消息并保存,不关心消息怎么消费
- 实现灵活的消息处理:方便消息的重新处理,即消息重演
负载均衡:
- 默认情况下AK的每台服务器都有均等的机会为AK的客户提供服务
- 这种负载均衡是通过分区领导者选举实现的,可以在集群的所有机器上均等机会分散各个partition的leader
故障转移:
- 故障转移是通过心跳或者会话机制来实现的
- AK采用的方式是会话机制,每台服务器启动后会以会话的形式把自己注册到ZK,,一旦服务器出现问题,与ZK的会话便不能维持从而超时失效,此时AK集群会选举出另一个台服务器来完全代替这台服务器
- 表示分布式系统中增加额外的计算资源时吞吐量提升的能力
- 对于AK来说,服务器上的状态统一交由ZK保管,扩展AK集群也只需要一步:启动新的AK服务器即可
核心架构总结:
- 生产者发送消息给AK服务器
- 消费者从AK服务器读取消息
- AK服务器依托ZK集群进行服务器的协调管理
AK服务器有一个官方名字:broker
AK的消息由多个字段组成,和通信协议类似,它采用一些固定结构,用户需要掌握三个字段含义:
- Key:消息建,对消息做partition时使用,即决定消息被保存在某topic下的哪个partition
- Value:消息体,保存实际的消息数据
- Timestamp:消息发送时间戳,用于流式处理以及其他依赖时间的处理语义,如果不指定则取当前时间
- topic是一个逻辑概念,代表一类消息
- AK采用topic-partition-message的三级结构来分散负载
- topic是由多个partition组成,partition是不可修改的有序消息队列
- partition上的每条消息都会被分配一个唯一的序列号-按照AK的术语,称为位移(offset)
- AK根据集群的实际配置设置具体的partition数,实现整体性能的最大化
有两个offset的概念
- AK端的offset指的是partition上每条消息都分配了一个offset
- 消费端对某个partition的消费也是存在一个offset,随着消费的进行,offset会增加
AK的一条消息就是(topic,partition,offset)三元组
AK高可靠性的一个实现途径是采用备份多份日志的方式(消息),这些备份的日志在AK中成为replica,副本分为两类:
- 领导者副本
- 追随者副本
追随者副本不能提供服务给客户端的,它只是被动地向领导者副本获取数据,一旦leader所在的broker宕机,会重新选举出新的leader继续提供服务
就是上面所提的领导者和追随者
AK保证同一个partition的多个replica一定不会分配在同一台broker上
间接表明副本数不能大于broker数量,多出的分区不会起作用
AK根据副本引子创建多个副本,并放在不同的broker上,并从这些副本中选举出一个领导者
全称为:in-sync replica,即与leader replica保持同步的replica集合
- AK为partition维护一个动态replica集合,该集合中的所有replica和leader replica保持一致
- 只有这个集合的replica才能被选举为leader,也只有这个集合中所有replica都接受到同一条消息,AK才会将消息置为已提交状态,即消息发送成功
- AK承诺只要这个集合中至少存在一个replica,那些已提交状态的消息就不会丢失,这里有两个关键点:1.已经提交 2.ISR中至少存在一个活着的replica
- 换句话说,AK对于没有提交成功的消息不做任何交付保证
这个replica集合维护规则:
- 若一小部分replica落后于leader replica的进度,当滞后达到一定程度时,AK会将这些replica踢出ISR
- 相反的,但replica追上了的leader的进度,那么AK会将它们加回到ISR中
AK的版本命令规则:major.minor.patch
AK使用java重写produce和consumer,即客户端代码
KafkaProduce即新版本使用的Producer类,它的特点:
- 发送过程被划分为两个不同的线程,用户主线程和Sender I/O线程
- 完全是异步发送消息,并提供回调机制用于判断发送成功与否
- 分批机制:每个批次中包括多个发送请求,提升整体吞吐量
- 更加合理的分区策略:对于没有指定的key的消息而言,旧版本producer分区是默认在一段时间将消息发送到固定分区,这容易造成数据倾斜,新版本采用轮询的方式,消息发送将更加均匀化
- 底层统一使用基于Java Selector的网络客户端,结合Java的Future实现更加健壮和优雅的生命周期管理
新版本KafkaConsumer的特点:
- 单线程设计:单个consumer线程可以管理多个分区消费Socket连接,极大地简化了实现
- 位移提交与保存交由AK来处理,不再依赖ZK
- 消费组的集中式管理
旧版本的producer和consumer
- 旧版本的producer默认为同步发送,若采用异步发送可能会丢失消息
- 旧版本的consumer分为high-level 和 low-lever,前者指的是消费组,后者指的是单个consumer
- high lever比较省事,但是死板,比如只能从上次保存的位移除开始顺序读取,而low consumer可以从任意位置消费消息
磁盘的容量和下面几个因素有关:
新增消息数,消息留存时间,平均消息大小,副本数,是否采用压缩
AK仅仅将消息写入page cache,然后由系统将缓存刷入磁盘,因此,page cache的大小很重要
尽量分配更多的内存给操作系统的page cache
不要为broker设置过大的堆内存,最好不超过6GB
page cache大小至少要大于一个日志段的大小
使用多核系统,CPU的核数最好大于8
如果使用旧版本或clients端与broker端消息版本不一致,则考虑多配置一些资源以防止消息解压缩消耗过多的CPU
尽量使用高速网络
根据自身网络条件和带宽来评估AK集群机器数量
避免使用跨机房网络
这里使用单个节点模拟分布式环境。ZK集群通常被称为一个ensemble,只要ensemble中的大多数节点存活,那么ZK集群就能正常提供服务,因此一般使用奇数个服务器,这里模拟3个服务器。
也可以使用AK自带的ZK,注意老版本的consumer需要ZK来保存位移信息。下载文件后,依次输入命令:
tar -zxvf zookeeper-3.4.10.tar.gz
mv zookeeper-3.4.10 zookeeper
sudo mkdir -p /home/user/zk1
sudo mkdir -p /home/user/zk2
sudo mkdir -p /home/user/zk3
在ZK conf目录下创建3个配置文件(如果使用的多台机器,每台机器上的名字可以相同),分别为zoo1.cfg,zoo2.cfg,zoo3.cfg,比如zoo1.cfg的配置,另外两个配置类似,只需要修改端口号以及dataDir的目录:
#ZK的最小时间单位
ickTime=2000
#指定follower节点初始连接leader节点的最大tick次数
initLimit=5
#follower节点与leader节点进行同步的最大时间
syncLimit=2
#ZK会在内存中保存系统快照,并定期写入该路径指定的文件夹中
dataDir=/home/user/zk1
#ZK监听客户端连接的端口,一般设置成默认值
clientPort=2181
#下面这个三个配置中server后面的数字是全局唯一的,代表ZK的编号
#zk1,zk2,zk3是假设的三个节点的主机名,单节点模拟需要在hosts名添加
server.1=zk1:2888:3888
server.2=zk2:2889:3889
server.3=zk3:2890:3890
下面要配置ZK的id,它位于dataDir中,且名字是myid,内容是ZK的编号
echo “1” > /home/user/zk1/myid
echo “2” > /home/user/zk1/myid
echo “3” > /home/user/zk3/myid
接着启动3个控制台,并启动ZK:
java -cp zookeeper-3.4.10.jar:lib/slf4j-api-1.6.1.jar:lib/slf4j-log4j12-1.6.1.jar:lib/log4j-1.2.16.jar:conf org.apache.zookeeper.server.quorum.QuorumPeerMain conf/zoo1.cfg
java -cp zookeeper-3.4.10.jar:lib/slf4j-api-1.6.1.jar:lib/slf4j-log4j12-1.6.1.jar:lib/log4j-1.2.16.jar:conf org.apache.zookeeper.server.quorum.QuorumPeerMain conf/zoo2.cfg
java -cp zookeeper-3.4.10.jar:lib/slf4j-api-1.6.1.jar:lib/slf4j-log4j12-1.6.1.jar:lib/log4j-1.2.16.jar:conf org.apache.zookeeper.server.quorum.QuorumPeerMain conf/zoo3.cfg
注意的是,启动第一个会有日志报错:
Cannot open channel to 3 at election address zk3/127.0.0.1:3890
其实这是由于第二三个节点还没起来导致的,继续启动第二三个节点就OK了
接着我们可以查看ZK的状态:
guanhang@ubuntu:~/Downloads/zookeeper bin/zkServer.shstatusconf/zoo1.cfgZooKeeperJMXenabledbydefaultUsingconfig:conf/zoo1.cfgMode:followerguanhang@ubuntu: /Downloads/zookeeper b i n / z k S e r v e r . s h s t a t u s c o n f / z o o 1. c f g Z o o K e e p e r J M X e n a b l e d b y d e f a u l t U s i n g c o n f i g : c o n f / z o o 1. c f g M o d e : f o l l o w e r g u a n h a n g @ u b u n t u : / D o w n l o a d s / z o o k e e p e r bin/zkServer.sh status conf/zoo2.cfg
ZooKeeper JMX enabled by default
Using config: conf/zoo2.cfg
Mode: leader
guanhang@ubuntu:~/Downloads/zookeeper$ bin/zkServer.sh status conf/zoo3.cfg
ZooKeeper JMX enabled by default
Using config: conf/zoo3.cfg
Mode: follower
其他问题附注:
如果使用多节点环境每个节点只需要运行下面命令启动(只会启动一个线程):
bin/zkServer.sh start conf/zoo_sample.cfg
单机的启动和关闭:
bin/zkServer.sh start conf/zoo_sample.cfg
bin/zkServer.sh stop conf/zoo_sample.cfg
如果输入启动命令发现端口已经被占用,可以kill -9干掉该进程,查看端口占用:
lsof -i:端口
netstat -anp | grep 端口
如果报错形如:
at org.apache.zookeeper.server.persistence.FileTxnSnapLog
需要删掉dataDir下面的version-2文件夹
仅需要创建多个配置文件就可以,其中一个配置文件案例:
#另外两个分别是1和2
delete.topic.enable=true
unclean.leader.election.enable=false
#另外两个端口9093和9094
listeners=PLAINTEXT://localhost:9092
num.network.threads=3
num.io.threads=8
socket.send.buffer.bytes=102400
socket.receive.buffer.bytes=102400
socket.request.max.bytes=104857600
#另外两个目录是k2和k3
log.dirs=/home/user/data_logs/k1
num.partitions=1
num.recovery.threads.per.data.dir=1
offsets.topic.replication.factor=1
transaction.state.log.replication.factor=1
transaction.state.log.min.isr=1
log.retention.hours=168
log.segment.bytes=1073741824
log.retention.check.interval.ms=300000
#对应上面zk的client端口
zookeeper.connect=zk1:2181,zk2:2182,zk3:2183
zookeeper.connection.timeout.ms=6000
group.initial.rebalance.delay.ms=0
启动3个kafka:
bin/kafka-server-start.sh -daemon config/server1.properties
bin/kafka-server-start.sh -daemon config/server2.properties
bin/kafka-server-start.sh -daemon config/server3.properties
可以从查看server.log验证启动是否成功
验证kafka进程是否已经启动:
jps | grep Kafka
建议使用AK集群之前最好提前 把所需要的topic创建出来,并执行对应的命令做验证,避免producer和consumer运行时不会因为topic分区leader的各种问题导致短暂停顿现象
创建分区:
bin/kafka-topics.sh –zookeeper zk1:2181,zk2:2182,zk3:2183 –create –topic test-topic –partitions 3 –replication-factor 3
验证:
bin/kafka-topics.sh –zookeeper zk1:2181,zk2:2182,zk3:2183 -list
bin/kafka-topics.sh –zookeeper zk1:2181,zk2:2182,zk3:2183 –describe topic test-topic
显示分区的信息:
Topic:test-topic PartitionCount:3 ReplicationFactor:3 Configs:
Topic: test-topic Partition: 0 Leader: 0 Replicas: 0,1,2Isr: 0,1,2
Topic: test-topic Partition: 1 Leader: 1 Replicas: 1,2,0Isr: 1,2,0
Topic: test-topic Partition: 2 Leader: 2 Replicas: 2,0,1Isr: 2,0,1
删除topic:
bin/kafka-topics.sh –zookeeper zk1:2181,zk2:2182,zk3:2183 –delete –topic test-topic
上面运行完后,提示只是把这个topic标记为delete,且在delete.topic.enable设置为true时,会去真正删除topic,在1.0.0后,该值不设置默认为true,之前为false.
需要注意的是,这是个异步任务,在topic分区过多或者数据过多时,会有些延迟
消费信息:
注意–bootstrap-server参数代表使用新版本的consumer,如果使用zookeeper参数表示老版本的consumer
bin/kafka-console-consumer.sh –bootstrap-server localhost:9092,localhost:9093,localhost:9094 –topic test-topic –from-beginning
生产信息:输入命令然后编辑发送消息,接收端能够看到
bin/kafka-console-producer.sh –broker-list localhost:9092,localhost:9093,localhost:9094 –topic test-topic
可以运行一下命令测试吞吐量:
bin/kafka-producer-perf-test.sh –topic test-topic –num-records 500000 –record-size 200 –throughput -1 –producer-props bootstrap.servers=localhost:9092,localhost:9093,localhost:9094 acks=-1
bin/kafka-producer-consumer-perf-test.sh –broker-list localhost:9092,localhost:9093,localhost:9094 –message-size 200 –messages 500000 –topic test-topic
broker参数在server.properties文件中进行设置,AK尚不支持动态修改,就是说,如果有变动,需要重启对应的broker服务器
broker.id:AK使用唯一的标识符来标识每个broker,这就是broker.id。该参数默认是-1,如果不指定,AK会自动生成一个唯一值
log.dirs:指定了AK持久化消息的目录,可以设置多个,以逗号分隔,这样可以把负载均匀地分配到多个目录下
zookeeper.connect:没有默认值,指定zk的端口和ip,如果使用一套zk环境管理多套kafka集群,设置该参数时必须指定chroot
listener:broker监听的列表,可以认为是broker端开放给clients的监听端口,用于客户端连接broker使用,其中PLAINTEXT表示协议,其他的还有SSL,SASL_SSL
advertised.listeners:用于IaaS环境,也就是有多个网卡的情况
unclean.leader.election.enable:是否开启unclean leader选举,为false时,在ISR为空,且leader宕机时,不允许从非ISR副本中选择一个当leader,因为这样会导致消息丢失
delete.topic.enable:是否允许删除topic,默认情况下,AK集群允许用户删除topic及其数据。
log.retention.{hours|minutes|ms}:控制消息的留存时间,如果都设置,优先级是:ms->minutes->hours。默认的留存时间是7天
log.retention.bytes:日志大小限制
min.insync.replics:只有在acks=-1时(表示producer寻求最高等级的持久化保证)有意义,表示broker端成功响应clients消息发送的最少副本数
num.network.threads:设置broker在后台用于处理网络请求的线程数,默认是3。注意这里的处理指的是转发请求
num.io.threads:设置broker端实际处理网络请求的线程数,默认是8。
message.max.bytes:broker能够接受的最大消息大小
更针对性的参数设置,会覆盖broker的全局参数,常见的有:
delete.retention.ms:每个topic可以设置自己的日志留存时间以覆盖全局默认值
max.message.bytes:覆盖全局的message.max.bytes
retention.bytes:覆盖全局的参数
GC参数设置参考:
如果用户机器上的cpu资源充足,推荐使用CMS收集器,相反地,则使用吞吐量收集器
G1收集器也是很好的选择,前提是JDK版本达到要求
需要打开GC日志的监控
不需要为JVM配置太多的内存,通常broker设置不超过6G的堆空间
可以优化的参数:
文件描述符限制:AK会频繁创建并修改系统的文件,最好增加进程能够打开的最大文件描述符上限
Socket缓冲区大小:这里指的是OS级别的Socket缓冲区大小,建议将缓冲区调大,比如128K
使用Ext4或XFS文件系统
关闭swap:
设置更长的flush时间:能够提升OS物理写入操作的性能
实例代码:
public static void main(String[] args) {
Properties pros = new Properties();
//必须指定
pros.put("bootstrap.servers", "localhost:9092");
//必须指定
pros.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
//必须指定
pros.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
pros.put("acks", "-1");
pros.put("retries", 3);
pros.put("batch.size", 323840);
pros.put("linger.ms", 10);
pros.put("buffer.memory", 33554432);
pros.put("max.block.ms", 3000);
KafkaProducer
至少要指定下面三个参数:
bootstrap.servers:指定broker连接的端口ip列表,如果kafka的集群较多,也可以只指定部分broker
key.serializer:发送到broker的消息都是字节序列,因此消息需要序列化,该参数指定的类需事先Kafka的Seriallizer接口,AK已经为初始类型提供了序列化器。注意的是,发送消息不指定key,该参数也是要指定的
value.serializer:和上面类似,用来对消息体进行序列化
只需简单的new该对象,并设置properties
除了指定topic和value,还可以指定发往的分区和消息的时间戳,不过一般不推荐指定时间戳,因为其和文件的索引项有关,如果指定错误,会影响功能
producer在底层是采用异步发送,并可以通过Future实现同步和异步+回调两种方式,这里的同步是指调用Future.get()。
异步+回调的方法:
producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e) {
if (e == null) {
}else {
}
}
});
同步:
RecordMetadata recordMetadata = producer.send(record).get();
发送异常分为可重试异常和不可重试异常
可重试异常:LeaderNotAvailableExcepton, NotControllerException, NetworkException等
底层会根据可重试次数进行重新发送,若重试成功异常不会被用户捕捉到
可重试异常时RetriableException的子类,其他异常都是不可重试异常
不可重试异常举例:
RecordTooLargeException:消息太大
SerializationException,KafkaException
减少不必要的系统资源占用,有多种传参方式:
无参的close:优雅关闭,处理完之前的发送请求后再关闭
传timeout:等到一定的超时时间,然后强制关闭
**acks:**AK在乎的是已提交消息的持久性。一旦消息被成功提交,那么只要有一个保存了该消息的副本存活,这条消息就被视为不会丢失的
acks的相关取值:
acks=0,表示不理睬leader broker端的处理结果,此时producer发送消息后立即开启下一条消息的发送
acks=all或者-1,表示当消息发送时,leader broker不仅会将消息写入本地日志,同时还会等待ISR中所有其他副本都成功写入它们各自的本地日志后,才发送响应结果给producer。可以达到最高的持久性
acks=1:一种这种方案leader收到消息后便发送响应给producer,无序等待ISR中其他副本写入消息
buffer.memory:指定producer端缓存消息的缓冲区大小,该参数越大,吞吐量越大
compression.type:设置producer端是否压缩消息,默认值是none。压缩会增加吞吐量,但也会提升CPU的开销,目前AK支持3种压缩方式:GZIP,Snappy,LZ4,其中GZIP性能最好
retries:对可自行修复的故障进行重试策略,默认是0。重试次数可能会带来的问题:
重试可能会导致消息重新发送
重试可能造成消息的乱序
batch.size:producer会将发送到统一分区的多条消息封装进一个batch中,当batch满了的时候,producer会发送batch中的所有消息。因此batch的大小非常重要,该参数越小,吞吐量越小,各参数越大,内存占用越大
linger.ms:控制消息发送延时行为,该参数默认值是0,表示消息需要立即发送,无需关系batch是否已经被填满,但是这样会拉低吞吐量
max.request.size:用于控制producer发送请求的大小
request.timout.ms:当producer发送请求给broker后,broker需要在规定的时间范围内将处理结果返回给producer,超过该时间就会认为请求超时了,并在回调函数中显式的抛出超时异常
分区之我见:
有了topic为什么还要分区,这就像有了分布式数据库之后,为什么还要分库分表一样,目的是为了让topic能够横向扩展
producer提供了分区策略以及对应的分区器供用户使用。AK默认的分区器会尽力确保具有相同key的所有消息都会被发送到相同的分区。用户也可以自定义自己的分区策略:
案例:假设有一些消息是用于审计功能的,这类消息的key会被固定地分配一个字符串”audit”,我们想让这个消息发到topic最后一个分区上,以便后续统一处理,其他消息则采用随机发送的策略发送到其他分区上,代码实现:
public class AuditPartitioner implements Partitioner {
private Random random;
@Override
public int partition(String topic, Object keyObj, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
String key = (String) keyObj;
List partitionInfos = cluster.availablePartitionsForTopic(topic);
int partitionCount = partitionInfos.size();
int auditPartition = partitionCount - 1;
return key == null || key.isEmpty() || !key.contains("audit") ?
random.nextInt(partitionCount - 1) : auditPartition;
}
@Override
public void close() {
//close
}
@Override
public void configure(Map map) {
random = new Random();
}
}
AK针对常见的类型提供了十几种序列化器,比如像ByteArraySerializer, IntegerSerializer。自定义序列化器需要继承AK的Serializer接口
需要实现接口ProducerInterceptor,其定义方法如下:
onSend:消息被序列化以及计算分区前调用该方法
onAcknowledgement:会在消息被应答之前或消息发送失败时调用,并且通常都是在producer回调逻辑触发之前,该方法运行在IO线程中,不能添加太中的逻辑
close:关闭interceptor
producer端配置:
block.on.buffer.full = true :该参数表示缓冲区满的时候阻塞,新版本应该设置max.block.ms
acks = all or -1 :最强程度的持久化保证
retries = Integer.MAX_VALUE :保证消息不丢失(可重试的情况下)
max.in.flight.requests.per.connection = 1:限制producer子单个broker连接上能够发送的未响应请求的数量,设置为1表示,只允许一个未响应,必须等待这个响应返回后才能继续发送
使用带回调机制的send发送消息,即KafkaProducer.send(record, callback):失败了会有通知
Callback逻辑中显式地立即关闭producer,使用close(0)
broker端配置:
unclean.leader.election.enable = false:避免broker端因日志水位截取而造成消息丢失
replication.factor = 3 :三备份原则
min.insync.replicas >1 : 控制某条消息至少被写入到ISR中多少个副本才算成功,acks=all or -1是才有意义
replication.factor > min.insync.replicas: 若两者相等,只要一个副本挂掉,分区就无法正常工作了
enable.auto.commit = false
AK在使用过程中会出现两种情况,表格如下
说明 | 优势 | 劣势 |
---|---|---|
单Producer实例 | 所有线程共享一个Producer实例 | 简单,性能好 |
多Producer实例 | 每个线程维护自己专属的Producer实例 | 可以进行细粒度调优,单个崩溃不会影响其他的工作 |
新旧consumer的大致对比:
consumer大致可以分为:
消费者组的特点:
对于同一个group而言,topic的每条消息只能发送到group下一个consumer实例上
topic消息可以发送到多个group中
AK可以通过消费者组实现Kafka的基于队列和基于发布/订阅的两种消息引擎
consumer实例来自于相同的group:实现基于队列的模型
consumer来自于不同的group:实现基于发布/订阅的模型
consumer group是用于高伸缩性、高容错性的consumer机制。组内多个consumer实例可以同时读取Kafka消息,而且一旦有某个consumer挂掉,consumer group会立即将已崩溃consumer负责的分区转交给其他consumer来负责,从而保证不丢数据–这也成为重平衡
AK目前只提供单个分区内的消息顺序,而不会维护全局的消息顺序,因此用户如果要实现topic全局的消息顺序读取,就只能通过让每个consumer group下只包含一个consumer实例的方式来实现
总结消费者组:
group可以有一个或者多个consumer实例,一个consumer实例可以是一个进程,也可以是运行在其他机器上的进程
group.id:唯一标识一个consumer group
订阅topic的每个分区只能分配给该group下的一个consumer实例
每个consumer实例都会为它消费的分区维护属于自己的位置信息来记录当前消费了多少条消息。消息如果保存在broker端的问题:
broker变成有状态了,增加了同步成本,影响伸缩性
需要引入应答机制来确认消费成功
由于要保存许多consumer的位移,需要引入复杂的数据结构,从而造成不必要的资源浪费
AK通过consumer来保存位移,同时还引入了检查点机制定期对位移进行持久化
旧版本的consumer会定期将位移信息提交到ZK的固定节点上,因此配置中指定ZK的地址
新版本会将位移提交到一个__consumer_offsets位移上
这里简称co,co是AK创建的,保存co的文件夹有50个,用户不可擅自删除,每个文件夹下面有一个日志文件和两个索引文件,日志中存储的位移信息可以看成一个key,value形式的数据,key:groupid+topic+分区号,value是offset的值
AK定期会对co进行压实操作,即为每个消息key只保存含有最新offset的消息
为了缓解写入压力,该topic创建了50个分区,并且对group.id做哈希求模运算后,将负载分散到不同的co分区上
重平衡只对消费者组有效,它本质上是一种协议,规定group下的所有consumer怎么分配订阅topic的所有分区
示例:
public class ConsumerDemo {
public static void main(String[] args) {
String topicName = "test-topic";
String groupId = "test-group";
Properties props = new Properties();
props.put("bootstrap.servers", "locahost:9092");
//必须指定
props.put("group.id", groupId);
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "1000");
//从最早的消息开始读取
props.put("auto.offset.reset", "earliest");
//必须指定
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
//必须指定
props.put("value.seserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer consumer = new KafkaConsumer<>(props);
//订阅不是增量式的,多次订阅会覆盖
consumer.subscribe(Collections.singleton(topicName));
try {
while (true) {
//使用了和selector类似的机制,需要用户轮询
//1000是超时时间,控制最大阻塞时间
ConsumerRecords records = consumer.poll(1000);
for (ConsumerRecord record : records) {
System.out.println(record.key() + ":" + record.value());
}
}
}finally {
//关闭并最多等待30s
consumer.close();
}
}
}
几个重要的参数解释:
bootstrap.servers:和producer相同,用来指定和broker连接的ip和端口,同样也不要指定完整的列表
group.id:group的名字
需要注意的是,AK认为只要poll方法返回了即认为consumer成功消费了消息
session.timeout.ms:超时时间,但是在老版本代表两个含义:1. 协调器发现consumer down的超时时间 2. 两次poll间隔处理的超时时间(协调器会认为这个consumer更不上其他成员的进度)。对于1我们想降低这个时间,对于2我们不能无限减低这个时间,因此需要在两者之间做个平衡
max.poll.interval.ms:0.10.1.0版本后,上述1,2拆开,1还是由上面的参数控制,2改为由本参数设置
auto.offset.reset:指定了无位移信息或位移越界时AK的应对策略。注意重启group后,由于位移信息保存了,不满足本参数生效的条件。目前该参数有三个可取值:1. earliest:从最早的位移开始消费。2.latest:指定从最新处位移开始消费。3.none:指定未发现位移信息或位移越界,则抛出异常,这个设置很少用
enable.auto.commit:是否自动提交位移。对于”精确处理一次“语义需求的用户来说,最好将该参数设置为false,由用户自行处理位移提交
fetch.max.bytes:consumer单次获取数据的最大字节数
max.poll.records:控制单词poll返回的最大消息数,默认500条
heartbeat.interval.ms:当协调器决定开启重平衡时,会将特殊的响应塞进心跳的response中,其他成员拿到response后才知道它要重新加入group,这个过程越快越好,而这个参数就是控制这个时间的,注意该值必须小于session.timeout.ms
connections.max.idle.ms:AK会定期关闭空闲的Socket,默认9分钟,可以通过该参数来控制时间,设置为-1表示不关闭空闲连接
AK基于支持正则表达式来订阅topic,使用正则的话,就必须指定ConsumerRebalanceListener接口
旧版本采用开启多线程去消费数据的形式。AK使用和linux IO相同的设计模式,采用单线程管理多个与broker的连接实现消息的并行读取。消费逻辑,协调器的协调以及消费者组的reblance,数据的获取都是在这个线程里处理的。
需要注意的是Java consumer是一个双线程的Java进程,还有一个线程是心跳线程
总结一下poll的使用方法:
consumer需要定期执行其他的子任务,推荐较小的超时时间+运行标识布尔变量(判断是否在运行,多线程中可设置结束标识,定义为volatile)的方式
consumer不需要定期执行子任务:推荐poll(MAX_VALUE)+捕获Wakeup异常的方式
consumer需要定期向AK提交自己的位置信息,也就是下一条待消费的消息的位置,位移是从0开始。位移是实现各种交付语义的基础,常见的3种交付语义:
最多一次处理语义:消息可能丢失,但不会被重复处理。实现:consumer在消息消费之前就提交位移
最少一次处理语义:消息不会丢失,但可能被处理多次。实现:consumer在消费后提交位移,也是默认提供的
精确一次处理语义:消息一定会被处理且只会被处理一次。老版本不支持,新版本会支持,需要类似事务的机制
关于位移的一些概念:
上次提交位移:最近一次提交的offset
当前位置:consumer已读取但未提交时的位置
水位:也程高水位,不属于consumer管理的范围,而是属于分区日志的概念。consumer是无法读取水位以上的消息
日志终端位移:不属于consumer的范围,表示了某个分区副本当前保存消息对应的最大位置。只有分区所有的副本都保存了某条消息,该分区的leader副本才会向上移动水位值
典型的手动提交代码:
props.put("enable.auto.commit", "false");
final int minBatchSize = 500;
List> buffer = new ArrayList<>();
while (true) {
ConsumerRecords records = consumer.poll(1000);
for (ConsumerRecord record : records) {
buffer.add(record);
}
if (buffer.size() >= minBatchSize) {
insertIntoDb(buffer);
consumer.commitAsync();
buffer.clear();
}
}
手动提交分为同步手动提交和异步手动提交,这里的异步不是开启一个线程提交,而是指不会阻塞,仍然会在poll中不断轮询提交的结果。同时提交的时候可以传一个map,显式告诉AK为哪些分区提交位移:
try{
while (running) {
ConsumerRecords records = consumer.poll(1000);
for (TopicPartition partition : records.partitions()) {
List> partitionRecords = records.records(partition);
for (ConsumerRecord record : partitionRecords) {
System.out.println(record.offset() + ": " + record.value());
}
long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
//+1是因为读取下一条消息
consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastOffset + 1)));
}
}
}finally {
consumer.close();
}
旧版本的consumer的位移默认保存在ZK节点中,与__consumer_offsets完全没有关系。旧版本consumer也区分自动提交和手动提交位移,只不过需要设置auto.commit.enable参数,旧版本consumer默认的提交间隔是60s。设置成手动提交时,需要显式调用:ConsumerConnector.commitOffsets方法来提交位移。
重平衡是一个协议,规定了group如何达成一致来分配订阅topic的所有分区,注意组订阅topic的每个分区只会分配组内的一个consumer实例。对于某个组而言,AK的某个broker会选举为组协调者,协调者负责对组的状态进行管理。
组重平衡触发的条件:
组成员发生变更,比如新consumer加入组,或已有consumer主动离开组,或consumer崩溃时触发重平衡
组订阅topoic数发生变更,当匹配正则表达式的新topic被创建时则会触发重平衡
组订阅topic的分区数发生变更,比如使用命令行脚本增加订阅topic的分区数
常见的是第一种情况,但并不是一定是进程挂掉和机器挂掉,也可能是consumer无法再指定的时间内完成消息的处理,协调器会任务consumer崩溃,从而引发新一轮重平衡
AK默认提供了3种分配策略,分别是range策略,roud-robin策略和sticky策略
range策略:将单个topic的所有分区按顺序排列,然后把这些分区划分成固定大小的分区段并依次分配给每个consumer
round-robin策略:把所有topic的所有分区顺序摆开,然后轮询式地分配给各个consumer
sticky策略:会参考历史分配方案
如果group下所有consumer实例的订阅是相同的,那么使用round-robin会带来更公平的分配方案。新版本consumer默认的分配策略是range,用户根据consumer参数:partition.assignment.strategy来进行设置。AK支持自定义的分配策略,用户可以创建自己的分配器
为了更好地隔离每次重平衡上的数据,新版本consumer设计了rebalance generation用于标识某次rebalance,通常从0开始,用于防止无效offset提交(上一代的offset)
重平衡本质上是一种协议,AK提供了5个协议来处理rebalance
JoinGroup:consumer请求加入组
SyncGroup请求:group leader把分配方案同步更新到组内所有成员中
Heartbeat请求:consumer定期向协调器汇报心跳表明自己依然存活
LeaveGroup请求:consumer主动通知协调器该consumer即将离组
DescribeGroup请求:查看组的所有信息,包括成员信息、协议信息、分配方案以及订阅信息
在重平衡中,协调器主要处理加入组和离开组的请求,成功重平衡之后,组内所有consumer都需要定期向协调器发送Heartbeat请求,而每个consumer也是根据Heartbeat请求的响应中是否包含REBALANCE_IN_PROGRESS来判断当前group是否开启 新一轮rebalance
指定协调器:计算groupI的哈希值%分区数量(默认是50)的值,寻找__consumer_offsets分区为该值的leader副本所在的broker,该broker即为这个group的协调器
成功连接协调器之后便可以执行rebalance操作, 目前rebalance主要分为两步:加入组和同步更新分配方案
加入组:协调器group中选择一个consumer担任leader,并把所有成员信息以及它们的订阅信息发送给leader
同步更新分配方案:leader在这一步开始制定分配方案,即根据前面提到的分配策略决定每个consumer都负责那些topic的哪些分区,一旦分配完成,leader会把这个分配方案封装进SyncGroup请求并发送给协调器。注意组内所有成员都会发送SyncGroup请求,不过只有leader发送的SyncGroup请求中包含分配方案。协调器接收到分配方案后把属于每个consumer的方案单独抽取出来作为SyncGroup请求的response返还给给自的consumer
consumer group分配方案是在consumer端执行的
AK也支持用户把位移提交到外部存储中,若实现这个功能,用户就必须使用rebalance监听器。如果使用的是独立consumer或是直接手动分配分区,那么rebalance监听器是无效的
consumer.subscribe(Arrays.asList("test-topoic"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection partitions) {
//在协调器开启新一轮rebalance前会调用
}
@Override
public void onPartitionsAssigned(Collection partitions) {
//rebalance完成后调用
}
});
注意:consumer在rebalance时检查用户是否启用了自动提交功能,如果是,他会帮用户执行提交,不需要在监听器里面显式提交;另外不要在rebalance中加入复杂的逻辑
需要注意的是consumer是非线程安全的,给出两种多线程消费的案例:
代码:
public class ConsumerGroup {
private List consumers;
public ConsumerGroup(int consumerNum, String groupId, String topic, String brokerList) {
consumers = new ArrayList<>();
for(int i = 0;inew ConsumerRunnable(brokerList, groupId, topic);
consumers.add(consumerRunnable);
}
}
public void execute(){
for (ConsumerRunnable task : consumers) {
new Thread(task).start();
}
}
public static void main(String[] args) {
String brokerList = "localhost:9092";
String groupId = "testGroup";
String topic = "test-topic";
int consumerNum = 3;
ConsumerGroup consumerGroup = new ConsumerGroup(consumerNum, groupId, topic, brokerList);
consumerGroup.execute();
}
}
public class ConsumerRunnable implements Runnable {
private final KafkaConsumer consumer;
public ConsumerRunnable(String brokerList, String groupId, String topic) {
Properties props = new Properties();
props.put("bootstrap.servers", brokerList);
props.put("group.id", groupId);
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "1000");
props.put("session.timeout.ms", "30000");
props.put("key,deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
this.consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList(topic));
}
@Override
public void run() {
while (true) {
ConsumerRecords poll = consumer.poll(200);
for (ConsumerRecord record : poll) {
System.out.println(Thread.currentThread().getName() + "consumed " + record.partition() +
"th message with offset: " + record.offset());
}
}
}
}
代码:
public class ConsumerWorker<K,V> implements Runnable {
private final ConsumerRecords records;
private final Map offsets;
public ConsumerWorker(ConsumerRecords records, Map offsets) {
this.records = records;
this.offsets = offsets;
}
@Override
public void run() {
for (TopicPartition partition : records.partitions()) {
List> records = this.records.records(partition);
for (ConsumerRecord record : records) {
System.out.println(String.format("topic=%s,partition=%d,offset=%d",
record.topic(), record.partition(), record.offset()));
}
long lastOffset = records.get(records.size() - 1).offset();
synchronized (offsets) {
if (!offsets.containsKey(partition)) {
offsets.put(partition, new OffsetAndMetadata(lastOffset + 1));
} else {
long curr = offsets.get(partition).offset();
if (curr <= lastOffset + 1) {
offsets.put(partition, new OffsetAndMetadata(lastOffset + 1));
}
}
}
}
}
}
public class ConsumerThreadHandler<K, V> {
private final KafkaConsumer consumer;
private ExecutorService executors;
private final Map offsets = new HashMap<>();
public ConsumerThreadHandler(String brokerList, String groupId, String topic) {
Properties props = new Properties();
props.put("bootstrap.servers", brokerList);
props.put("group.id", groupId);
props.put("enable.auto.commit", "false");
props.put("auto.offset.reset", "earliest");
props.put("key,deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList(topic), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection partitions) {
//提交位移
consumer.commitSync( );
}
@Override
public void onPartitionsAssigned(Collection partitions) {
offsets.clear();
}
});
}
public void consumer(int threadNum) {
executors = new ThreadPoolExecutor(threadNum,
threadNum,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue(1000),
new ThreadPoolExecutor.CallerRunsPolicy());
try {
while (true) {
ConsumerRecords records = consumer.poll(1000);
if (!records.isEmpty()) {
executors.submit(new ConsumerWorker<>(records, offsets));
}
commitOffsets();
}
} catch (WakeupException e) {
//忽略
}finally {
commitOffsets();
consumer.close();
}
}
private void commitOffsets() {
Map unmodifiedMap;
synchronized (offsets) {
if (offsets.isEmpty()) {
return;
}
unmodifiedMap = Collections.unmodifiableMap(new HashMap<>(offsets));
offsets.clear();
}
consumer.commitSync(unmodifiedMap);
}
public void close() {
consumer.wakeup();
executors.shutdown();
}
}
public class MultiMain {
public static void main(String[] args) {
String brokerList = "localhost:9092";
String topic = "test-topic";
String groupId = "test-group";
final ConsumerThreadHandler handler = new ConsumerThreadHandler<>(brokerList, groupId, topic);
final int cpuCount = Runtime.getRuntime().availableProcessors();
Runnable runnable = new Runnable() {
@Override
public void run() {
handler.consumer(cpuCount);
}
};
new Thread(runnable).start();
try {
Thread.sleep(20000L);
} catch (InterruptedException e) {
//忽略
}
System.out.println("Starting to close the consumer....");
handler.close();
}
}
对比:
多consumer:连接开销大,consumer数受限于topic分区数,broker端负载高,rebalance可能性大。优点:速度快,方便位移管理
单consumer:难以维护分区内的消息顺序,位移管理困难,worker线程异常可能导致消费数据丢失。优点:消息获取与处理解耦
独立的consumer可以精确控制消费的需求,比如严格控制某个consumer固定地消费哪些分区。实例代码
List partitions = new ArrayList<>();
List allPartitions = consumer.partitionsFor("test-topic");
if (allPartitions != null && !allPartitions.isEmpty()) {
for (PartitionInfo partitionInfo : allPartitions) {
partitions.add(new TopicPartition(partitionInfo.topic(), partitionInfo.partition()));
}
consumer.assign(partitions);
}
broker通常是以服务器的形式出现的,broker的主要功能就是持久化消息以及将消息队列中的消息从发送端传输到消费端
目前AK的消息有三个版本,V0版本,V1版本,V2版本
V0版本:
主要指0.10之前的版本,消息格式如下:
CRC校验码:4字节
magic:单字节,表示版本号
attribute:单字节,低三位表示消息的压缩类型
key长度:4字节,未指定key为-1
key值:无key则没有该字段
value长度:4字节
value值:无value则没有该字段
出去key值和value值,一共14个字节
V1版本:
V0版本的弊端:
没有消息时间信息,只能用来日志的新增时间来删除过期日志,但是这个时间是可以通过修改日志文件来修改的
因此V1版本加入了时间戳字段,占用8个字节。同时attribute的第4位表明时间戳的类型,支持两种时间戳类型,可以支持由producer还是broker设置时间戳
消息和消息集合
消息结合:包含多个日志项,每个日志项都封装了实际的消息和一组元数据。AK日志文件就是由一系列消息集合构成的(也就是写入的消息其实消息集合),AK不会在消息层面上直接操作,它总是在消息集合上进行写入操作
V1,V2的消息集合的日志项格式如下:
offset:8字节,非consumer端的offset,是指消息在AK分区日志中的offset,如果未启用压缩,就是消息的offset,如果有压缩,表示最后一条消息的offset
size:4字节
message:不定,如果采用消息压缩会将多条消息装进其value字段
V1,V2版本日志项的一个问题是broker端需要解压缩,需要遍历才能知道压缩消息的起始offset。缺陷总结如下:
空间利用率不高:长度固定是4个字节
只保存最新消息位移:也就是上面提到的问题
冗余消息级的CRC校验:每条消息都要进行校验没必要
未保存消息长度:每次需要单条消息的总字节数信息时都需要计算得出,没有使用单独字段来保存
因此,AK提出V2版本的消息和消息集合格式
V2版本
V2中消息集合也称为消息批次,消息的格式如下:
消息总长度:可变,一次性计算后保存
属性:1字节
时间戳增量:可变,以前需要8个字节保存时间戳
位移增量:可变
key长度:可变
key:key的值
value长度:可变
value:value的值
header个数:可变,包含两个字段,头部key和value,类型分别是String和byte[],用来满足定制化需求
header:header的内容
上面的可变长度表示AK会根据具体的值来确定到底需要几个字节保存。对于上述的可变长度,V2版本借鉴Zig-zag编码方式(负数编码成对应的正数,正数编码成其2倍的数值),使得绝对值小的整数占用比较少的字节(由于小的负数的补码有大量的1,真正的信息不多),因为长度是可变的,因此每个字节的最高位表示是否是最后一个字节,只有7位参与编码。
删减的字段:
attribute字段:保存在外层的batch中
CRC校验码:放到batch中
消息batch的格式:
起始位移、长度、分区leader版本号、版本、CRC、属性、最大位移增量、起始时间戳、最大时间戳、PID、producer epoch、起始序列号、消息个数、消息内容
其中属性变成双字节,PID、epoch(版本号)、都是实现幂等性producer和支持事务而引入的
成员管理:
AK依赖ZK实现成员管理。每个broker在ZK下注册节点的路径是:
chroot/brokers/ids/
一个AK分区本质上就是一个备份日志,即利用多份相同的备份来提供冗余机制保证高可靠性。副本分为leader副本和follower副本,只有leader副本才对外服务,follower副本被动地向leader副本请求数据,对于落后leader太多的副本,他们是没有资格竞选leader的,因此引入了ISR机制
ISR就是集群维护的一组同步副本集合,每个topic分区都有自己的ISR列表,leader副本也是在ISR中的,只有ISR中的副本才能成为leader,producer写入的一条AK消息只有被ISR中的所有副本都接收到才被视为已提交状态。
follower副本同步:
follower副本只做一件事情:向leader副本请求数据,一些重要的概念如下:
起始位移:副本当前所含的第一条消息的offset
高水印值:也称HW,保存了该副本最新一条已提交消息的位移,leader的HW决定了consumer能够消费的最大值,超过HW的消息是未提交的消息
日志某段位移:LEO,下一条待写入的消息,follower副本向leader请求到数据后会增加自己的LEO。
交互流程如下:
producer给leader发消息,更新LEO
follower请求消息
leader发送消息给follower
follower更新LEO
leader接收响应后更新HW
当ack=-1时,上面的步骤做完之后才算producer发送成功
ISR设计:
0.9之前:提供了replica.lag.max.messages参数控制follower落后的消息数(这个参数是全局的),超过这个数量会被任务不同步,从而被踢出ISR
follower追不上leader的可能情况:
请求速度追不上leader的接收速度
进程卡住
新建的副本,需要追赶进度
注意replica.lag.max.messages参数只能针对请求追不上的情况,对于另外两种,提供replica.lag.time.max.ms来控制,表示如果follower不能该参数设置的时间内追上leader就会被认为是不同步的
这种设计的缺陷:
假设producer发起了一波生产的高峰,此时follower很可能会落后leader(落后消息数设置不合理的情况),导致踢出ISR,但是在下一次FetchRequest后,follower又会追上,从而又加入了ISR,如此往复造成震荡
0.9之后,AK改用统一的参数replica.lag.max.ms同时检测由于慢以及进程卡壳导致的滞后,默认是10秒
水印也就是前面提到HW,实际就是指offset。注意的是HW指的是存在的消息,而LEO指的是下一条存入的位置。
LEO的更新机制
LEO的更新机制:follower会不断的向leader副本所在的broker发送FETCH请求,一旦获取消息,便写入自己的日志中进行备份
follower的LEO除了在副本所在的broker缓存中会保存,同时也会保存在leader副本所在的broker上,用来确定leader的HW值
leader端的follower副本的LEO更新时间:
leader副本端的follower副本LEO的更新发生在leader处理follower FETCH请求时,在给follower返回数据之前它先去更新follower的LEO(根据follower携带的fetch offset判断 )
HW更新机制
follower的HW更新:
在f接收到消息后,会先更新LEO值,然后更新HW值,即在LEO和leader的HW两者中取小着作为HW值
在出现以下四种情况时,leader尝试更新HW(leader HW用户可见):
副本成为leader副本:分区leader发生了变更
broker出现崩溃导致副本被踢出ISR
producer向leader副本写入消息时
leader处理follower FETCH请求时
后两种是正常场景,leader的HW更新规则:
比较满足条件的所有副本的LEO,选取最小的那个作为HW值。
满足的条件(满足之一):1.处于ISR中 2.副本LEO落后于leader LEO的时间不大于replica.lag.time.max.ms
注意:按照上面的更新规则,在一轮producer发出消息,以及follower发出FETCH请求后,leader和follower的HW都不会跟新的,要在第二轮更新。这种更新方法的解读:
首先leader和follower的LEO的更新原则是很简单的,即收到消息即更新,leader的HW看ISR请求的offset,follower的HW主要还是leader的HW,即第一轮整体的分区HW差不多是ISR请求的最小的offset(也就是LEO)。由于是先请求,再写入消息并更新follower的LEO,因此当前轮次,虽然日志都已写入,但是分区HW还是旧的
同时注意的是,为了防止无数据时,FETCH请求过于频繁,此时会将请求寄存,超时500ms后或者producer有新消息后再强制处理请求。
这种下一轮请求才会更新HW的缺陷:
备份数据可能会丢失
备份数据不一致
基于水印备份日志的缺陷:
数据丢失:在副本数只有1的时候,leader只需要自己写入了数据就更新HW,不用考虑ISR,同时会马上返回给producer。此时followerHW还没更新,所以若它宕机,重启后会做日志截断,导致丢弃刚刚存入的消息,若此时leader宕机,follower成为leader,由于follower有话语权导致这条丢弃的消息完全从日志删除
数据不一致/数据离散:和上面的场景类似,只不过l和f同时崩溃,f先重启回来,producer又发送消息给新的leader f,然后l重启会来,正好两者的HW一样了,导致不会做任何日志截断,但是f中存的顺序和l中的不一样(f中有一条消息漏了,l中应该也漏了一条)
0.11版本解决之道:
针对上面的问题,加入了leader epoch来代替HW,它实际是一对值(epoch,offset),epoch表示leader的版本,当leader变更一次,epoch就会+1,offset对应该epoch版本的leader写入第一条消息的位移。每个副本都会保存自己当leader时写入的第一条消息的offset以及leader版本。解决上面问题的过程:
数据丢失:当f重启后,给l发消息获取它当leader时的offset,f中存入的消息没有超过这条offset的,因此不会进行日志截取
乱序问题:f先重启回来后成为leader,l后重启回来发送消息给f,返回的leader epoch中的offset小于当前l中的存入的offset,因此会截取超过该offset的消息。
关于索引文件的说明:
索引文件采用稀疏索引的方式,可以通过参数设置log.index.interval.bytes设置间隔
索引文件支持只读模式和读写模式,对于当前日志段索引采用读写的方式打开
broker端通过设置log.index.size.max.bytes设置索引文件的最大大小,默认值是10M,当前日志段的索引文件大小是预分配的,日志切分后的大小才是真正大小
位置索引文件记录了相对位移到文件物理位置的映射
时间戳索引文件记录了时间戳到相对位移的映射
AK强制要求索引文件必须是索引项大小的整数倍,对于位移索引是8的倍数,对于时间戳索引是12的倍数
关于日志留存:
AK会定期清除日志的,而且清除的单位是日志段文件,当前的策略有两种:
基于时间的留存策略:AK默认会清除7天前的日志段数据,可以通过log.retention.{hours|minutes|ms}来设置,0.10之前是通过日志修改时间判断,之后是通过当前时间和日志第一条消息时间戳之差判断
基于大小的留存策略:通过参数log.retention.bytes设置,默认是-1
日志清除是异步过程,并且对当前日志段是不生效的
关于日志压缩:
确保每个分区下的每条消息具有相同key的消息都至少保存最新value的消息,AK使用Cleaner组件完成这件事
消息压缩只会使用某种策略有选择性的移除log中的消息,而不会变更消息的offset值
消息压缩是topic级别的,AK使用一些后台线程定期执行清理任务
消息压缩使用的参数如下:
log.cleanup.policy:是否启用压缩
log.cleaner.enable:是否启用log Cleaner,如果启用压缩该参数必须设置为true
log.cleaner.compaction.lag.ms:默认值是0,表示除了当前日志段,理论上所有的日志段都属于可清理部分。我们可以通过该参数设置不清理比当前时间往前的一段时间内的日志
协议设计:
AK协议中的请求发送流有三种:
clients向broker发送请求
controller向broker发送请求
broker向broker发送请求
所有的请求和响应都具有统一的格式,即size+Request/Response,请求头部的结构:
api_key:请求类型
api_version:请求版本号
correlation_id:与对应响应的关联号,用于关联response和request
client_id:表示发出次请求的client id。
响应头只有一个字段:correlation_id,和请求头的对应
常见的请求类型:
PRODUCE请求:client向broker发送
FETCH请求:client向broker发送,也包括follower向leader发送
METADATA请求:client向broker发送获取指定topic的信息
请求处理流程:
controller的职责:
更新集群元数据信息:client可以向任意台broker发送METADATA请求,同时controller负责在集群信息有变动后将消息同步到所有的broker
创建topic:通过监听topic下子节点的变更情况
删除topic:通过监听delete_topic下的节点变化
分区重分配:通过监听reassign_partitions下的节点变化
leader副本选举:AK引入了preferred副本的概念,会将分区副本列表的第一个当成preferred leader
topic分区扩展:也是监听topic下的节点变化
broker加入集群:监听/broker/ids的变化
broker崩溃
受控关闭:是指优雅的关闭broker,能够在降低broker的不一致性。受控关闭是broker会给controller发送请求,而不是依赖ZK监听实现受控关闭
controller leader 选举
controller启动时会为集群中所有broker创建一个专属的Socket连接,100台broker会创建100个连接,当前controller只给broker发送3种请求:
UpdateMetadataRequest:上面已经提到
LeaderAndIsrRequest:用于创建分区、副本,同时完成作为leader和作为follower角色各自的逻辑
StopReplicaRequest:停止指定副本的数据请求操作,另外还负责删除副本数据的功能
controller中最重要的组件是ControllContext,它汇总了AK集群的所有元数据信息,是controller能够正确提供服务的基础,controller的设计是多线程的,因此保护好这个上下文,使其免受多线程并发修改成了controller很重要的任务,老版本controller的设计缺陷:
多线程共享状态:使用私有monitor锁来实现,没有并行度
代码组织混乱
管理类请求与数据类请求未分开
controller同步写ZK且是一个分区一个分区地写
controller一个分区一个分区的发送
controller给broker的请求无版本号信息
ZkClient阻塞状态管理
新版本controller主要改进了controller多线程时间处理模型
AK broker请求处理模式就是Reactor设计模式,服务处理器或分发器将入站连接请求按照多路复用的方式分发到对应的请求处理器中。具体的处理细节如下:
每个broker有一个acceptor线程和若干个processor线程,processor的数量通过参数num.network.threads控制,默认是3。broker会为用户配置的每组listener创建一组processor线程。
broker端固定使用一个acceptor线程来唯一监听入站连接,processor线程接收acceptor线程分配的新Socket连接通道,然后开始监听该通道上的数据。processor实际也不是执行者,它会创建一个线程池去处理请求
每个processor线程中维护一个Java Selector实例,管理多个通道上的数据交互
新版本producer的大致工作流程
producer接收到消息先进行序列化,然后加上一些元数据,一起发送给partitioner确定目标分区,然后写入消息缓冲池,此时AK的send方法返回。接着Sender线程进行预处理以及发送消息,消息发送完后Sender线程处理response。
可以看到producer发送事件完全是异步过程,因此在调优producer前我们需要搞清楚性能瓶颈到底是在用户主线程还是Sender线程上
脚本管理略
public class ZkUtilServerTest {
public static void main(String[] args) {
//创建topic
//创建与ZK的连接
ZkUtils zkUtils = ZkUtils.apply("localhost:2181", 3000, 30000, JaasUtils.isZkSecurityEnabled());
//创建一个单分区、单副本、名为t1的topic,未指定topic级别的参数,所以传的空的properties
//RackAwareMode.Enforced$.MODULE$等同于指定了RackAwareMode.Enforced,表示考虑机架位置
AdminUtils.createTopic(zkUtils, "t1", 1, 1, new Properties(), RackAwareMode.Enforced$.MODULE$);
//删除topic
AdminUtils.deleteTopic(zkUtils, "t1");
//查询topic级别的属性
Properties props = AdminUtils.fetchEntityConfig(zkUtils, ConfigType.Topic(), "t1");
Iterator> iterator = props.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry next = iterator.next();
Object key = next.getKey();
Object value = next.getValue();
System.out.println(key + "=" + value);
}
//变更topic级别的参数
props.setProperty("min.cleanable.dirty.ratio", "0.3");
AdminUtils.changeTopicConfig(zkUtils, "test", props);
zkUtils.close();
//查询当前集群下所有consumer group的信息
Properties properties = new Properties();
properties.put("bootstrap.servers", "localhost:9092");
AdminClient adminClient = AdminClient.create(properties);
Map> nodeListMap = JavaConversions.mapAsJavaMap(adminClient.listAllGroups());
for (Map.Entry> entry : nodeListMap.entrySet()) {
Iterator groupOverviewIterator = JavaConversions.asJavaIterator(entry.getValue().iterator());
while (groupOverviewIterator.hasNext()) {
GroupOverview next = groupOverviewIterator.next();
System.out.println(next.groupId());
}
}
//查看指定group的位移消息
Properties props1 = new Properties();
props.put("bootstrap.servers", "localhostA:9092");
AdminClient client = AdminClient.create(props1);
String groupId = "a1";
Map topicPartitionObjectMap = JavaConversions.mapAsJavaMap(adminClient.listGroupOffsets(groupId));
Long offset = (Long) topicPartitionObjectMap.get(new TopicPartition("test", 0));
System.out.println(offset);
client.close();
}
}
/**
* 客户端API管理
*/
public class ClientTest {
//返送请求的主方法
public ByteBuffer send(String host, int port, AbstractRequest request, ApiKeys apiKeys) throws IOException {
Socket socket = connect(host, port);
try {
return send(request, apiKeys, socket);
}finally {
socket.close();
}
}
//建立连接
private Socket connect(String host, int port) throws IOException {
return new Socket(host, port);
}
//向给定的Socket发送请求
private ByteBuffer send(AbstractRequest request, ApiKeys apiKeys, Socket socket) throws IOException {
RequestHeader header = new RequestHeader(apiKeys.id, request.version(), "client-id", 0);
ByteBuffer buffer = ByteBuffer.allocate(header.sizeOf() + request.sizeOf());
header.writeTo(buffer);
request.writeTo(buffer);
byte[] seializedRequest = buffer.array();
byte[] response = issueRequestAndWaitForResponse(socket, seializedRequest);
ByteBuffer responseBuffer = ByteBuffer.wrap(response);
ResponseHeader.parse(responseBuffer);
return responseBuffer;
}
//发送序列化请求并等待response返回
private byte[] issueRequestAndWaitForResponse(Socket socket, byte[] request) throws IOException {
sendRequest(socket, request);
return getResponse(socket);
}
private byte[] getResponse(Socket socket) throws IOException {
DataInputStream dis = null;
try {
dis = new DataInputStream(socket.getInputStream());
byte[] bytes = new byte[dis.readInt()];
dis.readFully(bytes);
return bytes;
} catch (IOException e) {
if (dis != null) {
dis.close();
}
}
return null;
}
private void sendRequest(Socket socket, byte[] request) throws IOException {
DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
dos.writeInt(request.length);
dos.write(request);
dos.flush();
}
//创建Topic
public void createTopoics(String topicName, int partitions, short replicationFactor) throws IOException {
Map topics = new HashMap<>();
topics.put(topicName, new CreateTopicsRequest.TopicDetails(partitions, replicationFactor));
int createionTimeoutMs = 60000;
CreateTopicsRequest request = new CreateTopicsRequest.Builder(topics, createionTimeoutMs).build();
ByteBuffer response = send("localhost", 9092, request, ApiKeys.CREATE_TOPICS);
CreateTopicsResponse.parse(response, request.version());
}
//删除topic
public void deleteTopics(Set topics) throws IOException {
int deleteTimeoutMs = 30000;
DeleteTopicsRequest request = new DeleteTopicsRequest.Builder(topics, deleteTimeoutMs).build();
ByteBuffer response = send("localhost", 9092, request, ApiKeys.DELETE_TOPICS);
DeleteTopicsRequest.parse(response, request.version());
}
//获取某个consumer group下所有topic分区的位移信息
public Map getAllOffsetForGroup(String groupId) throws IOException {
OffsetFetchRequest request = new OffsetFetchRequest.Builder(groupId, null).setVersion((short) 2).build();
ByteBuffer response = send("localhost", 9092, request, ApiKeys.OFFSET_FETCH);
OffsetFetchResponse resp = OffsetFetchResponse.parse(response, request.version());
return resp.responseData();
}
//查询某个consumer group下的某个topic分区的位移
public void getOffsetForPartition(String groupID, String topic, int partition) throws IOException {
TopicPartition tp = new TopicPartition(topic, partition);
OffsetFetchRequest request = new OffsetFetchRequest.Builder(groupID, Collections.singletonList(tp)).setVersion((short) 2).build();
ByteBuffer response = send("localhost", 9092, request, ApiKeys.OFFSET_FETCH);
OffsetFetchResponse resp = OffsetFetchResponse.parse(response, request.version());
OffsetFetchResponse.PartitionData partitionData = resp.responseData().get(tp);
System.out.println(partitionData.offset);
}
}
0.11版本后,AK社区退出了AdminClient和KafkaAdminClient,统一所有的集群管理API。AdminClient是线程安全的。
public class AdminClientTest {
private static final String TEST_TOPIC = "test-topic";
public static void main(String[] args) throws ExecutionException, InterruptedException {
Properties pros = new Properties();
//kafaka集群信息
pros.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092,localhost:9093");
try (AdminClient client = AdminClient.create(pros)) {
//描述集群信息
describeCluster(client);
createTopic(client);
listAllTopics(client);
describeTopics(client);
alterConfigs(client);
describeConfig(client);
deleteTopics(client);
}
}
private static void deleteTopics(AdminClient client) throws ExecutionException, InterruptedException {
KafkaFuture futures = client.deleteTopics(Arrays.asList(TEST_TOPIC)).all();
futures.get();
}
private static void describeConfig(AdminClient client) throws ExecutionException, InterruptedException {
DescribeConfigsResult ret = client.describeConfigs(Collections.singleton(new ConfigResource(ConfigResource.Type.TOPIC, TEST_TOPIC)));
Map configs = ret.all().get();
for (Map.Entry entry : configs.entrySet()) {
ConfigResource key = entry.getKey();
Config value = entry.getValue();
System.out.println(String.format("Resource type:%s,resource name:%s", key.type(), key.name()));
Collection configEntries = value.entries();
for (ConfigEntry each : configEntries) {
System.out.println(each.name() + " = " + each.value());
}
}
}
private static void alterConfigs(AdminClient client) throws ExecutionException, InterruptedException {
Config topicConfigs = new Config(Arrays.asList(new ConfigEntry("cleanup.policy", "compact")));
client.alterConfigs(Collections.singletonMap(new ConfigResource(ConfigResource.Type.TOPIC, TEST_TOPIC), topicConfigs)).all().get();
}
private static void describeTopics(AdminClient client) throws ExecutionException, InterruptedException {
DescribeTopicsResult ret = client.describeTopics(Arrays.asList(TEST_TOPIC, "__consumer_offsets"));
Map topics = ret.all().get();
for (Map.Entry entry : topics.entrySet()) {
System.out.println(entry.getKey() + "=" + entry.getValue());
}
}
private static void listAllTopics(AdminClient client) throws ExecutionException, InterruptedException {
ListTopicsOptions options = new ListTopicsOptions();
//包括内部topics,比如__consumer_offsets
options.listInternal(true);
ListTopicsResult topics = client.listTopics(options);
Set topicNames = topics.names().get();
System.out.println("Current topics in this cluster: " + topicNames);
}
private static void createTopic(AdminClient client) throws ExecutionException, InterruptedException {
NewTopic newTopic = new NewTopic(TEST_TOPIC, 3, (short) 3);
CreateTopicsResult ret = client.createTopics(Arrays.asList(newTopic));
ret.all().get();
}
private static void describeCluster(AdminClient client) throws ExecutionException, InterruptedException {
DescribeClusterResult ret = client.describeCluster();
System.out.println(String.format("Cluster id: %s, controller: %s", ret.clusterId().get(), ret.controller().get()));
System.out.println("Current cluster nodes info: ");
for (Node node : ret.nodes().get()) {
System.out.println(node);
}
}
}
UnkonwTopicOrPartitionException:可重试异常,表示请求的分区不在抛出该异常的broker上,常见的原因有一下是三个
follower副本所在的broker在另一个broker成为leader之前率先完成了成为follower的操作,使得follower从leader拉取数据时发现leader broker上还未准备好数据,从而抛出异常,会在下一轮RPC中自动恢复
producer向不存在的topic发送数据,broker会封装该异常返回给producer,属于可重试异常,如果一直报错,查看auto.create.topics.enable参数
当启用ACL后,AK对未授权操作中topic一律返回异常,而非“无权访问”之类的错误
LEADER_NOT_AVAILABLE:表示对应分区没有leader,原因可能如下:
正在进行leader的选举,或者topic正在删除,如果一直报错,建议使用election脚本重新进行leader选举
NotLeaderForPartitionExcepton:和上面一样,一般是瞬时的错误
该异常主要是指当前broker已不是对应分区的leader broker,这通常发生在leader变更的情况下
TimeoutException:请求超时,确定是从producer端、broker端还是consumer端抛出的,哪里抛出的就增加哪里的request.timeout.ms参数的值,若亦然不管用,则需要考虑用户环境中的broker或clients是否负载过重,导致任务堆积不能被处理
RecordToolLargeException:常见于producer端,通常是因为producer应用的后台发送线程无法匹配用户主线程的消息创建速率。解决思路:
尽量避免producer实例
适当增加request.timeout.ms
适当减少batch.size
当producer端无法从AK集群获取元数据时,也会抛出这个异常,特别是对那些为正确配置链接的producer来说,此时需要查看bootstrap.servers的连接设置是否正确。同时要让AK集群处理大消息,需要调整三个参数:
broker端参数message.max.bytes:设置broker端能处理的最大消息长度
producer端参数max.request.size:设置producer端能处理的最大消息长度
consumer端参数fetch.max.bytes(新版本), fetch.message.max.bytes(旧版本):设置consumer端能处理的最大消息长度
broker端参数socket.request.max.bytes:设置broker端Socket请求的最大字节数。通常用户不需要额外配置该参数,但如果AK发送超过100MB的超大消息,则必须需要调整该参数
NetworkExcetpin:通常是producer端抛出,可能是因为工作过程中中断了某个broker的连接,属于可重试异常
ILLEGAL_GENERATION:这是新版本consumer抛出的异常,表明当前consumer错过了正在进行的rebalance,原因是该consumer花费了大量的时间处理poll返回的数据。用户需要适当减少max.poll.records值以及增加max.poll.interval.ms值。对于老版本的AK,需要减少max.partition.fetch.bytes参数的值