ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostgreSQL CPU飙高100%:从现象定位到根因治理的完整排查指南

PostgreSQL CPU飙高100%:从现象定位到根因治理的完整排查指南

凌晨两点被值班电话叫醒,生产环境的PostgreSQL实例CPU飙到100%,业务接口超时告警刷屏——这大概是每个运维和DBA都经历过的场景。我第一次遇到的时候手忙脚乱,直接重启了数据库,业务确实恢复了,但根因没找到,两周后同一个坑又踩了一次。后来我沉下心来把整个排查过程梳理成一套固定打法,基本能做到一刻钟内定位元凶。这篇文章就把PostgreSQL CPU 100%的排查思路完整讲一遍,覆盖从现象确认、根因分析到处理手段、长期预防的完整链路,里面包含可直接执行的SQL脚本,也包含我在现场踩过的坑和总结的教训。

适合谁看?只要生产环境跑着PostgreSQL,无论你是专职DBA、运维还是后端开发,都值得收藏。即使你不是专职数据库管理员,遇到CPU飙高时知道第一步做什么、哪几条SQL能帮你找到问题,就已经能解决掉80%的焦虑。

1. 现象确认与初步判断

1.1 CPU飙高不一定就是数据库在跑大查询

收到CPU告警后,我的习惯是先做三件事:打开一个终端执行top,确认是不是postgres进程本身在消耗CPU,观察load average的变化趋势。这听起来像是废话,但真有过几次告警是其他服务把系统资源挤爆了,数据库只是被殃及池鱼。

top -b -n 1 | head -20

重点看两列:%CPU和%MEM。如果排在前面的是多个postgres进程,CPU占用在90%以上,那基本可以坐实是数据库内部的问题。此时还要顺手看一眼top里进程的完整命令行,确认是普通的backend连接进程还是autovacuum worker——这两类的处理方式完全不同,如果是autovacuum在狂飙,处理优先级和思路跟慢查询是两码事。

另一个容易误判的点:CPU 100%未必意味着数据库在高效干活。有一种常见情况是数据库CPU在疯狂消耗,但同时大量会话处于wait_event_type='IO'的状态,说明系统其实是在追着IO跑,CPU有一部分花在等待和上下文切换上。我在确认问题时会顺手跑一遍vmstat 1 5,看us、sy、wa三列的比例。wa如果很高,说明磁盘才是瓶颈,这时候就算CPU爆到100%,本质也是IO问题绕了一圈体现在CPU上,光盯着数据库调SQL是不够的。

1.2 第一时间采集现场信息

一旦坐实是数据库的问题,我在动手“修复”之前,会先抢时间把现场信息固定下来。原因很简单:一旦你杀掉那个查询、重启实例或者重置连接池,很多线索就消失了。复现问题的成本比多花两分钟采集信息要高得多。

快速采集的清单包括:

  1. 当前活跃会话快照:pg_stat_activity里所有state='active'的记录,带上query。
  2. 锁等待信息:pg_locks和pg_stat_activity联查,确认有没有blocked的会话。
  3. 系统层指标:top、vmstat、iostat各留一份记录。
  4. 数据库日志:检查PostgreSQL日志目录下有没有错误或慢查询记录,log_min_duration_statement如果开着,可以直接看到哪些SQL超过了阈值。

这一步不需要做复杂的分析,先把原始数据存下来,然后才是真正的排查。我要把这条经验放在最前面强调:一切排查动作之前,先留证据。现场没了,后面的分析全是猜测。

2. 定位问题SQL的几个实用手段

2.1 盯住pg_stat_activity

PostgreSQL定位CPU爆炸最直接的视图就是pg_stat_activity。它会实时展示每个后端进程的状态、当前执行的SQL、执行开始时间、等待事件等关键信息。CPU飙高时,第一步就是在数据库里查正在跑的会话:

SELECT pid, usename, datname, state, wait_event_type, wait_event, now() - query_start AS query_time, left(query, 200) AS query_preview FROM pg_stat_activity WHERE state = 'active' AND pid <> pg_backend_pid() ORDER BY query_start ASC;

这个查询会按照执行时间从早到晚列出所有活跃查询。通常CPU 100%的情况下,你会看到某一条SQL的query_time比其他人大一个量级,或者同时有多条重SQL在并行执行。left(query, 200)只是为了预览,看到嫌疑SQL后,再去日志或通过pg_stat_statements拿完整文本。

