Kafka高级

129

首先先认识一下Kafka相关概念 Topic 主题 ,相当于关系型数据库的表 一个topic会被被拆分成多个partition,用于负载均衡 partition 分区 分区会有多个副本,leader-flower模式 Broker kafka程序 Message 消息 provider 生产者 consumer 消费者

一 基础概念

Topic(主题)

image.png 主题在创建之后可以进行编辑,如编辑分区数,但是只能增加分区数,不能减少分区数。一般也不建议修改分区数,如果修改分区数据会触发rebalance,有可能造成消息的顺序。

Partition(分区)

一个主题下面有多个分区,这些分区会存储到不同的服务器(Broker)上面,这些分区主要的信息就存在了.log文件里面。跟数据库里面的分区差不多,用于负载均衡,是为了提高性能。

为什么提高了性能?

很简单,多个分区多个线程,多个线程并行处理肯定会比单线程好得多

分区越多吞吐量就越高吗?

并不是,一旦分区数超过了某个阈值,整体的吞吐量不升,反而会降

同一个分区的消息消费是有续的,但是不能保证多个分区之间消息的顺序消费 image.png Topic和partition像是HBASE里的table和region的概念,table只是一个逻辑上的概念,真正存储数据的是region,这些region会分布式地存储在各个服务器上面,对应于kafka,也是一样,Topic也是逻辑概念,而partition就是分布式存储单元。这个设计是保证了海量数据处理的基础。

分区会有单点故障问题,所以我们会为每个分区设置副本数,是一种副本之间leader-flower的模式,分区的编号是从0开始的 此时我们对分区0,1,2分别设置3个副本(其实设置两个副本是比较合适的)

provider生产和consumer消费的入口都是针对的leader partition,生产者在发送数据的时候,是直接发送到leader partition里面,然后follower partition会去leader那里自行同步数据,消费者消费数据的时候,也是从leader那去消费数据的「读写操作都是发生在leader身上,follower只是进行数据同步,leader挂了以后需要选举新的leader(优先副本选举原则)」

分区出现负载不平衡时「服务端的负载是否均衡就基本上取决于leader分布的是否均匀」,kafka支持自动平衡的功能,kafka控制器会开启定时任务去计算每个节点的分区不平衡率,然后进行再分区,但不建议开启它,会引起负面的性能问题

juejin.cn/post/704033…

分区副本如何分配

在创建topic的时候,可以指定分区数和副本数,也可以指定分区的副本分别分配到那个broker上,那么只是指定分区数和副本数,那么kafka是怎样去分配分区呢?

kafka分为指定机架和未制定机架的情况,下面是未制定机架分区分配的源码

// 未指定机架信息的分配策略
private def assignReplicasToBrokersRackUnaware(nPartitions: Int, // 分区号
                                               replicationFactor: Int, // 副本因子
                                               brokerList: Seq[Int], //集群broker地址
                                               fixedStartIndex: Int,// 起始索引,即第一个副本分配的位置,默认未-1
                                               startPartitionId: Int) // 起始分区编号 ,默认未-1
                                              : Map[Int, Seq[Int]] = {
  // 创建一个可变的map 用于保存分配结果的集合
  val ret = mutable.Map[Int, Seq[Int]]()
  // broker 列表
  val brokerArray = brokerList.toArray
  // fixedStartIndex默认为-1,可以看作startIndex是根据brokerArray随机选择了一个起始索引
  val startIndex = if (fixedStartIndex >= 0) fixedStartIndex else rand.nextInt(brokerArray.length)
  // 当前分配的分区
  var currentPartitionId = math.max(0, startPartitionId)
  // 指定了副本间隔,将多个分区副本分配到不同的broker上
  var nextReplicaShift = if (fixedStartIndex >= 0) fixedStartIndex else rand.nextInt(brokerArray.length)
  // 遍历分区
  for (_ <- 0 until nPartitions) {
    // 如果不是第一个分区,副本间隔 + 1
    if (currentPartitionId > 0 && (currentPartitionId % brokerArray.length == 0))
      nextReplicaShift += 1
    // 计算第一个副本在哪个broker
    val firstReplicaIndex = (currentPartitionId + startIndex) % brokerArray.length
    val replicaBuffer = mutable.ArrayBuffer(brokerArray(firstReplicaIndex))
    // 计算其他的副本分到哪些broker上
    for (j <- 0 until replicationFactor - 1)
      replicaBuffer += brokerArray(replicaIndex(firstReplicaIndex, nextReplicaShift, j, brokerArray.length))
    // 保存分配结果
    ret.put(currentPartitionId, replicaBuffer)
    currentPartitionId += 1
  }
  ret
}

//计算其他的副本分到哪些broker上
private def replicaIndex(firstReplicaIndex: Int, secondReplicaShift: Int, replicaIndex: Int, nBrokers: Int): Int = {
  val shift = 1 + (secondReplicaShift + replicaIndex) % (nBrokers - 1)
  (firstReplicaIndex + shift) % nBrokers
}

分区重分配 当对集群中的某个broker挂了,或者有计划的进行下线,或者扩容新增broker时,需要再次让分区副本进行合理分配。分区重分配对集群的性能有很大的影响,需要占据网络、磁盘等资源,在实际操作中,我们将降低重分配的粒度,分多多个小批次来执行。

复制限流 分区重分配的本质在于数据复制,先增加新的副本,然后进行数据同步,最后删除旧的副本达到最终目的。数据复制会占用额外的资源,如果重分配的量太大必然会严重影响整体的性能,尤其是在业务高峰期的时候。减少重分配的粒度,以小批的方式来操作是一种可行的思路。如果集群中某个主题或者分区的流量在某段时间的流量特别大,只靠减小粒度是不足以应对的,这时就需要限流机制,可对副本间的复制流量加以限制来保证重分配期间整体服务不会受太大的影响。

Broker

