多表联查详解
用充电桩运营数据掌握 MySQL 多表联查:内连接、外连接与 LEFT JOIN 找孤儿行、同城配对的自连接、三表联查与行数膨胀的根源。
场景:运营数据散在三张表里
充电桩平台的表按职责拆开:站点表、设备表、充电订单表。运营想知道”每个站点的充电收入”,就必须把三张表拼起来——这正是关系数据库分表存储后每天要做的事。
CREATE TABLE stations (
station_id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
city VARCHAR(20) NOT NULL
);
CREATE TABLE chargers (
charger_id INT PRIMARY KEY,
station_id INT NOT NULL,
power_kw DECIMAL(5,1) NOT NULL,
status ENUM('online','offline','fault') NOT NULL,
FOREIGN KEY (station_id) REFERENCES stations(station_id)
);
CREATE TABLE charge_sessions (
session_id BIGINT PRIMARY KEY,
charger_id INT NOT NULL,
started_at DATETIME NOT NULL,
kwh DECIMAL(7,2) NOT NULL,
amount DECIMAL(8,2) NOT NULL,
FOREIGN KEY (charger_id) REFERENCES chargers(charger_id)
);
INSERT INTO stations VALUES
(1, '滨江服务区站', '杭州'),
(2, '萧山机场站', '杭州'),
(3, '虹桥枢纽站', '上海');
INSERT INTO chargers VALUES
(11, 1, 120.0, 'online'),
(12, 1, 60.0, 'fault'),
(13, 2, 120.0, 'online'),
(14, 2, 180.0, 'offline'),
(15, 3, 240.0, 'online');
INSERT INTO charge_sessions VALUES
(9001, 11, '2026-09-01 08:15:00', 45.20, 67.80),
(9002, 11, '2026-09-01 14:40:00', 52.00, 78.00),
(9003, 13, '2026-09-02 10:05:00', 38.50, 57.75),
(9004, 15, '2026-09-02 16:20:00', 61.00, 91.50);注意一个关键事实:14 号桩(offline)从来没有产生过订单,3 号站虹桥只有一根桩且有订单,但假如没有订单的桩挂在没有订单的站呢——外连接要解决的就是”不匹配的行去哪了”。
动手:四种连接逐一试
先给四种连接立一张”结果集地图”,每个小圆是一对能匹配上的行:
flowchart LR
subgraph A["表A(桩)"]
A1["1"]
A2["2"]
A3["3"]
A4["4"]
end
subgraph B["表B(订单)"]
B1["A"]
B2["B"]
B3["C"]
end
A1 --- B1
A2 --- B2
A3 --- B3- 内连接(INNER JOIN):只留 1、2、3(两边都有的)
- 左连接(LEFT JOIN):1、2、3、4(A 全部 + B 匹配的)
- 右连接(RIGHT JOIN):1、2、3、A、B、C(B 全部 + A 匹配的)
- 全连接(FULL JOIN):两边全部(MySQL 要用 UNION 模拟,见下)
INNER JOIN:只留两边都匹配的行
SELECT s.name AS station, c.charger_id, c.power_kw
FROM stations s
INNER JOIN chargers c ON c.station_id = s.station_id;每个桩都挂在某个站下,五行全保留。INNER 可省略,写 JOIN 默认就是内连接。ON 后面是两表的关联条件,通常写”从表外键 = 主表主键”。
把第三张表接上,就是最常见的三表联查——按站点统计充电收入:
SELECT s.name, SUM(cs.amount) AS revenue, COUNT(cs.session_id) AS sessions
FROM stations s
JOIN chargers c ON c.station_id = s.station_id
JOIN charge_sessions cs ON cs.charger_id = c.charger_id
GROUP BY s.station_id, s.name
ORDER BY revenue DESC;注意聚合的是 COUNT(cs.session_id) 而不是 COUNT(*)——原因在坑点二。
LEFT JOIN:左表全保留,右边补 NULL
“哪些桩从未产生过订单?“内连接答不了——没订单的桩根本不会出现在结果里:
SELECT c.charger_id, c.status, cs.session_id
FROM chargers c
LEFT JOIN charge_sessions cs ON cs.charger_id = c.charger_id
WHERE cs.session_id IS NULL; -- 右边没匹配上 => 14 号桩LEFT JOIN 的语义:左表每一行都保留;右表没有匹配行时,右表各列填 NULL。配一个 WHERE 右表.主键 IS NULL 就是标准的”找孤儿行”手法(反连接)。
方向反过来就是 RIGHT JOIN,语义相同。实际写 SQL 的惯例是统一用 LEFT JOIN,把想全保留的表放左边——RIGHT JOIN 读起来要换个方向思考,团队代码里少见。
FULL OUTER JOIN:MySQL 没有,用 UNION 模拟
MySQL 不支持 FULL OUTER JOIN(PostgreSQL 原生支持)。想同时保留两侧未匹配行时,左右各查一遍再合并:
SELECT c.charger_id, cs.session_id
FROM chargers c LEFT JOIN charge_sessions cs ON cs.charger_id = c.charger_id
UNION
SELECT c.charger_id, cs.session_id
FROM chargers c RIGHT JOIN charge_sessions cs ON cs.charger_id = c.charger_id;UNION 会去重(两侧都匹配上的行只留一份),这也正是模拟 FULL JOIN 需要它的原因。要保留重复行用 UNION ALL,但在这里会算重。
自连接:同一张表和自己拼
“同城有哪些站点,方便互相调拨运维?“站点表和自己连接,关联条件是城市相同、站点不同:
SELECT a.name AS station_a, b.name AS station_b, a.city
FROM stations a
JOIN stations b ON a.city = b.city AND a.station_id < b.station_id;
-- 结果:滨江服务区站 x 萧山机场站(杭州)自连接的本质是给同一张表起两个别名,当成两张独立表用。a.station_id < b.station_id 让每对站点只出现一次(去掉 (A,B) 与 (B,A) 的镜像和 A=A 的自身配对)。经典同款:员工表里”员工-上级”配对,分类表里”子类-父类”配对。
CROSS JOIN 与 USING
-- 笛卡尔积:3 站 x 3 时段 = 9 行,生成巡检排班矩阵的底表
SELECT s.name, t.slot
FROM stations s
CROSS JOIN (
SELECT '06:00-14:00' AS slot
UNION ALL SELECT '14:00-22:00' UNION ALL SELECT '22:00-06:00'
) t;
-- 两表同名列关联时,USING 是 ON 的简写(结果里同名列只出现一次)
SELECT s.name, c.power_kw
FROM stations s JOIN chargers c USING (station_id);CROSS JOIN 常被拿来当”行生成器”用(本例的时段表就是这么拼的)。NATURAL JOIN 按全部同名列自动连接,列名一变就悄悄改语义,生产代码不要用。
为什么:行数膨胀是理解一切连接的地基
判断一条联查结果对不对,先算行数。 charger_sessions 与 chargers 是多对一:一个桩多笔订单。联查后行数 = 订单数(每笔订单恰好带出它桩和站的信息),这个方向安全。
反过来,一对多方向的聚合就是事故高发区:
-- 想算每个站有几根桩 + 几笔订单,直接联三表再 COUNT(*) 就错了:
-- 站 1 有 2 根桩,订单都挂在桩 11 上,联查后站 1 有 2 笔订单 x ... 行数被桩数放大一对多联查的结果里,“一”侧的每一行会随”多”侧重复出现。所以:COUNT 主表用 COUNT(DISTINCT 主表.主键) 或先聚合子表再 JOIN;SUM 更危险,金额会被成倍放大。这是所有报表联查的第一自检项。
ON 与 WHERE 的执行时机差异也源于此:外连接的”补 NULL 行”发生在连接阶段,之后 WHERE 再过滤——把右表条件写进 WHERE 会把补出来的 NULL 行过滤掉,LEFT JOIN 退化成 INNER JOIN。这条链的完整推演在 SELECT 执行顺序。
坑点与自检
坑一:忘写连接条件,行数爆表
-- 逗号连接 = CROSS JOIN:5 桩 x 4 订单 = 20 行垃圾数据
SELECT * FROM chargers c, charge_sessions cs;症状是结果行数远超预期、同值行大量重复。自检:联查结果行数先于内容检查,心里要有一个预期行数(多对一 = 多表行数;一对多 = 膨胀)。
坑二:LEFT JOIN 后的 COUNT
-- 想统计每个站的订单数,包括 0 单的站
SELECT s.name, COUNT(*) AS cnt -- 错:0 单的站显示 1(一行全 NULL 的行)
FROM stations s
LEFT JOIN chargers c ON c.station_id = s.station_id
LEFT JOIN charge_sessions cs ON cs.charger_id = c.charger_id
GROUP BY s.station_id, s.name;
-- 正确:COUNT 带表名前缀的右表列,NULL 不计数
SELECT s.name, COUNT(cs.session_id) AS cnt;自检:LEFT JOIN 之后写聚合,全部用 COUNT(右表.列),禁止 COUNT(*)。
坑三:右表条件放错位置,LEFT JOIN 静默退化
-- 错误:只看 9 月订单,但写在 WHERE 里 => 从没在 9 月充过电的桩整行消失
SELECT c.charger_id, cs.session_id
FROM chargers c
LEFT JOIN charge_sessions cs ON cs.charger_id = c.charger_id
WHERE cs.started_at >= '2026-09-01';
-- 正确:时间条件属于"匹配规则",写进 ON
SELECT c.charger_id, cs.session_id
FROM chargers c
LEFT JOIN charge_sessions cs
ON cs.charger_id = c.charger_id
AND cs.started_at >= '2026-09-01';判别口诀:这个条件筛的是”匹配什么”(进 ON)还是”留下什么”(进 WHERE)。想保留左表全部行,右表条件一律进 ON。
坑四:连接条件写错列,不报错但数据错
ON c.station_id = cs.charger_id 这类”类型兼容但语义无关”的列相等不会报任何错,只会给出错误的匹配。防御手段:外键约束(建表时写好)、联查前先单表 SELECT 熟悉各表主键、结果抽查几行人工核对。
性能预览:这条 JOIN 打算怎么执行
连接快不快,取决于被驱动表关联列上有没有索引:
-- 给从表的关联列建索引(外键在 MySQL InnoDB 会自动建,手工删过就要补)
SHOW INDEX FROM chargers;
EXPLAIN SELECT ... ; -- 见 mysql/320-EXPLAINDetailed嵌套循环、哈希连接这些算法层面的展开见 JOIN 算法。
练习
- 查出”每个站点的桩数和订单数”,要求 0 单的站也出现且显示 0(提示:LEFT JOIN + COUNT 明细列)。
- 查出从未产生订单的站点(而不是桩)。先想清楚:14 号桩会让它所在的站免于”孤儿”判定吗?
- 用自连接找出”同站同功率”的桩对(提示:
a.charger_id < b.charger_id去重)。 - 把三表收入统计改写成”先按桩聚合订单,再 JOIN 站点”的两段式,对比结果是否一致,并解释哪种写法在订单表巨大时更稳。
- 构造一条 WHERE 让 LEFT JOIN 退化的查询,再用 EXPLAIN 观察 type 列从 ALL/ref 的变化,验证”退化是可以被观测的”。