03 Confluent_Kafka权威指南 第三章: Kafka 生产者:向kafka写消息

CHAPTER 3 Kafka Producers: Writing Messages to Kafka

无论你将kafka当作一个队列、消息总线或者数据存储平台,你都需要通过一个生产者向kafka写入数据,通过一个消费者从kafka读取数据。或者开发一个同时具备生产者和消费者功能的程序来使用kafka。
例如,在信用卡交易处理系统中,有一个客户端的应用程序(可能是一个在线商店)在支付事物发生之后将每个事物信息发送到kafka。另外一个应用程序负责根据规则引擎去检查该事物,确定该事物是否被批准还是被拒绝。然后将批准/拒绝的响应写回kafka。之后kafka将这个事物的响应回传。第三个应用程序可以从kafka中读取事物信息和其审批状态,并将他们存储在数据库中,以便分析人员桑后能对决策进行检查并改进审批规则引擎。
apache kafka提供了内置的客户端API,开发者在开发与kafka交互的应用程序时可以使用这些API。
在本章中,我们将学习如何使用kafka的生产者。首先对其设计理念和组件进行概述。我们将说明如何创建kafkaProducer和ProducerRecord对象。如何发送信息到kafka,以及如何处理kafak可能返回的错误。之后,我们将回顾用于控制生产者行为的重要配置选项。最后,我们将深入理解如何使用不同的分区方法和序列化。以及如何编写自己的序列化器和分区器。
在第四章我们将对kafka消费者客户端和消费kafka数据进行阐述。

Third-Party Clients 第三方的客户端

除了内置的客户端之外,kafka还有一个二进制协议,这意味着,应用程序可以通过这个协议写入消息到kafka或者消费kafka的消息。有多个不同语言实现的客户端,这不仅为java程序使用kafka提供了样例,也为c++,python、go等语言提供了简单的方法。
这些客户端不是Apache kafka项目的一部分。打算在项目wiki中维护了一个非java客户端列表,外部客户端不在本章讨论范围之内。

Producer Overview

应用程序可能需要向kafka写入消息的原因有很多,如:记录用于审计和分析的用户活动、记录指标、存储日志消息、记录来自只能设备的信息、与其他应用程序异步通信、在写入数据库之前进行缓冲等等。
那些不同的用例也意味着不同的需求:每个消息都是关键的吗?或者我们能容忍消息丢失吗?我们能容忍消息重复吗?我们需要支持严格的延迟和吞吐量需求吗?
另外一种情况是可能用来存储来自网站的单击信息。在这种情况下,我们可以容忍一些消息丢失或者消息重复。只要不影响用户体验,高延迟也可以容忍。换句话说,我们不介意消息达到kafka需要的几秒钟时间,只要用户点击链接后立即进行响应就可以。吞吐量将决定我们对网站的预期活动水平。
不同的需要将影响使用 producer API向kafka发送消息的方式和使用的配置。
虽然producer API非常简单,但当我们发送消息时,生产者的内部还有很多步骤。下图展示了发送数据到kafak的主要步骤。
(img-pQDTnDZc-1593415992716)(439373F5957D4B22A1D5EC15FD328D0E)]
我们通过创建一个producerRecord开始发送消息给kafka。它必须包含我们想要发送记录的主题和一个消息内容。此外还可以选择指定key或者分区。发送PoducerRecord之后,生产者要做的第一件事情就是将key和对象序列化为字节数组。以便网络能发送他们。
接下来,数据被发送到分区器partitioner,如果我们在ProducerRecord中指定了分区,分区器partitioner将不做任何处理,直接返回我们指定的分区。如果没有指定,那么分区器partitioner将为我们选择一个分区,通常基于ProducerRecord的key。一旦选择了一个分区,生产者就指定该记录将要发送到哪个topic的分区。然后它将这些记录添加到一个批次中,这个批次的消息将发送到相同的topic的分区。一个单独的线程负责将这些批次的激励发送到适当的kafkabroker。当broker接收到消息之后,他返回一个响应。如果消息成功写入kafka,则返回一个RecordMetadata对象,其中包含topic、分区和分区中的offset。如果broker写入失败,则返回一个错误。当生产者收到一个错误,在放弃这条消息错误之前,可以进行多次重试。

