<Kafka核心技术与实战>学习笔记 -- 客户端实践 & 原理剖析

<Kafka核心技术与实战>学习笔记 -- 客户端实践 & 原理剖析

生产者消息分区机制

使用 Apache Kafka 生产和消费消息的时候,肯定是希望能够将数据均匀地分配到所有服务器上

为什么分区?

Kafka 的主题(Topic): 承载真实数据的逻辑容器
在主题之下还分为若干个分区
Kafka 的消息组织方式: 三级结构:主题 - 分区 - 消息

主题下的每条消息只会保存在某一个分区中,而不会在多个分区中被保存多份

Kafka 的三级结构图
在这里插入图片描述

为什么使用分区的概念而不是直接使用多个主题呢?
对数据分区的作用&原因:
提供负载均衡的能力
实现系统的高伸缩性(Scalability)

不同的分区能够被放置到不同节点的机器上,而数据的读写操作也都是针对分区这个粒度而进行的,这样每个节点的机器都能独立地执行各自分区的读写请求处理
并且,还可以通过添加新的节点机器增加整体系统的吞吐量

多个分区允许多个consumer同时消费 多个broker可以组成集群,提高性能,扩展性,以及可用性

分区的概念以及分区数据库早在 1980 年就已经有人在做了,比如那时候有个叫 Teradata 的数据库就引入了分区的概念
不同的分布式系统对分区的叫法也不尽相同
在 Kafka 中叫分区,在 MongoDB 和 Elasticsearch 中就叫分片 Shard,而在 HBase 中则叫 Region,在 Cassandra 中又被称作 vnode

从表面看起来它们实现原理可能不尽相同,但对底层分区(Partitioning)的整体思想却从未改变
除了提供负载均衡这种最核心的功能之外,利用分区也可以实现其他一些业务级别的需求,比如实现业务级别的消息顺序的问题

Kafka 生产者的分区策略

分区策略: 决定生产者将消息发送到哪个分区的算法
Kafka提供默认的分区策略,同时也支持自定义分区策略

自定义分区策略

如果要自定义分区策略,需要显式地配置生产者端的参数partitioner.class

实现org.apache.kafka.clients.producer.Partitioner接口
接口定义了两个方法:`partition()close()`,通常只需要实现最重要的 partition 方法
int partition(String topic, 
		Object key, 
		byte[] keyBytes, 
		Object value, 
		byte[] valueBytes, 
		Cluster cluster);
	topic、key、keyBytes、value和valueBytes --- 消息数据
	cluster --- 集群信息(比如当前 Kafka 集群共有多少主题、多少 Broker 等)
	计算出消息要被发送到哪个分区中

设置partitioner.class参数为实现类的 Full Qualified Name,那么生产者程序就会按照代码逻辑对消息进行分区

分区策略 – 轮询

Round-robin 策略,即顺序分配
Kafka Java 生产者 API 默认提供的分区策略

轮询策略示意图
在这里插入图片描述

轮询策略有非常优秀的负载均衡表现,它总是能保证消息最大限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略,也是最常用的分区策略之一

分区策略 – 随机

Randomness 策略, 随意地将消息放置到任意一个分区上

随机策略示意图
在这里插入图片描述

随机策略版的 partition 方法
计算出该主题总的分区数,然后随机地返回一个小于它的正整数
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return ThreadLocalRandom.current().nextInt(partitions.size());

随机策略是老版本生产者使用的分区策略,表现逊于轮询策略,在新版本中已经改为轮询了

按消息键保序策略

Kafka 允许为每条消息定义消息键,简称为 Key
这个 Key 的作用非常大,它可以是一个有着明确业务含义的字符串,比如客户代码、部门编号或是业务 ID 等
也可以用来表征消息元数据
特别是在 Kafka 不支持时间戳的年代,在一些场景中,工程师们都是直接将消息创建时间封装进 Key 里面的
一旦消息被定义了 Key,那么就可以保证同一个 Key 的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略,如下图所示。

在这里插入图片描述

实现这个策略的 partition 方法:

List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return Math.abs(key.hashCode()) % partitions.size();

Kafka 默认分区策略

  • 如果指定了 Key,那么默认实现按消息键保序策略;
  • 如果没有指定 Key,则使用轮询策略

如何实现消息的顺序问题

方案1: 给 Kafka 主题设置单分区,这样所有的消息都只在这一个分区内读写
这样做虽然实现了因果关系的顺序性,但也丧失了 Kafka 多分区带来的高吞吐量和负载均衡的优势
方案2: 具有因果关系的消息都有一定的特点,比如在消息体中都封装了固定的标志位,可以对此标志位设定专门的分区策略,保证同一标志位的所有消息都发送到同一分区
这样既可以保证分区内的消息顺序,也可以享受到多分区带来的性能红利

基于个别字段的分区策略本质上就是按消息键保序的思想,其实更加合适的做法是把标志位数据提取出来统一放到 Key 中,这样更加符合 Kafka 的设计思想

其他分区策略

基于地理位置的分区策略
一般只针对那些大规模的 Kafka 集群,特别是跨城市、跨国家甚至是跨大洲的集群

假设所有服务都部署在北京的一个机房, 考虑在广州再创建一个机房
从两个机房中选取一部分机器共同组成一个大的 Kafka 集群
这个集群中有一部分机器在北京,另外一部分机器在广州

假设要为每个新注册用户提供一份注册礼品,南方的用户注册可以免费得到一碗“甜豆腐脑”,北方的新注册用户可以得到一碗“咸豆腐脑”
如果用 Kafka 来实现则很简单,只需要创建一个双分区的主题,然后再创建两个消费者程序分别处理南北方注册用户逻辑即可

但问题是需要把南北方注册用户的注册消息正确地发送到位于南北方的不同机房中,因为处理这些消息的消费者程序只可能在某一个机房中启动着

此时就可以根据 Broker 所在的 IP 地址实现定制化的分区策略

List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
return partitions.stream()
			.filter(p -> isSouth(p.leader().host()))
			.map(PartitionInfo::partition).findAny()
			.get();

可以从所有分区中找出那些 Leader 副本在南方的所有分区,然后随机挑选一个进行消息发送

小结

切记分区是实现负载均衡以及高吞吐量的关键
在生产者这一端就要仔细盘算合适的分区策略,避免造成消息数据的“倾斜”,使得某些分区成为性能瓶颈,这样极易引发下游数据消费的性能下降

在这里插入图片描述

生产者压缩算法

压缩(compression)
用时间去换空间的经典 trade-off 思想
用 CPU 时间去换磁盘空间或网络 I/O 传输量,希望以较小的 CPU 开销带来更少的磁盘占用或更少的网络 I/O 传输

怎么压缩

Kafka 共有两大类消息格式,社区分别称之为 V1 版本和 V2 版本。V2 版本是 Kafka 0.11.0.0 中正式引入的

不论是哪个版本,Kafka 的消息层次都分为两层:消息集合(message set)以及消息(message)
一个消息集合中包含若干条日志项(record item),而日志项才是真正封装消息的地方
Kafka 底层的消息日志由一系列消息集合日志项组成
Kafka 通常不会直接操作具体的一条条消息,它总是在消息集合这个层面上进行写入操作

V2 版本主要是针对 V1 版本的一些弊端做了修正

  • 消息的公共部分抽取出来放到外层消息集合里面,不用每条消息都保存这些信息
  • 保存压缩消息的方法发生了变化
    V1: 把多条消息进行压缩然后保存到外层消息的消息体字段中
    V2: 对整个消息集合进行压缩

在相同条件下,
不论是否启用压缩,V2 版本都比 V1 版本节省磁盘空间
当启用压缩时,这种节省空间的效果更加明显

在这里插入图片描述

何时压缩

在 Kafka 中,压缩可能发生在两个地方:生产者端和 Broker 端

生产者程序中配置 compression.type 参数 即表示启用指定类型的压缩算法。比如下面这段程序代码展示了如何构建一个开启 GZIP 的 Producer 对象:

 Properties props = new Properties();
 props.put("bootstrap.servers", "localhost:9092");
 props.put("acks", "all");
 props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
 props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
 // 开启GZIP压缩, 表明该 Producer 的压缩算法使用的是 GZIP
 props.put("compression.type", "gzip");
 
 Producer<String, String> producer = new KafkaProducer<>(props);

大部分情况下 Broker 从 Producer 端接收到消息后仅仅是原封不动地保存
两种 Broker 会重新压缩消息的情况

  1. Broker 端指定了和 Producer 端不同的压缩算法
    Broker 端也有一个参数叫 compression.type, 默认值是 producer,表示 Broker 端会“尊重”Producer 端使用的压缩算法
    如果在 Broker 端设置了不同的 compression.type 值,可能会发生预料之外的压缩 / 解压缩操作,通常表现为 Broker 端 CPU 使用率飙升
  2. Broker 端发生了消息格式转换
    Kafka 集群中同时保存多种版本的消息格式, 为了兼容老版本的格式,Broker 端会对新版本消息执行向老版本格式的转换, 这个过程中会涉及消息的解压缩和重新压缩, 还会让 Kafka 丧失 Zero Copy 特性

Zero Copy零拷贝
当数据在磁盘和网络进行传输时避免昂贵的内核态数据拷贝,从而实现快速的数据传输

何时解压缩

producer压缩,broker保持,consumer解压缩 使用的压缩算法信息被封装进消息集合

除了在 Consumer 端解压缩,Broker 端也会进行解压缩
每个压缩过的消息集合在 Broker 端写入时都要发生解压缩操作,目的就是为了对消息执行各种验证
对 Broker 端性能是有一定影响的,特别是对 CPU 的使用率而言

各种压缩算法对比

在 Kafka 2.1.0 版本之前,Kafka 支持 3 种压缩算法:GZIP、Snappy 和 LZ4
从 2.1.0 开始,Kafka 正式支持 Zstandard 算法(简写为 zstd)
– Facebook 开源的一个压缩算法,能够提供超高的压缩比(compression ratio)

Facebook Zstandard 官网提供的一份压缩算法 benchmark 比较结果
在这里插入图片描述
zstd 算法有着最高的压缩比,而在吞吐量上的表现只能说中规中矩
LZ4 算法,它在吞吐量方面表现优异

对于 Kafka 而言,性能测试结果

  • 吞吐量方面:LZ4 > Snappy > zstd 和 GZIP
  • 而在压缩比方面,zstd > LZ4 > GZIP > Snappy

使用 Snappy 算法占用的网络带宽最多,zstd 最少,这是合理的,毕竟 zstd 就是要提供超高的压缩比
在 CPU 使用率方面,各个算法表现得差不多,只是在压缩时 Snappy 算法使用的 CPU 较多一些,而在解压缩时 GZIP 算法则可能使用更多的 CPU

实践

启用压缩的条件:

  1. Producer 程序运行机器上的 CPU 资源要很充足
  2. 环境中带宽资源有限

解压缩:
尽量保证不要出现消息格式转换的情况

小结

在这里插入图片描述

无消息丢失配置

Kafka 只对“已提交”的消息(committed message)做有限度的持久化保证

  • 已提交的消息:
    Kafka 的若干个 Broker 成功地接收到一条消息并写入到日志文件后,它们会告诉生产者程序这条消息已成功提交
  • 有限度的持久化保证:
    假如消息保存在 N 个 Kafka Broker 上, 这 N 个 Broker 中至少有 1 个存活, Kafka 就能保证这条消息永远不会丢失

