📈 第12章 关系数据库
📄 一句话概括

路线是从问题倒推出来的:要什么、在哪个表、靠什么接,三步就出来了。 「封面主题 → 销量」的路线是:筛封面 → 用期号接 → 按主题平均。只有第二步涉及关系。 路线随问题变。换个问题,路线可能只在一张表内,也可能要绕三张表。

📝 本章和前面几章的关系

上一篇认清了表和键。这一篇讲怎么从一个问题走到一句查询。 书里这一段的核心动作是画关系图。这里不用图,用一张「路线表」把同一件事说清。

一、路线是从问题倒推出来的

问题:封面主题和销量是什么关系。先把它拆成「要什么」。 要的东西有两样:封面主题、当期销量。 封面主题在文章表里,而且只在那条 is_cover = 1 的记录上。

当期销量在期号表里。两边都有期号,桥就在这里。 路线不是设计出来的,是问出来的。问题一变,路线立刻变。

二、把路线写成三步

步 在哪张表 挑什么 拿到什么
1 文章表 封面那一条 每期的封面主题
2 两张表 期号相等 主题和销量对上
3 结果表 按主题求平均 每个主题一个数

三步里只有第 2 步涉及「关系」,第 1 步是筛选,第 3 步是汇总。 所以「关系数据库很难」这个印象有点偏:难的只有中间那一步。 筛选、连接、汇总——所有分析查询都是这三件事的组合。

三、先手工走一遍

在写 SQL 之前,先拿四期手工试。 第 3 期封面 AI,销量 58300;第 5 期 AI,61200;第 9 期 AI,59700;第 12 期 AI,62400。 四个数平均 60400。这就是「AI 封面平均销量」的手工版答案。

手工能走通,说明路线没错;路线错了,写多漂亮的 SQL 都是错的。 先手工,再自动化。这一步省掉,后面查错会多花十倍时间。

四、换个问题就换条路线

问「哪些期的文章篇数超过两篇」,只用文章表,一步就够。 问「哪个月销量最高」,只用期号表,也不用接。 问「作者和销量的关系」,得再加一张作者表,路线变成三张表。

同一个数据库,不同的问题走不同的路线。这就是「关系」两个字的实际含义。 设计数据库时能做的,是让常见问题走短路。走不通的路线,说明表还没拆到位。

五、关系的类型

从 到 靠什么 类型
文章表 期号表 期号 多对一
期号表 文章表 期号 一对多

一期对应多篇文章,所以从文章看是「多对一」,从期号看是「一对多」。同一件事的两个方向。 多对一这一侧要小心:一篇销量会被多篇文章共享,直接求和就会重复计算。 看到「一对多」,就该想到「汇总时要小心重复」。这是下一节的主题。

六、动手 Demo:从问题出发,找一条穿过各表的路线

不写 SQL,先用手工把路线走一遍:筛出封面文章,按主题分组,求平均销量。 再把这套手工逻辑和「只查一张表」的简单路线对比。

ISSUES = [
    (1, "2025-01", 52400, "EFF"), (2, "2025-02", 49100, "MONEY"),
    (3, "2025-03", 58300, "AI"), (4, "2025-04", 47800, "MONEY"),
    (5, "2025-05", 61200, "AI"), (6, "2025-06", 53600, "EFF"),
    (7, "2025-07", 45900, "CAREER"), (8, "2025-08", 46800, "CAREER"),
    (9, "2025-09", 59700, "AI"), (10, "2025-10", 55100, "EFF"),
    (11, "2025-11", 50300, "MONEY"), (12, "2025-12", 62400, "AI"),
]

# 第一步:封面主题其实就在期号表里,这里直接取
cover = [(issue, topic, copies) for issue, month, copies, topic in ISSUES]

# 第三步:按主题分组求平均
acc = {}
for issue, topic, copies in cover:
    acc.setdefault(topic, []).append(copies)
