一条点赞,六张表:SQL 数据库设计实战
一个类掘金的内容社区,看起来只是"用户发文章、其他人点赞评论",落到数据库里,却至少涉及用户、头像、文章、点赞、收藏、评论六类数据。
数据库表不是越多越专业,索引也不是加得越多越快。设计一张表时,真正应该先问三个问题:
- 这张表服务哪个业务动作?
- 业务最常怎么查询它?
- 哪些数据关系必须由数据库强制保证?
本文用一个内容社区的业务模型,串起建表、索引、外键、联合主键和自引用评论五个核心知识点。示例以 MySQL 8.0 为主,字段命名统一使用 snake_case。
一、先画业务关系,再写 CREATE TABLE
六张表的职责可以先压缩成一张图:

text
users 1 ─── 1 avatars
users 1 ─── n posts
users n ─── n posts ← post_likes / post_bookmarks
users 1 ─── n comments ─── n 1 posts
comments 1 ─── n comments ← parent_id 自引用
这张图里包含四种关系:
| 关系 | 表达方式 |
|---|---|
| 一对一 | 在从表外键上加 UNIQUE |
| 一对多 | 在多的一方保存外键 |
| 多对多 | 增加一张中间表 |
| 树状关系 | 表通过外键引用自身 |
二、用户表:字段少一点,边界清楚一点
用户表只放身份认证和稳定的核心字段:
sql
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_users_username (username)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
这里有三个设计点:
id是主键,适合按 ID 查询用户和建立关联。username用唯一索引,既支持登录查询,也从数据库层阻止重名。- 不保存明文密码,字段名直接写成
password_hash,实际存放 bcrypt、Argon2 等算法生成的哈希值。
头像、个人简介、偏好设置等扩展信息不一定都要塞进用户表。拆表不是为了"分布式",而是为了让核心用户记录和低频扩展字段有清晰边界;是否分表,还要看访问模式和业务复杂度。
三、头像表:数据库存元数据,不存图片文件
图片文件通常放在对象存储、静态服务器或 CDN,数据库保存文件描述和归属关系:
sql
CREATE TABLE avatars (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
object_key VARCHAR(512) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
file_size BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_avatars_user_id (user_id),
FOREIGN KEY (user_id) REFERENCES users (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
UNIQUE KEY (user_id) 很关键。它把"一个用户最多一张当前头像"落实成数据库约束,否则普通索引只能加速查询,不能阻止同一用户插入多条头像记录。
object_key 比直接存完整 URL 更灵活。以后更换 CDN 域名时,只需要在应用层拼接 URL,不必批量修改数据库里的每一条记录。
四、文章表:一对多关系的典型场景
一个用户可以发布多篇文章,所以外键放在文章表:
sql
CREATE TABLE posts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
author_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(255) NOT NULL,
content LONGTEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_posts_author_created (author_id, created_at),
FOREIGN KEY (author_id) REFERENCES users (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
为什么索引不是只写 author_id?因为常见查询往往是:
sql
SELECT * FROM posts
WHERE author_id = ?
ORDER BY created_at DESC;
(author_id, created_at) 让索引同时服务"查某个作者"和"按时间排序"。索引设计应该从真实 SQL 出发,而不是看到外键就机械地加一个同名索引。
如果允许删除用户,要先决定文章怎么办:禁止删除作者、把文章转给系统用户,还是级联删除。内容社区通常不会轻易级联删除文章,避免一次用户删除带走大量内容。
五、点赞与收藏:多对多关系交给中间表
用户和文章是多对多关系:一个用户可以点赞多篇文章,一篇文章也可以被多个用户点赞。用中间表保存这次关系:
sql
CREATE TABLE post_likes (
user_id BIGINT UNSIGNED NOT NULL,
post_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, post_id),
KEY idx_likes_post_user (post_id, user_id),
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (post_id) REFERENCES posts (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
联合主键 (user_id, post_id) 的意义是:同一个用户对同一篇文章只能出现一条点赞关系。重复插入时,数据库会拒绝,而不是等业务代码"记得检查"。
为什么还要有 (post_id, user_id)?联合主键的最左列是 user_id,适合查"某用户点过哪些文章";如果要查"某文章被哪些用户点赞",就需要以 post_id 开头的索引。
收藏表结构几乎一样:
sql
CREATE TABLE post_bookmarks (
user_id BIGINT UNSIGNED NOT NULL,
post_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, post_id),
KEY idx_bookmarks_post_user (post_id, user_id),
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (post_id) REFERENCES posts (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
点赞和收藏虽然字段相似,但它们是两个不同的业务动作。拆成两张表,查询语义更清楚,也能分别扩展取消时间、来源等字段。
六、评论表:parent_id 把平面数据变成树
评论除了属于一篇文章,还可能回复另一条评论:
sql
CREATE TABLE comments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
parent_id BIGINT UNSIGNED NULL,
content TEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_comments_post_created (post_id, created_at),
KEY idx_comments_parent (parent_id),
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (parent_id) REFERENCES comments (id) ON DELETE SET NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
parent_id 为 NULL 时是顶级评论,不为 NULL 时指向父评论。它是一条指向自身的外键:
text
评论 1
├── 评论 2(回复评论 1)
└── 评论 3(回复评论 1)
两种删除策略要分开理解:
- 删除文章时,评论没有独立存在的意义,使用
ON DELETE CASCADE比较自然。 - 删除父评论时,通常保留子评论,把
parent_id置空,所以使用ON DELETE SET NULL。
外键动作不是越多越好。CASCADE 可能造成大范围删除,应该根据业务生命周期谨慎选择。
七、索引:不要看到字段就加索引
| 类型 | 示例 | 解决什么问题 |
|---|---|---|
| 主键 | PRIMARY KEY (id) |
唯一标识记录,支持主键查询 |
| 唯一索引 | UNIQUE (username) |
防重复,同时支持查询 |
| 普通索引 | KEY (parent_id) |
加速过滤和关联查询 |
| 联合索引 | KEY (post_id, created_at) |
服务多列过滤、排序 |
| 联合主键 | PRIMARY KEY (user_id, post_id) |
防止关系重复 |
设计索引时记住两个代价:
- 索引会占用磁盘和内存,写入、更新、删除时也需要维护。
- 联合索引遵循最左前缀原则。
(user_id, post_id)能服务只按user_id查询,但通常不能高效服务只按post_id查询。
索引是否有效,最终要用 EXPLAIN 看执行计划,而不是凭感觉判断。
八、外键:让数据库拒绝脏关系
外键表达的是"这条引用必须指向真实记录":
sql
FOREIGN KEY (post_id) REFERENCES posts (id)
它可以阻止点赞指向不存在的文章,也能配合删除策略维护关联数据。但外键不是所有系统都必须使用的银弹:跨库、分库分表、极高写入吞吐场景可能会选择应用层或异步校验。单体 MySQL 内容社区通常可以优先使用外键,换取更强的数据完整性。
九、这些 SQL 坑很小,排查起来很浪费时间
1. 注释语法写错
SQL 中不要混入 HTML 注释:
sql
-- 正确:单行注释
KEY idx_posts_author (author_id)
2. 外键约束漏写完整
sql
CONSTRAINT fk_comments_post
FOREIGN KEY (post_id) REFERENCES posts (id)
3. 拼写和逗号错误
CASCADE 不能写成 CASADE;最后一个字段或约束后不能多一个逗号。
4. 表名和字段名不统一
userID、userId、user_id 混用会让 SQL、ORM 和接口 DTO 都变得难以维护。项目开始前先确定命名规范。
十、面试怎么答
数据库表设计应该从业务关系和查询场景出发。用户和文章是一对多,外键放在文章表;用户和文章的点赞、收藏是多对多,用中间表和联合主键防止重复关系;评论通过
parent_id自引用形成树。索引服务于高频查询,联合索引要考虑最左前缀;外键和级联策略负责维护引用完整性,但删除动作必须结合业务生命周期选择。
总结
| 业务 | 表设计 | 关键约束 |
|---|---|---|
| 用户 | users |
用户名唯一、密码只存哈希 |
| 头像 | avatars |
user_id 唯一,文件放对象存储 |
| 文章 | posts |
作者外键,作者与时间联合索引 |
| 点赞 | post_likes |
(user_id, post_id) 联合主键 |
| 收藏 | post_bookmarks |
与点赞相同的多对多模型 |
| 评论 | comments |
parent_id 自引用,按文章和父评论建索引 |
一句话总结:每张表服务一个业务动作,索引服务查询路径,约束负责让错误数据进不来。