表关系设计

为什么拆表

一张表只存一类东西。

users      → 存用户信息
posts      → 存文章
categories → 存分类
comments   → 存评论
tags       → 存标签

拆完之后,问题来了:这些表怎么关联?

答案是外键——在一张表里存另一张表的 id,再用约束保证关系正确。

常见关系只有三种:一对一、一对多、多对多。下面逐个讲清楚每种关系怎么建表、用什么约束、为什么这么用


一、一对一(1:1)

A 有一条,B 也只有一条。

例子

用户的基本信息(账号密码)在 users 表,详细资料(生日、签名、头像)在 user_profiles 表。

users 表          user_profiles 表
┌──────────┐      ┌──────────────────┐
│ id       │◄─────│ user_id (外键)    │
│ username │      │ birthday         │
│ password │      │ bio              │
└──────────┘      │ avatar_url       │
                  └──────────────────┘

建表 SQL

CREATE TABLE user_profiles (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    birthday DATE,
    bio VARCHAR(500),
    avatar_url VARCHAR(255),
    PRIMARY KEY (id),
    UNIQUE (user_id),                    -- ① 保证一对一
    FOREIGN KEY (user_id) REFERENCES users(id)  -- ② 引用 users 表
);

用到了什么约束,为什么

UNIQUE (user_id) —— 这是区分一对一和一对多的关键

没有 UNIQUE,同一个用户可以插入多条资料:

iduser_idbio
110资料A
210资料B

加了 UNIQUEuser_id = 10 只能出现一次,第二次插入直接报错。这才是一对一。

FOREIGN KEY (user_id) REFERENCES users(id) —— 保证引用的用户存在

外键的作用:user_profiles.user_id 的值必须在 users.id 里真实存在。

-- 假设 users 表没有 id=999 的用户
INSERT INTO user_profiles (user_id, bio) VALUES (999, '测试');
-- ❌ 报错:foreign key constraint fails

不会出现"资料属于一个不存在的用户"这种情况。

什么时候拆,什么时候不拆

字段少、经常一起查 → 直接放 users 表,简单。

字段多、不常用、涉及隐私 → 拆出去做一对一。


二、一对多(1:N)

A 有很多个 B,但每个 B 只属于一个 A。

最常见的关系,几乎每个项目都有。

例子

一个用户写了很多篇文章
一个分类下有很多篇文章
一篇文章下有很多条评论
users 表              posts 表
┌──────────┐          ┌──────────────────┐
│ id       │◄─────────│ user_id (外键)    │
│ username │          │ category_id      │
└──────────┘          │ title            │
                      └──────────────────┘
categories 表              │
┌──────────┐               │
│ id       │◄──────────────┘
│ name     │
└──────────┘

建表 SQL

CREATE TABLE posts (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,          -- ① 必须有作者
    category_id BIGINT UNSIGNED NULL,          -- ② 可以没分类
    title VARCHAR(255) NOT NULL,
    content TEXT,
    PRIMARY KEY (id),
    FOREIGN KEY (user_id) REFERENCES users(id),           -- ③ 引用 users
    FOREIGN KEY (category_id) REFERENCES categories(id)   -- ④ 引用 categories
);

用到了什么约束,为什么

NOT NULL —— 关系必须存在

文章必须有作者,所以 user_idNOT NULL。插入时不填这个字段直接报错。

② 允许 NULL —— 关系可选

文章可以暂时没有分类,所以 category_id 允许 NULL

关系必须存在 → NOT NULL
关系可选     → NULL

③④ FOREIGN KEY —— 保证引用的数据存在

外键放在"多"的一方(posts 表),而不是在 users 表里存 '1,2,3' 这种字符串。

为什么?想想现实:一个老师带 100 个学生,你不会在老师档案里写 100 个学生名字,而是在每个学生的档案里写"我的老师是谁"。

博客里的一对多汇总

谁是"一"谁是"多"外键放哪NOT NULL?
用户文章posts.user_id✅ 必须有作者
分类文章posts.category_id❌ 分类可选
文章评论comments.post_id✅ 评论必须属于文章
用户评论comments.user_id✅ 评论必须有评论者

三、多对多(N:N)

A 有很多个 B,B 也有很多个 A。

例子

一篇文章可以有多个标签("MySQL"、"后端"、"入门")
一个标签可以属于多篇文章("MySQL" 标签下有 50 篇文章)

