ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL 8 实战速通:从 SELECT 到窗口函数,三小时课程笔记落地指南

MySQL 8 实战速通:从 SELECT 到窗口函数,三小时课程笔记落地指南

简介:这份资源是B站Mosh老师SQL三小时入门课程的配套笔记,以PDF形式整理,面向零基础或希望系统梳理SQL核心语法的开发者与数据库初学者。内容覆盖SQL基础知识、SELECT子句、WHERE条件筛选、AND/OR/NOT逻辑操作符,以及IN、BETWEEN、LIKE、REGEXP等常用操作符的语法与用法,帮助读者快速建立查询语句的书写框架。资源包共1个PDF文件,大小约2.43MB,轻量便携,适合随时查阅与打印。目前已有976人学习下载,说明其在入门群体中具有一定认可度。笔记以速查表形式呈现,条理清晰,便于对照练习与复习,可作为学习Mosh视频课程时的辅助资料,也可独立用于日常SQL语句查阅,帮助读者在较短时间内掌握关系型数据库查询的基本操作与常见条件表达方式。

1. 三小时 SQL 速通课,为什么值得你花一个周末啃完

很多人第一次接触 SQL 是在面试前一周,打开某站搜「SQL 三小时」,然后被推荐到一套节奏极快的课程。Mosh 的 SQL 三小时课程笔记就是这类场景下被反复提起的——它把关系型数据库的核心操作压缩进一个下午能看完的体量,覆盖 SELECT、JOIN、子查询、视图、存储过程、事务和索引。问题在于:看完视频不等于会用,笔记记了不等于能跑。

这套内容真正解决的不是「SQL 是什么」,而是「给你一张表,你能不能在十分钟内写出正确的多表查询」。适合三类人:转行做后端或数据分析的初学者、写业务代码但 SQL 全靠搜索的开发者、以及需要快速复习准备技术面试的人。我见过太多人把三小时视频刷了两遍,遇到「查询每个部门薪资第二高的员工」还是卡住,原因不是语法没记住,而是没有把笔记转化成可执行的练习路径。

这篇笔记的定位很明确:把课程里的知识点拆成能落地的操作步骤,配上可直接运行的代码和参数说明,再补上那些视频里不会讲、但实际工作中一定会踩的坑。你不需要先看完视频再来读,反过来也行——边读边在本地数据库里敲,效果最好。

2. 先搭一个能跑 SQL 的本地环境:选型与最小验证

2.1 为什么选 MySQL 而不是 SQLite 或 PostgreSQL

课程里用的是 MySQL 语法,但很多知识点(窗口函数、CTE、存储过程)在不同数据库里写法有差异。如果你只是跟着笔记敲一遍,SQLite 最省事——一个文件就是一个数据库,不需要安装服务。但如果你要覆盖课程里的事务隔离级别、存储过程、索引优化这些内容,SQLite 撑不住。

我的建议是:主力用 MySQL 8.x,因为课程默认语法就是它,而且窗口函数、CTE 在 8.0 之后才完整支持。PostgreSQL 语法更严格、功能更强,但和课程示例有出入,初学阶段容易在细节上卡住。如果你已经装了 Docker,直接拉一个 MySQL 容器是最干净的方案,不污染本机环境。

2.2 用 Docker 起一个 MySQL 8 实例

# 拉取 MySQL 8.0 镜像并启动容器 # MYSQL_ROOT_PASSWORD 设置 root 密码,改成你自己的 # 端口映射 3306,如果本机已占用改成 3307:3306 docker run -d \ --name sql-practice \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -p 3306:3306 \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci

启动后等 10 秒左右,用docker logs sql-practice确认出现ready for connections再连接。字符集参数必须加,否则中文数据插入会报错或乱码——这是新手最常翻车的地方之一。

连接方式有两种:命令行mysql -h 127.0.0.1 -P 3306 -u root -p,或者用 DBeaver、DataGrip 这类图形客户端。我一般用命令行做快速验证,图形工具做复杂查询的调试。

2.3 建库建表:把课程示例数据还原出来

课程里用的是一套电商场景的表结构,核心表包括 customers、orders、order_items、products。下面这段 SQL 可以直接复制执行,建一个练习库和四张表:

-- 创建练习数据库,字符集和排序规则与容器启动参数一致 CREATE DATABASE IF NOT EXISTS sql_practice CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE sql_practice; -- 客户表:id 自增主键,姓名和邮箱不允许为空 CREATE TABLE customers ( customer_id INT PRIMARY KEY AUTO_INCREMENT, first_name VARCHAR(50) NOT NULL, last_name VARCHAR(50) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, birth_date DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 订单表:外键指向 customers,status 用枚举限制取值范围 CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL, order_date DATE NOT NULL, status ENUM('pending','shipped','delivered','cancelled') DEFAULT 'pending', total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ); -- 商品表:价格用 DECIMAL 避免浮点误差 CREATE TABLE products ( product_id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, unit_price DECIMAL(8,2) NOT NULL, stock INT NOT NULL DEFAULT 0 ); -- 订单明细表:联合主键,quantity 和 unit_price 分开存 CREATE TABLE order_items ( order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(8,2) NOT NULL, PRIMARY KEY (order_id, product_id), FOREIGN KEY (order_id) REFERENCES orders(order_id), FOREIGN KEY (product_id) REFERENCES products(product_id) );

几个参数值得注意:DECIMAL(10,2)表示总共 10 位、小数 2 位,金额字段绝对不要用 FLOAT。ENUM在 MySQL 里是合法的,但迁移到其他数据库会不兼容,生产环境更推荐用 TINYINT 加字典表。order_items用联合主键而不是自增 id,是因为订单和商品的组合天然唯一,这样设计能防止重复插入同一商品。

插入测试数据这一步课程里是直接给的脚本,我建议你自己写几条 INSERT 语句,故意插入一些边界数据——比如没有订单的客户、金额为 0 的订单、同一天下的多笔订单。后面练 JOIN 和子查询时,这些边界数据能帮你验证查询结果是否正确。

3. 把 SELECT 到 JOIN 串成一条练习线:从单表到多表

3.1 SELECT 的执行顺序和 WHERE 的真正位置

很多人写 SQL 是「想到哪写到哪」,但 SELECT 语句的执行顺序和书写顺序不一样。实际执行顺序是:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。这意味着 WHERE 里不能用 SELECT 中定义的别名,而 ORDER BY 可以。

-- 错误写法:WHERE 中引用 SELECT 的别名 SELECT unit_price * quantity AS line_total FROM order_items WHERE line_total > 100; -- 报错:Unknown column 'line_total' -- 正确写法一:在 WHERE 中重复表达式 SELECT unit_price * quantity AS line_total FROM order_items WHERE unit_price * quantity > 100; -- 正确写法二:用子查询包一层 SELECT * FROM ( SELECT unit_price * quantity AS line_total FROM order_items ) AS t WHERE t.line_total > 100;

这个坑我在实际项目里见过不止一次,尤其是从其他语言转过来的人,习惯性地认为别名可以在任何地方用。记住执行顺序,这类问题就不会再犯。

3.2 INNER JOIN 和 LEFT JOIN 的选型判断

课程里讲 JOIN 用了很多图,但实际写代码时判断标准很简单:你要的结果里,左表的行是否必须全部保留?如果是,用 LEFT JOIN;如果只保留两表都匹配的行,用 INNER JOIN。

-- 查询每个客户的订单总数,包括没有订单的客户 -- LEFT JOIN 保证没有订单的客户也会出现在结果里,COUNT 返回 0 SELECT c.customer_id, CONCAT(c.first_name, ' ', c.last_name) AS full_name, COUNT(o.order_id) AS order_count FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.first_name, c.last_name ORDER BY order_count DESC;

这里有个细节:COUNT(o.order_id)和COUNT(*)在 LEFT JOIN 下结果不同。前者只统计非 NULL 的 order_id,没有订单的客户返回 0;后者会统计包括 NULL 行在内的所有行,没有订单的客户返回 1。这个差异在统计报表里是致命的,我一般会明确写COUNT(o.order_id)并在注释里说明。

3.3 多表 JOIN 的写法和 ON 条件顺序

