消费者:订阅kafka中的主题Topic,并且从订阅的主题上拉取消息。
消费组:每个消费者都有一个对应的消费组,当消息发布到主题后,只会被投递给订阅它的每个消费组中的一个消费者。
如图,某个Topic中有4个分区,有俩个消费组都订阅了这个主题。消费组A中有4个消费者,消费组B中有2个消费者。按照kafka的默认规则,消费组A中的每一个消费者都分配到一个分区,消费组B中每一个消费组分配到两个分区,两个消费组之间互不影响。
当某个消费组中消费组个数大于分区数时,就会有消费者分配不到任何分区。
点对点模式
如果所有的消费者都属于同一个消费组,那么所有的消息都会被均衡地投递给每一个消费者,即每条消息只会被一个消费者处理,这就相当于点对点模式的应用。
发布/订阅模式
如果所有的消费者都属于不同的消费组,那么所有的消息被广播给所有的消费者,即每条消息都会被所有的消费者处理,就相当于发布/订阅模式的应用。
一个正常的消费逻辑需要具备的步骤:
KafkaConsumer<String,String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
try{
while(isRunning.get()){
ConsumerRecords<String,String> records = consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecords<String,String> record : records){
System.out.println("topic =" + record.topic()
+ ",partition =" + record.partition()
+ ",offset =" + offset());
System.out.println("key =" + record.key()
+ ",value =" + value());
// do something...
}
} catch(Exception e){
log.err(e);
} finally{
consumer.close();
}
}
创建完消费者之后,就需要为该消费者订阅相关的主题了。一个消费者可以订阅一个或多个主题。
public void subscribe(Collection<String> topics)
public void subscribe(Pattern pattern)
对应的取消订阅方法:unsubscribe
通过subscribe订阅主题,具有 消费者自动均衡 的概念:在多个消费者的情况下,可以根据分区策略自动分配各个消费者与分区的关系。
public void assign(Clooection<TopicPartition> partitions)
TopicPartition用来表示分区,有两个属性:topic和partition,分别表示分区所属的主题和自身的分区编号,这个类可以和「主题——分区」的概念映射起来。
使用如:
consumer.assign(Arrays.asList(new TopicPartition("topic-1",0)));
kafka中的消息消费是基于拉模式,通过重复地调用 poll方法不断轮询 来实现。poll方法返回的是所订阅主题上的一组消息。
public ConsumerRecords<K,V> poll(final Duration timeout)
对于一次拉取获取到的所有消息,提供一个iterator方法来遍历:
public Iterator<ConsumerRecord<K,V>> iterator()
1. 消费位移
消费者在分区中消费到的位置。对于消费者而言,使用一个offset表示消费到分区中某个消息所在的位置。
2. 消费位移的提交
每次调用poll方法时,返回的是还没有被消费过的消息集,要做到这一点,就需要记录上一次消费的消费位移,并持久化保存。
这里把消费位移持久化的操作,被称为「提交」,消费者在消费完消息之后需要执行消费位移的提交。
3. 消息位移提交可能出现的问题
比如一次poll操作所拉取的消息集为[x+2,x+7]
kafka默认的消费位移提交方式为自动提交:定期提交(默认5s)每次在poll中向服务端发起拉取请求之前,检查是否可以提交,如果可以,将提交上一次的位移。对于上述的消息重复和消息丢失问题,可以通过减小位移提交的时间间隔来调整。
另外一种提交方式是手动提交,可以使开发中对于消费位移的管理更加灵活。
4. 手动提交的两种方式
while (isRunning) {
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
// do something
}
consumer.commitSync();
}
只要没有发生不可恢复的错误,它就会阻塞消费者线程直至位移提交完成。
public void commitAsync()
public void commitAsync(OffsetCommitCallback callback)
public void commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets,
OffsetCommitCallback callback)
使用callback来异步提交:
while (isRunning) {
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
// do something
}
consumer.commitAsync(new OffsetCommitCallback() {
public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
if (exception == null) {
System.out.println(offsets);
} else {
System.out.println("fail to commit offsets :" + offsets + ",exception :" + exception);
}
}
});
}
异步提交失败的情况,我们可以重试解决。
5. 无消息位移时的消费策略
旧的消费者客户端中,是存储在zookeeper中的;新的消费者客户端,存储在Kafka内部主题_consumer_offsets中。这种持久化,可以在消费者关闭、崩溃、或者再均衡时,接替的消费者根据存储的消费位移继续消费。
但是当一个新的消费组建立、消费组内的一个新消费者订阅了一个新的主题,都没有可查找的位移。此时有三种消费策略可供配置选择:
public void seek(TopicPartition partition, long offset)
但是更多情况下我们并不知道特定的消费位置,而是知道相关的时间点:如需要消费一天之内的数据。
KafkaConsumer提供了一个offsetsForTimes方法:
public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes
(Map<TopicPartition, Long> timestampsToSearch);
方法将会返回时间戳大于等于查询时间的第一条消息对应的位置和时间戳,参数timestampsToSearch的key:待查询的分区;value:时间戳。
// 本消费者分配到的分区集合
Set<TopicPartition> assignment = consumer.assignment();
// 构建查询参数
Map<TopicPartition, Long> timestampToSearch = new HashMap<TopicPartition, Long>();
for (TopicPartition tp : assignment) {
timestampToSearch.put(tp, System.currentTimeMillis() - 1 * 24 * 3600 * 1000);
}
// 查询offsets
Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(timestampToSearch);
// 每个分区分别处理
for (TopicPartition tp : assignment) {
OffsetAndTimestamp offsetAndTimestamp = offsets.get(tp);
if (offsetAndTimestamp != null) {
consumer.seek(tp, offsetAndTimestamp.offset());
}
}
另外,seek方法可以突破消费位移存储在内部主题的限制:可以将位移保存在任意存储介质中。
概念:
特点:
针对这一问题,在subscribe方法订阅主题时,可以使用带有 再均衡监听器 ConsumerRebalanceListener参数的subscribe方法:
subscribe(Collection<String> topics,ConsumerRebalanceListener listener)
再均衡监听器用来设定发生再均衡动作前后的一些准备或收尾工作。
包含的方法:
void onPartitionsRevoked(Collection<TopicPartition partitions)
调用时间:再均衡开始之前、消费者停止读取消息之后
void onPartitionsAssigned(Collection<TopicPartition partitions)
调用时间:重新分配分区之后、消费者开始读取消息之前
消费者拦截器主要在消费到消息或者在提交消息位移时进行一些定制化操作。
public ConsumerRecords<K,V> onConsume(ConsumerRecords<K,V> records);
public void onCommit(Map<TopicPartition,OffsetAndMetadata> offsets);
public void close();
在某些业务场景中,会对消息设置一个有效期,如果某条消息在既定的时间窗口内无法到达,那么就会被视为无效。我们可以通过在onConsume中过滤来实现。
Acquire方法:
kafkaConsumer是非线程安全的,其中定义了一个acquire方法,用来检测当前是否只有一个线程在操作。如果有其他线程正在操作,则抛出ConcurrentModificationException异常。kafka中的每个公共方法在执行所要执行的操作之前,都会调用这个acquire方法进行检查。
private void acquire() {
long threadId = Thread.currentThread().getId();
if (threadId != currentThread.get() && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId))
throw new ConcurrentModificationException("KafkaConsumer is not safe for multi-threaded access");
refcount.incrementAndGet();
}
与锁不同,acquire不会造成阻塞等待,我们可以将其看做轻量级锁,仅通过线程操作计数标记的方式来检测线程是否发生了并发。
与其相对的方法:
private void release() {
if (refcount.decrementAndGet() == 0)
currentThread.set(NO_CURRENT_THREAD);
}
这并不意味着消费消息只能以单线程的方式执行,多线程消费可以提高整体的消费能力。
class KafkaConsumerThread extends Thread {
private KafkaConsumer<String, String> kafkaConsumer;
public KafkaConsumerThread(Properties properties, String topic) {
this.kafkaConsumer = new KafkaConsumer<String, String>(properties);
this.kafkaConsumer.subscribe(Arrays.asList(topic));
}
@Override
public void run() {
try {
while (true) {
ConsumerRecords<String, String> records =
kafkaConsumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// do something ...
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
kafkaConsumer.close();
}
}
}
这个线程称之为消费线程,一个消费线程可以消费一个或多个分区中的消息。如果分区数<消费线程数,就会有部分线程一直处于空闲状态。
这种多消费线程的方式和多消费进程的方式没有本质区别,优点是每个线程可以按顺序消费各个分区中的消息。缺点是每个消费线程都需要维护一个独立的TCP连接。
一般而言poll拉取消息的速度是很快的,整体消费的瓶颈在于处理消息的逻辑。我们可以将消息处理模块改成多线程的实现。
class KafkaConsumerThread2 extends Thread {
private KafkaConsumer<String, String> kafkaConsumer;
private ExecutorService executorService;
private int threadNumber;
public KafkaConsumerThread2(Properties properties, String topic, int threadNumber) {
this.kafkaConsumer = new KafkaConsumer<String, String>(properties);
this.kafkaConsumer.subscribe(Arrays.asList(topic));
this.threadNumber = threadNumber;
executorService = new ThreadPoolExecutor(threadNumber, threadNumber, 0L,
TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy());
}
@Override
public void run() {
try {
while (true) {
ConsumerRecords<String, String> records =
kafkaConsumer.poll(Duration.ofMillis(100));
if (!records.isEmpty()) {
executorService.submit(new)
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
kafkaConsumer.close();
}
}
}
class RecordsHandler extends Thread {
public final ConsumerRecord<String, String> records;
public RecordsHandler(ConsumerRecord<String, String> records) {
this.records = records;
}
@Override
public void run(){
// do something
}
}
这种方式,可以减少TCP连接对资源的消耗,缺点就是对于消息处理的顺序难以保证。