客户端实践及原理剖析

09 生产者消息分区机制原理剖析

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

为什么分区?

Kafka有主题(Topic)的概念,它是承载真实数据的逻辑容器,而在主题之下还分为若干个分区,也就是说Kafka的消息组织方式实际上是三级结构:主题-分区-消息。主题下的每条消息只会保存在某一个分区中,而不会在多个分区中被保存多份。官网上的这张图非常清晰地展示了Kafka的三级结构,如下所示:

现在我抛出一个问题你可以先思考一下:你觉得为什么Kafka要做这样的设计?为什么使用分区的概念而不是直接使用多个主题呢?

其实分区的作用就是提供负载均衡的能力,或者说对数据进行分区的主要原因,就是为了实现系统的高伸缩性(Scalability)。不同的分区能够被放置到不同节点的机器上,而数据的读写操作也都是针对分区这个粒度而进行的,这样每个节点的机器都能独立地执行各自分区的读写请求处理。并且,我们还可以通过添加新的节点机器来增加整体系统的吞吐量

除了提供负载均衡这种最核心的功能之外,利用分区也可以实现其他一些业务级别的需求,比如实现业务级别的消息顺序的问题,这一点我今天也会分享一个具体的案例来说明。

都有哪些分区策略?

分区策略是决定生产者将消息发送到哪个分区的算法。Kafka为我们提供了默认的分区策略,同时它也支持你自定义分区策略。

轮询策略

轮询策略是Kafka Java生产者API默认提供的分区策略。如果你未指定partitioner.class参数,那么你的生产者程序会按照轮询的方式在主题的所有分区间均匀地"码放"消息。
轮询策略是Kafka Java生产者API默认提供的分区策略。如果你未指定partitioner.class参数,那么你的生产者程序会按照轮询的方式在主题的所有分区间均匀地"码放"消息。

随机策略

随机就是我们随意地将消息放置到任意一个分区上。

按消息键报序策略

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

前面提到的Kafka默认分区策略实际上同时实现了两种策略:如果指定了Key,那么默认实现按消息键保序策略;如果没有指定Key,则使用轮询策略。

其他分区策略

此时我们就可以根据Broker所在的IP地址实现定制化的分区策略。比如下面这段代码:

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

10 生产者压缩算法面面观

怎么压缩?

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

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

何时压缩?

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

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

java 复制代码
 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压缩
 props.put("compression.type", "gzip");

 Producer<String, String> producer = new KafkaProducer<>(props);

这里比较关键的代码行是props.put("compression.type", "gzip"),它表明该Producer的压缩算法使用的是GZIP。这样Producer启动后生产的每个消息集合都是经GZIP压缩过的,故而能很好地节省网络传输带宽以及Kafka Broker端的磁盘占用。

有两种例外情况就可能让Broker重新压缩消息:

  • Broker端指定了和Producer端不同步的压缩算法。可能会发生预料之外的压缩/解压缩操作,通常表现为Broker端CPU使用率飙升。
  • Broker端发生了消息格式转换。为了兼容老版本的格式,Broker端会对新版本消息执行向老版本格式的转换。这个过程中会涉及消息的解压缩和重新压缩。一般情况下这种消息格式转换对性能是有很大影响的,除了这里的压缩之外,它还让Kafka丧失了引以为豪的Zero Copy特性。

何时解压缩?

通常来说解压缩发生在消费者程序中,也就是说Producer发送压缩消息到Broker后,Broker照单全收并原样保存起来。当Consumer程序请求这部分消息时,Broker依然原样发送出去,当消息到达Consumer端后,由Consumer自行解压缩还原成之前的消息。

,Consumer怎么知道这些消息是用何种压缩算法压缩的呢?其实答案就在消息中。Kafka会将启用了哪种压缩算法封装进消息集合中,这样当Consumer读取到消息集合时,它自然就知道了这些消息使用的是哪种压缩算法。如果用一句话总结一下压缩和解压缩,那么我希望你记住这句话:Producer端压缩、Broker端保持、Consumer端解压缩

除了在Consumer端解压缩,Broker端也会进行解压缩。注意了,这和前面提到消息格式转换时发生的解压缩是不同的场景。每个压缩过的消息集合在Broker端写入时都要发生解压缩操作,目的就是为了对消息执行各种验证。我们必须承认这种解压缩对Broker端性能是有一定影响的,特别是对CPU的使用率而言。

最近国内京东的小伙伴们刚刚向社区提出了一个bugfix,建议去掉因为做消息校验而引入的解压缩。据他们称,去掉了解压缩之后,Broker端的CPU使用率至少降低了50%。不过有些遗憾的是,目前社区并未采纳这个建议,原因就是这种消息校验是非常重要的,不可盲目去之。毕。

各种压缩算法对比

有两个重要的指标:一个指标是压缩比,原先占100份空间的东西经压缩之后变成了占20份空间,那么压缩比就是5,显然压缩比越高越好;另一个指标就是压缩/解压缩吞吐量,比如每秒能压缩或解压缩多少MB的数据。同样地,吞吐量也是越高越好。

最佳实践

Producer端完成的压缩,那么启用压缩的一个条件就是Producer程序运行机器上的CPU资源要很充足。如果Producer运行机器本身CPU已经消耗殆尽了,那么启用消息压缩无疑是雪上加霜,只会适得其反。

除了CPU资源充足这一条件,如果你的环境中带宽资源有限,那么我也建议你开启压缩。

一旦启用压缩,解压缩是不可避免的事情。这里只想强调一点:我们对不可抗拒的解压缩无能为力,但至少能规避掉那些意料之外的解压缩。就像我前面说的,因为要兼容老版本而引入的解压缩操作就属于这类。有条件的话尽量保证不要出现消息格式转换的情况。

11 无消息丢失配置怎么实现?

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

什么是已提交的消息?当Kafka的若干个Broker成功地接收到一条消息并写入到日志文件后,它们会告诉生产者程序这条消息已成功提交。此时,这条消息在Kafka看来就正式变为"已提交"消息了。

那为什么是若干个Broker呢?这取决于你对"已提交"的定义。你可以选择只要有一个Broker成功保存该消息就算是已提交,也可以是令所有Broker都成功保存该消息才算是已提交。不论哪种情况,Kafka只对已提交的消息做持久化保证这件事情是不变的。

说Kafka不丢消息是有前提条件的。假如你的消息保存在N个Kafka Broker上,那么这个前提条件就是这N个Broker中至少有1个存活。只要这个条件成立,Kafka就能保证你的这条消息永远不会丢失。

"消息丢失"案例

案例1:生产者程序丢失数据

描述一个场景:你写了一个Producer应用向Kafka发送消息,最后发现Kafka没有保存,于是大骂:"Kafka真烂,消息发送居然都能丢失,而且还不告诉我?!"如果你有过这样的经历,那么请先消消气,我们来分析下可能的原因。

目前Kafka Producer是异步发送消息的,也就是说如果你调用的是producer.send(msg)这个API,那么它通常会立即返回,但此时你不能认为消息发送已成功完成。

不过,就算不是Kafka的"锅",我们也要解决这个问题吧。实际上,解决此问题的方法非常简单:Producer永远要使用带有回调通知的发送API,也就是说不要使用producer.send(msg),而要使用producer.send(msg, callback)。不要小瞧这里的callback(回调),它能准确地告诉你消息是否真的提交成功了。一旦出现消息提交失败的情况,你就可以有针对性地进行处理。

案例2:消费者程序丢失数据

Consumer端丢失数据主要体现在Consumer端要消费的消息不见了。Consumer程序有个"位移"的概念,表示的是这个Consumer当前消费到的Topic分区的位置。下面这张图来自于官网,它清晰地展示了Consumer端的位移数据。

比如对于Consumer A而言,它当前的位移值就是9;Consumer B的位移值是11。

正确使用书签有两个步骤:第一步是读书,第二步是更新书签页。如果这两步的顺序颠倒了,就可能出现这样的场景:当前的书签页是第90页,我先将书签放到第100页上,之后开始读书。当阅读到第95页时,我临时有事中止了阅读。那么问题来了,当我下次直接跳到书签页阅读时,我就丢失了第96~99页的内容,即这些消息就丢失了。

办法很简单:**维持先消费消息,在更新位移的顺序,**这样就能最大限度地保证消息不丢失。当然,这种处理方式可能带来的问题是消息的重复处理。

除了上面所说的场景,其实还存在一种比较隐蔽的消息丢失场景。

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

这个问题的解决方案也很简单:如果是多线程异步处理消费消息,Consumer程序不要开启自动提交位移,而是要应用程序手动提交位移。在这里我要提醒你一下,单个Consumer程序使用多线程来消费消息说起来容易,写成代码却异常困难,因为你很难正确地处理位移的更新,也就是说避免无消费消息丢失很简单,但极易出现消息被消费了多次的情况。

最佳实践

