PostgreSQL学徒

深入浅出 bpftrace

前言

很早之前就想写一篇关于 bpftrace 分析 PostgreSQL 的文章了。

原因也很简单,日常排查 PostgreSQL 问题时,我们已经习惯了先看 pg_stat_activity、pg_stat_statements、pg_locks,再结合数据库日志、操作系统指标综合分析。大多数问题到这里基本就能有个眉目。

但是总有一些场景,让人看得云里雾里:

1.pg_stat_statements 里 SQL 很慢,但慢在 CPU、锁、IO,还是系统调用?2.明明 shared_blks_read 很高,但到底有没有真正落到内核 IO?3.wait_event 看到 LWLock,但到底卡在什么路径上?4.临时文件写了不少,但系统调用层面读写了多少?5.PostgreSQL 自己的统计视图看不到的东西,还有没有办法继续往下挖?

这时候 bpftrace 就很有意思了。

它不是替代 PostgreSQL 视图的工具,而是帮我们把观察范围从 PostgreSQL 内部继续往下打穿,打到系统调用、内核 tracepoint、用户态 USDT 探针、甚至函数调用栈。

以前看 Andres Freund、Dmitrii Dolgov 关于 perf/eBPF/bpftrace 的分享,觉得思路非常漂亮,但光看材料总有种“隔靴搔痒”的感觉。于是这次我专门准备了一台 Rocky Linux 9.5 云主机,把 PostgreSQL、bpftrace、pgbench 都跑起来,真实抓了一轮数据。(文章使用 CodeX + GPT 在真实环境上生成)

bpftrace 是什么

简单来说,bpftrace 是一个基于 eBPF 的动态追踪工具,语法有点像 awk + C 的混合体。你可以把一段小程序挂到某个事件上,当事件发生时执行统计、打印、聚合等动作。

它常用的探针大概有几类:

1.tracepoint:内核预定义的稳定事件,比如系统调用、块设备 IO。2.kprobe/kretprobe:动态挂到内核函数入口和返回处。3.uprobe/uretprobe:动态挂到用户态程序函数入口和返回处。4.usdt:用户态静态探针,PostgreSQL 就内置了一批 DTrace/USDT 探针。5.interval / END:定时或结束时输出聚合结果。

举个最简单的例子:

bpftrace -e 'tracepoint:syscalls:sys_exit_read /comm == "postgres"/ { @[pid] = count(); }'

这段脚本的意思就是:只要 postgres 进程退出 read 系统调用,就按 pid 计数。

看起来很简单,但它解决的是 PostgreSQL 自己不一定方便统计的问题。数据库内部视图如果把每一次 syscall、每一次 LWLock、每一次内核 IO 都统计进去,那开销和可移植性都会很麻烦。而 bpftrace 的价值在于:临时挂上去,观察一段时间,拿到证据,然后摘下来。

用完就走,不留感情包袱。

实验环境

这次实验环境如下:

OS      : Rocky Linux 9.5 (Blue Onyx)Kernel  : 5.14.0-503.26.1.el9_5.x86_64CPU     : 2 coresMemory  : 8GBPG      : PostgreSQL 13.23,Rocky 官方包bpftrace: v0.23.5

安装依赖:

dnf -y install bpftrace perf sysstat strace \  postgresql postgresql-server postgresql-contrib postgresql-devel \  systemtap-sdt-devel gcc make git

先验证 bpftrace 能否正常工作:

[root@iv-yemjqp9qm8s6iplx9rez ~]# bpftrace --versionbpftrace v0.23.5
[root@iv-yemjqp9qm8s6iplx9rez ~]# bpftrace -e 'BEGIN { printf("BPFTRACE_OK\n"); exit(); }'Attaching 1 probe...BPFTRACE_OK

接下来是关键点:PostgreSQL 是否带 USDT 探针。

这点非常重要。你脚本写得再漂亮,如果 PostgreSQL 编译时没有打开相关探针,最后也是竹篮打水一场空。

让我们看一下:

[root@iv-yemjqp9qm8s6iplx9rez ~]# bpftrace -l 'usdt:/usr/bin/postgres:postgresql:query*'usdt:/usr/bin/postgres:postgresql:query__doneusdt:/usr/bin/postgres:postgresql:query__execute__doneusdt:/usr/bin/postgres:postgresql:query__execute__startusdt:/usr/bin/postgres:postgresql:query__parse__doneusdt:/usr/bin/postgres:postgresql:query__parse__startusdt:/usr/bin/postgres:postgresql:query__plan__doneusdt:/usr/bin/postgres:postgresql:query__plan__startusdt:/usr/bin/postgres:postgresql:query__rewrite__doneusdt:/usr/bin/postgres:postgresql:query__rewrite__startusdt:/usr/bin/postgres:postgresql:query__start

再看看锁相关探针:

[root@iv-yemjqp9qm8s6iplx9rez ~]# bpftrace -l 'usdt:/usr/bin/postgres:postgresql:lock*'usdt:/usr/bin/postgres:postgresql:lock__wait__doneusdt:/usr/bin/postgres:postgresql:lock__wait__start
[root@iv-yemjqp9qm8s6iplx9rez ~]# bpftrace -l 'usdt:/usr/bin/postgres:postgresql:lwlock*'usdt:/usr/bin/postgres:postgresql:lwlock__acquireusdt:/usr/bin/postgres:postgresql:lwlock__acquire__or__waitusdt:/usr/bin/postgres:postgresql:lwlock__acquire__or__wait__failusdt:/usr/bin/postgres:postgresql:lwlock__condacquireusdt:/usr/bin/postgres:postgresql:lwlock__condacquire__failusdt:/usr/bin/postgres:postgresql:lwlock__releaseusdt:/usr/bin/postgres:postgresql:lwlock__wait__doneusdt:/usr/bin/postgres:postgresql:lwlock__wait__start

可以看到,Rocky 官方 PostgreSQL 13.23 包已经带了 USDT 探针。这一点值得点赞,不用再单独源码编译了。

当然,如果你的环境里看不到这些探针,那就需要自己编译:

./configure --enable-dtrace --enable-debug CFLAGS='-O2 -g'make -jmake install

准备测试实例

为了不污染系统 PostgreSQL,这里单独拉一个测试实例,端口使用 55432。

mkdir -p /tmp/pg_bpftrace_labchown -R postgres:postgres /tmp/pg_bpftrace_labrunuser -u postgres -- initdb -D /tmp/pg_bpftrace_lab/data

追加一些方便观察的参数:

port = 55432listen_addresses = '127.0.0.1'shared_preload_libraries = 'pg_stat_statements'track_io_timing = onlogging_collector = onlog_directory = 'log'log_filename = 'postgresql.log'log_line_prefix = '%m [%p] [%l] user=%u,db=%d,app=%a,client=%r 'log_min_duration_statement = 100log_lock_waits = ondeadlock_timeout = '200ms'log_temp_files = 0log_checkpoints = onlog_autovacuum_min_duration = 0work_mem = '1MB'shared_buffers = '128MB'max_wal_size = '256MB'checkpoint_timeout = '30s'

启动:

runuser -u postgres -- pg_ctl -D /tmp/pg_bpftrace_lab/data \  -l /tmp/pg_bpftrace_lab/server.log start
postgres=# select version();                                                    version                                                     ---------------------------------------------------------------------------------------------------------------- PostgreSQL 13.23 on x86_64-redhat-linux-gnu, compiled by gcc (GCC) 11.5.0 20240719 (Red Hat 11.5.0-11), 64-bit(1 row)

准备三张实验表:

create extension if not exists pg_stat_statements;
create table lab_cpu asselect    i as id,    md5(i::text) as payload,    (random() * 1000000)::int as vfrom generate_series(1, 800000) as s(i);
create table lab_sort asselect    i as id,    md5(random()::text) || md5(i::text) as payload,    (random() * 1000000)::int as vfrom generate_series(1, 600000) as s(i);
create table lab_lock(id int primary key, payload text);insert into lab_lock values (1, 'before');
analyze lab_cpu;analyze lab_sort;analyze lab_lock;select pg_stat_statements_reset();

