Spring Boot 配置主从数据库实现读写分离

一、前言

现在的 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 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 属性,维护了多个数据源。

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

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

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

二、实现思路

首先,创建自己的 AbstractRoutingDataSource 实现类。把它的默认数据源 resolvedDefaultDataSource 设置为主库,从库则保存到 Map 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 驱动)依赖:


    org.springframework.boot
    spring-boot-starter-jdbc


    com.mysql
    mysql-connector-j

五、配置定义

我们需要在 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 slave;

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

    public Properties master() {
        return master;
    }

    public Map 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 flag = new ThreadLocal();

    // 返回标记
    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 在当前线程中保存当前业务的读写状态。

如果 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 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 getSlaveKeys() {
        return slaveKeys;
    }
    public void setSlaveKeys(List slaveKeys) {
        this.slaveKeys = slaveKeys;
    }
}
 
  

其中,定义了一个 List 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 slaveDataSource = new HashMap<>();
        
        // 从数据库 Key
        dataSource.setSlaveKeys(new ArrayList<>());
        
        for (Map.Entry 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();
    }
}

通过构造函数注入 JdbcTemplate(spring 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 注解明确定义是读还是写。

你可能感兴趣的:(springboot,spring,boot,数据库,java)