创建日期:2026-09-08 | 最近更新:2026-09-08 本篇偏「设计方法论」,示例结合本系列
shop库(customers / products / orders / order_items)讲,不依赖额外实测。
MySQL 设计篇 5:表设计与范式——别让表结构成为项目的债
表设计是欠债 vs 还债的游戏:早期随便建表,后期加字段、拆表、改约束的成本指数上升。这篇讲一套「先按范式想清楚、再按实际反范式」的方法,让表结构禁得起需求演进。
1. 设计的起点:先想「实体」和「关系」
拿到需求先画三样东西(别急着写 CREATE TABLE):
- 实体:顾客、商品、订单……(一个实体一张主表);
- 属性:顾客有 name/email/city(一个属性一列);
- 关系:
- 1 对 N:一个顾客 → 多个订单(顾客表和订单表,用
orders.customer_id指向顾客); - N 对 M:一个订单含多个商品、一个商品出现在多个订单 → 需要中间表
order_items(含 order_id + product_id)。
- 1 对 N:一个顾客 → 多个订单(顾客表和订单表,用
本系列 shop 库就是标准答案:
customers 1 ──── N orders N ──── N products
(orders 记录每个顾客的订单)
orders 1 ──── N order_items ──── N products ← 多对多用中间表拆开
经验法则:看到「某订单有多个商品且每个商品有数量/单价」→ 别把商品塞进 orders 一行,拆出 order_items(存 order_id/product_id/qty/price)。
2. 范式(Normalization):三张「审查表」
范式是发现坏设计的检查清单,不是玄学。
1NF:每一格只放一个值,列别重复
❌ 坏:orders 里 items = '苹果×2, 香蕉×1'(一个格多个值)→ 没法聚合、没法查单个商品。
✅ 好:拆成每行一个明细(order_items 的行)。
2NF:非主键列要完全依赖主键(针对复合主键)
❌ 坏:order_items(id, product_id, qty, product_name, product_price) 里 product_name 只依赖 product_id,不依赖整个订单明细 → 商品改名要改一堆历史明细。
✅ 好:商品信息放 products 表,order_items 只存 product_id + 下单时的 price(下单快照),要名字就去 JOIN products。
3NF:非主键列之间别互相依赖(消除传递依赖)
❌ 坏:orders(id, customer_id, customer_city) —— city 依赖 customer,不直接依赖订单 → 顾客搬家要改所有历史订单。
✅ 好:顾客信息只放 customers,orders 只存 customer_id,城市靠 JOIN。
一句话总结:把重复的、依赖错的、间接的数据踢出去,只留「主键决定的直接事实」;跨表要的细节用外键 + JOIN 现查。
3. 反范式:什么时候「故意」打破规范
范式过度会让查询 JOIN 太多、变慢。实测权衡后再反范式,常见三种:
| 场景 | 做法 |
|---|---|
| 高频读、几乎不改 | 在冗余列存统计值(如订单表冗余 customer_name),用应用保证同步 |
| 报表/汇总频繁 | 建汇总表(日订单汇总),别每次都全表聚合 |
| 关系树/路径 | 冗余 parent path,避免递归查 |
原则:先按范式设计保证正确,压测后针对热点反范式。别一上来就冗余——冗余是「用正确性换速度」,要有数据支撑。
4. 数据类型选择(选错很痛的几个)
| 数据 | 用 | 别用 |
|---|---|---|
| 主键 id | BIGINT UNSIGNED 或有序 UUID(8.0 可 UUID_TO_BIN) | 字符串无序 UUID 直接当 PK(碎片大) |
| 金额 | DECIMAL(10,2) | float/double(精度) |
| 状态/枚举 | VARCHAR + 应用枚举;真稳定小集合才 ENUM | 乱用 ENUM(改值要改表) |
| 布尔 | TINYINT(1) 或 BOOLEAN 别名 | 字符串 'true'/'false' |
| 长文本 | TEXT(别放进索引) | VARCHAR(巨大) 滥设 |
| 时间 | DATETIME(存 UTC 亦可,展示层转) | 字符串存时间(没法比较/索引浪费) |
| 手机/身份证 | VARCHAR(不运算,别 INT——会丢前导 0/溢出) | INT/BIGINT |
通用习惯:ID 用整数/UUID、金额 DECIMAL、时间 DATETIME、描述性文本 VARCHAR 足够就给长度。别所有字符串都 VARCHAR(255) 一刀切。
5. 列名 / 约束的「团队公约」
- 命名:小写下划线,
customer_id、created_at;布尔is_/has_前缀; - 每张表必有:
id主键 +created_at;需要审计加updated_at; - 外键列要能一眼看出指向:
customer_id→customers(id); - 软删除 vs 硬删除:数据要留痕(订单、用户)用
deleted_at/status;日志类可直接 DELETE; - 索引:所有外键列 + 高频 WHERE 列(篇 3)在上线前想好,别等慢查询来了再补。
6. 案例复盘:把 shop 的设计「为什么这样」讲明白
| 表 | 主键 | 外键/关键列 | 设计理由 |
|---|---|---|---|
| customers | id | email UNIQUE、city 有索引 | 实体;email 业务唯一 |
| products | id | price DECIMAL | 实体 |
| orders | id | customer_id FK、status、amount | 一个顾客 N 个订单 → 外键;金额在订单上冗余快照 |
| order_items | id | order_id FK、product_id FK、qty、price | N:M 中间表;price 是下单时快照(商品涨价不影响历史) |
「amount 冗余在 orders」和「price 快照在 order_items」都是有意的设计:订单总额/单价一旦生成就不随商品改价变动——这是业务正确性,不是随便反范式。
动手
把下面这个「反面教材」按 3NF 拆成好表(这就是面试常见题):
-- 坏设计:一张表装天下
CREATE TABLE bad_orders (
id INT PRIMARY KEY,
customer_name VARCHAR(50), customer_city VARCHAR(30),
product1 VARCHAR(50), product1_price DECIMAL(10,2), product2 VARCHAR(50), ...
);
拆成几张表?各自主键/外键是什么?
自测
- 一个订单多个商品,该用哪几张表?中间表存什么?
- 1NF / 2NF / 3NF 各在防什么坏味道?
- 为什么「下单金额」要在订单/明细里存快照而不是查商品现价?
- 金额、时间、手机号分别该用什么类型?为什么?
- 什么时候才值得反范式冗余?
下一篇:视图、存储过程、函数与触发器。