MySQL 增删改查(CRUD)完全指南:从 INSERT 到 GROUP BY

0. 开篇:从「约束」到「操作」

如果你看过我上一篇《MySQL 表的约束》,应该还记得这句话:

约束是「一次写好、长期受益」的东西。它不能帮你写出更漂亮的代码,但它能保证:当你的代码出 bug 的时候,脏数据进不了数据库。

现在问题来了:约束装好了,数据怎么进去?

这就轮到 CRUD 出场了:

CRUD:Create(创建)、Retrieve(读取)、Update(更新)、Delete(删除)

它是所有业务系统的「四件套」。你写的每一个「新增用户」「查订单列表」「改密码」「删评论」,本质都是这四个动作的排列组合。

而这篇博客我最想让你带走的,不是「语法长什么样」,而是这四件事在什么情况下会坑你:

  • 为什么 insert 有时候报 1062,有时候「悄悄更新了」?
  • 为什么 SELECT * 被老程序员骂?
  • 为什么 WHERE 里用别名会报错,ORDER BY 里用别名却没事?
  • 为什么 UPDATE 不带 WHERE 是新手最恐怖的事故?
  • 为什么 DELETE 删完数据,磁盘却没变小?
  • 为什么 GROUP BY 之后 SELECT 里不能随便写列?

先给你一张地图,我们一个一个拆。


1. CRUD 全景 + 动手建环境

操作 关键字 一句话 危险度
创建 INSERT / REPLACE 往表里放数据 ⭐
读取 SELECT 从表里拿数据 ⭐
更新 UPDATE 改表里的数据 ⭐⭐⭐⭐
删除 DELETE / TRUNCATE 删表里的数据 ⭐⭐⭐⭐⭐

本篇所有的例子,都基于下面这两张表,建议你先在本地建好,跟着敲一遍。

本篇文章的实验环境(原文照抄课件,可以直接执行):

复制代码
create database if not exists crud_demo default character set utf8mb4;
use crud_demo;

-- 表一:学生表(用来演示 INSERT / UPDATE / DELETE / 聚合函数)
create table students (
    id   int unsigned primary key auto_increment,
    sn   int not null unique comment '学号',
    name varchar(20) not null,
    qq   varchar(20)
);

-- 表二:考试成绩表(用来演示 SELECT 的各种玩法)
create table exam_result (
    id      int unsigned primary key auto_increment,
    name    varchar(20) not null comment '同学姓名',
    chinese float default 0.0 comment '语文成绩',
    math    float default 0.0 comment '数学成绩',
    english float default 0.0 comment '英语成绩'
);

insert into exam_result (name, chinese, math, english) values
('唐三藏', 67, 98, 56),
('孙悟空', 87, 78, 77),
('猪悟能', 88, 98, 90),
('曹孟德', 82, 84, 67),
('刘玄德', 55, 85, 45),
('孙权',   70, 73, 78),
('宋公明', 75, 65, 30);

建完先别急着往下看,注意这两个表的定义里藏着上一篇的知识点:

定义 来自上一篇的哪个约束 作用
id int unsigned primary key auto_increment 主键 + 自增 冲突报 for key 'PRIMARY'
sn int not null unique NOT NULL + 唯一键 冲突报 for key 'sn'
name varchar(20) not null NOT NULL 插 NULL 报 1048
qq varchar(20) 可以为空 才有后面 IS NULL 的演示
chinese float default 0.0 DEFAULT 省略插入不报错,填 0.0

🔗 前后照应 :上一篇讲过,NOT NULL 且没有默认值 的列,插入时省略会报 ERROR 1364。而这里的三个成绩列都写了 default 0.0,所以插入时完全可以省略 ------这就是「NOT NULL + DEFAULT 是最经典搭档」的实战验证。

更进一步:正因为它们有默认值 0.0 而不是 NULL,chinese + math + english 这个表达式永远不会因为「某科是 NULL」而整体变成 NULL。你在第 3.4 节会看到,这个设计有多省心。


2. Create:插入数据

2.1 语法全貌

复制代码
INSERT [INTO] table_name
    [(column [, column] ...)]
    VALUES (value_list) [, (value_list)] ...

三个可选项,记住了就掌握了 90%:

  • INTO:可省略(insert students values(...) 也能跑,但建议始终写,可读性更好)
  • (column, ...):可省略(省略 = 全列插入,必须按建表顺序给满所有列)
  • (value_list), (value_list):可重复 → 多行插入

2.2 单行数据 + 全列插入

复制代码
-- 插入两条记录,value_list 数量必须和定义表的列的数量及顺序一致
insert into students values (101, 10001, '孙悟空', '11111');
-- Query OK, 1 row affected

insert into students values (100, 10000, '唐三藏', NULL);
-- Query OK, 1 row affected

select * from students;
+-----+-------+-----------+-------+
| id  | sn    | name      | qq    |
+-----+-------+-----------+-------+
| 100 | 10000 | 唐三藏    | NULL  |
| 101 | 10001 | 孙悟空    | 11111 |
+-----+-------+-----------+-------+

这里有三个细节,一个都不能漏:

细节 ①:全列插入时,值得一个一个对齐,不能错位

复制代码
-- ❌ 危险:少给了列
insert into students values (102, 20001, '曹孟德');
-- ERROR 1136 (21S01): Column count doesn't match value count at row 1

细节 ②:'11111' 是字符串,插进 varchar(20) 的 qq 正好;但 '10001' 也是字符串,插进 int 的 sn 会怎样?

复制代码
-- 你写的是 '10001'(字符串),sn 是 int,MySQL 会隐式转换
insert into students values (101, '10001', '孙悟空', '11111');
-- Query OK
select sn from students where id = 101;
+-------+
| sn    |
+-------+
| 10001 |   -- 存进去的是整数 10001
+-------+

这是 MySQL 的「隐式类型转换」 :字符串 → 整数,能转就转,转不了就截成 0 并给 warning。这个特性后面在第 3 节讲 WHERE 时还会咬人一口(where sn = '10001abc' 竟然也能匹配上)。能自己写好类型就写对,别依赖隐式转换。

细节 ③:id 是自增的,你可以不写

课件原话:

注意,这里在插入的时候,也可以不用指定 id(当然,那时候就需要明确插入数据到哪些列了),那么 mysql 会使用默认的值进行自增。

复制代码
-- ❌ 全列插入又想省略 id:做不到
insert into students values (10000, '唐三藏', NULL);
-- ERROR 1136: Column count doesn't match value count at row 1(3 个值对 4 列)

-- ✅ 正确做法:指定列,把 id 明确排除在外
insert into students (sn, name) values (10000, '唐三藏');
-- id 自动生成

🔗 前后照应 :上一篇第 7 节讲过,自增列的最大特点是「你不给它值,它自己长」;但前提是「你压根没提这一列」。你一旦显式给了值(哪怕是 NULL),规则就变了。下一篇讲自增的坑时我们还会回来。

2.3 多行数据 + 指定列插入

复制代码
-- 一次插两条,效率比插两次高得多
insert into students (id, sn, name) values
    (102, 20001, '曹孟德'),
    (103, 20002, '孙仲谋');
-- Query OK, 2 rows affected
-- Records: 2  Duplicates: 0  Warnings: 0

select * from students;
+-----+-------+-----------+-------+
| id  | sn    | name      | qq    |
+-----+-------+-----------+-------+
| 100 | 10000 | 唐三藏    | NULL  |
| 101 | 10001 | 孙悟空    | 11111 |
| 102 | 20001 | 曹孟德    | NULL  |
| 103 | 20002 | 孙仲谋    | NULL  |
+-----+-------+-----------+-------+

注意 qq 列:没指定,又没有默认值 → 存 NULL(因为 qq 允许为空)。

为什么「多行插入」比「循环单行插入」快得多?

方式 网络往返 事务提交 SQL 解析 速度
循环 1000 次 insert 1000 次 1000 次 1000 次 🐢 慢
一条 SQL 插 1000 行 1 次 1 次 1 次 🚀 快几个数量级

批量插入是性价比最高的性能优化之一。 工程上普遍做法是「每批 500~1000 行提交一次」------太小浪费往返,太大超过 max_allowed_packet 会报错。

2.4 主键 / 唯一键冲突:那个 1062

复制代码
-- 主键冲突
insert into students (id, sn, name) values (100, 10010, '唐大师');
-- ERROR 1062 (23000): Duplicate entry '100' for key 'PRIMARY'

-- 唯一键冲突
insert into students (sn, name) values (20001, '曹阿瞒');
-- ERROR 1062 (23000): Duplicate entry '20001' for key 'sn'

请把这两行报错放在一起看,这是上一篇那套对照表的实战版:

冲突来源 报错文本 索引名
主键 Duplicate entry '100' for key 'PRIMARY' 永远是 PRIMARY
唯一键 Duplicate entry '20001' for key 'sn' 默认是列名

🔗 前后照应 :上一篇我专门用一张表对比过「主键冲突报 PRIMARY,唯一键冲突报列名」。这里你看到了------同一个 students 表,两条 1062 报错,一眼就能看出是哪条约束炸的。 这就是「约束命名规范」的实战价值。

这里留一个问题 :既然插不进去,那如果业务诉求是「有这个学号就更新,没有就插入」呢?------这就是下一节。

2.5 插入否则更新:ON DUPLICATE KEY UPDATE

2.5.1 它解决什么问题

课件的标题起得很准:

由于主键 或者唯一键 对应的值已经存在而导致插入失败,可以选择性地进行同步更新操作。

复制代码
INSERT ... ON DUPLICATE KEY UPDATE column = value [, column = value] ...

insert into students (id, sn, name) values (100, 10010, '唐大师')
    on duplicate key update sn = 10010, name = '唐大师';
-- Query OK, 2 rows affected (0.47 sec)

select * from students where id = 100;
+-----+-------+-----------+-------+
| id  | sn    | name      | qq    |
+-----+-------+-----------+-------+
| 100 | 10010 | 唐大师    | NULL  |
+-----+-------+-----------+-------+

没报错,而且数据被改了。 这就是所谓的 upsert(update + insert)。

2.5.2 关键细节:0 / 1 / 2 rows affected 到底啥意思

课件给的三行注释是全篇最值钱的部分之一,我一字不改地放上来:

复制代码
-- 0 row affected:  表中有冲突数据,但冲突数据的值和 update 的值相等
-- 1 row affected:  表中没有冲突数据,数据被插入
-- 2 row affected:  表中有冲突数据,并且数据已经被更新

再用 ROW_COUNT() 验证:

复制代码
select row_count();
+-------------+
| ROW_COUNT() |
+-------------+
|           2 |
+-------------+

记忆口诀:

返回值 语义 你该怎么理解
0 冲突了,但你更新的值和原值一模一样 「白跑一趟,数据没变」
1 没冲突,纯插入 「一条新数据」
2 冲突了,且值真的变了 「先删旧行 + 插新行」= 2 次行操作

为什么会是 2 而不是 1? 因为 MySQL 内部把它拆成了 DELETE + INSERT 两次行操作,所以计数器是 2。

⚠️ 多行插入时,返回值是「累加」的:

复制代码
insert into students (id, sn, name) values
    (100, 10010, '唐大师'),     -- 冲突 + 值没变 → 0
    (105, 30001, '新同学')       -- 不冲突,插入  → 1
on duplicate key update name = values(name);
-- affected rows = 1(0 + 1)

所以不要以为返回值能精确告诉你「插了几条、改了几条」 ,它只是一个粗略的指标。要精确统计,得靠业务代码或者 select 复核。

2.5.3 补全:values() 函数在 MySQL 8.0.20 被弃用了

课件没讲的一个非常重要的写法变化。

在旧写法里,ON DUPLICATE KEY UPDATE 想引用「本来要插入的那个值」,得用 values(列名):

复制代码
-- 老写法(MySQL 8.0.20 起标记为 deprecated)
insert into students (id, sn, name) values (100, 10010, '唐大师')
    on duplicate key update name = values(name);

MySQL 8.0.20 之后官方推荐给 VALUES 起别名:

复制代码
-- 新写法(8.0.20+ 推荐)
insert into students (id, sn, name) values (100, 10010, '唐大师') as new
    on duplicate key update name = new.name;

为什么要改? 因为 values() 这个函数名和 VALUES 关键字撞了,语义容易歧义,而且新写法还能给列起别名:

复制代码
insert into t (a, b, c) values (1, 2, 3) as new(x, y, z)
    on duplicate key update c = new.x + new.y;