Constructing a Kafka Producer

将消息写入kafka的第一步是创建一个包含各种属性的生产者对象,一个kafka的生产者包含三个基本属性:

  • bootstrap.servers
    主机列表,生产者将用于建立到kafka集群broker的初始连接。此列表不需要包括所有的broker,因为生产者在初始连接之后可以获得更多的信息。但是建议至少包括两个broker,这样如果一个broker宕机,生产者仍然能够连接到集群。
  • key.serializer 用于给kafka每条消息key序列化的类的名称。kafka broker希望用字节数组做为消息key和value序列化的方式。但是生产者运行使用任何参数类型将任何java对象做为key和value发送。这使得代码的可读性更强。但是也意味着生产者必须指定如何将这些对象转换为字节数组。key.serializer 应该设置为一个实现了org.apache.kafka.common.serialization.Serializer接口的类的名称。生产者将用这个类将key的对象序列化为字节数组。kafka的客户端jar包中包括ByteArraySerializer(它的序列化方式很简单),StringSerializer和IntegerSerializer,因此,如果设置通用类型,就不需要实现自己的序列化器,即使你只想发送value不携带key,key.serializer仍需要设置。
  • value.serializer 用与生产者将消息发送到kafka的value的序列化类名称。设置方式与set key.serializer将消息的key序列化字节数组的类名相同。value.serializer将把value对象序列化为字节数组。

如下代码展示了如何通过设置这些强制的基本参数和使用默认值来创建一个新的生产者:

//创建一个Properties对象
private Properties kafkaProps = new Properties();
kafkaProps.put("bootstrap.servers", "broker1:9092,broker2:9092");
//使用字符串做为key和value的序列化方式
kafkaProps.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
kafkaProps.put("value.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
//设置适当的key和value的序列化类型到Properties,创建一个生产者实例
producer = new KafkaProducer<String, String>(kafkaProps);

对于这样一个简单的接口,对生产者的行为控制大多是通过设置正确的配置属性来完成。Apache Kafka官方文档涵盖了所有的配置选项,我们将在本章后续对重要配置选项展开讨论。
一旦对生产者进行了实例化,就可以开始发送消息。发送消息由三种主要方法:

  • Fire-and-forget (发后即忘) 我们向broker服务端发送消息,并不关心是否真正送达。大多数情况下,它会成功送达。因为kafka生产者有基于高可用性的重试机制。但是这种方法会导致一些消息丢失。
  • Synchronous send 同步发送,我们发送一条消息,send方法返回一个Future对象。之后我们使用get方法来等待Future对象的返回值,来确认消息是否发送成功。之后再进行下一条消息的发送。
  • Asynchronous send 异步发送。我们再send方法中发送一个callback对象。该对象的callback函数在收到来自kafka broker上的响应之后会被触发。

在如下的实例中,我们将看懂如何使用这些方法发送消息,以及如何处理在发送消息过程中产生的各种类型的错误。
虽然本章的实例中都是基于单线程,但是生产者对象可以用于多线程发送。你可以用一个单线程来发送生产者消息。如果你需要更好的吞吐量,也可以添加更多的生产者线程来实现。一旦生产者的吞吐量受限,你可以通过增加更多的线程来提高。

Sending a Message to Kafka

发送消息最简单的方法如下:

//生产者通过ProducerRecord对象,发送消息,实例化。ProducerRecord对象有多个构造函数,我们将在后续讨论。需要将发送数据的topic名称以及我们发送的key和value参数。
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Precision Products",
"France");
try {
//使用生产者的send方法发送消息。send方法将消息发送到特定的缓冲区,并通过特定的线程发送给broker。send方法返回要给RecordMetadata对象。由于我们没有对这个返回值做处理,因此无法确认是否发送成功。在可以容忍消息丢失的情况下,可以采用此方法发送,但是在生产环节中通常不这么处理。
producer.send(record);
} catch (Exception e) {
//虽然我们互联了在发送消息给broker的过程中broker本身可能产生的错误,
//但是如果生产者在发送消息给kafka之前遇到错误,我们仍然会得到一个异常。
//SerializationException在序列化消息失败的时候抛出。
//如果缓冲区已满,则抛出BufferExhaustedException或者TimeoutException,
//如果发送线程被中断,则返回InterruptException异常。
e.printStackTrace();
}
Sending a Message Synchronously

