kafka学习笔记(九) --- Consumer 位移管理那些事儿

  • 位移主题(Offset Topic)

__consumer_offsets是kafka内部的主题,这里使用位移主题指代__consumer_offsets。

在上一章中我们讲过,老版本Consumer的位移管理依托于Apache Zookeeper的,自动的或手动的将位移提交给Zookeeper中保存。这种设计使得Kafka Broker不需要保存位移数据,减少了Broker端需要持有的状态空间,有利于实现高伸缩性。但是Zookeeper并不适用于高频的写操作,kafka社区自0.8.2.x开始,酝酿着修改掉这种设计,最终在更新版本Consumer中正式推出全新的位移管理机制,不过0.9新版本consumer刚刚推出,一堆bug,还是不要用它了。

将Consumer的位移数据作为一条条普通的Kafka消息,提交到__consumer_offsets主题中。位移主题和普通的kafka主题一样,可以手动的创建、删除,只不过是个内部主题,大部分情况都是由kafka自己管理。位移主体的消息格式kafka已经定好了,用户不能修改,也就是不能随意向这个主意写消息,因为一旦写入的消息不满足kafka规定的格式,kafka内部就无法成功解析。造成Broker崩溃。事实上,Kafka Consumer有API帮你提交位移,也就是向位移主题写消息。

那么位移主题的消息格式是什么样的呢?前面说过他的消息格式就是KV对,Key表示键值,Value表示消息体。Key的内容:<Gruop ID, 主题名, 分区号>。Group ID唯一标识消费消息的主题,主题名和分区号自然表示Consumer Group消费的主题和组内实例消费的分区。另外多说几句,除了Consumer Group,kafka还支持Consumer,也称 Standalone Consumer。它的运行机制与Consumer Group完全不同,但是位移管理的机制相同,所以也是适用这套消息格式的。消息体的设计,除了保存位移值外,还要保存位移提交的其他一些元数据,如时间戳和用户自定义的数据等。保存这些元数据,是为了帮助Kafka执行各种各样的后续操作,比如删除过期位移消息等。

位移主题的格式还有另外2种:用于保存Consumer Group信息的消息;用于删除Group过期位移甚至删除Group的消息。前面一种格式非常神秘,以至于几乎无法搜到它,只需要记住它是用来注册Consumer Group的。所以一般情况,位移主题中会有两种格式消息,一种是保存实例消费的位移消息,一种就是这个组注册消息,Key是Group ID。后面一种格式,有专属的称呼:tombstone消息,即墓碑消息也称delete mark。这些消息只出现在源码中,主要特点是他的消息体是null,即空消息体。什么时候写入这类消息,一旦某个Consumer Group下所有Consumer实例都停止,而且他们的位移数据都已被删除,Kafka会向位移主题对应分区写入tombstone消息,表明要彻底删除这个Group 的信息。

接下来看看位移主题怎么被创建?当Kafka集群中第一个Consumer程序启动时,Kafka会自动创建位移主题。分区数由Brokerd端参数offsets.topic.num.partitions的指定,默认值50,副本数或备份因子有Broker端另一个参数offfsets.topic.replication.factor指定,默认值3。当然你也可以手动创建位移主题,具体方法是,在Kafka集群尚未启动Consumer之前,使用Kafka API创建它。手动创建的好处在于,可以创建满足实际场景需要的位移主题,比如说50个分区太多,可以自己创建它,不用理会参数值。不过还是推荐Kafka自动创建,目前Kafka源码有一些地方硬编码50个分区数,如果你自行闯进一个不同于默认分区数的位移主题,可能会碰到各种各样的问题,这个是社区的BUG,目前已修复但仍在审核中。

