11 Rental:事务、锁、并发与幂等

租赁是这套练习中最重要的业务流程。本章不仅要让接口“能运行”,还要思考两个请求同时操作同一库存时会发生什么。所有接口必须经过 authenticateAdmin

1. 业务模型

film 1 --- n inventory n --- 1 store
inventory 1 --- n rental
customer 1 --- n rental
staff 1 --- n rental

一条 rental 表示一次租赁历史:

登录管理员 req.admin.id 是系统操作者,不等同于 staff_id。如果需要审计,应写入管理员审计日志,而不是强行写入 Sakila rental 表。

2. 接口清单

方法 路径 作用
GET /api/sakila/stores/:storeId/available-inventory 查询可出租库存
POST /api/sakila/rentals 创建租赁
PATCH /api/sakila/rentals/:rentalId/return 归还电影
GET /api/sakila/rentals 查询租赁记录
const router = Router();
router.use(authenticateAdmin);

router.get(
  "/stores/:storeId/available-inventory",
  availableInventoryValidation,
  validateRequest,
  listAvailableInventoryController,
);
router.post("/rentals", createRentalValidation, validateRequest, createRentalController);
router.patch(
  "/rentals/:rentalId/return",
  returnRentalValidation,
  validateRequest,
  returnRentalController,
);
router.get("/rentals", rentalListValidation, validateRequest, listRentalsController);

3. 类型契约

export interface AvailableInventoryQuery {
  page?: string;
  pageSize?: string;
  filmId?: string;
  title?: string;
}

export interface InventoryAvailabilityDto {
  inventoryId: number;
  storeId: number;
  filmId: number;
  filmTitle: string;
  rentalRate: string;
}

export interface CreateRentalBody {
  customerId: number;
  inventoryId: number;
  staffId: number;
}

export interface RentalDto {
  rentalId: number;
  customerId: number;
  inventoryId: number;
  staffId: number;
  filmId: number;
  filmTitle: string;
  rentalDate: string;
  returnDate: string | null;
}

export interface RentalListQuery {
  page?: string;
  pageSize?: string;
  customerId?: string;
  storeId?: string;
  status?: "open" | "returned";
  startDate?: string;
  endDate?: string;
}

4. 查询可出租库存

业务定义:

某份 inventory 当前不存在 return_date IS NULL 的 rental,才是可出租。

export interface FindAvailableInventoryOptions {
  storeId: number;
  page: number;
  pageSize: number;
  filmId?: number;
  title?: string;
}

export async function findAvailableInventory(
  options: FindAvailableInventoryOptions,
): Promise<PageResult<InventoryAvailabilityDto>> {
  // TODO 1:确认 store 存在。
  // TODO 2:查询该门店的 inventory 和 film。
  // TODO 3:排除存在未归还 rental 的 inventory。
  // TODO 4:稳定分页并正确统计 total。
  throw new Error("TODO");
}

常见错误逻辑:

只选择从未出现过 rental 的 inventory

这会把已经归还、现在可以再次出租的库存错误排除。

5. 创建租赁:基础业务检查

请求:

POST /api/sakila/rentals
Authorization: Bearer <token>
Content-Type: application/json

{
  "customerId": 1,
  "inventoryId": 10,
  "staffId": 1
}

需要检查:

  1. 客户存在并且 active
  2. 库存存在。
  3. 员工存在且可办理业务。
  4. 员工所属门店与库存门店一致。
  5. 该库存当前没有未归还租赁。
export async function createRental(
  input: CreateRentalBody,
  operatorAdminId: string,
): Promise<RentalDto> {
  return sequelize.transaction(async (transaction) => {
    // TODO 1:在事务中查询并锁定 inventory 行。
    // TODO 2:使用同一个 transaction 查询 customer 和 staff,并验证业务规则。
    // TODO 3:使用同一个 transaction 检查该 inventory 是否存在 returnDate=null 的 rental。
    // TODO 4:创建 Rental,也传入该 transaction。
    // TODO 5:要求原子性的数据库审计写入同一事务。
    // TODO 6:转换 DTO。
    throw new Error("TODO");
  });
}

托管事务回调正常返回后才真正提交。若审计是数据库表且要求与 Rental 原子一致,应在回调内携带同一个 transaction 写入;若是外部日志系统,应在 await sequelize.transaction(...) 成功返回后发送,或进一步学习 afterCommit / Outbox,不能在尚未提交时称为“事务成功”。

Controller 明确传递登录者:

export async function createRentalController(
  req: Request<
    Record<string, never>,
    ApiResponse<RentalDto>,
    CreateRentalBody
  >,
  res: Response<ApiResponse<RentalDto>>,
): Promise<void> {
  if (req.admin === undefined) {
    throw new AppError("AUTH_REQUIRED", "请先登录");
  }

  // TODO:调用 createRental(req.body, req.admin.id)。
}

6. 为什么事务还不够

下面的代码即使放进事务,也可能发生竞态:

请求 A 查询:库存可用
请求 B 查询:库存也可用
请求 A 插入 rental
请求 B 也插入 rental

普通事务不会自动让两个“先查再写”流程串行执行。你需要研究:

本练习不直接给出完整锁查询答案。你需要开两个并发请求验证,而不是只看代码觉得“应该没问题”。

7. 归还接口与幂等

export interface RentalIdParams {
  rentalId: string;
}

export async function returnRental(
  rentalId: number,
  operatorAdminId: string,
): Promise<RentalDto> {
  return sequelize.transaction(async (transaction) => {
    // TODO 1:用同一个 transaction 锁定并查询 Rental。
    // TODO 2:不存在时抛出 RENTAL_NOT_FOUND。
    // TODO 3:已经 returnDate 非 null 时,决定幂等返回当前结果。
    // TODO 4:使用数据库时间或统一的应用时间写 returnDate,并用同一 transaction 保存。
    // TODO 5:要求原子性的审计信息也使用同一 transaction。
    throw new Error("TODO");
  });
}

推荐的幂等语义:同一个归还请求重复执行,不生成新记录,也不改变第一次归还时间,直接返回当前已归还数据。

需要区分:

8. 租赁列表与日期范围

export interface FindRentalsOptions {
  page: number;
  pageSize: number;
  customerId?: number;
  storeId?: number;
  status?: "open" | "returned";
  startAt?: Date;
  endAtExclusive?: Date;
}

export async function findRentals(
  options: FindRentalsOptions,
): Promise<PageResult<RentalDto>> {
  // TODO:动态 where。
  // TODO:include Inventory -> Film,并按需筛选 Store。
  // TODO:使用左闭右开的 rentalDate 范围。
  // TODO:findAndCountAll 和稳定排序;根据关联是否放大主表行判断是否需要 distinct。
  throw new Error("TODO");
}

本查询主要沿 belongsTo 关联读取 Inventory 和 Film,通常不会让一条 Rental 变成多行,因此不要机械地添加 distinct。它主要用于修正一对多或多对多 JOIN 造成的 count 重复,也不能自动解决所有行膨胀和分页问题。

用户选择:

2026-01-01 到 2026-01-02(包含两天)

Service 内部应表达为:

[2026-01-01 00:00:00, 2026-01-03 00:00:00)

也就是:

rental_date >= startAt
AND rental_date < endAtExclusive

不要使用 23:59:59.999 拼结束时间,它依赖数据库精度并容易漏数据。

日期由谁加一天要形成明确契约。即使前端会转换,后端仍应校验并把内部变量命名为 endAtExclusive,避免团队成员误解。

9. 错误码建议

STORE_NOT_FOUND
CUSTOMER_NOT_FOUND
CUSTOMER_INACTIVE
INVENTORY_NOT_FOUND
INVENTORY_NOT_AVAILABLE
STAFF_NOT_FOUND
STAFF_STORE_MISMATCH
RENTAL_NOT_FOUND

这些错误全部返回 HTTP 200。日志中需要记录 businessCodeadminIdinventoryIdrequestId,但不要记录 Authorization Token。

10. 常见错误

11. curl 验收

TOKEN="替换为 accessToken"

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

curl -sS \
  -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"customerId":1,"inventoryId":10,"staffId":1}' \
  http://127.0.0.1:8080/api/sakila/rentals

curl -sS \
  -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8080/api/sakila/rentals/1/return

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8080/api/sakila/rentals?startDate=2026-01-01&endDate=2026-01-02&status=open"

并发验收可以在两个终端尽可能同时提交相同 inventoryId。最终只能有一个请求返回 OK,另一个应为 INVENTORY_NOT_AVAILABLE。测试后查询数据库确认不存在两个未归还记录。

12. 练习题

  1. filmId 和 inventoryId 为什么不能互换?
  2. 如何用业务语言定义“可出租库存”?
  3. 为什么事务不自动解决“先查再写”的并发竞争?
  4. 为什么更适合锁 inventory 行,而不是锁一条不存在的 rental?
  5. 重复归还怎样设计成幂等?
  6. 日期范围为什么推荐左闭右开?
  7. 登录管理员 ID 和 Sakila staffId 分别代表什么?
  8. 如何通过并发测试证明锁真正有效?