为学日益,为道日损,损之又损,以至于无为,无为而无不为
0x01: 概述
kafka作为大数据领域消息系统一哥,其架构与代码设计十分巧妙与优雅,从中我们可以学习与借鉴到很多分布式高性能并发与缓存方案,接下来开始kafka底层设计探索之旅。
本文主要分析Kafka生产端Producer架构设计以及核心的消息发送流程,阅读前可以带着问题进行阅读如:
- Producer如何实现高性能与高并发的发送消息
- Producer消息数据结构、线程、内存的设计
- Producer核心流程、以及比较优雅的设计
kafka版本选择,源码编译环境搭建可参考以下博客:
Kafka源码环境搭建_Pushkin.的博客-CSDN博客
0x02: example
先从example模块出发,kafka代码里已经自带了测试的案例
接下来查看下Producer线程
producer案例生产的消息为"Message_编号",从源码中我们可以发现
1. 两种发送机制
kafka生产端是支持两种发送机制
- 异步发送
- 同步发送
异步发送的效率更高,生产中对于大数据的场景推荐使用异步发送,消息的响应结果交给匿名函数(内部类实现)DemoCallBack实现回调,其回调的时期在发送到Broker服务端的记录被确认时回调
The exception thrown during processing of this record. Null if no error occurred.
可以发现通过回调函数,其实我们可以监控并记录到异常的消息,对于实现发送端到服务端这条链路可靠性是至关重要的。
2. 配置设置
bootstrap.servers的设置其实只要kafka服务集群中任意一台节点,因为kafka的元数据其实并不存在于zookeeper中了,而是存在于每个broker server中,这点可以从后续初始化的代码中发现。
kafka支持对于消息的序列化,RPC通信的序列化这里不进行展开讲了。
0x03: KafkaProducer初始化
接下来看下clients模块KafkaProducer端初始化操作,从经验来看对于分布式客户端首先要做的肯定是获取分布式环境的元数据信息,一些配置变量的初始化,客户端的初始化以及检查等等,那么kafka是如何来做的呢?
初步看了下KafkaProducer的初始化,主要是对:
- metric的初始化
- 分区器的设置(默认Partitioner.class,负载均衡主要两种机制,轮询与对key取模)
- 序列化器的设置
- 拦截器的设置
- 元数据的初始化 (注意这里只是初始化,获取在发送消息的时候)
- 请求消息大小设置(默认为1M, 所以在生产中要看下发送的单条消息是否超过默认的大小)
- 缓存大小的设置(buffer.memory默认32M)
- 压缩的设置(设置压缩了,提高吞吐量了,但CPU的消耗需要考量)
- create record accumulator(重点的一个结构设计)
- create NetworkClient网络通信客户端
- create Sender Thread (异步线程处理消息的发送到集群)
- 注册App客户端信息
以上kafaka的初始化,主要的重点在于9,10,11这三块组件: 两线程一结构
以及元数据加载更新数据的流程
kafka消息的缓存结构如何实现的高性能并发,通信与消息发送线程的如何高效协作我们可以继续研究从中窥见一二。
0x04: 元数据加载流程(KafkaProducer.metaData)
元数据信息
按惯例先看下作者对于Metadata这个类的注释描述
/** * A class encapsulating some of the logic around metadata. * <p> * This class is shared by the client thread (for partitioning) and the background sender thread. * * Metadata is maintained for only a subset of topics, which can be added to over time. When we request metadata for a * topic we don't have any metadata for it will trigger a metadata update. * <p> * If topic expiry is enabled for the metadata, any topic that has not been used within the expiry interval * is removed from the metadata refresh set after an update. Consumers disable topic expiry since they explicitly * manage topics while producers rely on topic expiry to limit the refresh set. */
从注释中可以了解到:
- 这个元数据类用于客户端线程(用于分区),以及sender线程
- 元数据只为主题的一个子集维护,这些主题可以随时间而添加。
- 如果元数据启用了主题过期,则在过期时间间隔内未使用的任何主题在更新后从元数据刷新集中删除。消费者禁用主题过期,因为他们管理主题,而制作人依靠主题过期来限制刷新集。
来看下具体的元数据信息:
元数据加载流程
接下来看下producer.send中元数据j加载流程
主要是通过waitOnMetadata去唤醒sender线程去真正获取元数据信息
并且awaitUpdate,自旋监控version是否更新
而sender线程(唤醒后将一直运行)通过NetWorkClient客户端线程发送poll请求,其中metadataUpdater请求以封装好了回调函数
继续看下metadataUpdater封装的回调函数是怎么处理结果的
代码部分分析完,接下来用流程图进行整理:
0x05: record accumulate缓存结构
在KafkaProducer初始化中创建了一个accumulator对象,主要用来累积消息充当缓存的作用
还是按照惯例查看其注释说明
根据注释可以了解到,此类其实就是一个累加器队列,记录将累积到MemoryRecords这个ByteBuffer中,
累加器队列使用有限的内存,当内存耗尽的时候,append调用将被阻塞
再看下RecordAccumulator构造器,创造了一个自定义的容器CopyOnWriteMap,为各主题分区关联上一个Deque队列去存储RecordBatch
private final ConcurrentMap<TopicPartition, Deque<RecordBatch>> batches; this.batches = new CopyOnWriteMap<>();
累加器核心CopyOnWriteMap数据结构
此结构主要作为一个btaches,主要存储各主题分区的消息队列partition-->queue
通过注释可以了解到:
- 这个数据结构是读写分离的,只在写数据的时候才加锁,所以其既能在高并发的情况下线程是安全的,又能保证高性能的读取数据
核心变量map
private volatile Map<K, V> map;
其中核心的变量为Map,并且用volatile关键字进行修饰保证多线程情况下,其变化是可见性的。
读数据
未加锁,保证高性能的读取
@Override public V get(Object k) { return map.get(k); }
写数据
整个方法使用的是synchronized关键字去修饰的,说明这个方法是线程安全。
即使加了锁,这段代码的性能依然很好,因为里面都是纯内存的操作。
@Override public synchronized V put(K k, V v) { Map<K, V> copy = new HashMap<K, V>(this.map); V prev = copy.put(k, v); this.map = Collections.unmodifiableMap(copy); return prev; }
累加器添加记录流程
原子性的incrementAndGet只是作为线程数量的监控
步骤1:getOrCreateDeque先根据分区tp获取对应的队列(无则新建)
这个场景就是读多写少的场景,来一个消息读一次,写(创建分区)只有第一次才创建
步骤2: 这块有点巧妙的是进行了两次synchronizedm,主要是因为数据是需要存储在批次对象里面(这个批次对象是需要分配内存的)
注意这个批次对象RecordBatch的MemoryRecords,是在消息的大小和批次的大小之间取一个最大值,用这个值作为当前这个批次的大小,默认一个batch的大小是16K,当我们一个消息大于16K的时候,那么这个batch的buff缓存就没啥意义了,所以我们一定要根据实际生产情况去设置批次的大小。
// we don't have an in-progress record batch try to allocate a new batch int size = Math.max(this.batchSize, Records.LOG_OVERHEAD + Record.recordSize(key, value)); log.trace("Allocating a new {} byte message buffer for topic {} partition {}", size, tp.topic(), tp.partition()); ByteBuffer buffer = free.allocate(size, maxTimeToBlock);
其实进一步思考,kafka为什么要划分这么小批次消息batch,一是可以小批量快速发送,二其实也主要是因为kafka使用的是JVM,逃脱不了GC的问题,如果不使用批次,每个分区一个内存队列去累计消息,当full gc了,需要清理的内存空间较大STW过长,及其影响服务性能。
MAP + 队列 + 小批量 的这种结构的方案设计,在分布式大量消息传递之中非常值得借鉴。
其中为加快内存分配的效率,kafaka还是用了一种BufferPool内存池的方案(类似线程池),以加速性能。
结构图
综上分析可得出kafka这块累加器数据结构如下:
0x06: 生产者发送消息流程
经过以上对于kafka producer几个核心点的分析,可整理出kafka生产者发送消息的整体流程如下: