kafka学习笔记(三)kafka的使用技巧

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

kafka学习笔记(三)kafka的使⽤技巧
概述
上⼀篇随笔主要介绍了kafka的基本使⽤包括集群参数,⽣产者基本使⽤,consumer基本使⽤,现在来介绍⼀下kafka的使⽤技巧。

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

⽐如很多公司使⽤ Kafka 收集应⽤服务器的⽇志数据,这种数据都是很多的,特别是对于那种⼤批量机器组成的集群环境,每分钟产⽣的⽇志量都能以 GB 数,因此如何将这么⼤的数据量均匀地分配到 Kafka 的各个 Broker 上,就成为⼀个⾮常重要的问题。

Kafka 有主题(Topic)的概念,它是承载真实数据的逻辑容器,⽽在主题之下还分为若⼲个分区,也就是说 Kafka 的消息组织⽅式实际上是三级结构:主题 - 分区 - 消息。

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

官⽹上的这张图⾮常清晰地展⽰了 Kafka 的三级结构,如下所⽰:
现在我抛出⼀个问题你可以先思考⼀下:你觉得为什么 Kafka 要做这样的设计?为什么使⽤分区的概念⽽不是直接使⽤多个主题呢?其实分区的作⽤就是提供负载均衡的能⼒,或者说对数据进⾏分区的主要原因,就是为了实现系统的⾼伸缩性(Scalability)。

不同的分区能够被放置到不同节点的机器上,⽽数据的读写操作也都是针对分区这个粒度⽽进⾏的,这样每个节点的机器都能独⽴地执⾏各⾃分区的读写请求处理。

并且,我们还可以通过添加新的节点机器来增加整体系统的吞吐量。

分区策略
下⾯我们说说 Kafka ⽣产者的分区策略。

所谓分区策略是决定⽣产者将消息发送到哪个分区的算法。

Kafka 为我们提供了默认的分区策略,同时它也⽀持你⾃定义分区策略。

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

这个参数该怎么设定呢?⽅法很简单,在编写⽣产者程序时,你可以编写⼀个具体的类实现org.apache.kafka.clients.producer.Partitioner接⼝。

这个接⼝也很简单,只定义了两个⽅法:partition()和close(),通常你只需要实现最重要的 partition ⽅法。

我们来看看这个⽅法的⽅法签名:
1 int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);
这⾥的topic、key、keyBytes、value和valueBytes都属于消息数据,cluster则是集群信息(⽐如当前 Kafka 集群共有多少主题、多少 Broker 等)。

Kafka 给你这么多信息,就是希望让你能够充分地利⽤这些信息对消息进⾏分区,计算出它要被发送到哪个分区中。

只要你⾃⼰的实现类定义好了 partition ⽅法,同时设置partitioner.class参数为你⾃⼰实现类的 Full Qualified Name,那么⽣产者程序就会按照你的代码逻辑对消息进⾏分区。

虽说可以有⽆数种分区的可能,但⽐较常见的分区策略也就那么⼏种,下⾯我来详细介绍⼀下。

轮训策略:
也称 Round-robin 策略,即顺序分配。

⽐如⼀个主题下有 3 个分区,那么第⼀条消息被发送到分区 0,第⼆条被发送到分区 1,第三条被发送到分区 2,以此类推。

当⽣产第 4 条消息时⼜会重新开始,即将其分配到分区 0,轮询策略有⾮常优秀的负载均衡表现,它总是能保证消息最⼤限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略,也是我们最常⽤的分区策略之⼀。

随机策略:
也称 Randomness 策略。

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

本质上看随机策略也是⼒求将数据均匀地打散到各个分区,但从实际表现来看,它要逊于轮询策略,所以如果追求数据的均匀分布,还是使⽤轮询策略⽐较好。

事实上,随机策略是⽼版本⽣产者使⽤的分区策略,在新版本中已经改为轮询了。

按消息建保存策略:
Kafka 允许为每条消息定义消息键,简称为 Key。

这个 Key 的作⽤⾮常⼤,它可以是⼀个有着明确业务含义的字符串,⽐如客户代码、部门编号或是业务 ID 等;也可以⽤来表征消息元数据。

特别是在 Kafka 不⽀持时间戳的年代,在⼀些场景中,⼯程师们都是直接将消息创建时间封装进 Key ⾥⾯的。

⼀旦消息被定义了 Key,那么你就可以保证同⼀个 Key 的所有消息都进⼊到相同的分区⾥⾯,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略。

其他分区策略:
上⾯这⼏种分区策略都是⽐较基础的策略,除此之外你还能想到哪些有实际⽤途的分区策略?其实还有⼀种⽐较常见的,即所谓的基于地理位置的分区策略。

当然这种策略⼀般只针对那些⼤规模的 Kafka 集群,特别是跨城市、跨国家甚⾄是跨⼤洲的集群。

压缩算法
说起压缩(compression),我相信你⼀定不会感到陌⽣。

它秉承了⽤时间去换空间的经典 trade-off 思想,具体来说就是⽤ CPU 时间去换磁盘空间或⽹络 I/O 传输量,希望以较⼩的 CPU 开销带来更少的磁盘占⽤或更少的⽹络 I/O 传输。

在 Kafka 中,压缩也是⽤来做这件事的。

今天我就来跟你分享⼀下 Kafka 中压缩的那些事⼉。

怎么压缩
Kafka 是如何压缩消息的呢?要弄清楚这个问题,就要从 Kafka 的消息格式说起了。

⽬前 Kafka 共有两⼤类消息格式,社区分别称之为 V1 版本和 V2 版本。

V2 版本是 Kafka 0.11.0.0 中正式引⼊的。

不论是哪个版本,Kafka 的消息层次都分为两层:消息集合(message set)以及消息(message)。

⼀个消息集合中包含若⼲条⽇志项(record item),⽽⽇志项才是真正封装消息的地⽅。

Kafka 底层的消息⽇志由⼀系列消息集合⽇志项组成。

Kafka 通常不会直接操作具体的⼀条条消息,它总是在消息集合这个层⾯上进⾏写⼊操作。

那么社区引⼊ V2 版本的⽬的是什么呢?V2 版本主要是针对 V1 版本的⼀些弊端做了修正,和我们今天讨论的主题相关的修正有哪些呢?先介绍⼀个,就是把消息的公共部分抽取出来放到外层消息集合⾥⾯,这样就不⽤每条消息都保存这些信息了。

我来举个例⼦。

原来在 V1 版本中,每条消息都需要执⾏ CRC 校验,但有些情况下消息的 CRC 值是会发⽣变化的。

⽐如在 Broker 端可能会对消息时间戳字段进⾏更新,那么重新计算之后的 CRC 值也会相应更新;再⽐如 Broker 端在执⾏消息格式转换时(主要是为了兼容⽼版本客户端程序),也会带来 CRC 值的变化。

鉴于这些情况,再对每条消息都执⾏ CRC 校验就有点没必要了,不仅浪费空间还耽误 CPU 时间,因此在 V2 版本中,消息的 CRC 校验⼯作就被移到了消息集合这⼀层。

V2 版本还有⼀个和压缩息息相关的改进,就是保存压缩消息的⽅法发⽣了变化。

之前 V1 版本中保存压缩消息的⽅法是把多条消息进⾏压缩然后保存到外层消息的消息体字段中;⽽ V2 版本的做法是对整个消息集合进⾏压缩。

显然后者应该⽐前者有更好的压缩效果。

何时压缩
在 Kafka 中,压缩可能发⽣在两个地⽅:⽣产者端和 Broker 端。

⽣产者程序中配置 compression.type 参数即表⽰启⽤指定类型的压缩算法。