broker实际就是一个kafka程序,可以理解为kafka集群的一个节点,kafka也是主从式的架构,主节点就叫controller,其余的为从节点,controller是需要和zookeeper进行配合管理整个kafka集群。

kafka严重依赖于zookeeper集群,所有的broker在启动的时候都会往zookeeper进行注册,目的就是选举出一个controller,这个选举过程非常简单粗暴,就是一个谁先谁当的过程,不涉及什么算法问题。

那成为controller之后要做啥呢?
监听zookeeper里面的多个目录,例如有一个目录/brokers/,其他从节点往这个目录上注册自己,这时命名规则一般是它们的id编号,比如/brokers/0,1,2.
注册时各个节点必定会暴露自己的主机名,端口号等等的信息,此时controller就要去读取注册上来的从节点的数据(通过监听机制),生成集群的元数据信息,之后把这些信息都分发给其他的服务器,让其他服务器能感知到集群中其它成员的存在

image.png 此时模拟一个场景,我们创建一个主题(其实就是在zookeeper上/topics/topicA这样创建一个目录而已),kafka会把分区方案生成在这个目录中,此时controller就监听到了这一改变,它会去同步这个目录的元信息,然后同样下放给它的从节点,通过这个方法让整个集群都得知这个分区方案,此时从节点就各自创建好目录等待创建分区副本即可。这也是整个集群的管理机制。

Kafka的元数据都指代什么?Kafka分为服务器端和客户端:
服务器端的元数据通常是指集群的元数据,包括集群有哪些Broker,有哪些主题,每个主题都有哪些分区,而每个分区的Leader副本在哪台Broker上等信息。这些信息保存在ZooKeeper和Controller中。Kafka以ZooKeeper中保存的元数据为权威数据,Controller会从ZooKeeper中获取最新的元数据并缓存在自己的内存中。
客户端的元数据通常是指消费者的注册信息和位移信息。在Kafka 0.9版本之前,这些信息的确保存在ZooKeeper中。不过目前已经废弃了。新版本的消费者把这些信息保存在一个Kafka的内部主题中,由集群中一个名为Coordinator的组件进行管理。

Producer - 生产者

Producer是生产消息方,负责向kafka中发送消息。 过程\

  1. 封装ProducerRecord对象 : 生产者需要往集群发送消息前,要先把每一条消息封装成ProducerRecord对象,这是生产者内部完成的。\
  2. 序列化 : 之后会经历一个序列化的过程(需要经过网络传输的数据都是二进制的一些字节数据,需要进行序列化才能传输)
  3. 拉取远数据信息,选择leader partition : controller可以视作为broker的领导,负责管理集群的元数据,从controller存储的元数据中就可以得到leader partition的信息。leader partition是做负载均衡用的,它们会分布式地存储在不同的服务器上面。集群中生产数据也好,消费数据也好,都是针对leader partition而操作的。「对于选择哪个分区的leader,producer是有对应的分区策略的」
  4. 放入缓冲区 : 生产者不着急把消息发送出去,而是先放到一个缓冲区,等积累到一定数据的消息后,一起批量发送。
  5. Sender发送 :把消息放进缓冲区之后,与此同时会有一个独立线程Sender去把消息分批次包装成一个个Batch,不难想到如果Kafka真的是一条消息一条消息地传输,一条消息就是一个网络连接,那性能就会被拉得很差。为了提升吞吐量,所以采取了分批次的做法

image.png

kafka内部如何进行处理和响应请求的「Kafka的网络三层架构」?
客户端(生产者,消费者)发送请求全部会先发送给一个Acceptor,broker里面会存在3个线程(默认是3个),这3个线程都是叫做processor,Acceptor不会对客户端的请求做任何的处理,直接封装成一个个socketChannel发送给这些processor形成一个队列,发送的方式是轮询,就是先给第一个processor发送,然后再给第二个,第三个,然后又回到第一个。消费者线程去消费这些socketChannel时,会获取一个个request请求,这些request请求中就会伴随着数据。
线程池里面默认有8个线程,这些线程是用来处理request的,解析请求,如果request是写请求,就写到磁盘里。读的话返回结果。 processor会从response中读取响应数据,然后再返回给客户端。这就是Kafka的网络三层架构。 所以如果我们需要对kafka进行增强调优,增加processor并增加线程池里面的处理线程,就可以达到效果。request和response那一块部分其实就是起到了一个缓存的效果,是考虑到processor们生成请求太快,线程数不够不能及时处理的问题。

image.png

读写数据到磁盘时性能优化

① 顺序写

操作系统每次从磁盘读写数据的时候,需要先寻址,也就是先要找到数据在磁盘上的物理位置,然后再进行数据读写,如果是机械硬盘,寻址就需要较长的时间。
kafka的设计中,数据其实是存储在磁盘上面,一般来说,会把数据存储在内存上面性能才会好。但是kafka用的是顺序写,追加数据是追加到末尾,磁盘顺序写的性能极高,在磁盘个数一定,转数达到一定的情况下,基本和内存速度一致
随机写的话是在文件的某个位置修改数据,性能会较低。

② 零拷贝

先来看看非零拷贝的情况

可以看到数据的拷贝从内存拷贝到kafka服务进程那块,又拷贝到socket缓存那块,整个过程耗费的时间比较高,kafka利用了Linux的sendFile技术(NIO),省去了进程切换和一次数据拷贝,让性能变得更好。

生产者选择分区策略

image.png

Consumer - 消费者

consumer采用pull(拉)模式从broker「coordinator」 leader partition中读取数据。 拉取模式也有不足,如果kafka没有数据,消费者可能会陷入循环中,一直返回空数据。针对这一点,kafka消费者在消费数据时会传入一个时长参数timeout,如果当前没有数据可供消费,consumer会等待一段时间后再返回,这段时长即为timeout.

注意:消费者在消消费的时候拉取的实际上是一个消息集

