Apache Kafka实战读书笔记(推荐指数:☆☆☆☆☆)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/guanhang89/article/details/82563231

Apache Kafka实战读书笔记(推荐指数:☆☆☆☆☆)

认识AK

快速入门

安装和启动

可能需要的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是通过下面四点实现特点达到了高吞吐量、低延时的设计目标的:

  1. 大量使用操作系统页缓存,内存操作速度快且命中率高
  2. AK不直接参与物理I/O操作,而是交由最擅长此事的操作系统来完成
  3. 采用追加写入方式,摒弃了缓存的磁盘随机读写操作
  4. 使用sendfiles为代表的零拷贝技术加强网络间的数据传输效率

消息持久化

AK的消息持久化就是把消息写到磁盘上:

  1. 解耦消息发送与消息消费:生产消息并保存,不关心消息怎么消费
  2. 实现灵活的消息处理:方便消息的重新处理,即消息重演

负载均衡和故障转移:

负载均衡:

  1. 默认情况下AK的每台服务器都有均等的机会为AK的客户提供服务
  2. 这种负载均衡是通过分区领导者选举实现的,可以在集群的所有机器上均等机会分散各个partition的leader

故障转移:

  1. 故障转移是通过心跳或者会话机制来实现的
  2. AK采用的方式是会话机制,每台服务器启动后会以会话的形式把自己注册到ZK,,一旦服务器出现问题,与ZK的会话便不能维持从而超时失效,此时AK集群会选举出另一个台服务器来完全代替这台服务器

伸缩性:

  1. 表示分布式系统中增加额外的计算资源时吞吐量提升的能力
  2. 对于AK来说,服务器上的状态统一交由ZK保管,扩展AK集群也只需要一步:启动新的AK服务器即可

AK的基本概念和术语

核心架构总结:

  1. 生产者发送消息给AK服务器
  2. 消费者从AK服务器读取消息
  3. AK服务器依托ZK集群进行服务器的协调管理

AK服务器有一个官方名字:broker

消息

AK的消息由多个字段组成,和通信协议类似,它采用一些固定结构,用户需要掌握三个字段含义:

  1. Key:消息建,对消息做partition时使用,即决定消息被保存在某topic下的哪个partition
  2. Value:消息体,保存实际的消息数据
  3. Timestamp:消息发送时间戳,用于流式处理以及其他依赖时间的处理语义,如果不指定则取当前时间

topic和partition

  1. topic是一个逻辑概念,代表一类消息
  2. AK采用topic-partition-message的三级结构来分散负载
  3. topic是由多个partition组成,partition是不可修改的有序消息队列
  4. partition上的每条消息都会被分配一个唯一的序列号-按照AK的术语,称为位移(offset)
  5. AK根据集群的实际配置设置具体的partition数,实现整体性能的最大化

offset

有两个offset的概念

  1. AK端的offset指的是partition上每条消息都分配了一个offset
  2. 消费端对某个partition的消费也是存在一个offset,随着消费的进行,offset会增加

AK的一条消息就是(topic,partition,offset)三元组

replica

AK高可靠性的一个实现途径是采用备份多份日志的方式(消息),这些备份的日志在AK中成为replica,副本分为两类:

  1. 领导者副本
  2. 追随者副本

追随者副本不能提供服务给客户端的,它只是被动地向领导者副本获取数据,一旦leader所在的broker宕机,会重新选举出新的leader继续提供服务

leader和follower

就是上面所提的领导者和追随者

  1. AK保证同一个partition的多个replica一定不会分配在同一台broker上

    间接表明副本数不能大于broker数量,多出的分区不会起作用

  2. AK根据副本引子创建多个副本,并放在不同的broker上,并从这些副本中选举出一个领导者

ISR

全称为:in-sync replica,即与leader replica保持同步的replica集合

  1. AK为partition维护一个动态replica集合,该集合中的所有replica和leader replica保持一致
  2. 只有这个集合的replica才能被选举为leader,也只有这个集合中所有replica都接受到同一条消息,AK才会将消息置为已提交状态,即消息发送成功
  3. AK承诺只要这个集合中至少存在一个replica,那些已提交状态的消息就不会丢失,这里有两个关键点:1.已经提交 2.ISR中至少存在一个活着的replica
  4. 换句话说,AK对于没有提交成功的消息不做任何交付保证

这个replica集合维护规则:

  1. 若一小部分replica落后于leader replica的进度,当滞后达到一定程度时,AK会将这些replica踢出ISR
  2. 相反的,但replica追上了的leader的进度,那么AK会将它们加回到ISR中