⽐如下⾯这段程序代码展⽰了如何构建⼀个开启 GZIP 的 Producer 对象:
1 Properties props = new Properties();
2 props.put("bootstrap.servers", "localhost:9092");
3 props.put("acks", "all");
4 props.put("key.serializer", "mon.serialization.StringSerializer");
5 props.put("value.serializer", "mon.serialization.StringSerializer");
6 // 开启GZIP压缩
7 props.put("compression.type", "gzip");
8
9 Producer<String, String> producer = new KafkaProducer<>(props);
在⽣产者端启⽤压缩是很⾃然的想法,那为什么我说在 Broker 端也可能进⾏压缩呢?其实⼤部分情况下 Broker 从 Producer 端接收到消息后仅仅是原封不动地保存⽽不会对其进⾏任何修改,但这⾥的“⼤部分情况”也是要满⾜⼀定条件的。

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

情况⼀:Broker 端指定了和 Producer 端不同的压缩算法。

你看,这种情况下 Broker 接收到 GZIP 压缩消息后,只能解压缩然后使⽤ Snappy 重新压缩⼀遍。

如果你翻开 Kafka 官⽹,你会发现 Broker 端也有⼀个参数叫 compression.type,和上⾯那个例⼦中的同名。

但是这个参数的默认值是 producer,这表⽰ Broker 端会“尊重”Producer 端使⽤的压缩算法。

可⼀旦你在 Broker 端设置了不同的 compression.type 值,就⼀定要⼩⼼了,因为可能会发⽣预料之外的压缩 / 解压缩操作,通常表现为 Broker 端 CPU 使⽤率飙升。

情况⼆:Broker 端发⽣了消息格式转换。

所谓的消息格式转换主要是为了兼容⽼版本的消费者程序。

还记得之前说过的 V1、V2 版本吧?在⼀个⽣产环境中,Kafka 集群中同时保存多种版本的消息格式⾮常常见。

为了兼容⽼版本的格式,Broker 端会对新版本消息执⾏向⽼版本格式的转换。

这个过程中会涉及消息的解压缩和重新压缩。

⼀般情况下这种消息格式转换对性能是有很⼤影响的,除了这⾥的压缩之外,它还让 Kafka 丧失了引以为豪的 Zero Copy 特性。

何时解压缩
有压缩必有解压缩!通常来说解压缩发⽣在消费者程序中,也就是说 Producer 发送压缩消息到 Broker 后,Broker 照单全收并原样保存起来。

当 Consumer 程序请求这部分消息时,Broker 依然原样发送出去,当消息到达 Consumer 端后,由 Consumer ⾃⾏解压缩还原成之前的消息。

那么现在问题来了,Consumer 怎么知道这些消息是⽤何种压缩算法压缩的呢?其实答案就在消息中。

Kafka 会将启⽤了哪种压缩算法封装进消息集合中,这样当 Consumer 读取到消息集合时,它⾃然就知道了这些消息使⽤的是哪种压缩算法。

如果⽤⼀句话总结⼀下压缩和解压缩,那么我希望你记住这句话:Producer 端压缩、Broker 端保持、Consumer 端解压缩。

除了在 Consumer 端解压缩,Broker 端也会进⾏解压缩。

注意了,这和前⾯提到消息格式转换时发⽣的解压缩是不同的场景。

每个压缩过的消息集合在 Broker 端写⼊时都要发⽣解压缩操作,⽬的就是为了对消息执⾏各种验证。

我们必须承认这种解压缩对 Broker 端性能是有⼀定影响的,特别是对 CPU 的使⽤率⽽⾔。

⽆消息丢失配置
⼀句话概括,Kafka 只对“已提交”的消息(committed message)做有限度的持久化保证。

第⼀个核⼼要素是“已提交的消息”。

什么是已提交的消息?当 Kafka 的若⼲个 Broker 成功地接收到⼀条消息并写⼊到⽇志⽂件后,它们会告诉⽣产者程序这条消息已成功提交。

此时,这条消息在 Kafka 看来就正式变为“已提交”消息了。

那为什么是若⼲个 Broker 呢?这取决于你对“已提交”的定义。

你可以选择只要有⼀个 Broker 成功保存该消息就算是已提交,也可以是令所有 Broker 都成功保存该消息才算是已提交。

不论哪种情况,Kafka 只对已提交的消息做持久化保证这件事情是不变的。

第⼆个核⼼要素就是“有限度的持久化保证”,也就是说 Kafka 不可能保证在任何情况下都做到不丢失消息。