再初始化 pgbench:

runuser -u postgres -- pgbench -p 55432 -i -s 5 postgres

万事俱备,开整。

先看看传统手段

我们先跑几个负载。

CPU 型查询:

select count(*)from lab_cpuwhere md5(payload || id::text) like 'a%';

故意制造排序 spill:

set work_mem = '64kB';create temp table sorted_lab_sort asselect * from lab_sortorder by payload;

再跑 pgbench:

runuser -u postgres -- pgbench -p 55432 -c 6 -j 2 -T 5 -P 2 postgres

从 pg_stat_statements 能看到什么?

query                                      calls  total_ms   temp_blks_read  temp_blks_writtenUPDATE pgbench_branches ...               44032  197629.45  0               0UPDATE pgbench_tellers ...                44032  162129.01  0               0select pg_sleep($1)                       1      5005.04    0               0update lab_lock set payload=$1 where...   2      4004.70    0               0create temp table sorted_lab_sort ...     2      2240.56    59351           63535UPDATE pgbench_accounts ...               44032  1259.14    0               0create temp table sorted_lab_sort2 ...    1      1120.25    29719           31812copy pgbench_accounts from stdin          1      897.32     0               0

信息已经不少了:

1.pgbench_branches 和 pgbench_tellers 更新耗时很高。2.sorted_lab_sort 写了大量临时块。3.lab_lock 的更新耗时约 4 秒,说明等待时间也会计入 SQL 总耗时。

再看日志,也能看到临时文件和锁等待:

LOG:  duration: 151.922 ms  statement: select count(*) from lab_cpu where md5(payload || id::text) like 'a%';
LOG:  temporary file: path "base/pgsql_tmp/pgsql_tmp22184.0", size 18259968LOG:  duration: 1098.071 ms  statement: set work_mem='64kB'; create temp table sorted_lab_sort as select * from lab_sort order by payload;
LOG:  process 22224 still waiting for ShareLock on transaction 20903 after 200.062 msLOG:  process 22224 acquired ShareLock on transaction 20903 after 4004.416 msLOG:  duration: 4006.146 ms  statement: update lab_lock set payload='waiter' where id=1;

这时候如果只是日常排障,基本已经能往下处理了。但如果我们想继续追问:

1.这段时间 query 延迟分布长啥样?2.锁等待在 bpftrace 里能不能抓到?3.临时文件对应系统调用层面 read/write 了多少?4.pgbench 压力下 LWLock 主要卡在哪里?

这就轮到 bpftrace 登场了。

实战一:抓 query 延迟

先抓 query 延迟,使用 query__start 和 query__done 两个 USDT 探针。

脚本如下:

usdt:/usr/bin/postgres:postgresql:query__start{    @q[tid] = str(arg0);    @ts[tid] = nsecs;}
usdt:/usr/bin/postgres:postgresql:query__done / @ts[tid] /{    $lat_us = (nsecs - @ts[tid]) / 1000;    @latency_us = hist($lat_us);    if ($lat_us > 100000) {        printf("SLOW_QUERY pid=%d lat_us=%llu sql=%s\n", pid, $lat_us, @q[tid]);    }    delete(@q[tid]);    delete(@ts[tid]);}
END{    print(@latency_us);}

这里我没有按 SQL 文本聚合,而是只保留一个全局延迟直方图,并打印超过 100ms 的 SQL。

为什么这么做?因为我一开始就掉坑了:直接拿完整 SQL 文本作为 map key,pgbench 一跑,立马报错:

WARNING: Map full; can't update element. Try increasing max_map_keys config

这个坑非常有代表性。高频事件里千万不要随便用完整 SQL 文本做高基数聚合,尤其简单协议下 SQL 里带字面量,map 很容易爆。

改完之后,真实输出如下:

Attaching 3 probes...SLOW_QUERY pid=22178 lat_us=151947 sql=select count(*) from lab_cpu where md5(payload || id::text) like 'a%';SLOW_QUERY pid=22184 lat_us=1098073 sql=set work_mem='64kB'; create temp table sorted_lab_sort as select * from lab_sort order by payload;
@latency_us:[2, 4)               639 |@                                                   |[4, 8)              9423 |@@@@@@@@@@@@@@@@@@@                                 |[8, 16)              788 |@                                                   |[16, 32)           25027 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|[32, 64)           16179 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@                   |[64, 128)           3365 |@@@@@@                                              |[128, 256)           367 |                                                    |[256, 512)          2165 |@@@@                                                |[512, 1K)           2598 |@@@@@                                               |[1K, 2K)            7757 |@@@@@@@@@@@@@@@@                                    |[2K, 4K)            3042 |@@@@@@                                              |[4K, 8K)             596 |@                                                   |[8K, 16K)             61 |                                                    |[128K, 256K)           1 |                                                    |[1M, 2M)               1 |                                                    |

这个结果很直观:

1.大量 pgbench 短 SQL 落在微秒到几毫秒区间。2.CPU 型查询耗时 151ms,被 SLOW_QUERY 打出来了。3.排序 spill 查询耗时 1098ms,也被打出来了。

这里 bpftrace 和日志对得上:

LOG: duration: 151.922 ms statement: select count(*) ...LOG: duration: 1098.071 ms statement: set work_mem='64kB'; create temp table ...

日志告诉我们哪条慢,bpftrace 还能顺便告诉我们整体延迟分布。

实战二:抓重量级锁等待

重量级锁等待使用 lock__wait__start 和 lock__wait__done。

脚本:

usdt:/usr/bin/postgres:postgresql:lock__wait__start{    @ts[tid] = nsecs;}
usdt:/usr/bin/postgres:postgresql:lock__wait__done / @ts[tid] /{    $lat_ms = (nsecs - @ts[tid]) / 1000000;    @lock_wait_ms[pid] = hist($lat_ms);    delete(@ts[tid]);}
END{    print(@lock_wait_ms);}

制造一个最简单的行锁等待:

-- 会话 1begin;update lab_lock set payload='holder' where id=1;select pg_sleep(5);commit;
-- 会话 2update lab_lock set payload='waiter' where id=1;

bpftrace 抓到:

Attaching 3 probes...@lock_wait_ms[22224]:[2K, 4K)               1 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|

日志里也能看到:

LOG: process 22224 still waiting for ShareLock on transaction 20903 after 200.062 msLOG: process 22224 acquired ShareLock on transaction 20903 after 4004.416 msLOG: duration: 4006.146 ms  statement: update lab_lock set payload='waiter' where id=1;

为什么会话 1 睡了 5 秒,但等待只有 4 秒?因为会话 2 是 1 秒后才启动的,这个结果符合预期。

这里要说一句,定位具体谁堵谁,pg_locks 还是更直接;bpftrace 更适合看一段时间内所有锁等待的分布,比如有大量几十毫秒的短等待,日志可能不一定好看出来。

实战三:抓临时文件对应的 syscall IO

前面排序 spill 里,PostgreSQL 日志告诉我们产生了临时文件,pg_stat_statements 告诉我们写了大量临时块。

那系统调用层面到底 read/write 了多少?

脚本如下:

tracepoint:syscalls:sys_exit_read / comm == "postgres" && args.ret > 0 /{    @read_bytes[pid] = sum(args.ret);}
tracepoint:syscalls:sys_exit_pread64 / comm == "postgres" && args.ret > 0 /{    @read_bytes[pid] = sum(args.ret);}
tracepoint:syscalls:sys_exit_write / comm == "postgres" && args.ret > 0 /{    @write_bytes[pid] = sum(args.ret);}
tracepoint:syscalls:sys_exit_pwrite64 / comm == "postgres" && args.ret > 0 /{    @write_bytes[pid] = sum(args.ret);}
END{    print(@read_bytes);    print(@write_bytes);}

执行排序:

set work_mem='64kB';create temp table sorted_lab_sort2 asselect * from lab_sortorder by payload;

真实输出如下:

Attaching 5 probes...@read_bytes[22107]: 1@read_bytes[22108]: 1@read_bytes[22104]: 1664@read_bytes[22243]: 67873495@read_bytes[22244]: 95628365@read_bytes[22242]: 127211682
@write_bytes[22107]: 1@write_bytes[22104]: 1610@write_bytes[22108]: 106497@write_bytes[22106]: 229786@write_bytes[22243]: 60556590@write_bytes[22244]: 88475300@write_bytes[22242]: 234283705

对应日志:

LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp22243.0", size 12771328LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp22244.0", size 17522688LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp22242.0", size 21504000LOG: duration: 1121.320 ms  statement: set work_mem='64kB'; create temp table sorted_lab_sort2 as select * from lab_sort order by payload;

为什么有多个 pid?因为排序过程中启用了并行 worker,所以不止一个 postgres 进程产生读写。

这里的链路就很清楚了:

SQL 层:temp_blks_written 增加日志层:出现 temporary file内核层:postgres 进程产生明显 read/write syscall 字节数

这就是 bpftrace 很香的地方。它不是只看数据库自己怎么说,还能继续看数据库对内核做了什么。

实战四:分析内存使用

数据库里,内存分析也是一门很重要的手艺。

很多时候我们会遇到这种问题:

1.work_mem 明明设置了,排序到底用了多少?2.SQL 没有写临时文件,是不是就代表没有吃内存?3.一个 backend 突然 RSS 涨了,到底是 PostgreSQL 内部申请,还是操作系统层面有大块 mmap?4.线上不方便改代码,能不能先抓一把证据?

这类问题,bpftrace 也可以帮上忙。

不过这里先说一句实话:PostgreSQL 的内存管理主要在 MemoryContext 里,SQL 执行过程大量使用 palloc。如果你想完整理解内存生命周期,最好结合 pg_log_backend_memory_contexts()、MemoryContextStats、日志和进程 RSS 一起看。bpftrace 不是内存剖析的银弹,但它可以在“不改代码”的情况下,帮我们抓到一些非常关键的现场证据。

先看 PostgreSQL 自己暴露出来的排序探针:

[root@iv-yemjqp9qm8s6iplx9rez ~]# bpftrace -l 'usdt:/usr/bin/postgres:postgresql:*sort*'usdt:/usr/bin/postgres:postgresql:sort__doneusdt:/usr/bin/postgres:postgresql:sort__start

写一个小脚本,观察排序开始时的 work_mem,以及排序结束时使用的空间:

usdt:/usr/bin/postgres:postgresql:sort__start{    printf("SORT_START pid=%d begin=%d unique=%d nkeys=%d workmem=%d random=%d\n",        pid, arg0, arg1, arg2, arg3, arg4);}
usdt:/usr/bin/postgres:postgresql:sort__done{    printf("SORT_DONE pid=%d external=%d space=%lld\n", pid, arg0, arg1);    @sort_count[pid] = count();    @sort_space[pid] = sum(arg1);}
END{    print(@sort_count);    print(@sort_space);}

这里有两个字段比较有用:

1.sort__start 里的 workmem,可以看到这个排序节点拿到的 work_mem。2.sort__done 里的 external 和 space,可以看到这次排序是否变成外部排序,以及使用了多少空间。

先把 work_mem 压到很小:

set work_mem='64kB';create temp table mem_sort_test asselect * from lab_sortorder by payload;

真实输出如下:

Attaching 6 probes...SORT_START pid=22808 begin=1 unique=1 nkeys=2 workmem=65536 random=0SORT_START pid=22808 begin=1 unique=0 nkeys=2 workmem=64 random=0SORT_DONE pid=22808 external=0 space=25SORT_DONE pid=22808 external=0 space=25SORT_START pid=22808 begin=0 unique=0 nkeys=1 workmem=64 random=0SORT_DONE pid=22808 external=1 space=6316@sort_count[22808]: 3@sort_space[22808]: 6366

最后这一行 external=1 space=6316 就比较直观了:这个排序没有老老实实待在内存里,而是变成了外部排序,空间大约 6316KB。

再把 work_mem 放大:

set work_mem='512MB';create temp table mem_sort_inmem2 asselect * from lab_sortorder by payload;

真实输出如下:

Attaching 6 probes...SORT_START pid=22847 begin=1 unique=1 nkeys=2 workmem=65536 random=0SORT_START pid=22847 begin=1 unique=0 nkeys=2 workmem=524288 random=0SORT_DONE pid=22847 external=0 space=25SORT_DONE pid=22847 external=0 space=25SORT_START pid=22847 begin=0 unique=0 nkeys=1 workmem=524288 random=0SORT_DONE pid=22847 external=0 space=108952@sort_count[22847]: 3@sort_space[22847]: 109002

这次 external=0,说明排序在内存里完成;space=108952,大约 108MB。

这就能回答一个很常见的问题:没有临时文件,不代表 SQL 不吃内存。它可能只是 work_mem 足够大,排序留在内存里完成了。

再往下看一层,看看这个 backend 有没有向操作系统申请大块内存。这里抓 mmap 和 brk:

tracepoint:syscalls:sys_enter_mmap / comm == "postgres" /{    @mmap_count[pid] = count();    @mmap_bytes[pid] = sum(args.len);}
tracepoint:syscalls:sys_enter_brk / comm == "postgres" /{    @brk_count[pid] = count();}
END{    print(@mmap_count);    print(@mmap_bytes);    print(@brk_count);}

配合刚才的 512MB work_mem 排序,抓到结果如下:

Attaching 3 probes...@mmap_count[22838]: 27@mmap_bytes[22838]: 101756928@brk_count[22838]: 14

也就是说,这个 backend 在采样窗口里发生了 27 次 mmap,累计申请长度约 101MB,同时还有 14 次 brk。

这里不要过度解读:mmap_bytes 是系统调用请求长度的累计,不等于最终 RSS,也不等于 PostgreSQL 某个 MemoryContext 的精确占用。它更像是在告诉我们:这个 SQL 执行过程中,backend 确实发生了明显的大块内存申请。

如果线上遇到某个 backend 内存飙升,我一般会这样组合拳:

第一步:从 pg_stat_activity 找 pid、SQL、等待事件;第二步:用 /proc/<pid>/status 或 ps 看 RSS/VmSize;第三步:必要时调用 pg_log_backend_memory_contexts(pid) 看 MemoryContext;第四步:用 bpftrace 短时间抓 mmap/brk,确认 OS 层面是否有大块申请;第五步:如果怀疑排序/哈希聚合,再抓 sort__start/sort__done 或结合执行计划看 Hash/Sort 节点。

这块最忌讳上来就全局抓 malloc/free。数据库系统调用频率很高,内存分配也非常频繁,抓太细很容易把观察者效应放大。先按 pid、短窗口、聚合输出,往往更靠谱。

实战五:抓 LWLock

LWLock 是今天最值得看的部分。

重量级锁还能靠 pg_locks 查,临时文件还能靠日志看,但 LWLock 很多时候就比较晦涩。pg_stat_activity.wait_event 可能告诉你在等某个 LWLock,但你很难进一步看到等待分布。

这里用 lwlock__wait__start 和 lwlock__wait__done 按 tranche 聚合:

usdt:/usr/bin/postgres:postgresql:lwlock__wait__start{    @ts[tid] = nsecs;    @tranche[tid] = str(arg0);}
usdt:/usr/bin/postgres:postgresql:lwlock__wait__done / @ts[tid] /{    $delta_us = (nsecs - @ts[tid]) / 1000;    @lwlock_wait_us[@tranche[tid]] = sum($delta_us);    @lwlock_wait_count[@tranche[tid]] = count();    @lwlock_stack[ustack(10)] = sum($delta_us);    delete(@ts[tid]);    delete(@tranche[tid]);}
END{    print(@lwlock_wait_count);    print(@lwlock_wait_us);    print(@lwlock_stack);}

跑一轮高并发 pgbench:

runuser -u postgres -- pgbench -p 55432 -c 40 -j 4 -T 10 -P 5 postgres

pgbench 结果:

progress: 5.0 s, 2502.2 tps, lat 15.693 msprogress: 10.0 s, 2218.2 tps, lat 18.019 ms
number of clients: 40duration: 10 snumber of transactions actually processed: 23642latency average = 16.842 mstps = 2355.617370