1、广播和组播

我们所熟知的一些消息系统一般来说会这样设计,就是只要有一个消费者去消费了消息系统里面的数据,那么其余所有的消费者都不能再去消费这个数据,这是一种单播机制。
kafka引入了group 组的概念,组内消费者是使用单播,即组播,组内消费者不能重复消息。如果想实现广播,就让消费者都放到不同的组里进行广播。
所以在kafka中,不同组可有唯一的一个消费者去消费同一主题的数据

image.png

如图,因为前面提到过了消费者会直接和leader建立联系,所以它们分别消费了三个leader,所以一个分区不会让消费者组里面的多个消费者去消费,但是在消费者不饱和的情况下,一个消费者是可以去消费多个分区的数据的,实际上一个消费者可以订阅多个主题,并且可以指定消费的分区且可以是多个

消费者何消费者组的关系

  1. 在同一个消费者组内,一个 Partition 只能被一个消费者消费。
  2. 在同一个消费者组内,所有消费者组合起来必定可以消费一个 Topic 下的所有 Partition。
  3. 在同一个消费组内,一个消费者可以消费多个 Partition 的信息。
  4. 在不同消费者组内,同一个分区可以被多个消费者消费。
  5. 每个消费者组一定会完整消费一个 Topic 下的所有 Partition。

消费者是如何和分区进行负载均衡的?

2、分区方案的负载均衡

如果临时有consumer加入或退出,leader consumer就需要重新制定消费方案。
比如我们消费的一个主题有12个分区: p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,p10,p11
假设我们的消费者组里面有三个消费者

range策略

range策略就是按照partiton的序号范围

p0~3             consumer1
p4~7             consumer2
p8~11            consumer3
复制代码
round-robin策略
consumer1:0,3,6,9
consumer2:1,4,7,10
consumer3:2,5,8,11
复制代码

但是前面的这两个方案有个问题: 假设consuemr1挂了:p0-5分配给consumer2,p6-11分配给consumer3 这样的话,原本在consumer2上的的p6,p7分区就被分配到了 consumer3上。

sticky策略

最新的一个sticky策略,就是说尽可能保证在rebalance的时候,让原本属于这个consumer 的分区还是属于他们, 然后把多余的分区再均匀分配过去,这样尽可能维持原来的分区分配的策略

consumer1:0-3
consumer2:  4-7
consumer3:  8-11 
假设consumer3挂了
consumer1:0-3,+8,9
consumer2: 4-7,+10,11
复制代码

消费者组中各个消费者的分区策略可能不同,那么例如在发生再均衡后应该选择哪个策略作为分区策略: 消费者中哪个策略选择的多就选择哪个。

Rebalance分代机制

在rebalance的时候,可能你本来消费了partition3的数据,结果有些数据消费了还没提交offset,结果此时rebalance,把partition3分配给了另外一个consumer了,此时你如果提交partition3的数据的offset,能行吗?必然不行,所以每次rebalance会触发一次consumer group generation,分代,每次分代会加1,然后你提交上一个分代的offset是不行的,那个partiton可能已经不属于你了,大家全部按照新的partiton分配方案重新消费数据。

什么是rebalance?

Rebalance 本质上是一种协议,规定了一个 Consumer Group 下的所有 consumer 如何达成一致,来分配订阅 Topic 的每个分区。
例如:某 Group 下有 20 个 consumer 实例,它订阅了一个具有 100 个 partition 的 Topic 。正常情况下,kafka 会为每个 Consumer 平均的分配 5 个分区。这个分配的过程就是 Rebalance。

触发 Rebalance 的时机

Rebalance 的触发条件有3个:

  • 组成员个数发生变化。例如有新的 consumer 实例加入该消费组或者离开组。
  • 订阅的 Topic 个数发生变化。
  • 订阅 Topic 的分区数发生变化。

Rebalance 发生时,Group 下所有 consumer 实例都会协调在一起共同参与,kafka 能够保证尽量达到最公平的分配。但是 Rebalance 过程对 consumer group 会造成比较严重的影响。在 Rebalance 的过程中 consumer group 下的所有消费者实例都会停止工作,等待 Rebalance 过程完成。

Rebalance 过程分析 Rebalance 过程分为两步:Join(加入组) 和 Sync(信息同步,分配消费方案)。

  1. Join 顾名思义就是加入组。这一步中,所有成员都向coordinator发送JoinGroup请求,请求加入消费组。一旦所有成员都发送了JoinGroup请求,coordinator会从中选择一个consumer担任leader的角色,并把组成员信息以及订阅信息发给leader——注意leader和coordinator不是一个概念。leader负责消费分配方案的制定。

img 2. Sync

  1. 消费者leader开始分配消费方案,即哪个consumer负责消费哪些topic的哪些partition
  2. 一旦完成分配,leader会将这个方案封装进SyncGroup请求中发给coordinator,非leader也会发SyncGroup请求,只是内容为空。
  3. coordinator接收到分配方案之后会把方案塞进SyncGroup的response中发给各个consumer。这样组内的所有成员就都知道自己应该消费哪些分区了。

img

Rebalance 场景分析

新成员加入组

img

  1. 消费者成员会和coordinator保持心跳检测。
  2. 新成员申请加入组(开始rebalance),发送自己的信息和订阅的主题信息。
  3. coordinator会通知组内其他消费者重新加入组。
  4. 所有成员都向coordinator发送JoinGroup请求。
  5. coordinator会从中选择一个consumer担任leader的角色,并把组成员信息以及订阅信息发给leader。
  6. 所有成员发送SyncGroup请求,leader会将这个方案封装进SyncGroup请求中
  7. coordinator接收到分配方案之后会把方案塞进SyncGroup的response中发给各个consumer

组成员“崩溃”

