KernelRanch
返回文章列表

用 eBPF 拆开数据库 p99 长尾:bpftrace 三件套的判读矩阵

2026-11-09 #数据库

数据库侧指标(pg_stat_activity、pg_stat_statements)只能看到"语句在等什么",看不到"进程为什么没被调度"。p99 长尾的常见根因——CPU 排队、块设备尾延迟、fsync 聚集——都发生在内核,而 eBPF 是唯一能在生产上以可接受开销直接观测它们的手段。

0. bpftrace 是什么,官方怎么说

bpftrace 官方的定位是"面向 Linux 的通用追踪工具与语言",通过 eBPF 提供"强大、高效、开销极小"的追踪能力[1]。实现上它用 LLVM 作编译后端、libbpf 与内核 BPF 子系统交互,可挂载的探针包括[1]:

  • 内核动态追踪:kprobes、硬件/软件 perf events
  • 用户态动态追踪:uprobes、USDT
  • 静态追踪点:tracepoints(普通与 raw)

这套能力对数据库运维的意义很直接:不需要改应用、不需要重启数据库、不需要装内核模块,就能拿到"某个进程在某段时间里到底在等什么"。

发行版覆盖(官方 README 列出的打包目标[1]):Ubuntu 22.04 / 24.04 / 25.04 / 25.10、Debian 13 / 14、Fedora 42 / 43、Alpine 3.21 / 3.22、Arch、Gentoo、Nix、openSUSE Tumbleweed,以及每晚构建的 AppImage。

# Debian / Ubuntu
sudo apt install bpftrace
# RHEL 系
sudo dnf install bpftrace
# Alpine
sudo apk add bpftrace

官方特别提醒:用发行版包时,务必用 bpftrace --version 核对版本再对照文档,因为语言特性与工具脚本会随版本变化[1]。

bpftrace --version
# 探针是否在当前内核上可用,先列出来再写脚本
sudo bpftrace -l 'tracepoint:block:*' | head

1. 三条判定路径:先分清"哪种慢"

长尾不是一种病。按等待发生的位置,可以分成三类,每一类对应不同的工具组合:

症状内核层假设首选工具判读特征
CPU 使用率不高,但查询 p99 抖动、吞吐上不去进程可运行但抢不到 CPUrunqlat.bt、runqlen.btrunqueue 延迟直方图右尾厚(出现 ms 级)
写入/提交变慢,fsync 相关等待升高块设备尾延迟或 fsync 聚集biolatency.bt、biosnoop.bt、biostacks.bt、syncsnoop.bt块 I/O 直方图出现 10ms+ 的独立峰;syncsnoop 呈周期性爆发
读放大、脏页回写拖慢查询回写(writeback)与下刷节奏问题writeback.bt、syncsnoop.bt、bitesize.bt回写集中在 checkpoint 附近,与慢查询时间窗重合

这三个工具(runqlat.bt、biolatency.bt、biosnoop.bt、biostacks.bt、bitesize.bt、runqlen.bt、writeback.bt、syncsnoop.bt)都在 bpftrace 仓库的 tools/ 目录里,官方对它们的描述是"由维护者精选、经过实战检验、随 bpftrace 一起打包"[2]。这一点很重要:优先用这些脚本,而不是自己从零写探测逻辑——它们处理了跨内核版本的字段差异。

官方对每个脚本的一句话定义[2]:

脚本官方描述(原文要点)
biolatency.bt块 I/O 延迟的直方图
biosnoop.bt每次 I/O 的延迟(逐条)
biostacks.bt带初始化栈的磁盘 I/O 延迟
bitesize.bt磁盘 I/O 尺寸直方图
runqlat.btCPU 调度器 run queue 延迟直方图
runqlen.bt调度器 run queue 长度直方图
writeback.bt文件系统回写事件(含细节)
syncsnoop.btsync() 系列系统调用
naptime.bt主动睡眠调用
tcpretrans.btTCP 重传
tcpsynbl.btTCP SYN backlog 直方图

2. 路径一:CPU 排队(最常被误判为"数据库慢")

云主机上最常见的一幕:数据库 CPU 使用率 60%,看起来很健康,但 p99 抖动。原因是使用率不等于可用性——进程被唤醒后要在 run queue 里排队,等多久取决于同宿主机上还有谁在跑。

