【从源码深入kafka】生产者(二)- 记录收集器的那些事

01上篇回顾

     

      上一篇我们说了生产者加入记录收集器前的一些准备工作,我们再来回顾一下这个图:

      拦截器处理、分区元数据更新、分配分区、key-value序列化环节在上一篇中已经说完,本节继续说下消息的大小检验工作、封装用户callback的过程以及消息写入ProducerBatch的整个过程。

      

02 消息的大小校验

          

      我们在使用生产的时候有时需要配置一些参数,比如:max.request.sizebuffer.memory等,我们看看这两个参数的作用:

     max.request.size:每次消息发送时,记录被序列化后的最大字节数,大于此数会抛出RecordTooLargeException

     buffer.memory:这个参数指定了生产者记录收集器的最大的buffer的字节数也就是我们后面将要讲到的BufferPool的最大容量。

      不同的magic的消息有不同的字段结构以及内存占用情况,在真正写入ProducerBatch前会有一系列的校验工作,按照Magic的版本计算消息预计序列化后占用的空间大小size,校验这个size是否大于max.request.size和buffer.memory,如果大于,则发送消息失败直接返回错误。

注:具体空间占用情况看上一篇中的第6个标题“不同版本消息的内部存储结构”内容。

03用户回调的封装

   

     触发消息的回调有两种途径,一种是我们使用生产者发送消息时可以传递一个callback,另外一个是上一篇提过的生产者的拦截器,kafka producer利用内部类InterceptorCallback聚合了这两个回调,InterceptorCallback实际上会被聚合到一个ProducerBatch的chunk对象中,这样当ProducerBatch发送成功或失败后触发InterceptorCallback#onCompletion,进而触发了我们自定义的callback和拦截器的onAcknowledgement方法。

       一般情况下我们使用生产者发送消息时需要附加上我们callback的逻辑(如下图),因为发送过程是异步的,我们需要依赖回调获取消息的metedata,通过metedata我们可以获取到消息所属分区和分区的offset等。

        Properties properties = KafkaProducerConfig.createProducerConfig();

        KafkaProducer<String,String> producer = new KafkaProducer<>(properties);

        ProducerRecord<String,String> record = new ProducerRecord<>(TOPIC,"111","test-data");

        producer.send(record, new Callback() {
            @Override
            public void onCompletion(RecordMetadata recordMetadata, Exception e) {
                if(Objects.nonNull(recordMetadata)){
                    //获取消息的offset
                    System.out.println(recordMetadata.offset());
                    //获取消息的分区
                    System.out.println(recordMetadata.partition());
                    //获取消息的topic
                    System.out.println(recordMetadata.topic());
                }
            }
        });

      当请求完成-即发送消息成功或失败时会调用自定义callback的onCompletion方法和拦截器的onAcknowledgement方法。    

