MySQL 15个经典练习与深度解析
引言
掌握SQL(结构化查询语言)的唯一途径就是不断练习。理论知识固然重要,但只有在实际操作中,你才能真正理解查询的细微差别、性能的影响以及如何规避常见的错误。
本教程我们将一起深入探讨每个查询背后的逻辑、SQL的执行顺序、如何优化查询性能,以及初学者最容易掉入的陷阱。无论你是数据库新手,还是希望巩固基础的开发者,这15个练习都将是你的宝贵财富。
我们将分三个核心部分进行:
- 单表查询:数据操作的基石。
- 数据排序与分组:从数据中提炼商业洞见。
- 多表连接:构建数据的关系网络。
让我们开始吧!
第一部分:单表查询——数据筛选的基础
单表查询是所有复杂查询的起点。它教会我们如何从一个数据表中精确地提取所需信息。核心语法结构是 SELECT ... FROM ... WHERE ...。
数据集介绍: cat 表
我们将使用一个名为 cat 的表,它记录了猫咪的信息。
id: 猫的唯一IDname: 名字breed: 品种 (如 Siamese, Ragdoll)coloration: 毛色age: 年龄sex: 性别fav_toy: 最喜欢的玩具
练习 1: 列出所有猫咪信息
需求: 从 cat 表中查询所有数据。
解决方案:
SELECT * FROM cat;
深度解析:
SELECT *: 这里的*是一个通配符,代表“所有列”。它会按照表中定义的列顺序返回所有列的数据。FROM cat: 指定了数据来源是cat这张表。
性能优化提示:
在生产环境中,强烈建议避免使用 SELECT *。原因如下:
- 资源浪费:你可能只需要2个列,但查询会返回所有10个列,这增加了网络传输的负担和数据库的I/O开销。
- 可读性与维护性差:代码无法清晰地表明需要哪些数据。如果未来表中增加了新的列,查询结果也会意外地改变,可能导致应用程序出错。
- 无法利用覆盖索引:当查询所需的所有列都包含在某个索引中时,数据库可以直接从索引中获取数据,无需回表查询,这被称为“覆盖索引”(Covering Index)。使用
SELECT *会使这种优化几乎不可能实现。
最佳实践: 明确列出你需要的列名。例如:SELECT id, name, breed FROM cat;
练习 2: 筛选年轻的猫
需求: 查询年龄小于5岁的猫的名字、品种和毛色。
解决方案:
SELECT name, breed, coloration
FROM cat
WHERE age < 5;
深度解析:
WHERE age < 5:WHERE子句用于过滤行。这里,它只保留age列的值小于5的记录。
性能优化提示:
WHERE 子句是性能优化的关键。如果 age 列上有一个索引,数据库可以非常高效地定位到符合条件的行,而无需扫描整个表(全表扫描)。对于数值、日期等范围查询(<、>、BETWEEN),索引效果显著。
练习 3: 按品种精确查找
需求: 查询品种为 'Ragdoll' (布偶猫) 的猫的ID和名字。
解决方案:
SELECT id, name
FROM cat
WHERE breed = 'Ragdoll';
深度解析:
WHERE breed = 'Ragdoll': 这里使用了等值比较。注意,在SQL中,字符串常量需要用单引号'括起来。
常见陷阱:
- 大小写敏感性:在MySQL中,字符串比较是否区分大小写取决于列的“校对规则”(Collation)。默认的校对规则(如
utf8mb4_general_ci)是不区分大小写的(ci代表 case-insensitive)。这意味着WHERE breed = 'ragdoll'也能得到相同结果。但在其他数据库(如PostgreSQL)或某些特定校对规则下,比较是区分大小写的。请务必了解你的数据库设置。 - 数值与字符串:
WHERE id = 5和WHERE id = '5'在某些情况下可能因为隐式类型转换而得到相同结果,但这依赖于数据库的行为,是不良实践。请始终确保比较的值类型与列的数据类型匹配。
练习 4: 多条件模糊查询
需求: 查询符合以下所有条件的猫的全部信息:
- 品种(
breed)以 'R' 开头。 - 最喜欢的玩具(
fav_toy)以 'ball' 开头。 - 毛色(
coloration)以 'm' 结尾。
解决方案:
SELECT *
FROM cat
WHERE
breed LIKE 'R%'
AND fav_toy LIKE 'ball%'
AND coloration LIKE '%m';
深度解析:
LIKE: 用于字符串的模式匹配。%:LIKE操作符中的通配符,代表零个、一个或多个任意字符。'R%': 匹配以 'R' 开头的任何字符串。'%m': 匹配以 'm' 结尾的任何字符串。AND: 逻辑运算符,用于连接多个条件,表示所有条件都必须为真。
性能优化提示:
LIKE 的性能与通配符的位置密切相关。
LIKE 'R%'(前缀匹配):这种模式是 SARGable (Search ARGument Able),意味着它可以有效利用breed列上的B-Tree索引。数据库可以快速定位到索引中以 'R' 开头的条目。LIKE '%m'(后缀匹配):这种模式是 non-SARGable。标准的B-Tree索引无法用于后缀或中缀匹配,因为索引是按从左到右的顺序组织的。这将导致全表扫描,在大数据量下性能极差。如果需要频繁进行这类查询,可以考虑使用全文索引(Full-Text Index)或反向索引等技术。
练习 5: 复杂的组合条件查询
需求: 查询符合以下所有条件的猫的全部信息:
- 年龄大于10岁。
- 品种是 'Ragdoll' 或 'Abyssinian'。
- 有明确记录最喜欢的玩具(即该字段不为NULL)。
解决方案:
SELECT *
FROM cat
WHERE
age > 10
AND (breed = 'Ragdoll' OR breed = 'Abyssinian')
AND fav_toy IS NOT NULL;
深度解析:
OR: 逻辑运算符,表示只需满足其中一个条件。- 括号
()的重要性:AND的优先级高于OR。如果不加括号,查询会被解释为(age > 10 AND breed = 'Ragdoll') OR breed = 'Abyssinian',这显然不符合我们的需求。使用括号可以明确指定逻辑运算的顺序。 IS NOT NULL: 这是判断一个列的值是否为NULL的标准方式。
常见陷阱:
绝对不要使用 = NULL 或 <> NULL。NULL 代表“未知”或“不存在”,它不是一个具体的值。任何与 NULL 进行的常规比较(=、!=、> 等)结果都是 NULL(可以理解为“未知”),WHERE 子句会将其视为 FALSE。正确的语法是 IS NULL 和 IS NOT NULL。
第二部分:数据排序与分组——从数据中提炼洞见
筛选出数据后,我们往往需要对其进行排序、汇总和分组,以便进行统计分析。
数据集介绍: games 表
id: 游戏IDtitle: 标题company: 公司type: 类型 (RPG, Action, etc.)production_year: 发行年份production_cost: 制作成本revenue: 收入rating: 评分
练习 6: 多重条件排序
需求: 查询所有游戏信息。首先按制作成本从低到高排序;如果成本相同,则按评分从高到低排序。
解决方案:
SELECT *
FROM games
ORDER BY production_cost ASC, rating DESC;
深度解析:
ORDER BY: 用于对结果集进行排序。ASC: 升序(Ascending),是默认值,可以省略。DESC: 降序(Descending)。production_cost ASC, rating DESC: 定义了两个排序规则。数据库会首先按production_cost升序排列所有行。当遇到production_cost相同的行时,再在这些行内部按rating降序排列。
性能优化提示:
为了优化排序,可以在排序列上创建索引。对于多列排序,创建一个复合索引 (production_cost, rating) 会非常高效。如果索引的排序方向(ASC/DESC)与查询完全匹配,数据库可以直接按索引顺序读取数据,避免昂贵的“文件排序”(filesort)操作。
练习 7: 筛选后排序
需求: 查询在2010年至2015年间制作、且评分高于7的游戏的制作成本。结果按成本从低到高排序。
解决方案:
SELECT production_cost
FROM games
WHERE
production_year BETWEEN 2010 AND 2015
AND rating > 7
ORDER BY production_cost ASC;
深度解析:
BETWEEN ... AND ...: 一个方便的范围操作符,它等价于production_year >= 2010 AND production_year <= 2015。它是包含边界值的。- SQL查询的逻辑执行顺序:理解这个顺序至关重要。
FROM: 确定操作的表。WHERE: 过滤行。SELECT: 选择要返回的列。ORDER BY: 对最终结果进行排序。
这意味着排序操作是在所有过滤完成之后才进行的。
练习 8: 按年份进行分组统计
需求: 按年份统计:
- 该年份发行的游戏总数(列名为
count)。 - 该年份游戏的平均制作成本(列名为
avg_cost)。 - 该年份游戏的平均收入(列名为
avg_revenue)。
解决方案:
SELECT
production_year,
COUNT(*) AS count,
AVG(production_cost) AS avg_cost,
AVG(revenue) AS avg_revenue
FROM games
GROUP BY production_year;
深度解析:
- 聚合函数:
COUNT(),AVG(),SUM(),MAX(),MIN()等函数会作用于一组行并返回单个值。 GROUP BY production_year: 这个子句是核心。它将所有具有相同production_year值的行合并成一个“组”,然后聚合函数会对每个组进行计算。AS: 用于给列或表达式起一个别名(Alias),使结果更具可读性。
常见陷阱:
SELECT 列表规则:当使用 GROUP BY 时,SELECT 列表中只能包含:
GROUP BY子句中出现的列(如production_year)。- 聚合函数(如
COUNT(*))。 - 常量。
如果你尝试SELECT title, COUNT(*) FROM games GROUP BY production_year;,就会报错(在标准的SQL模式下)。因为对于每个年份分组,存在多个title,数据库不知道该显示哪一个。
练习 9: 多列分组
需求: 统计每个公司制作的每种类型的游戏中,有多少是盈利的(收入 > 制作成本)。显示公司、类型和盈利游戏数量。
解决方案:
SELECT
company,
type,
COUNT(*) AS number_of_games
FROM games
WHERE revenue > production_cost
GROUP BY company, type;
深度解析:
GROUP BY company, type: 这里按两个列进行分组。数据库会为(company, type)的每个唯一组合创建一个分组。例如,("EA", "RPG") 是一个组,("EA", "Action") 是另一个组。
练习 10: 分组后筛选
需求: 统计每个公司的游戏总数和平均制作成本,但只显示那些制作了超过一款游戏的公司。
解决方案:
SELECT
company,
COUNT(company) AS number_of_games,
AVG(production_cost) AS avg_cost
FROM games
GROUP BY company
HAVING COUNT(company) > 1;
深度解析:
HAVING: 这个子句专门用于在GROUP BY之后对分组结果进行过滤。WHEREvsHAVING:WHERE在分组前作用于单行数据。HAVING在分组后作用于聚合结果。
你可以这样记:WHERE是“入场券”,HAVING是“分组后的VIP筛选”。在本例中,我们必须先计算出COUNT(company),然后才能判断它是否大于1,所以必须用HAVING。
COUNT(*) vs COUNT(column):
COUNT(*): 统计所有行的数量,不管任何列的值是什么。这是最常用、通常也是最优化的方式。COUNT(column): 只统计指定列中值不为NULL的行的数量。在本例中,因为company列不可能为NULL,所以COUNT(company)和COUNT(*)结果相同。但如果是在一个可能为NULL的列上计数,两者就有本质区别。
第三部分:多表连接——构建数据的关系网络
现实世界的数据库很少只有一个表。连接(JOIN)是将多个表中的数据根据相关联的列组合在一起的关键操作。
数据集介绍: employees
employee表:id: 员工IDfirst_name,last_name: 姓名salary: 薪水manager_id: 该员工上级的ID (关联回employee表的id)
salgrade表:grade: 薪资等级lower_limit,upper_limit: 该等级的薪资上下限
benefits表:salary_req: 获得该福利所需的最低薪水benefit_name: 福利名称
可视化JOIN类型
在开始之前,我们用Mermaid图来理解两种最核心的JOIN:
graph TD
subgraph "INNER JOIN (交集)"
A((Table A))
B((Table B))
A -- ON A.key = B.key --> C{Matched Rows}
B -- ON A.key = B.key --> C
end
subgraph "LEFT JOIN (左连接)"
D((Table A - Left))
E((Table B - Right))
D -- ON D.key = E.key --> F{All Rows from A}
E -- Matched --> F
style D fill:#cde,stroke:#333
end
INNER JOIN: 只返回两个表中连接键能匹配上的行。LEFT JOIN: 返回左表的所有行,以及右表中能匹配上的行。如果右表没有匹配行,则右表的列显示为NULL。
练习 11: 非等值连接
需求: 查询每位员工的姓名、薪水及其对应的薪资等级。
解决方案:
SELECT
e.first_name,
e.last_name,
e.salary,
sg.grade
FROM
employee AS e
JOIN
salgrade AS sg ON e.salary BETWEEN sg.lower_limit AND sg.upper_limit;
深度解析:
- 表别名
AS:employee AS e为employee表起了个简称e。这在多表连接中非常重要,可以使代码更简洁,并避免列名冲突。AS关键字可以省略,写成employee e。 JOIN ... ON ...: 这是现代SQL推荐的连接语法。JOIN指定要连接的表,ON指定连接条件。它比旧的FROM table1, table2 WHERE table1.id = table2.id语法更清晰,能将“连接条件”和“过滤条件”分离开。- 非等值连接: 这里的连接条件
ON e.salary BETWEEN sg.lower_limit AND sg.upper_limit不是简单的相等比较,而是一个范围判断。
性能优化提示:
非等值连接的优化比等值连接复杂。尽管如此,在 salgrade 表的 lower_limit 和 upper_limit 上建立索引仍然可能带来性能提升。
练习 12: 自连接 (Self-Join)
需求: 显示每位员工的姓名、薪水,以及其直属上级的姓名。只列出有上级的员工。
解决方案:
SELECT
e.first_name AS employee_first_name,
e.last_name AS employee_last_name,
e.salary AS employee_salary,
m.first_name AS manager_first_name,
m.last_name AS manager_last_name
FROM
employee AS e
JOIN
employee AS m ON e.manager_id = m.id;
深度解析:
- 自连接的核心思想: 我们需要将
employee表“一分为二”,在概念上把它看作两张独立的表:一张是“员工表”(别名为e),另一张是“经理表”(别名为m)。 ON e.manager_id = m.id: 这就是连接的桥梁。它表示“员工表”中的manager_id必须等于“经理表”中的id。通过这个条件,我们就把员工和他们的经理关联在了一起。- 这是一个
INNER JOIN,所以它只会返回e.manager_id不为NULL且能在m表中找到对应id的员工,这正好满足了“只列出有上级的员工”的需求。
练习 13: 多表连接与过滤
需求: 查询ID为5的员工可以获得的所有福利。显示该员工的姓名和福利名称。
解决方案:
SELECT
e.first_name,
e.last_name,
b.benefit_name
FROM
employee AS e
JOIN
benefits AS b ON e.salary >= b.salary_req
WHERE
e.id = 5;
深度解析:
ON e.salary >= b.salary_req: 这是另一个非等值连接。一个员工可以获得所有薪水要求低于或等于其当前薪水的福利,所以这是一个一对多的关系。WHERE e.id = 5:WHERE子句在JOIN操作之后执行,用于从连接后的巨大结果集中筛选出我们感兴趣的特定员工的记录。
性能优化提示:
- 先过滤后连接:虽然逻辑上
WHERE在JOIN后执行,但现代数据库的查询优化器非常智能。它可能会重写查询,先对employee表应用WHERE e.id = 5(如果id是主键,这会极快地返回一行),然后再用这一行数据去连接benefits表。这比先连接两个大表再过滤要高效得多。 - 索引:
employee.id(主键,自带索引)、employee.salary和benefits.salary_req上的索引对性能至关重要。
练习 14: 连接与分组聚合
需求: 统计每项福利分别有多少名员工可以获得。不显示无人获得的福利。
解决方案:
SELECT
b.benefit_name,
COUNT(e.id) AS employee_count
FROM
benefits AS b
JOIN
employee AS e ON b.salary_req <= e.salary
GROUP BY
b.benefit_name;
深度解析:
这个查询完美地结合了JOIN和GROUP BY。
FROM ... JOIN ... ON ...: 首先,通过薪水条件将benefits和employee表连接起来,生成一个临时的、巨大的结果集,其中每一行代表一个员工获得的一项福利。GROUP BY b.benefit_name: 然后,根据福利名称对这个结果集进行分组。SELECT b.benefit_name, COUNT(e.id): 最后,对每个福利分组,计算该组内有多少个员工ID(即有多少员工),从而得出结果。
练习 15: 外连接 (Outer Join) 的妙用
需求: 统计每项福利分别有多少名员工可以获得。这次,即使没有任何员工可以获得某项福利,也要显示出来,其员工计数为0。
解决方案:
SELECT
b.benefit_name,
COUNT(e.id) AS employee_count
FROM
benefits AS b
LEFT JOIN
employee AS e ON b.salary_req <= e.salary
GROUP BY
b.benefit_name
HAVING COUNT(e.id) = 0; -- 只显示0的
为了完整性,我们先讨论不带HAVING的情况,因为它更能体现LEFT JOIN的用途。
深度解析:
LEFT JOIN的关键作用: 与INNER JOIN不同,LEFT JOIN会保证左表 (benefits) 中的所有行都出现在最终结果里。- 当连接不成功时:对于某项福利(例如一项薪水要求极高的福利),如果在
employee表中找不到任何一个员工的薪水满足b.salary_req <= e.salary,那么在连接结果中,该福利所对应的所有employee表的列(如e.id,e.first_name等)都会被填充为NULL。 COUNT(e.id)的行为:COUNT(column)函数会忽略NULL值。因此,对于那些没有任何员工能获得的福利,COUNT(e.id)的结果自然就是0。这就是我们能够统计到0的方法。