摘要

本文围绕一个常见的使用场景深入分析在高吞吐场景下,使用Pulsar客户端收发消息可能会遇到的若干问题。并以此为切入点,梳理一下Pulsar客户端在内存控制上所做的优化改进。

使用场景

假设这样一个常见的场景,一个搜索类业务需要记录用户搜索请求,以便后续分析搜索热点,以及有针对性的优化搜索效果等。于是,我们有下面这段逻辑,简化后如下:

PulsarClient pulsarClient = PulsarClient.builder()
.serviceUrl("pulsar://localhost:6650")
.build();
Producer<byte[]> producer = pulsarClient.newProducer()
.topic("search-activities")
.create();
try {
MessageId messageId = producer.send(/* message payload here */);
log.debug("Search activity messageId={}", messageId);
} catch (Exception e) {
log.error("Failed to record search activity", e);
}

注意pulsarClientproducer均支持复用,并且推荐这么做,这里只是为了演示写到了一起。

producer.send是阻塞方式发送消息,换句话说就是线程会卡在这里等待发送结果返回。在现实中可以根据消息在实际业务中的需要选择阻塞非阻塞两种方式,例如这里我们的业务是在用户发起一次搜索请求时记录搜索请求和上下文信息,业务上对搜索请求事件并无强依赖,因此这里使用阻塞方式发消息就不太适合了,从性能上考虑会加长整体的搜索延迟,从稳定性上考虑会增加搜索执行过程中的不确定性,总的来说,要区分支线流程和主线流程,不应该把支线流程全部嵌套在主线流程中。

于是,我们可以优化一下,调整为非阻塞方式,将记录搜索事件放到其它线程中完成:

producer.sendAsync(/* message payload here */).whenComplete((msgId, ex) -> {
if (ex != null) {
log.error("Failed to record search activity", ex);
} else {
log.debug("Search activity messageId={}", msgId);
}
});

在现实中,若用户搜索的TPS较高,例如在单实例上可以超过1000QPS(高和低都是相对而言的,这里只是举个例子)。若恰好记录的搜索事件内容较多(例如包含了搜索请求的完整上下文和搜索结果等),序列化之后大小能达到100KB甚至1MB,那么上面代码在运行时你可以会遇到MemoryBufferIsFullError异常:

org.apache.pulsar.client.api.PulsarClientException$MemoryBufferIsFullError: Client memory buffer is full
at org.apache.pulsar.client.impl.ProducerImpl.canEnqueueRequest(ProducerImpl.java:972)
at org.apache.pulsar.client.impl.ProducerImpl.sendAsync(ProducerImpl.java:452)
at org.apache.pulsar.client.impl.ProducerImpl.internalSendAsync(ProducerImpl.java:343)
at org.apache.pulsar.client.impl.TypedMessageBuilderImpl.sendAsync(TypedMessageBuilderImpl.java:102)
at org.apache.pulsar.client.impl.ProducerBase.sendAsync(ProducerBase.java:68)

另外若服务本身与Pulsar的broker之间出现了网络波动,或者Pulsar服务内部组件之间出现网络波动,导致整体producer写入延迟升高,亦或是短时间出现大量写入,你还可能会遇到ProducerQueueIsFullError异常:

org.apache.pulsar.client.api.PulsarClientException$ProducerQueueIsFullError: Producer send queue is full
at org.apache.pulsar.client.impl.ProducerImpl.canEnqueueRequest(ProducerImpl.java:965)
at org.apache.pulsar.client.impl.ProducerImpl.sendAsync(ProducerImpl.java:452)
at org.apache.pulsar.client.impl.ProducerImpl.internalSendAsync(ProducerImpl.java:343)
at org.apache.pulsar.client.impl.TypedMessageBuilderImpl.sendAsync(TypedMessageBuilderImpl.java:102)
at org.apache.pulsar.client.impl.ProducerBase.sendAsync(ProducerBase.java:68)

Producer的内存控制

下面我们对上面两种异常产生的原因作一下分析,我们先来看一下构建Producer时,ProducerBuilder中与内存使用有关的配置项:

/*
* Set the max size of the queue holding the messages pending to receive an acknowledgment from the broker.
*/
ProducerBuilder<T> maxPendingMessages(int maxPendingMessages); /*
* Set the number of max pending messages across all partitions.
*/
ProducerBuilder<T> maxPendingMessagesAcrossPartitions(int maxPendingMessagesAcrossPartitions);