组成员崩溃和组成员主动离开是两个不同的场景。 因为在崩溃时成员并不会主动地告知coordinator此事,coordinator有可能需要一个完整的session.timeout周期(心跳周期)才能检测到这种崩溃,这必然会造成consumer的滞后。可以说离开组是主动地发起rebalance;而崩溃则是被动地发起rebalance。

img

组成员主动离开组

img

提交位移

img

coordinator与consumer之间的心跳检测

消费者成员正常的添加和停掉会导致rebalance,这种情况无法避免,但是时在某些情况下,Consumer 实例会被 Coordinator 错误地认为 “已停止” 从而被“踢出”Group,从而导致rebalance
心跳检测时长超长造成rebalance:
每个 Consumer 实例都会定期地向 Coordinator 发送心跳请求,表明它还存活着。如果某个 Consumer 实例不能及时地发送这些心跳请求,Coordinator 就会认为该 Consumer 已经 “死” 了,从而将其从 Group 中移除,然后开启新一轮 Rebalance。这个时间可以通过Consumer 端的参数 session.timeout.ms进行配置。默认值是 10 秒
除了这个参数,Consumer 还提供了一个控制发送心跳请求频率的参数,就是 heartbeat.interval.ms。这个值设置得越小,Consumer 实例发送心跳请求的频率就越高。频繁地发送心跳请求会额外消耗带宽资源,但好处是能够更加快速地知晓当前是否开启 Rebalance,因为,目前 Coordinator 通知各个 Consumer 实例开启 Rebalance 的方法,就是将 REBALANCE_NEEDED 标志封装进心跳请求的响应体中。 消费时常超长发起离组请求,开启rebalance:
除了以上两个参数,Consumer 端还有一个参数,用于控制 Consumer 实际消费能力对 Rebalance 的影响,即 max.poll.interval.ms 参数。它限定了 Consumer 端应用程序两次调用 poll 方法的最大时间间隔。它的默认值是 **5 分钟,**表示你的 Consumer 程序如果在 5 分钟之内无法消费完 poll 方法返回的消息,那么 Consumer 会主动发起 “离开组” 的请求,Coordinator 也会开启新一轮 Rebalance。

offset 偏移量

当消费者程序出现了问题停止运行或者手动关闭,那么消费者重新开启后,会从哪里开始消费呢? consumer如何想从故障前的位置的继续消费,那么就需要实时记录自己消费到了哪个offset,以便故障恢复后继续消费。
所以偏移量其实就是记录一个位置而使用的,用来标识消费者这次消费到了这个位置。 consumer如何维护offset? 在kafka里面,kafka是不帮忙维护这个offset偏移量的(只负责保存),这个offset需要consumer自行维护。
offset的存储位置在0.8版本之前,是存放在zookeeper里面的。这个设计明显是存在问题的,整个kafka集群有众多的topic,而系统中又有成千上万的消费者去消费它们,如果offset存放在zookeeper上,消费者每次都要提交给zookeeper这个值,这样zookeeper顶不住的!
在0.8版本之后,kafka就把这个offset存在了内部的一个主题里面(consumer_offset),这个内部主题默认有50个分区.消费者组是group.id的。提交偏移量时会把偏移量消息发布到主题中,消息key是group.id+topic+分区号(这是为了通过key来取模计算提交消息的分区保证Kakfa集群中同分区的数据偏移量都提交到consumer_offset的同一个分区下)。value就是当前offset的值,每隔一段时间,kafka内部会对这个topic进行compact。也就是每个group.id+topic+分区号就保留最新的那条数据即可。而且因为这个 consumer_offsets可能会接收高并发的请求,所以默认分区50个,这样如果你的kafka部署了一个大的集群,比如有50台机器,就可以用50台机器来抗offset提交的请求压力,就好很多。
kafka提供了两个关于offset的参数,一个是enable_auto_commit,当这个参数设置为true的时候,每次重启kafka都会把所有的数据重新消费一遍。再一个是auto_commit_interval_ms,这个是每次提交offset的一个时间间隔。

offset 偏移量提交

我们把更新分区当前位置的操作叫作提交。 如果消费者一直处于运行状态,维护偏移量的意义并不大。不过,如果悄费者发生崩溃或者有新的消费者加入群组时,就会触发再均衡(rebalance),完成再均衡之后,每个消费者可能分配到新的分区,而不是之前处理的那个。为了能够继续 之前的工作,消费者需要读取每个分区最后一次提交 的偏移量,然后从偏移量指定的地方 继续处理。\

注意:消费者在进行提交的位移并不是当前消费的偏移量,而是偏移量+1

image.png 位移提交是在消费完所有拉取到的消息之后才执行的,如果不能正确提交偏移量,就可能发生数据丢失或重复消费。

如果在消费到 x+2 的时候发生异常,发生故障,在故障恢复后,重新拉取消息还是从 x处开始,那么之前 x到 x+2 的数据就重复消费了。

如果在消费到 x+2 的时候,提前把 offset 提交了,此时消息还没有消费完,然后发生故障,等重启之后,就从新的 offset x+5 处开始消费,那么 x+2 到 x+5 中间的消息就丢失了。

因此,在什么时机提交 偏移量 显的尤为重要,在 Kafka 中位移的提交分为手动提交和自动提交,下面对这两种展示讲解。
自动提交偏移量
在 Kafka 中默认的消费位移的提交方式是 自动提交。这个在消费者客户端参数 enable.auto.commit 配置,默认为 true。它是定期向 _comsumer_offsets 中提交 poll 拉取下来的最大消息偏移量。定期时间在 auto.commit.interval.ms 配置,默认为 5s。
自动提交时间设置并不是非常严格,可能会大于设置的时间间隔;自动提交并不是严格地每间隔一段时间提交一次偏移量(旧版的客户端是有一个AutoCommitTask进行轮询提交),而是每次在调用 KafkaConsumer.poll()时判断当前时间距离上次提交时间是否超过了配置了提交间隔,如果超过了就进行提交,所以实际上的提交时间会超过配置的提交间隔.
虽然自动提交消费位移的方式非常方便,让编码更加简洁,但是自动提交是存在问题的,就是我们上面说的数据丢失和重复消费,这两种它一个不落,因此,Kafka 提供了手动提交位移量,更加灵活的处理消费位移。
手动提交偏移量
开启手动提交位移的前提是需要关闭自动提交配置,将 enable.auto.commit 配置更改为 false。

