Skip to content

MySQL 指南 ​

适用范围

  • 读者画像:零 SQL 基础的 Java 后端开发者,想系统掌握 MySQL——从"怎么用对 SQL"到"类型怎么选、索引为什么快"。
  • 假定知识:无需 SQL 经验;了解 Java 与关系型数据库的基本概念("库、表、行")即可。
  • 本篇视角:两段式——第 1 章讲"SQL 怎么用对"(语句本身),第 2、3 章讲"引擎原理"(类型选型、索引为什么快)。
  • 版本口径:MySQL 8.x + InnoDB。5.7 关键差异用 > **MySQL 5.7 差异** callout 就地标注。
  • 示例锚点:第 1~3 章全程基于自创的 orders + users 两张表(1:N),完整 DDL 已前置到「§1.1」,建议先去独立 MySQL 实例建表再结合本文阅读。本仓库 sql/init.sql 是跨文档共享的 MyBatis 实操脚本,本指南不逐行解读它。
  • 非目标:不展开事务/锁/MVCC(见 spring-transaction-guide);不写视图、存储过程、触发器、窗口函数等进阶 SQL 特性。

目录 ​


1. SQL 快速上手 ​

SQL 分三类语句:DDL(定义库表结构)、DML(增删改数据)、DQL(查询数据)。本章按"建表 → 填数 → 查数 → 多表查"的顺序,用 orders + users 两张表把基础语句过一遍。第 2、3 章的全部示例沿用本章的表与数据。

1.1 DDL:建库与建表 ​

DDL(Data Definition Language,数据定义语言)——定义数据"存在哪、长什么样":建库、建表、改表结构。

先建库并选中它:

sql
-- 建库(库名自定),显式声明字符集(为什么用 utf8mb4 见 §2.4)
CREATE DATABASE `demo` DEFAULT CHARACTER SET utf8mb4;
USE `demo`;

再建本指南全程使用的两张自创表。第一张 orders(订单表):

