摘要 :慢查询治理最常见的失败姿势是"凭感觉加索引"------加完没变快,索引却越堆越多。本文给出一条可复现的闭环:先用
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.117、loops=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=16 对 actual rows=6,同一量级,规划器判断是可信的。但如果你看到 rows=1 对 actual rows=250000,偏差三五个数量级,那么这个计划从根上就选错了------规划器以为只有一行,于是选了嵌套循环,实际却要循环 25 万次。
偏差大通常是这几个原因:
- 统计信息过期 。大批量导入、大规模删除之后没跑
ANALYZE,autovacuum 还没追上。手动补一次即可。 - 多列相关性没被感知 。规划器默认列之间独立,会把
status = 'paid'和customer_id = 42的选择率直接相乘。而现实中"某个客户的订单几乎都是已支付"这类相关性很常见,相乘的结果会严重偏低。 - 表达式挡住了统计信息 。
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 末值------差值就是这个节点自己花掉的时间,Sort、Hash Join、Aggregate 这类节点常常是真凶。
然后看 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: 666661 配 actual 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_id、status、created_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 而不是 Filter;Rows 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,索引可能膨胀到比表还大,缓存全被它挤占,收益立刻变成负数。选择原则是只放窄类型、且真的被高频查询读取的列------numeric、boolean、短 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 Filter、Sort / 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 Time、Buffers: 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 Scan 的 Heap Fetches 很高 |
可见性映射未更新 | VACUUM 该表;调低 autovacuum 阈值 |
shared read 远大于 shared hit |
反复读磁盘,工作集超出缓存 | 减少扫描量优先;再考虑 shared_buffers |
Planning Time > Execution Time |
索引/分区过多导致规划开销过高 | 清理零使用索引,而不是继续加索引 |
| 建了索引但计划不用 | 部分索引谓词未被蕴含;或类型不匹配 | 补齐谓词条件;检查列类型与参数类型是否一致 |
最后一行的类型问题值得多说一句:bigint 列拿字符串参数去比,或者 varchar 列传了整型,都可能让规划器插入隐式转换从而绕开索引。这类问题在计划里的表现是索引明明存在却走 Seq Scan,而 Filter 行里能看到 ::text 之类的转换痕迹。
总结
慢查询治理的顺序比技巧重要。把这四步固定成肌肉记忆,比记住十条索引口诀有用:
- 先定位再动手 。
pg_stat_statements按total_exec_time倒序,找的是"总共最耗时"而非"单次最慢"。 - 读计划先读结构,再读数字 。估算与实际行数的偏差决定计划形状对不对;
Rows Removed by Filter、Sort Method、Buffers 三个信号定位真正的热点。 - 索引按查询形状设计 。复合索引等值列在前、范围列在后且只放一个;子集查询用部分索引压体积;高频窄列查询用
INCLUDE换 Index-Only Scan。 - 验证以 Buffers 为准。时间会被缓存预热骗到,读取页数不会。
还有一条容易被忘记的:索引是有维护成本的资产,不是免费的加速开关 。每加一个索引,所有写入都要为它付账。定期用 pg_stat_user_indexes 清掉 idx_scan = 0 的索引,和加索引一样属于优化工作。
下一步可以往两个方向深入:一是 auto_explain 扩展,把超过阈值的慢查询计划自动记进日志,省掉手动复现这一步;二是 pg_stat_io 与 pg_buffercache,从实例层面看清缓存和 IO 的全貌。
参考资料
- Using EXPLAIN --- PostgreSQL 官方文档
- Partial Indexes --- PostgreSQL 官方文档
- 另外三章建议在官方文档站内直接检索章节名:
Multicolumn Indexes、Index-Only Scans and Covering Indexes、pg_stat_statements。
© 2026 | 转载请注明出处