消息丢失 – 生产者程序丢失数据

Kafka Producer 是异步发送消息的
producer.send(msg)
fire and forget 调用后不管结果

不成功的原因:

  1. 网络抖动, 消息压根就没有发送到 Broker 端
  2. 消息本身不合格导致 Broker 拒绝接收(比如消息太大了,超过了 Broker 的承受能力)等

Producer 永远要使用带有回调通知的发送 API,也就是说不要使用 producer.send(msg),而要使用 producer.send(msg, callback)

瞬时错误,那么仅仅让 Producer 重试就可以了;
如果是消息不合格造成的,那么可以调整消息格式后再次发送

消息丢失 – 消费者程序丢失数据

Consumer 程序 – 位移:
这个 Consumer 当前消费到的 Topic 分区的位置
如图, Consumer A 的位移值就是 9;Consumer B 的位移值是 11
在这里插入图片描述
保证消息不丢失的办法:

  1. 先消费消息(阅读)
  2. 再更新位移(书签)

可能会带来消息的重复处理

丢失消息的情况:
Consumer 程序从 Kafka 获取到消息后开启了多个线程异步处理消息,而 Consumer 程序自动地向前更新位移
假如其中某个线程运行失败了,它负责的消息没有被成功处理,但位移已经被更新了
因此这条消息对于 Consumer 而言实际上是丢失了

解决方案:
如果是多线程异步处理消费消息,Consumer 程序不要开启自动提交位移,而是要应用程序手动提交位移

注意:
单个 Consumer 程序使用多线程来消费消息说起来容易,写成代码却异常困难
因为很难正确地处理位移的更新,也就是说避免无消费消息丢失很简单,但极易出现消息被消费了多次的情况

Kafka 无消息丢失的配置实践

  1. 不要使用 producer.send(msg),而要使用 producer.send(msg, callback)。记住,一定要使用带有回调通知的 send 方法

Producer 端参数:

  1. 设置 acks = all。acks 参数代表了对“已提交”消息的定义。如果设置成 all,则表明所有副本 Broker 都要接收到消息,该消息才算是“已提交”。这是最高等级的“已提交”定义
  2. 设置 retries 为一个较大的值。这里的 retries 对应前面提到的 Producer 自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了 retries > 0 的 Producer 能够自动重试消息发送,避免消息丢失

Broker 端参数:

  1. 设置 unclean.leader.election.enable = false ,控制的是哪些 Broker 有资格竞选分区的 Leader。如果一个 Broker 落后原先的 Leader 太多,那么它一旦成为新的 Leader,必然会造成消息的丢失。故一般都要将该参数设置成 false,即不允许这种情况的发生
  2. 设置 replication.factor >= 3,表述的是最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余
  3. 设置 min.insync.replicas > 1,控制的是消息至少要被写入到多少个副本才算是“已提交”。设置成大于 1 可以提升消息持久性。在实际环境中千万不要使用默认值 1

  1. 确保 replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成 replication.factor = min.insync.replicas + 1
  2. 确保消息消费完成再提交。Consumer 端有个参数 enable.auto.commit,最好把它设置成 false,并采用手动提交位移的方式。就像前面说的,这对于单 Consumer 多线程处理的场景而言是至关重要的

第二条与第六条是否冲突?
ISR: 与leader副本同步的副本集合
其实不冲突。如果ISR中只有1个副本了,acks=all也就相当于acks=1了,引入min.insync.replicas的目的就是为了做一个下限的限制:
1. 不能只满足于ISR全部写入,2. 还要保证ISR中的写入个数不少于min.insync.replicas

小结

在这里插入图片描述

开放讨论

Kafka 中特别隐秘的消息丢失场景:增加主题分区
当增加主题分区后,在某段“不凑巧”的时间间隔后,Producer 先于 Consumer 感知到新增加的分区,而 Consumer 设置的是“从最新位移处”开始读取消息,因此在 Consumer 感知到新分区前,Producer 发送的这些消息就全部“丢失”了,或者说 Consumer 无法读取到这些消息
这个小缺陷有什么解决的办法吗?

A:
新建分区丢失是因为没有offset就从lastest开始读取,可以改成没有offset的时候从ealiest读取应该就可以了

单个 Consumer 程序使用多线程来消费消息 Java 实践

【原创】Kafka Consumer多线程实例续篇

Kafka 拦截器

拦截器基本思想:
允许应用程序在不修改逻辑的情况下,动态地实现一组可插拔的事件处理逻辑链。它能够在主业务操作的前后多个时间点上插入对应的“拦截”逻辑

Spring MVC 拦截器的工作原理
在这里插入图片描述
拦截器 1 和拦截器 2 分别在请求发送之前、发送之后以及完成之后三个地方插入了对应的处理逻辑
而 Flume 中的拦截器也是同理,它们插入的逻辑可以是修改待发送的消息,也可以是创建新的消息,甚至是丢弃消息
这些功能都是以配置拦截器类的方式动态插入到应用程序中的,故可以快速地切换不同的拦截器而不影响主程序逻辑

Kafka 拦截器借鉴了这样的设计思路。可以在消息处理的前后多个时点动态植入不同的处理逻辑,比如在消息发送前或者在消息被消费后

拦截器detail

生产者拦截器允许在发送消息前以及消息提交成功后植入的拦截器逻辑;
消费者拦截器支持在消费消息前以及提交位移后编写特定逻辑

这两种拦截器都支持链的方式,即可以将一组拦截器串连成一个大的拦截器,Kafka 会按照添加顺序依次执行拦截器逻辑

Producer 端指定拦截器
生产者和消费者两端有一个相同的参数,名字叫 interceptor.classes,它指定的是一组类的列表,每个类就是特定逻辑的拦截器实现类

Properties props = new Properties();
List<String> interceptors = new ArrayList<>();
// 第一个拦截器的完整类路径
interceptors.add("com.yourcompany.kafkaproject.interceptors.AddTimestampInterceptor"); // 拦截器1
// 第二个拦截器的完整类路径
interceptors.add("com.yourcompany.kafkaproject.interceptors.UpdateCounterInterceptor"); // 拦截器2
props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, interceptors);
……

Producer 端拦截器实现类都要继承 org.apache.kafka.clients.producer.ProducerInterceptor 接口
实现两个方法:
1. onSend:该方法会在消息发送之前被调用
2. onAcknowledgement:该方法会在消息成功提交或发送失败之后被调用
	与发送回调通知 callback 相比, onAcknowledgement 的调用要早于 callback 的调用
	onAcknowledgement 方法和 onSend 不是在同一个线程中被调用的,因此如果要在这两个方法中调用了某个共享可变对象,一定要保证线程安全
	onAcknowledgement 方法处在 Producer 发送的主路径中,所以最好别放一些太重的逻辑进去,否则 Producer 的 TPS 会直线下降

消费者拦截器也是同样的方法,只是具体的实现类要实现 org.apache.kafka.clients.consumer.ConsumerInterceptor 接口
1. onConsume:该方法在消息返回给 Consumer 程序之前调用
2. onCommit:Consumer 在提交位移之后调用该方法。通常可以在该方法中做一些记账类的动作,比如打日志等。

指定拦截器类时要指定它们的全限定名,即 full qualified name,并且还要保证 Producer 程序能够正确加载的拦截器类。

典型应用场景

Kafka 拦截器可以应用于包括客户端监控、端到端系统性能检测、消息审计等多种功能在内的场景

端到端系统性能检测

Kafka 默认提供的监控指标都是针对单个客户端或 Broker 的,很难从具体的消息维度去追踪集群间消息的流转路径
同时,如何监控一条消息从生产到最后消费的端到端延时也是很多 Kafka 用户迫切需要解决的问题

方案1: 在客户端程序中增加统计逻辑
在应用代码中编写统一的监控逻辑其实是很难的
监控逻辑与主业务逻辑耦合也是软件工程中不提倡的做法

方案2: Kafka 拦截器
可插拔的机制 √

消息审计

设想公司把 Kafka 作为一个私有云消息引擎平台向全公司提供服务,这必然要涉及多租户以及消息审计的功能
作为私有云的 PaaS 提供方,肯定要能够随时查看每条消息是哪个业务方在什么时间发布的,之后又被哪些业务方在什么时刻消费
一个可行的做法就是编写一个拦截器类,实现相应的消息审计逻辑,然后强行规定所有接入 Kafka 服务的客户端程序必须设置该拦截器

案例分析 – 编写拦截器类来统计消息端到端处理的延时

计算总延时
需要让生产者和消费者程序都能访问
假设保存在redis中

生产者端拦截器实现

public class AvgLatencyProducerInterceptor implements ProducerInterceptor<String, String> {

  private Jedis jedis; // 省略Jedis初始化

  @Override
  public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
    // 在发送消息前更新总的已发送消息数
    // 还可以考虑消息发送失败的情况
    jedis.incr("totalSentMessage");
    return record;
  }

  @Override
  public void onAcknowledgement(RecordMetadata metadata, Exception exception) {}

  @Override
  public void close() {}

  @Override
  public void configure(Map<String, ?> configs) {}
}

消费者端的拦截器实现

public class AvgLatencyConsumerInterceptor implements ConsumerInterceptor<String, String> {

  private Jedis jedis; // 省略Jedis初始化

  @Override
  public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
    long lantency = 0L;
    for (ConsumerRecord<String, String> record : records) {
      // 在真正消费一批消息前首先更新了它们的总延时
      lantency += (System.currentTimeMillis() - record.timestamp());
    }
    jedis.incrBy("totalLatency", lantency);
    // 从 Redis 中读取更新过的总延时和总消息数,两者相除即得到端到端消息的平均处理延时
    long totalLatency = Long.parseLong(jedis.get("totalLatency"));
    long totalSentMsgs = Long.parseLong(jedis.get("totalSentMessage"));
    jedis.set("avgLatency", String.valueOf(totalLatency / totalSentMsgs));
    return records;
  }

  @Override
  public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {}

  @Override
  public void close() {}

  @Override
  public void configure(Map<String, ?> configs) {}
}

小结

在这里插入图片描述

讨论

Producer 拦截器 onSend 方法的签名如下:

public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record)

如果return null 会怎么办?

onSend传null会在KafkaProducer类中调用doSend时引发NPE (NullPointException),并通过 ProducerInterceptors.onSendError 方法传导至onAcknowledgement,以及throw到用户编写的Producer中

Java生产者是如何管理TCP连接的?

why tcp?

Apache Kafka 的所有通信都是基于 TCP 的,而不是基于 HTTP 或其他协议。无论是生产者、消费者,还是 Broker 之间的通信都是如此

Kafka 社区决定采用 TCP 协议作为所有请求通信的底层协议的两个原因:

  • 多路复用请求 & 同时轮询多个连接的能力

所谓的多路复用请求,即 multiplexing request,是指将两个或多个数据流合并到底层单一物理连接中的过程
TCP 的多路复用请求会在一条物理连接上创建若干个虚拟连接,每个虚拟连接负责流转各自对应的数据流
其实严格来说,TCP 并不能多路复用,它只是提供可靠的消息交付语义保证,比如自动重传丢失的报文
更严谨地说,作为一个基于报文的协议,TCP 能够被用于多路复用连接场景的前提是,上层的应用协议(比如 HTTP)允许发送多条消息

  • 目前已知的 HTTP 库在很多编程语言中都略显简陋