04记录收集器

       记录收集器是本篇的主角。如果把生产者发送消息的场景比作是我们生活中邮寄快递的场景,记录收集器扮演着一个快递点“调度员”的角色,负责指挥货物打包,装车以及决定哪些货物装到同一辆车等很多重要的工作。我们先看下这个记录收集器-“调度员”长什么样子吧?

       上图是记录收集器的类图,列出了一些认为比较重要的属性

  •  closed: 记录收集器的状态,和生产者的状态同步更新     

  •  batchSize:这个参数是生产者创建记录收集器时传递过来的,也是来源于生产者的配置参数 batch.size

  •  lingerMs:生产者为了将更多的消息合并到同一个ProducerBatch中一起发送而减少网络请求的次数,通常会设置一个允许延迟发送的的参数等待更多的消息加入同一个批次,可以类比TCP的Nagle算法。当然如果ProducerBatch填满后会忽略该参数,该参数默认为0

  •  retryBackoffMs:它是因消息发送失败而进行重试的时间间隔。该参数也是来源于生产者的参数retry.backoff.ms

  •  deliveryTimeoutMs:消息最长的交付时间,即消息从创建到发送的最长等待时间。该参数和生产者的三个参数有关:delivery.timeout.ms、linger.msrequest.timeout.ms,这三个参数的默认值分别为120000、0和30000,单位都是毫秒。 deliveryTimeoutMs具体计算逻辑如下:

     分两种情况:

     1)用户配置了delivery.timeout.ms,如果该参数小于linger.ms+request.timeout.ms则会报错,生产者启动失败,否则使用该参数。

     2)如果没有配置 delivery.timeout.ms。如果delivery.timeout.ms小于linger.ms+request.timeout.ms,则会使用linger.ms+request.timeout.ms作为deliveryTimeoutMs,否则使用默认参数120000。

  •  apiVersion:和生产者共用同一个apiVersion对象,用来做客户端和服务端之间的版本兼容控制。

  •  incomplete: 所有的已经被IO线程取走的batch会放在这个容器中,代表batch目前正在发送处理中并且没有收到ack的响应。

  •  muted: 一个set集合,用于存放暂时不可发送的分区。在要保证消息顺序的业务场景中,同一个分区的多个batch不能同时发送,即当一个batch正在发送过程中同一分区的其他batch不能发送,那么这个分区会被加入到set集合中,待当前正在发送中的batch成功收到ack响应后,会将这个分区从set集合中移除。

  • drainIndex: 还记得上篇文章提到的生产者线程和IO发送线程形成一个小的生产消费模型吗?这个drainIndex相当于记录上次IO线程消费完分区的index,下一次IO线程轮训会从上次的index开始以防止分区饥饿,后面讲IO线程的时候我们详细聊聊。 

  •  batches:这个是“收集器”中非常重要的一个属性,其结构如下图:

       是不是有点眼熟?没错,就是上一篇文章的图,batches是一个ConcurrentMap结构,key为分区对象TopicPartition,value为Deque,Deque队列中存放的就是我们的ProducerBatch对象。我们知道消息是需要写入ProducerBatch中的,首先从batches中取出分区对应的deque,如果没有则新建一个队列,新的消息一定要加入到队列的最后一个batches中,如果batch满了就新建一个再入队。那么为什么使用双端队列呢?这里原因有两个:

  •   看过tryAppend源码就会明白了,是因为我们需要把记录优先添加到最后一个还没有填充满的ProducerBatch中,所以从队尾取出ProducerBatch并把记录追加进去,但是IO发送线程需要从队头取。

  • 第二个原因是,当网络发送失败需要将消息重新入队,这个时候需要从队列头部再次入队。   

        为了了解消息加入到ProducerBatch的整个过程,我整理了记录收集器append消息过程的时序图: 

          

       友情提示:这一段的调用过程比较复杂,参考下源码看会容易理解。 

       这个时序图是添加新消息的一个最长路径,有些步骤是有条件判断的可能会跳过,为了看起来不这么乱索性图中没有标识出分支逻辑。从整体看整个消息append的过程中涉及到六个关键对象:KafkaProducer生产者、RecordAccumulator消息收集器、Partitioner分区器、BufferPool消息的缓冲区、MemoryRecords消息对象和ProducerBatch消息的批记录,为了能更清楚的了解他们之间的关系,我梳理了一下简化的类图:

     ProducerBatch定义有成员变量RecordAccumulator和Partitioner,RecordAccumulator组合了BufferPool并且依赖MemoryRecords和ProducerBatch。

     下面我们就时序图中几个关键步骤讨论一下,我们假定生产者刚刚启动,记录收集器中的队列也不存在。

  • 创建Deque-编号①

       由KafkaProducer调用RecordAccumulator执行append过程,首先需要从batches中获取对应的分区队列,没有会创建ArrayDeque

  •  返回新建ProducerBatch标识的append结果-编号②

       调用KafkaProducer内部方法tryAppend,从deque中取出最后一个batch记录,第一次当然取不到直接返回appendResult

  •  触发分区器的onNewBatch方法-编号③

          在上一步中的appendResult结果中获取属性abortForNewBatch,为true需要新建batch,会调用Partitioner的onNewBatch方法(绿色代码),可以参考下面的源码:

if (result.abortForNewBatch) {
   int prevPartition = partition;
   partitioner.onNewBatch(record.topic(), cluster, prevPartition);
   partition = partition(record, serializedKey, serializedValue, cluster);
   tp = new TopicPartition(record.topic(), partition);
   interceptCallback = new InterceptorCallback<>(callback, this.interceptors, tp);
   result = accumulator.append(tp, timestamp, serializedKey,
                    serializedValue, headers, interceptCallback, remainingWaitMs, false, nowMs);
            }

       分区器可能会生成新的分区(参考上一篇的分区器的原理),由于分区可能发生了变化需要重新封装callback(上面红色代码)

  • 向BufferPool申请内存-编号④

       KafkaProducer再次将append请求委托给记录收集器,记录收集器再一次tryAppend,这个时候会委托给BufferPool来分配内存(具体逻辑看下文的标题05部分),那么需要分配多大的内存呢?还记得生产者的配置参数batch.size吗?对!这个参数实际上就是批记录的大小,这里还有一个问题,就是一条记录的消息大小比batch.size,那么这个批记录可能连一个记录都放不下,那能不能将消息发送出去呢?答案肯定是可以的,这里在创建batch的时候会考虑消息比批记录还大的情况,所以分配内存时取消息和batch.size的较大者,消息的大小就是上篇所说的根据magic来计算出来的。

  byte maxUsableMagic = apiVersions.maxUsableProduceMagic();
  int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(maxUsableMagic, compression, key, value, headers));
  buffer = free.allocate(size, maxTimeToBlock);

  •   创建MemoryRecordsBuilder-编号⑤  

      记录收集器这一步还是要调用tryAppend尝试添加消息,当然deque中还是没有(因为还没放进去呢,上一步只是分配了一个buffer,这里需要反反复复调用tryAppend,可以参考下源码看理解起来更容易),这一次我们需要利用上一步的buffer构建MemoryRecordsBuilder,MemoryRecords.builder内部还要层层调用重载的方法,大家看下MemoryRecords这个类,这里就不贴里层的代码了。

