SQL JOIN 命令速查表 - SQL 连接查询常用命令大全
面向要关联查询、却常把结果行数统计错的开发者。最常见的坑是:连接一对一多(一对多)的表时,父表的每一行会被子表的匹配行放大一次,结果行数虚增,除非去重。读完能按"未匹配的行是否要保留"决定用 INNER 还是 LEFT,理解一对多连接的行重复陷阱,用 LEFT JOIN ... WHERE ... IS NULL 表达"没有匹配"的场景,并在相关子查询里用 EXISTS 替代 JOIN。
典型使用场景
面向每天写报表、排查数据对不上、给已有查询提速的工程师。JOIN 的难点从来不是语法,而是两件事:语义上,LEFT JOIN 后再在 WHERE 里过滤右表条件会把外连接降级成内连接;性能上,多表连接时驱动表选错会导致行数指数级放大。本表覆盖 INNER / LEFT / RIGHT / FULL / CROSS JOIN 的实际写法与等价形式、自连接与多层连接、JOIN 与聚合(COUNT、SUM、AVG、GROUP BY、HAVING)、JOIN 性能优化(EXPLAIN、索引、STRAIGHT_JOIN、FORCE INDEX)、USING 与 NATURAL JOIN 的陷阱,以及子查询与 EXISTS。适合在 review 一条新报表 SQL 时逐条对照「这个 JOIN 语义对不对、有没有索引」。
INNER JOIN 5
SELECT * FROM users INNER JOIN orders ON users.id = orders.user_idSELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_idSELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id AND o.status = 'paid'SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id WHERE o.total > 100SELECT u.name, o.total, p.amount FROM users u JOIN orders o ON u.id = o.user_id JOIN payments p ON o.id = p.order_idLEFT / RIGHT JOIN 6
SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_idSELECT * FROM users RIGHT JOIN orders ON users.id = orders.user_idSELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.id IS NULLSELECT * FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'SELECT * FROM orders o RIGHT JOIN users u ON u.id = o.user_id WHERE o.id IS NULLSELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.idFULL / CROSS JOIN 5
SELECT * FROM users FULL OUTER JOIN orders ON users.id = orders.user_idSELECT * FROM users u FULL JOIN orders o ON u.id = o.user_id WHERE u.id IS NULL OR o.user_id IS NULLSELECT * FROM table1 CROSS JOIN table2SELECT * FROM table1, table2SELECT * FROM sizes CROSS JOIN colors自连接与多表 5
SELECT e.name, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.idSELECT a.name, b.name AS parent FROM categories a JOIN categories b ON a.parent_id = b.idSELECT * FROM a JOIN b ON a.id = b.a_id JOIN c ON b.id = c.b_idSELECT t1.name AS grandchild, t3.name AS grandparent FROM tree t1 JOIN tree t2 ON t1.parent_id = t2.id JOIN tree t3 ON t2.parent_id = t3.idSELECT u.name, o.total, p.amount FROM users u JOIN orders o ON u.id = o.user_id JOIN payments p ON o.id = p.order_idJOIN 与聚合 5
SELECT u.name, COUNT(o.id) AS order_count FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.idSELECT u.name, SUM(o.total) AS total_spent FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.id HAVING SUM(o.total) > 1000SELECT c.name, AVG(o.total) AS avg_order FROM customers c JOIN orders o ON c.id = o.customer_id GROUP BY c.idSELECT u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id HAVING COUNT(o.id) = 0SELECT DATE(o.created_at) AS d, COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY DATE(o.created_at)JOIN 性能优化 6
EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_idEXPLAIN ANALYZE SELECT * FROM users u JOIN orders o ON u.id = o.user_idCREATE INDEX idx_orders_user_id ON orders(user_id)SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_idSELECT * FROM users u STRAIGHT_JOIN orders o ON u.id = o.user_idSELECT * FROM users u JOIN orders o FORCE INDEX(idx_user_id) ON u.id = o.user_idUSING 与自然连接 4
SELECT * FROM users JOIN orders USING (user_id)SELECT * FROM users u JOIN orders o USING (user_id, tenant_id)SELECT * FROM users NATURAL JOIN ordersSELECT * FROM users u JOIN orders o ON u.id = o.user_id AND u.tenant_id = o.tenant_id子查询与 EXISTS 5
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)SELECT * FROM orders o WHERE o.user_id IN (SELECT id FROM users WHERE status = 'active')SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt FROM users uSELECT * FROM orders WHERE user_id = (SELECT MAX(user_id) FROM orders)参数矩阵
| 参数 | 作用 | 示例 |
|---|---|---|
INNER JOIN ... ON | 只保留两侧都匹配的行;不匹配的行直接消失 | SELECT * FROM users INNER JOIN orders ON users.id = orders.user_id |
JOIN u ON ...(表别名) | JOIN 是 INNER JOIN 的缩写;写别名让多表连接时的列引用不易混淆 | SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id |
LEFT JOIN ... ON | 保留左表全部行,右表无匹配时补 NULL,是「左表为主」的默认选择 | SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id |
RIGHT JOIN ... ON | 保留右表全部行;实际工程里通常改写成 LEFT JOIN 更易读 | SELECT * FROM users RIGHT JOIN orders ON users.id = orders.user_id |
... WHERE o.id IS NULL | 反连接写法:找出左表中没有右表记录的实体(无订单用户、未分类商品) | SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.id IS NULL |
LEFT JOIN ... ON u.id = o.user_id AND o.status = 'paid' | 把状态条件放进 ON 里,才不会把 LEFT JOIN 降级成 INNER JOIN | SELECT u.name, o.total FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid' |
FULL OUTER JOIN | 两侧都保留;MySQL 不支持,需要用 LEFT JOIN 与 RIGHT JOIN 加 UNION 模拟 | SELECT * FROM users FULL OUTER JOIN orders ON users.id = orders.user_id |
CROSS JOIN / 逗号连接 | 笛卡尔积,行数是两表行数相乘;只在「所有组合都合法」的小表上用 | SELECT * FROM sizes CROSS JOIN colors |
自连接 e LEFT JOIN employees m | 同一张表连接自身,是层级关系(员工-经理、分类-父分类)的标准写法 | SELECT e.name, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.id |
多表链式 JOIN | 三张以上表要逐段写清 ON 条件,漏一段就会产生笛卡尔积 | SELECT u.name, o.total, p.amount FROM users u JOIN orders o ON u.id = o.user_id JOIN payments p ON o.id = p.order_id |
COUNT(o.id) AS order_count | 统计时数的是具体列而不是 *,这样无匹配的 LEFT JOIN 行才会正确计为 0 | SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id |
GROUP BY ... HAVING SUM(o.total) > 1000 | HAVING 过滤聚合结果,WHERE 过滤聚合前的行;两者顺序不能互换 | SELECT u.name, SUM(o.total) AS total_spent FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.id HAVING SUM(o.total) > 1000 |
SELECT DATE(o.created_at) AS d, COUNT(*) | 按函数结果分组做时间序列统计;注意部分数据库要求函数与列同组 | SELECT DATE(o.created_at) AS d, COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY DATE(o.created_at) |
EXPLAIN SELECT ... | 查看执行计划里的驱动表、连接类型与索引命中情况,是排 JOIN 慢查询的第一步 | EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id |
EXPLAIN ANALYZE SELECT ... | 真正执行并给出每步实际行数与耗时,能暴露「估算行数与实际差几个数量级」的统计信息问题 | EXPLAIN ANALYZE SELECT * FROM users u JOIN orders o ON u.id = o.user_id |
CREATE INDEX idx_orders_user_id ON orders(user_id) | 为连接键建索引;没有索引的连接在大表上必然全表扫描 | CREATE INDEX idx_orders_user_id ON orders(user_id) |
STRAIGHT_JOIN | 强制按 FROM 的书写顺序连接,作为优化器选错驱动表时的临时手段 | SELECT * FROM users u STRAIGHT_JOIN orders o ON u.id = o.user_id |
FORCE INDEX (idx_user_id) | 强制指定某表用哪个索引,比 STRAIGHT_JOIN 更精确,但会把索引固化在查询里 | SELECT * FROM users u JOIN orders o FORCE INDEX(idx_user_id) ON u.id = o.user_id |
JOIN ... USING (user_id) | 按同名列连接,且该列在结果里只出现一次;适合规范一致的宽表 | SELECT * FROM users JOIN orders USING (user_id) |
NATURAL JOIN | 隐式按所有同名列连接;表一改列名语义就静默改变,极不推荐 | SELECT * FROM users NATURAL JOIN orders |
ON u.id = o.user_id AND u.tenant_id = o.tenant_id | 多租户场景必须把租户列写进 ON,否则会跨租户串数据 | SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id AND u.tenant_id = o.tenant_id |
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) | 相关子查询的存在性判断,找到即停;比 IN 更能正确处理含 NULL 的列 | SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) |
WHERE NOT EXISTS (...) | 反连接的存在性判断,通常比 LEFT JOIN + IS NULL 执行计划更干净 | SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) |
user_id IN (SELECT id FROM users WHERE status = 'active') | 半连接写法;子查询结果含 NULL 时 NOT IN 会整体返回空集 | SELECT * FROM orders o WHERE o.user_id IN (SELECT id FROM users WHERE status = 'active') |
SELECT (SELECT COUNT(*) ...) AS cnt | 标量子查询作投影,对每一行执行一次;行数多时明显变慢,应改用 JOIN + GROUP BY | SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt FROM users u |
WHERE user_id = (SELECT MAX(user_id) FROM orders) | 取极值行的经典写法,比排序取第一行更直观 | SELECT * FROM orders WHERE user_id = (SELECT MAX(user_id) FROM orders) |
易错点与避坑指南
现象本该显示「无订单的用户」,结果这些用户整行都没了。
原因写的是 LEFT JOIN 却把右表条件放在了 WHERE:WHERE orders.status = 'paid' 在补 NULL 后不成立,等于把 LEFT JOIN 降级成 INNER JOIN。
处置把右表条件移进 ON 子句里;若确实是只要已支付订单,就明确改用 INNER JOIN。
现象统计订单数时「没有订单的用户」被算成了 1。
原因聚合用了 COUNT(*) 或 COUNT(o.*),无匹配时补出来的一行 NULL 也被计入。
处置改成 COUNT(o.id) 这类指定可空列的写法,无匹配时 COUNT 忽略 NULL 并返回 0。
现象行数暴涨,多表连接后结果比预期大几个数量级。
原因三表链式连接时某一段 ON 条件写漏或写错,导致中间产生笛卡尔积。
处置逐段验证:先只 join 两张表看行数,再加第三张;EXPLAIN 里确认每段的驱动表与 rows 估算。
现象JOIN 突然变慢,数据量没涨但耗时翻倍。
原因连接键缺索引,或统计信息陈旧导致优化器选错驱动表。
处置EXPLAIN 看是否全表扫描,给连接键建索引;必要时用 EXPLAIN ANALYZE 对比估算与实际行数。
现象同一份 SQL 在不同数据库(MySQL / PostgreSQL)行为不一致。
原因FULL OUTER JOIN 与 USING 支持程度不同,字符串拼接与 GROUP BY 规则(是否要求所有非聚合列都在组内)也不一致。
处置按目标数据库写等价实现;跨库报表尽量收敛到 JOIN + 显式列名 + 严格 GROUP BY。
现象NATURAL JOIN 后结果少了一列,语义突然变了。
原因NATURAL JOIN 隐式按所有同名列连接,任一侧新增同名列都会静默改变连接条件。
处置改用显式 JOIN ... ON,把连接列写死,避免 schema 变更带来隐性 bug。
现象多租户库里查出了别的租户的数据。
原因连接条件只写了主键相等,忘了 tenant_id;或者过滤租户的 WHERE 只加在了一侧。
处置把 tenant_id 写进 ON 子句,并在两侧都用 tenant_id 过滤,必要时对租户列建复合索引。
现象WHERE user_id NOT IN (SELECT ...) 返回空结果。
原因子查询结果里含 NULL,NOT IN 遇到 NULL 会整体判定为未知,从而一行都不返回。
处置改用 NOT EXISTS,或先在子查询里过滤掉 NULL。
排障路径
1看执行计划与连接类型
EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id重点看每个表的 type、key 与 rows。
2对比估算与实际行数
EXPLAIN ANALYZE SELECT * FROM users u JOIN orders o ON u.id = o.user_id两者差几个数量级说明统计信息需要更新。
3确认连接键是否命中索引
grep -n "CREATE INDEX" schema.sql索引缺失是最常见的 JOIN 性能根因。
4验证多租户过滤是否成对出现
grep -n "tenant_id" queries.sql每个涉及多租户表的查询都应两侧过滤。
5检查是否误用 NATURAL JOIN
grep -n "NATURAL JOIN" queries.sql生产查询里出现它基本都需要改成显式 ON。
6排查是否把右表条件写进了 WHERE
grep -n "LEFT JOIN" -A 3 queries.sql紧随其后的 WHERE 若引用右表列,就是降级成内连接的信号。
命令示例
反连接:找出一个订单都没有的用户
SELECT u.name, o.total FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'
WHERE o.id IS NULL条件写在 ON 而不是 WHERE,是这条查询成立的关键:LEFT JOIN 的 ON 条件只影响右表能否匹配上,不会把左表的行过滤掉。所以零订单用户仍然留在结果里,再靠 WHERE o.id IS NULL 挑出来。如果把 o.status 写进 WHERE,所有未支付的用户都会被误判成「无订单」。
零订单用户:LEFT JOIN + 聚合 + HAVING
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id
HAVING COUNT(o.id) = 0这里必须用 COUNT(o.id) 而不是 COUNT(*):无订单时 o.id 全是 NULL,COUNT(o.id) 得 0,COUNT(*) 因为左表那行仍存在会得 1,判断永远不成立。区分「有没有」和「有多少」时,永远数右表的非空列。
自连接把扁平表还原成层级
SELECT t1.name AS grandchild, t3.name AS grandparent
FROM tree t1
JOIN tree t2 ON t1.parent_id = t2.id
JOIN tree t3 ON t2.parent_id = t3.id同一张 tree 表用三个别名连续自连接三次,就能沿 parent_id 上溯两代。结果里出现的 grandparent 为 NULL 并不代表写错,而是根节点的父节点本来就不存在——需要时补一个 LEFT JOIN 并对 t3.name 做 IS NULL 判断来区分根节点和断链数据。
建外键索引前后对比执行计划
EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id
CREATE INDEX idx_orders_user_id ON orders(user_id)
EXPLAIN ANALYZE SELECT * FROM users u JOIN orders o ON u.id = o.user_id第一次 EXPLAIN 如果看到 Nested Loop 加上很长的 rows 估算,说明 orders.user_id 没走索引,数据库要对 users 的每一行去全表扫一次 orders——orders 一大就是灾难。建索引后再 EXPLAIN ANALYZE 拿到真实耗时,确认扫描行数确实降下来了再收工。
输出
Nested Loop (cost=0.00..45231.44 rows=1500)
用 EXISTS 代替 IN,避免大子查询反复求值
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)
SELECT * FROM orders o WHERE o.user_id IN (SELECT id FROM users WHERE status = 'active')两者的语义差别很关键:NOT EXISTS / EXISTS 遇到第一条匹配行就短路,IN 通常需要把整个子查询结果物化成集合。用户表几十万行时,IN 子查询的中间结果可能比主查询还大,改成 EXISTS 后执行计划通常会从物化(Materialize)变成半连接(Semi Join)。
常见坑与注意事项
- JOIN 漏写 ON(或写成 ON 1=1、ON a.id=b.id 里手误写成同表同列)会产生笛卡尔积:两张各 5 万行的表会返回 25 亿行,磁盘写满、连接池被打满,线上常常表现为「接口变慢」而不是「SQL 报错」,极难定位。写完 JOIN 先数一下 ON 里的条件数是否等于前 N-1 张表的连接键数。
- 内连接时 NULL 永远不参与比较:ON a.user_id = o.user_id 遇到 NULL 会被判为不匹配,不是匹配。任何带 NULL 的关联列(未填的外部 ID、历史遗留字段)都会静默丢行。关联列可能为 NULL 时必须用 NULLIF 包一层或改用 IS NOT DISTINCT FROM 语义,并在条件里补 o.user_id IS NOT NULL。
- SELECT DISTINCT 只是把重复行压掉,不解决重复的来源。用 LEFT JOIN 接明细表(orders、items)时行数本来就会膨胀,加 DISTINCT 会把金额、数量一起丢掉——三个订单总额 100 的用户去重后只剩一行 100。正确做法是用 GROUP BY 按主键聚合,而不是加 DISTINCT 掩盖。
- 把右表过滤条件写进 LEFT JOIN 的 WHERE 会让它退化成 INNER JOIN:本该保留的无订单用户被 WHERE o.total > 100 全部滤掉,最后 LEFT JOIN 白写。要保留左表全部行,右表条件必须放在 ON 里。
- SELECT * 出现在 JOIN 里时,两表同名列(比如都叫 id、created_at)会在结果集里出现两遍,业务代码按下标取值很容易取到另一张表的字段;更糟的是加了 count(o.id) 后语义仍然正确,导致这个 bug 长期潜伏。写查询时只 SELECT 用到的列,并显式给列加别名。
- 多表 JOIN 忘了建索引会触发全表扫描,EXPLAIN 里的 rows 估算能提前暴露。生产上给 users JOIN orders ON u.id = o.user_id 加索引前,先确认 orders.user_id 上是否已有可用索引,重复建索引只会拖慢写入。
提示
- LEFT JOIN 的 WHERE 条件如果放在 ON 里,只影响右表匹配;放在 WHERE 里会过滤整个结果集。
- CROSS JOIN 会产生笛卡尔积,数据量大时慎用。
- 用表别名(u、o)可以让多表查询更简洁易读。
- EXISTS 子查询在某些场景下比 JOIN 性能更好,特别是只需要判断存在性时。
- JOIN 的连接列建议建索引,否则会触发全表扫描导致性能下降。
- USING(a, b) 比 ON x.a = y.a AND x.b = y.b 更简洁,且结果集会合并同名列。
常见问题
INNER JOIN 和 LEFT JOIN 到底怎么选?判断标准是什么?
只看你是否需要保留「右表没有匹配」的那批左表行。要两边都有对应记录才输出用 INNER:SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id(JOIN 默认就是 INNER)。要保留所有用户、没订单的人也要看到,就换 LEFT JOIN,未匹配时右表字段为 NULL。最容易踩的坑是把过滤条件写错位置:写成 LEFT JOIN ... WHERE o.status = 'paid' 会把没订单的用户过滤掉,因为 NULL 比较不为真;正确做法是把状态条件写进 ON:ON u.id = o.user_id AND o.status = 'paid',这样只影响匹配而不减少左表行数。
JOIN 之后行数比预期多了,是什么导致的?
几乎都是「一对多」被放大。users JOIN orders 时,一个用户有 3 笔订单就产生 3 行;再 JOIN payments,一个订单有多条支付记录,行数继续翻倍。改法有几种:只 SELECT 真正需要的列而不是 SELECT *,用表别名和聚合把每行收敛成一行:SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id;或者在 ON 上加聚合前先限定范围;只看存在性时根本不用 JOIN,改成 WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id),某些场景下性能反而更好。
怎么查出「没有订单的用户」这类反连接场景?
LEFT JOIN 加 IS NULL 判定:SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.id IS NULL。匹配上的行右表 id 非空被 WHERE 排除,剩下的正是完全没有订单的用户。用 HAVING 也能做等价的事:SELECT u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id HAVING COUNT(o.id) = 0;或者完全不用 JOIN,SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)。要查两表各自独有的记录,则用 FULL OUTER JOIN 加 WHERE u.id IS NULL OR o.user_id IS NULL 取对称差。
多表关联时 USING、NATURAL JOIN 和 ON 怎么选?
ON 最通用也最清晰,表名不同名时必须用它:ON u.id = o.user_id AND u.tenant_id = o.tenant_id。两表关联列同名时用 USING 更短:SELECT * FROM users JOIN orders USING (user_id),多列可以写 USING (user_id, tenant_id),而且结果集里同名列会合并成一个字段,省掉 u. 和 o. 的区分。NATURAL JOIN 会自动按所有同名列匹配,列一改就静默出错,表中明确标注慎用,只在临时查询里用。
JOIN 查询突然变慢,先查什么?
先看执行计划:EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id 确认连接方式,EXPLAIN ANALYZE(PostgreSQL / MySQL 8.0+)能实际执行并显示耗时。最常见的原因是连接列没索引,用 CREATE INDEX idx_orders_user_id ON orders(user_id) 补上,避免全表扫描。其次把 SELECT * 换成只查需要的列,减少回表。MySQL 下优化器选错驱动表时,可以用 STRAIGHT_JOIN 强制按写的顺序连接,或 FORCE INDEX(idx_user_id) 指定索引;注意 COUNT(*)、SUM、SUM 这类聚合后要过滤结果,走的是 HAVING 而不是 WHERE。
由 巧匠 维护
公开更新于 2026年7月21日,内容持续校对官方文档。