PostgreSQL 慢查询治理实战:读懂 EXPLAIN ANALYZE 执行计划,落地复合索引、部分索引与覆盖索引

摘要 :慢查询治理最常见的失败姿势是"凭感觉加索引"------加完没变快,索引却越堆越多。本文给出一条可复现的闭环:先用 pg_stat_statements 按累计耗时定位真正拖慢系统的 SQL,再逐行读懂 EXPLAIN (ANALYZE, BUFFERS) 里的 cost、rows、actual time 与 Buffers 信号,最后按"等值列在前、范围列在后"落地复合索引,用部分索引压缩体积,用 INCLUDE 覆盖索引换取 Index-Only Scan。全文附可直接执行的建表脚本与前后计划对比。

导语

线上接口变慢,很多人的第一反应是打开慢日志,挑一条看起来最长的 SQL,给 WHERE 里出现的每个列都建一个单列索引,然后祈祷。

这套做法的问题不在于"加索引",而在于跳过了定位和读计划两步。结果往往是:单次耗时最长的那条 SQL 一天只跑两次,而真正吃掉数据库 60% 时间的是一条平均 8 毫秒、每分钟跑三千次的查询;同时新加的三个单列索引一个都没被用上,只是让每次写入多付了三份维护成本。

这篇文章按"定位 → 读计划 → 建索引 → 复测"的顺序走一遍完整闭环。所有 SQL 都可以在本地库直接执行,你可以边读边跑。

准备一份可复现的数据集

后面所有示例都基于同一张 orders 表。先把它建出来并灌 200 万行数据,这样计划里的 Seq Scan 才有真实的痛感:

sql 复制代码
DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
    id           bigserial PRIMARY KEY,
    customer_id  integer       NOT NULL,
    status       text          NOT NULL,
    billed       boolean       NOT NULL DEFAULT false,
    order_nr     integer       NOT NULL,
    total_amount numeric(12,2) NOT NULL,
    created_at   timestamptz   NOT NULL
);

INSERT INTO orders (customer_id, status, billed, order_nr, total_amount, created_at)
SELECT
    (random() * 50000)::int + 1,
    (ARRAY['paid','pending','shipped','cancelled'])[(random() * 3)::int + 1],
    random() < 0.97,
    g,
    (random() * 5000)::numeric(12,2),
    '2023-01-01'::timestamptz + (random() * 900) * interval '1 day'
FROM generate_series(1, 2000000) AS g;

ANALYZE orders;

最后那句 ANALYZE orders 不是可选项。灌完数据不做 ANALYZE,统计信息还是空表时的样子,规划器会给出完全离谱的行数估算,你读到的执行计划也就失去了参考价值。

慢查询治理的正确姿势:先定位,再读计划

用 pg_stat_statements 找出真正慢的 SQL

pg_stat_statements 是 PostgreSQL 自带的 contrib 扩展,它把执行过的 SQL 按"归一化后的语句"聚合,累计调用次数、总耗时、返回行数和缓冲区命中情况。它不是慢日志的替代品,而是慢日志看不到的那部分真相:高频小查询的累计成本。

它需要在启动时预加载,所以要改配置文件并重启实例:

ini 复制代码
# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'

# 默认只统计顶层语句,改成 all 可以把存储过程内部的嵌套语句也统计进来
pg_stat_statements.track = all

# 计算 query id,pg_stat_statements 依赖它做语句归一化
compute_query_id = on

重启后在目标库里创建扩展,并把计数器清零,避免历史噪声干扰这次观察:

sql 复制代码
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 开始一轮观察前清零,跑一段真实流量后再查
SELECT pg_stat_statements_reset();

接下来是这个扩展最有价值的一条查询。注意排序键是 total_exec_time(累计执行耗时)而不是 mean_exec_time(平均耗时)------我们要找的是"总共吃掉最多时间"的语句:

sql 复制代码
SELECT
    substring(query, 1, 70) AS query_head,
    calls,
    round(total_exec_time::numeric, 1) AS total_ms,
    round(mean_exec_time::numeric, 2)  AS mean_ms,
    rows,
    round(100.0 * shared_blks_hit
          / nullif(shared_blks_hit + shared_blks_read, 0), 2) AS hit_percent
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;

几个列的读法:

含义 怎么用
calls 该语句累计执行次数 高频语句优化收益被次数放大
total_exec_time 累计执行耗时(毫秒) 排序主键,代表对数据库的真实压迫
mean_exec_time 单次平均耗时(毫秒) 判断是"单次慢"还是"次数多"
rows 累计返回/影响行数 rows / calls 远大于业务需要 → 可能少了 LIMIT 或过滤条件
hit_percent 共享缓冲区命中率 明显偏低说明这条语句在反复读磁盘

total_ms 排第一但 mean_ms 只有几毫秒的语句,优化方向通常是减少调用次数(批量化、加缓存);mean_ms 很大而 calls 只有几十的语句,才是索引和执行计划该管的事。先分清这两类,再决定动哪里。

一个容易踩的点:pg_stat_statements 会把字面量归一化成 $1$2,所以你在 query 列里看到的是模板而不是原始 SQL。要拿去 EXPLAIN 时得自己填回一组有代表性的参数值,别用极端稀有值,否则计划会和线上不一致。

EXPLAIN 与 EXPLAIN ANALYZE 的根本区别

这两个命令差一个单词,行为差别却是本质性的:

  • EXPLAIN只做规划,不执行。输出的全是规划器基于统计信息算出来的估算值。语句再重也是毫秒级返回。
  • EXPLAIN ANALYZE真实执行语句,然后把每个节点的实际耗时、实际行数、循环次数一并打印出来。

所以 EXPLAIN ANALYZE 有两个必须记住的副作用:一是慢查询该跑多久还是跑多久;二是如果语句是 INSERT / UPDATE / DELETE,数据会真的被改掉。后面第六节会给出用事务包住的安全做法。

日常排查建议固定用这个组合:

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, total_amount
FROM orders
WHERE customer_id = 42
  AND status = 'paid'
  AND created_at > '2024-01-01';

BUFFERS 选项会额外打印每个节点的缓冲区读写情况,这是判断"慢在 CPU 还是慢在 IO"的关键依据,成本几乎为零,没有理由不加。

读懂 EXPLAIN ANALYZE 输出:三个数字定生死

上面那条 SQL 在没有任何合适索引时,执行计划长这样。请把注意力放在节点结构和数字关系上,绝对耗时会随机器配置浮动:

text 复制代码
Gather  (cost=1000.00..48123.45 rows=38 width=26) (actual time=0.412..212.883 rows=17 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  Buffers: shared hit=1024 read=26310
  ->  Parallel Seq Scan on orders  (cost=0.00..47120.31 rows=16 width=26) (actual time=140.221..205.117 rows=6 loops=3)
        Filter: ((customer_id = 42) AND (status = 'paid'::text) AND (created_at > '2024-01-01 00:00:00+08'::timestamp with time zone))
        Rows Removed by Filter: 666661
        Buffers: shared hit=1024 read=26310
Planning Time: 0.183 ms
Execution Time: 212.941 ms

计划是一棵树,缩进越深执行越早,数据从叶子往根流。读的时候从最深的叶子节点开始往上看,而不是从第一行往下看。

cost / rows / actual time 分别是什么

每个节点后面挂着两组括号,前一组是估算,后一组是实测:

text 复制代码
(cost=0.00..47120.31 rows=16 width=26) (actual time=140.221..205.117 rows=6 loops=3)
      │        │          │        │                │        │          │       │
   启动代价  总代价    估算行数  行宽字节         首行耗时  末行耗时  实际行数  循环次数
  • cost 是抽象代价单位,不是时间。它以"顺序读一个数据页"为 1.0 基准换算而来,只能同一条语句内横向比较大小,拿去和毫秒对应毫无意义。启动代价是"吐出第一行前的开销",排序、哈希构建这类节点启动代价很高。
  • rows 是估算行数,来自统计信息。
  • actual time 单位是毫秒,且是单次循环的平均值 。这是最容易读错的地方:上面那个 Parallel Seq Scan 显示 actual time=...205.117loops=3,它对整条语句的贡献是 205.117 × 3 ≈ 615ms 的 CPU 时间,只是三个进程并行跑掉了,墙上时间才落到 212ms。
  • actual rows 同样是每次循环的平均行数 。嵌套循环内层节点显示 rows=6 loops=3,意味着一共产出约 18 行。

顶部两行汇总也别跳过:Planning Time 是规划耗时,Execution Time 是执行耗时。如果 Planning Time 反而比 Execution Time 大,说明这条语句本身很轻,问题在规划开销上(典型原因是表上索引和分区太多),加索引只会让情况更糟。

估算行数 vs 实际行数偏差意味着什么

这是读计划时第一个要算的比值:actual rows / estimated rows

上面的例子里 rows=16actual rows=6,同一量级,规划器判断是可信的。但如果你看到 rows=1actual rows=250000,偏差三五个数量级,那么这个计划从根上就选错了------规划器以为只有一行,于是选了嵌套循环,实际却要循环 25 万次。

偏差大通常是这几个原因:

  1. 统计信息过期 。大批量导入、大规模删除之后没跑 ANALYZE,autovacuum 还没追上。手动补一次即可。
  2. 多列相关性没被感知 。规划器默认列之间独立,会把 status = 'paid'customer_id = 42 的选择率直接相乘。而现实中"某个客户的订单几乎都是已支付"这类相关性很常见,相乘的结果会严重偏低。
  3. 表达式挡住了统计信息WHERE date(created_at) = '2024-05-01' 这种写法,规划器对 date(created_at) 的分布一无所知,只能用默认选择率硬猜。

第二种情况可以用扩展统计信息(PostgreSQL 10 起提供)显式告诉规划器"这两列相关":

sql 复制代码
CREATE STATISTICS orders_cust_status_stx (dependencies, ndistinct)
    ON customer_id, status FROM orders;

ANALYZE orders;

第三种情况的解法是把表达式改成范围条件(created_at >= '2024-05-01' AND created_at < '2024-05-02'),或者为该表达式单独建一个表达式索引。

时间热点、Buffers 与落盘信号

确认计划形状合理之后,再找耗时最集中的那个节点。方法是拿每个节点的 actual time 末值减去其子节点的 actual time 末值------差值就是这个节点自己花掉的时间,SortHash JoinAggregate 这类节点常常是真凶。

然后看 BUFFERS 给出的四类信号:

输出片段 含义 应对方向
shared hit=N 从共享缓冲区直接命中 N 个页 理想状态,越高越好
shared read=N 不得不从磁盘(或 OS 缓存)读 N 个页 数值远大于 hit → IO 瓶颈,先看能否用索引减少扫描量
temp read/written 用了临时文件,即已经落盘 work_mem 或减少参与排序/哈希的数据量
Rows Removed by Filter: N 读上来 N 行然后被条件丢掉 N 很大 → 过滤发生得太晚,索引没用上

开头那个计划里 Rows Removed by Filter: 666661actual rows=6,等于每捞出 1 行有用数据要白读 11 万行,这是"缺索引"最典型的指纹。

排序和哈希节点还有两个专属的落盘信号,值得单独记住:

text 复制代码
->  Sort  (cost=... rows=200000 width=26) (actual time=...)
      Sort Key: created_at DESC
      Sort Method: external merge  Disk: 24816kB     <-- 落盘了

Sort Method 显示 quicksort Memory: 1234kB 表示在内存里排完了;显示 external merge Disk: ... 就是 work_mem 装不下、写了临时文件。同理,Hash 节点上的 Batches: 1 是内存内完成,Batches: 8 意味着哈希表被切成 8 批轮流落盘。

work_mem会话级 参数,且每个排序/哈希节点各自可用一份,所以别在全局配置里往上猛调。针对单个报表会话临时提高更安全:

sql 复制代码
-- 只影响当前会话,退出即失效
SET work_mem = '64MB';

EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, count(*)
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY customer_id
ORDER BY count(*) DESC
LIMIT 20;

RESET work_mem;

复合索引:列顺序决定生死

回到那条三条件查询。给 customer_idstatuscreated_at 各建一个单列索引,通常只会命中其中一个(或者走 BitmapAnd 合并,代价仍然不低)。正确做法是建一个复合索引,并且把列顺序排对。

最左前缀原则与 B-tree 索引

B-tree 复合索引的本质是按列顺序做字典序排列。理解这一点,所有关于列顺序的规则都能自己推导出来:

text 复制代码
索引 (customer_id, status, created_at) 的物理有序性

customer_id | status  | created_at
------------+---------+------------
     41     | paid    | 2023-05-02
     41     | pending | 2024-07-11
     42     | paid    | 2023-01-08   <-- 先按 customer_id 定位到 42
     42     | paid    | 2024-03-19   <-- 再按 status 定位到 paid
     42     | paid    | 2024-09-30   <-- 段内 created_at 天然有序,范围扫描直接切片
     42     | pending | 2023-02-14
     43     | paid    | 2023-08-21

WHERE customer_id = 42 AND status = 'paid' AND created_at > '2024-01-01'
        └─── 等值 ───┘   └─── 等值 ───┘   └──────── 范围 ────────┘

只有当查询条件能从最左列开始连续用上时,索引才能高效定位。跳过最左列去用第二列,等于在一本按"姓 → 名"排序的电话簿里只知道名字------名字相同的人散落在全书各处。

顺带一句版本变化:PostgreSQL 18 为 B-tree 引入了 skip scan,前导列缺少等值条件时也能有限度地利用索引。但它是兜底优化,不是"列顺序可以随便排"的许可。

列顺序怎么排:等值列在前、范围列在后

一条足够实用的规则:等值条件的列放左边,范围条件的列放最右边,且范围列只放一个

按这个规则给示例查询建索引:

sql 复制代码
CREATE INDEX idx_orders_cust_status_created
    ON orders (customer_id, status, created_at);

ANALYZE orders;

重新执行同一条 EXPLAIN (ANALYZE, BUFFERS),计划会变成这样:

text 复制代码
Index Scan using idx_orders_cust_status_created on orders
      (cost=0.43..92.18 rows=38 width=26) (actual time=0.041..0.118 rows=17 loops=1)
  Index Cond: ((customer_id = 42) AND (status = 'paid'::text)
               AND (created_at > '2024-01-01 00:00:00+08'::timestamp with time zone))
  Buffers: shared hit=4 read=16
Planning Time: 0.207 ms
Execution Time: 0.152 ms

三处变化就是验收标准:Parallel Seq Scan 变成 Index Scan;三个条件全部进入 Index Cond 而不是 FilterRows Removed by Filter 消失,Buffers 从两万多页降到 20 页。

三个条件是否全部落进 Index Cond,是判断复合索引列顺序排对了没有的最快方法。 如果某个条件仍然出现在 Filter 行里,说明它没能参与索引定位,只是在取回的行上做了二次过滤。

把范围列提前会怎样?假设建成 (created_at, customer_id, status),那么 created_at > '2024-01-01' 之后的全部条目里,customer_id 是乱序的,索引只能先按时间切出一大段再逐条过滤。范围条件一旦出现,它右边的列就丧失了定位能力------这就是"范围列必须放最右"的原因。

如果查询还带 ORDER BY,列顺序可以顺便把排序也覆盖掉。索引 (customer_id, created_at DESC) 能让 WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20 直接从索引有序读取,计划里的 Sort 节点会整个消失。

何时复合索引反而没必要

复合索引不是越多列越好,官方文档对它的态度也偏保守。以下几种情况要收手:

  • 超过 3 列通常收益递减。第四列往后的列很少参与定位,却让索引体积和写放大持续上升(B-tree 最多支持 32 列,但那是上限不是建议值)。
  • 最左列本身就极具选择性customer_id = 42 已经把 200 万行筛到几十行时,再拼 status 只是让索引更胖。
  • 只有一个查询模式用得上它。为一个每天跑一次的报表建复合索引,代价由所有写入承担,不划算。
  • 已有索引的前缀能覆盖新需求 。有了 (a, b, c),就不必再建 (a)(a, b)------前者是后两者的超集。

建完索引后隔一段时间回头查一次谁在吃灰,这是控制索引膨胀的常规动作:

sql 复制代码
SELECT
    relname            AS table_name,
    indexrelname       AS index_name,
    idx_scan           AS scans,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;

idx_scan = 0 且体积可观的索引,基本可以判定为纯负担(主键和唯一约束用的索引除外,它们承担约束职责)。

部分索引:给常用子集建更小更快的索引

有一类查询的特征很明显:只关心表中一小部分行。示例表里 billed = false 的订单只占 3%,但每次查未开票订单都要走完整索引。部分索引就是给这个子集单独建一个小索引。

排除常见值 / 排除不关心的值

语法是在 CREATE INDEX 后面挂一个 WHERE

sql 复制代码
CREATE INDEX orders_unbilled_idx
    ON orders (order_nr)
    WHERE billed IS NOT true;

这个索引只包含满足谓词的行,条目数只有全量索引的约 3%。收益是三重的:索引体积小,缓存命中率高;写入时只有满足谓词的行需要维护索引项;扫描时不用越过大量不相关条目。

用法有两个方向:

  • 排除常见值:主流数据(已开票、已完成、正常状态)从不单独查,就把它们排除掉。
  • 排除不关心的值 :软删除表用 WHERE deleted_at IS NULL,只索引活跃数据。

部分唯一索引也很实用------它能表达"只在某个子集内唯一"这种约束,而 UNIQUE 约束做不到:

sql 复制代码
-- 每个客户同时只允许有一条未开票的 pending 订单
CREATE UNIQUE INDEX orders_one_pending_per_cust
    ON orders (customer_id)
    WHERE status = 'pending' AND billed IS NOT true;

命中前提:查询 WHERE 必须蕴含索引 predicate

这是部分索引最大的坑,也是"建了但没用上"的唯一原因:规划器必须能从查询的 WHERE 子句在逻辑上推导出索引谓词成立,否则一律不用这个索引。

能命中:

sql 复制代码
-- 查询条件包含了索引谓词本身
SELECT * FROM orders
WHERE billed IS NOT true
  AND order_nr < 10000;

不能命中:

sql 复制代码
-- 少了 billed IS NOT true,规划器无法证明这些行都在索引里
SELECT * FROM orders
WHERE order_nr < 10000;

蕴含关系不必是字面相等,简单的比较运算可以被推导。比如索引谓词是 created_at >= '2024-01-01',查询条件 created_at >= '2024-06-01' 是它的子集,能命中:

sql 复制代码
CREATE INDEX orders_recent_idx
    ON orders (customer_id, created_at)
    WHERE created_at >= '2024-01-01';

-- 6 月 >= 1 月,蕴含成立,可以走 orders_recent_idx
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total_amount
FROM orders
WHERE customer_id = 42
  AND created_at >= '2024-06-01';

但推导能力有边界,只覆盖 B-tree 可比较运算符和常量的简单情形。EXPLAIN 实际验证,比推理它能不能证明更可靠 。另外要注意,索引谓词里用到的列和索引键列不必相同:上面 orders_unbilled_idx 的键是 order_nr,谓词用的是 billed

还有一个隐蔽陷阱:如果谓词里写了 created_at >= now() - interval '30 days',这个索引会在创建那一刻把范围固化下来,随时间推移逐渐失效。部分索引的谓词必须是不可变表达式,别把动态时间窗口写进去。

别把部分索引当分区用

见过一种用法:为 status 的每个取值建一个互不相交的部分索引,四个状态四个索引,试图模拟分区。

这样做没有收益。规划器一次查询只会挑一个索引用,你付出了四份维护成本,却没得到分区的任何好处(无法整段裁剪、无法独立 VACUUM、无法快速丢弃旧数据)。部分索引解决的是"只查一小部分行",分区解决的是"物理上拆开管理",两者不能互相替代。 真需要按状态或时间物理拆分,就用声明式分区。

覆盖索引:让查询只走索引不回表

即使走了 Index Scan,还有一次开销常被忽略:回表 。索引里存的是索引键加行指针,SELECT 要的其他列得拿指针去堆表里取,这些访问是随机 IO。命中几十行时无所谓,命中几万行时就是主要成本。

Index-Only Scan 与可见性映射

如果查询需要的所有列 都能从索引里直接取到,PostgreSQL 可以走 Index Only Scan,完全不碰堆表。

但这里有个前提常被漏掉:索引里没有存行的可见性信息(哪个事务插入、是否已删除),单看索引无法判断一行对当前事务是否可见。PostgreSQL 的解法是可见性映射(visibility map)------每个堆页对应一个 all-visible 标记位。扫描时如果某条索引项所在的堆页被标记为 all-visible,就可以放心跳过回表;否则还是得回去确认。

推论很直接:刚经历大量写入、还没被 VACUUM 过的表,Index Only Scan 会退化成大量 Heap Fetches。判断依据就在计划输出里:

text 复制代码
Index Only Scan using idx_orders_cust_cover on orders
      (cost=0.43..48.21 rows=41 width=20) (actual time=0.033..0.070 rows=39 loops=1)
  Index Cond: (customer_id = 42)
  Heap Fetches: 0          <-- 0 才是真正的"不回表"
  Buffers: shared hit=5

Heap Fetches 不为 0,说明可见性映射还没跟上。手动补一次 VACUUM 即可让它归零:

sql 复制代码
VACUUM (ANALYZE, VERBOSE) orders;

写入频繁的大表要长期保持 Index-Only Scan,得让 autovacuum 足够勤快,可以针对单表调低触发阈值:

sql 复制代码
ALTER TABLE orders SET (
    autovacuum_vacuum_scale_factor = 0.02,
    autovacuum_analyze_scale_factor = 0.01
);

用 INCLUDE 列构建覆盖索引

要凑齐"所有列都在索引里",把载荷列直接加进索引键当然可行,但那样它们会参与排序和唯一性判断,白付代价。B-tree 提供了 INCLUDE 子句:这些列只作为载荷存在叶子节点,不参与索引排序

sql 复制代码
CREATE INDEX idx_orders_cust_cover
    ON orders (customer_id)          -- 键列:参与定位与排序
    INCLUDE (status, total_amount);  -- 载荷列:仅供读取,不参与排序

然后这条查询就能走 Index-Only Scan:

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS)
SELECT status, total_amount
FROM orders
WHERE customer_id = 42;

