深入浅出 bpftrace 分析 PostgreSQL

本页目录

前言

很早之前就想写一篇关于 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 都跑起来,真实抓了一轮数据。

bpftrace 是什么

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

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

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

举个最简单的例子:

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

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

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

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

实验环境

这次实验环境如下:

TEXT
OS      : Rocky Linux 9.5 (Blue Onyx)
Kernel  : 5.14.0-503.26.1.el9_5.x86_64
CPU     : 2 cores
Memory  : 8GB
PG      : PostgreSQL 13.23,Rocky 官方包
bpftrace: v0.23.5

安装依赖:

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

先验证 bpftrace 能否正常工作:

Shell
[root@lab ~]# bpftrace --version
bpftrace v0.23.5

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

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

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

让我们看一下:

Shell
[root@lab ~]# bpftrace -l 'usdt:/usr/bin/postgres:postgresql:query*'
usdt:/usr/bin/postgres:postgresql:query__done
usdt:/usr/bin/postgres:postgresql:query__execute__done
usdt:/usr/bin/postgres:postgresql:query__execute__start
usdt:/usr/bin/postgres:postgresql:query__parse__done
usdt:/usr/bin/postgres:postgresql:query__parse__start
usdt:/usr/bin/postgres:postgresql:query__plan__done
usdt:/usr/bin/postgres:postgresql:query__plan__start
usdt:/usr/bin/postgres:postgresql:query__rewrite__done
usdt:/usr/bin/postgres:postgresql:query__rewrite__start
usdt:/usr/bin/postgres:postgresql:query__start

再看看锁相关探针:

Shell
[root@lab ~]# bpftrace -l 'usdt:/usr/bin/postgres:postgresql:lock*'
usdt:/usr/bin/postgres:postgresql:lock__wait__done
usdt:/usr/bin/postgres:postgresql:lock__wait__start

[root@lab ~]# bpftrace -l 'usdt:/usr/bin/postgres:postgresql:lwlock*'
usdt:/usr/bin/postgres:postgresql:lwlock__acquire
usdt:/usr/bin/postgres:postgresql:lwlock__acquire__or__wait
usdt:/usr/bin/postgres:postgresql:lwlock__acquire__or__wait__fail
usdt:/usr/bin/postgres:postgresql:lwlock__condacquire
usdt:/usr/bin/postgres:postgresql:lwlock__condacquire__fail
usdt:/usr/bin/postgres:postgresql:lwlock__release
usdt:/usr/bin/postgres:postgresql:lwlock__wait__done
usdt:/usr/bin/postgres:postgresql:lwlock__wait__start

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

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

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

准备测试实例

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

Shell
mkdir -p /tmp/pg_bpftrace_lab
chown -R postgres:postgres /tmp/pg_bpftrace_lab
runuser -u postgres -- initdb -D /tmp/pg_bpftrace_lab/data

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

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

启动:

Shell
runuser -u postgres -- pg_ctl -D /tmp/pg_bpftrace_lab/data \
  -l /tmp/pg_bpftrace_lab/server.log start

确认版本:

PSQL
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)

准备三张实验表:

SQL
create extension if not exists pg_stat_statements;

create table lab_cpu as
select
    i as id,
    md5(i::text) as payload,
    (random() * 1000000)::int as v
from generate_series(1, 800000) as s(i);

create table lab_sort as
select
    i as id,
    md5(random()::text) || md5(i::text) as payload,
    (random() * 1000000)::int as v
from 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:

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

万事俱备,开整。

先看看传统手段

我们先跑几个负载。

CPU 型查询:

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

故意制造排序 spill:

SQL
set work_mem = '64kB';
create temp table sorted_lab_sort as
select * from lab_sort
order by payload;

再跑 pgbench:

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

从 pg_stat_statements 能看到什么?

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

信息已经不少了:

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

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

TEXT
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 18259968
LOG:  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 ms
LOG:  process 22224 acquired ShareLock on transaction 20903 after 4004.416 ms
LOG:  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 探针。

脚本如下:

C
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 一跑,立马报错:

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

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

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

TEXT
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 和日志对得上:

TEXT
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。

脚本:

C
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);
}

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

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

-- 会话 2
update lab_lock set payload='waiter' where id=1;

bpftrace 抓到:

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

日志里也能看到:

TEXT
LOG: process 22224 still waiting for ShareLock on transaction 20903 after 200.062 ms
LOG: process 22224 acquired ShareLock on transaction 20903 after 4004.416 ms
LOG: 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 了多少?

脚本如下:

C
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);
}

执行排序:

SQL
set work_mem='64kB';
create temp table sorted_lab_sort2 as
select * from lab_sort
order by payload;

真实输出如下:

TEXT
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

对应日志:

TEXT
LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp22243.0", size 12771328
LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp22244.0", size 17522688
LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp22242.0", size 21504000
LOG: 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 进程产生读写。

这里的链路就很清楚了:

TEXT
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 自己暴露出来的排序探针:

Shell
[root@lab ~]# bpftrace -l 'usdt:/usr/bin/postgres:postgresql:*sort*'
usdt:/usr/bin/postgres:postgresql:sort__done
usdt:/usr/bin/postgres:postgresql:sort__start

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

C
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 压到很小:

SQL
set work_mem='64kB';
create temp table mem_sort_test as
select * from lab_sort
order by payload;

真实输出如下:

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

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

再把 work_mem 放大:

SQL
set work_mem='512MB';
create temp table mem_sort_inmem2 as
select * from lab_sort
order by payload;

真实输出如下:

TEXT
Attaching 6 probes...
SORT_START pid=22847 begin=1 unique=1 nkeys=2 workmem=65536 random=0
SORT_START pid=22847 begin=1 unique=0 nkeys=2 workmem=524288 random=0
SORT_DONE pid=22847 external=0 space=25
SORT_DONE pid=22847 external=0 space=25
SORT_START pid=22847 begin=0 unique=0 nkeys=1 workmem=524288 random=0
SORT_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:

C
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 排序,抓到结果如下:

TEXT
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 内存飙升,我一般会这样组合拳:

TEXT
第一步:从 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 聚合:

C
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:

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

pgbench 结果:

TEXT
progress: 5.0 s, 2502.2 tps, lat 15.693 ms
progress: 10.0 s, 2218.2 tps, lat 18.019 ms

number of clients: 40
duration: 10 s
number of transactions actually processed: 23642
latency average = 16.842 ms
tps = 2355.617370

bpftrace 抓到的 LWLock 等待如下:

TEXT
@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,我们能看到:

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

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

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

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

TEXT
@lwlock_stack[
        0x55d99cf924ed
        0
]: 659

@lwlock_stack[
        0x55d99cf920ca
        0
]: 2522

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

看一下系统包:

Shell
[root@lab ~]# file /usr/bin/postgres
/usr/bin/postgres: ELF 64-bit LSB pie executable, x86-64, ... stripped

[root@lab ~]# 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 一跑,立刻出现:

TEXT
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 最有价值的地方。

本文实验脚本

文中的脚本都已经完整贴出,也可以按下面的名称拆成独立 .bt 文件复用:

TEXT
pg_query_latency.bt
pg_lock_wait.bt
pg_syscall_io.bt
pg_sort_mem.bt
pg_mem_syscall.bt
pg_lwlock_stack.bt

参考

  1. Analyzing postgres performance problems using perf and eBPF, Andres Freund
  2. Party tricks for PostgreSQL perf, ftrace and bpftrace, Dmitrii Dolgov
  3. PostgreSQL & DTrace Lightning Talk, Robert Lor
  4. PostgreSQL Documentation: Dynamic Tracing
  5. Brendan Gregg: eBPF / perf / FlameGraph 相关资料

讨论与补充

在 GitHub 查看讨论

支持作者

感谢支持 PostgreSQL Howto 的长期维护。

微信赞赏码

读者交流群

二维码若已过期,可通过公众号 PostgreSQL 学徒或邮件联系。

PostgreSQL 学徒读者交流群二维码