写在前面
本文从代码层面实现。废话不多说,扯开袖子干!
场景一
必属经典的数据库主从模式,俗称多源数据库,主要应用在读写分离场景,可以通过中间件(myCat、Sharding-JDBC),可以通过spring内置AbstractRoutingDataSource实现。
场景二
一般的架构除了读写分离之后,还有异构数据库(sql+noSql),除了以上还有一种情况是服务同时使用两个不同的数据库,就好像同时使用redis和caffeine缓存一样,不常更新的数据可以放在caffeine中,利用本地缓存分摊部分redis压力,这种情况在数据库层面也会发现。
例如微服务架构中存在一些大部分服务需要访问的数据,常见的有业务配置数据、公共数据等。这部分数据具有共享性,如果落地到具体的服务去做,就会出现冗余,倒不如把这类数据统一放到一个数据库,可以当作中间件(实际不是)吧,架构(java)嘛,通过在中间插一个东西解决问题!!!
原理
实现原理关键在于spring的AbstractRoutingDataSource。该类允许我们选择指定的datasource,我们通过实现该方法动态切换datasource。
配置datasource
我们看一下单库情况下datasource配置套路,在配置文件配置datasource的基本数据,启动项目的时候spring自动帮我们注入。
spring:
datasource:
url: jdbc:mysql://${MYSQL_MASTER_SERVER_HOST:10.211.55.20}:${MYSQL_MASTER_SERVER_PORT:3306}/${MYSQL_SERVER_DB:account}?useUnicode=true&characterEncoding=UTF-8
username: ${MYSQL_MASTER_SERVER_USERNAME:root}
password: ${MYSQL_MASTERSERVER_PWD:root}
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
复制代码
要想使用动态datasource,需要自己注入datasource。首先配置服务专用数据库一主一从和一个公共库。
spring:
datasource:
master:
jdbc-url: jdbc:mysql://${MYSQL_MASTER_SERVER_HOST:10.211.55.20}:${MYSQL_MASTER_SERVER_PORT:3306}/${MYSQL_SERVER_DB:account}?useUnicode=true&characterEncoding=UTF-8
username: ${MYSQL_MASTER_SERVER_USERNAME:root}
password: ${MYSQL_MASTERSERVER_PWD:root}
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
slave_1:
jdbc-url: jdbc:mysql://${MYSQL_SLAVE1_SERVER_HOST:10.211.55.20}:${MYSQL_SLAVE1_SERVER_PORT:3307}/${MYSQL_SERVER_DB:account}?useUnicode=true&characterEncoding=UTF-8
username: ${MYSQL_SLAVE1_SERVER_USERNAME:root}
password: ${MYSQL_SLAVE1_SERVER_PWD:root}
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
common:
jdbc-url: jdbc:mysql://${MYSQL_SLAVE1_SERVER_HOST:10.211.55.20}:${MYSQL_SLAVE1_SERVER_PORT:3306}/${MYSQL_SERVER_DB:common}?useUnicode=true&characterEncoding=UTF-8
username: ${MYSQL_SLAVE1_SERVER_USERNAME:root}
password: ${MYSQL_SLAVE1_SERVER_PWD:root}
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
复制代码
除此之外还需要在启动类exclude DataSourceAutoConfiguration.class,不然启动会爆错,就是因为上面说的spring自动帮我们注入datasource,它找不到配置的数据库,启动就报错。
...
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
复制代码
配置文件写好,下一步需要交给IOC容器管理,选择一个实体用于注入datasource。
@ConfigurationProperties(prefix = "spring")
public class DynamicDataSourceConfig {
private Map<String, HikariDataSource> datasource = new HashMap<>();
// ignore setter getter...
}
复制代码
datasource已准备好,接下来是不是得实现datasource的加载逻辑。需要注意,必须定义@Primary。
@Configuration
@EnableConfigurationProperties(DynamicDataSourceConfig.class)
public class DynamicDataSourceConfiguration {
private static final Logger logger = LoggerFactory.getLogger(DynamicDataSourceConfiguration.class);
@Autowired
private DynamicDataSourceConfig dynamicDataSourceConfig;
@Bean
@Primary
public DynamicDataSource dynamicDataSource() {
//定义数据源map
Map<Object, Object> dataSources = new HashMap<>();
for(Map.Entry<String, HikariDataSource> entry : dynamicDataSourceConfig.getDatasource().entrySet()) {
dataSources.put(entry.getKey(), entry.getValue());
logger.info("初始化数据源: [key] = {}, [url] = {}", entry.getKey(), entry.getValue().getJdbcUrl());
}
//定义数据源
DynamicDataSource dynamicDataSource = new DynamicDataSource();
//尝试从配置中获取到主库
DataSource masterDataSource = dynamicDataSourceConfig.getDatasource().get(DynamicDataSourceHolder.MASTER);
if(masterDataSource == null) {
throw new IllegalArgumentException("未定义, master库(" + DynamicDataSourceHolder.MASTER + ")");
}
//设置动态数据源默认使用的库为主库
dynamicDataSource.setDefaultTargetDataSource(masterDataSource);
//设置数据源map
dynamicDataSource.setTargetDataSources(dataSources);
return dynamicDataSource;
}
}
复制代码
Datasource Route - Key Step
determineCurrentLookupKey是路由关键方法,根据自己的业务写路由方法,我的基本套路是判断是不是master或者slave,如果都不是就从slave轮训,也可以多写slave几个策略类代替轮训。
public class DynamicDataSource extends AbstractRoutingDataSource {
//int的原子操纵类,在多线程环境下可以安全的自增,初始值设置为 -1
private AtomicInteger counter = new AtomicInteger(-1);
//存放从库的key
private List<Object> slaveDataSource = new ArrayList<>(0);
@Override
protected Object determineCurrentLookupKey() {
if (DynamicDataSourceHolder.isSlave() && !slaveDataSource.isEmpty()) {
return this.getSlaveKey();
} else if (DynamicDataSourceHolder.isCommon()) {
return DynamicDataSourceHolder.COMMON;
}
return DynamicDataSourceHolder.MASTER;
}
@Override
public void afterPropertiesSet() {
super.afterPropertiesSet();
for (Map.Entry<Object, DataSource> entry : getResolvedDataSources().entrySet()) {
if (!DynamicDataSourceHolder.MASTER.equals(entry.getKey())
&& !DynamicDataSourceHolder.COMMON.equals(entry.getKey())) {
slaveDataSource.add(entry.getKey());
}
}
}
//轮询从库key
public Object getSlaveKey() {
int index = counter.incrementAndGet() % this.slaveDataSource.size();
if (counter.get() > 9999) {
counter.set(-1);
}
return slaveDataSource.get(index);
}
}
复制代码
切换datasource
datasource有了,加载逻辑也有了,路由也写好了,好像万事俱备,但回头想想,凡事都有导火索,我们还需要一个导火索,触发数据源切换,下面我们使用自定义注解 + AOP实现切换逻辑。写切面的时候需要注意@DS注解嵌套的情况。
举个例子:
背景:a方法使用master库,b方法使用common库,master库和common库数据不一样,a方法调用b方法。
调用a方法的时候,此时是master库,a方法调用b方法的时候datasource切换成common,执行完b方法返回给a方法,这时datasource还是common,继续执行curd会出错,所以需要在环绕通知里目标方法执行完毕把datasource切回master。
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface DS{
/**
* 数据源名称
*/
String value() default DynamicDataSourceHolder.MASTER;
}
复制代码
@Aspect
@Component
@Order(-9999) //它必须优先于业务层的所有切面方法之前执行
public class DynamicDataSourceAspect {
private static final Logger logger = LoggerFactory.getLogger(DynamicDataSourceConfiguration.class);
//切面定义,业务层的所有类的所有方法
@Pointcut("@annotation(com.robotto.base.infrastructure.persistence.db.dynamic.DS)")
public void pointCut() {}
@Around("pointCut()")
public Object beforeService(ProceedingJoinPoint point) throws Throwable {
Object target = point.getTarget();
String methodName = point.getSignature().getName();
Object[] args = point.getArgs();
Class<?>[] parameterTypes = ((MethodSignature) point.getSignature()).getMethod().getParameterTypes();
//目标方法
Method method = target.getClass().getMethod(methodName, parameterTypes);
if (method.isBridge()) {
for (int i = 0; i < args.length; i++) {
Class<?> genClazz = getSuperClassGenericType(target.getClass(), 0);
if (args[i].getClass().isAssignableFrom(genClazz)) {
parameterTypes[i] = genClazz;
}
}
method = target.getClass().getMethod(methodName, parameterTypes);
}
DS ds = method.getAnnotation(DS.class);
logger.info("transactional method: [{}], dynamic datasource: [{}]", methodName, ds.value());
DynamicDataSourceHolder.setup(ds.value());
Object proceed = point.proceed();
// mark master if datasource is common
DynamicDataSourceHolder.callBack();
return proceed;
}
public Class<?> getSuperClassGenericType(Class<?> clazz, int index) {
Type genType = clazz.getGenericSuperclass();
if (!(genType instanceof ParameterizedType)) {
return Object.class;
}
Type[] params = ((ParameterizedType) genType).getActualTypeArguments();
if (!(params[index] instanceof Class)) {
return Object.class;
}
return (Class<?>) params[index];
}
}
复制代码
并发问题
切换datasource的时候,存在并发问题,这里我们引入ThreadLocal解决该问题。
public class DynamicDataSourceHolder {
//定义主库的key
public static final String MASTER = "master";
public static final String SLAVE = "slave";
public static final String COMMON ="common";
private static final ThreadLocal<String> OLD_HOLDER = new ThreadLocal<>();
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
private static void putDataSourceKey(String key) {
HOLDER.set(key);
}
public static String getDataSourceKey() {
return HOLDER.get();
}
//标记为主库
public static void markMaster() {
putDataSourceKey(MASTER);
}
//标签为从库
public static void markSlave() {
putDataSourceKey(SLAVE);
}
//标签为common库
public static void markCommon() {
putDataSourceKey(COMMON);
}
//判断是否是主库
public static boolean isMaster() {
return SLAVE.equals(HOLDER.get());
}
//判断是否common库
public static boolean isCommon() {
return COMMON.equals(HOLDER.get());
}
//判断是否从库
public static boolean isSlave() {
return COMMON.equals(HOLDER.get());
}
public static void setup(String dsName) {
if (DynamicDataSourceHolder.MASTER.equals(dsName)) {
DynamicDataSourceHolder.markMaster();
} else if (DynamicDataSourceHolder.COMMON.equals(dsName)) {
if (null != HOLDER.get()) {
OLD_HOLDER.set(HOLDER.get());
}
DynamicDataSourceHolder.markCommon();
} else {
DynamicDataSourceHolder.markSlave();
}
}
public static void callBack() {
if (COMMON.equals(HOLDER.get()) && null != OLD_HOLDER.get()) {
putDataSourceKey(OLD_HOLDER.get());
// remove after callBack
OLD_HOLDER.remove();
}
}
复制代码
这种有效支持动态扩容slave,具体做法是修改配置文件,增加salve_2, slave_3...,然后重启服务。虽然是这个流程,但总感觉比不上大厂成熟的扩容方案。
课后补充-如果你用flyway
flyway不会识别数据源,它使用的是默认的数据源,需要我们指定具体数据源。类似下面这样,执行master库的migrate。
flyway:
url: ${spring.datasource.master.jdbc-url}
user: ${spring.datasource.master.username}
password: ${spring.datasource.master.password}
复制代码
如果你还配置了common库,common库和master库,slave库数据不一样,就得另外配置一个bean专门做这件事执行common库的migrate。
@Configuration
@EnableConfigurationProperties(DynamicDataSourceConfig.class)
public class FlywayConfig {
private static final Logger logger = LoggerFactory.getLogger(FlywayConfig.class);
@Autowired
private DynamicDataSourceConfig dynamicDataSourceConfig;
private static final String SQL_LOCATION_SCHEMA = "db/common/schema";
private static final String SQL_LOCATION_SCRIPT = "db/common/script";
@Bean
public void migrate() {
DataSource dataSource = dynamicDataSourceConfig.getDatasource().get(DynamicDataSourceHolder.COMMON);
if (null == dataSource) {
logger.debug("there isn't common db, use master to migrate by default!");
dataSource = dynamicDataSourceConfig.getDatasource().get(DynamicDataSourceHolder.MASTER);
}
FlywayProperties flywayProperties = ApplicationUtil.getBean(FlywayProperties.class);
Flyway.configure()
.dataSource(dataSource)
.locations(SQL_LOCATION_SCHEMA, SQL_LOCATION_SCRIPT)
.baselineOnMigrate(flywayProperties.isBaselineOnMigrate())
.baselineVersion(flywayProperties.getBaselineVersion())
.table("schema_history_common")
.cleanOnValidationError(flywayProperties.isCleanOnValidationError())
.cleanDisabled(flywayProperties.isCleanDisabled())
.load()
.migrate();
}
}
复制代码
写在最后
临近过年,疫情四起,大家注意防疫,过个好年。