5.4RecordAccumulator封装消息流程

5.4.1RecordAccumulator封装消息流程图

接着我们来看关键流程,步骤七

看代码之前我们先整理出流程图,根据流程图先来捋清思路
在这里插入图片描述
1.先通过分区器计算出来分区号

2.然后封装出来TopicPartition对象

3.之前我们提到过一个缓存中的队列对应一个分区

4.每个队列里面会有一个一个的batch(16k)

5.这里使用了一个内存池,可以重复使用内存,避免造成浪费

5.4.2RecordAccumulator封装消息大体流程

 /**
             * 步骤七:
             * 把消息放入accumulator(32M的一个内存)
             * 然后由accumulator把消息封装成为一个批次一个批次的去发送
             */
            RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey, serializedValue, interceptCallback, remainingWaitMs);

跟进来看append方法

public RecordAppendResult append(TopicPartition tp,
                                     long timestamp,
                                     byte[] key,
                                     byte[] value,
                                     Callback callback,
                                     long maxTimeToBlock) throws InterruptedException {
        // We keep track of the number of appending thread to make sure we do not miss batches in
        // abortIncompleteBatches().
        appendsInProgress.incrementAndGet();
        try {
            // check if we have an in-progress batch
            /**
             * 步骤一:先根据分区找到应该插入到哪一个队列里面
             * 如果有已经存在的队列,那么就使用已经存在的队列
             * 如果队列不存在,那么就新创建一个队列
             * 
             */
            Deque<RecordBatch> dq = getOrCreateDeque(tp);
            synchronized (dq) {
                if (closed)
                    throw new IllegalStateException("Cannot send after the producer is closed.");
                /**
                 * 尝试往队列里面添加数据
                 * 
                 * 一开始添加数据肯定是失败的,目前只是有了队列
                 * 数据是需要存储在批次对象里面的,所以按照场景驱动的方式
                 * 代码第一次执行到这其实是不成功的
                 */
                RecordAppendResult appendResult = tryAppend(timestamp, key, value, callback, dq);
                if (appendResult != null)
                    return appendResult;
            }

            // we don't have an in-progress record batch try to allocate a new batch
            /**
             * 步骤三:计算一个批次的大小
             * 在消息的大小和批次的大小之间取一个最大值,用这个值作为当前这个批次的大小
             * 因为有可能一个消息的大小比一个设定好的批次的大小还要大。
             * 默认一个批次16k
             * 所以在这要注意一下
             * 如果生产者发送数据的时候,如果消息的大小都是超过16k的
             * 说明一条消息就是一个批次,也就是说消息是一条一条的发送出去的
             * 这样的话,批次这个概念就没有意义了
             * 所以要注意合理设置批次大小
             */
            int size = Math.max(this.batchSize, Records.LOG_OVERHEAD + Record.recordSize(key, value));
            log.trace("Allocating a new {} byte message buffer for topic {} partition {}", size, tp.topic(), tp.partition());
            /**
             * 步骤四:根据批次的大小去分配内存
             */
            ByteBuffer buffer = free.allocate(size, maxTimeToBlock);
            synchronized (dq) {
                // Need to check if producer is closed again after grabbing the dequeue lock.
                if (closed)
                    throw new IllegalStateException("Cannot send after the producer is closed.");
                /**
                 * 步骤五:尝试吧数据写入到批次里
                 * 代码第一次执行到这的时候 依然还是失败的
                 * appendResult这个值还是等于null
                 * 目前虽然已经分配了内存,但是还没有创建批次
                 * 那往批次里面谢数据,还是不能写的
                 * 
                 */
                RecordAppendResult appendResult = tryAppend(timestamp, key, value, callback, dq);
                if (appendResult != null) {
                    // Somebody else found us a batch, return the one we waited for! Hopefully this doesn't happen often...
                    free.deallocate(buffer);
                    return appendResult;
                }
                /**
                 * 步骤六:
                 * 根据内存大小封装批次
                 */
                MemoryRecords records = MemoryRecords.emptyRecords(buffer, compression, this.batchSize);
                RecordBatch batch = new RecordBatch(tp, records, time.milliseconds());
                //尝试往这个批次里面写数据,这时代码会执行成果
                FutureRecordMetadata future = Utils.notNull(batch.tryAppend(timestamp, key, value, callback, time.milliseconds()));
                /**
                 * 步骤七:把这个批次放入到队列的队尾
                 */
                dq.addLast(batch);
                incomplete.add(batch);
                return new RecordAppendResult(future, dq.size() > 1 || batch.records.isFull(), true);
            }
        } finally {
            appendsInProgress.decrementAndGet();
        }
    }

