本文基于 MySQL2 官方文档,使用当前项目的 TypeScript、CommonJS 编译模式和
mysql2/promise。示例主要围绕 MySQL 官方 Sakila 与 World 示例数据库。
完成本教程后,你应该能够:
query()、execute() 和占位符安全执行 SQL;DECIMAL、BIGINT、日期和 NULL;可以把一次 Sequelize 查询理解成下面这条链路:
业务代码
-> Sequelize:模型、关联、校验、查询对象
-> mysql2:MySQL 协议、连接、参数传输、结果解析
-> MySQL Server:执行 SQL、事务、约束、索引
Sequelize 在使用 MySQL 方言时依赖 mysql2 与数据库通信,但两者不是同一层的工具:
| 工具 | 主要职责 |
|---|---|
| Sequelize | ORM、模型映射、关联、实例、查询构造 |
| mysql2 | 连接 MySQL、发送 SQL、绑定参数、读取结果 |
| MySQL | 真正保存数据并执行 SQL、事务、索引和约束 |
直接使用 mysql2 时,你不再拥有模型和关联等 ORM 能力,但能更直接地控制 SQL。学习 mysql2 的价值并不只是“绕过 Sequelize”,还包括:
项目已经安装 mysql2 时,不需要再次安装:
npm install mysql2
mysql2 自带 TypeScript 类型声明,不需要安装 @types/mysql2。项目仍然需要 Node.js 类型:
npm install --save-dev @types/node
本文使用 Promise API:
import mysql from "mysql2/promise";
当前项目启用了 esModuleInterop: true,因此可以使用默认导入。TypeScript 最终仍会按照当前 tsconfig.json 编译为 CommonJS,这与源码使用 import 并不冲突。
本文代码中的 await 应放在 async 函数内:
async function main(): Promise<void> {
// 在这里使用 await。
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
最小示例:
import mysql from "mysql2/promise";
async function main(): Promise<void> {
const connection = await mysql.createConnection({
host: "127.0.0.1",
port: 3306,
user: "app_user",
password: "password",
database: "sakila",
charset: "utf8mb4",
});
try {
const [rows] = await connection.query("SELECT 1 AS value");
console.log(rows);
} finally {
await connection.end();
}
}
单连接适合:
Web 服务不应该为每个 HTTP 请求重新 createConnection()。频繁建立 TCP 连接、认证和关闭连接会增加延迟,也会给 MySQL 带来额外压力。
不要把真实密码写在源码中。结合当前项目已经安装的 dotenv:
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=app_user
MYSQL_PASSWORD=change_me
MYSQL_DATABASE=sakila
import "dotenv/config";
import type { PoolOptions } from "mysql2";
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`缺少环境变量:${name}`);
}
return value;
}
const port = Number(process.env.MYSQL_PORT ?? "3306");
if (!Number.isInteger(port) || port <= 0) {
throw new Error("MYSQL_PORT 必须是有效端口号");
}
export const mysqlConfig: PoolOptions = {
host: requiredEnv("MYSQL_HOST"),
port,
user: requiredEnv("MYSQL_USER"),
password: requiredEnv("MYSQL_PASSWORD"),
database: requiredEnv("MYSQL_DATABASE"),
charset: "utf8mb4",
};
PoolOptions 给配置对象提供静态检查,但 TypeScript 不会验证环境变量是否真的存在,因此仍需要运行时检查。
连接池维护一组可复用连接。请求执行 SQL 时借用连接,操作结束后把连接归还给池,而不是断开它。
import mysql from "mysql2/promise";
import { mysqlConfig } from "./mysql-config";
export const pool = mysql.createPool({
...mysqlConfig,
waitForConnections: true,
connectionLimit: 10,
maxIdle: 10,
idleTimeout: 60_000,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0,
});
常用配置含义:
| 配置 | 含义 |
|---|---|
connectionLimit |
池内允许同时存在的最大连接数 |
maxIdle |
最多保留多少空闲连接 |
idleTimeout |
空闲连接保留时间,单位毫秒 |
waitForConnections |
连接用尽时是否等待 |
queueLimit |
等待队列上限;0 表示不限制 |
enableKeepAlive |
是否对 TCP 连接启用 keep-alive |
连接池不会在创建时一次性建立所有连接,而是按需创建。connectionLimit: 10 也不是越大越好:
max_connections 和监控数据调整。普通的单条查询直接使用 pool.query() 或 pool.execute(),mysql2 会自动借出并归还连接:
const [rows] = await pool.query("SELECT actor_id FROM actor LIMIT 10");
服务关闭时再结束整个连接池:
async function shutdown(signal: string): Promise<void> {
console.log(`收到 ${signal},正在关闭数据库连接池`);
await pool.end();
}
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
不要在每个请求结束时调用 pool.end();那会关闭整个应用共享的连接池。
query() 与 execute() 的区别两者都能执行 SQL,但工作方式不同。
query()const [rows] = await pool.query(
"SELECT actor_id, first_name FROM actor WHERE actor_id = ?",
[10],
);
query() 使用客户端格式化和转义占位符,再把 SQL 发送给 MySQL。它适合:
IN (?) 值;execute()const [rows] = await pool.execute(
"SELECT actor_id, first_name FROM actor WHERE actor_id = ?",
[10],
);
execute() 使用服务器端 prepared statement。mysql2 会准备语句、执行语句,并缓存经常重复使用的预处理语句。
它适合 SQL 结构固定、只是参数值变化的高频操作:
const sql = "SELECT actor_id, first_name FROM actor WHERE actor_id = ?";
await pool.execute(sql, [10]);
await pool.execute(sql, [20]);
简单判断:
| 场景 | 建议 |
|---|---|
| 固定 SQL、多次执行 | 优先考虑 execute() |
| 动态拼装查询条件 | 通常使用 query(),但值仍必须参数化 |
| 动态表名、列名、排序方向 | 占位符不能解决,必须使用白名单 |
| 大量不同 SQL 文本 | 不要误以为 prepared statement 缓存一定更快 |
不要把 execute() 理解成“自动让所有 SQL 变快”。网络、索引、扫描行数、排序、锁等待往往比 prepare 本身更重要。
mysql2 默认把 SELECT 结果表示为对象数组。自定义行类型应继承 RowDataPacket:
import type { RowDataPacket } from "mysql2";
import { pool } from "./database";
interface ActorRow extends RowDataPacket {
actorId: number;
firstName: string;
lastName: string;
lastUpdate: Date;
}
export async function findActorById(
actorId: number,
): Promise<ActorRow | null> {
const [rows] = await pool.execute<ActorRow[]>(
`
SELECT
actor_id AS actorId,
first_name AS firstName,
last_name AS lastName,
last_update AS lastUpdate
FROM actor
WHERE actor_id = ?
`,
[actorId],
);
return rows[0] ?? null;
}
这里的泛型只告诉 TypeScript“你认为数据库会返回什么”。它不会在运行时检查:
NULL;因此 SQL、DDL 和 TypeScript 类型必须一起维护。启用了 noUncheckedIndexedAccess 后,rows[0] 的类型包含 undefined,使用 ?? null 能明确表达未查到记录。
interface FilmRow extends RowDataPacket {
filmId: number;
title: string;
releaseYear: number | null;
}
export async function listFilms(
limit: number,
offset: number,
): Promise<FilmRow[]> {
const [rows] = await pool.execute<FilmRow[]>(
`
SELECT
film_id AS filmId,
title,
release_year AS releaseYear
FROM film
ORDER BY film_id
LIMIT ? OFFSET ?
`,
[limit, offset],
);
return rows;
}
分页必须有稳定的 ORDER BY。大 offset 会导致 MySQL 扫描并跳过很多行,数据量较大时应考虑基于主键的游标分页。
query() 和 execute() 返回二元数组:
const [rows, fields] = await pool.query<ActorRow[]>(
"SELECT actor_id AS actorId FROM actor LIMIT 1",
);
console.log(rows);
console.log(fields);
fields 包含列名、类型等元数据。普通业务通常只使用 rows,调试动态查询、结果映射或底层工具时才更常关注 fields。
写操作通常使用 ResultSetHeader:
import type { ResultSetHeader } from "mysql2";
interface CreateActorInput {
firstName: string;
lastName: string;
}
export async function createActor(
input: CreateActorInput,
): Promise<number> {
const [result] = await pool.execute<ResultSetHeader>(
`
INSERT INTO actor (first_name, last_name)
VALUES (?, ?)
`,
[input.firstName, input.lastName],
);
return result.insertId;
}
更新时应检查 affectedRows:
export async function renameActor(
actorId: number,
firstName: string,
lastName: string,
): Promise<boolean> {
const [result] = await pool.execute<ResultSetHeader>(
`
UPDATE actor
SET first_name = ?, last_name = ?
WHERE actor_id = ?
`,
[firstName, lastName, actorId],
);
return result.affectedRows === 1;
}
删除同理:
export async function deleteActor(actorId: number): Promise<boolean> {
const [result] = await pool.execute<ResultSetHeader>(
"DELETE FROM actor WHERE actor_id = ?",
[actorId],
);
return result.affectedRows === 1;
}
经验上不要只判断 SQL 是否“没有抛异常”。affectedRows === 0 可能意味着记录不存在、条件不匹配或状态已经变化,这通常属于业务需要处理的结果。
危险写法:
const keyword = String(requestQuery.keyword ?? "");
// 错误:用户输入直接进入 SQL 结构。
const sql = `SELECT film_id, title FROM film WHERE title LIKE '%${keyword}%'`;
await pool.query(sql);
安全写法:
const keyword = String(requestQuery.keyword ?? "");
const [rows] = await pool.execute<FilmRow[]>(
`
SELECT film_id AS filmId, title, release_year AS releaseYear
FROM film
WHERE title LIKE ?
ORDER BY film_id
LIMIT 20
`,
[`%${keyword}%`],
);
% 和 _ 是 LIKE 通配符。如果业务要求按普通文本匹配,需要额外设计转义规则,不能只考虑 SQL 注入。
下面的写法不能安全地动态替换列名:
// 错误思路:SELECT ? FROM film ORDER BY ? ?
表名、列名和排序方向属于 SQL 结构,应使用程序内部白名单:
const filmSortColumns = {
id: "film_id",
title: "title",
updatedAt: "last_update",
} as const;
type FilmSortKey = keyof typeof filmSortColumns;
type SortDirection = "ASC" | "DESC";
function buildFilmOrder(
key: FilmSortKey,
direction: SortDirection,
): string {
return `${filmSortColumns[key]} ${direction}`;
}
const orderSql = buildFilmOrder("title", "ASC");
await pool.query(`
SELECT film_id, title
FROM film
ORDER BY ${orderSql}
LIMIT 20
`);
这里能够拼接,是因为两个输入都被 TypeScript 和白名单限制为程序内部允许的值。HTTP 查询参数不能在未经转换和校验时直接传入。
mysql2 默认不会执行一段字符串中的多条 SQL。不要仅为“方便”开启:
multipleStatements: true
多语句会扩大 SQL 注入造成的破坏面,也会让返回类型和错误处理更复杂。多个相关写操作应通过事务组织,而不是把用户相关 SQL 拼成一个长字符串。
搜索接口经常有可选条件。应分别维护 SQL 片段和值数组:
import type { QueryValues, RowDataPacket } from "mysql2";
interface FilmSearchInput {
title?: string;
releaseYear?: number;
limit: number;
}
interface FilmSearchRow extends RowDataPacket {
filmId: number;
title: string;
releaseYear: number | null;
}
export async function searchFilms(
input: FilmSearchInput,
): Promise<FilmSearchRow[]> {
const conditions: string[] = [];
const values: QueryValues = [];
if (input.title !== undefined) {
conditions.push("title LIKE ?");
values.push(`%${input.title}%`);
}
if (input.releaseYear !== undefined) {
conditions.push("release_year = ?");
values.push(input.releaseYear);
}
const whereSql =
conditions.length === 0 ? "" : `WHERE ${conditions.join(" AND ")}`;
values.push(input.limit);
const [rows] = await pool.query<FilmSearchRow[]>(
`
SELECT
film_id AS filmId,
title,
release_year AS releaseYear
FROM film
${whereSql}
ORDER BY film_id
LIMIT ?
`,
values,
);
return rows;
}
这里动态变化的是由程序预先写好的 SQL 片段;真正的用户值仍然通过占位符传递。
条件越来越复杂时,可以把查询封装成明确的 repository 函数,但不要过早制作一个能够接受任意表名、任意字段和任意操作符的“万能查询器”。那通常会让类型、安全和 SQL 性能都更难控制。
事务是 mysql2 最重要、也最容易写错的部分。
下面是“创建租赁记录并更新对应库存记录”的事务结构示例。这里更新 inventory.last_update 只是为了演示两个写操作必须共同提交或回滚;真实的租赁可用性规则还需要结合未归还租赁记录和并发锁设计:
import type { ResultSetHeader } from "mysql2";
interface CreateRentalInput {
inventoryId: number;
customerId: number;
staffId: number;
}
export async function createRental(
input: CreateRentalInput,
): Promise<number> {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [rentalResult] = await connection.execute<ResultSetHeader>(
`
INSERT INTO rental (
rental_date,
inventory_id,
customer_id,
staff_id,
last_update
)
VALUES (NOW(), ?, ?, ?, NOW())
`,
[input.inventoryId, input.customerId, input.staffId],
);
await connection.execute(
`
UPDATE inventory
SET last_update = NOW()
WHERE inventory_id = ?
`,
[input.inventoryId],
);
await connection.commit();
return rentalResult.insertId;
} catch (error: unknown) {
try {
await connection.rollback();
} catch (rollbackError: unknown) {
console.error("事务回滚失败", rollbackError);
}
throw error;
} finally {
connection.release();
}
}
核心规则:
beginTransaction();commit();rollback();finally 中 release()。下面的写法是错误的:
await pool.query("START TRANSACTION");
await pool.query("INSERT INTO ...");
await pool.query("COMMIT");
每次 pool.query() 都可能借到不同连接。MySQL 事务属于连接级状态,三条 SQL 如果不在同一个连接上,就不构成预期中的事务。
事务保证一组操作的原子性,但并不会自动阻止两个事务同时读到旧值。库存扣减、余额变化和抢购场景还需要结合:
affectedRows;SELECT ... FOR UPDATE 行锁;先明确并发不变量,再选择锁和 SQL;不要看到并发问题就盲目提高隔离级别。
DECIMAL 默认常以字符串返回MySQL 的 DECIMAL 能精确存储金额,而 JavaScript number 是浮点数。mysql2 默认倾向于把 DECIMAL 返回为字符串,避免无声丢失精度:
interface PaymentRow extends RowDataPacket {
paymentId: number;
amount: string;
}
mysql2 提供 decimalNumbers: true,但启用后可能发生精度损失。金额场景通常更适合保留字符串,再交给明确的十进制定点方案处理。
BIGINT 可能超出安全整数范围JavaScript 安全整数上限是 Number.MAX_SAFE_INTEGER。对可能很大的 BIGINT,可考虑:
const pool = mysql.createPool({
...mysqlConfig,
supportBigNumbers: true,
bigNumberStrings: true,
});
然后在 TypeScript 中把对应字段声明为 string。不要为了使用方便直接 Number(value),除非已经验证它不会超出安全范围。
dateStringsMySQL 的 DATE、DATETIME、TIMESTAMP 语义不同。驱动还涉及数据库会话时区和 Node.js 时区。
可以让特定日期类型保持字符串:
const pool = mysql.createPool({
...mysqlConfig,
dateStrings: ["DATE", "DATETIME"],
});
这会把日期解析责任交给应用。无论选择 Date 还是字符串,团队都应明确:
NULL 必须反映在类型里如果数据库列允许 NULL:
interface FilmRow extends RowDataPacket {
releaseYear: number | null;
}
不要仅因为当前测试数据没有 NULL 就写成 number。TypeScript 类型应对应 DDL,而不是对应几条样本数据。
TINYINT(1) 不一定天然是业务布尔值MySQL 经常用 TINYINT(1) 表示布尔状态,但数据库本质上仍可能返回数字。应根据真实驱动结果和业务含义决定类型,不要仅凭显示宽度推断。
TypeScript 的 catch 变量应按 unknown 处理:
import type { QueryError } from "mysql2";
function isQueryError(error: unknown): error is QueryError {
return (
error instanceof Error &&
"code" in error &&
typeof error.code === "string"
);
}
try {
await createActor({ firstName: "MARY", lastName: "SMITH" });
} catch (error: unknown) {
if (isQueryError(error)) {
console.error({
code: error.code,
errno: error.errno,
sqlState: error.sqlState,
message: error.message,
});
}
throw error;
}
常见错误类别包括:
不要向客户端直接返回 error.sql、数据库地址、账号或完整错误堆栈。日志中可以记录稳定的错误码、请求 ID 和必要上下文,但参数可能包含密码、手机号等敏感数据,也不能无条件打印。
数据库错误码是实现细节。业务层应把它们转换成稳定的领域结果,例如“邮箱已存在”“引用对象不存在”或“服务暂时不可用”。
推荐保持三层边界清晰:
Router / Controller
-> 解析 HTTP 输入、设置 HTTP 响应
Service
-> 业务规则、事务边界
Repository
-> SQL 与 mysql2 返回结果映射
Repository 示例:
export interface ActorDto {
id: number;
firstName: string;
lastName: string;
}
export async function getActor(id: number): Promise<ActorDto | null> {
const actor = await findActorById(id);
if (actor === null) {
return null;
}
return {
id: actor.actorId,
firstName: actor.firstName,
lastName: actor.lastName,
};
}
路由只负责 HTTP:
import { Router } from "express";
export const actorRouter = Router();
actorRouter.get("/:id", async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(400).json({ message: "演员 ID 不合法" });
return;
}
const actor = await getActor(id);
if (actor === null) {
res.status(404).json({ message: "演员不存在" });
return;
}
res.json({ data: actor });
});
不要让路由文件充满数百行 SQL,也不要把 req、res 传到 repository。这样 repository 才能脱离 Express 单独测试。
批量插入比逐条网络往返更高效:
const actors: ReadonlyArray<readonly [string, string]> = [
["AMY", "ADAMS"],
["JOHN", "DOE"],
];
await pool.query(
"INSERT INTO actor (first_name, last_name) VALUES ?",
[actors],
);
但批量越大并不一定越好,还要考虑:
max_allowed_packet;生产中常按固定批次处理,例如每批数百或数千行,并根据数据大小和监控结果调整。
对于动态 IN 查询,先处理空数组:
async function findActorsByIds(ids: readonly number[]): Promise<ActorRow[]> {
if (ids.length === 0) {
return [];
}
const [rows] = await pool.query<ActorRow[]>(
`
SELECT
actor_id AS actorId,
first_name AS firstName,
last_name AS lastName,
last_update AS lastUpdate
FROM actor
WHERE actor_id IN (?)
`,
[ids],
);
return rows;
}
空数组直接生成 IN () 会产生无效 SQL,因此应在应用层提前返回。
连接池只能减少建连成本,不能修复慢 SQL。遇到接口慢时依次检查:
开发环境可以记录 SQL 耗时,生产环境应结合采样、慢查询阈值和敏感信息脱敏。推荐记录:
SELECT *显式列出所需字段能:
Promise API 通常把结果完整收集后返回。导出大量数据时,应考虑分页、游标或 mysql2 回调 API 提供的查询流,并处理背压。不要把几十万行查询结果直接转成一个巨大 JSON 响应。
数据库连接超时、SQL 执行时间、Express 请求超时和 Axios 超时不是同一个概念。mysql2 的 connectTimeout 主要约束建立连接的等待时间,不等于限制任意 SQL 的总执行时间。
对于慢查询,更可靠的做法包括:
rowsAsArray 的使用场景默认结果是对象:
[{ actorId: 1, firstName: "PENELOPE" }]
当查询中存在重复列名,或底层工具更适合按字段位置读取时,可以使用 rowsAsArray: true:
const [rows] = await pool.query({
sql: "SELECT 1 AS value, 2 AS value",
rowsAsArray: true,
});
console.log(rows); // [[1, 2]]
普通业务代码更推荐使用带清晰别名的对象结果。数组结果依赖列顺序,SQL 增删列后更容易发生静默映射错误。
适合直接使用 mysql2:
适合使用 Sequelize:
同一个工程可以同时使用两者,因为 Sequelize 的 sequelize.query() 本身也允许执行原生 SQL。但要注意:
pool.getConnection() 获得的连接;connection.query() 和 pool.query();DECIMAL、BIGINT 无条件转换成 number;NULL 的列;multipleStatements 却没有明确必要性;RowDataPacket?ResultSetHeader.affectedRows?finally 中释放?DECIMAL、BIGINT、日期和 NULL 类型是否与真实返回一致?multipleStatements?mysql2/promise 和连接池查询 Sakila 的 actor 表,返回前 20 名演员。为结果声明继承 RowDataPacket 的 TypeScript 接口,并使用 SQL 别名转换成 camelCase。film 表编写按标题关键字和上映年份搜索的函数。两个条件都可以省略,所有值必须参数化,并限制最多返回 50 条。query() 和 execute() 按 actor_id 查询演员,观察两者的调用方式,并说明哪一种更适合重复执行的固定 SQL。city 表实现分页查询,先使用 limit + offset,再改写成基于 ID 的游标分页,并比较预计扫描的数据量。actor 表插入一条记录,通过 ResultSetHeader.insertId 获得新主键;随后更新它,并检查 affectedRows。rental 记录和对应的 payment 记录。要求任一步骤失败时回滚,并保证连接最终释放。country 与 city 表完成 JOIN 查询,统计每个国家的城市数量,只返回城市数大于指定值的国家,并分析应在哪些列上建立或利用索引。payment.amount,验证 mysql2 实际返回的 JavaScript 类型。分别评估保留字符串和启用 decimalNumbers 的风险,不要直接修改生产配置。unknown 错误并通过类型守卫读取 MySQL 错误码,但不要把 SQL 和敏感参数返回给 HTTP 客户端。readonly number[]。要求正确处理空数组、参数化 IN 条件,并限制一次最多接收 100 个 ID。