AK的使用场景

  1. 消息传输
  2. 网站行为日志追踪
  3. 审计数据收集
  4. 日志收集
  5. Event Sourcing
  6. 流式处理

AK的发展历史

AK的历史

  1. 从批处理到流逝处理的变化,流逝处理只要实现:正确性和时间推导工具,就能够完全替代批处理
  2. AK设计之初就提供了三个方面的功能特性:
    1. 为生产者和消费者提供了一套简单的API
    2. 减低网络的传输和磁盘存储开销
    3. 具有高伸缩性架构
  3. AK主要应用于数据管道中

版本变迁

  1. AK的版本命令规则:major.minor.patch

  2. AK使用java重写produce和consumer,即客户端代码

  3. KafkaProduce即新版本使用的Producer类,它的特点:

    1. 发送过程被划分为两个不同的线程,用户主线程和Sender I/O线程
    2. 完全是异步发送消息,并提供回调机制用于判断发送成功与否
    3. 分批机制:每个批次中包括多个发送请求,提升整体吞吐量
    4. 更加合理的分区策略:对于没有指定的key的消息而言,旧版本producer分区是默认在一段时间将消息发送到固定分区,这容易造成数据倾斜,新版本采用轮询的方式,消息发送将更加均匀化
    5. 底层统一使用基于Java Selector的网络客户端,结合Java的Future实现更加健壮和优雅的生命周期管理
  4. 新版本KafkaConsumer的特点:

    1. 单线程设计:单个consumer线程可以管理多个分区消费Socket连接,极大地简化了实现
    2. 位移提交与保存交由AK来处理,不再依赖ZK
    3. 消费组的集中式管理
  5. 旧版本的producer和consumer

    1. 旧版本的producer默认为同步发送,若采用异步发送可能会丢失消息
    2. 旧版本的consumer分为high-level 和 low-lever,前者指的是消费组,后者指的是单个consumer
    3. high lever比较省事,但是死板,比如只能从上次保存的位移除开始顺序读取,而low consumer可以从任意位置消费消息

选择版本

  1. 如果要使用流式处理组件,必须使用新版本
  2. 如果要启用Kafka Security,必须使用新版本
  3. 对于自行研发的客户端,推荐新版本;如果是第三方框架直接提供客户端,按照官网说明选择

AK线上环境部署

集群环境规划

操作系统的选择

  1. AK 新版本的clients的网络库采用的是Java Selector机制,底层实现使用的是Linux的epoll,epoll模型比select高级,但是在Win上使用的是select模型,因此Linux更适合
  2. 从网络传输效率来说,由于AK直接使用了Linux上的sendfile,即领拷贝调用,因此可以提升数据传输性能

磁盘规划

  1. SSD的极端寻道时间和存取时间能够有效提升性能
  2. 由于采用的是随机存储,机械硬盘和SSD差距不大
  3. RAID10有两个优势:天然提供负载均衡以及提供冗余的数据存储空间,缺点是磁盘利用率低,和AK提供的冗余机制叠加
  4. JBOD的优势:性价比高,使用AK的冗余机制也能达到高可靠。缺点:任意磁盘的损坏都会导致broker宕机

磁盘容量规划

磁盘的容量和下面几个因素有关:

新增消息数,消息留存时间,平均消息大小,副本数,是否采用压缩

内存规划:

AK仅仅将消息写入page cache,然后由系统将缓存刷入磁盘,因此,page cache的大小很重要

尽量分配更多的内存给操作系统的page cache

不要为broker设置过大的堆内存,最好不超过6GB

page cache大小至少要大于一个日志段的大小

cpu规划

使用多核系统,CPU的核数最好大于8

如果使用旧版本或clients端与broker端消息版本不一致,则考虑多配置一些资源以防止消息解压缩消耗过多的CPU

带宽规划

尽量使用高速网络

根据自身网络条件和带宽来评估AK集群机器数量

避免使用跨机房网络

伪分布式环境安装

这里使用单个节点模拟分布式环境。ZK集群通常被称为一个ensemble,只要ensemble中的大多数节点存活,那么ZK集群就能正常提供服务,因此一般使用奇数个服务器,这里模拟3个服务器。

安装多节点ZK

也可以使用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 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文件夹

安装多节点AK

仅需要创建多个配置文件就可以,其中一个配置文件案例:

#另外两个分别是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

验证部署

topic的创建和删除

建议使用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端参数

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能够接受的最大消息大小

