使用火焰图提高 RabbitMQ 性能
最近的 Erlang/OTP 版本支持 Linux perf。本文提供了分步说明,介绍如何在 RabbitMQ 中创建 CPU 和内存 火焰图,以快速准确地检测性能瓶颈。我们还提供了一些示例,说明火焰图如何帮助我们提高 RabbitMQ 的消息吞吐量。
概述
在 Erlang/OTP 24 之前,已经有许多工具可用于 分析 Erlang 代码,例如 fprof、eprof 和 cprof。然而,这些工具都无法在几乎不产生额外开销的情况下生成调用图。
Erlang/OTP 24 引入了即时 (JIT) 编译器。与解释代码不同,JIT 编译器会生成机器码。这些机器码可以被原生工具进行检测。具体来说,Erlang/OTP 24 支持 Linux perf。Perf 可以分析机器码并生成低开销的调用图,因为分析是在 Linux 内核中进行的。
这是 Erlang/OTP 24 的一项重要功能,因为它允许任何 Erlang 程序高效且精确地检测其源代码中的性能瓶颈。
一旦程序被分析并生成了调用图,这些堆栈跟踪就可以通过各种工具进行可视化和分析。最受欢迎的工具是火焰图。火焰图由 Brendan Gregg 于 2011 年发明。
虽然多年前就已有可能为已分析的 Erlang 代码创建火焰图,但新之处在于现在可以创建具有精确调用堆栈报告的火焰图,而不会导致 Erlang 程序出现明显的减速。
在这篇博文中,我们将演示如何创建 CPU 和内存火焰图,解释如何解读它们,并展示它们如何帮助我们提升 RabbitMQ 的性能。
CPU 火焰图
为了使用 Linux perf 分析 RabbitMQ,我们需要在 Linux 操作系统上运行 RabbitMQ。(本博文中的命令是在 Ubuntu 22.04 LTS 上运行的。)
物理 Linux 服务器是最好的选择,因为会有更多的硬件计数器。然而,Linux 虚拟机 (VM) 足够用于入门。请记住,VM 可能存在一些限制。例如,RabbitMQ 大量使用的 fsync 系统调用在某些虚拟化环境中可能不起作用。
执行以下步骤来创建我们的第一个火焰图
- 安装 Erlang/OTP 25.0(其中包含对 JIT 中帧指针的支持)。这里我们使用 kerl
kerl build 25.0 25.0
kerl install 25.0 ~/kerl/25.0
source ~/kerl/25.0
- 安装 Elixir。这里我们使用 kiex
kiex install 1.12.3
kiex use 1.12.3
- 克隆 RabbitMQ 服务器
git clone git@github.com:rabbitmq/rabbitmq-server.git
cd rabbitmq-server
git checkout v3.10.1
make fetch-deps
git -C $(pwd)/deps/seshat checkout 68f2b9d4ae7ea730cef613fd5dc4456e462da492
- 由于我们将对 RabbitMQ 进行压力测试,并且不希望 RabbitMQ 通过自我保护机制人为地减慢我们的性能测试速度,因此我们将内存阈值提高,以避免触发内存告警,并将信用流控设置比其默认值提高 4 倍。您也可以尝试将
credit_flow_default_credit设置为{0, 0},这将完全禁用基于信用的流控。创建以下 advanced.config 文件
[
{rabbit,[
{vm_memory_high_watermark, {absolute, 15_000_000_000}},
{credit_flow_default_credit, {1600, 800}}
]}
].
- 启动 RabbitMQ 服务器。我们不启用任何 RabbitMQ 插件(因为插件可能会对性能产生负面影响,尤其是某些在具有数千个对象(如队列、交换器、通道等)的情况下查询统计信息的插件)。我们设置 Erlang 虚拟机标志
+JPperf true以启用对 Linux perf 的支持,并设置+S 4以创建 4 个调度器线程。
make run-broker PLUGINS="" RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS="+JPperf true +S 4" \
RABBITMQ_CONFIG_FILE="advanced.config" TEST_TMPDIR="test-rabbit"
- 在第二个 shell 窗口中,启动 RabbitMQ PerfTest。在我们的示例中,PerfTest 创建一个生产者,以最多 2,000 条未确认的消息流式传输到名为
my-stream的流中,持续 60 秒。
# Install PerfTest
wget -O perf-test https://github.com/rabbitmq/rabbitmq-perf-test/releases/download/v2.17.0/perf-test_linux_x86_64
chmod +x perf-test
# Start PerfTest client generating load against RabbitMQ server
./perf-test --queue my-stream --queue-args x-queue-type=stream --auto-delete false --flag persistent \
--producers 1 --confirm 2000 --consumers 0 --time 60
- 在 PerfTest 运行时,在第三个 shell 窗口中,记录一个配置文件,以 999 赫兹 (
--freq) 的频率对 RabbitMQ 服务器进程 (--pid) 的 CPU 堆栈跟踪进行采样,记录内核空间和用户空间 (-g) 的调用图,持续 30 秒。
# Install perf, e.g. on Ubuntu:
# sudo apt-get install linux-tools-common linux-tools-generic linux-tools-`uname -r`
sudo perf record --pid $(cat "test-rabbit/rabbit@$(hostname --short)/rabbit@$(hostname --short).pid") --freq 999 -g -- sleep 30
- 在第二个 shell 窗口中,PerfTest 运行 60 秒结束后,检查结果。在这台机器上,我们得到的发送速率平均约为 103,000 条消息/秒。您的机器上的结果可能会更快或更慢。
test stopped (Reached time limit)
id: test-114336-500, sending rate avg: 103132 msg/s
id: test-114336-500, receiving rate avg: 0 msg/s
- 之前的 perf 命令输出一个文件
perf.data。根据 Erlang 文档中的说明,从此数据创建 CPU 火焰图。
git clone git@github.com:brendangregg/FlameGraph.git
# Convert perf.data (created by perf record) to trace output
sudo perf script > out.perf
# Collapse multiline stacks into single lines
./FlameGraph/stackcollapse-perf.pl out.perf > out.folded
# Merge scheduler profile data
sed -e 's/^[0-9]\+_//' -e 's/^erts_\([^_]\+\)_[0-9]\+/erts_\1/' out.folded > out.folded_sched
# Create the SVG file
./FlameGraph/flamegraph.pl --title="CPU Flame Graph" out.folded_sched > cpu.svg
在浏览器中打开生成的 cpu.svg 文件,应该会看到一个类似于以下内容的 CPU 火焰图