根据用户需要,这个偏移量值可以是分为两类:
常规的,手动提交拉取到的最大偏移量。
手动提交固定值的偏移量。

手动提交offset的方法有两种:分别是commitSync(同步提交)和commitAsync(异步提交)。两者的相同点是,都会将本次poll的一批数据最高的偏移量提交;不同点是,commitSync阻塞当前线程,一直到提交成功,并且会自动失败重试(由不可控因素导致,也会出现提交失败);而commitAsync则没有失败重试机制,故有可能提交失败。

消息重复和消息丢失

消息重复和丢失是kafka中很常见的问题,主要发生在以下三个阶段:

  1. 生产者阶段
  2. broke阶段
  3. 消费者阶段

生产者消息重复和丢失

丢失原因:
当producer 的acks参数值设置为‘0’或者‘1’,不等待服务器确认或者只让leader确认,因为没有完成的确认机制,如果消息发送失败,producer是没有感知的,也不会有什么动作,只会继续向下生产数据,所以会有丢失的情况。
解决方案:
开启acks机制,将acks的值设置为all或者-1,让leader和followers全部进行确认。当生产者发送完消息后,分区们接受到消息后会给一个ack的确认,这样producer就知道发送成功了,如果迟迟没有接受到ack确认,producer会任务消息发送失败了,此时会进行重试。

重复原因:
开启ack后会避免消息丢失的问题,但这样也会引发消息重复的情况。
生产发送的消息没有收到正确的broke响应,导致producer重试。 如:producer发出一条消息,broke落盘以后因为网络等种种原因发送端得到一个发送失败的响应或者网络中断,然后producer收到一个可恢复的Exception重试消息导致消息重复。

image.png 说明:

  1. new KafkaProducer()后创建一个后台线程KafkaThread扫描RecordAccumulator中是否有消息;
  2. 调用KafkaProducer.send()发送消息,实际上只是把消息保存到RecordAccumulator中;
  3. 后台线程KafkaThread扫描到RecordAccumulator中有消息后,将消息发送到kafka集群;
  4. 如果发送成功,那么返回成功;
  5. 如果发送失败,那么判断是否允许重试。如果不允许重试,那么返回失败的结果;如果允许重试,把消息再保存到RecordAccumulator中,等待后台线程KafkaThread扫描再次发送;

解决方案:
1、启动kafka的幂等性
无需修改代码,默认为关闭,需要修改配置件:enable.idempotence=true

同时要求 ack=all or -1 且 retries>1,max.in.flight.requests.per.connection<5「单个连接上发送的未确认请求的最大数量,表示上一个发出的请求没有确认下一个请求又发出了」,否则会抛出ConfigException.

幂等原理:

每个producer有一个producer id,服务端会通过这个id关联记录每个producer的状态,每个producer的每条消息会带上一个递增的sequence,服务端会记录每个producer对应的当前最大sequence「producerId + sequence 」\

  1. 如果新的消息带上的sequence不大于当前的最大sequence,即发送来的消息的序列值如果小于等于当前服务器存的序列值,就是会被认定为消息已经消费了,此时发生消息重复,就拒绝这条消息。如果消息落盘会同时更新最大sequence,这个时候重发的消息会被服务端拒掉从而避免消息重复。该配置同样应用于kafka事务中。\
  2. 如果新的消息带上的sequence大于当前的最大sequence+1,即发送来的消息的序列值如果大于等于当前服务器存的序列值+1,就是会被认定为消息已经乱了,此时发生会抛出异常。\
  3. 如果新的消息带上的sequence等于最大sequence+1,此时是正常消费。
    2、引入序列号来实现幂等性也只是针对每一对<PID,分区>而言的,也就是说,kafka的幂等性也只能保证单个生产者会话中单分区的幂等性。幂等性并不能跨越多个分区运作,而事务可以弥补这个缺陷。事务可以保证对多个分区写入操作的原子性。操作原子性指多个操作要么成功要么失败,不存在部分成功,部分失败的可能。
    为了实现事务,应用程序必须提供一个唯一的transcationalId,这个事务id通过客户端参数来显示设置,如下
properties.put("transactional.id","transcationId")

事务要求生产者开启幂等特性,因此通过将transactional.id 参数设置为非空从而开启事物特性的同时需要将enable.idempotencetrue
image.png

Kafka producer关于事务的5个方法

  • initTransactions()
  • beginTransaction()
  • sendOffsetsToTransaction()
  • commitTransaction()
  • abortTransaction() 3、关闭重试,ack=0
    可能会丢消息,适用于吞吐量指标重要性高于数据丢失,例如:日志收集。

消费者消息重复和丢失

At most once   消息可能会丢,但绝不会重复传输

At least one     消息绝不会丢,但可能会重复传输

Exactly once    每条消息肯定会被传输一次且仅传输一次,很多时候这是用户所想要的。

丢失原因:
Consumer在从broker读取消息后,可以选择commit,该操作会在Zookeeper中保存该Consumer在该Partition中读取的消息的offset。该Consumer下一次再读该Partition时会从下一条开始读取。如未commit,下一次读取的开始位置会跟上一次commit之后的开始位置相同。
当Consumer将提交方式设置为autocommit,即Consumer一旦读到数据立即自动commit。如果当消费者执行下游业务逻辑时出现了错误,此次消费相当于一次无效消费,但是此时offset已经提交,消费者在此拉取消息,消息其实已经是下一条了,就会导致无效消费的那条消息被丢失。 image.png 解决方案:
consumer 关闭自动提交,使用手动提交

