[消息队列]-Kafka

概述

Kafka 是一个 分布式 的基于 发布/订阅模式消息队列

消息队列

消息就是要传输的各种形式的数据

数据传输的过程由发出消息的 生产者 以及接收消息的 消费者 组成

所以消息队列就是让生产者在其中存放要传输的数据,等待后续消费者从中取出数据进行消费的组件

消息队列的好处

生产者直接把消息传输给消费者然后等待消费者给出响应,以 串行,同步 的方式完成一系列连续的操作,等所有操作都完成后才返回给客户端的方式,称为 同步处理

在两者之间添加一个用于存放消息的消息队列,生产者把消息写入消息队列,自己可以先对客户端做出回应,避免一次请求中过多的等待时间,这是 异步处理 的方式。异步处理的方式起到了 解耦削峰异步,减少接口响应时间 的好处

在实际应用场景中,一个操作往往是跟许多操作连在一起的,涉及到的操作越多,不同模块之间的耦合就会更多

引入了消息队列,可以让不同模块之间解耦,使队列两端相互独立;让处于后面的一部分操作变成 异步执行,也减少客户端请求的耗时;有助于控制和优化数据流经过系统的速度,解决生产消息和消费消息速度不一致的问题;消息队列的转储消息作用 (异步通信) 也能帮助服务器缓解压力,也即 削峰

消息队列的两种模式

  • 点对点 模式,生产者将消息发送到队列中,消费者主动从队列中取出 并消费消息,消息 被消费之后队列中不再存储,也就是说其它消费者不可能消费到已经被消费的消息。可以存在多个消费者,但对于一个消息而言,只会被一个消费者消费

  • 发布/订阅 模式,消费者消费之后 不会清除消息。一个消息可以被多个消费者订阅,发送到队列的消息 会被所有订阅者消费。消息的保留时间也是有限制的

    该模式下还有两种模式,一种是 消费者主动去拉取消息,坏处是为了得到消息,需要隔一段时间就去轮询,查看队列中的消息是否更新,这样可能会出现浪费消费者的运行资源的情况,因为有可能轮询了很多次消息都没有更新,造成无效轮询的情况。而且轮询间隔如何取值也是个需要思考的问题;

    一种是 队列主动把消息推送给消费者,坏处是不同消费者消费的速度可能不一样,但队列推送的速度肯定是一样的,这样可能就会导致消费者消费能力不足或资源浪费等问题

Kafka 是基于 发布/订阅模式 中 消费者主动拉取消息 的方式

Kafka 基础架构

Kafka架构

  1. Producer:消息生产者,向 Kafka broker 发消息的客户端
  2. Consumer:消息消费者,从 Kafka broker 取消息的客户端
  3. Consumer Group:消费者组,消费者组内每个消费者负责消费不同分区的数据,一个分区只能由一个组内消费者消费,消费者组之间互不影响,所有消费者都属于某个消费者组。消费者组是逻辑上的一个订阅者
  4. Broker:一台 Kafka 服务器就是一个 broker,一个集群由多个 broker 组成,一个 broker 可以容纳多个 topic
  5. Topic:可以理解为一个队列,生产者和消费者面向的都是一个 topic
  6. Partition:为了实现 可扩展性,一个非常大的 topic 可以分布到多个broker上,一个 topic 可以分为多个 partition,每个 partition 都是一个有序的队列
  7. Replica:副本,为了实现 容错性,保证集群中的某个节点发生故障时,该节点上的 partition 数据不丢失,且 Kafka 仍然能够继续工作,Kafka 提供了副本机制,一个 topic 的每个分区都有若干个副本,一个 leader 和若干个 follower,即副本中包含 leader 跟 follower
  8. leader:每个分区多个副本的 “主”,生产者发送数据的对象,以及消费者消费数据的对象都是 leader
  9. follower:每个分区多个副本中的 “从”,实时从 leader 中同步数据,保持和 leader 数据的同步。在 leader 发生故障时,某个 follower 会成为新的 leader。follower 与 leader 不会在同一个 broker 中,很好理解,如果 follower 跟 leader 都在一个 broker 中,那如果 broker 宕机了,不仅 leader 也故障了,follower 也会故障,那 follower 的存在就没有意义了

Kafka 中消息是以 topic 进行分类的,生产者跟消费者都是面向 topic 的
topic 是逻辑上的概念,而 partition 是物理上的概念,每个 partition 对应于一个 log 文件,该 log 文件中存储的是生产者生产的数据。生产者生产的数据会被不断追加到该 log 文件末端,且每条数据都有自己的 offset 偏移量,用于定位该数据
消费组中的每个消费者都会实时记录自己消费到了哪个 offset,以便出错恢复时从上次的位置继续消费。每个 partition 中的 offset 数列都是独立的,并没有全局的对所有数据的 offset 列

Kafka 架构深入

文件存储

由于生产者生产的消息会不断追加到 log 文件末尾,为防止文件过大导致数据定位的效率低下,Kafka 采取了 分片索引 机制,将原本一个 partition 对应一个 .log 文件的设计,改为一个 partition 分为多个 segment,即将 .log 文件分为多个 segment 片段。每个 segment 对应两个文件:.index.log 文件。但 segment 也是逻辑上的概念,并不实际存在