最简单的同步发送方法如下:

ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Precision Products", "France");
try {
//我们用Future.get方法来等待kafka的响应。如果消息没有成功发送给kafka,这个方法将抛出一个异常。如果没有异常,我们将获得一个RecordMetadata对象,我们可以用它来获得写入消息的offset。
    producer.send(record).get();
} catch (Exception e) {
//在向kafka发送数据之前有任何错误,kafka的broker就会返回一个不可重试的异常,如果我们尝试了最大的重试次数任然没有成功,那么将会遇到一个异常。在本例中,我们捕获了所有的异常并打印。
    e.printStackTrace();
}

KafkaProducer有两种类型的错误,可重试的异常时哪些可以通过再次发送消息来解决的异常。例如,当连接建立错误,可以通过重试建立新的连接。当分区选出新的leader的时候,可以解决无leader错误。KafkaProducer可以配置为对这些错误进行自动重试,因此只有当重试次数达到最大还没有解决这些错误时,程序代码才会返回不可重试异常。有些错误异常无法通过重试来解决,例如,消息的大小太大,这种情况下,kafkkaProducer不会尝试重试,将立即返回错误。

Sending a Message Asynchronously

假定我们应用程序和kafka集群之间往返时间为10ms,如果我们发送每条消息后等待回复。发送100调消息大约1秒。如果通过异步方式,我们知识发送消息而不等待任何回复,那么我们发送100条消息机会不会花费任何时间。在大多数情况下,我们真的不需要对一个kafka回答的消息进行应答,消息记录发送成功后写入的topic、分区和offset,生产者程序通常不需要这些。另一方面,我们只需要知道什么时候发送消息失败了,这样我们可以通过抛出异常,记录错误,或者将消息写入错误记录文件供后续分析。
为了异步发送消息并同时处理错误场景,生产者在发送记录时添加回调。下面时我们如何使用回掉的例子:

private class DemoProducerCallback implements Callback {
//使用回调功能,你需要实现org.apache.kafka.clients.producer.Callback接口
//这个接口只有一个函数 就是 onCompletion
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e) {
if (e != null) {
//如果kafka返回了一个非空异常,我们在此捕获并打印
//在生产环境中会采用更健壮的异常处理方法进行处理
e.printStackTrace();
}
}
}
//ProducerRecord 和之前的发送方式一样
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Biomedical Materials", "USA");
//在发送消息记录的时候,我们将回调的callback对象传递
producer.send(record, new DemoProducerCallback());

Configuring Producers

到目前为止,我们看懂的生产者配置参数非常少,只有一些强制的基本参数。如bootstrap.servers 和服务端URL还有序列化参数。
生产者具有大量的配置参数,大多数在Apache Kafka的官方文档中有描述,许多参数都有合理的默认值,所以没有理由对每个值都进行修改。但是,有些参数会对生产者的内存使用、性能和可靠性产生重大影响,我们在此进行回顾:

acks

ack参数控制了生产者在发送消息到broker之后多少个分区必须接收到消息才算写入成功。这个参数会对消息发送过程中是否会丢失产生影响。其允许的值主要有如下三个:

  • ack=0 在消息成功发送之前,生产者不会等待来自broker的回复。这意味着如果发生了错误,生产者不会知道其发送的消息有可能会丢失。但是由于生产者不会等待broker的任何响应,可以在带宽满负荷的情况下来发送消息。因此可以以此来实现高吞吐量。
  • acks=1 leader副本收到消息之后,生产者较高从broker收到成功的响应。如果消息不能写入leader(如leader宕机但是新的leader还没有选出)生产者将收到一个错误的响应。避免潜在的数据丢失。如果leader宕机,而没有此消息的副本呗选举为新的leader(脏选举),这会造成消息丢失。在这种情况下,吞吐量取决于时同步发送消息还是异步发送消息。如果我们的客户端代码等待服务器的应答(通过调用返回Future的get方法),显然会显著增加发送延时。如果客户端使用回调机制异步发送,延迟将被隐藏,但是吞吐量将受到正在处理的消息的数量限制(寄生产者在收到来自服务器响应之前将发送多少条消息)。
  • ack=all 在所有的副本都收到消息之后,生产者才会收到成功的响应。这是最安全的方式,因为可以确定多个broker收到消息,在出现宕机的情况下消息不丢失(在第五章将讨论更多的信息)。然而,延迟将会比ack=1的情况还高,因为需要等待不止一个broker接收到消息。
