Kafka源码解析之Producer缓存设计

1,224 阅读11分钟

前言

一个高效的消息队列肯定离不开缓存和网络传输,而缓存设计的好坏一定程度能决定消息队列在网络传输存在波动下消息队列的吞吐量。第一次看kafka发送缓存源码的时候,还是有一点惊喜的感觉,那个点在于缓存分块的重复利用设计

相关配置参数

buffer.memory

查看官方文档:

The total bytes of memory the producer can use to buffer records waiting to be sent to the server. If records are sent faster than they can be delivered to the server the producer will block for max.block.ms after which it will throw an exception.This setting should correspond roughly to the total memory the producer will use, but is not a hard bound since not all memory the producer uses is used for buffering. Some additional memory will be used for compression (if compression is enabled) as well as for maintaining in-flight requests.

翻译:

producer能够用于缓存等待发送到服务端的record的总字节大小。如果消息投递的速度低于他们被传输到服务端的数据,producer会在阻塞max.block.ms以后抛出超时异常。这个配置项的大小大致上匹配producers使用的在哪个内存大小,但是这不是硬性的设置,因为producer所使用的缓存并不都是用于缓存消息。如果压缩配置开启的话,额外数据量内存会被用于数据的压缩 ,还有维持在传输中的消息。

该参数决定producer能够使用的缓存大小

batch.size

The producer will attempt to batch records together into fewer requests whenever multiple records are being sent to the same partition. This helps performance on both the client and the server. This configuration controls the default batch size in bytes.No attempt will be made to batch records larger than this size.Requests sent to brokers will contain multiple batches, one for each partition with data available to be sent.A small batch size will make batching less common and may reduce throughput (a batch size of zero will disable batching entirely). A very large batch size may use memory a bit more wastefully as we will always allocate a buffer of the specified batch size in anticipation of additional records.

如果有多条消息被投递到相同的分区时,producer会尝试将消息合并成批数据,以达到减少投递次数的目的。这可以同时提高客户端和服务单的性能。 这个配置项就是控制合并的总字节大小,如果消息的大小超过这个配置,producer将不会进行批处理。发送到broker的请求将包含多条 批数据,每个分区如果有有效的数据,都会包含一条。 如果批数据的大小设置的比较小,会导致批量处理变更不通用,反而可能会降低吞吐量(如果设置为0则将会禁用这个功能)。该参数如果设置的过大,有可能会造成内存的浪费,我们总是在分配指定大小的缓存,然后等待其他消息的到来

该参数决定了kafka 批处理的标准尺寸。最好和业务数据结构和数据量保持一致

缓存初始化

我们跟着参数配置传递情况,看一下缓存如何初始化

从上文中可以看出,所有的消息都被追加到accumulator中,那么可以推测缓存池的管理应该也是在这个类中。来看一下Producer的构造器中的代码:

    this.totalMemorySize = config.getLong(ProducerConfig.BUFFER_MEMORY_CONFIG);
    this.compressionType = CompressionType.forName(config.getString(ProducerConfig.COMPRESSION_TYPE_CONFIG));
    this.accumulator = new RecordAccumulator(logContext,
                    config.getInt(ProducerConfig.BATCH_SIZE_CONFIG),
                    this.totalMemorySize,
                    this.compressionType,
                    config.getLong(ProducerConfig.LINGER_MS_CONFIG),
                    retryBackoffMs,
                    metrics,
                    time,
                    apiVersions,
                    transactionManager);
  

从上面的代码看出,两个配置项被传递到了RecordAccumulator中,

    public RecordAccumulator(LogContext logContext,
                             int batchSize,
                             long totalSize,
                             CompressionType compression,
                             long lingerMs,
                             long retryBackoffMs,
                             Metrics metrics,
                             Time time,
                             ApiVersions apiVersions,
                             TransactionManager transactionManager) {
         //只保留部分核心代码
        this.batchSize = batchSize;
        this.lingerMs = lingerMs;
        this.batches = new CopyOnWriteMap<>();
        this.free = new BufferPool(totalSize, batchSize, metrics, time, metricGrpName);
    }

