MySQL Sakila 与 World 练习答案(三):性能与综合接口

对应题目:mysql-sakila-world-query-exercises.md

分册导航:Sakila S01~S24 · World W01~W16

执行计划会随 MySQL 版本、索引、统计信息和数据量变化。下面给出检查方法和通常预期,不能伪造某台数据库的 rows、actual time。

P01 时间函数过滤与范围过滤

EXPLAIN
SELECT payment_id, payment_date
FROM payment
WHERE DATE(payment_date) = ''2005-08-01'';

EXPLAIN
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'';

SHOW INDEX FROM payment;

两者在相同会话时区和 DATETIME 语义下目标结果相同。DATE(payment_date) 对列做函数运算,普通 payment_date B-Tree 通常不能直接做范围定位;左闭右开写法具有可搜索性。若表上根本没有 payment_date 索引,两者仍可能全表扫描,此时不能把“都扫描”误解为写法没有区别。可在测试环境增加合适索引后再比较。

P02 WHERE 与 HAVING

EXPLAIN
SELECT customer_id, SUM(amount) AS total_amount
FROM payment
WHERE customer_id = :customer_id
GROUP BY customer_id;

EXPLAIN
SELECT customer_id, SUM(amount) AS total_amount
FROM payment
GROUP BY customer_id
HAVING customer_id = :customer_id;

客户 ID 是明细行条件,放 WHERE 更符合语义,也明确要求聚合前缩小输入。MySQL 可能把第二种条件下推,但是否下推应查看 EXPLAIN,业务代码不应依赖优化器替开发者表达正确阶段。

P03 深分页与游标分页

SELECT ID, Name, Population
FROM city
ORDER BY Population DESC, ID DESC
LIMIT 4000, 20;

SELECT ID, Name, Population
FROM city
WHERE (Population, ID) < (:last_population, :last_id)
ORDER BY Population DESC, ID DESC
LIMIT 20;

OFFSET 查询至少要定位并丢弃前 4000 行,再返回 20 行;游标查询可从边界继续。若业务规模需要,可评估:

CREATE INDEX idx_city_population_id
ON city (Population DESC, ID DESC);

建索引前必须检查现有索引、写入成本和真实执行计划。一亿行下,大 OFFSET 的扫描、排序和回表成本会明显扩大。

P04 SELECT 星号与明确字段

EXPLAIN
SELECT *
FROM film
WHERE film_id BETWEEN 1 AND 100;

EXPLAIN
SELECT film_id, title, rating, rental_rate
FROM film
WHERE film_id BETWEEN 1 AND 100;

主键范围定位可能相同,但明确字段减少网络、驱动解码和 Node.js 对象内存。只有查询所需列全部存在于某个索引中时才可能成为覆盖索引;第二条并不自动保证覆盖。包含 TEXT/BLOB 的宽表中差异更明显。

P05 检查 JOIN 放大

SELECT COUNT(*) FROM film WHERE film_id = :film_id;

SELECT COUNT(*)
FROM film AS f
JOIN inventory AS i ON i.film_id = f.film_id
WHERE f.film_id = :film_id;

SELECT 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
WHERE f.film_id = :film_id;

SELECT 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
JOIN payment AS p ON p.rental_id = r.rental_id
WHERE f.film_id = :film_id;

再同时观察业务键:

SELECT
  COUNT(DISTINCT i.inventory_id) AS inventories,
  COUNT(DISTINCT r.rental_id) AS rentals,
  COUNT(p.payment_id) AS payments,
  SUM(p.amount) AS payment_amount
FROM inventory AS i
LEFT JOIN rental AS r ON r.inventory_id = i.inventory_id
LEFT JOIN payment AS p ON p.rental_id = r.rental_id
WHERE i.film_id = :film_id;

一部 film 有多份 inventory,一份 inventory 有多次 rental,一次 rental 在模式层可能对应支付记录。聚合前必须明确要统计库存、租赁还是支付。

P06 EXISTS 与 JOIN

存在性写法:

SELECT cu.customer_id, cu.first_name, cu.last_name
FROM customer AS cu
WHERE EXISTS (
  SELECT 1
  FROM rental AS r
  WHERE r.customer_id = cu.customer_id
)
ORDER BY cu.customer_id;

JOIN 写法先把右侧压到每客户一行:

SELECT cu.customer_id, cu.first_name, cu.last_name
FROM customer AS cu
JOIN (
  SELECT customer_id
  FROM rental
  GROUP BY customer_id
) AS customers_with_rentals
  ON customers_with_rentals.customer_id = cu.customer_id
ORDER BY cu.customer_id;

业务只问“是否存在”时 EXISTS 更直接,也不需要用 DISTINCT 掩盖一对多结果。

P07 低选择性字段

SELECT active, COUNT(*) AS customer_count
FROM customer
GROUP BY active;

EXPLAIN
SELECT customer_id, last_name
FROM customer
WHERE active = 1;

如果绝大多数客户 active=1,单列索引可能要访问表中大部分记录,优化器可能认为全表扫描更便宜。索引价值取决于选择性、返回列和查询组合;例如真实查询经常使用状态、创建时间和稳定排序时,才评估相应联合索引,而不是看见 WHERE 字段就建索引。

P08 EXPLAIN ANALYZE

测试示例:

EXPLAIN ANALYZE
SELECT
  cu.customer_id,
  cu.last_name,
  SUM(p.amount) AS total_amount
FROM customer AS cu
JOIN payment AS p ON p.customer_id = cu.customer_id
WHERE cu.active = 1
  AND p.payment_date >= ''2005-08-01''
  AND p.payment_date <  ''2005-09-01''
GROUP BY cu.customer_id, cu.last_name
ORDER BY total_amount DESC, cu.customer_id
LIMIT 20;

记录每个节点的预计 rows、实际 rows、actual time、loops、访问方式和索引。预计与实际差异很大可能意味着统计信息、列相关性或过滤估算不准;loops 很高的内层节点可能放大成本。EXPLAIN ANALYZE 会真实执行查询,只能在受控环境使用。

C01 客户管理列表

避免 payment 与 rental 明细互相相乘,先分别聚合:

WITH payment_totals AS (
  SELECT
    customer_id,
    COUNT(*) AS payment_count,
    SUM(amount) AS total_amount
  FROM payment
  GROUP BY customer_id
),
ranked_rentals AS (
  SELECT
    r.customer_id,
    r.rental_id,
    r.rental_date,
    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
)
SELECT
  cu.customer_id,
  cu.first_name,
  cu.last_name,
  cu.email,
  cu.active,
  co.country,
  COALESCE(pt.payment_count, 0) AS payment_count,
  COALESCE(pt.total_amount, 0.00) AS total_amount,
  rr.rental_date AS latest_rental_date
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
LEFT JOIN payment_totals AS pt ON pt.customer_id = cu.customer_id
LEFT JOIN ranked_rentals AS rr
  ON rr.customer_id = cu.customer_id
 AND rr.row_number_in_customer = 1
WHERE (:active IS NULL OR cu.active = :active)
  AND (:last_name_prefix IS NULL
       OR cu.last_name LIKE CONCAT(:last_name_prefix, ''%''))
  AND (:country_id IS NULL OR co.country_id = :country_id)
ORDER BY total_amount DESC, cu.customer_id
LIMIT :page_size OFFSET :offset;

可选条件的 OR 写法方便展示,但可能影响索引选择。真实后端可使用白名单动态组装“是否加入整个条件”,值仍参数化。total_amount 与 customer_id 共同保证稳定排序;大数据应改为包含两者的游标分页。total 查询应单独执行与筛选条件一致的 COUNT,不要对分页结果取 length。

C02 电影详情

详情接口不必强行一条巨大 SQL。推荐少量职责明确的查询,在同一请求中有限并发执行。

基础信息与语言:

SELECT
  f.film_id, f.title, f.description, f.release_year,
  f.rental_duration, f.rental_rate, f.length,
  f.replacement_cost, f.rating,
  l.name AS language_name
FROM film AS f
JOIN language AS l ON l.language_id = f.language_id
WHERE f.film_id = :film_id;

分类:

SELECT c.category_id, c.name
FROM film_category AS fc
JOIN category AS c ON c.category_id = fc.category_id
WHERE fc.film_id = :film_id
ORDER BY c.name, c.category_id;

演员:

SELECT
  a.actor_id,
  CONCAT_WS('' '', a.first_name, a.last_name) AS actor_name
FROM film_actor AS fa
JOIN actor AS a ON a.actor_id = fa.actor_id
WHERE fa.film_id = :film_id
ORDER BY a.last_name, a.first_name, a.actor_id;

各门店库存和可出租数量:

SELECT
  s.store_id,
  COUNT(i.inventory_id) AS inventory_count,
  SUM(
    CASE WHEN i.inventory_id IS NOT NULL
      AND 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
FROM store AS s
LEFT JOIN inventory AS i
  ON i.store_id = s.store_id
 AND i.film_id = :film_id
GROUP BY s.store_id
ORDER BY s.store_id;

累计出租次数:

SELECT COUNT(r.rental_id) AS rental_count
FROM inventory AS i
LEFT JOIN rental AS r ON r.inventory_id = i.inventory_id
WHERE i.film_id = :film_id;

先验证基础电影存在,再组合结果。不要把分类、演员、库存、租赁全部 JOIN 后依靠 GROUP_CONCAT(DISTINCT …) 修补乘法膨胀。

C03 国家统计列表

城市和语言分别聚合,最大城市单独排名:

WITH city_statistics AS (
  SELECT
    CountryCode,
    COUNT(*) AS city_count,
    SUM(Population) AS city_population
  FROM city
  GROUP BY CountryCode
),
ranked_cities AS (
  SELECT
    CountryCode,
    ID,
    Name,
    Population,
    ROW_NUMBER() OVER (
      PARTITION BY CountryCode
      ORDER BY Population DESC, ID
    ) AS position_in_country
  FROM city
),
official_language_statistics AS (
  SELECT
    CountryCode,
    COUNT(*) AS official_language_count
  FROM countrylanguage
  WHERE IsOfficial = ''T''
  GROUP BY CountryCode
)
SELECT
  co.Code AS country_code,
  co.Name AS country_name,
  co.Continent AS continent,
  co.Region AS region,
  co.Population AS country_population,
  COALESCE(cs.city_count, 0) AS city_count,
  COALESCE(cs.city_population, 0) AS city_population,
  rc.Name AS largest_city_name,
  rc.Population AS largest_city_population,
  COALESCE(ols.official_language_count, 0)
    AS official_language_count,
  capital.Name AS capital_name
FROM country AS co
LEFT JOIN city_statistics AS cs ON cs.CountryCode = co.Code
LEFT JOIN ranked_cities AS rc
  ON rc.CountryCode = co.Code
 AND rc.position_in_country = 1
LEFT JOIN official_language_statistics AS ols
  ON ols.CountryCode = co.Code
LEFT JOIN city AS capital ON capital.ID = co.Capital
WHERE (:continent IS NULL OR co.Continent = :continent)
ORDER BY co.Population DESC, co.Code
LIMIT :page_size OFFSET :offset;

因为 city 和 countrylanguage 已各自压缩为每国家一行,所以不会交叉相乘。可选筛选、LIMIT 参数和深分页处理方式与 C01 相同;列表 total 另做相同 WHERE 条件的 COUNT。