maxPendingMessages用来控制producer内部队列中正在发送还没有接收到broker确认的消息数量,若队列大小超出了这个限制,默认的行为就是抛出ProducerQueueIsFullError异常,你可以通过修改另外一个配置blockIfQueueFull=true调整为阻塞等待队列中空出新的空间,这里还有另外需要注意的地方在下面会细说。

maxPendingMessages这个配置实际上是直接传递给底层各个分区的内部producer的,对于多分区的topic,实际处于pending状态的最大消息数量是maxPendingMessages乘以topic分区数量。由于maxPendingMessages结合可变的topic分区数量使得最终的pending消息数量变得不可控,因此还有另外一个优先级更高的配置maxPendingMessagesAcrossPartitions用来控制整个topic所有分区的总的一个pending消息数量,最终到各个分区内部producer取maxPendingMessagesmaxPendingMessagesAcrossPartitions / partitions的较小值。

然而,在现实应用场景中,不同业务的消息大小差异很大,单纯基于消息数量控制内存使用,开发者需要预估平均消息大小,这几乎不可能做到,因为消息的实际大小很可能会随着业务的变化而发生变化,因此在PIP-74中,在构建PulsarClient时,ClientBuilder提供了一个面向整个client实例统一的内存限制配置:

/*
* Configure a limit on the amount of memory that will be allocated by this client instance.
*
* Setting this to 0 will disable the limit.
*/
ClientBuilder memoryLimit(long memoryLimit, SizeUnit unit);

当客户端所有producer中所有pending的消息大小总和超过这个限制时,默认则会抛出MemoryBufferIsFullError异常,若同时配置了blockIfQueueFull=true,则当前线程会阻塞等待前面pending的消息发送完成。

前面提到关于blockIfQueueFull配置的使用有一个细节需要注意,这个配置是为了限制客户端producer内存使用的同时,让开发者简化处理队列或者内存buffer满了的情况可以继续发送消息,例如在一个后台定时任务的场景中批量发送消息。然而这里需要强调的是blockIfQueueFull一旦配置为true,不论是应用发送消息调用的是阻塞的Producer.send方法还是非阻塞的Producer.sendAsync方法都会出现阻塞等待,”卡“住当前线程,那么对于我们上面的业务来说这是不可接受的,若由于支线流程(特殊情况容忍丢失的用户搜索事件)异常抖动,阻塞了主线流程(搜索主线程)就得不偿失了。

// 注意:若producer发送队列满或者内存buffer满,当前线程将卡在sendAsync方法调用
producer.sendAsync(/* message payload here */).whenComplete((msgId, ex) -> {
if (ex != null) {
log.error("Failed to record search activity", ex);
} else {
log.debug("Search activity messageId={}", msgId);
}
});

PIP-1202.10.0以及之后版本的客户端中,默认启用了memoryLimit配置,其默认值为64MB,同时默认禁用了maxPendingMessagesmaxPendingMessagesAcrossPartitions配置(默认值修改为0),另外将maxPendingMessagesAcrossPartitions配置标记为了Deprecated,因为使用这个配置最终目的就是控制客户端producer的内存使用,现在已经有memoryLimit这个更加直接的配置可以替代了。

Consumer的内存控制

上面说的全部都是围绕着Producer侧的内存使用来讲的,其实在PIP-74中也提到了Pulsar客户端consumer侧的内存使用,只不过在实现中是分阶段进行的。

我们先来看一下Pulsar客户端的API早期在构造一个Consumer时,ConsumerBuilder提供的与内存使用有关的选项:

/*
* Sets the size of the consumer receive queue.
*
* (default: 1000)
*/
ConsumerBuilder<T> receiverQueueSize(int receiverQueueSize); /*
* Sets the max total receiver queue size across partitions.
*
* (default: 50000)
*/
ConsumerBuilder<T> maxTotalReceiverQueueSizeAcrossPartitions(int maxTotalReceiverQueueSizeAcrossPartitions);

Pulsar客户端通过预接收队列临时存放broker推送过来的消息,以便应用程序调用Consumer#receive或者Consumer#receiveAsync方法时直接从内存中返回消息,这是出于消费吞吐的考虑,本质上是一种以空间换取时间的策略。上面两个选项是给这个”空间“设置一个数量上的上限,注意这里仅是数量上的上限,实际的内存空间使用还要取决于平均消息大小。receiverQueueSize控制每个分区consumer的接收队列大小,maxTotalReceiverQueueSizeAcrossPartitions来控制所有分区consumer和parent consumer的接收队列总大小。

前面提到receiverQueueSizemaxTotalReceiverQueueSizeAcrossPartitions参数是以数量的形式间接的控制Consumer预接收队列的内存使用,在[PIP-74][pip-74]中提出了整个client级别的memoryLimit,同时提出了一个新的控制Consumer内存使用的方案,就是autoScaledReceiverQueueSizeEnabled:

/*
* If this is enabled, the consumer receiver queue size is initialized as a very small value, 1 by default,
* and will double itself until it reaches either the value set by {@link #receiverQueueSize(int)} or the client
* memory limit set by {@link ClientBuilder#memoryLimit(long, SizeUnit)}.
*/
ConsumerBuilder<T> autoScaledReceiverQueueSizeEnabled(boolean enabled);

当启用了这个特性之后,receiverQueueSize会从1开始呈2的指数倍增长,直至达到receiverQueueSize的限制或达到client的memoryLimit限制,其目标是在有限制的内存使用下,达到最大的吞吐效率。

番外

除了上面说的Producer和Consumer在生产和消费过程中的内存使用之外,还有一个容易被忽视的点是创建Consumer时ackTimeoutackTimeoutTickTime的配置如果不匹配,会消耗较多堆内内存。

/*
* Sets the timeout for unacknowledged messages, truncated to the nearest millisecond. The timeout must be greater than 1 second.
*/
ConsumerBuilder<T> ackTimeout(long ackTimeout, TimeUnit timeUnit); /**
* Define the granularity of the ack-timeout redelivery.
*
* <p>By default, the tick time is set to 1 second. Using a higher tick time
* reduces the memory overhead to track messages when the ack-timeout is set to
* bigger values (e.g., 1 hour).
*/
ConsumerBuilder<T> ackTimeoutTickTime(long tickTime, TimeUnit timeUnit);

若Consumer配置了ackTimeout并且配置了较大的时间窗口(例如1小时或者更长)时,应适当的调大ackTimeoutTickTime,这是因为Consumer内部使用了一个简单时间轮的算法对消息的处理时间计时,若ackTimeout时间窗口很大,ackTimeoutTickTime仍然使用其默认值1s,时间轮本身将会占用大量堆内存空间。具体细节可参考客户端源码UnAckedMessageTracker.java

总结

  1. 使用sendAsync非阻塞方法要注意其不能保证消息一定发送成功,特别是开启了blockIfQueueFull之后,它会在特定情况下演变成阻塞方法。
  2. 对于同时使用到了Producer和Consumer的应用,推荐创建两个client,分别用来创建Producer和Consumer,做读写分离,避免由于共用memoryLimit导致相互影响。

本文最先发表于: https://aops.io/article/pulsar-client-memory-control.html

作者 萧易客 一线深耕消息中间件,RPC框架多年,欢迎评论区或通过邮件交流。

微信公众号: 萧易客

github id: shawyeok

