【架构设计与实现】动态数据源切换:核心代码实现手册

动态数据源切换:核心代码实现手册

文档说明 :本文档是《动态数据源切换架构设计》的实现篇,深入剖析核心类的代码实现细节。建议先阅读架构设计文档以理解整体设计思想。


一、核心类概览

类名 核心职责 对应架构层级
ConnectionConfig DTO,承载外部数据库的连接信息(URL/User/Pwd)。 L1 应用层
@DynamicSource 注解,标记需要进行数据源切换的方法。 L1 应用层
ContextSwitchAspect AOP切面 ,拦截注解,负责上下文的设置清理 L2 拦截层
DynamicRoutingEngine 核心引擎 ,继承 Spring AbstractRoutingDataSource,管理连接池全生命周期。 L3 路由层 / L4 资源层

二、契约定义 (Contract)

2.1 配置对象 (ConnectionConfig)

这是一个纯粹的数据传输对象(DTO),利用 Lombok 简化了代码。它是业务层与底层数据源之间的"协议"。

java 复制代码
@SuperBuilder(toBuilder = true)
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ConnectionConfig {
    String driverClassName; // 驱动类,如 com.mysql.cj.jdbc.Driver
    String url;             // JDBC URL
    String userName;        // 用户名
    String password;        // 密码
}

2.2 切换注解 (@DynamicSource)

用于标记在 Service 或 DAO 层的方法上,声明该方法需要连接到外部数据源。

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DynamicSource {
    String DEFAULT_GROUP = "default_group";
    // 数据源分组,用于日志或监控
    String value() default DEFAULT_GROUP;
}

三、切面拦截 (The Interceptor)

ContextSwitchAspect 是整个机制的入口。它利用 Spring AOP 环绕通知(Around Advice)接管了方法的执行。

3.1 核心拦截逻辑 (around)

java 复制代码
@Around("@annotation(dynamicSource)")
public Object around(ProceedingJoinPoint joinPoint, DynamicSource dynamicSource) throws Throwable {
    // 1. 【提取参数】从方法参数中寻找 Config 对象
    ConnectionConfig config = fetchConfig(joinPoint);
    Assert.notNull(config, "connection config is null");

    // 2. 【URL 转换】处理内网/外网地址映射(可选逻辑)
    config = config.toBuilder()
            .url(normalizeUrl(config.getUrl()))
            .build();

    // 3. 【事务检查】关键安全机制
    ensureTransactionSafety(config.getUrl());

    try {
        // 4. 【激活数据源】创建连接池并绑定到 ThreadLocal
        routingEngine.activateConnection(dynamicSource.value(), config);
        
        // 5. 【执行业务逻辑】
        return joinPoint.proceed();
    } finally {
        // 6. 【资源清理】必须执行,防止 ThreadLocal 污染
        routingEngine.clearCurrentContext();
    }
}

3.2 事务安全检查 (ensureTransactionSafety)

这是架构设计中"禁止跨库事务"的代码落地

🤔 为什么必须禁止?

Spring 的事务管理器(TransactionManager)在事务开启时,会将数据库连接(Connection)绑定到当前线程。

如果在事务执行过程中尝试切换数据源,Spring 可能会继续复用旧的连接 (导致数据写入错误的库),或者抛出连接不可用的异常。

因此,必须在切面层提前拦截,确保"在事务中不能切换数据源"。

java 复制代码
private void ensureTransactionSafety(String targetUrl) {
    // 1. 如果当前不在事务中,直接放行 (安全)
    if (!TransactionSynchronizationManager.isActualTransactionActive()) {
        return;
    }
    
    // 2. 获取当前线程绑定的数据库连接资源
    ConnectionHolder connectionHolder = (ConnectionHolder) TransactionSynchronizationManager
            .getResourceMap().get(routingEngine);
    
    // 3. 校验:如果已经持有连接,必须保证 URL 一致
    if (connectionHolder != null) {
        Connection conn = connectionHolder.getConnection();
        String currentUrl = conn.getMetaData().getURL();
        
        // 如果事务已经开启在 DB-A,但当前方法请求 DB-B,这是危险操作,必须报错
        if (!Objects.equals(targetUrl, currentUrl)) {
            throw new RuntimeException("禁止在事务中切换数据源:事务已绑定到 " + currentUrl + ",但试图切换至 " + targetUrl);
        }
    }
}

