从 0 设计一个掘金风格社区数据库:建表、索引、约束与静态资源加速全解析
最近我在学习后端项目时,以稀土掘金的业务为原型,从零拆解了一个内容社区需要哪些表、每张表怎么设计、为什么这么设计。这篇文章是我学习笔记的整理,希望能帮你把"建表、索引、约束"这些知识点串联起来,并且理解它们在一个真实业务中的落地方式。
本文核心表一览
| 表名 | 用途 | 关系类型 |
|---|---|---|
user |
用户账号信息,只存核心字段 | 核心表 |
avatar |
用户头像元数据,图片走 CDN | 一对多 → user |
post |
文章标题、正文、作者 | 一对多 → user |
user_like_post |
点赞关系 | 多对多:user ↔ post |
user_favorite_post |
收藏关系 | 多对多:user ↔ post |
comment |
评论内容,支持楼中楼 | 一对多 → post、user;自关联 |
tag |
标签名称 | 独立表 |
post_tag |
文章-标签关联 | 多对多:post ↔ tag |
file |
文章图片/附件元数据 | 一对多 → post、user |
其中"点赞 "和"收藏 "并不是文章表里的一个字段,而是独立 的关系表;标签和文章是多对多关系,也需要中间表。理解这些,是设计数据库的第一步。
一、数据库设计三件事:建表、索引、约束
在建任何一张表之前,先问自己三个问题:
- 建表:这张表有哪些列?每列存什么类型?
- 建索引:哪些列会被高频查询?需要给它们建"目录"吗?
- 建约束:怎样防止脏数据写入?
建表:告诉数据库"我有什么"
建表就是用 CREATE TABLE 定义字段和类型。比如:
sql
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;
AUTO_INCREMENT:主键自增NOT NULL:非空UNIQUE KEY:唯一索引
索引:给数据建"目录"
当数据量变大时,数据库一条条翻找非常慢。索引就是给某些列做"目录",查的时候直接翻目录,不用从头扫到尾。
索引的本质是空间换时间:它用额外的存储空间来维护一份有序的"目录",从而把查询从 O(n) 的全表扫描降到 O(log n) 甚至 O(1)。但代价就是写入数据时要同时更新索引,而且索引本身也占磁盘和内存。所以索引不是越多越好,只为高频查询字段建索引。

