在 Spring Boot 中配置主从数据库实现读写分离
前言
现在的 Web 应用大都是读多写少。除了缓存以外还可以通过数据库 “主从复制” 架构,把读请求路由到从数据库节点上,实现读写分离,从而大大提高应用的吞吐量。
通常,我们在 Spring Boot 中只会用到一个数据源,即通过 spring.datasource
进行配置。前文 《在 Spring Boot 中配置和使用多个数据源》 介绍了一种在 Spring Boot 中定义、使用多个数据源的方式。但是这种方式对于实现 “读写分离” 的场景不太适合。首先,多个数据源都是通过 @Bean
定义的,当需要新增额外的从数据库时需要改动代码,非常不够灵活。其次,在业务层中,如果需要根据读、写场景切换不同数据源的话只能手动进行。
对于 Spring Boot “读写分离” 架构下的的多数据源,我们需要实现如下需求:
- 可以通过配置文件新增数据库(从库),而不不需要修改代码。
- 自动根据场景切换读、写数据源,对业务层是透明的。
幸运的是,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 不允许重复。
测试
创建 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()
方法读取修改后的结果、返回。
创建测试类
创建测试类,如下:
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
注解明确定义是读还是写。