Kafka Producer 客户端原理剖析

1,532 阅读14分钟

一、核心组件

1.1 Partitioner

后面用来决定,你发送的每条消息是路由到Topic的哪个分区里去的

1.2 Metadata

这个是对于生产端来说非常核心的一个组件,他是用来从broker集群去拉取元数据的Topics(Topic -> Partitions(Leader+Followers,ISR)),后面如果写消息到Topic,才知道这个Topic有哪些Partitions,Partition Leader所在的Broker

后面肯定会每隔一小段时间就再次发送请求刷新元数据,metadata.max.age.ms,默认是5分钟,默认每隔5分钟一定会强制刷新一下

还有就是我们猜测,在发送消息的时候,如果发现你要写入的某个Topic对应的元数据不在本地,那么他是不是肯定会通过这个组件,发送请求到broker尝试拉取这个topic对应的元数据,如果你在集群里增加了一台broker,也会涉及到元数据的变化

1.3 RecordAccumulator

缓冲区,负责消息的复杂的缓冲机制,发送到每个分区的消息会被打包成batch,一个broker上的多个分区对应的多个batch会被打包成一个request,batch size(16kb)

默认情况下,如果光光是考虑batch的机制的话,那么必须要等到足够多的消息打包成一个batch,才能通过request发送到broker上去;但是有一个问题,如果你发送了一条消息,但是等了很久都没有达到一个batch大小

所以说要设置一个linger.ms,如果在指定时间范围内,都没凑出来一个batch把这条消息发送出去,那么到了这个linger.ms指定的时间,比如说5ms,如果5ms还没凑出来一个batch,那么就必须立即把这个消息发送出去

1.4 网络通信的组件

NetworkClient,一个网络连接最多空闲多长时间(9分钟),每个连接最多有几个request没收到响应(5个),重试连接的时间间隔(50ms),Socket发送缓冲区大小(128kb),Socket接收缓冲区大小(32kb)

1.5 Sender线程

负责从缓冲区里获取消息发送到broker上去,request最大大小(1mb),acks(1,只要leader写入成功就认为成功),重试次数(0,无重试),请求超时的时间(30s),线程类叫做“KafkaThread”,线程名字叫做“kafka-producer-network-thread”,此处线程直接被启动

1.6 序列化组件,拦截器组件

1.7 核心参数

每个请求的最大大小(1mb),缓冲区的内存大小(32mb),重试时间间隔(100ms),缓冲区填满之后的阻塞时间(60s),请求超时时间(30s)

1.8 核心行为

初始化的时候,直接调用Metadata组件的方法,去broker上拉取了一次集群的元数据过来,后面每隔5分钟会默认刷新一次集群元数据,但是在发送消息的时候,如果没找到某个Topic的元数据,一定也会主动去拉取一次的

二、Metadata

这个的话, 主要就是靠 Producer 在初始化的时候, 构建的 KafkaClient(通信组件), Sender(发送组件), 然后有 KafkaThread 作为一层壳子, 包装 Sender 组件.

然后阻塞进行元数据的获取, 每次会更新一个 version 值, 和 needUpdate 标记位.

04_KafkaProducer源码分析 (1).jpg

三、Partition 分区

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();
        if (keyBytes == null) {
            int nextValue = nextValue(topic);
            List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
            if (availablePartitions.size() > 0) {
                int part = Utils.toPositive(nextValue) % availablePartitions.size();
                return availablePartitions.get(part).partition();
            } else {
                // no partitions are available, give a non-available partition
                return Utils.toPositive(nextValue) % numPartitions;
            }
        } else {
            // hash the keyBytes to choose a partition
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }

3.1 无 key 值分区

首先会随机出来一个随机值, 对这个随机值进行递增, 然后获取到这个 Topic 的可用分区, 通过工具类保证 递增后的随机值为一个正整数 , 然后和 可用分区长度 进行取模, 算出来的结果, 就是要发往的分区.

* *

3.2 有 key 值分区

直接通过工具类将 Key 值转换为一个 int 类型的整数, 然后和 Topic 的分区数量取模, 算出来的结果就是这条消息要发往的分区.

四、RecordAccumulator

消息进行完序列化和大小校验之后, 会被放到 Accumulator 这样的一个队列中, 核心的数据类型就是 ConcurrentMap<TopicPartition, Deque> batch