这些 .index.log 文件位于一个文件夹下,该文件夹的命名为:topic名称-分区序号。如 first 这个 topic 有三个分区,那么其对应文件夹为 first-0first-1first-2

index 文件和 log 文件以当前 segment 的第一条消息的 offset 命名,所以同一个 segment 的 .index 跟 .log 文件的前缀名是相同的。log 文件是存放实际数据的 (与 “日志” 无关),该文件最大可以达到一个 G,达到该大小就会出现一个新的文件。index 文件存储的就是 log 文件中数据的索引信息:

Kafka的文件存储
这里我们模拟一下查找 offset 为 3 的数据的过程来理解使用 index 文件中的索引信息查找实际数据的过程:

由于 offset 数列是有序的,因此 Kafka 使用 二分查找 的方法找到 offset 为 3 的数据对应的索引应该在 0000…00.index 文件中

index 文件中每条索引记录的大小是一样的,因此可以通过计算方便地找出每个 offset 所对应的索引信息所在的记录。该索引记录中除了存放 offset 的值 (该 offset 值是实际数据在当前 segment 的偏移量而不是在整个 partition 中的偏移量,所以在每个 segment 中都是从 0 开始的) 以外还存放了该 offset 对应的实际数据在 log 文件中的 物理偏移量 以及 该数据的大小 等其他信息

如图中在 index 文件中找到 offset 为 3 的实际数据 在 log 文件中的物理偏移量为 756,且还能找到这个数据的大小 (上图中没有画出来),这里假设为 1000,那么可以知道该数据在 log 文件中的位置就是 756 ~ 1756,根据这个信息就可以在 log 文件中快速找到所要的目标数据

滚动策略

Kafka 使用分段机制存储数据日志,那么就会涉及到一个问题,何时开始写一个新的日志段,这个问题就需要滚动策略来解决,编辑一个新的日志段的动作就称为日志段的滚动

滚动策略有如下几种:

  1. 当前日志段大小超过阈值时,默认为 1 GB
  2. 当前日志段不为空且等待滚动的时间已超过阈值,默认为 7 天
  3. 当前日志段的索引文件大小超过阈值,默认为 10 MB

生产者分区策略

进行分区的原因

  • 方便在集群中 扩展,提高负载能力。集群中服务区器数量增多的话,partition 的数量也能一起增多,提高了扩展性
  • 可以提高 并发,因为可以以 partition 为单位进行读写

消息分区的原则

我们需要把生产者发送的数据封装为一个 ProduceRecord 对象。在构造函数中有两个参数:partitionkey

  • 指明 partition 的情况下,直接把指明的值作为目标 partition
  • 没有指明 partition 值但有 key 的情况下,将 key 的哈希值与 topic 的 partition 数进行取余得到 partition 值
  • partition 值和 key 值都没有的情况下,第一次调用时随机生成一个整数,将这个值与 topic 可用 partition 总数取余得到 partition 值;后续每次调用就对这个整数进行自增然后进行同样的取余操作,这也就是 Round-Robin 算法

分区如何分配到各个服务器上

在创建 Topic 时我们会指定分区数目,对于第一个分区,Kafka 会以服务器数目作为上界,随机生成一个索引作为该分区的第一个副本所存储的服务器,然后其它分区的第一个副本就会依次轮询存储在这个服务器之后的每个服务器上。这些第一个副本也就是每个分区的 Leader。这样的话就把每个分区的 Leader 分配出去了。

然后对于每个分区的 Follower,首先 Kafka 会生成一个随机数作为每个分区第一个 Follower 相对于 Leader 的偏移量,然后从偏移后的服务器开始,依次轮询分配每个 Follower,如果分配到了最后一个服务器就继续分配到第一个服务器进行循环,每个分区都会使用同一个偏移量。这样就把每个分区的 Follower 也分配出去了,从而完成对所有 Partition 的分配。

这种方式不是简单地把每个分区的副本都从第一台服务器开始轮询分配,不会使得第一台服务器总是被分配最多的副本,从而避免负载不均衡问题

数据可靠性保证

ack 机制

为保证生产者发送的数据能可靠地发送到指定的 topic,topic 的每个 partition 收到数据后都需要向生产者发送 ack (acknowledgement,确认收到),如果生产者收到了 ack,就会进行下一轮的发送,否则重新发送数据

何时发送 ack:确保有 follower 与 leader 同步完成,leader 才发送 ack,才能保证 leader 挂掉之后能在 follower 中选举出新的 leader

要多少个follower同步完成? 两种方案:

方案优点缺点
半数以上延迟低选举新的 leader 时,容忍 n 台节点故障的话需要 2n+1 个副本
全部完成选举新的 leader 时,容忍 n 台节点故障的话需要 n+1 个副本延迟高

对于选取新的 leader 时,容忍 n 台节点故障的话,需要多少个副本的问题:

在 半数以上 的方案中,当有 2n+1 个副本时,由于保证半数以上的副本同步完成,也就是说至少有 n+1 台副本是同步完成的,n 台副本是同步失败的。如果容忍 n 台节点的故障,最极端的情况是故障的这 n 台都是同步完成的,那么在剩下的 n+1 台节点中,也至少有 1 台是同步完成的能成为 leader,因此至少需要 2n+ 1 个副本

