SpringBoot代码实现读写分离的方案

本文介绍了如何在Spring Boot项目中实现MySQL的读写分离和主从同步。通过配置一主多从的数据库集群,利用Mybatis和Spring Data Source,结合注解实现读写分离。在代码层面,创建了一个读写标识ThreadLocal,通过自定义注解和AOP切面来切换数据源,确保读操作使用从库,写操作使用主库。此外,还展示了如何配置多数据源和事务管理。
摘要由CSDN通过智能技术生成

背景

一个项目中数据库最基础同时也是最主流的是单机数据库,读写都在一个库中。当用户逐渐增多,单机数据库无法满足性能要求时,就会进行读写分离改造(适用于读多写少),写操作一个库,读操作多个库,通常会做一个数据库集群,开启主从备份,一主多从或多主多从,以提高读取性能。

主从同步

正常情况下读写分离的实现,首先要做一个一主多从的数据库集群,同时还需要进行数据同步。
MySQL主从同步配置方法见另一篇文章:MySQL主从同步配置

读写分离

代码层面实现读写分离

代码层面读写分离有两种方式判断是读还是写,根据方法名称、利用注解。在实际项目中,往往会存在这样的service,需要根据从数据库中读到的值进行不同的写操作,这处于一个事务中,那么这个service就只能走主库,不适合使用读写分离,因此,下面将按照注解的方式,让只读的service走从库。
通过注解的方式实现读写分离,无需修改业务代码,只要在只读的 service 方法上加一个注解即可。

1. mybatis和数据源配置

mybatis:
  typeAliasesPackage: com.test.demo.entity
  mapperLocations: classpath:mapper/*.xml
  
spring:
  datasource:
    masters:
      - url: jdbc:mysql://master1_IP:port/test?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC&nullCatalogMeansCurrent=true
        username: root
        password: 123456
        driver-class-name: com.mysql.cj.jdbc.Driver
    slaves:
      - url: jdbc:mysql://slave1_IP:port/test?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC&nullCatalogMeansCurrent=true
        username: root
        password: 123456
        driver-class-name: com.mysql.cj.jdbc.Driver
      - url: jdbc:mysql://slave2_IP:port/test?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC&nullCatalogMeansCurrent=true
        username: root
        password: 123456
        driver-class-name: com.mysql.cj.jdbc.Driver

2. 数据源切换

/**
 * 多数据源配置项
 */
@Component
@ConfigurationProperties(prefix = "spring.datasource")
public class DataSourceProperties {

    private List<Map<String,String>> masters;

    private List<Map<String,String>> slaves;

    public List<Map<String, String>> getMasters() {
        return masters;
    }

    public List<Map<String, String>> getSlaves() {
        return slaves;
    }
}
/**
 * 数据源切换(读、写)
 * 利用ThreadLocal保存当前线程是否处于读模式
 * 通过设置READ_ONLY注解在开始操作前设置模式为读模式,操作结束后清除该数据,避免内存泄漏,同时也为了后续在该线程进行写操作时任然为读模式
 */
public class DataSourceContextHolder {

    private static Logger logger = LoggerFactory.getLogger(DataSourceContextHolder.class);

    public static final String WRITE = "write";
    public static final String READ = "read";

    private static ThreadLocal<String> contextHolder= new ThreadLocal<>();

    public static void setDataSOurceType(String dataSourceType) {
        if (dataSourceType == null) {
            logger.error("dataSource type为空");
            throw new NullPointerException();
        }
        logger.info("设置dataSource type为:{}", dataSourceType);
        contextHolder.set(dataSourceType);
    }

    /**
     * 默认写模式
     * @return
     */
    public static String getDataSourceType() {
        return contextHolder.get() == null ? WRITE : contextHolder.get();
    }

    public static void clearDataSourceType() {
        contextHolder.remove();
    }
}
/**
 * 重写 determineCurrentLookupKey
 */
public class MyRoutingDataSource extends AbstractRoutingDataSource {

    @Autowired
    private DataSourceProperties dataSourceProperties;

    private final Logger logger = LoggerFactory.getLogger(MyRoutingDataSource.class);

