【Pulsar学习笔记】核心概念

829 阅读13分钟

本篇主要介绍pulsar中一些核心概念及如何使用

pulsar建立在发布-订阅模式(通常缩写为发布-订阅)之上。在这种模式中,producerstopics发布消息,consumers订阅这些主题,处理传入的消息,并在处理完成时向broke发送acknowledgmentsimage.png

创建订阅后,即使客户断开连接,Pulsar也会保留所有消息。只有当consumer确认所有这些消息都已成功处理时,才会丢弃保留的消息。

如果消息的消费失败,并且希望再次消费此消息,则可以启用消息重发机制,请求broker重新发送此消息。

一、消息 message

1. 生产者

1.1 Send modes

模式描述
Sync send生产者在发送每条消息后等待来自broker的确认。如果没有收到确认,则生产者将发送操作视为失败。
Async send生产者将消息放入阻塞队列并立即返回。客户端在后台将消息发送到broker。如果队列已满(您可以配置最大大小),则在调用API时,生产者将被阻塞或立即失败,这取决于传递给生产者的参数。

1.2 Access mode

访问模式描述
Shared多个生产者可以在一个主题上发布。这是默认设置。
Exclusive一个主题只能由一个生产者发表。如果已经有一个生产者连接,其他生产者试图发布这个主题立即得到错误。
WaitForExclusive如果已经连接了一个生产者,那么生产者的创建将挂起(而不是超时),直到生产者获得独占访问权限。

一旦应用程序成功地创建了具有ExclusiveWaitForExclusive访问模式的生产者,该应用程序的实例就保证是该主题的唯一写入者。任何试图生成关于此主题的消息的其他生产者要么立即得到错误,要么必须等到获得独占访问权。

1.3 deliverAfter和deliverAt

在 Pulsar 中使用延迟消息,可以精确指定延迟投递的时间,有 deliverAfterdeliverAt 两种方式。其中 deliverAt 可以指定具体的时间戳;deliverAfter 可以指定在当前多长时间后执行。两种方式的本质是一样的,Client 会计算出时间戳送到 Broker。


//deliverAfter发送

producer.newMessage().deliverAfter(3L, TimeUnit.Minute)

.value("Hello Pulsar!").send();


//deliverAt发送

producer.newMessage().deliverAt(1670574629343L)t

.value("Hello Pulsar!").send();

1.4 消息压缩

消息压缩是优化信息传输的手段之一,我们通常看见一些大型文件都会是以一个压缩包的形式提供下载,在我们消息队列中我们也可以用这种思想,我们将一个batch的消息,比如有1000条可能有1M的传输大小,但是经过压缩之后可能就只会有几十kb,增加了我们和broker的传输效率,但是与之同时我们的cpu也带来了损耗。Pulsar客户端支持多种压缩类型,如 lz4、zlib、zstd、snappy 等。

client.newProducer() 
.topic(“test-topic”) 
.compressionType(CompressionType.LZ4) 
.create();

2. 消费者

3.1 消息确认 Acknowledgement

消息可以通过以下两种方式确认

模式模式
acknowledge individually消息立即确认,消费者消费后每一条消息都会给broker发送一个确认请求
acknowledged cumulatively消息累计确认,消费者仅提交最后一条消息,流中的所有消息直到(并包括)所提供的消息都不会重新投递给该消费者。

// 立即提交api

consumer.acknowledge(msg);


// 累计提交api

consumer.acknowledgeCumulative(msg);

如果是Shared订阅类型,消息确认只能使用acknowledge individually。因为Shared模式所有消费者共享一条数据。

3.2 消息重试

3.2.1 Negative acknowledgement

消息否定机制可以在consumer未成功使用消息时,向broker发送nack,从而将消息重新投递给consumer。

Acknowledgement一样,消息是立即确认还是累计确认,取决于订阅类型。

Consumer<byte[]> consumer = pulsarClient.newConsumer()
                .topic(topic)
                .subscriptionName("sub-negative-ack")
                .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest)
                .negativeAckRedeliveryDelay(2, TimeUnit.SECONDS) // the default value is 1 min
                .subscribe();

Message<byte[]> message = consumer.receive();

// 发送消息否认
consumer.negativeAcknowledge(message);

message = consumer.receive();
// 发送消息确认
consumer.acknowledge(message);

redelivery backoff 可以设置重试的时间间隔策略,指定最大和最小时间,在这段时间内通过计算因子计算每次间隔时间:

Consumer<byte[]> consumer = pulsarClient.newConsumer()
        .topic(topic)
        .subscriptionName("sub-negative-ack")
        .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest)
        .negativeAckRedeliveryBackoff(MultiplierRedeliveryBackoff.builder()
            //最小1s
            .minDelayMs(1000)
            //最大60s
            .maxDelayMs(60 * 1000)
            //计算因子 1s 2s 4s 6s 8s 16s …… 60s
            .multiplier(2)
            .build())
        .subscribe();
Redelivery countRedelivery delay
11 seconds
22 seconds
34 seconds
48 seconds
516 seconds
632 seconds
760 seconds
860 seconds

只有在SharedKey_Shared订阅类型中,消费者可以逐个否定消息, ExclusiveFailover 消费者只确认他们收到的最后一条消息。

请注意,如果是对消息有序性有要求,在否定消息后,顺序无法得到保证

3.2.2 Acknowledgement timeout

如果指定了ack超时时间,且未提交确认消息,客户端会向broker发送重新发送未确认消息的请求,与Negative acknowledgement 相比,不建议使用。因为很难去设置超时时间。

当消息处理时间超出超时时间时,会发起不必要的消息重试。

如果想使用nack,需要保证在acknowledgment timeout前将消息进行提交,不对ack timeout进行设置即可.

Consumer<byte[]> consumer = pulsarClient.newConsumer()
                .topic(topic)
                .ackTimeout(2, TimeUnit.SECONDS) // 默认值为0
                .ackTimeoutTickTime(1, TimeUnit.SECONDS) //每s检查一次
                .subscriptionName("sub")
                .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest)
                .subscribe();

Message<byte[]> message = consumer.receive();

// 等待至少2s
message = consumer.receive();
consumer.acknowledge(message);

3.2.3 Retry letter topic  

img.png  

Retry letter topic允许您存储未能使用的消息,并在以后重试使用它们。使用此方法,您可以自定义重新传递消息的间隔。原始主题上的消费者也会自动订阅重试信件主题。一旦达到最大重试次数,未使用的消息将被移动到死信主题以进行手动处理。

目前在SharedKey_Shared订阅类型下才能使用

使用 Retry letter topicdelayed message delivery 两者都旨在稍后使用消息,区别在于Retry letter topic 用于故障处理,确保关键数据不会丢失,而delayed message delivery 是以指定的延迟时间传递消息。

nack相比,Retry letter topic更适合于需要大量重试且重试间隔可配置的消息。因为Retry letter topic中的消息被持久化到BookKeeper,而由于nack需要重试的消息被缓存到客户端。

缺省情况下,禁用自动重试功能。您可以将enableRetry设置为true,在consumer上启用自动重试。使用以下API来消费来自Retry letter topic的消息。当达到maxRedeliverCount的值时,未使用的消息将被移动到Dead letter topic

Consumer<byte[]> consumer = pulsarClient.newConsumer(Schema.BYTES)
                .topic("my-topic")
                .subscriptionName("my-subscription")
                .subscriptionType(SubscriptionType.Shared)
                //开启重试
                .enableRetry(true)
                .deadLetterPolicy(DeadLetterPolicy.builder()
                //最大重试次数,超过发送到死信队列
                        .maxRedeliverCount(maxRedeliveryCount)
                        .build())
                .subscribe();

retryLetterTopic 用来设置重试队列的topic名称,如果不设置默认为:


<topicname>-<subscriptionname>-RETRY

使用以下API将消息存储在重试队列中。

consumer.reconsumeLater(msg, 3, TimeUnit.SECONDS);

3.3 死信队列 Dead letter topic

Dead letter topic服务于消息的重新发送,它由acknowledgement timeout negative acknowledgementretry letter topic触发,消费失败的消息.

目前在SharedKey_Shared订阅类型下才能使用。如果设置了maxRedeliverCount 会默认开启死信队列的投递。

Consumer<byte[]> consumer = pulsarClient.newConsumer(Schema.BYTES)
                .topic("my-topic")
                .subscriptionName("my-subscription")
                .subscriptionType(SubscriptionType.Shared)
                .deadLetterPolicy(DeadLetterPolicy.builder()
                      .maxRedeliverCount(maxRedeliveryCount)
                      .build())
                .subscribe();

如果不设置名称,系统默认为

<topicname>-<subscriptionname>-DLQ

默认情况下,在DLQ主题创建期间没有订阅。如果不及时订阅DLQ主题,可能会丢失消息。要为DLQ自动创建初始订阅,可以指定initialSubscriptionName参数。如果设置了这个参数,需要保证broker侧的配置allowAutoSubscriptionCreation是开启的,才能有效创建DLQ生成器。