buffer.memory

这将设置生产者用于缓冲等待发送给broker的消息的内存大小。如果应用程序发送消息的速度比发送到broker的速度快,则生产者将会耗尽缓冲区空间,并且抛出异常。基于block.on.buffer.full 配置。(在0.9.0.0取代了max.block.ms,允许阻塞一段时间之后再抛出异常)。

compression.type

默认情况下,消息不会被压缩,这个参数可以设置为snappy、gzip或者lz4,设置了压缩参数之后,再将数据发送给broker之前,将使用配置的压缩算法对数据进行压缩。snappy也锁算法是google发明的,它提高了良好的压缩比,同时具有较低的CPU开销和良好的性能。因此在考虑网络带宽的情况下,推荐使用snappy压缩算法。GZIP压缩算法通常会使用更高的CPU耗时,但是压缩比会更高。因此在网络带宽受限的情况下使用,通过启用压缩算法,可以降低网络的的利用率和存储空间。这些通常是发送消息到kafka的瓶颈。

retries

当生产者收到服务端的错误之后,这个错误可能是暂时的(比如分区缺少leader)。在这种情况下,通过设置retries参数的值将控制生产者在放弃发送消息自强进行重试的次数。默认情况下,生产者将等待重试的时间间隔为100ms,但是你可以使用retry.backoff.ms参数来控制重试的时间。我们建议对broker的选举恢复时间进行测试。并设置重试次数和重试间隔时间,使重试花费的总时间大于kafka集群的故障恢复时间。否则生产者可能过早放弃消息。并不是所有的错误都能够进行重试,有些错误不是暂时性的,此类错误不建议重试(如消息太大的错误)。通常由于生产者为你处理重试,所以在你的应用程序逻辑中自定义重试将没用任何意义。你最好是将精力放在处理不可重试的错误或者失败的情况上面。

batch.size

当多个消息记录被发送到统一个分区的时候,生产者将对这些消息进行批次处理。这个参数将控制每个批次处理的内存字节数(注意,不是消息条数)。当批次大小已满的时候,将发送批次中的所有消息。但是,这并不是说生产者将等待批次全部装满。生产者也会发送半批次或者发送只有一条消息的批次。因此,将批次设置得较大的话,也不会造成消息的发送延迟。它只会为批次使用更多内存。将批次处理大小设置得太小将增加一些额外的开销,因为生产者将要频繁的发送消息。

linger.ms

linger.ms控制在发送当前批次处理之前等待其他消息的时间,KafkaProducer在当前批次已满或者耗时已经达到linger.ms时,将会把批次中的消息进行发送。默认情况下有生产者发送线程可用,生产者就会发送消息,即便一个批次中只有一条消息。linger.ms的值最好设置大于0,我们要求生产者等待几毫秒,以便在发送消息之前将其他消息添加到批次中。这虽然会增加一些延迟,但是也增加了吞吐量。(因为我们一次发送了更多的消息,对每条消息而言,平均的时间开销会更小)。

client.id

客户端的ID,可以是任意字符串,broker将使用它来标识从哪个客户端发送的消息。它用于日志记录和统计分析。

max.in.flight.requests.per.connection

控制生产者在没有接收响应的情况下可以发送给服务器的消息数量,设置这个值会增加内存的使用,同时提高了吞吐量。但是设置太高也会造成吞吐量降低,因为会导致批处理的效率降低。将此值设置为1将保证消息按照发送顺序写入到broker,即使加上重试机制也能保障顺序性。

timeout.ms, request.timeout.ms, and metadata.fetch.timeout.ms

这些参数在发送数据和请求元数据时,生产者等待服务端响应的时间。如果超时而没有应答,生产者将返回重试或者响应一个错误超时(通过异常或者发送回调)。timeout.ms控制broker等待同步副本确认消息以满足acks配置的时间。如果这个时间超过了ack响应时间则返回一个错误。

