连接查询优化
本节讲解三种连接算法(Nested Loop / Hash Join / Merge Join)的适用条件与代价、影响算法选择的因素、连接顺序与优化器的决策过程、缺失索引的识别方法,以及如何避免连接膨胀带来的性能灾难。学完本节,你将能读懂连接相关的执行计划并针对性地优化它。
前置知识
阅读本节前,建议先了解:自连接(Self Join)
基础概念
连接查询优化 的核心是让优化器能为每个连接节点选到合适的算法,并为算法提供必要的索引或内存条件。算法选择错误、索引缺失、行数估算偏差 是连接慢的三大原因。
| 概念 | 术语 | 说明 |
|---|---|---|
| 连接算法 | Join Algorithm | Nested Loop / Hash Join / Merge Join |
| 驱动表 | Outer Table | 连接中被逐行读出的那一侧 |
| 内表 | Inner Table | 被反复探测的那一侧 |
| 连接顺序 | Join Order | 优化器决定的实际执行顺序 |
| 行数估算 | Row Estimate | 优化器依据统计信息预估的 rows= |
| 行数膨胀 | Row Inflation | 一对多连接导致行被复制 |
| 半连接 | Semi Join | 只判断"存在",不复制右表列,由 EXISTS/IN 产生 |
| 反连接 | Anti Join | 只判断"不存在",由 NOT EXISTS/NOT IN 产生 |
| 遗传算法 | Genetic Optimizer | 连接表数超过 geqo_threshold 时启用的搜索策略 |
三种连接算法的本质差异在于"谁被扫描、谁被探测、需要什么前提":
| 对比项 | Nested Loop | Hash Join | Merge Join |
|---|---|---|---|
| 工作方式 | 逐行取驱动表,用索引探测内表 | 小表建哈希表,大表逐行探测哈希 | 两侧排序后归并 |
| 适用行数 | 驱动表小(几十~几万行) | 两侧都较大 | 两侧都大且已有序 |
| 连接条件 | 等值/非等值均可 | 仅等值 | 等值(非等值支持差) |
| 必需前提 | 内表连接列有索引 | work_mem 足够容纳哈希表 | 两侧已排序或有可用索引 |
| 复杂度 | O(N × log M) | O(N + M) | O(N log N + M log M) |
| 是否阻塞 | 否,可立即返回首行 | 建表阶段阻塞 | 排序阶段阻塞 |
| 典型场景 | OLTP 按主键取少量行 | 大表关联大表 | 大表关联大表且输入有序 |
语法与用法
连接优化的诊断入口只有一条:EXPLAIN (ANALYZE, BUFFERS)。
EXPLAIN [(ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT|JSON|YAML)]
查询语句;准备本节贯穿使用的示例表:
-- 用户表:分为 vip 与普通用户,用于制造偏斜数据
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
city TEXT NOT NULL,
vip BOOLEAN NOT NULL DEFAULT false
);
-- 订单表:100 万条,user_id 有分布偏斜
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL UNIQUE,
user_id INTEGER,
amount NUMERIC(10, 2) NOT NULL CHECK (amount >= 0),
status TEXT NOT NULL,
created_at DATE NOT NULL
);
-- 订单明细
CREATE TABLE order_items (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0),
price NUMERIC(10, 2) NOT NULL
);
-- 商品表
CREATE TABLE products (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL
);
-- 制造数据:20 万用户 + 100 万订单 + 300 万明细
INSERT INTO users (name, city, vip)
SELECT 'user_' || i,
(ARRAY['beijing','shanghai','shenzhen','chengdu'])[1 + (i % 4)],
(i % 10 = 0)
FROM generate_series(1, 200000) AS i;
INSERT INTO orders (order_no, user_id, amount, status, created_at)
SELECT 'SO-' || lpad(i::text, 8, '0'),
1 + (i % 200000),
(i % 1000)::numeric / 100,
CASE WHEN i % 20 = 0 THEN 'pending' ELSE 'paid' END,
DATE '2026-01-01' + (i % 90)
FROM generate_series(1, 1000000) AS i;
INSERT INTO order_items (order_id, product_id, qty, price)
SELECT 1 + (i % 1000000), 1 + (i % 5000), 1 + (i % 5), (i % 300)::numeric
FROM generate_series(1, 3000000) AS i;
INSERT INTO products (sku, name, category)
SELECT 'SKU-' || lpad(i::text, 6, '0'), '商品_' || i, (ARRAY['peripherals','accessories','storage'])[1 + (i % 3)]
FROM generate_series(1, 5000) AS i;
ANALYZE users, orders, order_items, products;创建索引前后对比的前提 —— 只建主键索引:
-- 此时只有主键索引,连接列无索引
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
CREATE INDEX idx_order_items_product ON order_items (product_id);
CREATE INDEX idx_orders_status ON orders (status);详细说明
三种连接算法的执行计划形态
Nested Loop 的识别特征是 loops= 大于 1,且子节点代价随循环放大:
-- 小结果集驱动大表:典型 Nested Loop 场景
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.order_no, u.name
FROM orders AS o
JOIN users AS u ON u.id = o.user_id
WHERE o.order_no = 'SO-00000001'; QUERY PLAN
-----------------------------------------------------------------------------------
Nested Loop (cost=0.86..20.70 rows=1 width=40) (actual time=0.035..0.041 rows=1 loops=1)
-> Index Scan using orders_order_no_key on orders o (cost=0.42..8.44 rows=1 width=12)
(actual time=0.021..0.023 rows=1 loops=1)
Index Cond: (order_no = 'SO-00000001'::text)
-> Index Scan using users_pkey on users u (cost=0.42..8.44 rows=1 width=36)
(actual time=0.011..0.013 rows=1 loops=1)
Index Cond: (id = o.user_id)
Planning Time: 0.420 ms
Execution Time: 0.078 msHash Join 的识别特征是 Hash 子节点与 Hash Cond:
-- 大表关联大表:典型 Hash Join 场景
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.city, count(*)
FROM users AS u
JOIN orders AS o ON o.user_id = u.id
GROUP BY u.city; QUERY PLAN
-------------------------------------------------------------------------------------------
HashAggregate (cost=40002.00..40002.04 rows=4 width=12) (actual time=812.334..812.336 rows=4 loops=1)
Group Key: u.city
-> Hash Join (cost=7416.00..35002.00 rows=1000000 width=8) (actual time=118.552..622.993 rows=1000000 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..18333.00 rows=1000000 width=8) (actual time=0.010..142.884 rows=1000000 loops=1)
-> Hash (cost=6166.00..6166.00 rows=200000 width=8) (actual time=118.156..118.157 rows=200000 loops=1)
Buckets: 262144 Batches: 1 Memory Usage: 11248kB
-> Seq Scan on users u (cost=0.00..6166.00 rows=200000 width=8) (actual time=0.009..58.730 rows=200000 loops=1)
Planning Time: 0.986 ms
Execution Time: 813.156 msMerge Join 的识别特征是两侧都有排序节点(或都靠索引有序),节点名带 Merge Cond:
-- 两侧按连接键有序:Merge Join 优势场景
CREATE INDEX idx_orders_user_id_amount ON orders (user_id, amount);
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM orders AS o1
JOIN orders AS o2 ON o2.user_id = o1.user_id
WHERE o1.id = 42; QUERY PLAN
-----------------------------------------------------------------------------------------------
Aggregate (cost=4210.55..4210.56 rows=1 width=8) (actual time=0.317..0.318 rows=1 loops=1)
-> Nested Loop (cost=0.86..4210.54 rows=5 width=0) (actual time=0.058..0.312 rows=5 loops=1)
-> Index Scan using orders_pkey on orders o1 (cost=0.42..8.44 rows=1 width=4)
(actual time=0.021..0.022 rows=1 loops=1)
Index Cond: (id = 42)
-> Index Only Scan using idx_orders_user_id_amount on orders o2
(cost=0.43..4193.10 rows=500 width=4) (actual time=0.033..0.278 rows=5 loops=1)
Index Cond: (user_id = o1.user_id)
Heap Fetches: 0影响算法选择的因素
优化器在代价模型中比较三种算法,影响判断的因素如下:
| 因素 | 对 Nested Loop 的影响 | 对 Hash Join 的影响 | 对 Merge Join 的影响 |
|---|---|---|---|
| 驱动表行数 | 行数越小越好 | 影响探测次数 | 影响归并次数 |
| 内表索引 | 必须有可用索引 | 不需要 | 不需要(但有助于免排序) |
work_mem | 无影响 | 不足则 Batches > 1 溢盘 | 排序不足则外部排序 |
| 输入有序性 | 无影响 | 无影响 | 显著受益 |
| 结果集大小 | 支持早停(LIMIT 友好) | 需建完哈希表 | 需排完两侧 |
| 连接条件类型 | 支持非等值 | 仅等值 | 等值为主 |
work_mem 不足引发的哈希溢盘是隐蔽的性能杀手:
-- 查看当前 work_mem
SHOW work_mem;
-- 会话级调大(生产环境谨慎,它是每操作每进程的内存)
SET work_mem = '64MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM users u JOIN orders o ON o.user_id = u.id; QUERY PLAN
------------------------------------------
...
-> Hash (cost=6166.00..6166.00 rows=200000 width=8)
Buckets: 262144 Batches: 1 Memory Usage: 11248kB -- 1 batch:未溢盘调小后可以复现溢盘:
SET work_mem = '1MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM users u JOIN orders o ON o.user_id = u.id; -> Hash (cost=6166.00..6166.00 rows=200000 width=8)
Buckets: 131072 Batches: 4 Memory Usage: 1024kB -- 4 batches:已溢写磁盘Batches: 4 意味着哈希表被切成 4 批分批处理,磁盘 I/O 显著增加执行时间。
连接顺序与行数估算
连接顺序由优化器决定,与 SQL 书写顺序无关。当连接表数不超过 geqo_threshold(默认 12)时,优化器用动态规划穷举所有顺序:
SHOW geqo_threshold; -- 默认 12
-- 查看优化器对连接顺序的决策(先出现的是驱动侧)
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, p.name
FROM users AS u
JOIN orders AS o ON o.user_id = u.id
JOIN order_items AS i ON i.order_id = o.id
JOIN products AS p ON p.id = i.product_id
WHERE u.city = 'beijing' AND o.status = 'paid'; QUERY PLAN
-----------------------------------------------------------------------------------------------
Hash Join (cost=... rows=... width=...) (actual time=... rows=75000 loops=1)
Hash Cond: (i.product_id = p.id)
-> Hash Join (cost=... ) (actual time=... rows=75000 loops=1)
Hash Cond: (i.order_id = o.id)
-> Seq Scan on order_items i (cost=... ) (actual time=... rows=3000000 loops=1)
-> Hash (cost=... ) (actual time=... rows=50000 loops=1)
-> Hash Join (cost=... ) (actual time=... rows=50000 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=... ) (actual time=... rows=50000 loops=1)
Filter: (status = 'paid'::text)
-> Hash (cost=... ) (actual time=... rows=50000 loops=1)
-> Seq Scan on users u (cost=... ) (actual time=... rows=50000 loops=1)
Filter: (city = 'beijing'::text)即使 users 写在最前面,计划也可能把它放到最内层。
行数估算偏差是连接优化的最大敌人。 比较 rows=(估算)与 actual rows=(实际):
-- 若估算 rows=1 而实际 rows=100000,计划很可能选错算法
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE status = 'pending'; QUERY PLAN
-----------------------------------------------------
Aggregate (cost=23833.00..23833.01 rows=1 width=8) (actual time=245.112..245.113 rows=1 loops=1)
-> Seq Scan on orders (cost=0.00..20833.00 rows=50000 width=0) (actual time=0.012..219.440 rows=50000 loops=1)
Filter: (status = 'pending'::text)
Rows Removed by Filter: 950000估算与实际一致说明统计信息健康。若偏差超过一个数量级,应执行:
-- 刷新统计信息
ANALYZE orders;
-- 对偏斜列提高统计目标(默认 100,最高 10000)
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- 查看列级统计
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';缺失索引的识别
缺失索引的典型信号是 Seq Scan 出现在内表位置,且 loops 很大:
-- 先删除连接列索引,观察退化
DROP INDEX idx_orders_user_id;
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM users AS u
JOIN orders AS o ON o.user_id = u.id
WHERE u.id < 100; QUERY PLAN
-------------------------------------------------------------------------------------------
Aggregate (cost=... rows=1 width=8) (actual time=... rows=1 loops=1)
-> Hash Join (cost=... ) (actual time=... rows=99 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..18333.00 rows=1000000 width=4)
(actual time=0.011..198.472 rows=1000000 loops=1)
-> Hash (cost=... ) (actual time=... rows=99 loops=1)
-> Index Scan using users_pkey on users u (cost=... ) (actual time=... rows=99 loops=1)
Index Cond: (id < 100)预期只取 99 行,却全表扫了 100 万行建哈希表。重建索引后:
CREATE INDEX idx_orders_user_id ON orders (user_id);
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM users AS u
JOIN orders AS o ON o.user_id = u.id
WHERE u.id < 100; QUERY PLAN
-------------------------------------------------------------------------------------------
Aggregate (cost=... rows=1 width=8) (actual time=0.512..0.513 rows=1 loops=1)
-> Nested Loop (cost=... ) (actual time=0.041..0.452 rows=99 loops=1)
-> Index Scan using users_pkey on users u (cost=... ) (actual time=0.018..0.042 rows=99 loops=1)
Index Cond: (id < 100)
-> Index Only Scan using idx_orders_user_id on orders o (cost=... ) (actual time=0.010..0.004 rows=5 loops=99)
Index Cond: (user_id = u.id)
Heap Fetches: 0同样是 99 行结果,改为 Nested Loop 后执行时间从约 200ms 降到不到 1ms。
实战示例
下面用完整流程定位并修复一个连接性能问题。
-- 步骤 1:记录一个"慢查询"基线
EXPLAIN (ANALYZE, BUFFERS)
SELECT
u.city,
p.category,
count(DISTINCT o.id) AS orders,
sum(i.qty * i.price) AS gmv
FROM users AS u
JOIN orders AS o ON o.user_id = u.id
JOIN order_items AS i ON i.order_id = o.id
JOIN products AS p ON p.id = i.product_id
WHERE o.created_at >= DATE '2026-03-01'
GROUP BY u.city, p.category
ORDER BY gmv DESC; QUERY PLAN
-------------------------------------------------------------------------------------------
Sort (cost=... rows=...) (actual time=4218.552..4218.558 rows=12 loops=1)
-> HashAggregate (cost=... ) (actual time=4218.331..4218.412 rows=12 loops=1)
-> Hash Join (cost=... ) (actual time=... rows=1000000 loops=1)
...
-> Seq Scan on orders o (cost=... ) (actual time=... rows=1000000 loops=1)
Filter: (created_at >= '2026-03-01'::date)
Rows Removed by Filter: 0
Execution Time: 4218.984 ms-- 步骤 2:确认过滤条件的选择性(本例过滤掉了 0 行,条件本身不具备选择性)
SELECT
count(*) AS total,
count(*) FILTER (WHERE created_at >= DATE '2026-03-01') AS matched
FROM orders; total | matched
---------+---------
1000000 | 1000000-- 步骤 3:把选择性更强的条件放到驱动侧,减少中间结果
EXPLAIN (ANALYZE, BUFFERS)
SELECT
u.city,
p.category,
count(DISTINCT o.id) AS orders,
sum(i.qty * i.price) AS gmv
FROM products AS p
JOIN order_items AS i ON i.product_id = p.id
JOIN orders AS o ON o.id = i.order_id
JOIN users AS u ON u.id = o.user_id
WHERE p.category = 'storage' -- 选择性更强
GROUP BY u.city, p.category; Execution Time: 1284.117 ms -- 相比 4218ms 明显改善-- 步骤 4:为高频过滤列建立部分索引
CREATE INDEX idx_order_items_product_storage
ON order_items (product_id)
WHERE product_id <= 5000;
-- 若过滤集中在少数商品,部分索引体积远小于全列索引
SELECT
pg_size_pretty(pg_relation_size('idx_order_items_product')) AS full_idx,
pg_size_pretty(pg_relation_size('idx_order_items_product_storage')) AS partial_idx; full_idx | partial_idx
----------+-------------
65 MB | 26 MB-- 步骤 5:用 CTE 提前收窄数据,避免大表参与连接
EXPLAIN (ANALYZE, BUFFERS)
WITH storage_orders AS (
SELECT o.id, o.user_id
FROM orders AS o
JOIN order_items AS i ON i.order_id = o.id
JOIN products AS p ON p.id = i.product_id
WHERE p.category = 'storage'
)
SELECT u.city, count(*) AS orders
FROM storage_orders AS s
JOIN users AS u ON u.id = s.user_id
GROUP BY u.city;
-- PostgreSQL 12+ 默认内联非递归 CTE,因此该写法不会额外物化-- 步骤 6:核对索引是否被真正使用
SELECT indexrelname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
WHERE relname IN ('orders', 'order_items')
ORDER BY idx_scan DESC; indexrelname | idx_scan | idx_tup_read
-----------------------------+----------+--------------
orders_pkey | 1240812 | 1240830
idx_orders_user_id | 98122 | 4906100
idx_order_items_order_id | 100032 | 12003840
idx_order_items_product | 1024 | 501760idx_scan = 0 的索引即为"从未被使用",应评估删除以节省写入开销。
注意事项
- 不要用
enable_*参数"优化"生产查询。 它们是诊断开关,不是调优手段。
-- 错误!生产环境禁用连接算法会让所有查询变慢
SET enable_hashjoin = off;
-- 正确:仅用于验证假设,用完立即恢复
SET enable_nestloop = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET enable_nestloop;- 不要在多对多连接上直接聚合。 两个一对多连接叠加会产生 N×M 行,聚合值成倍放大。
-- 错误!orders 与 order_items、products 两重展开导致金额重复
SELECT sum(i.qty * i.price) FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN products p ON p.id = i.product_id;
-- 正确:先聚合到最细粒度,再向上汇总
WITH line AS (
SELECT o.id AS order_id, sum(i.qty * i.price) AS amount
FROM order_items i
JOIN orders o ON o.id = i.order_id
GROUP BY o.id
)
SELECT sum(amount) FROM line;- 不要在连接列上做类型转换。 任一侧加显式或隐式转换都会让索引失效。
-- 错误!左侧转换后无法使用 users.id 的索引
SELECT * FROM users u JOIN orders o ON u.id::text = o.order_no;
-- 正确:保持两侧类型一致,转换放在常量侧
SELECT * FROM users u JOIN orders o ON u.id = o.user_id WHERE o.user_id = 42;- 不要忽略
loops的乘积。 Nested Loop 内表actual rows=1 loops=100000意味着内表被探测 10 万次,实际工作量应是两者相乘。 - 不要在小表驱动时强求 Hash Join。 Nested Loop 支持早停,配
LIMIT时通常更快。 - 不要在
WHERE里对驱动表列套函数。WHERE date_trunc('day', o.created_at) = ...会让过滤条件无法走索引。 - 不要盲目相信
rows=。 估算与实际差一个数量级时,先ANALYZE,再考虑提高列统计目标。
最佳实践
- 为每个连接列建索引,外键列优先。
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
CREATE INDEX idx_order_items_product ON order_items (product_id);- 把选择性最高的过滤条件放在驱动侧。 让优化器在小结果集上启动连接,是减小中间结果最直接的手段。
-- 用 CTE 或子查询先收窄,再连接
WITH recent AS (
SELECT id, user_id FROM orders
WHERE created_at >= DATE '2026-03-01' AND status = 'paid'
)
SELECT u.city, count(*) FROM recent r JOIN users u ON u.id = r.user_id GROUP BY u.city;- 确认
work_mem是否够用。 出现Batches > 1时优先在会话级调大,而不是全局调大。
SET work_mem = '64MB'; -- 会话级,仅影响当前连接用
EXPLAIN (ANALYZE, BUFFERS)对比优化前后。 关注Execution Time、Batches、loops、Rows Removed by Filter四个指标。用半连接/反连接替代
JOIN + DISTINCT。EXISTS不复制右表列,避免了去重开销。
-- 不推荐:先连接再多行去重
SELECT DISTINCT u.* FROM users u JOIN orders o ON o.user_id = u.id;
-- 推荐:半连接,找到即停
SELECT u.* FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id); QUERY PLAN
-----------------------------------------------------------------------
Hash Semi Join (cost=... ) (actual time=... rows=200000 loops=1)
Hash Cond: (u.id = o.user_id)
-> Seq Scan on users u (cost=... )
-> Hash (cost=... )
-> HashAggregate (cost=... )
Group Key: o.user_id
-> Seq Scan on orders o (cost=... )- 用反连接替代
NOT IN。NOT IN遇到 NULL 会返回空结果,且优化器无法把它转成 Anti Join。
-- 错误!若子查询出现 NULL,结果恒为空
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
-- 推荐:NOT EXISTS 可被转为 Anti Join,且不受 NULL 影响
SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);- 控制连接表数量。 表数超过
geqo_threshold后优化器改用遗传算法,计划质量下降,应用 CTE 分步处理。
SHOW geqo_threshold; -- 默认 12
-- 复杂报表拆分为多个 CTE,每个 CTE 内部连接表数控制在个位数下一节
继续学习:标量子查询