这里呢,我们就看到了封装消息的一个大体流程,接下来我们就要去看一些细节的实现。

5.4.3copyonwritemap数据结构使用

我们接着上一节一个步骤一个步骤的来看

 /**
             * 步骤一:先根据分区找到应该插入到哪一个队列里面
             * 如果有已经存在的队列,那么就使用已经存在的队列
             * 如果队列不存在,那么就新创建一个队列
             *
             */
            Deque<RecordBatch> dq = getOrCreateDeque(tp);

点进来看一下

    private Deque<RecordBatch> getOrCreateDeque(TopicPartition tp) {
        //直接从batches里面获取当前分区对应的队列
        Deque<RecordBatch> d = this.batches.get(tp);
        //如果是场景驱动的方式,代码第一次执行到这
        //是获取不到队列的,这个变量的值为null
        if (d != null)
            return d;
        //代码继续执行,创建出来一个新的空队列
        d = new ArrayDeque<>();
        //把这个空的队列存入batches这个数据结构里面
        Deque<RecordBatch> previous = this.batches.putIfAbsent(tp, d);
        if (previous == null)
            return d;
        else
            //直接返回新的结果
            return previous;
    }

这里所有队列是放在batches这个数据结构里面去的,batches是kafka自己设计的一个数据结构,我们可以重点关注一下

我们可以具体看一下,它的数据类型

 private final ConcurrentMap<TopicPartition, Deque<RecordBatch>> batches;

这里可以看到其实就是一个map,key,value的形式

TopicPartition:分区

Deque:队列

RecordBatch:批次

我们来看一下它的初始化方法

//CopyOnWriteMap这个数据结构在jdk里面是没有的,是kafka自己设计的
        //因为有了这个数据结构,整体提升了封装批次这个流程的性能
        this.batches = new CopyOnWriteMap<>();

点进来看一下

public class CopyOnWriteMap<K, V> implements ConcurrentMap<K, V> {
    /**
     * 核心变量就是一个map
     * 这个map有一个特点,它的修饰符是volatile
     * 在多线程时,具有可见性
     */

    private volatile Map<K, V> map;

因为是map我们先来看一下它的put方法

  @Override
    public synchronized V putIfAbsent(K k, V v) {
        //如果传进来的key不存在
        if (!containsKey(k))
            //那就调用内部的put方法
            return put(k, v);
        else
            //返回结果
            return get(k);
    }

    /**
     * 整个方法是用synchronized修饰的,说明这个方法是线程安全的
     * 因为是基于内存操作,所以即使加了锁,性能依然很好
     *
     * 这里使用了读写分离的设计思想
     * 读操作和写操作是相互不影响的
     * 所以我们读数据的操作就是线程安全的
     *
     * 最后把值赋给了map,因为map用volatile修饰
     * 说明这个map具有可见性,如果get数据的时候,这的值发生了变化,是可以感知到的
     * @param k
     * @param v
     * @return
     */
    @Override
    public synchronized V put(K k, V v) {
        //新的内存空间
        Map<K, V> copy = new HashMap<K, V>(this.map);
        //插入数据
        V prev = copy.put(k, v);
        this.map = Collections.unmodifiableMap(copy);
        return prev;
    }

接下来我们看一下get方法


