后端开发绕不开的 SQL:从建表、查询到索引优化,一篇讲透
摘要:NestJS 代码最终都会被翻译成 SQL 发往数据库。本文从 SQL 的核心分类出发,拆解建表规范、字段类型选型、约束设计、查询执行顺序,深入 B+树索引原理与最左前缀原则,并结合博客系统的多表设计,展示后端开发中 SQL 的完整实践。
📑 目录
- 为什么后端开发必须懂 SQL?
- SQL 四大分类:你 95% 的时间在写哪两类?
- 建库与建表:从字符集到字段类型
- 约束:让数据库替你"把关"
- CRUD 操作:两条安全红线
- 查询语句的物理执行顺序
- 聚合函数:数据统计的"计算器"
- 索引:查询加速器的底层原理
- 博客系统多表设计实战
- 互动讨论
为什么后端开发必须懂 SQL?
你用 NestJS 写的 TypeORM、Prisma、Knex 代码,最终都会被翻译成 SQL,发往 MySQL 或 PostgreSQL 去执行。
一个形象的类比:
- 你的 NestJS 代码(TypeScript)是 "前台点单员" (接收请求、校验参数)
- SQL 是 "后厨的标准化语言" (告诉数据库"怎么切菜、怎么炒菜")
- MySQL 是 "后厨的实际操作台" (真正执行数据读写)
如果不懂 SQL,你无法理解为什么某个查询慢、为什么索引没生效、为什么联合主键的顺序会影响性能。懂 SQL 不是为了手写所有查询,而是为了在 ORM 生成低效 SQL 时,能精准定位和优化。
SQL 四大分类:你 95% 的时间在写哪两类?
| 分类 | 全称 | 作用 | 你接触过的语句 |
|---|---|---|---|
| DDL | Data Definition Language | 定义数据库结构 | CREATE、ALTER、DROP |
| DML | Data Manipulation Language | 操作数据(增、删、改) | INSERT、UPDATE、DELETE |
| DQL | Data Query Language | 查询数据(最常用) | SELECT |
| DCL | Data Control Language | 权限管理 | GRANT、REVOKE |
作为后端开发,95% 的时间都花在 DQL 和 DML 上------查数据和改数据。
建库与建表:从字符集到字段类型
建库(CREATE DATABASE)
sql
sql
CREATE DATABASE IF NOT EXISTS `my_app_db`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
| 参数 | 含义 |
|---|---|
IF NOT EXISTS |
库已存在则跳过,避免重复执行报错 |
utf8mb4 |
必须用这个 ,支持 Emoji,不要用 utf8(MySQL 的 utf8 实际只支持 3 字节,存不了 Emoji) |
utf8mb4_unicode_ci |
大小写不敏感的排序规则,ci 即 Case Insensitive |
建表(CREATE TABLE)
sql
sql
CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL UNIQUE,
`email` VARCHAR(100) NOT NULL,
`age` INT NOT NULL DEFAULT 18,
`status` TINYINT NOT NULL DEFAULT 1,
`bio` TEXT,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
字段类型选型指南
① 数值类型
| 类型 | 字节 | 范围 | 使用场景 |
|---|---|---|---|
TINYINT |
1 | -128 ~ 127 | 状态码、性别、开关 |
INT |
4 | ±21亿 | 用户 ID、订单号(绝大多数够用) |
BIGINT |
8 | 极大 | 雪花算法 ID |
DECIMAL(10,2) |
可变 | 精确小数 | 金额(必须用这个!) |
警告 :金额绝对不要用
FLOAT或DOUBLE,浮点数会有精度丢失。0.1 + 0.2在浮点数中不等于0.3,这是金融系统的大忌。
② 字符串类型
| 类型 | 最大长度 | 存储方式 | 使用场景 |
|---|---|---|---|
CHAR(n) |
255 | 固定长度 | 手机号、MD5 密码(性能最高) |
VARCHAR(n) |
~65000 字符 | 可变长度 | 用户名、邮箱、标题 |
TEXT |
65535 字符 | 单独存储 | 文章正文、长评论(不能设默认值) |
③ 日期时间类型
| 类型 | 特点 | 使用场景 |
|---|---|---|
DATETIME |
不受时区影响 | 活动开始时间、固定时间点 |
TIMESTAMP |
受时区影响,更省空间 | created_at、updated_at(推荐) |
DATE |
仅日期 | 生日、日期统计 |
约束:让数据库替你"把关"
约束是 MySQL 自动帮你检查的规则,不需要你写额外的 if 判断。数据在写入时如果违反约束,MySQL 直接拒绝并报错。
常用约束速查
| 约束 | SQL 写法 | 作用 |
|---|---|---|
NOT NULL |
email VARCHAR(100) NOT NULL |
必须填,不能为空 |
DEFAULT |
age INT DEFAULT 18 |
不填时自动填充默认值 |
UNIQUE |
username VARCHAR(50) UNIQUE |
不允许重复值 |
AUTO_INCREMENT |
id INT AUTO_INCREMENT |
自增主键 |
PRIMARY KEY |
PRIMARY KEY (id) |
主键(唯一标识) |
CONSTRAINT:给约束起名字(强烈推荐)
作用:报错时能直接看到是哪个约束出了问题,而不是看到一个自动生成的神秘名字。
sql
sql
CREATE TABLE orders (
id INT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
user_id INT NOT NULL,
CONSTRAINT uk_orders_no UNIQUE (order_no),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id)
);
对比:
- ❌ 没起名字:
Duplicate entry 'ORD-001' for key 'order_no_2' - ✅ 起了名字:
Duplicate entry 'ORD-001' for key 'uk_orders_no'
外键(FOREIGN KEY)与级联策略
sql
sql
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
| 策略 | 含义 | 适用场景 |
|---|---|---|
RESTRICT(默认) |
子表有引用时禁止删除父表 | 财务、核心订单数据 |
CASCADE |
父表删/改,子表自动同步 | 删除用户时清理购物车、评论 |
SET NULL |
父表删/改,子表外键置空 | 保留订单记录但断开关联 |
CRUD 操作:两条安全红线
sql
sql
-- 插入
INSERT INTO users (username, email) VALUES ('大许', 'daxu@email.com');
-- 查询
SELECT id, username, email FROM users;
-- 更新(⚠️ 必须加 WHERE!)
UPDATE users SET age = 19 WHERE id = 1;
-- 删除(⚠️ 必须加 WHERE!)
DELETE FROM users WHERE id = 2;
安全红线 :
UPDATE和DELETE不加WHERE会操作全表。在 NestJS 的 ORM 中,忘记加where条件同样危险。
查询语句的物理执行顺序
书写顺序 ≠ 执行顺序。这是 SQL 中最容易踩的坑。
sql
sql
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent
FROM orders
WHERE status = 'paid'
AND created_at >= '2026-01-01'
GROUP BY user_id
HAVING COUNT(*) >= 3
ORDER BY total_spent DESC
LIMIT 10 OFFSET 0;
真正的执行顺序:
| 执行顺序 | 关键词 | 作用 |
|---|---|---|
| ① | FROM |
确定数据来源表 |
| ② | WHERE |
筛选行(分组前过滤) |
| ③ | GROUP BY |
分组 |
| ④ | HAVING |
筛选组(分组后过滤) |
| ⑤ | SELECT |
计算聚合函数,取最终列 |
| ⑥ | ORDER BY |
排序 |
| ⑦ | LIMIT / OFFSET |
分页 |
关键区别 :WHERE 过滤行,HAVING 过滤组。
sql
sql
-- ❌ 错误:WHERE 里不能写聚合函数
SELECT user_id, COUNT(*) FROM orders
WHERE COUNT(*) > 3 GROUP BY user_id;
-- ✅ 正确:用 HAVING
SELECT user_id, COUNT(*) FROM orders
GROUP BY user_id
HAVING COUNT(*) > 3;
聚合函数:数据统计的"计算器"
| 函数 | 作用 | 示例 |
|---|---|---|
COUNT(*) |
统计总行数 | SELECT COUNT(*) FROM users; |
COUNT(列名) |
统计该列非空行数 | COUNT(email) |
COUNT(DISTINCT 列名) |
统计不重复值个数 | COUNT(DISTINCT user_id) |
SUM(列) |
求和 | SELECT SUM(amount) FROM orders; |
AVG(列) |
平均值 | SELECT AVG(age) FROM users; |
MAX(列) |
最大值 | SELECT MAX(amount) FROM orders; |
MIN(列) |
最小值 | SELECT MIN(created_at) FROM users; |
实战示例:
sql
sql
SELECT
COUNT(*) AS total_orders,
SUM(amount) AS total_revenue,
AVG(amount) AS avg_order_value
FROM orders
WHERE status = 'paid';
索引:查询加速器的底层原理
索引为什么快?
没有索引 → 全表扫描(逐行翻找)
有索引 → B+树多级目录(跳着找)
没有索引时,MySQL 只能一行行读完整个表,直到找到符合条件的记录------这就是 "全表扫描(Full Table Scan)" 。
有了索引,就像有了一本书的目录。索引(B+树)本质上就是一个 "多级目录" ,存放了 索引字段的值 + 指向完整数据行的指针。
主键索引 vs 普通索引
| 索引类型 | 叶子节点存什么? | 说明 |
|---|---|---|
| 主键索引(聚集索引) | 整行完整数据 | 主键自带的索引,权重最高 |
| 普通索引(非聚集索引) | 索引字段的值 + 主键值(ID) | 查到后还需"回表" |
什么是"回表"?
先查普通索引找到匹配记录的主键 ID,再通过主键索引找到最终记录的完整数据。这个"两步走"的过程就是 "回表(Back to Table)" 。
联合索引与"最左前缀原则"
联合索引 INDEX idx_name_age (name, age) 的物理存储顺序是:先按 name 排序,在 name 组内再按 age 排序。
| SQL 写法 | 是否走索引? | 原因 |
|---|---|---|
WHERE name = '大许' |
✅ 命中 | 索引第一列是 name |
WHERE name = '大许' AND age = 18 |
✅ 完全命中 | 先定位 name,再定位 age |
WHERE age = 18 |
❌ 不命中 | 跳过了 name,索引无法定位 |
核心结论:联合索引不是"只有第一列建索引",而是"第二列依赖第一列排序"。跳过第一列,整个索引失去意义。
优化建议 :联合主键中,非最左字段需要单独建普通索引。例如联合主键 (user_id, order_id),单独查 order_id 用不上这个索引,需要单独给 order_id 建索引。
覆盖索引
如果查询的所有字段都在索引里,MySQL 就不需要回表了。
sql
sql
-- 覆盖索引:不需要回表(name 和 age 都在索引里)
SELECT name, age FROM users WHERE name = '大许';
-- 非覆盖索引:需要回表取 email
SELECT name, age, email FROM users WHERE name = '大许';
索引设计原则
- 给
WHERE、ORDER BY、JOIN中频繁出现的字段加索引 - 联合索引的顺序:最常用的过滤条件放最左边
- 不要滥用索引(索引会拖慢写入速度,每次
INSERT/UPDATE都要更新索引) - 联合主键中,非最左字段需要单独建普通索引
博客系统多表设计实战
以一个经典的个人博客为场景,涉及文章、点赞、收藏、评论、用户、标签等核心模块。
用户表(user)
用户表是系统的核心,只存储最关键的字段,有利于快速查询和分布式扩展。
sql
sql
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是自增主键,用作用户唯一标识name同时加UNIQUE约束,确保用户名不重复,且作为高频查询条件(搜索用户、登录)利用索引加速- 头像、个性签名等扩展信息独立建表关联,保持
user表小而精
头像表(avatar)
头像等静态资源一般存储在静态服务器或云 OSS 上,数据库中只存储文件的元信息。
sql
sql
CREATE TABLE `avatar` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`mimetype` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`filename` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`size` int(11) NOT NULL,
`userId` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `userId` (`userId`),
CONSTRAINT `avatar_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
设计说明:
userId建立普通索引是因为查询用户头像时高频使用- 外键约束确保头像记录始终对应一个存在的用户
文章表(post)
sql
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,
`userId` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `userId` (`userId`),
CONSTRAINT `post_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
设计说明:
content用longtext存储长文章内容title不允许为空,但content可以为空(允许用户先创建标题再写内容)
点赞表(user_like_post)
多对多关系表,记录哪个用户点赞了哪篇文章。
sql
sql
CREATE TABLE `user_like_post` (
`userId` int(11) NOT NULL,
`postId` int(11) NOT NULL,
PRIMARY KEY (`userId`, `postId`),
KEY `postId` (`postId`),
CONSTRAINT `user_like_post_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `user` (`id`),
CONSTRAINT `user_like_post_ibfk_2` FOREIGN KEY (`postId`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
索引设计分析:
PRIMARY KEY (userId, postId)是联合主键,也是索引- 单独给
postId建普通索引,因为查询"哪些用户点赞了某篇文章"时以postId为条件,不走联合主键(不满足最左前缀) - 不需要单独给
userId建索引,因为(userId, postId)联合主键已经覆盖了以userId为条件的查询(最左前缀原则:userId在最左边,命中)
评论表(comment)
支持多级评论(楼中楼),通过 parentId 实现自关联。
sql
sql
CREATE TABLE `comment` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`content` longtext COLLATE utf8mb4_unicode_ci,
`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 `comment_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `user` (`id`),
CONSTRAINT `comment_ibfk_2` FOREIGN KEY (`parentId`) REFERENCES `comment` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT `comment_ibfk_3` FOREIGN KEY (`postId`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
设计说明:
parentId指向同表的另一条评论,实现"楼中楼"结构parentId的外键使用ON DELETE SET NULL------如果父评论被删除,子评论的parentId置为空(保留子评论)
标签表(tag)与文章-标签关联表
sql
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 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 `post_tag_ibfk_1` FOREIGN KEY (`postId`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `post_tag_ibfk_2` FOREIGN KEY (`tagId`) REFERENCES `tag` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
文件表(file)
上传文件元信息存储,支持图片尺寸记录。
sql
less
CREATE TABLE `file` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`originalname` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`mimetype` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`filename` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`size` int(11) NOT NULL,
`postId` int(11) DEFAULT NULL,
`userId` int(11) NOT NULL,
`width` smallint(6) NOT NULL,
`height` smallint(6) NOT NULL,
`metadata` json DEFAULT NULL,
KEY `postId` (`postId`),
KEY `userId` (`userId`),
CONSTRAINT `file_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `user` (`id`),
CONSTRAINT `file_ibfk_2` FOREIGN KEY (`postId`) REFERENCES `post` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
设计说明:
metadata使用json类型存储扩展信息(如图片 EXIF、压缩参数等)width和height单独列出来便于列表展示,避免每次都要解析 JSON
互动讨论
💬 为什么金额必须用 DECIMAL 而不用 FLOAT?
FLOAT 和 DOUBLE 是近似值类型,存在精度丢失。0.1 + 0.2 在浮点数中不等于 0.3。金融系统要求绝对精确,必须用 DECIMAL(定点数)。
💬 WHERE 和 HAVING 有什么区别?
WHERE 在分组前过滤行 ,HAVING 在分组后过滤组 。WHERE 不能使用聚合函数(如 COUNT()),HAVING 可以。
💬 联合索引的最左前缀原则是什么意思?
联合索引 (a, b, c) 的物理存储顺序是"先按 a 排序,再按 b,再按 c"。查询条件如果从 a 开始连续匹配(如 WHERE a=1 AND b=2)则命中索引;如果跳过了 a(如 WHERE b=2)则索引失效。
💬 普通索引为什么需要"回表"?
主键索引(聚集索引)的叶子节点存的是整行数据。普通索引(非聚集索引)的叶子节点存的是"索引字段的值 + 主键值"。用普通索引查到主键后,还要再到主键索引里取完整数据,这就是"回表"。
💬 覆盖索引怎么理解?
如果 SELECT 的字段全部都在索引里,MySQL 就不需要回表了,直接从索引中返回数据。这就是"覆盖索引",效率最高。