来源: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 函数:
assembly
(gdb) x/10bx bms_is_member
0x55e0c2f7242c <bms_is_member>: 0x55 0x48 0x89 0xe5 0x48 0x83 0xec 0x20
0x55e0c2f72434 <bms_is_member+8>: 0x89 0x7d
bms_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),函数的第一个字节会改变:
assembly
(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):
bash
grep -m1 flags /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
gdb -p $(pgrep -o postgres)
(gdb) print &TransamVariables->nextXid
$1 = (FullTransactionId *) 0x7f6791925608
之后,我们可以在 bpftrace 中使用该信息,在 TransamVariables->nextXid 的内存地址上设置一个硬件断点,并在其被访问时触发 eBPF 程序。此外,eBPF 程序可以读取 nextXid 的值(参见下面 bpftrace 命令中的 *(uint64 *)0x7f6791925608 表达式),并连同访问它的进程 ID 和命令名一起打印出来:
bash
sudo bpftrace -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 的新值。例如:
sql
test2=# SELECT pg_current_xact_id();
pg_current_xact_id
--------------------
2246
(1 row)
test2=# SELECT pg_current_xact_id();
pg_current_xact_id
--------------------
2247
(1 row)
bpftrace 命令的输出显示了每次 nextXid 更新时的进程 ID、命令名和新值:
text
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 的内容,然后为下一个间隔清空映射:
bash
sudo bpftrace -e '
watchpoint:0x7f6791925608:8:w {
@count[comm] = count();
}
interval:s:1 {
time("%H:%M:%S: ");
print(@count);
clear(@count);
}'
运行上述 bpftrace 命令时,它会每秒打印硬件断点被触发的次数,这对应于 PostgreSQL 中创建的事务数。例如,输出可能如下所示:
text
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
(gdb) print &TransamVariables->nextOid
$1 = (Oid *) 0x7f6791925600
为了监控 OID 分配,我们可以使用一个简单的 bpftrace 命令,在 TransamVariables->nextOid 的内存地址上设置一个硬件断点,并在其更新时打印 nextOid 的新值:
bash
sudo bpftrace -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 的新值:
sql
test2=# CREATE TABLE test100();
CREATE TABLE
test2=# CREATE TABLE test101();
CREATE TABLE
test2=# CREATE TABLE test102();
CREATE TABLE
test2=# SELECT 'test100'::regclass::oid;
oid
-------
57539
(1 row)
bpftrace 命令的输出显示了每次 nextOid 更新时的进程 ID、命令名和新值。它还显示了表 test100 的 OID 是 57539。输出的第一行对应于表 test100 的 OID 分配;表创建后,nextOid 递增到 57540。
text
[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 映射的内容,然后为下一个间隔清空映射:
bash
sudo bpftrace -e '
watchpoint:0x7f6791925600:4:w {
@count[tid, comm] = count();
}
interval:s:5 {
time("%H:%M:%S: ");
print(@count);
clear(@count);
}'
上述 bpftrace 命令的输出将显示每五秒每个后端进程触发硬件断点的次数,这对应于每个 PostgreSQL 后端分配的 OID 数量。例如,输出可能如下所示:
text
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 对其进行监控。
c
#include <stdio.h>
#include <stdint.h>
#include <time.h>
#include <stdbool.h>
#include <math.h>
// 用于硬件监视点的全局变量
volatile uint64_t target_var = 0;
// 用于 uprobe 的函数
__attribute__((noinline))
void trace_target_func(uint64_t val) {
target_var = val;
}
int main() {
uint64_t iterations = 0;
struct timespec start, now;
double elapsed;
double dummy_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(int i = 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);
}
}
return 0;
}
在下面的示例中,程序使用 gcc -O3 perf_test.c -lm -o perf_test 编译,然后执行。在我的机器上(使用低功耗优化的 Intel® Pentium® Silver J5005 CPU)运行该程序时,输出如下:
text
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 函数上来计算函数被调用的次数时,吞吐量显著下降:
text
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:
text
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。