举个极端点的例⼦,如果地球都不存在了,Kafka 还能保存任何消息吗?显然不能!倘若这种情况下你依然还想要 Kafka 不丢消息,那么只能在别的星球部署 Kafka Broker 服务器了。

现在你应该能够稍微体会出这⾥的“有限度”的含义了吧,其实就是说 Kafka 不丢消息是有前提条件的。

假如你的消息保存在 N 个 Kafka Broker 上,那么这个前提条件就是这 N 个 Broker 中⾄少有 1 个存活。

只要这个条件成⽴,Kafka 就能保证你的这条消息永远不会丢失。

总结⼀下,Kafka 是能做到不丢失消息的,只不过这些消息必须是已提交的消息,⽽且还要满⾜⼀定的条件。

当然,说明这件事并不是要为 Kafka 推卸责任,⽽是为了在出现该类问题时我们能够明确责任边界。

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

这种发送⽅式有个有趣的名字,叫“fire and forget”,翻译⼀下就是“发射后不管”。

这个术语原本属于导弹制导领域,后来被借鉴到计算机领域中,它的意思是,执⾏完⼀个操作后不去管它的结果是否成功。

调⽤ producer.send(msg) 就属于典型的“fire and forget”,因此如果出现消息丢失,我们是⽆法知晓的。

这个发送⽅式挺不靠谱吧,不过有些公司真的就是在使⽤这个 API 发送消息。

实际上,解决此问题的⽅法⾮常简单:Producer 永远要使⽤带有回调通知的发送 API,也就是说不要使⽤ producer.send(msg),⽽要使⽤producer.send(msg, callback)。

不要⼩瞧这⾥的 callback(回调),它能准确地告诉你消息是否真的提交成功了。

⼀旦出现消息提交失败的情况,你就可以有针对性地进⾏处理。

消费者丢失消息
Consumer 端丢失数据主要体现在 Consumer 端要消费的消息不见了。

Consumer 程序有个“位移”的概念,表⽰的是这个 Consumer 当前消费到的 Topic 分区的位置。

下⾯这张图来⾃于官⽹,它清晰地展⽰了 Consumer 端的位移数据。

⽐如对于 Consumer A ⽽⾔,它当前的位移值就是 9;Consumer B 的位移值是 11。

Kafka 中 Consumer 端的消息丢失就是这么⼀回事。

要对抗这种消息丢失,办法很简单:维持先消费消息(阅读),再更新位移(书签)的顺序即可。

这样就能最⼤限度地保证消息不丢失。

如果Consumer 程序⾃动地向前更新位移。

假如其中某个线程运⾏失败了,它负责的消息没有被成功处理,但位移已经被更新了,因此这条消息对于 Consumer ⽽⾔实际上是丢失了。

这⾥的关键在于 Consumer ⾃动提交位移,与你没有确认书籍内容被全部读完就将书归还类似,你没有真正地确认消息是否真的被消费就“盲⽬”地更新了位移。

这个问题的解决⽅案也很简单:如果是多线程异步处理消费消息,Consumer 程序不要开启⾃动提交位移,⽽是要应⽤程序⼿动提交位移。

在这⾥我要提醒你⼀下,单个 Consumer 程序使⽤多线程来消费消息说起来容易,写成代码却异常困难,因为你很难正确地处理位移的更新,也就是说避免⽆消费消息丢失很简单,但极易出现消息被消费了多次的情况。

最佳实践
看完这两个案例之后,我来分享⼀下 Kafka ⽆消息丢失的配置,每⼀个其实都能对应上⾯提到的问题。

不要使⽤ producer.send(msg),⽽要使⽤ producer.send(msg, callback)。

记住,⼀定要使⽤带有回调通知的 send ⽅法。

设置 acks = all。

acks 是 Producer 的⼀个参数,代表了你对“已提交”消息的定义。

如果设置成 all,则表明所有副本 Broker 都要接收到消息,该消息才算是“已提交”。

这是最⾼等级的“已提交”定义。

设置 retries 为⼀个较⼤的值。

