MySQL 实战精通系列 · 第2篇:数据建模与建表实战
本篇目标:以电商系统为例,搞懂表该怎么设计、字段类型怎么选、范式怎么用、索引怎么初步规划。
文章目录
- [MySQL 实战精通系列 · 第2篇:数据建模与建表实战](#MySQL 实战精通系列 · 第2篇:数据建模与建表实战)
-
- [一、为什么建模是 MySQL 实战的第一道分水岭?](#一、为什么建模是 MySQL 实战的第一道分水岭?)
- [二、ER 模型:先画图,再建表](#二、ER 模型:先画图,再建表)
-
- [2.1 电商核心实体](#2.1 电商核心实体)
- [2.2 实体关系图](#2.2 实体关系图)
- [2.3 关系类型](#2.3 关系类型)
- 三、字段类型选择实战
-
- [3.1 整数类型](#3.1 整数类型)
- [3.2 字符串类型](#3.2 字符串类型)
- [3.3 时间类型](#3.3 时间类型)
- [3.4 金额类型](#3.4 金额类型)
- [3.5 类型选择速查表](#3.5 类型选择速查表)
- 四、范式与反范式
-
- [4.1 三大范式](#4.1 三大范式)
- [4.2 什么时候该反范式?](#4.2 什么时候该反范式?)
- [五、完整建表 SQL(带注释)](#五、完整建表 SQL(带注释))
- [六、生成 10 万条测试数据](#六、生成 10 万条测试数据)
-
- [6.1 用存储过程批量插入](#6.1 用存储过程批量插入)
- [6.2 生成商品数据](#6.2 生成商品数据)
- [6.3 生成订单数据](#6.3 生成订单数据)
- [6.4 验证数据量](#6.4 验证数据量)
- 七、建模检查清单
- 八、本篇实战任务
- 九、本篇小结
一、为什么建模是 MySQL 实战的第一道分水岭?
很多人写 SQL 很溜,但表一设计就出问题:
- 用字符串存时间,查询慢还占空间
- 用逗号分隔多值,无法索引
- 该唯一的字段没加唯一约束,数据重复
- 该拆的表没拆,一张表几百个字段
- 该冗余的没冗余,每次查询要 JOIN 五张表
这些问题,后期优化很难弥补。建模错了,后面全是坑。
建模正确
│
├── 查询简单
├── 索引有效
├── 扩展容易
└── 维护成本低
建模错误
│
├── 查询复杂
├── 索引失效
├── 扩展困难
└── 维护成本高
二、ER 模型:先画图,再建表
2.1 电商核心实体
| 实体 | 说明 | 对应表 |
|---|---|---|
| 用户 | 买东西的人 | users |
| 分类 | 商品分类 | categories |
| 商品 | 被购买的东西 | products |
| 订单 | 一次购买行为 | orders |
| 订单项 | 订单里的具体商品 | order_items |
| 支付 | 订单的支付记录 | payments |
| 地址 | 用户的收货地址 | addresses |
2.2 实体关系图
┌──────────┐ ┌──────────┐ ┌──────────┐
│ users │ │ orders │ │order_items│
├──────────┤ ├──────────┤ ├──────────┤
│ id (PK) │────┐ │ id (PK) │────┐ │ id (PK) │
│ username │ │ │ user_id │ │ │ order_id │
│ phone │ └───>│ total │ └───>│ product_id│
│ created │ │ status │ │ quantity │
└──────────┘ │ created │ │ price │
└──────────┘ └──────────┘
│ ↑
│ │
↓ │
┌──────────┐ ┌──────────┐
│ payments │ │ products │
├──────────┤ ├──────────┤
│ id (PK) │ │ id (PK) │
│ order_id │ │ name │
│ amount │ │ price │
│ status │ │ stock │
│ paid_at │ │category_id│
└──────────┘ │ created │
└──────────┘
↑
│
┌──────────┐
│categories│
├──────────┤
│ id (PK) │
│ name │
│ parent_id│
└──────────┘
┌──────────┐
│ addresses│
├──────────┤
│ id (PK) │
│ user_id │
│ detail │
│ phone │
└──────────┘
2.3 关系类型
一对多(1:N)
users 1 ──── N orders
orders 1 ──── N order_items
users 1 ──── N addresses
categories 1 ──── N products
多对多(M:N)
orders M ──── N products
通过 order_items 中间表实现
一对一(1:1)
orders 1 ──── 1 payments(简化场景)
三、字段类型选择实战
3.1 整数类型
| 类型 | 范围 | 占用 | 适用场景 |
|---|---|---|---|
| TINYINT | -128~127 | 1字节 | 状态、布尔 |
| SMALLINT | -32768~32767 | 2字节 | 小范围数值 |
| INT | -21亿~21亿 | 4字节 | 普通 ID、数量 |
| BIGINT | 极大 | 8字节 | 主键、大数值 |
实战建议:
- 主键统一用
BIGINT UNSIGNED - 状态用
TINYINT - 数量用
INT - 不要用
INT(11)这种写法,宽度不影响存储
sql
-- 推荐
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT
status TINYINT NOT NULL DEFAULT 0
stock INT NOT NULL DEFAULT 0
-- 不推荐
id INT(11) NOT NULL AUTO_INCREMENT
status VARCHAR(10) NOT NULL DEFAULT '0'
3.2 字符串类型
| 类型 | 特点 | 适用场景 |
|---|---|---|
| CHAR | 定长,不足补空格 | 固定长度:手机号、MD5 |
| VARCHAR | 变长,按实际长度存 | 大多数场景:用户名、地址 |
| TEXT | 大文本,不能有默认值 | 文章内容、描述 |
| JSON | 结构化存储 | 扩展属性、配置 |
实战建议:
- 手机号用
VARCHAR(20),不用 BIGINT(可能带 +86) - 用户名用
VARCHAR(64) - 地址用
VARCHAR(255) - 能用 VARCHAR 就别用 TEXT
- JSON 谨慎用,无法直接索引(MySQL 8.0 支持虚拟列索引)
sql
-- 推荐
username VARCHAR(64) NOT NULL
phone VARCHAR(20) NOT NULL
detail VARCHAR(255) NOT NULL
-- 不推荐
username TEXT
phone BIGINT
3.3 时间类型
| 类型 | 范围 | 时区 | 适用场景 |
|---|---|---|---|
| DATETIME | 1000~9999 | 不受时区影响 | 大多数业务时间 |
| TIMESTAMP | 1970~2038 | 受时区影响 | 创建时间、更新时间 |
| DATE | 仅日期 | --- | 生日、日期 |
| TIME | 仅时间 | --- | 时长 |
实战建议:
- 业务时间用
DATETIME - 创建/更新时间用
DATETIME DEFAULT CURRENT_TIMESTAMP - 不要用字符串存时间
- 不要用 INT 存时间戳(可读性差)
sql
-- 推荐
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
paid_at DATETIME DEFAULT NULL
-- 不推荐
created_at VARCHAR(20)
created_at INT
3.4 金额类型
| 类型 | 特点 | 适用场景 |
|---|---|---|
| DECIMAL | 精确小数 | 金额、价格 |
| FLOAT | 浮点,有精度损失 | 科学计算 |
| DOUBLE | 双精度,有精度损失 | 科学计算 |
实战建议:
- 金额一律用
DECIMAL(10,2) - 不要用 FLOAT/DOUBLE 存金额
sql
-- 推荐
price DECIMAL(10,2) NOT NULL
total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00
-- 不推荐
price FLOAT
price DOUBLE
3.5 类型选择速查表
| 字段 | 推荐类型 | 理由 |
|---|---|---|
| 主键 | BIGINT UNSIGNED | 足够大,不会用完 |
| 状态 | TINYINT | 省空间,语义清晰 |
| 数量 | INT | 够用 |
| 手机号 | VARCHAR(20) | 可能带区号 |
| 用户名 | VARCHAR(64) | 变长 |
| 地址 | VARCHAR(255) | 变长 |
| 金额 | DECIMAL(10,2) | 精确 |
| 时间 | DATETIME | 不受时区影响 |
| 布尔 | TINYINT(1) | MySQL 没有真正布尔 |
四、范式与反范式
4.1 三大范式
第一范式(1NF):字段不可再分
错误:address = "北京市朝阳区xxx"
正确:province / city / detail 分开
第二范式(2NF):非主键字段完全依赖主键
错误:order_items 里存 product_name
正确:只存 product_id,需要时 JOIN
第三范式(3NF):非主键字段不依赖其他非主键字段
错误:orders 里存 user_name
正确:只存 user_id,需要时 JOIN
4.2 什么时候该反范式?
反范式场景
│
├── 查询频繁,JOIN 成本高
├── 数据变化少,冗余安全
├── 性能要求高,空间换时间
└── 历史快照,需要保留当时值
不反范式场景
│
├── 数据频繁变化
├── 一致性要求高
└── 空间敏感
电商实战中的反范式:
| 场景 | 反范式做法 | 理由 |
|---|---|---|
| 订单项 | 存 price 快照 | 商品改价不影响历史订单 |
| 订单 | 存 total_amount | 避免每次汇总 |
| 订单 | 存收货地址快照 | 用户改地址不影响历史订单 |
sql
-- order_items 存 price 快照
CREATE TABLE order_items (
...
price DECIMAL(10,2) NOT NULL, -- 下单时的价格
...
);
-- orders 存地址快照
CREATE TABLE orders (
...
receiver_name VARCHAR(64),
receiver_phone VARCHAR(20),
receiver_address VARCHAR(255),
...
);
五、完整建表 SQL(带注释)
sql
USE shop;
-- 用户表
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(64) NOT NULL COMMENT '用户名',
phone VARCHAR(20) NOT NULL COMMENT '手机号',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 分类表
CREATE TABLE categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID',
name VARCHAR(64) NOT NULL COMMENT '分类名',
parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分类ID,0为顶级',
PRIMARY KEY (id),
KEY idx_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表';
-- 商品表
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品ID',
name VARCHAR(128) NOT NULL COMMENT '商品名',
price DECIMAL(10,2) NOT NULL COMMENT '价格',
stock INT NOT NULL DEFAULT 0 COMMENT '库存',
category_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '分类ID',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_name (name),
KEY idx_category (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
-- 订单表
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID',
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单总额',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1已支付 2已发货 3已完成 4已取消',
receiver_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '收货人',
receiver_phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '收货电话',
receiver_address VARCHAR(255) NOT NULL DEFAULT '' COMMENT '收货地址',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_user_created (user_id, created_at),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
-- 订单项表
CREATE TABLE order_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单项ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单ID',
product_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID',
product_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名快照',
quantity INT NOT NULL COMMENT '数量',
price DECIMAL(10,2) NOT NULL COMMENT '下单时单价',
PRIMARY KEY (id),
KEY idx_order (order_id),
KEY idx_product (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单项表';
-- 支付表
CREATE TABLE payments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '支付ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单ID',
amount DECIMAL(10,2) NOT NULL COMMENT '支付金额',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1成功 2失败',
paid_at DATETIME DEFAULT NULL COMMENT '支付时间',
PRIMARY KEY (id),
KEY idx_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付表';
-- 地址表
CREATE TABLE addresses (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '地址ID',
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
detail VARCHAR(255) NOT NULL COMMENT '详细地址',
phone VARCHAR(20) NOT NULL COMMENT '联系电话',
PRIMARY KEY (id),
KEY idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户地址表';
六、生成 10 万条测试数据
6.1 用存储过程批量插入
sql
DELIMITER $$
CREATE PROCEDURE gen_users(IN cnt INT)
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < cnt DO
INSERT INTO users (username, phone)
VALUES (CONCAT('user_', i), CONCAT('138', LPAD(i, 8, '0')));
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
CALL gen_users(100000);
6.2 生成商品数据
sql
DELIMITER $$
CREATE PROCEDURE gen_products(IN cnt INT)
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < cnt DO
INSERT INTO products (name, price, stock, category_id)
VALUES (
CONCAT('商品_', i),
ROUND(RAND() * 10000, 2),
FLOOR(RAND() * 1000),
FLOOR(RAND() * 5) + 1
);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
CALL gen_products(10000);
6.3 生成订单数据
sql
DELIMITER $$
CREATE PROCEDURE gen_orders(IN cnt INT)
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < cnt DO
INSERT INTO orders (user_id, total_amount, status)
VALUES (
FLOOR(RAND() * 100000) + 1,
ROUND(RAND() * 10000, 2),
FLOOR(RAND() * 5)
);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
CALL gen_orders(100000);
6.4 验证数据量
sql
SELECT 'users' AS tbl, COUNT(*) AS cnt FROM users
UNION ALL
SELECT 'products', COUNT(*) FROM products
UNION ALL
SELECT 'orders', COUNT(*) FROM orders;
七、建模检查清单
建表前必问
│
├── 主键设计
│ ├── 用 BIGINT UNSIGNED?
│ └── 自增还是业务主键?
│
├── 字段类型
│ ├── 金额用 DECIMAL?
│ ├── 时间用 DATETIME?
│ ├── 状态用 TINYINT?
│ └── 字符串长度合理?
│
├── 约束
│ ├── 该唯一的加了唯一索引?
│ ├── 非空字段加了 NOT NULL?
│ └── 默认值合理?
│
├── 范式
│ ├── 是否满足 1NF/2NF/3NF?
│ └── 哪些字段需要反范式?
│
├── 索引
│ ├── 外键字段加索引?
│ ├── 高频查询字段加索引?
│ └── 联合索引顺序合理?
│
└── 扩展性
├── 未来可能加什么字段?
└── 数据量增长后怎么拆?
八、本篇实战任务
任务清单
- 画出电商 ER 图
- 执行完整建表 SQL
- 用存储过程生成 10 万用户、1 万商品、10 万订单
- 验证数据量
- 检查每张表的字段类型是否合理
- 找出可以反范式的地方并说明理由
自检问题
- 为什么金额要用 DECIMAL 而不是 FLOAT?
- 为什么手机号用 VARCHAR 而不是 BIGINT?
- 什么情况下该反范式?电商里哪些字段适合反范式?
- 为什么订单项要存 price 快照?
- 主键为什么推荐 BIGINT UNSIGNED?
九、本篇小结
第2篇 核心收获
│
├── ER 建模
│ ├── 7 张核心表
│ └── 1:N、M:N 关系
│
├── 字段类型
│ ├── 整数:BIGINT/TINYINT/INT
│ ├── 字符串:VARCHAR/CHAR/TEXT
│ ├── 时间:DATETIME
│ └── 金额:DECIMAL
│
├── 范式
│ ├── 1NF/2NF/3NF
│ └── 反范式:快照、冗余
│
├── 建表 SQL
│ ├── 主键、索引、约束
│ └── 注释规范
│
└── 测试数据
├── 存储过程批量插入
└── 10万+数据
下一篇:第3篇《SQL 核心与复杂查询实战》