🎯 课程目标

从 MySQL 零基础到能独立设计表结构、编写复杂查询、使用 ORM 构建数据层。

👥 适合谁

有 1 年以上 JS/TS 经验的前端工程师,想理解后端数据库是怎么工作的。

⏱️ 学习时长

认真学完约需 3-4 周,每天 1.5 小时。每节都有动手练习。

📍 课程地图(24 节)

阶段内容关键产出
总览(2节)课程地图、安装与工具MySQL + 客户端工具就绪
表设计(5节)数据类型/主键/关系/索引/建表实战能独立设计规范化表结构
SQL 查询(6节)SELECT/JOIN/聚合/子查询/增删改/EXPLAIN能写出并优化复杂查询
事务与锁(3节)事务/隔离级别/锁与死锁理解并发数据安全
ORM(4节)SQLAlchemy/关系查询/N+1/MyBatisORM 实战能力达标
实战(4节)博客/电商/慢查询排查/SQL 速查完整数据层设计经验

🔑 前端工程师学 MySQL 的核心类比

localStorage → 数据表

localStorage 是 key-value 存储,数据库表是结构化的行列存储。类型确定、关系明确,不再是松散的 JSON。

Array.filter() → WHERE 子句

你在前端用 filter() 筛选数据,SQL 用 WHERE 做同样的事——但在服务端、百万行数据上。

Array.reduce() → GROUP BY

前端的聚合用 reduce,SQL 用 GROUP BY + 聚合函数(SUM/COUNT/AVG)——性能高几个数量级。

fetch() 的响应 → SELECT 的结果

你每天调用的 API 返回的 JSON,绝大部分是后端某条 SELECT 查询的结果。学会 SQL 就是学会看清数据的来源。

💡 学习建议:每学一个 SQL 概念,先问自己"这在前端 JavaScript 里对应什么操作?"。大部分数组方法都有 SQL 等价物。
返回总入口

🔄 前端工具 vs 数据库工具对比

前端概念数据库对应说明
Node.jsMySQL Server运行环境/引擎
VS CodeDBeaver / TablePlus可视化编辑工具
浏览器 Consolemysql 命令行快速执行命令
package.jsonCREATE DATABASE项目初始化
npm installCREATE TABLE创建数据结构

🍎 macOS 安装(Homebrew)

# ===== 用 Homebrew 安装 MySQL ===== # 安装 MySQL 8.0 brew install mysql # 启动 MySQL 服务(类似 npm start 启动开发服务器) brew services start mysql # 初始安全设置(设置 root 密码) mysql_secure_installation # 连接数据库(类似在浏览器打开 localhost:3000) mysql -u root -p

🐧 Linux / Docker 安装

# ===== Docker 一键启动(推荐,不污染系统环境)===== # 拉取并启动 MySQL 容器(类似 npx create-react-app) docker run --name mysql-learn \ -e MYSQL_ROOT_PASSWORD=123456 \ -p 3306:3306 \ -d mysql:8.0 # 进入容器的 MySQL 命令行 docker exec -it mysql-learn mysql -u root -p123456 # 停止 / 启动 docker stop mysql-learn docker start mysql-learn

🔧 第一次连接:创建学习数据库

-- ===== 创建学习用数据库 ===== -- 查看已有数据库(类似 ls 查看目录) SHOW DATABASES; -- 创建数据库(类似 mkdir 创建项目文件夹) CREATE DATABASE learn_mysql DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -- 使用数据库(类似 cd 进入目录) USE learn_mysql; -- 验证当前数据库 SELECT DATABASE(); -- 输出: learn_mysql
⚠️ 字符集必须用 utf8mb4!MySQL 的 utf8 只支持 3 字节,存不了 emoji 表情。utf8mb4 才是真正的 UTF-8。这是 MySQL 最著名的历史坑之一。
✏️ 填空:基本命令
-- 创建数据库 learn_mysql; -- 进入数据库 learn_mysql; -- 查看当前用的是哪个数据库 SELECT ;
🧠 小测验:安装与配置

为什么 MySQL 建议使用 utf8mb4 而不是 utf8?

🔄 JS 类型 vs MySQL 类型对比

JS 类型MySQL 类型说明
number(整数)INT / BIGINTINT 最大 21 亿,BIGINT 用于 ID/时间戳
number(小数)DECIMAL(10,2)精确小数,金额必须用这个!
number(浮点)FLOAT / DOUBLE近似值,科学计算用
string(短文本)VARCHAR(255)可变长字符串,括号里是最大长度
string(长文本)TEXT / LONGTEXT文章内容、富文本用 TEXT
booleanTINYINT(1) / BOOLEANMySQL 的 BOOLEAN 本质就是 TINYINT(1)
DateDATETIME / TIMESTAMPDATETIME 不带时区,TIMESTAMP 带时区转换
JSON 对象JSONMySQL 5.7+ 原生支持 JSON 类型
nullNULL任意列都可以允许或禁止 NULL

📌 整数类型:选对大小很重要

-- ===== 整数类型选择指南 ===== -- TINYINT:0-255(无符号),适合状态码、布尔值 -- 类比:前端 enum 里只有几个值的场景 ALTER TABLE users ADD status TINYINT UNSIGNED DEFAULT 1; -- INT:0-42亿(无符号),最常用 -- 类比:大部分前端数组的 length 都在这个范围 ALTER TABLE orders ADD quantity INT UNSIGNED NOT NULL; -- BIGINT:超大整数,雪花 ID / 时间戳 -- 类比:JS 的 Number.MAX_SAFE_INTEGER = 2^53-1 ALTER TABLE events ADD snowflake_id BIGINT UNSIGNED NOT NULL; -- ⚠️ 金额绝对不能用 FLOAT! -- 0.1 + 0.2 = 0.30000000000000004(JS 和 MySQL 都有这个问题) -- 解决方案:DECIMAL(10,2) —— 精确到分 ALTER TABLE products ADD price DECIMAL(10,2) NOT NULL;

📝 字符串类型:VARCHAR vs TEXT

-- ===== 字符串类型选择 ===== -- VARCHAR(N):可变长,N 是最大字符数 -- 用于:用户名、邮箱、标题(长度可预估) CREATE TABLE users ( username VARCHAR(50) NOT NULL, -- 用户名最多 50 字符 email VARCHAR(255) NOT NULL, -- 邮箱最多 255 字符 bio VARCHAR(500) -- 个人简介 ); -- TEXT:最大 65,535 字符(约 64KB) -- 用于:文章内容、评论正文(长度不可预估) CREATE TABLE posts ( title VARCHAR(200) NOT NULL, content TEXT NOT NULL, -- 文章正文 summary VARCHAR(500) -- 摘要用 VARCHAR 就够 ); -- CHAR(N):定长字符串,总是占 N 个字符空间 -- 用于:固定长度的编码(如国家代码 'CN'、MD5 哈希) ALTER TABLE users ADD country_code CHAR(2) DEFAULT 'CN';

🕐 时间类型:DATETIME vs TIMESTAMP

-- ===== 时间类型对比 ===== -- DATETIME:'2024-01-15 14:30:00',不做时区转换 -- 适合:业务时间(如活动开始时间、出生日期) ALTER TABLE events ADD start_time DATETIME NOT NULL; -- TIMESTAMP:会按当前会话时区做转换,底层以 UTC 语义处理 -- 适合:记录时间(创建时间、更新时间) -- 类比:前端的 new Date().toISOString() 存 UTC ALTER TABLE posts ADD created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; ALTER TABLE posts ADD updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; -- 更新时自动刷新!

📋 速查:类型选择决策树

场景推荐类型理由
自增 IDINT UNSIGNED42 亿够用就选 INT
雪花 IDBIGINT UNSIGNED超过 INT 范围
金额DECIMAL(10,2)精确计算,分为单位
用户名/邮箱VARCHAR(255)可变长,够长
文章正文TEXT长度不可预估
布尔值TINYINT(1)0/1,节省空间
创建时间TIMESTAMP按会话时区转换,适合记录创建/更新时间
业务时间DATETIME不需要时区转换
附加数据JSON半结构化数据
✏️ 填空:类型选择
-- 存储商品价格(精确到分) price NOT NULL, -- 存储文章内容(长度不确定) content NOT NULL, -- 存储是否删除的标记 is_deleted DEFAULT 0
🧠 小测验:数据类型

