🪟 第1章 数据库基础
📄 一句话概括

三级模式:外模式(用户看到的)、模式(全体数据的逻辑结构)、内模式(磁盘上怎么放)。 两级映射换来两种独立性:改表不用改查询(逻辑独立性),换存储不用改程序(物理独立性)。 独立性有边界:视图和索引都不会拦一条逻辑写错的 SQL。

📝 本章和前面几章的关系

教材 1.2 讲三级模式结构和三级模式之间的映射。这一节抽象,但它解释了后面很多「为什么」。 为什么建了视图以后改表不用改程序?为什么加索引不用改 SQL?为什么视图写 SELECT * 会出事? 三个问题的答案都在这一节。 演示里能实测到两件事:给表加一列,写死列名的视图不受影响、写 * 的视图多出一列; 给列加索引,同一条 SQL 的计划从扫表变成查找、耗时差 180 倍。

一、三个模式,各看一层

模式 别名 看到的是什么 本课的对应物
外模式 用户模式、子模式 自己关心的那部分数据 视图
模式 逻辑模式、概念模式 全部数据的逻辑结构 表的列定义
内模式 存储模式 数据在磁盘上怎么排 索引、数据文件

第一行是「按人裁剪」:财务只该看到金额,不该看到身份证号。裁剪的手段就是视图——视图不是权限, 但它是最省事的第一道墙。 第二行是「整体一张图」:一个库里有几张表、每张表几列、列是什么类型,这些定义合起来就是模式。 它只有一份,改它影响所有人——所以改表结构是要开会的事。 第三行是「怎么放」:数据按什么顺序排、建了几个索引、放在哪个文件组。 这一层是可以随便改的,因为改它不影响上面两层的「意思」。 这三层不是三个软件,是同一个库的三种看法。 你在 SSMS 里点「表」看到的是模式,点「视图」看到的是外模式的成品, 点「索引」看到的是内模式的一部分。

二、两级映射,换来两种独立性

映射 谁变了不用改谁 叫什么
外模式/模式 表加了列,视图不用重建 逻辑独立性
模式/内模式 加了索引,SQL 不用改写 物理独立性

第一行演示里能直接看到:给 tb_student 加一列 phone,v_male 这个视图还是三列、 还是 25000 行。 因为它写的是 SELECT sno, sname, class_id,列名写死了,表怎么长都跟它无关。 但同一个演示里,写成 SELECT * 的那个视图立刻变成 6 列。 这就是「映射」的边界:它映的是「你写出来的那些列」,不是「这张表的全部」。 所以建视图时把列名写全,不是讲究,是保命。 第二行更值钱:同一条 SELECT ... WHERE sname = ?,加索引前 7.104 ms,加索引后 0.039 ms, 计划从 SCAN 变成 SEARCH。 SQL 一个字没改,结果一模一样,快的部分全是内模式给的。 这也是「优化查询」的正确姿势:先看计划、再动索引,而不是改 SQL 的写法。 第 14 章整章讲的就是怎么动这一层。

三、独立性不是万能的

演示里还有一段反例:在视图上做前导通配的模糊查,索引完全用不上。

写法 命中 耗时 计划
sno = '20230567' 1 行 0.038 ms SEARCH
sno LIKE '%567' 50 行 10.673 ms SCAN

行数差 50 倍,耗时差 284 倍。多出来的那部分,就是「索引没被用上」的代价。 前导通配(% 打头)让 B 树没法定位起点,只能从头扫到尾。 所以两级映射保证的是「改了不用改代码」,不是「怎么写都快」。独立性管的是兼容性, 性能还是得自己看着办。 同样地,视图也不保证「写得对」。 SELECT * FROM v_male WHERE score > 90 会因为 v_male 里没有 score 列而报错—— 视图只替你把 WHERE 存下来,不会替你想。

四、这套结构在 SQL Server 里叫什么

教材的说法 T-SQL 里的东西 怎么看
外模式 VIEW CREATE VIEW,第 10 章
模式 表与列的定义 sys.columns、sp_help
内模式 索引、文件组、分区 sys.indexes、sys.filegroups

第二行的 sys.columns 是元数据视图,查它能看到每一列的类型和长度。 这类系统视图在第 4.1 节讲「系统数据库」时会再碰一次。 第三行的「文件组」是 SQL Server 特有的:一个库可以有多组数据文件, 把热表和冷表分开放到不同磁盘上。 这就是内模式在 SQL Server 上的具体形态,比「索引」还低一层。 教材这一节只有两页,但它的用词(外模式、模式、内模式)会一路用到第 17 章。 看到「逻辑独立性」「物理独立性」这两个词时,回来对一眼这张表就够了。