Pulsar客户端如何控制内存使用的更多相关文章

  1. 在浏览器中将表格导入到本地的EXCEL文件,注意控制内存

    if ($export_flag == 1) { $rr = $this->mdl->test($test); header("Content-Type: application ...

  2. [C++] 重载new和delete——控制内存分配

      1.new和delete表达式的工作机理      1)new表达式实际执行了三步 string *sp=new string("aaaa"); ];//string采用默认初 ...

  3. C++之控制内存分配

    一.内存分配方式 在C++中,内存分成5个区,他们分别是堆.栈.自由存储区.全局/静态存储区和常量存储区.栈:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释 ...

  4. c++控制内存分配

    为了满足应用程序对内存分配的特殊需求,C++允许重载new运算符和delete运算符控制内存分配,通过定位new表达式初始化对象(好处是可以在某些场景下避免重新内存分配的消耗) 1.operate n ...

  5. IdentityServer4[3]:使用客户端认证控制API访问(客户端授权模式)

    使用客户端认证控制API访问(客户端授权模式) 场景描述 使用IdentityServer保护API的最基本场景. 我们定义一个API和要访问API的客户端.客户端从IdentityServer请求A ...

  6. SQL Server sp_configure 控制内存使用

    背景知识: sp_configure   显示或更改当前服务器的全局配置设置(使用 sp_configure 可以显示或更改服务器级别的设置.) 查看 全局配置值 方法 1.execute sp_co ...

  7. 使用cgroups来控制内存使用

    磨砺技术珠矶,践行数据之道,追求卓越价值 回到上一级页面:PostgreSQL内部结构与源代码研究索引页    回到顶级页面:PostgreSQL索引页 [作者 高健@博客园  luckyjackga ...

  8. ASP.NET Core的身份认证框架IdentityServer4(7)- 使用客户端证书控制API访问

    前言 今天(2017-9-8,写于9.8,今天才发布)一口气连续把最后几篇IdentityServer4相关理论全部翻译完了,终于可以进入写代码的过程了,比较累.目前官方的文档和Demo以及一些相关组 ...

  9. IdentityServer4(7)- 使用客户端认证控制API访问(客户端授权模式)

    一.前言 本文已更新到 .NET Core 2.2 本文包括后续的Demo都会放在github:https://github.com/stulzq/IdentityServer4.Samples (Q ...

  10. C++ Primer 笔记——控制内存分配

    1.当我们使用一条new表达式时,实际执行了三步操作. new表达式调用一个名为operator new ( 或者operator new[] ) 的标准库函数.该函数分配一块足够大的,原始的,未命名 ...

随机推荐

  1. C# 13(.Net 9) 中的新特性 - 半自动属性

    C# 13 即 .Net 9 按照计划会在2024年11月发布,目前一些新特性已经定型,今天让我们来预览其中的一个新特性: 作者注:该特性虽然随着 C# 13 发布,但是仍然是处于 preview 状 ...

  2. winform计算器

    引言 本次项目目的主要为了熟悉winform控件使用,以及学习Microsoft.CSharp的使用. 技术栈 C# winform 实现效果 设计与实现 按键使用button空间,计算算式以及计算结 ...

  3. games101_Homework5

    使用光线追踪来渲染图像,实现两个部分:光线的生成和光线与三角的求交 你需要修改的函数是: • Renderer.cpp 中的 Render():这里你需要为每个像素生成一条对应的光 线,然后调用函数 ...

  4. FPGA时序约束基础

    一.时序约束的目的 由于实际信号在FPGA内部期间传输时,由于触发器等逻辑期间并非理想期间,因此不可避免地存在传输延时,这种延迟在高速工作频率.高逻辑级数时会造成后级触发器地建立时间和保持时间不满足, ...

  5. weblogic历史漏洞

    weblogic历史漏洞 是什么?  weblogic是一个web服务器应用(中间件),和jboss一样都是javaee中间件,只能识别java语言,绝大部分漏洞都是T3反序列化漏洞  常见的中间件还 ...

  6. ubuntu apache默认没开启rewrite 如何开启

    注意看到 /etc/apache2/apache2.conf # Include module configuration:IncludeOptional mods-enabled/*.loadInc ...

  7. computed methods watch filters

    computed(计算属性) 计算属性该属性里面的方法必须要有return返回值,这个返回值就是(value值). 有几个关键点 1) 计算后属性不需要在data中重复定义 2) 计算后属性必须渲染后 ...

  8. JDBC中驱动加载的过程分析

    江苏 无锡 缪小东 本篇从java.sql.Driver接口.java.sql.DriveManager类以及其它开源数据库的驱动类讨论JDBC中驱动加载的全过程以及JDBC的Framework如何做 ...

  9. px转换为rem,响应式js

    (function (doc, win) { var docEl = doc.documentElement, resizeEvt = 'orientationchange' in window ? ...

  10. GPU服务器常见问题汇总

    目录 Q1.从启动盘安装时黑屏/屏幕卡住? Q2.1T固态硬盘Ubuntu系统磁盘分区策略: Q3.安装Ubuntu需要选择更新吗? Q4.安装Ubuntu后重启无法开机? Q5.首次开机的配置代码? ...