什么时候会用到位移主题?Kafka Consumer提交位移的时候写入该主题,提交位移方式有两种:手动提交和自动提交。Consumer端有个参数auto.commit.enable,如果值为true,则后台默认定期提交位移,提交间隔由一个专属参数auto.commit.interval.ms控制。自动提交省事儿,但是丧失了灵活性和可控性。事实上,很多集成了Kafka的的大数据框架都是禁用自动提交的,如Spark、Flink等。那就要使用手动提交位移,先要将参数设置成false,Kafka Consumer API为你提供了位移提交的方法,如consumer.commitSync等。另外,自动提交还有一个问题,那就是只要Consumer一直启动着,就会无限期的往位移主题写入消息。举一个极端的例子,假设Consumer当前消费的某主题的最新一条消息,位移是100,之后该主题没有任何消息产生,故Consumer无消息可消费,由于是自动提交,位移主题中会不停的写入位移等于100的消息。显然只需要保存一条这样的消息就可以了,这就要求Kafka必须要有针对位移主题消息特点的消息删除策略,否则消息会越来越多,最终撑爆整个磁盘。

Kafka是怎么删除位移主题中的过期消息?答案是Compaction。翻译成压实或者整理,但是很多人翻译成压缩,有些欠妥。Kafka使用Compact策略删除位移主题过期消息,避免该主题无限膨胀。那如何定义过期呢?对于同一个Key的两条消息M1和M2,如果M1发送时间早于M2,那M1就是过期消息,对应参数是offsets.retention.minutes。Compact过程就是扫描日志所有消息,剔除过期的消息,把剩下的放到一起。这里贴一张官网的图片:

                                                       

Kafka专门提供后台线程定期巡检待Compact的主题,看看是否存在满足条件的可删除数据,这个后台线程叫Log Cleaner。如果你的生产环境中出现过位移主题无限膨胀的问题,建议检查一下这个线程状态,看是否挂掉。

实际上,将很多元数据存入内部主题的做法越来越流行,除了位移管理,kafka事务也利用这个方法,但是另外一个主题。社区想法很简单:既然kafka天然实现高持久性和高吞吐量,那么任何子服务有这两方面需求,都可以Kafka自己去实现,不必求助于外部系统。

  • 位移提交

之前说过,Consumer的消费位移跟分区位移不是一个概念,但是值的具体形式是一样的,消费位移记录的是Consumer要消费的下一条消息的位移,而不是目前消费的最新位移。那么,Consumer需要向Kafka汇报自己的位移数据,这个汇报过程被称为提交位移。Consumer需要为分配给他的每个分区提交各自的位移数据。

提交位移主要是为了表征Consumer的消费进度,这样当Consumer发生故障重启后,就能够从kafka中读取之前提交的位移值,避免整个消费过程重来一遍。换句话说,位移提交,是kafka提供给你的一个工具或语义保障,你负责维护这个语义保障,即你提交了位移X,那么kafka就认为所有位移值小于X的消息都被你消费过了。位移提交非常灵活,你完全可以提交任何位移值,但由此产生的后果你也要一并承担。假设你的Consumer消费了10条消息,你提交的位移是20,那么介于11~19之间的消息是有可能丢失的;相反地,你提交了5的位移值,那么介于5~9之间的位移就可能被重复消费了。所以位移提交的语义保障是由你负责,kafka只会“无脑”接收你提交的位移。

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

所谓自动提交,就是指Kafka Consumer在后台默默地为你提交位移。在Consumer端有个参数enable.auto.commit,把他设置成true或者压根不管他就可以,因为默认值就是true,如果你启用了自动提交,还有一个参数是有用的,auto.commit.interval.ms,默认值是5秒,表示每5秒自动提交一次。下面展示了设置自动提交位移的方法:

                                           

如果使用手动提交位移,那么上面的参数就要显示地设置成false,再在代码中调用手动提交位移的API。最简单的API就是KafkaConsumer#commitSync(),该方法会提交KafkaConsumer#poll()返回的最新位移。从名字看就知道它是一个同步操作,即该方法会一直等待,直到位移提交成功返回。如果提交过程出现异常,就会抛出异常。看下面代码展示:

                                           