三张表以上的 JOIN,写法上建议把主表放最前面,然后依次 JOIN 关联表,ON 条件紧跟在每个 JOIN 后面。不要把所有表用逗号隔开然后 WHERE 里写关联条件——那是旧式写法,可读性差且容易漏条件。

-- 查询每个商品的销售总量和总收入 -- 从 products 出发,LEFT JOIN 到 order_items,保证没有销量的商品也显示 SELECT p.product_id, p.name, COALESCE(SUM(oi.quantity), 0) AS total_quantity, COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS total_revenue FROM products p LEFT JOIN order_items oi ON p.product_id = oi.product_id GROUP BY p.product_id, p.name ORDER BY total_revenue DESC;

COALESCE在这里的作用是把 NULL 转成 0。没有销量的商品,SUM 返回 NULL,直接显示会很难看。这个函数在报表查询里几乎必用,建议养成习惯。

注意:GROUP BY 后面要列出 SELECT 中所有非聚合列,MySQL 5.7 之后默认开启 ONLY_FULL_GROUP_BY,不写全会报错。这不是 bug,是规范。

4. 子查询、视图、存储过程:课程里最容易听懵的三块

4.1 子查询的三种位置和性能差异

子查询可以出现在 SELECT、FROM、WHERE 三个位置,课程里都讲了,但没强调性能差异。WHERE 里的子查询如果返回多行,必须用 IN 或 EXISTS;FROM 里的子查询必须起别名;SELECT 里的子查询每行执行一次,数据量大时性能极差。

-- WHERE 子查询:查询买过单价超过 100 元商品的客户 SELECT customer_id, first_name, last_name FROM customers WHERE customer_id IN ( SELECT o.customer_id FROM orders o JOIN order_items oi ON o.order_id = oi.order_id WHERE oi.unit_price > 100 ); -- FROM 子查询:先算出每个客户的消费总额,再筛选 SELECT * FROM ( SELECT o.customer_id, SUM(oi.quantity * oi.unit_price) AS total_spent FROM orders o JOIN order_items oi ON o.order_id = oi.order_id GROUP BY o.customer_id ) AS customer_totals WHERE total_spent > 500;

WHERE 子查询和 JOIN 在大多数情况下可以互相改写,MySQL 优化器对 JOIN 的处理通常更好。我的经验是:能用 JOIN 表达的,优先用 JOIN;子查询只在逻辑更清晰时使用,比如「不存在」语义用 NOT EXISTS 比 LEFT JOIN 加 IS NULL 更直观。

4.2 视图:把复杂查询封装成虚拟表

视图的本质是一条被保存的 SELECT 语句,不存储数据。它的价值在于简化复杂查询和统一口径。比如「客户消费汇总」这个查询在多个报表里都要用,就可以建一个视图。

-- 创建客户消费汇总视图 CREATE OR REPLACE VIEW customer_summary AS SELECT c.customer_id, CONCAT(c.first_name, ' ', c.last_name) AS full_name, COUNT(DISTINCT o.order_id) AS order_count, COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS total_spent FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id LEFT JOIN order_items oi ON o.order_id = oi.order_id GROUP BY c.customer_id, c.first_name, c.last_name; -- 使用视图就像查普通表 SELECT * FROM customer_summary WHERE total_spent > 500;

视图不存储数据,每次查询都会重新执行底层 SELECT。如果底层查询很重,视图会成为性能瓶颈。MySQL 不支持物化视图,需要缓存结果的话得自己建汇总表加定时任务。这一点课程里没展开,但实际工作中一定会遇到。

4.3 存储过程:参数模式和异常处理

存储过程是课程里最容易让人放弃的部分,因为语法和普通 SQL 差别大。核心记住三点:参数有 IN、OUT、INOUT 三种模式;用 DELIMITER 改变语句结束符;异常处理用 DECLARE ... HANDLER。

-- 创建一个存储过程:根据客户 ID 返回订单总数和总消费 DELIMITER // CREATE PROCEDURE get_customer_stats( IN p_customer_id INT, OUT p_order_count INT, OUT p_total_spent DECIMAL(10,2) ) BEGIN -- 如果查询无结果,把变量置为默认值 DECLARE EXIT HANDLER FOR NOT FOUND BEGIN SET p_order_count = 0; SET p_total_spent = 0; END; SELECT COUNT(DISTINCT o.order_id), COALESCE(SUM(oi.quantity * oi.unit_price), 0) INTO p_order_count, p_total_spent FROM orders o LEFT JOIN order_items oi ON o.order_id = oi.order_id WHERE o.customer_id = p_customer_id; END // DELIMITER ; -- 调用存储过程 CALL get_customer_stats(1, @cnt, @spent); SELECT @cnt, @spent;

