数据建模实战
问题
如何进行数据建模?常见业务场景(电商、社交、内容系统)的数据模型如何设计?
答案
数据建模方法论
ER 模型核心要素
- 实体(Entity):业务中的对象(用户、商品、订单)
- 属性(Attribute):实体的特征(用户名、价格)
- 关系(Relationship):实体间的联系(1:1、1:N、M:N)
建模流程
电商系统数据模型
关键设计决策:
| 决策 | 选择 | 原因 |
|---|---|---|
| 订单金额 | 订单表存总金额 | 避免每次查询都聚合计算 |
| 商品快照 | order_items 存当时的价格和名称 | 商品修改后不影响历史订单 |
| 地址 | 独立地址表 + 订单冗余 | 地址可修改,但订单地址不能变 |
| 库存 | products 表存 stock | 简单场景够用,高并发需独立库存服务 |
-- 订单明细表:保存下单时的商品快照
CREATE TABLE order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
product_name VARCHAR(200) NOT NULL, -- 快照:下单时的商品名
product_price DECIMAL(10,2) NOT NULL, -- 快照:下单时的价格
quantity INT NOT NULL,
subtotal DECIMAL(10,2) NOT NULL,
INDEX idx_order_id(order_id)
);
社交系统数据模型
关注关系(M:N):
-- 用户关注关系
CREATE TABLE follows (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
follower_id BIGINT NOT NULL, -- 关注者
following_id BIGINT NOT NULL, -- 被关注者
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_follow(follower_id, following_id),
INDEX idx_following(following_id)
);
-- 查询 A 关注了谁
SELECT following_id FROM follows WHERE follower_id = ?;
-- 查询谁关注了 A
SELECT follower_id FROM follows WHERE following_id = ?;
朋友圈/Feed 流:
| 方案 | 说明 | 优势 | 劣势 |
|---|---|---|---|
| 拉模型 | 查询时拉取关注人的动态 | 写入简单 | 查询慢(扇出读) |
| 推模型 | 发布时推送到粉丝收件箱 | 查询快 | 写入多(大 V 问题) |
| 推拉结合 | 大 V 用拉,普通用户用推 | 均衡 | 实现复杂 |
内容管理数据模型
树形结构(分类/评论):
-- 方案 1:邻接表(最常用)
CREATE TABLE categories (
id BIGINT PRIMARY KEY,
parent_id BIGINT DEFAULT 0, -- 父级 ID,0 表示顶级
name VARCHAR(100),
level INT, -- 层级深度
sort_order INT,
INDEX idx_parent(parent_id)
);
-- 方案 2:路径枚举(适合读多写少)
CREATE TABLE categories (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
path VARCHAR(500), -- 如 '/1/3/7/'
INDEX idx_path(path)
);
-- 查询某节点的所有子节点
SELECT * FROM categories WHERE path LIKE '/1/3/%';
-- 方案 3:闭包表(适合深层嵌套,如评论楼中楼)
CREATE TABLE comment_paths (
ancestor_id BIGINT, -- 祖先节点
descendant_id BIGINT, -- 后代节点
depth INT, -- 深度差
PRIMARY KEY (ancestor_id, descendant_id)
);
| 方案 | 查子节点 | 查祖先链 | 移动节点 | 适用场景 |
|---|---|---|---|---|
| 邻接表 | 递归查 | 递归查 | 简单 | 通用、层级不深 |
| 路径枚举 | LIKE 查 | 字符串切割 | 需更新所有子节点 | 读多写少 |
| 闭包表 | 关联查 | 关联查 | 需删除重建 | 深层嵌套 |
多态关联设计
一个实体关联多种不同类型的实体(如评论可以属于文章/商品/视频):
-- 方案 1:通用外键(不推荐,无法加 FK 约束)
CREATE TABLE comments (
id BIGINT PRIMARY KEY,
target_type VARCHAR(20), -- 'article', 'product', 'video'
target_id BIGINT,
content TEXT,
INDEX idx_target(target_type, target_id)
);
-- 方案 2:独立关联表(推荐)
CREATE TABLE article_comments (
comment_id BIGINT,
article_id BIGINT,
FOREIGN KEY (article_id) REFERENCES articles(id)
);
CREATE TABLE product_comments (
comment_id BIGINT,
product_id BIGINT,
FOREIGN KEY (product_id) REFERENCES products(id)
);
通用设计原则
| 原则 | 说明 |
|---|---|
| 选择合适的主键 | 推荐自增 BIGINT 或分布式 ID,避免 UUID 做聚簇索引 |
| 合理使用 NOT NULL | 尽量定义 NOT NULL + DEFAULT,减少 NULL 判断 |
| 选择合适的数据类型 | 用最小够用的类型:TINYINT、SMALLINT、日期用 DATETIME |
| 避免过早优化 | 先按 3NF 设计,遇到性能瓶颈再反范式 |
| 预留扩展字段 | JSON 列或 ext_info 存储非核心扩展数据 |
| 软删除 | is_deleted 标记代替物理删除 |
常见面试问题
Q1: 为什么不推荐 UUID 做主键?
答案:
InnoDB 使用聚簇索引按主键顺序存储数据:
- UUID 无序:插入时数据随机分布,导致频繁页分裂,写入慢
- 占用空间大:UUID 36 字节 vs BIGINT 8 字节,索引更大
- 比较效率低:字符串比较比整数慢
推荐:自增 BIGINT 或雪花算法生成的有序 ID。
Q2: 订单表为什么要保存商品快照?
答案:
商品信息(名称、价格、图片)可能随时变更,但历史订单必须保留下单时的信息:
- 用户查看历史订单时,应看到下单时的价格
- 发生退款纠纷时,以下单时的价格为准
做法:在 order_items 表中冗余 product_name、product_price、product_image 等字段。
Q3: 如何设计评论的楼中楼结构?
答案:
推荐方案:邻接表 + 冗余 root_id
CREATE TABLE comments (
id BIGINT PRIMARY KEY,
parent_id BIGINT DEFAULT 0, -- 上级评论(0=顶级)
root_id BIGINT DEFAULT 0, -- 所属的顶级评论
article_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
content TEXT,
INDEX idx_article(article_id, id),
INDEX idx_root(root_id, id)
);
查询方式:
- 先查文章的顶级评论(
parent_id = 0),分页 - 再查每条顶级评论的回复(
root_id = ?),LIMIT 前几条 - 点击"展开更多回复"时加载更多