SpringBoot 專案優雅實現讀寫分離

2023-11-13 12:00:26

一、讀寫分離介紹

當使用Spring Boot開發資料庫應用時,讀寫分離是一種常見的優化策略。讀寫分離將讀操作和寫操作分別分配給不同的資料庫範例,以提高系統的吞吐量和效能。

讀寫分離實現主要是通過動態資料來源功能實現的,動態資料來源是一種通過在執行時動態切換資料庫連線的機制。它允許應用程式根據不同的條件或設定選擇不同的資料來源,以實現更靈活和可延伸的資料庫存取。

二、實現讀寫分離-基礎

1. 設定主資料庫和從資料庫的連線資訊

# 主庫設定
spring.datasource.master.jdbc-url=jdbc:mysql://ip:port/master?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
spring.datasource.master.username=master
spring.datasource.master.password=123456
spring.datasource.master.driver-class-name=com.mysql.jdbc.Driver

# 從庫設定
spring.datasource.slave.jdbc-url=jdbc:mysql://ip:port/slave?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
spring.datasource.slave.username=slave
spring.datasource.slave.password=123456
spring.datasource.slave.driver-class-name=com.mysql.jdbc.Driver 

2. 建立主資料庫和從資料庫的資料來源設定類

通過不同的條件限制和組態檔字首可以完成不同資料來源的建立工作,不止是主從也可以是多個不同的資料庫

主庫資料來源設定

@Configuration
@ConditionalOnProperty("spring.datasource.master.jdbc-url")
public class MasterDataSourceConfiguration {
    @Bean("masterDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.master")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().build();
    }
}

從庫資料來源設定

@Configuration
@ConditionalOnProperty("spring.datasource.slave.jdbc-url")
public class SlaveDataSourceConfiguration {
    @Bean("slaveDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.slave")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().build();
    }
}



3. 建立主從資料來源列舉

public enum DataSourceTypeEnum {
    /**
     * 主庫
     */
    MASTER,

    /**
     * 從庫
     */
    SLAVE,
   ;
  
}

4. 建立動態路由資料來源

這兒做了一個開關,可以控制讀寫分離的開啟和關閉工作,可以講操作全部切換到主庫進行。然後根據上下文中的資料來源型別來返回不同的資料來源型別列舉

@Slf4j
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {

    @Value("${DB_RW_SEPARATE_SWITCH:false}")
    private boolean dbRwSeparateSwitch;
    @Override
    protected Object determineCurrentLookupKey() {
        if(dbRwSeparateSwitch && DataSourceTypeEnum.SLAVE.equals(DataSourceContextHolder.getDataSourceType())) {
            log.info("DynamicRoutingDataSource 切換資料來源到從庫");
            return DataSourceTypeEnum.SLAVE;
        }
        log.info("DynamicRoutingDataSource 切換資料來源到主庫");
        // 根據需要指定當前使用的資料來源,這裡可以使用ThreadLocal或其他方式來決定使用主庫還是從庫
        return DataSourceTypeEnum.MASTER;
    }
}

5. 建立動態資料來源設定類

將主資料庫和從資料庫的資料來源新增到動態資料來源中,並可以通過列舉建立一個資料來源 map,這樣就可以通過上面的路由返回的列舉來切換資料來源

@Configuration
@ConditionalOnProperty("spring.datasource.master.jdbc-url")
public class DynamicDataSourceConfiguration {
    @Bean("dataSource")
    @Primary
    public DataSource dynamicDataSource(DataSource masterDataSource, DataSource slaveDataSource) {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceTypeEnum.MASTER, masterDataSource);
        targetDataSources.put(DataSourceTypeEnum.SLAVE, slaveDataSource);

        DynamicRoutingDataSource dynamicDataSource = new DynamicRoutingDataSource();
        dynamicDataSource.setTargetDataSources(targetDataSources);
        dynamicDataSource.setDefaultTargetDataSource(masterDataSource);
        return dynamicDataSource;
    }
}

6. 建立DatasourceContextHolder類使用ThreadLocal儲存當前執行緒的資料來源型別

注意這兒有個潛在風險就是建立新的執行緒時會導致 ThreadLocal 中的資料無法正確讀取,如果涉及到在開啟新執行緒可以使用 TransmittableThreadLocal 來進行父子執行緒資料的同步,git 地址: https://github.com/alibaba/transmittable-thread-local

