14 消费者多线程实现

消费者多线程实现

KafkaProducer 是线程安全的,而 KafkaConsumer 却是非线程安全的。KafkaConsumer中定义了一个 acquire() 方法用来检查当前是否只有一个线程在操作,如果有其他线程在操作那么就会抛出 ConcurrentModifcationException 异常。KafkaConsumer 所有公用方法在执行之前都会调用 acquire() 方法,只有 wakeup() 方法例外,具体可以参考11节。acquire()具体实现如下:

private void acquire() {
		//当前线程id
        long threadId = Thread.currentThread().getId();
        // this.currentThread 是KafkaConusmer 当中的一个成员变量
        //如果当前线程id和缓存中不一致 且 替换失败 compareAndSet(如果期望值-参数1与内存值一样 那么将内存值修改为参数2 并且返回true)
        if (threadId != this.currentThread.get() && !this.currentThread.compareAndSet(-1L, threadId)) {
            throw new ConcurrentModificationException("KafkaConsumer is not safe for multi-threaded access");
        } else {
        	//Atomically increments by one the current value.
            this.refcount.incrementAndGet();
        }
    }

acquire() 和我们通常所说的锁(synchronized,Lock等)不同,它不会造成阻塞等待,我们可以将它看成一个轻量级的锁,它只会通过线程操作计数标记的方式去检查是否有并发操作,以此保证只有一个线程在操作。acquire()方法和 release() 方法成对出现,分别表示加锁和解锁,release() 具体定义如下:

private void release() {
    if (refcount.decrementAndGet() == 0)
        currentThread.set(NO_CURRENT_THREAD);
}
消费者多线程的实现方式

KafkaConsumer 非线程安全并不意味着消费的时候只能以单线程的方式进行。如果生产者发送消息的速度大于消费者处理消息的速度,那么就会有越来越多的消息不能及时消费,消费就会有一定的延迟。除此之外,由于Kafka中消息保留机制的作用,很可能造成有些消息还未被消费就被清理,从而造成消息丢失。我们可以通过多线程的方式来实现消息消费,多线程的目的就是为了提高整体消费能力。
第一种也是常见的一种:线程封闭,也就是为每个线程实例化一个 KafkaConsumer对象,如图。
在这里插入图片描述
一个线程对应一个 KafkaConsumer示例,称之为消费线程。一个消费线程可以消费一个或多个分区中的消息,所有的消费线程都属于一个消费组。这种实现方式的并发度取决于分区数量,根据之前介绍的消费者与分区数的关系,当消费线程的数量大于分区数的时候就会有部分消费线程一直处于空闲状态。

第二种:多个消费者同时消费同一个分区,这个通过 assign() --订阅特定主题的某个分区、seek() --指定位移消费 等方法实现,这样可以打破原有的消费线程数不能超过分区数的限制,进一步提高消费能力。不过这这种实现方式对于位移提交和顺序控制的处理会变的非常复杂,实际应用中使用的极少,并不推荐使用。一般而已,分区就是消费线程的最小划分单位。下面通过实际代码来演示第一种多线程消费实现的方式,代码清单14-1。

public class FirsdtMultiConsumerThread {
    public static final String brokerList ="";
    public static final String topic="topic-demo";
    public static final String groupId = "group.demo";

    public static Properties initConofig(){
        Properties props=new Properties();
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,StringDeserializer.class.getName());
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
        props.put(ConsumerConfig.GROUP_ID_CONFIG,groupId);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,true);
        return props;
    }

    public static void main(String[] args) {
        Properties properties=initConofig();
        for (int i =0;i<4;i++){
            new Thread(new KafkaConsumerThread(properties,topic)).start();
        }
    }

    public static class KafkaConsumerThread implements Runnable{

        private KafkaConsumer<String,String> kafkaConsumer;

        public KafkaConsumerThread(Properties props,String topic){
            kafkaConsumer=new KafkaConsumer<>(props);
            kafkaConsumer.subscribe(Arrays.asList(topic));
        }

        @Override
        public void run() {
            while (true){
                //拉消息
                ConsumerRecords<String,String> records=kafkaConsumer.poll(Duration.ofMillis(100));
                //循环输出
                for (ConsumerRecord record:records){
                    System.out.println(record.offset()+"======="+record.value());
                }
            }
        }
    }
}

