多线程消费方式

方式1:一个线程对应一个消费者
消费者数量不大于分区数,最好也能对等起来

方式2:多线程消费同一个分区
位移提交和顺序控制的处理非常复杂,不推荐

方式1:消费者<=分区数

int threadNum = 4;
for (int i = 0; i < threadNum; i++) {
	new KafkaConsumer(config,"tcp_link").start();
}
public KafkaConsumerThread(Properties config, String topic) {
	this.kafkaConsumer = new KafkaConsumer(config);
	this.kafkaConsumer.subscribe(Collections.singleton(topic));
}

每个线程可以顺序消费各个分区中的消息,但也有个问题,每个
这里有个问题,每个线程都要维护一个独立的TCP连接,如果分区数和线程数很大,系统开销是大大的!

Flink里也在用的模型

利用Kafka的topic分多个partition的机制来实现并行:每个线程都有自己的consumer实例,负责消费若干个partition。各个线程之间是完全独立的,不涉及任何线程同步和通信,像Flink里面用的就是这种模型。
先确定线程数,然后将分区数平均分给这些线程

final String topic = "test";
    int partitionNum = getPartitionNum(topic);
    final int threadNum = 4;
    int partitionNumPerThread = (partitionNum <= threadNum) ? 1 : partitionNum / threadNum + 1;

    ExecutorService threadPool = Executors.newFixedThreadPool(partitionNum);
    List<TopicPartition> topicPartitions = new ArrayList<>(partitionNumPerThread);
    for (int i = 0; i < partitionNum; i++) {
      topicPartitions.add(new TopicPartition(topic, i));
      if ((i + 1) % partitionNumPerThread == 0) {
        threadPool.submit(new Task(new ArrayList<>(topicPartitions), config));
        topicPartitions.clear();
      }
    }
    if (!topicPartitions.isEmpty()) {
      threadPool.submit(new Task(new ArrayList<>(topicPartitions), config));
    }

    threadPool.shutdown();
    threadPool.awaitTermination(Integer.MAX_VALUE, TimeUnit.SECONDS);
  }

static class Task implements Runnable {
    private final List<TopicPartition> topicPartitions;
    private final Properties config;

    public Task(List<TopicPartition> topicPartitions, Properties config) {
      this.topicPartitions = topicPartitions;
      this.config = config;
    }

    @Override
    public void run() {
      KafkaConsumer<String, String> consumer = new KafkaConsumer<>(config);
      consumer.assign(topicPartitions);
      try {
        while (true) {
          ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(10_000));
          for (ConsumerRecord<String, String> record : records) {
            System.out.println(record);
          }
        }
      } catch (Exception e) {
        e.printStackTrace();
      } finally {
        consumer.close();
      }
    }
  }

这种模型最大的优点是简单,但并非完美。在这种模型里面,获取数据、处理数据都是在一个线程里面的,如果在处理流程需要耗费很长时间,甚至是不可控的时候就会产生问题。

Kafka有一个参数 max.poll.interval.ms ,它表示的是两次poll调用的最大时间间隔。如果超过这个时间间隔,kafka就会认为这个consumer已经挂了,就会触发consumer group rebalance。 这个参数默认值是5分钟,也就是如果某次处理超过5分钟,那就可能导致rebalance。

Rebalance有什么问题呢?问题很大,对于Rebalance以后再介绍,这里先简单概括一下:
1、引发Consumer Group Rebalance操作主要有3种情况:(1)consumer数量发生变化(2)consumer订阅的主题发生变化(2)主题的分区数发生变化
2、Rebalance是一个比较重的操作,它需要consumer group的所有活着的consumer参与,当consumer比较多的时候,rebalance会很耗时(比如若干小时);而且在没有完成之前,大家都是干不了活的,体现在业务上就是停止处理了。
3、Rebalance其实就是重新划分partition,如果是自己提交offset的话,处理不好,就可能产生重复数据,后面再说。

总之,Rebalance操作能避免应该尽可能避免,特别是因为编码不合理产生的应该坚决改掉。
调整以下参数也能一定程度上解决问题:

max.poll.records
max.poll.interval.ms

如果这个过程中GES挂掉 也是解决不了的。

方式3:Multi-Thread Consumer Model

poll与处理消息模块拆分,处理消息模块用多线程

演示一下,这只是其中一种
流程为:poll线程专门负责拉取数据,然后将数据按partition分组,交给处理线程池,每个线程一次只处理一个分区的数据。数据交给处理线程后,poll继续拉取数据。现在有2个问题:
1、如何像Thread-Per-Consumer那样,保证一个分区里面的数据有序
2、如何提交offset

