MySQL 与 Clickhouse 多数据源切换技术分析

零、代码摘要

在 Spring Boot 等生态中,多数据源切换是一种常用的基础组件,虽然功能简单但要实现一个并发稳定、鲁棒性好、集成容易的多数元切换组件也需要花费一点功夫。这里给出一些代码示例分析隐藏问题并给出优化建议,不论是面试候选还是代码评审都是一个不错的素材。

1. Clickhouse

java 复制代码
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Clickhouse {

    String value() default "";
}

2. ClickhouseDatasource

java 复制代码
@Configuration
@MapperScan(basePackages = {"com.xxx.anomaly.**.mapper"}, sqlSessionFactoryRef = "SqlSessionFactory")
public class ClickhouseDatasource {
    
    @Bean(name = "defaultDatasource")
    @ConfigurationProperties(prefix = "spring.datasource")
    @Primary
    public DataSource getDefault() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = "clickhouse")
    @ConfigurationProperties(prefix = "spring.clickhouse")
    public DataSource clickhouse(@Qualifier("defaultDatasource") DataSource defaultDatasource) {
        if(StringUtils.isNotEmpty(dbUrl)) {
            // 获取Clickhouse连接参数
            return balancedClickhouseDataSource;
        } else {
            LOGGER.info("Clickhouse数据源未配置");
            return null;
        }
    }
}

3. DatasourceAop

java 复制代码
@Aspect
@Order(-1)
@Component
public class DatasourceAop {

    private static final String PACKAGE = "com.xxx.anomaly";

    @Pointcut("execution(* com.xxx.anomaly..*.*(..))")
    public void pointCut(){};

