✈️[Kafka Technology Topic] "Development Practical Chapter" In-depth practical exploration of the development and implementation of Kafka's producers and practical guidelines

kafka overview

definition

Kafka is a distributed publish/subscribe-based message queue (message queue), which is mainly used in the field of real-time processing of big data.

message queue

Traditional message queue & new message queue mode

The above is a traditional message queue. For example, if a user wants to register information, after the user information is written into the database, there are some other processes behind, such as sending a short message. You need to wait for these processes to be processed before returning to the user.

The new queue is, for example, a user registration information, the data is directly thrown into the database, and it is directly returned to the user success

Kafka's infrastructure

The infrastructure of Kafka mainly consists of brokers, producers, and consumer groups, and currently includes zookeeper

Producers are responsible for sending messages to partitions.

why partition

Kafka has the concept of topic (Topic), which is a logical container that carries real data, and is divided into several partitions under the topic, which means that Kafka's message organization method is actually a three-level structure: topic --- partition ---information. Each message under the topic will only be saved in a certain partition, and will not be saved in multiple partitions. The picture on the official website shows the three-level structure of kafka very clearly , as follows:

  • In fact, the role of partitioning is to provide load balancing capabilities, or the main reason for partitioning data is to achieve high scalability of the system .

  • Different partitions can be placed on different node machines, and the read and write operations of the database are all performed at the granularity of the partition, so that each node machine can independently execute the read and write request processing of its own partition.

  • Also, we can increase the overall system throughput by adding new nodes.

partition strategy

reason for partition

  1. It is convenient to expand in the cluster. Each Partition can be adjusted to adapt to the machine where it is located, and a topic can be composed of multiple Partitions, so the entire cluster can adapt to data of any size.

  2. Concurrency can be improved because it can be read and written in units of Partition.

Principles of Partitioning

We need to encapsulate the data sent by the producer into a ProducerRecord object.

public ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value) {
    this(topic, partition, timestamp, key, value, (Iterable)null);
}

public ProducerRecord(String topic, Integer partition, K key, V value, Iterable<Header> headers) {
    this(topic, partition, (Long)null, key, value, headers);
}

public ProducerRecord(String topic, Integer partition, K key, V value) {
    this(topic, partition, (Long)null, key, value, (Iterable)null);
}

public ProducerRecord(String topic, K key, V value) {
    this(topic, (Integer)null, (Long)null, key, value, (Iterable)null);
}

public ProducerRecord(String topic, V value) {
    this(topic, (Integer)null, (Long)null, (Object)null, value, (Iterable)null);
}
复制代码
  1. 指明partition的情况下,直接将指明的值直接作为partiton值。
  2. 没有指明 partition 值但有 key 的情况下,将 key 的 hash 值与 topic 的 partition数进行取余得到 partition 值。
  3. 既没有 partition 值又没有 key 值的情况下,第一次调用时随机生成一个整数(后面每次调用在这个整数上自增),将这个值与 topic 可用的 partition 总数取余得到 partition值,也就是常说的 round-robin 算法。

RoundRobinPartitioner(轮询策略)源码:

public class RoundRobinPartitioner implements Partitioner {

    private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new 
		ConcurrentHashMap();

    public RoundRobinPartitioner() {
    }

    public void configure(Map<String, ?> configs) {
    }

    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] 
					valueBytes, Cluster cluster) {
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        int nextValue = this.nextValue(topic);
        List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
        if (!availablePartitions.isEmpty()) {
            int part = Utils.toPositive(nextValue) % availablePartitions.size();
            return ((PartitionInfo)availablePartitions.get(part)).partition();
        } else {
            return Utils.toPositive(nextValue) % numPartitions;
        }
    }

    private int nextValue(String topic) {
        AtomicInteger counter = (AtomicInteger)this.topicCounterMap.computeIfAbsent(topic, (k) -> {
            return new AtomicInteger(0);
        });
        return counter.getAndIncrement();
    }

    public void close() {
    }
}
复制代码

数据可靠性保证