如果您没有运行上述步骤,请单击此处在浏览器中以 SVG 文件格式打开图 1。
CPU 火焰图的解读如下
- 每个框代表一个堆栈帧。
- SVG 是交互式的。尝试点击堆栈帧以深入查看特定的调用图。在 SVG 左上角,点击
Reset Zoom可返回。 - 颜色(黄色、橙色、红色)没有特定含义。
- 高度表示调用堆栈的深度。“高塔”可能代表递归函数调用。大多数情况下,一定程度的递归是完全可以接受的。在上面的 SVG 中,点击左侧图表中最高塔上的堆栈帧,您会发现名为
lists:foldr_1/3的函数导致了这种递归。 - 同一级别的堆栈帧的水平顺序是按字母顺序排列的。因此,水平顺序不代表时间。
- 最需要关注的特点是框的宽度。宽度决定了函数在 CPU 上被调用的频率。特别要注意图顶部的宽堆栈帧,因为它们直接消耗大量 CPU 周期!
- 所有 Erlang 函数都以美元符号 (
$) 作为前缀。 - 在火焰图的右上角,您可以点击灰色的
Search图标。如果我们在搜索框中输入正则表达式^\$(表示“匹配所有以美元符号开头的字符”),所有 Erlang 函数将以紫色高亮显示。
不出所料,最困难的部分是根据火焰图提供的见解来优化性能。总的来说,两种策略已被证明是成功的
- 运行一个您知道对 RabbitMQ 有问题的负载。例如,如果 RabbitMQ 在某个特定的客户端负载下运行缓慢或占用大量内存,则运行该客户端负载(例如使用 PerfTest),使用 Linux perf 记录 RabbitMQ 服务器的配置文件,并创建火焰图。火焰图很可能会显示出瓶颈。
- 尝试进行探索性性能优化。这就是我们将在本博文中要做的。
我们启动了 PerfTest,其中一个发布者在不知道任何性能问题的情况下发送消息到流。点击某些堆栈帧并检查哪些函数消耗了 CPU 时间,令人惊讶的是,函数 rabbit_exchange:route/2 占用了 9.5% 的 CPU。在上面的 SVG 中搜索该函数以将其高亮为紫色,然后点击紫色框以放大(或单击此处)。它将显示以下图像

