多租户 DataSource 池:LRU 淘汰 + 双重锁 + 锁外构建

0 阅读12分钟

Enterprise Connector 系列第 04 篇。完整代码:enterprise-connector

适合谁读:做多租户 SaaS 后端、租户量 100+ 的同行;被 HikariCP 在 synchronized 里阻塞过其他租户的人;想用 LinkedHashMap 自己实现 LRU 又怕踩并发坑的工程师。


一句话结论(先给答案)

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

  • LinkedHashMap(accessOrder=true) 实现 LRU,key=tenantId:dsName
  • 上限 300 个池(可配),超了淘汰最久未访问的
  • 双锁分离:池命中走 synchronized 内的 map.get(纳秒级),池未命中走"锁外建池 → 再加锁决策"避免阻塞其他租户
  • 关闭/淘汰用 closeQuietly 在锁外执行,因为 HikariCP close() 会走网络 IO

最坏情况:300 池 × 5 连接 = 1500 连接。需要 PgBouncer 或调大 PG max_connections

下面讲清楚每个决策为什么这么做。


一、为什么不能"每个租户一个 DataSource Bean"

最朴素的多租户做法:Spring 容器里给每个租户注册一个 @Bean DataSource。

启动时枚举 tenant_datasource 表,循环 applicationContext.getBeanFactory().registerSingleton(...) 注册。

这条路在 10 个租户以下能跑,到 100 个就崩:

  1. 启动慢:每个 HikariCP 池初始化要建 5 个连接 × N 租户 = 启动等几分钟
  2. 常驻内存:哪怕这个租户一年不来一次,连接池也常驻 + 维持心跳
  3. 运维静态:新租户入驻必须重启应用才能注册 Bean,违反 SaaS 多租户的核心诉求
  4. 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),原因:

  1. 零依赖:JDK 自带,连 Caffeine 都不用引(Caffeine 已经在项目里用作两级缓存的 L1,但那是用作"缓存对象",不是"管理生命周期资源")
  2. 淘汰时机精确可控:maximumSize 容器在你想关掉 Hikari 池的那一刻不一定真把它从 map 里移除(异步策略),但 LinkedHashMap 想关就关
  3. 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;
    }
}

三段:

  1. 第一次加锁(短):只调 pool.get(key),纳秒级。命中直接返回
  2. 锁外建池(长):HikariCP 初始化 + 网络 IO 在锁外跑。其他租户的快路径不被阻塞
  3. 第二次加锁(短):再查一次 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