为保证 producer 发送的数据,能可靠的发送到指定的 topic,topic 的每个 partition 收到producer 发送的数据后,都需要向 producer 发送 ack(acknowledgement 确认收到),如果producer 收到 ack,就会进行下一轮的发送,否则重新发送数据。

副本数据同步策略

Kafka 选择了第二种方案,原因如下:

  1. 同样为了容忍 n 台节点的故障,第一种方案需要 2n+1个副本,而第二种方案只需要 n+1个副本,而 Kafka 的每个分区都有大量的数据,第一种方案会造成大量数据的冗余。

  2. 虽然第二种方案的网络延迟会比较高,但网络延迟对Kafka的影响较小。

ISR

  • 采用第二种方案之后,设想以下情景:leader收到数据,所有follower都开始同步数据,但有一个follower,因为某种故障,迟迟不能与 leader 进行同步,那 leader 就要一直等下去,直到它完成同步,才能发送ack。这个问题怎么解决呢?

  • Leader维护了一个动态的in-sync replica set (ISR),意为和 leader 保持同步的 follower 集合。当 ISR 中的 follower 完成数据的同步之后,leader 就会给 follower 发送 ack。

  • 如果follower长时间 未 向 leader 同 步 数 据 , 则 该 follower 将 被 踢 出 ISR , 该 时 间 阈值由replica.lag.time.max.ms参数设定。Leader 发生故障之后,就会从 ISR 中选举新的 leader。

ack应答机制

对于某些不太重要的数据,对数据的可靠性要求不是很高,能够容忍数据的少量丢失,所以没必要等ISR中的follower全部接收成功。

所以Kafka为用户提供了三种可靠性级别,用户根据对可靠性和延迟的要求进行权衡, 选择以下的配置。

  • acks = 0:生产者只负责发消息,不管Leader 和Follower 是否完成落盘就会发送ack 。这样能够最大降低延迟,但当Leader还未落盘时发生故障就会造成数据丢失。

  • acks = 1:Leader将数据落盘后,不管Follower 是否落盘就会发送ack 。这样可以保证Leader节点内有一份数据,但当Follower还未同步时Leader发生故障就会造成数据丢失。

  • acks = -1(all):生产者等待Leader 和ISR 集合内的所有Follower 都完成同步才会发送ack 。但当Follower同步完之后,broker发送ack之前,Leader发生故障时,此时会重新从ISR内选举一个新的Leader,此时由于生产者没收到ack,于是生产者会重新发消息给新的Leader,此时就会造成数据重复。

故障处理细节


  • LEO——Log End Offset:Producer 写入到 Kafka 中的最新一条数据的 offset。
  • HW——High Watermark::指的是消费者能见到的最大的 offset,ISR 队列中最小的 LEO

follower 故障

follower 发生故障后会被临时踢出 ISR,待该 follower 恢复后,follower会读取本地磁盘记录的上次的HW,并将log文件高于HW的部分截取掉,从HW开始向leader进行同步。等该 follower 的 LEO 大于等于该 Partition 的 HW,即 follower 追上 leader 之后,就可以重新加入 ISR 了。

leader 故障

leader 发生故障之后,会从 ISR 中选出一个新的 leader之后,为保证多个副本之间的数据一致性,其余的 follower会先将各自的log文件高于HW的部分截掉,然后从新的 leader同步数据。

注意:这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。

Exactly Once 语义

在分布式消息传递一致性语义上有下面三种:

  • At Least Once:消息不会丢,但可能会重复。
  • At Most Once:消息会丢,但不会重复。
  • Exactly Once:消息不会丢,也不会重复。

在kafka0.11版本之前是无法在kafka内实现exactly once 精确一次性保证的语义的,在0.11之后的版本,我们可以结合新、幂等性以及acks=-1 来实现kafka生产者的exactly once。

  • acks=-1在上面ack机制里已经介绍过,他能实现at least once语义,保证数据不会丢,但可能会有重复数据。

幂等性:0.11版本之后新增的特性,针对生产者,指生产者无论向broker发送多少次重复数据,broker 只会持久化一条;