如果你的 MySQL 是 8.0.20+,写老写法会收到一条 warning。新项目一律用 as new 的写法。

2.5.4 补全:这个语法的「三宗罪」

ON DUPLICATE KEY UPDATE 好用,但生产环境要小心:

  1. 会消耗自增值 :即使走的是 UPDATE 分支,自增计数器照样 +1,所以会跳号。
  2. 可能死锁 :它是「先加锁再判断冲突」,高并发下多个事务争抢同一批唯一键,容易出现死锁(ERROR 1213)。
  3. 「更新」的是整行还是部分列? 只有你 SET 的那几列会变,其他列保持原样 ------这点和 REPLACE 完全相反(见下节)。

2.6 替换:REPLACE INTO

课件对它的描述非常精准:

ON DUPLICATE KEY 当发生重复 key 的时候:

  • 主键 或者 唯一键没有冲突,则直接插入;
  • 主键 或者 唯一键如果冲突 ,则删除后再插入。
复制代码
replace into students (sn, name) values (20001, '曹阿瞒');
-- Query OK, 2 rows affected
-- 1 row affected: 表中没有冲突数据,数据被插入
-- 2 row affected: 表中有冲突数据,删除后重新插入

select * from students;
+-----+-------+-----------+-------+
| id  | sn    | name      | qq    |
+-----+-------+-----------+-------+
| 100 | 10010 | 唐大师    | NULL  |
| 101 | 10001 | 孙悟空    | 11111 |
| 104 | 20001 | 曹阿瞒    | NULL  |   ← 注意 id 从 102 变成了 104!
| 103 | 20002 | 孙仲谋    | NULL  |
+-----+-------+-----------+-------+

这是个绝佳的教学素材:原来的 id=102,被 REPLACE 之后变成了 id=104。

因为 REPLACE 真的是先 DELETE 再 INSERT:

  1. 发现 sn=20001 冲突
  2. 删掉旧行(id=102)
  3. 插入新行(id 自增拿到 104)
2.6.1 补全:REPLACE 的四个隐形炸弹
坑 后果
① 主键会变 上例 id 从 102 → 104。如果其他表用 id 做外键,关系直接断掉
② 未指定的列被重置 你没写的 qq 不会保留旧值,而是回到默认值(NULL)------数据丢失
③ 触发器会触发两次 一次 DELETE 触发器 + 一次 INSERT 触发器
④ 会触发级联删除 如果子表设了 ON DELETE CASCADE,REPLACE 会连带删掉子表数据(超级危险)

REPLACE vs ON DUPLICATE KEY UPDATE 怎么选?

对比 ON DUPLICATE KEY UPDATE REPLACE INTO
实现 冲突则 UPDATE 指定列 冲突则 DELETE + INSERT
主键 保持原值 可能变化
未指定列 保留旧值 重置为默认值
自增值 消耗但不跳太多 消耗
触发器 1 次 2 次
危险度 ⭐⭐ ⭐⭐⭐⭐
推荐 ✅ 优先用 ⚠️ 只在「整行覆盖」语义下用

一句话总结 :ON DUPLICATE KEY UPDATE 是「打补丁」,REPLACE 是「拔了重栽」。 绝大多数场景你要的是「打补丁」,而不是「拔了重栽」。

2.7 补全:还有两种插入方式

① INSERT IGNORE:遇到错误就跳过

复制代码
insert ignore into students (id, sn, name) values (100, 10010, '重复的人');
-- Query OK, 0 rows affected, 1 warning
-- 不报错,只是被忽略,错误降级为 warning

注意 :IGNORE 会把所有 错误都降级成 warning(不只是唯一键冲突,还有类型越界、NOT NULL 等),可能掩盖其他问题。用它之前先想清楚「我是不是真的只想忽略重复」。

② INSERT ... SET:单行插入的可读性写法

复制代码
insert into students set sn = 40001, name = '新写法';

这是 MySQL 的扩展语法,好处是不用记列顺序。但只能插一行 ,所以批量插入还是得用 VALUES。

2.8 小结:四种插入方式的选择

复制代码
要插数据
   ├─ 确定不冲突? → INSERT(最普通的)
   ├─ 可能冲突,冲突了要「改部分列」? → INSERT ... ON DUPLICATE KEY UPDATE  ✅ 首选
   ├─ 可能冲突,冲突了要「整行覆盖」? → REPLACE(注意主键会变!)
   ├─ 可能冲突,冲突了「啥也不干」? → INSERT IGNORE
   └─ 数据来自另一张表? → INSERT ... SELECT(见第 6 节)

3. Retrieve:查询数据(本篇重点)

3.1 SELECT 的完整语法骨架

复制代码
SELECT
    [DISTINCT] {* | {column [, column] ...}}
    [FROM table_name]
    [WHERE ...]
    [GROUP BY ...]
    [HAVING ...]
    [ORDER BY column [ASC | DESC], ...]
    [LIMIT ...]

SELECT 是 SQL 里唯一「语法按书写顺序、执行按另一套顺序」的语句。 这一点我们在第 9 节会彻底讲透。

3.2 SELECT 列的五种玩法

3.2.1 全列查询 SELECT *
复制代码
select * from exam_result;
+----+-----------+-------+--------+--------+
| id | name      | chinese | math | english |
+----+-----------+-------+--------+--------+
|  1 | 唐三藏     |    67 |     98 |     56 |
|  2 | 孙悟空     |    87 |     78 |     77 |
|  3 | 猪悟能     |    88 |     98 |     90 |
|  4 | 曹孟德     |    82 |     84 |     67 |
|  5 | 刘玄德     |    55 |     85 |     45 |
|  6 | 孙权       |    70 |     73 |     78 |
|  7 | 宋公明     |    75 |     65 |     30 |
+----+-----------+-------+--------+--------+

但课件紧接着就给了警告:

-- 通常情况下不建议使用 * 进行全列查询

  1. 查询的列越多,意味着需要传输的数据量越大;
  2. 可能会影响到索引的使用。(索引待后面课程讲解)

这两条我要各补一刀,让你彻底明白为什么老程序员看到 select * 就想打人:

① 传输量:不只是「多几个字段」那么简单。

复制代码
-- 假设 user 表有 40 个列,其中有一个 text 类型的大字段(个人简介)
-- 你只想查 id 和 name
select * from user where id = 1;        -- 可能拉回几十 KB
select id, name from user where id = 1; -- 只拉回几十字节

在一个 QPS 上万的接口里,这就是网络带宽和内存的血案。

② 索引使用(关键):

复制代码
-- 假设有联合索引 idx_name_age(name, age)
select name, age from user where name = '张三';   -- ✅ 覆盖索引,不用回表
select * from user where name = '张三';           -- ❌ 必须回表取其他列

「覆盖索引(covering index)」 的意思:查询要的所有列,索引里全有,直接从索引返回结果,不用再回主键树取整行。select * 天然破坏了覆盖索引------因为你要的列永远比索引多。

③ 我补第三条:select * 会让「表结构变更」变成「线上事故」。

如果某天 DBA 给表加了一列 is_deleted,而你代码里写的是:

复制代码
// 假设用 JDBC 按位置取值
String name = rs.getString(3);

加列之后列顺序变了,getString(3) 拿到了别的字段------静默的脏数据。用显式列名就完全没这个问题。

结论 :select * 只在你自己随手调试时用。写进代码里的 SQL,一律显式列名。

3.2.2 指定列查询
复制代码
-- 指定列的顺序不需要按定义表的顺序来
select id, name, english from exam_result;
+----+-----------+--------+
| id | name      | english |
+----+-----------+--------+
|  1 | 唐三藏     |     56 |
|  2 | 孙悟空     |     77 |
|  3 | 猪悟能     |     90 |
...

注意课件那句注释:指定列的顺序不要求按建表顺序来。这是 SELECT 和 INSERT 的一个重要区别------

语句 列顺序要求
insert into t values(...) 全列插入 必须严格按建表顺序
select a, c, b from t 随便写,输出按你写的顺序
3.2.3 查询字段为表达式

这是 SELECT 最灵活的地方:它可以当选「计算器」用。

① 表达式不包含字段(纯常量)

复制代码
select id, name, 10 from exam_result;
+----+-----------+----+
| id | name      | 10 |
+----+-----------+----+
|  1 | 唐三藏     | 10 |
|  2 | 孙悟空     | 10 |
...

每一行都多了一列常量 10。有什么意义?意义在于「验证 SELECT 是按行执行的」------你写的常量,会被复制到每一行。

② 表达式包含一个字段

复制代码
select id, name, english + 10 from exam_result;
+----+-----------+-------------+
| id | name      | english + 10 |
+----+-----------+-------------+
|  1 | 唐三藏     |          66 |
|  2 | 孙悟空     |          87 |
|  3 | 猪悟能     |         100 |
...

注意列名变成了 english + 10------MySQL 用表达式本身做列名。 这很难看,所以有了 3.2.4 的别名。

③ 表达式包含多个字段

复制代码
select id, name, chinese + math + english from exam_result;
+----+-----------+-------------------------+
| id | name      | chinese + math + english |
+----+-----------+-------------------------+
|  1 | 唐三藏     |                     221 |
|  2 | 孙悟空     |                     242 |
|  3 | 猪悟能     |                     276 |
|  4 | 曹孟德     |                     233 |
|  5 | 刘玄德     |                     185 |
|  6 | 孙权       |                     221 |
|  7 | 宋公明     |                     170 |
+----+-----------+-------------------------+

🔗 前后照应 :这里我要专门夸一下课件的建表设计------chinese/math/english 三列都是 float default 0.0,允许为空吗?其实不允许(NOT NULL 隐含在 default 里?不,是没有 NOT NULL)。

等等,仔细看:chinese float DEFAULT 0.0 COMMENT '语文成绩' ------ 它没有 NOT NULL! 也就是说理论上可以插 NULL 进去。

那如果某行 math 是 NULL,chinese + math + english 会得到什么? 答案是 NULL(整个总和变成 NULL)。

这就是上一篇讲的「NULL 会污染整个表达式」。如果你要做这种跨列运算,强烈建议把列定义成 NOT NULL DEFAULT 0,否则你的「总分」列会时不时变成一片 NULL。这不是理论的坑,是真实项目里最常见的脏数据来源之一。

3.2.4 为查询结果指定别名
复制代码
SELECT column [AS] alias_name [...] FROM table_name;

select id, name, chinese + math + english 总分 from exam_result;
+----+-----------+--------+
| id | name      | 总分   |
+----+-----------+--------+
|  1 | 唐三藏     |    221 |
|  2 | 孙悟空     |    242 |
|  3 | 猪悟能     |    276 |
...

AS 可以省略 (... english 总分 就行),但建议写,可读性更好,也不容易和列名混淆。

三个知识点:

  1. 别名只在「这次查询」有效,不会改表结构

  2. 别名可以是中文、可以带空格(但要加引号)

    复制代码
    select name, chinese + math + english '总分 (分)' from exam_result;
  3. 别名能不能用在 WHERE 里?------不能! 这个问题很关键,我们留到 3.4 节讲完 WHERE 再回头解释。

3.2.5 结果去重:DISTINCT
复制代码
-- 98 分重复了
select math from exam_result;
+--------+
| math   |
+--------+
|     98 |
|     78 |
|     98 |   ← 重复
|     84 |
|     85 |
|     73 |
|     65 |
+--------+

-- 去重结果
select distinct math from exam_result;
+--------+
| math   |
+--------+
|     98 |
|     78 |
|     84 |
|     85 |
|     73 |
|     65 |
+--------+

DISTINCT 的三个关键细节(课件没讲,但面试常问):

① DISTINCT 作用于「整个 SELECT 列表」,不是单个列

复制代码
-- 这不是「name 去重」,而是「(name, math) 这个组合去重」
select distinct name, math from exam_result;

② DISTINCT 必须紧跟在 SELECT 后面 ,不能写成 select name, distinct math。

③ DISTINCT + ORDER BY 有限制

复制代码
-- 5.7+ 会报错 3065
select distinct name from exam_result order by math;
-- ERROR 3065 (HY000): Expression #1 of ORDER BY clause is not in SELECT list,
-- references column 'math' which is not in SELECT list;
-- this is incompatible with DISTINCT

原因:去重后一列 name 对应多个 math,不知道该按哪个排。裁决方式:ORDER BY 的列必须出现在 SELECT 列表里。

3.3 WHERE 条件:比较运算符全表

