Spring Boot 配置主从数据库实现读写分离-CSDN博客

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

一、前言

现在的 Web 应用大都是读多写少。除了缓存以外还可以通过数据库 “主从复制” 架构把读请求路由到从数据库节点上实现读写分离从而大大提高应用的吞吐量。

通常我们在 Spring Boot 中只会用到一个数据源即通过 spring.datasource 进行配置。前文 《在 Spring Boot 中配置和使用多个数据源》 介绍了一种在 Spring Boot 中定义、使用多个数据源的方式。但是这种方式对于实现 “读写分离” 的场景不太适合。首先多个数据源都是通过 @Bean 定义的当需要新增额外的从数据库时需要改动代码非常不够灵活。其次在业务层中如果需要根据读、写场景切换不同数据源的话只能手动进行。

对于 Spring Boot “读写分离” 架构下的的多数据源我们需要实现如下需求

  1. 可以通过配置文件新增数据库从库而不不需要修改代码。
  2. 自动根据场景切换读、写数据源对业务层是透明的。

幸运的是Spring Jdbc 模块类提供了一个 AbstractRoutingDataSource 抽象类可以实现我们的需求。

它本身也实现了 DataSource 接口表示一个 “可路由” 的数据源。

核心的代码如下

public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {

    // 维护的所有数据源
    @Nullable
    private Map<Object, DataSource> resolvedDataSources;

    // 默认的数据源
    @Nullable
    private DataSource resolvedDefaultDataSource;

    // 获取 Jdbc 连接
    @Override
    public Connection getConnection() throws SQLException {
        return determineTargetDataSource().getConnection();
    }
    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        return determineTargetDataSource().getConnection(username, password);
    }

    // 获取目标数据源
    protected DataSource determineTargetDataSource() {
        Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
        // 调用  determineCurrentLookupKey() 抽象方法获取 resolvedDataSources 中定义的 key。
        Object lookupKey = determineCurrentLookupKey();
        DataSource dataSource = this.resolvedDataSources.get(lookupKey);
        if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
            dataSource = this.resolvedDefaultDataSource;
        }
        if (dataSource == null) {
            throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
        }
        return dataSource;
    }

    // 抽象方法返回 resolvedDataSources 中定义的 key。需要自己实现
    @Nullable
    protected abstract Object determineCurrentLookupKey();
}

核心代码如上它的工作原理一目了然。它在内部维护了一个 Map<Object, DataSource> 属性维护了多个数据源。

当尝试从 AbstractRoutingDataSource 数据源获取数据源连接对象 Connection 时会调用 determineCurrentLookupKey() 方法得到一个 Key然后从数据源 Map<Object, DataSource> 中获取到真正的目标数据源如果 Key 或者是目标数据源为 null 则使用默认的数据源。

得到目标数据数据源后返回真正的 Jdbc 连接。这一切对于使用到 Jdbc 的组件Repository、JdbcTemplate 等来说都是透明的。

了解了 AbstractRoutingDataSource 后我们来看看如何使用它来实现 “读写分离”。

二、实现思路

首先创建自己的 AbstractRoutingDataSource 实现类。把它的默认数据源 resolvedDefaultDataSource 设置为主库从库则保存到 Map<Object, DataSource> resolvedDataSources 中。

在 Spring Boot 应用中通常使用 @Transactional 注解来开启声明式事务它的默认传播级别为 REQUIRED也就是保证多个事务方法之间的相互调用都是在同一个事务中使用的是同一个 Jdbc 连接。它还有一个 readOnly 属性表示是否是只读事务。

于是我们可以通过 AOP 技术在事务方法执行之前先获取到方法上的 @Transactional 注解从而判断是读、还是写业务。并且把 “读写状态” 存储到线程上下文ThreadLocal中

在 AbstractRoutingDataSource 的 determineCurrentLookupKey 方法中我们就可以根据当前线程上下文中的 “读写状态” 判断当前是否是只读业务如果是则返回从库 resolvedDataSources 中的 Key反之则返回 null 表示使用默认数据源也就是主库。

三、初始化数据库

首先在本地创建 4 个不同名称的数据库用于模拟 “MYSQL 主从” 架构。

-- 主库
CREATE DATABASE `demo_master` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci';
-- 从库
CREATE DATABASE `demo_slave1` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci';
-- 从库
CREATE DATABASE `demo_slave2` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci';
-- 从库
CREATE DATABASE `demo_slave3` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci';