在 全部完成 的方案中,当有 n+1 个副本时,可以保证这 n+1 个副本都是同步完成的,那么如果有 n 台节点故障,剩下的一台也一定是同步完成的能够作为 leader,因此至少需要 n+1 个副本

Kafka 选择了 第二种方案,原因如下:

  1. 同样为了容忍 n 台节点的故障,第一种方案需要 2n+1 个副本,而第二种方案只需要 n+1 个副本,而 Kafka 每个分区都有大量的数据,第一种方案会造成大量数据的冗余
  2. 网络延迟对 Kafka 的影响较小

ISR

采取了第二种方案后,面临一个问题:假设 leader 收到数据,所有 follower 都开始同步数据,既然需要所有 follower 都完成同步才算消息确认收到,那么如果有一个 follower 因为某种故障迟迟不能进行同步,那 leader 就得一直等下去直至它完成同步才能发送 ack,容错性比较低

为此,leader 维护了一个动态的 In-Sync Replica set,简称为 ISR,意为 和 leader 保持同步的 follower 集合 (包含 leader),当 ISR 中的 follower 完成数据的同步之后,leader 就会给 follower 发送 ack,如果 follower 长时间未向 leader 同步数据,该 follower 就会被踢出 ISR

时间阈值replica.lag.time.max.ms 参数设定。除了响应时间以外,选择进入 ISR 的规则还有一个可以考虑的参数是 follower 已同步的数据 与 leader 中数据的条数的差值,相差小的也具备进入 ISR 的资格

但 0.9 版本开始只保留时间这个参数,不再参考数据条数差值,因为生产者发送消息是 批量发送 的,假设 ISR 设定的最大条数差值是 10,而生产者每次都批量发送消息 12 条,发送到 leader 后,此时所有 follower 中数据与 leader 的差值都大于 10,就都会被踢出 ISR,但在时间阈值内他们又可以完成同步,满足了时间阈值和数据差值两个条件,于是又被加入 ISR;另一方面,ISR 是需要维护的,它被保存在内存以及 zookeeper (用于集群环境下共享信息) 中,频繁地将 follower 踢出以及加入 ISR 会导致频繁地访问,读写 zookeeper,使得效率低下。因此移除了数据条数这个限制,只保留时间限制

在 leader 发生故障之后就会从 ISR 中选举出一个新的 leader

ISR + OSR (OutSynv /Repli) = AR (AllRepli)

ack 应答机制

对于某些不太重要的数据,对数据的可靠性要求不是很高,能够容忍数据的少量丢失,所以没必要等 ISR 中的 follower 全部接收成功

Kafka 为用户提供了三种可靠性级别,用户根据对可靠性和延迟的要求进行权衡。选择以下的配置:
acks 参数:

  • 0,生产者不等待 broker 的 ack,这一操作提供了一个最低的延迟,broker 只要一接收到数据,还没有写入磁盘就直接返回,当 broker 故障时有可能丢失数据

  • 1,生产者等待 broker 的 ack,partition 的 leader 落盘成功后返回 ack,如果在 follower 同步成功之前 leader 故障那么将会丢失数据

  • -1 (all),生产者等待 broker 的 ack,partition 的 leader 和 follower (ISR) 全部落盘成功后才返回 ack

    但是如果在 follower 同步完成后,broker 发送 ack 之前,leader 发生故障,那么会造成数据重复,因为此时 follower 和 leader 已经拥有相同的数据,此时还未发送 ack 的时候 leader 挂了不能发送 ack,那么生产者就会再次发送相同的数据,此时由于旧的 leader 已经挂了,有一个 follower 成为新的 leader,那么这个新的 leader 就会接收到完全相同的一份数据

    当 ISR 中只有 leader 的时候,此时相当于退化到 1 的情况,也会出现丢失数据,实际中这种情况很少,是一种极限情况

数据一致性

消费数据一致性

对于消费者来说有两个参数:
LEO:Log End Offset,每个副本最大的 offset
HW:high watermark,ISR 队列中 最小 的 LEO,指的是 消费者能见到 的最大的 offset

HW 的作用:如果没有 HW,假设 leader 的 LEO 为 19,当一个消费者消费 leader 时消费到 18 时 leader 挂了,此时一个 LEO 为 12 的 follower 成为了 leader,要从 offset 为 18 开始继续消费,然而新的 leader 中最大 offset 也只有 12,找不到对应 18 的数据,因此消费者能见到的是 HW,只要保证在 HW 的范围内消费,这就保持了消费数据的一致性

存储数据一致性 (副本间数据一致性)
  1. follower 故障:follower 发生故障后会被临时踢出 ISR,待该 follower 恢复后,follower 会读取本地磁盘记录的上次的 HW,并将 log 文件高于 HW 的部分截掉,从 HW 开始向 leader 进行同步,待该 follower 的 LEO 大于等于该 partition 的 HW,即 follower 追上 leader 之后,就可以重新加入 ISR 了
  2. leader 故障:leader 发生故障后会从 ISR 中选出一个新的 leader 之后,为保证多个副本之间的数据一致性,其余的 follower 会先将各自的 log 文件高于 HW 的部分截掉,然后从新的 leader 同步数据