分享一下Kafka无消息丢失的配置,每一个其实都能对应上面提到的问题。

  1. 不要使用producer.send(msg),而要使用producer.send(msg, callback)。记住,一定要使用带有回调通知的send方法。
  2. 设置acks = all。acks是Producer的一个参数,代表了你对"已提交"消息的定义。如果设置成all,则表明所有副本Broker都要接收到消息,该消息才算是"已提交"。这是最高等级的"已提交"定义。
  3. 设置retries为一个较大的值。这里的retries同样是Producer的参数,对应前面提到的Producer自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了retries > 0的Producer能够自动重试消息发送,避免消息丢失。
  4. 设置unclean.leader.election.enable = false。这是Broker端的参数,它控制的是哪些Broker有资格竞选分区的Leader。如果一个Broker落后原先的Leader太多,那么它一旦成为新的Leader,必然会造成消息的丢失。故一般都要将该参数设置成false,即不允许这种情况的发生。
  5. 设置replication.factor >= 3。这也是Broker端的参数。其实这里想表述的是,最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余
  6. 设置min.insync.replicas > 1。这依然是Broker端参数,控制的是消息至少要被写入到多少个副本才算是"已提交"。设置成大于1可以提升消息持久性。在实际环境中千万不要使用默认值1。
  7. 确保replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成replication.factor = min.insync.replicas + 1。
  8. 确保消息消费完成再提交。Consumer端有个参数enable.auto.commit,最好把它设置成false,并采用手动提交位移的方式。就像前面说的,这对于单Consumer多线程处理的场景而言是至关重要的。

12 客户端都有哪些不常见但是很高级的功能?

什么是拦截器?

类似于Spring Interceptor(拦截器),其基本思想就是允许应用程序在不修改逻辑的情况下,动态地实现一组可插拔的事件处理逻辑链。它能够在主业务操作的前后多个时间点上插入对应的"拦截"逻辑。下面这张图展示了Spring MVC拦截器的工作原理:

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

作为一个非常小众的功能,Kafka拦截器自0.10.0.0版本被引入后并未得到太多的实际应用,我也从未在任何Kafka技术峰会上看到有公司分享其使用拦截器的成功案例。但即便如此,在自己的Kafka工具箱中放入这么一个有用的东西依然是值得的。今天我们就让它来发挥威力,展示一些非常酷炫的功能。

Kafka拦截器

Kafka拦截器分为生产者拦截器和消费者拦截器。生产者拦截器允许你在发送消息前以及消息提交成功后植入你的拦截器逻辑;而消费者拦截器支持在消费消息前以及提交位移后编写特定逻辑。值得一提的是,这两种拦截器都支持链的方式,即你可以将一组拦截器串连成一个大的拦截器,Kafka会按照添加顺序依次执行拦截器逻辑。

当前Kafka拦截器的设置方法是通过参数配置完成的。生产者和消费者两端有一个相同的参数,名字叫interceptor.classes,它指定的是一组类的列表,每个类就是特定逻辑的拦截器实现类。拿上面的例子来说,假设第一个拦截器的完整类路径是com.yourcompany.kafkaproject.interceptors.AddTimeStampInterceptor,第二个类是com.yourcompany.kafkaproject.interceptors.UpdateCounterInterceptor,那么你需要按照以下方法在Producer端指定拦截器:

java 复制代码
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);
......

对应自定义的拦截器要继承org.apache.kafka.clients.producer.ProducerInterceptor接口。同理,指定消费者拦截器也是同样的方法,只是具体的实现类要实现org.apache.kafka.clients.consumer.ConsumerInterceptor接口。

典型使用场景

Kafka拦截器都能用在哪些地方呢?其实,跟很多拦截器的用法相同,Kafka拦截器可以应用于包括客户端监控、端到端系统性能检测、消息审计等多种功能在内的场景

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

从技术上来说,我们可以在客户端程序中增加这样的统计逻辑,但是对于那些将Kafka作为企业级基础架构的公司来说,在应用代码中编写统一的监控逻辑其实是很难的,毕竟这东西非常灵活,不太可能提前确定好所有的计算逻辑。另外,将监控逻辑与主业务逻辑耦合也是软件工程中不提倡的做法。

现在,通过实现拦截器的逻辑以及可插拔的机制,我们能够快速地观测、验证以及监控集群间的客户端性能指标,特别是能够从具体的消息层面上去收集这些数据。这就是Kafka拦截器的一个非常典型的使用场景。

设想你的公司把Kafka作为一个私有云消息引擎平台向全公司提供服务,这必然要涉及多租户以及消息审计的功能。

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

案例分享

编写拦截器类来统计消息端到端处理的延时,非常实用,我建议你可以直接移植到你自己的生产环境中。

他们的场景很简单,某个业务只有一个Producer和一个Consumer,他们想知道该业务消息从被生产出来到最后被消费的平均总时长是多少,但是目前Kafka并没有提供这种端到端的延时统计。

我们在真正消费一批消息前首先更新了它们的总延时,方法就是用当前的时钟时间减去封装在消息中的创建时间,然后累计得到这批消息总的端到端处理延时并更新到Redis中。之后的逻辑就很简单了,我们分别从Redis中读取更新过的总延时和总消息数,两者相除即得到端到端消息的平均处理延时。

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

为何采用TCP?

Apache Kafka的所有通信都是基于TCP的,而不是基于HTTP或其他协议。无论是生产者、消费者,还是Broker之间的通信都是如此。你可能会问,为什么Kafka不使用HTTP作为底层的通信协议呢?其实这里面的原因有很多,但最主要的原因在于TCP和HTTP之间的区别。

从社区的角度来看,在开发客户端时,人们能够利用TCP本身提供的一些高级功能,比如多路复用请求以及同时轮询多个连接的能力。

所谓的多路复用请求,即multiplexing request,是指将两个或多个数据流合并到底层单一物理连接中的过程。TCP的多路复用请求会在一条物理连接上创建若干个虚拟连接,每个虚拟连接负责流转各自对应的数据流。其实严格来说,TCP并不能多路复用,它只是提供可靠的消息交付语义保证,比如自动重传丢失的报文。

除了TCP提供的这些高级功能有可能被Kafka客户端的开发人员使用之外,社区还发现,目前已知的HTTP库在很多编程语言中都略显简陋。

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

Kafka生产者程序概览

Kafka的Java生产者API主要的对象就是KafkaProducer。通常我们开发一个生产者的步骤有4步。

第1步:构造生产者对象所需的参数对象。

第2步:利用第1步的参数对象,创建KafkaProducer对象实例。

第3步:使用KafkaProducer的send方法发送消息。

第4步:调用KafkaProducer的close方法关闭生产者并释放各种系统资源。

上面这4步写成Java代码的话大概是这个样子:

java 复制代码
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);
	......
}

何时创建TCP连接?

我们首先要弄明白生产者代码是什么时候创建TCP连接的。首先,生产者应用在创建KafkaProducer实例时是会建立与Broker的TCP连接的。其实这种表述也不是很准确,应该这样说:在创建KafkaProducer实例时,生产者应用会在后台创建并启动一个名为Sender的线程,该Sender线程开始运行时首先会创建与Broker的连接。我截取了一段测试环境中的日志来说明这一点:

复制代码
[2018-12-09 09:35:45,620] 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)
[2018-12-09 09:35:45,622] 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)
[2018-12-09 09:35:45,814] 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)
[2018-12-09 09:35:45,815] 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)
[2018-12-09 09:35:45,828] 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)

怎么可能是这样?如果不调用send方法,这个Producer都不知道给哪个主题发消息,它又怎么能知道连接哪个Broker呢?难不成它会连接bootstrap.servers参数指定的所有Broker吗?嗯,是的,Java Producer目前还真是这样设计的。

我在这里稍微解释一下bootstrap.servers参数。它是Producer的核心参数之一,指定了这个Producer启动时要连接的Broker地址。请注意,这里的"启动时",代表的是Producer启动时会发起与这些Broker的连接。因此,如果你为这个参数指定了1000个Broker连接信息,那么很遗憾,你的Producer启动时会首先创建与这1000个Broker的TCP连接

在实际使用过程中,我并不建议把集群中所有的Broker信息都配置到bootstrap.servers中,通常你指定3~4台就足以了。因为Producer一旦连接到集群中的任一台Broker,就能拿到整个集群的Broker信息,故没必要为bootstrap.servers指定所有的Broker。

从这段日志中,我们可以发现,在KafkaProducer实例被创建后以及消息被发送前,Producer应用就开始创建与两台Broker的TCP连接了。当然了,在我的测试环境中,我为bootstrap.servers配置了localhost:9092、localhost:9093来模拟不同的Broker,但是这并不影响后面的讨论。另外,日志输出中的最后一行也很关键:它表明Producer向某一台Broker发送了METADATA请求,尝试获取集群的元数据信息------这就是前面提到的Producer能够获取集群所有信息的方法。(可以用本机的不同端口代替不同的kafka服务端)。

