key
和partition
属性有关;partition.assignment.strategy
来设置消费者与订阅主题之间的分区分配策略。RangeAssignor
、RoundRobinAssignor
、StickyAssignor
;partition.assignment.strategy
的默认参数是org.apache.kafka.clients.consumer.RangeAssignor
,即默认使用RangeAssignor
分区分配策略;RangeAssignor
分区分配策略的原理是:按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费组;RangeAssignor
会将消费组内所有订阅这个主题的消费者按名称的字典顺序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典靠前的消费者会被多分配一个分区;假设主题T0有4个分区:P0、P1、P2、P3,消费组内有两个消费者:C0、C1,则RangeAssinger
策略分区分配方案是:
T0: P0 P1 P2 P3
| | | |
C0 C0 C1 C1
假设主题T1有3个分区:P0、P1、P2 ,消费者内有两个消费者:C0、C1,则RangeAssigner
策略的分区分配方案是:
T1: P0 P1 P2
| | |
C0 C0 C1
RoundRobinAssignor
分区分配策略的原理是:将消费组内所有消费者及消费组订阅的所有主题的分区按照字典序排序,然后通过轮询方式逐个将分区依次分配给每个消费者;RoundRobinAssignor
会把消费组订阅的所有主题的所有分区排序;TO: P0 P1 P2
T1: P1 P1 P2
消费者排序: C0 C1
所有分区排序: T0P0 T0P1 T0P2 T1P0 T1P1 T1P2
轮询分配:T0P0 T0P1 T0P2 T1P0 T1P1 T1P2
| | | | | |
C0 C1 C0 C1 C0 C1
最终分配结果:
消费者C0: T0P0、T0P2、T1P1
消费者C1: T0P1、T1P0、T1P2
消费组: C0、C1、C2
主题T0: P0 <-订阅--C0 <-订阅--C1 <-订阅--C2
主题T1: P0 P1 <-订阅--C1 <-订阅--C2
主题T2: P0 P1 P2 <-订阅--C2
轮询分配: T0P0 T1P0 T1P1 T2P0 T2P1 T2P2
| | | | | |
C0 C1 C2 C2 C2 C2
最终分配结果:
消费者C0: T0P0
消费者C1: T1P0
消费者C2: T1P1、T2P0、T2P1、T2P2
StickAssignor
分配策略又叫粘性分配策略,它有两个目标:
当两者发生冲突时,第一个目标优先于第二个目标;
StickAssignor
分配策略的实现比较复杂(书上并没有讲解,只给出了分配结果,网络上也没有找到好的讲解),根据分配结果可以有以下推测:
RoundRobinAssignor
分配策略的分配结果相同;RoundRobinAssignor
不同,分配结果更加均匀;RoundRobinAssignor
会重新分配所有的分区,而StickAssignor
会将退出的消费者分配的分区分配给其他消费者,未退出的消费者之前分配的分区尽量不会变化(粘性);对于消息中间件而言,一般有两种消息投递模式:点对点模式(P2P)和发布/订阅模式(Pub/Sub)
点对点模式中,生产者将消息发送到队列中,消费者从队列中取出并消费消息,消息被消费后队列不再存储,队列支持多个消费者。该模式有以下特点:
Kafak不是一种典型的点对点模式,但是通过合理的使用消费组(Consumer Group),可以实现点对点模式:
发布订阅模式中,生产者将消息发布到topic中,同时有多个消费者可以消费到该消息。该模式的特点是:
Kafka是典型的发布/订阅模式,但是要实现发布/订阅模式还需要正确使用消费组的概念:
org.apache.kafka
kafka-clients
2.0.0
public class ConsumerFastStart {
public static final String brokerList = "localhost:9092";
public static final String topic = "topic-learn";
public static final String groupId = "group.demo";
public static final AtomicBoolean isRunning = new AtomicBoolean(true);
public static Properties initProperties() {
Properties proper = new Properties();
proper.put("bootstrap.servers", brokerList);
proper.put("key.deserializer", StringDeserializer.class.getName());
proper.put("value.deserializer",StringDeserializer.class.getName());
proper.put("client.id", "consumer.client.id.demo");
// 设置消费者所属的消费组的名称
proper.put("group.id", groupId);
return proper;
}
public static void main(String[] args) {
Properties proper = initProperties();
// 创建一个消费者客户端实例
KafkaConsumer consumer = new KafkaConsumer<>(proper);
// 订阅主题
consumer.subscribe(Collections.singletonList(topic));
// 循环消费消息
System.out.println("====== 接收消息 ======");
// 使用 AtomicBoolean来作为while循环,可以通过 isRunning.set(false)来结束下一轮循环
while (isRunning.get()) {
ConsumerRecords records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord record : records) {
System.out.println(record.value());
}
}
}
}
bootstrap.servers
、group.id
、key.deserializer
、value.deserializer
是消费者客户端必填的参数;ConsumerConfig
里的常量;ProducerConfig
;bootstrap.servers
:释义和生产者客户端KafkaProducer
中的相同,指定连接kafka集群所需的broker地址清单;group.id
:消费者隶属的消费组,默认值为’’,这个是必填值,如果设置为空,会报异常;key.deserializer
和value.deserializer
:用来指定消息中key
和value
所需的反序列化器,参数无默认值;需要和生产者客户端KafkaProducer
中配置的key.serializer
和value.serializer
相对应;client.id
:用来设定KafkaConsumer
对应的客户端id,默认值为’’,如果客户端不设置,则KafkaConsumer会自动生成一个非空字符串;KafkaConsumer订阅主题的方法有以下几个:
/**
* KafkaConsumer订阅主题的方法
*/
public void subscribe(Collection topics);
public void subscribe(Collection topics, ConsumerRebalanceListener listener)
public void subscribe(Pattern pattern);
public void subscribe(Pattern pattern, ConsumerRebalanceListener listener);
KafkaConsumer还可以通过assign
方法直接订阅分区
/**
* KafkaConsumer的 assign方法
*/
public void assign(Collection partitions);
其中,TopicPartition
对象代表主题的分区
/**
* 主题分区对象
*/
public final class TopicPartition implements Serializable {
private int hash = 0;
private final int partition;
private final String topic;
// ... 省略内部方法
}
KafkaConsumer中的unsubscribe
方法可以用来取消订阅,既可以取消通过subscribe
订阅的主题,也可以取消通过assign
方法直接订阅的分区;
/**
* KafkaConsumer的unsubscribe方法
*/
public void unsubscribe();
subscribe
方法订阅主题具有消费者自动再均衡的功能;而通过assign
方法订阅分区时,是不具备消费者自动均衡功能的;再均衡监听器接口
/**
* 再均衡监听器接口
*/
public interface ConsumerRebalanceListener {
void onPartitionsRevoked(Collection<TopicPartition> partitions);
void onPartitionsAssigned(Collection<TopicPartition> partitions);
}
onPartitionsRevoked
方法:在再均衡开始之前和消费者停止读取消息之后被调用,参数partitions
表示再均衡前所分配的分区;onPartitionsAssigned
方法:在重新分配分区之后和消费者开始读取消费之前被调用。参数partitions
表示再均衡后所分配的分区;消息的消费一般有两种模式:推模式和拉模式:
Kafka中的消费是基于拉模式的;
poll()
方法,而poll
方法返回的是所订阅的主题(分区)上的一组消息;/**
* KafkaConsumer的poll方法
*/
@Deprecated
public ConsumerRecords poll(final long timeout);
public ConsumerRecords poll(final Duration timeout);
// 内部调用的方法
private ConsumerRecords poll(final long timeoutMs, final boolean includeMetadataInTimeout);
poll(final long timeout)
和poll(final Duration timeout)
,参数传递一个超时时间,用来控制poll
方法阻塞的时间,在消费者的缓冲区里没有可用数据时,会发生阻塞;Deprecated
,被第二个替代;因为第一个方法的时间单位固定为毫秒,第二个方法可以根据Duration
中的ofMillis()
、ofSeconds
、ofHours
等多种不同的方法指定不同的时间单位;Duration
是从jdk1.8开始添加的时间内,在包java.time
包下;poll
方法内部会进行分区分配的逻辑,如果将参数设置为0
,则该方法会立刻返回,内部的分区分配逻辑会来不及实施;poll
方法返回的是ConsumerRecords
,它用来表示一次拉取操作所获得的消息集,内部包含了若干ConsumerRecord
(不带s
);/**
* 消息集 ConsumerRecords 内部的方法
*/
public class ConsumerRecords implements Iterable> {
// 提取消息集中指定分区的消息
public List> records(TopicPartition partition);
// 提取消息集中指定主题的消息
public Iterable> records(String topic);
// 查看拉取的消息集中的分区列表
public Set partitions();
// 循环遍历消息集中的消息
public Iterator> iterator();
// 返回消息集中消息的个数
public int count();
// 判断消息集是否为空
public boolean isEmpty();
// ...
}
消费者拉取的消息ConsumerRecord:
/**
* 消费者客户端拉取的消息 ConsumerRecord
*/
public class ConsumerRecord {
public static final long NO_TIMESTAMP = RecordBatch.NO_TIMESTAMP;
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;
private final TimestampType timestampType;
private final int serializedKeySize;
private final int serializedValueSize;
private final Headers headers;
private final K key;
private final V value;
private volatile Long checksum;
// ... 省略内部方法
}
K
代表key
的类型;V
代表value
的类型;auto.offset.reset
的配置来决定从何处开始进行消费;auto.offset.reset
参数取值:
latest
,默认参数,会从分区末尾开始消费消息;earlist
,会从起始处开始消费;none
,表示出现查不到消费位移的时候,既不从最新的消息位置处开始消费,也不从最早的消息位置处开始消费,而是会报出NoOffsetForPartitionException
异常;auto.offset.reset
参数的执行;KafkaConsumer的seek
方法提供可以从特定的位移处开始拉取消息:
/**
* KafkaConsumer的seek方法
*/
public void seek(TopicPartition partition, long offset);
public void seekToBeginning(Collection partitions);
public void seekToEnd(Collection partitions);
seek
方法的参数partitions
表示分区,offset
参数用来指定从分区的哪个位置开始消费;seek
方法只能重置消费者分配到的分区的消费位置,而消费者的分区分配是在poll
方法调用过程中实现的,所以在执行seek
方法之前需要先执行一次poll
方法,等到分配到分区之后才可以重置消费位移;poll
方法的时间参数设置为0
,则会立刻返回,那么方法内部的分区分配逻辑会来不及实施;代码:使用seek()方法从分区末尾消费
/**
* 使用seek()方法从分区末尾消费
*/
public static void seekTest() {
Properties proper = initProperties();
KafkaConsumer consumer = new KafkaConsumer<>(proper);
consumer.subscribe(Arrays.asList(topic));
Set assignment = new HashSet<>();
while (assignment.size() == 0) {
consumer.poll(Duration.ofMillis(100));
// KafkaConsumer的 assignment 方法获取消费者所分配到的分区信息
assignment = consumer.assignment();
}
// KafkaConsumer的 endOffsets 方法用来获取指定分区的末尾的消息位置,返回一个 Map
Map offsets = consumer.endOffsets(assignment);
for (TopicPartition tp : assignment) {
consumer.seek(tp, offsets.get(tp));
}
}
assignment
方法获取消费者所分配到的分区信息;endOffsets
方法用来获取指定分区的末尾的消息位置,返回一个 Map;endOffsets
方法还可以接收一个时间参数Duration timeout
,指定等待获取的超时时间,如果没有指定timeout
,那么等待时间由客户端参数request.timout.ms
来设置,默认是30000
;endOffsets
方法对应的还有beginningOffsets
方法;seekToBeginning
和seekToEnd
方法来实现直接从分区的开头或末尾开始消费;pause()
和resume()
方法来分别实现:暂停某些分区在拉取操作时返回数据给客户端;恢复某些分区在向客户端返回数据;/**
* KafkaConsumer的pause()和resume()方法
*/
public void pause(Collection partitions);
public void resume(Collection partitions);
KafkaConsumer是线程不安全的,但是有一个wakeup()
方法可以从其他线程里安全调用,调用wakeup()
方法可以退出poll()
方法的逻辑,并抛出WakeupException
异常,我们不需要处理该异常,它只是一种跳出循环的方式;
close()
方法来实现关闭;/**
* KafkaConsumer的wakeup()方法和close()方法
*/
public void wakeup();
public void close();
public void close(Duration timeout);
@Deprecated
public void close(long timeout, TimeUnit timeUnit)
offset
,用来标识消息在分区中对应的位置;offset
,表示当前消费到分区中的某个消息所在的位置;poll()
方法时,返回的是还没有消费过的消息集,要做到这一点就要记录上一次消费时的消费位移;并且这个消费位移必须做持久化保存,而不是单单保存在内存中;这样在消费者重启、新的消费者加入、再均衡发生时,都能够知晓之前的消费位移,然后继续消费后续的消息;Zookeeper
中的,而在新消费者客户端中,消费位移存储在Kafka内部的主题__consumer_offsets
中;ConsumerRecord
消息中有属性offset
记录本次消息在分区的偏移量,KafkaConsumer
的committed()
方法获取提交的消费位移,比获取的消息的最大偏移量大1;位移提交时机的把握也很讲究,不同的提交时机可能造成重复消费或消息丢失的现象;
如果拉取完消息还未做消息处理前,就立即提交消费位移,有可能造成消息丢失现象:
[x+2,x+7]
其中x+2
代表上一次提交的消费位移,如果拉取到消息之后就进行了位移提交,即提交了x+8
(下一次需要消费的消息位移),那么假如当前消费到了x+5时,消费者遇到了异常,在故障恢复后,消费者重新拉取消息,因为已经提交了消费位移x+8
,所以重新拉取的消息是从x+8
开始的,这样会导致x+5
到x+7
之间的消息未被处理,如此便发生了消息丢失现象;如果位移提交动作是在消费完所有拉取的消息后才执行,有可能造成重复消费现象:
[x+2,x+7]
其中x+2
代表上一次提交的消费位移,当消费到x+5
时遇到了异常,在故障恢复后,重新拉取消息,因为本次消费位移还未提交,则重新拉取的消息是从x+2
开始的,也就是说x+2
到x+4
的消息又重新消费了一遍,故而发生了重新消费的现象;enable.auto.commit
配置,默认值为true
;5秒
(由参数auto.commit.interval.ms
控制)会将拉取的每个分区中最大的消息位移进行提交。自动位移提交的动作是在poll()
方法的逻辑里完成的,在每次真正向服务器发起拉取请求之前会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移;手动提交分为:同步提交和异步提交;
commitSync()
方法;commitSync()
提交消费位移时,会阻塞消费者线程直至位移提交完成;/**
* KafkaConsumer 同步位移提交方法 commitSync
*/
public void commitSync();
public void commitSync(Duration timeout);
public void commitSync(final Map offsets);
public void commitSync(final Map offsets, final Duration timeout);
commitAsync()
方法;commitAsync()
执行时,消费者线程不会被阻塞,可能在提交消费位移的结果还未返回之前就开始了新一次的拉取操作;/**
* KafkaConsumer 异步位移提交方法 commitAsync
*/
public void commitAsync();
public void commitAsync(OffsetCommitCallback callback);
public void commitAsync(final Map offsets, OffsetCommitCallback callback);
commitAsync()
中可以传递一个异步提交的回调方法,OffsetCommitCallback
接口中有一个方法onComplete()
,在提交完成时调用public interface OffsetCommitCallback {
void onComplete(Map offsets, Exception exception);
}
生产者KafkaProducer
发送消息时会调用序列化器将消息转换成字节数组byte[]
,消费者KafkaConsumer
在接收消息时,会调用对应的反序列化器将字节数组反序列化为消息对象;
反序列化器需要实现接口Deserializer
:
/**
* 反序列化器接口
*/
public interface Deserializer extends Closeable {
void configure(Map configs, boolean isKey);
T deserialize(String topic, byte[] data);
@Override
void close();
}
configure()
方法用来配置当前类;deserialize()
方法用来执行反序列化,如果data
为null,那么处理的时候直接返回null,而不是抛出一个异常;void()
用来关闭当前序列化器;Kafka提供的反序列化器有:ByteBufferDeserializer
、ByteArrayDeserializer
、BytesDeserializer
、DoubleDeserializer
、FloatDeserializer
、IntegerDeserializer
、LongDeserializer
、ShortDeserializer
、StringDeserializer
,分别用于ByteBuffer
、ByteArray
、Bytes
、Double
、Float
、Integer
、Long
、Short
及String
类型的反序列化;
如果Kafka提供的反序列化器满足不了需求时,可以自定义实现反序列化器,推荐使用通用的序列化工具,如JSON
、ProtoBuf
或Protostuff
等,并且自定义的反序列化器需要与序列化器配套;
public class ProtoStuffDeserializer implements Deserializer {
@Override
public void configure(Map configs, boolean isKey) {
}
@Override
public Object deserialize(String topic, byte[] data) {
Schema schema;
String result="";
try {
schema = RuntimeSchema.createFrom(result.getClass());
ProtostuffIOUtil.mergeFrom(data, result, schema);
} catch (Exception e) {
throw new IllegalStateException(e);
}
return result;
}
@Override
public void close() {
}
}
key.deserializer
或value.deserializer
参数中指定使用的类;ProducerInterceptor
,消费者拦截器的接口是ConsumerInterceptor
/**
* 消费者拦截器接口 ConsumerInterceptor
*/
public interface ConsumerInterceptor extends Configurable {
public ConsumerRecords onConsume(ConsumerRecords records);
public void onCommit(Map offsets);
public void close();
}
onConsume()
方法:KafkaConsumer会在poll()
方法返回之前调用onConsume()
方法来对消息进行定制化操作,如果onConsume()
方法中抛出异常,那么会被捕获并记录到日志中,但是异常不会再向上传递;onCommit()
方法:KafkaConsumer会在提交完消费位移之后调用拦截器的onCommit()
方法;interceptor.classes
参数中配置,该参数的默认值为"",即默认不使用消费组拦截器;生产者KafkaProducer
是线程安全的,然而消费者KafkaConsumer
是非线程安全的,KafkaConsumer
中定义了一个acquire()
方法,用来检测当前是否只有一个线程在操作,若有其他线程正在操作则抛出ConcurrentModifcationException
异常;
KafkaConsumer中的每个公用方法(public
方法),在执行所要执行的动作之前都会调用这个acquire()
方法,只有wakeup()
方法是个例外;
acquire()
方法可通常所说的锁(synchronized
、Lock
等)不同,它不会造成阻塞等待,仅通过线程操作计数标记的方式来检测线程是否发生了并发操作,以此保证只有一个线程在操作;