跳到主要内容

创建日期:2026-09-08 | 最近更新:2026-09-08 本篇偏「设计方法论」,示例结合本系列 shop 库(customers / products / orders / order_items)讲,不依赖额外实测。

MySQL 设计篇 5:表设计与范式——别让表结构成为项目的债

表设计是欠债 vs 还债的游戏:早期随便建表,后期加字段、拆表、改约束的成本指数上升。这篇讲一套「先按范式想清楚、再按实际反范式」的方法,让表结构禁得起需求演进。

1. 设计的起点:先想「实体」和「关系」

拿到需求先画三样东西(别急着写 CREATE TABLE):

  1. 实体:顾客、商品、订单……(一个实体一张主表);
  2. 属性:顾客有 name/email/city(一个属性一列);
  3. 关系
    • 1 对 N:一个顾客 → 多个订单(顾客表和订单表,用 orders.customer_id 指向顾客);
    • N 对 M:一个订单含多个商品、一个商品出现在多个订单 → 需要中间表 order_items(含 order_id + product_id)。

本系列 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:每一格只放一个值,列别重复

❌ 坏:ordersitems = '苹果×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. 数据类型选择(选错很痛的几个)

数据别用
主键 idBIGINT 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_idcreated_at;布尔 is_/has_ 前缀;
  • 每张表必有:id 主键 + created_at;需要审计加 updated_at
  • 外键列要能一眼看出指向:customer_idcustomers(id)
  • 软删除 vs 硬删除:数据要留痕(订单、用户)用 deleted_at/status;日志类可直接 DELETE;
  • 索引:所有外键列 + 高频 WHERE 列(篇 3)在上线前想好,别等慢查询来了再补。

6. 案例复盘:把 shop 的设计「为什么这样」讲明白

主键外键/关键列设计理由
customersidemail UNIQUE、city 有索引实体;email 业务唯一
productsidprice DECIMAL实体
ordersidcustomer_id FK、status、amount一个顾客 N 个订单 → 外键;金额在订单上冗余快照
order_itemsidorder_id FK、product_id FK、qty、priceN: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), ...
);

拆成几张表?各自主键/外键是什么?

自测

  1. 一个订单多个商品,该用哪几张表?中间表存什么?
  2. 1NF / 2NF / 3NF 各在防什么坏味道?
  3. 为什么「下单金额」要在订单/明细里存快照而不是查商品现价?
  4. 金额、时间、手机号分别该用什么类型?为什么?
  5. 什么时候才值得反范式冗余?

下一篇:视图、存储过程、函数与触发器