    /**
     * get这里没有加锁,读取数据的时候性能很高(高并发场景下,性能很高)
     * 因为采用了读写分离的设计,所以是线程安全的
     * 
     * @param k
     * @return
     */
    @Override
    public V get(Object k) {
        return map.get(k);
    }

总结:

1.这个数据结构是在高并发场景下线程安全的

2.采用读写分离的思想设计的数据结构

每次写数据的时候都开辟新的内存空间

所以会有一个缺点,就是插入数据的时候,会比较耗费内存空间。

3.这个数据结构适合写少读多的场景

对于batches来说,它面对的就是读多写少的场景

读数据:

每生产一条消息,都会从batches里面读取数据

如果每秒生产10条数据,就意味着要读取10w次

肯定是高并发

写数据:

如果有100个分区,那么就会插入100次数据

并且队列只需要插入一次就可以了

这是一个低频的操作

5.4.4把数据写到对应的批次

现在经过步骤一,我们已经获取了一个空的队列

接下来我们看一下步骤二

    throw new IllegalStateException("Cannot send after the producer is closed.");
                /**
                 * 步骤二:
                 * 尝试往队列里面添加数据
                 *
                 * 一开始添加数据肯定是失败的,目前只是有了队列
                 * 数据是需要存储在批次对象里面的,所以按照场景驱动的方式
                 * 代码第一次执行到这其实是不成功的
                 */
                RecordAppendResult appendResult = tryAppend(timestamp, key, value, callback, dq);
                if (appendResult != null)
                    return appendResult;
            }

跟进去看一下

    private RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Callback callback, Deque<RecordBatch> deque) {
        //首先要获取到队列里面的一个批次
        RecordBatch last = deque.peekLast();
        //第一次进来是没有批次的,所以last为null
        if (last != null) {
            FutureRecordMetadata future = last.tryAppend(timestamp, key, value, callback, time.milliseconds());
            if (future == null)
                last.records.close();
            else
                return new RecordAppendResult(future, deque.size() > 1 || last.records.isFull(), false);
        }
        //返回null
        return null;
    }

所以第一次进来的时候appendResult的值为null

接下来会到步骤三

 /**
             * 步骤三:计算一个批次的大小
             * 在消息的大小和批次的大小之间取一个最大值,用这个值作为当前这个批次的大小
             * 因为有可能一个消息的大小比一个设定好的批次的大小还要大。
             * 默认一个批次16k
             * 所以在这要注意一下
             * 如果生产者发送数据的时候,如果消息的大小都是超过16k的
             * 说明一条消息就是一个批次,也就是说消息是一条一条的发送出去的
             * 这样的话,批次这个概念就没有意义了
             * 所以要注意合理设置批次大小
             */
            int size = Math.max(this.batchSize, Records.LOG_OVERHEAD + Record.recordSize(key, value));

计算出来一个批次的大小

接下来到步骤四

/**
             * 步骤四:根据批次的大小去分配内存
             */
            ByteBuffer buffer = free.allocate(size, maxTimeToBlock);

根据批次大小去分配内存,这里会有一个内存池

再接下来回到步骤五

 /**
                 * 步骤五:尝试吧数据写入到批次里
                 * 代码第一次执行到这的时候 依然还是失败的
                 * appendResult这个值还是等于null
                 * 目前虽然已经分配了内存,但是还没有创建批次
                 * 那往批次里面谢数据,还是不能写的
                 *
                 */
                RecordAppendResult appendResult = tryAppend(timestamp, key, value, callback, dq);
                if (appendResult != null) {
                    // Somebody else found us a batch, return the one we waited for! Hopefully this doesn't happen often...
                    free.deallocate(buffer);
                    return appendResult;
                }

会再次去尝试把数据写到批次的操作,还是来到tryAppend方法,与上一次一样,还是没有批次,所以appendResult依然为null

然后到步骤六

/**
                 * 步骤六:
                 * 根据内存大小封装批次
                 */
                MemoryRecords records = MemoryRecords.emptyRecords(buffer, compression, this.batchSize);
                RecordBatch batch = new RecordBatch(tp, records, time.milliseconds());
                //尝试往这个批次里面写数据,这时代码会执行成果
                FutureRecordMetadata future = Utils.notNull(batch.tryAppend(timestamp, key, value, callback, time.milliseconds()));

这次再次尝试写数据