存储金额时应该用什么类型?

🔄 前端 ID vs 数据库主键

概念前端做法MySQL 做法
唯一标识id: Math.random()PRIMARY KEY 约束
自增 ID需要自己维护计数器AUTO_INCREMENT 自动递增
UUIDcrypto.randomUUID()UUID() 函数
唯一性保证纯靠代码逻辑数据库引擎强制保证

📌 自增主键(最常用)

-- ===== 自增主键:最简单也最常用 ===== CREATE TABLE users ( -- id 自动递增,每插入一行自动 +1 -- 类比:前端数组的 index;默认插入时通常单调递增 id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, email VARCHAR(255) NOT NULL ); -- 插入时不需要指定 id,数据库自动分配 INSERT INTO users (username, email) VALUES ('alice', 'alice@test.com'); INSERT INTO users (username, email) VALUES ('bob', 'bob@test.com'); -- 查看结果:id 自动为 1, 2 SELECT * FROM users; -- +----+----------+----------------+ -- | id | username | email | -- +----+----------+----------------+ -- | 1 | alice | alice@test.com | -- | 2 | bob | bob@test.com | -- +----+----------+----------------+

🆔 UUID 主键 vs 自增主键

-- ===== UUID 主键:分布式系统常用 ===== CREATE TABLE orders ( -- CHAR(36) 存储 UUID 字符串 id CHAR(36) PRIMARY KEY, user_id INT UNSIGNED NOT NULL, total DECIMAL(10,2) NOT NULL ); -- 插入时显式生成 UUID(兼容更多 MySQL 版本) INSERT INTO orders (id, user_id, total) VALUES (UUID(), 1, 99.99); -- 结果:id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'

✅ 自增主键优点

• 占用空间小(4-8 字节)
• 插入性能好(顺序写入)
• 索引查找快
• URL 友好(/users/123)

✅ UUID 主键优点

• 全局唯一(分布式安全)
• 不暴露业务量(看不出第几条)
• 可在应用层生成
• 合并数据不冲突

⚠️ UUID 性能陷阱:UUID 是随机的,作为主键会导致 InnoDB 聚簇索引频繁页分裂(类比:往有序数组的随机位置插入元素)。如果必须用 UUID,考虑使用有序 UUID(如 UUID_TO_BIN(UUID(), 1))。

🔗 复合主键

-- ===== 复合主键:多对多关系表常用 ===== -- 类比:前端的 Map 用 `${userId}_${postId}` 做 key CREATE TABLE post_tags ( post_id INT UNSIGNED NOT NULL, tag_id INT UNSIGNED NOT NULL, -- 两个字段组合起来做主键,保证同一篇文章不会有重复标签 PRIMARY KEY (post_id, tag_id) ); -- 这样同一组合只能插入一次 INSERT INTO post_tags (post_id, tag_id) VALUES (1, 5); -- ✅ 成功 INSERT INTO post_tags (post_id, tag_id) VALUES (1, 5); -- ❌ 重复,报错!
✏️ 填空:主键语法
CREATE TABLE products ( id INT UNSIGNED PRIMARY KEY, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL );
🧠 小测验:主键设计

以下哪种说法是正确的?

🔄 前端嵌套 vs 数据库关系

关系类型前端 JSON 写法MySQL 写法
一对一{ user: { profile: {...} } }两张表,profile 里存 user_id
一对多{ user: { posts: [...] } }posts 表存 user_id 外键
多对多{ post: { tags: [...] } }第三张关联表 post_tags

📌 一对多关系(最常见)

-- ===== 一对多:一个用户有多篇文章 ===== -- 用户表("一"的一方) CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL ); -- 文章表("多"的一方,存外键) CREATE TABLE posts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, content TEXT, -- 外键:指向 users 表的 id -- 类比:前端对象里的 userId 字段,但数据库会强制检查 user_id INT UNSIGNED NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -- 删除用户时,自动删除其所有文章 ON UPDATE CASCADE -- 更新用户 id 时,自动更新关联 );

🔗 多对多关系(中间表)

-- ===== 多对多:文章和标签 ===== -- 类比:前端的 post.tags = [1, 3, 5], -- 但数据库不能在一个字段里存数组,需要"中间表" -- 标签表 CREATE TABLE tags ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE -- 标签名不能重复 ); -- 中间关联表(核心!) CREATE TABLE post_tags ( post_id INT UNSIGNED NOT NULL, tag_id INT UNSIGNED NOT NULL, PRIMARY KEY (post_id, tag_id), -- 复合主键防重复 FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ); -- 给文章打标签 INSERT INTO post_tags (post_id, tag_id) VALUES (1, 1); INSERT INTO post_tags (post_id, tag_id) VALUES (1, 3); -- 查询某篇文章的所有标签 SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = 1;

👤 一对一关系

-- ===== 一对一:用户和详细资料 ===== -- 类比:把一个大的前端 state 对象拆成两个更小的模块 CREATE TABLE user_profiles ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL UNIQUE, -- UNIQUE 保证一对一 avatar VARCHAR(500), phone VARCHAR(20), address TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );
💡 为什么不用 JSON 数组代替中间表?虽然 MySQL 支持 JSON 类型,也能通过生成列/表达式索引做部分索引优化,但它仍不适合替代关系表:外键约束、标准 JOIN、唯一性约束和关系一致性都会更难维护。数据库的核心优势就是"关系"——用好它。
✏️ 填空:外键语法
CREATE TABLE comments ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, content TEXT NOT NULL, post_id INT UNSIGNED NOT NULL, (post_id) posts(id) ON DELETE CASCADE );
🧠 小测验:关系建模

多对多关系在数据库中如何实现?

🔄 前端类比:索引 = Map vs 遍历 = Array

操作无索引(Array 遍历)有索引(Map 查找)
查找users.find(u => u.email === x)emailMap.get(x)
时间复杂度O(n) 全表扫描O(log n) B+树
100 万条数据扫描 100 万行约 20 次比较
空间代价无额外空间额外存储索引结构
写入代价直接插入需要维护索引结构

📌 创建索引

-- ===== 索引创建语法 ===== -- 普通索引:加速查询,允许重复值 -- 类比:按"城市"给通讯录分组 CREATE INDEX idx_users_email ON users(email); -- 唯一索引:加速查询 + 保证不重复 -- 类比:Map 的 key 天然不重复 CREATE UNIQUE INDEX uk_users_email ON users(email); -- 联合索引(复合索引):多列组合 -- 类比:先按"城市"分,城市内再按"姓名"排 CREATE INDEX idx_posts_user_created ON posts(user_id, created_at); -- 建表时直接加索引 CREATE TABLE articles ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, author_id INT UNSIGNED NOT NULL, status TINYINT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_author (author_id), -- 普通索引 INDEX idx_status_created (status, created_at), -- 联合索引 UNIQUE INDEX uk_title (title) -- 唯一索引 );

📏 最左前缀原则(核心!)

-- ===== 联合索引的最左前缀原则 ===== -- 假设有索引 idx(A, B, C) -- ✅ 能用到索引(从最左列开始匹配) SELECT * FROM t WHERE A = 1; -- 用到 A SELECT * FROM t WHERE A = 1 AND B = 2; -- 用到 A, B SELECT * FROM t WHERE A = 1 AND B = 2 AND C = 3; -- 用到 A, B, C -- ❌ 无法用到索引(跳过了最左列) SELECT * FROM t WHERE B = 2; -- 跳过 A,索引失效 SELECT * FROM t WHERE C = 3; -- 跳过 A 和 B SELECT * FROM t WHERE B = 2 AND C = 3; -- 跳过 A -- 类比:电话簿按"城市-区-街道"排序 -- 你可以查"北京市",也可以查"北京市朝阳区" -- 但不能直接查"朝阳区"(不知道是哪个城市的朝阳区)
⚠️ 索引不是越多越好!每个索引都会占用磁盘空间,并且在 INSERT/UPDATE/DELETE 时需要额外维护。一张表通常 3-5 个索引就够了。索引的维护成本类似于前端 React 中过多的 useMemo——优化过度反而变慢。