# 观察调度器排队延迟分布(Ctrl-C 结束并输出直方图)
sudo ./runqlat.bt

判读:

  • 直方图集中在微秒级 → 调度健康,问题不在 CPU。
  • 出现明显的毫秒级(1000µs 以上)右尾 → 存在 CPU 争抢。此时 pg_stat_activity 里看到的 wait_event_type = 'CPU' 或干脆"在跑但很慢"就解释得通了。

runqlen.bt 是它的补充:长度直方图告诉你"有多少个可运行任务在等",配合 nproc 就能判断是否超卖。

这类问题的正确解法不是调数据库参数,而是:限制同宿主机上的邻居负载、给数据库绑核(taskset/cgroup cpuset)、或换到独占型实例。db 参数在这里几乎无效。

3. 路径二:块设备尾延迟

写完一条事务,PostgreSQL 需要保证 WAL 落盘(fsync)。如果块设备的 p99 延迟是 20ms 而 p50 是 0.2ms,那么每秒几百次提交里就会有若干次被拖到 20ms——这正好是"平均延迟正常、p99 很难看"的典型形态。

# 块 I/O 延迟直方图
sudo ./biolatency.bt

# 需要逐条看是哪个进程、多大 IO、多长延迟
sudo ./biosnoop.bt

# 需要知道这条 IO 是谁发起的(调用栈)
sudo ./biostacks.bt

biolatency.bt 的价值在于形状:健康的 SSD 是单峰,接近对数正态;一旦出现第二个峰(典型在 1ms、4ms、10ms、100ms 附近),说明存在另一条慢路径(队列拥塞、RAID 写惩罚、云盘限流)。

biosnoop.bt 用来把"慢"定位到具体进程与 I/O 尺寸:如果慢 I/O 全部来自 postgres 且尺寸是 8kB(一个数据页),问题在随机写放大;如果尺寸是 128kB 以上且来自 checkpointer,问题在下刷节奏。

biostacks.bt 更狠——它带调用栈,能直接回答"这条慢 I/O 是 checkpoint 触发的,还是某个查询的 buffer miss 触发的"。这也是它开销最大的原因:每条 I/O 都要采栈,只适合短时间窗。

自己写一条最小探针

不依赖脚本时,从"按进程统计块 I/O 次数"这种低风险聚合开始:

# 按进程统计块 I/O 提交次数
sudo bpftrace -e 'tracepoint:block:block_rq_issue { @[comm] = count(); }'

# 块 I/O 尺寸分布(args->bytes 为该 tracepoint 的字段)
sudo bpftrace -e 'tracepoint:block:block_rq_issue { @bytes = hist(args->bytes); }'

# x86_64 上 vfs_read 的第二个参数是读取字节数
sudo bpftrace -e 'kprobe:vfs_read { @[comm] = hist(arg2); }'

注意最后一条:arg2 的含义依赖架构与内核版本(x86_64 上 vfs_read(file, buf, count, pos) 的 count 是第三个参数即 arg2)。上生产前先用 bpftrace -l 与你内核的源码//proc/kallsyms 核对,或改用 tracepoint(字段名稳定得多)而不是 kprobe 的裸参数。

4. 路径三:fsync 聚集与回写节奏

这是 PostgreSQL 类数据库最"结构性"的长尾来源:checkpoint 与后台写进程的节奏如果和业务高峰重叠,就会出现周期性的延迟尖峰。

# 调用 sync() 系列的位置与频率
sudo ./syncsnoop.bt

# 文件系统回写事件细节
sudo ./writeback.bt

判读要点:

  • syncsnoop 的输出是否呈周期性爆发。如果每隔约 checkpoint_timeout 出现一簇,说明尖峰来自 checkpoint 尾部。
  • writeback 的时间窗是否与慢查询重合。重合则说明脏页回写正在和查询抢带宽。

对应的数据库侧旋钮(默认值见 PostgreSQL 18 文档 19.4 节[3]):

现象可调参数默认值(PG 18)
checkpoint 尾部 fsync 抖动bgwriter_flush_afterLinux 512kB,其他平台 0
单后端写入过多导致内核脏页堆积backend_flush_after0(关闭)
VACUUM 抢带宽vacuum_buffer_usage_limit2MB(范围 128kB–16GB,且被压到 shared_buffers 的 1/8)
一次写太多导致 checkpoint 更重max_wal_size见 19.5 节