再来说说自动提交可能出现的问题以及手动提交的优缺点。默认情况下,Consumer每5秒自动提交一次位移,现在我们假设提交位移之后的3秒发生了Rebalance操作,在Rebalance之后,所有Consumer从上一次提交的位移处继续消费,但该位移已经是3秒之前的数据,故在Rebalance之前的3秒消费的所有数据都要重新消费一次。虽然你能通过调整参数auto.commit.interval.ms的值来提高频率,但终究还是不能消除。值得注意的是,这里提交间隔其实是至少的意思,比如单线程处理消息,那么只有处理完消息后才会提交位移,可能远比你设置的时间长;反过来,如果消息已经处理完,但还没有到达这个至少时间,仍然需要等待达到这个时间再提交。反观手动提交,好处在于更加灵活,完全能够把控位移提交的时机和频率。缺陷是在调用commitSync()时,Consumer处于阻塞状态,直到远端Broker返回结果,这个状态才会结束。在任何系统中,因为程序而非资源限制造成的阻塞都可能是系统的瓶颈,会影响整个应用的TPS。如果想拉长提交时间间隔,会使提交频率下降,下次重启回来,会有更多消息被重新消费。

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

                                              

commitAsync是否能替代commitSync?当然不能。异步提交的问题在于,出现问题不会重试,而且异步提交的重试也没有意义,因为你想一下,消费和提交是分开进行的,当提交失败后自动重试,其实重试提交的位移值早已不是最新值。因此,如果手动提交时,能将同步提交和异步提交组合使用,那就能达到最理想的效果,因为:

  1. 我们可以利用commitSync的自动重试规避瞬时错误,比如网络抖动造成、Broker端的GC等。
  2. 我们不希望程序总是处于阻塞状态,影响TPS。

下面展示一下组合使用同步提交和异步提交的代码:

                                            

对于常规性、阶段性的手动提交,调用commitAsync()避免程序阻塞,而在程序关闭前,调用commitSync()执行同步阻塞式的位移提交,确保能够保存正确的位移数据。以上所说的都是提交poll方法返回地所有消息的位移,比如poll方法一次返回500条消息,当你处理完这500条消息,会有此行的额将这500条消息的位移一并处理。简单来说,直接提交最新一条消息的位移。但是如果想更细粒度的提交位移,该怎么办?

更细粒度的场景,先来解释一下,假如:你的poll方法返回的是5000条消息肯定不想把这5000条消息处理完再提交位移,因为一旦中间出错,之前处理的全部都要重来一遍。对于一次要处理很多消息的consumer而言,有没有方法允许在消费的中间进行位移提交,比如没处理100个消息就提交一次位移,这样能避免大批量的消息重新消费。Kafka Consumer API为手动提价提供了这样的方法:commitSync(Map<TopicPartition, OffsetAndMetadata>) 和 commitAsync(Map<TopicPartition, OffsetAndMetadata>)。OffsetAndMetadata保存的就是位移数据。

下面展示commitAsync调用示例,其实commitSync调用和他一样:

                                          

无论是自动提交还是手动提交位移,都是无法完全避免消息的重复消费,我们可以考虑,将offset提交个事件处理结果放入一个支持原子性操作的存储可以避免,类似于事务。另外Kafka Streams支持精确处理语义,也可以一试。

  • CommitFailedException异常处理

所谓的CommitFailedException,就是Consumer在提交位移是出现错误或者异常,而且还是那种不可恢复的严重异常。如果异常可恢复,那么提交位移API自己就可以规避,commitSync方法。每次和CommitFailedException一起出现还有一段著名的注释,下面看看社区的最新解释:

                                          

这段话前半部分意思是,本次提交位移失败,原因是消费者组已经开启Rebalance过程,并且将要提交唯一的分区分配其他实例,出现这种情况原因是,你的消费者实例连续两次调用poll方法时间间隔超过了max.poll.interval.ms参数值。者通吃表明你的实例花费太长时间来处理消息,耽误poll调用。橙色字部分给出了相应的解决办法:

  1. 增加参数期望值;
  2. 减少poll方法一次性返回的消息数量,即减少max.poll.records参数值。

