返回首页

07|多表查询与 JOIN 详解

适用版本:MySQL 8.0.x

当业务系统从“只有一张表”发展到“数据按主题拆分存储”后,多表查询就成了日常开发里的核心能力。

例如:

  • 订单表里只有 customer_id,要关联客户表才能看到客户名称
  • 订单明细表里只有 product_id,要关联商品表才能拿到商品信息
  • 员工表里只有 department_id,要关联部门表才能展示部门名称

这背后的本质,是关系型数据库最重要的特点之一:数据分表存储,通过关联关系还原业务语义

也正因为如此,JOIN 是 MySQL 查询里非常重要、也非常容易出错的一部分。很多“结果为什么重复了”“为什么 LEFT JOIN 后数据反而少了”“为什么一加关联就变慢了”的问题,根源都在 JOIN 没有真正理解清楚。

这篇文章就围绕多表查询展开,重点讲清楚:

  • 为什么要分表与关联
  • 各种 JOIN 的含义和差别
  • ONWHERE 的不同职责
  • 一对多、多对多场景下为什么会出现数据膨胀
  • 实际开发中如何把 JOIN 写得更稳、更清晰

一、为什么关系型数据库离不开多表查询

在关系型数据库里,通常不会把所有信息都塞进一张大表。这样做虽然看起来“一查就全有”,但很快会遇到这些问题:

  • 字段重复严重,冗余高
  • 更新某个维度信息时,可能要改很多行
  • 某些字段可为空,表会越来越宽、越来越难维护
  • 数据一致性很难保证

因此,更常见的设计方式是:

  • 客户信息放在客户表
  • 订单主信息放在订单表
  • 订单商品明细放在订单明细表
  • 商品基础信息放在商品表

这样虽然需要关联查询,但整体模型更清晰、可维护性更高。

你可以把 JOIN 理解为:

把存放在不同表中的业务信息,按照关联键重新拼接成你真正需要的结果集。


二、示例表结构

后文我们使用一个典型的电商简化模型:

CREATE TABLE customers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_name VARCHAR(100) NOT NULL,
    city VARCHAR(50) NOT NULL,
    level VARCHAR(20) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at DATETIME NOT NULL
);

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(50) NOT NULL,
    customer_id BIGINT NOT NULL,
    order_status VARCHAR(20) NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    created_at DATETIME NOT NULL
);

CREATE TABLE order_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    sale_price DECIMAL(10,2) NOT NULL
);

CREATE TABLE products (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_name VARCHAR(100) NOT NULL,
    category VARCHAR(50) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL
);

它们之间的典型关系如下:

关键字段 说明
customers id 客户主表
orders customer_id 一个客户可有多个订单
order_items order_id 一个订单可有多个明细
order_items product_id 一条明细对应一个商品
products id 商品主表

这个模型非常适合演示多表 JOIN 的主要场景。


三、JOIN 到底在做什么

先看一个最基础的需求:查询订单及其所属客户名称。

SELECT o.id,
       o.order_no,
       o.total_amount,
       c.customer_name
FROM orders AS o
JOIN customers AS c
  ON o.customer_id = c.id;

这条 SQL 的含义可以拆成三步:

  1. orders 中取出订单数据
  2. customers 中取出客户数据
  3. o.customer_id = c.id 这个关联条件进行匹配

如果能匹配上,就把两边的列拼在一行里返回。

所以 JOIN 的本质不是“把两张表简单并排放”,而是:

根据连接条件,对两张结果集进行匹配和组合。

这里有两个非常关键的点:

  • 关联键要写对
  • 必须理解不同 JOIN 对“匹配失败的行”如何处理

四、INNER JOIN:只返回能匹配上的记录

1. 基本语义

INNER JOIN 只保留两边都能成功匹配的行。

SELECT o.order_no,
       c.customer_name,
       o.total_amount
FROM orders AS o
INNER JOIN customers AS c
  ON o.customer_id = c.id;

