前言

写 CURD 之前的第一步是设计表结构。表设计不好,后面写代码处处别扭——要么数据冗余得到处 UPDATE,要么查一个东西要 JOIN 七八张表。

这篇文章从实际设计流程出发,从需求分析到最终建表语句。


一、数据库设计的四个阶段

1
2
3
需求分析 → 概念设计 → 逻辑设计 → 物理设计
(搞清楚 (ER 图) (ER 图转 (建表语句、
有什么) 表结构) 索引、分区)

二、需求分析:把业务描述变成数据需求

以一个简化版电商系统为例,业务描述如下:

用户可以注册账号,一个用户可以有多个收货地址。商品有名称、价格、库存、分类。用户下单时从购物车生成订单,一个订单包含多个商品项。支付后订单状态变更。

从这段描述中提取实体关系

实体(Entity):用户、收货地址、商品、商品分类、订单、订单项(订单行)

关系(Relationship)

  • 用户 1:N 收货地址
  • 商品 N:1 商品分类
  • 用户 1:N 订单
  • 订单 1:N 订单项
  • 订单项 N:1 商品

三、概念设计:画 ER 图

用文字描述也可以,但 ER 图更直观。核心要素只有两个:

1
2
3
实体(矩形) ── 关系(菱形) ── 实体(矩形)

1:1 / 1:N / M:N

电商系统的 ER 图(文字版):

1
2
3
4
[用户] ──1:N── [收货地址]
[用户] ──1:N── [订单]
[订单] ──1:N── [订单项] ──N:1── [商品]
[商品] ──N:1── [商品分类]

关键动作:检查所有 M:N 关系——这种关系在逻辑设计阶段需要拆成中间表。


四、逻辑设计:ER 图转表结构

4.1 实体 → 表

每个实体变成一张表:

用户表(users):用户 ID、用户名、密码、手机号、注册时间

商品表(products):商品 ID、分类 ID、名称、价格、库存、描述、状态

分类表(categories):分类 ID、分类名、父分类 ID、排序

订单表(orders):订单 ID、用户 ID、订单号、总金额、状态、创建时间

订单项表(order_items):订单项 ID、订单 ID、商品 ID、数量、单价

收货地址表(addresses):地址 ID、用户 ID、收货人、电话、省市区、详细地址

4.2 1:N 关系 → 外键

1 的一端主键作为 N 的一端的外键。比如 orders.user_id 指向 users.id

4.3 M:N 关系 → 中间表

如果出现 M:N 关系(比如学生和课程是多对多),需要额外建一张中间表。


五、范式设计:三范式就够了

第一范式(1NF):列不可再分

1
2
❌ address: "广东省深圳市南山区"     -- 一串字符串
✅ province: "广东省", city: "深圳市", district: "南山区" -- 分开存

第二范式(2NF):非主键列完全依赖于主键

1
2
3
4
5
❌ 订单项表: (order_id, product_id, product_name, quantity)
product_name 只依赖 product_id,不依赖 order_id(部分依赖)

✅ 拆成两张表:order_items 只存 (order_id, product_id, quantity)
products 里有 product_name

第三范式(3NF):非主键列不传递依赖于主键

1
2
3
4
❌ 订单表: (id, user_id, user_name, user_phone)
user_name 依赖 user_id,user_id 依赖 id(传递依赖)

✅ 订单表只存 user_id,用户信息在 users 表里

六、什么时候该反范式

规范化不是越彻底越好。有些场景故意冗余数据,换取查询性能。

典型反范式场景:

1
2
3
4
5
6
7
8
9
-- 订单项表存了当时的商品单价
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT,
product_id BIGINT,
product_name VARCHAR(200), -- 冗余,但如果商品改名不影响历史订单
price DECIMAL(10,2), -- 冗余,存下单时的价格
quantity INT
);

商品名和单价在 products 表里有,但这里也存了一份。原因是:订单是历史快照——商品后来改名字或涨价,不应该影响已下单的订单记录。

反范式的判断标准:这个字段的值需要”定格在某个时间点”吗?如果需要,就应该冗余存下来。


七、完整建表语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
-- 用户表
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
phone VARCHAR(20) NOT NULL,
avatar VARCHAR(255) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 商品分类表
CREATE TABLE categories (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
parent_id BIGINT DEFAULT 0 COMMENT '0=顶级分类',
sort_order INT DEFAULT 0,
INDEX idx_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表';

-- 商品表
CREATE TABLE products (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
category_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT DEFAULT 0,
description TEXT,
status TINYINT DEFAULT 1 COMMENT '1=上架 0=下架',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_category (category_id),
INDEX idx_status_price (status, price)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

-- 收货地址表
CREATE TABLE addresses (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
receiver_name VARCHAR(50) NOT NULL,
phone VARCHAR(20) NOT NULL,
province VARCHAR(50),
city VARCHAR(50),
district VARCHAR(50),
detail VARCHAR(255),
is_default TINYINT DEFAULT 0,
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货地址表';

-- 订单表
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号',
user_id BIGINT NOT NULL,
total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额',
status VARCHAR(20) DEFAULT 'pending'
COMMENT 'pending=待支付 paid=已支付 shipped=已发货 completed=已完成 cancelled=已取消',
address_snapshot TEXT COMMENT '下单时的地址快照(JSON)',
paid_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_status (status),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

-- 订单项表
CREATE TABLE order_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
product_title VARCHAR(200) COMMENT '下单时商品名(冗余)',
price DECIMAL(10,2) NOT NULL COMMENT '下单时单价',
quantity INT NOT NULL,
INDEX idx_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单项表';

八、设计原则总结

  1. 主键用自增 BIGINT:不用业务字段做主键(业务字段会变),不用 UUID 做主键(随机值会导致页分裂)

  2. 字段避免 NULL:能设默认值就设默认值,NULL 会让索引和查询逻辑变复杂

  3. 金额用 DECIMALDECIMAL(10,2) 精确存储,绝对不能用 FLOAT/DOUBLE

  4. 状态字段用 VARCHAR 而非 TINYINT'pending'0 可读性好得多,维护时不用翻文档查 “0 是什么意思”

  5. 时间字段用 DATETIME:TIMESTAMP 有 2038 年问题,且时区转换容易踩坑

  6. 适当冗余:历史快照类的字段该冗余就冗余,不要为了范式把查询搞得需要关联三四张表

  7. 硬删除 vs 软删除:重要数据用 is_deleted 软删除,日志类数据直接物理删除

数据库设计不是一次性做对的,而是在开发过程中不断优化调整的。但开头花半天把 ER 图画清楚,后面改结构的代价会小很多。