分布式消息通信之Kafka的实现原理

3,376 阅读9分钟

特别注意:本文出自某泡学院的笔记

消息中间件能做什么

消息中间件主要解决的就是分布式系统之间消息传递的问题,它能够屏蔽各种平台以及协议之间的特性,实现应用程序之间的协同。举个非常简单的例子,就拿一个电商平台的注册功能来简单分析下,用户注册这一个服务,不单单只是insert一条数据到数据库里面就完事了,还需要发送激活邮件、发送新人红包或者积分、发送营销短信等一系列操作。假如说这里面的每一个操作,都需要消耗1s,那么整个注册过程就需要耗时4s才能响应给用户。 image.png 但是我们从注册这个服务可以看到,每一个子操作都是相对独立的,同时,基于领域划分以后,发送激活邮件、发送营销短信、赠送积分及红包都属于不同的子域。所以我们可以对这些子操作进行来实现异步化执行,类似于多线程并行处理的概念。
如何实现异步化呢?用多线程能实现吗?多线程当然可以实现,只是,消息的持久化、消息的重发这些条件,多线程并不能满足。所以需要借助一些开源中间件来解决。而分布式消息队列就是一个非常好的解决办法,引入分布式消息队列以后,架构图就变成这样了(下图是异步消息队列的场景)。通过引入分布式队列,就能够大大提升程序的处理效率,并且还解决了各个模块之间的耦合问题。

Ø 这个是分布式消息队列的第一个解决场景【异步处理】 image.png 我们再来展开一种场景,通过分布式消息队列来实现流量整形,比如在电商平台的秒杀场景下,流量会非常大。通过消息队列的方式可以很好的缓解高流量的问题。 image.png Ø 用户提交过来的请求,先写入到消息队列。消息队列是有长度的,如果消息队列长度超过指定长度,直接抛弃
Ø 秒杀的具体核心处理业务,接收消息队列中消息进行处理,这里的消息处理能力取决于消费端本身的吞吐量

当然,消息中间件还有更多应用场景,比如在弱一致性事务模型中,可以采用分布式消息队列的实现最大能力通知方式来实现数据的最终一致性等等

Java中使用kafka进行通信

依赖

<dependency> 
     <groupId>org.apache.kafka</groupId>
     <artifactId>kafka-clients</artifactId> 
     <version>2.0.0</version> 
 </dependency>

发送端代码

public class Producer extends Thread {
        private final KafkaProducer<Integer, String> producer;
        private final String topic;

        public Producer(String topic) {
            Properties properties = new Properties();
            properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.13.102:9092,192 .168.13.103:9092,192.168.13.104:9092");
            properties.put(ProducerConfig.CLIENT_ID_CONFIG, "practice-producer");
            properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, IntegerSerializer.class.getName());
            消费端代码
            properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
            producer = new KafkaProducer<Integer, String>(properties);
            this.topic = topic;
        }

        @Override
        public void run() {
            int num = 0;
            while (num < 50) {
                String msg = "pratice test message:" + num;
                try {
                    producer.send(new ProducerRecord<Integer, String>(topic, msg)).get();
                    TimeUnit.SECONDS.sleep(2);
                    num++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (ExecutionException e) {
                    e.printStackTrace();
                }
            }
        }

        public static void main(String[] args) {
            new Producer("test").start();
        }
    }

消费端代码

public class Consumer extends Thread {
        private final KafkaConsumer<Integer, String> consumer;
        private final String topic;

        public Consumer(String topic) {
            Properties properties = new Properties();
            properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.13.102:9092,192 .168.13.103:9092,192.168.13.104:9092");
            properties.put(ConsumerConfig.GROUP_ID_CONFIG, "practice-consumer");
            properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");//设置 offset自动提交
            properties.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");// 自动提交间隔时间 
            properties.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
            properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.IntegerDeserializer");
            properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
            properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");//对于 当前groupid来说,消息的offset从最早的消息开始消费 
            consumer = new KafkaConsumer<>(properties);
            this.topic = topic;
        }

