AI编程(五十四):多表关联 — JOIN,把两张表”对上号”拼成一张

数据终于多到一张表装不下了:商品信息一张表、价格记录一张表。想看”每个商品的当前价,连着它的品牌”,就得让两张表手拉手——这就是今天的主角 JOIN。三种牵手方式、”对不上号”的行去哪了,还有那个著名的”关联之后行数暴增”之谜,一次讲透。

一、真实场景:一张表装不下的时候

接着价格监控工具的故事讲。数据库用顺手之后,你有了新想法:给每个商品记上品牌和品类——”机械键盘是樱桃的、显示器是戴尔的”,以后好按品牌看价格。

问题来了:这些信息往哪记?

下策:塞进 prices 表。每条价格记录后面补两列 brand、category。看着省事,其实是个坑:机械键盘查了 4 次价,”樱桃、外设”就重复存了 4 遍;哪天品牌名想从”樱桃”改成”Cherry”,得改 4 行,漏一行数据就自相矛盾了。

上策:单开一张 products 表。商品信息只存一份:

id name brand category
1 机械键盘 樱桃 外设
2 无线鼠标 罗技 外设
… … … …

价格记录还住 prices 表,商品信息住 products 表,各记各的,谁也不重复。于是今天的问题自然浮出来:“每个商品的当前价 + 品牌”这个答案,一半在 prices 表、一半在 products 表,怎么拼成一张?

答案就是 JOIN(关联):让两张表按”对上号”的规则手拉手,拼成一张结果表。”对上号”三个字是核心——机械键盘的价格记录,要跟 products 里”机械键盘”那一行对上号,品牌”樱桃”才能贴到它头上。

好消息:JOIN 只是”查询时临时拼表”,两张原表纹丝不动,拼错大不了重查。坏消息:JOIN 有两个非常经典的坑——对不上号的行悄悄消失、关联之后行数莫名暴增。今天一起把疫苗打了。

二、先备好料:两张表的练习场

换一个新数据库 shop.db,装两张表:products(5 个商品)+ prices(沿用上一篇的 20 条价格记录):

# setup.py — 两张表的练习场
import sqlite3

conn = sqlite3.connect("shop.db")
cur = conn.cursor()

cur.execute("""
CREATE TABLE IF NOT EXISTS products (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    name     TEXT NOT NULL,
    brand    TEXT NOT NULL,
    category TEXT NOT NULL
)
""")

cur.execute("""
CREATE TABLE IF NOT EXISTS prices (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    product    TEXT    NOT NULL,
    price      REAL    NOT NULL,
    checked_at TEXT    NOT NULL
)
""")

cur.execute("DELETE FROM products WHERE id > 0")
cur.execute("DELETE FROM prices WHERE id > 0")

products = [
    ("机械键盘",   "樱桃",     "外设"),
    ("无线鼠标",   "罗技",     "外设"),
    ("显示器",     "戴尔",     "屏幕"),
    ("耳机",       "索尼",     "音频"),
    ("人体工学椅", "赫曼米勒", "家具"),
]
cur.executemany(
    "INSERT INTO products (name, brand, category) VALUES (?, ?, ?)", products
)

prices = [
    ("机械键盘",   399.0, "2026-10-08 09:00"),
    ("机械键盘",   359.0, "2026-10-15 09:00"),
    ("机械键盘",   329.0, "2026-10-22 09:00"),
    ("机械键盘",   299.0, "2026-10-25 09:00"),
    ("无线鼠标",   129.0, "2026-10-08 09:00"),
    ("无线鼠标",   119.0, "2026-10-15 09:00"),
    ("无线鼠标",    99.0, "2026-10-22 09:00"),
    ("无线鼠标",   109.0, "2026-10-29 09:00"),
    ("显示器",    1299.0, "2026-10-08 09:00"),
    ("显示器",    1199.0, "2026-10-15 09:00"),
    ("显示器",    1099.0, "2026-10-22 09:00"),
    ("显示器",     899.0, "2026-10-29 09:00"),
    ("耳机",       199.0, "2026-10-08 09:00"),
    ("耳机",       189.0, "2026-10-15 09:00"),
    ("耳机",       199.0, "2026-10-22 09:00"),
    ("耳机",       179.0, "2026-10-29 09:00"),
    ("人体工学椅", 1899.0, "2026-10-08 09:00"),
    ("人体工学椅", 1799.0, "2026-10-15 09:00"),
    ("人体工学椅", 1699.0, "2026-10-22 09:00"),
    ("人体工学椅", 1599.0, "2026-10-29 09:00"),
]
cur.executemany(
    "INSERT INTO prices (product, price, checked_at) VALUES (?, ?, ?)", prices
)

