本文描述了一些 Pulsar 客戶端編碼相關的最佳實踐,並提供了可商用的樣例程式碼,供大家研發的時候參考,提升大家接入 Pulsar 的效率。在生產環境上,Pulsar 的地址資訊往往都通過配置中心或者是 K8s 域名發現的方式獲得,這塊不是這篇文章描述的重點,以 PulsarConstant.SERVICE_HTTP_URL
代替。本文中的例子均已上傳到 Github 。
前期 Client 初始化和配置
初始化 Client--demo 級別
import lombok.extern.slf4j.Slf4j;
import org.apache.pulsar.client.api.PulsarClient;
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarClientInit {
private static final DemoPulsarClientInit INSTANCE = new DemoPulsarClientInit();
private PulsarClient pulsarClient;
public static DemoPulsarClientInit getInstance() {
return INSTANCE;
}
public void init() throws Exception {
pulsarClient = PulsarClient.builder()
.serviceUrl(PulsarConstant.SERVICE_HTTP_URL)
.build();
}
public PulsarClient getPulsarClient() {
return pulsarClient;
}
}
Demo 級別的 Pulsar client 初始化的時候沒有配置任何自定義引數,並且初始化的時候沒有考慮異常,init
的時候會直接丟擲異常。
初始化 Client--可上線級別
import io.netty.util.concurrent.DefaultThreadFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.pulsar.client.api.PulsarClient;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarClientInitRetry {
private static final DemoPulsarClientInitRetry INSTANCE = new DemoPulsarClientInitRetry();
private volatile PulsarClient pulsarClient;
private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1, new DefaultThreadFactory("pulsar-cli-init"));
public static DemoPulsarClientInitRetry getInstance() {
return INSTANCE;
}
public void init() {
executorService.scheduleWithFixedDelay(this::initWithRetry, 0, 10, TimeUnit.SECONDS);
}
private void initWithRetry() {
try {
pulsarClient = PulsarClient.builder()
.serviceUrl(PulsarConstant.SERVICE_HTTP_URL)
.build();
log.info("pulsar client init success");
this.executorService.shutdown();
} catch (Exception e) {
log.error("init pulsar error, exception is ", e);
}
}
public PulsarClient getPulsarClient() {
return pulsarClient;
}
}
在實際的環境中,我們往往要做到 pulsar client
初始化失敗後不影響微服務的啟動,即待微服務啟動後,再一直重試建立 pulsar client
。
上面的程式碼示例通過 volatile
加不斷迴圈重建實現了這一目標,並且在客戶端成功建立後,銷燬了定時器執行緒。
初始化 Client--商用級別
import io.netty.util.concurrent.DefaultThreadFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.pulsar.client.api.PulsarClient;
import org.apache.pulsar.client.api.SizeUnit;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarClientInitUltimate {
private static final DemoPulsarClientInitUltimate INSTANCE = new DemoPulsarClientInitUltimate();
private volatile PulsarClient pulsarClient;
private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1, new DefaultThreadFactory("pulsar-cli-init"));
public static DemoPulsarClientInitUltimate getInstance() {
return INSTANCE;
}
public void init() {
executorService.scheduleWithFixedDelay(this::initWithRetry, 0, 10, TimeUnit.SECONDS);
}
private void initWithRetry() {
try {
pulsarClient = PulsarClient.builder()
.serviceUrl(PulsarConstant.SERVICE_HTTP_URL)
.ioThreads(4)
.listenerThreads(10)
.memoryLimit(64, SizeUnit.MEGA_BYTES)
.operationTimeout(5, TimeUnit.SECONDS)
.connectionTimeout(15, TimeUnit.SECONDS)
.build();
log.info("pulsar client init success");
this.executorService.shutdown();
} catch (Exception e) {
log.error("init pulsar error, exception is ", e);
}
}
public PulsarClient getPulsarClient() {
return pulsarClient;
}
}
商用級別的 Pulsar Client
新增了 5 個配置引數:
- ioThreads netty 的 ioThreads 負責網路 IO 操作,如果業務流量較大,可以調高
ioThreads
個數; - listenersThreads 負責呼叫以
listener
模式啟動的消費者的回撥函式,建議配置大於該 client 負責的partition
數目; - memoryLimit 當前用於限制
pulsar
生產者可用的最大記憶體,可以很好地防止網路中斷、Pulsar 故障等場景下,訊息積壓在producer
側,導致 Java 程式 OOM; - operationTimeout 一些後設資料操作的超時時間,Pulsar 預設為 30s,有些保守,可以根據自己的網路情況、處理效能來適當調低;
- connectionTimeout 連線 Pulsar 的超時時間,配置原則同上。
客戶端進階引數(記憶體分配相關)
我們還可以通過傳遞 Java 的 property 來控制 Pulsar 客戶端記憶體分配的引數,這裡列舉幾個重要引數:
- pulsar.allocator.pooled 為 true 則使用堆外記憶體池,false 則使用堆記憶體分配,不走記憶體池。預設使用高效的堆外記憶體池;
- pulsar.allocator.exit_on_oom 如果記憶體溢位,是否關閉 jvm,預設為 false;
- pulsar.allocator.out_of_memory_policy 在 https://github.com/apache/pul... 引入,目前還沒有正式 release 版本,用於配置當堆外記憶體不夠使用時的行為,可選項為
FallbackToHeap
和ThrowException
,預設為FallbackToHeap
,如果你不希望訊息序列化的記憶體影響到堆記憶體分配,則可以配置成ThrowException
。
生產者
初始化 producer 重要引數
maxPendingMessages
生產者訊息傳送佇列,根據實際 topic 的量級合理配置,避免在網路中斷、Pulsar 故障場景下的 OOM。建議和 client 側的配置 memoryLimit
之間挑一個進行配置。
messageRoutingMode
訊息路由模式。預設為 RoundRobinPartition
。根據業務需求選擇,如果需要保序,一般選擇 SinglePartition
,把相同 key 的訊息發到同一個 partition
。
autoUpdatePartition
自動更新 partition 資訊。如 topic
中 partition
資訊不變則不需要配置,降低叢集的消耗。
batch 相關引數
因為批量傳送模式底層由定時任務實現,如果該 topic 上訊息數較小,則不建議開啟 batch
。尤其是大量的低時間間隔的定時任務會導致 netty 執行緒 CPU 飆高。
- enableBatching 是否啟用批量傳送;
- batchingMaxMessages 批量傳送最大訊息條數
- batchingMaxPublishDelay 批量傳送定時任務間隔。
靜態 producer 初始化
靜態 producer,指不會隨著業務的變化進行 producer 的啟動或關閉。那麼就在微服務啟動完成、client 初始化完成之後,初始化 producer,樣例如下:
一個生產者一個執行緒,適用於生產者數目較少的場景
import io.netty.util.concurrent.DefaultThreadFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.pulsar.client.api.Producer;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarStaticProducerInit {
private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1, new DefaultThreadFactory("pulsar-producer-init"));
private final String topic;
private volatile Producer<byte[]> producer;
public DemoPulsarStaticProducerInit(String topic) {
this.topic = topic;
}
public void init() {
executorService.scheduleWithFixedDelay(this::initWithRetry, 0, 10, TimeUnit.SECONDS);
}
private void initWithRetry() {
try {
final DemoPulsarClientInit instance = DemoPulsarClientInit.getInstance();
producer = instance.getPulsarClient().newProducer().topic(topic).create();
} catch (Exception e) {
log.error("init pulsar producer error, exception is ", e);
}
}
public Producer<byte[]> getProducer() {
return producer;
}
}
多個生產者一個執行緒,適用於生產者數目較多的場景
import io.netty.util.concurrent.DefaultThreadFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.pulsar.client.api.Producer;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarStaticProducersInit {
private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1, new DefaultThreadFactory("pulsar-consumer-init"));
private CopyOnWriteArrayList<Producer<byte[]>> producers;
private int initIndex;
private List<String> topics;
public DemoPulsarStaticProducersInit(List<String> topics) {
this.topics = topics;
}
public void init() {
executorService.scheduleWithFixedDelay(this::initWithRetry, 0, 10, TimeUnit.SECONDS);
}
private void initWithRetry() {
if (initIndex == topics.size()) {
return;
}
for (; initIndex < topics.size(); initIndex++) {
try {
final DemoPulsarClientInit instance = DemoPulsarClientInit.getInstance();
final Producer<byte[]> producer = instance.getPulsarClient().newProducer().topic(topics.get(initIndex)).create();;
producers.add(producer);
} catch (Exception e) {
log.error("init pulsar producer error, exception is ", e);
break;
}
}
}
public CopyOnWriteArrayList<Producer<byte[]>> getProducers() {
return producers;
}
}
動態生成銷燬的 producer 示例
還有一些業務,我們的 producer 可能會根據業務來進行動態的啟動或銷燬,如接收道路上車輛的資料併傳送給指定的 topic。我們不會讓記憶體裡面駐留所有的 producer,這會導致佔用大量的記憶體,我們可以採用類似於 LRU Cache 的方式來管理 producer 的生命週期。
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarDynamicProducerInit {
/**
* topic -- producer
*/
private AsyncLoadingCache<String, Producer<byte[]>> producerCache;
public DemoPulsarDynamicProducerInit() {
this.producerCache = Caffeine.newBuilder()
.expireAfterAccess(600, TimeUnit.SECONDS)
.maximumSize(3000)
.removalListener((RemovalListener<String, Producer<byte[]>>) (topic, value, cause) -> {
log.info("topic {} cache removed, because of {}", topic, cause);
try {
value.close();
} catch (Exception e) {
log.error("close failed, ", e);
}
})
.buildAsync(new AsyncCacheLoader<>() {
@Override
public CompletableFuture<Producer<byte[]>> asyncLoad(String topic, Executor executor) {
return acquireFuture(topic);
}
@Override
public CompletableFuture<Producer<byte[]>> asyncReload(String topic, Producer<byte[]> oldValue,
Executor executor) {
return acquireFuture(topic);
}
});
}
private CompletableFuture<Producer<byte[]>> acquireFuture(String topic) {
CompletableFuture<Producer<byte[]>> future = new CompletableFuture<>();
try {
ProducerBuilder<byte[]> builder = DemoPulsarClientInit.getInstance().getPulsarClient().newProducer().enableBatching(true);
final Producer<byte[]> producer = builder.topic(topic).create();
future.complete(producer);
} catch (Exception e) {
log.error("create producer exception ", e);
future.completeExceptionally(e);
}
return future;
}
}
這個模式下,可以根據返回的 CompletableFuture<Producer<byte[]>>
來優雅地進行流式處理。
可以接受訊息丟失的傳送
final CompletableFuture<Producer<byte[]>> cacheFuture = producerCache.get(topic);
cacheFuture.whenComplete((producer, e) -> {
if (e != null) {
log.error("create pulsar client exception ", e);
return;
}
try {
producer.sendAsync(msg).whenComplete(((messageId, throwable) -> {
if (throwable != null) {
log.error("send producer msg error ", throwable);
return;
}
log.info("topic {} send success, msg id is {}", topic, messageId);
}));
} catch (Exception ex) {
log.error("send async failed ", ex);
}
});
以上為正確處理 Client
建立失敗和傳送失敗的回撥函式。但是由於在生產環境下,Pulsar 並不是一直保持可用的,會因為虛擬機器故障、Pulsar 服務升級等導致傳送失敗。這個時候如果要保證訊息傳送成功,就需要對訊息傳送進行重試。
可以容忍極端場景下的傳送丟失
final Timer timer = new HashedWheelTimer();
private void sendMsgWithRetry(String topic, byte[] msg, int retryTimes) {
final CompletableFuture<Producer<byte[]>> cacheFuture = producerCache.get(topic);
cacheFuture.whenComplete((producer, e) -> {
if (e != null) {
log.error("create pulsar client exception ", e);
return;
}
try {
producer.sendAsync(msg).whenComplete(((messageId, throwable) -> {
if (throwable == null) {
log.info("topic {} send success, msg id is {}", topic, messageId);
return;
}
if (retryTimes == 0) {
timer.newTimeout(timeout -> DemoPulsarDynamicProducerInit.this.sendMsgWithRetry(topic, msg, retryTimes - 1), 1 << retryTimes, TimeUnit.SECONDS);
}
log.error("send producer msg error ", throwable);
}));
} catch (Exception ex) {
log.error("send async failed ", ex);
}
});
}
這裡在傳送失敗後,做了退避重試,可以容忍 pulsar
服務端故障一段時間。比如退避 7 次、初次間隔為 1s,那麼就可以容忍 1+2+4+8+16+32+64=127s
的故障。這已經足夠滿足大部分生產環境的要求了。
因為理論上存在超過 127s 的故障,所以還是要在極端場景下,向上遊返回失敗。
生產者 Partition 級別嚴格保序
生產者嚴格保序的要點:一次只傳送一條訊息,確認傳送成功後再傳送下一條訊息。實現上可以使用同步非同步兩種模式:
- 同步模式的要點就是迴圈傳送,直到上一條訊息傳送成功後,再啟動下一條訊息傳送;
- 非同步模式的要點是觀測上一條訊息傳送的 future,如果失敗也一直重試,成功則啟動下一條訊息傳送。
值得一提的是,這個模式下,partition 間是可以並行的,可以使用 OrderedExecutor
或 per partition per thread
。
同步模式舉例:
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarProducerSyncStrictlyOrdered {
Producer<byte[]> producer;
public void sendMsg(byte[] msg) {
while (true) {
try {
final MessageId messageId = producer.send(msg);
log.info("topic {} send success, msg id is {}", producer.getTopic(), messageId);
break;
} catch (Exception e) {
log.error("exception is ", e);
}
}
}
}
消費者
初始化消費者重要引數
receiverQueueSize
注意: 處理不過來時,消費緩衝佇列會積壓在記憶體中,合理配置防止 OOM。
autoUpdatePartition
自動更新 partition 資訊。如 topic
中 partition
資訊不變則不需要配置,降低叢集的消耗。
subscribeType
訂閱型別,根據業務需求決定。
subscriptionInitialPosition
訂閱開始的位置,根據業務需求決定最前或者最後。
messageListener
使用 listener 模式消費,只需要提供回撥函式,不需要主動執行 receive()
拉取。一般沒有特殊訴求,建議採用 listener 模式。
ackTimeout
當服務端推送訊息,但消費者未及時回覆 ack 時,經過 ackTimeout 後,會重新推送給消費者處理,即 redeliver
機制。
注意在利用 redeliver
機制的時候,一定要注意僅僅使用重試機制來重試可恢復的錯誤。舉個例子,如果程式碼裡面對訊息進行解碼,解碼失敗就不適合利用 redeliver
機制。這會導致客戶端一直處於重試之中。
如果拿捏不準,還可以通過下面的 deadLetterPolicy
配置死信佇列,防止訊息一直重試。
negativeAckRedeliveryDelay
當客戶端呼叫 negativeAcknowledge
時,觸發 redeliver
機制的時間。redeliver
機制的注意點同 ackTimeout
。
需要注意的是, ackTimeout
和 negativeAckRedeliveryDelay
建議不要同時使用,一般建議使用 negativeAck
,使用者可以有更靈活的控制權。一旦 ackTimeout
配置的不合理,在消費時間不確定的情況下可能會導致訊息不必要的重試。
deadLetterPolicy
配置 redeliver
的最大次數和死信 topic。
初始化消費者原則
消費者只有建立成功才能工作,不像生產者可以向上遊返回失敗,所以消費者要一直重試建立。示例程式碼如下:注意:消費者和 topic 可以是一對多的關係,消費者可以訂閱多個 topic。
一個消費者一個執行緒,適用於消費者數目較少的場景
import io.netty.util.concurrent.DefaultThreadFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.pulsar.client.api.Consumer;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarConsumerInit {
private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1, new DefaultThreadFactory("pulsar-consumer-init"));
private final String topic;
private volatile Consumer<byte[]> consumer;
public DemoPulsarConsumerInit(String topic) {
this.topic = topic;
}
public void init() {
executorService.scheduleWithFixedDelay(this::initWithRetry, 0, 10, TimeUnit.SECONDS);
}
private void initWithRetry() {
try {
final DemoPulsarClientInit instance = DemoPulsarClientInit.getInstance();
consumer = instance.getPulsarClient().newConsumer().topic(topic).messageListener(new DemoMessageListener<>()).subscribe();
} catch (Exception e) {
log.error("init pulsar producer error, exception is ", e);
}
}
public Consumer<byte[]> getConsumer() {
return consumer;
}
}
多個消費者一個執行緒,適用於消費者數目較多的場景
import io.netty.util.concurrent.DefaultThreadFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.pulsar.client.api.Consumer;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* @author hezhangjian
*/
@Slf4j
public class DemoPulsarConsumersInit {
private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1, new DefaultThreadFactory("pulsar-consumer-init"));
private CopyOnWriteArrayList<Consumer<byte[]>> consumers;
private int initIndex;
private List<String> topics;
public DemoPulsarConsumersInit(List<String> topics) {
this.topics = topics;
}
public void init() {
executorService.scheduleWithFixedDelay(this::initWithRetry, 0, 10, TimeUnit.SECONDS);
}
private void initWithRetry() {
if (initIndex == topics.size()) {
return;
}
for (; initIndex < topics.size(); initIndex++) {
try {
final DemoPulsarClientInit instance = DemoPulsarClientInit.getInstance();
final Consumer<byte[]> consumer = instance.getPulsarClient().newConsumer().topic(topics.get(initIndex)).messageListener(new DemoMessageListener<>()).subscribe();
consumers.add(consumer);
} catch (Exception e) {
log.error("init pulsar producer error, exception is ", e);
break;
}
}
}
public CopyOnWriteArrayList<Consumer<byte[]>> getConsumers() {
return consumers;
}
}
消費者達到至少一次語義
使用手動回覆 ack 模式,確保處理成功後再 ack。如果處理失敗可以自己重試或通過 negativeAck
機制進行重試
同步模式舉例
這裡需要注意,如果處理訊息時長差距比較大,同步處理的方式可能會讓本來可以很快處理的訊息得不到處理的機會。
/**
* @author hezhangjian
*/
@Slf4j
public class DemoMessageListenerSyncAtLeastOnce<T> implements MessageListener<T> {
@Override
public void received(Consumer<T> consumer, Message<T> msg) {
try {
final boolean result = syncPayload(msg.getData());
if (result) {
consumer.acknowledgeAsync(msg);
} else {
consumer.negativeAcknowledge(msg);
}
} catch (Exception e) {
// 業務方法可能會丟擲異常
log.error("exception is ", e);
consumer.negativeAcknowledge(msg);
}
}
/**
* 模擬同步執行的業務方法
* @param msg 訊息體內容
* @return
*/
private boolean syncPayload(byte[] msg) {
return System.currentTimeMillis() % 2 == 0;
}
}
非同步模式舉例
非同步的話需要考慮記憶體的限制,因為非同步的方式可以很快地從 broker
消費,不會被業務操作阻塞,這樣 inflight 的訊息可能會非常多。如果是 Shared
或 KeyShared
模式,可以通過 maxUnAckedMessage
進行限制。如果是 Failover
模式,可以通過下面的 消費者繁忙時阻塞拉取訊息,不再進行業務處理
通過判斷 inflight 訊息數來阻塞處理。
/**
* @author hezhangjian
*/
@Slf4j
public class DemoMessageListenerAsyncAtLeastOnce<T> implements MessageListener<T> {
@Override
public void received(Consumer<T> consumer, Message<T> msg) {
try {
asyncPayload(msg.getData(), new DemoSendCallback() {
@Override
public void callback(Exception e) {
if (e == null) {
consumer.acknowledgeAsync(msg);
} else {
log.error("exception is ", e);
consumer.negativeAcknowledge(msg);
}
}
});
} catch (Exception e) {
// 業務方法可能會丟擲異常
consumer.negativeAcknowledge(msg);
}
}
/**
* 模擬非同步執行的業務方法
* @param msg 訊息體
* @param demoSendCallback 非同步函式的callback
*/
private void asyncPayload(byte[] msg, DemoSendCallback demoSendCallback) {
if (System.currentTimeMillis() % 2 == 0) {
demoSendCallback.callback(null);
} else {
demoSendCallback.callback(new Exception("exception"));
}
}
}
消費者繁忙時阻塞拉取訊息,不再進行業務處理
當消費者處理不過來時,通過阻塞 listener
方法,不再進行業務處理。避免在微服務積累太多訊息導致 OOM,可以通過 RateLimiter 或者 Semaphore 控制處理。
/**
* @author hezhangjian
*/
@Slf4j
public class DemoMessageListenerAsyncAtLeastOnce<T> implements MessageListener<T> {
@Override
public void received(Consumer<T> consumer, Message<T> msg) {
try {
asyncPayload(msg.getData(), new DemoSendCallback() {
@Override
public void callback(Exception e) {
if (e == null) {
consumer.acknowledgeAsync(msg);
} else {
log.error("exception is ", e);
consumer.negativeAcknowledge(msg);
}
}
});
} catch (Exception e) {
// 業務方法可能會丟擲異常
consumer.negativeAcknowledge(msg);
}
}
/**
* 模擬非同步執行的業務方法
* @param msg 訊息體
* @param demoSendCallback 非同步函式的callback
*/
private void asyncPayload(byte[] msg, DemoSendCallback demoSendCallback) {
if (System.currentTimeMillis() % 2 == 0) {
demoSendCallback.callback(null);
} else {
demoSendCallback.callback(new Exception("exception"));
}
}
}
消費者嚴格按 partition 保序
為了實現 partition
級別消費者的嚴格保序,需要對單 partition
的訊息,一旦處理失敗,在這條訊息重試成功之前不能處理該 partition
的其他訊息。示例如下:
/**
* @author hezhangjian
*/
@Slf4j
public class DemoMessageListenerSyncAtLeastOnceStrictlyOrdered<T> implements MessageListener<T> {
@Override
public void received(Consumer<T> consumer, Message<T> msg) {
retryUntilSuccess(msg.getData());
consumer.acknowledgeAsync(msg);
}
private void retryUntilSuccess(byte[] msg) {
while (true) {
try {
final boolean result = syncPayload(msg);
if (result) {
break;
}
} catch (Exception e) {
log.error("exception is ", e);
}
}
}
/**
* 模擬同步執行的業務方法
*
* @param msg 訊息體內容
* @return
*/
private boolean syncPayload(byte[] msg) {
return System.currentTimeMillis() % 2 == 0;
}
}
致謝
作者簡介
賀張儉,Apache Pulsar Contributor,西安電子科技大學畢業,華為雲物聯網高階工程師,目前 Pulsar 已經在華為雲物聯網大規模商用,瞭解更多內容可以訪問他的簡書部落格地址。
相關連結
加入 Apache Pulsar 中文交流群 ??
點選 連結,檢視 Apache Pulsar 乾貨集錦