技术分享|Java SDK动态数据源和上下文机制
随着天际平台的诞生,承接了整个公司的技术底座。建模也同样需要从只支持ERP的产品线,扩充到支撑公司SaaS类业务。为了更多产品,客户能用上建模,享受低代码能力带来的便捷,我们引入Java SDK作为低代码的后端开发语言。配合上原有的.Net Core的改造,完成整个建模跨平台,跨语言开发,和容器化部署。
在实际的业务中:
1.数据量迅速增长,单库无法满足业务的需求,需要考虑分库的操作;
2.在多租户场景下,不同的租户需要使用不同的数据库来做到资源隔离;
3.在业务集中部署的情况下,不同业务本身就是连接的不同数据库,需要在调用不用的业务系统使用不同的数据库。
一般的业务需求可以根据业务规划自己做分库,但是平台层面应该考虑通用的切库方案,需要做到分库的配置化,可扩展,支持SASS等功能。
本篇着眼于 Java SDK 的相关经验,在动态数据源和上下文机制上做出一些分享和探讨。
二、常用方案
Shardingsphere
比较完整的分库分表方案,一般是通过ShardingSphere-JDBC和ShardingSphere-Proxy来处理,主要是来解决数据量大,解决应用增长带来的问题,运维成本较高,在业务体量没有达到时使用会增加较大成本。
ShardingSphere-JDBC:
ShardingSphere-Proxy:
MultiDataSource
一般来说方案如下:
1.配置文件中新增多个数据源(datasource)配置
2.在Configuration中为每个数据源注册各自的SessionFactory,TransactionManager,datasource
3.在代码中根据不同的规则,切换不同的datasource,一般有如下三种切换规则:
1)根据业务分包,不同的jar包使用不同的数据源(datasource)
2)通过注解来切换数据源(datasource),比如使用一个自定义注解,根据这个注解的内容去获取对应的数据源(datasource)
3)通过上下文来切换数据源(datasource),通过设置不同threadlocal,根据threadlocal的内容获取对应的数据源(datasource)
方案对比
分库 | 分表 | 学习成本 | 运维成本 | 扩展性 | |
---|---|---|---|---|---|
Shardingsphere | 支持 | 支持 | 高 | 高 | 高 |
MultiDataSource | 支持 | 不支持 | 低 | 低 | 低 |
三、解决方案
为了解决切库的痛点,解决SASS场景下租户数据库隔离,又不想使用如Shardingsphere这么复杂需要运维的组件的时候,我们需要一套可灵活扩展,方便且通用的切库方案,下面介绍一下我们在应用过程中的一些思路:
整体说明
如果是单独的业务系统,可以根据自己具体的业务需求设计出固定的分库逻辑,但是作为平台来讲,需要遵从通用性,可扩展,方便等原则。整体思路如下:
1.将分库规则做到可配置,这样方便用户根据业务的发展,方便调整分库规则。
2.分库规则依赖上下文进行传递,分库规则可以通过一些迭代器等设计模式实现可扩展。
3.在Service层能灵活的根据分库规则修改上下文信息,从而实现跨Service层切库逻辑(实现不同service使用不同数据库)。
4.当用户希望在某一块代码,固定使用某一个数据库的时候,可以通过注解的方式,修改上下文,进行手动切库。
切库配置化
切库配置化整体来说还是具有一定的业务属性的,简而言之,就是根据一些属性(appcode,租户编码等)去确定一个数据库连接字符串:
在建模目前是使用的配置中心来实现的,常用的场景如下:
1.在传统的ERP场景,主要是使用的应用编码分库(appcode),后续也会拓展,按照业务单元,公司ID等等做分库。
2.在SASS场景下,根据租户CODE等进行分库
我们目前是使用一个DbRouter类去做数据库选择的pojo类,用户可以通过重写该类,扩展新的类型来实现。
public class DbRouter implements Cloneable {
/**
* 运行状态
*/
private RunStatus runStatus;
/**
* 租户编码
*/
private String tenantCode;
/**
* 公司ID
*/
private String companyId;
/**
* 应用CODE
*/
private String appCode;
}
迭代器模式
一般来说分库的配置信息(DbRouter类:租户CODE,公司ID等)来源是不固定的,比如可以从Http请求的header里面传递,也可以从Http请求的参数来传递,也可以从包路径来获取,DbRouter里面的属性不是一次性获取的,属性填充是一个依次获取的内容,目前我们是采用迭代器模式来实现的。
上图描述了整个从请求进入(controller/service层),到生成上下文,到生成动态数据源的流程。
将上面的resolver在spring启动的时候注册到List<Resolver>中,用户有新的拓展,只需要实现一个新的Resolver注册到spring中即可。
封装了一个ResolverProvider方法,提供切库的方法,如下:
public class ResolverProvider {
/**
* 拦截器resolvers
*/
private List<InterceptResolver> interceptResolvers;
/**
* AOP的resolvers
*/
private List<AopResolver> aopResolvers;
/**
* 手动的resolvers
*/
private ManualResolver manualResolver;
/**
* analysisInterceptResolver 拦截器生成dbrouter
*
* @param request
*/
public void analysisRequest(HttpServletRequest request) {
interceptResolvers.stream().forEach(key -> key.generateDbRouter(dbRouter, request));
}
/**
* analysisAopResolver AOP方法生成dbrouter
*
* @return DbRouter
*/
public void analysisInvocation(MethodInvocation methodInvocation) {
aopResolvers.stream().forEach(key -> key.generateDbRouter(dbRouter, methodInvocation));
}
/**
* 手动设置dbRouter上下文
*
* @param manualRouter 手动信息
* @return 返回
*/
public DbRouter analysisDbRouter(DbRouter manualRouter) {
DbRouter dbRouter = ThreadBusinessContext.getDbRouter();
// 设置dbRouter
manualResolver.generateDbRouter(dbRouter, manualRouter);
// 返回
return dbRouter;
}
}
上下文切库逻辑
上面的实例demo可以看出,提供了InterceptResolver,AopResolver,ManualResolver几种类型的实现
InterceptResolver:基于拦截器的实现,处理HTTP请求的切库信息,埋点在param或者header中的信息
AopResolver:基于AOP的实现,处理包路径的切库信息,例如不同的包区分不同的APPCODE或者业务单元编码等
ManualResolver:手动切库,用户自定义的一些切库信息
在service层之间互相调用的时候,如果是跨服务之间的调用(RPC),当链路比较长,需要频繁的切库,为了解决这个问题,在AOP层做了一个栈的结构,通过出入栈实现了service之间调用的切库逻辑。
如下 AService BService CService 分别是不同的三个业务系统的服务,分别要连接A,B,C三个自己的库。
@Service
public class CService extends AppService {
@Autowired
private Aservice aservice;
@Autowired
private Bservice bservice;
public void changeDb(){
//切换到C库,查询C的列表
findClist();
//切换到A库,查询A的列表
aservice.findAlist();
//重新切换到C库,查询C的列表
findClist();
//切换到B库,查询B的列表
bservice.findBlist();
//重新切换到C库,查询C的列表
findClist();
}
}
可以发现,需要通过AOP做好切面,在每个service的进入和推出做好切库的操作,比如进入CService 时需要把上下文切换到C库,进入AService 后,需要把上下文切换到A库,从Aservice执行完成后,需要重新切换回CService 需要的C库,直到整个调用结束完成,是不是特别像一个出入栈的操作,因此这里我们也是通过栈来解决这个问题。再进一步发现,如果我们把这个出入栈维护好以后,我们的调用链其实也完成了。
如下图:
解决问题的场景
局限
现今 Java SDK 的实现方式存在有一定的局限性,其主要如下:
1.实现方式是依赖于栈结构,这样的话对数据的顺序有强要求,也就是说在多线程场景下会有问题(比如Aservice多线程调用Bserice,不是多线程调用service而是service内部多线程时是没有问题的);
2.现在的实现方式,dbrouter里面的内容还是通过手动编码(加字段)的形式进行拓展的(也和现在使用ERP业务相关),在平台层来看不够灵活,更好的方案应该是一个多维度可扩展的结构,dbrouter本身就是一个抽象的概念,里面的分库是可以函数式编程实现多维度的组合,需要完全做到零代码。
优化思考
整体来看目前这个方案主要还是分库,也可以解决SASS资源隔离等问题,如果说需要做到分表等,那么整体复杂度就会有很大的提高,但是随着后面SASS化,性能问题凸显的时候,我们也会考虑集成一些比较好的分库方案来实现,目前的思路是可以通过hibernate的EmptyInterceptor来实现分表的逻辑。
------ END ------
周同学: 研发工程师,目前负责建模平台相关研发工作。