broker 的大部分工作是处理客户端、分区副本和控制器发送给分区首领(Leader)的请求。
Kafka 提供了一个二进制协议(基于 TCP),指定了请求消息的格式以及 broker 如何对请求作出响应——包括成功处理请求或在处理请求过程中遇到错误。
基于上述协议,Kafka生态除了有Java客户端,还有其他语言的客户端,例如 C、Python、Go 等等。
客户端发起连接并发送请求, broker 处理请求并作出响应。broker 按照请求到达的顺序来处理它们——这种顺序保证让Kafka 具有了消息队列的特性,同时保证保存的消息也是有序的。
所有的请求消息都包含一个标准消息头:
- Request type(也就是 API key)
- Request version(broker 可以处理不同版本的客户端请求,并根据客户端版本作出不同的响应)
- Correlation ID:一个具有唯一性的数字,用于标识请求消息,同时也会出现在响应消息和错误日志里(用于诊断问题)
- Client ID——用于标识发送请求的客户端
这里不打算详细描述该协议,Kafka 文档里已经有很详细的说明。不过,了解broker 如何处理请求还是有必要的——后面在我们讨论 Kafka 监控和各种配置选项时,你就会了解到那些与队列和线程有关的度量指标和配置参数。
Broker 会在它所监听的每一个端口上运行一个 Acceptor 线程,这个线程会创建一个连接,并把它交给Processor 线程去处理。Processor 线程(也被叫作“网络线程”)的数量是可配置的。
网络线程负责从客户端获取请求消息,把它们放进请求队列,然后从响应队列获取响应消息,把它们发送给客户端。下图为 Kafka 处理请求的内部流程:
请求消息被放到请求队列后,IO 线程会负责处理它们。下面是几种最常见的请求类型:
- 生产请求
生产者发送的请求,它包含客户端要写入 broker 的消息。
- 获取请求
在消费者和跟随者副本需要从 broker 读取消息时发送的请求。
- 管理请求
管理客户端在执行元数据操作(如创建和删除主题)时发送的信息
生产请求和获取请求都必须发送给分区的首领副本。如果 broker 收到一个针对特定分区的请求,而该分区的首领在另一个 broker 上,那么发送请求的客户端会收到一个“非分区首领”的错误响应。
当针对特定分区的获取请求被发送到一个不含有该分区首领的 broker上,也会出现同样的错误。
Kafka 客户端要自己负责把生产请求和获取请求发送到正确的broker 上。
元数据请求
那么客户端怎么知道该往哪里发送请求呢(哪个是首领副本)?
客户端使用了另一种请求类型,也就是元数据请求。
这种请求包含了客户端感兴趣的主题列表。服务器端的响应消息里指明了这些主题所包含的分区、每个分区都有哪些副本,以及哪个副本是首领。
元数据请求可以发送给任意一个 broker,因为所有 broker 都缓存了这些信息。
一般情况下,客户端会把这些信息缓存起来,并直接往目标 broker 上发送生产请求和获取请求。它们需要时不时地通过发送元数据请求来刷新这些信息(刷新的时间间隔通过 metadata.max.age.ms 参数来配置),从而知道元数据是否发生了变更.
另外,如果客户端收到“非首领”错误,它会在尝试重发请求之前先刷新元数据,因为这个错误说明了客
户端正在使用过期的元数据信息,之前的请求被发到了错误的 broker 上。
生产请求
之前介绍配置生产者的时候,提到过 acks 这个配置参数——该参数指定了需要多少个 broker 确认才可以认为一个消息写入是成功的。
不同的配置对“写入成功”的界定是不一样的,如果 acks=1,那么只要首领收到消息就认为写入成功;如果 acks=all,那么需要所有同步副本收到消息才算写入成功;如果 acks=0,那么生产者在把消息发出去之后,完全不需要等待 broker 的响应。
包含首领副本的 broker 在收到生产请求时,会对请求做一些验证:
- 发送数据的用户是否有主题写入权限?
- 请求里包含的 acks 值是否有效(只允许出现 0、1 或 all)?
- 如果 acks=all,是否有足够多的同步副本保证消息已经被安全写入?(我们可以对broker 进行配置,如果同步副本的数量不足,broker 可以拒绝处理新消息。)
之后,消息被写入本地磁盘。在 Linux 系统上,消息会被写到文件系统缓存里,并不保证它们何时会被刷新到磁盘上。Kafka 不会一直等待数据被写到磁盘上——它依赖复制功能来保证消息的持久性。
在消息被写入分区的首领之后,broker 开始检查 acks 配置参数——如果 acks 被设为 0 或 1,那么 broker 立即返回响应;如果 acks 被设为 all,那么请求会被保存在一个叫作_炼狱_的缓冲区里,直到首领发现所有跟随者副本都复制了消息,响应才会被返回给客户端。
获取请求
broker 处理获取请求的方式与处理生产请求的方式很相似。
客户端发送请求,向 broker 请求主题分区里具有特定偏移量的消息,好像在说:“请把主题 Test 分区 0 偏移量从 53 开始的消息以及主题 Test 分区 3 偏移量从 64 开始的消息发给我”。
指定最大数据量
客户端还可以指定 broker 最多可以从一个分区里返回多少数据。这个限制是非常重要的,因为客户端需要为 broker 返回的数据分配足够的内存。
如果没有这个限制,broker 返回的大量数据有可能耗尽客户端的内存。
校验请求
之前讨论过,请求需要先到达指定的分区首领上,然后客户端通过查询元数据来确保请求的路由是正确的。
首领在收到请求时,它会先检查请求是否有效——比如,指定的偏移量在分区上是否存在?如果客户端请求的是已经被删除的数据,或者请求的偏移量不存在,那么 broker 将返回一个错误。
如果请求的偏移量存在,broker 将按照客户端指定的数量上限从分区里读取消息,再把消息返回给客户端。
零拷贝
Kafka 使用零拷贝技术向客户端发送消息——也就是说,Kafka 直接把消息从文件(或者更确切地说是 Linux 文件系统缓存)里发送到网络通道,而不需要经过任何中间缓冲区。
这是 Kafka 与其他大部分数据库系统不一样的地方,其他数据库在将数据发送给客户端之前会先把它们保存在本地缓存里。这项技术避免了字节复制,也不需要管理内存缓冲区,从而获得更好的性能。
指定最小数据量
客户端除了可以设置 broker 返回数据的上限,也可以设置下限。例如,如果把下限设置为10KB,就好像是在告诉 broker:“等到有 10KB 数据的时候再把它们发送给我。”
在主题消息流量不是很大的情况下,这样可以减少 CPU 和网络开销。
客户端发送一个请求,broker 等到有足够的数据时才把它们返回给客户端,然后客户端再发出请求,而不是让客户端每 隔几毫秒就发送一次请求,每次只能得到很少的数据甚至没有数据。
对比这两种情况,它们最终读取的数据总量是一样的,但前者的来回传送次数更少,因此开销也更小。
最大获取间隔
当然,我们不会让客户端一直等待 broker 累积数据。在等待了一段时间之后,就可以把可用的数据拿回处理,而不是一直等待下去。
所以,客户端可以定义一个超时时间,告诉 broker:“如果你无法在 X 毫秒内累积满足要求的数据量,那么就把当前这些数据返回给我。”
ISR
有意思的是,并不是所有保存在分区首领上的数据都可以被客户端读取。
大部分客户端只能读取已经被写入所有同步副本的消息(跟随者副本也不行,尽管它们也是消费者——否则复制功能就无法工作)。分区首领知道每个消息会被复制到哪个副本上,在消息还没有被写入所有同步副本之前,是不会发送给消费者的——尝试获取这些消息的请求会得到空 的响应而不是错误
因为还没有被足够多副本复制的消息被认为是“不安全”的——如果首领发生崩溃,另一个副本成为新首领,那么这些消息就丢失了。如果我们允许消费者读取这些消息,可能就会破坏一致性。试想,一个消费者读取并处理了这样的一个消息,而另一个消费者发现这个消息其实并不存在。
所以,我们会等到所有同步副本复制了这些消息,才允许消费者读取它们(如下图所示)。这也意味着,如果 broker 间的消息复制因为某些原因变慢,那么消息到达消费者的时间也会随之变长(因为我们会先等待消息复制完毕)。
延迟时间可以通过参数** replica.lag.time.max.ms** 来配置,它指定了副本在复制消息时可被允许的最大延迟时间。
其他请求
到此为止,我们讨论了 Kafka 最为常见的几种请求类型:元数据请求、生产请求和获取请求。重要的是,我们讨论的是客户端在网络上使用的通用二进制协议。
Kafka 内置了由开源社区贡献者实现和维护的 Java 客户端,同时也有用其他语言实现的客户端,如 C、Python、Go 语言等。Kafka 网站上有它们的完整清单,这些客户端就是使用这个二进制协议与 broker 通信的。
另外,broker 之间也使用同样的通信协议。它们之间的请求发生在 Kafka 内部,客户端不应该使用这些请求。例如,当一个新首领被选举出来,控制器会发送 LeaderAndIsr 请求给新首领(这样它就可以开始接收来自客户端的请求)和跟随者(这样它们就知道要开始跟随新首领)。