🏠 总目录📚 本教程 02 · JOIN 的几种语义 ← →
📑 本页目录(点开跳转)

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 上,⭐ 关系库能直接连过去,正是那一章选它的理由

✅ 检查点

  1. 三个用户、四张单(含一张孤儿单、一个没下过单的人)那组实测里,五种连接各返回几行?每个数字怎么来的?
  2. 同一个 status='paid' 条件,写在 LEFT JOIN 的 ON 里和 WHERE 里,实跑分别返回几行?为什么?
  3. 那条判据是什么?为什么这个坑常常是「改一个词改出来的」?
  4. LEFT JOIN … WHERE 右表列 IS NULL 为什么是合法用法而不是同一个坑?
  5. 扇出那组实测里,真实总额是多少,JOIN 之后 SUM 是多少,参与求和的是几行?用一行算式说明差在哪。
  6. 为什么说扇出「偏大的比例不固定」?它等于什么?
  7. 三种修法各在什么时候用?为什么 SUM(DISTINCT amount) 是错的?
  8. 连之前怎么一行 SQL 判断会不会扇出?实测那个数是多少?为什么还要看最大值?
  9. 两张一对多的表连在同一个主表上,实跑的行数和 SUM 是多少?和真实值差几倍?
  10. 为什么 NATURAL JOIN 不该用?连接键类型不一致时 SQLite 和 MySQL 的表现有什么不同?
👀 答案
  1. INNER 3(3 张单配上了人,孤儿单掉了)、LEFT 4(3 + 阿花补 NULL 那行)、 RIGHT 4(3 + 孤儿单)、FULL 5(3 + 阿花 + 孤儿单)、CROSS 12(3 × 4,不配对)。
  2. 写在 ON 里 3 行(阿呆有 paid 单,阿瓜和阿花右边补 NULL,行还在); 写在 WHERE 里 1 行,和 INNER JOIN + WHERE 输出一模一样。 因为 ON 在第①步配对时生效,WHERE 在第②步逐行筛 —— 补出来的 NULL 让 NULL='paid' 判不出真,整行被扔。
  3. LEFT JOIN 时右表条件写 ON,左表条件写 WHERE;右表条件进了 WHERE,LEFT JOIN 就退化成 INNER JOIN。 💀 常见来路:本来是 INNER JOIN(条件写哪儿都一样),后来需求变成「没单的人也要出现」, 把 INNER 改成 LEFT —— 只改了一个词,WHERE 忘了动,报表还是少人且不报错。
  4. 因为它是故意利用这个退化行为做「反连接」:先 LEFT JOIN 让没配上的补 NULL, 再 WHERE 右表列 IS NULL 把这些「右边没有」的挑出来。实跑返回 ('阿花',)。
  5. 真实 150.0,JOIN 之后 250.0,参与求和 3 行。 100 + 100 + 50 = 250(100 出现两次)而不是 100 + 50 = 150 —— 因为一行不再代表一张订单,而是一个 (订单, 标签) 配对。
  6. 因为放大倍数 = 平均每张单有几个标签。今天 1.5 倍,运营多打一批标签就变 2.3 倍, ⚠️ 趋势图上只会看到「增长」。
  7. EXISTS —— 只想按右表筛选、右表的列一个不要(首选);先聚合再连 —— 右表的信息要用(标签数、最后时间); DISTINCT 去重 —— 能用但别当默认,必须带上左表主键。 ⚠️ SUM(DISTINCT amount) 是「把重复的金额去掉」,两张真实订单都是 100 元时会少算一张 —— 要按主键去重,不能按值去重。
  8. SELECT COUNT(*)*1.0/COUNT(DISTINCT order_id) FROM tags,实测 1.5。大于 1 就会扇出。 还要看 MAX(n),因为长尾那几个键会把误差集中放大,平均值看不出来。
  9. orders(2) JOIN tags(3) JOIN items(4) → 7 行(1 号单 2 × 3 = 6,2 号单 1 × 1 = 1), SUM(amount) = 650.0,是真实值 150 的 4.3 倍。💀 误差是乘出来的,不是加出来的。
  10. NATURAL JOIN 按所有同名列自动连接 —— 哪天有人给两张表都加了 updated_at, 连接条件就悄悄多一条、行数当场不对,而 SQL 一个字没改。 类型不一致时 SQLite 连不上('1' vs 1,实跑只剩 [(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

打卡记录保存在你的浏览器里,首页能看到总进度