课件给的两张表我原样放上来,因为它们总结得很全:

比较运算符

运算符 说明
>, >=, <, <= 大于、大于等于、小于、小于等于
= 等于,NULL 不安全 ,例如 NULL = NULL 的结果是 NULL
<=> 等于,NULL 安全 ,例如 NULL <=> NULL 的结果是 TRUE(1)
!=, <> 不等于(两者等价)
BETWEEN a0 AND a1 范围匹配,[a0, a1],如果 a0 <= value <= a1,返回 TRUE(1)
IN (option, ...) 如果是 option 中的任意一个,返回 TRUE(1)
IS NULL 是 NULL
IS NOT NULL 不是 NULL
LIKE 模糊匹配。% 表示任意多个(包括 0 个)任意字符;_ 表示任意一个字符

逻辑运算符

运算符 说明
AND 多个条件必须都为 TRUE(1),结果才是 TRUE(1)
OR 任意一个条件为 TRUE(1),结果为 TRUE(1)
NOT 条件为 TRUE(1),结果为 FALSE(0)

🔗 前后照应 :看到 NULL = NULL 结果是 NULL 这一行了吗?这就是上一篇第 2.3 节讲的那个式子 ,一模一样。上一篇讲「为什么 NULL 会导致 count(name) 少算」,这一篇讲「为什么 = NULL 查不出数据」。同一个知识点,两个战场。

3.4 九个案例逐个拆

3.4.1 英语不及格的同学及英语成绩(< 60)
复制代码
select name, english from exam_result where english < 60;
+-----------+--------+
| name      | english |
+-----------+--------+
| 唐三藏     |     56 |
| 刘玄德     |     45 |
| 宋公明     |     30 |
+-----------+--------+

最基础的比较查询。但值得问一句 :如果某行 english 是 NULL,NULL < 60 结果是 NULL(不是 TRUE),所以这行不会被查出来。这通常符合直觉(没成绩当然不算不及格),但你要知道自己「漏掉了什么」。

3.4.2 语文成绩在 80, 90 分的同学及语文成绩
复制代码
-- 使用 AND 进行条件连接
select name, chinese from exam_result where chinese >= 80 and chinese <= 90;
+-----------+-------+
| name      | chinese |
+-----------+-------+
| 孙悟空     |    87 |
| 猪悟能     |    88 |
| 曹孟德     |    82 |
+-----------+-------+

-- 使用 BETWEEN ... AND ... 条件
select name, chinese from exam_result where chinese between 80 and 90;
-- 结果完全相同

结果完全一样,但两者有区别:

对比 between 80 and 90 >= 80 and <= 90
区间 闭区间 80, 90 80, 90
可读性 ✅ 更易读(尤其日期范围) 啰嗦
边界顺序 小的在前面,写反了返回空集 无所谓
是否违反索引 可以用索引 可以用索引

⚠️ BETWEEN 的坑 :between 90 and 80 返回的是空集,不是「反向区间」。很多人写日期范围时把顺序写反了,结果查出 0 条,debug 半天。

3.4.3 数学成绩是 58 / 59 / 98 / 99 分的同学
复制代码
-- 使用 OR 进行条件连接
select name, math from exam_result
where math = 58 or math = 59 or math = 98 or math = 99;
+-----------+--------+
| name      | math   |
+-----------+--------+
| 唐三藏     |     98 |
| 猪悟能     |     98 |
+-----------+--------+

-- 使用 IN 条件
select name, math from exam_result where math in (58, 59, 98, 99);
-- 结果相同

IN 就是「多个 OR」的简写,但可读性高得多。而且:

  • IN 的列表里可以是子查询:where id in (select id from t2)
  • IN 列表不能太长(极端情况下会变慢),一般几百个以内没问题
  • IN 列表的匹配不受类型严格约束 (where sn in ('10001') 也能命中 int 列)

⚠️ 【重点补全】NOT IN + NULL 的致命坑

这是本章最重要的一条补充,课件没有,但它是真实生产事故的高发区:

复制代码
-- 假设想查「不在 (58, 59, NULL) 里的成绩」
select name, math from exam_result where math not in (58, 59, null);
-- Empty set !!!  永远返回空!

为什么? 展开成逻辑表达式:

复制代码
math <> 58 AND math <> 59 AND math <> NULL
                             ↑ 这一项永远是 NULL

TRUE AND NULL = NULL,FALSE AND NULL = FALSE。所以只要 NOT IN 的列表里有一个 NULL,整个条件就不可能为 TRUE,结果集必然是空。

危害有多大? 如果你写的是:

复制代码
-- 想排除「黑名单用户」,结果一个都没排除掉(甚至是全删了)
delete from t where user_id not in (select user_id from blacklist);

blacklist 里只要有一条 user_id 为 NULL,这条 DELETE 一条都删不掉(幸好删不掉,反过来如果逻辑是「保留不在黑名单的」,可能就删错人了)。

正确写法:

复制代码
where user_id not in (select user_id from blacklist where user_id is not null)
-- 或者用 NOT EXISTS
where not exists (select 1 from blacklist b where b.user_id = t.user_id)

🔗 前后照应 :又是 NULL。上一篇我说「NULL 是 SQL 里最毒的东西」,这一篇它已经咬了两次(= NULL 查不出、NOT IN 全空)。你越早把 NULL 当敌人,你的 SQL 就越不容易出事故。

3.4.4 姓孙的同学 及 孙某同学
复制代码
-- % 匹配任意多个(包括 0 个)任意字符
select name from exam_result where name like '孙%';
+-----------+
| name      |
+-----------+
| 孙悟空     |
| 孙权       |
+-----------+

-- _ 匹配严格的一个任意字符
select name from exam_result where name like '孙_';
+--------+
| name   |
+--------+
| 孙权    |
+--------+

% 和 _ 的区别,一张图讲清楚:

写法 匹配 不匹配
'孙%' 孙、孙权、孙悟空、孙某某某 老孙(不是以孙开头)
'孙_' 孙权、孙X(正好两个字) 孙、孙悟空(三个字)
'%孙%' 含「孙」的任意位置 ---
'_孙' 某孙(两个字,孙在第二) 孙

补全:LIKE 的三个实用细节

① 转义 :如果要匹配真正的 % 或 _,要转义(默认转义符是 \):

复制代码
-- 查名字里真的带下划线的人
select * from t where name like '%\_%';
-- 或用 ESCAPE 显式指定转义符
select * from t where name like '%!_%' escape '!';

② 性能 :LIKE 是索引杀手,具体分三种情况:

复制代码
like '孙%'      -- ✅ 能用索引(前缀匹配)
like '%孙'      -- ❌ 全表扫描
like '%孙%'     -- ❌ 全表扫描(最慢)

原因 :索引(B+ 树)是按「前缀」排序的。你不知道前缀是什么,就没法在树上定位,只能一行行扫。数据量大时,like '%xx%' 必须换成 全文索引(FULLTEXT) 或 ES / 搜索引擎。这个坑很多人踩过------「模糊搜索怎么越用越慢」,答案就在这里。

③ 字符集 :like 的匹配受 collation 影响。utf8mb4_general_ci 下 like 'sun%' 能匹配 Sun(大小写不敏感);要区分大小写得用 utf8mb4_bin。

3.4.5 语文成绩好于英语成绩的同学
复制代码
-- WHERE 条件中比较运算符两侧都是字段
select name, chinese, english from exam_result where chinese > english;
+-----------+-------+--------+
| name      | chinese | english |
+-----------+-------+--------+
| 唐三藏     |    67 |     56 |
| 孙悟空     |    87 |     77 |
| 曹孟德     |    82 |     67 |
| 刘玄德     |    55 |     45 |
| 宋公明     |    75 |     30 |
+-----------+-------+--------+

这一条的意义在于:WHERE 的左边和右边都可以是「列」,不只是「列 vs 常量」。 这是很多人一开始没意识到的能力------「同一行内两个字段做比较」,语法上完全支持。

where 字段A > 字段B 是 SQL 的行内比较,非常常用(比如「实际发货时间 > 承诺发货时间」)。

3.4.6 总分在 200 分以下的同学
复制代码
-- WHERE 条件中使用表达式
-- 别名不能用在 WHERE 条件中
select name, chinese + math + english 总分 from exam_result
where chinese + math + english < 200;
+-----------+--------+
| name      | 总分   |
+-----------+--------+
| 刘玄德     |    185 |
| 宋公明     |    170 |
+-----------+--------+

这里有全篇最重要的一个「为什么」:

课件注释写着「别名不能用在 WHERE 条件中」。很多人试过:

复制代码
-- ❌ 报错
select name, chinese + math + english 总分 from exam_result where 总分 < 200;
-- ERROR 1054 (42S22): Unknown column '总分' in 'where clause'

为什么?因为执行顺序。 WHERE 在 SELECT 之前执行,那时候别名还不存在。

我们在第 9 节会用一整节讲执行顺序,这里先记住这个结论:

WHERE 看不到 SELECT 里定义的别名,因为 WHERE 先执行。 ORDER BY / HAVING 能看到,因为它们后执行(或 MySQL 做了特殊处理)。

3.4.7 语文成绩 > 80 并且不姓孙的同学
复制代码
-- AND 与 NOT 的使用
select name, chinese from exam_result
where chinese > 80 and name not like '孙%';
+-----------+-------+
| name      | chinese |
+-----------+-------+
| 猪悟能     |    88 |
| 曹孟德     |    82 |
+-----------+-------+

教训 :这里 not like 而不是 != '孙%'。

复制代码
-- ❌ 这种写法不会「排除所有姓孙的」,只会排除「名字正好叫 孙% 的人」
where name != '孙%'

'孙%' 在 = 比较里就是普通字符串 ,不是通配符。通配符只在 LIKE 里生效。

3.4.8 孙某同学,否则要求总成绩 > 200 并且语文 < 数学并且英语 > 80
复制代码
-- 综合性查询
select name, chinese, math, english, chinese + math + english 总分
from exam_result
where name like '孙_'
   or (chinese + math + english > 200 and chinese < math and english > 80);
+-----------+-------+-------+--------+--------+
| name      | chinese | math | english | 总分  |
+-----------+-------+-------+--------+--------+
| 猪悟能     |    88 |    98 |     90 |    276 |
| 孙权       |    70 |    73 |     78 |    221 |
+-----------+-------+-------+--------+--------+

这一条考的是「括号和优先级」,是全篇最容易翻车的一题。

AND 的优先级高于 OR。所以:

复制代码
a or b and c        ≡  a or (b and c)     -- 默认
a or b and c        ≢  (a or b) and c     -- 你想要的可能是这个

本题实际执行的逻辑是:

复制代码
name like '孙_'                                   ← 孙权
   OR
(总分 > 200 AND chinese < math AND english > 80)  ← 猪悟能

如果没有括号呢?

复制代码
-- 少了括号,AND 会先结合,语义完全变形
where name like '孙_' or chinese + math + english > 200 and chinese < math and english > 80
-- 等价于
where name like '孙_' or (总分 > 200 and chinese < math and english > 80)
-- 本题恰好因为 or 两边语义清晰而等价,但换个条件就翻车

工程建议:只要出现 OR 和 AND 混用,一律给每个逻辑单元加括号。 这不会让 SQL 更慢,只会让你少加班。

3.4.9 NULL 的查询(本节的点睛之笔)
复制代码
-- 查询 students 表
+-----+-------+-----------+-------+
| id  | sn    | name      | qq    |
+-----+-------+-----------+-------+
| 100 | 10010 | 唐大师     | NULL  |
| 101 | 10001 | 孙悟空     | 11111 |
| 103 | 20002 | 孙仲谋     | NULL  |
| 104 | 20001 | 曹阿瞒     | NULL  |
+-----+-------+-----------+-------+

-- 查询 qq 号已知的同学姓名
select name, qq from students where qq is not null;
+-----------+-------+
| name      | qq    |
+-----------+-------+
| 孙悟空     | 11111 |
+-----------+-------+

-- NULL 和 NULL 的比较,= 和 <=> 的区别
select null = null, null = 1, null = 0;
+-------------+----------+----------+
| NULL = NULL | NULL = 1 | NULL = 0 |
+-------------+----------+----------+
|        NULL |     NULL |     NULL |
+-------------+----------+----------+

select null <=> null, null <=> 1, null <=> 0;
+---------------+------------+------------+
| NULL <=> NULL | NULL <=> 1 | NULL <=> 0 |
+---------------+------------+------------+
|             1 |          0 |          0 |
+---------------+------------+------------+