针对TCP连接何时创建的问题,目前我们的结论是这样的:**TCP连接是在创建KafkaProducer实例时建立的。**那么,我们想问的是,它只会在这个时候被创建吗?

当然不是!**TCP连接还可能在两个地方被创建:一个是在更新元数据后,另一个是在消息发送时。**为什么说是可能?因为这两个地方并非总是创建TCP连接。当Producer更新了集群的元数据信息之后,如果发现与某些Broker当前没有连接,那么它就会创建一个TCP连接。同样地,当要发送消息时,Producer发现尚不存在与目标Broker的连接,也会创建一个。

接下来,我们来看看Producer更新集群元数据信息的两个场景。

场景一:当Product尝试给一个不存在的主题发送消息时,Broker会告诉Producer说这个主题不存在。此时Producer会发送METADATA请求给Kafka集群,去尝试获取最新的元数据信息。

场景二:Producer通过metadata.max.age.ms参数定期地去更新元数据信息。该参数的默认值是300000,即5分钟,也就是说不管集群那边是否有变化,Producer每5分钟都会强制刷新一次元数据以保证它是最及时的数据。

讲到这里,我们可以"挑战"一下社区对Producer的这种设计的合理性。目前来看,一个Producer默认会向集群的所有Broker都创建TCP连接,不管是否真的需要传输请求。这显然是没有必要的。再加上Kafka还支持强制将空闲的TCP连接资源关闭,这就更显得多此一举了。

试想一下,在一个有着1000台Broker的集群中,你的Producer可能只会与其中的3~5台Broker长期通信,但是Producer启动后依次创建与这1000台Broker的TCP连接。一段时间之后,大约有995个TCP连接又被强制关闭。这难道不是一种资源浪费吗?很显然,这里是有改善和优化的空间的。

何时关闭TCP连接?

Producer端关闭TCP连接的方式有两种:一种是用户主动关闭;一种是Kafka自动关闭

这里的主动关闭实际上是广义的主动关闭,甚至包括用户调用kill -9主动"杀掉"Producer应用。当然最推荐的方式还是调用producer.close()方法来关闭。

第二种是Kafka帮你关闭,这与Producer端参数connections.max.idle.ms的值有关。默认情况下该参数值是9分钟,即如果在9分钟内没有任何请求"流过"某个TCP连接,那么Kafka会主动帮你把该TCP连接关闭。用户可以在Producer端设置connections.max.idle.ms=-1禁掉这种机制。一旦被设置成-1,TCP连接将成为永久长连接。当然这只是软件层面的"长连接"机制,由于Kafka创建的这些Socket连接都开启了keepalive,因此keepalive探活机制还是会遵守的。

值得注意的是,在第二种方式中,TCP连接是在Broker端被关闭的,但其实这个TCP连接的发起方是客户端,因此在TCP看来,这属于被动关闭的场景,即passive close。被动关闭的后果就是会产生大量的CLOSE_WAIT连接,因此Producer端或Client端没有机会显式地观测到此连接已被中断。

14 幂等生产者和事务生产者是一回事吗?

所谓的消息交付可靠性保障,是指Kafka对Producer和Consumer要处理的消息提供什么样的承诺。常见的承诺有以下三种:

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

目前,Kafka默认提供的交付可靠性保障是第二种,即至少一次。我们说过消息"已提交"的含义,即只有Broker成功"提交"消息且Producer接到Broker的应答才会认为该消息成功发送。不过倘若消息成功"提交",但Broker的应答没有成功发送回Producer端(比如网络出现瞬时抖动),那么Producer就无法确定消息是否真的提交成功了。因此,它只能选择重试,也就是再次发送相同的消息。这就是Kafka默认提供至少一次可靠性保障的原因,不过这会导致消息重复发送。

Kafka是怎么做到精确一次的呢?简单来说,这是通过两种机制:幂等性(Idempotence)和事务(Transaction)。它们分别是什么机制?两者是一回事吗?要回答这些问题,我们首先来说说什么是幂等性。

什么是幂等性(Idempotence)?

"幂等"指的是某些操作或函数能够被执行多次,但每次得到的结果都是不变的。

幂等性有很多好处,**其最大的优势在于我们可以安全地重试任何幂等性操作,反正它们也不会破坏我们的系统状态。**如果是非幂等性操作,我们还需要担心某些操作执行多次对状态的影响,但对于幂等性操作而言,我们根本无需担心此事。

幂等性Producer

在Kafka 0.11之后,指定Producer幂等性的方法很简单,仅需要设置一个参数即可,即props.put("enable.idempotence", ture),或props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true)。

enable.idempotence被设置成true后,Producer自动升级成幂等性Producer,其他所有的代码逻辑都不需要改变。Kafka自动帮你做消息的重复去重。底层具体的原理很简单,就是经典的用空间去换时间的优化思路,即在Broker端多保存一些字段。当Producer发送了具有相同字段值的消息后,Broker能够自动知晓这些消息已经重复了,于是可以在后台默默地把它们"丢弃"掉。当然,实际的实现原理并没有这么简单,但你大致可以这么理解。

看上去,幂等性Producer的功能很酷,使用起来也很简单,仅仅设置一个参数就能保证消息不重复了,但实际上,我们必须要了解幂等性Producer的作用范围。

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

如果想实现多分区以及多会话上的消息无重复,应该怎么做呢?答案就是事务(transaction)或者依赖事务型Producer。这也是幂等性Producer和事务型Producer的最大区别!

事务

在数据库领域,事务提供的安全性保障是经典的ACID,隔离性表明并发执行的事务彼此相互隔离,互不影响。经典的数据库教科书把隔离性称为可串行化(serializability),即每个事务都假装它是整个数据库中唯一的事务。

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

事务型Producer

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

设置事务型Producer的方法也很简单,满足两个要求即可:

此外,你还需要在Producer代码中做一些调整,如这段代码所示:

java 复制代码
producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(record1);
    producer.send(record2);
	producer.commitTransaction();
} catch (KafkaException e) {
	producer.abortTransaction();
}

和普通Producer代码相比,事务型Producer的显著特点是调用了一些事务API,如initTransaction、beginTransaction、commitTransaction和abortTransaction,它们分别对应事务的初始化、事务开始、事务提交以及事务终止。

这段代码能够保证Record1和Record2被当作一个事务统一提交到Kafka,要么它们全部提交成功,要么全部写入失败。实际上即使写入失败,Kafka也会把它们写入到底层的日志中,也就是说Consumer还是会看到这些消息。因此在Consumer端,读取事务型Producer发送的消息也是需要一些变更的。修改起来也很简单,设置isolation.level参数的值即可。当前这个参数有两个取值:

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

15 消费者组到底是什么?

消费者组

Consumer Group是Kafka提供的可扩展且具有容错性的消费者机制。既然是一个组,那么组内必然可以有多个消费者或消费者实例(Consumer Instance),它们共享一个公共的ID,这个ID被称为Group ID。组内的所有消费者协调在一起来消费订阅主题(Subscribed Topics)的所有分区(Partition)。当然,每个分区只能由同一个消费者组内的一个Consumer实例来消费。裂解消费者组记住下面这三个特性就好了:

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

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

如果有这么一种机制,既可以避开这两种模型的缺陷,又兼具它们的优点,那就太好了。Kafka的Consumer Group就是这样的机制。当Consumer Group订阅了多个主题后,组内的每个实例不要求一定要订阅主题的所有分区,它只会消费部分分区中的消息。

Consumer Group之间彼此独立,互不影响,它们能够订阅相同的一组主题而互不干涉。再加上Broker端的消息留存机制,Kafka的Consumer Group完美地规避了上面提到的伸缩性差的问题。可以这么说**,Kafka仅仅使用Consumer Group这一种机制,却同时实现了传统消息引擎系统的两大模型**:如果所有实例都属于同一个Group,那么它实现的就是消息队列模型;如果所有实例分别属于不同的Group,那么它实现的就是发布/订阅模型。

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

举个简单的例子,假设一个Consumer Group订阅了3个主题,分别是A、B、C,它们的分区数依次是1、2、3(总共是6个分区),那么通常情况下,为该Group设置6个Consumer实例是比较理想的情形,因为它能最大限度地实现高伸缩性。

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

看上去该Offset就是一个数值而已,其实对于Consumer Group而言,它是一组KV对,Key是分区,V对应Consumer消费该分区的最新位移。如果用Java来表示的话,你大致可以认为是这样的数据结构,即Map,其中TopicPartition表示一个分区,而Long表示位移的类型。当然,我必须承认Kafka源码中并不是这样简单的数据结构,而是要比这个复杂得多,不过这并不会妨碍我们对Group位移的理解。

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

不过,慢慢地人们发现了一个问题,即ZooKeeper这类元框架其实并不适合进行频繁的写更新,而Consumer Group的位移更新却是一个非常频繁的操作。这种大吞吐量的写操作会极大地拖慢ZooKeeper集群的性能,因此Kafka社区渐渐有了这样的共识:将Consumer位移保存在ZooKeeper中是不合适的做法。

