零、代码摘要
在 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阶段将已创建的数据源缓存到targetDataSourceMap 中 - 支持懒加载机制:首次访问 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 行
配置优先级(从高到低):
- 数据库表
ums_sys_datasource_config中的配置 application.yml中的spring.clickhouse.*静态配置- 若以上均无配置,则返回
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 时序图
时序图流程详解
阶段一:Spring Boot 应用启动与初始化(步骤 1-8)
- 加载配置类 :Spring Boot 启动时扫描并加载
@Configuration注解的ClickhouseDatasource配置类 - 查询 ClickHouse 配置 :
ClickhouseDatasourceBean 方法执行,使用默认数据源(MySQL)查询配置表ums_sys_datasource_config,获取 ClickHouse 的连接参数 - 返回配置信息:数据库返回 ClickHouse 的 JDBC URL、用户名、密码、连接池参数等配置
- 创建 ClickHouse 数据源 :根据查询到的配置创建
BalancedClickhouseDataSource实例,并设置连接池参数(最大连接数、超时时间等) - 注册 Bean :将创建好的 ClickHouse 数据源注册为 Spring Bean(名称:
clickhouse) - 创建动态数据源 :Spring 容器创建
DynamicDataSourceBean - 初始化数据源缓存 :
DynamicDataSource的@PostConstruct方法执行,将defaultDatasource和clickhouse两个数据源放入内部targetDataSource缓存 Map 中 - 初始化完成:所有数据源初始化完成,应用启动成功,进入就绪状态
阶段二:运行时动态数据源路由(步骤 9-22)
- 拦截方法调用 :业务方法被调用时,
DatasourceAop切面拦截com.xxx.anomaly..*包下的所有方法 10-12. 判断注解并设置数据源类型 :- 如果方法标注了
@Clickhouse注解 → 通过ThreadLocal设置数据源类型为CLICKHOUSE - 如果方法未标注注解 → 设置数据源类型为
DEFAULT(MySQL)
- 如果方法标注了
- 获取数据库连接 :MyBatis 通过
DynamicDataSource请求获取数据库连接 - 确定目标数据源 :
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
导致的问题:
- 重复创建数据源 :多个线程同时判断为
null后都创建数据源实例 - 连接池泄漏 :被覆盖的
BalancedClickhouseDataSource对象未正确关闭,连接资源无法释放 - 连接池耗尽:每次创建都建立独立的连接池,快速消耗数据库连接数
- 内存浪费:重复创建的数据源对象占用堆内存
风险等级:中高 - 在高并发场景下可能导致资源耗尽
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.mappervs*.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 不支持标准事务协议,实现困难