一个博客系统,为什么要拆成 6 张表?
朋友把一个博客后端的 database 目录发给我,说「帮我看看这些建表语句」。
我扫了一眼,发现一件事:几乎所有新手 DBA 都会在这张表上写一行多余代码。
就是这张"点赞表":
sql
CREATE TABLE `user_like_post` (
`userId` int(11) NOT NULL,
`postId` int(11) NOT NULL,
PRIMARY KEY (`userId`, `postId`), -- 🔑 联合主键
KEY `postId` (`postId`), -- ⚠️ 这一行到底需不需要?
...
)
90% 的人会在下面补一个 KEY userId (userId),总觉得没给 userId 建单独索引就不踏实。
但它是多余的------白白浪费磁盘空间。
为什么?答案藏在这张表的第一个设计决策里。但要说清楚,得把"为什么要拆成 6 张表"这件事从头讲完。这篇文章就是用这个真实的博客系统,从业务需求反推一件事:表到底该怎么拆,索引到底该不该建。
先看业务:一个博客有哪些数据
一个典型博客(比如掘金这样的社区)有这些数据:文章、点赞、收藏、评论、用户、头像、标签。
我们猜猜第一版会怎么设计?新手最爱干的事------全塞进一张表:
❌ 错误示范:文章 + 用户 + 评论 + 点赞全塞一张大表
sql
CREATE TABLE `everything` (
id, title, content,
userName, userPassword, userAvatar,
commentContent, commentUserId,
likeUserId,
...
)
看起来"一张表搞定所有查询",多省事。但一旦用户规模上来:
text
一切查询都经过一张大表
│
├─ 查文章详情 → 要扫描超宽行(含评论、点赞)
│
├─ 查用户信息 → 也一样全表扫
│
├─ 数据量到百万级 → 单表越来越大
│
└─ 想横向扩展/分表 → 一张大表无从下手
表太宽、耦合太紧,查询和扩展都难。核心目的:把大表拆小。
那到底怎么拆?记住一句话:拆表的依据是"业务实体"和"实体间的关系",不是"快就行"。
第一步:把每个独立实体拆成一张表
先区分"实体"和"关系"。
实体 :用户、文章、标签、图片 ------ 它们是独立存在的"东西",各自一张表。 关系:点赞、收藏、评论、文章↔标签 ------ 它们是"两个实体之间的关系",拆成关系表。
先从最核心的用户表说起,这张表的决定最有意思。
用户表:为什么只存核心字段?
用户表是所有表的关键:
sql
CREATE TABLE `user`(
`id` int(11) NOT NULL AUTO_INCREMENT, -- 主键,自增
`name` varchar(255) NOT NULL, -- 唯一,不能重名
`password` varchar(255) NOT NULL, -- 注意:不能存明文
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci
注意,用户表只有 3 个字段。头像呢?slogan(签名)呢?个人简介呢?
全都不在这里。
这不是偷懒,是刻意的。理由很朴素:用户表是全系统查询最频繁的表------每个请求几乎都要校验登录、带出用户信息。它越小,缓存越友好,查询越快,将来要分表也越容易。
反直觉点 :数据不是"能放就放",而是"有理由才放"。头像这种低频、独立的数据,单独拆出去,用一张
avatar表 + 外键关联。
sql
CREATE TABLE `avatar` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`filename` varchar(255) NOT NULL,
`size` int(11) NOT NULL,
`userId` int(11) NOT NULL, -- 🔑 外键,关联 user
PRIMARY KEY (`id`),
KEY `userId` (`userId`), -- 普通索引:按 userId 查头像
CONSTRAINT `avatar_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci
userId 是外键。它实现了两件事:
text
外键 FOREIGN KEY
│
├─ 保证一致性:不能给一个不存在的用户加头像
│
└─ 建了普通索引:按 userId 高频查头像就快
一个小知识 :头像图片本身不存在的库里,存的是
filename(静态资源路径)。图片放 OSS / CDN 静态服务器,数据库只记"位置"。流量大的图片走 CDN 就近分发,不该压在数据库上。
第二步:文章表 ------ 又一个外键,又一个「关系」
文章表同样关联用户(一篇文章属于一个作者):
sql
CREATE TABLE `post` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`content` longtext, -- 🔑 正文很长 → longtext
`userId` int(11) DEFAULT NULL, -- 🔑 作者,外键
PRIMARY KEY(`id`),
KEY `userId` (`userId`),
CONSTRAINT `post_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci
到这里其实都是熟悉的套路:实体表 + 外键关联。真正的考验是"关系"表------尤其是那个"点赞表"。
第三步:点赞表 ------ 联合主键的省空间智慧
点赞是一个"用户点赞了某篇文章"的关系。它有天然的约束:一个用户对同一篇文章只能点赞一次。
秒懂的人会这么写:
sql
CREATE TABLE `user_like_post` (
`userId` int(11) NOT NULL,
`postId` int(11) NOT NULL,
PRIMARY KEY (`userId`, `postId`), -- 🔑 联合主键
...
)
把 (userId, postId) 做成联合主键------数据库直接保证了"同一个人不能重复赞同一篇文章",连业务代码都不用写判重。
现在回到开头那个问题:我需不需要再单独给 userId 建一个索引?
结论是不需要。这就是 InnoDB 最反直觉、也最容易省空间的地方。
要理解它,必须先懂 InnoDB 的聚簇索引:
text
InnoDB 聚簇索引机制
│
└─ 每个表的主键(PRIMARY KEY)就是一个聚簇索引
│
└─ 索引叶子节点 = 整行数据,都按主键顺序存放
联合主键是 (userId, postId),那么聚簇索引最左边的第一列就是 userId。
而数据库的索引规则里有一条铁律:任何"从最左列开始"的查询,都能命中这个复合索引。
按
userId查询时,它命中的是最左列userId,所以联合主键本身就能覆盖"按用户查点赞" ,单独再建一个KEY userId完全重复,纯属浪费空间。
text
联合主键 (userId, postId) 聚簇索引
│
├─ 按 userId 查 ✅ 命中最左列,覆盖
│
└─ 核心规则:最左前缀原则(leftmost prefix)
所以这张表最终正确的应该是:
✅ 正确示范:点赞表
sql
CREATE TABLE `user_like_post` (
`userId` int(11) NOT NULL,
`postId` int(11) NOT NULL,
PRIMARY KEY (`userId`, `postId`), -- 联合主键,已覆盖 userId 查询
KEY `postId` (`postId`), -- postId 在联合主键里不在最左,才需要单独建
...
)
金句:判断一个字段要不要单独建索引,先看它是不是"某个复合索引的最左列"------如果是,多半已经覆盖了。
踩坑提示 :如果业务是"按文章查谁赞过",postId才需要单独建索引,因为它不在主键最左侧。点赞表这样加KEY postId是对的。
第四步:评论表 ------ 评论的评论,用自关联
评论有个特点:评论下面能回评论,无限层级。这就是树状结构。
最省事的建模是自关联外键 ------同一张表,parentId 指向自己的 id:
sql
CREATE TABLE `comment` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`content` longtext,
`postId` int(11) NOT NULL, -- 属于哪篇文章
`userId` int(11) NOT NULL, -- 谁评论的
`parentId` int(11) DEFAULT NULL, -- 🔑 评论的评论:指向自己
PRIMARY KEY(`id`),
KEY `postId` (`postId`),
CONSTRAINT `comment_ibfk_3` FOREIGN KEY (`postId`) REFERENCES `post` (`id`) ON DELETE CASCADE,
CONSTRAINT `comment_ibfk_2` FOREIGN KEY (`parentId`) REFERENCES `comment` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci
一条评论 parentId = NULL 就是顶层评论;parentId = 某条评论的 id 就是这条评论的回复。
text
一条顶层评论 → parentId = NULL
│
└─ 一条回复 → parentId = 顶层评论的 id
│
└─ 它的回复 → parentId = 那条回复的 id
(同一张表,自己指向自己)
踩坑 :
parentId用了ON DELETE SET NULL------被回复的那条评论删了,回复还在,只是变成顶层。而postId用ON DELETE CASCADE------文章删了,底下评论全删。不同场景,级联策略不同,别一刀切。
第五步:多对多 ------ 文章和标签,用中间表
一篇文章可以打多个标签,一个标签下有多篇文章。这是多对多关系 ,用中间表 post_tag:
sql
CREATE TABLE `post_tag` (
`postId` int(11) NOT NULL,
`tagId` int(11) NOT NULL,
PRIMARY KEY(`postId`, `tagId`), -- 联合主键,同前省空间逻辑
KEY `tagId` (`tagId`),
...
)
tag 表存标签名(唯一约束),post_tag 只存关联。这和点赞表是同一个设计模式------多对多关系 → 中间表 + 联合主键。
一张图看懂 6 张表的关系
把整个博客的表关系画成一张 ASCII 图:
text
┌─────────┐ ┌─────────┐
│ user │ │ tag │
│ id,name │ │ id │
└────┬────┘ └────┬────┘
│ │
┌─────────────┼─────────┐ │
│ │ │ │
┌────┴────┐ ┌────┴────┐ │ ┌────┴────┐
│ avatar │ │ post │ │ │post_tag │ 多对多
│ (外键) │ │ (作者外键)│ │ │(中间表) │
└─────────┘ └────┬────┘ │ └─────────┘
1:1 │ │
│ │
┌───────┴───┐ │
│ comment │ │ 评论的评论
│(postId外键) │ │ (parentId自关联)
└───────────┘ │
│ │
┌────┴────┐ │
│user_like│◄───┘ 联合主键
│ _post │ (userId, postId)
└─────────┘
设计规律总结:
| 关系类型 | 建模方式 | 本系统例子 |
|---|---|---|
| 一对多(1:N) | 多的那端加外键 | 用户→文章、用户→头像 |
| 一对多(自引用) | 自己表加 parentId 外键 | 评论→评论 |
| 多对多(M:N) | 中间表 + 联合主键 | 文章↔标签、用户↔文章(点赞) |
关于索引,补一个统一的认识
看完以上,索引的决策其实可以收敛成一条主线:
text
建索引的决策链条
│
├─ 主键一定是索引(聚簇索引)
│
├─ 高频查询字段 → 建索引
│ 例:postId 被查询/被外键引用 → KEY postId
│
├─ 复合索引 → 最左列已覆盖 → 别重复建
│ 例:联合主键 (userId, postId) 覆盖 userId 查询
│
└─ 唯一约束(UNIQUE)本身就是索引
例:user.name → UNIQUE KEY name
这不是"背规则",而是先想清楚业务的高频查询是什么,再决定建哪些索引。索引不是越多越好,每多一个索引就多一分写入开销和磁盘占用。
结尾
回到开头那个朋友的问题:为什么拆成 6 张表?
因为表的粒度 = 业务实体的粒度。实体单独成表,关系单独成表。这不仅是"规范好看",更是为了查询快、易扩展、好分表------所有的外在约束(外键、联合主键、级联策略)都是从"这个表承载什么关系"反推出来的。
而这篇文章真正想留在你脑子里的,是这句:
判断一个字段要不要单独建索引,先看它是不是"某个复合索引的最左列"。拆表看实体,建索引看最左------两次"看",就是表设计的核心判断。
下次设计一张表,先问自己一句:「这个表是一个实体,还是一个关系?」 答案不同,建模完全不同。
留个开放问题给你:评论的无限层级用 parentId 自关联能省空间,但查询"某条评论的全部子孙"会比较麻烦。你能想到哪些替代方案?评论区聊聊你的做法。