Spring Data JPA 使用主从数据源

Mysql 配置主从复制

参考:Mysql主从复制-半同步复制
这里我配置了master主库,slave从库slave0和slave1

创建 Spring boot工程

maven pom

<properties>
    <maven.compiler.source>8</maven.compiler.source>
    <maven.compiler.target>8</maven.compiler.target>
    <lombok.maven.plugin.encoding>UTF-8</lombok.maven.plugin.encoding>
    <lombok.version>1.18.20</lombok.version>
    <lombok.maven.plugin.version>1.18.20.0</lombok.maven.plugin.version>
</properties>

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.5.1</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.25</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
    </dependency>
</dependencies>

application.yml

spring:
  jpa:
    properties:
      hibernate:
        enable_lazy_load_no_trans: true
        show_sql: true
        use_sql_comments: true
        format_sql: true
    hibernate:
      ddl-auto: update
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    database-platform: org.hibernate.dialect.MySQL8Dialect
    open-in-view: false

server:
  port: 9300

app:
  datasource:
    parameters: useUnicode=true&characterEncoding=utf8&autoReconnect=true&failOverReadOnly=false&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
    username: &username root
    password: &password 123456
    driver-class-name: &driver-class-name com.mysql.cj.jdbc.Driver
    master: &default-db-config
      url: jdbc:mysql://localhost:3201/test?${app.datasource.parameters}
      username: *username
      password: *password
      driver-class-name: *driver-class-name
      configuration:
        maximum-pool-size: 30
    slave0:
      <<: *default-db-config
      url: jdbc:mysql://localhost:3202/test?${app.datasource.parameters}
    slave1:
      <<: *default-db-config
      url: jdbc:mysql://localhost:3203/test?${app.datasource.parameters}

数据源相关配置

DataSource

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;

/**
 * 多数据源配置
 */
@Configuration
public class DataSourceConfig {
    /**
     * master
     */
    @Bean
    @Primary
    @ConfigurationProperties(prefix = "app.datasource.master")
    public DataSourceProperties masterDataSourceProperties() {
        return new DataSourceProperties();
    }

    /**
     * slave0
     */
    @Bean("slave0DataSourceProperties")
    @ConfigurationProperties(prefix = "app.datasource.slave0")
    public DataSourceProperties slave0DataSourceProperties() {
        return new DataSourceProperties();
    }

    /**
     * slave1
     */
    @Bean("slave1DataSourceProperties")
    @ConfigurationProperties(prefix = "app.datasource.slave1")
    public DataSourceProperties slave1DataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @Primary
    @ConfigurationProperties(prefix = "app.datasource.master.configuration")
    public DataSource masterDataSource(DataSourceProperties dataSourceProperties) {
        return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Bean("slave0DataSource")
    @ConfigurationProperties(prefix = "app.datasource.slave0.configuration")
    public DataSource slave0DataSource(@Qualifier("slave0DataSourceProperties") DataSourceProperties dataSourceProperties) {
        return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Bean("slave1DataSource")
    @ConfigurationProperties(prefix = "app.datasource.slave1.configuration")
    public DataSource slave1DataSource(@Qualifier("slave1DataSourceProperties") DataSourceProperties dataSourceProperties) {
        return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }
}

数据源切换思路

动态切换数据源时序
  • 利用ThreadLocal作为单个请求(每个请求单独一个线程)的全局容器连接Service方法控制使用哪个数据源和EntityManager使用的数据源,这样EntityManager使用的数据源就是在Service方法上要求的数据源,即可做到写Service方法时决定使用哪个数据源
  • 利用org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource::determineCurrentLookupKey()暴露获取DataSource的逻辑
  • 在service方法上通过配置Annotation,告诉EntityManager使用哪个数据源
    • 自定义Annotation
    • 利用Spring AOP识别Annotation,织入设置数据源逻辑

数据源切换配置

  • Annotation
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 主数据源
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Master {
}
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 从数据源
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Slave {
}
  • 多数据源 key
public enum DBInstanceEnum {
    MASTER, SLAVE0, SLAVE1;
}
  • 数据源容器
import lombok.extern.slf4j.Slf4j;
import lombok.val;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * 数据源选择器
 */
@Slf4j
public final class DBContextHolder {

    private static final ThreadLocal<DBInstanceEnum> contextHolder = ThreadLocal.withInitial(() -> DBInstanceEnum.MASTER);

    private static final AtomicInteger router = new AtomicInteger(-1);

    public static void switchToMaster() {
        contextHolder.set(DBInstanceEnum.MASTER);
        log.info("switch to master db");
    }

    public static void switchToSlave() {
        // 1.3改进:支持多个从库的负载均衡
        val next = router.incrementAndGet();
        val index = next % 2;
        if (index == 0) {
            contextHolder.set(DBInstanceEnum.SLAVE0);
            log.info("switch to slave0 db");
        } else {
            contextHolder.set(DBInstanceEnum.SLAVE1);
            log.info("switch to slave1 db");
        }
        if (next > 9999) {
            router.set(-1);
        }
    }

    public static DBInstanceEnum get() {
        val db = contextHolder.get();
        log.info("get db{} from contextHolder", db);
        return db;
    }
}
  • 多数据源AOP配置
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

/**
 * 多数据源切换AOP
 */
@Aspect
@Component
@Slf4j
public class DataSourceSwitchAspect {

    /**
     * 切换为 master 数据源织入点
     */
    @Pointcut("@annotation(cc.gegee.study.week7.db.tags.Master) ")
    public void switchToMasterPointcut() {

    }

    /**
     * 切换为 slave 数据源织入点
     */
    @Pointcut("@annotation(cc.gegee.study.week7.db.tags.Slave) ")
    public void switchToSlavePointcut() {

    }

    @Before("switchToSlavePointcut()")
    public void readBefore() {
        DBContextHolder.switchToSlave();
    }

    /**
     * slave 结束后切回到主数据源
     */
    @AfterReturning("switchToSlavePointcut()")
    public void readAfter() {
        DBContextHolder.switchToMaster();
        log.info("after use slave db, set to master db");
    }

    @Before("switchToMasterPointcut()")
    public void writeBefore() {
        DBContextHolder.switchToMaster();
    }
}

动态数据源配置

将上面配置的数据源注入AbstractRoutingDataSource,并将数据源容器获取接入

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.stereotype.Component;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Component
public class RoutingDataSource extends AbstractRoutingDataSource {

    public RoutingDataSource(DataSource masterDataSource, @Qualifier("slave0DataSource") DataSource slave0DataSource, @Qualifier("slave1DataSource") DataSource slave1DataSource) {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DBInstanceEnum.MASTER, masterDataSource);
        targetDataSources.put(DBInstanceEnum.SLAVE0, slave0DataSource);
        targetDataSources.put(DBInstanceEnum.SLAVE1, slave1DataSource);
        this.setDefaultTargetDataSource(masterDataSource);
        this.setTargetDataSources(targetDataSources);
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return DBContextHolder.get();
    }
}

自定义 JPA 的 EntityManager

将动态数据源注入EntityManager

import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.SharedEntityManagerCreator;

import javax.persistence.EntityManager;
import java.util.Objects;

import static cc.gegee.study.week7.Application.BASE_PACKAGE;
import static cc.gegee.study.week7.db.JpaEntityManagerMaster.ENTITY_MANAGER_FACTORY_BEAN;

/**
 * 自定义 JPA 的 EntityManager
 */
@Configuration
@EnableJpaRepositories(basePackages = {BASE_PACKAGE}, entityManagerFactoryRef = ENTITY_MANAGER_FACTORY_BEAN)
public class JpaEntityManagerMaster {