从源码中可以看出,配置项被传递到BufferPool


 public BufferPool(long memory, int poolableSize, Metrics metrics, Time time, String metricGrpName) {
        this.poolableSize = poolableSize;
        this.lock = new ReentrantLock();
        this.free = new ArrayDeque<>();
        this.waiters = new ArrayDeque<>();
        this.totalMemory = memory;
        this.nonPooledAvailableMemory = memory;
        this.metrics = metrics;
        this.time = time;
        this.waitTime = this.metrics.sensor(WAIT_TIME_SENSOR_NAME);
        MetricName rateMetricName = metrics.metricName("bufferpool-wait-ratio",
                                                   metricGrpName,
                                                   "The fraction of time an appender waits for space allocation.");
        MetricName totalMetricName = metrics.metricName("bufferpool-wait-time-total",
                                                   metricGrpName,
                                                   "The total time an appender waits for space allocation.");
        this.waitTime.add(new Meter(TimeUnit.NANOSECONDS, rateMetricName, totalMetricName));
    }

配置项的传递到这里结束了,也就是说,缓存的奥秘都在BufferPool这个类中

BufferPool源码解析

BufferPool uml类结构解析

bufferPool
核心参数已经在上面描述,我们将主要解析allocate 和 deallocate两个方法,我们先对核心概念做一个说明:

  • totalMemory: 标识缓存总大小
  • poolableSize: 标准大小
  • ByteBuffer: nio中字节缓存区: 相关介绍文章
  • nonPooledAvailableMemory: 没有被分配的缓存池余量,初始化时 = totalMemory

整个结构设计:

ByteBuffer allocate(int size, long maxTimeToBlockMs)分配解析

他的唯一调用方是RecordAccumulator的append方法,也就是负责缓存区的划分和分配。让我们一起看看他的实现

public ByteBuffer allocate(int size, long maxTimeToBlockMs) throws InterruptedException {
        ByteBuffer buffer = null;
        this.lock.lock();
        try {
            // 场景一:也就是预期中最常见的场景
            if (size == poolableSize && !this.free.isEmpty())
                return this.free.pollFirst();

            //场景二:应当规避的场景
            int freeListSize = freeSize() * this.poolableSize;
            if (this.nonPooledAvailableMemory + freeListSize >= size) {
                // we have enough unallocated or pooled memory to immediately
                // satisfy the request, but need to allocate the buffer
                freeUp(size);
                this.nonPooledAvailableMemory -= size;
            } else {
                // 场景三:当投递速度超过传递到server端速度时,会进入的竞争状态
                int accumulated = 0;
                Condition moreMemory = this.lock.newCondition();
                try {
                    long remainingTimeToBlockNs = TimeUnit.MILLISECONDS.toNanos(maxTimeToBlockMs);
                    this.waiters.addLast(moreMemory);
                    while (accumulated < size) {
                        long startWaitNs = time.nanoseconds();
                        long timeNs;
                        boolean waitingTimeElapsed;
                        try {
                            waitingTimeElapsed = !moreMemory.await(remainingTimeToBlockNs, TimeUnit.NANOSECONDS);
                        } finally {
                            long endWaitNs = time.nanoseconds();
                            timeNs = Math.max(0L, endWaitNs - startWaitNs);
                            this.waitTime.record(timeNs, time.milliseconds());
                        }

                        if (waitingTimeElapsed) {
                            throw new TimeoutException("Failed to allocate memory within the configured max blocking time " + maxTimeToBlockMs + " ms.");
                        }

                        remainingTimeToBlockNs -= timeNs;

                        if (accumulated == 0 && size == this.poolableSize && !this.free.isEmpty()) {
                            buffer = this.free.pollFirst();
                            accumulated = size;
                        } else {
                            freeUp(size - accumulated);
                            int got = (int) Math.min(size - accumulated, this.nonPooledAvailableMemory);
                            this.nonPooledAvailableMemory -= got;
                            accumulated += got;
                        }
                    }
                    accumulated = 0;
                } finally {
                    this.nonPooledAvailableMemory += accumulated;
                    this.waiters.remove(moreMemory);
                }
            }
        } finally {
            //如果有竞争,但是还有新的空闲,那就继续唤醒等待最久的人
            try {
                if (!(this.nonPooledAvailableMemory == 0 && this.free.isEmpty()) && !this.waiters.isEmpty())
                    this.waiters.peekFirst().signal();
            } finally {
                lock.unlock();
            }
        }

        //场景四:程序启动时,最开始的场景
        if (buffer == null)
            return safeAllocateByteBuffer(size);
        else
            return buffer;
    }

从整体架构可以看出,总的缓存会被切割称过个ByteBuffer,然后通过双端队列来保持对ByteBuffer的复用,我将通过不同的场景来详细描述kafka如何进行应对。