四、动态路由引擎 (The Engine)

DynamicRoutingEngine 是最复杂的类,它集成了 Spring 路由连接池工厂LRU 缓存 三大功能。

4.1 线程上下文管理 (ThreadLocal)

🤔 为什么要用 ThreadLocal?
DynamicRoutingEngine 继承自 Spring 的 AbstractRoutingDataSource

它的核心路由方法 determineCurrentLookupKey()无参 的。

这意味着我们无法通过方法参数直接把"当前要用哪个数据库"传递进去。

因此,ThreadLocal 成为了唯一的"隐式通道",用于将 AOP 层解析出的 Data Source Key 传递给底层的路由方法。

java 复制代码
// 存储当前线程的数据源 Key (MD5值)
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();

// AOP 调用此方法设置 Key
public void activateConnection(String group, ConnectionConfig config) {
    // ... 创建或获取连接池逻辑 ...
    contextHolder.set(context.getLookupKey());
}

// AOP 调用此方法清理 Key
void clearCurrentContext() {
    contextHolder.remove();
    MDC.remove("ds_name"); // 清理日志上下文
}

4.2 Spring 路由钩子 (determineCurrentLookupKey)

这是 AbstractRoutingDataSource 定义的抽象方法。ORM 框架(如 MyBatis)在请求 DataSource.getConnection() 时,Spring 会自动回调此方法来决定返回哪个具体的 DataSource。

java 复制代码
@Override
protected Object determineCurrentLookupKey() {
    // 1. 从 ThreadLocal 获取 Key
    String key = contextHolder.get();
    
    // 2. 如果 Key 为空,返回 null (Spring 会使用默认数据源)
    if (key == null) {
        return null;
    }
    
    // 3. 简单的校验与日志
    RoutingContext context = lookupKeyMap.get(key);
    if (context != null) {
        // 刷新活跃时间,用于 LRU
        context.refreshLastActiveTime();
        // 设置 MDC,让日志中包含数据源名称
        MDC.put(MDC_KEY, context.getLookupKey());
    }
    
    return key;
}

4.3 双 Key 索引与连接池复用

为了解决隐私安全去重 的矛盾,我们设计了双 Key 机制。我们可以把它比作 "身份证"与"房卡" 的关系:

  1. LongKey (身份证+详细信息)
    • 内容category_url_username_password_driver(包含密码等所有细节)。
    • 作用只在"办理入住"(创建连接池)时使用
    • 逻辑:系统拿着这个详细清单去查:"这位客人(这个配置)以前来过吗?"。如果来过,直接复用旧房间;没来过,才开新房间。
  2. LookupKey (房卡/房间号)
    • 内容MD5(LongKey)(一串看不出原始信息的短字符)。
    • 作用日常通行证
    • 价值
      • 安全:你拿着房卡(LookupKey)在系统里到处走(存入 ThreadLocal、打印日志),别人捡到了也无法反推出你的银行卡密码(数据库密码)。
      • 轻便:MD5 长度固定,做 Map 索引比长字符串更快。
java 复制代码
// Map 1: 全量 Key -> Context (用于去重)
private final Map<String, RoutingContext> configMap = new ConcurrentHashMap<>();

// Map 2: MD5 Key -> Context (用于路由查找)
private final Map<String, RoutingContext> lookupKeyMap = new ConcurrentHashMap<>();

