在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如网易、有赞、希音、百度、网易、滴滴的面试资格,遇到一几个很重要的面试题:
问题1:单节点2000Wtps,Kafka高性能原理是什么?
问题2:做过Kafka 进行性能压测吗?单个节点的极限处理能力是多少?是怎么做到的?
注意,单个节点的极限处理能力接近每秒 2000万 条消息,吞吐量达到每秒 600MB
那 Kafka 为什么这么快?如何做到这个高的性能?
尼恩提示,Kafka相关的问题,是开发的核心知识,又是线上的重点难题。
所以,这里尼恩给大家做一下系统化、体系化的线程池梳理,使得大家可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”。
也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典》V100版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到公号【技术自由圈】获取
这里,主要从这 3 个角度来分析:
先来看下生产端发送消息,Kafka 做了哪些优化?
先来回顾下 Producer 生产者发送消息的流程:
Kafka的源码最核心的是由client模块和core模块构成,用一幅图大致介绍一下生产者发送消息的流程。
队列缓存+批量写入架构,是尼恩反复强调的一个高并发写入架构,
kafka、netty都用到这个架构
kafka的生产者,也用了这个架构。 设计了一个核心组件RecordAccumulator
生产者生成某个消息后,ProducerRecord首先会经过一个或多个组成的拦截器链。
当消息通过所有的拦截器之后,会进行序列化,会根据key和value的序列化配置进行序列化消息内容,生产者和消费者必须使用相同的key-value序列化方式。
// 消息key序列化
properties.setProperty(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 消息value序列化
properties.setProperty(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
自定义分区器可以参考下面。
Kafka默认的分区器规则:
Kafka默认分区器源码:
public class DefaultPartitioner implements Partitioner {
private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new ConcurrentHashMap<>();
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) {
int nextValue = nextValue(topic);
List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
if (availablePartitions.size() > 0) {
int part = Utils.toPositive(nextValue) % availablePartitions.size();
return availablePartitions.get(part).partition();
} else {
// no partitions are available, give a non-available partition
return Utils.toPositive(nextValue) % numPartitions;
}
} else {
// hash the keyBytes to choose a partition
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
}
private int nextValue(String topic) {
AtomicInteger counter = topicCounterMap.get(topic);
if (null == counter) {
counter = new AtomicInteger(ThreadLocalRandom.current().nextInt());
AtomicInteger currentCounter = topicCounterMap.putIfAbsent(topic, counter);
if (currentCounter != null) {
counter = currentCounter;
}
}
return counter.getAndIncrement();
}
public void close() {}
}
自定义分区器:
public class CustomerPartitions implements Partitioner{
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
int partition = 0;
if(key == null) {
} else {
String keyStr = key.toString();
if(keyStr.contains("Test")) {
partition = 1;
} else {
partition = 2;
}
}
return partition;
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> configs) {
}
}
根据topic和分区可以确定一个双端队列(Deque)中,这个队列中每个节点为多个消息的合集(ProducerBatch),新的消息会被放到队列的最后一个节点上,存放会存在多种情况。
场景一:消息大小不足16K。
首先会根据topic和分区获取所属队列的最后一个ProducerBatch,
场景二:消息大小超过16K
sender线程会不断的扫描RecordAccumulator中所有的ProducerBatch,如果ProducerBatch达到batch.size(默认16K)大小或者最早的一个消息已经等待超过linger.ms(默认为0)时,这个ProducerBatch会被sender线程收集到。由于不同的topic和分区会被分到不同的Broker节点上,sender线程会把发送到相同Broker姐节点的ProducerBatch合并在一个Request请求中,一个Request请求不会超过max.request.size(默认1048576B = 1M)
新的请求会放在队列尾部,每个队列最多能够容纳max.in.flight.requests.per.connection(默认值为5)个Request,队列满了不会产生新的Request。
acks 有三个配置值:[-1 , 0 , 1]
acks = -1 表示不需要收到leader节点的ACK回复就会发送下一个Request。高吞吐,低一致性
acks = 0 表示只需要接收到leader节点的ACK后就可以发送下一个Request。
acks = 1 表示 需要接收到leaer节点和ISR节点的ACK后才会发送下一个Request。一致性较高
RecordAccumulator Clear清理场景:针对2.4.1.1,2.4.1.2,2.4.1.3,ProducerBatch都会标记为删除,然后放入池化队列中,不会进行GC。2.4.1.3中从nonPooledAvailableMemory获取的内存也不会归还給nonPooledAvailableMemory,任然存放在池化队列中。
针对2.4.2.1,2.4.2.2,超过16K的消息内存空间会被GC进行回收,然后作为nonPooledAvailableMemory的一部分
private boolean canRetry(ProducerBatch batch, ProduceResponse.PartitionResponse response) {
return batch.attempts() < this.retries &&
((response.error.exception() instanceof RetriableException) ||
(transactionManager != null && transactionManager.canRetry(response, batch)));
}
在消息发送时候,可发现这两个亮点**:批量消息和自定义协议格式。**
调用 send() 方法时,不会立刻把消息发送出去,而是缓存起来,选择恰当时机把缓存里的消息划分成一批数据,按批次发送给服务端 Broker。
各种压缩算法对比:
Compressor name | Ratio | Compression | Decompress. |
---|---|---|---|
zstd 1.3.4-1 | 2.877 | 470 MB/s | 1380 MB/s |
zlib 1.2.11-1 | 2.743 | 110 MB/s | 400 MB/s |
brotli 1.0.2-0 | 2.701 | 410 MB/s | 430 MB/s |
quicklz 1.5.0-1 | 2.238 | 550 MB/s | 710 MB/s |
lzo1x2.09-1 | 2.108 | 650 MB/s | 830 MB/s |
lz4 1.8.1 | 2.101 | 750 MB/s | 3700 MB/s |
snappy 1.1.4 | 2.091 | 530 MB/s | 1800 MB/s |
lzf 3.6-1 | 2.077 | 400 MB/s | 860 MB/s |
Broker 的高性能主要从这 3 个方面体现:
下面展开讲讲。
使用 PageCache 主要能带来如下好处:
如果消息刚刚写入到服务端就会被消费,按照 LRU 的“优先清除最近最少使用的页”这种策略,读取的时候,对于这种刚刚写入的 PageCache,命中的几率会非常高。
文件布局如下图所示:
主要特征是:文件的组织方式是“topic + 分区”,每一个 topic 可以创建多个分区,每一个分区包含单独的文件夹。
Kafka 在分区级别实现文件顺序写:即多个文件同时写入,更能发挥磁盘 IO 的性能。
所以使用 Kafka 时,要警惕 Topic 和 分区数量。
当不使用零拷贝技术读取数据时:
流程如下:
1)消费端 Consumer:向 Kafka Broker 请求拉取消息
2)Kafka Broker 从 OS Cache 读取消息到 应用程序的内存空间:
3)再通过网卡,socket 将数据发送给 消费端Consumer
当使用零拷贝技术读取数据:
Kafka 使用零拷贝技术可以把这个复制次数减少一次,直接从 PageCache 中把数据复制到 Socket 缓冲区中。
消费者只从 Leader分区批量拉取消息。
为了提高消费速度,多个消费者并行消费比不可少。Kafka 允许创建消费组(唯一标识 group.id),在同一个消费组的消费者共同消费数据。
举个例子:
如上图,举例 4 中情况:
group.id = 1,有一个消费者:这个消费者要处理所有数据,即 3 个分区的数据。
group.id = 2,有两个消费者:consumer 1消费者需处理 2个分区的数据,consumer2 消费者需处理 1个分区的数据。
group.id = 3,有三个消费者**:消费者数量与分区数量相等,**刚好每个消费者处理一个分区。
group.id = 4,有四个消费者**:消费者数量 > 分区数量,**第四个消费者则会处于空闲状态。
kafka相关面试题,是非常常见的面试题。
以上的内容,如果大家能对答如流,如数家珍,基本上 面试官会被你 震惊到、吸引到。
最终,让面试官爱到 “不能自已、口水直流”。offer, 也就来了。
学习过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。
https://juejin.cn/post/7134463012563320868
《炸裂,靠“吹牛”过京东一面,月薪40K》
《太猛了,靠“吹牛”过顺丰一面,月薪30K》
《炸裂了…京东一面索命40问,过了就50W+》
《问麻了…阿里一面索命27问,过了就60W+》
《百度狂问3小时,大厂offer到手,小伙真狠!》
《饿了么太狠:面个高级Java,抖这多硬活、狠活》
《字节狂问一小时,小伙offer到手,太狠了!》
《收个滴滴Offer:从小伙三面经历,看看需要学点啥?》
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