Kafka消费者-笔记2
消费者,消费组
一个消费组只有一个消费者能消费消息
消费者Consumer负责订阅kafka中的主题Topic,并且从订阅的主题上拉取消息。
每个消费者有一个对应的消费组Consumer Group,当消息发布到主题后,只会被订阅它的每个消费组中的一个消费者
可通过消费者客户端参数partition.assignment.strategy
来设置消费者与订阅主题之间的分区分配策略
kafka支持两种消息投递模式:点对点P2P(所有消费者属于一个消费组),发布/订阅Pub/Sub模式(所有消费者属于不同的消费组)
消费者客户端参数group.id配置消费组
消费者客户端
正常的消费逻辑:
- 配置消费者客户端参数并创建消费者实例
- 订阅主题
- 拉取消息消费
- 提交消费位移
- 关闭消费者实例
demo
public static final String brokerList = "localhost:9092";
public static final String topic = "topic-demo";
public static final String groupId = "group.demo";
public static final AtomicBoolean isRunning = new AtomicBoolean(true);
public static Properties initConfig() {
Properties props = new Properties();
props.put("key.deserializer",
"org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer",
"org.apache.kafka.common.serialization.StringDeserializer");
props.put("bootstrap.servers", brokerList);
props.put("group.id", groupId);
props.put("client.id", "consumer.client.id.demo");
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.ofMinutes(2));
// 客户端拉取消息并消费所有消息
for (ConsumerRecord<String, String> record : records) {
System.out.println("topic = " + record.topic()
+ ", partition = " + record.partition()
+ ", offset = " + record.offset());
System.out.println("key = " + record.key()
+ ", value = " + record.value());
//do something to process record.
System.out.println(record.headers());
}
}
} catch (Exception e) {
log.error("occur exception ", e);
} finally {
consumer.close();
}
}
需要初始化配置于KafkaConsumer(subscribe方法订阅主题)(poll方法拉取消息列表)
参数配置
必要参数:
bootstrap.servers:连接kafka集群地址
group.id:消费者所属消费组的id
key.deserializer和value.deserializer:与生产者客户端相对应的反序列器
client.id:设定KafkaConsumer对应的客户端id,不设置 自动生成形如consumer-1
更多参数查看类ConsumerConfig
订阅主题和分区
消费者可以订阅一个或多个主题,使用subscribe方法订阅主题,订阅方式有数组和正则
主题
subscribe订阅主题:
// 集合方式订阅主题
public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener)
public void subscribe(Collection<String> topics)
// 正则方式订阅主题
public void subscribe(Pattern pattern, ConsumerRebalanceListener listener)
public void subscribe(Pattern pattern)
注意:subscribe执行多次以最后一次执行结果为准
ConsumerRebalanceListener用来设置相应的再均衡监听器
正则subscribe订阅主题实例:
订阅形如topic-1这样的主题 贪婪匹配
consumer.subscribe(Pattern.compile("topic-.*"));
取消订阅unsubscribe:
public void unsubscribe() 亦可取消assign订阅的主题
分区
KafkaConsumer通过assign方法直接订阅某些主题的分区
public void assign(Collection<TopicPartition> partitions)
TopicPartition:表示分区的对象
public final class TopicPartition implements Serializable {
private int hash = 0;
private final int partition;
// 分区所属主题
private final String topic;
...
}
assign实例:
指定消费topic-test主题的0分区
consumer.assign(Arrays.asList(new TopicPartition("topic-test", 0)));
KafkaConsumer:partitionsFor
查询指定主题的元数据信息 PartitionInfo主题的分区元数据
public List<PartitionInfo> partitionsFor(String topic)
public class PartitionInfo {
private final String topic;
private final int partition;
// 分区leader副本所在的位置
private final Node leader;
// 分区的AR集合
private final Node[] replicas;
// 分区的ISR集合
private final Node[] inSyncReplicas;
// 分区的OSR集合
private final Node[] offlineReplicas;
...
}
通过assign 和partitionsFor订阅所有主题的全部分区:
consumer.assign(consumer.partitionsFor(topic).stream().map(info -> new TopicPartition(info.topic(), info.partition())).collect(Collectors.toList()));
通过subscribe方法订阅主题具有消费者自动再均衡功能,消费组内的消费者数量变化,分区分配关系自动调整;assign方法订阅分区时,不具备消费者自动均衡的功能
反序列化
反序列化需要实现Deserializer
public interface Deserializer<T> extends Closeable {
void configure(Map<String, ?> var1, boolean var2);
T deserialize(String var1, byte[] var2);
void close();
}
kafka提供的序列化器和反序列化器满足不了应用需求的前提下,推荐使用Avro,JSON,Thrift,ProtoBuf或Protostuff等通用的序列化工具来包装
使用通用的序列化工具也要实现Serializer和Deserializer接口
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.5.6</version>
</dependency>
序列化:ProtostuffIOUtil
public static <T> byte[] toByteArray(T message, io.protostuff.Schema<T> schema, LinkedBuffer buffer)
Schema schema = (Schema) RuntimeSchema.getSchema(需要序列化的对象.getClass());
LinkedBuffer buffer =
LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
byte[] protostuff = null;
try {
protostuff = ProtostuffIOUtil.toByteArray(data, schema, buffer);
} catch (Exception e) {
throw new IllegalStateException(e.getMessage(), e);
} finally {
buffer.clear();
}
反序列化:ProtostuffIOUtil
public static <T> void mergeFrom(byte[] data, T message, io.protostuff.Schema<T> schema)
Schema schema = RuntimeSchema.getSchema(需要反序列化对象类.class);
需要反序列化对象 obj = new 需要反序列化对象();
ProtostuffIOUtil.mergeFrom(byteArray, obj, schema);
消息消费模式
Kafka的消费基于poll 拉取模式。消息的消息一般有种模式:1 推模式(服务端主动将消息推送给消费者)和2 拉模式(消费者主动向服务端发起请求来拉取消息)
消费者需要轮询调用poll方法,poll方法返回所订阅主题或分区上的一组消息
poll:涉及消费位移,消费者协调器,组协调器,消费者的选举,分区分配的分发,再均衡的逻辑,心跳等
timeout控制方法阻塞时间
public ConsumerRecords<K, V> poll(Duration timeout)
消费者消费的每条消息ConsumerRecord:
public class ConsumerRecord<K, V> {
public static final long NO_TIMESTAMP = -1L;
public static final int NULL_SIZE = -1;
public static final int NULL_CHECKSUM = -1;
private final String topic;
private final int partition;
private final long offset;
// 时间戳
private final long timestamp;
// 表示时间戳的类型:CreateTime和LogAppendTime
private final TimestampType timestampType;
// 以下两个字段表示key和value经过序列化之后的大小
private final int serializedKeySize;
private final int serializedValueSize;
private final Headers headers;
private final K key;
// 消息的值 一般读取这个value
private final V value;
private volatile Long checksum;
...
}
ConsumerRecords:消费者消息的集合
获取消息集合中指定分区的消息
public List<ConsumerRecord<K, V>> records(TopicPartition partition)
获取消息集合中所有分区
public Set<TopicPartition> partitions()
获取指定主题的消息
public Iterable<ConsumerRecord<K, V>> records(String topic)
计算消息集合中消息个数 count(),判断消息集合是否为空isEmpty(),empty()获取一个空的消息集合
位移提交
Kafka保持分区有序,分区中的消息都有一个offset表示消息在分区中的位置,一般称偏移量
而消费者也有一个offset,表示消费到分区中某个消息所在的位置,一般称消费位移
消费位移存储在Kafka的内部主题__consumer_offsets中,持久化消费位移的方法为commit提交,消费者在消费完消息时需要执行消费位移的提交
已经消费的位移=提交的位移-1
名词解释:
committed offset:已经提交过的消费位移
KafkaConsumer的以下方法
OffsetAndMetadata committed(TopicPartition partition)
position:下一次拉取的消息的位置
KafkaConsumer的以下方法
long position(TopicPartition partition)
lastConsumedOffet:当前消费到的位置
一般来说position = committed offset = lastConsumedOffset + 1 但并非绝对
kafka默认的消费位移提交方式是自动提交,消费者客户端参数为enable.auto.commit
,默认true。提交周期时间由客户端参数auto.commit.interval.ms
配置,默认5s
自动位移提交的逻辑在方法poll里
手动提交位移:enable.auto.commit
置位false
同步提交
// commitSync方法会根据poll方法拉取的最新位移来进行提交,该方法阻塞消费者线程直至位移提交完成
void commitSync();
void commitSync(Duration timeout);
// offsets参数,用来提交指定分区的位移
void commitSync(Map<TopicPartition, OffsetAndMetadata> offsets);
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> partitionRecords =
records.records(partition);
for (ConsumerRecord<String, String> record : partitionRecords) {
//do some logical processing.
System.out.println(record.value());
}
long lastConsumedOffset = partitionRecords
.get(partitionRecords.size() - 1).offset();
consumer.commitSync(Collections.singletonMap(partition,
new OffsetAndMetadata(lastConsumedOffset + 1)));
}
void commitSync(final Map<TopicPartition, OffsetAndMetadata> offsets, final Duration timeout);
异步提交
void commitAsync();
void commitAsync(OffsetCommitCallback callback);
// 异步提交的回调方法callback
void commitAsync(Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback);
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record : records) {
//do some logical processing.
}
consumer.commitAsync(new OffsetCommitCallback() {
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets,
Exception exception) {
if (exception == null) {
// success
} else {
log.error("fail to commit offsets {}", offsets, exception);
}
}
});
控制或关闭消费:通过pause和resume方法分别实现暂停某些分区在拉取操作时返回数据给客户端和恢复某些分区向客户端返回数据的操作
KafkaConsumer
以分区为单位
public void pause(Collection<TopicPartition> partitions)
public void resume(Collection<TopicPartition> partitions)
返回被暂停的分区集合
public Set<TopicPartition> paused()
KafkaConsumer非线程安全,wakeup方法是唯一可以从其他线程里安全调用的方法
指定位移消费
当消费者找不到记录的消费位移时,会根据消费者客户端参数auto.offset.reset
的配置来决定从何处开始消费,默认latest,表示从分区末尾开始消费消息 (earliest分区起始处 none报异常)
为了更细粒度的控制消费的位置,使用KafkaConsumer的seek方法,可以追前消费或回溯消费
- poll内分配分区
- seek重置消费者分配到的分区的消费位置
执行seek方法前需要先执行poll方法
public void seek(TopicPartition partition, long offset)
指定从分区末尾开始消费
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
Set<TopicPartition> assignment = new HashSet<>();
//1 等待poll内配分区
while (assignment.size() == 0) {
consumer.poll(Duration.ofMillis(100));
assignment = consumer.assignment();
}
//2 endOffsets获取指定分区的末尾的消息位置 若没指定timeout则该方法等待时间由客户端参数`request.timeout.ms设置 默认30000ms`
Map<TopicPartition, Long> offsets = consumer.endOffsets(assignment);
for (TopicPartition tp : assignment) {
consumer.seek(tp, offsets.get(tp));
}
//2代码简化为seekToEnd方法,从末尾执行
consumer.seekToEnd(assignment);
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofMillis(1000));
//consume the record.
for (ConsumerRecord<String, String> record : records) {
System.out.println(record.offset() + ":" + record.value());
}
}
消费指定时间的位移
// map 的key分区 value时间戳 以下方法返回时间戳大于等于待查询时间的第一条消息对应的位置和时间戳,对应返回值中OffsetAndTimestamp的offset和timestamp字段
public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch)
public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch, Duration timeout)
// 指定特定时间的消费位置
Map<TopicPartition, Long> partitionTimestamp = new HashMap<>(10);
for (TopicPartition partition : consumer.assignment()) {
// 构造Map<TopicPartition, Long> 时间是两天前
partitionTimestamp.put(partition, LocalDateTime.now().minusDays(2).toInstant(ZoneOffset.of("+8")).toEpochMilli());
}
Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes = consumer.offsetsForTimes(partitionTimestamp);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : offsetsForTimes.entrySet()) {
consumer.seek(entry.getKey(), entry.getValue().offset());
}
再均衡
再均衡指分区的所属权从一个消费者转移到另一个消费者的行为,在再均衡发生时,消费组内的消费者无法读取消息,再均衡可能导致重复消费
再均衡监听器:
public interface ConsumerRebalanceListener {
// 在再均衡开始前和消费者停止读取消息之后被调用 如保存位移进DB
void onPartitionsRevoked(Collection<TopicPartition> partitions);
// 在重新分配分区后和消费者开始读取消费之前被调用 如从DB读取位移消费
void onPartitionsAssigned(Collection<TopicPartition> partitions);
}
subscribe方法里配置监听器
消费者拦截器
消费者拦截器在消费到消息或在提交消费位移时进行一些定制化的操作,需要实现ConsumerInterceptor
public interface ConsumerInterceptor<K, V> extends Configurable {
// KafkaConsumer在poll方法返回之前调用拦截器onConsume方法对消息进行相应操作,如过滤,修改消息内容
public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);
// 在提交完消费位移后调用拦截器的onCommit方法,可记录跟踪提交的位移信息
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);
public void close();
}
消费者拦截器实现TTL拦截,判断信息的timestamp判断消息是否过期
public class ConsumerInterceptorTTL implements
ConsumerInterceptor<String, String> {
private static final long EXPIRE_INTERVAL = 10 * 1000;
// 消费之前进入
@Override
public ConsumerRecords<String, String> onConsume(
ConsumerRecords<String, String> records) {
System.out.println("before:" + records);
long now = System.currentTimeMillis();
Map<TopicPartition, List<ConsumerRecord<String, String>>> newRecords
= new HashMap<>();
for (TopicPartition tp : records.partitions()) {
List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
List<ConsumerRecord<String, String>> newTpRecords = new ArrayList<>();
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);
}
// commit提交位移时进入
@Override
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
System.out.println("提交位置");
offsets.forEach((tp, offset) ->
System.out.println(tp + ":" + offset.offset()));
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> configs) {
}
}
自定义拦截器需要配置interceptor.classes
,消费者拦截器需要注意 有参提交方法可能提交了错误的位移信息。 或,再一次消息poll中,可能含有最大偏移量的消息会被消费者拦截器过滤掉
消费者拦截器也存在拦截链,按照interceptor.classes
参数配置的拦截器的顺序来执行,若拦截链中某个拦截器执行失败,那么下一个拦截器会接着从上一个执行成功的拦截器继续执行(失败继续执行)
消费者客户端参数
1.fetch.min.bytes:配置Consumer在一次拉取请求(调用poll方法)中能从Kafka中拉取的最小数据量,默认1B
2.fetch.max.bytes:与上面相反,默认50MB,该参数不是绝对的最大值,大于该值的消息能被消费;Kafka中所能接收的最大消息的大小通过服务器端参数`message.max.bytes`对应于主题端参数max.message.bytes来设置
3.fetch.max.wait.ms:与配置1有关,指定Kafka的等待时间,默认500ms
4.max.partition.fetch.bytes:配置从每个分区里返回给Consumer的最大数据量,默认1MB,与参数2相似,不过4限制一次拉取中每个分区的消息大小,2限制一次拉取中整体消息的大小
5.max.poll.records:一次poll中拉取的最大消息数,默认500条
6.connections.max.idle.ms:指定多久关闭闲置的连接
7.exclude.internal.topics:内部主题__consumer_offsets 和 __transaction_state有关,false则内部主题不向消费者公开,默认true (无法正则匹配)
8.receive.buffer.bytes
9.send.buffer.bytes
10.request.timeout.ms:配置Consumer等待请求响应的最长时间 默认30000ms
11.metadata.max.age.ms:配置元数据的过期时间,默认5分钟
12.reconnect.backoff.ms:配置尝试重新连接指定主机之前的等待时间 默认50ms
13.retry.backoff.ms:配置尝试重新发送失败的请求到指定的主题分区前的等待时间 默认100ms
14.isolation.level:配置消费者的事务隔离级别,有效值read_uncommitted(默认) 和 read_committed,表示消费者所消费到的位置,后者会忽略事务未提交的消息,即只能消费到LSO的位置,默认可以消费到HW处的位置