INNER JOIN 里的 INNER 关键字可以省略,因此下面写法等价:

SELECT o.order_no,
       c.customer_name,
       o.total_amount
FROM orders AS o
JOIN customers AS c
  ON o.customer_id = c.id;

2. 什么时候适合用

当你的业务要求是“必须两边都有对应数据才算有效结果”时,INNER JOIN 很适合。

例如:

  • 查询有效订单及其客户信息
  • 查询订单明细及其商品名称
  • 查询员工及其所属部门,且部门必须存在

3. 示例:查询订单明细和商品名

SELECT oi.order_id,
       oi.product_id,
       p.product_name,
       oi.quantity,
       oi.sale_price
FROM order_items AS oi
JOIN products AS p
  ON oi.product_id = p.id;

如果某条明细引用了一个不存在的商品,那么它不会出现在结果集中。


五、LEFT JOIN:保留左表全部记录

1. 基本语义

LEFT JOIN 的含义是:

  • 左表中的记录全部保留
  • 右表能匹配上就拼接
  • 匹配不上时,右表字段用 NULL 补齐

示例:查询所有订单及客户名称,即使客户信息缺失,也要把订单查出来。

SELECT o.id,
       o.order_no,
       o.customer_id,
       c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.id;

2. 什么时候适合用

常见场景有:

  • 主表数据必须保留,附属信息有则展示、无则置空
  • 做数据排查,想找出“有主记录但缺维表映射”的异常数据
  • 在主业务表基础上补充标签、字典、统计信息

3. 用 LEFT JOIN 找异常数据

比如:查询没有匹配到客户的订单。

SELECT o.id,
       o.order_no,
       o.customer_id
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.id
WHERE c.id IS NULL;

这个写法非常实用,本质是在找“左表存在、右表缺失”的孤儿数据。


六、RIGHT JOIN:能用,但多数团队更偏向 LEFT JOIN

RIGHT JOINLEFT JOIN 语义对称,表示保留右表全部记录。

SELECT o.order_no,
       c.customer_name
FROM orders AS o
RIGHT JOIN customers AS c
  ON o.customer_id = c.id;

它的含义是:

  • 右表 customers 全部保留
  • 左表 orders 能匹配则拼接
  • 匹配不上时左表字段为 NULL

为什么很多团队不常用 RIGHT JOIN

因为从阅读习惯看,大家通常更习惯把“主表”放在左边。使用 LEFT JOIN 能让 SQL 结构更统一、更易读。

所以在真实项目中,很多人会把 RIGHT JOIN 改写成等价的 LEFT JOIN

例如上面的 SQL,可以改写成:

SELECT o.order_no,
       c.customer_name
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.id;

语义更直观:以客户表为主,补充订单信息。


七、CROSS JOIN:笛卡尔积要谨慎使用

CROSS JOIN 表示两张表做笛卡尔积,也就是左表每一行都和右表每一行组合。

SELECT c.customer_name,
       p.product_name
FROM customers AS c
CROSS JOIN products AS p;

如果客户 100 行、商品 1000 行,结果就是 100000 行。

什么时候会用到

虽然它不常见,但也不是完全没用,例如:

  • 生成维度组合
  • 准备报表模板数据
  • 某些测试场景下构造组合结果

但在业务 SQL 中,如果你无意间漏写了 JOIN 条件,效果可能就接近笛卡尔积,结果集会迅速膨胀,这是一类非常危险的问题。


八、多表 JOIN:真正常见的业务写法

1. 查询订单、客户、商品明细

比如想查询每条订单明细对应的客户名、订单号、商品名:

SELECT o.order_no,
       c.customer_name,
       p.product_name,
       oi.quantity,
       oi.sale_price
FROM orders AS o
JOIN customers AS c
  ON o.customer_id = c.id
JOIN order_items AS oi
  ON oi.order_id = o.id
JOIN products AS p
  ON oi.product_id = p.id;

这类 SQL 在系统导出、运营报表、后台明细列表中非常常见。

2. 多表 JOIN 的阅读顺序