生产者代码

使用了 try-with-resource 特性,所以并没有显式调用 producer.close() 方法

    Properties props = new Properties();
    props.put("参数1", "参数1的值");
    props.put("参数2", "参数2的值");

    try (Producer<String, String> producer = new KafkaProducer<>(props)) {
      producer.send(new ProducerRecord<String, String>(), callback);
    } catch (Exception e) {

    }

何时创建 TCP 连接?如何管理TCP连接?

何时建立tcp连接的呢?

Producer producer = new KafkaProducer(props) 
producer.send(msg, callback)

在创建 KafkaProducer 实例时,生产者应用会在后台创建并启动一个名为 Sender 的线程,该 Sender 线程开始运行时首先会创建与 Broker 的连接

– 没有调用send()方法, 不知道连接哪个broker

会连接 bootstrap.servers 参数指定的所有 Broker

– bootstrap.servers 参数, 指定了 Producer 启动时要连接的 Broker 地址

注意:
如果为 bootstrap.servers 参数指定了 1000 个 Broker 连接信息,那么 Producer 启动时会首先创建与这 1000 个 Broker 的 TCP 连接

建议:
不需要配置全部 broker,通常配置 3~4 台就可以了, 因为任一台 broker 可以知道整个集群 broker 的信息

启动日志

测试环境中,用 bootstrap.servers 配置了 localhost:9092、localhost:9093 来模拟不同的 BrokerKafkaProducer 实例被创建后以及消息被发送前,Producer 应用就开始创建与 --- 两台 Broker --- 的 TCP 连接了
DEBUG [Producer clientId=producer-1] 
	Initialize connection to node localhost:9093 (id: -2 rack: null) for sending metadata request
	(org.apache.kafka.clients.NetworkClient:1084)
	
DEBUG [Producer clientId=producer-1] 
	Initiating connection to node localhost:9093 (id: -2 rack: null) using address localhost/127.0.0.1
	(org.apache.kafka.clients.NetworkClient:914)
	
DEBUG [Producer clientId=producer-1] 
	Initialize connection to node localhost:9092 (id: -1 rack: null) for sending metadata request
	(org.apache.kafka.clients.NetworkClient:1084)
	
DEBUG [Producer clientId=producer-1] 
	Initiating connection to node localhost:9092 (id: -1 rack: null) using address localhost/127.0.0.1
	(org.apache.kafka.clients.NetworkClient:914)
	
DEBUG [Producer clientId=producer-1] 
	Sending metadata request (type=MetadataRequest, topics=) to node localhost:9093 (id: -2 rack: null)
	(org.apache.kafka.clients.NetworkClient:1068)
表明 Producer 向某一台 Broker 发送了 METADATA 请求,尝试获取集群的元数据信息 ------ Producer 能够获取集群所有信息的方法

KafkaProducer 实例创建的线程 和 Sender 线程共享的可变数据结构只有 RecordAccumulator 类
故维护了 RecordAccumulator 类的线程安全,也就实现了KafkaProducer 类的线程安全
RecordAccumulator主要的数据结构是ConcurrentMap

TopicPartition 是 Kafka 用来表示主题分区的 Java 对象,本身是不可变对象
而 RecordAccumulator 代码中用到 Deque 的地方都有锁的保护,所以基本上可以认定 RecordAccumulator 类是线程安全的

kafka设计思考:
纵然 KafkaProducer 是线程安全的,创建 KafkaProducer 实例时启动 Sender 线程的做法是否有问题?
在对象构造器中启动线程会造成 this 指针的逃逸 – 《Java 并发编程实践》
Sender 线程完全能够观测到一个尚未构造完成的 KafkaProducer 实例。当然,在构造对象时创建线程没有任何问题,但最好是不要同时启动它


TCP 连接是在创建 KafkaProducer 实例时建立的
TCP 连接还可能在两个地方被创建:一个是在更新元数据后,另一个是在消息发送时, 这两个地方并非总是创建 TCP 连接

  • 当 Producer 更新了集群的元数据信息之后,如果发现与某些 Broker 当前没有连接,那么它就会创建一个 TCP 连接
  • 同样地,当要发送消息时,Producer 发现尚不存在与目标 Broker 的连接,也会创建一个

Producer 更新集群元数据信息的两个场景

  1. 当 Producer 尝试给一个不存在的主题发送消息时,Broker 会告诉 Producer 说这个主题不存在。此时 Producer 会发送 METADATA 请求给 Kafka 集群,去尝试获取最新的元数据信息
  2. Producer 通过 metadata.max.age.ms 参数定期地去更新元数据信息。该参数的默认值是 300000,即 5 分钟,也就是说不管集群那边是否有变化,Producer 每 5 分钟都会强制刷新一次元数据以保证它是最及时的数据

思考
一个 Producer 默认会向集群的所有 Broker 都创建 TCP 连接,不管是否真的需要传输请求
Kafka 支持强制将空闲的 TCP 连接资源关闭
会造成资源的浪费

eg:
在一个有着 1000 台 Broker 的集群中,Producer 可能只会与其中的 3~5 台 Broker 长期通信
但是 Producer 启动后依次创建与这 1000 台 Broker 的 TCP 连接
一段时间之后,大约有 995 个 TCP 连接又被强制关闭

何时关闭TCP连接?

Producer 端关闭 TCP 连接的方式有两种:

  • 用户主动关闭
    kill -9 杀掉”Producer 应用 / 调用 producer.close() 方法
  • Kafka 自动关闭
    Producer 端参数 connections.max.idle.ms, 默认9min
    如果在 9 分钟内没有任何请求“流过”某个 TCP 连接,那么 Kafka 会主动把该 TCP 连接关闭
    Producer 端参数 connections.max.idle.ms=-1 禁掉这种机制, TCP 连接将成为永久长连接
    软件层面的“长连接”机制, Kafka 创建的 Socket 连接都开启了 keepalive,遵守 keepalive 探活机制
    注意:
    TCP 连接是在 Broker 端被关闭的,但其实这个 TCP 连接的发起方是客户端
    被动关闭的后果就是会产生大量的 CLOSE_WAIT 连接

CLOSE_WAIT状态是在被动方的,即:主动方发起close请求(FIN),被动方回复ACK后,被动方将进入CLOSE_WAIT状态; 之后,被动方才发起FIN请求,进行后续的关闭操作。
为什么被动关闭的后果是会产生大量的CLOSE_WAIT连接呢?
客户端(productor)有可能会hold住这个连接,比如不是使用完这个Producer实例发送完消息就进行关闭,而是一直持有,那么就可能会出现上面这种现象

小结

对 Kafka(2.1.0)而言,Java Producer 端管理 TCP 连接的方式是:

  1. KafkaProducer 实例创建时启动 Sender 线程,从而创建与 bootstrap.servers 中所有 Broker 的 TCP 连接
  2. KafkaProducer 实例首次更新元数据信息之后,还会再次创建与集群中所有 Broker 的 TCP 连接
  3. 如果 Producer 端发送消息到某台 Broker 时发现没有与该 Broker 的 TCP 连接,那么也会立即创建连接
  4. 如果设置 Producer 端 connections.max.idle.ms 参数大于 0,则步骤 1 中创建的 TCP 连接会被自动关闭;如果设置该参数 =-1,那么步骤 1 中创建的 TCP 连接将无法被关闭,从而成为“僵尸”连接

在这里插入图片描述

幂等生产者 vs 事务生产者

参考文章:
Kafka设计解析(三)恰好一次和事务消息

消息交付可靠性保障

  • 最多一次(at most once):消息可能会丢失,但绝不会被重复发送
  • 至少一次(at least once):消息不会丢失,但有可能被重复发送 (Kafka 默认)
  • 精确一次(exactly once):消息不会丢失,也不会被重复发送

已提交:

  • 只有 Broker 成功“提交”消息
  • Producer 接到 Broker 的应答

精确一次:幂等性(Idempotence) + 事务(Transaction)

幂等性

数学领域
某些操作或函数能够被执行多次,但每次得到的结果都是不变的
eg
数字乘以 1
取整函数(floor 和 ceiling)是幂等函数,运行 1 次 floor(3.4) 和 100 次 floor(3.4),结果都是3

计算机领域

  • 在命令式编程语言(比如 C)中,若一个子程序是幂等的,那它必然不能修改系统状态。这样不管运行这个子程序多少次,与该子程序关联的那部分系统状态保持不变
  • 在函数式编程语言(比如 Scala 或 Haskell)中,很多纯函数(pure function)天然就是幂等的,它们不执行任何的 side effect

幂等性的优势
可以安全地重试任何幂等性操作,反正它们也不会破坏系统状态

幂等性productor

0.11.0.0 版本引入了幂等性 Producer

  • 在此之前,Kafka 向分区发送数据时,可能会出现同一条消息被发送了多次,导致消息重复的情况
  • 在 0.11 之后,指定 Producer 幂等性需要设置一个参数即可,
    props.put(“enable.idempotence”, ture),或 props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true)

底层原理, 用空间去换时间,即在 Broker 端多保存一些字段
简单理解: 当 Producer 发送了具有相同字段值的消息后,Broker 能够自动知晓这些消息已经重复了,于是可以在后台默默地把它们“丢弃”掉

幂等性 Producer 的作用范围:

  • 只能保证单分区上的幂等性,即一个幂等性 Producer 能够保证某个主题的一个分区上不出现重复消息,它无法实现多个分区的幂等性
  • 只能实现单会话上的幂等性,不能实现跨会话的幂等性
    这里的会话,可以理解为 Producer 进程的一次运行
    当重启了 Producer 进程之后,这种幂等性保证就丧失了

实现多分区以及多会话上的消息无重复
->
事务(transaction)或者依赖事务型 Producer

数据库事务

Kafka 的事务概念类似于数据库提供的事务

在数据库领域,事务提供的安全性保障是经典的 ACID,即原子性(Atomicity)、一致性 (Consistency)、隔离性 (Isolation) 和持久性 (Durability)

数据库事务操作的并发一致性问题:

  • 丢失更新:
    两个不同事务同时获得相同数据,然后在各自事务中同时修改了该数据,那么先提交的事务更新会被后提交事务的更新给覆盖掉,这种情况先提交的事务所做的更新就被覆盖,导致数据更新丢失
  • 脏读:
    事务A读取了事务B未提交的数据,由于事务B回滚,导致了事务A的数据不一致,结果事务A出现了脏读
  • 不可重复读:
    一个事务在自己没有更新数据库数据的情况,同一个查询操作执行两次或多次得到的结果数值不同,因为别的事务更新了该数据,并且提交了事务
  • 幻读:
    事务A读的时候读出了N条记录,事务B在事务A执行的过程中增加 了1条,事务A再读的时候就变成了N+1条,这种情况就叫做幻读

幻读是指一种结构上的改变,比如说条数发生了改变;不可重复读是指读出的数值发生了改变