重复原因:
数据消费完没有及时提交offset到broke。
如: 消息消费端在消费过程中挂掉没有及时提交offset到broke,另一个消费端启动拿之前记录的offset开始消费,由于offset的滞后性可能会导致新启动的客户端有少量重复消费。

image.png 解决方案:
1、取消自动自动提交
每次消费完或者程序退出时手动提交。这可能也没法保证一条重复。 2、下游做幂等
一般的解决方案是让下游做幂等或者尽量每消费一条消息都记录offset,对于少数严格的场景可能需要把offset或唯一ID,例如订单ID和下游状态更新放在同一个数据库里面做事务来保证精确的一次更新或者在下游数据表里面同时记录消费offset,然后更新下游数据的时候用消费位点做乐观锁拒绝掉旧位点的数据更新。
幂等方案:

  1. 数据库使用联合索引
  2. 使用redis分布式锁
  3. 将offset存到数据库中,并使用数据库事务管理
  4. flink中使用二阶段事务提交来保障 Exactly once

image.png

broke阶段消息丢失

Coordinator

每个consumer group都会选择一个broker作为自己的coordinator,负责监控这个消费组里的各个消费者的心跳,以及判断是否宕机,然后开启rebalance。Kafka会把各个消费组均匀分配给各个Broker作为coordinator来进行管理,consumer group中的每个consumer刚刚启动就会跟选举出来的coordinator所在的broker进行通信,然后由coordinator分配分区给这个consumer来进行消费。coordinator通过rebalance会尽可能均匀的分配分区给各个consumer来消费。

1 如何选择哪台是coordinator?

首先对消费组的groupId进行hash,接着对consumer_offsets的分区数量取模,默认是50(offsets.topic.num.partitions设置),找到你的这个consumer group的offset要提交到consumer_offsets的哪个分区。比如说:groupId,“membership-consumer-group” -> hash值(数字)-> 对50取模(结果只能是0~49,又是以往的那个套路) -> 就知道这个consumer group下的所有的消费者提交offset的时候是往哪个分区去提交offset,找到consumer_offsets的一个分区(这里consumer_offset的分区的副本数量默认来说1,只有一个leader),然后对这个分区找到对应的leader所在的broker,这个broker就是这个consumer group的coordinator了,consumer接着就会维护一个Socket连接跟这个Broker进行通信

其实简单点解释,就是找到consumer_offsets中编号和它对应的一个分区而已。取模后是2,那就找consumer_offsets那50个分区中的第二个分区,也就是p1。取模后是10,那就找consumer_offsets那50个分区中的第十个分区,也就是p9.

2.2 coordinator完成了什么工作

coordinator会选出一个leader consumer(谁先注册上来,谁就是leader),这时候coordinator也会把整个Topic的情况汇报给leader consumer,由leader consumer来制定消费方案。之后会发送一个SyncGroup请求把消费方案返回给coordinator。

用一小段话再总结一遍吧:

首先有一个消费者组,这个消费者组会有一个它们的group.id号,根据这个可以计算出哪一个broker作为它们的coodinator,确定了coordinator之后,所有的consumer都会发送一个join group请求注册。之后coordinator就会默认把第一个注册上来的consumer选择成为leader consumer,把整个Topic的情况汇报给leader consumer。之后leader consumer就会根据负载均衡的思路制定消费方案,返回给coordinator,coordinator拿到方案之后再下发给所有的consumer,完成流程。

consumer都会向coordinator发送心跳,可以认为consumer是从节点,coordinator是主节点。当有consumer长时间不再和coordinator保持联系,就会重新把分配给这个consumer的任务重新执行一遍。如果断掉的是leader consumer,就会重新选举新的leader,再执行刚刚提到的步骤。

ISR机制

ack机制:

image.png 光是依靠多副本机制能保证Kafka的高可用性,但是能保证数据不丢失吗?不行,因为如果leader宕机,但是leader的数据还没同步到follower上去,此时即使选举了follower作为新的leader,当时刚才的数据已经丢失了。

image.png ISR是:in-sync replica,就是跟leader partition保持同步的follower partition的数量,只有处于ISR列表中的follower才可以在leader宕机之后被选举为新的leader,因为在这个ISR列表里代表他的数据跟leader是同步的。

如果要保证写入kafka的数据不丢失,首先需要保证ISR中至少有一个follower,其次就是在一条数据写入了leader partition之后,要求必须复制给ISR中所有的follower partition,才能说代表这条数据已提交,绝对不会丢失,这是Kafka给出的承诺

那什么情况下副本会被踢出出ISR呢,如果一个副本超过10s没有去和leader同步数据的话,那么它就会被踢出ISR列表。但是这个问题如果解决了(网络抖动或者full gc···等),follower再次和leader同步了,leader会有一个判断,如果数据差异小就会让follower重新加入。

消息底层存储

image.png

partiton中文件存储方式

下面示意图形象说明了partition中文件存储方式:

image.png

  • 每个partion(目录)相当于一个巨型文件被平均分配到多个大小相等segment(段)数据文件中。但每个段segment file消息数量不一定相等,这种特性方便old segment file快速被删除。
  • 每个partiton只需要支持顺序读写就行了,segment文件生命周期由服务端配置参数决定。

这样做的好处就是能快速删除无用文件,有效提高磁盘利用率。

partiton中segment文件存储结构「日志分段存储」

Kafka规定了一个分区内的.log文件最大为1G,做这个限制目的是为了方便把.log加载到内存去操作

00000000000000000000.index (偏移量的索引)
00000000000000000000.log (日志文件)
00000000000000000000.timeindex (时间的索引)

00000000000005367851.index
00000000000005367851.log
00000000000005367851.timeindex

00000000000009936472.index
00000000000009936472.log
00000000000009936472.timeindex

