定位:本文档定位 “从入门到精通”**,循序渐进——先带你建表 + 灌入真实数据 + 增删改查完整走一遍(「实战入门」章,标 ★),再逐步深入到数据类型选型、索引、事务锁、性能优化与真实业务 SQL。既覆盖语法,更强调设计取舍、生产陷阱与高性能写法**。即使从零起步,目标也是”能写出可在生产环境长期运行、可维护、高性能的 SQL”。
环境约定:除非特别说明,本文基于 MySQL 8.0(默认存储引擎 InnoDB,默认字符集
utf8mb4)。涉及 5.7 差异时会标注。
学习路线(入门 → 精通,建议按顺序学,先动手再理论):标记 ★ 的章节强烈建议照着敲一遍。
| 章节 | 难度 | 你学到什么 |
| — | — | — |
| 一、定位与架构 | 入门 | MySQL 是什么、存储引擎怎么选 |
| 二、安装连接 | 入门 | 连上数据库、基础元命令 |
| ★ 实战入门:建表 + 真实数据 + CRUD | 入门 | 第一个完整例子(必做) |
| 三、数据类型 | 入门 | 字段怎么选类型(最易踩坑) |
| 四、约束与表设计 | 入门→进阶 | 主键/外键/范式与反范式 |
| 五、DDL | 入门→进阶 | 建表/改表(生产高风险) |
| 六、DML | 入门 | 增删改(配合实战章数据练) |
| 七、DQL 查询 | 入门→进阶 | 查(核心能力) |
| 八、索引 | 进阶 | 性能命脉(重中之重) |
| 九、事务与锁 | 进阶→精通 | 并发正确性 |
| 十、函数 | 入门 | 常用函数 |
| 十一、视图/存储过程 | 进阶 | 现代实践克制使用 |
| 十二、业务实战 | 进阶→精通 | 真实业务 SQL 长什么样 |
| 十三、性能优化 | 精通 | 排障 SOP |
| 十四、字符集 | 入门 | 不乱码的根因 |
| 十五、安全与规范 | 入门 | 防 SQL 注入 |
| 十六、备份恢复 | 进阶 | DBA 底线 |
一、MySQL 定位与整体架构
1.1 MySQL 是什么
MySQL 是关系型、客户端/服务器(C/S)架构的 OLTP(联机事务处理)数据库。所谓”关系型”,指数据以二维表(行/列)组织,表与表之间通过关系(外键/逻辑关联)建模。OLTP 强调高并发、短事务、强一致(与 OLAP 大数据分析相对)。
核心特点:
- 插件式存储引擎:SQL 解析/优化统一,但数据怎么存、怎么加锁由引擎决定(InnoDB / MyISAM / Memory…)。这是 MySQL 区别于 Oracle/PostgreSQL 的关键架构。
- ACID 事务(InnoDB):靠 redo log(持久性)、undo log(原子性/回滚)、锁+MVCC(隔离性)共同实现。
- B+Tree 索引为主:绝大多数查询性能问题,本质都是”索引有没有用对”。
1.2 逻辑架构(面试/排错必懂)
1 | 客户端(JDBC/CLI) |
要点:优化器决定”怎么执行”(选哪个索引、谁做驱动表),它基于统计信息(Cardinality)估算,不是绝对最优——这也是为什么有时要用 FORCE INDEX 或改写 SQL 来引导它。
1.3 存储引擎对比(选型基础)
| 引擎 | 事务 | 锁粒度 | 外键 | 聚簇索引 | 适用场景 |
|---|---|---|---|---|---|
| InnoDB | ✅ | 行锁 + 表锁 | ✅ | ✅ | 默认,99% 业务(订单、账户、用户) |
| MyISAM | ❌ | 表锁 | ❌ | ❌ | 只读/读多写极少、全文检索老方案、统计计数表 |
| Memory | ❌ | 表锁 | ❌ | ❌ | 临时结果、缓存表(重启即丢) |
| Archive | ❌ | 行锁(insert) | ❌ | ❌ | 归档日志、只插不删改 |
结论:新项目无脑 InnoDB。它支持事务、崩溃恢复(redo/undo)、行锁高并发、MVCC 无锁读,几乎全面优于 MyISAM。
1.4 MySQL 5.7 vs 8.0 关键差异
| 特性 | 5.7 | 8.0 |
|---|---|---|
| 默认字符集 | latin1 |
utf8mb4 |
| 窗口函数 | ❌ | ✅(ROW_NUMBER/RANK…) |
| CTE(WITH 子句) | ❌ | ✅ |
| 降序索引 | ❌ | ✅ |
| 隐藏索引(INVISIBLE) | ❌ | ✅(可线上验证删索引是否安全) |
| 原子 DDL | ❌ | ✅(CREATE/DROP 要么全成要么全败) |
| 持久化自增计数器 | ❌(重启可能重用) | ✅ |
CHECK 约束 |
解析但忽略 | ✅ 真正生效 |
| 默认认证插件 | mysql_native_password |
caching_sha2_password |
二、安装、连接与基础命令
1 | -- 连接(命令行) |
1 | -- 库/表/列等元信息查询(比 SHOW 更适合程序化查询) |
实战入门:建库建表 + 真实数据 + CRUD 全流程(★ 入门第一练,建议照着敲一遍)
难度:入门。这一章不堆概念,带你从建库到增删改查完整走一遍,每一步都给出”你敲的 SQL”和”MySQL 实际返回 / 查出来的数据”。强烈建议照着在本地 MySQL 敲一遍——比看十遍都管用。后面所有进阶章节(数据类型、索引、事务…)都会基于这里打下的手感。
第 1 步:建库建表(先有”容器”)
1 | -- 1) 建一个练习库(IF NOT EXISTS 防止重复建库报错) |
说明:
id设了AUTO_INCREMENT+PRIMARY KEY,所以插入时**不用写id**,MySQL 自动从 1 开始递增。DEFAULT ...表示不写该列时自动填的默认值。COMMENT是给字段/表写的”备注”,方便团队协作。
第 2 步:插入数据(INSERT,先有”表数据”)
1 | -- 一次插入多行(推荐,比循环单条 INSERT 快很多) |
插入成功后 MySQL 返回:Query OK, 5 rows affected(表示成功插入 5 行)。
现在表里就有了 5 行真实数据,我们用 SELECT * 看一眼整张表:
1 | SELECT * FROM student; |
返回(这就是你后续所有查询的”原始数据”,请记住它):
| id | name | gender | age | score | city | created_at |
|---|---|---|---|---|---|---|
| 1 | 张三 | M | 18 | 88.50 | 北京 | 2026-07-09 10:00:01 |
| 2 | 李四 | F | 19 | 92.00 | 上海 | 2026-07-09 10:00:01 |
| 3 | 王五 | M | 20 | 75.50 | 北京 | 2026-07-09 10:00:01 |
| 4 | 赵六 | F | 18 | 60.00 | 广州 | 2026-07-09 10:00:01 |
| 5 | 钱七 | M | 21 | 99.00 | 上海 | 2026-07-09 10:00:01 |
小提示:你看到的
created_at时间会和我不同(= 你执行插入那一刻的时间),这是正常的。id从 1 开始、连续递增也是正常的。
第 3 步:查询数据(SELECT,最常用的操作)
查全部 / 查指定列
1 | SELECT * FROM student; -- 查所有列 |
后一条返回:
| name | score | city |
|---|---|---|
| 张三 | 88.50 | 北京 |
| 李四 | 92.00 | 上海 |
| 王五 | 75.50 | 北京 |
| 赵六 | 60.00 | 广州 |
| 钱七 | 99.00 | 上海 |
带条件查询(WHERE)
1 | -- 年龄大于 18 的学生 |
返回:
| name | age |
|---|---|
| 李四 | 19 |
| 王五 | 20 |
| 钱七 | 21 |
1 | -- 城市是北京,且成绩 >= 80 的 |
返回:
| name | score |
|---|---|
| 张三 | 88.50 |
💡 前端视角:
WHERE就像 JS 数组的.filter()条件:arr.filter(s => s.age > 18 && s.city === '北京' && s.score >= 80)
只不过 SQL 是在数据库里过滤,能利用索引,比在代码里遍历快得多。
排序(ORDER BY)
1 | SELECT name, score FROM student ORDER BY score DESC; -- 成绩从高到低 |
返回:
| name | score |
|---|---|
| 钱七 | 99.00 |
| 李四 | 92.00 |
| 张三 | 88.50 |
| 王五 | 75.50 |
| 赵六 | 60.00 |
分组统计(GROUP BY)
1 | -- 每个城市分别有多少人、平均分多少 |
返回:
| city | 人数 | 平均分 |
|---|---|---|
| 北京 | 2 | 82.00 |
| 上海 | 2 | 95.50 |
| 广州 | 1 | 60.00 |
AVG(score)默认返回很多小数位,用ROUND(..., 2)保留两位更直观。
第 4 步:修改数据(UPDATE)
⚠️ 新手最容易犯的错:
UPDATE忘了写WHERE,会把整张表每一行都改掉!动手前先想清楚”到底改哪几行”。
1 | -- 把"王五"的成绩改成 80 分 |
改完立刻查一下,确认改对了:
1 | SELECT name, score FROM student WHERE name = '王五'; |
返回:
| name | score |
|---|---|
| 王五 | 80.00 |
第 5 步:删除数据(DELETE)
⚠️ 同理:
DELETE不写WHERE= 删光整张表(生产环境重大事故!)。
1 | -- 删除"赵六"这条记录 |
删完再看整表(赵六没了,只剩 4 行):
1 | SELECT * FROM student; |
返回:
| id | name | gender | age | score | city | created_at |
|---|---|---|---|---|---|---|
| 1 | 张三 | M | 18 | 88.50 | 北京 | … |
| 2 | 李四 | F | 19 | 92.00 | 上海 | … |
| 3 | 王五 | M | 20 | 80.00 | 北京 | … |
| 5 | 钱七 | M | 21 | 99.00 | 上海 | … |
注意
id = 4这一行不会再出现了——自增id用掉就不会复用,这是正常现象,不用担心。
第 6 步:一句话记住 CRUD
| 操作 | SQL 关键字 | 前端类比 |
|---|---|---|
| 增 Create | INSERT INTO ... VALUES ... |
arr.push(obj) |
| 查 Read | SELECT ... FROM ... WHERE |
arr.filter() / arr.find() |
| 改 Update | UPDATE ... SET ... WHERE |
arr.map(x => x.id===3 ? {...x, score:80} : x) |
| 删 Delete | DELETE FROM ... WHERE |
arr.filter(x => x.id !== 4) |
✅ 到这里,你已经走完了建表 → 灌数据 → 增删改查的完整闭环。接下来的章节会在这个手感之上,讲解:
- 为什么这么设计:数据类型怎么选(§三)、约束与主键(§四);
- 怎么写得更快更对:索引(§八)、查询优化(§十三);
- 并发下怎么不出错:事务与锁(§九);
- 真实业务 SQL 长什么样:§十二 业务实战。
三、数据类型(最易被低估、最影响性能与正确性)
为什么数据类型这么重要? 选错类型不只是”不优雅”,而是真实的生产事故:用
FLOAT存金额,对账时发现少了 1 分钱(浮点误差);用VARCHAR(255)存手机号,索引比CHAR(11)大好几倍,百万级表查询慢 3 倍;用DATETIME存跨国用户时间,结果不同时区用户看到的时间全是错的。下面每种类型都会告诉你:在什么业务场景用、为什么这么选、选错了会怎样。
💡 学习建议:看到一个类型时,先想”我的项目里哪个字段该用它”——把它和你正在开发的真实业务对应起来,而不是死记字节数。
3.1 数值类型
整数(精确,无误差)
| 类型 | 字节 | 有符号范围 | 无符号范围 (UNSIGNED) |
|---|---|---|---|
TINYINT |
1 | -128 ~ 127 | 0 ~ 255 |
SMALLINT |
2 | -32768 ~ 32767 | 0 ~ 65535 |
MEDIUMINT |
3 | -8388608 ~ 8388607 | 0 ~ 16777215 |
INT / INTEGER |
4 | -2147483648 ~ 2147483647 | 0 ~ 4294967295 |
BIGINT |
8 | -2^63 ~ 2^63-1 | 0 ~ 2^64-1 |
- UNSIGNED:非负且范围翻倍(如自增主键、年龄、数量用 UNSIGNED 更省)。
- 显示宽度(如
INT(11)):仅影响 zerofill 显示,不影响存储与取值范围,8.0 已不推荐使用INT(11),直接写INT。 - 自增主键:
INT UNSIGNED AUTO_INCREMENT约 42 亿上限,BIGINT UNSIGNED几乎无限。
💡 真实案例:整数类型怎么选?
1
2
3
4
5
6
7
8 -- 一个电商用户表,每个字段选最合适的整数类型
CREATE TABLE user (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 主键用BIGINT:用户量可能超42亿(INT上限)
age TINYINT UNSIGNED DEFAULT 0, -- 年龄0~255足够,只占1字节
status TINYINT NOT NULL DEFAULT 1, -- 状态:1启用 2禁用 3注销,值很少
login_count INT UNSIGNED DEFAULT 0, -- 登录次数:不会超过42亿
balance DECIMAL(12,2) NOT NULL DEFAULT '0.00' -- ⚠️ 余额不能用整数!用DECIMAL(见下节)
);选型口诀:能确定范围的就选最小类型(
TINYINT < SMALLINT < INT < BIGINT),每少一个级别省 1~4 字节 × 数据量 = 百万级省出几 MB 索引空间。
定点数(金额必须用这个)
| 类型 | 说明 | 精度 |
|---|---|---|
DECIMAL(M,D) |
精确小数,以字符串形式存储 | M 总位数(1 |
NUMERIC |
DECIMAL 的同义词 |
同 |
1 | -- 金额/汇率/比例:永远用 DECIMAL,不要用 FLOAT/DOUBLE |
FLOAT/DOUBLE 是二进制浮点,有误差:
0.1 + 0.2 ≠ 0.3(0.30000000000000004)。金融、计费、对账场景一律DECIMAL。
💡 真实案例:FLOAT vs DECIMAL 的差距
1
2
3
4
5
6
7
8
9
10
11 -- 模拟:用 FLOAT 存金额,结果"丢钱"
CREATE TABLE test_float (amount FLOAT);
INSERT INTO test_float VALUES (0.1), (0.2);
SELECT SUM(amount) FROM test_float;
-- 返回:0.30000001192092896(不是 0.3!对账直接差了零点几分)
-- 改用 DECIMAL:精确到分
CREATE TABLE test_decimal (amount DECIMAL(10,2));
INSERT INTO test_decimal VALUES (0.10), (0.20);
SELECT SUM(amount) FROM test_decimal;
-- 返回:0.30(精确!)使用场景:订单金额、会员余额、佣金、税费、汇率——凡是涉及”钱”的,全部用
DECIMAL。FLOAT/DOUBLE只用在科学计算(如传感器温度、GPS 经纬度等不要求精确到分的场景)。
浮点(仅用于科学/非精确度量)
| 类型 | 字节 | 有效位数 |
|---|---|---|
FLOAT |
4 | ~7 位十进制 |
DOUBLE |
8 | ~15 位十进制 |
位类型
BIT(M):存储位串(0~64 位),适合权限位标记;用b'101'字面量。查询返回二进制,需用BIN()/CAST()转换。
3.2 字符串类型
| 类型 | 说明 | 长度上限 |
|---|---|---|
CHAR(N) |
定长,N 0~255,不足右补空格 | 固定 N 字符 |
VARCHAR(N) |
变长,N 0 |
变长 |
TINYTEXT/TEXT/MEDIUMTEXT/LONGTEXT |
大文本 | 255 / 64KB / 16MB / 4GB |
TINYBLOB/BLOB/… |
二进制大对象 | 同上 |
ENUM('a','b',...) |
枚举,存 1/2/3 整数,省空间 | 最多 65535 个值 |
SET('a','b',...) |
集合,位掩码 | 最多 64 个 |
JSON (8.0) |
JSON 文档,校验格式、可路径查询 | —— |
CHAR vs VARCHAR 选择原则:
CHAR:长度固定且短(如手机号CHAR(11)、UUID 去横杠CHAR(32)、国家码CHAR(2)、MD5CHAR(32)),且对比更快(定长无碎片)。VARCHAR:绝大多数业务字段(用户名、地址、标题),按需分配、省空间。TEXT:不进行、单独存、不能建普通索引但可建前缀索引;大字段建议拆表,避免拖慢主表回表。
💡 真实案例:一个用户表的字符串字段选型
1
2
3
4
5
6
7
8
9
10
11
12 CREATE TABLE user_profile (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
phone CHAR(11) NOT NULL COMMENT '手机号,固定11位', -- ✅ CHAR:定长,索引紧凑
nickname VARCHAR(32) NOT NULL DEFAULT '' COMMENT '昵称', -- ✅ VARCHAR:长度不确定
id_card CHAR(18) DEFAULT NULL COMMENT '身份证号', -- ✅ CHAR:固定18位
email VARCHAR(128) DEFAULT '' COMMENT '邮箱', -- ✅ VARCHAR
bio VARCHAR(500) DEFAULT '' COMMENT '个人简介', -- ✅ VARCHAR:不超过500字
avatar_url VARCHAR(256) DEFAULT '' COMMENT '头像URL', -- ✅ VARCHAR
-- ❌ 错误示范:用 TEXT 存短字段(如 nickname TEXT)→ 每次查询额外IO,且不能直接加索引
-- ❌ 错误示范:用 VARCHAR(255) 存手机号 → 浪费(255-11)*变长头=字节,索引也变大
content TEXT COMMENT '文章正文(很长,单独拆表更好)' -- TEXT 适合不常检索的大文本
);使用场景总结:手机号/身份证/MD5/UUID →
CHAR;姓名/标题/URL →VARCHAR;文章正文/评论详情 →TEXT(但建议拆到副表)。
陷阱:
VARCHAR的 N 是字符数不是字节数。utf8mb4 下每个字符最多 4 字节,所以VARCHAR(255)最多占 255×4+2 ≈ 1022 字节。一个 InnoDB 行所有列总字节数上限 65535,所以一个表不能有太多长VARCHAR。
ENUM/SET 注意:
- 优点:存整数省空间、自带校验。
- 缺点:枚举值修改需
ALTER TABLE(DDL,8.0 原子但仍有成本)、排序按存储序号而非字母序、部分 ORM 不友好。现代实践更推荐用TINYINT+ 代码字典,或VARCHAR+ 应用层校验。
3.3 日期时间类型
| 类型 | 字节 | 范围 | 时区敏感 | 说明 |
|---|---|---|---|---|
DATE |
3 | 1000-01-01 ~ 9999-12-31 | 否 | 仅日期 |
TIME |
3 | -838:59:59 ~ 838:59:59 | 否 | 可表示时长 |
DATETIME |
8 (5.6+) | 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 | 否 | 与时区无关,原样存原样取 |
TIMESTAMP |
4 | 1970-01-01 00:00:01 ~ 2038-01-19 03:14:07 (UTC) | 是 | 存UTC,按会话时区转换;自动更新 |
YEAR |
1 | 1901 ~ 2155 | 否 | 年份 |
DATETIME vs TIMESTAMP 选型:
- 需要跨时区一致(如全球用户活动时间、审计时间)→ 用
TIMESTAMP(随 sessiontime_zone转换,存 UTC)。 - 需要大范围/不随时区漂移(如生日、历史归档、未来超过 2038 的时间)→ 用
DATETIME。 - 2038 年问题:
TIMESTAMP上限 2038-01-19,长期系统慎用。
1 | -- 8.0 支持小数秒(最多 6 位,即微秒) |
💡 真实案例:DATETIME vs TIMESTAMP 选型场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 -- 场景1:国内单机房项目(时区固定),用DATETIME即可,范围大且不怕2038
CREATE TABLE article (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(128) NOT NULL,
publish_time DATETIME NOT NULL COMMENT '发布时间(不跟时区走)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 场景2:全球化SaaS,用户遍布各时区 → 用TIMESTAMP
-- 存UTC,读取时MySQL按当前session时区自动转换(美国用户看到美国时间,中国用户看到北京时间)
CREATE TABLE global_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_name VARCHAR(64) NOT NULL,
event_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '自动按时区转换'
);
-- 场景3:用户生日 → DATE(不需要时分秒、不跟时区走)
ALTER TABLE user ADD COLUMN birthday DATE DEFAULT NULL COMMENT '生日';选型口诀:全球化跨时区 →
TIMESTAMP(但注意 2038 年问题!);国内项目/不需时区转换 →DATETIME;只要日期不要时间 →DATE。
3.4 类型选择总原则(资深经验)
- 越小越好:能用
TINYINT别用INT,能用INT别用BIGINT(索引更小、内存更少、缓存更多行)。 - 越简单越好:整型比字符串快;能用整型表示(如状态码)就不用字符串。
- 避免 NULL 列:
NULL使索引/统计/比较更复杂(需要额外位标记);可用NOT NULL+ 默认值替代(如字符串''、数值0)。主键必须NOT NULL。 - **IP 地址用
INT UNSIGNED**(而非VARCHAR(15)):INET_ATON('127.0.0.1')↔INET_NTOA(ip),省 75% 空间且可范围查询。 - JSON(8.0):适合结构不稳定、无需频繁按内部字段聚合的属性(如商品扩展属性);但频繁查询/过滤的字段应独立成列 + 索引,别全塞 JSON。
四、约束、主键与表设计
4.1 六种约束
| 约束 | 关键字 | 作用 |
|---|---|---|
| 主键 | PRIMARY KEY |
唯一标识一行,非空且唯一,InnoDB 用它组织数据(聚簇索引) |
| 非空 | NOT NULL |
列不可为空 |
| 唯一 | UNIQUE [KEY] |
值唯一(允许 NULL,多个 NULL 不冲突) |
| 默认值 | DEFAULT |
插入未指定时填充 |
| 外键 | FOREIGN KEY ... REFERENCES |
参照完整性(子表值须存在于父表) |
| 检查 | CHECK (expr) |
8.0 真正生效,如 CHECK (age >= 0) |
1 | CREATE TABLE user ( |
4.2 主键设计(重要)
- **自增
BIGINT**:最经典,插入顺序追加、聚簇索引无碎片、性能好。缺点:分库分表时易冲突、可被遍历猜测 ID。 - UUID:全局唯一,但无序 → 聚簇索引频繁页分裂、写入性能差;若用请用
CHAR(32)去横杠或 8.0UUID_TO_BIN(uuid, 1)(交换时间低位,提升有序性)。 - 雪花 ID(Snowflake):64 位趋势递增、分布式唯一,兼具自增的有序性与分布式的唯一性,生产首选。
- 业务联合主键(如订单明细
(order_id, item_id)):仅在确属自然键且无性能顾虑时使用,否则用代理主键 + 唯一索引。
外键的取舍:外键保证一致性但带来写锁耦合、跨表级联成本、分库分表不可用。互联网高并发系统多在应用层维护参照完整性,而非数据库外键。
💡 真实案例:外键用不用?两种做法对比
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 -- 做法1:用外键(适合:数据一致性要求极高的内部管理系统、不需要分库分表的传统项目)
CREATE TABLE order_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
FOREIGN KEY (order_id) REFERENCES `order`(id) ON DELETE CASCADE
-- CASCADE=删订单自动删明细;但代价:每次INSERT都要检查父表是否存在、加锁
);
-- 做法2:不用外键,应用层保证(适合:高并发互联网项目、微服务、分库分表)
CREATE TABLE order_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL, -- 只建索引,不建外键
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
KEY idx_order (order_id) -- 查询用
);
-- 一致性由代码保证:删订单时,代码里同时删明细(或软删除)使用场景:银行/ERP/政务等对数据一致性”零容忍”的系统 → 考虑外键;电商/社交/内容平台等高并发互联网系统 → 不用外键,靠代码+索引+定时数据校验保证一致性。
4.3 范式与反范式(冗余设计)
- 三范式:消除冗余、避免更新异常(适合写多、一致性要求高的核心表)。
- 反范式/冗余列:把频繁 JOIN 的字段冗余到主表(如订单表冗余
user_name),以空间换 JOIN 性能(适合读多、报表、宽表)。 - 实践:核心交易表遵循范式;统计/展示宽表适度冗余。本质是读写比的权衡。
💡 真实案例:范式 vs 反范式——订单表要不要冗余用户名?
1
2
3
4
5
6
7
8
9
10
11
12
13 -- 范式做法(不冗余):每次查订单列表都要 JOIN 用户表拿 username
SELECT o.order_no, o.amount, u.username
FROM `order` o JOIN user u ON o.user_id = u.id
WHERE o.status = 1 ORDER BY o.created_at DESC LIMIT 20;
-- 问题:订单表 500 万行、用户表 100 万行,高峰期 JOIN 很慢
-- 反范式做法(冗余 user_name 到订单表):
ALTER TABLE `order` ADD COLUMN user_name VARCHAR(64) DEFAULT '' COMMENT '冗余用户名';
-- 插入订单时同时写入 user_name
-- 查询时不再需要 JOIN:
SELECT order_no, amount, user_name FROM `order`
WHERE status = 1 ORDER BY created_at DESC LIMIT 20;
-- 代价:用户改名时需要同步更新订单表的 user_name(写成本增加)使用场景:
- 遵守范式:核心交易表(用户表、账户余额表)—— 数据不重复,改一处即全局生效。
- 适度冗余:订单列表页、商品详情页等”读多写少、经常需要关联展示”的场景 —— 用冗余字段省掉 JOIN,接受”改名时多更新一张表”的写代价。
- 经验法则:如果一个 JOIN 在高频查询中反复出现,且被 JOIN 的字段几乎不变(如用户名、商品名),就值得冗余。
五、DDL(数据定义语言)
5.1 建库
1 | CREATE DATABASE IF NOT EXISTS shop |
字符集铁律:全程
utf8mb4(真正的 4 字节 UTF-8,支持 emoji 与所有中文)。MySQL 的utf8是伪 UTF-8(仅 3 字节),存不了 emoji 和部分生僻字。排序规则_ci大小写不敏感、_bin二进制精确(需区分大小写如验证码/令牌用_bin)。
5.2 建表(完整要素)
1 | CREATE TABLE `order` ( |
5.3 改表(ALTER,生产高风险)
1 | -- 加列(8.0 多数场景可 INSTANT,秒级;5.7 可能锁表重建) |
生产铁律:大表 DDL 绝不可直接在业务高峰
ALTER!用 pt-online-schema-change(Percona) 或 gh-ost(GitHub) 做在线无锁变更;或 8.0 的ALGORITHM=INSTANT(仅支持加列/删列等有限操作)。变更前务必在影子库验证并评估锁时长。
5.4 TRUNCATE vs DELETE
| 操作 | 是否回滚 | 是否触发触发器 | 速度 | 自增计数器 |
|---|---|---|---|---|
DELETE FROM t |
✅(事务内) | ✅ | 逐行慢 | 不变 |
TRUNCATE TABLE t |
❌(DDL,隐式提交) | ❌ | 秒级(重建表) | 重置为初始值 |
1 | DELETE FROM log WHERE created_at < '2025-01-01'; -- 大批量删除要分批+LIMIT,避免长事务/锁表 |
六、DML(数据操纵语言)
💡 配合练习:下面每条
INSERT/UPDATE/DELETE都建议结合「实战入门」章的student表亲手敲一遍——先SELECT看改之前的数据,执行后再SELECT看改之后的结果,对比着学印象最深。
6.1 INSERT
1 | -- 单行 |
** VALUES() 引用**:在
ON DUPLICATE KEY UPDATE中用VALUES(col)引用本次待插入的值,避免重复写字面量。
💡 真实案例:ON DUPLICATE KEY UPDATE 的 3 大使用场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 -- 场景1:用户每日签到(今天签过就不再插入,只更新签到次数)
-- sign_record 表有 UNIQUE KEY uk_user_date (user_id, sign_date)
INSERT INTO sign_record (user_id, sign_date, count)
VALUES (123, '2026-07-09', 1)
ON DUPLICATE KEY UPDATE count = count + 1;
-- 第一次签到:插入新行;重复签到:count+1(幂等,不报错)
-- 场景2:第三方数据同步(有则更新、无则新增)
-- 同步微信用户信息到本地表,openid 是唯一键
INSERT INTO wx_user (openid, nickname, avatar, updated_at)
VALUES ('oXyz123', '张三', 'https://...', NOW())
ON DUPLICATE KEY UPDATE nickname=VALUES(nickname), avatar=VALUES(avatar), updated_at=NOW();
-- 场景3:实时统计计数表(每次请求累加)
INSERT INTO api_stat (api_path, call_date, count)
VALUES ('/user/info', CURDATE(), 1)
ON DUPLICATE KEY UPDATE count = count + 1;一句话:凡是”存在就更新、不存在就新增”的需求,就用
ON DUPLICATE KEY UPDATE(需要有唯一键/主键冲突触发)。
6.2 UPDATE
1 | UPDATE user SET age = 21, updated_at = NOW() WHERE id = 1; |
UPDATE 铁律:必须带 WHERE 且 WHERE 命中索引;写前先
SELECT确认影响行数;大批量更新分批(LIMIT)+ 低峰执行,避免长事务锁表拖垮主库。
6.3 DELETE
1 | DELETE FROM user WHERE id = 1; |
删全表用
TRUNCATE(更快且重置自增);带外键约束时删除顺序要注意(先删子表再删父表,或设ON DELETE CASCADE)。生产禁止无 WHERE 的 DELETE。
6.4 REPLACE
REPLACE INTO = 若存在主键/唯一冲突则先删后插(DELETE+INSERT,自增主键会变化、触发器会被触发两次)。多数场景不如 INSERT ... ON DUPLICATE KEY UPDATE 可控,谨慎使用。
七、DQL 查询(核心能力)
💡 配合练习:本章所有查询示例都可套用到「实战入门」章的
student表(5 行真实数据)。建议把 SQL 复制到本地 MySQL 跑一下,对照返回结果理解每个子句的作用。
7.1 SELECT 完整语法与执行顺序
1 | SELECT [DISTINCT] 列/聚合 |
SQL 逻辑执行顺序(理解优化关键):
1 | FROM → JOIN/ON → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT |
这意味着:
WHERE在分组前过滤(不能用聚合函数),HAVING在分组后过滤(可用COUNT/SUM等)。SELECT里起的别名不能在 WHERE 用,但可在ORDER BY/HAVING用(MySQL 扩展允许)。
7.2 WHERE 条件与 NULL 陷阱
1 | SELECT * FROM user WHERE age >= 18 AND city = 'SZ'; |
NULL 三大铁律:
NULL不等于任何值,包括它自己:NULL = NULL结果为NULL(非 true)。判断空用 **IS NULL/IS NOT NULL**。WHERE col != 'x'不会返回 col 为 NULL 的行(NULL 参与比较结果未知,被当作 false 过滤掉)——这是最隐蔽的漏数据 bug。NOT IN (子查询)若子查询含NULL,整个结果可能为 NULL(返回空集)。用NOT EXISTS替代更安全。
7.3 LIKE 模糊查询
1 | SELECT * FROM user WHERE username LIKE 'tom%'; -- 前缀匹配,可用索引 ✅ |
前缀
%在最左才走索引;前后都有%必全表扫描。需要”包含/后缀”检索请用全文索引(FULLTEXT)或 ES。
7.4 排序 ORDER BY
1 | SELECT * FROM `order` ORDER BY created_at DESC; -- 单字段 |
- 排序字段有索引且顺序一致 → 索引有序,免排序(Extra 无 Using filesort)。
- 无索引或
ORDER BY与索引顺序不符 → Using filesort(内存/磁盘排序,大数据量慢)。LIMIT常配合ORDER BY主键做深度分页优化(见 §12.1)。
7.5 分组聚合 GROUP BY + HAVING
1 | -- 各城市用户数 |
COUNT 三兄弟(高频考点):
| 写法 | 含义 | 是否计 NULL 行 | InnoDB 实现 |
|---|---|---|---|
COUNT(*) |
行数 | 计(NULL 整行也算) | 全表/索引扫描计数 |
COUNT(1) |
行数 | 计 | 等价于 COUNT(*) |
COUNT(col) |
col 非 NULL 的行数 | 不计 col 为 NULL 的行 | 扫描 col |
结论:
COUNT(*)与COUNT(1)在 InnoDB 性能基本无差,语义最清晰,**统计行数用COUNT(*)**。COUNT(col)是”非空计数”,语义不同不要混用。MyISAM 的COUNT(*)有缓存总数(无 WHERE 时 O(1)),InnoDB 无,需扫表或靠二级索引。
7.6 多表 JOIN
1 | -- INNER JOIN:两表都匹配的行 |
JOIN 核心认知(资深必懂):
- 驱动表:MySQL 用嵌套循环(Nested-Loop):先取驱动表(外表)的行,再用关联列去被驱动表查。
- 优化器选驱动表依据预估行数,通常小表驱动大表更优;确保被驱动表的关联列有索引(否则退化成”全表 × 全表”灾难)。
- ON vs WHERE:
INNER JOIN中ON与WHERE过滤效果等价(优化器会下推)。LEFT JOIN中把右表过滤条件放ON**(保留左表所有行)还是WHERE(过滤后只剩匹配行,等价于 INNER)语义完全不同**,务必分清。
💡 真实案例:JOIN 在什么业务场景下用?(4 种典型)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 -- 场景1:订单详情页(JOIN 拿用户名 + 商品名)
SELECT o.order_no, o.amount, u.username, p.product_name
FROM `order` o
JOIN user u ON o.user_id = u.id
JOIN order_item oi ON oi.order_id = o.id
JOIN product p ON oi.product_id = p.id
WHERE o.id = 100;
-- 场景2:统计每个用户的订单总金额(LEFT JOIN 保留没下过单的用户)
SELECT u.username, COALESCE(SUM(o.amount), 0) AS total
FROM user u
LEFT JOIN `order` o ON o.user_id = u.id
GROUP BY u.id, u.username;
-- LEFT JOIN 确保没下过单的用户也显示出来(total=0),INNER JOIN 会丢掉他们
-- 场景3:查"从未下过单的用户"(LEFT JOIN + IS NULL)
SELECT u.id, u.username FROM user u
LEFT JOIN `order` o ON o.user_id = u.id
WHERE o.id IS NULL;
-- 使用场景:运营想找"注册了但从未消费的用户"做促活推送
-- 场景4:关联更新(把用户最新手机号同步到订单表的冗余字段)
UPDATE `order` o
JOIN user u ON o.user_id = u.id
SET o.user_phone = u.phone
WHERE o.user_phone = '' OR o.user_phone IS NULL;
1 | -- 经典:查"从未下过单的用户"(LEFT JOIN + IS NULL 反模式,改用 NOT EXISTS 更优) |
7.7 子查询
1 | -- IN 子查询(8.0 优化为 Semi-Join,通常没问题) |
IN vs EXISTS 经验:
- 子查询结果小、外表大 →
IN(MySQL 8.0 自动转 semi-join,效率高)。- 子查询大、外表小且关联列有索引 →
EXISTS。NOT IN遇 NULL 返回空集的坑(见 §7.2),”不存在”语义一律用NOT EXISTS或LEFT JOIN ... IS NULL。
7.8 UNION / UNION ALL
1 | SELECT id, name FROM a |
7.9 窗口函数(8.0,分析利器)
窗口函数不折叠行,在”行间”做计算(排名、累计、同比),是报表 SQL 的核心。
1 | -- 基本语法 |
排名三兄弟:
1 | SELECT |
聚合窗口(累计/占比/移动平均):
1 | SELECT |
偏移(同比/环比/前后比较):
1 | SELECT |
每组的 TopN(经典:每用户最新一笔订单):
1 | SELECT * FROM ( |
7.10 CTE(WITH,8.0,可读性 + 递归)
1 | WITH city_stat AS ( |
八、索引(性能命脉,重中之重)
一句话:大多数 MySQL 性能问题 = 索引没建对 / 没用上。资深工程师的核心竞争力之一,就是”看一眼 SQL 和表结构,能预判它会不会慢、慢在哪”。
8.1 为什么索引快(B+Tree)
InnoDB 默认索引是 B+Tree(多路平衡树):
- 数据按主键顺序存在叶子节点(聚簇索引,Clustered Index),叶子间用双向链表串联 → 范围查询/排序极快。
- 树高通常 3~4 层即可存千万
亿级数据,一次查询只需 34 次磁盘 IO。 - 二级索引(Secondary)叶子存
(索引列, 主键值),查到主键后再回表取整行(这就是”回表”开销)。
1 | 聚簇索引(主键): [叶子节点 = 完整数据行,按主键排序] |
💡 真实案例:没有索引 vs 有索引——百万数据查询对比
1
2
3
4
5
6
7
8
9
10 -- 假设订单表有 100 万行,user_id 上没有索引
EXPLAIN SELECT * FROM `order` WHERE user_id = 12345;
-- 结果:type=ALL, rows=1000000(全表扫描100万行!耗时约2秒)
-- 给 user_id 加索引
ALTER TABLE `order` ADD INDEX idx_user_id (user_id);
-- 再看执行计划
EXPLAIN SELECT * FROM `order` WHERE user_id = 12345;
-- 结果:type=ref, key=idx_user_id, rows=15(只扫描15行!耗时<1ms)使用场景:只要你的
WHERE/JOIN ON/ORDER BY用到了某个列,且表数据超过几千行,就应该考虑给它建索引。不建索引 = 每次查询都要扫全表(想象从一本 1000 页的书里找一个词,没有目录只能一页页翻)。
8.2 索引类型
| 类型 | 说明 | 关键字 |
|---|---|---|
| 普通索引 | 无唯一约束 | INDEX / KEY |
| 唯一索引 | 值唯一(允许 NULL) | UNIQUE KEY |
| 主键索引 | 特殊的唯一非空聚簇索引 | PRIMARY KEY |
| 组合索引 | 多列联合 | INDEX idx(a,b,c) |
| 前缀索引 | 对长字符串前 N 字符建索引 | INDEX idx(col(20)) |
| 全文索引 | 分词检索 | FULLTEXT |
| 空间索引 | 地理 | SPATIAL |
| 覆盖索引 | 查询列全在索引中,无需回表 | 查询利用组合索引 |
8.3 创建/查看/删除
1 | CREATE INDEX idx_user_created ON `order` (user_id, created_at); |
8.4 最左前缀原则(组合索引的灵魂)
组合索引 (a, b, c) 相当于建立了 (a)、(a,b)、(a,b,c) 三个有效前缀。查询必须从最左列开始且连续才能用上索引:
| 查询条件 | 是否用索引 | 说明 |
|---|---|---|
WHERE a=1 |
✅ (a) |
用最左列 |
WHERE a=1 AND b=2 |
✅ (a,b) |
连续前缀 |
WHERE a=1 AND b=2 AND c=3 |
✅ (a,b,c) |
全用 |
WHERE b=2 |
❌ | 跳过最左列 a |
WHERE a=1 AND c=3 |
✅ 仅 (a) |
a 用索引,c 在索引内过滤但非前缀 |
WHERE a>1 AND b=2 |
✅ 仅 a |
范围查询(a)后的列(b)失效 |
组合索引列顺序设计原则:区分度高的列放前面(如
user_id放前,status放后),且把最常用于等值过滤的列放最左。
💡 真实案例:组合索引的最左前缀——“查某用户某时间段的订单”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 -- 业务需求:后台管理页面查"某用户最近30天的订单列表,按时间倒序"
-- SQL:
SELECT * FROM `order`
WHERE user_id = 12345 AND created_at >= '2026-06-09'
ORDER BY created_at DESC;
-- 正确索引:(user_id, created_at)
ALTER TABLE `order` ADD INDEX idx_user_created (user_id, created_at);
-- EXPLAIN: type=range, key=idx_user_created, Extra无filesort ✅
-- 解释:先按user_id等值定位(最左),再在这批数据内按created_at范围扫描+排序(索引本身有序)
-- ❌ 错误索引顺序:(created_at, user_id)
-- 如果查询是 WHERE user_id=12345(跳过了最左列created_at),这个索引完全用不上!
-- ❌ 单独给 created_at 建索引?
-- 能用,但要扫描30天内所有用户的订单再过滤user_id,效率远不如组合索引使用场景:只要你的查询是”A=某值 AND B 范围/排序”的模式,就建
(A, B)组合索引。常见于:查某用户的订单/消息/日志(按时间排序)、查某商户的交易(按金额/时间排序)。
8.5 索引失效(必背场景)
以下情况会导致优化器放弃索引、全表扫描:
- 对索引列用函数/运算:
WHERE DATE(created_at)='2026-07-09'(应改为范围>= AND <);WHERE amount*1.1 > 100。 - 隐式类型转换:列是
VARCHAR,却WHERE phone = 13800000000(传数字)→ MySQL 对列做CAST,索引失效。务必类型匹配。 - 前导
%模糊:LIKE '%abc'。 - OR 连接非索引列:
WHERE a=1 OR b=2(b 无索引则全表)。可改为UNION或给 b 加索引。 - 不等于 / NOT IN / IS NOT NULL(通常):选择性差时优化器认为全表更划算。
- 优化器认为全表更快:当命中数据超过约 20%~30% 时,优化器往往选全表扫描(回表成本高)。此时覆盖索引或
FORCE INDEX可引导。
💡 真实案例:索引失效的”坑”——函数包裹索引列
1
2
3
4
5
6
7
8
9
10 -- 需求:查今天注册的用户
-- ❌ 错误写法(索引失效!DATE() 函数包裹了 created_at 列)
SELECT * FROM user WHERE DATE(created_at) = '2026-07-09';
-- EXPLAIN: type=ALL(全表扫描),即使 created_at 有索引也用不上
-- ✅ 正确写法(范围查询,索引生效)
SELECT * FROM user
WHERE created_at >= '2026-07-09 00:00:00'
AND created_at < '2026-07-10 00:00:00';
-- EXPLAIN: type=range, key=idx_created ✅记忆口诀:索引列上不能”加工”(函数、运算、类型转换),否则索引失效。查日期段永远用
>= AND <,不用DATE()/YEAR()包裹。
8.6 覆盖索引(性能优化杀手锏)
“覆盖索引”= 查询所需的所有列都包含在某个索引中,无需回表。
1 | -- idx_user_created = (user_id, created_at) |
实战:高频只读查询可建宽组合索引覆盖常用列(以空间换免回表的性能);
SELECT *几乎不可能被覆盖,尽量只查需要的列。
8.7 解读执行计划 EXPLAIN(排错核心)
1 | EXPLAIN SELECT * FROM `order` WHERE user_id = 123 ORDER BY created_at DESC; |
| 字段 | 含义 / 关注点 |
|---|---|
id |
执行顺序(大→先,相同则自上而下) |
select_type |
SIMPLE / PRIMARY / SUBQUERY / DERIVED 等 |
type |
访问类型,越靠前越好:system > const > eq_ref > ref > range > index > ALL。**ALL=全表扫描(需警惕)** |
possible_keys |
可能用的索引 |
key |
实际用的索引(NULL=没用上,要查为什么) |
key_len |
索引用到的字节数(判断组合索引用了几列) |
rows |
预估扫描行数(越小越好) |
Extra |
关键提示:Using index(覆盖) / Using where(回表后过滤) / Using index condition(ICP) / Using filesort(需排序,慢) / Using temporary(用临时表,慢) |
排错套路:
type=ALL或rows巨大或Extra出现Using filesort/Using temporary→ 基本就是慢查询,回去检查 WHERE/JOIN/ORDER BY 是否命中索引。
8.8 索引设计 checklist(资深经验)
- ✅ WHERE / JOIN ON / ORDER BY / GROUP BY 用到的列优先建索引。
- ✅ 高基数(Cardinality)列放组合索引前面。
- ✅ 用覆盖索引消除回表;少
SELECT *。 - ✅ 长字符串用前缀索引
col(20),并权衡前缀长度与选择性。 - ❌ 不过度索引:每个索引拖慢写入、占空间、增加优化器选择成本。写多读少的表索引从简。
- ❌ 不在低基数列(如
gender只有 2 值)单独建索引(选择性差,优化器多半不用)。 - ❌ 不建”永远用不上”的索引(用
sys.schema_unused_indexes/ 8.0 隐藏索引验证后清理)。
九、事务与锁(并发正确性)
9.1 ACID
- A 原子性:事务内操作要么全成功要么全失败(undo log 支持回滚)。
- C 一致性:数据从一个一致状态到另一个(业务约束由应用+DB 共同保证)。
- I 隔离性:并发事务互不干扰(靠锁 + MVCC)。
- D 持久性:提交后数据永久保存(redo log + 刷盘)。
9.2 事务控制
1 | BEGIN; -- 或 START TRANSACTION |
事务内注意:
DDL(CREATE/ALTER/DROP/TRUNCATE)会隐式提交当前事务;事务中混 DDL 是常见坑。
💡 真实案例1:转账——事务保证”钱不会凭空消失”
1
2
3
4
5
6
7
8
9
10
11
12
13
14 -- 场景:用户A给用户B转100元。必须"扣A + 加B"同时成功,否则全部回滚
BEGIN;
-- 1) 先查A余额(加排他锁防并发,确保读到的余额是最新的)
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- 假设返回 balance=500,够转
-- 2) 扣A的钱
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 3) 加B的钱
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 两步都成功,一起持久化
-- 如果中间任何一步报错,ROLLBACK → A的钱不会少、B的钱不会多为什么需要事务:如果不开事务,扣了A的钱后系统崩溃,B的钱没加上 → A少了100、B没收到 = 钱”消失”了。事务保证要么全成功、要么全失败(原子性)。
💡 真实案例2:秒杀扣库存——防超卖
1
2
3
4
5
6
7
8
9
10
11
12 -- 场景:商品库存100件,1000人同时抢。不加锁 → 可能卖出200件(超卖!)
BEGIN;
-- 用 FOR UPDATE 加排他锁:同一时刻只有一个事务能锁住这行
SELECT stock FROM product WHERE id = 1001 FOR UPDATE;
-- 假设 stock=1(还剩1件)
-- 判断库存够不够(在代码里判断)
-- 如果 stock > 0:
UPDATE product SET stock = stock - 1 WHERE id = 1001;
INSERT INTO order_item (product_id, user_id) VALUES (1001, 999);
COMMIT;
-- 如果 stock <= 0:ROLLBACK(已卖完,不扣)更高效的写法(乐观锁/CAS):
1
2
3 -- 不需要 FOR UPDATE,靠 WHERE stock > 0 保证不会扣到负数
UPDATE product SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
-- affected_rows = 1 → 扣成功;= 0 → 库存不足,不创建订单使用场景:任何”先查后改”的并发场景(转账、扣库存、抢红包、抢优惠券),都需要事务+锁来防止数据不一致。
9.3 四种隔离级别与并发异常
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED(读未提交) | ❌可能 | ❌可能 | ❌可能 | 能看到别人未提交的数据,几乎不用 |
| READ COMMITTED(读已提交) | ✅避免 | ❌可能 | ❌可能 | Oracle 默认,每次读最新已提交 |
| REPEATABLE READ(可重复读) | ✅ | ✅ | ✅(靠间隙锁) | MySQL InnoDB 默认 |
| SERIALIZABLE(串行化) | ✅ | ✅ | ✅ | 加读锁,性能差,并发低 |
- 脏读:读到别的事务未提交的数据(可能回滚)。
- 不可重复读:同一事务内两次读同一行,结果不同(被别的事务UPDATE了)。
- 幻读:同一事务内两次范围查询,行数不同(被别的事务 INSERT/DELETE 了)。
InnoDB 如何解决可重复读 + 幻读:靠 MVCC(多版本并发控制) 让普通读(快照读)看到事务开始时的快照(解决不可重复读);靠 Next-Key Lock(记录锁 + 间隙锁) 在范围查询时锁住间隙,防止别的事务插入(解决幻读)。所以 InnoDB 在 RR 下不会发生幻读。
9.4 锁机制(InnoDB)
| 锁 | 说明 |
|---|---|
共享锁 S(LOCK IN SHARE MODE / FOR SHARE) |
读锁,多个事务可同时持有,互斥写 |
排他锁 X(FOR UPDATE / UPDATE/DELETE/INSERT) |
写锁,独占 |
| 意向锁 IS/IX | 表级,表明”事务将要在某些行加 S/X 锁”,避免行锁与表锁冲突 |
| 记录锁 Record Lock | 锁单行 |
| 间隙锁 Gap Lock | 锁索引记录间的”间隙”,防插入 |
| Next-Key Lock | 记录锁 + 间隙锁(RR 默认,解决幻读) |
1 | -- 当前读(加锁读),常用于"先查后改"防并发覆盖 |
死锁:两个事务互相等待对方持有的锁。InnoDB 会检测并回滚其中一个(代价小者)。避免方法:事务尽量短、按固定顺序访问多张表/多行、降低隔离级别/锁粒度、及时提交。
💡 真实案例:死锁是怎么发生的?
1
2
3
4
5
6
7
8
9
10
11
12
13
14 -- 事务A:先锁用户1、再锁用户2
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 1; -- 锁住 id=1
-- 此时还没执行到 id=2...
-- 事务B(同时):先锁用户2、再锁用户1
BEGIN;
UPDATE account SET balance = balance - 30 WHERE id = 2; -- 锁住 id=2
UPDATE account SET balance = balance + 30 WHERE id = 1; -- 等待 id=1(被A持有)💀
-- 事务A继续:
UPDATE account SET balance = balance + 50 WHERE id = 2; -- 等待 id=2(被B持有)💀
-- 此时A等B、B等A → 死锁!InnoDB检测到后回滚其中一个事务并报错:
-- ERROR 1213 (40001): Deadlock found when trying to get lock解决办法:让所有事务按 id 从小到大的顺序更新(如都先锁 id=1 再锁 id=2),就不会互相等待。代码里捕获死锁异常后重试即可(InnoDB 只回滚一个)。
长事务危害(生产大忌):持有锁和 undo 版本时间长 → 阻塞他人、undo 膨胀、主从延迟、甚至 OOM。务必缩小事务边界,别在事务里调外部接口/睡大觉。
十、常用函数
10.1 字符串
1 | CONCAT('a','-','b') -- 'a-b' |
10.2 数值
1 | ROUND(x, d) -- 四舍五入保留 d 位 |
10.3 日期
1 | NOW() -- 当前 datetime(语句开始时刻) |
避免 DATE(col) 包裹索引列(见 §8.5)。统计”今日”应写:
WHERE created_at >= CURDATE() AND created_at < CURDATE() + INTERVAL 1 DAY
10.4 聚合
COUNT / SUM / AVG / MAX / MIN / GROUP_CONCAT(将分组内多行拼成一个串)。
10.5 流程控制
1 | -- IF(条件, 真值, 假值) |
10.6 JSON(8.0)
1 | -- 提取 |
十一、视图 / 存储过程 / 触发器 / 事件
资深建议:现代应用层(Java/Go)足够强大,存储过程/触发器应克制使用——它们难以调试、难以版本管理、不利于水平扩展、把逻辑藏进数据库。视图(简化复杂查询)可适度用;触发器/存储过程仅用于 DBA 维护类场景。
1 | -- 视图(虚拟表,不存数据,查询时实时计算) |
十二、常见业务场景 SQL 实战(精华)
以下示例用统一测试表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 -- 用户
CREATE TABLE user (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
city VARCHAR(32), age TINYINT UNSIGNED,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 订单
CREATE TABLE `order` (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_user (user_id),
KEY idx_created (created_at)
);
12.1 分页查询(含深度分页优化)
使用场景:几乎所有列表页面都需要分页——商品列表、订单列表、用户管理后台。前端传
page=1&pageSize=20,后端用LIMIT offset, size返回对应页数据。问题在于翻到越后面越慢(如第 5000 页),因为 MySQL 要先扫描前 10 万行再丢掉。
1 | -- 常规分页(offset 大时越来越慢:要扫描 offset+limit 行再丢弃前 offset 行) |
深度分页优化(游标分页 / 延迟关联):
1 | -- 方案A:游标(上一页最后一条的 id),前提 id 有序且连续查询 |
前端”上拉加载更多”用游标分页(无限滚动);后台管理”跳页”才用 offset,但要限制最大页数。
12.2 树形结构(分类/组织架构)
使用场景:电商的商品分类(一级分类→二级→三级)、公司组织架构(集团→事业部→部门→组)、评论的回复树、文件夹目录——任何”有父子层级关系”的数据都是树形结构。核心问题:怎么在关系型表里存树、怎么高效查某节点的所有子孙?
方案:邻接表(最常见)——每行带 parent_id。
1 | CREATE TABLE category ( |
1 | -- 查询某节点的所有子孙(递归 CTE,8.0) |
其它方案:路径枚举(存
'/1/3/10'全路径,LIKE 查子孙)、闭包表(另建关系表,查子树最快但写入复杂)。层级浅/变更少选邻接表+递归 CTE 最简洁。
12.3 行转列 / 列转行
使用场景:报表展示。数据库里成绩是”一行一条记录”(张三-语文-90、张三-数学-85),但报表/前端需要一行展示一个学生的所有科目(张三 | 90 | 85 | 78)。这就是”行转列”。反过来,有时宽表需要转成长表做分析/导入,就是”列转行”。
1 | -- 行转列(学生成绩:每个学生的语文/数学分作为列) |
12.4 连续登录天数 / 留存
使用场景:运营分析。产品经理想知道”用户连续登录了多少天”来做签到奖励/活跃度分析;或计算”次日/7日/30日留存率”判断产品粘性。这是游戏、社交、教育类 App 最常见的数据需求。
1 | -- 计算用户最长连续登录天数(经典窗口函数解法) |
思路:连续日期的”日期 - 行号”是常数,按该常数分组即得每段连续区间的长度。
12.5 排名 / 每组 TopN
使用场景:排行榜(成绩排名、销售额排名、热搜排名);以及”每个分组里取前 N 个”(如每个用户最新 3 条消息、每个城市 TOP3 商家、每个部门工资最高的人)——这是后台管理系统和 BI 报表最高频的需求之一。
1 | -- 每个城市销售额 TOP 3 的商品 |
12.6 累计求和 / 同比环比
1 | -- 每日销售额 + 累计 + 环比(窗口函数) |
12.7 去重保留最新一条
使用场景:数据清洗。业务 bug 或重复提交导致同一用户有多条重复记录(如重复的登录日志、重复的消息推送记录),需要只保留最新/最大的那一条,删掉其余脏数据。也用于”每个用户只保留最后一次操作”的业务逻辑。
1 | -- 场景:日志表重复,只保留每个 user_id 最新一条 |
注意:含子查询的 DELETE 在 MySQL 中不能直接
DELETE FROM t WHERE id NOT IN (子查询同表),需套一层派生表(如方法2的内层t)规避 “不能先 SELECT 再 UPDATE 同表” 限制。
12.8 缺失日期补 0(报表常用)
使用场景:做报表/图表时,如果某天没有数据(如周末没订单),直接查会”跳过”那天,导致前端折线图断裂。解决方案:先生成一组连续日期,再 LEFT JOIN 实际数据,无数据的天自动补 0。运营日报、销售趋势图、活跃用户曲线都需要。
1 | -- 生成最近 30 天连续日期,LEFT JOIN 实际数据补 0 |
12.9 随机取样 / 高效随机
1 | -- ❌ 禁用:ORDER BY RAND() 会物化全表再排序,千万级表灾难 |
12.10 分桶 / 取模分组
1 | -- 按 user_id 取模分 10 桶(灰度、分批处理、分库分表路由) |
十三、性能优化清单(排障 SOP)
- 开启并分析慢查询日志:用
1
2SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过1秒记日志pt-query-digest聚合慢日志,找出高频/高耗时 SQL。 - EXPLAIN 每一类慢 SQL(见 §8.7),看
type/key/rows/Extra。 - 索引先行:按 §8.4~8.8 原则补/调索引,优先覆盖索引。
- 消灭全表扫描与 filesort/temporary:改写 SQL、加合适索引、
SELECT只取所需列。 - SQL 改写:
- 避免
SELECT *;避免ORDER BY RAND(); IN元素过多改JOIN临时表;COUNT(*)大表用估算/EXPLAIN的rows,或冗余计数表;- 深分页用游标/延迟关联(§12.1)。
- 避免
- JOIN 优化:确保被驱动表关联列有索引;小表驱动大表。
- 批量操作:
INSERT多值、批量UPDATE用CASE WHEN一次性改,减少交互。 - 连接池:合理设置最大连接数(如 HikariCP
maximumPoolSize ≈ DB 核数*2),避免连接耗尽。 - 表结构:类型最小化、避免大字段在主表、定期
OPTIMIZE/ANALYZE TABLE更新统计信息。 - 架构层:读写分离(主写从读)、分库分表(ShardingSphere)、缓存(Redis 扛读热点)、ES 扛检索。
十四、字符集与排序规则(乱码根因)
1 | -- 查看 |
utf8mb4是真正 UTF-8(4 字节),支持 emoji 与全部中文;**utf8(utf8mb3) 仅 3 字节**,存 emoji 报错。- 三级一致:库、表、列、连接字符集都要一致,否则乱码。连接串加
?useUnicode=true&characterEncoding=utf8mb4。 - 排序规则:
utf8mb4_general_ci(快、精度略低)、utf8mb4_0900_ai_ci(8.0 默认,ai=口音不敏感、ci=大小写不敏感)、utf8mb4_bin(二进制精确,区分大小写,适合令牌/校验码)。 - 常见乱码排查:客户端编码 ≠ 服务端编码、列单独指定了不同字符集、
emoji用utf8存。
十五、安全与规范
15.1 SQL 注入(必防)
1 | // ❌ 字符串拼接:用户输入 ' OR '1'='1 直接破坏语义 |
铁律:任何用户输入必须参数化(? 占位),禁止拼接。ORM(MyBatis
#{}是预编译,${}是拼接,禁用${}接用户输入)。最小权限账号、关闭FILE等危险权限。
15.2 命名与开发规范(团队共识)
- 表/字段用小写蛇形
user_order/created_at;表名单数或复数统一。 - 必备:
id主键、created_at、updated_at;逻辑删除用deleted/is_deleted而非物理删。 - 每个字段加
COMMENT;表加COMMENT。 - 索引命名:
idx_字段/uk_字段/pk_。 - SQL 关键字大写,便于阅读与区分。
十六、备份与恢复(DBA 底线)
1 | # 逻辑备份(mysqldump,适合中小库/单表恢复) |
生产必须:定期全量 + binlog 增量 + 异地/云存储 + 定期恢复演练。没演练过的备份 = 没有备份。
附:高频面试题 & 易错点速记
COUNT(*)、COUNT(1)、COUNT(col)区别?→ 见 §7.5。CHARvsVARCHAR?→ §3.2。DATETIMEvsTIMESTAMP?→ §3.3。- 为什么
FLOAT不能存金额?→ §3.1。 - 最左前缀原则?→ §8.4。
- 索引失效场景?→ §8.5。
- 覆盖索引?→ §8.6。
- 事务隔离级别 & 幻读如何解决?→ §9.3。
WHERE col != x为何漏数据?→ §7.2(NULL 陷阱)。- 深度分页怎么优化?→ §12.1。
- 为什么
ORDER BY RAND()慢?→ §12.9。 - 死锁怎么办?→ §9.4(InnoDB 自动回滚代价小者,应用重试)。
本文档持续沉淀自真实生产实践。遇到具体慢 SQL 或表结构问题,先用
EXPLAIN定位,再对症下药。
- 本文标题:MySQL 学习文档(从入门到精通 · 表数据 + CRUD 实战)
- 本文作者:邵预鸿
- 创建时间:2026-07-09 00:00:00
- 本文链接:/images/logo.jpg2026/07/09/mysql/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!