topic级别参数

更针对性的参数设置,会覆盖broker的全局参数,常见的有:

delete.retention.ms:每个topic可以设置自己的日志留存时间以覆盖全局默认值

max.message.bytes:覆盖全局的message.max.bytes

retention.bytes:覆盖全局的参数

GC参数

GC参数设置参考:

如果用户机器上的cpu资源充足,推荐使用CMS收集器,相反地,则使用吞吐量收集器

G1收集器也是很好的选择,前提是JDK版本达到要求

需要打开GC日志的监控

JVM参数

不需要为JVM配置太多的内存,通常broker设置不超过6G的堆空间

OS参数

可以优化的参数:

文件描述符限制:AK会频繁创建并修改系统的文件,最好增加进程能够打开的最大文件描述符上限

Socket缓冲区大小:这里指的是OS级别的Socket缓冲区大小,建议将缓冲区调大,比如128K

使用Ext4或XFS文件系统

关闭swap:

设置更长的flush时间:能够提升OS物理写入操作的性能

Producer开发

概览

  1. AK封装了一套二进制通信协议,用于对外提供各种各样的服务
  2. producer比consumer要简单些,不涉及复杂的组件管理,每个producer是独立进行工作的
  3. producer的首要功能是向某个topic的某个分区发送消息,其中确定目标分区是分区器(partitioner)的功能
  4. 用户可以自定义自己的分区策略
  5. 相同key的所有消息都会被路由到相同的分区中,没有指定key所有的消息会被均匀的发送到所有的分区
  6. 确定分区后需要寻找分区的leader,也就是leader副本所在的broker,只要leader才能响应clients的请求,剩下的ISR副本会和leader保持一致
  7. producer使用一个线程将待发送的消息封装进一个ProducerRecord实例,然后将其序列化之后发送到位于producer程序中的线程缓冲区中,另一个IO发送线程负责实时地从缓冲区提取准备就绪的消息封装进一个batch,统一发送给对应的broker

构造producer

实例代码:

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<Object, Object> producer = new KafkaProducer<>(pros);
    for (int i = 0; i < 100; i++) {
        producer.send(new ProducerRecord<Object, Object>("my-topic", Integer.toString(i), Integer.toString(i)));
        producer.close();
    }
}

主要步骤如下:

构造properties对象

至少要指定下面三个参数:

bootstrap.servers:指定broker连接的端口ip列表,如果kafka的集群较多,也可以只指定部分broker

key.serializer:发送到broker的消息都是字节序列,因此消息需要序列化,该参数指定的类需事先Kafka的Seriallizer接口,AK已经为初始类型提供了序列化器。注意的是,发送消息不指定key,该参数也是要指定的

value.serializer:和上面类似,用来对消息体进行序列化

构造KafkaProducer对象

只需简单的new该对象,并设置properties

构造ProducerRecord对象

除了指定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

关闭producer

减少不必要的系统资源占用,有多种传参方式:

无参的close:优雅关闭,处理完之前的发送请求后再关闭

传timeout:等到一定的超时时间,然后强制关闭

Producer的主要参数

**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<PartitionInfo> 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<String, ?> map) {
        random = new Random();
    }
}

消息序列化

AK针对常见的类型提供了十几种序列化器,比如像ByteArraySerializer, IntegerSerializer。自定义序列化器需要继承AK的Serializer接口

producer拦截器

需要实现接口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

消息压缩

  1. 压缩是IO性能和CPU资源的平衡
  2. AK支持GZIP、Snappy和LZ4,性能由高到低
  3. 压缩性能和producer端的batch有关,batch大小越大,压缩时间就越长

多线程处理

AK在使用过程中会出现两种情况,表格如下

说明 优势 劣势
单Producer实例 所有线程共享一个Producer实例 简单,性能好
多Producer实例 每个线程维护自己专属的Producer实例 可以进行细粒度调优,单个崩溃不会影响其他的工作

旧版本的producer

  1. 旧版本用的是Producer类
  2. 默认同步发送,新版本默认异步发送
  3. 参数列表几乎完全不同
  4. 旧版本直接与ZK通信发送数据,新版本摆脱ZK的依赖

Consumer开发

概览

消费者

新旧consumer的大致对比:

  1. 旧的consumer在使用low-level consumer时,需要用户自行实现错误处理和转移等功能
  2. 新版本的consumer是用Java重写的

consumer大致可以分为:

  1. 消费者组
  2. 独立消费者

消费者组

消费者组的特点:

对于同一个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位移上