        @Override
        public void run() {
            while (true) {
                consumer.subscribe(Collections.singleton(this.topic));
                ConsumerRecords<Integer, String> records = consumer.poll(Duration.ofSeconds(1));
                records.forEach(record -> {
                    System.out.println(record.key() + " " + record.value() + " -> offset:" + record.offset());
                });
            }
        }

        public static void main(String[] args) {
            new Consumer("test").start();
        }
    }

异步发送

kafka对于消息的发送,可以支持同步和异步,前面演示的案例中,我们是基于同步发送消息。同步会需要阻塞,而异步不需要等待阻塞的过程。

从本质上来说,kafka都是采用异步的方式来发送消息到broker,但是kafka并不是每次发送消息都会直接发送到broker上,而是把消息放到了一个发送队列中,然后通过一个后台线程不断从队列取出消息进行发送,发送成功后会触发callback。kafka客户端会积累一定量的消息统一组装成一个批量消息发送出去,触发条件是前面提到batch.size和linger.ms

而同步发送的方法,无非就是通过future.get()来等待消息的发送返回结果,但是这种方法会严重影响消 息发送的性能。

public void run() {
        int num = 0;
        while (num < 50) {
            String msg = "pratice test message:" + num;
            try {
                producer.send(new ProducerRecord<>(topic, msg), new Callback() {
                    @Override
                    public void onCompletion(RecordMetadata recordMetadata, Exception e) {
                        System.out.println("callback: " + recordMetadata.offset() + "->" + recordMetadata.partition());
                    }
                });
                TimeUnit.SECONDS.sleep(2);
                num++;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

batch.size

生产者发送多个消息到broker上的同一个分区时,为了减少网络请求带来的性能开销,通过批量的方式来提交消息,可以通过这个参数来控制批量提交的字节数大小,默认大小是16384byte,也就是16kb,意味着当一批消息大小达到指定的batch.size的时候会统一发送。

linger.ms

Producer默认会把两次发送时间间隔内收集到的所有Requests进行一次聚合然后再发送,以此提高吞吐量,而linger.ms就是为每次发送到broker的请求增加一些delay,以此来聚合更多的Message请求。这个有点想TCP里面的Nagle算法,在TCP协议的传输中,为了减少大量小数据包的发送,采用了Nagle算法,也就是基于小包的等-停协议。

batch.size和linger.ms这两个参数是kafka性能优化的关键参数,很多同学会发现batch.size和 linger.ms这两者的作用是一样的,如果两个都配置了,那么怎么工作的呢?实际上,当二者都配 置的时候,只要满足其中一个要求,就会发送请求到broker上

一些基础配置分析

group.id

consumer group是kafka提供的可扩展且具有容错性的消费者机制。既然是一个组,那么组内必然可以有多个消费者或消费者实例(consumer instance),它们共享一个公共的ID,即group ID。组内的所有消费者协调在一起来消费订阅主题(subscribed topics)的所有分区(partition)。当然,每个分区只能由同一个消费组内的一个consumer来消费.如下图所示,分别有三个消费者,属于两个不同的group,那么对于firstTopic这个topic来说,这两个组的消费者都能同时消费这个topic中的消息,对于此事的架构来说,这个firstTopic就类似于ActiveMQ中的topic概念。如右图所示,如果3个消费者都属于同一个group,那么此事firstTopic就是一个Queue的概念。 image.png image.png

enable.auto.commit

消费者消费消息以后自动提交,只有当消息提交以后,该消息才不会被再次接收到,还可以配合auto.commit.interval.ms控制自动提交的频率。 当然,我们也可以通过consumer.commitSync()的方式实现手动提交。

auto.offset.reset

这个参数是针对新的groupid中的消费者而言的,当有新groupid的消费者来消费指定的topic时,对于该参数的配置,会有不同的语义
auto.offset.reset=latest情况下,新的消费者将会从其他消费者最后消费的offset处开始消费Topic下的 消息
auto.offset.reset= earliest情况下,新的消费者会从该topic最早的消息开始消费
auto.offset.reset=none情况下,新的消费者加入以后,由于之前不存在offset,则会直接抛出异常。

max.poll.records

此设置限制每次调用poll返回的消息数,这样可以更容易的预测每次poll间隔要处理的最大值。通过调整此值,可以减少poll间隔。

Springboot+kafka

springboot的版本和kafka的版本,有一个对照表格,如果没有按照正确的版本来引入,那么会存在版本问题导致ClassNotFound的问题,具体请参考 spring.io/projects/sp…

jar包依赖

<dependency> 
    <groupId>org.springframework.kafka</groupId> 
    <artifactId>spring-kafka</artifactId> 
    <version>2.2.0.RELEASE</version> 
</dependency>

KafkaProducer

@Component
public class KafkaProducer {
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    public void send() {
        kafkaTemplate.send("test", "msgKey", "msgData");
    }
}

KafkaConsumer

@Component
public class KafkaConsumer {
    @KafkaListener(topics = {"test"})
    public void listener(ConsumerRecord record) {
        Optional<?> msg = Optional.ofNullable(record.value());
        if (msg.isPresent()) {
            System.out.println(msg.get());
        }
    }
}

application配置

spring.kafka.bootstrap-servers=192.168.13.102:9092,192.168.13.103:9092,192.168.13.104:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer 
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer 

spring.kafka.consumer.group-id=test-consumer-group 
spring.kafka.consumer.auto-offset-reset=earliest 
spring.kafka.consumer.enable-auto-commit=true 

spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer 
spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer

测试

public static void main(String[] args) {
        ConfigurableApplicationContext context = SpringApplication.run(KafkaDemoApplication.class, args);
        KafkaProducer kafkaProducer = context.getBean(KafkaProducer.class);
        for (int i = 0; i < 3; i++) {
            kafkaProducer.send();
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

原理分析

从前面的整个演示过程来看,只要不是超大规模的使用kafka,那么基本上没什么大问题,否则,对于kafka本身的运维的挑战会很大,同时,针对每一个参数的调优也显得很重要。 据了解,快手在使用kafka集群规模是挺大的,他们在19年的开发者大会上有提到, 总机器数大概 2000 台;30 多集群;topic 12000 个;一共大概 20 万 TP(topic partition);每天总处理的消息 数超过 4 万亿条;峰值超过1 亿条。文章出处

技术的使用是最简单的,要想掌握核心价值,就势必要了解一些原理,在设计这个课程的时候,我想了很久应该从哪个地方着手,最后还是选择从最基础的消息通讯的原理着手。

关于Topic和Partition

Topic

在kafka中,topic是一个存储消息的逻辑概念,可以认为是一个消息集合。每条消息发送到kafka集群的消息都有一个类别。物理上来说,不同的topic的消息是分开存储的,每个topic可以有多个生产者向它发送消息,也可以有多个消费者去消费其中的消息。 image.png

Partition

每个topic可以划分多个分区(每个Topic至少有一个分区),同一topic下的不同分区包含的消息是不同的。每个消息在被添加到分区时,都会被分配一个offset(称之为偏移量),它是消息在此分区中的唯一编号,kafka通过offset保证消息在分区内的顺序,offset的顺序不跨分区,即kafka只保证在同一个分区内的消息是有序的。

下图中,对于名字为test的topic,做了3个分区,分别是p0、p1、p2。
Ø 每一条消息发送到broker时,会根据partition的规则选择存储到哪一个partition。如果partition规则设置合理,那么所有的消息会均匀的分布在不同的partition中,这样就有点类似数据库的分库分表的概念,把数据做了分片处理。 image.png

Topic&Partition的存储

Partition是以文件的形式存储在文件系统中,比如创建一个名为firstTopic的topic,其中有3个partition,那么在kafka的数据目录(/tmp/kafka-log)中就有3个目录,firstTopic-0~3, 命名规则是 <topic_name>-<partition_id> sh kafka-topics.sh --create --zookeeper 192.168.11.156:2181 --replication-factor 1 --partitions 3 --topic firstTopic

关于消息分发

kafka消息分发策略

消息是kafka中最基本的数据单元,在kafka中,一条消息由key、value两部分构成,在发送一条消息时,我们可以指定这个key,那么producer会根据key和partition机制来判断当前这条消息应该发送并存储到哪个partition中。我们可以根据需要进行扩展producer的partition机制。

代码演示

自定义Partitioner

public class MyPartitioner implements Partitioner {
        private Random random = new Random();

        @Overridepublic
        int partition(String s, Object o, byte[] bytes, Object o1, byte[] bytes1, Cluster cluster) {
            //获取集群中指定topic的所有分区信息 
            List<PartitionInfo> partitionInfos = cluster.partitionsForTopic(s);
            int numOfPartition = partitionInfos.size();
            int partitionNum = 0;
            if (o == null) {
                //key没有设置 
                partitionNum = random.nextInt(numOfPartition); //随机指定分区 
            } else {
                partitionNum = Math.abs((o1.hashCode())) % numOfPartition;
            }
            System.out.println("key->" + o + ",value->" + o1 + "->send to partition:" + partitionNum);
            return partitionNum;
        }
}

发送端代码添加自定义分区

public KafkaProducerDemo(String topic, boolean isAysnc) {
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.13.102:9092,192.168.13.103:9092,192.168.13.104:9092");
        properties.put(ProducerConfig.CLIENT_ID_CONFIG, "KafkaProducerDemo");
        properties.put(ProducerConfig.ACKS_CONFIG, "-1");
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.IntegerSerializer");
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
        properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, "com.gupaoedu.kafka.MyPa rtitioner");
        producer = new KafkaProducer<Integer, String>(properties);
        this.topic = topic;
        this.isAysnc = isAysnc;
    }

消息默认的分发机制

默认情况下,kafka采用的是hash取模的分区算法。如果Key为null,则会随机分配一个分区。这个随机是在这个参数”metadata.max.age.ms”的时间范围内随机选择一个。对于这个时间段内,如果key为null,则只会发送到唯一的分区。这个值值哦默认情况下是10分钟更新一次。

关于Metadata,这个之前没讲过,简单理解就是Topic/Partition和broker的映射关系,每一个topic的每一个partition,需要知道对应的broker列表是什么,leader是谁、follower是谁。这些信息都是存储在Metadata这个类里面。

消费端如何消费指定的分区

通过下面的代码,就可以消费指定该topic下的0号分区。其他分区的数据就无法接收

//消费指定分区的时候,不需要再订阅 
// kafkaConsumer.subscribe(Collections.singletonList(topic)); 
// 消费指定的分区 
TopicPartition topicPartition=new TopicPartition(topic,0); 
kafkaConsumer.assign(Arrays.asList(topicPartition));

消息的消费原理

kafka消息消费原理演示

在实际生产过程中,每个topic都会有多个partitions,多个partitions的好处在于,一方面能够对broker上的数据进行分片有效减少了消息的容量从而提升io性能。另外一方面,为了提高消费端的消费能力,一般会通过多个consumer去消费同一个topic ,也就是消费端的负载均衡机制,也就是我们接下来要了解的,在多个partition以及多个consumer的情况下,消费者是如何消费消息的

kafka存在consumer group的概念,也就是group.id一样的consumer,这些consumer属于一个consumer group,组内的所有消费者协调在一起来消费订阅主题的所有分区。当然每一个分区只能由同一个消费组内的consumer来消费,那么同一个consumer group里面的consumer是怎么去分配该消费哪个分区里的数据的呢?如下图所示,3个分区,3个消费者,那么哪个消费者消分哪个分区? image.png 对于上面这个图来说,这3个消费者会分别消费test这个topic 的3个分区,也就是每个consumer消费一个partition。