1、引言
随着业务的不断发展,单一数据源已经无法满足日益增长的数据存储和访问需求。本文档将介绍多数据源切换的相关技术,包括基于 JDBC、Spring JDBC 和 Sharding-JDBC 实现动态切换数据源的方法,帮助你在实际项目中应用。
2、基础概念
JDBC
JDBC是Java DataBase Connectivity的缩写,它是Java程序访问数据库的标准接口。使用Java程序访问数据库时,Java代码并不是直接通过TCP连接去访问数据库,而是通过JDBC接口来访问,而JDBC接口则通过JDBC驱动来实现真正对数据库的访问。
tips:JDBC驱动的实现使用的是桥接的设计模式
spring-jdbc
Spring框架对原生JDBC的封装,简化了数据库操作,提供了模板化编程、异常处理等高级特性。
sharding-jdbc
一个轻量级的Java库,用于实现透明化的数据库分片、读写分离以及分布式事务管理,特别适合微服务架构下的数据库水平扩展。
连接池
连接池是数据库访问中的核心组件,它通过预先创建并维护一定数量的数据库连接,减少频繁创建和销毁连接的开销,从而显著提高应用程序性能。
- Druid: 阿里巴巴开源的数据库连接池,具备监控、SQL审计等功能,适合需要详细监控的场景。
- HikariCP: 被誉为速度最快的Java连接池,因其低延迟和高效并发处理能力而广受欢迎。
- C3P0: 功能全面的老牌连接池,适合需要高度定制化的应用
事务控制
操作数据库时,肯定会有事务的存在,那么,JDBC中的事务是怎么使用的呢?
- JDBC默认情况下,事务是自动提交的:即在JDBC中执行一条DML语句就执行了一次事务
- 将事务的自动提交,修改为手动提交即可避免自动提交
- 在事务执行的过程中,任何一步出现异常都要进行回滚
3、实现方案
3.1 基于jdbc的实现
在 JDBC 层面实现多数据源切换,首先创建多个 DataSource 实例,每个 DataSource 对应一个数据源,然后在需要切换数据源的地方切换 DataSource。相对其他方案 基于原生的jdbc实现多数据源,需要处理事务和管理链接池等,这里给出基本实现方案不做深入的讨论。
基本实现方案
数据源配置
首先,需要为每个数据源创建和配置javax.sql.DataSource实例。在Java中,你可以使用不同的数据源实现,例如Apache Commons DBCP、HikariCP、Druid等。以下是一个配置两个数据源的示例代码:
public class DataSourcePool {
private static Map<String, DataSource> dataSourcePool = null;
public static synchronized void initDataSourcePool() {
dataSourcePool.put( "db1",createDataSource("jdbc:mysql://localhost:3306/db1", "user1", "pass1"));
dataSourcePool.put( "db2",createDataSource("jdbc:mysql://localhost:3306/db1", "user1", "pass1"));
}
public static DataSource createDataSource(String url, String username, String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
return new HikariDataSource(config);
}
public static DataSource getDataSource(String dataSourceName) {
return dataSourcePool.get(dataSourceName);
}
}
切换数据源
数据源切面类,用于实现数据源的路由功能。通过AOP切面编程,动态选择合适的数据源进行数据库操作。
*/
@Aspect
public class ConnectionAspect {
@Pointcut("execution(* javax.sql.DataSource.getConnection())")
public void pointcut() {
}
@Around("pointcut()")
public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
DataSource dataSourcePool = DataSourcePool.getDataSource("db1");
//TODO 判断当前有没有事务,有事务的情况下数据源的切换规则?
return dataSourcePool.getConnection();
}
}
思考:数据源切换遇到事务应该如何处理?
-
- 不同的主库之间
- 主库和从库之间
3.2 spring-jdbc实现
spring-jdbc模块提供了AbstractRoutingDataSource抽象类,其内部可以包含多个DataSource,只需要实现其抽象方法,在运行时就可以动态访问指定的数据库。
3.2.1 实现原理
3.2.1.1 数据源管理
- AbstractRoutingDataSource 维护了一个 Map<Object, DataSource> 类型的 resolvedDataSources 成员变量,用于存储所有可用的数据源。
- 通过 setTargetDataSources() 方法,可以向 resolvedDataSources 中添加或修改数据源。
3.2.1.2 数据源确定
- AbstractRoutingDataSource 提供了一个抽象方法 determineCurrentLookupKey(),由子类实现该方法返回当前线程需要使用的数据源标识。
- 典型的实现是根据当前线程的上下文信息(如 ThreadLocal 中保存的值)来确定数据源标识。
3.2.1.3 数据源路由
- AbstractRoutingDataSource 重写了 DataSource 接口的各种数据操作方法,如 getConnection()、getConnection(String username, String password) 等。
- 在这些方法中,它首先通过 determineCurrentLookupKey() 获取当前线程需要的数据源标识,然后从 resolvedDataSources 中查找对应的 DataSource 实例,最后将操作委托给找到的数据源执行。
3.2.1.4 事务控制
当事务开始时,它会与当前的数据源关联,之后的操作都会在这个数据源的上下文中执行。先看
数据源事务管理器DataSourceTransactionManager#doBegin,当开启事务时会执行这个方法,我们再来看一下源代码
可以看到方法先构建jdbc的连接持有对象ConnectionHolder,然后将ConnectionHolder对象绑定到当前线程,再看获取链接DataSourceUtils#doGetConnection方法。
可以看到获取链接的时候会先判断事务是否有ConnectionHolder对象,有从就从其中获取数据库链接,当在一个事务方法中有多个数据源切换时使用的始终是第一个数据源。因此理论上,在事务已经启动后再切换数据源是不直接支持的,因为这可能会导致事务管理的混乱,影响事务的ACID特性,尤其是原子性和一致性,所以本方案在事务开启的情况下数据不支切换。可以配置为事务内的方法都使用默认数据源。要实现在事务中跨越多个数据源进行操作,需要考虑分布式事务解决方案,比如通过JTA、两阶段提交(2PC)、 Saga模式、或基于消息队列的最终一致性方案等,不建议使用此方案实现事务中多数据切换。
tips: 通过注解@Transactional开启事务的时候,底层是通过aop切面实现整个事务控制,切面的优先级比较高,当一个方法上同时有多个切面时,优先级高的切面会先进后出,会导致其它切面逻辑已结束事务还未提交。比如下面的使用注解切面实现分布式锁同时开启事务的场景会导致
锁已经释放事务还未提交。
@Transactional
@Lock
public void list() {
bookMapper.selectList(null);
}
应该把事务包裹在分布式锁中
public class test{
@Lock
public void lock() {
list();
}
@Transactional
public void list() {
bookMapper.selectList(null);
}
}
思考:上面这种写法会有什么问题?
3.2.2 实现案例
一个报表系统需要查询不同的业务系统数据源就行统计展示,并且某些业务系统的数据源以租户维度进行数据隔离。即报表系统需要在多个数据源之间进行切换,并且有些数据源分不同的租户。
3.2.2.1 数据源配置
数据源的配置基于spring-boot自动配置的数据就行实现,采用DataSourceProperties 配置类。
- 定义配置类
@ConfigurationProperties(prefix = MultiDataSourceProperties.PREFIX)
@Getter
@Setter
public class MultiDataSourceProperties {
public static final String PREFIX = "spring.dynamic";
private boolean enabled = true;
private String primary;
private Map<String, DataSourceProperties> datasource;
}
- 配置文件设置
spring:
dynamic:
enabled: true #是否开启 默认开启
primary: db1 #默认数据源
datasource:
db1:
username: train_dev
password: PBSKT_fBW7U1S8XW96sZeUWMa7PcgICY
url: jdbc:mysql://xxxxxxxv:33066/train_dev?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC&allowPublicKeyRetrieval=True
driver-class-name: com.mysql.cj.jdbc.Driver
db2-ip: # ip租户数据源
username:
password:
url: jdbc:mysql://xxxxxxxv:33066/train_dev?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC&allowPublicKeyRetrieval=True
driver-class-name: com.mysql.cj.jdbc.Driver
db2-oi: #oi 租户数据源
username:
password:
url: jdbc:mysql://xxxxxxxv:33066/train_dev?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC&allowPublicKeyRetrieval=True
driver-class-name: com.mysql.cj.jdbc.Driver
3.2.2.2 数据源标识
- 定义标识上下文对象
@Getter
@Setter
@Builder
public class DataSourceContext {
/**
* 数据源名称
*/
private String dataSourceName;
/*
* 是否是租户隔离
*/
private boolean isTenantIsolation;
}
- 当前线程上下文管理对象
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceContext> CONTEXT = new ThreadLocal<>();
public static void set(DataSourceContext dataSourceContext) {
CONTEXT.set(dataSourceContext);
}
public static DataSourceContext get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
3.2.2.3 数据源标识的切换
- 定义注解用于标识类或者方法使用那个数据源是否开启租户隔离
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MandatoryDataSource {
/**
* 数据源名称
*/
String value() default "";
/**
*是否开启租户隔离
*/
boolean isTenantIsolation() default false;
}
- 定义数据路由切面,通过注解来控制方法执行时使用的数据源和是否开启租户
@Aspect
@Slf4j
public class RoutingDataSourceAspect {
@Pointcut("@within(oppo.crm.multisource.jdbc.annotation.MandatoryDataSource) "
+ "|| @annotation(oppo.crm.multisource.jdbc.annotation.MandatoryDataSource)"
)
public void pointcut() {
}
@Around("pointcut()")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
MandatoryDataSource routingDataSource = method.getAnnotation(MandatoryDataSource.class);
if (null == routingDataSource) {
routingDataSource = method.getDeclaringClass().getAnnotation(MandatoryDataSource.class);
}
if (routingDataSource != null) {
DataSourceContextHolder.set(DataSourceContext.builder()
.dataSourceName(routingDataSource.value())
.isTenantIsolation(routingDataSource.isTenantIsolation())
.build());
}
try {
return joinPoint.proceed();
}finally {
DataSourceContextHolder.clear();
}
}
}
- 定义获取租户的接口
public interface TenantCodeManager {
String getTenantCode();
}
3.2.2.5 动态数据源规则配置
- 自定义动态数据源,继承自AbstractRoutingDataSource,用于根据上下文信息动态选择数据源 。
@Slf4j
public class DynamicDataSource extends AbstractRoutingDataSource {
private static final String UNDERLINE = "-";
private final MultiDataSourceProperties multiDataSourceProperties;
private final TenantCodeManager tenantCodeManager;
public DynamicDataSource(MultiDataSourceProperties multiDataSourceProperties, TenantCodeManager tenantCodeManager) {
this.multiDataSourceProperties = multiDataSourceProperties;
this.tenantCodeManager = tenantCodeManager;
}
@Override
protected Object determineCurrentLookupKey() {
DataSourceContext dataSourceContext = DataSourceContextHolder.get();
if (dataSourceContext == null || StrUtil.isEmpty(dataSourceContext.getDataSourceName())) {
return multiDataSourceProperties.getPrimary();
}
String dataSourceName = dataSourceContext.getDataSourceName();
if (dataSourceContext.isTenantIsolation()) {
String tenantCode = tenantCodeManager.getTenantCode();
if (StrUtil.isEmpty(tenantCode)) {
return multiDataSourceProperties.getPrimary();
}
dataSourceName = dataSourceName + UNDERLINE + tenantCode;
}
log.info("DynamicDataSource---{}", dataSourceName);
return dataSourceName;
}
}
- 动态数据源配置类,用于根据配置自动装配多数据源
@Configuration
@AutoConfigureBefore({DataSourceAutoConfiguration.class})
@EnableConfigurationProperties({MultiDataSourceProperties.class})
@ConditionalOnProperty(prefix = MultiDataSourceProperties.PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true)
public class DynamicDataSourceConfiguration {
@Bean
public RoutingDataSourceAspect routingDataSourceAspect(){
return new RoutingDataSourceAspect();
}
@Bean
@ConditionalOnMissingBean
public TenantCodeManager tenantCodeManager() {
return new DefaultTenantCodeManager();
}
@Bean(name = "dynamicDataSource")
public DataSource dynamicDataSource(MultiDataSourceProperties multiDataSourceProperties, TenantCodeManager tenantCodeManager) {
DynamicDataSource dynamicDataSource = new DynamicDataSource(multiDataSourceProperties, tenantCodeManager);
Map<Object, Object> targetDataSources = new HashMap<>(8);
multiDataSourceProperties.getDatasource().forEach((k, v) -> {
targetDataSources.put(k, v.initializeDataSourceBuilder().build());
});
dynamicDataSource.setTargetDataSources(targetDataSources);
dynamicDataSource.setDefaultTargetDataSource(targetDataSources.get(multiDataSourceProperties.getPrimary()));
return dynamicDataSource;
}
备注:@AutoConfigureBefore({DataSourceAutoConfiguration.class}),用于关闭spring-boot数据源的自动装配,不然启动会出现报错
3.2.2.6 如何使用
通过注解标识使用那个数据源
@MandatoryDataSource(value = "master", isTenantIsolation = true)
public void list() {
bookMapper.selectList(null);
}
3.3 基于sharding-jdbc实现
ShardingSphere-JDBC 是一个轻量级的 Java 框架,它在 JDBC 层提供了增强的功能,如数据分片、分布式事务和读写分离。它以 Jar 包形式提供服务,无需额外部署和依赖,完全兼容 JDBC 和各种 ORM 框架。读写分离本身也是一个多数据源操作,本文将介绍如何利用 ShardingSphere-JDBC 的读写分离功能来实现多租户的数据源切换。ShardingSphere-JDBC的版本为4.1.1。
3.3.1 如何实现读写分离
与将数据根据分片键打散至各个数据节点的水平分片不同,读写分离则是根据 SQL 语义的分析,将读操作和写操作分别路由至主库与从库。
我们来看一下读写分离的数据源路由器MasterSlaveDataSourceRouter#route方法
从判断是否走主库的方法isMasterRoute()中我们可以看到,不是select语句的都是走主库,既默认所有的写都走主库所有的读都走从库,但有以下几种情况读也是走主库:
- sqlStatement对象包含锁
- MasterVisitedManager指定了走主库
- HintManager.isMasterRouteOnly()指定了走主库
3.3.2 事务场景下主库和从库如何切换
支持的分布式事务
- 本地事务
-
- 完全支持非跨库事务,例如:仅分表,或分库但是路由的结果在单库中。
- 完全支持因逻辑异常导致的跨库事务。例如:同一事务中,跨两个库更新。更新完毕后,抛出空指针,则两个库的内容都能回滚。
- 不支持因网络、硬件异常导致的跨库事务。例如:同一事务中,跨两个库更新,更新完毕后、未提交之前,第一个库宕机,则只有第二个库数据提交。
- 两阶段事务:XA
- 柔性事务:saga、seata
sharding-jdbc默认使用本地事务,我们来看一下它是如何实现分布式事务控制,其它事务依赖第三方中间件此处不做深入讨论。
这是一个非常常见的处理模式,一个总连接处理了多条sql语句,最后一次性提交整个事务,每一条sql语句可能会分为多条子sql分库分表去执行,这意味着底层可能会关联多个真正的数据库连接,我们先来看看如果一切正常,commit会如何去处理AbstractConnectionAdapter。
引擎会遍历底层所有真正的数据库连接,一个个进行commit操作,如果任何一个出现了异常,直接捕获异常,但是也只是捕获而已,然后接着下一个连接的commit,这也就很好的说明了,如果在执行任何一条sql语句出现了异常,整个操作是可以原子性回滚的,因为此时所有连接都不会执行commit从而实现了分布式事务的控制。但如果已经到了commit这一步的话,如果有连接commit失败了,是不会影响到其他连接的。因此不支持不支持因网络、硬件异常导致的跨库事务。
主从库如何切换
先看一下MasterSlaveDataSourceRouter#route方法中的下面的代码片段
可以看出当执行主库路由后会先执行MasterVisitedManager.setMasterVisited(),用于标记执行的是主库路由。再看BackendConnection#close()方法:
从上面的代码可以看到链接关闭时会先执行MasterVisitedManager.clear(),会清理掉主库的标识。在事务开启的场景下前面的数据库链接不会关闭,当切换数据源时前面数据源有走主库的操作后面所有数据源都是走主库。因此可以得出一下规则:
- 同一线程且同一数据库连接内,如有写入操作,以后的读操作均从主库读取,用于保证数据一致性。
- 事务场景下写之前的读还是走的从库,写之后的读都是走的主库
- 读写分离不支持多主库,避免数据源切换产生分布式事务
3.3.3 实现案例
业务场景实现按租户进行数据隔离,并同时支持读写分离配置,即每个租户对应一个主数据源和一个或者多个从数据源。
3.3.3.1 自定义读写分离路由规则
sharding-jdbc默认的分写分离配置不支持多主库,我们的需求需要支持多主库,因此从写读写分离的路由规则,我们先看它原来路由规则
可以看出当进入主从路由策略时,最后选择那个数据源由MasterSlaveDataSourceRouter决定,我们再来看一下它的读写分离配置只能配置单个主库。
再看路由策略的代码实现,可以用看出当走主库时获取到的就是配置的唯一的主库,走从库通过负载均衡策略选从库。
因此要实现多主库,我们要扩展读写分离主库的配置,并重新获取主库的策略。
CustomMasterSlaveDataSourceRouter,默认所有的操作读走从库
public final class MasterSlaveDataSourceRouter {
private final MasterSlaveRule masterSlaveRule;
private static final String TENANT_SEPARATOR = "-";
public String route(SQLStatement sqlStatement) {
//获取当前租户
String tenantCode = getTenantCode(sqlStatement);
if (isMasterRoute(sqlStatement)) {
//通过租户编码获取主数据源
return getTenantMasterDataSourceName(tenantCode);
}
//通过租户编码获取从数据源
return routeSlaveDataSourceName(masterSlaveRule, getTenantSlaveDataSourceNames(tenantCode));
}
private boolean isMasterRoute(SQLStatement sqlStatement) {
return containsLockSegment(sqlStatement)
|| !DataSourceContext.isSalve()
|| specifyMaster()
|| MasterVisitedManager.isMasterVisited()
|| HintManager.isMasterRouteOnly();
/**
* 获取当前租户对应的主数据源名称
*/
private String getTenantMasterDataSourceName(String tenantCode) {
List<String> masterDataSourceNames = List.of(this.masterSlaveRule.getMasterDataSourceName().split(","));
return masterDataSourceNames.stream()
.filter(name -> name.toUpperCase().contains(TENANT_SEPARATOR + tenantCode.toUpperCase()))
.findFirst()
.orElseThrow(() -> new RuntimeException(
String.format("The tenant coded as does not have a master data source: %s", tenantCode)));
}
/**
* 获取当前租户对应的从数据源名
*/
private String routeSlaveDataSourceName(MasterSlaveRule masterSlaveRule, List<String> slaveDataSourceNames) {
if (CollectionUtils.isEmpty(slaveDataSourceNames)) {
// 必须存在一个从库数据源
throw new GcsmBaseException("[GcsmSharding Error]must exists one slave database configured in sharding datasource yaml config");
}
// 走Sharding的从库读取策略
return masterSlaveRule.getLoadBalanceAlgorithm().getDataSource(masterSlaveRule.getName(), masterSlaveRule.getMasterDataSourceName(), slaveDataSourceNames);
}
/**
* 获取当前租户对应的从数据源名称列表
*/
private List<String> getTenantSlaveDataSourceNames(String tenantCode) {
List<String> tenantSlaveDataSourceNames = this.masterSlaveRule.getSlaveDataSourceNames().stream()
.filter(name -> name.toUpperCase().contains(TENANT_SEPARATOR + tenantCode.toUpperCase()))
.collect(Collectors.toList());
return tenantSlaveDataSourceNames;
}
/**
* 获取租户编码 通过spi实现
*/
private String getTenantCode() {
ServiceLoader<TenantCodeManager> serviceLoader = ServiceLoader.load(TenantCodeManager.class);
return serviceLoader.findFirst()
.map(TenantCodeManager::getTenantCode)
.orElse(""); // 如果没有找到服务提供者,返回空字符串
}
}
3.3.3.2 数据源配置
spring:
shardingsphere:
dataSources:
ds_master_oi:
driverClassName: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ds_master
username: root
password:
ds_master_ip:
driverClassName: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ds_master
username: root
password:
ds_slave_oi:
driverClassName: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ds_slave0
username: root
password:
ds_slave_ip:
driverClassName: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ds_slave1
username: root
password:
masterSlaveRule:
name: ds_ms
masterDataSourceName: ds_master_oi,ds_master_ip
slaveDataSourceNames: ds_slave_oi, ds_slave_ip
3.3.3.2 数据源切换标识
定义方 @MandatoryDataSource注解 法或者类上面使用,方法的优先级最高,被这个注解标识走从库
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MandatoryDataSource {
}
数据源标识处理切面MandatoryDataSourceAspect , 把数据源标识设置到上下文中。
@Aspect
@Slf4j
public class MandatoryDataSourceAspect {
@Pointcut("@within(oppo.crm.multisource.sharding.annotation.MandatoryDataSource) "
+ "|| @annotation(oppo.crm.multisource.sharding.annotation.MandatoryDataSource)")
private void pointCut() {
}
@Around("pointCut()")
public Object runWithLock(ProceedingJoinPoint point) throws Throwable {
Method method = JaAspectUtil.getMethod(point);
MandatoryDataSource annotation = getAnnotation(method);
if (null == annotation) {
annotation = method.getDeclaringClass().getAnnotation(MandatoryDataSource.class);
}
if (null == annotation) {
return point.proceed();
}
try {
if(annotation!=null){
DataSourceContext.setSalve(true);
}
return point.proceed();
} finally {
DataSourceContext.remove();
}
}
private MandatoryDataSource getAnnotation(Method method) {
MandatoryDataSource annotation = (MandatoryDataSource) method.getAnnotation(MandatoryDataSource.class);
if (null == annotation) {
annotation = (MandatoryDataSource) method.getDeclaringClass().getAnnotation(MandatoryDataSource.class);
}
return annotation;
}
}
3.3.3.2 获取租户编码
定义获取租户编码的接口TenantCodeManager
public interface TenantCodeManager {
String getTenantCode();
}
定义实现类通过java spi实现加载例如:
- 配置如例如定义TenantCodeManagerImpl
@Component
public class TenantCodeManagerImpl implements TenantCodeManager {
@Override
public String getTenantCode() {
return TenantCodeContext.getTenantCode();
}
}
- spi配置
tips: spi机制的使用
3.4 开源的方案
dynamic-datasource
- 官方文档:github.com/baomidou/dy…
- 支持 数据源分组 ,适用于多种场景 纯粹多库 读写分离 一主多从 混合模式。
- 支持数据库敏感配置信息 加密(可自定义) ENC()。
- 支持每个数据库独立初始化表结构schema和数据库database。
- 支持无数据源启动,支持懒加载数据源(需要的时候再创建连接)。
- 支持 自定义注解 ,需继承DS(3.2.0+)。
- 提供并简化对Druid,HikariCp,BeeCp,Dbcp2的快速集成。
- 提供对Mybatis-Plus,Quartz,ShardingJdbc,P6sy,Jndi等组件的集成方案。
- 提供 自定义数据源来源 方案(如全从数据库加载)。
- 提供项目启动后 动态增加移除数据源 方案。
- 提供Mybatis环境下的 纯读写分离 方案。
- 提供使用 spel动态参数 解析数据源方案。内置spel,session,header,支持自定义。
- 支持 多层数据源嵌套切换 。(ServiceA >>> ServiceB >>> ServiceC)。
- 提供 基于seata的分布式事务方案 。
- 提供 本地多数据源事务方案。