这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复,ack解决的才是丢失和重复的问题

Exactly Once 语义

将服务器 ack 级别设置为 -1 可以保证生产者到服务器之间不会丢失数据,即 At Least Once 语义,数据至少发送一次,它可以保证数据不丢失,但不能保证数据不重复

相对的,将 ack 级别设置为 0,可以保证生产者每条消息只会被发送一次,即 At Most Once 语义,可以保证数据不重复,但不能保证数据不丢失

对于一些非常重要的信息,比如说交易数据,下游数据消费者要求数据既不重复也不丢失,即 Exactly Once 语义

在 0.11 版本之前的 Kafka 只能保证数据不丢失,再在下游消费者对数据做全局去重,对于多个下游应用的情况,每个都需要单独去做去重,这就对性能造成了很大影响
0.11 版本引入了 幂等性,所谓 幂等性 就是指 生产者不论向服务端发送多少次重复数据,服务端都只会持久化一条,幂等性结合 At Least Once 语义就构成了 Exactly Once 语义

要启用幂等性,只需要将 Producer 的参数中 enable.idompotence 设置为 true (此时 ack 级别也会被设为 -1) 即可,Kafka 幂等性的实现其实就是将原来下游需要做的去重工作放在了数据上游

开启幂等性的 Producer 在初始化时会被分配一个 PID,发往同一 partition 的消息会附带 Sequence Number,而 broker 端会对 <PID,Partition,SeqqNumber> 做缓存,当具有相同主键的消息提交时,broker 只会持久化一条
但是 PID 重启就会变化,同时不同的 partition 也具有不同主键,所以幂等性无法保证跨分区跨会话 (即生产者挂掉重新启动) 的 Exactly Once

消费者

消费方式

消费者采用 Pull 模式从 broker 中读取数据。Push 模式很难适应消费速率不同的消费者,因为消息发送速率是由 broker 决定的,它的目标肯定是尽可能以最快速度传递消息提高吞吐量,但是这样很容易造成消费者来不及处理消息,典型的表现就是 拒绝服务 以及 网络拥塞,而 Pull 模式则可以让消费者自身根据自己的消费能力以适当的速率消费消息

Pull 模式不足之处是,如果 Kafka 没有数据,消费者可能会陷入循环中,一直返回空数据。针对这一点,Kafka 的消费者在消费数据时会传入一个时长参数 timeout,如果当前没有数据可供消费,消费者会等待一段时间之后再返回,这段时长即为 timeout

消费分区分配策略

每个 topic 中不同的 partition 只会由一个消费者组中的一个消费者来消费,所以需要确定哪个 partition 由哪个消费者负责消费。有两种策略:

RoundRobin

按消费者组来分配。这是一种轮询的方式,前提是消费者组中的消费者订阅的 topic 是一样的

当只有一个主题时,假设它有 7 个分区,消费者组中有 3 个消费者,那么具体分配方式就是分区 0 给消费者 1,分区 1 给消费者 2,分区 2 给消费者 3,分区 3 给消费者 1,分区 4 给消费者 2,分区 5 给消费者 3,以此类推,最后每个消费者分到的区的数目的差值最多为 1;

当有两个以上主题时,所有主题的所有分区会被当成一个整体,每个分区就是 TopicAndPartition 类对象,然后对这些对象进行排序,然后再用跟上述只有一个主题的情况相同的方法进行分区

由于并不能保证前提条件一定成立,默认的分区策略不是这一种而是下面这一种,如果前提条件不成立的话,是可能会导致某个消费者被分配到自己没有订阅的主题分区,因为分配的时候是把一个组内所有消费者所有订阅的主题来分配给组内所有消费者,不管消费者是否订阅了正在被分配的主题

Range

按主题进行划分,假设一个主题中有 n 个 partition,一个消费组中 订阅该主题的消费者 有 m 个,n/m=p,n%m=q,则这 m 个消费者中前 q 个消费者分到 p+1 个分区,剩下的消费者分到 p 个分区
由于是面向主题的,这种策略可能会导致不同消费者间消费分区个数差别过大

当消费者个数发生变化就会触发分区策略进行分区

Rebalance

消费者组中每个消费者负责消费的 Partition 并不是一成不变的,对消费者所负责的 Partition 进行重新分配的机制称为 Rebalance。

触发条件
  1. 消费者组中消费者数目变化,如消费者宕机或者新的消费者加入组
  2. 消费者组订阅的 Topic 数目变化
  3. 消费者组订阅的 Topic 的 Partition 数目变化

当触发条件发生时,Kafka 会将消费者组所订阅的 Topic 的 Partition 重新分配给组中的消费者,使消费者组后续的消费行为正常进行。但是 Rebalance 的过程中消费者组中的所有消费者实例都无法工作