这里的因果关系值得说清楚:bgwriter_flush_after 的作用是"后台写进程累计写满该量后,强制内核把这些写下发到存储,减少 checkpoint 结束时 fsync 的卡顿"[3]。所以当你用 eBPF 看到"慢集中在 checkpoint 尾",调它才有意义;如果慢是均匀分布的,改它没用。

5. 一次完整的排查顺序

按成本从低到高,每一步都可能提前结束:

  1. runqlat.bt(30 秒)→ 有毫秒级右尾?有 → 走 CPU 争抢路线,结束。
  2. biolatency.bt(30 秒)→ 直方图是否双峰/厚尾?是 → 第 3 步。
  3. biosnoop.bt(10 秒)→ 慢 I/O 集中在哪个进程、什么尺寸、什么方向(读/写)?
  4. biostacks.bt(5 秒,开销最大)→ 慢 I/O 的调用栈来自 checkpoint、autovacuum 还是用户查询?
  5. syncsnoop.bt + writeback.bt(30 秒)→ 是否与 checkpoint 周期对齐?
  6. 回到数据库侧,用 pg_stat_statements 确认"受影响的语句"和"受影响的表",再动参数。

第 6 步不属于 eBPF,但缺了它就会变成"内核指标很好看、业务还是慢"。配合方法见同目录《pg_stat_statements 三步定位慢查询》。

6. 生产可用的边界

eBPF 很强,但不是免费的,也不是无风险的:

风险表现控制手段
逐事件输出打爆磁盘/终端biosnoop.bt 在高 IOPS 机器上每秒数万行只用聚合类脚本(hist/count),或用 head -n 200 限流
采栈开销biostacks.bt 每条 I/O 采栈,CPU 占用上升时间窗 ≤ 10 秒,避开业务高峰
探针名不匹配报 No probes to attach先 bpftrace -l 'tracepoint:block:*' 确认
权限非 root 无法加载 BPF 程序需要 root 或 CAP_BPF+CAP_PERFMON
容器内不可用缺少 /sys/kernel/debug 或 seccomp 限制在宿主机侧执行,而不是容器内

一条硬性纪律:任何逐事件脚本都不允许在没有时间限制的情况下跑在生产主库上。用 timeout 兜底:

sudo timeout 15 ./biosnoop.bt > /tmp/biosnoop.txt 2>&1

7. 本机实测验证(2026-11-09)

第 3 节那三条 bpftrace -e 不是凭记忆写的——我在本机把它们逐条跑了一遍,确认语法与字段可用。

7.1 环境

项值
宿主机Windows 11 + Docker Desktop 29.4.3(WSL2 内核)
bpftracev0.23.2(apt install bpftrace,Debian 容器内)
BTF/sys/kernel/btf/vmlinux 存在 ✅
容器权限--privileged --pid=host,并挂载 /sys/kernel/debug
被观测负载同机运行的 PostgreSQL 18.6(正在写 WAL)

7.2 三条命令的真实输出

① 按进程统计块 I/O 提交次数

bpftrace -e 'tracepoint:block:block_rq_issue { @[comm] = count(); } interval:s:3 { exit(); }'
Attaching 2 probes...
@[postgres]: 10

comm 字段直接给出进程名,postgres 出现 10 次——正好是我们那个正在写 WAL 的实例。这条命令用来做"谁在打盘"的第一层筛选,成本极低。

② 块 I/O 尺寸直方图(args->bytes)

bpftrace -e 'tracepoint:block:block_rq_issue { @bytes = hist(args->bytes); } interval:s:3 { exit(); }'
@bytes:
[0]                    2 |@@@@@@@@@@@@@@@@@@@@                                |
[1]                    0 |                                                    |
[2, 4)                 0 |                                                    |
[4, 8)                 0 |                                                    |
...

注意第一行是 [0]——args->bytes 为 0 的请求确实存在(部分请求在 issue 阶段尚未带尺寸)。这提醒你:用 bytes 求和时会偏低,做容量核算时应改用 block_rq_complete 或结合 bitesize.bt。

③ kprobe:vfs_read 的 arg2

