Node.js 数据库访问
后端应用几乎离不开数据库。Node.js 生态提供了从原生驱动到 ORM 的完整方案:需要极致性能与精细控制时用原生驱动,需要开发效率与可维护性时用 ORM。本文以最常见的 MySQL、PostgreSQL、MongoDB 为主线,讲清楚"怎么连、怎么查、怎么写事务、怎么上 ORM"。
一、连接数据库方案总览
| 方案 | 对应数据库 | 特点 | 适用场景 |
|---|---|---|---|
mysql2 | MySQL / MariaDB | 性能高、支持 Promise、占位符防注入 | 直接用 SQL 的团队 |
pg | PostgreSQL | 官方驱动、支持原生查询与流式结果 | 使用 PG 的服务 |
mongodb | MongoDB | 官方驱动、面向文档 | 文档型数据、快速迭代 |
Sequelize | MySQL / PG / SQLite 等 | 老牌 ORM、模型驱动、支持迁移 | 多数据库迁移、成熟团队 |
Prisma | MySQL / PG / MongoDB 等 | 类型安全、schema 优先、自动生成 client | 追求类型安全的新项目 |
Knex | 多数据库 | 查询构建器(介于 SQL 与 ORM 之间) | 需要灵活组装 SQL |
# 安装驱动与 ORM
npm install mysql2
npm install pg
npm install mongoose # MongoDB 的 ODM
npm install sequelize sequelize-cli
npm install prisma @prisma/client二、mysql2 使用
2.1 连接与连接池
const mysql = require("mysql2/promise");
// 方式一:单连接(不推荐用于生产,请求多时会排队)
const connection = await mysql.createConnection({
host: "localhost",
user: "root",
password: "123456",
database: "shop",
});
// 方式二:连接池(推荐,请求并发时复用连接)
const pool = mysql.createPool({
host: "localhost",
user: "root",
password: "123456",
database: "shop",
waitForConnections: true, // 连接耗尽时排队等待而非报错
connectionLimit: 10, // 最大连接数
queueLimit: 0, // 排队上限,0 为不限制
});
const [rows] = await pool.query("SELECT * FROM users");
console.log(rows);2.2 query 与 execute 的区别
| API | 说明 | 特点 |
|---|---|---|
pool.query(sql, params) | 直接执行 | 每次解析 SQL,参数会被拼接转义 |
pool.execute(sql, params) | 预处理语句 | 走 PREPARE 缓存,性能更好、更安全 |
const [rows] = await pool.execute(
"SELECT id, name FROM users WHERE age > ? AND city = ?",
[18, "上海"]
);2.3 参数化防注入
永远不要用字符串拼接 SQL——把用户输入拼进 SQL 就相当于把数据库钥匙交给了攻击者:
// 危险写法:SQL 注入入口
const name = req.query.name;
const [rows] = await pool.query(
`SELECT * FROM users WHERE name = '${name}'` // 输入 "'; DROP TABLE users; --" 即被注入
);
// 安全写法:占位符 ? 绑定参数
const [rows] = await pool.query(
"SELECT * FROM users WHERE name = ? AND age > ?",
[req.query.name, 18]
);占位符 ? 由驱动负责转义,值永远不参与 SQL 语法解析,从根本上杜绝注入。PostgreSQL 的 pg 使用 $1、$2 编号占位符,MongoDB 驱动则天然面向文档,无需拼接。
三、事务处理
事务保证"要么全部成功,要么全部失败"。转账场景最典型:扣款和入账必须原子完成。
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// 扣款
await connection.execute(
"UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",
[100, 1, 100]
);
// 入账
await connection.execute(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
[100, 2]
);
await connection.commit(); // 全部成功才提交
} catch (error) {
await connection.rollback(); // 任一步失败则回滚
throw error;
} finally {
connection.release(); // 归还连接给连接池
}| 概念 | 含义 |
|---|---|
beginTransaction() | 开启事务,之后的语句处于同一事务中 |
commit() | 提交,使修改永久生效 |
rollback() | 回滚,撤销本次事务的所有修改 |
release() | 释放连接回连接池(不是关闭连接) |
事务执行期间会持有数据库连接,务必用 try/finally 保证连接归还,否则连接池会被耗尽。事务内的 UPDATE 会加行锁,尽量保持事务短小。
四、Sequelize ORM
Sequelize 用模型(Model)映射表,先定义模型再操作,SQL 由框架生成。
4.1 模型定义
const { Sequelize, DataTypes } = require("sequelize");
const sequelize = new Sequelize("shop", "root", "123456", {
host: "localhost",
dialect: "mysql",
});
const User = sequelize.define("User", {
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, unique: true },
age: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "users",
timestamps: true, // 自动维护 createdAt / updatedAt
});
// 关联定义:一个用户有多篇文章
const Post = sequelize.define("Post", { title: DataTypes.STRING });
User.hasMany(Post, { foreignKey: "userId" });
Post.belongsTo(User, { foreignKey: "userId" });
// 首次启动时按模型建表(开发用;生产请用迁移)
// await sequelize.sync({ alter: true });4.2 查询 API
// 查询所有
const users = await User.findAll();
// 条件 + 排序 + 分页
const adults = await User.findAll({
where: { age: { [Op.gte]: 18 } },
order: [["createdAt", "DESC"]],
limit: 10,
offset: 20,
});
// 单条
const user = await User.findByPk(1);
// 创建 / 更新 / 删除
await User.create({ name: "张三", email: "zs@example.com" });
await User.update({ age: 20 }, { where: { id: 1 } });
await User.destroy({ where: { id: 1 } });
// 关联查询(预加载)
const userWithPosts = await User.findByPk(1, { include: [{ model: Post }] });| 方法 | 对应 SQL | 说明 |
|---|---|---|
findAll | SELECT | 查询多条,支持 where、order、limit/offset |
findByPk | SELECT ... WHERE pk = ? | 按主键查询 |
create | INSERT | 插入记录 |
update | UPDATE | 批量更新 |
destroy | DELETE | 删除记录 |
Op.gte 等操作符 | >=、LIKE、IN | 条件操作符 |
4.3 迁移(migration)
迁移用文件记录表结构的变更历史,可版本化、可回滚,是生产环境建表的正规方式:
npx sequelize-cli init # 生成 migrations/ 等目录
npx sequelize-cli migration:generate --name create-users
npx sequelize-cli db:migrate # 执行全部未运行的迁移
npx sequelize-cli db:migrate:undo # 回滚最近一次// migrations/xxxx-create-users.js(生成后编辑)
module.exports = {
up: async (queryInterface) => {
await queryInterface.createTable("users", {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, allowNull: false },
createdAt: DataTypes.DATE,
updatedAt: DataTypes.DATE,
});
},
down: async (queryInterface) => queryInterface.dropTable("users"),
};五、Prisma ORM
Prisma 采用 schema 优先:在 schema.prisma 中声明数据模型,然后生成类型安全的 Client。
5.1 schema 定义
// prisma/schema.prisma
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
age Int @default(0)
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
title String
userId Int
user User @relation(fields: [userId], references: [id])
}npx prisma db push # 开发环境:直接同步表结构
npx prisma migrate dev --name create-users # 生成迁移并应用
npx prisma generate # 生成 client(每次改 schema 后都要执行)5.2 client 查询
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// 创建
const user = await prisma.user.create({
data: { name: "李四", email: "ls@example.com" },
});
// 查询
const adults = await prisma.user.findMany({
where: { age: { gte: 18 } },
orderBy: { createdAt: "desc" },
take: 10, skip: 20,
});
// 关联
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: true },
});
// 更新 / 删除
await prisma.user.update({ where: { id: 1 }, data: { age: 21 } });
await prisma.user.delete({ where: { id: 1 } });
process.on("SIGINT", () => prisma.$disconnect());查询 API 与 Sequelize 高度相似,但返回结果是强类型的,字段名写错会在编译期报错,这是 Prisma 的核心优势。
六、ORM 与原生 SQL 对比
| 维度 | 原生 SQL(mysql2/pg) | ORM(Sequelize/Prisma) |
|---|---|---|
| 学习成本 | 需要熟悉 SQL 语法 | 只需学会模型 API |
| 性能 | 可控性最强,SQL 可手写优化 | 复杂查询易生成低效 SQL |
| 类型安全 | 无(结果任意对象) | Prisma 强类型,Sequelize 较弱 |
| 复杂查询 | 自由(联表、窗口函数、子查询) | 需要 raw 或写原生片段 |
| 迁移管理 | 需借助第三方工具(如 flyway) | 内置迁移系统 |
| 可维护性 | 长 SQL 难复用 | 模型与 API 统一,重构友好 |
| 典型场景 | 报表、高并发读写、复杂聚合 | 业务 CRUD、快速交付 |
组合拳:业务 CRUD 用 ORM,复杂聚合查询用原生 SQL(Sequelize 支持 sequelize.query(),Prisma 支持 $queryRaw)。
七、连接池配置与最佳实践
连接池参数直接决定数据库高并发下的表现:
| 参数 | 默认 | 说明 |
|---|---|---|
connectionLimit | 10 | 池中最大连接数,需与数据库 max_connections 匹配 |
waitForConnections | true | 连接耗尽时是否排队等待 |
queueLimit | 0 | 最大排队数,超限直接报错 |
connectTimeout | 10000 | 建立连接超时(ms) |
idleTimeout | 60000 | 空闲连接自动关闭时间(ms) |
const pool = mysql.createPool({
connectionLimit: Number(process.env.DB_POOL_MAX) || 10,
enableKeepAlive: true, // 心跳保活,防止数据库断开空闲连接
keepAliveInitialDelay: 0,
charset: "utf8mb4", // 中文支持
timezone: "+08:00", // 时区一致
});最佳实践:
- 每个进程一个连接池,全局复用,不要每次请求新建。
connectionLimit不要超过数据库max_connections ÷ 应用实例数,否则连接打满。- 长查询、事务占用连接时间长,池要留足余量;必要时用读写分离(主库写、从库读)。
- 连接字符串、账号密码一律走环境变量,禁止硬编码在源码中。
八、常见错误处理
| 错误 | 原因 | 解决 |
|---|---|---|
ER_ACCESS_DENIED_ERROR | 账号密码错误 | 检查凭据与 user@host 授权 |
ER_BAD_DB_ERROR | 数据库不存在 | 先 CREATE DATABASE |
PROTOCOL_CONNECTION_LOST | 连接被数据库断开 | 检查空闲超时,开启 enableKeepAlive |
ETIMEDOUT | 网络不通或防火墙 | 确认端口、安全组、connectTimeout |
ER_DUP_ENTRY | 唯一键冲突 | 捕获后返回 409 或走 INSERT ... ON DUPLICATE KEY |
ER_LOCK_WAIT_TIMEOUT | 事务锁等待超时 | 缩小事务范围,检查慢查询 |
SequelizeConnectionRefusedError | 数据库未启动 | 确认服务与端口 |
async function queryWithRetry(sql, params, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await pool.query(sql, params);
} catch (error) {
if (!isRetryable(error) || i === retries - 1) throw error;
await sleep(100 * 2 ** i); // 指数退避
}
}
}只有网络抖动、连接被断开这类可重试错误才值得重试;ER_DUP_ENTRY 这类业务错误重试没有意义。所有错误都应记录日志并向上抛出,由统一错误处理中间件转为友好响应。