做 PostgreSQL 性能分析的人,几乎每天都离不开 EXPLAIN ANALYZE。但很少有人会去琢磨一件事:这个工具本身是有“测量成本”的。它在每一个计划节点的前后都要读一次系统时钟,节点一多、查询一短,这笔开销甚至会直接影响你看到的执行时间。今天这篇日报想聊的,就是怎么用 x86 CPU 上的 rdtsc 指令,把 EXPLAIN ANALYZE 的计时开销压到极低。如果你经常调优短查询、做性能回归对比,或者单纯想研究 PostgreSQL 底层的时间测量机制,这篇文章应该能给你一些启发。项目不大,坑倒不少,我会把它背后的原理和我实际试出来的问题一并讲清楚。
1. 先搞清楚 EXPLAIN ANALYZE 为什么会有额外开销
1.1 一个计划节点,至少要量两次时间
EXPLAIN ANALYZE 打印出来的每个节点耗时,不是“猜”出来的,而是真实测量出来的。PostgreSQL 在执行计划时,会在每个节点开始执行前调用一次计时接口,节点执行完再调用一次,两次采样值相减,得到这个节点的执行时间。这套逻辑在源码里对应InstrStartNode和InstrStopNode,它们背后依赖的是一组叫做instr_time的抽象类型和宏。
默认情况下,instr_time在 Linux 上走的是clock_gettime(CLOCK_MONOTONIC)。这个函数现在有 vDSO 加速,单次调用大概在几十纳秒,听着不算贵。但一棵计划树往往不止一个节点,少则三五个、多则几十上百个。每个节点都要采两次样,一次 EXPLAIN ANALYZE 跑下来,可能有几百次时间采样被叠加到结果里。对于执行时间只有几百微秒的快速查询,这个比例就相当可观了。
更麻烦的是,EXPLAIN ANALYZE和执行本身是耦合的:它必须先执行 SQL,才能把真实耗时打点记录出来。所以每次执行 EXPLAIN ANALYZE,计时开销都会真实进入Execution Time的统计结果。换句话说,你看到的数字里,本来就含有一部分“测量设备本身”的耗时。
1.2 短查询最容易受到“观测者效应”干扰
物理学里有个观测者效应:测量动作本身会改变被测量的对象。EXPLAIN ANALYZE 的计时也是一个道理。你用一把本身就慢的尺子去量一个短小的东西,量出来的结果里混进了很多尺子的误差。
我举个例子。一条简单查询可能总共只需要 50 微秒,如果它的计划里有 10 个节点,每个节点采样两次,那就是 20 次clock_gettime调用。哪怕每次平均只要 80 纳秒,20 次也累积出 1.6 微秒,占比超过 3%。要是节点更多、查询更快,占比可以到 10% 以上。最气人的是,clock_gettime本身就存在不小的抖动,可能这次 60 纳秒、下次 120 纳秒,于是同一个查询反复执行,Execution Time经常忽高忽低,看起来像计划出了问题,实际上只是时钟采样在“抖”。
大查询反而不受影响。几秒钟的查询里塞进去几微秒的测量开销,基本可以忽略。所以这个优化真正的目标人群,是那些整天盯着微秒级和毫秒级查询的人。
2. rdtsc 是怎么把“找时钟”这件事变便宜的
2.1 TSC 是 CPU 内部的一条秒表
rdtsc 是 x86 平台上的一条指令,全称 Read Time-Stamp Counter。CPU 内部有一个单调递增的计数器,每个时钟周期自动加一。你执行 rdtsc 时,它会把这个计数器的值读到你手里。
关键点在“每个时钟周期加一”。早期 TSC 会随 CPU 主频变化,省电降频时计数变慢,这就没法直接当时间用。后来 Intel 和 AMD 都支持了 Invariant TSC,计数器的基频不再随主频变化,跟操作系统调频完全解耦。也就是说,你可以把它当作一个固定频率的时钟源,只需要知道频率,就能把两次读数的差值换算成纳秒。
读取一次 TSC 的成本极低,大约就是 20 到 30 个 CPU 周期,在 2.5GHz 到 4GHz 的现代 CPU 上换算一下,大概 5 到 15 纳秒。这比通用时钟函数便宜一个数量级。
2.2 为什么 clock_gettime 比 rdtsc 贵这么多
很多人会疑惑:clock_gettime不是也很快吗?vDSO 已经避免了内核态切换,怎么还有那么大开销?
其实clock_gettime要保证的语义太多了。它要检查 clockid 是否合法,要确定当前时钟源,要处理单调性保证,还要在用户态和内核态之间同步时间和校正参数。哪怕 vDSO 把一部分工作移到了用户态,这些校验和数据结构维护也省不掉。更要命的是,为了读取某些时钟源,它往往需要配合内存屏障来保证读数的顺序,这就进一步拉高了单次调用成本。
我找了一组典型的对比,具体数值取决于 CPU 架构和内核版本,但量级可以参考:
| 时间来源 | 单次读取耗时(2.5GHz~4GHz CPU) | 特点 |
|---|---|---|
clock_gettime(vDSO) | 约 40~100ns | 可移植、单调性有保证,但仍有附加逻辑 |
clock_gettime(纯系统调用) | 数百 ns ~ 1us | 某些容器或受限环境下会出现 |
| rdtsc | 约 5~20ns | 极快,但需要自己做换算和防乱序处理 |
| rdtscp | 比 rdtsc 稍高 | 附带核心 ID,能检测跨核迁移,适合更严格场景 |
纳秒级差距在单次调用内确实不起眼,但乘上 EXPLAIN ANALYZE 的采样次数,再乘上你反复执行基准测试的次数,收益就出来了。
2.3 官方为什么没有默认用 rdtsc
看到这里你可能会问:既然 rdtsc 便宜这么多,PostgreSQL 官方为什么不直接换成它?
答案是可移植性。PostgreSQL 要跑在 x86、ARM、PowerPC 等各种平台上,rdtsc 只是 x86 系的专属指令,ARM 上还得用别的虚拟计时寄存器。而且 TSC 在不同硬件、不同虚拟化环境下的行为差异很大,老旧 CPU 上 TSC 不稳定,云环境里读 TSC 还可能被虚拟化层拦截,直接把默认计时源换成它,会制造一堆平台相关的诡异问题。
但好在 PostgreSQL 没有把计时逻辑写死,而是抽出了instr_time这一层抽象。正常情况下你没感觉,但它给了想优化的人一个“合法的动手位置”。这也是这次改造能成立的前提:我们不需要改 EXPLAIN ANALYZE 的逻辑,只需要把底层计时源替换成 rdtsc。
3. 动手改造:把 instr_time 的时钟换成 rdtsc
3.1 找准替换位置
PostgreSQL 源码里负责时间快照的头文件是src/portability/instr_time.h。EXPLAIN ANALYZE 的计时依赖的宏都在这里,比如INSTR_TIME_SET_CURRENT、INSTR_TIME_ACCUM_DIFF、INSTR_TIME_GET_DOUBLE等。
我的建议是先打开这份文件,把当前默认实现完整读一遍。不同版本的宏名称可能略有差异,但思路是通用的:你只需要保证“设置当前时间”“累加差值”“换算成 double 秒/毫秒/纳秒”这几个操作语义不变,底层怎么实现是可以替换的。
3.2 准备一个可用的 rdtsc 读取函数
在 x86_64 下,最简单的 rdtsc 读取可以写成这样:
#include <stdint.h> static inline uint64 rdtsc(void) { uint32 lo, hi; __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi)); return ((uint64) hi << 32) | lo; }这里把 EAX 和 EDX 两个寄存器里的值拼成一个 64 位整数。__volatile__是告诉编译器别把这个指令乱挪位置,避免优化器重新排列导致计数误差。
如果你对精度要求更高,可以在指令前加一条序列化指令,比如lfence,防止 CPU 乱序执行把 rdtsc 提前执行到其他昂贵指令之前。也可以在支持rdtscp的 CPU 上用rdtscp,它会把核心 ID 一并返回,后面排查跨核问题也有迹可循。
3.3 把 instr_time 的数据结构改成计数器
默认的instr_time往往是一个结构体,比如struct timespec。在我们这个改造里,可以直接把它定义成一个uint64,保存 TSC 计数快照。
核心宏大致长这样:
typedef uint64 instr_time; #define INSTR_TIME_SET_CURRENT(t) \ ((t) = rdtsc()) #define INSTR_TIME_ACCUM_DIFF(total, t1, t2) \ ((total) += (t1) - (t2)) #define INSTR_TIME_GET_DOUBLE(t) \ (((double) (t)) * 1e9 / tsc_hz)注意最后一个宏:两个 TSC 计数相减拿到的是“多少个时钟周期”,要乘上 1e9(把秒换成纳秒)再除以 TSC 频率,才能得到纳秒数。这里的tsc_hz是全局变量,需要在程序启动时初始化。
读取 TSC 频率可以走 Linux 内核导出的接口:
cat /sys/devices/system/cpu/cpu0/tsc_freq_khz把 kHz 乘 1000 变成 Hz,就好用了。比如输出 2600000,说明 TSC 基频 2.6GHz,那把tsc_hz设为 2600000000。
3.4 重新编译并做基础验证
改完头文件后,按正常流程重新编译:
./configure --prefix=/opt/pg-rdtsc make -j8 make install然后在测试环境里初始化一个新的数据目录,启动 PostgreSQL,随便跑一条EXPLAIN (ANALYZE, BUFFERS) SELECT 1;。如果输出的时间没有明显异常,说明基础链路通了。
我强烈建议这一步起个单独的前缀目录或者用独立的端口,不要覆盖你日常在用的实例,毕竟这是改源码的实验,风险要隔离。它打印出来的时间是否比原版更“准”,从直觉上可能看不出来,但至少应该在一个合理的范围内。
4. 实测:计时开销降了多少,结果准不准
4.1 构造一个能重复测量的基准
要验证效果,最好的办法不是跑一条特大查询,而是构造一个“节点多、单节点又快”的计划。比如对一张只有几千行的小表做聚合,加上 WHERE 条件,生成一个两三层节点的计划,再反复执行 EXPLAIN ANALYZE,分别记录原版和 rdtsc 版打印出来的 Execution Time。
对比建议这么设计:
- 固定走同一套优化器配置,最好关闭并行,排除跨核调度干扰;
- 一个查询反复跑 5~10 次,记录每次的结果而不是只看单次;
- 用只读查询,避免测试过程中反复对表产生实际写入。
下面是我在一台 x86 测试机上看到的示意结果,数据仅供说明趋势,不同 CPU 内核差异会很大:
| 查询形态 | 计划节点数 | 原版 Execution Time | rdtsc 版 Execution Time |
|---|---|---|---|
| 单表简单聚合 | 3 | 0.18ms | 0.14ms |
| 带子查询的多层计划 | 12 | 0.72ms | 0.61ms |
从绝对值看,差的可能就是几十微秒,但对一个总共才几百微秒的查询来说,已经是一个不可忽视的比例。这还没考虑采样抖动带来的方差,很多时候 rdtsc 版跑 10 次的极差会更小。
4.2 更重要的收益是测量噪声变小
我在测试中最直观的感受是:换了 rdtsc 之后,同一个查询反复执行的 Execution Time 分布更稳定了。默认时钟源受内核时钟源选择、vDSO 维护、中断上下文等影响,单次采样总有点抖动;rdtsc 读的就是 CPU 计数器,简单粗暴,几乎没有额外逻辑,所以读数本身更可预测。
如果你在做的优化工作本身只有 5% 的效果,而测量噪声就有 3%,那结论很容易被噪声淹没。rdtsc 不能消除所有噪声来源,比如缓存、CPU 调度、锁竞争仍然在,但它能把“计时器自身抖动”这一项压到很低,让你更快看清楚 SQL 本身的真实变化。
另外要记住,EXPLAIN ANALYZE 是会真实执行语句的,所以测试写操作时要特别小心。我的习惯是包一层事务再回滚:BEGIN; EXPLAIN ANALYZE ...; ROLLBACK;,这样既拿到了分析结果,又不会把测试数据污染到表里。
4.3 什么场景才值得折腾
不是所有环境都值得动手改源码。我的判断标准很简单:
- 查询执行时间在亚毫秒到毫秒级别,节点数多;
- 你需要在一条 SQL 上反复做对比优化;
- 你手头是固定的 x86 物理机或可控的虚拟化环境;
- 你能接受使用自编译版本的维护成本。
如果只是日常看一条运行几秒钟的慢查询,原版EXPLAIN ANALYZE完全够用。给卡车称重用台秤就行,给蚂蚁称重才需要微量天平,rdtsc 就是那把微量天平,用错了地方反而折腾自己。
5. 踩坑指南:TSC 不稳、跨核迁移、虚拟机慎用
5.1 怎么确认当前 CPU 的 TSC 是稳定的
改造之前,先确认你的 CPU 支不支持恒定 TSC:
grep constant_tsc /proc/cpuinfo如果能搜到constant_tsc标志,说明 TSC 基频不随主频变化。另外最好确认一下nonstop_tsc标志,这个决定 CPU 在省电状态下 TSC 是否仍然保持递增。这两个标志都出现,rdtsc 直接换算时间才比较靠谱。
如果环境里搜不到这些标志,我劝你趁早放弃这个方案。不要拿命去赌一个不稳定的时钟源,结果只会是时间数值乱跳,怎么看都不对。
5.2 跨 CPU 迁移导致时间回退
TSC 虽然全系统共享,但在多路服务器、或者不同物理核心之间,计数器的起始值可能不完全同步。如果测量前被 CPU 调度器切到了另一个核,前后两次 rdtsc 的差值可能是负数,也可能大得离谱。
一种解决办法是绑核。测试前用taskset -c 0固定进程跑到 CPU0 上,减少迁移概率。更严谨的做法是用rdtscp,它会返回核心 ID,你可以在两次采样后校验核心是否一致,不一致就重试。
简单示例:
static inline uint64 rdtscp(uint32 *core_id) { uint32 lo, hi, aux; __asm__ __volatile__("rdtscp" : "=a"(lo), "=d"(hi), "=c"(aux)); if (core_id) *core_id = aux & 0xFFF; return ((uint64) hi << 32) | lo; }低 12 位通常足够区分核心,但严格一点最好再查一下当前 CPU 的 topology 布局。这个坑在物理机上偶尔出现,在云主机上更容易碰到。
5.3 虚拟化环境要额外验证
云实例和虚拟机不是不能用 rdtsc,但需要先做个微观基准确认。有些虚拟化平台会把 rdtsc 指令暴露给 guest,让 CPU 直接访问 TSC,这时候开销确实低;但也有的平台会把 rdtsc 拦截下来,触发 VM-exit,反而比clock_gettime更慢。
在虚拟机上验证的方法很简单:写一个 100ms 级别的循环,分别用clock_gettime和 rdtsc 测量总耗时,对比两者和实际睡眠时间的偏差。如果你发现 rdtsc 算出来的时间严重偏离预期,或者单次读数的开销比clock_gettime还高,那就不要用这个方案。虚拟化的时间问题千奇百怪,适合物理机的手段不适合所有云环境。
5.4 频率校准不能省
TSC 频率看起来很好拿,但陷阱也不少。/proc/cpuinfo里打印的 MHz 很多时候是标称主频,和 TSC 的实际基频不一定完全一致。更好的来源是/sys/devices/system/cpu/cpu0/tsc_freq_khz,这是内核根据实际校准结果导出的。
如果嫌对内核版本有依赖,也可以自己做一次启动校准:先夹住一段真实的单调时钟,在它前后各读一次 rdtsc,用clock_gettime的耗时差反推出 tsc_hz。大致思路是:
clock_gettime(CLOCK_MONOTONIC, &start); c1 = rdtsc(); /* 睡眠或忙等待一段时间 */ c2 = rdtsc(); clock_gettime(CLOCK_MONOTONIC, &end); seconds = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9; tsc_hz = (uint64)((c2 - c1) / seconds);校准期间最好绑核,并且用 delay 而不是只靠nanosleep,因为睡眠可能会被调度打断。拿一次校准的结果去换算所有采样,如果频率差 0.1%,1 毫秒的测量就会误差 1 微秒。对于我们要优化的短查询场景,这已经是不可接受的了。
6. 我最后想补充的实操心得
如果你也想走一遍这个方向,我的建议是:先把最小改动跑通,再考虑收益,不要上来就大改文件。我实际试下来的体会是,这个方案的适用范围其实很窄,它不能让你眼中的 PostgreSQL 变快多少,只是让 EXPLAIN ANALYZE 在短查询场景下更接近真实值。真正有价值的,不是省下来的那几十微秒计时成本,而是它逼着你去理解了一个 EXPLAIN ANALYZE 里最容易忽略的问题:你看到的时间,究竟是从哪一刻开始、到哪一刻结束的。搞明白这一点之后,再去分析慢查询,你会比原来更有底气。