自连接(Self Join)
本节讲解自连接(Self Join)的概念与必须使用别名的原因、层级数据查询、同表不同行的比较模式、与 EXISTS 的取舍,以及何时应当迁移到递归 CTE。学完本节,你将能熟练处理组织架构、分类树、同表差值等典型自连接场景。
前置知识
阅读本节前,建议先了解:自然连接与 USING
基础概念
自连接(Self Join) 指同一张表与自身连接。它在语法上没有特殊之处,但必须为两个"实例"取不同的别名,否则 PostgreSQL 无法区分引用的是哪一份。
| 概念 | 术语 | 说明 |
|---|---|---|
| 自连接 | Self Join | 同一张表与自身连接,用别名区分两份实例 |
| 层级数据 | Hierarchical Data | 树形/层级结构,如组织架构、分类树 |
| 邻接表 | Adjacency List | 用 parent_id 指向父行的层级存储方式 |
| 根节点 | Root Node | 没有父节点的行(parent_id IS NULL) |
| 叶子节点 | Leaf Node | 没有子节点的行 |
| 递归查询 | Recursive Query | 需要多层展开时用 WITH RECURSIVE |
自连接的核心价值在于让同一张表的不同行出现在同一行输出中,从而实现"行与行的比较"。
| 场景 | 需求 | 自连接的作用 |
|---|---|---|
| 层级查询 | 找出每行的上级 | 子行连接父行 |
| 差异比较 | 找出金额高于另一个订单的订单 | 同行连接同行 |
| 重复检测 | 找出重复记录 | 同值行配对 |
| 前后记录 | 找出相邻时间点的记录 | 前连接后 |
| 层级展开 | 展开多层的树 | 无法一次完成,需递归 CTE |
语法与用法
自连接的语法要点是别名必须不同:
SELECT 列列表
FROM 表名 AS 别名A
JOIN 表名 AS 别名B ON 连接条件(别名A, 别名B)
[WHERE 过滤]
[ORDER BY 排序];准备本节贯穿使用的示例表:
-- 员工表:用 manager_id 指向直属上级(邻接表结构)
CREATE TABLE employees (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
title TEXT NOT NULL,
manager_id INTEGER, -- 指向 employees.id,顶级为 NULL
salary NUMERIC(10, 2) NOT NULL,
dept TEXT NOT NULL
);
INSERT INTO employees (name, title, manager_id, salary, dept) VALUES
('root', 'CEO', NULL, 80000.00, 'exec'), -- id=1 根节点
('alice', 'CTO', 1, 60000.00, 'tech'), -- id=2
('bob', 'CFO', 1, 60000.00, 'finance'), -- id=3
('carol', 'Tech Lead', 2, 45000.00, 'tech'), -- id=4
('dave', 'Engineer', 4, 30000.00, 'tech'), -- id=5
('eve', 'Engineer', 4, 30000.00, 'tech'), -- id=6
('frank', 'Accountant', 3, 28000.00, 'finance'); -- id=7
-- 分类表:自引用层级
CREATE TABLE categories (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER
);
INSERT INTO categories (name, parent_id) VALUES
('全部商品', NULL), -- id=1
('数码', 1), -- id=2
('家电', 1), -- id=3
('手机', 2), -- id=4
('笔记本', 2), -- id=5
('洗衣机', 3); -- id=6基本自连接:查出每个员工与其上级:
SELECT
e.name AS employee,
e.title AS emp_title,
m.name AS manager,
m.title AS mgr_title
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id
ORDER BY e.id; employee | emp_title | manager | mgr_title
----------+------------+---------+-----------
root | CEO | (null) | (null)
alice | CTO | root | CEO
bob | CFO | root | CEO
carol | Tech Lead | alice | CTO
dave | Engineer | carol | Tech Lead
eve | Engineer | carol | Tech Lead
frank | Accountant | bob | CFO
(7 rows)详细说明
为什么必须用别名
不用别名时,PostgreSQL 无法区分两份实例,会报错或产生歧义:
-- 错误!表名重复,无法区分
SELECT name FROM employees JOIN employees ON employees.id = employees.manager_id;
-- 报错:table name "employees" specified more than once
-- 正确:用别名区分两份实例
SELECT e.name, m.name
FROM employees AS e
JOIN employees AS m ON m.id = e.manager_id;即使 PostgreSQL 允许重复表名(某些数据库会隐式处理),引用列时也无法区分,因此别名是自连接的必要条件。
层级数据查询
自连接一次只能向上/向下看一层。这是它的核心限制,也是判断"是否该用递归 CTE"的关键。
-- 只查一层:每个员工的直属上级
SELECT e.name AS employee, m.name AS direct_manager
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id
ORDER BY e.id;
-- 只查两层:再连接一次得到"上级的上级"
SELECT
e.name AS employee,
m.name AS manager,
g.name AS grand_manager
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id
LEFT JOIN employees AS g ON g.id = m.manager_id
ORDER BY e.id; employee | manager | grand_manager
----------+---------+---------------
root | (null) | (null)
alice | root | (null)
bob | root | (null)
carol | alice | root
dave | carol | alice
eve | carol | alice
frank | bob | root
(7 rows)层级深浅不定时,自连接无法胜任,必须改用递归 CTE:
-- 展开任意深度:用递归 CTE(PostgreSQL 8.4+)
WITH RECURSIVE tree AS (
-- 锚点:根节点
SELECT id, name, parent_id, 1 AS depth,
name::text AS path
FROM categories
WHERE parent_id IS NULL
UNION ALL
-- 递归:连接到已展开的父节点
SELECT c.id, c.name, c.parent_id, t.depth + 1,
t.path || ' > ' || c.name
FROM categories AS c
JOIN tree AS t ON t.id = c.parent_id
)
SELECT id, name, depth, path
FROM tree
ORDER BY path; id | name | depth | path
----+------------+-------+-------------------------------
1 | 全部商品 | 1 | 全部商品
2 | 数码 | 2 | 全部商品 > 数码
4 | 手机 | 3 | 全部商品 > 数码 > 手机
5 | 笔记本 | 3 | 全部商品 > 数码 > 笔记本
3 | 家电 | 2 | 全部商品 > 家电
6 | 洗衣机 | 3 | 全部商品 > 家电 > 洗衣机
(6 rows)判断标准:如果能事先确定最大层数(如"最多 3 级组织"),用两三次自连接即可;若层数不定,一律用递归 CTE。
比较同表不同行
自连接最强大的能力是"把同一张表的两行放在一起比较"。典型模式是 a.key = b.key AND a.value > b.value。
-- 找出薪资高于同部门其他同事的员工对
SELECT
a.name AS higher_paid,
a.salary AS a_salary,
b.name AS lower_paid,
b.salary AS b_salary,
a.dept
FROM employees AS a
JOIN employees AS b
ON b.dept = a.dept
AND a.salary > b.salary
ORDER BY a.dept, a.salary DESC; higher_paid | a_salary | lower_paid | b_salary | dept
-------------+----------+------------+----------+---------
alice | 60000.00 | carol | 45000.00 | tech
alice | 60000.00 | dave | 30000.00 | tech
alice | 60000.00 | eve | 30000.00 | tech
carol | 45000.00 | dave | 30000.00 | tech
carol | 45000.00 | eve | 30000.00 | tech
bob | 60000.00 | frank | 28000.00 | finance
(6 rows)查找重复数据也是典型场景:
-- 找出同名的员工(重复检测)
SELECT a.id AS id_a, b.id AS id_b, a.name, a.dept
FROM employees AS a
JOIN employees AS b
ON b.name = a.name
AND b.id > a.id -- 用 id 大小过滤,避免自我配对与重复配对
ORDER BY a.name;-- 找出同部门同薪资的员工对
SELECT a.name, b.name, a.dept, a.salary
FROM employees AS a
JOIN employees AS b
ON b.dept = a.dept
AND b.salary = a.salary
AND b.id > a.id -- 关键:只保留一个方向,避免 A-B 与 B-A 重复
ORDER BY a.dept, a.salary DESC; name | name | dept | salary
-------+------+---------+---------
alice | carol| tech | 45000.00
dave | eve | tech | 30000.00
(2 rows)b.id > a.id 是自连接比较模式的关键技巧:它保证每对只出现一次,并排除自我配对。
自连接与 EXISTS 的取舍
"是否存在满足条件的另一行"可以用自连接或 EXISTS 表达。EXISTS 通常更快,因为它短路——找到第一行就停止。
-- 自连接:找出至少带一个下属的管理者
SELECT DISTINCT m.id, m.name
FROM employees AS e
JOIN employees AS m ON m.id = e.manager_id
ORDER BY m.id;
-- EXISTS:语义更直接,通常执行计划更优(半连接)
SELECT m.id, m.name
FROM employees AS m
WHERE EXISTS (
SELECT 1 FROM employees AS e WHERE e.manager_id = m.id
)
ORDER BY m.id; id | name
----+-------
1 | root
2 | alice
3 | bob
4 | carol
(4 rows)两种方案的对比:
| 对比项 | 自连接 | EXISTS |
|---|---|---|
是否需要 DISTINCT | 需要(否则重复) | 不需要 |
| 是否短路 | 否,需生成全部匹配行 | 是,找到即停 |
| 可否输出另一行的字段 | 可以 | 不可以(除非用子查询) |
| 执行计划 | Hash Join + Unique | Semi Join / Nested Loop Semi |
| 推荐度 | 需要另一行数据时 | 仅判断存在性时 |
-- 需要"另一行的具体数据"时,自连接或 LATERAL 是唯一选择
-- 找出每位管理者薪资最高的下属
SELECT m.name AS manager, e.name AS top_paid_sub,
e.salary AS sub_salary
FROM employees AS m
JOIN LATERAL (
SELECT name, salary
FROM employees AS e
WHERE e.manager_id = m.id
ORDER BY e.salary DESC
LIMIT 1
) AS e ON TRUE
ORDER BY m.name; manager | top_paid_sub | sub_salary
---------+--------------+------------
alice | carol | 45000.00
bob | frank | 28000.00
carol | dave | 30000.00
(3 rows)自连接的性能与索引要求
自连接的代价约为普通连接的平方级(若不加以限制),因为它连接的是同一份数据。
| 因素 | 影响 | 应对 |
|---|---|---|
| 表大小 | 结果行数与表大小平方相关(无谓词时) | 用 >、< 等谓词限制配对方向 |
| 连接列索引 | 无索引则退化为嵌套循环 + 全表扫描 | 为 manager_id、parent_id 等自引用列建索引 |
| 谓词选择性 | 条件弱则中间结果巨大 | 先用 WHERE 缩小行驶范围 |
| 排序需求 | 若需有序输出则额外 Sort | 索引可提供顺序 |
-- 自引用列(外键列)必须有索引
CREATE INDEX idx_employees_manager_id ON employees (manager_id);
CREATE INDEX idx_categories_parent_id ON categories (parent_id);
-- 复合索引:连接列 + 过滤列
CREATE INDEX idx_employees_mgr_salary ON employees (manager_id, salary DESC);-- 有索引时的计划:Nested Loop + Index Scan
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.name, m.name
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id; Nested Loop Left Join (cost=0.14..8.62 rows=7 width=64) (actual time=0.021..0.045 rows=7 loops=1)
-> Seq Scan on employees e (cost=0.00..1.07 rows=7 width=36) (actual time=0.008..0.010 rows=7 loops=1)
-> Index Scan using employees_pkey on employees m (cost=0.14..1.06 rows=1 width=32) (actual time=0.003..0.004 rows=1 loops=7)
Index Cond: (id = e.manager_id)
Planning Time: 0.256 ms
Execution Time: 0.079 ms关键读法:loops=7 表示内表被探测 7 次(等于外表行数),每次都是 Index Scan —— 这正是自连接配合索引的高效形态。
实战示例
下面完成三个典型自连接场景。
-- 场景一:组织架构 —— 员工 / 上级 / 上上级 三级视图
SELECT
e.name AS employee,
e.title,
coalesce(m.name, '-') AS manager,
coalesce(g.name, '-') AS grand_manager,
e.salary
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id
LEFT JOIN employees AS g ON g.id = m.manager_id
ORDER BY e.id; employee | title | manager | grand_manager | salary
----------+------------+---------+---------------+----------
root | CEO | - | - | 80000.00
alice | CTO | root | - | 60000.00
bob | CFO | root | - | 60000.00
carol | Tech Lead | alice | root | 45000.00
dave | Engineer | carol | alice | 30000.00
eve | Engineer | carol | alice | 30000.00
frank | Accountant | bob | root | 28000.00
(7 rows)-- 场景二:分类树 —— 只查"二级分类及其父分类"
SELECT
child.id AS child_id,
child.name AS child_name,
parent.name AS parent_name
FROM categories AS child
JOIN categories AS parent ON parent.id = child.parent_id
ORDER BY child.id; child_id | child_name | parent_name
----------+------------+-------------
2 | 数码 | 全部商品
3 | 家电 | 全部商品
4 | 手机 | 数码
5 | 笔记本 | 数码
6 | 洗衣机 | 家电
(5 rows)-- 场景三:同表差值 —— 找出薪资差距最大的同事对(仅限同部门)
SELECT
a.name AS emp_high,
b.name AS emp_low,
a.dept,
a.salary - b.salary AS salary_gap
FROM employees AS a
JOIN employees AS b
ON b.dept = a.dept
AND a.salary > b.salary
ORDER BY salary_gap DESC
LIMIT 5; emp_high | emp_low | dept | salary_gap
----------+---------+---------+------------
alice | dave | tech | 30000.00
alice | eve | tech | 30000.00
carol | dave | tech | 15000.00
carol | eve | tech | 15000.00
bob | frank | finance | 32000.00
(5 rows)-- 场景四:找出"薪资高于其直属上级"的员工(反常规检测)
SELECT
e.name AS employee,
e.salary AS emp_salary,
m.name AS manager,
m.salary AS mgr_salary
FROM employees AS e
JOIN employees AS m ON m.id = e.manager_id
WHERE e.salary > m.salary
ORDER BY e.salary DESC; employee | emp_salary | manager | mgr_salary
----------+------------+---------+------------
(0 rows)本示例数据中无此情况,说明数据符合"上级薪资更高"的组织规则。
注意事项
- 自连接必须使用不同别名。 否则报 "table name specified more than once"。
-- 错误!
-- SELECT name FROM employees JOIN employees ON employees.id = employees.manager_id;
-- 正确
SELECT e.name, m.name FROM employees AS e JOIN employees AS m ON m.id = e.manager_id;- 比较模式必须加方向谓词。 只用
=会得到自我配对(A-A)与双向重复(A-B、B-A)。
-- 错误!会包含 A-A 自我配对与 A-B / B-A 重复
SELECT a.name, b.name FROM employees AS a JOIN employees AS b ON b.dept = a.dept;
-- 正确:用 id 大小限定方向
SELECT a.name, b.name FROM employees AS a JOIN employees AS b ON b.dept = a.dept AND b.id > a.id;- 自连接无法展开不定深度的层级。 层数不确定时必须用递归 CTE,不要靠堆叠
LEFT JOIN硬撑。
-- 不推荐:靠固定层数硬撑,超过层数的数据会丢失
SELECT e.name, m1.name, m2.name, m3.name
FROM employees AS e
LEFT JOIN employees AS m1 ON m1.id = e.manager_id
LEFT JOIN employees AS m2 ON m2.id = m1.manager_id
LEFT JOIN employees AS m3 ON m3.id = m2.manager_id;LEFT JOIN自连接时,注意根节点的补空行为。 根节点manager_id IS NULL,连接后上级列为NULL,这是预期结果。自连接容易产生"平方级"中间结果。 无方向谓词的全表自连接行数为 n²,在 100 万行表上会产生万亿行。
-- 危险!全表自连接
SELECT count(*) FROM employees AS a JOIN employees AS b ON TRUE;DISTINCT会抵消自连接的部分性能优势。 若只为判断存在性,用EXISTS更好。自引用外键应加约束。
manager_id/parent_id应引用本表主键,且要防止形成环。
ALTER TABLE employees
ADD CONSTRAINT fk_emp_manager
FOREIGN KEY (manager_id) REFERENCES employees (id);最佳实践
- 为所有自引用列建索引。 这是自连接性能的第一要素。
CREATE INDEX idx_employees_manager_id ON employees (manager_id);
CREATE INDEX idx_categories_parent_id ON categories (parent_id);- 优先用
LEFT JOIN表达层级关系。 因为根节点没有上级,用INNER JOIN会把根节点丢掉。
-- 正确:根节点也要出现在结果中
SELECT e.name, coalesce(m.name, '(顶级)') AS manager
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id;- 比较模式固定使用
b.id > a.id模式。 保证每对只出现一次,并排除自我配对。
-- 自连接比较模板
SELECT a.*, b.*
FROM t AS a
JOIN t AS b
ON b.group_key = a.group_key
AND b.compare_col <op> a.compare_col
AND b.id > a.id;- 层级深度超过 2~3 层时改用递归 CTE。 代码更短,且能正确处理任意深度。
-- 递归 CTE 是层级查询的标准解法
WITH RECURSIVE tree AS (
SELECT id, name, parent_id, 1 AS depth
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, t.depth + 1
FROM categories AS c JOIN tree AS t ON t.id = c.parent_id
)
SELECT * FROM tree ORDER BY depth, id;- 用窗口函数替代部分自连接。 取"前后一条记录"时,
lag/lead通常比自连接更简洁高效。
-- 窗口函数替代自连接:取同一部门中薪资排名前后的人
SELECT
name, dept, salary,
lag(name) OVER (PARTITION BY dept ORDER BY salary) AS prev_person,
lead(name) OVER (PARTITION BY dept ORDER BY salary) AS next_person
FROM employees
ORDER BY dept, salary;- 用
EXPLAIN (ANALYZE, BUFFERS)确认内表走的是Index Scan而非Seq Scan。
ANALYZE employees, categories;
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.name, m.name
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id;- 层级数据规模增大时考虑
ltree扩展。 它能用物化路径(如tech.dept.team)配合 GiST 索引做高效的祖先/后代查询,性能远超递归 CTE。
-- PostgreSQL 9.1+ 需先安装扩展
CREATE EXTENSION IF NOT EXISTS ltree;
-- ltree 类型的列可用 <@(是后代)、@>(是祖先)操作符直接查询
-- SELECT * FROM org WHERE path <@ 'tech'::ltree;下一节
继续学习:连接查询优化