数据库事务的隔离级别:

  • 读未提交(Read Uncommitted):
    允许脏读取。如果一个事务已经开始写数据,则另外一个数据则不允许同时进行写操作,但允许其他事务读此行数据
  • 读已提交(Read Committed):(Oracle默认隔离级别)
    允许不可重复读取,但不允许脏读取。读取数据的事务允许其他事务继续访问该行数据,但是未提交的写事务将会禁止其他事务访问该行
  • 可重复读(Repeatable Read):(MySQL默认隔离级别)
    禁止不可重复读取和脏读取,但是有时可能出现幻读。读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务
  • 序列化(Serializable):
    提供严格的事务隔离。它要求事务序列化执行,事务只能一个接着一个地执行,但不能并发执行

Kafka事务

Kafka 自 0.11 版本开始也提供了对事务的支持,目前主要是在 read committed 读已提交 隔离级别上做事情
它能保证多条消息原子性地写入到目标分区,同时也能保证 Consumer 只能看到事务成功提交的消息

事务型 Productor

事务型 Producer 能够保证将消息原子性地写入到多个分区中
这批消息要么全部写入成功,要么全部失败
另外,事务型 Producer 也不惧进程的重启。Producer 重启回来后,Kafka 依然保证它们发送消息的精确一次处理

设置事务型 Producer 需要满足两个要求:

  • 和幂等性 Producer 一样,开启 enable.idempotence = true
  • 设置 Producer 端参数 transactional. id。最好为其设置一个有意义的名字
事务型 Producer 需要调用事务 API
        producer.initTransactions();  -- 事务的初始化
        try {
            producer.beginTransaction();  -- 事务的开始
            producer.send(record1);
            producer.send(record2);
            producer.commitTransaction();  -- 事务的提交
        } catch (KafkaException e) {
            producer.abortTransaction();  -- 事务的终止
        }
保证 Record1 和 Record2 被当作一个事务统一提交到 Kafka,要么它们全部提交成功,要么全部写入失败

即使写入失败,Kafka 也会把它们写入到底层的日志中,也就是说 Consumer 还是会看到这些消息
因此在 Consumer 端,读取事务型 Producer 发送的消息也是需要一些变更的, 需要设置 isolation.level 参数
参数的两个取值

  • read_uncommitted:这是默认值,表明 Consumer 能够读取到 Kafka 写入的任何消息,不论事务型 Producer 提交事务还是终止事务,其写入的消息都可以读取。很显然,如果用了事务型 Producer,那么对应的 Consumer 就不要使用这个值
  • read_committed:表明 Consumer 只会读取事务型 Producer 成功提交事务写入的消息。当然了,它也能看到非事务型 Producer 写入的所有消息

小结

幂等性 Producer 和事务型 Producer 都是 Kafka 社区力图为 Kafka 实现精确一次处理语义所提供的工具,只是它们的作用范围是不同的

  • 幂等性 Producer 只能保证单分区、单会话上的消息幂等性;
  • 而事务能够保证跨分区、跨会话间的幂等性, 交付语义上能做的更多, 但是性能更差

注意:
在实际使用过程中,需要仔细评估引入事务的开销,切不可无脑地启用事务

在这里插入图片描述

消费者组

Consumer Group 是 Kafka 提供的可扩展且具有容错性的消费者机制, 三个特性:

  1. Consumer Group 下可以有一个或多个 Consumer 实例(Consumer Instance)。这里的实例可以是一个单独的进程,也可以是同一进程下的线程。在实际场景中,使用进程更为常见一些
  2. Group ID 是一个字符串,在一个 Kafka 集群中,它标识唯一的一个 Consumer Group
  3. Consumer Group 下所有实例订阅的主题的单个分区,只能分配给组内的某个 Consumer 实例消费。这个分区当然也可以被其他的 Group 消费

两种消息引擎的优劣

  • 点对点模型
    缺陷: 消息一旦被消费,就会从队列中被删除,而且只能被下游的一个 Consumer 消费
    伸缩性(scalability)很差,因为下游的多个 Consumer 都要“抢”这个共享消息队列的消息
  • 发布 / 订阅模型
    允许消息被多个 Consumer 消费,但它的问题也是伸缩性不高,因为每个订阅者都必须要订阅主题的所有分区
    全量订阅的方式既不灵活,也会影响消息的真实投递效果

Kafka 的 Consumer Group
订阅了多个主题后,组内的每个实例不要求一定要订阅主题的所有分区,它只会消费部分分区中的消息
Consumer Group 之间彼此独立,互不影响,它们能够订阅相同的一组主题而互不干涉
加上 Broker 端的消息留存机制,Kafka 的 Consumer Group 完美地规避了上面提到的伸缩性差的问题


Kafka 仅仅使用 Consumer Group 这一种机制,却同时实现了传统消息引擎系统的两大模型

  • 如果所有实例都属于同一个 Group,那么它实现的就是消息队列模型
  • 如果所有实例分别属于不同的 Group,那么它实现的就是发布 / 订阅模型
    每个组都订阅这个主题, 但是组内只有一个消费者能消费

Group 下 Consumer 的实例数量

在实际使用场景中,怎么知道一个 Group 下该有多少个 Consumer 实例呢?
理想情况下,Consumer 实例的数量应该等于该 Group 订阅主题的分区总数

eg
假设一个 Consumer Group 订阅了 3 个主题,分别是 A、B、C,它们的分区数依次是 1、2、3(总共是 6 个分区)

  • 通常情况下,为该 Group 设置 6 个 Consumer 实例是比较理想的情形,因为它能最大限度地实现高伸缩性
  • 如果有 3 个实例,那么平均下来每个实例大约消费 2 个分区(6 / 3 = 2)
  • 如果设置了 8 个实例,那么有 2 个实例(8 – 6 = 2)将不会被分配任何分区,它们永远处于空闲状态

在实际使用过程中一般不推荐设置大于总分区数的 Consumer 实例。设置多余的实例只会浪费资源,而没有任何好处

Consumer Group 位移管理

针对 Consumer Group,Kafka 是怎么管理位移的呢?
消费者在消费的过程中需要记录自己消费了多少数据,即消费位置信息
在 Kafka 中,这个位置信息有个专门的术语:位移(Offset)

对于 Consumer Group 而言,
位移可以简单理解为一组 KV 对,Key 是分区,V 对应 Consumer 消费该分区的最新位移


老版本的 Consumer Group 把位移保存在 ZooKeeper 中
Apache ZooKeeper 是一个分布式的协调服务框架,Kafka 重度依赖它实现各种各样的协调管理
将位移保存在 ZooKeeper 外部系统的做法,最显而易见的好处就是减少了 Kafka Broker 端的状态保存开销
现在比较流行的提法是将服务器节点做成无状态的,这样可以自由地扩缩容,实现超强的伸缩性
Kafka 最开始也是基于这样的考虑,才将 Consumer Group 位移保存在独立于 Kafka 集群之外的框架中

存在问题:
ZooKeeper 这类元框架其实并不适合进行频繁的写更新,而 Consumer Group 的位移更新却是一个非常频繁的操作
这种大吞吐量的写操作会极大地拖慢 ZooKeeper 集群的性能
将 Consumer 位移保存在 ZooKeeper 中是不合适的做法

在新版本的 Consumer Group 中,Kafka 社区重新设计了 Consumer Group 的位移管理方式,采用了将位移保存在 Kafka 内部主题的方法
新版本的 Consumer Group 将位移保存在 Broker 端的内部主题__consumer_offsets中

Consumer Group 端的重平衡, Rebalance 过程

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

Rebalance 的触发条件
  1. 组成员数发生变更。比如有新的 Consumer 实例加入组或者离开组,抑或是有 Consumer 实例崩溃被“踢出”组
  2. 订阅主题数发生变更。Consumer Group 可以使用正则表达式的方式订阅主题,比如 consumer.subscribe(Pattern.compile(“t.*c”)) 就表明该 Group 订阅所有以字母 t 开头、字母 c 结尾的主题。在 Consumer Group 的运行过程中,新创建了一个满足这样条件的主题,那么该 Group 就会发生 Rebalance
  3. 订阅主题的分区数发生变更。Kafka 当前只能允许增加一个主题的分区数。当分区数增加时,就会触发订阅该主题的所有 Group 开启 Rebalance

Rebalance 发生时,Group 下所有的 Consumer 实例都会协调在一起共同参与
每个 Consumer 实例怎么知道应该消费订阅主题的哪些分区呢?
需要分配策略的协助, Kafka 默认提供了 3 种分配策略
社区会不断地完善这些策略,保证提供最公平的分配策略,即每个 Consumer 实例都能够得到较为平均的分区数
eg
比如一个 Group 内有 10 个 Consumer 实例,要消费 100 个分区,理想的分配策略自然是每个实例平均得到 10 个分区
这就叫公平的分配策略。如果出现了严重的分配倾斜,势必会出现这种情况:有的实例会“闲死”,而有的实例则会“忙死”

在这里插入图片描述
假设目前某个 Consumer Group 下有两个 Consumer,比如 A 和 B,当第三个成员 C 加入时,Kafka 会触发 Rebalance,并根据默认的分配策略重新为 A、B 和 C 分配分区
Rebalance 之后的分配依然是公平的,即每个 Consumer 实例都获得了 2 个分区的消费权

Rebalance 缺陷
  • 在 JVM 垃圾回收 STW 期间,所有应用线程都会停止工作,表现为整个应用程序僵在那边一动不动
    Rebalance 过程也和这个类似,在 Rebalance 过程中,所有 Consumer 实例都会停止消费,等待 Rebalance 完成

  • 目前 Rebalance 的设计是所有 Consumer 实例共同参与,全部重新分配所有分区
    其实更高效的做法是尽量减少分配方案的变动
    例如实例 A 之前负责消费分区 1、2、3,那么 Rebalance 之后,如果可能的话,最好还是让实例 A 继续消费分区 1、2、3,而不是被重新分配其他的分区
    这样的话,实例 A 连接这些分区所在 Broker 的 TCP 连接就可以继续用,不用重新创建连接其他 Broker 的 Socket 资源

  • Rebalance 实在是太慢
    eg: Group 内有几百个 Consumer 实例,成功 Rebalance 一次要几个小时!这完全是不能忍受的

小结

在这里插入图片描述

位移主题

内部主题(Internal Topic)__consumer_offsets (注意, 两个下划线) 在 Kafka 源码中有个更为正式的名字,叫位移主题,即 Offsets Topic

把位移交给kafka的主题

老版本 Consumer 的位移管理是依托于 Apache ZooKeeper 的,它会自动或手动地将位移数据提交到 ZooKeeper 中保存
当 Consumer 重启后,它能自动从 ZooKeeper 中读取位移数据,从而在上次消费截止的地方继续消费
这种设计使得 Kafka Broker 不需要保存位移数据,减少了 Broker 端需要持有的状态空间,因而有利于实现高伸缩性
但是,ZooKeeper 其实并不适用于这种高频的写操作

新版本 Consumer 的位移管理机制: 将 Consumer 的位移数据作为一条条普通的 Kafka 消息,提交到 __consumer_offsets 中
可以这么说,__consumer_offsets 的主要作用是保存 Kafka 消费者的位移信息
它要求这个提交过程不仅要实现高持久性,还要支持高频的写操作
显然,Kafka 的主题设计天然就满足这两个条件
因此,使用 Kafka 主题来保存位移这件事情,实际上就是一个水到渠成的想法了

