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

零、代码摘要

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

1. Clickhouse


@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Clickhouse { String value() default ""; }

2. ClickhouseDatasource


@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


@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


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


@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


@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)的初始化:


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 数据源:


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 的内部缓存中:


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:运行时数据源动态路由

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


业务方法调用 ↓ 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 时序图

Spring BootClickhouseDatasourceDynamicDataSourceDatasourceAopMyBatisMySQLClickHouse=== 运行时调用 ===alt[方法有 @Clickhouse][无注解]alt[缓存命中][缓存未命中 (懒加载)]alt[使用 DEFAULT][使用 CLICKHOUSE]加载 @Configuration1查询 ums_sys_datasource_config2返回 CH 配置3创建 BalancedClickhouseDataSource4注册 clickhouse Bean5创建 DynamicDataSource6@PostConstruct 初始化缓存7初始化完成8拦截方法调用9ThreadLocal.set(CLICKHOUSE)10ThreadLocal.set(DEFAULT)11getConnection()12determineTargetDataSource()13返回已缓存数据源14再次查询配置15创建数据源并缓存16返回新数据源17执行 SQL18执行 SQL19Spring BootClickhouseDatasourceDynamicDataSourceDatasourceAopMyBatisMySQLClickHouse

时序图流程详解

阶段一: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 方法执行,将 defaultDatasource 和 clickhouse 两个数据源放入内部 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 的实现:


@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 线程池)中,线程会被复用,导致以下问题:

场景时序:


时刻 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 行):


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() 操作之间非原子

并发问题场景:


时刻 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 在事务开始时获取并绑定数据库连接,事务期间无法切换数据源。

问题场景

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


@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 切面中通过反射匹配方法,仅使用方法名判断:


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

代码缺陷分析:

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

问题场景示例:


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); } }

错误流程:


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

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

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

问题描述

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


@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 连接时:


@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 使用模式:


// ✅ 场景 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


@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


@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


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


@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


@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(推荐)


@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 不支持标准事务协议,实现困难
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值