建议按下面的顺序理解:

  1. 主表是谁
  2. 第一层关联表是谁
  3. 关联键是什么
  4. 每张表各自提供哪些字段
  5. 最终是明细粒度,还是汇总粒度

一旦这五件事理清,多表 SQL 就不容易乱。


九、ONWHERE 的区别:这是 JOIN 最常见的理解误区

很多初学者觉得 ONWHERE 都是在写条件,似乎放哪都一样。其实不是。

1. ON 的职责:定义“如何连接”

SELECT o.order_no,
       c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.id;

这里的 ON 用来说明:订单和客户通过哪两个字段建立匹配关系。

2. WHERE 的职责:对连接后的结果再过滤

SELECT o.order_no,
       c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.id
WHERE o.order_status = 'PAID';

这是在 JOIN 完成后,再筛出已支付订单。

3. 为什么在 LEFT JOIN 中尤其要注意

看两个例子。

写法一:把右表过滤条件写在 WHERE 中

SELECT o.order_no,
       c.customer_name,
       c.status
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.id
WHERE c.status = 'ACTIVE';

这会把右表为 NULL 的行过滤掉,结果效果接近 INNER JOIN

写法二:把右表过滤条件写在 ON 中

SELECT o.order_no,
       c.customer_name,
       c.status
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.id
 AND c.status = 'ACTIVE';

这时,左表订单仍然全部保留,只是只有客户状态为 ACTIVE 的才会成功匹配。

4. 一个经验判断

  • 连接关系条件,优先写在 ON
  • 结果集过滤条件,优先写在 WHERE
  • 右表过滤但仍想保留左表全部记录,条件通常要放到 ON

这个原则非常重要。


十、一对多 JOIN 为什么会“重复”

这是多表查询中最常见的疑惑之一。

例如:一个订单有 3 条明细。

当你执行:

SELECT o.order_no,
       oi.product_id,
       oi.quantity
FROM orders AS o
JOIN order_items AS oi
  ON oi.order_id = o.id;

你会发现同一个订单号可能出现多次。

这不是数据库“重复返回”,而是因为:

  • ordersorder_items 是一对多关系
  • 订单一行,匹配到明细表中的多行
  • JOIN 后自然会展开成多行

如何判断这是不是问题

先问自己:

  • 我需要的是订单明细粒度,还是订单粒度?

如果你需要的是订单明细粒度,那多行完全正常。

如果你需要的是订单粒度,那就不能直接拿明细 JOIN 结果当最终输出,通常要:

  • 再做聚合统计
  • 或先对子表汇总后再关联

例如:统计每个订单的商品种类数。

SELECT o.id,
       o.order_no,
       COUNT(*) AS item_count
FROM orders AS o
JOIN order_items AS oi
  ON oi.order_id = o.id
GROUP BY o.id, o.order_no;

十一、自连接:同一张表也可以 JOIN 自己

有些业务关系并不体现在两张不同的表里,而是同一张表的不同记录之间有关联。

例如员工表里有 manager_id,表示该员工的直属上级也是员工表中的另一行。

SELECT e.id,
       e.name AS employee_name,
       m.name AS manager_name
FROM employees AS e
LEFT JOIN employees AS m
  ON e.manager_id = m.id;

这就是自连接

自连接的典型场景

  • 员工与上级
  • 分类与父分类
  • 评论与父评论
  • 区域节点与上级节点

写自连接时,一定要用不同别名,否则根本无法区分“同一张表的两个角色”。


十二、JOIN 类型对比表

为了把常见 JOIN 的差异看得更清楚,可以用下面这张表总结:

JOIN 类型 保留左表全部行 保留右表全部行 只保留匹配行 常见用途
INNER JOIN 两边都必须有数据
LEFT JOIN 主表必须保留,右表可缺失
RIGHT JOIN 可用但相对少见
CROSS JOIN 是(全部组合) 是(全部组合) 不适用 生成组合、笛卡尔积

