1. 先看一张「像 Excel」的表
上一课说了「为什么需要数据库」。本课不讲空道理,先看数据长什么样。
假设班级要记同学信息,在 Excel 里可能是这样:
| 学号 | 姓名 | 手机号 |
|---|---|---|
| 1 | 小明 | 13800001111 |
| 2 | 小红 | 13900002222 |
| 3 | 小刚 | 13700003333 |
在数据库里,这整张格子就叫一张 表(table)。可以把它想成:
- 表 ≈ 一张有表头的 Excel 工作表(比如叫
students) - 列(column) ≈ 表头:
学号、姓名、手机号(规定每一列存什么) - 行(row) ≈ 表头下面的每一条记录:小明一行、小红一行
再记三个中文说法,后面会反复用到:
| 说法 | 是什么 | 上面例子 |
|---|---|---|
| 表 | 一类东西的清单 | 学生表 |
| 行 / 记录 | 清单里的一条 | 小明那一整行 |
| 列 / 字段 | 清单里的一种信息 | 「姓名」这一列 |
小练习(心里想) :若再加一列「生日」,是加了一行 还是一列 ?
答案:加的是一列(多了一种信息);小明、小红各多一个生日格子,行数仍是 3。
2. 为什么要拆成多张表?(看坏例子)
Day24 的记账如果全塞进一张大表,可能变成:
| 流水号 | 用户姓名 | 用户邮箱 | 金额 | 备注 |
|---|---|---|---|---|
| 1 | alice | alice@example.com | 12.5 | lunch |
| 2 | bob | bob@example.com | 30.0 | taxi |
| 3 | alice | alice@example.com | -5.0 | refund |
问题一眼能看出来:
- alice 的邮箱写了两遍。她以后改邮箱,你要改两行(实际业务可能改几百行),漏改一行就乱了。
- 「人」和「一笔账」混在同一行,表又宽又难维护。
更好的做法(本课主线):
表 1:users(只存「人」)
| id | name | |
|---|---|---|
| 1 | alice | alice@example.com |
| 2 | bob | bob@example.com |
表 2:ledger(只存「账」)
| id | user_id | amount | note |
|---|---|---|---|
| 1 | 1 | 12.5 | lunch |
| 2 | 2 | 30.0 | taxi |
| 3 | 1 | -5.0 | refund |
注意 ledger 里没有再写姓名、邮箱,只写了 user_id = 1 表示「属于 id 为 1 的用户」 。
1 就是指向 users 表里 alice 那一行的「门牌号」。
这样:
- 改 alice 邮箱 → 只改
users里一行; - 流水再多,也不用复制姓名邮箱。
3. 键、主键、外键------用「身份证 / 门牌号」理解
3.1 什么是「键」?
键 不是键盘上的键,而是:用来认出某一行 、或把两张表对上号的那一列(或几列)。
最常见两种:
| 名称 | 白话 | 在本课例子里 |
|---|---|---|
| 主键(PRIMARY KEY) | 这一行的「身份证号」,全表唯一 | users.id、ledger.id |
| 外键(FOREIGN KEY) | 写在 A 表里、指向 B 表主键的「门牌号」 | ledger.user_id → users.id |
3.2 主键长什么样?
再看 users 表,把主键圈出来:
text
users
+----+-------+---------------------+
| id | name | email | ← 列名
+----+-------+---------------------+
| 1 | alice | alice@example.com | ← 第 1 行,主键是 1
| 2 | bob | bob@example.com | ← 第 2 行,主键是 2
+----+-------+---------------------+
↑
主键列:每人一个号,不重复、不能空
规则记三句就够:
- 主键用来唯一认出一行(不能两个人都是 id=1)。
- 主键不能为空(没有身份证号就无法认人)。
- 常用自增整数:插入时不写 id,数据库自动给 1、2、3...
SQL 里常见写法:
sql
id INTEGER PRIMARY KEY AUTOINCREMENT
3.3 外键长什么样?
外键写在「多」的那一侧。一笔账属于一个用户 → 外键在流水表:
text
users(被指向的表) ledger(带着门牌号的表)
+----+-------+ +----+---------+--------+--------+
| id | name | | id | user_id | amount | note |
+----+-------+ +----+---------+--------+--------+
| 1 | alice | <----+ | 1 | 1 | 12.5 | lunch |
| 2 | bob | <----|----+ | 2 | 2 | 30.0 | taxi |
+----+-------+ | | | 3 | 1 | -5.0 | refund |
| | +----+---------+--------+--------+
| | ↑
| +---- user_id=2 表示「bob 的账」
+--------- user_id=1 表示「alice 的账」
对照读一遍:
- 流水第 1 行:
user_id = 1→ 去users找id=1→ alice,午餐 12.5 - 流水第 2 行:
user_id = 2→ bob,打车 30 - 流水第 3 行:又是
user_id = 1→ 还是 alice(一人可以有多笔账)
如果有人强行插入 user_id = 999,但 users 里没有 id=999 的人,这就是脏数据 。
打开外键约束后,数据库会直接拒绝,后面实践里你会看到报错:
IntegrityError: FOREIGN KEY constraint failed
3.4 和 Excel「VLOOKUP」的感觉类似
你在 Excel 里用学号去另一张表查姓名,就是「用编号把两张表对上」。
数据库的外键 + 查询,就是把这件事变成规则(不能指到不存在的学号),而不只靠人工小心。
4. SQL 是什么?(先建立地图)
SQL 是和数据库说话的专用语言。你写出「建什么表、查什么数据」,由数据库去执行,而不是自己用 Python 一行行扫文件。
本课只要分清四大类(后三类下节细讲):
| 类别 | 人话 | 例子 |
|---|---|---|
| DDL | 定规矩:桌子长什么样 | CREATE TABLE users (...) |
| DML | 改数据:增删改格子里的值 | INSERT / UPDATE / DELETE |
| DQL | 查数据:把符合条件的行找出来 | SELECT ... FROM users |
| DCL | 谁能看、谁能改(权限) | 后续课 |
本课工具仍是 SQLite (Python 自带 sqlite3,不用先装 MySQL)。
5. 建表语句怎么读?(对照真实例子)
下面这段 SQL 就是在「画表头 + 定规则」:
sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 主键,自动 1,2,3...
name TEXT NOT NULL, -- 姓名,不能空
email TEXT NOT NULL UNIQUE -- 邮箱,不能空,且不能重复
);
CREATE TABLE ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, -- 存的是 users.id 的号码
amount REAL NOT NULL, -- 金额
note TEXT, -- 备注,允许空
FOREIGN KEY (user_id) REFERENCES users(id) -- 声明:user_id 必须在 users 里存在
);
逐项对照:
| 写法 | 意思 | 生活例子 |
|---|---|---|
TEXT / INTEGER / REAL |
这列存文字 / 整数 / 小数 | 姓名、学号、金额 |
NOT NULL |
这格不能空着 | 报名表「姓名」必填 |
UNIQUE |
整列不能有重复值 | 邮箱不能两人相同 |
PRIMARY KEY |
本表主键 | 每人唯一学号 |
FOREIGN KEY ... REFERENCES |
外键指向哪张表的哪一列 | 成绩单上的学号必须在学生花名册里 |
类型入门记三样即可:
| 存什么 | 声明 | 例子 |
|---|---|---|
| 整数、编号 | INTEGER |
id、user_id、数量 |
| 小数 | REAL |
12.5 元 |
| 文字 | TEXT |
姓名、备注、邮箱 |
删表(表结构 + 里面所有数据都没):
sql
DROP TABLE IF EXISTS ledger;
改表结构用 ALTER TABLE,本课知道有这回事即可。
6. 表设计:三个「好习惯」(仍用例子)
习惯 1:一格只放一种信息
不好:爱好 一格写 "篮球,钢琴,编程",以后很难查「所有喜欢编程的人」。
更好:爱好单独一张表,或先接受「本课不查爱好」------至少别在一格里塞一堆又当多个值用。
习惯 2:少复制会变的信息
不好:每笔流水都抄一遍用户邮箱。
更好:流水只存 user_id,邮箱只在 users 里存一份。
习惯 3:历史可以「拍快照」
订单打印小票时的「商品名称」可以抄一份存在订单里,这样商品以后改名,旧订单标题不变。
这是有意冗余,和「无害地到处复制邮箱」不同。
命名建议:表名、列名用小写英文 + 下划线,如 user_id、created_at,团队统一一种风格。
7. 画关系图(ER):动笔前先想清楚
ER 草图 = 用方框和连线回答:有哪些「东西」?它们谁连谁?
三种常见关系(只记形状):
| 关系 | 人话例子 | 外键放哪 |
|---|---|---|
| 1 对 1 | 一人一本护照 | 外键放任一侧,或两表合并 |
| 1 对 多 | 一用户有多笔流水 | 外键放在「多」的那张表(流水表的 user_id) |
| 多 对 多 | 学生选多门课,一门课很多学生 | 再加一张中间表,两头各一个外键 |
本课:用户 1 ------ 多 流水
text
users ledger
+--------+ +----------+
| id (主键) | ←------门牌号------ | user_id(外键) |
| name | | amount |
| email | | note |
+--------+ +----------+
1 人 多 笔账
扩展:图书借阅(多对多)
一个读者可借多本书,一本书可被多人借过 → 中间表 loans:
text
readers loans books
+--------+ +-------------+ +--------+
| id |←----| reader_id | | id |
| name | | book_id |----→ | title |
+--------+ | borrowed_at | +--------+
+-------------+
中间表每一行的意思很具体:「某读者在某天借了某本书」。
8. 实践:亲手建「用户表 + 流水表」
8.0 运行环境说明
| 项目 | 要求 |
|---|---|
| Python | 3.10+(推荐 3.12);标准库 sqlite3 |
| 工作目录 | 自拟,下文以 ~/python-lab/src/day25 为例 |
| Shell | Bash 兼容(支持 mkdir、cat <<'EOF') |
各系统建议:
| 系统 | 建议 |
|---|---|
| Linux / macOS | 系统终端 + python3 |
| Windows | 推荐 Cygwin (或 WSL)。不推荐在纯 CMD / 未配置 heredoc 的 PowerShell 中硬套 cat << |
bash
mkdir -p ~/python-lab/src/day25
cd ~/python-lab/src/day25
python3 --version
8.1 一键生成并运行
脚本会:建库文件 shop.db → 建两张表 → 插入样例 → 打印表结构 → 按 user_id 对上姓名 → 故意插入不存在的用户看外键是否拦住。
bash
cat > schema_demo.py << 'EOF'
# Day25: users + ledger schema demo
import sqlite3
from pathlib import Path
DB = Path(__file__).with_name("shop.db")
def main():
if DB.exists():
DB.unlink()
conn = sqlite3.connect(DB)
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
amount REAL NOT NULL,
note TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
"""
)
conn.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("alice", "alice@example.com"),
)
conn.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("bob", "bob@example.com"),
)
conn.execute(
"INSERT INTO ledger (user_id, amount, note) VALUES (?, ?, ?)",
(1, 12.5, "lunch"),
)
conn.execute(
"INSERT INTO ledger (user_id, amount, note) VALUES (?, ?, ?)",
(2, 30.0, "taxi"),
)
conn.execute(
"INSERT INTO ledger (user_id, amount, note) VALUES (?, ?, ?)",
(1, -5.0, "refund"),
)
conn.commit()
print("--- tables ---")
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
):
print(row[0])
print("--- users columns ---")
for row in conn.execute("PRAGMA table_info(users)"):
print(row)
print("--- ledger columns ---")
for row in conn.execute("PRAGMA table_info(ledger)"):
print(row)
print("--- sample join ---")
sql = (
"SELECT u.name, l.amount, l.note "
"FROM ledger l JOIN users u ON u.id = l.user_id "
"ORDER BY l.id"
)
for row in conn.execute(sql):
print(row)
print("--- bad FK insert ---")
try:
conn.execute(
"INSERT INTO ledger (user_id, amount, note) VALUES (?, ?, ?)",
(999, 1.0, "ghost"),
)
conn.commit()
print("unexpected success")
except sqlite3.IntegrityError as e:
print("IntegrityError:", e)
conn.close()
if __name__ == "__main__":
main()
EOF
python3 schema_demo.py
8.2 实测输出
text
--- tables ---
ledger
sqlite_sequence
users
--- users columns ---
(0, 'id', 'INTEGER', 0, None, 1)
(1, 'name', 'TEXT', 1, None, 0)
(2, 'email', 'TEXT', 1, None, 0)
--- ledger columns ---
(0, 'id', 'INTEGER', 0, None, 1)
(1, 'user_id', 'INTEGER', 1, None, 0)
(2, 'amount', 'REAL', 1, None, 0)
(3, 'note', 'TEXT', 0, None, 0)
--- sample join ---
('alice', 12.5, 'lunch')
('bob', 30.0, 'taxi')
('alice', -5.0, 'refund')
--- bad FK insert ---
IntegrityError: FOREIGN KEY constraint failed
对照着读输出:
| 输出 | 说明 |
|---|---|
users / ledger |
两张表建成功了 |
sqlite_sequence |
自增主键用的内部表,可先忽略 |
PRAGMA table_info |
打印每一列:名字、类型等;最后一项为 1 表示该列是主键 |
('alice', 12.5, 'lunch') |
用 user_id 对上了姓名,说明两表通过「门牌号」连起来了 |
FOREIGN KEY constraint failed |
user_id=999 在用户表不存在,被拦住了------这就是外键的作用 |
SQLite 默认不强制外键,所以脚本里有一句:
python
conn.execute("PRAGMA foreign_keys = ON")
每个新连接都要开一次,外键才会真正生效。
总结
- 表 像 Excel 工作表,列 是表头,行是一条记录。
- 主键 是这一行的唯一编号(身份证);外键是写在另一张表上的门牌号,指向主键。
- 拆表是为了少复制、好维护;
user_id把「人」和「账」连起来。 - 建表用 DDL(
CREATE TABLE+ 类型 +NOT NULL/UNIQUE+ 主键外键)。 - SQLite 使用外键前先
PRAGMA foreign_keys = ON。 - 下一课:增删改查与过滤(系统学 INSERT / UPDATE / DELETE / SELECT)。
小练笔
题 1
看表:
| id | title |
|---|---|
| 1 | Python 入门 |
| 2 | SQL 基础 |
这里的 主键 最可能是哪一列?为什么?
题 2
流水表有一行:user_id = 2, amount = 30。用户表如下:
| id | name |
|---|---|
| 1 | alice |
| 2 | bob |
这笔 30 元是谁花的?user_id 在这里扮演什么角色(主键还是外键)?
题 3
判断:可以把主键设为 NULL,反正很少用到空值。
(对 / 错)
题 4
设计题:学校要记录「哪个学生选了哪门课」。学生可多选,一门课也有很多学生。
请写出建议的表(至少表名 + 主键/外键列),可用文字列表。
题 5(可选实践)
在本课 day25 目录(已有 shop.db)下列出所有表名。写出命令与输出。
小练笔参考答案
题 1
id
它能唯一区分每一本书;title 理论上可能重名,不适合单独当主键。
题 2
是 bob 花的(users 里 id=2)。
user_id 是流水表上的 外键 ,指向用户表主键 id。
题 3
错
主键必须能认出这一行,不能为空,也不能重复。
题 4(参考)
text
students(id 主键, name, ...)
courses(id 主键, title, ...)
enrollments(id 主键,
student_id 外键→students.id,
course_id 外键→courses.id)
中间表 enrollments 每一行表示「某学生选了某课」。
题 5(示例)
bash
python3 - <<'PY'
import sqlite3
conn = sqlite3.connect('shop.db')
for (name,) in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
):
print(name)
conn.close()
PY
典型输出:
text
ledger
sqlite_sequence
users