跳至主要内容

使用火焰图提升 RabbitMQ 性能

·阅读 16 分钟

最近的 Erlang/OTP 版本附带了对 Linux perf 的支持。这篇博文提供了分步说明,说明如何为 RabbitMQ 创建 CPU 和内存 火焰图,以便快速准确地检测性能瓶颈。我们还提供了火焰图如何帮助我们提高 RabbitMQ 消息吞吐量的示例。

概述

在 Erlang/OTP 24 之前,许多工具(包括 fprofeprofcprof)都可用于 分析 Erlang 代码。但是,这些工具都不能生成开销最小的调用图。

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 系统调用在某些虚拟化环境中可能不起作用。

执行以下步骤来创建我们的第一个火焰图

  1. 安装 Erlang/OTP 25.0(其中包括 支持 JIT 中的帧指针)。这里我们使用 kerl
kerl build 25.0 25.0
kerl install 25.0 ~/kerl/25.0
source ~/kerl/25.0
  1. 安装 Elixir。这里我们使用 kiex
kiex install 1.12.3
kiex use 1.12.3
  1. 克隆 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
  1. 由于我们将对 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}}
]}
].
  1. 启动 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"
  1. 在第二个 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
  1. 在 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
  1. 在第二个 shell 窗口中 60 秒的 PerfTest 运行完成后,检查结果。在这台机器上,我们获得了约 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
  1. 之前的 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 火焰图

Figure 1: CPU Flame Graph - RabbitMQ v3.10.1 - 1 producer publishing to a stream
图 1:CPU 火焰图 - RabbitMQ v3.10.1 - 1 个生产者发布到流中

如果您没有运行上述步骤,请点击 此处 以在浏览器中以 SVG 文件格式打开图 1。

CPU 火焰图的解释如下

  • 每个框都是一个堆栈帧。
  • SVG 是交互式的。尝试点击一个堆栈帧以放大特定调用图。在 SVG 的左上角点击“重置缩放”以返回。
  • 颜色(黄色、橙色、红色)没有意义。
  • 高度表示调用堆栈的深度。高“塔”可以表示递归函数调用。大多数情况下,存在一定程度的递归是完全正常的。在上面的 SVG 中,点击最高塔(图形左侧)中的一个堆栈帧,您会看到名为 lists:foldr_1/3 的函数导致了这种递归。
  • 同一级别的堆栈帧的水平顺序按字母顺序排列。因此,水平顺序不代表时间。
  • 最需要注意的特征是框的宽度。宽度决定了函数在 CPU 上停留的频率。特别是,查看图形顶部的宽堆栈帧,因为它们会直接消耗大量的 CPU 周期!
  • 所有 Erlang 函数都以美元符号 ($) 为前缀。
  • 在火焰图的右上角,您可以点击灰色的“搜索”图标。如果您在搜索框中输入正则表达式 ^\$(表示“匹配以美元符号开头的所有内容”),则所有 Erlang 函数都将以紫色突出显示。

毫不奇怪,最困难的部分是根据火焰图提供的见解来优化性能。通常,两种策略已被证明是成功的

  1. 运行您知道对 RabbitMQ 有问题的负载。例如,如果 RabbitMQ 运行缓慢或在特定客户端负载下消耗大量内存,请运行该客户端负载(例如,使用 PerfTest),使用 Linux perf 记录 RabbitMQ 服务器的概要文件,并创建火焰图。很有可能火焰图会显示瓶颈。
  2. 尝试探索性地优化性能。这就是我们将在本博文中要做的。

我们启动了 PerfTest,其中一个发布者将消息发送到流中,而不知道任何性能问题。点击一些堆栈帧并检查哪些函数消耗 CPU 时间,令人惊讶的是,函数 rabbit_exchange:route/2 在 CPU 上花费了 9.5% 的时间。在上面的 SVG 中搜索该函数以将其突出显示为紫色,然后点击紫色框以放大(或点击 此处)。它将向您显示以下图像

Figure 2: CPU Flame Graph - RabbitMQ v3.10.1 - zoomed into function rabbit_exchange/2
图 2:CPU 火焰图 - RabbitMQ v3.10.1 - 放大函数 rabbit_exchange/2

在 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,这意味着为每个要路由的消息编译相同的 匹配规范

拉取请求 (PR) #4606 遵循 表和数据库效率指南

Ets 表是一个单键表(哈希表或按键排序的树),应将其用作一个表。换句话说,尽可能使用键来查找内容。

此 PR 添加了一个 Mnesia 索引表,其表键是 Erlang 元组 {SourceExchange, RoutingKey},以便通过该键查找路由目标。

在我们的示例中,这意味着不是调用使用昂贵匹配规范的 ets:select/2,而是使用 ets:loookup_element/3 通过提供表键 {direct, f809d879-b5ad-4159-819b-b39d6b50656a} 来查找路由目标 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(或点击 此处

Figure 3: reduced CPU usage in rabbit_exchange/2 on RabbitMQ master branch (May 2022)
图 3:RabbitMQ master 分支 (2022 年 5 月) 上 rabbit_exchange/2 中的 CPU 使用率降低

通过新的优化,该函数的 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%。

在 PR #4787#3934rabbitmq/ra #272 中可以找到更多通过分析 CPU 火焰图来降低 RabbitMQ CPU 使用率的示例。

内存火焰图

火焰图可视化层次数据。在上一节中,这些数据表示消耗 CPU 时间的代码路径。本节介绍表示导致内存使用情况的代码路径的数据。

Brendan Gregg 建议使用不同的跟踪方法来分析内存使用情况。

  1. 分配器,例如 malloc() 库函数。glibc 的 malloc() 实现使用系统调用 brk()mmap() 来请求内存。
  2. brk() 系统调用通常表示内存增长。
  3. mmap() 系统调用由 glibc 用于更大的分配。mmap() 在虚拟地址空间中创建一个新的映射。除非随后使用 munmap() 释放,否则 mmap() 火焰图可能会检测到导致内存增长或泄漏的函数。
  4. 页面错误指示物理内存使用情况。

并非所有 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() 火焰图。

Figure 4: mmap() Flame Graph - RabbitMQ v3.10.1 - 4 consumers reading from a stream
图 4:mmap() 火焰图 - RabbitMQ v3.10.1 - 4 个消费者从流中读取

如果您没有运行上述步骤,请点击 此处 以在浏览器中将图 4 作为 SVG 文件打开。

与 CPU 火焰图一样,除了紫色突出显示搜索匹配项外,颜色(绿色、蓝色)在内存火焰图中没有意义。

火焰图显示,所有 mmap() 系统调用的 10.1% 发生在模块 amqp10_binary_parser 中。PR #4811 通过遵循 匹配二进制效率指南 对该模块中的代码进行了优化,即通过重用匹配上下文而不是创建新的子二进制。

停止 RabbitMQ 服务器。在不删除其数据目录的情况下,检出包含 PR #4811 的标签 v3.10.2。通过启动 RabbitMQ 服务器并在记录 mmap() 系统调用的同时使用 Linux perf 从流中使用 4 个消费者重复之前的步骤。

当 PerfTest 在 90 秒后完成时,这次它输出的平均接收速率约为每秒 407,000 条消息。与 v3.10.1 中的 287,000 条消息相比,这相当于每秒提高了约 120,000 条消息的接收吞吐量,或约 42%。

像之前一样再次创建一个 mmap() 火焰图,并搜索 amqp10_binary_parser

Figure 5: mmap() Flame Graph - RabbitMQ v3.10.2 - 4 consumers reading from a stream
图 5:mmap() 火焰图 - RabbitMQ v3.10.2 - 4 个消费者从流中读取

如果您没有运行上述步骤,请点击 此处 以在浏览器中将图 5 作为 SVG 文件打开。

二进制匹配优化将模块 amqp10_binary_parsermmap() 系统调用从 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%。

在 PR #4801 中可以找到另一个通过探索 mmap() 火焰图来减少 RabbitMQ 中 mmap() 系统调用的示例。

创建页面错误火焰图(方法 4)的工作方式与创建 mmap() 火焰图(方法 3)相同。唯一的区别是将 Linux perf 标志 --event syscalls:sys_enter_mmap 替换为 --event page-faults。PR #4110 中提供了一个页面错误火焰图如何帮助提高 RabbitMQ 性能的示例,其中新的队列实现通过维护自己的队列长度来消耗更少的物理内存。

总结

自从 Erlang/OTP 25 以来,我们可以通过使用 Linux perf 和创建火焰图来高效且精确地检测 RabbitMQ 中消耗大量 CPU 或内存的堆栈跟踪。一旦确定了瓶颈,我们就可以优化代码路径,从而提高 RabbitMQ 的性能。

不同的客户端工作负载会导致 RabbitMQ 服务器不同的 CPU 和内存使用模式。无论您是否遇到 RabbitMQ 运行缓慢、怀疑 RabbitMQ 内存泄漏,或者只是想加快 RabbitMQ 的速度,我们都建议您创建火焰图以查明性能瓶颈。

与本博文中所述的对单个 RabbitMQ 节点进行一次性分析相反,我们还在实验跨 RabbitMQ 集群中的多个节点进行持续分析。将来,在生产环境中持续分析 RabbitMQ 的一种潜在方法是在 Kubernetes 上部署 RabbitMQ,使用 rabbitmq/cluster-operator 并使用 Parca Agent 进行分析。

© 2024 RabbitMQ. All rights reserved.