来源:https://jnidzwetzki.github.io/2026/05/08/ebpf-hw-breakpoints-postgresql.html
使用 eBPF 和硬件断点跟踪 PostgreSQL
作者: Jan Nidzwetzki
日期: 2026 年 5 月 8 日
当特定内存地址被访问时,硬件断点可以利用 CPU 硬件支持以较低的开销触发 eBPF 程序。通过利用这些硬件断点,我们可以有效地监控 PostgreSQL 的内部变量更新,例如事务 ID 生成和 OID 分配。在这篇文章中,我们将讨论什么是硬件断点,它们是否比 uprobe 的开销更低,以及如何使用 bpftrace 回答诸如“每秒执行多少个事务?”或“哪个后端进程消耗的 OID 最多?”等问题。
在之前的一篇博文中,我讨论了如何使用 eBPF、uprobe/uretprobe 和 bpftrace 来监控 PostgreSQL 的内部函数,例如 vacuum。当进入或退出用户空间中的函数时,uprobe 和 uretprobe 会触发 Linux 内核中的 eBPF 代码。尽管 uprobe 和 uretprobe 的开销非常低,它们仍然需要通过软件中断来检测函数的入口或出口。对于调用非常频繁的函数来说,这种开销尤其值得关注。相比之下,硬件断点使用 CPU 硬件特性来监控特定的内存地址,并在被监控的地址被访问时触发真正的硬件中断。因此,它们也能让我们捕获对特定变量的所有更新,即使该变量在多个函数中被更新,而无需检测每个触及它的函数。
Uprobe 在底层是如何工作的?
Uprobe 和 uretprobe 通过将函数入口或出口的前几条指令替换为一个软件(int3)中断来检测函数。当函数被调用时,CPU 执行该软件中断,触发 CPU 模式切换,从而使 eBPF 程序能够运行。
当 eBPF 程序完成时,内核需要执行被 int3 替换的指令。这被称为线下执行(out-of-line execution),需要内核单独运行原始指令,这增加了额外的开销。
在将 uprobe 附加到函数之前和之后,可以通过在 gdb 中检查函数的前几个字节来观察指令替换。例如,让我们检查 PostgreSQL 中的bms_is_member函数:
(gdb) x/10bx bms_is_member 0x55e0c2f7242c <bms_is_member>: 0x55 0x48 0x89 0xe5 0x48 0x83 0xec 0x20 0x55e0c2f72434 <bms_is_member+8>: 0x89 0x7dbms_is_member函数的第一个字节是0x55,对应push rbp指令。当运行一个将 uprobe 附加到bms_is_member函数的 eBPF 程序时(例如funccount-bpfcc /home/jan/postgresql-sandbox/bin/REL_17_1_DEBUG/bin/postgres:bms_is_member),函数的第一个字节会改变:
(gdb) x/10bx bms_is_member 0x55e0c2f7242c <bms_is_member>: 0xcc 0x48 0x89 0xe5 0x48 0x83 0xec 0x20 0x55e0c2f72434 <bms_is_member+8>: 0x89 0x7d执行funccount-bpfcc命令后,bms_is_member函数的第一个字节被替换为0xcc,这是 x86_64 CPU 上int3指令的操作码。这允许内核在调用bms_is_member函数时执行 eBPF 程序。
注意:在 gdb 中运行disassemble bms_is_member将显示原始指令,因为 gdb 使用相同的int3指令来设置断点,并在反汇编时将int3指令替换为原始指令。
硬件断点是如何工作的?
与 uprobe 相比,硬件断点不需要任何指令替换。相反,它们使用 CPU 硬件特性来监控特定的内存地址,并在被监控的地址被访问时触发真正的硬件中断。当 CPU 试图访问该特定地址(读、写或执行)时,硬件比较器会触发,这可以在监控频繁访问的函数或变量时实现更低的开销。
在 x86_64 CPU 上,硬件断点通常是可用的,但确切的数量取决于 CPU。一个快速的检查方法是使用以下命令查找de标志,该标志表示调试扩展(debug extensions):
grep-m1flags /proc/cpuinfo|grep-o'de'&&echo"CPU supports hardware breakpoints"||echo"CPU does not support hardware breakpoints"不幸的是,没有简单的方法来检查有多少个硬件断点可用,但 x86_64 CPU 通常支持最多四个硬件断点。确定可用硬件断点数量的一种方法是使用 gdb 设置硬件断点,直到失败为止。例如,gdb 命令hbreak可用于在特定内存地址设置硬件断点。
示例用例
在本节中,我们将讨论如何使用 eBPF 硬件断点来监控 PostgreSQL 的内部操作,例如事务 ID 生成和 OID 分配。为了能够正确地将 uprobe 附加到 PostgreSQL,以下示例中使用的是 PostgreSQL 的调试构建版本。
监控 PostgreSQL 事务 ID 生成
为了在访问特定变量时使用硬件断点来触发 eBPF 程序,我们可以使用bpftrace工具。第一步是确定要监控的变量的内存地址。例如,为了监控 PostgreSQL 的事务 ID 生成,我们可以检查nextXid变量。为了确定nextXid的内存地址,我们可以使用 gdb 附加到一个正在运行的 PostgreSQL 进程并打印变量的地址:
gdb -p $(pgrep -o postgres) (gdb) print &TransamVariables->nextXid $1 = (FullTransactionId *) 0x7f6791925608之后,我们可以在 bpftrace 中使用该信息,在TransamVariables->nextXid的内存地址上设置一个硬件断点,并在其被访问时触发 eBPF 程序。此外,eBPF 程序可以读取nextXid的值(参见下面 bpftrace 命令中的*(uint64 *)0x7f6791925608表达式),并连同访问它的进程 ID 和命令名一起打印出来:
sudobpftrace-e" watchpoint:0x7f6791925608:8:w { \$val= *(uint64 *)0x7f6791925608; printf(\"[XID Event] PID: %-6d | comm: %-10s | next xid: %lu\n\", pid, comm, \$val); }"在此示例中,watchpoint探针用于在内存地址0x7f6791925608(对应TransamVariables->nextXid)上设置一个硬件断点。:8:w后缀表示我们想要监控对该地址的 8 字节写访问。
当在第二个终端中调用pg_current_xact_id()时,PostgreSQL 会分配一个新的事务 ID,这会更新nextXid。这会触发硬件断点并执行 eBPF 程序,该程序会打印nextXid的新值。例如:
test2=# SELECT pg_current_xact_id();pg_current_xact_id--------------------2246(1row)test2=# SELECT pg_current_xact_id();pg_current_xact_id--------------------2247(1row)bpftrace 命令的输出显示了每次nextXid更新时的进程 ID、命令名和新值:
Attaching 1 probe... [XID Event] PID: 117447 | comm: postgres | next xid: 2247 [XID Event] PID: 117447 | comm: postgres | next xid: 2248为了监控事务 ID 生成速率,我们可以使用 bpftrace 计算每秒硬件断点被触发的次数。eBPF 程序将这些计数存储在一个名为@count的 eBPF 映射中。interval:s:1探针每秒打印一次@count的内容,然后为下一个间隔清空映射:
sudobpftrace-e' watchpoint:0x7f6791925608:8:w { @count[comm] = count(); } interval:s:1 { time("%H:%M:%S: "); print(@count); clear(@count); }'运行上述 bpftrace 命令时,它会每秒打印硬件断点被触发的次数,这对应于 PostgreSQL 中创建的事务数。例如,输出可能如下所示:
21:49:45: 21:49:46: @count[postgres]: 1 21:49:47: @count[postgres]: 23 21:49:48: @count[postgres]: 24 21:49:49: @count[postgres]: 2 21:49:50: 21:49:51: @count[postgres]: 1 21:49:52: @count[postgres]: 1 21:49:53: @count[postgres]: 2这意味着在 21:49:46 开始的一秒间隔内,硬件断点被触发了一次,对应于 PostgreSQL 中创建了一个事务。在下一个从 21:49:47 开始的一秒间隔内,硬件断点被触发了 23 次,对应于 PostgreSQL 中创建了 23 个事务,依此类推。
监控 PostgreSQL OID 分配
使用相同的方法,我们也可以通过设置硬件断点在TransamVariables->nextOid变量上,来监控 PostgreSQL 的 OID 分配。第一步是使用 gdb 确定nextOid变量的内存地址:
(gdb) print &TransamVariables->nextOid $1 = (Oid *) 0x7f6791925600为了监控 OID 分配,我们可以使用一个简单的 bpftrace 命令,在TransamVariables->nextOid的内存地址上设置一个硬件断点,并在其更新时打印nextOid的新值:
sudobpftrace-e" watchpoint:0x7f6791925600:4:w { \$val= *(uint32 *)0x7f6791925600; printf(\"[OID Event] PID: %-6d | comm: %-10s | next oid: %lu\n\", pid, comm, \$val); }"当在第二个终端中,PostgreSQL 分配一个新的 OID 时(例如,通过创建一个新表),nextOid变量会被更新,这会触发硬件断点并执行 eBPF 程序,打印出nextOid的新值:
test2=# CREATE TABLE test100();CREATETABLEtest2=# CREATE TABLE test101();CREATETABLEtest2=# CREATE TABLE test102();CREATETABLEtest2=# SELECT 'test100'::regclass::oid;oid-------57539(1row)bpftrace 命令的输出显示了每次nextOid更新时的进程 ID、命令名和新值。它还显示了表test100的 OID 是 57539。输出的第一行对应于表test100的 OID 分配;表创建后,nextOid递增到 57540。
[OID Event] PID: 117447 | comm: postgres | next oid: 57540 [OID Event] PID: 117447 | comm: postgres | next oid: 57541 [OID Event] PID: 117447 | comm: postgres | next oid: 57542为了监控哪个后端进程消耗的 OID 最多,我们可以使用一个 eBPF 程序,计算每个后端进程触发硬件断点的次数。interval:s:5探针每五秒打印一次@count映射的内容,然后为下一个间隔清空映射:
sudobpftrace-e' watchpoint:0x7f6791925600:4:w { @count[tid, comm] = count(); } interval:s:5 { time("%H:%M:%S: "); print(@count); clear(@count); }'上述 bpftrace 命令的输出将显示每五秒每个后端进程触发硬件断点的次数,这对应于每个 PostgreSQL 后端分配的 OID 数量。例如,输出可能如下所示:
21:47:15: 21:47:20: 21:47:25: @count[519125, postgres]: 6 21:47:30: @count[519125, postgres]: 6 21:47:35: @count[673992, postgres]: 6 @count[519125, postgres]: 6 21:47:40: 21:47:45: @count[673992, postgres]: 18这意味着 ID 为 519125 的进程在 21:47:25 开始的五秒间隔内触发了 6 次硬件断点,在下一个五秒间隔内也触发了 6 次。ID 为 673992 的进程在 21:47:35 开始的五秒间隔内触发了 6 次硬件断点,在下一个五秒间隔内触发了 18 次,这表明它比 ID 为 519125 的进程消耗了更多的 OID。
基准测试:硬件断点与 Uprobe 的对比
为了比较硬件断点和 uprobe 的开销,我们可以使用一个简单的 C 程序,该程序在循环中执行大量计算并更新一个全局变量,然后分别通过硬件断点或 uprobe 对其进行监控。
#include<stdio.h>#include<stdint.h>#include<time.h>#include<stdbool.h>#include<math.h>// 用于硬件监视点的全局变量volatileuint64_ttarget_var=0;// 用于 uprobe 的函数__attribute__((noinline))voidtrace_target_func(uint64_tval){target_var=val;}intmain(){uint64_titerations=0;structtimespecstart,now;doubleelapsed;doubledummy_math=0.0;printf("Target address for watchpoint: %p\n",(void*)&target_var);printf("Symbol for uprobe: trace_target_func\n\n");clock_gettime(CLOCK_MONOTONIC,&start);while(true){// 执行一些高负荷计算以模拟工作负载for(inti=0;i<500;i++){dummy_math+=sin(i)*cos(iterations);dummy_math=sqrt(fabs(dummy_math+1.0));}// 触发探针trace_target_func((uint64_t)dummy_math+iterations);iterations++;// 每秒测量一次吞吐量clock_gettime(CLOCK_MONOTONIC,&now);elapsed=(now.tv_sec-start.tv_sec)+(now.tv_nsec-start.tv_nsec)/1e9;if(elapsed>=1.0){printf("Throughput: %.2f thousand iterations/s | Current Value: %.2f\n",(iterations/elapsed)/1e3,dummy_math);iterations=0;clock_gettime(CLOCK_MONOTONIC,&start);}}return0;}在下面的示例中,程序使用gcc -O3 perf_test.c -lm -o perf_test编译,然后执行。在我的机器上(使用低功耗优化的 Intel® Pentium® Silver J5005 CPU)运行该程序时,输出如下:
Target address for watchpoint: 0x55c55f9eb040 Symbol for uprobe: trace_target_func Throughput: 56.13 thousand iterations/s | Current Value: 1.51 Throughput: 55.80 thousand iterations/s | Current Value: 1.84 Throughput: 56.11 thousand iterations/s | Current Value: 1.55 Throughput: 56.42 thousand iterations/s | Current Value: 1.80当使用sudo bpftrace -e 'uprobe:./perf_test:trace_target_func { @ = count(); }'将 uprobe 附加到trace_target_func函数上来计算函数被调用的次数时,吞吐量显著下降:
Throughput: 34.46 thousand iterations/s | Current Value: 1.32 Throughput: 34.99 thousand iterations/s | Current Value: 1.32 Throughput: 35.07 thousand iterations/s | Current Value: 1.63 Throughput: 34.78 thousand iterations/s | Current Value: 1.58当使用sudo bpftrace -e 'watchpoint:0x558b32ba9040:8:w { @ = count(); }'将硬件断点附加到target_var变量上时,输出也有所下降,但幅度略小于 uprobe:
Throughput: 38.61 thousand iterations/s | Current Value: 1.46 Throughput: 38.80 thousand iterations/s | Current Value: 1.84 Throughput: 38.75 thousand iterations/s | Current Value: 1.66 Throughput: 39.09 thousand iterations/s | Current Value: 1.84因此,在没有探针的情况下,我们大约有 56.115 千次迭代/秒;使用 uprobe 时,约为 34.825 千次迭代/秒;使用硬件断点时,约为 38.8125 千次迭代/秒。这意味着,在这个特定的基准测试中,uprobe 的开销约为 38%,而硬件断点的开销约为 30%。确切的开销可能因 CPU 架构、工作负载以及被监控函数或变量被访问的频率而异。
在这两种情况下,开销仍然显著的原因在于,当探针被触发时执行 eBPF 程序所需的 CPU 模式切换。触发 eBPF 程序的方式会影响开销,但 CPU 模式切换本身以及 eBPF 程序的执行是主要的贡献因素。
结论
在这篇博文中,我们讨论了如何使用 eBPF 硬件断点来监控 PostgreSQL 的内部操作,例如事务 ID 生成和 OID 分配。我们还比较了硬件断点与 uprobe 的开销,发现硬件断点的开销可能略低于 uprobe。