土法炼钢兴趣小组的算法知识备份

eBPF 追踪实战:用 bpftrace 在生产环境找到那个慢请求

目录

凌晨两点,告警响了。

P99 延迟从 8ms 飙到了 500ms。但 P50 纹丝不动,Grafana 上的平均延迟图看起来一切正常。你打开日志——什么都没有,因为你的日志只记了错误,没记延迟。你加了个 timing middleware 重新部署——但问题消失了,因为它只在高负载下出现。

这就是经典的”观测者效应”困境:你越想看清它,它越不出现。

你需要的不是更多日志,而是一种不修改代码、不重启进程、不影响性能就能观测内核和应用行为的能力。这就是 bpftrace。

bpftrace 架构:从脚本到内核探针

如果你还不熟悉 eBPF 的基础概念——验证器、Map、hook 点,建议先读 eBPF:Linux 内核的隐藏武器。本文假设你知道 eBPF 是什么,重点放在怎么用 bpftrace 解决真实问题

一、bpftrace 是什么:和 BCC/perf 的关系

bpftrace 是一个高级追踪语言,灵感来自 awk 和 DTrace。它把 eBPF 的复杂性藏在了一行命令背后:

# 统计每个进程的 syscall 次数——就这么简单
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'

但”简单”不意味着”玩具”。bpftrace 背后是完整的 eBPF 编译管线:你的脚本会经过词法分析、语法解析、语义检查,最终通过 LLVM 编译为 eBPF 字节码,再由内核验证器审核后 JIT 执行。

那它和其他工具是什么关系?

工具 定位 适合场景 上手难度
bpftrace 高级追踪语言 一次性调查、快速诊断、延迟分析 ★★☆☆☆
BCC (Python/C) eBPF 工具库 编写可重复使用的追踪工具 ★★★☆☆
libbpf (C) 底层加载库 编写高性能、可部署的 eBPF 程序 ★★★★☆
perf 内核自带性能工具 CPU profiling、PMC 计数、火焰图采样 ★★★☆☆
ftrace 内核内置追踪框架 函数追踪、事件过滤、不需要额外安装 ★★★☆☆

简单说:bpftrace 是你的第一反应工具。就像你用 top 看 CPU、用 ss 看连接一样,bpftrace 是你看”内核里到底发生了什么”的第一选择。如果 bpftrace 搞不定(比如你需要长驻 daemon 或者复杂的数据结构),再换 BCC 或 libbpf。

安装非常简单:

# Ubuntu/Debian
sudo apt-get install -y bpftrace

# CentOS/RHEL 8+
sudo dnf install -y bpftrace

# 验证安装
bpftrace --version
# bpftrace v0.21.0

# 查看你的内核支持哪些探针
sudo bpftrace -l 'tracepoint:syscalls:*' | head -20

二、bpftrace one-liner 速查:进程、I/O、网络、调度

one-liner 是 bpftrace 的灵魂。以下是我在生产环境中反复使用的命令,每一条都能在 30 秒内给你有价值的信息。

进程与系统调用

# 1. 统计每个进程的 syscall 次数(找出谁在疯狂做系统调用)
sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
    @syscalls[comm] = count();
}'

# 输出示例:
# @syscalls[nginx]: 284721
# @syscalls[postgres]: 89432
# @syscalls[node]: 312897  ← 这家伙在干什么?
# 2. 追踪某个进程的所有 open() 调用(看它在打开什么文件)
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat /comm == "nginx"/ {
    printf("%s opened: %s\n", comm, str(args.filename));
}'
# 3. 统计新进程创建(谁在 fork 炸弹?)
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_execve {
    printf("%-6d %-16s %s\n", pid, comm, str(args.filename));
}'

磁盘 I/O

# 4. 按进程统计磁盘 I/O 大小(谁在疯狂写磁盘)
sudo bpftrace -e '
tracepoint:block:block_rq_issue {
    @bytes[comm] = sum(args.bytes);
}'

# 5. 磁盘 I/O 延迟分布(直方图,单位微秒)
sudo bpftrace -e '
tracepoint:block:block_rq_issue { @start[args.dev, args.sector] = nsecs; }
tracepoint:block:block_rq_complete /@start[args.dev, args.sector]/ {
    @usecs = hist((nsecs - @start[args.dev, args.sector]) / 1000);
    delete(@start[args.dev, args.sector]);
}'