数据有序性
要保证partition内数据有序,只要避免多个线程并行处理同一个partition的数据即可。在poll线程给线程池分发数据的时候,已经按partition做了分组,也就是保证了一次拉取的数据中同一个partition的数据只会分配给一个线程。现在只要保证分区数据处理完成之前不再拉取该分区的数据,就可以保证数据的有序了。 KafkaConsumer 类提供了一个 pause 和 resume 方法,参数都是分区信息的集合:

public void pause(Collection<TopicPartition> partitions)
public void resume(Collection<TopicPartition> partitions)

调用 pause 方法后,后续 poll 的时候,这些被 pause 的分区不会再返回任何数据,直到被 resume 。所以,可以在本次partition的数据处理完成之前,pause该partition。

Offset提交
很显然,poll线程和处理线程解耦异步以后,就不能使用自动提交offset了,必须手动提交,否则可能offset提交了,但数据其实还没有处理。提交是poll线程做的,而offset的值则是处理线程才知道的,两者之前需要一个信息传递机制。代码实现的时候需要考虑。

这里插一个关于offset提交的话题。我们知道有2种方式提交offset:

自动提交 :将 enable.auto.commit 设置为 true 即可;每次poll的时候会自动提交上一次的offset。

手动提交 :分为同步提交 commitSync 和异步提交 commitAsync 。同步就是阻塞式提交,异步就是非阻塞式。而且二者都支持指定具体partition的offset,也就是可以精细化的自定义offset。

这里不展开介绍具体细节,只讨论一个问题:offset提交和消息传递语义(Message Delivery Semantics)的关系。Kafka提供了3种消息传递保证:

at most once :最多一次,即不会产生重复数据,但可能会丢数据

at least once :至少一次,即可能会产生重复数据,但不会丢数据

exactly once :准确的一次,不多也不少

首先 自动提交offset提供的是at least once的保证 ,因为他是在poll的时候提交上次数据的offset,也就是如果处理本次poll拉取的数据的时候异常了,导致没有执行到下次poll,那这次这些数据的offset就无法提交了,但数据可能已经处理了一部分了。要实现最多一次也很简单,每次poll完数据,先提交offset,提交成功之后再开始处理即可。而要实现exactly once很难,而且这里的exactly once其实仅指消费,但我们一般想要的是全系统数据链上的exactly once。这个问题比较复杂,要实现真正的端到端exactly once,可以去查查flink、dataflow的设计,需要有很多假设和权衡,这里就不展开了。

重点说明一种情况,考虑这样一个流程:poll数据,处理数据,手动同步提交offset,然后再poll。当然也可能会有变种,就是边处理数据,边提交offset,但实质一样。这种情况会不会产生数据重复?答案是:会。其实不管你用同步提交,还是异步提交,都可能产生数据重复。因为系统随时可能因为其它原因产生Rebalance。所以要真正避免重复数据,正确的方式应该是实现 ConsumerRebalanceListener 接口,监听Rebalance的发生。当监听到发生Rebalance导致当前consumer的partition发生变化时,同步提交offset。但即使这样,其实也不能严格避免,因为系统还可能在任意阶段挂掉。因此这种流程实际提供的也是at least once的保证。 所以这里想要表达的意思是,如果你的系统不能容忍有重复数据(在不丢数据的前提下),光靠kafka是做不到的,这里通过一些编码优化只能减少发生的概率,无法杜绝。终极方案应该是在应用层解决。 比如幂等设计、使用数据的时候去重等。

书上的代码

public class KafkaConsumerBase {
    public final Properties configs;
    public KafkaConsumerBase() {
        this.configs = buildConfig();
    }
    public Properties buildConfig() {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:8080");
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "group.demo");
        properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
        return properties;
    }
    public void Scheduled(String[] args) {
        List<String> topics = Arrays.asList("tcp_link", "agent");
        KafkaConsumerThread kafkaConsumerThread = new KafkaConsumerThread(this.configs,
                topics, Runtime.getRuntime().availableProcessors());
    }
    public static
    class KafkaConsumerThread extends Thread {
        private KafkaConsumer kafkaConsumer;
        private ThreadPoolExecutor executor;
        private Map<TopicPartition, OffsetAndMetadata> offsets;
        public KafkaConsumerThread(Properties configs, List<String> topics, int threadNum) {
            this.kafkaConsumer = new KafkaConsumer<>(configs);
            kafkaConsumer.subscribe(topics);
            executor = new ThreadPoolExecutor(threadNum, threadNum, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy());
        }
        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(10));
					Iterable<ConsumerRecord<String, String>> recordIterable = records.records("tcp_link");
                    recordIterable.forEach(record -> {
                        record.partition();
                        record.value();
                    });
                    if (!records.isEmpty()) {
                        executor.submit(new MetricParser(records, offsets));
                        synchronized (offsets) {
                            if (!offsets.isEmpty()) {
                                kafkaConsumer.commitSync();
                            }
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }
}