在 shell 中执行以下命令以列出 RabbitMQ 绑定
./sbin/rabbitmqctl list_bindings --formatter=pretty_table
Listing bindings for vhost /...
┌─────────────┬─────────────┬──────────────────┬──────────────────┬──────────────────────────────────────┬───────────┐
│ source_name │ source_kind │ destination_name │ destination_kind │ routing_key │ arguments │
├─────────────┼─────────────┼──────────────────┼──────────────────┼──────────────────────────────────────┼───────────┤
│ │ exchange │ my-stream │ queue │ my-stream │ │
├─────────────┼─────────────┼──────────────────┼──────────────────┼──────────────────────────────────────┼───────────┤
│ direct │ exchange │ my-stream │ queue │ f809d879-b5ad-4159-819b-b39d6b50656a │ │
└─────────────┴─────────────┴──────────────────┴──────────────────┴──────────────────────────────────────┴───────────┘
PerfTest 客户端创建了一个名为 my-stream 的流(队列)。第一个绑定显示每个队列都自动绑定到默认交换器(空字符串 "" 的交换器)。PerfTest 还创建了一个名为 direct 的直连交换器,并将流与该交换器以及一些随机路由键绑定。
尽管只有 2 个绑定(路由),RabbitMQ 在路由消息的函数 rabbit_exchange:route/2 中花费了大量 CPU 时间(9.5%),并且在 ets:select/2 函数中花费了 6.69% 的 CPU 时间。
我们还在函数堆栈跟踪中看到一个宽框 db_match_compile,这意味着每次路由消息时都会编译相同的匹配规范。
Ets 表是单键表(按键排序的哈希表或树),应按此方式使用。换句话说,尽可能使用键来查找内容。
此 PR 添加了一个 Mnesia 索引表,其表键为 Erlang 元组 {SourceExchange, RoutingKey},以便通过该键查找路由目标。
在我们的示例中,这意味着不再调用使用昂贵匹配规范的 ets:select/2,而是通过提供表键 {direct, f809d879-b5ad-4159-819b-b39d6b50656a} 使用 ets:loookup_element/3 来查找路由目标 my-stream。
使用 Ctrl+g q 停止 RabbitMQ 服务器并删除其数据目录
rm -rf test-rabbit
我们检出 master 分支中的一个提交(撰写本文时是 2022 年 5 月),其中包含 PR #4606
git checkout c22e1cb20e656d211e025c417d1fc75a9067b717
通过重复上述步骤 5-9,重新运行相同的场景。
打开新的 CPU 火焰图并搜索堆栈帧 rabbit_exchange:route/2(或单击此处)