这张表请你务必背下来:

表达式 = 的结果 <=> 的结果
NULL = NULL NULL 1(TRUE)
NULL = 1 NULL 0(FALSE)
NULL = 0 NULL 0(FALSE)

<=> 是什么? 它是 MySQL 提供的「NULL 安全等于」(NULL-Safe Equal):

  • 普通值比较时,<=> 和 = 行为完全一样
  • 遇到 NULL 时,<=> 把 NULL 当成「一个特殊值」来比较:NULL <=> NULL 为真

什么时候用 <=>? 在「NULL 有业务含义、且要参与相等判断」的场景:

复制代码
-- 用 = 永远查不到(因为 null = null 是 null)
select * from t where comment = null;         -- ❌ 空结果

-- 用 <=> 才能命中
select * from t where comment <=> null;        -- ✅

但注意 :判断 NULL 的标准写法 依然是 IS NULL / IS NOT NULL,语义更清晰、可读性更好、跨数据库也通用。<=> 主要用在「可能是 NULL 也可能是值」的动态条件里。

比如:

复制代码
-- 动态条件:如果参数是 NULL,也要匹配 NULL 的行
select * from t where parent_id <=> ?;

这一句如果用 =,? 传 NULL 时就查不到任何数据。

🔗 前后照应:这是本篇第三次和 NULL 交手。回顾一下它的三次攻击:

  1. = NULL 查不出数据(3.4.9)
  2. NOT IN 里有 NULL 就全空(3.4.3)
  3. 表达式里有 NULL,整列结果变 NULL(3.2.3)

上一篇的结论在这里完全成立:能 NOT NULL 就 NOT NULL,能 DEFAULT 就 DEFAULT。 你在建表时省下的那点事,将来都要在查询和排错时还回来。

3.5 结果排序:ORDER BY

3.5.1 语法与基本用法
复制代码
-- ASC 为升序(从小到大)
-- DESC 为降序(从大到小)
-- 默认为 ASC

SELECT ... FROM table_name
    [WHERE ...]
    ORDER BY column [ASC|DESC], [...];

注意:没有 ORDER BY 子句的查询,返回的顺序是未定义的,永远不要依赖这个顺序。

这句话看起来是废话,其实是一条铁律 。很多人写分页时以为「不排序返回的顺序和插入顺序一样」,结果某天 MySQL 换了个执行计划(比如用了索引覆盖),顺序就变了------接口分页数据开始重复、丢失。

记住:只要顺序重要,就必须写 ORDER BY。

3.5.2 按分数升序
复制代码
select name, math from exam_result order by math;
+-----------+--------+
| name      | math   |
+-----------+--------+
| 宋公明     |     65 |
| 孙权       |     73 |
| 孙悟空     |     78 |
| 曹孟德     |     84 |
| 刘玄德     |     85 |
| 唐三藏     |     98 |
| 猪悟能     |     98 |
+-----------+--------+

注意最后两行:都是 98 分,谁在前谁在后? MySQL 不保证 ------这叫「排序不稳定」。如果业务要求「同分时按 id 排」,必须显式加第二排序键:

复制代码
order by math desc, id asc

⚠️ 这是分页的一个经典深坑 :如果排序不稳定,第 1 页和第 2 页可能重复出现同一条数据 ,或者某条数据永远不出现 。所以任何带分页的排序,最后都要加一个唯一键作为「兜底排序」(tiebreaker) ,通常是 id。

复制代码
-- 分页的正确姿势
select * from exam_result order by math desc, id asc limit 3 offset 0;
3.5.3 NULL 的排序位置
复制代码
-- NULL 视为比任何值都小,升序出现在最上面
select name, qq from students order by qq;
+-----------+-------+
| name      | qq    |
+-----------+-------+
| 唐大师     | NULL  |
| 孙仲谋     | NULL  |
| 曹阿瞒     | NULL  |
| 孙悟空     | 11111 |
+-----------+-------+

-- NULL 视为比任何值都小,降序出现在最下面
select name, qq from students order by qq desc;
+-----------+-------+
| name      | qq    |
+-----------+-------+
| 孙悟空     | 11111 |
| 唐大师     | NULL  |
| 孙仲谋     | NULL  |
| 曹阿瞒     | NULL  |
+-----------+-------+

关键结论:在 MySQL 的排序里,NULL 被视为「小于任何值」。

排序方向 NULL 的位置
ASC(升序) 最前面
DESC(降序) 最后面

这就带出一个非常常见的业务需求: 「我要按分数降序,但没成绩的排最后」------这在 MySQL 里恰好是默认行为。反过来「升序时把 NULL 排最后」就不行了,需要用技巧:

复制代码
-- 升序,但 NULL 排最后:用 isnull() 做第一排序键
select name, qq from students order by qq is null, qq;
-- isnull(qq)=0 的(有值)排前面,=1 的(NULL)排后面

-- 或者用 -qq(如果 qq 是数值)
select * from t order by -math;

顺带一句:Oracle 和 PostgreSQL 对 NULL 的排序默认相反 (认为 NULL 最大),而且都支持 NULLS FIRST / NULLS LAST 子句。MySQL 不支持 NULLS FIRST/LAST 语法(8.0 也没有),只能用上面的技巧。这是个跨数据库迁移时的经典坑。

3.5.4 多字段排序
复制代码
-- 多字段排序,排序优先级随书写顺序
select name, math, english, chinese from exam_result
order by math desc, english, chinese;
+-----------+--------+--------+-------+
| name      | math   | english | chinese |
+-----------+--------+--------+-------+
| 唐三藏     |     98 |     56 |    67 |
| 猪悟能     |     98 |     90 |    88 |
| 刘玄德     |     85 |     45 |    55 |
| 曹孟德     |     84 |     67 |    82 |
| 孙悟空     |     78 |     77 |    87 |
| 孙权       |     73 |     78 |    70 |
| 宋公明     |     65 |     30 |    75 |
+-----------+--------+--------+-------+

读懂这张表的关键,就在第一行:

  • 唐三藏和猪悟能 math 都是 98(第一排序键相同)
  • 那就比 english:唐三藏 56 < 猪悟能 90 → 唐三藏在前
  • 如果 english 也相同,再比 chinese

「排序优先级 = 书写顺序,从左到右,逐级决胜」。 这跟 Excel 的多条件排序、以及「先按省份再按城市」完全一样。

3.5.5 ORDER BY 中使用表达式和别名
复制代码
-- ORDER BY 中可以使用表达式
select name, chinese + english + math from exam_result
order by chinese + english + math desc;
+-----------+-------------------------+
| name      | chinese + english + math |
+-----------+-------------------------+
| 猪悟能     |                     276 |
| 孙悟空     |                     242 |
| 曹孟德     |                     233 |
| 唐三藏     |                     221 |
| 孙权       |                     221 |
| 刘玄德     |                     185 |
| 宋公明     |                     170 |
+-----------+-------------------------+

-- ORDER BY 子句中可以使用列别名
select name, chinese + english + math 总分 from exam_result
order by 总分 desc;
-- 结果完全相同

看到了吗?

WHERE 里不能用别名,但 ORDER BY 里可以!

这就是 3.4.6 那个问题的另一面。原因还是执行顺序------ORDER BY 在 SELECT 之后执行,那时候别名已经诞生了。

这一条我们会在第 9 节用一张执行流程图彻底锁死。

3.5.6 WHERE + ORDER BY 组合
复制代码
-- 结合 WHERE 子句 和 ORDER BY 子句
select name, math from exam_result
where name like '孙%' or name like '曹%'
order by math desc;
+-----------+--------+
| name      | math   |
+-----------+--------+
| 曹孟德     |     84 |
| 孙悟空     |     78 |
| 孙权       |     73 |
+-----------+--------+

书写顺序是死的:WHERE 必须在 ORDER BY 前面。

复制代码
-- ❌ 语法错误
select * from t order by math desc where chinese > 60;

-- ✅ 正确
select * from t where chinese > 60 order by math desc;

为什么?因为这恰好就是执行顺序:先筛(WHERE),再排(ORDER BY)。

一条性能建议 :能放在 WHERE 里的过滤条件,绝不要放到 ORDER BY 之后(后者根本做不到);能早过滤就早过滤------先把 100 万行缩到 100 行再排序,和排完 100 万行再取 100 行,性能差几个数量级。

3.5.7 补全:ORDER BY 的两个性能杀手

① ORDER BY RAND():随机取 N 条

复制代码
-- 「随机推荐 10 个商品」的常见写法
select * from goods order by rand() limit 10;
-- 大表上会把 MySQL 拖死:需要给全表每一行算一次随机数,再整体排序

替代方案:

复制代码
-- 方案一:先随机取一个 id 范围,再按范围查(有偏但够快)
select * from goods where id >= (select floor(rand() * (select max(id) from goods))) limit 10;

-- 方案二:应用层随机(先 count,再 offset 随机值)
select count(*) from goods;   -- 得 N
select * from goods limit ?, 10;  -- 传随机 offset(深分页慢,但小表够用)

-- 方案三:预生成一个随机列 random_key,每次取 > 随机数的前 10 条(均匀且快)

② ORDER BY 导致 filesort

如果排序用不上索引(比如 order by 的列没有索引、或者 ASC/DESC 混用、或者排序的列不是索引前缀),MySQL 会把结果放进临时文件排序(Using filesort),数据量大时直接落磁盘。

复制代码
explain select * from exam_result order by math desc;
-- 看 Extra 列:出现 Using filesort 就要警惕

本篇不深入索引,但你要有一个意识:ORDER BY 不是免费的。

3.6 筛选分页结果:LIMIT

3.6.1 三种写法
复制代码
-- ① 从 s 开始,筛选 n 条结果(起始下标为 0)
SELECT ... FROM table_name [WHERE ...] [ORDER BY ...] LIMIT s, n;

-- ② 从 0 开始,筛选 n 条结果
SELECT ... FROM table_name [WHERE ...] [ORDER BY ...] LIMIT n;

-- ③ 从 s 开始,筛选 n 条结果,比第二种用法更明确,建议使用
SELECT ... FROM table_name [WHERE ...] [ORDER BY ...] LIMIT n OFFSET s;

三种写法对照:

写法 含义 推荐度
limit 3 前 3 条(等价 limit 0, 3) ✅
limit 0, 3 从第 0 条开始取 3 条 ⚠️ 容易和「取 0 到 3」混淆
limit 3 offset 0 取 3 条,跳过 0 条 ⭐ 最清晰,推荐

为什么推荐 LIMIT n OFFSET s? 因为 LIMIT s, n 的参数顺序是反直觉 的(先「跳过几条」再「取几条」),很多人会写成 limit 3, 0,结果取不到数据还以为出错了。

课件还给了个非常实用的建议:

建议:对未知表进行查询时,最好加一条 LIMIT 1,避免因为表中数据过大,查询全表数据导致数据库卡死。

这个习惯值得养成。 我补充两个更狠的做法:

复制代码
-- ① 连数据都不取,只看表结构(最快)
select * from 未知表 limit 0;

-- ② 只看执行计划,不真的查
explain select * from 未知表;

-- ③ 想知道有没有数据、有多少,别 select *
select count(*) from 未知表;
3.6.2 分页实战:每页 3 条
复制代码
-- 第 1 页
select id, name, math, english, chinese from exam_result
order by id limit 3 offset 0;
+----+-----------+--------+--------+-------+
| id | name      | math   | english | chinese |
+----+-----------+--------+--------+-------+
|  1 | 唐三藏     |     98 |     56 |    67 |
|  2 | 孙悟空     |     78 |     77 |    87 |
|  3 | 猪悟能     |     98 |     90 |    88 |
+----+-----------+--------+--------+-------+

-- 第 2 页
select id, name, math, english, chinese from exam_result
order by id limit 3 offset 3;
+----+-----------+--------+--------+-------+
| id | name      | math   | english | chinese |
+----+-----------+--------+--------+-------+
|  4 | 曹孟德     |     84 |     67 |    82 |
|  5 | 刘玄德     |     85 |     45 |    55 |
|  6 | 孙权       |     73 |     78 |    70 |
+----+-----------+--------+--------+-------+

-- 第 3 页,如果结果不足 3 个,不会有影响
select id, name, math, english, chinese from exam_result
order by id limit 3 offset 6;
+----+-----------+--------+--------+-------+
| id | name      | math   | english | chinese |
+----+-----------+--------+--------+-------+
|  7 | 宋公明     |     65 |     30 |    75 |
+----+-----------+--------+--------+-------+

