SQL 多表操作全解

0 阅读22分钟

SQL 多表操作全解

本文完全承接之前的《SQL 单表操作》内容,聚焦关系型数据库的核心能力 ——多表操作

业务开发中 90% 的复杂需求都依赖多表操作实现,核心解决「数据拆分后的关联查询、跨表联动、数据一致性保障」三大问题。

本文全程以真实业务场景为基础,只讲开发高频使用的语法,覆盖 JOIN 关联、外键约束、级联操作、跨表增删改全流程,附带避坑指南和生产规范,看完即可直接落地使用。

单表操作仅能处理单一维度的数据,而真实业务中,数据是相互关联的。如果把所有数据都存在一张表里,会出现严重的数据冗余(比如一个用户有 10 个订单,用户的姓名、邮箱等信息会重复存储 10 次)、更新异常、插入异常等问题。

因此我们会按照「数据库设计范式」,把数据拆分到多张表里,通过关联键建立表与表之间的关系,而多表操作,就是把拆分后的数据重新关联、整合、操作的核心手段。

概念核心定义关键作用
主键(PRIMARY KEY)唯一标识一张表中一行数据的字段,非空、唯一单表数据的唯一标识,多表关联的核心锚点
外键(FOREIGN KEY)从表中关联主表主键的字段,用于建立两张表的关联关系保证跨表数据的一致性,是多表关联的桥梁
主表 / 父表被关联的表,提供主键作为关联依据比如用户表users,是订单表的主表
从表 / 子表持有外键、关联主表的表比如订单表orders,是用户表的从表
笛卡尔积两张表无有效关联条件时,返回的结果是两张表行数的乘积(1000 行用户表 × 1000 行订单表 = 100 万行结果)多表操作的头号大坑,业务中必须 100% 避免

本文所有实例,均基于《SQL 单表操作》中的users用户表,配套 3 张业务中最常用的关联表,表之间的关联关系完全贴合真实电商业务,后续所有操作均围绕这 4 张表展开:

-- 主表1:用户表(承接单表操作的表结构,仅保留核心字段)
CREATE TABLE IF NOT EXISTS users (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键',
  username VARCHAR(50) NOT NULL COMMENT '用户名',
  email VARCHAR(100) DEFAULT '' COMMENT '用户邮箱',
  age TINYINT UNSIGNED DEFAULT 0 COMMENT '年龄',
  city VARCHAR(20) DEFAULT '' COMMENT '所在城市',
  is_delete TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (id),
  UNIQUE KEY uk_username (username),
  KEY idx_city (city)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 主表2:商品表
CREATE TABLE IF NOT EXISTS goods (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品ID,主键',
  goods_name VARCHAR(100) NOT NULL COMMENT '商品名称',
  price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '商品价格',
  stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '商品库存',
  is_delete TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (id),
  KEY idx_goods_name (goods_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

-- 从表1:订单表(关联用户表,一对多关系:一个用户对应多个订单)
CREATE TABLE IF NOT EXISTS orders (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID,主键',
  user_id INT UNSIGNED NOT NULL COMMENT '下单用户ID,外键关联users.id',
  order_no VARCHAR(32) NOT NULL COMMENT '订单编号,唯一',
  total_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '订单总金额',
  is_pay TINYINT NOT NULL DEFAULT 0 COMMENT '是否支付:0-未支付,1-已支付',
  order_status TINYINT NOT NULL DEFAULT 1 COMMENT '订单状态:1-待付款,2-待发货,3-已完成,4-已取消',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '订单创建时间',
  pay_time DATETIME DEFAULT NULL COMMENT '支付时间',
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_user_id (user_id), -- 关联字段必须建索引,性能核心
  KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

-- 从表2:订单商品表(中间关联表,实现订单和商品的多对多关系)
CREATE TABLE IF NOT EXISTS order_goods (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  order_id INT UNSIGNED NOT NULL COMMENT '订单ID,外键关联orders.id',
  goods_id INT UNSIGNED NOT NULL COMMENT '商品ID,外键关联goods.id',
  goods_num INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '商品购买数量',
  goods_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '商品下单时的单价',
  PRIMARY KEY (id),
  KEY idx_order_id (order_id),
  KEY idx_goods_id (goods_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单商品关联表';

表与表的核心关联关系:

  1. 一对多users.idorders.user_id(一个用户对应多个订单,一个订单仅属于一个用户)
  2. 一对多orders.idorder_goods.order_id(一个订单对应多个商品,一个商品条目仅属于一个订单)
  3. 一对多goods.idorder_goods.goods_id(一个商品对应多个订单条目,一个订单条目仅对应一个商品)
  4. 多对多ordersgoods 通过中间表order_goods实现多对多关系(一个订单包含多个商品,一个商品出现在多个订单中)

多表操作核心:JOIN 关联查询全解

JOIN 是多表查询的核心,作用是通过关联键,将两张及以上的表横向拼接成一个结果集,再配合单表操作的 WHERE、分组、排序等语法,实现复杂的关联查询需求。

开发中 99% 的场景仅使用INNER JOIN(内连接)和LEFT JOIN(左连接),其余类型仅需了解适用场景即可。

JOIN 核心执行逻辑

所有 JOIN 操作都遵循统一的执行顺序,理解这个顺序,能解决 90% 的 JOIN 踩坑问题:

确定驱动表和被驱动表
执行ON关联条件,生成临时笛卡尔积结果集
过滤ON条件不匹配的行
执行LEFT/RIGHT JOIN的补行逻辑(左表/右表未匹配的行补NULL)
执行WHERE条件过滤结果集
执行分组、聚合、排序、分页等后续逻辑

核心结论ON是 JOIN 关联时的匹配条件,WHERE是 JOIN 完成后的结果过滤条件,二者执行时机完全不同。

INNER JOIN(内连接)

仅返回两张表中ON 关联条件完全匹配的行,相当于取两张表的交集,是业务中最常用的关联类型。

SELECT1.字段, 表2.字段
FROM 主表 表别名
INNER JOIN 从表 表别名
ON 主表.关联主键 = 从表.外键 -- 核心:关联条件,必须有效,避免笛卡尔积
[WHERE 结果过滤条件]
[GROUP BY 分组]
[ORDER BY 排序];
-- 场景1:查询已支付订单的用户信息和订单信息
-- 需求:只查有支付订单的用户,无订单的用户不返回
SELECT 
  u.id AS user_id,
  u.username,
  u.city,
  o.order_no,
  o.total_amount,
  o.pay_time
FROM users u
INNER JOIN orders o 
ON u.id = o.user_id -- 关联条件:用户ID=订单的用户ID
WHERE o.is_pay = 1 -- 过滤已支付的订单
  AND u.is_delete = 0
  AND o.order_status != 4;
  1. 必须给表起别名,简化 SQL 书写,避免多表同名字段冲突;
  2. 查询的字段必须指定所属表(如u.id),避免同名字段报错;
  3. INNER JOIN 中,条件写在 ON 里和 WHERE 里,最终结果一致(但建议关联条件写 ON,过滤条件写 WHERE,语义更清晰)。

LEFT JOIN(左连接)

返回左表(FROM 后的表)的所有行,右表中匹配不到 ON 条件的行,所有字段均补 NULL,相当于「左表全集 + 右表匹配交集」。

需要保留左表的所有数据,无论右表是否有对应数据,比如:

  • 查询所有用户,无论是否有订单;
  • 查询所有商品,无论是否有销量;
  • 统计未下单的用户、无销量的商品。
SELECT1.字段, 表2.字段
FROM 左表 表别名 -- 左表:要保留全部数据的表
LEFT JOIN 右表 表别名
ON 左表.关联主键 = 右表.外键 -- 关联条件
[WHERE 结果过滤条件]
[GROUP BY 分组]
[ORDER BY 排序];
-- 场景1:查询所有用户及其订单信息,无订单的用户也显示
SELECT 
  u.id AS user_id,
  u.username,
  o.order_no,
  o.total_amount
FROM users u
LEFT JOIN orders o 
ON u.id = o.user_id
WHERE u.is_delete = 0;

-- 场景2:查询从未下过单的用户(LEFT JOIN高频经典用法)
SELECT 
  u.id AS user_id,
  u.username,
  u.email
FROM users u
LEFT JOIN orders o 
ON u.id = o.user_id
WHERE o.id IS NULL; -- 右表匹配不到,主键为NULL,精准筛选无订单用户

LEFT JOIN 中,右表的过滤条件写在 WHERE 里,会导致 LEFT JOIN 失效,变成 INNER JOIN。

错误示例

-- 需求:查询所有用户,显示用户的账单已支付与未支付
-- 错误写法:把右表的过滤条件写在WHERE里
SELECT 
  u.id AS user_id,
  u.username,
  o.order_no,
  o.is_pay
FROM users u
LEFT JOIN orders o 
ON u.id = o.user_id
WHERE o.is_pay = 1; -- 错误:WHERE会过滤掉o.is_pay为NULL的行,无订单的用户被剔除

正确示例

-- 正确写法:把右表的过滤条件写在ON里,在关联时就过滤,不影响左表全集
SELECT 
  u.id AS user_id,
  u.username,
  o.order_no,
  o.is_pay
FROM users u
LEFT JOIN orders o 
ON u.id = o.user_id AND o.is_pay = 1 -- 右表过滤条件写在ON里
WHERE u.is_delete = 0;

ON 条件在 JOIN 关联时执行,过滤右表的行,不匹配的补 NULL;WHERE 条件在 JOIN 完成后执行,会把 NULL 值的行直接过滤掉,最终只保留匹配的行,LEFT JOIN 的特性完全失效。

  • ON 是 “关联时过滤右表” :不影响左表数量

  • WHERE 是 “关联后整体过滤” :会把 NULL 行干掉

其他 JOIN 类型

JOIN 类型核心作用适用场景开发使用频率
RIGHT JOIN(右连接)与 LEFT JOIN 完全对称,返回右表所有行,左表不匹配的补 NULL无专属场景,完全可以用 LEFT JOIN 替换,仅需了解★☆☆☆☆
FULL JOIN(全外连接)返回两张表的所有行,匹配不到的补 NULL,相当于左 + 右连接的并集极少业务场景需要,MySQL 不直接支持,需用 UNION 实现★☆☆☆☆
CROSS JOIN(交叉连接)无 ON 关联条件,返回两张表的笛卡尔积仅适用于数据补全、字典表全匹配等极特殊场景,业务开发严禁滥用★☆☆☆☆

多表级联关联

业务中绝大多数复杂查询,都需要关联 3 张及以上的表,JOIN 支持无限级联,只需保证每一级关联都有有效的 ON 条件即可。

-- 场景:查询用户的订单详情,包含用户信息、订单信息、商品信息
-- 关联4张表:users → orders → order_goods → goods
SELECT 
  u.id AS user_id,
  u.username,
  o.order_no,
  o.total_amount,
  o.pay_time,
  g.goods_name,
  og.goods_num,
  og.goods_price
FROM users u
INNER JOIN orders o ON u.id = o.user_id
INNER JOIN order_goods og ON o.id = og.order_id
INNER JOIN goods g ON og.goods_id = g.id
WHERE o.is_pay = 1
  AND u.is_delete = 0
ORDER BY o.create_time DESC;
  1. 生产环境建议关联表数量不超过 3 张,超过 5 张会严重影响查询性能,且维护难度极大;
  2. 每一级关联的字段,必须建立索引,且数据类型、字符集完全一致,避免索引失效;
  3. 混合使用 INNER JOIN 和 LEFT JOIN 时,需注意关联顺序,避免 LEFT JOIN 的特性失效。

自连接

自连接是指一张表自己和自己做 JOIN 关联,核心用于处理表内的树形结构、层级关系、同表数据对比等场景,开发中常用于部门上下级、商品分类、区域层级等需求。

-- 先创建示例表:部门表,有parent_id关联自身的id,实现上下级层级
CREATE TABLE IF NOT EXISTS department (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '部门ID',
  dept_name VARCHAR(50) NOT NULL COMMENT '部门名称',
  parent_id INT UNSIGNED DEFAULT NULL COMMENT '上级部门ID,关联自身id',
  PRIMARY KEY (id),
  KEY idx_parent_id (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表';

-- 场景:查询所有部门及其上级部门名称,无上级的部门也显示
SELECT 
  d1.id AS dept_id,
  d1.dept_name AS child_dept,
  d2.dept_name AS parent_dept
FROM department d1
LEFT JOIN department d2 ON d1.parent_id = d2.id;

外键约束与级联操作(跨表数据一致性保障)

核心作用是在数据库层面保证跨表数据的一致性和完整性,避免出现「订单关联的用户 ID 不存在」、「删除用户后订单变成无主数据」等脏数据问题。

外键是用于建立两张表关联关系的约束,要求从表的外键字段值,必须存在于主表的主键字段中,否则无法插入 / 更新。

这张表里的某列数据,必须依赖另一张表里的数据才能存在。

建表时创建外键

-- 以订单表为例,创建外键关联用户表
CREATE TABLE IF NOT EXISTS orders (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID,主键',
  user_id INT UNSIGNED NOT NULL COMMENT '下单用户ID,外键关联users.id',
  order_no VARCHAR(32) NOT NULL COMMENT '订单编号',
  total_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '订单总金额',
  -- 其他字段省略...
  PRIMARY KEY (id),
  -- 外键约束定义
  CONSTRAINT fk_orders_user_id -- 外键名,建议规范:fk_从表名_字段名
  FOREIGN KEY (user_id) -- 从表的外键字段
  REFERENCES users(id) -- 主表名(主键字段)
  -- 级联规则定义
  ON DELETE RESTRICT -- 删除主表记录时的处理规则
  ON UPDATE CASCADE -- 更新主表主键时的处理规则
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建表后新增外键

ALTER TABLE orders
ADD CONSTRAINT fk_orders_user_id
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
ON UPDATE CASCADE;

删除外键

ALTER TABLE orders DROP FOREIGN KEY fk_orders_user_id;

级联规则

级联规则定义了主表的主键被删除 / 更新时,从表的外键数据如何联动处理,也就是你提到的跨表增删默认处理,核心有 4 种规则:

级联规则核心作用适用场景生产风险
ON DELETE RESTRICT默认规则,主表记录有从表关联数据时,禁止删除主表记录强数据一致性场景,比如用户有未完成的订单,禁止删除用户低,最安全
ON DELETE CASCADE主表记录删除时,从表关联的记录同步自动删除父子表强绑定场景,比如订单删除,订单商品同步删除极高,极易误删全链路数据,生产慎用
ON DELETE SET NULL主表记录删除时,从表关联的外键字段设为 NULL主表数据删除,从表数据需保留的场景,比如用户删除,订单保留,user_id 设为 NULL中,需保证外键字段允许为 NULL
ON DELETE NO ACTION与 RESTRICT 几乎一致,部分数据库有细微差异,MySQL 中完全等效无专属场景,建议用 RESTRICT 替代

补充:ON UPDATE 规则与ON DELETE完全一致,仅作用于主表主键更新的场景。由于生产环境中主键几乎不会更新,因此ON UPDATE极少使用,仅需了解即可。

-- 场景:订单表外键设置 ON DELETE CASCADE
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user_id
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE;

-- 执行删除用户操作
DELETE FROM users WHERE id = 1;
-- 结果:id=1的用户被删除的同时,orders表中user_id=1的所有订单,会被自动同步删除

物理外键 vs 逻辑外键

虽然外键能在数据库层面保证数据一致性,但互联网高并发场景中,绝大多数公司都会放弃物理外键,改用逻辑外键,核心原因如下:

类型实现方式优势劣势适用场景
物理外键在数据库层面创建 FOREIGN KEY 约束,由数据库保证一致性1. 数据库层面强制保证数据一致性,杜绝脏数据;2. 级联操作自动完成,无需代码处理1. 级联操作会触发锁,高并发场景极易导致死锁、性能问题;2. 分布式场景、分库分表场景不支持;3. 数据批量导入、迁移难度大;4. 级联删除易导致数据误删,不可逆传统企业级系统、低并发、强一致性需求的内部系统
逻辑外键仅在表中保留 user_id 这类关联字段,不创建物理外键,由代码层面保证数据一致性1. 性能高,无锁开销,支持高并发;2. 适配分布式、分库分表场景;3. 数据迁移、批量操作灵活;4. 业务逻辑可控,不会出现意外的级联删除1. 代码层面需额外处理一致性,开发成本略高;2. 可能出现脏数据,需定期巡检互联网高并发业务、电商系统、分布式系统、分库分表场景
  1. 非核心、低并发的内部系统,可使用物理外键 + RESTRICT 规则,禁止使用 CASCADE 级联删除;
  2. 互联网高并发业务、核心交易系统,统一使用逻辑外键,由代码层面保证数据一致性;
  3. 无论是否使用物理外键,关联字段都必须建立索引,保证查询性能。

跨表增删改操作

除了关联查询,业务中经常需要基于多张表的关联关系,执行跨表的新增、更新、删除操作,避免先查询再循环操作的低性能写法。

跨表更新:UPDATE JOIN

基于表之间的关联关系,用一张表的数据更新另一张表,支持多表关联条件过滤。

-- 语法
UPDATE1 别名
[INNER/LEFT JOIN2 别名 ON 关联条件]
SET 要更新的字段 = 新值
[WHERE 过滤条件];

-- 场景1:给北京地区的用户的所有未支付订单,添加专属备注
UPDATE orders o
INNER JOIN users u ON o.user_id = u.id
SET o.remark = '北京用户专属优惠订单', o.update_time = NOW()
WHERE u.city = '北京' 
  AND o.is_pay = 0 
  AND o.order_status = 1;

-- 场景2:根据订单商品的购买数量,扣减商品表的库存(下单扣库存)
UPDATE goods g
INNER JOIN order_goods og ON g.id = og.goods_id
INNER JOIN orders o ON og.order_id = o.id
SET g.stock = g.stock - og.goods_num
WHERE o.id = 100 
  AND o.is_pay = 1
  AND g.stock >= og.goods_num;

跨表删除:DELETE JOIN

基于关联条件,删除一张或多张表中符合条件的数据,避免先查 ID 再删除的二次操作。

-- 语法1:仅删除单表数据,基于多表关联过滤
DELETE 要删除的表别名
FROM1 别名
[INNER/LEFT JOIN2 别名 ON 关联条件]
[WHERE 过滤条件];

-- 语法2:级联删除多张表的数据
DELETE1别名, 表2别名
FROM1 别名
INNER JOIN2 别名 ON 关联条件
[WHERE 过滤条件];

-- 场景1:删除上海地区用户的未支付、已取消的订单
DELETE o
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.city = '上海' 
  AND o.order_status = 4 
  AND o.is_pay = 0;

-- 场景2:删除订单时,同步删除订单商品表的关联数据(无物理外键场景)
DELETE o, og
FROM orders o
INNER JOIN order_goods og ON o.id = og.order_id
WHERE o.id = 100;

跨表插入:INSERT ... SELECT

将多表关联查询的结果,直接插入到另一张表中,常用于数据统计、数据归档、报表生成等场景,无需先查询再插入。

-- 语法
INSERT INTO 目标表(字段1, 字段2, 字段3)
SELECT 字段1, 字段2, 聚合函数
FROM1 别名
[INNER/LEFT JOIN2 别名 ON 关联条件]
[WHERE 过滤条件]
[GROUP BY 分组];

-- 场景:统计每个用户的消费总额,插入到用户数据统计表中
-- 先创建目标表
CREATE TABLE IF NOT EXISTS user_consume_stats (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
  user_id INT UNSIGNED NOT NULL COMMENT '用户ID',
  username VARCHAR(50) NOT NULL COMMENT '用户名',
  total_order_num INT NOT NULL DEFAULT 0 COMMENT '总订单数',
  total_consume_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '总消费金额',
  stats_date DATE NOT NULL COMMENT '统计日期',
  PRIMARY KEY (id),
  UNIQUE KEY uk_user_date (user_id, stats_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户消费统计表';

-- 跨表插入:统计2026年的用户消费数据,插入统计表
INSERT INTO user_consume_stats(user_id, username, total_order_num, total_consume_amount, stats_date)
SELECT 
  u.id AS user_id,
  u.username,
  COUNT(o.id) AS total_order_num,
  SUM(o.total_amount) AS total_consume_amount,
  CURDATE() AS stats_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.is_pay = 1 
  AND o.create_time BETWEEN '2026-01-01' AND '2026-12-31'
  AND u.is_delete = 0
GROUP BY u.id, u.username;

跨表增删改生产铁律

  1. 所有跨表更新、删除操作,必须加 WHERE 条件,严禁无过滤的全表操作;
  2. 执行前必须先用 SELECT 语句,确认关联条件和 WHERE 条件匹配的行数,避免误操作;
  3. 生产环境的核心数据修改,必须包裹事务,执行失败可回滚;
  4. 大表批量操作必须拆分,避免长事务锁表,导致数据库性能雪崩;
  5. 严禁在循环中执行单条跨表操作,必须使用批量语法一次性完成。

多表操作进阶场景

多表分组聚合统计

多表关联 + 分组聚合,是业务报表、数据统计的核心写法,基于关联后的结果集,做分维度的聚合计算。

-- 场景1:统计每个城市的用户总消费金额、订单数,按消费金额倒序
SELECT 
  u.city,
  COUNT(DISTINCT u.id) AS user_count, -- 去重统计用户数
  COUNT(o.id) AS total_order_num,     -- 总订单数
  SUM(o.total_amount) AS total_consume_amount -- 总消费金额
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.is_pay = 1
WHERE u.is_delete = 0
GROUP BY u.city
HAVING total_consume_amount > 0
ORDER BY total_consume_amount DESC;

-- 场景2:统计商品销量TOP10,包含商品名称、销量、销售额
SELECT 
  g.id AS goods_id,
  g.goods_name,
  SUM(og.goods_num) AS sale_num,
  SUM(og.goods_num * og.goods_price) AS sale_amount
FROM goods g
INNER JOIN order_goods og ON g.id = og.goods_id
INNER JOIN orders o ON og.order_id = o.id
WHERE o.is_pay = 1 AND o.order_status != 4
GROUP BY g.id, g.goods_name
ORDER BY sale_num DESC
LIMIT 10;

子查询 vs JOIN 对比

很多多表查询需求,既可以用 JOIN 实现,也可以用子查询实现,二者的适用场景和性能有明显差异:

特性JOIN子查询
性能大多数场景性能更优,优化器可优化执行计划,多表关联一次完成简单子查询性能好,复杂嵌套子查询性能差,大数据量下易出现性能问题
可读性关联逻辑清晰,适合多表数据整合展示适合简单的条件过滤,嵌套子查询可读性差
适用场景需要返回多张表的字段、多表分组聚合、大数据量查询简单的 IN/EXISTS 条件过滤、小表数据匹配
-- 需求:查询有已支付订单的用户信息
-- 写法1:INNER JOIN实现(推荐,性能更优)
SELECT DISTINCT u.id, u.username, u.city
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.is_pay = 1 AND u.is_delete = 0;

-- 写法2:IN子查询实现(小表适用,大表性能差)
SELECT id, username, city
FROM users
WHERE id IN (
  SELECT DISTINCT user_id FROM orders WHERE is_pay = 1
) AND is_delete = 0;

-- 写法3:EXISTS子查询实现(大数据量下优于IN,推荐)
SELECT id, username, city
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.is_pay = 1
) AND u.is_delete = 0;

最佳实践

  1. 需要返回多张表的字段,统一用 JOIN 实现;
  2. 简单的条件过滤,小数据量用 IN 子查询,大数据量用 EXISTS 子查询;
  3. 避免使用 3 层以上的嵌套子查询,改用 JOIN 优化,提升性能和可读性。

多表操作性能优化

核心性能优化要点

  • 关联字段必须建立索引,且类型完全一致这是多表查询性能的核心,所有 JOIN 的 ON 关联字段,必须建立索引;且字段的数据类型、长度、字符集必须完全一致,否则会触发隐式类型转换,导致索引失效,全表扫描。

  • 小表驱动大表JOIN 的核心优化原则:小表作为驱动表,大表作为被驱动表,减少循环匹配的次数。

    • INNER JOIN:MySQL 优化器会自动选择小表驱动大表;
    • LEFT JOIN:左表是驱动表,右表是被驱动表,尽量把小表放在左表;
    • RIGHT JOIN:右表是驱动表,左表是被驱动表。
  • 严格控制关联表的数量生产环境建议关联表数量不超过 3 张,超过 5 张会导致优化器选错执行计划,查询性能指数级下降,且维护难度极大。复杂需求可拆分为多次查询,在代码层面做数据整合。

  • 避免大表 JOIN,提前过滤数据先通过 WHERE 条件过滤掉大部分数据,再做 JOIN 关联,减少参与关联的数据量。比如先过滤出 2026 年的订单,再和用户表关联,而不是全表关联后再过滤。

  • 避免在 JOIN 关联字段上使用函数 / 表达式比如ON YEAR(u.create_time) = YEAR(o.create_time),会导致索引失效,全表扫描。

生产环境避坑指南

  • 100% 避免笛卡尔积所有 JOIN 必须有有效的 ON 关联条件,严禁无 ON 条件的 JOIN,严禁 ON 条件为恒真(如1=1),否则会生成笛卡尔积,直接导致数据库雪崩。

  • 避免 SELECT * 查询多表字段多表查询必须指定需要的字段,严禁 SELECT *,避免冗余字段传输、同名字段冲突,提升查询性能。

  • 避免 LEFT JOIN + INNER JOIN 混用导致特性失效比如users LEFT JOIN orders ON ... INNER JOIN order_goods ON ...,如果 orders 表匹配不到为 NULL,后续的 INNER JOIN 会把 NULL 行过滤掉,LEFT JOIN 完全失效。如需保留左表全集,后续的关联也必须用 LEFT JOIN。

  • 避免大表深分页 JOIN比如LIMIT 100000, 10的深分页,会先关联全表数据,再分页,性能极差。优化方案:先分页查出主表的主键,再做 JOIN 关联,减少参与关联的数据量。

    -- 优化前:先关联全表,再分页,性能极差
    SELECT u.id, u.username, o.order_no FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    ORDER BY u.id DESC
    LIMIT 100000, 10;
    
    -- 优化后:先分页查主键,再关联,性能提升10倍以上
    SELECT u.id, u.username, o.order_no
    FROM (SELECT id, username FROM users ORDER BY id DESC LIMIT 100000, 10) u
    LEFT JOIN orders o ON u.id = o.user_id;
    

上线强制规范

  • 所有多表操作的 SQL,上线前必须用EXPLAIN查看执行计划,确认索引是否生效、是否有全表扫描、文件排序等问题;
  • 严禁在核心交易链路中,使用超过 3 张表的 JOIN 关联;
  • 严禁在循环中执行多表操作,必须使用批量语法;
  • 所有跨表更新、删除操作,必须先在测试环境验证,再上线生产;
  • 生产环境禁止使用ON DELETE CASCADE级联删除,避免误删数据。