执行时间只是一个维度,还需要关注wait_event_type那一列。如果wait_event_type='Lock',说明查询不是自己在算,而是在等别人的锁,CPU高可能是持有锁的那条SQL导致的;如果wait_event_type='IO',比如DataFileRead,那系统可能在疯狂扫盘。锁定目标SQL之后,再往下走就是还原它的执行计划。

这里要提一个很容易踩的坑:只看pg_stat_activity容易漏掉那些反复快速执行的短查询。短查询本身单次执行只要几十毫秒,CPU占用不高,但被高并发循环调用时,总量会非常惊人。你截图的瞬间它可能不在活跃列表里,或者一闪而过。这种情况就需要借助历史统计视图来定位。

2.2 翻历史账:pg_stat_statements

pg_stat_statements是PostgreSQL内置的SQL统计插件,地位相当于MySQL里performance_schema的一部分功能。如果生产环境还没启用,需要先在postgresql.conf里配置shared_preload_libraries = 'pg_stat_statements'并重启实例才能生效。如果你还没开,强烈建议下次维护窗口就把它加上,没有历史统计数据的排查,基本等于盲人摸象。

启用状态下,可以用这条SQL直接拉出累计耗时最高的查询(PostgreSQL不直接统计CPU,但可以从总执行时间和调用次数来近似):

SELECT calls, total_exec_time AS total_ms, mean_exec_time AS avg_ms, max_exec_time AS max_ms, rows, shared_blks_hit, shared_blks_read, left(query, 120) AS query_preview FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;

注意:total_exec_time统计的是会话执行SQL的总时间,其中包含等待IO、等待锁的时间,并不严格等于CPU时间。但在CPU 100%场景下,排在前面的基本就是嫌疑人。观察mean_exec_time和calls的组合可以区分两类问题:要么是单条SQL执行时间巨长,要么是单条SQL不长但被调用了几十万次、累计时间爆炸。

如果要更接近“纯CPU”的视角,可以结合shared_blks_read与shared_blks_hit的比例。如果shared_blks_read非常高,说明大量物理IO,SQL在反复扫表;如果是hit高而read低,才更可能是纯CPU计算密集型问题。这两种情况后续的处理思路不一样。

2.3 还原执行计划,找到最耗时的节点

拿到嫌疑SQL后,常规操作是用EXPLAIN ANALYZE把它跑一遍,观察执行计划。这一步要强调:执行计划里看的不只是“哪一步慢”,而是“估算行数和实际行数的巨大偏差”。

EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT ...;

假设一条SQL执行了8秒,EXPLAIN结果里某个Seq Scan节点的actual time是8秒,而rows的估算值是1000行,实际扫了100万行,那就找到了核心矛盾:优化器基于错误的统计信息选了一个糟糕的路径。这时候解决方向很明确——重新ANALYZE、更新统计信息,或者检查表膨胀、索引是否失效。

另一个常见问题是计划中出现了大量Nested Loop嵌套,每一层loop都带着一次索引扫描。当外层扫描行数比预想多时,内层索引扫描会被放大执行几万次,总耗时就会爆炸。这类问题在报表类关联查询里非常典型,执行计划看起来合理,但实际数据分布一变化,执行成本就会失控。

我在实际操作中会带上BUFFERS参数,它能告诉你每个节点读取了多少个buffer,把逻辑读和物理读拆开看。如果某个节点buffers数值巨大,即使时间显示不算特别慢,也要高度警惕,因为它可能会随着并发升高而持续放大。

3. 常见CPU飙高的根因分析

3.1 统计信息失效与执行计划失控

PostgreSQL的查询优化器依赖表上的统计信息来估算代价。统计信息是ANALYZE或者autovacuum自动采样得到的,正常情况下会定期更新。但如果一张表在短时间内数据量发生剧烈变化——比如大批量导入、历史数据被大量UPDATE、数据被批量删除——而统计信息还没来得及更新,优化器就会拿着过时的分布数据去选择执行计划。

结果就是优化器认为“这个表只有1000行,走全表扫描就行”,但实际上表已经膨胀到5000万行,一个Seq Scan直接把CPU和IO同时打满。这类问题的特征是:SQL逻辑没变,之前跑得很快,突然某一天就慢了;或者同一套SQL在测试环境正常,生产环境就慢得不忍直视。

处理方式分两层。立即缓解的方法是:

ANALYZE verbose 表名;

