文章目录
-
- 一、架构全景
-
- [1.1 六层设计体系](#1.1 六层设计体系)
- [1.2 核心执行流 (The "Happy Path")](#1.2 核心执行流 (The "Happy Path"))
- 二、关键技术逻辑
-
- [2.1 零侵入的上下文管理 (AOP + ThreadLocal)](#2.1 零侵入的上下文管理 (AOP + ThreadLocal))
- [2.2 运行时动态路由 (Spring AbstractRoutingDataSource)](#2.2 运行时动态路由 (Spring AbstractRoutingDataSource))
- [2.3 资源复用与内存保护 (LRU + Double Key)](#2.3 资源复用与内存保护 (LRU + Double Key))
- 三、架构核心权衡 (Trade-offs)
-
- [3.1 为什么禁止跨库事务?](#3.1 为什么禁止跨库事务?)
- [3.2 为什么使用 ThreadLocal 而不是传参?](#3.2 为什么使用 ThreadLocal 而不是传参?)
核心结论 :本架构通过 AOP 切面 + ThreadLocal 线程隔离 + Spring 动态路由 的组合,实现了零侵入的运行时数据源切换。解决了在不重启服务的情况下,根据用户请求动态连接数百个不同数据库的核心难题。
本套设计方案为了解决**"在多租户/BI场景下,如何灵活、高效、安全地连接任意数据库"**这一具体业务问题。
它通过 AOP 封装复杂性 ,让上层业务开发"无感";通过 池化技术 + LRU 解决性能与资源的矛盾;通过 严格的事务检查 兜底系统稳定性。这是一个典型的 "用空间换时间,用约束换安全" 的架构设计案例。
一、架构全景
1.1 六层设计体系
我们将数据源切换过程拆解为六个职责单一的层级,确保每一层只关注一个核心问题:
| 层级 | 核心组件 | 核心职责 | 业务价值 |
|---|---|---|---|
| L1 应用层 | @DynamicSource |
意图声明 | 开发者只需打个注解,无需关心底层实现。 |
| L2 拦截层 | Spring AOP | 环境准备 | 自动提取参数,完成"切换前"的上下文设置。 |
| L3 路由层 | DynamicRoutingEngine |
决策分发 | 像交通枢纽一样,根据上下文将请求导向正确的数据库。 |
| L4 资源层 | ConnectionPool |
连接复用 | (DruidDataSource)管理数百个物理连接池,提供高性能连接复用。 |
| L5 清理层 | LRU Eviction Task | 生命周期 | 自动回收 30 分钟无用的连接池,防止内存泄漏。 |
| L6 物理层 | MySQL/Oracle/StarRocks | 数据存储 | 最终的异构数据库集群。 |
1.2 核心执行流 (The "Happy Path")
一个 SQL 请求从发出到执行,经历了以下关键流转:
携带连接配置
- 提取配置生成 Key No
Yes - 绑定 Key 到 ThreadLocal 3. determineLookupKey 4. 清理 ThreadLocal 应用层发起调用
AOP 拦截器
连接池是否存在?
创建新物理连接池
复用现有连接池
Spring 动态路由
获取目标物理连接
执行 SQL
请求结束
二、关键技术逻辑
2.1 零侵入的上下文管理 (AOP + ThreadLocal)
- 逻辑描述 :我们不希望业务代码里充斥着
context.setDataSource(...)这样的代码。 - 实现方案 :
- 利用 Spring AOP 拦截所有带有
@DynamicSource注解的方法。 - 在方法执行前 ,将目标数据源的标识(Key)放入 ThreadLocal。
- ThreadLocal 就像每个线程的"随身背包",将数据源信息隐式地传递给底层的 ORM 框架,实现了参数的"透明传输"。
- 关键点 :必须在
finally块中清理 ThreadLocal,防止线程复用导致的"脏数据源"问题。
- 利用 Spring AOP 拦截所有带有
2.2 运行时动态路由 (Spring AbstractRoutingDataSource)
- 逻辑描述 :Spring 默认的数据源是静态的(启动时配置)。我们需要在运行时决定用哪个。
- 实现方案 :
- 继承 Spring 的
AbstractRoutingDataSource。 - 重写
determineCurrentLookupKey()方法。 - 核心逻辑 :每当 ORM 框架请求连接时,该方法会被触发 -> 从 ThreadLocal 获取 Key -> 在内部 Map 中找到对应的
DataSource-> 返回物理连接。 - 这实现了从"硬编码"到"动态查找"的转变。
- 继承 Spring 的
2.3 资源复用与内存保护 (LRU + Double Key)
- 逻辑描述:如果用户频繁切换数据库,不能每次都新建连接池(耗时 500ms+),也不能无限创建导致内存溢出(OOM)。
- 实现方案 :
- 双重索引 :使用
LongKey(全参数拼接) 保证唯一性,使用MD5(短 Key) 作为路由查找键,平衡了准确性与查找性能。 - LRU 驱逐策略 :维护一个后台守护线程,每分钟检查一次。如果某个连接池超过 30 分钟 未被访问,强制关闭并移除。
- 价值:在有限的内存(如 8GB)下,支持了理论上无限的动态数据源访问,只要活跃数不超过阈值。
- 双重索引 :使用
三、架构核心权衡 (Trade-offs)
3.1 为什么禁止跨库事务?
- 逻辑:在 AOP 层检测到当前若已处于事务中,且目标数据源与当前不同,直接抛出异常。
- 权衡 :
- 🔴 牺牲:不支持在一个事务中同时操作 Database A 和 Database B (XA 协议)。
- 🟢 获得 :系统极度简洁与稳定。分布式事务(2PC/Seata)极其复杂且性能低下,对于 BI/分析类只读场景,通过禁止跨库事务,避免了 99% 的潜在数据一致性灾难。
3.2 为什么使用 ThreadLocal 而不是传参?
- 逻辑:数据源信息存储在线程上下文中。
- 权衡 :
- 🔴 风险:若不清理,线程池复用会导致后续请求连接到错误的数据库。
- 🟢 获得 :接口零侵入。Service 层、DAO 层接口定义无需改变,完全兼容现有 ORM 代码生成逻辑。