Rebalance 的过程
  1. 消费者组中的 leader 会从协调者 coordinator 获取到自己所在的消费者组的成员列表。这里说的 leader 不是 Kafka 架构中的 leader,而只是作为消费者组中的 leader,负责在 Rebalance 时为所在消费者组中的每个消费者分配 partition;
  2. 获取到组成员列表后,leader 就会进行分区,分区完成后,再把分配情况发送给协调者
  3. 协调者再把分配信息发送给消费者组中其它消费者,这样每个消费者都知道自己的分区情况。从而完成整个消费者组的重新分区
避免 Rebalance

我们知道 Rebalance 期间整个消费者组的所有实例都不能进行工作,而且随着消费者组规模越大,Rebalance 所需的时间就更多,消费者组不可用的时间就越长,很大程度地降低了吞吐量,因此我们应该尽可能避免 Rebalance。

比如,尽可能不要变更 Topic 以及 Topic 下的分区数;对于消费者数目变化的问题,如果是出于业务规模问题需要扩缩容倒是无可厚非,但有些情况是意外发生的,比如消费者的心跳未及时到达协调者,这要求我们合理配置 session.timeout.ms 和 heartbeat.interval.ms 两个参数,一个是协调者认为消费者退出的心跳超时,一个是消费者发送心跳的间隔,心跳超时应该大于心跳间隔,比如说心跳超时设置为心跳间隔的 3 倍,确保消费者在被认定为超时之前至少能发送 3 次心跳,以避免偶发性的网络问题导致的误超时

offset 的维护

由于消费者在消费过程中可能会出现断电宕机等故障,消费者恢复后需要从故障前的位置继续消费,所以消费者需要实时记录自己消费到了哪个 offset 以便故障恢复之后继续消费

在一个消费者组内,一个消费者挂掉了,另一个消费者接手时应该接着它的 offset 去继续消费,因此由 消费者组 + 主题 + partition 唯一确定一个 offset

offset 既保存在 Kafka 本地,也保存在 zookeeper,Kafka 0.9 版本之前,消费者默认将 offset 保存在 zookeeper 中,从 0.9 版本开始默认将 offset 保存在 Kafka 一个内置的 topic 中,该 topic 为 _consumer_offsets

Kafka 高效读写数据的原因

  1. 顺序写

    Kafka 的生产者生产数据写入 log 文件,写的过程是一直追加到 log 文件末端,为顺序写的方式。同样的磁盘,顺序写的速度比随机写速度更快,因为顺序写省去了大量磁头寻址的时间

  2. 零拷贝。Kafka 底层在发送数据时使用的是 sendfile() 系统调用

  3. 分布式 (分区),可以并发读写 (如果只有单台服务器当然就跟分布式无关)

zookeeper 在 Kafka中的作用

Kafka 集群中有一个 broker 会被选为 Controller,负责管理集群 broker 的上下线,所有 topic 的分区副本分配和 leader 选举等工作
controller 的管理工作都是依赖于 zookeeper 的

事务

Kafka 从 0.11 版本开始引入事务支持,事务可以保证 Kafka 在 Exactly Once 语义上实现生产和消费 可以跨分区和会话,要么全部成功,要么全部失败

主要指的是 生产者事务,目的是完善 Exactly Once 语义:
为了实现跨分区跨会话的事务,引入了一个 全局唯一的 TransactionID,并将 Producer 和 TransactionID 绑定,这样 当 Producer 重启后就可以通过正在进行的 TransactionID 获取原来的 PID (所以 ID 应该是来自客户端而不是由 broker 来随机生成)

为了管理 Transaction,Kafka 引入了一个新的组件 Transaction Coordinator,Producer 就是通过和 Transaction Coordinator 交互获得 Transaction ID 对应的任务状态
Transaction Coordinator 还负责将事务所有写入 Kafka 的一个内部 topic,这样即使整个服务重启,由于事务状态得到保存,进行中的事务状态可以得到恢复从而继续进行

API

Producer API

发送流程

Kafka 的 Producer 发送消息采用的是 异步发送 (在消息量上分批次地发送消息,但发送时间上各批次间是独立的,一个批次来了就发送,不受其它批次成功与否的影响,一个批次对应一个 ack,如果该批次发送失败了就重发该批次的数据) 的方式,在消息发送的过程中,涉及到了 两个线程:main 线程和 Sender 线程,以及一个线程共享变量 RecordAccumulator

main 线程将消息发送给 RecordAccumulator,Sender 线程不断从 RecordAccumulator 中拉取消息发送到 Kafka broker

生产者客户端整体结构:

Kafka消息发送的流程
相关参数:
batch.size:只有数据积累到 batch.size 之后,Sender 才会发送数据
linger.ms:如果数据迟迟未达到 batch.size,Sender 等待 linger.ms 之后就会发送数据