它这个数据结构的话是 Kafka 自己封装的一个 CopyOnWriteMap , 适合的是读多写少的场景, 每次更新的时候, 都是 copy 一个副本, 在副本里来更新, 接着更新整个副本, 好处就在于说写和读的操作互相之间不会有长时间的锁互斥, 写的时候不会阻塞读, 坏处在于说对内存的占用是很大的, 适合的是读多写少的场景, 大量读的场景就直接基于快照副本来进行读取的, CoypOnWriteMap 也是类似的思路, 一个分区创建一个 Deque, 其实是频次很低的写行为.

首先进行递增, 表示当前在执行追加的线程个数, 然后根据之前计算出来的 topic 的分区信息, 找到这个分区所对应的 Deque 队列, 如果存在则直接返回, 若不存在, 就进行创建, 对 Deque 队列进行加锁, 尝试进行 append , 因为是第一次, 所以获取到的队列中的最后一个元素为空, 所以追加操作是失败的.

             // check if we have an in-progress batch
            Deque<ProducerBatch> dq = getOrCreateDeque(tp);
            synchronized (dq) {
                if (closed)
                    throw new KafkaException("Producer closed while send in progress");
                RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callback, dq);
                if (appendResult != null)
                    return appendResult;
            }

在继续往后,他会基于BufferPool给这个batch分配一块内存出来,之所以说是Pool,就是因为这个batch代表的内存空间是可以复用的,用完一块内存之后会放回去下次给别人来使用,复用内存,避免了频繁的使用内存,丢弃对象,垃圾回收。

再次尝试进行追加, 直接获取到 Deque 中最后一个元素, 在最后进行元素追加, 后续会将数据封装为 MemoryRecordsBuilder, 然后在封装为 ProducerBatch, 然后加入到 Deque 队列当中, 将这个 batch 加入到正在执行的集合当中, 最后每次都会进行一次递减, 表示执行追加的线程 -1 .

当第一次获取 Deque 队列中最近的一个 Batch 为空的时候,会继续往下走,有一个 free.allocate 用来分配 buffer 空间,这个的话,一个 batch 默认的空间是 16kb ,如果说在构建内存空间的时候,消息的大小大于默认的 16kb 的话,就会用消息的大小进行构建,然后更新 可用内存空间为 默认的32MB - 申请的空间

这里构建完毕之后, 下面还会有一个尝试追加 batch 的逻辑, 这里为什么要进行两次尝试, 主要就是因为可能别的线程已经将数据加入到 Deque 里面了, 这时其余线程就会将自身申请到的 16 kb 的内存大小, 返还给 BufferPool , 做到一个内存空间的复用.

也就是说, 每次在获取 Batch 的时候, 首先会看 Buffer Pool 中是否有空闲的, 如果有的话, 会直接复用, 当 Batch 写满了之后, 会重新在 Buffer 里面申请, 当追加完数据之后再将空间放回池子中, 如果可用空间全部都申请完毕的话, 这时会进行一个阻塞, 默认最长为 60 秒, 如果 60 秒之后没有腾出来可用空间的话, 这时候会抛出异常.

************未命名文件.png**************

五、发送

5.1 简单流程

(1) 获取到已经可以执行发送的 Partition 的 Batch , 会将其封装为 Set, 意为可以发送的 Partition 的 Partition Leader 所在的机器, 另外会将查不到 leader 的 topic 也加入到一个集合当中, 并更新需要更新元数据的标记为, 让其去更新元数据信息.

(2) 遍历已经准备好要发送的 Node 节点, 检查是否已经建立好了连接, 如果没有建立的话, 建立 TCP 长连接.

(3) 有很多 Partiton 可以发送数据, 有一些 Partition Leader 是在同一个 Broker 上, 此时按照 Broker 对 Partition 进行分组, 找到一个 Broker 对应的多个 Partition 的 Batch, 如果一个 batch 已经在内存缓冲里停留超过 60s, 超时不要了

(4) 对每个 Broker 都会创建一个 ClientRequest, 这个 ClientRequest 里包涵多个 Batch 的数据, leader 在同一个 Broker 上的都在这里, 通过这一条请求, 全部发送过去.

(5) 通过 NetWorkClient 底层网络通信, 将 ClientRequest 发送出去即可, poll 方法, 他是负责实际的 进行网络 IO 通信操作的一个核心的方法, 负责发送数据出去, 也包括读取响应回来

5.2 判断哪些 Batch 可以发送

