主题
表与关系建模
核心原则
数据库建模主要考虑:
- 从业务中识别实体。
- 判断实体之间的关系。
- 根据关系决定外键或关联表。
- 判断字段属于实体本身,还是实体之间的关系。
- 区分实时引用数据与历史快照数据。
关系由业务规则决定。同一种业务对象,在不同系统中可能采用不同的关系设计。
一对一(1:1)
一个 A 最多对应一个 B,一个 B 最多对应一个 A。
例如:
text
用户 1 ─── 1 用户资料通常只需要一侧保存外键:
text
用户资料表
- id
- user_id
- avatar
- bio如果需要数据库严格保证 1:1,外键通常还需要唯一约束:
text
user_id UNIQUE是否拆表
1:1 不代表必须拆表。
适合放在同一张表:
- 数据只是主体的一组普通属性
- 字段较少
- 生命周期基本一致
- 通常一起查询
例如订单的收货信息:
text
订单表
- id
- receiver_name
- receiver_phone
- detail_address适合拆表:
- 已形成相对独立的业务概念
- 字段较多
- 生命周期不同
- 查询方式明显不同
例如:
text
用户表
- id
- email
- password
用户资料表
- user_id
- avatar
- bio
- birthday
- company一对多(1:N)
一个 A 可以对应多个 B,一个 B 只属于一个 A。
核心规则:
外键通常放在 N 的一方。
例如:
text
用户 1 ─── N 订单设计:
text
用户表
- id
订单表
- id
- user_id常见形式:
text
分类 1 ─── N 商品
→ product.category_id
文章 1 ─── N 评论
→ comment.article_id
商品 1 ─── N SKU
→ sku.product_id多对多(N:N)
一个 A 可以对应多个 B,一个 B 也可以对应多个 A。
通常需要使用关联表 / 中间表:
text
文章 N ←──→ N 标签text
文章表
- id
标签表
- id
文章标签表
- article_id
- tag_id关联表的一行表示两个实体之间的一条关系。
判断条件
必须同时满足:
text
一个 A → 多个 B
一个 B → 多个 A例如:
text
一个商品只能属于一个分类
一个分类可以有多个商品属于 1:N。
如果业务规则是:
text
一个商品可以属于多个分类
一个分类可以包含多个商品才属于 N:N。
关联表的业务属性
关联表不仅可以保存双方外键,还可以保存关系自身产生的数据。
例如:
text
用户 N ←──→ N 项目text
项目成员表
- id
- user_id
- project_id
- role
- joined_at其中:
text
role
joined_at描述的是「某个用户参与某个项目」这段关系,而不是用户或项目本身。
其他典型情况:
text
学生课程表
- student_id
- course_id
- score
- enrolled_atscore、enrolled_at 都属于学生与课程之间的关系。
关联表与业务实体
当关联表具有明确业务含义和较多业务字段时,可以将其视为一个业务实体,并按业务含义命名。
订单明细
订单与商品存在 N:N:
text
订单 N ←──→ N 商品通过订单明细拆解:
text
订单 1 ─── N 订单明细
商品 1 ─── N 订单明细text
订单明细表 order_item
- id
- order_id
- product_id
- product_name
- unit_price
- quantity此时相比 order_product_relation,order_item 更准确地表达业务含义。
用户优惠券
text
优惠券表 coupon
- id
- threshold_amount
- discount_amounttext
用户优惠券表 user_coupon
- id
- user_id
- coupon_id
- received_at
- status
- order_id
- used_at区别:
text
coupon
→ 优惠券定义 / 模板
user_coupon
→ 实际发放给某个用户的一张优惠券order_id 只是记录这张用户优惠券最终在哪个订单使用,不代表必须建立「用户 + 订单 + 优惠券」三方关联表。
自关联
一张表的外键可以引用自身,用于表达同类实体之间的层级关系。
多级分类
text
分类表
- id
- parent_id
- name关系:
text
category.parent_id
↓
category.id本质仍然是 1:N:
text
一个父分类 → 多个子分类
一个子分类 → 最多一个父分类顶级分类通常使用:
text
parent_id = NULL表示没有父分类。
评论回复
text
评论表
- id
- article_id
- user_id
- content
- parent_id例如:
text
评论1
├── 评论2
│ └── 评论3
└── 评论4对应:
text
评论2.parent_id = 评论1.id
评论3.parent_id = 评论2.id顶层评论:
text
parent_id = NULL快照数据
历史业务数据不能总是依赖实体的当前数据。
例如下单时:
text
商品名称:iPhone 17
商品单价:5999之后商品修改为:
text
商品名称:Apple iPhone 17
商品单价:5499历史订单仍应保留下单时的数据。
因此订单明细可以同时保存:
text
product_id // 引用原商品
product_name // 下单时名称快照
unit_price // 下单时价格快照
quantity // 本次购买数量外键与快照可以同时存在
product_id 表示:
当时购买的是哪个商品。
product_name、unit_price 表示:
业务发生时商品的实际状态。
两者用途不同,不冲突。
常见快照数据
订单中常见:
text
商品名称
SKU 规格
商品图片
商品单价
收件人
联系电话
收货地址快照产生的数据冗余属于有业务意义的冗余,不能简单以「消除重复数据」为理由删除。
字段归属判断
字段应该放在哪张表,取决于它描述的对象。
描述实体本身
放实体表:
text
商品名称 → 商品表
用户邮箱 → 用户表
订单状态 → 订单表描述两个实体之间的关系
放关联表:
text
学生 + 课程 → 成绩
用户 + 项目 → 项目角色
用户 + 商品 → 收藏时间描述整张订单
放订单表:
text
订单状态
订单总金额
收货地址快照描述订单中的某个商品
放订单明细表:
text
商品名称快照
商品单价快照
购买数量核心判断:
字段描述的是实体本身,还是某段关系 / 某次业务行为。
实时引用与历史数据
实时数据
需要反映实体当前状态:
text
商品当前名称
商品当前价格
用户当前地址通常直接引用或查询对应实体。
历史数据
需要保持业务发生时的状态:
text
下单时商品名称
下单时商品价格
下单时收货地址通常保存快照。
数据库设计的目标不是消除所有重复数据,而是:
根据业务语义决定使用实时引用还是历史快照。
关系设计速查
text
1:1
→ 通常一侧保存外键
→ 是否拆表取决于业务边界
1:N
→ 外键通常放在 N 方
N:N
→ 使用关联表保存双方外键
关系本身产生数据
→ 字段放关联表
关联表形成明确业务概念
→ 使用业务名称,如 order_item、project_member
同类实体存在层级关系
→ 使用自关联 parent_id
历史数据不能随原数据变化
→ 保存快照
外键引用 + 历史快照
→ 可以同时存在建模检查项
拿到业务需求时,重点判断:
text
1. 有哪些独立实体?
2. 实体之间是 1:1、1:N 还是 N:N?
3. 外键应该放在哪一侧?
4. 是否需要关联表?
5. 字段属于实体还是关系?
6. 关联关系是否已经形成独立业务实体?
7. 是否存在同表内部的层级关系?
8. 数据应该实时引用还是保存历史快照?
9. 1:1 数据是否有必要拆表?