我们先将Kafka Broker想象成一个消息盒,来打通消息的上下游传输流程,侧面理解整个消息系统应用层面的工作方式,另外也可以参考学习一些不错的设计实践。
消息格式
消息格式经过迭代,从0.11.0开始使用v2格式。
Kafka使用了批量消息格式,多个消息Record合并到同一个批量RecordBatch,进行存储和传输。对于应用程序来说还是单Record粒度的,批量是由Client端自动合并和拆分,对应用程序透明。
RecordBatch =>
BaseOffset => Int64 // 消息偏移,由broker赋值后才有意义
Length => Int32 // 从PartitionLeaderEpoch开始到后面所有的字节数
PartitionLeaderEpoch => Int32 // 支持幂等,后续幂等和事务单独分析~
Magic => Int8 // 版本号
CRC => Uint32 // 校验码,计算从Attributes到后面所有的字节
Attributes => Int16 // 属性,如下表所示,一个属性占1bit
LastOffsetDelta => Int32 // 与BaseOffset对应偏移,在broker端校验
FirstTimestamp => Int64 // 时间戳,同offset相同等级,时间级标识
MaxTimestamp => Int64 // 同理LastOffsetDelta
ProducerId => Int64 // 同理PartitionLeaderEpoch
ProducerEpoch => Int16 // 同理PartitionLeaderEpoch
BaseSequence => Int32 // 同理PartitionLeaderEpoch
Records => [Record] // Record集合
The current attributes are given below:
-------------------------------------------------------------------------------------------------
| Unused (6-15) | Control (5) | Transactional (4) | Timestamp Type (3) | Compression Type (0-2) |
-------------------------------------------------------------------------------------------------
Producer如果选择了压缩,Records就是被压缩的范围,前面的字段保留原型的好处是broker可以不解压就进行读取,比如crc校验,原则上不希望Broker参与到解压缩流程,而是直接交给Consumer消费时再解压缩。
Record也有自己的格式
Record =>
Length => Varint
Attributes => Int8 // 预留
TimestampDelta => Varlong // 与RecordBatch的FirstTimestamp对应连接
OffsetDelta => Varint // 与RecordBatch的BaseOffset对应连接
Key => Bytes
Value => Bytes
Headers => [HeaderKey HeaderValue]
HeaderKey => String
HeaderValue => Bytes
在字节序列化上,有些字段使用了Google Protocol Buffers,按需可变长编码节省了消息的存储空间。
在批量内部使用偏移值而不是全量值,用轻量计算换取存储空间,比如TimestampDelta和OffsetDelta。
批量+压缩+可变长+偏移,是kafka进行的消息格式优化。
消息传递
RecordBatch往上的聚类层次是Topic和Partition。
Topic是为了区分业务层而产生的。
Topic下的Partition是为了伸缩性和可用性而设计的。
- 将一个Topic的消息拆分给多个Partition,既可以分摊存储压力,也可以分摊负载压力。
- 这些Partition分布在多个Broker,再搭配副本同步,可以提高数据可用性。
后面我们统一将Topic和Partition称为TP,那么RecordBatch在传递上是如何聚类到TP的呢?
KafkaProducer
消息发送是由用户线程调用的,我们不希望这个用户线程被消耗在网络传输上,何况网络底层还有很多繁琐操作和不确定因素,为此Kafka使用了独立的Sender线程来执行真正的消息发送。
那么Sender线程是如何与用户线程进行消息交互的呢?Kafka使用了消息累加器RecordAccumulator,本质上就是双向队列,用户线程往队尾加数据,Sender线程从队头取数据。
Record在经过拦截、序列化、分区后就进入RecordAccumulator了,RecordAccumulator内部的队列是按TP隔离的,RecordBatch的组装也是在这里完成,这里的实现是ProducerBatch,每当一个ProducerBatch内存满了就会新开一个ProducerBatch,并提示Sender进行数据发送。
虽然说Record聚类到TP,但Sender实际要进行网络传输的对象还是Broker Node,所以每次需要找出ready的Node,发出这些Node各自lead的TP的ProduceBatch,如何判断一个Node是ready的呢?
- 当ProducerBatch满了
- 当ProducerBatch超过lingerMs时间未发送
- 当ProducerBatch重试等待时间超过retryBackoffMs
- 当RecordAccumulator内存耗尽,阻塞了发送线程
- 当RecordAccumulator处于flush流程
这套ready探测机制属实通用,笔者也曾开发过类似的聚合队列,方式基本一致
如果消息太大被Broker拒绝,则需要先split后重入队。如果发送失败是可重试异常,也支持重入队重试。如果内存池被耗尽,发送线程就会被阻塞。
Sender线程退出默认是优雅flush的,等待所有已经append的Record发送完毕,技术上可以通过future等待。
public void flush() {
this.accumulator.beginFlush();
this.sender.wakeup();
try {
this.accumulator.awaitFlushCompletion();
} catch (InterruptedException e) {
throw new InterruptException("Flush interrupted.", e);
}
}
如果强制关闭需要注意一个并发细节,开启关闭后可能有的线程正在append,需要等待这些线程退出,并保证对它们进行响应。
public void abortIncompleteBatches() {
// We need to keep aborting the incomplete batch until no thread is trying to append to
// 1. Avoid losing batches.
// 2. Free up memory in case appending threads are blocked on buffer full.
// This is a tight loop but should be able to get through very quickly.
do {
abortBatches();
} while (appendsInProgress());
// After this point, no thread will append any messages because they will see the close
// flag set. We need to do the last abort after no thread was appending in case there was a new
// batch appended by the last appending thread.
abortBatches();
this.batches.clear();
}
KafkaConsumer
拉取模式
数据拉取模式主要分pull和push两种,Kafka选择了pull模式,这种模式的好处是客户端可以根据自行消费情况择时拉取,不会出现上游产生数据的速率高于下游消费数据的速率,而导致下游负担沉重。
如果从metircs监控上得知消费进度落后太多,可以根据具体情况分析,要么添加消费者实例,要么选择并发消费,要么进行消费流程优化等等。
组消费
拉取模式定好后,我们需要确认Consumer要拉取数据的对象,我们之前提到Record聚类到TP,那么订阅相同Topic的Consumer该如何分配这些TP呢,以及谁来执行分配决策并同步决策结果呢?
Kafka定义了一个Consumer Group的概念,Group内的所有Member分担Group内订阅的所有TP。注意这里是以Group为单位独立分配,而不是以Topic为单位分配,更与订阅相同Topic的其他Group无关。
组分配
Group内的TP分配过程称为rebalance,Kafka Broker支持一种Coordinator的功能,协助Group实现自治分配,真正的分配策略由Coordinator选出的Consumer Leader计算出来,再通过Coordinator同步给其他Member。
同一个Group内各个Member订阅的Topic可能各不相同,理想状态下就是每个Member分到的TP数比较平均,并且当前分配结果尽量少影响到前面分配的结果(这块算法比较有含金量,后面我们介绍Tidb时会重新提出来看看,Tidb有一些自己的优化策略),分配接口大致如下:
/**
* @param subscriptions 每个Member各自订阅的Topic
* @param partitionsPerTopic 每个Topic的分区数
* @return 每个Member分配到TP列表
*/
public Map<String, List<TopicPartition>> assign(Map<String, Integer> partitionsPerTopic,
Map<String, Subscription> subscriptions);
rebalance分两个步骤:
- JoinGroup:让Coordinator感知到Member的存在和其订阅信息,并选出Leader
- SyncGroup:让Leader计算TP分配策略,并同步结果给所有Member
以上说明了rebalance的基本流程,实际在运行过程中可能会发生变数导致rebalance再次触发:
- Consumer添加或者退出,最常见的情况,服务部署升级都会触发
- Topic的Partition数量发生变化,运维级操作,很少发生
- Group订阅的Topic范围发生变化,见于正则订阅Topic场景,不常发生
其实就是涉及到关联Member或TP变更都需要重新分配,如何识别这些变更以及rebalance流程如何触发呢?
每个Consumer在与Coordinator连接上后,都会启动一个心跳线程定时保活,如果Coordinator在指定时间内没有收到Consumer的心跳,就会判定此Consumer不正常并将其踢出Group,在其他Consumer下次心跳时返回REBALANCE_IN_PROGRESS的标识让其重新JoinGroup。
以上只是rebalance触发的一个场景示例,还有其他一些场景可以由Consumer主动触发:
- Consumer所属服务退出前,主动LeaveGroup
- Consumer通过订阅Broker Metadata,识别到正则订阅的Topic范围发生变化,重新JoinGroup
- Consumer通过订阅Broker Metadata,识别到订阅Topic的Partition数量发生变化,重新JoinGroup
数据拉取与确认
Consumer在知道自己分配到的TP后,就可以进行数据拉取了,跟Producer一样,实际网络传输的对象是Broker Node,所以需要根据Broker Metadata找到这些TP所属Node,按Node分别fetch消息。
因为Record被设计成顺序存储的,每个Record在所属TP内都有一个offset标识,这里我们称为record offset,Consumer只需按顺序fetch消息即可,可以充分利用Broker顺序读写磁盘的性能。
因为rebalance的关系,哪个Consumer消费哪个TP是不固定的,那么当Consumer重新分配到新的TP时,该从哪里继续消费呢?为此需要在Broker端记录这个Group在这个TP的消费进度,这里我们称为commit offset,下次同一个Group下的任意Member要继续消费这个TP,可以从commit offset处继续拉取。如果之前没有commit offset,可以根据配置策略选择EARLIEST或LATEST,从TP头部或尾部开始消费。
因为Kafka支持Consumer从任意offset位置拉取Record,便于一些回滚场景等,所以实际在fetch时,fetch offset由Consumer自己决定,而不是由Broker决定。
sequenceDiagram
Consumer->>Broker: 获取commit offset
Broker->>Consumer: 返回commit offset
Consumer->>Consumer: 判断commit offset是否为空
Consumer-->>Broker: 如果commit offset为空,获取EARLIEST或LATEST offset
Broker-->>Consumer: 返回EARLIEST或LATEST offset
Consumer->>Consumer: 更新fetch offset为前面返回的offset
Consumer->>Broker: fetch消息,指定fetch offset
Broker->>Consumer: 返回RecordBatch,每个Record有自己的record offset
Consumer->>Consumer: 更新fetch offset为拉取的record offset + 1,并消费Record
Consumer->>Broker: 手动或自动提交commit offset
Broker->>Broker: 更新Group在TP的commit offset
fetch消息不是每次都会执行网络请求Broker,如果上次请求还有Record在本地内存中,会取出来来给本次poll消费。在rebalance发生后,那些已经fetch到本地的Record所属的TP如果已经不再分配给本Consumer,这些Record会被本地忽略,由其他Consumer重新fetch并消费。
commit offset的提交可选手动和自动,自动提交是定时周期执行的,每次poll准备fetch前都会判断本次是否需要执行自动异步提交,如果是就先提交commit offset,这个值取当前的fetch offset。在Consumer退出前则需要同步提交commit offset。
poll流程
整个Consumer主体就工作在poll循环中,大体的流程如下:
因为众多逻辑集成到一个poll中,在同一个线程中运行,所以需要把控好各个流程,比如rebalance完成之前消息是无法消费的;用户自定义interceptor逻辑也不应该太重,影响到整体的消费进度;如果消息消费逻辑执行太久,heartbeat就会执行leaveGroup。
Consumer本身不是线程安全的,Spring的做法是按照并发配置,启动concurrency个Consumer独立消费,同个Group的多个Member可能就是同一个进程内。
网络传输
前面我们提到,Producer和Consumer需要和Broker Node进行网络传输,在Java端底层是如何实现的呢?
Kafka基于Java NIO自己实现了一套NetworkClient,同时搭配了一套Connection状态管理、Metadata同步和Node ApiVersion版本兼容。
Metadata支持同步和定时刷新,以获取Broker集群信息。选择Node时,可以根据当前连接状态和InflightRequest数量择少录取,发送请求给Node前,需要从Node和本地的ApiVersion中选择一个兼容版本构造请求。
NetworkClient的主逻辑也工作在poll调用中
public List<ClientResponse> poll(long timeout, long now) {
// 如果版本不兼容或者connection已经关闭,上层的请求会被放弃
if (!abortedSends.isEmpty()) {
// If there are aborted sends because of unsupported version exceptions or disconnects,
// handle them immediately without waiting for Selector#poll.
List<ClientResponse> responses = new ArrayList<>();
handleAbortedSends(responses);
completeResponses(responses);
return responses;
}
// 执行可能的metadata同步
long metadataTimeout = metadataUpdater.maybeUpdate(now);
try {
// 执行网络io
this.selector.poll(Utils.min(timeout, metadataTimeout, requestTimeoutMs));
} catch (IOException e) {
log.error("Unexpected error during I/O", e);
}
// 处理网络io结果事件
long updatedNow = this.time.milliseconds();
List<ClientResponse> responses = new ArrayList<>();
// 发送完成的Request
handleCompletedSends(responses, updatedNow);
// 接收完成的Response,包括Metadata、ApiVersion、Fetch等
handleCompletedReceives(responses, updatedNow);
// 断开的连接
handleDisconnections(responses, updatedNow);
// 新建的连接
handleConnections();
// 请求Node的版本信息
handleInitiateApiVersionRequests(updatedNow);
// 过期未响应的Request
handleTimedOutRequests(responses, updatedNow);
// 统一处理剩余的Response,包括断开连接等情况
completeResponses(responses);
return responses;
}
NioSelector是经过二次封装的,执行真正的网络io,比如Send并不会立马发送,而是先标记,等Selector在poll中发送。当前poll执行完和下次pol执行前,NetworkClient可以取出事件结果去处理。
这套flag和handle分离的解耦机制,在Kafka内部广泛应用,各种flag和queue被使用
NioSelector的每一个SocketChannel通过attach绑定了自定义的KafkaChannel,其支持了明文传输层和加密传输层(Selector也需要为此提供一些的支持工作)。KafkaChannel支持通过mute/unmute channel实现消息顺序处理,同一channel在处理完当前request并把response发出去之前,不允许接收新的request(这一功能在Broker端更被需要)
private SelectionKey registerChannel(String id, SocketChannel socketChannel, int interestedOps) throws IOException {
SelectionKey key = socketChannel.register(nioSelector, interestedOps);
KafkaChannel channel = buildAndAttachKafkaChannel(socketChannel, id, key);
this.channels.put(id, channel);
return key;
}
NioSelector支持清理idle connection,太久没有经历过io读写的connection会被关闭。
public Map.Entry<String, Long> pollExpiredConnection(long currentTimeNanos) {
if (currentTimeNanos <= nextIdleCloseCheckTime)
return null;
// connection维护在lru中
if (lruConnections.isEmpty()) {
nextIdleCloseCheckTime = currentTimeNanos + connectionsMaxIdleNanos;
return null;
}
// 每次只跟踪最旧没有活动过的connection,不会给所有的connection都维护一个定时器,跟tcp滑动窗口类似
Map.Entry<String, Long> oldestConnectionEntry = lruConnections.entrySet().iterator().next();
Long connectionLastActiveTime = oldestConnectionEntry.getValue();
nextIdleCloseCheckTime = connectionLastActiveTime + connectionsMaxIdleNanos;
if (currentTimeNanos > nextIdleCloseCheckTime)
return oldestConnectionEntry;
else
return null;
}
小结
本文对Kafka客户端主体脉络进行了梳理,其实内部还有非常多的细节,为了不影响整体的阅读吸收,就没有逐个列出,感兴趣的童靴可以自行翻阅源码,除开connection连接和rebalance流程,整体的设计质量还是挺高的。
后面的文章会开始注重Broker端的功能和实现,比如Partition同步与可用,Partition分配策略,Controller控制器,Record存储与传输等;也会探讨一些业务实践场景的解决方案,比如exactly once语义实现,幂等和事务等。