有回我们给一台跑 PostgreSQL 的生产服务器做了例行系统更新,内核和系统管理工具都升了一级。当时的想法很简单:这种维护窗口我们做过很多次,无非是重启一下,等服务起来,看两眼监控就收工。结果第二天业务方在群里连发消息,说后台查询慢得离谱,等于直接把我拉进了一个耗时两天的排查周期。
这篇文章想说的就是这次故障的完整复盘。如果你也在维护 PostgreSQL,或者任何其他对系统底层敏感的数据库服务,那么"服务器更新之后性能异常"这类问题迟早会找上门。我会把这次踩坑的完整链路写出来,从取证、误判、用工具撕开口子,到最终确认根因,再附上一套可以直接抄走的更新前后基线对比流程。内容偏向 DBA、后端开发和运维同学,但只要你负责的服务器上跑着数据库,应该都有参考价值。
1. 异常从一张导出报表开始:第一时间的取证动作
1.1 早上九点的业务告警:从"慢"到"很慢"
业务方反馈说,后台有一个统计报表的导出功能,原本 1~2 秒就能出来的数据,现在要等 30 多秒甚至超时;实时看板的几个接口也在频繁转圈。最开始我以为是数据量涨了,但问了一圈,业务那边没人改过 SQL,数据量也没有明显变化。而且时间点非常明确——前一天晚上我们刚做完系统更新,这个时间点不可能无关。
于是我先去看慢查询日志和活跃会话。这一步很关键:不要一上来就改参数,也不要急着重启数据库,先把现场记录下来。 数据没了,后面排查只能靠猜。
1.2 取证优先级:先留现场,再查原因
我当时的取证动作,按顺序大概是这样做:
- 执行
psql查询pg_stat_activity,把长时间运行的查询、锁等待、事务持续时间全部捞出来; - 把慢查询日志里时间戳在业务反馈时间段内的 SQL 单独截出来;
- 顺手记录当时的系统负载、CPU 使用率、磁盘 IO、内存状态;
- 再对那几条核心慢 SQL 单独执行
explain (analyze, buffers),拿到真实的执行计划和耗时。
这里有一个容易踩的坑:很多人遇到慢查询,第一反应是去调 work_mem、shared_buffers,或者伸手去 kill 掉几个会话。但如果你连执行计划和系统指标都没记录,就把会话清了、缓存刷了,那问题现场就被你自己销毁了。 做取证的人要像对待事故现场一样,先拍照再动手。
1.3 第一波观察结果:负载不高,但查询确实变慢
比较诡异的是,从系统层面看,CPU 使用率并不高,平均负载也没有异常飙升,磁盘 IO 的 util 也就在 30% 上下徘徊。连接数正常,没有任何活跃锁等待。但慢查询日志里确实多了很多之前从未出现的慢 SQL,主要集中在某几张表的统计查询上。
同一句 SQL,用 explain analyze 手动跑,第一次 1.8 秒,第二次 800 毫秒,第三次又变成 2.5 秒。非常不稳定,时快时慢。这种"不太规律"的劣化,通常不是执行计划本身的问题,而是某些底层资源在高负载下出现间歇性竞争。数据库内部看了一圈没发现问题,我于是转向系统层排查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常规体检全部正常,反而让人更慌
2.1 CPU、内存、磁盘IO:一切正常
先说结论:如果只看常规监控,这台服务器一切正常。
我用了 top、vmstat、iostat、sar 这几个命令挨个过了一遍。CPU 整体空闲在 75% 以上,用户态 CPU 和系统态 CPU 都没有异常;内存没有明显的 swap 交换;磁盘队列深度很低,IO 延迟也没有上涨;网络流量和更新前持平。换句话说,从常用指标看,这台机器完全健康。
但慢查询就在那里。业务不会骗人,告警不会骗人,只有监控工具在骗人——准确说,是常规监控工具的粒度太粗,没有把真正被拖慢的环节暴露出来。
2.2 数据库内部检查:执行计划变了没有?
接下来我很仔细地审查了数据库侧:
- 检查了表膨胀和死元组比例,
n_dead_tup并没有异常升高,autovacuum 运行正常; - 对慢 SQL 做
explain,发现执行计划、预估行数、实际扫描行数都和更新前没有明显变化; - 查看
pg_locks,没有阻塞; - 检查
pg_stat_statements,总耗时上涨集中在原来的高频查询上,而不是出现了新的坏 SQL。
我甚至把其中一台只读从库重启了一次,服务恢复后,前几分钟看起来正常,但没过多久慢查询又冒出来了。这说明问题不是"长时间运行累积出来的脏状态",而是每一次查询在执行过程中都遇到了某种底层阻力。
2.3 这个阶段的认知误区
这一步最容易产生误判。因为数据库内部看起来无懈可击,执行计划没变、锁没冲突、慢查询类型又是分散的,我当时一度怀疑是网络抖动或者应用服务器那边出了什么问题。于是我们拉上应用开发一起排查了半小时连接池、超时配置、网关路由——结果当然是一无所获。
现在复盘,这个阶段的教训是:数据库服务器性能异常,并不一定是数据库软件的问题,也可能是数据库依赖的系统底座变了。 而且这种"系统底座"的变化,往往不会被 CPU 使用率这种粗粒度指标反映出来。如果只看常规监控,很容易就把方向带到沟里去走。真正的线索,需要用更底层的工具来挖。
3. 用 strace 和 perf 把看不见的时间找出来
3.1 strace 抓一次查询的完整系统调用
常规工具看不出问题,我决定直接跟踪一个后端进程,看一条慢查询到底把时间花在哪里。
我的做法是:从 pg_stat_activity 里拿到一个正在执行慢查询的进程 PID,然后用 strace 挂上去:
bash复制strace -f -tt -T -p 12345 -o /tmp/pg_slow_trace.log
-tt 打印毫秒级时间戳,-T 输出每个系统调用的耗时,-o 存到文件里。跑几秒后收紧,开始看日志。
结果发现,futex 和 fdatasync 这两个系统调用的单次耗时非常不正常。fdatasync 平均要 15~20 毫秒,个别甚至到了 30 毫秒以上。这个数字在 SSD 上很离谱,正常应该在亚毫秒到 1 毫秒左右。
更扎眼的是 futex 的等待。数据库进程之间的锁竞争虽然不严重,但每次 futex 等待的时间都明显偏长。这说明不是锁本身冲突了,而是等待锁的线程在系统调度层面被拖慢了。这套系统调用的耗时模式,已经能说明问题出在更底层,而不是 PostgreSQL 本身的 SQL 执行逻辑。
3.2 perf top 揭示的热点
接下来上 perf。我关心的是 CPU 时间到底耗在哪:
bash复制perf top -p 12345
结果立刻把矛头指了出来——热点集中在内核的内存管理路径上,尤其是页面分配和释放相关的函数,占比达 30% 以上。这不是用户态查询逻辑的问题,而是内核在处理进程的内存请求时消耗了大量 CPU 时间。
你可能要问:为什么内存分配会拖慢这么明显?因为 PostgreSQL 是一个多进程架构,每个连接都有一个独立后端进程,这些进程会频繁申请和释放内存,尤其是执行查询时中间结果、排序缓冲区、哈希表等都会分配大量内存页。如果内核的内存分配路径变慢,那么每一个数据库查询的每一步都会被拖累。
3.3 把更新前后的系统参数翻出来
有了这个方向,我开始对照系统参数。先看当时最有可能被系统更新改动的几个内核参数:
bash复制cat /sys/kernel/mm/transparent_hugepage/enabled
cat /proc/sys/vm/swappiness
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
cat /sys/block/sda/queue/scheduler
问题找到了。 /sys/kernel/mm/transparent_hugepage/enabled 打印出来的是 always,而更新之前我们部署时特意改成过 madvise 或 never。也就是说,这次系统更新把透明大页(THP)的策略重置为默认的 always 状态了。
再看 CPU 调频策略,也发现更新后从 performance 变成了 powersave。这一下就全说通了:CPU 频率被降下来,加上 THP 开启后内核内存管理路径变复杂,两者叠加,每一条查询的执行时间自然成倍上涨。
4. 元凶确认:透明大页与系统底座的变化
4.1 THP 在 PostgreSQL 场景下的副作用
先说透明大页是什么。简单理解,Linux 内核默认使用 4KB 大小的小页管理内存,而 THP 机制会在条件允许时自动分配 2MB 的"大页",目的是减少页表项数量、降低 TLB miss。
听起来是好事,对吧?但对 PostgreSQL 这类应用来说,THP 的副作用非常明显。数据库进程的内存分配特点是:频繁、量小、对象生命周期长短不一。THP 要生效,内核必须在后台做内存规整(memory compaction),把分散的 4KB 页合并成 2MB 的大页。这个合并过程会引入延迟和 CPU 开销。更麻烦的是,一旦内存碎片化加剧,THP 分配可能失败并触发更重的内存管理路径,导致分配延迟抖动。
而且,数据库查询期间会有大量 mmap 和 brk 操作,THP 开启后这些操作会走更复杂的分配流程,这在低并发场景可能看不出来,但一旦查询并发稍微上来,内核内存管理路径上的代价就会放大。这也能解释为什么 Web 服务或者 Java 应用对 THP 不敏感——它们的内存分配模式不同,后端进程数量、分配频率、内存页的访问模式都跟数据库不一样。
MySQL、MongoDB 也都有官方文档建议关闭 THP,PostgreSQL 社区同样有大量讨论。我们之前部署文档里明确写了要关 THP,但一次系统更新就把它重置了,这提醒我:系统更新后的默认值,才是你真正的敌人。
4.2 实验验证:关闭 THP 之后
确定的怀疑对象之后,我没有直接改配置文件,而是先做临时实验:
bash复制echo never > /sys/kernel/mm/transparent_hugepage/enabled
然后把 CPU 调频策略切回 performance:
bash复制echo performance > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
再重跑之前那些慢 SQL。结果变化非常明显:同一套查询,从平均 2 秒左右降回 50~80 毫秒。perf top 里的内存管理热点也基本消失了。为了验证不是巧合,我重新写回 always,跑一轮发现慢查询又回来了——来回复现了两次,确认就是 THP 的问题。
之后我又用 pgbench 做了一个简单的基准对比,在 THP 开启与关闭两种状态下各跑一轮:
| 场景 | TPS | 平均延迟 |
|---|---|---|
| THP = always | 约 1200 | 约 42ms |
| THP = never | 约 3900 | 约 13ms |
这个差距相当可观。对一个生产数据库来说,TPS 掉到原来的三分之一,业务体感就是"系统卡了"。
4.3 容易被"更新"带偏的其它系统参数
这次事件里,THP 是主因,CPU 调频策略是帮凶。但我在复盘时把服务器上常见的"会和数据库性能强相关、又容易被系统更新重置"的参数全部列了一遍,这里分享一下:
- swappiness:更新后可能被重置为 60,数据库服务器建议调低到 1 或 10,减少内存回收时的换页倾向,降低 IO 延迟抖动;
- NUMA 策略:如果机器是多路 CPU,更新可能导致 NUMA interleave 或自动平衡策略变化,导致远程内存访问增加,延迟上升。建议数据库进程或共享内存使用
numactl --interleave=all或者绑定到本地节点; - CPU 调频 governor:
powersave会让 CPU 动态降频,数据库这种对单线程延迟敏感的场景,建议固定成performance; - IO scheduler:更新后可能从
none/noop变成cfq或kyber,在高并发 IO 下对延迟有影响。SSD 裸设备上通常建议none或noop; - 文件系统挂载参数:
relatime、barrier、discard等参数变化会影响元数据更新开销,更新后如果发现 IO 延迟异常,检查一下/etc/fstab和实际挂载参数。
这些东西在数据库社区里其实都是老生常谈,但问题在于:大部分用户只在部署时调一次,之后就再也不看了。 系统更新会把这些精心调过的参数悄悄改回默认值,而你不会第一时间发现。
5. 把这次教训固化成一套基线对比流程
5.1 更新前"拍照":十分钟收集基线
这个教训的直接产物就是:任何涉及数据库服务器的系统更新前,先花十分钟给服务器"拍照"。 所谓拍照,就是把关键参数和性能基线记录下来,更新后逐项对比。我现在的固定动作如下:
bash复制# 内核版本和启动参数
uname -a
cat /proc/cmdline
# 内存与 THP
cat /proc/meminfo | grep -i huge
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /proc/sys/vm/swappiness
# NUMA
numactl --hardware
# CPU 调频
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
# IO 调度器和文件系统挂载参数
cat /sys/block/sda/queue/scheduler
mount | grep -E '(/data|/var/lib/postgresql)'
# 数据库侧基线
su - postgres -c "pgbench -i -s 50 && pgbench -c 8 -j 4 -T 30 -M prepared"
pgbench 那条是跑一个快速基准,把 TPS 和平均延迟记下来。如果你的业务有典型查询,也可以直接记录下来,更新前和更新后各跑一次。
如果你在容器里跑 PostgreSQL,同样要注意:容器共享宿主内核,宿主更新内核或者系统参数,容器里的数据库一样会中招。 所以这套基线流程不只适用于裸机部署,容器化部署同样少不了。
5.2 更新后逐项对比
更新完成后,把上面的命令再跑一遍,和更新前的记录做 diff。重点看以下几项:
/proc/cmdline是否变化,特别是transparent_hugepage相关参数有没有被移除;/sys/kernel/mm/transparent_hugepage/enabled是不是还是never;swappiness、IO scheduler、CPU governor 有没有被重置;- 文件系统挂载参数有没有变化;
- 用同样的
pgbench命令再跑一轮,对比 TPS 和延迟是否接近。
如果发现某几项变了,即使在监控上看不到明显异常,也要立刻修正。别等业务方来找你的时候再查,那会儿就已经晚了。
还有一点我会特别提醒:如果你用 EXPLAIN 去分析慢 SQL,发现执行计划变了——那就好办了。如果执行计划没变但执行时间翻倍,那几乎可以断定问题在数据库之外。 这个判断准则可以帮你少走很多弯路。同理,如果发现某条 SQL 不稳定、时快时慢,优先怀疑 THP、NUMA、CPU 调频这类系统层因素,而不是马上调整 shared_buffers 和 work_mem。
5.3 让坑一次填平:把关键参数固化到配置里
临时用 echo 修改参数只对当前运行状态生效,重启后很可能又变回去。要让数据库服务器稳定的跑在正确的参数下,要把这些配置固化下来。
关闭 THP 的持久化做法,我推荐用内核启动参数,因为它在内核启动阶段就生效,最稳定。编辑 /etc/default/grub:
text复制GRUB_CMDLINE_LINUX="... transparent_hugepage=never"
然后重新生成 grub 配置:
bash复制grub2-mkconfig -o /boot/grub2/grub.cfg
如果是 UEFI 引导,路径可能换成 /boot/efi/EFI/...,根据发行版调整。
另外也可以用 systemd service 的方式,在系统启动后写入 sysfs:
ini复制[Unit]
Description=Disable Transparent Huge Pages
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled"
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
CPU governor 和 swappiness 也可以做成类似的 systemd unit,或者直接写进 /etc/rc.local(如果发行版还支持)。我现在的做法是:凡是 sysctl 能管的参数就写进 /etc/sysctl.conf,凡是 sysfs 路径的参数就用 systemd unit 管起来。 这样重启后不需要再额外操心。
如果你用的是编译安装的 PostgreSQL(比如在 CentOS 7 上编译 PostgreSQL 16),这些内核参数的影响同样适用,编译时的 glibc、编译器版本也会影响运行时性能,更新前最好把二进制备份好,方便回滚。如果是 Docker Compose 部署的 PostgreSQL,也要把宿主机的这些参数纳入管理——因为容器里的 sysctl 和 /sys 很多是只读的,真正决定底层行为的就是宿主机。你写的 sysctl.conf 放在宿主机里,容器里的 PostgreSQL 才会受益。
那次故障之后,我把这套"更新前拍照、更新后对比"的流程加进了所有运行数据库的服务器维护手册。后来不管是打系统补丁还是升级内,都先跑基线再动刀。这个方法拦下了不止一次可能的性能事故。如果你正准备更新一台跑着 PostgreSQL 的服务器,强烈建议先把参数拍下来,跑一发 pgbench,再开始维护窗口。更新完别急着宣布"完成",先对比,后切换流量。我个人的经验是,在这个流程上多花二十分钟,能给你省下后面两天的排查时间。