于是,在新版本的Consumer Group中,Kafka社区重新设计了Consumer Group的位移管理方式,采用了将位移保存在Kafka内部主题的方法。这个内部主题就是让人既爱又恨的__consumer_offsets。我会在专栏后面的内容中专门介绍这个神秘的主题。不过,现在你需要记住新版本的Consumer Group将位移保存在Broker端的内部主题中。

Rebalance再平衡

我们来说说Consumer Group端大名鼎鼎的重平衡,也就是所谓的Rebalance过程。我形容其为"大名鼎鼎",从某种程度上来说其实也是"臭名昭著",因为有关它的bug真可谓是此起彼伏,从未间断。这里我先卖个关子,后面我会解释它"遭人恨"的地方。我们先来了解一下什么是Rebalance。

Rebalance本质上是一种协议,规定了一个Consumer Group下的所有Consumer如何达成一致,来分配订阅Topic的每个分区。

比如某个Group下有20个Consumer实例,它订阅了一个具有100个分区的Topic。正常情况下,Kafka平均会为每个Consumer分配5个分区。这个分配的过程就叫Rebalance。

那么Consumer Group何时进行Rebalance呢?Rebalance的触发条件有3个。

  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 Group发生Rebalance的过程。假设目前某个Consumer Group下有两个Consumer,比如A和B,当第三个成员C加入时,Kafka会触发Rebalance,并根据默认的分配策略重新为A、B和C分配分区,如下图所示:

讲完了Rebalance,现在我来说说它"遭人恨"的地方。

首先,Rebalance过程对Consumer Group消费过程有极大的影响。如果你了解JVM的垃圾回收机制,你一定听过万物静止的收集方式,即著名的stop the world,简称STW。在STW期间,所有应用线程都会停止工作,表现为整个应用程序僵在那边一动不动。Rebalance过程也和这个类似,在Rebalance过程中,所有Consumer实例都会停止消费,等待Rebalance完成。这是Rebalance为人诟病的一个方面。

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

最后,Rebalance实在是太慢了。曾经,有个国外用户的Group内有几百个Consumer实例,成功Rebalance一次要几个小时!这完全是不能忍受的。最悲剧的是,目前社区对此无能为力,至少现在还没有特别好的解决方案。所谓"本事大不如不摊上",也许最好的解决方案就是避免Rebalance的发生吧。

16 揭开神秘的"位移主题"面纱

consumer_offsets在Kafka源码中有个更为正式的名字,叫位移主题,即Offsets Topic。为了方便今天的讨论,我将统一使用位移主题来指代consumer_offsets。

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

但是,ZooKeeper其实并不适用于这种高频的写操作,因此,Kafka社区自0.8.2.x版本开始,就在酝酿修改这种设计,并最终在新版本Consumer中正式推出了全新的位移管理机制,自然也包括这个新的位移主题。

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

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

虽说位移主题是一个普通的Kafka主题,但它的消息格式却是Kafka自己定义的,用户不能修改,也就是说你不能随意地向这个主题写消息,因为一旦你写入的消息不满足Kafka规定的格式,那么Kafka内部无法成功解析,就会造成Broker的崩溃。事实上,Kafka Consumer有API帮你提交位移,也就是向位移主题写消息。你千万不要自己写个Producer随意向该主题发送消息。

通常来说,当Kafka集群中的第一个Consumer程序启动时,Kafka会自动创建位移主题。我们说过,位移主题就是普通的Kafka主题,那么它自然也有对应的分区数。但如果是Kafka自动创建的,分区数是怎么设置的呢?这就要看Broker端参数offsets.topic.num.partitions的取值了。它的默认值是50,因此Kafka会自动创建一个50分区的位移主题。如果你曾经惊讶于Kafka日志路径下冒出很多__consumer_offsets-xxx这样的目录,那么现在应该明白了吧,这就是Kafka自动帮你创建的位移主题啊。

总结一下,如果位移主题是Kafka自动创建的,那么该主题的分区数是50,副本数是3。

目前Kafka Consumer提交位移的方式有两种:自动提交位移和手动提交位移

Consumer端有个参数叫enable.auto.commit,如果值是true,则Consumer在后台默默地为你定期提交位移,提交间隔由一个专属的参数auto.commit.interval.ms来控制。自动提交位移有一个显著的优点,就是省事,你不用操心位移提交的事情,就能保证消息消费不会丢失。但这一点同时也是缺点。因为它太省事了,以至于丧失了很大的灵活性和可控性,你完全没法把控Consumer端的位移管理。

事实上,很多与Kafka集成的大数据框架都是禁用自动提交位移的,如Spark、Flink等。这就引出了另一种位移提交方式:手动提交位移,即设置enable.auto.commit = false。一旦设置了false,作为Consumer应用开发的你就要承担起位移提交的责任。Kafka Consumer API为你提供了位移提交的方法,如consumer.commitSync等。当调用这些方法时,Kafka会向位移主题写入相应的消息。

如果你选择的是自动提交位移,那么就可能存在一个问题:只要Consumer一直启动着,它就会无限期地向位移主题写入消息。

Kafka使用Compact策略 来删除位移主题中的过期消息,避免该主题无限期膨胀。那么应该如何定义Compact策略中的过期呢?对于同一个Key的两条消息M1和M2,如果M1的发送时间早于M2,那么M1就是过期消息。Compact的过程就是扫描日志的所有消息,剔除那些过期的消息,然后把剩下的消息整理在一起。我在这里贴一张来自官网的图片,来说明Compact过程。

图中位移为0、2和3的消息的Key都是K1。Compact之后,分区只需要保存位移为3的消息,因为它是最新发送的。

**Kafka提供了专门的后台线程定期地巡检待Compact的主题,看看是否存在满足条件的可删除数据。**这个后台线程叫Log Cleaner。很多实际生产环境中都出现过位移主题无限膨胀占用过多磁盘空间的问题,如果你的环境中也有这个问题,我建议你去检查一下Log Cleaner线程的状态,通常都是这个线程挂掉了导致的。

17 消费者组重平衡能避免吗?

Rebalance的3个弊端

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

所谓协调者,在Kafka中对应的术语是Coordinator,它专门为Consumer Group服务,负责为Group执行Rebalance以及提供位移管理和组成员管理等。

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

所有Broker在启动时,都会创建和开启相应的Coordinator组件。也就是说,**所有Broker都有各自的Coordinator组件。**那么,Consumer Group如何确定为它服务的Coordinator在哪台Broker上呢?答案就在我们之前说过的Kafka内部位移主题__consumer_offsets身上。

目前,Kafka为某个Consumer Group确定Coordinator所在的Broker的算法有2个步骤。

第1步:确定由位移主题的哪个分区来保存该Group数据:partitionId=Math.abs(groupId.hashCode() % offsetsTopicPartitionCount)。

第2步:找出该分区Leader副本所在的Broker,该Broker即为对应的Coordinator。

简单解释一下上面的算法。首先,Kafka会计算该Group的group.id参数的哈希值。比如你有个Group的group.id设置成了"test-group",那么它的hashCode值就应该是627841412。其次,Kafka会计算__consumer_offsets的分区数,通常是50个分区,之后将刚才那个哈希值对分区数进行取模加求绝对值计算,即abs(627841412 % 50) = 12。此时,我们就知道了位移主题的分区12负责保存这个Group的数据。有了分区号,算法的第2步就变得很简单了,我们只需要找出位移主题分区12的Leader副本在哪个Broker上就可以了。这个Broker,就是我们要找的Coordinator。(???)

在实际使用过程中,Consumer应用程序,特别是Java Consumer API,能够自动发现并连接正确的Coordinator,我们不用操心这个问题。知晓这个算法的最大意义在于,它能够帮助我们解决定位问题。当Consumer Group出现问题,需要快速排查Broker端日志时,我们能够根据这个算法准确定位Coordinator对应的Broker,不必一台Broker一台Broker地盲查。

Rebalance的弊端是什么呢?总结起来有以下3点:

  1. Rebalance影响Consumer端TPS。在Rebalance期间,Consumer会停下手头的事情,什么也干不了。
  2. Rebalance很慢。如果你的Group下成员很多,就一定会有这样的痛点。还记得我曾经举过的那个国外用户的例子吧?他的Group下有几百个Consumer实例,Rebalance一次要几个小时。在那种场景下,Consumer Group的Rebalance已经完全失控了。
  3. Rebalance效率不高。当前Kafka的设计机制决定了每次Rebalance时,Group下的所有成员都要参与进来,而且通常不会考虑局部性原理,但局部性原理对提升系统性能是特别重要的。