通过新的优化,该函数占用的 CPU 使用率从 9.5% 下降到仅 2.5%。
因此,PerfTest 的发送速率平均约为 129,000 条消息/秒。与此更改之前的约 103,000 条消息/秒相比,单发布者的吞吐量提高了 26,000 条消息/秒,或 25%。此加速适用于通过直连交换器类型的 AMQP 0.9.1、AMQP 1.0、STOMP 和 MQTT 发送消息的发布者。
如 PR 中所述,端到端(从客户端到 RabbitMQ 服务器)的发送吞吐量提高幅度较低(20,000 条消息/秒或 15%),当发送到经典队列或仲裁队列时(因为它们存储消息的速度比流慢),并且吞吐量提高幅度较高(90,000 条消息/秒或 35%)当存在许多绑定时(因为此更改后,路由表查找以常数时间通过表键进行)。
总而言之,在本节中,我们为典型的 RabbitMQ 工作负载创建了一个 CPU 火焰图。CPU 火焰图精确地显示了哪些函数需要 CPU 使用:框越宽,需要的 CPU 时间越多。仅通过探索堆栈帧,我们就能够检测到路由是瓶颈。认识到这个瓶颈后,我们随后可以通过直连交换器类型将路由速度提高,从而将发送吞吐量提高 15% - 35%。
通过分析 CPU 火焰图来减少 RabbitMQ CPU 使用率的其他示例可以在 PR #4787、#3934 和 rabbitmq/ra #272 中找到。
内存火焰图
火焰图可视化分层数据。在前一节中,这些数据代表消耗 CPU 时间的代码路径。本节讨论代表引起内存使用情况的代码路径的数据。
Brendan Gregg 提出了不同的追踪方法来分析内存使用情况
- 分配器,例如 malloc() 库函数。glibc 的
malloc()实现使用系统调用 brk() 和 mmap() 来请求内存。 brk()系统调用通常指示内存增长。mmap()系统调用由 glibc 用于较大的分配。mmap()在虚拟地址空间中创建一个新的映射。除非随后使用munmap()释放,否则mmap()火焰图可能会检测到增长或泄漏内存的函数。- 页面错误指示物理内存使用情况。
并非所有 Erlang 内存分配都可以通过这些方法之一进行追踪。例如,Erlang VM(在启动时)已预先分配的内存无法进行追踪。此外,为了减少系统调用的数量,通过 mmap() 分配的一些内存段会在被销毁之前被缓存。新分配的段将从该缓存中提供,因此无法使用 Linux perf 进行追踪。
在本节中,我们将创建一个 mmap() 火焰图(方法 3)。
停止 RabbitMQ 服务器,删除其数据目录,检出标签 v3.10.1,然后按照上一节第 5 步中的方法启动 RabbitMQ 服务器。
在第二个 shell 窗口中,启动 PerfTest,让 1 个发布者发送消息到流,持续 4 分钟
./perf-test --queue my-stream --queue-args x-queue-type=stream --auto-delete false --flag persistent \
--producers 1 --consumers 0 --time 240
4 分钟后,我们看到 PerfTest 客户端发布了超过 3200 万条消息。
./sbin/rabbitmqctl list_queues name type messages --formatter=pretty_table
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
┌───────────┬────────┬──────────┐
│ name │ type │ messages │
├───────────┼────────┼──────────┤
│ my-stream │ stream │ 32748214 │
└───────────┴────────┴──────────┘
启动 4 个消费者,每个消费者消费这些消息 90 秒。
./perf-test --queue my-stream --queue-args x-queue-type=stream --auto-delete false --flag persistent \
--producers 0 --consumers 4 --qos 10000 --multi-ack-every 1000 -consumer-args x-stream-offset=first --time 90
在 PerfTest 运行时,在第三个 shell 窗口中,记录一个配置文件,该文件追踪 mmap() 系统调用。
sudo perf record --pid $(cat "test-rabbit/rabbit@$(hostname --short)/rabbit@$(hostname --short).pid") \
--event syscalls:sys_enter_mmap -g -- sleep 60
90 秒后,PerfTest 输出接收速率平均约为 287,000 条消息/秒。
test stopped (Reached time limit)
id: test-102129-000, sending rate avg: 0 msg/s
id: test-102129-000, receiving rate avg: 287086 msg/s
之前的 Linux perf 命令写入文件 perf.data。创建一个 mmap() 火焰图。
sudo perf script > out.perf
# Collapse multiline stacks into single lines
./FlameGraph/stackcollapse-perf.pl out.perf > out.folded
# Merge scheduler profile data
sed -e 's/^[0-9]\+_//' out.folded > out.folded_sched
# Create the SVG file
./FlameGraph/flamegraph.pl --title="mmap() Flame Graph" --color=mem --countname="calls" out.folded_sched > mmap.svg
在浏览器中打开生成的 mmap.svg 文件并搜索 amqp10_binary_parser,应该会看到一个类似于以下内容的 mmap() 火焰图

