前言
在 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 家,生产环境彻底崩盘,暴露出四大致命问题:
- 连接爆炸每次请求都创建新 DataSource,每个 DataSource 都会初始化独立连接池,数据库连接数呈指数级增长,直接打满数据库服务器的最大连接数限制,导致新请求无法连接数据库。
- 重复初始化高并发场景下,同一租户的多个请求同时到达,会触发多次 DataSource 初始化,造成资源浪费、接口延迟飙升。
- 内存泄漏使用完成后的 DataSource 没有主动销毁,大量闲置数据源驻留在内存中,内存占用持续上涨,最终引发 OOM(内存溢出)。
- 故障传播租户之间没有隔离,单个租户数据库响应缓慢、宕机,会导致对应请求阻塞,耗尽服务线程池,最终拖垮整个系统。
从 CTO 视角复盘,这是典型的 “先跑起来再优化” 的反面案例:小规模场景下隐藏的问题,在规模化后瞬间引爆,成为生产环境的 “定时炸弹”。
二、方案设计:三大核心策略选型
针对上述问题,我们确定了三大核心解决策略,并通过多方案对比,最终选定最优组合。
核心策略
- Map 缓存:用 Map 存储租户对应的 DataSource,实现数据源复用,避免重复创建。
- Promise 缓存:缓存初始化中的 Promise,解决并发请求下的重复初始化问题。
- 懒加载 + 预加载混合:服务启动时预加载存量租户,新增租户运行时动态创建,兼顾启动速度与扩展性。
方案对比选型
表格
| 方案 | 问题 | 采纳结果 |
|---|---|---|
| 基于 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(最大连接) | 1000 | 20 | 单租户并发查询峰值≤15 |
| min(最小空闲) | 10 | 2 | 避免低峰期连接浪费 |
| idleTimeoutMillis | 无 | 30 秒 | 空闲连接自动回收 |
| acquireTimeoutMillis | 无 | 10 秒 | 连接池耗尽时快速失败,避免阻塞 |
优化后,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% |
五、现存不足与未来规划
现存不足
- 懒加载并发竞争:单个新租户的并发请求,仍可能触发多次数据源创建,存在竞态条件;
- 监控缺失:无租户数据源数量、连接池使用率、内存占用等监控指标;
- 配置库单点故障:配置库宕机后,无法新增租户,重启服务会完全不可用。
未来优化规划
- LRU 缓存淘汰:引入带 TTL 的 LRU 缓存,自动清理长期未使用的租户数据源,释放资源;
- 可观测性建设:接入 Prometheus + Grafana,采集租户连接池活跃 / 空闲连接数、超时次数、延迟等指标;
- 配置库高可用:配置库主从架构 + Redis 配置缓存,避免单点故障;
- 租户级连接池动态调优:根据租户规模、流量,动态调整单租户连接池大小,实现资源精细化分配。
六、核心总结
- Promise 缓存是极简并发控制方案:Node.js 单线程环境下,无需复杂锁机制,一个 Promise 就能解决重复初始化问题;
- Promise.allSettled 是高可用必备:微服务与多租户场景下,允许部分失败是保障系统可用性的关键;
- 连接池宁小勿大:不要盲目设置超大连接数,基于真实流量调优才是最优解;
- 架构设计要兼顾扩展性与易用性:底层复杂逻辑封装后,给业务层提供极简接口,才是合格的生产级方案。
这套方案已在我们的生产环境稳定运行超 1 年,支撑上百租户的并发访问,无连接泄漏、无内存溢出、无故障传播,完美适配 SaaS 多租户独立数据库的核心场景。