max.block.ms

这个参数控制在调用send方法和通过partitionsFor方法请求元数据时生产者的阻塞时间。当生产者的发送缓冲区已满或者元数据不可用时这些方法将阻塞。当阻塞时间超过max.block.ms时返回一个超时的异常。

max.request.size

此设置控制生产者发送的请求的大小,它限制了可以发送最大消息的大小,间接限制了生产者在一个请求中可以发送消息的数量。例如,这个size设置为1M,那么你既可以发送最大为1M的一条消息,也可以发送1000条大小为1KB的消息。此外,broker对它能接收消息的最大大小也有自己的限制(message.max.bytes)。这些配置最好一致,这样生产者就不会试图将broker拒绝的消息重发。

receive.buffer.bytes and send.buffer.bytes

这是socket在写入和读取数据时可以使用的tcp发送和接收的缓冲区的大小。如果这个值设置为-1,那么将会使用操作系统的默认设置。当生产者和消费者位于不同的数据中心跨网络通信时,增加这些缓冲区的大小是个不错的选择,因为这些网络链接通常具有较高的延迟和更低的带宽。

Ordering Guarantees 顺序性担保

apache kafka能确保分区数据的顺序性。这意味着如果消息以特定的顺序从生产者发送,broker将按照顺序写入分区,所有的消费者将按照顺序读取他们。对于某些场景,顺序性特别重要。如存款和取款就有很大的不同。假定账户有100美元,必须先存款操作之余额增加了之后取款才能成功。否则如果先取款再存款就可能导致取款失败。反之,对某些场景,顺序性就不那么重要了。
将重试参数设置为非零和max.in.fights.requests.per.session 大于1,这意味着一旦broker失败,无法处理第一批消息。之后第二批消息成功处理,第一批重试成功,这将导致顺序性被破坏。
通常再可靠性要求较高的系统中,将重试次数设置为0时不可选的,因此,要保证顺序性的关键就是设置in.flight.requests.per.session=1,以确保一批消息重试的时候,将不会发送其他的消息。但是这将严重限制生产者的吞吐量。因此只有在顺序性要求特别高的时候才使用它。

Serializers

如前文描述,生产者的配置参数中需要强制配置序列化器。我们已经了解如何使用默认的字符串序列化器。kafka还包括了整数和字节数组的序列化器,这并没有涵盖大部分用例。如果你希望将序列化更加定制化,那么我们将展示如何编写自定义的序列化器。之后介绍一下Avro序列化器做为一个i而推荐的替代方案。

Custom Serializers

当需要发送给kafka的对象不是简单的字符串或者整数时,你可以选择使用序列化库avro、thrift或者prtobuf来创建或者为正在使用的对象创建自定义的序列化器。我们强烈推荐使用通用的序列化库。为了理解序列化器是如何工作的和使用序列化有哪些好处,我们编写一个自定义的序列化器进行详细介绍。
假定创建一个简单的类来表示客户信息:

public class Customer {
    private int customerID;
    private String customerName;
    public Customer(int ID, String name) {
        this.customerID = ID;
        this.customerName = name;
    }
    public int getID() {
        return customerID;
    }
    public String getName() {
        return customerName;
    }
}

假定我们为客户信息创建了一个定制的序列化器,如下:

import org.apache.kafka.common.errors.SerializationException;
import java.nio.ByteBuffer;
import java.util.Map;
public class CustomerSerializer implements Serializer<Customer> {
	@Override
	public void configure(Map configs, boolean isKey) {
// nothing to configure
	}

	@Override
/**
 We are serializing Customer as:
 4 byte int representing customerId
 4 byte int representing length of customerName in UTF-8 bytes (0 if name is
 Null)
 N bytes representing customerName in UTF-8
 */
	public byte[] serialize(String topic, Customer data) {
		try {
			byte[] serializedName;
			int stringSize;
			if (data == null)
				return null;
			else {
				if (data.getName() != null) {
					serializeName = data.getName().getBytes("UTF-8");
					stringSize = serializedName.length;
				} else {
					serializedName = new byte[0];
					stringSize = 0;
				}
			}
			ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + stringSize);
			buffer.putInt(data.getID());
			buffer.putInt(stringSize);
			buffer.put(serializedName);
			return buffer.array();
		} catch (Exception e) {
			throw new SerializationException("Error when serializing Customer to
			byte[] " + e);
		}
	}