sql
CREATE TABLE `orders` (
    `id`          BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `order_no`    VARCHAR(32)   NOT NULL                COMMENT '订单号(业务唯一)',
    `user_id`     BIGINT        NOT NULL                COMMENT '下单用户ID',
    `status`      TINYINT       NOT NULL DEFAULT 0      COMMENT '状态:0待付1已付2发货3完成',
    `amount`      DECIMAL(10,2) NOT NULL                COMMENT '订单金额',
    `remark`      VARCHAR(200)           DEFAULT NULL   COMMENT '备注(可空)',
    `create_time` DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_order_no` (`order_no`),
    KEY `idx_user_status_time` (`user_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

第二张 users(用户表,§1.4 的多表查询要用):

sql
CREATE TABLE `users` (
    `id`     BIGINT      NOT NULL AUTO_INCREMENT COMMENT '用户ID',
    `name`   VARCHAR(50) NOT NULL                COMMENT '用户名',
    `mobile` CHAR(11)    DEFAULT NULL            COMMENT '手机号',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

建表语句速览:每一列 = 列名 + 类型 + 约束/默认值 + 注释。COMMENT 是注释;ENGINE=InnoDB 指定存储引擎;CHARSET=utf8mb4 指定字符集。每列的类型怎么选是第 2 章的内容;NOT NULL/PRIMARY KEY 等约束是 §2.5 的内容。

建表语句里给索引起名用了 uk_/idx_ 前缀——这是业界通行的索引命名约定:

前缀索引类型示例 / 说明
uk_唯一键(Unique Key)uk_order_no;报错 Duplicate entry ... for key 'uk_order_no' 里出现的就是它
idx_普通索引(inDex)idx_user_status_time,惯例是 idx_ + 按顺序拼列名

给已有表加/删索引:

sql
CREATE INDEX idx_status ON orders(status);   -- 加单列索引
DROP INDEX idx_status ON orders;             -- 删索引

改表结构用 ALTER TABLE(加列、改类型),删表用 DROP TABLE——本指南用不到,知道存在这两个语句即可。

1.2 DML:增删改 ​

DML(Data Manipulation Language,数据操纵语言)——对已有表增删改数据。第 2、3 章的示例都基于下面的教学数据,先插入:

sql
-- users:三个用户(id 自增,省略不写)
INSERT INTO `users` (`name`, `mobile`) VALUES
    ('张三', '13800000001'),
    ('李四', '13800000002'),
    ('王五', NULL);

-- orders:三笔订单(user_id 对应 users.id,即 1、2、3;id 自增,省略不写)
INSERT INTO `orders` (`order_no`, `user_id`, `status`, `amount`, `create_time`) VALUES
    ('A1002', 1, 1,  89.00, '2026-01-02 10:00:00'),
    ('A1003', 2, 0, 199.00, '2026-01-03 11:00:00'),
    ('A1004', 1, 2,  55.00, '2026-01-04 12:00:00');

语法要点:INSERT INTO 表 (列1, 列2, ...) VALUES (值1, 值2, ...);一次插多行就用逗号分隔多组 VALUES。id 是 AUTO_INCREMENT 自增列,不需要手动赋值——在列清单里省略 id,InnoDB 每次自动分配下一个值(规则见 §2.5)。首次插入后 users、orders 的 id 都是 1、2、3;重复执行会继续往后编号(自增计数器不会因 DELETE 回收旧编号,TRUNCATE TABLE 才会重置,见 §2.5)。

UPDATE 与 DELETE:

sql
-- UPDATE:只把订单 A1002 这一行的状态改成 2
UPDATE orders SET status = 2 WHERE order_no = 'A1002';

-- DELETE:删除指定行(按业务唯一键定位,不依赖自增 id)
DELETE FROM orders WHERE order_no = 'A1004';

⚠ 危险警告:UPDATE/DELETE 不带 WHERE 会命中全表——UPDATE orders SET status = 1 会把所有订单状态改掉,DELETE FROM orders 会清空整张表。生产环境先 SELECT 同条件确认命中行数,重要操作放事务里执行(事务见 spring-transaction-guide)。

1.3 单表查询 ​

查询(DQL,Data Query Language)是日常写得最多的语句。骨架:

sql
SELECT 要哪些列
FROM 哪张表
WHERE 哪些行满足条件
ORDER BY 按什么排序
LIMIT 取多少条;

① WHERE 条件族——用条件筛行:

写法含义示例
=等于WHERE status = 1
> >= < <= !=比较WHERE amount > 100
BETWEEN a AND b闭区间WHERE amount BETWEEN 50 AND 200
IN (...)枚举匹配WHERE user_id IN (1, 2)
LIKE 'xx%'模糊匹配WHERE order_no LIKE 'A1%'
IS NULL / IS NOT NULL判空WHERE remark IS NULL
AND / OR组合条件WHERE user_id = 1 AND status >= 1

② 排序与分页:

sql
-- 按金额从大到小排序,取前 2 条
SELECT order_no, amount FROM orders ORDER BY amount DESC LIMIT 0 2;

ORDER BY 列 ASC|DESC(默认升序);LIMIT 偏移, 数量 从 0+1 条数据开始,取出2条数据 (大数据量翻页的性能坑见第 4 章"深分页")。

③ 聚合与分组:

sql
-- 整体统计:共几单、总金额、平均金额
SELECT COUNT(*), SUM(amount), AVG(amount) FROM orders;

-- 按用户分组:查询至少有 2 笔订单的用户
SELECT user_id, COUNT(*) AS order_cnt, SUM(amount) AS total
FROM orders
GROUP BY user_id
HAVING order_cnt >= 2;

要点:WHERE 在分组前过滤行,HAVING 在分组后过滤组;聚合函数(COUNT/SUM/AVG/MAX/MIN)配合 GROUP BY 才有分组意义;AS 给结果列起别名。

④ 去重:SELECT DISTINCT user_id FROM orders; 返回出现过的 user_id,重复的只留一个。

1.4 多表查询与子查询 ​

问题:orders 表只存了 user_id,想查出"每笔订单的用户姓名"怎么办?把姓名冗余存在每行订单里会带来不一致(用户改名要改所有订单)。正确做法是用户拆到 users 表、orders 用 user_id 引用 users.id——这就是"关系型数据库"里"关系"的由来。注意两张表的 id 各自独立自增,恰好都从 1 开始,别把 orders.id 和 users.id 混为一谈。

text
users 表              orders 表
┌────┬──────┐        ┌────┬─────────┐
│ id │ name │        │ id │ user_id │
├────┼──────┤        ├────┼─────────┤
│ 1  │ 张三 │◄───────│ 1  │ 1       │
│ 2  │ 李四 │◄───────│ 2  │ 2       │
│ 3  │ 王五 │        │ 3  │ 1       │
└────┴──────┘        └────┴─────────┘

JOIN 按"保留哪边的行"分三种:

sql
-- INNER JOIN:只返回两边都匹配的行
SELECT o.order_no, o.amount, u.name
FROM orders o
INNER JOIN users u ON o.user_id = u.id;

-- LEFT JOIN:users 是左表、orders 是右表
-- 左表全保留,右表对不上的补 NULL(王五没订单,仍出现且 order_no 为 NULL)
SELECT u.id, u.name, o.order_no
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;

RIGHT JOIN 与 LEFT JOIN 对称(右表全保留)——把两张表位置对调就等价,日常统一用 LEFT JOIN。o/u 是表别名,用来区分两张表的同名列。

子查询——查询里套查询:

sql
-- IN + 子查询:查"有过订单"的用户姓名
SELECT name FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders);

-- 标量子查询(结果只有一行一列):查金额大于平均值的订单
SELECT order_no, amount FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders);

收口:JOIN 查出的结果在 Java 侧如何映射成"对象里嵌套对象"(Order 里带 User),属于 ORM 框架的职责,本指南不再展开;第 3 章索引的全部示例回归单表 orders。


2. 数据类型与字符集 ​

建表时每一个列类型、每一个 CHARSET 声明,都会影响后续的存储、索引和查询。这一章讲"怎么选、为什么",最后用约束把建表语句收口。

2.1 数值类型 ​

类型字节有符号范围典型用途
TINYINT1-128 ~ 127状态位、布尔(0/1)、枚举值
INT4约 -21 亿 ~ 21 亿一般主键、外键、计数
BIGINT8约 -922 京 ~ 922 京大表主键、雪花 ID、金额分、时间戳毫秒
DECIMAL(M,D)变长精确金额、利率——绝不用 FLOAT/DOUBLE

选型要点:

  • 主键优先 BIGINT:INT 上限 21 亿看着够,但自增主键一旦跳号(自增只增不减,空洞不会回填)或业务暴涨就会触顶,改类型代价极大。
  • 金额必须 DECIMAL:FLOAT/DOUBLE 是二进制浮点,0.1 + 0.2 != 0.3,做钱会算错。DECIMAL(10,2) 表示最多 10 位、小数 2 位,精确存储。
  • UNSIGNED 慎用:无符号把范围翻倍,但两个 UNSIGNED 相减出现负数会溢出成巨大正数,反而埋雷。

2.2 字符串类型 ​

类型适用场景
CHAR(n)长度固定且常参与比较:手机号、身份证、MD5、国家码
VARCHAR(n)需 WHERE/索引的文本:姓名、标题、地址、状态
TEXT不检索的中等文本:备注、说明、JSON 片段

选型要点:

  • VARCHAR(50) 的 50 是字符数不是字节数——utf8mb4 下一个汉字/emoji 占 1 个字符(但 3~4 字节),能存 50 个汉字。
  • CHAR vs VARCHAR:定长内容用 CHAR(省去记录长度的 1~2 字节,且无碎片),如手机号 CHAR(11)、身份证 CHAR(18);长度不定又要检索的才用 VARCHAR,如 orders.order_no 的 VARCHAR(32)。
  • 别滥用大 VARCHAR:VARCHAR(255) 不是免费的,InnoDB 行溢出、内存临时表都按声明长度估算,能小则小。

2.3 时间类型 ​

