Kafka 生产者剖析
”生存还是毁灭,这是一个问题。“ 是的对Kafka来说这个曾经受万人追捧的分布式消息引擎,现在倒还真有点跌入神坛的趋势。因为Pulsar(消息系统的新贵)仿佛正在全面替代Kafka。
Kafka真的不行了吗?
答案个人觉得是否定的 固然Pulsar有着Kafka没有的存储和计算分离的设计,Pulsar在大数据大集群的租户管理上确实也要比Kafka更好。
但是Kafka2.8版本推出了社区呼吁已久的操作移除了Zookeeper,使用Kraft来进行代替,虽然只是测试版本,但是官方实测的数据对比上:
- 支持的分区数由20万个分区,变成了可以支持到200万个分区左右,是之前的数十倍之多。
- 性能相同分区的情况下也是得到了数倍的提升
- 最重要的是Kafka现在仅仅是一个进程,而不再需要一个Zookeeper集群了,更加轻量化。
现在看来对于性能Kafka还是有所期待的。
俗话说”万变不离其宗“,Pulsar肯定也有很多好的优秀的设计值得我们学习。但是现在的技术更新换代真的是太快了,也许,你今天正在学习的一个技术,明天就湮灭在历史的尘埃之中。我们要做的就是抓住事情的本质。弄明白它的原理。
无论是 Kafka、Pulsar、rabbitmq 它们不变的都是作为一个消息系统的构成 生产者、消费者、服务端。只有弄明白其中的原理,才能在技术快速更新还贷的时代里不被淘汰。
接下来详细的剖析一下KafkaProducer 的原理。
1.Kafka如何发送消息
1.1Producer发送消息代码示例
public class Producer extends Thread {
private final KafkaProducer<Integer, String> producer;
private final String topic;
private final Boolean isAsync;
public void run() {
int messageNo = 1;
// todo: 一直会往kafka发送数据
while (true) {
String messageStr = "Message_" + messageNo;
long startTime = System.currentTimeMillis();
if (isAsync) { // Send asynchronously
producer.send(new ProducerRecord<>(topic,
messageNo,
messageStr), new DemoCallBack(startTime, messageNo, messageStr));
} else { // Send synchronously
try {
producer.send(new ProducerRecord<>(topic,
messageNo,
messageStr)).get();
System.out.println("Sent message: (" + messageNo + ", " + messageStr + ")");
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
++messageNo;
}
}
}
上边代码所示为KafkaProducer发送消息的一个简单的实例。主要有两个步骤:
-
1.初始化KafkaProducer对象(代码中省略了)
-
2.发送消息
但是Kafka发送一条消息的过程就这么简单呢,实则不然,一条消息要发送并存储到Server端的路还很漫长。
1.2 Kafka 发送消息具体流程
如下图所示为KafkaProducer发送消息的具体流程:
总体来说分为四个步骤:
- 主线程
- 拦截器对消息做一些封装
- 序列化消息以便进行网络传输
- 消息分区 (默认轮询的分区策略)
- 将消息 添加到 RecordAccumulator中。
- Sender线程
- 更新元数据
- 从RecordAccumulator拉取消息
- Ready
- Drain
- 封装ClientRequest
- 调用NetworkClient进行发送(使用的是NIO)
其实消息的发送的步骤 不止这些,比如元数据的更新、消息失败的重试、响应信息的各种处理方式等等 这里就不再做详细的叙述了,主需要了解消息发送的一个整体流程就可以了。
1.3Kafka为什么选择双线程来进行消息发送?
优点:
-
客户端使用者仅仅需要调用KafkaProducer 的send 方法,具体的消息发送、重试、与Server端的网络连接等都交给Sender线程来进行处理。分工更明确,逻辑更清晰。
-
Sender来与Server端交互,主线程不比去做网络连接处理请求等操作。
-
主线程仅仅将一条一条的消息放入消息累加器中,Sender线程根据触发发送消息的条件将消息一批一批的发送,效率更高。
缺点
这个缺点其实不是双线程发送的缺点,而是Kafka创建Sender线程的方式,Kafka创建Sender的方式是在调用KafkaProducer的构造方法的时候创建的,并且启动了Sender线程。Kafka并发编程的坐着曾经指出在对象的构造方法中创建并且启动一个线程会造成this指针的逃逸。
afkaProducer(ProducerConfig config,
Serializer<K> keySerializer,
Serializer<V> valueSerializer,
Metadata metadata,
KafkaClient kafkaClient,
ProducerInterceptors interceptors,
Time time) {
...
this.sender = newSender(logContext, kafkaClient, this.metadata);
String ioThreadName = NETWORK_THREAD_PREFIX + " | " + clientId;
this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
this.ioThread.start();
...
}
2.Sender线程
Sender线程发送流程如下所示:
- 1.获取元数据的最新信息
- 2.获取RecordAccumulator有哪些消息准备好了
- 3.如果有topic的元数据不存在降该topic的更新元数据的标记设置为true意味着可以进行元数据更新了
- 4.检查与要发送数据的主机网络是否建立好,去掉那些不能发送信息的节点
- 5.drain这个方法很重要一会会做详细的分析。
- 6.放弃超时的Batchs。
- 7.创建ProducerRequest
- 8.调用NetWorkClient的send方法,降请求添加到请求队列中
- 9.触发发送操作。
drain操作
将ProducerBatch与Broker节点做映射
核心逻辑是将RecordAccumulator记录的Map<TopicPartition,Deque> 转换Map<String,Deque> 类型。
- 在网络层面更关心的是数据和对应节点的映射而不是TopicPartition的映射。而上层逻辑与之相反所以需要做这一次的转换。
drain的操作其实和MapReduce和Spark的 shuffle有着异曲同工的作用,而且都是处于非常重要的位置。这样看来大数据领域的好多理念都是想通的,最重要的就是去弄通它们的原理,就可以达到一知百解的效果。
public Map<Integer, List<ProducerBatch>> drain(Cluster cluster, Set<Node> nodes, int maxSize, long now) {
if (nodes.isEmpty())
return Collections.emptyMap();
Map<Integer, List<ProducerBatch>> batches = new HashMap<>();
for (Node node : nodes) {
List<ProducerBatch> ready = drainBatchesForOneNode(cluster, node, maxSize, now);
batches.put(node.id(), ready);
}
return batches;
}
drainIndex 防止饥饿提高系统的可用性
如果strat在每次发送消息的时候,都是从0开始遍历,就会出现每次只发送相对Topic的前几个分区的数据,后边分区的数据一直得不到发送。利用drainIndex记录了上次发送分区的位置,可以防止饥饿提高系统的可用性。
private List<ProducerBatch> drainBatchesForOneNode(Cluster cluster, Node node, int maxSize, long now) {
int size = 0;
List<PartitionInfo> parts = cluster.partitionsForNode(node.id());
List<ProducerBatch> ready = new ArrayList<>();
/* to make starvation less likely this loop doesn't start at 0
* 防止饥饿
* */
int start = drainIndex = drainIndex % parts.size();
do {
PartitionInfo part = parts.get(drainIndex);
TopicPartition tp = new TopicPartition(part.topic(), part.partition());
this.drainIndex = (this.drainIndex + 1) % parts.size();
...
}
...
}
3.RecordAccumulator
如下图所示,为RecordAccumulator,它会将Producer发送的消息按照TopicPartition进行分类。然后将消息存入BufferPoll中。每个TopicPartition的消息放入一个队列中。TopicPartition 的唯一性由两个字段确定 topicName和partition.
BufferPoll
如上图所示BufferPoll主要由两部分构成:
- free 缓存数据,有效的数据的频繁的创建和销毁。
- nonPooledAvailableMemory 防止传入的消息size太大。free 的batchSize不够分配的情况。
/**
* 缓存了指定大小的 byteBuffer 对象 batchSize 缓冲了大量的 ByteBuffer防止频繁的创建和销毁。每个批次的
* 文件中配置制定的
*/
private final Deque<ByteBuffer> free;
/** Total available memory is the sum of nonPooledAvailableMemory and the number of byte buffers in free * poolableSize. */
// 非缓冲池的可用内存大小,非缓冲池分配内存,其实就是调用ByteBuffer.allocate分配真实的JVM内存。
//但是这部分的数据是不走内存池的用完就销毁,用了再重新申请
private long nonPooledAvailableMemory;
4.消息交付可靠性保障
4.1 可靠性保障种类
- At most once:最多发送一次 消息可能会丢失,但是不会重复
- At Least once:最少发送一次 消息可能会重复 不会丢失
- Exactly once:恰好一次 每条消息只被传递一次
日常的开发场景 At most once 很少用到,我们最需要的就是 Exactly once 恰好一次。
但是如果 某个topic的所有消息都是幂等的,存储多条,重复消费也不会影响结果,那么At least once 是一个好的选择。因为所有的事物都是平衡的,在保证Exactly only的同时,一定会损失点其他的东西,就是性能。其实并不是说那种语义最好,脱离了场景一切都是白谈,假如我对消息的丢失无所谓,你却非要去保证消息的Exactly once 那不就是做了很多无用功还损失了性能,所以一切脱离了具体的场景去谈问题,都是耍流氓。
Kafka默认选择的是 At Least once的方式消息发送失败会选择重试,这样就可能会造成消息重复。
如果关掉了重试的机制就是 At most once
4.2Kafka如何实现Exactly once?
分区维度
幂等性 Producer 是Kakfa 0.11版本引入的新功能,添加如下配置即可:
trueprops.put(ProducerfConfig.ENABLE_IDEMPOTENCE_CONFIG, true分区)
服务端会根据一个唯一标识给我们做去重,但是仅仅是对单分区保证恰好一次的,不同的分区并不能保证恰好一次的语义,还是会有消息重复。而且 单个分区也是单次会话起作用的,假如生产者端重启了,不好意思,他就不能消息不会和上次会话的消息不重复了。
全局维度
事务型 Producer 能够保证将消息原子性地写入到多个分区中,不会有重复消息。
事务型 Producer即使在多次回话中 ,Kafka 依然保证它们发送消息的精确一次处理。
如何开启事务:
#开启事务
enable.idempotence = true。
#设置事务id 最好是和业务相关是一个有意义的id
transactional.id=MYTRAN
代码也会做一些修改:
//开启事务
producer.initTransactions();
try {
producer.beginTransaction();
producer.send(record1);
producer.send(record2);
producer.send(record3);
//提交事务
producer.commitTransaction();
} catch (KafkaException e) {
//回滚
producer.abortTransaction();
}
需要注意的是Producer开启了事务后,Consumer对这些API也要有着相同的事务试图:
- read_uncommitted:默认值, Consumer 能够读取到 Kafka 写入的任何消息,不论事务型 Producer 提交事务还是终止事务,其写入的消息都可以读取。
- read_committed:表明 Consumer 只会读取事务型 Producer 成功提交事务写入的消息。
这里需要将Conumer的isolation.level参数设置为read_committed才可以。
4.3 自己如何实现Exactly once
- 禁止重试,消息只发送到一个分区,当消息发送失败后,具体的重试逻辑由生产者主线程做处理
- 生产者不做处理,并开启重试机制,对每条消息创建一个唯一表示,具体的去重操作,由消费者来做。
KakfaProducer端还有很多优秀的设计,提供的API也比较丰富,比如分段锁的使用,在多线程下使线程更少的去竞争锁的资源,ConcurrentMap的使用针对都多写少的场景,网络请求使用TCP方式,使用了NIO实现了自己的网络框架等。都是值得我们去学习的地方。