第 3 页只有 1 条,但 MySQL 不报错------这符合「取最多 n 条」的语义。

分页的通用公式:

复制代码
-- 第 page 页,每页 size 条(page 从 1 开始)
select ... from ... order by ... limit size offset (page - 1) * size;
3.6.3 补全(重点):深分页的性能灾难与优化

课件没讲,但这是后端面试必考的一道题。

复制代码
-- 「第 10 万页」,offset = 999990
select * from orders order by id limit 10 offset 999990;

为什么慢? 因为 MySQL 的 OFFSET 不是「跳过去」,而是「一条条读出来,然后扔掉」:

复制代码
扫描 1,000,000 行
   ↓
丢弃前 999,990 行   ← 这 100 万次读取全是白干的
   ↓
返回最后 10 行

优化方案一:游标分页(推荐,最快)

不要传「第几页」,而是传「上一页最后一条的 id」:

复制代码
-- 第一页
select id, name from orders order by id limit 10;

-- 下一页:把上一页最后一条的 id 传进来(假设是 1000)
select id, name from orders where id > 1000 order by id limit 10;

优点 :走索引范围扫描,永远只读 10 行,跟第几页无关 。 缺点:不能「跳到第 N 页」(但这恰恰是「下一页」场景的合理取舍)。

优化方案二:延迟关联(deferred join)

必须支持页码跳转时的经典写法:

复制代码
-- ❌ 慢:回表 100 万次
select * from orders order by id limit 10 offset 999990;

-- ✅ 快:先在索引上跳过,再回表取 10 行
select o.* from orders o
join (select id from orders order by id limit 10 offset 999990) t
  on o.id = t.id;

原理 :子查询 select id ... 只需要 id 一列,可以走覆盖索引(不回表),所以「丢弃 99 万行」的代价从「读 99 万次磁盘」降到「扫 99 万次索引页」。虽然还是慢,但快了一个数量级。

优化方案三:业务上限制最大页数

淘宝、京东的商品列表,你翻到 100 页之后通常是「只展示前 N 件」,这不是偷懒,是工程上的正确取舍。

方案 适用场景 性能
limit offset 普通分页 页码少、数据量小 🐢 深分页崩塌
延迟关联 必须支持页码跳转 🐇
游标分页(where id > ?) 无限滚动、导出、日志 🚀 最快
限制最大页数 商品列表、搜索结果 🚀

4. Update:更新数据

4.1 语法

复制代码
UPDATE table_name SET column = expr [, column = expr ...]
    [WHERE ...] [ORDER BY ...] [LIMIT ...];

UPDATE 的语法和 SELECT 几乎一样,只多了 SET。 这也意味着:你在 SELECT 里学过的 WHERE / ORDER BY / LIMIT,在 UPDATE 里全部适用。

4.2 单列更新

复制代码
-- 查看原数据
select name, math from exam_result where name = '孙悟空';
+-----------+--------+
| name      | math   |
+-----------+--------+
| 孙悟空     |     78 |
+-----------+--------+

-- 数据更新
update exam_result set math = 80 where name = '孙悟空';
-- Query OK, 1 row affected (0.04 sec)
-- Rows matched: 1  Changed: 1  Warnings: 0

-- 查看更新后数据
select name, math from exam_result where name = '孙悟空';
+-----------+--------+
| name      | math   |
+-----------+--------+
| 孙悟空     |     80 |
+-----------+--------+

注意那行 Rows matched: 1 Changed: 1,我要专门讲:

字段 含义
Rows matched WHERE 匹配到了几行
Changed 其中真正被修改了几行
复制代码
-- 如果更新的值和原值一样呢?
update exam_result set math = 80 where name = '孙悟空';
-- Query OK, 0 rows affected
-- Rows matched: 1  Changed: 0     ← 匹配到 1 行,但没变化

这个区别非常重要:

  • 如果你用 Changed 判断「操作是否成功」,会误判(更新成相同值时 Changed=0)
  • 正确做法是判断 Rows matched,或者干脆在更新前先查一次

UPDATE 的三步走「肌肉记忆」:

复制代码
-- ① 先查
select * from exam_result where name = '孙悟空';
-- ② 再改(把①的 where 原样搬过来)
update exam_result set math = 80 where name = '孙悟空';
-- ③ 再查验证
select * from exam_result where name = '孙悟空';

这看起来笨,但能救你无数次。 尤其是第 ② 步------把第 ① 步验证过的 WHERE 直接复制过来,是防止「忘写 WHERE」最有效的习惯。

4.3 一次更新多个列

复制代码
-- 查看原数据
select name, math, chinese from exam_result where name = '曹孟德';
+-----------+--------+-------+
| name      | math   | chinese |
+-----------+--------+-------+
| 曹孟德     |     84 |    82 |
+-----------+--------+-------+

-- 数据更新
update exam_result set math = 60, chinese = 70 where name = '曹孟德';
-- Query OK, 1 row affected (0.14 sec)
-- Rows matched: 1  Changed: 1  Warnings: 0

-- 查看更新后数据
select name, math, chinese from exam_result where name = '曹孟德';
+-----------+--------+-------+
| name      | math   | chinese |
+-----------+--------+-------+
| 曹孟德     |     60 |    70 |
+-----------+--------+-------+

语法要点:多个 SET 子句用逗号连接,不是用 AND!

复制代码
-- ❌ 错误
update t set a = 1 and b = 2 where id = 1;   -- a 会被设成一个布尔表达式的值

-- ✅ 正确
update t set a = 1, b = 2 where id = 1;

⚠️ 注意上面的错误写法:set a = 1 and b = 2 不会报错 !它会把 1 and 2 的结果(1)赋给 a,b 完全没变。这是极其隐蔽的 bug。

教训 :UPDATE 的 SET 用逗号,WHERE 的多个条件用 AND。别搞混。

4.4 基于原值更新:math = math + 30

复制代码
-- 查看原数据(总成绩倒数前三)
-- 别名可以在 ORDER BY 中使用
select name, math, chinese + math + english 总分 from exam_result
order by 总分 limit 3;
+-----------+--------+--------+
| name      | math   | 总分   |
+-----------+--------+--------+
| 宋公明     |     65 |    170 |
| 刘玄德     |     85 |    185 |
| 曹孟德     |     60 |    197 |
+-----------+--------+--------+

-- 数据更新,不支持 math += 30 这种语法
update exam_result set math = math + 30
order by chinese + math + english limit 3;

课件那句注释很关键:

不支持 math += 30 这种语法

MySQL 没有 复合赋值运算符。你必须写成 math = math + 30。

💡 有趣的对比 :math += 30 这种写法 SQL Server 是支持的(T-SQL 扩展),但 MySQL、Oracle、PostgreSQL 都不支持。所以从 SQL Server 转过来的人很容易在这里翻车。

而且注意性能含义 :math = math + 30 里的 math 是旧值。MySQL 保证赋值时用的是「更新前」的值,所以不会出现「+30 之后又被 +30」的链式错误。

4.4.1 课件留的那个「思考」,答案是什么?

课件在这里写了一句:

-- 查看更新后数据 -- 思考:这里还可以按总分升序排序取前 3 个么?

复制代码
-- 更新后的数据
select name, math, chinese + math + english 总分 from exam_result
where name in ('宋公明', '刘玄德', '曹孟德');
+-----------+--------+--------+
| name      | math   | 总分   |
+-----------+--------+--------+
| 曹孟德     |     90 |    227 |
| 刘玄德     |    115 |    215 |
| 宋公明     |     95 |    200 |
+-----------+--------+--------+

-- 再按总成绩排序取前 3
select name, math, chinese + math + english 总分 from exam_result
order by 总分 limit 3;
+-----------+--------+--------+
| name      | math   | 总分   |
+-----------+--------+--------+
| 宋公明     |     95 |    200 |
| 刘玄德     |    115 |    215 |
| 唐三藏     |     98 |    221 |   ← 变成了唐三藏!
+-----------+--------+--------+

答案是:不能了!

原因:UPDATE ... ORDER BY ... LIMIT 是「先按旧值选出行,再更新」:

  1. 更新前,总分最低的三个是:宋公明(170)、刘玄德(185)、曹孟德(197)
  2. 给他们三个各 +30 → 总分变成 200、215、227
  3. 但唐三藏的总分是 221 (没被加),现在反而比宋公明和刘玄德低了

所以再按总分升序取前 3,就变成了「宋公明(200)、刘玄德(215)、唐三藏(221)」------集合变了。

这就是「更新会改变筛选依据」的经典陷阱。 它有一个更广的形态:

任何「先用条件选中一批数据,再修改这些数据的某个字段,而这个字段本身又参与了选中条件」的操作,都是反直觉的。

update t set score = score * 2 where score < 60; 这种还安全(因为更新只是让它们更"满足"条件)。但像本节这样「+30 之后排名变了」的,就必须分两步走:

复制代码
-- ✅ 正确做法:先把目标行「钉住」,再更新
-- 用一张临时表或派生表固定住 id
update exam_result set math = math + 30
where id in (
    select id from (
        select id from exam_result order by chinese + math + english limit 3
    ) t
);

⚠️ 注意上面为什么要套一层 select id from (...) t------因为 MySQL 不允许在 UPDATE 的子查询里直接引用正在被更新的表 (报 ERROR 1093)。套一层派生表(derived table)会让 MySQL 先把结果物化成临时表,从而绕过限制。这是 6.5 节去重时也会用到的同一个技巧。

4.4.2 补全:UPDATE ... ORDER BY ... LIMIT 的复制风险

UPDATE ... ORDER BY ... LIMIT 是 MySQL 的扩展语法(标准 SQL 没有)。它有一个隐患:

如果 binlog_format = STATEMENT(老版本默认),主库按「排序取前 3」更新,从库重放时由于数据/执行计划可能不同,排序结果可能不一样,导致主从数据不一致。

MySQL 会给你一条 warning:

复制代码
Unsafe statement written to the binary log using statement format
since BINLOG_FORMAT = STATEMENT. Statements writing to a table with an
AUTO_INCREMENT column after another table with a AUTO_INCREMENT column ...

实践建议 :生产环境不要用 UPDATE ... LIMIT,改用「先 select 出 id 列表 → 按 id 批量 update」两步走。这样既能保证幂等,也能保证主从一致。

4.5 更新全表(最危险的动作)

复制代码
-- 注意:更新全表的语句慎用!
-- 没有 WHERE 子句,则更新全表

-- 查看原数据
select * from exam_result;
...
-- 数据更新
update exam_result set chinese = chinese * 2;
-- Query OK, 7 rows affected (0.00 sec)
-- Rows matched: 7  Changed: 7  Warnings: 0

-- 查看更新后数据
select * from exam_result;
+----+-----------+-------+--------+--------+
| id | name      | chinese | math | english |
+----+-----------+-------+--------+--------+
|  1 | 唐三藏     |   134 |     98 |     56 |
|  2 | 孙悟空     |   174 |     80 |     77 |
|  3 | 猪悟能     |   176 |     98 |     90 |
|  4 | 曹孟德     |   140 |     90 |     67 |
|  5 | 刘玄德     |   110 |    115 |     45 |
|  6 | 孙权       |   140 |     73 |     78 |
|  7 | 宋公明     |   150 |     95 |     30 |
+----+-----------+-------+--------+--------+

「忘写 WHERE」是所有数据库事故里最常见的一类。 我给你三道防线:

防线 ①:开 sql_safe_updates(最实用的习惯)

复制代码
set sql_safe_updates = 1;

-- 现在想更新全表会被拦住
update exam_result set chinese = 0;
-- ERROR 1175 (HY000): You are using safe update mode and you tried to update a table
-- without a WHERE that uses a KEY column

-- 必须显式加 WHERE(哪怕是 WHERE 1=1)才能全表更新
update exam_result set chinese = 0 where id > 0;

也可以在连库时加 --safe-updates 参数。强烈建议每个开发者的本地和测试库都开这个。

防线 ②:先 select 验证,再「改 select 为 update」

复制代码
-- ① 先用 select 确认影响范围
select count(*) from exam_result where chinese < 60;
-- ② 确认无误后,把 select 换成 update(WHERE 原样保留)
update exam_result set chinese = chinese + 10 where chinese < 60;

防线 ③:事务 + 备份