📋 速查:什么时候该建索引

场景该建索引?说明
WHERE 条件列✅ 是查询过滤最常用
JOIN 的关联列✅ 是外键列/关联列通常应有索引支持
ORDER BY 列✅ 是避免排序临时表
频繁更新的列⚠️ 慎重写入时维护索引有成本
数据量很小的表❌ 否全表扫描比走索引快
选择性很低的列❌ 否如性别(只有男/女)
✏️ 填空:索引操作
-- 为 email 列创建唯一索引 CREATE INDEX uk_email ON users(); -- 查看表上的所有索引 SHOW FROM users;
🧠 小测验:索引设计

联合索引 (A, B, C) 中,WHERE B = 2 AND C = 3 能用到索引吗?

🎯 实战场景:博客系统

-- ===== 完整的博客系统建表 ===== -- 1. 用户表 CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, -- 永远不存明文密码! avatar VARCHAR(500), status TINYINT UNSIGNED DEFAULT 1 COMMENT '0=禁用 1=正常', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE INDEX uk_email (email), UNIQUE INDEX uk_username (username) ) COMMENT='用户表'; -- 2. 分类表 CREATE TABLE categories ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, slug VARCHAR(50) NOT NULL, -- URL 友好的标识符 sort_order INT DEFAULT 0, UNIQUE INDEX uk_slug (slug) ) COMMENT='文章分类'; -- 3. 文章表 CREATE TABLE posts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL, content TEXT NOT NULL, summary VARCHAR(500), user_id INT UNSIGNED NOT NULL, category_id INT UNSIGNED, status TINYINT DEFAULT 0 COMMENT '0=草稿 1=已发布', view_count INT UNSIGNED DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE INDEX uk_slug (slug), INDEX idx_user (user_id), INDEX idx_category (category_id), INDEX idx_status_created (status, created_at), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL ) COMMENT='文章表'; -- 4. 标签表 + 关联表 CREATE TABLE tags ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE ) COMMENT='标签'; CREATE TABLE post_tags ( post_id INT UNSIGNED NOT NULL, tag_id INT UNSIGNED NOT NULL, PRIMARY KEY (post_id, tag_id), FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ) COMMENT='文章-标签关联';

📐 建表规范清单

✅ 必须做

• 每张核心表都有稳定主键(常见是 AUTO_INCREMENT / BIGINT / UUID)
• 使用 utf8mb4 字符集
• 加 created_at / updated_at
• 外键列要有索引支持
• 用 COMMENT 注释每张表和关键字段

❌ 不要做

• 不要用 MySQL 保留字做列名
• 不要用 FLOAT 存金额
• 不要在一个字段里存逗号分隔的 ID
• 不要给每个字段都建索引
• 不要用 utf8(用 utf8mb4)

🔧 常用 ALTER TABLE 操作

-- ===== 修改表结构(类似前端重构组件 props)===== -- 添加列 ALTER TABLE users ADD phone VARCHAR(20) AFTER email; -- 修改列类型 ALTER TABLE users MODIFY bio VARCHAR(1000); -- 重命名列 ALTER TABLE users CHANGE username nickname VARCHAR(50) NOT NULL; -- 删除列 ALTER TABLE users DROP COLUMN phone; -- 添加索引 ALTER TABLE users ADD INDEX idx_status (status); -- 删除索引 ALTER TABLE users DROP INDEX idx_status;
✏️ 填空:建表语法
CREATE TABLE comments ( id INT UNSIGNED AUTO_INCREMENT , content TEXT NOT NULL, user_id INT UNSIGNED NOT NULL, post_id INT UNSIGNED NOT NULL, created_at DEFAULT CURRENT_TIMESTAMP, INDEX idx_post (post_id), FOREIGN KEY (user_id) users(id) ) COMMENT='评论表';
🧠 小测验:建表实战

以下哪种做法是错误的?

🔄 JS 数组方法 vs SQL 对应

JS 方法SQL 子句作用
.map(u => u.name)SELECT name选择特定字段
.filter(u => u.age > 18)WHERE age > 18过滤条件
.sort((a,b) => a.name - b.name)ORDER BY name排序
.slice(0, 10)LIMIT 10取前 N 条
.slice(10, 20)LIMIT 10 OFFSET 10分页
array.lengthSELECT COUNT(*)计数
[...new Set(arr)]SELECT DISTINCT去重

📌 基础查询语法

-- ===== SELECT 基础语法 ===== -- 查询所有列(类似 console.log(users)) SELECT * FROM users; -- 查询指定列(类似 users.map(u => ({ name: u.name, email: u.email }))) SELECT username, email FROM users; -- 带条件过滤(类似 users.filter(u => u.status === 1)) SELECT * FROM users WHERE status = 1; -- 多条件(AND / OR) SELECT * FROM posts WHERE status = 1 AND user_id = 1; SELECT * FROM posts WHERE status = 1 OR view_count > 1000; -- 模糊搜索(类似 name.includes('ali')) SELECT * FROM users WHERE username LIKE '%ali%'; -- 范围查询 SELECT * FROM posts WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'; SELECT * FROM users WHERE id IN (1, 2, 3); -- NULL 判断(注意:不能用 = NULL,必须用 IS NULL) SELECT * FROM users WHERE avatar IS NULL; SELECT * FROM users WHERE avatar IS NOT NULL;

📊 排序与分页

-- ===== 排序 ORDER BY ===== -- 按创建时间倒序(最新的在前面) -- 类比:posts.sort((a, b) => b.createdAt - a.createdAt) SELECT * FROM posts ORDER BY created_at DESC; -- 多字段排序:先按状态,再按时间 SELECT * FROM posts ORDER BY status ASC, created_at DESC; -- ===== 分页 LIMIT + OFFSET ===== -- 第一页(每页 10 条) -- 类比:posts.slice(0, 10) SELECT * FROM posts ORDER BY created_at DESC LIMIT 10; -- 第二页 -- 类比:posts.slice(10, 20) SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10; -- LIMIT 的简写形式:LIMIT offset, count SELECT * FROM posts ORDER BY created_at DESC LIMIT 10, 10; -- 等同上面

🏷️ 别名与表达式

-- ===== 别名 AS ===== -- 列别名(类似 map 时重命名字段) SELECT username AS '用户名', email AS '邮箱' FROM users; -- 计算列(类似 map 中做计算) SELECT title, view_count, view_count * 0.1 AS score FROM posts; -- 字符串拼接 SELECT CONCAT(username, ' <', email, '>') AS display_name FROM users; -- 条件表达式 CASE(类似三元运算符) SELECT title, CASE status WHEN 0 THEN '草稿' WHEN 1 THEN '已发布' ELSE '未知' END AS status_text FROM posts;
✏️ 填空:SELECT 练习
-- 查询已发布的文章,按时间倒序,取前 5 条 SELECT title, created_at FROM posts status = 1 created_at DESC 5;
🧠 小测验:SELECT 基础

判断 NULL 值应该用什么语法?

🔄 前端数据拼接 vs SQL JOIN

前端做法SQL JOIN 做法
users.map(u => ({...u, posts: posts.filter(p => p.userId === u.id)}))SELECT * FROM users JOIN posts ON users.id = posts.user_id
两次 fetch,前端拼接一条 SQL,数据库内部完成
数据量大时浏览器容易卡顿通常比前端拼接更高效(取决于索引与执行计划)

📌 INNER JOIN(交集)

-- ===== INNER JOIN:只返回两边都匹配的行 ===== -- 类比:两个数组取交集 -- 查询所有文章及其作者信息 SELECT p.title AS '文章标题', p.created_at AS '发布时间', u.username AS '作者' FROM posts p INNER JOIN users u ON p.user_id = u.id WHERE p.status = 1 ORDER BY p.created_at DESC; -- 如果某篇文章的 user_id 不存在于 users 表,这条记录不会出现

📌 LEFT JOIN(保留左表全部)

