生产者和消费者往往是一对多的关系,多个消费者可以形成一个消费组来订阅主题消息,对消息进行分类。一个消费组中订阅的都是同一个主题,每个消费者接受主题一部分分区的消息。同一个消费组内的不同消费者只能订阅一个主题下不同分区的消息,不同消费组可以订阅同一个主题的统一分区消息
在同一个消费组下,根据消费者数量的不同,消费者订阅的主题数量也会变化,假设一个主题有4个分区p1,p2,p3,p4,只有唯一的消费者c1,则订阅关系为:
c1->p1
c1->p2
c1->p3
c1->p4
如果有两个消费者c1,c2,则订阅关系会发生变化,可能会变成:
c1->p1
c2->p2
c1->p3
c2->p4
如果消费者数量大于分区数,则会导致部分消费者闲置,不会接收到任何消息。如有5个消费者:
c1->p1
c2->p2
c3->p3
c4->p4
c5 闲置
从上面分析,可以通过适当地增加消费者数量来横向拓展消费能力,这是尤其当消费者需要做一些高延迟处理或发送者发送速率较大的情况下。但不要让消费者数量超过主题分区数。
一个消费组即可保证生产者生产的所有消息都被唯一消费,但如果存在多方对生产的消息感兴趣,可以初始化不同的消费组,不同消费组互不干扰,都分别能对生产者生产的所有消息进行唯一消费。示例如下所示:
对于消费组,有5个状态:
对应以下生命周期流转模型:
类似生产者,在创建消费者时,同样需要指定三个最基本的属性:
在3个必备配置外,还有一个最基本配置group.id,用来指定消费者属于哪一个消费者群组,如果不指定会分配在默认消费组,但这不太常见。
下面来看一个创建示例:
Properties properties = new Properties();
// 指定broker连接
properties.setProperty("bootstrap.servers", "127.0.0.1:9092");
// 指定key反序列化工具类
properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
// 指定value反序列化工具类
properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
// 指定消费群组id
properties.setProperty("group.id", "test2");
// 初始化消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
// 指定订阅主题,可以同时订阅多个主题,还可以通过指定test*订阅所有test开头的主题
consumer.subscribe(Collections.singletonList("kafka-topic"));
// 循环消费消息
while (true) {
// 不断尝试拉取
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> recode : records) {
// 输出消息内容
System.out.println("recodeOffset = " + recode.offset() + ",recodeValue = " + recode.value() + ",record.key = " + recode.key());
}
}
默认情况,消费群组会自动根据消费者数量和分区数量来分配消费者消费分区,此外,还可以通过程序手动指定,指定代码如下所示:
// 可选,指定消费分区
List<TopicPartition> partitions = consumer.partitionsFor("kafka-topic") // 获取指定主题下的所有分区
.stream() // 初始化流
.map(p -> new TopicPartition(p.topic(), p.partition())) // 遍历根据分区信息初始化主题分区对象
.collect(Collectors.toList()); // 收集为List
// 给当前消费组分配消费分区
consumer.assign(partitions);
在示例中为当前消费者指定了消费主题的所有分区。
基于一个消费组订阅特定主题的情况下,消费者数量或主题分区数量发生变化都会引起分区再均衡。在再均衡期间,消费者无法读取消息,会造成整个群组一小段时间的不可用。另外,当分区被重新分配到另一个消费者时,消费者当前的读取状态会丢失,可能需要去刷新缓存,在消费者重新恢复状态之间会拖慢应用程序。
消费者通过被指派为群组协调器的broker(不同的群组有不同的协调器)发送心跳来维持他们和群组的从属关系以及他们对分区的所有权。只要消费者以正常的时间间隔发送心跳,就被认为是活跃的,说明还在读取分区的消息。消费者会在轮询尝试获取新消息或提交偏移量时发送心跳,如果消费者停止发送心跳的时间足够长,会话就会过期,群组协调器认为消费者宕机,会触发一次再均衡。另一方面,我们在主动清理消费者时,消费者也会通知协调器它即将离开群组,也会触发一次再均衡。
具体而言,可以分为以下三种情况:
kafka新版本提供了3种分配策略,用于决策归属topic的每个分配会被分配给哪个消费者,具体有:
每次触发再均衡后,有一个标志再均衡代数的变量,会在每次触发再均衡后+1。主要用于保护consumer group,尤其是防止无效offset的提交。比如上一代的consumer成员由于某些原因延迟提交了offset.但再均衡后该group产生了新一届的group成员,而这次延迟的offset提交携带的是旧的generation信息,则这次提交会被拒绝。
rebalance本质是一组协议,由group和coordinator(协调者)共同完成,其中coordinator是每个组的一个协调者,负责对组的状态进行管理,主要职责是再均衡时促成组内所有成员达成新的分区分配方案。再均衡协议包含以下协议请求:
在rebalance过程中,coordinator主要处理consumer发来的JoinGroup和SyncGroup请求。当consumer主动离组时会发送LeaveGroup给coordinator。
在成功rebalance后,组内所有consumer定期向coordinator发送Heartbeat请求。而consumer则根据Heartbeat请求的响应中是否包含REBALANCE_IN_PROGRESS来判断当前group是否开启新一轮rebalance。
再均衡的流程分为以下几步:
收集consumer,选举Leader并制定分配方案实例:
同步更新分配方案示例:
除了上面几个配置外,消费者还有一些核心配置,通过这些配置有助于我们更好地理解消费者的运行逻辑。
指定消费者从服务器获取记录的最小字节数。broker在收到消费者的数据请求时,如果可用的数据量小于配置指定的大小,会等有足够的数据再一起返回给消费者,以此降低消费者和broker的工作负载。
指定broker在没有收到足够数据时的最大等待时间,默认500ms,如果没有足够的数据流入broker,即使消费者尝试获取数据,broker也不会立即返回,而会等待离上次拉取数据时间间隔fetch.max.wait.ms才会返回给客户端。这个配置设置过大,会导致数据消费延迟,但可以降低消费者和broker的工作负载
指定服务器从每个分区里返回给消费者的最大字节数。默认为1MB。这个配置值必须比broker能够接受的最大消息的字节数(max.message.size配置)大,否则消费者可能无法读取过大的消息,导致消费者一直刮起重试。另外还需要考虑消费者处理的时间,如果单词poll数据太多,消费者处理可能无法及时进行下一个轮询来避免会话过期。
该属性指定了消费者在被认为死亡之前可以与服务器断开连接的时间,默认为3s,如果消费者没有在指定时间内发送心跳给群组协调器,会被认为死亡,群组协调器会触发再均衡,把它的分区分配给其他消费者。阈值关联的另一个配置是heartbeat.interval.ms,用来指定poll()方法向协调器发送心跳的频率。因此两个属性一把你需要同步修改,如session.timeout.ms是3s,则heartbeat.interval.ms应该是1s。将session.timeout.ms设置更小一些,可以更快地监测和恢复崩溃节点,但可能会导致非预期的再均衡。
指定消费者在读取一个没有偏移量的分区或者偏移量无效(银消费者长时间失效,包含偏移量的记录已经过期或被删除)的情况下的处理动作,有两个值:
指定消费者是否自动提交偏移量,默认为true。为了尽量避免出现重复数据和数据丢失,可以设为false,有自己控制何时提交偏移量,如果设为true,则可以通过配置auto.commit.intervall.ms来控制提交的频率
分区分配策略,决定哪些分区由哪些消费者消费,有两种默认策略:
c1->p1,c1->p2,c2->p3
c1->p1,c2->p2,c1->p3
。RoundRobin策略保证所有消费这分配相差0或1个数量的分区。消费者需要更新自己在分区消费的记录偏移量,这个操作叫做提交。通常,偏移量是下一条带消费的消息的位置。消费者提交的偏移量作用在于当消费者发生崩溃或有新消费者加入群组引发分区再均衡时,当分区被分配到新的消费者时,新的消费者可以根据分区记录的偏移量来继续消费消息。这里有两种异常情况:
对于精确一次的时间,依赖于幂等姓Producer,设置producer端参数enable.idempotence=true,同一消息可能被producer发送多次,但在broker端这条消息只会被写入一次。
幂等性prodecuer发送到broker的每批消息都会标志一个序列号用于消息去重。序列号会和每个producer(pid)建立一一映射,对于接收的每条消息,如果其序号比Broker缓存中序号大于1则接受它,否则将其丢弃。但是,只能保证单个Producer对于同一个
序列号会被保存到底层日志,即使leader副本挂掉,新选出来的leader broker也能执行消息去重工作。
Kafka为实现事务用一个位移idTransactionalId标志事务。当提供了 Transactionalld后, Kafka就能确保:
consumer中存在多种位置信息:
consumer 提交位移的主要机制是通过向所属的coordinator发送位移提交请求来实现的 。 每个位移提交请求都会往_consumer_offsets 对应分区上追加写入一条消息 。 消息的 key 是 group.id、 topic和分区的元组,而 value就是位移值。 如果 consumer为同一个 group的同一个 topic 分区提交了多次位移,那么__consumer_offsets 对应的分区上就会有若干条 key 相同但 value 不同的消息,但显然我们只关心最新一次提交的那条消息。从某种程度来说,只有最新 提交的位移值是有效的,其他消息包含的位移值其实都已经过期了。Kafka 通过压实( compact) 策略来处理这种消息使用模式。
kafka提供了多种策略来提交偏移量:
自动提交通过两个配置指定:
自动提交虽然便利,但存在风险:
可以通过设置auto.commit.offset=false。在每轮消费完调用poll()获取的消息后,手动调用commitSync()来提交最新偏移量。如果在这个过程中,分区发生再均衡,也会有消息被重复消费的可能。调用示例如下所示:
// 循环消费消息
while (true) {
// 不断尝试拉取
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> recode : records) {
// 输出消息内容
System.out.println("recodeOffset = " + recode.offset() + ",recodeValue = " + recode.value() + ",record.key = " + recode.key());
}
// 手动提交
consumer.commitSync();
}
手动提交后,会堵塞一直重试,知道提交成功
如果不想在提交的时候发生堵塞,影响程序的吞吐量,可以降低提交频率来提高吞吐量,但会由于再均衡而导致增加重复消费消息量的风险。可以异步提交。调用类似同步提交,只是api从consumer.commitSync()
变为consumer.commitAsync()
。和同步提交不同的是,异步提交可以指定一个callback,来在提交成功或失败的时候回调相关逻辑。示例如下:
consumer.commitAsync(new OffsetCommitCallback() {
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
if(exception!=null){
System.out.println("提交失败");
}else{
System.out.println("提交成功");
}
}
});
commitAsync在提交失败后不会重试,我们可以在回调中尝试重试提交,但要注意的是,如果已经有一个更大的偏移量提交成功,可能会出现小偏移量覆盖大偏移量的情况。这个可以在重试前,先检查回调的序列号和即将提交的偏移量是否相等来规避。
可以结合同步和异步提交,在正常轮询消费过程中采用异步提交,当出现异常或消费被中断时,再用同步提交来兜底。示例如下:
// 循环消费消息
try {
while (true) {
// 不断尝试拉取
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> recode : records) {
// 输出消息内容
System.out.println("recodeOffset = " + recode.offset() + ",recodeValue = " + recode.value() + ",record.key = " + recode.key());
}
// 异步提交提交
consumer.commitAsync();
}
}catch (Exception e){
e.printStackTrace();
}finally {
// 最后重步提交
consumer.commitSync();
consumer.close();
}
在消费者指定订阅主题时,可以传入一个ConsumerRebalanceListener接口实现类,在监听需要分区再均衡时,进行相关的逻辑处理,如提交偏移量,具体示例:
// 存储当前消费偏移量
Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
// 指定订阅主题,同时指定分区再均衡类
consumer.subscribe(Collections.singletonList("kafka-topic"), new ConsumerRebalanceListener() {
/**
* 方法在再均衡开始之前和消费者停止读取消息之后被调用,
* 可以在这里提交偏移量,以便后续接管的消费者找到偏移量继续消息消费
* @param partitions 当前消费者负责消费的分区
*/
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// 提交偏移量
consumer.commitSync();
}
/**
* 方法会在分区再均衡后和消费者开始读取消息前被调用
* @param partitions 当前消费者负责消费的分区
*/
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
System.out.println("开始监听以下分区:" + partitions);
}
});
// 循环消费消息
while (true) {
// 不断尝试拉取
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> recode : records) {
// 输出消息内容
System.out.println("recodeOffset = " + recode.offset() + ",recodeValue = " + recode.value() + ",record.key = " + recode.key());
// 每次消费记录消费偏移量
currentOffsets.put(new TopicPartition(recode.topic(), recode.partition()), new OffsetAndMetadata(recode.offset() + 1, ""));
}
consumer.commitSync(currentOffsets);
}
在上面,我们每次消费消息后,都实时记录消费的偏移量,偏移在任何时刻触发监听器,都会提交有效的偏移量。
Kafka提供了三种api操作消费的起始偏移量:
消费者会在在轮询的死循环里不断尝试拉取消息,如果想退出消费,可以另起一个线程,调用consumer.wakeup()来唤醒消费者。而后消费者会在poll()的时候抛出WakeupException。在退出前,最后调用consume.close()来提交任何还没有提交的东西,同时向群组协调器发送消息,接下来会触发分区再均衡而无需等待当前消费者的会话超时。