复制代码
start transaction;
update exam_result set chinese = 0;      -- 干坏事
select * from exam_result;                -- 立刻检查
rollback;                                  -- 发现不对,回滚

⚠️ 但要注意 :事务能回滚,前提是你在事务里,且用的是 InnoDB 。很多人在 Navicat 的「自动提交」模式下直接执行,commit 早就发生了,回滚不了。

4.6 补全:UPDATE 的四个常见坑

坑 现象 正确做法
① 忘写 WHERE 全表被改 开 sql_safe_updates,先查后改
② SET a = 1 and b = 2 只改了 a,且值是布尔结果 多列用逗号
③ 用 = NULL 清空 set qq = null 是对的;但 where qq = null 查不到行 清空用 = NULL,查询用 IS NULL
④ 隐式类型转换 where varchar_col = 123 可能导致索引失效 + 类型错误 值和列类型保持一致

第 ④ 条值得展开,因为它同时影响 UPDATE 和 SELECT:

复制代码
-- phone 是 varchar(20),存的是 '13800138000'
where phone = 13800138000       -- ⚠️ 右边是数字,MySQL 会把 varchar 列转成数字比较
                                 -- 结果:索引失效(全表扫描)+ 精度可能丢失

where phone = '13800138000'     -- ✅ 字符串对字符串

这条规则叫「类型隐式转换」。 反过来也有:

复制代码
-- id 是 int,你写 where id = '123abc'
where id = '123abc'
-- MySQL 会截取前缀 '123' 转成 123 来比较 ------ 竟然能匹配上 id=123 的行!

这种「能跑但跑错了」的 SQL,比报错还可怕。


5. Delete:删除数据

5.1 删除部分数据

复制代码
DELETE FROM table_name [WHERE ...] [ORDER BY ...] [LIMIT ...];

-- 查看原数据
select * from exam_result where name = '孙悟空';
+----+-----------+-------+--------+--------+
| id | name      | chinese | math | english |
+----+-----------+-------+--------+--------+
|  2 | 孙悟空     |   174 |     80 |     77 |
+----+-----------+-------+--------+--------+

-- 删除数据
delete from exam_result where name = '孙悟空';
-- Query OK, 1 row affected (0.17 sec)

-- 查看删除结果
select * from exam_result where name = '孙悟空';
Empty set (0.00 sec)

DELETE 的语法结构和 UPDATE 几乎一样,同样支持 WHERE / ORDER BY / LIMIT。

5.2 删除整张表的数据

复制代码
-- 注意:删除整表操作要慎用!

-- 准备测试表
create table for_delete (
    id int primary key auto_increment,
    name varchar(20)
);

-- 插入测试数据
insert into for_delete (name) values ('A'), ('B'), ('C');
select * from for_delete;
+----+------+
| id | name |
+----+------+
|  1 | A    |
|  2 | B    |
|  3 | C    |
+----+------+

-- 删除整表数据
delete from for_delete;
-- Query OK, 3 rows affected (0.00 sec)

select * from for_delete;
Empty set (0.00 sec)

关键的来了------再插一条数据看看:

复制代码
-- 再插入一条数据,自增 id 在原值上增长
insert into for_delete (name) values ('D');
select * from for_delete;
+----+------+
| id | name |
+----+------+
|  4 | D    |   ← id 是 4,不是 1!
+----+------+

-- 查看表结构,会有 AUTO_INCREMENT=n 项
show create table for_delete\G
*************************** 1. row ***************************
       Table: for_delete
Create Table: CREATE TABLE `for_delete` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8

🔗 前后照应 :AUTO_INCREMENT=5 这一行,就是上一篇第 7.6 节那句话的实物证据:

delete 不重置自增,truncate 才重置。

上一篇我只给了结论,这一篇你自己用 delete 验证了一遍。这就是「学」和「会」的区别。

5.3 截断表:TRUNCATE

复制代码
TRUNCATE [TABLE] table_name

课件给了三条注意事项,一条比一条重要:

注意:这个操作慎用

  1. 只能对整表操作,不能像 DELETE 一样针对部分数据操作;
  2. 实际上 MySQL 不对数据操作,所以比 DELETE 更快,但是 TRUNCATE 在删除数据的时候,并不经过真正的事务,所以无法回滚;
  3. 会重置 AUTO_INCREMENT 项。
复制代码
-- 准备测试表
create table for_truncate (
    id int primary key auto_increment,
    name varchar(20)
);
insert into for_truncate (name) values ('A'), ('B'), ('C');

select * from for_truncate;
+----+------+
| id | name |
+----+------+
|  1 | A    |
|  2 | B    |
|  3 | C    |
+----+------+

-- 截断整表数据,注意影响行数是 0,所以实际上没有对数据真正操作
truncate for_truncate;
-- Query OK, 0 rows affected (0.10 sec)     ← 注意是 0 rows!

select * from for_truncate;
Empty set (0.00 sec)

-- 再插入一条数据,自增 id 在重新增长
insert into for_truncate (name) values ('D');
select * from for_truncate;
+----+------+
| id | name |
+----+------+
|  1 | D    |   ← id 回到 1 了!
+----+------+

-- 查看表结构,会有 AUTO_INCREMENT=2 项
show create table for_truncate\G
...
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8

注意那个 Query OK, 0 rows affected ------这是 TRUNCATE 的本质:它不是在「删数据」,而是在「重建表」 (内部相当于 DROP TABLE + CREATE TABLE),所以 MySQL 连删了几行都懒得数给你。

5.4 补全:DELETE / TRUNCATE / DROP 终极对比

这张表请你收藏,面试和工作都用得上:

对比项 DELETE TRUNCATE DROP
语句类型 DML(数据操作) DDL(定义操作) DDL
删除范围 部分或全部行 只能整表 整张表(结构+数据)
能否加 WHERE ✅ 可以 ❌ 不行 ❌ 不行
能否回滚 ✅ 可以(在事务中) ❌ 隐式提交,不可回滚 ❌ 不可回滚
自增计数器 保留(继续增长) 重置为 1 随表消失
影响行数 返回删了几行 返回 0 返回 0
触发器 ✅ 触发 DELETE 触发器 ❌ 不触发 ❌ 不触发
速度 🐢 慢(逐行删 + 写日志) 🚀 快(重建表) 🚀 快
磁盘空间 不释放 释放 释放
权限要求 DELETE DROP DROP
外键引用时 可删(无引用行) 报 1701,不能截断 需先删外键

几个关键补充:

① DELETE 不释放磁盘空间,这是很多人不知道的

复制代码
-- 一张 10GB 的表,delete 掉 9GB 数据
delete from big_table where id < 900000000;

-- 查一下文件大小:还是 10GB!
-- 因为 InnoDB 只把空间标记为「可复用」,并不还给操作系统

要做空间回收,得重建表:

复制代码
optimize table big_table;
-- 或(在线重建,8.0+ 支持)
alter table big_table engine = innodb;

② TRUNCATE 遇到外键会失败

复制代码
truncate table myclass;
-- ERROR 1701 (42000): Cannot truncate a table referenced in a foreign key constraint

🔗 前后照应 :还记得上一篇第 9 节的外键吗?stu 表引用了 myclass.id。这时你 truncate myclass 会被拒绝------外键又一次替你挡住了误操作。

但反过来,DELETE FROM myclass 在子表有引用时也会被拒绝(ERROR 1451)。要清空有关联的两张表,必须先删子表、再删主表。

③ 补全:DELETE 的双表语法(级联删除的替代方案)

复制代码
-- 删除 stu 中所有属于「已撤销班级」的记录
delete s from stu s
join myclass c on s.class_id = c.id
where c.status = 0;

这是 DELETE 的多表语法(MySQL 扩展),可以绕过外键的级联限制,精确控制删哪些行。

④ 补全:生产环境请用「逻辑删除」

物理删除(真的 DELETE)在业务系统里其实是少见的。主流做法是加一个字段:

复制代码
alter table students add column is_deleted tinyint not null default 0 comment '0-正常 1-已删除';
alter table students add column deleted_at datetime null comment '删除时间';

-- 「删除」其实是更新
update students set is_deleted = 1, deleted_at = now() where id = 100;

-- 所有查询都要带上过滤条件
select * from students where is_deleted = 0;

为什么要这么麻烦? 因为:

需求 物理删除 逻辑删除
误删恢复 ❌ 只能靠备份 ✅ 改个字段就回来了
数据审计 ❌ 记录没了 ✅ 历史都在
关联数据 ❌ 外键炸 / 脏数据 ✅ 关系不断
存储成本 ✅ 省空间 ❌ 要一直存着

代价是 :所有查询都得记得加 where is_deleted = 0------所以很多团队会封装成 @Where(clause = "is_deleted = 0")(MyBatis-Plus)之类的注解,或者直接用数据库视图。


6. 插入查询结果:一场「原子去重」的漂亮仗

这一节是整篇课件里设计最精巧的一段,值得单独拿出来讲。

6.1 场景

案例:删除表中的重复记录,重复的数据只能有一份。

复制代码
-- 创建原数据表
create table duplicate_table (id int, name varchar(20));

-- 插入测试数据
insert into duplicate_table values
(100, 'aaa'),
(100, 'aaa'),
(200, 'bbb'),
(200, 'bbb'),
(200, 'bbb'),
(300, 'ccc');
-- Query OK, 6 rows affected

select * from duplicate_table;
+------+------+
| id   | name |
+------+------+
|  100 | aaa  |
|  100 | aaa  |
|  200 | bbb  |
|  200 | bbb  |
|  200 | bbb  |
|  300 | ccc  |
+------+------+

需求:把 6 行变成 3 行(每个 (id, name) 组合只留一份)。

6.2 语法:INSERT ... SELECT

复制代码
INSERT INTO table_name [(column [, column ...])] SELECT ...

它把「查询结果」直接当成「插入的数据源」。 于是「去重」这件事就变成了:

把 select distinct ... 的结果,插进一张空表。

6.3 四步走

第一步:建一张结构相同的空表

复制代码
-- 创建一张空表 no_duplicate_table,结构和 duplicate_table 一样
create table no_duplicate_table like duplicate_table;
-- Query OK, 0 rows affected

CREATE TABLE ... LIKE 会复制什么?

  • ✅ 列定义(类型、NOT NULL、DEFAULT、COMMENT)
  • ✅ 索引(主键、唯一键、普通索引)
  • ❌ 不复制数据
  • ❌ 不复制外键(这是个坑,很多人以为会复制)

第二步:把去重后的数据插进去

复制代码
-- 将 duplicate_table 的去重数据插入到 no_duplicate_table
insert into no_duplicate_table select distinct * from duplicate_table;
-- Query OK, 3 rows affected
-- Records: 3  Duplicates: 0  Warnings: 0

第三步:原子地重命名两张表

复制代码
-- 通过重命名表,实现原子的去重操作
rename table duplicate_table to old_duplicate_table,
             no_duplicate_table to duplicate_table;
-- Query OK, 0 rows affected

第四步:查看结果

复制代码
select * from duplicate_table;
+------+------+
| id   | name |
+------+------+
|  100 | aaa  |
|  200 | bbb  |
|  300 | ccc  |
+------+------+

6 行变 3 行,干净利落。

6.4 为什么要「重命名」,而不是「删了再插」?

这是全节最精妙的地方。对比一下两种思路:

❌ 笨办法:先删后插

复制代码
delete from duplicate_table;                        -- 表空了(数据没了!)
insert into duplicate_table select distinct * from duplicate_table;   -- 没数据可查了

自相矛盾------你删完之后,源数据就没了。

❌ 次笨办法:删掉重复行(用子查询)

复制代码
delete from duplicate_table
where id not in (select min(id) from duplicate_table group by id, name);
-- ERROR 1093 (HY000): You can't specify target table 'duplicate_table' for update in FROM clause

直接在子查询里引用正在被删的表,MySQL 不允许。

✅ 巧办法:建新表 + 原子改名

复制代码
rename table a to b, c to a;

这一条 RENAME TABLE 语句是原子的 :要么全部成功,要么全部失败。在改名的那一瞬间,外部的查询不会看到「表不存在」的中间态。

这直接决定了这个方案可以在生产环境跑,而且不锁业务:

特点 说明
原子性 不会出现「旧表删了、新表还没好」的空窗期
可回滚 老数据还完整地躺在 old_duplicate_table 里,出问题一条 rename 就回来了
低阻塞 insert ... select 期间,原表仍可读(取决于隔离级别)
可验证 改名之前可以 select count(*) 对比新旧表行数

