Apache Kafka客户端KafkaProducer

1,554 阅读13分钟

更多大数据学习文章或总结,可微信搜索 知了小巷,关注公众号并回复 资料 两个字,有大数据学习资料和视频。

Apache Kafka客户端KafkaProducer

涉及源码部分,kafka版本:version=2.6.1

内容提纲

  1. KafkaProducer消息分区机制
  2. KafkaProducer消息压缩
  3. KafkaProducer保证消息不丢失
  4. 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…

压缩算法的对比

github.com/facebook/zs…

在吞吐量方面: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端程序有以下简单的几个步骤:

  1. new一个Properties对象props,配置Producer端相关的参数
  2. new一个KafkaProducer<>(props),创建KafkaProducer对象实例producer
  3. 调用producer的send方法发送消息
  4. 不要忘记调用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的真正操作并不是直接异步发送,而是把数据放在一个中间队列中。生产者在往内存队列中放入数据,然后一个专有的后台守护线程负责把这些数据真正发送出去。