Consumer
我们知道消息队列一般有两种实现方式,(1)Push(推模式) (2)Pull(拉模式),那么 Kafka Consumer 究竟采用哪种方式进行消费的呢?其实 Kafka Consumer 采用的是主动拉取 Broker 数据进行消费的即 Pull 模式。这两种方式各有优劣,我们来分析一下:
1)、为什么不采用Push模式?**如果是选择 Push 模式最大缺点就是 Broker 不清楚 Consumer 的消费速度,且推送速率是 Broker 进行控制的, 这样很容易造成消息堆积,如果 Consumer 中执行的任务操作是比较耗时的,那么 Consumer 就会处理的很慢, 严重情况可能会导致系统 Crash。
2)、为什么采用Pull模式?**如果选择 Pull 模式,这时 Consumer 可以根据自己的情况和状态来拉取数据, 也可以进行延迟处理。**但是 Pull 模式也有不足,Kafka 又是如何解决这一问题?**如果 Kafka Broker 没有消息,这时每次 Consumer 拉取的都是空数据, 可能会一直循环返回空数据。 针对这个问题,Consumer 在每次调用 Poll() 消费数据的时候,顺带一个 timeout 参数,当返回空数据的时候,会在 Long Polling 中进行阻塞,等待 timeout 再去消费,直到数据到达。
初始化 Consumer 有4步:
- 构造 Propertity 对象,进行 Consumer 相关的配置;
- 创建 KafkaConsumer 的对象 Consumer;
- 订阅相应的 Topic 列表;
- 调用 Consumer 的 poll() 方法拉取订阅的消息
消费者组
Consumer 消费者组机制,为什么 Kafka 要设计 Consumer Group, 只有 Consumer 不可以吗? 我们知道 Kafka 是一款高吞吐量,低延迟,高并发, 高可扩展性的消息队列产品, 那么如果某个 Topic 拥有数百万到数千万的数据量, 仅仅依靠 Consumer 进程消费, 消费速度可想而知, 所以需要一个扩展性较好的机制来保障消费进度, 这个时候 Consumer Group 应运而生, Consumer Group 是 Kafka 提供的可扩展且具有容错性的消费者机制。
Kafka Consumer Group 特点如下:
- 每个 Consumer Group 有一个或者多个 Consumer
- 每个 Consumer Group 拥有一个公共且唯一的 Group ID
- Consumer Group 在消费 Topic 的时候,Topic 的每个 Partition 只能分配给组内的某个 Consumer,只要被任何 Consumer 消费一次, 那么这条数据就可以认为被当前 Consumer Group 消费成功
Kafka 客户端提供了3 种分区分配策略:RangeAssignor、RoundRobinAssignor 和 StickyAssignor,前两种分配方案相对简单一些StickyAssignor 分配方案相对复杂一些。
- RangeAssignor 是 Kafka 默认的分区分配算法,它是按照 Topic 的维度进行分配的,对于每个 Topic,首先对 Partition 按照分区ID进行排序,然后对订阅这个 Topic 的 Consumer Group 的 Consumer 再进行排序,之后尽量均衡的按照范围区段将分区分配给 Consumer。此时可能会造成先分配分区的 Consumer 进程的任务过重(分区数无法被消费者数量整除)。
- RoundRobinAssignor 的分区分配策略是将 Consumer Group 内订阅的所有 Topic 的 Partition 及所有 Consumer 进行排序后按照顺序尽量均衡的一个一个进行分配。如果 Consumer Group 内,每个 Consumer 订阅都订阅了相同的Topic,那么分配结果是均衡的。如果订阅 Topic 是不同的,那么分配结果是不保证“尽量均衡”的,因为某些 Consumer 可能不参与一些 Topic 的分配。
- StickyAssignor 分区分配算法是 Kafka Java 客户端提供的分配策略中最复杂的一种,可以通过 partition.assignment.strategy 参数去设置,从 0.11 版本开始引入,目的就是在执行新分配时,尽量在上一次分配结果上少做调整,其主要实现了以下2个目标:
- Topic Partition 的分配要尽量均衡。
- 当 Rebalance(重分配,后面会详细分析) 发生时,尽量与上一次分配结果保持一致。
注意:当两个目标发生冲突的时候,优先保证第一个目标,这样可以使分配更加均匀,其中第一个目标是3种分配策略都尽量去尝试完成的, 而第二个目标才是该算法的精髓所在。
因此在生产环境上如果想要减少重分配带来的开销,可以选用 StickyAssignor 的分区分配策略。
Consumer Group 中 Rebalance (重分配) 机制,对于 Consumer Group 来说,可能随时都会有 Consumer 加入或退出,那么 Consumer 列表的变化必定会引起 Partition 的重新分配。我们将这个分配过程叫做 Consumer Rebalance,但是这个分配过程需要借助 Broker 端的 Coordinator 协调者组件,在 Coordinator 的帮助下完成整个消费者组的分区重分配,也是通过监听ZooKeeper 的 /admin/reassign_partitions 节点触发的。
Rebalance 的触发条件有三种:
- 当 Consumer Group 组成员数量发生变化(主动加入或者主动离组,故障下线等)
- 当订阅主题数量发生变化
- 当订阅主题的分区数发生变化
Rebalance 如何通知其他 consumer 进程?
Rebalance 的通知机制就是靠 Consumer 端的心跳线程,它会定期发送心跳请求到 Broker 端的 Coordinator,当协调者决定开启 Rebalance 后,它会将“REBALANCE_IN_PROGRESS”封装
进心跳请求的响应中发送给 Consumer ,当 Consumer 发现心跳响应中包含了“REBALANCE_IN_PROGRESS”,就知道 Rebalance 开始了。
其实 Rebalance 本质上也是一组协议。Consumer Group 与 Coordinator 共同使用它来完成 Consumer Group 的 Rebalance。下面我看看这5种协议都是什么,完成了什么功能:
- Heartbeat请求:Consumer 需要定期给 Coordinator 发送心跳来证明自己还活着。
- LeaveGroup请求:主动告诉 Coordinator 要离开 Consumer Group
- SyncGroup请求:Group Leader Consumer 把分配方案告诉组内所有成员
- JoinGroup请求:成员请求加入组
- DescribeGroup请求:显示组的所有信息,包括成员信息,协议名称,分配方案,订阅信息等。通常该请求是给管理员使用。
Coordinator 在 Rebalance 的时候主要用到了前面4种请求
Rebalance 一旦发生,必定会涉及到 Consumer Group 的状态流转,此时 Kafka 为我们设计了一套完整的状态机机制,来帮助 Broker Coordinator 完成整个重平衡流程。了解整个状态流转过程可以帮助我们深入理解 Consumer Group 的设计原理。
5种状态,定义分别如下:
- Empty 状态表示当前组内无成员, 但是可能存在 Consumer Group 已提交的位移数据,且未过期,这种状态只能响应 JoinGroup 请求。
- Dead 状态表示组内已经没有任何成员的状态,组内的元数据已经被 Broker Coordinator 移除,这种状态响应各种请求都是一个Response:UNKNOWN_MEMBER_ID。
- PreparingRebalance 状态表示准备开始新的 Rebalance, 等待组内所有成员重新加入组内。
- CompletingRebalance 状态表示组内成员都已经加入成功,正在等待分配方案,旧版本中叫“AwaitingSync”。
- Stable 状态表示 Rebalance 已经完成, 组内 Consumer 可以开始消费了。
Rebalance 主要分为两个步骤:加入组(对应JoinGroup请求)和等待 Leader Consumer 分配方案(SyncGroup 请求)。
- JoinGroup 请求: 组内所有成员向 Coordinator 发送 JoinGroup 请求,请求加入组,顺带会上报自己订阅的 Topic,这样 Coordinator 就能收集到所有成员的 JoinGroup 请求和订阅 Topic 信息,Coordinator 就会从这些成员中选择一个担任这个Consumer Group 的 Leader(一般情况下,第一个发送请求的 Consumer 会成为 Leader),这里说的Leader 是指具体的某一个 consumer,它的任务就是收集所有成员的订阅 Topic 信息,然后制定具体的消费分区分配方案。待选出 Leader 后,Coordinator 会把 Consumer Group 的订阅 Topic 信息封装进 JoinGroup 请求的 Response 中,然后发给 Leader ,然后由 Leader 统一做出分配方案后,进入到下一步
- SyncGroup 请求: Leader 开始分配消费方案,即哪个 Consumer 负责消费哪些 Topic 的哪些 Partition。一旦完成分配,Leader 会将这个分配方案封装进 SyncGroup 请求中发给 Coordinator ,其他成员也会发 SyncGroup 请求,只是内容为空,待 Coordinator 接收到分配方案之后会把方案封装进 SyncGroup 的 Response 中发给组内各成员, 这样各自就知道应该消费哪些 Partition 了
位移 和 消费者位移 之间的区别。通常所说的位移是指 Topic Partition 在 Broker 端的存储偏移量,而消费者位移则是指某个 Consumer Group 在不同 Topic Partition 上面的消费偏移量(也可以理解为消费进度),它记录了 Consumer 要消费的下一条消息的位移。
Consumer 需要向 Kafka 上报自己的位移数据信息,我们将这个上报过程叫做提交位移(Committing Offsets)。它是为了保证 Consumer的消费进度正常,当 Consumer 发生故障重启后, 可以直接从之前提交的 Offset 位置开始进行消费而不用重头再来一遍(Kafka 认为小于提交的 Offset 的消息都已经成功消费了),Kafka 设计了这个机制来保障消费进度。我们知道 Consumer 可以同时去消费多个分区的数据,所以位移提交是按照分区的粒度进行上报的,也就是说 Consumer 需要为分配给它的每个分区提交各自的位移数据。
**自动提交是指 Kafka Consumer 在后台默默地帮我们提交位移,用户不需要关心这个事情。**启用自动提交位移,在 初始化 KafkaConsumer 的时候,通过设置参数 enable.auto.commit = true (默认为true),开启之后还需要另外一个参数进行配合即 auto.commit.interval.ms,这个参数表示 Kafka Consumer 每隔 X 秒自动提交一次位移,这个值默认是5秒。
自动提交看起来是挺美好的, 那么自动提交会不会出现消费数据丢失的情况呢?在设置了 enable.auto.commit = true 的时候,Kafka 会保证在开始调用 Poll() 方法时,提交上一批消息的位移,再处理下一批消息, 因此它能保证不出现消费丢失的情况。但自动提交位移也有设计缺陷,那就是它可能会出现重复消费。就是在自动提交间隔之间发生 Rebalance 的时候,此时 Offset 还未提交,待 Rebalance 完成后, 所有 Consumer 需要将发生 Rebalance 前的消息进行重新消费一次。
与自动提交相对应的就是手动提交了。开启手动提交位移的方法就是在初始化KafkaConsumer 的时候设置参数 **enable.auto.commit = false,**但是只设置为 false 还不行,它只是告诉 Kafka Consumer 不用自动提交位移了,你还需要在处理完消息之后调用相应的 Consumer API 手动进行提交位移,对于手动提交位移,又分为同步提交和异步提交。
1)、同步提交API:
KafkaConsumer#commitSync(),**该方法会提交由 KafkaConsumer#poll() 方法返回的最新位移值,它是一个同步操作,会一直阻塞等待直到位移被成功提交才返回,如果提交的过程中出现异常,该方法会将异常抛出。这里我们知道在调用 commitSync() 方法的时机是在处理完 Poll() 方法返回所有消息之后进行提交,如果过早的提交了位移就会出现消费数据丢失的情况。
2)、异步提交API:
KafkaConsumer#commitAsync(),**该方法是异步方式进行提交的,调用 commitAsync() 之后,它会立即返回,并不会阻塞,因此不会影响 Consumer 的 TPS。另外 Kafka 针对它提供了callback,方便我们来实现提交之后的逻辑,比如记录日志或异常处理等等。由于它是一个异步操作, 假如出现问题是不会进行重试的,这时候重试位移值可能已不是最新值,所以重试无意义。
3)、混合提交模式:
从上面分析可以得出 commitSync 和 commitAsync 都有自己的缺陷,**我们需要将 commitSync 和 commitAsync 组合使用才能到达最理想的效果,既不影响 Consumer TPS,又能利用 commitSync 的自动重试功能来避免一些瞬时错误(网络抖动,GC,Rebalance 问题),在生产环境中建议大家使用混合提交模式来提高 Consumer的健壮性。
旧版本的 Consumer Group 是把位移保存在 ZooKeeper 中,减少 Broker 端状态存储开销,鉴于 Zookeeper 的存储架构设计来说, 它不适合频繁写更新,而 Consumer Group 的位移提交又是高频写操作,这样会拖慢 ZooKeeper 集群的性能, 于是在新版 Kafka 中, 社区重新设计了 Consumer Group 的位移管理方式,采用了将位移保存在 Kafka 内部(这是因为 Kafka Topic 天然支持高频写且持久化),这就是所谓大名鼎鼎的__consumer_offsets。
__consumer_offsets:用来保存 Kafka Consumer 提交的位移信息,另外它是由 Kafka 自动创建的,和普通的 Topic 相同,它的消息格式也是 Kafka 自己定义的,我们无法进行修改
位移主题的 Key 中应该保存 3 部分内容:<Group ID,主题名,分区号 >
value 可以简单认为存储的是offset值,当然底层还存储其他一些元数据,帮助 Kafka 来完成一些其他操作,比如删除过期位移数据等。
当 Kafka 集群中的第一个 Consumer 启动时,Kafka 会自动创建__consumer_offsets。前面说过,它就是普通的 Topic, 它也有对应的分区数,如果由 Kafka 自动创建的,那么分区数又是怎么设置的呢?这个依赖 Broker 端参数 offsets.topic.num.partitions (默认值是50),因此 Kafka 会自动创建一个有 50 个分区的__consumer_offsets ****。这就是我们在 Kafka 日志路径下看到有很多 __consumer_offsets-xxx 这样的目录的原因。既然有分区数,必然就会有对应的副本数,这个是依赖 Broker 端另一个参数 offsets.topic.replication.factor(默认值为3)。总结一下,**如果__consumer_offsets 由 Kafka 自动创建的,那么该 Topic 的分区数是 50,副本数是 3,而具体 Group 的消费情况要存储到哪个 Partition ,根据abs(GroupId.hashCode()) % NumPartitions 来计算的,**这样就可以保证 Consumer Offset 信息与 Consumer Group 对应的 Coordinator 处于同一个 Broker 节点上。