最近一直研究kafka源码,想着有必要记录一下。不管研究是否到位,也算是一个里程碑吧。
当我们说到 Kafka 服务器端,也就是 Broker 的时候,往往会说它承担着消息持久化的功能,但本质上,它其实就是一个不断接收外部请求、处理请求,然后发送处理结果的 Java 进程 (因为scala 代码被编译之后生成.class 文件,它和 Java 代码被编译后的效果是一样的)。
高效地保存排队中的请求,是确保 Broker 高处理性能的关键。既然这样,Broker 上的请求队列是怎么实现的呢?接下来,就看下 Broker 底层请求对象的建模和请求队列的实现原理,以及 Broker请求处理方面的核心监控指标。目前,Broker 与 Clients 进行交互主要是基于Request / Response 机制,所以,很有必要学习一下源码是如何定义 Request 和 Response 的。
先看一下 RequestChannel 源码中的 Request 定义代码。源码位于 core/src/main/scala/kafka/network ,RequestChannel.scala 文件,是主要实现类。
sealed trait BaseRequest
case object ShutdownRequest extends BaseRequest
class Request(val processor: Int,
val context: RequestContext,
val startTimeNanos: Long,
memoryPool: MemoryPool,
@volatile private var buffer: ByteBuffer,
metrics: RequestChannel.Metrics) extends BaseRequest {
.....
}
BaseRequest 是一个 trait 接口,定义了基础的请求类型。它有两个实现类:ShutdownRequest 类和 Request 类。
ShutdownRequest 仅仅起到一个标志位的作用。当 Broker 进程关闭时,请求处理器会发送 ShutdownRequest 到专属的请求处理线程。该线程接收到此请求后,会主动触发一系列的 Broker 关闭逻辑。Request 则是真正定义各类 Clients 端或 Broker 端请求的实现类。它定义的属性包括 processor、context、startTimeNanos、memoryPool、buffer 和 metrics等等。
processor 是 Processor 线程的序号,即这个请求是由哪个 Processor 线程接收处理的。Broker 端参数 num.network.threads 控制了 Broker 每个监听器上创建的 Processor 线程数。在默认情况下,Broker 启动时会创建 3 个 Processor 线程,为一组,分别给 listeners 参数中设置的监听器使用,每组的序号分别是 0、1、2。
那为什么要保存 Processor 线程的序号呢?这是因为,当 Request 被后面的 I/O 线程处理完成后,还要依靠 Processor 线程发送 Response 给请求发送方,因此,Request 中必须记录它之前是被哪个 Processor 线程接收的。另外,这里明确一点:Processor 线程仅仅是网络接收线程,不会执行真正的 Request 请求处理逻辑,那是 I/O 线程负责的事情。
context 是用来标识请求上下文信息的。Kafka 源码中定义了 RequestContext 类,顾名思义,它保存了有关 Request 的所有上下文信息。RequestContext 类定义在 clients 工程中,下面是它主要的逻辑代码。我用注释的方式解释下主体代码的含义。
public class RequestContext implements AuthorizableRequestContext {
// Request头部数据,主要是一些对用户不可见的元数据信息,如Request类型、Request API版本、clientId等
public final RequestHeader header;
// Request发送方的TCP连接串标识,由Kafka根据一定规则定义,主要用于表示TCP连接
public final String connectionId;
// Request发送方IP地址
public final InetAddress clientAddress;
// Kafka用户认证类,用于认证授权
public final KafkaPrincipal principal;
// 监听器名称,可以是预定义的监听器(如PLAINTEXT),也可自行定义
public final ListenerName listenerName;
// 安全协议类型,目前支持4种:PLAINTEXT、SSL、SASL_PLAINTEXT、SASL_SSL
public final SecurityProtocol securityProtocol;
// 用户自定义的一些连接方信息
public final ClientInformation clientInformation;
// RequestContext的封装
public RequestContext(RequestHeader header,
String connectionId,
InetAddress clientAddress,
KafkaPrincipal principal,
ListenerName listenerName,
SecurityProtocol securityProtocol,
ClientInformation clientInformation) {
this.header = header;
this.connectionId = connectionId;
this.clientAddress = clientAddress;
this.principal = principal;
this.listenerName = listenerName;
this.securityProtocol = securityProtocol;
this.clientInformation = clientInformation;
}
// 从给定的ByteBuffer中提取出Request和对应的Size值
public RequestAndSize parseRequest(ByteBuffer buffer) {
if (isUnsupportedApiVersionsRequest()) {
// Unsupported ApiVersion requests are treated as v0 requests and are not parsed
// 不支持的ApiVersions请求类型被视为是V0版本的请求,并且不做解析操作,直接返回
ApiVersionsRequest apiVersionsRequest = new ApiVersionsRequest(new ApiVersionsRequestData(), (short) 0, header.apiVersion());
return new RequestAndSize(apiVersionsRequest, 0);
} else {
// 从请求头部数据中获取ApiKeys信息
ApiKeys apiKey = header.apiKey();
try {
// 从请求头部数据中获取版本信息
short apiVersion = header.apiVersion();
// 解析请求
Struct struct = apiKey.parseRequest(apiVersion, buffer);
AbstractRequest body = AbstractRequest.parseRequest(apiKey, apiVersion, struct);
// 封装解析后的请求对象以及请求大小返回
return new RequestAndSize(body, struct.sizeOf());
} catch (Throwable ex) {
// 解析过程中出现任何问题都视为无效请求,抛出异常
throw new InvalidRequestException("Error getting request for apiKey: " + apiKey +
", apiVersion: " + header.apiVersion() +
", connectionId: " + connectionId +
", listenerName: " + listenerName +
", principal: " + principal, ex);
}
}
}
startTimeNanos 记录了 Request 对象被创建的时间,主要用于各种时间统计指标的计算。
请求对象中的很多 JMX 指标,特别是时间类的统计指标,都需要使用 startTimeNanos 字段。它是以纳秒为单位的时间戳信息,可以实现非常细粒度的时间统计精度。
memoryPool 表示源码定义的一个非阻塞式的内存缓冲区,主要作用是避免 Request 对象无限使用内存。当前,该内存缓冲区的接口类和实现类,分别是 MemoryPool 和 SimpleMemoryPool。
buffer 是真正保存 Request 对象内容的字节缓冲区。Request 发送方必须按照 Kafka RPC 协议规定的格式向该缓冲区写入字节,否则将抛出 InvalidRequestException 异常。这个逻辑主要是由 RequestContext 的 parseRequest 方法实现的,看上图。
metrics 是 Request 相关的各种监控指标的一个管理类。它里面构建了一个 Map,封装了所有的请求 JMX 指标。除了上面这些重要的字段属性之外,Request 类中的大部分代码都是与监控指标相关的。
Kafka 为 Response 定义了 1 个抽象父类和 5 个具体子类。具体如下:
接下来看下Response的代码:
abstract class Response(val request: Request) {
locally {
val nowNs = Time.SYSTEM.nanoseconds
request.responseCompleteTimeNanos = nowNs
if (request.apiLocalCompleteTimeNanos == -1L)
request.apiLocalCompleteTimeNanos = nowNs
}
def processor: Int = request.processor
def responseString: Option[String] = Some("")
def onComplete: Option[Send => Unit] = None
override def toString: String
}
这个抽象基类只有一个属性字段:request。这就是说,每个 Response 对象都要保存它对应的 Request 对象。上面说过,onComplete 方法是调用指定回调逻辑的地方。SendResponse 类就是复写(Override)了这个方法,如下所示:
class SendResponse(request: Request,
val responseSend: Send,
val responseAsString: Option[String],
val onCompleteCallback: Option[Send => Unit])
extends Response(request) {
......
override def onComplete: Option[Send => Unit] = onCompleteCallback
}
这里的 SendResponse 类继承了 Response 父类,并重新定义了 onComplete 方法。复写的逻辑很简单,就是指定输入参数 onCompleteCallback。
RequestChannel,顾名思义,就是传输 Request/Response 的通道。
class RequestChannel(val queueSize: Int, val metricNamePrefix : String) extends KafkaMetricsGroup {
import RequestChannel._
val metrics = new RequestChannel.Metrics
// ArrayBlockingQueue 是java中阻塞队列,来保存Broker接收到的各类请求
private val requestQueue = new ArrayBlockingQueue[BaseRequest](queueSize)
// 字段 processors 封装的是 RequestChannel 下辖的 Processor 线程池。
// 每个 Processor 线程负责具体的请求处理逻辑
private val processors = new ConcurrentHashMap[Int, Processor]()
val requestQueueSizeMetricName = metricNamePrefix.concat(RequestQueueSizeMetric)
val responseQueueSizeMetricName = metricNamePrefix.concat(ResponseQueueSizeMetric)
......
}
就 RequestChannel 类本身的主体功能而言,它定义了最核心的 3 个属性:requestQueue、queueSize 和 processors。
说下有关processor的管理
Processor 线程池——它是用 Java 的 ConcurrentHashMap 数据结构去保存的。Map 中的 Key 就是前面我们说的 processor 序号,而 Value 则对应具体的 Processor 线程对象。
这个线程池的存在告诉了我们一个事实:当前 Kafka Broker 端所有网络线程都是在 RequestChannel 中维护的。既然创建了线程池,代码中必然要有管理线程池的操作。RequestChannel 中的 addProcessor 和 removeProcessor 方法就是做这些事的。
def addProcessor(processor: Processor): Unit = {
// 添加Processor到Processor线程池
if (processors.putIfAbsent(processor.id, processor) != null)
warn(s"Unexpected processor with processorId ${processor.id}")
newGauge(responseQueueSizeMetricName,
() => processor.responseQueueSize,
// 为给定Processor对象创建对应的监控指标
Map(ProcessorMetricTag -> processor.id.toString))
}
def removeProcessor(processorId: Int): Unit = {
processors.remove(processorId) // 从Processor线程池中移除给定Processor线程
removeMetric(responseQueueSizeMetricName, Map(ProcessorMetricTag -> processorId.toString)) // 移除对应Processor的监控指标
}
代码很简单,基本上就是调用 ConcurrentHashMap 的 putIfAbsent 和 remove 方法分别实现增加和移除线程。每当 Broker 启动时,它都会调用 addProcessor 方法,向 RequestChannel 对象添加 num.network.threads 个 Processor 线程
如果查询 Kafka 官方文档的话,你就会发现,num.network.threads 这个参数的更新模式(Update Mode)是 Cluster-wide。这就说明,Kafka 允许你动态地修改此参数值。比如,Broker 启动时指定 num.network.threads 为 8,之后你通过 kafka-configs 命令将其修改为 3。显然,这个操作会减少 Processor 线程池中的线程数量。在这个场景下,removeProcessor 方法会被调用。
除了 Processor 的管理之外,RequestChannel 的另一个重要功能,是处理 Request 和 Response,具体表现为收发 Request 和发送 Response。
1. 比如,收发 Request 的方法有 sendRequest 和 receiveRequest:
def sendRequest(request: RequestChannel.Request): Unit = {
requestQueue.put(request)
}
def receiveRequest(timeout: Long): RequestChannel.BaseRequest =
requestQueue.poll(timeout, TimeUnit.MILLISECONDS)
def receiveRequest(): RequestChannel.BaseRequest =
requestQueue.take()
所谓的 sendRequest,仅仅是将 Request 对象放置在 Request 队列中而已,而接收 Request 则是从队列中取出 Request。整个流程构成了一个迷你版的“生产者 - 消费者”模式,然后依靠 ArrayBlockingQueue 的线程安全性来确保整个过程的线程安全。
2. 对于 Response 而言,则没有所谓的接收 Response,只有发送 Response,即 sendResponse 方法。sendResponse 是啥意思呢?其实就是把 Response 对象发送出去,也就是将 Response 添加到 Response 队列的过程。
def sendResponse(response: RequestChannel.Response): Unit = {
if (isTraceEnabled) { // 构造Trace日志输出字符串
val requestHeader = response.request.header
val message = response match {
case sendResponse: SendResponse =>
s"Sending ${requestHeader.apiKey} response to client ${requestHeader.clientId} of ${sendResponse.responseSend.size} bytes."
case _: NoOpResponse =>
s"Not sending ${requestHeader.apiKey} response to client ${requestHeader.clientId} as it's not required."
case _: CloseConnectionResponse =>
s"Closing connection for client ${requestHeader.clientId} due to error during ${requestHeader.apiKey}."
case _: StartThrottlingResponse =>
s"Notifying channel throttling has started for client ${requestHeader.clientId} for ${requestHeader.apiKey}"
case _: EndThrottlingResponse =>
s"Notifying channel throttling has ended for client ${requestHeader.clientId} for ${requestHeader.apiKey}"
}
trace(message)
}
// 找出response对应的Processor线程,即request当初是由哪个Processor线程处理的
val processor = processors.get(response.processor)
// 将response对象放置到对应Processor线程的Response队列中
if (processor != null) {
processor.enqueueResponse(response)
}
}
前面的一大段 if 代码块仅仅是构造 Trace 日志要输出的内容。根据不同类型的 Response,代码需要确定要输出的 Trace 日志内容。接着,代码会找出 Response 对象对应的 Processor 线程。当 Processor 处理完某个 Request 后,会把自己的序号封装进对应的 Response 对象。一旦找出了之前是由哪个 Processor 线程处理的,代码直接调用该 Processor 的 enqueueResponse 方法,将 Response 放入 Response 队列中,等待后续发送。
RequestChannel 类还定义了丰富的监控指标,用于实时动态地监测 Request 和 Response 的性能表现。
object RequestMetrics {
val consumerFetchMetricName = ApiKeys.FETCH.name + "Consumer"
val followFetchMetricName = ApiKeys.FETCH.name + "Follower"
val RequestsPerSec = "RequestsPerSec"
val RequestQueueTimeMs = "RequestQueueTimeMs"
val LocalTimeMs = "LocalTimeMs"
val RemoteTimeMs = "RemoteTimeMs"
val ThrottleTimeMs = "ThrottleTimeMs"
val ResponseQueueTimeMs = "ResponseQueueTimeMs"
val ResponseSendTimeMs = "ResponseSendTimeMs"
val TotalTimeMs = "TotalTimeMs"
val RequestBytes = "RequestBytes"
val MessageConversionsTimeMs = "MessageConversionsTimeMs"
val TemporaryMemoryBytes = "TemporaryMemoryBytes"
val ErrorsPerSec = "ErrorsPerSec"
}
最后,总结一下RequestChannel请求类: