摘要

最近手撕的 tcp-reverse-proxy,相比 nginx 来说,高并发时整体吞吐量太差了,需要定位具体的性能损耗点。因此特意了解了下火焰图。

正文

最近手撕的 tcp-reverse-proxy,相比 nginx 来说,高并发时整体吞吐量太差了,需要定位具体的性能损耗点。因此特意了解了下火焰图。

一、火焰图

1.1 概念

火焰图(Flame Graph) 是一种用于可视化性能分析数据的图形化工具,它通过直观的方式展示程序在运行时的资源使用情况(如 CPU 时间、内存分配、I/O 时间等),帮助开发者快速定位性能瓶颈。

1.2 基本构成

火焰图由一个二维坐标系构成

  1. x 轴
    • 含义:表示某种资源的占比,如 CPU 时间、内存大小、IO 时间等。
    • 说明:宽度越大、表示该函数消耗的资源越多。
  2. y 轴
    • 含义:表示调用栈的深度。
    • 说明:顶层是实际执行函数,下层是调用顶层的函数。

二、async-profiler

针对 Java 程序,如果想要生成火焰图,通过 JDK 自带的 jstack 与 fastthread 即可生成。但是这个过程太过麻烦。

在实际中,更推荐使用轻量工具 async-profiler,这个工具主要适用于 Linux 环境

2.1 安装

配置 bash 脚本,直接运行

sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/usr/bin/env bash
set -e

# 日志打印函数
function log() {
  echo "$(date +"%Y-%m-%d %H:%M:%S"): $1"
}

function downloadAsyncProfiler() {
  log "正在下载..."
  curl -k -L -o async-profiler.tgz "https://github.com/async-profiler/async-profiler/releases/download/v3.0/async-profiler-3.0-linux-x64.tar.gz"
  log "下载完成"
}

function decompressAsyncProfiler() {
  if [! -f "$1"]; then
    log "错误:未找到 $1"
    exit 1
  fi

  log "正在解压..."
  mkdir -p async-profiler
  tar -zxvf "$1" --strip-components=1 -C async-profiler
  log "解压完成"
}

function configAsyncProfiler() {
  log "开始配置环境变量..."
  mv async-profiler /usr/local/async-profiler
  cat > /etc/profile.d/async-profiler.sh <<EOF
export PATH=\$PATH:/usr/local/async-profiler/bin
EOF
  # 刷新环境变量
  source /etc/profile.d/async-profiler.sh
  log "配置环境变量结束"
}

downloadAsyncProfiler
decompressAsyncProfiler async-profiler.tgz
configAsyncProfiler

运行 asprof -v

image-20250316190341282.png

2.2 解读火焰图

该内容摘自 async-profiler/docs/FlamegraphInterpretation.md at master · async-profiler/async-profiler

1.) 首先是示例代码

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
main() {
     // 一些业务逻辑
    func3() {
        // 一些业务逻辑
        func7();
    }

    // 一些业务逻辑
    func4();

    // 一些业务逻辑
    func1() {
        // 一些业务逻辑
        func5();
    }

    // 一些业务逻辑
    func2() {
        // 一些业务逻辑
        func6() {
            // 一些业务逻辑
            func8(); // 这里是 CPU 密集型工作
    }
}

2.) asprof 进行采样。每秒采样一次样本,每次采样时,都会保存其当前调用堆栈,如下图。

image-20250316192522847.png

3.) 对采样数据进行分类。由上图采样结果可得。

  • func3()->func7():3 样本
  • func4(): 1 样本
  • func1()->func5():2 样本
  • func2()->func6()->func8():4 样本
  • func2()->func6(): 1 样本

4.) 排序,由按照字母排序,排序优先级由底层到上层。

image-20250316193437500.png

5.) 聚合。将深度相同的函数拼成一块,获得聚合视图。

image-20250316194637447.png

6.) 解读。在此示例中

  • func4、func5、func6、func7、func8 都是实际消耗资源的函数。
  • 在实际开发中,像 func4 这类是比较接近底层的函数。这种底层的优化,我们可以暂时忽略。主要关注顶层优化,也就是像 func8 这种。

另外 asprof 生成的火焰图,也有颜色说明。如下

image-20250316195802587.png

2.3 生成火焰图

以下火焰图的生成,是基于我手撕的 tcp-reverse-proxy

运行 java 程序后,执行命令 ps -ef|grep java 可以获取 PID。

详细的分析模式,可以自行查阅官方文档 Profiling modes

下面只记录常用的分析模式。

2.3.1 CPU

运行命令,采集 30 秒目标进程 (PID 为 111) 的 CPU 占用情况

sh
1
asprof -d 30 -f cpu.html 111

搜索我的包名 top.meethigehr,通过火焰图,呈现为紫色,可以定位到以下两个函数还是可以进一步优化的。

image-20250316200507929.png

分别是如下这两个函数

java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
 * 判断是否是逐跳标头。
 * hopByHopHeaders 是固定值,我在此处使用的是数组
 * 可以考虑改用 Set.contains,但是忽略大小写呢?
 */
protected boolean isHopByHopHeader(String headerName) {
    for (String hopByHopHeader : hopByHopHeaders) {
        if (hopByHopHeader.equalsIgnoreCase(headerName)) {
            return true;
        }
    }
    return false;
}
/**
 * 获取代理后的完整 proxyUrl,不区分代理目标路径是否以 / 结尾。
 * 处理逻辑为删除掉匹配的路径,并将剩下的内容追加到代理目标路径后面。
 */
protected String getProxyUrl(RoutingContext ctx, HttpServerRequest serverReq, HttpServerResponse serverResp) {
    String targetUrl = getContextData(ctx, P_TARGET_URL).toString();
    // 不区分 targetUrl 是否以 / 结尾,均以 targetUrl 不带 / 来处理
    if (targetUrl.endsWith("/")) {
        targetUrl = targetUrl.substring(0, targetUrl.length() - 1);
    }


    // 在 vertx 中,uri 表示 hostPort 后面带有参数的地址。而这里的 uri 表示不带有参数的地址。
    final String uri = serverReq.path();
    final String params = serverReq.uri().replace(uri, "");


    // 若不是多级匹配,则直接代理到目标地址。注意要带上请求参数
    if (!getContextData(ctx, P_SOURCE_URL).toString().endsWith("*")) {
        return targetUrl + params;
    }

    String matchedUri = ctx.currentRoute().getPath();
    if (matchedUri.endsWith("/")) {
        matchedUri = matchedUri.substring(0, matchedUri.length() - 1);
    }
    String suffixUri = uri.replace(matchedUri, "");

    // 代理路径尾部与用户初始请求保持一致
    if (uri.endsWith("/") && !suffixUri.endsWith("/")) {
        suffixUri = suffixUri + "/";
    }
    if (!uri.endsWith("/") && suffixUri.endsWith("/")) {
        suffixUri = suffixUri.substring(0, suffixUri.length() - 1);
    }

    // 因为 targetUrl 后面不带 /,因此后缀需要以 / 开头
    if (!suffixUri.isEmpty() && !suffixUri.startsWith("/")) {
        suffixUri = "/" + suffixUri;
    }

    return targetUrl + suffixUri + params;
}

2.3.2 内存

运行命令,采集 30 秒目标进程 (PID 为 111) 的内存分配情况

sh
1
asprof -d 30 -e alloc -f mem.html 111

搜索我的包名 top.meethigher,通过火焰图,呈现为紫色,矛头还是直指 top.meethigher.proxy.http.ReverseHttpProxy#getProxyUrl,他居然平分了我一个函数的一半内存,比 vertx 内存占用还要高。

image-20250316203407933.png

2.3.3 锁消耗 / 锁等待

运行命令,采集 30 秒目标进程 (PID 为 111) 的锁占用的状态

sh
1
asprof -d 30 -e lock -f lock.html 111

2.3.4 I/O 时间

运行命令,采集 30 秒目标进程 (PID 为 111) 的运行延时状况

sh
1
asprof -d 30 -e wall -f lock.html 111

可以看到更多的时间,还是花在 write 上了。

image-20250316210754829.jpg

2.4 与 Jar 集成

上面介绍的生成火焰图的方式,适用于长时间运行的程序。

如果一个本身运行时间就很短的程序,用上述方式就不行了。就比如,我在使用 vertx 的 httpclient 时,发现他对于域名的解析,比 okhttp 要慢很多。我需要针对 httpclient 的实现,进行火焰图的生成,以定位问题所在。

这类情况,可以将生成火焰图与 Jar 进行集成,让 asprof 伴随 jar 的整个生命周期。

sh
1
java -agentpath:/usr/local/async-profiler/lib/libasyncProfiler.so=start,event=cpu,file=cpu-okhttp.html -jar bug-test-okhttp.jar

三、JetBrains IDEA Profier

async-profiler 是在 Linux 环境中使用的,但是有些问题,只有在 Windows 系统上可以复现,这就需要在 Windows 上也能生成火焰图。

在 Windows 上直接使用 IDEA 内置工具就可以,比如我的 IDEA 版本是 IntelliJ IDEA 2024.1.2 (Ultimate Edition),运行程序时,选择 Profiler 'main()' with 'Intellij Profiler' 即可

image-20250323181318730.jpg

image-20250323190742042.jpg

四、参考

async-profiler/async-profiler: Sampling CPU and HEAP profiler for Java featuring AsyncGetCallTrace + perf_events

async-profiler/docs/FlamegraphInterpretation.md at master · async-profiler/async-profiler

如何读懂火焰图? - 阮一峰的网络日志

Read the profiler snapshot | IntelliJ IDEA Documentation