Kafka消费者多线程实现

Kafka的生产者KafkaProducer是线程安全的,然而消费者KafkaConsumer却是非线程安全的。KafkaConsumer定义了一个acquire()方法,用来检测当前是否只有一个线程在操作,若有其它线程正在操作会抛出ConcurrentModifycationException异常:

java.util.ConcurrentModifycationException: KafkaConsumer is not sage for multi-threaded access

KafkaConsumer中的每个公用方法在执行所要执行的动作之前都会调用这个acquire()方法。acquire()如下:

private final AtomicLong currentThread = new AtomicLong(NO_CURRENT_THREAD);//KafkaConsumer成员变量
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()方法与通常的锁(synchronized、Lock等)不同,它不会造成阻塞等待,可将其看成一个轻量级锁,它仅通过线程操作计数标记的方式来检测线程是否发生了并发操作,以此保证只有一个线程在操作。acquire()方法和release()方法成对出现,表示相应的加锁与解锁操作,release()方法如下:

private void release() {
    if (refcount.decrementAndGet() == 0)
        currentThread.set(NO_CURRENT_THREAD);
}

acquire()方法和release()方法都是私有方法,因此在实际应用中不需要显式地调用,但了解其内部原理有助于我们编写相应的逻辑程序。

KafkaConsumer非线程安全并不意味着在消费消息的时候只能以单线程的方式执行。如果生产者发送消息的速度大于消息者处理消息的速度,那么就会有越来越多的消息得不到及时的消费,造成了一定的延迟。除此之外,由于Kafka中消息保留机制的作用,有些消息有可能在被消费之前就被清理了,从而造成消息的丢失。通过多线程的方式就是为了提高整体的消费能力。

多线程的实现有多种,第一种也是最常见的方式:线程封闭,即为每个线程实例化一个KafkaConsumer对象

一个线程对应一个KafkaConsumer实例,可以称之为消费线程。一个消费线程可以消费一个或多个分区中的消息,所有的消费线程都隶属于同一个消费组,这种实现方式的并发度受限于分区的实际个数。

public class FirstConsumerThread {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupId = "group.demo";

    public static Properties initConfig() {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);

        properties.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        properties.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");
        return properties;
    }

    public static void main(String[] args) {
        int consumerThreadNum = 4;
        for (int i = 0; i < consumerThreadNum; i++) {
            new KafkaConsumerThread(initConfig(), topic).run();
        }
    }

    static 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(1000));
                    for (ConsumerRecord<String, String> record : records) {
                        //处理消息模块
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }
}

内部类KafkaConsumerThread代表消费线程,其内部包裹着一个独立的KafkaConsumer实例。通过外部类的main()方法来启动多个消费线程,消费线程的数量由consumerThreadNum变量指定。一般一个主题的分区数事先可以知晓,可以将consumerThreadNum设置成不大于分区数的值,如果不知道主题的分区数,那么也可以通过KafkaConsumer类的partitionsFor()方法来间接获取,进而再设置合理的进程数量。

上面这种多线程的实现方式和开启多个消费进程的方式没有本质上的区别,优点是每个线程可以按顺序消费各个分区的消息。缺点也很明显,每个消费线程都要维护一个独立的TCP连接,如果分区数和consumerThreadNum的值都很大,那么会造成系统比较大的开销。

针对上述代码中的“处理消息模块”,如果这里对消息的处理非常迅速,那poll()拉取的频次也会更高,进而整体消费的性能也会提升;相反,如果在这里对消息的处理缓慢,比如进行一个事务性的操作,或者等待一个RPC的同步响应,那么poll()拉取的频次也会随之下降,进而造成整体消费性能下降。一般而言,poll()拉取消息的速度是相当快的,而整体消费的瓶劲是在处理消息这一块,如果通过一定的方式来改进这一部分,那么就能带动整体消费的性能提升。那么 可以考虑如下实现方式,将处理消息模块改成多线程的实现:

public class SecondConsumerThread {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupId = "group.demo";

    public static Properties initConfig() {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true); //设置偏移量

        properties.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        return properties;
    }

    static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;
        private ExecutorService executorService;
        private int threadNumber;

        public KafkaConsumerThread(Properties properties, String topic, int threadNumber) {
            this.kafkaConsumer = new KafkaConsumer<String, String>(properties);
            this.kafkaConsumer.subscribe(Collections.singletonList(topic));
            this.threadNumber = threadNumber;
            executorService = new ThreadPoolExecutor(threadNumber, threadNumber, 0L, TimeUnit.MICROSECONDS,
                    new ArrayBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy());
        }

        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(1000));
                    executorService.submit(new RecordHandler(records));
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }

    static class RecordHandler extends Thread {
        public final ConsumerRecords<String, String> records;

        public RecordHandler(ConsumerRecords<String, String> records){
            this.records = records;
        }

        @Override
        public void run() {
            //处理消息
        }
    }
}

上述代码中RecordHandler类是用来处理消息的,而KafkaConsumerThread类对应的是一个消费线程,里面通过线程池的方法来调用RecordHandler处理一批批的消息。注意,ThreadPoolExecutor里的最一个参数设置的是CallerRunsPolicy(),这样可以防止线程池的总体消费能力跟不上poll()拉取的能力,从而导致异常现象的发生。

第二种实现方式相比第一种实现方式而言,除了横向扩展的能力,还可以减少TCP连接系统资源的消耗,不过缺点就是对于消息的顺序处理就比较困难了。在上述代码中,特意加了一个配置:

properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true); //设置偏移量

针对第二种实现方式,这里引入了一个共享变量offsets来参与提交。

每一个处理消息的RecordHandler类在处理完消息之后都将对应的消费位移保存到共享变量offsets中,KafkaConsumerThread在每一次poll()方法之后都读取offsets中的内容并对其进行位移提交。注意在实现的过程中对offsets读写需要加锁处理,防止出现并发问题;并且在写入offsets的时候需要注意位移覆盖的问题。针对这个问题,可以将RecordHandler类中的run()方法实现改为如下内容:

public class SecondConsumerThread {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupId = "group.demo";
    public static Map<TopicPartition, OffsetAndMetadata> offsets;

    public static Properties initConfig() {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);

        properties.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        return properties;
    }

    static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;
        private ExecutorService executorService;
        private int threadNumber;

        public KafkaConsumerThread(Properties properties, String topic, int threadNumber) {
            this.kafkaConsumer = new KafkaConsumer<String, String>(properties);
            this.kafkaConsumer.subscribe(Collections.singletonList(topic));
            this.threadNumber = threadNumber;
            executorService = new ThreadPoolExecutor(threadNumber, threadNumber, 0L, TimeUnit.MICROSECONDS,
                    new ArrayBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy());
        }

        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(1000));
                    if (!records.isEmpty()) {
                        executorService.submit(new RecordHandler(records));
                    }
                    synchronized (offsets) { //添加提交offset方法
                        if (!offsets.isEmpty()) {
                            kafkaConsumer.commitSync(offsets);
                            offsets.clear();
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }

    static class RecordHandler extends Thread {
        private final ConsumerRecords<String, String> records;


        public RecordHandler(ConsumerRecords<String, String> records){
            this.records = records;
        }

        @Override
        public void run() {
            //处理消息
            for (TopicPartition topicPartition : records.partitions()) {
                List<ConsumerRecord<String, String>> tpRecords = this.records.records(topicPartition);
                //处理tpRecords
                long lastConsumerOffset = tpRecords.get(tpRecords.size() - 1).offset();
                synchronized (offsets) {
                    if (!offsets.containsKey(topicPartition)) {
                        offsets.put(topicPartition, new OffsetAndMetadata(lastConsumerOffset + 1));
                    } else {
                        long position = offsets.get(topicPartition).offset();
                        if (position < lastConsumerOffset + 1) {
                            offsets.put(topicPartition, new OffsetAndMetadata(lastConsumerOffset + 1));
                        }
                    }
                }
            }
        }
    }
}

上述代码的多线程实现方式性能高效,且能最大限度地保证数据不丢失,但这种位移提交的方式还是会有数据丢失的风险。对于同一个分区中的信息,假设一个处理线程RecordHandler1正在处理offset为0~99的消息,而另一个处理线程RecordHandler2已经处理完成了offset为100~199的消息并进行了位移提交,此时如果RecordHandler1发生异常,则之后的消费只能从200开始而无法再次消费0~99的消息,从而造成了消息丢失的现象。这里虽然针对位移覆盖做了一定的处理,但还没有解决异常情况下的位移覆盖问题,对此就要引入更加复杂的处理机制。

这里再提供一种解决思路,如下图:

本方式是基于滑动窗口实现的,它所呈现的结构是通过消费者拉取分批次的消息,然后提交给多线程进行处理,而这里的滑动窗口式的实现方式是将拉取的消息暂存起来,多个消费线程可以拉取暂存的消息,这个用于暂存消息的缓存大小即为滑动窗口的大小,总体上而言没有太多的变化,不同的是对于消费位移的把控。

每一个方格代表一个年终奖的消息一个滑动的窗口包含若干方格,startOffset标注的是当前滑动窗口的起始位置,endOffset标注的是末尾位置。每当startOffset指向的方格中的消息被消费完成,就可以提交这部分的位移,与此同时,窗口向前滑动一格,删除原来startOffset所指方格中对应的消息并且拉取新的消息进入窗口。滑动窗口的大小固定,所以对应的用来暂存消息的缓存大小也就固定了,这部分内存开销可控。方格大小和滑动窗口的大小同时决定了消费线程的并发数:一个方格对应一个消费线程,对于窗口大小固定的情况,方格越小并行度越高;对于方格大小固定的情况,窗口越大并行度越高。不过,若窗口设置得过大,不仅会增大内存的开销,而且在发生异常的情况下也会引起大量的重复消费,同时还考虑线程切换的开销,建议根据实际情况设置一个合理的值,不管是对于方格还是窗口而言,过大或过小都不合适。

如果一个方格内的消息无法被标记消费完成,那么就会造成startOffset的悬停。为了使窗口能够继续向前滑动,那么就需要设定一个阈值,当startOffset悬停一定的时间就对这部分消息进行本地重试消费,如果重试失败就转入重试队列,如果还不奏效就转入死信队列。

 

  • 4
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值