SQL 改写优化(Query Rewriting)
当索引与统计信息都已到位,查询仍然慢,问题往往出在 SQL 的写法本身——同一种业务语义有若干种等价写法,而它们生成的执行计划可能截然不同。改写(rewriting)是成本最低、收益最直接的优化手段:它不依赖参数调优,也不必新建索引,只要让 SQL 更贴近规划器「看得懂」的形式。
本节聚焦 PostgreSQL 的改写要点:CTE 的内联与物化、子查询形式的等价变换、UNION/UNION ALL 的选择、分页与批量处理的写法、以及 MERGE 这类新语法带来的改写空间。
前置知识
阅读本节前,建议先了解:连接算法(Join Algorithms)
基础概念
SQL 的「等价改写」有两个层次,理解它们的区别是本节的核心:
| 层次 | 含义 | 是否改变结果 | 典型手段 |
|---|---|---|---|
| 语义等价改写 | 结果集完全相同,只是写法不同 | 否 | NOT IN → NOT EXISTS、子查询 → JOIN、UNION → UNION ALL |
| 语义修正改写 | 原写法有 NULL 陷阱或错误语义,改写后结果正确 | 是(修正错误) | NOT IN 的 NULL 问题、LEFT JOIN 条件位置 |
| 结构改写 | 不改变语义,但改变规划器可优化的空间 | 否 | CTE 内联、谓词下推、子查询扁平化 |
| 分批改写 | 把一个大查询拆成多批小查询 | 否 | keyset 分页、批量 DML 分片 |
改写的收益来源可以概括为一张图:
改写前的执行路径
SQL 写法 ──► 优化器看到的形状(形状受限)──► 执行计划
改写后的执行路径
SQL 写法(优化器友好的形状)──► 优化器可选的计划空间更大 ──► 更优计划常见的改写方向与「为什么有效」:
| 改写方向 | 为什么有效 |
|---|---|
| 让谓词下推到扫描节点 | 扫描时就过滤,避免把大量行送到上层再丢弃 |
| 让规划器能利用索引 | 索引只在「裸列 vs 常量/参数」的等值或范围条件上可用 |
| 消除重复计算(如物化中间结果) | 避免同一子查询被多次执行 |
| 减少中间结果行数(提前聚合/去重) | 连接与排序的代价与行数直接相关 |
提供更多可选计划形状(如拆开 OR) | 让规划器可以分别优化每个分支 |
一个关键认知:PostgreSQL 的规划器不是「SQL 重写器」。它做的变换很保守,尤其是越过子查询边界、CTE 边界、外连接边界时。SQL 里写出的结构,很大程度上决定了优化器能看到什么。
语法与用法
改写的过程是「观测 → 假设 → 改写 → 对比验证」的循环。这里给出标准的操作方法。
第一步,建立基线(改造前必须先记下数据):
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
<原始 SQL>;第二步,观察是否有「不该出现」的节点特征:
-- 这些信号说明 SQL 写法限制了优化空间
Filter: (上层才能判断的条件) -- 谓词没下推
SubPlan -- 相关子查询被逐行执行
Materialize (loops 很大) -- 中间结果被反复重扫
CTE Scan / 显式的临时结果 -- CTE 被物化,谓词无法下推
HashAggregate -- 本可用 UNION ALL 避免去重
Batches: N (N > 1) -- 哈希表落盘第三步,改写并对比。建议把「改写前后对照」固化为调优流程的一部分:
-- ===== 改造前 =====
EXPLAIN (ANALYZE, BUFFERS) <旧写法>;
-- ===== 改造后 =====
EXPLAIN (ANALYZE, BUFFERS) <新写法>;IN 子查询是观察规划器改写能力的经典例子(PostgreSQL 会把简单 IN 扁平化为半连接):
-- 写法 A:IN 子查询。规划器通常能把它变成 Semi Join
SELECT * FROM customers c
WHERE c.id IN (SELECT o.customer_id FROM orders o WHERE o.amount > 100);-- 写法 B:IN 的值列表。规划器会转成 ScalarArrayOpExpr(= ANY),走同一个索引
SELECT * FROM customers c
WHERE c.id IN (101, 102, 103, 104, 105); Index Scan using customers_pkey on customers c (cost=0.29..40.50 rows=5 width=40)
Index Cond: (id = ANY ('{101,102,103,104,105}'::integer[]))= ANY 是「单次索引扫描匹配多个值」,比 5 次独立的 OR 等值条件更容易被优化(OR 展开为多个索引扫描后还要 BitmapOr 合并)。
详细说明
CTE 的物化与内联(MATERIALIZED / NOT MATERIALIZED)
WITH(公共表表达式,Common Table Expression,CTE)在 PostgreSQL 12 之前的版本里总是被物化——即使只引用一次,也会先算成临时结果,等于在查询里插入一道「优化屏障」,父查询的谓词无法下推到 CTE 内部。
PostgreSQL 12 起,非递归、无副作用的 WITH 查询默认可以被内联(fold / inline)到父查询。规则如下:
| 情况 | 默认行为 | 可用于预判的信号 |
|---|---|---|
| 父查询只引用 CTE 一次 | 内联(除非显式 MATERIALIZED) | 计划里看不到 CTE Scan |
| 父查询引用 CTE 多次 | 物化 | 计划里出现 CTE Scan |
CTE 含 VOLATILE 函数或有副作用 | 物化 | 必须物化以保证只执行一次 |
递归 CTE(WITH RECURSIVE) | 总是物化 | 出现 CTE Scan + WorkTable Scan |
显式干预的两个关键字:
-- NOT MATERIALIZED:强制内联,让父查询的谓词下推
WITH w AS NOT MATERIALIZED (
SELECT * FROM big_table
)
SELECT * FROM w WHERE key = 123;
-- 等价于 SELECT * FROM big_table WHERE key = 123; 索引可用-- MATERIALIZED:强制物化,避免昂贵的 CTE 被重复计算
WITH w AS MATERIALIZED (
SELECT key, very_expensive_function(val) AS f FROM some_table
)
SELECT * FROM w AS w1 JOIN w AS w2 ON w1.f = w2.f;
-- 物化保证 very_expensive_function 每行只算一次判断是否需要物化,只需问一句:这个 CTE 会被引用几次?它的计算是否昂贵、是否有副作用?
- 引用一次 + 廉价 → 内联(默认行为即可,或显式
NOT MATERIALIZED)。 - 引用多次 + 昂贵 → 物化(
MATERIALIZED)。 - 引用多次 + 廉价 → 视情况,通常
NOT MATERIALIZED加上重复计算会更划算。
子查询形式的等价变换
同一个语义可以写成相关子查询、非相关子查询、JOIN、EXISTS 等,规划器的处理能力差异很大。
非相关子查询可以放心使用,规划器会把它当作常量或半连接处理:
-- 非相关子查询:只执行一次
SELECT * FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders);相关子查询(Correlated Subquery)要警惕,它出现在 SELECT 列表或 WHERE 里时,可能被逐行执行:
-- 相关子查询出现在 SELECT 列表:每行执行一次
SELECT c.id, c.name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS order_count
FROM customers c;虽然 PostgreSQL 常能把上述形式优化成一次 GROUP BY 加左连接(计划里表现为 Aggregate + Left Join),但当子查询逻辑复杂或带 LIMIT 时,就会退化成 SubPlan 逐行执行:
Seq Scan on customers c (cost=0.00..162500.00 rows=50000 width=40) (actual time=0.03..5200.10 rows=50000 loops=1)
SubPlan 1
-> Aggregate (cost=16.25..16.26 rows=1 width=8) (actual time=0.09..0.09 rows=1 loops=50000)
-> Index Only Scan using idx_orders_customer_id on orders o (actual time=0.05..0.08 rows=150 loops=1)
Index Cond: (customer_id = c.id)loops=50000 是危险信号。改写为显式的聚合 + 左连接,让规划器一次算完:
-- 推荐:聚合一次,然后左连接
SELECT c.id, c.name, COALESCE(o.order_count, 0) AS order_count
FROM customers c
LEFT JOIN (
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
) o ON o.customer_id = c.id;EXISTS 优于 IN 的场合:子查询结果集大、只需要存在性判断时。规划器会把 EXISTS 变成 Semi Join,找到第一行即可短路:
-- 推荐:存在性判断用 EXISTS
SELECT * FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.amount > 1000);IN 与 EXISTS 的 NULL 差异同样重要:
-- 不推荐:若子查询返回 NULL,NOT IN 会让整个结果为空
SELECT * FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders);
-- 推荐:NOT EXISTS 不受 NULL 影响
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);三种写法(IN / EXISTS / JOIN)的选择速查:
| 需求 | 推荐写法 | 规划器会生成的节点 |
|---|---|---|
| 判断存在性,不需要右表列 | EXISTS | Semi Join |
判断不存在,需要 NULL 安全 | NOT EXISTS | Anti Join |
| 需要右表的列 | JOIN | 三种连接算法之一 |
| 判断是否在「小值列表」中 | IN (v1, v2, ...) | = ANY / BitmapOr |
| 判断不存在且子查询列非空 | NOT IN + IS NOT NULL | Anti Join |
UNION 与 UNION ALL 的选择
UNION 会做去重(隐式 HashAggregate 或 Sort + Unique),UNION ALL 只是简单地拼接结果。如果业务上不需要去重,一定要写 UNION ALL。
-- 不推荐:UNION 触发去重,等价于多了一次全量排序/哈希
SELECT id FROM orders_2025
UNION
SELECT id FROM orders_2026;-- 推荐:确认无重复(或允许重复)时用 UNION ALL
SELECT id FROM orders_2025
UNION ALL
SELECT id FROM orders_2026;计划差异非常直观:
-- UNION:多一层去重
HashAggregate (cost=...) ← 去重开销
Group Key: orders_2025.id
-> Append (cost=...)
-> Seq Scan on orders_2025
-> Seq Scan on orders_2026-- UNION ALL:直接拼接
Append (cost=...)
-> Seq Scan on orders_2025
-> Seq Scan on orders_2026如果两个分支来自同一张表的互斥条件(如按时间分区),更好的写法是用 OR 或直接用范围条件,让规划器看到单一数据源:
-- 不推荐:两次扫描同一张表
SELECT * FROM orders WHERE created_at < DATE '2026-01-01'
UNION ALL
SELECT * FROM orders WHERE created_at >= DATE '2026-01-01';-- 推荐:单次扫描即可覆盖全部数据
SELECT * FROM orders;UNION ALL 还有一个重要用途:把一个大查询拆成多个小查询并行跑,或让每个分支单独用上不同的索引。这在「OR 条件无法同时利用两个索引」时特别有效:
-- 不推荐:OR 两个不同列,规划器可能退化成一个索引扫描 + Filter
SELECT * FROM orders WHERE customer_id = 42 OR status = 'vip';-- 推荐:拆成 UNION ALL,两个分支各自走自己的索引
SELECT * FROM orders WHERE customer_id = 42
UNION ALL
SELECT * FROM orders WHERE status = 'vip' AND customer_id IS DISTINCT FROM 42;注意第二个分支要排除第一个分支已覆盖的行(IS DISTINCT FROM 处理 NULL),否则会产生重复。
分页:OFFSET 深翻页的替代方案
LIMIT n OFFSET m 的问题在于:数据库必须先扫描并丢弃前 m 行。m 达到百万级时,即使有索引,也要走完百万次索引查找。
-- 不推荐:偏移 100 万行,需扫描并丢弃 100 万行
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 1000000; Limit (cost=48500.00..48500.50 rows=20 width=64) (actual time=3200.15..3200.16 rows=20 loops=1)
-> Index Scan using orders_pkey on orders (cost=0.42..121000.00 rows=5000000 width=64) (actual time=0.05..2800.30 rows=1000020 loops=1)rows=1000020 明确暴露了「扫描了 100 万行却只返回 20 行」。
keyset 分页(keyset pagination) 用「上一页最后一行的排序键」作为起点,把 OFFSET 换成 WHERE 条件:
-- 推荐:keyset 分页。用上一页最后一行的 id 作为游标
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20; Limit (cost=0.42..8.60 rows=20 width=64) (actual time=0.02..0.10 rows=20 loops=1)
-> Index Scan using orders_pkey on orders (cost=0.42..121000.00 rows=4999000 width=64) (actual time=0.02..0.07 rows=20 loops=1)
Index Cond: (id > 1000000)rows=20——索引直接定位到起点,读取 20 行即停。耗时从 3200ms 降到 0.1ms。
唯一的约束是:排序键必须唯一且稳定,否则需要多列组合键(如 (created_at, id))来打破并列:
-- 推荐:多列 keyset,确保顺序稳定
SELECT * FROM orders
WHERE (created_at, id) > (TIMESTAMP '2026-01-01 10:00:00', 1000000)
ORDER BY created_at, id
LIMIT 20;聚合前置:把过滤与收敛做在连接之前
连接、排序、聚合的代价都与输入行数成正比。能提前收敛行数的地方,绝不拖到后面。
-- 不推荐:先连接全部数据,再 GROUP BY 收敛
SELECT c.id, COUNT(*)
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items i ON i.order_id = o.id
GROUP BY c.id;-- 推荐:先各自聚合/收敛,再连接。中间结果行数大幅减少
SELECT c.id, o.order_count
FROM customers c
JOIN (
SELECT o.customer_id, COUNT(*) AS order_count
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.customer_id
) o ON o.customer_id = c.id;同理,DISTINCT 常常可以用 EXISTS 或 GROUP BY 替代,从而避免一次全量排序:
-- 不推荐:DISTINCT 需要排序或哈希去重整个结果集
SELECT DISTINCT c.id, c.name
FROM customers c
JOIN orders o ON o.customer_id = c.id;-- 推荐:存在性判断即可,可短路
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);在 WHERE 里做「先过滤再连接」,比「先连接再过滤」高效;但要注意谓词下推的边界:
-- 推荐:过滤条件写在子查询内,先收敛再连接
SELECT c.id, o.amount
FROM customers c
JOIN (
SELECT * FROM orders WHERE created_at >= DATE '2026-01-01'
) o ON o.customer_id = c.id;PostgreSQL 通常能把外层的 WHERE 条件下推到内联的子查询,但一旦子查询带 LIMIT、DISTINCT、窗口函数或聚合,下推就会被阻断,此时把条件写进子查询内部是必要的:
-- 窗口函数会阻断谓词下推,条件必须写在窗口函数之前
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders
WHERE created_at >= DATE '2026-01-01' -- 必须写在窗口函数内部
) t WHERE rn <= 3;批量 DML 与 MERGE
INSERT、UPDATE、DELETE 也有改写空间。核心原则是避免逐行操作。
-- 不推荐:逐行 UPDATE(在应用层循环执行 10000 次)
UPDATE orders SET status = 'archived' WHERE id = 12345;-- 推荐:一条语句批量更新
UPDATE orders SET status = 'archived'
WHERE created_at < DATE '2024-01-01' AND status <> 'archived';用 UPDATE ... FROM 按另一张表的数据批量更新(比相关子查询快得多):
-- 推荐:用 UPDATE ... FROM 做「按表更新」
UPDATE orders o
SET status = s.new_status
FROM order_status_stage s
WHERE s.order_id = o.id
AND o.status IS DISTINCT FROM s.new_status;IS DISTINCT FROM 这个条件很关键:它避免了对「值没变」的行做无意义的写入,从而大幅减少 WAL 与死元组(dead tuple)的产生。
MERGE(PostgreSQL 15 起支持)把「存在则更新、不存在则插入」的 upsert 逻辑写成单条语句,比应用层的「先 SELECT 再分支」更高效且无竞态:
-- PostgreSQL 15 起支持 MERGE
MERGE INTO inventory AS t
USING daily_sales AS s
ON t.product_id = s.product_id
WHEN MATCHED THEN
UPDATE SET quantity = t.quantity - s.sold
WHEN NOT MATCHED THEN
INSERT (product_id, quantity) VALUES (s.product_id, -s.sold);对于简单的 upsert,INSERT ... ON CONFLICT 更简洁:
INSERT INTO inventory (product_id, quantity)
VALUES (42, 100)
ON CONFLICT (product_id) DO UPDATE
SET quantity = inventory.quantity + EXCLUDED.quantity;大表 DELETE 建议分批,避免单事务持有大量锁和产生巨大 WAL:
-- 推荐:分批删除,每批提交
DELETE FROM orders
WHERE id IN (
SELECT id FROM orders WHERE created_at < DATE '2023-01-01' LIMIT 10000
);
-- 重复执行直到影响行数为 0让索引可用的改写规则
索引能否被使用,取决于条件是否写成「裸列 vs 常量/参数」。以下改写几乎总能带来收益。
规则一:不要在索引列上套函数。
-- 不推荐:索引列被函数包裹,无法使用 idx_orders_created_at
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';规则二:避免在索引列上做隐式类型转换。
-- 不推荐:phone 是 varchar,写成数字会触发隐式转换
SELECT * FROM users WHERE phone = 13800138000;-- 推荐:类型匹配
SELECT * FROM users WHERE phone = '13800138000';规则三:前置模糊查询要用 text_pattern_ops 或显式转换。
-- 默认排序规则(如 en_US.UTF-8)下 LIKE 'x%' 用不上普通索引
CREATE INDEX idx_users_name_pattern ON users (name text_pattern_ops);规则四:!= 与 NOT IN 几乎无法用索引,改成 NOT EXISTS 或范围。
-- 不推荐:不等于条件的可选择性差,索引通常帮不上忙
SELECT * FROM orders WHERE status != 'cancelled';-- 推荐:若是枚举列,用正列举(可走索引)
SELECT * FROM orders WHERE status IN ('paid', 'shipped', 'pending');规则五:复合索引的列顺序要与条件形态匹配。
-- 索引 (status, created_at):等值列在前,范围列在后
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
-- 推荐:等值 + 范围,两列都能作为索引条件
SELECT * FROM orders
WHERE status = 'paid' AND created_at >= DATE '2026-01-01';只读视图与函数的下推边界
视图本质上是一个命名的子查询,规划器会把视图展开(除非是物化视图),谓词下推通常正常。但以下结构会阻断下推:
| 结构 | 是否阻断谓词下推 | 说明 |
|---|---|---|
| 普通视图 | 否 | 展开后参与整体优化 |
| 物化视图 | 是 | 已是独立存储的数据,谓词只能在读取后应用 |
含 LIMIT 的子查询 | 是 | 行数不确定,不能下推 |
| 含窗口函数的子查询 | 是 | 窗口函数在过滤前计算 |
含 DISTINCT 的子查询 | 视情况 | 可能被合并,也可能阻断 |
| 含聚合的子查询 | 是 | 除非是「可以合并的聚合」 |
含 VOLATILE 函数的表达式 | 是 | 不能改变执行次数 |
物化视图的改写策略是:把过滤条件下推到「刷新物化视图的查询」里,而不是在读取时过滤:
-- 推荐:定义时就用 WHERE 收敛,让物化视图本身更小
CREATE MATERIALIZED VIEW mv_active_orders AS
SELECT * FROM orders WHERE status IN ('paid', 'shipped');大表上的 SUM、COUNT 也可以预先算好,用物化视图替代实时聚合:
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT DATE(created_at) AS d, SUM(amount) AS total, COUNT(*) AS cnt
FROM orders
GROUP BY DATE(created_at);用 EXPLAIN 验证改写效果
改写后必须用 EXPLAIN (ANALYZE, BUFFERS) 对比,而不是凭直觉判断「这样写应该更快」。关注四个指标:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) <改写后的 SQL>;-- 对比清单
① Execution Time 总耗时是否下降?
② Buffers: shared hit/read 实际 I/O 是否减少?
③ 估算 rows 与实际 rows 偏差是否缩小?
④ 节点形状 是否去掉了 SubPlan / 多余的 Sort / HashAggregate?一个容易被忽略的验证点:改写后的计划是否对参数化查询稳定。预处理语句(prepared statement)在 PostgreSQL 里可能使用通用计划(generic plan),它的代价估算不考虑具体参数值,可能导致计划与「自定义计划(custom plan)」不同:
-- 观察是否使用通用计划
PREPARE p (int) AS SELECT * FROM orders WHERE customer_id = $1;
EXPLAIN (ANALYZE) EXECUTE p (42);
EXPLAIN (ANALYZE) EXECUTE p (42); -- 第 6 次执行后通常会切换为通用计划PostgreSQL 16 起,EXPLAIN 支持 GENERIC_PLAN 选项,可以直接查看通用计划而不必真的执行:
-- PostgreSQL 16 起支持 GENERIC_PLAN
EXPLAIN (GENERIC_PLAN) SELECT * FROM orders WHERE customer_id = $1;这填补了「通用计划难以观测」的空白——过去要看到通用计划,必须把同一个预处理语句执行 6 次以上。如果发现通用计划明显劣于自定义计划,可以考虑 plan_cache_mode = force_custom_plan。
实战示例
场景一:CTE 物化导致的谓词无法下推
需求是「统计 2026 年下单且消费超过 1000 的客户,及其订单数」。开发者为了可读性用了 CTE。
-- 改造前:CTE 被引用两次 → 物化 → 谓词无法下推
EXPLAIN (ANALYZE, BUFFERS)
WITH recent_orders AS (
SELECT customer_id, amount, created_at
FROM orders
)
SELECT c.id,
(SELECT COUNT(*) FROM recent_orders r
WHERE r.customer_id = c.id AND r.amount > 1000) AS big_orders
FROM customers c
WHERE EXISTS (SELECT 1 FROM recent_orders r
WHERE r.customer_id = c.id
AND r.created_at >= DATE '2026-01-01'); Seq Scan on customers c (cost=0.00..187500.00 rows=25000 width=40) (actual time=0.05..6420.30 rows=18200 loops=1)
Filter: (SubPlan 2)
Rows Removed by Filter: 31800
CTE recent_orders
-> Seq Scan on orders (cost=0.00..121000.00 rows=5000000 width=24) (actual time=0.02..780.50 rows=5000000 loops=1)
SubPlan 2
-> Aggregate (cost=1450.00..1450.01 rows=1 width=8) (actual time=0.35..0.35 rows=1 loops=50000)
-> CTE Scan on recent_orders r (cost=0.00..125000.00 rows=2500 width=0) (actual time=0.20..0.30 rows=150 loops=50000)
Filter: ((customer_id = c.id) AND (amount > 1000))
Rows Removed by Filter: 4999850
SubPlan 3
-> CTE Scan on recent_orders r_1 (cost=0.00..125000.00 rows=2500 width=0) (actual time=0.30..0.30 rows=1 loops=50000)
Filter: ((customer_id = c.id) AND (created_at >= '2026-01-01'::date))
Buffers: shared hit=250000 read=4800000
Planning Time: 1.204 ms
Execution Time: 6420.552 ms逐行解读:
CTE recent_orders被物化:Seq Scan on orders一次读取了全部 500 万行,构建出一个临时结果。因为 CTE 被引用两次(SubPlan 2与SubPlan 3),规划器按默认规则选择了物化。- 谓词完全无法下推:
amount > 1000与created_at >= '2026-01-01'都只能在CTE Scan上作为Filter应用。每个SubPlan的单次执行都看到 500 万行(Rows Removed by Filter: 4999850),这就是Buffers: read=4800000的来源。 loops=50000:两个SubPlan各被外表循环 5 万次,单次 0.30~0.35ms,总计约 3 万次 × 0.3ms。- 总耗时 6420ms,而这张查询的「语义复杂度」其实很低。
改写:把过滤条件下推到 CTE 内部,并避免 CTE 被多次引用。
-- 改造后:条件写进 CTE,并用 NOT MATERIALIZED + 聚合一次算完
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.id, COALESCE(o.big_orders, 0) AS big_orders
FROM customers c
JOIN (
SELECT customer_id,
COUNT(*) FILTER (WHERE amount > 1000) AS big_orders,
BOOL_OR(created_at >= DATE '2026-01-01') AS has_recent
FROM orders
GROUP BY customer_id
) o ON o.customer_id = c.id
WHERE o.has_recent; Hash Join (cost=152000.00..198000.00 rows=18200 width=40) (actual time=920.40..1480.25 rows=18200 loops=1)
Hash Cond: (o.customer_id = c.id)
-> HashAggregate (cost=121000.00..131000.00 rows=1000000 width=25) (actual time=480.10..820.30 rows=995180 loops=1)
Group Key: orders.customer_id
-> Seq Scan on orders (cost=0.00..121000.00 rows=5000000 width=21) (actual time=0.02..320.15 rows=5000000 loops=1)
-> Hash (cost=1250.00..1250.00 rows=50000 width=8) (actual time=10.20..10.20 rows=50000 loops=1)
Buckets: 65536 Batches: 1 Memory Usage: 2864kB
-> Seq Scan on customers c (cost=0.00..1250.00 rows=50000 width=8) (actual time=0.02..4.10 rows=50000 loops=1)
Buffers: shared hit=78000 read=125000
Planning Time: 0.880 ms
Execution Time: 1480.878 ms关键变化:
- 只扫一遍
orders。条件被合并进一次HashAggregate,COUNT(*) FILTER (...)与BOOL_OR(...)都在同一次扫描里计算。 SubPlan完全消失,loops=50000的逐行执行被消除。Buffers从 505 万降到 20 万,执行时间从 6420ms 降到 1480ms。
这里没有新建任何索引。仅靠「让过滤条件进入扫描层、让聚合只做一次」,就获得了 4 倍以上的提升。
场景二:OR 条件导致索引失效,改写为 UNION ALL
需求是「查出 VIP 客户的订单,或金额超过 10000 的订单」。
-- 改造前:OR 两侧中有一侧无索引
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, customer_id, amount, status
FROM orders
WHERE status = 'vip'
OR amount > 10000; Seq Scan on orders (cost=0.00..246500.00 rows=250000 width=64) (actual time=0.03..3150.44 rows=248100 loops=1)
Filter: ((status = 'vip'::text) OR (amount > 10000))
Rows Removed by Filter: 4751900
Buffers: shared hit=1200 read=240000
Planning Time: 0.420 ms
Execution Time: 3150.612 ms逐行解读:
Seq Scan读了全部 500 万行,Rows Removed by Filter: 4751900——48 万行有用,95% 是白读的。- 两个条件都有索引(
idx_orders_status、idx_orders_amount),但BitmapOr只在「两个分支都可索引且估计合并代价更低」时才会被选。这里amount > 10000的估算选择率不够低(约 5%),BitmapOr合并的估算代价超过了顺序扫描,所以规划器放弃了索引。
改写一:降低 random_page_cost,让索引看起来更便宜(若硬件是 SSD)。
SET LOCAL random_page_cost = 1.1;改写二(更通用):拆成 UNION ALL,让每个分支独立优化。
-- 改造后:拆成两个分支,各自走自己的索引
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, customer_id, amount, status
FROM orders
WHERE status = 'vip'
UNION ALL
SELECT id, customer_id, amount, status
FROM orders
WHERE amount > 10000
AND status IS DISTINCT FROM 'vip'; -- 排除第一分支已覆盖的行,避免重复 Append (cost=0.42..42800.00 rows=248100 width=64) (actual time=0.05..620.85 rows=248100 loops=1)
Buffers: shared hit=18500 read=42000
-> Index Scan using idx_orders_status on orders (cost=0.42..9850.00 rows=48500 width=64) (actual time=0.04..145.20 rows=48620 loops=1)
Index Cond: (status = 'vip'::text)
-> Index Scan using idx_orders_amount on orders (cost=0.00..29500.00 rows=199600 width=64) (actual time=0.06..420.30 rows=199480 loops=1)
Index Cond: (amount > 10000)
Filter: (status IS DISTINCT FROM 'vip'::text)
Rows Removed by Filter: 120
Planning Time: 0.950 ms
Execution Time: 621.102 ms关键变化:
- 计划变成
Append+ 两个Index Scan,两个分支各自用上了自己的索引。这正是BitmapOr合并失败时最有效的替代方案。 Buffers从 24 万降到 6 万,执行时间从 3150ms 降到 621ms(约 5 倍)。status IS DISTINCT FROM 'vip'是保证结果正确性的关键:它用NULL安全的方式排除了第一分支已经输出的行。如果只写status <> 'vip',status IS NULL的行会被两个分支都漏掉。
这个例子展示了改写的核心价值:当规划器的自动变换(BitmapOr)失败时,SQL 结构层面的改写(UNION ALL 拆分)可以人工补上这个变换。
注意事项
不要用
WITH包裹「只引用一次」的简单子查询而不加NOT MATERIALIZED(PostgreSQL 12 之前尤其危险)。 物化会阻断谓词下推。sql-- 不推荐(PG 12 之前必然物化;PG 12+ 若被多次引用也会物化) WITH w AS (SELECT * FROM big_table) SELECT * FROM w WHERE key = 123;不要对「本就有唯一性保证」的结果集使用
UNION。 多余的HashAggregate/Sort是纯浪费。sql-- 不推荐:两个分支的主键本就互不重复 SELECT id FROM orders_2025 UNION SELECT id FROM orders_2026;不要用
NOT IN处理可能含NULL的子查询。 会静默返回空结果,是最隐蔽的「性能问题」——其实是正确性问题。sql-- 错误! SELECT * FROM customers c WHERE c.id NOT IN (SELECT o.customer_id FROM orders o);不要在
UNION ALL拆分OR时忘记去重条件。 两个分支的重叠部分会产生重复行。sql-- 错误!status = 'vip' 且 amount > 10000 的行会出现两次 SELECT * FROM orders WHERE status = 'vip' UNION ALL SELECT * FROM orders WHERE amount > 10000;不要在大表的窗口函数子查询外部写过滤条件。 窗口函数会阻断谓词下推。
sql-- 不推荐:条件写在窗口函数外层,无法下推 SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) rn FROM orders ) t WHERE rn <= 3 AND created_at >= DATE '2026-01-01';不要用
SELECT *制造「无法覆盖索引」的查询。 返回列越少,越可能命中Index Only Scan。sql-- 不推荐:需要回表取全部列 SELECT * FROM orders WHERE status = 'paid';不要在
MERGE的源侧保留重复键。 同一目标行被匹配两次会直接报错。sql-- 错误!源侧 product_id 重复时会报: -- ERROR: MERGE command cannot affect row a second time MERGE INTO inventory t USING daily_sales s ON t.product_id = s.product_id ...不要为了「可读性」把热路径查询写成多层嵌套 CTE。 每一层 CTE 都可能是物化屏障,代价会随层数放大。
sql-- 不推荐:三层 CTE,每层都可能物化 WITH a AS (...), b AS (SELECT * FROM a ...), c AS (SELECT * FROM b ...) SELECT * FROM c WHERE ...;不要在应用层逐行执行 DML。 一万次单行
UPDATE的开销远超一次批量语句。sql-- 不推荐:应用层循环一万次 UPDATE orders SET status = 'archived' WHERE id = ?;不要忽略批量 DML 写放大。 对「值没变」的行执行
UPDATE会额外产生死元组与 WAL。sql-- 推荐:加上 IS DISTINCT FROM 过滤掉无变化的行 UPDATE orders o SET status = s.new_status FROM stage s WHERE s.order_id = o.id AND o.status IS DISTINCT FROM s.new_status;
最佳实践
改写前先固定基线。 把改造前的
EXPLAIN (ANALYZE, BUFFERS)完整保存,改写后逐项对比。sqlEXPLAIN (ANALYZE, BUFFERS, VERBOSE) <SQL>; -- 记录 Execution Time、Buffers、节点形状三项优先做「谓词下推」类改写。 让过滤尽早发生,往往是最立竿见影的手段。
sql-- 条件写进子查询/CTE 内部,而不是最外层 JOIN (SELECT * FROM orders WHERE created_at >= DATE '2026-01-01') o ON ...CTE 引用一次就用默认内联;引用多次且昂贵才显式
MATERIALIZED。sqlWITH delta AS NOT MATERIALIZED (SELECT ... ) -- 引用一次,要下推 WITH expensive AS MATERIALIZED (SELECT slow_fn(..)) -- 引用多次,要复用存在性判断统一用
EXISTS/NOT EXISTS。 既避免NULL陷阱,又便于生成 Semi/Anti Join。sqlWHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)不需要去重时一律写
UNION ALL。 这是零风险、零成本的改写。sqlSELECT ... UNION ALL SELECT ...OR拆分是「索引没被用上」的通用兜底方案。 拆分时务必用IS DISTINCT FROM去重。sqlSELECT ... WHERE a = 1 UNION ALL SELECT ... WHERE b = 2 AND a IS DISTINCT FROM 1;深分页一律改 keyset 分页。 排序键要唯一且稳定,必要时用多列组合。
sqlWHERE (created_at, id) > (:last_created_at, :last_id) ORDER BY created_at, id LIMIT 20连接前先收敛行数。 尽量让
GROUP BY、DISTINCT、LIMIT在连接之前完成。sqlJOIN (SELECT customer_id, COUNT(*) c FROM orders GROUP BY customer_id) agg ON ...用
EXPLAIN (GENERIC_PLAN)(PostgreSQL 16 起支持)检查预处理语句的通用计划。 通用计划与自定义计划的差异是常见的「线上慢、本地快」根因。sqlEXPLAIN (GENERIC_PLAN) SELECT * FROM orders WHERE customer_id = $1;物化视图把过滤与聚合前置。 让视图本身尽量小,而不是在读取时过滤。
sqlCREATE MATERIALIZED VIEW mv_active_orders AS SELECT * FROM orders WHERE status IN ('paid', 'shipped');用
pg_stat_statements找出「最值得改写」的查询。 按总耗时排序,优先优化占比最高的那些。sqlSELECT queryid, calls, total_exec_time, mean_exec_time, query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;
下一节
参考链接
- PostgreSQL 官方文档:WITH Queries (Common Table Expressions)
- PostgreSQL 官方文档:Common Table Expression Materialization
- PostgreSQL 官方文档:Table Expressions(JOIN / LATERAL)
- PostgreSQL 官方文档:Subquery Expressions(EXISTS / IN / ANY / ALL)
- PostgreSQL 官方文档:MERGE
- PostgreSQL 官方文档:INSERT(ON CONFLICT / upsert)
- PostgreSQL 官方文档:EXPLAIN
- PostgreSQL 官方文档:Using EXPLAIN
- PostgreSQL 官方文档:pg_stat_statements
- PostgreSQL 官方文档:CREATE MATERIALIZED VIEW
- PostgreSQL 官方文档:Indexes on Expressions
- PostgreSQL 官方文档:Operator Classes and Operator Families