23 排序、分页与计数:数的是客户,还是 JOIN 行?
对应项目:findCustomers()、listActorFilms()。
1. LIMIT 不代表“最近”
错误思路:租赁查询加 limit: 10 就得到最近 10 条。
正确写法:
const rentals = await Rental.findAll({
where: { customer_id },
order: [["rental_date", "DESC"], ["rental_id", "DESC"]],
limit: 10,
});
SELECT ... FROM rental
WHERE customer_id = :customer_id
ORDER BY rental_date DESC, rental_id DESC
LIMIT 10;
主键打破相同时间的并列,得到稳定顺序。只按姓氏、时间排序时,同值记录的前后位置没有保证。
ORDER BY customer_id 并不保证业务上的“最近创建”;它只定义按 ID 的顺序。业务含义仍要选对时间字段。
2. 页码、大小和偏移量各自校验
const page = options.page ?? 1;
const page_size = options.page_size ?? 20;
if (!Number.isSafeInteger(page) || page < 1) {
throw new Error("page 必须是正整数"); // 接入项目时映射为 AppError
}
if (!Number.isInteger(page_size) || page_size < 1 || page_size > 100) {
throw new Error("page_size 必须在 1~100 之间");
}
const offset = (page - 1) * page_size;
if (!Number.isSafeInteger(offset)) {
throw new Error("offset 超出安全整数范围");
}
?? 只补 null/undefined;但接口是否允许 null 由验证层决定。|| 会把 0 偷偷替换成默认值,掩盖非法输入。
HTTP 层校验不取代 Service 的关键边界检查:以后 CLI、定时任务、测试可能直接调用 Service。防止超大每页大小也能限制响应体与数据库工作量。
3. order 的字段名必须白名单
type CustomerSort = "customer_id" | "last_name" | "create_date";
const sort: CustomerSort = options.sort ?? "customer_id";
const direction = options.order === "desc" ? "DESC" : "ASC";
const order: Order = [[sort, direction]];
if (sort !== "customer_id") order.push(["customer_id", direction]);
示例假设 options 已通过对应校验,Order 从 Sequelize 导入。Service 仍可再检查运行时白名单,当前项目就是这样做的。
不能用值绑定替代 SQL 标识符白名单:ORDER BY :sort 常得到按一个字符串常量排序,根本不是按该列排序。更不能 literal(req.query.sort)。
4. findAndCountAll 是两次查询
const { count, rows } = await Customer.findAndCountAll({
where: { active: 1 },
attributes: ["customer_id", "first_name"],
order: [["customer_id", "ASC"]],
limit: 20,
offset: 20,
});
等价 SQL:
SELECT COUNT(*) AS count FROM customer WHERE active = 1;
SELECT customer_id, first_name FROM customer
WHERE active = 1
ORDER BY customer_id ASC
LIMIT 20 OFFSET 20;
本版本内部用 Promise.all 调用 count 与 findAll,不应假设固定执行先后。COUNT 会去掉分页和排序;结果 count 是筛选总数。
两次查询之间有并发变化时,count 与 rows 可能不属于同一快照。普通管理列表往往接受这个小偏差。报表若严格要求一致,需明确使用适当隔离级别的一致性快照,并让相关查询都参加同一事务;仅有两个 SQL 不一定值得加事务。
5. JOIN 为什么会把 count 放大?
假设只有两个客户:客户 1 有 3 条租赁,客户 2 没有租赁。
SELECT c.customer_id, r.rental_id
FROM customer AS c
LEFT JOIN rental AS r ON r.customer_id = c.customer_id;
结果有四行:
customer_id | rental_id
1 | 101
1 | 102
1 | 103
2 | NULL
COUNT(c.customer_id) 是 4;客户数量是 2。ORM 可能把结果装配为 2 个 Customer 实例,但 SQL 计数不会因此自动变成 2。
const result = await Customer.findAndCountAll({
attributes: ["customer_id"],
include: [{ model: Rental, as: "rentals", attributes: ["rental_id"] }],
distinct: true,
limit: 2,
});
本项目版本计数 SQL 的核心是:
SELECT COUNT(DISTINCT(`Customer`.`customer_id`)) AS count
FROM customer AS Customer
LEFT JOIN rental AS rentals ON Customer.customer_id = rentals.customer_id;
distinct 在这里让 count 按基表主键去重,不是把整个 rows 查询改成万能的 SELECT DISTINCT。
required include 会影响计数筛选;某些非 required JOIN 即使不筛掉客户也可能进入计数 SQL。复杂情况下直接看两条实际 SQL,别只凭参数名推测。
当前 Customer 列表只 JOIN belongsTo 的 address/city/country 链,不会形成上述一对多放大,因此不需要盲目加 distinct。未来加 rentals 后再重新评估。
6. distinct 为什么不能顺带修好分页?
如果直接对前面的 JOIN 行执行 LIMIT 2,可能只得到客户 1 的两条租赁,最后装配出来只有 1 个客户。
Sequelize 会根据关联和选项,可能生成父表分页子查询:
SELECT Customer.*, rentals.rental_id
FROM (
SELECT customer_id FROM customer ORDER BY customer_id LIMIT 2
) AS Customer
LEFT JOIN rental AS rentals ON rentals.customer_id = Customer.customer_id
ORDER BY Customer.customer_id;
先限定两个客户,再展开其租赁。subQuery: false 可能取消这个保护。顶层子表条件、排序、required 和多层 include 又可能让 SQL 更复杂。
当查询难以验证时,优先考虑:
- 先分页查客户 ID,再批量查这些客户及关联,恢复原来的顺序;
- hasMany 使用 separate;
- 将详情中的大数组独立成分页接口;
- 用清晰、可测试的 Raw SQL。
两步查不是天然错误,但需要接受或处理两步之间的并发变化。
7. group 以后 count 不是一个数字
const result = await Customer.findAndCountAll({
attributes: ["store_id"],
group: ["store_id"],
});
计数结果形如:
[
{ store_id: 1, count: 326 },
{ store_id: 2, count: 273 },
]
数字仅示意。count.length 是分组数;每个 count 是该组内记录数。不能拿整个数组去做 Math.ceil(count / page_size)。
如果列表主体就是“门店分组报表”,要定义总组数的查询,而不是硬复用客户分页 DTO。
8. 深分页与游标分页
大 offset 常需要扫描并跳过很多记录。按主键升序翻页时可用游标:
const rows = await Customer.findAll({
where: { customer_id: { [Op.gt]: last_customer_id } },
order: [["customer_id", "ASC"]],
limit: 20,
});
SELECT ... FROM customer
WHERE customer_id > :last_customer_id
ORDER BY customer_id ASC LIMIT 20;
多字段游标则需完整匹配排序:
WHERE last_name > :last_name
OR (last_name = :last_name AND customer_id > :customer_id)
ORDER BY last_name ASC, customer_id ASC
LIMIT 20;
再与其他筛选用 AND 连接,并为整组 OR 加括号。游标不能随意跳到第 100 页;排序值更新时仍可能移动记录。选择取决于 API 需求,不是 offset 永远错误。
9. 练习
- 创建只在纸上的“2 客户、3 租赁”数据,预测 COUNT 与 COUNT DISTINCT。
- 比较默认 subQuery 与 subQuery=false 的日志和结果。
- 给姓氏排序补主键,解释两字段游标的 OR 条件。
- 说明 FilmActor 为基表的演员电影列表为什么更容易分页。
下一章:fn、col、literal 与表达式。