【数据库】MySQL 实战精通系列 · 第2篇:数据建模与建表实战

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 万订单
  • 验证数据量
  • 检查每张表的字段类型是否合理
  • 找出可以反范式的地方并说明理由

自检问题

  1. 为什么金额要用 DECIMAL 而不是 FLOAT?
  2. 为什么手机号用 VARCHAR 而不是 BIGINT?
  3. 什么情况下该反范式?电商里哪些字段适合反范式?
  4. 为什么订单项要存 price 快照?
  5. 主键为什么推荐 BIGINT UNSIGNED?

九、本篇小结

复制代码
第2篇 核心收获
│
├── ER 建模
│   ├── 7 张核心表
│   └── 1:N、M:N 关系
│
├── 字段类型
│   ├── 整数:BIGINT/TINYINT/INT
│   ├── 字符串:VARCHAR/CHAR/TEXT
│   ├── 时间:DATETIME
│   └── 金额:DECIMAL
│
├── 范式
│   ├── 1NF/2NF/3NF
│   └── 反范式:快照、冗余
│
├── 建表 SQL
│   ├── 主键、索引、约束
│   └── 注释规范
│
└── 测试数据
    ├── 存储过程批量插入
    └── 10万+数据

下一篇:第3篇《SQL 核心与复杂查询实战》

相关推荐
夜雪一千1 小时前
MySQL自定义函数应该如何设计
数据库·mysql
一条小小yu1 小时前
mysql的锁
数据库·mysql·oracle
Martin -Tang1 小时前
uniapp app嵌套webview 弹窗方式
android·ios·uni-app
fb_123451 小时前
MySQL基础核心|数据库原理+MySQL详解
数据库·mysql
‎ദ്ദിᵔ.˛.ᵔ₎1 小时前
Mysql 基本查询
数据库·mysql
-madongyu-2 小时前
GaussDB 性能调优:等待事件、慢 SQL 定位与关键参数
数据库·sql·gaussdb
-madongyu-2 小时前
GaussDB 部署规划:磁盘水位、RAID 与端口设计
数据库·gaussdb
weixin_511255219 小时前
MySQL升级记录
数据库·mysql
程序猿乐锅10 小时前
【黑马点评 | 第八篇】Redisson分布式锁
java·数据库·spring boot·redis·分布式·spring·缓存