conn.commit()
conn.close()
print("两张表就位:5 个商品 + 20 条价格记录")

跑完,注意两张表”对上号”的暗号是什么:prices.product 和 products.name 存的是同一个商品名。”机械键盘” == “机械键盘”,一个字都不能差——这就是今天所有 JOIN 的牵手依据,后面坑点环节会拿它做文章。

三、JOIN:让两张表”对上号”

语法先看骨架:

SELECT 列 FROM 表A
JOIN 表B ON 表A.某列 = 表B.某列

翻译成人话:”从表A 出发,每一行都去表B 找’暗号相同’的那行,找到就把两边拼成一行给我。”ON 后面就是”对上号”的规则。

第一个需求:每条价格记录,带上品牌。20 条价格记录,每条都要贴上自家商品的品牌:

# join_basic.py — 每条价格记录带上品牌
import sqlite3

conn = sqlite3.connect("shop.db")
cur = conn.cursor()

cur.execute("""
    SELECT prices.product, products.brand, prices.price, prices.checked_at
    FROM prices
    JOIN products ON prices.product = products.name
    ORDER BY prices.checked_at DESC, prices.id DESC
""")
for row in cur.fetchall():
    print(row)
conn.close()

输出(前 5 条):

('人体工学椅', '赫曼米勒', 1599.0, '2026-10-29 09:00')
('耳机', '索尼', 179.0, '2026-10-29 09:00')
('显示器', '戴尔', 899.0, '2026-10-29 09:00')
('无线鼠标', '罗技', 109.0, '2026-10-29 09:00')
('机械键盘', '樱桃', 299.0, '2026-10-25 09:00')

(同一个时间点有多条记录时,第二排序键 id DESC 决定了椅子、耳机、显示器、鼠标的先后——上一篇”排序钉死到唯一”的纪律在这里悄悄生效。)

拆开看这个 JOIN 干了什么:prices 的第 1 行是”机械键盘 399″,拿着”机械键盘”去 products 找,对上 id=1 那行,于是”樱桃”被拼到这行右边;20 条价格记录每条都这么走一遍,结果还是 20 行——每行只是”变宽了”(多了 brand 列),行数没变。这个”行数没变”能成立,全靠 products 里每个商品只有一行;反过来会怎样,第五节见分晓。

给表起小名(别名)。表名写全太啰嗦,SQL 允许给表起个小名,之后用小名点列:

cur.execute("""
    SELECT p.product, pr.brand, p.price
    FROM prices p
    JOIN products pr ON p.product = pr.name
    ORDER BY p.price ASC
""")

FROM prices p 就是”prices 表,小名叫 p”,后面 p.product 即 prices.product。小名只是省字,不影响任何结果——一眼能看懂就用,看不懂就写全名,别硬凑。

两个需求一起查:品牌 + 品类 + 当前价。上一篇学的”每商品最新一条”照旧出场,只是 SELECT 里多了 products 的列:

# current_price.py — 每个商品的当前价,连着品牌和品类
import sqlite3

conn = sqlite3.connect("shop.db")
cur = conn.cursor()

cur.execute("""
    SELECT p.product, pr.brand, pr.category, p.price, p.checked_at
    FROM prices p
    JOIN products pr ON p.product = pr.name
    WHERE p.checked_at = (
        SELECT MAX(checked_at) FROM prices WHERE product = p.product
    )
    ORDER BY p.product
""")
for row in cur.fetchall():
    print(row)
conn.close()

输出:

('人体工学椅', '赫曼米勒', '家具', 1599.0, '2026-10-29 09:00')
('无线鼠标', '罗技', '外设', 109.0, '2026-10-29 09:00')
('显示器', '戴尔', '屏幕', 899.0, '2026-10-29 09:00')
('机械键盘', '樱桃', '外设', 299.0, '2026-10-25 09:00')
('耳机', '索尼', '音频', 179.0, '2026-10-29 09:00')

五个商品,各一行,价格、品牌、品类并排一站——这就是第一节开头你要的那张”答案拼图”。注意一个顺序细节:JOIN 负责拼表,WHERE 负责筛选,子查询的活儿一点没变,只是拼完表之后每一行”家底更厚了”。

四、”对不上号”的行去哪了:LEFT JOIN

先做个实验。往 prices 里塞一条products 里查无此货的记录——”显示器支架”:

# orphan.py — 塞一条"对不上号"的记录,看看 JOIN 怎么处理它
import sqlite3

conn = sqlite3.connect("shop.db")
cur = conn.cursor()
cur.execute("""
    INSERT INTO prices (product, price, checked_at)
    VALUES ('显示器支架', 159.0, '2026-10-30 09:00')
""")
conn.commit()

cur.execute("""
    SELECT p.product, pr.brand, p.price
    FROM prices p
    JOIN products pr ON p.product = pr.name
""")
rows = cur.fetchall()
print("JOIN 之后的行数:", len(rows))
print("包含显示器支架吗:", any(r[0] == "显示器支架" for r in rows))
conn.close()

输出:

JOIN 之后的行数: 20
包含显示器支架吗: False

明明有 21 条价格记录,JOIN 完只剩 20 行——“显示器支架”那行静悄悄地消失了。这就是普通 JOIN(全名 INNER JOIN,”内关联”)的规矩:只拼”两边都对上号”的行,对不上号的两边都不要。它不报错、不警告,少一行就少一行,第一次撞上的人还以为数据丢了。

如果你的规矩是”价格记录必须全留下,品牌查不到就空着“,换 LEFT JOIN(左关联):

# left_join.py — 左边的行一个都不许丢
import sqlite3

conn = sqlite3.connect("shop.db")
cur = conn.cursor()

cur.execute("""
    SELECT p.product, pr.brand, p.price
    FROM prices p
    LEFT JOIN products pr ON p.product = pr.name
    ORDER BY p.checked_at DESC, p.id DESC
""")
rows = cur.fetchall()
print("LEFT JOIN 之后的行数:", len(rows))
for row in rows[:3]:
    print(row)
conn.close()

输出:

LEFT JOIN 之后的行数: 21
('显示器支架', None, 159.0)
('人体工学椅', '赫曼米勒', 1599.0)
('耳机', '索尼', 179.0)

21 行全在。”显示器支架”对不上号,brand 的位置就填 None(空值 NULL)——”没找到”和”没这条”是两回事,LEFT JOIN 老老实实标了出来。记忆口诀一句话:LEFT JOIN = 左表全要,右表能对上就拼、对不上就留空(把 LEFT JOIN 的两张表想成左右站位,谁在 FROM 后面第一位谁是”左”)。

什么时候用哪个?看你的业务口径:

  • “只看有品牌信息的记录”(没品牌的不算数)→ JOIN;
  • “价格记录一条都不能少,品牌是附加信息”(做对账、查漏)→ LEFT JOIN。

五、那个著名的”关联之后行数暴增”之谜

第 3 节说过”20 行进去,20 行出来,只是变宽了”。现在来解密它为什么不是永远成立。

先看”正常暴增”:一对多。换一个方向 JOIN——从 products 出发去拼 prices:

cur.execute("""
    SELECT pr.name, p.price, p.checked_at
    FROM products pr
    JOIN prices p ON p.product = pr.name
""")
print(len(cur.fetchall()))