bpftrace -e 'kprobe:vfs_read { @[comm] = hist(arg2); } interval:s:3 { exit(); }'
@[wsl-bootstrap]:
[8, 16)                1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
...
[4K, 8K)               2 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|

读尺寸集中在 4K–8K,与页大小一致,说明 arg2(x86_64 上的 count)取到的确实是字节数,而不是别的参数。

7.3 没做的部分(避免误导)

  • 没有跑 biolatency.bt / biostacks.bt 并给出分布。原因很实在:本机是 WSL2 虚拟磁盘,块设备事件稀疏(3 秒内只有 10 次),直方图没有统计意义。用这种数据去谈"p99 块延迟"就是伪基准。
  • 没有在真实数据库长尾场景里做全流程排查。第 5 节的排查顺序是基于工具能力与文档语义推导的,不是一次真实事故复盘。

如果你要在自己环境验证,最小可用命令是这个(timeout 兜底,避免忘关):

sudo timeout 15 bpftrace -e 'tracepoint:block:block_rq_issue { @[comm] = count(); }'

7.4 真机复测:拿到真实 I/O 延迟直方图(2026-11-10,Ubuntu 24.04)

7.3 说过"WSL2 上块 I/O 太稀疏,直方图没有统计意义"。我在一台 Ubuntu 24.04 / 内核 6.8.0 / 1 vCPU / 961MB 的 KVM 机器上补做了:

bpftrace --version          # v0.20.2
cat /proc/sys/kernel/io_uring_disabled   # 0(io_uring 可用)

用 dd 顺序读一个 1.2GB 文件制造真实磁盘 I/O,同时挂探针:

① 按进程统计块 I/O 提交次数(6 秒窗口)

bpftrace -e 'tracepoint:block:block_rq_issue { @io[comm] = count(); } interval:s:6 { exit(); }'
@io[ionice]: 1
@io[jbd2/vda1-8]: 1
@io[kworker/u2:1]: 4
@io[kswapd0]: 144
@io[kworker/0:1H]: 307
@io[apt-check]: 1588
@io[dd]: 5590

这张表本身就是一课:你以为只有 dd 在打盘,实际上 apt-check、kworker、甚至 kswapd0 都在参与。kswapd0 出现 144 次说明这台 961MB 的机器在换页——这些 I/O 会污染你对"数据库慢"的归因。这就是为什么排查需要按进程分离,而不是看整机 IOPS。

② 真实块 I/O 延迟直方图(biolatency.bt,8–10 秒)

curl -fsSL -o biolatency.bt \
  https://raw.githubusercontent.com/bpftrace/bpftrace/release/0.20.x/tools/biolatency.bt
timeout 10 bpftrace biolatency.bt
@usecs:
[64, 128)            123 |@                                                   |
[128, 256)          2868 |@@@@@@@@@@@@@@@@@@@@@@@@                            |
[256, 512)          6148 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[512, 1K)            282 |@@                                                  |
[1K, 2K)              65 |                                                    |
[2K, 4K)              59 |                                                    |
[4K, 8K)              20 |                                                    |
[8K, 16K)              2 |                                                    |

这就是一块健康 SSD 上的真实形状:单峰,峰值 256–512µs;p99 附近开始出现 1–4ms;最深尾部到 8–16ms(2 个 I/O)。把它和数据库侧的 p99 对齐——如果查询 p99 是 15ms 而块 I/O 尾部只有 8ms,那剩下的时间不在磁盘上。

③ 一个要记下来的坑:不要自己用 issue/complete 配对算延迟

我第一版自己写了 block_rq_issue 记 nsecs、block_rq_complete 相减的脚本,结果输出是这样的:

@start[265289728, 35824640]: 1476602771092359
@start[265289728, 2126208]: 1476602805212406
...

@start 里积压了成百上千条没被清掉的记录——因为完成事件的 sector 值与提交事件不是一一对应的(合并、拆分、重试都会改变它),用 (dev, sector) 做 key 必然漏配对。这正是官方 biolatency.bt 存在的意义:它用更可靠的方式关联。结论:算延迟用官方工具,自己写只做计数。

④ 顺带确认:bytes=0 的请求真实存在

jbd2/vda1-8 pid=197 bytes=4096
jbd2/vda1-8 pid=197 bytes=114688
kworker/0:1H pid=43 bytes=0
kworker/0:1H pid=43 bytes=4096