场景四: 应用刚启动时

应用刚启动时,free为空,nonPooledAvailableMemory=totalMemory,则需要总的缓存池中分配新的batchSize大小的ByteBuffer

该场景经历的分支如下:

//满足nonPooledAvailableMemory 有足够的空间
if (this.nonPooledAvailableMemory + freeListSize >= size) {
    //该函数由于free为空,直接返回
    freeUp(size);
    this.nonPooledAvailableMemory -= size;
} 
//场景四中,当buffer为null,然后从总池中分配了额定的缓存
if (buffer == null)
return safeAllocateByteBuffer(size);

来看一下safeAllocateByteBuffer函数

 private ByteBuffer safeAllocateByteBuffer(int size) {
        boolean error = true;
        try {
            //分配了给定大小的ByteBuffer
            ByteBuffer buffer = allocateByteBuffer(size);
            error = false;
            return buffer;
        } finally {
            //异常处理分支,失败补偿逻辑,通过error的重置来标记主干流程执行完毕也是不错的设计方式
            if (error) {
                this.lock.lock();
                try {
                    this.nonPooledAvailableMemory += size;
                    if (!this.waiters.isEmpty())
                        this.waiters.peekFirst().signal();
                } finally {
                    this.lock.unlock();
                }
            }
        }
    }
    
    // 实际调用的就是nio的ByteBuffer
    protected ByteBuffer allocateByteBuffer(int size) {
        return ByteBuffer.allocate(size);
    }

从上述的源码中可以看出,kafka底层使用的缓存就是NIO的byteBuffer,以及如何进行分片,kafka预期的分片都是额定大小的,这种方式可以高效的应对场景二。

场景一 标准内的消息投递和缓存分配

当应用运行一段时间后,有消息成功投递后,那该消息的ByteBuffer也就无用了,大家知道ByteBuffer通过captcity,position,limit来实现缓存的复用。kafka也是利用这个特性,达到批消息缓存的重复使用,来降低新建和回收的代价。

if (size == poolableSize && !this.free.isEmpty())
    return this.free.pollFirst();

这个是kafka预期调用的分支,这样只要有成功传递消息,那么free就会有数据,由于是标准尺寸,只要重新设置ByteBuffer的参数,就是一个全新的ByteBuffer,可以不用在累加和减少nonPooledAvailableMemory的值。可以规避长时间占用锁。所以要合理的设计batch.size参数。不要发过大的消息。

场景二 非标准尺寸消息的投递

不管怎么样,还是需要处理非标准尺寸的消息,该场景需要尽量规避,因为这个场景需要重新新建ByteBuffer。

           int freeListSize = freeSize() * this.poolableSize;
            // 计算是否有足够的可用量
            if (this.nonPooledAvailableMemory + freeListSize >= size) {
                //需要释放free中的ByteBuffer,然后来分配当前size大小的ByteBuffer
                freeUp(size);
                this.nonPooledAvailableMemory -= size;
            }
            ....
            if (buffer == null)
                return safeAllocateByteBuffer(size);

来看一下freeUp的实现

 //在这里,只是不断的从free中取出ByteBuffer,然后把capacity累加到nonPooledAvailableMemory,已达到满足size
 private void freeUp(int size) {
        while (!this.free.isEmpty() && this.nonPooledAvailableMemory < size)
            //pollLast(),byteBuffer将会变得无root引用,在下次GC时被回收
            this.nonPooledAvailableMemory += this.free.pollLast().capacity();
 }

通过加锁,这里的操作不存在并发问题,也就是说只有满足size以后才会返回。然后再分配出一个不标准的ByteBuffer

场景三 当投递和服务端传输速度不匹配时,会出现阻塞等待的场景。

当投递和传输速度不匹配时,会将缓存池耗光,这时,就需要进行阻塞等待,等待新的空闲的ByteBuffer