5 个商品进去,出来 20 行。这不是事故:每个商品有 4 条价格记录,拼的时候”机械键盘”那一行要跟它名下 4 条价格各拼一次,自然是 5 × 4 = 20。JOIN 后的一行 = 两边各贡献半行,一边一对多,行数就跟着乘。看结果前先问自己一句”这一行代表什么“——现在的一行是”一条价格记录(带品牌)”,不再是”一个商品”,行数变多天经地义。

再看”事故暴增”:多对多。这才是真的坑。假如 products 表录重了——”机械键盘”进了两条(一条品牌还写错了):

cur.execute("""
    INSERT INTO products (name, brand, category)
    VALUES ('机械键盘', '樱桃(录重了)', '外设')
""")
conn.commit()

cur.execute("""
    SELECT p.product, p.price, pr.brand
    FROM prices p
    JOIN products pr ON p.product = pr.name
""")
rows = cur.fetchall()
print("行数:", len(rows))          # 20 变 24
print("299 那条出现了几次:",
      sum(1 for r in rows if r[1] == 299.0))

输出:

行数: 24
299 那条出现了几次: 2

能对上号的 20 条价格记录进去,24 行出来——”机械键盘”的 4 条价格各 ×2(4 行变 8 行),加上其他商品的 16 行,8 + 16 = 24。事故暴增的特征很明显:总数悄悄变多,某些行莫名其妙出现两次,而且 JOIN 从不报错。自查三板斧:

  1. 先数行:JOIN 前后各 SELECT COUNT(*),行数变化是否符合”一对多”的预期;
  2. 查暗号列有没有重:SELECT name, COUNT(*) FROM products GROUP BY name HAVING COUNT(*) > 1(COUNT 就是数数,先会用就行);
  3. 对不上号的一对一约束:这正是”正经设计里价格表存 product_id(products.id)而不是商品名”的原因——id 是唯一的主键,从根上杜绝”两个机械键盘”。今天用名字当暗号是为了零基础好懂,混个眼熟:真项目里 JOIN 的暗号通常是 id,叫”外键”,它的故事后面单独讲。

六、三个必踩的坑(先打疫苗)

坑1:ON 忘写或写错,行数直接”爆炸”。忘写 ON(FROM prices p JOIN products pr 后面直接接 SELECT)多数数据库直接报语法错误,还算友好;更阴的是 ON 的暗号写错了,比如手滑写成 ON p.product = pr.brand——商品名跟品牌永远对不上号,普通 JOIN 返回 0 行(你可能以为没数据),要是换成 LEFT JOIN 加上右表全空的场景,或者 ON 条件写得松松垮垮(比如 ON p.price >= pr.id 这种”条件关联”),就会拼出笛卡尔积:两边每一行都互相拼一遍,5 × 20 = 100 行瞬间糊脸。疫苗一句话:ON 后面必须是”两张表的暗号列相等”,写完盯着它读一遍:”prices 的商品名,等于 products 的商品名”,读不通就是写错了。

坑2:两张表有同名列,不写表名(小名)就是不行。假设 products 也有一列叫 product(商品名),那 SELECT product FROM prices JOIN products ... 就出事了——数据库会问”你要哪个表的 product?”,SQLite 直接报 ambiguous column name。疫苗一句话:JOIN 查询里,SELECT 和 ON 的列名前面永远带上表名(或小名),p.product、pr.brand——多打三个字符,换整条查询无歧义。

坑3:NULL 不等于 NULL,孤儿行永远”对不上号”。第四节的”显示器支架”品牌是 NULL。注意两个诡异事实:① WHERE pr.brand = NULL 永远查不到任何行(NULL 表示”没有值”,连”等于自己”都不成立),判断空值必须写 IS NULL / IS NOT NULL;② 如果哪天 products 表里商品名是 NULL,那行永远不会跟任何价格对上号——ON p.product = pr.name 里 name 是 NULL 时,等号永远不成立。疫苗一句话:JOIN 的暗号列两边都不许有 NULL,查”对不上号/没有关联记录”的行用 IS NULL:

# 找出所有"没有品牌信息"的价格记录(对不上号的孤儿)
cur.execute("""
    SELECT p.product, p.price
    FROM prices p
    LEFT JOIN products pr ON p.product = pr.name
    WHERE pr.id IS NULL
""")

