1.概述
转载:【生产者分析四】RecordAccumulator封装消息流程
并且做一定的修改
1.1 整体架构
1.1 RecordAccumulator封装消息流程初探
下面我们来分析下生产者核心流程中RecordAccumulator是如何封装消息的
简图如下,这里有个数据结构大致如下
核心代码KafkaProducer当中doSend方法定义以下内容
/**
* todo: 第七步:把消息放入Accumulator(32M的一个内存)
* 然后由accumulator把消息封装成为一个批次一个批次的去发送。
*/
RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey, serializedValue, interceptCallback, remainingWaitMs);
accumulator.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 ba tch 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.");
/**
* 步骤五:再次尝试把数据写入到批次中
* 代码第一次执行到这里,依然还是失败的,
* 目前只是有内存了,目前还没有创建批次,还没有把内存分配给批次
*/
RecordAppendResult appendResult = tryAppend(timestamp, key, value, callback, dq);
//appendResult 还是等于null
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();
}
}
1.2 生产者源码之CopyOnWriteMap数据结构使用
了解了RecordAccumulator 封装消息的流程后,下面我们来具体分析每一个步骤的详细逻辑和底层设计
核心代码RecordAccumulator当中的append方法
/**
* 步骤一:
* 先根据分区获取该消息所属的队列中
* 如果有已经存在的队列,那么我们就使用存在队列
* 如果队列不存在,那么我们新创建一个队列
*/
Deque<RecordBatch> dq = getOrCreateDeque(tp);
getOrCreateDeque方法实现
private Deque<RecordBatch> getOrCreateDeque(TopicPartition tp) {
//todo: 直接从batches里面获取当前分区对应的存储队列
Deque<RecordBatch> d = this.batches.get(tp);
//我们是以场景驱动的方式,代码第一次执行到这里的话,获取不到队列,该变量d为null
if (d != null)
return d;
//创建出一个新的空队列
d = new ArrayDeque<>();
//把空的队列存入到batches这个map数据结构中
//使用putIfAbsent方法添加键值对,如果map集合中没有该key对应的值,则直接添加,并返回null,
// 如果已经存在对应的值,则依旧为原来的值。
Deque<RecordBatch> previous = this.batches.putIfAbsent(tp, d);
if (previous == null)
return d;
else
//直接返回新的结果
return previous;
}
这里的this.batches其本质就是一个Map数据结构
//TopicPartition 分区-------》 Deque<RecordBatch> 队列
private final ConcurrentMap<TopicPartition, Deque<RecordBatch>> batches;
在RecordAccumulator构造方法中给batches 进行初始化
//todo: CopyOnWriteMap这样的一个数据类型
//这个数据结构在jdk里面是没有,是kafka自己设计的
//这个数据结构设计的很好,有了这个数据结构,整体的提升了封装批次的这个流程性能
this.batches = new CopyOnWriteMap<>();
1.2.1 CopyOnWriteMap
下面我们来学习下CopyOnWriteMap这种数据结构的底层使用
CopyOnWriteMap类
public class CopyOnWriteMap<K, V> implements ConcurrentMap<K, V> {
/**
* 核心的变量就是一个map
*
* 这个map有个特点,它的修饰符是volatile关键字
* 作用;在多线程的情况下,如果这个map的值发生变化,其他线程是可见的
*/
private volatile Map<K, V> map;
/**
* (1) 整个方法是使用synchronized去修饰的,它是线程安全的
* 即使加了锁,这段代码的性能依然很好,因为里面都是纯内存操作的
*
* (2) 读数据的流程和写数据的流程分离了,这里采用了读写分离的设计思想
* 读操作和写操作是互不影响
* 所以我们读数据的操作就是线程安全的
*
* (3) 最后把之赋值给了map,map是使用了volatile去修饰
* 说明这个map是具有可见性的,如果map的值发生了变化,进行get操作的时候,是可以感知到的
*
*/
@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);
//赋值给map
this.map = Collections.unmodifiableMap(copy);
return prev;
}
/**
* 没有加锁,读取数据的时候性能很高(高并发场景下,性能肯定很好)
* 并且是线程安全的(由于采用了读写分离的思想)
*/
@Override
public V get(Object k) {
return map.get(k);
}
@Override
public synchronized V putIfAbsent(K k, V v) {
//如果k不存在,就调用内部的put方法
if (!containsKey(k))
return put(k, v);
else
//如果存在就返回结果
return get(k);
}
}
1.3 生产者源码之数据写入到对应的批次(分段加锁)
前面已经创建好了队列,但是队列内部目前来说还是没有批次,也就是说构建好一个空的队列,下面我们来学习数据写如何入到对应的批次中
核心代码RecordAccumulator核心类当中的append方法
synchronized (dq) {
if (closed)
throw new IllegalStateException("Cannot send after the producer is closed.");
/**
* 步骤二:
* 尝试向队列里面的批次中添加数据
* 这里需要说明一下:目前我们只是有了队列,数据是存储在批次中(批次对象是需要分配内存的)
* 目前还没有分配到内存,所以添加数据会失败
*/
RecordAppendResult appendResult = tryAppend(timestamp, key, value, callback, dq);
//第一次进来appendResult返回结果为null
if (appendResult != null)
return appendResult;
}
tryAppend方法分析RecordAccumulator核心类当中的append方法
/**
* If `RecordBatch.tryAppend` fails (i.e. the record batch is full), close its memory records to release temporary
* resources (like compression streams buffers).
*/
private RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Callback callback, Deque<RecordBatch> deque) {
//todo: 获取到队列中最后一个批次
RecordBatch last = deque.peekLast();
//todo: 第一次进来是没有批次的,if不会执行,直接返回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);
}
//todo: 第一次直接返回null
return null;
}
由于之前我们已经分析过了,由于队列中没有批次,第一次添加数据会失败,然后继续执行后面的逻辑,计算一个批次的大小------->根据批次大小分配内存--------->再次尝试把数据写入到批次中-------->根据内存大小封装批次
,此时此刻,队列有了,队列中的批次批次也构建好了,最后第三次把数据写入到批次中会执行成功。
1.3.1 分析
这里发现代码中会出现申请内存(free.allocate)和释放内存(free.deallocate)的操作,那么什么时候会去申请内存,什么时候会去释放内存,还有在代码中会看到有些代码中使用了synchronized进行加锁,有些并没有实现synchronized进行加锁。
这里其本质就是用到了分段加锁的设计思想,该加锁的地方就加锁,不该加锁的地方就不加锁,好处就是为了在支持高并发的场景下,既要保证线程的安全,还有尽可能的提升代码的性能。
1.4 生产者源码之内存池设计
给批次分配对应大小的内存,这是用到了内存池的设计,通过内存池的使用,可以实现内存的复用,不需要进行频繁的GC。下面我们来学习下kafka底层高效的内存池的设计思路。
1.4.1 核心代码
RecordAccumulator.append方法中的核心代码逻辑
/**
* 步骤四:根据批次大小分配内存
*/
ByteBuffer buffer = free.allocate(size, maxTimeToBlock);
free.allocate申请内存方法分析
public ByteBuffer allocate(int size, long maxTimeToBlockMs) throws InterruptedException {
//todo: 如果要申请的内存大小超过了 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
//todo: poolableSize就是一个批次的大小,默认就是16KB
//如果size我们要申请的批次大小正好等于一个批次默认的大小,并且内存池不为空
//todo:代码第一次进来的话,内存池中是没有内存的,这里是获取不到
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
//todo: 内存块的个数* 批次的大小=内存池的内存大小
int freeListSize = this.free.size() * this.poolableSize;
/**
* size是我们要申请的内存
* this.availableMemory + freeListSize 目前可用的总内存
*/
if (this.availableMemory + freeListSize >= size) {
// we have enough unallocated or pooled memory to immediately
// satisfy the request
freeUp(size);
//todo: 进行内存扣减
this.availableMemory -= size;
lock.unlock();
//todo: 直接分配内存
return ByteBuffer.allocate(size);
} else {
// we are out of memory and will have to block
/**
* 还有一种情况是,比如内存池中还剩10K的内存,但是需要申请32K的内存
*/
//统计分配的内存
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 {
//todo:等待别人释放内存,如果这儿的代码是等待wait操作
//假设其他线程归还了内存,那么这里的代码就可以被唤醒,代码就继续往下执行
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
//todo: 再次尝试看看内存池中是否有内存了,如果有,并且你申请的内存大小就是一个批次的大小
if (accumulated == 0 && size == this.poolableSize && !this.free.isEmpty()) {
// just grab a buffer from the free list
//todo: 这里就是可以直接获取到内存
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);
//todo: 可以给你分配的内存
int got = (int) Math.min(size - accumulated, this.availableMemory);
//todo: 内存扣减
this.availableMemory -= got;
//todo: 统计已经分配的内存大小
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();
}
}
free.deallocate释放内存方法分析
/**
* 步骤五:再次尝试把数据写入到批次中
* 代码第一次执行到这里,依然还是失败的,
* 目前只是有内存了,目前还没有创建批次,还没有把内存分配给批次
*/
RecordAppendResult appendResult = tryAppend(timestamp, key, value, callback, dq);
//appendResult 还是等于null
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;
}
public void deallocate(ByteBuffer buffer, int size) {
//加锁
lock.lock();
try {
//todo:需要申请的内存等于一个批次的内存大小,就把内存归还给内存池
//该判断可以有效的提高内存的利用率,等于批次大小的内存直接归还内存池利用率才是最高的
if (size == this.poolableSize && size == buffer.capacity()) {
//todo: 清空数据
buffer.clear();
//todo:内存放入到内存池中
this.free.add(buffer);
} else {
//todo:否则不相等,就直接归还给可用的内存中
this.availableMemory += size;
}
Condition moreMem = this.waiters.peekFirst();
if (moreMem != null)
//todo: 内存释放后,需要唤醒处于wait等待内存的线程----> !moreMemory.await(remainingTimeToBlockNs, TimeUnit.NANOSECONDS);
moreMem.signal();
} finally {
lock.unlock();
}
}