这就是「数据库操作要留退路」的教科书级示范。

6.5 补全:另外三种去重方案,以及什么时候用哪种

方案 A:DELETE + 派生表(不建新表)

复制代码
-- 保留每组最小的 id,删掉其他
delete from duplicate_table
where id not in (
    select min_id from (
        select min(id) as min_id from duplicate_table group by id, name
    ) t
);

注意那个 select ... from (...) t------套一层派生表,让 MySQL 先把结果物化,从而绕开 1093 限制 。这和第 4.4.1 节那个 update ... where id in (...) 用的是同一个技巧。

⚠️ 但这个方案有个致命 bug :如果 id 本身有重复(本例中 100 出现了两次!),not in (min_id) 会把两行 100 都删掉,导致 100 组合一行不剩。

复制代码
-- 本例实际执行结果(错误!)
select * from duplicate_table;
+------+------+
| id   | name |
+------+------+
|  200 | bbb  |   ← 100 和 300 的全没了
+------+------+

这说明:去重前必须有一个「行唯一标识」 (比如自增主键),否则 min(id) 这种思路不成立。本例的 duplicate_table 恰好没有主键------这就是课件为什么选择「建新表」而不是「删除重复行」的深层原因。

方案 B:加唯一索引 + INSERT IGNORE

复制代码
-- ① 建新表并加唯一索引
create table tmp like duplicate_table;
alter table tmp add unique key uk_id_name (id, name);

-- ② 用 INSERT IGNORE 忽略重复
insert ignore into tmp select * from duplicate_table;

-- ③ 原子改名
rename table duplicate_table to old_duplicate_table, tmp to duplicate_table;

这个方案更好 ,因为它顺便给表加上了唯一约束 ------从源头上防止以后再出现重复。这就是上一篇讲的「约束是长期收益」的又一次验证。

方案 C:窗口函数(MySQL 8.0+)

复制代码
-- 用 row_number() 给每组打编号,只保留 rn=1 的
delete from duplicate_table
where id in (
    select id from (
        select id, row_number() over (partition by id, name order by id) as rn
        from duplicate_table
    ) t where t.rn > 1
);

方案对比:

方案 需要主键 会加约束 MySQL 版本 推荐度
建新表 + 原子改名(课件方案) ❌ 不需要 ❌ 不加 5.x 都行 ⭐⭐⭐⭐
加唯一索引 + INSERT IGNORE ❌ 不需要 ✅ 加了 5.x 都行 ⭐⭐⭐⭐⭐
DELETE + 派生表 ✅ 必须有 ❌ 不加 5.x 都行 ⭐⭐
窗口函数 ✅ 必须有 ❌ 不加 8.0+ ⭐⭐⭐

我个人的选择 :优先方案 B(加唯一索引 + INSERT IGNORE)。因为它不仅解决了「这一次的重复」,还从数据库层面保证了「以后不会再有重复」。一次操作,两个收益。


7. 聚合函数

7.1 五个函数

函数 说明
COUNT([DISTINCT] expr) 返回查询到的数据的数量
SUM([DISTINCT] expr) 返回查询到的数据的总和,不是数字没有意义
AVG([DISTINCT] expr) 返回查询到的数据的平均值,不是数字没有意义
MAX([DISTINCT] expr) 返回查询到的数据的最大值,不是数字没有意义
MIN([DISTINCT] expr) 返回查询到的数据的最小值,不是数字没有意义

五个函数,一句话记住:COUNT 数个数,SUM 求和,AVG 求平均,MAX/MIN 求极值。

7.2 COUNT 的三种写法

复制代码
-- 使用 * 做统计,不受 NULL 影响
select count(*) from students;
+----------+
| COUNT(*) |
+----------+
|        4 |
+----------+

-- 使用表达式做统计
select count(1) from students;
+----------+
| COUNT(1) |
+----------+
|        4 |
+----------+

COUNT(*)、COUNT(1)、COUNT(列) 到底有什么区别? 这是面试高频题,我一次讲透:

写法 语义 是否统计 NULL 性能(InnoDB)
COUNT(*) 统计行数 NULL 行也算 最快(优化器选最小的二级索引扫描)
COUNT(1) 统计行数(1 是常量,非 NULL) NULL 行也算 和 COUNT(*) 完全等价
COUNT(列) 统计该列非 NULL 的个数 ❌ 不算 略慢(要判断每行该列是否为 NULL)
COUNT(DISTINCT 列) 统计该列去重后非 NULL 的个数 ❌ 不算 更慢(要排序去重)

来看课件的实证:

复制代码
-- 统计班级收集的 qq 号有多少
-- NULL 不会计入结果
select count(qq) from students;
+-----------+
| COUNT(qq) |
+-----------+
|         1 |
+-----------+

students 表有 4 行 ,但 count(qq) 只有 1 ------因为只有孙悟空一个人填了 QQ 号,另外三行 qq 是 NULL。

复制代码
-- 统计本次考试的数学成绩分数个数
-- COUNT(math) 统计的是全部成绩
select count(math) from exam_result;
+---------------+
| COUNT(math)   |
+---------------+
|             6 |
+---------------+

-- COUNT(DISTINCT math) 统计的是去重成绩数量
select count(distinct math) from exam_result;
+------------------------+
| COUNT(DISTINCT math)   |
+------------------------+
|                      5 |
+------------------------+

6 是「有成绩的人数」,5 是「不同分数的种类数」。 差的那 1 个,就是重复的 98 分。

工程建议 :统计行数永远用 COUNT(*) 。它是 SQL 标准写法,语义最清晰,而且 MySQL 对它有专门优化。COUNT(1) 虽然等价,但没有额外好处。

7.3 SUM / AVG / MAX / MIN

复制代码
-- 统计数学成绩总分
select sum(math) from exam_result;
+-----------+
| SUM(math) |
+-----------+
|       569 |
+-----------+

-- 不及格 < 60 的总分,没有结果,返回 NULL
select sum(math) from exam_result where math < 60;
+-----------+
| SUM(math) |
+-----------+
|      NULL |   ← 注意!是 NULL,不是 0
+-----------+

这个「没有结果时返回 NULL 而不是 0」,是聚合函数最经典的坑,也是面试必问:

情况 COUNT() SUM() / AVG() / MAX() / MIN()
没有任何匹配行 返回 0 返回 NULL

为什么这么设计? 因为「没有数据」和「总和是 0」是两回事:

  • 一个班一个人都没考 → 平均分是 NULL(因为根本没人考),不是 0(0 分是"考了 0 分")
  • 一个班有人考了 0 分 → 平均分是 0

这个区别在业务代码里会咬人:

复制代码
// ❌ 危险:如果是 NULL,会抛 NPE 或者被转成 0
BigDecimal avg = jdbcTemplate.queryForObject("select avg(score) from t where ...", BigDecimal.class);

// ✅ 正确做法:用 IFNULL / COALESCE 兜底
select ifnull(avg(score), 0) from t where ...;

7.4 三个进阶聚合例子

复制代码
-- 统计平均总分
select avg(chinese + math + english) 平均总分 from exam_result;
+--------------+
| 平均总分     |
+--------------+
|        297.5 |
+--------------+

-- 返回英语最高分
select max(english) from exam_result;
+-------------+
| MAX(english) |
+-------------+
|          90 |
+-------------+

-- 返回 > 70 分以上的数学最低分
select min(math) from exam_result where math > 70;
+-----------+
| MIN(math) |
+-----------+
|        73 |
+-----------+

三个值得注意的点:

① 聚合函数可以接受「表达式」

avg(chinese + math + english) ------ 先算每行的总分,再求平均。这等价于 select sum(chinese+math+english) / count(*)。

② WHERE 与聚合函数的配合

min(math) where math > 70 ------ 先过滤,再求极值。过滤发生在聚合之前,这一点在第 9 节讲执行顺序时会再强调。

③ 平均值的小数位

297.5 看起来很整齐,但如果结果是 297.3333333,通常需要格式化:

复制代码
select round(avg(chinese + math + english), 2) 平均总分 from exam_result;
-- 297.33

select format(avg(chinese + math + english), 2) 平均总分 from exam_result;
-- '297.33'(带千分位:1,234.56)

🔗 前后照应 :avg(...) 返回的 297.5 是浮点数 。上一篇我们讲过「金额千万别用 float/double」,这里是分数------分数可以容忍一点精度误差,金额不行 。如果你在做一个「学员平均分」的报表,round(avg(...), 2) 就够了;但如果是「订单平均客单价」,必须用 decimal 计算。

7.5 补全:聚合函数不能出现在 WHERE 里

复制代码
-- ❌ 报错
select name from exam_result where count(*) > 1;
-- ERROR 1111 (HY000): Invalid use of group function

为什么不行? 因为 WHERE 是「逐行过滤」,而 COUNT 是「整组统计」------执行时机根本对不上(WHERE 在 GROUP BY 之前执行,那时还没有「组」这个概念)。

正确做法:用 HAVING

复制代码
-- ✅ 正确:HAVING 在分组之后执行
select name, count(*) from exam_result group by name having count(*) > 1;

这就引出了下一节的主角。


8. GROUP BY 与 HAVING

8.1 语法与语义

在 select 中使用 group by 子句可以对指定列进行分组查询

复制代码
select column1, column2, .. from table group by column;

GROUP BY 的心智模型:

复制代码
原始数据(N 行)
    ↓ 按某个列的值「归类」
分组(K 个组,K ≤ N)
    ↓ 每个组「压缩」成一行
结果(K 行)

「分组」= 把相同值的行归到一起,「压缩」= 每组合并成一行。 所以:

  • group by deptno 之后,每个部门只剩一行
  • 那一行里的「部门号」是确定的,但「员工姓名」是不确定的(一个部门有多个员工,压缩时该留谁?)

这就解释了 8.5 节要讲的 ONLY_FULL_GROUP_BY。

8.2 准备工作:三张经典测试表

创建一个雇员信息表(来自 oracle 9i 的经典测试表):

  • EMP 员工表
  • DEPT 部门表
  • SALGRADE 工资等级表

这三张表是数据库界的「Hello World」,几乎所有教材都用它。核心字段:

表 关键字段
EMP empno(员工号)、ename(姓名)、job(岗位)、sal(工资)、deptno(部门号)
DEPT deptno(部门号)、dname(部门名)、loc(位置)
SALGRADE grade(等级)、losal(最低工资)、hisal(最高工资)

8.3 案例一:每个部门的平均工资和最高工资

复制代码
select deptno, avg(sal), max(sal) from EMP group by deptno;

读法:

  1. group by deptno → 按部门号把员工分成若干组
  2. 每组内 avg(sal) → 该部门的平均工资
  3. 每组内 max(sal) → 该部门的最高工资
  4. 每组输出一行

结果大概是这样的:

复制代码
+--------+----------+----------+
| deptno | avg(sal) | max(sal) |
+--------+----------+----------+
|     10 |  2916.67 |     5000 |
|     20 |  2175.00 |     3000 |
|     30 |  1566.67 |     2850 |
+--------+----------+----------+

8.4 案例二:每个部门的每种岗位的平均工资和最低工资

复制代码
select avg(sal), min(sal), job, deptno from EMP group by deptno, job;

注意这里的 group by deptno, job------多列分组。

语义:把「部门 + 岗位」当成一个复合 key 来分组。

复制代码
(10, 'CLERK')     → 一组
(10, 'MANAGER')   → 另一组
(20, 'CLERK')     → 又是另一组  ← 虽然 job 相同,但 deptno 不同
(30, 'SALESMAN')  → 又是另一组

多列分组的本质,和「复合主键」的思维完全一样:

🔗 前后照应 :上一篇讲复合主键时,报错里出现了 Duplicate entry '1-123'------「1-123」就是复合 key 的拼接 。这里的 (deptno, job) 分组,本质上是同一套「组合唯一性」的思路:单列可以重复,组合必须区分。

8.5 案例三:显示平均工资低于 2000 的部门和它的平均工资

这是本节的重点,因为它是两步走的:

复制代码
-- 第一步:统计各个部门的平均工资
select avg(sal) from EMP group by deptno;

第二步:对分组结果做过滤 ------ 这就需要 HAVING

复制代码
-- having 和 group by 配合使用,对 group by 结果进行过滤
select avg(sal) as myavg from EMP group by deptno having myavg < 2000;

课件那句注释很到位:

-- having 经常和 group by 搭配使用,作用是对分组进行筛选,作用有些像 where。

