快速入门
Kafka消息消费者逻辑应当具备以下步骤:
- 配置消费者参数,创建
KafkaConsumer
实例; - 订阅至少一个主题;
- 拉取消息获得
ConsumerRecords
,遍历ConsumerRecords
获取ConsumerRecord
对象,从中提取消息的内容; - 程序退出或者无需消费消息时关闭
KafkaConsumer
。
public class Consumer {
public static void main(String[] args) {
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.142.128:9092"); //Broker列表
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); //key反序列化器
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); //value反序列化器
p.put(ConsumerConfig.GROUP_ID_CONFIG, "group.1"); //消费者组
p.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo"); //Client ID
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);
consumer.subscribe(Collections.singletonList("topic-test")); //订阅主题topic-test
while(true) {
ConsumerRecords<String, String> record = consumer.poll(Duration.ofSeconds(5)); //拉取消息,阻塞时间5秒
if(record.isEmpty())
break;
//遍历消息并打印value
record.forEach(rec -> System.out.println("topic:" + rec.topic() + " val:" + rec.value()));
}
//关闭消费者
consumer.close();
}
}
需要注意的是,虽然KafkaProducer
是线程安全的,但是KafkaConsumer
不是线程安全的(除了wakeup方法
)。
必要的参数
bootstrap.servers
(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG
):意义和生产者相同,指定了生产者客户端连接Kafka集群所需的Broker列表,格式为host:port,用逗号分隔。这里并非需要所有的Broker,生产者可以根据一个Broker来自动获取其它Broker的信息。建议设置为2个以上,当客户端连接的Broker因为网络故障或者该Broker宕机时可以根据该配置连接至其它Broker。group.id
(ConsumerConfig.GROUP_ID_CONFIG
):当前消费者隶属消费者组的名称,默认为""
,不能设置为null
否则会抛出异常,建议设置为和业务功能相关的名称。key.deserializer
和value.deserializer
(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG
和ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG
):和生产者序列化器参数对应,这里是反序列化器,负责将字节流转换为Java对象,并将结果赋值给ConsumerRecord
的key
和value
字段。必须填写反序列化器的全限定名。
消费者组概念
和其它消息中间件不同,Kafka有一层消费者组的概念,每个消费者都必须具有一个消费者组。消息通过生产者发送到主题后,只会投递到订阅它的每个消费组中的一个消费者,也就是说,如果两个消费者都是同一个消费者组,那么消息会均摊给这两个消费者,每个消费者收到的消息不同。如果两个消费者隶属不同消费者组,那么它们获得到的消息将是一致的,类似于广播。
例如下图中有主题topic-test
,该主题拥有4个分区
、
、
、
。两个消费者组
和
订阅了该主题,消费者
、
、
所属消费者组
。
、
所属消费者组
。那么各个消费者获取消息的规律可能是这样的:
因为有4个分区,所以消费者组
中的消费者必须有且一个消费者消费2个分区,其它两个各自消费一个分区,也就是说,
消费
,
消费
,
消费
和
。消费组
有两个消费者,所以每个消费者负责两个分区,可能就是
消费
和
,
消费
和
。两个消费者组之间互不影响,每个消费者只能消费所分配到的分区中的消息。
需要注意的是,如果消费组中的消费者多于一个主题中分区数,那么多出的消费者由于无法分配到分区而无法消费到该主题的消息,也就是下面这种情况:
需要注意消费组是一个逻辑概念,它可以将消费者归为一类。消费者并非逻辑概念,它是应用的实例,可以是一个线程或是一个进程,每个KafkaConsumer
对象可以看成是一个消费者。
主题订阅
创建完KafkaConsumer
后,就需要为消费者订阅相关的主题,一个消费者可以订阅一或多个主题,可通过调用KafkaConsumer
的subscribe
方法订阅:
public void subscribe(Collection<String> topics);
public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener);
public void subscribe(Pattern pattern);
public void subscribe(Pattern pattern, ConsumerRebalanceListener listener);
最简单的方式调用subscribe(Collection<String>)
传入一个集合,集合中需要包含主题的名称。或者调用subscribe(Pattern)
传入一个正则表达式,Broker中所有和该正则表达式匹配的主题都将被当前消费者订阅。此外还可以传入一个参数ConsumerRebalanceListener
,这个参数是用来设置再均衡监听器的,我们稍后再讨论。
如果在没有订阅主题的情况下就尝试进行消费,KafkaConsumer
会抛出IllegalStateException
异常。
此外,如果需要指定订阅的分区,可以调用KafkaConsumer
的assign
方法:
public void assign(Collection<TopicPartition> partitions)
TopicPartition
对象包含了主题名称和分区编号,用户可以直接构造该对象并传入assign
方法。
为了方便用户能够获知Kafka集群中一个主题的分区信息,KafkaConsumer
提供了partitionsFor
方法供用户查询:
public List<PartitionInfo> partitionsFor(String topic);
PartitionInfo
类型为主题的分区元数据信息,包含以下字段:
public class PartitionInfo {
private final String topic; //主题
private final int partition; //分区
private final Node leader; //分区的leader副本所在位置
private final Node[] replicas; //分区的AR集合
private final Node[] inSyncReplicas; //分区的ISR集合
private final Node[] offlineReplicas; //分区的OSR集合
}
如果需要取消所有主题的订阅,直接调用KafkaConsumer
的unsubscribe
方法即可。或者,也可以通过调用subscribe
或assign
方法时传入一个空的集合来取消所有的订阅。
反序列化器
因为从Broker拉取的消息都是字节流的形式,所以需要通过反序列化器将消息中的key
和value
转换为Java对象。
反序列化器必须实现Deserializer
接口,它包含以下几个方法:
public interface Deserializer<T> extends Closeable {
void configure(Map<String, ?> configs, boolean isKey);
T deserialize(String topic, byte[] data);
default T deserialize(String topic, Headers headers, byte[] data) {
return deserialize(topic, data);
}
@Override
void close();
}
Kafka提供了几个基本的Deserializer
实现类,它们有:StringDeserializer
、UUIDDeserializer
、IntegerDeserializer
、LongDeserializer
、ByteArrayDeserializer
、ByteBufferDeserializer
、ShortDeserializer
、FloatDeserializer
、DoubleDeserializer
。
消息消费
Kafka的消费模式是基于拉(poll
)模式的,需要消费者主动从消息队列拉取消息。从开头的示例代码可以看出,Kafka的消费是一个轮询的过程,需要重复地调用poll
方法,它会返回所订阅的主题中的消息。poll
方法定义如下:
public ConsumerRecords<K, V> poll(final Duration timeout);
如果此时消费者的缓冲区没有可用的消息数据时就会发生阻塞,超时参数timeout
用于控制其阻塞时间。timeout
的设置取决于应用程序对响应速度的要求,如果将timeout
设置为0,poll
方法会立刻返回,不论是否已经拉取到了消息,需要注意的是有可能会造成CPU的高占用。如果当前线程是专门拉取消息的,将timeout
设置为Long.MAX_VALUE
也未尝不可,因为poll
方法是可以捕获线程的interrupt
的,如果当前线程被interrupt
,那么会抛出org.apache.kafka.common.errors.InterruptException
异常,注意这个异常是RuntimeException
,需要用户手动捕获。此外,中止消费还可以调用KafkaConsumer
的wakeup
方法,调用后再调用poll
方法会抛出WakeupException
。
poll
方法返回的ConsumerRecords
包含了多个ConsumerRecord
,与生产者发送的ProducerRecord
一一对应,只不过ConsumerRecord
包含了更多的字段:
public class ConsumerRecord<K, V> {
private final String topic; //主题
private final int partition; //分区
private final long offset; //偏移量
private final long timestamp; //时间戳
private final TimestampType timestampType; //时间戳类型
private final int serializedKeySize; //key经过序列化后的大小,如果key为null那么该值为-1
private final int serializedValueSize; //value经过序列化后的大小
private final Headers headers; //消息头
private final K key; //key经过反序列化后的对象
private final V value; //value经过反序列化后的对象
private final Optional<Integer> leaderEpoch; //leader的epoch版本
private volatile Long checksum; //检验和,该值为CRC32的校验值
}
时间戳有两种类型:CREATE_TIME
和LOG_APPEND_TIME
,前者表示消息创建的时间戳,后者表示消息追加到日志的时间戳。
消费位移
一个分区中的每条消息都有一个唯一的offset
,用来表示消息在分区中的位置。对于消费者而言也有一个offset
的概念,消费者通过它来表示消费到分区中某个消息所在的位置。对于前者,我们称offset
为偏移量,对于后者,我们称之为消费位移。
每次调用poll
方法时,它返回仅仅只是一个消息集合,并没有真正将消息从分区中“拉取”出来。如果我们在构造KafkaConsumer
时加入以下参数:
p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
反复运行上述程序,会发现每次取出来的消息是一样的,看上去像是peek
而不是poll
。
这是因为KafkaConsumer
默认打开了enable.auto.commit
配置,从而导致KafkaConsumer
会自动将消费位移提交给Broker。如果关闭了enable.auto.commit
,那么需要显式调用KafkaConsumer
的commitSync
或commitAsync
方法来提交消费位移,前者是同步提交,后者是异步提交。
消费位移存储在Kafka内部的主题__consumer_offsets
中。把消费位移存储起来持久化的动作称为提交。
在上图中,
表示某一次拉取操作中此分区的消息的最大偏移量,那么我们可以说消费者的消费位移为
。不过需要明确的是,当前消费者提交的消费位移不是
,而是
,表示下一条需要拉取的消息的位置。
KafkaConsumer
提供了以下方法来获取上面的值:
public long position(TopicPartition partition)
public OffsetAndMetadata committed(TopicPartition partition)
position
方法可以获取上图中
的值,comrnitted
方法可以获取上次提交过的消费位移。
重复消费问题
在自动提交打开的情况下(默认情况),消费者每隔5秒就会将拉取到的每个分区中最大的消息位移进行提交。自动提交虽然简便,它免去了复杂的位移提交逻辑,使得客户端代码更加简洁。但是会造成重复消费和消息丢失的问题:假设刚刚提交完一次消费位移,然后此时又拉取了一批消息,然后在提交本次消费位移时消费者宕机,那么消费者重启后就会收到这批重复消费过的消息。我们可以通过增大位移提交时间间隔来减少重复消息的窗口大小,但是依然不能从根本上避免重复消费。
同步提交和异步提交
之前提到过,手动提交可以分为同步提交和异步提交,对应KafkaConsumer
的commitSync
方法和commitAsync
方法。
同步提交使用起来很简单,例如:
List<ConsumerRecord<String, String>> cache = new ArrayList<>();
while(Thread.currentThread().isInterrupted()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(3));
records.forEach(cache::add);
if(cache.size() > 100) {
//Do something...
consumer.commitSync();
cache.clear();
}
}
这里批量拉取消息并存入本地List
缓存,缓存容量达到100后再对消息进行批量执行,然后再提交消费位移。如果在业务处理完成之后,同步位移前程序宕机就会发生重复消费问题。
这里的commitSync
方法会根据poll
方法拉取的最新位移进行提交。如果没有发生不可恢复的错误,就会阻塞当前线程直到位移提交完成。不可恢复的异常需要手动捕获并进行处理。
如果需要进行更细粒度的提交,那么需要使用到另外一个重载的commitSync
方法:
public void commitSync(Map<TopicPartition , OffsetAndMetadata> offsets)
offsets
参数可用来指定分区的位移,例如:
while(Thread.currentThread().isInterrupted()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(3));
records.forEach(e -> {
TopicPartition tp = new TopicPartition(e.topic(), e.partition());
consumer.commitSync(Collections.singletonMap(tp, new OffsetAndMetadata(e.offset() + 1)));
});
}
这里仅仅只是演示使用方法,生产环境中不要遍历一个消息就同步一次位移。
对于异步提交方式,在执行该方法时不会阻塞消费者线程,可在提交消费位移后还未收到响应后就可以执行一次消息拉取操作。异步提交可以提高消费者消息吞吐量。commitAsync
方法具有三个不同的重载方法:
public void commitAsync();
public void commitAsync(OffsetCommitCallback callback);
public void commitAsync(Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback)
相比commitSync
就是多了一个OffsetCommitCallback
选项,在位移提交完成后会执行这个回调。
commitAsync
同样也会有提交失败情况产生。异步提交有一个情景需要考虑,在某次提交的消费位移为
,但是提交失败了,下一次异步提交的消费位移为
,这次成功了,考虑到重试机制,如果前者进行重试并且这次提交成功了,那么此时消费位移又变成了
。如果此时发生了宕机,那么重启之后消费者又从
开始消费消息,这样就产生了重复消费的问题了。为此我们可以增加一种机制:如果重试时发现更大的位移已经提交了,那么就不应当进行重试操作了。一般情况下,位移提交失败的操作很少发生,不重试的话问题也不大,后面的提交操作应该也会提交成功。如果消费者异常退出,那么重复消费的问题也较难得到避免,我们可以使用try...finally...
机制并使用同步提交来尽可能避免重复消费问题:
try {
while(Thread.currentThread().isInterrupted()) {
//poll message
consumer.commitAsync();
}
} finally {
try {
consumer.commitSync();
} finally {
consumer.close();
}
}
消费者拦截器
和生产者拦截器类似,消费者也有拦截器ConsumerInterceptor
:
public interface ConsumerInterceptor<K, V> extends Configurable, AutoCloseable {
public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);
public void close();
}
KafkaConsumer
会在poll
方法返回消息之前调用拦截器的onConsume
方法,并传入本次poll
方法拉取到的消息。在这里可以进行一些定制化操作,比如过滤一些不合法的消息等,写一些日志等。
onCommit
方法则是在KafkaConsumer
提交消费位移时被触发,并传入消费位移的具体细节。可以使用该方法来跟踪位移提交记录。
close
方法和ProducerInterceptor
作用一样。
消费控制
使用KafkaConsumer
的paused
和resume
方法可以实现暂停某些分区在拉取操作时返回给客户端和恢复指定分区向客户端返回数据的操作:
public void pause(Collection<TopicPartition> partitions);
public void resume(Collection<TopicPartition> partitions);
同时也可以调用paused
方法来获取被暂停的分区集合:
public Set<TopicPartition> paused();
消费起始位置的控制
Kafka服务器通过持久化消费位移的方式,来让消费者客户端在关闭、崩溃或者遇到再均衡的时候,能让接替的消费者根据存储的消费位移继续进行消费。
如果一个消费者组建立的时候,如果因为没有可供查找的消费惟一或者__consumer_offsets
主题中有关这个消费者组的位移信息过期而被删除,就会根据客户端参数auto.offset.reset
配置来决定从何处开始消费,该参数取值可以为:
latest
(默认值),表示从分区尾端开始消费earlist
:从分区起始处开始消费none
:抛出NoOffsetForPartitionException
异常
需要注意的是,位移越界也会触发参数auto.offset.reset
的执行。
如果需要更细粒度地掌控消费的起始位置,可以通过调用seek
方法:
public void seek(TopicPartition partition, long offset)
partition
存储了主题信息和分区信息,offset
参数指定从分区的哪个位置开始消费。seek
方法只能重置消费者分配的分区消费位置,而分区的分配是在poll
方法中实现的。也就是说在执行seek
方法前需要执行一次poll
方法,并且poll
方法的阻塞时间不能过短,否则可能会因为分区分配的逻辑来不及得到执行就返回。
在调用seek
方法时,如果需要获知分区消息的起始位置的offset
(一个分区的起始位置为0,但是Kafka会清理旧的数据,所以起始位置也会增加),那么可以调用KafkaConsumer
的beginningOffsets
方法:
public Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions)
public Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions, Duration timeout)
参数partitions
表示分区集合,timeout
参数用于控制阻塞时间。如果没有指定timeout
参数,则阻塞时间由参数request.timeout.ms
来决定,该参数默认为30000。
我们还可以获取一个分区末尾消息位置,通过方法endOffsets
来获取:
public Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions)
public Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions, Duration timeout)
再均衡
再均衡是指分区所属权从一个消费者转移到另外一个消费者的行为,它为消费者组的高可用性和伸缩性提供了保障。再均衡机制可以让我们既方便又安全地删除消费者组内的消费者或是添加消费者。
在再均衡执行期间,消费者组内的消费者是无法拉取消息的。此外,当一个分区被分配到另外一个消费者时,消费者当前的状态也会发生丢失。例如一个消费者在消费一批消息后还没有提交位移就发生了再均衡操作,之后该分区被分配到的新的消费者会把那批消息再进行一次消费,也就是发生了重复消费问题。
再均衡监听器ConsumerRebalanceListener
可以在再均衡动作前后做一些准备和收尾工作:
public interface ConsumerRebalanceListener {
/**
* 该方法会在再均衡开始之前和消费停止拉取消息之后调用。可以通过该回调方法来处理消费位移的提交,
* 以避免一些不必要的重复消息发生
* @param partitions 再均衡前所订阅的主题分区信息
*/
void onPartitionsRevoked(Collection<TopicPartition> partitions);
/**
* 重新分配分区之后和消费者开始拉取消息之前调用
* @param partitions 再均衡后所分配的主题分区信息
*/
void onPartitionsAssigned(Collection<TopicPartition> partitions);
}
我们可以在onPartitionsRevoked
方法内同步提交位移,以避免不必要的重复消费。
其它重要的消费者参数
讨论完上述概念后,我们再来看看还有哪些重要的消费者参数。
fetch.min.bytes
: Consumer 在一次拉取请求中能从 Kafka 中拉取的最小数据量,默认为1字节。如果调用poll
方法返回给Consumer
的数据量小于这个参数,那么会进行等待直到数据量超出该参数值。对于时间敏感性型消息不建议将该参数设置得过大。fetch.max.bytes
:Consumer 在一次拉取请求中能从 Kafka 中拉取的最大数据量,默认为52428800字节,也就是50MB。该参数并不是绝对的,如果第一条消息大于该值,那么仍然可以被消费,只不过每次只能消费一条。fetch.max.wait.ms
:该参数用来指定等待时间,默认为500。即使本次拉取的数据量小于fetch.min.bytes
,只要超出该时间就会返回。max.poll.records
:一次拉取请求中的最大消息数,默认为500条。connections.max.idle.ms
:多久之后关闭空闲的Kafka连接,默认540000,9分钟。receive.buffer.bytes
:Socket接收缓冲区(SO_RECBUF
)大小,默认为65536字节,设置为-1则采用操作系统默认值。send.buffer.bytes
:Socket发送缓冲区(SO_SNDBUF
)大小,默认131072字节,设置为-1则采用操作系统默认值。request.timeout.ms
:消费者等待请求响应的最长时间,默认为30000。metadata.max.age.ms
:配置元数据过期时间,默认为300000ms,5分钟。reconnect.backoff.ms
:配置尝试重新连接指定主机之前的等待时间,避免频繁地连接主机,默认为50ms。isolation.level
:消费者事务隔离级别。有效值为read_uncommitted
和read_commited
。