Kafka Producer 内存管理

931 阅读8分钟

Kafka Producer 内存管理

每个框架都有一套自己的内存管理机制 kafka在发送消息的时候 会先将消息压缩 然后通过sender线程批量将消息发送到服务端 底层其实是一个ByteBuffer对象 我们都知道 为ByteBuffer申请内存资源是一个费时费力的操作,就跟我们创建线程一样,不可能来一个请求创建一个线程,一般我们通过线程池来管理我们的线程,这种叫做池化技术,kafka实现了自己的一套池化方案来管理内存,具体类为BufferPool,这篇文章也主要讲解BufferPool这个类的实现

BufferPool 内存管理模型

可将总空间分为两部分 已用空间和可用空间 可用空间又分为非池化空间和池化空间

  • free队列是由ByteBuffer对象组成的ArrayDeque 每个ByteBuffer大小固定为poolableSize,由BufferPool构造函数初始化 默认16384 可以用batch.size设置 totalMemory总空间大小 默认值为33554432 可通过buffer.memory设置
  • 总空间 = 已用空间 + 可用空间
  • 可用空间 = free.size()*poolableSize + nonPooledAvailableMemory; (池化空间+非池化空间)
  • 池化空间也可以理解成已申请未使用的空间 非池化空间可以理解成未申请未使用的空间

BufferPool属性 及重要属性解析


private final long totalMemory;
    //byteBuffer大小
    private final int poolableSize;
    private final ReentrantLock lock;
    //byteBuffer 队列
    private final Deque<ByteBuffer> free;
    //等待中的线程条件变量 
    private final Deque<Condition> waiters;
    /** Total available memory is the sum of nonPooledAvailableMemory and the number of byte buffers in free * poolableSize.  */
    //非池化可用空间大小
    private long nonPooledAvailableMemory;
    private final Metrics metrics;
    private final Time time;
    private final Sensor waitTime;
    private boolean closed;

  • poolableSize:ByteBuffer初始化大小 final修饰 表示这个值不能改变 在事先就要想好该值的大小 对性能有很大影响
  • lock:对多个线程同时申请空间时进行控制
  • free : ByteBuffer队列 只有当大小消息的大小等于poolableSize时才会在内存释放的时候添加回free队列 其余都是直接释放到非池化空间中
  • waiter : 线程顺序执行的保证
  • nonPooledAvailableMemory : 非池化空间大小

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);
 	//省略掉其余不想关代码...
    }

可以看到 nonPooledAvailableMemory 初始化时等于 totalMemory 而free队列初始化为空 这也就满足我们上面的 totalMemory = nonPooledAvailableMemory + free.size()*poolableSize的公式了

allocate方法 解析

声明1 可用空间 = 池化空间+非池化空间 (nonPooledAvailableMemory + free.size()*poolableSize) 声明2 BufferPool都是先扣除掉内存使用量再申请 不是先申请再扣除使用量 在解析allocate方法之前 我们先来看看该方法执行的流程

allocate源代码

因为代码较长 笔者简化了一部分代码

public ByteBuffer allocate(int size, long maxTimeToBlockMs) throws InterruptedException {
		// 1.
        if (size > this.totalMemory){
        	throw new IllegalArgumentException();
        }
        ByteBuffer buffer = null;
        this.lock.lock();
        //省略是否关闭判断逻辑 。。。
        try {
            // 2. 如果大小刚好等于一个ByteBuffer的大小,移除第一个元素  说明发送消息大小了默认值 不会走这条分支
            if (size == poolableSize && !this.free.isEmpty())
            	//3.直接弹出free队列队首元素
                return this.free.pollFirst();
             // 计算池化空间大小
            int freeListSize = free.size() * this.poolableSize;
            //4.判断可用空间是否大于消息长度
            if (this.nonPooledAvailableMemory + freeListSize >= size) {
                //从free队列释放内存到非池化空间(freeUp内部逻辑 )
                freeUp(size);
                this.nonPooledAvailableMemory -= size;
            } else {
                // we are out of memory and will have to block
                
                int accumulated = 0;
                //7.新建线程私有condition 并且添加进阻塞等待变量队列waiters中
                Condition moreMemory = this.lock.newCondition();
                try {
                //计算需要阻塞的时长
                    long remainingTimeToBlockNs = TimeUnit.MILLISECONDS.toNanos(maxTimeToBlockMs);
                    //等待
                    this.waiters.addLast(moreMemory);
                    // loop over and over until we have a buffer or have reserved
              
                    //8.
                    while (accumulated < size) {
                        long startWaitNs = time.nanoseconds();
                        long timeNs;
                        boolean waitingTimeElapsed;
                        try {
                            //8.没有足够的空间  等待其他线程唤醒 返回中断类型 
                            waitingTimeElapsed = !moreMemory.await(remainingTimeToBlockNs, TimeUnit.NANOSECONDS);
                        } finally {
                        //计算阻塞时长 用于后面重新计算下一轮阻塞时间
                            long endWaitNs = time.nanoseconds();
                            timeNs = Math.max(0L, endWaitNs - startWaitNs);
                            recordWaitTime(timeNs);
                        }
                        //9.判断是否是被中断唤醒的  如果不是  那么就表明等待超时  
                        if (waitingTimeElapsed) {
                            直接报出错误      
                            throw new BufferExhaustedException("");
                        }
						//重新计算下一轮需要阻塞的时长
                        remainingTimeToBlockNs -= timeNs;

                      // otherwise allocate memory
                      //11 
                        if (accumulated == 0 && size == this.poolableSize && !this.free.isEmpty()) {
                            /14 /走到这里的逻辑 可用空间小于消息长度 并且有线程申请的时候刚好申请的是poolableSize大小的内存 释放直接释放到free队列 所以这里再进一步检查
                            buffer = this.free.pollFirst();
                            accumulated = size;
                        } else {
                           //12 释放free队列空间  有可能本轮释放的长度还是小于消息大小 所以需要while循环等待其他线程释放内存
                            freeUp(size - accumulated);
                            //取最小值作为当前的可用空间
                            int got = (int) Math.min(size - accumulated, this.nonPooledAvailableMemory);
                         
                            this.nonPooledAvailableMemory -= got;
                            //13.
                            accumulated += got;
                        }
                    }
                    accumulated = 0;
                } finally {
                    // When this loop was not able to successfully terminate don't loose available memory 
                    //防止异常退出 有内存泄漏情况 所以做回滚
                    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;
    }

在分配内存之前 kafka首先会使用lock进行加锁 保证线程安全性

  • 1.首先判断消息的大小是否大于总内存大小 如果大于 直接抛出异常 小于才回进行内存分配
  • 2.判断size是否等于poolableSize并且free队列不为空 注意这里遵循一个原则只有当size等于poolableSize时才会在释放内存时直接将创建的ByteBuffer添加进free队列 否则 都是从非池化空间申请内存创建ByteBuffer 释放时也直接释放回非池化空间。所以这一步的操作如果size==poolableSize直接从free队列弹出队首即可
  • 3.直接从free队列弹出队头元素进行返回即可
  • 4.判断可用空间是否大于消息大小 小于 则表示需要等待其他线程释放内存 大于 则表示可以直接申请内存使用
    1. 由于可用空间足够 所以需要判断池化空间和非池化空间大小 如果非池化空间足够 就不要从free队列释放空间了 这里如果需要free队列释放空间 也是直接释放到非池化空间中 freeUp函数 相当简单
    private void freeUp(int size) {
        while (!this.free.isEmpty() && this.nonPooledAvailableMemory < size)
            //释放free里面内存
            this.nonPooledAvailableMemory += this.free.pollLast().capacity();
    }
  • 6.底层调用ByteBuffer.allocate(size)直接申请内存来使用
  • 7.从lock创建一个condition 于当前线程进行绑定 用于线程的有序执行
  • 8.这里循环执行的原因是因为可能初次释放内存是释放的不够 要等待其他线程发送完成归还内存继续释放
  • 9,10.阻塞等待 有里等待有时间限制 如果不是被其他线程中断 表明执行超时 直接异常退出 发送失败
  • 11 有可能有线程归还了内存 但是刚好归还到了free队列 导致free队列不再为空 所以 在这一步还要再判断是否要从free队列直接获取
  • 12 调用freeUp函数 继续尝试释放free队列内存 如果内存足够 就直接返回
  • 13 重置accumulated 作为循环退出条件 注意 在finally代码块里面如果waiters不为空 唤醒下一个等待的线程继续执行

BufferPool释放内存逻辑

原则 当归还的ByteBuffer大小等于poolableSize的时候 直接将其添加进free队列 实现复用

    public void deallocate(ByteBuffer buffer, int size) {
        lock.lock();
        try {
            if (size == this.poolableSize && size == buffer.capacity()) {
            //直接归还进free队列 实现复用
                buffer.clear();
                this.free.add(buffer);
            } else {
            //归还到非池化空间 GC自动回收ByteBuffer(因为在申请的时候实际创建的是HeapByteBuffer)
                this.nonPooledAvailableMemory += size;
            }
            Condition moreMem = this.waiters.peekFzzzirst();
            if (moreMem != null)
            //唤醒等待内存释放的第一个线程
                moreMem.signal();
        } finally {
            lock.unlock();
        }
    }

总结

可以看到BufferPool只针对特定大小(poolableSize)的ByteBuffer进行管理,对于其它大小的并不会缓存进来。kafka在发送消息的时候 如果发送的消息小于poolableSize 大小时 kafka也会创建poolableSize大小的ByteBuffer 下一个消息会尝试往已创建的ByteBuffer里面追加内容(如果有足够的空间)但是如果在极端情况下 内存利用率还是比较低 比如batch大小默认16k,如果消息以5k、12k间隔发,则内存实际利用率只有(5+12)/(2*16)。

批量管理

batch创建后会逗留linger.ms时间,它集聚该段时间内属于该分区的消息。如果生产速率特别高又或者有超大消息流入很快将分区打满,则实际逗留时间会低于linger.ms。

另一方面,RecordAccumulator挤出前先要做就绪节点检查,挤出动作也只针对leader在这些节点上的分区批次,当节点ready to drain后,可能因为连接或者inflightRequests超限等问题,被从发送就绪列表移除,从而导致这些节点的可发送批次不会被挤出。它们始终占据分区队列的最高挤出优先级,这会导致:

后追加的消息被积压,即使连接恢复后新入的消息也只能等待顺序处理,整体投递延时猛增。 批次占据的内存得不到释放,有可能发生雪崩:因为只有追加没有挤出,问题节点的批次有可能占满全部内存空间导致其他正常节点分区无法为新批次申请空间。 Kafka提供请求超时timeout.ms解决这个问题,从逗留截止开始计算批次超时则被废弃–释放内存空间并从分区队列移除。

理想状况下,单位时间内追入和挤出应该恰好相等且内存被充分使用。长期观察下调好linger.ms、batch.size、timeout.ms、和buffer.memory这几个参数将有助于达到这个目标。