或者临时SET enable_seqscan = off绕过全表扫描,但这个方法只能应急,不能长期用。禁掉全表扫描会让优化器丧失一个重要选项,其他SQL可能因此变慢。

长期治理则是让autovacuum和ANALYZE的策略跟数据变化节奏匹配。PostgreSQL的autovacuum默认参数按通用场景配置,如果业务有大量批量更新,可以考虑把该表的autovacuum_analyze_scale_factor调小,让它更频繁更新统计信息。还有一种做法是对这类热表定期在低峰期手动执行ANALYZE,把交给自动机制的部分减少一点。

3.2 慢查询引发连接池雪崩

CPU 100%往往不是一条慢查询的独角戏,而是慢查询占着连接,新请求进不来,应用侧不断重试、不断新建连接,连接池被撑爆,PostgreSQL被迫为越来越多的backend进程分配资源,CPU进一步飙升。这是一个典型的正反馈:越慢越阻塞,越阻塞越慢。

我在现场见过一个印象很深的场景:单条慢查询其实只跑了3秒,但应用侧的超时时间是2秒。3秒的查询让应用超时,应用重试,重试又产生更多并发查询,数据库CPU总量快速攀升。最后靠kill掉前面的慢查询才把雪崩止住。

这类问题的特征是执行计划可能并没有那么糟,问题出在并发度和连接数上。pg_stat_activity里会看到大量state='active'但query文本雷同的会话,或者大量state='idle in transaction'的会话占住连接不放。处理思路上要同时做两件事:杀掉拖尾的会话;把连接池的最大连接数降下来,让应用侧排队重试而不是无限新建连接。连接池参数在应用侧一般叫maxPoolSize之类的,从源头上限制并发才能防止雪崩再次发生。

3.3 在SQL里做昂贵的计算

另一种典型的CPU杀手是:把不适合在数据库里做的计算写进了SQL。典型的例子包括:

  • 对索引列使用函数:WHERE date(created_at) = '2024-01-01'会让时间列上的索引失效,系统必须全表扫描再逐行计算date()函数。
  • 大量正则匹配:WHERE content ~ '某复杂正则'在几百万行的text列上逐行匹配,CPU消耗非常恐怖。
  • 隐式类型转换:字段类型和参数类型不匹配导致索引失效,比如字符串字段和数值参数比较。
  • 复杂JSON/数组操作:在查询中对jsonb字段做大量嵌套解构和聚合计算。

这些问题的共性在于:数据库在数据量级变大之后,逐行CPU计算成本会被放大到不可接受。处理这类问题,第一步永远是先看执行计划中是否存在类似的Filter节点耗时,而它前面的扫描节点本身没有有效索引支撑。第二步是考虑把函数计算挪到应用层,或者改用表达式索引、预计算字段,让数据库回到“按索引取数”的舒适区。

比如一个更友好的写法:

-- 错误示范:对索引列套函数 SELECT * FROM orders WHERE date(created_at) = '2024-01-01'; -- 正确写法:走范围查询 SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';

这个改写前后逻辑一致,但后者可以正常走created_at的B-tree索引,CPU消耗天差地别。我在review代码时看到这种写法,都会直接标注为高风险。

3.4 连接风暴、空闲事务与autovacuum干扰

除了SQL本身,还有几个容易被忽略的CPU消耗来源。

连接风暴往往发生在应用刚刚重启、连接池预热阶段。所有实例同时建立新连接,PostgreSQL要为每个连接fork进程,成百上千的连接在短时间内涌入,CPU时间和内存都会瞬间拉高。如果网络层还有健康检查频繁发起短连接,也会造成类似问题。

空闲事务是用完没提交也没回滚的事务,它们不消耗大量CPU,但会阻塞autovacuum清理死元组,还会把连接占住不放,最终导致连接池见底,新查询排不上队。观察pg_stat_activity里state='idle in transaction'的会话,如果数量很多且持续时间长,就要推动业务侧排查事务管理逻辑,同时考虑在连接池层面设置较短的idle超时。

autovacuum worker占CPU是另一个长期被低估的因素。如果表膨胀严重,autovacuum会长时间全表扫描清理死元组,一次性消耗大量CPU和IO。这种场景下CPU 100%的元凶不是某个查询,而是后台维护进程。解决思路是错峰:调整autovacuum的代价模型参数,比如调高autovacuum_vacuum_cost_limit让它跑得快一些,或者调低autovacuum_vacuum_cost_delay减少对业务IO的影响,同时配合监控工具观察进度。业务上也要治理大量UPDATE/DELETE产生的死元组,避免表无限膨胀。

