mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
del
This commit is contained in:
122
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/09 | 生产者消息分区机制原理剖析.md
Normal file
122
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/09 | 生产者消息分区机制原理剖析.md
Normal file
@@ -0,0 +1,122 @@
|
||||
<audio id="audio" title="09 | 生产者消息分区机制原理剖析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/83/352b301194fa9466be5587682fd91c83.mp3"></audio>
|
||||
|
||||
我们在使用Apache Kafka生产和消费消息的时候,肯定是希望能够将数据均匀地分配到所有服务器上。比如很多公司使用Kafka收集应用服务器的日志数据,这种数据都是很多的,特别是对于那种大批量机器组成的集群环境,每分钟产生的日志量都能以GB数,因此如何将这么大的数据量均匀地分配到Kafka的各个Broker上,就成为一个非常重要的问题。
|
||||
|
||||
今天我就来和你说说Kafka生产者如何实现这个需求,我会以Java API为例进行分析,但实际上其他语言的实现逻辑也是类似的。
|
||||
|
||||
## 为什么分区?
|
||||
|
||||
如果你对Kafka分区(Partition)的概念还不熟悉,可以先返回专栏[第2期](https://time.geekbang.org/column/article/99318)回顾一下。专栏前面我说过Kafka有主题(Topic)的概念,它是承载真实数据的逻辑容器,而在主题之下还分为若干个分区,也就是说Kafka的消息组织方式实际上是三级结构:主题-分区-消息。主题下的每条消息只会保存在某一个分区中,而不会在多个分区中被保存多份。官网上的这张图非常清晰地展示了Kafka的三级结构,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/51/a9fde3dd19a6ea5dc7e7e3d1f42ffa51.jpg" alt="">
|
||||
|
||||
现在我抛出一个问题你可以先思考一下:你觉得为什么Kafka要做这样的设计?为什么使用分区的概念而不是直接使用多个主题呢?
|
||||
|
||||
其实分区的作用就是提供负载均衡的能力,或者说对数据进行分区的主要原因,就是为了实现系统的高伸缩性(Scalability)。不同的分区能够被放置到不同节点的机器上,而数据的读写操作也都是针对分区这个粒度而进行的,这样每个节点的机器都能独立地执行各自分区的读写请求处理。并且,我们还可以通过添加新的节点机器来增加整体系统的吞吐量。
|
||||
|
||||
实际上分区的概念以及分区数据库早在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等)。Kafka给你这么多信息,就是希望让你能够充分地利用这些信息对消息进行分区,计算出它要被发送到哪个分区中。只要你自己的实现类定义好了partition方法,同时设置`partitioner.class`参数为你自己实现类的Full Qualified Name,那么生产者程序就会按照你的代码逻辑对消息进行分区。虽说可以有无数种分区的可能,但比较常见的分区策略也就那么几种,下面我来详细介绍一下。
|
||||
|
||||
**轮询策略**
|
||||
|
||||
也称Round-robin策略,即顺序分配。比如一个主题下有3个分区,那么第一条消息被发送到分区0,第二条被发送到分区1,第三条被发送到分区2,以此类推。当生产第4条消息时又会重新开始,即将其分配到分区0,就像下面这张图展示的那样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/e2/bed44c33d6707c0028cc3f14207ea6e2.jpg" alt="">
|
||||
|
||||
这就是所谓的轮询策略。轮询策略是Kafka Java生产者API默认提供的分区策略。如果你未指定`partitioner.class`参数,那么你的生产者程序会按照轮询的方式在主题的所有分区间均匀地“码放”消息。
|
||||
|
||||
**轮询策略有非常优秀的负载均衡表现,它总是能保证消息最大限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略,也是我们最常用的分区策略之一。**
|
||||
|
||||
**随机策略**
|
||||
|
||||
也称Randomness策略。所谓随机就是我们随意地将消息放置到任意一个分区上,如下面这张图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/ff/97fd864312f804bf414001c2f9228aff.jpg" alt="">
|
||||
|
||||
如果要实现随机策略版的partition方法,很简单,只需要两行代码即可:
|
||||
|
||||
```
|
||||
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
|
||||
return ThreadLocalRandom.current().nextInt(partitions.size());
|
||||
|
||||
```
|
||||
|
||||
先计算出该主题总的分区数,然后随机地返回一个小于它的正整数。
|
||||
|
||||
本质上看随机策略也是力求将数据均匀地打散到各个分区,但从实际表现来看,它要逊于轮询策略,所以**如果追求数据的均匀分布,还是使用轮询策略比较好**。事实上,随机策略是老版本生产者使用的分区策略,在新版本中已经改为轮询了。
|
||||
|
||||
**按消息键保序策略**
|
||||
|
||||
也称Key-ordering策略。有点尴尬的是,这个名词是我自己编的,Kafka官网上并无这样的提法。
|
||||
|
||||
Kafka允许为每条消息定义消息键,简称为Key。这个Key的作用非常大,它可以是一个有着明确业务含义的字符串,比如客户代码、部门编号或是业务ID等;也可以用来表征消息元数据。特别是在Kafka不支持时间戳的年代,在一些场景中,工程师们都是直接将消息创建时间封装进Key里面的。一旦消息被定义了Key,那么你就可以保证同一个Key的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/a8/cf7383078f4c7c022c1113c096d5d5a8.jpg" alt="">
|
||||
|
||||
实现这个策略的partition方法同样简单,只需要下面两行代码即可:
|
||||
|
||||
```
|
||||
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
|
||||
return Math.abs(key.hashCode()) % partitions.size();
|
||||
|
||||
```
|
||||
|
||||
前面提到的Kafka默认分区策略实际上同时实现了两种策略:如果指定了Key,那么默认实现按消息键保序策略;如果没有指定Key,则使用轮询策略。
|
||||
|
||||
在你了解了Kafka默认的分区策略之后,我来给你讲一个真实的案例,希望能加强你对分区策略重要性的理解。
|
||||
|
||||
我曾经给一个国企进行过Kafka培训,当时碰到的一个问题就是如何实现消息的顺序问题。这家企业发送的Kafka的消息是有因果关系的,故处理因果关系也必须要保证有序性,否则先处理了“果”后处理“因”必然造成业务上的混乱。
|
||||
|
||||
当时那家企业的做法是给Kafka主题设置单分区,也就是1个分区。这样所有的消息都只在这一个分区内读写,因此保证了全局的顺序性。这样做虽然实现了因果关系的顺序性,但也丧失了Kafka多分区带来的高吞吐量和负载均衡的优势。
|
||||
|
||||
后来经过了解和调研,我发现这种具有因果关系的消息都有一定的特点,比如在消息体中都封装了固定的标志位,后来我就建议他们对此标志位设定专门的分区策略,保证同一标志位的所有消息都发送到同一分区,这样既可以保证分区内的消息顺序,也可以享受到多分区带来的性能红利。
|
||||
|
||||
这种基于个别字段的分区策略本质上就是按消息键保序的思想,其实更加合适的做法是把标志位数据提取出来统一放到Key中,这样更加符合Kafka的设计思想。经过改造之后,这个企业的消息处理吞吐量一下提升了40多倍,从这个案例你也可以看到自定制分区策略的效果可见一斑。
|
||||
|
||||
**其他分区策略**
|
||||
|
||||
上面这几种分区策略都是比较基础的策略,除此之外你还能想到哪些有实际用途的分区策略?其实还有一种比较常见的,即所谓的基于地理位置的分区策略。当然这种策略一般只针对那些大规模的Kafka集群,特别是跨城市、跨国家甚至是跨大洲的集群。
|
||||
|
||||
我就拿“极客时间”举个例子吧,假设极客时间的所有服务都部署在北京的一个机房(这里我假设它是自建机房,不考虑公有云方案。其实即使是公有云,实现逻辑也差不多),现在极客时间考虑在南方找个城市(比如广州)再创建一个机房;另外从两个机房中选取一部分机器共同组成一个大的Kafka集群。显然,这个集群中必然有一部分机器在北京,另外一部分机器在广州。
|
||||
|
||||
假设极客时间计划为每个新注册用户提供一份注册礼品,比如南方的用户注册极客时间可以免费得到一碗“甜豆腐脑”,而北方的新注册用户可以得到一碗“咸豆腐脑”。如果用Kafka来实现则很简单,只需要创建一个双分区的主题,然后再创建两个消费者程序分别处理南北方注册用户逻辑即可。
|
||||
|
||||
但问题是你需要把南北方注册用户的注册消息正确地发送到位于南北方的不同机房中,因为处理这些消息的消费者程序只可能在某一个机房中启动着。换句话说,送甜豆腐脑的消费者程序只在广州机房启动着,而送咸豆腐脑的程序只在北京的机房中,如果你向广州机房中的Broker发送北方注册用户的消息,那么这个用户将无法得到礼品!
|
||||
|
||||
此时我们就可以根据Broker所在的IP地址实现定制化的分区策略。比如下面这段代码:
|
||||
|
||||
```
|
||||
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
|
||||
return partitions.stream().filter(p -> isSouth(p.leader().host())).map(PartitionInfo::partition).findAny().get();
|
||||
|
||||
```
|
||||
|
||||
我们可以从所有分区中找出那些Leader副本在南方的所有分区,然后随机挑选一个进行消息发送。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们讨论了Kafka生产者消息分区的机制以及常见的几种分区策略。切记分区是实现负载均衡以及高吞吐量的关键,故在生产者这一端就要仔细盘算合适的分区策略,避免造成消息数据的“倾斜”,使得某些分区成为性能瓶颈,这样极易引发下游数据消费的性能下降。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/13/fb38053d6f7f880ab12fef7ee0d64813.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
在你的生产环境中使用最多的是哪种消息分区策略?实际在使用过程中遇到过哪些“坑”?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
112
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/10 | 生产者压缩算法面面观.md
Normal file
112
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/10 | 生产者压缩算法面面观.md
Normal file
@@ -0,0 +1,112 @@
|
||||
<audio id="audio" title="10 | 生产者压缩算法面面观" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/68/e9/68593a3334a47b30eb9c3846cb889ae9.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的内容是:生产者压缩算法面面观。
|
||||
|
||||
说起压缩(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版本的做法是对整个消息集合进行压缩。显然后者应该比前者有更好的压缩效果。
|
||||
|
||||
我对两个版本分别做了一个简单的测试,结果显示,在相同条件下,不论是否启用压缩,V2版本都比V1版本节省磁盘空间。当启用压缩时,这种节省空间的效果更加明显,就像下面这两张图展示的那样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/21/11ddc5575eb6e799f456515c75e1d821.png" alt="">
|
||||
|
||||
## 何时压缩?
|
||||
|
||||
在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压缩
|
||||
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重新压缩消息。
|
||||
|
||||
情况一:Broker端指定了和Producer端不同的压缩算法。
|
||||
|
||||
先看一个例子。想象这样一个对话。
|
||||
|
||||
Producer说:“我要使用GZIP进行压缩。”
|
||||
|
||||
Broker说:“不好意思,我这边接收的消息必须使用Snappy算法进行压缩。”
|
||||
|
||||
你看,这种情况下Broker接收到GZIP压缩消息后,只能解压缩然后使用Snappy重新压缩一遍。如果你翻开Kafka官网,你会发现Broker端也有一个参数叫compression.type,和上面那个例子中的同名。但是这个参数的默认值是producer,这表示Broker端会“尊重”Producer端使用的压缩算法。可一旦你在Broker端设置了不同的compression.type值,就一定要小心了,因为可能会发生预料之外的压缩/解压缩操作,通常表现为Broker端CPU使用率飙升。
|
||||
|
||||
情况二:Broker端发生了消息格式转换。
|
||||
|
||||
所谓的消息格式转换主要是为了兼容老版本的消费者程序。还记得之前说过的V1、V2版本吧?在一个生产环境中,Kafka集群中同时保存多种版本的消息格式非常常见。为了兼容老版本的格式,Broker端会对新版本消息执行向老版本格式的转换。这个过程中会涉及消息的解压缩和重新压缩。一般情况下这种消息格式转换对性能是有很大影响的,除了这里的压缩之外,它还让Kafka丧失了引以为豪的Zero Copy特性。
|
||||
|
||||
所谓“Zero Copy”就是“零拷贝”,我在专栏[第6期](https://time.geekbang.org/column/article/101107)提到过,说的是当数据在磁盘和网络进行传输时避免昂贵的内核态数据拷贝,从而实现快速的数据传输。因此如果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%。不过有些遗憾的是,目前社区并未采纳这个建议,原因就是这种消息校验是非常重要的,不可盲目去之。毕竟先把事情做对是最重要的,在做对的基础上,再考虑把事情做好做快。针对这个使用场景,你也可以思考一下,是否有一个两全其美的方案,既能避免消息解压缩也能对消息执行校验。
|
||||
|
||||
## **各种压缩算法对比**
|
||||
|
||||
那么我们来谈谈压缩算法。这可是重头戏!之前说了这么多,我们还是要比较一下各个压缩算法的优劣,这样我们才能有针对性地配置适合我们业务的压缩策略。
|
||||
|
||||
在Kafka 2.1.0版本之前,Kafka支持3种压缩算法:GZIP、Snappy和LZ4。从2.1.0开始,Kafka正式支持Zstandard算法(简写为zstd)。它是Facebook开源的一个压缩算法,能够提供超高的压缩比(compression ratio)。
|
||||
|
||||
对了,看一个压缩算法的优劣,有两个重要的指标:一个指标是压缩比,原先占100份空间的东西经压缩之后变成了占20份空间,那么压缩比就是5,显然压缩比越高越好;另一个指标就是压缩/解压缩吞吐量,比如每秒能压缩或解压缩多少MB的数据。同样地,吞吐量也是越高越好。
|
||||
|
||||
下面这张表是Facebook Zstandard官网提供的一份压缩算法benchmark比较结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/68/cfe20a2cdcb1ae3b304777f7be928068.png" alt="">
|
||||
|
||||
从表中我们可以发现zstd算法有着最高的压缩比,而在吞吐量上的表现只能说中规中矩。反观LZ4算法,它在吞吐量方面则是毫无疑问的执牛耳者。当然对于表格中数据的权威性我不做过多解读,只想用它来说明一下当前各种压缩算法的大致表现。
|
||||
|
||||
在实际使用中,GZIP、Snappy、LZ4甚至是zstd的表现各有千秋。但对于Kafka而言,它们的性能测试结果却出奇得一致,即在吞吐量方面:LZ4 > Snappy > zstd和GZIP;而在压缩比方面,zstd > LZ4 > GZIP > Snappy。具体到物理资源,使用Snappy算法占用的网络带宽最多,zstd最少,这是合理的,毕竟zstd就是要提供超高的压缩比;在CPU使用率方面,各个算法表现得差不多,只是在压缩时Snappy算法使用的CPU较多一些,而在解压缩时GZIP算法则可能使用更多的CPU。
|
||||
|
||||
## **最佳实践**
|
||||
|
||||
了解了这些算法对比,我们就能根据自身的实际情况有针对性地启用合适的压缩算法。
|
||||
|
||||
首先来说压缩。何时启用压缩是比较合适的时机呢?
|
||||
|
||||
你现在已经知道Producer端完成的压缩,那么启用压缩的一个条件就是Producer程序运行机器上的CPU资源要很充足。如果Producer运行机器本身CPU已经消耗殆尽了,那么启用消息压缩无疑是雪上加霜,只会适得其反。
|
||||
|
||||
除了CPU资源充足这一条件,如果你的环境中带宽资源有限,那么我也建议你开启压缩。事实上我见过的很多Kafka生产环境都遭遇过带宽被打满的情况。这年头,带宽可是比CPU和内存还要珍贵的稀缺资源,毕竟万兆网络还不是普通公司的标配,因此千兆网络中Kafka集群带宽资源耗尽这件事情就特别容易出现。如果你的客户端机器CPU资源有很多富余,我强烈建议你开启zstd压缩,这样能极大地节省网络资源消耗。
|
||||
|
||||
其次说说解压缩。其实也没什么可说的。一旦启用压缩,解压缩是不可避免的事情。这里只想强调一点:我们对不可抗拒的解压缩无能为力,但至少能规避掉那些意料之外的解压缩。就像我前面说的,因为要兼容老版本而引入的解压缩操作就属于这类。有条件的话尽量保证不要出现消息格式转换的情况。
|
||||
|
||||
## 小结
|
||||
|
||||
总结一下今天分享的内容:我们主要讨论了Kafka压缩的各个方面,包括Kafka是如何对消息进行压缩的、何时进行压缩及解压缩,还对比了目前Kafka支持的几个压缩算法,最后我给出了工程化的最佳实践。分享这么多内容,我就只有一个目的:就是希望你能根据自身的实际情况恰当地选择合适的Kafka压缩算法,以求实现最大的资源利用率。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/df/ab1578f5b970c08b9ec524c0304bbedf.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
最后给出一道作业题,请花时间思考一下:前面我们提到了Broker要对压缩消息集合执行解压缩操作,然后逐条对消息进行校验,有人提出了一个方案:把这种消息校验移到Producer端来做,Broker直接读取校验结果即可,这样就可以避免在Broker端执行解压缩操作。你认同这种方案吗?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
115
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/11 | 无消息丢失配置怎么实现?.md
Normal file
115
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/11 | 无消息丢失配置怎么实现?.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<audio id="audio" title="11 | 无消息丢失配置怎么实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/d5/4befbe3ae4c84e0b1ad3f12efdcab2d5.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的主题是:如何配置Kafka无消息丢失。
|
||||
|
||||
一直以来,很多人对于Kafka丢失消息这件事情都有着自己的理解,因而也就有着自己的解决之道。在讨论具体的应对方法之前,我觉得我们首先要明确,在Kafka的世界里什么才算是消息丢失,或者说Kafka在什么情况下能保证消息不丢失。这点非常关键,因为很多时候我们容易混淆责任的边界,如果搞不清楚事情由谁负责,自然也就不知道由谁来出解决方案了。
|
||||
|
||||
那Kafka到底在什么情况下才能保证消息不丢失呢?
|
||||
|
||||
**一句话概括,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是怎样做到不丢失消息的,那接下来我带你复盘一下那些常见的“Kafka消息丢失”案例。注意,这里可是带引号的消息丢失哦,其实有些时候我们只是冤枉了Kafka而已。
|
||||
|
||||
**案例1:生产者程序丢失数据**
|
||||
|
||||
Producer程序丢失消息,这应该算是被抱怨最多的数据丢失场景了。我来描述一个场景:你写了一个Producer应用向Kafka发送消息,最后发现Kafka没有保存,于是大骂:“Kafka真烂,消息发送居然都能丢失,而且还不告诉我?!”如果你有过这样的经历,那么请先消消气,我们来分析下可能的原因。
|
||||
|
||||
目前Kafka Producer是异步发送消息的,也就是说如果你调用的是producer.send(msg)这个API,那么它通常会立即返回,但此时你不能认为消息发送已成功完成。
|
||||
|
||||
这种发送方式有个有趣的名字,叫“fire and forget”,翻译一下就是“发射后不管”。这个术语原本属于导弹制导领域,后来被借鉴到计算机领域中,它的意思是,执行完一个操作后不去管它的结果是否成功。调用producer.send(msg)就属于典型的“fire and forget”,因此如果出现消息丢失,我们是无法知晓的。这个发送方式挺不靠谱吧,不过有些公司真的就是在使用这个API发送消息。
|
||||
|
||||
如果用这个方式,可能会有哪些因素导致消息没有发送成功呢?其实原因有很多,例如网络抖动,导致消息压根就没有发送到Broker端;或者消息本身不合格导致Broker拒绝接收(比如消息太大了,超过了Broker的承受能力)等。这么来看,让Kafka“背锅”就有点冤枉它了。就像前面说过的,Kafka不认为消息是已提交的,因此也就没有Kafka丢失消息这一说了。
|
||||
|
||||
不过,就算不是Kafka的“锅”,我们也要解决这个问题吧。实际上,解决此问题的方法非常简单:**Producer永远要使用带有回调通知的发送API,也就是说不要使用producer.send(msg),而要使用producer.send(msg, callback)**。不要小瞧这里的callback(回调),它能准确地告诉你消息是否真的提交成功了。一旦出现消息提交失败的情况,你就可以有针对性地进行处理。
|
||||
|
||||
举例来说,如果是因为那些瞬时错误,那么仅仅让Producer重试就可以了;如果是消息不合格造成的,那么可以调整消息格式后再次发送。总之,处理发送失败的责任在Producer端而非Broker端。
|
||||
|
||||
你可能会问,发送失败真的没可能是由Broker端的问题造成的吗?当然可能!如果你所有的Broker都宕机了,那么无论Producer端怎么重试都会失败的,此时你要做的是赶快处理Broker端的问题。但之前说的核心论据在这里依然是成立的:Kafka依然不认为这条消息属于已提交消息,故对它不做任何持久化保证。
|
||||
|
||||
**案例2:消费者程序丢失数据**
|
||||
|
||||
Consumer端丢失数据主要体现在Consumer端要消费的消息不见了。Consumer程序有个“位移”的概念,表示的是这个Consumer当前消费到的Topic分区的位置。下面这张图来自于官网,它清晰地展示了Consumer端的位移数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/37/0c97bed3b6350d73a9403d9448290d37.png" alt="">
|
||||
|
||||
比如对于Consumer A而言,它当前的位移值就是9;Consumer B的位移值是11。
|
||||
|
||||
这里的“位移”类似于我们看书时使用的书签,它会标记我们当前阅读了多少页,下次翻书的时候我们能直接跳到书签页继续阅读。
|
||||
|
||||
正确使用书签有两个步骤:第一步是读书,第二步是更新书签页。如果这两步的顺序颠倒了,就可能出现这样的场景:当前的书签页是第90页,我先将书签放到第100页上,之后开始读书。当阅读到第95页时,我临时有事中止了阅读。那么问题来了,当我下次直接跳到书签页阅读时,我就丢失了第96~99页的内容,即这些消息就丢失了。
|
||||
|
||||
同理,Kafka中Consumer端的消息丢失就是这么一回事。要对抗这种消息丢失,办法很简单:**维持先消费消息(阅读),再更新位移(书签)的顺序**即可。这样就能最大限度地保证消息不丢失。
|
||||
|
||||
当然,这种处理方式可能带来的问题是消息的重复处理,类似于同一页书被读了很多遍,但这不属于消息丢失的情形。在专栏后面的内容中,我会跟你分享如何应对重复消费的问题。
|
||||
|
||||
除了上面所说的场景,其实还存在一种比较隐蔽的消息丢失场景。
|
||||
|
||||
我们依然以看书为例。假设你花钱从网上租借了一本共有10章内容的电子书,该电子书的有效阅读时间是1天,过期后该电子书就无法打开,但如果在1天之内你完成阅读就退还租金。
|
||||
|
||||
为了加快阅读速度,你把书中的10个章节分别委托给你的10个朋友,请他们帮你阅读,并拜托他们告诉你主旨大意。当电子书临近过期时,这10个人告诉你说他们读完了自己所负责的那个章节的内容,于是你放心地把该书还了回去。不料,在这10个人向你描述主旨大意时,你突然发现有一个人对你撒了谎,他并没有看完他负责的那个章节。那么很显然,你无法知道那一章的内容了。
|
||||
|
||||
对于Kafka而言,这就好比Consumer程序从Kafka获取到消息后开启了多个线程异步处理消息,而Consumer程序自动地向前更新位移。假如其中某个线程运行失败了,它负责的消息没有被成功处理,但位移已经被更新了,因此这条消息对于Consumer而言实际上是丢失了。
|
||||
|
||||
这里的关键在于Consumer自动提交位移,与你没有确认书籍内容被全部读完就将书归还类似,你没有真正地确认消息是否真的被消费就“盲目”地更新了位移。
|
||||
|
||||
这个问题的解决方案也很简单:**如果是多线程异步处理消费消息,Consumer程序不要开启自动提交位移,而是要应用程序手动提交位移**。在这里我要提醒你一下,单个Consumer程序使用多线程来消费消息说起来容易,写成代码却异常困难,因为你很难正确地处理位移的更新,也就是说避免无消费消息丢失很简单,但极易出现消息被消费了多次的情况。
|
||||
|
||||
## **最佳实践**
|
||||
|
||||
看完这两个案例之后,我来分享一下Kafka无消息丢失的配置,每一个其实都能对应上面提到的问题。
|
||||
|
||||
<li>
|
||||
不要使用producer.send(msg),而要使用producer.send(msg, callback)。记住,一定要使用带有回调通知的send方法。
|
||||
</li>
|
||||
<li>
|
||||
设置acks = all。acks是Producer的一个参数,代表了你对“已提交”消息的定义。如果设置成all,则表明所有副本Broker都要接收到消息,该消息才算是“已提交”。这是最高等级的“已提交”定义。
|
||||
</li>
|
||||
<li>
|
||||
设置retries为一个较大的值。这里的retries同样是Producer的参数,对应前面提到的Producer自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了retries > 0的Producer能够自动重试消息发送,避免消息丢失。
|
||||
</li>
|
||||
<li>
|
||||
设置unclean.leader.election.enable = false。这是Broker端的参数,它控制的是哪些Broker有资格竞选分区的Leader。如果一个Broker落后原先的Leader太多,那么它一旦成为新的Leader,必然会造成消息的丢失。故一般都要将该参数设置成false,即不允许这种情况的发生。
|
||||
</li>
|
||||
<li>
|
||||
设置replication.factor >= 3。这也是Broker端的参数。其实这里想表述的是,最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余。
|
||||
</li>
|
||||
<li>
|
||||
设置min.insync.replicas > 1。这依然是Broker端参数,控制的是消息至少要被写入到多少个副本才算是“已提交”。设置成大于1可以提升消息持久性。在实际环境中千万不要使用默认值1。
|
||||
</li>
|
||||
<li>
|
||||
确保replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成replication.factor = min.insync.replicas + 1。
|
||||
</li>
|
||||
<li>
|
||||
确保消息消费完成再提交。Consumer端有个参数enable.auto.commit,最好把它设置成false,并采用手动提交位移的方式。就像前面说的,这对于单Consumer多线程处理的场景而言是至关重要的。
|
||||
</li>
|
||||
|
||||
## **小结**
|
||||
|
||||
今天,我们讨论了Kafka无消息丢失的方方面面。我们先从什么是消息丢失开始说起,明确了Kafka持久化保证的责任边界,随后以这个规则为标尺衡量了一些常见的数据丢失场景,最后通过分析这些场景,我给出了Kafka无消息丢失的“最佳实践”。总结起来,我希望你今天能有两个收获:
|
||||
|
||||
- 明确Kafka持久化保证的含义和限定条件。
|
||||
- 熟练配置Kafka无消息丢失参数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/5a/3fc09aa33dc1022e867df4930054ce5a.jpg" alt="">
|
||||
|
||||
## **开放讨论**
|
||||
|
||||
其实,Kafka还有一种特别隐秘的消息丢失场景:增加主题分区。当增加主题分区后,在某段“不凑巧”的时间间隔后,Producer先于Consumer感知到新增加的分区,而Consumer设置的是“从最新位移处”开始读取消息,因此在Consumer感知到新分区前,Producer发送的这些消息就全部“丢失”了,或者说Consumer无法读取到这些消息。严格来说这是Kafka设计上的一个小缺陷,你有什么解决的办法吗?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
178
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/12 | 客户端都有哪些不常见但是很高级的功能?.md
Normal file
178
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/12 | 客户端都有哪些不常见但是很高级的功能?.md
Normal file
@@ -0,0 +1,178 @@
|
||||
<audio id="audio" title="12 | 客户端都有哪些不常见但是很高级的功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/20/a1ad5d91dae66a37ed372d09fd335f20.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的主题是:客户端都有哪些不常见但是很高级的功能。
|
||||
|
||||
既然是不常见,那就说明在实际场景中并没有太高的出场率,但它们依然是很高级很实用的。下面就有请今天的主角登场:Kafka拦截器。
|
||||
|
||||
## **什么是拦截器?**
|
||||
|
||||
如果你用过Spring Interceptor或是Apache Flume,那么应该不会对拦截器这个概念感到陌生,其基本思想就是允许应用程序在不修改逻辑的情况下,动态地实现一组可插拔的事件处理逻辑链。它能够在主业务操作的前后多个时间点上插入对应的“拦截”逻辑。下面这张图展示了Spring MVC拦截器的工作原理:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/c4/096831a3ba037b3f9e507e6db631d3c4.png" alt="">
|
||||
|
||||
拦截器1和拦截器2分别在请求发送之前、发送之后以及完成之后三个地方插入了对应的处理逻辑。而Flume中的拦截器也是同理,它们插入的逻辑可以是修改待发送的消息,也可以是创建新的消息,甚至是丢弃消息。这些功能都是以配置拦截器类的方式动态插入到应用程序中的,故可以快速地切换不同的拦截器而不影响主程序逻辑。
|
||||
|
||||
Kafka拦截器借鉴了这样的设计思路。你可以在消息处理的前后多个时点动态植入不同的处理逻辑,比如在消息发送前或者在消息被消费后。
|
||||
|
||||
作为一个非常小众的功能,Kafka拦截器自0.10.0.0版本被引入后并未得到太多的实际应用,我也从未在任何Kafka技术峰会上看到有公司分享其使用拦截器的成功案例。但即便如此,在自己的Kafka工具箱中放入这么一个有用的东西依然是值得的。今天我们就让它来发挥威力,展示一些非常酷炫的功能。
|
||||
|
||||
## **Kafka拦截器**
|
||||
|
||||
**Kafka拦截器分为生产者拦截器和消费者拦截器**。生产者拦截器允许你在发送消息前以及消息提交成功后植入你的拦截器逻辑;而消费者拦截器支持在消费消息前以及提交位移后编写特定逻辑。值得一提的是,这两种拦截器都支持链的方式,即你可以将一组拦截器串连成一个大的拦截器,Kafka会按照添加顺序依次执行拦截器逻辑。
|
||||
|
||||
举个例子,假设你想在生产消息前执行两个“前置动作”:第一个是为消息增加一个头信息,封装发送该消息的时间,第二个是更新发送消息数字段,那么当你将这两个拦截器串联在一起统一指定给Producer后,Producer会按顺序执行上面的动作,然后再发送消息。
|
||||
|
||||
当前Kafka拦截器的设置方法是通过参数配置完成的。生产者和消费者两端有一个相同的参数,名字叫interceptor.classes,它指定的是一组类的列表,每个类就是特定逻辑的拦截器实现类。拿上面的例子来说,假设第一个拦截器的完整类路径是com.yourcompany.kafkaproject.interceptors.AddTimeStampInterceptor,第二个类是com.yourcompany.kafkaproject.interceptors.UpdateCounterInterceptor,那么你需要按照以下方法在Producer端指定拦截器:
|
||||
|
||||
```
|
||||
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);
|
||||
……
|
||||
|
||||
```
|
||||
|
||||
现在问题来了,我们应该怎么编写AddTimeStampInterceptor和UpdateCounterInterceptor类呢?其实很简单,这两个类以及你自己编写的所有Producer端拦截器实现类都要继承org.apache.kafka.clients.producer.ProducerInterceptor接口。该接口是Kafka提供的,里面有两个核心的方法。
|
||||
|
||||
<li>
|
||||
onSend:该方法会在消息发送之前被调用。如果你想在发送之前对消息“美美容”,这个方法是你唯一的机会。
|
||||
</li>
|
||||
<li>
|
||||
onAcknowledgement:该方法会在消息成功提交或发送失败之后被调用。还记得我在上一期中提到的发送回调通知callback吗?onAcknowledgement的调用要早于callback的调用。值得注意的是,这个方法和onSend不是在同一个线程中被调用的,因此如果你在这两个方法中调用了某个共享可变对象,一定要保证线程安全哦。还有一点很重要,这个方法处在Producer发送的主路径中,所以最好别放一些太重的逻辑进去,否则你会发现你的Producer TPS直线下降。
|
||||
</li>
|
||||
|
||||
同理,指定消费者拦截器也是同样的方法,只是具体的实现类要实现org.apache.kafka.clients.consumer.ConsumerInterceptor接口,这里面也有两个核心方法。
|
||||
|
||||
<li>
|
||||
onConsume:该方法在消息返回给Consumer程序之前调用。也就是说在开始正式处理消息之前,拦截器会先拦一道,搞一些事情,之后再返回给你。
|
||||
</li>
|
||||
<li>
|
||||
onCommit:Consumer在提交位移之后调用该方法。通常你可以在该方法中做一些记账类的动作,比如打日志等。
|
||||
</li>
|
||||
|
||||
一定要注意的是,**指定拦截器类时要指定它们的全限定名**,即full qualified name。通俗点说就是要把完整包名也加上,不要只有一个类名在那里,并且还要保证你的Producer程序能够正确加载你的拦截器类。
|
||||
|
||||
## **典型使用场景**
|
||||
|
||||
Kafka拦截器都能用在哪些地方呢?其实,跟很多拦截器的用法相同,**Kafka拦截器可以应用于包括客户端监控、端到端系统性能检测、消息审计等多种功能在内的场景**。
|
||||
|
||||
我以端到端系统性能检测和消息审计为例来展开介绍下。
|
||||
|
||||
今天Kafka默认提供的监控指标都是针对单个客户端或Broker的,你很难从具体的消息维度去追踪集群间消息的流转路径。同时,如何监控一条消息从生产到最后消费的端到端延时也是很多Kafka用户迫切需要解决的问题。
|
||||
|
||||
从技术上来说,我们可以在客户端程序中增加这样的统计逻辑,但是对于那些将Kafka作为企业级基础架构的公司来说,在应用代码中编写统一的监控逻辑其实是很难的,毕竟这东西非常灵活,不太可能提前确定好所有的计算逻辑。另外,将监控逻辑与主业务逻辑耦合也是软件工程中不提倡的做法。
|
||||
|
||||
现在,通过实现拦截器的逻辑以及可插拔的机制,我们能够快速地观测、验证以及监控集群间的客户端性能指标,特别是能够从具体的消息层面上去收集这些数据。这就是Kafka拦截器的一个非常典型的使用场景。
|
||||
|
||||
我们再来看看消息审计(message audit)的场景。设想你的公司把Kafka作为一个私有云消息引擎平台向全公司提供服务,这必然要涉及多租户以及消息审计的功能。
|
||||
|
||||
作为私有云的PaaS提供方,你肯定要能够随时查看每条消息是哪个业务方在什么时间发布的,之后又被哪些业务方在什么时刻消费。一个可行的做法就是你编写一个拦截器类,实现相应的消息审计逻辑,然后强行规定所有接入你的Kafka服务的客户端程序必须设置该拦截器。
|
||||
|
||||
## **案例分享**
|
||||
|
||||
下面我以一个具体的案例来说明一下拦截器的使用。在这个案例中,我们通过编写拦截器类来统计消息端到端处理的延时,非常实用,我建议你可以直接移植到你自己的生产环境中。
|
||||
|
||||
我曾经给一个公司做Kafka培训,在培训过程中,那个公司的人提出了一个诉求。他们的场景很简单,某个业务只有一个Producer和一个Consumer,他们想知道该业务消息从被生产出来到最后被消费的平均总时长是多少,但是目前Kafka并没有提供这种端到端的延时统计。
|
||||
|
||||
学习了拦截器之后,我们现在知道可以用拦截器来满足这个需求。既然是要计算总延时,那么一定要有个公共的地方来保存它,并且这个公共的地方还是要让生产者和消费者程序都能访问的。在这个例子中,我们假设数据被保存在Redis中。
|
||||
|
||||
Okay,这个需求显然要实现生产者拦截器,也要实现消费者拦截器。我们先来实现前者:
|
||||
|
||||
```
|
||||
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<java.lang.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);
|
||||
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) {
|
||||
|
||||
```
|
||||
|
||||
在上面的消费者拦截器中,我们在真正消费一批消息前首先更新了它们的总延时,方法就是用当前的时钟时间减去封装在消息中的创建时间,然后累计得到这批消息总的端到端处理延时并更新到Redis中。之后的逻辑就很简单了,我们分别从Redis中读取更新过的总延时和总消息数,两者相除即得到端到端消息的平均处理延时。
|
||||
|
||||
创建好生产者和消费者拦截器后,我们按照上面指定的方法分别将它们配置到各自的Producer和Consumer程序中,这样就能计算消息从Producer端到Consumer端平均的处理延时了。这种端到端的指标监控能够从全局角度俯察和审视业务运行情况,及时查看业务是否满足端到端的SLA目标。
|
||||
|
||||
## **小结**
|
||||
|
||||
今天我们花了一些时间讨论Kafka提供的冷门功能:拦截器。如之前所说,拦截器的出场率极低,以至于我从未看到过国内大厂实际应用Kafka拦截器的报道。但冷门不代表没用。事实上,我们可以利用拦截器满足实际的需求,比如端到端系统性能检测、消息审计等。
|
||||
|
||||
从这一期开始,我们将逐渐接触到更多的实际代码。看完了今天的分享,我希望你能够亲自动手编写一些代码,去实现一个拦截器,体会一下Kafka拦截器的功能。要知道,“纸上得来终觉浅,绝知此事要躬行”。也许你在敲代码的同时,就会想到一个使用拦截器的绝妙点子,让我们拭目以待吧。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/0d/f2cbe18428396aab14749be10dc5550d.jpg" alt="">
|
||||
|
||||
## **开放讨论**
|
||||
|
||||
思考这样一个问题:Producer拦截器onSend方法的签名如下:
|
||||
|
||||
```
|
||||
public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record)
|
||||
|
||||
```
|
||||
|
||||
如果我实现的逻辑仅仅是return null,你觉得Kafka会丢弃该消息,还是原封不动地发送消息?请动手试验一下,看看结果是否符合你的预期。
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
134
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/13 | Java生产者是如何管理TCP连接的?.md
Normal file
134
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/13 | Java生产者是如何管理TCP连接的?.md
Normal file
@@ -0,0 +1,134 @@
|
||||
<audio id="audio" title="13 | Java生产者是如何管理TCP连接的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/89/85f5341d87794da1d86f37e392e60c89.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的主题是:Kafka的Java生产者是如何管理TCP连接的。
|
||||
|
||||
## 为何采用TCP?
|
||||
|
||||
Apache Kafka的所有通信都是基于TCP的,而不是基于HTTP或其他协议。无论是生产者、消费者,还是Broker之间的通信都是如此。你可能会问,为什么Kafka不使用HTTP作为底层的通信协议呢?其实这里面的原因有很多,但最主要的原因在于TCP和HTTP之间的区别。
|
||||
|
||||
从社区的角度来看,在开发客户端时,人们能够利用TCP本身提供的一些高级功能,比如多路复用请求以及同时轮询多个连接的能力。
|
||||
|
||||
所谓的多路复用请求,即multiplexing request,是指将两个或多个数据流合并到底层单一物理连接中的过程。TCP的多路复用请求会在一条物理连接上创建若干个虚拟连接,每个虚拟连接负责流转各自对应的数据流。其实严格来说,TCP并不能多路复用,它只是提供可靠的消息交付语义保证,比如自动重传丢失的报文。
|
||||
|
||||
更严谨地说,作为一个基于报文的协议,TCP能够被用于多路复用连接场景的前提是,上层的应用协议(比如HTTP)允许发送多条消息。不过,我们今天并不是要详细讨论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代码的话大概是这个样子:
|
||||
|
||||
```
|
||||
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);
|
||||
……
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码使用了Java 7 提供的try-with-resource特性,所以并没有显式调用producer.close()方法。无论是否显式调用close方法,所有生产者程序大致都是这个路数。
|
||||
|
||||
现在问题来了,当我们开发一个Producer应用时,生产者会向Kafka集群中指定的主题(Topic)发送消息,这必然涉及与Kafka Broker创建TCP连接。那么,Kafka的Producer客户端是如何管理这些TCP连接的呢?
|
||||
|
||||
## 何时创建TCP连接?
|
||||
|
||||
要回答上面这个问题,我们首先要弄明白生产者代码是什么时候创建TCP连接的。就上面的那段代码而言,可能创建TCP连接的地方有两处:Producer producer = new KafkaProducer(props)和producer.send(msg, callback)。你觉得连向Broker端的TCP连接会是哪里创建的呢?前者还是后者,抑或是两者都有?请先思考5秒钟,然后我给出我的答案。
|
||||
|
||||
首先,生产者应用在创建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能够获取集群所有信息的方法。
|
||||
|
||||
讲到这里,我有一些个人的看法想跟你分享一下。通常情况下,我都不认为社区写的代码或做的设计就一定是对的,因此,很多类似的这种“质疑”会时不时地在我脑子里冒出来。
|
||||
|
||||
拿今天的这个KafkaProducer创建实例来说,社区的官方文档中提及KafkaProducer类是线程安全的。我本人并没有详尽地去验证过它是否真的就是thread-safe的,但是大致浏览一下源码可以得出这样的结论:KafkaProducer实例创建的线程和前面提到的Sender线程共享的可变数据结构只有RecordAccumulator类,故维护了RecordAccumulator类的线程安全,也就实现了KafkaProducer类的线程安全。
|
||||
|
||||
你不需要了解RecordAccumulator类是做什么的,你只要知道它主要的数据结构是一个ConcurrentMap<TopicPartition, Deque>。TopicPartition是Kafka用来表示主题分区的Java对象,本身是不可变对象。而RecordAccumulator代码中用到Deque的地方都有锁的保护,所以基本上可以认定RecordAccumulator类是线程安全的。
|
||||
|
||||
说了这么多,我其实是想说,纵然KafkaProducer是线程安全的,我也不赞同创建KafkaProducer实例时启动Sender线程的做法。写了《Java并发编程实践》的那位布赖恩·格茨(Brian Goetz)大神,明确指出了这样做的风险:在对象构造器中启动线程会造成this指针的逃逸。理论上,Sender线程完全能够观测到一个尚未构造完成的KafkaProducer实例。当然,在构造对象时创建线程没有任何问题,但最好是不要同时启动它。
|
||||
|
||||
好了,我们言归正传。针对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分钟都会强制刷新一次元数据以保证它是最及时的数据。
|
||||
|
||||
讲到这里,我们可以“挑战”一下社区对Producer的这种设计的合理性。目前来看,一个Producer默认会向集群的所有Broker都创建TCP连接,不管是否真的需要传输请求。这显然是没有必要的。再加上Kafka还支持强制将空闲的TCP连接资源关闭,这就更显得多此一举了。
|
||||
|
||||
试想一下,在一个有着1000台Broker的集群中,你的Producer可能只会与其中的3~5台Broker长期通信,但是Producer启动后依次创建与这1000台Broker的TCP连接。一段时间之后,大约有995个TCP连接又被强制关闭。这难道不是一种资源浪费吗?很显然,这里是有改善和优化的空间的。
|
||||
|
||||
## 何时关闭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端没有机会显式地观测到此连接已被中断。
|
||||
|
||||
## 小结
|
||||
|
||||
我们来简单总结一下今天的内容。对最新版本的Kafka(2.1.0)而言,Java Producer端管理TCP连接的方式是:
|
||||
|
||||
1. KafkaProducer实例创建时启动Sender线程,从而创建与bootstrap.servers中所有Broker的TCP连接。
|
||||
1. KafkaProducer实例首次更新元数据信息之后,还会再次创建与集群中所有Broker的TCP连接。
|
||||
1. 如果Producer端发送消息到某台Broker时发现没有与该Broker的TCP连接,那么也会立即创建连接。
|
||||
1. 如果设置Producer端connections.max.idle.ms参数大于0,则步骤1中创建的TCP连接会被自动关闭;如果设置该参数=-1,那么步骤1中创建的TCP连接将无法被关闭,从而成为“僵尸”连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/38/de71cc4b496a22e47b4ce079dbe99238.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
对于今天我们“挑战”的社区设计,你有什么改进的想法吗?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<audio id="audio" title="14 | 幂等生产者和事务生产者是一回事吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/40/b63295b32983f69e7e850ecd06bd8740.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的主题是:Kafka消息交付可靠性保障以及精确处理一次语义的实现。
|
||||
|
||||
所谓的消息交付可靠性保障,是指Kafka对Producer和Consumer要处理的消息提供什么样的承诺。常见的承诺有以下三种:
|
||||
|
||||
- 最多一次(at most once):消息可能会丢失,但绝不会被重复发送。
|
||||
- 至少一次(at least once):消息不会丢失,但有可能被重复发送。
|
||||
- 精确一次(exactly once):消息不会丢失,也不会被重复发送。
|
||||
|
||||
目前,Kafka默认提供的交付可靠性保障是第二种,即至少一次。在专栏[第11期](https://time.geekbang.org/column/article/102931)中,我们说过消息“已提交”的含义,即只有Broker成功“提交”消息且Producer接到Broker的应答才会认为该消息成功发送。不过倘若消息成功“提交”,但Broker的应答没有成功发送回Producer端(比如网络出现瞬时抖动),那么Producer就无法确定消息是否真的提交成功了。因此,它只能选择重试,也就是再次发送相同的消息。这就是Kafka默认提供至少一次可靠性保障的原因,不过这会导致消息重复发送。
|
||||
|
||||
Kafka也可以提供最多一次交付保障,只需要让Producer禁止重试即可。这样一来,消息要么写入成功,要么写入失败,但绝不会重复发送。我们通常不会希望出现消息丢失的情况,但一些场景里偶发的消息丢失其实是被允许的,相反,消息重复是绝对要避免的。此时,使用最多一次交付保障就是最恰当的。
|
||||
|
||||
无论是至少一次还是最多一次,都不如精确一次来得有吸引力。大部分用户还是希望消息只会被交付一次,这样的话,消息既不会丢失,也不会被重复处理。或者说,即使Producer端重复发送了相同的消息,Broker端也能做到自动去重。在下游Consumer看来,消息依然只有一条。
|
||||
|
||||
那么问题来了,Kafka是怎么做到精确一次的呢?简单来说,这是通过两种机制:幂等性(Idempotence)和事务(Transaction)。它们分别是什么机制?两者是一回事吗?要回答这些问题,我们首先来说说什么是幂等性。
|
||||
|
||||
## 什么是幂等性(Idempotence)?
|
||||
|
||||
“幂等”这个词原是数学领域中的概念,指的是某些操作或函数能够被执行多次,但每次得到的结果都是不变的。我来举几个简单的例子说明一下。比如在乘法运算中,让数字乘以1就是一个幂等操作,因为不管你执行多少次这样的运算,结果都是相同的。再比如,取整函数(floor和ceiling)是幂等函数,那么运行1次floor(3.4)和100次floor(3.4),结果是一样的,都是3。相反地,让一个数加1这个操作就不是幂等的,因为执行一次和执行多次的结果必然不同。
|
||||
|
||||
在计算机领域中,幂等性的含义稍微有一些不同:
|
||||
|
||||
- 在命令式编程语言(比如C)中,若一个子程序是幂等的,那它必然不能修改系统状态。这样不管运行这个子程序多少次,与该子程序关联的那部分系统状态保持不变。
|
||||
- 在函数式编程语言(比如Scala或Haskell)中,很多纯函数(pure function)天然就是幂等的,它们不执行任何的side effect。
|
||||
|
||||
幂等性有很多好处,**其最大的优势在于我们可以安全地重试任何幂等性操作,反正它们也不会破坏我们的系统状态**。如果是非幂等性操作,我们还需要担心某些操作执行多次对状态的影响,但对于幂等性操作而言,我们根本无需担心此事。
|
||||
|
||||
## 幂等性Producer
|
||||
|
||||
在Kafka中,Producer默认不是幂等性的,但我们可以创建幂等性Producer。它其实是0.11.0.0版本引入的新功能。在此之前,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的最大区别!
|
||||
|
||||
## 事务
|
||||
|
||||
Kafka的事务概念类似于我们熟知的数据库提供的事务。在数据库领域,事务提供的安全性保障是经典的ACID,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
|
||||
|
||||
当然,在实际场景中各家数据库对ACID的实现各不相同。特别是ACID本身就是一个有歧义的概念,比如对隔离性的理解。大体来看,隔离性非常自然和必要,但是具体到实现细节就显得不那么精确了。通常来说,**隔离性表明并发执行的事务彼此相互隔离,互不影响**。经典的数据库教科书把隔离性称为可串行化(serializability),即每个事务都假装它是整个数据库中唯一的事务。
|
||||
|
||||
提到隔离级别,这种歧义或混乱就更加明显了。很多数据库厂商对于隔离级别的实现都有自己不同的理解,比如有的数据库提供Snapshot隔离级别,而在另外一些数据库中,它们被称为可重复读(repeatable read)。好在对于已提交读(read committed)隔离级别的提法,各大主流数据库厂商都比较统一。所谓的read committed,指的是当读取数据库时,你只能看到已提交的数据,即无脏读。同时,当写入数据库时,你也只能覆盖掉已提交的数据,即无脏写。
|
||||
|
||||
Kafka自0.11版本开始也提供了对事务的支持,目前主要是在read committed隔离级别上做事情。它能保证多条消息原子性地写入到目标分区,同时也能保证Consumer只能看到事务成功提交的消息。下面我们就来看看Kafka中的事务型Producer。
|
||||
|
||||
## 事务型Producer
|
||||
|
||||
事务型Producer能够保证将消息原子性地写入到多个分区中。这批消息要么全部写入成功,要么全部失败。另外,事务型Producer也不惧进程的重启。Producer重启回来后,Kafka依然保证它们发送消息的精确一次处理。
|
||||
|
||||
设置事务型Producer的方法也很简单,满足两个要求即可:
|
||||
|
||||
- 和幂等性Producer一样,开启enable.idempotence = true。
|
||||
- 设置Producer端参数transactional. id。最好为其设置一个有意义的名字。
|
||||
|
||||
此外,你还需要在Producer代码中做一些调整,如这段代码所示:
|
||||
|
||||
```
|
||||
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参数的值即可。当前这个参数有两个取值:
|
||||
|
||||
1. read_uncommitted:这是默认值,表明Consumer能够读取到Kafka写入的任何消息,不论事务型Producer提交事务还是终止事务,其写入的消息都可以读取。很显然,如果你用了事务型Producer,那么对应的Consumer就不要使用这个值。
|
||||
1. read_committed:表明Consumer只会读取事务型Producer成功提交事务写入的消息。当然了,它也能看到非事务型Producer写入的所有消息。
|
||||
|
||||
## 小结
|
||||
|
||||
简单来说,幂等性Producer和事务型Producer都是Kafka社区力图为Kafka实现精确一次处理语义所提供的工具,只是它们的作用范围是不同的。幂等性Producer只能保证单分区、单会话上的消息幂等性;而事务能够保证跨分区、跨会话间的幂等性。从交付语义上来看,自然是事务型Producer能做的更多。
|
||||
|
||||
不过,切记天下没有免费的午餐。比起幂等性Producer,事务型Producer的性能要更差,在实际使用过程中,我们需要仔细评估引入事务的开销,切不可无脑地启用事务。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/ed/419a092ef55d0fa248a56fe582a551ed.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
你理解的事务是什么呢?通过今天的分享,你能列举出未来可能应用于你们公司实际业务中的事务型Producer使用场景吗?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
75
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/15 | 消费者组到底是什么?.md
Normal file
75
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/15 | 消费者组到底是什么?.md
Normal file
@@ -0,0 +1,75 @@
|
||||
<audio id="audio" title="15 | 消费者组到底是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/56/e777655d5bdc6032804d9abcfe853b56.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的主题是:Kafka的消费者组。
|
||||
|
||||
消费者组,即Consumer Group,应该算是Kafka比较有亮点的设计了。那么何谓Consumer Group呢?用一句话概括就是:**Consumer Group是Kafka提供的可扩展且具有容错性的消费者机制**。既然是一个组,那么组内必然可以有多个消费者或消费者实例(Consumer Instance),它们共享一个公共的ID,这个ID被称为Group ID。组内的所有消费者协调在一起来消费订阅主题(Subscribed Topics)的所有分区(Partition)。当然,每个分区只能由同一个消费者组内的一个Consumer实例来消费。个人认为,理解Consumer Group记住下面这三个特性就好了。
|
||||
|
||||
1. Consumer Group下可以有一个或多个Consumer实例。这里的实例可以是一个单独的进程,也可以是同一进程下的线程。在实际场景中,使用进程更为常见一些。
|
||||
1. Group ID是一个字符串,在一个Kafka集群中,它标识唯一的一个Consumer Group。
|
||||
1. Consumer Group下所有实例订阅的主题的单个分区,只能分配给组内的某个Consumer实例消费。这个分区当然也可以被其他的Group消费。
|
||||
|
||||
你应该还记得我在专栏[第1期](https://time.geekbang.org/column/article/98948)中提到的两种消息引擎模型吧?它们分别是**点对点模型和发布/订阅模型**,前者也称为消费队列。当然,你要注意区分很多架构文章中涉及的消息队列与这里的消息队列。国内很多文章都习惯把消息中间件这类框架统称为消息队列,我在这里不评价这种提法是否准确,只是想提醒你注意这里所说的消息队列,特指经典的消息引擎模型。
|
||||
|
||||
好了,传统的消息引擎模型就是这两大类,它们各有优劣。我们来简单回顾一下。传统的消息队列模型的缺陷在于消息一旦被消费,就会从队列中被删除,而且只能被下游的一个Consumer消费。严格来说,这一点不算是缺陷,只能算是它的一个特性。但很显然,这种模型的伸缩性(scalability)很差,因为下游的多个Consumer都要“抢”这个共享消息队列的消息。发布/订阅模型倒是允许消息被多个Consumer消费,但它的问题也是伸缩性不高,因为每个订阅者都必须要订阅主题的所有分区。这种全量订阅的方式既不灵活,也会影响消息的真实投递效果。
|
||||
|
||||
如果有这么一种机制,既可以避开这两种模型的缺陷,又兼具它们的优点,那就太好了。幸运的是,Kafka的Consumer Group就是这样的机制。当Consumer Group订阅了多个主题后,组内的每个实例不要求一定要订阅主题的所有分区,它只会消费部分分区中的消息。
|
||||
|
||||
Consumer Group之间彼此独立,互不影响,它们能够订阅相同的一组主题而互不干涉。再加上Broker端的消息留存机制,Kafka的Consumer Group完美地规避了上面提到的伸缩性差的问题。可以这么说,**Kafka仅仅使用Consumer Group这一种机制,却同时实现了传统消息引擎系统的两大模型**:如果所有实例都属于同一个Group,那么它实现的就是消息队列模型;如果所有实例分别属于不同的Group,那么它实现的就是发布/订阅模型。
|
||||
|
||||
在了解了Consumer Group以及它的设计亮点之后,你可能会有这样的疑问:在实际使用场景中,我怎么知道一个Group下该有多少个Consumer实例呢?**理想情况下,Consumer实例的数量应该等于该Group订阅主题的分区总数。**
|
||||
|
||||
举个简单的例子,假设一个Consumer Group订阅了3个主题,分别是A、B、C,它们的分区数依次是1、2、3(总共是6个分区),那么通常情况下,为该Group设置6个Consumer实例是比较理想的情形,因为它能最大限度地实现高伸缩性。
|
||||
|
||||
你可能会问,我能设置小于或大于6的实例吗?当然可以!如果你有3个实例,那么平均下来每个实例大约消费2个分区(6 / 3 = 2);如果你设置了8个实例,那么很遗憾,有2个实例(8 – 6 = 2)将不会被分配任何分区,它们永远处于空闲状态。因此,在实际使用过程中一般不推荐设置大于总分区数的Consumer实例。设置多余的实例只会浪费资源,而没有任何好处。
|
||||
|
||||
好了,说完了Consumer Group的设计特性,我们来讨论一个问题:针对Consumer Group,Kafka是怎么管理位移的呢?你还记得吧,消费者在消费的过程中需要记录自己消费了多少数据,即消费位置信息。在Kafka中,这个位置信息有个专门的术语:位移(Offset)。
|
||||
|
||||
看上去该Offset就是一个数值而已,其实对于Consumer Group而言,它是一组KV对,Key是分区,V对应Consumer消费该分区的最新位移。如果用Java来表示的话,你大致可以认为是这样的数据结构,即Map<TopicPartition, Long>,其中TopicPartition表示一个分区,而Long表示位移的类型。当然,我必须承认Kafka源码中并不是这样简单的数据结构,而是要比这个复杂得多,不过这并不会妨碍我们对Group位移的理解。
|
||||
|
||||
我在专栏[第4期](https://time.geekbang.org/column/article/100285)中提到过Kafka有新旧客户端API之分,那自然也就有新旧Consumer之分。老版本的Consumer也有消费者组的概念,它和我们目前讨论的Consumer 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端的内部主题中。
|
||||
|
||||
最后,我们来说说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实例崩溃被“踢出”组。
|
||||
1. 订阅主题数发生变更。Consumer Group可以使用正则表达式的方式订阅主题,比如consumer.subscribe(Pattern.compile("t.*c"))就表明该Group订阅所有以字母t开头、字母c结尾的主题。在Consumer Group的运行过程中,你新创建了一个满足这样条件的主题,那么该Group就会发生Rebalance。
|
||||
1. 订阅主题的分区数发生变更。Kafka当前只能允许增加一个主题的分区数。当分区数增加时,就会触发订阅该主题的所有Group开启Rebalance。
|
||||
|
||||
Rebalance发生时,Group下所有的Consumer实例都会协调在一起共同参与。你可能会问,每个Consumer实例怎么知道应该消费订阅主题的哪些分区呢?这就需要分配策略的协助了。
|
||||
|
||||
当前Kafka默认提供了3种分配策略,每种策略都有一定的优势和劣势,我们今天就不展开讨论了,你只需要记住社区会不断地完善这些策略,保证提供最公平的分配策略,即每个Consumer实例都能够得到较为平均的分区数。比如一个Group内有10个Consumer实例,要消费100个分区,理想的分配策略自然是每个实例平均得到10个分区。这就叫公平的分配策略。如果出现了严重的分配倾斜,势必会出现这种情况:有的实例会“闲死”,而有的实例则会“忙死”。
|
||||
|
||||
我们举个简单的例子来说明一下Consumer Group发生Rebalance的过程。假设目前某个Consumer Group下有两个Consumer,比如A和B,当第三个成员C加入时,Kafka会触发Rebalance,并根据默认的分配策略重新为A、B和C分配分区,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/c0/b3b0a5917b03886d31db027b466200c0.jpg" alt="">
|
||||
|
||||
显然,Rebalance之后的分配依然是公平的,即每个Consumer实例都获得了2个分区的消费权。这是我们希望出现的情形。
|
||||
|
||||
讲完了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的发生吧。
|
||||
|
||||
## 小结
|
||||
|
||||
总结一下,今天我跟你分享了Kafka Consumer Group的方方面面,包括它是怎么定义的,它解决了哪些问题,有哪些特性。同时,我们也聊到了Consumer Group的位移管理以及著名的Rebalance过程。希望在你开发Consumer应用时,它们能够助你一臂之力。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/f5/60478ddbf101b19a747d8110ae019ef5.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
今天我貌似说了很多Consumer Group的好话(除了Rebalance),你觉得这种消费者组设计的弊端有哪些呢?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
86
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/16 | 揭开神秘的“位移主题”面纱.md
Normal file
86
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/16 | 揭开神秘的“位移主题”面纱.md
Normal file
@@ -0,0 +1,86 @@
|
||||
<audio id="audio" title="16 | 揭开神秘的“位移主题”面纱" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/24/4bb6de24aef22b89961ad9b1cdb42f24.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的内容是:Kafka中神秘的内部主题(Internal Topic)__consumer_offsets。
|
||||
|
||||
__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随意向该主题发送消息。
|
||||
|
||||
你可能会好奇,这个主题存的到底是什么格式的消息呢?所谓的消息格式,你可以简单地理解为是一个KV对。Key和Value分别表示消息的键值和消息体,在Kafka中它们就是字节数组而已。想象一下,如果让你来设计这个主题,你觉得消息格式应该长什么样子呢?我先不说社区的设计方案,我们自己先来设计一下。
|
||||
|
||||
首先从Key说起。一个Kafka集群中的Consumer数量会有很多,既然这个主题保存的是Consumer的位移数据,那么消息格式中必须要有字段来标识这个位移数据是哪个Consumer的。这种数据放在哪个字段比较合适呢?显然放在Key中比较合适。
|
||||
|
||||
现在我们知道该主题消息的Key中应该保存标识Consumer的字段,那么,当前Kafka中什么字段能够标识Consumer呢?还记得之前我们说Consumer Group时提到的Group ID吗?没错,就是这个字段,它能够标识唯一的Consumer Group。
|
||||
|
||||
说到这里,我再多说几句。除了Consumer Group,Kafka还支持独立Consumer,也称Standalone Consumer。它的运行机制与Consumer Group完全不同,但是位移管理的机制却是相同的。因此,即使是Standalone Consumer,也有自己的Group ID来标识它自己,所以也适用于这套消息格式。
|
||||
|
||||
Okay,我们现在知道Key中保存了Group ID,但是只保存Group ID就可以了吗?别忘了,Consumer提交位移是在分区层面上进行的,即它提交的是某个或某些分区的位移,那么很显然,Key中还应该保存Consumer要提交位移的分区。
|
||||
|
||||
好了,我们来总结一下我们的结论。**位移主题的Key中应该保存3部分内容:<Group ID,主题名,分区号>**。如果你认同这样的结论,那么恭喜你,社区就是这么设计的!
|
||||
|
||||
接下来,我们再来看看消息体的设计。也许你会觉得消息体应该很简单,保存一个位移值就可以了。实际上,社区的方案要复杂得多,比如消息体还保存了位移提交的一些其他元数据,诸如时间戳和用户自定义的数据等。保存这些元数据是为了帮助Kafka执行各种各样后续的操作,比如删除过期位移消息等。但总体来说,我们还是可以简单地认为消息体就是保存了位移值。
|
||||
|
||||
当然了,位移主题的消息格式可不是只有这一种。事实上,它有3种消息格式。除了刚刚我们说的这种格式,还有2种格式:
|
||||
|
||||
1. 用于保存Consumer Group信息的消息。
|
||||
1. 用于删除Group过期位移甚至是删除Group的消息。
|
||||
|
||||
第1种格式非常神秘,以至于你几乎无法在搜索引擎中搜到它的身影。不过,你只需要记住它是用来注册Consumer Group的就可以了。
|
||||
|
||||
第2种格式相对更加有名一些。它有个专属的名字:tombstone消息,即墓碑消息,也称delete mark。下次你在Google或百度中见到这些词,不用感到惊讶,它们指的是一个东西。这些消息只出现在源码中而不暴露给你。它的主要特点是它的消息体是null,即空消息体。
|
||||
|
||||
那么,何时会写入这类消息呢?一旦某个Consumer Group下的所有Consumer实例都停止了,而且它们的位移数据都已被删除时,Kafka会向位移主题的对应分区写入tombstone消息,表明要彻底删除这个Group的信息。
|
||||
|
||||
好了,消息格式就说这么多,下面我们来说说位移主题是怎么被创建的。通常来说,**当Kafka集群中的第一个Consumer程序启动时,Kafka会自动创建位移主题**。我们说过,位移主题就是普通的Kafka主题,那么它自然也有对应的分区数。但如果是Kafka自动创建的,分区数是怎么设置的呢?这就要看Broker端参数offsets.topic.num.partitions的取值了。它的默认值是50,因此Kafka会自动创建一个50分区的位移主题。如果你曾经惊讶于Kafka日志路径下冒出很多__consumer_offsets-xxx这样的目录,那么现在应该明白了吧,这就是Kafka自动帮你创建的位移主题啊。
|
||||
|
||||
你可能会问,除了分区数,副本数或备份因子是怎么控制的呢?答案也很简单,这就是Broker端另一个参数offsets.topic.replication.factor要做的事情了。它的默认值是3。
|
||||
|
||||
总结一下,**如果位移主题是Kafka自动创建的,那么该主题的分区数是50,副本数是3**。
|
||||
|
||||
当然,你也可以选择手动创建位移主题,具体方法就是,在Kafka集群尚未启动任何Consumer之前,使用Kafka API创建它。手动创建的好处在于,你可以创建满足你实际场景需要的位移主题。比如很多人说50个分区对我来讲太多了,我不想要这么多分区,那么你可以自己创建它,不用理会offsets.topic.num.partitions的值。
|
||||
|
||||
不过我给你的建议是,还是让Kafka自动创建比较好。目前Kafka源码中有一些地方硬编码了50分区数,因此如果你自行创建了一个不同于默认分区数的位移主题,可能会碰到各种各样奇怪的问题。这是社区的一个Bug,目前代码已经修复了,但依然在审核中。
|
||||
|
||||
创建位移主题当然是为了用的,那么什么地方会用到位移主题呢?我们前面一直在说Kafka Consumer提交位移时会写入该主题,那Consumer是怎么提交位移的呢?目前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一直启动着,它就会无限期地向位移主题写入消息。
|
||||
|
||||
我们来举个极端一点的例子。假设Consumer当前消费到了某个主题的最新一条消息,位移是100,之后该主题没有任何新消息产生,故Consumer无消息可消费了,所以位移永远保持在100。由于是自动提交位移,位移主题中会不停地写入位移=100的消息。显然Kafka只需要保留这类消息中的最新一条就可以了,之前的消息都是可以删除的。这就要求Kafka必须要有针对位移主题消息特点的消息删除策略,否则这种消息会越来越多,最终撑爆整个磁盘。
|
||||
|
||||
Kafka是怎么删除位移主题中的过期消息的呢?答案就是Compaction。国内很多文献都将其翻译成压缩,我个人是有一点保留意见的。在英语中,压缩的专有术语是Compression,它的原理和Compaction很不相同,我更倾向于翻译成压实,或干脆采用JVM垃圾回收中的术语:整理。
|
||||
|
||||
不管怎么翻译,Kafka使用**Compact策略**来删除位移主题中的过期消息,避免该主题无限期膨胀。那么应该如何定义Compact策略中的过期呢?对于同一个Key的两条消息M1和M2,如果M1的发送时间早于M2,那么M1就是过期消息。Compact的过程就是扫描日志的所有消息,剔除那些过期的消息,然后把剩下的消息整理在一起。我在这里贴一张来自官网的图片,来说明Compact过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/e7/86a44073aa60ac33e0833e6a9bfd9ae7.jpeg" alt="">
|
||||
|
||||
图中位移为0、2和3的消息的Key都是K1。Compact之后,分区只需要保存位移为3的消息,因为它是最新发送的。
|
||||
|
||||
**Kafka提供了专门的后台线程定期地巡检待Compact的主题,看看是否存在满足条件的可删除数据**。这个后台线程叫Log Cleaner。很多实际生产环境中都出现过位移主题无限膨胀占用过多磁盘空间的问题,如果你的环境中也有这个问题,我建议你去检查一下Log Cleaner线程的状态,通常都是这个线程挂掉了导致的。
|
||||
|
||||
## 小结
|
||||
|
||||
总结一下,今天我跟你分享了Kafka神秘的位移主题__consumer_offsets,包括引入它的契机与原因、它的作用、消息格式、写入的时机以及管理策略等,这对我们了解Kafka特别是Kafka Consumer的位移管理是大有帮助的。实际上,将很多元数据以消息的方式存入Kafka内部主题的做法越来越流行。除了Consumer位移管理,Kafka事务也是利用了这个方法,当然那是另外的一个内部主题了。
|
||||
|
||||
社区的想法很简单:既然Kafka天然实现了高持久性和高吞吐量,那么任何有这两个需求的子服务自然也就不必求助于外部系统,用Kafka自己实现就好了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/b7/927e436fb8054665d81db418c25af3b7.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
今天我们说了位移主题的很多好处,请思考一下,与ZooKeeper方案相比,它可能的劣势是什么?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
98
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/17 | 消费者组重平衡能避免吗?.md
Normal file
98
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/17 | 消费者组重平衡能避免吗?.md
Normal file
@@ -0,0 +1,98 @@
|
||||
<audio id="audio" title="17 | 消费者组重平衡能避免吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/a9/33863d59da1fd372ddba245bcca460a9.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的内容是:消费者组重平衡能避免吗?
|
||||
|
||||
其实在专栏[第15期](https://time.geekbang.org/column/article/105112)中,我们讲过重平衡,也就是Rebalance,现在先来回顾一下这个概念的原理和用途。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。既然我们今天要讨论的是如何避免Rebalance,那就说明Rebalance这个东西不好,或者说至少有一些弊端需要我们去规避。那么,Rebalance的弊端是什么呢?总结起来有以下3点:
|
||||
|
||||
<li>
|
||||
Rebalance影响Consumer端TPS。这个之前也反复提到了,这里就不再具体讲了。总之就是,在Rebalance期间,Consumer会停下手头的事情,什么也干不了。
|
||||
</li>
|
||||
<li>
|
||||
Rebalance很慢。如果你的Group下成员很多,就一定会有这样的痛点。还记得我曾经举过的那个国外用户的例子吧?他的Group下有几百个Consumer实例,Rebalance一次要几个小时。在那种场景下,Consumer Group的Rebalance已经完全失控了。
|
||||
</li>
|
||||
<li>
|
||||
Rebalance效率不高。当前Kafka的设计机制决定了每次Rebalance时,Group下的所有成员都要参与进来,而且通常不会考虑局部性原理,但局部性原理对提升系统性能是特别重要的。
|
||||
</li>
|
||||
|
||||
关于第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有以上这三个方面的弊端。你可能会问,这些问题有解吗?特别是针对Rebalance慢和影响TPS这两个弊端,社区有解决办法吗?针对这两点,我可以很负责任地告诉你:“无解!”特别是Rebalance慢这个问题,Kafka社区对此无能为力。“本事大不如不摊上”,既然我们没办法解决Rebalance过程中的各种问题,干脆就避免Rebalance吧,特别是那些不必要的Rebalance。
|
||||
|
||||
就我个人经验而言,**在真实的业务场景中,很多Rebalance都是计划外的或者说是不必要的**。我们应用的TPS大多是被这类Rebalance拖慢的,因此避免这类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,我们就不能不管了。
|
||||
|
||||
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是“不必要的”。
|
||||
|
||||
**第一类非必要Rebalance是因为未能及时发送心跳,导致Consumer被“踢出”Group而引发的**。因此,你需要仔细地设置**session.timeout.ms和heartbeat.interval.ms**的值。我在这里给出一些推荐数值,你可以“无脑”地应用在你的生产环境中。
|
||||
|
||||
- 设置session.timeout.ms = 6s。
|
||||
- 设置heartbeat.interval.ms = 2s。
|
||||
- 要保证Consumer实例在被判定为“dead”之前,能够发送至少3轮的心跳请求,即session.timeout.ms >= 3 * 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了。
|
||||
|
||||
## 小结
|
||||
|
||||
总而言之,我们一定要避免因为各种参数或逻辑不合理而导致的组成员意外离组或退出的情形,与之相关的主要参数有:
|
||||
|
||||
- session.timeout.ms
|
||||
- heartbeat.interval.ms
|
||||
- max.poll.interval.ms
|
||||
- GC参数
|
||||
|
||||
按照我们今天所说的内容,恰当地设置这些参数,你一定能够大幅度地降低生产环境中的Rebalance数量,从而整体提升Consumer端TPS。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/d3/321c73b51f5e5c3124765101edc53ed3.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
说说在你的业务场景中,Rebalance发生的频率、原因,以及你是怎么应对的,我们一起讨论下是否有更好的解决方案。
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
158
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/18 | Kafka中位移提交那些事儿.md
Normal file
158
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/18 | Kafka中位移提交那些事儿.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="18 | Kafka中位移提交那些事儿" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/1d/e906d8f6d04720fd021b92663becf61d.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我们来聊聊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代码。这段代码展示了设置自动提交位移的方法。有了这段代码做基础,今天后面的讲解我就不再展示完整的代码了。
|
||||
|
||||
```
|
||||
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()的使用方法:
|
||||
|
||||
```
|
||||
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重启回来后,会有更多的消息被重新消费。
|
||||
|
||||
鉴于这个问题,Kafka社区为手动提交位移提供了另一个API方法:**KafkaConsumer#commitAsync()**。从名字上来看它就不是同步的,而是一个异步操作。调用commitAsync()之后,它会立即返回,不会阻塞,因此不会影响Consumer应用的TPS。由于它是异步的,Kafka提供了回调函数(callback),供你实现提交之后的逻辑,比如记录日志或处理异常等。下面这段代码展示了调用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呢?答案是不能。commitAsync的问题在于,出现问题时它不会自动重试。因为它是异步操作,倘若提交失败后自动重试,那么它重试时提交的位移值可能早已经“过期”或不是最新值了。因此,异步提交的重试其实没有意义,所以commitAsync是不会重试的。
|
||||
|
||||
显然,如果是手动提交,我们需要将commitSync和commitAsync组合使用才能达到最理想的效果,原因有两个:
|
||||
|
||||
1. 我们可以利用commitSync的自动重试来规避那些瞬时错误,比如网络的瞬时抖动,Broker端GC等。因为这些问题都是短暂的,自动重试通常都会成功,因此,我们不想自己重试,而是希望Kafka Consumer帮我们做这件事。
|
||||
1. 我们不希望程序总处于阻塞状态,影响TPS。
|
||||
|
||||
我们来看一下下面这段代码,它展示的是如何将两个API方法结合使用进行手动提交。
|
||||
|
||||
```
|
||||
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条消息的位移一并处理。简单来说,就是**直接提交最新一条消息的位移**。但如果我想更加细粒度化地提交位移,该怎么办呢?
|
||||
|
||||
设想这样一个场景:你的poll方法返回的不是500条消息,而是5000条。那么,你肯定不想把这5000条消息都处理完之后再提交位移,因为一旦中间出现差错,之前处理的全部都要重来一遍。这类似于我们数据库中的事务处理。很多时候,我们希望将一个大事务分割成若干个小事务分别提交,这能够有效减少错误恢复的时间。
|
||||
|
||||
在Kafka中也是相同的道理。对于一次要处理很多消息的Consumer而言,它会关心社区有没有方法允许它在消费的中间进行位移提交。比如前面这个5000条消息的例子,你可能希望每处理完100条消息就提交一次位移,这样能够避免大批量的消息重新消费。
|
||||
|
||||
庆幸的是,Kafka Consumer API为手动提交提供了这样的方法:commitSync(Map<TopicPartition, OffsetAndMetadata>)和commitAsync(Map<TopicPartition, OffsetAndMetadata>)。它们的参数是一个Map对象,键就是TopicPartition,即消费的分区,而值是一个OffsetAndMetadata对象,保存的主要是位移数据。
|
||||
|
||||
就拿刚刚提过的那个例子来说,如何每处理100条消息就提交一次位移呢?在这里,我以commitAsync为例,展示一段代码,实际上,commitSync的调用方法和它是一模一样的。
|
||||
|
||||
```
|
||||
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方法返回的消息总数的限制了。
|
||||
|
||||
## 小结
|
||||
|
||||
好了,我们来总结一下今天的内容。Kafka Consumer的位移提交,是实现Consumer端语义保障的重要手段。位移提交分为自动提交和手动提交,而手动提交又分为同步提交和异步提交。在实际使用过程中,推荐你使用手动提交机制,因为它更加可控,也更加灵活。另外,建议你同时采用同步提交和异步提交两种方式,这样既不影响TPS,又支持自动重试,改善Consumer应用的高可用性。总之,Kafka Consumer API提供了多种灵活的提交方法,方便你根据自己的业务场景定制你的提交策略。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/d1/a6e24c364321aaa44b8fedf3836bccd1.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
实际上,手动提交也不能避免消息重复消费。假设Consumer在处理完消息和提交位移前出现故障,下次重启后依然会出现消息重复消费的情况。请你思考一下,如何实现你的业务场景中的去重逻辑呢?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
@@ -0,0 +1,102 @@
|
||||
<audio id="audio" title="19 | CommitFailedException异常怎么处理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/73/bccc4d34ccff271123c704a33236d273.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我来跟你聊聊CommitFailedException异常的处理。
|
||||
|
||||
说起这个异常,我相信用过Kafka Java Consumer客户端API的你一定不会感到陌生。**所谓CommitFailedException,顾名思义就是Consumer客户端在提交位移时出现了错误或异常,而且还是那种不可恢复的严重异常**。如果异常是可恢复的瞬时错误,提交位移的API自己就能规避它们了,因为很多提交位移的API方法是支持自动错误重试的,比如我们在上一期中提到的**commitSync方法**。
|
||||
|
||||
每次和CommitFailedException一起出现的,还有一段非常著名的注释。为什么说它很“著名”呢?第一,我想不出在近50万行的Kafka源代码中,还有哪个异常类能有这种待遇,可以享有这么大段的注释,来阐述其异常的含义;第二,纵然有这么长的文字解释,却依然有很多人对该异常想表达的含义感到困惑。
|
||||
|
||||
现在,我们一起领略下这段文字的风采,看看社区对这个异常的最新解释:
|
||||
|
||||
>
|
||||
Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member. 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. 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.
|
||||
|
||||
|
||||
这段话前半部分的意思是,本次提交位移失败了,原因是消费者组已经开启了Rebalance过程,并且将要提交位移的分区分配给了另一个消费者实例。出现这个情况的原因是,你的消费者实例连续两次调用poll方法的时间间隔超过了期望的max.poll.interval.ms参数值。这通常表明,你的消费者实例花费了太长的时间进行消息处理,耽误了调用poll方法。
|
||||
|
||||
在后半部分,社区给出了两个相应的解决办法(即橙色字部分):
|
||||
|
||||
1. 增加期望的时间间隔max.poll.interval.ms参数值。
|
||||
1. 减少poll方法一次性返回的消息数量,即减少max.poll.records参数值。
|
||||
|
||||
在详细讨论这段文字之前,我还想提一句,实际上这段文字总共有3个版本,除了上面的这个最新版本,还有2个版本,它们分别是:
|
||||
|
||||
>
|
||||
Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member. This means that the time between subsequent calls to poll() was longer than the configured session.timeout.ms, which typically implies that the poll loop is spending too much time message processing. You can address this either by increasing the session timeout or by reducing the maximum size of batches returned in poll() with max.poll.records.
|
||||
|
||||
|
||||
>
|
||||
Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member. 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. You can address this either by increasing the session timeout or by reducing the maximum size of batches returned in poll() with max.poll.records.
|
||||
|
||||
|
||||
这两个较早的版本和最新版相差不大,我就不详细解释了,具体的差异我用橙色标注了。我之所以列出这些版本,就是想让你在日后看到它们时能做到心中有数,知道它们说的是一个事情。
|
||||
|
||||
其实不论是哪段文字,它们都表征位移提交出现了异常。下面我们就来讨论下该异常是什么时候被抛出的。从源代码方面来说,CommitFailedException异常通常发生在手动提交位移时,即用户显式调用KafkaConsumer.commitSync()方法时。从使用场景来说,有两种典型的场景可能遭遇该异常。
|
||||
|
||||
**场景一**
|
||||
|
||||
我们先说说最常见的场景。当消息处理的总时间超过预设的max.poll.interval.ms参数值时,Kafka Consumer端会抛出CommitFailedException异常。这是该异常最“正宗”的登场方式。你只需要写一个Consumer程序,使用KafkaConsumer.subscribe方法随意订阅一个主题,之后设置Consumer端参数max.poll.interval.ms=5秒,最后在循环调用KafkaConsumer.poll方法之间,插入Thread.sleep(6000)和手动提交位移,就可以成功复现这个异常了。在这里,我展示一下主要的代码逻辑。
|
||||
|
||||
```
|
||||
…
|
||||
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种方法。
|
||||
|
||||
<li>
|
||||
**缩短单条消息处理的时间**。比如,之前下游系统消费一条消息的时间是100毫秒,优化之后成功地下降到50毫秒,那么此时Consumer端的TPS就提升了一倍。
|
||||
</li>
|
||||
<li>
|
||||
**增加Consumer端允许下游系统消费一批消息的最大时长**。这取决于Consumer端参数max.poll.interval.ms的值。在最新版的Kafka中,该参数的默认值是5分钟。如果你的消费逻辑不能简化,那么提高该参数值是一个不错的办法。值得一提的是,Kafka 0.10.1.0之前的版本是没有这个参数的,因此如果你依然在使用0.10.1.0之前的客户端API,那么你需要增加session.timeout.ms参数的值。不幸的是,session.timeout.ms参数还有其他的含义,因此增加该参数的值可能会有其他方面的“不良影响”,这也是社区在0.10.1.0版本引入max.poll.interval.ms参数,将这部分含义从session.timeout.ms中剥离出来的原因之一。
|
||||
</li>
|
||||
<li>
|
||||
**减少下游系统一次性消费的消息总数**。这取决于Consumer端参数max.poll.records的值。当前该参数的默认值是500条,表明调用一次KafkaConsumer.poll方法,最多返回500条消息。可以说,该参数规定了单次poll方法能够返回的消息总数的上限。如果前两种方法对你都不适用的话,降低此参数值是避免CommitFailedException异常最简单的手段。
|
||||
</li>
|
||||
<li>
|
||||
**下游系统使用多线程来加速消费**。这应该算是“最高级”同时也是最难实现的解决办法了。具体的思路就是,让下游系统手动创建多个消费线程处理poll方法返回的一批消息。之前你使用Kafka Consumer消费数据更多是单线程的,所以当消费速度无法匹及Kafka Consumer消息返回的速度时,它就会抛出CommitFailedException异常。如果是多线程,你就可以灵活地控制线程数量,随时调整消费承载能力,再配以目前多核的硬件条件,该方法可谓是防止CommitFailedException最高档的解决之道。事实上,很多主流的大数据流处理框架使用的都是这个方法,比如Apache Flink在集成Kafka时,就是创建了多个KafkaConsumerThread线程,自行处理多线程间的数据消费。不过,凡事有利就有弊,这个方法实现起来并不容易,特别是在多个线程间如何处理位移提交这个问题上,更是极容易出错。在专栏后面的内容中,我将着重和你讨论一下多线程消费的实现方案。
|
||||
</li>
|
||||
|
||||
综合以上这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。
|
||||
|
||||
**场景二**
|
||||
|
||||
Okay,现在我们已经说完了关于CommitFailedException异常的经典发生场景以及应对办法。从理论上讲,关于该异常你了解到这个程度,已经足以帮助你应对应用开发过程中由该异常带来的“坑”了 。但其实,该异常还有一个不太为人所知的出现场景。了解这个冷门场景,可以帮助你拓宽Kafka Consumer的知识面,也能提前预防一些古怪的问题。下面我们就来说说这个场景。
|
||||
|
||||
之前我们花了很多时间学习Kafka的消费者,不过大都集中在消费者组上,即所谓的Consumer Group。其实,Kafka Java Consumer端还提供了一个名为Standalone Consumer的独立消费者。它没有消费者组的概念,每个消费者实例都是独立工作的,彼此之间毫无联系。不过,你需要注意的是,独立消费者的位移提交机制和消费者组是一样的,因此独立消费者的位移提交也必须遵守之前说的那些规定,比如独立消费者也要指定group.id参数才能提交位移。你可能会觉得奇怪,既然是独立消费者,为什么还要指定group.id呢?没办法,谁让社区就是这么设计的呢?总之,消费者组和独立消费者在使用之前都要指定group.id。
|
||||
|
||||
现在问题来了,如果你的应用中同时出现了设置相同group.id值的消费者组程序和独立消费者程序,那么当独立消费者程序手动提交位移时,Kafka就会立即抛出CommitFailedException异常,因为Kafka无法识别这个具有相同group.id的消费者实例,于是就向它返回一个错误,表明它不是消费者组内合法的成员。
|
||||
|
||||
虽然说这个场景很冷门,但也并非完全不会遇到。在一个大型公司中,特别是那些将Kafka作为全公司级消息引擎系统的公司中,每个部门或团队都可能有自己的消费者应用,谁能保证各自的Consumer程序配置的group.id没有重复呢?一旦出现不凑巧的重复,发生了上面提到的这种场景,你使用之前提到的哪种方法都不能规避该异常。令人沮丧的是,无论是刚才哪个版本的异常说明,都完全没有提及这个场景,因此,如果是这个原因引发的CommitFailedException异常,前面的4种方法全部都是无效的。
|
||||
|
||||
更为尴尬的是,无论是社区官网,还是网上的文章,都没有提到过这种使用场景。我个人认为,这应该算是Kafka的一个bug。比起返回CommitFailedException异常只是表明提交位移失败,更好的做法应该是,在Consumer端应用程序的某个地方,能够以日志或其他方式友善地提示你错误的原因,这样你才能正确处理甚至是预防该异常。
|
||||
|
||||
## 小结
|
||||
|
||||
总结一下,今天我们详细讨论了Kafka Consumer端经常碰到的CommitFailedException异常。我们从它的含义说起,再到它出现的时机和场景,以及每种场景下的应对之道。当然,我也留了个悬念,在专栏后面的内容中,我会详细说说多线程消费的实现方式。希望通过今天的分享,你能清晰地掌握CommitFailedException异常发生的方方面面,从而能在今后更有效地应对此异常。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/88/df3691cee68c7878efd21e79719bec88.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
请比较一下今天我们提到的预防该异常的4种方法,并说说你对它们的理解。
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
153
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/20 | 多线程开发消费者实例.md
Normal file
153
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/20 | 多线程开发消费者实例.md
Normal file
@@ -0,0 +1,153 @@
|
||||
<audio id="audio" title="20 | 多线程开发消费者实例" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/60/43/607cf49616bb537e5822c0a3804ca643.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我们来聊聊Kafka Java Consumer端多线程消费的实现方案。
|
||||
|
||||
目前,计算机的硬件条件已经大大改善,即使是在普通的笔记本电脑上,多核都已经是标配了,更不用说专业的服务器了。如果跑在强劲服务器机器上的应用程序依然是单线程架构,那实在是有点暴殄天物了。不过,Kafka Java Consumer就是单线程的设计,你是不是感到很惊讶。所以,探究它的多线程消费方案,就显得非常必要了。
|
||||
|
||||
## Kafka Java Consumer设计原理
|
||||
|
||||
在开始探究之前,我先简单阐述下Kafka Java Consumer为什么采用单线程的设计。了解了这一点,对我们后面制定多线程方案大有裨益。
|
||||
|
||||
谈到Java Consumer API,最重要的当属它的入口类KafkaConsumer了。我们说KafkaConsumer是单线程的设计,严格来说这是不准确的。因为,从Kafka 0.10.1.0版本开始,KafkaConsumer就变为了双线程的设计,即**用户主线程和心跳线程**。
|
||||
|
||||
**所谓用户主线程,就是你启动Consumer应用程序main方法的那个线程,而新引入的心跳线程(Heartbeat Thread)只负责定期给对应的Broker机器发送心跳请求,以标识消费者应用的存活性(liveness)**。引入这个心跳线程还有一个目的,那就是期望它能将心跳频率与主线程调用KafkaConsumer.poll方法的频率分开,从而解耦真实的消息处理逻辑与消费者组成员存活性管理。
|
||||
|
||||
不过,虽然有心跳线程,但实际的消息获取逻辑依然是在用户主线程中完成的。因此,在消费消息的这个层面上,我们依然可以安全地认为KafkaConsumer是单线程的设计。
|
||||
|
||||
其实,在社区推出Java Consumer API之前,Kafka中存在着一组统称为Scala Consumer的API。这组API,或者说这个Consumer,也被称为老版本Consumer,目前在新版的Kafka代码中已经被完全移除了。
|
||||
|
||||
我之所以重提旧事,是想告诉你,老版本Consumer是多线程的架构,每个Consumer实例在内部为所有订阅的主题分区创建对应的消息获取线程,也称Fetcher线程。老版本Consumer同时也是阻塞式的(blocking),Consumer实例启动后,内部会创建很多阻塞式的消息获取迭代器。但在很多场景下,Consumer端是有非阻塞需求的,比如在流处理应用中执行过滤(filter)、连接(join)、分组(group by)等操作时就不能是阻塞式的。基于这个原因,社区为新版本Consumer设计了单线程+轮询的机制。这种设计能够较好地实现非阻塞式的消息获取。
|
||||
|
||||
除此之外,单线程的设计能够简化Consumer端的设计。Consumer获取到消息后,处理消息的逻辑是否采用多线程,完全由你决定。这样,你就拥有了把消息处理的多线程管理策略从Consumer端代码中剥离的权利。
|
||||
|
||||
另外,不论使用哪种编程语言,单线程的设计都比较容易实现。相反,并不是所有的编程语言都能够很好地支持多线程。从这一点上来说,单线程设计的Consumer更容易移植到其他语言上。毕竟,Kafka社区想要打造上下游生态的话,肯定是希望出现越来越多的客户端的。
|
||||
|
||||
## 多线程方案
|
||||
|
||||
了解了单线程的设计原理之后,我们来具体分析一下KafkaConsumer这个类的使用方法,以及如何推演出对应的多线程方案。
|
||||
|
||||
首先,我们要明确的是,KafkaConsumer类不是线程安全的(thread-safe)。所有的网络I/O处理都是发生在用户主线程中,因此,你在使用过程中必须要确保线程安全。简单来说,就是你不能在多个线程中共享同一个KafkaConsumer实例,否则程序会抛出ConcurrentModificationException异常。
|
||||
|
||||
当然了,这也不是绝对的。KafkaConsumer中有个方法是例外的,它就是**wakeup()**,你可以在其他线程中安全地调用**KafkaConsumer.wakeup()**来唤醒Consumer。
|
||||
|
||||
鉴于KafkaConsumer不是线程安全的事实,我们能够制定两套多线程方案。
|
||||
|
||||
1.**消费者程序启动多个线程,每个线程维护专属的KafkaConsumer实例,负责完整的消息获取、消息处理流程**。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/40/d921a79085ef214byy50d7f94cde7a40.jpg" alt="">
|
||||
|
||||
2.**消费者程序使用单或多线程获取消息,同时创建多个消费线程执行消息处理逻辑**。获取消息的线程可以是一个,也可以是多个,每个线程维护专属的KafkaConsumer实例,处理消息则交由**特定的线程池**来做,从而实现消息获取与消息处理的真正解耦。具体架构如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/bb/02b7945cab3c2a574d8a49e1a9927dbb.jpg" alt="">
|
||||
|
||||
总体来说,这两种方案都会创建多个线程,这些线程都会参与到消息的消费过程中,但各自的思路是不一样的。
|
||||
|
||||
我们来打个比方。比如一个完整的消费者应用程序要做的事情是1、2、3、4、5,那么方案1的思路是**粗粒度化**的工作划分,也就是说方案1会创建多个线程,每个线程完整地执行1、2、3、4、5,以实现并行处理的目标,它不会进一步分割具体的子任务;而方案2则更**细粒度化**,它会将1、2分割出来,用单线程(也可以是多线程)来做,对于3、4、5,则用另外的多个线程来做。
|
||||
|
||||
这两种方案孰优孰劣呢?应该说是各有千秋。我总结了一下这两种方案的优缺点,我们先来看看下面这张表格。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/0c/84dc0edb73f203b55808b33ca004670c.jpg" alt="">
|
||||
|
||||
接下来,我来具体解释一下表格中的内容。
|
||||
|
||||
我们先看方案1,它的优势有3点。
|
||||
|
||||
1. 实现起来简单,因为它比较符合目前我们使用Consumer API的习惯。我们在写代码的时候,使用多个线程并在每个线程中创建专属的KafkaConsumer实例就可以了。
|
||||
1. 多个线程之间彼此没有任何交互,省去了很多保障线程安全方面的开销。
|
||||
1. 由于每个线程使用专属的KafkaConsumer实例来执行消息获取和消息处理逻辑,因此,Kafka主题中的每个分区都能保证只被一个线程处理,这样就很容易实现分区内的消息消费顺序。这对在乎事件先后顺序的应用场景来说,是非常重要的优势。
|
||||
|
||||
说完了方案1的优势,我们来看看这个方案的不足之处。
|
||||
|
||||
1. 每个线程都维护自己的KafkaConsumer实例,必然会占用更多的系统资源,比如内存、TCP连接等。在资源紧张的系统环境中,方案1的这个劣势会表现得更加明显。
|
||||
1. 这个方案能使用的线程数受限于Consumer订阅主题的总分区数。我们知道,在一个消费者组中,每个订阅分区都只能被组内的一个消费者实例所消费。假设一个消费者组订阅了100个分区,那么方案1最多只能扩展到100个线程,多余的线程无法分配到任何分区,只会白白消耗系统资源。当然了,这种扩展性方面的局限可以被多机架构所缓解。除了在一台机器上启用100个线程消费数据,我们也可以选择在100台机器上分别创建1个线程,效果是一样的。因此,如果你的机器资源很丰富,这个劣势就不足为虑了。
|
||||
1. 每个线程完整地执行消息获取和消息处理逻辑。一旦消息处理逻辑很重,造成消息处理速度慢,就很容易出现不必要的Rebalance,从而引发整个消费者组的消费停滞。这个劣势你一定要注意。我们之前讨论过如何避免Rebalance,如果你不记得的话,可以回到专栏第17讲复习一下。
|
||||
|
||||
下面我们来说说方案2。
|
||||
|
||||
与方案1的粗粒度不同,方案2将任务切分成了**消息获取**和**消息处理**两个部分,分别由不同的线程处理它们。比起方案1,方案2的最大优势就在于它的**高伸缩性**,就是说我们可以独立地调节消息获取的线程数,以及消息处理的线程数,而不必考虑两者之间是否相互影响。如果你的消费获取速度慢,那么增加消费获取的线程数即可;如果是消息的处理速度慢,那么增加Worker线程池线程数即可。
|
||||
|
||||
不过,这种架构也有它的缺陷。
|
||||
|
||||
1. 它的实现难度要比方案1大得多,毕竟它有两组线程,你需要分别管理它们。
|
||||
1. 因为该方案将消息获取和消息处理分开了,也就是说获取某条消息的线程不是处理该消息的线程,因此无法保证分区内的消费顺序。举个例子,比如在某个分区中,消息1在消息2之前被保存,那么Consumer获取消息的顺序必然是消息1在前,消息2在后,但是,后面的Worker线程却有可能先处理消息2,再处理消息1,这就破坏了消息在分区中的顺序。还是那句话,如果你在意Kafka中消息的先后顺序,方案2的这个劣势是致命的。
|
||||
1. 方案2引入了多组线程,使得整个消息消费链路被拉长,最终导致正确位移提交会变得异常困难,结果就是可能会出现消息的重复消费。如果你在意这一点,那么我不推荐你使用方案2。
|
||||
|
||||
## 实现代码示例
|
||||
|
||||
讲了这么多纯理论的东西,接下来,我们来看看实际的实现代码大概是什么样子。毕竟,就像Linus说的:“Talk is cheap, show me the code!”
|
||||
|
||||
我先跟你分享一段方案1的主体代码:
|
||||
|
||||
```
|
||||
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的多线程架构。
|
||||
|
||||
对于方案2来说,核心的代码是这样的:
|
||||
|
||||
```
|
||||
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));
|
||||
}
|
||||
}
|
||||
..
|
||||
|
||||
```
|
||||
|
||||
这段代码最重要的地方是最后一行:当Consumer的poll方法返回消息后,由专门的线程池来负责处理具体的消息。调用poll方法的主线程不负责消息处理逻辑,这样就实现了方案2的多线程架构。
|
||||
|
||||
## 小结
|
||||
|
||||
总结一下,今天我跟你分享了Kafka Java Consumer多线程消费的实现方案。我给出了比较通用的两种方案,并介绍了它们各自的优缺点以及代码示例。我希望你能根据这些内容,结合你的实际业务场景,实现适合你自己的多线程架构,真正做到举一反三、融会贯通,彻底掌握多线程消费的精髓,从而在日后实现更宏大的系统。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/b1/8e3ca3a977b7ee373878b732be6646b1.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
今天我们讨论的都是多线程的方案,可能有人会说,何必这么麻烦,我直接启动多个Consumer进程不就得了?那么,请你比较一下多线程方案和多进程方案,想一想它们各自的优劣之处。
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
141
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/21 | Java 消费者是如何管理TCP连接的?.md
Normal file
141
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/21 | Java 消费者是如何管理TCP连接的?.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<audio id="audio" title="21 | Java 消费者是如何管理TCP连接的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/96/2481b5d4d26b94ef7b6ba91dc3ac7b96.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要和你分享的主题是:Kafka的Java消费者是如何管理TCP连接的。
|
||||
|
||||
在专栏[第13讲](https://time.geekbang.org/column/article/103844)中,我们专门聊过“Java**生产者**是如何管理TCP连接资源的”这个话题,你应该还有印象吧?今天算是它的姊妹篇,我们一起来研究下Kafka的Java**消费者**管理TCP或Socket资源的机制。只有完成了今天的讨论,我们才算是对Kafka客户端的TCP连接管理机制有了全面的了解。
|
||||
|
||||
和之前一样,我今天会无差别地混用TCP和Socket两个术语。毕竟,在Kafka的世界中,无论是ServerSocket,还是SocketChannel,它们实现的都是TCP协议。或者这么说,Kafka的网络传输是基于TCP协议的,而不是基于UDP协议,因此,当我今天说到TCP连接或Socket资源时,我指的是同一个东西。
|
||||
|
||||
## 何时创建TCP连接?
|
||||
|
||||
我们先从消费者创建TCP连接开始讨论。消费者端主要的程序入口是KafkaConsumer类。**和生产者不同的是,构建KafkaConsumer实例时是不会创建任何TCP连接的**,也就是说,当你执行完new KafkaConsumer(properties)语句后,你会发现,没有Socket连接被创建出来。这一点和Java生产者是有区别的,主要原因就是生产者入口类KafkaProducer在构建实例的时候,会在后台默默地启动一个Sender线程,这个Sender线程负责Socket连接的创建。
|
||||
|
||||
从这一点上来看,我个人认为KafkaConsumer的设计比KafkaProducer要好。就像我在第13讲中所说的,在Java构造函数中启动线程,会造成this指针的逃逸,这始终是一个隐患。
|
||||
|
||||
如果Socket不是在构造函数中创建的,那么是在KafkaConsumer.subscribe或KafkaConsumer.assign方法中创建的吗?严格来说也不是。我还是直接给出答案吧:**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连接?
|
||||
|
||||
下面我们来说说消费者创建TCP连接的数量。你可以先思考一下大致需要的连接数量,然后我们结合具体的Kafka日志,来验证下结果是否和你想的一致。
|
||||
|
||||
我们来看看这段日志。
|
||||
|
||||
>
|
||||
**[2019-05-27 10:00:54,142] DEBUG [Consumer clientId=consumer-1, groupId=test] Initiating connection to node localhost:9092 (id: -1 rack: null) using address localhost/127.0.0.1 (org.apache.kafka.clients.NetworkClient:944)**
|
||||
|
||||
|
||||
>
|
||||
**…**
|
||||
|
||||
|
||||
>
|
||||
**[2019-05-27 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) (org.apache.kafka.clients.NetworkClient:1097)**
|
||||
|
||||
|
||||
>
|
||||
**…**
|
||||
|
||||
|
||||
>
|
||||
**[2019-05-27 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 (org.apache.kafka.clients.NetworkClient:496)**
|
||||
|
||||
|
||||
>
|
||||
**[2019-05-27 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} (org.apache.kafka.clients.NetworkClient:837)**
|
||||
|
||||
|
||||
>
|
||||
**…**
|
||||
|
||||
|
||||
>
|
||||
**[2019-05-27 10:00:54,204] DEBUG [Consumer clientId=consumer-1, groupId=test] Initiating connection to node localhost:9094 (id: 2147483645 rack: null) using address localhost/127.0.0.1 (org.apache.kafka.clients.NetworkClient:944)**
|
||||
|
||||
|
||||
>
|
||||
**…**
|
||||
|
||||
|
||||
>
|
||||
**[2019-05-27 10:00:54,237] DEBUG [Consumer clientId=consumer-1, groupId=test] Initiating connection to node localhost:9094 (id: 2 rack: null) using address localhost/127.0.0.1 (org.apache.kafka.clients.NetworkClient:944)**
|
||||
|
||||
|
||||
>
|
||||
**[2019-05-27 10:00:54,237] DEBUG [Consumer clientId=consumer-1, groupId=test] Initiating connection to node localhost:9092 (id: 0 rack: null) using address localhost/127.0.0.1 (org.apache.kafka.clients.NetworkClient:944)**
|
||||
|
||||
|
||||
>
|
||||
**[2019-05-27 10:00:54,238] DEBUG [Consumer clientId=consumer-1, groupId=test] Initiating connection to node localhost:9093 (id: 1 rack: null) using address localhost/127.0.0.1 (org.apache.kafka.clients.NetworkClient:944)**
|
||||
|
||||
|
||||
这里我稍微解释一下,日志的第一行是消费者程序创建的第一个TCP连接,就像我们前面说的,这个Socket用于发送FindCoordinator请求。由于这是消费者程序创建的第一个连接,此时消费者对于要连接的Kafka集群一无所知,因此它连接的Broker节点的ID是-1,表示消费者根本不知道要连接的Kafka Broker的任何信息。
|
||||
|
||||
值得注意的是日志的第二行,消费者复用了刚才创建的那个Socket连接,向Kafka集群发送元数据请求以获取整个集群的信息。
|
||||
|
||||
日志的第三行表明,消费者程序开始发送FindCoordinator请求给第一步中连接的Broker,即localhost:9092,也就是nodeId等于-1的那个。在十几毫秒之后,消费者程序成功地获悉协调者所在的Broker信息,也就是第四行标为橙色的“node_id = 2”。
|
||||
|
||||
完成这些之后,消费者就已经知道协调者Broker的连接信息了,因此在日志的第五行发起了第二个Socket连接,创建了连向localhost:9094的TCP。只有连接了协调者,消费者进程才能正常地开启消费者组的各种功能以及后续的消息消费。
|
||||
|
||||
在日志的最后三行中,消费者又分别创建了新的TCP连接,主要用于实际的消息获取。还记得我刚才说的吗?要消费的分区的领导者副本在哪台Broker上,消费者就要创建连向哪台Broker的TCP。在我举的这个例子中,localhost:9092,localhost:9093和localhost:9094这3台Broker上都有要消费的分区,因此消费者创建了3个TCP连接。
|
||||
|
||||
看完这段日志,你应该会发现日志中的这些Broker节点的ID在不断变化。有时候是-1,有时候是2147483645,只有在最后的时候才回归正常值0、1和2。这又是怎么回事呢?
|
||||
|
||||
前面我们说过了-1的来由,即消费者程序(其实也不光是消费者,生产者也是这样的机制)首次启动时,对Kafka集群一无所知,因此用-1来表示尚未获取到Broker数据。
|
||||
|
||||
那么2147483645是怎么来的呢?它是**由Integer.MAX_VALUE减去协调者所在Broker的真实ID计算得来的**。看第四行标为橙色的内容,我们可以知道协调者ID是2,因此这个Socket连接的节点ID就是Integer.MAX_VALUE减去2,即2147483647减去2,也就是2147483645。这种节点ID的标记方式是Kafka社区特意为之的结果,目的就是要让组协调请求和真正的数据获取请求使用不同的Socket连接。
|
||||
|
||||
至于后面的0、1、2,那就很好解释了。它们表征了真实的Broker ID,也就是我们在server.properties中配置的broker.id值。
|
||||
|
||||
我们来简单总结一下上面的内容。通常来说,消费者程序会创建3类TCP连接:
|
||||
|
||||
1. 确定协调者和获取集群元数据。
|
||||
1. 连接协调者,令其执行组成员管理操作。
|
||||
1. 执行实际的消息获取。
|
||||
|
||||
那么,这三类TCP请求的生命周期都是相同的吗?换句话说,这些TCP连接是何时被关闭的呢?
|
||||
|
||||
## 何时关闭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连接存在。
|
||||
|
||||
## 可能的问题
|
||||
|
||||
从理论上说,Kafka Java消费者管理TCP资源的机制我已经说清楚了,但如果仔细推敲这里面的设计原理,还是会发现一些问题。
|
||||
|
||||
我们刚刚讲过,第一类TCP连接仅仅是为了首次获取元数据而创建的,后面就会被废弃掉。最根本的原因是,消费者在启动时还不知道Kafka集群的信息,只能使用一个“假”的ID去注册,即使消费者获取了真实的Broker ID,它依旧无法区分这个“假”ID对应的是哪台Broker,因此也就无法重用这个Socket连接,只能再重新创建一个新的连接。
|
||||
|
||||
为什么会出现这种情况呢?主要是因为目前Kafka仅仅使用ID这一个维度的数据来表征Socket连接信息。这点信息明显不足以确定连接的是哪台Broker,也许在未来,社区应该考虑使用**<主机名、端口、ID>**三元组的方式来定位Socket资源,这样或许能够让消费者程序少创建一些TCP连接。
|
||||
|
||||
也许你会问,反正Kafka有定时关闭机制,这算多大点事呢?其实,在实际场景中,我见过很多将connection.max.idle.ms设置成-1,即禁用定时关闭的案例,如果是这样的话,这些TCP连接将不会被定期清除,只会成为永久的“僵尸”连接。基于这个原因,社区应该考虑更好的解决方案。
|
||||
|
||||
## 小结
|
||||
|
||||
好了,今天我们补齐了Kafka Java客户端管理TCP连接的“拼图”。我们不仅详细描述了Java消费者是怎么创建和关闭TCP连接的,还对目前的设计方案提出了一些自己的思考。希望今后你能将这些知识应用到自己的业务场景中,并对实际生产环境中的Socket管理做到心中有数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/04/f13d7008d7b251df0e6e6a89077d7604.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
假设有个Kafka集群由2台Broker组成,有个主题有5个分区,当一个消费该主题的消费者程序启动时,你认为该程序会创建多少个Socket连接?为什么?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
140
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/22 | 消费者组消费进度监控都怎么实现?.md
Normal file
140
极客时间专栏/geek/Kafka核心技术与实战/客户端实践及原理剖析/22 | 消费者组消费进度监控都怎么实现?.md
Normal file
@@ -0,0 +1,140 @@
|
||||
<audio id="audio" title="22 | 消费者组消费进度监控都怎么实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/f0/80d9eab437cd0fe6689b2e83d33787f0.mp3"></audio>
|
||||
|
||||
你好,我是胡夕。今天我要跟你分享的主题是:消费者组消费进度监控如何实现。
|
||||
|
||||
对于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脚本。
|
||||
1. 使用Kafka Java Consumer API编程。
|
||||
1. 使用Kafka自带的JMX监控指标。
|
||||
|
||||
接下来,我们分别来讨论下这3种方法。
|
||||
|
||||
## Kafka自带命令
|
||||
|
||||
我们先来了解下第一种方法:使用Kafka自带的命令行工具bin/kafka-consumer-groups.sh(bat)。**kafka-consumer-groups脚本是Kafka为我们提供的最直接的监控消费者消费进度的工具**。当然,除了监控Lag之外,它还有其他的功能。今天,我们主要讨论如何使用它来监控Lag。
|
||||
|
||||
如果只看名字,你可能会以为它只是操作和管理消费者组的。实际上,它也能够监控独立消费者(Standalone Consumer)的Lag。我们之前说过,**独立消费者就是没有使用消费者组机制的消费者程序**。和消费者组相同的是,它们也要配置group.id参数值,但和消费者组调用KafkaConsumer.subscribe()不同的是,独立消费者调用KafkaConsumer.assign()方法直接消费指定分区。今天的重点不是要学习独立消费者,你只需要了解接下来我们讨论的所有内容都适用于独立消费者就够了。
|
||||
|
||||
使用kafka-consumer-groups脚本很简单。该脚本位于Kafka安装目录的bin子目录下,我们可以通过下面的命令来查看某个给定消费者的Lag值:
|
||||
|
||||
```
|
||||
$ bin/kafka-consumer-groups.sh --bootstrap-server <Kafka broker连接信息> --describe --group <group名称>
|
||||
|
||||
```
|
||||
|
||||
**Kafka连接信息就是<主机名:端口>对,而group名称就是你的消费者程序中设置的group.id值**。我举个实际的例子来说明具体的用法,请看下面这张图的输出:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/7d/18bc0ee629cfa761b1d17e638be9f67d.png" alt="">
|
||||
|
||||
在运行命令时,我指定了Kafka集群的连接信息,即localhost:9092。另外,我还设置了要查询的消费者组名:testgroup。kafka-consumer-groups脚本的输出信息很丰富。首先,它会按照消费者组订阅主题的分区进行展示,每个分区一行数据;其次,除了主题、分区等信息外,它会汇报每个分区当前最新生产的消息的位移值(即LOG-END-OFFSET列值)、该消费者组当前最新消费消息的位移值(即CURRENT-OFFSET值)、LAG值(前两者的差值)、消费者实例ID、消费者连接Broker的主机名以及消费者的CLIENT-ID信息。
|
||||
|
||||
毫无疑问,在这些数据中,我们最关心的当属LAG列的值了,图中每个分区的LAG值大约都是60多万,这表明,在我的这个测试中,消费者组远远落后于生产者的进度。理想情况下,我们希望该列所有值都是0,因为这才表明我的消费者完全没有任何滞后。
|
||||
|
||||
有的时候,你运行这个脚本可能会出现下面这种情况,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/04/59f9fd209e9559c098d2f56b8c959c04.png" alt="">
|
||||
|
||||
简单比较一下,我们很容易发现它和前面那张图输出的区别,即CONSUMER-ID、HOST和CLIENT-ID列没有值!如果碰到这种情况,你不用惊慌,这是因为我们运行kafka-consumer-groups脚本时没有启动消费者程序。请注意我标为橙色的文字,它显式地告诉我们,当前消费者组没有任何active成员,即没有启动任何消费者实例。虽然这些列没有值,但LAG列依然是有效的,它依然能够正确地计算出此消费者组的Lag值。
|
||||
|
||||
除了上面这三列没有值的情形,还可能出现的一种情况是该命令压根不返回任何结果。此时,你也不用惊慌,这是因为你使用的Kafka版本比较老,kafka-consumer-groups脚本还不支持查询非active消费者组。一旦碰到这个问题,你可以选择升级你的Kafka版本,也可以采用我接下来说的其他方法来查询。
|
||||
|
||||
## Kafka Java Consumer API
|
||||
|
||||
很多时候,你可能对运行命令行工具查询Lag这种方式并不满意,而是希望用程序的方式自动化监控。幸运的是,社区的确为我们提供了这样的方法。这就是我们今天要讲的第二种方法。
|
||||
|
||||
简单来说,社区提供的Java Consumer API分别提供了查询当前分区最新消息位移和消费者组最新消费消息位移两组方法,我们使用它们就能计算出对应的Lag。
|
||||
|
||||
下面这段代码展示了如何利用Consumer端API监控给定消费者组的Lag值:
|
||||
|
||||
```
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你不用完全了解上面这段代码每一行的具体含义,只需要记住3处地方即可:第1处是调用AdminClient.listConsumerGroupOffsets方法获取给定消费者组的最新消费消息的位移;第2处则是获取订阅分区的最新消息位移;最后1处就是执行相应的减法操作,获取Lag值并封装进一个Map对象。
|
||||
|
||||
我把这段代码送给你,你可以将lagOf方法直接应用于你的生产环境,以实现程序化监控消费者Lag的目的。**不过请注意,这段代码只适用于Kafka 2.0.0及以上的版本**,2.0.0之前的版本中没有AdminClient.listConsumerGroupOffsets方法。
|
||||
|
||||
## Kafka JMX监控指标
|
||||
|
||||
上面这两种方式,都可以很方便地查询到给定消费者组的Lag信息。但在很多实际监控场景中,我们借助的往往是现成的监控框架。如果是这种情况,以上这两种办法就不怎么管用了,因为它们都不能集成进已有的监控框架中,如Zabbix或Grafana。下面我们就来看第三种方法,使用Kafka默认提供的JMX监控指标来监控消费者的Lag值。
|
||||
|
||||
当前,Kafka消费者提供了一个名为kafka.consumer:type=consumer-fetch-manager-metrics,client-id=“{client-id}”的JMX指标,里面有很多属性。和我们今天所讲内容相关的有两组属性:**records-lag-max和records-lead-min**,它们分别表示此消费者在测试窗口时间内曾经达到的最大的Lag值和最小的Lead值。
|
||||
|
||||
Lag值的含义我们已经反复讲过了,我就不再重复了。**这里的Lead值是指消费者最新消费消息的位移与分区当前第一条消息位移的差值**。很显然,Lag和Lead是一体的两个方面:**Lag越大的话,Lead就越小,反之也是同理**。
|
||||
|
||||
你可能会问,为什么要引入Lead呢?我只监控Lag不就行了吗?这里提Lead的原因就在于这部分功能是我实现的。开个玩笑,其实社区引入Lead的原因是,只看Lag的话,我们也许不能及时意识到可能出现的严重问题。
|
||||
|
||||
试想一下,监控到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,这说明此消费者有很大的消费滞后性。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/52/598a8e2c16efb23b1dc07376773c7252.png" alt="">
|
||||
|
||||
**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指标:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/85/4a/850e91e0025c2443aebce21a29ac784a.png" alt="">
|
||||
|
||||
分区级别的JMX指标中多了records-lag-avg和records-lead-avg两个属性,可以计算平均的Lag值和Lead值。在实际场景中,我们会更多地使用这两个JMX指标。
|
||||
|
||||
## 小结
|
||||
|
||||
我今天完整地介绍了监控消费者组以及独立消费者程序消费进度的3种方法。从使用便捷性上看,应该说方法1是最简单的,我们直接运行Kafka自带的命令行工具即可。方法2使用Consumer API组合计算Lag,也是一种有效的方法,重要的是它能集成进很多企业级的自动化监控工具中。不过,集成性最好的还是方法3,直接将JMX监控指标配置到主流的监控框架就可以了。
|
||||
|
||||
在真实的线上环境中,我建议你优先考虑方法3,同时将方法1和方法2作为备选,装进你自己的工具箱中,随时取出来应对各种实际场景。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/e2/c2a03833838589fa5839c7c27f3982e2.jpg" alt="">
|
||||
|
||||
## 开放讨论
|
||||
|
||||
请说说你对这三种方法的看法。另外,在真实的业务场景中,你是怎么监控消费者进度的呢?
|
||||
|
||||
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
Reference in New Issue
Block a user