代码示例

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>2.8.0</version>
</dependency>
public static void main(String[] args) {
    //1.创建Kafka生产者的配置信息,以下的配置参数都可以在ProducerConfigs中找到,无需记忆。其中部分参数的值都有默认值不用自己设定
    Properties properties = new Properties();
    //指定连接的Kafka集群
    properties.put("boostrap.servers","hadoop102:9092");
    //ack应答级别
    properties.put("acks","all");
    //重试次数
    properties.put("retries",3);
    //批次大小,字节
    properties.put("batch.size",16384);
    //等待时间,ms
    properties.put("linger.ms",1);
    //RecordAccumulator缓冲区大小,字节
    properties.put("buffer.memory",33554432);
    //序列化器
    properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
    properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
    //2.创建生产者对象
    KafkaProducer<String, String> producer = new KafkaProducer<String, String>(properties);
    //3.发送数据
    for (int i = 0; i < 10; i++) {
        producer.send(new ProducerRecord<String, String>("first","record" + i));
    }
    //4.切记关闭资源,调用该方法会直接把数据发送出去(就算不满足batch.size等参数)。该方法除了把producer自己的资源关了,还会关闭其它跟producer相关的资源,比如分区器和拦截器,如果有资源的话也会关掉
    //也就是说这个close()方法会去调分区器或拦截器等的close()方法,没有这个close(),那些类对象中的close()方法也不会被调用
    producer.close();
}

带回调函数的生产者:

for (int i = 0; i < 10; i++) {
    producer.send(new ProducerRecord<String, String>("first", "record" + i), new Callback() {
        //成功返回recordMetadata,失败返回异常
        public void onCompletion(RecordMetadata recordMetadata, Exception e) {
            if(e == null){
                System.out.println(recordMetadata.partition());
            }
        }
    });
}

自定义分区器:

public class MyPartition implements Partitioner {...
//修改生产者的配置信息中的分区器类
properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,"com.xxx.www.partition.MyPartition");

同步发送的方式发送数据:
通过在 sender 线程的时候阻塞 main 线程实现。具体做法:由于 Producer 的 send 方法返回的是 Future 对象,该类与线程有关,调用该类对象的 get() 方法时除了会获取对象的值外还会阻塞前面的线程,借此实现线程阻塞,同步发送

producer.send(new ProducerRecord<String, String>("first","record" + i)).get();

消费者

public static void main(String[] args) {
    Properties properties = new Properties();
    //集群
    properties.put("boostrap.servers","hadoop102:9092");
    //开启自动提交offset
    properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,true);
    //自动提交的延时,1000ms(只有开启自动提交时才会生效)
    properties.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG,"1000");
    //反序列化器
    properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
    properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringDeserializer");
    //消费者组
    properties.put(ConsumerConfig.GROUP_ID_CONFIG,"group");
    KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(properties);
    //订阅主题(单个元素变为list使用这个方法而不使用Arrays.asList())
    //可以订阅一个不存在的主题,不会报错,会给警告,但不影响运行以及其它主题获取数据
    consumer.subscribe(Collections.singletonList("first"));
    //消费者打开就不再关闭,持续获取数据。除了强制kill掉或强制退出
    while (true){
        //获取数据(会获取到多个)
        ConsumerRecords<String, String> consumerRecords = consumer.poll(100);
        for (ConsumerRecord<String, String> consumerRecord : consumerRecords) {
            //key能获取到,说明key也会被存到集群,而不是只为了分区用的
            System.out.println(consumerRecord.key());
        }
        consumer.close();
    }
}

消费者重置 offset

properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,"earliest");

关于该参数的文档如下:

What to do when there is no initial offset in Kafka or if the current offset does not exist any more on the server (e.g. because that data has been deleted):

  • earliest: automatically reset the offset to the earliest offset
  • latest: automatically reset the offset to the latest offset
  • none: throw exception to the consumer if no previous offset is found for the consumer’s group
  • anything else: throw exception to the consumer.

可以看出,只有消费者换组了,或者 offset 过期 (7天) 了,这个参数配置才会生效,否则写了这条配置也不会生效。该参数默认是 latest

offset 的提交

  • 如果没有提交offset

    如果没有开启 offset 自动提交 也没有 手动提交到本地,假设本次开启消费者时内存中存储的 offset 为 90,本次消费到了 100,关闭消费者后,本地保存的 offset 还是 90,下一次打开,内存中的 offset 是从本地获取的,所以还是 90,虽然每次运行时内存中的 offset 会随着消费进度而更新,但每次开始运行都是从本地获取的 offset 值作为本次消费的初始 offset 值

  • 手动提交 offset

    自动提交虽然十分便利,但由于是基于时间延迟提交的,开发人员难以把握 offset 提交的时机,因此还可以选择 手动提交offset
    手动提交有两种,commitSync 同步提交和 commitAsync 异步提交。两者的相同点是都会将本次 poll 的一批数据最高的偏移量提交;不同点是,同步提交阻塞当前线程直到提交成功,并且会自动失败重试 (由不可控因素导致,也会出现提交失败),而 异步提交没有失败重试机制,有可能提交失败

  • 自定义存储offset

    逻辑处理和 offset 提交是一起执行的,如果是先完成逻辑处理再提交 offset有可能出现 重复消费 的问题;如果先提交 offset 再进行逻辑处理就可能出现 漏消费 的问题

    Kafka 提供了自定义存储 offset 的方式

PS:消费者提交消费位移时提交的是当前消费到的最新消息的offset+1
注意区分 重复消费,漏消费 以及 数据重复,数据丢失 的不同,一个是从消费者出发的,一个是从生产者出发的

自定义拦截器

