博客数据库进阶设计:点赞、收藏、评论、标签与文件表怎么建?

上一篇我们已经设计了博客系统最基础的三张表:

sql 复制代码
user
avatar
post

同时认识了:

sql 复制代码
PRIMARY KEY
UNIQUE
INDEX
FOREIGN KEY
一对多

但真正让数据库设计开始有意思的,是下面这些业务:

复制代码
用户点赞文章
用户收藏文章
用户评论文章
文章拥有多个标签
用户上传文章图片

这些功能会引出几个非常重要的数据库设计模式:

sql 复制代码
多对多
中间表
联合主键
联合索引
自关联
级联删除
SET NULL

这一篇,我们把它们全部串起来。


一、从"点赞"开始

需求非常简单:

用户可以点赞文章。

先观察业务关系。

一个用户可以点赞:

css 复制代码
文章 A
文章 B
文章 C

而一篇文章也可能被:

复制代码
用户 1
用户 2
用户 3

点赞。

所以:

复制代码
用户 → 多篇文章
文章 → 多个用户

这就叫:

多对多关系。


二、多对多为什么需要中间表?

假设我们试图直接在 user 表里记录:

复制代码
likedPostIds

可能变成:

复制代码
1,5,8,10,20

这种设计会让查询、约束、更新都非常麻烦。

反过来,在 post 表记录:

复制代码
likedUserIds

也有同样的问题。

关系型数据库中,多对多关系通常通过:

中间表

解决。

于是创建:

复制代码
user_like_post

三、点赞表实际上是一张"关系表"

例如:

复制代码
user_like_post

userId    postId
1         100
1         200
2         100

它表达的不是三个"点赞对象"。

而是三条关系:

复制代码
用户 1 点赞了文章 100
用户 1 点赞了文章 200
用户 2 点赞了文章 100

所以这种中间表通常不一定需要额外的:

bash 复制代码
id

因为:

复制代码
userId + postId

本身就足以唯一确定一次点赞关系。


四、点赞表怎么建?

可以这样设计:

sql 复制代码
CREATE TABLE `user_like_post` (
  `userId` INT NOT NULL,
  `postId` INT NOT NULL,

  PRIMARY KEY (`userId`, `postId`),

  KEY `idx_postId` (`postId`),

  CONSTRAINT `fk_like_user`
    FOREIGN KEY (`userId`)
    REFERENCES `user` (`id`)
    ON DELETE CASCADE
    ON UPDATE CASCADE,

  CONSTRAINT `fk_like_post`
    FOREIGN KEY (`postId`)
    REFERENCES `post` (`id`)
    ON DELETE CASCADE
    ON UPDATE CASCADE
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

五、为什么主键是两个字段?

这里出现:

go 复制代码
PRIMARY KEY (`userId`, `postId`)

这叫:

联合主键。

不是一个字段唯一,而是:

复制代码
userId + postId

这个组合唯一。

例如:

复制代码
userId   postId
1        100
1        200
2        100

合法。

但是:

复制代码
1        100
1        100

不合法。

这恰好对应一个业务规则:

同一个用户不能重复点赞同一篇文章。

所以数据库结构本身就帮我们保证了业务正确性。

这就是一个很典型的设计思路:

能让数据库约束的规则,就不要完全依赖业务代码自觉维护。


六、联合主键同时也是联合索引

在 MySQL 中:

go 复制代码
PRIMARY KEY (`userId`, `postId`)

不仅是唯一约束,也会建立索引。

这个索引的顺序可以理解为:

scss 复制代码
(userId, postId)

因此它很适合这样的查询:

ini 复制代码
SELECT *
FROM user_like_post
WHERE userId = 1;

意思是:

用户 1 点赞过哪些文章?

也适合:

ini 复制代码
SELECT *
FROM user_like_post
WHERE userId = 1
  AND postId = 100;

意思是:

用户 1 是否点赞过文章 100?


七、为什么不需要再给 userId 单独建索引?

因为已经存在:

scss 复制代码
(userId, postId)

这个联合索引。

它是以:

复制代码
userId

开头的。

因此:

ini 复制代码
WHERE userId = ?

已经可以利用它。

如果再创建:

go 复制代码
KEY `userId` (`userId`)

很多情况下就是重复索引。

重复索引会:

复制代码
占用更多磁盘空间
插入时多维护一份索引
更新时多维护一份索引
删除时也要维护

所以索引不是越多越好。


八、为什么 postId 又需要单独建索引?

考虑另一个查询:

ini 复制代码
SELECT *
FROM user_like_post
WHERE postId = 100;

意思:

文章 100 被哪些用户点赞?

虽然我们有:

scss 复制代码
(userId, postId)

但它首先按照:

复制代码
userId

组织。

如果只根据:

复制代码
postId

查询,并不能很好地利用这个联合索引的前缀。

所以我们建立:

go 复制代码
KEY `idx_postId` (`postId`)

这其实就是理解联合索引非常好的例子。

可以先记住:

联合索引 (A, B) 通常适合从 A 开始的查询,但不能简单认为它等价于同时拥有 AB 两个独立索引。

这和常说的:

最左前缀原则

有关。


九、索引不是"字段重要就建"

通过点赞表,你应该开始建立真正的索引思维。

不是:

复制代码
userId 很重要
→ 建索引

postId 很重要
→ 建索引

而应该是:

复制代码
业务经常怎么查询?
已有索引能不能覆盖?
是否真的需要额外索引?

例如:

ini 复制代码
PRIMARY KEY(userId, postId)

已经支持:
WHERE userId = ?
WHERE userId = ? AND postId = ?

还经常需要:
WHERE postId = ?

所以补:
INDEX(postId)

这才是真正的索引设计。


十、收藏表其实已经会了

现在新增:

用户收藏文章。

关系完全一样:

复制代码
用户
多对多
文章

所以又是一张中间表:

复制代码
user_collect_post

例如:

sql 复制代码
CREATE TABLE `user_collect_post` (
  `userId` INT NOT NULL,
  `postId` INT NOT NULL,

  PRIMARY KEY (`userId`, `postId`),

  KEY `idx_postId` (`postId`),

  CONSTRAINT `fk_collect_user`
    FOREIGN KEY (`userId`)
    REFERENCES `user` (`id`)
    ON DELETE CASCADE,

  CONSTRAINT `fk_collect_post`
    FOREIGN KEY (`postId`)
    REFERENCES `post` (`id`)
    ON DELETE CASCADE
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

你会发现:

复制代码
点赞
收藏

数据库结构极其相似。

以后遇到:

复制代码
学生选择课程
用户加入群聊
用户关注话题
演员出演电影
用户参加活动

都应该对:

多对多 + 中间表

产生敏感度。


十一、接下来设计评论表

评论比点赞复杂一些。

一条评论至少需要:

bash 复制代码
id
content
postId
userId

也就是:

复制代码
谁
在什么文章下
评论了什么

例如:

bash 复制代码
id       1
content  写得不错
postId   100
userId   7

翻译成人话:

用户 7 在文章 100 下发表了"写得不错"。


十二、评论为什么还需要 parentId?

真实博客通常支持:

markdown 复制代码
评论
└── 回复
    └── 再回复

例如:

复制代码
评论 1:
这篇文章写得不错

评论 2:
谢谢!

评论 3:
确实讲得很清楚

如果评论 2 是回复评论 1,可以保存:

arduino 复制代码
comment

id     content               parentId
1      这篇文章写得不错        NULL
2      谢谢                  1

parentId = 1 的意思就是:

我的父评论是评论 1。


十三、一张表居然可以关联自己

这里:

复制代码
comment.parentId

指向:

python 复制代码
comment.id

所以外键是:

go 复制代码
FOREIGN KEY (`parentId`)
REFERENCES `comment` (`id`)

这叫:

自关联。

并不是外键一定要指向另一张表。

一张表也完全可以指向自己。

这种设计经常用于:

复制代码
评论回复
菜单树
部门层级
分类树
文件夹结构

十四、comment 表完整设计

例如:

r 复制代码
CREATE TABLE `comment` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `content` LONGTEXT NOT NULL,
  `postId` INT NOT NULL,
  `userId` INT NOT NULL,
  `parentId` INT DEFAULT NULL,

  PRIMARY KEY (`id`),

  KEY `idx_postId` (`postId`),
  KEY `idx_userId` (`userId`),
  KEY `idx_parentId` (`parentId`),

  CONSTRAINT `fk_comment_user`
    FOREIGN KEY (`userId`)
    REFERENCES `user` (`id`),

  CONSTRAINT `fk_comment_post`
    FOREIGN KEY (`postId`)
    REFERENCES `post` (`id`)
    ON DELETE CASCADE
    ON UPDATE CASCADE,

  CONSTRAINT `fk_comment_parent`
    FOREIGN KEY (`parentId`)
    REFERENCES `comment` (`id`)
    ON DELETE SET NULL
    ON UPDATE CASCADE
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

十五、ON DELETE CASCADE 是什么意思?

注意文章外键:

objectivec 复制代码
ON DELETE CASCADE

假设:

复制代码
文章 100

下面有:

复制代码
100 个点赞
30 个收藏
50 条评论

现在文章 100 被删除。

这些关系已经没有意义:

ini 复制代码
user_like_post.postId = 100
comment.postId = 100

于是:

objectivec 复制代码
ON DELETE CASCADE

表示:

父记录删除以后,依赖它的数据跟着删除。

例如:

复制代码
删除 post 100

自动删除
→ 它的点赞关系
→ 它的收藏关系
→ 它的评论

这就是:

级联删除。


十六、ON DELETE SET NULL 又是什么意思?

但是评论回复关系有一点不同。

例如:

复制代码
评论 1
└── 评论 2

如果评论 1 被删除:

要不要顺便把评论 2 也删了?

这取决于业务。

如果我们希望:

评论 2 仍然保留,只是原来的父评论已经不存在。

就可以:

sql 复制代码
ON DELETE SET NULL

于是删除评论 1 以后:

复制代码
评论 2.parentId

从:

复制代码
1

变成:

arduino 复制代码
NULL

所以可以简单理解:

objectivec 复制代码
CASCADE
跟着删

SET NULL
数据保留,但解除关系

十七、Tag 为什么又需要中间表?

文章还可能有标签:

复制代码
JavaScript
React
MySQL
数据库
后端

一篇文章可能有很多标签:

css 复制代码
文章 A
→ JavaScript
→ React
→ 前端

一个标签也可能属于很多文章:

css 复制代码
JavaScript
→ 文章 A
→ 文章 B
→ 文章 C

所以:

复制代码
post
多对多
tag

又是一组多对多关系。

因此:

复制代码
tag
post_tag

两张表出现了。


十八、tag 表

sql 复制代码
CREATE TABLE `tag` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL,

  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

为什么:

复制代码
name

需要 UNIQUE?

因为通常不会希望:

复制代码
JavaScript
JavaScript
JavaScript

在 tag 表中出现三次。


十九、post_tag 表

sql 复制代码
CREATE TABLE `post_tag` (
  `postId` INT NOT NULL,
  `tagId` INT NOT NULL,

  PRIMARY KEY (`postId`, `tagId`),

  KEY `idx_tagId` (`tagId`),

  CONSTRAINT `fk_post_tag_post`
    FOREIGN KEY (`postId`)
    REFERENCES `post` (`id`)
    ON DELETE CASCADE
    ON UPDATE CASCADE,

  CONSTRAINT `fk_post_tag_tag`
    FOREIGN KEY (`tagId`)
    REFERENCES `tag` (`id`)
    ON DELETE CASCADE
    ON UPDATE CASCADE
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

是不是很眼熟?

它和:

复制代码
user_like_post

几乎是一个模式。

这也是学习数据库设计非常重要的一件事:

不要只背具体表,要发现背后的通用结构。


二十、最后还有文件表

博客文章中通常会上传:

复制代码
封面
文章图片
附件

真实文件一般不会全部塞进 MySQL。

更常见的结构:

复制代码
文件本体
→ OSS / S3 / 文件服务器

文件信息
→ MySQL

所以数据库可以保存:

arduino 复制代码
originalname
mimetype
filename
size
width
height
metadata
userId
postId

二十一、设计 file 表

例如:

sql 复制代码
CREATE TABLE `file` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `originalname` VARCHAR(255) NOT NULL,
  `mimetype` VARCHAR(255) NOT NULL,
  `filename` VARCHAR(255) NOT NULL,
  `size` INT NOT NULL,
  `postId` INT DEFAULT NULL,
  `userId` INT NOT NULL,
  `width` SMALLINT DEFAULT NULL,
  `height` SMALLINT DEFAULT NULL,
  `metadata` JSON DEFAULT NULL,

  PRIMARY KEY (`id`),

  KEY `idx_postId` (`postId`),
  KEY `idx_userId` (`userId`),

  CONSTRAINT `fk_file_user`
    FOREIGN KEY (`userId`)
    REFERENCES `user` (`id`),

  CONSTRAINT `fk_file_post`
    FOREIGN KEY (`postId`)
    REFERENCES `post` (`id`)
    ON DELETE SET NULL
    ON UPDATE CASCADE
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

其中:

复制代码
userId

表示:

谁上传了这个文件?

而:

复制代码
postId

表示:

这个文件当前属于哪篇文章?

如果文章还没发布:

ini 复制代码
postId = NULL

也是合理的。


二十二、整个数据库关系终于出现了

现在可以把博客数据库理解为:

sql 复制代码
user
│
├── avatar
│
├── post
│   │
│   ├── comment
│   ├── file
│   ├── user_like_post
│   ├── user_collect_post
│   └── post_tag
│
└── comment

tag
└── post_tag

再按照关系分类:

sql 复制代码
user → post
一对多

user → comment
一对多

post → comment
一对多

user ↔ post
多对多
通过 user_like_post

user ↔ post
多对多
通过 user_collect_post

post ↔ tag
多对多
通过 post_tag

comment → comment
自关联
通过 parentId

这张关系图比背所有 SQL 更重要。


二十三、项目里为什么会有 database/blog.sql?

完成表设计以后,一个非常常见的项目结构是:

css 复制代码
project
│
├── src
├── ...
└── database
    └── blog.sql

blog.sql 可以保存:

sql 复制代码
CREATE TABLE
外键
索引
初始化数据

这样新环境拿到项目以后,就可以通过 SQL 文件快速初始化数据库结构。

还可以准备一些测试数据,例如:

复制代码
几个用户
几篇文章
几个标签
一些评论
一些点赞

方便开发接口时直接使用。


二十四、这一篇最应该记住的不是 SQL

真正应该形成的是下面这些模式。

模式 1:一对多

复制代码
一个用户
很多文章

通常:

复制代码
post.userId

模式 2:多对多

复制代码
用户
很多文章

文章
很多用户

中间表:

复制代码
user_like_post

模式 3:联合主键

scss 复制代码
(userId, postId)

表达:

这组关系不能重复。


模式 4:自关联

python 复制代码
comment.parentId
→ comment.id

表达树状结构。


模式 5:外键删除策略

objectivec 复制代码
CASCADE
父记录删掉,子记录跟着删

SET NULL
父记录删掉,子记录留下,关系清空

模式 6:查询决定索引

不要看到字段就无脑加索引。

先问:

sql 复制代码
最常见的 WHERE 是什么?
最常见的 JOIN 是什么?
已有联合索引能不能利用?

总结

数据库设计并不是:

为每个业务随便创建一张表。

真正的过程应该是:

复制代码
分析实体
↓
分析实体关系
↓
决定一对多还是多对多
↓
设计主键
↓
设计外键
↓
把业务规则变成约束
↓
根据查询需求设计索引

当你理解这一套以后,再看到:

复制代码
点赞
收藏
关注
标签
评论
回复

就不会觉得它们是六个完全不同的问题。

你会发现,它们实际上只是几个数据库设计模式在不断重复。

下一篇,我们把视角从数据库拉高:

一个真正上线的网站,从用户输入 juejin.cn 开始,请求究竟怎么经过 DNS、Nginx、服务器集群、OSS 和 CDN?

相关推荐
夕除21 分钟前
redis--009
redis·后端
东风破_9 小时前
从输入 juejin.cn 到看到页面:DNS、Nginx、服务器集群与 CDN 到底在做什么?
后端·mysql
小成很成9 小时前
mysql 8.0.21 升级 mysql 8.0.45(经过生产环境验证)
android·mysql·adb
东风破_9 小时前
从 0 设计一个博客数据库:用户、头像与文章表应该怎么建?
后端·mysql
大鸡腿同学10 小时前
黄仁勋点透的 AI 真相:能实现你知道但做不到的,却实现不了你不知道的
后端
FfHUCisI10 小时前
Go 编译过程全景
开发语言·后端·golang
FfHUCisI10 小时前
Golang 语法分析与 AST:Parser 与 go/ast
开发语言·后端·golang
大模型码小白11 小时前
Spring AI Tool 实现自然语言操作 MySQL 数据库详解
服务器·开发语言·数据库·人工智能·python·mysql·spring
++==12 小时前
RESTful详解:核心思想、框架、与HTTP的区别,API的设计风格
后端·http·restful