教训:数据库连接池泄漏导致服务频繁重启

记录日期:2026-02-05

1. 问题现象

症状:后端服务 celoria-api 频繁重启(PM2 显示 30+ 次重启),用户访问时看到 "Network Error" 页面。

PM2 状态显示:

restarts: 34
uptime: 55s  (不断重置)
Heap Usage: 92.33%

错误日志中的关键错误:

sorry, too many clients already
severity: 'FATAL'

2. 根本原因分析

2.1 问题:多个独立的连接池

系统中存在 3 个独立的数据库连接池,各自管理自己的连接:

连接池 位置 Max 连接数
Pool 1 server.js:228 直接 new Pool() 20
Pool 2 database/pool.js 模块加载时创建 20
Pool 3 database/sync-pool.js 10
总计 50
server.js database/pool.js │ │ │ new Pool() ──────┐ ┌────── new Pool() │ │ │ ▼ ▼ ▼ initializeDataAccess(pool) 73个API模块直接require │ │ ▼ ▼ tenantDb/platformDb appointments.js, stores.js 等 使用 server.js 的 pool 使用 pool.js 的 pool ↑ ↑ └──── 两个 pool 是独立的! ────┘

2.2 问题:"优雅关闭"根本不优雅

原来的关闭逻辑:

// server.js - 修改前
process.on('SIGTERM', () => {
  console.log('🛑 收到SIGTERM信号,正在关闭服务器...');
  process.exit(0);  // 直接退出!没有关闭数据库连接!
});
问题:直接 process.exit(0) 退出,完全没有关闭任何数据库连接池

2.3 连接泄漏的恶性循环

服务启动 → 创建 50 个潜在连接 ↓ 某个原因崩溃(查询超时、未捕获异常等) ↓ PM2 立即重启(2 秒延迟) ↓ 旧连接还没释放(idle timeout 30 秒) ↓ 新服务启动,又创建 50 个连接 ↓ PostgreSQL: "sorry, too many clients already" (默认上限约 100) ↓ 服务再次崩溃 → 循环

3. 解决方案

3.1 统一连接池管理

删除 server.js 中直接创建 pool 的代码,改用 database/pool.js 的统一管理:

// server.js - 修改后
const {
  getPool,
  gracefulShutdown: gracefulShutdownPool,
  reconnectPool,
} = require('./database/pool');

// 使用统一的连接池
let pool = getPool();

// 初始化数据访问层,使用同一个 pool
initializeDataAccess(pool);

3.2 实现真正的优雅关闭

// server.js - 修改后
const gracefulShutdown = async (signal) => {
  console.log(`🛑 收到${signal}信号,正在优雅关闭服务器...`);

  try {
    // 1. 停止接受新请求
    server.close(() => {
      console.log('✅ HTTP 服务器已停止接受新连接');
    });

    // 2. 关闭主数据库连接池 (database/pool.js)
    console.log('🔄 正在关闭主数据库连接池...');
    const poolShutdownResult = await gracefulShutdownPool();
    console.log(`✅ 主数据库连接池已关闭: ${poolShutdownResult.message}`);

    // 3. 关闭同步数据库连接池 (sync-pool.js)
    try {
      const syncPool = require('./database/sync-pool');
      if (syncPool && syncPool.close) {
        console.log('🔄 正在关闭同步数据库连接池...');
        await syncPool.close();
        console.log('✅ 同步数据库连接池已关闭');
      }
    } catch (syncError) {
      console.log('ℹ️ 同步数据库连接池未初始化或已关闭');
    }

    console.log('✅ 所有资源已释放,服务器正在退出...');
    process.exit(0);
  } catch (error) {
    console.error('❌ 优雅关闭过程中发生错误:', error.message);
    process.exit(1);
  }
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

3.3 修改数据库切换功能

使用 reconnectPool() 替代直接创建新 Pool:

// 修改前
await pool.end();
pool = new Pool(newPoolConfig);

// 修改后
const reconnectResult = await reconnectPool(newPoolConfig);
pool = getPool();
initializeDataAccess(pool);

4. 结果验证

优雅关闭日志:
🛑 收到SIGINT信号,正在优雅关闭服务器...
🔄 正在关闭主数据库连接池...
✅ 主数据库连接池已关闭: Pool closed successfully
🔄 正在关闭同步数据库连接池...
✅ 同步数据库连接池已关闭
✅ 所有资源已释放,服务器正在退出...

5. 关键教训

教训 1:连接池必须统一管理

不要在多个地方创建 new Pool()。应该有一个统一的连接池管理模块,所有地方都从这个模块获取连接池实例。

教训 2:优雅关闭必须释放所有资源

process.exit() 之前必须:

教训 3:错误日志是最重要的线索

sorry, too many clients already 直接指向了问题:数据库连接数超限。结合服务频繁重启的现象,可以推断出连接泄漏的问题。

6. 相关文件

文件 修改内容
backend/server.js 删除直接创建 Pool,使用 getPool(),实现优雅关闭
backend/database/pool.js 提供 getPool(), gracefulShutdown(), reconnectPool()
backend/database/sync-pool.js 提供 close() 方法用于优雅关闭

7. 预防措施

  1. 代码审查:检查是否有 new Pool() 出现在非 database/pool.js 的地方
  2. 监控:添加数据库连接数监控,设置告警阈值
  3. 测试:在 CI 中测试优雅关闭是否正确释放连接
  4. 文档:在 CLAUDE.md 中明确要求使用统一的连接池管理