11 Rental:事务、锁、并发与幂等
租赁是这套练习中最重要的业务流程。本章不仅要让接口“能运行”,还要思考两个请求同时操作同一库存时会发生什么。所有接口必须经过 authenticateAdmin。
1. 业务模型
film 1 --- n inventory n --- 1 store
inventory 1 --- n rental
customer 1 --- n rental
staff 1 --- n rental
一条 rental 表示一次租赁历史:
rental_date:借出时间。return_date:归还时间;null 表示仍未归还。inventory_id:实际借出的库存副本,不是 filmId。customer_id:客户。staff_id:办理业务的 Sakila 员工。
登录管理员 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
}
需要检查:
- 客户存在并且
active。 - 库存存在。
- 员工存在且可办理业务。
- 员工所属门店与库存门店一致。
- 该库存当前没有未归还租赁。
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
普通事务不会自动让两个“先查再写”流程串行执行。你需要研究:
- 使用
SELECT ... FOR UPDATE的思想锁定同一inventory行。 - Sequelize 的
lock选项应与同一个transaction一起使用。 - 为什么应锁定稳定存在的 inventory 行,而不是尝试锁定一条尚不存在的“未归还 rental”。
- MySQL 事务隔离级别会怎样影响读取。
本练习不直接给出完整锁查询答案。你需要开两个并发请求验证,而不是只看代码觉得“应该没问题”。
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");
});
}
推荐的幂等语义:同一个归还请求重复执行,不生成新记录,也不改变第一次归还时间,直接返回当前已归还数据。
需要区分:
- 幂等成功:租赁确实已经归还。
RENTAL_NOT_FOUND:目标从未存在。
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。日志中需要记录 businessCode、adminId、inventoryId 和 requestId,但不要记录 Authorization Token。
10. 常见错误
- 用 filmId 创建租赁,而不是 inventoryId。
- 把“没有历史租赁”当成“当前可用”。
- 只使用事务却没有锁,两个并发请求都创建成功。
- 锁查询没有传 transaction,锁立即失去意义。
- 在事务内部做耗时的密码 Hash、网络请求或大量日志 I/O,长时间占锁。
- 重复归还时覆盖原 returnDate。
- 日期结束条件使用
<= 当天 23:59:59。 - 创建租赁时忘记再次检查客户 active。
- 把登录管理员和 Sakila staff 混成同一个 ID。
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. 练习题
- filmId 和 inventoryId 为什么不能互换?
- 如何用业务语言定义“可出租库存”?
- 为什么事务不自动解决“先查再写”的并发竞争?
- 为什么更适合锁 inventory 行,而不是锁一条不存在的 rental?
- 重复归还怎样设计成幂等?
- 日期范围为什么推荐左闭右开?
- 登录管理员 ID 和 Sakila staffId 分别代表什么?
- 如何通过并发测试证明锁真正有效?