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

新来一期,摊平表要动四处;关系数据库只要插两张表各一次。 漏改一处不会报错:期号表少一行,那期的文章在 JOIN 里静悄悄消失。 这就是关系数据库的核心交易:用一次性的连接成本,换永远不会分叉的数据。

📝 本章和前面几章的关系

上一篇算了摊平的容量账,这一篇算维护账。维护账才是真正贵的那笔。 书里这段的情绪是「复制粘贴太痛苦」。痛苦是可以量化的:数一下要动几处。

一、新来一期,要动几处

第 13 期到了:2026-01,销量 57900,封面 效率工具,两篇文章。 在摊平的电子表格里:文章区加两行、销量抄两遍、汇总表加一行、图表的数据源拉一下。 在关系数据库里:期号表插一行,文章表插两行。完。

四处对三处,看着差别不大。差别在「漏了会怎样」。 摊平表的四处是手工的四处,每一处都可能漏;数据库的三次插入是三个 INSERT 语句。

二、漏改一处会怎样

假设只往文章表插了两行,忘了往期号表插。 这时文章表 25 行、期号表 12 行。跑一个内连接,第 13 期那两篇文章直接消失。 数据库不报错。它给出一个 24 行的结果,看起来和之前一模一样。

这是最坏的一种错:结果变少了,但你不知道。 唯一能发现的办法是主动查:用左连接找出右边为空的行。这里能查出 1 行。

三、改一个数,要改几处

第 5 期销量录错了,61200 应该是 62100。 摊平表里它出现两次(两篇文章各一行);期号表里只有一处。 如果改了一处漏了另一处,同一期在表里就有两个销量值。

这种错最难查:每一行单看都对,只有把它们对起来才矛盾。 重复存储的真正代价不是空间,是「同一件事可以有多个版本」。

四、关系数据库怎么解决

销量只存一个地方:期号表。文章表只存期号,不存销量。 要改就改一处,永远不会出现两个版本。这个性质叫「一处修改,处处生效」。 代价是:每次要用都得 JOIN 一下,不能直接看到摊平的样子。

所以这是一笔交易:用一次性的连接成本,换永远不会分叉的数据。 数据改得越频繁、用得越久,这笔交易越划算。

五、手工维护的底线

如果非要手工维护表格,至少要做到一件事:每次改动都能撤干净。 做不到这一点,就不该手工改——因为你迟早会撤不干净。 更实际的做法:把「一处事实只存一处」当成硬规矩,重复的那部分用公式引用,不用复制。

复制是数据分叉的起点。任何一次 Ctrl+C 都在制造未来的一个矛盾。

六、动手 Demo:复制粘贴的痛苦

模拟「新来一期只改了一半」:往文章表插一行,期号表不动。 再看 JOIN 结果怎么静悄悄变少,以及怎么用左连接把它查出来。

import sqlite3

ISSUES = [(i, 40000 + i * 1000) for i in range(1, 13)]
ARTS = []
aid = 0
for issue, copies in ISSUES:
    for k in range(2):
        aid += 1
        ARTS.append((aid, issue))

con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE issues (issue INTEGER PRIMARY KEY, copies INTEGER)")
con.execute("CREATE TABLE articles (article_id INTEGER PRIMARY KEY, issue INTEGER)")
con.executemany("INSERT INTO issues VALUES (?,?)", ISSUES)
con.executemany("INSERT INTO articles VALUES (?,?)", ARTS)
con.commit()

print("改动前:文章 %d 行" % con.execute("SELECT COUNT(*) FROM articles").fetchone()[0])
print("        JOIN 结果 %d 行" % con.execute(
    "SELECT COUNT(*) FROM articles a JOIN issues i ON a.issue = i.issue").fetchone()[0])

