1. 前言
- Sync Producer:低延迟,低吞吐率,无数据丢失
- Async Producer:高延迟,高吞吐率,可能会有数据丢失
2.Producer发送消息
通过KafkaProducer的send方法发送消息,send方法有两种重载:
Future<RecordMetadata> send(ProducerRecord<K, V> record)
Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback)
消息是被封装成ProducerRecord,那么ProducerRecord是怎样的呢?
核心属性:
- String topic 消息所属的主题
- Integer partition 消息所处的主题的分区数,可以人为指定,如果指定了 key 的话,会使用 key 的 hashCode 与队列总数进行取模来选择分区,如果前面两者都未指定,则会轮询主题下的所有分区
- Headers headers 该消息的额外属性对,与消息体分开存储
- K key 消息键,如果指定该值,则会使用该值的 hashcode 与 队列数进行取模来选择分区
- V value 消息体
- Long timestamp 消息时间戳,根据 topic 的配置信息 message.timestamp.type 的值来赋予不同的值
CreateTime:发送客户端发送消息时的时间戳
LogAppendTime:消息在broker追加时的时间戳
3.源码分析消息追加流程
KafkaProducer的send方法,并不会直接向broker发送消息,kafka将消息发送异步化,即分解成两个步骤,send 方法的职责是将消息追加到内存中(分区的缓存队列中),然后会由专门的 Send 线程异步将缓存中的消息批量发送到 Kafka Broker 中。
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
// intercept the record, which can be potentially modified; this method does not throw exceptions
ProducerRecord<K, V> interceptedRecord = this.interceptors == null ? record : this.interceptors.onSend(record);
return doSend(interceptedRecord, callback);
}
3.1 首先执行消息发送的拦截器
拦截器是通过属性interceptor.classes
指定,
转成List<String>类型,每一个元素就是拦截器的全类路径限定名,默认是空的(即没有默认拦截器存在)
3.2 执行doSend方法
step1:
// first make sure the metadata for the topic is available
ClusterAndWaitTime clusterAndWaitTime = waitOnMetadata(record.topic(), record.partition(), maxBlockTimeMs);
long remainingWaitMs = Math.max(0, maxBlockTimeMs - clusterAndWaitTime.waitedOnMetadataMs);
获取元数据信息,包括topic可用的分区列表,如果本地没有该topic的分区信息,则需要向远端 broker 获取,该方法会返回拉取元数据所耗费的时间。在消息发送时的最大等待时间时会扣除该部分损耗的时间。
step2:
byte[] serializedKey;
try {
serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key());
} catch (ClassCastException cce) {
throw new SerializationException("Can't convert key of class " + record.key().getClass().getName() +
" to class " + producerConfig.getClass(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG).getName() +
" specified in key.serializer");
}
byte[] serializedValue;
try {
serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value());
} catch (ClassCastException cce) {
throw new SerializationException("Can't convert value of class " + record.value().getClass().getName() +
" to class " + producerConfig.getClass(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG).getName() +
" specified in value.serializer");
}
对key和value进行序列化,虽然序列化方法传入了topic、headers两个属性,但是参与序列化的只是key和value。
step3:
int partition = partition(record, serializedKey, serializedValue, cluster);
tp = new TopicPartition(record.topic(), partition);
根据分区负载算法计算本次消息发送该发往的分区。其默认实现类为 DefaultPartitioner,路由算法如下:
- 如果消息指定了分区,则将消息发送到对应分区。
- 如果指定了 key ,则使用 key 的 hashcode 与分区数取模。
- 如果未指定 key,则轮询所有的分区。
step4:
setReadOnly(record.headers());
Header[] headers = record.headers().toArray();
int serializedSize = AbstractRecords.estimateSizeInBytesUpperBound(apiVersions.maxUsableProduceMagic(),
compressionType, serializedKey, serializedValue, headers);
ensureValidRecordSize(serializedSize);
long timestamp = record.timestamp() == null ? time.milliseconds() : record.timestamp();
log.trace("Sending record {} with callback {} to topic {} partition {}", record, callback, record.topic(), partition);
// producer callback will make sure to call both 'callback' and interceptor callback
Callback interceptCallback = this.interceptors == null ? callback : new InterceptorCallback<>(callback, this.interceptors, tp);
if (transactionManager != null && transactionManager.isTransactional())
transactionManager.maybeAddPartitionToTransaction(tp);
将消息头信息设置为只读,根据使用的版本号,按照消息协议来计算消息的长度,并是否超过指定长度,如果超过则抛出异常,先初始化消息时间戳,并对传入的 Callable(回调函数) 加入到拦截器链中。如果事务处理器不为空,执行事务管理相关的。
step5:重要
RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey,
serializedValue, headers, interceptCallback, remainingWaitMs);
if (result.batchIsFull || result.newBatchCreated) {
log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), partition);
this.sender.wakeup();
}
return result.future;
// handling exceptions and record the errors;
// for API exceptions return them in the future,
// for other exceptions throw directly
将消息追加到缓存区,如果当前缓存区写满或者创建了一个新的缓存区,则唤醒Sender(即消息发送线程),将缓存区中的消息发送到broker服务器,最终返回future。
3.3 RecordAccumulator的append方法
public RecordAppendResult append(TopicPartition tp,
long timestamp,
byte[] key,
byte[] value,
Header[] headers,
Callback callback,
long maxTimeToBlock) throws InterruptedException {
- TopicPartition tp topic 与分区信息,即发送到哪个 topic 的那个分区。
- long timestamp 客户端发送时的时间戳。
- byte[] key 消息的 key。
- byte[] value 消息体。
- Header[] headers 消息头,可以理解为额外消息属性。
- Callback callback 回调方法。
- long maxTimeToBlock 消息追加超时时间。
step1:
// check if we have an in-progress batch
Deque<ProducerBatch> dq = getOrCreateDeque(tp);
synchronized (dq) {
if (closed)
throw new IllegalStateException("Cannot send after the producer is closed.");
RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callback, dq);
if (appendResult != null)
return appendResult;
}
使用getOrCreateDeque
尝试根据topic和分区获取一个双端队列,如果不存在,则创建一个,然后调用tryAppend
方法将消息追加到缓存中(即消息队列中)。
Kafka会为每一个topic的每一个分区创建一个消息缓存区消息先追加到缓存中,然后消息发送 API 立即返回,然后由单独的线程 Sender 将缓存区中的消息定时发送到 broker 。这里的缓存区的实现使用的是 ArrayQeque。然后调用 tryAppend 方法尝试将消息追加到其缓存区,如果追加成功,则返回结果。
Kafka双端队列ArrayDeque:
step2:
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 {}", size, tp.topic(), tp.partition());
buffer = free.allocate(size, maxTimeToBlock);
如果第一步未追加成功,说明当前没有可用的 ProducerBatch,则需要创建一个 ProducerBatch,故先从 BufferPool 中申请 batch.size 的内存空间,为创建 ProducerBatch 做准备,如果由于 BufferPool 中未有剩余内存,则最多等待 maxTimeToBlock ,如果在指定时间内未申请到内存,则抛出异常。
step3:
synchronized (dq) {
// Need to check if producer is closed again after grabbing the dequeue lock.
if (closed)
throw new KafkaException("Producer closed while send in progress");
// 省略部分代码
MemoryRecordsBuilder recordsBuilder = recordsBuilder(buffer, maxUsableMagic);
ProducerBatch batch = new ProducerBatch(tp, recordsBuilder, time.milliseconds());
FutureRecordMetadata future = Utils.notNull(batch.tryAppend(timestamp, key, value, headers, callback, time.milliseconds()));
dq.addLast(batch);
incomplete.add(batch);
// Don't deallocate this buffer in the finally block as it's being used in the record batch
buffer = null;
return new RecordAppendResult(future, dq.size() > 1 || batch.isFull(), true);
}
创建一个新的批次ProducerBatch,并且将消息写入该批次中,并返回追加结果,
- 创建 ProducerBatch ,其内部持有一个 MemoryRecordsBuilder对象,该对象负责将消息写入到内存中,即写入到 ProducerBatch 内部持有的内存,大小等于 batch.size。
- 将消息追加到 ProducerBatch 中。
- 将新创建的 ProducerBatch 添加到双端队列的末尾。
- 将该批次加入到 incomplete 容器中,该容器存放未完成发送到 broker 服务器中的消息批次,当 Sender 线程将消息发送到 broker 服务端后,会将其移除并释放所占内存。
总结:整个append的过程,基本上就是从双端队列获取一个未填充完毕的ProducerBatch(消息批次),然后尝试将其写入到该批次中(缓存、内存中),如果追加失败,则创建一个新的ProducerBatch然后继续追加。
3.4 ProducerBatch tryAppend方法
上面RecordAccumulator的append方法,会去调用器tryAppend方法,然后会再去调用ProducerBatch的tryAppend方法。
/**
* Append the record to the current record set and return the relative offset within that record set
*
* @return The RecordSend corresponding to this record or null if there isn't sufficient room.
*/
public FutureRecordMetadata tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers, Callback callback, long now) {
if (!recordsBuilder.hasRoomFor(timestamp, key, value, headers)) {
return null;
} else {
Long checksum = this.recordsBuilder.append(timestamp, key, value, headers);
this.maxRecordSize = Math.max(this.maxRecordSize, AbstractRecords.estimateSizeInBytesUpperBound(magic(),
recordsBuilder.compressionType(), key, value, headers));
this.lastAppendTime = now;
FutureRecordMetadata future = new FutureRecordMetadata(this.produceFuture, this.recordCount,
timestamp, checksum,
key == null ? -1 : key.length,
value == null ? -1 : value.length);
// we have to keep every future returned to the users in case the batch needs to be
// split to several new batches and resent.
thunks.add(new Thunk(callback, future));
this.recordCount++;
return future;
}
}
step1:
首先去判断ProducerBatch是否还能够容纳消息,如果剩余内存不足,将直接返回 null。如果返回 null ,会尝试再创建一个新的ProducerBatch。
step2:
通过 MemoryRecordsBuilder 将消息写入按照 Kafka 消息格式写入到内存中,即写入到 在创建 ProducerBatch 时申请的 ByteBuffer 中。
step3:
更新 ProducerBatch 的 maxRecordSize、lastAppendTime 属性,分别表示该批次中最大的消息长度与最后一次追加消息的时间。
step4:
构建 FutureRecordMetadata 对象,这里是典型的 Future模式,里面主要包含了该条消息对应的批次的 produceFuture、消息在该批消息的下标,key 的长度、消息体的长度以及当前的系统时间。
step5:
将 callback 、本条消息的凭证(Future) 加入到该批次的 thunks 中,该集合存储了 一个批次中所有消息的发送回执。
4 总结
Kafka的send方法,并不会直接向broker发送消息,而是首先追加到生产者的内存缓存中,其内存存储结构如下:ConcurrentMap<TopicPartition,Deque<ProducerBatch>> batches
,Kafka的生产者会为每一个topic的每一个分区单独维护一个队列,即ArrayDeque,内部存放的元素就是ProducerBatch,即代表一个批次,即Kafka消息发送时按批发送的。
KafkaProducer的send方法,最终返回的就是Future的子类,Future模式 FutureRecordMetadata
。所以kafka的消息发送如何实现异步,同步发送也就是这个返回值决定的。
- 若需要同步发送,只要拿到send方法的返回结果后,调用get方法,此时如果消息未发送到Broker上,该方法就会被阻塞,等到 broker 返回消息发送结果后该方法会被唤醒并得到消息发送结果。
- 若需要异步发送,则建议使用send(ProducerRecord< K, V > record, Callback callback),但是不能调用get方法,Callback 会在收到 broker 的响应结果后被调用,并且支持拦截器。