在0.11版本之前要实现exactly once语义只能通过外部系统如hbase的rowkey实现基于主键的去重。

幂等性解读:

在生产者配置文件producer.properties 设置参数 enable.idompotence = true 即可启用幂等性。

Kafka的幂等性其实就是将原来需要在下游进行的去重操作放在了数据上游。

  • 开启幂等性的生产者在初始化时会被分配一个PID(producer ID),该生产者发往同一个分区(Partition)的消息会附带一个序列号(Sequence Number),Broker端会对<PID, Partition, SeqNumber>作为该消息的主键进行缓存,当有相同主键的消息提交时,Broker 只会持久化一条。

  • 但是生产者重启时PID就会发生变化,同时不同的分区(Partition)也具有不同的编号,所以生产者幂等性无法保证跨分区和跨会话的 Exactly Once

事务:kafka在0.11 版本引入了事务支持。事务可以保证 Kafka 在 Exactly Once 语义的基础上,生产和消费可以跨分区和会话,要么全部成功,要么全部失败。

生产者事务:

  • Kafka引入了一个新的组件Transaction Coordinator,它管理了一个全局唯一的事务ID(Transaction ID),并将生产者的PID和事务ID进行绑定,当生产者重启时虽然PID会变,但仍然可以和Transaction Coordinator交互,通过事务ID可以找回原来的PID,这样就保证了重启后的生产者也能保证Exactly Once了

  • 同时,Transaction Coordinator 将事务信息写入Kafka的一个内部Topic,即使整个kafka服务重启,由于事务状态已持久化到topic,进行中的事务状态也可以得到恢复,然后继续进行。

生产者客户端的基本架构图

由上图可以看出:KafkaProducer有两个基本线程:

  • 主线程:负责消息创建,拦截器,序列化器,分区器等操作,并将消息追加到消息收集器RecorderAccumulator中(这里可以看出拦截器确实在序列化和分区之前执行)。

  • 消息收集器RecoderAccumulator为每个分区都维护了一个 Deque<ProducerBatch> 类型的双端队列

ProducerBatch 可以暂时理解为是 ProducerRecord 的集合,批量发送有利于提升吞吐量,降低网络影响。


  • 由于生产者客户端使用 java.io.ByteBuffer 在发送消息之前进行消息保存,并维护了一个 BufferPool 实现 ByteBuffer 的复用

  • 该缓存池只针对特定大小( batch.size 指定)的 ByteBuffer进行管理,对于消息过大的缓存,不能做到重复利用

每次追加一条ProducerRecord消息,会寻找/新建对应的双端队列,从其尾部获取一个ProducerBatch,判断当前消息的大小是否可以写入该批次中。

若可以写入则写入;若不可以写入,则新建一个ProducerBatch,判断该消息大小是否超过客户端参数配置 batch.size 的值,

不超过,则以 batch.size建立新的ProducerBatch,这样方便进行缓存重复利用;

若超过,则以计算的消息大小建立对应的ProducerBatch ,缺点就是该内存不能被复用了。

Sender线程:

该线程从消息收集器获取缓存的消息,将其处理为 <Node, List 的形式, Node 表示集群的broker节点

进一步将<Node, List转化为<Node, Request>形式,此时才可以向服务端发送数据。

在发送之前,Sender线程将消息以 Map<NodeId, Deque> 的形式保存到 InFlightRequests 中进行缓存,可以通过其获取 leastLoadedNode ,即当前Node中负载压力最小的一个,以实现消息的尽快发出。

写入流程

producer 写入消息序列图如下所示:

流程说明:

  1. producer 先从 zookeeper 的 "/brokers/.../state" 节点找到该 partition 的 leader
  2. producer 将消息发送给该 leader
  3. leader 将消息写入本地 log
  4. followers 从 leader pull 消息,写入本地 log 后 leader 发送 ACK
  5. leader 收到所有 ISR 中的 replica 的 ACK 后增加 HW(high watermark,最后 commit的offset) 并向producer发送 ACK

Guess you like

Origin juejin.im/post/7193697959425835063