public void activateConnection(...) {
    // 1. 生成包含密码的全量 Key
    String longKey = generateUniqueKey(group, config);
    
    // 2. 先查缓存 (是否存在该连接池)
    RoutingContext context = configMap.get(longKey);
    
    if (context == null) {
        // 3. 如果不存在,创建新连接池
        DataSource pool = createConnectionPool(config);
        
        // 4. 生成短 Key (MD5),用于后续的路由和日志
        String lookupKey = md5(longKey); 
        
        context = RoutingContext.builder()
                .longKey(longKey)
                .lookupKey(lookupKey)
                .build();
                
        // 5. 注册到 Spring 的 targetDataSources Map 中
        registerDataSource(context, pool);
    }
    
    // 6. 绑定短 Key 到当前线程 (安全)
    contextHolder.set(context.getLookupKey());
}

五、生命周期管理 (Lifecycle & LRU)

为了防止无限创建连接池导致 OOM,系统通过定时任务调用 evictExpiredDataSources 进行清理。

5.1 LRU 驱逐逻辑

java 复制代码
private boolean isDataSourceAvailable(RoutingContext context, DataSource pool) {
    // 策略 1: 快速检查 (连接池自身状态)
    if (isPoolHealthy(pool)) {
        return true;
    }
    
    // 策略 2: LRU 超时检查
    // 如果 (当前时间 - 最后活跃时间) > maxIdleTime (默认30分钟)
    Duration idleTime = Duration.between(context.getLastActiveTime(), LocalDateTime.now());
    if (idleTime.compareTo(MAX_IDLE_TIME) > 0) {
        return false; // 标记为不可用 -> 将被移除
    }
    
    // 策略 3: 物理连接探活 (异步执行 SQL: SELECT 1)
    // ...
}

六、代码使用示例

6.1 定义 DAO 接口

java 复制代码
@Repository
public class DataRepository {

    @Autowired
    private SqlSession sqlSession;

    // 核心:打上注解,第一个参数必须是 Config
    @DynamicSource
    public List<Map<String, Object>> queryExternalData(ConnectionConfig config, String sql) {
        // 这里的 sqlSession 会自动被路由到 config 指定的数据库
        return sqlSession.selectList("com.example.mapper.selectBySql", sql);
    }
}

6.2 业务层调用

java 复制代码
public void generateReport() {
    // 1. 构建配置
    ConnectionConfig mysqlConfig = ConnectionConfig.builder()
            .url("jdbc:mysql://10.0.0.1:3306/bi_db")
            .userName("admin")
            .password("secret")
            .driverClassName("com.mysql.cj.jdbc.Driver")
            .build();

    // 2. 调用 DAO (切面会自动介入)
    List<Map<String, Object>> result = dataRepository.queryExternalData(mysqlConfig, "SELECT * FROM report LIMIT 10");
    
    // 3. 处理结果...
}
相关推荐
Monly218 小时前
Java:修改打包配置文件
java·开发语言
XiaoFan0128 小时前
免密批量抓取日志并集中输出
java·linux·服务器
顾北128 小时前
MCP服务端开发:图片搜索助力旅游计划
java·spring boot·dubbo
我命由我123459 小时前
Android 广播 - 静态注册与动态注册对广播接收器实例创建的影响
android·java·开发语言·java-ee·android studio·android-studio·android runtime
赛姐在努力.9 小时前
【拓扑排序】-- 算法原理讲解,及实现拓扑排序,附赠热门例题
java·算法·图论
yxc_inspire9 小时前
Java学习第二天
java·面向对象
毕设源码-赖学姐9 小时前
【开题答辩全过程】以 基于net超市销售管理系统为例,包含答辩的问题和答案
java
昀贝9 小时前
IDEA启动SpringBoot项目时报错:命令行过长
java·spring boot·intellij-idea
roman_日积跬步-终至千里9 小时前
【LangGraph4j】LangGraph4j 核心概念与图编排原理
java·服务器·数据库