关于第3点,我们来举个简单的例子。比如一个Group下有10个成员,每个成员平均消费5个分区。假设现在有一个成员退出了,此时就需要开启新一轮的Rebalance,把这个成员之前负责的5个分区"转移"给其他成员。显然,比较好的做法是维持当前9个成员消费分区的方案不变,然后将5个分区随机分配给这9个成员,这样能最大限度地减少Rebalance对剩余Consumer成员的冲击。

遗憾的是,目前Kafka并不是这样设计的。在默认情况下,每次Rebalance时,之前的分配方案都不会被保留。就拿刚刚这个例子来说,当Rebalance开始时,Group会打散这50个分区(10个成员 * 5个分区),由当前存活的9个成员重新分配它们。显然这不是效率很高的做法。基于这个原因,社区于0.11.0.0版本推出了StickyAssignor,即有粘性的分区分配策略。所谓的有粘性,是指每次Rebalance时,该策略会尽可能地保留之前的分配方案,尽量实现分区分配的最小变动。不过有些遗憾的是,这个策略目前还有一些bug,而且需要升级到0.11.0.0才能使用,因此在实际生产环境中用得还不是很多。

这些问题有解吗?特别是针对Rebalance慢和影响TPS这两个弊端,社区有解决办法吗?针对这两点,我可以很负责任地告诉你:"无解!"特别是Rebalance慢这个问题,Kafka社区对此无能为力。"本事大不如不摊上",既然我们没办法解决Rebalance过程中的各种问题,干脆就避免Rebalance吧,特别是那些不必要的Rebalance。

就我个人经验而言,在真实的业务场景中,很多Rebalance都是计划外的或者说是不必要的。我们应用的TPS大多是被这类Rebalance拖慢的,因此避免这类Rebalance就显得很有必要了。下面我们就来说说如何避免Rebalance。

要避免Rebalance,还是要从Rebalance发生的时机入手。我们在前面说过,Rebalance发生的时机有三个:

  • 组成员数量发生变化
  • 订阅主题数量发生变化
  • 订阅主题的分区数发生变化

后面两个通常都是运维的主动操作,所以它们引发的Rebalance大都是不可避免的。

如果Consumer Group下的Consumer实例数量发生变化,就一定会引发Rebalance。这是Rebalance发生的最常见的原因。我碰到的99%的Rebalance,都是这个原因导致的。

Consumer实例增加的情况很好理解,当我们启动一个配置有相同group.id值的Consumer程序时,实际上就向这个Group添加了一个新的Consumer实例。此时,Coordinator会接纳这个新实例,将其加入到组中,并重新分配分区。通常来说,增加Consumer实例的操作都是计划内的,可能是出于增加TPS或提高伸缩性的需要。总之,它不属于我们要规避的那类"不必要Rebalance"。

我们更在意的是Group下实例数减少这件事。如果你就是要停掉某些Consumer实例,那自不必说,关键是在某些情况下,Consumer实例会被Coordinator错误地认为"已停止"从而被"踢出"Group。如果是这个原因导致的Rebalance,我们就不能不管了。

2类非必要的Rebalance

Coordinator会在什么情况下认为某个Consumer实例已挂从而要退组呢?这个绝对是需要好好讨论的话题,我们来详细说说。

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

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

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

用于减少Rebalance的4个参数

第一类非必要Rebalance是因为未能及时发送心跳,导致Consumer被"踢出"Group而引发的。因此,你需要仔细地设置session.timeout.ms和heartbeat.interval.ms的值。我在这里给出一些推荐数值,你可以"无脑"地应用在你的生产环境中。

将session.timeout.ms设置成6s主要是为了让Coordinator能够更快地定位已经挂掉的Consumer。毕竟,我们还是希望能尽快揪出那些"尸位素餐"的Consumer,早日把它们踢出Group。希望这份配置能够较好地帮助你规避第一类"不必要"的Rebalance。

第二类非必要Rebalance是Consumer消费时间过长导致的 。我之前有一个客户,在他们的场景中,Consumer消费数据时需要将消息处理之后写入到MongoDB。显然,这是一个很重的消费逻辑。MongoDB的一丁点不稳定都会导致Consumer程序消费时长的增加。此时,max.poll.interval.ms参数值的设置显得尤为关键。如果要避免非预期的Rebalance,你最好将该参数值设置得大一点,比你的下游最大处理时间稍长一点。就拿MongoDB这个例子来说,如果写MongoDB的最长时间是7分钟,那么你可以将该参数设置为8分钟左右。

总之,你要为你的业务处理逻辑留下充足的时间。这样,Consumer就不会因为处理这些消息的时间太长而引发Rebalance了。

如果你按照上面的推荐数值恰当地设置了这几个参数,却发现还是出现了Rebalance,那么我建议你去排查一下Consumer端的GC表现,比如是否出现了频繁的Full GC导致的长时间停顿,从而引发了Rebalance。为什么特意说GC?那是因为在实际场景中,我见过太多因为GC设置不合理导致程序频发Full GC而引发的非预期Rebalance了。

小结

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

按照我们今天所说的内容,恰当地设置这些参数,你一定能够大幅度地降低生产环境中的Rebalance数量,从而整体提升Consumer端TPS。

18 Kafka中位移提交那些事儿

Consumer端有个位移的概念,它和消息在分区中的位移不是一回事儿,虽然它们的英文都是Offset。今天我们要聊的位移是Consumer的消费位移,它记录了Consumer要消费的下一条消息的位移。这可能和你以前了解的有些出入,不过切记是下一条消息的位移,而不是目前最新消费消息的位移。

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

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

提交位移主要是为了表征Consumer的消费进度,这样当Consumer发生故障重启之后,就能够从Kafka中读取之前提交的位移值,然后从相应的位移处继续消费,从而避免整个消费过程重来一遍。换句话说,位移提交是Kafka提供给你的一个工具或语义保障,你负责维持这个语义保障,即如果你提交了位移X,那么Kafka会认为所有位移值小于X的消息你都已经成功消费了。

这一点特别关键。因为位移提交非常灵活,你完全可以提交任何位移值,但由此产生的后果你也要一并承担。假设你的Consumer消费了10条消息,你提交的位移值却是20,那么从理论上讲,位移介于11~19之间的消息是有可能丢失的;相反地,如果你提交的位移值是5,那么位移介于5~9之间的消息就有可能被重复消费。所以,我想再强调一下,位移提交的语义保障是由你来负责的,kafka只会"无脑"地接受你提交的位移。你对位移提交的管理直接影响了你的Consumer所能提供的消息语义保障。

鉴于位移提交甚至是位移管理对Consumer端的巨大影响,Kafka,特别是KafkaConsumer API,提供了多种提交位移的方法。从用户的角度来说,位移提交分为自动提交和手动提交;从Consumer端的角度来说,位移提交分为同步提交和异步提交。

我们先来说说自动提交和手动提交。所谓自动提交,就是指Kafka Consumer在后台默默地为你提交位移,作为用户的你完全不必操心这些事;而手动提交,则是指你要自己提交位移,Kafka Consumer压根不管。

开启自动提交位移的方法很简单。Consumer端有个参数enable.auto.commit,把它设置为true或者压根不设置它就可以了。因为它的默认值就是true,即Java Consumer默认就是自动提交位移的。如果启用了自动提交,Consumer端还有个参数就派上用场了:auto.commit.interval.ms。它的默认值是5秒,表明Kafka每5秒会为你自动提交一次位移。

为了把这个问题说清楚,我给出了完整的Java代码。这段代码展示了设置自动提交位移的方法。有了这段代码做基础,今天后面的讲解我就不再展示完整的代码了。

java 复制代码
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());
}

上面的第3、第4行代码,就是开启自动提交位移的方法。总体来说,还是很简单的吧。

和自动提交相反的,就是手动提交了。开启手动提交位移的方法就是设置enable.auto.commit为false。但是,仅仅设置它为false还不够,因为你只是告诉Kafka Consumer不要自动提交位移而已,你还需要调用相应的API手动提交位移。

最简单的API就是KafkaConsumer#commitSync()。该方法会提交KafkaConsumer#poll()返回的最新位移。从名字上来看,它是一个同步操作,即该方法会一直等待,知道位移被成功提交才会返回。如果提交过程中出现异常,该方法会将异常信息抛出。下面这段代码展示了commitSync()的使用方法:

java 复制代码
while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
	process(records);	// 处理消息
	try {
		consumer.commitSync();
	} catch (CommitFailedException e) {
		handle(e);		// 处理提交失败异常
	}
}

可见,调用consumer.commitSync()方法的时机,是在你处理完了poll()方法返回的所有消息之后。如果你莽撞地过早提交了位移,就可能会出现消费数据丢失的情况。那么你可能会问,自动提交位移就不会出现消费数据丢失的情况了吗?它能恰到好处地把握时机进行位移提交吗?为了搞清楚这个问题,我们必须要深入地了解一下自动提交位移的顺序。

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

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

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

"TPS" 表示每秒事务处理量的**"每秒事务数"。

