引言
在数据分析工作中,多表 JOIN 是最常见的操作之一。无论你是做客户-订单关联分析、产品-分类多表查询,还是复杂的宽表拼接,JOIN 的性能直接影响查询效率。
DuckDB 作为列式分析的数据库引擎,对 JOIN 操作有独特的优化策略。本文将从实战出发,带你深入了解 DuckDB 中各种 JOIN 类型、执行计划、以及性能优化技巧。
一、DuckDB 支持的 JOIN 类型
1.1 INNER JOIN — 只返回匹配行
INNER JOIN 是最基础的 JOIN 类型,只返回两个表中满足连接条件的行。
-- 创建测试数据
CREATE TABLE customers AS SELECT * FROM (VALUES
(1,'Alice'),(2,'Bob'),(3,'Charlie'),(4,'Diana'),(5,'Eve')
) AS t(id, name);
CREATE TABLE orders AS SELECT * FROM (VALUES
(1,1,100),(2,1,200),(3,2,150),(4,2,300),(5,3,250),
(6,4,175),(7,4,225),(8,5,180),(9,5,320),(10,1,275)
) AS t(order_id, customer_id, amount);
-- INNER JOIN 查询
SELECT c.name AS customer, o.amount, o.order_id
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
ORDER BY o.order_id;

图:INNER JOIN 查询结果,只返回有订单匹配的客户
注意:由于测试数据中所有客户都有订单,INNER JOIN 和 LEFT JOIN 结果看起来相同。但在实际业务中,LEFT JOIN 会保留无订单的客户(显示 NULL)。
1.2 LEFT JOIN — 保留左表所有行
LEFT JOIN 返回左表的全部行,右表中没有匹配的行用 NULL 填充。
-- LEFT JOIN 查询
SELECT c.name AS customer, COALESCE(SUM(o.amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.name
ORDER BY total_spent DESC;
1.3 FULL OUTER JOIN — 两表全部行
FULL OUTER JOIN 返回两个表的全部行,没有匹配的行用 NULL 填充。
1.4 半连接 (SEMI JOIN) — 使用 EXISTS
DuckDB 不直接支持 SEMI JOIN 语法,但可以用 EXISTS 子查询实现相同效果:
-- 用 EXISTS 实现半连接:找出金额 > 200 的客户
SELECT c.name AS customer
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.amount > 200
);
执行计划显示 DuckDB 会将其优化为 LEFT_DELIM_JOIN:
EXPLAIN SELECT c.name AS customer
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.amount > 200
);

图:Hash Join 与 Merge Join 对比 — DuckDB 在不同场景下选择不同的 JOIN 算法
二、Hash Join vs Merge Join
DuckDB 查询优化器会根据数据特征自动选择最优的 JOIN 算法。理解这两种算法有助于你编写更高效的查询。
2.1 Hash Join — 大多数场景的首选
Hash Join 是 DuckDB 默认的 JOIN 算法,特别适合不等值连接和随机分布的数据。
执行流程:
- 构建阶段:扫描较小的表(Build 表),在内存中构建哈希表
- 探测阶段:扫描较大的表(Probe 表),对每行计算哈希值并探测哈希表
- 合并阶段:返回匹配的行
-- 查看 Hash Join 的执行计划
EXPLAIN SELECT *
FROM large_orders o
JOIN large_products p ON o.id = p.id
WHERE o.amount > 300;
┌───────────────────────────┐
│ HASH_JOIN │
│ Join Type: INNER │
│ Conditions: id = id │
│ ~1,000 rows │
└─────────────┬─────────────┘
┌───────┴───────┐
▼ ▼
┌─────────┐ ┌──────────┐
│SEQ_SCAN │ │ FILTER │
│products │ │(id<=5000)│
└─────────┘ └──────────┘
Hash Join 特点:
- ✅ 无需排序,适用任意数据分布
- ✅ 支持不等值连接(
>、<、BETWEEN等) - ✅ 内存占用中等(存储哈希表)
- ✅ DuckDB 默认策略
2.2 Merge Join — 有序数据的利器
Merge Join 适用于已经排序的数据,通过双指针同步扫描实现高效连接。
执行流程:
- 排序阶段:对两表按连接键排序(如果未排序)
- 合并阶段:双指针同时扫描,找到匹配的行
Merge Join 特点:
- ✅ 流式处理,内存占用极低
- ✅ 适合已排序的数据(如分区表、有序列存)
- ✅ 适合范围连接(如
a.id BETWEEN b.start AND b.end) - ⚠️ 需要预排序,排序成本高
-- 当表已经按连接键排序时,DuckDB 可能自动选择 Merge Join
EXPLAIN SELECT *
FROM orders_sorted o
JOIN customers_sorted c ON o.customer_id = c.id;
2.3 如何选择?
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| 随机分布的大表 JOIN | Hash Join | 无需排序,单次扫描 |
不等值连接(>、<) | Hash Join | Merge Join 不支持 |
| 已排序的有序表 | Merge Join | 流式处理,内存效率高 |
| 小表 JOIN 大表 | Hash Join(小表作 Build) | 哈希表小,探测快 |
| 宽表多列连接 | Hash Join | 灵活性更高 |
三、物化策略与查询优化
3.1 谓词下推(Predicate Pushdown)
DuckDB 的优化器会将过滤条件尽可能下推到 Join 之前执行,减少参与 JOIN 的数据量。
-- 优化前:先 JOIN 再过滤(低效)
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.amount > 200;
-- 优化后:先过滤再 JOIN(高效,DuckDB 自动完成)
-- 执行计划显示 amount>200 在 SEQ_SCAN 阶段就应用了
EXPLAIN SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.amount > 200;