如果您没有运行上述步骤,请单击此处在浏览器中以 SVG 文件格式打开图 4。
与 CPU 火焰图一样,除了紫色突出显示搜索匹配项之外,颜色(绿色、蓝色)在内存火焰图中没有特定含义。
火焰图显示,10.1% 的 mmap() 系统调用发生在模块 amqp10_binary_parser 中。PR #4811 通过遵循匹配二进制文件效率指南(即重用匹配上下文而不是创建新的子二进制文件)来优化该模块中的代码。
停止 RabbitMQ 服务器。不删除其数据目录,检出包含 PR #4811 的标签 v3.10.2。重复之前的步骤,通过启动 RabbitMQ 服务器并使用 4 个消费者从流中消费,同时使用 Linux perf 记录 mmap() 系统调用。
当 PerfTest 在 90 秒后完成时,这次它输出了平均接收速率约 407,000 条消息/秒。与 v3.10.1 中的 287,000 条消息/秒相比,接收吞吐量提高了约 120,000 条消息/秒,或约 42%。
再次按照之前的方法创建一个 mmap() 火焰图,并搜索 amqp10_binary_parser。

如果您没有运行上述步骤,请单击此处在浏览器中以 SVG 文件格式打开图 5。
二进制匹配优化将模块 amqp10_binary_parser 的 mmap() 系统调用从 v3.10.1 的 10.1% 减少到 v3.10.2 的 1.3%。
总而言之,在本节中,我们为典型的 RabbitMQ 工作负载创建了一个 mmap() 内存火焰图。mmap() 火焰图精确地显示了哪些函数导致 mmap() 系统调用:框越宽,触发的 mmap() 系统调用越多。仅通过探索堆栈帧,我们就能够检测到 AMQP 1.0 二进制解析是瓶颈。认识到这个瓶颈后,我们随后可以通过优化 AMQP 1.0 二进制解析来加快从 AMQP 0.9.1 流的接收吞吐量,速度提高了约 42%。
通过探索 mmap() 火焰图来减少 RabbitMQ 中 mmap() 系统调用的另一个示例可以在 PR #4801 中找到。
创建页面错误火焰图(方法 4)的步骤与创建 mmap() 火焰图(方法 3)相同。唯一的区别是用 --event page-faults 替换 Linux perf 标志 --event syscalls:sys_enter_mmap。PR #4110 中的一个示例展示了页面错误火焰图如何帮助提高 RabbitMQ 性能,其中新的队列实现通过维护自己的队列长度来消耗更少的物理内存。
总结
自 Erlang/OTP 25 起,我们就可以使用 Linux perf 并创建火焰图来高效且精确地检测 RabbitMQ 中消耗大量 CPU 或内存的堆栈跟踪。一旦识别出瓶颈,我们就可以优化代码路径,从而提高 RabbitMQ 的性能。
不同的客户端工作负载会导致 RabbitMQ 服务器产生不同的 CPU 和内存使用模式。无论您是遇到 RabbitMQ 运行缓慢,怀疑 RabbitMQ 内存泄漏,还是只想加快 RabbitMQ 的速度,我们都鼓励您创建火焰图来精确定位性能瓶颈。
与本博文中对单个 RabbitMQ 节点进行一次性性能分析不同,我们还在试验跨 RabbitMQ 集群中的多个节点的持续性能分析。未来,在生产环境中持续分析 RabbitMQ 的一种可能方法是使用 rabbitmq/cluster-operator 将 RabbitMQ 部署在 Kubernetes 上,并使用 Parca Agent 进行性能分析。
