NestJS 多租户数据库连接池动态管理:Promise 缓存与懒加载实战

2 阅读10分钟

前言

在 SaaS 系统开发中,多租户数据隔离是核心需求之一。相较于共享数据库、共享 Schema 的方案,独立数据库模式能提供最强的数据隔离性、安全性与定制化能力,成为金融、医疗、政企类 SaaS 的首选架构。

但独立数据库模式会带来一个棘手问题:如何高效、稳定地管理海量租户的数据库连接池?如果处理不当,很容易出现连接爆炸、内存泄漏、服务雪崩等生产故障。

本文将基于真实生产踩坑经验,详细讲解 NestJS 环境下,如何通过 Map 缓存、Promise 缓存、懒加载 三大核心技术,实现多租户数据库连接池的动态管理,解决连接爆炸、重复初始化、故障传播等问题,最终打造出高可用、易扩展、零重启新增租户的生产级方案。

一、背景:从 “能用” 到 “崩了” 的惨痛教训

我们的 SaaS 服务面向医疗机构,每个客户医院都拥有独立的数据库,确保数据完全隔离。初期为了快速上线,采用了最简单粗暴的实现方式:每次请求都创建新的 DataSource

核心伪代码如下:

typescript

运行

// 错误示范:每次请求都新建 DataSource
async getRepository(tenantCode: string, entity: EntityTarget<T>) {
  // 加载租户数据库配置
  const config = await this.loadConfig(tenantCode);
  // 新建数据源
  const dataSource = new DataSource(config);
  // 初始化数据源
  await dataSource.initialize();
  // 返回对应实体仓库
  return dataSource.getRepository(entity);
}

这种写法在租户数量≤3 时毫无问题,但随着租户突破 10 家,生产环境彻底崩盘,暴露出四大致命问题:

  1. 连接爆炸每次请求都创建新 DataSource,每个 DataSource 都会初始化独立连接池,数据库连接数呈指数级增长,直接打满数据库服务器的最大连接数限制,导致新请求无法连接数据库。
  2. 重复初始化高并发场景下,同一租户的多个请求同时到达,会触发多次 DataSource 初始化,造成资源浪费、接口延迟飙升。
  3. 内存泄漏使用完成后的 DataSource 没有主动销毁,大量闲置数据源驻留在内存中,内存占用持续上涨,最终引发 OOM(内存溢出)。
  4. 故障传播租户之间没有隔离,单个租户数据库响应缓慢、宕机,会导致对应请求阻塞,耗尽服务线程池,最终拖垮整个系统。

从 CTO 视角复盘,这是典型的 “先跑起来再优化” 的反面案例:小规模场景下隐藏的问题,在规模化后瞬间引爆,成为生产环境的 “定时炸弹”。

二、方案设计:三大核心策略选型

针对上述问题,我们确定了三大核心解决策略,并通过多方案对比,最终选定最优组合。

核心策略

  1. Map 缓存:用 Map 存储租户对应的 DataSource,实现数据源复用,避免重复创建。
  2. Promise 缓存:缓存初始化中的 Promise,解决并发请求下的重复初始化问题。
  3. 懒加载 + 预加载混合:服务启动时预加载存量租户,新增租户运行时动态创建,兼顾启动速度与扩展性。

方案对比选型

表格

方案问题采纳结果
基于 Schema 的多租户无法满足客户独立数据库的强隔离需求❌ 拒绝
共享连接池租户数据库地址、账号完全不同,无法共享❌ 拒绝
纯懒加载首次请求延迟高,影响用户体验⚠️ 部分采纳
纯预加载新增租户需重启服务,无法动态扩展⚠️ 部分采纳
预加载 + 懒加载混合存量租户快速响应,新增租户零重启添加✅ 采纳

设计核心目标:无需重启服务、无需重新部署,仅在配置库新增记录,即可完成新租户接入。

三、核心实现:从零到一的代码落地

1. 核心结构定义

首先定义多租户数据库服务,实现 NestJS 的 OnModuleInit(模块初始化)和 OnModuleDestroy(模块销毁)生命周期钩子,统一管理数据源的创建、销毁与状态。

typescript

运行

import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { DataSource, EntityTarget } from 'typeorm';

@Injectable()
export class MultiTenantDatabaseService implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(MultiTenantDatabaseService.name);

  // 租户数据源缓存池:key=租户编码,value=初始化完成的数据源
  private datasourcesMap = new Map<string, DataSource>();

  // 配置库数据源:用于查询所有租户的数据库配置
  private configDataSource: DataSource | null = null;

  // 服务初始化状态
  private initialized = false;

  // 初始化Promise缓存:解决并发初始化问题
  private initializationPromise: Promise<void> | null = null;

  // 共享实体:所有租户数据库共用的实体集合
  private readonly SHARED_ENTITIES = [];
}

四大核心变量各司其职:

  • datasourcesMap:租户数据源的核心缓存容器;
  • configDataSource:配置库数据源,负责读取租户配置;
  • initialized:标记服务是否完成初始化,避免重复执行;
  • initializationPromise:并发控制核心,缓存初始化 Promise。

