MySQL2 + TypeScript 实战教程:不使用 ORM 操作 MySQL

本文基于 MySQL2 官方文档,使用当前项目的 TypeScript、CommonJS 编译模式和 mysql2/promise。示例主要围绕 MySQL 官方 Sakila 与 World 示例数据库。

1. 学习目标

完成本教程后,你应该能够:

2. mysql2 与 Sequelize 是什么关系

可以把一次 Sequelize 查询理解成下面这条链路:

业务代码
  -> Sequelize:模型、关联、校验、查询对象
  -> mysql2:MySQL 协议、连接、参数传输、结果解析
  -> MySQL Server:执行 SQL、事务、约束、索引

Sequelize 在使用 MySQL 方言时依赖 mysql2 与数据库通信,但两者不是同一层的工具:

工具 主要职责
Sequelize ORM、模型映射、关联、实例、查询构造
mysql2 连接 MySQL、发送 SQL、绑定参数、读取结果
MySQL 真正保存数据并执行 SQL、事务、索引和约束

直接使用 mysql2 时,你不再拥有模型和关联等 ORM 能力,但能更直接地控制 SQL。学习 mysql2 的价值并不只是“绕过 Sequelize”,还包括:

3. 安装与 TypeScript 支持

项目已经安装 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;
});

4. 建立单个数据库连接

最小示例:

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 带来额外压力。

5. 使用环境变量保存连接配置

不要把真实密码写在源码中。结合当前项目已经安装的 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 不会验证环境变量是否真的存在,因此仍需要运行时检查。

6. Web 服务应优先使用连接池

连接池维护一组可复用连接。请求执行 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 也不是越大越好:

普通的单条查询直接使用 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();那会关闭整个应用共享的连接池。

7. query()execute() 的区别

两者都能执行 SQL,但工作方式不同。

7.1 query()

const [rows] = await pool.query(
  "SELECT actor_id, first_name FROM actor WHERE actor_id = ?",
  [10],
);

query() 使用客户端格式化和转义占位符,再把 SQL 发送给 MySQL。它适合:

7.2 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 本身更重要。

8. SELECT 查询与 TypeScript 返回类型

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“你认为数据库会返回什么”。它不会在运行时检查:

因此 SQL、DDL 和 TypeScript 类型必须一起维护。启用了 noUncheckedIndexedAccess 后,rows[0] 的类型包含 undefined,使用 ?? null 能明确表达未查到记录。

8.1 列表查询

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 扫描并跳过很多行,数据量较大时应考虑基于主键的游标分页。

8.2 字段元数据

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

9. INSERT、UPDATE、DELETE 的结果类型

写操作通常使用 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 可能意味着记录不存在、条件不匹配或状态已经变化,这通常属于业务需要处理的结果。

10. 参数化查询与 SQL 注入

10.1 不要拼接用户输入

危险写法:

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 注入。

10.2 占位符只能代表值

下面的写法不能安全地动态替换列名:

// 错误思路: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 查询参数不能在未经转换和校验时直接传入。

10.3 不要随意开启多语句

mysql2 默认不会执行一段字符串中的多条 SQL。不要仅为“方便”开启:

multipleStatements: true

多语句会扩大 SQL 注入造成的破坏面,也会让返回类型和错误处理更复杂。多个相关写操作应通过事务组织,而不是把用户相关 SQL 拼成一个长字符串。

11. 动态查询条件的正确组织方式

搜索接口经常有可选条件。应分别维护 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 性能都更难控制。

12. 事务:必须使用同一个连接

事务是 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();
  }
}

核心规则:

  1. 从 pool 显式取得一个连接;
  2. 在这个连接上调用 beginTransaction()
  3. 事务内所有 SQL 都通过这个连接执行;
  4. 成功时 commit()
  5. 失败时 rollback()
  6. 无论成功失败,都在 finallyrelease()

下面的写法是错误的:

await pool.query("START TRANSACTION");
await pool.query("INSERT INTO ...");
await pool.query("COMMIT");

每次 pool.query() 都可能借到不同连接。MySQL 事务属于连接级状态,三条 SQL 如果不在同一个连接上,就不构成预期中的事务。

12.1 事务不等于自动解决并发

事务保证一组操作的原子性,但并不会自动阻止两个事务同时读到旧值。库存扣减、余额变化和抢购场景还需要结合:

先明确并发不变量,再选择锁和 SQL;不要看到并发问题就盲目提高隔离级别。

13. MySQL 数据类型与 JavaScript 类型

13.1 DECIMAL 默认常以字符串返回

MySQL 的 DECIMAL 能精确存储金额,而 JavaScript number 是浮点数。mysql2 默认倾向于把 DECIMAL 返回为字符串,避免无声丢失精度:

interface PaymentRow extends RowDataPacket {
  paymentId: number;
  amount: string;
}

mysql2 提供 decimalNumbers: true,但启用后可能发生精度损失。金额场景通常更适合保留字符串,再交给明确的十进制定点方案处理。

13.2 BIGINT 可能超出安全整数范围

JavaScript 安全整数上限是 Number.MAX_SAFE_INTEGER。对可能很大的 BIGINT,可考虑:

const pool = mysql.createPool({
  ...mysqlConfig,
  supportBigNumbers: true,
  bigNumberStrings: true,
});

然后在 TypeScript 中把对应字段声明为 string。不要为了使用方便直接 Number(value),除非已经验证它不会超出安全范围。

