多表设计实战
前面讲了三种关系(一对一、一对多、多对多)的概念和写法。这一篇用一个完整的真实场景把它们全部串起来,从需求分析 → 建表 → 插数据 → 查询,完整走一遍。
场景选在线书店,因为它天然包含了全部三种关系:
一、先想清楚有哪些"东西"
动手建表之前,先把现实里有哪些实体列出来,再想它们之间的关系。这是设计数据库的第一步。
关系梳理
二、ER 关系总览
把全部 9 张表的关系拆成三组来看,避免一张大图乱成一团。三组之间通过 users.id 和 books.id 衔接。
① 用户域(一对一 + 一对多)
user_profiles.user_id 加了 UNIQUE 所以是一对一;user_addresses.user_id 没加,所以是一对多——区别就这一行约束。
② 订单商品域(一对多 + 多对多中间表)
order_items 夹在 orders 和 books 中间——它既是 orders 的一对多(一个订单有多个明细),又是 books 的一对多(一本书能出现在多个明细里)。两边都是 1:N 指向它,所以 orders 和 books 之间就是多对多,order_items 就是那张中间表(只不过它额外存了数量、快照价)。
③ 标签域(多对多)
book_tags 是纯中间表,只存关系(book_id + tag_id),不存别的信息。注意 books 同时出现在 ② 和 ③ 里,是这两组关系的衔接点。
下面逐个表讲建表细节。
三、建表顺序很重要
外键引用别的表,被引用的表必须先存在。所以建表顺序是:父表在前,子表在后。
记住一个口诀:先建不依赖别人的,再建依赖别人的;中间表最后建。
四、开始建表
① users —— 用户表
为什么这么设计:
username和email都加了UNIQUE,注册时数据库层面就保证不重名,不用先查再插。created_at用DEFAULT CURRENT_TIMESTAMP,插入时不填也自动有值。
② user_profiles —— 用户详情表(一对一)
用到了什么约束,为什么:
① UNIQUE (user_id) —— 区分一对一和一对多的关键
没有它,同一个 user_id 可以插多条详情,就变成一对多了。加了它,一个用户只能对应一条详情,这才是一对一。
② 外键引用 users —— 详情必须属于一个真实存在的用户。
为什么不直接放 users 表?
id_card、birthday 这些是隐私字段,查询频率低。拆出去后,普通的用户列表查询不用碰这些字段,更安全也更轻。这是"字段多、不常用、涉及隐私 → 拆出去"的典型场景。
③ user_addresses —— 收货地址表(一对多)
用到了什么约束,为什么:
- 外键放"多"的一方(
user_addresses),不在users表里存地址列表。原因和前面讲的师生关系一样:一个用户有多个地址,你在用户表里没法存得下。 ON DELETE CASCADE:删用户时,他的收货地址自动清掉,不用手动维护。- 注意这里没有
UNIQUE(user_id),所以一个用户可以有多条地址——这是一对多。
对比 ②:
user_profiles有UNIQUE(user_id)所以是一对一;user_addresses没有,所以是一对多。唯一区别就是这一行约束。
④ categories —— 分类表
① UNIQUE (name) —— 避免"文学"和"文学 "这种重复分类。
⑤ tags —— 标签表
⑥ books —— 书籍表(一对多:分类 → 书籍)
用到了什么约束,为什么:
① category_id NOT NULL —— 每本书必须有分类,不允许空。
② DECIMAL(10,2) 而不是 FLOAT —— 存钱用 DECIMAL。FLOAT 有精度丢失,0.1 + 0.2 可能变成 0.30000000000000004,算账时会出问题。DECIMAL(10,2) 表示总共 10 位、小数 2 位,最大存 99999999.99。
③ ON DELETE RESTRICT —— 分类下还有书时不让删,防止书变成"孤儿数据"。这是默认行为,写出来更清晰。
⑦ book_tags —— 书籍标签中间表(多对多)
用到了什么约束,为什么:
① PRIMARY KEY (book_id, tag_id) —— 联合主键,防止一本书重复绑定同一个标签:
中间表只存关系,不存别的信息,所以不需要单独的自增 id,两个外键直接做主键就行。
ON DELETE CASCADE:删书或删标签时,对应的绑定关系自动清掉。
⑧ orders —— 订单表(一对多:用户 → 订单)
① 为什么地址被引用时不让删?
订单是历史记录,必须能查到"当时寄到哪"。如果删了地址,订单的 address_id 就指向了不存在的地址。所以用 RESTRICT 保护住。
现实项目里更常见的做法是给地址加"软删除"字段(
is_deleted),而不是真删。但这里用RESTRICT演示外键策略。
⑨ order_items —— 订单商品表(订单 ↔ 书籍 的多对多)
用到了什么约束,为什么:
① UNIQUE (order_id, book_id) —— 一个订单里,同一本书只能出现一行。防止"订单1 买了书A 2本"和"订单1 买了书A 3本"这种重复记录。
为什么这是"多对多的中间表",但又和 book_tags 不太一样?
因为它除了关系还存了别的信息:买了多少本(quantity)、买的时候单价多少(unit_price)。这种"带业务字段"的中间表,一般会加自增 id 做主键,再用联合唯一约束防重复。
unit_price 是"快照"——重点理解
为什么不直接 JOIN books 拿价格?因为书的价格会变。今天下单 39 元,明天书涨到 49 元,你不能让历史订单的总价跟着变。所以下单时把当时的单价存一份到 order_items.unit_price,这就是"数据快照"思想。
五、建表顺序回顾
提示:删表顺序正好相反,
DROP TABLE order_items必须在DROP TABLE orders之前,否则会因为外键约束删不掉。或者先SET FOREIGN_KEY_CHECKS = 0;再删。
六、插入测试数据
光建表没法练习查询,来点数据。
七、几种典型查询
数据有了,跑几个能体现关系设计的查询。(JOIN 语法详见下一篇)
1. 查每个用户的详情(一对一)
2. 查 tom 的所有收货地址(一对多)
3. 查"技术"分类下的所有书(一对多)
4. 查一本书的所有标签(多对多)
多对多查询的关键:中间表要 JOIN 两次——一次连到 tags,一次连到 books。
5. 查某个标签下有哪些书(多对多的反向)
6. 查一个订单里买了哪些书、各几本(带快照价格)
注意这里用的是 oi.unit_price(订单里的快照价),不是 b.price(书当前的价)——这就是前面强调快照的原因。
八、外键策略在这个项目里的完整分布
把整个项目的外键策略汇总一下,对照着看:
规律:
九、几个常见坑
坑 1:建表顺序反了
解决: 先建被引用的表。
坑 2:外键字段类型不一致
解决: 两边完全一致,包括 UNSIGNED。
坑 3:忘了 UNIQUE,一对一变成一对多
解决: 想要一对一,外键加 UNIQUE。
坑 4:存金额用 FLOAT
坑 5:中间表忘了防重复
下一篇开始讲怎么把这些表用 JOIN 连起来查询。

