协议设计
在实际应用中,Kafka 经常被用作高性能、可扩展的消息中间件。Kafka 自定义了一组基于 TCP 的二进制协议,只要遵守这组协议的格式,就可以向 Kafka 发送消息,也可以从 Kafka 中拉取消息,或者做一些其他的事情,比如提交消费位移等。
在目前的 Kafka 2.0.0 中,一共包含了43种协议类型,每种协议类型都有对应的请求(Request)和响应(Response),它们都遵守特定的协议模式。每种类型的 Request 都包含相同结构的协议请求头(RequestHeader)和不同结构的协议请求体(RequestBody),如下图所示。
协议请求头中包含4个域(Field):api_key、api_version、correlation_id和client_id,这4个域对应的描述可以参考下表。
域(Field) | 描述(Description) |
---|---|
api_key | API标识,比如PRODUCE、FETCH等分别表示发送消息和拉取消息的请求 |
api_version | API版本号 |
correlation_id | 由客户端指定的一个数字来唯一地标识这次请求的id,服务端在处理完请求后也会把同样的coorelation_id写到Response中,这样客户端就能把某个请求和响应对应起来了 |
client_id | 客户端id |
每种类型的 Response 也包含相同结构的协议响应头(ResponseHeader)和不同结构的响应体(ResponseBody),如下图所示。
协议响应头中只有一个 correlation_id,对应的释义可以参考上表中的相关描述。
细心的读者会发现不管是在第一张图中还是在第二张图中都有类似 int32、int16、string 的字样,它们用来表示当前域的数据类型。Kafka 中所有协议类型的 Request 和 Response 的结构都是具备固定格式的,并且它们都构建于多种基本数据类型之上。这些基本数据类型如下表所示。
类型(Type) | 描述(Description) |
---|---|
boolean | 布尔类型,使用0和1分别代表false和true |
int8 | 带符号整型,占8位,值在-27至27 - 1之间 |
int16 | 带符号整型,占16位,值在-215至215 - 1之间 |
int32 | 带符号整型,占32位,值在-231至231 - 1之间 |
int64 | 带符号整型,占64位,值在-263至263 - 1之间 |
unit32 | 无符号整型,占32位,值在0至232 - 1之间 |
varint | 变长整型,值在- 231至231 - 1之间,使用ZigZag编码 |
varlong | 变长长整型,值在-263至263 - 1之间,使用ZigZag编码 |
string | 字符串类型。开头是一个int16类型的长度字段(非负数),代表字符串的长度N,后面包含N个UTF-8编码的字符 |
nullable_string | 可为空的字符串类型。如果此类型的值为空,则用-1表示,其余情况同string类型一样 |
bytes | 表示一个字节序列。开头是一个int32类型的长度字段,代表后面字节序列的长度N,后面再跟N个字节 |
nullable_bytes | 表示一个可为空的字节序列,为空时用-1表示,其余情况同bytes |
records | 表示Kafka中的一个消息序列,也可以看作nullable_bytes |
array | 表示一个给定类型T的数组,也就是说,数组中包含若干T类型的实例。T可以是基础类型或基础类型组成的一个结构。该域开头的是一个int32类型的长度字段,代表T实例的个数为N,后面再跟N个T的实例。可用-1表示一个空的数组 |
下面就以最常见的消息发送和消息拉取的两种协议类型做细致的讲解。首先要讲述的是消息发送的协议类型,即 ProduceRequest/ProduceResponse,对应的api_key = 0,表示 PRODUCE。从 Kafka 建立之初,其所支持的协议类型就一直在增加,并且对特定的协议类型而言,内部的组织结构也并非一成不变。以 ProduceRequest/ProduceResponse 为例,截至目前就经历了7个版本(V0~V6)的变迁。下面就以最新版本(V6,即api_version = 6)的结构为例来做细致的讲解。ProduceRequest 的组织结构如下图所示。
除了请求头中的4个域,其余 ProduceRequest 请求体中各个域的含义如下表所示。
域(Field) | 类 型 | 描述(Description) |
---|---|---|
transactional_id | nullable_string | 事务id,从Kafka 0.11.0开始支持事务。如果不使用事务的功能,那么该域的值为null |
acks | int16 | 对应客户端参数acks |
timeout | int32 | 请求超时时间,对应客户端参数request.timeout.ms,默认值为30000,即30秒 |
topic_data | array | 代表ProduceRequest中所要发送的数据集合。以主题名称分类,主题中再以分区分类。注意这个域是数组类型 |
topic | string | 主题名称 |
data | array | 与主题对应的数据,注意这个域也是数组类型 |
partition | int32 | 分区编号 |
record_set | records | 与分区对应的数据 |
如果我们了解 KafkaProducer 的原理,那么我们应该了解到消息累加器 RecordAccumulator 中的消息是以 <分区, Deque< ProducerBatch>> 的形式进行缓存的,之后由 Sender 线程转变成 <Node, List<ProducerBatch>> 的形式,针对每个 Node,Sender 线程在发送消息前会将对应的 List<ProducerBatch> 形式的内容转变成 ProduceRequest 的具体结构。List<ProducerBatch> 中的内容首先会按照主题名称进行分类(对应 ProduceRequest 中的域 topic),然后按照分区编号进行分类(对应 ProduceRequest 中的域 partition),分类之后的 ProducerBatch 集合就对应 ProduceRequest 中的域 record_set。
从另一个角度来讲,每个分区中的消息是顺序追加的,那么在客户端中按照分区归纳好之后就可以省去在服务端中转换的操作了,这样将负载的压力分摊给了客户端,从而使服务端可以专注于它的分内之事,如此也可以提升整体的性能。
如果参数 acks 设置非0值,那么生产者客户端在发送 ProduceRequest 请求之后就需要(异步)等待服务端的响应 ProduceResponse。对 ProduceResponse 而言,V6 版本中 ProduceResponse 的组织结构如下图所示。
除了响应头中的 correlation_id,其余 ProduceResponse 各个域的含义如下表所示。
域(Field) | 类 型 | 描述(Description) |
---|---|---|
throttle_time_ms | int32 | 如果超过了配额(quota)限制则需要延迟该请求的处理时间。如果没有配置配额,那么该字段的值为0 |
responses | array | 代表ProudceResponse中要返回的数据集合。同样按照主题分区的粒度进行划分,注意这个域是一个数组类型 |
topic | string | 主题名称 |
partition_responses | array | 主题中所有分区的响应,注意这个域也是一个数组类型 |
partition | int32 | 分区编号 |
error_code | int16 | 错误码,用来标识错误类型。目前版本的错误码有74种,具体可以参考这里 |
base_offset | int64 | 消息集的起始偏移量 |
log_append_time | int64 | 消息写入broker端的时间 |
log_start_offset | int64 | 所在分区的起始偏移量 |
消息追加是针对单个分区而言的,那么响应也是针对分区粒度来进行划分的,这样 ProduceRequest 和 ProduceResponse 做到了一一对应。
我们再来了解一下拉取消息的协议类型,即 FetchRequest/FetchResponse,对应的 api_key = 1,表示 FETCH。截至目前,FetchRequest/FetchResponse 一共历经了9个版本(V0~V8)的变迁,下面就以最新版本(V8)的结构为例来做细致的讲解。FetchRequest 的组织结构如下图所示。
除了请求头中的4个域,其余 FetchRequest 中各个域的含义如下表所示。
域(Field) | 类 型 | 描述(Description) |
---|---|---|
replica_id | int32 | 用来指定副本的brokerId,这个域是用于follower副本向leader副本发起FetchRequest请求的,对于普通消费者客户端而言,这个域的值保持为-1 |
max_wait_time | int32 | 和消费者客户端参数fetch.max.wait.ms对应,默认值为500 |
min_bytes | int32 | 和消费者客户端参数fetch.min.bytes对应,默认值为1 |
max_bytes | int32 | 和消费者客户端参数fetch.max.bytes对应,默认值为52428800,即50MB |
isolation_level | int8 | 和消费者客户端参数isolation.level对应,默认值为“read_uncommitted”,可选值为“read_committed”,这两个值分别对应本域的0和1的值 |
session_id | int32 | fetch session的id,详细参考下面的释义 |
epoch | int32 | fetch session的epoch纪元,它和seesion_id一样都是fetch session的元数据,详细参考下面的释义 |
topics | array | 所要拉取的主题信息,注意这是一个数组类型 |
topic | string | 主题名称 |
partitions | array | 分区信息,注意这也是一个数组类型 |
partition | int32 | 分区编号 |
fetch_offset | int64 | 指定从分区的哪个位置开始读取消息。如果是follower副本发起的请求,那么这个域可以看作当前follower副本的LEO |
log_start_offset | int64 | 该域专门用于follower副本发起的FetchRequest请求,用来指明分区的起始偏移量。对于普通消费者客户端而言这个值保持为-1 |
max_bytes | int32 | 注意在最外层中也包含同样名称的域,但是两个所代表的含义不同,这里是针对单个分区而言的,和消费者客户端参数max.partition.fetch.bytes对应,默认值为1048576,即1MB |
forgotten_topics_data | array | 数组类型,指定从fetch session中指定要去除的拉取信息,详细参考下面的释义 |
topic | string | 主题名称 |
partitions | array | 数组类型,表示分区编号的集合 |
不管是 follower 副本还是普通的消费者客户端,如果要拉取某个分区中的消息,就需要指定详细的拉取信息,也就是需要设定 partition、fetch_offset、log_start_offset 和 max_bytes 这4个域的具体值,那么对每个分区而言,就需要占用 4B+8B+8B+4B = 24B 的空间。
一般情况下,不管是 follower 副本还是普通的消费者,它们的订阅信息是长期固定的。也就是说,FetchRequest 中的 topics 域的内容是长期固定的,只有在拉取开始时或发生某些异常时会有所变动。FetchRequest 请求是一个非常频繁的请求,如果要拉取的分区数有很多,比如有1000个分区,那么在网络上频繁交互 FetchRequest 时就会有固定的 1000×24B ≈ 24KB 的字节的内容在传动,如果可以将这 24KB 的状态保存起来,那么就可以节省这部分所占用的带宽。
Kafka 从 1.1.0 版本开始针对 FetchRequest 引入了 session_id、epoch 和 forgotten_ topics_data 等域,session_id 和 epoch 确定一条拉取链路的 fetch session,当 session 建立或变更时会发送全量式的 FetchRequest,所谓的全量式就是指请求体中包含所有需要拉取的分区信息;当 session 稳定时则会发送增量式的 FetchRequest 请求,里面的 topics 域为空,因为 topics 域的内容已经被缓存在了 session 链路的两侧。如果需要从当前 fetch session 中取消对某些分区的拉取订阅,则可以使用 forgotten_topics_data 字段来实现。
这个改进在大规模(有大量的分区副本需要及时同步)的 Kafka 集群中非常有用,它可以提升集群间的网络带宽的有效使用率。不过对客户端而言效果不是那么明显,一般情况下单个客户端不会订阅太多的分区,不过总体上这也是一个很好的优化改进。
与 FetchRequest 对应的 FetchResponse 的组织结构(V8版本)可以参考下图。
FetchResponse 结构中的域也很多,它主要分为4层,第1层包含 throttle_time_ms、error_code、session_id 和 responses,前面3个域都见过,其中 session_id 和 FetchRequest 中的 session_id 对应。responses 是一个数组类型,表示响应的具体内容,也就是 FetchResponse 结构中的第2层,具体地细化到每个分区的响应。第3层中包含分区的元数据信息(partition、error_code 等)及具体的消息内容(record_set),aborted_transactions 和事务相关。
除了 Kafka 客户端开发人员,绝大多数的其他开发人员基本接触不到或不需要接触具体的协议,那么我们为什么还要了解它们呢?其实,协议的具体定义可以让我们从另一个角度来了解 Kafka 的本质。以 PRODUCE 和 FETCH 为例,从协议结构中就可以看出消息的写入和拉取消费都是细化到每一个分区层级的。并且,通过了解各个协议版本变迁的细节也能够从侧面了解 Kafka 变迁的历史,在变迁的过程中遇到过哪方面的瓶颈,又采取哪种优化手段,比如 FetchRequest 中的 session_id 的引入。
由于篇幅限制,笔者并不打算列出所有Kafka协议类型的细节。不过对于 Kafka 协议的介绍并没有到此为止,后面的章节中会针对其余41种类型的部分协议进行相关的介绍,完整的协议类型列表可以参考官方文档。Kafka 中最枯燥的莫过于它的上百个参数、几百个监控指标和几十种请求协议,掌握这三者的“套路”,相信你会对 Kafka 有更深入的理解。