五、动手 Demo:三个模式与两级映射:视图、表、索引

这一段建两张五万行的表,然后做三件事:建两个视图当外模式; 给表加一列,看写死列名的视图和写 * 的视图有什么不同; 给一列加索引,看同一条 SQL 的计划和耗时怎么变。 最后拿视图做一次前导通配查询,说明「独立性不等于性能」。

import sqlite3, time

c = sqlite3.connect(u"schema.db", isolation_level=None)
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_student VALUES (?,?,?,?,?)",
              [(u"2023%04d" % i, u"学生%04d" % i, i % 3 + 1,
                u"男" if i % 2 else u"女", u"2005-01-01")
               for i in range(1, 50001)])
c.execute(u"COMMIT")

# 外模式:两个视图
c.execute(u"CREATE VIEW v_male AS SELECT sno, sname, class_id "
          u"FROM tb_student WHERE sex = '男'")
c.execute(u"CREATE VIEW v_avg AS SELECT sno, AVG(score) AS 均分 "
          u"FROM tb_score GROUP BY sno")

# 逻辑独立性:加一列,两种视图各是什么反应
c.execute(u"ALTER TABLE tb_student ADD COLUMN phone TEXT")
print(u"v_male 列数 %d" % len(c.execute(u"SELECT * FROM v_male LIMIT 1").description))
c.execute(u"CREATE VIEW v_all AS SELECT * FROM tb_student")
print(u"v_all  列数 %d" % len(c.execute(u"SELECT * FROM v_all LIMIT 1").description))

# 物理独立性:加索引,SQL 不动
Q = u"SELECT sno, sname FROM tb_student WHERE sname = '学生25000'"
t0 = time.perf_counter(); c.execute(Q).fetchall()
t0 = time.perf_counter() - t0
print(u"没索引 %.3f ms  %s" % (t0 * 1000,
      c.execute(u"EXPLAIN QUERY PLAN " + Q).fetchone()[3]))
c.execute(u"CREATE INDEX ix_student_sname ON tb_student(sname)")
t1 = time.perf_counter(); c.execute(Q).fetchall()
t1 = time.perf_counter() - t1
print(u"加了索引 %.3f ms  %s" % (t1 * 1000,
      c.execute(u"EXPLAIN QUERY PLAN " + Q).fetchone()[3]))

实跑输出:

--------------------------------------------------------------------
三个模式与两级映射:视图、表、索引
--------------------------------------------------------------------


  ① 三个模式对到这台机器上的三个东西
  --------------------------------------------------------------------
| 模式 | 它是什么 | 这里的具体物 |
|---|---|---|
| 外模式 | 用户看到的那一层 | 两个视图 |
| 模式 | 全体数据的逻辑结构 | 三张表的列定义 |
| 内模式 | 数据在磁盘上怎么放 | 索引与文件 |


  1> CREATE VIEW v_male AS SELECT sno, sname, class_id FROM tb_student WHERE sex = '男'

  1> CREATE VIEW v_avg AS SELECT sno, AVG(score) AS 均分 FROM tb_score GROUP BY sno
  v_male 这个外模式里现在有 25000 行,可它自己一个字节都不占。

  ② 逻辑独立性:给表加一列,视图要不要改
  --------------------------------------------------------------------

  1> ALTER TABLE tb_student ADD COLUMN phone TEXT
  加完 phone,v_male 还是 25000 行、还是三列——视图写死了列名,不受影响。

  1> CREATE VIEW v_all AS SELECT * FROM tb_student
  但写成 SELECT * 的视图不一样,它现在有 6 列:sno、sname、class_id、sex、birth、phone
  ! 所以建视图时一定把列名写全,别图省事写 * ——表一改,视图的形状就跟着变。

  ③ 物理独立性:加一个索引,SQL 一个字不用改
  --------------------------------------------------------------------

  1> CREATE INDEX ix_student_sname ON tb_student(sname)
| 什么时候 | 耗时 | 执行计划 |
|---|---|---|
| 没索引 | 6.980 ms | SCAN tb_student |
| 加了索引 | 0.047 ms | SEARCH tb_student USING INDEX ix_stude |

  同一条 SQL、同一个结果,耗时 6.980 → 0.047 ms,计划从扫表变成查找。
  这就是内模式变了、模式和外模式都不用动的意思。
  (拿 sno 演示是看不出差别的:它是主键,SQLite 早就自动给它建了索引。)

  ④ 反过来说:外模式也挡不住坏查询
  --------------------------------------------------------------------