int accumulated = 0;
//创建一个新的竞争着
Condition moreMemory = this.lock.newCondition();
try {
    //max.block.ms参数,如果超过,则抛出超时异常,表示投递失败
    long remainingTimeToBlockNs = TimeUnit.MILLISECONDS.toNanos(maxTimeToBlockMs);
    //先进先出双端队列,如果有新的ByteBuffer空闲,会取第一个,然后进行singal
    this.waiters.addLast(moreMemory);
    //就一直阻塞,等待唤醒或者超时
    while (accumulated < size) {
        long startWaitNs = time.nanoseconds();
        long timeNs;
        boolean waitingTimeElapsed;
        try {
            //休眠等待,
            waitingTimeElapsed = !moreMemory.await(remainingTimeToBlockNs, TimeUnit.NANOSECONDS);
        } finally {
            long endWaitNs = time.nanoseconds();
            //这里有一个耗时的监控
            timeNs = Math.max(0L, endWaitNs - startWaitNs);
            this.waitTime.record(timeNs, time.milliseconds());
        }
        //超时异常处理
        if (waitingTimeElapsed) {
            throw new TimeoutException("Failed to allocate memory within the configured max blocking time " + maxTimeToBlockMs + " ms.");
        }
        //减去阻塞等待的时间
        remainingTimeToBlockNs -= timeNs;
        //如果标准尺寸,直接取货走人
       if (accumulated == 0 && size == this.poolableSize && !this.free.isEmpty()) {
            buffer = this.free.pollFirst();
            accumulated = size;
        //如果非标准尺寸,那么还需要确认现有的空闲量是否满足需求,不满足,继续await
        } else {
            freeUp(size - accumulated);
            int got = (int) Math.min(size - accumulated, this.nonPooledAvailableMemory);
            this.nonPooledAvailableMemory -= got;
            accumulated += got;
        }

    }
    //表示成功等待到了数据
    accumulated = 0;
} finally {
    //如果超时异常,需要进行数据补偿
    this.nonPooledAvailableMemory += accumulated;
    this.waiters.remove(moreMemory);
}
}

根据双端队列的先进先出的场景,如果某个时间点出现网络抖动,很容易就会出现大量的发送超时。而且阻塞超时是不包含在重试分支内,相当于直接发送失败了。正常一般会加个本地文件恢复逻辑,然后异步重新发送。来达到不丢数据的目的。

总结

从整个流程来看,如果排除发送和投递速度不匹配的情况,如果批消息大小设置的合理,整个流程基本没有其他开销,都是CPU操作。因此尽量要根据业务数据包的大小来合理的配置batch.size的大小。 如果对数据时序无特别的要求,那可以将max.block.ms的时间设置的小一点,然后同步本地存储发送失败的数据,另外起个线程尝试恢复,这样可以降低为接口RT的影响。防止在服务压力较大时,导致发送阻塞,影响在线接口

void deallocate(ProducerBatch batch) 回收解析

这个函数主要负责回收已经完成使命的byteBuffer缓存。

先看一下调用链

从方法的调用链可以看到,只有两个上层调用链,BufferPool的追踪下去,调用方也是RecordAccumulator,也就是在该类中封装了所有回收的操作,继续跟踪下去,会发现最上层的调用都在Sender类中,基本包含了各类场景下,都保障缓存能够正常回收

来看一下实现

 public void deallocate(ByteBuffer buffer, int size) {
        //对nonPooledAvailableMemory free 加锁控制,防止并发问题
        lock.lock();
        try {
            //如果时额定大小的缓存,则回收利用    
            if (size == this.poolableSize && size == buffer.capacity()) {
                //将buffer清空
                buffer.clear();
                //重新加入到free队列中,
                this.free.add(buffer);
            } else {
                //非标准的,直接放弃,等待GC清理,在分配时重新分配
                this.nonPooledAvailableMemory += size;
            }
            // 先进先出,通知第一个
            Condition moreMem = this.waiters.peekFirst();
            if (moreMem != null)
                moreMem.signal();
        } finally {
            lock.unlock();
        }
    }

在上面的实现中,可以看出,

  • 如果发送的消息体是标准的,可以通过free这个队列达到重复利用的目的,而不是每次重新分配
  • 如果速度不匹配时,存在竞争等待关系时,那整个关系时先进先出。

总结

从上面的解析中,可以看到整个BufferPool的设计,也就是整个kafka发送缓存的设计。这里有个很巧妙的设计就是通过创建额定大小的 ByteBuffer,然后对消息进行批处理,通过队列回收,来达到ByteBuffer重复利用。这个利用对于并发高的应用来说,是非常有效的,可以减少ByteBuffer的新建和回收。 kafka同时也支持(非标准)大消息体的发送。以后在实际缓存的设计中,可以参照这个设计。

最后,整个消息投递到缓存区,最后使用完的缓存区回收的整个实现,分层做的很干净,每个类的责任很清晰,BufferPool只负责分配和回收ByteBuffer,具体什么场景分配,什么场景回收,都通过Accumulator暴露出去。后续在做方案设计时,可以参考