public class MetricParser extends Thread {
    public final ConsumerRecords<String, String> records;
    public Map<TopicPartition, OffsetAndMetadata> offsets;
    public MetricParser(ConsumerRecords<String, String> records, Map<TopicPartition, OffsetAndMetadata> offsets) {
        this.records = records;
        this.offsets = offsets;
    }
    @Override
    public void run() {
        for (TopicPartition tp : records.partitions()) {
            List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
            long lastOffset = tpRecords.get(tpRecords.size() - 1).offset();
            synchronized (offsets) {
                if (!offsets.containsKey(tp)) {
                    offsets.put(tp, new OffsetAndMetadata(lastOffset + 1));
                } else {
                    long currOffset = offsets.get(tp).offset();
                    if (currOffset < lastOffset + 1) {
                        offsets.put(tp, new OffsetAndMetadata(lastOffset + 1));
                    }
                }
            }
        }
    }
}

网友代码

@Override
public void run() {
    try {
        // 主流程
        while (!stopped.get()) {
            // 消费数据
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(10_000));
            // 按partition分组,分发数据给处理线程池
            handleFetchedRecords(records);
            // 检查正在处理的线程
            checkActiveTasks();
            // 提交offset
            commitOffsets();
        }

    } catch (WakeupException we) {
        if (!stopped.get()) {
            throw we;
        }
    } finally {
        consumer.close();
    }
}

@Slf4j
public class ProcessThread implements Runnable {
  private final List<ConsumerRecord<String, String>> records;
  private final AtomicLong currentOffset = new AtomicLong();
  private volatile boolean stopped = false;
  private volatile boolean started = false;
  private volatile boolean finished = false;
  private final CompletableFuture<Long> completion = new CompletableFuture<>();
  private final ReentrantLock startStopLock = new ReentrantLock();

  public ProcessThread(List<ConsumerRecord<String, String>> records) {
    this.records = records;
  }

  @Override
  public void run() {
    startStopLock.lock();
    try {
      if (stopped) {
        return;
      }
      started = true;
    } finally {
      startStopLock.unlock();
    }

    for (ConsumerRecord<String, String> record : records) {
      if (stopped) {
        break;
      }
      // process record here and make sure you catch all exceptions;
      currentOffset.set(record.offset() + 1);
    }
    finished = true;
    completion.complete(currentOffset.get());
  }

  public long getCurrentOffset() {
    return currentOffset.get();
  }

  public void stop() {
    startStopLock.lock();
    try {
      this.stopped = true;
      if (!started) {
        finished = true;
        completion.complete(currentOffset.get());
      }
    } finally {
      startStopLock.unlock();
    }
  }

  public long waitForCompletion() {
    try {
      return completion.get();
    } catch (InterruptedException | ExecutionException e) {
      return -1;
    }
  }

  public boolean isFinished() {
    return finished;
  }
}

private void handleFetchedRecords(ConsumerRecords<String, String> records) {
    if (records.count() > 0) {
        List<TopicPartition> partitionsToPause = new ArrayList<>();
        // 按partition分组
        records.partitions().forEach(partition -> {
            List<ConsumerRecord<String, String>> consumerRecords = records.records(partition);
            // 提交一个分区的数据给处理线程池
            ProcessThread processThread = new ProcessThread(consumerRecords);
            processThreadPool.submit(processThread);
            // 记录分区与处理线程的关系,方便后面查询处理状态
            activeTasks.put(partition, processThread);
        });
        // pause已经在处理的分区,避免同个分区的数据被多个线程同时消费,从而保证分区内数据有序处理
        consumer.pause(partitionsToPause);
    }
}

@Slf4j
public class ProcessThread implements Runnable {
  private final List<ConsumerRecord<String, String>> records;
  private final AtomicLong currentOffset = new AtomicLong();
  private volatile boolean stopped = false;
  private volatile boolean started = false;
  private volatile boolean finished = false;
  private final CompletableFuture<Long> completion = new CompletableFuture<>();
  private final ReentrantLock startStopLock = new ReentrantLock();

  public ProcessThread(List<ConsumerRecord<String, String>> records) {
    this.records = records;
  }

  @Override
  public void run() {
    startStopLock.lock();
    try {
      if (stopped) {
        return;
      }
      started = true;
    } finally {
      startStopLock.unlock();
    }

    for (ConsumerRecord<String, String> record : records) {
      if (stopped) {
        break;
      }
      // process record here and make sure you catch all exceptions;
      currentOffset.set(record.offset() + 1);
    }
    finished = true;
    completion.complete(currentOffset.get());
  }

