Sequelize v6 教程 03:Model Querying - Basics

对应官方文档:Model Querying - Basics

1. 本章目标

完成本章后,你应该能够:

示例主要使用 Sakila 的 ActorFilm 与 World 的 City 模型。模型定义方式参见《01 Model Basics》。

本文带 await 的片段默认位于 async 函数、Express 异步路由或服务层方法中。当前工程编译为 CommonJS,不应直接使用文件顶层 await

2. ORM 查询不是“没有 SQL”

下面两段代码处在不同抽象层,但表达相似意图:

const actors = await Actor.findAll({
  where: { lastName: "GUINESS" },
});
SELECT `actor_id`, `first_name`, `last_name`, `last_update`
FROM `actor`
WHERE `last_name` = 'GUINESS';

Sequelize 的价值是:

但索引是否命中、返回多少行、排序是否昂贵、事务是否正确,最终仍由 MySQL 执行计划决定。后端工程师不能只会 ORM API 而不看 SQL。

学习阶段建议开启:

const sequelize = new Sequelize(database, username, password, {
  dialect: "mysql",
  logging: console.log,
});

生产环境可以改成结构化、采样或慢查询日志,避免无差别记录敏感参数和大量成功请求。

3. 新增:create()

const actor = await Actor.create({
  firstName: "LIN",
  lastName: "ZHANG",
});

console.log(actor.actorId);

create() 返回已创建的模型实例。大致 SQL:

INSERT INTO `actor` (`first_name`, `last_name`, `last_update`)
VALUES ('LIN', 'ZHANG', CURRENT_TIMESTAMP);

3.1 批量新增:bulkCreate()

const actors = await Actor.bulkCreate(
  [
    { firstName: "ONE", lastName: "TEST" },
    { firstName: "TWO", lastName: "TEST" },
  ],
  {
    validate: true,
    fields: ["firstName", "lastName"],
  },
);

批量写入通常比循环执行多次 create() 更省网络往返,但要注意:

MySQL 方言可使用 ignoreDuplicates: true 忽略唯一键冲突,但“忽略”可能掩盖数据质量问题,必须有明确业务语义后再用。

4. 基础查询:findAll()

const actors = await Actor.findAll();

这相当于没有 WHERE 的查询,会读取整张表。Sakila 的演员表较小,但生产表可能有数百万行。接口查询通常需要条件、分页和返回列限制。

返回值是:

Actor[]

而不是单个实例。即使没有结果也返回空数组 []

5. 控制返回列:attributes

5.1 只查询需要的列

const actors = await Actor.findAll({
  attributes: ["actorId", "firstName", "lastName"],
});

大致 SQL:

SELECT
  `actor_id` AS `actorId`,
  `first_name` AS `firstName`,
  `last_name` AS `lastName`
FROM `actor` AS `Actor`;

返回列越少,通常网络传输、对象构造和内存成本越低。更重要的是,可以避免误把敏感字段带到后续响应或日志中。

5.2 排除少数列

const films = await Film.findAll({
  attributes: {
    exclude: ["description", "lastUpdate"],
  },
});

exclude 在列很多、只需排除少数大字段时比较方便。但公开 API 更推荐明确列出返回字段,因为数据库以后新增列时,白名单不会意外扩大响应。

5.3 计算列与别名

import { fn, col } from "sequelize";

const results = await Actor.findAll({
  attributes: [
    "lastName",
    [fn("COUNT", col("actor_id")), "actorCount"],
  ],
  group: [col("last_name")],
});

for (const result of results) {
  console.log(result.lastName, result.get("actorCount"));
}

大致 SQL:

SELECT
  `last_name` AS `lastName`,
  COUNT(`actor_id`) AS `actorCount`
FROM `actor` AS `Actor`
GROUP BY `last_name`;

actorCount 不是 Actor 模型原本声明的持久化属性,所以通过 get("actorCount") 读取更能体现它是查询产生的别名。复杂报表查询通常更适合定义单独的结果类型或使用原始查询,而不是强行伪装成完整模型实例。

6. where:从对象构造查询条件

6.1 相等条件

const actors = await Actor.findAll({
  where: {
    firstName: "NICK",
    lastName: "WAHLBERG",
  },
});

同一对象中的字段默认使用 AND

WHERE `first_name` = 'NICK'
  AND `last_name` = 'WAHLBERG';

6.2 导入运算符 Op

import { Op } from "sequelize";

常用运算符:

Sequelize SQL 含义
Op.eq / Op.ne = / !=
Op.gt / Op.gte > / >=
Op.lt / Op.lte < / <=
Op.between / Op.notBetween BETWEEN / NOT BETWEEN
Op.in / Op.notIn IN / NOT IN
Op.like / Op.notLike LIKE / NOT LIKE
Op.is IS NULL
Op.and / Op.or 逻辑与 / 逻辑或

6.3 数值范围

const cities = await City.findAll({
  where: {
    population: {
      [Op.between]: [1_000_000, 5_000_000],
    },
  },
});

SQL 大致为:

WHERE `Population` BETWEEN 1000000 AND 5000000;

6.4 IN

数组可以直接表达 IN,但显式 Op.in 更容易阅读:

const actors = await Actor.findAll({
  where: {
    actorId: {
      [Op.in]: [1, 2, 3],
    },
  },
});
WHERE `actor_id` IN (1, 2, 3);

特别大的 IN 列表可能产生很长 SQL,应考虑临时表、关联表、批处理或重新设计查询来源。

6.5 LIKE

const actors = await Actor.findAll({
  where: {
    lastName: {
      [Op.like]: "WA%",
    },
  },
});
WHERE `last_name` LIKE 'WA%';

这里的 %_LIKE 通配符。即使 Sequelize 会把值作为参数安全处理,用户输入中的 %_ 仍可能改变匹配语义;如果业务要求字面搜索,需要制定转义规则。

Op.iLike 是 PostgreSQL 特有运算符,不适用于 MySQL。MySQL 是否区分大小写通常取决于列的字符集和 collation,例如常见的 _ci 排序规则表示大小写不敏感。不要仅靠切换 Sequelize 运算符猜测行为。

6.6 NULL

const activeRentals = await Rental.findAll({
  where: {
    returnDate: {
      [Op.is]: null,
    },
  },
});

SQL 应使用 IS NULL,而不是 = NULL

WHERE `return_date` IS NULL;

7. 组合 ANDORNOT

查询“姓为 GUINESS,或者名字为 PENELOPE 且主键大于 10”:

const actors = await Actor.findAll({
  where: {
    [Op.or]: [
      { lastName: "GUINESS" },
      {
        [Op.and]: [
          { firstName: "PENELOPE" },
          { actorId: { [Op.gt]: 10 } },
        ],
      },
    ],
  },
});

大致 SQL:

WHERE `last_name` = 'GUINESS'
   OR (`first_name` = 'PENELOPE' AND `actor_id` > 10);

复杂条件最常见的问题不是语法,而是括号和业务逻辑不一致。建议:

  1. 先用自然语言写出逻辑;
  2. 再写目标 SQL 的括号;
  3. 最后转换为 Op.and/Op.or
  4. 通过日志或测试检查实际 SQL 与边界数据。

8. 列与列之间比较

如果要比较两列,可以使用 col()

import { col, Op } from "sequelize";

const rows = await SomeModel.findAll({
  where: {
    actualValue: {
      [Op.gt]: col("expectedValue"),
    },
  },
});

大致 SQL:

WHERE `actual_value` > `expected_value`;

不要把列名伪装成普通字符串值,否则 Sequelize 会把它当作参数,而不是 SQL 标识符。

9. 更新:静态 Model.update()

const [affectedCount] = await Actor.update(
  { lastName: "UPDATED" },
  {
    where: {
      actorId: 201,
    },
  },
);

console.log(`影响 ${affectedCount} 行`);

大致 SQL:

UPDATE `actor`
SET `last_name` = 'UPDATED'
WHERE `actor_id` = 201;

在 MySQL 中,不要期待通过 returning: true 像 PostgreSQL 那样直接拿到更新后的完整行。需要最新数据时再查询:

const actor = await Actor.findByPk(201);

9.1 防止无条件批量更新

危险代码:

await Actor.update({ lastName: "WRONG" }, { where: {} });

这可能更新整张表。实战中应:

10. 删除:静态 Model.destroy()

const deletedCount = await Actor.destroy({
  where: {
    actorId: 201,
  },
});

返回值是删除行数,而不是被删除的实例:

DELETE FROM `actor` WHERE `actor_id` = 201;

清空表的选项更危险:

await TemporaryModel.destroy({
  where: {},
  truncate: true,
});

truncate 的事务、外键和自增值行为与普通 DELETE 不完全相同,并受 MySQL 规则影响。普通接口不应暴露这种能力。

11. 排序:order

const actors = await Actor.findAll({
  order: [
    ["lastName", "ASC"],
    ["firstName", "ASC"],
    ["actorId", "ASC"],
  ],
});

大致 SQL:

ORDER BY `last_name` ASC, `first_name` ASC, `actor_id` ASC;

分页查询应该有稳定排序。只按可能重复的 lastName 排序,同姓记录之间顺序不确定;追加唯一主键可以提供稳定的次序。

不要允许客户端把任意字符串直接作为排序列。应建立白名单映射:

const sortableFields = {
  id: "actorId",
  firstName: "firstName",
  lastName: "lastName",
} as const;

type SortKey = keyof typeof sortableFields;

function getOrder(sortKey: SortKey, descending: boolean) {
  const direction = descending ? "DESC" : "ASC";
  return [[sortableFields[sortKey], direction]] as const;
}

如果将用户提供的列名、方向或 SQL 片段直接拼接,会带来注入风险或任意昂贵排序风险。

12. 分页:limitoffset

const pageSize = 20;
const page = 3;

const actors = await Actor.findAll({
  order: [["actorId", "ASC"]],
  limit: pageSize,
  offset: (page - 1) * pageSize,
});

大致 SQL:

ORDER BY `actor_id` ASC
LIMIT 20 OFFSET 40;

基础后台页面使用 offset 分页很方便,但页码越靠后,MySQL 通常仍需扫描并丢弃大量前置记录。大数据量、无限滚动或时间线接口可使用游标/键集分页:

const actors = await Actor.findAll({
  where: {
    actorId: { [Op.gt]: lastActorId },
  },
  order: [["actorId", "ASC"]],
  limit: 20,
});

这依赖稳定、有索引且方向一致的游标列。多列排序时,游标条件也必须表达完整的字典序关系。

13. 分组:group 与聚合

统计 World 中每个国家的城市数量:

import { fn, col } from "sequelize";

const counts = await City.findAll({
  attributes: [
    "countryCode",
    [fn("COUNT", col("ID")), "cityCount"],
  ],
  group: [col("CountryCode")],
  order: [[fn("COUNT", col("ID")), "DESC"]],
});

大致 SQL:

SELECT
  `CountryCode` AS `countryCode`,
  COUNT(`ID`) AS `cityCount`
FROM `city` AS `City`
GROUP BY `CountryCode`
ORDER BY COUNT(`ID`) DESC;

MySQL 开启 ONLY_FULL_GROUP_BY 时,未聚合的 SELECT 列通常必须出现在 GROUP BY 中。不要为迁就错误查询而关闭严格 SQL 模式,应修正查询含义。

14. 常用聚合快捷方法

const actorCount = await Actor.count();

const maxPopulation = await City.max("population");
const minPopulation = await City.min("population");
const totalPopulation = await City.sum("population", {
  where: { countryCode: "CHN" },
});

这些方法最终仍会执行 COUNTMAXMINSUM SQL。返回类型和空集行为应通过项目中的实际模型类型与测试确认,尤其是 DECIMALBIGINT 和可能返回 NULL 的聚合结果。

统计接口要关注:

15. 模型实例与 raw: true

const actors = await Actor.findAll({
  attributes: ["actorId", "firstName", "lastName"],
  raw: true,
});

raw: true 让查询结果成为普通对象,而不是 Actor 实例。它适合只读报表、简单映射和不需要实例方法的场景。

代价包括:

不要为了“性能”在整个项目全局开启 raw: true。先确认查询确实不需要模型实例,并用性能数据验证收益。

16. 查询安全:参数与 SQL 标识符要区分

普通 where 值会由 Sequelize 处理:

await Actor.findAll({
  where: { lastName: userInput },
});

这比字符串拼接安全:

// 错误示范:不要把用户输入拼成 SQL
const sql = `SELECT * FROM actor WHERE last_name = '${userInput}'`;

但参数化只保护“值”。表名、列名、排序方向等 SQL 结构不能通过普通参数替代,必须使用可信映射或白名单。

同时要区分:

ORM 主要帮助第一类,后两类仍需业务代码、索引和接口限制。

17. MySQL 查询性能实践

17.1 先限制行,再限制列

列表接口通常同时需要 where、稳定 orderlimit 和精简的 attributes,任何一项都不能替代其他项。

17.2 索引服务于真实查询

如果接口经常执行:

WHERE CountryCode = ?
ORDER BY Population DESC, ID ASC
LIMIT 20

应结合数据分布和 EXPLAIN 评估复合索引,而不是看到 WHEREORDER BY 中每一列就各建一个单列索引。

17.3 避免前导通配符误用

LIKE '%abc' 通常难以利用普通 B-Tree 索引的前缀能力。搜索需求复杂时,应考虑全文索引或专门搜索服务,而不是无限堆叠 LIKE

17.4 记录慢查询,而不是只记录报错

成功返回的查询也可能非常慢。可以使用 Sequelize logging 回调记录耗时,结合 MySQL 慢查询日志和 EXPLAIN 定位问题。

17.5 缓存不是坏 SQL 的替代品

少量高频、读多写少的查询以后可以用 Redis 缓存普通 JSON 结果;但应先修正索引和查询,并在 MySQL 事务成功后处理缓存失效。MySQL 仍是事实来源。

18. 常见错误

18.1 将查询参数原样映射成 where

// 不推荐
await Actor.findAll({ where: req.query });

这会让客户端控制意料之外的字段和类型。应解析、校验并显式建立查询对象。

18.2 忘记处理类型转换

Express 的 query 参数通常来自字符串。?minPopulation=1000000 不能仅凭 TypeScript 类型断言就变成合法数字,应在运行时验证范围和整数性。

18.3 认为 update() 返回更新实例

MySQL 下静态 Model.update() 主要返回受影响行数。接口若要返回最新资源,应明确再查询,不要照搬 PostgreSQL 的 returning 经验。

18.4 无稳定排序却使用分页

没有 order,或排序列大量重复而没有唯一键兜底,会导致翻页时重复或遗漏记录。

18.5 忽略实际生成 SQL

查询对象看起来很短,并不代表 SQL 很便宜。关联、分组和子查询会进一步放大这种差异,应结合日志与 EXPLAIN 学习。

19. 本章检查清单

20. 练习题(暂不提供答案)

  1. 查询 Sakila actor 表中姓以 S 开头的演员,只返回主键、姓和名,并按姓、名、主键稳定排序。
  2. 查询 Sakila 中 actor_id 位于 50 到 100 之间,并且姓为 TEMPLEALLEN 的演员。先写目标 SQL,再写 Sequelize 查询。
  3. 对 Sakila film 表实现列表查询:片长在 90–120 分钟、分级位于 PGPG-13、按租金降序和主键升序排列,每页 20 条。
  4. 使用 Sakila rental 表查询尚未归还的租赁记录,说明为什么 return_date 条件必须使用 IS NULL 语义。
  5. 使用 World city 表统计每个 CountryCode 的城市数量和总人口,按总人口降序排列。确保查询能在启用 ONLY_FULL_GROUP_BY 时正常工作。
  6. 分别使用 offset 分页和以 ID 为游标的分页读取 World city,比较两种方法在第 1 页和较深页码上的 SQL 与执行计划。
  7. 编写一个更新 Sakila 演员姓名的服务函数,限制只能更新 firstNamelastName,要求主键有效,并检查受影响行数是否为 1。
  8. 使用 bulkCreate() 向一张自建的 Sakila 练习表批量插入 500 条记录:每 100 条一批,开启校验,并设计任意一批失败时的事务策略。
  9. 为 World 城市列表设计排序白名单,只允许按 namepopulationid 排序;拒绝任意 SQL 字符串作为排序列或方向。
  10. 找出 Sakila 中姓氏重复最多的前 10 组演员,分别观察模型实例结果与 raw: true 结果,并为聚合结果设计明确的 TypeScript DTO。

21. 官方参考