但「像」不等于「是」。下面是本节的灵魂对比。

8.6 WHERE vs HAVING(本节最重要)

对比项 WHERE HAVING
执行时机 分组之前 分组之后
过滤对象 行(一行一行筛) 组(一组一组筛)
能否用聚合函数 ❌ 不能 (ERROR 1111) ✅ 能(这就是它存在的理由)
能否用 SELECT 别名 ❌ 不能 (ERROR 1054) ✅ 能(MySQL 扩展)
能否独立使用 ✅ 可以(不需要 GROUP BY) ⚠️ 可以但没有意义(等价于 WHERE)
性能 🚀 好(早过滤,减少参与分组的行数) 🐢 差(先把所有行分好组,再筛掉)

最关键的两条,我用例子证明:

① WHERE 里不能用聚合函数

复制代码
-- ❌
select deptno from EMP where avg(sal) < 2000 group by deptno;
-- ERROR 1111 (HY000): Invalid use of group function

-- ✅
select deptno from EMP group by deptno having avg(sal) < 2000;

② HAVING 里可以用别名,WHERE 里不行

复制代码
-- ✅ HAVING 可以用别名(课件的例子)
select avg(sal) as myavg from EMP group by deptno having myavg < 2000;

-- ❌ WHERE 不能用别名
select chinese + math + english 总分 from exam_result where 总分 < 200;
-- ERROR 1054 (42S22): Unknown column '总分' in 'where clause'

-- ✅ HAVING 可以(配合 GROUP BY)
select name, chinese + math + english 总分 from exam_result
group by name having 总分 < 200;

这里有个需要说清楚的地方(很多博客讲错):

标准 SQL 的执行顺序里,HAVING 在 SELECT 之前 执行,按道理别名也不该可见。但 MySQL 特意做了扩展 :它允许 GROUP BY、HAVING、ORDER BY 引用 SELECT 列表中的别名,而 WHERE 绝对不行。

为什么 WHERE 一定不行? 因为在 WHERE 执行的那一刻,SELECT 列表根本还没被解析 ------总分 这个名字在 MySQL 眼里完全不存在。

一句话记住 : WHERE 筛「行」,HAVING 筛「组」。 能用 WHERE 就别用 HAVING,因为 WHERE 更早执行,能减少后面所有环节的数据量。

8.7 补全:ONLY_FULL_GROUP_BY(1055 报错)

这是 MySQL 5.7 之后默认开启的一个严格模式,也是新手最容易撞的一个报错:

复制代码
-- ❌ 5.7+ 默认配置下会报错
select deptno, ename, avg(sal) from EMP group by deptno;
-- ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause
-- and contains nonaggregated column 'test.EMP.ename' which is not functionally
-- dependent on columns in GROUP BY clause; this is incompatible with
-- sql_mode=only_full_group_by

为什么报错? 因为按 deptno 分组后,一个部门有多个员工,ename 有多个值------你让 MySQL 显示哪一个? 它决定不了,所以干脆报错。

这是好事(在 5.7 之前,它会「随便挑一个」给你,结果不可复现,极度危险)。

三种解法:

复制代码
-- ① 把非聚合列放进 GROUP BY(最正确)
select deptno, ename, avg(sal) from EMP group by deptno, ename;

-- ② 用聚合函数包起来(如果只是"随便取一个")
select deptno, max(ename), avg(sal) from EMP group by deptno;
-- 或 MySQL 的 any_value()
select deptno, any_value(ename), avg(sal) from EMP group by deptno;

-- ③ 关掉这个模式(❌ 强烈不推荐,治标不治本)
set session sql_mode = replace(@@sql_mode, 'ONLY_FULL_GROUP_BY', '');

我的态度:永远用方案 ①。 方案 ③ 是在给未来的自己挖坑------它在本地能跑,到了生产(默认开启)就炸。

8.8 补全:MySQL 8.0 移除了 GROUP BY 的隐式排序

这是一个版本差异大坑:

复制代码
-- MySQL 5.7 及之前:GROUP BY 会自动按分组列升序排列
-- MySQL 8.0 及之后:不再隐式排序,顺序未定义
select deptno, avg(sal) from EMP group by deptno;
版本 GROUP BY 后的顺序
5.7 及以前 默认按分组列 ASC 排序(隐式排序)
8.0 起 不保证任何顺序(隐式排序被移除)

影响 :很多老代码依赖「group by 之后就是有序的」,升级到 8.0 后结果顺序变了,如果有分页或者「取第一个」的逻辑,直接出 bug。

解决 :永远显式写 ORDER BY。

复制代码
select deptno, avg(sal) from EMP group by deptno order by deptno;

这也再次印证了 3.5.1 那句铁律:「没有 ORDER BY 子句的查询,返回的顺序是未定义的」 ------GROUP BY 同样不例外。

8.9 补全:WITH ROLLUP(小计与总计)

复制代码
select deptno, job, avg(sal) from EMP group by deptno, job with rollup;

WITH ROLLUP 会在分组结果后面追加「小计行」和「总计行」:

复制代码
+--------+----------+----------+
| deptno | job      | avg(sal) |
+--------+----------+----------+
|     10 | CLERK    |  1300.00 |
|     10 | MANAGER  |  2450.00 |
|     10 | NULL     |  1875.00 |  ← 10 部门小计
|     20 | CLERK    |   950.00 |
|     20 | ANALYST  |  3000.00 |
|     20 | NULL     |  1975.00 |  ← 20 部门小计
|   NULL | NULL     |  1925.00 |  ← 全公司总计
+--------+----------+----------+

做报表时非常有用,能省掉一轮「再查一次总计」的 SQL。

⚠️ 但注意:WITH ROLLUP 会产生 NULL 行,如果原数据里本来就有 NULL,会分不清「是真实数据还是小计」 。生产报表里建议配合 grouping() 函数(8.0+)区分。


9. SQL 关键字的真实执行顺序(面试必考)

课件最后一页留了这么一行:

面试题:SQL 查询中各个关键字的执行先后顺序 from > on > join > where > group by > with > having > select > distinct > order by > limit

这一行只写了结论,但没有解释为什么。而恰恰是「为什么」,决定了你能不能在任何场合下推出正确答案。

9.1 完整执行顺序

复制代码
⑨ LIMIT
⑧ ORDER BY
⑦ DISTINCT
⑥ SELECT           ← 到这里才产生「别名」
⑤ HAVING
④ GROUP BY
③ WHERE
② ON / JOIN
① FROM

从下往上执行。

写成线性顺序:

复制代码
FROM → ON → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT

(UNION 在所有之后;窗口函数在 SELECT 阶段求值,但在 DISTINCT 和 ORDER BY 之前。)

9.2 八个「为什么」,全部用执行顺序解释

# 问题 答案
1 为什么 WHERE 不能用 SELECT 的别名? WHERE(③)在 SELECT(⑥)之前执行,别名根本还不存在
2 为什么 ORDER BY 能用别名? ORDER BY(⑧)在 SELECT(⑥)之后,别名已经有了
3 为什么 HAVING 能用别名? HAVING(⑤)虽在 SELECT 前,但 MySQL 做了别名解析扩展
4 为什么 WHERE 里不能用聚合函数? 聚合发生在 GROUP BY(④)之后,WHERE(③)时还没有「组」
5 为什么 HAVING 里能用聚合函数? HAVING(⑤)在 GROUP BY(④)之后,已经有分组结果了
6 为什么 DISTINCT 之后 ORDER BY 的列必须出现在 SELECT 里? DISTINCT(⑦)已经把结果列压缩了,ORDER BY(⑧)只能看到压缩后的列
7 为什么 LIMIT 能写表达式/别名? LIMIT(⑨)最后执行,前面所有结果都已就绪
8 为什么 GROUP BY 之后 SELECT 只能取分组列或聚合列? 组内非分组列有多个值,SELECT(⑥)无法决定显示哪个(→ 1055)

这八个问题,覆盖了 90% 的「SQL 为什么这样」类面试题。 你只要能背出执行顺序图,就能现场推出来。

9.3 一个杀手级的例子

复制代码
select name, count(*) as cnt
from exam_result
where math > 60                    -- ③ WHERE:先按行筛,math<=60 的行直接出局
group by name                      -- ④ GROUP BY:剩下的行按 name 分组
having cnt > 1                     -- ⑤ HAVING:筛掉只有 1 行的组(用别名,MySQL 扩展)
order by cnt desc                  -- ⑧ ORDER BY:按统计数量降序(用别名)
limit 3;                           -- ⑨ LIMIT:只取前 3 组

这个例子里,cnt 这个别名出现在 HAVING 和 ORDER BY 里,但绝不出现在 WHERE 里------这正是执行顺序决定的写法。

如果你把它改写成 where count(*) > 1,立刻报 ERROR 1111。

9.4 性能优化启示(这才是执行顺序的真正价值)

执行顺序不只是一个「面试题」,它是你写高性能 SQL 的地图。

优化手段 原理(对应执行顺序)
把过滤条件写在 WHERE 而不是 HAVING WHERE(③)比 HAVING(⑤)早,能减少分组的数据量
用小表驱动大表(JOIN 顺序) ON/JOIN(②)阶段决定中间结果集大小
尽量早过滤 WHERE 越早,后面每个环节处理的行数越少
SELECT 只取需要的列 SELECT(⑥)之前的结果集要尽量小,才能用覆盖索引
LIMIT 配合索引排序 如果 ORDER BY(⑧)能用索引有序,LIMIT(⑨)可以直接提前停止

举个具体的:

复制代码
-- ❌ 慢:先分组求平均(全表),再筛部门
select deptno, avg(sal) from EMP
group by deptno
having deptno = 10;

-- ✅ 快:先在 WHERE 里筛出 10 部门(可能走索引),再分组
select deptno, avg(sal) from EMP
where deptno = 10
group by deptno;

同样的结果,性能可能差 10 倍。 因为你把「筛选」从第 ⑤ 步提前到了第 ③ 步。

这就是我为什么说:执行顺序是 SQL 的「物理定律」。 你不需要背它,但你必须理解它------因为它决定了你的 SQL 为什么慢。


10. 实战 OJ 参考解法

课件列了一批牛客和 LeetCode 的题目。我把它们的通用解法整理在下面(表名以题目实际为准)。

① 批量插入数据

复制代码
insert into actor (actor_id, first_name, last_name, last_update) values
(1, 'PENELOPE', 'GUINESS', '2006-02-15 12:34:33'),
(2, 'NICK', 'WAHLBERG', '2006-02-15 12:34:33');

考点:多行插入语法、不能重复插入已存在的行 (insert ignore 或 on duplicate key update)。

② 找出所有员工当前(to_date = '9999-01-01')具体的薪水情况,相同薪水只显示一次,按逆序

复制代码
select distinct salary
from salaries
where to_date = '9999-01-01'
order by salary desc;

考点:DISTINCT + ORDER BY(注意 3.2.5 讲的限制------ORDER BY 的列必须在 SELECT 里,这里恰好满足)。

③ 查找最晚入职员工的所有信息

复制代码
-- 写法一:子查询(标准)
select * from employees
where hire_date = (select max(hire_date) from employees);

-- 写法二:排序 + limit(简单,但如果有并列只返回一条)
select * from employees order by hire_date desc limit 1;
相关推荐
为思念酝酿的痛1 小时前
MySQL表的约束
数据库·mysql
时间售卖铺1 小时前
MySQL存储过程流程控制全解析
运维·数据库·mysql·云原生
拾起零碎1 小时前
U8/触发器,把自定义项写入批次档案
数据库
IT199510 小时前
Fail2Ban 防护 MySQL 实战|登录失败 3 次,IP 封禁 24 小时
数据库·mysql
夕除10 小时前
redis--IO 多路复用(select + poll)
数据库·sql
paopaokaka_luck12 小时前
流浪动物救助与收养系统(DeepSeek AI对话、 ECharts多维分析、救助流程衔接、领养与回访衔接、 捐赠确认与公示、志愿者协作)
java·前端·javascript·人工智能·mysql·echarts·推荐算法
lzhdim14 小时前
100条常用SQL语句
数据库·sql·mysql·mybatis
天河归来14 小时前
从原始消息到业务数据:三层数据架构的设计与实践
数据库·数据仓库·架构
数据库小学妹14 小时前
金融数据库成本怎么算?五笔钱加一张TCO表
数据库·金融行业·数据库选型·国产化替代·tco·金融数据库