用”右表的主键 IS NULL”来判定”没对上号”,是 SQL 世界的经典惯用招——比判断 brand IS NULL 更可靠(万一品牌列本身允许为空呢)。

七、跟AI协作的正确姿势(多表关联特供版)

  1. 需求里把”三要素”说清:哪两张表、按什么对上号、对不上的要不要。“把价格表和商品表按商品名关联,价格记录一条都不能少,品牌查不到就空着”——三要素齐了,AI 直接产出 LEFT JOIN,一次到位。只说”把两张表拼一下”,AI 猜不出来你要 JOIN 还是 LEFT JOIN、按名字还是按 id。
  2. 让 AI 显式写 ON,验收时数行数。拿到 JOIN 代码,先检查 ON 有没有、暗号对不对,然后自己跑一遍 COUNT(*):”20 条价格进去,出来多少行?符合一对多的预期吗?”行数对不上,先怀疑 ON 写错或暗号列有重复,再怀疑别的。
  3. 行数莫名暴增时,让 AI”先查重复再下结论”。把症状描述给它:”prices 20 行,JOIN 后 22 行”,并附上两张表的建表语句和几条样例数据,让它先写”暗号列查重”的 SQL。常见答案就是”你 products 里录重了一个商品”。
  4. 带结构、带样例、带期望行数的报障,一次定位。“JOIN 结果不对”是差需求;”prices 表 20 行(product, price, checked_at),products 表 5 行(name, brand, category),按商品名 JOIN 期望 20 行实得 22 行,样例数据如下”是好需求。表结构 + 样例 + 期望 vs 实际,三样贴齐。

八、今日小练习

练习1:拼出”品牌价格榜”。用练习场的两张表,写一句 JOIN 查询:按品牌分组前先看个简单的——查出”每条价格记录 + 品牌 + 品类”,按价格从高到低排,列出前 5 条。再进阶一步:只查”外设”品类的价格记录(JOIN + WHERE 组合)。

提示:SELECT 里三个列分别来自两张表,列名前带小名;进阶那问把品类过滤放在 WHERE 里——JOIN 负责拼,WHERE 负责筛,别写进 ON。

练习2:LEFT JOIN 查孤儿。先往 prices 里自造两条”products 里没有”的商品记录,然后用第六节坑 3 的惯用招,查出所有”没有品牌信息”的价格记录(商品名 + 价格),最后把孤儿的商品名去重列出来(想想去重的关键词是哪个——第 51 篇 SELECT 里见过它)。

提示:LEFT JOIN 之后用 WHERE pr.id IS NULL;去重用 DISTINCT,SELECT DISTINCT p.product FROM ...。

练习3(坑点自考):先诊断,再对答案。下面每段都有毛病,不看文章说出病在哪、怎么改:①需求是”每条价格记录都要显示,哪怕没有品牌”,AI 给的是 SELECT * FROM prices p JOIN products pr ON p.product = pr.name,结果少了一条”显示器支架”;②products 表后来加了一列 product(冗余存了商品名),查询写成 SELECT product FROM prices p JOIN products pr ON p.product = pr.product,报错 ambiguous column name: product;③想查”品牌还没录的商品”,写了 SELECT * FROM products WHERE brand = NULL,结果一行都查不出,但表里明明有个商品的 brand 是空的。

提示:①一种 JOIN 只管一种规矩,”都要”换另一种;②两张表都有 product 这个列名,SELECT 里的 product 没报家门,数据库不知道你要哪个表的——用小名限定;③NULL 的世界里等号不好使,有专门的判词。

下篇预告

下一篇(第55篇):Python+SQLite — 把 sqlite3 模块的家伙事儿认全。这几篇的代码里,connect、cursor、execute、commit 已经用得飞起,但它们到底是干嘛的、为什么要 commit、忘了 close 会怎样、SQL 语句里拼字符串有什么危险(SQL 注入),都还没有系统交代。下一篇把 sqlite3 模块从头到尾理一遍,顺手打上”防注入”的疫苗——从此你写的数据库代码,才算真正上了”正经软件”的秤。