# 只插文章表,忘了插期号表
con.execute("INSERT INTO articles VALUES (?,?)", (25, 13))
con.commit()
print()
print("改动后:文章 %d 行" % con.execute("SELECT COUNT(*) FROM articles").fetchone()[0])
print("        JOIN 结果 %d 行(少了,但不报错)" % con.execute(
    "SELECT COUNT(*) FROM articles a JOIN issues i ON a.issue = i.issue").fetchone()[0])
print("        孤儿行 %d 行" % con.execute(
    "SELECT COUNT(*) FROM articles a LEFT JOIN issues i ON a.issue = i.issue "
    "WHERE i.issue IS NULL").fetchone()[0])

实跑输出:

--------------------------------------------------------------------
复制粘贴的痛苦
--------------------------------------------------------------------

  ① 新来一期,要动几个地方
  --------------------------------------------------------------------
  13 期到了:2026-01,销量 57900,封面主题 效率工具,两篇文章。
  在摊平的电子表格里,要手动做的事:在文章区加两行、把销量抄两遍、更新汇总表、更新图表数据源。
  在关系数据库里,要做的只有两件事:给期号表插一行,给文章表插两行。

  ② 漏改一处会怎样
  --------------------------------------------------------------------
  假设新一期只加进了文章表,忘了加进期号表。
  文章表 25 行,期号表 12 行。
  这时跑 JOIN,第 13 期的两篇文章会直接消失——不报错,只是少了两行。
  能查出来的孤儿行:1 行。不主动查,你永远不知道少了几行。

  ③ 改一个数,要改几处
  --------------------------------------------------------------------
  第 5 期销量录错了,61200 应该是 62100。
  摊平表里,它出现两次(两篇文章各一行);期号表里只有一处。
  改了摊平表的一处、漏了另一处,同一期在表里就有两个销量值。
  这种错最难查,因为每一行单看都没问题,只有对起来才发现矛盾。

  ④ 关系数据库怎么解决这件事
  --------------------------------------------------------------------
  销量只存一个地方:期号表。文章表只存期号,不存销量。
  要改就改一处,永远不会出现两个版本。这个性质叫「一处修改,处处生效」。
  代价是:每次要用都得 JOIN 一下,不能直接看到摊平的样子。
  ! 这就是关系数据库的核心交易:用一次性的连接成本,换永远不会分叉的数据。

  ⑤ 回到那 24 行
  --------------------------------------------------------------------
  把刚才插的脏数据撤掉,文章表回到 24 行。
  手工维护表格的每一次改动,都得能撤干净。做不到这一点,就不该手工改。

文章表从 24 涨到 25,JOIN 结果却还是 24。两个数不一致,就是唯一的报警信号。 所以每次 JOIN 之后都要数行数。这不是洁癖,是唯一能自动发现问题的动作。

30 秒实验

  1. 如果漏的是期号表多插了一行(没有文章指向它),内连接结果会变吗?外连接呢?
  2. 「一处修改,处处生效」在多个人同时改数据时会带来什么新问题?
  3. 想一想:为什么说复制的代价要在半年后才会显现?

常见误区

  1. 相信「不报错就是对的」:丢行、笛卡尔积、重复计数,全都不报错。
  2. 改完就交,不复检行数:行数是唯一便宜的自动检查。
  3. 用复制来同步两份数据:一次复制就是一次分叉的开始。
  4. 以为关系数据库只是「更大」:它解决的是重复与分叉,不是容量。
自测三问(点开对答案) 1. 为什么「不报错」在数据库里是危险的? —— 因为丢行、多行、重复都不算语法错误。数据库只会执行,不会判断结果对不对。 2. 怎么发现漏插了一行? —— 比较 JOIN 前后的行数,或者用左连接找右侧为空的行。两个办法都便宜。 3. 关系数据库拿什么换什么? —— 用每次查询都要连接的成本,换数据永远只有一份、永远不会出现两个版本。

账算清了。下一篇真的把这条路线写成 SQL,看它比复制粘贴快多少。

相关笔记