位移主题

位移主题就是普通的 Kafka 主题
可以手动地创建它、修改它,甚至是删除它
只不过,它同时也是一个内部主题,大部分情况下,其实并不需要“搭理”它,也不用花心思去管理它,把它丢给 Kafka 就完事了

位移主题的消息格式

消息格式是 Kafka 自己定义的,用户不能修改
一旦写入的消息不满足 Kafka 规定的格式,那么 Kafka 内部无法成功解析,就会造成 Broker 的崩溃
Kafka Consumer 有 API 帮提交位移,也就是向位移主题写消息
千万不要自己写个 Producer 随意向该主题发送消息

位移主题消息头中应该保存 3 部分内容:
Group Id, 主题名, 分区号

位移主题消息体的三种消息格式:

  1. 用于保存位移值
    消息体还保存了位移提交的一些其他元数据,诸如时间戳用户自定义的数据等。保存这些元数据是为了帮助 Kafka 执行各种各样后续的操作,比如删除过期位移消息
  2. 用于保存 Consumer Group 信息的消息 – 用来注册 Consumer Group
  3. 用于删除 Group 过期位移甚至是删除 Group 的消息
    tombstone 消息,即墓碑消息,也称 delete mark
    消息体是 null,即空消息体
    一旦某个 Consumer Group 下的所有 Consumer 实例都停止了,而且它们的位移数据都已被删除时,Kafka 会向位移主题的对应分区写入 tombstone 消息,表明要彻底删除这个 Group 的信息
位移主题是怎么被创建

A :当Kafka集群中的第一个Consumer程序启动时,Kafka会自动创建位移主题。也可以手动创建(建议自动创建)
B :分区数依赖于Broker端的offsets.topic.num.partitions的取值,默认为50
C :副本数依赖于Broker端的offsets.topic.replication.factor的取值,默认为3

位移主题的使用 – 推荐手动提交

A :Kafka Consumer 提交位移时会写入该主题, 分为自动提交位移和手动提交位移

B :Consumer 端有个参数叫 enable.auto.commit,如果值是 true,则 Consumer 在后台默默地为你定期提交位移,提交间隔由一个专属的参数 auto.commit.interval.ms 来控制

C :推荐使用 consumer.commitSync 手动提交位移,自动提交位移会存在问题:只要 Consumer 一直启动着,它就会无限期地向位移主题写入消息

eg:
假设 Consumer 当前消费到了某个主题的最新一条消息,位移是 100,之后该主题没有任何新消息产生,故 Consumer 无消息可消费了,所以位移永远保持在 100
由于是自动提交位移,位移主题中会不停地写入位移 =100 的消息
显然 Kafka 只需要保留这类消息中的最新一条就可以了,之前的消息都是可以删除的
这就要求 Kafka 必须要有针对位移主题消息特点的消息删除策略,否则这种消息会越来越多,最终撑爆整个磁盘

位移主题的清理 – compact 策略

Kafka 使用 Compact 策略来删除位移主题中的过期消息,避免该主题无限期膨胀
对于同一个 Key 的两条消息 M1 和 M2,如果 M1 的发送时间早于 M2,那么 M1 就是过期消息
Compact 的过程就是扫描日志的所有消息,剔除那些过期的消息,然后把剩下的消息整理在一起

Compact 过程示意图
图中位移为 0、2 和 3 的消息的 Key 都是 K1。Compact 之后,分区只需要保存位移为 3 的消息,因为它是最新发送的
在这里插入图片描述
Kafka 提供了专门的 后台线程 Log Cleaner 定期地巡检待 Compact 的主题,看看是否存在满足条件的可删除数据
如果 Log Cleaner 线程挂掉, 可能会出现位移主题无限膨胀占用过多磁盘空间

小结

Kafka 天然实现了高持久性和高吞吐量, 所以自己实现了两个子服务:

  • 将很多元数据以消息的方式存入 Kafka 内部主题 ( Consumer 位移管理 )
  • Kafka 事务 ( 另外的一个内部主题 )

在这里插入图片描述

避免消费者组重平衡

重平衡, Rebalance
让一个 Consumer Group 下所有的 Consumer 实例就如何消费订阅主题的所有分区达成共识的过程
在 Rebalance 过程中,所有 Consumer 实例共同参与,在协调者组件的帮助下,完成订阅主题分区的分配
但是,在整个过程中,所有实例都不能消费任何消息,因此它对 Consumer 的 TPS 影响很大

协调者,Coordinator

专门为 Consumer Group 服务,负责为 Group 执行 Rebalance 以及提供位移管理组成员管理

Consumer 端应用程序在提交位移时,其实是向 Coordinator 所在的 Broker 提交位移
同样地,当 Consumer 应用启动时,也是向 Coordinator 所在的 Broker 发送各种请求,然后由 Coordinator 负责执行消费者组的注册、成员管理记录等元数据管理操作

所有 Broker 在启动时,都会创建和开启相应的 Coordinator 组件
也就是说,所有 Broker 都有各自的 Coordinator 组件!!!!!!!!!!!!!!!!
Consumer Group 通过 内部位移主题 __consumer_offsets 确定为它服务的 Coordinator 在哪台 Broker 上

  1. 确定由位移主题的哪个分区来保存该 Group 数据:
    partitionId=Math.abs(groupId.hashCode() % offsetsTopicPartitionCount)
    ① 计算该 Group 的 group.id 参数的哈希值
    eg: group.id 设置成了“test-group”,那么它的 hashCode 值就应该是 627841412
    ② 计算 __consumer_offsets 的分区数 offsetsTopicPartitionCount,通常是 50 个分区
    ③ 之后将刚才那个哈希值对分区数进行取模加求绝对值计算,即 abs(627841412 % 50) = 12
    ④ 此时,得知位移主题的分区 12 负责保存这个 Group 的数据

  2. 找出该分区 Leader 副本所在的 Broker,该 Broker 即为对应的 Coordinator

在实际使用过程中,Consumer 应用程序,特别是 Java Consumer API,能够自动发现并连接正确的 Coordinator

Rebalance 弊端

  • Rebalance 影响 Consumer 端 TPS。在 Rebalance 期间,Consumer 会停下手头的事情,什么也干不了
  • Rebalance 很慢。如果 Group 下成员很多,Rebalance 一次要几个小时
  • Rebalance 效率不高。当前 Kafka 的设计机制决定了每次 Rebalance 时,Group 下的所有成员都要参与进来,而且通常不会考虑局部性原理,但局部性原理对提升系统性能是特别重要的
    eg: 一个 Group 下有 10 个成员,每个成员平均消费 5 个分区
    假设现在有一个成员退出了,此时就需要开启新一轮的 Rebalance,把这个成员之前负责的 5 个分区“转移”给其他成员
    显然,比较好的做法是维持当前 9 个成员消费分区的方案不变,然后将 5 个分区随机分配给这 9 个成员
    这样能最大限度地减少 Rebalance 对剩余 Consumer 成员的冲击

在默认情况下,每次 Rebalance 时,之前的分配方案都不会被保留

社区于 0.11.0.0 版本推出了StickyAssignor 有粘性的分区分配策略
所谓的有粘性,是指每次 Rebalance 时,该策略会尽可能地保留之前的分配方案,尽量实现分区分配的最小变动
不过有些遗憾的是,这个策略目前还有一些 bug,而且需要升级到 0.11.0.0 才能使用,因此在实际生产环境中用得还不是很多

Rebalance 过程中的各种问题 -- 无解

Rebalance 发生的时机

  • 组成员数量发生变化
  • 订阅主题数量发生变化 (运维的主动操作)
  • 订阅主题的分区数发生变化 (运维的主动操作)

Consumer 实例数量发生变化

  • Consumer 实例增加 (属于计划内)
    当启动一个配置有相同 group.id 值的 Consumer 程序时,实际上就向这个 Group 添加了一个新的 Consumer 实例
    此时,Coordinator 会接纳这个新实例,将其加入到组中,并重新分配分区
  • Consumer 实例减少 (需要规避)
    Consumer 实例会被 Coordinator 错误地认为“已停止”从而被“踢出”Group

当 Consumer Group 完成 Rebalance 之后,每个 Consumer 实例都会定期地向 Coordinator 发送心跳请求,表明它还存活着
如果某个 Consumer 实例不能及时地发送这些心跳请求,Coordinator 就会认为该 Consumer 已经“死”了,从而将其从 Group 中移除,然后开启新一轮 Rebalance
Consumer 端参数 session.timeout.ms,默认值是 10 秒
如果 Coordinator 在 10 秒之内没有收到 Group 下某 Consumer 实例的心跳,它就会认为这个 Consumer 实例已经挂了

Consumer 端发送心跳请求频率的参数 heartbeat.interval.ms
这个值设置得越小,Consumer 实例发送心跳请求的频率就越高
频繁地发送心跳请求会额外消耗带宽资源,但好处是能够更加快速地知晓当前是否开启 Rebalance
因为,目前 Coordinator 通知各个 Consumer 实例开启 Rebalance 的方法,就是将 REBALANCE_NEEDED 标志封装进心跳请求的响应体中

Consumer 端参数 max.poll.interval.ms,用于控制 Consumer 实际消费能力对 Rebalance 的影响
它限定了 Consumer 端应用程序两次调用 poll 方法的最大时间间隔, 默认值是 5 分钟,表示 Consumer 程序如果在 5 分钟之内无法消费完 poll 方法返回的消息,那么 Consumer 会主动发起“离开组”的请求,Coordinator 也会开启新一轮 Rebalance

哪些 Rebalance 是“不必要的”

第一类非必要 Rebalance 是因为未能及时发送心跳,导致 Consumer 被“踢出”Group 而引发的。
推荐设置:

  • 设置 session.timeout.ms = 6s
  • 设置 heartbeat.interval.ms = 2s
  • 要保证 Consumer 实例在被判定为“dead”之前,能够发送至少 3 轮的心跳请求,即 session.timeout.ms >= 3 * heartbeat.interval.ms

第二类非必要 Rebalance 是 Consumer 消费时间过长导致的
eg, Consumer 消费数据时需要将消息处理之后写入到 MongoDB,MongoDB 微小的不稳定都会导致 Consumer 程序消费时长的增加
如果要避免非预期的 Rebalance,最好将 max.poll.interval.ms 参数设置得大一点,比下游最大处理时间稍长一点
比如写 MongoDB 的最长时间是 7 分钟,那么你可以将 max.poll.interval.ms 参数设置为 8 分钟左右

其余 rebalance 原因, 查看
Consumer 端的 GC 表现,比如是否出现了频繁的 Full GC 导致的长时间停顿,从而引发了 Rebalance

小结

避免因为各种参数或逻辑不合理而导致的组成员意外离组或退出的情形,与之相关的主要参数

  • session.timeout.ms
  • heartbeat.interval.ms
  • max.poll.interval.ms
  • GC参数

在这里插入图片描述

Kafka 位移提交相关

Consumer 的消费位移 ----- 不同于 ----- 消息在分区中的位移
记录了 Consumer 要消费的下一条消息的位移
下一条消息的位移,而不是目前最新消费消息的位移