-- ===== LEFT JOIN:左表全部保留,右表没匹配到的填 NULL ===== -- 类比:users.map(u => ({ ...u, postCount: posts.filter(p => p.userId === u.id).length })) -- 即使某用户没有文章,也会出现在结果中 -- 查询所有用户及其文章数(包括没写过文章的用户) SELECT u.username AS '用户名', COUNT(p.id) AS '文章数' FROM users u LEFT JOIN posts p ON u.id = p.user_id GROUP BY u.id, u.username; -- 结果可能是: -- alice | 5 -- bob | 3 -- carol | 0 ← LEFT JOIN 才能看到她(没有文章)

🔗 多表 JOIN

-- ===== 三表 JOIN:文章 + 作者 + 分类 ===== SELECT p.title AS '文章', u.username AS '作者', c.name AS '分类', p.view_count AS '阅读量' FROM posts p INNER JOIN users u ON p.user_id = u.id LEFT JOIN categories c ON p.category_id = c.id -- 文章可能没分类 WHERE p.status = 1 ORDER BY p.view_count DESC LIMIT 10; -- ===== 多对多 JOIN:文章 + 标签 ===== SELECT p.title AS '文章', GROUP_CONCAT(t.name SEPARATOR ', ') AS '标签列表' FROM posts p LEFT JOIN post_tags pt ON p.id = pt.post_id LEFT JOIN tags t ON pt.tag_id = t.id WHERE p.status = 1 GROUP BY p.id, p.title;
💡 INNER JOIN vs LEFT JOIN 选择:如果"右表没数据"代表异常(如文章没作者),用 INNER JOIN。如果"右表没数据"是正常情况(如用户没文章),用 LEFT JOIN。
✏️ 填空:JOIN 语法
-- 查询所有用户和他们的文章(包含没有文章的用户) SELECT u.username, p.title FROM users u JOIN posts p u.id = p.user_id;
🧠 小测验:JOIN

LEFT JOIN 的特点是什么?

🔄 JS reduce vs SQL 聚合

JS 写法SQL 写法作用
arr.lengthCOUNT(*)计数
arr.reduce((s,x) => s+x.price, 0)SUM(price)求和
sum / arr.lengthAVG(price)平均值
Math.max(...arr.map(x => x.price))MAX(price)最大值
Math.min(...arr.map(x => x.price))MIN(price)最小值

📌 基础聚合

-- ===== 聚合函数基础 ===== -- 文章总数 SELECT COUNT(*) AS '文章总数' FROM posts; -- 已发布文章数 SELECT COUNT(*) AS '已发布' FROM posts WHERE status = 1; -- 总阅读量、平均阅读量 SELECT SUM(view_count) AS '总阅读量', AVG(view_count) AS '平均阅读量', MAX(view_count) AS '最高阅读量', MIN(view_count) AS '最低阅读量' FROM posts WHERE status = 1;

📊 GROUP BY 分组聚合

-- ===== GROUP BY:按某列分组统计 ===== -- 类比:Object.groupBy(posts, p => p.userId) 然后对每组 reduce -- 每个用户的文章数 SELECT u.username AS '作者', COUNT(p.id) AS '文章数', SUM(p.view_count) AS '总阅读量' FROM users u LEFT JOIN posts p ON u.id = p.user_id GROUP BY u.id, u.username ORDER BY COUNT(p.id) DESC; -- 每月发布文章数(时间维度统计) SELECT DATE_FORMAT(created_at, '%Y-%m') AS '月份', COUNT(*) AS '发布数' FROM posts WHERE status = 1 GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY '月份' DESC;

🔍 HAVING:过滤分组结果

-- ===== HAVING:对分组结果做筛选 ===== -- WHERE 过滤的是原始行,HAVING 过滤的是分组后的结果 -- 类比:先 groupBy,再 filter 分组结果 -- 找出发了 5 篇以上文章的活跃用户 SELECT u.username, COUNT(p.id) AS post_count FROM users u JOIN posts p ON u.id = p.user_id GROUP BY u.id, u.username HAVING COUNT(p.id) >= 5 -- 不能用 WHERE,因为 COUNT 是分组后才有的 ORDER BY post_count DESC; -- SQL 执行顺序(重要!): -- FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT -- 类比前端:获取数据 → filter → groupBy → filter 分组 → map → sort → slice
⚠️ WHERE vs HAVING:WHERE 在分组前过滤原始行(不能用聚合函数),HAVING 在分组后过滤结果(可以用聚合函数)。记住:WHERE 过滤行,HAVING 过滤组。
✏️ 填空:聚合练习
-- 统计每个分类的文章数,只显示文章数大于 3 的分类 SELECT category_id, AS cnt FROM posts category_id cnt > 3;
🧠 小测验:聚合函数

SQL 的执行顺序中,GROUP BY 在哪个步骤之后执行?

📌 标量子查询(返回单值)

-- ===== 标量子查询:返回一个值 ===== -- 类比:const avgViews = posts.reduce(...) / posts.length; -- const hotPosts = posts.filter(p => p.views > avgViews); -- 查询阅读量高于平均值的文章 SELECT title, view_count FROM posts WHERE view_count > ( SELECT AVG(view_count) FROM posts -- 子查询返回一个数字 ) ORDER BY view_count DESC;

📌 列表子查询(返回一列)

-- ===== 列表子查询:配合 IN 使用 ===== -- 类比:const activeUserIds = users.filter(u => u.active).map(u => u.id); -- const posts = allPosts.filter(p => activeUserIds.includes(p.userId)); -- 查询活跃用户的所有文章 SELECT title, created_at FROM posts WHERE user_id IN ( SELECT id FROM users WHERE status = 1 -- 子查询返回一列 ); -- EXISTS 写法(大数据量时更快) SELECT p.title FROM posts p WHERE EXISTS ( SELECT 1 FROM users u WHERE u.id = p.user_id AND u.status = 1 );

🏗️ CTE(WITH 子句)——SQL 里的变量声明

-- ===== CTE:Common Table Expression ===== -- 类比:先 const 声明中间变量,再用这个变量 -- const activeUsers = users.filter(u => u.active); -- const result = activeUsers.map(...); -- 用 CTE 让复杂查询更易读 WITH active_users AS ( -- 第一步:筛选活跃用户(类似声明一个变量) SELECT id, username FROM users WHERE status = 1 ), user_stats AS ( -- 第二步:统计每个活跃用户的文章数 SELECT au.username, COUNT(p.id) AS post_count, COALESCE(SUM(p.view_count), 0) AS total_views FROM active_users au LEFT JOIN posts p ON au.id = p.user_id GROUP BY au.id, au.username ) -- 第三步:使用前面的结果 SELECT * FROM user_stats WHERE post_count > 0 ORDER BY total_views DESC;
💡 CTE vs 子查询选择:CTE 的语义更清晰、可读性更好,推荐在复杂查询中优先使用。性能上两者通常接近;CTE 的优势是可以给中间结果命名并在后续查询中重复引用,而普通子查询往往需要重复书写。
✏️ 填空:子查询练习
-- 用 CTE 查询发文超过 3 篇的用户 prolific AS ( SELECT user_id, COUNT(*) AS cnt FROM posts GROUP BY user_id cnt > 3 ) SELECT u.username, p.cnt FROM users u JOIN prolific p ON u.id = p.user_id;
🧠 小测验:子查询与 CTE

CTE (WITH 子句) 的核心优势是什么?

🔄 RESTful API vs SQL 操作

HTTP 方法SQL 语句前端操作
POST /usersINSERT INTO users ...创建新用户
PUT /users/1UPDATE users SET ... WHERE id=1更新用户信息
DELETE /users/1DELETE FROM users WHERE id=1删除用户
GET /usersSELECT * FROM users获取列表

📌 INSERT 插入数据

-- ===== INSERT 各种写法 ===== -- 单条插入 INSERT INTO users (username, email) VALUES ('alice', 'alice@test.com'); -- 批量插入(比循环单条插入快很多!) -- 类比:一次 fetch 发送数组 vs 循环发 N 次 fetch INSERT INTO users (username, email) VALUES ('bob', 'bob@test.com'), ('carol', 'carol@test.com'), ('dave', 'dave@test.com'); -- 插入时处理重复(UNIQUE 冲突) -- 类比:前端 Map.set() 覆盖已有值 INSERT INTO users (username, email) VALUES ('alice', 'alice-new@test.com') ON DUPLICATE KEY UPDATE email = VALUES(email); -- 获取刚插入的自增 ID INSERT INTO posts (title, content, user_id) VALUES ('第一篇', '内容...', 1); SELECT LAST_INSERT_ID(); -- 返回刚插入的 id

📌 UPDATE 更新数据

-- ===== UPDATE 更新 ===== -- 更新单条记录 UPDATE users SET email = 'newemail@test.com' WHERE id = 1; -- 更新多个字段 UPDATE posts SET title = '新标题', status = 1, updated_at = NOW() WHERE id = 5; -- 基于计算的更新(类似 setState(prev => prev + 1)) UPDATE posts SET view_count = view_count + 1 WHERE id = 5; -- 批量更新(用 CASE WHEN) UPDATE posts SET status = CASE WHEN created_at < '2023-01-01' THEN 0 -- 旧文章设为草稿 ELSE status END WHERE user_id = 1;
⚠️ UPDATE/DELETE 不加 WHERE 会作用于全表!这是最危险的操作。永远记得加 WHERE 条件,建议先用 SELECT 验证要操作的数据范围。

📌 DELETE 与软删除

-- ===== DELETE 删除 ===== -- 物理删除(数据彻底消失) DELETE FROM posts WHERE id = 5; -- ⚠️ 生产环境推荐:软删除(标记删除,数据还在) -- 类比:前端标记为 hidden 而不是 DOM.remove() UPDATE posts SET is_deleted = 1, deleted_at = NOW() WHERE id = 5; -- 查询时过滤已删除的 SELECT * FROM posts WHERE is_deleted = 0; -- 批量删除 DELETE FROM posts WHERE status = 0 AND created_at < '2023-01-01'; -- TRUNCATE:清空整张表(比 DELETE 快,不可回滚!) TRUNCATE TABLE temp_logs;
✏️ 填空:增删改练习
-- 批量插入两条数据 INSERT INTO tags (name) ('JavaScript'), ('MySQL'); -- 将文章阅读量 +1 UPDATE posts SET view_count = WHERE id = 1;
🧠 小测验:增删改

生产环境删除数据的推荐做法是?

📌 EXPLAIN 基本用法

-- ===== 在任意 SELECT 前加 EXPLAIN ===== EXPLAIN SELECT * FROM posts WHERE user_id = 1; -- 输出(关键列): -- +----+------+------+------+------+------+ -- | id | type | key | rows | filtered | Extra | -- +----+------+------+------+------+------+ -- | 1 | ref | idx_user | 5 | 100.00 | NULL | -- +----+------+------+------+------+------+ -- type = ref → 使用了索引(好!) -- key = idx_user → 用的是哪个索引 -- rows = 5 → 预估扫描 5 行(越少越好)

🔍 type 列详解(执行效率排序)

type含义前端类比效率
const主键/唯一索引等值查找map.get(key)🟢 最快
eq_refJOIN 时主键/唯一索引匹配两个 Map 关联查找🟢 快
ref普通索引等值查找index.get(key)🟢 快
range索引范围查找二分查找区间🟡 良好
index全索引扫描遍历 Map 的所有 key🟡 一般
ALL全表扫描array.find() 遍历🔴 最慢

📊 实战:分析慢查询

-- ===== 分析并优化查询 ===== -- ❌ 慢查询:全表扫描(type = ALL) EXPLAIN SELECT * FROM posts WHERE title LIKE '%MySQL%'; -- type=ALL, rows=100000 → 扫描全表! -- ✅ 优化方案 1:如果是前缀搜索,可以走索引 EXPLAIN SELECT * FROM posts WHERE title LIKE 'MySQL%'; -- type=range, rows=50 → 用到索引 -- ❌ 索引失效:对列使用函数 EXPLAIN SELECT * FROM users WHERE YEAR(created_at) = 2024; -- type=ALL → 对列加函数导致索引失效! -- ✅ 优化:改写成范围查询 EXPLAIN SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; -- type=range → 走索引了

📋 速查:索引失效的常见场景

场景原因优化方法
LIKE '%xxx'前缀模糊无法用 B+树改用全文索引或搜索引擎
YEAR(col) = 2024对列使用函数改写为范围查询
col + 1 = 5对列做运算改写为 col = 4
隐式类型转换字符串列用数字查询保持类型一致
OR 连接不同列优化器难以选择索引改用 UNION
✏️ 填空:EXPLAIN 解读
-- 分析查询性能 SELECT * FROM posts WHERE id = 1; -- type 为 表示全表扫描,需要优化
🧠 小测验:EXPLAIN

以下哪个操作会导致索引失效?

🔄 前端类比

概念前端类比MySQL 事务
原子性(A)Redux action 要么全部 dispatch,要么不 dispatch事务内所有操作要么全成功,要么全回滚
一致性(C)state 从一个合法状态到另一个合法状态数据从一个一致状态到另一个一致状态
隔离性(I)不同用户的操作互不干扰并发事务互不影响
持久性(D)localStorage 关闭浏览器不丢失提交后的数据永久保存到磁盘

📌 事务基本语法

-- ===== 事务的基本用法 ===== -- 开始事务 START TRANSACTION; -- 或 BEGIN; -- 执行一系列操作 UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 扣钱 UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 加钱 INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 2, 100); -- 记录 -- 一切顺利?提交!(类似 git commit) COMMIT; -- 出错了?回滚!(类似 git reset) -- ROLLBACK;

🎯 实战:转账场景

-- ===== 安全的转账实现 ===== START TRANSACTION; -- 1. 检查余额是否足够 SELECT balance INTO @sender_balance FROM accounts WHERE user_id = 1 FOR UPDATE; -- 加锁防并发 -- 2. 余额不够就回滚 -- (在应用层用 if 判断,此处演示 SQL 逻辑) -- 如果 @sender_balance < 100,应用层执行 ROLLBACK -- 3. 扣款和加款 UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 4. 记录转账日志 INSERT INTO transfer_log (from_id, to_id, amount, created_at) VALUES (1, 2, 100, NOW()); -- 5. 提交 COMMIT;
💡 MySQL 默认自动提交(autocommit=1)。每条单独的 INSERT/UPDATE/DELETE 都会立即生效。使用 START TRANSACTION 后,直到 COMMIT 才真正提交。这类似于 React 的 batch update——先收集变更,最后一起应用。
✏️ 填空:事务语法
-- 开启事务 ; UPDATE accounts SET balance = balance - 50 WHERE id = 1; UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- 全部成功,提交 ; -- 如果失败,回滚 -- ;
🧠 小测验:事务

ACID 中的 A(原子性)是什么意思?

👻 三种并发问题

👻 脏读

读到了另一个事务未提交的数据。类比:你看到了别人还没发送的消息草稿。

👻 不可重复读

同一事务内两次读取结果不同(被别人改了)。类比:你刷新页面,价格突然变了。

👻 幻读

同一查询两次执行,行数变了(被别人插入/删除了)。类比:你分页查询,数据莫名多出几条。

🔀 四种隔离级别

隔离级别脏读不可重复读幻读性能
READ UNCOMMITTED❌ 有❌ 有❌ 有🟢 最快
READ COMMITTED✅ 无❌ 有❌ 有🟢 快
REPEATABLE READ✅ 无✅ 无⚠️ 部分🟡 适中
SERIALIZABLE✅ 无✅ 无✅ 无🔴 最慢

📌 查看和设置隔离级别

-- ===== 隔离级别操作 ===== -- 查看当前隔离级别 SELECT @@transaction_isolation; -- 默认结果:REPEATABLE-READ(MySQL 的默认级别) -- 设置当前会话的隔离级别 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 设置全局(所有新连接生效) SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

🎯 MVCC:MySQL 的并发秘密武器