DELIMITER //是因为存储过程内部有分号,不改变结束符的话 MySQL 会在第一个分号处截断。EXIT HANDLER FOR NOT FOUND处理查询无结果的情况,没有它的话变量会是 NULL。存储过程在互联网公司用得越来越少,但在金融、电信等传统行业仍然是主流,面试时被问到的概率不低。

5. 事务与索引:课程里讲得浅但工作中最要命的部分

5.1 事务的 ACID 和隔离级别怎么验证

事务的四个特性课程里用概念讲了一遍,但隔离级别这种东西不亲手验证是记不住的。MySQL 默认隔离级别是 REPEATABLE READ,你可以开两个会话手动制造脏读、不可重复读、幻读的场景。

-- 会话 A:开启事务,查询余额 START TRANSACTION; SELECT balance FROM accounts WHERE user_id = 1; -- 假设返回 1000 -- 会话 B:另一个连接里更新余额并提交 UPDATE accounts SET balance = 500 WHERE user_id = 1; COMMIT; -- 会话 A:再次查询,REPEATABLE READ 下仍然返回 1000 SELECT balance FROM accounts WHERE user_id = 1; COMMIT;

在 REPEATABLE READ 下,会话 A 两次查询结果一致,因为 InnoDB 用 MVCC 保证了可重复读。如果把隔离级别改成 READ COMMITTED,第二次查询就会返回 500。验证方法就是开两个终端,按顺序执行,观察结果差异。这种亲手验证比看十遍概念图都管用。

5.2 索引:什么时候该建,什么时候建了也没用

索引的核心作用是加速 WHERE 和 JOIN 中的条件匹配,但以下情况索引会失效:对索引列使用函数、隐式类型转换、LIKE 以通配符开头、OR 连接的条件中有未索引列。

-- 在 customers 表的 email 列建唯一索引 CREATE UNIQUE INDEX idx_customers_email ON customers(email); -- 索引生效:直接等值匹配 EXPLAIN SELECT * FROM customers WHERE email = 'test@example.com'; -- 索引失效:对索引列使用函数 EXPLAIN SELECT * FROM customers WHERE UPPER(email) = 'TEST@EXAMPLE.COM'; -- 索引失效:LIKE 以 % 开头 EXPLAIN SELECT * FROM customers WHERE email LIKE '%example.com';

用EXPLAIN查看执行计划,重点看type列:ALL是全表扫描,ref或eq_ref是索引查找,range是范围扫描。key列显示实际使用的索引,如果是 NULL 就说明没走索引。我一般会在建完索引后立刻用 EXPLAIN 验证一遍,确认优化器真的用了它。

注意:索引不是越多越好。每个索引都会增加写入成本,而且占用磁盘空间。一张表的索引数量控制在 5 个以内比较合理,联合索引要遵循最左前缀原则。

6. 避坑与排查:五个我实际踩过的 SQL 问题

6.1 COUNT 和 SUM 在 LEFT JOIN 下的错误结果

现象:统计每个客户的订单数,没有订单的客户显示 1 而不是 0。原因:LEFT JOIN 后没有订单的客户会有一行全 NULL 的记录,COUNT(*)把这行也算进去了。解决:用COUNT(o.order_id)代替COUNT(*),或者用SUM(CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END)。

6.2 GROUP BY 漏列导致 ONLY_FULL_GROUP_BY 报错

现象:SELECT customer_id, first_name, COUNT(*) FROM customers GROUP BY customer_id报错。原因:MySQL 5.7 之后默认开启 ONLY_FULL_GROUP_BY,SELECT 中的非聚合列必须全部出现在 GROUP BY 里。解决:把 first_name 加到 GROUP BY 后面,或者用ANY_VALUE(first_name)明确表示你接受任意值。后者在确定分组内该列值唯一时可以用。

