📑 本页目录(点开跳转)
02 · JOIN 的几种语义,和扇出这个隐形杀手
⏱ 78 分钟 | ⭐ 实测:JOIN 之后 SUM 从 150 变成 250——没报错,账就这么错了
🎯 一句话
JOIN 不是「把两张表拼在一起」,是「按条件做一次配对」——配对的结果行数,可能比任何一张原表都多。
一旦多出来,你后面所有的 SUM / COUNT / AVG 全部跟着放大,而数据库一个字都不会提醒你。
🚦 一、五种连接,一次跑完
三个用户、四张单,其中「阿花」一张单都没有,而 id=13 那张单挂在一个不存在的用户 99 上(孤儿行,真实系统里非常常见):
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (id INT, name TEXT)")
db.execute("CREATE TABLE orders(id INT, user_id INT, amount REAL)")
db.executemany("INSERT INTO users VALUES(?,?)",
[(1, "阿呆"), (2, "阿瓜"), (3, "阿花")]) # 阿花没下过单
db.executemany("INSERT INTO orders VALUES(?,?,?)",
[(10, 1, 100.0), (11, 1, 50.0), (12, 2, 30.0),
(13, 99, 7.0)]) # user_id=99 是孤儿单
def n(sql):
return len(db.execute(sql).fetchall())
base = "FROM users u {j} JOIN orders o ON o.user_id = u.id"
print("表里:", n("SELECT * FROM users"), "个用户,", n("SELECT * FROM orders"), "张单")
print("INNER JOIN :", n("SELECT * " + base.format(j="INNER")), "行")
print("LEFT JOIN :", n("SELECT * " + base.format(j="LEFT")), "行")
print("RIGHT JOIN :", n("SELECT * " + base.format(j="RIGHT")), "行")
print("FULL JOIN :", n("SELECT * " + base.format(j="FULL")), "行")
print("CROSS JOIN :", n("SELECT * FROM users u CROSS JOIN orders o"), "行")
print()
print("LEFT JOIN 明细:")
for r in db.execute("SELECT u.name, o.id, o.amount " + base.format(j="LEFT")):
print(" ", r)
实跑输出:
表里: 3 个用户, 4 张单
INNER JOIN : 3 行
LEFT JOIN : 4 行
RIGHT JOIN : 4 行
FULL JOIN : 5 行
CROSS JOIN : 12 行
LEFT JOIN 明细:
('阿呆', 10, 100.0)
('阿呆', 11, 50.0)
('阿瓜', 12, 30.0)
('阿花', None, None)
| 写法 | 保留谁 | 上面为什么是这个数 |
|---|---|---|
INNER JOIN |
只保留配上对的 | 3 张单配上了人(13 号是孤儿,掉了) |
LEFT JOIN |
左表全留,右边没配上的补 NULL |
3 + 阿花那一行 = 4 |
RIGHT JOIN |
右表全留 | 3 + 孤儿单那一行 = 4 |
FULL JOIN |
两边都全留 | 3 + 阿花 + 孤儿单 = 5 |
CROSS JOIN |
不配对,每一对组合都要 | 3 × 4 = 12 |
⭐ LEFT JOIN 补出来的那一行不是空行,是「左边有、右边所有列都是 NULL」的一行。
('阿花', None, None) 里那两个 None 不是数据库里存着的 NULL —— 表里根本没有那一行,
是连接当场造出来的。04 章会讲这个区别为什么要紧。
⚠️ RIGHT JOIN / FULL JOIN 是 SQLite 3.39 才有的(本机 3.50.4)。老版本 SQLite 和老版本 MySQL 没有 FULL JOIN,
习惯做法是「LEFT JOIN 一遍 + UNION + 反过来再 LEFT JOIN 一遍」。
⭐ 实践里 RIGHT JOIN 几乎不用 —— 把两张表调个位置写成 LEFT JOIN 就行,读起来还顺。
🧩 二、ON 和 WHERE,在 LEFT JOIN 上不是一回事
这是全章最容易被忽略、后果又最具体的一条。同样一个 status='paid' 条件,写在 ON 里和写在 WHERE 里,结果完全不同:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (id INT, name TEXT)")
db.execute("CREATE TABLE orders(id INT, user_id INT, amount REAL, status TEXT)")
db.executemany("INSERT INTO users VALUES(?,?)",
[(1, "阿呆"), (2, "阿瓜"), (3, "阿花")])
db.executemany("INSERT INTO orders VALUES(?,?,?,?)",
[(10, 1, 100.0, "paid"), (11, 1, 50.0, "refunded"),
(12, 2, 30.0, "refunded")]) # 阿花一张单都没有
print("① LEFT JOIN,条件写在 ON 里:")
for r in db.execute("SELECT u.name, o.id, o.amount FROM users u "
"LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid'"):
print(" ", r)
print("② LEFT JOIN,同样的条件写在 WHERE 里:")
for r in db.execute("SELECT u.name, o.id, o.amount FROM users u "
"LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid'"):
print(" ", r)
print("③ INNER JOIN + WHERE(和 ② 一模一样):")
for r in db.execute("SELECT u.name, o.id, o.amount FROM users u "
"JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid'"):
print(" ", r)
print("④ 找「一张单都没有」的人 —— WHERE 右表列 IS NULL 是合法用法:")
for r in db.execute("SELECT u.name FROM users u "
"LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL"):
print(" ", r)
实跑输出:
① LEFT JOIN,条件写在 ON 里:
('阿呆', 10, 100.0)
('阿瓜', None, None)
('阿花', None, None)
② LEFT JOIN,同样的条件写在 WHERE 里:
('阿呆', 10, 100.0)
③ INNER JOIN + WHERE(和 ② 一模一样):
('阿呆', 10, 100.0)
④ 找「一张单都没有」的人 —— WHERE 右表列 IS NULL 是合法用法:
('阿花',)
三行 vs 一行。 原因就是 01 章那张八步表:
| 条件在哪一步生效 | 后果 | |
|---|---|---|
写在 ON 里 |
第①步,配对的时候 | 阿瓜配不上 paid 的单 → 右边补 NULL,⭐ 他这一行还在 |
写在 WHERE 里 |
第②步,配对完之后逐行筛 | 阿瓜那行的 o.status 是 NULL,NULL='paid' 判不出真 → 整行被扔 |
⭐ 判据(背下来):
LEFT JOIN的时候,针对【右表】的条件写ON,针对【左表】的条件写WHERE。 把右表条件写进WHERE,你的LEFT JOIN就退化成INNER JOIN了 —— 实跑里 ② 和 ③ 的输出一模一样。
⚠️ 对 INNER JOIN 则完全没区别:两边都要配上对,条件写哪儿结果一样。
💀 所以这个坑是这样长出来的:先写了个 INNER JOIN,条件顺手放进 WHERE,一切正常;
后来需求变成「没有单的人也要出现在报表里」,于是把 INNER 改成 LEFT —— 只改了一个词,WHERE 忘了动。
报表还是少那几个人,而且不报错。
⭐ 唯一的例外是第④种写法:LEFT JOIN 之后 WHERE 右表列 IS NULL。
它是故意利用这个退化行为来做「反连接」(找左边有、右边没有的),是标准手法,05 章还会再用它一次。
💀 三、扇出:SUM 从 150 变成 250
症状
两张订单(100 元 + 50 元,真实总额 150),一张标签表。1 号单有两个标签,2 号单有一个。 只是顺手连了一下标签表,总额就变成了 250:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders(id INTEGER PRIMARY KEY, amount REAL)")
db.execute("CREATE TABLE tags (order_id INT, tag TEXT)")
db.executemany("INSERT INTO orders VALUES (?,?)", [(1, 100.0), (2, 50.0)])
db.executemany("INSERT INTO tags VALUES (?,?)", [(1, "vip"), (1, "promo"), (2, "new")])
print("真实总额 =", db.execute("SELECT SUM(amount) FROM orders").fetchone()[0])
print("JOIN 之后 SUM =", db.execute(
"SELECT SUM(amount) FROM orders o JOIN tags t ON t.order_id = o.id").fetchone()[0],
" 参与求和的行数 =", db.execute(
"SELECT COUNT(*) FROM orders o JOIN tags t ON t.order_id = o.id").fetchone()[0])
print()
print("连接后的明细(看 100 出现了几次):")
for r in db.execute("SELECT o.id, o.amount, t.tag FROM orders o JOIN tags t ON t.order_id = o.id"):
print(" ", r)
print()
print("修法① EXISTS(只问在不在,不带行进来) =", db.execute(
"SELECT SUM(amount) FROM orders o "
"WHERE EXISTS (SELECT 1 FROM tags t WHERE t.order_id = o.id)").fetchone()[0])
print("修法② 先把右表聚合成一行一单再连 =", db.execute(
"SELECT SUM(o.amount) FROM orders o "
"JOIN (SELECT order_id, COUNT(*) AS n FROM tags GROUP BY order_id) t "
"ON t.order_id = o.id").fetchone()[0])
print("修法③ 对左表主键去重后再求和 =", db.execute(
"SELECT SUM(amount) FROM (SELECT DISTINCT o.id, o.amount FROM orders o "
"JOIN tags t ON t.order_id = o.id)").fetchone()[0])
print()
print("⚠️ 体检:右表每个键平均带来几行 =", db.execute(
"SELECT COUNT(*)*1.0 / COUNT(DISTINCT order_id) FROM tags").fetchone()[0])
实跑输出:
真实总额 = 150.0
JOIN 之后 SUM = 250.0 参与求和的行数 = 3
连接后的明细(看 100 出现了几次):
(1, 100.0, 'vip')
(1, 100.0, 'promo')
(2, 50.0, 'new')
修法① EXISTS(只问在不在,不带行进来) = 150.0
修法② 先把右表聚合成一行一单再连 = 150.0
修法③ 对左表主键去重后再求和 = 150.0
⚠️ 体检:右表每个键平均带来几行 = 1.5
病因
⭐ 回到 01 章那句话:「现在一行代表什么?」
连接之前,orders 的一行代表一张订单。连接之后,一行代表一个 (订单, 标签) 配对。
100.0 这个金额在结果里物理上出现了两次,SUM 老老实实把它加了两次 —— 它没做错任何事,
是你把「按订单求和」的问题喂给了一张「按配对」的表。
100 + 100 + 50 = 250 ← SUM 看到的
100 + 50 = 150 ← 你想要的
💀 这个错误的全部危险都在「不报错」三个字上。
它不会抛异常、不会返回 NULL、不会给出负数。它给你一个看起来完全合理的、只是偏大的数。
⚠️ 尤其可怕的是偏大的比例不固定 —— 它等于「平均每张单有几个标签」,
今天 1.5 倍,下个月运营多打了一批标签就变成 2.3 倍,趋势图上你只会看到「增长」。
三种修法,怎么选
| 修法 | 写法 | 什么时候用 |
|---|---|---|
⭐ ① EXISTS |
WHERE EXISTS (SELECT 1 FROM tags t WHERE t.order_id=o.id) |
只是想按右表做筛选、右表的列一个也不要。⭐ 首选,语义最清楚 |
| ⭐ ② 先聚合再连 | JOIN (SELECT order_id, COUNT(*) n FROM tags GROUP BY order_id) t ON … |
右表的信息要用(要标签数、要最后一次时间)。先把右表压成「一个键一行」,再连 |
③ DISTINCT 去重 |
SELECT SUM(amount) FROM (SELECT DISTINCT o.id, o.amount FROM …) |
⚠️ 能用但别当默认:DISTINCT 必须带上左表主键才对,而且两张单金额都是 100 时靠 id 才没被合掉 |
⚠️ 第③种最常被误用成 SELECT SUM(DISTINCT amount) —— 那是「把重复的金额去掉」,
两张真实订单都是 100 元时会少算一张。去重要按主键去重,不能按值去重。
事前体检:一行 SQL 判断会不会扇出
⭐ 连之前先量一下右表:同一个连接键平均对应几行。
SELECT COUNT(*) * 1.0 / COUNT(DISTINCT order_id) FROM tags;
实跑是 1.5 —— 只要这个数大于 1,JOIN 就会扇出,你后面的聚合就必须处理它。
等于 1 就是安全的一对一(或一对零一)。
⚠️ 1.5 这个平均值还会骗人:真正决定风险的是最大值,因为长尾那几个键会把误差集中放大:
SELECT MAX(n) FROM (SELECT order_id, COUNT(*) AS n FROM tags GROUP BY order_id);
🛑 读到这里可以停 —— 前半章讲完了(约 35 分钟)。 后半章还有:连接键的基数,决定结果的行数 · 自连接:把「同一张表的两行」摆到一行上 · 四个安静的语义坑 回来的时候不用重读,直接从下一节接着看就行。
📋 四、连接键的基数,决定结果的行数
在写 JOIN 之前,先回答一句话:「右表里,同一个连接键最多有几行?」
| 关系 | 结果行数 | 需要小心吗 |
|---|---|---|
| 一对一(用户 ↔ 用户画像) | 不变 | 安全 |
| 一对零一(用户 ↔ 可选的实名信息) | INNER 会变少,LEFT 不变 |
⚠️ 会不会掉行,看你用哪种 |
| 一对多(订单 ↔ 标签 / 会话 ↔ 消息) | 放大 | 💀 扇出的产地 |
| 多对多(文章 ↔ 标签,中间表) | 乘法放大 | 💀💀 最贵的一种 |
⚠️ 多路 JOIN 是相乘,不是相加
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders(id INTEGER PRIMARY KEY, amount REAL)")
db.execute("CREATE TABLE tags (order_id INT, tag TEXT)")
db.execute("CREATE TABLE items (order_id INT, sku TEXT)")
db.executemany("INSERT INTO orders VALUES(?,?)", [(1, 100.0), (2, 50.0)])
db.executemany("INSERT INTO tags VALUES(?,?)", [(1, "vip"), (1, "promo"), (2, "new")])
db.executemany("INSERT INTO items VALUES(?,?)",
[(1, "A"), (1, "B"), (1, "C"), (2, "D")])
print("orders 2 行, tags 3 行, items 4 行")
print("orders JOIN tags ->", db.execute(
"SELECT COUNT(*) FROM orders o JOIN tags t ON t.order_id=o.id").fetchone()[0], "行")
print("orders JOIN items ->", db.execute(
"SELECT COUNT(*) FROM orders o JOIN items i ON i.order_id=o.id").fetchone()[0], "行")
print("orders JOIN tags JOIN items ->", db.execute(
"SELECT COUNT(*) FROM orders o JOIN tags t ON t.order_id=o.id "
"JOIN items i ON i.order_id=o.id").fetchone()[0], "行 ⚠️ 是相乘不是相加")
print("此时 SUM(amount) =", db.execute(
"SELECT SUM(o.amount) FROM orders o JOIN tags t ON t.order_id=o.id "
"JOIN items i ON i.order_id=o.id").fetchone()[0], " 真实值 150.0")
实跑输出:
orders 2 行, tags 3 行, items 4 行
orders JOIN tags -> 3 行
orders JOIN items -> 4 行
orders JOIN tags JOIN items -> 7 行 ⚠️ 是相乘不是相加
此时 SUM(amount) = 650.0 真实值 150.0
⭐ 1 号单:2 个标签 × 3 个商品 = 6 行;2 号单:1 × 1 = 1 行。合计 7 行,
于是 100 被加了六次、50 被加了一次 → 650,真实值的 4.3 倍。
💀 两张一对多的表连在同一个主表上,误差是乘出来的,不是加出来的。 ⚠️ 而这正是「订单 + 商品明细 + 优惠券」这种最常见的报表结构。 ⭐ 正确做法是各自先聚合成一行一单,再分别连过来(第三节的修法②),或者干脆写成两条查询。
🧯 五、自连接:把「同一张表的两行」摆到一行上
表连自己没有任何特殊之处,只是必须起别名(否则数据库分不清 id 是哪个 id)。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE runs(id INT, model TEXT, acc REAL, dt TEXT)")
db.executemany("INSERT INTO runs VALUES(?,?,?,?)", [
(1, "m-a", 0.81, "2026-08-01"),
(2, "m-a", 0.83, "2026-08-02"),
(3, "m-a", 0.79, "2026-08-03"),
(4, "m-b", 0.90, "2026-08-01"),
(5, "m-b", 0.88, "2026-08-02"),
])
# 自连接:把「同一个模型的相邻两天」摆在同一行上
print("相邻两天的差:")
for r in db.execute("""
SELECT a.model, a.dt, a.acc, b.acc, ROUND(b.acc - a.acc, 4)
FROM runs a
JOIN runs b ON b.model = a.model AND b.id = a.id + 1
ORDER BY a.model, a.dt"""):
print(" ", r)
# 自连接的另一个用法:找「同一天里比自己好的行」
print()
print("每个模型的最好一次(自连接反证法):")
for r in db.execute("""
SELECT a.model, a.dt, a.acc
FROM runs a
LEFT JOIN runs b ON b.model = a.model AND b.acc > a.acc
WHERE b.id IS NULL"""):
print(" ", r)
实跑输出:
相邻两天的差:
('m-a', '2026-08-01', 0.81, 0.83, 0.02)
('m-a', '2026-08-02', 0.83, 0.79, -0.04)
('m-b', '2026-08-01', 0.9, 0.88, -0.02)
每个模型的最好一次(自连接反证法):
('m-a', '2026-08-02', 0.83)
('m-b', '2026-08-01', 0.9)
⭐ 第二个查询是「反证法」的标准形状:「找不到比我更好的,我就是最好的」——
LEFT JOIN 一个「比我大」的条件,然后 WHERE b.id IS NULL(第二节那个合法例外)。
⚠️ 但请注意第一个查询的做法很脆:它靠 b.id = a.id + 1 假设 id 连续。
删掉一行,链就断了。
⭐ 「和上一行比」这件事的正确工具是窗口函数 LAG(),那是 07 章的正题 ——
本章演示自连接是为了让你看清窗口函数在替你做什么,不是推荐这么写。
🔎 六、四个安静的语义坑
① USING(col) 是 ON a.col = b.col 的简写,但它会把那一列合并成一列。
SELECT * 时 ON 版给你两个 order_id,USING 版只给一个。⭐ 两边列名一样时 USING 更干净。
② NATURAL JOIN 不要用。 它按「所有同名列」自动连接 ——
💀 哪天有人给两张表都加了一个 updated_at,你的连接条件就悄悄多了一条,行数当场不对,而 SQL 一个字没改。
③ 连接键类型不一致,会安静地连不上。
import sqlite3
db2 = sqlite3.connect(":memory:")
db2.execute("CREATE TABLE a(k)") # 无类型声明,存什么是什么
db2.execute("CREATE TABLE b(k)")
db2.executemany("INSERT INTO a VALUES(?)", [(1,), (2,)])
db2.executemany("INSERT INTO b VALUES(?)", [("1",), (2,)])
print("a JOIN b ON a.k=b.k ->", db2.execute(
"SELECT a.k, b.k FROM a JOIN b ON a.k=b.k").fetchall())
print("CAST 之后 ->", db2.execute(
"SELECT a.k, b.k FROM a JOIN b ON CAST(a.k AS TEXT)=CAST(b.k AS TEXT)").fetchall())
实跑输出:
a JOIN b ON a.k=b.k -> [(2, 2)]
CAST 之后 -> [(1, '1'), (2, 2)]
⚠️ 字符串 '1' 和整数 1 连不上,结果少一半,不报错。
真实场景:一边的 id 是从 CSV / JSON 导进来的字符串,另一边是自增整数。
(MySQL 相反 —— 它会隐式转换然后连上,⚠️ 代价是索引失效,09 章会讲。)
④ 连接键是 NULL 时永远连不上,哪怕两边都是 NULL。
import sqlite3
db3 = sqlite3.connect(":memory:")
db3.execute("CREATE TABLE x(k, v)")
db3.execute("CREATE TABLE y(k, w)")
db3.executemany("INSERT INTO x VALUES(?,?)", [(1, "x1"), (None, "x-null")])
db3.executemany("INSERT INTO y VALUES(?,?)", [(1, "y1"), (None, "y-null")])
print("INNER JOIN ->", db3.execute("SELECT x.v, y.w FROM x JOIN y ON x.k=y.k").fetchall())
print("LEFT JOIN ->", db3.execute("SELECT x.v, y.w FROM x LEFT JOIN y ON x.k=y.k").fetchall())
print("IS 而不是 = ->", db3.execute("SELECT x.v, y.w FROM x JOIN y ON x.k IS y.k").fetchall())
实跑输出:
INNER JOIN -> [('x1', 'y1')]
LEFT JOIN -> [('x1', 'y1'), ('x-null', None)]
IS 而不是 = -> [('x1', 'y1'), ('x-null', 'y-null')]
⭐ NULL = NULL 判不出真(04 章的正题),所以两个 NULL 键配不上对。
⚠️ 这在多数时候是你想要的(「未知」和「未知」当然不该算同一个人),
但如果你的连接键真的允许 NULL 并且希望它们相等,SQLite 用 IS、Postgres 用 IS NOT DISTINCT FROM。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 01-一条查询是怎么跑的.html | ⭐ 第二节 ON / WHERE 的区别,直接就是那一章八步表的推论:ON 在第①步,WHERE 在第②步 |
| 03-聚合与分组.html | 扇出之所以致命,是因为下一步几乎总是聚合。那一章讲聚合本身怎么算,两章合起来才是完整的账 |
| 04-NULL与三值逻辑.html | ⭐ 本章两处依赖它:LEFT JOIN 补出来的 NULL 会让 WHERE 整行落空;连接键是 NULL 时永远配不上对 |
| 05-子查询的三种形状.html | 扇出的首选修法 EXISTS 在那一章展开:IN / EXISTS / JOIN 到底该怎么选 |
| 07-窗口函数.html | 第五节那个「和上一行比」的自连接很脆(假设 id 连续);LAG() 才是它的正确工具 |
| 09-计划里的连接与索引失效.html | 本章只讲连接的语义;连接的顺序和代价、以及 CAST 为什么会毁掉索引,在那一章 |
| AI 全栈 · 07 向量检索落地 | 站内唯一一条真实业务里的多表 JOIN:过滤条件 archived 在 documents 上、向量在 chunks 上,⭐ 关系库能直接连过去,正是那一章选它的理由 |
✅ 检查点
- 三个用户、四张单(含一张孤儿单、一个没下过单的人)那组实测里,五种连接各返回几行?每个数字怎么来的?
- 同一个
status='paid'条件,写在LEFT JOIN的ON里和WHERE里,实跑分别返回几行?为什么? - 那条判据是什么?为什么这个坑常常是「改一个词改出来的」?
LEFT JOIN … WHERE 右表列 IS NULL为什么是合法用法而不是同一个坑?- 扇出那组实测里,真实总额是多少,
JOIN之后SUM是多少,参与求和的是几行?用一行算式说明差在哪。 - 为什么说扇出「偏大的比例不固定」?它等于什么?
- 三种修法各在什么时候用?为什么
SUM(DISTINCT amount)是错的? - 连之前怎么一行 SQL 判断会不会扇出?实测那个数是多少?为什么还要看最大值?
- 两张一对多的表连在同一个主表上,实跑的行数和
SUM是多少?和真实值差几倍? - 为什么
NATURAL JOIN不该用?连接键类型不一致时 SQLite 和 MySQL 的表现有什么不同?
👀 答案
INNER3(3 张单配上了人,孤儿单掉了)、LEFT4(3 + 阿花补NULL那行)、RIGHT4(3 + 孤儿单)、FULL5(3 + 阿花 + 孤儿单)、CROSS12(3 × 4,不配对)。- 写在
ON里 3 行(阿呆有 paid 单,阿瓜和阿花右边补NULL,行还在); 写在WHERE里 1 行,和INNER JOIN + WHERE输出一模一样。 因为ON在第①步配对时生效,WHERE在第②步逐行筛 —— 补出来的NULL让NULL='paid'判不出真,整行被扔。 LEFT JOIN时右表条件写ON,左表条件写WHERE;右表条件进了WHERE,LEFT JOIN就退化成INNER JOIN。 💀 常见来路:本来是INNER JOIN(条件写哪儿都一样),后来需求变成「没单的人也要出现」, 把INNER改成LEFT—— 只改了一个词,WHERE忘了动,报表还是少人且不报错。- 因为它是故意利用这个退化行为做「反连接」:先
LEFT JOIN让没配上的补NULL, 再WHERE 右表列 IS NULL把这些「右边没有」的挑出来。实跑返回('阿花',)。 - 真实 150.0,
JOIN之后 250.0,参与求和 3 行。100 + 100 + 50 = 250(100 出现两次)而不是100 + 50 = 150—— 因为一行不再代表一张订单,而是一个(订单, 标签)配对。 - 因为放大倍数 = 平均每张单有几个标签。今天 1.5 倍,运营多打一批标签就变 2.3 倍, ⚠️ 趋势图上只会看到「增长」。
EXISTS—— 只想按右表筛选、右表的列一个不要(首选);先聚合再连 —— 右表的信息要用(标签数、最后时间);DISTINCT去重 —— 能用但别当默认,必须带上左表主键。 ⚠️SUM(DISTINCT amount)是「把重复的金额去掉」,两张真实订单都是 100 元时会少算一张 —— 要按主键去重,不能按值去重。SELECT COUNT(*)*1.0/COUNT(DISTINCT order_id) FROM tags,实测 1.5。大于 1 就会扇出。 还要看MAX(n),因为长尾那几个键会把误差集中放大,平均值看不出来。orders(2) JOIN tags(3) JOIN items(4)→ 7 行(1 号单 2 × 3 = 6,2 号单 1 × 1 = 1),SUM(amount)= 650.0,是真实值 150 的 4.3 倍。💀 误差是乘出来的,不是加出来的。NATURAL JOIN按所有同名列自动连接 —— 哪天有人给两张表都加了updated_at, 连接条件就悄悄多一条、行数当场不对,而 SQL 一个字没改。 类型不一致时 SQLite 连不上('1'vs1,实跑只剩[(2, 2)],不报错、结果少一半); MySQL 会隐式转换连上,⚠️ 代价是索引失效。
🛑 可以停在这里
⚡ 走神救援
⭐
JOIN不是「拼表」,是按条件配对,配出来的行数可能比任何一张原表都多。⭐ 第一条要背的判据:
LEFT JOIN时,针对右表的条件写ON,针对左表的条件写WHERE。 同一个条件写错位置,配不上的那些行就整批消失——LEFT JOIN当场退化成INNER。💀 这个坑常常是「改一个词改出来的」:本来是INNER(条件写哪儿都一样),需求变成「没单的人也要出现」就改成LEFT,而WHERE忘了动,报表少人且不报错。⭐ 唯一的例外是配上IS NULL做反连接,那是故意用这个退化。💀💀 本章的正题是扇出:顺手连一下标签表,求和就变大了——因为一行不再代表一张订单,而是一个「订单加标签」的配对。⚠️ 危险全在「不报错」三个字上:不抛异常、不返回空值,只给一个看起来合理、只是偏大的数;而且放大倍数不固定,趋势图上只会显示「增长」。
三种修法:⭐
EXISTS只筛不取列(首选)、⭐ 先把右表聚合成一行一键再连、DISTINCT按主键去重(别当默认)。⚠️ 按值去重是错的——两张真实订单金额相同会少算一张。事前体检一行 SQL 就够:总行数除以去重主键数,大于 1 就会扇出;⚠️ 还要看最大值,长尾那几个键会集中放大。💀 多路
JOIN是相乘不是相加——而「订单加明细加优惠券」正是最常见的报表结构。另外四个安静的坑:
USING会合并同名列;💀NATURAL JOIN不要用(有人加个时间戳字段,连接条件就悄悄多一条);⚠️ 连接键类型不一致时,有的库少连一半且不报错,有的库隐式转换但索引失效;连接键是 NULL 永远配不上对。
下一节 👉 03-聚合与分组.md