人人都会AI编程

13.4 数据库连接池、事务、读写分离最佳实践

更新时间:2026-07-10

前面几节介绍了 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 } } });
});

事务最佳实践

  1. 事务要尽可能短:长时间持有事务会锁住行或表,阻塞其他连接。不要将无关的 I/O、HTTP 请求、日志写入放在事务内。
  2. 正确处理死锁:并发事务可能导致死锁,数据库会回滚其中一个。应用层必须捕获死锁错误并重试。MySQL 错误码 1213,PostgreSQL 错误码 40P01。
  3. 设置事务超时:避免由于代码逻辑问题导致无限期挂起事务。可以通过连接配置 statement_timeout 或应用层定时器主动回滚。
  4. 避免嵌套事务:Node.js 下多数 ORM 不支持真正的嵌套事务(保存点除外),不要手动在事务内再开一个事务。用函数封装可复用的操作,通过传入事务参数来复用。
  5. 事务与连接池的关系:事务会独占一个连接直至结束。如果事务过多且时间长,可能耗尽连接池,导致死锁或超时。事务时长应与连接池大小配合评估。

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 语句,自动将读请求分流到从库。应用层完全透明,无需任何代码修改。这种方案运维成本稍高,但解耦了业务和数据架构,适合较大规模的服务。

读写一致的常见问题与应对

  1. 复制延迟(Replication Lag):主库写入后,从库可能还没同步完成,此时立即读取会拿到旧数据。解决方案:
  • 关键业务(如用户注册后自动登录)的读取强制走主库。
  • 在写入后短时间内(如 500ms)的读操作仍走主库。
  • 使用半同步复制或 Group Replication 降低延迟。
  1. 事务中的读仍需强一致性:ORM 通常会在事务内将所有查询定向到主库,这是正确的行为。
  1. 从库故障转移:配置多个读源时,应用需要能够自动摘除故障从库。连接池参数如 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 后端工程师数据库功底的重要体现。