类型字节范围时区行为
DATETIME5(8.0)1000-01-01 00:00:00 ~ 9999-12-31 23:59:59不带时区,存什么读什么
TIMESTAMP41970-01-01 00:00:01 UTC ~ 2038-01-19 UTC带时区,按会话时区转换
DATE31000-01-01 ~ 9999-12-31仅日期,无时分秒

选型要点:

  • TIMESTAMP 有 2038 问题:底层是 32 位 Unix 秒,2038-01-19 后溢出。长期数据用 DATETIME。
  • 跨时区场景:TIMESTAMP 存 UTC、读时按会话时区转换,适合"同一时刻全球统一";DATETIME 是"墙上时钟",存进去什么就是什么,适合生日、营业日期这类与时区无关的值。

2.4 字符集与排序规则 ​

字符集(charset) 决定字符怎么编码成字节,排序规则(collation) 决定字符怎么比较和排序。

text
utf8mb3(旧名 utf8):每字符最多 3 字节 —— 存不了 emoji、部分生僻字 ❌
utf8mb4             :每字符最多 4 字节 —— 完整 Unicode,能存 emoji ✅

踩坑现场:MySQL 里的 utf8 是历史遗留的错误命名,它其实是 utf8mb3,最多 3 字节。用户昵称带个 emoji 🙂 就会插入失败或乱码。结论:一律用 utf8mb4,建表显式写 DEFAULT CHARSET=utf8mb4。

collation 一句话:utf8mb4_general_ci(旧默认,快但不完全准)、utf8mb4_unicode_ci(更准)、utf8mb4_0900_ai_ci(8.0 新默认,基于 Unicode 9.0,ai=不区分重音、ci=不区分大小写)。日常跟随默认即可,除非要做区分大小写的唯一约束。

MySQL 5.7 差异:utf8mb4 的默认排序规则 5.7 是 utf8mb4_general_ci,8.0 改为 utf8mb4_0900_ai_ci。跨版本迁移时若不显式声明 collation,唯一索引/排序行为可能有细微差异。

2.5 约束 ​

类型定了"这一列存什么",建表语句里还要回答一件事:这一列允许存什么——这就是约束(constraint),数据库在 INSERT/UPDATE 时强制执行的校验规则,不依赖应用代码自觉。三条最常用的:

  • NOT NULL:必填。列默认可空(不赋值落库即 NULL),所以要显式声明来收紧。orders 除备注 remark 外全部 NOT NULL。
  • UNIQUE:值不许重复,一表可建多个;但多个 NULL 互不冲突——业务上必填的唯一键要 NOT NULL UNIQUE 一起写,光靠 UNIQUE 拦不住空值。orders.order_no 就是 NOT NULL + UNIQUE。
  • PRIMARY KEY:每行数据的身份证,一表只能有一个;= UNIQUE + NOT NULL。用自增 id、不用业务列(业务会变,主键不能变)。

3. 索引 ​

这是全篇最重要的一章,也是基础期必修:不懂索引,写出来的查询在小数据量下全绿,数据一涨就把数据库拖垮——"接口功能正确,但上线后数据库被拖垮"。

四个核心概念层层递进:聚簇索引(数据怎么存)→ 覆盖索引(怎么少读)→ 联合索引 + 最左前缀(怎么组合),最后用"索引失效"收口。

3.0 EXPLAIN:验证走没走索引 ​

建表、加索引的语句在 §1.1 已备齐。现在缺最后一件工具:怎么知道一条查询走没走索引——在查询前加 EXPLAIN:

sql
EXPLAIN SELECT * FROM orders WHERE order_no = 'A1003';

输出里看四个关键列:

列含义
type访问方式好坏:const/eq_ref(最优)→ ref(走二级索引)→ range → index → ALL(全表扫描,最差)
key实际使用的索引名,NULL = 没走索引
rows预估要扫描的行数
Extra附加信息:Using index(覆盖索引)、Using filesort(额外排序)等

§3.3、§3.4 的"命中 vs 失效"对照都用它。

教学示意:本指南所有 EXPLAIN 输出均为教学示意,实际输出因数据量/统计信息而异。

