序列化器
从前文我们了解到,创建一个生产者对象必须指定序列化器,且知道如何使用默认的字符串序列化器,Kafka 还提供了整型和字节数组序列化器,不过这并不足以满足大部分场景的需求。因为我们需要序列化的记录类型会越来越多。
接下来演示如何开发自己的序列化器,并介绍 Avro 序列化器作为推荐的备选方案。
自定义序列化器
如果发送到 Kafka 的对象不是简单的字符串或整型,则可以使用序列化框架来创建消息记录,如 Avro、Thrift 或 Protobuf,或者使用自定义序列化器。强烈建议使用通用的序列化框架。
不过为了了解序列化器的工作原理,也为了说明为什么要使用序列化框 架,下面介绍如何自定义一个序列化器:
假设用一个简单的类来表示一个客户:
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) {
// 不做任何配置
}
@Override
/**
Customer对象被序列化成:
表示customerID的4字节整数 表示customerName长度的4字节整数(如果customerName为空,则长度为0) 表示customerName的N个字节
*/
public byte[] serialize(String topic, Customer data) {
try {
byte[] serializedName;
int stringSize;
if (data == null)
return null;
else {
if (data.getName() != null) {
serializedName = 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() {
// 不需要关闭任何东西
}
}
只要使用这个 CustomerSerializer,就可以把消息记录定义成 ProducerRecord<String, Customer>,并且可以直接把 Customer 对象传给生产者。
这个例子很简单,不过代码看起来太脆弱了。在不同版 本的序列化器和反序列化器之间调试兼容性问题着实是个挑战——你需要比较原始的字节数组。
所以,不建议使用自定义序列化器,而是使用已有的序列化器和反序列化器,比如 JSON、Avro、Thrift 或 Protobuf。
下面我们将会介绍 Avro,然后演示如何序列化Avro记录并发送给 Kafka。
使用Avro序列化
Apache Avro(以下简称 Avro)是一种与编程语言无关的序列化格式,目的是提供一种共享数据文件的方式。
Avro 数据通过与语言无关的 schema 来定义。schema 通过 JSON 来描述,数据被序列化成二进制文件或JSON 文件,不过一般会使用二进制文件。
Avro在读写文件时需要用到 schema,schema一般会被内嵌在数据文件里。
Avro有一个很有意思的特性是,当负责写消息的应用程序使用了新的 schema,负责读消息的应用程序可以继续处理消息而无需做任何改动,这个特性使得它特别适合用在像Kafka 这样的消息系统上。
假设最初的 schema 是这样的:
{
"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。
假设我们已经使用了这个 schema 几个月的时间,并用它生成了几个太字节的数据。现在决定在新版本里做一些修改,用email字段替代faxNumber:
{
"namespace": "customerManagement.avro",
"type": "record",
"name": "Customer",
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"},
{"name": "email", "type": ["null", "string"], "default": "null"}
]
}
更新到新版的 schema后,旧记录仍然包含 faxNumber字段,而新记录则包含email 字段。
部分负责读取数据的应用程序进行了升级,那么它们是如何处理这些变化的呢?
在应用程序升级之前,它们会调用类似 getName()、getId() 和 getFaxNumber() 这样的方法。
如果碰到使用新 schema 构建的消息,getName()和getId()方法仍然能够正常返回,但getFaxNumber()方法会返回null,因为消息里不包含传真号码。
在应用程序升级之后,getEmail() 方法取代了 getFaxNumber() 方法。如果碰到一个使用旧 schema 构建的消息,那么 getEmail() 方法会返回 null,因为旧消息不包含邮件地址。
现在可以看出使用 Avro 的好处了:我们修改了消息的 schema,但并没有更新所有负责读取数据的应用程序,而这样仍然不会出现异常或阻断性错误,也不需要对现有数据进行大 幅更新。
不过有两个需要注意的地方:
• 用于写入数据和读取数据的 schema 必须是相互兼容的。Avro 文档提到了一些兼容性原则。
• 反序列化器需要用到用于写入数据的 schema,即使它可能与用于读取数据的 schema 不 一样。Avro 数据文件里就包含了用于写入数据的 schema,不过在 Kafka 里有一种更好的处理方式,下一小节会介绍。
在Kafka里使用Avro
Avro的数据文件里包含了整个 schema,虽然这样的开销是可接受的,但是如果在每条 Kafka 记录里都嵌入 schema,会让记录的大小成倍地增加。
不过无论怎样,在读取记录时仍需要用到整个 schema,所以要先找到schema才可以反序列化/序列化。
我们遵循通用的结构模式并使用“schema 注册表”来达到目的。
schema 注册表并不属于 Kafka,现在已经有一些开源的 schema 注册表实现。
在下面的例子里,我们使用的是Confluent Schema Registry。该注册表 的代码可以在 GitHub 上找到,你也可以把它作为 Confluent 平台的一部分进行安装。如果你决定使用这个注册表,可以参考它的文档。
我们把所有写入数据需要用到的 schema 保存在注册表里,然后在记录里引用 schema 的标识符。
负责读取数据的应用程序使用标识符从注册表里拉取 schema 来反序列化记录。
序列化器和反序列化器分别负责处理 schema 的注册和拉取。
Avro 序列化器的使用方法与其他序列化器是一样的。
下面的例子演示了如何把生成的 Avro 对象发送到 Kafka(关于如何使用 Avro 生成代码请参考 Avro 文档):
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("value.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer"); //➊
props.put("schema.registry.url", schemaUrl); //➋
String topic = "customerContacts";
Producer<String, Customer> producer =
new KafkaProducer<String, Customer>(props); // ➌
// 不断生成事件,直到有人按下Ctrl+C组合键
while (true) {
Customer customer = CustomerGenerator.getNext();
System.out.println("Generated customer " + customer.toString());
ProducerRecord<String, Customer> record =
new ProducerRecord<>(topic, customer.getId(), customer); //➍
producer.send(record); //➎
}
- ➊ 使用 Avro 的KafkaAvroSerializer 来序列化对象。注意,AvroSerializer 也可以处理原语,这就是我们以后可以使用字符串作为记录键、使用客户对象作为值的原因。
- ➋ **schema.registry.url **是一个新的参数,指向 schema 的存储位置。
- ➌ Customer 是生成的对象。我们会告诉生产者 Customer 对象就是要发送的记录的值。
- ➍ 实例化一个 ProducerRecord 对象,并指定 Customer 为value的类型,然后再传给它一个Customer 对象。
- 最后把 Customer 对象作为记录发送出去,KafkaAvroSerializer 会处理剩下的事情。
但如果使用一般的 Avro 对象而非生成的 Avro 对象该怎么办?不用担心,只需提供 schema 就可以了:
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer"); // ➊
props.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
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\" }" +
"]}";
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";
GenericRecord customer = new GenericData.Record(schema); //➎
customer.put("id", nCustomers);
customer.put("name", name);
customer.put("email", email);
ProducerRecord<String, GenericRecord> data =
new ProducerRecord<String,name, customer);
producer.send(data);
}
- ➊ 仍然使用同样的 KafkaAvroSerializer。
- ➋ 提供同样的 schema 注册表 URI。
- ➌ 这里需要提供 Avro schema,因为我们没有使用 Avro 生成的对象。
- ➍ 对象类型是 Avro GenericRecord,我们通过 schema 和需要写入的数据来初始化它。
- 最后ProducerRecord 的值就是一个 GenericRecord 对象,它包含了 schema 和数据。序列化器知道如何从记录里获取 schema,把它保存到注册表里,并用它序列化对象数据。
分区
之前的例子里,ProducerRecord 对象包含了目标主题、键和值。
Kafka的消息是一个个键值对,ProducerRecord 对象可以只包含目标主题和值,键可以设置为默认的 null,不过大多数应用程序会用到键。
根据键的值分区
键有两个用途:
- 可以作为消息的附加信息。
- 也可以用来决定消息该被写到主题的哪个分区。
拥有相同键的消息将被写到同一个分区。也就是说,如果一个进程只从一个主题的分区读取数据,那么具有相同键的所有记录都会被该进程读取。
要创建一个包含键值的记录,只需像下面这样创建 ProducerRecord 对象:
ProducerRecord<Integer, String> record =
new ProducerRecord<>("CustomerCountry", "Laboratory Equipment", "USA");
这里的键为"Laboratory Equipment"
如果要创建键为 null 的消息,不指定键就可以了:
ProducerRecord<Integer, String> record =
new ProducerRecord<>("CustomerCountry", "USA"); //➊
这里的键为null。
默认分区
如果键值为 null,并且使用了默认的分区器,那么记录将被随机地发送到主题内各个可用的分区上。
分区器使用轮询(Round Robin)算法将消息均衡地分布到各个分区上。
如果键不为空,并且使用了默认的分区器,那么 Kafka 会对键进行散列(使用 Kafka 自己 的散列算法,即使升级 Java 版本,散列值也不会发生变化),然后根据散列值把消息映射到特定的分区上。
这里的关键之处在于,同一个键总是被映射到同一个分区上,所以在进行映射时,我们会使用主题所有的分区,而不仅仅是可用的分区。
这也意味着,如果写入数据的分区是不可用的,那么就会发生错误。但这种情况很少发生。后面介绍Kafka 的复制功能和可用性。
只有在不改变主题分区数量的情况下,键与分区之间的映射才能保持不变。
举个例子,在分区数量保持不变的情况下,可以保证用户 045189 的记录总是被写到分区34。在从分区读取数据时,可以进行各种优化。
不过,一旦主题增加了新的分区,这些就无法保证了——旧数据仍然留在分区 34,但新的记录可能被写到其他分区上。
如果要使用键来映射分区,那么最好在创建主题的时候就把分区规划好,而且永远不要增加新分区。
实现自定义分区策略
默认分区是使用次数最多的分区器。
不过除了散列分区之 外,有时候也需要对数据进行不一样的分区。
比如业务内存在KA(大客户)。如果使用默 认的散列分区算法,KA的数据可能会和其他普通用户分配到相同的分区,导致这个分区比其他分区要大一些。服务器可能因此出现存储空间不足、处理缓慢等问题。
所以需要要给KA分配单独的分区,然后使用散列分区算法处理其他客户。
下面是一个自定义分区器的例子:
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();
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总是被分配到最后一个分区
// 其他记录被散列到其他分区
return (Math.abs(Utils.murmur2(keyBytes)) % (numPartitions - 1))
}
public void close() {}
}
- ➊ Partitioner 接口包含了 configure、partition 和 close 这 3 个方法。这里我们只实现 partition 方法,不过不应该在 partition 方法里硬编码客户的名字,而应该通过 configure方法传进来。
- ➋ 我们只接受字符串作为键,如果不是字符串,就抛出异常。
头部信息(Headers)
除了键和值之外,记录还可以包括headers。
记录头使您能够添加一些关于 Kafka 记录的元数据,而无需向记录本身的键/值对添加任何额外信息。
Headers通常用于行文(lineage),以表明记录中数据的来源,并用于根据头信息进行路由或追踪消息,而不必解析消息本身(也许消息是加密的,路由器没有访问数据的权限)。
Headers被实现为键/值对的有序集合。键始终是字符串,值可以是任何序列化对象——就像消息值一样。
下面是一个小示例,展示了如何将标头添加到 ProduceRecord:
ProducerRecord<String, String> record =
new ProducerRecord<>("CustomerCountry", "Precision Products", "France");
record.headers().add("privacy-level","YOLO".getBytes(StandardCharsets.UTF_8));
拦截器(Interceptors)
有时你可能希望在不修改其代码的情况下修改 Kafka 客户端应用程序的行为,比如希望为组织中的所有应用程序添加相同的行为。或者你可能无法访问源码。
Kafka的ProducerInterceptor拦截器提供了两个关键方法:
- ProducerRecord<K, V> onSend(ProducerRecord<K, V> record)
这个方法将在产生的记录被发送到Kafka之前被调用,实际上在它被序列化之前就已经被调用。
当重写这个方法时,你可以捕获关于发送记录的信息,甚至修改它。
只要确保从这个方法返回一个有效的ProducerRecord。这个方法返回的记录将被序列化并发送给Kafka
- void onAcknowledgement(RecordMetadata metadata, Exception exception)
如果当Kafka对一个发送的确认作出回应时,这个方法将被调用。该方法不允许修改来自 Kafka 的响应,但可以捕获有关响应的信息。
**使用生产者拦截器的场景包括:捕获监控和跟踪信息;使用标准标头增强消息,特别是用于跟踪消息;**和编辑敏感信息。
下面是一个非常简单的生产者拦截器的例子。这个拦截器只是简单地统计了在特定时间窗口内发送和接收的消息:
public class CountingProducerInterceptor implements ProducerInterceptor {
ScheduledExecutorService executorService =
Executors.newSingleThreadScheduledExecutor();
static AtomicLong numSent = new AtomicLong(0);
static AtomicLong numAcked = new AtomicLong(0);
public void configure(Map<String, ?> map) {
Long windowSize = Long.valueOf(
(String) map.get("counting.interceptor.window.size.ms"));
executorService.scheduleAtFixedRate(CountingProducerInterceptor::run,
windowSize, windowSize, TimeUnit.MILLISECONDS);
}
public ProducerRecord onSend(ProducerRecord producerRecord) {
numSent.incrementAndGet();
return producerRecord;
}
public void onAcknowledgement(RecordMetadata recordMetadata, Exception e) {
numAcked.incrementAndGet();
}
public void close() {
executorService.shutdownNow();
}
public static void run() {
System.out.println(numSent.getAndSet(0));
System.out.println(numAcked.getAndSet(0));
}
}
正如我们前面提到的,可以在不更改客户端代码的情况下应用生产者拦截器。
要使用前面的拦截器应用在kafka-console-producer(一个使用Apache Kafka发行的示例应用程序)上,请按照以下三个简单步骤:
To use the preceding interceptor with kafka-console-producer, an example application that ships with Apache Kafka, follow these three simple steps:
- 将你的jar包添加到classPath:
export CLASSPATH=$CLASSPATH:~./target/CountProducerInterceptor-1.0- SNAPSHOT.jar
- 创建一个包含下列配置的配置文件:
interceptor.classes=com.shapira.examples.interceptors.CountProducer Interceptor counting.interceptor.window.size.ms=10000
- 像往常一样运行应用程序,但请确保包括您在上一步中创建的配置是正确的:
bin/kafka-console-producer.sh --broker-list localhost:9092 --topic interceptor-test --producer.config producer.config