MySQL Sakila 与 World 练习答案(一):S01~S24
对应题目:mysql-sakila-world-query-exercises.md。
分册导航:World W01~W16 · 性能与综合接口
以下使用 MySQL 8.0 和命名参数示意。mysql2 实际默认使用问号占位符;Sequelize replacements 可以使用冒号命名参数。不要把参数直接拼接到 SQL。
S01 查询客户详情
SELECT
cu.customer_id,
cu.first_name,
cu.last_name,
cu.email,
cu.active,
a.address,
a.district,
ci.city,
co.country,
a.postal_code,
a.phone
FROM customer AS cu
JOIN address AS a ON a.address_id = cu.address_id
JOIN city AS ci ON ci.city_id = a.city_id
JOIN country AS co ON co.country_id = ci.country_id
WHERE cu.customer_id = :customer_id;
customer_id 是主键,正常最多一行。每个 JOIN 都沿外键方向连接,没有必要使用 DISTINCT。
S02 启用客户前缀列表
SELECT customer_id, first_name, last_name, email, active
FROM customer
WHERE active = 1
AND last_name LIKE CONCAT(:last_name_prefix, ''%'')
ORDER BY last_name, first_name, customer_id
LIMIT 20;
前缀中的百分号和下划线仍是 LIKE 通配符。如果业务要求把用户输入当作普通文本,需要约定 ESCAPE 并转义它们。
S03 电影筛选列表
SELECT film_id, title, rating, rental_rate, length
FROM film
WHERE rating = :rating
AND rental_rate BETWEEN :min_rate AND :max_rate
AND length >= :min_length
ORDER BY rental_rate, film_id
LIMIT 30;
BETWEEN 两端都包含;若产品要求不同边界,应显式使用大于或小于运算符。
S04 分类电影
SELECT f.film_id, f.title, f.rating, f.rental_rate
FROM category AS c
JOIN film_category AS fc ON fc.category_id = c.category_id
JOIN film AS f ON f.film_id = fc.film_id
WHERE c.name = :category_name
ORDER BY f.title, f.film_id;
film_category 的复合主键保证同一电影和分类组合不重复。
S05 演员参演电影
SELECT
a.actor_id,
CONCAT_WS('' '', a.first_name, a.last_name) AS actor_name,
f.film_id,
f.title,
f.release_year
FROM actor AS a
JOIN film_actor AS fa ON fa.actor_id = a.actor_id
JOIN film AS f ON f.film_id = fa.film_id
WHERE a.actor_id = :actor_id
ORDER BY f.title, f.film_id;
S06 客户当前未归还电影
SELECT
r.rental_id,
r.rental_date,
f.film_id,
f.title,
i.store_id
FROM rental AS r
JOIN inventory AS i ON i.inventory_id = r.inventory_id
JOIN film AS f ON f.film_id = i.film_id
WHERE r.customer_id = :customer_id
AND r.return_date IS NULL
ORDER BY r.rental_date, r.rental_id;
租赁对象是一份 inventory,而不是抽象的 film。
S07 租赁详情
SELECT
r.rental_id,
r.rental_date,
r.return_date,
CONCAT_WS('' '', cu.first_name, cu.last_name) AS customer_name,
f.title AS film_title,
i.store_id,
CONCAT_WS('' '', s.first_name, s.last_name) AS staff_name
FROM rental AS r
JOIN customer AS cu ON cu.customer_id = r.customer_id
JOIN inventory AS i ON i.inventory_id = r.inventory_id
JOIN film AS f ON f.film_id = i.film_id
JOIN staff AS s ON s.staff_id = r.staff_id
WHERE r.rental_id = :rental_id;
S08 时间范围内支付流水
SELECT payment_id, customer_id, staff_id, rental_id, amount, payment_date
FROM payment
WHERE payment_date >= :start_at
AND payment_date < :end_at
ORDER BY payment_date DESC, payment_id DESC
LIMIT 50;
左闭右开能自然表达整天和连续分页区间,也避免在索引列上套 DATE 函数。
S09 客户累计消费
要求客户一定返回时:
SELECT
cu.customer_id,
COUNT(p.payment_id) AS payment_count,
COALESCE(SUM(p.amount), 0.00) AS total_amount
FROM customer AS cu
LEFT JOIN payment AS p ON p.customer_id = cu.customer_id
WHERE cu.customer_id = :customer_id
GROUP BY cu.customer_id;
外连接下 COUNT(*) 会把客户占位行计为 1;COUNT(p.payment_id) 只统计真实支付。
S10 时间范围内高消费客户
SELECT
cu.customer_id,
CONCAT_WS('' '', cu.first_name, cu.last_name) AS customer_name,
COUNT(p.payment_id) AS payment_count,
SUM(p.amount) AS total_amount
FROM customer AS cu
JOIN payment AS p ON p.customer_id = cu.customer_id
WHERE p.payment_date >= :start_at
AND p.payment_date < :end_at
GROUP BY cu.customer_id, cu.first_name, cu.last_name
HAVING SUM(p.amount) > :minimum_amount
ORDER BY total_amount DESC, cu.customer_id;
WHERE 先过滤原始支付;HAVING 再过滤聚合组。
S11 没有租赁记录的客户
NOT EXISTS 版本:
SELECT cu.customer_id, cu.first_name, cu.last_name, cu.email
FROM customer AS cu
WHERE NOT EXISTS (
SELECT 1
FROM rental AS r
WHERE r.customer_id = cu.customer_id
)
ORDER BY cu.customer_id;
LEFT JOIN 版本:
SELECT cu.customer_id, cu.first_name, cu.last_name, cu.email
FROM customer AS cu
LEFT JOIN rental AS r ON r.customer_id = cu.customer_id
WHERE r.rental_id IS NULL
ORDER BY cu.customer_id;
二者表达相同反连接。实际计划以 EXPLAIN 为准。
S12 没有任何库存的电影
SELECT f.film_id, f.title
FROM film AS f
WHERE NOT EXISTS (
SELECT 1
FROM inventory AS i
WHERE i.film_id = f.film_id
)
ORDER BY f.film_id;
它查询“无库存副本”,不是“库存都借出”。
S13 门店当前可出租库存
SELECT i.inventory_id, i.film_id, f.title, i.store_id
FROM inventory AS i
JOIN film AS f ON f.film_id = i.film_id
WHERE i.store_id = :store_id
AND NOT EXISTS (
SELECT 1
FROM rental AS r
WHERE r.inventory_id = i.inventory_id
AND r.return_date IS NULL
)
ORDER BY f.title, i.inventory_id;
S14 每门店库存与可出租数量
先将粒度保持为“一份库存一行”:
SELECT
i.store_id,
COUNT(*) AS total_inventory_count,
SUM(
CASE WHEN NOT EXISTS (
SELECT 1
FROM rental AS r
WHERE r.inventory_id = i.inventory_id
AND r.return_date IS NULL
) THEN 1 ELSE 0 END
) AS available_inventory_count,
SUM(
CASE WHEN EXISTS (
SELECT 1
FROM rental AS r
WHERE r.inventory_id = i.inventory_id
AND r.return_date IS NULL
) THEN 1 ELSE 0 END
) AS rented_inventory_count
FROM inventory AS i
GROUP BY i.store_id
ORDER BY i.store_id;
直接 JOIN 全部 rental 历史会把一份库存扩成多行。也可先把未归还 inventory_id 去重后再关联。
S15 逾期未归还
SELECT
r.rental_id,
r.customer_id,
CONCAT_WS('' '', cu.first_name, cu.last_name) AS customer_name,
f.title AS film_title,
r.rental_date,
DATE_ADD(r.rental_date, INTERVAL f.rental_duration DAY)
AS expected_return_at,
TIMESTAMPDIFF(
DAY,
DATE_ADD(r.rental_date, INTERVAL f.rental_duration DAY),
CURRENT_TIMESTAMP
) AS overdue_days
FROM rental AS r
JOIN customer AS cu ON cu.customer_id = r.customer_id
JOIN inventory AS i ON i.inventory_id = r.inventory_id
JOIN film AS f ON f.film_id = i.film_id
WHERE r.return_date IS NULL
AND CURRENT_TIMESTAMP >
DATE_ADD(r.rental_date, INTERVAL f.rental_duration DAY)
ORDER BY expected_return_at, r.rental_id;
TIMESTAMPDIFF(DAY) 计算完整经过的天数;DATEDIFF 只比较日期部分,午夜附近语义不同。
S16 按天统计营业额
SELECT
DATE(payment_date) AS payment_day,
COUNT(*) AS payment_count,
SUM(amount) AS total_amount
FROM payment
WHERE payment_date >= :start_at
AND payment_date < :end_at
GROUP BY DATE(payment_date)
ORDER BY payment_day;
展示和分组可使用 DATE;范围过滤仍直接作用于原字段。
S17 按月、门店统计营业额
SELECT
DATE_FORMAT(p.payment_date, ''%Y-%m'') AS payment_month,
s.store_id,
COUNT(*) AS payment_count,
SUM(p.amount) AS total_amount
FROM payment AS p
JOIN staff AS s ON s.staff_id = p.staff_id
WHERE p.payment_date >= :start_at
AND p.payment_date < :end_at
GROUP BY
DATE_FORMAT(p.payment_date, ''%Y-%m''),
s.store_id
ORDER BY payment_month, s.store_id;
年份和月份一起分组,避免不同年份的一月合并。
S18 电影出租次数 Top 10
SELECT f.film_id, f.title, COUNT(r.rental_id) AS rental_count
FROM film AS f
JOIN inventory AS i ON i.film_id = f.film_id
JOIN rental AS r ON r.inventory_id = i.inventory_id
GROUP BY f.film_id, f.title
ORDER BY rental_count DESC, f.film_id
LIMIT 10;
S19 分类营业额
SELECT
c.category_id,
c.name AS category_name,
COUNT(p.payment_id) AS payment_count,
COALESCE(SUM(p.amount), 0.00) AS total_amount
FROM category AS c
LEFT JOIN film_category AS fc ON fc.category_id = c.category_id
LEFT JOIN inventory AS i ON i.film_id = fc.film_id
LEFT JOIN rental AS r ON r.inventory_id = i.inventory_id
LEFT JOIN payment AS p ON p.rental_id = r.rental_id
GROUP BY c.category_id, c.name
ORDER BY total_amount DESC, c.category_id;
Sakila 中 film_category 每部电影通常只有一个分类,payment.rental_id 也有索引但并非模式层唯一约束。若业务假设一次 rental 只有一笔 payment,应先用数据约束或审计查询验证;多笔支付会被真实累计。
S20 员工和门店收款
SELECT
s.staff_id,
CONCAT_WS('' '', s.first_name, s.last_name) AS staff_name,
s.store_id,
COUNT(p.payment_id) AS payment_count,
COALESCE(SUM(p.amount), 0.00) AS total_amount
FROM staff AS s
LEFT JOIN payment AS p
ON p.staff_id = s.staff_id
AND p.payment_date >= :start_at
AND p.payment_date < :end_at
GROUP BY s.staff_id, s.first_name, s.last_name, s.store_id
ORDER BY s.store_id, total_amount DESC, s.staff_id;
时间条件放在 ON 中可保留范围内没有收款的员工;若只需要有收款者,可改 INNER JOIN 并放 WHERE。
S21 每名客户最近一次租赁
WITH ranked_rentals AS (
SELECT
r.customer_id,
r.rental_id,
r.rental_date,
f.title,
ROW_NUMBER() OVER (
PARTITION BY r.customer_id
ORDER BY r.rental_date DESC, r.rental_id DESC
) AS row_number_in_customer
FROM rental AS r
JOIN inventory AS i ON i.inventory_id = r.inventory_id
JOIN film AS f ON f.film_id = i.film_id
)
SELECT
cu.customer_id,
CONCAT_WS('' '', cu.first_name, cu.last_name) AS customer_name,
rr.rental_id,
rr.rental_date AS latest_rental_date,
rr.title AS latest_film_title
FROM customer AS cu
LEFT JOIN ranked_rentals AS rr
ON rr.customer_id = cu.customer_id
AND rr.row_number_in_customer = 1
ORDER BY cu.customer_id;
ROW_NUMBER 的第二排序键解决相同时间的确定性。
S22 每分类出租次数最多的三部电影
WITH film_counts AS (
SELECT
c.category_id,
c.name AS category_name,
f.film_id,
f.title,
COUNT(r.rental_id) AS rental_count
FROM category AS c
JOIN film_category AS fc ON fc.category_id = c.category_id
JOIN film AS f ON f.film_id = fc.film_id
LEFT JOIN inventory AS i ON i.film_id = f.film_id
LEFT JOIN rental AS r ON r.inventory_id = i.inventory_id
GROUP BY c.category_id, c.name, f.film_id, f.title
),
ranked AS (
SELECT
film_counts.*,
ROW_NUMBER() OVER (
PARTITION BY category_id
ORDER BY rental_count DESC, film_id
) AS position_in_category
FROM film_counts
)
SELECT category_id, category_name, film_id, title, rental_count
FROM ranked
WHERE position_in_category <= 3
ORDER BY category_id, position_in_category;
ROW_NUMBER 保证每分类最多三行。若要求并列第三名全部进入,使用 DENSE_RANK,但可能超过三行。
S23 高于所属分类平均租金
WITH films_with_average AS (
SELECT
c.category_id,
c.name AS category_name,
f.film_id,
f.title,
f.rental_rate,
AVG(f.rental_rate) OVER (
PARTITION BY c.category_id
) AS category_average_rate
FROM category AS c
JOIN film_category AS fc ON fc.category_id = c.category_id
JOIN film AS f ON f.film_id = fc.film_id
)
SELECT
category_id,
category_name,
film_id,
title,
rental_rate,
category_average_rate
FROM films_with_average
WHERE rental_rate > category_average_rate
ORDER BY category_id, rental_rate DESC, film_id;
S24 客户累计消费排名
包含没有支付的客户:
WITH customer_totals AS (
SELECT
cu.customer_id,
cu.first_name,
cu.last_name,
COALESCE(SUM(p.amount), 0.00) AS total_amount
FROM customer AS cu
LEFT JOIN payment AS p ON p.customer_id = cu.customer_id
GROUP BY cu.customer_id, cu.first_name, cu.last_name
)
SELECT
customer_id,
CONCAT_WS('' '', first_name, last_name) AS customer_name,
total_amount,
DENSE_RANK() OVER (ORDER BY total_amount DESC) AS amount_rank
FROM customer_totals
ORDER BY amount_rank, customer_id;
先聚合再排名,避免在同一层混用明细聚合和窗口排名。