-- ===== MVCC(多版本并发控制)===== -- 类比:Git 的版本控制——每个事务看到的是自己的"快照" -- 事务 A(先开始) START TRANSACTION; SELECT * FROM accounts WHERE id = 1; -- balance = 1000 -- ...还在执行中 -- 事务 B(同时) START TRANSACTION; UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT; -- 已提交 -- 事务 A 再次查询(REPEATABLE READ 级别下) SELECT * FROM accounts WHERE id = 1; -- 仍然看到 balance = 1000! -- 因为 MVCC 给事务 A 一个"快照",不受事务 B 影响 COMMIT;
💡 MySQL 默认用 REPEATABLE READ,绝大多数场景够用。只有在要求"每次读到最新数据"的场景(如库存扣减),才考虑 READ COMMITTED 或加锁。
✏️ 填空:隔离级别
-- 查看当前隔离级别 SELECT @@; -- MySQL 默认隔离级别是 --
🧠 小测验:隔离级别

"脏读"是什么意思?

🔄 锁的类型对比

锁类型前端类比MySQL 行为
共享锁(S锁)多人同时阅读同一文档多个事务可同时读,但不能改
排他锁(X锁)有人在编辑文档,其他人等待一个事务在改,其他事务等待
行锁只锁定正在编辑的那一段InnoDB 默认:只锁定操作的行
表锁锁定整个文档,任何人都不能操作MyISAM 默认:锁整张表

📌 显式加锁

-- ===== 显式加锁(在事务中使用)===== START TRANSACTION; -- 共享锁(读锁):允许其他事务读,不允许改 SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE; -- 排他锁(写锁):不允许其他事务读和改 -- 类比:先读取再更新的场景,防止其他人同时操作 SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 拿到锁后安全地修改 UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT; -- 提交后自动释放锁

💀 死锁:两个事务互相等待

-- ===== 死锁示例 ===== -- 类比:两个人互相等对方先让路,谁也走不了 -- 事务 A: START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 锁住 id=1 -- ...等待 id=2 的锁 -- 事务 B(同时): START TRANSACTION; UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- 锁住 id=2 UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 等待 id=1 的锁 → 死锁! -- MySQL 检测到死锁后,会自动回滚其中一个事务 -- ERROR 1213: Deadlock found when trying to get lock -- ===== 避免死锁的方法 ===== -- 1. 按固定顺序加锁(先锁 id 小的,再锁 id 大的) -- 2. 缩小事务范围,尽快 COMMIT -- 3. 用合理的索引减少锁范围
⚠️ 死锁不是 Bug,是正常现象。MySQL 有死锁检测机制,会自动选择一个事务回滚。你的代码需要捕获这个错误并重试。类比前端的网络请求失败重试。

🔧 监控锁和死锁

-- ===== 查看当前锁状态 ===== -- 查看正在运行的事务 SELECT * FROM information_schema.INNODB_TRX; -- 查看锁等待情况 SELECT * FROM performance_schema.data_lock_waits; -- 查看最近一次死锁信息 SHOW ENGINE INNODB STATUS; -- 搜索 "LATEST DETECTED DEADLOCK" 段落 -- 查看锁等待超时设置(默认 50 秒) SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
✏️ 填空:锁操作
-- 对行加排他锁(写锁) SELECT * FROM accounts WHERE id = 1 ; -- 对行加共享锁(读锁) SELECT * FROM accounts WHERE id = 1 ;
🧠 小测验:锁与死锁

如何避免死锁?

🔄 原生 SQL vs ORM 对比

操作原生 SQLSQLAlchemy ORM
定义表CREATE TABLE users (...)class User(Base): ...
插入INSERT INTO users VALUES ...session.add(User(...))
查询SELECT * FROM users WHERE ...session.query(User).filter(...)
更新UPDATE users SET ...user.name = "new"
删除DELETE FROM users WHERE ...session.delete(user)

📌 定义模型(Model)

# ===== SQLAlchemy 模型定义 ===== # 类比:TypeScript 的 interface + 数据库表结构 from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from sqlalchemy.sql import func # 连接数据库(类似前端的 API base URL) engine = create_engine('mysql+pymysql://root:123456@localhost/learn_mysql') Base = declarative_base() Session = sessionmaker(bind=engine) class User(Base): __tablename__ = 'users' id = Column(Integer, primary_key=True, autoincrement=True) username = Column(String(50), nullable=False, unique=True) email = Column(String(255), nullable=False, unique=True) status = Column(Integer, default=1) created_at = Column(DateTime, server_default=func.now())

🔍 CRUD 操作

# ===== SQLAlchemy CRUD ===== session = Session() # 创建(INSERT) new_user = User(username='alice', email='alice@test.com') session.add(new_user) session.commit() # 类似 git commit,不 commit 数据不会写入 # 查询(SELECT) user = session.query(User).filter_by(username='alice').first() all_users = session.query(User).filter(User.status == 1).all() count = session.query(User).count() # 更新(UPDATE) user.email = 'newemail@test.com' session.commit() # ORM 自动检测属性变化,生成 UPDATE # 删除(DELETE) session.delete(user) session.commit()
💡 session.commit() 类似 git commit:所有的 add/修改/delete 操作都在内存中暂存,直到 commit() 才真正写入数据库。出错时可以 session.rollback() 回滚。
✏️ 填空:SQLAlchemy 基础
# 创建新用户 user = User(username='bob', email='bob@test.com') session.(user) session.() # 提交到数据库
🧠 小测验:SQLAlchemy

ORM 相比原生 SQL 的最大优势是什么?

📌 定义关系

# ===== ORM 关系定义 ===== from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime from sqlalchemy.orm import relationship from sqlalchemy.sql import func class User(Base): __tablename__ = 'users' id = Column(Integer, primary_key=True, autoincrement=True) username = Column(String(50), nullable=False) # 定义关系:一个用户有多篇文章 # 类比:TypeScript 中 User { posts: Post[] } posts = relationship('Post', back_populates='author', lazy='select') class Post(Base): __tablename__ = 'posts' id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(200), nullable=False) content = Column(Text) user_id = Column(Integer, ForeignKey('users.id'), nullable=False) # 反向关系:文章属于一个用户 # 类比:Post { author: User } author = relationship('User', back_populates='posts')

🔍 关系查询

# ===== 通过关系访问数据 ===== # 查询用户的所有文章(自动生成 JOIN 或子查询) user = session.query(User).first() for post in user.posts: # 像访问属性一样! print(f"{post.title} - {post.author.username}") # 通过文章访问作者 post = session.query(Post).first() print(post.author.username) # 自动关联查询 # 带条件的关系查询 from sqlalchemy import and_ published = session.query(Post).filter( and_(Post.user_id == 1, Post.status == 1) ).order_by(Post.created_at.desc()).all()

🔗 多对多关系

# ===== 多对多关系(文章 ↔ 标签)===== from sqlalchemy import Table # 关联表(不需要单独的 Model 类) post_tags = Table('post_tags', Base.metadata, Column('post_id', Integer, ForeignKey('posts.id'), primary_key=True), Column('tag_id', Integer, ForeignKey('tags.id'), primary_key=True) ) class Tag(Base): __tablename__ = 'tags' id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(50), nullable=False, unique=True) class Post(Base): __tablename__ = 'posts' # ...其他字段 tags = relationship('Tag', secondary=post_tags, backref='posts') # 使用: post.tags.append(tag) # 给文章打标签 post.tags # 获取文章的所有标签 tag.posts # 获取标签下的所有文章
✏️ 填空:ORM 关系
# 定义外键列 user_id = Column(Integer, , nullable=False) # 定义关系 posts = ('Post', back_populates='author')
🧠 小测验:ORM 关系

SQLAlchemy 中 relationship() 的作用是?

🔄 前端类比:循环请求 vs 批量请求

前端反模式ORM N+1 问题
users.forEach(u => fetch(`/api/posts?userId=${u.id}`))for user in users: user.posts
10 个用户 → 11 次 HTTP 请求10 个用户 → 11 条 SQL 查询
解决:fetch('/api/posts?userIds=1,2,3...')解决:joinedloadsubqueryload

💥 N+1 问题复现