这里要重点强调:任何case里,都要先看到底是哪个进程在烧CPU,不要上来就kill查询。我有几次差点误伤,其实慢查询并不严重,反而是autovacuum在跑,按错误的对象处理会造成更大的混乱。

4. 一套可落地的排查流程

4.1 五分钟快速定位的脚本化思路

我把平时排查的节奏总结成一套固定动作,目标是“五分钟内锁定嫌疑对象,不慌乱、不重复动工”。

第一步(0-1分钟),确认系统层现象:

top -b -n 1 | head -25 vmstat 1 3

判断是postgres进程还是其他进程消耗CPU;观察us和sy比例,sy高要关注上下文切换。

第二步(1-3分钟),采样数据库活跃会话:

SELECT pid, usename, datname, state, wait_event_type, wait_event, now() - state_change AS state_duration, now() - query_start AS query_duration, query FROM pg_stat_activity ORDER BY state DESC, now() - query_start DESC;

隔几秒拍两张快照,对比哪些会话的query_duration在持续增长,同时把结果存到本地文件。

第三步(3-4分钟),调用pg_stat_statements查Top SQL(如果开启的话),判断是单条长SQL还是高频短SQL。

第四步(4-5分钟),确定处理优先级:如果存在一条语句执行时间极长的SQL,先pg_cancel_backend;如果锁等待严重,找出阻塞源头;如果发现连接数异常膨胀,通知应用侧先缩连接池。

这套动作我不建议在出事之后才第一次演练。平时可以用它在测试环境做一次模拟演习,跑一遍就知道哪些工具没开、哪些权限没有。真到现场再摸索工具,心态和节奏都会很被动。

4.2 应急处理手段:从温和到强硬

处理正在飙CPU的查询,手段要分级。我的原则是:能取消就不终止,能终止就不重启。

第一级,温和取消:

SELECT pg_cancel_backend(pid);

这个函数会向目标后端进程发送取消请求,让正在执行的查询优雅终止。对大多数长时间运行的SQL有效,不会破坏事务状态。

第二级,强制终止:

SELECT pg_terminate_backend(pid);

当pg_cancel_backend无效时(比如查询卡在锁等待或者某些不可中断的IO上),需要用这个函数强制终止后端进程。它会断开该会话,事务回滚,应用侧会收到连接断开错误,需要处理重连逻辑。

第三级,万不得已才重启实例。生产环境重启PostgreSQL代价很高,不仅所有连接断开,还会丢失shared buffer里的缓存,重启后的短时间内,所有热数据都要从磁盘重新加载,往往会让系统变得比重启前更慢。所以不到完全卡死,我不建议直接重启。

在决定杀会话之前,还要再三确认pid是否就是你要处理的那个会话。误杀一个事务性连接,在业务高峰可能引发一批失败请求,这个责任比慢查询本身更麻烦。所以我在操作前都会再用一次精确查询来核对:

SELECT pid, usename, client_addr, query, now() - query_start FROM pg_stat_activity WHERE pid = 目标pid;

4.3 恢复后的验证与复盘

处理完紧急情况后,别急着认为事情已经结束。我每次都会做三件事:第一,观察5-15分钟,确认CPU回落到正常区间,活跃会话数量稳定;第二,把前面保存的现场信息翻出来,定位根因,区分是SQL问题、统计信息问题、连接管理问题还是autovacuum问题;第三,写一份简短的事故记录,把时间线、证据、处置动作和根因判断都写进去。

事故记录的价值不在出事当下,而在于积累。时间久了之后,你会发现大多数事故都有相似的模式,同一类问题第二次出现时,你只需要打开以前的记录,照着路径就能快速处理。我处理过的生产事故,最后帮我节约最多时间的都是之前写的这类记录。

5. 预防措施与长期治理

5.1 监控告警的合理配置

CPU 100%这种问题,理想的处理方式是在它变成事故之前就发现苗头。监控体系需要覆盖三个层次:系统层(CPU、内存、IO、load)、数据库层(活跃连接数、等待事件分布、事务年龄、死元组数量)、SQL层(慢查询、执行计划变化)。

数据库层几个关键指标我会特别关注:

  • 活跃会话数:设置阈值告警,比如连续5分钟活跃session超过50,就要人工介入。
  • 最长事务时间:超过10分钟的事务需要告警,可能阻塞autovacuum。
  • 等待事件分布:当pg_stat_activity中某种wait_event占比异常升高,说明有问题在积累。
  • 死元组比例:单表死元组数量超过总行数的一定比例,就要关注膨胀趋势。