	@Override
	public void close() {
		// nothing to close
	}
}

使用这个CustomerSerializer,生产者允许你定义ProducerRecord<String, Customer>发送客户数据,并直接将客户的对象直接传递到生产者。这个示例非常简单,但是代码非常脆弱。例如,我们有很多的客户,并且需要将customerID改为Long,或者如果我们决定向客户信息中增加一个新字段startDate,那么在维护新旧消息的兼容性之间就会遇到很严重的问题。调试不同版本的序列化和反序列化器之间的兼容性问题非常具有挑战性,你需要还原成原始的字节数组。更糟糕的是多个团队将数据写入kafka,他们需要使用相同的序列化器的话,就需要同时对各自的代码就行修改。
由于这些原因,我们建议使用现有的序列化器和反序列化器。比如,JSON、Apache Avro、Thrift、或者Protobuf。在下一节中,我们会对apache avro进行描述,然后说明如何将序列化之后avro记录发送到kafka。

Serializing Using Apache Avro

Apache avro是一种语言无关的数据序列化格式。这个项目是由Doung Cutting创建,目的是提供一种与大量与用户共享的数据文件格式。Avro数据是采用一种与语言无关的模式进行描述。模式通常用json描述,序列化通常是二进制文件,不过通常也支持序列化为json。Avro假定模式在读写文件时出现,通常将模式嵌入文件本身。
Avro一个有趣的特性就是,它适合在消息传递系统中向kafka之中,当写消息的程序切换到一个新的模式时,应用程序读取可以继续处理的消息,而无须更改或者更新。
假定原模式为:

{"namespace": "customerManagement.avro",
  "type": "record",
  "name": "Customer",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string""},
    {"name": "faxNumber", "type": ["null", "string"], "default": "null"}
  ]
}

上述id和name时强制的,faxNumber则是可选的,默认值为null。
我们使用这个模式一段时间,并且用这个模式生成了几个TB的数据,限制我们决定升级,去掉faxNumber字段,改为email字段。
新的模式如下:

{"namespace": "customerManagement.avro",
  "type": "record",
  "name": "Customer",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": ["null", "string"], "default": "null"}
  ]
}

现在升级到新版本之后,旧的记录将包含faxNumber,而新的记录将包含email。在许多组织中,升级时很慢的,通常要经历好几个月。因此我们需要考虑仍然使用faxNumber的预升级方案来兼容升级之后只有邮件的所有kafka数据。
读取应用程序将包含对类似于getName、getId和getFaxNumer的方法调用,如果遇到新模式编写的消息,则getName和getId将继续工作,getFaxNumber将返回空。因为新的消息中不包括FaxNumber。现在我们升级了读取的应用程序,他不再具有getFaxNumber方法,而是getEmail方法。如果遇到旧的模式编写的消息,getEmail方法将返回null,因为旧的消息不包含email地址。
这个例子说明了使用avro的好处,即使我们在没由更改读取数据的全部应用程序的情况下而更改了消息中的模式,也不会出现异常和中断错误,也不需要对全部数据进行更新。
然而,有如下两点是需要注意的:

  • 用于写入的数据模式和用于读取消息所需的模式必须兼容,Avro文档中包括兼容性规则。
  • 反序列化器将需要访问在写入数据时使用模式。即使它于访问数据的应用程序所期望的模式不同。在avro文件中,写入模式包含在文件本身,但是有一种更好的方法来处理kafka消息,在下文中继续讨论。
Using Avro Records with Kafka