2. Promise 缓存:解决并发重复初始化

NestJS 的 onModuleInit 只会执行一次,但初始化未完成时涌入的请求,会触发多次初始化逻辑。通过 Promise 缓存,能让所有并发请求等待同一个初始化 Promise 完成,从根本上杜绝重复初始化。

typescript

运行

async onModuleInit(): Promise<void> {
  // 若已存在初始化Promise,直接返回,避免重复执行
  if (this.initializationPromise) {
    return this.initializationPromise;
  }

  // 创建新的初始化Promise并缓存
  this.initializationPromise = this.initialize();
  return this.initializationPromise;
}

// 全局初始化逻辑
private async initialize(): Promise<void> {
  try {
    // 1. 初始化配置库数据源
    this.configDataSource = new DataSource({
      // 配置库连接信息
      type: 'postgres',
      host: process.env.CONFIG_DB_HOST,
      port: +process.env.CONFIG_DB_PORT,
      username: process.env.CONFIG_DB_USER,
      password: process.env.CONFIG_DB_PWD,
      database: process.env.CONFIG_DB_NAME,
      entities: [ConfigEntity],
      synchronize: false,
    });
    await this.configDataSource.initialize();

    // 2. 加载所有存量租户配置
    const configRepo = this.configDataSource.getRepository(ConfigEntity);
    const tenantConfigs = await configRepo.find();

    // 3. 批量初始化租户数据源(允许部分失败)
    await this.initializeAllDataSources(tenantConfigs);

    this.initialized = true;
    this.logger.log('多租户数据库服务初始化完成');
  } catch (error) {
    this.logger.error('多租户服务初始化失败', error);
    throw error;
  }
}

技术亮点:无需引入 Mutex、Semaphore 等复杂并发锁,利用 Node.js 单线程特性,仅通过 Promise 缓存就实现了高效的并发控制,减少第三方依赖,降低维护成本。

3. 懒加载:运行时动态创建新租户

服务启动时仅预加载存量租户,后续新增的租户,在首次请求时动态创建数据源,实现零重启接入。

typescript

运行

/**
 * 获取租户数据源(核心方法)
 * @param tenantCode 租户编码
 */
async getDataSource(tenantCode: string): Promise<DataSource> {
  // 确保服务已完成初始化
  if (!this.initialized) {
    await this.ensureInitialized();
  }

  // 从缓存中获取数据源
  let dataSource = this.datasourcesMap.get(tenantCode);

  // 缓存不存在:从配置库读取配置,动态创建数据源
  if (!dataSource) {
    this.logger.log(`懒加载租户数据源:${tenantCode}`);
    const configRepo = this.configDataSource.getRepository(ConfigEntity);
    const config = await configRepo.findOne({ where: { tenantCode } });

    if (!config) {
      throw new Error(`租户${tenantCode}配置不存在`);
    }

    // 创建并初始化租户数据源
    await this.createTenantDataSource(config);
    dataSource = this.datasourcesMap.get(tenantCode);
  }

  // 校验数据源状态
  if (!dataSource || !dataSource.isInitialized) {
    const availableTenants = Array.from(this.datasourcesMap.keys()).join(',');
    throw new Error(`租户${tenantCode}数据源初始化失败,可用租户:${availableTenants}`);
  }

  return dataSource;
}

/**
 * 创建单个租户数据源
 */
private async createTenantDataSource(config: ConfigEntity): Promise<void> {
  const dataSource = new DataSource({
    url: config.databaseUrl,
    type: config.databaseType as any,
    entities: this.SHARED_ENTITIES,
    synchronize: false, // 生产环境禁用自动同步,通过脚本管理
    // 连接池核心配置
    pool: {
      max: 20, // 单租户最大连接数(从1000优化为20)
      min: 2,  // 单租户最小空闲连接(从10优化为2)
      idleTimeoutMillis: 30000, // 空闲30秒自动释放
      acquireTimeoutMillis: 10000, // 获取连接超时10秒
    },
    extra: {
      connectionTimeoutMillis: 5000, // 数据库连接超时5秒
    },
  });

  await dataSource.initialize();
  this.datasourcesMap.set(config.tenantCode, dataSource);
  this.logger.log(`租户${config.tenantCode}数据源创建完成`);
}

调试优化:错误信息中包含可用租户列表,生产环境排查问题时,无需登录服务器查看缓存,直接通过日志就能定位问题。

4. 连接池优化:从 “暴力配置” 到 “精准调优”

初期我们盲目将单租户最大连接数设为 1000,仅 10 个租户就打满数据库连接。通过分析真实业务流量,最终确定最优连接池配置:

表格

配置项优化前优化后优化依据
max(最大连接)100020单租户并发查询峰值≤15
min(最小空闲)102避免低峰期连接浪费
idleTimeoutMillis30 秒空闲连接自动回收
acquireTimeoutMillis10 秒连接池耗尽时快速失败,避免阻塞

优化后,10 个租户总连接数仅 200,与数据库 max_connections 完美匹配,彻底解决连接爆炸问题。

