Kafka是一种高吞吐量、高性能、高可靠的分布式发布订阅消息系统,主要组件有:生产者(Producer)、消费者(Consumer)、服务代理节点(Broker)、主题(Topic)、分区(Partition),拓扑图如下:
主题是一个逻辑上的概念,Kafka的消息是以主题为单位归类的,生产者发送到Kafka集群中的每一条消息都要指定一个主题,消费者负责订阅主题进行消费,一个主题可以分为多个分区。
分区在存储层面可以看作一个可追加的日志(Log)文件,消息再被追加到分区日志文件时,都会分配给一个特定的偏移量(offset),offset是消息在分区中的唯一标识,通过该标识保证消息在分区中有序。
Kafka分区引入了多副本机制(Replica),通过增加副本数量提升容灾能力,副本处于不同的Broker上,副本之间是一主多从的关系,Leader副本负责处理读写请求,Follower副本只负责与Leader副本的消息同步。当Leader副本出现故障时,从Follower副本中重新选举新的Leader对外服务。
分区中的所有副本统称为 AR(Assigned Replicas)。所有与Leader副本保持一定程度同步的副本(包括Leader副本)组成 ISR(In-Sync Replicas)。与Leader副本同步滞后太多的副本组成 OSR(Out-of-Sync Replicas)。
在消息同步期间,Follower副本相对于Leader副本具有一定程度的滞后,Leader副本负责维护和跟踪ISR集合中所有Follower副本的滞后状态。当Follower副本落后太多或失效时,Leader副本会将它从ISR集合中剔除。只有在ISR集合中的副本才有资格被选举为新的Leader。
默认情况下,创建主题时分区总是从编号为0的分区依次轮询进行分配。
参数名 | 释义 |
---|---|
min.insync.replicas | 分区ISR集合中至少要有多少个副本,默认值为1 |
unclean.leader.election.enable | 是否可以从非ISR集合中选举leader副本,默认值为 false,如果设置为 true,则可能造成数据丢失 |
Broker用来管理主题与分区,以及和生产者消费者之间的消息交互
Kafka中存在大量的延时操作,比如延时生产、延时拉取和延时删除,Kafka使用的是基于时间轮(TimingWheel)的概念自己实现的一个用于延时功能的定时器(SystemTimer)。
时间轮是一个存储定时任务的环形队列,底层采用数组实现,数组中的每一个元素可以存放一个定时任务列表(TimerTaskList)。定时任务列表是一个环形的双向链表,链表中的每一项表示的都是定时任务项(TimerTaskEntry),任务项封装的是真正的定时任务(TimerTask)。
时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度(tickMs),时间格数是固定的(wheelSize),当指针每走一步的时候,会获取当前时钟刻度上挂载的任务并执行,时间轮对于时间的计算是交给一个类似时钟的组件来做,如下图所示:
图中也可以看出时间轮的缺点,不管是单层时间轮,还是层级时间轮,当定时时间变长的时候,要么扩充时间轮间隔,要么每一个刻度记录多个任务,在实际运行中占用的内存是非常可观的。
Kafka集群中有一个Broker会被选举为控制器(Kafka Controller),它负责管理整个集群中所有分区和副本的状态,在任意时刻,集群中有且仅有一个控制器。
控制器的职责包括:
参数名 | 释义 |
---|---|
auto.leader.rebalance.enable | 是否开始自动Leader再均衡的功能,默认值为true |
leader.imbalance.check.interval.seconds | 检查leader是否分布不均衡的周期,默认值为5分钟 |
leader.imbalance.per.broker.percentage | 允许leader不均衡的比例,超过该值会触发再均衡操作,默认值为10。分区不平衡比例 = 非优先副本的leader个数 / 分组总数 |
log.cleaner.delete.retention.ms | 被标识为删除的数据能够保留多久,默认值为1天 |
log.retention.bytes | 分区中所能保留的消息总量,默认值为-1,即没有限制 |
log.retention.ms | 使用日志删除时,消息能够保留的最长时间,默认值为7天 |
log.segment.bytes | 日志分段的最大值,默认值为1GB |
log.index.size.max.bytes | 日志分段索引的最大值,默认为10MB |
发送消息到Broker端,发送消息主要有三种模式:发后即忘(fire-and-forget)、同步(sync)、异步(async)
生产者需要使用序列化器将对象转换成字节数组才能通过网络发送给Kafka,常用的序列化器有StringSerializer、ByteArraySerializer、BytesSerializer、DoubleSerializer等,可以自定义序列化器,实现Serializer接口即可。
消息经过序列化后需要确定发往的分区,如果没有指定分区,需要分区器进行消息分配,可以自定义分区器,实现Partitioner接口即可。
在默认分区器DefaultPartitioner中,如果Key不为null,那么会对Key进行哈希,最终根据得到的Hash值来计算分区号,拥有相同Key的消息会被写入同一个分区。如果Key为null,那么消息会将以轮询的方式发往各个可用分区。
Key可以是一个有明确业务含义的字符串:客户代码、部门编号、业务ID、用来表征消息的元数据等。
这个生产者客户端由主线程和Sender线程(发送线程)协调运行,主线程创建消息,通过拦截器、序列化器、分区器后缓存到消息累加器(RecordAccumulator)中,Sender线程负责从消息累加器中获取消息并将其发送到Kafka中。
消息累加器(RecordAccumulator),也称为消息收集器,用来缓存消息以便Sender线程可以批量发送,减少网络传输的资源消耗以提升性能。缓存大小可以通过生产者客户端参数buffer.memory配置,默认值为32M。
如果生产者发送消息的速度超过发送到服务器的速度,则会导致生产者空间不足,发送会阻塞。如果阻塞时间超过max.block.ms(默认值为60秒),则会抛出异常。
主线程发送的消息会被追加到RecordAccumulator的某个双端队列的尾部,队列内容为ProducerBatch,在RecordAccumulator内部为每个分区都维护了一个双端队列,Sender线程从队列头部读取消息。
消息在网络上是以字节(Byte)的形式传输的,在发送之前需要一块内存区域来保护对应的消息,生产者通过ByteBuffer实现内存的创建与释放。RecordAccumulator的内部有一个BufferPool,用来实现ByteBuffer的复用,BufferPool只管理特定大小的ByteBuffer,由batch.size参数来指定,默认值为16KB。
在写入队列时,如果消息的大小可以写入现有的ProducerBatch,就直接写入,如果不能写入,则新建一个ProducerBatch。如果消息的大小小于batch.size,则以batch.size的大小创建ProducerBatch,这块内存可以复用;如果大于,则以评估大小来创建ProducerBatch,这块内存不可复用。
Sender线程从RecordAccumulator中获取缓存的消息之后,将消息缓存到InFlightRequests中,InFlightRequests缓存了已经发出但还没有收到响应的请求。缓存的同时,还将消息发送给Selector,然后发到Kafka中。最后将发送成功的消息,从InFlightRequests和RecordAccumulator中清除。
参数名 | 释义 |
---|---|
acks | 字符串类型,指定分区中必须有多少个副本收到这个消息,生产者才会认为这条消息是成功写入的。1:默认值为1,即leader副本成功写入消息,服务端就会响应成功,存在消息丢失的风险。0:生产者发送消息之后不需要等待服务端响应,消息丢失风险较大。-1/all:需要ISR中的所有副本都成功写入消息,才响应成功 |
max.request.size | 生产者发送消息的最大值,默认值为1MB |
retries | 生产者出现发送异常后的重试次数,默认值为0 |
retry.backoff.ms | 两次重试之间的间隔,默认值为100ms |
compression.type | 字符串类型,消息的压缩方式,默认值为none,不进行压缩。还可以配置为gzip、snappy |
buffer.memory | 生产者用于缓存消息的缓冲区大小,默认值为32MB |
batch.size | 用于指定ProducerBatch可以复用内存区域的大小,默认值为16KB |
linger.ms | 生产者发送ProducerBatch之前等待更多消息加入ProducerBatch的时间,默认值为0。生产者在ProducerBatch被填满或者等待时间超过linger.ms时发出去 |
max.block.ms | 发送到缓存区的阻塞时间,缓存区已满会请求阻塞,超过改时间会抛出异常,默认值为60s |
request.timeout.ms | 生产者等待请求响应的最长时间,默认值为30s,请求超时可以选择重试 |
Kafka中的消息消费是基于拉模式,是一个不断轮询的过程。
消息消费完需要提交消费位移,消费位移是存储在Kafka的内部主题__consumer_offsets中的,消费位移提交分为两种,自动提交、手动提交
Kafka默认的消费位移的提交方式是自动提交,由参数enable.auto.commit配置,默认为true,自动提交为定期提交,周期时间由参数auto.commit.interval.ms,默认值为5s。
在默认的方式,每隔5秒,消费者向服务端发起拉取请求前,会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移。
自动提交会出现重复消费和消息丢失的问题。假如刚提交完上一次的消费位移,拉取新的一批消息进行消费,在新的消费位移自动提交之前,消费者崩溃,重启后需要从上一次位移提交的位置重新消费,造成重复消费。假如拉取完消息,将消息存入本地缓存再去消费,存储完成后消费位移就提交了,但是从缓存中读取并处理发生了异常,则消费位移已经更新,会造成消息丢失。
手动提交又分为同步提交和异步提交
同步提交在消息消费完成之后,提交消费位移,提交成功之后,开始新一次的拉取操作,从而避免了消息丢失,还会有重复消费的情况,但是性能比较差。
异步提交在消费完成后提交位移,无需等待broker的响应,开始新的拉取,会造成消息丢失和重复消费的问题。
消费者通过参数partition.assignment.strategy来设置消费者与订阅主题之间的分区分配策略,默认为RangeAssignor,另外还有两种分配策略:RoundRobinAssignor、StickyAssignor
Range策略会将消费组内所有订阅这个主题的消费者按照名称的字母顺序进行排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么名称字母顺序靠前的消费者会被多分配一个分区。
比如有两个主题T0和T1,并且每个主题都有3个分区P0、P1、P2,如果消费组内有两个消费者C0、C1,那么分配结果就为:
Range策略会将消费组内所有订阅这个主题的消费者按照名称进行排序,然后通过轮询的方式逐个将分区依次分配给每个消费者。
上述例子的分配结果为:
如果同一个消费组内的消费者订阅的信息是不相同的,那么在执行分区分配的时候就不是完全的轮询,有可能导致分区分配不均匀。
比如消费组内有三个主题T0、T1、T2,分别有1、2、3个分区,即总共有六个分区T0P0、T1P0、T1P1、T2p0、T2P1、T2P2,消费者C0订阅的是主题T0,消费者C1订阅的是主题T0、T1,消费者C2订阅的是主题T0、T1、T2,则分配结果为:
Sticky策略的目标有两个:
如上面第二个例子,使用该策略分配结果为:
再均衡是通过消费者协调器(ConsumerCoordinator)和组协调器(GroupCoordinator)实现的,分区分配也是在再均衡期间完成的,触发再均衡的操作有:
再均衡流程
以新消费者加入消费组为例,再均衡操作分为以下几个阶段:
找到消费组对应的组协调器
消费者需要确定他所有的消费组对应的GroupCoordinator所在的Broker,并创建与该Broker相互通信的网络连接。如果消费者已经保存了与消费组对应的GroupCoordinator节点信息,并且网络连接是正常的,该阶段可以跳过。
加入消费组
消费者发送JoinGroupRequest请求加入消费组,组协调器会给消费者分配一个唯一的member_id,所有的消费者信息是以HashMap的形式存储的,Key为member_id,value为消费者的元数据信息
选举消费者的Leader
组协调器为消费组内的消费者选举出一个消费组的Leader,选举算法为,当消费组内没有Leader,第一个加入消费组的消费者即为消费组的Leader。如果某一时刻Leader消费者退出消费组,取上述HashMap中的第一个member_id,跟随机无异。
选举分区分配策略
选举过程为,收集各个消费者支持的所有分配策略,组成候选集,每个消费者从候选集中找出一个自身支持的策略,为该策略投票,最后选票数最多的策略即为当前消费组的分配策略。
同步
Leader消费者根据分区分配策略将分配方案同步给各个消费者,通过组协调器进行转发同步分配方案。
心跳检测
该阶段已经再均衡完成,消费者需要确定拉取消息的起始位置,如果__consumer_offsets主题中有该消费者提交的位移消费,则从该位移消费处继续消费,如果没有,从头开始消费。
参数名 | 释义 |
---|---|
enable.auto.commit | 消费位移提交方式,默认值为true,自动提交 |
auto.commit.interval.ms | 自动提交消费的周期,默认值为5秒 |
auto.offset.reset | 消费者查找不到消费位移是,从何处进行消费。latest:默认值,从分区末尾开始消费消息;earliest:从分区起始处开始消费消息;none:抛出异常 |
fetch.min.bytes | 消费者一次拉取所有分区,拉取的最小数据量,默认值为1B |
fetch.max.bytes | 消费者一次拉取所有分区,拉取的最大数据量,默认值为50MB |
fetch.max.wait.ms | 消费者一次拉取最长等待时间,默认值为500ms |
max.partition.fetch.bytes | 消费者在每个分区拉取的最大数据量,默认值为1MB |
max.poll.records | 消费者一次拉取所有分区最大消息数,默认值为500条 |
heartbeat.interval.ms | 消费组内消费者心跳检测间隔时间,默认为3秒 |
session.timeout.ms | 组协调器超过该指定时间没有收到心跳报文则认为消费者已经下线,默认为10秒 |
group.min.session.timeout.ms | 组协调器判断消费者下线最小设置时间,默认为6秒,用来限制session.timeout.ms参数 |
group.max.session.timeout.ms | 组协调器判断消费者下线最大设置时间,默认为5分钟,用来限制session.timeout.ms参数 |
max.poll.interval.ms | 消费组再平衡时,组协调器等待各个消费者重新加入的最长等待时间,默认为5分钟 |