Sequelize v6 教程 07:Raw Queries(原生 SQL)

主线环境:Node.js、TypeScript、Sequelize v6、MySQL、mysql2
本章讨论的是:已经使用 Sequelize,为什么仍然需要 SQL,以及怎样安全地执行原生 SQL。

1. 学习目标

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

2. 为什么用了 ORM 还要学习原生 SQL

Sequelize 可以覆盖大多数 CRUD,但以下场景中原生 SQL 往往更直接:

原生 SQL 不是“绕过 Sequelize”,而是借用 Sequelize 的连接池、事务和日志能力来执行 SQL。

经验上,不要因为 sequelize.query() 更像熟悉的 SQL 就把所有查询都改成原生 SQL。普通 CRUD 继续使用 Model API,复杂且确有收益的部分再使用原生 SQL,项目会更容易维护。

3. 基础配置

import { Sequelize } from "sequelize";

export const sequelize = new Sequelize("sakila", "app_user", "password", {
  host: "127.0.0.1",
  dialect: "mysql",
  logging: (sql, timing) => {
    console.log({ sql, timing });
  },
  benchmark: true,
});

dialect: "mysql" 表示 Sequelize 会通过 MySQL 方言工作;项目还需要安装 mysql2。学习阶段建议开启 SQL 日志。生产环境不要无条件打印所有 SQL,更不能把密码、令牌等敏感参数写入日志。

4. sequelize.query() 的基本返回值

const [results, metadata] = await sequelize.query(
  "SELECT actor_id, first_name, last_name FROM actor LIMIT 5",
);

console.log(results);
console.log(metadata);

默认返回一个二元数组:

对于 MySQL 的某些更新类查询,结果和元数据的表达方式与 PostgreSQL 不同,甚至可能引用同一个对象。因此不要编写依赖跨数据库统一元数据形状的代码,应先观察当前 MySQL 方言的真实返回结果。

5. 使用 QueryTypes 简化返回结果

对 SELECT 查询明确指定 QueryTypes.SELECT,可以直接得到行数组:

import { QueryTypes } from "sequelize";

interface ActorRow {
  actorId: number;
  firstName: string;
  lastName: string;
}

const actors = await sequelize.query<ActorRow>(
  `
    SELECT
      actor_id AS actorId,
      first_name AS firstName,
      last_name AS lastName
    FROM actor
    ORDER BY actor_id
    LIMIT 10
  `,
  {
    type: QueryTypes.SELECT,
  },
);

actors.forEach((actor) => {
  console.log(actor.actorId, actor.firstName);
});

这里的 ActorRow 是 TypeScript 对查询结果的静态描述,不会在运行时验证数据库返回值。SQL 中的别名必须和接口字段保持一致。

常见类型包括:

如果已经明确查询类型,优先设置 type,调用端更容易理解返回值。

6. 参数化查询:不要拼接用户输入

6.1 危险写法

const cityName = "用户输入";

// 不要这样做:可能产生 SQL 注入。
await sequelize.query(
  `SELECT city_id, city FROM city WHERE city = '${cityName}'`,
);

所有来自 URL、请求体、Cookie、消息队列和第三方 API 的数据,都应视为不可信输入。

6.2 replacements

命名替换:

interface CityRow {
  cityId: number;
  city: string;
}

const cities = await sequelize.query<CityRow>(
  `
    SELECT city_id AS cityId, city
    FROM city
    WHERE country_id = :countryId
    ORDER BY city_id
    LIMIT :limit
  `,
  {
    replacements: {
      countryId: 44,
      limit: 20,
    },
    type: QueryTypes.SELECT,
  },
);

位置替换使用 ?

const rows = await sequelize.query<CityRow>(
  "SELECT city_id AS cityId, city FROM city WHERE country_id = ?",
  {
    replacements: [44],
    type: QueryTypes.SELECT,
  },
);

replacements 会由 Sequelize 正确转义并替换到 SQL 中。数组还可用于 IN(:ids)

const rows = await sequelize.query<CityRow>(
  `
    SELECT city_id AS cityId, city
    FROM city
    WHERE city_id IN(:ids)
  `,
  {
    replacements: { ids: [1, 2, 3] },
    type: QueryTypes.SELECT,
  },
);

6.3 bind

绑定参数不会先被拼进 SQL 文本,而是作为参数交给数据库驱动:

const rows = await sequelize.query<CityRow>(
  `
    SELECT city_id AS cityId, city
    FROM city
    WHERE country_id = $countryId
    LIMIT $rowLimit
  `,
  {
    bind: {
      countryId: 44,
      rowLimit: 20,
    },
    type: QueryTypes.SELECT,
  },
);

也可以使用 $1$2 配合数组。

二者都能安全传值,但不要同时使用。简单理解:

方式 SQL 中的写法 处理位置
replacements :name? Sequelize 转义并替换
bind $name$1 参数与 SQL 分开发给数据库

参数只能表示“值”,不能表示表名、列名、排序方向等 SQL 结构。下面的思路是不成立的:

// 错误思路:绑定参数不能替代列名。
// ORDER BY $column $direction

动态排序应使用白名单:

const allowedColumns = {
  name: "last_name",
  updatedAt: "last_update",
} as const;

type SortKey = keyof typeof allowedColumns;

function getActorOrderColumn(sort: SortKey): string {
  return allowedColumns[sort];
}

const orderColumn = getActorOrderColumn("name");

await sequelize.query(
  `SELECT actor_id, first_name, last_name FROM actor ORDER BY ${orderColumn} ASC`,
  { type: QueryTypes.SELECT },
);

模板字符串在这里是可接受的前提,是 orderColumn 只能来自程序内部白名单,绝不能直接来自 req.query.sort

6.4 LIKE 通配符

通配符通常放在参数值中:

const keyword = "AIR";

const films = await sequelize.query<{ filmId: number; title: string }>(
  `
    SELECT film_id AS filmId, title
    FROM film
    WHERE title LIKE :pattern
  `,
  {
    replacements: { pattern: `%${keyword}%` },
    type: QueryTypes.SELECT,
  },
);

参数化能够防注入,但 %_ 仍是 LIKE 语义中的通配符。如果业务要求“按字面搜索”,还要定义转义规则。

7. 把结果映射为 Model 实例

import {
  DataTypes,
  InferAttributes,
  InferCreationAttributes,
  Model,
} from "sequelize";

class Actor extends Model<
  InferAttributes<Actor>,
  InferCreationAttributes<Actor>
> {
  declare actorId: number;
  declare firstName: string;
  declare lastName: string;
}

Actor.init(
  {
    actorId: {
      type: DataTypes.SMALLINT.UNSIGNED,
      primaryKey: true,
      autoIncrement: true,
      field: "actor_id",
    },
    firstName: {
      type: DataTypes.STRING(45),
      allowNull: false,
      field: "first_name",
    },
    lastName: {
      type: DataTypes.STRING(45),
      allowNull: false,
      field: "last_name",
    },
  },
  {
    sequelize,
    tableName: "actor",
    timestamps: false,
  },
);

const actors = await sequelize.query(
  `
    SELECT
      actor_id AS actorId,
      first_name AS firstName,
      last_name AS lastName
    FROM actor
    WHERE actor_id <= :maxId
  `,
  {
    replacements: { maxId: 5 },
    model: Actor,
    mapToModel: true,
  },
);

actors.forEach((actor) => {
  console.log(actor.getDataValue("lastName"));
});

model 指定目标模型,mapToModel: true 会考虑模型的字段映射。返回的是 Model 实例,不再是普通对象。

如果只是给 API 返回报表数据,普通 DTO 通常更简单;只有确实需要实例方法、getter 等 Model 行为时才映射为实例。

8. 点号列名与 nest

当 SQL 别名包含点号时,nest: true 可以把结果转换为嵌套对象:

const rows = await sequelize.query(
  `
    SELECT
      c.customer_id AS 'customer.id',
      c.first_name AS 'customer.firstName',
      a.address AS 'customer.address.text'
    FROM customer AS c
    JOIN address AS a ON a.address_id = c.address_id
    LIMIT 1
  `,
  {
    type: QueryTypes.SELECT,
    nest: true,
  },
);

结果大致为:

[
  {
    customer: {
      id: 1,
      firstName: "MARY",
      address: {
        text: "47 MySakila Drive",
      },
    },
  },
]

嵌套结构适合内部展示,但对外 API 仍建议显式定义 DTO,避免数据库列别名直接决定接口契约。

9. 写操作与事务

假设需要先更新库存,再写入审计记录,这两个操作必须共同成功或共同失败:

await sequelize.transaction(async (transaction) => {
  await sequelize.query(
    `
      UPDATE inventory
      SET last_update = CURRENT_TIMESTAMP
      WHERE inventory_id = :inventoryId
    `,
    {
      replacements: { inventoryId: 10 },
      type: QueryTypes.UPDATE,
      transaction,
    },
  );

  await sequelize.query(
    `
      INSERT INTO inventory_audit (inventory_id, action, created_at)
      VALUES (:inventoryId, :action, CURRENT_TIMESTAMP)
    `,
    {
      replacements: {
        inventoryId: 10,
        action: "TOUCHED",
      },
      type: QueryTypes.INSERT,
      transaction,
    },
  );
});

关键点:每一条参与事务的查询都要传入同一个 transaction。漏传一次,该语句就可能从连接池获取另一条连接,在事务外执行。

不要手写 START TRANSACTIONCOMMIT 再期待 Sequelize 自动管理连接;优先使用 sequelize.transaction()

