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
容易混淆的概念:
film是电影资料,例如《某部电影》。inventory是某门店持有的一份可出租副本。- 一部电影可能有多份库存,一份库存可以产生多次历史租赁。
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;
}
rentalRate 和 replacementCost 对应 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");
}
提示:
- 只有最小值时用
Op.gte,只有最大值时用Op.lte,二者都有时可用Op.between。 - 空字符串、
undefined和数字 0 不一样,动态 where 不要用简单的真假判断丢掉合法的 0。 - category 是多对多关联,JOIN 后应检查 count 是否重复。
through: { attributes: [] }可以隐藏 API 不需要的中间表字段。- 排序名称先映射为 Model attribute;不要把原始查询字符串放入
order。
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。
}
然后分别尝试:
include一次加载关联。- 先查询电影,再用一次
IN批量查询关联,最后在内存中按 filmId 分组。
比较以下指标:
- SQL 条数。
- 返回行数。
- 代码复杂度。
- 是否容易正确分页。
- 是否会因为 JOIN 让主表记录重复。
不要把“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 映射核对键名。
常见报错来源:
- include 的
as与 Association 不一致。 - 两侧
foreignKey/otherKey写反。 - Model 尚未 init 就建立关联。
- 中间表被 Sequelize 自动寻找了不存在的时间字段。
- API 使用 camelCase,而 Raw SQL 或数据库字段使用 snake_case,映射混乱。
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. 练习题
film和inventory的业务含义有什么区别?- 多对多 include 为什么可能导致 count 重复?
through: { attributes: [] }解决了什么问题?- 一条查询同时 JOIN 演员和分类,为什么可能出现乘法膨胀?
- “演员不存在”和“演员没有电影”为什么应该区分?
- 什么是 N+1?如何从 SQL 日志中发现它?
- 为什么一条 SQL 不一定比三条批量 SQL 更快?
DECIMAL为什么建议在 DTO 中使用 string?