网络

# 6. 追踪 TCP 连接建立(谁连了你的服务器)
sudo bpftrace -e '
kprobe:tcp_v4_connect {
    printf("%-6d %-16s → connecting...\n", pid, comm);
}
kretprobe:tcp_v4_connect /retval == 0/ {
    printf("%-6d %-16s → connected\n", pid, comm);
}'

# 7. 统计每个远端 IP 的 TCP 接收字节数
sudo bpftrace -e '
kprobe:tcp_recvmsg {
    @recv_bytes[ntop(((struct sock *)arg0)->__sk_common.skc_daddr)] = sum(arg2);
}'

CPU 调度

# 8. 追踪进程被调度到哪个 CPU(NUMA 问题排查)
sudo bpftrace -e '
tracepoint:sched:sched_switch {
    printf("cpu%-2d: %-16s → %-16s\n",
           cpu, args.prev_comm, args.next_comm);
}' | head -50

# 9. 统计每个进程的运行队列等待时间(调度延迟)
sudo bpftrace -e '
tracepoint:sched:sched_wakeup { @qtime[args.pid] = nsecs; }
tracepoint:sched:sched_switch /@qtime[args.next_pid]/ {
    @usecs[args.next_comm] = hist((nsecs - @qtime[args.next_pid]) / 1000);
    delete(@qtime[args.next_pid]);
}'

# 10. 找出哪些进程在用 futex(锁竞争的信号)
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_futex {
    @futex[comm, args.op & 0xf] = count();
}'

技巧:运行 bpftrace 后按 Ctrl+C 退出,它会自动打印所有聚合的 map(@ 变量)。这个设计非常优雅——你不需要写任何输出逻辑。

三、延迟直方图:用 hist() 和 lhist() 找到延迟分布

平均值是骗人的。如果你的请求延迟是”99% 在 1ms 以内,1% 在 500ms”,平均值会告诉你”6ms,看起来还行”。你需要的是分布

bpftrace 的 hist() 函数生成 power-of-2 直方图,lhist() 生成线性直方图。这是追踪延迟最有力的武器。

系统调用延迟分布

# 测量 read() 系统调用的延迟分布
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read {
    @start[tid] = nsecs;
}
tracepoint:syscalls:sys_exit_read /@start[tid]/ {
    @read_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

运行几秒后按 Ctrl+C,你会看到类似这样的输出:

@read_us:
[0]                  412 |@@@@@@@@@@@@                                        |
[1]                 1728 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2, 4)               893 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
[4, 8)               241 |@@@@@@@                                             |
[8, 16)               87 |@@                                                  |
[16, 32)              34 |@                                                   |
[32, 64)              12 |                                                    |
[64, 128)              3 |                                                    |
[128, 256)             0 |                                                    |
[256, 512)             1 |                                                    |
[512, 1K)              0 |                                                    |
[1K, 2K)               2 |                                                    |

看到了吗?大部分 read 在 1-4 微秒内完成,但有 2 个请求花了超过 1 毫秒。在高并发场景下,这 2 个请求可能就是你的 P99 延迟杀手。

线性直方图:更精确的区间

# 用 lhist() 看 0-1000us 范围内的精确分布
# lhist(value, min, max, step) - 线性分桶
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_read /@start[tid]/ {
    @read_detail = lhist((nsecs - @start[tid]) / 1000, 0, 1000, 50);
    delete(@start[tid]);
}'

# 输出:每 50us 一个桶,看得更清楚
# [0, 50)     3241 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
# [50, 100)    187 |@@                                                  |
# [100, 150)    43 |                                                    |
# ...

按进程名分组的延迟