如上创建了 4 个数据库。1 个主库3 个从库。它们本质上毫无关系并不是真正意义上的主从架构这里只是为了方便演示。

接着在这 4 个数据库下依次执行如下 SQL 创建一张名为 test 的表。

该表只有 2 个字段1 个是 id 表示主键一个是 name 表示名称。

CREATE TABLE `test` (
  `id` int NOT NULL COMMENT 'ID',
  `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '名称',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

最后初始化数据。往不同的数据库插入对应的记录。

INSERT INTO `demo_master`.`test` (`id`, `name`) VALUES (1, 'master');
INSERT INTO `demo_slave1`.`test` (`id`, `name`) VALUES (1, 'slave1');
INSERT INTO `demo_slave2`.`test` (`id`, `name`) VALUES (1, 'slave2');
INSERT INTO `demo_slave3`.`test` (`id`, `name`) VALUES (1, 'slave3');

不同数据库节点下 test 表中的 name 字段不同用于区别不同的数据库节点。

四、创建应用

创建 Spring Boot 应用添加 spring-boot-starter-jdbc 和 mysql-connector-j MYSQL 驱动依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
</dependency>

五、配置定义

我们需要在 application.yaml 中定义上面创建好的所有主、从数据库。

app:
  datasource:
    master: # 唯一主库
      jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo_master?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true
      username: root
      password: root

    slave: # 多个从库
      slave1:
        jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo_slave1?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true
        username: root
        password: root
      
      slave2:
        jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo_slave2?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true
        username: root
        password: root
      
      slave3:
        jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo_slave3?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true
        username: root
        password: root

在 app.datasource.master 下配置了唯一的一个主库也就是写库。然后在 app.datasource.slave 下以 Map 形式配置了多个从库也就是读库每个从库使用自定义的名称作为 Key。

数据源的实现使用的是默认的 HikariDataSource并且数据源的配置是按照 HikariConfig 类定义的。也就是说你可以根据 HikariConfig 的属性在配置中添加额外的设置。

有了配置后还需要定义对应的配置类如下

package cn.springdoc.demo.db;

import java.util.Map;
import java.util.Objects;
import java.util.Properties;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;

@ConfigurationProperties(prefix = "app.datasource")  //  配置前缀
public class MasterSlaveDataSourceProperties {

    // 主库
    private final Properties master;

    // 从库
    private final Map<String, Properties> slave;

    @ConstructorBinding // 通过构造函数注入配置文件中的值
    public MasterSlaveDataSourceProperties(Properties master, Map<String, Properties> slave) {
        super();
        
        Objects.requireNonNull(master);
        Objects.requireNonNull(slave);
        
        this.master = master;
        this.slave = slave;
    }

    public Properties master() {
        return master;
    }

    public Map<String, Properties> slave() {
        return slave;
    }
}

还需要在 main 类上使用 @EnableConfigurationProperties 注解来加载我们的配置类

package cn.springdoc.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

import cn.springdoc.demo.db.MasterSlaveDataSourceProperties;

@SpringBootApplication
@EnableAspectJAutoProxy
@EnableConfigurationProperties(value = {MasterSlaveDataSourceProperties.class}) // 指定要加载的配置类
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

这里还使用 @EnableAspectJAutoProxy 开启了 AOP 的支持后面会用到。

六、创建 MasterSlaveDataSourceMarker

创建一个 MasterSlaveDataSourceMarker 类用于维护当前业务的 “读写状态”。

package cn.springdoc.demo.db;

public class MasterSlaveDataSourceMarker {

    private static final ThreadLocal<Boolean> flag = new ThreadLocal<Boolean>();

    // 返回标记
    public static Boolean get() {
        return flag.get();
    }

    // 写状态标记为主库
    public static void master() {
        flag.set(Boolean.TRUE);
    }

    // 读状态标记为从库
    public static void slave() {
        flag.set(Boolean.FALSE);
    }

    // 清空标记
    public static void clean() {
        flag.remove();
    }
}

通过 ThreadLocal<Boolean> 在当前线程中保存当前业务的读写状态。

如果 get() 返回 null 或者 true 则表示非只读需要使用主库。反之则表示只读业务使用从库。

七、创建 MasterSlaveDataSourceAop

创建 MasterSlaveDataSourceAop 切面类在事务方法开始之前执行。

package cn.springdoc.demo.db;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)  // 在事务开始之前执行
public class MasterSlaveDataSourceAop {

    static final Logger log = LoggerFactory.getLogger(MasterSlaveDataSourceAop.class);

    @Pointcut(value = "@annotation(org.springframework.transaction.annotation.Transactional)")
    public void txMethod () {}

    @Around("txMethod()")
    public Object handle (ProceedingJoinPoint joinPoint) throws Throwable {
        
        // 获取当前请求的主从标识
        try {
                
            // 获取事务方法上的注解
            Transactional transactional = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(Transactional.class);
            
            if (transactional != null && transactional.readOnly()) {
                log.info("标记为从库");
                MasterSlaveDataSourceMarker.slave();    // 只读从库
            } else {
                log.info("标记为主库");
                MasterSlaveDataSourceMarker.master(); // 可写主库
            }
            
            // 执行业务方法
            Object ret = joinPoint.proceed();
            
            return ret;
            
        } catch (Throwable e) {
            throw e;
        } finally {
            MasterSlaveDataSourceMarker.clean();
        }
    }
}

首先通过 @Order(Ordered.HIGHEST_PRECEDENCE) 注解保证它必须比声明式事务 AOP 更先执行。

该 AOP 会拦截所有声明了 @Transactional 的方法在执行前从该注解获取 readOnly 属性从而判断是否是只读业务并且在 MasterSlaveDataSourceMarker 标记。

八、创建 MasterSlaveDataSource

现在创建 AbstractRoutingDataSource 的实现类 MasterSlaveDataSource

package cn.springdoc.demo.db;

import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

public class MasterSlaveDataSource extends AbstractRoutingDataSource {

    static final Logger log = LoggerFactory.getLogger(MasterSlaveDataSource.class);

    // 从库的 Key 列表
    private List<Object> slaveKeys;

    // 从库 key 列表的索引
    private AtomicInteger index = new AtomicInteger(0);

    @Override
    protected Object determineCurrentLookupKey() {
        
        // 当前线程的主从标识
        Boolean master = MasterSlaveDataSourceMarker.get();
        
        if (master == null || master || this.slaveKeys.isEmpty()) {
            // 主库返回 null使用默认数据源
            log.info("数据库路由主库");
            return null;
        }
        
        // 从库从 slaveKeys 中选择一个 Key
        int index = this.index.getAndIncrement() % this.slaveKeys.size();

        if (this.index.get() > 9999999) {
            this.index.set(0); 
        }
        
        Object key = slaveKeys.get(index);
        
        log.info("数据库路由从库 = {}", key);
        
        return key;
    }


    public List<Object> getSlaveKeys() {
        return slaveKeys;
    }
    public void setSlaveKeys(List<Object> slaveKeys) {
        this.slaveKeys = slaveKeys;
    }
}

其中定义了一个 List<Object> slaveKeys 字段用于存储在配置文件中定义的所有从库的 Key。

在 determineCurrentLookupKey 方法中判断当前业务的 “读写状态”如果是只读则通过 AtomicInteger 原子类自增后从 slaveKeys 轮询出一个从库的 Key。反之则返回 null 使用主库。

九、创建 MasterSlaveDataSourceConfiguration 配置类

最后需要在 @Configuration 配置类中创建 MasterSlaveDataSource 数据源 Bean。

package cn.springdoc.demo.db;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

import javax.sql.DataSource;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

@Configuration
public class MasterSlaveDataSourceConfiguration {

    @Bean
    public DataSource dataSource(MasterSlaveDataSourceProperties properties) {

        MasterSlaveDataSource dataSource = new MasterSlaveDataSource();

        // 主数据库
        dataSource.setDefaultTargetDataSource(new HikariDataSource(new HikariConfig(properties.master())));

        // 从数据库
        Map<Object, Object> slaveDataSource = new HashMap<>();
        
        // 从数据库 Key
        dataSource.setSlaveKeys(new ArrayList<>());
        
        for (Map.Entry<String,Properties> entry : properties.slave().entrySet()) {
            
            if (slaveDataSource.containsKey(entry.getKey())) {
                throw new IllegalArgumentException("存在同名的从数据库定义" + entry.getKey());
            }
            
            slaveDataSource.put(entry.getKey(), new HikariDataSource(new HikariConfig(entry.getValue())));
            
            dataSource.getSlaveKeys().add(entry.getKey());
        }
        
        // 设置从库
        dataSource.setTargetDataSources(slaveDataSource);

        return dataSource;
    }
}

首先通过配置方法注入配置类该类定义了配置文件中的主库、从库属性。

使用 HikariDataSource 实例化唯一主库数据源、和多个从库数据源并且设置到 MasterSlaveDataSource 对应的属性中。

同时还存储每个从库的 Key且该 Key 不允许重复。

十、测试

1、创建 TestService

创建用于测试的业务类。

package cn.springdoc.demo.service;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TestService {
    final JdbcTemplate jdbcTemplate;
    public TestService(JdbcTemplate jdbcTemplate) {
        super();
        this.jdbcTemplate = jdbcTemplate;
    }

    // 只读
    @Transactional(readOnly = true)
    public String read () {
        return this.jdbcTemplate.queryForObject("SELECT `name` FROM `test` WHERE id = 1;", String.class);
    } 


    // 先读再写
    @Transactional
    public String write () {
        this.jdbcTemplate.update("UPDATE `test` SET `name` = ? WHERE id = 1;", "new name");
        return this.read();
    }
}

通过构造函数注入 JdbcTemplatespring jdbc 模块自动配置的。

Service 类中定义了 2 个方法。

  • read()只读业务从表中检索 name 字段返回。
  • write可写业务先修改表中的 name 字段值为 new name然后再调用 read() 方法读取修改后的结果、返回。

2、创建测试类

创建测试类如下

package cn.springdoc.demo.test;

import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;

import cn.springdoc.demo.service.TestService;


@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class DemoApplicationTests {

    static final Logger log = LoggerFactory.getLogger(DemoApplicationTests.class);

    @Autowired
    TestService testService;

    @Test
    public void test() throws Exception {

        // 连续4次读
        log.info("read={}", this.testService.read());
        log.info("read={}", this.testService.read());
        log.info("read={}", this.testService.read());
        log.info("read={}", this.testService.read());

        // 写
        log.info("write={}", this.testService.write());
    }
}

在测试类方法中连续调用 4 次 TestService 的 read() 方法。由于这是一个只读方法按照我们的设定它会在 3 个从库之间轮询使用。由于我们故意把三个从库 test 表中 name 的字段值设置得不一样所以这里可以通过返回的结果看出来是否符合我们的预期。

最后调用了一次 write() 方法按照设定会路由到主库。先 UPDATE 修改数据再调用 read() 读取数据虽然 read() 设置了 @Transactional(readOnly = true)但因为入口方法是 write()所以 read() 还是会从主库读取数据默认的事务传播级别。

执行测试输出的日志如下

[           main] c.s.demo.db.MasterSlaveDataSourceAop     : 标记为从库
[           main] c.s.demo.db.MasterSlaveDataSource        : 数据库路由从库 = slave1
[           main] c.s.demo.test.DemoApplicationTests       : read=slave1
[           main] c.s.demo.db.MasterSlaveDataSourceAop     : 标记为从库
[           main] c.s.demo.db.MasterSlaveDataSource        : 数据库路由从库 = slave2
[           main] c.s.demo.test.DemoApplicationTests       : read=slave2
[           main] c.s.demo.db.MasterSlaveDataSourceAop     : 标记为从库
[           main] c.s.demo.db.MasterSlaveDataSource        : 数据库路由从库 = slave3
[           main] c.s.demo.test.DemoApplicationTests       : read=slave3
[           main] c.s.demo.db.MasterSlaveDataSourceAop     : 标记为从库
[           main] c.s.demo.db.MasterSlaveDataSource        : 数据库路由从库 = slave1
[           main] c.s.demo.test.DemoApplicationTests       : read=slave1
[           main] c.s.demo.db.MasterSlaveDataSourceAop     : 标记为主库
[           main] c.s.demo.db.MasterSlaveDataSource        : 数据库路由主库
[           main] c.s.demo.test.DemoApplicationTests       : write=new name

你可以看到对于只读业务。确实轮询了三个不同的从库符合预期。最后的 write() 方法也成功地路由到了主库执行了修改并且返回了修改后的结果。

十一总结

通过 AbstractRoutingDataSource 可以不使用任何第三方中间件就可以在 Spring Boot 中实现数据源 “读写分离”这种方式需要在每个业务方法上通过 @Transactional 注解明确定义是读还是写。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: Spring数据库