这⾥的 retries 同样是 Producer 的参数,对应前⾯提到的 Producer ⾃动重试。

当出现⽹络的瞬时抖动时,消息发送可能会失败,此时配置了 retries > 0 的 Producer 能够⾃动重试消息发送,避免消息丢失。

设置 unclean.leader.election.enable = false。

这是 Broker 端的参数,它控制的是哪些 Broker 有资格竞选分区的 Leader。

如果⼀个 Broker 落后原先的 Leader 太多,那么它⼀旦成为新的 Leader,必然会造成消息的丢失。

故⼀般都要将该参数设置成 false,即不允许这种情况的发⽣。

设置 replication.factor >= 3。

这也是 Broker 端的参数。

其实这⾥想表述的是,最好将消息多保存⼏份,毕竟⽬前防⽌消息丢失的主要机制就是冗余。

设置 min.insync.replicas > 1。

这依然是 Broker 端参数,控制的是消息⾄少要被写⼊到多少个副本才算是“已提交”。

设置成⼤于 1 可以提升消息持久性。

在实际环境中千万不要使⽤默认值 1。

确保 replication.factor > min.insync.replicas。

如果两者相等,那么只要有⼀个副本挂机,整个分区就⽆法正常⼯作了。

我们不仅要改善消息的持久性,防⽌数据丢失,还要在不降低可⽤性的基础上完成。

推荐设置成 replication.factor = min.insync.replicas + 1。

确保消息消费完成再提交。

Consumer 端有个参数 mit,最好把它设置成 false,并采⽤⼿动提交位移的⽅式。

就像前⾯说的,这对于单 Consumer 多线程处理的场景⽽⾔是⾄关重要的。

拦截器
Kafka 拦截器分为⽣产者拦截器和消费者拦截器。

⽣产者拦截器允许你在发送消息前以及消息提交成功后植⼊你的拦截器逻辑;⽽消费者拦截器⽀持在消费消息前以及提交位移后编写特定逻辑。

值得⼀提的是,这两种拦截器都⽀持链的⽅式,即你可以将⼀组拦截器串连成⼀个⼤的拦截器,Kafka 会按照添加顺序依次执⾏拦截器逻辑。

举个例⼦,假设你想在⽣产消息前执⾏两个“前置动作”:第⼀个是为消息增加⼀个头信息,封装发送该消息的时间,第⼆个是更新发送消息数字段,那么当你将这两个拦截器串联在⼀起统⼀指定给 Producer 后,Producer 会按顺序执⾏上⾯的动作,然后再发送消息。

当前 Kafka 拦截器的设置⽅法是通过参数配置完成的。

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

拿上⾯的例⼦来说,假设第⼀个拦截器的完整类路径是
com.yourcompany.kafkaproject.interceptors.AddTimeStampInterceptor,第⼆个类是
com.yourcompany.kafkaproject.interceptors.UpdateCounterInterceptor,那么你需要按照以下⽅法在 Producer 端指定拦截器:
1 Properties props = new Properties();
2 List<String> interceptors = new ArrayList<>();
3 interceptors.add("com.yourcompany.kafkaproject.interceptors.AddTimestampInterceptor"); // 拦截器1
4 interceptors.add("com.yourcompany.kafkaproject.interceptors.UpdateCounterInterceptor"); // 拦截器2
5 props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, interceptors);
6 ……
现在问题来了,我们应该怎么编写 AddTimeStampInterceptor 和 UpdateCounterInterceptor 类呢?其实很简单,这两个类以及你⾃⼰编写的所有 Producer 端拦截器实现类都要继承 org.apache.kafka.clients.producer.ProducerInterceptor 接⼝。

该接⼝是 Kafka 提供的,⾥⾯有两个核⼼的⽅法。

onSend:该⽅法会在消息发送之前被调⽤。

如果你想在发送之前对消息“美美容”,这个⽅法是你唯⼀的机会。

onAcknowledgement:该⽅法会在消息成功提交或发送失败之后被调⽤。

还记得我在上⼀期中提到的发送回调通知 callback
吗?onAcknowledgement 的调⽤要早于 callback 的调⽤。