bytes=0 是 flush/FUA 这类无数据载荷的请求。这验证了 7.2 节的提醒:用 args->bytes 求和会系统性偏低。

⑤ 工具脚本不一定随包安装

这台机器装了 bpftrace 包,但 /usr/share/bpftrace/tools/ 并不存在,所以 biolatency.bt 要按上面的方式从 GitHub 对应 release 标签取。注意标签要和版本匹配(0.20.2 取 release/0.20.x),否则语法可能不兼容。

8. 小结

这三条是排查顺序,不是并列结论。

pg_stat_activity 能看到语句在等锁还是等 I/O,但看不到进程为什么没被调度。这一段空白只能靠 eBPF 补。

真要动手,先跑 runqlat.bt 和 biolatency.bt 各 30 秒。多数长尾在这两步就能定性:是 CPU 排队,还是块设备的尾部延迟。

写探针时优先用官方 tools/ 里的脚本,它们是维护者长期维护、随版本一起打包的("经过实战检验"[2])。自己写就从 tracepoint 加聚合函数起步,别一上来就采栈——采栈会把一台正常的机器压出新的长尾。


数据来源与检索时间

检索时间统一为 2026-11-09(Asia/Shanghai)。

编号来源URL对应数据
[1]bpftrace 官方仓库 README(master 分支原始文件)https://raw.githubusercontent.com/bpftrace/bpftrace/master/README.md定位为"通用 Linux 追踪工具与语言","借助 eBPF 提供强大、高效、开销极小的追踪";LLVM 编译后端 + libbpf;探针类型 kprobes / perf events / uprobes / USDT / tracepoints(普通与 raw);发行版打包目标 Ubuntu 22.04·24.04·25.04·25.10、Debian 13·14、Fedora 42·43、Alpine 3.21·3.22、Arch、Gentoo、Nix、openSUSE Tumbleweed、AppImage nightly;"务必用 bpftrace --version 核对版本"
[2]bpftrace 官方 tools/README.md(master 分支原始文件)https://raw.githubusercontent.com/bpftrace/bpftrace/master/tools/README.md工具由维护者精选、"经过实战检验、随 bpftrace 打包";biolatency.bt(块 I/O 延迟直方图)、biosnoop.bt(逐 I/O 延迟)、biostacks.bt(带初始化栈的 I/O 延迟)、bitesize.bt(I/O 尺寸直方图)、runqlat.bt(调度器 run queue 延迟直方图)、runqlen.bt(run queue 长度直方图)、writeback.bt(回写事件)、syncsnoop.bt(sync 系列)、naptime.bt、tcpretrans.bt、tcpsynbl.bt 的官方一句话描述
[3]PostgreSQL 18 文档《19.4 Resource Consumption》https://www.postgresql.org/docs/18/runtime-config-resource.htmlbgwriter_flush_after 默认 Linux 512kB/其他 0 及其"减少 checkpoint 末尾 fsync 卡顿"的作用描述;backend_flush_after 默认 0、范围 0–2MB;vacuum_buffer_usage_limit 默认 2MB、范围 128kB–16GB、被压到 shared_buffers 1/8

核验说明:工具名称、官方描述、探针类型、发行版清单均逐条比对上述原始文件(2026-11-09)。

第 7 节为本机实测(bpftrace v0.23.2,Docker/WSL2,2026-11-09):

  • 三条 bpftrace -e 命令逐条实跑,输出为真实终端内容(仅裁剪行数);
  • 确认了 tracepoint:block:block_rq_issue 的 args->bytes 字段可用,且存在 bytes=0 的样本,这是正文提醒"不要直接用 bytes 求和"的依据;
  • 确认了 x86_64 上 kprobe:vfs_read 的 arg2 为读取字节数。

没做:biosnoop.bt / biostacks.bt 等脚本未运行(WSL2 阶段块 I/O 过稀疏)。但 7.4 节已在一台 Ubuntu 24.04 / 内核 6.8 的 KVM 机器上用 biolatency.bt 取得了真实 I/O 延迟直方图(峰值 256–512µs,尾部 8–16ms),并记录了一个自写探针的配对失败案例。第 1 节的判读特征仍只描述形状(单峰/双峰/厚尾/周期性),这是直方图本身能可靠呈现的信息。