为什么不能直接存

posts 表加个 tag_ids 字段存 '1,2,3'

❌ 查询 "MySQL 标签有哪些文章" → 只能用 LIKE '%1%',慢得要死
❌ 外键约束加不上
❌ 可能存成 '1,1,2' 这种重复值
❌ 删除某个标签后,还要去每个文章里改字符串

正确做法:中间表

把多对多拆成两个一对多:

posts 表          post_tags 中间表         tags 表
┌──────────┐      ┌──────────────┐        ┌──────────┐
│ id       │◄─────│ post_id      │        │ id       │
│ title    │      │ tag_id       │───────►│ name     │
└──────────┘      └──────────────┘        └──────────┘

中间表只存关系:

post_idtag_id
11
12
21

建表 SQL

CREATE TABLE post_tags (
    post_id BIGINT UNSIGNED NOT NULL,
    tag_id BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (post_id, tag_id),       -- ① 联合主键防重复
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,  -- ② 引用 posts
    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE     -- ③ 引用 tags
);

用到了什么约束,为什么

PRIMARY KEY (post_id, tag_id) —— 联合主键

两个字段组合做主键,意味着同一个 post_id + tag_id 的组合只能出现一次:

(1, 1) → ✅ 文章1 绑定 MySQL
(1, 2) → ✅ 文章1 绑定 后端
(1, 1) → ❌ 重复了,文章1 已经绑定过 MySQL

单独的 post_id 可以重复(一篇文章可以有多个标签),单独的 tag_id 也可以重复(一个标签可以属于多篇文章),但组合不能重复。

②③ FOREIGN KEY ... ON DELETE CASCADE —— 级联删除

ON DELETE CASCADE 的意思是:删文章时,自动删掉这篇文章的所有标签绑定。

DELETE FROM posts WHERE id = 1;
-- post_tags 里 post_id=1 的记录自动被删除

不用手动去清理中间表,数据库帮你处理。

如果不想用联合主键,也可以加自增 id,但要额外加联合唯一约束:

CREATE TABLE post_tags (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    post_id BIGINT UNSIGNED NOT NULL,
    tag_id BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (id),
    UNIQUE (post_id, tag_id),            -- 组合唯一,效果一样
    FOREIGN KEY (post_id) REFERENCES posts(id),
    FOREIGN KEY (tag_id) REFERENCES tags(id)
);

四、外键删除策略

父表数据删了,子表怎么办?

策略行为适合场景
RESTRICT(默认)子表还有引用,禁止删除用户有文章时不能删用户
CASCADE父表删了,子表跟着删删文章时自动删评论、删标签绑定
SET NULL父表删了,子表外键设为 NULL删分类时文章保留,分类变空
-- 用户有文章时不允许删除(默认行为)
FOREIGN KEY (user_id) REFERENCES users(id)

-- 删文章时,自动删掉文章的所有标签绑定
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE

-- 删分类时,文章保留,category_id 变成 NULL
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL

注意:用 SET NULL 的话,外键字段必须允许 NULL


五、外键字段类型必须一致

外键创建失败最常见的原因:父表和子表字段类型不一致。

-- ✅ 两边都是 BIGINT UNSIGNED
users.id      BIGINT UNSIGNED
posts.user_id BIGINT UNSIGNED

-- ❌ 一个 UNSIGNED 一个不是,外键建不起来
users.id      BIGINT UNSIGNED
posts.user_id BIGINT

差一个 UNSIGNED 也不行。


六、物理外键 vs 逻辑外键

类型做法优点缺点
物理外键数据库里建 FOREIGN KEY数据库帮你检查数据一致性影响性能,删数据麻烦
逻辑外键只存 user_id,不建外键约束灵活,性能好数据一致性靠代码保证

实际项目大多用逻辑外键,靠后端代码保证关系正确。

学习阶段建议先用物理外键,能帮你理解表关系。


七、总结

一对一  → 外键放扩展表 + UNIQUE(没有 UNIQUE 就变成一对多)
一对多  → 外键放"多"的一方 + NOT NULL/NULL(决定关系是否必须)
多对多  → 中间表 + 联合主键或联合唯一(防止重复绑定)

外键类型必须和父表主键一致
外键阻止插入不存在的引用,也阻止删除被引用的父表数据
ON DELETE CASCADE 让数据库自动清理关联数据