public class DataSourceContextHolder {
    private static final ThreadLocal<DataSourceTypeEnum> contextHolder = new ThreadLocal<>();

    public static void setDataSourceType(DataSourceTypeEnum dataSourceType) {
        contextHolder.set(dataSourceType);
    }

    public static DataSourceTypeEnum getDataSourceType() {
        return contextHolder.get();
    }

    public static void clearDataSourceType() {
        contextHolder.remove();
    }
}

7. 建立自定義註解,用於標記主和從資料來源

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MasterDataSource {
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SlaveDataSource {
}

8. 建立切面類,攔截資料庫操作,並根據註解設定切換資料來源引數

@Aspect
@Component
public class DataSourceAspect {

    @Before("@annotation(xxx.MasterDataSource)")
    public void setMasterDataSource(JoinPoint joinPoint) {
        DataSourceContextHolder.setDataSourceType(DataSourceTypeEnum.MASTER);
    }

    @Before("@annotation(xxx.SlaveDataSource)")
    public void setSlaveDataSource(JoinPoint joinPoint) {
        DataSourceContextHolder.setDataSourceType(DataSourceTypeEnum.SLAVE);
    }

    @After("@annotation(xxx.MasterDataSource) || @annotation(xxx.SlaveDataSource)")
    public void clearDataSource(JoinPoint joinPoint) {
        DataSourceContextHolder.clearDataSourceType();
    }
}

9. 在Service層的方法上使用自定義註解標記查詢資料來源

@Service
public class TestService {
    @Autowired
    private TestDao testDao;

    @SlaveDataSource
    public Test test() {
        return testDao.queryByPrimaryKey(11L);
    }
}

10. 排除掉資料來源自動設定類

如果不排除自動設定類會導致初始化多個 dataSource 物件導致出現問題

SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})

三、實現讀寫分離-進階

1. 使用連結池,以Hikari為例

修改連結設定,加入連結池相關設定即可

# 主庫設定
spring.datasource.master.jdbc-url=jdbc:mysql://ip:port/master?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
spring.datasource.master.username=master
spring.datasource.master.password=123456
spring.datasource.master.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.master.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.master.hikari.name=master
spring.datasource.master.hikari.minimum-idle=5
spring.datasource.master.hikari.idle-timeout=30
spring.datasource.master.hikari.maximum-pool-size=10
spring.datasource.master.hikari.auto-commit=true
spring.datasource.master.hikari.pool-name=DatebookHikariCP
spring.datasource.master.hikari.max-lifetime=1800000
spring.datasource.master.hikari.connection-timeout=30000
spring.datasource.master.hikari.connection-test-query=SELECT 1

# 從庫設定
spring.datasource.slave.jdbc-url=jdbc:mysql://ip:port/slave?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
spring.datasource.slave.username=root
spring.datasource.slave.password=123456
spring.datasource.slave.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.slave.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.slave.hikari.name=master
spring.datasource.slave.hikari.minimum-idle=5
spring.datasource.slave.hikari.idle-timeout=30
spring.datasource.slave.hikari.maximum-pool-size=10
spring.datasource.slave.hikari.auto-commit=true
spring.datasource.slave.hikari.pool-name=DatebookHikariCP
spring.datasource.slave.hikari.max-lifetime=1800000
spring.datasource.slave.hikari.connection-timeout=30000
spring.datasource.slave.hikari.connection-test-query=SELECT 1

2. 整合 mybatis 並在寫入時強制切換到主庫

不需要做任何設定,正常整合 mybatis 即可使用讀寫分離功能

可以通過 mybatis 的攔截器在寫入操作時強制切換到主庫

@Intercepts({
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
})
@Component
public class WriteInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 獲取 SQL 型別
        DataSourceTypeEnum dataSourceType = DataSourceContextHolder.getDataSourceType();
        if(DataSourceTypeEnum.SLAVE.equals(dataSourceType)) {
            DataSourceContextHolder.setDataSourceType(DataSourceTypeEnum.MASTER);
        }
        try {
            // 執行 SQL
            return invocation.proceed();
        } finally {
            // 恢復資料來源  考慮到寫入後可能會反查,後續都走主庫
            // DataSourceContextHolder.setDataSourceType(dataSourceType);
        }
    }
}

作者:京東健康 蘇曼

來源:京東雲開發者社群 轉發請註明來源