Pulsar client原理解析
导语
Pulsar 作为一个消息传输的解决方案,最基本的功能是提供了pub/sub模型的消息服务,即作为一个消息中间件的能力,本文主要以Java Client为例讲述生产者、消费者和Broker之间的交互过程。
1. Client 与 broker 交互流程
和常见的MQ一样,Topic(分区)由 Broker 持有,因此生产者和消费者首先需要创建连接到 Broker,然后查询对应发布或者订阅的 Topic 所在的 Broker 信息。
这个过程起始分为以下几个步骤:
- 创建到 Broker 的连接
- 连接创建完毕之后,向 Broker 查询 Topic 的分区数信息
- 根据分区数,查询对应分区当前被哪个 Broker 持有
- 创建到分区所在 Broker 的连接
当 producer/consumer 已经连接到具体分区所在的 Broker 之后,
生产者可以进行生产:
- 向 Broker 发送请求注册 Producer
- 发送数据
消费者可以进行消费:
- 向 Broker 发送请求注册 Subscription
- 请求数据
- 发送 Ack 请求到 Broker
当 Broker 接收到上述请求之后,会一一处理并且回应:
- 对于创建链接请求,响应创建成功
- 对于分区查询请求,响应分区数
- 对于分区所有 Broker 查询请求,响应 Broker URL
- 对于生产者/消费者注册请求,响应生产者、消费者注册成功
- 对于生产者注册请求,响应生产注册成功
- 对于生产数据请求,响应生产已经接受
- 对于消费数据请求,响应数据
总体上的生产者、消费者和Broker的交互逻辑如上所述,下面结合代码详细描述整个过程。
2. PulsarClient 初始化过程
初始化Pulsar Producer和Consumer都需要先初始化 Pulsar client。创建一个 pulsar client 的代码如下:
PulsarClient pulsarClient =
PulsarClient.builder()
.serviceUrl(url)
.statsInterval(intervalInSecs, TimeUnit.SECONDS)
.build();
这里会涉及到两个参数
- serviceUrl: broker 地址列表,也可以通过ServiceUrlProvider提供
- statsInterval: client状态采集的周期,默认是60s,比如生产者会采集生产速率,消费者会采集消费速率,当配置大于0时生效,如果小于等于0则不采集状态
初始化 PulsarClientImpl
当所有的参数设置完成之后,最后build()
会初始化一个PulsarClientImpl对象。在这个过程中,首先会初始化 EventLoopGroup 和 ConnectionPool ,然后执行 PulsarClientImpl的初始化。
初始化EventLoopGroup
- 根据平台是否支持epoll,决定初始化一个 EpollEventLoopGroup 或者NioEventLoopGroup(netty可以使用native transport来提升性能),并且设置EventLoopGroup的IO线程数。
一个EventLoopGroup包含多个EventLoop, Netty使用EventLoop来处理连接上的读写事件,而一个连接上的所有请求都保证在一个EventLoop中被处理,一个EventLoop中只有一个Thread,所以也就实现了一个连接上的所有事件只会在一个线程中被执行。
初始化ConnectionPool
主要是初始化一下内容
- eventLoopGroup
- 连接池缓存pool,pool用来保存 链接地址和ClientCnx的映射关系
- 初始化 Netty Bootstrap,设置一些参数以及handler,
PulsarChannelInitializer
, 在链接建立之后,会初始化 Channel 的 handler pipeline,主要的handler是 ClientCnx - 初始化 Netty dnsResolver
public ConnectionPool(ClientConfigurationData conf, EventLoopGroup eventLoopGroup,
Supplier<ClientCnx> clientCnxSupplier) throws PulsarClientException {
this.eventLoopGroup = eventLoopGroup;
this.maxConnectionsPerHosts = conf.getConnectionsPerBroker();
pool = new ConcurrentHashMap<>();
bootstrap = new Bootstrap();
bootstrap.group(eventLoopGroup);
bootstrap.channel(EventLoopUtil.getClientSocketChannelClass(eventLoopGroup));
bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, conf.getConnectionTimeoutMs());
bootstrap.option(ChannelOption.TCP_NODELAY, conf.isUseTcpNoDelay());
bootstrap.option(ChannelOption.ALLOCATOR, PulsarByteBufAllocator.DEFAULT);
try {
bootstrap.handler(new PulsarChannelInitializer(conf, clientCnxSupplier));
} catch (Exception e) {
log.error("Failed to create channel initializer");
throw new PulsarClientException(e);
}
this.dnsResolver = new DnsNameResolverBuilder(eventLoopGroup.next()).traceEnabled(true)
.channelType(EventLoopUtil.getDatagramChannelClass(eventLoopGroup)).build();
}
EventLoopGroup 和 ConnectionPool初始化完毕之后,继续进行PulsarClientImpl的初始化工作
- 初始化lookup服务 :Lookup服务用来查找topic 的元数据信息,比如分区数、分区所在broker地址、Schema信息等,有两种类型,HttpLookupService 和 BinaryProtoLookupService
- 初始化producer/consumer缓存(set)
- 初始化MemoryLimitController:MemoryLimitController用来做生产消息的内存占用限制,内部会维护内存占用的计数器,当生产消息时,会增加计数;生产完毕时减少计数;如果计数达到上限时,根据blockIfQueueFull来决定阻塞请求或者抛出异常
完成初始化工作之后,将Client的状态置为Open。
3. Publish工作原理
向Pulsar Publish数据,需要首先初始化一个producer,初始化的过程中可以为producer指定一些属性
pulsarClient.newProducer()
.enableBatching(true) // 开启batch
.batchingMaxPublishDelay(100, TimeUnit.MILLISECONDS) // batch的最大等待时间
.batchingMaxBytes(12800) // batch的最大大小
.batchingMaxMessages(10) // batch中最多保留的消息条数
.messageRoutingMode(MessageRoutingMode.RoundRobinPartition) // Message Routing Mode
.roundRobinRouterBatchingPartitionSwitchFrequency(10) // batch开启时,分区切换频率
.maxPendingMessages(50000) // 最大缓存的生成请求数
.maxPendingMessagesAcrossPartitions(100000)// 分区topic配置,即所有分区的最大缓存数量
.blockIfQueueFull(true) // 当达到最大缓存数时是否block客户端
.sendTimeout(5, TimeUnit.SECONDS) // 发送超时时间
.topic("test")
.compressionType(CompressionType.SNAPPY) // 压缩类型
.producerName("producer-name")
.enableChunking(true) // 是否开启Chunking特性
.accessMode(ProducerAccessMode.Shared) // producer mode
.intercept(new ProducerInterceptor() { // 设置interceptor,实现自定义的功能
@Override
public void close() {}
@Override
public boolean eligible(Message message) {return false;}
@Override
public Message beforeSend(Producer producer, Message message) { return null;}
@Override
public void onSendAcknowledgement(Producer producer, Message message, MessageId msgId, Throwable exception) {}
})
.create();
Producer配置
Batch 配置
Producer可以配置Batch发送消息和单条发送消息,开启了batch之后,可以通过:
- 消息条数
- 消息大小
- batch等待时间
以上三个条件来产生batch,上述配置只要满足一个就会触发一个新的batch
Routing 配置
这个配置是针对分区topic的,可以为message设置路由策略来决定message会被发到哪个分区,包含三种:
- SinglePartition:随机选择一个分区,将所有消息写到这个分区
- RoundRobinPartition:轮询策略,会将消息轮询的发送到每个分区,如果producer没有开启batch,则每发送一条message,就会切换一个分区;如果开启了batch,则按照batch的最大等待时间batchingMaxPublishDelayMicros的倍数来决定,具体的倍数由参数 batchingPartitionSwitchFrequencyByPublishDelay 指定,即每经过 batchingMaxPublishDelayMicros * batchingPartitionSwitchFrequencyByPublishDelay 的时间,分区切换一次,在切换的时间内message被发送到同一个分区
- CustomPartition: 自定义分区策略,分区方式有MessageRouter决定,即需要自己实现MessageRouter
对于 SinglePartition 和 RoundRobinPartition,如果message设置了key,都会按照hash的方式决定message写到哪个分区。
默认配置RoundRobinPartition;如果存在customMessageRouter,则RoutingMode只能是CustomPartition。
Pending配置
Producer可以最多hold的message数量,如果超过这个配置,则按照blockIfQueueFull来决定是阻塞生产者或者抛出异常信息。
如果是分区topic,则每个分区可以hold的最大消息数量为:
Math.min(conf.getMaxPendingMessages(), conf.getMaxPendingMessagesAcrossPartitions() / numPartitions);
Chunk 配置
当一条Message的大小超出了broker配置的最大消息大小时,开启Chunk配置,可以让producer自动将消息split成为多个小的消息,并且按照顺序发送到Broker。如果消费者希望按照获取切分之前的Message,需要对producer和consumer进行以下配置:
- 目前只支持 non-shared subscription 和 persistent topic
- disable batch
- Pulsar-client 在接收到 ack之后才能继续发送消息到Broker(防止chunk乱序),所以可以调小maxPendingMessages来避免太高的内存占用
- 配置message 的 ttl/retention来清理不完整的chunk message, 有些场景比如producer或者broker重启,会导致broker接收到了不完整的chunk message,这一部分消息consumer无法ack,需要按照ttl或者retention被清理掉;或者通过配置ConsumerBuilder#expireTimeOfIncompleteChunkedMessage(long, TimeUnit)来执行message expire逻辑
- 消费者最好配置 receiverQueueSize 和 maxPendingChuckedMessage
如果一个消息大小超过了最大值,那么会默认做切分,这是后会根据producerName-sequenceId拼接出一个UUid,所有的chunk都有一样的sequenceId和uuid。
Producer Mode 配置
生产者的模式,目前包括三种:
- Shared :默认多个producer可以同时向一个topic publish消息
- Exclusive:请求独占式生产,如果已经存在其他producer,则立即失败
- WaitForExclusive:producer的创建会pending,直至获取到独占访问权限
Producer 与 Broker 交互
完成了配置之后,就可以对Producer进行初始化,然后publish message 到broker,这一系列的过程需要和Broker进行不断的交互,主要的交互流程如下:
接下来就围绕Producer的整个初始化以及生产过程分析Producer/Broker交互的整个过程
创建ProducerImpl/PartitionedProducerImpl
首先是一些参数校验和补齐的工作:
- 校验是否batch和chunk同时开启,如果是,抛出异常
- 校验topic
然后获取元数据信息:
- 获取元数据信息
- 创建ProdducerImpl或者PartitionedProducerImpl
- 在Client的Producer缓存中保存Producer信息
获取元数据
Producer会向Broker发出元数据请求,通过lookup.getPartitionedTopicMetadata来完成,这个过程会涉及到几个交互命令,我们逐个进行分析(以BinaryProtoLookupService为例):
- 第一步,Producer根据serviceUrl指定的Broker地址连接到Broker;如果配置了多个Broker地址,第一次连接时会随机选择一个,之后按照配置的顺序选取下一个Broker地址,通过bootstrap.connect(remoteAddress)完成,不会发送任务Pulsar Command到Broker,链接创建完毕之后,会触发Netty Handler的channelActive方法:
- 如果keepAliveIntervalSeconds > 0,则每经过keepAliveIntervalSeconds的时间就启动keepAlive的任务,发送PING到Broker,Broker会返回PONG响应
- 启动 request 超时定时检查任务,检查requestTimeoutQueue中是否存在超时的请求,如果有则从pendingRequests移除对应的请求并将请求结果置为超时
- 发送CONNECT命令到Broker,并将Client状态置为 SentConnectFrame; Broker接收到CONNECT请求之后,会返回CONNECTED响应,并且携带Broker端的maxMessageSize配置信息
- 第二步,链接创建完毕之后,Producer 向 Broker 发送PARTITIONED_METADATA请求,Broker接收到请求之后,会获取topic的元数据信息,然后返回PARTITIONED_METADATA_RESPONSE 给 Producer
创建Producer实例
根据 PARTITIONED_METADATA_RESPONSE 中的分区数,决定创建那种类型的Producer(ProducerImpl或者PartitionedProducerImpl),我们这里以ProducerImpl为例:
- 将Producer状态置为Uninitialized
- 初始化 pendingMessages 和 pendingCallbacks 队列,用来存放 pending 的 Message 和 Callback
- 初始化 sequenceId,如果Producer 有配置 initialSequenceId,则将 lastSequenceIdPublished 和 lastSequenceIdPushed 都置为 initialSequenceId ,将msgIdGenerator 置为 initialSequenceId+1 ;如果没有配置,则 lastSequenceIdPublished 和 lastSequenceIdPushed 都置为-1,msgIdGenerator 置为0; lastSequenceIdPushed 表示已经 send 到 broker 的消息的 sequenceId,lastSequenceIdPublished 表示已经 publish 成功的 message 的 sequenceId
- 启动 sendRequest超时检查任务,从 pendingMessages选取第一个请求,
- 如果没有pending的Message,按照配置的超时时间新建一个新的超时检查任务
- 如果存在pending的request,则判断是否超时,
- 如果不超时,则计算剩余的等待时间,按照剩余时间new一个新的超时检查任务
- 如果超时,则释放 pendingMessages 中所有的请求,并且清空 pendingMessages 和 pendingCallbacks,如果开启了batch,丢弃batchContainer中的所有内容,然后按照配置的超时时间创建一个新的超时检查任务
- 计算出producer的创建超时时间
- 如果开启Batch,初始化一个BatchContainer
- 初始StatsRecorder
- 初始化ConnectionHandler,然后调用ConnectionHandler.grabCnx 查找topic所在的broker地址,然后建立到broker的连接
grabCnx
grabCnx分为两个阶段,首先是lookup topic所在的broker,然后创建producer到broker的连接。先看findBroker的过程,依旧是通过LookupService来查找:
- 首先根据serviceUrl向一个Broker发送 LOOKUP 请求,broker接收到lookup请求之后,首先根据请求的topic,查找对应的namespacebundle信息,然后查找bundle所在的broker地址,最后将结果返回给producer
- producer得到topic所在的broker地址之后,创建到对应broker的连接,连接建立之后,回调ProducerImpl的connectionOpened方法,执行接下来的初始化工作:
- 发送 PRODUCER 请求到broker,broker接收到 PRODUCER 请求之后,会进行以下操作:
- 获取Topic信息
- 检查 Topic 是否超出了BacklogQuota的限制,如果超过则返回异常,异常根据BacklogQuota.RetentionPolicy的不同而不同,如果policy是producer_request_hold,client接收到异常之后,会阻塞Producer的创建;如果是producer_exception则会抛出异常
- 创建服务端的Producer对象,并将Producer注册到Topic中
- 返回 PRODUCER_SUCCESS响应给Producer,会携带消息去重中的最大sequenceId
- 接收到PRODUCER_SUCCESS响应之后,Producer会根据sequenceId来初始化lastSequenceIdPublished以及msgIdGenerator,逻辑是如果msgIdGenerator为0并且producer没有指定InitialSequenceId,则将lastSequenceIdPublished置为sequenceId,msgIdGenerator置为sequenceId+1
- 如果开启了Batch,则初始化一个定时任务,根据配置的batchingMaxPublishDelayMicros时间来定时发送消息,发送是会将BatchContainer中的所有message遍历封装到send请求中发送到Broker,请求中会携带第一条message的sequenceId和最后一条mesage的sequenceId
- 重新发送pendingMessages中的消息
- 发送 PRODUCER 请求到broker,broker接收到 PRODUCER 请求之后,会进行以下操作:
到这里,producer的初始化工作全部完成。
生产数据
Producer初始化完成之后,就可以使用Producer来发送数据到broker。发送数据之前需要首先构造出Message,然后将Message发送到Broker。
TypedMessageBuilder<byte []> messageBuilder = producer.newMessage()
.key("Key" + key) // 分区路由使用的key,即partition key
.value("Value" + value) // 内容
.deliverAfter(100, TimeUnit.SECONDS) // 延迟投递
.eventTime(System.currentTimeMillis()) // eventtime时间
.orderingKey("orkerkey".getBytes()) // 用于SubscriptionType#Key_Shared模式的key,如果没有会使用partition key
.property("myKey-async","myValue-async"); // 消息属性
MessageIdImpl messageId = (MessageIdImpl) messageBuilder.sendAsync().get();
在构建Message的过程中可以指定一些参数,如上代码所示。生产数据的流程
- Producer首先判断是否超过MaxPendingMessage的限制,超出则抛出异常
- 判断是否是replicated的消息并且具有ProducerName,如果不是replicated的消息但是具有producerName,则抛出异常,因为producerName是在序列化send请求是设置的,在这里不应该存在producerName
- 判断是否是不支持Batch 或者 hasDeliverAtTime,这两种场景都需要单独发送,不需要batch,并且如果请求的大小超过了最大限制,则需要判断是否支持chunk,不支持则抛出异常
- 如果需要切分,那么切分之后会增加send请求数,再次判断是否超过MaxPendingMessage的限制,对每个chunk单独进行发送,切分之后的chunk具有相同的sequenceId(UUID,组合了producerName和sequenceId的字符串),不同的chunkId,相同的totalChunk,以及相同的压缩/非压缩size、producerName
- 如果是chunked message,不用考虑batch,因为初始化producer的时候已经做了校验,batch和chunk不能同时存在,直接将消息发送到Broker
- 如果是普通message,判断是否支持可以batch发送
- 如果可以Batch(开启batch,并且没有deliverAtTime),判断是否可以添加到当前Batch
- 如果可以添加到当前Batch(batch的消息大小和条数都可以容纳当前message),判断sequenceId是否小于等于 lastSequenceIdPushed
- 如果sequenceId <= lastSequenceIdPushed,说明message可能重复,isLastSequenceIdPotentialDuplicated = true,需要则先把BatchContainer中的消息打包发送到Broker,原因是把可能重复的消息和不重复的消息区分开,然后将当前Message添加到BatchContainer
- 否则,现将Message加到BatchContainer中,然后判断batch是否full,如果batch是full状态,则把BatchContainer中的消息打包发送到Broker
- 如果不能添加到当前Batch,则先把BatchContainer中的消息打包发送到Broker,然后将当前Message添加到BatchContainer
- 如果可以添加到当前Batch(batch的消息大小和条数都可以容纳当前message),判断sequenceId是否小于等于 lastSequenceIdPushed
- 如果不可以Batch,直接发送消息到Broker
- 如果可以Batch(开启batch,并且没有deliverAtTime),判断是否可以添加到当前Batch
Broker接收到send请求之后,将通过ManagedLedger将数据写入到Bookie,然后返回 SEND_RECEIPT 给client。 客户端拿到SEND_RECEIPT之后,可以获取messageId,包括LedgerId、EntryId (batchIndex)。
4. Subscription 工作原理
从Pulsar subscription 数据,需要首先初始化一个 consumer,初始化的过程中可以为consumer指定一些属性
Consumer<byte[]> consumer = pulsarClient.newConsumer()
.topic("persistent://public/default/topic")
.subscriptionName("sub")
.consumerName("consumer")
.subscriptionMode(SubscriptionMode.Durable)
.subscriptionType(SubscriptionType.Exclusive)
.ackTimeout(3, TimeUnit.SECONDS)
.ackTimeoutTickTime(5, TimeUnit.SECONDS)
.acknowledgmentGroupTime(2, TimeUnit.SECONDS)
.isAckReceiptEnabled(true)
.maxPendingChuckedMessage(100)
.autoAckOldestChunkedMessageOnQueueFull(true)
.isAckReceiptEnabled(ackReceiptEnabled)
.startMessageIdInclusive()// 是否包含seek的messageId
.subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) // 订阅的起始positino,默认lastest
.enableRetry(true)
.deadLetterPolicy(DeadLetterPolicy.builder()
.maxRedeliverCount(5)
.deadLetterTopic("persistent://public/default/dead-letter-topic")
.retryLetterTopic("persistent://public/default/retry-letter-topic")
.build())
.receiverQueueSize(5000)
.maxTotalReceiverQueueSizeAcrossPartitions(50000)
.batchReceivePolicy(BatchReceivePolicy.DEFAULT_POLICY)
.priorityLevel(1)
.enableBatchIndexAcknowledgment(true)
.maxPendingChuckedMessage(1000)
.subscriptionName("my-subscriber-name")
.subscribe();
Consuemr配置
Consumer的配置相比于Producer要多一些,我们详细的来看一下:
基本配置
- topic:订阅的topic名称
- subscriptionName:订阅名称
- consumerName:消费者名称
订阅模式
subscriptionMode:订阅模式,包括durable和non-durable两种,
- durable的订阅,会有cursor来记录订阅的位置信息
- non-durable的订阅则没有cursor
订阅类型
subscriptionType:订阅类型,包括:
- Exclusive:一个订阅中只能有一个consumer,可以保证消费的顺序
- Failover:一个订阅中可以存在多个consumer,只有一个consumer可以接收消息,当这个consumer断开连接之后,其他的consumer才可以接收消息,可以保证消费的顺序;对于多分区topic,每个分区都可以保证消费的顺序,多个分区会在所有可用的consumer上分配,对于一个分区,最多有一个active的consumer(类似于kafka、tubemq)
- Shared:一个订阅中可以存在多个consumer,消息以 round-robin 的方式发送给所有连接的consumer,不能保证消费顺序
- Key_Shared:一个订阅中可以存在多个consumer,所有具有相同key的消息会给发送给同一个consumer
ACK设置
-
ackTimeout : message ack的超时时间,配置时需要大于1s;默认ackTimeout是disable的,即所有已经投递的消息都不会重新投递;当enable ack timeout时,如果一个投递的消息在timeout的时间内没有ack,这个消息将被重新投递
-
ackTimeoutTickTime: UnAckedMessageTracker中ack 超时检查任务的周期,不能超过ackTimeout,这里之所以不用ackTimeout是为了提供一个更加细粒度的超时控制
-
acknowledgmentGroupTime:ack在默认情况下并不是每次都提交到broker,而是通过group的方式提交组合成一个ack请求提交到broker,默认group时间是100ms
-
enableBatchIndexAcknowledgment:是否开启batch index级别的ack,默认pulsar的ack级别是<LedgerId, EntryID>级别,这个配置开启之后,可以ack到batch内的index级别
ChunkMessage消费配置
- maxPendingChuckedMessage: 最大可以缓存的chunk message数量
- autoAckOldestChunkedMessageOnQueueFull: 是否自动对超出 maxPendingChuckedMessage的chunk message进行ack,如果设置为true,则自动ack,否则不自动ack(会有超时检查任务触发重新投递)
- expireTimeOfIncompleteChunkedMessage:chunk message的超时时间,如果consumer超过这个时间没有接收到一个message的所有chunk message,consumer可以expire这些不完整的chunk
Deadletter / RetryLetter 配置
-
enableRetry :是否支持自动retry
-
deadLetterPolicy: 死信队列配置,可以配置最大 re-deliver 次数,超过这个次数之后,consumer会将数据写入到deadLetterTopic中,然后对message进行ack;如果enableRetry =true,consumer会自动订阅RetryLetterTopic,在调用 reconsumeLater 方法之后,会首先将数据写入到RetryLetterTopic中并对message进行ack,当retry次数超过max re-deliver次数之后,会将topic写入deadLetter中,并且对message进行ack
如果enableRetry =false,ack超时或者nack的次数大于 maxRedeliverCount ,就会将 message 写入DeadLetterTopic,并且ack,重试是在broker的RedeliveryTracker记录的;
如果enableRetry =true, 那么retry也会生效,如果调用了reconsumeLater方法,consumer会将message写到RetryLetterTopic中,并且进行ack (不需要等待 重试次数大于maxRedeliverCount),因为consumer会自动订阅 RetryLetterTopic 所以consumer可以从RetryLetterTopic中继续消费到这个message;当retry的次数超过了maxRedeliverCount时,会将message写入到DeadLetterTopic,并且 ack RetryLetterTopic
接收缓存队列配置
- receiverQueueSize:consumer最大的接收缓存大小
- maxTotalReceiverQueueSizeAcrossPartitions:分区topic的最大接收缓存
这两个配置和producer的配置类似,决定了consumer一次可以从broker的最大的数据接收量
优先级
- priorityLevel: 0表示最高优先级。在Share模式的订阅中,broker会有限将数据dispatch到最高优先级的consumer中(如果这个consumer的接收队列没有满),当高优先级的consumer没有permits时,才会dispatch到低一级优先级的consumer。
对于Share的订阅,Consumer-A 优先级为0,Consumer-B 优先级为 1,那么broker会有限将数据dispatch到A,直到A的permits使用完才会向B发送数据。下面是一个示例:
Consumer PriorityLevel Permits
-
C1 0 2
-
C2 0 1
-
C3 0 1
-
C4 1 2
-
C5 1 1
Broker dispatch的顺序为C1,C2,C3,C1,C4,C5,C4
对于Failover订阅,Broker会根据优先级和consumerName的字典序选择active 的 consumer,
如果优先级一样,按照name的字典序选择,如下,active consumer为C1
Consumer PriorityLevel Name
- C1 0 aaa
- C2 0 bbb
如果优先级币一样,按照优先级选择,如下,active consumer 为C2
Consumer PriorityLevel Name
- C1 1 aaa
- C2 0 bbb
对于分区topic,Broker会将partition平均的分配给最高优先级的consumer。
BatchReceive 配置
- batchReceivePolicy : batch receive策略可以现在在一个batch中的消息数量和大小,也可以指定一个等待消息的超时时间,batch receive会在数量、大小或者超时三个条件中任何一个触发的条件下完成。
自定义batch receive policy。
client.newConsumer().batchReceivePolicy(BatchReceivePolicy.builder()
.maxNumMessages(100)
.maxNumBytes(5 * 1024 * 1024)
.timeout(100, TimeUnit.MILLISECONDS);
Consumer 和 Broker 交互
完成了配置之后,就可以对Consumer(后续均使用ConsumerImpl为例,并且topic为Non-Paititioned topic)进行初始化,然后从broker消费数据,这一系列的过程需要和Broker进行不断的交互,主要的交互流程如下:
创建ConsumerImpl
首先是一些参数校验和补齐的工作:
- 校验topic和subscription name
- 初始化DLQ相关配置
- 校验 Compact topic,如果是读取Compact topic,那么订阅类型只能是Exclusive或者Failover
- 校验ConsumerEventListener,用来监听Failover模式下active consumer切换时间
根据配置的Topic信息,决定创建那种类型的Topic,包括三种类型:配置一个Topic,配置多个Topic,Topic parttern,以一个topic为例:
和Producer一样首先要获取元数据信息,
- 如果是一个分区Topic,则初始化MultiTopicsConsumerImpl,否则,初始化ConsumerImpl
- 在consumers缓存中记录consumer信多个Topic信息
ConsumerImpl
如果Topic是一个非分区Topic,client会创建一个ConsumerImpl对象,并且做初始化的相关工作,关键的初始化如下:
- 最大接收队列长度,maxReceiverQueueSize
- incomingMessages,接收队列,用于存放broker 发送过来的message
- unAckedChunkedMessageIdSequenceMap,保存没有完全消费的chunk message信息的map
- 配置batch receive policy ,如果policy 中配置了超时时间,则会启动一个TimeoutTask,这个超时任务会定期检查pending的batch receive 请求,如果请求超时,那么会从incomming中取出message,封装在MessagesImpl中返回,这个过程会将所有的message添加到unAckTracker中跟踪ack状态
- 设置 receiverQueueRefillThreshold,这个值用来重新出发FlOW请求,当permit数量大于这个值时就会想broker发送FLOW请求,是maxReceiveQueueSize的一半
- 初始化NegativeAcksTracker,NegativeAcksTracker 用来跟踪nack的message
- consumer调用nack时,NegativeAcksTracker 会记录messageId信息
- 然后由一个定时任务去检查nack的时间是否超过nackDelayNanos,如果没有超过,则用剩余的时间初始化一个新的定时任务;如果超过,则触发nack的逻辑
- 首先将chunkmessage和nack的message 都加到 messagesToRedeliver中
- 如果订阅类型不是share,那么为了保证有序,需要重传所有 的消息,清空incomingqueue和unackTracker,然后向Broker发送 REDELIVER_UNACKNOWLEDGED_MESSAGES 请求,broker会重新投递所有的unack的消息
- 如果订阅类型是share,那么首先从incomming queue中的移除所有需要messagesToRedeliver的所有message,然后向broker 发送REDELIVER_UNACKNOWLEDGED_MESSAGES ,并携带 需要重传的MessageId信息,broker会投递所有messageId对应的Message
- 初始化chunk message相关内容,maxPendingChunkedMessage,pendingChunkedMessageUuidQueue,expireTimeOfIncompleteChunkedMessageMillis,autoAckOldestChunkedMessageOnQueueFull
- 初始化unAckTracker,用来跟踪没有ack的message信息,当message在timeout时间内没有ack时,unAckTracker会触发消息的重新投递
- 初始化acknowledgmentsGroupingTracker,ack在默认情况下并不是每次都提交到broker,而是通过group的方式提交组合成一个ack请求提交到broker,默认group时间是100ms
grabcnx
grabcnx的过程和producer一致,grabCnx分为两个阶段,首先是lookup topic所在的broker,然后创建consumer到broker的连接。先看findBroker的过程,依旧是通过LookupService来查找:
- 首先根据serviceUrl向一个Broker发送 LOOKUP 请求,broker接收到lookup请求之后,首先根据请求的topic,查找对应的namespacebundle信息,然后查找bundle所在的broker地址,最后将结果返回给consumer
- consumer得到topic所在的broker地址之后,创建到对应broker的连接,连接建立之后,回调ConsumerImpl的connectionOpened方法,执行接下来的初始化工作:
- 清理内部的接收队列,并且为startMessageId赋值,如果incomming queue 有message,那么startMessageId为第一个message的上一个message(如果是batch message,返回batchIndex -1;否则返回 entryId -1);如果incomming queue 中没有message,则startMessageId为lastDequeuedMessageId(如果lastDequeuedMessageId != Position.earliest); 如果没有接收和处理过任务数据,则使用startMessageId
- 清理possibleSendToDeadLetterTopicMessages
- 发送Subscribe请求到Broker,如果是durable的订阅,那么subscribe请求中的startMessageId为空,因为对于durable的订阅,起始位置有broker的cursor决定;另外注意是否允许自动创建Topic, 由subscribe请求中携带的 forCreateTopic 和Broker的 isAllowAutoTopicCreation 共同;是否可以自动创建 Subscription,由 broker 配置 isAllowAutoSubscriptionCreation 和 topic 配置 autoSubscriptionCreationOverride 共同决定
- Broker 接收到SubScribe请求之后,首先获取topic对应的Topic对象,然后调用Topic#Subscribe创建新的subscription,以duarable为例,会调用Topic#getDurableSubscription-> ML#asyncOpenCursor
- 初始化一个 ManagedCursorImpl,主要是individualDeletedMessages,batchDeletedIndexes以及一些状态信息的初始化,cursor状态为uninitialized
- 对cursor进行初始化操作,将curosr的markDeletePosition置为lac,readposition置为lac的下一个位置,cursor状态为No_Ledger
- 为cursor创建一个ledger,根据订阅配置的消费起始位置初始化cursor,重置markDeletePosition和readposition
- 通过ML创建cursor完毕之后,为这个订阅和cursor初始化一个subscription,以PersistentSubscription为例,初始化包括PersistentMessageExpiryMonitor,PersistentMessageExpiryMonitor用来处理消息的过期逻辑
- 实例化一个broker侧的Consumer,consumer会跟踪individual ack的状态信息
- 将consumer添加到subscription中,这个过程会根据订阅类型为subscription初始化dispatcher,
- Exclusive,对应PersistentDispatcherSingleActiveConsumer
- Shared,对应PersistentDispatcherMultipleConsumers
- Failover,对应PersistentDispatcherSingleActiveConsumer
- Key_Shared, 对应PersistentStickyKeyDispatcherMultipleConsumers
- dispatcher初始化之后,将consumer记录在dispatcher中,
- 对于Exclusive,会选择第一个consumer作为active的consumer;
- 对于Failover,会选择第partitionIndex % consumersSize个consumer作为active的consumer,如果是费分区topic,则选择第一个consumer;
- 然后会开始为active consumer读数据,过程如下
- 如果是Exclusive,或者Failover并且activeConsumerFailoverDelayTimeMillis <0,对cursor进行rewind,即将cursor重置到mark delete的位置,将readposition置为markdelete的下一个位置
- 如果是failover的consuemr,通知所有consumer,active consumer的变更
- 然后尝试读消息,不过consumer初始化的permit数为0,所有不会执行读取
- 如果是Shared,会将consumer保存在consumerList中,之后会根据优先级进行轮训发送
- 如果是Key_Shared,则会根据key的hash值选择consumer,这里选择策略有ConsistentHashingStickyKeyConsumerSelector、HashRangeAutoSplitStickyKeyConsumerSelector和HashRangeExclusiveStickyKeyConsumerSelector,这里不展开详细说明。
- consumer添加到subscription之后,进行检查backlogged cursor信息
- 如果consumers不为空并且cursor的entry数量小于backloggedCursorThresholdEntries,则将cursor置为active
- 否则将cursor置为inactive,active可以从cache中读数据,inactive的数据只能充bookkeeper中读取
- 返回SUCCESS响应给Consumer
- Broker 接收到SubScribe请求之后,首先获取topic对应的Topic对象,然后调用Topic#Subscribe创建新的subscription,以duarable为例,会调用Topic#getDurableSubscription-> ML#asyncOpenCursor
- 接收到Broker响应之后,首先将状态设置为Ready,并将permit数量设置为0,如果是非分区的consumer或者是一个重连的分区consumer,将permit数增加为receiveQueue的大小,并且向broker 发送flow请求。
到这里,consumer的初始化工作全部完成。
FLOW
可以看到对于一个非分区的topic,初始化完成之后,就会想broker 发送 FLOW 请求来获取消息,flow请求中会携带permit数量,broker 接收到 FLOW 之后的处理流程如下:
- 使用Broker侧的consumer对象,执行Consumer#flowPermits ,这个过程会增加consumer的permit值,然后 Subscription#consumerFlow->dispatcher#consumerFlow
- 计算可以读取的entry数量
- ManagedCursorImpl#asyncReadEntriesOrWait,如果有数据就读取,如果没有,则等待10ms之后再去读取,
- ManagerLedger#asyncReadEntry -> EntryCacheImpl#asyncReadEntry,首先从cache中读取,否则从bookie读取entry
- 过滤entry,将可以发送给consumer的数据准备好,过滤的原因包括:checksum 或者metadata corrupt,内部标记数据,延迟消息等
- 发送数据给consuemr,发送是每个entry单独发送的,并且增加broker侧Consumer的permit值
- 如果没有pending的读请求,发起一次新的读请求(push过程)
consumer接收到entry之后,如果entry中是一个message,将其加入到incommingqueue中;如果是batch message,则将解析出来的每个message加到incomming queue中;
receive
consumer下一步会调用receive方法处理message,处理过程如下:
- 从incommingqueue中取出一个message
- 增加consumer的permit
- 在 unAckedMessageTracker 中记录messageId, 如果在ack 超时时间内没有ack,就会向broker 发送redeliver请求
ACK
执行完消费逻辑之后,可以进行ack,ack分为两种类型Cumulative和Individual,默认是Individual类型的ack。Individual会ack一条message,Cumulative会ack这个message之前的所有数据,Shared和Key_Shared模式不能使用Cumulative数据。ack默认不是每个请求都会发到broker,而是group ack的方式,聚集一批ack一起发送。
- 如果是Individual,记录指标,重unAckTracker和PossibleSendToDeadLetterTopicMessages中删除messageId,
- 如果acknowledgementGroupTimeMicros==0,说明不用等待直接发送ack到broker
- 否则,将ack请求放在pendingIndividualAcks中,当ack数量大于MAX_ACK_GROUP_SIZE或者超时时,将pendingIndividualAcks的所有ack一起发送给broker
- 如果是Cumulative,
- 如果acknowledgementGroupTimeMicros==0,说明不用等待直接发送ack到broker
- 否则,更新LAST_CUMULATIVE_ACK_UPDATER中的messageId信息,当超时时,发送Cumulative ack 给broker
Broker接收到ack请求之后的处理如下:
- 如果是individual类型的ack,Subscription#acknowledgeMessage -> ManagedCursor#asyncDelete
- 对于每个ack的position,
- 查看individualDeletedMessages是否包含,如果包含,说明position已经全部ack,则从batchDeletedIndexes中删除
- 在individualDeletedMessages中保存position信息,如果是batch消息则在batchDeletedIndexes保存响应的信息,如果对应的ackset是empty说明所有batchMessage中的singleMessage都已经ack,则从batchDeletedIndexes删除,并且individualDeletedMessages保存position的range信息
- 检查individualDeletedMessages第一个range,如果range的下限小于等于markDeletePosition,说明第一个ranger的下限位置对应的已经ack,将markDeletePosition移动至range的上限位置,
- 更新markDeletePosition,并且将individualDeletedMessages中所有在新markDeletePosition之前的信息删除
- 更新readPosition
- 持久化ack信息,包括position、IndividualDeletedMessages、BatchedEntryDeletionIndexInfo信息
- 对于每个ack的position,
- 如果是cumulative类型的ack,只有一个ack的position
- 如果支持batchIndex,从batchIndex中删除position之前的所有内容
- 更新markDeletePosition为ack position的前一个position
- 更新readPosition
- 持久化ack信息,包括position、IndividualDeletedMessages、BatchedEntryDeletionIndexInfo信息
整个消费的流程大致如上文所述,MultiTopicsConsumerImpl以及PatternMultiTopicsConsumerImpl的没有涉及,另外消费过程中的一些细节和特性,比如Batch消息生产、消费,Chunk message的生产、消费,Key_Shared消费的数据分发模式,也没有展开描述,会在后续的文章中逐个讲述。