在大多数 Web 应用中,持久化数据的存储与查询是核心环节之一。Node.js 与关系型数据库交互的方式从最底层的原生驱动到高度抽象的 ORM 框架,开发者可以根据项目的规模和团队的偏好灵活选择。这一节我们将以 MySQL 为主线,梳理原生驱动、连接池、事务、ORM 选型以及性能优化等关键实践。
13.1.1 原生驱动:mysql2 的使用
虽然 MySQL 官方提供了 mysql 包,但目前更推荐使用 mysql2,它在性能、Promise 支持和错误处理方面都做了显著改进,并且与 mysql 的 API 保持高度兼容。
安装 mysql2:
npm install mysql2
基础查询
mysql2 同时支持回调方式和 Promise 方式。回调方式的典型用法如下:
const mysql = require('mysql2');
const connection = mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'password',
database: 'test'
});
connection.query('SELECT * FROM users WHERE id = ?', [1], (err, results) => {
if (err) throw err;
console.log(results);
});
更推荐使用 promise() 方法获得支持 async/await 的连接对象:
const mysql = require('mysql2/promise');
async function main() {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'password',
database: 'test'
});
const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [1]);
console.log(rows);
await connection.end();
}
main();
execute 方法会自动对传入的参数进行转义,防止 SQL 注入。与 query 相比,execute 性能更好,因为它使用了 MySQL 的预处理语句(prepared statement)。如果一条 SQL 需要反复执行(例如在循环中),用 execute 能减少解析开销。
连接池
生产环境中极少使用单连接,因为每一个连接都会占用 MySQL 服务器的资源,频繁创建和销毁连接也会带来不必要的消耗。正确的方式是使用连接池,它维护一定数量的连接,复用这些连接处理请求。
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'password',
database: 'test',
waitForConnections: true,
connectionLimit: 10, // 最大连接数
maxIdle: 10, // 最大空闲连接数
idleTimeout: 60000, // 空闲连接超时
queueLimit: 0 // 排队等待数量,0 为不限制
});
async function getUsers() {
const [rows] = await pool.execute('SELECT * FROM users LIMIT 10');
return rows;
}
连接池的配置需要根据 MySQL 服务器的最大连接数(max_connections 变量)和业务的实际并发量来调整。connectionLimit 设置过高可能会撑爆 MySQL 服务器,过低则会导致请求排队等待甚至超时。监控连接的利用率和等待队列长度是运维阶段的重要工作。
13.1.2 事务处理
关系型数据库的核心优势之一就是事务的 ACID 特性。在 Node.js 中,事务通常需要手动控制连接的获取和提交/回滚。
使用原生驱动处理事务
要执行事务,必须从连接池中获取一个专用连接,因为事务要求所有操作都在同一个连接上完成。
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.execute(
'UPDATE accounts SET balance = balance - ? WHERE id = ?',
[100, 1]
);
await connection.execute(
'UPDATE accounts SET balance = balance + ? WHERE id = ?',
[100, 2]
);
await connection.commit();
console.log('转账成功');
} catch (err) {
await connection.rollback();
console.error('转账失败,已回滚', err);
} finally {
connection.release(); // 释放回连接池
}
在所有 ORM 中,事务的实现最终都依赖于底层驱动的这种模式,只是封装了 API 使其更易用。
13.1.3 ORM 框架:选型与对比
对于业务逻辑较为复杂、数据模型经常变化的项目,直接写 SQL 不仅繁琐,而且难以维护。ORM(对象关系映射)将数据库表映射为编程语言中的对象,提供面向对象的数据操作方式,同时也保留了执行原生 SQL 的能力。目前 Node.js 生态中主流的 ORM 有三个:Sequelize、TypeORM 和 Prisma。
Sequelize:成熟稳重
Sequelize 是 Node.js 历史上最久远的 ORM 之一,支持 MySQL、PostgreSQL、SQLite 等,文档齐全,社区庞大。它采用 Active Record 模式,模型本身集成了查询方法。
定义模型:
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize('test', 'root', 'password', {
host: 'localhost',
dialect: 'mysql'
});
const User = sequelize.define('User', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, unique: true }
});
await sequelize.sync(); // 自动创建表(生产环境慎用,应使用迁移)
增删改查:
const user = await User.create({ name: '张三', email: 'zhangsan@example.com' });
const users = await User.findAll({ where: { name: '张三' } });
await User.update({ email: 'new@example.com' }, { where: { id: 1 } });
await User.destroy({ where: { id: 1 } });
优点:
- 迁移(Migration)和种子数据(Seeder)工具成熟。
- 支持关联关系(一对一、一对多、多对多)声明式定义。
- 庞大的社区和第三方插件。
缺点:
- API 略显老旧,Promise 时代的设计导致某些用法不够优雅。
- TypeScript 类型推导较弱,需要手动声明接口。
- 性能不如更轻量的查询构建器(如 Knex),在复杂查询下生成的 SQL 可能不理想。
TypeORM:TypeScript 优先
TypeORM 受到 Hibernate、Doctrine 等 Java/PHP ORM 的启发,对 TypeScript 提供了一等支持,采用装饰器或声明式配置定义实体,并支持 ActiveRecord 和 DataMapper 两种模式。
定义实体:
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ unique: true })
email: string;
}
使用:
const userRepo = dataSource.getRepository(User);
const user = new User();
user.name = '张三';
user.email = 'zhangsan@example.com';
await userRepo.save(user);
const users = await userRepo.find({ where: { name: '张三' } });
优点:
- 对 TypeScript 类型推导友好,实体即类型。
- 支持多种数据库,拥有丰富的装饰器和关系配置。
- 迁移工具和 CLI 功能完善。
缺点:
- 学习曲线较陡,文档虽全但组织略显松散。
- 在某些边缘场景下可能生成意料之外的 SQL,需要仔细检查。
- 社区活跃度近两年有下降趋势,更新节奏放缓。
Prisma:新一代 ORM
Prisma 并非传统的面向对象 ORM,它首先是一种 声明式数据建模工具,通过 Prisma Schema 定义数据模型,然后自动生成类型安全的客户端。这一模式近年来受到广泛欢迎。
定义 Schema (prisma/schema.prisma):
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
authorId Int
author User @relation(fields: [authorId], references: [id])
}
生成客户端并使用:
npx prisma generate
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const user = await prisma.user.create({
data: { name: '张三', email: 'zhangsan@example.com' }
});
const users = await prisma.user.findMany({
where: { name: '张三' },
include: { posts: true }
});
优点:
- 类型安全贯穿整个开发流程,自动生成的类型让开发体验极佳。
- 数据模型即文档,直观可读。
- 迁移工具
prisma migrate简洁易用。 - 查询 API 清晰,不容易写出低效查询。
- 内置连接池和查询日志。
缺点:
- 生成的客户端体积较大(Node.js 端),需要分发到多个服务时需要注意。
- 对自定义 SQL 的支持偏弱,虽然可以通过
$queryRaw执行原生 SQL,但类型安全会丢失。 - 事务 API 较新,虽然已支持交互式事务,但与传统 ORM 的事务 API 仍有差异。
选型建议
- 中小型项目或快速原型:Prisma 的开发效率最高,类型安全减少低级错误,尤其适合 TypeScript 全栈项目。
- 已有大量 Sequelize 代码积累的老项目:不必贸然迁移,Sequelize 足够可靠。
- 对关系数据库高级特性(复杂关联、多态关联、查询器)要求极高:TypeORM 或 Knex(SQL 构建器)+ 简单封装的组合可能更灵活。
- 团队核心是 SQL 专家,讨厌 ORM:直接使用 mysql2 + SQL 模板或选用 Knex 作为 SQL 构建器。
无论选择哪种 ORM,都应当保留执行原生 SQL 的能力。在遇到 ORM 生成的 SQL 效率低下时,可以优化为手写 SQL 并通过 ORM 提供的原生查询方法执行,而不必完全绕过 ORM。
13.1.4 索引优化与慢查询排查
数据库性能的大多数问题都可以归结为两类:没建索引 或 索引设计不佳。
索引设计基本原则
- WHERE、JOIN、ORDER BY 中出现频繁的字段应建立索引。
- 区分度高的字段优先:例如
email优于gender。 - 复合索引遵循最左前缀:如果建立了
(A, B, C)索引,查询条件只包含A或A+B可以走索引,但单独查B或B+C则不行。 - 避免在索引列上使用函数或运算:
WHERE LEFT(name, 3) = 'abc'会导致索引失效。 - 定期审视冗余索引:联合索引
(A, B)已经包含了A索引的功能,单独建A索引可能不再需要。
在生产环境上线前,可以使用 MySQL 的 EXPLAIN 命令查看执行计划。在 Node.js 中也可以直接在 mysql2 中执行:
const [rows] = await pool.execute('EXPLAIN SELECT * FROM users WHERE email = ?', ['test@example.com']);
console.log(rows);
关键字段 type 应为 ref、eq_ref 或 const,rows 值应尽可能小,Extra 中不应出现 Using filesort(除非确实需要排序)或 Using temporary。
慢查询日志与排查
开启 MySQL 的慢查询日志是定位性能瓶颈的起点。在 MySQL 配置中设置:
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1 # 超过1秒的查询即记录
之后可以使用 mysqldumpslow 工具分析慢查询日志,找出执行频率高且耗时长的语句,然后对其进行优化。
应用层也可以借助 ORM 的日志功能记录 SQL 执行时间,例如 Prisma 的 log 配置:
const prisma = new PrismaClient({
log: [{ emit: 'stdout', level: 'query' }],
});
结合 APM 工具(如 Datadog、Elastic APM)可以更好地追踪慢查询的上下文和调用来源。
13.1.5 分库分表基础
单表数据量达到数百万乃至数千万行时,即使索引优化到位,写入和查询都可能因 B+ 树层级加深、锁竞争激增而变慢。此时需要实施水平拆分,即将一个大表的数据分布到多个结构相同但范围不重叠的小表中。
分表策略
常见的分表策略有两种:
- 按范围分表(Range):例如按时间拆分
orders_2024_01、orders_2024_02。实现简单,但可能造成热点表。 - 按哈希或取模分表(Hash/Mod):例如
orders_0、orders_1、orders_2,通过user_id % 3决定路由到哪张表。数据分布均匀,但扩容时需要重新哈希。
在应用层,可以通过封装一个路由中间件来隐藏分表逻辑:
function getTableName(base, shardKey, totalShards) {
const index = hash(shardKey) % totalShards;
return `${base}_${index}`;
}
ORM 本身一般不直接支持分表,需要开发者手动管理。Prisma 的优势在于可以使用 $queryRaw 执行动态表名的 SQL,Sequelize 和 TypeORM 也提供原生查询方式。分表会大幅增加查询复杂性,只有确认单表瓶颈确实无法通过优化解决时才应考虑。
分库
当数据库的整体写入压力或存储容量超出单机上限时,需要将不同模块的数据划分到不同数据库实例。在微服务架构中,每个服务拥有独立数据库是最常见的分库形态。如果单体应用需要分库,则需要引入类似 Apache ShardingSphere 等中间件,或者自己实现数据源路由。
读写分离
更轻量级的缓解方案是读写分离:主库负责写,从库负责读。Node.js 中可以配置两个连接池,然后根据操作类型选择使用哪个池。
const masterPool = mysql.createPool({ /* 主库配置 */ });
const slavePool = mysql.createPool({ /* 从库配置 */ });
async function executeQuery(sql, params, isWrite = false) {
const pool = isWrite ? masterPool : slavePool;
return pool.execute(sql, params);
}
许多 ORM 也内置了读写分离支持,例如 Sequelize 的 replication 配置:
const sequelize = new Sequelize({
replication: {
read: [{ host: 'slave1' }, { host: 'slave2' }],
write: { host: 'master' }
},
dialect: 'mysql'
});
以上便是 Node.js 与关系型数据库交互的核心实践。从最底层的 mysql2 到高度抽象的 ORM,选择的关键在于项目复杂度、团队习惯以及对 SQL 的控制度需求。无论采用何种工具,深入理解数据库索引、事务隔离和分布式基础都是后端开发者必备的素养。下一节我们将转向 NoSQL 数据库,探讨 MongoDB 与 Mongoose 在 Node.js 生态中的运用。