00 学习路线与实践环境

1. 本章目标

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

本章不追求复杂功能。先把观察环境准备好,后面每个 CRUD、事务和关联示例才不会变成盲目复制。

2. Sequelize 位于哪一层

当前项目的管理员登录大致经过:

POST /api/auth/login
  ↓
routes/auth.ts
  ↓
controllers/auth.controller.ts
  ↓
services/auth.service.ts
  ↓
models/admin_user.ts
  ↓
Sequelize
  ↓
mysql2
  ↓
MySQL Server

各部分职责如下:

部分 主要职责
Controller 读取 HTTP 请求,调用 Service,组织响应
Service 执行业务规则和事务编排
Sequelize Model 描述表映射,提供查询和写入接口
Sequelize 生成 SQL、映射结果、管理事务和连接池
mysql2 实现 MySQL 网络协议,把 SQL 发给服务器
MySQL 真正保存数据、执行约束、索引、锁和事务

Sequelize 不会代替 MySQL 执行查询。下面的代码:

const admin = await AdminUser.findOne({
  where: { username: "admin" },
});

最终仍然会变成类似的 SQL:

SELECT
  `id`,
  `username`,
  `display_name`,
  `email`,
  `account_status`,
  `token_version`,
  `failed_login_count`,
  `locked_until`,
  `last_login_at`,
  `password_changed_at`,
  `created_at`,
  `updated_at`
FROM `admin_user`
WHERE `username` = 'admin'
LIMIT 1;

实际字段会受 Scope 和 Sequelize 版本影响,因此不要背生成文本,要理解查询语义并查看真实日志。

3. 三层真相

开发中经常把下面三件事混为一谈:

3.1 Model 声明

declare email: string | null;

这是 TypeScript 对程序员的承诺,只参与类型检查。

3.2 Sequelize 映射

email: {
  type: DataTypes.STRING(254),
  allowNull: true,
}

这是 Sequelize 在运行时使用的表映射和部分校验信息。

3.3 MySQL 真实结构

email VARCHAR(254) NULL

这才是数据库真正执行写入、约束和查询时依赖的结构。

修改其中一层不会神奇地自动修改另外两层。后续教程会反复检查这三层是否一致。

4. 当前连接配置

当前 db.ts 的核心结构是:

const db = new Sequelize({
  database: getEnv("DB_NAME"),
  host: getEnv("DB_HOST"),
  username: getEnv("DB_USER"),
  password: getEnv("DB_PASSWORD"),
  dialect: "mysql",
  dialectOptions: {
    supportBigNumbers: true,
    bigNumberStrings: true,
  },
  define: {
    charset: "utf8mb4",
    collate: "utf8mb4_0900_ai_ci",
  },
});

它创建的是一个 Sequelize 实例。这个实例内部管理连接池,不表示在模块加载的一瞬间就为每次请求永久占用一条独立连接。

环境变量至少需要:

DB_NAME=sakila
DB_HOST=127.0.0.1
DB_USER=你的本地练习用户
DB_PASSWORD=你的本地练习密码

不要把包含真实凭据的 .env 提交到 Git。

5. authenticate() 是什么

执行:

await db.authenticate();

Sequelize 会从连接池取得连接,并执行一个很小的测试查询。MySQL 方言常见日志是:

Executing (default): SELECT 1+1 AS result

这只能证明此时此刻:

它不能证明:

6. SQL 日志应该怎样使用

Sequelize 默认可以输出 SQL。学习阶段建议保留日志,因为它能建立 ORM 与 SQL 的对应关系。

可以显式提供日志函数:

const db = new Sequelize({
  // 其他配置
  logging: (sql) => {
    console.log({ event: "sequelize_sql", sql });
  },
});

但生产环境需要谨慎。根据查询方式和库配置,日志可能包含:

不要为了调试改成记录完整 Model、req.body、密码或 JWT。

学习时可采用以下步骤:

  1. 先在纸上写出预期 SQL 的 SELECT、WHERE 和 LIMIT;
  2. 运行 Sequelize 代码;
  3. 比较日志中的真实 SQL;
  4. 把 SQL 复制到本地练习库的 DBeaver 或 MySQL CLI;
  5. 使用 EXPLAIN 检查查询计划;
  6. 解释差异来自 Scope、默认字段、关联还是分页实现。

7. 连接池与 close()

