〇、问题描述
有这么一个项目,底层框架(无源码jar引用)依赖oracle数据库,而业务代码需要查询另一sqlserver数据源。
为了使这个臃肿的项目跑起来,我们引入了spring的动态数据源AbstractRoutingDataSource
,将默认数据源路由设为oracle,供底层代码使用。而业务代码根据需要,可以将当前线程的数据源切换为sqlserver。
这个方案稳定运行了将近一年,但随着项目愈加臃肿,环境愈加复杂,有一天它突然不好使了...
我被委托排查这个问题。
既然项目本身就不怎么优雅,我也不寻求优雅的解决方案,能跑就行。
一、现状分析
1.1 依赖版本
没错,mybatis和hibernate都在跑( ̄ー ̄)
组件 | 版本 |
---|---|
springboot | 2.2.4.RELEASE |
spring framework | 5.2.1.RELEASE |
mybatis-plus | 3.3.0 |
mybatis | 3.5.3 |
hibernate | 5.4.10.Final |
1.2 数据源实现
![](https://img.haomeiwen.com/i24795540/56c39ea1f7817e95.png)
动态数据源的实现结构如图,组件释义如下,代码中隐去了具有业务含义的对象命名:
-
DynamicDataSource
继承自org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
,使用HashMap<DataSourceKey, javax.sql.DataSource>
存储数据源对象;
public class DynamicDataSource extends AbstractRoutingDataSource {
private Map<Object, Object> datasources;
public DynamicDataSource() {
datasources = new HashMap<>();
super.setTargetDataSources(datasources);
}
public <T extends DataSource> void addDataSource(DataSourceKey key, T data) {
datasources.put(key, data);
}
@Override
public Object determineCurrentLookupKey() {
return DataSourceHolder.getDataSourceKey();
}
@Override
public DataSource determineTargetDataSource() {
return super.determineTargetDataSource();
}
}
-
DataSourceKey
是一个枚举,定义了数据源的路由键;public enum DataSourceKey { DS1, DS2 }
-
DataSourceHolder
使用ThreadLocal<DataSourceKey>
控制当前线程的数据源路由切换;public class DataSourceHolder { private static final ThreadLocal<DataSourceKey> dataSourceKey = new ThreadLocal<>(); public static DataSourceKey getDataSourceKey() { return dataSourceKey.get(); } public static void setDataSourceKey(DataSourceKey type) { dataSourceKey.set(type); } public static void clearDataSourceKey() { dataSourceKey.remove(); } }
-
通过aop切方法注解,实现数据源的自动切换。
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {
DataSourceKey value() default DataSourceKey.DS1;
}
---
@Slf4j
@Aspect
@Order(-1)
public class DataSourceAOP {
@Before("@annotation(ds)")
public void changeDataSource(JoinPoint point, DataSource ds) {
try {
DataSourceKey dataSourceKey = ds.value();
DataSourceHolder.setDataSourceKey(dataSourceKey);
} catch (Exception e) {
log.error("数据源[{}]切换异常,目标方法:[{}]", ds.value(), point.getSignature(), e);
}
}
@After("@annotation(ds)")
public void restoreDataSource(DataSource ds) {
DataSourceHolder.clearDataSourceKey();
}
}
二、 尝试修复
2.1 尝试修正dataSource
一脸蒙圈地开始查问题,我意识到mybatis
会话管理器SqlSessionFactory
持有错误的dataSource
对象,表面现象是这样,原因不详...
既然它错了,那我就强行修正呗。这属性还不对外暴露,我只能选择反射。像这样:
Environment environment = sessionFactory.getConfiguration().getEnvironment();
Field dataSourceField = environment.getClass().getDeclaredField("dataSource");
dataSourceField.setAccessible(true);
dataSourceField.set(environment, dynamicDataSource.determineTargetDataSource());
重启,测试,没报错!完事交差,回工位喝茶了。
然而,几分钟后,同事就告诉我,sqlserver是好了,但底层的oracle开始报错了。
2.2 尝试动态代理
上面我犯了一个低级错误,sessionFactory
对象是全局单例的,我把它当单线程程序处理了,随便测两下肯定一堆错误。
思来想去,简单暴力的方式就是给sessionFactory
做一个动态代理,在获取连接配置对象时,给每一个线程拷贝一份单独的副本。像这样:
Configuration configuration = sessionFactory.getConfiguration();
final Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(configuration.getClass());
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
Object result = methodProxy.invokeSuper(o, objects);
if ("getEnvironment".equals(method.getName())) {
Environment environment = (Environment) result;
Environment environmentCopy = new Environment.Builder(environment.getId() + "." + Thread.currentThread().getId())
.dataSource(dynamicDataSource.determineTargetDataSource()).transactionFactory(environment.getTransactionFactory()).build();
return environmentCopy;
}
return result;
}
});
Configuration configurationProxy = (Configuration) enhancer.create();
BeanUtil.copyProperties(configuration, configurationProxy);
ReflectUtil.setFieldValue(sessionFactory, "configuration", configurationProxy);
实际我写的代码更多,但没卵用,因为代理后根本用不了,直接抛错...
算了,我告诉同事搞不定,他去别的服务里写了,反正暴露接口调用就能实现了︿( ̄︶ ̄)︿
三、仔细研究
回去睡了一觉,第二天仍然打开这段代码,看来简单粗暴的方法解决不了啊。
3.1 问题定位
经过仔细研究,我定位到org.springframework.transaction.support.TransactionSynchronizationManager
中也有几个ThreadLocal
,缓存了一些数据库连接和事务资源。
我切我的,他缓存他的,能好使才怪!
3.2 打补丁
优雅的解决方案我已经放弃,我决定直接覆盖同名类,能跑就行。
经过反复调试,我得出了以下解决方案:
-
动态数据源加两个方法
目的是获取默认数据源;
private Object defaultTargetDataSource; @Override public void setDefaultTargetDataSource(Object defaultTargetDataSource) { super.setDefaultTargetDataSource(defaultTargetDataSource); this.defaultTargetDataSource = defaultTargetDataSource; } public Object getDefaultTargetDataSource() { return defaultTargetDataSource; }
-
TransactionSynchronizationManager
打补丁- 当发生数据源切换,备份默认数据源相关的线程局部变量,然后清空
ThreadLocal
,这样新数据源的相关资源可以重新初始化; - 当再次切换回默认数据源,把备份的资源还原回来;
- 不备份不好使,默认数据源的线程局部变量没法重新生成,不知道为啥,不管它了,能跑就行。
- 当发生数据源切换,备份默认数据源相关的线程局部变量,然后清空
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
private static final ThreadLocal<Map<Object, Object>> resourcesBak =
new NamedThreadLocal<>("Transactional resources bak");
public static void resolveResource() {
Map<Object, Object> resource = resources.get();
if (resource == null) {
return;
}
for (Object o : resource.keySet()) {
if (!(o instanceof DynamicDataSource)) {
continue;
}
DynamicDataSource dynamicDataSource = (DynamicDataSource) o;
if (dynamicDataSource.determineTargetDataSource() != dynamicDataSource.getDefaultTargetDataSource()) {
resourcesBak.set(resource);
resources.remove();
break;
}
Map<Object, Object> resourceBak = resourcesBak.get();
if (resourceBak == null) {
break;
}
ConnectionHolder connectionHolder = (ConnectionHolder) resourceBak.get(dynamicDataSource);
try {
if (!connectionHolder.getConnection().isClosed()) {
resources.set(resourceBak);
} else {
resourcesBak.remove();
}
} catch (SQLException e) {
logger.error("connection error, connectionHolder: [{" + connectionHolder + "}]", e);
}
}
}
- 在
DataSourceHolder
里调用以上方法
public static void setDataSourceKey(DataSourceKey type) {
dataSourceKey.set(type);
TransactionSynchronizationManager.resolveResource();
}
3.3 补丁的补丁
本来清空TransactionSynchronizationManager
资源是spring的某些后置任务做的,我提前把它做了,后面会报错。
-
org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor
修改
afterCompletion
方法,资源为null不抛异常:@Override public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException { if (!decrementParticipateCount(request)) { EntityManagerHolder emHolder = (EntityManagerHolder) TransactionSynchronizationManager.unbindResource(obtainEntityManagerFactory()); logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor"); // 这里加个非空判断 if (emHolder != null) { EntityManagerFactoryUtils.closeEntityManager( emHolder.getEntityManager()); } } }
-
org.springframework.transaction.support.TransactionSynchronizationManager
修改
unbindResource
方法,资源为null不抛异常:public static Object unbindResource(Object key) { Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key); // Object value = doUnbindResource(actualKey); // if (value == null) { // throw new IllegalStateException( // "No value for key [" + actualKey + "] bound to thread [" + Thread.currentThread().getName() + "]"); // } return doUnbindResource(actualKey); }
3.4 顺便做个优化
测试了几下,应该好使了。但有些代码看着不太顺,比如底层没有提供自动配置。顺手优化下,给配置加个开关:
@Slf4j
@Configuration
@Import(DataSourceAOP.class)
@ConditionalOnProperty(name = {"project-name.datasource.dynamic.enabled"}, matchIfMissing = true)
public class DynamicDatasourceConfig {
public DynamicDatasourceConfig() {
log.info("动态数据源切面初始化, DataSourceKeys: {}", ArrayUtil.toString(DataSourceKey.values()));
}
}
完事, git commit && git push -f
,大家元旦快乐!
四、后续思考
表面上看,问题解决了,项目能跑了,但是解决方案未免太复杂了,看起来还不太稳定。
仔细回忆了一下这个项目的提交历史,初期我的确做过多数据源的集成与测试。
后来,经过与平台同事的沟通,再加上我的自信,我删除了一些看起来没啥用的平台依赖。
可能正是这个操作引起了项目环境的变化,动态数据源已经不能正常工作了,但直到最近才有需求要查询另一数据库,所以错误得以隐藏。
如果它再出问题,我就出绝招: 回退。
有时间还是重构吧,再也不想打这破补丁了(╬▔皿▔)╯**
网友评论