eg:
假设一个分区中有 10 条消息,位移分别是 0 到 9
某个 Consumer 应用已消费了 5 条消息,这就说明该 Consumer 消费了位移为 0 到 4 的 5 条消息,此时 Consumer 的位移是 5,指向了下一条消息的位移

Consumer 需要向 Kafka 汇报自己的位移数据,这个汇报过程被称为提交位移(Committing Offsets)
因为 Consumer 能够同时消费多个分区的数据,所以位移的提交实际上是在分区粒度上进行的
Consumer 需要为分配给它的每个分区提交各自的位移数据

提交位移主要是为了表征 Consumer 的消费进度,这样当 Consumer 发生故障重启之后,就能够从 Kafka 中读取之前提交的位移值,然后从相应的位移处继续消费,从而避免整个消费过程重来一遍

位移提交的语义保障是由开发者来负责的,Kafka 只会“无脑”地接受提交的位移

自动提交 & 手动提交

从用户的角度来说,位移提交分为自动提交手动提交
从 Consumer 端的角度来说,位移提交分为同步提交异步提交

  • 自动提交
    Consumer 端参数 enable.auto.commit, 默认为true
    auto.commit.interval.ms, 默认5秒, Kafka 每 5 秒会自动提交一次位移
	设置自动提交位移
	Properties props = new Properties();
	props.put("bootstrap.servers", "localhost:9092");
	props.put("group.id", "test");
	props.put("enable.auto.commit", "true"); -- 开启自动提交
	props.put("auto.commit.interval.ms", "2000"); -- 设置自动提交位移时间间隔
	props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
	props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
	KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
	consumer.subscribe(Arrays.asList("foo", "bar"));
	while (true) {
	    ConsumerRecords<String, String> records = consumer.poll(100);
	    for (ConsumerRecord<String, String> record : records)
	        System.out.printf("offset = %d, key = %s, value = %s%n", 
	        	record.offset(), record.key(), record.value());
	}
  • 手动提交
    设置 enable.auto.commit 为 false
    调用相应的 API 手动提交位移
    KafkaConsumer#commitSync(). 提交 KafkaConsumer#poll() 返回的最新位移
    同步操作,即该方法会一直等待,直到位移被成功提交才会返回。如果提交过程中出现异常,该方法会将异常信息抛出
while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
	process(records); // 处理消息
	try {
		consumer.commitSync();
	} catch (CommitFailedException e) {
		handle(e); // 处理提交失败异常
	}
}

调用 consumer.commitSync() 方法是处理完了 poll() 方法返回的所有消息之后
如果过早提交了位移,就可能会出现消费数据丢失的情况
自动提交位移的缺陷 – rebalance 带来的重复消费

一旦设置了 enable.auto.commit 为 true,Kafka 会保证在开始调用 poll 方法时,提交上次 poll 返回的所有消息
从顺序上来说,poll 方法的逻辑是先提交上一批消息的位移,再处理下一批消息,因此它能保证不出现消费丢失的情况
但自动提交位移的一个问题在于,它可能会出现重复消费

在默认情况下,Consumer 每 5 秒自动提交一次位移
现在,假设提交位移之后的 3 秒发生了 Rebalance 操作
在 Rebalance 之后,所有 Consumer 从上一次提交的位移处继续消费,但该位移已经是 3 秒前的位移数据了,故在 Rebalance 发生前 3 秒消费的所有数据都要重新再消费一次
虽然你能够通过减少 auto.commit.interval.ms 的值来提高提交频率,但这么做只能缩小重复消费的时间窗口,不可能完全消除它
这是自动提交机制的一个缺陷

手动提交位移的缺陷 – 同步提交时会阻塞consumer

手动提交位移,它的好处就在于更加灵活,能够把控位移提交的时机和频率
但是,它也有一个缺陷,就是在调用 commitSync() (同步)时,Consumer 程序会处于阻塞状态,直到远端的 Broker 返回提交结果,这个状态才会结束
在任何系统中,因为程序而非资源限制而导致的阻塞都可能是系统的瓶颈,会影响整个应用程序的 TPS
当然,可以选择拉长提交间隔,但这样做的后果是 Consumer 的提交频率下降,在下次 Consumer 重启回来后,会有更多的消息被重新消费

手动提交位移, 异步API: KafkaConsumer#commitAsync()

while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
	process(records); // 处理消息
	consumer.commitAsync((offsets, exception) -> {
	if (exception != null)
		handle(exception); -- 回调函数, 可用于记录日志或处理异常等
	});
}

commitAsync 不能替代 commitSync, 因为出现问题时它不会自动重试
异步操作,倘若提交失败后自动重试,那么它重试时提交的位移值可能早已经“过期”或不是最新值了
异步提交的重试没有意义

手动提交, 将 commitSync同步 和 commitAsync异步 组合使用

才能达到最理想的效果,原因:

  • 利用 commitSync 的自动重试来规避那些瞬时错误,比如网络的瞬时抖动,Broker 端 GC 等。因为这些问题都是短暂的,自动重试通常都会成功,因此,无需自己重试,可以让 Kafka Consumer 帮忙做这件事
  • 不能使程序总处于阻塞状态,影响 TPS
	代码示例
	既实现了异步无阻塞式的位移管理,也确保了 Consumer 位移的正确性
    try {
      while (true) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
        process(records); // 处理消息
        commitAysnc(); // 对于常规性、阶段性的手动提交,调用 commitAsync() 避免程序阻塞, 使用异步提交规避阻塞
      }
    } catch (Exception e) {
      handle(e); // 处理异常
    } finally {
      try {
      	// 在 Consumer 要关闭前,调用 commitSync() 方法执行同步阻塞式的位移提交
      	// 以确保 Consumer 关闭前能够保存正确的位移数据
        consumer.commitSync(); // 最后一次提交使用同步阻塞式提交
      } finally {
        consumer.close();
      }
    }
手动提交, 更细粒度的提交位移

避免大批量的消息重新消费

commitSync(Map) 和 commitAsync(Map)

  • Map的键: TopicPartition,即消费的分区
  • 值: OffsetAndMetadata 对象,保存的主要是位移数据
      Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
      int count = 0;
      while (true) {
          ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
          for (ConsumerRecord<String, String> record: records) {
              process(record);  // 处理消息
              offsets.put(
              		new TopicPartition(record.topic(), record.partition()), 
              		// 构造 OffsetAndMetadata 对象时,使用当前消息位移加 1
              		// 因为要提交下一条消息
              		new OffsetAndMetadata(record.offset() + 1));
              if(count % 100 == 0) {
                  consumer.commitAsync(offsets, null); // 回调处理逻辑是null
                  count++;
              }
          }
      }

	调用了带 Map 对象参数的 commitAsync 进行细粒度的位移提交
	每处理 100 条消息就提交一次位移, 不用再受 poll 方法返回的消息总数的限制了

小结

在这里插入图片描述

思考

手动提交也不能避免消息重复消费
假设 Consumer 在处理完消息和提交位移前出现故障,下次重启后依然会出现消息重复消费的情况
如何实现业务场景中的去重逻辑呢?

CommitFailedException 异常处理

CommitFailedException
Consumer 客户端在提交位移时出现了错误或异常,而且还是那种不可恢复的严重异常

CommitFailedException 异常通常发生在手动提交位移时,即用户显式调用 KafkaConsumer.commitSync() 方法时

异常注释

Commit cannot be completed 
	since the group has already rebalanced 
	and assigned the partitions to another member. 
本次提交位移失败了,原因是消费者组已经开启了 Rebalance 过程,并且将要提交位移的分区分配给了另一个消费者实例

This means that 
	the time between subsequent calls to poll() 
	was longer than 
	the configured max.poll.interval.ms, 
	which typically implies that the poll loop is spending too much time message processing. 
出现这个情况的原因是,你的消费者实例连续两次调用 poll 方法的时间间隔超过了期望的 max.poll.interval.ms 参数值
这通常表明,你的消费者实例花费了太长的时间进行消息处理,耽误了调用 poll 方法

You can address this 
	either by increasing max.poll.interval.ms 
	or by reducing the maximum size of batches returned in poll() with max.poll.records.

解决方案:
1. 增加期望的时间间隔 max.poll.interval.ms 参数值
2. 减少 poll 方法一次性返回的消息数量,即减少 max.poll.records 参数值

异常发生场景一

当消息处理的总时间超过预设的 max.poll.interval.ms 参数值时

	异常复现
    Properties props = new Properties();
    props.put("max.poll.interval.ms", 5000); -- 5KafkaConsumer consumer = new KafkaConsumer(props);
    consumer.subscribe(Arrays.asList("test-topic"));

    while (true) {
      ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
      // 使用Thread.sleep模拟真实的消息处理逻辑
      try {
        Thread.sleep(6000L); -- 6} catch (InterruptedException e) {
        e.printStackTrace();
      }
      consumer.commitSync();
    }

场景一解决方案

  1. 缩短单条消息处理的时间
    比如,之前下游系统消费一条消息的时间是 100 毫秒,优化之后成功地下降到 50 毫秒,那么此时 Consumer 端的 TPS 就提升了一倍
  2. 增加 Consumer 端允许下游系统消费一批消息的最大时长
    Kafka 0.10.1.0 版本之前: 使用 session.timeout.ms 参数 (包含其他含义, 之后版本独立出max.poll.interval.ms参数)
    之后: 取决于 Consumer 端参数 max.poll.interval.ms, 默认5min, 提高该参数值
  3. 减少下游系统一次性消费的消息总数
    Consumer 端参数 max.poll.records, 默认500条
    表明调用一次 KafkaConsumer.poll 方法,最多返回 500 条消息
    规定了单次 poll 方法能够返回的消息总数的上限
    如果前两种方法都不适用的话,降低此参数值是避免 CommitFailedException 异常最简单的手段
  4. 下游系统使用多线程来加速消费
    让下游系统手动创建多个消费线程处理 poll 方法返回的一批消息

异常发生场景二

Kafka Java Consumer 端提供了一个名为 Standalone Consumer 的独立消费者
它没有消费者组的概念,每个消费者实例都是独立工作的,彼此之间毫无联系
独立消费者的位移提交机制和消费者组是一样的,消费者组和独立消费者在使用之前都要指定 group.id

如果应用中同时出现了设置相同 group.id 值的消费者组程序独立消费者程序
当独立消费者程序手动提交位移时,Kafka 就会立即抛出 CommitFailedException 异常
因为 Kafka 无法识别这个具有相同 group.id 的消费者实例,于是就向它返回一个错误,表明它不是消费者组内合法的成员

小结

在这里插入图片描述

多线程开发消费者实例

Kafka Java Consumer 设计原理

从 Kafka 0.10.1.0 版本开始,KafkaConsumer 由单线程变为了双线程的设计,即用户主线程和心跳线程

  • 用户主线程,启动 Consumer 应用程序 main 方法的那个线程
  • 心跳线程(Heartbeat Thread)只负责定期给对应的 Broker 机器发送心跳请求,以标识消费者应用的存活性(liveness)

期望心跳线程能将心跳频率主线程调用 KafkaConsumer.poll 方法的频率分开,从而解耦真实的消息处理逻辑与消费者组成员存活性管理

老版本的Consumer