bpftrace 抓到的 LWLock 等待如下:

@lwlock_wait_count[SyncRep]: 2@lwlock_wait_count[XactSLRU]: 24@lwlock_wait_count[WALBufMapping]: 41@lwlock_wait_count[XidGen]: 51@lwlock_wait_count[ProcArray]: 62@lwlock_wait_count[WALInsert]: 386@lwlock_wait_count[BufferContent]: 1775@lwlock_wait_count[LockManager]: 3798@lwlock_wait_count[WALWrite]: 35214
@lwlock_wait_us[SyncRep]: 2@lwlock_wait_us[WALBufMapping]: 68@lwlock_wait_us[XidGen]: 79@lwlock_wait_us[ProcArray]: 624@lwlock_wait_us[XactSLRU]: 633@lwlock_wait_us[WALInsert]: 6074@lwlock_wait_us[BufferContent]: 10291@lwlock_wait_us[LockManager]: 97419@lwlock_wait_us[WALWrite]: 26186806

这就很有意思了。

按等待次数看,WALWrite 有 35214 次;按累计等待时间看,WALWrite 也最突出,累计约 26.18 秒。其次是 LockManager、BufferContent、WALInsert。

这和 pgbench 场景是匹配的:大量小事务频繁更新、提交,WAL 写入路径自然容易被打得比较热。

如果只看 pg_stat_statements,我们能看到:

UPDATE pgbench_branches ... total_ms 197629.45UPDATE pgbench_tellers  ... total_ms 162129.01

但是 bpftrace 进一步告诉我们:这轮压力下,LWLock 层面最突出的等待在 WALWrite。

一个真实踩坑:ustack 只有地址

上面的脚本里,我也加了 ustack(10),原本是想抓用户态调用栈。结果抓出来是这样的:

@lwlock_stack[        0x55d99cf924ed        0]: 659
@lwlock_stack[        0x55d99cf920ca        0]: 2522

是不是有点尴尬?只有地址,没有函数名。

看一下系统包:

[root@iv-yemjqp9qm8s6iplx9rez ~]# file /usr/bin/postgres/usr/bin/postgres: ELF 64-bit LSB pie executable, x86-64, ... stripped
[root@iv-yemjqp9qm8s6iplx9rez ~]# readelf -S /usr/bin/postgres | grep -i debug  [30] .gnu_debuglink    PROGBITS  [31] .gnu_debugdata    PROGBITS

系统包的 postgres 是 stripped 的,所以 ustack() 没法直接给我们漂亮的函数名。

这也是生产上很常见的坑。你以为抓调用栈很潇洒,结果抓出来一堆地址,瞬间索然无味。

要解决这个问题,可以考虑:

1.安装对应的 debuginfo 包;2.自编译 PostgreSQL,打开 --enable-debug;3.编译时加 -fno-omit-frame-pointer,让栈展开更友好;4.perf 场景下使用 --call-graph dwarf。

所以生产中我建议先抓 tranche 聚合,这个已经很有价值;如果还要继续看函数路径,再准备 debug 环境。

生产使用建议

这次实验下来,有几点感受比较深。

观察者效应

首先必须承认,bpftrace 一定会有观察者效应。

所谓观察者效应,简单理解就是:你为了观察系统而加上的探针,本身也会改变系统的行为。只不过这个影响有大有小,有时候可以忽略,有时候就会很明显。

bpftrace 的开销主要来自几个地方:

1.探针触发频率:比如 syscall、LWLock、query start/done,在高并发场景下都可能非常频繁。2.探针动作复杂度:只是 count() 和 sum(),通常还好;如果每次都 printf(),开销就会明显上来。3.map key 基数:按 pid、tranche 聚合比较稳;按完整 SQL 文本、路径、随机字符串聚合,很容易爆。4.调用栈采集:ustack() / kstack() 很有价值,但成本也更高。5.输出频率:终端疯狂刷屏,本身就是额外负担。

这次我就真实踩到了一个坑:一开始按完整 SQL 文本作为 map key 聚合 query 延迟,pgbench 一跑,立刻出现:

WARNING: Map full; can't update element. Try increasing max_map_keys config

这不仅说明脚本写法不够优雅,也提醒我们:生产环境里 bpftrace 不是想怎么挂就怎么挂。

所以我的建议是:

1.先低频、短时间采样:比如先抓 10 秒、30 秒,不要一上来常驻。2.先聚合,少打印:优先 count()、sum()、hist(),少在高频路径里 printf()。3.先粗后细:先按 pid、tranche、SQL 总延迟看方向,再决定是否抓栈。4.慎用调用栈:ustack() 很香,但不要默认每个脚本都加,尤其是高频事件。5.控制 key 基数:不要随便用完整 SQL、完整路径作为 map key。6.避开业务高峰:除非正在救火,否则尽量在可控窗口使用。7.有对照数据:采样前后看一下 TPS、延迟、CPU,确认 bpftrace 本身没有把问题放大。

简单来说,bpftrace 很适合“短、平、快”地拿证据,不适合没想清楚就长期挂着。

第一,bpftrace 很强,但不要一上来就用。

大多数 PostgreSQL 问题,先看 pg_stat_activity、pg_stat_statements、pg_locks、日志,已经能解决一大半。bpftrace 更适合用在“还差最后一公里”的场景。

第二,脚本要控制 key 的基数。

这次 Map full 就是血淋淋的例子。不要在高频事件里随便用完整 SQL 文本、完整路径、大量随机字符串做 map key。生产里更建议:

1.先看全局 hist;2.用 pid、tranche、queryid 这类可控 key;3.慢 SQL 用 printf 抽样;4.短时间采样,拿到证据就撤。

第三,优先使用稳定探针。

PostgreSQL USDT 和 Linux tracepoint 相对稳定;uprobe/kprobe 更灵活,但也更依赖版本、符号和编译选项。

第四,不要疯狂 printf。

高频事件里一直 printf,工具本身就可能制造开销。更推荐用 map 聚合,最后通过 END 或 interval 输出。

第五,提前准备符号信息。

如果你想看漂亮的 PostgreSQL 函数栈,最好提前准备 debuginfo 或 debug build。不然你看到的可能就是一串地址。

小结

这次总算把 bpftrace 真正跑起来了,也抓到了一些挺有代表性的结果:

1.query 延迟:抓到了 151ms 的 CPU 型 SQL 和 1098ms 的排序 spill SQL。2.lock wait:抓到了真实行锁等待,耗时约 4004ms。3.syscall IO:排序 spill 期间,抓到了多个 postgres 进程的 read/write 字节数。4.memory:排序探针抓到了外部排序约 6316KB、内存排序约 108MB,mmap 侧也抓到约 101MB 的申请。5.LWLock:高并发 pgbench 下,WALWrite 等待最突出,35214 次,累计约 26.18 秒。

我觉得 bpftrace 对 DBA 最大的意义,不是多一个炫技工具,而是让我们能把数据库现象和系统行为真正连起来。

pg_stat_statements 告诉你哪类 SQL 慢,日志告诉你出现了临时文件和锁等待,而 bpftrace 可以继续往下问:

它在 PostgreSQL 内部和内核层,到底卡在哪里?

这才是 bpftrace 最有价值的地方。

本文实验脚本

本文相关脚本已整理在本地:

pg_bpftrace_lab/bpftrace/pg_query_latency.btpg_bpftrace_lab/bpftrace/pg_lock_wait.btpg_bpftrace_lab/bpftrace/pg_syscall_io.btpg_bpftrace_lab/bpftrace/pg_sort_mem.btpg_bpftrace_lab/bpftrace/pg_mem_syscall.btpg_bpftrace_lab/bpftrace/pg_lwlock_stack.bt

参考

1.Analyzing postgres performance problems using perf and eBPF, Andres Freund2.Party tricks for PostgreSQL perf, ftrace and bpftrace, Dmitrii Dolgov3.PostgreSQL & DTrace Lightning Talk, Robert Lor4.PostgreSQL Documentation: Dynamic Tracing5.Brendan Gregg: eBPF / perf / FlameGraph 相关资料