5. 允许部分失败:Promise.allSettled 实战

服务启动时批量初始化租户数据源,不能因为单个租户故障导致整个服务启动失败。这里使用 Promise.allSettled 替代 Promise.all,实现 “部分失败不影响整体”。

typescript

运行

/**
 * 批量初始化租户数据源,允许部分失败
 */
private async initializeAllDataSources(configs: ConfigEntity[]): Promise<void> {
  // 并行初始化所有租户数据源
  const results = await Promise.allSettled(
    configs.map(config => this.createTenantDataSource(config))
  );

  // 仅日志记录失败的租户,不阻塞服务启动
  results.forEach((result, index) => {
    const tenantCode = configs[index].tenantCode;
    if (result.status === 'rejected') {
      this.logger.error(`租户${tenantCode}数据源初始化失败`, result.reason);
    }
  });
}

生产价值:曾有客户医院数据库维护 2 小时,若用 Promise.all 会导致整个服务无法启动;而 Promise.allSettled 让其余 9 家客户完全无感知,保障了核心业务连续性。

6. 安全资源清理:避免内存泄漏

服务关闭时,必须安全销毁所有数据源,且单个数据源销毁失败不影响其他资源释放。

typescript

运行

async onModuleDestroy(): Promise<void> {
  this.logger.log('开始销毁多租户数据源');

  // 并行销毁所有数据源,单个失败不影响整体
  const closePromises = Array.from(this.datasourcesMap.entries()).map(
    async ([tenantCode, dataSource]) => {
      try {
        if (dataSource.isInitialized) {
          await dataSource.destroy();
          this.logger.log(`租户${tenantCode}数据源已销毁`);
        }
      } catch (error) {
        this.logger.error(`租户${tenantCode}数据源销毁失败`, error);
      }
    }
  );

  // 等待所有销毁操作完成
  await Promise.allSettled(closePromises);

  // 清空缓存,重置状态
  this.datasourcesMap.clear();
  this.configDataSource = null;
  this.initialized = false;
  this.initializationPromise = null;

  this.logger.log('多租户数据源全部清理完成');
}

7. 业务层使用:极简调用体验

底层逻辑再复杂,也要给业务层提供极简的调用方式,这是架构设计的核心原则。

typescript

运行

// 业务Service中使用
@Injectable()
export class OrderService {
  constructor(
    private readonly multiTenantDbService: MultiTenantDatabaseService
  ) {}

  async getActiveOrders(tenantCode: string) {
    // 仅需传入租户编码,自动获取对应数据源的仓库
    const orderRepo = await this.multiTenantDbService.getRepository<OrderEntity>(
      tenantCode,
      OrderEntity
    );
    // 正常执行数据库操作
    return orderRepo.find({ where: { status: 'active' } });
  }
}

业务层无需关心数据源缓存、连接池管理、懒加载等细节,只需传入租户编码,即可像使用单数据源一样操作多租户数据库。

四、落地效果:数据说话的优化成果

方案上线后,生产环境核心指标得到量级提升

表格

指标优化前优化后提升幅度
单租户数据库连接≈1000 个≈20 个连接数下降 98%
内存使用持续增长,频繁 OOM稳定无波动彻底解决内存泄漏
故障隔离单租户故障拖垮全服务租户完全隔离杜绝故障传播
新增租户需重启服务 + 重新部署配置库新增记录即可效率提升 100%

五、现存不足与未来规划

现存不足

  1. 懒加载并发竞争:单个新租户的并发请求,仍可能触发多次数据源创建,存在竞态条件;
  2. 监控缺失:无租户数据源数量、连接池使用率、内存占用等监控指标;
  3. 配置库单点故障:配置库宕机后,无法新增租户,重启服务会完全不可用。

未来优化规划

  1. LRU 缓存淘汰:引入带 TTL 的 LRU 缓存,自动清理长期未使用的租户数据源,释放资源;
  2. 可观测性建设:接入 Prometheus + Grafana,采集租户连接池活跃 / 空闲连接数、超时次数、延迟等指标;
  3. 配置库高可用:配置库主从架构 + Redis 配置缓存,避免单点故障;
  4. 租户级连接池动态调优:根据租户规模、流量,动态调整单租户连接池大小,实现资源精细化分配。

六、核心总结

  1. Promise 缓存是极简并发控制方案:Node.js 单线程环境下,无需复杂锁机制,一个 Promise 就能解决重复初始化问题;
  2. Promise.allSettled 是高可用必备:微服务与多租户场景下,允许部分失败是保障系统可用性的关键;
  3. 连接池宁小勿大:不要盲目设置超大连接数,基于真实流量调优才是最优解;
  4. 架构设计要兼顾扩展性与易用性:底层复杂逻辑封装后,给业务层提供极简接口,才是合格的生产级方案。

这套方案已在我们的生产环境稳定运行超 1 年,支撑上百租户的并发访问,无连接泄漏、无内存溢出、无故障传播,完美适配 SaaS 多租户独立数据库的核心场景。