约束:防止脏数据的"门卫"
- 主键约束 :每行有唯一编号,比如
id - 非空约束 :
NOT NULL,用户名不能为空 - 唯一约束 :
UNIQUE,用户名不能重复 - 外键约束:评论必须指向一条真实存在的文章,不能乱指
进阶提示:物理外键能保证数据完整性,但在高并发互联网场景中,外键检查会带来额外开销。很多公司选择不在数据库层面建物理外键,而是在应用层做逻辑校验("逻辑外键"),把完整性交给后端代码来维护。学习阶段可以先把物理外键用好,理解它的行为,再根据实际场景做取舍。
二、用户表:小表哲学与索引设计
用户表是社区的核心,但它的设计却非常"克制"。
为什么只存核心字段?
用户表最好只存 id、username、password。原因有三:
- 性能更好:表越小,查询越快,越容易缓存
- 利于分布式:小表做水平拆分、分库分表更容易
- 职责清晰:头像、简介等可以放到其他表,按需关联查询
密码为什么不能存明文?
密码必须加密存储。常见做法是使用 bcrypt 或 argon2 这类哈希算法,并且加盐。这样即使数据库泄露,攻击者也无法直接拿到用户明文密码。
索引设计
- 根据
id查询用户 →id主键天然是聚簇索引 - 根据
username登录或搜索 →username加唯一索引,防重复 + 加速查询
三、头像表:文件元数据与静态资源加速
用户头像图片通常不会直接存在数据库里,而是放在静态服务器、OSS 或 CDN 上。数据库只存头像的元数据。
sql
sql
CREATE TABLE `avatar` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`mime_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`filename` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`size` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
CONSTRAINT `avatar_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;
亮点 1 :给
user_id建普通索引,因为"根据用户 ID 查头像"是最高频的查询。外键约束保证头像记录一定指向一个真实存在的用户。
那图片文件到底存在哪?
你注意到了,avatar 表里没有图片本身,只有 mime_type、filename、size。答案就藏在一次完整的网络请求里。
一次请求的接力寻址
当你在浏览器敲下 juejin.cn 并回车,一场跨地域的"接力赛"开始了。
第一步:查缓存
浏览器和操作系统都会缓存 DNS 结果。如果之前访问过且缓存未过期(TTL),直接拿到 IP,后面的步骤全省了。
第二步:问局域网
本地没缓存,就去找路由器或校园网/公司的 DNS 服务器。它知道就告诉你,不知道就继续向上问。
第三步:问运营商
再往上是中国移动、电信等运营商的 DNS 服务器。它们维护着巨大的"账本",记录了热门域名的 IP。比如双 11 期间很多人访问淘宝,运营商就会缓存淘宝的 IP,下次有人问就能秒回。
第四步:问根服务器
运营商也不清楚,请求就继续向上到根服务器。根服务器不会直接给 IP,而是告诉你"去找 .cn 的顶级域名服务器",顶级域名服务器再告诉你"去找 juejin.cn 的权威 DNS"。这个过程叫迭代查询------像去政府办事,一层层指引你到正确窗口。
拿到 IP,故事才刚开始
终于拿到 IP,但注意:这个 IP 往往不是真正运行业务的服务器,而是 nginx 负载均衡服务器 的地址。
为什么?因为掘金访问量巨大,一台服务器扛不住。以掘金为例,后端服务用 Nest.js 编写,和数据库一起部署在中央机房,对外由 nginx 反向代理的一批服务器集群提供服务。每台服务器都跑着同样的 Web 程序,nginx 就像前台,不做具体业务,只负责反向代理 和负载均衡------把请求分配给健康的服务器。常用策略有轮询、最少连接、IP 哈希等。
静态资源走 CDN:把仓库开到你家门口
页面 HTML 是动态生成的,需要后端处理;但图片、CSS、JS 这些静态资源,如果每次都要回源到中央机房,又慢又费带宽。于是就有了 CDN(Content Delivery Network,内容分发网络) 。
CDN 把静态资源复制到全国各地的边缘节点,用户访问时被引导到离自己最近的节点,就像仓库开到了家门口。
比如你在掘金上看到的头像地址,可能长这样:
text
bash
https://p3-passport.byteacctimg.com/img/user-avatar/813d2aafa035e5d8d6e8588beb6b1e3e~140x140.awebp
拆解一下:
p3-passport.byteacctimg.com是字节的 CDN 域名user-avatar表明是用户头像- 那一长串哈希是文件名,由服务端自动生成避免重名
~140x140是 CDN 帮你裁剪好的尺寸.awebp是 WebP 格式,比普通图片更小更快
数据库里只存了那个文件名,前端拼接 CDN 域名就能就近拉取。
四、文章表:内容核心
文章表是社区的主要内容载体,字段相对简单,但要注意类型选择。
sql
CREATE TABLE `post` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`content` longtext COLLATE utf8mb4_unicode_ci,
`user_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
CONSTRAINT `post_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;
title用varchar(255),长度够用且便于索引content用longtext,文章正文可能很长user_id建普通索引,方便查某个用户的所有文章
小贴士:文章正文不适合用普通索引做模糊搜索,全文检索可以借助 Elasticsearch 或 MySQL 的 FULLTEXT 索引。
五、点赞表与收藏表:关系表与复合主键
点赞和收藏是典型的多对多关系:一个用户可以点赞多篇文章,一篇文章也可以被多个用户点赞。所以需要独立的关系表,而不是在文章表里加一个 like_count 字段。
点赞表
sql
CREATE TABLE `user_like_post` (
`user_id` int(11) NOT NULL,
`post_id` int(11) NOT NULL,
PRIMARY KEY (`user_id`, `post_id`),
KEY `post_id` (`post_id`),
CONSTRAINT `user_like_post_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
CONSTRAINT `user_like_post_ibfk_2` FOREIGN KEY (`post_id`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;
复合主键防重 :PRIMARY KEY (user_id, post_id) 保证同一个用户对同一篇文章只能有一条点赞记录,从数据库层面防止重复点赞。
为什么不需要单独给 user_id 建索引?
因为联合主键 (user_id, post_id) 已经覆盖了 user_id 的最左前缀。用 WHERE user_id = ? 查询时,联合索引可以直接生效。再单独建 user_id 索引就是浪费空间。
这也呼应了前面说的索引的本质是空间换时间:联合主键本身已经消耗了存储空间来维护一份索引,我们就没必要再为同一个查询模式额外增加一个索引,否则就是用双倍的空间去做同一件事。
但 post_id 需要单独建索引,因为它不是联合主键的最左列。查"这篇文章被哪些用户点赞"时,WHERE post_id = ? 需要走 post_id 索引。
收藏表
收藏表的设计和点赞表几乎一样,只是多一个 created_at 字段记录收藏时间:
sql
CREATE TABLE `user_favorite_post` (
`user_id` int(11) NOT NULL,
`post_id` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`, `post_id`),
KEY `post_id` (`post_id`),
CONSTRAINT `user_favorite_post_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
CONSTRAINT `user_favorite_post_ibfk_2` FOREIGN KEY (`post_id`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;

六、评论表:parent_id 实现树状结构
评论表的特别之处在于它不仅要关联文章和用户,还要支持"评论的评论",也就是楼中楼。
sql
CREATE TABLE `comment` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`content` longtext COLLATE utf8mb4_unicode_ci,
`post_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`parent_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `user_id` (`user_id`),
KEY `parent_id` (`parent_id`),
CONSTRAINT `comment_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `post` (`id`),
CONSTRAINT `comment_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
CONSTRAINT `comment_ibfk_3` FOREIGN KEY (`parent_id`) REFERENCES `comment` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;
亮点 2 :
parent_id指向comment表自己的id,这样一条评论可以挂在另一条评论下面,形成树状结构。
查询文章评论时,可以先查 parent_id IS NULL 的根评论,再递归查子评论,或者在应用层一次性取出后组装成树。
这种设计简单直观,适合大多数社区场景。如果评论层级非常深,或需要频繁做子树查询,可以考虑"闭包表"或"路径枚举"等更复杂的设计,但前期不必过度设计。
索引设计:
post_id:查某篇文章的所有评论user_id:查某个用户发表的所有评论parent_id:查某个评论的所有子评论

七、标签表与文章-标签关系表:多对多
文章和标签是多对多关系:一篇文章可以有多个标签,一个标签下面也可以有多篇文章。
标签表
sql
CREATE TABLE `tag` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;
name 加唯一索引,防止标签重复。
文章-标签关系表
sql
CREATE TABLE `post_tag` (
`post_id` int(11) NOT NULL,
`tag_id` int(11) NOT NULL,
PRIMARY KEY (`post_id`, `tag_id`),
KEY `tag_id` (`tag_id`),
CONSTRAINT `post_tag_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `post_tag_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tag` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;
复合主键 (post_id, tag_id) 保证同一篇文章不会重复关联同一个标签。post_id 作为联合主键最左列,直接支持"查某篇文章的所有标签";tag_id 需要单独建索引,用于"查某个标签下的所有文章"。
八、文件表:文章图片/附件的元数据
和头像一样,文章里的图片、附件也不直接存数据库,而是存元数据,文件实体交给 OSS 或 CDN。
sql
CREATE TABLE `file` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`originalname` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`mimitype` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`filename` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`size` int(11) NOT NULL,
`post_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`width` smallint(6) NOT NULL,
`height` smallint(6) NOT NULL,
`metadata` json DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `user_id` (`user_id`),
CONSTRAINT `file_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
CONSTRAINT `file_ibfk_2` FOREIGN KEY (`post_id`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci;
originalname:原始文件名filename:服务器上存储的文件名(通常是随机生成的字符串)size、width、height:记录图片尺寸和大小metadata:JSON 类型,方便扩展额外信息
post_id 和 user_id 都建了索引,分别支持"查某篇文章的所有附件"和"查某个用户上传的所有文件"。
九、项目准备:database 文件夹和测试数据
在实际项目中,我通常会在项目根目录下建一个 database 文件夹,里面放:
blog.sql:数据库设计文档,包含所有建表语句seed.sql:测试数据,方便本地开发和调试
测试数据可以自己写脚本生成,也可以参考一些开源项目的示例。比如我学习时参考的这份数据源就很有价值,里面包含了完整的建表语句和测试数据,直接导入 MySQL 就能跑起来:
👉 xb2-node-assets / database / xb2_node.sql
有了测试数据,才能真正验证索引和查询逻辑是否合理。空表跑查询看不出性能差异,数据量上来之后,索引的价值才会真正体现出来。
写在最后
这篇文章从建表、索引、约束三个基础概念出发,拆解了一个掘金风格社区的核心表设计:
- 用户表要小,存核心字段
- 头像和文件表只存元数据,文件实体走 OSS/CDN
- 点赞和收藏用关系表,复合主键防重
- 评论用
parent_id自关联实现树状结构 - 标签和文章用中间表实现多对多
- 高频查询字段建索引,联合索引注意最左前缀
- 外键和级联删除保证数据一致性
数据库设计没有绝对的标准答案,关键是根据业务场景做取舍。希望这篇笔记整理能帮你把零散的知识点串起来,形成一套自己的设计思路。
如果你也在学习后端数据库设计,欢迎在评论区交流你的表设计心得。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论三连支持一下! 👍
你的每一次互动都是我继续分享的动力。也欢迎关注我,后续会持续输出后端学习笔记和实战经验。我们下一篇文章见!