告警不要太敏感,否则夜里告警疲劳之后,真出问题反而没人响应。我的经验是把阈值设在“能抓住85%的真实问题,同时每周误报不超过2次”的水平。

5.2 慢查询治理的常态化

慢查询治理不是一次性的运动。我会在业务上线评审时就把所有SQL的EXPLAIN结果固定为基线,后续每次发版对比执行计划是否出现质的变差。PostgreSQL的auto_explain插件可以记录慢查询的执行计划,开启后配置:

auto_explain.log_min_duration = '1000' auto_explain.log_analyze = on auto_explain.log_buffers = on

这样任何超过1秒的查询都会在日志里留下完整的执行计划和分析信息,排查时不再依赖“恰好在现场抓证据”。这个插件对性能的影响非常小,强烈建议默认开启。

每周在低峰期对Top慢查询做一次纠察:查询pg_stat_statements,找出平均执行时间排名靠前的SQL,逐个检查执行计划是否有优化空间、统计信息是否需要刷新、索引是否缺失。把这些检查固定成周期性任务,比等告警出现再救火要省力得多。

5.3 参数与部署层面的调优方向

最后提几个长期调优方向,都是方向性建议,具体参数取决于硬件和业务模型。

  • shared_buffers:一般建议设置为物理内存的25%左右,但不要盲目调大,过大时缓存管理开销反而增加。
  • work_mem:排序和哈希操作的内存上限。调大能减少临时文件,但要小心并发放大,每个连接都可能分配这个内存。比如work_mem=64MB、100个并发排序就意味着6.4GB内存压力。
  • max_connections:控制连接上限,配合应用端连接池一起设置。连接数不是越高越好,每个连接都有内存和CPU成本。
  • vacuum相关参数:在业务低峰允许vacuum更快完成,但要在业务压力大的时段控制它对CPU/IO的影响。
  • 分区表和大表的定期维护:数据生命周期管理能从根本上减少表膨胀和统计信息漂移。

调参与硬件的匹配度很关键。同一套参数在SSD和机械盘上的最优值差距可能很大,不要照搬别人的模板,要在自己的环境里用监控数据验证。

6. 常见问题速查表

整理一份速查表,方便现场临阵查阅。这张表基本覆盖了我遇到过的CPU 100%主流场景。

症状可能原因快速处置长期方案
单条SQL长时间运行,CPU飙高统计信息滞后/执行计划失控EXPLAIN ANALYZE检查计划,ANALYZE表,必要时pg_cancel_backend定期ANALYZE、计划变化基线监控
大量相似SQL,平均执行时间不长但并发爆炸应用重试风暴/连接池配置过大应用侧缩连接池,kill多余会话限制并发、连接池排队策略
等待事件显示Lock类,CPU不高也不低锁冲突引发连锁阻塞找出持有锁的源头,terminate阻塞源头事务超时设置、应用事务逻辑治理
IO等待高,CPU有一部分耗在wait大表全表扫描、索引缺失定位慢SQL,添加合适索引索引管理、SQL规范化
autovacuum进程CPU高表膨胀严重/频繁UPDATE监控进度,调整vacuum代价参数死元组治理、分区表/数据生命周期管理
idle in transaction会话扎堆应用事务未提交/连接池泄漏中止空闲事务连接应用层修复事务管理,设置idle超时

这张表虽然已经覆盖了常见场景,但我还是要提醒一句:每一场事故都有它独特的“脾气”。表只能帮你快速缩小范围,最终定位还是要靠现场证据一步步分析。

说实话,干了这么多年,我对PostgreSQL CPU 100%最大的体会就四个字:事在人为。几乎所有CPU事故最后都能在SQL层面、统计信息层面或者连接管理层面找到明确的落点,它不是玄学。只要留存好证据、掌握定位工具、建立监控和排查流程,这类问题的处理会越来越从容。

最后分享一个小习惯:我会在每次处理完故障之后,顺手把当天用到的SQL和排查路径整理到一个笔记里,现在这个笔记已经有几十个案例了。下次再遇到类似告警的时候,打开笔记对着做就行。这套方法不仅适用于PostgreSQL,换个数据库引擎,排查思路也可以平移过去——先看现象,再留证据,然后定位对象,最后根因治理。方向对了,剩下的都是时间问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进