消息队列(1)Kafka客户端原理

814 阅读11分钟

我们先将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线程从队头取数据。

image.png

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无关。

image.png

组分配

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分两个步骤:

  1. JoinGroup:让Coordinator感知到Member的存在和其订阅信息,并选出Leader
  2. SyncGroup:让Leader计算TP分配策略,并同步结果给所有Member

image.png

image.png

以上说明了rebalance的基本流程,实际在运行过程中可能会发生变数导致rebalance再次触发:

  • Consumer添加或者退出,最常见的情况,服务部署升级都会触发
  • Topic的Partition数量发生变化,运维级操作,很少发生
  • Group订阅的Topic范围发生变化,见于正则订阅Topic场景,不常发生

其实就是涉及到关联Member或TP变更都需要重新分配,如何识别这些变更以及rebalance流程如何触发呢?

每个Consumer在与Coordinator连接上后,都会启动一个心跳线程定时保活,如果Coordinator在指定时间内没有收到Consumer的心跳,就会判定此Consumer不正常并将其踢出Group,在其他Consumer下次心跳时返回REBALANCE_IN_PROGRESS的标识让其重新JoinGroup。

image.png

以上只是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循环中,大体的流程如下:

kafak consumer poll.jpg

因为众多逻辑集成到一个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语义实现,幂等和事务等。