Producer 拦截器是在 Kafka 0.10 版本引入的,主要用于实现 clients 端的定制化控制逻辑
对于 Producer 而言,interceptor 使得用户在消息发送前以及 Producer 回调逻辑前有机会对消息做一些定制化需求,比如修改消息等。同时,Producer 允许用户指定多个 interceptor 按序作用于同一条消息从而形成一个拦截链

代码示例

需求:实现一个双拦截器的拦截链。第一个拦截器用于在消息发送前在消息前添加时间戳信息;第二个拦截器用于在消息发送后更新成功发送消息数及失败发送消息数

public class TimeInterceptor implements ProducerInterceptor<String,String> {
    //获取配置信息和初始化数据时调用
    public void configure(Map<String, ?> configs) {
    }
    //==========循环调用以下两个方法,每一条数据都会经过==========

    //该方法封装进KafkaProducer.send()方法中,即它运行在用户主线程中。Producer确保在消息被序列化以及计算分区前
    //调用该方法。用户可以在方法中对消息做任何操作,但最好保证不要修改消息所属的topic和分区,否则会影响目标分区的计算
    public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
        //....
        return new ProducerRecord<String, String>(record.topic(),record.partition(), record.key(), System.currentTimeMillis() + record.value());
    }
    //该方法会在消息从RecordAccumulator成功发送到Kafka Broker之后,或者在发送过程中失败时调用。并且通常都是在producer回调逻辑触发之前
    //onAcknowledgement运行在producer的IO线程中,因此不要在该方法中放入很重的逻辑,否则会拖慢producer的消息发送效率
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
    }
    //关闭interceptor,主要用于执行一些资源清理工作。如前所述,interceptor可能被运行在多个线程中,因此在具体实现时用户需要自行确保线程安全
    //另外倘若指定了多个interceptor,则producer将按照指定顺序调用它们,
    //并仅仅是捕获每个interceptor可能抛出的异常记录到错误日志中而非再向上传递。
    public void close() {
    }
}
public class CounterInterceptor implements ProducerInterceptor<String,String> {
    int success;
    int error;
    public void configure(Map<String, ?> configs) {
    }
    //这个函数不用添加逻辑,但要把参数的record返回,返回null的话所有数据就都被过滤掉了
    public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
        return record;
    }
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
        if(metadata != null){
            success++;
        }else {
            error++;
        }
    }
    public void close() {
        System.out.println("success:" + success);
        System.out.println("error:" + error);
    }
}

生产者配置拦截器

//多个拦截器需要用集合写在一起,一个一个设的话会覆盖
properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, Arrays.asList("com.xxx.www.interceptor.TimeInterceptor","com.xxx.www.interceptor.CounterInterceptor"));

这里注意最后,生产者的 close() 方法必须调用,否则拦截器的 close() 方法也不会被执行。实际场景中,生产者跟消费者一样是一直开着的,通常是把 Producer 发送数据的代码用 try-catch 代码块 包起来,在 finally 代码块中调用 close() 方法

其他问题

  1. 当你使用 kafka-topics.sh创建(删除)了一个topic之后,Kafka内部会执行什么逻辑?

    • 会在 zookeeper 中的 /brokers/topics 节点下创建一个新的 topic 节点,如 /brokers/topics/first
    • 触发 Controller 的监听程序
    • c. Kafka Controller 负责 topic 的创建工作,并更新 metadata cache
  2. topic 的分区数可以增加,但不能减少,因为已经存在的数据无法处理

  3. Kafka 中需要选举的地方有 Controller 跟 leader,Controller 是通过抢夺资源选出来的

  4. Kafka 日志保存时间:7 天
    Kafka 硬盘大小:每天的数据量 * 7 天

  5. Kafka 消息数据积压,Kafka 消费能力不足怎么处理?
    我们知道消息从发送到消费会经过 broker 然后到达后下游消费者,那么消息可能堆积在 broker 或者消费者,我们就从这两个方面入手解决问题:

    • 如果是 kafka broker 集群内部消费能力不足,可以考虑增加 topic 的分区数,并且同时提升消费组的消费者数量,消费者数 = 分区数 (两者缺一不可),相当于横向扩展

    • 如果是下游消费者的数据处理不及时,有可能是消费者本身消费能力足够但是每次拉取到消息量太少,所以可以提高每批次拉取的数量,使消费者的消费能力充分发挥,使消息消费速度大于生产速度;如果是消费者本身消费能力不足,吞吐量上不来,那么就需要对消费者进行纵向的扩展,提高消费能力,比如使用并发编程,异步方式进行改造

      方案 a 比 b 提高得要更靠谱一点

  6. 如果其他节点消费能力很强, 某个消费节点消费能力很弱,如何解决这个节点消息堆积问题

    • 只有单个节点消费能力弱的话跟处理全局消息积压不同,我们应该针对于这个消费能力弱的节点进行优化,关键就是提高它的消费能力,比如说在节点的业务进行改造,使用并发编程,提高处理能力;或者类似于微服务架构中的熔断降级手段,我们可以降级节点中某些不重要的服务。使运行资源让出来给消费消息的业务使用。
    • 如果这些优化消费能力的手段都没有成效的话,那就可能是硬件因素或者其它因素使得节点的消费能力无法获得更高的提升,那我们就只能减少它需要处理的消息量,把消息分配给有余力的节点去消费,使全局的消费正常