long waitedTimeMs = batch.waitedTimeMs(nowMs);
boolean backingOff = batch.attempts() > 0 && waitedTimeMs < retryBackoffMs;
long timeToWaitMs = backingOff ? retryBackoffMs : lingerMs;
boolean full = deque.size() > 1 || batch.isFull();
boolean expired = waitedTimeMs >= timeToWaitMs;
boolean sendable = full || expired || exhausted || closed || flushInProgress();
if (sendable && !backingOff) {
     readyNodes.add(leader);
} else {
     long timeLeftMs = Math.max(timeToWaitMs - waitedTimeMs, 0);
     nextReadyCheckDelayMs = Math.min(timeLeftMs, nextReadyCheckDelayMs);
}
  • exhausted : 内存空间满了,有在排序等待获取空间的线程;

  • waitedTimeMs : 现在的时间 - Batch 创建时的时间;

  • backingOff : 判断是否进入了重试逻辑且等待的时间是否小于重试间隔;

  • timeToWaitMs : 需要重试就返回重试间隔,不需要重试就返回 lingerMs(设置好的Batch 等待时间);

  • full : deque长度是否大于1(大于1表示已经有一个 Batch 满了),或者当前 Batch 是否已经满了;

  • expired : 等待时间 > 需要等待的时间,就代表过期了,过期就需要发送;

  • sendable : 是否可以发送(满了 | 过期 | 内存满了 | 关闭 | 需要强制刷新)

    最后判断的逻辑就是, 可以发送并且不需要重试, 这时会将 partition leader 所在的 broker 加入到代表可以发送的 Set 集合中. 如果不可以发送的话, 会计算出来一个下一次过来检查 Batch 是否可以发送的时间.

这里要注意的一点就是, 每个 Partition 会有一个 Deque , 每个 Deque 里会有多个 Batch, 上述算法在判断 Batch 是否可以发送的时候, 实际上每次都是取出 Deque 中第一个 Batch 进行判断

5.3 判断哪些 Broker 可以发送

对上面封装好的可以发送的 Batch 进行遍历, 检查 Broker 是否可以进行发送.

    • Broker 不处于更新和要更新元数据的过程 (一部分)
    • 已经和这个 Broker 建立好了链接 (另一部分)
    • 已经通过 selector 和这个 Broker 建立好了 channel
    • 已经发送出去但是还没收到相应的请求 < 设置的值。

    如果第二部分检查和 Broker 连接发现并没有连接的话, 会进行检查是否可以进行连接:

    • Broker 的连接状态为 null ,表示这个 Broker 从未建立过连接,如果是,则直接返回 true,表示可以进行创建连接
    • Broker 当前的连接状态为断开状态且当前时间 - 上一次连接的时间已经大于了设置的重试连接时间

    可以创建连接的话, 就会设置 Node 的状态为 CONNECTING, 然后通过 selector 组件和 Broker 创建连接

5.4 网络请求组件

在初始化 Producer 的时候, 初始化了 KafkaNetWorkClient , 主要就是负责进行网络通信, 它主要就是通过 java 的 nio 进行封装. 也是就是说 , NewWorkClient 里面其实主要就是 selector 组件, 通过 KafkaChannel 封装 SocketChannel, 每个 Broker.Id 都对应一个 KafkaChannel.

public void config() {
    socketChannel.configureBlocking(false);
    Socket socket = socketChannel.socket();
    socket.setKeepAlive(true);
    if (sendBufferSize != Selectable.USE_DEFAULT_BUFFER_SIZE)
        socket.setSendBufferSize(sendBufferSize);
    if (receiveBufferSize != Selectable.USE_DEFAULT_BUFFER_SIZE)
        socket.setReceiveBufferSize(receiveBufferSize);
    socket.setTcpNoDelay(true);
}

在调用发送之前, 相关组件就是初始化好的, 在上一个阶段, 判断出来还没有对 Broker 进行连接之后, 就尝试对这个 Broker 进行连接, 这里就是直接通过 SockerChannler 进行注册:

  • keepAlive : 主要用来避免客户端和服务端任何一方断开连接之后,对方不知道,一直保持着网络连接的资源,所以设置这个之后,当双方两个小时没有通信之后,就会发送一个探测包,根据探测包的结果判断是保持连接 或者 中断连接 或者 重新连接

  • 缓冲区大小:在 socket 开发中,一般都要设置 发送缓冲区大小接收缓冲区大小 ,这里前者设置的是 128k,后者设置的 32k

  • tcpNoDelay:这个参数如果是 false 的话,那么会开始 Nagle 算法,就是把网络通信中的一些小的数据包给收集起来,组成一个大包一起发出去。因为小的数据包可能会导致网络拥挤。这个参数如果是 true ,就代表要立马将这个数据包发送出去。

    之后就是会调用 channel.connect() 方法, 它的一个语义就是说, 如果他设置为一个非阻塞式的请求, 那么对这个 connect 方法的调用, 会初始化一个非阻塞的网络请求, 如果发送完请求之后, 立马就建立成功了, 这样就表明 客户端和服务端在同一台机器上 , 这样立马连接成功的情况, 就会返回 true; 只要返回值不是 true , 就需要后续去调用 finshConnect 方法, 去完成最终的连接.