6.3 隐式类型转换让索引失效

现象:customer_id 是 INT 类型,用WHERE customer_id = '123'查询,EXPLAIN 显示全表扫描。原因:字符串和数字比较时 MySQL 会把字符串转成数字,但对索引列做转换会导致索引失效。解决:参数类型和列类型保持一致,INT 列就传数字,VARCHAR 列就传字符串。ORM 框架里尤其要注意,有时候传参类型是框架自动推断的。

6.4 事务未提交导致锁等待超时

现象:一个会话执行了 UPDATE 但忘记 COMMIT,另一个会话查询同一行时卡住,最终报Lock wait timeout exceeded。原因:InnoDB 的行锁在事务提交前不会释放,未提交的事务会一直持有锁。解决:用SHOW PROCESSLIST找到长时间运行的事务,用KILL命令终止。预防措施是设置innodb_lock_wait_timeout为一个合理值(默认 50 秒),并在代码里确保事务要么提交要么回滚。

6.5 存储过程调试困难

现象:存储过程执行后结果不对,但不知道中间变量是什么值。原因:MySQL 存储过程没有内置的调试器,不能像普通代码那样打断点。解决:在关键步骤用SELECT @variable输出中间变量,或者建一张日志表,在存储过程里 INSERT 调试信息。生产环境记得把调试代码清理掉。

7. 用窗口函数把课程里的「难题」降维打击

课程里有一类练习题:查询每个部门薪资第二高的员工、计算每个客户的累计消费金额、给订单按金额排名。用子查询和 JOIN 写这些题,代码又长又容易错。窗口函数(MySQL 8.0+)能把这类问题压缩成几行。

-- 给每个客户的订单按金额降序排名 -- ROW_NUMBER 不处理并列,RANK 并列后跳号,DENSE_RANK 并列后不跳号 SELECT o.customer_id, o.order_id, o.total_amount, ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.total_amount DESC) AS rn, RANK() OVER (PARTITION BY o.customer_id ORDER BY o.total_amount DESC) AS rnk, DENSE_RANK() OVER (PARTITION BY o.customer_id ORDER BY o.total_amount DESC) AS drnk FROM orders o; -- 查询每个客户金额最高的那一笔订单 SELECT * FROM ( SELECT o.*, ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.total_amount DESC) AS rn FROM orders o ) AS ranked WHERE rn = 1;

PARTITION BY定义分组,ORDER BY定义组内排序,两者组合决定了窗口函数的计算范围。ROW_NUMBER、RANK、DENSE_RANK的区别只在处理并列值时体现:假设两个订单金额相同,ROW_NUMBER 会给出 1 和 2,RANK 给出 1 和 1(下一行是 3),DENSE_RANK 给出 1 和 1(下一行是 2)。选哪个取决于业务需求——取 Top N 且不关心并列时用 ROW_NUMBER,需要保留并列排名时用 RANK 或 DENSE_RANK。

窗口函数不能出现在 WHERE 里,因为 WHERE 的执行顺序在窗口函数之前。必须用子查询或 CTE 包一层,在外层筛选。这个限制和前面讲的 SELECT 别名问题本质相同,都是执行顺序决定的。

-- 用 CTE 计算每个客户的累计消费金额 WITH daily_spending AS ( SELECT o.customer_id, o.order_date, SUM(oi.quantity * oi.unit_price) AS daily_total FROM orders o JOIN order_items oi ON o.order_id = oi.order_id GROUP BY o.customer_id, o.order_date ) SELECT customer_id, order_date, daily_total, SUM(daily_total) OVER ( PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_spent FROM daily_spending ORDER BY customer_id, order_date;

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW定义了窗口帧,表示从第一行到当前行的累计。如果不写这个子句,默认帧是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,在日期有重复时结果会不同。这个细节课程里通常不讲,但实际计算累计值时必须明确指定。

我自己的习惯是:每学完一个窗口函数,就找一道以前用子查询写过的题重写一遍,对比代码量和可读性。写过五六道之后,遇到「分组内排名」「累计」「同比环比」这类需求,第一反应就是窗口函数,而不是 JOIN 套 JOIN。这个思维转变一旦完成,SQL 水平会上一个台阶。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进