INCLUDE 的另一个用法是给唯一索引挂载荷。UNIQUE (order_nr) INCLUDE (status) 既保持 order_nr 唯一,又让"按单号查状态"不必回表------把载荷列塞进键列会破坏唯一性语义,INCLUDE 不会。

代价也要摆明:载荷列会写进每一个索引叶子项 。把 text 长描述或 jsonb 放进 INCLUDE,索引可能膨胀到比表还大,缓存全被它挤占,收益立刻变成负数。选择原则是只放窄类型、且真的被高频查询读取的列------numericboolean、短 varchar 可以,大字段不要。

另外,只要 SELECT 列表里多出一个不在索引中的列(SELECT * 是最常见的破坏方式),Index-Only Scan 立刻退回普通 Index Scan。覆盖索引和查询列表是绑定的,改 SQL 前先想想它还覆不覆盖得住。

实战落地:一个慢查询的完整治理闭环

前面五节是拆开讲的零件,这一节把它们串成一条可重复执行的流程。

复现 → 读计划 → 加索引 → 验证

text 复制代码
 ①定位                ②读计划              ③建索引             ④复测
pg_stat_statements  EXPLAIN(ANALYZE,     CREATE INDEX      再跑同一条
按 total_exec_time   BUFFERS) 看          按等值/范围        EXPLAIN ANALYZE
倒序取 TOP 5    ───▶ rows偏差/Filter ───▶ 排列顺序      ───▶ 对比 Buffers
                     /Sort落盘            CONCURRENTLY       与 Execution Time
     ▲                                                            │
     └──────────────── 没达标就回到 ② 重新读计划 ◀─────────────────┘

