文章目录
- 1. Kafka 消费者相关概念
- 2. 创建 Kafka 消费者
- 3. 订阅主题
- 4. 轮询
- 5. 线程安全
- 6. 配置消费者
-
- (1)fetch.min.bytes
- (2)fetch.max.wait.ms
- (3)fetch.max.bytes
- (4)max.poll.records √
- (5)max.partition.fetch.bytes
- (6)session.timeout.ms 和 heartbeat.interval.ms √
- (7)max.poll.interval.ms √
- (8)default.api.timeout.ms
- (9)request.timeout.ms
- (10)auto.offset.reset √
- (11)enable.auto.commit √
- (12) partition.assignment.strategy √
- (13)client.id
- (14)client.rack
- (15)group.instance.id √
- (16)receive.buffer.bytes和send.buffer.bytes
- (17) offsets.retention.minutes √
- 7. 提交和偏移量
- 8.再均衡监听器 √
- 9.从特定偏移量位置读取记录 √
- 10.退出 √
- 11.订阅主题:使用不属于任何群组的消费者
1. Kafka 消费者相关概念
消费者和消费者组
- 假设有一个应用程序,它从一个 Kafka 主题读取消息,在对消息做一些验证后再保存起来。应用程序需要创建一个消费者对象,订阅主题并开始接收消息、验证消息和保存结果。但过了一阵子,生产者向主题写入消息的速度超过了应用程序验证数据的速度,这时候该怎么办呢?如果只使用单个消费者来处理消息,那么应用程序会远远跟不上消息生成的速度。显然,此时很有必要对消费者进行横向伸缩。就像多个生产者可以向相同的主题写入消息一样,也可以让多个消费者从同一个主题读取消息。
- Kafka 消费者从属于消费者群组。一个群组里的消费者订阅的是同一个主题,每个消费者负责读取这个主题的部分消息。
(1)横向伸缩消费者
- 向群组里添加消费者是横向扩展数据处理能力的主要方式。Kafka 消费者经常需要执行一些高延迟的操作,比如把数据写到数据库或用数据做一些比较耗时的计算。在这些情况下,单个消费者无法跟上数据生成的速度,因此可以增加更多的消费者来分担负载,让每个消费者只处理部分分区的消息,这是横向扩展消费者的主要方式。我们可以为主题创建大量的分区,当负载急剧增长时,可以加入更多的消费者。不要让消费者的数量超过主题分区的数量,因为多余的消费者只会被闲置。
- 只包含一个消费者的群组接收 4 个分区的消息:
- 包含两个消费者的群组接收 4 个分区的消息:
- 包含 4 个消费者的群组,每个消费者分配到一个分区:
- 消费者数量超过分区数量,有消费者空闲:
(2)横向伸缩消费者组
-
除了通过增加消费者数量来横向伸缩单个应用程序,我们还经常遇到多个应用程序从同一个主题读取数据的情况。实际上,Kafka 的一个主要设计目标是让 Kafka 主题里的数据能够满足企业各种应用场景的需求。在这些应用场景中,我们希望每一个应用程序都能获取到所有的消息,而不只是其中的一部分。只要保证每个应用程序都有自己的消费者群组就可以让它们获取到所有的消息。不同于传统的消息系统,横向伸缩消费者和消费者群组并不会导致 Kafka 性能下降。
-
新增一个消费者群组,每个群组都能收到所有消息:
分区再平衡
- 分区的所有权从一个消费者转移到另一个消费者的行为称为再均衡。再均衡为消费者群组带来了高可用性和伸缩性(你可以放心地添加或移除消费者)。不过,在正常情况下,我们并不希望发生再均衡。消费者群组里的消费者共享主题分区的所有权,以下情况会发生再均衡。
- 当一个新消费者加入群组时,它将开始读取一部分原本由其他消费者读取的消息。
- 当一个消费者被关闭或发生崩溃时,它将离开群组,原本由它读取的分区将由群组里的其他消费者读取。
- 消费者正确关闭,调用 close() 方法。消费者在被关闭时会提交还没有提交的偏移量,并向消费者协调器发送消息,告知自己正在离开群组。协调器会立即触发再均衡,被关闭的消费者所拥有的分区将被重新分配给群组里其他的消费者,不需要等待会话超时。
- 消费者没有正确调用 close(),并且在一段时间内没有发送心跳,会话超时(session.timeout.ms 默认 10s),群组协调器认为它已经“死亡”,进而触发再均衡。
- 消费者死锁导致长时间等待超过 poll 设定的时间间隔(max.poll.interval.ms 默认 5 分钟),后台线程向 broker 发送“离开群组”的请求,让 broker 知道这个消费者已经“死亡”,必须进行群组再均衡,然后停止发送心跳。
- 主题发生变化会导致分区重分配。
- 管理员添加了新分区。
- 在调用 subscribe() 方法时传入一个正则表达式。如果有人创建了新主题,并且主题的名字与正则表达式匹配,那么就会立即触发一次再均衡。
再均衡的类型
- 根据消费者群组所使用的分区分配策略的不同,再均衡可以分为两种类型。
(1)主动再均衡
- 主动再均衡包含两个不同的阶段:
- 第一个阶段,所有消费者都会停止读取消息,放弃分区所有权。
- 第二个阶段,消费者重新加入消费者群组,并获得重新分配到的分区,并继续读取消息。
- 会导致整个消费者群组在一个很短的时间窗口内不可用。这个时间窗口的长短取决于消费者群组的大小和几个配置参数。
- 区间(range)、轮询(roundRobin)、黏性(sticky)策略使用的是主动再均衡。
(2)协作再均衡(增量再均衡)
- 将一个消费者的部分分区重新分配给另一个消费者,其他消费者则继续读取没有被重新分配的分区。包含两个或多个阶段。
- 第一个阶段,消费者群组首领会通知所有消费者,它们将失去部分分区的所有权,然后消费者会停止读取这些分区,并放弃对它们的所有权。
- 第二个阶段,消费者群组首领会将这些没有所有权的分区分配给其他消费者。
- 需要进行几次迭代,直到达到稳定状态,但它避免了主动再均衡中出现的“停止世界”停顿。这对大型消费者群组来说尤为重要,因为它们的再均衡可能需要很长时间。
- 协作黏性(cooperative sticky)策略支持协作再均衡。将 partition.assignment.strategy 设置为 org.apache.kafka.clients.consumer.CooperativeStickyAssignor。
分配分区的过程
- 当一个消费者想要加入消费者群组时,它会向被指定为群组协调器的 broker(不同消费者群组的协调器可能不同)发送 JoinGroup 请求。
- 第一个加入群组的消费者将成为群组首领。首领从群组协调器那里获取群组的成员列表(列表中包含了所有最近发送过心跳的消费者,它们被认为还“活着”),并负责为每一个消费者分配分区。它使用实现了 PartitionAssignor 接口的类来决定哪些分区应该被分配给哪个消费者。
- 分区分配完毕之后,首领会把分区分配信息发送给群组协调器,群组协调器再把这些信息发送给所有的消费者。每个消费者只能看到自己的分配信息,只有首领会持有所有消费者及其分区所有权的信息。每次再均衡都会经历这个过程。
- 消费者会向群组协调器发送心跳,以此来保持群组成员关系和对分区的所有权关系。心跳是由消费者的一个后台线程发送的,只要消费者能够以正常的时间间隔发送心跳,它就会被认为还“活着”。如果消费者没有正确调用 close(),并且在一段时间内没有发送心跳,那么它的会话就将超时(session.timeout.ms 默认 10s),群组协调器会认为它已经“死亡”,进而触发再均衡。
- 如果一个消费者遇到了死锁导致长时间等待超过了 poll 设定的时间间隔(max.poll.interval.ms 默认 5 分钟),后台线程将向 broker 发送一个“离开群组”的请求,让 broker 知道这个消费者已经“死亡”,必须进行群组再均衡,然后停止发送心跳。
- 消费者正确关闭,调用 close() 方法。消费者在被关闭时会提交还没有提交的偏移量,并向消费者协调器发送消息,告知自己正在离开群组。协调器会立即触发再均衡,被关闭的消费者所拥有的分区将被重新分配给群组里其他的消费者,不需要等待会话超时。
群组固定成员
- 在默认情况下,消费者的群组成员身份标识是临时的。当一个消费者离开群组时,分配给它的分区所有权将被撤销;当该消费者重新加入时,将通过再均衡协议为其分配一个新的成员 ID 和新分区。
- 可以给消费者分配一个唯一的 group.instance.id,让它成为群组的固定成员。通常,当消费者第一次以固定成员身份加入群组时,群组协调器会按照分区分配策略给它分配一部分分区。当这个消费者被关闭时,它不会自动离开群组——它仍然是群组的成员,直到会话超时。当这个消费者重新加入群组时,它会继续持有之前的身份,并分配到之前所持有的分区。群组协调器缓存了每个成员的分区分配信息,只需要将缓存中的信息发送给重新加入的固定成员,不需要进行再均衡。
- 如果两个消费者使用相同的 group.instance.id 加入同一个群组,则第二个消费者会收到
错误,告诉它具有相同 ID 的消费者已存在。 - 如果应用程序需要维护与消费者分区所有权相关的本地状态或缓存,那么群组固定成员关系就非常有用。如果重建本地缓存非常耗时,那么你肯定不希望在每次重启消费者时都经历这个过程。更重要的是,在消费者重启时,消费者所拥有的分区不会被重新分配。在重启过程中,消费者不会读取这些分区,所以当消费者重启完毕时,读取进度会稍稍落后,但你要相信它们一定会赶上。
- 需注意的是,群组的固定成员在调用 close()关闭时不会主动离开群组,它们何时“真正消失”取决于 session.timeout.ms 参数。你可以将这个参数设置得足够大,避免在进行简单的应用程序重启时触发再均衡,但又要设置得足够小,以便在出现严重停机时自动重新分配分区,避免这些分区的读取进度出现较大的滞后。
2. 创建 Kafka 消费者
- 创建 KafkaConsumer 对象与创建 KafkaProducer 对象非常相似——把想要传给消费者的属性放在 Properties 对象里。
- 第一个属性 bootstrap.servers 指定了连接 Kafka 集群的字符串。
- 另外两个属性 key.deserializer 和 value.deserializer 与生产者的 key.serializer 和 value.serializer 类似,只不过它们不是使用指定类把 Java 对象转成字节数组,而是把字节数组转成 Java 对象。
- 第 4 个属性 group.id 不是必需的,但会经常被用到。它指定了一个消费者属于哪一个消费者群组。也可以创建不属于任何一个群组的消费者,只是这种做法不太常见。
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092");
props.put("group.id", "CountryCounter");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<String,String>(props);
3. 订阅主题
- subscribe() 方法会接收一个主题列表作为参数。
- 使用 consumer.unsubscribe() 取消订阅主题。
consumer.subscribe(Collections.singletonList("customerCountries"));
- 也可以在调用 subscribe() 方法时传入一个正则表达式。正则表达式可以匹配多个主题,如果有人创建了新主题,并且主题的名字与正则表达式匹配,那么就会立即触发一次再均衡,然后消费者就可以读取新主题里的消息。如果应用程序需要读取多个主题,并且可以处理不同类型的数据,那么这种订阅方式就很有用。
- 如果 Kafka 集群包含了大量分区(30 000 个或更多),则需注意,主题的过滤是在客户端完成的。
- 当使用正则表达式而不是指定列表订阅主题时,消费者将定期向 broker 请求所有已订阅的主题及分区。然后,客户端会用这个列表来检查是否有新增的主题,如果有,就订阅它们。
- 如果主题很多,消费者也很多,那么通过正则表达式订阅主题就会给 broker、客户端和网络带来很大的开销。
- 在某些情况下,主题元数据使用的带宽会超过用于发送数据的带宽。另外,为了能够使用正则表达式订阅主题,需要授予客户端获取集群全部主题元数据的权限,即全面描述整个集群的权限。
consumer.subscribe(Pattern.compile("test.*"));
4. 轮询
- 消费者 API 最核心的东西是通过一个简单的轮询向服务器请求数据。
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092");
props.put("group.id", "CountryCounter");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<String,String>(props);
consumer.subscribe(Collections.singletonList("customerCountries"));
try {
while (true) {
// 拉取数据
ConsumerRecords<String, String> records = consumer.poll(timeout);
// 拉取到的数据为空则跳过
if (records.isEmpty()) {
continue;
}
for (ConsumerRecord<String, String> record : records) {
try {
// 实际处理逻辑
System.out