有一次线上接口突然变慢,数据库 CPU 冲到 90%,连接数异常。大家第一反应是加副本,但我做的第一件事是打开慢查询日志,结果发现罪魁祸首是一条平时 30ms 的聚合查询——统计信息过期,执行计划从索引扫描退化成了全表扫描。如果没有慢查询日志,这种问题排查起来就像在茫茫代码里捞针。这篇文章我把 PostgreSQL 慢查询日志的开启、日志内容解读,以及拿到慢 SQL 之后的排查链路从头到尾理一遍,适合刚上手 PG 运维的 DBA,也适合后端同学自查慢查询。
1. 慢查询日志能帮我找到什么(以及为什么监控面板帮不上忙)
先说一个我观察了很久的现象:很多人把 Prometheus、Grafana 配得漂漂亮亮,CPU、内存、磁盘、QPS 全都有大屏,但生产环境出问题时,这些面板帮不上大忙。为什么?因为它们展示的是"数据库这台机器发生了什么",而不是"数据库内部哪条 SQL 做了什么"。
1.1 监控面板负责报警,慢查询日志负责破案
监控面板的可观测粒度最多到"实例级别"。比如 CPU 90%,你只能知道数据库压力很大,但到底是一个 3 秒的聚合查询在刷全表,还是一万个 5ms 的小查询把 CPU 打满,面板上根本分不清。你只能从应用侧一层层翻日志,猜是哪个接口引起的,效率极低。
慢查询日志恰好补上了这一环。它记录的东西非常朴素:哪条 SQL、耗时多久、在哪个时间点执行、由哪个客户端发起的。有了这三样,你就能把"数据库很慢"这个模糊结论,落成"这条 SQL 在这个时间段突然变慢了"这个具体问题。排查链路从这里开始才真正可操作。
1.2 什么时候你该去翻慢查询日志
以下几种场景我都会先去翻慢查询日志,而不是看监控面板:
- 接口偶发超时,但应用日志里没有报错,只有超时记录
- 数据库 CPU 或 IO 长时间处于高位,怀疑有 SQL 拖后腿
- 上线了新查询或者改了表结构,业务反馈"变慢了"
- 大促、月末结算这类流量高峰前,需要提前找出可能会被打爆的查询
慢查询日志在这些场景下就是第一手证据。它比"我猜是某个接口的问题"靠谱得多,也比"全表 EXPLAIN"高效得多——你只需要聚焦在日志里真正超过阈值的语句上。
2. 开启慢查询日志:完整参数配置一次讲透
PG 的慢查询日志不像 MySQL 那样有一个独立的 slow log 文件,它其实是通用日志系统的一部分,只是通过阈值过滤。所以配置逻辑上要分三步:先让日志能写文件,再定义"多慢算慢",最后做好轮转防止磁盘爆炸。
2.1 先让 PostgreSQL 愿意把日志写到文件
最容易被忽略的一个参数是logging_collector。它默认是 off,意思是 PG 把日志直接输出到标准错误,交给进程管理器(比如 systemd)处理。如果你没接管 stderr,日志会散落在系统日志里,慢查询就沉在里面很难捞。
所以在postgresql.conf里先把日志收集打开:
logging_collector = on log_destination = 'stderr' log_directory = 'log' log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'log_destination默认就是 stderr,配合logging_collector = on后,PG 会自己把 stderr 重定向到log_directory目录下的文件里。log_filename里的%Y-%m-%d会按日期生成文件名,建议保留时间戳,方便按天归档。
这里有两个细节你最好知道。第一,logging_collector是 postmaster 级参数,改完需要重启 PG 实例,不是 reload 就能生效。第一次配置时把所有日志参数一次性写进 conf,重启一次最省心。第二,log_destination也可以设成csvlog,会额外产出一个 CSV 格式日志,字段更结构化,方便导入数据库分析。但你得想清楚,CSV 日志文件大概比 stderr 日志大 20% 到 40%,磁盘紧张的话先别开。
2.2 定义"多慢才算慢":阈值参数
这一步是慢查询日志的核心。log_min_duration_statement就是那个阈值,单位毫秒,默认是 -1(关闭)。设置为 1000,表示执行时间超过 1 秒的语句会被记录到日志,包含完整 SQL 文本。
log_min_duration_statement = 1000如果你用的是 PostgreSQL 14 及以上版本,还有一个采样参数值得一起配上:
log_min_duration_sample = 200 log_statement_sample_rate = 0.05这两个参数配合的意思是:执行时间超过 200ms 的语句,以 5% 的概率被采样记录。听起来有点绕,实际场景很好用——当系统里慢查询很多、日志量爆炸时,log_min_duration_statement全量记录会写疯掉,采样记录则能抓出典型样本,又不至于把磁盘打爆。
我自己的习惯是分级配置:log_min_duration_statement设成业务容忍线的 80% 左右,比如业务要求 2 秒内返回,就设 1500ms 到 2000ms,确保超过告警线的 SQL 一条不漏;log_min_duration_sample再设低一档(比如 200ms),配合 5% 采样,收集中等耗时的语句做趋势分析。两者互不冲突,采样参数只对低于硬阈值的区间生效,硬阈值以上的照样全记。
千万别在生产环境开log_statement = 'all',那是记录所有 SQL 的开关,实际跑一个高峰就够你磁盘喝一壶。
改完参数后用ALTER SYSTEM最方便,不需要去服务器上翻文件:
ALTER SYSTEM SET log_min_duration_statement = 1000; ALTER SYSTEM SET logging_collector = on;然后 reload 一次:
SELECT pg_reload_conf();提醒一下,logging_collector属于例外,上面说过它需要重启。reload 之后可以用SHOW log_min_duration_statement;确认当前值,再随手执行一条SELECT pg_sleep(1.2);,然后去日志里看是否出现duration: 1201 ms的记录。这一步能直接验证整条链路是否打通。
2.3 日志轮转和写入格式:防止日志把磁盘打满
慢查询日志开起来之后,最怕的就是没人管它。一个高峰期能产出几个 GB 的日志完全不稀奇,磁盘写满比慢查询本身更致命。所以轮转参数必须一起配:
log_rotation_age = 1d log_rotation_size = 100MB log_truncate_on_rotation = onlog_rotation_age和log_rotation_size谁先触发就切新文件,前者保证每天一个,后者保证单文件不超过 100MB。log_truncate_on_rotation = on表示如果新文件名和时间戳没变,就覆盖旧文件而不是追加。大部分场景建议按照"每天切 + 100MB 上限"来,再配合一个 cron 清理 N 天前的日志。
日志内容本身也要有足够的上下文。默认的日志前缀信息量太少,我建议至少配成这样:
log_line_prefix = '%m [%p] %q%u@%d %a %r '各占位符对应的内容:
%m:带毫秒的时间戳%p:进程 ID%u:用户名%d:数据库名%a:应用名,连接串里配了什么 application_name,这里就显示什么%r:客户端 IP 和端口
%a和%r这两项价值很大。没有它们,日志里只有 SQL,你很难判断是哪个业务模块、哪台应用服务器发来的。很多团队忽略这个细节,等真出问题需要回溯时,只能对着一条孤零零的 SQL 干瞪眼。
3. 日志到手之后,先学会读它
日志开启之后,问题从"系统慢"变成了"日志里有线索但不知道怎么看"。很多后端同学第一次打开 PG 日志会觉得格式奇怪,我来逐行拆开讲清楚。
3.1 一行日志的完整解剖
我随便从线上摘一条慢查询日志:
2025-06-11 14:32:17.883 CST [88214] app_user@mydb 192.168.1.21:54321 LOG: duration: 2341.619 ms statement: SELECT o.id, o.amount, c.name FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.status = 'PAID' AND o.created_at >= now() - interval '7 days' ORDER BY o.created_at DESC LIMIT 100从左到右拆一下:
2025-06-11 14:32:17.883 CST:日志时间,带毫秒。这个时间来自log_timezone参数的时区,不是会话时区[88214]:PID,可以用来关联这个进程后续的日志行app_user@mydb:执行用户和数据库名192.168.1.21:54321:客户端地址和端口,这是log_line_prefix里%r带来的LOG::日志级别,慢查询归在 LOG 级别duration: 2341.619 ms:这条语句的实际执行时间,单位毫秒statement: ...:完整的 SQL 文本
有些语句很长会在日志里换行,后续行没有LOG:前缀,只有 SQL 文本的续行。这时候要按 PID 把多行拼起来,别只抄半截 SQL 去分析。
3.2 log_line_prefix 值不值得精心设计
相当值得。我接手过一套 PG 环境,日志前缀只有%t [%p],拿到一条慢查询之后得靠猜——猜是哪个业务、哪台机器发来的。追了半小时才通过应用日志对上号。
后来我统一规范成'%m [%p] %q%u@%d %a %r ',并且要求所有业务连接串里必须设置application_name。这样任何一条慢查询日志都能直接回答四个问题:什么时候、哪个进程、哪个服务、从哪台机器来。排查效率完全不一样。
如果你用连接池,application_name可能被连接池覆盖成连接池进程的名字,这一点可以在连接串里显式传参解决。总之日志前缀的优先级很高,别省那几个字符。
3.3 几个容易踩的坑:时区、多行 SQL、漏记
第一个坑是时区。log_timezone默认跟随服务端时区,但客户端连接可以指定timezone,这会让 SQL 里的now()等函数产生不同的返回。日志显示的是服务端时间,如果你部署在跨时区环境,排查时要记得换算,别把日志时间当成业务时间直接对接口超时时段。
第二个坑是"漏记"。log_min_duration_statement只记录语句执行时间超过阈值的完整 SQL,但如果是函数内部嵌套的查询,默认不会单独记录嵌套语句。想抓这种问题要把log_min_duration_statement和 auto_explain 配合使用,后面我会讲到。
第三个坑是慢查询日志里没有参数值。日志记录的是参数化后的 SQL 模板还是带字面量的 SQL?默认是带字面量的原始 SQL,这一点比较好。但如果业务用了 PREPARE,日志里只会有$1这类参数占位符,无法直接看到具体值。这时候可以把log_statement临时开成all抓几分钟,定位具体参数,用完必须关掉。
4. 慢 SQL 排查链路:从日志到执行计划到修复
日志里出现了慢 SQL,只是第一步。接下来要搞清楚:它为什么慢?瓶颈在哪?这时候才进入真正决定成败的阶段。
4.1 先复现,再动手,脱离场景谈优化是耍流氓
拿到一条慢 SQL 之后,我建议不要立刻去改索引、改 SQL。先在测试环境或者只读副本上原样执行一遍,最好加上和线上一致的数据量。如果稳定慢,万事大吉,直接进入分析环节;如果时快时慢,就要考虑是不是有并发干扰、锁等待,或者统计信息波动。
复现时有个安全细节必须说:EXPLAIN ANALYZE会真的执行 SQL。对 SELECT 无所谓,对 INSERT、UPDATE、DELETE 来说,它会真实修改数据。正确做法是包在一个事务里执行,然后回滚:
BEGIN; EXPLAIN ANALYZE UPDATE orders SET status = 'SHIPPED' WHERE id = 12345; ROLLBACK;我见过有人直接在库上EXPLAIN ANALYZE DELETE然后没回滚,删了线下数据,这种事故虽然小但特别丢人。养成回滚习惯,永远没错。
4.2 EXPLAIN ANALYZE 到底怎么看
执行计划输出里,我看五个关键点,按优先级排序:
第一,看每个节点的actual time。这是真实消耗的时间,比 cost 可信得多。
第二,看扫描方式。Seq Scan是大范围扫全表,Index Scan/Bitmap Index Scan是走索引。一条慢 SQL 如果出现 Seq Scan 且Rows Removed by Filter很大,说明索引没建对,或者优化器觉得索引没用。
第三,对比估算行数和实际行数。优化器预估 100 万行,实际只返回 1000 行,说明统计信息严重失真。这种情况先ANALYZE table_name;刷新统计信息,很可能执行计划就变好了。
第四,看 Sort 部分。如果输出里有Sort Method: external merge Disk,说明排序溢写到磁盘了。这是work_mem不够的直接证据,但别急着盲目调大 work_mem,优先考虑排序本身能不能走索引避免。
第五,看Buffers: shared hit/read。shared hit 高说明数据基本在内存,read 高说明有磁盘 IO。如果一条 SQL 大量 read,说明 buffer pool 覆盖不足,或数据量确实大到内存放不下。
Seq Scan on orders o (cost=0.00..487122.00 rows=81234 width=32) (actual time=0.105..1523.004 rows=48947 loops=1) Filter: ((status = 'PAID'::text) AND (created_at >= (now() - '7 days'::interval))) Rows Removed by Filter: 3910053上面就是很典型的全表扫描慢查询。优化器预测能过滤到 8 万行,实际过滤了 390 万行,剩下 4.8 万行,耗时 1.5 秒。问题非常明确:过滤列上没有合适的索引。
4.3 一份慢 SQL 的完整体检案例
我拿一个常见的订单查询场景来完整走一遍排查链路。慢查询日志里出现这条语句:
SELECT o.order_id, o.amount, c.name FROM orders o JOIN customers c ON c.customer_id = o.customer_id WHERE o.status = 'PAID' AND o.created_at >= now() - interval '7 days' ORDER BY o.created_at DESC LIMIT 100;线上订单表约 400 万行,这个查询耗时 2.3 秒。慢查询日志显示时间点是每半小时执行一次,业务反馈"运营后台打开订单列表很卡"。
排查步骤,一步步来:
第一步,EXPLAIN ANALYZE 复现,确认确实是 orders 表全表扫描。Rows Removed by Filter高达 390 万,说明扫描了几乎全表才筛出 4.8 万行符合条件的记录。
第二步,看 customers 表上的 JOIN 走的是什么路径。因为是按主键customer_id关联,走的是 Index Scan,每行订单一次索引查找,问题不大。主要瓶颈就在 orders 表的过滤条件上。
第三步,决定索引方案。这里的查询条件有两个:status 等值过滤 + created_at 时间范围过滤,再加上排序字段也是 created_at。最优方案是建复合索引,把等值列放前面,范围列和排序列放后面:
CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC);索引建好后,再 EXPLAIN ANALYZE 一次:
Index Scan using idx_orders_status_created on orders o (cost=0.56..5734.22 rows=48947 width=32) (actual time=0.022..78.445 rows=48947 loops=1) Index Cond: ((created_at >= (now() - '7 days'::interval)) AND (status = 'PAID'::text))执行时间从 2.3 秒降到 78ms,效果立竿见影。
第四步,验证影响面。重新跑一遍慢查询日志,确认这条 SQL 不再出现在慢日志里。同时观察一周的 pg_stat_statements,看这条语句的累计耗时是否明显下降。
这个例子说明一个道理:慢 SQL 排查不能只看 SQL 本身,要结合表结构、数据量、过滤条件选择性一起判断。status 如果只有两个值,选择性很差,单独建 status 索引大概率没用;但把它和 created_at 组合起来,就能让优化器走一条优美的索引路径。
4.4 是 SQL 真慢,还是在等锁
还有一种情况特别坑:日志里 duration 显示 3 秒,但你把 SQL 拿出来单独执行只要 20ms。这时候大概率不是查询本身慢,而是它花了两秒多等锁。
怎么确认?如果现场还在,直接查pg_stat_activity:
SELECT pid, state, wait_event_type, wait_event, query, now() - query_start AS query_age FROM pg_stat_activity WHERE state = 'active' ORDER BY query_age DESC LIMIT 20;如果wait_event_type = Lock,说明确实在等锁。想定位是谁占着锁,要关联pg_locks:
SELECT l.pid, l.locktype, l.mode, a.query AS blocked_query FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid WHERE l.granted = false;但如果事后来看,现场早就没了,怎么办?唯一能依赖的就是日志。建议把这两个参数开起来:
log_lock_waits = on deadlock_timeout = 1sdeadlock_timeout默认也是 1 秒,但log_lock_waits默认是 off。打开之后,任何进程等待锁超过 deadlock_timeout,日志里就会记录等待锁的详细信息,包括哪条语句在等哪个锁。等锁导致的"慢 SQL",从此在日志里也会留下痕迹。
5. 长效防线:pg_stat_statements 与 auto_explain 的组合拳
慢查询日志解决的是"事后排查",但如果你每周都要排查一次,说明你一直在救火。真正靠谱的做法是再加两个 PostgreSQL 自带的扩展,把慢查询观测变成日常习惯。
5.1 pg_stat_statements:把性能账单打出来
pg_stat_statements 是 PostgreSQL 自带的扩展,它的核心能力是统计所有执行过的 SQL,按模板聚合,告诉你每类 SQL 跑了多少次、总耗时多少、平均耗时多少、占了多少 buffer。
配置分两步。先改postgresql.conf或ALTER SYSTEM:
shared_preload_libraries = 'pg_stat_statements'记住这个参数要重启才能生效,因为它要在启动时加载。重启后建扩展:
CREATE EXTENSION pg_stat_statements;然后就能直接查账了:
SELECT query, calls, total_exec_time, mean_exec_time, max_exec_time, rows FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;我一直把它理解成"数据库的性能账单"。它不会告诉你某一条 SQL 有多慢,它告诉你的是:过去这段时间里,哪类 SQL 累计消耗的数据库时间最多。这个视角和慢查询日志互补——慢日志抓单条超标,pg_stat_statements 抓长期大户。
有一个列名坑要提一下:PostgreSQL 13 之前,时间列叫total_time、mean_time、max_time;13 及之后改成了total_exec_time、mean_exec_time、max_exec_time。抄网上的旧查询可能会报错,按你的实际版本调整。
5.2 auto_explain:执行计划自动归档
慢查询日志只记录 SQL 和耗时,不记录执行计划。所以每次排查都要手动 EXPLAIN ANALYZE,有点烦。auto_explain 这个扩展就是干这个的:它会把超过指定阈值的语句的执行计划自动写到日志里。
配置方式同样简单:
shared_preload_libraries = 'pg_stat_statements, auto_explain' auto_explain.log_min_duration = 1000 auto_explain.log_analyze = on auto_explain.log_buffers = onlog_min_duration设为 1000,表示执行超过 1 秒的语句,日志里除了 duration 之外,还会附带一份 EXPLAIN ANALYZE 输出。log_analyze开启会让它输出实际执行时间,log_buffers会输出 buffer 命中信息。
这个扩展最实用的场景有两个:一是函数内部或者复杂业务里,嵌套 SQL 的耗时很难定位,auto_explain 配合auto_explain.log_nested_statements = on可以抓到嵌套计划;二是事后复盘时,日志里直接有当时的执行计划,不用再去生产环境复现。
需要注意,auto_explain 本身有开销。它会对符合条件的语句额外做一次 explain analyze,阈值越小影响越大。生产环境建议阈值保持和慢日志一致,或者略高,别设成 100ms 这种低值。
5.3 定期用 pgBadger 生成巡检报告
如果日志已经按照前文的方式配置好了,还有一个非常推荐的外围工具:pgBadger。它是一个 Perl 写的日志分析器,输入 PG 日志,输出一个 HTML 报告,里面包含慢查询 Top N、执行耗时分布、锁等待统计、临时文件使用等等。
用法很简单,跑一条命令就行:
pgbadger /var/lib/postgresql/log/postgresql-*.log -o /tmp/pgreport.html我一般是每个月或者每个大版本发布后跑一次,把报告发给团队看一眼。它有慢查询 Top 排名,也有时间维度上的热力图,能直观看出哪个时段慢查询集中爆发。这个工具不改变任何数据库配置,纯离线分析,零风险,适合例行巡检。
最后分享两个体会
慢查询日志配置一次只要十分钟,但每次生产抖动时它都能帮我省下至少半天的排查时间。如果你刚开始做 PG 运维,我的建议是:阈值不要一开始就定很低,先按业务容忍度设一个值,观察一两周再往下调;日志轮转和磁盘容量必须一起看,否则日志先于慢查询把磁盘打满,会变成更大的事故。
根据我的个人经验,配置慢查询日志、pg_stat_statements、auto_explain 这三件事,最好在项目第一天就做完,而不是等出现线上问题再补。等到生产抖动时再去翻文档、改参数、重启实例,那可真是一个身心的磨砺。