主线环境:Node.js、TypeScript、Sequelize v6、MySQL、
mysql2。
本章讨论的是:已经使用 Sequelize,为什么仍然需要 SQL,以及怎样安全地执行原生 SQL。
完成本章后,你应该能够:
sequelize.query() 执行查询和写操作;results 与 metadata;QueryTypes 明确查询类型;replacements 与 bind,避免 SQL 注入;Sequelize 可以覆盖大多数 CRUD,但以下场景中原生 SQL 往往更直接:
JSON_EXTRACT、全文索引;原生 SQL 不是“绕过 Sequelize”,而是借用 Sequelize 的连接池、事务和日志能力来执行 SQL。
经验上,不要因为 sequelize.query() 更像熟悉的 SQL 就把所有查询都改成原生 SQL。普通 CRUD 继续使用 Model API,复杂且确有收益的部分再使用原生 SQL,项目会更容易维护。
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,更不能把密码、令牌等敏感参数写入日志。
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);
默认返回一个二元数组:
results:结果数据;metadata:受影响行数、字段信息等元数据,具体结构由查询类型和数据库方言决定。对于 MySQL 的某些更新类查询,结果和元数据的表达方式与 PostgreSQL 不同,甚至可能引用同一个对象。因此不要编写依赖跨数据库统一元数据形状的代码,应先观察当前 MySQL 方言的真实返回结果。
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 中的别名必须和接口字段保持一致。
常见类型包括:
QueryTypes.SELECTQueryTypes.INSERTQueryTypes.UPDATEQueryTypes.DELETEQueryTypes.RAW如果已经明确查询类型,优先设置 type,调用端更容易理解返回值。
const cityName = "用户输入";
// 不要这样做:可能产生 SQL 注入。
await sequelize.query(
`SELECT city_id, city FROM city WHERE city = '${cityName}'`,
);
所有来自 URL、请求体、Cookie、消息队列和第三方 API 的数据,都应视为不可信输入。
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,
},
);
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。
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 语义中的通配符。如果业务要求“按字面搜索”,还要定义转义规则。
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 行为时才映射为实例。
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,避免数据库列别名直接决定接口契约。
假设需要先更新库存,再写入审计记录,这两个操作必须共同成功或共同失败:
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 TRANSACTION、COMMIT 再期待 Sequelize 自动管理连接;优先使用 sequelize.transaction()。
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 接口。
单次查询可以覆盖全局日志选项:
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,
},
);
后端工程中的常见做法:
EXPLAIN 检查扫描行数、索引选择、临时表和 filesort;SELECT *,只取需要的列;原生 SQL 并不天然比 ORM 快。真正影响性能的是生成的 SQL、索引、数据量、返回列数和调用次数。
不要轻易给 mysql2 开启 multipleStatements。它扩大了 SQL 注入事故的影响范围,也使权限和错误处理更复杂。一次业务需要多步写入时,应使用事务并分别执行参数化语句。
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。
MySQL BIGINT、DECIMAL 可能超出 JavaScript 安全整数或精确小数能力。不要看到 TS 中写了 number 就认为精度一定安全。金额可保持字符串、使用十进制定点库,或在明确范围内转换。
Redis 不是本章 SQL 查询的替代品。MySQL 通常是事实数据源,Redis 只适合缓存高频且允许短暂陈旧的报表结果。
transaction;SELECT *;multipleStatements 来减少代码行数;sequelize.query() 让原生 SQL 仍可复用 Sequelize 的连接池、事务和日志;QueryTypes.SELECT;replacements 和 bind 都用于安全传值,不能用于 SQL 结构;EXPLAIN 为依据。以下练习使用 MySQL Sakila 或 World 示例数据库:
sequelize.query() 和 QueryTypes.SELECT 查询 Sakila 中前 20 位演员,结果字段转换为 actorId、fullName。country_id 查询城市的参数化 SQL,分别使用命名 replacements 和命名 bind 实现。Population 和 Name 中选择,排序方向只能是 ASC 或 DESC;请用白名单实现,不能直接拼接用户输入。% 或 _ 时业务语义是否符合预期。customer、address、city、country 编写连接查询,借助列别名和 nest: true 返回嵌套结构。EXPLAIN 观察执行计划,并说明可能需要关注的索引。city 记录,并更新对应国家的某个统计字段;任一步失败都必须回滚。actor 的原生 SQL 映射为 Actor Model 实例,并比较它与普通对象结果的差异。