3.1 聚簇索引与二级索引:回表 ​

InnoDB 的索引本质是 B+Tree 排序结构——树怎么长不展开,本章关心的是:叶子节点存什么、查询怎么用它。按"叶子存什么"分两种:

text
聚簇索引(主键索引)              二级索引(普通索引/唯一索引)
─────────────────────            ─────────────────────
叶子存【整行数据】                叶子存【索引列的值 + 主键值】
主键即表,表和主键索引是一体的     除了主键,其他索引都是二级索引
一张表只有一个聚簇索引            一张表可以有多个二级索引

定义有点抽象,看具体数据。orders 表已存入三行(教学示意,略去 remark):

idorder_nouser_idstatusamountcreate_time
1A10021189.002026-01-02 10:00:00
2A100320199.002026-01-03 11:00:00
3A10041255.002026-01-04 12:00:00

同一份数据,三棵树里存的内容不一样:

text
① 聚簇索引(按主键 id 组织)——叶子存整行:
   key=1 → (1, A1002, 1, 1, 89.00, 2026-01-02 10:00:00)
   key=2 → (2, A1003, 2, 0, 199.00, 2026-01-03 11:00:00)
   key=3 → (3, A1004, 1, 2, 55.00, 2026-01-04 12:00:00)

② uk_order_no(唯一索引,按 order_no 组织)——叶子存「order_no + 主键」:
   A1002 → id=1
   A1003 → id=2
   A1004 → id=3

③ idx_user_status_time(联合索引,先按 user_id、同值再按 status、最后按 create_time 排序)
   ——叶子存「三列的值 + 主键」:
   (1, 1, 2026-01-02 10:00:00) → id=1
   (1, 2, 2026-01-04 12:00:00) → id=3    ← user_id 同为 1,按 status 排
   (2, 0, 2026-01-03 11:00:00) → id=2    ← user_id=2 整体靠后

三者的差异就在"叶子存什么、按什么排序":

索引叶子内容排序依据查整行要回表吗
聚簇索引(主键)整行所有列id不用,叶子就是整行
uk_order_noorder_no + idorder_no要:拿 id 再查
idx_user_status_timeuser_id + status + create_time + id三列依次比较(最左优先)要:同上

关键推论:用二级索引查询,往往要"回表"——先在二级索引树里找到主键值,再拿主键值去聚簇索引树里查整行数据,两趟树查找:

sql
SELECT * FROM orders WHERE order_no = 'A1003';
text
第一趟:走 uk_order_no 树       A1003 → 拿到主键 id=2
                                     ↓(回表)
第二趟:走聚簇索引(主键)树     id=2 → 取出整行 (2, A1003, 2, 0, 199.00, ...)

走联合索引同理——WHERE user_id = 1 只用到 idx_user_status_time 的最左列,同样命中:

sql
SELECT * FROM orders WHERE user_id = 1;
text
第一趟:走 idx_user_status_time 树(只用最左列) user_id=1 → 命中 id=1、id=3
                                    ↓(回表两次)
第二趟:走聚簇索引(主键)树     id=1、id=3 → 各取出整行

EXPLAIN 里这类查询 type=ref(走了索引,但不是最优)。回表是额外开销——能不能不回表?能,见 §3.2。

3.2 覆盖索引:不回表的查询 ​

覆盖索引(covering index):如果查询要的所有列,二级索引的叶子里都已经有了,就不用回表——一趟树查找搞定。

sql
-- orders 的 idx_user_status_time 叶子里有 (user_id, status, create_time, id)
-- 只查这几列,索引已覆盖,无需回聚簇索引

SELECT id, user_id FROM orders WHERE user_id = 1;   -- ✅ 覆盖索引,不回表
SELECT *         FROM orders WHERE user_id = 1;      -- ❌ 要 amount/remark,必须回表

实战价值:SELECT * 是覆盖索引的天敌——它要所有列,必然回表。只查需要的列,既省网络传输,又可能命中覆盖索引。这也是"禁止 SELECT *"这条团队规范背后的性能原因之一。

3.3 联合索引与最左前缀 ​