值得注意的是,这个⽅法和 onSend 不是在同⼀个线程中被调⽤的,因此如果你在这两个⽅法中调⽤了某个共享可变对象,⼀定要保证线程安全哦。

还有⼀点很重要,这个⽅法处在 Producer 发送的主路径中,所以最好别放⼀些太重的逻辑进去,否则你会发现你的 Producer TPS 直线下降。

同理,指定消费者拦截器也是同样的⽅法,只是具体的实现类要实现 org.apache.kafka.clients.consumer.ConsumerInterceptor 接⼝,这⾥⾯也有两个核⼼⽅法。

onConsume:该⽅法在消息返回给 Consumer 程序之前调⽤。

也就是说在开始正式处理消息之前,拦截器会先拦⼀道,搞⼀些事情,之后再返回给你。

onCommit:Consumer 在提交位移之后调⽤该⽅法。

通常你可以在该⽅法中做⼀些记账类的动作,⽐如打⽇志等。

⼀定要注意的是,指定拦截器类时要指定它们的全限定名,即 full qualified name。

通俗点说就是要把完整包名也加上,不要只有⼀个类名在那⾥,并且还要保证你的 Producer 程序能够正确加载你的拦截器类。

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

今天 Kafka 默认提供的监控指标都是针对单个客户端或 Broker 的,你很难从具体的消息维度去追踪集群间消息的流转路径。

同时,如何监控⼀条消息从⽣产到最后消费的端到端延时也是很多 Kafka ⽤户迫切需要解决的问题。

从技术上来说,我们可以在客户端程序中增加这样的统计逻辑,但是对于那些将 Kafka 作为企业级基础架构的公司来说,在应⽤代码中编写统⼀的监控逻辑其实是很难的,毕竟这东西⾮常灵活,不太可能提前确定好所有的计算逻辑。

另外,将监控逻辑与主业务逻辑耦合也是软件⼯程中不提倡的做法。

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

这就是 Kafka 拦截器的⼀个⾮常典型的使⽤场景。

我们再来看看消息审计(message audit)的场景。

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

作为私有云的 PaaS 提供⽅,你肯定要能够随时查看每条消息是哪个业务⽅在什么时间发布的,之后⼜被哪些业务⽅在什么时刻消费。

⼀个可⾏的做法就是你编写⼀个拦截器类,实现相应的消息审计逻辑,然后强⾏规定所有接
⼊你的 Kafka 服务的客户端程序必须设置该拦截器。

kafka如何管理TCP连接
Apache Kafka 的所有通信都是基于 TCP 的,⽽不是基于 HTTP 或其他协议。

⽆论是⽣产者、消费者,还是 Broker 之间的通信都是如此。

你可能会问,为什么 Kafka 不使⽤ HTTP 作为底层的通信协议呢?其实这⾥⾯的原因有很多,但最主要的原因在于 TCP 和 HTTP 之间的区别。

从社区的⾓度来看,在开发客户端时,⼈们能够利⽤ TCP 本⾝提供的⼀些⾼级功能,⽐如多路复⽤请求以及同时轮询多个连接的能⼒。

所谓的多路复⽤请求,即 multiplexing request,是指将两个或多个数据流合并到底层单⼀物理连接中的过程。

TCP 的多路复⽤请求会在⼀条物理连接上创建若⼲个虚拟连接,每个虚拟连接负责流转各⾃对应的数据流。

其实严格来说,TCP 并不能多路复⽤,它只是提供可靠的消息交付语义保证,⽐如⾃动重传丢失的报⽂。

更严谨地说,作为⼀个基于报⽂的协议,TCP 能够被⽤于多路复⽤连接场景的前提是,上层的应⽤协议(⽐如 HTTP)允许发送多条消息。

不过,我们今天并不是要详细讨论 TCP 原理,因此你只需要知道这是社区采⽤ TCP 的理由之⼀就⾏了。

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

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

Java⽣产者程序管理tcp
Kafka 的 Java ⽣产者 API 主要的对象就是 KafkaProducer。

通常我们开发⼀个⽣产者的步骤有 4 步。

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

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