Consumer<byte[]> consumer = pulsarClient.newConsumer(Schema.BYTES)
                .topic("my-topic")
                .subscriptionName("my-subscription")
                .subscriptionType(SubscriptionType.Shared)
                .deadLetterPolicy(DeadLetterPolicy.builder()
                      .maxRedeliverCount(maxRedeliveryCount)
                      .deadLetterTopic("my-dead-letter-topic-name")
                      .initialSubscriptionName("init-sub")
                      .build())
                .subscribe();

4. 消息延迟投递

延迟消息传递使您可以稍后使用消息。在这种机制中,消息存储在BookKeeper中。在消息发布到broker之后,DelayedDeliveryTracker在内存中维护时间索引(time -> messageId)。一旦指定的延迟结束,该消息将被传递给使用者。

延迟消息传递仅适用于Shared类型。在ExclusiveFailover类型中,延迟消息将立即分派。  

    image.png

broker保存消息而不进行任何检查。当consumer消费时,如果消息被设置为延迟,则该消息将被添加到DelayedDeliveryTracker。subscription从DelayedDeliveryTracker检查并获取超时的消息。

5 事务消息

Pulsar 事务消息的适用场景为一次事务中需要发送多个消息的情况,保证多个消息之间的事务约束,即多条消息要么都发送成功,要么都发送失败,而不是保证本地事务的执行和发送消息的事务约束。

这里以一个资金流转场景举例,分账系统收到订单系统发送的一条信息A,然后进行自己的逻辑处理后,需要往余额系统投递两条信息B、C

这里的ack A、send B、send C,三个动作即构成一个事务

//在初始化pulsarClient的时候需要开启enableTransaction
PulsarClient pulsarClient = PulsarClient.builder()
        .serviceUrl("pulsar://localhost:6650")
        //开启事务支持
        .enableTransaction(true)
        .build();

//收到消息A
message = consumer.receive(10, TimeUnit.SECONDS);
 
//自己的各种业务逻辑.....
 
//开启一个事务
Transaction transaction = pulsarClient.newTransaction()
                    .withTransactionTimeout(5, TimeUnit.MINUTES)
                    .build().get();
//发送消息B
producer.newMessage(transaction).value("B".getBytes()).sendAsync();
 
//发送消息C
producer.newMessage(transaction).value("C".getBytes()).sendAsync();
 
//ack消息A
consumer.acknowledgeAsync(message.getMessageId(), transaction);
 
//提交事务
transaction.commit().get();
//或者回滚事务
transaction.abort().get();

二、 主题 topic

2.1 命名规则

与其他pub-sub系统一样,pulsar中的主题被命名为通道,用于将消息从生产者传输到消费者。topic的定义应遵循以下结构:

{persistent|non-persistent}://tenant/namespace/topic

Topic name componentDescription
persistent / non-persistentPulsar支持两种类型的的topicpersistent and non-persistent.默认是持久化的,表示所有消息都持久化在磁盘上,而未非持久化的主题不会存储在磁盘上
tenant实例内的主题租户。租户对于Pulsar中的多租户至关重要,并且分布在集群中。
namespace管理主题的单元,类似于分组机制,大多数主题都配置在 namespace 上. 每一个租户可以有多个 namespaces.
topic名字组成的最后的一部分,它在pulsar中没有特殊的含义
PulsarClient client = PulsarClient.builder()
        .serviceUrl("pulsar://localhost:6650")
        .build();
 //指定非持久化topic
String npTopic = "non-persistent://public/default/my-topic";
String subscriptionName = "my-subscription-name";

Consumer<byte[]> consumer = client.newConsumer()
        .topic(npTopic)
        .subscriptionName(subscriptionName)
        .subscribe();

2.2 Multi-topic

当消费者订阅一个pulsar主题时,默认情况下它会订阅一个特定的主题,例如persistent://public/default/my-topic。然而,从1.23.0版本开始,pulsar可以同时订阅多个主题。可以用两种方式定义主题列表:

  • 基于正则表达式(regex),例如persistent://public/default/finance-.*
  • 通过显式定义主题列表

当通过regex订阅多个主题时,所有主题必须在相同的名称空间中。

当生产者向单个主题发送消息时,所有消息都保证以相同的顺序从该主题读取。然而,这些保证并不适用于Multi-topic。因此,当生产者向多个主题发送消息时,从这些主题读取消息的顺序不能保证相同。

   
// 订阅所有 namespace 下所有topic
Pattern allTopicsInNamespace = Pattern.compile("persistent://public/default/.*");  
Consumer<byte[]> allTopicsConsumer = pulsarClient.newConsumer()  
.topicsPattern(allTopicsInNamespace)  
.subscriptionName("subscription-1")  
.subscribe();  
  