private SelectionKey registerChannel(String id, SocketChannel socketChannel, int interestedOps) throws IOException {
    SelectionKey key = socketChannel.register(nioSelector, interestedOps);
    KafkaChannel channel = buildAndAttachKafkaChannel(socketChannel, id, key);
    this.channels.put(id, channel);
    return key;
}

后续的话, 会通过 channel 将请求注册缓存到 selector 上, 并生成一个 SelectionKey 和 channel 进行一一对应, 这个的话, 主要就是通过 select 去监听 OP_CONNECT 的事件, 然后会将 channel , brokerid, key 先封装为一个 TransportLayer , 然后在封装为 KafkaChannel, 最后通过 attach 方法, 将 selectionKey 和 封装好的 KafkaChannel 进行一个关联. 关联完毕之后, 会判断之前的那个是否是瞬间连接的情况, 如果是的话, 会将 key 加入到一个集合中去.

生成 selectionKey 主要为了便于后续根据 selectionKey 查看这个 channel 的连接情况.

NetWorkClient (1).png

这时, 基本的前置条件都已经满足了, 会通过调用 channel 的 poll 方法, 完成连接的创建, 首先是会通过 socket 查看哪个 channel 已经收到了响应请求, 这时会将这个 channel 的 selectionKey 返回, 如果发现 SelectionKey 的 channel 当前处于的状态是可以建立连接, isConnectable 方法是 true, 接着其实就是调用到 KafkaChannel 最底层的 SocketChannel 的 finishConnect 方法, 等待这个连接必须执行完毕, 同时接下来就不要关注 OP_CONNECT 事件了, 对于这个 Channel, 接下来 Selector 就不要关注连接相关的事件了, 也不是 OP_READ 读取事件, 肯定 selector 要关注的是 OP_WRITE 事件, 要针对这个连接写数据, 在连接创建完成之后, 会将状态设置为 CONNECTED , 这时, 当进入下次 run 方法的循环的时候, 先获取可以发送的 batch , 在检查哪些 broker 可以直接发送, 因为有了上一轮的创建连接, 所以这时在进行 broker 连接状态检查的时候, 都是返回的 true, 就会直接封装请求信息, 通过 client.send() , 将请求发送到 Broker 上去, 在发送的时候, 会将请求放入到 inFlightRequest 集合当中 (这个就是最多允许发送多少还没有收到响应的请求) , 也会将这个请求暂存到 channel 里面, 并设置监听 OP_WRITE 事件, 最终就是通过 selector.send() 将请求发出, 主要也是通过 Poll 方法进行轮询, 判断是否是可以写的状态, 可以写的话就将数据写入 Broker.

** **

5.5 基于位运算控制时间监听

key.interestOps(key.interestOps() & ~SelectionKey.OP_CONNECT | SelectionKey.OP_READ);

SeletionKey,里面封装了 Selector 对一个连接关注那个连接上的哪些事件,共有 OP_CONNECT,OP_WRITE,OP_READ三种事件,像上面这个就是取消对OP_CONNECT事件的关注,增加对OP_READ事件的一个关注,主要都是通过二进制位运算来实现的。(~ 代表取消, | 代表添加)

一旦建立好连接之后, 天然的就会去监听这个连接的 OP_READ 事件, 要发送请求的时候, 会把这个请求暂存到 KafkaChannel 里去, 同时让 Selector 监视他的 OP_WRITE 事件, 增加一种 OP_WRITE 事件, 同时保留了 OP_READ 事件, 此时 Selector 会同时监听这个连接的 OP_WRITE 和 OP_READ 事件, 一旦写完请求之后, 就会把 OP_WRITE 事件取消监听, 就是此时不关注这个写请求的事件了, 此时仅仅保留关注 OP_READ 事件

5.6 拆包问题

如果 Kafka 一个请求一次 write 操作没有把全部的数据都写到 broker 去, 相当于出现了类似于拆包的问题, 一个请求一次没法发送完毕, 如果说一个请求对应的 ByteBuffer 中的二进制字节数据一次 write 没有全部发送完毕, 如果说一次请求没有发送完毕, 此时肯定 remaining (数据量 减去 发送了的数据量) 是大于 0, 此时就不会取消对 OP_WRITE 事件的监听, 这样这个请求就不会加入到 已完成请求 的集合当中, 那么会继续进行下一轮 run() 循环.

在进行下一轮循环的时候, 判断 Broker 连接的时候, 会判断是否上一次的 Request 请求的数据都发送完毕了, 还得保证向 Broker 发送请求但还没有收到响应的数量小于阈值, 也就是说, 如果上一次的请求出现了拆包的现象, 这时是不会在对这个 Broker 发起请求的, 但是此时针对于这个 Broker 的 channel 的 OP_WRITE 事件监听还是在的, 只需要在可以写的情况下再次调用 write , 进行一次写操作即可, 上述的过程重复多次, 一定会把这个请求发送完毕的

5.7 OP_READ读取和粘包,拆包问题

************************ **************************

一个broker是可以通过一个连接连续发送出去多个请求的,这个多个请求可能都没有收到响应消息,此时人家broker端可能会连续处理完多个请求然后连续返回多个响应给你,所以在这里,你一旦去读数据,可能会连续读到多个请求的响应,所以说在这里处理OP_READ事件的时候,必须要通过一个while循环,连续不断的读,可能会读到多个响应消息,全部放到一个暂存的集合里,stagedReceives

 private void attemptRead(SelectionKey key, KafkaChannel channel) throws IOException {
        //if channel is ready and has bytes to read from socket or buffer, and has no
        //previous receive(s) already staged or otherwise in progress then read from it
        if (channel.ready() && (key.isReadable() || channel.hasBytesBuffered()) && !hasStagedReceive(channel)
            && !explicitlyMutedChannels.contains(channel)) {
            NetworkReceive networkReceive;
            while ((networkReceive = channel.read()) != null) {
                madeReadProgressLastPoll = true;
                addToStagedReceives(channel, networkReceive);
            }
            if (channel.isMute()) {
                outOfMemory = true; //channel has muted itself due to memory pressure.
            } else {
                madeReadProgressLastPoll = true;
            }
        }
    }

读取数据的时候, 其实可能会遇到连续不断的多个响应粘在一起给你返回回来的, 就是你在这里读取, 可能会在不停的读取的过程中发现你读到了多个响应消息, 这个就是类似于粘包的问题, 简单来说就是无法区分一个响应是从哪到哪.

public long readFrom(ScatteringByteChannel channel) throws IOException {
        int read = 0;
        if (size.hasRemaining()) {
            int bytesRead = channel.read(size);
            if (bytesRead < 0)
                throw new EOFException();
            read += bytesRead;
            if (!size.hasRemaining()) {
                size.rewind();
                int receiveSize = size.getInt();
                if (receiveSize < 0)
                    throw new InvalidReceiveException("Invalid receive (size = " + receiveSize + ")");
                if (maxSize != UNLIMITED && receiveSize > maxSize)
                    throw new InvalidReceiveException("Invalid receive (size = " + receiveSize + " larger than " + maxSize + ")");
                requestedBufferSize = receiveSize; //may be 0 for some payloads (SASL)
                if (receiveSize == 0) {
                    buffer = EMPTY_BUFFER;
                }
            }
        }
        if (buffer == null && requestedBufferSize != -1) { //we know the size we want but havent been able to allocate it yet
            buffer = memoryPool.tryAllocate(requestedBufferSize);
            if (buffer == null)
                log.trace("Broker low on memory - could not allocate buffer of size {} for source {}", requestedBufferSize, source);
        }
        if (buffer != null) {
            int bytesRead = channel.read(buffer);
            if (bytesRead < 0)
                throw new EOFException();
            read += bytesRead;
        }

        return read;
    }

其实这里解决粘包问题是通过在 每个响应消息前加入一个 4 个字节的 integer 类型的值 这个值代表是 这条响应消息的大小 , 比如说, 现在有三个响应数据:

响应消息1,199个字节;响应消息2,238个字节;响应消息3,355个字节

199响应消息(1)238响应消息(2)355响应消息(3)

