This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View 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&lt;PartitionInfo&gt; 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&lt;PartitionInfo&gt; 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&lt;PartitionInfo&gt; partitions = cluster.partitionsForTopic(topic);
return partitions.stream().filter(p -&gt; isSouth(p.leader().host())).map(PartitionInfo::partition).findAny().get();
```
我们可以从所有分区中找出那些Leader副本在南方的所有分区然后随机挑选一个进行消息发送。
## 小结
今天我们讨论了Kafka生产者消息分区的机制以及常见的几种分区策略。切记分区是实现负载均衡以及高吞吐量的关键故在生产者这一端就要仔细盘算合适的分区策略避免造成消息数据的“倾斜”使得某些分区成为性能瓶颈这样极易引发下游数据消费的性能下降。
<img src="https://static001.geekbang.org/resource/image/fb/13/fb38053d6f7f880ab12fef7ee0d64813.jpg" alt="">
## 开放讨论
在你的生产环境中使用最多的是哪种消息分区策略?实际在使用过程中遇到过哪些“坑”?
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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(&quot;bootstrap.servers&quot;, &quot;localhost:9092&quot;);
props.put(&quot;acks&quot;, &quot;all&quot;);
props.put(&quot;key.serializer&quot;, &quot;org.apache.kafka.common.serialization.StringSerializer&quot;);
props.put(&quot;value.serializer&quot;, &quot;org.apache.kafka.common.serialization.StringSerializer&quot;);
// 开启GZIP压缩
props.put(&quot;compression.type&quot;, &quot;gzip&quot;);
Producer&lt;String, String&gt; producer = new KafkaProducer&lt;&gt;(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 &gt; Snappy &gt; zstd和GZIP而在压缩比方面zstd &gt; LZ4 &gt; GZIP &gt; 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端执行解压缩操作。你认同这种方案吗
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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而言它当前的位移值就是9Consumer B的位移值是11。
这里的“位移”类似于我们看书时使用的书签,它会标记我们当前阅读了多少页,下次翻书的时候我们能直接跳到书签页继续阅读。
正确使用书签有两个步骤第一步是读书第二步是更新书签页。如果这两步的顺序颠倒了就可能出现这样的场景当前的书签页是第90页我先将书签放到第100页上之后开始读书。当阅读到第95页时我临时有事中止了阅读。那么问题来了当我下次直接跳到书签页阅读时我就丢失了第9699页的内容即这些消息就丢失了。
同理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 &gt; 0的Producer能够自动重试消息发送避免消息丢失。
</li>
<li>
设置unclean.leader.election.enable = false。这是Broker端的参数它控制的是哪些Broker有资格竞选分区的Leader。如果一个Broker落后原先的Leader太多那么它一旦成为新的Leader必然会造成消息的丢失。故一般都要将该参数设置成false即不允许这种情况的发生。
</li>
<li>
设置replication.factor &gt;= 3。这也是Broker端的参数。其实这里想表述的是最好将消息多保存几份毕竟目前防止消息丢失的主要机制就是冗余。
</li>
<li>
设置min.insync.replicas &gt; 1。这依然是Broker端参数控制的是消息至少要被写入到多少个副本才算是“已提交”。设置成大于1可以提升消息持久性。在实际环境中千万不要使用默认值1。
</li>
<li>
确保replication.factor &gt; 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设计上的一个小缺陷你有什么解决的办法吗
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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&lt;String&gt; interceptors = new ArrayList&lt;&gt;();
interceptors.add(&quot;com.yourcompany.kafkaproject.interceptors.AddTimestampInterceptor&quot;); // 拦截器1
interceptors.add(&quot;com.yourcompany.kafkaproject.interceptors.UpdateCounterInterceptor&quot;); // 拦截器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>
onCommitConsumer在提交位移之后调用该方法。通常你可以在该方法中做一些记账类的动作比如打日志等。
</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&lt;String, String&gt; {
private Jedis jedis; // 省略Jedis初始化
@Override
public ProducerRecord&lt;String, String&gt; onSend(ProducerRecord&lt;String, String&gt; record) {
jedis.incr(&quot;totalSentMessage&quot;);
return record;
}
@Override
public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
}
@Override
public void close() {
}
@Override
public void configure(Map&lt;java.lang.String, ?&gt; configs) {
}
```
上面的代码比较关键的是在发送消息前更新总的已发送消息数。为了节省时间,我没有考虑发送失败的情况,因为发送失败可能导致总发送数不准确。不过好在处理思路是相同的,你可以有针对性地调整下代码逻辑。
下面是消费者端的拦截器实现,代码如下:
```
public class AvgLatencyConsumerInterceptor implements ConsumerInterceptor&lt;String, String&gt; {
private Jedis jedis; //省略Jedis初始化
@Override
public ConsumerRecords&lt;String, String&gt; onConsume(ConsumerRecords&lt;String, String&gt; records) {
long lantency = 0L;
for (ConsumerRecord&lt;String, String&gt; record : records) {
lantency += (System.currentTimeMillis() - record.timestamp());
}
jedis.incrBy(&quot;totalLatency&quot;, lantency);
long totalLatency = Long.parseLong(jedis.get(&quot;totalLatency&quot;));
long totalSentMsgs = Long.parseLong(jedis.get(&quot;totalSentMessage&quot;));
jedis.set(&quot;avgLatency&quot;, String.valueOf(totalLatency / totalSentMsgs));
return records;
}
@Override
public void onCommit(Map&lt;TopicPartition, OffsetAndMetadata&gt; offsets) {
}
@Override
public void close() {
}
@Override
public void configure(Map&lt;String, ?&gt; 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&lt;K, V&gt; onSend(ProducerRecord&lt;K, V&gt; record)
```
如果我实现的逻辑仅仅是return null你觉得Kafka会丢弃该消息还是原封不动地发送消息请动手试验一下看看结果是否符合你的预期。
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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&lt;String, String&gt; producer = new KafkaProducer&lt;&gt;(props)) {
producer.send(new ProducerRecord&lt;String, String&gt;(……), 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中通常你指定34台就足以了。因为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&lt;TopicPartition, Deque&gt;。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可能只会与其中的35台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禁掉这种机制。一旦被设置成-1TCP连接将成为永久长连接。当然这只是软件层面的“长连接”机制由于Kafka创建的这些Socket连接都开启了keepalive因此keepalive探活机制还是会遵守的。
值得注意的是在第二种方式中TCP连接是在Broker端被关闭的但其实这个TCP连接的发起方是客户端因此在TCP看来这属于被动关闭的场景即passive close。被动关闭的后果就是会产生大量的CLOSE_WAIT连接因此Producer端或Client端没有机会显式地观测到此连接已被中断。
## 小结
我们来简单总结一下今天的内容。对最新版本的Kafka2.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="">
## 开放讨论
对于今天我们“挑战”的社区设计,你有什么改进的想法吗?
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View File

@@ -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使用场景吗
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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 GroupKafka是怎么管理位移的呢你还记得吧消费者在消费的过程中需要记录自己消费了多少数据即消费位置信息。在Kafka中这个位置信息有个专门的术语位移Offset
看上去该Offset就是一个数值而已其实对于Consumer Group而言它是一组KV对Key是分区V对应Consumer消费该分区的最新位移。如果用Java来表示的话你大致可以认为是这样的数据结构即Map&lt;TopicPartition, Long&gt;其中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你觉得这种消费者组设计的弊端有哪些呢
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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 GroupKafka还支持独立Consumer也称Standalone Consumer。它的运行机制与Consumer Group完全不同但是位移管理的机制却是相同的。因此即使是Standalone Consumer也有自己的Group ID来标识它自己所以也适用于这套消息格式。
Okay我们现在知道Key中保存了Group ID但是只保存Group ID就可以了吗别忘了Consumer提交位移是在分区层面上进行的即它提交的是某个或某些分区的位移那么很显然Key中还应该保存Consumer要提交位移的分区。
好了,我们来总结一下我们的结论。**位移主题的Key中应该保存3部分内容&lt;Group ID主题名分区号&gt;**。如果你认同这样的结论,那么恭喜你,社区就是这么设计的!
接下来我们再来看看消息体的设计。也许你会觉得消息体应该很简单保存一个位移值就可以了。实际上社区的方案要复杂得多比如消息体还保存了位移提交的一些其他元数据诸如时间戳和用户自定义的数据等。保存这些元数据是为了帮助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方案相比它可能的劣势是什么
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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 &gt;= 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发生的频率、原因以及你是怎么应对的我们一起讨论下是否有更好的解决方案。
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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那么从理论上讲位移介于1119之间的消息是有可能丢失的相反地如果你提交的位移值是5那么位移介于59之间的消息就有可能被重复消费。所以我想再强调一下**位移提交的语义保障是由你来负责的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(&quot;bootstrap.servers&quot;, &quot;localhost:9092&quot;);
props.put(&quot;group.id&quot;, &quot;test&quot;);
props.put(&quot;enable.auto.commit&quot;, &quot;true&quot;);
props.put(&quot;auto.commit.interval.ms&quot;, &quot;2000&quot;);
props.put(&quot;key.deserializer&quot;, &quot;org.apache.kafka.common.serialization.StringDeserializer&quot;);
props.put(&quot;value.deserializer&quot;, &quot;org.apache.kafka.common.serialization.StringDeserializer&quot;);
KafkaConsumer&lt;String, String&gt; consumer = new KafkaConsumer&lt;&gt;(props);
consumer.subscribe(Arrays.asList(&quot;foo&quot;, &quot;bar&quot;));
while (true) {
ConsumerRecords&lt;String, String&gt; records = consumer.poll(100);
for (ConsumerRecord&lt;String, String&gt; record : records)
System.out.printf(&quot;offset = %d, key = %s, value = %s%n&quot;, 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&lt;String, String&gt; records =
consumer.poll(Duration.ofSeconds(1));
process(records); // 处理消息
try {
consumer.commitSync();
} catch (CommitFailedException e) {
handle(e); // 处理提交失败异常
}
}
```
可见调用consumer.commitSync()方法的时机是在你处理完了poll()方法返回的所有消息之后。如果你莽撞地过早提交了位移,就可能会出现消费数据丢失的情况。那么你可能会问,自动提交位移就不会出现消费数据丢失的情况了吗?它能恰到好处地把握时机进行位移提交吗?为了搞清楚这个问题,我们必须要深入地了解一下自动提交位移的顺序。
一旦设置了enable.auto.commit为trueKafka会保证在开始调用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&lt;String, String&gt; records =
consumer.poll(Duration.ofSeconds(1));
process(records); // 处理消息
consumer.commitAsync((offsets, exception) -&gt; {
if (exception != null)
handle(exception);
});
}
```
commitAsync是否能够替代commitSync呢答案是不能。commitAsync的问题在于出现问题时它不会自动重试。因为它是异步操作倘若提交失败后自动重试那么它重试时提交的位移值可能早已经“过期”或不是最新值了。因此异步提交的重试其实没有意义所以commitAsync是不会重试的。
显然如果是手动提交我们需要将commitSync和commitAsync组合使用才能达到最理想的效果原因有两个
1. 我们可以利用commitSync的自动重试来规避那些瞬时错误比如网络的瞬时抖动Broker端GC等。因为这些问题都是短暂的自动重试通常都会成功因此我们不想自己重试而是希望Kafka Consumer帮我们做这件事。
1. 我们不希望程序总处于阻塞状态影响TPS。
我们来看一下下面这段代码它展示的是如何将两个API方法结合使用进行手动提交。
```
try {
while(true) {
ConsumerRecords&lt;String, String&gt; 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&lt;TopicPartition, OffsetAndMetadata&gt;)和commitAsync(Map&lt;TopicPartition, OffsetAndMetadata&gt;)。它们的参数是一个Map对象键就是TopicPartition即消费的分区而值是一个OffsetAndMetadata对象保存的主要是位移数据。
就拿刚刚提过的那个例子来说如何每处理100条消息就提交一次位移呢在这里我以commitAsync为例展示一段代码实际上commitSync的调用方法和它是一模一样的。
```
private Map&lt;TopicPartition, OffsetAndMetadata&gt; offsets = new HashMap&lt;&gt;();
int count = 0;
……
while (true) {
ConsumerRecords&lt;String, String&gt; records =
consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord&lt;String, String&gt; record: records) {
process(record); // 处理消息
offsets.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
ifcount % 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在处理完消息和提交位移前出现故障下次重启后依然会出现消息重复消费的情况。请你思考一下如何实现你的业务场景中的去重逻辑呢
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View File

@@ -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(&quot;max.poll.interval.ms&quot;, 5000);
consumer.subscribe(Arrays.asList(&quot;test-topic&quot;));
while (true) {
ConsumerRecords&lt;String, String&gt; 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种方法并说说你对它们的理解。
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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同时也是阻塞式的blockingConsumer实例启动后内部会创建很多阻塞式的消息获取迭代器。但在很多场景下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(&quot;topic&quot;));
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&lt;String, String&gt; consumer;
private ExecutorService executors;
...
private int workerNum = ...;
executors = new ThreadPoolExecutor(
workerNum, workerNum, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue&lt;&gt;(1000),
new ThreadPoolExecutor.CallerRunsPolicy());
...
while (true) {
ConsumerRecords&lt;String, String&gt; 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进程不就得了那么请你比较一下多线程方案和多进程方案想一想它们各自的优劣之处。
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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:9092localhost: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也许在未来社区应该考虑使用**&lt;主机名、端口、ID&gt;**三元组的方式来定位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连接为什么
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View 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 &lt;Kafka broker连接信息&gt; --describe --group &lt;group名称&gt;
```
**Kafka连接信息就是&lt;主机名:端口&gt;对而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&lt;TopicPartition, Long&gt; 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&lt;TopicPartition, OffsetAndMetadata&gt; 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&lt;String, String&gt; consumer = new KafkaConsumer&lt;&gt;(props)) {
Map&lt;TopicPartition, Long&gt; endOffsets = consumer.endOffsets(consumedOffsets.keySet());
return endOffsets.entrySet().stream().collect(Collectors.toMap(entry -&gt; entry.getKey(),
entry -&gt; 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(&quot;Timed out when getting lag for consumer group &quot; + 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="">
## 开放讨论
请说说你对这三种方法的看法。另外,在真实的业务场景中,你是怎么监控消费者进度的呢?
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。