05|日常 CRUD 高频接口
CRUD 是 Create、Read、Update、Delete。真正容易出错的不是方法名,而是返回值、NULL、默认 Scope、更新范围和并发语义。
1. 创建:create()
业务场景:创建首个管理员。
const admin = await AdminUser.create({
username,
password_hash,
display_name,
email,
locked_until: null,
last_login_at: null,
});
大致 SQL:
INSERT INTO admin_user
(username, password_hash, display_name, email,
locked_until, last_login_at, ...)
VALUES (?, ?, ?, ?, ?, ?, ...);
返回值是已创建的 AdminUser 实例,不是受影响行数。
注意:TypeScript 允许省略 CreationOptional 字段;数据库默认值仍应是最终保证。
2. build() + save()
const admin = AdminUser.build({
username,
password_hash,
display_name,
email: null,
locked_until: null,
last_login_at: null,
});
// 此时通常还没有 INSERT
await admin.save();
build() 只构造实例,save() 才持久化。适合保存前还要同步设置多个属性或检查实例状态的场景;普通创建优先用更直观的 create()。
3. 批量创建:bulkCreate()
await AdminLoginAudit.bulkCreate(rows, {
validate: true,
transaction,
});
适合一次插入多行,通常比循环 create() 少往返。但必须明确:
- 是否运行逐实例 Hook;
- 是否开启
validate; - 返回的自增 ID 在不同方言中的可靠性;
- 批量失败是否需要事务;
- 单批数据是否太大。
不要为了方便把任何用户数组直接传给 bulkCreate()。
4. findOrCreate() 与并发
const [admin, created] = await AdminUser.findOrCreate({
where: { username },
defaults: { /* 创建字段 */ },
});
返回元组:实例和“本次是否创建”。它适合“存在就取,不存在就建”的业务,但正确性仍依赖数据库唯一索引。不要自己写:
if (!(await AdminUser.findOne({ where: { username } }))) {
await AdminUser.create(...);
}
两个并发请求可能都查到不存在。数据库唯一约束必须兜底。
5. upsert()
const [instance, created] = await SomeModel.upsert({
natural_key: key,
value,
});
表示插入,发生主键/唯一键冲突时更新。适用于确实拥有稳定唯一键且业务语义就是“覆盖”的配置或同步数据。
Sequelize v6 的返回值是 [instance, created]。created 的类型是 boolean | null:某些方言或执行路径无法可靠判断本次究竟是插入还是更新,此时会返回 null,不要把它无条件当作布尔值。
它不适合管理员注册:重复用户名通常应报告冲突,而不是悄悄覆盖现有密码或邮箱。
6. 按主键查询:findByPk()
const admin = await AdminUser.findByPk(admin_id);
返回类型:
Promise<AdminUser | null>
必须处理 null:
if (admin === null) {
throw new AppError(/* ... */);
}
大致 SQL:
SELECT ... FROM admin_user WHERE id = ? LIMIT 1;
本项目 ID 是字符串,不要用 Number(admin_id) 转换。
7. 查询一条:findOne()
当前登录查询:
const admin = await AdminUser.scope("withPassword").findOne({
where: { username },
});
同样返回 AdminUser | null。如果条件可能匹配多行,它只返回数据库选中的第一行;没有 order 时“第一行”不应被当作稳定业务规则。
唯一用户名查询是合理场景,因为数据库唯一索引确保最多一行。
8. 查询多条:findAll()
const admins = await AdminUser.findAll({
where: { account_status: AdminAccountStatus.Active },
limit: 100,
});
返回 AdminUser[],查不到时返回空数组,不返回 null。
Web 接口不要无上限 findAll()。随着数据增长,它会占用数据库、网络和 Node.js 内存。
9. count() 与“是否存在”
const count = await AdminUser.count({
where: { account_status: AdminAccountStatus.Active },
});
对应 COUNT(*)。如果只关心是否存在,完整统计所有匹配行可能多做工作,可查询一条主键:
const row = await AdminUser.findOne({
attributes: ["id"],
where: { username },
});
const exists = row !== null;
但“先查存在”不能代替唯一约束。
10. 分页:findAndCountAll()
const result = await AdminUser.findAndCountAll({
where,
order: [
["created_at", "DESC"],
["id", "DESC"],
],
limit: page_size,
offset,
});
返回:
{
rows: AdminUser[];
count: number;
}
它通常发出两条 SQL:一条 count,一条分页查询。以后加入一对多 include 时可能重复计数,需要理解 distinct 和生成 SQL。
上面的 count: number 适用于没有 group 的常见分页。传入 group 后,Sequelize v6 的 count 是分组计数数组,不再是单个数字。
11. 实例更新:update()
当前失败登录记录:
await current_admin.update(
{
failed_login_count: next_failed_count,
locked_until,
},
{ transaction },
);
优点是目标行已明确,返回更新后的同一实例。大致 SQL:
UPDATE admin_user
SET failed_login_count = ?, locked_until = ?
WHERE id = ?;
它仍然不自动解决“事务外读取、稍后覆盖”的并发问题。当前代码通过事务内重新查询和 FOR UPDATE 解决。
12. 静态更新:Model.update()
当前成功登录:
await AdminUser.update(
{
failed_login_count: 0,
locked_until: null,
last_login_at: logged_in_at,
},
{ where: { id: admin.id }, transaction },
);
MySQL 下返回的重点通常是受影响行数,不应误以为它直接返回更新后的 AdminUser。因此当前代码随后:
await admin.reload({ transaction });
使旧实例重新读取数据库。
最危险的错误是漏掉 where。更新与删除前都应明确目标范围,必要时把主键条件作为 Service 的硬约束。
13. 修改属性后 save()
admin.display_name = new_display_name;
await admin.save();
Sequelize 会追踪实例发生变化的字段,通常只更新变化列。可以检查:
admin.changed("display_name");
但读取到实例后长时间等待再 save(),可能覆盖其他请求期间的变化。并发敏感场景需要条件更新、乐观锁或行锁。
14. reload()
await admin.reload({ transaction });
重新执行 SELECT,把数据库最新值写回该实例。适合:
- 静态
update()后需要 DTO 使用最新值; - 数据库触发器/default 修改了列;
- 需要确认当前事务内状态。
它是额外一次查询,不应无条件到处调用。
15. increment() / decrement()
await AdminUser.increment("failed_login_count", {
by: 1,
where: { id: admin_id },
});
大致 SQL:
UPDATE admin_user
SET failed_login_count = failed_login_count + 1
WHERE id = ?;
这比“先读 number、JS +1、再写回”更不容易丢失更新。但当前锁定逻辑还要基于新计数同时设置 locked_until,所以事务行锁方案更容易把复合业务规则讲清楚。
16. 删除:destroy()
实例删除:
await admin.destroy({ transaction });
静态删除:
const deleted_count = await AdminUser.destroy({
where: { id: admin_id },
transaction,
});
静态方法返回删除行数。漏写或写错 where 可能造成大范围删除。
物理删除会真正移除数据。软删除通常通过 deleted_at 或 Sequelize paranoid 实现,但会影响唯一索引、所有查询和合规要求,不应只为“以后可能用”而提前引入。
17. 字段选择与 raw
const admins = await AdminUser.findAll({
attributes: ["id", "username", "display_name"],
});
只取所需字段可减少数据传输和敏感字段风险。
const rows = await AdminUser.findAll({ raw: true });
raw: true 在运行时返回普通对象而非 Model 实例,不能调用 save()、reload() 等实例方法;复杂关联下返回形状也不同。不要仅为了“看起来像 JSON”就使用它。
这里还有一个 Sequelize v6 的类型边界:findAll() 的部分重载即使收到 raw: true,静态结果仍可能显示为 AdminUser[],而 attributes 也不会自动推导成精确投影类型。因此 rows[0]?.save() 可能通过 TypeScript,却在运行时报“不是函数”。只读投影应定义专用 Row/DTO 类型,并在查询边界做明确映射或运行时校验,不能把这一处库类型当作运行时保证。
18. 初学者常见错误
- 忘记处理
findOne()/findByPk()的null。 - 认为
findAll()查不到时返回null。 - 误以为静态
update()返回更新后实例。 - 更新/删除漏写安全的
where。 - 无上限
findAll()用在列表接口。 - 用“先查再创建”代替唯一索引。
- 读取计数后在 JS 自增,产生并发丢失更新。
- 使用
raw: true后仍调用实例方法。 - 把
upsert()用在应报告冲突的业务。 - 批量写入时忽略校验、Hook 和事务语义。
19. 动手练习
- 写一个按 ID 查询管理员的函数,返回 DTO,并正确处理
null。 - 写一个 Active 管理员列表,限制 20 条并只选择安全字段。
- 运行静态
update(),打印其真实返回值,再reload()比较实例。 - 用两个并发 Promise 演示“读取后 +1 再 save”可能丢失更新,并与事务行锁版本对比。
- 为
destroy()设计一层 Service 防护:只能按明确 ID 删除。
20. 复盘问题
create()与build() + save()有什么区别?findOne()和findAll()的空结果分别是什么?Model.update()为什么常常需要后续查询或reload()?increment()解决了哪类并发问题,又不能自动解决什么复合规则?- 为什么
findOrCreate()仍需要数据库唯一索引?
下一章把查询进一步展开:WHERE 操作符、排序、分页和用户输入安全。