18 日常 Sequelize 速查表
这份速查表用于完成前面章节后的日常查询,不代替对 SQL、事务和类型的理解。示例沿用当前项目的 AdminUser,字段采用 snake_case。
1. 返回值先记清楚
| 调用 | 常见返回值 | 初学者容易误解的点 |
|---|---|---|
findByPk() |
`Model | null` |
findOne() |
`Model | null` |
findAll() |
Model[] |
没有结果时是空数组,不是 null |
count() |
number |
返回数量,不返回记录 |
create() |
创建后的 Model 实例 | 数据库默认字段可能需要确认或 reload() |
实例 update() |
更新后的当前实例 | 与静态 Model.update() 不同 |
静态 update() |
[affected_count](MySQL 常见) |
通常不直接返回更新后的记录 |
destroy() |
删除数量 | 0 表示没有匹配记录 |
increment() |
方言相关的更新结果 | 需要最新实例值时显式 reload() |
findAndCountAll() |
{ rows, count } |
无 group 时 count 常为 number;有 group 时是分组计数数组 |
不要仅凭表格猜测复杂选项下的精确类型。编辑器中的 Sequelize 类型定义和实际方言行为仍然是最终依据。
2. 查询一条记录
按主键
const admin = await AdminUser.findByPk(admin_id);
if (admin === null) {
// 处理不存在
}
对应 SQL 思维:
SELECT ...
FROM admin_user
WHERE id = ?
LIMIT 1;
按条件
const admin = await AdminUser.findOne({
where: { username },
});
需要密码摘要的登录查询必须显式使用 Scope:
const admin = await AdminUser.scope("withPassword").findOne({
where: { username },
});
不要把包含 password_hash 的实例直接写入日志或 API 响应。
3. 查询多条记录
const admins = await AdminUser.findAll({
attributes: ["id", "username", "display_name", "account_status"],
where: {
account_status: AdminAccountStatus.Active,
},
order: [
["created_at", "DESC"],
["id", "DESC"],
],
limit: 20,
});
始终考虑:
- 是否需要字段白名单;
- 是否需要
limit; - 排序是否稳定;
- 查询条件是否有合适索引。
4. 高频 WHERE 操作符
import { Op } from "sequelize";
IN
where: {
account_status: {
[Op.in]: [AdminAccountStatus.Active, AdminAccountStatus.Locked],
},
}
范围
where: {
created_at: {
[Op.gte]: start_at,
[Op.lt]: end_at,
},
}
时间范围通常推荐左闭右开:
created_at >= start_at AND created_at < end_at
OR
where: {
[Op.or]: [
{ username: keyword },
{ email: keyword },
],
}
LIKE
where: {
display_name: {
[Op.like]: `%${keyword}%`,
},
}
即使通过 Sequelize 查询 API 安全传入值,% 和 _ 仍具有 LIKE 通配符语义。产品要求“按字面搜索”时需要设计转义规则。
5. 计数和存在性
计数
const admin_count = await AdminUser.count({
where: { account_status: AdminAccountStatus.Active },
});
判断是否存在
只需要判断存在时,不要查询完整行:
const existing = await AdminUser.findOne({
attributes: ["id"],
where: { username },
});
const exists = existing !== null;
“先查不存在,再插入”不能代替数据库唯一约束,因为两个并发请求可能同时查到不存在。
6. 分页
const page = 1;
const page_size = 20;
const { rows, count } = await AdminUser.findAndCountAll({
attributes: ["id", "username", "display_name", "created_at"],
order: [
["created_at", "DESC"],
["id", "DESC"],
],
limit: page_size,
offset: (page - 1) * page_size,
});
注意:
page和page_size必须先进行整数、范围和最大值校验;- 只按
created_at排序可能不稳定,应增加唯一键作为第二排序; - 大 offset 会越来越慢,数据量大时考虑基于游标的分页;
- 一对多
include可能让count重复,需检查 SQL,并按场景考虑distinct: true。
7. 创建记录
const admin = await AdminUser.create({
username,
password_hash,
display_name,
email: email ?? null,
// 当前 AdminUser 类型没有把这两个可空字段标为 CreationOptional,
// 所以创建输入仍需显式传 null。
locked_until: null,
last_login_at: null,
});
创建前明确:
- 哪些字段由调用者提供;
- 哪些字段由数据库默认值生成;
undefined、省略字段和null的含义;- 唯一约束冲突怎样转换成业务错误。
exactOptionalPropertyTypes 开启时,可选字段缺失通常应该省略,而不是随意传入 undefined。
8. 更新记录
实例更新
已经查询到实例时:
await admin.update({
display_name,
email,
});
静态更新
不需要先读取实例时:
const [affected_count] = await AdminUser.update(
{ account_status: AdminAccountStatus.Disabled },
{ where: { id: admin_id } },
);
if (affected_count === 0) {
// 没有匹配记录
}
危险写法:
await AdminUser.update({ account_status: 0 }, { where: {} });
空 where 可能更新全表。更新前先确认筛选条件,并根据项目需要封装防护。
9. reload 和 save
admin.display_name = "New name";
await admin.save();
save() 会把实例的变化持久化到数据库。
重新读取数据库状态:
await admin.reload();
适合场景:
- 数据库生成了默认值;
- 数据库触发器修改了字段;
- 静态更新后需要获取最新状态;
- 后续 DTO 必须使用更新后的值。
reload() 会产生额外查询,不要为了“保险”无条件调用。
10. 原子自增
await AdminUser.increment(
{ failed_login_count: 1 },
{ where: { id: admin_id } },
);
比下面的读改写更能避免简单的丢失更新:
admin.failed_login_count += 1;
await admin.save();
如果业务还需要基于自增后的数值决定 locked_until,单独 increment() 未必足够,需要条件更新或事务行锁。
11. 删除
const deleted_count = await AdminUser.destroy({
where: { id: admin_id },
});
执行删除前确认:
- 是否真的需要物理删除;
- 是否存在外键引用;
- 是否需要保留审计记录;
where是否准确;- API 重复调用时
0行删除应怎样解释。
不要使用空 where 做试验。全表清理应在明确的测试数据库中使用专门脚本。
12. 托管事务模板
await db.transaction(async (transaction) => {
const admin = await AdminUser.findByPk(admin_id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (admin === null) {
throw new Error("管理员不存在");
}
await admin.update(
{ failed_login_count: admin.failed_login_count + 1 },
{ transaction },
);
});
规则:
- callback 正常完成,Sequelize 提交;
- callback 抛错,Sequelize 回滚;
- 事务内每条相关查询都显式传入
transaction; - 不在托管事务中手工
commit()或rollback(); - 不吞掉异常;
- 不在事务中做 Argon2、外部 HTTP、邮件等长耗时或不可回滚操作。
13. 非托管事务模板
只有确实需要手工控制生命周期时再使用:
const transaction = await db.transaction();
try {
await doFirstStep(transaction);
await doSecondStep(transaction);
await transaction.commit();
} catch (error: unknown) {
await transaction.rollback();
throw error;
}
初学阶段优先使用托管事务,减少漏提交、漏回滚和重复结束事务的风险。
14. 行锁模板
await db.transaction(async (transaction) => {
const current_admin = await AdminUser.findByPk(admin_id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (current_admin === null) {
return;
}
await current_admin.update(
{
failed_login_count: current_admin.failed_login_count + 1,
},
{ transaction },
);
});
FOR UPDATE 只有在事务中才有正确的持锁生命周期。事务外查询得到的实例不能代替事务内重新读取的最新行。
15. 定义一对多关联
AdminUser.hasMany(AdminLoginAudit, {
foreignKey: "admin_user_id",
as: "login_audits",
});
AdminLoginAudit.belongsTo(AdminUser, {
foreignKey: "admin_user_id",
as: "admin",
});
数据库仍应有真实外键和索引。Sequelize Association 负责 ORM 查询关系,不应被当作数据库约束的替代品。
16. include 连表查询
const audits = await AdminLoginAudit.findAll({
attributes: ["id", "login_result", "created_at"],
include: [
{
model: AdminUser,
as: "admin",
attributes: ["id", "username", "display_name"],
required: true,
},
],
order: [["created_at", "DESC"]],
limit: 50,
});
记忆 SQL 语义:
required: true → 通常是 INNER JOIN
required: false → 通常是 LEFT OUTER JOIN
as 必须与关联定义一致。始终限制主表和关联表的 attributes,并检查一对多 JOIN 对分页与计数的影响。
17. 避免 N+1
容易出现 N+1 的写法:
const admins = await AdminUser.findAll();
for (const admin of admins) {
await AdminLoginAudit.findAll({
where: { admin_user_id: admin.id },
});
}
如果有 100 个管理员,会产生 1 次管理员查询和 100 次审计查询。
根据返回需求考虑:
- 使用
include; - 一次查询全部关联记录后在内存分组;
- 使用聚合查询;
- 对大集合分批处理。
不是所有情况都应该用一个巨大 JOIN,关键是明确查询次数、数据量和结果形状。
18. 捕获常见约束错误
import { UniqueConstraintError } from "sequelize";
try {
await AdminUser.create(input);
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
// 根据 error.errors 中的字段映射成安全业务错误
}
throw error;
}
不要把未知数据库异常全部转换成可以公开的业务错误。未知错误应继续向上交给统一错误处理边界,并记录在服务器内部。
19. Raw SQL
import { QueryTypes } from "sequelize";
interface LockedAdminRow {
id: string;
username: string;
}
const rows = await db.query<LockedAdminRow>(
`
SELECT id, username
FROM admin_user
WHERE locked_until > :now
ORDER BY locked_until ASC
LIMIT :limit
`,
{
replacements: {
now: new Date(),
limit: 50,
},
type: QueryTypes.SELECT,
},
);
不要把用户输入通过字符串拼接放入 SQL:
// 危险示例
`SELECT * FROM admin_user WHERE username = '${username}'`
这里的 replacements 会由 Sequelize 转义后替换进 SQL;如果使用 bind 才属于驱动层绑定参数。Raw SQL 适合 ORM 难以清晰表达的查询,但无论选择哪一种安全传值方式,都不能放弃类型声明和字段白名单。
20. DDL 与 Migration 检查清单
创建或修改表时检查:
- [ ] 业务字段是否明确必填、长度、范围和默认值;
- [ ] 主键和外键类型是否完全一致,包括
UNSIGNED; - [ ]
NULL是否表示明确的“尚未发生/未知”; - [ ] 唯一性是否由数据库唯一索引保证;
- [ ] 索引是否对应真实查询条件和排序;
- [ ] DDL 是否保存为版本化 Migration;
- [ ] Migration 是否在空数据库和已有数据数据库验证;
- [ ] Sequelize Model 是否同步修改;
- [ ] TypeScript 类型是否同步修改;
- [ ] API 是否需要兼容新旧结构;
- [ ] 大表 DDL 是否评估锁表、回填和磁盘空间;
- [ ] 删除字段前是否确认旧版本代码和回滚需求。
21. 最后一分钟排查顺序
Sequelize 代码出现问题时,按下面顺序检查通常比反复试 API 更有效:
1. 输入值和业务条件是否正确
2. TypeScript 类型是否准确表达 null、默认值和关联
3. Model.init 与真实 DDL 是否一致
4. Sequelize 实际生成了什么 SQL
5. SQL 在 MySQL CLI/DBeaver 中结果是否正确
6. WHERE、JOIN、ORDER、LIMIT 是否符合预期
7. 是否命中索引,EXPLAIN 结果如何
8. 事务内所有查询是否传了同一个 transaction
9. 是否存在并发、锁等待或死锁
10. 日志是否遗漏 cause,或意外记录了敏感信息
始终从真实 SQL 和真实数据库状态出发。ORM 能减少重复代码,但数据库仍然是约束、事务、锁和查询性能最终发生的地方。
22. Actor / Customer 进阶阅读索引
- options 不知道从哪里开始:19 总览。
- OR 括号、动态条件不确定:20 WHERE。
- 字段重复、raw 和 DTO 混淆:21 attributes、28 类型答疑。
- JOIN 与 required、through、separate:22 include。
- distinct、count、分页主体:23 排序与分页。
- col/literal 与计算表达式:24 表达式。
- SUM 被算大、COUNT 字符串:25 聚合。
- 事务、锁和替代方案:26 场景选择、27 并发控制。
- reload 查不到刚创建的记录:29 await 与事务。
- matchedData 与查询排错:30 调试答疑。