# 哪个进程的 write() 延迟最高?
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_write { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_write /@start[tid]/ {
    @write_us[comm] = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

# 输出会按进程名分组显示直方图:
# @write_us[nginx]:
# [0]             1241 |@@@@@@@@@@@@@@@@@@@@@@@@|
# [1]             2891 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
# ...
#
# @write_us[postgres]:
# [1]              342 |@@@@@@@@@@                                          |
# [2, 4)          1879 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
# [4, 8)          2341 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@      |
# ...  ← postgres 的 write 明显更慢,可能是 fsync

关键洞察:直方图能告诉你平均值隐藏的真相。如果你看到双峰分布(bimodal),说明有两种不同的代码路径在影响同一个系统调用——一种快,一种慢。这是排查问题的黄金线索。

四、自定义 kprobe/uprobe:追踪你自己的函数

one-liner 很好,但有时你需要更精确的追踪。kprobe 让你在任意内核函数上设置断点,uprobe 让你追踪用户态程序的函数。这是 bpftrace 真正强大的地方。

kprobe:追踪 TCP 发送行为

# 追踪 tcp_sendmsg:谁在发数据,发了多少字节
sudo bpftrace -e '
kprobe:tcp_sendmsg {
    // arg0 = struct sock *, arg2 = size
    @send_bytes[comm] = sum(arg2);
    @send_sizes[comm] = hist(arg2);
}

interval:s:5 {
    print(@send_bytes);
    clear(@send_bytes);
}'

# 每 5 秒打印一次统计,持续监控

kprobe + kretprobe:测量内核函数执行时间

#!/usr/bin/env bpftrace
// 文件:tcp_sendmsg_latency.bt
// 用途:测量 tcp_sendmsg 的执行时间,找出慢的 TCP 发送

kprobe:tcp_sendmsg
{
    @start[tid] = nsecs;
    @size[tid] = arg2;
}

kretprobe:tcp_sendmsg
/@start[tid]/
{
    $delta_us = (nsecs - @start[tid]) / 1000;
    $sz = @size[tid];

    // 只关注超过 100us 的慢调用
    if ($delta_us > 100) {
        printf("[SLOW] %-16s pid=%-6d size=%-6d latency=%dus\n",
               comm, pid, $sz, $delta_us);
    }

    @latency = hist($delta_us);
    @by_proc[comm] = hist($delta_us);

    delete(@start[tid]);
    delete(@size[tid]);
}

END
{
    // Ctrl+C 时清理临时 map,只保留直方图
    clear(@start);
    clear(@size);
}

运行方式:

sudo bpftrace tcp_sendmsg_latency.bt

# 输出:
# [SLOW] nginx            pid=1234   size=4096   latency=2341us
# [SLOW] nginx            pid=1234   size=8192   latency=5123us
# ^C  ← 按 Ctrl+C 查看汇总直方图

uprobe:追踪用户态函数

uprobe 可以追踪任何用户态程序的函数,只要二进制文件里有符号表。这意味着你可以追踪 libc 的 malloc、你的 Go/Rust/C++ 应用的自定义函数,甚至 OpenSSL 的加解密调用。

# 追踪 libc malloc 的分配大小分布
sudo bpftrace -e '
uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc {
    @alloc_size = hist(arg0);
}
uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc /arg0 > 1048576/ {
    printf("[BIG ALLOC] %-16s pid=%-6d size=%d bytes\n",
           comm, pid, arg0);
}'

# 找出谁在做大内存分配——这往往是 GC 压力或内存碎片的根因
# 追踪 OpenSSL 的 SSL_read 延迟(排查 TLS 握手和解密开销)
sudo bpftrace -e '
uprobe:/usr/lib/x86_64-linux-gnu/libssl.so.3:SSL_read {
    @start[tid] = nsecs;
}
uretprobe:/usr/lib/x86_64-linux-gnu/libssl.so.3:SSL_read /@start[tid]/ {
    @ssl_read_us[comm] = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

追踪自定义应用函数

如果你的应用是用 C/C++ 编写并保留了符号表(-g 编译),你可以直接追踪你的函数:

# 假设你有一个 HTTP server,想追踪 handle_request 函数
sudo bpftrace -e '
uprobe:/opt/myapp/server:handle_request {
    @start[tid] = nsecs;
}
uretprobe:/opt/myapp/server:handle_request /@start[tid]/ {
    $us = (nsecs - @start[tid]) / 1000;
    if ($us > 10000) {
        // 超过 10ms 的请求,打印调用栈
        printf("[SLOW REQUEST] %dus\n", $us);
        print(ustack);
    }
    @req_latency = hist($us);
    delete(@start[tid]);
}'

注意:Go 程序的 uprobe 支持比较特殊。Go 的 goroutine 栈可以动态增长和迁移,而且 Go 的 ABI(参数传递方式)和 C 不同。Go 1.17+ 开始用寄存器传参,之前用栈传参。如果你要追踪 Go 应用,建议用 USDT(User-level Statically Defined Tracing)或者直接用 tracepoint。

五、Off-CPU 分析:那些不在 CPU 上的时间去哪了

这是我见过最多人忽略的分析维度。

你用 perf 做 CPU profiling,发现 CPU 利用率只有 30%,但请求延迟很高。为什么?因为你的程序大部分时间不在 CPU 上运行——它在等锁、等 I/O、等网络、等页面调入。这些时间不会出现在 on-CPU 火焰图里。

On-CPU vs Off-CPU

分析类型 看到的是 看不到的是 典型工具
On-CPU 函数执行时间(CPU 在忙什么) 等待时间(CPU 在等什么) perf, profile
Off-CPU 线程阻塞时间(为什么不运行) CPU 计算时间 bpftrace, offcputime

如果你的应用是 CPU-bound(如编解码、加密运算),on-CPU profiling 就够了。但大部分 Web 服务是 I/O-bound——off-CPU 分析才是关键。

关于为什么延迟分析如此反直觉,这和 CPU 的流水线、缓存一致性协议有关。如果你对多核环境下的时间测量感兴趣,可以看 Memory Barriers:在多核战场上排兵布阵,里面有 TSC(Time Stamp Counter)在多核下漂移的实战案例。

用 bpftrace 做 Off-CPU 分析

#!/usr/bin/env bpftrace
// 文件:offcpu.bt
// 用途:记录线程离开 CPU 到重新上 CPU 的时间 + 内核栈

tracepoint:sched:sched_switch
{
    // 记录被切走的线程的时间戳
    if (args.prev_state != 0) {
        // prev_state != 0 表示非自愿切换(被阻塞了)
        @off[args.prev_pid] = nsecs;
    }
}

tracepoint:sched:sched_switch
/@off[args.next_pid]/
{
    // 被切回来了,计算 off-cpu 时间
    $delta_us = (nsecs - @off[args.next_pid]) / 1000;

    // 只关注超过 1ms 的 off-cpu 事件
    if ($delta_us > 1000) {
        @stacks[kstack, comm] = sum($delta_us);
    }

    @off_cpu_us[comm] = hist($delta_us);
    delete(@off[args.next_pid]);
}

END
{
    clear(@off);
}
sudo bpftrace offcpu.bt

# 运行 30 秒后 Ctrl+C,查看结果
# @stacks 会显示哪些内核调用栈导致了长时间 off-CPU
# 常见的罪魁祸首:

# 1. 锁竞争
# schedule() → __schedule() → schedule_preempt_disabled()
#   → __mutex_lock_slowpath() → mutex_lock()  ← 等互斥锁
#   → your_function()

# 2. I/O 等待
# schedule() → io_schedule() → blk_mq_get_tag()
#   → __blkdev_direct_IO() → ext4_file_write_iter()  ← 等磁盘

# 3. 网络等待
# schedule() → sk_wait_data() → tcp_recvmsg()  ← 等对端数据

# 4. 页面错误
# schedule() → wait_on_page_locked()
#   → __do_page_fault()  ← 等页面从磁盘调入

生成 Off-CPU 火焰图

off-cpu 数据最直观的展示方式是火焰图。你可以用 bpftrace 导出栈数据,然后用 Brendan Gregg 的 FlameGraph 工具生成:

# 第一步:用 bpftrace 收集 off-cpu 栈(折叠格式)
sudo bpftrace -e '
tracepoint:sched:sched_switch {
    if (args.prev_state != 0) { @off[args.prev_pid] = nsecs; }
}
tracepoint:sched:sched_switch /@off[args.next_pid]/ {
    $us = (nsecs - @off[args.next_pid]) / 1000;
    if ($us > 100 && args.next_comm == "myapp") {
        @[kstack] = sum($us);
    }
    delete(@off[args.next_pid]);
}' -d 2>/dev/null | grep -E "^@" > offcpu_stacks.txt

# 第二步:转换为火焰图(需要 FlameGraph 工具)
# git clone https://github.com/brendangregg/FlameGraph.git
cat offcpu_stacks.txt | \
    FlameGraph/stackcollapse-bpftrace.pl | \
    FlameGraph/flamegraph.pl \
        --title "Off-CPU Flame Graph" \
        --colors io \
        --countname "microseconds" \
    > offcpu_flamegraph.svg

Off-CPU 常见元凶速查

症状 内核栈关键函数 可能原因 排查方向
等互斥锁 __mutex_lock_slowpath 锁竞争激烈 减小临界区、换读写锁
等自旋锁 native_queued_spin_lock_slowpath 自旋锁持有时间过长 检查中断上下文持锁
等磁盘 I/O io_schedule, blk_mq_get_tag 磁盘 IOPS 饱和 iostat 确认、考虑异步 I/O
等网络数据 sk_wait_data, inet_csk_wait_for_connect 对端慢或网络延迟 tcpdump / ss 确认
等 futex futex_wait_queue 用户态锁/条件变量竞争 检查应用层锁设计
等 epoll ep_poll 正常等待事件(通常无害) 确认 timeout 设置合理
等页面 __do_page_fault, wait_on_page_locked 内存压力、swap 使用 free -h / vmstat 确认

真实案例:某次排查 Go 服务的 P99 延迟抖动,on-CPU profiling 显示 runtime.mcall 占比很高但看不出原因。换用 off-CPU 分析后发现:大量 goroutine 在 futex_wait_queue 上阻塞,根因是 sync.Mutex 在高并发下的竞争。最终方案:将全局锁拆分为分片锁(sharded lock),P99 从 200ms 降到 8ms。

六、生产环境注意事项

bpftrace 很强大,但在生产环境使用任何追踪工具都需要谨慎。以下是我踩过的坑。

性能开销

bpftrace 的开销取决于探针触发频率,而不是脚本复杂度。

# 低开销:tracepoint 触发频率低
sudo bpftrace -e 'tracepoint:block:block_rq_complete { @bytes = hist(args.bytes); }'
# 大约每秒触发几千次,开销 < 1%

# 中等开销:追踪高频系统调用
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_read { @[comm] = count(); }'
# 高负载下每秒触发几十万次,开销 1-5%

# 高开销(危险!):追踪调度器事件
sudo bpftrace -e 'tracepoint:sched:sched_switch { @[kstack] = count(); }'
# 每秒触发几百万次 + 每次采栈,开销 10-30%
# 生产环境慎用!

黄金法则

  1. 先用 -l 估算探针数量bpftrace -l 'kprobe:tcp_*' | wc -l
  2. interval:s:1 { print(@); clear(@); } 观察触发频率
  3. 加过滤条件减少触发/comm == "myapp"//pid == 12345/
  4. 设置超时timeout 30 bpftrace -e '...',避免忘记关闭
  5. 避免在高频路径上采内核栈(kstack 每次调用约 5-10us)

内核版本要求

功能 最低内核版本 说明
bpftrace 基础功能 4.9 kprobe, tracepoint, 基本 map
uprobe 4.17 用户态函数追踪
BTF (CO-RE) 5.2 跨内核版本兼容,不需要内核头文件
bpf_ringbuf 5.8 更高效的数据传输通道
kfunc 5.18 类型安全的内核函数追踪
bpf_loop 5.17 支持有界循环
usdt 需要 bpftrace 0.14+ User-level SDT 探针
# 检查你的内核版本
uname -r
# 5.15.0-92-generic  ← 这个版本支持 BTF 但不支持 kfunc

# 检查 BTF 支持
ls /sys/kernel/btf/vmlinux && echo "BTF supported" || echo "No BTF"

# 检查当前内核支持的 eBPF 特性
sudo bpftrace --info 2>/dev/null | head -30

安全性考量

eBPF 的验证器提供了强大的安全保证(详见 eBPF:Linux 内核的隐藏武器 中的验证器章节),但仍有一些需要注意的点:

# bpftrace 默认需要 root 或 CAP_BPF + CAP_PERFMON
# 不要把 bpftrace 的 suid 位设给普通用户!

# 推荐做法:用 capabilities 控制权限
sudo setcap cap_bpf,cap_perfmon=ep /usr/bin/bpftrace

# 或者用 unprivileged_bpf_disabled 限制非特权用户
cat /proc/sys/kernel/unprivileged_bpf_disabled
# 1 = 只有 root 可以加载 BPF 程序(推荐生产环境设置)
# 2 = 完全禁止非特权 BPF

安全 vs 风险对照

安全保证 潜在风险
验证器保证不会 crash 内核 kprobe 挂载到被 inline 的函数会失败
内存访问边界检查 高频探针可能导致性能下降
程序有指令数量上限 BPF 辅助函数可能引入微小延迟
自动清理:bpftrace 退出时探针自动卸载 如果 bpftrace 进程被 OOM kill,探针可能残留

实用调试技巧

# 调试 bpftrace 脚本本身——打印 AST 和 LLVM IR
sudo bpftrace -d -e 'kprobe:tcp_sendmsg { @[comm] = count(); }' 2>&1 | head -50

# 列出所有可用的 tracepoint
sudo bpftrace -l 'tracepoint:*' | wc -l
# 通常有 1800+ 个

# 查看某个 tracepoint 的参数结构
sudo bpftrace -lv 'tracepoint:syscalls:sys_enter_read'
# tracepoint:syscalls:sys_enter_read
#     int __syscall_nr;
#     unsigned int fd;
#     char * buf;
#     size_t count;

# 查看内核函数的参数(需要 BTF)
sudo bpftrace -lv 'kprobe:tcp_sendmsg'

生产环境部署注意事项

上面的”性能开销”只是冰山一角。真正在生产跑长时间 bpftrace 脚本时,还有几个容易被忽略的坑:

CPU 开销会随时间累积。 一个”看起来没什么”的脚本,如果探针触发频率高,跑几个小时后你会在 perf top 里看到 bpf_prog_* 占了好几个百分点。经验法则:长驻脚本的探针触发频率不要超过 10K/s,否则 CPU 开销会从”可忽略”滑向”不可接受”。

# 监控你的 bpftrace 脚本对 CPU 的实际影响
# 在另一个终端运行:
pidstat -p $(pgrep -f bpftrace) 1
# 关注 %usr + %system,超过 5% 就要警惕

采样频率权衡。profile:hz:99 做 CPU profiling 时,99 Hz 是经典选择(避开与 100 Hz 时钟对齐)。但如果你只是想看趋势,profile:hz:49 甚至 profile:hz:19 就够了,开销降一半以上。别默认拉到 999 Hz——那是给短期诊断用的,不是给长驻监控用的。

# 低采样频率的长期 CPU profiling(适合跑几小时)
sudo bpftrace -e 'profile:hz:49 /comm == "myapp"/ { @[kstack] = count(); }'

# 高采样频率的短期精确诊断(最多跑 30 秒)
sudo timeout 30 bpftrace -e 'profile:hz:999 /pid == 12345/ { @[ustack] = count(); }'

Map 大小会增长。 每个 @ 变量背后是一个 eBPF Map,默认 max_entries 通常是 4096 或更多。如果你的 key 是高基数的(比如 @[pid, tid, kstack]),Map 会持续增长直到触及上限,然后新 key 静默丢失。更糟的是,大 Map 占内存,bpftrace 退出时打印 Map 内容也会卡很久。

# 用 interval{} 定期清理 Map,防止无限增长
sudo bpftrace -e '
kprobe:tcp_sendmsg {
    @by_proc[comm] = sum(arg2);
}
interval:s:30 {
    print(@by_proc);
    clear(@by_proc);  // 每 30 秒清零重来
}'

-c--unsafe 的使用纪律:

# 推荐:用 -c 自动限定追踪范围和生命周期
sudo bpftrace -c '/opt/myapp/server --config prod.yaml' -e '
uprobe:/opt/myapp/server:handle_request {
    @start[tid] = nsecs;
}
uretprobe:/opt/myapp/server:handle_request /@start[tid]/ {
    @latency = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

# 危险:永远不要在生产这么干
# sudo bpftrace --unsafe -e '... system("rm -rf /something") ...'

常见反模式

在帮团队排查过几十次”bpftrace 把机器搞慢了”之后,我总结了这些反模式。每一条都是真实踩过的坑。

反模式 1:滥用 bpf_printk / printf

bpf_printk() 写的是内核的 trace buffer(perf ring buffer),printf() 在 bpftrace 里最终也走类似通道。高频触发时,ring buffer 会饱和,导致事件丢失甚至影响其他 tracing 工具。

# ❌ 错误:每个包都 printf,高负载下每秒几十万次输出
sudo bpftrace -e '
kprobe:tcp_sendmsg {
    printf("pid=%d comm=%s size=%d\n", pid, comm, arg2);  // 刷爆 ring buffer
}'

# ✅ 正确:用 Map 聚合,只在需要时打印
sudo bpftrace -e '
kprobe:tcp_sendmsg {
    @send[comm] = sum(arg2);
    @sizes[comm] = hist(arg2);
}
interval:s:5 {
    print(@send);
    clear(@send);
}'

判断标准:如果你的 printf 触发频率可能超过 1000 次/秒,换成 Map 聚合。

反模式 2:高基数 Map Key 导致内存膨胀

# ❌ 错误:用 (pid, tid, kstack) 做 key,基数爆炸
sudo bpftrace -e '
tracepoint:sched:sched_switch {
    @[args.prev_pid, args.next_pid, kstack] = count();
    // 每一种 (prev_pid, next_pid, 内核栈) 组合都是一个 Map 条目
    // 跑几分钟后 Map 可能有几十万条目 → 内存暴涨 + 退出时打印卡死
}'

# ✅ 正确:降低 key 基数,只关注你需要的维度
sudo bpftrace -e '
tracepoint:sched:sched_switch /args.prev_comm == "myapp"/ {
    @[kstack] = count();  // 只按栈聚合,过滤特定进程
}'

反模式 3:缺少 interval{} 清理导致 Map 无限增长

长驻脚本如果不定期清理 Map,条目数会持续增长直到 max_entries 上限。更隐蔽的是,即使 key 不再活跃(比如已退出的进程 PID),它的条目还在 Map 里占着空间。

# ❌ 错误:跑了 3 天的脚本,Map 里全是已经不存在的进程
sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
    @[comm, pid] = count();
    // pid 会不断新增(新进程),老的永远不删
}'

# ✅ 正确:定期快照并清理
sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
    @[comm, pid] = count();
}
interval:s:60 {
    print(@);
    clear(@);  // 每分钟重置,只看最近一分钟的活跃情况
}'

反模式 4:在热路径 kprobe 上不加过滤

内核里有些函数每秒被调用几百万次(比如 __schedule_raw_spin_lockpage_fault)。无条件地挂 kprobe 上去,等于给每次调用都加了一个函数调用的开销——几百万次/秒 × 几百纳秒 = 显著的 CPU 占用

# ❌ 危险:__schedule 是内核最热的路径之一
sudo bpftrace -e 'kprobe:__schedule { @[kstack] = count(); }'
# 每次调度切换都采栈 → CPU 开销可达 20-30%

# ✅ 正确:加过滤条件,只追踪你关心的进程/场景
sudo bpftrace -e '
kprobe:__schedule /comm == "myapp"/ {
    @[kstack] = count();
}
interval:s:10 {
    print(@);
    clear(@);
}'
# 只追踪 myapp 的调度事件 → 开销从 20% 降到 < 1%

经验法则:挂 kprobe 之前,先用 sudo bpftrace -e 'kprobe:你的函数 { @cnt = count(); } interval:s:1 { print(@cnt); clear(@cnt); }' 看一下触发频率。超过 100K/s 的,必须加过滤条件

总结

bpftrace 是我工具箱里使用频率最高的诊断工具,没有之一。它让你在不修改一行代码的情况下,看到内核和应用内部的一切。总结一下核心要点:

场景 推荐方法 关键函数/探针
系统调用延迟 hist() + syscall tracepoint sys_enter_*/sys_exit_*
TCP 网络问题 kprobe 追踪内核 TCP 函数 tcp_sendmsg, tcp_recvmsg
内存分配分析 uprobe 追踪 libc malloc uprobe:libc:malloc
P99 延迟分析 延迟直方图 + 条件过滤 hist(), lhist()
进程被阻塞 Off-CPU 分析 + 内核栈 sched_switch + kstack
锁竞争 Off-CPU 火焰图 futex_wait, mutex_lock
磁盘 I/O 慢 block tracepoint block_rq_issue/complete

记住这个排查流程:

  1. 先用 one-liner 快速定位方向(是 CPU 的问题?I/O?网络?锁?)
  2. 用 hist() 看延迟分布(是均匀慢还是长尾?)
  3. 如果是长尾,加条件过滤只抓慢请求if ($us > 10000) { print(kstack); }
  4. 如果 on-CPU 看不到问题,换 off-CPU 分析
  5. 用火焰图可视化内核栈

最后一个忠告:不要在生产环境上跑没测过的 bpftrace 脚本。先在 staging 跑一遍,确认开销可控,再上生产。

bpftrace 不是银弹,但它是我见过最接近”x-ray vision”的东西——它让你透视内核,而内核甚至不知道自己被观察了。


参考资料


By .