private MemoryRecordsBuilder recordsBuilder(ByteBuffer buffer, byte maxUsableMagic) {
        if (transactionManager != null && maxUsableMagic < RecordBatch.MAGIC_VALUE_V2) {
            throw new UnsupportedVersionException("Attempting to use idempotence with a broker which does not " +
                "support the required message format (v2). The broker must be version 0.11 or later.");
        }
        return MemoryRecords.builder(buffer, maxUsableMagic, compression, TimestampType.CREATE_TIME, 0L);
}

    MemoryRecordsBuilder类图:

        我们再一次把上篇中的消息的记录格式(magic >= 2)搬过来,对照一下

      对比一下我们可以看到MemoryRecords中的字段和我们上一篇中讲到的消息的格式能够对应上,每个字段的具体用途需要用到时候再给大家细细讲讲,现在说完我估计大家看完也是一脸懵逼。MemoryRecordsBuilder创建完成后接着会创建ProducerBatch,并且MemoryRecordsBuilder会作为构造参数。

  •  append请求委托给MemoryRecordsBuilder-编号⑥

   这一步消息收集器将append请求委托给上一步创建出来的ProducerBatch对象,ProducerBatch又将append请求委托给MemoryRecordsBuilder,首先要检查batch的内存是否容得下将要添加的记录,注意这里要根据magic版本来计算除了记录本身外的所有开销,如果忘记了请看下上一篇。空间足够那么是会采用ByteUtils工具包根据magic类型依次写入对应的字段(如offset、timestamp、attribute等),具体的细节可以看下kafka-client-3.1源码的MemoryRecordsBuilder#append方法。

  •      封装回调并且唤醒生产者⑦

      ProducerBatch会将append成功后返回的FutureRecordMetadata对象和callback(用户传递的callback和生产者拦截器) 封装到一个被称之为Trunk的List结构中,以便batch发送成功后触发调用。

    private final List<Thunk> thunks = new ArrayList<>();

     完成记录的追加后,ProducerBatch将会被加入到分区对应的deque队列的末尾(如果是新建的batch),同时将ProducerBatch记录加入到记录收集器的IncompleteBatches中。

      最后KafkaProducer根据append的最终结果会唤醒发送线程客户端,唤醒的条件有两个:

     1)append成功后记录收集器中该分区对应的deque的size大于1,说明肯定有满了的ProducerBatch等着要发送

     2)append的结果是当前的batch满了(添加完本记录后满了)

      至此,生产者的主线程的工作已经全部结束了

05BufferPool的内存分配策略

       上文说到在新建ProducerBatch之前先要向缓冲区BufferPool中申请内存,申请内存的过程也是有点小复杂,这里把这块单独拎出来说说。

首先看下BufferPool这个类的结构

       这个类是专为生产者提供的,BufferPoll保证公平分配,即当内存不足时,优先分配等待时间最长的线程,尤其是当有线程申请大内存时,可以保证一直等待多个buffer被释放直到满足申请的内存大小,而不会产生线程饥饿或死锁。BufferPool对象由生产者创建,并通过构造函数传递给收集器。

      BufferPoll的主要构造参数如下:

       memory:内存池的最大内存字节数,由生产者的参数buffer.memory指定。

       poolableSize:内存池把大内存划分成一个一个小的free内存,这个参数规定了这个小块内存的大小,由生产者的参数batch.size指定。

       除此之外BufferPoll的构造中会初始化free和waiters队列,free里面放的是空闲的内存块,waiters中存放的是等待线程的Condition条件,具体的分配逻辑我们看下这个流程图:

      上文已经说明,BufferPool会将空闲的内存分割成batch.size大小的小内存块,但是它不是一开始就把所有的内存其分成batch块,而是通过记录收集器和sender线程来释放buffer(发送成功后释放记录占用的buffer),而后加入到free队列中。主要看图在请求分配内存时,首先判断下free队列中是否存在内存块,并且刚好等于请求的size(因为有大记录时,请求的内存块可能会大于batch.size),如果存在则直接返回,否则从没有被分成块的内存进行分配,如果free+没有被分成块的空闲内存小于请求的,则需要将请求加入到waiters队列,等待其他线程释放内存以唤醒,BufferPoll公平地为每一个请求者分配内存,在请求内存需要附加一个最大的等待时间,超时会返回给请求方超时错误以便请求方进行后续的业务处理而不至于一直死等。

06 小结

       本篇首主要讲到了消息记录的大小校验规则、用户回调的封装,并且对记录收集器append消息的主要流程做了详细的说明,后面着重说了下缓冲池BufferPool的主要设计思想和处理逻辑,希望对大家在工作中有所帮助。

       通过这两篇文章我们已经讲解了消息从生产者写入到BufferPool的全过程,完成了生产者主线程的所有工作,下一篇我们主要说说IO发送线封装网络请求和发送的过程。       

更多文章请关注公众号“戏说分布式技术

下一篇预告 《【从源码深入理解kafka】- 生产者(三)-sender线程那些事 》

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值