从概念模型到物理表结构的数据库设计方法
前言
写 CURD 之前的第一步是设计表结构。表设计不好,后面写代码处处别扭——要么数据冗余得到处 UPDATE,要么查一个东西要 JOIN 七八张表。
这篇文章从实际设计流程出发,从需求分析到最终建表语句。
一、数据库设计的四个阶段
flowchart LR
P1["📋 1. 需求分析<br/>提炼业务实体与行为关系"] --> P2["🧩 2. 概念设计<br/>建立高内聚 ER 概念模型"]
P2 --> P3["📐 3. 逻辑设计<br/>规范化三范式 / 关系映射表"]
P3 --> P4["💾 4. 物理设计<br/>建表 DDL / 选型存储引擎 / 索引调优"]
classDef stage fill:#f8fafc,stroke:#3b82f6,stroke-width:2px;
class P1,P2,P3,P4 stage;
二、需求分析:把业务描述变成数据需求
以一个简化版电商系统为例,业务描述如下:
用户可以注册账号,一个用户可以有多个收货地址。商品有名称、价格、库存、分类。用户下单时从购物车生成订单,一个订单包含多个商品项。支付后订单状态变更。
从这段描述中提取实体和关系:
实体(Entity):用户、收货地址、商品、商品分类、订单、订单项(订单行)
关系(Relationship):
- 用户 1:N 收货地址
- 商品 N:1 商品分类
- 用户 1:N 订单
- 订单 1:N 订单项
- 订单项 N:1 商品
三、概念设计:画 ER 图
用文字描述也可以,但 ER 矢量图更直观。电商系统的标准实体关系图如下:
erDiagram
USERS ||--o{ ADDRESSES : "1:N 拥有多个收货地址"
USERS ||--o{ ORDERS : "1:N 创建多笔订单"
ORDERS ||--|{ ORDER_ITEMS : "1:N 包含多个商品行"
PRODUCTS ||--o{ ORDER_ITEMS : "1:N 对应销售行"
CATEGORIES ||--o{ PRODUCTS : "1:N 归属商品分类"
USERS {
bigint id PK
varchar username
varchar phone
}
ADDRESSES {
bigint id PK
bigint user_id FK
varchar detail
}
ORDERS {
bigint id PK
varchar order_no
bigint user_id FK
decimal total_amount
}
ORDER_ITEMS {
bigint id PK
bigint order_id FK
bigint product_id FK
int quantity
}
PRODUCTS {
bigint id PK
bigint category_id FK
varchar title
decimal price
}
CATEGORIES {
bigint id PK
varchar name
}
关键动作:检查所有 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 | ❌ address: "广东省深圳市南山区" -- 一串字符串 |
第二范式(2NF):非主键列完全依赖于主键
1 | ❌ 订单项表: (order_id, product_id, product_name, quantity) |
第三范式(3NF):非主键列不传递依赖于主键
1 | ❌ 订单表: (id, user_id, user_name, user_phone) |
六、什么时候该反范式
规范化不是越彻底越好。有些场景故意冗余数据,换取查询性能。
典型反范式场景:
1 | -- 订单项表存了当时的商品单价 |
商品名和单价在 products 表里有,但这里也存了一份。原因是:订单是历史快照——商品后来改名字或涨价,不应该影响已下单的订单记录。
反范式的判断标准:这个字段的值需要”定格在某个时间点”吗?如果需要,就应该冗余存下来。
七、完整建表语句
1 | -- 用户表 |
八、设计原则总结
主键用自增 BIGINT:不用业务字段做主键(业务字段会变),不用 UUID 做主键(随机值会导致页分裂)
字段避免 NULL:能设默认值就设默认值,NULL 会让索引和查询逻辑变复杂
金额用 DECIMAL:
DECIMAL(10,2)精确存储,绝对不能用 FLOAT/DOUBLE状态字段用 VARCHAR 而非 TINYINT:
'pending'比0可读性好得多,维护时不用翻文档查 “0 是什么意思”时间字段用 DATETIME:TIMESTAMP 有 2038 年问题,且时区转换容易踩坑
适当冗余:历史快照类的字段该冗余就冗余,不要为了范式把查询搞得需要关联三四张表
硬删除 vs 软删除:重要数据用
is_deleted软删除,日志类数据直接物理删除
数据库设计不是一次性做对的,而是在开发过程中不断优化调整的。但开头花半天把 ER 图画清楚,后面改结构的代价会小很多。