10. 一个 Express 5 查询接口示例

import { Router, Request, Response } from "express";
import { QueryTypes } from "sequelize";
import { sequelize } from "./database";

interface FilmSummaryRow {
  filmId: number;
  title: string;
  rentalCount: number;
}

const router = Router();

router.get(
  "/reports/films",
  async (
    req: Request<object, object, object, { minRentals?: string }>,
    res: Response,
  ) => {
    const parsedMinRentals = Number(req.query.minRentals ?? "1");
    const minRentals = Number.isInteger(parsedMinRentals)
      ? Math.max(0, parsedMinRentals)
      : 1;

    const rows = await sequelize.query<FilmSummaryRow>(
      `
        SELECT
          f.film_id AS filmId,
          f.title,
          COUNT(r.rental_id) AS rentalCount
        FROM film AS f
        JOIN inventory AS i ON i.film_id = f.film_id
        LEFT JOIN rental AS r ON r.inventory_id = i.inventory_id
        GROUP BY f.film_id, f.title
        HAVING COUNT(r.rental_id) >= :minRentals
        ORDER BY rentalCount DESC, f.film_id ASC
        LIMIT 100
      `,
      {
        replacements: { minRentals },
        type: QueryTypes.SELECT,
      },
    );

    res.json({ data: rows });
  },
);

export default router;

MySQL 的 COUNT() 经驱动返回时可能不是你预想的 JavaScript number,具体行为还受驱动配置影响。金额 DECIMAL 尤其常以字符串返回,这是为了避免 JavaScript 浮点数损失精度。接口 DTO 应根据实际驱动配置校验和转换,而不是只相信 TypeScript 接口。

11. 日志与性能分析

单次查询可以覆盖全局日志选项:

const rows = await sequelize.query(
  "SELECT film_id, title FROM film LIMIT 10",
  {
    type: QueryTypes.SELECT,
    logging: (sql, timing) => {
      console.log({ sql, timing });
    },
    benchmark: true,
  },
);

后端工程中的常见做法:

原生 SQL 并不天然比 ORM 快。真正影响性能的是生成的 SQL、索引、数据量、返回列数和调用次数。

12. MySQL 特别注意事项

12.1 禁止开启多语句只是为了方便

不要轻易给 mysql2 开启 multipleStatements。它扩大了 SQL 注入事故的影响范围,也使权限和错误处理更复杂。一次业务需要多步写入时,应使用事务并分别执行参数化语句。

12.2 日期与时区

DATETIME 不携带时区信息。数据库连接时区、Node.js 进程时区和业务时区必须形成明确约定。按“日期结束日”查询时,推荐半开区间:

WHERE rental_date >= :start
  AND rental_date < :endExclusive

例如查询 2026-01-01 至 2026-01-02 两个自然日,结束边界传 2026-01-03 00:00:00,而不是拼 23:59:59.999。

12.3 大整数和金额

MySQL BIGINTDECIMAL 可能超出 JavaScript 安全整数或精确小数能力。不要看到 TS 中写了 number 就认为精度一定安全。金额可保持字符串、使用十进制定点库,或在明确范围内转换。

13. 与 Redis 的边界

Redis 不是本章 SQL 查询的替代品。MySQL 通常是事实数据源,Redis 只适合缓存高频且允许短暂陈旧的报表结果。

14. 常见错误清单

15. 本章小结

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

以下练习使用 MySQL Sakila 或 World 示例数据库:

  1. 使用 sequelize.query()QueryTypes.SELECT 查询 Sakila 中前 20 位演员,结果字段转换为 actorIdfullName
  2. 编写一个按 country_id 查询城市的参数化 SQL,分别使用命名 replacements 和命名 bind 实现。
  3. 在 World 数据库中查询人口大于指定值的城市。排序字段只能在 PopulationName 中选择,排序方向只能是 ASCDESC;请用白名单实现,不能直接拼接用户输入。
  4. 查询 Sakila 中标题包含指定关键字的电影,并分析关键字包含 %_ 时业务语义是否符合预期。
  5. 使用 Sakila 的 customeraddresscitycountry 编写连接查询,借助列别名和 nest: true 返回嵌套结构。
  6. 查询每部电影的出租次数,使用 EXPLAIN 观察执行计划,并说明可能需要关注的索引。
  7. 在事务中完成两项操作:新增一条 World city 记录,并更新对应国家的某个统计字段;任一步失败都必须回滚。
  8. 将一段查询 Sakila actor 的原生 SQL 映射为 Actor Model 实例,并比较它与普通对象结果的差异。
  9. 设计一个电影热门榜缓存方案:写出缓存键包含的条件、过期时间,以及租赁数据写入成功后何时使缓存失效。Redis 只需写设计,不要求实现。

官方文档