for topic in sorted(acc, key=lambda t: -sum(acc[t]) / len(acc[t])):
    v = acc[topic]
    print("%-6s %d 期  平均 %6.0f" % (topic, len(v), sum(v) / float(len(v))))

# 换个问题:只查期号表就能回答的
print()
print("不用接表:总期数 %d,总篇数 %d" % (len(ISSUES), 2 * len(ISSUES)))

实跑输出:

--------------------------------------------------------------------
从问题出发,找一条穿过各表的路线
--------------------------------------------------------------------

  ① 路线是从问题倒推出来的
  --------------------------------------------------------------------
  问题:封面主题 → 销量。这两个东西分别在哪张表?
  主题在文章表(而且只要 is_cover = 1 的那一行),销量在期号表。
  中间的桥是期号。所以路线是:文章表 →(期号)→ 期号表。

  ② 把路线画成三步
  --------------------------------------------------------------------

| 步 | 在哪张表 | 挑什么 | 拿到什么 |
|:---:|---|---|---|
| 1 | 文章表 | is_cover = 1 | 每期的封面主题 |
| 2 | 两张表 | 期号相等 | 主题和销量对上 |
| 3 | 结果表 | 按主题分组求平均 | 每个主题的平均销量 |

  三步里只有第 2 步涉及「关系」,第 1 步和第 3 步都是普通的筛选与汇总。

  ③ 第一条路线不够,还有第二条
  --------------------------------------------------------------------
  如果问题换成「哪种主题的文章字数多」,路线就变成:文章表内部就够,不用碰期号表。
  换成「哪个作者的文章带来销量」,就得加一张作者表,路线变成三条表。
  路线不是固定的,是随问题变的。这是关系数据库最容易被误解的一点。
  ! 数据库设计得好不好,就看能不能为常见的问题提供短路线。

  ④ 一张图说清有哪些关系
  --------------------------------------------------------------------

| 从 | 到 | 靠什么 | 关系类型 |
|---|---|---|---|
| 文章表 | 期号表 | 期号 | 多对一(一期多文) |
| 期号表 | 文章表 | 期号 | 一对多 |

  多对一意味着一篇销量会被多篇文章共享。这一点在摊平成表格时会变成麻烦。

  ⑤ 先手工走一遍
  --------------------------------------------------------------------
  在写 SQL 之前,先拿一期试:第 3 期封面是 AI,销量 58300。
  第 5 期也是 AI,61200。第 9 期 59700,第 12 期 62400。
  四期 AI 封面平均 60400。
  手工能走通,就说明路线是对的。先手工,再自动化,这一步不能省。

手工版只有两步——因为我把封面主题直接存进了期号表。真实设计里它属于文章表,接表那一步不能省。 这就是「路线取决于表怎么设计」。同一个问题,表设计不同,路线长短不同。

30 秒实验

  1. 如果封面主题存在文章表里,手工版要多写几步?
  2. 想回答「每期两篇文章的主题是否相同」,路线怎么走?
  3. 想一想:为什么说「数据库设计得好不好,看常见问题的路线短不短」?

常见误区

  1. 先写 SQL 再想问题:路线错了,写得再快也是白跑。
  2. 把所有表都 JOIN 一遍:不需要的表接上只会引入重复行和更慢的查询。
  3. 跳过手工验证:手工算一个数,是后面查错的唯一基准。
  4. 忽视一对多的方向:从错的一侧求和,结果会成倍偏大。
自测三问(点开对答案) 1. 一条分析路线通常由哪三步组成? —— 筛选(挑出要的行)、连接(用键把表接起来)、汇总(分组求平均或计数)。 2. 为什么路线会随问题变? —— 因为问题的目标字段落在不同的表上,要经过的桥就不同。数据库设计决定了桥有几座。 3. 「一对多」在汇总时要注意什么? —— 多的一侧会让同一份数据重复出现,直接求和会成倍偏大。要先想清楚从哪一侧汇总。

路线走通了,可客户用的是电子表格。下一篇就把这条路线摊平到一张表里,看会发生什么。

相关笔记