前言
本文是基于Kafka 2.7版本,参考《Apache Kafka源码剖析》这本书做的学习笔记
概要
先从一个宏观的角度来看producer的发送流程,上图分为两个线程,一个是业务线程(主线程),另一个是Sender线程
业务线程
- ProducerInterceptors对message进行过滤或者修改(很少使用)
- Serializer对message的key和value进行序列化
- Partitioner根据策略为message选择合适的partition
- 将message封装成ProducerRecord写入到RecordAccumulator中暂存,RecordAccumulator对象中维护了多个队列,可以看做是message的缓冲区,用来实现message的批量发送
Sender线程
- Sender线程从RecordAccumulator中批量获取message数据,构建ClientRequest
- 将构造好的ClientRequest交给NetworkClient客户端发送
- NetworkClient客户端将请求放入KafkaChannel的缓存
- NetworkClient执行网络IO,完成请求的发送
- NetworkClient收到响应,调用ClientRequest的回调函数,最终触发每个message上注册的回调函数
Metadata
在我们平时使用Kafka发送message的时候,我们只明确指定了message要写入哪个topic,并没有明确指定要写入的partition,但是同一个topic的partition可能位于kafka的不同broker上,所以producer需要明确地知道该topic下所有partition的元信息(即所在broker的IP、端口等信息),这样才能与partition所在broker建立网络连接并发送message,所以Kafka客户端维护了一套集群元数据,使用Node、TopicPartition、PartitionInfo三个类来记录集群元数据的信息
- Node 表示 Kafka 集群中的一个节点,其中维护了节点的 host、ip、port 等基础信息
-
TopicPartition 用来抽象一个 topic 中的的一个 partition,其中维护 topic 的名称以及 partition 的编号信息
-
PartitionInfo 用来抽象一个 partition 的信息,其中
- leader 字段记录了 leader replica 所在节点的 id
- replica 字段记录了全部 replica 所在的节点信息
- inSyncReplicas 字段记录了ISR集合中所有replica 所在的节点信息
通过这三个类的组合,我们可以完整表示出KafkaProducer需要的集群元数据。这些元数据保存在了Cluster这个类中,并按照不同的映射方式进行存放,方便查询,下面看看Cluster这个类的核心字段
public final class Cluster {
// Kafka集群节点信息列表,其中维护了节点的ip、host、port等信息
private final List<Node> nodes;
private final Set<String> unauthorizedTopics;
private final Set<String> invalidTopics;
private final Set<String> internalTopics;
// Kafka集群中controller所在的节点
private final Node controller;
// TopicPartition:用来抽象一个topic中的一个partition,其中维护topic的名称以及partition的编号信息
// PartitionInfo:用来抽象一个partition的信息,leader字段记录了leader replica所在的节点
// replicas记录了全部replica的节点信息,inSyncReplicas记录了ISR集合中所有replica所在的节点信息
private final Map<TopicPartition, PartitionInfo> partitionsByTopicPartition;
// 以下Map都是为了做冗余
// 根据topic查询其下partition的信息
private final Map<String, List<PartitionInfo>> partitionsByTopic;
private final Map<String, List<PartitionInfo>> availablePartitionsByTopic;
// 根据nodeId查询落在其上的partition
private final Map<Integer, List<PartitionInfo>> partitionsByNode;
// brokerId与Node节点之间对应关系
private final Map<Integer, Node> nodesById;
// 维护topic名称和唯一标识
private final ClusterResource clusterResource;
}
Metadata中封装了Cluster对象,并保存Cluster数据的最后更新时间、版本号、是否需要更新等信息,下面看看Metadata这个类的核心字段
public class Metadata implements Closeable {
// 两次更新元数据请求的最小时间差,默认100ms。这是为了防止更新操作过于频繁而造成网络阻塞和服务端压力
// 一般涉及到重试操作,都需要添加这种退避间隔,对下游系统的保护
private final long refreshBackoffMs;
// 元数据的失效时间,也就是需要更新元数据的时间间隔,默认5分钟
private final long metadataExpireMs;
// 当完全更新元数据的时候,会递增这个版本号
private int updateVersion; // bumped on every metadata response
// 当新topic增加的时候,会递增这个版本号
private int requestVersion; // bumped on every new topic addition
// 最近一次尝试更新元数据的时间戳
private long lastRefreshMs;
// 最近一次成功更新元数据的时间戳
private long lastSuccessfulRefreshMs;
// 更新元数据失败的异常信息
private KafkaException fatalException;
// topic的信息
private Set<String> invalidTopics;
private Set<String> unauthorizedTopics;
// 元数据缓存,更新的元数据都在MetadataCache中存储
private MetadataCache cache = MetadataCache.empty();
// 元数据是否需要全部更新
private boolean needFullUpdate;
// 元数据是否需要部分更新
private boolean needPartialUpdate;
// 当元数据更新的时候,会触发我们自定义的clusterResourceListeners监听器
private final ClusterResourceListeners clusterResourceListeners;
private boolean isClosed;
// 为每个partition记录一个epoch,当发生leader replica切换的时候,对应的epoch就会增加
// 此时,我们就需要更新元数据
private final Map<TopicPartition, Integer> lastSeenLeaderEpochs;
}
KafkaProducer有一个比较重要的方法waitOnMetadata跟Metadata有交互,负责触发Kafka集群元数据更新,并阻塞主线程等待更新完毕
private ClusterAndWaitTime waitOnMetadata(String topic, Integer partition, long nowMs, long maxWaitMs) {
// 获取MetadataCache当前缓存的Cluster对象
Cluster cluster = metadata.fetch();
if (cluster.invalidTopics().contains(topic))
throw new InvalidTopicException(topic);
// 更新元数据缓存,重置到期时间
metadata.add(topic, nowMs);
// 从partitionsByTopic集合中获取目标topic的partition数量
Integer partitionsCount = cluster.partitionCountForTopic(topic);
// 这里有两个判断
// 1.有缓存的元数据
// 2.消息未指定发送到的分区或者消息要发送到的分区在已知分区的范围内
// 满足以上两个条件则直接返回元数据,等待时间为0
if (partitionsCount != null && (partition == null || partition < partitionsCount))
return new ClusterAndWaitTime(cluster, 0);
// 剩余等待时间
long remainingWaitMs = maxWaitMs;
long elapsed = 0;
// 这是while循环,直到请求到元数据
// 或者请求时间超过了maxWaitMs
do {
if (partition != null) {
log.trace("Requesting metadata update for partition {} of topic {}.", partition, topic);
} else {
log.trace("Requesting metadata update for topic {}.", topic);
}
// 更新元数据缓存,重置到期时间,这是一个while循环,elapsed有可能在循环中叠加
metadata.add(topic, nowMs + elapsed);
// 更新获取当前updateVersion,并设置相应标识,尽快触发元数据更新
int version = metadata.requestUpdateForTopic(topic);
// 唤醒Sender线程,由Sender线程去完成元数据更新
sender.wakeup();
try {
// 阻塞等待元数据更新,停止阻塞的条件是:更新后的updateVersion大于当前version,超时的话直接抛出异常
// 注意,此处将剩余等待时间作为参数传了进去
metadata.awaitUpdate(version, remainingWaitMs);
} catch (TimeoutException ex) {
throw new TimeoutException(
String.format("Topic %s not present in metadata after %d ms.",
topic, maxWaitMs));
}
cluster = metadata.fetch();
// time.milliseconds()为System.currentTimeMillis(),当前系统时间
elapsed = time.milliseconds() - nowMs;
// 获取元数据的时间超过最长等待时间,直接抛出异常
if (elapsed >= maxWaitMs) {
throw new TimeoutException(partitionsCount == null ?
String.format("Topic %s not present in metadata after %d ms.",
topic, maxWaitMs) :
String.format("Partition %d of topic %s with partition count %d is not present in metadata after %d ms.",
partition, topic, partitionsCount, maxWaitMs));
}
metadata.maybeThrowExceptionForTopic(topic);
// 更新剩余等待时间
remainingWaitMs = maxWaitMs - elapsed;
// 获取partition数
partitionsCount = cluster.partitionCountForTopic(topic);
} while (partitionsCount == null || (partition != null && partition >= partitionsCount));
return new ClusterAndWaitTime(cluster, elapsed);
}
Metadata内部还做了元数据的缓存,MetadataCache、Metadata、Cluster、Node、TopicPartition、PartitionInfo的关系如下
本地调试看看这几个的数据长什么样子
Partitioner
producer发送message的时候,只指定了topic,没有指定partition,但是在实际存放的时候,需要知道这条消息要放到哪个partition,这里有两种情况,在有的业务场景中,需要控制消息到合适的partition,比如业务逻辑对消息的前后顺序有严格的要求,而有的业务场景,不关心消息对partition的选择,消息可以到任意一个partition,Kafka在这两方面都支持得很好,关于partition的选择,Kafka内部定义了一个Partitioner的接口,并内置了三种实现,默认是使用DefaultPartitioner,如下图
DefaultPartitioner具体分为三种情况
- 指明partition的情况下,直接将指明的值作为partition值
- 没有指明partition但有key的情况下,将key的hash值与topic的partition数进行取余得到partition
- 既没有partition又没有key的情况下,随机生成一个整数,将这个值与topic可用的partition总数取余得到partition值
具体实现如下
private int partition(ProducerRecord<K, V> record, byte[] serializedKey, byte[] serializedValue, Cluster cluster) {
// 如果message指定partition,直接使用message指定的partition
Integer partition = record.partition();
return partition != null ?
partition :
// 没有指定partition的情况下,走这个逻辑
partitioner.partition(
record.topic(), record.key(), serializedKey, record.value(), serializedValue, cluster);
}
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster,
int numPartitions) {
// 没有key值的情况下,走这个逻辑
if (keyBytes == null) {
return stickyPartitionCache.partition(topic, cluster);
}
// 有key值的情况下,将key的hash值与topic的partition数进行取余
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
public int partition(String topic, Cluster cluster) {
Integer part = indexCache.get(topic);
if (part == null) {
return nextPartition(topic, cluster, -1);
}
return part;
}
public int nextPartition(String topic, Cluster cluster, int prevPartition) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
Integer oldPart = indexCache.get(topic);
Integer newPart = oldPart;
// oldPart == null表示没有分区缓存, 新增topic或第一次调用的
// oldPart == prevPartition
if (oldPart == null || oldPart == prevPartition) {
// 获取topic的可用分区数
List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
if (availablePartitions.size() < 1) {
Integer random = Utils.toPositive(ThreadLocalRandom.current().nextInt());
newPart = random % partitions.size();
// 如果topic只有一个分区,直接选择该分区
} else if (availablePartitions.size() == 1) {
newPart = availablePartitions.get(0).partition();
} else {
// 这里有两种情况,一种是首次选择partition,一种是之前选择的partition缓冲区已经装满了,要再选择一个
// 首先,随机生成一个整数,将这个值与topic可用的partition总数取余得到partition值
// 将这个值与之前缓存的partition值比较,如果相同,再次随机计算一个partition值
// 如果是首次选择,缓存里的partition值为空,与计算出来的值必定是不相同的
while (newPart == null || newPart.equals(oldPart)) {
Integer random = Utils.toPositive(ThreadLocalRandom.current().nextInt());
newPart = availablePartitions.get(random % availablePartitions.size()).partition();
}
}
// 更新缓存中的partiton值
if (oldPart == null) {
indexCache.putIfAbsent(topic, newPart);
} else {
indexCache.replace(topic, prevPartition, newPart);
}
return indexCache.get(topic);
}
return indexCache.get(topic);
}
这里涉及到一个知识点StickyPartitionCache,前面讲过,message会封装成ProducerRecord写入到RecordAccumulator中暂存,等待Sender线程的批量发送,既然是批量,那肯定要尽量让所有message都上同一班车,这样才能发挥批量的优势,不至于让其中一个批次等待太久,StickyPartitionCache 主要实现的是”黏性选择”,一辆车到了,尽量先往这辆车里塞数据,尽快让这辆车发车,我们也不需要担心partition的分配不均匀,只要message够多,每一趟车的发送量都是均匀的
问题来了,什么时候换车呢?在KafkaProducer.doSend()方法中,有这么一个片段
// 尝试向RecordAccumulator中追加message
RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey,
serializedValue, headers, interceptCallback, remainingWaitMs, true, nowMs);
// 由于目标partition的当前batch没有空间了,需要更换一个partition,再次尝试
if (result.abortForNewBatch) {
int prevPartition = partition;
// 更换目标partition,同时也会更换StickyPartitionCache黏住的partition
partitioner.onNewBatch(record.topic(), cluster, prevPartition);
// 计算新的目标partition
partition = partition(record, serializedKey, serializedValue, cluster);
tp = new TopicPartition(record.topic(), partition);
if (log.isTraceEnabled()) {
log.trace("Retrying append due to new batch creation for topic {} partition {}. The old partition was {}", record.topic(), partition, prevPartition);
}
// producer callback will make sure to call both 'callback' and interceptor callback
interceptCallback = new InterceptorCallback<>(callback, this.interceptors, tp);
// 再次调用append()向RecordAccumulator写入message,如果该partition缓冲区中的batch也没有空间
// 则创建新batch了,不会再次尝试了
result = accumulator.append(tp, timestamp, serializedKey,
serializedValue, headers, interceptCallback, remainingWaitMs, false, nowMs);
}
也就是说,上一辆车满了,就可以换车了
RecordAccumulator
上文讲过,message会先放到RecordAccumulator的队列中,达到一定数量,Sender线程会进行发送,这里面便涉及了内存管理
BufferPool
ByteBuffer的创建和释放是比较消耗资源的,为了实现内存的高效利用,Kafka使用BufferPool来实现ByteBuffer的复用
BufferPool预先将特定大小的ByteBuffer(由poolableSize字段指定)缓存到成员变量中,对于其他大小的ByteBuffer并不会缓存进BufferPool,也就是说,BufferPool做好了一个一个的套餐,如果客户没有定制需求,来了就能吃,如果有定制需求,那么就需要后厨花多点时间。具体的实现如下
一些重要的变量
// 记录了整个BufferPool的大小
private final long totalMemory;
// 当前BufferPool管理的单个ByteBuffer的大小
private final int poolableSize;
// 因为有多线程并发分配和回收ByteBuffer,所以使用锁控制并发,保证线程安全
private final ReentrantLock lock;
// 一个ArrayDeque<ByteBuffer>队列,缓存了指定大小的ByteBuffer对象
private final Deque<ByteBuffer> free;
// 记录因申请不到足够空间而阻塞的线程,此队列中实际记录的是阻塞线程对应的Condition对象
private final Deque<Condition> waiters;
// 未分配空间,即totalMemory减去free列表中全部ByteBuffer的大小
private long nonPooledAvailableMemory;
主要看分配和归还内存的逻辑
public ByteBuffer allocate(int size, long maxTimeToBlockMs) throws InterruptedException {
// 检查申请的空间大小是否超过了BufferPool的总空间大小,如果超过了,则抛出异常
if (size > this.totalMemory)
throw new IllegalArgumentException("Attempt to allocate " + size
+ " bytes, but there is a hard limit of "
+ this.totalMemory
+ " on memory allocations.");
ByteBuffer buffer = null;
// 加锁,保证线程安全
this.lock.lock();
// 检查当前BufferPool的状态,如果当前BufferPool处于关闭状态,则直接抛出异常
if (this.closed) {
this.lock.unlock();
throw new KafkaException("Producer closed while allocating memory");
}
try {
// 请求的是poolableSize大小的ByteBuffer,且free列表中有可用的ByteBuffer,则直接从free列表中获取一个ByteBuffer
if (size == poolableSize && !this.free.isEmpty())
return this.free.pollFirst();
// 走到这里,说明申请的空间大小不是poolableSize,或者free列表中没有可用的ByteBuffer
// 计算free列表中的ByteBuffer总空间
int freeListSize = freeSize() * this.poolableSize;
// 当前BufferPool能够释放出目标大小的空间,则直接通过freeUp()方法进行分配
if (this.nonPooledAvailableMemory + freeListSize >= size) {
// 走到这里,说明当前BufferPool足以提供目标空间
freeUp(size);
this.nonPooledAvailableMemory -= size;
} else {
int accumulated = 0;
// 如果当前BufferPool空间不足以提供目标空间,则需要阻塞当前线程
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 {
// 当前线程阻塞等待,返回结果为false则表示阻塞超时
waitingTimeElapsed = !moreMemory.await(remainingTimeToBlockNs, TimeUnit.NANOSECONDS);
} finally {
long endWaitNs = time.nanoseconds();
timeNs = Math.max(0L, endWaitNs - startWaitNs);
recordWaitTime(timeNs);
}
if (this.closed)
throw new KafkaException("Producer closed while allocating memory");
// 指定时间没有获取到目标大小的空间,抛出异常
if (waitingTimeElapsed) {
this.metrics.sensor("buffer-exhausted-records").record();
throw new BufferExhaustedException("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
// 目标大小是poolableSize大小的ByteBuffer,且free中出现了空闲的ByteBuffer
if (accumulated == 0 && size == this.poolableSize && !this.free.isEmpty()) {
// 直接从free列表中获取一个ByteBuffer
buffer = this.free.pollFirst();
accumulated = size;
} else {
// 先分配一部分空间,并继续等待空闲空间
freeUp(size - accumulated);
int got = (int) Math.min(size - accumulated, this.nonPooledAvailableMemory);
this.nonPooledAvailableMemory -= got;
accumulated += got;
}
}
// Don't reclaim memory on throwable since nothing was thrown
// TODO:不明白这行代码什么意思
accumulated = 0;
} finally {
// When this loop was not able to successfully terminate don't loose available memory
// 如果上面的while循环不是正常结束,则accumulated不为0,这里会归还
this.nonPooledAvailableMemory += accumulated;
this.waiters.remove(moreMemory);
}
}
} finally {
// 当前BufferPool要是还存在空闲空间,则唤醒下一个等待线程来尝试分配BufferPool
try {
if (!(this.nonPooledAvailableMemory == 0 && this.free.isEmpty()) && !this.waiters.isEmpty())
this.waiters.peekFirst().signal();
} finally {
// Another finally... otherwise find bugs complains
// 释放锁
lock.unlock();
}
}
// 分配成功但无法复用free列表中的BufferPool(可能目标不是poolableSize大小,或是free列表本身是空的)
if (buffer == null)
return safeAllocateByteBuffer(size);
else
return buffer;
}
public void deallocate(ByteBuffer buffer, int size) {
lock.lock();
try {
// 释放的ByteBuffer大小为poolableSize,放入free列表进行管理
if (size == this.poolableSize && size == buffer.capacity()) {
buffer.clear();
this.free.add(buffer);
} else {
// 如果不是poolableSize大小,则由JVM GC来回收ByteBuffer并增加nonPooledAvailableMemory
this.nonPooledAvailableMemory += size;
}
// 唤醒waiters中的第一个阻塞线程
Condition moreMem = this.waiters.peekFirst();
if (moreMem != null)
moreMem.signal();
} finally {
lock.unlock();
}
}
RecordAccumulator
终于到了将message发送到RecordAccumulator暂存区的代码了
一些重要的变量
// 每个ProducerBatch底层ByteBuffer的大小
private final int batchSize;
// batches类型是CopyOnWriteMap,是线程安全的集合,但其中的Deque是ArrayDeque,是非线程安全的集合,所以追加消息时需要加锁
// TopicPartition表示目标partition
// Deque,缓冲发往目标partition的消息
private final ConcurrentMap<TopicPartition, Deque<ProducerBatch>> batches;
// 未发送的ProducerBatch集合,底层通过HashSet<ProducerBatch>集合实现
private final IncompleteBatches incomplete;
// Sender线程在使用RecordAccumulator.drain()方法批量导出ProducerBatch时
// 会使用drainIndex记录上次发送的位置,下次继续从此位置开始发送
private int drainIndex;
public RecordAppendResult append(TopicPartition tp,
long timestamp,
byte[] key,
byte[] value,
Header[] headers,
Callback callback,
long maxTimeToBlock,
boolean abortOnNewBatch,
long nowMs) throws InterruptedException {
// 统计正在向RecordAccumulator追加数据的线程数
appendsInProgress.incrementAndGet();
ByteBuffer buffer = null;
if (headers == null) headers = Record.EMPTY_HEADERS;
try {
// 在batches集合中查找目标partition对应的ArrayDeque<ProducerBatch>集合
// 如果查找失败,则创建新的ArrayDeque<ProducerBatch>,并添加到batches集合中
Deque<ProducerBatch> dq = getOrCreateDeque(tp);
synchronized (dq) {
if (closed)
throw new KafkaException("Producer closed while send in progress");
// 向Deque最后一个ProducerBatch追加Record
RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callback, dq, nowMs);
if (appendResult != null)
return appendResult;
}
// abortOnNewBatch这个值是入参决定的,这个方法的调用方会根据这个参数决定是否再次调用本方法
// 如果追加Record失败,则可能是因为当前使用的ProducerBatch已经被填满了,这里会根据abortOnNewBatch参数,
// 决定是否立即返回RecordAppendResult结果,返回的RecordAppendResult中如果abortForNewBatch为true,
// 会再触发一次append()方法
if (abortOnNewBatch) {
// Return a result that will cause another call to append.
return new RecordAppendResult(null, false, false, true);
}
byte maxUsableMagic = apiVersions.maxUsableProduceMagic();
int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(maxUsableMagic, compression, key, value, headers));
log.trace("Allocating a new {} byte message buffer for topic {} partition {} with remaining timeout {}ms", size, tp.topic(), tp.partition(), maxTimeToBlock);
// BuffPool中申请ByteBuffer
buffer = free.allocate(size, maxTimeToBlock);
nowMs = time.milliseconds();
synchronized (dq) {
if (closed)
throw new KafkaException("Producer closed while send in progress");
// 再次尝试tryAppend()方法追加Record
RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callback, dq, nowMs);
if (appendResult != null) {
return appendResult;
}
// 走到这里,说明tryAppend()方法追加Record失败,因为当前使用的ProducerBatch已经被填满了或者空间不足了
// 将ByteBuffer封装成MemoryRecordsBuilder
MemoryRecordsBuilder recordsBuilder = recordsBuilder(buffer, maxUsableMagic);
// 创建ProducerBatch对象
ProducerBatch batch = new ProducerBatch(tp, recordsBuilder, nowMs);
// 通过tryAppend()方法将Record追加到ProducerBatch中
FutureRecordMetadata future = Objects.requireNonNull(batch.tryAppend(timestamp, key, value, headers,
callback, nowMs));
// 将ProducerBatch添加到ArrayDeque中
dq.addLast(batch);
// 将ProducerBatch添加到incomplete集合中
incomplete.add(batch);
// 置空buffer
buffer = null;
return new RecordAppendResult(future, dq.size() > 1 || batch.isFull(), true, false);
}
} finally {
// 如果buffer不为空,则表示写入过程中出现异常,这里会释放ByteBuffer
if (buffer != null)
free.deallocate(buffer);
// 当前Record已经写入完成,递减appendsInProgress
appendsInProgress.decrementAndGet();
}
}
这里需要注意,这段代码分段加了两个synchronized,是因为这里的Deque 使用的是 ArrayDeque ( 非线程安全),所以需要加锁同步。问题来了,为什么分多个 synchronized 块而不是在一个完整的synchronized块中完成呢?主要是因为在向 BufferPool申请新 ByteBuffer 的时候,可能会导致阻塞。我们假设在一个synchronized 块中完成上面所有追加操作,有下面的场景:线程 1发送的消息比较大,需要向 BufferPool申请新空间,而此时 BufferPool空间不足,线程 1在 BufferPool上等待,此时它依然持有对应 Deque 的锁;线程2发送的消息较小,Deque 最后一个 RecordBatch剩余空间够用,但是由于线程1未释放 Deque 的锁,所以也需要一起等待。若线程2这样的线程较多,就会造成很多不必要的线程阻塞,降低了吞吐量。所以锁的粒度越小,锁的时间越短,对吞吐量的提升都是有巨大帮助的
第二次加锁后重试,是为了防止多个线程并发向 BufferPool 申请空间后,造成内部碎片。线程 1发现最后一个 RecordBatch 空间不够用,申请空间并创建一个新 RecordBatch 对象添加到 Deque 的尾部;线程2与线程 1并发执行,也将新创建一个 RecordBatch 添加到 Deque 尾部。从上面的逻辑中我们可以得知,之后的追加操作只会在 Deque 尾部进行,这样就会出现下图的场景,RecordBatch4 不再被使用,这就出现了内部碎片
ready
在客户端将消息发送给服务端之前,会调用 RecordAccumulator.ready0方法获取集群中符合发送消息条件的节点集合。这些条件是站在 RecordAccumulator 的角度对集群中的Node 进行筛选的,具体的条件如下:
- Deque 中有多个 RecordBatch 或是第一个 RecordBatch 是否满了
- 是否到时间发送消息了
- 是否有其他线程在等待 BufferPool 释放空间(即 BufferPool的空间耗尽了)
- 是否有线程正在等待 fush 操作完成
- Sender 线程准备关闭
下面来看一下 ready 方法的代码,它会遍历 batches 集合中每个分区,首先查找当前分区 Leader 副本所在的 Node,如果满足上述五个条件,则将此 Node 信息记录到readyNodes 集合中。遍历完成后返回 ReadyCheckResult对象,其中记录了满足发送条件的Node集合、在遍历过程中是否有找不到 Leader 副本的分区(也可以认为是 Metadata 中当前的元数据过时了)、下次调用 ready0 方法进行检查的时间间隔
public ReadyCheckResult ready(Cluster cluster, long nowMs) {
// 用来记录可以向哪些Node节点发送数据
Set<Node> readyNodes = new HashSet<>();
// 记录下次需要调用ready()方法的时间间隔
long nextReadyCheckDelayMs = Long.MAX_VALUE;
// 记录Cluster元数据中找不到leader replica的topic
Set<String> unknownLeaderTopics = new HashSet<>();
// 是否有线程在阻塞等待BufferPool释放空间
boolean exhausted = this.free.queued() > 0;
// 下面遍历batches集合,对其中每个partition的leader replica所在的Node都进行判断
for (Map.Entry<TopicPartition, Deque<ProducerBatch>> entry : this.batches.entrySet()) {
Deque<ProducerBatch> deque = entry.getValue();
synchronized (deque) {
// 如果当前partition对应的Deque为空,直接跳过
ProducerBatch batch = deque.peekFirst();
if (batch != null) {
TopicPartition part = entry.getKey();
// 查找目标partition的leader replica所在的节点
Node leader = cluster.leaderFor(part);
if (leader == null) {
// leader replica找不到,会认为是异常情况,不能发送消息
unknownLeaderTopics.add(part.topic());
} else if (!readyNodes.contains(leader) && !isMuted(part)) {
// 这里的参数比较多,大概解释一下
// waitedTimeMs:这个批次已经等待了多久
// backingOff:是否需要退避,如果这个批次已经发送过一次了,那么就需要退避
// timeToWaitMs:这个批次还需要等待多久才能发送,它有两个取值,如果这个批次已经发送过一次并且退避时间还不够,取退避时间,否则取lingerMs
// 消息发送的时候,如果一直凑不齐一个批次,也会限定一个时间,要求这个时间之内即使不满一个批次也要把数据发送出去
// lingerMs:这个值默认是0,表示消息最多存多久就要发送出去了,如果这个值默认是0的话,代表着来一条消息就发送一条消息
long waitedTimeMs = batch.waitedTimeMs(nowMs);
boolean backingOff = batch.attempts() > 0 && waitedTimeMs < retryBackoffMs;
long timeToWaitMs = backingOff ? retryBackoffMs : lingerMs;
// 有可能这个队列只有一个批次且已经写满了
boolean full = deque.size() > 1 || batch.isFull();
// expired为true表示时间到了,可以发送
boolean expired = waitedTimeMs >= timeToWaitMs;
// 检查上述五个条件,找到此次发送涉及到的Node
boolean sendable = full || expired || exhausted || closed || flushInProgress();
if (sendable && !backingOff) {
readyNodes.add(leader);
} else {
// timeToWaitMs:最多能等待多久
// waitedTimeMs:已经等待了多久
// timeLeftMs:还要再等待多久
long timeLeftMs = Math.max(timeToWaitMs - waitedTimeMs, 0);
// 记录下次需要调用ready()方法检查的时间间隔
nextReadyCheckDelayMs = Math.min(timeLeftMs, nextReadyCheckDelayMs);
}
}
}
}
}
return new ReadyCheckResult(readyNodes, nextReadyCheckDelayMs, unknownLeaderTopics);
}
drain
调用RecordAccumulator.ready()方法得到 readyNodes 集合后,就要准备发送的message了,我们之前所有关于message的逻辑都是以topic为单位来进行存储的,drain()方法的核心逻辑是进行映射的转换,将RecordAccumulator记录的TopicPartition -> ProducerBatch集合的映射,转换成了NodeId -> ProducerBatch集合的映射。为什么需要这次转换呢?在网络I/O层面,生产者是面向Node节点发送消息数据,它只建立到Node的连接并发送数据,并不关心这些数据属于哪个TopicPartition
public Map<Integer, List<ProducerBatch>> drain(Cluster cluster, Set<Node> nodes, int maxSize, long now) {
if (nodes.isEmpty())
return Collections.emptyMap();
// 转换后的结果,key是目标Node的Id,value是发送到目标Node的ProducerBatch集合
Map<Integer, List<ProducerBatch>> batches = new HashMap<>();
for (Node node : nodes) {
// 获取目标Node的ProducerBatch集合
List<ProducerBatch> ready = drainBatchesForOneNode(cluster, node, maxSize, now);
batches.put(node.id(), ready);
}
return batches;
}
private List<ProducerBatch> drainBatchesForOneNode(Cluster cluster, Node node, int maxSize, long now) {
int size = 0;
// 获取当前Node上的partition集合
List<PartitionInfo> parts = cluster.partitionsForNode(node.id());
// 记录发往目标Node的ProducerBatch集合
List<ProducerBatch> ready = new ArrayList<>();
// drainIndex是batches的下标,记录上次发送停止时的位置,下次继续从此位置开始发送。如果始终从
// 索引0的队列开始发送,可能会出现一直只发送前几个partition的消息的情况,造成其他partition饥饿
int start = drainIndex = drainIndex % parts.size();
do {
// 获取partition的元数据
PartitionInfo part = parts.get(drainIndex);
TopicPartition tp = new TopicPartition(part.topic(), part.partition());
this.drainIndex = (this.drainIndex + 1) % parts.size();
// Only proceed if the partition has no in-flight batches.
if (isMuted(tp))
continue;
Deque<ProducerBatch> deque = getDeque(tp);
if (deque == null)
continue;
// 检查目标partition对应的ArrayDeque是否为空
synchronized (deque) {
// invariant: !isMuted(tp,now) && deque != null
// 获取ArrayDeque中第一个ProducerBatch对象
ProducerBatch first = deque.peekFirst();
if (first == null)
continue;
// 如果在退避期内,跳过此次循环
boolean backoff = first.attempts() > 0 && first.waitedTimeMs(now) < retryBackoffMs;
// Only drain the batch if it is not during backoff period.
if (backoff)
continue;
// 请求的大小超过了max.request.size,结束循环
if (size + first.estimatedSizeInBytes() > maxSize && !ready.isEmpty()) {
// there is a rare case that a single batch size is larger than the request size due to
// compression; in this case we will still eventually send this batch in a single request
break;
} else {
if (shouldStopDrainBatchesForPartition(first, tp))
break;
......
}
batch.close();
size += batch.records().sizeInBytes();
// 将ProducerBatch记录到ready集合中
ready.add(batch);
// 修改ProducerBatch的drainedMs标记
batch.drained(now);
}
}
// 直到start等于drainIndex,说明已经遍历了一遍所有的partition,结束循环
} while (start != drainIndex);
return ready;
}
Sender
public class Sender implements Runnable {
@Override
public void run() {
while (running) {
try {
runOnce();
} catch (Exception e) {
log.error("Uncaught error in kafka producer I/O thread: ", e);
}
}
}
void runOnce() {
// 去除事务相关的逻辑
......
long currentTimeMs = time.milliseconds();
// 创建发送到Kafka集群的请求
long pollTimeout = sendProducerData(currentTimeMs);
// 发送请求
client.poll(pollTimeout, currentTimeMs);
}
}
private long sendProducerData(long now) {
Cluster cluster = metadata.fetch();
// 查询可以发送请求的Node节点
RecordAccumulator.ReadyCheckResult result = this.accumulator.ready(cluster, now);
// 如果unknownLeaderTopics不为空,表示待发送message存在未知topic或者leader,则调用
// Metadata.requestUpdate方法更新 needFullUpdate 标记,表示需要更新 Kafka 集群的元数据
if (!result.unknownLeaderTopics.isEmpty()) {
for (String topic : result.unknownLeaderTopics)
this.metadata.add(topic, now);
log.debug("Requesting metadata update due to unknown leader topics from the batched records: {}",
result.unknownLeaderTopics);
this.metadata.requestUpdate();
}
// remove any nodes we aren't ready to send to
Iterator<Node> iter = result.readyNodes.iterator();
long notReadyTimeout = Long.MAX_VALUE;
while (iter.hasNext()) {
Node node = iter.next();
if (!this.client.ready(node, now)) {
iter.remove();
notReadyTimeout = Math.min(notReadyTimeout, this.client.pollDelayMs(node, now));
}
}
// create produce requests
Map<Integer, List<ProducerBatch>> batches = this.accumulator.drain(cluster, result.readyNodes, this.maxRequestSize, now);
addToInflightBatches(batches);
// guaranteeMessageOrder字段与max.in.flight.requests.per.connection配置有关,当该配置为1时,
// guaranteeMessageOrder为true,会将该topicPartition加入muted集合中
// 之后调用RecordAccumulator.ready方法时,会判断该topicPartition是否在muted集合中,
// 忽略发往muted集合中的topicPartition的数据
if (guaranteeMessageOrder) {
// Mute all the partitions drained
for (List<ProducerBatch> batchList : batches.values()) {
for (ProducerBatch batch : batchList)
this.accumulator.mutePartition(batch.topicPartition);
}
}
accumulator.resetNextBatchExpiryTime();
// 获取inFlightBatches集合中已经过期的ProducerBatch
List<ProducerBatch> expiredInflightBatches = getExpiredInflightBatches(now);
// 获取RecordAccumulator中已经过期的ProducerBatch
List<ProducerBatch> expiredBatches = this.accumulator.expiredBatches(now);
expiredBatches.addAll(expiredInflightBatches);
if (!expiredBatches.isEmpty())
log.trace("Expired {} batches in accumulator", expiredBatches.size());
for (ProducerBatch expiredBatch : expiredBatches) {
String errorMessage = "Expiring " + expiredBatch.recordCount + " record(s) for " + expiredBatch.topicPartition
+ ":" + (now - expiredBatch.createdMs) + " ms has passed since batch creation";
// 调用batch.done()处理超时的ProducerBatch
failBatch(expiredBatch, -1, NO_TIMESTAMP, new TimeoutException(errorMessage), false);
if (transactionManager != null && expiredBatch.inRetry()) {
// This ensures that no new batches are drained until the current in flight batches are fully resolved.
transactionManager.markSequenceUnresolved(expiredBatch);
}
}
sensors.updateProduceRequestMetrics(batches);
// 这个pollTimeout的计算方式很复杂
// 在正常发送的情况下,这个pollTimeout是delivery.timeout.ms 配置项指定的
long pollTimeout = Math.min(result.nextReadyCheckDelayMs, notReadyTimeout);
pollTimeout = Math.min(pollTimeout, this.accumulator.nextExpiryTimeMs() - now);
pollTimeout = Math.max(pollTimeout, 0);
if (!result.readyNodes.isEmpty()) {
log.trace("Nodes with data ready to send: {}", result.readyNodes);
pollTimeout = 0;
}
sendProduceRequests(batches, now);
return pollTimeout;
}
至此,对Kafka消息的发送流程有了大概的了解,当然,细节还有很多,日后还需继续深入了解