更多大数据学习文章或总结,可微信搜索 知了小巷,关注公众号并回复 资料 两个字,有大数据学习资料和视频。
Apache Kafka客户端KafkaProducer
涉及源码部分,kafka版本:version=2.6.1
内容提纲
- KafkaProducer消息分区机制
- KafkaProducer消息压缩
- KafkaProducer保证消息不丢失
- KafkaProducer消息发送源码解析
KafkaProducer消息分区机制
Kafka消息的三级组织结构:主题 - 分区 - 消息。 主题下的每条消息只会保存在某一个分区中(会有副本),而不会在多个分区中(不同分区号)被保存多份。
kafka/core/src/main/scala/kafka/cluster/Partition.scala
// 集群下的分区(主题名称 分区号)
// Data structure that represents a topic partition.
class Partition(
// 主题分区(主题名称 分区号)
val topicPartition: TopicPartition,
// replica.lag.time.max.ms
// replicas响应partition leader的最长等待时间
val replicaLagTimeMaxMs: Long,
// inter.broker.protocol.version
// CURRENT_KAFKA_VERSION
// 如果进行Kafka滚动升级,需要配置此项,比如2.6
interBrokerProtocolVersion: ApiVersion,
// broker.id
// 配置文件里面配置的broker的唯一id号
localBrokerId: Int,
time: Time,
// ZkPartitionStateStore
stateStore: PartitionStateStore,
delayedOperations: DelayedOperations,
// A cache for the state (e.g., current leader) of each partition.
// This cache is updated through UpdateMetadataRequest from the controller. Every broker maintains the same cache, asynchronously.
metadataCache: MetadataCache,
// The entry point to the kafka log management subsystem.
logManager: LogManager,
// Handles the sending of AlterIsr requests to the controller.
// Updating the ISR is an asynchronous operation
alterIsrManager: AlterIsrManager) extends Logging with KafkaMetricsGroup {
// 主题名称
def topic: String = topicPartition.topic
// 分区号
def partitionId: Int = topicPartition.partition
// ...
}
KafkaProducer在什么地方对数据做的分区(切分):
kafka/clients/src/main/java/org/apache/kafka/clients/producer/KafkaProducer.java
看看最简单的两个构造方法:
// 参数是Map-生产者配置项
public KafkaProducer(final Map<String, Object> configs) {
this(configs, null, null, null, null, null, Time.SYSTEM);
}
// 参数是Properties-生产者配置项
public KafkaProducer(Properties properties) {
this(Utils.propsToMap(properties), null, null, null, null, null, Time.SYSTEM);
}
// 最终都是走到这里,非public构造方法
// 配置、KV序列化、元数据、客户端、拦截器、默认系统当前时间
KafkaProducer(Map<String, Object> configs,
Serializer<K> keySerializer,
Serializer<V> valueSerializer,
ProducerMetadata metadata,
KafkaClient kafkaClient,
ProducerInterceptors<K, V> interceptors,
Time time) {
ProducerConfig config = new ProducerConfig(ProducerConfig.appendSerializerToConfig(configs, keySerializer,
valueSerializer));
// 外面一层try...catch
try {
Map<String, Object> userProvidedConfigs = config.originals();
this.producerConfig = config;
this.time = time;
String transactionalId = (String) userProvidedConfigs.get(ProducerConfig.TRANSACTIONAL_ID_CONFIG);
this.clientId = config.getString(ProducerConfig.CLIENT_ID_CONFIG);
LogContext logContext;
// 判断是否启用事务
if (transactionalId == null)
logContext = new LogContext(String.format("[Producer clientId=%s] ", clientId));
else
logContext = new LogContext(String.format("[Producer clientId=%s, transactionalId=%s] ", clientId, transactionalId));
log = logContext.logger(KafkaProducer.class);
// 启动Kafka producer
log.trace("Starting the Kafka producer");
// ...
// 这里获取分区实现类-Partitioner对象
// partitioner.class
// ProducerConfig里面定义了默认Class
this.partitioner = config.getConfiguredInstance(ProducerConfig.PARTITIONER_CLASS_CONFIG, Partitioner.class);
// ...
// 压缩类型
this.compressionType = CompressionType.forName(config.getString(ProducerConfig.COMPRESSION_TYPE_CONFIG));
// ...
// 事务管理器
this.transactionManager = configureTransactionState(config, logContext);
// ...
// 发送消息的Runnable
this.sender = newSender(logContext, kafkaClient, this.metadata);
String ioThreadName = NETWORK_THREAD_PREFIX + " | " + clientId;
// 发送消息的线程,daemon为true 即后台线程
this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
// 线程启动
this.ioThread.start();
// ...
} catch (Throwable t) {
// call close methods if internal objects are already constructed this is to prevent resource leak. see KAFKA-2121
close(Duration.ofMillis(0), true);
// now propagate the exception
throw new KafkaException("Failed to construct kafka producer", t);
}
}
// 生产者发送数据会调用send方法
// send方法里面会调用doSend方法
/**
* Implementation of asynchronously send a record to a topic.
*/
private Future<RecordMetadata> doSend(ProducerRecord<K, V> record, Callback callback) {
// 主题分区对象
TopicPartition tp = null;
// 外面一层try...catch
// 此处计算分区号
int partition = partition(record, serializedKey, serializedValue, cluster);
tp = new TopicPartition(record.topic(), partition);
// ...
return result.future;
// ...
}
// 计算分区号,如果已经设置了分区号就直接返回
// 否则,通过配置的partitioner进行计算
private int partition(ProducerRecord<K, V> record, byte[] serializedKey, byte[] serializedValue, Cluster cluster) {
Integer partition = record.partition();
return partition != null ?
partition :
partitioner.partition(
record.topic(), record.key(), serializedKey, record.value(), serializedValue, cluster);
}
// ProducerConfig定义的默认分区实现是DefaultPartitioner
static {
CONFIG = new ConfigDef()...
// partitioner.class
.define(PARTITIONER_CLASS_CONFIG,
Type.CLASS,
DefaultPartitioner.class,
Importance.MEDIUM, PARTITIONER_CLASS_DOC)
}
// DefaultPartitioner.java#partition方法
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster,
int numPartitions) {
if (keyBytes == null) {
// 如果key为空,缓存+随机
// StickyPartitionCache里面是一个ConcurrentHashMap
return stickyPartitionCache.partition(topic, cluster);
}
// key不为空,使用Hash算法计算分区号(Hash取模)
// hash the keyBytes to choose a partition
// Generates 32 bit murmur2 hash from byte array
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
分区的作用和原因:
分区可以起到负载均衡的作用,也是为了实现系统的高伸缩性(Scalability)。
不同分区的数据能够被放置到不同节点的机器上,而数据的读写操作也都是针对分区这个粒度进行的,这样每个节点的机器都能独立处理各自分区的读写请求。除此之外,还可以通过添加新的节点机器来增加整体集群的吞吐量。
分区策略:
// 轮询策略,顺序分配
// 比如,分区号:0 1 2,有7条消息:A B C D E F G
// A - 0; B - 1; C -2
// D - 0; E - 1; F -2
// G - 0
public class RoundRobinPartitioner implements Partitioner {...}
// 默认的分区策略:key为空和不为空两种情况
// key为空,逻辑和UniformStickyPartitioner类似
// key不为空,Hash取模,相同key的消息进入同一个Partition
public class DefaultPartitioner implements Partitioner {...}
// 根据topic选择partition,缓存+随机
public class UniformStickyPartitioner implements Partitioner {...}
分区策略就是决定生产者将消息发送到哪个分区的算法,就是上面的计算分区号。clients包里默认实现的三个分区器是:RoundRobinPartitioner、DefaultPartitioner和UniformStickyPartitioner。如果是自定义实现,直接实现Partitioner接口和相关方法即可。然后在生产者的Properties配置中显式地配置partitioner.class参数。
Partitioner接口:
// Partitioner Interface
public interface Partitioner extends Configurable, Closeable {
// Compute the partition for the given record.
// topic、key、keyBytes、value和valueBytes属于消息数据
// cluster是集群信息(元数据:多少topic broker等)
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);
// This is called when partitioner is closed.
public void close();
// Notifies the partitioner a new batch is about to be created.
// When using the sticky partitioner,
// this method can change the chosen sticky partition for the new batch.
default public void onNewBatch(String topic, Cluster cluster, int prevPartition) {
// 空实现
}
}
消息的顺序
Broker端Offset的生成:
Log.scala#append方法
// assign offsets to the message set
val offset = new LongRef(nextOffsetMetadata.messageOffset)
Kafka保证的是分区内有序,即Offset自增长,因此有的业务会把所有消息设置一个分区,保证全局有序,自然也就失去了Kafka多分区带来的高吞吐量和负载均衡的优势。还是要对具体业务进行调研,是否可以根据特殊业务类型或场景领域进行分区,既可以保证分区内的消息顺序,也可以享受到多分区带来的性能优势。
保证一个分区内数据有序:是出于这些数据有前后因果或依赖关系,如果是隔离独立开的,自然可以分布到多个分区里面;比如业务数据库表的增删改,一般只要保证一行数据(主键)的增删改有序即可,因此使用主键作为Key即可。实际上即便如此,也有极端情况,比如多个应用对同一个主键的数据做修改,如果是直接从应用内部将消息发到Kafka,由于网络原因,虽然数据都是进入同一个分区,自然时间有序,但有可能会出现逻辑时间无序,不过binlog可以保证自然时间和逻辑时间都是有序的。
基于地理位置的分区策略
一般针对具体业务场景,比如大规模的Kafka集群,特别是跨城市、跨国家甚至是跨大洲的集群。
比如异地多中心,不同地域的数据只在特定地域内进行消息处理(生产发送和消费),可以根据Broker所在的IP地址实现定制化的分区策略:
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
// 南方LeaderPartition的IP地址(HOST)
return partitions.stream().filter(p -> isSouth(p.leader().host())).map(PartitionInfo::partition).findAny().get();
KafkaProducer消息压缩
kafka/core/src/main/scala/kafka/message/CompressionCodec.scala
// Kafka支持的压缩编码格式
object CompressionCodec {
def getCompressionCodec(codec: Int): CompressionCodec = {
codec match {
// 没有压缩
case NoCompressionCodec.codec => NoCompressionCodec
// GZIP
case GZIPCompressionCodec.codec => GZIPCompressionCodec
// Snappy
case SnappyCompressionCodec.codec => SnappyCompressionCodec
// LZ4
case LZ4CompressionCodec.codec => LZ4CompressionCodec
// ZStd
case ZStdCompressionCodec.codec => ZStdCompressionCodec
case _ => throw new UnknownCodecException("%d is an unknown compression codec".format(codec))
}
}
def getCompressionCodec(name: String): CompressionCodec = {
name.toLowerCase(Locale.ROOT) match {
// ...
}
}
}
压缩
压缩是一种通过特定的算法来减小计算机文件大小的机制。这种机制是一种很方便的发明,尤其是对网络用户,因为它可以减小文件的字节总数,使文件能够通过较慢的互联网连接实现更快传输,此外还可以减少文件的磁盘占用空间。【百科】
baike.baidu.com/item/压缩/130…
思想:用时间换空间,用CPU时间去换磁盘空间或网络I/O传输量,希望以较小的CPU开销带来更少的磁盘占用或更少的网络I/O传输。不同压缩算法CPU耗时有所不同。
前面KafkaProducer构造方法里面,会去拿到压缩类型的配置:
// 压缩类型
// compression.type
this.compressionType = CompressionType.forName(config.getString(ProducerConfig.COMPRESSION_TYPE_CONFIG));
生产者端配置:
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("acks", "all");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// 开启 GZIP 压缩
props.put("compression.type", "gzip");
Producer<String, String> producer = new KafkaProducer<>(props);
如果上述配置里面不指定,默认是none,即不使用压缩。
// Type.STRING, "none"
static {
CONFIG = new ConfigDef()...
.define(COMPRESSION_TYPE_CONFIG, Type.STRING, "none", Importance.HIGH, COMPRESSION_TYPE_DOC)
}
压缩类型:CompressionType
// The compression type to use
public enum CompressionType {
// ...
public static CompressionType forId(int id) {
switch (id) {
case 0:
return NONE;
case 1:
return GZIP;
case 2:
return SNAPPY;
case 3:
return LZ4;
case 4:
return ZSTD;
default:
throw new IllegalArgumentException("Unknown compression type id: " + id);
}
}
// ...
}
指定压缩类型比如gzip,Producer启动后生产的每个消息集合都是经GZIP压缩过的,故而能很好地节省网络传输带宽以及Kafka Broker端的磁盘占用。
除了Producer端会对消息压缩之外,Broker端也可能进行压缩
object BrokerCompressionCodec {
// 这里多一个ProducerCompressionCodec
val brokerCompressionCodecs = List(UncompressedCodec, ZStdCompressionCodec, LZ4CompressionCodec, SnappyCompressionCodec, GZIPCompressionCodec, ProducerCompressionCodec)
val brokerCompressionOptions: List[String] = brokerCompressionCodecs.map(codec => codec.name)
def isValid(compressionType: String): Boolean = brokerCompressionOptions.contains(compressionType.toLowerCase(Locale.ROOT))
def getCompressionCodec(compressionType: String): CompressionCodec = {
compressionType.toLowerCase(Locale.ROOT) match {
case UncompressedCodec.name => NoCompressionCodec
case _ => CompressionCodec.getCompressionCodec(compressionType)
}
}
def getTargetCompressionCodec(compressionType: String, producerCompression: CompressionCodec): CompressionCodec = {
if (ProducerCompressionCodec.name.equals(compressionType))
producerCompression
else
getCompressionCodec(compressionType)
}
}
// ProducerCompressionCodec
case object ProducerCompressionCodec extends BrokerCompressionCodec {
val name = "producer"
}
Broker端默认compression.type是producer,以Producer端配置的压缩算法为准。但是如果改变了Broker端的压缩算法,与Producer端的压缩类型不一致,可能会发生预料之外的压缩/解压缩操作,通常表现为Broker端CPU使用率飙升。
另外,Kafka为了兼容老版本的消息格式,Broker端会对新版本消息执行向老版本格式的转换。这个过程中会涉及消息的解压缩和重新压缩。一般情况下这种消息格式转换对性能是有很大影响的,除了这里的压缩之外,它还让Kafka丧失了零拷贝特性。
解压缩
生产者端压缩消息,Broker端直接落存,消费者端解压缩。
Kafka会将启用了哪种压缩算法封装进消息集合中,这样当Consumer读取到消息集合时,它自然就知道了这些消息使用的是哪种压缩算法,从而进行相对应的解压缩。
// clients api的DefaultRecordBatch有具体实现
// 重点是元数据里面存储的压缩类型
// return CompressionType.forId(attributes() & COMPRESSION_CODEC_MASK);
public interface RecordBatch extends Iterable<Record> {
// ...
/**
* Get the compression type of this record batch.
*
* @return The compression type
*/
CompressionType compressionType();
// ...
}
除了Consumer端对压缩过的消息进行解压缩之外,Broker端也会进行解压缩,主要是因为做消息校验而引入的解压缩,这对CPU的使用率是有一定影响的,不过这个解压缩校验可以绕过。
issues.apache.org/jira/browse…
压缩算法的对比
在吞吐量方面:LZ4 > Snappy > zstd和GZIP;在压缩比方面,zstd > LZ4 > GZIP > Snappy。具体到物理资源,使用Snappy算法占用的网络带宽最多,zstd最少,这是合理的,毕竟zstd就是要提供超高的压缩比;在CPU使用率方面,各个算法表现得差不多,只是在压缩时Snappy算法使用的CPU较多一些,而在解压缩时GZIP算法则可能使用更多的CPU。
压缩实战
对于KafkaProducer端开启压缩,需要用到更多的CPU资源,要保证Producer所在机器的CPU资源充足。如果网络带宽比较有限,可以开启压缩,比如开启zstd压缩,可以极大地节省网络资源消耗。当然,一旦启用压缩,解压缩是不可避免的事情。
KafkaProducer保证消息不丢失
Kafka只对“已提交”的消息(committed message)做有限度的持久化保证。
生产者程序本身丢失数据
KafkaProducer是异步发送消息的,如果直接调用producer.send(msg),它通常会立即返回响应,但此时显然不能认为消息发送已成功完成(消息被Kafka Broker持久化)。
在producer.send调用之后,有可能出现网络抖动,导致消息压根就没有发送到Broker 端;或者消息本身不符合要求(校验不通过)导致Broker端拒绝接收(比如消息太大了,超过了Broker的承受能力)等。在这两种情况下,Kafka不认为消息是已提交的,所以在Kafka看来就没有丢不丢失的说法了。
KafkaProducer除了producer.send(msg),还有带回调函数的API,producer.send(msg, callback),通过callback可以明确得到消息是否真的提交成功了。一旦出现消息提交失败的情况,我们就可以有针对性地进行“善后”处理。
至于“善后”处理,如果是因为瞬时错误,则仅仅让KafkaProducer重试就可以了;如果是消息不符合要求造成的,则可以调整消息格式后再次发送。如果是其它“未知”原因导致重试或者重发“多次”失败,则可以输出告警、错误日志,人工及时介入修复。
比如Kafka服务节点Broker宕机了,Producer端怎么重试都会失败的,因此需要立即恢复Kafka集群的正常服务。当然这种情况,Kafka依然不认为这条消息属于已提交消息,故对它不做任何持久化保证。
API使用和配置项
- 最好使用producer.send(msg, callback),当然如果实际业务场景“允许”出现少量数据丢失,且要保证较高的生产效率(高吞吐),直接使用producer.send(msg)就可以了,只管发送。
- Producer端设置acks = all,意味着所有副本Partition都要接收到消息,该消息才算是“已提交”。
- 设置retries为一个较大的值。这里的retries同样是Producer端的参数,对应前面提到的Producer自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了retries > 0的Producer能够自动重试消息发送,避免消息丢失。
- 设置unclean.leader.election.enable = false。这是Broker端的参数,它控制的是哪些Partition有资格竞选分区的Leader。如果一个Partition落后原先的Leader 太多,那么它一旦成为新的Leader,必然会造成消息的丢失。所以一般都要将该参数设置成 false,即不允许这种情况的发生。
- 一般replication.factor设置为3或者>=3足够了,在Broker端配置。
- min.insync.replicas,至少为2。 Broker端参数,控制的是消息至少要被写入到多少个副本才算是“已提交”。
- 确保replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。建议是:replication.factor = min.insync.replicas + 1
KafkaProducer消息发送源码解析
上面已经简略的解析了非public的KafkaProducer构造方法,通常开发一个Producer端程序有以下简单的几个步骤:
- new一个Properties对象props,配置Producer端相关的参数
- new一个KafkaProducer<>(props),创建KafkaProducer对象实例producer
- 调用producer的send方法发送消息
- 不要忘记调用producer.close();关闭生产者并释放各种系统资源
Runnable newSender和Thread KafkaThread
// KafkaProducer构造方法实现中就已经启动了ioThread
// sender是一个Runnable
this.sender = newSender(logContext, kafkaClient, this.metadata);
// 线程名称
String ioThreadName = NETWORK_THREAD_PREFIX + " | " + clientId;
// KafkaThread继承自Thread,true为后台线程
this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
// 线程启动
this.ioThread.start();
在Sender.java#run()方法里面,会有各种判断,while(xxxx)。除去事务处理之外,会调用runOnce();以及最后this.client.close();。在runOnce()方法中,会调用sendProducerData(currentTimeMs)方法,里面会调用sendProduceRequests(batches, now);然后调用sendProduceRequest(now, entry.getKey(), acks, requestTimeoutMs, entry.getValue());最后调用client.send(clientRequest, now);
// sendProducerData(currentTimeMs)
// 从accumulator获取batches
Map<Integer, List<ProducerBatch>> batches = this.accumulator.drain(cluster, result.readyNodes, this.maxRequestSize, now);
// client.send(clientRequest, now)
// 构造一个客户端请求clientRequest
ClientRequest clientRequest = client.newClientRequest(nodeId, requestBuilder, now, acks != 0,
requestTimeoutMs, callback);
// 把请求发送出去
// client是NetworkClient对象,newSender里面创建的
client.send(clientRequest, now);
要发送客户端请求,显然是与Kafka Broker建立起了TCP连接,Producer端配置了多少bootstrap.servers,就会创建多少连接。Producer一旦连接到集群中的任一台Broker,就能拿到整个集群的Broker信息,所以没必要为bootstrap.servers指定所有的Broker。
最终的send是调用selector.send(send); selector是:
// newSender里面创建的对象
// 这个Selector是clients模块下的
new Selector(producerConfig.getLong(ProducerConfig.CONNECTIONS_MAX_IDLE_MS_CONFIG)
// Selector.java#send
// Queue the given request for sending in the subsequent {@link #poll(long)} calls
public void send(Send send) {
String connectionId = send.destination();
KafkaChannel channel = openOrClosingChannelOrFail(connectionId);
if (closingChannels.containsKey(connectionId)) {
// ensure notification via `disconnected`, leave channel in the state in which closing was triggered
this.failedSends.add(connectionId);
} else {
try {
channel.setSend(send);
} catch (Exception e) {
// update the state for consistency, the channel will be discarded after `close`
channel.state(ChannelState.FAILED_SEND);
// ensure notification via `disconnected` when `failedSends` are processed in the next poll
this.failedSends.add(connectionId);
close(channel, CloseMode.DISCARD_NO_NOTIFY);
if (!(e instanceof CancelledKeyException)) {
log.error("Unexpected exception during send, closing connection {} and rethrowing exception {}",
connectionId, e);
throw e;
}
}
}
}
看看KafkaProducer.java#send()做了什么
// KafkaProducer.java#send()
// 调用KafkaProducer.java#doSend()
// 调用accumulator.append()
// RecordAccumulator
result = accumulator.append(tp, timestamp, serializedKey,
serializedValue, headers, interceptCallback, remainingWaitMs, false, nowMs);
RecordAccumulator.java#append()
// RecordAccumulator.java
// This class acts as a queue that accumulates records into MemoryRecords instances to be sent to the server.
public RecordAppendResult append(...) {
// 外层一个try...catch ...
// 获取Deque对象
// RecordAccumulator会维护一个ConcurrentMap<TopicPartition, Deque<ProducerBatch>> batches,实际是一个new CopyOnWriteMap<>()
// 一个主题分区一个Deque
Deque<ProducerBatch> dq = getOrCreateDeque(tp);
// ...
ProducerBatch batch = new ProducerBatch(tp, recordsBuilder, nowMs);
dq.addLast(batch);
}
简单总结
KafkaProducer这个异步发送是建立在生产者和消费者模式上的,send的真正操作并不是直接异步发送,而是把数据放在一个中间队列中。生产者在往内存队列中放入数据,然后一个专有的后台守护线程负责把这些数据真正发送出去。