什么情况会消息丢失,如何解决

一条消息从生产到消费完成经历三个环节,分别是生产者,消息中间件,消息消费者。那么消息丢失就可能出现在这些环节中的每一个:

生产者

  1. 首先,生产者调用 send 方法后,消息不会立即发出去,而是存在缓存中,等待消息量达到 batch.size 或者停留时间达到 linger.ms 后才会发送,那么在缓存的这段时间,如果生产者宕机,就会导致缓存丢失,这种情况我们可以适当调整缓存消息量或者停留时间,这个可以根据系统可用性的要求来调整,比如可用性达到 5 个 9,也就是一年内只有 5 分钟会不可用 (6 个 9 则为30秒),那就把停留时间设为小于5分钟的值,以此类推;而且如果真的在缓存丢失了,那么服务端也不会发送 ack,因此生产者应该进行重发请求的操作
  2. 第二,可能由于网络波动等原因,消息发送的 请求超时,没有发送成功。超时错误通常采取的补救措施就是重试;
  3. 第三,可能生产者知道发送消息 失败了,但没有回调,这种情况就可以通过设置 ack 的值来调整消息发送失败时的策略

服务端

  1. Kafka 的 Broker 接收到生产者传来的数据时,会先写到页高速缓存,然后 Linux 对于缓存采取的是 回写 策略,只有在空闲内存低于一个阈值时,或者脏页在内存中驻留时间超过一个阈值时,再或者用户进程主动调用 sync() 或者 fsync() 时,才会把缓存写回磁盘。那么这里就会存在一个隐患,如果在数据写入了页高速缓存但未写回磁盘时 Broker 进程崩溃了,或者 Broker 所在机器宕机了,页高速缓存会不会丢失?如果只是 Broker 进程崩溃的话消息是不会丢失的,因为内核对页高速缓存的写回与用户进程正常与否无关;但如果是机器宕机了,消息就丢失了,因为页高速缓存是处于内存的,掉电了内存自然就丢失数据了。

    对此可以考虑比如说使用带后备电池的缓存,防止机器异常掉电,即使掉电了也能讲缓存写回;或者对比 Redis 的 AOF 策略,缩短写回操作的间隔时间,最大程度减少由于写回间隔所带来的数据丢失量

消费端

  1. 消息堆积,导致一些消息没有被消费,就跟消息丢失一样
    解决措施就是提高消费者进程的消费速度,例如提高并发;也可能是消费者拉取的消息量太少,或者拉取的间隔太长,导致消息消费速度小于消息生产速度,也会造成消息积压,此时就可以修改 max.poll.records 跟 max.poll.interval.ms 参数对 单次拉取的数据量大小 以及 两次拉取的间隔 进行调整
  2. 自动提交 offset,消费者拉取了一批数据后,正在处理还没处理完就自动提交了 offset,此时消费端宕机了,重启后拉取的是新的 offset 的消息,上次未消费完的消息 无法再次被处理了
    解决措施就是关闭自动提交
    手动提交又有很多种方式,比如说消费一个消息提交一个 offset,这样可能吞吐量不够。可以并发消费;对于时效性要求不高的消息,可以先把消息持久化到本地然后提交 offset,后续异步从磁盘取出消息进行本地的消费
  3. 心跳超时,如果客户端心跳超时,会触发 kafka 服务端进行 rebalance,将客户端踢出消费者组,然后对消费者组进行重新分区。那么最坏的情况下,如果这个消费者组只有一个消费者,那么原先的消息就不会再被消费了

会不会出现消息重复消费的情况,如何解决

消息重复消费通常是由于消费端的问题导致的。因为 Kafka 中有幂等性的机制,可以做到从消息生产者到服务端发送的消息只会被保存一次。那我们考虑消费端什么情况下会出现消息重复消费
消费者在拉取了消息后,有可能消费完了,但是还未提交 offset 时就出现异常或者崩溃;那么就可能会出现消费者正常后或者 topic 被 rebalance 然后其它消费者来继续消费这个未提交的 offset。
对于这种问题的话,首先在异常时我们可以考虑进行事务回滚,保证数据的一致性;在真正修改数据的时候,比如修改数据库数据时,可以考虑执行 SQL 前进行一些状态位,版本号的检查,例如 where 字段 = 预期值,才去修改,这样当数据已经被更新过时就不会被再次更新,做到幂等性;另外有一些场景,数据可以接收更新,只要求不保存重复数据,可以使用 ON DUPLICATE KEY UPDATE 语法,不存在时插入,存在时更新,从而也能做到幂等性

消息乱序问题

有些业务场景可能涉及到多个消息,而这多个消息间又存在逻辑上的先后的关系。例如订单的下单跟支付,肯定是先下单才能支付。那么类似的情景下就要求多个消息之间顺序必须是正确的。
而同一个 Topic 分为多个 Partition,一个 Partition 内部的消息才是有序的,一个 Topic 整体上是无序的,所以我们如果要确保消息的有序性,应该在发送消息时主动地设置目标 Partition,同时消费者也从指定 Partition 中拉取消息

如果需要严格保证消息的顺序问题的话,就只能把 partition 数设置为 1

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值