上面代码中,内部类 KafkaConsumerThread 代表消费线程,其内部包含着一个独立的 KafkaConsumer 实例。通过外部类的main()方法启动多个线程,一般一个主题的线程数量可以知道,可以将线程数设置成和分区数一样,如果不知道分区数可以通过 KafkaConsumer 类的 partitionsFor() 方法间接获取。上面这种多线程的实现方式,优点是每个线程可以按顺序消费各个分区的消息。缺点也很明显,每个线程都要维护一个独立的TCP连接,如果分区数很大,那么对应的线程数量也要增加这样会造成不小的系统开销。

第三种 将处理消息模块改成多线程实现方式,代码如14-2
如果对消息的处理非常迅速,那么poll()拉取的频次也会同步提高,整体消费能力也会提示;相反,如果消息处理慢,比如进行一个事务性操作或者等待一个RPC的同步响应,那么poll()拉取的频次也会随之下降,那么整体消费能力就会下降。其实,poll()拉取是非常快的,消费速度的瓶颈就在于消费处理的速度。
在这里插入图片描述

/**
 * 多线程处理消息
 * Created By baipeng
 * 2020/1/8 10:32
 */
public class ThirdMultiConsumerThread {
    public static final String brokerList ="172.16.11.7:9092";
    public static final String topic="topic-demo";
    public static final String groupId = "group.demo";

    public static Properties initConofig(){
        Properties props=new Properties();
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,StringDeserializer.class.getName());
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
        props.put(ConsumerConfig.GROUP_ID_CONFIG,groupId);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,true);
        return props;
    }


    /**
     * consumer
     */
    public static class KafkaConsumerThread implements Runnable{

        private KafkaConsumer kafkaConsumer;
        private ExecutorService executorService;
        private int threadNumber;

        public KafkaConsumerThread(Properties properties,String topic,int threadNumber){
            // 创建消费者 订阅主题
            kafkaConsumer=new KafkaConsumer(properties);
            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() {

            while (true){
                //拉取消息 将消息放入消息处理线程
                ConsumerRecords<String,String> records = kafkaConsumer.poll(Duration.ofMillis(100));
                if (!records.isEmpty()){
                    executorService.submit(new RecordsHandler(records));
                }
            }
        }
    }

    /**
     * 消息处理
     */
    public static class RecordsHandler extends Thread{
        public final ConsumerRecords<String,String> records;

        public RecordsHandler(ConsumerRecords records){
            this.records=records;
        }

        @Override
        public void run(){
            //处理records.
            for (ConsumerRecord record:records){
                System.out.println(Thread.currentThread().getName());
                System.out.println("partition:"+record.partition()+";============offset:"+record.offset()+";=======value:"+record.value()+";");
            }
        }
    }

    public static void main(String[] args) {
        Properties properties=initConofig();
        new Thread(new KafkaConsumerThread(properties,topic, Runtime.getRuntime().availableProcessors())).start();

    }
}

代码清单14-2中 RecordHandler类是用来处理消息的,kafkaConosumerThread类对应的是一个消费线程,在这个线程里面通过线程池的方式来调用 RecordHandler 处理一批批的消息,类似下图。但是因为主题只有两个分区 拉取的时候拉取了两次然后消息处理的线程启动了两个。注意:KafkaConsumerThread 类中 ThreadPoolExecutor里的最后一个参数设置的是 CallerRunsPolicy(),这样可以防止线程池的总体消费能力跟不上poll()拉取的能力,从而导致异常发生。第三种实现方式还可以横向扩展,通过开启多个 KafkaConsumerThread 实例进一步提升整体的消费能力。
在这里插入图片描述第三种实现相比较第一点来说,除了增强横向扩展的能力,还可以减少TCP连接对系统资源的消耗,不过缺点就是对消息的顺序处理比较困难了。所以在initConfig()方法里面加入了配置,这样说明在处理消息的时候并没有考虑位移提交的情况。

props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,true);

对于第一种方法,如果要做具体的位移提交,那么直接在 KafkaConsumerThread run方法中提交即可。但是对于第三种方法来说,如果做具体的位移提交那么这里需要引入一个 offsets 来参与提交,如下图。
在这里插入图片描述
每一个处理消息的 recordHandler 类在处理完消息之后都将对应的消费位移保存到共享变量 offsets 中,KafkaConsumerThread 在每一次poll()方法之后都读取 offsets中的内容并对其进行位移提交。注意:注意 在实现过程中对 offsets 读写需要加锁处理,防止出现并发。并且在写入 offsets 的时候需要防止覆盖的问题,可以将 RecordHandler 类中的 run() 方法实现改为如下内容:

// 具体位移提交
        @Override
        public void run(){
            //根据分区循环
            for (TopicPartition tp:records.partitions()){
                //根据tp获取消息
                List<ConsumerRecord<String,String>> tpRecords= records.records(tp);
                //获取最大位移
                Long lastConsumerOffset = tpRecords.get(tpRecords.size()-1).offset();
                //给offsets加锁 防止并发
                synchronized (offsets){
                    //判断集合是否有这个分区的位移 保证唯一性
                    if (!offsets.containsKey(tp)){
                        offsets.put(tp,new OffsetAndMetadata(lastConsumerOffset+1));
                    }else {
                        //如果集合有这个分区 那么判断集合中的offset大还是lastConsumerOffset 大
                        long position = offsets.get(tp).offset();
                        if (position<lastConsumerOffset){
                            offsets.put(tp,new OffsetAndMetadata(lastConsumerOffset+1));
                        }
                    }
                }

            }
        }

对应的位移提交实现 在KafkaCousumerThread类的 拉取消息后实现

@Override
        public void run() {

            while (true){
                //拉取消息 将消息放入消息处理线程
                ConsumerRecords<String,String> records = kafkaConsumer.poll(Duration.ofMillis(100));
                if (!records.isEmpty()){
                    executorService.submit(new RecordsHandler(records));
                }
                //实现手动提交
                synchronized (offsets){
                    if (!offsets.isEmpty()){
                        kafkaConsumer.commitSync(offsets);
                        offsets.clear();
                    }
                }

            }
        }

我们可以细想下这样实现是否万无一失,其实这种方式会有数据丢失的风险。对于同一个分区中的消息,假设处理线程 RecordHandler1 正在处理 offset 为0-99的消息,而另一个处理线程 RecordHandler2 已经处理完了100-199的消息并且进行了位移提交,此时如果 RecrodHandler1 发生异常,则之后的消费只能从200开始而无法消费0-99的消息,从而导致消息丢失。
对此就要引入更加复杂的处理机制,参考下图,总体结构上是基于滑动窗口实现的。对于第三种方式而言,结构是通过消费者拉取消息,然后提交给多线程去处理消息。而这里的滑动窗口的实现方式是将拉取到的消息暂存起来,多个消费线程可以拉取暂存的消息,这个用于暂存消息的缓存大小即为滑动窗口大小,总体而言没有太多变化,不同的是对消费位移的把控。
在这里插入图片描述
如上图所示,每一个方格代表一个批次的消息,一个滑动窗口包含若干方格,startOffset 标注的是当前滑动窗口的起始位置,endOffset 标注的是末尾位置。每当 startOffset 指向的方格中的消息被消费完成,就可以提交这部分的位移,与此同时,窗口向前滑动一格,删除原来 startOffset 所指方格中对应的消息,并且拉取新的消息进入窗口。滑动窗口的大小固定,所对应的用来暂存消息的缓存大小也就固定了,这部分内存开销可控。

方格大小和滑动窗口的大小同时决定了消费线程的并发数:一个方格对应一个消费线程,对于窗口大小固定的情况,方格越小并行度越高;对于方格大小固定的情况,窗口越大并行度越高。不过,若窗口设置得过大,不仅会增大内存的开销,而且在发生异常(比如 Crash)的情况下也会引起大量的重复消费,同时还考虑线程切换的开销,建议根据实际情况设置一个合理的值,不管是对于方格还是窗口而言,过大或过小都不合适。

如果一个方格内的消息无法被标记为消费完成,那么就会造成 startOffset 的悬停。为了使窗口能够继续向前滑动,那么就需要设定一个阈值,当 startOffset 悬停一定的时间后就对这部分消息进行本地重试消费,如果重试失败就转入重试队列,如果还不奏效就转入死信队列。真实应用中无法消费的情况极少,一般是由业务代码的处理逻辑引起的,比如消息中的内容格式与业务处理的内容格式不符,无法对这条消息进行决断,这种情况可以通过优化代码逻辑或采取丢弃策略来避免。如果需要消息高度可靠,也可以将无法进行业务逻辑的消息(这类消息可以称为死信)存入磁盘、数据库或 Kafka,然后继续消费下一条消息以保证整体消费进度合理推进,之后可以通过一个额外的处理任务来分析死信进而找出异常的原因。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值