1. 动手前先备好一张能折腾的业务表
聊 MySQL 表数据查询之前,我建议你先别急着看语法,先把“实验环境”搭起来。我见过太多新手对着文档背了一堆 SELECT 的写法,结果一开 Navicat 连建表都磕磕绊绊。MySQL 这东西,你光看不练是真学不会,尤其是表结构设计、字段管理、多表关联这些操作,必须在真实的数据上折腾一遍才能形成肌肉记忆。
1.1 环境准备:Docker 五分钟拉起一个 MySQL 8.0
如果你电脑上还没有 MySQL 环境,我最推荐的方式是用 Docker 起一个临时实例。别一上来就去官网下载安装包,Windows 上装 MySQL 8.0 的过程中什么 Configuration of MySQL Server is taking、安装启动服务报错、端口被占用 这些问题,能把你一晚上的热情全磨没。Docker 就不一样,一条命令解决战斗:
bash复制docker run --name mysql-demo \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=root123456 \
-e MYSQL_DATABASE=shop_demo \
-d mysql:8.0
解释一下我为什么选 8.0 而不是 5.7:8.0 之后窗口函数、公共表表达式(CTE)、JSON 操作这些都完善了,后面我要讲的高级查询功能,5.7 要么不支持、要么用起来很别扭。如果你在公司还在维护 5.7 的老项目,也不亏,基础语法都是一套的。
拉起来之后连接测试一下:
bash复制docker exec -it mysql-demo mysql -uroot -proot123456
能看到 mysql> 提示符就说明环境 OK 了。如果你更习惯图形化工具,Navicat 或者 MySQL Workbench 都行。个人建议新手用 Workbench,免费、官方出、还自带 ER 图功能,后面你想把表导出成关系图也方便。Navicat 功能确实更强,但那个授权问题你得自己处理好。
提示:本地 3306 端口如果被占了,把
-p 3306:3306改成-p 3307:3306,后面连接时端口改成 3307 就行。
1.2 设计一张有业务代入感的订单表
很多教程建表都是 student、course、score 这类教学表,不是不行,但离真实业务太远。我这次直接用电商订单场景,做一张 orders 订单表加一张 users 用户表,后面讲查询、分组、关联、开窗函数全都围绕这套数据展开,你学完拿去面试或者做项目,思路是能直接迁移的。
sql复制USE shop_demo;
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
nickname VARCHAR(50) DEFAULT '' COMMENT '昵称',
age TINYINT UNSIGNED DEFAULT 0 COMMENT '年龄',
city VARCHAR(50) DEFAULT '' COMMENT '所在城市',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID',
user_id INT NOT NULL COMMENT '下单用户ID',
order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单编号',
status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0-待付款 1-已付款 2-已发货 3-已完成 4-已取消',
total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额',
pay_time DATETIME DEFAULT NULL COMMENT '支付时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
KEY idx_user_id (user_id),
KEY idx_status (status),
KEY idx_created_at (created_at),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
这个表结构我在字段类型上做了一些刻意选择,后面讲字段管理会具体说。先插入测试数据:
sql复制INSERT INTO users (username, nickname, age, city) VALUES
('zhangsan', '张三', 25, '北京'),
('lisi', '李四', 30, '上海'),
('wangwu', '王五', 28, '广州'),
('zhaoliu', '赵六', 22, '深圳'),
('sunqi', '孙七', 35, '北京'),
('zhouba', '周八', 27, NULL);
INSERT INTO orders (user_id, order_no, status, total_amount, pay_time, created_at) VALUES
(1, 'ORD202401010001', 3, 199.00, '2024-01-01 10:00:00', '2024-01-01 09:55:00'),
(1, 'ORD202401020002', 1, 59.90, '2024-01-02 14:20:00', '2024-01-02 14:15:00'),
(2, 'ORD202401030003', 3, 399.00, '2024-01-03 08:30:00', '2024-01-03 08:00:00'),
(2, 'ORD202401030004', 4, 89.00, NULL, '2024-01-03 11:00:00'),
(3, 'ORD202401040005', 2, 1299.00, '2024-01-04 16:40:00', '2024-01-04 16:20:00'),
(3, 'ORD202401050006', 0, 49.00, NULL, '2024-01-05 09:10:00'),
(4, 'ORD202401060007', 3, 2680.00, '2024-01-06 20:00:00', '2024-01-06 19:30:00'),
(5, 'ORD202401070008', 3, 158.00, '2024-01-07 12:00:00', '2024-01-07 11:45:00'),
(5, 'ORD202401080009', 2, 799.00, '2024-01-08 15:30:00', '2024-01-08 15:00:00'),
(6, 'ORD202401090010', 1, 329.00, '2024-01-09 18:25:00', '2024-01-09 18:20:00');
这套数据里我故意埋了几个坑:有用户没有城市(NULL),有订单还没有支付时间,有用户没有下单记录,有订单被取消。这些边界情况在实际业务里天天遇到,后面查询的时候你就知道为什么要留这些数据了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字段管理:ALTER TABLE 不只是“加一列”那么简单
先给新手吃颗定心丸:MySQL 的字段操作无非就是增、删、改、查四种,语法不复杂,但踩坑的点特别多。我接过不少“接手半年后出问题”的案例,最后都定位到早期建表时字段设计不合理、后期硬改导致的。所以这一章不只是教命令,还讲什么时候该用哪个命令。
2.1 ALTER TABLE 四件套:ADD、MODIFY、CHANGE、DROP
先看最基础的增删改:
sql复制-- 新增字段:给 users 表加一个 vip_level 字段
ALTER TABLE users ADD COLUMN vip_level TINYINT NOT NULL DEFAULT 0 COMMENT '会员等级:0-普通 1-黄金 2-铂金 3-钻石';
-- 修改字段类型:把 nickname 从 50 扩到 100
ALTER TABLE users MODIFY COLUMN nickname VARCHAR(100) NOT NULL DEFAULT '' COMMENT '昵称';
-- 修改字段名和定义:把 vip_level 改名为 member_level,类型也改了
ALTER TABLE users CHANGE COLUMN vip_level member_level TINYINT NOT NULL DEFAULT 1 COMMENT '会员等级';
-- 删除字段:把 member_level 删掉
ALTER TABLE users DROP COLUMN member_level;
这里有一个新手最常见的困惑:MODIFY 和 CHANGE 到底什么区别?
MODIFY 只能改字段的数据类型、默认值、注释这些“属性”,改完字段名不变。CHANGE 可以重命名字段,同时修改属性。也就是说 CHANGE 是 MODIFY 的超级版,但它有两个坑:
- 必须写两次列名。
CHANGE COLUMN old_name new_name ...,新名字老名字都要写。我见过有人把老名字写错,结果 MySQL 直接报Unknown column,这还算好的;最怕的是写反了,字段悄悄被改成别的名字,没人发现,到线上查询才炸。 - CHANGE 改完不会保留原定义,你必须写全所有属性。比如原来
nickname VARCHAR(50) NOT NULL DEFAULT '',你用CHANGE COLUMN nickname nickname VARCHAR(50)之后,NOT NULL DEFAULT ''全没了,字段变成允许 NULL、默认值 NULL。这在线上是灾难级的变更。
所以我的经验就一条:想改字段属性用 MODIFY,想改名才用 CHANGE。 如果线上表很大,这种 DDL 操作还要考虑锁表问题,8.0 支持了在线 DDL,但尽量别在业务高峰期跑批量字段变更。
sql复制-- 查看当前表结构,确认改对没有
SHOW CREATE TABLE users;
DESC users;
SHOW CREATE TABLE 会返回完整的建表语句,DESC 则是精简版的字段信息。两个命令都建议经常用,尤其你接手一个不认识的老表时,先 SHOW CREATE TABLE 看一眼索引、字符集、外键这些隐藏信息。
2.2 字段设计里的类型选择与隐性坑
我在建表时故意让 users.age 用了 TINYINT UNSIGNED,orders.total_amount 用了 DECIMAL(10,2),这些都是有原因的。
TINYINT 是 1 个字节,取值范围 -128 到 127(加 UNSIGNED 后 0-255)。年龄这个字段 0-255 足够,用 INT 纯粹浪费空间。一个表几百万行,每个字段省 3 个字节,全部字段加起来省的空间相当可观,而且 InnoDB 行越小,一页能放的行越多,查询 IO 越少。这是面试常问的“MySQL 表设计优化”的一个点。
DECIMAL(10,2) 则是金额字段的铁律。千万别用 FLOAT 或 DOUBLE 存钱,原因很简单:浮点数有精度损失。0.1 + 0.2 在二进制浮点里不等于 0.3,这在财务计算里是致命的。DECIMAL 是定点数,按字符串存储精确值,做账不会差一分钱。你可以自己试试:
sql复制SELECT 0.1 + 0.2; -- 0.3,DECIMAL 精确
SELECT 0.1e0 + 0.2e0; -- 0.30000000000000004,浮点误差
再说字符集。建表时我统一用了 utf8mb4,这是 MySQL 8.0 的默认值,但很多老项目还在用 utf8。这两个的区别是:utf8 在 MySQL 里最多存 3 字节的字符,遇到 emoji 表情(4 字节)插入就直接报错 Incorrect string value。utf8mb4 才是真正的“完整 utf8”,兼容所有 Unicode 字符。凡是用户能输入文本的字段,一律 utf8mb4,不要犹豫。
sql复制ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
注意:
CONVERT TO会重建表,涉及全表扫描和行级复制,线上大表别乱跑。utf8mb4_0900_ai_ci是 8.0 默认排序规则,如果是 5.7 就用utf8mb4_general_ci。
2.3 用 INFORMATION_SCHEMA 查字段元数据
字段管理不止 ALTER TABLE 这一条路。有时候你想知道“某张表有哪些字段、哪些字段没建索引”,靠人眼看 DESC 效率太低。SQL 标准里有一个 INFORMATION_SCHEMA 库,里面存了所有表的元信息。
sql复制-- 查询 orders 表所有字段信息
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'shop_demo' AND TABLE_NAME = 'orders'
ORDER BY ORDINAL_POSITION;
这个查询在你需要写脚本批量生成文档、或者做数据库巡检时非常有用。比如我想找出所有没注释的字段:
sql复制SELECT TABLE_NAME, COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'shop_demo'
AND (COLUMN_COMMENT IS NULL OR COLUMN_COMMENT = '');
一个团队里要是大家都遵守“建表必写注释”的规范,这个查询永远查不出数据。但现实是很多项目没人管这个,等到接手的人看着 col1、col2 这种字段名一脸懵,就知道注释的重要性了。我自己建表的习惯是:字段名、类型、默认值、注释一次性写全,免得后面再补。
3. 查询基本功:先搞懂 SQL 的“执行顺序”
SELECT 的语法,新手一般看两眼就会写了。但“会写”和“写得对”是两码事。我面试的时候特别喜欢问一个看似简单的问题:“WHERE 子句里能不能用 SELECT 里才定义的别名?”一半以上的人答错,或者答不上来为什么。这事的根源就在于不懂 SQL 的逻辑执行顺序。
3.1 逻辑执行顺序:FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY→LIMIT
先看 SQL 的书写顺序:
sql复制SELECT ...
FROM ...
[JOIN ...]
WHERE ...
GROUP BY ...
HAVING ...
ORDER BY ...
LIMIT ...;
但 MySQL 真正执行的逻辑顺序是:
- FROM:确定数据来源表,做多表连接
- WHERE:对 FROM 阶段的结果按行过滤
- GROUP BY:按分组条件把行聚合成组
- HAVING:对分组后的结果过滤
- SELECT:计算要返回的列、别名、去重
- ORDER BY:对最终结果排序
- LIMIT:截取行数
这个顺序解释了超级多的“为什么”。
为什么 WHERE 里不能使用 SELECT 定义的别名? 因为别名是第 5 步才生成的,第 2 步执行的时候根本不存在这个名字。
sql复制-- 这条会报错:Unknown column 'total_with_fee' in 'where clause'
SELECT id, total_amount * 1.1 AS total_with_fee
FROM orders
WHERE total_with_fee > 200;
正确写法是直接写原始表达式:
sql复制SELECT id, total_amount * 1.1 AS total_with_fee
FROM orders
WHERE total_amount * 1.1 > 200;
为什么 ORDER BY 可以使用别名? 因为 ORDER BY 在第 6 步,SELECT 在第 5 步,别名已经生成了。
sql复制-- 这样是合法的
SELECT id, total_amount * 1.1 AS total_with_fee
FROM orders
ORDER BY total_with_fee DESC;
这个执行顺序不是纸上谈兵,它是查“为什么我这句 SQL 报错”“为什么这个别名不生效”“为什么 HAVING 和 WHERE 效果不一样”的唯一钥匙。建议你把它抄在便签上,贴显示器旁边,写 SQL 的时候对着看。
3.2 JOIN 连接:INNER、LEFT 的语义差别
多表查询是 MySQL 查询里绕不开的坎。JOIN 这个东西,我见过不少工作了两三年的人还在用它拼数据,但经常拼出一堆重复行,然后对着结果怀疑人生。
咱先把类型理清:
| 连接类型 | 语义 | 返回结果 |
|---|---|---|
| INNER JOIN | 内连接 | 只返回两表都匹配的行 |
| LEFT JOIN | 左外连接 | 返回左表全部行,右表无匹配补 NULL |
| RIGHT JOIN | 右外连接 | 返回右表全部行,左表无匹配补 NULL |
| CROSS JOIN | 交叉连接 | 两表所有行组合,笛卡尔积 |
用我们测试数据来做个经典需求:“查每个用户及其订单信息”。
sql复制-- INNER JOIN:只列出有订单的用户+订单
SELECT u.username, o.order_no, o.total_amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
结果 10 行,所有订单都有对应用户,所以返回全部订单。但如果有个用户没有订单,INNER JOIN 就不会出现这个用户。
sql复制-- LEFT JOIN:列出所有用户,没有订单的用户订单字段显示 NULL
SELECT u.username, o.order_no, o.total_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
结果应该是 11 行,多出第 6 个用户(周八),他的 order_no 和 total_amount 都是 NULL。
这两个结果对比,你就能直观感受到 LEFT JOIN 的“保留左表全部行”是什么意思。实际业务里,LEFT JOIN 的使用频率远高于 INNER JOIN,因为很多需求是“以某张主表为准,补全其他信息”,比如“所有用户,带上他们的订单统计”,主表用户不能少。
但 LEFT JOIN 有一个最阴间的坑:右边表有重复匹配行,会把左边表的行翻倍。 比如如果 orders 表里同一个 order_no 出现了多次(虽然我们建表时加了 UNIQUE 约束),LEFT JOIN 的结果就会多出重复的用户行。我排查过不止一个“数据莫名翻倍”的线上问题,最后都是 JOIN 出来的重复行,不是原始数据有重复。
所以写 JOIN 之前,先问自己两个问题:左表的主键是什么?右表 match 字段上有唯一索引吗?如果右表有多行匹配左表的一行,你拿到的一定是膨胀后的结果,这时候要想想是不是该用 GROUP BY 或者改成子查询。
3.3 ON 与 WHERE 的过滤时机不一样
这是 JOIN 查询里最容易踩的第二个坑。看这个例子:
sql复制-- 需求:查所有用户,以及他们已支付的订单(status=1)
-- 写法1:条件放 WHERE
SELECT u.username, o.order_no
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 1;
-- 写法2:条件放 ON
SELECT u.username, o.order_no
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 1;
这两个结果天差地别。
写法1 的执行顺序是:先 LEFT JOIN,把 11 行全查出来,然后用 WHERE o.status = 1 过滤,这样只留下 3 行——没有订单的用户直接被干掉了,LEFT JOIN 等于白用。这就是很多人困惑的“我明明写的 LEFT JOIN,为什么用户还是少了”。
写法2 把 AND o.status = 1 放到 ON 里,JOIN 的时候就直接只连接 status=1 的订单,没有订单的用户照样保留,NULL 补齐。
规则很简单:对左侧主表的过滤条件放 WHERE,对右侧补全表的过滤条件必须放 ON。 如果你搞反了,LEFT JOIN 就会悄悄退化成 INNER JOIN,数据对不上还不好发现。
4. 聚合分组:统计报表里最容易翻车的三件事
接着聊 GROUP BY 和聚合函数。这章内容在“mysql 常用函数”“mysql 面试题”里出现的频率极高。聚合查询不难,但三个问题天天有人犯:HAVING 和 WHERE 混着用、COUNT 选了错参数、GROUP BY 报错不会看。
4.1 WHERE 先过滤、HAVING 再过滤,顺序别搞反
先看执行顺序:WHERE 在第 2 步,GROUP BY 在第 3 步,HAVING 在第 4 步。所以 WHERE 先按行过滤,过滤完剩下的行才分组,分组之后 HAVING 再过滤组。
做个实际需求:“统计每个用户的订单总金额,只要总金额大于 200 的”。
sql复制SELECT user_id, SUM(total_amount) AS total_spent
FROM orders
GROUP BY user_id
HAVING SUM(total_amount) > 200;
用 HAVING 是因为 total_spent 这个聚合结果在 WHERE 阶段还不存在,只能分组之后过滤。
再看另一个需求:“统计每个用户已支付订单的总额(status=1),只要总额大于 300 的”。
sql复制SELECT user_id, SUM(total_amount) AS paid_total
FROM orders
WHERE status = 1
GROUP BY user_id
HAVING SUM(total_amount) > 300;
关键点在 WHERE 和 HAVING 的分工:status = 1 是单行条件,必须在分组前用 WHERE 过滤,否则被过滤的行还会参与 SUM 求和,结果就错了;SUM 结果大于 300 是组条件,只能用 HAVING。
这个“顺序感”很重要。我在排查慢查询和错误结果的时候,第一反应永远是检查 WHERE 和 HAVING 的位置,十次有八次能定位到问题。
4.2 COUNT(*) 和 COUNT(字段) 的区别,一堆人搞错
统计行数所有人都会写 SELECT COUNT(*) FROM orders。但遇到 NULL,就容易翻车。
COUNT(*):统计整行存在的数量,包含所有行,不考虑是否 NULL。COUNT(1):和COUNT(*)基本等价,统计行数。COUNT(column):统计该字段 非 NULL 的行数。
举个实际例子:
sql复制-- 统计订单数量:一共10单
SELECT COUNT(*) AS order_count FROM orders;
-- 统计已支付的订单数量:pay_time 非空的订单
SELECT COUNT(pay_time) AS paid_order_count FROM orders;
第一条返回 10,第二条返回 7(因为有三条订单 pay_time 是 NULL)。如果你用 COUNT(*) 去统计已支付订单,会把未支付的也算进去,这种错误在报表里非常隐蔽,因为数值差异不大,不容易察觉。
那 COUNT(*) 和 COUNT(1) 呢?性能上几乎没区别,MySQL 优化器会把 COUNT(1) 转换为 COUNT(*) 处理。你不用纠结选哪个,习惯用哪个都行。
还有一个小技巧:统计“有多少个用户下了订单”,用 COUNT(DISTINCT user_id):
sql复制SELECT COUNT(DISTINCT user_id) AS active_user_count FROM orders;
这个返回 6,因为六个用户都有订单(注意我们测试数据确实如此)。千万不能用 COUNT(user_id),它统计的是订单行数 10,不是用户数。
4.3 分组报错 ONLY_FULL_GROUP_BY:看到别慌
MySQL 5.7 以后默认开启了 ONLY_FULL_GROUP_BY 模式,很多从 5.6 转到 5.7/8.0 的老手也踩过这个坑。比如这个 SQL:
sql复制-- 需求:每个用户的最近一笔订单信息
SELECT user_id, order_no, total_amount
FROM orders
GROUP BY user_id;
在 5.6 里能跑,返回每个 user_id 对应的“任意”一行,到了 5.7/8.0 直接报错:
code复制Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'shop_demo.orders.order_no' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
翻译成人话:你 GROUP BY 了 user_id,但 SELECT 里还选了 order_no 和 total_amount,这两个字段不是分组字段,也没有被聚合函数包住,在分组模式下 MySQL 不知道选哪一行。
这个限制其实是对的——它强制你写清楚意图:你到底要每个用户哪条订单?是订单金额最高的?是最新的?还是随便哪条?
取“最新一笔订单”的正确姿势是用窗口函数(下一章讲)或者自连接。简单取“金额最高的订单”可以用子查询:
sql复制SELECT o.user_id, o.order_no, o.total_amount
FROM orders o
INNER JOIN (
SELECT user_id, MAX(total_amount) AS max_amount
FROM orders
GROUP BY user_id
) t ON o.user_id = t.user_id AND o.total_amount = t.max_amount;
不过注意,如果同一个用户有两笔相同最高金额的订单,这个写法会把两笔都返回。要完全精确就需要窗口函数,咱们下一章解决。
提示:SQL 报错不可怕,关键是理解报错在告诉你什么。
ONLY_FULL_GROUP_BY报错是一个保护机制,提醒你“分组结果不唯一”,别为了通过校验去改sql_mode,那是掩耳盗铃,线上数据错了才是大麻烦。
5. 高级查询三板斧:开窗函数、CTE 与 JSON 字段
聊到这才算进入进阶区。很多人用 MySQL 三五年,还在用子查询 + JOIN 硬刚所有复杂需求,不是不行,但代码可读性差、逻辑绕、难维护。MySQL 8.0 带来的开窗函数和 CTE,是真正提升开发效率的利器。
5.1 开窗函数:GROUP BY 压扁行,开窗不压扁
先搞懂为什么要开窗函数。GROUP BY 有一个天然缺陷:分组后,这一组的多行被“压扁”成一行,你再也看不到组内的明细。但实际业务经常要“保留明细 + 同时计算聚合值”,比如“每个用户每笔订单,加上这个用户订单总额排名”。
开窗函数(Window Function)就是干这个的。它不会把行合并,而是在每一行旁边“开一扇窗”,计算窗口内聚合值,每行都保留。
MySQL 8.0 支持的开窗函数主要分三类:
- 聚合类:
SUM()、AVG()、COUNT()、MAX()、MIN()配合OVER() - 排名类:
ROW_NUMBER()、RANK()、DENSE_RANK() - 取值类:
LAG()、LEAD()、FIRST_VALUE()、LAST_VALUE()
先看最常用的排名。经典需求:“统计每个用户订单金额排名”。
sql复制SELECT
user_id,
order_no,
total_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total_amount DESC) AS rn
FROM orders;
这个查询对每个用户(PARTITION BY user_id)按金额降序排,rn 就是该用户的订单金额排名。ROW_NUMBER、RANK、DENSE_RANK 三个函数的区别是面试高频题:
| 函数 | 相同值处理 | 排名序列 |
|---|---|---|
| ROW_NUMBER() | 相同值也强行编号,不重复 | 1, 2, 3, 4 |
| RANK() | 相同值并列,后续排名跳号 | 1, 1, 3, 4 |
| DENSE_RANK() | 相同值并列,后续排名不跳号 | 1, 1, 2, 3 |
放到实际场景:比赛成绩排名,两个人并列第二,RANK 会给第三名排第 4,DENSE_RANK 给第三名排第 3。选哪个取决于业务语义。
回到我们上一章的遗留问题:“每个用户最新一笔订单”。用开窗函数一行搞定:
sql复制SELECT user_id, order_no, total_amount, created_at
FROM (
SELECT
user_id,
order_no,
total_amount,
created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders
) t
WHERE rn = 1;
子查询里给每一行标号,外层过滤取 rn = 1,完美解决“分组取 TopN”的问题。这也是面试考烂了的一个题,现在你知道标准答案了。
5.2 累计求和与移动平均:开窗函数的下一个战场
排名只是开窗函数的基本用法。更实用的是聚合类开窗函数,能让你在保留明细的同时算累计值。
经典需求:“统计每个用户到当前订单为止的累计消费金额”。
sql复制SELECT
user_id,
order_no,
total_amount,
SUM(total_amount) OVER (PARTITION BY user_id ORDER BY created_at) AS cumulative_amount
FROM orders
ORDER BY user_id, created_at;
注意 ORDER BY created_at 写在 OVER() 里之后,SUM 变成了“截至当前行的累计和”,这叫累积窗口。如果不写 ORDER BY,SUM 计算的是全部分区的总和,每一行都一样。
再比如移动平均:“每个订单前 2 笔订单的平均金额”。
sql复制SELECT
user_id,
order_no,
total_amount,
AVG(total_amount) OVER (PARTITION BY user_id ORDER BY created_at
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg
FROM orders;
ROWS BETWEEN ... PRECEDING AND ... FOLLOWING 是窗口的边界定义,控制了窗口范围。这个功能在做数据分析、报表时非常有用,比如算最近 30 天平均销售额、算用户的滑动消费趋势,都是这一类写法。之前用纯 SQL 做这种计算要写子查询自连接,性能差还容易错,开窗函数一来,代码短了一半。
5.3 CTE:把子查询拆成人话
公共表表达式(Common Table Expression,简称 CTE)是另一种让 SQL 变优雅的语法。它的核心作用就一个:给一段子查询起个名字,可以在后面重复引用。
写法:
sql复制WITH user_order_stats AS (
SELECT
user_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM orders
GROUP BY user_id
)
SELECT
u.username,
u.city,
s.order_count,
s.total_amount
FROM users u
LEFT JOIN user_order_stats s ON u.id = s.user_id;
效果和嵌套子查询一样,但可读性提升了不止一个档次。复杂报表 SQL 动辄上百行,如果全靠嵌套子查询,你自己过一个星期都看不懂,更别说交接了。用 CTE 一段一段拆开,每一段相当于一个命名的中间结果,调试也方便。
CTE 还有一个杀招:递归查询。比如查询“组织架构树里某个人的所有下级”“商品分类的所有子分类”,这类树形结构查询,用传统 SQL 写起来非常痛苦,但递归 CTE 直接搞定。MySQL 8.0 之前递归只能靠存储过程配合临时表实现,代码巨长还很绕。
sql复制-- 递归 CTE 示例:生成 1 到 5 的数字序列(理解递归的起手式)
WITH RECURSIVE seq (n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 5
)
SELECT n FROM seq;
递归 CTE 的结构分成两部分:SELECT 1 是种子查询,UNION ALL 后面是递归查询,每次把上一轮结果 +1。实际业务里遍历分类树、组织树都是这个模式,种子是顶级节点,递归是不断往下找子节点。
5.4 JSON 字段查询:8.0 的最大福利
如果你的表里有 JSON 类型的字段,操作它的语法一定要会。我见过太多人把结构化数据塞进 VARCHAR,然后在代码里 parse JSON,不仅查询没法走索引,数据一致性也没法保证。
MySQL 5.7 开始支持 JSON 类型,8.0 加强了不少,可以用 SQL 直接解析、过滤,甚至可以给 JSON 字段建虚拟列加索引。
先设计一个简单的 JSON 实用场景。给 users 表加一个 JSON 字段,存用户的扩展信息:
sql复制ALTER TABLE users ADD COLUMN profile JSON DEFAULT NULL COMMENT '用户扩展信息';
UPDATE users SET profile = JSON_OBJECT('level', 3, 'points', 1200, 'tags', JSON_ARRAY('vip', 'foodie')) WHERE id = 1;
UPDATE users SET profile = JSON_OBJECT('level', 1, 'points', 300, 'tags', JSON_ARRAY('newbie')) WHERE id = 2;
UPDATE users SET profile = JSON_OBJECT('level', 2, 'points', 800, 'tags', JSON_ARRAY('vip')) WHERE id = 3;
查询 JSON 字段里的某个键,推荐用 -> 和 ->>:
sql复制-- 查询 profile 里的 level 字段
SELECT id, profile->'$.level' AS level_raw, profile->>'$.level' AS level_text
FROM users
WHERE profile IS NOT NULL;
区别在于:
profile->'$.level':返回的值带类型标记,显示为3,拿到的可能是JSON类型或带引号的字符串。profile->>'$.level':返回纯文本,相当于JSON_UNQUOTE(JSON_EXTRACT(...))。
实际业务中 ->> 用得多,因为它能直接和字符串比较:
sql复制-- 找出所有等级为 3 的用户
SELECT id, username
FROM users
WHERE profile->>'$.level' = '3';
这里有个坑:->> 返回的是文本,'3' 是字符串,能比。但如果你用 profile->'$.level' = 3(数字),类型不匹配可能查不出结果,除非 MySQL 做了隐式转换。建议统一用 ->> 再比较字符串,避免踩坑。
JSON 数组的查询可以用 JSON_CONTAINS:
sql复制-- 找出 tags 里包含 'vip' 的用户
SELECT id, username
FROM users
WHERE JSON_CONTAINS(profile->'$.tags', '"vip"');
注意 "vip" 这个值是 JSON 字符串,必须带双引号。
还有一个更高级的:把 JSON 数组展开成多行,用 JSON_TABLE。虽然这个函数语法看着有点吓人,但在处理埋点数据、接口日志表时是真的好用,能把 JSON 数组拆成一张虚拟表直接 JOIN。
sql复制SELECT u.id, jt.tag
FROM users u,
JSON_TABLE(u.profile, '$.tags[*]' COLUMNS (
tag VARCHAR(20) PATH '$'
)) AS jt
WHERE u.profile IS NOT NULL;
这能把每个用户的 tags 数组拆成多行,id=1 的用户会出两行,分别是 vip 和 foodie。实际业务里,比如接口请求日志把参数存成 JSON 数组,要用 SQL 做明细分析,这个函数就派上用场了。JSON 这一整套语法是 MySQL 相对其他数据库的一个差异化优势,面试问到 JSON 字段存储方案时,你能说出 JSON_TABLE 的组合玩法,就算过关了。
6. 查询性能体检:索引、EXPLAIN 和深分页避坑
最后这章讲讲怎么把查询写“快”。前面所有查询在几十行数据上跑得飞快,但同样的 SQL,数据量到百万级可能就慢得让人抓狂。性能问题虽然不是新手阶段最关心的,但越早建立索引意识,后面少走越多弯路。
6.1 索引为什么快:B+ 树的类比
要说索引,必须说清楚它为什么快。想象一本新华字典,如果没有目录和拼音检字表,你要找一个字,只能从第一页翻到最后一页——这叫全表扫描。有了拼音检字表,你先定位到“zhang”在哪一页,再翻到那一页找到字,几步到位——这就是索引。
MySQL InnoDB 引擎使用 B+ 树作为索引结构,底层细节可以不深究,你只需要记住几个特征:
- B+ 树是矮胖型结构,三层就能存几千万条记录,查询一条数据只需要几次磁盘 IO。
- 主键索引(聚簇索引)的叶子节点存的是整行数据。查询走主键时,直接拿到完整记录。
- 二级索引(普通索引)的叶子节点存的是主键值 + 索引列的值。所以走二级索引查数据,要先找到主键值,再回主键索引取整行——这叫回表。
这个“回表”概念很重要,它解释了为什么很多优化技巧有效。比如“覆盖索引”:如果查询的列全部在二级索引的叶子节点里,就不用回表了,效率大增。
一个典型反例:
sql复制-- orders 表有 idx_created_at,但这条查询会慢
SELECT * FROM orders WHERE created_at >= '2024-01-01';
SELECT * 要获取所有列,二级索引 idx_created_at 里没有其他列的数据,必须每条记录都回表,数据量大时效率很差。如果只需要查 id 和 created_at:
sql复制SELECT id, created_at FROM orders WHERE created_at >= '2024-01-01';
这两列在 idx_created_at 的叶子节点里就有,MySQL 看到查询列完全被索引覆盖,就决定不回表了,速度提升非常明显。这就是“覆盖索引优化”。
6.2 用 EXPLAIN 看执行计划
遇到慢查询,第一步永远是 EXPLAIN:
sql复制EXPLAIN SELECT * FROM orders WHERE user_id = 3;
结果是一个表格,关键列就这么几个:
| 列名 | 含义 | 你要关注什么 |
|---|---|---|
| type | 访问类型 | const/eq_ref/ref/range/index/all,越靠左越好,all 是最差的全表扫描 |
| key | 实际用到的索引 | 是否为 NULL,NULL 说明没走索引 |
| rows | 预估扫描行数 | 越小越好 |
| Extra | 额外信息 | 看到 Using filesort、Using temporary 就要警惕 |
举个例子,不加索引的 WHERE city = '北京',type 通常是 ALL,rows 是全部行数。给 city 加索引后,type 变成 ref,rows 大幅降低。
我们用测试数据跑一下:
sql复制-- 查看一条关联查询的执行计划
EXPLAIN SELECT u.username, o.order_no
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.city = '北京';
重点看 key 列:orders 表的关联字段 user_id 有索引 idx_user_id,所以 JOIN 不会全表扫描;users 表的过滤条件 city 没有索引,所以走 ALL 全表扫描。如果 users 表有几十万行,这条 SQL 就会慢。解决方案就是给 city 加索引:
sql复制ALTER TABLE users ADD INDEX idx_city (city);
加完再 EXPLAIN 一次,type 应该变成 ref。一个实用的排查套路。
6.3 深分页问题:LIMIT 越大越慢
分页查询人人会写:
sql复制SELECT * FROM orders ORDER BY created_at DESC LIMIT 10, 20;
这个 SQL 在 MySQL 里执行的逻辑是:先取前 30 行,然后丢掉前 10 行,返回剩下的 20 行。数据量小无所谓,但当你翻到第 10000 页,LIMIT 是 LIMIT 200000, 20,MySQL 会先把 200020 行全部查出来排序,再丢掉前 200000 行。这个“偏移量越大越慢”的问题,就叫深分页。
怎么优化?最常用的两个方案。
方案一:延迟关联(延迟 JOIN)。先用覆盖索引快速定位需要的 id,再回表取完整数据。
sql复制-- 慢的写法
SELECT * FROM orders ORDER BY created_at DESC LIMIT 200000, 20;
-- 优化写法
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id
FROM orders
ORDER BY created_at DESC
LIMIT 200000, 20
) t ON o.id = t.id;
子查询里只查 id,走覆盖索引不回表,速度快很多;外层再用 id 关联回原表取完整数据。
方案二:游标分页 / 键集分页。不用 LIMIT OFFSET,而是记住上一页最后一条记录的某个唯一字段,然后取这个字段之后的记录。
sql复制-- 保留 last_seen_id 作为上一页最后一条 id,下一页这么查
SELECT *
FROM orders
WHERE created_at < '2024-01-08 15:00:00'
ORDER BY created_at DESC
LIMIT 20;
这种做法的前提是排序字段有唯一性保证。如果只用 created_at 排序,遇到两条相同时间就可能在翻页时重复或漏掉。最稳妥的写法是排序字段用 (created_at, id) 两个字段,WHERE 条件也带上两个字段的联合比较。这属于 API 设计层面的分页方案,MySQL 这边执行效率极高,因为不受偏移量影响。
6.4 慢查询的常见病根与排查手法
最后总结几个我平时排查慢 SQL 时最常遇到的病根:
- **SELECT ***。哪怕只用到两列,也把你所有字段全捞出来,既浪费 IO 又破坏覆盖索引。改写成明确列名,收益立竿见影。
- 隐式类型转换。比如
orders表order_no是VARCHAR(32),但查询时用了WHERE order_no = 123456,MySQL 会把字符串转成数字比较,导致order_no上的唯一索引失效,全表扫描。检查方法:EXPLAIN看type=ALL,再对比WHERE order_no = '123456'是否走索引。 - LIKE 前置通配符。
LIKE '%abc'因为通配符在前面,索引无法使用,只能全表扫。如果业务真的需要前缀模糊搜索,考虑全文索引或者专门做搜索服务。 - 函数作用于索引列。
WHERE DATE(created_at) = '2024-01-01'会让索引失效。改成范围查询:WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'。 - 排序没走索引。
ORDER BY的字段如果和WHERE过滤的字段不构成联合索引,就会Using filesort,数据量大时特别慢。解决方案是设计联合索引,让排序字段索引直接有序。
排查慢 SQL 有一个标准流程:先 SHOW GLOBAL STATUS LIKE 'Slow_queries'; 看慢查询数量,然后打开慢查询日志,把日志里时间超过阈值的 SQL 捞出来,逐条 EXPLAIN 分析,看 type、rows、Extra,顺着上面的五条逐一对照。这个方法我用了很多年,没失手过。
MySQL 的优化其实没有太多玄学,核心就是“减少扫描行数、减少回表次数、避免无用操作”。你去看网上那些“秒杀级优化方案”,本质全在这三句话里。
这套表数据、这些查询案例,建议你从头到尾在本地跑一遍。MySQL 的表操作、查询、字段管理,尤其是高级查询,光看不练,过两周就忘。把示例 SQL 改成你自己业务里的真实场景,踩过几个坑之后,才算真正学会。
