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,
});

始终考虑:

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,
});

注意:

7. 创建记录

const admin = await AdminUser.create({
  username,
  password_hash,
  display_name,
  email: email ?? null,
  // 当前 AdminUser 类型没有把这两个可空字段标为 CreationOptional,
  // 所以创建输入仍需显式传 null。
  locked_until: null,
  last_login_at: 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();

适合场景:

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 做试验。全表清理应在明确的测试数据库中使用专门脚本。

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 },
  );
});

规则:

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 次审计查询。

根据返回需求考虑:

不是所有情况都应该用一个巨大 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 检查清单

创建或修改表时检查:

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 进阶阅读索引