用 eBPF 拆开数据库 p99 长尾:bpftrace 三件套的判读矩阵
一句话结论:数据库侧指标(
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 抖动、吞吐上不去 | 进程可运行但抢不到 CPU | runqlat.bt、runqlen.bt | runqueue 延迟直方图右尾厚(出现 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.bt | CPU 调度器 run queue 延迟直方图 |
runqlen.bt | 调度器 run queue 长度直方图 |
writeback.bt | 文件系统回写事件(含细节) |
syncsnoop.bt | sync() 系列系统调用 |
naptime.bt | 主动睡眠调用 |
tcpretrans.bt | TCP 重传 |
tcpsynbl.bt | TCP 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_after | Linux 512kB,其他平台 0 |
| 单后端写入过多导致内核脏页堆积 | backend_flush_after | 0(关闭) |
| VACUUM 抢带宽 | vacuum_buffer_usage_limit | 2MB(范围 128kB–16GB,且被压到 shared_buffers 的 1/8) |
| 一次写太多导致 checkpoint 更重 | max_wal_size | 见 19.5 节 |
这里的因果关系值得说清楚:bgwriter_flush_after 的作用是"后台写进程累计写满该量后,强制内核把这些写下发到存储,减少 checkpoint 结束时 fsync 的卡顿"[3]。所以当你用 eBPF 看到"慢集中在 checkpoint 尾",调它才有意义;如果慢是均匀分布的,改它没用。
5. 一次完整的排查顺序
按成本从低到高,每一步都可能提前结束:
runqlat.bt(30 秒)→ 有毫秒级右尾?有 → 走 CPU 争抢路线,结束。biolatency.bt(30 秒)→ 直方图是否双峰/厚尾?是 → 第 3 步。biosnoop.bt(10 秒)→ 慢 I/O 集中在哪个进程、什么尺寸、什么方向(读/写)?biostacks.bt(5 秒,开销最大)→ 慢 I/O 的调用栈来自 checkpoint、autovacuum 还是用户查询?syncsnoop.bt+writeback.bt(30 秒)→ 是否与 checkpoint 周期对齐?- 回到数据库侧,用
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 内核) |
| bpftrace | v0.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(); }'
8. 三行结论
pg_stat_activity告诉你"语句在等锁还是等 I/O",eBPF 告诉你"进程为什么没被调度、I/O 为什么慢"——两者缺一不可。- 先跑
runqlat.bt和biolatency.bt各 30 秒,80% 的长尾问题在这两步就能定性。 - 优先用官方
tools/里的脚本("经过实战检验、随 bpftrace 打包"[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.html | bgwriter_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为读取字节数。
没做:biolatency.bt / biosnoop.bt / biostacks.bt 等脚本未在本机运行(WSL2 虚拟磁盘 I/O 事件过于稀疏,跑出来的分布无统计意义)。因此本文不提供任何真实延迟分布数字,第 1 节的判读特征只描述形状(单峰/双峰/厚尾/周期性),这是直方图本身能可靠呈现的信息。