public FutureRecordMetadata tryAppend(long timestamp, byte[] key, byte[] value, Callback callback, long now) {
        if (!this.records.hasRoomFor(key, value)) {
            return null;
        } else {
            //TODO 往批次里面去写数据
            long checksum = this.records.append(offsetCounter++, timestamp, key, value);
            this.maxRecordSize = Math.max(this.maxRecordSize, Record.recordSize(key, value));
            this.lastAppendTime = now;
            FutureRecordMetadata future = new FutureRecordMetadata(this.produceFuture, this.recordCount,
                                                                   timestamp, checksum,
                                                                   key == null ? -1 : key.length,
                                                                   value == null ? -1 : value.length);
            if (callback != null)
                thunks.add(new Thunk(callback, future));
            this.recordCount++;
            return future;
        }
    }

这次写数据成功了因为已经有了批次

紧接着步骤七

  /** 步骤七:把这个批次放入到队列的队尾
                 */
                dq.addLast(batch);

就是把批次放入队列的队尾

以上说的是代码第一次进来的流程

如果代码第二次进来,还是会往批次里面写数据

再次进入tryAppend方法,他就会执行下面的代码,因为队尾已经有批次了,他就会像这个批次里面写数据

但是这里我们发现在步骤二,五,六有三次都尝试去写数据

         /**
                 * 步骤五:尝试吧数据写入到批次里
                 * 代码第一次执行到这的时候 依然还是失败的
                 * appendResult这个值还是等于null
                 * 目前虽然已经分配了内存,但是还没有创建批次
                 * 那往批次里面谢数据,还是不能写的
                 *
                 */
                RecordAppendResult appendResult = tryAppend(timestamp, key, value, callback, dq);
                if (appendResult != null) {
                   //释放内存
                    free.deallocate(buffer);
                    return appendResult;
                }

在步骤五这里如果步骤二尝试成功了,这里就不为null,那么就会去进行释放内存,这里具体有什么作用呢?

我们去看整个append方法可以发现它的一个结构

append(){
步骤一
sync对象锁{
步骤二
}
步骤三
步骤四
sunc对象锁{
步骤五
步骤六
步骤七
}
}

这里使用了分段加锁的思想

该加锁的地方加锁,不该加锁的地方不要加锁

在高并发的情况下,尽可能的提升代码的性能

这个方法,肯定是超高并发被调用的

高并发调用的情况下,我们必须要保证线程安全

最简单的做法是在方法加锁,但是这样性能会下降

Deque<RecordBatch> dq = getOrCreateDeque(tp);

我们来看步骤一这里,主要是对batches进行操作,因为我们介绍过这里本身就是线程安全的,所以就不要在这个操作加锁了。

上面我们提到的释放内存就是把内存还给内存池了。

5.4.5内存池的设计

在这里插入图片描述
我们可以回到步骤四去看一下

/**
             * 步骤四:根据批次的大小去分配内存
             */
            ByteBuffer buffer = free.allocate(size, maxTimeToBlock);

跟进来

//池子就是一个队列,队列里面放的就是一块一块的内存
    private final Deque<ByteBuffer> free;
