Producer分区策略------粘性分区、轮询分区、Key-Ordering分区
欢迎关注,分享更多原创技术内容~
微信公众号:ByteRaccoon、知乎:一只大狸花啊、稀土掘金:浣熊say
微信公众号海量Java、数字孪生、工业互联网电子书免费送~
本文参考的Kafka源码版本:3.0.11,相关的机制的有效性仅适用于该版本的Kafka,但是我相信应该其它版本的变化也不大。
为什么Kafka要分区
在了解分区策略之前,我们有必要先了解一下为什么要分区。
其实分区的作用就是提供负载均衡的能力或者说对数据进行分区的主要原因就是为了实现系统的高伸缩性。假设Kafka Broker是三峡大坝的话,那么分区就是三峡大坝里面一个个的小池塘。想象一下,在没有这些小池塘之前,所有数据相当于都只能通过一个固定的管道流向三峡大坝主体,这样数据的流量就会受到限制。然而,采用多个小池塘的机制之后,可以理解为增加了好几根管道,可以流向不同的池塘,这样一来Kafka Broker的吞吐量就增加了。
另外,Kafka的数据的读写一般是通过主分区来进行的,不同的分区能够被放到不同的节点机器上,而数据的读写操作也是针对分区这个粒度进行的,使用分区之后每个节点的机器都能独立地执行各自分区的读写请求处理并且,我们还能通过添加新的节点机器来增加系统整体的吞吐量。
Kafka中常见的分区策略包括:粘性分区(无key时默认)、轮询分区、Key分区(有key时默认)和自定义分区(优先级最高)。在上面这些分区方法当前,自定义分区方式是优先级最高的,只要自定义了分区方法就会优先按照该方法来进行分区。其次,有key和无key的时候则分别默认采用key分区和粘性分区策略,轮询分区策略则需要自行通过配置文件指定。
本文主要说明Kafka中Producer常见的分区策略,就是逻辑上有哪些分区的方法,具体的分区流程可以参考:《Producer消息预处理------序列化器、分区器、拦截器》
Producer常见分区策略
分区策略是决定生产者将消息发送到哪一个分区的算法,Kafka不仅仅提供了默认的分区策略,同时它也支持你自定义分区策略。做这一部分原理的解析的时候,我查了网上很多博客,真的是众说纷纭,有些看起来质量很高的博客我开始信以为真,后面深入了解之后才发现错得离谱。因此,本节的内容都从Kafka的核心源码入手,来梳理分区机制的基本原理。
粘性分区策略(默认,StickyPartitioner)
如上图所示,粘性分区的核心逻辑其实很简单,那就是发送消息的时候优先发送到上一个消息发送的分区,这样跟随着前面的分区而选择分区的策略就像具有粘性一样,所以被称为粘性分区。
粘性分区有的好处在于,可以优先将某一个或者某几个分区先行填满,填满之后的ProducerBatch可以先行发送出去,而其它未满分分区则需要等待到阈值时间后才能发送,能够增加Kafka Producer的吞吐量。
核心源码(org.apache.kafka.clients.producer.internals.StickyPartitionCache#nextPartition):
这里给出了粘性分区核心代码和位置,关键步骤都给出了注释和解析,这段源码对理解粘性分区策略至关重要。
ini
//分区缓存,topic -> partition id的映射
private final ConcurrentMap<String, Integer> indexCache;
public int nextPartition(String topic, Cluster cluster, int prevPartition) {
//根据消息主题,从Kafka集群中获取所有的分区信息
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
//先查询分区缓存,上一个该Topic的信息存在哪个分区
Integer oldPart = indexCache.get(topic);
//默认当前消息的分区就是之前的老分区(这也就是粘性分区的意思,和之前的分区保持一致)
Integer newPart = oldPart;
//oldPart == null说明该topic的消息是第一个,还没有老的分区存在
//oldPart == prevPartition说明上一个分区已满,需要找新的分区
//这两种情况下才会真正的去进行找新分区的操作
if (oldPart == null || oldPart == prevPartition) {
List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
//如果可用分区数<1,随机在所有分区中选择一个分区
if (availablePartitions.size() < 1) {
Integer random = Utils.toPositive(ThreadLocalRandom.current().nextInt());
newPart = random % partitions.size();
}
//如果可用分区数=1,则直接放到可用分区中即可
else if (availablePartitions.size() == 1) {
newPart = availablePartitions.get(0).partition();
}
//如果可用分区数>1,随机在可用分区里面选择一个分区
else {
while (newPart == null || newPart.equals(oldPart)) {
int random = Utils.toPositive(ThreadLocalRandom.current().nextInt());
newPart = availablePartitions.get(random % availablePartitions.size()).partition();
}
}
if (oldPart == null) {
indexCache.putIfAbsent(topic, newPart);
} else {
indexCache.replace(topic, prevPartition, newPart);
}
return indexCache.get(topic);
}
return indexCache.get(topic);
}
从上面的源码可以看出,所谓的粘性分区就是优先将消息放到上一个消息放过的分区里面,先填满一个分区再去找其它分区填充,具体流程如下:
-
根据topic,先从kafka集群中拉取所有可用的分区信息;
-
通过本地缓存indexCache,查询上一个topic是发送到哪个分区的,直接默认将新分区赋值为上一个分区;
-
如果消息是该topic的第一个消息或者上一个分区已满,这两种情况下都需要去切换新的分区,具体策略如下:
上面就是这段源码的核心逻辑,也是粘性分区的核心逻辑,即,优先根据上一条消息的分区来选择发送的分区。
2.2 轮询分区策略(RoundRobinPartitioner)
轮询分区策略的核心点在于通过取余的方式,将消息均衡放置在每个partition中,如上图所示,消息1被放置到partition-1 中,那么消息2就会被放置到partition-2中,同样消息3被放置到partition-3中,消息4会再被放置到partition-1中。
让消息均匀的分布在所有的partition中,更有利于使得各个Broker的负载更加均衡,Kafka集群整体稳定性更好,但是这样做所有ProducerBatch都很难达到full的状态,需要等待linger.ms时间到了消息才能被统一发送,因此,效率相较于粘性分区会更低。
Spring Boot 配置
如果你想使用轮询分区的方式,可以使用RoundRobinPartitioner,在application.properties或application.yml中添加如下配置:
ymal
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.properties.partitioner.class=org.apache.kafka.clients.producer.RoundRobinPartitioner
它会将消息依次分配到每个可用的分区,实现了轮询的效果,这种方式不需要额外编写自定义分区策略。
核心代码(org.apache.kafka.clients.producer.RoundRobinPartitioner#partition):
java
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
//从kafka集群中获取所有分区列表
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
//原子性的将topic中的消息总数+1,得到当前消息总数nextValue
int numPartitions = partitions.size();
int nextValue = nextValue(topic);
//获取可用分区列表
List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
//如果可用分区不为空,则消息总数%可用分区数来决定消息最终应该放置的分区
if (!availablePartitions.isEmpty()) {
int part = Utils.toPositive(nextValue) % availablePartitions.size();
return availablePartitions.get(part).partition();
}
//如果可用分区数为空,则消息总数%所有分区数来确认最终应该放置的分区
else {
return Utils.toPositive(nextValue) % numPartitions;
}
}
//原子性的将分区中的消息数目+1
private int nextValue(String topic) {
AtomicInteger counter = topicCounterMap.computeIfAbsent(topic, k -> new AtomicInteger(0));
return counter.getAndIncrement();
}
轮询分区策略的核心逻辑就是消息会被依次放置到不同的分区,这样做的好处在于可以使消息更加均匀的分布在不同的分区当中,具体的流程如下:
-
根据topic从Kafka集群中获取所有可用的分区数;
-
原子性的将topic中的消息总数+1,得到当前消息总数,得到消息总数之后可以用取余的方式来确定最终消息放置的分区;
轮询分区通过取余的方式将消息均匀的放置在所有分区当中,这样的分区方式可以使得整个topic的消息负载更加均衡,但是相应的效率可能会更低。
Key-Ordering策略
Kafka允许为每条消息定义key,这个key可以是一个有着明确业务含义的字符串,也可以用来表征消息元数据。一旦消息被定义了Key,那就可以保证同一个Key的所有消息都被发送到相同的分区中。对于一些需求是严格要求执行顺序的,可以采用这种方法进行发送消息,这样就能保证消息有序(消费同一个分组中的数据是有序的)。
核心代码(org.apache.kafka.clients.producer.internals.BuiltInPartitioner#partitionForKey):
java
//具体的根据key来进行分区的方法,很简单就是将key转换成Integer类型之后,取余的方式进行分区
public static int partitionForKey(final byte[] serializedKey, final int numPartitions) {
return Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions;
}
具体的根据key分区策略的方法实现非常简单,对序列化之后key采用murmur2算法将其转换成为Interger类型的整数之后再取余分区总数来确定最终的分区数。
对于相同的key来说,采用murmur2算法得到整数值是一样的,这也意味着最终计算出的分区也是一样的,因此相同key的消息会被放到同一个分区里面。
自定义分区策略
自定义分区策略在Kafka中的优先级是最高的,也就是说Kafka会优先执行用户指定的分区策略,在Spring Boot中,自定义Kafka Producer的分区策略可以通过以下步骤实现:
- 创建自定义分区策略类:
java
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import java.util.Map;
public class CustomPartitioner implements Partitioner {
@Override
public void configure(Map<String, ?> configs) {
// 在这里进行配置初始化
}
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
// 实现自定义的分区逻辑
// 这里示例简单地使用 key 的 hashCode 模拟分区逻辑,你可以根据实际需求实现自己的分区算法
int numPartitions = cluster.partitionCountForTopic(topic);
return Math.abs(key.hashCode()) % numPartitions;
}
@Override
public void close() {
// 在关闭时执行清理工作
}
}
- 在Spring Boot配置中使用自定义分区策略:
在application.properties或application.yml中添加如下配置:
ymal
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.properties.partitioner.class=com.example.CustomPartitioner
确保替换com.example.CustomPartitioner为你自己的分区策略类的完整路径。
- 使用自定义分区策略发送消息:
java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
@Service
public class KafkaProducerService {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
public void sendMessage(String topic, String key, String message) {
kafkaTemplate.send(topic, key, message);
}
}
在这个示例中,topic是消息发送的目标主题,key是用于分区的键,message是要发送的消息内容。分区策略会根据键计算目标分区。通过自定义分区策略,你可以更灵活地控制消息发送到Kafka的哪个分区。
4. 总结
本文介绍了Kafka中Producer的三种常见分区策略:粘性分区策略、轮询分区策略和Key-Ordering策略。
粘性分区策略优先选择上一个消息所在的分区,以增加吞吐量;
轮询分区策略按顺序轮询将消息均匀分布到所有分区;
Key-Ordering策略通过对消息的Key进行哈希计算,确保相同Key的消息被发送到同一分区,实现有序性。
另外,介绍了如何在Spring Boot中使用自定义分区策略。通过创建自定义的分区策略类,并在配置中指定,可以更灵活地控制消息发送到Kafka的哪个分区。