基础概念
下面的表格给出了 Kafka 中出现的一些高频和重要概念的对照解释
英文名 | 中文名 | 解释 | 备注 |
---|---|---|---|
Broker | 服务端实例 | 已发布的消息保存在一组服务器中,称之为Kafka集群。集群中的每一个服务器都是一个代理(Broker)。 | 消费者可以订阅一个或多个话题,并从Broker拉数据,从而消费这些已发布的消息。 |
Partition | 分区 | 一个独立不可再分割的消息队列,分区中会有多个副本保存消息,他们的状态应该是一致的 | Kafka 分区副本的同步机制不是纯异步的,有高水位机制去跟踪从副本的同步进度,并且有对应的领导者副本选举机制保证分区整体对外可见的消息都是已提交的 |
Replica | 副本 | 分区中消息的物理存储单元,通常对应磁盘上的一个日志目录,目录中会对消息文件进一步进行分段保存 | 每个Partition都会存在多个副本,其中包括主副本和从副本 |
Leader Replica | 主副本 | 指一个 Partition 的多个副本中,对外提供读写服务的那个副本 | Kafka 集群范围有对等地位的组件是 Controller |
Consumer | 消费者 | Kafka 客户端消费侧的一个角色,负责将 Broker 中的消息拉取到客户端本地进行处理,还可以使用 Kafka 提供的消费者组管理机制进行消费进度的跟踪 | |
Consumer Group Leader | 消费者组领导者 | 通常指 Consumer Group 中负责生成分区分配方案的 Consumer | 这个概念非常容易和上面的 Leader Replica 混淆 |
Message | 消息 | 是通信的基本单位,每个producer可以向一个topic发布一些消息。 | |
Log start offset | 消息起始偏移 | Log start offset,Kafka 分区消息可见性的起点 | 此位置对应一条消息,对外可见可以消费 |
LSO | 上次稳定偏移 | 和 Kafka 事务相关的一个偏移 | 当消费端的参数isolation.level 设置为“read_committed"的时候,那么消费者就会忽略事务未提交的消息,既只能消费到LSO的位置 |
LEO | 消息终止偏移 | 每个Kafka 分区消息的终点 | LEO 是下一条消息将要写入的位置,对外不可见不可供消费 |
HW | 高水位 | 用于控制消息可见性,HW 以下的消息对外可见 | HW 的位置可能对应一条消息,但是对外不可见不可以消费,HW 的最大值是 LEO |
LW | 低水位 | 用于控制消息可见性,LW 及以上的消息对外可见 | 一般情况下和 Log start offset 可以等价替换,代码里也是这个逻辑 |
ISR | 已同步副本 | 指满足副本同步要求的副本集合,包括领导者副本 | 副本同步是按照时间差进行判定的,而非消息偏移的延迟,通常为10s |
OSR | 未同步副本 | 指不满足副本同步要求的副本集合 | |
AR | 所有副本 | 无论该副本是否满足副本同步要求,都包含在该集合内 | AR=ISR+OSR |
Producer
消息发送机制
kafka生产者核心组件
- Metadata 这个组件是管理元数据的,包括集群有哪些broker,然后topic有哪些partition,每个partition的leader 在哪个broker上,follower在哪些broker上等等。
- Partitioner 是个选择partition的组件,比如说你一个topic 有多个partition,它就会按照规则为某条消息选择一个partition。
- RecordAccumulator 缓冲区,累加器,它会将多个发往同一个partition的消息打成一个batch,这样就能够减少传输次数,一次传输成发送多条消息。达到批量发送的效果。
- NetworkClient 网络通信组件,封装了jdk nio一套api,建立连接,发送消息,接收响应,断开连接等等。
图解kafka发送消息原理
这里我们脑子里要有两个线程在不停的运行,一个是业务线程,它会不停的调用KafkaProducer 的send方法进行发送我们产生的消息;另一个是sender 线程,这个线程是KafkaProducer 初始化时创建的。sender线程是从RecordAccumulator 缓冲区中获取消息,然后将消息发送给broker。
业务线程:
- 当业务线程调用KafkaProducer 的send方法发送消息的时候,先会经过一堆拦截器来过滤我们的消息;
- 然后等着获取关于这个topic 的元数据Metadata;
- 接着把指定的key 跟value 进行序列化,这个序列化其实就是使用我们指定的那个两个序列化器来进行,就是将字符串等转成二进制;
- 使用partitioner 根据生产者分区分配原则来指定这个消息发送到哪个partition;
- 接着将消息追加到RecordAccumulator 这个缓冲区中, 在这个内存缓冲区中,一个topic下的partition 对应着一个队列,队列中的元素是batch,batch可以理解为是一批消息。它首先是先找到这个消息topic-partition对应的队列,如果没有的话就创建,然后将消息追加到队列队尾的那个batch中,如果队尾的那个batch不存在或者是满了(满了是由batch.size这个参数控制)就会新创建个batch,将消息追加到这个batch中,然后添加batch到队列队尾;
- 只要追加消息到batch成功,这个业务线程发送这条消息的任务就算结束,就可以返回继续发送其它消息;
sender线程:
- sender 线程首先遍历RecordAccumulator 中的所有partition对应的队列,找出每个对头的batch,检查该batch是否写满,否为重试的,是否超过了linger.ms这个参数配置还未发送出去,满足条件其一,则认为该队列中的batch准备完成,等待发送,并找出这个队列对应partition对应的node;
- 接着遍历准备好的node,检查与该node的连接建立,没有建立连接就请求建立连接等,从node列表中移除未准备好的node;
- 去RecordAccumulator 内存缓冲区中,取出准备好的node下面partition对应队列的batch集合;
- 将获取出来的node与batch集合对应关系的map进行遍历,封装成每个node的请求;
- 遍历这堆请求,找到NetworkClient这个组件进行发送,这个发送其实就是放到每个node对应的channel中了,没有真实的发送,只是给channel一个OP_WRITE事件,当这个sender线程进行poll的时候就会拿到这个事件,然后真正处理发送;
问:此处有个疑问,是发送对应列表中所有的batch,还是只是发送列表中写满了的batch集合,还是只发送列表中第一个batch?
答:应该是每个写满了的batch,有时候可能两次唤醒sender线程的时间间隔偏长,会导致出现两个及两个以上的batch累积到一次进行请求发送。
生产者ACK机制
为保证producer发送的数据能够可靠的发送到指定的topic中,topic的每个partition收到producer发送的数据后,都需要向producer发送ack,如果producer收到ack就会进行下一轮的发送,否则重新发送数据。
发送ack的时机
确保有follower与leader同步完成,leader再发送ack,这样可以保证在leader挂掉之后,follower中可以选出新的leader(主要是确保follower中数据不丢失)。
通过在producer端进行设置可以有如下三种ack应答机制:
- request.required.acks=0;producer不等待broker的ack,这一操作提供了最低的延迟,broker接收到还没有写入磁盘就已经返回,当broker故障时有可能丢失数据。
- request.required.acks=1;producer等待broker的ack,partition的leader落盘成功后返回ack,如果在follower同步成功之前leader故障,那么将丢失数据。(只是leader落盘)
- request.required.acks=-1;producer等待broker的ack,partition的leader和ISR的follower全部落盘成功才返回ack。在follower同步完成后,broker发送ack之前,如果leader发生故障,会造成数据重复。(这里的数据重复是因为没有收到ack,所以继续重发导致的数据重复)。
Exactly Once
服务器的ACK级别设置为-1,可以保证producer到Server之间不会丢失数据,但是不能保证数据不重复,即At Least Once。
服务器的ACK级别设置为0,可以保证生产者每条消息只会被发送一次,可以保证数据不重复,但是不能保证数据不丢失,即At Most Once。
对于重要的数据,则要求数据不重复也不丢失,即Exactly Once即精确的一次。
- 保证数据不丢失,在下游对数据的重复进行去重操作
- 开启幂等性,将去重操作放在数据上游来做,Producer在初始化时会被分配一个PID,发往同一个Partition的消息会附带Sequence Number,而Broker端会对<PID,Partition,Sequence Number>做缓存,当具有相同主键的消息的时候,Broker只会持久化一条。
生产者分区分配策略
分区的原因
方便在集群中扩展:每个partition通过调整以适应它所在的机器,而一个Topic又可以有多个partition组成,因此整个集群可以适应适合的数据
可以提高并发:以partition为单位进行读写。类似于多路。
分区的原则
- 指明partition的情况下,直接将指明的值作为partition的值
- 没有指明partition的情况下,但是存在值key,此时将key的hash值与topic的partition总数进行取余得到partition值
- 值与partition均无的情况下,第一次调用时随机生成一个整数,后面每次调用在这个整数上自增,将这个值与topic可用的partition总数取余得到partition值。
Consumer
消费者分区分配策略
每个Topic一般会有多个partions。Kafka 存在 Consumer Group 的概念,组内的所有消费者协调在一起来消费订阅主题Topic的所有分区partions。当然,每个分区只能由同一个消费组内的一个consumer来消费,因此需要指定消费者分区分配策略,让同一个 Consumer Group 里面的每个 consumer知道该对应消费哪些分区里面的数据。
为了能够及时消费消息,每个 Consumer 又会启动一个或多个streams去分别消费 Topic 里面的数据。
当以下事件发生时,Kafka 将会进行一次分区分配:
- 同一个 Consumer Group 内新增消费者
- 消费者离开当前所属的Consumer Group,包括shuts down 或 crashes
- 订阅的主题新增分区
将分区的所有权从一个消费者移到另一个消费者称为重新平衡(rebalance)。
下面我们将详细介绍 Kafka 内置的两种分区分配策略Range 和 RoundRobin。本文假设我们有个名为 T1 的主题,其包含10个分区,然后我们有两个消费者(C1,C2)来消费这10个分区里面的数据,而且 C1 的 num.streams = 1,C2 的 num.streams = 2。
Range Strategy
Range策略是对每个主题而言的,首先对同一个主题里面的分区按照序号进行排序,并对消费者按照字母顺序进行排序。在我们的例子里面,排完序的分区将会是0, 1, 2, 3, 4, 5, 6, 7, 8, 9;消费者线程排完序将会是C1-0, C2-0, C2-1。然后将partitions的个数除于消费者线程的总数来决定每个消费者线程消费几个分区。如果除不尽,那么前面几个消费者线程将会多消费一个分区。在我们的例子里面,我们有10个分区,3个消费者线程, 10 / 3 = 3,而且除不尽,那么消费者线程 C1-0 将会多消费一个分区,所以最后分区分配的结果看起来是这样的:
C1-0 将消费 0, 1, 2, 3 分区
C2-0 将消费 4, 5, 6 分区
C2-1 将消费 7, 8, 9 分区
假如我们有11个分区,那么最后分区分配的结果看起来是这样的:
C1-0 将消费 0, 1, 2, 3 分区
C2-0 将消费 4, 5, 6, 7 分区
C2-1 将消费 8, 9, 10 分区
假如我们有2个主题(T1和T2),分别有10个分区,那么最后分区分配的结果看起来是这样的:
C1-0 将消费 T1主题的 0, 1, 2, 3 分区以及 T2主题的 0, 1, 2, 3分区
C2-0 将消费 T1主题的 4, 5, 6 分区以及 T2主题的 4, 5, 6分区
C2-1 将消费 T1主题的 7, 8, 9 分区以及 T2主题的 7, 8, 9分区
可以看出,C1-0 消费者线程比其他消费者线程多消费了2个分区,这就是Range strategy的一个很明显的弊端。随着消费者订阅的Topic的数量的增加,不均衡的问题会越来越严重!!!
RoundRobin Strategy
RoundRobin的分配策略是将消费组内订阅的所有Topic的分区及消费组内所有消费者进行排序后尽量均衡的分配(Range是针对单个Topic的分区进行排序分配的)。
使用RoundRobin策略有两个前提条件必须满足:
- 同一个Consumer Group里面的所有消费者的num.streams必须相等;
- 每个消费者订阅的主题必须相同。
如果消费组中的消费者订阅的Topic列表是不同的,那么分配结果是不保证“尽量均衡”的,因为某些消费者不参与一些Topic的分配。
在我们的例子里面,加入按照 hashCode 排序完的topic-partitions组依次为T1-5, T1-3, T1-0, T1-8, T1-2, T1-1, T1-4, T1-7, T1-6, T1-9,我们的消费者线程排序为C1-0, C1-1, C2-0, C2-1,最后分区分配的结果为:
C1-0 将消费 T1-5, T1-2, T1-6 分区
C1-1 将消费 T1-3, T1-1, T1-9 分区
C2-0 将消费 T1-0, T1-4 分区
C2-1 将消费 T1-8, T1-7 分区
Sticky Strategy(拓展)
Sticky分区分配算法,目的是在执行一次新的分配时,能在上一次分配的结果的基础上,尽量少的调整分区分配的变动,节省因分区分配变化带来的开销。可以理解为分配结果是带“粘性的”——每一次分配变更相对上一次分配做最少的变动。其目标有两点:
- 分区的分配尽量的均衡。
- 每一次重分配的结果尽量与上一次分配结果保持一致。
举例:有4个Topic:T0、T1、T2、T3,每个Topic有2个分区。有3个Consumer:C0、C1、C2,所有Consumer都订阅了这4个分区。
核心机制
日志文件存储机制
日志文件
Kafka 中消息是以 topic 进行分类的,生产者生产消息,消费者消费消息,都是面向 topic 的。每个 partition 对应于一个 log 文件,该 log 文件中存储的就是 producer 生产的数据。Producer 生产的数据会被不断追加到该log 文件末端,且每条数据都有自己的 offset。消费者组中的每个消费者,都会实时记录自己消费到了哪个offset,以便出错恢复时,从上次的位置继续消费。
由于生产者生产的消息会不断追加到 log 文件末尾,为防止 log 文件过大导致数据定位效率低下,Kafka 采取了分片和索引机制,将每个 partition 分为多个 segment。
每个 segment 对应三个文件——“.index”索引文件、“.log ”日志文件和“.timeindex”时间索引文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic名称+分区序号。
写入消息
- 新的消息总是写入到最后的一个日志文件中
- 该文件如果到达指定的大小(默认为:1GB)时,将滚动到一个新的文件中
读取消息
- 首先根据offset找到存储数据对应的segment,并根据该offset在.index索引文件中获取全局分区offset;
- 然后根据该全局分区offset在.log日志文件中定位并读取消息;
- 为了提高查询效率,每个文件都会维护对应的范围内存,查找的时候就是使用简单的二分查找;
重平衡(Rebalance)
触发时机
1. 消费者的数量发生变化,影响消费者数量减少的参数:
session.timeout.ms
:Broker端参数,消费者的存活时间,默认10秒,如果在这段时间内,协调者没收到任何心跳,则认为该消费者已崩溃离组;heartbeat.interval.ms
:消费者端参数,发送心跳的频率,默认3秒;max.poll.interval.ms
:消费者端参数,两次调用poll的最大时间间隔,默认5分钟,如果5分钟内无法消费完,则会主动离组;
2. 对分区数量进行修改,但是Kafka只允许增加分区,所以我们只能把分区数量调大,不能调小,否则会收到异常通知。因为删除掉的分区中的消息无法处理,如果分散插入到现有的分区中,那么在消息量很大的时候,内部的数据复制会占用很大的资源,而且在复制期间,此主题的可用性无法得到保障。
3. 消费者订阅主题时使用的是正则表达式,例如“test.*”,表示订阅所有以test开头的主题,当有新的以test开头的主题被创建时,则需要通过再均衡将该主题的分区分配给消费者。
协调者(Coordinator)
消费组的协调管理依赖于 Broker 端某个节点,该节点即是该消费组的 Coordinator, 并且每个消费组有且只有一个 Coordinator,它负责消费组内所有的事务协调,其中包括分区分配,重平衡触发,消费者离开与剔除等等,整个消费组都会被 Coordinator管控着。
consumer group在执行rebalance之前必须首先确认Coordinator所在的broker,并创建与该broker相互通信的Socket连接。确定Coordinator的算法与确定offset被提交到_consumer_offsets目标分区的算法是相同的。算法如下:
- 计算Math.abs(groupID.hashCode)%offsets.topic.num.partitions参数值(默认是50),假设是10.
- 寻找_consumer_offsets分区10的leader副本所在的broker,该broker即为这个group的Coordinator。
协调者与消费者之间的交互
消费者端的心跳线程负责以heartbeat.interval.ms
的间隔频率向协调者发送心跳,同时协调者会返回一个响应。而当Rebalance时,协调者则会在响应中加入REBALANCE_IN_PROGRESS
,当消费者收到响应时,便能知道即将执行再均衡。
Rebalance流程
- 消费者收到
REBALANCE_IN_PROGRESS
响应,立即提交偏移量; - 消费者在收到提交偏移量成功的响应后,再发送JoinGroup请求,重新申请加入组,请求中会含有订阅的主题信息;
- 当协调者收到第一个JoinGroup请求时,会将该消费者指定为Leader消费者。在收集其他消费者的JoinGroup请求中的订阅信息后,将订阅信息放在JoinGroup响应中发送给Leader消费者,并告知他成为了Leader,同时也会发送成功入组的JoinGroup响应给其他消费者;
- Leader消费者收到JoinGroup响应后,根据消费者的订阅信息制定分配方案,把方案放在SyncGroup请求中,发送给协调者。比较有意思的是,组内所有成员都会发送SyncGroup请求,不过只有leader发送的SyncGroup请求中包含了分配方案;
- 协调者收到分配方案后,再通过SyncGroup响应把分配方案发给组内所有消费者;
- 所有消费者收到分配方案后,就意味着再均衡结束,可以正常开始消费工作了;
kafka的请求处理机制
每个Broker 中都有个Acceptor
监听新连接的到来,与新连接建连之后轮询选择一个Processor
管理这个连接。
而Processor
会监听其管理的连接,当事件到达之后,读取封装成Request
,并将Request
放入共享请求队列中。
然后IO线程池不断的从该队列中取出请求,执行真正的处理。处理完之后将响应发送到对应的Processor
的响应队列中,然后由Processor
将Response
返还给客户端。
每个listener
只有一个Acceptor线程
,因为它只是作为新连接建连再分发,没有过多的逻辑,很轻量,一个足矣。
Processor
在Kafka中称之为网络线程,默认网络线程池有3个线程,对应的参数是num.network.threads
。并且可以根据实际的业务动态增减。
还有个 IO 线程池,即KafkaRequestHandlerPool
,执行真正的处理,对应的参数是num.io.threads
,默认值是 8。IO线程处理完之后会将Response
放入对应的Processor
中,由Processor
将响应返还给客户端。
可以看到网络线程和IO线程之间利用的经典的生产者 - 消费者模式,不论是用于处理Request的共享请求队列,还是IO处理完返回的Response。
Kafka 原理以及分区分配策略剖析 - 知乎
深度解析kafka生产者发送消息(原理篇)
Kafka史上最详细原理总结 - chenxiangxiang - 博客园
面试官:说说Kafka处理请求的全流程 - 掘金