文章目录
- 一、架构全景
- 1.1 六层设计体系
- 1.2 核心执行流 (The "Happy Path")
- 二、关键技术逻辑
- 2.1 零侵入的上下文管理 (AOP + ThreadLocal)
- 2.2 运行时动态路由 (Spring AbstractRoutingDataSource)
- 2.3 资源复用与内存保护 (LRU + Double Key)
- 三、架构核心权衡 (Trade-offs)
- 3.1 为什么禁止跨库事务?
- 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 请求从发出到执行,经历了以下关键流转:
二、关键技术逻辑
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 代码生成逻辑。