四步的具体动作:

① 定位 。用前面那条 pg_stat_statements 查询取 TOP 5,把语句模板里的 $1 换成一组有代表性的参数值。别用 id = 1 这种边界值,也别用只有三行数据的稀有客户。

② 读计划 。固定用 EXPLAIN (ANALYZE, BUFFERS),按顺序检查四件事:计划形状对不对(估算 vs 实际行数偏差)、有没有巨大的 Rows Removed by FilterSort / Hash 有没有落盘、Buffers 的 read 是不是远大于 hit。

③ 加索引 。生产库一定加 CONCURRENTLY,它不会长时间持有阻塞写入的锁:

sql 复制代码
CREATE INDEX CONCURRENTLY idx_orders_cust_status_created
    ON orders (customer_id, status, created_at);

代价是它不能在事务块里执行,而且失败时会留下一个不可用的无效索引。所以建完要检查一遍:

sql 复制代码
SELECT c.relname AS index_name, i.indisvalid
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;

查出来的无效索引要 DROP INDEX 后重建,它不会被查询使用,但会继续拖累写入。

④ 验证 。跑同一条 EXPLAIN (ANALYZE, BUFFERS),比对三个指标:Execution TimeBuffers: shared read、扫描节点类型。只看时间变快是不够的------时间可能因为缓存预热而变快,Buffers 下降才说明真的少读了数据。