老版本 Consumer 是多线程的架构
每个 Consumer 实例在内部为所有订阅的主题分区创建对应的消息获取线程,也称 Fetcher 线程
老版本 Consumer 同时也是阻塞式的(blocking),Consumer 实例启动后,内部会创建很多阻塞式的消息获取迭代器

新版本使用单线程的原因

在很多场景下,Consumer 端是有非阻塞需求的,比如在流处理应用中执行过滤(filter)、连接(join)、分组(group by)等操作时就不能是阻塞式的

新版本 Consumer 使用单线程 + 轮询的机制, 可以较好的实现非阻塞式的消息获取

简化 Consumer 端的设计
处理消息的逻辑是否采用多线程交给开发者
更容易移植到其他语言上,打造上下游生态

多线程设计方案

KafkaConsumer 类不是线程安全的 (thread-safe)
所有的网络 I/O 处理都是发生在用户主线程中,在使用过程中必须要确保线程安全
简单来说,不能在多个线程中共享同一个 KafkaConsumer 实例,否则程序会抛出 ConcurrentModificationException 异常

KafkaConsumer 中 wakeup() 方法例外,在其他线程中可以安全地调用 KafkaConsumer.wakeup() 来唤醒 Consumer

方案一

消费者程序启动多个线程,每个线程维护专属的 KafkaConsumer 实例,负责完整的消息获取、消息处理流程

在这里插入图片描述

优势:

  • 实现简单
  • 线程之间相互独立, 节省开销
  • 每个线程使用专属的 KafkaConsumer 实例, 保证Kafka主题中的每个分区都能保证只被一个线程处理, 可以保证消费顺序

不足:

  • 占用更多的系统资源,比如内存、TCP 连接等
  • 使用的线程数受限于 Consumer 订阅主题的总分区数
    在一个消费者组中,每个订阅分区都只能被组内的一个消费者实例所消费
    假设一个消费者组订阅了 100 个分区,那么方案 1 最多只能扩展到 100 个线程,多余的线程无法分配到任何分区
    但是这种扩展性方面的局限可以被多机架构所缓解
    除了在一台机器上启用 100 个线程消费数据,也可以选择在 100 台机器上分别创建 1 个线程,效果是一样的
    因此,如果你的机器资源很丰富,这个劣势就不足为虑
  • 每个线程完整地执行消息获取和消息处理逻辑
    一旦消息处理逻辑很重,造成消息处理速度慢,就很容易出现不必要的 Rebalance,从而引发整个消费者组的消费停滞
  public class KafkaConsumerRunner implements Runnable {

    private final AtomicBoolean closed = new AtomicBoolean(false);
    // 每个 KafkaConsumerRunner 类都会创建一个专属的 KafkaConsumer 实例
    private final KafkaConsumer consumer;

    @Override
    public void run() {
      try {
        consumer.subscribe(Arrays.asList("topic"));
        while (!closed.get()) {
          ConsumerRecords records = consumer.poll(Duration.ofMillis(10000));
          //  执行消息处理逻辑
        }
      } catch (WakeupException e) {
        // Ignore exception if closing
        if (!closed.get()) {
          throw e;
        }
      } finally {
        consumer.close();
      }
    }

    // Shutdown hook which can be called from a separate thread
    public void shutdown() {
      closed.set(true);
      consumer.wakeup();
    }
  }
方案二

消费者程序使用单或多线程获取消息,同时创建多个消费线程执行消息处理逻辑

  • 获取消息的线程可以是一个,也可以是多个,每个线程维护专属的 KafkaConsumer 实例
  • 处理消息则交由特定的线程池来做,从而实现消息获取与消息处理的真正解耦

在这里插入图片描述

优势:
高伸缩性, 将任务切分成了消息获取和消息处理两个部分,分别由不同的线程处理它们
可以独立地调节消息获取的线程数,以及消息处理的线程数,而不必考虑两者之间是否相互影响

  • 如果你的消费获取速度慢,那么增加消费获取的线程数即可
  • 如果是消息的处理速度慢,那么增加 Worker 线程池线程数即可

不足:

  • 实现难度大
  • 无法保证分区内的消费顺序
  • 引入了多组线程,使得整个消息消费链路被拉长,最终导致正确位移提交会变得异常困难,结果就是可能会出现消息的重复消费
private final KafkaConsumer<String, String> consumer;
private ExecutorService executors;
...

private int workerNum = ...;
executors = new ThreadPoolExecutor(
  workerNum, workerNum, 0L, TimeUnit.MILLISECONDS,
  new ArrayBlockingQueue<>(1000), 
  new ThreadPoolExecutor.CallerRunsPolicy());

...
while (true)  {
  // 当 Consumer 的 poll 方法返回消息后,由专门的线程池来负责处理具体的消息
  // 调用 poll 方法的主线程不负责消息处理逻辑,这样就实现了方案 2 的多线程架构
  ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
  for (final ConsumerRecord record : records) {
    executors.submit(new Worker(record));
  }
}
..
方案对比

在这里插入图片描述

小结

在这里插入图片描述

Java 消费者是如何管理TCP连接的?

在 Kafka 的世界中,无论是 ServerSocket,还是 SocketChannel,它们实现的都是 TCP 协议

创建TCP连接的时机

Kafka Productor:
生产者入口类 KafkaProducer 在构建实例的时候,会在后台默默地启动一个 Sender 线程
这个 Sender 线程负责 Socket 连接的创建
隐患: 在 Java 构造函数中启动线程,会造成 this 指针的逃逸

Kafka Consumer:
构建 KafkaConsumer 实例时是不会创建任何 TCP 连接的
当执行完 new KafkaConsumer(properties)语句后,没有 Socket 连接被创建出来

TCP 连接是在调用 KafkaConsumer.poll 方法时被创建的

再细粒度地说,在 poll 方法内部有 3 个时机可以创建 TCP 连接

  1. 发起 FindCoordinator 请求时
    消费者端组件协调者(Coordinator): 驻留在 Broker 端的内存中,负责消费者组的组成员管理和各个消费者的位移提交管理
    当消费者程序首次启动调用 poll 方法时,它需要向 Kafka 集群发送一个名为 FindCoordinator 的请求,希望 Kafka 集群告诉它哪个 Broker 是管理它的协调者
    理论上消费者可以向集群中的任意服务器发送 FindCoordinator 请求
    社区优化: 消费者程序会向集群中当前负载最小的那台 Broker发送请求
    负载评估: 看消费者连接的所有 Broker 中,谁的待发送请求最少
    消费者端的单向评估,并非是站在全局角度,因此有的时候也不一定是最优解
    在这一步,消费者会创建一个 Socket 连接
  2. 连接协调者时
    Broker 处理完上一步发送的 FindCoordinator 请求之后,会返还对应的响应结果(Response),显式地告诉消费者哪个 Broker 是真正的协调者
    因此在这一步,消费者知晓了真正的协调者后,会创建连向该 Broker 的 Socket 连接
    只有成功连入协调者,协调者才能开启正常的组协调操作,比如加入组、等待组分配方案、心跳请求处理、位移获取、位移提交等
  3. 消费数据时
    消费者会为每个要消费的分区创建与该分区领导者副本所在 Broker 连接的 TCP
    举个例子,假设消费者要消费 5 个分区的数据,这 5 个分区各自的领导者副本分布在 4 台 Broker 上,那么该消费者在消费时会创建与这 4 台 Broker 的 Socket 连接

创建TCP连接的数量

消费者创建 TCP 连接的数量

Kafka 日志
[2021-08-26 10:00:54,142] DEBUG [Consumer clientId=consumer-1, groupId=test] 
	Initiating connection to 
	node localhost:9092 (id: -1 rack: null)                         ------ Broker 节点 -1
	using address localhost/127.0.0.1 
	(org.apache.kafka.clients.NetworkClient:944)
	消费者程序创建的第一个 TCP 连接,这个 Socket 用于发送 FindCoordinator 请求
	由于这是消费者程序创建的第一个连接,此时消费者对于要连接的 Kafka 集群一无所知,
	因此它连接的 Broker 节点的 ID 是 -1,表示消费者根本不知道要连接的 Kafka Broker 的任何信息

[2021-08-26 10:00:54,188] DEBUG [Consumer clientId=consumer-1, groupId=test] 
	Sending metadata request MetadataRequestData
	(topics=[MetadataRequestTopic(name=‘t4’)], 
	allowAutoTopicCreation=true, 
	includeClusterAuthorizedOperations=false, 
	includeTopicAuthorizedOperations=false) to 
	node localhost:9092 (id: -1 rack: null)                         ------ Broker 节点 -1
	(org.apache.kafka.clients.NetworkClient:1097)
	消费者复用了刚才创建的那个 Socket 连接,向 Kafka 集群发送元数据请求以获取整个集群的信息

[2021-08-26 10:00:54,188] TRACE [Consumer clientId=consumer-1, groupId=test] 
	Sending FIND_COORDINATOR {key=test,key_type=0} with correlation id 0 to 
	node -1                                                         ------ Broker 节点 -1
	(org.apache.kafka.clients.NetworkClient:496)
	消费者程序开始发送 FindCoordinator 请求给第一步中连接的 Broker,
		即 localhost:9092, 也就是 nodeId 等于 -1 的那个

[2021-08-26 10:00:54,203] TRACE [Consumer clientId=consumer-1, groupId=test] 
	Completed receive from node -1 for FIND_COORDINATOR with correlation id 0, 
	received {throttle_time_ms=0,error_code=0,error_message=null,
	node_id=2,host=localhost,port=9094}                             ------ Broker 节点 -2
	(org.apache.kafka.clients.NetworkClient:837)
	在十几毫秒之后,消费者程序成功地获悉协调者所在的 Broker 信息,也就是“node_id = 2[2021-08-26 10:00:54,204] DEBUG [Consumer clientId=consumer-1, groupId=test] 
	Initiating connection to 
	node localhost:9094 (id: 2147483645 rack: null)                 ------ Broker 节点 2147483645
	using address localhost/127.0.0.1 
	(org.apache.kafka.clients.NetworkClient:944)
	消费者就已经知道协调者 Broker 的连接信息了,因此发起了第二个 Socket 连接,
	创建了连向 localhost:9094 的 TCP
	只有连接了协调者,消费者进程才能正常地开启消费者组的各种功能以及后续的消息消费

[2021-08-26 10:00:54,237] DEBUG [Consumer clientId=consumer-1, groupId=test] 
	Initiating connection to 
	node localhost:9094 (id: 2 rack: null)                          ------ Broker 节点 2
	using address localhost/127.0.0.1 
	(org.apache.kafka.clients.NetworkClient:944)

[2021-08-26 10:00:54,237] DEBUG [Consumer clientId=consumer-1, groupId=test] 
	Initiating connection to 
	node localhost:9092 (id: 0 rack: null)                          ------ Broker 节点 0
	using address localhost/127.0.0.1 
	(org.apache.kafka.clients.NetworkClient:944)

[2021-08-26 10:00:54,238] DEBUG [Consumer clientId=consumer-1, groupId=test] 
	Initiating connection to 
	node localhost:9093 (id: 1 rack: null)                          ------ Broker 节点 1
	using address localhost/127.0.0.1 
	(org.apache.kafka.clients.NetworkClient:944)

