目录
一、消费者和消费组
-
消费者:订阅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();
}
}
1. 订阅主题与分区
创建完消费者之后,就需要为该消费者订阅相关的主题了。一个消费者可以订阅一个或多个主题。
- subscribe方法可以以集合和正则表达式两种形式订阅:
public void subscribe(Collection<String> topics)
public void subscribe(Pattern pattern)
对应的取消订阅方法:unsubscribe
通过subscribe订阅主题,具有 消费者自动均衡 的概念:在多个消费者的情况下,可以根据分区策略自动分配各个消费者与分区的关系。
- 消费者不仅可以通过subscribe方法来订阅主题,还可以直接订阅某些主题的特定分区:
public void assign(Clooection<TopicPartition> partitions)
TopicPartition用来表示分区,有两个属性:topic和partition,分别表示分区所属的主题和自身的分区编号,这个类可以和「主题——分区」的概念映射起来。
使用如:
consumer.assign(Arrays.asList(new TopicPartition("topic-1",0)));
2. 消息消费
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]
- 重复消费
如果在消费完所有拉取到的消息之后才执行位移提交,当消费x+5时遇到了异常,在故障恢复之后,重新拉取消息是从x+2开始的。 - 消息丢失
如果拉取到消息之后就进行位移提交,即提交x+8。当消费x+5时遇到了异常,在故障恢复之后,重新拉取消息是从x+8开始的。
kafka默认的消费位移提交方式为自动提交:定期提交(默认5s)每次在poll中向服务端发起拉取请求之前,检查是否可以提交,如果可以,将提交上一次的位移。对于上述的消息重复和消息丢失问题,可以通过减小位移提交的时间间隔来调整。
另外一种提交方式是手动提交,可以使开发中对于消费位移的管理更加灵活。
4. 手动提交的两种方式
- 同步:先对每一条消息进行处理,然后对整个消息集做同步提交。
while (isRunning) {
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
// do something
}
consumer.commitSync();
}
只要没有发生不可恢复的错误,它就会阻塞消费者线程直至位移提交完成。
- 异步:在执行的时候消费者线程不会被阻塞,可能在提交消费位移的结果还没返回之前,就开始了新一次的拉取操作。commitAsync有三个重载方法:
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中。这种持久化,可以在消费者关闭、崩溃、或者再均衡时,接替的消费者根据存储的消费位移继续消费。
但是当一个新的消费组建立、消费组内的一个新消费者订阅了一个新的主题,都没有可查找的位移。此时有三种消费策略可供配置选择:
- 从分区末尾开始消费:auto.offset.reset = latest
- 从起始处开始消费:auto.offset.reset = 0
- 从特定位移处开始消费:seek方法
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:在poll方法返回之前。如修改消息返回的内容、按某种规则过滤消息
- onCommit:提交完消费位移之后。可以用来记录跟踪所提交的位移信息
在某些业务场景中,会对消息设置一个有效期,如果某条消息在既定的时间窗口内无法到达,那么就会被视为无效。我们可以通过在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);
}
这并不意味着消费消息只能以单线程的方式执行,多线程消费可以提高整体的消费能力。
1. 每个线程实例化一个KafkaConsumer对象
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连接。
2. 仅多线程处理消息
一般而言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连接对资源的消耗,缺点就是对于消息处理的顺序难以保证。