poll(timeout){
根据poll(timeout)参数,估算剩余时间
while(还有剩余时间)
从Fetcher端拉取消费到的消息
if(消息数量不为空)
创建发送请求
立刻将请求发送
else
return
end //if ends
计算剩余时间
end //while ends
}
从上述伪代码可以看到,在超时时间到达之前,KafkaConsumer会反复通过调用KafkaConsumer.poll()
进行消息的拉取,其实这次消息的获取是上一次请求的返回数据,同时,每一次poll请求,KafkaConsumer都会顺便再一次发送请求以便下一次poll操作能够直接获取返回结果。
看到这里,肯定有人会问,每次poll完成以后都再一次发送请求,那是否会让每一次poll()
的执行时间延长?答案是否定的,请求的发送是异步执行的。这个可以通过ConsumerNetworkClient.send()
方法看出,读者可自行阅读代码。
public ConsumerRecords poll(long timeout) {
acquire();//确保只有一个唯一线程调用poll方法
try {
if (timeout < 0)
throw new IllegalArgumentException("Timeout must not be negative");
// poll for new data until the timeout expires
long start = time.milliseconds();
long remaining = timeout;
do {
//进行一次消费操作
Map>> records = pollOnce(remaining);
if (!records.isEmpty()) {
fetcher.sendFetches();//在请求到数据以后,顺便发送下一次请求,由于请求是异步发送,因此并不会影响本次消息消费的效率
client.pollNoWakeup();//发送一个poll请求,并且是立刻返回的,因为timeout=0
if (this.interceptors == null)
return new ConsumerRecords<>(records);
else
return this.interceptors.onConsume(new ConsumerRecords<>(records));
}
long elapsed = time.milliseconds() - start;//计算剩余可用的时间
remaining = timeout - elapsed;
} while (remaining > 0);
return ConsumerRecords.empty();
} finally {
release();
}
}
在保证超时时间没有到达的前提下,通过调用pollOnce()
来进行一次消息的拉取,其实是调用一次Fetcher.fetchedRecords()
方法取出已经收到的Kafka消息:
/**
* 进行一次消费操作,如果这次操作直接在fetcher已经存在,则直接返回这些已经完成的结果,而如果fetcher没有返回任何结果,则会强行进行一次poll操作。
*/
private Map>> pollOnce(long timeout) {
// TODO: Sub-requests should take into account the poll timeout (KAFKA-1894)
//确认服务端的GroupCoordinator已经获取并且已经能够接受请求
coordinator.ensureCoordinatorReady();
// ensure we have partitions assigned if we expect to
//确认已经完成了分区分配
if (subscriptions.partitionsAutoAssigned())
coordinator.ensurePartitionAssignment();
// fetch positions if we have partitions we're subscribed to that we
// don't know the offset for
if (!subscriptions.hasAllFetchPositions())
updateFetchPositions(this.subscriptions.missingFetchPositions());
long now = time.milliseconds();
// execute delayed tasks (e.g. autocommits and heartbeats) prior to fetching records
//执行heartbeat任务或者自动提交offset任务
client.executeDelayedTasks(now);
// init any new fetches (won't resend pending fetches)
Map>> records = fetcher.fetchedRecords();//直接获取已经收到的数据
// if data is available already, e.g. from a previous network client poll() call to commit,
// then just return it immediately
if (!records.isEmpty())
return records;
//如果没有接收到任何一条消息,则真正地发送fetch请求
fetcher.sendFetches();
client.poll(timeout, now);
return fetcher.fetchedRecords();
}
pollOnce()
的基本执行逻辑,就是首先确保远程的GroupCoordinator是正常并且已经连接的状态。在这里我需要解释一下Kafka的两种类型的Coordinator:
在pollOnce()
开始时,首先需要确认消费消息以前的所有准备工作已经做完,包括:
通过coordinator.ensureCoordinatorReady();
确认GroupCoordinator的身份已经明确并且可以接收请求。如果发现GroupCoordinator还没有准备好,则该方法会一直block直到其处于ready的状态:
/**
* Block until the coordinator for this group is known and is ready to receive requests.
* 等待直到我们和服务端的GroupCoordinator取得连接
*/
public void ensureCoordinatorReady() {
while (coordinatorUnknown()) {//无法获取GroupCoordinator
RequestFuture future = sendGroupCoordinatorRequest();//发送请求
client.poll(future);//同步等待异步调用的结果
if (future.failed()) {
if (future.isRetriable())
client.awaitMetadataUpdate();
else
throw future.exception();
} else if (coordinator != null && client.connectionFailed(coordinator)) {
// we found the coordinator, but the connection has failed, so mark
// it dead and backoff before retrying discovery
coordinatorDead();
time.sleep(retryBackoffMs);//等待一段时间,然后重试
}
}
}
同时,通过 coordinator.ensurePartitionAssignment();
确认已经成功加入了group并且分派给自己的分区都是正常的。
当确认了自己与GroupCoordinator的所有状态都正常,在正式获取数据之前,还会对已经到达运行时间的定时任务执行。这种定时任务主要包括两种:
AutoCommitTask.run()
方法,同时,AutoCommitTask.run()
中,会调用AutoCommitTask.reschedule()
再次提交一个任务,从而实现这个定时任务的不断提交,即offset的不断提交。注意,这两种定时任务在Kafka上叫做delayedTask
,即可以 容忍适当延迟 的任务。客户端每次执行poll操作,都会检查这些延迟任务的执行时间是否已经到了,如果到了就执行。同时,我们看到,远程的GroupCoordinator是通过心跳来判断ConsumerCoordinator的心跳来判断ConsumerCoordinator是否还活着,而心跳信息只有在poll()被调用的时候发出,因此,如果我们在两次相邻地poll之间的时间超过阈值,GroupCoordinator会认为ConsumerCoordinator已经消失并进行rebalance操作。咋大多数情况下,无论Kafka的代码多么的健壮,一次rebalce都会是一次不稳定因素,是应该竭力避免的行为。因此,我们应该通过合理设置一下两个参数,来竭力避免两次poll相邻时间过长导致的rebalance:
max.poll.records
:合理设置每次poll的消息消费数量,如果数量过多,导致一次poll操作返回的消息记录无法在指定时间内完成,则会出发rebalance;max.poll.interval.ms
:尽力保证一次poll的消息能够很快完成,无论我们的业务代码在拿到poll()
的结果之后做了什么操作,比如需要存入hdfs、需要存入hive、关系型数据库,都需要对消耗的时间进行预估,保证时间不会太长;在执行完了中的延迟任务以后,开始调用fetcher.fetchedRecords();
获取数据。上面已经说过,这次获取的数据是上一次poll发出的请求所返回的数据,因此是直接从内存中获取的已有数据:
public Map>> fetchedRecords() {
if (this.subscriptions.partitionAssignmentNeeded()) {//是否需要重新进行分区分配
return Collections.emptyMap();//返回空结果
} else {
//保存返回结果,key为TopicPartition,value为这个TopicPartition的所有消费到到数据
Map>> drained = new HashMap<>();
int recordsRemaining = maxPollRecords;
//从方法sendFetches可以看到,每一个CompletedFetch的一条数据,是某个TopicPartition的一批数据
Iterator completedFetchesIterator = completedFetches.iterator();//遍历已经返回的结果
while (recordsRemaining > 0) {//计算剩余可以poll的消息量
if (nextInLineRecords == null || nextInLineRecords.isEmpty()) {//第一次进入循环
if (!completedFetchesIterator.hasNext())
break;
CompletedFetch completion = completedFetchesIterator.next();
completedFetchesIterator.remove();
//将字节消息转换成ConsumerRecord对象
nextInLineRecords = parseFetchedData(completion);
} else {
//将数据从nextInLineRecords中取出,放入到drained中,并且清空nextInLineRecords,更新offset
recordsRemaining -= append(drained, nextInLineRecords, recordsRemaining);
}
}
return drained;
}
}
fetchedRecords()
方法中,通过不停地迭代遍历保存了已完成的消费请求所返回到数据的List
,从中取出CompletedFetch,但是由于CompletedFetch中保存是返回的原始字节码数据,因此会将字节码翻译为数据对象,依照数据的TopicPartition,存入到Map
中。当消息数量已经不小于用户配置的最大消费消息数量,活着当前completedFetches已经没有了数据,则循环退出,返回数据。其中比较重要的方法是private int append(Map
方法:
private int append(Map>> drained,
PartitionRecords partitionRecords,
int maxRecords) {
if (partitionRecords.isEmpty())
return 0;
if (!subscriptions.isAssigned(partitionRecords.partition)) {//判断是否是分配给自己的分区
// this can happen when a rebalance happened before fetched records are returned to the consumer's poll call
log.debug("Not returning fetched records for partition {} since it is no longer assigned", partitionRecords.partition);
} else {//是自己的分区
// note that the consumed position should always be available as long as the partition is still assigned
long position = subscriptions.position(partitionRecords.partition);//当前的分区消费位置
//当且仅当1.这个分区的确是分派给这个consumer 2当前不是pause状态 3.当前存在合法的分区位置,这个分区才会是fetchable
if (!subscriptions.isFetchable(partitionRecords.partition)) {
// this can happen when a partition is paused before fetched records are returned to the consumer's poll call
log.debug("Not returning fetched records for assigned partition {} since it is no longer fetchable", partitionRecords.partition);
} else if (partitionRecords.fetchOffset == position) {//分区位置校验通过
// we are ensured to have at least one record since we already checked for emptiness
List> partRecords = partitionRecords.take(maxRecords);
long nextOffset = partRecords.get(partRecords.size() - 1).offset() + 1;//下一个offset是当前收到的最后一条消息的offset+1
log.trace("Returning fetched records at offset {} for assigned partition {} and update " +
"position to {}", position, partitionRecords.partition, nextOffset);
//将这一批数据保存到map中
List> records = drained.get(partitionRecords.partition);
if (records == null) {
records = partRecords;
drained.put(partitionRecords.partition, records);
} else {
records.addAll(partRecords);
}
//更新offset
subscriptions.position(partitionRecords.partition, nextOffset);
return partRecords.size();
} else {
// these records aren't next in line based on the last consumed position, ignore them
// they must be from an obsolete request
log.debug("Ignoring fetched records for {} at offset {} since the current position is {}",
partitionRecords.partition, partitionRecords.fetchOffset, position);
}
}
partitionRecords.discard();
return 0;
}
这个方法等职责比较关键,核心任务是把返回的一批数据按照TopicPartition
归类,存入Map
作为最终返回数据,同时,还进行了数据校验:
KafkaConsuer.pause()
方法,用来暂停消费;当所有校验通过,则将数据保存在drained中作为最终返回结果,同时,通过subscriptions.position(partitionRecords.partition, nextOffset);
更新本地保存的该TopicPartition对应的分区位置为nextOffset:
从上述代码:long nextOffset = partRecords.get(partRecords.size() - 1).offset() + 1;
,nextoffset
是下一条消息的offset值。
在上文中,我们从KafkaConsumer.poll(timeout)
方法为入口,分析了消费者如何通过Fetcher进行消息消费的。我们说过,每次消息消费,都是上一次请求对应的返回结果,是从内存中直接获取的请求。因此,现在我们来看看每一次的消费请求是如何发出的。
其实,从poll(timeout)
的代码可以看到,每次消费完数据,都会通过Fetcher.sendFetches()
顺带发送下一次的消费请求:
public void sendFetches() {
//调用createFetchRequests创建发送请求,然后逐个请求发送到远程broker
for (Map.Entry fetchEntry: createFetchRequests().entrySet()) {
final FetchRequest request = fetchEntry.getValue();//request是对某个节点上的某个TopicPartition的请求数据
//ConsumerNetworkClient.send会将请求放到unsend中
client.send(fetchEntry.getKey(), ApiKeys.FETCH, request)
.addListener(new RequestFutureListener() {
@Override
public void onSuccess(ClientResponse resp) {
FetchResponse response = new FetchResponse(resp.responseBody());
//获取这一批响应数据中的所有的TopicPartition
Set partitions = new HashSet<>(response.responseData().keySet());
FetchResponseMetricAggregator metricAggregator = new FetchResponseMetricAggregator(sensors, partitions);
//对响应数据进行遍历
for (Map.Entry entry : response.responseData().entrySet()) {
TopicPartition partition = entry.getKey();
long fetchOffset = request.fetchData().get(partition).offset;//请求发送的时候这个TopicPartition的offset
FetchResponse.PartitionData fetchData = entry.getValue();//fetchData中存放了这个TopicPartition所返回的数据
completedFetches.add(new CompletedFetch(partition, fetchOffset, fetchData, metricAggregator));
} sensors.fetchLatency.record(resp.requestLatencyMs()); sensors.fetchThrottleTimeSensor.record(response.getThrottleTime());
}
@Override
public void onFailure(RuntimeException e) {
log.debug("Fetch failed", e);
}
});
}
}
sendFetches()
方法通过createFetchRequests()
来创建请求,然后,将请求通过ConsumerNetworkClient.send()
逐渐发送出去。ApiKeys.FETCH
代表了请求类型为数据请求,即消费请求,除了数据消费请求,还有各种其它请求,都是通过ConsumerNetworkClient.send()
发送到远程的,比如:
ApiKeys.PRODUCE 生产消息的请求
ApiKeys.METADATA:获取服务器元数据的请求
ApiKeys.JOIN_GROUP:加入到group的请求
ApiKeys.LEAVE_GROUP:离开group请求
ApiKeys.SYNC_GROUP:同步group信息的请求
ApiKeys.HEARTBEAT:心跳请求
ApiKeys.OFFSET_COMMIT:提交offset的请求
ApiKeys.OFFSET_FETCH:获取远程offset的请求
client.send(fetchEntry.getKey(), ApiKeys.FETCH, request)
是通过异步回调的方式来处理返回结果,通过定义一个实现了RequestFutureListener的匿名实现类,实现了收到相应成功或者失败以后的回调:
.addListener()
public interface RequestFutureListener {
void onSuccess(T value);
void onFailure(RuntimeException e);
}
当成功收到相应,会将消息经过处理放入到List
中。上文已经说过,Fetcher.fetchedRecords
就是从completedFetches
获取消息的。
同时,我们一起来看看Fetcher是如何创建数据消费请求的:
/**
* Create fetch requests for all nodes for which we have assigned partitions
* that have no existing requests in flight.
* 创建fetch请求,这个请求的key是node,value是一个FetchRequest对象,这个对象封装了对这个节点上的一个或者多个TopicPartition的数据获取请求
*/
private Map createFetchRequests() {
// create the fetch info
Cluster cluster = metadata.fetch();
//fetchable的key是节点,value是在这个节点上所有TopicPartition的请求信息
Map> fetchable = new HashMap<>();
for (TopicPartition partition : fetchablePartitions()) {//对于每一个partition
Node node = cluster.leaderFor(partition);//查看这个partition的leader节点
if (node == null) {
metadata.requestUpdate();//node是空,则重新更新元数据
} else if (this.client.pendingRequestCount(node) == 0) {//如果这个节点上的pending请求为0,pending既包括in-flight,也包括unsent
// if there is a leader and no in-flight requests, issue a new fetch
Map fetch = fetchable.get(node);
if (fetch == null) {
fetch = new HashMap<>();
fetchable.put(node, fetch);
}
long position = this.subscriptions.position(partition);
//将当前的offset信息、请求数据的大小放入request中
fetch.put(partition, new FetchRequest.PartitionData(position, this.fetchSize));//将每个partition的请求保存
log.trace("Added fetch request for partition {} at offset {}", partition, position);
}
}
// create the fetches
Map requests = new HashMap<>();
for (Map.Entry> entry : fetchable.entrySet()) {
Node node = entry.getKey();
FetchRequest fetch = new FetchRequest(this.maxWaitMs, this.minBytes, entry.getValue());
requests.put(node, fetch);
}
return requests;
}
createFetchRequests()
的执行伪代码:
获取集群元数据
获取所有的fetchablePartitions
for(每一个fetchablePartition){
获取这个partition的leader node
if(无法获取lead node信息)
发送元数据更新请求
else
{
创建对这个节点的数据获取请求,保存在一个Map中
}
}
请求创建完毕,保存在Map中,返回这个Map
createFetchRequests会获取所谓fetchablePartitions,那么,究竟哪些TopicPartition被认为是fetchable的呢?
我们一起来看 :
private Set fetchablePartitions() {
Set fetchable = subscriptions.fetchablePartitions();
//从fetchedRecords()方法中可以看到,nextInLineRecords代表正在进行处理的返回结果
if (nextInLineRecords != null && !nextInLineRecords.isEmpty())
fetchable.remove(nextInLineRecords.partition);
//completedFetches代表已经取回的等待消费的数据
for (CompletedFetch completedFetch : completedFetches)
fetchable.remove(completedFetch.partition);
return fetchable;
}
以上就是KafkaConsumer委托Fetcher创建消费请求、获取消费数据的基本流程,其实涉及到比较多的东西,包括通过ConsumerCoordinator代理自己与远程的GroupCoordinator进行沟通,进入和离开Group,分区的分派,通过ConsumerNetworkClient负责底层的网络通信,通过SubscriptionState对象维护本地的TopicPartition的信息,获取到消息以后的校验,通过定时任务进行自动offset提交,通过定时任务进行心跳以报告活性等等。有兴趣的读者可以自行详细阅读代码。我将会有更多的博客来对本过程涉及到的其他方面进行专门的介绍。
虽然Kafka的核心代码在Server端,但是从Consumer或者Producer端进入,基本上可以看到整个消息通信的基本逻辑、设计和业务流程。Consumer端的代码在保证高效、节点网络流量的负载均衡以及客户端和服务端所有状态的一致性、单线程方面做了大量非常好的设计和解决方案,同时,通过ConsumerGroup的概念、Topic订阅的概念、基于Master/Slave设计的Group责任制(一个Group只有一个Consumer会被选举为Group Leader,剩余未Follower)、基于Master/Slave设计的TopicPartition责任制(对于每一个TopicPartition,只有一个Consumer会被选举为Leader,剩余作为Repliation),使得Kafka的消息系统具有非常棒的轻松横向扩展性,分布式环境下也有了很好的数据一致性(所有TopicParition的请求都发往这个TopicParition 的leader),这是我非常喜欢Kafka的一个重要原因。当然,这也对服务端的Leader角色提出了非常高的并发性。后面我们会介绍基于Reactor模式的设计,Kafka Server能够很好处理高并发响应、多任务处理的切换等。