扫描类型(Scan Types)
执行计划最底层的节点永远是「扫描节点」——它们负责从表、索引、函数或常量列表中取出原始行。上层所有的连接、排序、聚合都建立在扫描节点的输出之上,因此读懂扫描类型,是优化 SQL 的第一步。
一条慢 SQL 的根因,八成可以归结为「该走索引却全表扫」或「走了索引却回表百万次」。本节系统梳理 PostgreSQL 的全部扫描节点,并给出「选择率与计划选择」的经验法则。
前置知识
阅读本节前,建议先了解:EXPLAIN 输出解读(成本 / 行数 / 时间)
基础概念
先建立术语表。PostgreSQL 的扫描节点在计划输出里以固定英文名出现,中文社区译法不一,下表给出对照与判定特征。
| 扫描节点 | 中文 | 访问对象 | 关键特征 |
|---|---|---|---|
Seq Scan | 顺序扫描 | 表 | 从头到尾读所有数据块 |
Index Scan | 索引扫描 | 索引 + 表 | 先查索引定位,再回表取行 |
Index Only Scan | 仅索引扫描 | 索引 | 所需列全在索引里,可能回表 |
Bitmap Index Scan | 位图索引扫描 | 索引 | 只产出位图,不出行(width=0) |
Bitmap Heap Scan | 位图堆扫描 | 表 | 按物理顺序读表,带 Recheck Cond |
Tid Scan | 元组 ID 扫描 | 表 | 按 ctid 直接定位 |
Subquery Scan | 子查询扫描 | 子计划 | 外层对子查询结果再包一层 |
Function Scan | 函数扫描 | 函数 | FROM 里出现返回集合的函数 |
Values Scan | 值列表扫描 | 常量 | VALUES (...) 作为数据源 |
CTE Scan | CTE 扫描 | CTE | 读取 WITH 定义的临时结果集 |
WorkTable Scan | 工作表扫描 | 递归 CTE | 递归 CTE 的自引用工作区 |
Sample Scan | 采样扫描 | 表 | TABLESAMPLE 触发的抽样 |
Foreign Scan | 外部表扫描 | FDW | 访问外部数据源 |
Custom Scan | 自定义扫描 | 扩展 | 扩展注册的扫描方法 |
扫描节点的「成本结构」由三部分组成,理解它才能理解为什么规划器会在两种扫描之间摇摆:
总成本 ≈ 读块成本 + 处理行成本 + 运算成本
│ │ │
│ │ └─ rows * cpu_operator_cost (每个条件判断)
│ └─ rows * cpu_tuple_cost (每行处理)
└─ pages * seq_page_cost 或 pages * random_page_cost一个典型的计划树,扫描节点位于树叶位置:
Sort
│
Hash Join
┌────┴─────┐
│ │
Seq Scan on orders Hash
│
Bitmap Heap Scan
│
Bitmap Index Scan ← 最底层,只产生位图语法与用法
扫描类型不是由 SQL 语法直接指定的,而是规划器根据代价模型自动选择。要做对比实验,需要知道「如何观察选择率」与「如何临时干预计划」。
查看某列的选择率估算(规划器决策的原料):
-- 查看单列统计信息:n_distinct、直方图、最常见值
SELECT attname, n_distinct, null_frac, correlation,
most_common_vals, most_common_freqs, histogram_bounds
FROM pg_stats
WHERE schemaname = 'public' AND tablename = 'orders' AND attname = 'status';估算「某个条件命中多少行」(用 EXPLAIN 的 rows= 反推):
EXPLAIN
SELECT * FROM orders WHERE status = 'paid';临时禁用某种扫描以观察替代计划的代价(仅用于诊断):
BEGIN;
SET LOCAL enable_seqscan = off; -- 强迫规划器优先考虑索引
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'paid';
ROLLBACK;要构造一个「位图扫描」而非「索引扫描」的计划,通常需要把返回比例控制在中低区间:
-- 返回约 5%~10% 的行时,位图扫描往往胜出
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE amount BETWEEN 100 AND 300; Bitmap Heap Scan on orders (cost=123.45..1200.67 rows=4200 width=64) (actual time=1.2..8.4 rows=4188 loops=1)
Recheck Cond: ((amount >= 100) AND (amount <= 300))
Heap Blocks: exact=1050
-> Bitmap Index Scan on idx_orders_amount (cost=0.00..122.40 rows=4200 width=0) (actual time=0.9..0.9 rows=4188 loops=1)
Index Cond: ((amount >= 100) AND (amount <= 300))Bitmap Index Scan 的 width=0 是识别它最直接的标志:它不产出真正的行,只产出「哪些块需要读」的位图。
详细说明
Seq Scan:顺序扫描
Seq Scan 从表的第一个数据块读到最后一个,逐行判断过滤条件。它的成本与表大小线性相关,与条件的严格程度无关——即使只要 1 行,只要规划器选了 Seq Scan,就必然读完整张表(除非有 LIMIT 提前中止)。
Seq Scan on orders (cost=0.00..2450.00 rows=100000 width=64)
Filter: (status = 'paid'::text)
Rows Removed by Filter: 98765Rows Removed by Filter 高达 98765 说明:扫了 100000 行,只有 1235 行最终有用。这就是「全表扫 + 高丢弃率」的典型信号,应当检查 status 上是否有索引。
Seq Scan 合适的场景:
- 表很小:几页到几十页的表,走索引的额外开销(读索引页 + 回表随机读)反而更贵。
- 需要返回大比例的行:比如返回 60% 的行,顺序读远比「索引定位 + 大量随机回表」便宜。
- 没有可用索引:条件列无索引,或条件形式无法被索引使用。
- 需要全表聚合:如
SELECT count(*) FROM t且无更优路径。
Index Scan:索引扫描
Index Scan 分两步:先在索引 B 树里定位符合条件的条目,拿到 ctid(行在堆表中的物理位置),再逐条回表读取完整的行。因为索引条目按索引键顺序排列,回表时的物理顺序是随机的,所以成本模型使用 random_page_cost(默认 4.0)而非 seq_page_cost(默认 1.0)。
Index Scan using orders_pkey on orders (cost=0.42..8.44 rows=1 width=64)
Index Cond: (id = 10086)Index Cond 表示这个条件被下推到了索引层,能显著缩小索引扫描范围。与之相对的 Filter 只能逐行事后筛选。
Index Scan 的天然优势是有序输出:索引本身就是有序的,因此下面的查询不需要额外的 Sort 节点。
EXPLAIN
SELECT id, created_at FROM orders ORDER BY id LIMIT 10; Limit (cost=0.42..1.02 rows=10 width=16)
-> Index Scan using orders_pkey on orders (cost=0.42..6000.00 rows=100000 width=16)Index Only Scan:仅索引扫描
如果查询需要的所有列都在索引里,PostgreSQL 就可以只读索引、完全不回表,这就是 Index Only Scan(覆盖索引扫描)。它需要满足两个条件:
- 索引包含
SELECT列表与WHERE用到的全部列(普通索引可用INCLUDE子句扩展非键列)。 - 对应数据块在可见性映射表(Visibility Map, VM)中被标记为全可见(all-visible)。
第 2 条是关键:PostgreSQL 的索引不存储行的可见性(由 MVCC 决定),因此即使索引里有值,也必须确认该版本对当前事务可见。若 VM 未标记,就要回表检查——这个次数就显示为 Heap Fetches。
Index Only Scan using idx_orders_status_amount on orders (cost=0.42..1520.00 rows=42000 width=12)
Index Cond: (status = 'paid'::text)
Heap Fetches: 0Heap Fetches: 0 是理想状态,说明 VM 全可见,真正的「零回表」。如果这个数字接近输出行数,说明表刚写入大量数据还没 VACUUM,此时 Index Only Scan 的性能优势会大打折扣。
-- 查看表的 VM 可见比例
SELECT relname, relpages,
n_dead_tup,
last_vacuum, last_autovacuum
FROM pg_stat_user_tables WHERE relname = 'orders';Bitmap Index Scan 与 Bitmap Heap Scan
这是一对必须成对出现的节点。执行流程:
Bitmap Index Scan扫一个或多个索引,不返回行,只构建一个位图,标记「哪些堆块包含匹配行」。Bitmap Heap Scan拿到位图后,按物理块号顺序读取堆表,从而把随机 I/O 转化为近似顺序 I/O。
因为位图扫描按物理顺序读,回表成本接近 seq_page_cost 而非 random_page_cost,所以它在中低选择率(经验上约 5%~40%)时特别有优势。
Bitmap Heap Scan on orders (cost=45.20..880.10 rows=3200 width=64)
Recheck Cond: (status = 'paid'::text)
Filter: (amount > 100)
Rows Removed by Filter: 400
Heap Blocks: exact=180
-> Bitmap Index Scan on idx_orders_status (cost=0.00..44.40 rows=3600 width=0)
Index Cond: (status = 'paid'::text)注意 Recheck Cond:位图是有损的(因为它只记录块号,不记录精确的元组位置),所以回表后必须重新校验原条件。位图还可能退化为「页级」精度,此时 Heap Blocks: exact 会变成 Heap Blocks: lossy,回表量激增。
多个索引的布尔组合会引入 BitmapAnd 与 BitmapOr 节点:
Bitmap Heap Scan on tenk1 (cost=25.07..60.11 rows=10 width=244)
Recheck Cond: ((unique1 < 100) AND (unique2 > 9000))
-> BitmapAnd (cost=25.07..25.07 rows=10 width=0)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=100 width=0)
Index Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique2 (cost=0.00..19.78 rows=999 width=0)
Index Cond: (unique2 > 9000)Tid Scan:按物理位置定位
Tid Scan 直接用 ctid(块号 + 元组槽位)定位行,跳过索引查找:
EXPLAIN SELECT * FROM orders WHERE ctid = '(42,7)'; Tid Scan on orders (cost=0.00..4.01 rows=1 width=64)
TID Cond: (ctid = '(42,7)'::tid)它的典型用途是批量按 ctid 删除重复行。注意 ctid 会随 VACUUM FULL、UPDATE 而改变,不能长期保存。
Subquery Scan、Function Scan 与 Values Scan
Subquery Scan 出现在 FROM 里有子查询且规划器未能内联它时:
EXPLAIN
SELECT * FROM (SELECT id, amount FROM orders WHERE amount > 500 OFFSET 0) sub; Subquery Scan on sub (cost=0.00..2450.00 rows=5000 width=12)
-> Seq Scan on orders (cost=0.00..2450.00 rows=5000 width=12)
Filter: (amount > 500::numeric)OFFSET 0 常被用来阻止子查询被内联(在 PG12 之前是一个常见技巧)。看到 Subquery Scan 就应追问:这层包装是否必要?
Function Scan 出现在 FROM 里有返回集合的函数时:
EXPLAIN SELECT * FROM generate_series(1, 3) AS g(n); Function Scan on generate_series g (cost=0.00..0.03 rows=3 width=4)Values Scan 出现在 VALUES 作为数据源时,常用于「常量驱动的批量更新」:
EXPLAIN
SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) AS v(id, name); Values Scan on "*VALUES*" (cost=0.00..0.04 rows=3 width=36)CTE Scan 与 WorkTable Scan:WITH 的两种形态
CTE Scan 读取 WITH 子句定义的结果集。PostgreSQL 12 起,非递归 CTE 默认可以被内联,因此不再必然出现 CTE Scan:
-- PG12+:此 CTE 会被内联,计划里不会出现 CTE Scan
EXPLAIN
WITH paid AS (SELECT * FROM orders WHERE status = 'paid')
SELECT count(*) FROM paid;若显式要求物化,则会出现 CTE Scan:
EXPLAIN
WITH paid AS MATERIALIZED (SELECT * FROM orders WHERE status = 'paid')
SELECT count(*) FROM paid; Aggregate (cost=2450.00..2450.01 rows=1 width=8)
CTE paid
-> Seq Scan on orders (cost=0.00..2450.00 rows=10000 width=64)
Filter: (status = 'paid'::text)
-> CTE Scan on paid (cost=0.00..200.00 rows=10000 width=0)WorkTable Scan 只出现在递归 CTE 中,代表对自引用工作区(working table)的读取:
EXPLAIN
WITH RECURSIVE t(n) AS (
VALUES (1)
UNION ALL
SELECT n + 1 FROM t WHERE n < 10
)
SELECT sum(n) FROM t; Aggregate (cost=2.95..2.96 rows=1 width=8)
CTE t
-> Recursive Union (cost=0.00..2.51 rows=21 width=4)
-> Result (cost=0.00..0.01 rows=1 width=4)
-> WorkTable Scan on t (cost=0.00..0.20 rows=10 width=4)
Filter: (n < 10)
-> CTE Scan on t (cost=0.00..0.45 rows=21 width=4)选择率与计划选择:经验法则
「选择率(selectivity)」是 匹配行数 / 总行数。规划器并不直接比较「选择率阈值」,而是比较两种计划的估算代价。但为了人脑快速判断,可以用下面的经验区间:
| 返回行占比 | 通常倾向 | 原因 |
|---|---|---|
| < 1% | Index Scan | 回表次数少,随机 I/O 可忽略 |
| 1% ~ 5% | Index Scan 或 Bitmap Heap Scan | 取决于索引顺序与相关性 |
| 5% ~ 40% | Bitmap Heap Scan | 位图把随机 I/O 变为准顺序 I/O |
| > 40% | Seq Scan | 读索引页 + 回表的成本超过全表扫 |
这个「40%」不是硬编码,而是由代价参数推导出来的:当选择率 s 满足 s * random_page_cost > seq_page_cost 时,随机访问的成本就超过了顺序访问。代入默认值 random_page_cost=4.0,s > 0.25 就开始不利;再计入 cpu_index_tuple_cost 后,转折点通常在 25%~40% 之间浮动。
相关性(correlation)会显著移动这个转折点:
SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'created_at';correlation 取值 -1 ~ 1:
- 接近
±1:列值与物理存储顺序高度一致,回表近似顺序 I/O,索引扫描的性价比大幅提升。 - 接近
0:列值与物理顺序无关,回表是纯随机 I/O,索引扫描代价高。
这也解释了为什么「按自增主键查询」比「按随机 UUID 查询」快得多。
为什么索引没被使用
这是最常被问到的问题。按排查优先级排列:
1. 类型不匹配导致隐式转换
-- 不推荐:phone 是 varchar,但传的是整数,可能触发类型转换
SELECT * FROM users WHERE phone = 13800138000;
-- 推荐:类型严格对齐
SELECT * FROM users WHERE phone = '13800138000';2. 索引列被函数或表达式包裹
-- 错误!索引列被函数包裹,索引失效
SELECT * FROM orders WHERE date(created_at) = DATE '2026-01-01';
-- 推荐:改写为范围条件(可走索引)
SELECT * FROM orders
WHERE created_at >= DATE '2026-01-01' AND created_at < DATE '2026-01-02';
-- 或者建立表达式索引
CREATE INDEX idx_orders_created_date ON orders ((created_at::date));3. 统计信息过期
-- 大量写入/删除后手动更新统计
ANALYZE orders;4. 选择率确实过高
如果条件命中了 80% 的行,Seq Scan 是正确的选择,强行改计划只会更慢。
5. enable_* 参数被关闭
SHOW enable_indexscan; -- 应为 on6. 排序规则(collation)不一致
索引按某种排序规则建立,查询用了另一种规则,索引无法用于排序或范围比较:
-- 索引默认排序规则与下面查询要求的不一致,可能导致索引失效
SELECT * FROM users WHERE name LIKE 'A%';7. LIKE 模式的前缀问题
-- 可能走索引(前缀固定)
SELECT * FROM users WHERE name LIKE 'Ali%';
-- 错误!左模糊无法使用 B-Tree 索引
SELECT * FROM users WHERE name LIKE '%li%';即使前缀固定,B-Tree 索引还需要排序规则支持前缀比较。默认的 libc 排序规则在部分区域设置下无法使用 LIKE 'x%' 优化,需要 text_pattern_ops 操作符类或 C(POSIX)排序规则:
-- 为前缀搜索建立专用索引
CREATE INDEX idx_users_name_pattern ON users (name text_pattern_ops);enable_* 开关的正确用法
enable_seqscan、enable_indexscan、enable_bitmapscan、enable_tidscan、enable_nestloop、enable_hashjoin、enable_mergejoin 等参数,只应作为诊断手段。它们的语义是「给该计划类型加上一个巨大的惩罚代价」,而不是真正禁用——当没有其他选择时,规划器仍会选它。
-- 诊断:当前计划是否比索引计划更慢?
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ; -- 默认计划
SET LOCAL enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ; -- 强迫索引
ROLLBACK;绝对不要在应用里用 SET enable_seqscan = off 来「解决问题」。正确的做法是:修正统计信息、增加合适索引、或调整代价参数。
random_page_cost 对 SSD 的影响
默认 random_page_cost = 4.0 是面向机械硬盘的假设(随机读约为顺序读的 4 倍)。SSD 与 NVMe 的随机读延迟极低,通常建议下调到 1.1 ~ 2.0:
# postgresql.conf(SSD / 全闪存环境)
random_page_cost = 1.1
effective_cache_size = 24GB下调后规划器会更倾向索引扫描。若数据库完全可缓存于内存,甚至可以令 random_page_cost = seq_page_cost = 1.0。注意:这类参数是全局平均值假设,改动前应结合整体负载评估,避免「修好一条 SQL、拖慢一批 SQL」。
-- 查看当前代价参数
SELECT name, setting, unit, context
FROM pg_settings
WHERE name IN ('seq_page_cost', 'random_page_cost', 'cpu_tuple_cost',
'cpu_index_tuple_cost', 'cpu_operator_cost', 'effective_cache_size');实战示例
场景一:从 Seq Scan 到 Index Only Scan 的完整改造
先建立带索引的测试表:
CREATE TABLE orders (
id bigserial PRIMARY KEY,
customer_id bigint NOT NULL,
status text NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (customer_id, status, amount, created_at)
SELECT (random() * 100000)::bigint,
(ARRAY['paid','pending','cancelled','refunded'])[1 + (random()*3)::int],
(random() * 1000)::numeric(12,2),
now() - (random() * interval '365 days')
FROM generate_series(1, 500000);
ANALYZE orders;改造前:统计某状态的订单总数。
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE status = 'paid'; Aggregate (cost=31000.00..31000.01 rows=1 width=8) (actual time=215.442..215.443 rows=1 loops=1)
Buffers: shared hit=20833 read=1024
-> Seq Scan on orders (cost=0.00..29875.00 rows=125000 width=0) (actual time=0.015..205.123 rows=124876 loops=1)
Filter: (status = 'paid'::text)
Rows Removed by Filter: 375124
Buffers: shared hit=20833 read=1024
Planning Time: 0.081 ms
Execution Time: 215.510 ms逐行解读:
Seq Scan扫了 500000 行,Rows Removed by Filter: 375124表示丢弃了 75% 的行,效率很低。Buffers: shared hit=20833 read=1024说明读了约 21857 个块,全部 I/O 都发生在表上。Execution Time: 215.510 ms是改造的基准。
第一次改造:建普通索引。
CREATE INDEX idx_orders_status ON orders (status);
ANALYZE orders; Aggregate (cost=7800.00..7800.01 rows=1 width=8) (actual time=48.221..48.222 rows=1 loops=1)
Buffers: shared hit=13500 read=12
-> Bitmap Heap Scan on orders (cost=1200.00..7500.00 rows=125000 width=0) (actual time=5.112..41.203 rows=124876 loops=1)
Recheck Cond: (status = 'paid'::text)
Heap Blocks: exact=10240
Buffers: shared hit=13500 read=12
-> Bitmap Index Scan on idx_orders_status (cost=0.00..1168.75 rows=125000 width=0) (actual time=3.921..3.921 rows=124876 loops=1)
Index Cond: (status = 'paid'::text)
Buffers: shared hit=1024 read=12时间从 215ms 降到 48ms。规划器选了 Bitmap Heap Scan——因为要返回 25% 的行,位图扫描比纯索引扫描更划算。
第二次改造:既然只需要 count(*),用覆盖索引彻底消除回表。
DROP INDEX idx_orders_status;
-- 用 INCLUDE 把非键列带进索引,或者直接让索引「包含」所需信息
CREATE INDEX idx_orders_status_only ON orders (status);
VACUUM (ANALYZE) orders; -- 让 VM 标记为全可见,Heap Fetches 才会是 0 Aggregate (cost=4400.00..4400.01 rows=1 width=8) (actual time=18.550..18.551 rows=1 loops=1)
Buffers: shared hit=1245
-> Index Only Scan using idx_orders_status_only on orders (cost=0.42..4088.00 rows=125000 width=0) (actual time=0.055..12.180 rows=124876 loops=1)
Index Cond: (status = 'paid'::text)
Heap Fetches: 0
Buffers: shared hit=1245
Planning Time: 0.101 ms
Execution Time: 18.612 msHeap Fetches: 0 证明 VM 已全可见,索引扫描完全没有回表;Buffers 从 13512 降到 1245,减少了约 90% 的 I/O。总耗时 215ms → 48ms → 18ms。
场景二:定位「索引失效」的真实原因
现象:WHERE created_at::date = '2026-01-01' 走了 Seq Scan。
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at::date = DATE '2026-01-01'; Seq Scan on orders (cost=0.00..29875.00 rows=1370 width=45) (actual time=0.014..201.334 rows=1372 loops=1)
Filter: ((created_at)::date = '2026-01-01'::date)
Rows Removed by Filter: 498628
Buffers: shared hit=20833 read=1024Filter: ((created_at)::date = ...) 中的 (created_at)::date 就是元凶——函数包裹了索引列,索引无法参与定位。改写为范围条件:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+08'
AND created_at < TIMESTAMPTZ '2026-01-02 00:00:00+08'; Index Scan using idx_orders_created_at on orders (cost=0.42..58.91 rows=1370 width=45) (actual time=0.028..0.914 rows=1372 loops=1)
Index Cond: ((created_at >= '2026-01-01 00:00:00+08'::timestamp with time zone) AND (created_at < '2026-01-02 00:00:00+08'::timestamp with time zone))
Buffers: shared hit=44 read=6
Planning Time: 0.136 ms
Execution Time: 1.012 msIndex Cond 中出现范围条件,Rows Removed by Filter 消失,耗时从 201ms 降到 1ms——200 倍。这是 SQL 改写中最立竿见影的一类。
注意事项
Seq Scan不等于「有问题」。 小表全扫、大比例返回、无索引可用的聚合,Seq Scan都是正确选择,先看Rows Removed by Filter再下结论。sql-- 小表全扫完全正常,不要为此建索引 EXPLAIN SELECT * FROM dict_status; -- 只有 4 行的字典表Heap Fetches高说明Index Only Scan名不副实。 若该值接近输出行数,实际代价与普通Index Scan无异,应排查VACUUM是否及时。sql-- 不推荐:只建了覆盖索引却从不 VACUUM,Heap Fetches 居高不下 CREATE INDEX idx_cover ON orders (status) INCLUDE (amount); -- 正确做法:写入密集表依赖 autovacuum,必要时手动 VACUUM VACUUM (ANALYZE) orders;左模糊
LIKE '%x'无法使用 B-Tree 索引。 需要pg_trgm+ GIN 索引。sql-- 错误!B-Tree 对左模糊无能为力 SELECT * FROM users WHERE name LIKE '%zhang%'; -- 推荐:三元组索引 CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_users_name_trgm ON users USING gin (name gin_trgm_ops);不要在索引列上做运算或函数调用。 这是「索引失效」第一大原因。
sql-- 错误! SELECT * FROM orders WHERE amount * 1.1 > 1000; -- 推荐:把运算移到常量一侧 SELECT * FROM orders WHERE amount > 1000 / 1.1;ctid不稳定,不能作为长期标识。 行被UPDATE或被VACUUM FULL后ctid会变化。sql-- 不推荐:把 ctid 存进应用表当主键 UPDATE app_table SET row_ref = ctid WHERE id = 1;OFFSET 0阻止子查询内联,多数情况下有害。 除非确实需要物化子查询,否则不要保留这个历史包袱。sql-- 不推荐:无意义的 Subquery Scan SELECT * FROM (SELECT id FROM orders OFFSET 0) s;enable_*参数不可用于生产。 它会给计划类型加惩罚代价,可能让规划器选出更差的替代计划,且掩盖真实问题。sql-- 错误!永久关闭顺序扫描会导致灾难性计划 ALTER DATABASE mydb SET enable_seqscan = off;位图会「有损(lossy)」。 当匹配块数超过
work_mem允许的位图容量,位图降级为页级精度,回表量激增,Heap Blocks: exact变为lossy。text-- 出现 lossy 时,考虑提高 work_mem 或改写查询缩小范围 Heap Blocks: lossy=4096 Rows Removed by Index Recheck: 152340random_page_cost不能盲目下调。 只在确认存储为 SSD 且缓存充足时下调,否则可能让规划器为小结果集选择高启动成本的计划。
最佳实践
先看
Rows Removed by Filter,再决定是否加索引。 这个数字大且返回行少,就是索引的最佳候选。sqlEXPLAIN (ANALYZE) SELECT * FROM orders WHERE status = 'paid'; -- 观察 Rows Removed by Filter 与 rows 的比例为高频等值查询建单列索引,为组合条件建多列索引。 多列索引的列顺序遵循「等值列在前、范围列在后」。
sql-- 等值 + 范围条件,等值列(status)放前面 CREATE INDEX idx_orders_status_created ON orders (status, created_at);让覆盖索引服务「只读少数列」的高频查询。 用
INCLUDE加入返回列,避免回表。sqlCREATE INDEX idx_orders_status_cover ON orders (status) INCLUDE (amount, created_at);保持统计信息新鲜。 大表在批量导入后立即
ANALYZE,必要时提高目标列的统计精度。sqlALTER TABLE orders ALTER COLUMN status SET STATISTICS 500; ANALYZE orders;用范围条件替代「列上的函数比较」。 时间、数值区间尤其适用。
sql-- 推荐写法 WHERE created_at >= DATE '2026-01-01' AND created_at < DATE '2026-02-01'前缀模糊搜索用
text_pattern_ops或C排序规则索引。 不要指望默认排序规则能优化LIKE 'x%'。sqlCREATE INDEX idx_users_code_prefix ON users (code text_pattern_ops);左模糊/模糊搜索用
pg_trgm+ GIN。 这是全文与模糊匹配的标准组合。sqlCREATE INDEX idx_users_name_trgm ON users USING gin (name gin_trgm_ops);SSD 环境下调
random_page_cost到 1.1~2.0。 同时合理设置effective_cache_size为「shared_buffers + OS 缓存」的估计值。inirandom_page_cost = 1.1 effective_cache_size = 24GB用
EXPLAIN (ANALYZE, BUFFERS)做 A/B 对比,而不是凭感觉。 每次改动都要保留Execution Time与Buffers两个基准数据。sql-EXPLAIN (ANALYZE, BUFFERS) <改造前 SQL>; +EXPLAIN (ANALYZE, BUFFERS) <改造后 SQL>;只读副本上验证计划时注意缓存差异。 副本的
shared_buffers命中率、统计信息更新时机与主库不同,结论要交叉验证。优先「加对索引」,而不是「关掉某个扫描类型」。 索引是持久的解决方案,参数开关只是临时的诊断工具。
下一节
参考链接
- PostgreSQL 官方文档:Using EXPLAIN
- PostgreSQL 官方文档:Bitmap Index Scans
- PostgreSQL 官方文档:Index-Only Scans and Covering Indexes
- PostgreSQL 官方文档:Planner Cost Constants
- PostgreSQL 官方文档:Planner Method Configuration
- PostgreSQL 官方文档:Statistics Used by the Planner
- PostgreSQL 官方文档:Interfacing Extensions to Indexes(操作符类)
- PostgreSQL 官方文档:pg_trgm