如果统计信息刚变动过(大批量导入、加了扩展统计信息),复测前先手动更新:

sql 复制代码
ANALYZE orders;

对写操作做计划分析的安全姿势

EXPLAIN ANALYZE 会真实执行语句,直接拿它分析 UPDATE / DELETE 等于在生产库上动手改数据。用显式事务包住,最后回滚

sql 复制代码
BEGIN;

EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders
SET status = 'shipped'
WHERE status = 'paid'
  AND created_at < '2023-06-01';

ROLLBACK;   -- 关键:计划已经拿到,数据改动全部撤销

这条链路里唯一不能省的就是 ROLLBACK在敲 BEGIN 之前先把 ROLLBACK 写好 ,比事后想起来靠得住。注意它撤销的是数据变更,序列(bigserial 背后的 sequence)的推进不会回滚,这一点不影响计划分析。

常见坑与排查清单

把前面散落的信号收成一张表,下次读计划时按这个顺序过一遍:

观察到的信号 最可能的原因 处理动作
估算 rows 与实际 rows 差 2 个数量级以上 统计信息过期或多列相关性未感知 ANALYZE;必要时 CREATE STATISTICS
大表出现 Seq Scan 且返回行数很少 缺可用索引,或条件写法阻断了索引 按等值/范围规则建复合索引
Rows Removed by Filter 数量巨大 过滤发生在取行之后而非索引定位阶段 把过滤列纳入索引,确认它进了 Index Cond
条件出现在 Filter 而不是 Index Cond 复合索引列顺序不对,或范围列位置太靠前 调整列顺序,范围列移到最右
Sort Method: external merge Disk work_mem 不足,排序落盘 会话级调 work_mem;或用索引消掉 Sort
Hash 节点 Batches > 1 哈希表内存不足,分批落盘 同上;或减少参与哈希的数据量
Index Only ScanHeap Fetches 很高 可见性映射未更新 VACUUM 该表;调低 autovacuum 阈值
shared read 远大于 shared hit 反复读磁盘,工作集超出缓存 减少扫描量优先;再考虑 shared_buffers
Planning Time > Execution Time 索引/分区过多导致规划开销过高 清理零使用索引,而不是继续加索引
建了索引但计划不用 部分索引谓词未被蕴含;或类型不匹配 补齐谓词条件;检查列类型与参数类型是否一致