在日志的最后三行中,消费者又分别创建了新的 TCP 连接,主要用于实际的消息获取
要消费的分区的领导者副本在哪台 Broker 上,消费者就要创建连向哪台 Broker 的 TCP
这个例子中,localhost:9092,localhost:9093 和 localhost:90943Broker 上都有要消费的分区,
	因此消费者创建了 3 个 TCP 连接

Broker 节点的 ID 不断变化的含义:

  • -1 消费者程序(包括生产者)首次启动时,对 Kafka 集群一无所知,因此用 -1 来表示尚未获取到 Broker 数据
  • 2147483645 由 Integer.MAX_VALUE 减去协调者所在 Broker 的真实 ID 计算得来的
    协调者 ID 是 2,因此这个 Socket 连接的节点 ID 就是 Integer.MAX_VALUE 减去 2,即 2147483647 减去 2,也就是 2147483645
    目的: 让组协调请求真正的数据获取请求使用不同的 Socket 连接
  • 0、1、2,表征了真实的 Broker ID,也就是在 server.properties 中配置的 broker.id 值

通常来说,消费者程序会创建 3 类 TCP 连接:

  1. 确定协调者和获取集群元数据
  2. 连接协调者,令其执行组成员管理操作
  3. 执行实际的消息获取

关闭 TCP 连接的时机

消费者关闭 Socket 分为

  • 主动关闭
    显式地调用消费者 API 的方法去关闭消费者,具体方式就是手动调用 KafkaConsumer.close() 方法
    或者是执行 Kill 命令,不论是 Kill -2 还是 Kill -9
  • Kafka 自动关闭
    由消费者端参数 connection.max.idle.ms 控制的,默认值 9 分钟
    即如果某个 Socket 连接上连续 9 分钟都没有任何请求“过境”的话,那么消费者会强行“杀掉”这个 Socket 连接

和生产者不同,如果在编写消费者程序时,使用了循环的方式来调用 poll 方法消费消息,那么上面提到的所有请求都会被定期发送到 Broker
因此这些 Socket 连接上总是能保证有请求在发送,从而也就实现了“长连接”的效果

当第三类 TCP 连接成功创建后,消费者程序就会废弃第一类 TCP 连接,之后在定期请求元数据时,它会改为使用第三类 TCP 连接
也就是说,最终第一类 TCP 连接会在后台被默默地关闭掉。对一个运行了一段时间的消费者程序来说,只会有后面两类 TCP 连接存在

可能的问题

第一类 TCP 连接仅仅是为了首次获取元数据而创建的,后面就会被废弃掉
最根本的原因是,消费者在启动时还不知道 Kafka 集群的信息,只能使用一个“假”的 ID 去注册,即使消费者获取了真实的 Broker ID,它依旧无法区分这个“假”ID 对应的是哪台 Broker,因此也就无法重用这个 Socket 连接,只能再重新创建一个新的连接

目前 Kafka 仅仅使用 ID 这一个维度的数据来表征 Socket 连接信息。这点信息明显不足以确定连接的是哪台 Broker
如果考虑使用 < 主机名、端口、ID> 三元组的方式来定位 Socket 资源,这样或许能够让消费者程序少创建一些 TCP 连接

Kafka 有定时关闭机制, 但是如果将 connection.max.idle.ms 设置成 -1,即禁用定时关闭, TCP 连接将不会被定期清除,只会成为永久的“僵尸”连接

小结

在这里插入图片描述

实现消费者组, 消费进度监控

滞后程度

对于 Kafka 消费者来说,最重要的事情就是监控它们的消费进度了,或者说是监控它们消费的滞后程度
滞后程度 (消费者 Lag) (Consumer Lag) 消费者当前落后于生产者的程度
eg:
Kafka 生产者向某主题成功生产了 100 万条消息,消费者当前消费了 80 万条消息,那么消费者滞后了 20 万条消息,即 Lag 等于 20 万

通常来说,Lag 的单位是消息数,而且一般是在主题这个级别上讨论 Lag 的
但实际上,Kafka 监控 Lag 的层级是在分区上
如果要计算主题级别的,需要手动汇总所有主题分区的 Lag,将它们累加起来,合并成最终的 Lag 值

一个正常工作的消费者,它的 Lag 值应该很小,甚至是接近于 0 的,这表示该消费者能够及时地消费生产者生产出来的消息,滞后程度很小。反之,如果一个消费者 Lag 值很大,通常就表明它无法跟上生产者的速度,最终 Lag 会越来越大,从而拖慢下游消息的处理速度

更可怕的是,由于消费者的速度无法匹及生产者的速度,极有可能导致它消费的数据已经不在操作系统的页缓存中了。这样的话,消费者就不得不从磁盘上读取它们,这就进一步拉大了与生产者的差距,进而出现马太效应,即那些 Lag 原本就很大的消费者会越来越慢,Lag 也会越来越大

在实际业务场景中必须时刻关注消费者的消费进度

监控消费进度

方法一: Kafka 自带的命令行工具 kafka-consumer-groups 脚本

使用 Kafka 自带的命令行工具 bin/kafka-consumer-groups.sh(bat)
kafka-consumer-groups 脚本是 Kafka 提供的最直接的监控消费者消费进度的工具

  • 操作和管理消费者组
  • 监控独立消费者(Standalone Consumer)的 Lag
    独立消费者就是没有使用消费者组机制的消费者程序
    和消费者组相同的是,它们也要配置 group.id 参数值
    但和消费者组调用 KafkaConsumer.subscribe()不同的是,独立消费者调用 KafkaConsumer.assign()方法直接消费指定分区

该脚本位于 Kafka 安装目录的 bin 子目录下,可以通过下面的命令来查看某个给定消费者的 Lag 值

$ bin/kafka-consumer-groups.sh 
	--bootstrap-server <Kafka broker连接信息> --describe --group <group名称>

Kafka 连接信息就是 < 主机名:端口 > 对,而 group 名称就是消费者程序中设置的 group.id 值

实例:

参数指定:
Kafka 集群的连接信息:localhost:9092
要查询的消费者组名:testgroup
在这里插入图片描述

按照消费者组订阅主题的分区进行展示,每个分区一行数据:

  • 该消费者组当前最新消费消息的位移值(即 CURRENT-OFFSET 值)
  • 当前最新生产的消息的位移值(即 LOG-END-OFFSET 列值)
  • LAG 值(前两者的差值)
  • 消费者实例 ID
  • 消费者连接 Broker 的主机名
  • 消费者的 CLIENT-ID 信息

图中每个分区的 LAG 值大约都是 60 多万,表明消费者组远远落后于生产者的进度
理想情况下,希望该列所有值都是 0,表明消费者完全没有任何滞后

如果出现下图情况:

在这里插入图片描述

原因:
运行 kafka-consumer-groups 脚本时没有启动消费者程序
图中红框:
当前消费者组没有任何 active 成员,即没有启动任何消费者实例

如果Kafka 版本比较老, kafka-consumer-groups 脚本还不支持查询非 active 消费者组, 不会返回任何结果

方法二: Kafka Java Consumer API 编程
  // 只适用于 Kafka 2.0.0 及以上的版本
  public static Map<TopicPartition, Long> lagOf(String groupID, String bootstrapServers)
      throws TimeoutException {
    Properties props = new Properties();
    props.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
    try (AdminClient client = AdminClient.create(props)) {
      // 1. 调用 AdminClient.listConsumerGroupOffsets 方法获取给定消费者组的最新消费消息的位移
      ListConsumerGroupOffsetsResult result = client.listConsumerGroupOffsets(groupID);
      try {
        Map<TopicPartition, OffsetAndMetadata> consumedOffsets =
            result.partitionsToOffsetAndMetadata().get(10, TimeUnit.SECONDS);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); // 禁止自动提交位移
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupID);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(
            ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        try (final KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
          // 2. 获取订阅分区的最新消息位移
          Map<TopicPartition, Long> endOffsets = consumer.endOffsets(consumedOffsets.keySet());
          // 3. 执行相应的减法操作,获取 Lag 值并封装进一个 Map 对象
          return endOffsets.entrySet().stream()
              .collect(
                  Collectors.toMap(
                      entry -> entry.getKey(),
                      entry -> entry.getValue() - consumedOffsets.get(entry.getKey()).offset()));
        }
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        // 处理中断异常
        // ...
        return Collections.emptyMap();
      } catch (ExecutionException e) {
        // 处理ExecutionException
        // ...
        return Collections.emptyMap();
      } catch (TimeoutException e) {
        throw new TimeoutException("Timed out when getting lag for consumer group " + groupID);
      }
    }
  }
方法三: Kafka 自带的 JMX 监控指标

Kafka 消费者提供了一个JMX 指标
kafka.consumer:type=consumer-fetch-manager-metrics,client-id=“{client-id}”, 属性:

  • records-lag-max, 此消费者在测试窗口时间内曾经达到的最大的 Lag 值
  • records-lead-min, 最小的 Lead 值

Lead 值: 消费者最新消费消息的位移与分区当前第一条消息位移的差值
Lag 和 Lead 是一体的两个方面:Lag 越大的话,Lead 就越小

一旦监测到 Lead 越来越小,甚至是快接近于 0 了,这可能预示着消费者端要丢消息

Kafka 的消息是有留存时间设置的,默认是 1 周,也就是说 Kafka 默认删除 1 周前的数据
倘若消费者程序足够慢,慢到它要消费的数据快被 Kafka 删除了,这时就必须立即处理,否则一定会出现消息被删除,从而导致消费者程序重新调整位移值的情形
这可能产生两个后果:

  • 消费者从头消费一遍数据,
  • 消费者从最新的消息位移处开始消费,之前没来得及消费的消息全部被跳过了,从而造成丢消息的假象

这两种情形都是不可忍受的,因此必须有一个 JMX 指标,清晰地表征这种情形,这就是引入 Lead 指标的原因
所以,Lag 值从 100 万增加到 200 万这件事情,远不如 Lead 值从 200 减少到 100 这件事来得重要
在实际生产环境中,一定要同时监控 Lag 值和 Lead 值

eg:
使用 JConsole 工具监控此 JMX 指标的截图
在这里插入图片描述
client-id 为 consumer-1 的消费者在给定的测量周期内最大的 Lag 值为 714202,最小的 Lead 值是 83,这说明此消费者有很大的消费滞后性


Kafka 消费者还在分区级别提供了额外的 JMX 指标,用于单独监控分区级别的 Lag 和 Lead 值
JMX 名称为:

kafka.consumer:
	type=consumer-fetch-manager-metrics,
	partition={partition},
	topic={topic},
	client-id={client-id}

上个例子中, client-id = consumer-1,主题 = test, 分区 = 0
下图是分区级别的 JMX 指标
分区级别的 JMX 指标中多了 records-lag-avgrecords-lead-avg 两个属性,可以计算平均的 Lag 值和 Lead 值
在实际场景中,会更多地使用这两个 JMX 指标

在这里插入图片描述

小结

方法 1 是最简单的,可以直接运行 Kafka 自带的命令行工具
方法 2 使用 Consumer API 组合计算 Lag,还能集成进很多企业级的自动化监控工具中
方法 3 集成性最好,可以直接将 JMX 监控指标配置到主流的监控框架

建议优先考虑方法 3,同时将方法 1 和方法 2 作为备选

在这里插入图片描述

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值