消息系统通常由生产者(producer)、消费者(consumer)和消息代理(broker)
分布式系统通常会自己实现一套负责不同节点之间数据传输的网络层通信机制,也叫做RPC框架。RPC框架会处理网络通信协议的编解码、客户端和服务端的请求发送和接收等。
新生产者客户端
生产者要发送消息,先在客户端吧消息放入队列中,然后由一个消息发送线程从队列中拉取消息,以批量的方式发送消息给服务端。kafka的记录收集器(RecordAccumulator)负责缓存生产者客户端产生的消息,发送线程(sender)负责读取记录收集器的批量消息,通过网络发送给服务端。为了保证客户端网络请求的快速响应,Kafka使用选择器(Selector)处理网络连接和读写处理,使用网络连接(NetworkClient)处理客户端网络请求。
同步和异步发送消息
public class Producer extends Thread{
private final KafkaProducer<Integer, String> prod;
private final String topic;
private final Boolean isAsync;//true-异步,false-同步
public Producer(String topic, Boolean isAsync){
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("client.id", "DemoProducer");
props.put("key.serializer","org.apache.kafka.common.serialization.IntegerSerializer");
props.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
prod = new KafkaProducer<Integer, String>(props);//创建一个生产者客户端对象
this.topic = topic;
this.isAsync = isAsync;
}
public void run(){
int num = 1;
while(true){
String msg = "Message_"+num;
/*
每条消息都会包装成一条生产者记录,并传给生产者客户端对象的send发送方法,异步模式传递了一个Callback匿名回调类,当客户端发送完一条消息,并且这条消息成功地存储到kafka集群后,服务端会调用匿名回调类的回调方法
*/
if (isAsync){//异步发送
prod.send(new ProducerRecord<Integer,String>(topic,num,msg),new Callback(){
public void onCompletion(RecordMetadata metadata, Exception e) {
System.out.println("#offset:"+metadata.offset());
}
});
} else{//同步发送
prod.send(new ProducerRecord<Integer, String>(topic, num, msg)).get();
}
++num;
}
}
};
生产者客户端对象KafkaProducer的send方法的处理逻辑是:首先序列化消息的key和value(消息必须序列化成二进制流的形式才能在网络中传输),然后为每一条消息选择对应的分区(表示要将消息存储到Kafka集群的哪个节点上),最后通知发送线程发送消息。
1.为消息选择分区
kafka负载均衡方法:
对于没有键的:采用round-robin方式,均衡地发送到不同的分区
指定了消息的键,选择的算法是:对键进行散列化后,再与分区的数量取模运算得到分区编号。
在客户端就为消息选择分区的目的是什么?
只有为消息选择分区,才能知道应该发送到哪个节点,否则,只能随便找一个服务端节点, 再由哪个节点去决定如何将消息转发给其他正确的节点来保存。这样增加了服务端的负担,多了不必要的数据传输。这种方式比在客户端选择分区多了一次消息传输,而且是全量的数据传输,显然不划算。
2.客户端记录收集器
生产者每生产一条消息,就向记录收集器追加一条消息,一旦分区的队列中有批记录满了,就会被发送线程发送到分区对应的节点;如果批记录没有满,就会继续等待直到收集到足够的消息。
客户端消息发送线程
消息发送线程由两种消息发送方式:1.按照分区直接发送 2.按照分区的目标节点发送
先按照分区的主副本节点进行分组,把属于同一个节点的所有分区放在一起(记录收集器的工作),这种做法可以大大减少网络的开销。
1.从记录收集器获取数据
//消息发送线程(Sender)读取记录收集器,按照节点分组,创建客户端请求,发送请求
public void run(long now){
Cluster cluster = metadata.fetch();
//获取准备发送的所有分区
ReadyCheckResult result = accumulator.ready(cluster, now);
//建立到主副本节点的网络连接,移除还没有准备好的节点
Iterator<Node> iter = result.readyNodes.iterator();
while(iter.hasNext()){
Node node = iter.next();
if(!this.client.ready(node, now)) iter.remove();
}
//读取记录收集器,返回的每个主副本节点对应批记录列表,每个批记录对应一个分区
Map<Integer, List<RecordBatch>> batches = accumulator.drain(cluster,result.readyNodes, this.maxRequestSize,now);
//以节点为级别的生产请求列表,即每个节点只有一个客户端请求
List<ClientRequest> requests = createProduceRequests(batches, now);
//将请求放入队列中并等待发送,请求只能发送给准备好的节点
for (ClientRequest request:requests) client.send(request, now);
//这里才会执行真正的网络读写请求,比如将上面的客户端请求真正发送出去
this.client.poll(pollTimeout, now);
};
2.创建生产者客户端请求
//发送线程为每个目标节点创建一个客户端请求
private ClientRequest produceRequest(long now, int destination,short acks, int timeout, List<RecordBatch> batches) {
Map<TopicPartition,ByteBuffer> produceRecordsByPart = new HashMap();
final Map<TopicPartition, RecordBatch> recordsByPart = new HashMap();
for(RecordBatch batch: batches) {
//每个RecordBatch都有唯一的TopicPartition
TopicPartition tp = batch.topicPartition;
//RecordBatch的records是MemortyRecords,底层是ByteBuffer
produceRecordsByPart.put(tp, batch.records.buffer());
recordsByPart.put(tp, batch);
}
//构造生产者的请求,最后封装为统一的客户端请求
ProduceRequest req = new ProduceRequest(acks,timeout,produceRecordsByPart);
ReqeustSend send = new ReqeustSend(Integer.toString(destination),this.client.nextRequestHeader(ApiKeys.PRODUCE),req.toStruct());
//回调函数会作为客户端请求的一个成员变量,当客户端请求完成后,会调用回调函数
RequestCompletionHandler callback = new RequestCompletionHandler(){
public void onComplete(ClientResponse response) {
handleProduceResponse(response,recordsByPart);
}
};
return new ClientRequest(now, acks != 0, send, callback);
};
发送线程并不负责真正发送客户端请求,它会从记录收集器中取出要发送的消息,创建好客户端请求,然后把请求交给客户端网络对象(NetworkClient)去发送。
客户端网络连接对象
1.准备发送客户端请求
//客户端的发送线程通过NetworkClient向服务端发起网络连接
public boolean ready(Node node, long now) {
if (isReady(node, now)) return true;
if (connectionStates.canConnect(node.idString(), now))
initiateConnect(node, now);//允许连接但还没连接,就初始化连接
return false;//不允许连接,或者刚刚初始化,都不算准备好
}
private void initiateConnect(Node node, long now) {
String nodeConnectionId = node.idString();
this.connectionStates.connecting(nodeConnectionId, now);
selector.connect(nodeConnectionId, new InetSocketAddress(node.host(), node.port()), this.socketSendBuffer, this.socketReceiveBuffer);
}
//NetworkClient的send方法发送客户端请求
public void send(ClientRequest request, long now ){
this.inFlightRequest.add(request);//还没开始真正发送,先加入队列
selector.send(request.request());//发送的对象是客户端请求中的RequestSend
}
为了保证服务端的处理性能,客户端网络连接对象有一个限制条件:针对同一个服务端,如果一个客户端请求还没有发送完成,则不允许发送新的客户端请求。inFlightRequest类包含一个节点到双端队列的映射结构。
2.客户端轮询并调用回调函数
//NetworkClient的poll()轮询
public List<ClientResponse> poll(long timeout, long now) {
this.selector.poll(Utils.min(timeout,metadataTimeout, requestTimeoutMs));
List<ClientResponse> responses = new ArrayList<>();
handleCompletedSends(responses, updatedNow);//完成发送的处理器
handleCompletedReceives(responses, updatedNow);//完成接收的处理器
handleDisconnections(responses, updatedNow);//端口连接的处理器
handleConnections();//处理连接的处理器
handleTimeOutRequests(response, updatedNow);//超时请求的处理器
//上面几个处理都会忘responses中添加数据,有了响应后开始调用请求的回调函数
for(ClientResponse response:responses) {
if (response.request().hasCallback()) {
response.request().callback().onComplete(response);
}
}
return responses;
}
//处理已经完成的发送请求,如果不期望得到响应,就认为整个请求全部完成
void handleCompletedSends(List<ClientResponse> responses, long now) {
for (Send send: this.selector.completedSends()) {
ClientRequest request = inFlightRequest.lastSent(send.destination());
if (!request.expectResponse()){//不需要响应,发送完请求就结束了
this.inFlightRequests.completeLastSent(send.destination());
responses.add(new ClientResponse(request, now, false, null));
}
}
}
//处理已经完成的接收请求,根据接收到的响应更新响应列表
void handleCompletedReceives(List<ClientResponse> responses, long now) {
for (NetworkReceive receive:this.selector.completeReceives()) {
//连接到完整的响应了,现在可以删除inFlightRequests中的ClientRequest
ClientRequest req = inFlightRequests.completeNext(receive.source());
if (!metadataUpdater.maybeHandleCompletedReceive(req, now ,body))
responses.add(new ClientReponse(req, now ,false, body));
}
}
** 不需要响应的流程**开始发送请求->添加客户端请求到队列->发送请求->请求发送成功->从队列中删除发送请求->构造客户端响应
需要响应的流程开始发送请求->添加客户端请求到队列->发送请求->请求发送成功->等待接收响应->接收响应->接收到完整的响应->从队列中删除客户端请求->构造客户端响应
客户端响应包含客户端请求的目的是:根据响应获取请求中的回调对象,在收到响应后调用回调函数
3.客户端请求和客户端响应的关系
//客户端请求、包含要发送的请求、回调对象
public final class ClientRequest{
private final RequestSend request;
private final RequestCompletionHandler callback;
};
//客户端响应、包含响应内容、对应的发送请求
public class ClientResponse{
private final ClientRequest request;
private final Struct responseBody;
};
NetworkClient不仅负责将发送线程构造好的客户端请求发送出去,而且还要将服务端的响应结果构造成客户端响应并返回给客户端。
客户端请求对应的底层数据来源与Send,客户端响应对应的底层数据来源于NetworkReceive。客户端网络连接对象(NetworkClient)的底层网络操作都交给了选择器(Selector)。
选择器处理网络请求
选择器使用java NIO异步非阻塞方式管理连接和读写请求,他用单个线程就可以管理多个网络连接通道。
使用选择器的好处:生产者客户端只需要使用一个选择器,就可以同时和Kafak集群的多个服务端进行网络通信。
SocketChannel(客户端网络连接通道)。底层的字节数据读写都发生在通道上
Selector(选择器):发生在通道上的时间有读和写,选择器会通过选择键的方式监听读写事件的发生
SelectionKey(选择键):将通道注册到选择器上,读写事件发生时,通过选择键可以得到对应的通道,从而进行读写操作。
1.客户端连接服务端并建立Kafak通道
//客户端的选择器(Selector)连接远程的服务端节点
public void connect(String id, InetSocketAddress address) {
SocketChannel socketChannel = SocketChannel.open();
SocketChannel.configureBlocking(false);
Socket socket = SocketChannel.socket();
SocketChannel.connect(address);//非阻塞连接服务端,只是发起连接请求
//注册连接时间,创建底层的transportLayer等
SelectionKey key = socketChannel.register(nioSelector, OP_CONNECT());
KafkaChannell channel = channelBuilder.buildChannel(id,key,maxReceiveSize);
key.attach(channel);//将kafka通道注册到选择键上
//选择器还维护了每个节点编号和Kafka通道的映射关系
this.channels.put(id,channel);//节点编号和客户端请求中的目标节点对应
}
SocketChannel注册到选择器上返回选择键,将选择器用于构造传输层,再把传输层用于构造Kafka通道。这样kafka通道就和SocketChannel通过选择键进行了关联。
//选择器在链接服务端时构造kafka通道
public KafkaChannel buildChannel(String id, SelectionKey key, int size) {
PlaintextTransportLayer transportLayer = new PlaintextTransportLayer(key);
Authenticator auth = new DefaultAuthenticator();
auth.configure(transportLayer, principalBuilder, configs);
KafkaChannel channel = new KafkaChannel(id, transportLayer, auth, size);
return channel;
}
构建kafka通道的传输层有多种实现,比如纯文本模式(PlaintextTransportLayer)、sasl、ssl加密模式
2.kafka通道和网络传输层
//kafka通道有负责字节操作的传输层,抽象的NetworkReceive和send对象
public class KafkaChannel{
private final String id;
private final TransportLayer transportLayer;
private final Authenticator authenticator;
private final int maxReceiveSize;
private NetworkReceive receive;
private Send send;//表示要发出去的数据,如果缓冲区的数据都发送完,说明Send写入完成
//KafkaChannel要操作SocketChannel时,都交给传输层去做
public boolean finishConnect() throws IOException{
return transportLayer.finishConnect();
}
};
NetworkReceive有两个缓冲区,其中size缓冲区表示数据的长度,buffer缓冲区表示数据的内容,如果两个缓冲区都写满了,说明NetworkReceive读取完成。
3.Kafka通道上的读写操作
一个kafka通道一次只能处理一个send请求,每次send时都要添加写事件。当send发送成功后,就要取消写事件。一个完整的请求和对应的事件监听步骤是:设置send请求到kafka通道->注册写事件->发送请求->Send请求发送完成->取消写事件。
服务端网络连接
KafkaServer是kafka服务端的主类。SocketServer主要关注网络层的通信协议,具体的业务处理逻辑则交给KafkaRequestHandler和KafkaApis来完成。SocketServer和这两个组件一起完成一次请求处理的具体步骤如下。
1)客户端发送的请求被接收器(Acceptor)转发给处理器(Processor)处理。
2)处理器将请求放到请求通道(RequestChannel)的全局请求队列中
3)KafkaRequestHandler取出请求通道中的客户端请求
4)调用KafkaApis进行业务逻辑处理
5)KafkaApis将相应结果发送给请求通道中与处理器对应的响应队列
6)处理器从对应的响应队列中取出响应结果
7)处理器将响应结果返回给客户端,客户端请求处理完毕。
服务端使用接收器接受客户端的连接
SocketServer是一个NIO服务,会启动一个接收器线程(Acceptor)负责接收所有的客户端连接请求,并将请求分发给不同的处理器(Processor)
Reactor模式的设计思想:==连接部分和请求部分用不同的线程来处理,这样请求的处理不会阻塞不断到来的连接。==使用Reactor模式并结合选择器管理多个客户端的网络连接,可以减少线程之间的上下文切换和资源的开销。
处理器使用选择器的轮询处理网络请求
一个完整的请求和响应过程串联起来的步骤如下
1)客户端完成请求的发送,服务端轮询到客户端发送的请求
2)服务端接受完客户端发送的请求,进行业务处理,并准备好响应结果准备发送
3)服务端完成响应的发送,客户端轮询到服务端的响应
4)客户端接受完服务端发送的响应,整个流程结束
请求通过的请求队列和响应队列
请求通道就是处理器与请求处理线程和KafkaApis交换数据的地方:如果处理器往请求通道添加请求,请求处理线程和kafkaApis都可以获取到请求通道中的请求;如果请求处理线程和kafkaApis往请求通道添加响应,处理器也可以从请求通道获取响应。