代码层面动态多数据源之主从和异库

写在前面

image.png本文从代码层面实现。废话不多说,扯开袖子干!

场景一

必属经典的数据库主从模式,俗称多源数据库,主要应用在读写分离场景,可以通过中间件(myCat、Sharding-JDBC),可以通过spring内置AbstractRoutingDataSource实现。

image.png

场景二

一般的架构除了读写分离之后,还有异构数据库(sql+noSql),除了以上还有一种情况是服务同时使用两个不同的数据库,就好像同时使用redis和caffeine缓存一样,不常更新的数据可以放在caffeine中,利用本地缓存分摊部分redis压力,这种情况在数据库层面也会发现。

例如微服务架构中存在一些大部分服务需要访问的数据,常见的有业务配置数据、公共数据等。这部分数据具有共享性,如果落地到具体的服务去做,就会出现冗余,倒不如把这类数据统一放到一个数据库,可以当作中间件(实际不是)吧,架构(java)嘛,通过在中间插一个东西解决问题!!!

image.png

原理

实现原理关键在于spring的AbstractRoutingDataSource。该类允许我们选择指定的datasource,我们通过实现该方法动态切换datasource。

image.png

配置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

image.png

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

image.png
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();
    }
}
复制代码

写在最后

临近过年,疫情四起,大家注意防疫,过个好年。

image.png

猜你喜欢

转载自juejin.im/post/7053811745126612999