再均衡
再均衡是指分区的所属权从一个消费者转移到另一个消费者的行为,它为消费组的高可用性和伸缩性提供保障,使消费组可以方便安全的删除或者添加组内的消费者。不过在再均衡发生期间,消费组内的消费者是无法读取信息的。也就是说,在再均衡发生期间的这一小段时间内,消费组会变的不可用。
另外,当一个分区被重新分配给另外一个消费者时,消费者当前的状态也会丢失。比如消费者消费完某个分区中的一部分信息,还没有提交消费位移就分配给了另外一个消费者,此时这个消费者会重新消费一遍,也就造成了重复消费,所以应该尽量避免不必要的在均衡发生。
在第8节中讲述 subscribe() 方法时提及再均衡监听器 ConsumerRebalanceListener,subscribe(Collection topics, ConsumerRebalanceListener listener) 和 subscribe(Pattern pattern, ConsumerRebalanceListener listener)方法中都有它的身影。再均衡监听器的作用是用来设定发生再均衡行为前后的一些准备或收尾动作。 ConsumerRebalanceListener 是一个接口,包含两个方法 释义如下:
void onPartitionsRevoked(Collection<TopicPartition> var1);
void onPartitionsAssigned(Collection<TopicPartition> var1);
onPartitionsRevoked 方法会在再均衡开始之前和消费者停止读取消息之后被调用。可以通过这个回调方法来处理消费位移的提交,以此来避免一些不必要的麻烦。参数为再均衡前分配到的分区。
onPartitionsAssigned 方法会在重新分配分区之后和消费者开始读取消息之前被调用。参数表示再均衡后分配到的分区。
通过一个例子来演示 ConsumerRebalanceListener 的使用。首先,创建一个Map其中 key 为主题分区信息,value为对应的位移信息。再消费位移的时候将对应的分区和位移+1(要提交的消费位移)保存到Map,当在均衡发生之前和消费者停止读取消息之后会调用再均衡监听器的 onPartitionsRevoked() 将位移同步提交。
private static final Logger logger = LoggerFactory.getLogger(KafkaConsumerAsync.class);
public static final String brokerList = "172.16.15.89:9092";
public static final String topic = "bbbbbb";
public static final String groupId = "group8";
public static final AtomicBoolean isRunning = new AtomicBoolean(true);
public static Properties ininConfig(){
Properties properties=new Properties();
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,groupId);
return properties;
}
public static void main(String[] args) {
Properties properties=ininConfig();
KafkaConsumer<String,String> consumer=new KafkaConsumer<String, String>(properties);
//保存分区和位移信息
Map<TopicPartition,OffsetAndMetadata> currentOffsets = new HashMap<>();
//订阅主题 添加再均衡监听器
consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> collection) {
//再均衡之前或者消费者停止读取消息后 同步提交消费位移
consumer.commitSync(currentOffsets);
currentOffsets.clear();
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> collection) {
}
});
while (isRunning.get()){
ConsumerRecords<String,String> records=consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecord<String,String> record:records){
System.out.println(record.value());
//将消费信息 保存到集合中
currentOffsets.put(new TopicPartition(record.topic(),record.partition()),new OffsetAndMetadata(record.offset()+1));
}
//异步提交
consumer.commitAsync(currentOffsets,null);
}
consumer.close();
}
为了测试,创建一个主题两个分区,创建两个消费者。然后让第一个消费者不停的去从分区起始处开始消费,然后启动第二个消费者,此时会执行第一个消费者的再均衡器的 onPartitionsRevoked() 方法,如下图
再均衡监听器也可以配合外部存储器来使用:
//订阅主题 添加再均衡监听器
consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> collection) {
//再均衡之前或者消费者停止读取消息后 同步提交消费位移
//consumer.commitSync(currentOffsets);
//currentOffsets.clear();
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> collection) {
//循环分配到的分区
for(TopicPartition tp:partitions){
//根据分区信息 从数据库读取位移
//使用seek()方法重置到对应位移
see(tp,数据库存储的位移)
}
}
});
消费者拦截器
第4节讲述了生产者拦截器的使用,对应的消费者也有拦截器。消费者拦截器主要在消费到消息或者在提交消费位移的时候进行一些定制化操作
与生产者拦截器对应的,消费者拦截器需要自定义实现org.apache.kafka.clients.consumer. ConsumerInterceptor 接口。该接口下有三个方法:
ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> var1);
void onCommit(Map<TopicPartition, OffsetAndMetadata> var1);
void close();
KafkaConusmer 会在 poll() 方法返回之前调用拦截器的 onConsumer() 方法来对消费者进行相应的定制化操作,比如说修改返回消息内容、按照某种规则过滤消息等。如果 onConsumer() 方法中抛出异常,那么会被捕获并记录到日志,但是异常不会再向上传递。
KafkaConsumer 会在提交完消费位移的时候调用 onCommit() 方法,可以使用这个方法来记录跟踪所提交的位移信息,比如当消费者使用 commitSync()的无参方法来同步提交消费位移时,我们不知道提交的具体细节,而使用拦截器的 onCommit()方法却可以做到这一点。
close() 方法和 ConsumerInterceptor 的父接口中的 configure() 方法与生产者的 ProducerInterceptor 接口中的用途一样。close() 方法主要用于在关闭拦截器时执行一些资源的清理工作; Configurable 接口中的 configure() 方法主要用来获取配置信息及初始化数据。
在某些业务场景中会对消息设置一个有效期的属性,如果某条消息在既定的时间内无法到达,那么就会被视为无效。下面使用消费者拦截器来实现一个简单的消息过期的功能。在代码中,自定义的消费者拦截器ConsumerInterceptorTTL 使用消息的timestamp字段来判断是否过期,如果消息的时间戳与当前的时间戳相差10秒则判定为过期,那么这条消息就会被过滤而不投递给消费者。
public class ConsumerInterceptorTTL implements ConsumerInterceptor<String,String> {
private static final long EXPIRE_INTERVAL = 10 * 1000;
@Override
public ConsumerRecords onConsume(ConsumerRecords<String,String> consumerRecords) {
//当前时间
long now=System.currentTimeMillis();
//返回的数据
Map<TopicPartition, List<ConsumerRecord<String,String>>> newRecords=new HashMap<>();
//循环拦截到的消息
for (TopicPartition tp:consumerRecords.partitions()){
//保存未过期消息
List<ConsumerRecord<String, String>> newTpRecords = new ArrayList<>();
//根据分区获取消息
List<ConsumerRecord<String,String>> tpRecords=consumerRecords.records(tp);
//循环分区消息
for (ConsumerRecord<String,String> record:tpRecords){
//判断是否过期
if (now-record.timestamp()<EXPIRE_INTERVAL){
newTpRecords.add(record);
}
}
//判断如果这个分区中有消息未过期 那么添加到返回数据中
if (!newTpRecords.isEmpty()){
newRecords.put(tp,newTpRecords);
}
}
return new ConsumerRecords(newRecords);
}
@Override
public void close() {}
@Override
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
//提交消费位移后 循环map 输出分区信息和对应的offset
offsets.forEach((tp,offset)-> System.out.println(tp+":"+offset.offset()));
}
@Override
public void configure(Map<String, ?> map) {}
}
实现自定义拦截器后需要在消费者客户端通过 interceptor.classes 参数配置。配置好后我们在发送消息的时候修改 ProducerRecord中的 timestamp 的指来使消息变的超时:
producer.send(new ProducerRecord<>("bbbbbb",0,System.currentTimeMillis(),null,"你好,这是一条未超时消息"));
producer.send(new ProducerRecord<>("bbbbbb", 0,System.currentTimeMillis()-(10 * 1000),null,"你好,这是一条超时消息"));
producer.send(new ProducerRecord<>("bbbbbb", 0,System.currentTimeMillis()-(10 * 1000),null,"你好,这是一条超时消息"));
然后启动消费者,之后启动生产者
首先拦截到三条消息
循环判断后 只保留一条消息
消费者只保留了一条消息
提交消费位移后拦截器的 onCommit() 方法打印了提交的消息
不过需要注意的是在使用拦截器功能的时候,如果使用了类似11-2代码清单中的带参数提交位移,那么可能会造成位移提交错误,因为拉取的消息集经过过滤器后集合中的最大位移可能被过滤。
在消费者中也有拦截链的概念,和生产者的拦截链一样,也是按照 interceptor.classes 配置的拦截顺序执行(拦截器之间用逗号隔开)。如果拦截链中某个拦截器执行失败,那么下一个拦截器会接着从上一个执行成功的拦截器继续执行。