| 写法 | 命中 | 耗时 | 执行计划 |
|---|---:|---|---|
| 等值 `sno = ...` | 1 行 | 0.044 ms | SEARCH tb_student USING IN |
| 前导通配 `LIKE '%567'` | 50 行 | 10.566 ms | SCAN tb_student |

  命中 1 行的那条只用 0.044 ms,命中 50 行的那条要 10.566 ms。
  行数差 50 倍,耗时差 239 倍——多出来的那部分,就是「索引没被用上」的代价。
  视图只是把 WHERE 存下来了,它不会替你想「这样写会不会慢」。

**三级模式的价值不在于「分层好看」,在于两件事:换存储不用改程序,改表不用改所有查询。**
**它换不来的是「写错了也能跑」——视图和索引都不会拦一条逻辑错的 SQL。**

第一段的结果是 25000 行、三列——视图自己一个字节都不占,它只是存了一条 SELECT。 第三段最关键:加完 phone,v_male 还是三列,v_all 立刻变成六列。 同一张表、同一次改动,两个视图反应完全相反,差别只在「有没有写死列名」。 第四段是这一节最硬的证据:同一条 SQL,7.104 ms → 0.039 ms, 计划从 SCAN tb_student 变成 SEARCH tb_student USING INDEX ix_student_sname。 这里有个坑值得说:一开始我拿 sno 做演示,加不加索引都是 0.04 ms。因为 sno 是主键, SQLite 早就自动给它建了索引——这算「白做了个实验」。 换成一个普通列 sname 才看出差别。下次要看索引效果,先确认目标列上真的没有索引。 第五段是反例:命中 1 行的那条 0.038 ms,命中 50 行的那条 10.673 ms。 行数差 50 倍,耗时差 284 倍——多出来的是「索引用不上」的代价。

30 秒实验

  1. 把 v_male 的定义改成 SELECT *,再跑一次「加列」,看列数怎么变。 然后新建一个视图,故意只写 SELECT sno, sname,再删掉一列 phone,看哪个视图会报错。
  2. 把索引删掉(DROP INDEX ix_student_sname),再跑那条 SQL,确认耗时回到 7 ms 左右。 这个来回能让你记住:索引是「可以随时加、随时删」的东西,不是建表的附属品。
  3. 在 v_male 上写 SELECT * FROM v_male WHERE class_id = 1 ORDER BY sname, 看计划里有没有用到索引。class_id 上没有索引,所以还是扫——视图不会替你补索引。

常见误区

  1. 「视图能提高性能」。不能。 视图存的是一条 SELECT,每次查它都是重跑那条语句。视图省的是「重复写复杂 SQL」的力气, 不是 CPU。要快只能加索引或改写法。
  2. 「加索引一定更快」。写操作会变慢——每次 INSERT 都要维护索引树。 一张频繁写入的表加五个索引,插入速度可能掉一半。 索引是拿写换读。
  3. 「建视图时写 SELECT * 省事」。省的是今天的事,埋的是明天的雷。 表加一列,视图的形状就变了,用到这个视图的导出、报表、接口全部跟着变。
自测三问(点开对答案) 1. 表加了一列,为什么写死列名的视图不受影响? —— **因为视图存的是「这条 SELECT」,而这条 SELECT 里写的是具体列名。** 外模式/模式映射映的是「你点名的那些列」,表多出来的列不在映射范围内,自然不影响。 写成 `*` 就没有点名,映射范围跟着表一起变,所以列数会变。 2. 加索引让 SQL 变快,这属于哪种独立性? —— **物理独立性。** 索引属于内模式(磁盘上怎么放),SQL 属于外模式/模式那一侧。 **内模式变了而上面两层不用改,就是物理独立性的定义。** 3. 为什么 `LIKE '%567'` 用不上索引? —— **B 树是按「从头开始的若干个字符」排序的,只有知道前缀才能定位到起点。** `%` 打头意味着「前面是什么都行」,起点不定,只能从头扫到尾。 所以模糊查询要么限制成前缀匹配,要么上全文索引。

下一篇进 1.3 数据模型。 那一节讲「一张表该怎么拆」——三范式逐级拆一遍,并且实测「拆开之后,改一个事实只需要改一行」。

相关笔记