    @Before(value = "pointCut()")
    public void beforeInvoke(JoinPoint joinpoint) {
        try {
            String clazzName = joinpoint.getTarget().getClass().getName();
            String methodName = joinpoint.getSignature().getName();
            if(clazzName.startsWith(PACKAGE)) {  // 防止第三方jar包的动态代理影响(如mybatis)
                Class targetClazz = Class.forName(clazzName);
                Method[] methods = targetClazz.getMethods();
                for(Method method : methods) {
                    if(method.getName().equals(methodName)) {
                        if(method.isAnnotationPresent(Clickhouse.class)) {
                            DatasourceType.setDataBaseType(DatasourceType.DataBaseType.CLICKHOUSE);
                        } else {
                            DatasourceType.setDataBaseType(DatasourceType.DataBaseType.DEFAULT);
                        }
                        break;
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

4. DatasourceType

java 复制代码
public class DatasourceType {

    public enum DataBaseType {
        CLICKHOUSE,DEFAULT
    }

    // 使用ThreadLocal保证线程安全
    private static final ThreadLocal<DataBaseType> TYPE = new ThreadLocal<DataBaseType>();

    // 往当前线程里设置数据源类型
    public static void setDataBaseType(DataBaseType dataBaseType) {
        if (dataBaseType == null) {
            throw new NullPointerException();
        }
        //System.err.println("[将当前数据源改为]:" + dataBaseType);
        TYPE.set(dataBaseType);
    }

    // 获取数据源类型
    public static DataBaseType getDataBaseType() {
        DataBaseType dataBaseType = TYPE.get() == null ? DataBaseType.DEFAULT : TYPE.get();
        //System.err.println("[获取当前数据源的类型为]:" + dataBaseType);
        return dataBaseType;
    }

    // 清空数据类型
    public static void clearDataBaseType() {
        TYPE.remove();
    }
}

5. DynamicDataSource

java 复制代码
@Service("dynamicDataSource")
public class DynamicDataSource extends AbstractRoutingDataSource {

    @PostConstruct
    public void init() {
        targetDataSource.put(DatasourceType.DataBaseType.DEFAULT, defaultDatasource);
        if(clickhouse != null) {
            targetDataSource.put(DatasourceType.DataBaseType.CLICKHOUSE, clickhouse);
        }
    }

    @Override
    protected DataSource determineTargetDataSource() {
        // 获取数据源名称
        Object dbName = (Object) determineCurrentLookupKey();
        if(dbName == null) {
            return defaultDatasource;
        }
        if(targetDataSource.get(dbName) == null) {
           		// 获取Clickhouse连接参数
                return balancedClickhouseDataSource;
            } else {
                LOGGER.error("Clickhouse数据源未配置");
                return null;
            }
        } else {
            return (DataSource) targetDataSource.get(dbName);
        }
    }

    @Override
    protected Object determineCurrentLookupKey() {
        DatasourceType.DataBaseType dataBaseType = DatasourceType.getDataBaseType();
        return dataBaseType;
    }

    @Override
    public void afterPropertiesSet() {

    }

    public void removeDatasouce(Object dbName) {
        if(targetDataSource.containsKey(dbName)) {
            targetDataSource.remove(dbName);
        }
    }

    public DataSource getDefaultDatasource() {
        try {
            DataSource dataSource = (DataSource) targetDataSource.get(DatasourceType.DataBaseType.DEFAULT);
            return dataSource;
        } catch (Exception e) {
            LOGGER.error(e.getMessage(), e);
            return null;
        }
    }
}

6. SessionFactory

java 复制代码
@Configuration
@MapperScan(basePackages = {"com.xxx.anomaly.**.mapper"}, sqlSessionFactoryRef = "SqlSessionFactory")
public class SessionFactory {

    @Autowired
    private DynamicDataSource dynamicDataSource;

    @Bean("defaultTransactionManager")
    @Primary
    public DataSourceTransactionManager defaultTransactionManager() {
        return new DataSourceTransactionManager(dynamicDataSource);
    }

    @Bean(name = "SqlSessionFactory")
    public MybatisSqlSessionFactoryBean sqlSessionFactory()
            throws Exception {
        MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
        sessionFactory.setDataSource(dynamicDataSource);
        MybatisConfiguration configuration = new MybatisConfiguration();
        configuration.setMapUnderscoreToCamelCase(true);
        configuration.setCallSettersOnNulls(true);
        sessionFactory.setConfiguration(configuration);
        if (DatabaseUtil.isGuanEnv()) {
            sessionFactory.setPlugins(new Interceptor[]{new PaginationInterceptor().setDialectType("postgresql"),new MybatisLikeSqlInterceptor()}); // 分页插件
        } else {
            sessionFactory.setPlugins(new Interceptor[]{new PaginationInterceptor(),new MybatisLikeSqlInterceptor()}); // 分页插件
        }
        sessionFactory.setPlugins(new Interceptor[]{new PaginationInterceptor()}); // 分页插件
        sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:config/dao/**/*.xml"));
        return sessionFactory;
    }
}

一、核心架构概述

本项目采用 动态数据源路由 架构,支持在运行时根据业务需求自动切换 MySQL(默认)和 ClickHouse 数据源。该架构通过 AOP 切面和 Spring 的 AbstractRoutingDataSource 实现透明的数据源切换,无需业务代码显式处理数据源选择逻辑。

1.1 关键组件及职责

1. ClickhouseDatasource (配置类)

  • 职责:初始化并注册数据源 Bean
  • 创建的 Bean
    • defaultDatasource:主数据源(MySQL/PostgreSQL),通过 spring.datasource.* 配置
    • clickhouse:ClickHouse 数据源,优先从数据库表 ums_sys_datasource_config 读取配置

2. DynamicDataSource (继承 AbstractRoutingDataSource)

  • 职责:实现运行时数据源路由逻辑
  • 核心功能
    • @PostConstruct 阶段将已创建的数据源缓存到 targetDataSource Map 中
    • 支持懒加载机制:首次访问 ClickHouse 时若缓存未命中,会实时查询配置并动态创建数据源
    • 通过 determineCurrentLookupKey() 方法读取 ThreadLocal 中的数据源类型标识

3. DatasourceAop (AOP 切面) + @Clickhouse (注解)

  • 职责:拦截业务方法调用,根据注解设置数据源类型
  • 拦截范围com.xxx.anomaly..* 包下的所有方法
  • 切换逻辑
    • 方法标注 @Clickhouse 注解 → 设置 ThreadLocal 为 CLICKHOUSE
    • 方法未标注注解 → 设置 ThreadLocal 为 DEFAULT
  • 线程隔离 :通过 ThreadLocal 保证多线程环境下数据源选择互不干扰

4. SessionFactory (配置类)

  • 职责:配置 MyBatis 集成和事务管理
  • 核心功能
    • DynamicDataSource 注入到 MyBatis 的 SqlSessionFactory
    • 配置 DataSourceTransactionManager 事务管理器

二、数据源加载完整流程

2.1 阶段 1:Spring Boot 启动与默认数据源配置绑定

此阶段完成默认数据源(MySQL/PostgreSQL)的初始化:

java 复制代码
Spring Boot 应用启动
    ↓
EnvironmentPostProcessor 处理配置文件(application.yml)
    ↓
Binder 绑定 spring.datasource.* 属性到数据源配置对象
    ↓
DataSourceBuilder 自动创建 defaultDatasource Bean
    (默认优先选择 HikariDataSource 作为连接池实现)

关键源码位置:

  • ClickhouseDatasource.java 第 63-68 行

技术说明:

  • Spring Boot 的自动配置机制会根据 classpath 中的依赖自动选择连接池实现
  • HikariCP 是 Spring Boot 2.x 默认的高性能连接池,1.x 中需要额外添加该依赖

2.2 阶段 2:ClickHouse 数据源动态创建

此阶段根据数据库配置表或配置文件创建 ClickHouse 数据源:

sql 复制代码
Spring 容器初始化 @Configuration 类
    ↓
执行 clickhouse() Bean 方法
    ↓
使用 defaultDatasource 查询配置表
    ↓
SELECT * FROM ums_sys_datasource_config 
WHERE moudle_name='general' AND status=1
    ↓
根据配置优先级决定数据源配置来源
    ↓
创建 BalancedClickhouseDataSource 实例
    ├─ 设置连接池参数(最大连接数、超时等)
    ├─ scheduleActualization(10s) - 定期刷新节点状态
    └─ withConnectionsCleaning(10s) - 定期清理无效连接

关键源码位置:

  • ClickhouseDatasource.java 第 70-113 行

配置优先级(从高到低):

  1. 数据库表 ums_sys_datasource_config 中的配置
  2. application.yml 中的 spring.clickhouse.* 静态配置
  3. 若以上均无配置,则返回 null(ClickHouse 数据源不可用)

设计优势:

  • 降级机制:优先使用数据库配置,退而使用配置文件,保证灵活性

2.3 阶段 3:动态数据源初始化与缓存

此阶段将所有数据源注册到 DynamicDataSource 的内部缓存中:

swift 复制代码
DynamicDataSource Bean 创建
    ↓
@PostConstruct init() 方法执行
    ↓
targetDataSource.put(DEFAULT, defaultDatasource) - 注册默认数据源
    ↓
if (clickhouse != null)
    targetDataSource.put(CLICKHOUSE, clickhouse) - 注册 ClickHouse 数据源
    ↓
setTargetDataSources(targetDataSource) - 设置到父类
    ↓
afterPropertiesSet() - 完成初始化

关键源码位置:

  • DynamicDataSource.java 第 61-67 行

技术细节:

  • targetDataSource 是一个 Map,key 为数据源类型枚举,value 为实际的 DataSource 对象
  • 此阶段完成后,数据源已就绪,等待运行时路由调用

2.4 阶段 4:运行时数据源动态路由

此阶段是核心业务逻辑,每次方法调用时都会执行数据源路由判断:

sql 复制代码
业务方法调用
    ↓
DatasourceAop 切面拦截(@Before 通知)
    ↓
检查方法是否标注 @Clickhouse 注解
    ├─ 有注解:DatasourceType.set(CLICKHOUSE) → 写入 ThreadLocal
    └─ 无注解:DatasourceType.set(DEFAULT) → 写入 ThreadLocal
    ↓
MyBatis 执行 Mapper 方法
    ↓
SqlSessionFactory 需要获取数据库连接
    ↓
调用 DynamicDataSource.determineCurrentLookupKey()
    └─ 从 ThreadLocal 读取数据源类型(CLICKHOUSE 或 DEFAULT)
    ↓
调用 DynamicDataSource.determineTargetDataSource()
    └─ 根据数据源类型从 targetDataSource 缓存查找
    ↓
缓存查找结果判断
    ├─ 缓存命中:直接返回已缓存的数据源对象
    └─ 缓存未命中且类型为 CLICKHOUSE(懒加载场景):
        ├─ 查询数据库配置表 ums_sys_datasource_config
        ├─ 创建新的 BalancedClickhouseDataSource 实例
        ├─ 放入 targetDataSource 缓存
        └─ 返回新创建的数据源
    ↓
从目标数据源获取 Connection 对象
    ↓
MyBatis 通过 Connection 执行 SQL 语句
    ↓
SQL 路由到对应数据库(MySQL 或 ClickHouse)

关键源码位置:

  • DatasourceAop.java 第 31-54 行(AOP 拦截逻辑)
  • DynamicDataSource.java 第 70-120 行(数据源路由与懒加载逻辑)

技术要点:

  • ThreadLocal 隔离:每个线程独立维护数据源类型,保证多线程环境下互不干扰
  • 懒加载机制:ClickHouse 数据源支持运行时动态创建
  • 透明路由:业务代码无感知,仅通过注解控制数据源选择

2.5 时序图

sequenceDiagram autonumber participant Boot as Spring Boot participant Cfg as ClickhouseDatasource participant Dyn as DynamicDataSource participant AOP as DatasourceAop participant MyB as MyBatis participant DB as MySQL participant CH as ClickHouse Boot->>Cfg: 加载 @Configuration Cfg->>DB: 查询 ums_sys_datasource_config DB-->>Cfg: 返回 CH 配置 Cfg->>Cfg: 创建 BalancedClickhouseDataSource Cfg-->>Boot: 注册 clickhouse Bean Boot->>Dyn: 创建 DynamicDataSource Dyn->>Dyn: @PostConstruct 初始化缓存 Dyn-->>Boot: 初始化完成 Note over AOP,MyB: === 运行时调用 === AOP->>AOP: 拦截方法调用 alt 方法有 @Clickhouse AOP->>Dyn: ThreadLocal.set(CLICKHOUSE) else 无注解 AOP->>Dyn: ThreadLocal.set(DEFAULT) end MyB->>Dyn: getConnection() Dyn->>Dyn: determineTargetDataSource() alt 缓存命中 Dyn-->>MyB: 返回已缓存数据源 else 缓存未命中 (懒加载) Dyn->>DB: 再次查询配置 Dyn->>Dyn: 创建数据源并缓存 Dyn-->>MyB: 返回新数据源 end alt 使用 DEFAULT MyB->>DB: 执行 SQL else 使用 CLICKHOUSE MyB->>CH: 执行 SQL end

时序图流程详解

阶段一:Spring Boot 应用启动与初始化(步骤 1-8)

  1. 加载配置类 :Spring Boot 启动时扫描并加载 @Configuration 注解的 ClickhouseDatasource 配置类
  2. 查询 ClickHouse 配置ClickhouseDatasource Bean 方法执行,使用默认数据源(MySQL)查询配置表 ums_sys_datasource_config,获取 ClickHouse 的连接参数
  3. 返回配置信息:数据库返回 ClickHouse 的 JDBC URL、用户名、密码、连接池参数等配置
  4. 创建 ClickHouse 数据源 :根据查询到的配置创建 BalancedClickhouseDataSource 实例,并设置连接池参数(最大连接数、超时时间等)
  5. 注册 Bean :将创建好的 ClickHouse 数据源注册为 Spring Bean(名称:clickhouse
  6. 创建动态数据源 :Spring 容器创建 DynamicDataSource Bean
  7. 初始化数据源缓存DynamicDataSource@PostConstruct 方法执行,将 defaultDatasourceclickhouse 两个数据源放入内部 targetDataSource 缓存 Map 中
  8. 初始化完成:所有数据源初始化完成,应用启动成功,进入就绪状态

阶段二:运行时动态数据源路由(步骤 9-22)

  1. 拦截方法调用 :业务方法被调用时,DatasourceAop 切面拦截 com.xxx.anomaly..* 包下的所有方法 10-12. 判断注解并设置数据源类型
    • 如果方法标注了 @Clickhouse 注解 → 通过 ThreadLocal 设置数据源类型为 CLICKHOUSE
    • 如果方法未标注注解 → 设置数据源类型为 DEFAULT(MySQL)
  2. 获取数据库连接 :MyBatis 通过 DynamicDataSource 请求获取数据库连接
  3. 确定目标数据源DynamicDataSource.determineTargetDataSource() 方法根据 ThreadLocal 中的数据源类型标识查找实际数据源 15-17. 缓存命中场景
  • targetDataSource 缓存中查找对应数据源
  • 如果缓存命中,直接返回已缓存的数据源实例(常规场景) 18-20. 懒加载场景(缓存未命中):
  • 如果缓存中没有 ClickHouse 数据源(例如配置动态更新后缓存被清除)
  • 再次查询数据库配置表获取最新配置
  • 创建新的 ClickHouse 数据源实例
  • 将新数据源放入缓存,避免重复创建
  • 返回新数据源给 MyBatis 21-22. 执行 SQL 语句
  • 如果使用 DEFAULT 数据源 → SQL 语句路由到 MySQL 执行
  • 如果使用 CLICKHOUSE 数据源 → SQL 语句路由到 ClickHouse 执行

架构设计亮点:

  • 懒加载机制:ClickHouse 数据源支持懒加载,首次访问或配置更新后会动态创建,提高灵活性
  • ThreadLocal 线程隔离:通过 ThreadLocal 保证多线程环境下不同线程的数据源选择互不干扰
  • 配置热更新支持 :通过 removeDatasouce() 方法清除缓存,下次访问时自动加载最新配置,无需重启应用
  • 透明路由:业务代码无需关心数据源切换逻辑,仅通过注解声明式控制

三、已知问题与风险分析

3.1 问题 1:ThreadLocal 内存泄漏风险

问题根源

查看当前 DatasourceAop.java 的实现:

java 复制代码
@Before(value = "pointCut()")
public void beforeInvoke(JoinPoint joinpoint) {
    // ... 省略其他代码
    if (method.isAnnotationPresent(Clickhouse.class)) {
        DatasourceType.setDataBaseType(DataBaseType.CLICKHOUSE);
    } else {
        DatasourceType.setDataBaseType(DataBaseType.DEFAULT);
    }
}

代码缺陷分析:

  • ✅ 使用 @Before 通知在方法执行前设置数据源类型
  • 缺少清理机制 :没有对应的 @After@AfterReturning / @AfterThrowing 清理 ThreadLocal
  • 未调用清理方法 :虽然 DatasourceType.clearDataBaseType() 方法已定义,但从未被调用

问题危害与场景分析

在 Web 应用的线程池环境(如 Tomcat 线程池)中,线程会被复用,导致以下问题:

场景时序:

sql 复制代码
时刻 T1:线程 Thread-1 执行标注 @Clickhouse 的方法
    → ThreadLocal 被设置为 CLICKHOUSE
    → SQL 正确路由到 ClickHouse 执行 ✅
    → 方法执行完毕,但 ThreadLocal 未清理 ❌
    → 线程返回线程池

时刻 T2:线程 Thread-1 被复用,执行未标注 @Clickhouse 的正常方法
    → AOP 拦截,将 ThreadLocal 设置为 DEFAULT
    → SQL 正确路由到 MySQL ✅
    → 看似正常运行

时刻 T3:线程 Thread-1 再次被复用,执行未标注注解的方法
    → 但前一次请求因异常中断,AOP 的 @Before 未执行
    → ThreadLocal 中残留上次的 CLICKHOUSE 标识 ❌
    → 本应路由到 MySQL 的业务请求误路由到 ClickHouse
    → 导致严重问题:
        ✗ 查询失败(ClickHouse 中不存在对应的业务表)
        ✗ 数据写入错误的数据库
        ✗ 事务管理异常
        ✗ 数据一致性被破坏

风险等级:高 - 可能导致数据错误和业务异常


3.2 问题 2:并发场景下的数据源缓存竞态条件

问题根源

查看 DynamicDataSource.determineTargetDataSource() 懒加载逻辑(第 77-111 行):

java 复制代码
if(targetDataSource.get(dbName) == null) {
    // 通过数据库获取 ClickHouse 数据源的配置并创建数据源
    JdbcTemplate jdbcTemplate = new JdbcTemplate();
    // ... 查询数据库配置
    BalancedClickhouseDataSource balancedClickhouseDataSource = new BalancedClickhouseDataSource(dbUrl, properties);
    targetDataSource.put(DatasourceType.DataBaseType.CLICKHOUSE, balancedClickhouseDataSource);
    return balancedClickhouseDataSource;
}

代码缺陷分析:

  • targetDataSource 使用普通的 HashMap(非线程安全容器)
  • 懒加载逻辑无同步控制:多线程并发访问时无锁保护
  • Check-Then-Act 竞态条件get(dbName) == null 判断与 put() 操作之间非原子

并发问题场景:

yaml 复制代码
时刻 T0:ClickHouse 数据源缓存为空

并发线程 A:
    T1: if(targetDataSource.get(CLICKHOUSE) == null) → true
    T2: 开始创建 BalancedClickhouseDataSource A

并发线程 B:
    T1: if(targetDataSource.get(CLICKHOUSE) == null) → true (同时判断为 null)
    T2: 开始创建 BalancedClickhouseDataSource B

    T3: 线程 B 先完成,put(CLICKHOUSE, dataSourceB)
    T4: 线程 A 后完成,put(CLICKHOUSE, dataSourceA) → 覆盖 B

导致的问题:

  1. 重复创建数据源 :多个线程同时判断为 null 后都创建数据源实例
  2. 连接池泄漏 :被覆盖的 BalancedClickhouseDataSource 对象未正确关闭,连接资源无法释放
  3. 连接池耗尽:每次创建都建立独立的连接池,快速消耗数据库连接数
  4. 内存浪费:重复创建的数据源对象占用堆内存

风险等级:中高 - 在高并发场景下可能导致资源耗尽


3.3 问题 3:事务边界内的数据源切换限制

问题描述

Spring 的 DataSourceTransactionManager 在事务开始时获取并绑定数据库连接,事务期间无法切换数据源。

问题场景

在同一个事务内尝试混用两种数据源:

java 复制代码
@Transactional
public void mixedTransaction() {
    // 1. 事务开始,获取 MySQL 连接并绑定到当前线程
    userMapper.insert(user);  // 路由到 MySQL ✅
    
    // 2. 调用标注 @Clickhouse 的方法
    logToClickhouse();  // 期望路由到 ClickHouse,但实际仍使用 MySQL 连接 ❌
    
    // 3. 继续 MySQL 操作
    orderMapper.insert(order);  // 仍使用同一个 MySQL 连接 ✅
}

@Clickhouse
public void logToClickhouse() {
    // ThreadLocal 被设置为 CLICKHOUSE
    // 但事务已绑定 MySQL 连接,无法切换
    logMapper.insert(log);  // 实际仍在 MySQL 执行!
}

问题根本原因:

  • DataSourceTransactionManager 在事务开始时调用 DataSource.getConnection() 获取连接
  • 连接通过 TransactionSynchronizationManager 绑定到当前线程
  • 事务期间,所有 SQL 操作都使用这个已绑定的连接,即使 ThreadLocal 数据源类型发生变化

导致的问题:

  • SQL 路由到错误的数据源(期望 ClickHouse,实际 MySQL)
  • 查询/插入失败(表不存在)
  • 业务逻辑错误(数据写入错误的库)

风险等级:中 - 业务代码设计不当时会触发


3.4 问题 4:方法匹配逻辑缺陷(方法重载场景)

问题根源

AOP 切面中通过反射匹配方法,仅使用方法名判断:

java 复制代码
for(Method method : methods) {
    if(method.getName().equals(methodName)) {  // ⚠️ 仅按名称匹配,未比较参数类型
        if(method.isAnnotationPresent(Clickhouse.class)) {
            // 设置数据源类型
        }
        break;  // 找到第一个同名方法即退出
    }
}

代码缺陷分析:

  • 仅比较方法名:未比较参数类型和数量,无法区分重载方法
  • 首个匹配即退出 :使用 break 语句,如果目标方法是第二个重载版本,会匹配到错误的方法
  • 注解丢失 :匹配到错误的重载方法时,可能读取不到正确的 @Clickhouse 注解

问题场景示例:

java 复制代码
public class UserService {
    // 方法 1:无注解,路由到 MySQL
    public List<User> query(String id) {
        return userMapper.selectById(id);
    }
    
    // 方法 2:有注解,路由到 ClickHouse
    @Clickhouse
    public List<User> query(String id, String type) {
        return userMapper.selectByIdAndType(id, type);
    }
}

错误流程:

kotlin 复制代码
业务调用:query("user123", "VIP")
    ↓
AOP 拦截:methodName = "query"
    ↓
反射遍历:找到第一个名为 "query" 的方法(方法 1)
    ↓
检查注解:方法 1 没有 @Clickhouse 注解
    ↓
设置数据源:DatasourceType.set(DEFAULT) ❌ 错误!应该是 CLICKHOUSE
    ↓
结果:ClickHouse 查询被误路由到 MySQL

风险等级:中 - 使用方法重载时会触发

3.5 问题 5:ClickHouse 数据源不应纳入事务管理

问题描述

查看当前 SessionFactory.java 的事务管理器配置(第 31-35 行):

java 复制代码
@Bean("defaultTransactionManager")
@Primary
public DataSourceTransactionManager defaultTransactionManager() {
    return new DataSourceTransactionManager(dynamicDataSource);  // ⚠️ 使用动态数据源
}

核心问题:

  • 事务管理器注册时使用的是 DynamicDataSource(包含 MySQL 和 ClickHouse 两种数据源)
  • 这意味着 所有通过动态数据源路由的操作都会被纳入事务管理,包括 ClickHouse 的查询和写入
  • ClickHouse 作为 OLAP 数据库,不支持传统的 ACID 事务,强制事务管理会带来副作用

问题分析

1. ClickHouse 的事务特性与 OLTP 数据库的本质差异

ClickHouse 是 OLAP(Online Analytical Processing,联机分析处理)数据库,设计目标:

  • 高吞吐量的批量数据写入
  • 快速的聚合查询和大规模数据分析
  • 列式存储优化,适合宽表和复杂聚合

MySQL 是 OLTP(Online Transaction Processing,联机事务处理)数据库,设计目标:

  • 高并发的小事务处理
  • ACID 事务保证(原子性、一致性、隔离性、持久性)
  • 行式存储优化,适合频繁的增删改查

ClickHouse 的事务支持情况:

特性 MySQL(OLTP) ClickHouse(OLAP)
BEGIN/COMMIT/ROLLBACK ✅ 完全支持 ❌ 不支持
行级锁 ✅ 支持 ❌ 仅支持表级和分区级锁
即时一致性 ✅ 支持 ❌ 最终一致性模型
单语句原子性 ✅ 支持 ✅ 支持(INSERT 是原子的)
跨语句事务 ✅ 支持 ❌ 不支持
幂等写入 需应用层保证 ✅ 通过 ReplicatedMergeTree 支持

2. 事务管理器对 ClickHouse 的副作用

DataSourceTransactionManager 管理 ClickHouse 连接时:

java 复制代码
@Transactional
public void queryClickhouseData() {
    // 事务管理器会尝试执行(但 ClickHouse 不支持):
    // 1. connection.setAutoCommit(false)  ← ClickHouse JDBC 驱动会忽略
    // 2. 执行业务 SQL
    // 3. connection.commit()              ← 无实际作用,数据已立即写入
    // 4. 异常时 connection.rollback()    ← 无法回滚已执行的查询/写入
}

导致的问题:

  • 连接资源浪费:事务管理器会保持连接打开直到事务结束,但 ClickHouse 查询通常毫秒级完成
  • 连接池耗尽风险:长事务场景下(如批处理),ClickHouse 连接被长时间占用,导致连接池耗尽
  • 语义混淆:开发人员可能误以为 ClickHouse 支持回滚,编写错误的业务逻辑
  • 性能损耗:不必要的事务管理调用(setAutoCommit、commit等)增加开销
  • 假性安全感@Transactional 注解无法保证 ClickHouse 操作的原子性

3. 实际使用场景分析

典型的 ClickHouse 使用模式:

java 复制代码
// ✅ 场景 1:纯查询操作(只读,不需要事务)
@Clickhouse
public List<LogEntry> queryLogs(String userId) {
    return logMapper.selectByUserId(userId);  // 查询操作,无需事务保护
}

// ✅ 场景 2:批量写入(INSERT 本身是原子的)
@Clickhouse
public void batchInsertLogs(List<LogEntry> logs) {
    logMapper.batchInsert(logs);  // 单个 INSERT 语句是原子操作
}

// ❌ 场景 3:混合操作(错误示例 - 不应在同一事务中混用)
@Transactional
public void processOrder(Order order) {
    orderMapper.insert(order);          // MySQL - 需要事务
    logToClickhouse(order.getId());     // ClickHouse - 不需要事务,且无法参与 MySQL 事务
}

风险等级:中低 - 影响性能和资源利用,但通常不会导致功能性错误


四、问题修复方案

4.1 修复方案 1:使用 @Around 环绕通知确保 ThreadLocal 清理(必须修复)

目标:解决 ThreadLocal 内存泄漏和线程污染问题

修改文件DatasourceAop.java

java 复制代码
@Aspect
@Order(-1) // 保证优先级在 AOP 前
@Component
public class DatasourceAop {

    private static final String PACKAGE = "com.xxx.anomaly";

    @Pointcut("execution(* com.xxx.anomaly..*.*(..))")
    public void pointCut(){};

    // 将 @Before 改为 @Around,确保清理
    @Around(value = "pointCut()")
    public Object aroundInvoke(ProceedingJoinPoint joinpoint) throws Throwable {
        // 保存原数据源类型(支持嵌套调用场景)
        DatasourceType.DataBaseType originalType = DatasourceType.getDataBaseType();
        
        try {
            // 获取目标类和方法信息
            String clazzName = joinpoint.getTarget().getClass().getName();
            String methodName = joinpoint.getSignature().getName();
            
            // 仅处理指定包下的方法
            if(clazzName.startsWith(PACKAGE)) {
                Class targetClazz = Class.forName(clazzName);
                Method[] methods = targetClazz.getMethods();
                
                // 遍历方法,查找匹配的方法并检查 @Clickhouse 注解
                for(Method method : methods) {
                    if(method.getName().equals(methodName)) {
                        if(method.isAnnotationPresent(Clickhouse.class)) {
                            // 设置为 ClickHouse 数据源
                            DatasourceType.setDataBaseType(DatasourceType.DataBaseType.CLICKHOUSE);
                        } else {
                            // 设置为默认数据源(MySQL)
                            DatasourceType.setDataBaseType(DatasourceType.DataBaseType.DEFAULT);
                        }
                        break;
                    }
                }
            }
            
            // 执行目标方法
            return joinpoint.proceed();
            
        } finally {
            // 【关键修复】:方法执行完毕后恢复原数据源类型
            // 如果是顶层调用(originalType == null),则清理 ThreadLocal
            // 如果是嵌套调用,则恢复为上层的数据源类型
            if (originalType == null) {
                DatasourceType.clearDataBaseType();  // 清理 ThreadLocal,防止内存泄漏
            } else {
                DatasourceType.setDataBaseType(originalType);  // 恢复嵌套调用的数据源
            }
        }
    }
}

修复效果:

  • ✅ 使用 finally 块确保 ThreadLocal 一定会被清理,即使方法抛出异常
  • ✅ 支持嵌套调用:保存并恢复原数据源类型
  • ✅ 防止线程污染:线程归还线程池时不会携带残留的数据源标识

4.2 修复方案 2:数据源缓存加锁(必须修复)

目标:解决并发场景下的竞态条件,防止重复创建数据源和连接泄漏

修改文件DynamicDataSource.java

java 复制代码
@Service("dynamicDataSource")
public class DynamicDataSource extends AbstractRoutingDataSource {

    private static final Logger LOGGER = LoggerFactory.getLogger(DynamicDataSource.class);

    // 【修复1】:改为线程安全的 ConcurrentHashMap
    private Map<Object, Object> targetDataSource = new ConcurrentHashMap<>();
    
    // 【修复2】:添加锁对象,用于懒加载的同步控制
    private final Object clickhouseLock = new Object();

    @Override
    protected DataSource determineTargetDataSource() {
        Object dbName = determineCurrentLookupKey();
        if(dbName == null) {
            return defaultDatasource;
        }
        
        // 先尝试从缓存获取(快速路径,无锁)
        DataSource cachedDs = (DataSource) targetDataSource.get(dbName);
        if (cachedDs != null) {
            return cachedDs;
        }
        
        // 缓存未命中且需要 ClickHouse,进入懒加载流程
        if (DatasourceType.DataBaseType.CLICKHOUSE.equals(dbName)) {
            return getOrCreateClickhouseDataSource();
        }
        
        // 默认返回 MySQL 数据源
        return defaultDatasource;
    }
    
    // 【修复3】:使用双重检查锁定(Double-Checked Locking)模式创建 ClickHouse 数据源
    private DataSource getOrCreateClickhouseDataSource() {
        // 第一次检查(无锁,提高性能)
        DataSource ds = (DataSource) targetDataSource.get(DatasourceType.DataBaseType.CLICKHOUSE);
        if (ds != null) {
            return ds;
        }
        
        // 加锁创建数据源
        synchronized (clickhouseLock) {
            // 第二次检查(防止重复创建)
            ds = (DataSource) targetDataSource.get(DatasourceType.DataBaseType.CLICKHOUSE);
            if (ds != null) {
                return ds;
            }
            
            // 通过数据库获取 ClickHouse 数据源的配置并创建数据源
            // ... 查询配置、创建数据源的代码
            // BalancedClickhouseDataSource newDs = new BalancedClickhouseDataSource(dbUrl, properties);
            // targetDataSource.put(DatasourceType.DataBaseType.CLICKHOUSE, newDs);
            // return newDs;
            ......
        }
    }
}

修复效果:

  • ✅ 使用 ConcurrentHashMap 替代 HashMap,保证基本的线程安全
  • ✅ 双重检查锁定模式:第一次无锁检查提高性能,加锁后再次检查防止重复创建
  • ✅ 消除竞态条件:确保同一时刻只有一个线程创建 ClickHouse 数据源
  • ✅ 防止连接泄漏:不会因并发导致多个数据源实例被创建后覆盖

4.3 修复方案 3:增强 removeDatasouce 方法(推荐修复)

目标:正确关闭旧数据源,防止资源泄漏

修改文件DynamicDataSource.java

java 复制代码
public void removeDatasouce(Object dbName) {
    // 使用与懒加载相同的锁,确保线程安全
    synchronized (clickhouseLock) {
        if(targetDataSource.containsKey(dbName)) {
            // 从缓存中移除数据源
            DataSource oldDs = (DataSource) targetDataSource.remove(dbName);
            
            // 如果是 BalancedClickhouseDataSource,需要正确关闭以释放资源
            if (oldDs instanceof BalancedClickhouseDataSource) {
                try {
                    ((BalancedClickhouseDataSource) oldDs).close();
                    LOGGER.info("已关闭旧的 ClickHouse 数据源,释放连接池资源");
                } catch (Exception e) {
                    LOGGER.error("关闭 ClickHouse 数据源失败,可能导致连接泄漏", e);
                }
            }
        }
    }
}

修复效果:

  • ✅ 加锁保护:与懒加载使用同一个锁,避免删除与创建的并发冲突
  • ✅ 资源释放:正确关闭旧数据源,释放连接池资源
  • ✅ 异常处理:捕获关闭异常并记录日志,不影响主流程

4.4 修复方案 4:优化方法匹配逻辑(替代方案)

目标:解决方法重载场景下的注解匹配错误

修改文件DatasourceAop.java

java 复制代码
@Aspect
@Order(-1)
@Component
public class DatasourceAop {

    private static final String PACKAGE = "com.xxx.anomaly";

    @Pointcut("execution(* com.xxx.anomaly..*.*(..))")
    public void pointCut(){};

    @Around(value = "pointCut()")
    public Object aroundInvoke(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            // 1. 切换数据源
            switchDataSource(joinPoint);
            
            // 2. 执行目标方法
            return joinPoint.proceed();
            
        } finally {
            // 3. 清理 ThreadLocal(防止内存泄漏)
            DatasourceType.clearDataBaseType();
        }
    }

    private void switchDataSource(ProceedingJoinPoint joinPoint) {
        try {
            // 【优化】:直接通过 MethodSignature 获取方法对象(避免复杂的反射遍历)
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            
            // 检查方法是否标注 @Clickhouse 注解
            if (method.isAnnotationPresent(Clickhouse.class)) {
                DatasourceType.setDataBaseType(DatasourceType.DataBaseType.CLICKHOUSE);
            } else {
                DatasourceType.setDataBaseType(DatasourceType.DataBaseType.DEFAULT);
            }
            
        } catch (Exception e) {
            // 异常时降级到默认数据源,保证系统可用性
            log.error("数据源切换失败,降级使用默认数据源(MySQL)", e);
            DatasourceType.setDataBaseType(DatasourceType.DataBaseType.DEFAULT);
        }
    }
}

修复效果:

  • ✅ 使用 finally 确保 ThreadLocal 一定会被清理
  • ✅ 直接通过 MethodSignature 获取方法对象,避免复杂的反射遍历和方法重载问题
  • ✅ 异常时自动降级到默认数据源,保证系统可用性
  • ✅ 使用日志框架记录错误(替代 printStackTrace()

注意 :此方案使用 MethodSignature.getMethod() 直接获取实际调用的方法对象,自动解决了方法重载的匹配问题,比手动遍历 getMethods() 更可靠。


4.5 修复方案 5:事务管理器仅管理 MySQL 数据源(推荐修复)

目标:将 ClickHouse 排除在事务管理之外,避免不必要的事务开销

修改文件SessionFactory.java

java 复制代码
@Configuration
@MapperScan(basePackages = {"com.xxx.anomaly.**.mapper"}, sqlSessionFactoryRef = "SqlSessionFactory")
public class SessionFactory {
    
    // 注入单独的默认数据源(仅 MySQL)
    @Autowired
    @Qualifier("defaultDatasource")
    private DataSource defaultDatasource;

    /**
     * 事务管理器仅管理默认数据源(MySQL)
     * ClickHouse 作为 OLAP 数据库不需要事务管理
     */
    @Bean("defaultTransactionManager")
    @Primary
    public DataSourceTransactionManager defaultTransactionManager() {
        // 【关键修改】:仅使用 MySQL 数据源,不使用 DynamicDataSource
        return new DataSourceTransactionManager(defaultDatasource);
    }
}

修复效果:

  • ✅ ClickHouse 连接不再被事务管理器管理,避免不必要的事务开销
  • ✅ 连接快速释放:ClickHouse 查询完成后立即释放连接,不等待事务结束
  • ✅ 避免语义混淆:开发人员清楚知道 ClickHouse 操作不在事务保护范围内
  • ✅ 性能优化:减少事务管理调用(setAutoCommit、commit等)的开销

使用建议:

  • 对于需要事务保护的 MySQL 操作,使用 @Transactional 注解
  • 对于 ClickHouse 操作,不使用 @Transactional 注解,让其自动提交
  • 不要在同一个 @Transactional 方法中混用 MySQL 和 ClickHouse 操作

五、架构替代方案

5.1 方案概述

鉴于 MySQL 和 ClickHouse 在业务场景中通常同时使用 而非互斥使用 ,可以考虑更彻底的架构调整:为 MySQL 和 ClickHouse 分别配置独立的数据源和 MyBatis SqlSessionFactory,完全避免动态切换带来的实现复杂性和运行时风险。

5.2 方案优势

1. 架构清晰,职责分离

  • MySQL SqlSessionFactory:负责 OLTP 业务操作,支持完整的事务管理
  • ClickHouse SqlSessionFactory:负责 OLAP 分析查询,无事务管理

2. 消除已知风险

  • ✅ 无 ThreadLocal 泄漏风险(不需要 ThreadLocal)
  • ✅ 无并发竞态条件(无动态创建逻辑)
  • ✅ 无事务边界问题(两个数据源独立管理)
  • ✅ 无方法匹配问题(通过不同的 Mapper 接口区分)

3. 开发体验更好

  • Mapper 接口通过包路径或命名规则自然区分(如 *.mysql.mapper vs *.clickhouse.mapper
  • 不需要额外的 @Clickhouse 注解
  • IDE 自动补全和类型检查更友好

5.3 跨数据源数据一致性方案

对于需要同时操作 MySQL 和 ClickHouse 的场景,推荐以下一致性保证方案:

方案 A:MySQL 事务 + 异步事件通知 ClickHouse(推荐)

java 复制代码
@Transactional
public void createOrder(Order order) {
    // 1. MySQL 事务内完成业务操作
    orderMapper.insert(order);
    
    // 2. 发布领域事件(事务提交后触发)
    applicationEventPublisher.publishEvent(new OrderCreatedEvent(order));
}

@EventListener
@Async
public void syncToClickhouse(OrderCreatedEvent event) {
    // 3. 异步写入 ClickHouse(最终一致性)
    clickhouseLogMapper.insert(event.getOrder());
}

方案 B:补偿机制(适用于对一致性要求不高的场景)

  • MySQL 操作成功,ClickHouse 写入失败 → 通过定时任务或消息队列重试
  • 接受短时间的数据不一致,通过最终一致性保证

方案 C:分布式事务(不推荐)

  • 使用 Seata、XA 等分布式事务框架
  • 性能开销大,ClickHouse 不支持标准事务协议,实现困难
相关推荐
白起那么早2 小时前
我又开发了一款idea插件-ContiNewGenerator
java·后端
Python私教2 小时前
fasttushare 需求分析
后端
装不满的克莱因瓶2 小时前
【Java架构师体系课 | MySQL篇】③ Explain执行计划详解
java·数据库·mysql·架构·优化·索引·explain
爱奥尼欧2 小时前
【QT笔记】常用控件——QWidget 核⼼属性
数据库·笔记·qt
iOS开发上架哦3 小时前
接口调试从入门到精通,Fiddler抓包工具、代理配置与HTTPS抓包实战技巧
后端
q***98523 小时前
Spring Boot 中使用 @Transactional 注解配置事务管理
数据库·spring boot·sql
leonardee3 小时前
Golang笔记——Interface类型
java·后端
数据知道3 小时前
MySQL:mysql中将表中数据进行删除后,多久空间会被释放出来?
数据库·mysql
武子康3 小时前
大数据-155 Apache Druid 存储与查询架构实战:Segment/Chunk/Roll-up/Bitmap 一文讲清
大数据·后端·nosql