前面几节介绍了 MySQL、MongoDB、Redis 的接入方式和 ORM 选择,但在实际项目中,能否用好数据库往往取决于三个关键机制的落地质量:连接池、事务和读写分离。连接池决定了应用能否稳定支撑并发流量;事务保证了业务数据的最终一致性;读写分离则是在数据量增长后必备的扩展手段。本节我们从真实工程场景出发,梳理这三个方面的最佳实践与常见陷阱。
13.4.1 连接池:别让数据库成为瓶颈
任何一个数据库的连接建立都是一次昂贵的操作:TCP 三次握手、TLS 协商(若开启)、数据库认证、初始化会话状态……如果每次请求都临时创建连接并在结束后销毁,不仅延迟极高,数据库端也会因为频繁创建和销毁连接而耗尽资源。连接池(Connection Pool)通过预先创建一批长连接并重复使用,从根本上解决了这个问题。
连接池的核心参数
以 mysql2 的连接池为例:
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
database: 'app_db',
password: 'secret',
waitForConnections: true,
connectionLimit: 10, // 最大连接数
queueLimit: 0, // 排队上限,0 为无限制
enableKeepAlive: true,
keepAliveInitialDelay: 10000, // 心跳间隔
});
- connectionLimit:池中最大连接数。它并不是越大越好,MySQL 默认最大连接数通常为 151,如果你的应用实例连接池总和超过这个值,数据库会拒绝新连接。通常一个 Node.js 进程设置 10~20 个连接即可,多进程需要累加评估。
- queueLimit:当连接池满时,新请求是排队等待还是直接报错。Web 服务一般希望等待而非报错,可设为 0(无限排队)或适当数值,但需注意排队过多可能导致请求堆积。
- 空闲回收:许多连接池实现会提供
idleTimeout参数,将长时间未使用的连接回收,避免占用数据库资源。但该值不宜过短,否则频繁重建连接反而失去连接池意义。
使用连接池的黄金法则
1. 永远不要从池中“拿走”连接后忘记释放
使用 pool.getConnection() 模式时,必须手动释放:
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute('SELECT * FROM users');
} finally {
connection.release(); // 必须释放
}
更推荐直接用 pool.execute() 或 pool.query(),它们内部自动获取和释放连接:
const [rows] = await pool.execute('SELECT * FROM users');
2. 一个请求只用一个连接
初学者容易犯的错误是,在一次请求处理中超时地从池中多次获取连接,又不及时释放,导致池中连接耗尽。应该在请求处理的最外层获取一个连接(或让 ORM 管理),然后通过参数或上下文传递给内部方法使用。这也是为什么很多 ORM 提供 transaction 方法管理连接上下文。
3. 设置连接超时与心跳
数据库中间可能有负载均衡器、防火墙等会主动关闭空闲连接。务必开启 TCP keepalive 或连接池的心跳机制(enableKeepAlive),防止应用拿到一个已经被服务端关闭的“死连接”而报错。
4. 监控连接池状态
生产中应暴露连接池指标:活跃连接数、空闲连接数、等待请求数等。这些信号直接反映数据库是否成为瓶颈。
ORM 层中的连接池
Sequelize、TypeORM、Prisma 内部都封装了连接池:
- Sequelize 通过
pool配置项控制:
const sequelize = new Sequelize('database', 'user', 'password', {
host: 'localhost',
dialect: 'mysql',
pool: {
max: 10,
min: 2,
acquire: 30000, // 获取连接超时
idle: 10000, // 空闲回收时间
},
});
- Prisma 默认使用连接池,可通过
connection_limit配置:
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
connectionLimit = 20
}
ORM 的连接池参数同样是性能调优的关键,尤其注意 acquire 超时不能过短,否则在流量峰值时会误报获取连接超时错误。
13.4.2 事务:保障数据一致性的底线
转账、下单、库存扣减等涉及多个写操作的业务,必须通过事务保证要么全部成功,要么全部回滚。Node.js 生态中,事务的实现从原生的 BEGIN/COMMIT/ROLLBACK 到 ORM 封装,各有适用场景。
原生驱动的事务控制
使用 mysql2/promise 直接管理事务:
const connection = await pool.getConnection();
await connection.beginTransaction();
try {
await connection.execute('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
await connection.execute('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
await connection.commit();
} catch (err) {
await connection.rollback();
throw err; // 上层处理或记录
} finally {
connection.release();
}
注意:事务必须绑定在同一个连接上。如果事务中调用了一个隐式使用连接池的方法,可能拿到另一个连接,从而导致事务失效。因此事务期间务必显式传递连接,或使用 ORM 的事务管理机制。
ORM 中的事务管理
Sequelize 提供两种方式:
- 托管事务(自动提交/回滚):
const result = await sequelize.transaction(async (t) => {
const user = await User.create({ name: 'Alice' }, { transaction: t });
await Profile.create({ userId: user.id, bio: '...' }, { transaction: t });
return user;
});
- 非托管事务(手动控制):
const t = await sequelize.transaction();
try {
// ... 操作,注意传入 transaction: t
await t.commit();
} catch (error) {
await t.rollback();
}
TypeORM 使用 dataSource.transaction 或 @Transaction() 装饰器(已废弃),推荐使用 QueryRunner 进行精细控制,或者使用事务实体管理器:
await dataSource.transaction(async (transactionalEntityManager) => {
await transactionalEntityManager.save(user);
await transactionalEntityManager.save(profile);
});
Prisma 支持交互式事务和批量事务:
await prisma.$transaction([
prisma.user.create({ data: { name: 'Alice' } }),
prisma.profile.create({ data: { ... } }),
]);
对于需要多次查询再写入的场景,使用交互式事务:
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: 1 } });
if (user.balance < 100) throw new Error('余额不足');
await tx.user.update({ where: { id: 1 }, data: { balance: { decrement: 100 } } });
});
事务最佳实践
- 事务要尽可能短:长时间持有事务会锁住行或表,阻塞其他连接。不要将无关的 I/O、HTTP 请求、日志写入放在事务内。
- 正确处理死锁:并发事务可能导致死锁,数据库会回滚其中一个。应用层必须捕获死锁错误并重试。MySQL 错误码 1213,PostgreSQL 错误码 40P01。
- 设置事务超时:避免由于代码逻辑问题导致无限期挂起事务。可以通过连接配置
statement_timeout或应用层定时器主动回滚。 - 避免嵌套事务:Node.js 下多数 ORM 不支持真正的嵌套事务(保存点除外),不要手动在事务内再开一个事务。用函数封装可复用的操作,通过传入事务参数来复用。
- 事务与连接池的关系:事务会独占一个连接直至结束。如果事务过多且时间长,可能耗尽连接池,导致死锁或超时。事务时长应与连接池大小配合评估。
13.4.3 读写分离:分担主库压力
当单个数据库实例无法承受读负载时,常见的方案是搭建一主多从的复制拓扑:所有写操作(INSERT/UPDATE/DELETE)发往主库,读操作(SELECT)分发到从库。Node.js 应用中实现读写分离主要有三种方式。
方案一:手动路由数据源
创建两个连接池,业务代码中根据操作类型显式选择:
const masterPool = mysql.createPool({...}); // 主库
const slavePool = mysql.createPool({...}); // 从库
async function getUser(id) {
const [rows] = await slavePool.execute('SELECT * FROM users WHERE id = ?', [id]);
return rows[0];
}
async function createUser(data) {
const [result] = await masterPool.execute('INSERT INTO users SET ?', [data]);
return result.insertId;
}
这种方法在简单项目中直接有效,但一旦业务逻辑复杂,容易遗漏判断,且需要在每次调用时区分读写,不够优雅。
方案二:ORM 自带读写分离支持
Sequelize 支持在定义模型时配置读写分离:
const sequelize = new Sequelize('database', 'user', 'password', {
replication: {
read: [
{ host: 'slave1', username: 'user', password: 'pass' },
{ host: 'slave2', username: 'user', password: 'pass' },
],
write: { host: 'master', username: 'user', password: 'pass' },
},
pool: {
max: 20,
idle: 10000,
},
});
Sequelize 会自动将 SELECT 查询路由到 read 配置的任意一个从库(默认轮询),写操作路由到 write 主库。需要注意:事务中的查询仍然全部发往主库,因为从库可能存在复制延迟,事务需要强一致性。
Prisma 目前不支持内置读写分离,可以使用 @prisma/client 配合多个数据源,自己封装路由逻辑。
方案三:中间件/代理层处理
在应用与数据库之间增加 ProxySQL、MaxScale 等数据库代理,它们可以解析 SQL 语句,自动将读请求分流到从库。应用层完全透明,无需任何代码修改。这种方案运维成本稍高,但解耦了业务和数据架构,适合较大规模的服务。
读写一致的常见问题与应对
- 复制延迟(Replication Lag):主库写入后,从库可能还没同步完成,此时立即读取会拿到旧数据。解决方案:
- 关键业务(如用户注册后自动登录)的读取强制走主库。
- 在写入后短时间内(如 500ms)的读操作仍走主库。
- 使用半同步复制或 Group Replication 降低延迟。
- 事务中的读仍需强一致性:ORM 通常会在事务内将所有查询定向到主库,这是正确的行为。
- 从库故障转移:配置多个读源时,应用需要能够自动摘除故障从库。连接池参数如
read数组顺序、重试逻辑需要在中间件或 ORM 配置中明确。
13.4.4 综合实践:一个稳健的数据库访问层
结合以上三点,一个典型的 Node.js 数据库访问层应该具备以下特征:
- 全局统一的连接池配置,根据实际压测调整参数。
- 所有数据操作通过仓库模式(Repository)封装,上层业务不直接接触连接池和事务。
- 事务通过装饰器或工厂方法统一管理,确保每个事务都能正确提交或回滚。
- 读写分离在 Repository 层透明处理,上层只调用
userRepo.findById()。
示例骨架:
class UserRepository {
constructor({ masterPool, slavePool }) {
this.master = masterPool;
this.slave = slavePool;
}
async findById(id) {
const [rows] = await this.slave.execute('SELECT * FROM users WHERE id = ?', [id]);
return rows[0];
}
async create(data) {
const conn = await this.master.getConnection();
await conn.beginTransaction();
try {
const [res] = await conn.execute('INSERT INTO users SET ?', [data]);
await conn.commit();
return res.insertId;
} finally {
conn.release();
}
}
// 事务组合多个操作
async transfer(fromId, toId, amount) {
const conn = await this.master.getConnection();
await conn.beginTransaction();
try {
// 扣款、入账均在同一个连接事务内
await conn.execute('UPDATE users SET balance = balance - ? WHERE id = ?', [amount, fromId]);
await conn.execute('UPDATE users SET balance = balance + ? WHERE id = ?', [amount, toId]);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
}
在真实生产环境中,这些逻辑还会被进一步抽象,但核心原则始终不变:连接池保证吞吐,事务保证准确,读写分离保证扩展。将这三个机制吃透并将其落实到代码规范中,是 Node.js 后端工程师数据库功底的重要体现。