从零设计一个博客系统的数据库:表结构、索引与约束的实战思考

在开发后端业务时,数据库设计往往是地基。地基不稳,上层应用写得再花哨也白搭。

本文将带你走一遍博客系统核心表的设计过程,涵盖用户、文章、点赞、收藏、评论、标签、文件等模块,

重点聊聊怎么建表、怎么建索引、怎么建约束 ,以及背后的性能考量。

同时,我们还会深入探讨头像等静态资源的存储与访问链路,看看一个 URL 背后隐藏的网络架构。

每张表都会明确其对应的业务场景,让你不仅知道怎么建,还知道为什么建。


一、用户表:小而美才是王道

业务场景 :用户注册、登录、身份认证。系统需要存储用户的核心凭证(用户名和密码),用于每次请求的身份校验。用户表是所有业务的基础,几乎所有其他表都通过 userId 与它关联。

用户表是系统的核心,几乎所有业务都要和它关联。但你会发现,很多成熟系统的用户表字段并不多。为什么?

用户表要尽量"瘦"

原因很简单:

  • 用户数据高频访问,表越小,单页能缓存的数据越多,查询越快。
  • 分布式场景下,用户表可能被频繁同步、分片,字段少更容易维护。
  • 有些非核心信息(头像、个人简介)可以拆到扩展表,按需加载。

所以,用户表通常只保留最核心的字段:idusernamepassword

建表语句如下:

复制代码
CREATE TABLE `user` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
    `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

索引设计

  • id 作为自增主键,物理存储顺序与插入顺序一致,对 InnoDB 友好。
  • name(用户名)必须唯一,所以加唯一索引,既能保证业务唯一性,又能加速按用户名登录的查询。
  • 密码不能存明文,这个属于应用层逻辑,但设计表时要注意字段长度够用(一般存加密后的哈希)。

一句话总结:用户表保持精简,高频字段建索引,唯一约束交给数据库。


二、头像表:静态资源与数据库的协作

业务场景:用户上传和更换头像。头像图片文件本身存放在静态资源服务器或对象存储中,数据库只保存文件的元数据(类型、文件名、大小),以及该头像属于哪个用户。这样用户信息页可以快速加载头像 URL,而不需要把图片二进制塞进数据库。

用户头像这类图片,通常不会直接以二进制形式存在数据库里。最佳实践是:

  • 将图片上传到 静态资源服务器(比如阿里云 OSS、自建 Nginx 静态目录)。
  • 数据库中只保存元信息:文件类型、文件名、大小、用户 ID 等。

这样做的好处:

  • 减轻数据库存储压力,图片不占数据库空间。
  • 静态资源可以走 CDN 加速,用户就近访问。
  • 文件与业务数据解耦,便于扩展(比如更换存储方案)。

头像表的建表语句:

复制代码
CREATE TABLE `avatar` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `mimetype` varchar(255) NOT NULL,
    `filename` varchar(255) NOT NULL,
    `size` int(11) NOT NULL,
    `userId` int(11) NOT NULL,
    PRIMARY KEY (`id`),
    KEY `userId` (`userId`),
    CONSTRAINT `avatar_userId` FOREIGN KEY (`userId`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

索引与外键

  • userId 建立普通索引:因为经常要根据用户 ID 查询头像。
  • 外键约束保证数据一致性,用户删除时头像记录如何处理?这里没有指定级联删除,实际项目中可以根据业务决定。

三、静态资源与网络架构深度解析

前面说到头像不存数据库,而是放在静态资源服务器上。那么,当你在浏览器里输入一个头像 URL,比如:

复制代码
https://p26-passport.byteacctimg.com/img/user-avatar/e086fe227e6e33b2c831e0c8197939e8~130x130.awebp

这背后发生了什么?为什么这个 URL 看起来跟 juejin.cn 域名完全不一样?

要回答这个问题,我们需要从DNS 解析 开始,一路走到CDN 节点

3.1 DNS 解析:递归寻找"门牌号"

当你访问 juejin.cn 时,浏览器需要先知道这个域名对应的 IP 地址。这个过程叫做 DNS 解析,是一个逐级递归查找的过程:

  1. 本地缓存:浏览器先看自己有没有缓存过该域名的 IP,有就直接用;没有则继续。
  2. 操作系统缓存:如果浏览器没缓存,操作系统会查自己的 hosts 文件和 DNS 缓存。
  3. 局域网 DNS 服务器:比如校园网、公司内网的 DNS 服务器,它可能已经缓存了结果,直接返回;否则向上级查询。
  4. 网络服务商 DNS 服务器:比如电信、联通、移动的 DNS,它们维护着大量域名记录(就像一本巨大的账本),如果命中则返回。
  5. 国家/顶级 DNS 服务器 :如果还没找到,会继续向上,直到根服务器。根服务器知道 .com 顶级域名的权威 DNS 在哪里。
  6. 权威 DNS 服务器 :最终找到管理 juejin.cn 的 DNS 服务器,它返回该域名对应的 IP 地址。

整个过程虽然描述起来很长,但实际耗时通常只有几十毫秒到几百毫秒,而且各级缓存大大加速了重复访问。

3.2 负载均衡与反向代理:Nginx 的"交通警察"角色

DNS 返回的 IP 地址,往往不是某台具体应用服务器的 IP,而是Nginx 负载均衡服务器 的 IP。

为什么?

因为一个大型网站(比如掘金)不可能只用一台服务器。它有服务器集群------很多台独立 IP 的服务器,每台都部署了相同的 Web 应用,都能处理请求。但用户访问时不可能随机挑一台,需要一个"调度员"来分配流量,这个调度员就是 Nginx。

Nginx 在这里扮演两个角色:

  • 反向代理:接收客户端请求,然后转发给内部集群中的某台服务器,再把响应返回给客户端。客户端并不知道真正处理请求的是哪台服务器。
  • 负载均衡:根据预设策略(轮询、最少连接、IP hash 等)从集群中选出一台"健康"的服务器,将请求代理过去。

所以,你的请求到达 Nginx 后,Nginx 会判断:

  • 如果是动态请求(比如登录、发文章),就转发给应用服务器(比如 Nest.js 写的后端服务)。
  • 如果是静态资源请求(图片、CSS、JS 文件),Nginx 可能直接处理(如果配置了静态目录),或者转发给专门的静态资源服务器/CDN。

3.3 静态资源服务器与 CDN:就近取货的"快递站"

对于图片、CSS、JS 这类不常变化、可共享的资源,如果每次都由应用服务器返回,会浪费大量计算资源和带宽。更好的做法是:

  • 将这些资源放在独立的静态资源服务器上,通常配置简单、性能高、专注文件传输。
  • 更进一步,使用 CDN(Content Delivery Network,内容分发网络) 。CDN 服务商在全国乃至全球部署了大量缓存节点,用户访问时,会被引导到离他最近的节点获取资源,速度飞快。

回到开头的头像 URL:p26-passport.byteacctimg.com 是字节跳动旗下的 CDN 域名,~130x130.awebp 表示图片经过了压缩和尺寸裁剪。这个 URL 看起来跟 juejin.cn 不同,是因为静态资源和主站域名分离了------主站域名解析到 Nginx 负载均衡器,而静态资源域名解析到 CDN 的智能调度系统,根据用户地理位置返回最优节点 IP。

3.4 一次完整的访问流程

假设你在浏览器访问掘金首页:

  1. 输入 juejin.cn,DNS 解析返回最近的 Nginx 负载均衡服务器 IP。

  2. 浏览器与 Nginx 建立 TCP 连接(三次握手),发送 HTTP 请求。

  3. Nginx 识别请求类型:

    • 首页 HTML 是动态请求,转发给某台 Nest.js 应用服务器。
    • 页面里的 CSS、JS、头像图片等,URL 指向 CDN 域名,浏览器会单独发起请求。
  4. 对于 CDN 域名的请求,DNS 解析返回距离你最近的 CDN 节点 IP。

  5. 浏览器与 CDN 节点建立连接,获取静态资源。如果节点上没有缓存,CDN 会回源到源站(可能是对象存储或静态服务器)拉取,然后缓存并返回给你。

整个流程中,应用服务器只处理业务逻辑,静态资源交给 CDN 分担,大大提升了整体性能和用户体验。

记住:数据库存元数据,文件存对象存储/静态服务器,访问走 CDN,应用服务器专注业务。


四、文章表:内容为王

业务场景:博客的核心功能------文章的发布、展示、编辑和删除。用户登录后可以撰写文章,文章需要有标题、正文和作者信息。文章列表页、详情页都依赖这张表提供数据。

文章表是博客的核心,通常包含标题、正文、作者 ID。

注意:正文可能很长,使用 LONGTEXT 类型,而标题用 VARCHAR

复制代码
CREATE TABLE `post` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `title` varchar(255) NOT NULL,
    `content` longtext,
    `userId` int(11) NOT NULL,
    PRIMARY KEY (`id`),
    KEY `userId` (`userId`),
    CONSTRAINT `post_userId` FOREIGN KEY (`userId`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
  • userId 加索引,因为"查询某个用户的文章列表"是高频操作。
  • 外键约束保证文章一定属于某个存在的用户。

五、点赞表:联合主键与最左前缀的经典案例

业务场景:用户对文章进行点赞/取消点赞。点赞功能是社交互动的基础,需要记录"哪个用户点赞了哪篇文章",同时要保证一个用户对同一篇文章只能点赞一次。点赞数通常会显示在文章卡片上,需要快速统计。

点赞关系是典型的多对多:一个用户可以点赞多篇文章,一篇文章可以被多个用户点赞。

常见设计是用中间表保存关系:

复制代码
CREATE TABLE `user_like_post` (
    `userId` int(11) NOT NULL,
    `postId` int(11) NOT NULL,
    PRIMARY KEY (`userId`, `postId`),
    KEY `postId` (`postId`),
    CONSTRAINT `fk_like_user` FOREIGN KEY (`userId`) REFERENCES `user` (`id`),
    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;

为什么联合主键是 (userId, postId)

  • 业务上,一个用户对一篇文章的点赞是唯一的,联合主键正好保证不重复。
  • 查询"某用户点赞了哪些文章"时,WHERE userId = ? 可以直接走联合主键索引,效率高。
  • 那为什么还要单独给 postId 建索引?
    因为查询"某篇文章被哪些用户点赞"也很常见:WHERE postId = ?
    如果只有联合主键 (userId, postId),由于最左前缀原则,这个查询无法使用该索引(因为第一列 userId 未提供),会导致全表扫描。
    所以额外建一个 postId 索引,两个方向的查询都高效。

记住:联合索引遵循最左前缀,设计时要考虑所有高频查询条件。


六、收藏表:同点赞表,一样的设计思路

业务场景:用户收藏喜欢的文章,便于日后查阅。收藏功能与点赞类似,但语义不同------点赞是表达态度,收藏是为了保存。收藏表同样需要记录用户和文章的多对多关系,并且要防止重复收藏。

收藏关系和点赞关系几乎一致,可以创建类似的表:

复制代码
CREATE TABLE `user_favorite_post` (
    `userId` int(11) NOT NULL,
    `postId` int(11) NOT NULL,
    PRIMARY KEY (`userId`, `postId`),
    KEY `postId` (`postId`),
    CONSTRAINT `fk_fav_user` FOREIGN KEY (`userId`) REFERENCES `user` (`id`),
    CONSTRAINT `fk_fav_post` FOREIGN KEY (`postId`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

不用重复造轮子,和点赞表几乎一样。


七、评论表:支持树状结构的自关联

业务场景:用户对文章发表评论,也可以回复其他用户的评论,形成楼中楼。评论是博客互动的重要部分,需要支持一级评论和二级回复,展示时通常按照时间或树状结构排序。

评论除了属于文章和用户外,还可能是"评论的评论"(回复)。

常见做法是增加一个 parentId 字段,指向父评论的 id,如果是一级评论则 parentIdNULL

复制代码
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`),
    KEY `userId` (`userId`),
    KEY `parentId` (`parentId`),
    CONSTRAINT `fk_comment_user` FOREIGN KEY (`userId`) REFERENCES `user` (`id`),
    CONSTRAINT `fk_comment_parent` FOREIGN KEY (`parentId`) REFERENCES `comment` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
    CONSTRAINT `fk_comment_post` FOREIGN KEY (`postId`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

设计要点

  • postIduserId 都要建索引:按文章查评论、按用户查评论都是高频操作。
  • parentId 也建索引:如果需要查询某个评论的所有回复,或者构建评论树,会用到。
  • 外键策略:删除文章时级联删除评论(ON DELETE CASCADE);删除父评论时,子评论的 parentId 设为 NULLON DELETE SET NULL),这样评论不会凭空消失,而是变成一级评论。

八、标签表:多对多关系的标准解法

业务场景:文章可以打上多个标签(如"前端"、"后端"、"数据库"),用户可以通过标签筛选文章。标签是内容分类的一种灵活方式,一篇文章可以有多个标签,一个标签下也可以有多篇文章。

文章可以有多个标签,标签也能对应多篇文章,典型多对多。

需要一张标签表 tag,一张中间表 post_tag

复制代码
CREATE TABLE `tag` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `name` varchar(255) NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `post_tag` (
    `postId` int(11) NOT NULL,
    `tagId` int(11) NOT NULL,
    PRIMARY KEY (`postId`, `tagId`),
    KEY `tagId` (`tagId`),
    CONSTRAINT `fk_pt_post` FOREIGN KEY (`postId`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT `fk_pt_tag` FOREIGN KEY (`tagId`) REFERENCES `tag` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
  • 联合主键 (postId, tagId) 保证一篇文章不重复打同一个标签。
  • 额外给 tagId 建索引,方便"查询某个标签下的所有文章"。
  • 外键都设置级联删除,文章或标签删除时自动清理中间表。

九、文件表:统一管理上传的附件

业务场景:博客文章中可能包含图片、附件等文件,用户也可能上传其他类型的文件(比如封面图)。文件表统一存储这些文件的元数据,并提供与文章、用户的关联,方便管理和检索。

除了头像,文章里可能还有图片、附件等。

可以设计一个通用的文件表,保存文件的元数据,甚至可以存一些 JSON 格式的扩展信息(比如图片宽高)。

复制代码
CREATE TABLE `file` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `originalname` varchar(255) NOT NULL,
    `mimetype` varchar(255) NOT NULL,
    `filename` varchar(255) NOT NULL,
    `size` int(11) NOT NULL,
    `postId` int(11) DEFAULT NULL,
    `userId` int(11) NOT NULL,
    `width` smallint(6) DEFAULT NULL,
    `height` smallint(6) DEFAULT NULL,
    `metadata` json DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `postId` (`postId`),
    KEY `userId` (`userId`),
    CONSTRAINT `fk_file_post` FOREIGN KEY (`postId`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT `fk_file_user` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
  • postId 可以为空,表示该文件可能不属于某篇文章(比如用户头像)。
  • metadata 使用 JSON 类型,灵活存储额外信息,比如图片 EXIF、视频时长等。
  • 索引 userIdpostId 分别支持"查用户上传的文件"和"查文章包含的附件"。

十、全局思考:索引到底怎么建?

回顾以上表设计,索引的创建并不是拍脑袋,而是遵循几个原则:

  1. 高频查询字段必须建索引 。比如 userIdpostId 几乎每张关联表都有。
  2. 唯一性字段建唯一索引。比如用户名、标签名。
  3. 联合索引要注意最左前缀 。联合主键 (userId, postId) 可以覆盖 userId 查询,但覆盖不了 postId 查询,所以额外建单列索引。
  4. 不要过度索引。每个索引都会占用磁盘空间,并拖慢写操作(插入、更新、删除都需要维护索引)。
  5. 外键列通常要建索引,否则外键约束检查可能全表扫描。

数据库设计没有银弹,一切从业务查询出发。


十一、结语

数据库设计是后端开发的基石,一个合理的表结构能让你的应用性能更好、扩展更容易、代码更清晰。

本文通过一个博客系统的核心表设计,展示了如何从业务需求出发,设计表、索引和约束。

同时,我们也深入理解了静态资源从上传到访问的整个链路,明白了为什么头像 URL 长那样,以及 DNS、Nginx、CDN 在其中的作用。

记住:表结构反映业务模型,索引服务查询性能,约束保障数据完整性;而网络架构决定了数据的流向和访问速度。

下次再设计数据库或搭建系统时,不妨先问自己几个问题:

  • 这张表会被怎么查询?
  • 哪些字段需要唯一?
  • 数据之间是什么关系?
  • 哪些数据适合放数据库,哪些适合放静态资源/CDN?

答案清楚了,建表和部署自然水到渠成。

相关推荐
Yang961138 分钟前
一台顶三台:鼎讯DLJ-1在不同故障类型中的模式切换数据复盘
服务器·网络·数据库
潘正翔43 分钟前
Memcached构建缓存服务器
运维·服务器·数据库·缓存·云原生·memcached
NineData1 小时前
NineData 与平凯数据库(TiDB 企业版)完成产品兼容性互认证
数据库·tidb·ninedata·玖章算术·平凯数据库·产品兼容性认证·tidb企业版
咖啡星人k1 小时前
2026 Text-to-SQL:一句话让 AI 写 SQL,自然语言直接查数据库(MonkeyCode 云端实战)
数据库·机器学习·语言模型·自然语言处理
GreatSQL社区1 小时前
少敲一个字母,GreatSQL 对所有查询“已读不回”
数据库·mysql·greatsql
那咋乎吧1 小时前
线程上下文切换和用户态进入内核态 linux内核都发生了什么
后端
YIAN1 小时前
NestJS 入门核心梳理:模块化架构、装饰器与依赖注入
后端·nestjs
唐青枫2 小时前
别只会 malloc:Zig Allocator、所有权与内存生命周期实战
后端
wangchunyu1142 小时前
PostgreSQL 入门与实践:从零搭建一个 CRUD 应用
数据库·postgresql