每个分区都对应一个文件夹,消息是追加写入的,会有多个segment文件
segment文件命名规则:partion全局的第一个segment从0开始,后续每个segment文件名为上一个segment文件最后一条消息的offset值。数值最大为64位long大小,19位数字字符长度,没有数字用0填充。这个9936472之类的数字,就是代表了这个日志段文件里包含的起始offset,也就说明这个分区里至少都写入了接近1000万条数据了。
Kafka broker有一个参数,log.segment.bytes,限定了每个日志段文件的大小,最大就是1GB,一个日志段文件满了,就自动开一个新的日志段文件来写入,避免单个文件过大,影响文件的读写性能,这个过程叫做log rolling,正在被写入的那个日志段文件,叫做active log segment。 HDFS中NameNode的edits log也会做出限制。
.log文件会对应一个.index和.timeindex两个索引文件。kafka在写入日志文件的时候,同时会写索引文件,就是.index和.timeindex,一个是位移索引,一个是时间戳索引。

日志压缩

blog.csdn.net/sherlockyb/…

日志二分查找

查询日志的方式依赖日志分段存储的特性。索引文件中记录的索引是一种稀松索引类似跳表的结构。当忘log文件中写日志时,不是每条数据都会对应一条索引,默认情况下,当文件中写入达到4KB大小的消息后,就要在索引文件写一条索引(由参数log.index.interval.bytes限定了在日志文件写入多少大小的数据),所以索引本身是稀疏格式的索引。
而且索引文件里的数据是按照位移和时间戳升序排序的,所以kafka在查找索引的时候,会用二分查找,时间复杂度是O(logN),找到索引,就可以在.log文件里定位到数据了.

image.png 上述图中索引文件存储大量元数据,数据文件存储大量消息,索引文件中元数据指向对应数据文件中message的物理偏移地址。
其中以索引文件中元数据3,497为例,依次在数据文件中表示第3个message(在全局partiton表示第368772个message)、以及该消息的物理偏移地址为497。日志问卷中上面的0,2039···这些代表的是物理位置。
比如现在要消费偏移量为7的数据,就直接先看这个稀松索引上的记录,找到一个6时,7比6大,然后直接看后面的数据,找到8,8比7大,再看回来,确定7就是在6~8之间,而6的物理位置在9807,8的物理位置在12345,直接从它们中间去找。就提升了查找物理位置的速度。就类似于普通情况下的二分查找。

LEO&HW原理

首先这里有两个Broker,也就是两台服务器,然后它们的分区中分别存储了两个p0的副本,一个是leader,一个是follower

此时生产者往leader partition发送数据,数据最终肯定是要写到磁盘上的。然后follower会从leader那里去同步数据,follower上的数据也会写到磁盘上

可是follower是先从leader那里去同步再写入磁盘的,所以它磁盘上面的数据肯定会比leader的那块少。

1. LEO是什么

LEO(last end offset)就是该副本底层日志文件上的数据的最大偏移量的下一个值,所以上图中leader那里的LEO就是5+1 = 6,follower的LEO是5。以此类推,当我知道了LEO为10,我就知道该日志文件已经保存了10条信息,位移范围为[0,9]

2. HW是什么

HW(highwater mark)就是水位,它一定会小于LEO的值。这个值规定了消费者仅能消费HW之前的数据。

3. 流程分析

image.png

  1. follower在和leader同步数据的时候,同步过来的数据会带上LEO和值。leader partition就会记录这些follower同步过来的LEO,然后取最小的LEO值作为HW值

维护HW目的:如果leader partition宕机,集群会从其它的follower partition里面选举出一个新的leader partition。这时候无论选举了哪一个节点作为leader,都能保证存在此刻待消费的数据,保证数据的安全性。

  1. 那么follower自身的HW的值如何确定? follower获取数据时也带上leader partition的HW的值,然后和自身的LEO值取一个较小的值作为自身的HW值。\

回想一下ISR,follower如果超过10秒没有到leader这里同步数据,就会被踢出ISR。它的作用就是帮助我们在leader宕机时快速再选出一个leader,因为在ISR列表中的follower都是和leader同步率高的,就算丢失数据也不会丢失太多。

而之前没提到什么情况下follower可以返回ISR中,现在解答,当follower的LEO值>=leader的HW值,就可以回到ISR

可是按照刚刚的流程确实无法避免丢失部分数据的情况,当然也是有办法来保证数据的完整的。

kafka完成流程分析

两个Broker,它们启动的时候会往zookeeper集群中注册,这时候这两台服务器会抢占一个名字叫controller的目录,谁抢到了,谁就是controller。比如现在第一台Broker抢到了。那它就是controller,它要监听zookeeper中各个目录的变化,管理整个集群的元数据

通过客户端来用命令来创建一个主题,这时候会有一个主题的分区方案写入到zookeeper的目录中,而在controller监听到这个目录写入了分区方案(其实就是一些元数据信息)之后,它也会更改自己的元数据信息。之后其他的Broker也会向controller来同步元数据。保证整个集群的Broker的元数据都是一致的

此时再比如我们现在通过元数据信息得知有一个分区p0,leader partition在第一台Broker,follower partition在第二台Broker。

此时生产者就该出来了,生产者需要往集群发送消息前,要先把每一条消息封装成ProducerRecord对象,这是生产者内部完成的。之后会经历一个序列化的过程。接下来它需要过去集群中拉取元数据。生产者代码里要提供一个或多个broker的地址,代码片段如下

props.put("bootstrap.servers", "hadoop1:9092,hadoop2:9092,hadoop3:9092");
复制代码

因为如果不提供服务器的地址,是没法获取到元数据信息的。此时生产者的消息是不知道该发送给哪个服务器的哪个分区的。之后根据分区策略选择leader partition.