    public final static String ENTITY_MANAGER_FACTORY_BEAN = "masterEntityManagerFactoryBean";

    private final RoutingDataSource dataSource;

    private final EntityManagerFactoryBuilder builder;

    private final HibernateConfiguration hibernateConfiguration;

    public JpaEntityManagerMaster(RoutingDataSource dataSource, EntityManagerFactoryBuilder builder, HibernateConfiguration hibernateConfiguration) {
        this.dataSource = dataSource;
        this.builder = builder;
        this.hibernateConfiguration = hibernateConfiguration;
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean masterEntityManagerFactoryBean() {
        return builder
                .dataSource(dataSource)
                .properties(hibernateConfiguration.getVendorProperties(dataSource))
                .packages(BASE_PACKAGE)
                .persistenceUnit("persistenceUnitMaster")
                .build();
    }

    @Bean
    public EntityManager masterEntityManager() {
        return SharedEntityManagerCreator.createSharedEntityManager(Objects.requireNonNull(masterEntityManagerFactoryBean().getObject()));
    }
}

测试

service添加CRUD方法

    @Master
    @Override
    public void add(DeviceModel model) {
        val device = Device.builder()
                .serialNumber(model.getSerialNumber())
                .deviceCategoryName(model.getDeviceCategoryName())
                .build();
        deviceRepository.save(device);
    }

    @Master
    @Override
    public void edit(UUID id, DeviceModel model) {
        val device = deviceRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("invalid id"));
        device.setSerialNumber(model.getSerialNumber());
        device.setDeviceCategoryName(model.getDeviceCategoryName());
        deviceRepository.save(device);
    }

    @Slave
    @Override
    public DeviceDto get(UUID id) {
        val device = deviceRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("invalid id"));
        return DeviceDto.builder()
                .serialNumber(device.getSerialNumber())
                .deviceCategoryName(device.getDeviceCategoryName())
                .build();
    }

    @Slave
    @Override
    public Page<DeviceDto> getPage(Pageable pageable) {
        return deviceRepository.findAll(pageable).map(x -> DeviceDto.builder()
                .serialNumber(x.getSerialNumber())
                .deviceCategoryName(x.getDeviceCategoryName())
                .build());
    }

配置好controller后调用,可以看到如下日志

2021-12-23 09:11:38.926  INFO 8678 --- [nio-9300-exec-1] cc.gegee.study.week7.db.DBContextHolder  : switch to master db
2021-12-23 09:11:48.976  INFO 8678 --- [nio-9300-exec-3] cc.gegee.study.week7.db.DBContextHolder  : switch to slave1 db
2021-12-23 09:11:48.977  INFO 8678 --- [nio-9300-exec-3] cc.gegee.study.week7.db.DBContextHolder  : get dbSLAVE1 from contextHolder

github地址

github地址

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容