鉴于这个问题,Kafka社区为手动提交位移提供了另一个API方法:KafkaConsumer#commitAsync()。从名字上来看它就不是同步的,而是一个异步操作。调用commitAsync()之后,它会立即返回,不会阻塞,因此不会影响Consumer应用的TPS。由于它是异步的,Kafka提供了回调函数(callback),供你实现提交之后的逻辑,比如记录日志或处理异常等。下面这段代码展示了调用commitAsync()的方法:

java 复制代码
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
    process(records); // 处理消息
    consumer.commitAsync((offsets, exception) -> {
		if (exception != null)
		handle(exception);
	});
}

commitAsync是否能够替代commitSync呢?答案是不能。commitAsync的问题在于,出现问题时它不会自动重试。因为它是异步操作,倘若提交失败后自动重试,那么它重试时提交的位移值可能早已经"过期"或不是最新值了。因此,异步提交的重试其实没有意义,所以commitAsync是不会重试的。

显然,如果是手动提交,我们需要将commitSync和commitAsync组合使用才能达到最理想的效果,原因有两个:

  1. 我们可以利用commitSync的自动重试来规避那些瞬时错误,比如网络的瞬时抖动,Broker端GC等。因为这些问题都是短暂的,自动重试通常都会成功,因此,我们不想自己重试,而是希望Kafka Consumer帮我们做这件事。
  2. 我们不希望程序总处于阻塞状态,影响TPS。

我们来看一下下面这段代码,它展示的是如何将两个API方法结合使用进行手动提交。

java 复制代码
try {
	while(true) {
	     ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
	     process(records); // 处理消息
	     commitAysnc(); // 使用异步提交规避阻塞
    }
} catch(Exception e) {
	handle(e); // 处理异常
} finally {
    try {
		consumer.commitSync(); // 最后一次提交使用同步阻塞式提交
	} finally {
		consumer.close();
	}
}

这段代码同时使用了commitSync()和commitAsync()。对于常规性、阶段性的手动提交,我们调用commitAsync()避免程序阻塞,而在Consumer要关闭前,我们调用commitSync()方法执行同步阻塞式的位移提交,以确保Consumer关闭前能够保存正确的位移数据。将两者结合后,我们既实现了异步无阻塞式的位移管理,也确保了Consumer位移的正确性,所以,如果你需要自行编写代码开发一套Kafka Consumer应用,那么我推荐你使用上面的代码范例来实现手动的位移提交。

我们说了自动提交和手动提交,也说了同步提交和异步提交,这些就是Kafka位移提交的全部了吗?其实,我们还差一部分。

实际上,Kafka Consumer API还提供了一组更为方便的方法,可以帮助你实现更精细化的位移管理功能。刚刚我们聊到的所有位移提交,都是提交poll方法返回的所有消息的位移,比如poll方法一次返回了500条消息,当你处理完这500条消息之后,前面我们提到的各种方法会一次性地将这500条消息的位移一并处理。简单来说,就是直接提交最新一条消息的位移。但如果我想更加细粒度化地提交位移,该怎么办呢?

对于一次要处理很多消息的Consumer而言,它会关心社区有没有方法允许它在消费的中间进行位移提交。比如前面这个5000条消息的例子,你可能希望每处理完100条消息就提交一次位移,这样能够避免大批量的消息重新消费。

庆幸的是,Kafka Consumer API为手动提交提供了这样的方法:commitSync(Map)和commitAsync(Map)。它们的参数是一个Map对象,键就是TopicPartition,即消费的分区,而值是一个OffsetAndMetadata对象,保存的主要是位移数据

就拿刚刚提过的那个例子来说,如何每处理100条消息就提交一次位移呢?在这里,我以commitAsync为例,展示一段代码,实际上,commitSync的调用方法和它是一模一样的。

java 复制代码
private 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()), new OffsetAndMetadata(record.offset() + 1);
	   if(count % 100 == 0)
	                consumer.commitAsync(offsets, null); // 回调处理逻辑是null
	    count++;
	}
}

程序先是创建了一个Map对象,用于保存Consumer消费处理过程中要提交的分区位移,之后开始逐条处理消息,并构造要提交的位移值。还记得之前我说过要提交下一条消息的位移吗?这就是这里构造OffsetAndMetadata对象时,使用当前消息位移加1的原因。代码的最后部分是做位移的提交。我在这里设置了一个计数器,每累计100条消息就统一提交一次位移。与调用无参的commitAsync不同,这里调用了带Map对象参数的commitAsync进行细粒度的位移提交。这样,这段代码就能够实现每处理100条消息就提交一次位移,不用再受poll方法返回的消息总数的限制了。

小结

自动提交位移

  • 把参数enable.auto.commit设置为true或者压根不设置它就可以了。

手动提交位移

  • 同步提交位移:把参数enable.auto.commit设置为false,调用相应的API。最简单的API就是Kafka-Consumer#commitSync();
  • 异步提交位移:调用KafkaConsumer#commitAsync()。
  • 更精细化的位移管理:调用commitSync(Map<TopicPartition, OffsetAndMetadata>)和commitAsync(Map<TopicPartition, OffsetAndMetadata>)。

19 CommitFailedException异常怎么处理?

所谓CommitFailedException,顾名思义就是Consumer客户端在提交位移时出现了错误或异常,而且还是那种不可恢复的严重异常 。如果异常是可恢复的瞬时错误,提交位移的API自己就能规避它们了,因为很多提交位移的API方法是支持自动错误重试的,比如我们在上一期中提到的commitSync方法

CommitFailedException异常通常发生在手动提交位移时,即用户显式调用KafkaConsumer.commitSync()方法时。从使用场景来说,有两种典型的场景可能遭遇该异常。

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

当消息处理的总时间超过预设的max.poll.interval.ms参数值时,Kafka Consumer端会抛出CommitFailedException异常。

java 复制代码
...
Properties props = new Properties();
...
props.put("max.poll.interval.ms", 5000);
consumer.subscribe(Arrays.asList("test-topic"));

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

如果要防止这种场景下抛出异常,你需要简化你的消息处理逻辑。具体来说有4种方法。

  1. 缩短单条消息处理的时间
  2. 增阿Consumer端允许下游系统消费一批消息的最大时长
  3. 减少下游系统一次性消费的消息总数
  4. 下游系统使用多线程来加锁消费

综合以上这4个处理方法,我个人推荐你首先尝试采用方法1来预防此异常的发生。优化下游系统的消费逻辑是百利而无一害的法子,不像方法2、3那样涉及到Kafka Consumer端TPS与消费延时(Latency)的权衡。如果方法1实现起来有难度,那么你可以按照下面的法则来实践方法2、3。

首先,你需要弄清楚你的下游系统消费每条消息的平均延时是多少。比如你的消费逻辑是从Kafka获取到消息后写入到下游的MongoDB中,假设访问MongoDB的平均延时不超过2秒,那么你可以认为消息处理需要花费2秒的时间。如果按照max.poll.records等于500来计算,一批消息的总消费时长大约是1000秒,因此你的Consumer端的max.poll.interval.ms参数值就不能低于1000秒。如果你使用默认配置,那默认值5分钟显然是不够的,你将有很大概率遭遇CommitFailedException异常。将max.poll.interval.ms增加到1000秒以上的做法就属于上面的第2种方法。

除了调整max.poll.interval.ms之外,你还可以选择调整max.poll.records值,减少每次poll方法返回的消息数。还拿刚才的例子来说,你可以设置max.poll.records值为150,甚至更少,这样每批消息的总消费时长不会超过300秒(150*2=300),即max.poll.interval.ms的默认值5分钟。这种减少max.poll.records值的做法就属于上面提到的方法3。

场景二:出现设置相同group.id值的消费者组程序和独立消费者程序(不为人知的出现场景)

Kafka Java Consumer端还提供了一个名为Standalone Consumer的独立消费者。它没有消费者组的概念,每个消费者实例都是独立工作的,彼此之间毫无联系。不过,你需要注意的是,独立消费者的位移提交机制和消费者组是一样的,因此独立消费者的位移提交也必须遵守之前说的那些规定,比如独立消费者也要指定group.id参数才能提交位移。你可能会觉得奇怪,既然是独立消费者,为什么还要指定group.id呢?没办法,谁让社区就是这么设计的呢?总之,消费者组和独立消费者在使用之前都要指定group.id

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

虽然说这个场景很冷门,但也并非完全不会遇到。发生了上面提到的这种场景,你使用之前提到的哪种方法都不能规避该异常。令人沮丧的是,无论是刚才哪个版本的异常说明,都完全没有提及这个场景,因此,如果是这个原因引发的CommitFailedException异常,前面的4种方法全部都是无效的。

更为尴尬的是,无论是社区官网,还是网上的文章,都没有提到过这种使用场景。我个人认为,这应该算是Kafka的一个bug。比起返回CommitFailedException异常只是表明提交位移失败,更好的做法应该是,在Consumer端应用程序的某个地方,能够以日志或其他方式友善地提示你错误的原因,这样你才能正确处理甚至是预防该异常。

