
“查个订单要联三张表写完 SQL 后数据还少了一半”——这句话是不是有点熟悉初学数据库的时候连接查询大概是整个 SQL 语法里最让人头疼的部分。后面在业务系统里写复杂报表发现要是当年把这一块学透了能少加好多班。连接查询是“数据库系统概论”课程里数据查询章节的核心内容也是从“会写单表查询”迈向“能处理真实业务数据”的第一道门槛。它解决的问题非常朴素关系模型为了消除冗余把数据拆成了多张表而业务需求又恰恰需要把这些拆开的数据重新拼回一起。这篇文章会把连接查询从原理到实战完整过一遍为什么会有连接、内连接和外连接有什么区别、多表连接怎么不踩笛卡尔积的坑以及连接查询怎么写出性能。适合正在学数据库课程的在校生也适合刚入门想做数据相关工作、写 SQL 总感觉不够利索的同学。看完这篇文章你可以直接用里面的例子练习也可以拿里面的思路去改自己项目里那些“跑得慢、结果怪”的多表查询。1. 连接查询要解决什么问题拆表存储与重新组装的矛盾1.1 关系模型为什么要把数据拆开先看一个很常见的例子。假如你要给一个小商城做数据库客户下单后订单表里如果直接存客户名字order_idcustomer_namephoneaddressproduct_nameprice这一行里如果同一个客户下了十单客户的姓名、电话、地址就要重复存十次。一旦客户改了手机号你得把十行全部更新漏掉一行数据就不一致了。而且订单和商品混在一张表里订单表的每一行会跟着商品数量膨胀想统计订单总数就得用 DISTINCT 去重非常别扭。关系数据库的解决办法就是拆表客户信息放 customers订单放 orders订单明细放 order_items商品放 products。每张表只存自己关心的属性表与表之间通过“外键”这种引用关系关联。customers 的主键 customer_id 出现在 orders 里作为外键orders 的 order_id 出现在 order_items 里这样在存储层面数据是干净的没有冗余。但代价是业务上的一次查询比如“查最近一个月的订单同时显示客户姓名和订单总金额”原始数据就被拆在两张表里了。这时候就需要一次“重新组装”把拆开的表按关联关系拼回来。这个组装动作在 SQL 里就是连接查询。1.2 连接查询的本质笛卡尔积、选择与投影从关系代数的角度理解连接其实只有三步。第一步叫笛卡尔积。把两张表的每一行两两组合得到一个超级大的结果集。表 A 有 m 行、表 B 有 n 行笛卡尔积就有 m 乘 n 行。这个结果在绝大多数情况下是没意义的比如 orders 的每一行会和 customers 的每一行都配对一次会产生大量“这个订单配上那个客户”的无效组合。第二步叫选择σ。从笛卡尔积里选出符合连接条件的行也就是外键相等的那部分比如 orders.customer_id customers.customer_id。经过这一步剩下的每行都是一个订单和它的真实客户之间的合法组合。第三步叫投影π。把业务需要的列挑出来比如订单号、下单时间、客户姓名。最终得到的结果就是一张“临时拼出来的宽表”可以像查询普通表一样继续过滤、分组、排序。我故意用关系代数的视角讲连接是因为后面很多看着奇怪的 SQL 行为比如“为什么 LEFT JOIN 之后行数变多了”“为什么我忘了写连接条件结果就爆炸”用笛卡尔积的角度理解就全部说得通。连接查询不是魔术它就是在笛卡尔积基础上做筛选和裁剪。2. 内连接的三种写法等值连接、自然连接和自连接的细节差异2.1 等值连接INNER JOIN ... ON 的完整执行语义内连接INNER JOIN是最常用的一类连接它只保留两张表中能够匹配上的行。等值连接是内连接里最典型的形式连接条件用等号判断两列值是否相等。SELECT c.customer_id, c.name, o.order_id, o.order_time, o.total_amount FROM customers c INNER JOIN orders o ON c.customer_id o.customer_id WHERE o.order_time 2024-01-01;执行语义可以理解为对于 customers 的每一行去 orders 表里找 customer_id 相等的行找到了就组合成一行输出找不到就丢弃。如果 orders 表里有多个同客户的订单最后结果里这个客户也会出现多行这符合“一个客户对应多笔订单”的业务逻辑。这里有个初学者特别容易困惑的点定义了表别名 c 和 o 之后是不是所有列都必须加别名答案是只有查询涉及的列名在两张表里有歧义时才必须加比如 customer_id 这种两边都有的列SELECT 时必须指定是 c.customer_id 还是 o.customer_id否则数据库不知道你要哪一列直接报错。我的习惯是哪怕不歧义也全部写带上别名这样 SQL 可读性好后面挪列改表也少踩坑。这也是我一般在项目里要求团队统一遵守的规范。还有一点要记住等值连接并不要求两列类型完全一致但必须“可比较”。实践中比较常见的坑是 varchar 类型的 id 和 int 类型的 id 做连接数据库会做隐式转换当然这会影响性能后面性能章节会专门说。2.2 NATURAL JOIN 和 USING偷懒写法的代价标准 SQL 还提供了两种看着很省事的写法NATURAL JOIN 和 JOIN ... USING (col)。NATURAL JOIN 会自动把两张表中所有同名列作为连接条件。比如 customers 和 orders 都有 customer_id那么写SELECT * FROM customers NATURAL JOIN orders就自动按 customer_id 相等连接。看起来非常简洁但我强烈建议在正式代码里别用。最大的问题是隐式同名列一旦因为表结构调整新增或改类型连接行为就会悄悄改变而你很难发现。另一个问题是如果两个同名列实际上语义不同比如一个叫“编号”但一个是客户号一个是订单号也会被强行连接产生全错的结果。USING 是折中方案显式指定要用的连接列。SELECT * FROM customers JOIN orders USING (customer_id);USING 的写法在连接列上会自动去重结果里只保留一个 customer_id在简单场景下很清晰。但一旦需要输出的列来自多张表USING 的隐式行为就不如 ON 那么直白了。所以在团队规范里我的倾向是生产代码统一使用 INNER JOIN ... ON 的显式写法USING 可以用于临时查询NATURAL JOIN 尽量不用。2.3 自连接一张表和自己连接解决同构数据查询自连接是内连接里脑洞比较大的用法表和自己连接。它解决的是“同一张表里行与行之间存在关系”的查询。最经典的例子是员工和经理。员工表 employees 里有一列 manager_id存的是他上级的员工编号。SELECT e.name AS employee_name, m.name AS manager_name FROM employees e JOIN employees m ON e.manager_id m.employee_id;这里的关键是你必须给同一张表起两个不同的别名 e 和 m因为数据库无法区分“员工的员工编号”和“经理的员工编号”。你可以理解为数据库把这张表复制成了两份逻辑副本再连接左半部分是员工视角右半部分是经理视角。自连接容易踩的坑是空值和层级深度。如果 CEO 的 manager_id 是 NULL它不会匹配到任何行所以 CEO 不会出现在结果里。要看完整的上下级关系可以把内连接改成 LEFT JOIN然后对经理名做空值处理。再比如要查“二级经理的姓名”自连接一次只能上探一级要查两级就得连两次表就会越连越多。这种多层级场景在关系数据库里处理起来比较绕实际项目中更推荐用递归 CTEWITH RECURSIVE来实现但理解自连接仍然是打好底子的重要一步。3. 外连接的语义边界LEFT、RIGHT、FULL 以及 ON 和 WHERE 的坑3.1 LEFT JOIN 和 RIGHT JOIN谁保留谁补 NULL内连接的缺点是只要有一张表匹配不上整行就被丢弃。但业务上经常要问“有哪些客户从没下过单”——这就需要外连接了。左外连接 LEFT JOIN 的语义左表写在 FROM 后面的表的每一行都必须出现在结果里右表只能提供“补充匹配”。对于左表里在右表找不到匹配行的记录右表所有列以 NULL 填充。右外连接 RIGHT JOIN 语义对称可以理解为以右表为主。实际项目中因为 SQL 书写习惯一般把主表放最前面所以 LEFT JOIN 远多于 RIGHT JOIN。我的建议是即使你主要使用 LEFT JOIN也把 RIGHT JOIN 的语义搞清楚这样阅读别人代码时不会一头雾水。连接类型保留行规则典型使用场景INNER JOIN仅保留两表匹配成功的行查询订单及其客户LEFT JOIN保留左表全部行右表无匹配补 NULL查询所有客户及订单包括无订单客户RIGHT JOIN保留右表全部行左表无匹配补 NULL语义上与 LEFT 对称使用较少FULL OUTER JOIN保留两表全部行数据对比、差异分析MySQL 需 UNION 模拟还是用客户和订单举例子SELECT c.name, o.order_id FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id;结果里有订单的客户会跟着显示订单号没订单的客户那行里 order_id 是 NULL。如果你在 WHERE 里加WHERE o.order_id IS NULL就能精准查到“从没下过单的客户”SELECT c.name FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id WHERE o.order_id IS NULL;这个是报表开发里非常经典的“补集查询”写法比子查询 NOT IN 在某些场景下性能更好。3.2 ON 和 WHERE 的致命区别过滤时机不同外连接里最有争议、也最让初学者懵的就是 ON 条件和 WHERE 条件的区别。我用一个实际例子说明。假设你想查“每个客户的姓名以及他名下已支付订单的订单号”。你可能会写出下面两种 SQL写法一SELECT c.name, o.order_id FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id AND o.status PAID;写法二SELECT c.name, o.order_id FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id WHERE o.status PAID;两个结果完全不同。写法一的含义连接时只把 status PAID 的订单连接过来其他订单视为“没有匹配”因此没有已支付订单的客户依然会出现只是 order_id 为 NULL。写法二的含义先把所有客户的订单都连接过来再用 WHERE 过滤掉非 PAID 订单过滤之后再输出没下过单和只有非支付订单的客户就全部消失了。这个 SQL 实际上等价于一个内连接加一个条件。理解了“ON 决定连接时匹配什么WHERE 决定连接后的结果中保留什么”之后很多外连接查询的逻辑就顺了。这条规则也直接解释了实际操作中“为什么 LEFT JOIN 一加 WHERE 条件数据反而变少了”的经典灵异事件。3.3 没有 FULL JOIN 的数据库怎么实现全外连接FULL OUTER JOIN全外连接同时保留左表和右表的全部行匹配不上的补 NULL。但 MySQL 并不原生支持 FULL JOINOracle 等部分数据库支持。在 MySQL 里我们一般通过 UNION 把 LEFT JOIN 和 RIGHT JOIN 合并来实现SELECT c.name, o.order_id FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id UNION SELECT c.name, o.order_id FROM customers c RIGHT JOIN orders o ON c.customer_id o.customer_id;UNION 会自动去重。如果业务上允许重复行可以改成 UNION ALL性能也更好。这个写法虽然不复杂但实际业务里用全外连接的机会并不算多因为它往往意味着表数据质量有问题或关联关系没理清。遇到要全外连接的场景建议先回头想想数据模型是不是该调整。4. 三表以上连接执行顺序、结果膨胀与笛卡尔积陷阱4.1 多表连接的 SQL 组织策略从订单到商品明细真实业务很少只连两张表。比如一个电商后台要查“某个客户在某个时间段内买过哪些商品每个商品买了几个”需要从 customers 走到 orders再通过 order_items 关联到 products一共四张表。SELECT c.name AS customer_name, o.order_id, p.product_name, oi.quantity, oi.price AS item_price FROM customers c JOIN orders o ON c.customer_id o.customer_id JOIN order_items oi ON o.order_id oi.order_id JOIN products p ON oi.product_id p.product_id WHERE o.order_time BETWEEN 2024-01-01 AND 2024-01-31;写多表连接时我习惯按“主表 → 一级关联表 → 二级关联表”的顺序组织 SQL每增加一张表就补一个 ON 条件。这样逻辑直白别人读起来也容易理解。4.2 连接顺序到底谁说了算优化器与 EXPLAIN 的关系很多同学以为 SQL 里表的书写顺序就是执行顺序其实不是这样。对于 INNER JOIN优化器会重新排列表的连接顺序目标是尽量减小中间结果集。比如 customers 只有 100 行orders 有 100 万行如果先连接一个过滤得很窄的中间结果再和第三张表连接性能就会好很多。想看真正的执行顺序用 EXPLAINEXPLAIN SELECT ...在 MySQL 中EXPLAIN 输出里会有一列 id、select_type、table、type、possible_keys、key、rows 等字段。多表连接时table 列的上下顺序大致反映了执行计划中表的读取顺序一般从上往下是驱动表到被驱动表。type 列也很关键eq_ref 代表每次最多匹配一行通常出现在按主键连接时ref 代表普通索引连接ALL 代表全表扫描通常意味着该表被反复扫描性能堪忧。真实项目里不要凭感觉调整 SQL 表顺序来优化。第一件事永远是 EXPLAIN看优化器选了什么计划再看看有没有因为统计信息不准导致选错驱动表的情况。必要时可以用 STRAIGHT_JOIN 提示强制连接顺序但这是最后的招数一般不需要。4.3 一对多连接导致的结果膨胀一查订单明细订单数就多了多表连接最容易踩的坑是结果集膨胀。举个例子。订单表和订单明细表是一对多关系一个订单对应多个商品明细。如果你用订单表 JOIN 明细表订单表里原来一行的数据会因为匹配到多个明细而复制成多行最后用 COUNT(*) 统计订单数就会比真实订单数大。SELECT o.order_id, COUNT(*) AS row_count FROM orders o JOIN order_items oi ON o.order_id oi.order_id GROUP BY o.order_id;这个结果中的 row_count 其实等于该订单的明细行数而不是订单数量。如果要做“每个订单的平均金额”这类统计直接对连接结果 AVG 也会被重复加权算错。正确的做法一般是要么在连接前先分组聚合把明细表先按订单算出小计和数量要么使用窗口函数做去重计数。这在“数据库系统概论”里是需要格外强调的一类问题因为它在业务报表中非常常见而且结果看起来“很合理”只有对账时才发现数据虚高。4.4 漏写连接条件的后果灾难性的笛卡尔积多表连接另一个经典事故N 张表连接却没写全连接条件。比如三张表你只写了一个 ON 条件那么剩下两张表之间会做笛卡尔积结果集可能瞬间膨胀到几十万甚至几百万行直接把数据库拖垮。检查技巧其实很简单对最常见的链式连接来说N 张表通常需要 N-1 个连接条件如果 ON 数量明显少于 N-1基本就是笛卡尔积。当然也有更复杂的连接结构比如一张事实表和两张维表做星形连接这时候也有 N-1 个连接条件只是拓扑不是链式而是星形。初学阶段用 N-1 作为快速检查标尺非常实用。万一真的跑了笛卡尔积也不用心慌先看 EXPLAIN 里的 rows 估算是不是大得离谱再排查每个 ON 是否漏写、是否写错列名导致隐式行为最后用 LIMIT 查看部分结果的前几行做验证。我自己曾经写过一次忘记加连接条件的查询直接把测试库的临时表空间撑爆了从那以后写多表连接前都会默念这句口诀“表多一个条件多一条”。5. 连接查询的性能优化索引、驱动表和 SQL 改写思路5.1 连接列为什么必须建索引连接查询的性能很大程度取决于连接列上有没有索引。原理并不复杂连接本质上是“用左表的每一行去右表找匹配行”。如果右表的连接列没有索引数据库只能把右表整个扫描一遍看哪些行能匹配。左表 100 行、右表 100 万行就要对右表做 100 次全表扫描——这显然不可接受。而如果右表连接列上有索引每次查找可以走索引成本大大降低。所以外键列上建索引是数据库设计阶段就应该养成的习惯。很多建模工具建表时并不会自动给外键加索引需要手动补CREATE INDEX idx_orders_customer_id ON orders(customer_id); CREATE INDEX idx_order_items_order_id ON order_items(order_id); CREATE INDEX idx_order_items_product_id ON order_items(product_id);在 OLTP 系统里连接列索引通常对查询性能的改善是最直接的。之前在某项目里一个三表连接查询从 8 秒优化到 0.2 秒唯一改动就是加了两个外键索引没有改任何 SQL。5.2 小表驱动大表驱动表与连接顺序优化连接查询里有一个流传很广的经验原则小表驱动大表。对内连接来说优化器会尽量把小表作为驱动表因为驱动表的每一行都要去探测被驱动表。如果驱动表大探测次数就多。当然现代优化器在很多情况下会自动选择合理的顺序但如果统计信息不准或者用了 LEFT JOIN驱动表通常固定为左表就会出现“小表在右、大表在左”的低效计划。遇到 LEFT JOIN 性能问题时先看左表是否已经通过 WHERE 过滤到足够小。如果没有考虑把 SQL 改写优先让驱动表尽早过滤比如左连接的左表先通过 WHERE 条件缩小范围。虽然从逻辑顺序上看是先连接后过滤但优化器的谓词下推机制通常会把过滤条件下推到最靠近基表的地方执行所以不用担心把 WHERE 写在 JOIN 后面会影响语义。还要注意一个点LEFT JOIN 下过度依赖“小表驱动大表”并不能通用因为左连接的语义是保留左表全部行驱动表被语义固定了。这时候优化空间更多在右表的连接列索引和聚合时机的优化上。5.3 JOIN 与子查询、EXISTS 的取舍实际开发中经常遇到一个问题一个查询能用 JOIN 写也能用子查询或者 EXISTS 写到底选哪个没有绝对的答案要看数据量和执行计划。我的经验是在结果集较小、只需判断“是否存在”时EXISTS 通常性能不错因为它在找到第一条匹配后就可以提前终止。语义上需要把两张表的列拼在一起输出时JOIN 更自然。子查询在某些优化器里会被改写成 JOIN也可能物化成临时表再和外层表连接最终用的是同一套连接算法。所以与其纠结“哪个写法一定快”不如学会用 EXPLAIN 看优化器最终怎么执行。我在带新人时经常见他们反复改写 SQL 想“优化”但从不 EXPLAIN结果在一个已经被优化器处理得很好的查询上浪费时间。5.4 实际项目中踩过的连接性能坑一张问题自查表最后分享几个我在实际项目中碰到过的连接性能问题大家可以对照自查。问题现象常见根因应对思路连接列索引失效两表字符集不一致导致隐式转换统一字符集或调整排序规则连接列上做函数运算ON date_col 1 other_col把运算移到无关列一侧SELECT * 导致 IO 过高拉取大量不需要的宽列只 select 需要的列说法中“N-1 个条件”失灵连接条件写错列名逐一核对 ON 字段与外键关系结果行数膨胀一对多连接后直接聚合先聚合再去连接或用窗口函数第一个坑最隐蔽两张表的连接列都是 varchar但一张是 utf8mb4一张是 latin1连接时会发生隐式字符集转换索引直接失效。这类问题不 EXPLAIN 很难发现因为 EXPLAIN 里 key 显示为 NULL。第二个坑是很多同学容易犯的“你以为写了个条件其实让索引失效了”。以上几个问题我在真实项目里都遇到过每次排查的耗时基本都花在“怀疑这个、怀疑那个”最后才发现根因很基础。如果你正在学数据库课程或者刚转来做数据开发我的建议是先别急着背各种连接的语法找一个小项目比如订单管理系统亲手把客户、订单、订单明细、商品四张表建出来然后写上面提到的每一条连接查询再用 EXPLAIN 观察执行计划变化。连接查询这一关纸上谈兵永远不如踩一次坑来得深刻。