其实这段文字还有 两段旧版本:

                                       

下面来讨论一下异常是什么时候抛出的,从源代码方面来说,是出现在手动提交位移时,即用户显示调用KafkaConsumer.commitSync()方法时。有两种典型场景可能会遭遇该异常。

场景一:当消息处理总时间超过预设的max.poll.interval.ms参数值时,Consumer端会抛出这个异常。下面用一个成来模拟一下:

                                            

如果要避免这种场景下抛出异常,需要简化的你的消息处理逻辑。有4中方法:

  1. 缩短单条消息处理时间,这个就需要优化消费系统。
  2. 增加Consumer端允许下游系统消费一批消息的最大时长,max.poll.interval.ms,默认值是5分钟。注意,这个参数是在0.10.1.0版本引入的。如果你依然在使用之前客户端API,就需要增加session.timeout.ms参数值。但还是这个参数还有其他意思,所以增加值可能会引起其他不良影响,这也是社区引入max.poll.interval.ms参数的原因。
  3. 减少下游系统一次性消费的消息总数,这取决于max.poll.records参数值。
  4. 下游系统使用多线程唉加速消费。这应该是最高级的同时也是最难实现的解决放办法了。具体的思路就是创建多个消费线程处理poll方法返回的消息,你可以灵活地控制线程数量,随时调整消费承载能力,再配以目前多核的硬件条件。事实上,很多主流大数据处理框架使用的都是这个方法,比如Apache Flink在集成Kafka是,就是创建多个KafkaConsumerThread线程,自行处理多线程间的数据消费。但是,多线程处理极易出现错误,特别是多线程在处理位移提交这个问题。

综合以上4中处理方法,个人推荐方法1来首先尝试预防此异常发生,但是如果方法1实现起来有难度,可以按照下面法则来实践2、3。

首先,需要弄清楚下游系统消费每条消息平均时延是多少。比如,你的消费逻辑从Kafka获取消息后写入到下游MongoDB中,假设访问MongoDB平演示不超过2s。如果按照max.poll.records=500来计算,一批消息的总消费时长大约是1000秒,所以参数值max.poll.interval.ms不能低于1000秒。如果使用默认值,那么就很大概率会出现CommitFailedException异常。

其次,你还以调整max.poll.records的值,还拿刚才例子,可以设置为150,甚至更少,这样每批消息的总消费时长不会超过300秒,即max.poll.interval.ms的默认值。

场景二:从理论上讲,关于该异常你了解到这个程度,已经足以帮你应对应用开发过程中由该异常带来的“坑”了。但是还有一个比较冷门的场景,可以帮你拓宽Kafka的知识面。Kafka Java Consumer端还提供了一个名为Standalone Consumer的独立消费者。独立消费者没有组的概念,彼此间独立,但是位移提交个消费者组是一样的,因此独立的消费者也必须遵循之前说的那些规定。Standalone Consumer并未出现在官方文档中,你可以在javadoc中看到一些:https://kafka.apache.org/23/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html#manualassignment

那么问题来了,如果你的应用中同时出现设置了相同的group.id值的消费者组和独立消费者,那么当独立消费者程序手动提交位移时,Kafka就会抛出该异常,因为afka无法识别具有相同group.id的消费实例。一旦出现不凑巧的重复group.id,出现了该异常,上面说的所有方法都不能规避该异常。比起返回该异常只是表明提交位移失败,更好做法应该是,在Consumer端应用程序的某个地方,能够以日志或其他友好的方式提示你错误的原因。

标注:这个系列文章是本人在极客时间专栏---kafka核心技术与实战中的学习笔记

    https://time.geekbang.org/column/article/101171

发布了37 篇原创文章 · 获赞 20 · 访问量 4956

猜你喜欢

转载自blog.csdn.net/qq_24436765/article/details/102503034