20 多线程开发消费者实例

Kafka Java Consumer就是单线程的设计。

Kafka Java Consumer设计原理

我们说KafkaConsumer是单线程的设计,严格来说这是不准确的。因为,从Kafka 0.10.1.0版本开始,KafkaConsumer就变为了双线程的设计,即用户主线程心跳线程

所谓用户主线程,就是你启动Consumer应用程序main方法的那个线程,而新引入的心跳线程(Heartbeat Thread)只负责定期给对应的Broker机器发送心跳请求,以标识消费者应用的存活性(liveness)。引入这个心跳线程还有一个目的,那就是期望它能将心跳频率与主线程调用KafkaConsumer.poll方法的频率分开,从而解耦真实的消息处理逻辑与消费者组成员存活性管理。

不过,虽然有心跳线程,但实际的消息获取逻辑依然是在用户主线程中完成的。因此,在消费消息的这个层面上,我们依然可以安全地认为KafkaConsumer是单线程的设计。

多线程方案

了解了单线程的设计原理之后,我们来具体分析一下KafkaConsumer这个类的使用方法,以及如何推演出对应的多线程方案。

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

当然了,这也不是绝对的。KafkaConsumer中有个方法是例外的,它就是wakeup(),你可以在其他线程中安全地调用KafkaConsumer.wakeup()来唤醒Consumer。

鉴于KafkaConsumer不是线程安全的事实,我们能够制定两套多线程方案。

  1. 消费者程序启动多个线程,每个线程维护专属的KafkaConsumer实例,负责完整的消息获取、消息处理流程。
  2. 消费者程序使用单或多线程获取消息,同时创建多个消费线程执行消息处理逻辑。

    总体来说,这两种方案都会创建多个线程,这些线程都会参与到消息的消费过程中,但各自的思路是不一样的。

我们来打个比方。比如一个完整的消费者应用程序要做的事情是1、2、3、4、5,那么方案1的思路是粗粒度化 的工作划分,也就是说方案1会创建多个线程,每个线程完整地执行1、2、3、4、5,以实现并行处理的目标,它不会进一步分割具体的子任务;而方案2则更细粒度化,它会将1、2分割出来,用单线程(也可以是多线程)来做,对于3、4、5,则用另外的多个线程来做。

这两种方案孰优孰劣呢?应该说是各有千秋。我总结了一下这两种方案的优缺点,我们先来看看下面这张表格。

方案一:
优势:

  1. 实现起来简单,因为它比较符合目前我们使用Consumer API的习惯。我们在写代码的时候,使用多个线程并在每个线程中创建专属的KafkaConsumer实例就可以了。
  2. 多个线程之间彼此没有任何交互,省去了很多保障线程安全方面的开销。
  3. 由于每个线程使用专属的KafkaConsumer实例来执行消息获取和消息处理逻辑,因此,Kafka主题中的每个分区都能保证只被一个线程处理,这样就很容易实现分区内的消息消费顺序。这对在乎事件先后顺序的应用场景来说,是非常重要的优势。

缺点:

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

代码示例:

java 复制代码
public class KafkaConsumerRunner implements Runnable {
     private final AtomicBoolean closed = new AtomicBoolean(false);
     private final KafkaConsumer consumer;

     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();
     }
}

这段代码创建了一个Runnable类,表示执行消费获取和消费处理的逻辑。。每个KafkaConsumerRunner类都会创建一个专属的KafkaConsumer实例。在实际应用中,你可以创建多个KafkaConsumerRunner实例,并依次执行启动它们,以实现方案1的多线程架构。

方案二:
优势:

与方案1的粗粒度不同,方案2将任务切分成了消息获取消息处理 两个部分,分别由不同的线程处理它们。比起方案1,方案2的最大优势就在于它的高伸缩性 ,就是说我们可以独立地调节消息获取的线程数,以及消息处理的线程数,而不必考虑两者之间是否相互影响。如果你的消费获取速度慢,那么增加消费获取的线程数即可;如果是消息的处理速度慢,那么增加Worker线程池线程数即可。

缺点:

  1. 它的实现难度要比方案1大得多,毕竟它有两组线程,你需要分别管理它们。
  2. 因为该方案将消息获取和消息处理分开了,也就是说获取某条消息的线程不是处理该消息的线程,因此无法保证分区内的消费顺序。举个例子,比如在某个分区中,消息1在消息2之前被保存,那么Consumer获取消息的顺序必然是消息1在前,消息2在后,但是,后面的Worker线程却有可能先处理消息2,再处理消息1,这就破坏了消息在分区中的顺序。还是那句话,如果你在意Kafka中消息的先后顺序,方案2的这个劣势是致命的。(对应的项目没办法保证时序的原因在此)
  3. 方案2引入了多组线程,使得整个消息消费链路被拉长,最终导致正确位移提交会变得异常困难 ,结果就是可能会出现消息的重复消费。如果你在意这一点,那么我不推荐你使用方案2。

代码示例:

java 复制代码
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)  {
	ConsumerRecords<String, String> records = 
		consumer.poll(Duration.ofSeconds(1));
	for (final ConsumerRecord record : records) {
		executors.submit(new Worker(record));
	}
}
..

小结

  • 从Kafka 0.10.1.0版本开始,KafkaConsumer变为了双线程的设计,即用户主线程和心跳线程。不过,虽然有心跳线程,但实际的消息获取逻辑依然是在用户主线程中完成的。因此,在消费消息的这个层面上,我们依然可以安全地认为KafkaConsumer是单线程的设计。
  • 鉴于KafkaConsumer不是线程安全的事实,我们能够制定两套多线程方案:消费者程序启动多个线程,每个线程维护专属的KafkaConsumer实例,负责完整的消息获取、消息处理流程;消费者程序使用单或多线程获取消息,同时创建多个消费线程执行消息处理逻辑。

方案一得益于:

你的Topic有足够多的分区(例如,你启动了3个线程,Topic最好至少有3个或以上的分区)。

Kafka会自动协调,将不同的分区分配给不同的消费者实例(即不同的线程)。

  • 线程1(Consumer-A)消费 Partition-0
  • 线程2(Consumer-B)消费 Partition-1
  • 线程3(Consumer-C)消费 Partition-2

这样,三个线程就在完全没有协作和通信开销的情况下,实现了真正的并行消费,每个线程内部还是简单的单线程顺序处理,位移管理非常简单可靠。这种方案是Kafka官方推荐的横向扩展(Scale Out)方式。它通过增加消费者实例(线程)来匹配分区数,从而实现并行化。它没有引入复杂的线程间协调问题,在提供高吞吐量的同时,仍然保持了位移提交的简单性和可靠性。

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

"Java生产者 是如何管理TCP连接资源的"这个话题算是它的姊妹篇,此处一起来研究下Kafka的Java消费者管理TCP或Socket资源的机制。

何时创建TCP连接?

消费者端主要的程序入口是KafkaConsumer类。和生产者不同的是,构建KafkaConsumer实例时是不会创建任何TCP连接的,也就是说,当你执行完new KafkaConsumer(properties)语句后,你会发现,没有Socket连接被创建出来。这一点和Java生产者是有区别的,主要原因就是生产者入口类KafkaProducer在构建实例的时候,会在后台默默地启动一个Sender线程,这个Sender线程负责Socket连接的创建。

从这一点上来看,我个人认为KafkaConsumer的设计比KafkaProducer要好。在Java构造函数中启动线程,会造成this指针的逃逸,这始终是一个隐患。

TCP连接是在调用KafkaConsumer.poll方法时被创建的。再细粒度地说,在poll方法内部有3个时机可以创建TCP连接。

1. 发起FindCoordinator请求时

还记得消费者端有个组件叫协调者(Coordinator)吗?它驻留在Broker端的内存中,负责消费者组的组成员管理和各个消费者的位移提交管理。当消费者程序首次启动调用poll方法时,它需要向Kafka集群发送一个名为FindCoordinator的请求,希望Kafka集群告诉它哪个Broker是管理它的协调者。

不过,消费者应该向哪个Broker发送这类请求呢?理论上任何一个Broker都能回答这个问题,也就是说消费者可以发送FindCoordinator请求给集群中的任意服务器。在这个问题上,社区做了一点点优化:消费者程序会向集群中当前负载最小的那台Broker发送请求。负载是如何评估的呢?其实很简单,就是看消费者连接的所有Broker中,谁的待发送请求最少。当然了,这种评估显然是消费者端的单向评估,并非是站在全局角度,因此有的时候也不一定是最优解。不过这不并影响我们的讨论。总之,在这一步,消费者会创建一个Socket连接。

2. 连接协调者时

Broker处理完上一步发送的FindCoordinator请求之后,会返还对应的响应结果(Response),显式地告诉消费者哪个Broker是真正的协调者,因此在这一步,消费者知晓了真正的协调者后,会创建连向该Broker的Socket连接。只有成功连入协调者,协调者才能开启正常的组协调操作,比如加入组、等待组分配方案、心跳请求处理、位移获取、位移提交等。