  public long getCurrentOffset() {
    return currentOffset.get();
  }

  public void stop() {
    startStopLock.lock();
    try {
      this.stopped = true;
      if (!started) {
        finished = true;
        completion.complete(currentOffset.get());
      }
    } finally {
      startStopLock.unlock();
    }
  }

  public long waitForCompletion() {
    try {
      return completion.get();
    } catch (InterruptedException | ExecutionException e) {
      return -1;
    }
  }

  public boolean isFinished() {
    return finished;
  }
}

很多设计都是在实现“ 如何优雅停止处理线程 ”的情况,这里简单说明一下。不考虑进程停止这种情况的话,其实需要停止处理线程的一个主要原因就是可能发生了rebalance,这个分区给其它consumer了。这个时候当前consumer处理线程优雅的停止方式就是完成目前正在处理的那个record,然后提交offset,然后停止。注意是完成当前正在处理的record,而不是完成本次分给该线程的所有records,因为处理完所有的records可能需要的时间会比较长。前面在offset提交的时候已经提到过,手动提交offset的时候,要想处理好rebalance这种情况,需要实现 ConsumerRebalanceListener 接口。

@Slf4j
public class PollThread implements Runnable, ConsumerRebalanceListener {
  private final KafkaConsumer<String, String> consumer;
  private final Map<TopicPartition, ProcessThread> activeTasks = new HashMap<>();
  private final Map<TopicPartition, OffsetAndMetadata> offsetsToCommit = new HashMap<>();
    
  // 省略其它代码
    
   @Override
  public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
    // 1. stop all tasks handling records from revoked partitions
    Map<TopicPartition, ProcessThread> stoppedTask = new HashMap<>();
    for (TopicPartition partition : partitions) {
      ProcessThread processThread = activeTasks.remove(partition);
      if (processThread != null) {
        processThread.stop();
        stoppedTask.put(partition, processThread);
      }
    }

    // 2. wait for stopped task to complete processing of current record
    stoppedTask.forEach((partition, processThread) -> {
      long offset = processThread.waitForCompletion();
      if (offset > 0) {
        offsetsToCommit.put(partition, new OffsetAndMetadata(offset));
      }
    });

    // 3. collect offsets for revoked partitions
    Map<TopicPartition, OffsetAndMetadata> revokedPartitionOffsets = new HashMap<>();
    for (TopicPartition partition : partitions) {
      OffsetAndMetadata offsetAndMetadata = offsetsToCommit.remove(partition);
      if (offsetAndMetadata != null) {
        revokedPartitionOffsets.put(partition, offsetAndMetadata);
      }
    }

    // 4. commit offsets for revoked partitions
    try {
      consumer.commitSync(revokedPartitionOffsets);
    } catch (Exception e) {
      log.warn("Failed to commit offsets for revoked partitions!", e);
    }
  }

  @Override
  public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
    // 如果分区之前没有pause过,那执行resume就不会有什么效果
    consumer.resume(partitions);
  }   
}
  • 当检测到有分区分配给当前consumer的时候,就会尝试resume这个分区,因为这个分区可能之前被其它consumer pause过。
  • 当检测到有分区要被回收时,执行了下面几个操作:
    查看 activeTasks ,看是否有线程正在处理这些分区的数据,有的话调用 ProcessThread的 stop 方法将这些处理线程的 stopped 标志位设置成true。同时记录找到的这些线程。
    在 ProcessThread 的 run 方法里面每次循环处理record的时候都会检测上一步置位的这个 stopped 标志位,从而实现完成当前处理的record后就停止的逻辑。然后这一步就是等待这些线程处理结束,拿到处理的offset值,放到待提交offset队列 offsetsToCommit 里面。
    从待提交队列里面找到要被回收的分区的offset,放到 revokedPartitionOffsets 里面。
    提交 revokedPartitionOffsets 里面的offset。

当收到有某些partition要从当前consumer回收的消息的时候,就从处理线程里面找到正在处理这些分区的线程,然后通知它们处理完正在处理的那一个record之后就退出吧。当然退出的时候需要返回已处理数据的offset,然后PollThread线程提交这些分区的offset。

https://mp.weixin.qq.com/s/PnPPD1OkWmfG0kdjHte-Cg

总体而言,两种模型各有利弊,但如果Thread-Per-Consumer模型能满足需求的话,肯定是应该优先使用的,简单明了。毕竟在软件世界,在满足需求的前提下,系统越简单越好。如无必要,勿增实体。

posted on 2024-01-18 23:06  大元王保保  阅读(27)  评论(0编辑  收藏  举报