最后一行的类型问题值得多说一句:bigint 列拿字符串参数去比,或者 varchar 列传了整型,都可能让规划器插入隐式转换从而绕开索引。这类问题在计划里的表现是索引明明存在却走 Seq Scan,而 Filter 行里能看到 ::text 之类的转换痕迹。

总结

慢查询治理的顺序比技巧重要。把这四步固定成肌肉记忆,比记住十条索引口诀有用:

  1. 先定位再动手pg_stat_statementstotal_exec_time 倒序,找的是"总共最耗时"而非"单次最慢"。
  2. 读计划先读结构,再读数字 。估算与实际行数的偏差决定计划形状对不对;Rows Removed by FilterSort Method、Buffers 三个信号定位真正的热点。
  3. 索引按查询形状设计 。复合索引等值列在前、范围列在后且只放一个;子集查询用部分索引压体积;高频窄列查询用 INCLUDE 换 Index-Only Scan。
  4. 验证以 Buffers 为准。时间会被缓存预热骗到,读取页数不会。

还有一条容易被忘记的:索引是有维护成本的资产,不是免费的加速开关 。每加一个索引,所有写入都要为它付账。定期用 pg_stat_user_indexes 清掉 idx_scan = 0 的索引,和加索引一样属于优化工作。

下一步可以往两个方向深入:一是 auto_explain 扩展,把超过阈值的慢查询计划自动记进日志,省掉手动复现这一步;二是 pg_stat_iopg_buffercache,从实例层面看清缓存和 IO 的全貌。


参考资料

相关推荐
一个天蝎座 白勺 程序猿2 小时前
SQL Server数据库迁移实测:金仓KES V9R4C019让存量T-SQL“少量修改”
数据库·sql·kingbasees
2601_962283883 小时前
Django数据库配置(一)
mysql·postgresql·django·sqlite·数据库配置
g10565591393 小时前
华为 OceanStor 基础使用入门指南
服务器·数据库·性能优化
AugustSkys4 小时前
PostgreSQL 完全指南:特性对比 + 安装部署 + 配置管理 + psql 实战 + pgAdmin 图形化
运维·postgresql·开源数据库·pgadmin
程序员清风4 小时前
从 JVM 内存模型到 GC 调优:Java 服务性能优化实战
java·jvm·性能优化
必须会一定会5 小时前
Spring Boot 3 + PostgreSQL 场景工程持久化:revision、contentHash 与历史回滚
人工智能·spring boot·后端·postgresql·ai编程
shirsl5 小时前
数据开发每日面试题 Day 1
大数据·数据库·sql·big data
2601_960356386 小时前
消费者洞察岗项目准备清单:SQL、问卷、用户分层与可视化
大数据·数据库·sql