    protected Object determineCurrentLookupKey() {
        final String dataSourceType = DataSourceContextHolder.getDataSourceType();

        final int masterCount = dataSourceProperties.getMasters().size();
        final int slaveCount = dataSourceProperties.getSlaves().size();

        int index;
        if ( DataSourceContextHolder.WRITE.equalsIgnoreCase(dataSourceType)) {
            //使用随机数决定使用哪个写库
            index = ThreadLocalRandom.current().nextInt(masterCount) % masterCount;
            logger.info("使用了写库 {}", index);
        } else {
            //使用随机数决定使用哪个读库
            index = ThreadLocalRandom.current().nextInt(slaveCount) % slaveCount;
            logger.info("使用了读库 {}", index);
        }
        return dataSourceType + index;
    }
}

/**
 * 多数据源配置
 */
@Configuration
public class DataSourceConfig {

    @Autowired
    private DataSourceProperties dataSourceProperties;

    @Value("${mybatis.typeAliasesPackage}")
    private String typeAliasesPackage;

    @Value("${mybatis.mapperLocations}")
    private String mapperLocations;

    @Bean
    public List<DataSource> masterDatasources () throws Exception {
        final List<Map<String, String>> masters = dataSourceProperties.getMasters();
        if (CollectionUtils.isEmpty(masters)) {
            throw new IllegalArgumentException("至少需要一个主数据源");
        }
        final List<DataSource> dataSources = new ArrayList<>();
        for (Map map : masters) {
            dataSources.add(DruidDataSourceFactory.createDataSource(map));
        }
        return dataSources;
    }

    @Bean
    public List<DataSource> slaveDatasources () throws Exception {
        final List<Map<String, String>> slaves = dataSourceProperties.getSlaves();
        if (CollectionUtils.isEmpty(slaves)) {
            throw new IllegalArgumentException("至少需要一个从数据源");
        }
        final List<DataSource> dataSources = new ArrayList<>();
        for (Map map : slaves) {
            dataSources.add(DruidDataSourceFactory.createDataSource(map));
        }
        return dataSources;
    }

    @Bean
    @Primary
    @DependsOn({"masterDatasources", "slaveDatasources"})
    public MyRoutingDataSource routingDataSource () {
        final Map<Object, Object> targetDataSources = new HashMap<>();

        for (int i = 0; i < dataSourceProperties.getMasters().size(); i++) {
            targetDataSources.put(DataSourceContextHolder.WRITE + i, dataSourceProperties.getMasters().get(i));
        }
        for (int i = 0; i < dataSourceProperties.getSlaves().size(); i++) {
            targetDataSources.put(DataSourceContextHolder.READ + i, dataSourceProperties.getSlaves().get(i));
        }

        final MyRoutingDataSource routingDataSource = new MyRoutingDataSource();
        // 该方法是AbstractRoutingDataSource的方法
        routingDataSource.setTargetDataSources(targetDataSources);
        // 默认的datasource设置为写库
        routingDataSource.setDefaultTargetDataSource(dataSourceProperties.getMasters().get(0));

        return routingDataSource;
    }

    /**
     * 多数据源需要自己设置sqlSessionFactory
     * @param dataSource
     * @return
     * @throws Exception
     */
    @Bean
    public SqlSessionFactory sqlSessionFactory (final MyRoutingDataSource dataSource) throws Exception {
        final SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);// 指定数据源
        sqlSessionFactoryBean.setTypeAliasesPackage(typeAliasesPackage);// 指定基包
        final ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        sqlSessionFactoryBean.setMapperLocations(resolver.getResources(mapperLocations));
        return sqlSessionFactoryBean.getObject();
    }

    /**
     * 设置事务,事务需要知道当前使用的是哪个数据源才能进行事务处理
     * @return
     */
    @Bean
    public DataSourceTransactionManager dataSourceTransactionManager() {
        return new DataSourceTransactionManager(routingDataSource());
    }
}

3. 自定义只读注解

/**
 * 被这个注解方法使用读库
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {
    
}

4. 拦截注解,切换数据源

/**
 * 写一个切面来切换数据使用哪种数据源,重写 getOrder 保证本切面优先级高于事务切面优先级
 */
@Aspect
public class ReadOnlyInterceptor implements Ordered {

    private final Logger logger = LoggerFactory.getLogger(ReadOnlyInterceptor.class);

    @Around("@annotation(readOnly)")
    public Object setRead(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable{
        try{
            DataSourceContextHolder.setDataSOurceType(DataSourceContextHolder.READ);
            return joinPoint.proceed();
        }finally {
            //清除 dataSourceType 一方面为了避免内存泄漏,更重要的是避免对后续在本线程上执行的操作产生影响
            DataSourceContextHolder.clearDataSourceType();
            logger.info("清除threadLocal");
        }
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值