// 订阅某个 namespace 下所有topic
Pattern someTopicsInNamespace = Pattern.compile("persistent://public/default/foo.*");  
Consumer<byte[]> someTopicsConsumer = pulsarClient.newConsumer()  
.topicsPattern(someTopicsInNamespace)  
.subscriptionName("subscription-1")  
.subscribe();

2.3 Partitioned topics

image.png

如图所示,topic1中有5个分区(p0-p4),他们分布在3个broker上,因为partition比broker多,所以两个broker一次处理2个partition,另一个代理只处理一个partition。

这个topic的消息将广播给2个消费者,路由模式决定每条消息应该发布到哪个分区,而订阅类型决定哪些消息发送给哪个消费者,在大多数情况下,可以分别决定路由和订阅模式。通常,吞吐量问题应该使用分区/路由决策,而订阅类型的决策取决于应用程序具体的使用场景,这个我们后面会详细介绍。

2.3.1 Routing modes 路由模式

ModeDescription
RoundRobinPartition如果没有提供key,生产者将以轮询方式跨所有分区发布消息,以实现最大吞吐量。请注意,轮循不是针对单个消息进行的,而是将其设置为相同的批处理延迟边界,以确保批处理有效。如果在消息上指定了一个键,则分区生产者将对该键进行散列,并将消息分配给特定的分区。这是默认模式。
SinglePartition如果没有提供key,生产者将随机选择一个分区,并将所有消息发布到该分区中。如果在消息上指定了一个键,则分区生产者将对该键进行散列,并将消息分配给特定的分区。
CustomPartition使用将被调用的自定义消息路由器实现来确定特定消息的分区。用户可以使用实例创建自定义路由模式使用 Java client 并实现 MessageRouter 接口。

2.3.2 Ordering guarantee 顺序保证

消息的排序与MessageRoutingModeMessage Key相关。通常,用户会希望每个键分区的排序保证。

如果消息附加了一个键,当使用SinglePartition或RoundRobinPartition模式时,消息将根据ProducerBuilder中HashingScheme指定的散列方案路由到相应的分区。

Ordering guaranteeDescriptionRouting Mode and Key
Per-key-partition具有相同键的所有消息将按顺序排列并放置在同一分区中.使用SinglePartitionRoundRobinPartition模式,Key由每条消息提供。
Per-producer来自同一生产者的所有消息都是有序的。使用SinglePartition模式,不为每条消息提供Key。

HashingScheme是一个枚举,它表示在为特定消息选择分区时可用的标准散列函数集。 有两种类型的标准哈希函数可用:JavaStringHash和Murmur3_32Hash。生产者的默认哈希函数是JavaStringHash。请注意,当生产者可以来自不同的多语言客户端时,JavaStringHash是没有用的,在这种情况下,建议使用Murmur3_32Hash。

三、 订阅者 Subscriptions

3.1 订阅类型

pulsar中有四种订阅类型:exclusivesharedfailoverkey_shared。这些类型如下图所示。

image.png

根据消息传递的顺序性和实时性可以将消息模型分为两类:

  • queuing 模型主要是采用无序或者共享的方式来消费消息。
  • streaming 模型要求消息的消费严格排序或独占消息消费

这四种订阅类型加上pulsar的API,我们可以根据实际场景,灵活的选择我们的消息模型。这部分内容会在功能特性章节详细介绍。

3.2 订阅模式

Subscription modeDescriptionNote
Durablecursor是持久的,它保留消息并持久保存当前位置。如果代理从故障中重新启动,它可以从持久存储(BookKeeper)中恢复cursor,这样就可以从上次消费的位置继续消费消息。Durable 是默认的订阅模式。
NonDurablecursor是非持久的。一旦代理停止,cursor将丢失,并且永远无法恢复,因此消息不能从最后消费的位置继续消费。Reader的订阅模式本质上是NonDurable的,它不阻止主题中的数据被删除。Reader的订阅模式无法更改

你可以使用java client api 进行设置


Consumer<byte[]> consumer = pulsarClient.newConsumer()  
.topic("my-topic")  
.subscriptionName("my-sub")  
.subscriptionMode(SubscriptionMode.Durable)  
.subscribe();

Consumer<byte[]> consumer = pulsarClient.newConsumer()  
.topic("my-topic")  
.subscriptionName("my-sub")  
.subscriptionMode(SubscriptionMode.NonDurable)  
.subscribe();