public ByteBuffer allocate(int size, long maxTimeToBlockMs) throws InterruptedException {
        //如果你想要申请的内存的大小超过了32M,就会报异常
        if (size > this.totalMemory)
            throw new IllegalArgumentException("Attempt to allocate " + size
                                               + " bytes, but there is a hard limit of "
                                               + this.totalMemory
                                               + " on memory allocations.");

        //加锁代码
        this.lock.lock();
        try {
            // check if we have a free buffer of the right size pooled
            //poolableSize代表的就是一个批次的大小,默认16k
            //如果这次申请的批次的大小等于我们设定好的一个批次的大小
            //并且内存池不为空,那么就直接从内存池里面获取一个内存块就可以使用了
            //类似于连接池

            //第一次进来的时候,内存池里面是没有内存的,所以这获取不到内存
            if (size == poolableSize && !this.free.isEmpty())
                return this.free.pollFirst();

            // now check if the request is immediately satisfiable with the
            // memory on hand or if we need to block
            //内存的个数 * 批次的大小 = free的大小
            int freeListSize = this.free.size() * this.poolableSize;
            //size:是这次要申请的内存
            //this.availableMemory + freeListSize目前可用的总内存
            //this.availableMemory + freeListSize 目前可用的总内存大于要申请的内存
            if (this.availableMemory + freeListSize >= size) {
                // we have enough unallocated or pooled memory to immediately
                // satisfy the request
                freeUp(size);
                //进行内存扣减
                this.availableMemory -= size;
                lock.unlock();
                //直接分配内存
                return ByteBuffer.allocate(size);
            } else {
                //还有一种情况是我们要申请的内存比目前可用的内存大

                // we are out of memory and will have to block
                //统计分配的内存
                int accumulated = 0;
                ByteBuffer buffer = null;
                Condition moreMemory = this.lock.newCondition();
                long remainingTimeToBlockNs = TimeUnit.MILLISECONDS.toNanos(maxTimeToBlockMs);
                //等待别人去释放内存
                this.waiters.addLast(moreMemory);
                // loop over and over until we have a buffer or have reserved
                // enough memory to allocate one
                /**
                 * 总的分配思路,可能一下子分配不了那么大的内存,但是可以先分配一点
                 */
                //如果分配的内存还是没有要申请的内存的大小大
                //内存池就会一直一点一点的去分配
                //等着别人释放内存
                while (accumulated < size) {
                    long startWaitNs = time.nanoseconds();
                    long timeNs;
                    boolean waitingTimeElapsed;
                    try {
                        //在等待,等待别人释放内存
                        //如果有人释放内存,肯定得去唤醒这的代码
                        waitingTimeElapsed = !moreMemory.await(remainingTimeToBlockNs, TimeUnit.NANOSECONDS);
                    } catch (InterruptedException e) {
                        this.waiters.remove(moreMemory);
                        throw e;
                    } finally {
                        long endWaitNs = time.nanoseconds();
                        timeNs = Math.max(0L, endWaitNs - startWaitNs);
                        this.waitTime.record(timeNs, time.milliseconds());
                    }

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

                    remainingTimeToBlockNs -= timeNs;
                    // check if we can satisfy this request from the free list,
                    // otherwise allocate memory
                    //再次尝试看一下,内存池里面有没有数据了
                    //如果内存池里面有数据
                    //并且申请的内存大小就是一个批次的大小
                    if (accumulated == 0 && size == this.poolableSize && !this.free.isEmpty()) {
                        // just grab a buffer from the free list
                        //这就可以直接获取到内存
                        buffer = this.free.pollFirst();
                        accumulated = size;
                    } else {
                        // we'll need to allocate memory, but we may only get
                        // part of what we need on this iteration
                        freeUp(size - accumulated);
                        //可以分配的内存
                        int got = (int) Math.min(size - accumulated, this.availableMemory);
                        //做内存扣减
                        this.availableMemory -= got;
                        //累加已经分配了多少内存
                        accumulated += got;
                    }
                }

                // remove the condition for this thread to let the next thread
                // in line start getting memory
                Condition removed = this.waiters.removeFirst();
                if (removed != moreMemory)
                    throw new IllegalStateException("Wrong condition: this shouldn't happen.");

                // signal any additional waiters if there is more memory left
                // over for them
                if (this.availableMemory > 0 || !this.free.isEmpty()) {
                    if (!this.waiters.isEmpty())
                        this.waiters.peekFirst().signal();
                }

                // unlock and return the buffer
                lock.unlock();
                if (buffer == null)
                    return ByteBuffer.allocate(size);
                else
                    return buffer;
            }
        } finally {
            if (lock.isHeldByCurrentThread())
                lock.unlock();
        }
    }

我们可以再看看释放内存的操作,这个操作在步骤五,我们看一下细节

public void deallocate(ByteBuffer buffer, int size) {
        lock.lock();
        try {
            //如果还回来的内存的大小等于一个批次的大小
            if (size == this.poolableSize && size == buffer.capacity()) {
                //内存里面的东西清空
                buffer.clear();
                //把内存放入到内存池
                this.free.add(buffer);
            } else {
                this.availableMemory += size;
            }
            Condition moreMem = this.waiters.peekFirst();
            if (moreMem != null)
                //释放了内存(还了内存以后)
                //都会唤醒等待内存的线程
                moreMem.signal();
        } finally {
            lock.unlock();
        }
    }

总的来说,内存池就是为了提升内存的复用率,减少内存的创建销毁时间,同时也减轻了GC的压力。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值