此时生产者不着急把消息发送出去,而是先放到一个缓冲区。把消息放进缓冲区之后,与此同时会有一个独立线程Sender去把消息分批次包装成一个个Batch。整好一个个batch之后,就开始发送给对应的主机上面。此时经过Kafka的三层网络架构模型,写到os cache,再继续写到磁盘上面。

之后写磁盘的过程又要将日志进行分段存储,和副本间数据同步会用到ISR,LEO和HW。因为当leader写入完成时,follower又要过去同步数据了。

此时消费者组也进来,这个消费者组会有一个它们的group.id号,根据这个可以计算出哪一个broker作为它们的coodinator,确定了coordinator之后,所有的consumer都会发送一个join group请求注册。之后coordinator就会默认把第一个注册上来的consumer选择成为leader consumer把整个Topic的情况汇报给leader consumer。之后leader consumer就会根据负载均衡的思路制定消费方案,返回给coordinator,coordinator拿到方案之后再下发给所有的consumer,完成流程。

生产者常见参数配置

props.put("acks", "-1");
props.put("retries", 3);
props.put("batch.size", 32384);
props.put("linger.ms", 100);
props.put("buffer.memory", 33554432);
props.put("max.block.ms", 3000);

① acks 消息验证

props.put("acks", "-1");
acks消息发送成功判断
-1leader & all follower接收
1leader接收
0消息发送即可

acks参数有3个值,分别是-1,0,1,设置这3个不同的值会成为kafka判断消息发送是否成功的依据
acks = -1: Kafka里面的分区是有副本的,消息在写入一个分区的leader partition后,还需要消被另外所有这个分区的副本同步完成这个消息后,才被发送成功,但此时发送数据的性能降低,并且在不开启幂等的情况下会存在消息重复的可能。

acks = 1: 消息只要写入了leader partition,即算发送成功。这种方式存在丢失数据的风险,比如在消息刚好发送成功给leader partition之后,这个leader partition立刻宕机了,此时剩余的follower无论选举谁成为leader,都不存在刚刚发送的那一条消息。

acks = 0:消息只要是发送出去了,就默认发送成功了。啥都不管,存在消息丢失的可能。

② retries 重试次数(重要)

这个参数比较重要,在生产环境中是必须设置的参数,设置消息重发的次数

props.put("retries", 3);
复制代码

在kafka中可能会遇到各种各样的异常(LeaderNotAvailableExceptionNotControllerExceptionNetworkException),但是无论是遇到哪种异常,消息发送此时都已经出现了问题,特别是网络突然出现问题。但是集群不可能每次出现异常都抛出,可能在下一秒网络就恢复了,所以我们要需要设置重试机制。

补充1:设置了retries之后,集群中95%的异常都会自己乘风飞去,我真没开玩笑🤣
补充2:如果需要设置隔多久重试一次,参数:retry.backoff.ms

props.put("retry.backoff.ms",100);

补充3:异常 不管是异步还是同步,都可能让你处理异常,常见的异常如下:
1)LeaderNotAvailableException:如果某台机器挂了,此时leader副本不可用,会导致消息写入失败,需要等待其他follower副本切换为leader副本之后,才能继续写入。此时可以重试发送即可。
如果说平时重启kafka的broker进程,肯定会导致leader切换,一定会导致你写入报错,是LeaderNotAvailableException

2)NotControllerException:同理,如果Controller所在Broker挂了,那么此时会有问题,需要等待Controller重新选举,此时也是一样就是重试即可

3)NetworkException:网络异常,重试即可,但是如果重试几次之后还是不行,就会提供Exception给我们来处理了。 参数:retries 默认值是3 参数:retry.backoff.ms 两次重试之间的时间间隔

③ batch.size 批次大小

批次的大小默认是16K,这里设置了32K,设置大一点可以稍微提高一下吞吐量,设置这个批次的大小还和消息的大小有关,假设一条消息的大小为16K,一个批次也是16K,这样的话批次就失去意义了。所以我们要事先估算一下集群中消息的大小,正常来说都会设置几倍的大小。

props.put("batch.size", 32384);

④ linger.ms 发送时间限制

比如现在设置了批次大小为32K,而一条消息是2K,此时已经有了3条消息发送过来,总大小为6K,而生产者这边就没有消息过来了,那在没够32K的情况下就不发送过去集群了吗?显然不是,linger.ms就是设置了固定多长时间,就算没塞满Batch,也会发送,下面我设置了100毫秒,所以就算我的Batch迟迟没有满32K,100毫秒过后都会向集群发送Batch。

props.put("linger.ms", 100);

⑤ buffer.memory 缓冲区大小

当我们的Sender线程处理非常缓慢,而生产数据的速度很快时,我们中间的缓冲区如果容量不够,生产者就无法再继续生产数据了,所以我们有必要把缓冲区的内存调大一点,缓冲区默认大小为32M,其实基本也是合理的。

props.put("buffer.memory", 33554432);

⑦ compression.type 压缩方式

compression.type,默认是none,不压缩,但是也可以使用lz4压缩,效率还是不错的,压缩之后可以减小数据量,提升吞吐量,但是会加大producer端的cpu开销

props.put("compression.type", lz4);

⑧ max.block.ms

留到源码时候说明,是设置某几个方法的阻塞时间

props.put("max.block.ms", 3000);

⑨ max.request.size 最大消息大小

max.request.size:这个参数用来控制发送出去的消息的大小,默认是1M,这个一般太小了,很多消息可能都会超过1mb的大小,所以需要自己优化调整.

props.put("max.request.size", 1048576);    
复制代码

⑩ request.timeout.ms 请求超时

request.timeout.ms:这个就是说发送一个请求出去之后,他有一个超时的时间限制,默认是30秒,如果30秒都收不到响应(也就是上面的回调函数没有返回),那么就会认为异常,会抛出一个TimeoutException来让我们进行处理。如果公司网络不好,要适当调整此参数

props.put("request.timeout.ms", 30000); 

消费者常见参数配置