# ===== N+1 问题演示 ===== # ❌ 错误写法:lazy loading 导致 N+1 users = session.query(User).limit(10).all() # 1 条 SQL:SELECT * FROM users for user in users: print(user.posts) # 每次访问 .posts 都会发一条 SQL! # SELECT * FROM posts WHERE user_id = 1 # SELECT * FROM posts WHERE user_id = 2 # ... 共 10 条额外 SQL # 总计:1 + 10 = 11 条 SQL(N+1 问题!) # 如果有 1000 个用户就是 1001 条 SQL

✅ 解决方案:预加载

# ===== 解决 N+1:Eager Loading ===== from sqlalchemy.orm import joinedload, subqueryload # 方案 1:joinedload(用 LEFT JOIN,一条 SQL 搞定) # 类似前端一次性请求所有数据 users = session.query(User).options( joinedload(User.posts) # 告诉 ORM:查用户时顺便把文章也查出来 ).limit(10).all() # 只生成 1 条 SQL: # SELECT users.*, posts.* FROM users LEFT JOIN posts ON ... for user in users: print(user.posts) # 不会再发额外 SQL! # 方案 2:subqueryload(用子查询,两条 SQL) # 适合关联数据量大时(避免 JOIN 产生笛卡尔积) users = session.query(User).options( subqueryload(User.posts) ).limit(10).all() # 生成 2 条 SQL: # 1. SELECT * FROM users LIMIT 10 # 2. SELECT * FROM posts WHERE user_id IN (1,2,3,...10) # 方案 3:selectinload(MySQL 推荐,用 IN 查询) from sqlalchemy.orm import selectinload users = session.query(User).options( selectinload(User.posts) ).limit(10).all()
⚠️ N+1 是面试高频题!当面试官问"ORM 有什么缺点"时,N+1 问题是必答项。记住三个关键词:lazy loading 导致 N+1,用 eager loading(joinedload/subqueryload)解决。

📋 速查:加载策略对比

策略SQL 数量适用场景
lazy='select'(默认)N+1 条极少访问关联数据时
joinedload1 条 JOIN一对一、数据量小的一对多
subqueryload2 条数据量大的一对多
selectinload2 条 IN通用推荐
✏️ 填空:N+1 解决
# 使用预加载避免 N+1 from sqlalchemy.orm import users = session.query(User).options( (User.posts) ).all()
🧠 小测验:N+1 问题

查询 100 个用户及其文章时,N+1 问题会产生多少条 SQL?

🔄 SQLAlchemy vs MyBatis

特性SQLAlchemy(Python)MyBatis(Java)
SQL 生成自动生成 SQL手写 SQL,框架映射
学习曲线ORM 概念多懂 SQL 就能上手
性能控制需要理解生成的 SQL完全掌控每条 SQL
复杂查询复杂查询难以表达复杂 SQL 随意写
前端类比React(声明式)jQuery(命令式)

📌 MyBatis 核心概念

// ===== MyBatis Mapper 接口(Java)===== // 类比:前端的 API service 层 // 1. 定义 Mapper 接口 @Mapper public interface UserMapper { // 注解方式:简单查询直接写 @Select("SELECT * FROM users WHERE id = #{id}") User findById(@Param("id") Integer id); @Insert("INSERT INTO users (username, email) VALUES (#{username}, #{email})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user); @Update("UPDATE users SET email = #{email} WHERE id = #{id}") int updateEmail(@Param("id") Integer id, @Param("email") String email); @Delete("DELETE FROM users WHERE id = #{id}") int deleteById(@Param("id") Integer id); }

📝 XML Mapper(复杂查询)

<!-- ===== MyBatis XML Mapper:复杂查询用 XML ===== --> <mapper namespace="com.example.mapper.PostMapper"> <!-- 动态 SQL:根据条件拼接查询 --> <!-- 类比:前端的条件渲染 {condition && <Component/>} --> <select id="searchPosts" resultType="Post"> SELECT * FROM posts <where> <if test="status != null"> AND status = #{status} </if> <if test="keyword != null"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="userId != null"> AND user_id = #{userId} </if> </where> ORDER BY created_at DESC LIMIT #{offset}, #{limit} </select> </mapper>
💡 MyBatis 的 #{} vs ${} :#{} 使用预编译参数(防 SQL 注入,推荐),${} 直接拼接字符串(有注入风险,仅用于表名/列名等动态 SQL)。类比前端:#{} 是 parameterizedQuery,${} 是 string concatenation
✏️ 填空:MyBatis 注解
// 查询单个用户 @("SELECT * FROM users WHERE id = #{id}") User findById(@Param("id") Integer id); // 插入用户 @("INSERT INTO users (name) VALUES (#{name})") int insert(User user);
🧠 小测验:MyBatis

MyBatis 的 #{} 和 ${} 有什么区别?

🎯 需求分析

📝 核心功能

• 用户注册/登录
• 发布/编辑/删除文章
• 文章分类和标签
• 评论系统
• 文章搜索

📊 典型查询

• 首页文章列表(分页)
• 按分类/标签筛选
• 用户个人文章列表
• 热门文章排行
• 最新评论

📐 完整 ER 图实现

-- ===== 博客系统完整建表 ===== -- 用户表 CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, avatar VARCHAR(500), bio VARCHAR(500), status TINYINT UNSIGNED DEFAULT 1 COMMENT '0=禁用 1=正常', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE INDEX uk_email (email), UNIQUE INDEX uk_username (username) ) COMMENT='用户表'; -- 文章表 CREATE TABLE posts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL, content TEXT NOT NULL, summary VARCHAR(500), cover_image VARCHAR(500), user_id INT UNSIGNED NOT NULL, category_id INT UNSIGNED, status TINYINT DEFAULT 0 COMMENT '0=草稿 1=已发布 2=已下架', is_deleted TINYINT(1) DEFAULT 0, view_count INT UNSIGNED DEFAULT 0, like_count INT UNSIGNED DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME, UNIQUE INDEX uk_slug (slug), INDEX idx_user (user_id), INDEX idx_category (category_id), INDEX idx_status_published (status, published_at), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL ) COMMENT='文章表'; -- 评论表(支持楼中楼) CREATE TABLE comments ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, content TEXT NOT NULL, user_id INT UNSIGNED NOT NULL, post_id INT UNSIGNED NOT NULL, parent_id INT UNSIGNED DEFAULT NULL COMMENT '父评论 ID,NULL=顶级评论', is_deleted TINYINT(1) DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_post (post_id), INDEX idx_parent (parent_id), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE SET NULL ) COMMENT='评论表';

🔍 典型业务查询

-- ===== 博客常见查询 ===== -- 1. 首页文章列表(分页、带作者、带分类) SELECT p.id, p.title, p.summary, p.cover_image, p.view_count, p.like_count, p.published_at, u.username AS author, u.avatar AS author_avatar, c.name AS category FROM posts p JOIN users u ON p.user_id = u.id LEFT JOIN categories c ON p.category_id = c.id WHERE p.status = 1 AND p.is_deleted = 0 ORDER BY p.published_at DESC LIMIT 10 OFFSET 0; -- 2. 热门文章排行(7 天内阅读量最高) SELECT title, view_count, like_count FROM posts WHERE status = 1 AND is_deleted = 0 AND published_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY view_count DESC LIMIT 10; -- 3. 文章详情 + 评论树 SELECT c.id, c.content, c.parent_id, c.created_at, u.username, u.avatar FROM comments c JOIN users u ON c.user_id = u.id WHERE c.post_id = 1 AND c.is_deleted = 0 ORDER BY c.created_at ASC;
✏️ 填空:博客查询
-- 查询某用户发布的文章数 SELECT AS post_count FROM posts WHERE user_id = 1 AND status = 1 AND = 0;
🧠 小测验:博客数据层

评论表的 parent_id 字段用于实现什么功能?

📐 核心表设计

