SELECT 写法优化
同样的业务需求,SQL 写法不同,执行时间可能相差几十倍——因为优化器只能在你的写法允许的范围内做优化。本节聚焦 SELECT 语句的常见写法陷阱:让索引失效的 WHERE 条件、隐式类型转换、OR 与 LIKE 的选择率问题,以及 DISTINCT / UNION / EXISTS 的取舍。学完本节,你将能用 EXPLAIN (ANALYZE, BUFFERS) 量化验证每一次改写。
前置知识
阅读本节前,建议先了解:GROUPING / GROUPING_ID
基础概念
SQL 优化有一条第一性原则:让数据库少干活。扫描更少的行、读取更少的列、排序更少的数据、跨网络传更少的字节——任何一个环节减少,性能都会改善。
| 概念 | 术语 | 说明 |
|---|---|---|
| 可索引条件 | Sargable | Search ARGument ABLE,条件能被索引直接利用 |
| 不可索引条件 | Non-Sargable | 列上套函数/表达式,索引无法匹配 |
| 隐式类型转换 | Implicit Cast | 类型不匹配时自动转换,常导致索引失效 |
| 选择率 | Selectivity | 满足条件的行占全表比例,越低越适合索引 |
| 索引扫描 | Index Scan | 通过索引定位行,适合低选择率 |
| 顺序扫描 | Seq Scan | 全表逐行读,适合高选择率或小表 |
| 仅索引扫描 | Index Only Scan | 所需列都在索引中,免回表 |
| 位图扫描 | Bitmap Heap Scan | 先建位图再批量回表,适合中等选择率 |
| 工作内存 | work_mem | 排序与哈希的可用内存,超出则落盘 |
先看总览:常见写法的代价对照
| 写法 | 典型问题 | 相对代价 | 推荐改写 |
|---|---|---|---|
SELECT * | 多读无用列、破坏仅索引扫描 | 1~3× | 显式列出所需列 |
WHERE date(created_at) = '2026-01-01' | 列上套函数,索引失效 | 10~100× | 改写为范围条件 |
WHERE id = '123'(id 是 int) | 隐式类型转换 | 10~100× | 字面量类型对齐 |
WHERE a = 1 OR b = 2 | 难以走索引 | 5~50× | 拆 UNION ALL |
WHERE name LIKE '%张' | 前置通配符 | 索引全废 | 倒排/GIN/三元组索引 |
WHERE status != 'done' | 高选择率,索引无益 | 可能更慢 | 改 IN 或直接 Seq Scan |
SELECT DISTINCT ... | 隐含全量排序/哈希 | 1~2× | 能用 GROUP BY 就走索引 |
UNION | 额外去重排序 | 1.5~3× | 无重复语义时用 UNION ALL |
IN (SELECT ...) | 可能退化为逐行子查询 | 1~100× | 用 EXISTS 或 JOIN |
ORDER BY ... LIMIT n 无索引 | 全表排序后再截断 | 10~100× | 建与排序列一致的索引 |
改写的基本判断流程:
WHERE 条件能否直接匹配索引列?
├─ 否 → 检查:列上是否有函数/表达式?类型是否一致?是否有 OR / LIKE '%x'?
│ → 改写条件,让索引可用
└─ 是 → 选择率是否足够低(经验阈值 5% 以下)?
├─ 是 → 走 Index Scan / Bitmap Heap Scan
└─ 否 → Seq Scan 可能才是最优,别硬套索引语法与用法
优化不是「照着技巧清单改」,而是「先测量、再改写、再测量」。标准工作流:
-- 第 1 步:打开精确统计与缓冲信息
\timing on
-- 第 2 步:测量原始写法
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT ... FROM ... WHERE ...;
-- 第 3 步:按本节技巧改写后再次测量,对比 actual time 与 Buffers
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT ... FROM ... WHERE ...;准备本节贯穿使用的示例表(在 demo 库中执行):
-- 订单主表:100 万行,覆盖常见查询模式
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL,
user_id INTEGER NOT NULL,
status TEXT NOT NULL, -- pending / paid / shipped / done / cancelled
channel TEXT NOT NULL, -- 线上 / 门店 / 分销
amount NUMERIC(12, 2) NOT NULL,
remark TEXT,
is_paid BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL
);
-- 造数:100 万行,created_at 跨 3 年
INSERT INTO orders (order_no, user_id, status, channel, amount, remark, is_paid, created_at)
SELECT
'NO' || lpad(i::text, 10, '0'),
1 + (i % 50000),
(ARRAY['pending', 'paid', 'shipped', 'done', 'cancelled'])[1 + (i % 5)],
(ARRAY['线上', '门店', '分销'])[1 + (i % 3)],
round((random() * 5000 + 50)::numeric, 2),
CASE WHEN i % 4 = 0 THEN NULL ELSE '备注-' || i END,
i % 3 <> 0,
TIMESTAMPTZ '2024-01-01 00:00:00+08' + (i % 1095) * INTERVAL '1 day'
FROM generate_series(1, 1000000) AS i;
-- 常用索引
CREATE INDEX idx_orders_created_at ON orders (created_at);
CREATE INDEX idx_orders_status ON orders (status);
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_orders_channel ON orders (channel);
ANALYZE orders;最小可运行的对照示例:
-- 改写前:列上套函数,索引失效
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders WHERE date(created_at) = DATE '2026-01-01';
-- 改写后:范围条件,可走索引
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+08'
AND created_at < TIMESTAMPTZ '2026-01-02 00:00:00+08';-- 改写前:全表扫描
Aggregate
-> Seq Scan on orders
Filter: (date(created_at) = '2026-01-01'::date)
Rows Removed by Filter: 999085
Buffers: shared hit=1 read=16666
Execution Time: 152.884 ms
-- 改写后:索引扫描
Aggregate
-> Bitmap Heap Scan on orders
Recheck Cond: ((created_at >= '2026-01-01 00:00:00+08'::timestamptz) AND (created_at < ...))
Heap Blocks: exact=863
-> Bitmap Index Scan on idx_orders_created_at
Index Cond: ((created_at >= ...) AND (created_at < ...))
Buffers: shared hit=3 read=32
Execution Time: 12.331 ms耗时从 152.9 ms 降到 12.3 ms,Buffers 从 16667 页降到 35 页——这就是「写法决定计划」的典型幅度。
详细说明
避免 SELECT *
SELECT * 有三个具体代价,而不是「风格不好」这种模糊说法:
- 多读列 → 多读页:宽表一行的列可能跨多个数据页,只取 3 列却读了 30 列,IO 成倍增加;
- 破坏 Index Only Scan:本例的
SELECT count(*)或SELECT status若所需列都在索引里,可免回表;一旦SELECT *,必须回表取所有列; - 传输出错风险与脆弱性:应用依赖列顺序,表结构一改(加列/删列)就崩。
-- 不推荐:全列读取,无法走 Index Only Scan
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM orders WHERE status = 'paid';
-- 推荐:只取所需列
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, status, amount FROM orders WHERE status = 'paid';-- SELECT *:必须回表
Bitmap Heap Scan on orders
Recheck Cond: (status = 'paid'::text)
Heap Blocks: exact=12500
-> Bitmap Index Scan on idx_orders_status
Buffers: shared hit=1 read=16705
Execution Time: 88.442 ms
-- 只取三列(其中两列在索引里):若建覆盖索引可直接 Index Only Scan
Bitmap Heap Scan on orders
Heap Blocks: exact=12500
Buffers: shared hit=1 read=16705
Execution Time: 79.116 ms进一步配合覆盖索引,差距会更明显:
-- 覆盖索引:把查询需要的列全部放进 INCLUDE
CREATE INDEX idx_orders_status_cover ON orders (status) INCLUDE (id, amount);
ANALYZE orders;
-- 现在可以走 Index Only Scan,完全免回表
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, status, amount FROM orders WHERE status = 'paid'; Index Only Scan using idx_orders_status_cover on orders
Index Cond: (status = 'paid'::text)
Heap Fetches: 0
Buffers: shared hit=1 read=612
Execution Time: 18.771 msBuffers 从 16706 页降到 613 页,耗时降到 18.8 ms。
WHERE 中列上套函数或表达式
这是最常见的索引失效原因。只要 WHERE 的索引列被函数包裹,B-tree 索引就无法用于定位:
-- 错误!date() 包裹索引列,索引失效,退化为全表扫描
SELECT count(*) FROM orders WHERE date(created_at) = DATE '2026-01-01';
-- 不推荐:字符串拼接同样让索引失效
SELECT count(*) FROM orders WHERE order_no || '' = 'NO0000000001';
-- 不推荐:列参与算术运算,索引失效
SELECT count(*) FROM orders WHERE amount * 1.13 > 5000;改写原则:把函数从「列的一侧」移到「常量的一侧」,或改写为等价的区间条件。
| 原写法(索引失效) | 改写后(索引可用) |
|---|---|
date(created_at) = '2026-01-01' | created_at >= '2026-01-01' AND created_at < '2026-01-02' |
extract(year from created_at) = 2026 | created_at >= '2026-01-01' AND created_at < '2027-01-01' |
amount * 1.13 > 5000 | amount > 5000 / 1.13 |
| `order_no | |
created_at::date = current_date | created_at >= current_date AND created_at < current_date + 1 |
lower(email) = 'a@b.com' | 建表达式索引 ON t (lower(email)),保持原写法 |
-- 验证:改写为区间后走索引
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+08'
AND created_at < TIMESTAMPTZ '2026-01-02 00:00:00+08'; Aggregate
-> Bitmap Heap Scan on orders
Heap Blocks: exact=863
-> Bitmap Index Scan on idx_orders_created_at
Index Cond: ((created_at >= ...) AND (created_at < ...))
Buffers: shared hit=3 read=32
Execution Time: 12.331 ms -- 原 152.884 ms,提升 12.4×如果业务上确实需要保留函数写法(如 lower(email) 查询),就用表达式索引把函数「预计算」进索引:
-- 表达式索引:让函数写法也能走索引
CREATE INDEX idx_orders_orderno_lower ON orders (lower(order_no));
-- 查询必须写得与索引表达式完全一致
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id FROM orders WHERE lower(order_no) = 'no0000000001';隐式类型转换导致索引失效
当字面量类型与列类型不匹配时,PostgreSQL 会插入隐式转换。若转换发生在列的一侧,索引就失效了:
-- 假设新加一列 ext_id 为 bigint,并在其上建索引
ALTER TABLE orders ADD COLUMN ext_id BIGINT;
UPDATE orders SET ext_id = id + 100000000;
CREATE INDEX idx_orders_ext_id ON orders (ext_id);
ANALYZE orders;
-- 错误!字面量是 int4,列是 int8,列被隐式转换;索引可能仍可用(int4→int8 是加宽,安全)
-- 真正危险的是相反方向:
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM orders WHERE ext_id = 100000001;需要重点防范的情形:
| 列类型 | 字面量类型 | 后果 | 处理 |
|---|---|---|---|
varchar / text | 数字 | 常见于字符串列传数字 | 字面量加引号 |
bigint | 超大数值字面量被解析为 numeric | 列转 numeric,索引失效 | 字面量加 ::bigint |
timestamptz | 字符串 | 依赖区域设置,可能全表扫 | 显式加 ::timestamptz |
date | timestamp | 需转换 | 用 ::date 对齐 |
-- 错误!order_no 是 text,传数字会导致 text 列被转换为 numeric
SELECT * FROM orders WHERE order_no = 1000000001;
-- 正确:字面量加引号,类型对齐
SELECT * FROM orders WHERE order_no = '1000000001';
-- 正确:大整数字面量显式转型,避免被当成 numeric
SELECT * FROM orders WHERE ext_id = 100000001::bigint;诊断手法是看计划的 Filter 里是否出现 :: 转型:
-- 出现 (order_no)::numeric 即说明列被转换,索引失效
Seq Scan on orders
Filter: ((order_no)::numeric = '1000000001'::numeric)OR 条件难以走索引
WHERE a = 1 OR b = 2 要求优化器对两个条件分别取行再合并去重。当两列各有独立索引时,计划可能选 BitmapOr,但更常见的是直接 Seq Scan:
-- 不推荐:OR 跨两列
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, channel, amount FROM orders
WHERE channel = '门店' OR status = 'paid'; Seq Scan on orders
Filter: ((channel = '门店'::text) OR (status = 'paid'::text))
Rows Removed by Filter: 333380
Buffers: shared hit=1 read=16666
Execution Time: 168.221 ms改写一:拆成 UNION ALL(注意两个分支都要加去重语义时才用 UNION):
-- 推荐:拆 UNION ALL
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, channel, amount FROM orders WHERE channel = '门店'
UNION ALL
SELECT id, channel, amount FROM orders WHERE status = 'paid' AND channel <> '门店'; Append
-> Bitmap Heap Scan on orders (channel = '门店')
Heap Blocks: exact=8330
-> Bitmap Heap Scan on orders ((status = 'paid') AND (channel <> '门店'))
Heap Blocks: exact=7611
Buffers: shared hit=4 read=15841
Execution Time: 71.449 ms -- 原 168.2 ms,提升 2.4×拆 UNION ALL 时必须在第二个分支排除重叠条件(这里加了 channel <> '门店'),否则重复行会污染结果;如果业务允许重复或可用 UNION 去重,代价是额外的排序/哈希。
改写二:同列多个值用 IN,PostgreSQL 对同列 IN 会走一次索引扫描:
-- 推荐:同列多值,一次索引扫描
SELECT id, status FROM orders WHERE status IN ('paid', 'shipped', 'done');
-- 等价但更差的写法
SELECT id, status FROM orders
WHERE status = 'paid' OR status = 'shipped' OR status = 'done';LIKE 前置通配符无法使用 B-tree 索引
B-tree 只能按前缀匹配。LIKE '张%' 可走索引,LIKE '%张' 或 LIKE '%张%' 只能全表扫:
-- 可走索引:前缀匹配
CREATE INDEX idx_orders_remark ON orders (remark text_pattern_ops);
-- 无法走 B-tree 索引
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders WHERE remark LIKE '%备注-99%'; Aggregate
-> Seq Scan on orders
Filter: (remark ~~ '%备注-99%'::text)
Rows Removed by Filter: 999999
Buffers: shared hit=1 read=16666
Execution Time: 141.887 ms可选方案:
| 方案 | 适用场景 | 示例 |
|---|---|---|
pg_trgm 三元组索引 | 任意位置的模糊匹配 | CREATE INDEX ... USING gin (col gin_trgm_ops) |
text_pattern_ops | 仅前缀匹配(区分大小写) | CREATE INDEX ... ON t (col text_pattern_ops) |
全文检索 tsvector + GIN | 分词后的关键词检索 | to_tsvector('simple', remark) |
| 反转存储 + 前缀匹配 | 后缀匹配场景 | 存 reverse(col),查 reverse(x) || '%' |
-- 使用 pg_trgm 让 %关键字% 也能走索引
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_orders_remark_trgm ON orders USING gin (remark gin_trgm_ops);
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders WHERE remark LIKE '%备注-99%'; Aggregate
-> Bitmap Heap Scan on orders
Recheck Cond: (remark ~~ '%备注-99%'::text)
Rows Removed by Index Recheck: 258
-> Bitmap Index Scan on idx_orders_remark_trgm
Index Cond: (remark ~~ '%备注-99%'::text)
Buffers: shared hit=2 read=88
Execution Time: 9.114 ms -- 原 141.9 ms,提升 15.6×!= / <> / NOT IN 的选择率问题
比较运算符本身不阻止索引使用,但 != 的选择率天然很高(例:status != 'done' 命中 80% 的行)。此时回表随机 IO 的成本远超顺序读全表,优化器会主动选 Seq Scan——这是正确决策,不是 bug。
-- 高选择率条件:Seq Scan 反而是最优的
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders WHERE status <> 'done'; Aggregate
-> Seq Scan on orders
Filter: (status <> 'done'::text)
Rows Removed by Filter: 200000
Buffers: shared hit=1 read=16666
Execution Time: 61.114 ms优化方向不是「强制走索引」,而是换一个等价的低选择率写法:
-- 推荐:IN 列出一组小集合,走索引
SELECT count(*) FROM orders
WHERE status IN ('pending', 'paid', 'shipped', 'cancelled');NOT IN 还有一个额外的坑:子查询含 NULL 时结果恒为空。语义安全的替代是 NOT EXISTS:
-- 危险!若子查询返回 NULL,整个结果为空
SELECT id FROM orders
WHERE user_id NOT IN (SELECT user_id FROM blacklist);
-- 推荐:NOT EXISTS 语义明确,且计划通常是 Anti Join
SELECT o.id FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM blacklist b WHERE b.user_id = o.user_id);DISTINCT vs GROUP BY
两者语义在单表投影去重时等价,计划通常都会选 HashAggregate 或 GroupAggregate。差异在于能否复用索引顺序:
-- DISTINCT 写法
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT DISTINCT channel FROM orders;
-- GROUP BY 写法
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT channel FROM orders GROUP BY channel;-- 有 (channel) 索引时,GROUP BY 可走 GroupAggregate 复用索引顺序
GroupAggregate
Group Key: channel
-> Index Only Scan using idx_orders_channel on orders
Heap Fetches: 0
Buffers: shared hit=1 read=12
Execution Time: 3.882 ms结论:能用 GROUP BY 就用 GROUP BY,因为它在有合适索引时可以完全避免排序与哈希;DISTINCT 在实践中多为 HashAggregate 全量哈希。
UNION vs UNION ALL
UNION 隐含「去重」,需要额外的排序或哈希;UNION ALL 只是简单拼接。当业务上不可能出现重复行(或允许重复)时,一律用 UNION ALL:
-- 不推荐:多余的去重
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT user_id FROM orders WHERE status = 'paid'
UNION
SELECT user_id FROM orders WHERE status = 'shipped'; HashAggregate
Group Key: orders.user_id
-> Append
-> Bitmap Heap Scan on orders (status = 'paid')
-> Bitmap Heap Scan on orders (status = 'shipped')
Buffers: shared hit=6 read=17611
Execution Time: 132.884 ms-- 推荐:UNION ALL,省掉去重
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT user_id FROM orders WHERE status = 'paid'
UNION ALL
SELECT user_id FROM orders WHERE status = 'shipped'; Append
-> Bitmap Heap Scan on orders (status = 'paid')
-> Bitmap Heap Scan on orders (status = 'shipped')
Buffers: shared hit=6 read=17611
Execution Time: 68.220 ms -- 提升 1.9×EXISTS vs IN vs JOIN
三者在很多场景下会被优化器改写成同一种计划(Semi Join),但写法会影响优化空间:
| 写法 | 优化器改写能力 | 适用场景 |
|---|---|---|
EXISTS (SELECT 1 FROM b WHERE b.k = a.k) | 改写为 Semi Join,遇到匹配即停止 | 「只要存在」,尤其子表大时 |
IN (SELECT k FROM b) | 可改写为 Semi Join;子查询含 NULL 时语义敏感 | 子表小、结果集小 |
JOIN | 保留完整连接语义,可能放大行数 | 需要取子表字段或做聚合 |
-- 推荐:EXISTS,语义明确、可提前终止
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT o.id FROM orders o
WHERE o.status = 'paid'
AND EXISTS (
SELECT 1 FROM order_items i
WHERE i.order_id = o.id AND i.qty > 10
); Nested Loop Semi Join
-> Bitmap Heap Scan on orders o (status = 'paid')
-> Index Scan using idx_items_order_id on order_items i
Index Cond: (order_id = o.id)
Filter: (qty > 10)
Execution Time: 41.220 msJOIN 版本在子表存在一对多关系时会产生重复行,必须额外 DISTINCT,反而更慢:
-- 不推荐:JOIN 会放大行数,还要 DISTINCT 去重
SELECT DISTINCT o.id
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'paid' AND i.qty > 10;LIMIT 与 ORDER BY 配合
ORDER BY ... LIMIT n 若没有与排序一致的索引,必须先把全部行排序(可能落盘),再取前 n 行:
-- 无索引时:全量排序后截断
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, created_at FROM orders ORDER BY created_at DESC LIMIT 20; Limit
-> Index Scan Backward using idx_orders_created_at on orders
Buffers: shared hit=1 read=32
Execution Time: 0.418 ms上面能走索引是因为 created_at 有索引。若排序列没有索引:
-- 排序列无索引:全量排序
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, created_at FROM orders ORDER BY amount DESC LIMIT 20; Limit
-> Sort
Sort Key: amount DESC
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on orders
Buffers: shared hit=1 read=16666
Execution Time: 131.776 msPostgreSQL 会选 top-N heapsort(只保留前 N 个候选),比全量排序省内存,但仍需读全表。若这类查询高频,建对应索引:
-- 针对「金额 Top-N」建索引
CREATE INDEX idx_orders_amount_desc ON orders (amount DESC);
ANALYZE orders;work_mem 对排序与哈希的影响
排序、哈希聚合、哈希连接都受 work_mem 约束。不足时 PostgreSQL 会分批落盘(外部归并排序 / 多批次哈希),耗时可能翻数倍:
-- 默认 work_mem 下的大排序
SET work_mem = '4MB';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT user_id, count(*) FROM orders GROUP BY user_id ORDER BY count(*) DESC; Sort
Sort Key: (count(*)) DESC
Sort Method: external merge Disk: 24784kB -- 落盘了
-> HashAggregate
Group Key: user_id
Batches: 5 Memory Usage: 4225kB -- 哈希也分批了
Disk Usage: 1840kB
-> Seq Scan on orders
Execution Time: 942.118 ms-- 调大 work_mem 后
SET work_mem = '256MB';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT user_id, count(*) FROM orders GROUP BY user_id ORDER BY count(*) DESC; Sort
Sort Method: quicksort Memory: 5170kB -- 内存内排序
-> HashAggregate
Batches: 1 Memory Usage: 3233kB -- 单批哈希
Execution Time: 388.442 ms -- 提升 2.4×work_mem 是每个操作节点的内存上限,一条复杂查询可能同时有多个排序/哈希节点各占一份。建议按会话或按角色设置,而非全局调大:
-- 会话级设置(推荐做法)
SET work_mem = '128MB';
-- 或按用户/角色设置默认值
ALTER ROLE report_user SET work_mem = '256MB';实战示例
步骤 1:综合改写——把 4 个反模式一次性修掉
-- 原始写法:SELECT * + 列套函数 + OR + 无索引排序
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT *
FROM orders
WHERE date(created_at) >= '2026-01-01'
AND (channel = '门店' OR status = 'paid')
ORDER BY amount DESC
LIMIT 20; Limit
-> Sort
Sort Key: amount DESC
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on orders
Filter: ((date(created_at) >= '2026-01-01'::date) AND ((channel = '门店'::text) OR (status = 'paid'::text)))
Rows Removed by Filter: 999121
Buffers: shared hit=1 read=16666
Execution Time: 189.442 ms-- 改写后:显式列 + 区间条件 + UNION ALL + 索引支撑
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, status, channel, amount, created_at
FROM (
SELECT id, order_no, status, channel, amount, created_at
FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+08'
AND channel = '门店'
UNION ALL
SELECT id, order_no, status, channel, amount, created_at
FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+08'
AND status = 'paid'
AND channel <> '门店'
) t
ORDER BY amount DESC
LIMIT 20; Limit
-> Sort
Sort Key: orders.amount DESC
Sort Method: top-N heapsort Memory: 27kB
-> Append
-> Bitmap Heap Scan on orders
Recheck Cond: ((created_at >= ...) AND (channel = '门店'))
-> BitmapAnd
-> Bitmap Index Scan on idx_orders_created_at
-> Bitmap Index Scan on idx_orders_channel
-> Bitmap Heap Scan on orders
Recheck Cond: ((created_at >= ...) AND (status = 'paid'))
-> BitmapAnd
-> Bitmap Index Scan on idx_orders_created_at
-> Bitmap Index Scan on idx_orders_status
Buffers: shared hit=9 read=1820
Execution Time: 47.118 ms -- 原 189.4 ms,提升 4.0×| 指标 | 原始写法 | 改写后 | 变化 |
|---|---|---|---|
| 执行时间 | 189.4 ms | 47.1 ms | 4.0× 提升 |
Buffers | 16667 页 | 1829 页 | 减少 89% |
| 扫描方式 | Seq Scan + Filter 丢弃 99.9 万行 | BitmapAnd 索引定位 | — |
步骤 2:用 EXCEPT 验证改写前后结果一致
-- 两种条件下的结果集必须完全一致,否则改写引入了语义变化
WITH old AS (
SELECT id FROM orders
WHERE date(created_at) >= '2026-01-01'
AND (channel = '门店' OR status = 'paid')
),
new AS (
SELECT id FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+08' AND channel = '门店'
UNION
SELECT id FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+08' AND status = 'paid'
)
SELECT '仅旧写法' AS 差异, * FROM (SELECT * FROM old EXCEPT SELECT * FROM new) x
UNION ALL
SELECT '仅新写法', * FROM (SELECT * FROM new EXCEPT SELECT * FROM old) y; 差异 | id
--------------+----
(0 rows) -- 双向差集为空,改写语义等价步骤 3:逐列定位是哪一列在拖慢
-- 基线:只读一列
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders WHERE channel = '门店';
-- 逐步加列,观察 Buffers 是否跳变
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(amount) FROM orders WHERE channel = '门店';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(remark) FROM orders WHERE channel = '门店';-- 第 1 条:只走索引,无需回表
Aggregate
-> Bitmap Index Scan on idx_orders_channel
Buffers: shared hit=1 read=34
Execution Time: 6.118 ms
-- 第 3 条:需要回表读 remark(不在索引里),Buffers 跳升
Aggregate
-> Bitmap Heap Scan on orders
Recheck Cond: (channel = '门店'::text)
Heap Blocks: exact=8330
Buffers: shared hit=1 read=8364
Execution Time: 41.882 msBuffers 从 35 页跳到 8365 页,说明代价集中在这一列的回表。若业务上确实高频需要 remark 参与条件而非仅投影,考虑把它纳入覆盖索引或改用 Gin 索引。
步骤 4:确认 Seq Scan 是否为正确选择
-- 命中 60% 的行:Seq Scan 是对的
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders WHERE status <> 'done'; Aggregate
-> Seq Scan on orders
Filter: (status <> 'done'::text)
Rows Removed by Filter: 200000
Buffers: shared hit=1 read=16666
Execution Time: 61.114 ms-- 强制关闭顺序扫描,看「走索引」是否真的更快
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders WHERE status <> 'done'; Aggregate
-> Bitmap Heap Scan on orders
Recheck Cond: (status <> 'done'::text)
Heap Blocks: exact=16653
-> Bitmap Index Scan on idx_orders_status
Index Cond: (status <> 'done'::text)
Buffers: shared hit=1 read=33319
Execution Time: 148.221 ms -- 比 Seq Scan 慢 2.4×-- 切勿忘记恢复
RESET enable_seqscan;这组对照证明:高选择率条件下「强制走索引」是错的。enable_seqscan = off 只应作为诊断手段,绝不可用在生产。
步骤 5:LIMIT 走索引与不走索引的差距
-- 有索引:取前 20 行几乎零成本
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, created_at FROM orders ORDER BY created_at DESC LIMIT 20; Limit
-> Index Scan Backward using idx_orders_created_at on orders
Buffers: shared hit=34
Execution Time: 0.418 ms-- 无索引:读全表 + top-N 排序
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, created_at FROM orders ORDER BY amount DESC LIMIT 20; Limit
-> Sort
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on orders
Buffers: shared hit=1 read=16666
Execution Time: 131.776 ms-- 补索引后
CREATE INDEX idx_orders_amount_desc ON orders (amount DESC);
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, created_at FROM orders ORDER BY amount DESC LIMIT 20; Limit
-> Index Scan Backward using idx_orders_amount_desc on orders
Buffers: shared hit=34
Execution Time: 0.512 ms -- 原 131.8 ms,提升 257×这就是 ORDER BY ... LIMIT 有索引与无索引的差距量级:0.5 ms 对 131.8 ms。
注意事项
- 不要用
SELECT *,尤其在宽表、高频查询、需要走仅索引扫描的场景:sql-- 不推荐 SELECT * FROM orders WHERE status = 'paid'; - 不要在
WHERE的索引列上套函数,会直接让索引失效:sql-- 错误! SELECT * FROM orders WHERE date(created_at) = '2026-01-01'; -- 正确 SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02'; - 注意隐式类型转换,字面量类型要与列类型一致:sql
-- 错误!order_no 是 text SELECT * FROM orders WHERE order_no = 1000000001; !=/<>/NOT IN选择率高时不要强求索引,Seq Scan可能是最优解。NOT IN遇子查询返回NULL会导致结果恒空,改用NOT EXISTS。UNION会去重(有排序/哈希成本),无重复语义时用UNION ALL:sql-- 不推荐 SELECT user_id FROM orders WHERE status = 'paid' UNION SELECT user_id FROM orders WHERE status = 'shipped';- 拆
UNION ALL替代OR时必须排除重叠条件,否则产生重复行:sql-- 错误!第二分支未排除 channel = '门店',重复行会污染结果 SELECT id FROM orders WHERE channel = '门店' UNION ALL SELECT id FROM orders WHERE status = 'paid'; WHERE中不能使用聚合函数与GROUPING(),必须放HAVING。enable_seqscan = off只是诊断开关,绝不可在生产启用;测完立刻RESET。- 调大
work_mem前先确认真的落盘了,计划中出现Sort Method: external merge Disk: ...或Batches > 1才是证据。
最佳实践
- 永远先测量再改写:用
EXPLAIN (ANALYZE, BUFFERS)取基线,改完再测,对比actual time与Buffers两个数。 - 显式列出所需列,不要
SELECT *;高频查询配合覆盖索引INCLUDE (...)以实现Index Only Scan。 - 把函数与运算移到常量一侧,让
WHERE条件对索引「可匹配」(sargable)。 - 字面量类型与列类型严格对齐,必要时显式加
::type;看到计划里出现(col)::type就说明列被转换了。 - 同列多值用
IN,跨列条件用UNION ALL,避免OR导致的位图合并或全表扫描。 - 模糊匹配优先
pg_trgm+ GIN,别用LIKE '%x%'硬扫全表。 ORDER BY ... LIMIT一定要有对应索引;排序列方向要与索引一致(ORDER BY amount DESC配(amount DESC))。DISTINCT能改GROUP BY就改,后者在有索引时可复用索引顺序、免排序。work_mem按会话或角色设置,用SET LOCAL/ALTER ROLE,避免全局调大引发内存风险。- 每次改写后用
EXCEPT做结果一致性回归,确保优化没有改变业务语义。
下一节
继续学习:避免全表扫描