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的压力。