在日常开发里,最常用的其实还是:

  • INNER JOIN
  • LEFT JOIN

把这两种理解透,已经能覆盖绝大多数业务查询。


十三、常见坑:多表 JOIN 里最容易翻车的地方

1. 漏写连接条件,导致结果爆炸

例如:

SELECT *
FROM orders AS o
JOIN customers AS c;

如果没有 ON 条件,这类写法会产生类似笛卡尔积的效果,结果行数可能远超预期。

2. 左连接后在 WHERE 里过滤右表,意外变成内连接

这是经典问题:

LEFT JOIN customers AS c ON o.customer_id = c.id
WHERE c.status = 'ACTIVE'

这样一来,右表为 NULL 的行就都没了。

3. 使用了错误的关联键

例如本该写:

ON oi.order_id = o.id

却误写成:

ON oi.id = o.id

语法没错,但结果完全不对。这类问题非常隐蔽。

4. 多表 JOIN 后字段名冲突

idstatuscreated_at 这类字段,在多张表中经常都存在。建议始终带上表别名:

SELECT o.id, o.order_status, c.status

而不要写成:

SELECT id, status

5. 一对多表直接 JOIN 后拿去做主表数量统计

例如统计订单数量时直接联明细表:

SELECT COUNT(*)
FROM orders AS o
JOIN order_items AS oi
  ON oi.order_id = o.id;

这统计出来的很可能是“订单明细数”,不是“订单数”。如果要统计订单数,通常要明确粒度,必要时使用:

SELECT COUNT(DISTINCT o.id)
FROM orders AS o
JOIN order_items AS oi
  ON oi.order_id = o.id;

十四、实践建议:如何把 JOIN 写得更清晰

1. 先确定主表

写 SQL 之前,先回答:

  • 这条 SQL 最终围绕谁展开?
  • 我要保留哪张表的全部记录?
  • 结果粒度是订单、订单明细、客户,还是商品?

主表一旦明确,JOIN 结构通常就会稳定很多。

2. 每张表都起简洁别名

例如:

  • o 表示 orders
  • c 表示 customers
  • oi 表示 order_items
  • p 表示 products

这会让 SQL 更短,也更好读。

3. 把连接条件和过滤条件分层写清楚

例如:

SELECT o.order_no,
       c.customer_name,
       p.product_name,
       oi.quantity
FROM orders AS o
JOIN customers AS c
  ON o.customer_id = c.id
JOIN order_items AS oi
  ON oi.order_id = o.id
JOIN products AS p
  ON oi.product_id = p.id
WHERE o.order_status = 'PAID'
  AND p.status = 'ON_SALE';

这样结构会非常清晰:上半部分讲“怎么连”,下半部分讲“怎么筛”。

4. 关注关联列的索引

如果高频按照 orders.customer_id = customers.id 做关联,那么:

  • 被关联主键通常已经有索引
  • 外键字段如 orders.customer_id 也建议建立索引

否则数据量一大,JOIN 性能会明显受影响。

5. 先验证结果粒度,再做统计

很多统计错误,并不是函数不会写,而是 JOIN 之后的粒度已经变了。先确认每一行代表什么,再去 COUNTSUM,会少踩很多坑。


十五、小结

多表查询的关键,不在于记住多少 JOIN 关键字,而在于真正理解“表和表之间是如何匹配的”。这篇文章重点讲了:

  • 为什么关系型数据库天然离不开多表查询
  • INNER JOINLEFT JOINRIGHT JOINCROSS JOIN 的含义和使用场景
  • 多表 JOIN 的阅读顺序与书写方法
  • ONWHERE 的职责区别
  • 一对多关系下为什么会出现结果膨胀
  • 自连接、字段冲突、统计偏差等常见问题

如果你把 JOIN 理解成“按关系恢复业务视图”的过程,而不只是“把两张表连起来”,那么后面写复杂报表、统计查询、关联明细时,思路会清楚很多。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

05|单表查询进阶

下一篇

08|子查询与集合操作