13.3 日期、时区和 dateStrings

MySQL 的 DATEDATETIMETIMESTAMP 语义不同。驱动还涉及数据库会话时区和 Node.js 时区。

可以让特定日期类型保持字符串:

const pool = mysql.createPool({
  ...mysqlConfig,
  dateStrings: ["DATE", "DATETIME"],
});

这会把日期解析责任交给应用。无论选择 Date 还是字符串,团队都应明确:

13.4 NULL 必须反映在类型里

如果数据库列允许 NULL

interface FilmRow extends RowDataPacket {
  releaseYear: number | null;
}

不要仅因为当前测试数据没有 NULL 就写成 number。TypeScript 类型应对应 DDL,而不是对应几条样本数据。

13.5 TINYINT(1) 不一定天然是业务布尔值

MySQL 经常用 TINYINT(1) 表示布尔状态,但数据库本质上仍可能返回数字。应根据真实驱动结果和业务含义决定类型,不要仅凭显示宽度推断。

14. MySQL 错误处理

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 和必要上下文,但参数可能包含密码、手机号等敏感数据,也不能无条件打印。

数据库错误码是实现细节。业务层应把它们转换成稳定的领域结果,例如“邮箱已存在”“引用对象不存在”或“服务暂时不可用”。

15. 在 Express 5 中组织 mysql2 代码

推荐保持三层边界清晰:

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,也不要把 reqres 传到 repository。这样 repository 才能脱离 Express 单独测试。

16. 批量操作

批量插入比逐条网络往返更高效:

const actors: ReadonlyArray<readonly [string, string]> = [
  ["AMY", "ADAMS"],
  ["JOHN", "DOE"],
];

await pool.query(
  "INSERT INTO actor (first_name, last_name) VALUES ?",
  [actors],
);

但批量越大并不一定越好,还要考虑:

生产中常按固定批次处理,例如每批数百或数千行,并根据数据大小和监控结果调整。

对于动态 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,因此应在应用层提前返回。

17. 性能与连接池经验

17.1 优先优化 SQL 和索引

连接池只能减少建连成本,不能修复慢 SQL。遇到接口慢时依次检查:

  1. 实际 SQL 与参数;
  2. MySQL 执行计划;
  3. 扫描行数和返回行数;
  4. 索引是否匹配 WHERE、JOIN、ORDER BY;
  5. 是否存在锁等待;
  6. 查询是否等待连接池;
  7. Node.js 是否做了大量同步计算。

17.2 不要无条件记录所有参数

开发环境可以记录 SQL 耗时,生产环境应结合采样、慢查询阈值和敏感信息脱敏。推荐记录:

17.3 避免 SELECT *

显式列出所需字段能:

17.4 大结果集不要一次全部载入内存

Promise API 通常把结果完整收集后返回。导出大量数据时,应考虑分页、游标或 mysql2 回调 API 提供的查询流,并处理背压。不要把几十万行查询结果直接转成一个巨大 JSON 响应。

17.5 超时要分层理解

数据库连接超时、SQL 执行时间、Express 请求超时和 Axios 超时不是同一个概念。mysql2 的 connectTimeout 主要约束建立连接的等待时间,不等于限制任意 SQL 的总执行时间。

对于慢查询,更可靠的做法包括:

18. 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 增删列后更容易发生静默映射错误。

19. 什么时候直接用 mysql2,什么时候用 Sequelize

适合直接使用 mysql2:

适合使用 Sequelize:

同一个工程可以同时使用两者,因为 Sequelize 的 sequelize.query() 本身也允许执行原生 SQL。但要注意:

20. 常见错误清单

21. 本章检查清单

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

  1. 使用 mysql2/promise 和连接池查询 Sakila 的 actor 表,返回前 20 名演员。为结果声明继承 RowDataPacket 的 TypeScript 接口,并使用 SQL 别名转换成 camelCase。
  2. 为 Sakila film 表编写按标题关键字和上映年份搜索的函数。两个条件都可以省略,所有值必须参数化,并限制最多返回 50 条。
  3. 分别使用 query()execute()actor_id 查询演员,观察两者的调用方式,并说明哪一种更适合重复执行的固定 SQL。
  4. 使用 World 数据库的 city 表实现分页查询,先使用 limit + offset,再改写成基于 ID 的游标分页,并比较预计扫描的数据量。
  5. 向 Sakila actor 表插入一条记录,通过 ResultSetHeader.insertId 获得新主键;随后更新它,并检查 affectedRows
  6. 编写一个事务:在 Sakila 中创建一条 rental 记录和对应的 payment 记录。要求任一步骤失败时回滚,并保证连接最终释放。
  7. 使用 World 的 countrycity 表完成 JOIN 查询,统计每个国家的城市数量,只返回城市数大于指定值的国家,并分析应在哪些列上建立或利用索引。
  8. 查询 Sakila payment.amount,验证 mysql2 实际返回的 JavaScript 类型。分别评估保留字符串和启用 decimalNumbers 的风险,不要直接修改生产配置。
  9. 故意制造唯一约束冲突或外键约束冲突,捕获 unknown 错误并通过类型守卫读取 MySQL 错误码,但不要把 SQL 和敏感参数返回给 HTTP 客户端。
  10. 编写一个批量查询演员的函数,参数是 readonly number[]。要求正确处理空数组、参数化 IN 条件,并限制一次最多接收 100 个 ID。

23. 官方参考