第 3 步:使⽤ KafkaProducer 的 send ⽅法发送消息。

第 4 步:调⽤ KafkaProducer 的 close ⽅法关闭⽣产者并释放各种系统资源。

上⾯这 4 步写成 Java 代码的话⼤概是这个样⼦:
1 Properties props = new Properties ();
2 props.put(“参数1”, “参数1的值”);
3 props.put(“参数2”, “参数2的值”);
4 ……
5 try (Producer<String, String> producer = new KafkaProducer<>(props)) {
6 producer.send(new ProducerRecord<String, String>(……), callback);
7 ……
8 }
这段代码使⽤了 Java 7 提供的 try-with-resource 特性,所以并没有显式调⽤ producer.close() ⽅法。

⽆论是否显式调⽤ close ⽅法,所有⽣产者程序⼤致都是这个路数。

现在问题来了,当我们开发⼀个 Producer 应⽤时,⽣产者会向 Kafka 集群中指定的主题(Topic)发送消息,这必然涉及与 Kafka Broker 创建 TCP 连接。

那么,Kafka 的 Producer 客户端是如何管理这些 TCP 连接的呢?
要回答上⾯这个问题,我们⾸先要弄明⽩⽣产者代码是什么时候创建 TCP 连接的。

⾸先,⽣产者应⽤在创建 KafkaProducer 实例时是会建⽴与 Broker 的 TCP 连接的。

其实这种表述也不是很准确,应该这样说:在创建KafkaProducer 实例时,⽣产者应⽤会在后台创建并启动⼀个名为 Sender 的线程,该 Sender 线程开始运⾏时⾸先会创建与 Broker 的连接。

我截取了⼀段测试环境中的⽇志来说明这⼀点:
你也许会问:怎么可能是这样?如果不调⽤ 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。

针对 TCP 连接何时创建的问题,⽬前我们的结论是这样的:TCP 连接是在创建 KafkaProducer 实例时建⽴的。

那么,我们想问的是,它只会在这个时候被创建吗?当然不是!TCP 连接还可能在两个地⽅被创建:⼀个是在更新元数据后,另⼀个是在消息发送时。

为什么说是可能?因为这两个地⽅并⾮总是创建 TCP 连接。

当 Producer 更新了集群的元数据信息之后,如果发现与某些 Broker 当前没有连接,那么它就会创建⼀个 TCP 连接。

同样地,当要发送消息时,Producer 发现尚不存在与⽬标 Broker 的连接,也会创建⼀个。

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

场景⼀:当 Producer 尝试给⼀个不存在的主题发送消息时,Broker 会告诉 Producer 说这个主题不存在。

此时 Producer 会发送 METADATA 请求给 Kafka 集群,去尝试获取最新的元数据信息。

场景⼆:Producer 通过 metadata.max.age.ms 参数定期地去更新元数据信息。

该参数的默认值是 300000,即 5 分钟,也就是说不管集群那边是否有变化,Producer 每 5 分钟都会强制刷新⼀次元数据以保证它是最及时的数据。

说完了 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 端没有机会显式地观测到此连接已被中断。

Java消费者程序管理tcp
我们先从消费者创建 TCP 连接开始讨论。

消费者端主要的程序⼊⼝是 KafkaConsumer 类。

和⽣产者不同的是,构建 KafkaConsumer 实例时是不会创建任何 TCP 连接的,也就是说,当你执⾏完 new KafkaConsumer(properties) 语句后,你会发现,没有 Socket 连接被创建出来。

这⼀点和 Java ⽣产者是有区别的,主要原因就是⽣产者⼊⼝类 KafkaProducer 在构建实例的时候,会在后台默默地启动⼀个 Sender 线程,这个Sender 线程负责 Socket 连接的创建。

从这⼀点上来看,我个⼈认为 KafkaConsumer 的设计⽐ KafkaProducer 要好。

就像我在第 13 讲中所说的,在 Java 构造函数中启动线程,会造成 this 指针的逃逸,这始终是⼀个隐患。

如果 Socket 不是在构造函数中创建的,那么是在。

相关文档
最新文档