Enterprise Connector 系列第 04 篇。完整代码:enterprise-connector
适合谁读:做多租户 SaaS 后端、租户量 100+ 的同行;被 HikariCP 在 synchronized 里阻塞过其他租户的人;想用 LinkedHashMap 自己实现 LRU 又怕踩并发坑的工程师。
一句话结论(先给答案)
多租户系统里每个租户连自己的 DB,连接池如果不加约束会无界增长。本系统的方案:
LinkedHashMap(accessOrder=true)实现 LRU,key=tenantId:dsName- 上限 300 个池(可配),超了淘汰最久未访问的
- 双锁分离:池命中走 synchronized 内的
map.get(纳秒级),池未命中走"锁外建池 → 再加锁决策"避免阻塞其他租户 - 关闭/淘汰用
closeQuietly在锁外执行,因为 HikariCPclose()会走网络 IO
最坏情况:300 池 × 5 连接 = 1500 连接。需要 PgBouncer 或调大 PG max_connections。
下面讲清楚每个决策为什么这么做。
一、为什么不能"每个租户一个 DataSource Bean"
最朴素的多租户做法:Spring 容器里给每个租户注册一个 @Bean DataSource。
启动时枚举 tenant_datasource 表,循环 applicationContext.getBeanFactory().registerSingleton(...) 注册。
这条路在 10 个租户以下能跑,到 100 个就崩:
- 启动慢:每个 HikariCP 池初始化要建 5 个连接 × N 租户 = 启动等几分钟
- 常驻内存:哪怕这个租户一年不来一次,连接池也常驻 + 维持心跳
- 运维静态:新租户入驻必须重启应用才能注册 Bean,违反 SaaS 多租户的核心诉求
- OOM 风险:100+ 租户 × 每池几 MB 元数据 → 直接吃掉一台机器的堆
正确的姿势是按需创建 + 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),原因:
- 零依赖:JDK 自带,连 Caffeine 都不用引(Caffeine 已经在项目里用作两级缓存的 L1,但那是用作"缓存对象",不是"管理生命周期资源")
- 淘汰时机精确可控:
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;
}
这样写在生产上就是大事故。
3.1 为什么大事故
build() 调用 HikariCP 初始化,里面要做的事:
1. 解密 DB 密码(CPU 微秒级)
2. 建立 5 个 JDBC 连接到目标 DB(每个走 TCP 三次握手 + TLS 协商 + 数据库认证)
3. 跑健康检查 SELECT 1
4. 注册到 HikariCP 内部状态机
网络 IO 主导,正常情况几百毫秒,跨地域 / 慢 DB 可能几秒。
如果这段在 synchronized 里跑:
租户 A 第一次访问 → 进锁 → 建池中 (耗时 800ms)
↓ 锁占着
租户 B 同时访问 → 阻塞等锁 → 等 800ms 才能进锁
租户 C 也来 → 阻塞 → 等更久
一个慢租户能把所有其他租户的请求全卡住。多租户系统这是灾难级故障。
3.2 双锁分离方案
把"快路径(命中)"和"慢路径(建池)"拆开:
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),纳秒级。命中直接返回 - 锁外建池(长):HikariCP 初始化 + 网络 IO 在锁外跑。其他租户的快路径不被阻塞
- 第二次加锁(短):再查一次 map,如果同时有别的线程也在建同一个 key 的池,丢弃自己刚建的,返回先到的那个
3.3 第二次加锁的去重为什么必要
并发场景:租户 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 连接。但这是低频场景(同一租户的冷启动并发),相比泄漏的代价完全值得。
3.4 close 也在锁外(主要路径)
注意第二次加锁的代码里:
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 会等连接归还才关——这个等待时长不可控。在锁内等就是把锁占着不放,所有其他租户的请求被卡住。
四、LRU 淘汰: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 又在锁内——这是一个有意识的取舍:
- LRU 淘汰是
getOrCreate的尾巴,已经在 synchronized 内 - 把 close 放出去再加一次锁,代码复杂度上升
- LRU 淘汰发生频率低(300 个池不超满就不会触发),对整体并发影响有限
如果以后发现这里成为瓶颈,可以参考 evictAllForTenant 的两阶段模式重写——但目前没必要。
五、上限设置:300 是怎么来的
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 的考虑:
- 单个租户的 MCP 调用是低频的(人在微信里问问题,几秒一次),5 个连接绰绰有余
- AI 不太可能短时间发起几十个并发查询
- 真有大客户突发流量,单独把
tenant_datasource表里那个租户的池调大(设计上支持 per-datasource 覆盖,但当前实现是统一值——这是已知的简化)
六、配套:Spring Event 失效
LRU 是"被动"淘汰(容量超了才淘)。还有"主动"淘汰场景:
- Admin 改了某租户的 DB 连接信息
- Admin 删/禁用了某租户
- 某个数据源被换了密码
这些场景下旧池里的连接信息已经过时,必须立刻关掉重建。本系统通过 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());
}
两个关键点:
6.1 AFTER_COMMIT 不是 IMMEDIATE
事件在 Admin 写操作的事务提交后才触发,不是 service 方法里发出来就立刻处理。原因:
- 如果在事务内立刻 evict,但事务后续回滚(比如 DB 唯一约束冲突),池被错误地关了
- AFTER_COMMIT 保证"DB 真改成功" → "本地池才失效",语义一致
6.2 跨实例失效靠 Redis Pub/Sub
Spring Event 只在本 JVM 生效。多实例部署时,Admin 在实例 A 改了配置,实例 B 的池还是旧的。
跨实例失效是另一套机制(Redis Pub/Sub channel cache:invalidate),上一篇 §03 三层配置体系 讲过 sys_dict 用类似机制广播。两套机制各司其职:
- Spring Event:本 JVM 内即时失效(毫秒级)
- Redis Pub/Sub:跨 JVM 最终失效(秒级)
写新 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平均耗时 → 反映"建池频率 / 缓存命中率" - HikariCP
pendingThreads→ 反映"per-tenant 5 个连接是否够用"
这些指标本系统通过 ConnectorMetrics 暴露给 Prometheus。
八、复盘:这套设计为什么没 over-engineer
我自己回看这段代码时一度怀疑过——双锁、两阶段 evict、Event 失效、LRU 淘汰,是不是一开始就太复杂了?
后来想清楚:这些复杂度都对应一个真实问题,不是想象出来的。
| 设计 | 对应的真实问题 |
|---|---|
| 双锁分离 | "锁内建池"会让多租户互相阻塞,这是多租户必然遇到的问题 |
| 两阶段 evict | "close 时长不可控",这是 HikariCP 行为决定的 |
| AFTER_COMMIT 失效 | "事务回滚 vs 池已关"的状态错位,是事务边界的固有问题 |
| LRU 上限 | DB max_connections 是硬约束,不能假装它不存在 |
判断"是不是过度设计"的标准是:去掉某段代码会不会暴露一个真实场景下的 bug。如果会,那就不是过度设计。
如果你做的是单租户系统、或者租户少于 10 个、或者并发量本身极低——本文这套设计确实是过度的。但本系统的目标场景是 SaaS 多租户、商家上千、AI Agent 调用并发不可预测——这套复杂度是匹配场景的。
九、4 条可推广的经验
经验 1:网络 IO 永远不要在 synchronized 里跑
HikariCP build() 是网络 IO,正常 800ms,慢的时候几秒。在 synchronized 里跑等于把这段时间送给所有其他线程当阻塞时间。多租户系统更敏感——一个慢租户能把所有其他租户卡住。所有"建资源"、"close 资源"、"健康检查"操作都要在锁外。
经验 2:LRU 容器选型看"淘汰时机能不能精确控制"
Caffeine.maximumSize 性能强,但淘汰是异步的——你想关 HikariCP 那一刻,Caffeine 可能还没真把元素从 map 里弹出来。资源生命周期管理需要"想关就关"的精确控制,这种场景**LinkedHashMap(accessOrder=true) 比 Caffeine 更合适**——尽管它非线程安全,但加 synchronized 比和 Caffeine 的异步策略斗智斗勇要简单。
经验 3:事务边界外的失效要用 AFTER_COMMIT
资源失效(关池、清缓存、删 Redis key)必须等事务 commit 后再触发。在事务内触发等于赌事务一定会成功——一旦回滚,DB 状态没变但资源已被错误失效,会导致下一次请求拿到的是不一致状态。@TransactionalEventListener(phase = AFTER_COMMIT) 是 Spring 给的标准答案。
经验 4:本进程一致性 + 跨进程最终一致性是两套机制
Spring ApplicationEvent 解决本 JVM 内的失效,Redis Pub/Sub 解决跨 JVM的失效。两者不可互相替代——只发 Event 多实例不一致,只发 Pub/Sub 本实例自己延迟。任何 Admin 写接口都要同时发两个,这是必须刻在脑子里的纪律。
写在最后
多租户 DataSource 池是个没有银弹的工程问题。要么选 PgBouncer 转移问题给 DB 中间件;要么自己在应用层管资源生命周期,承担本文这套复杂度。
完整代码:enterprise-connector