Web 服务会长期运行。正常情况下,同一个进程共享同一个 Sequelize 实例和连接池:

请求 A ─┐
请求 B ─┼→ Sequelize 连接池 → 若干 MySQL 连接
请求 C ─┘

因此不能在每次 HTTP 请求结束时调用:

await db.close();

否则后续请求无法继续使用这个实例建立查询。

一次性 CLI 脚本不同。脚本执行完就应该释放自己的连接池:

void main()
  .catch((error: unknown) => {
    console.error(error);
    process.exitCode = 1;
  })
  .finally(async () => {
    await db.close();
  });

如果 Web 服务和 CLI 是两个独立 Node.js 进程,它们各自加载模块、创建各自的 Sequelize 实例。CLI 关闭自己的连接池,不会隔空关闭另一个进程中的连接池。

8. 建立安全的练习数据库

建议为教程创建单独数据库,而不是直接对重要数据执行实验:

CREATE DATABASE sakila_practice
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

然后使用权限受限的练习用户。不要使用生产管理员账号练习 DROP TABLE、ALTER TABLE 或锁等待。

每次执行危险 SQL 前先确认:

SELECT DATABASE();
SELECT CURRENT_USER();

执行数据修改前先把同一个条件用于查询:

SELECT id, username
FROM admin_user
WHERE id = 1;

UPDATE admin_user
SET display_name = 'Administrator'
WHERE id = 1;

9. 第一个观察实验

新建临时学习脚本时,可以完成下面几步。脚本不是本章要求直接加入当前业务代码的修改。

import "dotenv/config";

import db from "../db";
import AdminUser from "../models/admin_user";

async function main(): Promise<void> {
  await db.authenticate();

  const count = await AdminUser.count();
  console.log({ event: "admin_count", count });

  const admin = await AdminUser.findOne({
    attributes: ["id", "username", "account_status"],
    order: [["id", "ASC"]],
  });

  console.log(admin?.toJSON() ?? null);
}

void main()
  .catch((error: unknown) => {
    console.error({ event: "sequelize_practice_failed", error });
    process.exitCode = 1;
  })
  .finally(async () => {
    await db.close();
  });

运行前先预测:

不要在日志里输出通过 withPassword Scope 查询到的完整管理员对象,否则可能把 password_hash 写入终端记录。

10. 常见误区

误区一:authenticate 成功等于全部功能正常

authenticate() 不检查全部表、字段、索引和权限。真正的业务查询仍可能失败。

误区二:ORM 可以让我不学 SQL

索引、事务、锁、关联和性能问题最终都发生在 MySQL 中。不会 SQL 就无法判断 ORM 查询是否正确、高效。

误区三:TS 没报错,运行时就不会错

TypeScript 不会连接数据库检查真实表结构,也不会验证环境变量和数据库响应。静态类型只是开发期保护。

误区四:每次操作都创建 Sequelize

这会产生多个连接池,浪费连接并增加关闭管理难度。当前项目使用 db.ts 导出的单例,适合单进程服务。

误区五:close 会关闭整个 MySQL

db.close() 只关闭当前 Sequelize 实例管理的连接,不会关闭 MySQL Server,也不会关闭其他进程的连接池。

11. 动手练习

  1. 运行 db.authenticate(),找到对应 SQL。
  2. 故意把本地练习环境的数据库名改错,观察错误类型;恢复配置后不要提交 .env。
  3. 分别执行 AdminUser.count() 和 AdminUser.findAll(),比较 SQL 与返回类型。
  4. 给 findAll() 加上 attributes、order 和 limit,观察 SQL 变化。
  5. 从根目录和 scripts/ 目录分别运行一个 dotenv 脚本,解释 process.cwd() 对 .env 查找的影响。
  6. 开启 Web 服务后,在另一个 CLI 进程中连接再关闭 Sequelize,验证 Web 服务仍可查询。

12. 复盘问题

  1. Sequelize 和 mysql2 分别负责什么?
  2. authenticate() 为什么会执行 SELECT 1+1 AS result?
  3. 为什么 Model 类型正确不能证明数据库表结构正确?
  4. 为什么 Web 请求结束时不应调用 db.close()?
  5. 为什么 CLI 脚本通常需要在 finally 中关闭连接池?
  6. 观察 Sequelize SQL 日志时,需要防止哪些敏感信息泄露?
  7. 一条 Sequelize 查询出现性能问题时,你会按什么顺序排查?