多租户系统里每个租户连自己的 DB,连接池如果不加约束会无界增长。本系统的方案:

LinkedHashMap(accessOrder=true) 实现 LRU,key=tenantId:dsNamemap.get(纳秒级),池未命中走"锁外建池 → 再加锁决策"避免阻塞其他租户closeQuietly 在锁外执行,因为 HikariCP close() 会走网络 IO最坏情况:300 池 × 5 连接 = 1500 连接。需要 PgBouncer 或调大 PG max_connections。
下面讲清楚每个决策为什么这么做。
最朴素的多租户做法:Spring 容器里给每个租户注册一个 @Bean DataSource。
启动时枚举 tenant_datasource 表,循环 applicationContext.getBeanFactory().registerSingleton(...) 注册。
这条路在 10 个租户以下能跑,到 100 个就崩:
正确的姿势是按需创建 + LRU 淘汰:租户来了再建池,长期不来就关掉。
LinkedHashMap(accessOrder=true)JDK 原生支持 LRU 的容器有几种,逐个看:
| 容器 | 优势 | 劣势 |
|---|---|---|
LinkedHashMap(accessOrder=true) | JDK 自带,零依赖;访问/插入双链表自动维护顺序 | 非线程安全 |
Caffeine.maximumSize | 高性能,准 LRU(Tiny LFU) | 自动淘汰是异步的,不能精确控制"什么时候关池" |
ConcurrentHashMap + 手动维护 LRU 队列 | 线程安全 | 复杂,且 LRU 队列本身得加锁,最后还是要回到 synchronized |
Guava CacheBuilder | 老牌方案 | Guava 重,且 Caffeine 基本是 Guava Cache 的继任者 |
本系统选 LinkedHashMap(accessOrder=true),原因:
maximumSize 容器在你想关掉 Hikari 池的那一刻不一定真把它从 map 里移除(异步策略),但 LinkedHashMap 想关就关accessOrder=true 的双链表语义对人友好:每次 get 也会把节点移到链表尾,淘汰时 iterator().next() 拿到的就是最久未用的——逻辑直观,不容易写错代价:非线程安全。所以所有访问都套 synchronized,下面会讲为什么这是可接受的。
private final Map<String, HikariDataSource> pool = new LinkedHashMap<>(16, 0.75f, true);
// ^^^^
// accessOrder=true
LinkedHashMap 非线程安全,最简单粗暴的方案:
public synchronized DataSource getOrCreate(String tenantId, String dsName) {
HikariDataSource ds = pool.get(key);
if (ds != null) return ds;
ds = build(tenantId, dsName); // ← 在锁内建池
pool.put(key, ds);
return ds;
}
这样写在生产上就是大事故。
build() 调用 HikariCP 初始化,里面要做的事:
1. 解密 DB 密码(CPU 微秒级)
2. 建立 5 个 JDBC 连接到目标 DB(每个走 TCP 三次握手 + TLS 协商 + 数据库认证)
3. 跑健康检查 SELECT 1
4. 注册到 HikariCP 内部状态机
网络 IO 主导,正常情况几百毫秒,跨地域 / 慢 DB 可能几秒。
如果这段在 synchronized 里跑:
租户 A 第一次访问 → 进锁 → 建池中 (耗时 800ms)
↓ 锁占着
租户 B 同时访问 → 阻塞等锁 → 等 800ms 才能进锁
租户 C 也来 → 阻塞 → 等更久
一个慢租户能把所有其他租户的请求全卡住。多租户系统这是灾难级故障。
把"快路径(命中)"和"慢路径(建池)"拆开:
public DataSource getOrCreate(String tenantId, String dsName) {
String key = buildKey(tenantId, dsName); // 快路径: 命中缓存
synchronized (this) {
HikariDataSource existing = pool.get(key);
if (existing != null && !existing.isClosed()) {
return existing;
}
} // 慢路径: 无锁构建 (读 TenantDatasource + HikariCP 初始化)
HikariDataSource built = build(tenantId, dsName); // 第二次加锁: 决策 + 防并发重复构建
synchronized (this) {
HikariDataSource winner = pool.get(key);
if (winner != null && !winner.isClosed()) {
log.debug("并发构建同 key DataSource, 丢弃本实例 key={}", key);
closeQuietly(built, key);
return winner;
}
pool.put(key, built);
enforceCapacity();
return built;
}
}
三段:
pool.get(key),纳秒级。命中直接返回并发场景:租户 A 第一次访问,两个请求线程几乎同时到。
线程 1: 锁内 get(key) → null → 解锁
线程 2: 锁内 get(key) → null → 解锁
线程 1: 锁外 build() → 池 P1
线程 2: 锁外 build() → 池 P2
线程 1: 锁内 put(key, P1) ← P1 进 map
线程 2: 锁内 get(key) → P1 ← 发现已有, 丢弃自己的 P2
线程 2: 在锁外 closeQuietly(P2)
如果没有第二次加锁的去重检查,P1 和 P2 都会进 map——后写入的 P2 覆盖 P1,导致 P1 泄漏(永远没机会被 close)。
代价是偶发的"建了池又立刻关"——浪费几百毫秒和几个 TCP 连接。但这是低频场景(同一租户的冷启动并发),相比泄漏的代价完全值得。
注意第二次加锁的代码里:
synchronized (this) {
HikariDataSource winner = pool.get(key);
if (winner != null && !winner.isClosed()) {
log.debug("并发构建同 key DataSource, 丢弃本实例 key={}", key);
closeQuietly(built, key); // ← 在 synchronized 块里 close
...
}
}
等等,closeQuietly 不是说要在锁外吗?
仔细看:这种"刚建出来还没用过的池"的 close 很快——HikariCP 内部连接还在初始化阶段,没有正在使用的连接需要等回收。所以这种特殊场景在锁内 close 影响不大。
而真正的"淘汰池"路径(LRU 淘汰旧池、租户配置变更失效池),close 是在锁外做的,看 evictAllForTenant:
public void evictAllForTenant(String tenantId) {
String prefix = tenantId + ":";
// Step 1: 锁内只做 map 修改, 收集待关闭的池实例
Map<String, HikariDataSource> removed = new LinkedHashMap<>();
synchronized (this) {
Iterator<Map.Entry<...>> it = pool.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<...> e = it.next();
if (e.getKey().startsWith(prefix)) {
removed.put(e.getKey(), e.getValue());
it.remove();
}
}
}
// Step 2: 锁外 close, 避免阻塞其他租户操作
removed.forEach((k, v) -> closeQuietly(v, k));
}
锁内只做 map.remove,把要关的池收集到一个临时 map;解锁后才真正调 close()。
为什么这样:旧池 close 时可能有连接正在被使用(比如长查询),HikariCP 会等连接归还才关——这个等待时长不可控。在锁内等就是把锁占着不放,所有其他租户的请求被卡住。
enforceCapacity 怎么写LinkedHashMap(accessOrder=true) 的优雅之处:iterator 的第一个元素就是最久未访问的。所以淘汰逻辑非常直接:
private void enforceCapacity() {
while (pool.size() > maxPools) {
Iterator<Map.Entry<String, HikariDataSource>> it = pool.entrySet().iterator();
if (!it.hasNext()) break;
Map.Entry<String, HikariDataSource> oldest = it.next();
it.remove();
log.info("DataSource 池超过上限 {}, LRU 淘汰 key={}", maxPools, oldest.getKey());
closeQuietly(oldest.getValue(), oldest.getKey());
}
}
while 循环是为了容错:理论上一次只会超 1 个(因为加进去之前一定先调用 enforceCapacity),但配置如果在运行时被改小,可能一次要淘多个。
注意这里 closeQuietly 又在锁内——这是一个有意识的取舍:
getOrCreate 的尾巴,已经在 synchronized 内如果以后发现这里成为瓶颈,可以参考 evictAllForTenant 的两阶段模式重写——但目前没必要。
connector:
datasource-pool:
max-pools: 300 # 池数上限
per-tenant-max-connections: 5 # 每池连接数
300 不是拍脑袋——是按目标 DB 的 max_connections 反推的。
PG 默认 max_connections=100,调大到 2000-5000 是常见运维操作(要相应配 shared_buffers / work_mem)。如果应用要支持 300 个池 × 5 连接 = 1500 连接,PG 必须能承担:
应用 PG max_connections
1500 ← 2000+ (留 headroom 给 superuser / 备份 / 监控)
如果 PG 只能给 500 连接,那应用层 max-pools 必须降到 100。这是个跨团队对齐的参数——和 DBA 谈定 DB 承载力,再倒推应用层上限。
更专业的做法:上 PgBouncer(PG 连接池中间件)。应用层连 PgBouncer,PgBouncer 复用真实 PG 连接,1500 应用连接可能只对应 50 个真实 PG 连接。但这是另一个话题,本系统暂未引入 PgBouncer,靠应用侧 max-pools 限制。
per-tenant-max-connections=5 的考虑:
tenant_datasource 表里那个租户的池调大(设计上支持 per-datasource 覆盖,但当前实现是统一值——这是已知的简化)LRU 是"被动"淘汰(容量超了才淘)。还有"主动"淘汰场景:
这些场景下旧池里的连接信息已经过时,必须立刻关掉重建。本系统通过 Spring ApplicationEvent 触发:
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true)
public void onTenantConfigChanged(TenantConfigChangedEvent event) {
if (event.getKind() == TenantConfigChangedEvent.Kind.CREATED) return;
evictAllForTenant(event.getTenantId());
}@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true)
public void onTenantDatasourceChanged(TenantDatasourceChangedEvent event) {
evict(event.getTenantId(), event.getDsName());
}
两个关键点:
AFTER_COMMIT 不是 IMMEDIATE事件在 Admin 写操作的事务提交后才触发,不是 service 方法里发出来就立刻处理。原因:
Spring Event 只在本 JVM 生效。多实例部署时,Admin 在实例 A 改了配置,实例 B 的池还是旧的。
跨实例失效是另一套机制(Redis Pub/Sub channel cache:invalidate),上一篇 §03 三层配置体系 讲过 sys_dict 用类似机制广播。两套机制各司其职:
写新 Admin 接口时两个都不能漏——只发 Event 多实例不一致;只发 Pub/Sub 本实例自己延迟。
实际部署时建议按这个公式估算:
真实 DB 连接数 = max_pools × per-tenant-max-connections × 应用实例数举例: 300 池 × 5 连接 × 3 实例 = 4500 真实 DB 连接 (worst case, 极少同时建满)
注意是 worst case,实际很少同时建满。但容量规划要按 worst case 来,不能赌。
如果租户量超过 300(系统上限不变,但 LRU 淘汰频繁),监控指标看:
enforceCapacity 触发次数 → 反映"租户在轮转"程度getOrCreate 平均耗时 → 反映"建池频率 / 缓存命中率"pendingThreads → 反映"per-tenant 5 个连接是否够用"这些指标本系统通过 ConnectorMetrics 暴露给 Prometheus。
我自己回看这段代码时一度怀疑过——双锁、两阶段 evict、Event 失效、LRU 淘汰,是不是一开始就太复杂了?
后来想清楚:这些复杂度都对应一个真实问题,不是想象出来的。
| 设计 | 对应的真实问题 |
|---|---|
| 双锁分离 | "锁内建池"会让多租户互相阻塞,这是多租户必然遇到的问题 |
| 两阶段 evict | "close 时长不可控",这是 HikariCP 行为决定的 |
| AFTER_COMMIT 失效 | "事务回滚 vs 池已关"的状态错位,是事务边界的固有问题 |
| LRU 上限 | DB max_connections 是硬约束,不能假装它不存在 |
判断"是不是过度设计"的标准是:去掉某段代码会不会暴露一个真实场景下的 bug。如果会,那就不是过度设计。
如果你做的是单租户系统、或者租户少于 10 个、或者并发量本身极低——本文这套设计确实是过度的。但本系统的目标场景是 SaaS 多租户、商家上千、AI Agent 调用并发不可预测——这套复杂度是匹配场景的。
HikariCP build() 是网络 IO,正常 800ms,慢的时候几秒。在 synchronized 里跑等于把这段时间送给所有其他线程当阻塞时间。多租户系统更敏感——一个慢租户能把所有其他租户卡住。所有"建资源"、"close 资源"、"健康检查"操作都要在锁外。
Caffeine.maximumSize 性能强,但淘汰是异步的——你想关 HikariCP 那一刻,Caffeine 可能还没真把元素从 map 里弹出来。资源生命周期管理需要"想关就关"的精确控制,这种场景**LinkedHashMap(accessOrder=true) 比 Caffeine 更合适**——尽管它非线程安全,但加 synchronized 比和 Caffeine 的异步策略斗智斗勇要简单。
资源失效(关池、清缓存、删 Redis key)必须等事务 commit 后再触发。在事务内触发等于赌事务一定会成功——一旦回滚,DB 状态没变但资源已被错误失效,会导致下一次请求拿到的是不一致状态。@TransactionalEventListener(phase = AFTER_COMMIT) 是 Spring 给的标准答案。
Spring ApplicationEvent 解决本 JVM 内的失效,Redis Pub/Sub 解决跨 JVM的失效。两者不可互相替代——只发 Event 多实例不一致,只发 Pub/Sub 本实例自己延迟。任何 Admin 写接口都要同时发两个,这是必须刻在脑子里的纪律。
多租户 DataSource 池是个没有银弹的工程问题。要么选 PgBouncer 转移问题给 DB 中间件;要么自己在应用层管资源生命周期,承担本文这套复杂度。
完整代码:enterprise-connector