一个博客系统,为什么要拆成 6 张表?

一个博客系统,为什么要拆成 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 ------被回复的那条评论删了,回复还在,只是变成顶层。而 postIdON 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 自关联能省空间,但查询"某条评论的全部子孙"会比较麻烦。你能想到哪些替代方案?评论区聊聊你的做法。


相关推荐
程序员-Benothing38 分钟前
MySQL 中的日志类型有哪些?binlog、redo log 和 undo log 的作用和区别是什么?
数据库·mysql
XuCoder42 分钟前
你写的每条 SQL 都没加过锁,可 MySQL 凭什么不怕两个事务打架?
数据库·后端
2301_800954991 小时前
MySQL 数据库基础:数据类型、查询与备份恢复
数据库·mysql·oracle
起名真的太难了1 小时前
Mysql的数据类型,查询与备份操作
数据库·sql·mysql
程序猿_极客1 小时前
【免费】分享一套优质的基于Php+Mysql的电子购物网站的设计与实现,校园小卖部系统
数据库·mysql·php·购物商城系统
fīɡЙtīиɡ ℡1 小时前
LLM/Agent 安全实战核心要点总结
网络·数据库·安全
程序员夏洛1 小时前
MySQL 中有哪些锁类型?
数据库·mysql
Hive_MOM1 小时前
制造企业技术文件与图纸管理数字化(DCC):从“图纸满天飞“到版本受控
服务器·数据库·制造
咋的啥名都用不了呢~1 小时前
Oracle数据库1
数据库