10 Film:多对多查询与 N+1

本章练习电影目录查询。它以读取为主,但比 Actor CRUD 更容易暴露 Association、重复行和 N+1 等问题。所有接口必须经过 authenticateAdmin

1. 表关系

film n --- 1 language
film 1 --- n film_actor n --- 1 actor
film 1 --- n film_category n --- 1 category
film 1 --- n inventory n --- 1 store
inventory 1 --- n rental

容易混淆的概念:

2. 接口清单

方法 路径 作用
GET /api/sakila/films 多条件分页查询
GET /api/sakila/films/:filmId 电影详情
GET /api/sakila/actors/:actorId/films 演员参演电影
GET /api/sakila/categories/:categoryId/films 分类电影列表

Router 骨架:

const router = Router();
router.use(authenticateAdmin);

router.get("/films", filmListValidation, validateRequest, listFilmsController);
router.get("/films/:filmId", filmIdValidation, validateRequest, getFilmController);
router.get(
  "/actors/:actorId/films",
  actorFilmsValidation,
  validateRequest,
  listActorFilmsController,
);
router.get(
  "/categories/:categoryId/films",
  categoryFilmsValidation,
  validateRequest,
  listCategoryFilmsController,
);

3. 请求和响应类型

export interface FilmIdParams {
  filmId: string;
}

export interface FilmListQuery {
  page?: string;
  pageSize?: string;
  title?: string;
  rating?: "G" | "PG" | "PG-13" | "R" | "NC-17";
  categoryId?: string;
  languageId?: string;
  minRentalRate?: string;
  maxRentalRate?: string;
  minLength?: string;
  maxLength?: string;
  sort?: "filmId" | "title" | "rentalRate" | "length" | "releaseYear";
  order?: "asc" | "desc";
}

export interface FilmListItemDto {
  filmId: number;
  title: string;
  releaseYear: number | null;
  language: string;
  rentalDuration: number;
  rentalRate: string;
  length: number | null;
  replacementCost: string;
  rating: string;
}

export interface FilmDetailDto extends FilmListItemDto {
  description: string | null;
  specialFeatures: string[];
  actors: Array<{
    actorId: number;
    firstName: string;
    lastName: string;
  }>;
  categories: Array<{
    categoryId: number;
    name: string;
  }>;
  stores: Array<{
    storeId: number;
    inventoryCount: number;
    availableCount: number;
  }>;
  rentalCount: number;
}

rentalRatereplacementCost 对应 MySQL DECIMAL,DTO 使用 string 以保留精度。

4. 电影分页列表

export interface FindFilmsOptions {
  page: number;
  pageSize: number;
  title?: string;
  rating?: FilmListQuery["rating"];
  categoryId?: number;
  languageId?: number;
  minRentalRate?: string;
  maxRentalRate?: string;
  minLength?: number;
  maxLength?: number;
  sort: NonNullable<FilmListQuery["sort"]>;
  order: "asc" | "desc";
}

export async function findFilms(
  options: FindFilmsOptions,
): Promise<PageResult<FilmListItemDto>> {
  // TODO 1:动态构造 Film 的 where。
  // TODO 2:根据 categoryId 决定是否 include Category 且 required。
  // TODO 3:include Language,只选必要字段。
  // TODO 4:findAndCountAll、distinct、分页与稳定排序。
  // TODO 5:转换 DTO,隐藏 FilmCategory 中间表字段。
  throw new Error("TODO");
}

提示:

Controller:

export async function listFilmsController(
  req: Request<
    Record<string, never>,
    ApiResponse<PageResult<FilmListItemDto>>,
    Record<string, never>,
    FilmListQuery
  >,
  res: Response<ApiResponse<PageResult<FilmListItemDto>>>,
): Promise<void> {
  // TODO:转换 query,调用 findFilms(),返回 OK。
}

5. 电影详情:避免 JOIN 乘法膨胀

电影详情同时需要演员、分类、库存与租赁统计。如果一条 SQL 同时 JOIN:

10 名演员 × 3 个分类 × 8 份库存 × 多条租赁

返回行数可能迅速膨胀,而且聚合统计可能被重复计算。

建议把问题按粒度拆开,但由你决定是几条查询:

export async function getFilmDetail(
  filmId: number,
): Promise<FilmDetailDto> {
  // TODO 1:查询 Film + Language。
  // TODO 2:查询 Actor 列表。
  // TODO 3:查询 Category 列表。
  // TODO 4:按 Store 统计库存和可用库存。
  // TODO 5:统计历史出租次数。
  // TODO 6:组合 DTO。
  throw new Error("TODO");
}

“可用库存”不是“从未出租的库存”。正确业务含义是:该 inventory_id 当前不存在 return_date IS NULL 的租赁。

6. 多对多反向查询

export interface ActorFilmsParams {
  actorId: string;
}

export interface RelatedFilmQuery {
  page?: string;
  pageSize?: string;
}

export async function findFilmsByActor(
  actorId: number,
  page: number,
  pageSize: number,
): Promise<PageResult<FilmListItemDto>> {
  // TODO 1:先区分“演员不存在”和“演员没有电影”。
  // TODO 2:通过 Association 或 FilmActor 查询电影。
  // TODO 3:隐藏 through 属性。
  // TODO 4:稳定分页。
  throw new Error("TODO");
}

以下结果语义不同:

ACTOR_NOT_FOUND       演员不存在
OK + list: []         演员存在,但没有参演电影

分类电影接口采用同样思路。

7. N+1 专项练习

先故意实现一个低效版本:

const films = await Film.findAll({ limit: 20 });

for (const film of films) {
  // 每部电影再查询一次演员。
  // TODO:观察产生了多少条 SQL。
}

然后分别尝试:

  1. include 一次加载关联。
  2. 先查询电影,再用一次 IN 批量查询关联,最后在内存中按 filmId 分组。

比较以下指标:

不要把“SQL 越少越好”当作唯一结论。一条极大的 JOIN 也可能比几条清晰的批量查询更差。

8. Association 检查清单

模型初始化后再建立关联:

Film.belongsTo(Language, { foreignKey: "languageId", as: "language" });
Film.belongsToMany(Actor, {
  through: FilmActor,
  foreignKey: "filmId",
  otherKey: "actorId",
  as: "actors",
});
Actor.belongsToMany(Film, {
  through: FilmActor,
  foreignKey: "actorId",
  otherKey: "filmId",
  as: "films",
});

这只是接口形态示意。你需要根据自己的 Model 属性和 field 映射核对键名。

常见报错来源:

9. 错误码建议

FILM_NOT_FOUND
ACTOR_NOT_FOUND
CATEGORY_NOT_FOUND
INVALID_FILM_FILTER

全部仍返回 HTTP 200。日志必须记录真正的业务 code,否则监控只会看到大量 200。

10. curl 验收

TOKEN="替换为 accessToken"

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8080/api/sakila/films?page=1&pageSize=10&rating=PG&minLength=60&maxLength=120"

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8080/api/sakila/films/1

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8080/api/sakila/actors/1/films?page=1&pageSize=10"

验收时开启 Sequelize SQL 日志,比较 N+1 版本与批量查询版本的 SQL 数量。

11. 练习题

  1. filminventory 的业务含义有什么区别?
  2. 多对多 include 为什么可能导致 count 重复?
  3. through: { attributes: [] } 解决了什么问题?
  4. 一条查询同时 JOIN 演员和分类,为什么可能出现乘法膨胀?
  5. “演员不存在”和“演员没有电影”为什么应该区分?
  6. 什么是 N+1?如何从 SQL 日志中发现它?
  7. 为什么一条 SQL 不一定比三条批量 SQL 更快?
  8. DECIMAL 为什么建议在 DTO 中使用 string?