本文基于 MySQL 官方常见示例数据库 sakila 和 world 编写,面向后端初学者练习日常高频查询。
本文只提供题目、结果要求、提示和易错点,不提供答案。完成某一道题后,可以携带题号和 SQL 进行求证。
GROUP BY、HAVING 和条件聚合。EXISTS、NOT EXISTS 查询存在性。NULL、日期范围和未归还数据。EXPLAIN 初步判断查询效率。除非题目明确要求,不使用 SELECT *。
列表查询必须有确定的排序规则。
LIMIT 查询遇到相同排序值时,应增加主键作为第二排序字段。
日期区间优先使用左闭右开:
>= 开始时间
< 结束时间
完成查询后,建议执行:
EXPLAIN
SELECT ...;
MySQL 8.0 可以在测试环境使用:
EXPLAIN ANALYZE
SELECT ...;
EXPLAIN ANALYZE 会真实执行查询,不要对未知成本的查询在生产环境随意使用。
customer.address_id → address.address_id
address.city_id → city.city_id
city.country_id → country.country_id
rental.customer_id → customer.customer_id
rental.inventory_id → inventory.inventory_id
rental.staff_id → staff.staff_id
inventory.film_id → film.film_id
inventory.store_id → store.store_id
payment.customer_id → customer.customer_id
payment.rental_id → rental.rental_id
payment.staff_id → staff.staff_id
film_actor.film_id → film.film_id
film_actor.actor_id → actor.actor_id
film_category.film_id → film.film_id
film_category.category_id → category.category_id
city.CountryCode → country.Code
country.Capital → city.ID
countrylanguage.CountryCode → country.Code
后台根据 customer_id 查询客户详情。
customer_id
first_name
last_name
email
active
address
district
city
country
postal_code
phone
customer、address、city、country。city.city_id 错误地与 country.country_id关联。SELECT *导致多个表的同名字段混在一起。后台分页查询处于启用状态的客户,支持按照姓氏前缀搜索。
active = 1。last_name、first_name、customer_id升序。LIKE '文本%'。customer_id用于保证稳定排序。LIKE '%文本%',导致普通索引难以定位前缀。LIMIT 20,没有 ORDER BY。电影列表接口需要按照评级、租金和时长筛选。
rating为指定值。rental_rate在指定范围内。length不小于指定时长。rental_rate升序、film_id升序。film_id
title
rating
rental_rate
length
rental_rate排序,导致相同价格的结果顺序不稳定。根据分类名称查询电影列表。
category、film_category、film。film_category。DISTINCT掩盖问题。根据 actor_id查询该演员参演的全部电影。
actor_id
actor_name
film_id
title
release_year
CONCAT_WS()拼接演员姓名。actor、film_actor、film。actor.actor_id直接与 film.film_id关联。根据 customer_id查询尚未归还的租赁记录。
return_date为空
rental_id
rental_date
film_id
title
store_id
rental、inventory、film。IS NULL。return_date = NULL。inventory_id。根据 rental_id返回一次租赁的完整详情。
rental_id
rental_date
return_date
customer_name
film_title
store_id
staff_name
rental不能直接关联 film,需要经过 inventory。查询某个时间段内的支付流水。
payment_date倒序、payment_id倒序。日期范围使用:
payment_date >= ?
AND payment_date < ?
DATE(payment_date) = ?,可能降低普通时间索引的利用率。BETWEEN '2026-01-01' AND '2026-01-31',遗漏最后一天大部分时间。根据 customer_id查询客户累计支付金额和支付次数。
customer_id
payment_count
total_amount
COUNT()和 SUM()。customer开始并考虑 LEFT JOIN与 COALESCE()。COUNT(*)和 COUNT(payment_id)时,没有理解外连接下的区别。查询指定时间范围内累计消费超过指定金额的客户。
WHERE或正确的JOIN条件中。HAVING。customer_id
customer_name
payment_count
total_amount
HAVING。ONLY_FULL_GROUP_BY错误。运营需要找出注册后从未租过电影的客户。
尝试分别使用:
NOT EXISTS。LEFT JOIN ... IS NULL。然后使用 EXPLAIN比较。
LEFT JOIN后在 WHERE中错误引用右表普通字段,使外连接变成内连接。NOT IN时没有考虑子查询中的 NULL。找出电影目录中存在,但两个门店都没有库存副本的电影。
film与 inventory是一对多。NOT EXISTS。查询指定门店中当前没有被借出的库存副本。
一份库存如果不存在 return_date IS NULL的租赁记录,就认为当前可出租。
inventory_id
film_id
title
store_id
NOT EXISTS表达“不存在未归还租赁”。门店库存看板需要显示:
store_id
total_inventory_count
available_inventory_count
rented_inventory_count
SUM(CASE WHEN ... THEN 1 ELSE 0 END)。电影的应归还时间为:
rental_date + film.rental_duration天
查询当前仍未归还且已经超过应归还时间的租赁记录。
DATE_ADD()。CURRENT_TIMESTAMP。rental_id
customer_id
customer_name
film_title
rental_date
expected_return_at
overdue_days
TIMESTAMPDIFF()或 DATEDIFF()的差异。return_date IS NULL。rental_duration单位是天。统计指定时间范围内每天的支付笔数和支付总额。
payment_day
payment_count
total_amount
DATE(payment_date)。payment_date字段。WHERE中使用 DATE(payment_date)过滤。按月份、门店统计支付总额。
payment通过 staff_id可以关联员工所属门店。DATE_FORMAT()。查询出租次数最多的10部电影。
film_id
title
rental_count
film → inventory → rental。film_id作为稳定排序字段。统计每个电影分类产生的支付金额。
关系路径较长:
category
→ film_category
→ film
→ inventory
→ rental
→ payment
完成后建议挑选一个分类,逐层查询验证总额。
统计指定时间范围内每名员工的收款笔数和收款金额,并显示所属门店。
payment.staff_id → staff.staff_id。WHERE过滤。客户列表需要展示每名客户最近一次租赁时间和电影名称。
ROW_NUMBER()。customer_id。rental_date DESC, rental_id DESC。LEFT JOIN保留没有租赁的客户。MAX(rental_date),却无法可靠取得同一行的电影名称。每个分类分别显示Top 3电影,而不是全局Top 3。
ROW_NUMBER()或 DENSE_RANK()分区排名。LIMIT 3,得到的是所有分类合计的前三名。PARTITION BY列选择错误。查询租金高于其所属分类平均租金的电影。
AVG() OVER (PARTITION BY ...)。计算每名客户的累计支付金额,并按照金额排名。
customer_id
customer_name
total_amount
amount_rank
DENSE_RANK()。根据国家代码查询国家详情,同时显示首都城市名称。
country_code
country_name
continent
region
population
life_expectancy
capital_city
country.Capital → city.ID。Capital可能为空,考虑 LEFT JOIN。city.CountryCode = country.Capital。根据国家代码查询城市,按人口倒序分页。
Population DESC
ID DESC
ID
Name
District
Population
查询指定洲、人口位于指定范围的国家。
Continent。查询全球人口最多的20座城市,并显示所属国家。
city_id
city_name
country_code
country_name
population
Name在两个表中都存在,没有使用表别名。国家搜索接口根据名称前缀返回最多20条结果。
使用:
LIKE CONCAT(?, '%')
根据国家代码查询所有官方语言及使用比例。
IsOfficial = 'T'。
按 Percentage倒序、Language升序。
IsOfficial误认为MySQL布尔数字字段。找出 country中不存在官方语言记录的国家。
尝试:
NOT EXISTS。LEFT JOIN ... IS NULL。LEFT JOIN后将 IsOfficial = 'T'放错位置。查询 country中不存在任何 city记录的国家。
NOT EXISTS。country.Code = city.CountryCode。continent
country_count
total_population
average_population
只保留国家数量不少于指定值的洲。
HAVING。WHERE。列出所有国家,包括没有城市记录的国家。
country_code
country_name
city_count
city_population
country_population
country开始使用 LEFT JOIN。COALESCE()处理城市人口合计为空。COUNT(*)导致没有城市的国家计数为1。COUNT(city.ID)。对每个国家找出人口最多的城市,并计算其人口占国家总人口的比例。
country_code
country_name
largest_city_name
largest_city_population
country_population
population_ratio
ROW_NUMBER()找每个国家人口最大的城市。NULLIF()避免除以零。ROUND()。查询官方语言数量不少于2种的国家。
country_code
country_name
official_language_count
HAVING。根据国家人口和语言使用比例,估算每种语言在所有国家中的使用人口。
国家人口 × Percentage / 100
language
country_count
estimated_speaker_population
countrylanguage关联 country。SUM()。ROUND()控制展示精度。每个国家分别查询Top 3城市。
ROW_NUMBER()。PARTITION BY CountryCode。LIMIT 3。找出人口高于该洲国家平均人口的国家。
可以使用:
AVG(Population) OVER (PARTITION BY Continent)。按人口倒序查询全球城市,每页20条,不使用大偏移量。
Population DESC
ID DESC
下一页需要携带上一页最后一条记录的:
Population
ID
可以使用行比较:
(Population, ID) < (?, ?)
分别为下面两种查询执行 EXPLAIN:
SELECT payment_id, payment_date
FROM payment
WHERE DATE(payment_date) = '2005-08-01';
以及:
SELECT payment_id, payment_date
FROM payment
WHERE payment_date >= '2005-08-01 00:00:00'
AND payment_date < '2005-08-02 00:00:00';
rows估算有何差异?分别编写两份“只统计指定客户支付总额”的查询:
WHERE。HAVING。执行 EXPLAIN并比较。
分别查询城市人口排序的深分页:
LIMIT 4000, 20
以及使用上一页最后一条记录作为游标的分页。
对比:
SELECT *
FROM film
WHERE film_id BETWEEN 1 AND 100;
和只选择:
film_id
title
rating
rental_rate
从一部指定电影开始,依次关联:
film
inventory
rental
payment
每增加一个JOIN,都统计一次总行数。
使用以下两种方式查询有租赁记录的客户:
EXISTS。JOIN。DISTINCT掩盖重复。分别统计 customer.active = 1和 customer.active = 0的数量。
active字段的不同值各占多少比例?选择一条包含JOIN、过滤和排序的查询,在测试环境执行:
EXPLAIN ANALYZE
SELECT ...;
预计行数
实际行数
actual time
loops
使用的索引
实现一个客户管理列表查询,支持:
active筛选。可以先分别在 CTE 中计算:
客户支付汇总
客户最近租赁
再与客户主表关联。
根据 film_id查询:
GROUP_CONCAT()后无法判断重复来自真实数据还是错误JOIN。后端详情接口不一定必须用一条SQL完成。可以评估:
一条复杂SQL
vs
少量职责明确的批量查询
查询国家列表,展示:
支持按洲筛选、按国家人口倒序分页。
COUNT(city.ID)和 COUNT(language.Language)结果被放大。先分别聚合:
城市统计
官方语言统计
最大城市
再关联国家表。
[ ] 查询是否只返回必要字段?
[ ] JOIN条件是否完整?
[ ] 查询结果的“一行”代表什么业务对象?
[ ] 是否因为一对多JOIN产生重复?
[ ] 普通条件是否放在WHERE?
[ ] 聚合结果条件是否放在HAVING?
[ ] NULL是否使用IS NULL或IS NOT NULL判断?
[ ] 日期范围是否使用左闭右开?
[ ] 排序是否稳定?
[ ] 分页是否可能出现重复或遗漏?
[ ] 是否存在大OFFSET?
[ ] 是否在索引字段上使用函数或隐式类型转换?
[ ] 是否可以用EXISTS表达存在性?
[ ] 是否无意义地使用了DISTINCT?
[ ] EXPLAIN显示使用了哪个索引?
[ ] 预计扫描行数和返回行数是否合理?
S01~S10
→ W01~W10
→ S11~S20
→ W11~W16
→ S21~S24
→ P01~P08
→ C01~C03
建议一次只完成一道题,并同时记录:
自己的SQL
查询结果行数
EXPLAIN结果
遇到的问题
修改前后的差异