这样的话, 会从 channel 中读取 4 个字节的数字, 写入到 size ByteBuffer (4 个字节) , 就是如果已经读取到了 4 个字节, position 就会变成 4, 就会跟 limit 是一样的, 此时就代表着 size ByteBuffer 的 4 个字节已经读满了, 此时就可以从 ByteBuffer 里读取数据了, ByteBuffer.getInt(), 转换为一个 int 类型的数字返回给你, 然后根据返回的这个 int 值, 去申请空间读取这个值的数据, 也就是代表了这个响应数据的大小, 接下来就会直接把 channel 里的一条响应消息的数据读取到一个跟他的大小一致的 ByteBuffer 中去, 粘包问题的解决, 就是完美的通过每条消息基于一个 4 个字节的 int 数字 (他们自己的大小) 来进行分割.

如果响应结果发过来的数据发生了拆包现象的话, 在读取消息的时候, 4 个字节的 size 都没读完, 只读到了 2 个字节, 或者是 199 个字节的消息就读到了 162 个字节, 现在读取 1 个字节, position = 1; 读取 2 个字节, position = 2, 此时 remaining 是 2, 还剩下个 2 字节是可以读取的, 这一次这个 poll 里面, 对这个 broker 的读取事件的处理就完事儿了, 就读到了 2 个字节, 什么都没有, 下一次如果再次执行 poll, 发现又有数据可以读取了, 此时的话呢, 就会再次运行到这里去, NetworkReceive 还是停留在那里, 所以呢可以继续读取, 剩余只能读 2 个字节, 所以最多就只能读取 2 个字节到里面去, 4 个字节凑满了, 此时就说明 size 数字是可以读取出来了, 解决了 size 的拆包的问题, 第二种拆包问题发生了, 199 个字节的消息, 只读取到了 162 个字节, 37 个字节是剩余可以读取的, 下一次循环的时候就会发现这个 broker 有 OP_READ 可以读取的时候, 再次进来, 继续读取数据

5.8 处理响应

这里的话就是从 stagedReceives 中进行数据的表里, 获取到每个 channel 中第一个响应数据, 加入到 completedReceives, 然后放到后面去处理

private void addToCompletedReceives() {
        if (!this.stagedReceives.isEmpty()) {
            Iterator<Map.Entry<KafkaChannel, Deque<NetworkReceive>>> iter = this.stagedReceives.entrySet().iterator();
            while (iter.hasNext()) {
                Map.Entry<KafkaChannel, Deque<NetworkReceive>> entry = iter.next();
                KafkaChannel channel = entry.getKey();
                if (!explicitlyMutedChannels.contains(channel)) {
                    Deque<NetworkReceive> deque = entry.getValue();
                    addToCompletedReceives(channel, deque);
                    if (deque.isEmpty())
                        iter.remove();
                }
            }
        }
    }

在后续的处理中,会首先从 inFlightRequest 集合中弹出第一个入队的请求信息,根据这个请求信息解析 RESPONSE_HEADER 和 REQUEST_HEADER,他们都有一个 coorelation_id 的参数,通过对比这个 id 值,就知道这个响应是属于哪个请求的,解析响应数据,看是否有异常,如果没有异常会调用在创建 Request 请求的时候,封装的 callback 回调方法,并将这个请求的 batch 进行释放,放到 BufferPoll 中,做资源的重复利用。如果有异常,会将这个 Batch 进行重新入队,等待一个重试。

5.9 重试

重试的Batch会放入到队列的头部,不是尾部,这样的话,下一次循环的时候就可以优先处理这个要重新发送的Batch了,attempts、lastAttemptMs这些参数都会进行设置,辅助判断这个Batch下一次是什么时候要进行重试发送,lastAttemptMs,是他重新入队的时间,retryBackoffMs其实就是重试的间隔,默认是100ms,他的意思是必须间隔一定的时间再进行重试,这个100ms一般来说建议保持默认值就可以了,但是重试的次数可以自己设置一下,一遍来说建议设置为3次。

主要是否能重试,是通过一个条件进行判断 lastAttemptMs + retryBackoffMs > now, 上次重新入队的时间到现在还没超过100ms呢,如果说当前时间距离上次入队时间还没到100ms,此时backingOff就是true,如果是true的话,就不能重试,假如说:lastAttemptMs + retryBackoffMs <= now,就说明现在的时间距离上次重新入队的时间已经超过了100ms了,此时backingOff就是false,此时就说明这个要重试的Batch就可以再次发送了。

如果这个 Batch 三次请求都失败了, 这时也是会调用回调方法, 后续进行 Batch 空间的释放, Batch 请求超时, 同理.

04_KafkaProducer源码分析 (10).jpg