掘金同款社区数据库设计:9张表带你吃透建表、索引与约束

从 0 设计一个掘金风格社区数据库:建表、索引、约束与静态资源加速全解析

最近我在学习后端项目时,以稀土掘金的业务为原型,从零拆解了一个内容社区需要哪些表、每张表怎么设计、为什么这么设计。这篇文章是我学习笔记的整理,希望能帮你把"建表、索引、约束"这些知识点串联起来,并且理解它们在一个真实业务中的落地方式。

本文核心表一览

表名 用途 关系类型
user 用户账号信息,只存核心字段 核心表
avatar 用户头像元数据,图片走 CDN 一对多 → user
post 文章标题、正文、作者 一对多 → user
user_like_post 点赞关系 多对多:userpost
user_favorite_post 收藏关系 多对多:userpost
comment 评论内容,支持楼中楼 一对多 → postuser;自关联
tag 标签名称 独立表
post_tag 文章-标签关联 多对多:posttag
file 文章图片/附件元数据 一对多 → postuser

其中"点赞 "和"收藏 "并不是文章表里的一个字段,而是独立 的关系表;标签和文章是多对多关系,也需要中间表。理解这些,是设计数据库的第一步。


一、数据库设计三件事:建表、索引、约束

在建任何一张表之前,先问自己三个问题:

  1. 建表:这张表有哪些列?每列存什么类型?
  2. 建索引:哪些列会被高频查询?需要给它们建"目录"吗?
  3. 建约束:怎样防止脏数据写入?

建表:告诉数据库"我有什么"

建表就是用 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,用户名不能重复
  • 外键约束:评论必须指向一条真实存在的文章,不能乱指

进阶提示:物理外键能保证数据完整性,但在高并发互联网场景中,外键检查会带来额外开销。很多公司选择不在数据库层面建物理外键,而是在应用层做逻辑校验("逻辑外键"),把完整性交给后端代码来维护。学习阶段可以先把物理外键用好,理解它的行为,再根据实际场景做取舍。


二、用户表:小表哲学与索引设计

用户表是社区的核心,但它的设计却非常"克制"。

为什么只存核心字段?

用户表最好只存 idusernamepassword。原因有三:

  1. 性能更好:表越小,查询越快,越容易缓存
  2. 利于分布式:小表做水平拆分、分库分表更容易
  3. 职责清晰:头像、简介等可以放到其他表,按需关联查询

密码为什么不能存明文?

密码必须加密存储。常见做法是使用 bcryptargon2 这类哈希算法,并且加盐。这样即使数据库泄露,攻击者也无法直接拿到用户明文密码。

索引设计

  • 根据 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_typefilenamesize。答案就藏在一次完整的网络请求里。

一次请求的接力寻址

当你在浏览器敲下 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;
  • titlevarchar(255),长度够用且便于索引
  • contentlongtext,文章正文可能很长
  • 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;

亮点 2parent_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:服务器上存储的文件名(通常是随机生成的字符串)
  • sizewidthheight:记录图片尺寸和大小
  • metadata:JSON 类型,方便扩展额外信息

post_iduser_id 都建了索引,分别支持"查某篇文章的所有附件"和"查某个用户上传的所有文件"。


九、项目准备:database 文件夹和测试数据

在实际项目中,我通常会在项目根目录下建一个 database 文件夹,里面放:

  • blog.sql:数据库设计文档,包含所有建表语句
  • seed.sql:测试数据,方便本地开发和调试

测试数据可以自己写脚本生成,也可以参考一些开源项目的示例。比如我学习时参考的这份数据源就很有价值,里面包含了完整的建表语句和测试数据,直接导入 MySQL 就能跑起来:

👉 xb2-node-assets / database / xb2_node.sql

有了测试数据,才能真正验证索引和查询逻辑是否合理。空表跑查询看不出性能差异,数据量上来之后,索引的价值才会真正体现出来。


写在最后

这篇文章从建表、索引、约束三个基础概念出发,拆解了一个掘金风格社区的核心表设计:

  • 用户表要小,存核心字段
  • 头像和文件表只存元数据,文件实体走 OSS/CDN
  • 点赞和收藏用关系表,复合主键防重
  • 评论用 parent_id 自关联实现树状结构
  • 标签和文章用中间表实现多对多
  • 高频查询字段建索引,联合索引注意最左前缀
  • 外键和级联删除保证数据一致性

数据库设计没有绝对的标准答案,关键是根据业务场景做取舍。希望这篇笔记整理能帮你把零散的知识点串起来,形成一套自己的设计思路。

如果你也在学习后端数据库设计,欢迎在评论区交流你的表设计心得。


如果这篇文章对你有帮助,欢迎点赞、收藏、评论三连支持一下! 👍

你的每一次互动都是我继续分享的动力。也欢迎关注我,后续会持续输出后端学习笔记和实战经验。我们下一篇文章见!

相关推荐
元界metalite1 小时前
SpringBoot项目Maven-BOM统一版本就不会冲突吗
后端
蓝宝石Kaze1 小时前
Gin 框架快速上手
后端
TechLee1 小时前
跨语言加解密总对不上?这个纯 Go 神库让 AES/RSA 与 PHP、Java 100% 互通
java·后端·算法
用户594404103561 小时前
从零手写轻量级 RPC 框架:基于 Netty + Zookeeper 的核心实现
后端
这个DBA有点耶1 小时前
SQL优化进阶:读懂“索引下推”,告别回表噩梦
数据库·sql·性能优化
FEF前端团队2 小时前
小程序微信支付 V3 接入实战手册:从商户配置到前后端落地
javascript·后端·node.js
马可家的菠萝3 小时前
自动保存已经有了,为什么笔记软件还需要“历史版本”?
前端·后端·架构
leavesleo3 小时前
AI Agent 开发实战:从零搭一个能用的 Agent
后端