图:DuckDB 执行计划 — Hash Join 中,谓词过滤在 SEQ_SCAN 阶段就已应用,大幅减少数据量
3.2 中间结果物化
当同一个子查询被多次引用时,DuckDB 会自动物化中间结果,避免重复计算:
-- DuckDB 会自动物化 subquery 结果,避免重复计算
SELECT *
FROM (SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id) subq
JOIN customers c ON subq.customer_id = c.id
WHERE subq.total > 400;
3.3 列裁剪(Projection Pushdown)
DuckDB 作为列式数据库,只会读取查询中需要的列:
-- 只读取 name 和 amount 两列,其他列不参与 JOIN 计算
SELECT c.name, o.amount
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE o.amount > 200;
执行计划中可以看到,即使 customers 表有其他列,也只会投影 id 和 name。
四、实战场景
4.1 场景一:订单分析 — 找出高价值客户
-- 找出消费总额 > 400 的客户及其订单详情
SELECT
c.name AS customer,
c.id AS customer_id,
SUM(o.amount) AS total_spent,
COUNT(o.order_id) AS order_count
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.name, c.id
HAVING SUM(o.amount) > 400
ORDER BY total_spent DESC;
结果:
| customer | customer_id | total_spent | order_count |
|---|---|---|---|
| Alice | 1 | 575 | 3 |
| Eve | 5 | 500 | 2 |
| Bob | 2 | 450 | 2 |
| Diana | 4 | 400 | 2 |
4.2 场景二:多表关联 — 产品-分类-订单
-- 关联产品、分类、订单三个表
SELECT
p.name AS product,
cat.name AS category,
o.amount AS order_amount
FROM products p
JOIN product_categories pc ON p.id = pc.product_id
JOIN categories cat ON pc.category_id = cat.id
JOIN orders o ON o.customer_id IN (
SELECT id FROM customers WHERE name = 'Alice'
)
WHERE p.price > 400;
4.3 场景三:用 EXISTS 替代 JOIN — 避免重复行
当只需要判断"是否存在"而不需要关联数据时,使用 EXISTS 比 JOIN 更高效:
-- ❌ 低效:JOIN 可能产生重复行
SELECT DISTINCT c.name
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE o.amount > 200;
-- ✅ 高效:EXISTS 直接返回布尔判断,无重复
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.amount > 200
);
4.4 场景四:反向查找 — NOT EXISTS
找出没有任何高价值订单的客户:
SELECT c.name AS customer
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.amount > 200
);
五、性能优化技巧总结
5.1 索引与排序
虽然 DuckDB 是列式存储、无需传统 B-tree 索引,但有序数据对 Merge Join 非常有利:
-- 创建有序表,可能触发 Merge Join
CREATE TABLE orders_sorted AS
SELECT * FROM orders ORDER BY customer_id;
5.2 控制 Build/Probe 表大小
DuckDB 默认选择较小的表作为 Build 表构建哈希表。你可以通过设置 parallel 参数或调整数据分布来优化:
-- 查看执行计划,确认 DuckDB 选择的 JOIN 策略
EXPLAIN VERBOSE SELECT * FROM large_table A
JOIN small_table B ON A.id = B.id;
5.3 避免不必要的列
只 SELECT 需要的列,减少内存占用和 I/O:
-- ✅ 只选取需要的列
SELECT c.name, o.amount
FROM customers c
JOIN orders o ON c.id = o.customer_id;
-- ❌ 避免 SELECT *
SELECT * FROM customers c JOIN orders o ON c.id = o.customer_id;
5.4 利用 Filter Pushdown
将过滤条件放在 JOIN 之前,减少参与 JOIN 的数据量:
-- ✅ 先过滤再 JOIN
SELECT *
FROM (SELECT * FROM orders WHERE amount > 200) o
JOIN customers c ON o.customer_id = c.id;
六、总结
DuckDB 的 JOIN 优化核心在于:
- Hash Join 是默认策略 — 适用于大多数场景,无需排序,单次扫描
- Merge Join 适用于有序数据 — 流式处理,内存效率高
- 谓词下推自动优化 — 过滤条件在 JOIN 前执行,减少数据量
- EXISTS 替代 JOIN — 半连接场景下更简洁高效,避免重复行
- 列裁剪减少 I/O — 只读取需要的列,提升查询性能
通过合理选择 JOIN 类型和优化策略,你可以充分发挥 DuckDB 在分析型查询中的性能优势。
更多 DuckDB 实战技巧,请关注 DuckDB Lab(duckdblab.org)