__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的所有分区

构建consumer

示例:

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<Object, Object> consumer = new KafkaConsumer<>(props);
        //订阅不是增量式的,多次订阅会覆盖
        consumer.subscribe(Collections.singleton(topicName));
        try {

            while (true) {
                //使用了和selector类似的机制,需要用户轮询
                //1000是超时时间,控制最大阻塞时间
                ConsumerRecords<Object, Object> 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成功消费了消息

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表示不关闭空闲连接

订阅Topic

AK基于支持正则表达式来订阅topic,使用正则的话,就必须指定ConsumerRebalanceListener接口

消息轮询

旧版本采用开启多线程去消费数据的形式。AK使用和linux IO相同的设计模式,采用单线程管理多个与broker的连接实现消息的并行读取。消费逻辑,协调器的协调以及消费者组的reblance,数据的获取都是在这个线程里处理的。

需要注意的是Java consumer是一个双线程的Java进程,还有一个线程是心跳线程

poll的使用

  1. poll方法根据当前consumer的消息位移返回消息集合
  2. 当poll首次被调用时,新的消费者组会被创建并根据auto.offset.reset来设定消费者组的位移;一旦consumer开始提交位移,每个手续的rebalance完成后都会将位置设置为上次已提交的位移
  3. AK的consumer不是线程安全的,如果没有显式的同步保护机制,AK会抛出异常
  4. 可以在一个线程中调用consumer.wakeup(),另一个线程捕捉WakeupException来实现线程通信。需要注意的是,该异常会在下一次的poll中捕捉到

总结一下poll的使用方法:

consumer需要定期执行其他的子任务,推荐较小的超时时间+运行标识布尔变量(判断是否在运行,多线程中可设置结束标识,定义为volatile)的方式

consumer不需要定期执行子任务:推荐poll(MAX_VALUE)+捕获Wakeup异常的方式

位移管理

consumer位移

consumer需要定期向AK提交自己的位置信息,也就是下一条待消费的消息的位置,位移是从0开始。位移是实现各种交付语义的基础,常见的3种交付语义:

最多一次处理语义:消息可能丢失,但不会被重复处理。实现:consumer在消息消费之前就提交位移

最少一次处理语义:消息不会丢失,但可能被处理多次。实现:consumer在消费后提交位移,也是默认提供的

精确一次处理语义:消息一定会被处理且只会被处理一次。老版本不支持,新版本会支持,需要类似事务的机制

关于位移的一些概念:

上次提交位移:最近一次提交的offset

当前位置:consumer已读取但未提交时的位置

水位:也程高水位,不属于consumer管理的范围,而是属于分区日志的概念。consumer是无法读取水位以上的消息

日志终端位移:不属于consumer的范围,表示了某个分区副本当前保存消息对应的最大位置。只有分区所有的副本都保存了某条消息,该分区的leader副本才会向上移动水位值

新版本的位移管理

  1. consumer在broker中选择一个broker作为group的协调器,用于组成员管理、消费分配方案制定以及提交位移等
  2. 协调器的选择依赖内部的位移topic

自动提交和手动提交

  1. 默认情况下consumer自动间隔5s提交位移
  2. 自动提交的问题是用户不能细粒度地处理位移的提交,特别是在有较强的精确一次的处理语义

典型的手动提交代码:

props.put("enable.auto.commit", "false");
final int minBatchSize = 500;
List<ConsumerRecord<String, String>> buffer = new ArrayList<>();
while (true) {
    ConsumerRecords<Object, Object> 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<Object, Object> records = consumer.poll(1000);
        for (TopicPartition partition : records.partitions()) {
            List<ConsumerRecord<Object, Object>> 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位移管理

旧版本的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支持自定义的分配策略,用户可以创建自己的分配器

rebalance generation

为了更好地隔离每次重平衡上的数据,新版本consumer设计了rebalance generation用于标识某次rebalance,通常从0开始,用于防止无效offset提交(上一代的offset)

rebalance 协议

重平衡本质上是一种协议,AK提供了5个协议来处理rebalance

JoinGroup:consumer请求加入组

SyncGroup请求:group leader把分配方案同步更新到组内所有成员中

Heartbeat请求:consumer定期向协调器汇报心跳表明自己依然存活

LeaveGroup请求:consumer主动通知协调器该consumer即将离组

DescribeGroup请求:查看组的所有信息,包括成员信息、协议信息、分配方案以及订阅信息

在重平衡中,协调器主要处理加入组和离开组的请求,成功重平衡之后,组内所有consumer都需要定期向协调器发送Heartbeat请求,而每个consumer也是根据Heartbeat请求的响应中是否包含REBALANCE_IN_PROGRESS来判断当前group是否开启 新一轮rebalance

rebalance流程

  1. 指定协调器:计算groupI的哈希值%分区数量(默认是50)的值,寻找__consumer_offsets分区为该值的leader副本所在的broker,该broker即为这个group的协调器

  2. 成功连接协调器之后便可以执行rebalance操作, 目前rebalance主要分为两步:加入组和同步更新分配方案

    加入组:协调器group中选择一个consumer担任leader,并把所有成员信息以及它们的订阅信息发送给leader

    同步更新分配方案:leader在这一步开始制定分配方案,即根据前面提到的分配策略决定每个consumer都负责那些topic的哪些分区,一旦分配完成,leader会把这个分配方案封装进SyncGroup请求并发送给协调器。注意组内所有成员都会发送SyncGroup请求,不过只有leader发送的SyncGroup请求中包含分配方案。协调器接收到分配方案后把属于每个consumer的方案单独抽取出来作为SyncGroup请求的response返还给给自的consumer

  3. consumer group分配方案是在consumer端执行的

rebalance监听器

AK也支持用户把位移提交到外部存储中,若实现这个功能,用户就必须使用rebalance监听器。如果使用的是独立consumer或是直接手动分配分区,那么rebalance监听器是无效的

consumer.subscribe(Arrays.asList("test-topoic"), new ConsumerRebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        //在协调器开启新一轮rebalance前会调用
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        //rebalance完成后调用
    }
});

注意:consumer在rebalance时检查用户是否启用了自动提交功能,如果是,他会帮用户执行提交,不需要在监听器里面显式提交;另外不要在rebalance中加入复杂的逻辑

多线程消费实例

需要注意的是consumer是非线程安全的,给出两种多线程消费的案例:

每个线程维护一个Consumer实例

代码:

public class ConsumerGroup {

    private List<ConsumerRunnable> consumers;

    public ConsumerGroup(int consumerNum, String groupId, String topic, String brokerList) {
        consumers = new ArrayList<>();
        for(int i = 0;i<consumerNum;++i) {
            ConsumerRunnable consumerRunnable = new 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<String, String> 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<String, String> poll = consumer.poll(200);
            for (ConsumerRecord record : poll) {
                System.out.println(Thread.currentThread().getName() + "consumed " + record.partition() +
                        "th message with offset: " + record.offset());
            }
        }
    }
}

但Consumer实例,多worker实例

代码:

public class ConsumerWorker<K,V> implements Runnable {

    private final ConsumerRecords<K, V> records;
    private final Map<TopicPartition, OffsetAndMetadata> offsets;

    public ConsumerWorker(ConsumerRecords<K, V> records, Map<TopicPartition, OffsetAndMetadata> offsets) {
        this.records = records;
        this.offsets = offsets;
    }

    @Override
    public void run() {
        for (TopicPartition partition : records.partitions()) {
            List<ConsumerRecord<K, V>> 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<K, V> consumer;
    private ExecutorService executors;
    private final Map<TopicPartition, OffsetAndMetadata> 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<TopicPartition> partitions) {
                //提交位移
                consumer.commitSync(                                                );
            }

            @Override
            public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
                offsets.clear();
            }
        });
    }

    public void consumer(int threadNum) {
        executors = new ThreadPoolExecutor(threadNum,
                threadNum,
                0L,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<Runnable>(1000),
                new ThreadPoolExecutor.CallerRunsPolicy());
        try {
            while (true) {
                ConsumerRecords<K, V> 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<TopicPartition, OffsetAndMetadata> 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<Object, Object> 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可以精确控制消费的需求,比如严格控制某个consumer固定地消费哪些分区。实例代码

List<TopicPartition> partitions = new ArrayList<>();
List<PartitionInfo> 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);
}

旧版本的consumer

  1. 旧版本的consumer group和独立consumer分别称为high-level consumer和low-level consumer
  2. 旧版本需要制定zookeeper.connect参数

high-level consumer

  1. 依赖zk完成goup管理的功能
  2. 采用多线程的方式消费,用户可以指定多个线程来消费订阅topic。假设某个consumer group订阅了一个topic,该topic有10个分区,用户在使用旧版本时指定10个线程来消费该topic,那么每个线程都会被分配一个分区。若用户制定了11个线程,,有一个线程不会被分到任何分区

low-level consumer

  1. 用户需要自己提交位移,自己寻找分区的leader broker,自己处理leader变更
  2. 优点是能够实现精确一次的处理语义

AK的设计原理

broker端设计架构

broker通常是以服务器的形式出现的,broker的主要功能就是持久化消息以及将消息队列中的消息从发送端传输到消费端

消息设计

  1. 消息如果采用普通的Java模型,会存在内存重排和字节对齐,可能会填充不必要的字节
  2. AK采用了java nio的ByteBuffer来保存消息,同时依赖文件系统的页缓存机制
  3. ByteBuffer是紧凑的二进制结构,不需要字节对齐,同时也具有很好的扩展性

目前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/

副本与ISR设计

一个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秒

水印和leader epoch

水印也就是前面提到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的消息。

日志存储设计

  1. AK会将消息和元数据信息打包在一起封装成一个record写入日志
  2. 每条记录都会被分配一个唯一且递增的序号
  3. 日志记录的排序按照时间顺序,如果指定用户生成时间戳,可能会导致消息乱序
  4. 没有每个日志来说,又可以分为日志段文件(.log文件)和日志段索引文件(.index和.timeindex文件)
  5. AK为每个分区在文件系统中创建了一个对应的子目录:topic->分区号
  6. 每个.log文件保存了一段位移范围的记录,该文件的名字实际就是起始的位移号
  7. broker端会根据log.segment.bytes控制每个log的大小
  8. 当log文件被填满会会进行日志切分
  9. 正在写入的日志文件成为当前日志段,它不受AK清理和compact的影响

关于索引文件的说明:

索引文件采用稀疏索引的方式,可以通过参数设置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的信息

请求处理流程:

  1. 就FETCH和PRODUCE请求而言,clients只能发给特定分区的leader broker
  2. 确定目标broker后,java clients会创建于broker的连接并一直保持(请求数据只要一个连接)
  3. broker启动会创建一个请求阻塞队列
  4. 在0.10.2.0之前,clients端和broker端之间的兼容性是单向的,即高版本的AK的broker可以处理低版本的client请求,反过来不行。该版本之后采用broker支持的最高版本来构造client请求

controller概览

  1. 每个AK集群任意时刻都只能有一个controller
  2. controller维护的状态分为两种:每台broker上的分区副本和每个分区的leader副本信息,从维度上看,这些状态又可以分为副本状态和分区状态,为了维护这两个状态分别引入了两个状态机
  3. 副本状态机主要管理副本的新建,离线,删除等状态:控制器决定leader分区和ISR,并将这些消息发送给所有副本
  4. 分区状态机主要管理分区的创建、在线、离线等状态:当创建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多线程时间处理模型

broker请求处理

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的大致工作流程

producer接收到消息先进行序列化,然后加上一些元数据,一起发送给partitioner确定目标分区,然后写入消息缓冲池,此时AK的send方法返回。接着Sender线程进行预处理以及发送消息,消息发送完后Sender线程处理response。

可以看到producer发送事件完全是异步过程,因此在调优producer前我们需要搞清楚性能瓶颈到底是在用户主线程还是Sender线程上

consumer端设计

  1. 新版本consumer依赖协调者来管理组内所有consumer实例并负责把分配方案发到每个consumer上,分配方案由组内leader consumer根据指定的分区分配策略指定的。AK为consumer定义了5个状态:Empty、PreparingRebalance、AwaitingSync、Stable、Dead
  2. 对于组管理协议,协调器有两个阶段:为gropu指定active成员并从它们之中选出leader consumer;让leader consumer制定分配方案并同步到其他组成员中

实现一次精确处理语义

  1. 精确处理依赖producer端和consumer端的处理语义,以及事务的支持
  2. AK producer默认提供的是最少处理一次的语义
  3. consumer端的提交语义和位移提交的时间有关,要实现精确一次提交需要依赖事务
  4. 0.11版本的AK 引入了幂等性producer,即消息可能被发送多次,但是在broker端只写入一次
  5. 幂等性采用类似TCP的传输形式,给发送到broker端的没批消息都赋予一个序列号,并且会保存在底层日志中,同时为每个producer配置一个id,该id和分区号构成key,序列号构成value,从而使用该映射消息避免消息的重复发送
  6. AK在应用程序提供一个事务id的情况下能够保证跨应用程序会话间的幂等发送语义,支持跨会话间的事务恢复
  7. consumer的事务支持要弱一些

猜你喜欢

转载自blog.csdn.net/guanhang89/article/details/82563231