3. 消费数据时

消费者会为每个要消费的分区创建与该分区领导者副本所在Broker连接的TCP。举个例子,假设消费者要消费5个分区的数据,这5个分区各自的领导者副本分布在4台Broker上,那么该消费者在消费时会创建与这4台Broker的Socket连接。

创建多少个TCP连接?

消费者程序会创建3类TCP连接:

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

何时关闭TCP连接?

和生产者类似,消费者关闭Socket也分为主动关闭和Kafka自动关闭。主动关闭是指你显式地调用消费者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连接存在。

可能的问题

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

为什么会出现这种情况呢?主要是因为目前Kafka仅仅使用ID这一个维度的数据来表征Socket连接信息。这点信息明显不足以确定连接的是哪台Broker,也许在未来,社区应该考虑使用<主机名、端口、ID>三元组的方式来定位Socket资源,这样或许能够让消费者程序少创建一些TCP连接。

也许你会问,反正Kafka有定时关闭机制,这算多大点事呢?其实,在实际场景中,我见过很多将connection.max.idle.ms设置成-1,即禁用定时关闭的案例,如果是这样的话,这些TCP连接将不会被定期清除,只会成为永久的"僵尸"连接。基于这个原因,社区应该考虑更好的解决方案。

小结

TCP连接创建的3个时机

  • 发起FindCoordinator请求时。
  • 连接协调者时。
  • 消费数据时。

消费者程序会创建3类TCP连接

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

22 消费者组消费进度监控都怎么实现?

对于Kafka消费者来说,最重要的事情就是监控它们的消费进度了,或者说是监控它们消费的滞后程度。这个滞后程度有个专门的名称:消费者Lag或Consumer Lag。

所谓滞后程度,就是指消费者当前落后于生产者的程度。比方说,Kafka生产者向某主题成功生产了100万条消息,你的消费者当前消费了80万条消息,那么我们就说你的消费者滞后了20万条消息,即Lag等于20万。

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

我们刚刚说过,对消费者而言,Lag应该算是最最重要的监控指标了。它直接反映了一个消费者的运行情况。一个正常工作的消费者,它的Lag值应该很小,甚至是接近于0的,这表示该消费者能够及时地消费生产者生产出来的消息,滞后程度很小。反之,如果一个消费者Lag值很大,通常就表明它无法跟上生产者的速度,最终Lag会越来越大,从而拖慢下游消息的处理速度。

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

鉴于这些原因,你在实际业务场景中必须时刻关注消费者的消费进度。一旦出现Lag逐步增加的趋势,一定要定位问题,及时处理,避免造成业务损失。

既然消费进度这么重要,我们有3种方法进行监控:

  1. 使用Kafka自带的命令行工具kafka-consumer-groups脚本。
  2. 使用Kafka Java Consumer API编程。
  3. 使用Kafka自带的JMX监控指标。

Kafka自带命令

使用Kafka自带的命令行工具bin/kafka-consumer-groups.sh(bat)。kafka-consumer-groups脚本是Kafka为我们提供的最直接的监控消费者消费进度的工具 。当然,除了监控Lag之外,它还有其他的功能。今天,我们主要讨论如何使用它来监控Lag。
独立消费者就是没有使用消费者组机制的消费者程序。和消费者组相同的是,它们也要配置group.id参数值,但和消费者组调用KafkaConsumer.subscribe()不同的是,独立消费者调用KafkaConsumer.assign()方法直接消费指定分区。今天的重点不是要学习独立消费者,你只需要了解接下来我们讨论的所有内容都适用于独立消费者就够了。

使用kafka-consumer-groups脚本很简单。该脚本位于Kafka安装目录的bin子目录下,我们可以通过下面的命令来查看某个给定消费者的Lag值:

bash 复制代码
bin/kafka-consumer-groups.sh --bootstrap-server <Kafka broker连接信息> --describe --group <group名称>

Kafka连接信息就是<主机名:端口>对,而group名称就是你的消费者程序中设置的group.id值 。我举个实际的例子来说明具体的用法,请看下面这张图的输出:

首先,它会按照消费者组订阅主题的分区进行展示,每个分区一行数据;其次,除了主题、分区等信息外,它会汇报每个分区当前最新生产的消息的位移值(即LOG-END-OFFSET列值)、该消费者组当前最新消费消息的位移值(即CURRENT-OFFSET值)、LAG值(前两者的差值)、消费者实例ID、消费者连接Broker的主机名以及消费者的CLIENT-ID信息。

毫无疑问,在这些数据中,我们最关心的当属LAG列的值了,图中每个分区的LAG值大约都是60多万,这表明,在我的这个测试中,消费者组远远落后于生产者的进度。理想情况下,我们希望该列所有值都是0,因为这才表明我的消费者完全没有任何滞后。

有的时候,你运行这个脚本可能会出现下面这种情况,如下图所示:

如果碰到这种情况,你不用惊慌,这是因为我们运行kafka-consumer-groups脚本时没有启动消费者程序。请注意我标为橙色的文字,它显式地告诉我们,当前消费者组没有任何active成员,即没有启动任何消费者实例。虽然这些列没有值,但LAG列依然是有效的,它依然能够正确地计算出此消费者组的Lag值。

除了上面这三列没有值的情形,还可能出现的一种情况是该命令压根不返回任何结果。此时,你也不用惊慌,这是因为你使用的Kafka版本比较老,kafka-consumer-groups脚本还不支持查询非active消费者组。一旦碰到这个问题,你可以选择升级你的Kafka版本,也可以采用我接下来说的其他方法来查询。

Kafka Java Consumer API

社区提供的Java Consumer API分别提供了查询当前分区最新消息位移消费者组最新消费消息位移两组方法,我们使用它们就能计算出对应的Lag。

下面这段代码展示了如何利用Consumer端API监控给定消费者组的Lag值:

java 复制代码
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)) {
            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)) {
                    Map<TopicPartition, Long> endOffsets = consumer.endOffsets(consumedOffsets.keySet());
                    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);
            }
        }

}

第1处是调用AdminClient.listConsumerGroupOffsets方法获取给定消费者组的最新消费消息的位移;

第2处则是获取订阅分区的最新消息位移;

最后1处就是执行相应的减法操作,获取Lag值并封装进一个Map对象。

不过请注意,这段代码只适用于Kafka 2.0.0及以上的版本,2.0.0之前的版本中没有AdminClient.listConsumerGroupOffsets方法。

Kafka JMX监控指标

当前,Kafka消费者提供了一个名为kafka.consumer:type=consumer-fetch-manager-metrics,client-id="{client-id}"的JMX指标,里面有很多属性。和我们今天所讲内容相关的有两组属性:records-lag-maxrecords-lead-min,它们分别表示此消费者在测试窗口时间内曾经达到的最大的Lag值和最小的Lead值。

为什么要引入Lead呢?

试想一下,监控到Lag越来越大,可能只会给你一个感受,那就是消费者程序变得越来越慢了,至少是追不上生产者程序了,除此之外,你可能什么都不会做。毕竟,有时候这也是能够接受的。但反过来,一旦你监测到Lead越来越小,甚至是快接近于0了,你就一定要小心了,这可能预示着消费者端要丢消息了。

为什么?我们知道Kafka的消息是有留存时间设置的,默认是1周,也就是说Kafka默认删除1周前的数据。倘若你的消费者程序足够慢,慢到它要消费的数据快被Kafka删除了,这时你就必须立即处理,否则一定会出现消息被删除,从而导致消费者程序重新调整位移值的情形。

这可能产生两个后果:一个是消费者从头消费一遍数据,另一个是消费者从最新的消息位移处开始消费,之前没来得及消费的消息全部被跳过了,从而造成丢消息的假象。

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

接下来,我给出一张使用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-avg和records-lead-avg两个属性,可以计算平均的Lag值和Lead值。在实际场景中,我们会更多地使用这两个JMX指标。

相关推荐
在未来等你5 小时前
Kafka面试精讲 Day 4:Consumer消费者模型与消费组
大数据·分布式·面试·kafka·消息队列
fht113 小时前
windows下安装kafka
分布式·kafka
IT闫17 小时前
《深入剖析Kafka分布式消息队列架构奥秘》之Springboot集成Kafka
分布式·架构·kafka
thginWalker1 天前
Kafka入门
kafka
Hello.Reader2 天前
用 Docker 玩转 Kafka 4.0镜像选型、快速起步、配置持久化与常见坑
docker·容器·kafka
Hello.Reader2 天前
Kafka 4.0 生产者配置全解析与实战调优
分布式·kafka
小鹅叻2 天前
kafka
后端·kafka
Hello.Reader2 天前
Kafka 主题级配置从创建到优化
分布式·kafka·linq
三不原则3 天前
Kafka入门指南:从安装到集群部署
分布式·kafka