Avro文件在数据文件中存储整个模式会造成适当的开销,与之不同的时,如果在每个记录中都存储模式文件的话,这样会造成每条记录的大小增加一倍以上。
但是avro在读取记录时任然需要提供整个模式文件,因此我们需要在其他地方对模式文件进行定义。为了实现这一点,我们遵循一个通用的体系结构,使用一个模式注册表。模式注册表不是apache kafka的一部分,但是有几个开源软件可供选择,在本例中,我们将用confluent的模式注册表。你可以在github上找到模式注册表的源码,也可以将其整合为融合性平台,如果你决定使用模式注册表,那么我们建议对文档进行检查。
将用于向kafka写入数据的所有模式存储在注册表中,然后,我们只需要将模式的标识符存储在生成给kafka的记录中。然后,消费者可以使用标识符从模式注册表中提取记录并反序列化数据。关键在于所有的工作都是在序列化和反序列化中完成的,在需要时将模式取出。为kafka生成数据的代码仅仅只需要使用avro的序列化器,与使用其他序列化器一样。如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4OAqq8pe-1593415992719)(FC706043B8514F198152C5DB1926C318)]
下文是如何为kafka生成avro对象的示例(请参考avro官方文档):

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
//我们用AvroSerializer来序列化我们的对象,请注意,AvroSerializer也能处理基本类型,这也是我们用其处理key的原因。
props.put("key.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("value.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
//schema.registry.url 这是一个新参数,指我们存储模式的具体位置
props.put("schema.registry.url", schemaUrl);
String topic = "customerContacts";
int wait = 500;
//Customer是我们生成的对象
Producer<String, Customer> producer = new KafkaProducer<String,
Customer>(props);
// We keep producing new events until someone ctrl-c
while (true) {
    Customer customer = CustomerGenerator.getNext();
    System.out.println("Generated customer " +
    customer.toString());
    //我们使用Customer做为值的类型实例化ProducerRecord
    ProducerRecord<String, Customer> record =
    new ProducerRecord<>(topic, customer.getId(), customer);
    //就这样我们使用KafkaAvroSerializer进行处理
    producer.send(record);
}

如果你需要使用通用的avro对象(模式放在每条消息中)而不是生成的avro对象,你只需要提供模式即可:

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
//仍然使用相同的KafkaAvroSerializer
props.put("key.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("value.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
//提供相同的注册表URL
props.put("schema.registry.url", url);
//还需要提供模式 
String schemaString = "{\"namespace\": \"customerManagement.avro\",
\"type\": \"record\", " +
"\"name\": \"Customer\"," +
"\"fields\": [" +
"{\"name\": \"id\", \"type\": \"int\"}," +
"{\"name\": \"name\", \"type\": \"string\"}," +
"{\"name\": \"email\", \"type\": [\"null\",\"string
\"], \"default\":\"null\" }" +
"]}";
//我们的数据对象此时是 Avro GenericRecord,我们传入模式对数据进行格式化
Producer<String, GenericRecord> producer =
new KafkaProducer<String, GenericRecord>(props);
Schema.Parser parser = new Schema.Parser();
Schema schema = parser.parse(schemaString);
for (int nCustomers = 0; nCustomers < customers; nCustomers++) {
    String name = "exampleCustomer" + nCustomers;
    String email = "example " + nCustomers + "@example.com"
    //ProducerRecord就是一个包含模式和数据的GenericRecord
    GenericRecord customer = new       GenericData.Record(schema);
    customer.put("id", nCustomer);
    customer.put("name", name);
    customer.put("email", email);
    ProducerRecord<String, GenericRecord> data =
    new ProducerRecord<String,
    GenericRecord>("customerContacts",
    name, customer);
    producer.send(data);
}
}

Partitions 分区

在之前的例子中,我们创建了ProducerRecord对象包括topic的名称、key和value。kafka的消息是K-V对,虽然可以创建一个ProducerRecord只有一个topic和一个值,默认将key设置为空。但是大多数应用程序都会生成带有key的记录。keys有两个目的,一是可以为消息提供补充信息,另外就是他们还将决定消息写入到哪个分区。具有相同key的所有消息将进入相同的分区,这意味着如果一个进程只订阅一个主题中的特定分区。相同key的所有记录将被相同的进程消费。要创建一个key-value记录,只需要创建一个ProducerRecord,如下所示:

ProducerRecord<Integer, String> record =
new ProducerRecord<>("CustomerCountry", "Laboratory Equipment", "USA");

如果需要创建key为空的记录,你可以使用如下方法:

ProducerRecord<Integer, String> record =
new ProducerRecord<>("CustomerCountry", "USA");
//上述位置如果只有两个参数,则默认不使用key

当key为空且使用默认的分区器的时候,记录将随机发送到topic的一个可用分区。将使用轮询算法来平衡分区之间的消息。
如果key存在,且使用了默认的分区器,那么kafka将对该key进行散列(kafka 的broker内部自己实现的散列算法,当java升级时,其值不会改变)。使用散列结果将消息映射到特定的分区。由于key总是映射到相同的分区在业务上很关键,因此我们使用topic中的所有分区来计算映射,而不是仅仅是可用分区才参与计算。这意味着,如果某个数据在写入数据的时候如果不可用,则可能会出现错误。只不过这种错误非常少见。我们将在第六章讨论kafka的复制机制和可用性。
key到分区的映射只有在topic的分区数量不发生改变时才是一致的。因此只要分区数量保持不变,你可以确保如 045189的key总是被写到34分区。这允许从分区消费数据时进行各种优化,但是,在向topic添加新分区的时候,这就无法进行保证了,旧的数据将保留在34分区中,但是新的记录将写入到不同的分区。当分区key很重要的时候,最简单的解决方案是创建具有足够分区的topic。(参见第二章)并且永远不要添加新的分区。

Implementing a custom partitioning strategy

到目前为止,我们已经讨论了最常用的默认分区器的特性。但是,kafka并没有限制你对分区进行自定义散列。有时候业务上也需要将数据进行不同的分区。假定你是一个B2B供应商,你最大的客户是一家称为banana的手持设备公司。如果你和这个客户超过总交易量的10%,如果使用默认的分区策略,banana将和其他的客户共享分区,导致一个分区的数据可能是其他分区的数倍。这可能导致broker节点的空间耗尽,处理速度变慢灯灯。我们真正想要的是给banana用户一个单独的分区,然后其他用户按散列均分。
我们可以参考如下生产者代码:

import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.record.InvalidRecordException;
import org.apache.kafka.common.utils.Utils;
public class BananaPartitioner implements Partitioner {
//分区接口包括配置、分区和关闭方法,这里我们只实现分区,客户名应该通过配置传递,这里我们为了简单就采用了在代码中硬编码
	public void configure(Map<String, ?> configs) {
	}

	public int partition(String topic, Object key, byte[] keyBytes,
	                     Object value, byte[] valueBytes,
	                     Cluster cluster) {
		List<PartitionInfo> partitions =
				cluster.partitionsForTopic(topic);
		int numPartitions = partitions.size();
		//我们期望字符串组成的key 如果不是抛出异常
		if ((keyBytes == null) || (!(key instanceOf String)))
		throw new InvalidRecordException("We expect all messages
				to have customer name as key")
		if (((String) key).equals("Banana"))
			return numPartitions; // Banana will always go to last
		partition
// Other records will get hashed to the rest of the
				partitions
		return (Math.abs(Utils.murmur2(keyBytes)) % (numPartitions - 1))
	}

	public void close() {
	}
}

Old Producer APIs

在本章中,我们讨论了java生产者的客户端,它是org.apache.kafka客户端jar包的一部分。然而,Apache kafka任然有两个用scala编写的旧的客户端,他们也是kafka的一部分。这些生产者被称为SyncProducers(取决于ack的参数可以在发送额外消息之前等待服务器ack每条消息或者一批消息)和AsyncProducer(a’c’k在后台调用,在单独的线程中发送,并且不向客户端提供关于成功的反馈)。
由于当前生产者支持上述两种行为,并且为开发任意提供了更多的可靠性的控制,所以我们不对旧的API进行讨论,如果你对此感兴趣,可以参考Apache Kafka官方文档了解更多的消息。

Summary

我们从一个简单的生产者示例开始了本章,一个仅用10行代码将事件消息发送给kafka的demo。我们通过添加错误处理并实验同步和异步来丰富demo。然后我们对生产者的重要配置参数进行探讨,并看到了他们是如何修改生产者行为的。我们讨论了序列化器,它允许我们控制写入kafka的事件格式,我们深入研究了avro,踏实序列化的多种实现方式之一,在kafka中非常常用,在本章的最后,我们讨论了kafka中的分区器并给出了一个高级定制分区器的示例。
现在我们知道了如何为kafka编写事件,在第四章中,我们将学习kafka的消费事件。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值