-- ===== 电商核心表 ===== -- 商品表 CREATE TABLE products ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, -- 精确金额! stock INT UNSIGNED DEFAULT 0, -- 库存 category_id INT UNSIGNED, status TINYINT DEFAULT 1 COMMENT '0=下架 1=上架', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_category (category_id), INDEX idx_status_price (status, price) ) COMMENT='商品表'; -- 订单表 CREATE TABLE orders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, -- 订单编号 user_id INT UNSIGNED NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0=待付款 1=已付款 2=已发货 3=已完成 4=已取消', payment_time DATETIME, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE INDEX uk_order_no (order_no), INDEX idx_user_status (user_id, status) ) COMMENT='订单表'; -- 订单商品明细表 CREATE TABLE order_items ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id INT UNSIGNED NOT NULL, product_id INT UNSIGNED NOT NULL, quantity INT UNSIGNED NOT NULL, unit_price DECIMAL(10,2) NOT NULL, -- 下单时的价格快照 INDEX idx_order (order_id), FOREIGN KEY (order_id) REFERENCES orders(id) ) COMMENT='订单明细';

🔐 库存扣减(并发安全)

-- ===== 安全的库存扣减 ===== -- 类比:前端的乐观更新 + 竞态条件处理 START TRANSACTION; -- 方案 1:乐观锁(推荐) -- 用 WHERE 条件防止超卖 UPDATE products SET stock = stock - 1 WHERE id = 100 AND stock >= 1; -- 只有库存够才扣减 -- 检查是否扣减成功(受影响行数) -- 如果返回 0 行受影响 → 库存不足,ROLLBACK -- 创建订单... INSERT INTO orders (...) VALUES (...); COMMIT; -- 方案 2:悲观锁(高并发场景) START TRANSACTION; SELECT stock FROM products WHERE id = 100 FOR UPDATE; -- 先锁住 -- 检查库存 → 扣减 → 创建订单 UPDATE products SET stock = stock - 1 WHERE id = 100; COMMIT;
⚠️ 金额计算必须用 DECIMAL!电商系统中所有金额字段(价格、总价、运费)都必须用 DECIMAL(10,2)。千万不能用 FLOAT/DOUBLE,否则会出现"0.1 + 0.2 ≠ 0.3"的精度问题。
✏️ 填空:库存扣减
-- 安全扣减库存(防超卖) UPDATE products SET stock = stock - 1 WHERE id = 100 AND >= 1;
🧠 小测验:电商数据层

为什么订单明细表要存 unit_price(下单时价格)而不是直接关联商品表的 price?

🔄 前端性能排查 vs MySQL 慢查询排查

前端做法MySQL 做法
Chrome Performance 面板慢查询日志
Network 面板看请求耗时EXPLAIN 分析执行计划
Lighthouse 评分SHOW STATUS 查看全局指标
React ProfilerSHOW PROFILE 分析 SQL 各阶段耗时

📌 开启慢查询日志

-- ===== 慢查询日志配置 ===== -- 查看是否已开启 SHOW VARIABLES LIKE 'slow_query_log'; -- 开启慢查询日志 SET GLOBAL slow_query_log = 'ON'; -- 设置阈值:超过 1 秒的查询记录下来 SET GLOBAL long_query_time = 1; -- 查看慢查询日志文件位置 SHOW VARIABLES LIKE 'slow_query_log_file'; -- 查看最近的慢查询数量 SHOW GLOBAL STATUS LIKE 'Slow_queries';

🔬 排查流程

-- ===== 慢查询排查四步法 ===== -- 第 1 步:找到慢查询 -- 在慢查询日志或监控工具中发现这条 SQL 耗时 3.2 秒 SELECT * FROM posts WHERE title LIKE '%MySQL%' ORDER BY created_at DESC; -- 第 2 步:EXPLAIN 分析 EXPLAIN SELECT * FROM posts WHERE title LIKE '%MySQL%' ORDER BY created_at DESC; -- type=ALL, rows=500000 → 全表扫描 50 万行! -- 第 3 步:定位原因 -- 原因:LIKE '%xxx%' 无法走索引 -- 且 ORDER BY created_at 导致了文件排序(filesort) -- 第 4 步:优化方案 -- 方案 A:如果是前缀搜索,改写 LIKE SELECT * FROM posts WHERE title LIKE 'MySQL%' ORDER BY created_at DESC; -- 方案 B:为搜索场景建全文索引 ALTER TABLE posts ADD FULLTEXT INDEX ft_title (title); SELECT * FROM posts WHERE MATCH(title) AGAINST('MySQL' IN BOOLEAN MODE); -- 方案 C:如果是复杂搜索,考虑 Elasticsearch -- (超出 MySQL 范围,但要知道边界在哪)

📊 常见优化手段

🟢 索引优化

• 为 WHERE 条件列加索引
• 利用联合索引的最左前缀
• 避免在索引列上使用函数
• 用覆盖索引避免回表

🟢 SQL 优化

• 只查需要的列(别用 SELECT *)
• 用 LIMIT 限制返回行数
• 避免大表 JOIN 大表
• 对慢查询评估是否改写为 JOIN / CTE / 更合适的索引方案

✏️ 填空:慢查询排查
-- 开启慢查询日志 SET GLOBAL = 'ON'; -- 分析 SQL 执行计划 SELECT * FROM posts WHERE title LIKE '%test%';
🧠 小测验:慢查询

SELECT * 相比 SELECT id, title 有什么性能问题?

📋 DDL(表结构操作)

建表 / 改表 / 删表

操作SQL
建表CREATE TABLE t (id INT PRIMARY KEY AUTO_INCREMENT, ...)
加列ALTER TABLE t ADD col VARCHAR(100)
改列ALTER TABLE t MODIFY col VARCHAR(200)
删列ALTER TABLE t DROP COLUMN col
加索引CREATE INDEX idx_name ON t(col)
加唯一索引CREATE UNIQUE INDEX uk_name ON t(col)
删索引DROP INDEX idx_name ON t
删表DROP TABLE IF EXISTS t

📋 DQL(查询)

SELECT 完整语法

子句用途示例
SELECT选择列SELECT id, name
FROM数据源FROM users u
JOIN关联表JOIN posts p ON u.id = p.user_id
WHERE行过滤WHERE status = 1
GROUP BY分组GROUP BY user_id
HAVING分组过滤HAVING COUNT(*) > 5
ORDER BY排序ORDER BY created_at DESC
LIMIT分页LIMIT 10 OFFSET 0

📋 DML(增删改)

INSERT / UPDATE / DELETE

操作SQL
单条插入INSERT INTO t (a, b) VALUES (1, 'x')
批量插入INSERT INTO t (a, b) VALUES (1,'x'), (2,'y')
插入或更新INSERT ... ON DUPLICATE KEY UPDATE ...
更新UPDATE t SET a = 1 WHERE id = 1
删除DELETE FROM t WHERE id = 1
软删除UPDATE t SET is_deleted = 1 WHERE id = 1

📋 常用函数

聚合 / 字符串 / 日期 / 条件

类别函数说明
聚合COUNT / SUM / AVG / MAX / MIN计数/求和/平均/最大/最小
字符串CONCAT / SUBSTRING / LENGTH / TRIM拼接/截取/长度/去空格
日期NOW() / CURDATE() / DATE_FORMAT当前时间/日期/格式化
日期DATE_SUB(NOW(), INTERVAL 7 DAY)日期减法
条件IF(cond, true_val, false_val)类似三元运算符
条件COALESCE(a, b, c)返回第一个非 NULL 值
条件CASE WHEN ... THEN ... END类似 switch
分组拼接GROUP_CONCAT(col SEPARATOR ',')把分组内的值拼成字符串

📋 事务与锁

事务控制 / 锁

操作SQL
开始事务START TRANSACTIONBEGIN
提交COMMIT
回滚ROLLBACK
排他锁SELECT ... FOR UPDATE
共享锁SELECT ... LOCK IN SHARE MODE
查看隔离级别SELECT @@transaction_isolation

📋 性能排查

EXPLAIN / 诊断命令

操作SQL
分析执行计划EXPLAIN SELECT ...
查看表索引SHOW INDEX FROM table_name
查看建表语句SHOW CREATE TABLE table_name
查看表状态SHOW TABLE STATUS LIKE 'table_name'
慢查询状态SHOW VARIABLES LIKE 'slow_query%'
当前进程SHOW PROCESSLIST
💡 收藏这个速查页面!日常开发中 80% 的 SQL 需求都在这里了。记不住语法时随时回来查。
📝 进入考试 → 返回总入口