自然连接与 USING
本节讲解 NATURAL JOIN 的自动列匹配语义、USING 子句的列合并特性,以及为什么生产代码应一律使用显式 ON。学完本节,你将能解释自然连接的隐式契约风险,并在维护老代码时正确评估其隐患。
前置知识
阅读本节前,建议先了解:交叉连接(CROSS JOIN)
基础概念
自然连接(Natural Join) 是 INNER JOIN 的语法糖:它自动找出两表中同名的所有列,并用这些列做等值连接。USING 子句则是"手动指定连接列名"的版本,适用于连接列同名但不想连接所有同名列的场景。
| 概念 | 术语 | 说明 |
|---|---|---|
| 自然连接 | NATURAL JOIN | 自动按全部同名列做等值连接 |
| 隐式契约 | Implicit Contract | 依赖列名相同,结构变更会静默改变语义 |
| 列合并 | Column Coalescing | 连接列在输出中只保留一份 |
| 限定名 | Qualified Name | 表名.列名 形式的引用 |
| 哑列 | Dummy Column | 无关但同名的列,会破坏自然连接 |
三种连接语法的对比:
| 语法 | 连接列来源 | 输出列 | 显式程度 | 推荐度 |
|---|---|---|---|---|
A JOIN B ON A.id = B.aid | 手写表达式 | 两侧列都保留 | 最高 | ✅ 强烈推荐 |
A JOIN B USING (id) | 指定的同名列 | 连接列合并为一列 | 中 | ⚠️ 有限使用 |
A NATURAL JOIN B | 全部同名列 | 连接列合并为一列 | 最低 | ❌ 不推荐 |
关键认识:NATURAL JOIN 的连接条件由表结构决定,而不是由 SQL 语句决定。一旦表加了新的同名列,连接行为就会静默改变——这就是它最危险的地方。
语法与用法
三种写法:
-- 自然连接:自动匹配所有同名列
SELECT 列列表 FROM 左表 NATURAL JOIN 右表;
-- USING:显式指定连接列
SELECT 列列表 FROM 左表 JOIN 右表 USING (列1 [, 列2]);
-- ON:完全显式
SELECT 列列表 FROM 左表 JOIN 右表 ON 左表.列 = 右表.列;准备本节贯穿使用的示例表:
-- 用户表
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
city TEXT NOT NULL,
status TEXT NOT NULL -- 注意:与 orders 表有同名列
);
-- 订单表:同样有 id 与 status 列(这是自然连接的事故源头)
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INTEGER NOT NULL,
amount NUMERIC(10, 2) NOT NULL,
status TEXT NOT NULL, -- 与 users.status 同名但含义不同!
order_no TEXT NOT NULL UNIQUE
);
INSERT INTO users (name, city, status) VALUES
('alice', 'beijing', 'active'),
('bob', 'shanghai', 'active'),
('carol', 'shenzhen', 'frozen');
INSERT INTO orders (user_id, amount, status, order_no) VALUES
(1, 607.00, 'paid', 'SO-1001'),
(1, 518.00, 'pending', 'SO-1003'),
(2, 199.00, 'paid', 'SO-1002'),
(3, 195.00, 'pending', 'SO-1004');三种写法的对照:
-- 写法一:NATURAL JOIN —— 会自动按 id 和 status 两列连接!
SELECT *
FROM users AS u
NATURAL JOIN orders AS o;
-- 等价于 ON u.id = o.id AND u.status = o.status (几乎永远不是你想要的结果)
-- 写法二:USING —— 指定按 id 连接(仍然是错的,因为 id 不是连接键)
SELECT *
FROM users AS u
JOIN orders AS o USING (id);
-- 写法三:ON —— 显式、正确
SELECT u.name, o.order_no, o.amount
FROM users AS u
JOIN orders AS o ON o.user_id = u.id; name | order_no | amount
-------+----------+--------
alice | SO-1001 | 607.00
alice | SO-1003 | 518.00
bob | SO-1002 | 199.00
carol | SO-1004 | 195.00
(4 rows)NATURAL JOIN 在这张表上返回 0 行,因为它同时按 id 与 status 连接:只有"订单 id 等于用户 id 且订单状态等于用户状态"的行才匹配,而这几乎不可能存在。
详细说明
NATURAL JOIN 的隐式匹配规则
NATURAL JOIN 的匹配规则是:取两表所有同名列,全部用等值条件连接。等价改写如下:
-- NATURAL JOIN 的等价展开(假设同名列有 id 和 status)
SELECT *
FROM users AS u
NATURAL JOIN orders AS o;
-- 完全等价的显式写法
SELECT u.id, u.name, u.city, u.status, o.user_id, o.amount, o.order_no
FROM users AS u
JOIN orders AS o ON o.id = u.id AND o.status = u.status;如果两表没有任何同名列,NATURAL JOIN 就退化为 CROSS JOIN:
-- 两张没有任何同名列的表做 NATURAL JOIN = 笛卡尔积
SELECT count(*)
FROM (SELECT 1 AS a, 2 AS b) AS t1
NATURAL JOIN (SELECT 3 AS c, 4 AS d) AS t2; count
-------
1-- 验证:与 CROSS JOIN 结果一致
SELECT count(*)
FROM (SELECT 1 AS a, 2 AS b) AS t1
CROSS JOIN (SELECT 3 AS c, 4 AS d) AS t2;为什么强烈不推荐 NATURAL JOIN
自然连接的问题不是"语法不好看",而是它在结构演进时是静默失败的。
| 风险 | 说明 | 后果 |
|---|---|---|
| 隐式契约 | 连接条件由列名决定 | 代码审查看不出连接键 |
| 结构脆弱 | 新增同名列即改变连接条件 | SQL 不改,结果变了 |
| 同名不同义 | status、type、created_at 等通用列名遍地都是 | 连接到完全无关的列 |
| 调试困难 | 报错/结果异常时难定位 | 需要 \d 逐表核对列 |
| 跨库移植 | 其他数据库行为有差异 | 迁移时行为改变 |
-- 场景演示:为表新增一个同名列,原有的 NATURAL JOIN 静默改变语义
-- 初始状态:orders 只有 user_id,两表没有同名列
-- SELECT * FROM users NATURAL JOIN orders; -- 此时是笛卡尔积(也是错的)
-- 5 个月后,需求变更,为 orders 加了 created_at 列
ALTER TABLE orders ADD COLUMN created_at DATE NOT NULL DEFAULT CURRENT_DATE;
-- 同时 users 表也加了 created_at 列用于记录注册时间
ALTER TABLE users ADD COLUMN created_at DATE NOT NULL DEFAULT CURRENT_DATE;
-- 现在 NATURAL JOIN 开始按 created_at 连接 —— SQL 一个字都没改,结果却变了这种"代码不变、结果改变"的行为是最危险的一类缺陷,因为它不会报错,只会在某天被用户发现数据不对。
USING 语法与输出列合并
USING (col) 是"显式指定连接列名"的写法,比 NATURAL JOIN 安全得多,但仍有两个特性需要留意。
特性一:输出列合并。 同名的连接列在结果中只输出一次,且列的顺序会调整到最前面。
-- USING 的输出:id 列只出现一次,且排在最前
SELECT *
FROM (SELECT 1 AS id, 'a' AS x, 'p' AS k) AS t1
JOIN (SELECT 1 AS id, 'b' AS y, 'q' AS k) AS t2 USING (id); id | x | k | y | k
----+---+---+---+---(实际输出为 id, x, k, y, k,第二列的 k 会被重命名以避免冲突)
-- 更清晰的验证:显式列出要看的列
SELECT id, t1.x, t2.y
FROM (SELECT 1 AS id, 'a' AS x) AS t1
JOIN (SELECT 1 AS id, 'b' AS y) AS t2 USING (id); id | x | y
----+---+---
1 | a | b
(1 row)特性二:合并后的列不能用限定名引用。 这是 USING 最容易踩的坑。
-- 正确:合并后的 id 只能用裸列名
SELECT id, t1.x, t2.y
FROM (SELECT 1 AS id, 'a' AS x) AS t1
JOIN (SELECT 1 AS id, 'b' AS y) AS t2 USING (id);
-- 错误!合并后的列不能加表限定
-- SELECT t1.id, t2.id FROM t1 JOIN t2 USING (id);
-- 报错:column reference "id" is ambiguous特性三:别名会改变合并行为。 如果对表做别名,USING 引用的是别名下的列:
-- 别名与 USING 配合:引用的是原始列名,不是别名
SELECT id, a.x, b.y
FROM (SELECT 1 AS id, 'a' AS x) AS a
JOIN (SELECT 1 AS id, 'b' AS y) AS b USING (id);
-- 若 USING 的列在子查询中被改了名,就找不到同名列
-- 错误!子查询里 id 被改名成 key,USING (id) 找不到
-- SELECT * FROM (SELECT 1 AS key) AS a JOIN (SELECT 1 AS id) AS b USING (id);USING 与 ON 的差异对照
| 对比项 | USING (col) | ON t1.col = t2.col |
|---|---|---|
| 连接列个数 | 只能是同名列,可以多列 | 任意表达式 |
| 非等值连接 | 不支持 | 支持(>, <, <> 等) |
| 输出列 | 合并为一列,排在最前 | 两侧列都保留 |
| 列引用 | 合并列不能加限定名 | 必须加限定名区分 |
JOIN ... USING + SELECT * | 无重复列 | 有重复列 |
NATURAL 的组合 | NATURAL 可视为自动 USING | — |
| 可读性 | 短,但隐含"同名"前提 | 长,但完全自解释 |
| 结构演进风险 | 中(指定了列名,但列名可能被改) | 无 |
-- USING 支持多列
SELECT id, a.x, b.y
FROM (SELECT 1 AS id, 2 AS seq, 'a' AS x) AS a
JOIN (SELECT 1 AS id, 2 AS seq, 'b' AS y) AS b USING (id, seq);
-- ON 可以做非等值连接,USING 不行
SELECT o1.order_no, o2.order_no
FROM orders AS o1
JOIN orders AS o2 ON o2.amount > o1.amount
ORDER BY o1.order_no, o2.order_no;JOIN ... USING 的列引用规则
USING 合并后的列在 SELECT、WHERE、ORDER BY、GROUP BY 中都可以直接用裸列名:
-- 合并列可以作为过滤条件与排序键
SELECT id, t1.x, t2.y
FROM (SELECT 1 AS id, 'a' AS x) AS t1
JOIN (SELECT 1 AS id, 'b' AS y) AS t2 USING (id)
WHERE id = 1
ORDER BY id;
-- 也可以用在 GROUP BY 中
SELECT id, count(*)
FROM (SELECT 1 AS id, 'a' AS x) AS t1
JOIN (SELECT 1 AS id, 'b' AS y) AS t2 USING (id)
GROUP BY id;但要注意:如果两表还有其他同名列,SELECT * 仍会输出两份,此时 USING 只合并了指定的那一列。
-- USING 只合并指定列;未在 USING 中指定的同名列不会合并
SELECT *
FROM (SELECT 1 AS id, 'a' AS status, 'x' AS p) AS t1
JOIN (SELECT 1 AS id, 'b' AS status, 'y' AS q) AS t2 USING (id);
-- 输出中 status 出现两次(来自两表),需要显式改名或加限定实战示例
对比 NATURAL JOIN、USING、ON 三种写法在同一查询上的表现。
-- 先看清两张表的列结构(这一步是使用 NATURAL JOIN 前必须做的)
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name IN ('users', 'orders')
AND table_schema = 'public'
ORDER BY table_name, ordinal_position; table_name | column_name | data_type
------------+-------------+-----------------------
orders | id | integer
orders | user_id | integer
orders | amount | numeric
orders | status | text
orders | order_no | text
users | id | integer
users | name | text
users | city | text
users | status | text
(9 rows)-- 写法一:NATURAL JOIN —— 按 (id, status) 连接,结果错误
SELECT u.name, o.order_no, o.amount, o.status
FROM users AS u
NATURAL JOIN orders AS o; name | order_no | amount | status
------+----------+--------+--------
(0 rows)-- 写法二:USING (id) —— 仍是错的,id 不是连接键
SELECT u.name, o.order_no, o.amount
FROM users AS u
JOIN orders AS o USING (id); name | order_no | amount
------+----------+--------
(0 rows)-- 写法三:ON 显式 —— 正确
SELECT u.name, o.order_no, o.amount, o.status AS order_status, u.status AS user_status
FROM users AS u
JOIN orders AS o ON o.user_id = u.id
ORDER BY o.order_no; name | order_no | amount | order_status | user_status
-------+----------+--------+--------------+-------------
alice | SO-1001 | 607.00 | paid | active
bob | SO-1002 | 199.00 | paid | active
alice | SO-1003 | 518.00 | pending | active
carol | SO-1004 | 195.00 | pending | frozen
(4 rows)-- 实战:为 legacy 代码审查 NATURAL JOIN 的隐患
-- 找出库中所有使用 NATURAL JOIN 的视图定义
SELECT schemaname, viewname, definition
FROM pg_views
WHERE definition ILIKE '%natural join%';-- 更安全的替代:显式 ON + 明确别名,语义自解释、不怕结构演进
CREATE VIEW v_user_orders AS
SELECT
u.id AS user_id,
u.name AS user_name,
u.status AS user_status,
o.id AS order_id,
o.order_no,
o.amount,
o.status AS order_status
FROM users AS u
JOIN orders AS o ON o.user_id = u.id;注意事项
- 不要在生产代码中使用
NATURAL JOIN。 它让连接条件隐式依赖表结构,结构变更会导致结果静默变化。
-- 不推荐
SELECT u.name, o.order_no FROM users AS u NATURAL JOIN orders AS o;
-- 推荐
SELECT u.name, o.order_no FROM users AS u JOIN orders AS o ON o.user_id = u.id;USING合并后的列不能加表限定名。 写t1.id会报歧义错误。
-- 错误!
-- SELECT t1.id FROM t1 JOIN t2 USING (id);USING只合并指定的列,其他同名列仍会出现两份。SELECT *时需注意输出中的重复列名。NATURAL JOIN与USING的输出列顺序会变化。 合并列被提到最前,依赖列位置的代码(如INSERT ... SELECT、COPY)可能出错。
-- 危险:依赖列位置的写法
-- CREATE TABLE snapshot AS SELECT * FROM users NATURAL JOIN orders; -- 列顺序不可控
-- 安全:显式列出列名与顺序
CREATE TABLE snapshot AS
SELECT u.id AS user_id, u.name, o.id AS order_id, o.order_no
FROM users AS u
JOIN orders AS o ON o.user_id = u.id;NATURAL LEFT JOIN同样不推荐。 外连接下补空行为叠加列合并,出错时更难定位。USING不能表达非等值连接。 需要>、<、<>等条件时必须用ON。列名大小写敏感。 PostgreSQL 会把未加引号的标识符折叠为小写,
USING ("ID")与USING (id)引用的是不同列。视图定义中使用
NATURAL JOIN影响面更大。 底层表加列时,所有依赖该视图的查询都会受影响,且不会有任何提示。
最佳实践
- 一律使用显式
JOIN ... ON。 这是 PostgreSQL 官方与业界一致的建议:语义清晰、结构无关、可移植。
-- 生产代码标准写法
SELECT u.name, o.order_no
FROM users AS u
JOIN orders AS o ON o.user_id = u.id;只在明确需要"列合并输出"时使用
USING。 例如SELECT *输出时不希望出现重复的连接列。即使如此,也建议显式列出列名。若必须维护使用
NATURAL JOIN的老代码,先做结构核对。 用information_schema.columns确认两表当前的同名列集合。
-- 检查两表的所有同名列(这些都会成为自然连接的连接条件)
SELECT a.column_name, a.data_type, b.data_type
FROM information_schema.columns AS a
JOIN information_schema.columns AS b
ON b.column_name = a.column_name
AND b.table_name = 'orders'
WHERE a.table_name = 'users'
AND a.table_schema = 'public'
ORDER BY a.column_name; column_name | data_type | data_type
-------------+-----------+-----------
id | integer | integer
status | text | text
(2 rows)同名列有 id 与 status —— 这正是自然连接会出错的证据。
- 为视图与存储过程加连接条件的注释或
COMMENT。 让后来者一眼看懂连接意图。
COMMENT ON VIEW v_user_orders IS '用户订单视图:按 orders.user_id = users.id 内连接,仅保留有用户信息的订单';- 重构
NATURAL JOIN时用ON逐列等价替换。 替换前先用count(*)验证改写后的结果与原查询一致(若原查询结果本身就是错的,改写会"修好"它,此时需要与业务确认预期)。
-- 改写前:记录原结果行数
SELECT count(*) FROM users NATURAL JOIN orders; -- 假设为 0
-- 改写后:确认新结果符合业务预期
SELECT count(*) FROM users AS u JOIN orders AS o ON o.user_id = u.id; -- 4- 在代码评审中把
NATURAL JOIN列为阻断项。 它几乎总是 bug 的温床,几乎没有正当使用场景。
下一节
继续学习:自连接(Self Join)