联合索引(复合索引):一个索引包含多列,如 orders 的 idx_user_status_time (user_id, status, create_time)。它的排序规则是先按第一列排,第一列相同再按第二列排,依次类推——像字典先按首字母、再按第二个字母排序。

text
联合索引 (user_id, status, create_time) 在 B+Tree 里的排列:

  (1,0,'…') (1,1,'…') (1,2,'…') │ (2,1,'…') (2,3,'…') │ (3,0,'…')
  └──── user_id=1,内部按 status、再按 create_time 有序 ────┘

最左前缀原则:查询条件必须从最左列开始、连续使用,索引才生效。对 idx_user_status_time (user_id, status, create_time):

text
✓ WHERE user_id=1                                    → 命中(用到 user_id)
✓ WHERE user_id=1 AND status=2                       → 命中(用到 user_id、status)
✓ WHERE user_id=1 AND status=2 AND create_time>'…'   → 命中(三列全用到)
✓ WHERE user_id=1 AND create_time>'…'                → 部分命中(只到 user_id;跳过 status,create_time 用不上)
✗ WHERE status=2                                     → 失效!没有最左的 user_id
✗ WHERE status=2 AND create_time>'…'                 → 失效!同样缺 user_id

范围截断:最左列之后一旦出现范围条件(>/</BETWEEN),它后面的列就用不上了:

text
✗ WHERE user_id=1 AND status>1 AND create_time>'…'
     → user_id 等值命中、status 取范围后,create_time 无法继续利用

所以建联合索引时,区分度高的等值列放最左、范围列放最后——本表把 create_time(常被用来查区间)放在三列末尾,正是这个原因。

3.4 索引失效常见写法 ​

即使建了索引,某些写法会让 MySQL 放弃它、退回全表扫描(type=ALL)。三类高频陷阱:

① 对索引列做函数/运算:

sql
-- ❌ 失效:对 create_time 套函数,破坏 B+Tree 的有序性
SELECT * FROM orders WHERE YEAR(create_time) = 2026;

-- ✅ 改写:让索引列"裸奔",把运算移到常量侧
SELECT * FROM orders WHERE create_time >= '2026-01-01'
                       AND create_time <  '2027-01-01';

② 隐式类型转换:

sql
-- order_no 是 VARCHAR,传数字会触发隐式转换 → 相当于对列套函数 → 失效
SELECT * FROM orders WHERE order_no = 123456;    -- ❌ 数字
SELECT * FROM orders WHERE order_no = '123456';  -- ✅ 字符串,类型一致

③ 前导模糊 %:

sql
SELECT * FROM orders WHERE order_no LIKE '%56';   -- ❌ 前导 %,B+Tree 无法定位起点,全表扫描
SELECT * FROM orders WHERE order_no LIKE '12%';   -- ✅ 后缀 %,能用到 uk_order_no(前缀确定)

一句话心法:让索引列独立、原样、类型匹配地出现在比较符左侧,别对它做任何加工——加工了就等于把 B+Tree 的有序性破坏了。


4. 进阶预告 ​

以下主题本章仅预告,不展开——它们是"出事/进阶时才需要"的运维与调优知识:

主题为什么留到进阶何时需要 / 入口
事务与隔离级别(MVCC/锁)引擎并发机制,Spring 侧有完整指南见 spring-transaction-guide.md
锁细节(行锁/间隙锁/死锁/next-key lock)抽象自并发事故,无场景硬学易忘遇到锁等待、死锁日志时
连接池调优(HikariCP 容量/超时/泄漏)需要真实压测数据支撑连接不够用、连接泄漏时
深分页(LIMIT 100万, 20 为什么慢)数据量到百万级才暴露列表页翻到很后面变慢时
慢查询定位(慢查询日志、EXPLAIN 深入)属于排障技能线上出现慢接口时

本章仅预告:事务、锁、连接池、深分页、慢查询的完整讲解不在本指南。本指南覆盖的是"基础期必修"——SQL 用法(第 1 章)、类型与约束(第 2 章)、索引(第 3 章)。


References ​