消费者与消费者组
消费者(Consumer)负责订阅Kafka中的主题(Topic),并且从主题上拉取消息。Kafka中还存在消费者组(Consumer Group)的概念。每一个消费者都有一个对应的消费者组。当消息发布到主题后,会被投递给订阅它的消费者组中的一个消费者。
Kafka支持两种消息投递模式:点对点(P2P,Point-to-Point)模式和发布订阅(Pub/Sub)模式.
- 点对点模式基于队列,消息生产者发送消息到队列,消费者从队列中接受消息。所有消费者都位于同一个消费者组中,每条消息只会被一个消费者所处理。
- 发布订阅模式在消息一对多广播时采用。所有消费者都属于不同的消费者组,那么所有消息都会广播给所有消费者,每条消息会被所有消费者所处理。
要注意,主题下的某个分区的消息只能分配给一个消费者消费,一条消息也只能给消费者组中一个成员消费者进行消费。关于消费者组,踩过的坑详见:关于Kafka消费者群组的使用与理解–记一次故障引入的及时测试暴露与定位。
客户端开发一般流程
一般消费者的消费逻辑包含以下几个步骤:
- 配置消费者客户端参数及创建相应的消费者实例;
- 订阅主题;
- 拉取消息并消费;
- 提交消费位移;
- 关闭消费者。
示例:
public class KafkaConsumerAnalysis {
public static final String brokerList = "localhost:9092";
public static final String topic = "topic-demo";
public static final String groupId = "group.demo";
public static AtomicBoolean isRunning = new AtomicBoolean(true);
//初始化消费者配置
public static Properties initConfig() {
Properties pros = new Properties();
props.put(“bootstrap.servers", brokerList);
props.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("client.id", "consumer.client.id.demo");
props.put("group.id", groupId);
return props;
}
public static void main(String[] args) {
Properties props = initConfig();
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
try {
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMills(1000));
for (ConsumerRecords<String, String> record : records) {
//处理消息
}
}
} catch (Exception e) {
log.error("exception happends" , e);
} finally {
consumer.close();
}
}
}
订阅主题与分区
-
订阅主题
consumer的subscribe()方法可以用来为消费者订阅主题,可以以集合形式订阅主题,也可以以正则表达式的形式订阅特定模式的主题。subscribe()方法有以下几种重载方法:
public void subscribe(Collection<String> topics); public void subscribe(Pattern pattern); public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener); public void subscribe(Pattern pattern, ConsumerRebalanceListener listener);
注意:一个消费者若先后两次调用subscribe()方法订阅主题,那么最终订阅的主题以最后调用的为准,即主题的多次订阅会以覆盖的形式呈现。
-
订阅特定分区
Kafka消费者除了可以订阅主题,也可以通过assign()方法来直接订阅主题下的特定分区,assign()方法签名如下:
public void assign(Collection<TopicPartition> partition);
泛型TopicPartiton类中只有两个属性:topic和partition,分别代表分区所属的主题和自身分区编号。下列代码实现了消费者只订阅分区中编号为0的分区:
consumer.assign(Arrays.asList(new TopicPartition("topic-demo", 0)));
-
获取主题元数据信息
KafkaConsumer还提供了一个获得某一主题下所有分区的方法:partitionsFor()方法,该方法可以查询指定主题的元数据信息,定义如下:
public List<PartitionInfo> partitionsFor(String topic);
-
取消订阅
unsubscribe()方法用于取消消费者所订阅的主题或分区,如果将subscribe(Collection)和assign(Collection)方法中的集合参数设置为空,也可以取消订阅,如下三个方法的效果相同:
consumer.unsubscribe(); consumer.subscribe(new ArrayList<>()); consumer.assign(new ArrayList<TopicPartition>());
消费消息
Kafka中的消息消费是基于拉模式的,由消费者主动向服务端发起请求来拉取消息。
Kafka中消息消费是一个不断轮询的过程,消费者不断调用poll()方法,返回所订阅的主题(分区)上的一组消息。
poll()方法定义如下:
public ConsumerRecords<K, V> poll(final Duration timeout);
超时参数timeout用来控制poll()方法的阻塞时间,在消费者的缓冲区里没有可用数据时会发生阻塞。
在Kafka消费者中,消费方式一般有以下两种:
-
按分区消费消息
ConsumerRecords类提供了records(TopicPartition)方法,用来获取去消息集中指定分区的消息:
public List<ConsumerRecord<K, V>> records(TopicPartition partition);
以下代码展示了如何获取消息集中的所有分区信息:
ConsumserRecord<String, String> records = consumer.poll(Duration.ofMills(1000)); for (TopicPartition tp : records.partition()) { for (ConsumerRecord<String, String> record : records.records(tp)) { System.out.println(record.partition() + ":" + record.value)); } }
-
按主题消费消息
public Iterable<ConsumerRecord<K, V>> records(String topic);
ConsumerRecords类并没有提供与partition()类似的topics()方法来获取消息集中所有包含的主题,所以按主题消费消息只能遍历消费者订阅的主题列表来获取消息,代码片段如下:
List<String> topicList = Arrays.asList(topic1, topic2); consumer.subscribe(topicList); try { while(isRunning.get()) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMills(1000)); for (String topic : topicList) { for (ConsumerRecord<String, String> record : records.records(topic)) { System.out.println(record.topic + ":" + record.value()); } } } } finally { consumer.close(); }
位移提交
每条消息在Kafka的分区中都有自己的offset,表征自身在分区中的位置。消费者也有offset的概念,表示消费到分区中某个消息所在的位置。在分区中,offset可以翻译为“偏移量”,在消费者层面,offset可以翻译为“位移”。
关于lastConsumerOffset、committed offset和position之间的关系如下图所示:
position = committed offset = lastConsumedOffset + 1
从用户角度来看,位移提交有两种方式,分别为自动提交和手动提交。
自动提交
开启Consumer端的参数enable.auto.commit,将其设置为true,则开启了自动提交。
同时派上用场的另一个参数:auto.commit.interval.ms,其默认值为5秒,表明Kafka每5秒自动提交一次位移。
设置上述两个参数的部分代码为:
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "2000");
开启了自动提交,Kafka会保证在开始调用poll方法时,提交上次poll返回的所有消息。先提交上一批消息的位移,在处理下一批消息,因此自动提交可以保证不出现消费丢失的情况。
自动提交消费位移的方式较为简便,避免了复杂的位移提交逻辑。但是自动提交也有较大的缺点,那就是容易造成重复消费的问题。
重复消费的具体场景如下:刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者发生了崩溃或者Kafka发生了重平衡,那么就会从上一次位移提交的地方重新开始消费。减少自动提交的时间间隔可以减小重复消息的窗口大小,但无法完全避免消息的重复消费,并且会导致位移提交更加频繁。
手动提交
与自动提交相比,手动提交实现更加灵活,能够完全把控位移提交的时机和频率。
使用手动提交,需要将enable.auto.commit参数设置为false,然后在应用程序中手动调用相应的API手动进行消费位移的提交。
从Consumer端角度来看,手动提交根据方法返回方式又分为两种,分别为同步提交和异步提交。
同步提交
同步提交对应的API 是KafkaConsumer#commitSync(),方法声明如下:
public void commitSync();
这是一个同步操作,方法被调用后会一直等待,直到位移被成功提交才会返回。同步提交的一般使用方法如下:
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.pool(Duration,ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
process(record);//处理消息
}
try {
consumer.commitSync();
} catch (CommitFailedException e) {
handle(e);//处理提交失败异常
}
}
手动同步提交在调用commitSync()时,Consumer会处于阻塞状态,直到Broker返回提交结果,阻塞状态才会结束,位移提交时的阻塞情况会影响整个应用程序的TPS。延长提交间隔可以减少阻塞对性能带来的负面影响,但是后果是Consumer的提交频率降低,下次Consumer重启回来后,会有更多消息被重复消费。
异步提交
异步提交的方法声明:
public void commitAsync();
这是一个异步操作,调用该方法后会立即返回,不会阻塞,不会影响Consumer应用的TPS 。由于它是异步的,Kafka提供了回调函数(callback),供实现提交之后的逻辑,如记录日志或处理异常等等。commitAsync的一般使用方法如下:
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.pool(Duration,ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
process(record);//处理消息
}
consumer.commitAsync(offsets, exception) -> {
if (exception != null) {
handle(exception);
}
});
}
异步提交能否完全替代同步提交呢?不能,异步提交的缺陷在于,出现问题时不会自动重试。因为是异步操作,如果提交失败后自动重试,那么重试时提交的位移可能已经过期。因此重试对于异步提交来说没有意义,故调用commitAsync是不会重试的。
同步+异步组合位移提交
那么,在手动提交时,使用同步提交+异步提交的组合方式,才能达到最理想的效果。二者配合使用可以解决一下问题:
- commitSync的重试机制可以规避瞬时性的错误,如网络抖动、Broker端GC等。针对这些瞬时性问题,自动重试通常都会成功;
- commitAsync的机制可以避免程序总是处于阻塞状态,从而减少对TPS的影响。
同步+异步提交的具体实现如下:
try {
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
process(records);// 处理消息
consumer.commitAsync();// 使用异步提交规避阻塞
}
}
} catch (Exception e) {
handle(e);//处理异常
} finally {
try {
consumer.commitSync(); // 最后异常提交使用同步阻塞式提交
} finally {
consumer.close();
}
}
上述代码段同时使用了commitSync()和commitAsync()。对于常规阶段性提交调用commitAsync()避免程序阻塞,而在Consumer关闭前,调用commitSync()方法执行同步阻塞式的位移提交,以确保Consumer关闭前能够保存正确的消费位移数据。
指定分区、消费位移提交
Consumer提供了带参数的同步和异步提交方法,实现按具体消费位移进行提交,方法声明如下:
public void commitSync(Map<TopicPartition, OffsetAndMetadata>);
public void commitAsync(Map<TopicPartition, OffsetAndMetadata>);
方法的参数是一个Map对象,key为TopicPartition,即消息的分区,value为OffsetAndMetadata对象,保存的主要是位移数据。
例如,我们需要每处理100条消息就提交一次位移,以commitAsync为例(commitSync实现方式相同)。相应代码如下:
private Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
int count = 0;
......
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
process(records);// 处理消息
offsets.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)); //待提交的位移为下一条消息的位移
if (count % 100 == 0) {
consumer.commitAsync(offsets, null); //回调处理逻辑为null
}
count++;
}
}